diff --git a/packages/dockview-core/src/__tests__/dockview/accessibility.spec.ts b/packages/dockview-core/src/__tests__/dockview/accessibility.spec.ts new file mode 100644 index 000000000..a66422026 --- /dev/null +++ b/packages/dockview-core/src/__tests__/dockview/accessibility.spec.ts @@ -0,0 +1,208 @@ +import { DockviewComponent } from '../../dockview/dockviewComponent'; +import { IContentRenderer } from '../../dockview/types'; +import { GroupPanelPartInitParameters } from '../../dockview/types'; +import { PanelUpdateEvent } from '../../panel/types'; +import { Emitter } from '../../events'; + +class PanelContentPartTest implements IContentRenderer { + element: HTMLElement = document.createElement('div'); + + readonly _onDidDispose = new Emitter(); + readonly onDidDispose = this._onDidDispose.event; + + constructor( + public readonly id: string, + public readonly component: string + ) {} + + init(_parameters: GroupPanelPartInitParameters): void { + // noop + } + + layout(_width: number, _height: number): void { + // noop + } + + update(_event: PanelUpdateEvent): void { + // noop + } + + dispose(): void { + this._onDidDispose.fire(); + this._onDidDispose.dispose(); + } +} + +/** + * Layer 1 of the accessibility pack — the free WAI-ARIA Tabs baseline that + * ships in core. Asserts roles, states and the tab <-> tabpanel relationships. + */ +describe('accessibility: WAI-ARIA tabs baseline', () => { + let container: HTMLElement; + let dockview: DockviewComponent; + + beforeEach(() => { + container = document.createElement('div'); + dockview = new DockviewComponent(container, { + createComponent(options) { + switch (options.name) { + case 'default': + return new PanelContentPartTest( + options.id, + options.name + ); + default: + throw new Error('unsupported'); + } + }, + }); + dockview.layout(1000, 1000); + }); + + const realTabs = (): HTMLElement[] => + Array.from(container.querySelectorAll('.dv-tab')) as HTMLElement[]; + + test('tablist / tab / tabpanel roles are present', () => { + dockview.addPanel({ id: 'panel1', component: 'default' }); + + const tablist = container.querySelector('.dv-tabs-container')!; + expect(tablist.getAttribute('role')).toBe('tablist'); + expect(tablist.getAttribute('aria-orientation')).toBe('horizontal'); + + const tabpanel = container.querySelector('.dv-content-container')!; + expect(tabpanel.getAttribute('role')).toBe('tabpanel'); + expect(tabpanel.id).toBeTruthy(); + + const tabs = realTabs(); + expect(tabs.length).toBe(1); + expect(tabs[0].getAttribute('role')).toBe('tab'); + expect(tabs[0].id).toBeTruthy(); + }); + + test('each tab controls the tabpanel and the active tab labels it', () => { + dockview.addPanel({ id: 'panel1', component: 'default' }); + dockview.addPanel({ id: 'panel2', component: 'default' }); + + const tabpanel = container.querySelector('.dv-content-container')!; + const tabs = realTabs(); + expect(tabs.length).toBe(2); + + // aria-controls: every tab references the single group tabpanel. + for (const tab of tabs) { + expect(tab.getAttribute('aria-controls')).toBe(tabpanel.id); + } + + // exactly one selected; the tabpanel is labelled by it. + const selected = tabs.filter( + (t) => t.getAttribute('aria-selected') === 'true' + ); + expect(selected.length).toBe(1); + expect(tabpanel.getAttribute('aria-labelledby')).toBe(selected[0].id); + }); + + test('aria-selected and aria-labelledby follow the active panel', () => { + const panel1 = dockview.addPanel({ + id: 'panel1', + component: 'default', + }); + dockview.addPanel({ id: 'panel2', component: 'default' }); + + const tabpanel = container.querySelector('.dv-content-container')!; + + // panel2 was added last → active. + const selectedBefore = realTabs().filter( + (t) => t.getAttribute('aria-selected') === 'true' + ); + expect(selectedBefore.length).toBe(1); + const activeTabId2 = selectedBefore[0].id; + expect(tabpanel.getAttribute('aria-labelledby')).toBe(activeTabId2); + + // activate panel1 — selection + labelling must move with it. + panel1.api.setActive(); + + const selectedAfter = realTabs().filter( + (t) => t.getAttribute('aria-selected') === 'true' + ); + expect(selectedAfter.length).toBe(1); + expect(selectedAfter[0].id).not.toBe(activeTabId2); + expect(tabpanel.getAttribute('aria-labelledby')).toBe( + selectedAfter[0].id + ); + }); + + test('tab ids and tabpanel ids are unique across groups', () => { + dockview.addPanel({ id: 'panel1', component: 'default' }); + dockview.addPanel({ + id: 'panel2', + component: 'default', + position: { referencePanel: 'panel1', direction: 'right' }, + }); + + const tabpanels = Array.from( + container.querySelectorAll('.dv-content-container') + ) as HTMLElement[]; + expect(tabpanels.length).toBe(2); + expect(tabpanels[0].id).not.toBe(tabpanels[1].id); + + const ids = realTabs().map((t) => t.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + test('group is a region labelled by its active panel title', () => { + dockview.addPanel({ + id: 'panel1', + component: 'default', + title: 'First', + }); + + const region = container.querySelector('.dv-groupview')!; + expect(region.getAttribute('role')).toBe('region'); + expect(region.getAttribute('aria-label')).toBe('First'); + }); + + test('region label follows the active panel', () => { + const panel1 = dockview.addPanel({ + id: 'panel1', + component: 'default', + title: 'First', + }); + dockview.addPanel({ + id: 'panel2', + component: 'default', + title: 'Second', + }); + + const region = container.querySelector('.dv-groupview')!; + expect(region.getAttribute('aria-label')).toBe('Second'); + + panel1.api.setActive(); + expect(region.getAttribute('aria-label')).toBe('First'); + }); + + test('region label updates when the active panel title changes', () => { + const panel1 = dockview.addPanel({ + id: 'panel1', + component: 'default', + title: 'First', + }); + + const region = container.querySelector('.dv-groupview')!; + expect(region.getAttribute('aria-label')).toBe('First'); + + panel1.api.setTitle('Renamed'); + expect(region.getAttribute('aria-label')).toBe('Renamed'); + }); + + test('floating group is a non-modal dialog', () => { + dockview.addPanel({ id: 'panel1', component: 'default' }); + dockview.addPanel({ + id: 'panel2', + component: 'default', + floating: true, + }); + + const dialog = container.querySelector('.dv-resize-container')!; + expect(dialog.getAttribute('role')).toBe('dialog'); + expect(dialog.getAttribute('aria-modal')).toBe('false'); + }); +}); diff --git a/packages/dockview-core/src/dockview/components/panel/content.ts b/packages/dockview-core/src/dockview/components/panel/content.ts index 6611b8e5b..8e2f0b04b 100644 --- a/packages/dockview-core/src/dockview/components/panel/content.ts +++ b/packages/dockview-core/src/dockview/components/panel/content.ts @@ -12,6 +12,10 @@ import { pointerBackend } from '../../../dnd/backend'; import { DockviewGroupPanelModel } from '../../dockviewGroupPanelModel'; import { getPanelData } from '../../../dnd/dataTransfer'; +let _contentId = 0; +/** Stable DOM id so each tab's `aria-controls` can reference its tabpanel. */ +const nextContentId = (): string => `dv-tabpanel-${_contentId++}`; + export interface IContentContainer extends IDisposable { // `Droptarget` here (not `IDropTarget`) because overlayRenderContainer // forwards HTML5 drag events through `dropTarget.dnd` (the inner @@ -28,6 +32,7 @@ export interface IContentContainer extends IDisposable { hide(): void; renderPanel(panel: IDockviewPanel, options: { asActive: boolean }): void; refreshFocusState(): void; + setLabelledBy(tabElementId: string | undefined): void; } export class ContentContainer @@ -60,6 +65,11 @@ export class ContentContainer this._element = document.createElement('div'); this._element.className = 'dv-content-container'; this._element.tabIndex = -1; + // WAI-ARIA Tabs pattern: the single content area per group is the + // tabpanel; `aria-labelledby` is pointed at the active tab in + // `setLabelledBy` (driven from the group model on activation). + this._element.id = nextContentId(); + this._element.setAttribute('role', 'tabpanel'); this.addDisposables(this._onDidFocus, this._onDidBlur); @@ -135,6 +145,14 @@ export class ContentContainer this.element.style.display = 'none'; } + setLabelledBy(tabElementId: string | undefined): void { + if (tabElementId) { + this._element.setAttribute('aria-labelledby', tabElementId); + } else { + this._element.removeAttribute('aria-labelledby'); + } + } + renderPanel( panel: IDockviewPanel, options: { asActive: boolean } = { asActive: true } diff --git a/packages/dockview-core/src/dockview/components/tab/tab.ts b/packages/dockview-core/src/dockview/components/tab/tab.ts index d52784379..06176cb77 100644 --- a/packages/dockview-core/src/dockview/components/tab/tab.ts +++ b/packages/dockview-core/src/dockview/components/tab/tab.ts @@ -26,6 +26,10 @@ import { IDockviewPanel } from '../../dockviewPanel'; import { DockviewHeaderDirection } from '../../options'; import { resolveDndCapabilities } from '../../dndCapabilities'; +let _tabId = 0; +/** Stable DOM id referenced by the tabpanel's `aria-labelledby`. */ +const nextTabId = (): string => `dv-tab-${_tabId++}`; + export class Tab extends CompositeDisposable { private readonly _element: HTMLElement; private readonly dropTarget: IDropTarget; @@ -71,6 +75,15 @@ export class Tab extends CompositeDisposable { this._element.className = 'dv-tab'; this._element.tabIndex = 0; this._element.draggable = caps.html5; + // WAI-ARIA Tabs pattern. `aria-controls` points at the group's single + // tabpanel (the content container); `aria-selected` tracks activation. + this._element.id = nextTabId(); + this._element.setAttribute('role', 'tab'); + this._element.setAttribute('aria-selected', 'false'); + const contentContainerId = this.group?.model?.contentContainerId; + if (contentContainerId) { + this._element.setAttribute('aria-controls', contentContainerId); + } toggleClass(this.element, 'dv-inactive-tab', true); @@ -237,6 +250,10 @@ export class Tab extends CompositeDisposable { public setActive(isActive: boolean): void { toggleClass(this.element, 'dv-active-tab', isActive); toggleClass(this.element, 'dv-inactive-tab', !isActive); + this._element.setAttribute( + 'aria-selected', + isActive ? 'true' : 'false' + ); } public setContent(part: ITabRenderer): void { diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabs.ts b/packages/dockview-core/src/dockview/components/titlebar/tabs.ts index c01509f88..6ff470c75 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabs.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabs.ts @@ -201,6 +201,10 @@ export class Tabs extends CompositeDisposable { } this._direction = value; + this._tabsList.setAttribute( + 'aria-orientation', + value === 'vertical' ? 'vertical' : 'horizontal' + ); if (this._scrollbar) { this._scrollbar.orientation = value; } @@ -233,6 +237,12 @@ export class Tabs extends CompositeDisposable { this._tabsList = document.createElement('div'); this._tabsList.className = 'dv-tabs-container'; + // WAI-ARIA Tabs pattern: the strip of tabs is the tablist. + this._tabsList.setAttribute('role', 'tablist'); + this._tabsList.setAttribute( + 'aria-orientation', + this._direction === 'vertical' ? 'vertical' : 'horizontal' + ); this.showTabsOverflowControl = options.showTabsOverflowControl; @@ -501,6 +511,11 @@ export class Tabs extends CompositeDisposable { return this._tabs.findIndex((tab) => tab.value.panel.id === id); } + /** DOM id of the tab element for a panel — for the tabpanel's `aria-labelledby`. */ + getTabId(panelId: string): string | undefined { + return this._tabMap.get(panelId)?.value.element.id; + } + isActive(tab: Tab): boolean { return ( this.selectedIndex > -1 && diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts index 2f4e078d9..a752247a7 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts @@ -56,6 +56,7 @@ export interface ITabsContainer extends IDisposable { direction: DockviewHeaderDirection; delete(id: string): void; indexOf(id: string): number; + getTabId(panelId: string): string | undefined; setActive(isGroupActive: boolean): void; setActivePanel(panel: IDockviewPanel): void; isActive(tab: Tab): boolean; @@ -360,6 +361,10 @@ export class TabsContainer return this.tabs.indexOf(id); } + getTabId(panelId: string): string | undefined { + return this.tabs.getTabId(panelId); + } + setActive(_isGroupActive: boolean) { // noop } diff --git a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts index 17bfd766b..3a642fd48 100644 --- a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts +++ b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts @@ -357,6 +357,11 @@ export class DockviewGroupPanelModel return this._activePanel; } + /** DOM id of the content container (the group's tabpanel), referenced by each tab's `aria-controls`. */ + get contentContainerId(): string { + return this.contentContainer.element.id; + } + get locked(): DockviewGroupPanelLocked { return this._locked; } @@ -496,6 +501,9 @@ export class DockviewGroupPanelModel super(); toggleClass(this.container, 'dv-groupview', true); + // Each group is a navigable region, labelled by its active panel + // (see `updateAccessibleLabel`, driven on activation / title change). + this.container.setAttribute('role', 'region'); this._api = new DockviewApi(this.accessor); @@ -514,6 +522,11 @@ export class DockviewGroupPanelModel options.headerPosition ?? accessor.defaultHeaderPosition; this.addDisposables( + // Keep the region's accessible name in sync when the active + // panel's title changes (no-op when a non-active title changes). + this._onDidPanelTitleChange.event(() => + this.updateAccessibleLabel() + ), this._onTabDragStart, this._onGroupDragStart, this._onWillShowOverlay, @@ -1557,6 +1570,11 @@ export class DockviewGroupPanelModel if (panel) { this.tabsContainer.setActivePanel(panel); + // Point the tabpanel's `aria-labelledby` at the now-active tab. + this.contentContainer.setLabelledBy( + this.tabsContainer.getTabId(panel.id) + ); + this.contentContainer.openPanel(panel); panel.layout(this._width, this._height); @@ -1570,6 +1588,18 @@ export class DockviewGroupPanelModel panel, }); } + + this.updateAccessibleLabel(); + } + + /** Label the group region with its active panel's title (the WAI-ARIA region name). */ + private updateAccessibleLabel(): void { + const title = this._activePanel?.title; + if (title) { + this.container.setAttribute('aria-label', title); + } else { + this.container.removeAttribute('aria-label'); + } } private updateMru(panel: IDockviewPanel): void { diff --git a/packages/dockview-core/src/overlay/overlay.ts b/packages/dockview-core/src/overlay/overlay.ts index 5f5904de2..0274c4352 100644 --- a/packages/dockview-core/src/overlay/overlay.ts +++ b/packages/dockview-core/src/overlay/overlay.ts @@ -116,6 +116,11 @@ export class Overlay extends CompositeDisposable { ); this._element.className = 'dv-resize-container'; + // Floating groups are non-modal dialogs over the layout. The contained + // group(s) carry their own `role="region"` + label, so the dialog + // itself only needs role + modality here. + this._element.setAttribute('role', 'dialog'); + this._element.setAttribute('aria-modal', 'false'); this._isVisible = true; this.setupResize('top');