diff --git a/packages/dockview-core/src/__tests__/dockview/accessibility.spec.ts b/packages/dockview-core/src/__tests__/dockview/accessibility.spec.ts index a66422026..e77c0c042 100644 --- a/packages/dockview-core/src/__tests__/dockview/accessibility.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/accessibility.spec.ts @@ -1,3 +1,4 @@ +import { fireEvent } from '@testing-library/dom'; import { DockviewComponent } from '../../dockview/dockviewComponent'; import { IContentRenderer } from '../../dockview/types'; import { GroupPanelPartInitParameters } from '../../dockview/types'; @@ -205,4 +206,143 @@ describe('accessibility: WAI-ARIA tabs baseline', () => { expect(dialog.getAttribute('role')).toBe('dialog'); expect(dialog.getAttribute('aria-modal')).toBe('false'); }); + + test('floating dialog is named by its active panel title', () => { + dockview.addPanel({ id: 'panel1', component: 'default' }); + dockview.addPanel({ + id: 'panel2', + component: 'default', + title: 'Floater', + floating: true, + }); + + const dialog = container.querySelector('.dv-resize-container')!; + expect(dialog.getAttribute('aria-label')).toBe('Floater'); + }); +}); + +/** + * Layer 2 — the free WAI-ARIA Tabs keyboard pattern within a strip + * (roving tabindex + arrow / Home / End navigation + manual activation). + */ +describe('accessibility: tab keyboard navigation', () => { + let container: HTMLElement; + let dockview: DockviewComponent; + + beforeEach(() => { + container = document.createElement('div'); + // Attach to the document so `.focus()` updates `document.activeElement` + // (jsdom only focuses connected elements). + document.body.appendChild(container); + dockview = new DockviewComponent(container, { + createComponent(options) { + return new PanelContentPartTest(options.id, options.name); + }, + }); + dockview.layout(1000, 1000); + }); + + afterEach(() => { + dockview.dispose(); + container.remove(); + }); + + const realTabs = (): HTMLElement[] => + Array.from(container.querySelectorAll('.dv-tab')) as HTMLElement[]; + + test('roving tabindex — only the active tab is in the tab order', () => { + dockview.addPanel({ id: 'p1', component: 'default' }); + dockview.addPanel({ id: 'p2', component: 'default' }); // p2 active + + const tabs = realTabs(); + const active = tabs.filter( + (t) => t.getAttribute('aria-selected') === 'true' + ); + expect(active).toHaveLength(1); + expect(active[0].tabIndex).toBe(0); + tabs.filter((t) => t !== active[0]).forEach((t) => + expect(t.tabIndex).toBe(-1) + ); + }); + + test('arrow keys move the roving focus along the strip', () => { + const p1 = dockview.addPanel({ id: 'p1', component: 'default' }); + dockview.addPanel({ id: 'p2', component: 'default' }); + dockview.addPanel({ id: 'p3', component: 'default' }); + p1.api.setActive(); + + const [t1, t2, t3] = realTabs(); + expect(t1.tabIndex).toBe(0); + + fireEvent.keyDown(t1, { key: 'ArrowRight' }); + expect(document.activeElement).toBe(t2); + expect(t2.tabIndex).toBe(0); + expect(t1.tabIndex).toBe(-1); + + fireEvent.keyDown(t2, { key: 'ArrowRight' }); + expect(document.activeElement).toBe(t3); + + fireEvent.keyDown(t3, { key: 'ArrowLeft' }); + expect(document.activeElement).toBe(t2); + }); + + test('arrow navigation clamps at the ends', () => { + const p1 = dockview.addPanel({ id: 'p1', component: 'default' }); + dockview.addPanel({ id: 'p2', component: 'default' }); + p1.api.setActive(); + const [t1, t2] = realTabs(); + + // ArrowLeft at the first tab is a no-op. + t1.focus(); + fireEvent.keyDown(t1, { key: 'ArrowLeft' }); + expect(document.activeElement).toBe(t1); + + // ArrowRight at the last tab is a no-op. + fireEvent.keyDown(t1, { key: 'ArrowRight' }); + expect(document.activeElement).toBe(t2); + fireEvent.keyDown(t2, { key: 'ArrowRight' }); + expect(document.activeElement).toBe(t2); + }); + + test('Home and End jump to the first and last tab', () => { + const p1 = dockview.addPanel({ id: 'p1', component: 'default' }); + dockview.addPanel({ id: 'p2', component: 'default' }); + dockview.addPanel({ id: 'p3', component: 'default' }); + p1.api.setActive(); + const tabs = realTabs(); + + fireEvent.keyDown(tabs[0], { key: 'End' }); + expect(document.activeElement).toBe(tabs[2]); + + fireEvent.keyDown(tabs[2], { key: 'Home' }); + expect(document.activeElement).toBe(tabs[0]); + }); + + test('arrowing moves focus but does not activate (manual activation)', () => { + const p1 = dockview.addPanel({ id: 'p1', component: 'default' }); + const p2 = dockview.addPanel({ id: 'p2', component: 'default' }); + p1.api.setActive(); + + const [t1] = realTabs(); + fireEvent.keyDown(t1, { key: 'ArrowRight' }); + + expect(p1.api.isActive).toBe(true); + expect(p2.api.isActive).toBe(false); + }); + + test('Enter and Space activate the focused tab', () => { + const p1 = dockview.addPanel({ id: 'p1', component: 'default' }); + const p2 = dockview.addPanel({ id: 'p2', component: 'default' }); + const p3 = dockview.addPanel({ id: 'p3', component: 'default' }); + p1.api.setActive(); + const [t1, t2, t3] = realTabs(); + + fireEvent.keyDown(t1, { key: 'ArrowRight' }); + fireEvent.keyDown(t2, { key: 'Enter' }); + expect(p2.api.isActive).toBe(true); + + fireEvent.keyDown(t2, { key: 'ArrowRight' }); + fireEvent.keyDown(t3, { key: ' ' }); + expect(p3.api.isActive).toBe(true); + }); }); diff --git a/packages/dockview-core/src/dockview/components/tab/tab.ts b/packages/dockview-core/src/dockview/components/tab/tab.ts index f49b84284..c89f5b0de 100644 --- a/packages/dockview-core/src/dockview/components/tab/tab.ts +++ b/packages/dockview-core/src/dockview/components/tab/tab.ts @@ -74,7 +74,10 @@ export class Tab extends CompositeDisposable { this._element = document.createElement('div'); this._element.className = 'dv-tab'; - this._element.tabIndex = 0; + // Roving tabindex (WAI-ARIA Tabs pattern): only the active tab is in + // the tab order; `setActive` flips this. Inactive tabs are reachable + // via arrow keys, handled by the tab strip. + this._element.tabIndex = -1; 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. @@ -255,6 +258,9 @@ export class Tab extends CompositeDisposable { 'aria-selected', isActive ? 'true' : 'false' ); + // Roving tabindex anchors to the active tab; arrow-key navigation in + // the tab strip moves the rover from there. + this._element.tabIndex = isActive ? 0 : -1; } 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 6ff470c75..6d37797eb 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabs.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabs.ts @@ -380,6 +380,13 @@ export class Tabs extends CompositeDisposable { }, { passive: false } ), + // WAI-ARIA Tabs keyboard pattern: arrow keys move the roving + // focus along the strip, Home/End jump to the ends, and + // Enter/Space activate the focused tab (manual activation, so + // arrowing through tabs doesn't switch panels until committed). + addDisposableListener(this._tabsList, 'keydown', (event) => { + this._onKeyDown(event); + }), addDisposableListener( this._tabsList, 'dragover', @@ -523,6 +530,55 @@ export class Tabs extends CompositeDisposable { ); } + private _onKeyDown(event: KeyboardEvent): void { + // Only handle when a tab element itself is focused — never hijack keys + // typed inside a custom tab renderer's own controls (inputs etc.). + const index = this._tabs.findIndex( + (tab) => tab.value.element === event.target + ); + if (index === -1) { + return; + } + + const isVertical = this._direction === 'vertical'; + const nextKey = isVertical ? 'ArrowDown' : 'ArrowRight'; + const prevKey = isVertical ? 'ArrowUp' : 'ArrowLeft'; + const last = this._tabs.length - 1; + + switch (event.key) { + case nextKey: + event.preventDefault(); + this._focusTab(Math.min(index + 1, last)); + break; + case prevKey: + event.preventDefault(); + this._focusTab(Math.max(index - 1, 0)); + break; + case 'Home': + event.preventDefault(); + this._focusTab(0); + break; + case 'End': + event.preventDefault(); + this._focusTab(last); + break; + case 'Enter': + case ' ': + // Manual activation of the focused tab. + event.preventDefault(); + this._tabs[index].value.panel.api.setActive(); + break; + } + } + + /** Move the roving focus to the tab at `index` (updates tabindex + DOM focus). */ + private _focusTab(index: number): void { + for (let i = 0; i < this._tabs.length; i++) { + this._tabs[i].value.element.tabIndex = i === index ? 0 : -1; + } + this._tabs[index].value.element.focus(); + } + setActivePanel(panel: IDockviewPanel): void { const isVertical = this._direction === 'vertical'; let running = 0; diff --git a/packages/dockview-core/src/dockview/floatingGroupService.ts b/packages/dockview-core/src/dockview/floatingGroupService.ts index 68773da8d..a4009a010 100644 --- a/packages/dockview-core/src/dockview/floatingGroupService.ts +++ b/packages/dockview-core/src/dockview/floatingGroupService.ts @@ -90,7 +90,22 @@ export class FloatingGroupService implements IFloatingGroupService { })() ); + // Floating windows are non-modal dialogs (role set in Overlay). Give + // the dialog an accessible name from the representative group's active + // panel, refreshed as the active panel changes. An untitled panel + // leaves the dialog unnamed rather than hard-coding a label string. + const updateDialogLabel = (): void => { + const title = group.activePanel?.title; + if (title) { + overlay.element.setAttribute('aria-label', title); + } else { + overlay.element.removeAttribute('aria-label'); + } + }; + updateDialogLabel(); + floatingGroupPanel.addDisposables( + group.api.onDidActivePanelChange(() => updateDialogLabel()), overlay.onDidChange(() => { gridview.layout(gridview.width, gridview.height); }),