Skip to content

Commit ea0bdfd

Browse files
authored
Merge pull request #1368 from mathuo/feat/a11y-popout-live-region
feat(dockview-core): cross-window accessibility for popout windows
2 parents aa45e41 + 9bbf5fb commit ea0bdfd

11 files changed

Lines changed: 542 additions & 64 deletions

File tree

e2e/fixtures/index.html

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,16 @@
6565
dockview.layout(el.clientWidth, el.clientHeight);
6666

6767
// Test-facing handle.
68+
const panels = {};
6869
window.__dv = {
69-
addPanel: (id) =>
70-
dockview.addPanel({
70+
addPanel: (id) => {
71+
panels[id] = dockview.addPanel({
7172
id,
7273
component: 'default',
7374
title: id,
74-
}),
75+
});
76+
},
77+
closePanel: (id) => panels[id] && panels[id].api.close(),
7578
popoutActiveGroup: () =>
7679
dockview.addPopoutGroup(dockview.activeGroup),
7780
groupCount: () => dockview.groups.length,

e2e/tests/keyboard-docking.spec.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { test, expect, Page } from '@playwright/test';
2+
3+
/**
4+
* Cross-window keyboard docking: the keyboard services attach their listeners to
5+
* each popout document and gate on `ownsElement`, so `Ctrl+M` docking can be
6+
* driven from *inside* a popped-out window — and its narration routes to that
7+
* window's live region. None of this is reachable in jsdom (the mock popout
8+
* shares the main document, so there is no second window to listen on).
9+
*/
10+
test.describe('cross-window keyboard docking', () => {
11+
const popout = async (page: Page, context) => {
12+
await page.goto('/e2e/fixtures/index.html');
13+
await page.waitForFunction(() => (window as any).__ready === true);
14+
await page.evaluate(() => {
15+
(window as any).__dv.addPanel('alpha');
16+
(window as any).__dv.addPanel('beta'); // beta active, same group
17+
});
18+
const [win] = await Promise.all([
19+
context.waitForEvent('page'),
20+
page.evaluate(() => (window as any).__dv.popoutActiveGroup()),
21+
]);
22+
await (win as Page).waitForLoadState();
23+
return win as Page;
24+
};
25+
26+
test('Ctrl+M inside a popout narrates into the popout and Esc cancels there', async ({
27+
page,
28+
context,
29+
}) => {
30+
const win = await popout(page, context);
31+
32+
// Activate + focus a panel in the popout, then arm keyboard docking
33+
// with a keystroke made *in the popout document*.
34+
await win.locator('.dv-test-panel').first().click();
35+
await win.keyboard.press('Control+m');
36+
37+
// The keydown was seen by the popout's own listener (not the opener's),
38+
// passed the cross-document `ownsElement` gate, and the narration routed
39+
// to the popout's live region.
40+
await expect(win.locator('.dv-live-region')).toContainText(
41+
'Moving beta'
42+
);
43+
44+
await win.keyboard.press('Escape');
45+
await expect(win.locator('.dv-live-region')).toHaveText(
46+
'Move cancelled.'
47+
);
48+
});
49+
50+
test('a keyboard split committed inside a popout acts on the popout group', async ({
51+
page,
52+
context,
53+
}) => {
54+
const win = await popout(page, context);
55+
56+
// Popping out leaves a placeholder group in the main grid, so capture
57+
// the starting count rather than assume it.
58+
const before = await page.evaluate(() =>
59+
(window as any).__dv.groupCount()
60+
);
61+
62+
await win.locator('.dv-test-panel').first().click();
63+
await win.keyboard.press('Control+m'); // arm: target = own group
64+
await win.keyboard.press('Enter'); // choose this group -> edge phase
65+
await win.keyboard.press('ArrowLeft'); // left edge (split)
66+
await win.keyboard.press('Enter'); // commit
67+
68+
// The split ran inside the popout's own gridview: beta is now its own
69+
// group, so the component reports exactly one more group than before.
70+
await expect
71+
.poll(() => page.evaluate(() => (window as any).__dv.groupCount()))
72+
.toBe(before + 1);
73+
await expect(win.locator('.dv-live-region')).toContainText('beta');
74+
});
75+
});

e2e/tests/live-region.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { test, expect, Page } from '@playwright/test';
2+
3+
/**
4+
* Per-window live regions: a popout window gets its own aria-live regions, and
5+
* announcements route to the window that currently has focus — so a screen
6+
* reader user working in a popout actually hears them. None of this is
7+
* expressible in jsdom (the mock popout shares the main document).
8+
*/
9+
test.describe('cross-window live regions', () => {
10+
const popout = async (page: Page, context) => {
11+
await page.goto('/e2e/fixtures/index.html');
12+
await page.waitForFunction(() => (window as any).__ready === true);
13+
await page.evaluate(() => {
14+
(window as any).__dv.addPanel('alpha');
15+
(window as any).__dv.addPanel('beta'); // beta active, same group
16+
});
17+
const [win] = await Promise.all([
18+
context.waitForEvent('page'),
19+
page.evaluate(() => (window as any).__dv.popoutActiveGroup()),
20+
]);
21+
await (win as Page).waitForLoadState();
22+
return win as Page;
23+
};
24+
25+
test('a popout window has its own polite and assertive regions', async ({
26+
page,
27+
context,
28+
}) => {
29+
const win = await popout(page, context);
30+
31+
await expect(win.locator('.dv-live-region')).toHaveCount(1);
32+
await expect(win.locator('.dv-live-region-assertive')).toHaveCount(1);
33+
});
34+
35+
test('announcements route to the focused popout, not the opener', async ({
36+
page,
37+
context,
38+
}) => {
39+
const win = await popout(page, context);
40+
41+
// Put focus in the popout, then close a panel there. The "closed"
42+
// announcement must land in the popout's region, not the opener's.
43+
await win.locator('.dv-test-panel').first().focus();
44+
await page.evaluate(() => (window as any).__dv.closePanel('beta'));
45+
46+
// The close lands in the popout's region...
47+
await expect(win.locator('.dv-live-region')).toHaveText('beta closed');
48+
// ...and never leaks into the opener's region. (The opener's region
49+
// still holds the earlier "opened in a new window" note, which routed
50+
// to it because the main window had focus at popout time.)
51+
await expect(page.locator('.dv-live-region')).not.toHaveText(
52+
'beta closed'
53+
);
54+
await expect(page.locator('.dv-live-region')).toHaveText(
55+
'beta opened in a new window'
56+
);
57+
});
58+
});

packages/dockview-core/src/__tests__/dockview/liveRegion.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,22 @@ describe('LiveRegion announcer', () => {
255255
expect(region().textContent).toBe('P2 opened in a new window');
256256
});
257257

258+
test('a popout sharing the document does not get a duplicate region', async () => {
259+
window.open = () => setupMockWindow();
260+
dockview.addPanel({ id: 'p1', component: 'default', title: 'P1' });
261+
const p2 = dockview.addPanel({
262+
id: 'p2',
263+
component: 'default',
264+
title: 'P2',
265+
});
266+
await dockview.addPopoutGroup(p2);
267+
// The mock popout reuses the main document, so per-window mounting is
268+
// skipped — there is still exactly one polite region and announcements
269+
// stay on it. (Real cross-document behaviour is covered by the e2e.)
270+
expect(container.querySelectorAll('.dv-live-region')).toHaveLength(1);
271+
expect(region().textContent).toBe('P2 opened in a new window');
272+
});
273+
258274
test('the messages catalog localises announcement strings', () => {
259275
dockview.dispose();
260276
container = document.createElement('div');
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { DockviewComponent } from '../../dockview/dockviewComponent';
2+
import { IContentRenderer } from '../../dockview/types';
3+
4+
class TestPanel implements IContentRenderer {
5+
element = document.createElement('div');
6+
init(): void {
7+
// noop
8+
}
9+
layout(): void {
10+
// noop
11+
}
12+
dispose(): void {
13+
// noop
14+
}
15+
}
16+
17+
/**
18+
* `ownsElement` is the cross-document containment primitive the keyboard
19+
* accessibility services use instead of a single-document `rootElement.contains`
20+
* — so a keydown / focus inside a popped-out window (a separate `document`) is
21+
* still recognised as belonging to this dock. jsdom can't open a real second
22+
* window, so the popout document is simulated by stubbing `getPopoutWindows`.
23+
*/
24+
describe('DockviewComponent.ownsElement', () => {
25+
let container: HTMLElement;
26+
let dockview: DockviewComponent;
27+
28+
beforeEach(() => {
29+
container = document.createElement('div');
30+
document.body.appendChild(container);
31+
dockview = new DockviewComponent(container, {
32+
createComponent: () => new TestPanel(),
33+
});
34+
dockview.layout(800, 600);
35+
});
36+
37+
afterEach(() => {
38+
dockview.dispose();
39+
container.remove();
40+
});
41+
42+
test('owns an element inside its own shell', () => {
43+
dockview.addPanel({ id: 'panel1', component: 'default' });
44+
const tab = container.querySelector('.dv-tab')!;
45+
expect(dockview.ownsElement(tab)).toBe(true);
46+
expect(dockview.ownsElement(dockview.element)).toBe(true);
47+
});
48+
49+
test('does not own an element in the main document outside the dock', () => {
50+
const stray = document.createElement('div');
51+
document.body.appendChild(stray);
52+
expect(dockview.ownsElement(stray)).toBe(false);
53+
stray.remove();
54+
});
55+
56+
test('does not own a foreign-document element with no matching popout', () => {
57+
const foreignDoc =
58+
document.implementation.createHTMLDocument('foreign');
59+
const node = foreignDoc.createElement('div');
60+
foreignDoc.body.appendChild(node);
61+
expect(dockview.ownsElement(node)).toBe(false);
62+
});
63+
64+
test('owns an element in a popout document it controls', () => {
65+
const popoutDoc = document.implementation.createHTMLDocument('popout');
66+
const node = popoutDoc.createElement('div');
67+
popoutDoc.body.appendChild(node);
68+
const popoutWindow = { document: popoutDoc } as unknown as Window;
69+
70+
jest.spyOn(dockview, 'getPopoutWindows').mockReturnValue([
71+
popoutWindow,
72+
]);
73+
74+
expect(dockview.ownsElement(node)).toBe(true);
75+
76+
// A node in a *different* foreign document is still not owned.
77+
const otherDoc = document.implementation.createHTMLDocument('other');
78+
const otherNode = otherDoc.createElement('div');
79+
otherDoc.body.appendChild(otherNode);
80+
expect(dockview.ownsElement(otherNode)).toBe(false);
81+
});
82+
83+
test('a same-document popout never claims sibling main-document nodes', () => {
84+
// The jsdom mock popout shares the main document; ownsElement must fall
85+
// back to true containment for it (not blanket-own the whole document).
86+
const samedocWindow = { document } as unknown as Window;
87+
jest.spyOn(dockview, 'getPopoutWindows').mockReturnValue([
88+
samedocWindow,
89+
]);
90+
91+
const stray = document.createElement('div');
92+
document.body.appendChild(stray);
93+
expect(dockview.ownsElement(stray)).toBe(false);
94+
stray.remove();
95+
});
96+
});

packages/dockview-core/src/dockview/dockviewComponent.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,12 @@ export class DockviewComponent
553553
readonly onDidRemovePopoutGroup: Event<PopoutGroup> =
554554
this._onDidRemovePopoutGroup.event;
555555

556+
private readonly _onDidChangePopouts = new Emitter<void>();
557+
/** Fires whenever a popout window opens or closes — i.e. the set of popout
558+
* documents changed. Used by accessibility services that mirror per-window
559+
* state (e.g. a live region in each popout). */
560+
readonly onDidChangePopouts: Event<void> = this._onDidChangePopouts.event;
561+
556562
private readonly _onDidOpenPopoutWindowFail = new Emitter<void>();
557563
readonly onDidOpenPopoutWindowFail: Event<void> =
558564
this._onDidOpenPopoutWindowFail.event;
@@ -828,6 +834,26 @@ export class DockviewComponent
828834
return this._shellManager?.element ?? this.element;
829835
}
830836

837+
/**
838+
* Does this dock own `node`, in any of its windows? True when the node is
839+
* inside the main shell, or inside one of this component's popout documents.
840+
* A popout window hosts only this component's content, so whole-document
841+
* membership is sufficient there; the main document may hold sibling docks,
842+
* so it must be a containment check. A same-document popout (the jsdom mock)
843+
* is already covered by the main check and contributes nothing.
844+
*/
845+
ownsElement(node: Node): boolean {
846+
if (this.rootElement.contains(node)) {
847+
return true;
848+
}
849+
const mainDoc = this.rootElement.ownerDocument;
850+
const doc = node.ownerDocument;
851+
if (!doc || doc === mainDoc) {
852+
return false;
853+
}
854+
return this.getPopoutWindows().some((win) => win.document === doc);
855+
}
856+
831857
/**
832858
* The next / previous group in gridview (spatial) order, wrapping round.
833859
* The keyboard accessibility module's focus navigation is built on this
@@ -1094,6 +1120,15 @@ export class DockviewComponent
10941120
this._onDidPopoutGroupPositionChange,
10951121
this._onDidAddPopoutGroup,
10961122
this._onDidRemovePopoutGroup,
1123+
this._onDidChangePopouts,
1124+
// Coalesce popout add/remove into a single "the popout set changed"
1125+
// signal for per-window accessibility state.
1126+
this._onDidAddPopoutGroup.event(() =>
1127+
this._onDidChangePopouts.fire()
1128+
),
1129+
this._onDidRemovePopoutGroup.event(() =>
1130+
this._onDidChangePopouts.fire()
1131+
),
10971132
this._onDidOpenPopoutWindowFail,
10981133
this._onDidCreateTabGroup,
10991134
this._onDidDestroyTabGroup,
@@ -1299,6 +1334,12 @@ export class DockviewComponent
12991334
);
13001335
}
13011336

1337+
/** The live popout `Window` handles — one per open popout group. The
1338+
* narrow surface accessibility services need to mirror per-window state. */
1339+
getPopoutWindows(): Window[] {
1340+
return this.getPopouts().map((popout) => popout.window);
1341+
}
1342+
13021343
private _doAddPopoutGroup(
13031344
itemToPopout: DockviewPanel | DockviewGroupPanel,
13041345
options?: DockviewPopoutGroupOptionsInternal

0 commit comments

Comments
 (0)