Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0537d0a
fix(dockview-core): give the default tab close button an accessible name
mathuo Jun 22, 2026
0acb7b4
test(dockview-modules): cover Delete-close and vertical-strip keyboar…
mathuo Jun 22, 2026
bdf15b5
feat(dockview): add a keyboard 'float' terminal action to keyboard do…
mathuo Jun 22, 2026
4f68c09
Merge pull request #1362 from mathuo/feat/a11y-tab-close-accessible-name
mathuo Jun 22, 2026
f9141f0
Merge pull request #1364 from mathuo/feat/a11y-keyboard-nav-tests
mathuo Jun 22, 2026
1c1b39c
Merge pull request #1363 from mathuo/feat/a11y-keyboard-float
mathuo Jun 22, 2026
d5768f2
test(dockview-modules): cover region label removal and roving-tabinde…
mathuo Jun 22, 2026
49577a2
test(dockview-modules): assert keyboard docking shows/clears the drop…
mathuo Jun 22, 2026
d678fbc
Merge pull request #1365 from mathuo/feat/a11y-baseline-label-tests
mathuo Jun 22, 2026
55f5f10
Merge pull request #1366 from mathuo/feat/a11y-docking-preview-tests
mathuo Jun 22, 2026
821407e
test(e2e): add a Playwright cross-window harness for popout behaviour
mathuo Jun 22, 2026
aa45e41
Merge pull request #1367 from mathuo/feat/e2e-playwright-harness
mathuo Jun 22, 2026
086e651
feat(dockview-core): per-window live regions with focus-aware routing
mathuo Jun 22, 2026
9bbf5fb
feat(dockview-modules): cross-window keyboard docking inside popout w…
mathuo Jun 23, 2026
190e0ce
fix(dockview-core): popout-window pointer drags + cross-group maximiz…
mathuo Jun 23, 2026
ea0bdfd
Merge pull request #1368 from mathuo/feat/a11y-popout-live-region
mathuo Jun 23, 2026
ed426ab
fix(dockview-core): use :focus-visible for keyboard focus indicators
mathuo Jun 23, 2026
3e51532
Merge pull request #1373 from mathuo/fix/focus-visible-indicators
mathuo Jun 23, 2026
d2d968c
Merge remote-tracking branch 'origin/v8-branch' into fix/popout-point…
mathuo Jun 23, 2026
dade17a
Merge pull request #1371 from mathuo/fix/popout-pointer-drag-and-maxi…
mathuo Jun 23, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ test-report.xml
*.code-workspace
yarn-error.log
/build
test-results/
playwright-report/
playwright/.cache/
/docs/
/generated/

Expand Down
35 changes: 35 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# End-to-end (cross-window) tests

Playwright tests that run dockview in a **real browser**. They exist for the
behaviour the jsdom unit tests cannot reach: anything spanning more than one
`document` — popout windows, cross-window focus routing, per-window listeners
and per-window live regions. (The unit-test `setupMockWindow` mock reuses the
main document, so a popout there is not a genuine second window.)

## Layout

- `fixtures/index.html` — loads the built UMD bundles, creates a
`DockviewComponent` with `keyboardNavigation` enabled, and exposes a small
`window.__dv` handle (`addPanel`, `popoutActiveGroup`, `groupCount`).
- `fixtures/popout.html` — the served target a popout window navigates to
before dockview injects the group (avoids an `about:blank` 404).
- `tests/` — the specs.

## Running

The fixture loads the UMD bundles from `dist/`, so build them first:

```bash
yarn nx run-many -t build:bundle -p dockview-core dockview-modules
yarn playwright install chromium # first time only
yarn test:e2e
```

The Playwright config starts a zero-dependency static server
(`python3 -m http.server 4321`) over the repo root; no extra tooling needed.

## Adding cross-window tests

Drive the layout through `window.__dv` (or add to it), capture the popout with
`context.waitForEvent('page')`, and assert against the popout `Page`. This is
the harness the popout focus / live-region work builds on.
101 changes: 101 additions & 0 deletions e2e/fixtures/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>dockview e2e fixture</title>
<style>
html,
body,
#app {
margin: 0;
height: 100%;
width: 100%;
}
.dv-test-panel {
height: 100%;
box-sizing: border-box;
padding: 8px;
}
</style>
</head>
<body>
<div id="app"></div>

<!-- Built UMD bundles. dockview-core exposes the global "dockview-core";
dockview-modules expects it aliased as DockviewCore. -->
<script src="/packages/dockview-core/dist/dockview-core.js"></script>
<script>
window.DockviewCore = window['dockview-core'];
</script>
<script src="/packages/dockview-modules/dist/dockview-modules.js"></script>

