Skip to content

Commit

Permalink
feat(select): Auto-Position Menu on open
Browse files Browse the repository at this point in the history
This PR completes the single-option dropdown component.

Additional work:

* Ensured all attached DOM nodes are cleaned up in simple menu tests

Part of #4475
[Delivers #129706423]
  • Loading branch information
traviskaufman committed Nov 29, 2016
1 parent 3500b21 commit 803d92c
Show file tree
Hide file tree
Showing 8 changed files with 324 additions and 22 deletions.
102 changes: 95 additions & 7 deletions packages/mdl-select/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
> **Status:**
> - [x] Pure CSS Select
> - [x] Initial Functionality / Styles for JS Select
> - [ ] Select Menu Auto-positioning
> - [x] Select Menu Auto-positioning
> - [ ] Multi-select
MDL Select provides a material design single-option select menu. It functions analogously to the
Expand Down Expand Up @@ -67,11 +67,63 @@ style dependencies for both the mdl-list and mdl-menu for this component to func

#### Select with pre-selected option

> TK
```html
<div class="mdl-select" role="listbox" tabindex="0">
<span class="mdl-select__selected-text">Vegetables</span>
<div class="mdl-simple-menu mdl-select__menu">
<ul class="mdl-list mdl-simple-menu__items">
<li class="mdl-list-item" role="option" id="grains" tabindex="0">
Bread, Cereal, Rice, and Pasta
</li>
<li class="mdl-list-item" role="option" aria-selected id="vegetables" tabindex="0">
Vegetables
</li>
<li class="mdl-list-item" role="option" id="fruit" tabindex="0">
Fruit
</li>
<li class="mdl-list-item" role="option" id="dairy" tabindex="0">
Milk, Yogurt, and Cheese
</li>
<li class="mdl-list-item" role="option" id="meat" tabindex="0">
Meat, Poultry, Fish, Dry Beans, Eggs, and Nuts
</li>
<li class="mdl-list-item" role="option" id="fats" tabindex="0">
Fats, Oils, and Sweets
</li>
</ul>
</div>
</div>
```

#### Disabled select

> TK
```html
<div class="mdl-select mdl-select--disabled" role="listbox" aria-disabled="true" tabindex="-1">
<span class="mdl-select__selected-text">Pick a food group</span>
<div class="mdl-simple-menu mdl-select__menu">
<ul class="mdl-list mdl-simple-menu__items">
<li class="mdl-list-item" role="option" id="grains" tabindex="0">
Bread, Cereal, Rice, and Pasta
</li>
<li class="mdl-list-item" role="option" id="vegetables" tabindex="0">
Vegetables
</li>
<li class="mdl-list-item" role="option" id="fruit" tabindex="0">
Fruit
</li>
<li class="mdl-list-item" role="option" id="dairy" tabindex="0">
Milk, Yogurt, and Cheese
</li>
<li class="mdl-list-item" role="option" id="meat" tabindex="0">
Meat, Poultry, Fish, Dry Beans, Eggs, and Nuts
</li>
<li class="mdl-list-item" role="option" id="fats" tabindex="0">
Fats, Oils, and Sweets
</li>
</ul>
</div>
</div>
```

### Using the Pure CSS Select

Expand Down Expand Up @@ -117,6 +169,26 @@ is outlined below.
The MDL Select JS component emits an `MDLSelect:change` event when the selected option changes as
the result of a user action.

#### Instantiating using a custom `MDLSimpleMenu` component.

`MDLSelect` controls an [MDLSimpleMenu](../mdl-menu) instance under the hood in order to display
its options. If you'd like to instantiate a custom menu instance, you can provide an optional 3rd
`menuFactory` argument to `MDLSelect`'s constructor.

```js
const menuFactory = menuEl => {
const menu = new MDLSimpleMenu(menuEl);
// Do stuff with menu...
return menu;
};
const selectEl = document.querySelector('.mdl-select');
const select = new MDLSelect(selectEl, /* foundation */ undefined, menuFactory);
```

The `menuFactory` function is passed an `HTMLElement` and is expected to return an `MDLSimpleMenu`
instance attached to that element. This is mostly used for testing purposes, but it's there if you
need it nonetheless.

## Using the foundation class

MDL Select ships with a foundation class that framework authors can use to integrate MDL Select
Expand All @@ -126,9 +198,17 @@ to use via GH Issues or on Gitter if they run into problems.

### Notes for component implementors

The `MDLSelectFoundation` expects that the select component _controls an instance of
`MDLSimpleMenu`_. We achieve this via composition in our vanilla `MDLSelect` component, and
recommend a similar approach for framework authors.
The `MDLSelectFoundation` expects that the select component conforms to the following two requirements:

1. The component owns an element that's used as its select menu, e.g. its **menu element**.

2. The component controls an instance of `MDLSimpleMenu`, which is attached to its menu element.

We achieve this by accepting a `menuFactory` optional constructor parameter, which is a function
which is passed our menu element, and is expected to return an `MDLSimpleMenu` component instance.
If you are attempting to implement mdl-select for your framework, and you find that this approach
does not work for you, and there is no suitable way to satisfy the above two requirements, please
[open an issue](../../issues/new).

`MDLSelectFoundation` also has the ability to resize itself whenever its options change, via the
`resize()` method. We recommend calling this method on initialization, or when the menu items are
Expand All @@ -141,8 +221,9 @@ within `componentDidUpdate`.
| --- | --- |
| `addClass(className: string) => void` | Adds a class to the root element. |
| `removeClass(className: string) => void` | Removes a class from the root element. |
| `setAttr(attr: string, value: string) => void` | Sets attribute `name` to value `value` on the root element. |
| `setAttr(attr: string, value: string) => void` | Sets attribute `attr` to value `value` on the root element. |
| `rmAttr(attr: string) => void` | Removes attribute `attr` from the root element. |
| `computeBoundingRect() => {left: number, top: number}` | Returns an object with a shape similar to a `ClientRect` object, with a `left` and `top` property specifying the element's position on the page relative to the viewport. The easiest way to achieve this is by calling `getBoundingClientRect()` on the root element. |
| `registerInteractionHandler(type: string, handler: EventListener) => void` | Adds an event listener `handler` for event type `type` on the root element. |
| `deregisterInteractionHandler(type: string, handler: EventListener) => void` | Removes an event listener `handler` for event type `type` on the root element. |
| `focus() => void` | Focuses the root element |
Expand All @@ -151,14 +232,21 @@ within `componentDidUpdate`.
| `getComputedStyleValue(propertyName: string) => string` | Get the root element's computed style value of the given dasherized css property `propertyName`. We achieve this via `getComputedStyle(...).getPropertyValue(propertyName). `|
| `setStyle(propertyName: string, value: string) => void` | Sets a dasherized css property `propertyName` to the value `value` on the root element. We achieve this via `root.style.setProperty(propertyName, value)`. |
| `create2dRenderingContext() => {font: string, measureText: (string) => {width: number}}` | Returns an object which has the shape of a CanvasRenderingContext2d instance. Namely, it has a string property `font` which is writable, and a method `measureText` which given a string of text returns an object containing a `width` property specifying how wide that text should be rendered in the `font` specified by the font property. An easy way to achieve this is simply `document.createElement('canvas').getContext('2d');`. |
| `setMenuElStyle(propertyName: string) => void` | Sets a dasherized css property `propertyName` to the value `value` on the menu element. |
| `setMenuElAttr(attr: string, value: string) => void` | Sets attribute `attr` to value `value` on the menu element. |
| `rmMenuElAttr(attr: string) => void` | Removes attribute `attr` from the menu element. |
| `getMenuElOffsetHeight() => number` | Returns the `offsetHeight` of the menu element. |
| `getOffsetTopForOptionAtIndex(index: number) => number` | Returns the `offsetTop` of the option element at the specified index. The index is guaranteed to be in bounds. |
| `openMenu(focusIndex: string) => void` | Opens the select's menu with focus on the option at the given `focusIndex`. The focusIndex is guaranteed to be in bounds. |
| `isMenuOpen() => boolean` | Returns true if the menu is open, false otherwise. |
| `setSelectedTextContent(selectedTextContent: string) => void` | Sets the text content of the `.mdl-select__selected-text` element to `selectedTextContent`. |
| `getNumberOfOptions() => number` | Returns the number of options contained in the select's menu. |
| `getTextForOptionAtIndex(index: number) => string` | Returns the text content for the option at the specified index within the select's menu. |
| `setAttrForOptionAtIndex(index: number, attr: string, value: string) => void` | Sets an attribute `attr` to value `value` for the option at the specified index within the select's menu. |
| `rmAttrForOptionAtIndex(index: number, attr: string) => void` | Removes an attribute `attr` for the option at the specified index within the select's menu. |
| `registerMenuInteractionHandler(type: string, handler: EventListener) => void` | Registers an event listener on the menu component's root element. Note that we will always listen for `MDLSimpleMenu:selected` for change events, and `MDLSimpleMenu:cancel` to know that we need to close the menu. If you are using a different events system, you could check the event type for either one of these strings and take the necessary steps to wire it up. |
| `deregisterMenuInteractionHandler(type: string, handler: EventListener) => void` | Opposite of `registerMenuInteractionHandler`. |
| `getWindowInnerHeight() => number` | Returns the `innerHeight` property of the `window` element. |

### The full foundation API

Expand Down
47 changes: 45 additions & 2 deletions packages/mdl-select/foundation.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default class MDLSelectFoundation extends MDLFoundation {
removeClass: (/* className: string */) => {},
setAttr: (/* attr: string, value: string */) => {},
rmAttr: (/* attr: string */) => {},
computeBoundingRect: () => /* {left: number, top: number} */ ({left: 0, top: 0}),
registerInteractionHandler: (/* type: string, handler: EventListener */) => {},
deregisterInteractionHandler: (/* type: string, handler: EventListener */) => {},
focus: () => {},
Expand All @@ -49,15 +50,22 @@ export default class MDLSelectFoundation extends MDLFoundation {
font: '',
measureText: () => ({width: 0})
}),
setMenuElStyle: (/* propertyName: string, value: string */) => {},
setMenuElAttr: (/* attr: string, value: string */) => {},
rmMenuElAttr: (/* attr: string */) => {},
getMenuElOffsetHeight: () => /* number */ 0,
openMenu: (/* focusIndex: number */) => {},
isMenuOpen: () => /* boolean */ false,
setSelectedTextContent: (/* textContent: string */) => {},
getNumberOfOptions: () => /* number */ 0,
getTextForOptionAtIndex: (/* index: number */) => /* string */ '',
setAttrForOptionAtIndex: (/* index: number, attr: string, value: string */) => {},
rmAttrForOptionAtIndex: (/* index: number, attr: string */) => {},
getOffsetTopForOptionAtIndex: (/* index: number */) => /* number */ 0,
registerMenuInteractionHandler: (/* type: string, handler: EventListener */) => {},
deregisterMenuInteractionHandler: (/* type: string, handler: EventListener */) => {},
notifyChange: () => {}
notifyChange: () => {},
getWindowInnerHeight: () => /* number */ 0
};
}

Expand All @@ -68,7 +76,9 @@ export default class MDLSelectFoundation extends MDLFoundation {
this.disabled_ = false;
this.displayHandler_ = evt => {
evt.preventDefault();
this.open_();
if (!this.adapter_.isMenuOpen()) {
this.open_();
}
};
this.displayViaKeyboardHandler_ = evt => this.handleDisplayViaKeyboard_(evt);
this.selectionHandler_ = ({detail}) => {
Expand Down Expand Up @@ -165,10 +175,43 @@ export default class MDLSelectFoundation extends MDLFoundation {
open_() {
const {OPEN} = MDLSelectFoundation.cssClasses;
const focusIndex = this.selectedIndex_ < 0 ? 0 : this.selectedIndex_;
const {left, top, transformOrigin} = this.computeMenuStylesForOpenAtIndex_(focusIndex);

this.adapter_.setMenuElStyle('left', left);
this.adapter_.setMenuElStyle('top', top);
this.adapter_.setMenuElStyle('transform-origin', transformOrigin);
this.adapter_.addClass(OPEN);
this.adapter_.openMenu(focusIndex);
}

computeMenuStylesForOpenAtIndex_(index) {
const innerHeight = this.adapter_.getWindowInnerHeight();
const {left, top} = this.adapter_.computeBoundingRect();

this.adapter_.setMenuElAttr('aria-hidden', 'true');
this.adapter_.setMenuElStyle('display', 'block');
const menuHeight = this.adapter_.getMenuElOffsetHeight();
const itemOffsetTop = this.adapter_.getOffsetTopForOptionAtIndex(index);
this.adapter_.setMenuElStyle('display', '');
this.adapter_.rmMenuElAttr('aria-hidden');

let adjustedTop = top - itemOffsetTop;
const adjustedHeight = menuHeight - itemOffsetTop;
const overflowsTop = adjustedTop < 0;
const overflowsBottom = adjustedTop + adjustedHeight > innerHeight;
if (overflowsTop) {
adjustedTop = 0;
} else if (overflowsBottom) {
adjustedTop = Math.max(0, adjustedTop - adjustedHeight);
}

return {
left: `${left}px`,
top: `${adjustedTop}px`,
transformOrigin: `center ${itemOffsetTop}px`
};
}

close_() {
const {OPEN} = MDLSelectFoundation.cssClasses;
this.adapter_.removeClass(OPEN);
Expand Down
15 changes: 12 additions & 3 deletions packages/mdl-select/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,9 @@ export class MDLSelect extends MDLComponent {
return null;
}

initialize(menu = null) {
this.menu_ = menu ? menu : new MDLSimpleMenu(this.root_.querySelector('.mdl-select__menu'));
initialize(menuFactory = el => new MDLSimpleMenu(el)) {
this.menuEl_ = this.root_.querySelector('.mdl-select__menu');
this.menu_ = menuFactory(this.menuEl_);
this.selectedText_ = this.root_.querySelector('.mdl-select__selected-text');
}

Expand All @@ -75,6 +76,7 @@ export class MDLSelect extends MDLComponent {
removeClass: className => this.root_.classList.remove(className),
setAttr: (attr, value) => this.root_.setAttribute(attr, value),
rmAttr: (attr, value) => this.root_.removeAttribute(attr, value),
computeBoundingRect: () => this.root_.getBoundingClientRect(),
registerInteractionHandler: (type, handler) => this.root_.addEventListener(type, handler),
deregisterInteractionHandler: (type, handler) => this.root_.removeEventListener(type, handler),
focus: () => this.root_.focus(),
Expand All @@ -87,17 +89,24 @@ export class MDLSelect extends MDLComponent {
getComputedStyleValue: prop => window.getComputedStyle(this.root_).getPropertyValue(prop),
setStyle: (propertyName, value) => this.root_.style.setProperty(propertyName, value),
create2dRenderingContext: () => document.createElement('canvas').getContext('2d'),
setMenuElStyle: (propertyName, value) => this.menuEl_.style.setProperty(propertyName, value),
setMenuElAttr: (attr, value) => this.menuEl_.setAttribute(attr, value),
rmMenuElAttr: attr => this.menuEl_.removeAttribute(attr),
getMenuElOffsetHeight: () => this.menuEl_.offsetHeight,
openMenu: focusIndex => this.menu_.show({focusIndex}),
isMenuOpen: () => this.menu_.open,
setSelectedTextContent: selectedTextContent => {
this.selectedText_.textContent = selectedTextContent;
},
getNumberOfOptions: () => this.options.length,
getTextForOptionAtIndex: index => this.options[index].textContent,
setAttrForOptionAtIndex: (index, attr, value) => this.options[index].setAttribute(attr, value),
rmAttrForOptionAtIndex: (index, attr) => this.options[index].removeAttribute(attr),
getOffsetTopForOptionAtIndex: index => this.options[index].offsetTop,
registerMenuInteractionHandler: (type, handler) => this.menu_.listen(type, handler),
deregisterMenuInteractionHandler: (type, handler) => this.menu_.unlisten(type, handler),
notifyChange: () => this.emit('MDLSelect:change', this)
notifyChange: () => this.emit('MDLSelect:change', this),
getWindowInnerHeight: () => window.innerHeight
});
}

Expand Down
3 changes: 2 additions & 1 deletion packages/mdl-select/mdl-select.scss
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,11 @@

&__menu {
position: fixed;
// TODO: https://www.pivotaltracker.com/story/show/129706423
top: 0;
left: 0;
max-height: 100%;
transform-origin: center center;
overflow-y: scroll;
}

&__selected-text {
Expand Down
13 changes: 13 additions & 0 deletions test/unit/mdl-menu/mdl-simple-menu.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ test('adapter#registerDocumentClickHandler proxies to addEventListener', t => {
component.getDefaultFoundation().adapter_.registerDocumentClickHandler(handler);
domEvents.emit(document, 'click');
t.doesNotThrow(() => td.verify(handler(td.matchers.anything())));
document.body.removeChild(root);
t.end();
});

Expand All @@ -172,6 +173,7 @@ test('adapter#deregisterDocumentClickHandler proxies to removeEventListener', t
component.getDefaultFoundation().adapter_.deregisterInteractionHandler(handler);
domEvents.emit(document, 'click');
t.doesNotThrow(() => td.verify(handler(td.matchers.anything()), {times: 0}));
document.body.removeChild(root);
t.end();
});

Expand Down Expand Up @@ -245,6 +247,8 @@ test('adapter#restoreFocus restores the focus saved by adapter#saveFocus', t =>
root.focus();
component.getDefaultFoundation().adapter_.restoreFocus();
t.equal(document.activeElement, button);
document.body.removeChild(button);
document.body.removeChild(root);
t.end();
});

Expand All @@ -253,6 +257,7 @@ test('adapter#isFocused returns whether the menu is focused', t => {
document.body.appendChild(root);
root.focus();
t.true(component.getDefaultFoundation().adapter_.isFocused());
document.body.removeChild(root);
t.end();
});

Expand All @@ -261,6 +266,7 @@ test('adapter#focus focuses the menu', t => {
document.body.appendChild(root);
component.getDefaultFoundation().adapter_.focus();
t.equal(document.activeElement, root);
document.body.removeChild(root);
t.end();
});

Expand All @@ -272,6 +278,7 @@ test('adapter#getFocusedItemIndex returns the item index of the focused menu ele
t.equal(component.getDefaultFoundation().adapter_.getFocusedItemIndex(), 1);
root.focus();
t.equal(component.getDefaultFoundation().adapter_.getFocusedItemIndex(), -1, 'missing index = -1');
document.body.removeChild(root);
t.end();
});

Expand All @@ -284,6 +291,7 @@ test('adapter#focusItemAtIndex focuses the right menu element', t => {
t.equal(document.activeElement, item1);
component.getDefaultFoundation().adapter_.focusItemAtIndex(0);
t.equal(document.activeElement, item2);
document.body.removeChild(root);
t.end();
});

Expand All @@ -310,6 +318,7 @@ test('adapter#getAnchorDimensions returns the dimensions of the anchor container
document.body.appendChild(anchor);
t.equal(component.getDefaultFoundation().adapter_.getAnchorDimensions().height, 21);
t.equal(component.getDefaultFoundation().adapter_.getAnchorDimensions().width, 42);
document.body.removeChild(anchor);
t.end();
});

Expand All @@ -318,6 +327,7 @@ test('adapter#getWindowDimensions returns the dimensions of the window', t => {
document.body.appendChild(root);
t.equal(component.getDefaultFoundation().adapter_.getWindowDimensions().height, window.innerHeight);
t.equal(component.getDefaultFoundation().adapter_.getWindowDimensions().width, window.innerWidth);
document.body.removeChild(root);
t.end();
});

Expand All @@ -327,6 +337,7 @@ test('adapter#isRtl returns true for RTL documents', t => {
anchor.appendChild(root);
document.body.appendChild(anchor);
t.true(component.getDefaultFoundation().adapter_.isRtl());
document.body.removeChild(anchor);
t.end();
});

Expand All @@ -336,6 +347,7 @@ test('adapter#isRtl returns false for explicit LTR documents', t => {
anchor.appendChild(root);
document.body.appendChild(anchor);
t.false(component.getDefaultFoundation().adapter_.isRtl());
document.body.removeChild(anchor);
t.end();
});

Expand All @@ -345,6 +357,7 @@ test('adapter#isRtl returns false for implicit LTR documents', t => {
anchor.appendChild(root);
document.body.appendChild(anchor);
t.false(component.getDefaultFoundation().adapter_.isRtl());
document.body.removeChild(anchor);
t.end();
});

Expand Down
Loading

0 comments on commit 803d92c

Please sign in to comment.