Bug 1518487 - implement simple accessible list component similar to the one used in debugger.html and memory panel. r=nchevobbe

MozReview-Commit-ID: 2KCXrB9zCr0

Differential Revision: https://phabricator.services.mozilla.com/D19052

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Yura Zenevich 2019-02-11 20:46:13 +00:00
Родитель 162e11d369
Коммит 2a58419670
6 изменённых файлов: 833 добавлений и 0 удалений

Просмотреть файл

@ -0,0 +1,41 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* List */
.list {
background-color: var(--theme-sidebar-background);
list-style-type: none;
padding: 0;
margin: 0;
width: 100%;
white-space: nowrap;
overflow: auto;
}
.list:focus, .list .list-item-content:focus {
outline: 0;
}
.list::-moz-focus-inner, .list .list-item-content::-moz-focus-inner {
border: 0;
}
.list li.current {
background-color: var(--theme-toolbar-hover);
}
.list:focus li.current, .list li.active.current {
background-color: var(--theme-emphasized-splitter-color);
}
.list:focus li:not(.current):hover,
.list:not(:focus) li:not(.active):hover {
background-color: var(--theme-selection-background-hover);
}
.list .list-item-content:not(:empty) {
font-size: 12px;
overflow: auto;
}

Просмотреть файл

@ -0,0 +1,378 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
"use strict";
const {
createFactory,
createRef,
Component,
cloneElement,
} = require("devtools/client/shared/vendor/react");
const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
const { ul, li, div } = require("devtools/client/shared/vendor/react-dom-factories");
const { scrollIntoView } = require("devtools/client/shared/scroll");
loader.lazyRequireGetter(this, "focusableSelector", "devtools/client/shared/focus", true);
class ListItemClass extends Component {
static get propTypes() {
return {
active: PropTypes.bool,
current: PropTypes.bool,
onClick: PropTypes.func,
item: PropTypes.shape({
component: PropTypes.object,
componentProps: PropTypes.object,
className: PropTypes.string,
}).isRequired,
};
}
constructor(props) {
super(props);
this.contentRef = createRef();
this._setTabbableState = this._setTabbableState.bind(this);
this._onKeyDown = this._onKeyDown.bind(this);
this._wrapMoveFocus = this._wrapMoveFocus.bind(this);
}
componentDidMount() {
this._setTabbableState();
}
componentDidUpdate() {
this._setTabbableState();
}
/**
* Get a list of all elements that are focusable with a keyboard inside the list item.
*/
getFocusableElements() {
return Array.from(this.contentRef.current.querySelectorAll(focusableSelector));
}
/**
* Wrap and move keyboard focus to first/last focusable element inside the list item to
* prevent the focus from escaping the list item container.
* element).
*
* @param {DOMNode} current currently focused element
* @param {Boolean} back direction
* @return {Boolean} true there is a newly focused element.
*/
_wrapMoveFocus(current, back) {
const elms = this.getFocusableElements();
let next;
if (elms.length === 0) {
return false;
}
if (back) {
if (elms.indexOf(current) === 0) {
next = elms[elms.length - 1];
next.focus();
}
} else if (elms.indexOf(current) === elms.length - 1) {
next = elms[0];
next.focus();
}
return !!next;
}
_onKeyDown(event) {
const { target, key, shiftKey } = event;
if (key !== "Tab") {
return;
}
const focusMoved = this._wrapMoveFocus(target, shiftKey);
if (focusMoved) {
// Focus was moved to the begining/end of the list, so we need to prevent the
// default focus change that would happen here.
event.preventDefault();
}
event.stopPropagation();
}
/**
* Makes sure that none of the focusable elements inside the list item container are
* tabbable if the list item is not active. If the list item is active and focus is
* outside its container, focus on the first focusable element inside.
*/
_setTabbableState() {
const elms = this.getFocusableElements();
if (elms.length === 0) {
return;
}
if (!this.props.active) {
elms.forEach(elm => elm.setAttribute("tabindex", "-1"));
return;
}
if (!elms.includes(document.activeElement)) {
elms[0].focus();
}
}
render() {
const { active, item, current, onClick } = this.props;
const { className, component, componentProps } = item;
return (
li({
className: `${className}${current ? " current" : ""}${active ? " active" : ""}`,
id: item.key,
onClick,
onKeyDownCapture: active && this._onKeyDown,
},
div({
className: "list-item-content",
role: "presentation",
ref: this.contentRef,
}, cloneElement(component, componentProps || {}))
)
);
}
}
const ListItem = createFactory(ListItemClass);
class List extends Component {
static get propTypes() {
return {
// A list of all items to be rendered using a List component.
items: PropTypes.arrayOf(PropTypes.shape({
component: PropTypes.object,
componentProps: PropTypes.object,
className: PropTypes.string,
key: PropTypes.string.isRequired,
})).isRequired,
// Note: the two properties below are mutually exclusive. Only one of the
// label properties is necessary.
// ID of an element whose textual content serves as an accessible label for
// a list.
labelledBy: PropTypes.string,
// Accessibility label for a list widget.
label: PropTypes.string,
};
}
constructor(props) {
super(props);
this.listRef = createRef();
this.state = {
active: null,
current: null,
mouseDown: false,
};
this._setCurrentItem = this._setCurrentItem.bind(this);
this._preventArrowKeyScrolling = this._preventArrowKeyScrolling.bind(this);
this._preventDefaultAndStopPropagation =
this._preventDefaultAndStopPropagation.bind(this);
this._onKeyDown = this._onKeyDown.bind(this);
}
shouldComponentUpdate(nextProps, nextState) {
const { active, current, mouseDown } = this.state;
return current !== nextState.current ||
active !== nextState.active ||
mouseDown === nextState.mouseDown;
}
_preventArrowKeyScrolling(e) {
switch (e.key) {
case "ArrowUp":
case "ArrowDown":
case "ArrowLeft":
case "ArrowRight":
this._preventDefaultAndStopPropagation(e);
break;
}
}
_preventDefaultAndStopPropagation(e) {
e.preventDefault();
e.stopPropagation();
if (e.nativeEvent) {
if (e.nativeEvent.preventDefault) {
e.nativeEvent.preventDefault();
}
if (e.nativeEvent.stopPropagation) {
e.nativeEvent.stopPropagation();
}
}
}
/**
* Sets the passed in item to be the current item.
*
* @param {null|Number} index
* The index of the item in to be set as current, or undefined to unset the
* current item.
*/
_setCurrentItem(index = -1, options = {}) {
const item = this.props.items[index];
if (item !== undefined && !options.preventAutoScroll) {
const element = document.getElementById(item.key);
scrollIntoView(element, {
...options,
container: this.listRef.current,
});
}
const state = {};
if (this.state.active != undefined) {
state.active = null;
if (this.listRef.current !== document.activeElement) {
this.listRef.current.focus();
}
}
if (this.state.current !== index) {
this.setState({
...state,
current: index,
});
}
}
/**
* Handles key down events in the list's container.
*
* @param {Event} e
*/
_onKeyDown(e) {
const { active, current } = this.state;
if (current == null) {
return;
}
if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) {
return;
}
this._preventArrowKeyScrolling(e);
const { length } = this.props.items;
switch (e.key) {
case "ArrowUp":
(current > 0) && this._setCurrentItem(current - 1, { alignTo: "top" });
break;
case "ArrowDown":
(current < length - 1) && this._setCurrentItem(
current + 1, { alignTo: "bottom" });
break;
case "Home":
this._setCurrentItem(0, { alignTo: "top" });
break;
case "End":
this._setCurrentItem(length - 1, { alignTo: "bottom" });
break;
case "Enter":
case " ":
// On space or enter make current list item active. This means keyboard focus
// handling is passed on to the component within the list item.
if (document.activeElement === this.listRef.current) {
this._preventDefaultAndStopPropagation(e);
if (active !== current) {
this.setState({ active: current });
}
}
break;
case "Escape":
// If current list item is active, make it inactive and let keyboard focusing be
// handled normally.
this._preventDefaultAndStopPropagation(e);
if (active != null) {
this.setState({ active: null });
}
this.listRef.current.focus();
break;
}
}
render() {
const { active, current } = this.state;
const { items } = this.props;
return (
ul({
ref: this.listRef,
className: "list",
tabIndex: 0,
onKeyDown: this._onKeyDown,
onKeyPress: this._preventArrowKeyScrolling,
onKeyUp: this._preventArrowKeyScrolling,
onMouseDown: () => this.setState({ mouseDown: true }),
onMouseUp: () => this.setState({ mouseDown: false }),
onFocus: () => {
if (current != null || this.state.mouseDown) {
return;
}
// Only set default current to the first list item if current item is
// not yet set and the focus event is not the result of a mouse
// interarction.
this._setCurrentItem(0);
},
onClick: () => {
// Focus should always remain on the list container itself.
this.listRef.current.focus();
},
onBlur: e => {
if (active != null) {
const { relatedTarget } = e;
if (!this.listRef.current.contains(relatedTarget)) {
this.setState({ active: null });
}
}
},
"aria-label": this.props.label,
"aria-labelledby": this.props.labelledBy,
"aria-activedescendant": (current != null) ? items[current].key : null,
},
items.map((item, index) => {
return ListItem({
item,
current: index === current,
active: index === active,
// We make a key unique depending on whether the list item is in active or
// inactive state to make sure that it is actually replaced and the tabbable
// state is reset.
key: `${item.key}-${index === active ? "active" : "inactive"}`,
// Since the user just clicked the item, there's no need to check if it should
// be scrolled into view.
onClick: () => this._setCurrentItem(index, { preventAutoScroll: true }),
});
})
)
);
}
}
module.exports = {
ListItem: ListItemClass,
List,
};

