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..857935286 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,22 @@ namespace Private { } } + export + function addAria(widget: Widget, tabBar: TabBar) { + widget.node.setAttribute('role', 'tabpanel'); + let renderer = tabBar.renderer; + if (renderer instanceof TabBar.Renderer) { + let tabId = renderer.createTabKey({ title: widget.title, current: false, zIndex: 0 }); + widget.node.setAttribute('aria-labelledby', tabId); + } + } + + export + 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 +2086,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..46cea33e0 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. * @@ -1318,11 +1352,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 +1464,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..e4a1a0b93 100644 --- a/packages/widgets/src/tabpanel.ts +++ b/packages/widgets/src/tabpanel.ts @@ -263,6 +263,14 @@ class TabPanel extends Widget { } this.stackedPanel.insertWidget(index, widget); this.tabBar.insertTab(index, widget.title); + + widget.node.setAttribute('role', 'tabpanel'); + + let renderer = this.tabBar.renderer + if (renderer instanceof TabBar.Renderer) { + let tabId = renderer.createTabKey({title: widget.title, current: false, zIndex: 0}); + widget.node.setAttribute('aria-labelledby', tabId); + } } /** @@ -322,6 +330,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); }