Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions packages/dockview-core/src/__tests__/dockview/accessibility.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
});
});
8 changes: 7 additions & 1 deletion packages/dockview-core/src/dockview/components/tab/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
56 changes: 56 additions & 0 deletions packages/dockview-core/src/dockview/components/titlebar/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions packages/dockview-core/src/dockview/floatingGroupService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}),
Expand Down
Loading