Просмотреть файл

@ -19,6 +19,8 @@ DevToolsModules(
'AutoCompletePopup.js',
'Frame.js',
'HSplitBox.js',
'List.css',
'List.js',
'MdnLink.css',
'MdnLink.js',
'NotificationBox.css',

Просмотреть файл

@ -7,6 +7,8 @@ support-files =
[test_frame_01.html]
[test_frame_02.html]
[test_HSplitBox_01.html]
[test_list.html]
[test_list_keyboard.html]
[test_notification_box_01.html]
[test_notification_box_02.html]
[test_notification_box_03.html]

Просмотреть файл

@ -0,0 +1,127 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<!DOCTYPE HTML>
<html>
<!--
Test that List renders correctly.
-->
<head>
<meta charset="utf-8">
<title>List component test</title>
<script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
<link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
<link rel="stylesheet" href="chrome://devtools/skin/light-theme.css" type="text/css">
</head>
<body>
<pre id="test">
<script src="head.js" type="application/javascript"></script>
<script src="list.snapshots.js" type="application/javascript"></script>
<script type="application/javascript">
"use strict";
window.onload = async function() {
try {
const { div } = require("devtools/client/shared/vendor/react-dom-factories");
const React = browserRequire("devtools/client/shared/vendor/react");
const {
Simulate,
renderIntoDocument,
findRenderedDOMComponentWithClass,
scryRenderedDOMComponentsWithTag,
scryRenderedComponentsWithType,
} = browserRequire("devtools/client/shared/vendor/react-dom-test-utils");
const { List, ListItem } =
browserRequire("devtools/client/shared/components/List");
const testItems = [
{
component: div({ className: "item-1" }, "Test List Item 1"),
className: "list-item-1",
key: "list-item-1",
},
{
component: div({ className: "item-2" }, "Test List Item 2"),
className: "list-item-2",
key: "list-item-2",
},
{
component: div({ className: "item-3" }, "Test List Item 3"),
className: "list-item-3",
key: "list-item-3",
},
];
const listReactEl = React.createElement(List, {
items: testItems,
labelledBy: "test-labelledby",
});
const list = renderIntoDocument(listReactEl);
const listEl = findRenderedDOMComponentWithClass(list, "list");
const items = scryRenderedComponentsWithType(list, ListItem);
let itemEls = scryRenderedDOMComponentsWithTag(list, "li");
function testCurrent(index) {
is(list.state.current, index, "Correct current item.");
is(listEl.getAttribute("aria-activedescendant"), testItems[index].key,
"Correct active descendant.");
}
is(items.length, 3, "Correct number of list item components in tree.");
is(itemEls.length, 3, "Correct number of list items is rendered.");
info("Testing initial tree properties.");
for (let index = 0; index < items.length; index++) {
const item = items[index];
const itemEl = itemEls[index];
const { active, current, item: itemProp } = item.props;
const content = itemEl.querySelector(".list-item-content");
is(active, false, "Correct active state.");
is(current, false, "Correct current state.");
is(itemProp, testItems[index], "Correct rendered item.");
is(item.contentRef.current, content, "Correct content ref.");
is(itemEl.className, testItems[index].className, "Correct list item class.");
is(itemEl.id, testItems[index].key, "Correct list item it.");
is(content.getAttribute("role"), "presentation", "Correct content role.");
is(content.innerHTML,
`<div class="item-${index + 1}">Test List Item ${index + 1}</div>`,
"Content rendered correctly.");
}
is(list.state.current, null, "Current item is not set by default.");
is(list.state.active, null, "Active item is not set by default.");
is(list.listRef.current, listEl, "Correct list ref.");
is(listEl.className, "list", "Correct list class.");
is(listEl.tabIndex, 0, "List is focusable.");
ok(!listEl.hasAttribute("aria-label"), "List has no label.");
is(listEl.getAttribute("aria-labelledby"), "test-labelledby",
"Correct list labelled by attribute.");
ok(!listEl.hasAttribute("aria-activedescendant"),
"No active descendant set by default.");
Simulate.focus(listEl);
testCurrent(0);
Simulate.click(itemEls[2]);
testCurrent(2);
Simulate.blur(listEl);
testCurrent(2);
Simulate.focus(listEl);
testCurrent(2);
} catch (e) {
ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e));
} finally {
SimpleTest.finish();
}
};
</script>
</pre>
</body>
</html>

