diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /devtools/client/accessibility/components | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/accessibility/components')
28 files changed, 3827 insertions, 0 deletions
diff --git a/devtools/client/accessibility/components/AccessibilityPrefs.js b/devtools/client/accessibility/components/AccessibilityPrefs.js new file mode 100644 index 0000000000..8c5de5f15f --- /dev/null +++ b/devtools/client/accessibility/components/AccessibilityPrefs.js @@ -0,0 +1,109 @@ +/* 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"; + +// React +const { + createFactory, + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + L10N, +} = require("resource://devtools/client/accessibility/utils/l10n.js"); + +const { + hr, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +loader.lazyGetter(this, "MenuButton", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuButton.js") + ); +}); +loader.lazyGetter(this, "MenuItem", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuItem.js") + ); +}); +loader.lazyGetter(this, "MenuList", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuList.js") + ); +}); + +const { + A11Y_LEARN_MORE_LINK, +} = require("resource://devtools/client/accessibility/constants.js"); +const { openDocLink } = require("resource://devtools/client/shared/link.js"); + +const { + updatePref, +} = require("resource://devtools/client/accessibility/actions/ui.js"); + +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const { + PREFS, +} = require("resource://devtools/client/accessibility/constants.js"); + +class AccessibilityPrefs extends Component { + static get propTypes() { + return { + dispatch: PropTypes.func.isRequired, + [PREFS.SCROLL_INTO_VIEW]: PropTypes.bool.isRequired, + toolboxDoc: PropTypes.object.isRequired, + }; + } + + togglePref(prefKey) { + this.props.dispatch(updatePref(prefKey, !this.props[prefKey])); + } + + onPrefClick(prefKey) { + this.togglePref(prefKey); + } + + onLearnMoreClick() { + openDocLink(A11Y_LEARN_MORE_LINK); + } + + render() { + return MenuButton( + { + menuId: "accessibility-tree-filters-prefs-menu", + toolboxDoc: this.props.toolboxDoc, + className: `devtools-button badge toolbar-menu-button prefs`, + title: L10N.getStr("accessibility.tree.filters.prefs"), + }, + MenuList({}, [ + MenuItem({ + key: "pref-scroll-into-view", + checked: this.props[PREFS.SCROLL_INTO_VIEW], + className: `pref ${PREFS.SCROLL_INTO_VIEW}`, + label: L10N.getStr("accessibility.pref.scroll.into.view.label"), + tooltip: L10N.getStr("accessibility.pref.scroll.into.view.title"), + onClick: this.onPrefClick.bind(this, PREFS.SCROLL_INTO_VIEW), + }), + hr({ key: "hr" }), + MenuItem({ + role: "link", + key: "accessibility-tree-filters-prefs-menu-help", + className: "help", + label: L10N.getStr("accessibility.documentation.label"), + tooltip: L10N.getStr("accessibility.learnMore"), + onClick: this.onLearnMoreClick, + }), + ]) + ); + } +} + +const mapStateToProps = ({ ui }) => ({ + [PREFS.SCROLL_INTO_VIEW]: ui[PREFS.SCROLL_INTO_VIEW], +}); + +// Exports from this module +module.exports = connect(mapStateToProps)(AccessibilityPrefs); diff --git a/devtools/client/accessibility/components/AccessibilityRow.js b/devtools/client/accessibility/components/AccessibilityRow.js new file mode 100644 index 0000000000..ffca5c74ef --- /dev/null +++ b/devtools/client/accessibility/components/AccessibilityRow.js @@ -0,0 +1,328 @@ +/* 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"; + +/* global gTelemetry, EVENTS */ + +// React & Redux +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + findDOMNode, +} = require("resource://devtools/client/shared/vendor/react-dom.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +const TreeRow = require("resource://devtools/client/shared/components/tree/TreeRow.js"); +const AuditFilter = createFactory( + require("resource://devtools/client/accessibility/components/AuditFilter.js") +); +const AuditController = createFactory( + require("resource://devtools/client/accessibility/components/AuditController.js") +); + +// Utils +const { + flashElementOn, + flashElementOff, +} = require("resource://devtools/client/inspector/markup/utils.js"); +const { openDocLink } = require("resource://devtools/client/shared/link.js"); +const { + PREFS, + VALUE_FLASHING_DURATION, + VALUE_HIGHLIGHT_DURATION, +} = require("resource://devtools/client/accessibility/constants.js"); + +const nodeConstants = require("resource://devtools/shared/dom-node-constants.js"); + +// Actions +const { + updateDetails, +} = require("resource://devtools/client/accessibility/actions/details.js"); +const { + unhighlight, +} = require("resource://devtools/client/accessibility/actions/accessibles.js"); + +const { + L10N, +} = require("resource://devtools/client/accessibility/utils/l10n.js"); + +loader.lazyRequireGetter( + this, + "Menu", + "resource://devtools/client/framework/menu.js" +); +loader.lazyRequireGetter( + this, + "MenuItem", + "resource://devtools/client/framework/menu-item.js" +); + +const { + scrollIntoView, +} = require("resource://devtools/client/shared/scroll.js"); + +const JSON_URL_PREFIX = "data:application/json;charset=UTF-8,"; + +const TELEMETRY_ACCESSIBLE_CONTEXT_MENU_OPENED = + "devtools.accessibility.accessible_context_menu_opened"; +const TELEMETRY_ACCESSIBLE_CONTEXT_MENU_ITEM_ACTIVATED = + "devtools.accessibility.accessible_context_menu_item_activated"; + +class HighlightableTreeRowClass extends TreeRow { + shouldComponentUpdate(nextProps) { + const shouldTreeRowUpdate = super.shouldComponentUpdate(nextProps); + if (shouldTreeRowUpdate) { + return shouldTreeRowUpdate; + } + + if ( + nextProps.highlighted !== this.props.highlighted || + nextProps.filtered !== this.props.filtered + ) { + return true; + } + + return false; + } +} + +const HighlightableTreeRow = createFactory(HighlightableTreeRowClass); + +// Component that expands TreeView's own TreeRow and is responsible for +// rendering an accessible object. +class AccessibilityRow extends Component { + static get propTypes() { + return { + ...TreeRow.propTypes, + dispatch: PropTypes.func.isRequired, + toolboxDoc: PropTypes.object.isRequired, + scrollContentNodeIntoView: PropTypes.bool.isRequired, + highlightAccessible: PropTypes.func.isRequired, + unhighlightAccessible: PropTypes.func.isRequired, + }; + } + + componentDidMount() { + const { + member: { selected, object }, + scrollContentNodeIntoView, + } = this.props; + if (selected) { + this.unhighlight(object); + this.update(); + this.highlight( + object, + { duration: VALUE_HIGHLIGHT_DURATION }, + scrollContentNodeIntoView + ); + } + + if (this.props.highlighted) { + this.scrollIntoView(); + } + } + + /** + * Update accessible object details that are going to be rendered inside the + * accessible panel sidebar. + */ + componentDidUpdate(prevProps) { + const { + member: { selected, object }, + scrollContentNodeIntoView, + } = this.props; + // If row is selected, update corresponding accessible details. + if (!prevProps.member.selected && selected) { + this.unhighlight(object); + this.update(); + this.highlight( + object, + { duration: VALUE_HIGHLIGHT_DURATION }, + scrollContentNodeIntoView + ); + } + + if (this.props.highlighted) { + this.scrollIntoView(); + } + + if (!selected && prevProps.member.value !== this.props.member.value) { + this.flashValue(); + } + } + + scrollIntoView() { + const row = findDOMNode(this); + // Row might not be rendered in the DOM tree if it is filtered out during + // audit. + if (!row) { + return; + } + + scrollIntoView(row); + } + + update() { + const { + dispatch, + member: { object }, + } = this.props; + if (!object.actorID) { + return; + } + + dispatch(updateDetails(object)); + window.emit(EVENTS.NEW_ACCESSIBLE_FRONT_SELECTED, object); + } + + flashValue() { + const row = findDOMNode(this); + // Row might not be rendered in the DOM tree if it is filtered out during + // audit. + if (!row) { + return; + } + + const value = row.querySelector(".objectBox"); + + flashElementOn(value); + if (this._flashMutationTimer) { + clearTimeout(this._flashMutationTimer); + this._flashMutationTimer = null; + } + this._flashMutationTimer = setTimeout(() => { + flashElementOff(value); + }, VALUE_FLASHING_DURATION); + } + + /** + * Scroll the node that corresponds to a current accessible object into view. + * @param {Object} + * Accessible front that is rendered for this node. + * + * @returns {Promise} + * Promise that resolves when the node is scrolled into view if + * possible. + */ + async scrollNodeIntoViewIfNeeded(accessibleFront) { + if (accessibleFront.isDestroyed()) { + return; + } + + const domWalker = (await accessibleFront.targetFront.getFront("inspector")) + .walker; + if (accessibleFront.isDestroyed()) { + return; + } + + const node = await domWalker.getNodeFromActor(accessibleFront.actorID, [ + "rawAccessible", + "DOMNode", + ]); + if (!node) { + return; + } + + if (node.nodeType == nodeConstants.ELEMENT_NODE) { + await node.scrollIntoView(); + } else if (node.nodeType != nodeConstants.DOCUMENT_NODE) { + // scrollIntoView method is only part of the Element interface, in cases + // where node is a text node (and not a document node) scroll into view + // its parent. + await node.parentNode().scrollIntoView(); + } + } + + async highlight(accessibleFront, options, scrollContentNodeIntoView) { + this.props.dispatch(unhighlight()); + // If necessary scroll the node into view before showing the accessibility + // highlighter. + if (scrollContentNodeIntoView) { + await this.scrollNodeIntoViewIfNeeded(accessibleFront); + } + + this.props.highlightAccessible(accessibleFront, options); + } + + unhighlight(accessibleFront) { + this.props.dispatch(unhighlight()); + this.props.unhighlightAccessible(accessibleFront); + } + + async printToJSON() { + if (gTelemetry) { + gTelemetry.keyedScalarAdd( + TELEMETRY_ACCESSIBLE_CONTEXT_MENU_ITEM_ACTIVATED, + "print-to-json", + 1 + ); + } + + const snapshot = await this.props.member.object.snapshot(); + openDocLink( + `${JSON_URL_PREFIX}${encodeURIComponent(JSON.stringify(snapshot))}` + ); + } + + onContextMenu(e) { + e.stopPropagation(); + e.preventDefault(); + + if (!this.props.toolboxDoc) { + return; + } + + const menu = new Menu({ id: "accessibility-row-contextmenu" }); + menu.append( + new MenuItem({ + id: "menu-printtojson", + label: L10N.getStr("accessibility.tree.menu.printToJSON"), + click: () => this.printToJSON(), + }) + ); + + menu.popup(e.screenX, e.screenY, this.props.toolboxDoc); + + if (gTelemetry) { + gTelemetry.scalarAdd(TELEMETRY_ACCESSIBLE_CONTEXT_MENU_OPENED, 1); + } + } + + /** + * Render accessible row component. + * @returns acecssible-row React component. + */ + render() { + const { member } = this.props; + const props = { + ...this.props, + onContextMenu: e => this.onContextMenu(e), + onMouseOver: () => this.highlight(member.object), + onMouseOut: () => this.unhighlight(member.object), + key: `${member.path}-${member.active ? "active" : "inactive"}`, + }; + + return AuditController( + { + accessibleFront: member.object, + }, + AuditFilter({}, HighlightableTreeRow(props)) + ); + } +} + +const mapStateToProps = ({ + ui: { [PREFS.SCROLL_INTO_VIEW]: scrollContentNodeIntoView }, +}) => ({ + scrollContentNodeIntoView, +}); + +module.exports = connect(mapStateToProps, null, null, { withRef: true })( + AccessibilityRow +); diff --git a/devtools/client/accessibility/components/AccessibilityRowValue.js b/devtools/client/accessibility/components/AccessibilityRowValue.js new file mode 100644 index 0000000000..ab631fedb4 --- /dev/null +++ b/devtools/client/accessibility/components/AccessibilityRowValue.js @@ -0,0 +1,58 @@ +/* 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 { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + span, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const Badges = createFactory( + require("resource://devtools/client/accessibility/components/Badges.js") +); +const AuditController = createFactory( + require("resource://devtools/client/accessibility/components/AuditController.js") +); + +const { + REPS, +} = require("resource://devtools/client/shared/components/reps/index.js"); +const { Grip } = REPS; +const Rep = createFactory(REPS.Rep); + +class AccessibilityRowValue extends Component { + static get propTypes() { + return { + member: PropTypes.shape({ + object: PropTypes.object, + }).isRequired, + }; + } + + render() { + return span( + { + role: "presentation", + }, + Rep({ + ...this.props, + defaultRep: Grip, + cropLimit: 50, + }), + AuditController( + { + accessibleFront: this.props.member.object, + }, + Badges() + ) + ); + } +} + +module.exports = AccessibilityRowValue; diff --git a/devtools/client/accessibility/components/AccessibilityTree.js b/devtools/client/accessibility/components/AccessibilityTree.js new file mode 100644 index 0000000000..df67377283 --- /dev/null +++ b/devtools/client/accessibility/components/AccessibilityTree.js @@ -0,0 +1,298 @@ +/* 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"; + +/* global EVENTS */ + +// React & Redux +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +const TreeView = createFactory( + require("resource://devtools/client/shared/components/tree/TreeView.js") +); +// Reps +const { + MODE, +} = require("resource://devtools/client/shared/components/reps/index.js"); + +const { + fetchChildren, +} = require("resource://devtools/client/accessibility/actions/accessibles.js"); + +const { + L10N, +} = require("resource://devtools/client/accessibility/utils/l10n.js"); +const { + isFiltered, +} = require("resource://devtools/client/accessibility/utils/audit.js"); +const AccessibilityRow = createFactory( + require("resource://devtools/client/accessibility/components/AccessibilityRow.js") +); +const AccessibilityRowValue = createFactory( + require("resource://devtools/client/accessibility/components/AccessibilityRowValue.js") +); +const { + Provider, +} = require("resource://devtools/client/accessibility/provider.js"); + +const { + scrollIntoView, +} = require("resource://devtools/client/shared/scroll.js"); + +/** + * Renders Accessibility panel tree. + */ +class AccessibilityTree extends Component { + static get propTypes() { + return { + toolboxDoc: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accessibles: PropTypes.object, + expanded: PropTypes.object, + selected: PropTypes.string, + highlighted: PropTypes.object, + filtered: PropTypes.bool, + getAccessibilityTreeRoot: PropTypes.func.isRequired, + startListeningForAccessibilityEvents: PropTypes.func.isRequired, + stopListeningForAccessibilityEvents: PropTypes.func.isRequired, + highlightAccessible: PropTypes.func.isRequired, + unhighlightAccessible: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + + this.onNameChange = this.onNameChange.bind(this); + this.onReorder = this.onReorder.bind(this); + this.onTextChange = this.onTextChange.bind(this); + this.renderValue = this.renderValue.bind(this); + this.scrollSelectedRowIntoView = this.scrollSelectedRowIntoView.bind(this); + } + + /** + * Add accessibility event listeners that affect tree rendering and updates. + */ + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillMount() { + this.props.startListeningForAccessibilityEvents({ + reorder: this.onReorder, + "name-change": this.onNameChange, + "text-change": this.onTextChange, + }); + window.on( + EVENTS.NEW_ACCESSIBLE_FRONT_INSPECTED, + this.scrollSelectedRowIntoView + ); + return null; + } + + componentDidUpdate(prevProps) { + // When filtering is toggled, make sure that the selected row remains in + // view. + if (this.props.filtered !== prevProps.filtered) { + this.scrollSelectedRowIntoView(); + } + + window.emit(EVENTS.ACCESSIBILITY_INSPECTOR_UPDATED); + } + + /** + * Remove accessible event listeners. + */ + componentWillUnmount() { + this.props.stopListeningForAccessibilityEvents({ + reorder: this.onReorder, + "name-change": this.onNameChange, + "text-change": this.onTextChange, + }); + + window.off( + EVENTS.NEW_ACCESSIBLE_FRONT_INSPECTED, + this.scrollSelectedRowIntoView + ); + } + + /** + * Handle accessible reorder event. If the accessible is cached and rendered + * within the accessibility tree, re-fetch its children and re-render the + * corresponding subtree. + * @param {Object} accessibleFront + * accessible front that had its subtree reordered. + */ + onReorder(accessibleFront) { + if (this.props.accessibles.has(accessibleFront.actorID)) { + this.props.dispatch(fetchChildren(accessibleFront)); + } + } + + scrollSelectedRowIntoView() { + const { treeview } = this.refs; + if (!treeview) { + return; + } + + const treeEl = treeview.treeRef.current; + if (!treeEl) { + return; + } + + const selected = treeEl.ownerDocument.querySelector( + ".treeTable .treeRow.selected" + ); + if (selected) { + scrollIntoView(selected, { center: true }); + } + } + + /** + * Handle accessible name change event. If the name of an accessible changes + * and that accessible is cached and rendered within the accessibility tree, + * re-fetch its parent's children and re-render the corresponding subtree. + * @param {Object} accessibleFront + * accessible front that had its name changed. + * @param {Object} parentFront + * optional parent accessible front. Note: if it parent is not + * present, we assume that the top level document's name has changed + * and use accessible walker as a parent. + */ + onNameChange(accessibleFront, parentFront) { + const { accessibles, dispatch } = this.props; + const accessibleWalkerFront = accessibleFront.getParent(); + parentFront = parentFront || accessibleWalkerFront; + + if ( + accessibles.has(accessibleFront.actorID) || + accessibles.has(parentFront.actorID) + ) { + dispatch(fetchChildren(parentFront)); + } + } + + /** + * Handle accessible text change (change/insert/remove) event. If the text of + * an accessible changes and that accessible is cached and rendered within the + * accessibility tree, re-fetch its children and re-render the corresponding + * subtree. + * @param {Object} accessibleFront + * accessible front that had its child text changed. + */ + onTextChange(accessibleFront) { + const { accessibles, dispatch } = this.props; + if (accessibles.has(accessibleFront.actorID)) { + dispatch(fetchChildren(accessibleFront)); + } + } + + renderValue(props) { + return AccessibilityRowValue(props); + } + + /** + * Render Accessibility panel content + */ + render() { + const columns = [ + { + id: "default", + title: L10N.getStr("accessibility.role"), + }, + { + id: "value", + title: L10N.getStr("accessibility.name"), + }, + ]; + + const { + accessibles, + dispatch, + expanded, + selected, + highlighted: highlightedItem, + toolboxDoc, + filtered, + getAccessibilityTreeRoot, + highlightAccessible, + unhighlightAccessible, + } = this.props; + + const renderRow = rowProps => { + const { object } = rowProps.member; + const highlighted = object === highlightedItem; + return AccessibilityRow( + Object.assign({}, rowProps, { + toolboxDoc, + highlighted, + decorator: { + getRowClass() { + return highlighted ? ["highlighted"] : []; + }, + }, + highlightAccessible, + unhighlightAccessible, + }) + ); + }; + const className = filtered ? "filtered" : undefined; + + return TreeView({ + ref: "treeview", + object: getAccessibilityTreeRoot(), + mode: MODE.SHORT, + provider: new Provider(accessibles, filtered, dispatch), + columns, + className, + renderValue: this.renderValue, + renderRow, + label: L10N.getStr("accessibility.treeName"), + header: true, + expandedNodes: expanded, + selected, + onClickRow(nodePath, event) { + if (event.target.classList.contains("theme-twisty")) { + this.toggle(nodePath); + } + + this.selectRow( + this.rows.find(row => row.props.member.path === nodePath), + { preventAutoScroll: true } + ); + + return true; + }, + onContextMenuTree(e) { + // If context menu event is triggered on (or bubbled to) the TreeView, it was + // done via keyboard. Open context menu for currently selected row. + let row = this.getSelectedRow(); + if (!row) { + return; + } + + row = row.getWrappedInstance(); + row.onContextMenu(e); + }, + }); + } +} + +const mapStateToProps = ({ + accessibles, + ui: { expanded, selected, highlighted }, + audit: { filters }, +}) => ({ + accessibles, + expanded, + selected, + highlighted, + filtered: isFiltered(filters), +}); +// Exports from this module +module.exports = connect(mapStateToProps)(AccessibilityTree); diff --git a/devtools/client/accessibility/components/AccessibilityTreeFilter.js b/devtools/client/accessibility/components/AccessibilityTreeFilter.js new file mode 100644 index 0000000000..90eabd350a --- /dev/null +++ b/devtools/client/accessibility/components/AccessibilityTreeFilter.js @@ -0,0 +1,171 @@ +/* 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"; + +/* global gTelemetry */ + +// React +const { + createFactory, + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + div, + hr, + span, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + L10N, +} = require("resource://devtools/client/accessibility/utils/l10n.js"); + +loader.lazyGetter(this, "MenuButton", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuButton.js") + ); +}); +loader.lazyGetter(this, "MenuItem", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuItem.js") + ); +}); +loader.lazyGetter(this, "MenuList", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuList.js") + ); +}); + +const actions = require("resource://devtools/client/accessibility/actions/audit.js"); + +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const { + FILTERS, +} = require("resource://devtools/client/accessibility/constants.js"); + +const TELEMETRY_AUDIT_ACTIVATED = "devtools.accessibility.audit_activated"; +const FILTER_LABELS = { + [FILTERS.NONE]: "accessibility.filter.none", + [FILTERS.ALL]: "accessibility.filter.all2", + [FILTERS.CONTRAST]: "accessibility.filter.contrast", + [FILTERS.KEYBOARD]: "accessibility.filter.keyboard", + [FILTERS.TEXT_LABEL]: "accessibility.filter.textLabel", +}; + +class AccessibilityTreeFilter extends Component { + static get propTypes() { + return { + auditing: PropTypes.array.isRequired, + filters: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + describedby: PropTypes.string, + toolboxDoc: PropTypes.object.isRequired, + audit: PropTypes.func.isRequired, + }; + } + + async toggleFilter(filterKey) { + const { audit: auditFunc, dispatch, filters } = this.props; + + if (filterKey !== FILTERS.NONE && !filters[filterKey]) { + if (gTelemetry) { + gTelemetry.keyedScalarAdd(TELEMETRY_AUDIT_ACTIVATED, filterKey, 1); + } + + dispatch(actions.auditing(filterKey)); + await dispatch(actions.audit(auditFunc, filterKey)); + } + + // We wait to dispatch filter toggle until the tree is ready to be filtered + // right after the audit. This is to make sure that we render an empty tree + // (filtered) while the audit is running. + dispatch(actions.filterToggle(filterKey)); + } + + onClick(filterKey) { + this.toggleFilter(filterKey); + } + + render() { + const { auditing, filters, describedby, toolboxDoc } = this.props; + const toolbarLabelID = "accessibility-tree-filters-label"; + const filterNoneChecked = !Object.values(filters).includes(true); + const items = [ + MenuItem({ + key: FILTERS.NONE, + checked: filterNoneChecked, + className: `filter ${FILTERS.NONE}`, + label: L10N.getStr(FILTER_LABELS[FILTERS.NONE]), + onClick: this.onClick.bind(this, FILTERS.NONE), + disabled: !!auditing.length, + }), + hr({ key: "hr-1" }), + ]; + + const { [FILTERS.ALL]: filterAllChecked, ...filtersWithoutAll } = filters; + items.push( + MenuItem({ + key: FILTERS.ALL, + checked: filterAllChecked, + className: `filter ${FILTERS.ALL}`, + label: L10N.getStr(FILTER_LABELS[FILTERS.ALL]), + onClick: this.onClick.bind(this, FILTERS.ALL), + disabled: !!auditing.length, + }), + hr({ key: "hr-2" }), + Object.entries(filtersWithoutAll).map(([filterKey, active]) => + MenuItem({ + key: filterKey, + checked: active, + className: `filter ${filterKey}`, + label: L10N.getStr(FILTER_LABELS[filterKey]), + onClick: this.onClick.bind(this, filterKey), + disabled: !!auditing.length, + }) + ) + ); + + let label; + if (filterNoneChecked) { + label = L10N.getStr(FILTER_LABELS[FILTERS.NONE]); + } else if (filterAllChecked) { + label = L10N.getStr(FILTER_LABELS[FILTERS.ALL]); + } else { + label = Object.keys(filtersWithoutAll) + .filter(filterKey => filtersWithoutAll[filterKey]) + .map(filterKey => L10N.getStr(FILTER_LABELS[filterKey])) + .join(", "); + } + + return div( + { + role: "group", + className: "accessibility-tree-filters", + "aria-labelledby": toolbarLabelID, + "aria-describedby": describedby, + }, + span( + { id: toolbarLabelID, role: "presentation" }, + L10N.getStr("accessibility.tree.filters") + ), + MenuButton( + { + menuId: "accessibility-tree-filters-menu", + toolboxDoc, + className: `devtools-button badge toolbar-menu-button filters`, + label, + }, + MenuList({}, items) + ) + ); + } +} + +const mapStateToProps = ({ audit: { filters, auditing } }) => { + return { filters, auditing }; +}; + +// Exports from this module +module.exports = connect(mapStateToProps)(AccessibilityTreeFilter); diff --git a/devtools/client/accessibility/components/Accessible.js b/devtools/client/accessibility/components/Accessible.js new file mode 100644 index 0000000000..02467ccaab --- /dev/null +++ b/devtools/client/accessibility/components/Accessible.js @@ -0,0 +1,563 @@ +/* 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"; + +/* global EVENTS, gTelemetry */ + +// React & Redux +const { + createFactory, + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + div, + span, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + findDOMNode, +} = require("resource://devtools/client/shared/vendor/react-dom.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +const { + TREE_ROW_HEIGHT, + ORDERED_PROPS, + ACCESSIBLE_EVENTS, + VALUE_FLASHING_DURATION, +} = require("resource://devtools/client/accessibility/constants.js"); +const { + L10N, +} = require("resource://devtools/client/accessibility/utils/l10n.js"); +const { + flashElementOn, + flashElementOff, +} = require("resource://devtools/client/inspector/markup/utils.js"); +const { + updateDetails, +} = require("resource://devtools/client/accessibility/actions/details.js"); +const { + select, + unhighlight, +} = require("resource://devtools/client/accessibility/actions/accessibles.js"); + +const VirtualizedTree = createFactory( + require("resource://devtools/client/shared/components/VirtualizedTree.js") +); +// Reps +const { + REPS, + MODE, +} = require("resource://devtools/client/shared/components/reps/index.js"); +const { Rep, ElementNode, Accessible: AccessibleRep, Obj } = REPS; + +const { + translateNodeFrontToGrip, +} = require("resource://devtools/client/inspector/shared/utils.js"); + +loader.lazyRequireGetter( + this, + "openContentLink", + "resource://devtools/client/shared/link.js", + true +); + +const TELEMETRY_NODE_INSPECTED_COUNT = + "devtools.accessibility.node_inspected_count"; + +const TREE_DEPTH_PADDING_INCREMENT = 20; + +class AccessiblePropertyClass extends Component { + static get propTypes() { + return { + accessibleFrontActorID: PropTypes.string, + object: PropTypes.any, + focused: PropTypes.bool, + children: PropTypes.func, + }; + } + + componentDidUpdate({ + object: prevObject, + accessibleFrontActorID: prevAccessibleFrontActorID, + }) { + const { accessibleFrontActorID, object, focused } = this.props; + // Fast check if row is focused or if the value did not update. + if ( + focused || + accessibleFrontActorID !== prevAccessibleFrontActorID || + prevObject === object || + (object && prevObject && typeof object === "object") + ) { + return; + } + + this.flashRow(); + } + + flashRow() { + const row = findDOMNode(this); + flashElementOn(row); + if (this._flashMutationTimer) { + clearTimeout(this._flashMutationTimer); + this._flashMutationTimer = null; + } + this._flashMutationTimer = setTimeout(() => { + flashElementOff(row); + }, VALUE_FLASHING_DURATION); + } + + render() { + return this.props.children(); + } +} + +const AccessibleProperty = createFactory(AccessiblePropertyClass); + +class Accessible extends Component { + static get propTypes() { + return { + accessibleFront: PropTypes.object, + dispatch: PropTypes.func.isRequired, + nodeFront: PropTypes.object, + items: PropTypes.array, + labelledby: PropTypes.string.isRequired, + parents: PropTypes.object, + relations: PropTypes.object, + toolbox: PropTypes.object.isRequired, + toolboxHighlighter: PropTypes.object.isRequired, + highlightAccessible: PropTypes.func.isRequired, + unhighlightAccessible: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + + this.state = { + expanded: new Set(), + active: null, + focused: null, + }; + + this.onAccessibleInspected = this.onAccessibleInspected.bind(this); + this.renderItem = this.renderItem.bind(this); + this.update = this.update.bind(this); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillMount() { + window.on( + EVENTS.NEW_ACCESSIBLE_FRONT_INSPECTED, + this.onAccessibleInspected + ); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps({ accessibleFront }) { + const oldAccessibleFront = this.props.accessibleFront; + + if (oldAccessibleFront) { + if ( + accessibleFront && + accessibleFront.actorID === oldAccessibleFront.actorID + ) { + return; + } + ACCESSIBLE_EVENTS.forEach(event => + oldAccessibleFront.off(event, this.update) + ); + } + + if (accessibleFront) { + ACCESSIBLE_EVENTS.forEach(event => + accessibleFront.on(event, this.update) + ); + } + } + + componentDidUpdate(prevProps) { + if ( + this.props.accessibleFront && + !this.props.accessibleFront.isDestroyed() && + this.props.accessibleFront !== prevProps.accessibleFront + ) { + window.emit(EVENTS.PROPERTIES_UPDATED); + } + } + + componentWillUnmount() { + window.off( + EVENTS.NEW_ACCESSIBLE_FRONT_INSPECTED, + this.onAccessibleInspected + ); + + const { accessibleFront } = this.props; + if (accessibleFront) { + ACCESSIBLE_EVENTS.forEach(event => + accessibleFront.off(event, this.update) + ); + } + } + + onAccessibleInspected() { + const { props } = this.refs; + if (props) { + props.refs.tree.focus(); + } + } + + update() { + const { dispatch, accessibleFront } = this.props; + if (accessibleFront.isDestroyed()) { + return; + } + + dispatch(updateDetails(accessibleFront)); + } + + setExpanded(item, isExpanded) { + const { expanded } = this.state; + + if (isExpanded) { + expanded.add(item.path); + } else { + expanded.delete(item.path); + } + + this.setState({ expanded }); + } + + async showHighlighter(nodeFront) { + if (!this.props.toolboxHighlighter) { + return; + } + + await this.props.toolboxHighlighter.highlight(nodeFront); + } + + async hideHighlighter() { + if (!this.props.toolboxHighlighter) { + return; + } + + await this.props.toolboxHighlighter.unhighlight(); + } + + showAccessibleHighlighter(accessibleFront) { + this.props.dispatch(unhighlight()); + this.props.highlightAccessible(accessibleFront); + } + + hideAccessibleHighlighter(accessibleFront) { + this.props.dispatch(unhighlight()); + this.props.unhighlightAccessible(accessibleFront); + } + + async selectNode(nodeFront, reason = "accessibility") { + if (gTelemetry) { + gTelemetry.scalarAdd(TELEMETRY_NODE_INSPECTED_COUNT, 1); + } + + if (!this.props.toolbox) { + return; + } + + const inspector = await this.props.toolbox.selectTool("inspector"); + inspector.selection.setNodeFront(nodeFront, reason); + } + + async selectAccessible(accessibleFront) { + if (!accessibleFront) { + return; + } + + await this.props.dispatch(select(accessibleFront)); + + const { props } = this.refs; + if (props) { + props.refs.tree.blur(); + } + await this.setState({ active: null, focused: null }); + + window.emit(EVENTS.NEW_ACCESSIBLE_FRONT_INSPECTED); + } + + openLink(link, e) { + openContentLink(link); + } + + renderItem(item, depth, focused, arrow, expanded) { + const object = item.contents; + const valueProps = { + object, + mode: MODE.TINY, + title: "Object", + openLink: this.openLink, + }; + + if (isNodeFront(object)) { + valueProps.defaultRep = ElementNode; + valueProps.onDOMNodeMouseOut = () => this.hideHighlighter(); + valueProps.onDOMNodeMouseOver = () => + this.showHighlighter(this.props.nodeFront); + + valueProps.inspectIconTitle = L10N.getStr( + "accessibility.accessible.selectNodeInInspector.title" + ); + valueProps.onInspectIconClick = () => + this.selectNode(this.props.nodeFront); + } else if (isAccessibleFront(object)) { + const target = findAccessibleTarget(this.props.relations, object.actor); + valueProps.defaultRep = AccessibleRep; + valueProps.onAccessibleMouseOut = () => + this.hideAccessibleHighlighter(target); + valueProps.onAccessibleMouseOver = () => + this.showAccessibleHighlighter(target); + valueProps.inspectIconTitle = L10N.getStr( + "accessibility.accessible.selectElement.title" + ); + valueProps.onInspectIconClick = (obj, e) => { + e.stopPropagation(); + this.selectAccessible(target); + }; + valueProps.separatorText = ""; + } else if (item.name === "relations") { + valueProps.defaultRep = Obj; + } else { + valueProps.noGrip = true; + } + + const classList = ["node", "object-node"]; + if (focused) { + classList.push("focused"); + } + + const depthPadding = depth * TREE_DEPTH_PADDING_INCREMENT; + + return AccessibleProperty( + { + object, + focused, + accessibleFrontActorID: this.props.accessibleFront.actorID, + }, + () => + div( + { + className: classList.join(" "), + style: { + paddingInlineStart: depthPadding, + inlineSize: `calc(var(--accessibility-properties-item-width) - ${depthPadding}px)`, + }, + onClick: e => { + if (e.target.classList.contains("theme-twisty")) { + this.setExpanded(item, !expanded); + } + }, + }, + arrow, + span({ className: "object-label" }, item.name), + span({ className: "object-delimiter" }, ":"), + span({ className: "object-value" }, Rep(valueProps) || "") + ) + ); + } + + render() { + const { expanded, active, focused } = this.state; + const { items, parents, accessibleFront, labelledby } = this.props; + + if (accessibleFront) { + return VirtualizedTree({ + ref: "props", + key: "accessible-properties", + itemHeight: TREE_ROW_HEIGHT, + getRoots: () => items, + getKey: item => item.path, + getParent: item => parents.get(item), + getChildren: item => item.children, + isExpanded: item => expanded.has(item.path), + onExpand: item => this.setExpanded(item, true), + onCollapse: item => this.setExpanded(item, false), + onFocus: item => { + if (this.state.focused !== item.path) { + this.setState({ focused: item.path }); + } + }, + onActivate: item => { + if (item == null) { + this.setState({ active: null }); + } else if (this.state.active !== item.path) { + this.setState({ active: item.path }); + } + }, + focused: findByPath(focused, items), + active: findByPath(active, items), + renderItem: this.renderItem, + labelledby, + }); + } + + return div( + { className: "info" }, + L10N.getStr("accessibility.accessible.notAvailable") + ); + } +} + +/** + * Match accessibility object from relations targets to the grip that's being activated. + * @param {Object} relations Object containing relations grouped by type and targets. + * @param {String} actorID Actor ID to match to the relation target. + * @return {Object} Accessible front that matches the relation target. + */ +const findAccessibleTarget = (relations, actorID) => { + for (const relationType in relations) { + let targets = relations[relationType]; + targets = Array.isArray(targets) ? targets : [targets]; + for (const target of targets) { + if (target.actorID === actorID) { + return target; + } + } + } + + return null; +}; + +/** + * Find an item based on a given path. + * @param {String} path + * Key of the item to be looked up. + * @param {Array} items + * Accessibility properties array. + * @return {Object?} + * Possibly found item. + */ +const findByPath = (path, items) => { + for (const item of items) { + if (item.path === path) { + return item; + } + + const found = findByPath(path, item.children); + if (found) { + return found; + } + } + return null; +}; + +/** + * Check if a given property is a DOMNode front. + * @param {Object?} value A property to check for being a DOMNode. + * @return {Boolean} A flag that indicates whether a property is a DOMNode. + */ +const isNodeFront = value => value && value.typeName === "domnode"; + +/** + * Check if a given property is an Accessible front. + * @param {Object?} value A property to check for being an Accessible. + * @return {Boolean} A flag that indicates whether a property is an Accessible. + */ +const isAccessibleFront = value => value && value.typeName === "accessible"; + +/** + * While waiting for a reps fix in https://github.com/firefox-devtools/reps/issues/92, + * translate accessibleFront to a grip-like object that can be used with an Accessible + * rep. + * + * @params {accessibleFront} accessibleFront + * The AccessibleFront for which we want to create a grip-like object. + * @returns {Object} a grip-like object that can be used with Reps. + */ +const translateAccessibleFrontToGrip = accessibleFront => ({ + actor: accessibleFront.actorID, + typeName: accessibleFront.typeName, + preview: { + name: accessibleFront.name, + role: accessibleFront.role, + // All the grid containers are assumed to be in the Accessibility tree. + isConnected: true, + }, +}); + +const translateNodeFrontToGripWrapper = nodeFront => ({ + ...translateNodeFrontToGrip(nodeFront), + typeName: nodeFront.typeName, +}); + +/** + * Build props ingestible by VirtualizedTree component. + * @param {Object} props Component properties to be processed. + * @param {String} parentPath Unique path that is used to identify a Tree Node. + * @return {Object} Processed properties. + */ +const makeItemsForDetails = (props, parentPath) => + Object.getOwnPropertyNames(props).map(name => { + let children = []; + const path = `${parentPath}/${name}`; + let contents = props[name]; + + if (contents) { + if (isNodeFront(contents)) { + contents = translateNodeFrontToGripWrapper(contents); + name = "DOMNode"; + } else if (isAccessibleFront(contents)) { + contents = translateAccessibleFrontToGrip(contents); + } else if (Array.isArray(contents) || typeof contents === "object") { + children = makeItemsForDetails(contents, path); + } + } + + return { name, path, contents, children }; + }); + +const makeParentMap = items => { + const map = new WeakMap(); + + function _traverse(item) { + if (item.children.length) { + for (const child of item.children) { + map.set(child, item); + _traverse(child); + } + } + } + + items.forEach(_traverse); + return map; +}; + +const mapStateToProps = ({ details }) => { + const { + accessible: accessibleFront, + DOMNode: nodeFront, + relations, + } = details; + if (!accessibleFront || !nodeFront) { + return {}; + } + + const items = makeItemsForDetails( + ORDERED_PROPS.reduce((props, key) => { + if (key === "DOMNode") { + props.nodeFront = nodeFront; + } else if (key === "relations") { + props.relations = relations; + } else { + props[key] = accessibleFront[key]; + } + + return props; + }, {}), + "" + ); + const parents = makeParentMap(items); + + return { accessibleFront, nodeFront, items, parents, relations }; +}; + +module.exports = connect(mapStateToProps)(Accessible); diff --git a/devtools/client/accessibility/components/AuditController.js b/devtools/client/accessibility/components/AuditController.js new file mode 100644 index 0000000000..e6ada9a0a9 --- /dev/null +++ b/devtools/client/accessibility/components/AuditController.js @@ -0,0 +1,90 @@ +/* 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 React = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +class AuditController extends React.Component { + static get propTypes() { + return { + accessibleFront: PropTypes.object.isRequired, + children: PropTypes.any, + }; + } + + constructor(props) { + super(props); + + const { + accessibleFront: { checks }, + } = props; + this.state = { + checks, + }; + + this.onAudited = this.onAudited.bind(this); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillMount() { + const { accessibleFront } = this.props; + accessibleFront.on("audited", this.onAudited); + } + + componentDidMount() { + this.maybeRequestAudit(); + } + + componentDidUpdate() { + this.maybeRequestAudit(); + } + + componentWillUnmount() { + const { accessibleFront } = this.props; + accessibleFront.off("audited", this.onAudited); + } + + onAudited() { + const { accessibleFront } = this.props; + if (accessibleFront.isDestroyed()) { + // Accessible front is being removed, stop listening for 'audited' events. + accessibleFront.off("audited", this.onAudited); + return; + } + + this.setState({ checks: accessibleFront.checks }); + } + + maybeRequestAudit() { + const { accessibleFront } = this.props; + if (accessibleFront.isDestroyed()) { + // Accessible front is being removed, stop listening for 'audited' events. + accessibleFront.off("audited", this.onAudited); + return; + } + + if (accessibleFront.checks) { + return; + } + + accessibleFront.audit().catch(error => { + // If the actor was destroyed (due to a connection closed for instance) do + // nothing, otherwise log a warning + if (!accessibleFront.isDestroyed()) { + console.warn(error); + } + }); + } + + render() { + const { children } = this.props; + const { checks } = this.state; + + return React.Children.only(React.cloneElement(children, { checks })); + } +} + +module.exports = AuditController; diff --git a/devtools/client/accessibility/components/AuditFilter.js b/devtools/client/accessibility/components/AuditFilter.js new file mode 100644 index 0000000000..361a1c30a7 --- /dev/null +++ b/devtools/client/accessibility/components/AuditFilter.js @@ -0,0 +1,91 @@ +/* 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 React = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +const { + isFiltered, +} = require("resource://devtools/client/accessibility/utils/audit.js"); +const { + FILTERS, +} = require("resource://devtools/client/accessibility/constants.js"); +const { + accessibility: { + AUDIT_TYPE, + SCORES: { BEST_PRACTICES, FAIL, WARNING }, + }, +} = require("resource://devtools/shared/constants.js"); + +function validateCheck({ error, score }) { + return !error && [BEST_PRACTICES, FAIL, WARNING].includes(score); +} + +const AUDIT_TYPE_TO_FILTER = { + [AUDIT_TYPE.CONTRAST]: { + filterKey: FILTERS.CONTRAST, + validator: validateCheck, + }, + [AUDIT_TYPE.KEYBOARD]: { + filterKey: FILTERS.KEYBOARD, + validator: validateCheck, + }, + [AUDIT_TYPE.TEXT_LABEL]: { + filterKey: FILTERS.TEXT_LABEL, + validator: validateCheck, + }, +}; + +class AuditFilter extends React.Component { + static get propTypes() { + return { + checks: PropTypes.object, + children: PropTypes.any, + filters: PropTypes.object.isRequired, + }; + } + + isVisible(filters) { + return !isFiltered(filters); + } + + shouldHide() { + const { filters, checks } = this.props; + if (this.isVisible(filters)) { + return false; + } + + if (!checks || Object.values(checks).every(check => check == null)) { + return true; + } + + for (const type in checks) { + if ( + AUDIT_TYPE_TO_FILTER[type] && + checks[type] && + filters[AUDIT_TYPE_TO_FILTER[type].filterKey] && + AUDIT_TYPE_TO_FILTER[type].validator(checks[type]) + ) { + return false; + } + } + + return true; + } + + render() { + return this.shouldHide() ? null : this.props.children; + } +} + +const mapStateToProps = ({ audit: { filters } }) => { + return { filters }; +}; + +module.exports = connect(mapStateToProps)(AuditFilter); diff --git a/devtools/client/accessibility/components/AuditProgressOverlay.js b/devtools/client/accessibility/components/AuditProgressOverlay.js new file mode 100644 index 0000000000..3c16998bcf --- /dev/null +++ b/devtools/client/accessibility/components/AuditProgressOverlay.js @@ -0,0 +1,92 @@ +/* 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 React = require("resource://devtools/client/shared/vendor/react.js"); +const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +const FluentReact = require("resource://devtools/client/shared/vendor/fluent-react.js"); +const Localized = React.createFactory(FluentReact.Localized); + +/** + * Helper functional component to render an accessible text progressbar. + * @param {Object} props + * - id for the progressbar element + * - fluentId: localized string id + */ +function TextProgressBar({ id, fluentId }) { + return Localized( + { + id: fluentId, + attrs: { "aria-valuetext": true }, + }, + ReactDOM.span({ + id, + key: id, + role: "progressbar", + }) + ); +} + +class AuditProgressOverlay extends React.Component { + static get propTypes() { + return { + auditing: PropTypes.array.isRequired, + total: PropTypes.number, + percentage: PropTypes.number, + }; + } + + render() { + const { auditing, percentage, total } = this.props; + if (auditing.length === 0) { + return null; + } + + const id = "audit-progress-container"; + + if (total == null) { + return TextProgressBar({ + id, + fluentId: "accessibility-progress-initializing", + }); + } + + if (percentage === 100) { + return TextProgressBar({ + id, + fluentId: "accessibility-progress-finishing", + }); + } + + return ReactDOM.span( + { + id, + key: id, + }, + Localized({ + id: "accessibility-progress-progressbar", + $nodeCount: total, + }), + ReactDOM.progress({ + max: 100, + value: percentage, + className: "audit-progress-progressbar", + "aria-labelledby": id, + }) + ); + } +} + +const mapStateToProps = ({ audit: { auditing, progress } }) => { + const { total, percentage } = progress || {}; + return { auditing, total, percentage }; +}; + +module.exports = connect(mapStateToProps)(AuditProgressOverlay); diff --git a/devtools/client/accessibility/components/Badge.js b/devtools/client/accessibility/components/Badge.js new file mode 100644 index 0000000000..207bb96f55 --- /dev/null +++ b/devtools/client/accessibility/components/Badge.js @@ -0,0 +1,40 @@ +/* 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"; + +// React +const { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + span, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +class Badge extends Component { + static get propTypes() { + return { + score: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + ariaLabel: PropTypes.string, + tooltip: PropTypes.string, + }; + } + + render() { + const { score, label, ariaLabel, tooltip } = this.props; + + return span( + { + className: `audit-badge badge`, + "data-score": score, + title: tooltip, + "aria-label": ariaLabel || label, + }, + label + ); + } +} + +module.exports = Badge; diff --git a/devtools/client/accessibility/components/Badges.js b/devtools/client/accessibility/components/Badges.js new file mode 100644 index 0000000000..00079d21b6 --- /dev/null +++ b/devtools/client/accessibility/components/Badges.js @@ -0,0 +1,88 @@ +/* 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"; + +// React +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + span, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const { + L10N, +} = require("resource://devtools/client/accessibility/utils/l10n.js"); + +const { + accessibility: { AUDIT_TYPE }, +} = require("resource://devtools/shared/constants.js"); + +loader.lazyGetter(this, "ContrastBadge", () => + createFactory( + require("resource://devtools/client/accessibility/components/ContrastBadge.js") + ) +); + +loader.lazyGetter(this, "KeyboardBadge", () => + createFactory( + require("resource://devtools/client/accessibility/components/KeyboardBadge.js") + ) +); + +loader.lazyGetter(this, "TextLabelBadge", () => + createFactory( + require("resource://devtools/client/accessibility/components/TextLabelBadge.js") + ) +); + +function getComponentForAuditType(type) { + const auditTypeToComponentMap = { + [AUDIT_TYPE.CONTRAST]: ContrastBadge, + [AUDIT_TYPE.KEYBOARD]: KeyboardBadge, + [AUDIT_TYPE.TEXT_LABEL]: TextLabelBadge, + }; + + return auditTypeToComponentMap[type]; +} + +class Badges extends Component { + static get propTypes() { + return { + checks: PropTypes.object, + }; + } + + render() { + const { checks } = this.props; + if (!checks) { + return null; + } + + const items = []; + for (const type in checks) { + const component = getComponentForAuditType(type); + if (checks[type] && component) { + items.push(component({ key: type, ...checks[type] })); + } + } + + if (items.length === 0) { + return null; + } + + return span( + { + className: "badges", + role: "group", + "aria-label": L10N.getStr("accessibility.badges"), + }, + items + ); + } +} + +module.exports = Badges; diff --git a/devtools/client/accessibility/components/Button.js b/devtools/client/accessibility/components/Button.js new file mode 100644 index 0000000000..92cd90a56c --- /dev/null +++ b/devtools/client/accessibility/components/Button.js @@ -0,0 +1,112 @@ +/* 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 { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + button, + span, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const defaultProps = { + disabled: false, + busy: false, + title: null, + children: null, + className: "", +}; + +/** + * Button component that handles keyboard in an accessible way. When user + * uses the mouse to hover/click on the button, there is no focus + * highlighting. However if the user uses a keyboard to focus on the button, + * it will have focus highlighting in the form of an outline. + */ +class Button extends Component { + static get propTypes() { + return { + disabled: PropTypes.bool, + busy: PropTypes.bool, + title: PropTypes.string, + children: PropTypes.string, + className: PropTypes.string, + }; + } + + static get defaultProps() { + return defaultProps; + } + + render() { + const className = [ + ...this.props.className.split(" "), + "devtools-button", + ].join(" "); + const props = Object.assign({}, this.props, { + className, + "aria-busy": this.props.busy.toString(), + busy: this.props.busy.toString(), + }); + + const classList = ["btn-content"]; + if (this.props.busy) { + classList.push("devtools-throbber"); + } + + return button( + props, + span( + { + className: classList.join(" "), + tabIndex: -1, + }, + this.props.children + ) + ); + } +} + +function ToggleButton(props) { + const { + active, + busy, + disabled, + label, + className, + onClick, + onKeyDown, + tooltip, + } = props; + const classList = [...className.split(" "), "toggle-button"]; + + if (active) { + classList.push("checked"); + } + + if (busy) { + classList.push("devtools-throbber"); + } + + return button( + { + disabled, + "aria-pressed": active === true, + "aria-busy": busy, + className: classList.join(" "), + onClick, + onKeyDown, + title: tooltip, + }, + label + ); +} + +module.exports = { + Button, + ToggleButton, +}; diff --git a/devtools/client/accessibility/components/Check.js b/devtools/client/accessibility/components/Check.js new file mode 100644 index 0000000000..3ca6c9c39a --- /dev/null +++ b/devtools/client/accessibility/components/Check.js @@ -0,0 +1,157 @@ +/* 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"; + +// React +const { + Component, + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const FluentReact = require("resource://devtools/client/shared/vendor/fluent-react.js"); +const Localized = createFactory(FluentReact.Localized); + +const { openDocLink } = require("resource://devtools/client/shared/link.js"); + +const { + accessibility: { + SCORES: { BEST_PRACTICES, FAIL, WARNING }, + }, +} = require("resource://devtools/shared/constants.js"); + +/** + * A map of accessibility scores to the text descriptions of check icons. + */ +const SCORE_TO_ICON_MAP = { + [BEST_PRACTICES]: { + l10nId: "accessibility-best-practices", + src: "chrome://devtools/skin/images/info.svg", + }, + [FAIL]: { + l10nId: "accessibility-fail", + src: "chrome://devtools/skin/images/error.svg", + }, + [WARNING]: { + l10nId: "accessibility-warning", + src: "chrome://devtools/skin/images/alert.svg", + }, +}; + +/** + * Localized "Learn more" link that opens a new tab with relevant documentation. + */ +class LearnMoreClass extends PureComponent { + static get propTypes() { + return { + href: PropTypes.string, + l10nId: PropTypes.string.isRequired, + onClick: PropTypes.func, + }; + } + + static get defaultProps() { + return { + href: "#", + l10nId: null, + onClick: LearnMoreClass.openDocOnClick, + }; + } + + static openDocOnClick(event) { + event.preventDefault(); + openDocLink(event.target.href); + } + + render() { + const { href, l10nId, onClick } = this.props; + const className = "link"; + + return Localized({ id: l10nId }, ReactDOM.a({ className, href, onClick })); + } +} + +const LearnMore = createFactory(LearnMoreClass); + +/** + * Renders icon with text description for the accessibility check. + * + * @param {Object} + * Options: + * - score: value from SCORES from "devtools/shared/constants" + */ +function Icon({ score }) { + const { l10nId, src } = SCORE_TO_ICON_MAP[score]; + + return Localized( + { id: l10nId, attrs: { alt: true } }, + ReactDOM.img({ src, "data-score": score, className: "icon" }) + ); +} + +/** + * Renders text description of the accessibility check. + * + * @param {Object} + * Options: + * - args: arguments for fluent localized string + * - href: url for the learn more link pointing to MDN + * - l10nId: fluent localization id + */ +function Annotation({ args, href, l10nId }) { + return Localized( + { + id: l10nId, + a: LearnMore({ l10nId: "accessibility-learn-more", href }), + ...args, + }, + ReactDOM.p({ className: "accessibility-check-annotation" }) + ); +} + +/** + * Component for rendering a check for accessibliity checks section, + * warnings and best practices suggestions association with a given + * accessibility object in the accessibility tree. + */ +class Check extends Component { + static get propTypes() { + return { + getAnnotation: PropTypes.func.isRequired, + id: PropTypes.string.isRequired, + issue: PropTypes.string.isRequired, + score: PropTypes.string.isRequired, + }; + } + + render() { + const { getAnnotation, id, issue, score } = this.props; + + return ReactDOM.div( + { + role: "presentation", + tabIndex: "-1", + className: "accessibility-check", + }, + Localized( + { + id, + }, + ReactDOM.h3({ className: "accessibility-check-header" }) + ), + ReactDOM.div( + { + role: "presentation", + tabIndex: "-1", + }, + Icon({ score }), + Annotation({ ...getAnnotation(issue) }) + ) + ); + } +} + +module.exports = Check; diff --git a/devtools/client/accessibility/components/Checks.js b/devtools/client/accessibility/components/Checks.js new file mode 100644 index 0000000000..f2b4e4835a --- /dev/null +++ b/devtools/client/accessibility/components/Checks.js @@ -0,0 +1,117 @@ +/* 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"; + +// React +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + div, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const List = createFactory( + require("resource://devtools/client/shared/components/List.js").List +); +const ColorContrastCheck = createFactory( + require("resource://devtools/client/accessibility/components/ColorContrastAccessibility.js") + .ColorContrastCheck +); +const TextLabelCheck = createFactory( + require("resource://devtools/client/accessibility/components/TextLabelCheck.js") +); +const KeyboardCheck = createFactory( + require("resource://devtools/client/accessibility/components/KeyboardCheck.js") +); +const { + L10N, +} = require("resource://devtools/client/accessibility/utils/l10n.js"); + +const { + accessibility: { AUDIT_TYPE }, +} = require("resource://devtools/shared/constants.js"); + +function EmptyChecks() { + return div( + { + className: "checks-empty", + role: "presentation", + tabIndex: "-1", + }, + L10N.getStr("accessibility.checks.empty2") + ); +} + +// Component that is responsible for rendering accessible audit data in the a11y panel +// sidebar. +class Checks extends Component { + static get propTypes() { + return { + audit: PropTypes.object, + labelledby: PropTypes.string.isRequired, + }; + } + + [AUDIT_TYPE.CONTRAST](contrastRatio) { + return ColorContrastCheck(contrastRatio); + } + + [AUDIT_TYPE.KEYBOARD](keyboardCheck) { + return KeyboardCheck(keyboardCheck); + } + + [AUDIT_TYPE.TEXT_LABEL](textLabelCheck) { + return TextLabelCheck(textLabelCheck); + } + + render() { + const { audit, labelledby } = this.props; + if (!audit) { + return EmptyChecks(); + } + + const items = []; + for (const name in audit) { + // There are going to be various audit reports for this object, sent by the server. + // Iterate over them and delegate rendering to the method with the corresponding + // name. + if (audit[name] && this[name]) { + items.push({ + component: this[name](audit[name]), + className: name, + key: name, + }); + } + } + + if (items.length === 0) { + return EmptyChecks(); + } + + return div( + { + className: "checks", + role: "presentation", + tabIndex: "-1", + }, + List({ items, labelledby }) + ); + } +} + +const mapStateToProps = ({ details, ui }) => { + const { audit } = details; + if (!audit) { + return {}; + } + + return { audit }; +}; + +module.exports = connect(mapStateToProps)(Checks); diff --git a/devtools/client/accessibility/components/ColorContrastAccessibility.js b/devtools/client/accessibility/components/ColorContrastAccessibility.js new file mode 100644 index 0000000000..117ab67593 --- /dev/null +++ b/devtools/client/accessibility/components/ColorContrastAccessibility.js @@ -0,0 +1,229 @@ +/* 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 { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + div, + span, + h3, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const LearnMoreLink = createFactory( + require("resource://devtools/client/accessibility/components/LearnMoreLink.js") +); + +const { + A11Y_CONTRAST_LEARN_MORE_LINK, +} = require("resource://devtools/client/accessibility/constants.js"); +const { + L10N, +} = require("resource://devtools/client/accessibility/utils/l10n.js"); + +/** + * Component that renders a colour contrast value along with a swatch preview of what the + * text and background colours are. + */ +class ContrastValueClass extends Component { + static get propTypes() { + return { + backgroundColor: PropTypes.array.isRequired, + color: PropTypes.array.isRequired, + value: PropTypes.number.isRequired, + score: PropTypes.string, + }; + } + + render() { + const { backgroundColor, color, value, score } = this.props; + + const className = ["accessibility-contrast-value", score].join(" "); + + return span( + { + className, + role: "presentation", + style: { + "--accessibility-contrast-color": `rgba(${color})`, + "--accessibility-contrast-bg": `rgba(${backgroundColor})`, + }, + }, + value.toFixed(2) + ); + } +} + +const ContrastValue = createFactory(ContrastValueClass); + +/** + * Component that renders labeled colour contrast values together with the large text + * indiscator. + */ +class ColorContrastAccessibilityClass extends Component { + static get propTypes() { + return { + error: PropTypes.string, + isLargeText: PropTypes.bool.isRequired, + color: PropTypes.array.isRequired, + value: PropTypes.number, + min: PropTypes.number, + max: PropTypes.number, + backgroundColor: PropTypes.array, + backgroundColorMin: PropTypes.array, + backgroundColorMax: PropTypes.array, + score: PropTypes.string, + scoreMin: PropTypes.string, + scoreMax: PropTypes.string, + }; + } + + render() { + const { + error, + isLargeText, + color, + value, + backgroundColor, + score, + min, + backgroundColorMin, + scoreMin, + max, + backgroundColorMax, + scoreMax, + } = this.props; + + const children = []; + + if (error) { + children.push( + span( + { + className: "accessibility-color-contrast-error", + role: "presentation", + }, + L10N.getStr("accessibility.contrast.error") + ) + ); + + return div( + { + role: "presentation", + tabIndex: "-1", + className: "accessibility-color-contrast", + }, + ...children + ); + } + + if (value) { + children.push(ContrastValue({ score, color, backgroundColor, value })); + } else { + children.push( + ContrastValue({ + score: scoreMin, + color, + backgroundColor: backgroundColorMin, + value: min, + }), + div({ + role: "presentation", + tabIndex: "-1", + className: "accessibility-color-contrast-separator", + }), + ContrastValue({ + score: scoreMax, + color, + backgroundColor: backgroundColorMax, + value: max, + }) + ); + } + + if (isLargeText) { + children.push( + span( + { + className: "accessibility-color-contrast-large-text", + role: "presentation", + title: L10N.getStr("accessibility.contrast.large.title"), + }, + L10N.getStr("accessibility.contrast.large.text") + ) + ); + } + + return div( + { + role: "presentation", + tabIndex: "-1", + className: "accessibility-color-contrast", + }, + ...children + ); + } +} + +const ColorContrastAccessibility = createFactory( + ColorContrastAccessibilityClass +); + +class ContrastAnnotationClass extends Component { + static get propTypes() { + return { + score: PropTypes.string, + }; + } + + render() { + const { score } = this.props; + + return LearnMoreLink({ + className: "accessibility-check-annotation", + href: A11Y_CONTRAST_LEARN_MORE_LINK, + learnMoreStringKey: "accessibility.learnMore", + l10n: L10N, + messageStringKey: `accessibility.contrast.annotation.${score}`, + }); + } +} + +const ContrastAnnotation = createFactory(ContrastAnnotationClass); + +class ColorContrastCheck extends Component { + static get propTypes() { + return { + error: PropTypes.string.isRequired, + }; + } + + render() { + const { error } = this.props; + + return div( + { + role: "presentation", + tabIndex: "-1", + className: "accessibility-check", + }, + h3( + { + className: "accessibility-check-header", + }, + L10N.getStr("accessibility.contrast.header") + ), + ColorContrastAccessibility(this.props), + !error && ContrastAnnotation(this.props) + ); + } +} + +module.exports = { + ColorContrastAccessibility: ColorContrastAccessibilityClass, + ColorContrastCheck, +}; diff --git a/devtools/client/accessibility/components/ContrastBadge.js b/devtools/client/accessibility/components/ContrastBadge.js new file mode 100644 index 0000000000..ee37958f1e --- /dev/null +++ b/devtools/client/accessibility/components/ContrastBadge.js @@ -0,0 +1,55 @@ +/* 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"; + +// React +const { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const { + L10N, +} = require("resource://devtools/client/accessibility/utils/l10n.js"); + +const { + accessibility: { SCORES }, +} = require("resource://devtools/shared/constants.js"); + +loader.lazyGetter(this, "Badge", () => + createFactory( + require("resource://devtools/client/accessibility/components/Badge.js") + ) +); + +/** + * Component for rendering a badge for contrast accessibliity check + * failures association with a given accessibility object in the accessibility + * tree. + */ +class ContrastBadge extends PureComponent { + static get propTypes() { + return { + error: PropTypes.string, + score: PropTypes.string, + }; + } + + render() { + const { error, score } = this.props; + if (error || score !== SCORES.FAIL) { + return null; + } + + return Badge({ + score, + label: L10N.getStr("accessibility.badge.contrast"), + ariaLabel: L10N.getStr("accessibility.badge.contrast.warning"), + tooltip: L10N.getStr("accessibility.badge.contrast.tooltip"), + }); + } +} + +module.exports = ContrastBadge; diff --git a/devtools/client/accessibility/components/Description.js b/devtools/client/accessibility/components/Description.js new file mode 100644 index 0000000000..a31dbcbfa6 --- /dev/null +++ b/devtools/client/accessibility/components/Description.js @@ -0,0 +1,56 @@ +/* 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"; + +// React & Redux +const { + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + div, + p, + img, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const LearnMoreLink = createFactory( + require("resource://devtools/client/accessibility/components/LearnMoreLink.js") +); + +// Localization +const { + L10N, +} = require("resource://devtools/client/accessibility/utils/l10n.js"); + +const { + A11Y_LEARN_MORE_LINK, +} = require("resource://devtools/client/accessibility/constants.js"); + +/** + * Landing UI for the accessibility panel when Accessibility features are + * deactivated. + */ +function Description() { + return div( + { className: "description", role: "presentation", tabIndex: "-1" }, + div( + { className: "general", role: "presentation", tabIndex: "-1" }, + img({ + src: "chrome://devtools/skin/images/accessibility.svg", + alt: L10N.getStr("accessibility.logo"), + }), + div( + { role: "presentation", tabIndex: "-1" }, + LearnMoreLink({ + href: A11Y_LEARN_MORE_LINK, + learnMoreStringKey: "accessibility.learnMore", + l10n: L10N, + messageStringKey: "accessibility.description.general.p1", + }), + p({}, L10N.getStr("accessibility.enable.disabledTitle")) + ) + ) + ); +} + +// Exports from this module +exports.Description = Description; diff --git a/devtools/client/accessibility/components/DisplayTabbingOrder.js b/devtools/client/accessibility/components/DisplayTabbingOrder.js new file mode 100644 index 0000000000..f6e3fdf453 --- /dev/null +++ b/devtools/client/accessibility/components/DisplayTabbingOrder.js @@ -0,0 +1,79 @@ +/* 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"; + +// React +const { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + label, + input, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const { + L10N, +} = require("resource://devtools/client/accessibility/utils/l10n.js"); + +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const { + updateDisplayTabbingOrder, +} = require("resource://devtools/client/accessibility/actions/ui.js"); + +class DisplayTabbingOrder extends PureComponent { + static get propTypes() { + return { + dispatch: PropTypes.func.isRequired, + tabbingOrderDisplayed: PropTypes.bool.isRequired, + }; + } + + constructor(props) { + super(props); + + this.state = { + disabled: false, + }; + + this.onChange = this.onChange.bind(this); + } + + async onChange() { + const { dispatch, tabbingOrderDisplayed } = this.props; + + this.setState({ disabled: true }); + await dispatch(updateDisplayTabbingOrder(!tabbingOrderDisplayed)); + this.setState({ disabled: false }); + } + + render() { + const { tabbingOrderDisplayed } = this.props; + return label( + { + className: + "accessibility-tabbing-order devtools-checkbox-label devtools-ellipsis-text", + htmlFor: "devtools-display-tabbing-order-checkbox", + title: L10N.getStr("accessibility.toolbar.displayTabbingOrder.tooltip"), + }, + input({ + id: "devtools-display-tabbing-order-checkbox", + className: "devtools-checkbox", + type: "checkbox", + checked: tabbingOrderDisplayed, + disabled: this.state.disabled, + onChange: this.onChange, + }), + L10N.getStr("accessibility.toolbar.displayTabbingOrder.label") + ); + } +} + +const mapStateToProps = ({ ui: { tabbingOrderDisplayed } }) => ({ + tabbingOrderDisplayed, +}); + +module.exports = connect(mapStateToProps)(DisplayTabbingOrder); diff --git a/devtools/client/accessibility/components/KeyboardBadge.js b/devtools/client/accessibility/components/KeyboardBadge.js new file mode 100644 index 0000000000..ac6b6b8841 --- /dev/null +++ b/devtools/client/accessibility/components/KeyboardBadge.js @@ -0,0 +1,56 @@ +/* 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"; + +// React +const { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const { + L10N, +} = require("resource://devtools/client/accessibility/utils/l10n.js"); + +const { + accessibility: { + SCORES: { BEST_PRACTICES, FAIL, WARNING }, + }, +} = require("resource://devtools/shared/constants.js"); + +loader.lazyGetter(this, "Badge", () => + createFactory( + require("resource://devtools/client/accessibility/components/Badge.js") + ) +); + +/** + * Component for rendering a badge for keyboard accessibliity check failures + * association with a given accessibility object in the accessibility tree. + */ +class KeyboardBadge extends PureComponent { + static get propTypes() { + return { + error: PropTypes.string, + score: PropTypes.string, + }; + } + + render() { + const { error, score } = this.props; + if (error || ![BEST_PRACTICES, FAIL, WARNING].includes(score)) { + return null; + } + + return Badge({ + score, + label: L10N.getStr("accessibility.badge.keyboard"), + tooltip: L10N.getStr("accessibility.badge.keyboard.tooltip"), + }); + } +} + +module.exports = KeyboardBadge; diff --git a/devtools/client/accessibility/components/KeyboardCheck.js b/devtools/client/accessibility/components/KeyboardCheck.js new file mode 100644 index 0000000000..0109dd22b0 --- /dev/null +++ b/devtools/client/accessibility/components/KeyboardCheck.js @@ -0,0 +1,94 @@ +/* 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"; + +// React +const { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const Check = createFactory( + require("resource://devtools/client/accessibility/components/Check.js") +); + +const { + A11Y_KEYBOARD_LINKS, +} = require("resource://devtools/client/accessibility/constants.js"); +const { + accessibility: { + AUDIT_TYPE: { KEYBOARD }, + ISSUE_TYPE: { + [KEYBOARD]: { + FOCUSABLE_NO_SEMANTICS, + FOCUSABLE_POSITIVE_TABINDEX, + INTERACTIVE_NO_ACTION, + INTERACTIVE_NOT_FOCUSABLE, + MOUSE_INTERACTIVE_ONLY, + NO_FOCUS_VISIBLE, + }, + }, + }, +} = require("resource://devtools/shared/constants.js"); + +/** + * A map from text label issues to annotation component properties. + */ +const ISSUE_TO_ANNOTATION_MAP = { + [FOCUSABLE_NO_SEMANTICS]: { + href: A11Y_KEYBOARD_LINKS.FOCUSABLE_NO_SEMANTICS, + l10nId: "accessibility-keyboard-issue-semantics", + }, + [FOCUSABLE_POSITIVE_TABINDEX]: { + href: A11Y_KEYBOARD_LINKS.FOCUSABLE_POSITIVE_TABINDEX, + l10nId: "accessibility-keyboard-issue-tabindex", + args: { + get code() { + return ReactDOM.code({}, "tabindex"); + }, + }, + }, + [INTERACTIVE_NO_ACTION]: { + href: A11Y_KEYBOARD_LINKS.INTERACTIVE_NO_ACTION, + l10nId: "accessibility-keyboard-issue-action", + }, + [INTERACTIVE_NOT_FOCUSABLE]: { + href: A11Y_KEYBOARD_LINKS.INTERACTIVE_NOT_FOCUSABLE, + l10nId: "accessibility-keyboard-issue-focusable", + }, + [MOUSE_INTERACTIVE_ONLY]: { + href: A11Y_KEYBOARD_LINKS.MOUSE_INTERACTIVE_ONLY, + l10nId: "accessibility-keyboard-issue-mouse-only", + }, + [NO_FOCUS_VISIBLE]: { + href: A11Y_KEYBOARD_LINKS.NO_FOCUS_VISIBLE, + l10nId: "accessibility-keyboard-issue-focus-visible", + }, +}; + +/** + * Component for rendering a check for text label accessibliity check failures, + * warnings and best practices suggestions association with a given + * accessibility object in the accessibility tree. + */ +class KeyboardCheck extends PureComponent { + static get propTypes() { + return { + issue: PropTypes.string.isRequired, + score: PropTypes.string.isRequired, + }; + } + + render() { + return Check({ + ...this.props, + getAnnotation: issue => ISSUE_TO_ANNOTATION_MAP[issue], + id: "accessibility-keyboard-header", + }); + } +} + +module.exports = KeyboardCheck; diff --git a/devtools/client/accessibility/components/LearnMoreLink.js b/devtools/client/accessibility/components/LearnMoreLink.js new file mode 100644 index 0000000000..2a21df4ea5 --- /dev/null +++ b/devtools/client/accessibility/components/LearnMoreLink.js @@ -0,0 +1,76 @@ +/* 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 { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + p, + a, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const { openDocLink } = require("resource://devtools/client/shared/link.js"); + +/** + * Localization friendly component for rendering a block of text with a "Learn more" link + * as a part of it. + */ +class LearnMoreLink extends Component { + static get propTypes() { + return { + className: PropTypes.string, + href: PropTypes.string, + learnMoreStringKey: PropTypes.string.isRequired, + l10n: PropTypes.object.isRequired, + messageStringKey: PropTypes.string.isRequired, + onClick: PropTypes.func, + }; + } + + static get defaultProps() { + return { + href: "#", + learnMoreStringKey: null, + l10n: null, + messageStringKey: null, + onClick: LearnMoreLink.openDocOnClick, + }; + } + + static openDocOnClick(event) { + event.preventDefault(); + openDocLink(event.target.href); + } + + render() { + const { + className, + href, + learnMoreStringKey, + l10n, + messageStringKey, + onClick, + } = this.props; + const learnMoreString = l10n.getStr(learnMoreStringKey); + const messageString = l10n.getFormatStr(messageStringKey, learnMoreString); + + // Split the paragraph string with the link as a separator, and include the link into + // results. + const re = new RegExp(`(\\b${learnMoreString}\\b)`); + const contents = messageString.split(re); + contents[1] = a({ className: "link", href, onClick }, contents[1]); + + return p( + { + className, + }, + ...contents + ); + } +} + +module.exports = LearnMoreLink; diff --git a/devtools/client/accessibility/components/MainFrame.js b/devtools/client/accessibility/components/MainFrame.js new file mode 100644 index 0000000000..d918dc7be0 --- /dev/null +++ b/devtools/client/accessibility/components/MainFrame.js @@ -0,0 +1,245 @@ +/* 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"; + +// React & Redux +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + span, + div, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const { + enable, + reset, + updateCanBeEnabled, + updateCanBeDisabled, +} = require("resource://devtools/client/accessibility/actions/ui.js"); + +// Localization +const FluentReact = require("resource://devtools/client/shared/vendor/fluent-react.js"); +const LocalizationProvider = createFactory(FluentReact.LocalizationProvider); + +// Constants +const { + SIDEBAR_WIDTH, + PORTRAIT_MODE_WIDTH, +} = require("resource://devtools/client/accessibility/constants.js"); + +// Accessibility Panel +const AccessibilityTree = createFactory( + require("resource://devtools/client/accessibility/components/AccessibilityTree.js") +); +const AuditProgressOverlay = createFactory( + require("resource://devtools/client/accessibility/components/AuditProgressOverlay.js") +); +const Description = createFactory( + require("resource://devtools/client/accessibility/components/Description.js") + .Description +); +const RightSidebar = createFactory( + require("resource://devtools/client/accessibility/components/RightSidebar.js") +); +const Toolbar = createFactory( + require("resource://devtools/client/accessibility/components/Toolbar.js") + .Toolbar +); +const SplitBox = createFactory( + require("resource://devtools/client/shared/components/splitter/SplitBox.js") +); + +/** + * Renders basic layout of the Accessibility panel. The Accessibility panel + * content consists of two main parts: tree and sidebar. + */ +class MainFrame extends Component { + static get propTypes() { + return { + fluentBundles: PropTypes.array.isRequired, + enabled: PropTypes.bool.isRequired, + dispatch: PropTypes.func.isRequired, + auditing: PropTypes.array.isRequired, + supports: PropTypes.object, + toolbox: PropTypes.object.isRequired, + getAccessibilityTreeRoot: PropTypes.func.isRequired, + startListeningForAccessibilityEvents: PropTypes.func.isRequired, + stopListeningForAccessibilityEvents: PropTypes.func.isRequired, + audit: PropTypes.func.isRequired, + simulate: PropTypes.func, + enableAccessibility: PropTypes.func.isRequired, + resetAccessiblity: PropTypes.func.isRequired, + startListeningForLifecycleEvents: PropTypes.func.isRequired, + stopListeningForLifecycleEvents: PropTypes.func.isRequired, + startListeningForParentLifecycleEvents: PropTypes.func.isRequired, + stopListeningForParentLifecycleEvents: PropTypes.func.isRequired, + highlightAccessible: PropTypes.func.isRequired, + unhighlightAccessible: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + + this.resetAccessibility = this.resetAccessibility.bind(this); + this.onPanelWindowResize = this.onPanelWindowResize.bind(this); + this.onCanBeEnabledChange = this.onCanBeEnabledChange.bind(this); + this.onCanBeDisabledChange = this.onCanBeDisabledChange.bind(this); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillMount() { + this.props.startListeningForLifecycleEvents({ + init: this.resetAccessibility, + shutdown: this.resetAccessibility, + }); + this.props.startListeningForParentLifecycleEvents({ + "can-be-enabled-change": this.onCanBeEnabledChange, + "can-be-disabled-change": this.onCanBeDisabledChange, + }); + this.props.startListeningForAccessibilityEvents({ + "top-level-document-ready": this.resetAccessibility, + }); + window.addEventListener("resize", this.onPanelWindowResize, true); + } + + componentWillUnmount() { + this.props.stopListeningForLifecycleEvents({ + init: this.resetAccessibility, + shutdown: this.resetAccessibility, + }); + this.props.stopListeningForParentLifecycleEvents({ + "can-be-enabled-change": this.onCanBeEnabledChange, + "can-be-disabled-change": this.onCanBeDisabledChange, + }); + this.props.stopListeningForAccessibilityEvents({ + "top-level-document-ready": this.resetAccessibility, + }); + window.removeEventListener("resize", this.onPanelWindowResize, true); + } + + resetAccessibility() { + const { dispatch, resetAccessiblity, supports } = this.props; + dispatch(reset(resetAccessiblity, supports)); + } + + onCanBeEnabledChange(canBeEnabled) { + const { enableAccessibility, dispatch } = this.props; + dispatch(updateCanBeEnabled(canBeEnabled)); + if (canBeEnabled) { + dispatch(enable(enableAccessibility)); + } + } + + onCanBeDisabledChange(canBeDisabled) { + this.props.dispatch(updateCanBeDisabled(canBeDisabled)); + } + + get useLandscapeMode() { + const { clientWidth } = document.getElementById("content"); + return clientWidth > PORTRAIT_MODE_WIDTH; + } + + /** + * If panel width is less than PORTRAIT_MODE_WIDTH px, the splitter changes + * its mode to `horizontal` to support portrait view. + */ + onPanelWindowResize() { + if (this.refs.splitBox) { + this.refs.splitBox.setState({ vert: this.useLandscapeMode }); + } + } + + /** + * Render Accessibility panel content + */ + render() { + const { + fluentBundles, + enabled, + auditing, + simulate, + toolbox, + getAccessibilityTreeRoot, + startListeningForAccessibilityEvents, + stopListeningForAccessibilityEvents, + audit, + highlightAccessible, + unhighlightAccessible, + } = this.props; + + if (!enabled) { + return Description(); + } + + // Audit is currently running. + const isAuditing = !!auditing.length; + + return LocalizationProvider( + { bundles: fluentBundles }, + div( + { className: "mainFrame", role: "presentation", tabIndex: "-1" }, + Toolbar({ + audit, + simulate, + toolboxDoc: toolbox.doc, + }), + isAuditing && AuditProgressOverlay(), + span( + { + "aria-hidden": isAuditing, + role: "presentation", + style: { display: "contents" }, + }, + SplitBox({ + ref: "splitBox", + initialSize: SIDEBAR_WIDTH, + minSize: "10%", + maxSize: "80%", + splitterSize: 1, + endPanelControl: true, + startPanel: div( + { + className: "main-panel", + role: "presentation", + tabIndex: "-1", + }, + AccessibilityTree({ + toolboxDoc: toolbox.doc, + getAccessibilityTreeRoot, + startListeningForAccessibilityEvents, + stopListeningForAccessibilityEvents, + highlightAccessible, + unhighlightAccessible, + }) + ), + endPanel: RightSidebar({ + highlightAccessible, + unhighlightAccessible, + toolbox, + }), + vert: this.useLandscapeMode, + }) + ) + ) + ); + } +} + +const mapStateToProps = ({ + ui: { enabled, supports }, + audit: { auditing }, +}) => ({ + enabled, + supports, + auditing, +}); + +// Exports from this module +module.exports = connect(mapStateToProps)(MainFrame); diff --git a/devtools/client/accessibility/components/RightSidebar.js b/devtools/client/accessibility/components/RightSidebar.js new file mode 100644 index 0000000000..f525b96294 --- /dev/null +++ b/devtools/client/accessibility/components/RightSidebar.js @@ -0,0 +1,67 @@ +/* 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"; + +// React +const { + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + div, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const { + L10N, +} = require("resource://devtools/client/accessibility/utils/l10n.js"); +const Accessible = createFactory( + require("resource://devtools/client/accessibility/components/Accessible.js") +); +const Accordion = createFactory( + require("resource://devtools/client/shared/components/Accordion.js") +); +const Checks = createFactory( + require("resource://devtools/client/accessibility/components/Checks.js") +); + +// Component that is responsible for rendering accessible panel's sidebar. +function RightSidebar({ highlightAccessible, unhighlightAccessible, toolbox }) { + const propertiesID = "accessibility-properties"; + const checksID = "accessibility-checks"; + + return div( + { + className: "right-sidebar", + role: "presentation", + tabIndex: "-1", + }, + Accordion({ + items: [ + { + className: "checks", + component: Checks, + componentProps: { labelledby: `${checksID}-header` }, + header: L10N.getStr("accessibility.checks"), + id: checksID, + opened: true, + }, + { + className: "accessible", + component: Accessible, + componentProps: { + highlightAccessible, + unhighlightAccessible, + toolboxHighlighter: toolbox.getHighlighter(), + toolbox, + labelledby: `${propertiesID}-header`, + }, + header: L10N.getStr("accessibility.properties"), + id: propertiesID, + opened: true, + }, + ], + }) + ); +} + +module.exports = RightSidebar; diff --git a/devtools/client/accessibility/components/SimulationMenuButton.js b/devtools/client/accessibility/components/SimulationMenuButton.js new file mode 100644 index 0000000000..6fd21f4cc8 --- /dev/null +++ b/devtools/client/accessibility/components/SimulationMenuButton.js @@ -0,0 +1,167 @@ +/* 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"; + +/* global gTelemetry */ + +// React +const { + createFactory, + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + hr, + span, + div, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + L10N, +} = require("resource://devtools/client/accessibility/utils/l10n.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const MenuButton = createFactory( + require("resource://devtools/client/shared/components/menu/MenuButton.js") +); +const { openDocLink } = require("resource://devtools/client/shared/link.js"); +const { + A11Y_SIMULATION_DOCUMENTATION_LINK, +} = require("resource://devtools/client/accessibility/constants.js"); +const { + accessibility: { SIMULATION_TYPE }, +} = require("resource://devtools/shared/constants.js"); +const actions = require("resource://devtools/client/accessibility/actions/simulation.js"); + +loader.lazyGetter(this, "MenuItem", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuItem.js") + ); +}); +loader.lazyGetter(this, "MenuList", function () { + return createFactory( + require("resource://devtools/client/shared/components/menu/MenuList.js") + ); +}); + +const TELEMETRY_SIMULATION_ACTIVATED = + "devtools.accessibility.simulation_activated"; +const SIMULATION_MENU_LABELS = { + NONE: "accessibility.filter.none", + [SIMULATION_TYPE.ACHROMATOPSIA]: "accessibility.simulation.achromatopsia", + [SIMULATION_TYPE.PROTANOPIA]: "accessibility.simulation.protanopia", + [SIMULATION_TYPE.DEUTERANOPIA]: "accessibility.simulation.deuteranopia", + [SIMULATION_TYPE.TRITANOPIA]: "accessibility.simulation.tritanopia", + [SIMULATION_TYPE.CONTRAST_LOSS]: "accessibility.simulation.contrastLoss", + DOCUMENTATION: "accessibility.documentation.label", +}; + +class SimulationMenuButton extends Component { + static get propTypes() { + return { + simulate: PropTypes.func, + simulation: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + toolboxDoc: PropTypes.object.isRequired, + }; + } + + constructor(props) { + super(props); + + this.disableSimulation = this.disableSimulation.bind(this); + } + + disableSimulation() { + const { dispatch, simulate: simulateFunc } = this.props; + + dispatch(actions.simulate(simulateFunc)); + } + + toggleSimulation(simKey) { + const { dispatch, simulation, simulate: simulateFunc } = this.props; + + if (!simulation[simKey]) { + if (gTelemetry) { + gTelemetry.keyedScalarAdd(TELEMETRY_SIMULATION_ACTIVATED, simKey, 1); + } + + dispatch(actions.simulate(simulateFunc, [simKey])); + return; + } + + this.disableSimulation(); + } + + render() { + const { simulation, toolboxDoc } = this.props; + const simulationMenuButtonId = "simulation-menu-button"; + const toolbarLabelID = "accessibility-simulation-label"; + const currSimulation = Object.entries(simulation).find( + simEntry => simEntry[1] + ); + + const items = [ + // No simulation option + MenuItem({ + key: "simulation-none", + label: L10N.getStr(SIMULATION_MENU_LABELS.NONE), + checked: !currSimulation, + onClick: this.disableSimulation, + }), + hr({ key: "hr-1" }), + // Simulation options + ...Object.keys(SIMULATION_TYPE).map(simType => + MenuItem({ + key: `simulation-${simType}`, + label: L10N.getStr(SIMULATION_MENU_LABELS[simType]), + checked: simulation[simType], + onClick: this.toggleSimulation.bind(this, simType), + }) + ), + hr({ key: "hr-2" }), + // Documentation link + MenuItem({ + className: "link", + key: "simulation-documentation", + label: L10N.getStr(SIMULATION_MENU_LABELS.DOCUMENTATION), + role: "link", + onClick: () => openDocLink(A11Y_SIMULATION_DOCUMENTATION_LINK), + }), + ]; + + return div( + { + role: "group", + className: "accessibility-simulation", + "aria-labelledby": toolbarLabelID, + }, + span( + { id: toolbarLabelID, role: "presentation" }, + L10N.getStr("accessibility.simulation") + ), + MenuButton( + { + id: simulationMenuButtonId, + menuId: simulationMenuButtonId + "-menu", + className: `devtools-button toolbar-menu-button simulation${ + currSimulation ? " active" : "" + }`, + toolboxDoc, + label: L10N.getStr( + SIMULATION_MENU_LABELS[currSimulation ? currSimulation[0] : "NONE"] + ), + }, + MenuList({}, items) + ) + ); + } +} + +const mapStateToProps = ({ simulation }) => { + return { simulation }; +}; + +// Exports from this module +module.exports = connect(mapStateToProps)(SimulationMenuButton); diff --git a/devtools/client/accessibility/components/TextLabelBadge.js b/devtools/client/accessibility/components/TextLabelBadge.js new file mode 100644 index 0000000000..0ff54ef8e0 --- /dev/null +++ b/devtools/client/accessibility/components/TextLabelBadge.js @@ -0,0 +1,57 @@ +/* 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"; + +// React +const { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const { + L10N, +} = require("resource://devtools/client/accessibility/utils/l10n.js"); + +const { + accessibility: { + SCORES: { BEST_PRACTICES, FAIL, WARNING }, + }, +} = require("resource://devtools/shared/constants.js"); + +loader.lazyGetter(this, "Badge", () => + createFactory( + require("resource://devtools/client/accessibility/components/Badge.js") + ) +); + +/** + * Component for rendering a badge for text alternative accessibliity check + * failures association with a given accessibility object in the accessibility + * tree. + */ +class TextLabelBadge extends PureComponent { + static get propTypes() { + return { + error: PropTypes.string, + score: PropTypes.string, + }; + } + + render() { + const { error, score } = this.props; + if (error || ![BEST_PRACTICES, FAIL, WARNING].includes(score)) { + return null; + } + + return Badge({ + score, + label: L10N.getStr("accessibility.badge.textLabel"), + tooltip: L10N.getStr("accessibility.badge.textLabel.tooltip"), + }); + } +} + +module.exports = TextLabelBadge; diff --git a/devtools/client/accessibility/components/TextLabelCheck.js b/devtools/client/accessibility/components/TextLabelCheck.js new file mode 100644 index 0000000000..adfbb4b412 --- /dev/null +++ b/devtools/client/accessibility/components/TextLabelCheck.js @@ -0,0 +1,225 @@ +/* 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"; + +// React +const { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const Check = createFactory( + require("resource://devtools/client/accessibility/components/Check.js") +); + +const { + A11Y_TEXT_LABEL_LINKS, +} = require("resource://devtools/client/accessibility/constants.js"); +const { + accessibility: { + AUDIT_TYPE: { TEXT_LABEL }, + ISSUE_TYPE: { + [TEXT_LABEL]: { + AREA_NO_NAME_FROM_ALT, + DIALOG_NO_NAME, + DOCUMENT_NO_TITLE, + EMBED_NO_NAME, + FIGURE_NO_NAME, + FORM_FIELDSET_NO_NAME, + FORM_FIELDSET_NO_NAME_FROM_LEGEND, + FORM_NO_NAME, + FORM_NO_VISIBLE_NAME, + FORM_OPTGROUP_NO_NAME_FROM_LABEL, + FRAME_NO_NAME, + HEADING_NO_CONTENT, + HEADING_NO_NAME, + IFRAME_NO_NAME_FROM_TITLE, + IMAGE_NO_NAME, + INTERACTIVE_NO_NAME, + MATHML_GLYPH_NO_NAME, + TOOLBAR_NO_NAME, + }, + }, + }, +} = require("resource://devtools/shared/constants.js"); + +/** + * A map from text label issues to annotation component properties. + */ +const ISSUE_TO_ANNOTATION_MAP = { + [AREA_NO_NAME_FROM_ALT]: { + href: A11Y_TEXT_LABEL_LINKS.AREA_NO_NAME_FROM_ALT, + l10nId: "accessibility-text-label-issue-area", + args: { + get code() { + return ReactDOM.code({}, "alt"); + }, + // Note: there is no way right now to use custom elements in privileged + // content. We have to use something like <div> since we can't provide + // three args with the same name. + get div() { + return ReactDOM.code({}, "area"); + }, + // Note: there is no way right now to use custom elements in privileged + // content. We have to use something like <span> since we can't provide + // three args with the same name. + get span() { + return ReactDOM.code({}, "href"); + }, + }, + }, + [DIALOG_NO_NAME]: { + href: A11Y_TEXT_LABEL_LINKS.DIALOG_NO_NAME, + l10nId: "accessibility-text-label-issue-dialog", + }, + [DOCUMENT_NO_TITLE]: { + href: A11Y_TEXT_LABEL_LINKS.DOCUMENT_NO_TITLE, + l10nId: "accessibility-text-label-issue-document-title", + args: { + get code() { + return ReactDOM.code({}, "title"); + }, + }, + }, + [EMBED_NO_NAME]: { + href: A11Y_TEXT_LABEL_LINKS.EMBED_NO_NAME, + l10nId: "accessibility-text-label-issue-embed", + }, + [FIGURE_NO_NAME]: { + href: A11Y_TEXT_LABEL_LINKS.FIGURE_NO_NAME, + l10nId: "accessibility-text-label-issue-figure", + }, + [FORM_FIELDSET_NO_NAME]: { + href: A11Y_TEXT_LABEL_LINKS.FORM_FIELDSET_NO_NAME, + l10nId: "accessibility-text-label-issue-fieldset", + args: { + get code() { + return ReactDOM.code({}, "fieldset"); + }, + }, + }, + [FORM_FIELDSET_NO_NAME_FROM_LEGEND]: { + href: A11Y_TEXT_LABEL_LINKS.FORM_FIELDSET_NO_NAME_FROM_LEGEND, + l10nId: "accessibility-text-label-issue-fieldset-legend2", + args: { + get code() { + return ReactDOM.code({}, "legend"); + }, + // Note: there is no way right now to use custom elements in privileged + // content. We have to use something like <span> since we can't provide + // two args with the same name. + get span() { + return ReactDOM.code({}, "fieldset"); + }, + }, + }, + [FORM_NO_NAME]: { + href: A11Y_TEXT_LABEL_LINKS.FORM_NO_NAME, + l10nId: "accessibility-text-label-issue-form", + }, + [FORM_NO_VISIBLE_NAME]: { + href: A11Y_TEXT_LABEL_LINKS.FORM_NO_VISIBLE_NAME, + l10nId: "accessibility-text-label-issue-form-visible", + }, + [FORM_OPTGROUP_NO_NAME_FROM_LABEL]: { + href: A11Y_TEXT_LABEL_LINKS.FORM_OPTGROUP_NO_NAME_FROM_LABEL, + l10nId: "accessibility-text-label-issue-optgroup-label2", + args: { + get code() { + return ReactDOM.code({}, "label"); + }, + // Note: there is no way right now to use custom elements in privileged + // content. We have to use something like <span> since we can't provide + // two args with the same name. + get span() { + return ReactDOM.code({}, "optgroup"); + }, + }, + }, + [FRAME_NO_NAME]: { + href: A11Y_TEXT_LABEL_LINKS.FRAME_NO_NAME, + l10nId: "accessibility-text-label-issue-frame", + args: { + get code() { + return ReactDOM.code({}, "frame"); + }, + }, + }, + [HEADING_NO_CONTENT]: { + href: A11Y_TEXT_LABEL_LINKS.HEADING_NO_CONTENT, + l10nId: "accessibility-text-label-issue-heading-content", + }, + [HEADING_NO_NAME]: { + href: A11Y_TEXT_LABEL_LINKS.HEADING_NO_NAME, + l10nId: "accessibility-text-label-issue-heading", + }, + [IFRAME_NO_NAME_FROM_TITLE]: { + href: A11Y_TEXT_LABEL_LINKS.IFRAME_NO_NAME_FROM_TITLE, + l10nId: "accessibility-text-label-issue-iframe", + args: { + get code() { + return ReactDOM.code({}, "title"); + }, + // Note: there is no way right now to use custom elements in privileged + // content. We have to use something like <span> since we can't provide + // two args with the same name. + get span() { + return ReactDOM.code({}, "iframe"); + }, + }, + }, + [IMAGE_NO_NAME]: { + href: A11Y_TEXT_LABEL_LINKS.IMAGE_NO_NAME, + l10nId: "accessibility-text-label-issue-image", + }, + [INTERACTIVE_NO_NAME]: { + href: A11Y_TEXT_LABEL_LINKS.INTERACTIVE_NO_NAME, + l10nId: "accessibility-text-label-issue-interactive", + }, + [MATHML_GLYPH_NO_NAME]: { + href: A11Y_TEXT_LABEL_LINKS.MATHML_GLYPH_NO_NAME, + l10nId: "accessibility-text-label-issue-glyph", + args: { + get code() { + return ReactDOM.code({}, "alt"); + }, + // Note: there is no way right now to use custom elements in privileged + // content. We have to use something like <span> since we can't provide + // two args with the same name. + get span() { + return ReactDOM.code({}, "mglyph"); + }, + }, + }, + [TOOLBAR_NO_NAME]: { + href: A11Y_TEXT_LABEL_LINKS.TOOLBAR_NO_NAME, + l10nId: "accessibility-text-label-issue-toolbar", + }, +}; + +/** + * Component for rendering a check for text label accessibliity check failures, + * warnings and best practices suggestions association with a given + * accessibility object in the accessibility tree. + */ +class TextLabelCheck extends PureComponent { + static get propTypes() { + return { + issue: PropTypes.string.isRequired, + score: PropTypes.string.isRequired, + }; + } + + render() { + return Check({ + ...this.props, + getAnnotation: issue => ISSUE_TO_ANNOTATION_MAP[issue], + id: "accessibility-text-label-header", + }); + } +} + +module.exports = TextLabelCheck; diff --git a/devtools/client/accessibility/components/Toolbar.js b/devtools/client/accessibility/components/Toolbar.js new file mode 100644 index 0000000000..b5f27485ed --- /dev/null +++ b/devtools/client/accessibility/components/Toolbar.js @@ -0,0 +1,74 @@ +/* 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"; + +// React +const { + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + div, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const AccessibilityTreeFilter = createFactory( + require("resource://devtools/client/accessibility/components/AccessibilityTreeFilter.js") +); +const AccessibilityPrefs = createFactory( + require("resource://devtools/client/accessibility/components/AccessibilityPrefs.js") +); +loader.lazyGetter(this, "SimulationMenuButton", function () { + return createFactory( + require("resource://devtools/client/accessibility/components/SimulationMenuButton.js") + ); +}); +const DisplayTabbingOrder = createFactory( + require("resource://devtools/client/accessibility/components/DisplayTabbingOrder.js") +); + +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +function Toolbar({ audit, simulate, supportsTabbingOrder, toolboxDoc }) { + const optionalSimulationSection = simulate + ? [ + div({ + role: "separator", + className: "devtools-separator", + }), + SimulationMenuButton({ simulate, toolboxDoc }), + ] + : []; + const optionalDisplayTabbingOrderSection = supportsTabbingOrder + ? [ + div({ + role: "separator", + className: "devtools-separator", + }), + DisplayTabbingOrder(), + ] + : []; + + return div( + { + className: "devtools-toolbar", + role: "toolbar", + }, + AccessibilityTreeFilter({ audit, toolboxDoc }), + // Simulation section is shown if webrender is enabled + ...optionalSimulationSection, + ...optionalDisplayTabbingOrderSection, + AccessibilityPrefs({ toolboxDoc }) + ); +} + +const mapStateToProps = ({ + ui: { + supports: { tabbingOrder }, + }, +}) => ({ + supportsTabbingOrder: tabbingOrder, +}); + +// Exports from this module +exports.Toolbar = connect(mapStateToProps)(Toolbar); diff --git a/devtools/client/accessibility/components/moz.build b/devtools/client/accessibility/components/moz.build new file mode 100644 index 0000000000..23e01d42c6 --- /dev/null +++ b/devtools/client/accessibility/components/moz.build @@ -0,0 +1,33 @@ +# 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/. + +DevToolsModules( + "AccessibilityPrefs.js", + "AccessibilityRow.js", + "AccessibilityRowValue.js", + "AccessibilityTree.js", + "AccessibilityTreeFilter.js", + "Accessible.js", + "AuditController.js", + "AuditFilter.js", + "AuditProgressOverlay.js", + "Badge.js", + "Badges.js", + "Button.js", + "Check.js", + "Checks.js", + "ColorContrastAccessibility.js", + "ContrastBadge.js", + "Description.js", + "DisplayTabbingOrder.js", + "KeyboardBadge.js", + "KeyboardCheck.js", + "LearnMoreLink.js", + "MainFrame.js", + "RightSidebar.js", + "SimulationMenuButton.js", + "TextLabelBadge.js", + "TextLabelCheck.js", + "Toolbar.js", +) |