Merge pull request #701 from iammola/tab-accessibility

feat: dockview accessibility
This commit is contained in:
mathuo 2025-06-03 20:46:29 +01:00 committed by GitHub
commit 9c04db8093
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 244 additions and 7 deletions

View File

@ -50,6 +50,7 @@ describe('groupPanelApi', () => {
const accessor = fromPartial<DockviewComponent>({
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidActivePanelChange: jest.fn(),
options: {},
onDidOptionsChange: jest.fn(),
});
@ -83,6 +84,7 @@ describe('groupPanelApi', () => {
const accessor = fromPartial<DockviewComponent>({
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidActivePanelChange: jest.fn(),
options: {},
onDidOptionsChange: jest.fn(),
});

View File

@ -17,6 +17,7 @@ describe('tabsContainer', () => {
const accessor = fromPartial<DockviewComponent>({
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidActivePanelChange: jest.fn(),
options: {},
onDidOptionsChange: jest.fn(),
});
@ -71,6 +72,7 @@ describe('tabsContainer', () => {
id: 'testcomponentid',
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidActivePanelChange: jest.fn(),
options: {},
onDidOptionsChange: jest.fn(),
});
@ -141,6 +143,7 @@ describe('tabsContainer', () => {
id: 'testcomponentid',
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidActivePanelChange: jest.fn(),
options: {},
onDidOptionsChange: jest.fn(),
});
@ -205,6 +208,7 @@ describe('tabsContainer', () => {
id: 'testcomponentid',
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidActivePanelChange: jest.fn(),
options: {},
onDidOptionsChange: jest.fn(),
});
@ -269,6 +273,7 @@ describe('tabsContainer', () => {
id: 'testcomponentid',
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidActivePanelChange: jest.fn(),
options: {},
onDidOptionsChange: jest.fn(),
});
@ -338,6 +343,7 @@ describe('tabsContainer', () => {
id: 'testcomponentid',
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidActivePanelChange: jest.fn(),
options: {},
onDidOptionsChange: jest.fn(),
});
@ -403,6 +409,7 @@ describe('tabsContainer', () => {
id: 'testcomponentid',
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidActivePanelChange: jest.fn(),
options: {},
onDidOptionsChange: jest.fn(),
});
@ -468,6 +475,7 @@ describe('tabsContainer', () => {
options: {},
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidActivePanelChange: jest.fn(),
element: document.createElement('div'),
addFloatingGroup: jest.fn(),
doSetGroupActive: jest.fn(),
@ -525,6 +533,7 @@ describe('tabsContainer', () => {
options: {},
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidActivePanelChange: jest.fn(),
element: document.createElement('div'),
addFloatingGroup: jest.fn(),
doSetGroupActive: jest.fn(),
@ -577,6 +586,7 @@ describe('tabsContainer', () => {
options: {},
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidActivePanelChange: jest.fn(),
element: document.createElement('div'),
addFloatingGroup: jest.fn(),
getGroupPanel: jest.fn(),
@ -634,6 +644,7 @@ describe('tabsContainer', () => {
options: {},
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidActivePanelChange: jest.fn(),
element: document.createElement('div'),
addFloatingGroup: jest.fn(),
getGroupPanel: jest.fn(),
@ -702,6 +713,7 @@ describe('tabsContainer', () => {
options: {},
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidActivePanelChange: jest.fn(),
element: document.createElement('div'),
addFloatingGroup: jest.fn(),
getGroupPanel: jest.fn(),
@ -770,6 +782,7 @@ describe('tabsContainer', () => {
options: {},
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidActivePanelChange: jest.fn(),
element: document.createElement('div'),
addFloatingGroup: jest.fn(),
getGroupPanel: jest.fn(),

View File

@ -6917,4 +6917,91 @@ describe('dockviewComponent', () => {
dockview.layout(1000, 1000);
});
});
test('that arrow keys should activate appropriate tabs', () => {
dockview.layout(500, 1000);
dockview.addPanel({
id: 'panel1',
component: 'default',
});
dockview.addPanel({
id: 'panel2',
component: 'default',
position: { referencePanel: 'panel1', direction: 'within' },
});
dockview.addPanel({
id: 'panel3',
component: 'default',
});
dockview.addPanel({
id: 'panel4',
component: 'default',
position: { referencePanel: 'panel3', direction: 'below' },
});
const panel1 = dockview.getGroupPanel('panel1')!;
const panel2 = dockview.getGroupPanel('panel2')!;
const panel3 = dockview.getGroupPanel('panel3')!;
const panel4 = dockview.getGroupPanel('panel4')!;
panel1.api.setActive();
expect(panel1.api.isActive).toBeTruthy();
expect(panel2.api.isActive).toBeFalsy();
expect(panel3.api.isActive).toBeFalsy();
expect(panel4.api.isActive).toBeFalsy();
const tabsContainer = (panel: IDockviewPanel) =>
panel.api.group.element.querySelector('.tabs-container')!;
const event = new KeyboardEvent('keydown', { key: 'ArrowRight' });
fireEvent(tabsContainer(panel1), event);
expect(panel1.api.isActive).toBeFalsy();
expect(panel2.api.isActive).toBeTruthy();
expect(panel3.api.isActive).toBeFalsy();
expect(panel4.api.isActive).toBeFalsy();
fireEvent(tabsContainer(panel1), event);
expect(panel1.api.isActive).toBeFalsy();
expect(panel2.api.isActive).toBeFalsy();
expect(panel3.api.isActive).toBeTruthy();
expect(panel4.api.isActive).toBeFalsy();
const event2 = new KeyboardEvent('keydown', { key: 'ArrowLeft' });
fireEvent(tabsContainer(panel1), event2);
expect(panel1.api.isActive).toBeFalsy();
expect(panel2.api.isActive).toBeTruthy();
expect(panel3.api.isActive).toBeFalsy();
expect(panel4.api.isActive).toBeFalsy();
fireEvent(tabsContainer(panel1), event2);
expect(panel1.api.isActive).toBeTruthy();
expect(panel2.api.isActive).toBeFalsy();
expect(panel3.api.isActive).toBeFalsy();
expect(panel4.api.isActive).toBeFalsy();
panel4.api.setActive();
expect(panel1.api.isActive).toBeFalsy();
expect(panel2.api.isActive).toBeFalsy();
expect(panel3.api.isActive).toBeFalsy();
expect(panel4.api.isActive).toBeTruthy();
fireEvent(tabsContainer(panel4), event2);
expect(panel1.api.isActive).toBeFalsy();
expect(panel2.api.isActive).toBeFalsy();
expect(panel3.api.isActive).toBeFalsy();
expect(panel4.api.isActive).toBeTruthy();
fireEvent(tabsContainer(panel4), event);
expect(panel1.api.isActive).toBeFalsy();
expect(panel2.api.isActive).toBeFalsy();
expect(panel3.api.isActive).toBeFalsy();
expect(panel4.api.isActive).toBeTruthy();
});
});

View File

@ -174,6 +174,8 @@ export class TestPanel implements IDockviewPanel {
private _group: DockviewGroupPanel | undefined;
private _params: IGroupPanelInitParameters | undefined;
readonly view: IDockviewPanelModel;
readonly componentElId: string;
readonly tabComponentElId: string;
get title() {
return '';
@ -189,6 +191,8 @@ export class TestPanel implements IDockviewPanel {
constructor(public readonly id: string, public api: DockviewPanelApi) {
this.view = new TestModel(id);
this.tabComponentElId = `tab-${id}`;
this.componentElId = `tab-panel-${id}`;
this.init({
title: `${id}`,
params: {},
@ -266,6 +270,7 @@ describe('dockviewGroupPanelModel', () => {
removeGroup: removeGroupMock,
onDidAddPanel: () => ({ dispose: jest.fn() }),
onDidRemovePanel: () => ({ dispose: jest.fn() }),
onDidActivePanelChange: () => ({ dispose: jest.fn() }),
overlayRenderContainer: new OverlayRenderContainer(
document.createElement('div'),
fromPartial<DockviewComponent>({})
@ -653,6 +658,7 @@ describe('dockviewGroupPanelModel', () => {
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidOptionsChange: jest.fn(),
onDidActivePanelChange: jest.fn(),
});
const groupviewMock = jest.fn<Partial<DockviewGroupPanelModel>, []>(
@ -716,6 +722,7 @@ describe('dockviewGroupPanelModel', () => {
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidOptionsChange: jest.fn(),
onDidActivePanelChange: jest.fn(),
});
const groupviewMock = jest.fn<Partial<DockviewGroupPanelModel>, []>(
@ -808,6 +815,7 @@ describe('dockviewGroupPanelModel', () => {
doSetGroupActive: jest.fn(),
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidActivePanelChange: jest.fn(),
overlayRenderContainer: new OverlayRenderContainer(
document.createElement('div'),
fromPartial<DockviewComponent>({})
@ -875,6 +883,7 @@ describe('dockviewGroupPanelModel', () => {
doSetGroupActive: jest.fn(),
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidActivePanelChange: jest.fn(),
overlayRenderContainer: new OverlayRenderContainer(
document.createElement('div'),
fromPartial<DockviewComponent>({})
@ -949,6 +958,7 @@ describe('dockviewGroupPanelModel', () => {
doSetGroupActive: jest.fn(),
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidActivePanelChange: jest.fn(),
overlayRenderContainer: new OverlayRenderContainer(
document.createElement('div'),
fromPartial<DockviewComponent>({})
@ -1030,6 +1040,7 @@ describe('dockviewGroupPanelModel', () => {
return {
id: 'testgroupid',
model: groupView,
dispose: jest.fn()
};
});

View File

@ -7,6 +7,7 @@ describe('gridviewPanel', () => {
return {
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
onDidActivePanelChange: jest.fn(),
options: {},
onDidOptionsChange: jest.fn(),
} as any;

View File

@ -6,7 +6,7 @@ import { createCloseButton } from '../../../svg';
export class DefaultTab extends CompositeDisposable implements ITabRenderer {
private readonly _element: HTMLElement;
private readonly _content: HTMLElement;
private readonly action: HTMLElement;
private readonly action: HTMLButtonElement;
private _title: string | undefined;
get element(): HTMLElement {
@ -22,22 +22,38 @@ export class DefaultTab extends CompositeDisposable implements ITabRenderer {
this._content = document.createElement('div');
this._content.className = 'dv-default-tab-content';
this.action = document.createElement('div');
this.action = document.createElement('button');
this.action.type = 'button';
this.action.className = 'dv-default-tab-action';
// originally hide this, so only when it is focused is it read out.
// so the SR when focused on the tab, doesn't read "<Tab Content> Close Button"
this.action.ariaHidden = 'true';
this.action.appendChild(createCloseButton());
this._element.appendChild(this._content);
this._element.appendChild(this.action);
this.addDisposables(
addDisposableListener(this.action, 'focus', (event) => {
this.action.ariaHidden = 'false';
}),
addDisposableListener(this.action, 'blur', (event) => {
this.action.ariaHidden = 'true';
})
);
this.render();
}
init(params: GroupPanelPartInitParameters): void {
this._title = params.title;
this.action.ariaLabel = `Close "${this._title}" tab`;
this.addDisposables(
params.api.onDidTitleChange((event) => {
this._title = event.title;
this.action.ariaLabel = `Close "${event.title}" tab`;
this.render();
}),
addDisposableListener(this.action, 'pointerdown', (ev) => {
@ -50,6 +66,18 @@ export class DefaultTab extends CompositeDisposable implements ITabRenderer {
ev.preventDefault();
params.api.close();
}),
addDisposableListener(this.action, 'keydown', (ev) => {
if (ev.defaultPrevented) {
return;
}
switch (ev.key) {
case 'Enter':
case 'Space':
params.api.close();
break;
}
})
);

View File

@ -52,6 +52,9 @@ export class Tab extends CompositeDisposable {
private readonly _onPointDown = new Emitter<MouseEvent>();
readonly onPointerDown: Event<MouseEvent> = this._onPointDown.event;
private readonly _onKeyDown = new Emitter<KeyboardEvent>();
readonly onKeyDown: Event<KeyboardEvent> = this._onKeyDown.event;
private readonly _onDropped = new Emitter<DroptargetEvent>();
readonly onDrop: Event<DroptargetEvent> = this._onDropped.event;
@ -73,9 +76,13 @@ export class Tab extends CompositeDisposable {
super();
this._element = document.createElement('div');
this._element.id = this.panel.tabComponentElId;
this._element.className = 'dv-tab';
this._element.tabIndex = 0;
this._element.role = 'tab';
this._element.tabIndex = -1;
this._element.draggable = true;
this._element.ariaSelected = 'false';
this._element.setAttribute('aria-controls', this.panel.componentElId);
toggleClass(this.element, 'dv-inactive-tab', true);
@ -139,6 +146,9 @@ export class Tab extends CompositeDisposable {
addDisposableListener(this._element, 'pointerdown', (event) => {
this._onPointDown.fire(event);
}),
addDisposableListener(this._element, 'keydown', (event) => {
this._onKeyDown.fire(event);
}),
this.dropTarget.onDrop((event) => {
this._onDropped.fire(event);
}),
@ -147,6 +157,9 @@ export class Tab extends CompositeDisposable {
}
public setActive(isActive: boolean): void {
this.element.tabIndex = isActive ? 0 : -1;
this.element.ariaSelected = isActive.toString();
toggleClass(this.element, 'dv-active-tab', isActive);
toggleClass(this.element, 'dv-inactive-tab', !isActive);
}

View File

@ -98,6 +98,7 @@ export class Tabs extends CompositeDisposable {
this._tabsList = document.createElement('div');
this._tabsList.className = 'dv-tabs-container dv-horizontal';
this._tabsList.ariaOrientation = 'horizontal';
this.showTabsOverflowControl = options.showTabsOverflowControl;
@ -109,12 +110,23 @@ export class Tabs extends CompositeDisposable {
this.addDisposables(scrollbar);
}
this.element.role = 'tablist';
this.element.ariaLabel =
'Use the Left Arrow to select the previous tab, Right Arrow for the next tab, Home for the first tab, and End for the last tab. Press Enter to select the focused tab.';
this.addDisposables(
this._onOverflowTabsChange,
this._observerDisposable,
this._onWillShowOverlay,
this._onDrop,
this._onTabDragStart,
this.accessor.onDidActivePanelChange((e) => {
if (e?.api.group === this.group) {
this.selectedIndex = this.indexOf(e.id);
} else {
this.selectedIndex = -1;
}
}),
addDisposableListener(this.element, 'pointerdown', (event) => {
if (event.defaultPrevented) {
return;
@ -154,6 +166,7 @@ export class Tabs extends CompositeDisposable {
for (const tab of this._tabs) {
const isActivePanel = panel.id === tab.value.panel.id;
tab.value.setActive(isActivePanel);
tab.value.panel.runEvents();
if (isActivePanel) {
const element = tab.value.element;
@ -224,6 +237,40 @@ export class Tabs extends CompositeDisposable {
break;
}
}),
tab.onKeyDown((event) => {
if (event.defaultPrevented) {
return;
}
const index = this.indexOf(tab.panel.id);
let nextTab: Tab | undefined = undefined;
switch (event.key) {
case 'ArrowLeft':
nextTab = this.tabs[Math.max(0, index - 1)];
break;
case 'ArrowRight':
nextTab = this.tabs[Math.min(this.size - 1, index + 1)];
break;
case 'Home':
nextTab = this.tabs[0];
break;
case 'End':
nextTab = this.tabs[this.size - 1];
break;
case 'Enter':
case 'Space':
nextTab = tab;
}
if (
nextTab != null &&
this.group.activePanel !== nextTab.panel
) {
nextTab.element.focus();
this.group.model.openPanel(nextTab.panel);
}
}),
tab.onDrop((event) => {
this._onDrop.fire({
event: event.nativeEvent,

View File

@ -20,6 +20,9 @@
&.dv-active-group {
> .dv-tabs-and-actions-container {
.dv-tabs-container > .dv-tab {
&:focus {
outline: 1px solid var(--dv-paneview-active-outline-color);
}
&.dv-active-tab {
background-color: var(
--dv-activegroup-visiblepanel-tab-background-color
@ -38,6 +41,9 @@
&.dv-inactive-group {
> .dv-tabs-and-actions-container {
.dv-tabs-container > .dv-tab {
&:focus {
outline: 1px solid var(--dv-paneview-active-outline-color);
}
&.dv-active-tab {
background-color: var(
--dv-inactivegroup-visiblepanel-tab-background-color

View File

@ -19,6 +19,8 @@ export interface IDockviewPanel extends IDisposable, IPanel {
readonly api: DockviewPanelApi;
readonly title: string | undefined;
readonly params: Parameters | undefined;
readonly componentElId: string;
readonly tabComponentElId: string;
readonly minimumWidth?: number;
readonly minimumHeight?: number;
readonly maximumWidth?: number;
@ -39,6 +41,18 @@ export class DockviewPanel
implements IDockviewPanel
{
readonly api: DockviewPanelApiImpl;
/**
* The unique DOM id for the rendered panel element
*
* Used for accessibility attributes
*/
readonly componentElId: string;
/**
* The unique DOM id for the rendered tab element
*
* Used for accessibility attributes
*/
readonly tabComponentElId: string;
private _group: DockviewGroupPanel;
private _params?: Parameters;
@ -100,6 +114,11 @@ export class DockviewPanel
this._maximumWidth = options.maximumWidth;
this._maximumHeight = options.maximumHeight;
const randomId = Math.random().toString(36).slice(2);
this.tabComponentElId = `tab-${id}-${randomId}`;
this.componentElId = `tab-panel-${id}-${randomId}`;
this.api = new DockviewPanelApiImpl(
this,
this._group,

View File

@ -642,7 +642,7 @@ export class Gridview implements IDisposable {
}
this._root = root;
this.element.appendChild(this._root.element);
this.element.prepend(this._root.element);
this.disposable.value = this._root.onDidChange((e) => {
this._onDidChange.fire(e);
});
@ -698,7 +698,7 @@ export class Gridview implements IDisposable {
this._root.addChild(oldRoot, Sizing.Distribute, 0);
}
this.element.appendChild(this._root.element);
this.element.prepend(this._root.element);
this.disposable.value = this._root.onDidChange((e) => {
this._onDidChange.fire(e);

View File

@ -73,12 +73,14 @@ export class OverlayRenderContainer extends CompositeDisposable {
if (!this.map[panel.api.id]) {
const element = createFocusableElement();
element.className = 'dv-render-overlay';
element.role = 'tabpanel';
element.tabIndex = 0;
element.setAttribute('aria-labelledby', panel.tabComponentElId);
this.map[panel.api.id] = {
panel,
disposable: Disposable.NONE,
destroy: Disposable.NONE,
element,
};
}

View File

@ -165,6 +165,10 @@ export class Splitview {
? 'dv-horizontal'
: 'dv-vertical'
);
this.element.ariaOrientation =
this.orientation == Orientation.HORIZONTAL
? 'horizontal'
: 'vertical';
}
get minimumSize(): number {
@ -1148,6 +1152,10 @@ export class Splitview {
? 'dv-horizontal'
: 'dv-vertical';
element.className = `dv-split-view-container ${orientationClassname}`;
element.ariaOrientation =
this._orientation == Orientation.HORIZONTAL
? 'horizontal'
: 'vertical';
return element;
}