Просмотреть файл

@ -0,0 +1,283 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<!DOCTYPE HTML>
<html>
<!--
Test that List component has working keyboard interactions.
-->
<head>
<meta charset="utf-8">
<title>List component keyboard test</title>
<script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
<link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
<link rel="stylesheet" href="chrome://devtools/skin/light-theme.css" type="text/css">
</head>
<body>
<pre id="test">
<script src="head.js" type="application/javascript"></script>
<script type="application/javascript">
"use strict";
window.onload = function() {
try {
const { a, button, div } =
require("devtools/client/shared/vendor/react-dom-factories");
const React = browserRequire("devtools/client/shared/vendor/react");
const {
Simulate,
findRenderedDOMComponentWithClass,
findRenderedDOMComponentWithTag,
scryRenderedDOMComponentsWithTag,
} = browserRequire("devtools/client/shared/vendor/react-dom-test-utils");
const { List } =
browserRequire("devtools/client/shared/components/List");
const testItems = [
{
component: div({}, "Test List Item 1"),
className: "list-item-1",
key: "list-item-1",
},
{
component: div({},
"Test List Item 2",
a({ href: "#" }, "Focusable 1"),
button({ }, "Focusable 2")),
className: "list-item-2",
key: "list-item-2",
},
{
component: div({}, "Test List Item 3"),
className: "list-item-3",
key: "list-item-3",
},
];
const list = React.createElement(List, {
items: testItems,
labelledby: "test-labelledby",
});
const tree = ReactDOM.render(list, document.body);
const listEl = findRenderedDOMComponentWithClass(tree, "list");
const items = scryRenderedDOMComponentsWithTag(tree, "li");
const defaultFocus = listEl.ownerDocument.body;
function blurEl(el) {
// Simulate.blur does not seem to update the activeElement.
el.blur();
}
function focusEl(el) {
// Simulate.focus does not seem to update the activeElement.
el.focus();
}
const tests = [{
name: "Test default List state. Keyboard focus is set to document body by default.",
state: { current: null, active: null },
activeElement: defaultFocus,
}, {
name: "Current item must be set to the first list item on initial focus. " +
"Keyboard focus should be set on list's conatiner (<ul>).",
action: () => focusEl(listEl),
activeElement: listEl,
state: { current: 0 },
}, {
name: "Current item should remain set even when the list is blured. " +
"Keyboard focus should be set back to document body.",
action: () => blurEl(listEl),
state: { current: 0 },
activeElement: defaultFocus,
}, {
name: "Unset list's current state.",
action: () => tree.setState({ current: null }),
state: { current: null },
}, {
name: "Current item must be re-set again to the first list item on initial " +
"focus. Keyboard focus should be set on list's conatiner (<ul>).",
action: () => focusEl(listEl),
activeElement: listEl,
state: { current: 0 },
}, {
name: "Current item should be updated to next on ArrowDown.",
event: { type: "keyDown", el: listEl, options: { key: "ArrowDown" }},
state: { current: 1 },
}, {
name: "Current item should be updated to last on ArrowDown.",
event: { type: "keyDown", el: listEl, options: { key: "ArrowDown" }},
state: { current: 2 },
}, {
name: "Current item should remain on last on ArrowDown.",
event: { type: "keyDown", el: listEl, options: { key: "ArrowDown" }},
state: { current: 2 },
}, {
name: "Current item should be updated to previous on ArrowUp.",
event: { type: "keyDown", el: listEl, options: { key: "ArrowUp" }},
state: { current: 1 },
}, {
name: "Current item should be updated to first on ArrowUp.",
event: { type: "keyDown", el: listEl, options: { key: "ArrowUp" }},
state: { current: 0 },
}, {
name: "Current item should remain on first on ArrowUp.",
event: { type: "keyDown", el: listEl, options: { key: "ArrowUp" }},
state: { current: 0 },
}, {
name: "Current item should be updated to last on End.",
event: { type: "keyDown", el: listEl, options: { key: "End" }},
state: { current: 2 },
}, {
name: "Current item should be updated to first on Home.",
event: { type: "keyDown", el: listEl, options: { key: "Home" }},
state: { current: 0 },
}, {
name: "Current item should be set as active on Enter.",
event: { type: "keyDown", el: listEl, options: { key: "Enter" }},
state: { current: 0, active: 0 },
activeElement: listEl,
}, {
name: "Active item should be unset on Escape.",
event: { type: "keyDown", el: listEl, options: { key: "Escape" }},
state: { current: 0, active: null },
}, {
name: "Current item should be set as active on Space.",
event: { type: "keyDown", el: listEl, options: { key: " " }},
state: { current: 0, active: 0 },
activeElement: listEl,
}, {
name: "Current item should unset when focus leaves the list.",
action: () => blurEl(listEl),
state: { current: 0, active: null },
activeElement: defaultFocus,
}, {
name: "Keyboard focus should be set on list's conatiner (<ul>) on focus.",
action: () => focusEl(listEl),
activeElement: listEl,
}, {
name: "Current item should be updated to next on ArrowDown.",
event: { type: "keyDown", el: listEl, options: { key: "ArrowDown" }},
state: { current: 1, active: null },
}, {
name: "Current item should be set as active on Enter. Keyboard focus should be " +
"set on the first focusable element inside the list item, if available.",
event: { type: "keyDown", el: listEl, options: { key: "Enter" }},
state: { current: 1, active: 1 },
get activeElement() {
// When list item becomes active/inactive, it is replaced with a newly rendered
// one.
return findRenderedDOMComponentWithTag(tree, "a");
},
}, {
name: "Keyboard focus should be set to next tabbable element inside the active " +
"list item on Tab.",
action() {
synthesizeKey("KEY_Tab");
},
state: { current: 1, active: 1 },
get activeElement() {
// When list item becomes active/inactive, it is replaced with a newly rendered
// one.
return findRenderedDOMComponentWithTag(tree, "button");
},
}, {
name: "Keyboard focus should wrap inside the list item when focused on last " +
"tabbable element.",
action() {
synthesizeKey("KEY_Tab");
},
state: { current: 1, active: 1 },
get activeElement() {
return findRenderedDOMComponentWithTag(tree, "a");
},
}, {
name: "Keyboard focus should wrap inside the list item when focused on first " +
"tabbable element.",
action() {
synthesizeKey("KEY_Tab", { shiftKey: true });
},
state: { current: 1, active: 1 },
get activeElement() {
return findRenderedDOMComponentWithTag(tree, "button");
},
}, {
name: "Active item should be unset on Escape. Focus should move back to the " +
"list container.",
event: { type: "keyDown", el: listEl, options: { key: "Escape" }},
state: { current: 1, active: null },
activeElement: listEl,
}, {
name: "Current item should be set as active on Space. Keyboard focus should be " +
"set on the first focusable element inside the list item, if available.",
event: { type: "keyDown", el: listEl, options: { key: " " }},
state: { current: 1, active: 1 },
get activeElement() {
// When list item becomes active/inactive, it is replaced with a newly rendered
// one.
return findRenderedDOMComponentWithTag(tree, "a");
},
}, {
name: "Current item should remain set even when the list is blured. " +
"Keyboard focus should be set back to document body.",
action: () => listEl.ownerDocument.activeElement.blur(),
state: { current: 1, active: null, },
activeElement: defaultFocus,
}, {
name: "Keyboard focus should be set on list's conatiner (<ul>) on focus.",
action: () => focusEl(listEl),
state: { current: 1, active: null },
activeElement: listEl,
}, {
name: "Current item should be updated to previous on ArrowUp.",
event: { type: "keyDown", el: listEl, options: { key: "ArrowUp" }},
state: { current: 0, active: null },
}, {
name: "Current item should be set as active on Enter.",
event: { type: "keyDown", el: listEl, options: { key: "Enter" }},
state: { current: 0, active: 0 },
activeElement: listEl,
}, {
name: "Keyboard focus should move to another focusable element outside of the " +
"list when there's nothing to focus on inside the list item.",
action() {
synthesizeKey("KEY_Tab", { shiftKey: true });
},
state: { current: 0, active: null },
activeElement: listEl.ownerDocument.documentElement,
}];
for (const test of tests) {
const { action, condition, event, state, name } = test;
is(listEl, findRenderedDOMComponentWithClass(tree, "list"), "Sanity check");
info(name);
if (event) {
const { type, options, el } = event;
Simulate[type](el, options);
} else if (action) {
action();
}
if (test.activeElement) {
is(listEl.ownerDocument.activeElement, test.activeElement,
"Focus is set correctly.");
}
for (let key in state) {
is(tree.state[key], state[key], `${key} state is correct.`);
}
}
} catch (e) {
ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e));
} finally {
SimpleTest.finish();
}
};
</script>
</pre>
</body>
</html>