Skip to content
This repository has been archived by the owner on Jan 13, 2025. It is now read-only.

Commit

Permalink
fix(list): Follow hrefs on keypresses on links (#3407)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: Adds a followHref adapter API.
  • Loading branch information
kfranqueiro authored Aug 24, 2018
1 parent e21eee3 commit e6d6deb
Show file tree
Hide file tree
Showing 6 changed files with 66 additions and 31 deletions.
33 changes: 17 additions & 16 deletions packages/mdc-list/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ npm install @material/list

### Two-Line List

You can use the `mdc-list--two-line` combined with some extra markup around the text to style a list
in the double line list style as defined by
You can use the `mdc-list--two-line` combined with some extra markup around the text to style a list
in the double line list style as defined by
[the spec](https://material.io/design/components/lists.html#specs) (see "Double line").

```html
Expand Down Expand Up @@ -133,8 +133,8 @@ OR

### Single Selection List

MDC List can handle selecting/deselecting list elements based on click or keyboard action. When enabled, the `space` and `enter` keys (or `click` event) will trigger an
single list item to become selected and any other previous selected element to become deselected.
MDC List can handle selecting/deselecting list elements based on click or keyboard action. When enabled, the `space` and `enter` keys (or `click` event) will trigger an
single list item to become selected and any other previous selected element to become deselected.

```html
<ul id="my-list" class="mdc-list" aria-orientation="vertical">
Expand Down Expand Up @@ -223,7 +223,7 @@ The MDCList JavaScript component implements the WAI-ARIA best practices for
within the list component. You should not add `tabindex` to any of the `li` elements in a list.

As the user navigates through the list, any `button` or `a` elements within the list will receive `tabindex="-1"`
when the list item is not focused. When the list item receives focus, the child `button` and `a` elements will
when the list item is not focused. When the list item receives focus, the child `button` and `a` elements will
receive `tabIndex="0"`. This allows for the user to tab through list item elements and then tab to the
first element after the list. The `Arrow`, `Home`, and `End` keys should be used for navigating internal list elements.
If `singleSelection=true`, the list will allow the user to use the `Space` or `Enter` keys to select or deselect
Expand Down Expand Up @@ -254,7 +254,7 @@ The default component requires that every list item receives a `tabindex` value
(`li` elements cannot receive focus at all without a `tabindex` value). Any element not already containing a
`tabindex` attribute will receive `tabindex=-1`. The first list item should have `tabindex="0"` so that the
user can find the first element using the `tab` key, but subsequent `tab` keys strokes will cause focus to
skip over the entire list. If the list items contain sub-elements that are focusable (`button` or `a` elements),
skip over the entire list. If the list items contain sub-elements that are focusable (`button` or `a` elements),
these should also receive `tabIndex="-1"`.

```html
Expand All @@ -268,9 +268,9 @@ these should also receive `tabIndex="-1"`.
#### Setup in `singleSelection()`

When implementing a component that will use the single selection variant, the HTML should be modified to include
the `aria-selected` attribute, the `mdc-list-item--selected` or `mdc-list-item--activated` class should be added,
and the `tabindex` of the selected element should be `0`. The first list item should have the `tabindex` updated
to `-1`. The foundation method `setSelectedIndex()` should be called with the initially selected element immediately
the `aria-selected` attribute, the `mdc-list-item--selected` or `mdc-list-item--activated` class should be added,
and the `tabindex` of the selected element should be `0`. The first list item should have the `tabindex` updated
to `-1`. The foundation method `setSelectedIndex()` should be called with the initially selected element immediately
after the foundation is instantiated.

```html
Expand All @@ -280,7 +280,7 @@ after the foundation is instantiated.
<li class="mdc-list-item" tabindex="-1">Single-line item</li>
</ul>
```

### `MDCListAdapter`

Method Signature | Description
Expand All @@ -293,19 +293,20 @@ Method Signature | Description
`removeClassForElementIndex(index: Number, className: String) => void` | Removes the `className` class to the list item at `index`.
`focusItemAtIndex(index: Number) => void` | Focuses the list item at the `index` value specified.
`setTabIndexForListItemChildren(index: Number, value: Number) => void` | Sets the `tabindex` attribute to `value` for each child `button` and `a` element in the list item at the `index` specified.
`followHref(element: Element) => void` | If the given element has an href, follows the link.

### `MDCListFoundation`

Method Signature | Description
--- | ---
`setWrapFocus(value: Boolean) => void` | Sets the list to allow the up arrow on the first element to focus the last element of the list and vice versa.
`setVerticalOrientation(value: Boolean) => void` | Sets the list to an orientation causing the keys used for navigation to change. `true` results in the Up/Down arrow keys being used. `false` results in the Left/Right arrow keys being used.
`setSingleSelection(value: Boolean) => void` | Sets the list to be a selection list. Enables the `enter` and `space` keys for selecting/deselecting a list item.
`setSelectedIndex(index: Number) => void` | Toggles the `selected` state of the list item at index `index`.
`setWrapFocus(value: Boolean) => void` | Sets the list to allow the up arrow on the first element to focus the last element of the list and vice versa.
`setVerticalOrientation(value: Boolean) => void` | Sets the list to an orientation causing the keys used for navigation to change. `true` results in the Up/Down arrow keys being used. `false` results in the Left/Right arrow keys being used.
`setSingleSelection(value: Boolean) => void` | Sets the list to be a selection list. Enables the `enter` and `space` keys for selecting/deselecting a list item.
`setSelectedIndex(index: Number) => void` | Toggles the `selected` state of the list item at index `index`.
`setUseActivated(useActivated: boolean) => void` | Sets the selection logic to apply/remove the `mdc-list-item--activated` class.
`handleFocusIn(evt: Event) => void` | Handles the changing of `tabindex` to `0` for all `button` and `a` elements when a list item receives focus.
`handleFocusIn(evt: Event) => void` | Handles the changing of `tabindex` to `0` for all `button` and `a` elements when a list item receives focus.
`handleFocusOut(evt: Event) => void` | Handles the changing of `tabindex` to `-1` for all `button` and `a` elements when a list item loses focus.
`handleKeydown(evt: Event) => void` | Handles determining if a focus action should occur when a key event is triggered.
`handleKeydown(evt: Event) => void` | Handles determining if a focus action should occur when a key event is triggered.
`handleClick(evt: Event) => void` | Handles toggling the selected/deselected state for a list item when clicked. This method is only used by the single selection list.
`focusNextElement(index: Number) => void` | Handles focusing the next element using the current `index`.
`focusPrevElement(index: Number) => void` | Handles focusing the previous element using the current `index`.
Expand Down
6 changes: 6 additions & 0 deletions packages/mdc-list/adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ class MDCListAdapter {
* @param {number} tabIndexValue
*/
setTabIndexForListItemChildren(listItemIndex, tabIndexValue) {}

/**
* If the given element has an href, follows the link.
* @param {!Element} ele
*/
followHref(ele) {}
}

export default MDCListAdapter;
4 changes: 4 additions & 0 deletions packages/mdc-list/foundation.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class MDCListFoundation extends MDCFoundation {
focusItemAtIndex: () => {},
isListItem: () => {},
setTabIndexForListItemChildren: () => {},
followHref: () => {},
});
}

Expand Down Expand Up @@ -203,6 +204,9 @@ class MDCListFoundation extends MDCFoundation {
// Check if the space key was pressed on the list item or a child element.
if (this.adapter_.isListItem(evt.target)) {
this.setSelectedIndex(currentIndex);

// Explicitly activate links, since we're preventing default on Enter, and Space doesn't activate them
this.adapter_.followHref(evt.target);
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions packages/mdc-list/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,11 @@ class MDCList extends MDCComponent {
const listItemChildren = [].slice.call(element.querySelectorAll(strings.FOCUSABLE_CHILD_ELEMENTS));
listItemChildren.forEach((ele) => ele.setAttribute('tabindex', tabIndexValue));
},
followHref: (ele) => {
if (ele.href) {
ele.click();
}
},
})));
}
}
Expand Down
2 changes: 1 addition & 1 deletion test/unit/mdc-list/foundation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ test('defaultAdapter returns a complete adapter implementation', () => {
verifyDefaultAdapter(MDCListFoundation, [
'getListItemCount', 'getFocusedElementIndex', 'getListItemIndex', 'setAttributeForElementIndex',
'removeAttributeForElementIndex', 'addClassForElementIndex', 'removeClassForElementIndex',
'focusItemAtIndex', 'isListItem', 'setTabIndexForListItemChildren',
'focusItemAtIndex', 'isListItem', 'setTabIndexForListItemChildren', 'followHref',
]);
});

Expand Down
47 changes: 33 additions & 14 deletions test/unit/mdc-list/mdc-list.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,31 +102,31 @@ test('#initializeListType calls the foundation if the --activated class is prese
td.verify(mockFoundation.setSingleSelection(true), {times: 1});
});

test('#adapter.getListItemCount returns correct number of list items', () => {
test('adapter#getListItemCount returns correct number of list items', () => {
const {root, component} = setupTest();
document.body.appendChild(root);
const number = root.querySelectorAll('.mdc-list-item').length;
assert.equal(number, component.getDefaultFoundation().adapter_.getListItemCount());
document.body.removeChild(root);
});

test('#adapter.getFocusedElementIndex returns the index of the currently selected element', () => {
test('adapter#getFocusedElementIndex returns the index of the currently selected element', () => {
const {root, component} = setupTest();
document.body.appendChild(root);
root.querySelectorAll('.mdc-list-item')[0].focus();
assert.equal(0, component.getDefaultFoundation().adapter_.getFocusedElementIndex());
document.body.removeChild(root);
});

test('#adapter.getListItemIndex returns the index of the element specified', () => {
test('adapter#getListItemIndex returns the index of the element specified', () => {
const {root, component} = setupTest();
document.body.appendChild(root);
const selectedNode = root.querySelectorAll('.mdc-list-item')[1];
assert.equal(1, component.getDefaultFoundation().adapter_.getListItemIndex(selectedNode));
document.body.removeChild(root);
});

test('#adapter.setAttributeForElementIndex does nothing if the element at index does not exist', () => {
test('adapter#setAttributeForElementIndex does nothing if the element at index does not exist', () => {
const {root, component} = setupTest();
document.body.appendChild(root);
const func = () => {
Expand All @@ -136,7 +136,7 @@ test('#adapter.setAttributeForElementIndex does nothing if the element at index
document.body.removeChild(root);
});

test('#adapter.setAttributeForElementIndex sets the attribute for the list element at index specified', () => {
test('adapter#setAttributeForElementIndex sets the attribute for the list element at index specified', () => {
const {root, component} = setupTest();
document.body.appendChild(root);
const selectedNode = root.querySelectorAll('.mdc-list-item')[1];
Expand All @@ -145,7 +145,7 @@ test('#adapter.setAttributeForElementIndex sets the attribute for the list eleme
document.body.removeChild(root);
});

test('#adapter.removeAttributeForElementIndex does nothing if the element at index does not exist', () => {
test('adapter#removeAttributeForElementIndex does nothing if the element at index does not exist', () => {
const {root, component} = setupTest();
document.body.appendChild(root);
const func = () => {
Expand All @@ -155,7 +155,7 @@ test('#adapter.removeAttributeForElementIndex does nothing if the element at ind
document.body.removeChild(root);
});

test('#adapter.removeAttributeForElementIndex sets the attribute for the list element at index specified', () => {
test('adapter#removeAttributeForElementIndex sets the attribute for the list element at index specified', () => {
const {root, component} = setupTest();
document.body.appendChild(root);
const selectedNode = root.querySelectorAll('.mdc-list-item')[1];
Expand All @@ -165,7 +165,7 @@ test('#adapter.removeAttributeForElementIndex sets the attribute for the list el
document.body.removeChild(root);
});

test('#adapter.addClassForElementIndex does nothing if the element at index does not exist', () => {
test('adapter#addClassForElementIndex does nothing if the element at index does not exist', () => {
const {root, component} = setupTest();
document.body.appendChild(root);
const func = () => {
Expand All @@ -175,7 +175,7 @@ test('#adapter.addClassForElementIndex does nothing if the element at index does
document.body.removeChild(root);
});

test('#adapter.addClassForElementIndex adds the class to the list element at index specified', () => {
test('adapter#addClassForElementIndex adds the class to the list element at index specified', () => {
const {root, component} = setupTest();
document.body.appendChild(root);
const selectedNode = root.querySelectorAll('.mdc-list-item')[1];
Expand All @@ -184,7 +184,7 @@ test('#adapter.addClassForElementIndex adds the class to the list element at ind
document.body.removeChild(root);
});

test('#adapter.removeClassForElementIndex does nothing if the element at index does not exist', () => {
test('adapter#removeClassForElementIndex does nothing if the element at index does not exist', () => {
const {root, component} = setupTest();
document.body.appendChild(root);
const func = () => {
Expand All @@ -194,7 +194,7 @@ test('#adapter.removeClassForElementIndex does nothing if the element at index d
document.body.removeChild(root);
});

test('#adapter.removeClassForElementIndex removes the class from the list element at index specified', () => {
test('adapter#removeClassForElementIndex removes the class from the list element at index specified', () => {
const {root, component} = setupTest();
document.body.appendChild(root);
const selectedNode = root.querySelectorAll('.mdc-list-item')[1];
Expand All @@ -204,7 +204,7 @@ test('#adapter.removeClassForElementIndex removes the class from the list elemen
document.body.removeChild(root);
});

test('#adapter.focusItemAtIndex does not throw an error if element at index is undefined/null', () => {
test('adapter#focusItemAtIndex does not throw an error if element at index is undefined/null', () => {
const {root, component} = setupTest();
document.body.appendChild(root);
const func = () => {
Expand All @@ -214,7 +214,7 @@ test('#adapter.focusItemAtIndex does not throw an error if element at index is u
document.body.removeChild(root);
});

test('#adapter.focusItemAtIndex focuses the list item at the index specified', () => {
test('adapter#focusItemAtIndex focuses the list item at the index specified', () => {
const {root, component} = setupTest();
document.body.appendChild(root);
const items = root.querySelectorAll('.mdc-list-item');
Expand All @@ -236,7 +236,7 @@ test('adapter#isListItem returns false if the element is a not a list item', ()
assert.isFalse(component.getDefaultFoundation().adapter_.isListItem(item1));
});

test('#adapter.setTabIndexForListItemChildren sets the child button/a elements of index', () => {
test('adapter#setTabIndexForListItemChildren sets the child button/a elements of index', () => {
const {root, component} = setupTest();
document.body.appendChild(root);
const listItemIndex = 1;
Expand All @@ -248,6 +248,25 @@ test('#adapter.setTabIndexForListItemChildren sets the child button/a elements o
document.body.removeChild(root);
});

test('adapter#followHref invokes click on element with href', () => {
const fakeElement = {
click: td.func('click'),
href: '#',
};
const {component} = setupTest();
component.getDefaultFoundation().adapter_.followHref(fakeElement);

td.verify(fakeElement.click());
});

test('adapter#followHref does not invoke click on element without href', () => {
const fakeElement = {click: td.func('click')};
const {component} = setupTest();
component.getDefaultFoundation().adapter_.followHref(fakeElement);

td.verify(fakeElement.click(), {times: 0});
});

test('layout adds tabindex=-1 to all list items without a tabindex', () => {
const {root} = setupTest();
assert.equal(0, root.querySelectorAll('.mdc-list-item:not([tabindex])').length);
Expand Down

0 comments on commit e6d6deb

Please sign in to comment.