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
208 changes: 208 additions & 0 deletions packages/dockview-core/src/__tests__/dockview/accessibility.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void>();
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');
});
});
18 changes: 18 additions & 0 deletions packages/dockview-core/src/dockview/components/panel/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 }
Expand Down
17 changes: 17 additions & 0 deletions packages/dockview-core/src/dockview/components/tab/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions packages/dockview-core/src/dockview/components/titlebar/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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 &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading