Skip to content

Commit e8defa4

Browse files
mathuoclaude
andcommitted
fix(dockview-core): keyboard docking listens on the document, scoped to the dockview
Keydowns from edge groups never reached the handler: the listener was on the inner gridview (this.element), but edge groups live in the outer dv-shell wrapper *outside* it — and the shell is created after this service, so a fixed element can't be used. Listen on the document in capture phase instead, scoped to events inside this dockview via a new host.rootElement (the shell once it exists, else the gridview). Verified against the live demo: Ctrl+M now shows the preview overlay and arrows drive the two-phase move. Also guard _commit so an invalid move (e.g. splitting an edge group's only panel) announces "that move is not allowed" instead of throwing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 97bc20e commit e8defa4

2 files changed

Lines changed: 48 additions & 22 deletions

File tree

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

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { CompositeDisposable, IDisposable } from '../lifecycle';
2-
import { addDisposableListener } from '../events';
32
import { Position } from '../dnd/droptarget';
43
import { DockviewGroupPanel } from './dockviewGroupPanel';
54
import { IDockviewPanel } from './dockviewPanel';
@@ -15,7 +14,12 @@ import { LiveRegionModule } from './liveRegionService';
1514
* LiveRegion module), and commits the dock.
1615
*/
1716
export interface IAccessibilityHost {
18-
readonly element: HTMLElement;
17+
/**
18+
* The outermost dockview element (the shell, which also contains edge
19+
* groups). A getter — it must resolve to the shell once that exists, not
20+
* the inner gridview, or keydowns from edge groups are missed.
21+
*/
22+
readonly rootElement: HTMLElement;
1923
readonly options: DockviewComponentOptions;
2024
readonly groups: DockviewGroupPanel[];
2125
readonly activePanel: IDockviewPanel | undefined;
@@ -70,16 +74,20 @@ export class AccessibilityService
7074
constructor(private readonly host: IAccessibilityHost) {
7175
super();
7276

77+
// Listen on the document (capture) rather than the dockview element:
78+
// edge groups live in the shell *outside* the gridview, and the shell
79+
// is created after this service, so a fixed element would miss them.
80+
// Capture also lets move-mode keys beat the free tab-strip navigation.
81+
const doc = host.rootElement.ownerDocument;
82+
const onKeyDown = (e: KeyboardEvent): void => this._onKeyDown(e);
83+
doc.addEventListener('keydown', onKeyDown, true);
84+
7385
this.addDisposables(
7486
{ dispose: () => this._clearPreview() },
75-
// Capture phase so move-mode arrows take precedence over the
76-
// free tab-strip keyboard navigation (which lives on the tablist).
77-
addDisposableListener(
78-
host.element,
79-
'keydown',
80-
(e) => this._onKeyDown(e),
81-
true
82-
)
87+
{
88+
dispose: () =>
89+
doc.removeEventListener('keydown', onKeyDown, true),
90+
}
8391
);
8492
}
8593

@@ -91,9 +99,15 @@ export class AccessibilityService
9199
this._onMoveKey(e);
92100
return;
93101
}
94-
// Ctrl+M only — NOT Cmd+M, which is the macOS "minimise window"
95-
// shortcut and is handled by the OS before the page can intercept it.
96-
if (e.ctrlKey && !e.metaKey && (e.key === 'm' || e.key === 'M')) {
102+
// Ctrl+M only — NOT Cmd+M (macOS minimise-window, OS-intercepted).
103+
// Scope to events originating inside *this* dockview.
104+
if (
105+
e.ctrlKey &&
106+
!e.metaKey &&
107+
(e.key === 'm' || e.key === 'M') &&
108+
e.target instanceof Node &&
109+
this.host.rootElement.contains(e.target)
110+
) {
97111
this._enterMoveMode(e);
98112
}
99113
}
@@ -201,14 +215,18 @@ export class AccessibilityService
201215
const source = move.source;
202216
const name = group.activePanel?.title ?? group.id;
203217
this._exit();
204-
this.host.dockPanel(source, group, position);
205-
this.host.announce(
206-
`${this._label(source)} ${
207-
position === 'center'
208-
? `docked into ${name}`
209-
: `split ${position} of ${name}`
210-
}.`
211-
);
218+
try {
219+
this.host.dockPanel(source, group, position);
220+
this.host.announce(
221+
`${this._label(source)} ${
222+
position === 'center'
223+
? `docked into ${name}`
224+
: `split ${position} of ${name}`
225+
}.`
226+
);
227+
} catch {
228+
this.host.announce('That move is not allowed.');
229+
}
212230
}
213231

214232
private _describe(position: Position): string {

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -701,7 +701,15 @@ export class DockviewComponent
701701

702702
// IAccessibilityHost — keyboard docking reaches the AdvancedDnD preview +
703703
// LiveRegion announcer through these so the service stays decoupled.
704-
showDropPreview(group: DockviewGroupPanel, position: Position): IDisposable {
704+
/** Outermost element — the shell (incl. edge groups) once built, else the gridview. */
705+
get rootElement(): HTMLElement {
706+
return this._shellManager?.element ?? this.element;
707+
}
708+
709+
showDropPreview(
710+
group: DockviewGroupPanel,
711+
position: Position
712+
): IDisposable {
705713
return (
706714
this._advancedDnDService?.showPreviewOverlay(group, position) ??
707715
Disposable.NONE

0 commit comments

Comments
 (0)