diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index 5e356be58..fbf08046a 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -313,6 +313,21 @@ class CommandRegistry { return cmd ? cmd.isToggled.call(undefined, args) : false; } + /** + * Test whether a specific command is toggleable. + * + * @param id - The id of the command of interest. + * + * @param args - The arguments for the command. + * + * @returns A boolean indicating whether the command is toggleable, + * or `false` if the command is not registered. + */ + isToggleable(id: string, args: ReadonlyJSONObject = JSONExt.emptyObject): boolean { + let cmd = this._commands[id]; + return cmd ? cmd.isToggleable : false; + } + /** * Test whether a specific command is visible. * @@ -759,6 +774,20 @@ namespace CommandRegistry { */ isToggled?: CommandFunc; + /** + * A function which indicates whether the command is toggleable. + * + * #### Notes + * Visual representations may use this value to display a toggled command in + * a different form, such as a check box for a menu item or a depressed + * state for a toggle button. This attribute also allows for accessible + * interfaces to notify the user that the command corresponds to some state. + * + * The default value is `true` if an `isToggled` function is given, `false` + * otherwise. + */ + isToggleable?: boolean; + /** * A function which indicates whether the command is visible. * @@ -1147,6 +1176,7 @@ namespace Private { readonly dataset: CommandFunc; readonly isEnabled: CommandFunc; readonly isToggled: CommandFunc; + readonly isToggleable: boolean; readonly isVisible: CommandFunc; } @@ -1167,6 +1197,7 @@ namespace Private { dataset: asFunc(options.dataset, emptyDatasetFunc), isEnabled: options.isEnabled || trueFunc, isToggled: options.isToggled || falseFunc, + isToggleable: options.isToggleable || !!options.isToggled, isVisible: options.isVisible || trueFunc }; } diff --git a/packages/virtualdom/src/index.ts b/packages/virtualdom/src/index.ts index 43a5bb37b..417b2eb2d 100644 --- a/packages/virtualdom/src/index.ts +++ b/packages/virtualdom/src/index.ts @@ -124,6 +124,66 @@ type ElementAttrNames = ( ); +/** + * The names of ARIA attributes for HTML elements. + * + * The attribute names are collected from + * https://www.w3.org/TR/html5/infrastructure.html#element-attrdef-aria-role + */ +export +type ARIAAttrNames = ( + 'aria-activedescendant' | + 'aria-atomic' | + 'aria-autocomplete' | + 'aria-busy' | + 'aria-checked' | + 'aria-colcount' | + 'aria-colindex' | + 'aria-colspan' | + 'aria-controls' | + 'aria-current' | + 'aria-describedby' | + 'aria-details' | + 'aria-dialog' | + 'aria-disabled' | + 'aria-dropeffect' | + 'aria-errormessage' | + 'aria-expanded' | + 'aria-flowto' | + 'aria-grabbed' | + 'aria-haspopup' | + 'aria-hidden' | + 'aria-invalid' | + 'aria-keyshortcuts' | + 'aria-label' | + 'aria-labelledby' | + 'aria-level' | + 'aria-live' | + 'aria-multiline' | + 'aria-multiselectable' | + 'aria-orientation' | + 'aria-owns' | + 'aria-placeholder' | + 'aria-posinset' | + 'aria-pressed' | + 'aria-readonly' | + 'aria-relevant' | + 'aria-required' | + 'aria-roledescription' | + 'aria-rowcount' | + 'aria-rowindex' | + 'aria-rowspan' | + 'aria-selected' | + 'aria-setsize' | + 'aria-sort' | + 'aria-valuemax' | + 'aria-valuemin' | + 'aria-valuenow' | + 'aria-valuetext' | + 'role' +); + + /** * The names of the supported HTML5 CSS property names. * @@ -599,6 +659,18 @@ type ElementBaseAttrs = { readonly [T in ElementAttrNames]?: string; }; +/** + * The ARIA attributes for a virtual element node. + * + * These are the attributes which are applied to a real DOM element via + * `element.setAttribute()`. The supported attribute names are defined + * by the `ARIAAttrNames` type. + */ +export +type ElementARIAAttrs = { + readonly [T in ARIAAttrNames]?: string; +}; + /** * The inline event listener attributes for a virtual element node. @@ -655,12 +727,13 @@ type ElementSpecialAttrs = { /** * The full set of attributes supported by a virtual element node. * - * This is the combination of the base element attributes, the inline - * element event listeners, and the special element attributes. + * This is the combination of the base element attributes, the ARIA attributes, + * the inline element event listeners, and the special element attributes. */ export type ElementAttrs = ( ElementBaseAttrs & + ElementARIAAttrs & ElementEventAttrs & ElementSpecialAttrs ); diff --git a/packages/widgets/src/docklayout.ts b/packages/widgets/src/docklayout.ts index b22c3b20a..185de2ff7 100644 --- a/packages/widgets/src/docklayout.ts +++ b/packages/widgets/src/docklayout.ts @@ -633,6 +633,8 @@ class DockLayout extends Layout { return; } + Private.removeAria(widget); + // If there are multiple tabs, just remove the widget's tab. if (tabNode.tabBar.titles.length > 1) { tabNode.tabBar.removeTab(widget.title); @@ -764,6 +766,7 @@ class DockLayout extends Layout { let tabNode = new Private.TabLayoutNode(this._createTabBar()); tabNode.tabBar.addTab(widget.title); this._root = tabNode; + Private.addAria(widget, tabNode.tabBar); return; } @@ -789,6 +792,7 @@ class DockLayout extends Layout { // Insert the widget's tab relative to the target index. refNode.tabBar.insertTab(index + (after ? 1 : 0), widget.title); + Private.addAria(widget, refNode.tabBar); } /** @@ -809,6 +813,7 @@ class DockLayout extends Layout { // Create the tab layout node to hold the widget. let tabNode = new Private.TabLayoutNode(this._createTabBar()); tabNode.tabBar.addTab(widget.title); + Private.addAria(widget, tabNode.tabBar); // Set the root if it does not exist. if (!this._root) { @@ -1976,6 +1981,19 @@ namespace Private { } } + export + async function addAria(widget: Widget, tabBar: TabBar) { + let tabId = tabBar.renderer.createTabKey({title: widget.title, current: false, zIndex: 0}); + widget.node.setAttribute('role', 'tabpanel'); + widget.node.setAttribute('aria-labelledby', tabId); + } + + export + async function removeAria(widget: Widget) { + widget.node.removeAttribute('role'); + widget.node.removeAttribute('aria-labelledby'); + } + /** * Normalize a tab area config and collect the visited widgets. */ @@ -2065,6 +2083,7 @@ namespace Private { each(config.widgets, widget => { widget.hide(); tabBar.addTab(widget.title); + Private.addAria(widget, tabBar); }); // Set the current index of the tab bar. diff --git a/packages/widgets/src/menu.ts b/packages/widgets/src/menu.ts index 910e8b5e2..a966d1a52 100644 --- a/packages/widgets/src/menu.ts +++ b/packages/widgets/src/menu.ts @@ -34,7 +34,7 @@ import { } from '@phosphor/signaling'; import { - ElementDataset, VirtualDOM, VirtualElement, h + ARIAAttrNames, ElementARIAAttrs, ElementDataset, VirtualDOM, VirtualElement, h } from '@phosphor/virtualdom'; import { @@ -1143,8 +1143,9 @@ namespace Menu { renderItem(data: IRenderData): VirtualElement { let className = this.createItemClass(data); let dataset = this.createItemDataset(data); + let aria = this.createItemARIA(data); return ( - h.li({ className, dataset }, + h.li({ className, dataset, ...aria }, this.renderIcon(data), this.renderLabel(data), this.renderShortcut(data), @@ -1269,6 +1270,21 @@ namespace Menu { return extra ? `${name} ${extra}` : name; } + createItemARIA(data: IRenderData): ElementARIAAttrs { + let aria: {[T in ARIAAttrNames]?: string} = {}; + switch (data.item.type) { + case 'separator': + aria.role = 'presentation'; + break; + case 'submenu': + aria['aria-haspopup'] = 'true'; + break; + default: + aria.role = 'menuitem'; + } + return aria; + } + /** * Create the render content for the label node. * @@ -1342,6 +1358,7 @@ namespace Private { let node = document.createElement('div'); let content = document.createElement('ul'); content.className = 'p-Menu-content'; + content.setAttribute('role', 'menu'); node.appendChild(content); node.tabIndex = -1; return node; diff --git a/packages/widgets/src/menubar.ts b/packages/widgets/src/menubar.ts index 457404794..8b2a4f1ae 100644 --- a/packages/widgets/src/menubar.ts +++ b/packages/widgets/src/menubar.ts @@ -22,7 +22,7 @@ import { } from '@phosphor/messaging'; import { - ElementDataset, VirtualDOM, VirtualElement, h + ElementARIAAttrs, ElementDataset, VirtualDOM, VirtualElement, h } from '@phosphor/virtualdom'; import { @@ -747,8 +747,9 @@ namespace MenuBar { renderItem(data: IRenderData): VirtualElement { let className = this.createItemClass(data); let dataset = this.createItemDataset(data); + let aria = this.createItemARIA(data); return ( - h.li({ className, dataset }, + h.li({ className, dataset, ...aria}, this.renderIcon(data), this.renderLabel(data) ) @@ -808,6 +809,10 @@ namespace MenuBar { return data.title.dataset; } + createItemARIA(data: IRenderData): ElementARIAAttrs { + return {role: 'menuitem', 'aria-haspopup': 'true'}; + } + /** * Create the class name for the menu bar item icon. * @@ -870,6 +875,7 @@ namespace Private { let node = document.createElement('div'); let content = document.createElement('ul'); content.className = 'p-MenuBar-content'; + content.setAttribute('role', 'menubar'); node.appendChild(content); node.tabIndex = -1; return node; diff --git a/packages/widgets/src/tabbar.ts b/packages/widgets/src/tabbar.ts index a50c00a7d..e3892cacb 100644 --- a/packages/widgets/src/tabbar.ts +++ b/packages/widgets/src/tabbar.ts @@ -30,7 +30,7 @@ import { } from '@phosphor/signaling'; import { - ElementDataset, ElementInlineStyle, VirtualDOM, VirtualElement, h + ElementARIAAttrs, ElementDataset, ElementInlineStyle, VirtualDOM, VirtualElement, h } from '@phosphor/virtualdom'; import { @@ -60,14 +60,15 @@ class TabBar extends Widget { constructor(options: TabBar.IOptions = {}) { super({ node: Private.createNode() }); this.addClass('p-TabBar'); + this.contentNode.setAttribute('role', 'tablist'); this.setFlag(Widget.Flag.DisallowLayout); this.tabsMovable = options.tabsMovable || false; this.allowDeselect = options.allowDeselect || false; this.insertBehavior = options.insertBehavior || 'select-tab-if-needed'; + this.name = options.name || ''; + this.orientation = options.orientation || 'horizontal'; this.removeBehavior = options.removeBehavior || 'select-tab-after'; this.renderer = options.renderer || TabBar.defaultRenderer; - this._orientation = options.orientation || 'horizontal'; - this.dataset['orientation'] = this._orientation; } /** @@ -246,6 +247,25 @@ class TabBar extends Widget { }); } + /** + * Get the name of the tab bar. + */ + get name(): string { + return this._name; + } + + /** + * Set the name of the tab bar. + */ + set name(value: string) { + this._name = value; + if (value) { + this.contentNode.setAttribute('aria-label', value); + } else { + this.contentNode.removeAttribute('aria-label'); + } + } + /** * Get the orientation of the tab bar. * @@ -274,6 +294,7 @@ class TabBar extends Widget { // Toggle the orientation values. this._orientation = value; this.dataset['orientation'] = value; + this.contentNode.setAttribute('aria-orientation', value); } /** @@ -893,6 +914,9 @@ class TabBar extends Widget { let ci = this._currentIndex; let bh = this.insertBehavior; + + // TODO: do we need to do an update to update the aria-selected attribute? + // Handle the behavior where the new tab is always selected, // or the behavior where the new tab is selected if needed. if (bh === 'select-tab' || (bh === 'select-tab-if-needed' && ci === -1)) { @@ -946,6 +970,8 @@ class TabBar extends Widget { return; } + // TODO: do we need to do an update to adjust the aria-selected value? + // No tab gets selected if the tab bar is empty. if (this._titles.length === 0) { this._currentIndex = -1; @@ -1006,6 +1032,7 @@ class TabBar extends Widget { this.update(); } + private _name: string; private _currentIndex = -1; private _titles: Title[] = []; private _orientation: TabBar.Orientation; @@ -1096,6 +1123,13 @@ namespace TabBar { */ export interface IOptions { + /** + * Name of the tab bar. + * + * This is used for accessibility reasons. The default is the empty string. + */ + name?: string; + /** * The layout orientation of the tab bar. * @@ -1288,6 +1322,19 @@ namespace TabBar { * @returns A virtual element representing the tab. */ renderTab(data: IRenderData): VirtualElement; + + /** + * Create a stable unique id for a tab based on the title. + * + * @param data - The data to use for the tab. + * + * @returns The unique id for a tab. + * + * #### Notes + * This method returns a stable unique id for a tab, depending only on the + * title. The tab DOM `id` is set to this value. + */ + createTabKey(data: IRenderData): string; } /** @@ -1318,11 +1365,13 @@ namespace TabBar { renderTab(data: IRenderData): VirtualElement { let title = data.title.caption; let key = this.createTabKey(data); + let id = key; let style = this.createTabStyle(data); let className = this.createTabClass(data); let dataset = this.createTabDataset(data); + let aria = this.createTabARIA(data); return ( - h.li({ key, className, title, style, dataset }, + h.li({ id, key, className, title, style, dataset, ...aria }, this.renderIcon(data), this.renderLabel(data), this.renderCloseIcon(data) @@ -1428,6 +1477,17 @@ namespace TabBar { return data.title.dataset; } + /** + * Create the ARIA attributes for a tab. + * + * @param data - The data to use for the tab. + * + * @returns The ARIA attributes for the tab. + */ + createTabARIA(data: IRenderData): ElementARIAAttrs { + return {role: 'tab', 'aria-selected': data.current.toString()}; + } + /** * Create the class name for the tab icon. * diff --git a/packages/widgets/src/tabpanel.ts b/packages/widgets/src/tabpanel.ts index 7d7c91deb..9d389aca6 100644 --- a/packages/widgets/src/tabpanel.ts +++ b/packages/widgets/src/tabpanel.ts @@ -263,6 +263,10 @@ class TabPanel extends Widget { } this.stackedPanel.insertWidget(index, widget); this.tabBar.insertTab(index, widget.title); + + let tabId = this.tabBar.renderer.createTabKey({title: widget.title, current: false, zIndex: 0}); + widget.node.setAttribute('role', 'tabpanel'); + widget.node.setAttribute('aria-labelledby', tabId); } /** @@ -322,6 +326,8 @@ class TabPanel extends Widget { * Handle the `widgetRemoved` signal from the stacked panel. */ private _onWidgetRemoved(sender: StackedPanel, widget: Widget): void { + widget.node.removeAttribute('role'); + widget.node.removeAttribute('aria-labelledby'); this.tabBar.removeTab(widget.title); }