<script>
(function () {
const core = window['dockview-core'];
const modules = window['dockview-modules'];

// Register the pro module set plus the optional keyboard-docking
// module so the harness can exercise cross-window keyboard work.
core.registerModules([
...modules.Modules,
modules.KeyboardDockingModule,
]);

const el = document.getElementById('app');
const dockview = new core.DockviewComponent(el, {
keyboardNavigation: true,
// A served (not about:blank) target avoids a 404 when the
// popout window navigates before dockview injects content.
popoutUrl: '/e2e/fixtures/popout.html',
createComponent: (options) => {
const element = document.createElement('div');
element.className = 'dv-test-panel';
element.tabIndex = 0;
// Render the panel id so tests can identify which panel
// (e.g. "beta") is showing in the popped-out window.
element.textContent = options.id;
return {
element,
init() {},
layout() {},
dispose() {},
};
},
});
dockview.layout(el.clientWidth, el.clientHeight);

// Test-facing handle.
const panels = {};
window.__dv = {
addPanel: (id) => {
panels[id] = dockview.addPanel({
id,
component: 'default',
title: id,
});
},
closePanel: (id) => panels[id] && panels[id].api.close(),
// Create a new panel and move it to the right of the
// popped-out group — the move lands in the popout's own
// gridview, producing a second group (and so a sash) there.
splitIntoPopout: (id) => {
const popoutGroup = dockview.getPopouts()[0].group;
const panel = dockview.addPanel({
id,
component: 'default',
title: id,
});
panel.api.moveTo({
group: popoutGroup,
position: 'right',
});
},
popoutActiveGroup: () =>
dockview.addPopoutGroup(dockview.activeGroup),
groupCount: () => dockview.groups.length,
};
window.__ready = true;
})();
</script>
</body>
</html>
18 changes: 18 additions & 0 deletions e2e/fixtures/popout.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>dockview popout</title>
<style>
html,
body {
margin: 0;
height: 100%;
width: 100%;
}
</style>
</head>
<body>
<!-- Intentionally empty: dockview injects the popped-out group here. -->
</body>
</html>
75 changes: 75 additions & 0 deletions e2e/tests/keyboard-docking.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { test, expect, Page } from '@playwright/test';

/**
* Cross-window keyboard docking: the keyboard services attach their listeners to
* each popout document and gate on `ownsElement`, so `Ctrl+M` docking can be
* driven from *inside* a popped-out window — and its narration routes to that
* window's live region. None of this is reachable in jsdom (the mock popout
* shares the main document, so there is no second window to listen on).
*/
test.describe('cross-window keyboard docking', () => {
const popout = async (page: Page, context) => {
await page.goto('/e2e/fixtures/index.html');
await page.waitForFunction(() => (window as any).__ready === true);
await page.evaluate(() => {
(window as any).__dv.addPanel('alpha');
(window as any).__dv.addPanel('beta'); // beta active, same group
});
const [win] = await Promise.all([
context.waitForEvent('page'),
page.evaluate(() => (window as any).__dv.popoutActiveGroup()),
]);
await (win as Page).waitForLoadState();
return win as Page;
};

test('Ctrl+M inside a popout narrates into the popout and Esc cancels there', async ({
page,
context,
}) => {
const win = await popout(page, context);

// Activate + focus a panel in the popout, then arm keyboard docking
// with a keystroke made *in the popout document*.
await win.locator('.dv-test-panel').first().click();
await win.keyboard.press('Control+m');

// The keydown was seen by the popout's own listener (not the opener's),
// passed the cross-document `ownsElement` gate, and the narration routed
// to the popout's live region.
await expect(win.locator('.dv-live-region')).toContainText(
'Moving beta'
);

await win.keyboard.press('Escape');
await expect(win.locator('.dv-live-region')).toHaveText(
'Move cancelled.'
);
});

test('a keyboard split committed inside a popout acts on the popout group', async ({
page,
context,
}) => {
const win = await popout(page, context);

// Popping out leaves a placeholder group in the main grid, so capture
// the starting count rather than assume it.
const before = await page.evaluate(() =>
(window as any).__dv.groupCount()
);

await win.locator('.dv-test-panel').first().click();
await win.keyboard.press('Control+m'); // arm: target = own group
await win.keyboard.press('Enter'); // choose this group -> edge phase
await win.keyboard.press('ArrowLeft'); // left edge (split)
await win.keyboard.press('Enter'); // commit

// The split ran inside the popout's own gridview: beta is now its own
// group, so the component reports exactly one more group than before.
await expect
.poll(() => page.evaluate(() => (window as any).__dv.groupCount()))
.toBe(before + 1);
await expect(win.locator('.dv-live-region')).toContainText('beta');
});
});
58 changes: 58 additions & 0 deletions e2e/tests/live-region.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { test, expect, Page } from '@playwright/test';

/**
* Per-window live regions: a popout window gets its own aria-live regions, and
* announcements route to the window that currently has focus — so a screen
* reader user working in a popout actually hears them. None of this is
* expressible in jsdom (the mock popout shares the main document).
*/
test.describe('cross-window live regions', () => {
const popout = async (page: Page, context) => {
await page.goto('/e2e/fixtures/index.html');
await page.waitForFunction(() => (window as any).__ready === true);
await page.evaluate(() => {
(window as any).__dv.addPanel('alpha');
(window as any).__dv.addPanel('beta'); // beta active, same group
});
const [win] = await Promise.all([
context.waitForEvent('page'),
page.evaluate(() => (window as any).__dv.popoutActiveGroup()),
]);
await (win as Page).waitForLoadState();
return win as Page;
};

test('a popout window has its own polite and assertive regions', async ({
page,
context,
}) => {
const win = await popout(page, context);

await expect(win.locator('.dv-live-region')).toHaveCount(1);
await expect(win.locator('.dv-live-region-assertive')).toHaveCount(1);
});

test('announcements route to the focused popout, not the opener', async ({
page,
context,
}) => {
const win = await popout(page, context);

// Put focus in the popout, then close a panel there. The "closed"
// announcement must land in the popout's region, not the opener's.
await win.locator('.dv-test-panel').first().focus();
await page.evaluate(() => (window as any).__dv.closePanel('beta'));

// The close lands in the popout's region...
await expect(win.locator('.dv-live-region')).toHaveText('beta closed');
// ...and never leaks into the opener's region. (The opener's region
// still holds the earlier "opened in a new window" note, which routed
// to it because the main window had focus at popout time.)
await expect(page.locator('.dv-live-region')).not.toHaveText(
'beta closed'
);
await expect(page.locator('.dv-live-region')).toHaveText(
'beta opened in a new window'
);
});
});
56 changes: 56 additions & 0 deletions e2e/tests/popout-pointer-drag.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { test, expect, Page } from '@playwright/test';

/**
* Pointer-drag inside a popout window. The splitview sash (and the scrollbar,
* which shares the identical fix) attached its `pointermove`/`pointerup`
* listeners to the *opener's* `document`, so a drag started in a popout was
* never heard — resizing a split was dead inside a popped-out window. The fix
* binds the drag to the element's own document. Only a real second window
* exercises this; jsdom reuses the main document.
*/
test.describe('cross-window pointer drag', () => {
const popoutWithSash = async (page: Page, context) => {
await page.goto('/e2e/fixtures/index.html');
await page.waitForFunction(() => (window as any).__ready === true);
await page.evaluate(() => {
(window as any).__dv.addPanel('alpha');
(window as any).__dv.addPanel('beta');
});
const [win] = await Promise.all([
context.waitForEvent('page'),
page.evaluate(() => (window as any).__dv.popoutActiveGroup()),
]);
await (win as Page).waitForLoadState();
// Split a second group into the popout's own gridview, creating a sash
// there to drag.
await page.evaluate(() =>
(window as any).__dv.splitIntoPopout('delta')
);
await (win as Page).locator('.dv-sash').first().waitFor();
return win as Page;
};

test('dragging a sash inside the popout resizes its split', async ({
page,
context,
}) => {
const win = await popoutWithSash(page, context);

const sash = win.locator('.dv-sash').first();
const before = (await sash.boundingBox())!;
expect(before).toBeTruthy();

// Drag the sash ~80px to the right, entirely within the popout window.
const cx = before.x + before.width / 2;
const cy = before.y + before.height / 2;
await win.mouse.move(cx, cy);
await win.mouse.down();
await win.mouse.move(cx + 80, cy, { steps: 8 });
await win.mouse.up();

// The boundary moved — proof the pointermove/up were heard in the
// popout's document, not lost to the opener.
const after = (await sash.boundingBox())!;
expect(after.x).toBeGreaterThan(before.x + 40);
});
});
35 changes: 35 additions & 0 deletions e2e/tests/popout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { test, expect, Page } from '@playwright/test';

/**
* Smoke test that validates the cross-window harness itself: popping out a
* group must open a real second window with the group rendered inside it.
* This is the foundation the (deferred) popout cross-window focus / live-region
* tests build on — none of which jsdom can express.
*/
test.describe('cross-window popout harness', () => {
test('popping out a group opens a second window that renders it', async ({
page,
context,
}) => {
await page.goto('/e2e/fixtures/index.html');
await page.waitForFunction(() => (window as any).__ready === true);

await page.evaluate(() => {
(window as any).__dv.addPanel('alpha');
(window as any).__dv.addPanel('beta'); // beta active, same group
});

// window.open surfaces as a new Playwright page.
const [popout] = await Promise.all([
context.waitForEvent('page'),
page.evaluate(() => (window as any).__dv.popoutActiveGroup()),
]);

await (popout as Page).waitForLoadState();

// The group's tab strip and the active panel's content now live in the
// popout document, not the opener.
await expect(popout.locator('.dv-tabs-container')).toBeVisible();
await expect(popout.locator('.dv-test-panel')).toContainText('beta');
});
});
Loading
Loading