diff --git a/packages/dockview-core/src/__tests__/api/dockviewPanelApi.spec.ts b/packages/dockview-core/src/__tests__/api/dockviewPanelApi.spec.ts index 0bc24eaa5..720823242 100644 --- a/packages/dockview-core/src/__tests__/api/dockviewPanelApi.spec.ts +++ b/packages/dockview-core/src/__tests__/api/dockviewPanelApi.spec.ts @@ -49,6 +49,7 @@ describe('groupPanelApi', () => { const accessor = fromPartial({ onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), options: {}, }); @@ -81,6 +82,7 @@ describe('groupPanelApi', () => { const accessor = fromPartial({ onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), options: {}, }); diff --git a/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabsContainer.spec.ts b/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabsContainer.spec.ts index eee78a588..d60c38aca 100644 --- a/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabsContainer.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabsContainer.spec.ts @@ -17,6 +17,7 @@ describe('tabsContainer', () => { const accessor = fromPartial({ onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), options: {}, }); @@ -70,6 +71,7 @@ describe('tabsContainer', () => { id: 'testcomponentid', onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), options: {}, }); @@ -136,6 +138,7 @@ describe('tabsContainer', () => { id: 'testcomponentid', onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), options: {}, }); @@ -199,6 +202,7 @@ describe('tabsContainer', () => { id: 'testcomponentid', onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), options: {}, }); @@ -262,6 +266,7 @@ describe('tabsContainer', () => { id: 'testcomponentid', onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), options: {}, }); @@ -330,6 +335,7 @@ describe('tabsContainer', () => { id: 'testcomponentid', onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), options: {}, }); @@ -394,6 +400,7 @@ describe('tabsContainer', () => { id: 'testcomponentid', onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), options: {}, }); @@ -458,6 +465,7 @@ describe('tabsContainer', () => { options: {}, onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), element: document.createElement('div'), addFloatingGroup: jest.fn(), doSetGroupActive: jest.fn(), @@ -514,6 +522,7 @@ describe('tabsContainer', () => { options: {}, onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), element: document.createElement('div'), addFloatingGroup: jest.fn(), doSetGroupActive: jest.fn(), @@ -565,6 +574,7 @@ describe('tabsContainer', () => { options: {}, onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), element: document.createElement('div'), addFloatingGroup: jest.fn(), getGroupPanel: jest.fn(), @@ -621,6 +631,7 @@ describe('tabsContainer', () => { options: {}, onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), element: document.createElement('div'), addFloatingGroup: jest.fn(), getGroupPanel: jest.fn(), @@ -688,6 +699,7 @@ describe('tabsContainer', () => { options: {}, onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), element: document.createElement('div'), addFloatingGroup: jest.fn(), getGroupPanel: jest.fn(), @@ -755,6 +767,7 @@ describe('tabsContainer', () => { options: {}, onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), element: document.createElement('div'), addFloatingGroup: jest.fn(), getGroupPanel: jest.fn(), diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts index 0be413221..a9ed3dc25 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts @@ -6612,4 +6612,91 @@ describe('dockviewComponent', () => { expect(dockview.gap).toBe(15); }); }); + + 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(); + }); }); diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts index 55aed39ec..f8ad58823 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts @@ -169,6 +169,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 ''; @@ -184,6 +186,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: {}, @@ -261,6 +265,7 @@ describe('dockviewGroupPanelModel', () => { removeGroup: removeGroupMock, onDidAddPanel: () => ({ dispose: jest.fn() }), onDidRemovePanel: () => ({ dispose: jest.fn() }), + onDidActivePanelChange: () => ({ dispose: jest.fn() }), overlayRenderContainer: new OverlayRenderContainer( document.createElement('div'), fromPartial({}) @@ -646,6 +651,7 @@ describe('dockviewGroupPanelModel', () => { getPanel: jest.fn(), onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), }); const groupviewMock = jest.fn, []>( @@ -708,6 +714,7 @@ describe('dockviewGroupPanelModel', () => { getPanel: jest.fn(), onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), }); const groupviewMock = jest.fn, []>( @@ -800,6 +807,7 @@ describe('dockviewGroupPanelModel', () => { doSetGroupActive: jest.fn(), onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), overlayRenderContainer: new OverlayRenderContainer( document.createElement('div'), fromPartial({}) @@ -872,6 +880,7 @@ describe('dockviewGroupPanelModel', () => { doSetGroupActive: jest.fn(), onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), overlayRenderContainer: new OverlayRenderContainer( document.createElement('div'), fromPartial({}) @@ -945,6 +954,7 @@ describe('dockviewGroupPanelModel', () => { doSetGroupActive: jest.fn(), onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), overlayRenderContainer: new OverlayRenderContainer( document.createElement('div'), fromPartial({}) @@ -1025,6 +1035,7 @@ describe('dockviewGroupPanelModel', () => { return { id: 'testgroupid', model: groupView, + dispose: jest.fn() }; }); diff --git a/packages/dockview-core/src/__tests__/gridview/gridviewPanel.spec.ts b/packages/dockview-core/src/__tests__/gridview/gridviewPanel.spec.ts index cfae5ea6b..88672e774 100644 --- a/packages/dockview-core/src/__tests__/gridview/gridviewPanel.spec.ts +++ b/packages/dockview-core/src/__tests__/gridview/gridviewPanel.spec.ts @@ -7,6 +7,7 @@ describe('gridviewPanel', () => { return { onDidAddPanel: jest.fn(), onDidRemovePanel: jest.fn(), + onDidActivePanelChange: jest.fn(), options: {}, } as any; }); diff --git a/packages/dockview-core/src/dockview/components/tab/tab.ts b/packages/dockview-core/src/dockview/components/tab/tab.ts index 1eb1174d8..6055086f1 100644 --- a/packages/dockview-core/src/dockview/components/tab/tab.ts +++ b/packages/dockview-core/src/dockview/components/tab/tab.ts @@ -72,10 +72,16 @@ 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.draggable = true; + this._element.role = 'tab'; + this._element.tabIndex = -1; + this._element.ariaSelected = 'false'; + this._element.setAttribute('aria-controls', this.panel.componentElId); + toggleClass(this.element, 'dv-inactive-tab', true); const dragHandler = new TabDragHandler( @@ -139,6 +145,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); } diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts index d3bd0568b..18cfc4208 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts @@ -195,6 +195,7 @@ export class TabsContainer this.tabContainer = document.createElement('div'); this.tabContainer.className = 'dv-tabs-container'; + this.tabContainer.role = 'tablist'; this.voidContainer = new VoidContainer(this.accessor, this.group); @@ -209,6 +210,13 @@ export class TabsContainer this._onDrop, this._onTabDragStart, this._onGroupDragStart, + this.accessor.onDidActivePanelChange((e) => { + if (e?.api.group === this.group) { + this.selectedIndex = this.indexOf(e.id); + } else { + this.selectedIndex = -1; + } + }), this.voidContainer, this.voidContainer.onDragStart((event) => { this._onGroupDragStart.fire({ @@ -270,6 +278,40 @@ export class TabsContainer if (isLeftClick) { this.accessor.doSetGroupActive(this.group); } + }), + addDisposableListener(this.tabContainer, 'keydown', (event) => { + if (event.defaultPrevented) { + return; + } + + let tab: IValueDisposable | undefined = undefined; + + switch (event.key) { + case 'ArrowLeft': { + if (this.selectedIndex > 0) { + tab = this.tabs[this.selectedIndex - 1]; + } + break; + } + case 'ArrowRight': { + if (this.selectedIndex + 1 < this.size) { + tab = this.tabs[this.selectedIndex + 1]; + } + break; + } + case 'Home': + tab = this.tabs[0]; + break; + case 'End': + tab = this.tabs[this.size - 1]; + break; + } + + if (tab == null) { + return; + } + + this.group.model.openPanel(tab.value.panel); }) ); } @@ -296,6 +338,7 @@ export class TabsContainer this.tabs.forEach((tab) => { const isActivePanel = panel.id === tab.value.panel.id; tab.value.setActive(isActivePanel); + tab.value.panel.runEvents(); }); } diff --git a/packages/dockview-core/src/dockview/dockviewPanel.ts b/packages/dockview-core/src/dockview/dockviewPanel.ts index bf71ca6c1..0c26f6eb8 100644 --- a/packages/dockview-core/src/dockview/dockviewPanel.ts +++ b/packages/dockview-core/src/dockview/dockviewPanel.ts @@ -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,8 @@ export class DockviewPanel implements IDockviewPanel { readonly api: DockviewPanelApiImpl; + readonly componentElId: string; + readonly tabComponentElId: string; private _group: DockviewGroupPanel; private _params?: Parameters; @@ -90,7 +94,11 @@ export class DockviewPanel private readonly containerApi: DockviewApi, group: DockviewGroupPanel, readonly view: IDockviewPanelModel, - options: { renderer?: DockviewPanelRenderer } & Partial + options: { + renderer?: DockviewPanelRenderer; + componentElId?: string; + tabComponentElId?: string; + } & Partial ) { super(); this._renderer = options.renderer; @@ -100,6 +108,13 @@ export class DockviewPanel this._maximumWidth = options.maximumWidth; this._maximumHeight = options.maximumHeight; + const randomId = Math.random().toString(36).slice(2); + + this.tabComponentElId = + options.tabComponentElId ?? `tab-${id}-${randomId}`; + this.componentElId = + options.componentElId ?? `tab-panel-${id}-${randomId}`; + this.api = new DockviewPanelApiImpl( this, this._group, diff --git a/packages/dockview-core/src/dockview/options.ts b/packages/dockview-core/src/dockview/options.ts index 3f7b94367..0ae0d8810 100644 --- a/packages/dockview-core/src/dockview/options.ts +++ b/packages/dockview-core/src/dockview/options.ts @@ -243,6 +243,18 @@ export type AddPanelOptions

= { * Defaults to `false` which forces newly added panels to become active. */ inactive?: boolean; + /** + * The unique DOM id for the rendered panel element + * + * Used for accessibility attributes + */ + componentElId?: string; + /** + * The unique DOM id for the rendered tab element + * + * Used for accessibility attributes + */ + tabComponentElId?: string; initialWidth?: number; initialHeight?: number; } & Partial & diff --git a/packages/dockview-core/src/gridview/gridview.ts b/packages/dockview-core/src/gridview/gridview.ts index bda8c0638..f44f81286 100644 --- a/packages/dockview-core/src/gridview/gridview.ts +++ b/packages/dockview-core/src/gridview/gridview.ts @@ -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); diff --git a/packages/dockview-core/src/overlay/overlayRenderContainer.ts b/packages/dockview-core/src/overlay/overlayRenderContainer.ts index f83f50e5e..12981e039 100644 --- a/packages/dockview-core/src/overlay/overlayRenderContainer.ts +++ b/packages/dockview-core/src/overlay/overlayRenderContainer.ts @@ -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, }; }