UNPKG

dockview

Version:

Docking layout manager — tabs, groups, grids, splitviews, drag and drop, floating panels

964 lines (951 loc) 37.3 kB
/** * dockview * @version 7.0.2 * @link https://github.com/mathuo/dockview * @license MIT */ 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var dockviewCore = require('dockview-core'); /** * dockview-modules * @version 7.0.2 * @link https://github.com/mathuo/dockview * @license MIT */ class TabGroupChipsService { constructor(host) { this._host = host; } attachToGroup(group) { return new dockviewCore.DockviewCompositeDisposable(group.model.onDidCreateTabGroup((e) => { this._host.fireDidCreateTabGroup(e); }), group.model.onDidDestroyTabGroup((e) => { this._host.fireDidDestroyTabGroup(e); }), group.model.onDidAddPanelToTabGroup((e) => { this._host.fireDidAddPanelToTabGroup(e); }), group.model.onDidRemovePanelFromTabGroup((e) => { this._host.fireDidRemovePanelFromTabGroup(e); }), group.model.onDidTabGroupChange((e) => { this._host.fireDidTabGroupChange(e); }), group.model.onDidTabGroupCollapsedChange((e) => { this._host.fireDidTabGroupCollapsedChange(e); })); } dispose() { // No internal state to tear down — emitters live on the host. } } const TabGroupChipsModule = dockviewCore.defineModule({ name: 'TabGroupChips', serviceKey: 'tabGroupChipsService', create: (host) => new TabGroupChipsService(host), init: (host, service) => { // Self-attach to existing and future groups; tear down when groups // are removed. Component doesn't need to know about this wiring. const perGroupDisposables = new Map(); return new dockviewCore.DockviewCompositeDisposable(host.onDidAddGroup((group) => { perGroupDisposables.set(group, service.attachToGroup(group)); }), host.onDidRemoveGroup((group) => { var _a; (_a = perGroupDisposables.get(group)) === null || _a === void 0 ? void 0 : _a.dispose(); perGroupDisposables.delete(group); }), { dispose: () => { for (const d of perGroupDisposables.values()) { d.dispose(); } perGroupDisposables.clear(); }, }); }, }); function popoverZIndexFor(target) { if (!(target instanceof HTMLElement)) { return undefined; } // Floating overlays live in the shell as siblings of the popover anchor // and the AriaLevelTracker sets their inline z-index. Without this, a // popover opened from inside a floating group would render behind it // because they share the shell stacking context. const relativeParent = dockviewCore.findRelativeZIndexParent(target); return (relativeParent === null || relativeParent === void 0 ? void 0 : relativeParent.style.zIndex) ? `calc(${relativeParent.style.zIndex} * 2)` : undefined; } let _nextId = 0; const nextContextMenuItemId = () => `dv-ctx-menu-item-${_nextId++}`; function isItemConfig(item) { return typeof item === 'object'; } function buildItem(label, close, action, disabled) { const el = document.createElement('div'); el.className = 'dv-context-menu-item'; el.setAttribute('role', 'menuitem'); if (disabled) { el.classList.add('dv-context-menu-item--disabled'); el.setAttribute('aria-disabled', 'true'); } el.textContent = label; if (!disabled) { el.addEventListener('click', () => { action(); close(); }); } return el; } function buildSeparator() { const el = document.createElement('div'); el.className = 'dv-context-menu-separator'; el.setAttribute('role', 'separator'); return el; } function isCoarsePrimaryInput() { if (typeof window === 'undefined' || !window.matchMedia) { return false; } const coarse = window.matchMedia('(pointer: coarse)').matches; const fine = window.matchMedia('(pointer: fine)').matches; return coarse && !fine; } function buildRenameInput(tabGroup) { const wrapper = document.createElement('div'); wrapper.className = 'dv-context-menu-rename'; const input = document.createElement('input'); input.className = 'dv-context-menu-rename-input'; input.type = 'text'; input.placeholder = 'Name This Group'; input.value = tabGroup.label; input.addEventListener('input', () => { tabGroup.setLabel(input.value); }); input.addEventListener('keydown', (e) => { if (e.key !== 'Escape' && e.key !== 'Enter') { e.stopPropagation(); } }); input.addEventListener('click', (e) => { e.stopPropagation(); }); wrapper.appendChild(input); // Skip auto-focus on touch-primary devices: focusing the input pops the // on-screen keyboard, which fires `window resize`, which `PopupService` // listens to and uses to dismiss the popover — so the menu opens, the // keyboard appears, and the menu immediately closes before the user can // type. The user can still tap the input to focus it intentionally. if (!isCoarsePrimaryInput()) { requestAnimationFrame(() => { input.focus(); input.select(); }); } return wrapper; } function buildColorPicker(tabGroup, palette) { const wrapper = document.createElement('div'); wrapper.className = 'dv-context-menu-color-picker'; if (!palette.enabled) { // Opt-out: render no swatches. Returning a wrapper rather than null // keeps the call site simple; the wrapper is empty and visually inert. return wrapper; } for (const entry of palette.entries()) { const swatch = document.createElement('div'); swatch.className = 'dv-context-menu-color-swatch'; // Use a CSS custom property rather than setting `backgroundColor` // directly: the IDL setter validates the value against a color // grammar and rejects `var(...)` references in some environments // (notably jsdom; some browsers have historically had similar // quirks). The matching SCSS rule reads the var at use time. swatch.style.setProperty('--dv-tab-group-color', entry.value); if (entry.label) { swatch.title = entry.label; } if (tabGroup.color === entry.id) { swatch.classList.add('dv-context-menu-color-swatch--selected'); } swatch.addEventListener('click', () => { tabGroup.setColor(entry.id); }); wrapper.appendChild(swatch); } return wrapper; } class ContextMenuController { constructor(accessor) { this.accessor = accessor; } show(panel, group, event) { var _a, _b; if (!this.accessor.options.getTabContextMenuItems) { return; } const items = this.accessor.options.getTabContextMenuItems({ panel, group, api: this.accessor.api, event, }); if (items.length === 0) { return; } event.preventDefault(); const popupService = this.accessor.getPopupServiceForGroup(group); const close = () => popupService.close(); const menuEl = document.createElement('div'); menuEl.className = 'dv-context-menu'; menuEl.setAttribute('role', 'menu'); for (const item of items) { if (item === 'separator') { menuEl.appendChild(buildSeparator()); } else if (item === 'close') { menuEl.appendChild(buildItem('Close', close, () => panel.api.close())); } else if (item === 'closeOthers') { menuEl.appendChild(buildItem('Close Others', close, () => { group.panels .filter((p) => p !== panel) .forEach((p) => p.api.close()); })); } else if (item === 'closeAll') { menuEl.appendChild(buildItem('Close All', close, () => { [...group.panels].forEach((p) => p.api.close()); })); } else if (isItemConfig(item) && item.element) { menuEl.appendChild(item.element); } else if (isItemConfig(item) && item.component) { const renderer = (_b = (_a = this.accessor.options).createContextMenuItemComponent) === null || _b === void 0 ? void 0 : _b.call(_a, { id: nextContextMenuItemId(), component: item.component, }); if (renderer) { renderer.init({ panel, group, api: this.accessor.api, close, componentProps: item.componentProps, }); menuEl.appendChild(renderer.element); } } else if (isItemConfig(item) && item.label) { menuEl.appendChild(buildItem(item.label, close, () => { var _a; return (_a = item.action) === null || _a === void 0 ? void 0 : _a.call(item); }, item.disabled)); } } popupService.openPopover(menuEl, { x: event.clientX, y: event.clientY, zIndex: popoverZIndexFor(event.target), }); } showForChip(tabGroup, group, event) { if (!this.accessor.options.getTabGroupChipContextMenuItems) { return; } const items = this.accessor.options.getTabGroupChipContextMenuItems({ tabGroup, group, api: this.accessor.api, event, }); if (items.length === 0) { return; } event.preventDefault(); const popupService = this.accessor.getPopupServiceForGroup(group); const close = () => popupService.close(); const menuEl = document.createElement('div'); menuEl.className = 'dv-context-menu'; menuEl.setAttribute('role', 'menu'); for (const item of items) { if (item === 'separator') { menuEl.appendChild(buildSeparator()); } else if (item === 'rename') { menuEl.appendChild(buildRenameInput(tabGroup)); } else if (item === 'colorPicker') { menuEl.appendChild(buildColorPicker(tabGroup, this.accessor.tabGroupColorPalette)); } else if (isItemConfig(item) && item.element) { menuEl.appendChild(item.element); } else if (isItemConfig(item) && item.label) { menuEl.appendChild(buildItem(item.label, close, () => { var _a; return (_a = item.action) === null || _a === void 0 ? void 0 : _a.call(item); }, item.disabled)); } } popupService.openPopover(menuEl, { x: event.clientX, y: event.clientY, zIndex: popoverZIndexFor(event.target), }); } } const ContextMenuModule = dockviewCore.defineModule({ name: 'ContextMenu', serviceKey: 'contextMenuService', create: (host) => new ContextMenuController(host), }); /** Cursor offset of the group drag ghost, matched to the long-shipped default. */ const GROUP_DRAG_GHOST_OFFSET_X = 30; const GROUP_DRAG_GHOST_OFFSET_Y = -10; /** * The narrow surface the {@link AdvancedDnDService} needs from the host * (the `DockviewComponent`). * * The `onWill*` emitters stay on the component so the public event shape is * unchanged whether or not this module is registered — the service is only the * dispatch point those fires are routed through. Engine policy (e.g. the * `disableDnd` guard) stays on the component, ahead of the dispatch. */ /** * Owns the dispatch of the advanced drag-and-drop hooks — `onWillDragPanel`, * `onWillDragGroup`, `onWillDrop` and `onWillShowOverlay`. * * At this stage the service is a thin dispatcher that forwards to the host's * emitters; the customisation surface it gates (custom drop overlays, custom * group drag ghosts, hook veto/transform behaviour) is currently inlined in * core and is extracted into this service in later phases. Routing the * dispatch through a module slot is what lets that customisation layer move * into a separately-distributed package in a future major version without * changing the public event surface. * * The service holds no drag state of its own — the gesture is driven by the * DnD backends, and the per-group subscriptions live on the component's group * lifecycle (so groups created mid-move are not missed). */ class AdvancedDnDService { constructor(host) { this.host = host; } dispatchWillDragPanel(event) { this.host.fireWillDragPanel(event); } dispatchWillDragGroup(event) { this.host.fireWillDragGroup(event); } dispatchWillDrop(event) { this.host.fireWillDrop(event); } dispatchWillShowOverlay(event) { this.host.fireWillShowOverlay(event); } buildGroupDragGhost(group) { const createGhost = this.host.options.createGroupDragGhostComponent; if (!createGhost) { return undefined; } const renderer = createGhost(group); renderer.init({ group, api: this.host.api }); return { element: renderer.element, offsetX: GROUP_DRAG_GHOST_OFFSET_X, offsetY: GROUP_DRAG_GHOST_OFFSET_Y, dispose: renderer.dispose ? () => { var _a; return (_a = renderer.dispose) === null || _a === void 0 ? void 0 : _a.call(renderer); } : undefined, }; } resolveOverlayModel(location, group) { var _a, _b; return (_b = (_a = this.host.options).dropOverlayModel) === null || _b === void 0 ? void 0 : _b.call(_a, { location, group }); } showPreviewOverlay(group, position) { const target = group.model.contentDropTarget; target.showOverlay(position); return dockviewCore.DockviewDisposable.from(() => target.clearOverlay()); } dispose() { // no-op — see class doc: the service holds no state to tear down. } } const AdvancedDnDModule = dockviewCore.defineModule({ name: 'AdvancedDnD', serviceKey: 'advancedDnDService', create: (host) => new AdvancedDnDService(host), }); /** * Marks (on the dockview root element) that a keyboard move is in progress, so * the default navigation listener stands down while the advanced docking module * drives the keys. A neutral DOM signal keeps the two listeners coordinated * without either service holding a reference to the other. */ const KEYBOARD_MOVE_ATTRIBUTE = 'data-dv-kbd-moving'; /** * Does `e` match a binding string like `'ctrl+]'` / `'shift+f6'`? Modifiers are * matched exactly (a binding without `shift` will not fire while Shift is held), * and the final part is compared to `KeyboardEvent.key`, lower-cased. */ function matchesBinding(e, binding) { const parts = binding.toLowerCase().split('+'); const key = parts[parts.length - 1]; const mods = parts.slice(0, -1); return (e.ctrlKey === mods.includes('ctrl') && e.shiftKey === mods.includes('shift') && e.altKey === mods.includes('alt') && e.metaKey === (mods.includes('meta') || mods.includes('cmd')) && e.key.toLowerCase() === key); } /** * Resolve the `keyboardNavigation` opt-in to its options object, or `undefined` * when keyboard support is off. Both keyboard modules read the same opt-in. */ function readKeyboardNavigation(options) { const opt = options.keyboardNavigation; if (!opt) { return undefined; } return opt === true ? {} : opt; } const DEFAULT_KEYMAP$1 = { nextTab: 'ctrl+]', prevTab: 'ctrl+[', focusNextGroup: 'f6', focusPrevGroup: 'shift+f6', focusTabs: 'ctrl+shift+\\', }; /** * Keyboard navigation & focus management — operate the dock without a mouse. * Opt-in via `keyboardNavigation`, with a rebindable {@link DockviewKeybindings} * keymap. * * - **Switch tab** (`Ctrl+]` / `Ctrl+[`) — cycle the focused group's tabs. * - **Focus group** (`F6` / `Shift+F6`) — move focus to the next / previous * group in sequence. * - **Focus the tab strip** (`Ctrl+Shift+\`) — jump focus from panel content to * the active group's tab strip (the strip's roving-tabindex takes over). * - **Focus restore on close** — when removing a panel/group pulls focus out of * the dock, focus returns to the neighbour the layout just activated instead * of being stranded on `<body>`. * - **Floating `Esc`** — `Esc` inside a floating group returns focus to the * control that had it before entering the float (polite: bubble phase, * respects `defaultPrevented`, so panel content keeps `Esc`). * - **Floating Tab-containment** — Tab wraps within the floating group so focus * doesn't leak to the grid behind it. * * Stands down while an advanced keyboard move is in progress (see * {@link KEYBOARD_MOVE_ATTRIBUTE}) so the docking module owns the keys then. */ class AccessibilityService extends dockviewCore.DockviewCompositeDisposable { constructor(host) { super(); this.host = host; this._focusWasInside = false; // Listen on the document (capture) rather than the dockview element: // edge groups live in the shell *outside* the gridview, and the shell // is created after this service, so a fixed element would miss them. const doc = host.rootElement.ownerDocument; const onKeyDown = (e) => this._onKeyDown(e); doc.addEventListener('keydown', onKeyDown, true); // Remember the last control focused in the main dock (outside any // float) so Esc inside a floating group can return focus to its // invoking control. Observe-only — never consumes. const onFocusIn = (e) => { const t = e.target; if (t instanceof HTMLElement && this.host.rootElement.contains(t) && !t.closest('[role="dialog"]')) { this._lastNonFloatFocus = t; } }; doc.addEventListener('focusin', onFocusIn, true); // Esc-from-float restore runs in the BUBBLE phase and respects // defaultPrevented, so panel content that uses Esc keeps priority. const onEscape = (e) => this._onFloatingEscape(e); doc.addEventListener('keydown', onEscape, false); this.addDisposables({ dispose: () => doc.removeEventListener('keydown', onKeyDown, true), }, { dispose: () => doc.removeEventListener('focusin', onFocusIn, true), }, { dispose: () => doc.removeEventListener('keydown', onEscape, false), }, // When a close pulls focus out of the dock, return it to the // neighbour the component just activated rather than leaving it // stranded on <body>. Snapshot before the teardown (focus still on // the closing panel), restore after. host.onWillMutateLayout((e) => { if (e.kind === 'remove' && this._nav) { this._focusWasInside = this._isFocusInside(); } }), host.onDidMutateLayout((e) => { if (e.kind === 'remove' && this._nav && this._focusWasInside && !this._isFocusInside()) { this._restoreFocus(); } })); } get _moveActive() { return this.host.rootElement.hasAttribute(KEYBOARD_MOVE_ATTRIBUTE); } _isFocusInside() { const active = this.host.rootElement.ownerDocument.activeElement; return active instanceof Node && this.host.rootElement.contains(active); } _onFloatingEscape(e) { if (!this._nav || this._moveActive || e.defaultPrevented || e.key !== 'Escape') { return; } const target = e.target; if (!(target instanceof Element)) { return; } // Only when focus is inside one of *this* dock's floating groups. const float = target.closest('[role="dialog"]'); if (!float || !this.host.rootElement.contains(float)) { return; } e.preventDefault(); e.stopPropagation(); this._returnFocusFromFloat(); } /** * Keep Tab inside the floating group that holds focus: at the last tabbable * Tab wraps to the first, at the first Shift+Tab wraps to the last. Returns * true if it handled the event. No-op outside a float. */ _trapFloatTab(e) { const target = e.target; if (!(target instanceof Element)) { return false; } const float = target.closest('[role="dialog"]'); if (!float || !this.host.rootElement.contains(float)) { return false; } // Always manage Tab inside a float, never just at the boundary: focus // often sits on non-tabbable plumbing (the content container, which is // tabindex="-1"), and the browser's default Tab from there escapes to // the grid behind. Drive the cursor through the float's tabbables // ourselves and swallow the default so it can't leak out. e.preventDefault(); const tabbables = this._tabbables(float); if (tabbables.length === 0) { return true; } const active = float.ownerDocument.activeElement; const index = active instanceof HTMLElement ? tabbables.indexOf(active) : -1; const n = tabbables.length; const next = index === -1 ? e.shiftKey ? n - 1 : 0 : (index + (e.shiftKey ? -1 : 1) + n) % n; tabbables[next].focus(); return true; } _tabbables(root) { const nodes = root.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), ' + 'select:not([disabled]), textarea:not([disabled]), [tabindex]'); // tabIndex >= 0 keeps naturally-focusable controls and roving anchors // (the active tab) while dropping tabindex="-1" plumbing (content // containers, inactive tabs). return Array.from(nodes).filter((el) => el.tabIndex >= 0); } _returnFocusFromFloat() { var _a; const prev = this._lastNonFloatFocus; if (prev && prev.isConnected && this.host.rootElement.contains(prev) && !prev.closest('[role="dialog"]')) { prev.focus(); return; } // Invoking control is gone — fall back to a grid group's content. (_a = this.host.groups .find((g) => g.api.location.type === 'grid')) === null || _a === void 0 ? void 0 : _a.model.focusContent(); } get _nav() { return readKeyboardNavigation(this.host.options); } get _keymap() { var _a; return Object.assign(Object.assign({}, DEFAULT_KEYMAP$1), (_a = this._nav) === null || _a === void 0 ? void 0 : _a.keymap); } _onKeyDown(e) { if (!this._nav) { return; } // Stand down while the docking module is driving a keyboard move. if (this._moveActive) { return; } // Only act on events originating inside *this* dockview. if (!(e.target instanceof Node) || !this.host.rootElement.contains(e.target)) { return; } // Trap Tab within a floating group so focus doesn't leak to the grid // behind it (the float is non-modal, but its Tab order should be). if (e.key === 'Tab' && this._trapFloatTab(e)) { return; } const keymap = this._keymap; if (matchesBinding(e, keymap.nextTab)) { this._consume(e); this._switchTab(false); } else if (matchesBinding(e, keymap.prevTab)) { this._consume(e); this._switchTab(true); } else if (matchesBinding(e, keymap.focusNextGroup)) { this._consume(e); this._cycleGroup(false); } else if (matchesBinding(e, keymap.focusPrevGroup)) { this._consume(e); this._cycleGroup(true); } else if (matchesBinding(e, keymap.focusTabs)) { this._consume(e); this._focusTabs(); } } // --- navigation (uses the public group API + the adjacentGroup primitive) --- _focusTabs() { var _a; // Jump from panel content to the active group's tab strip; the // tablist's own roving-tabindex handler takes over from there. (_a = this.host.activeGroup) === null || _a === void 0 ? void 0 : _a.model.focusActiveTab(); } _switchTab(reverse) { const group = this.host.activeGroup; if (!group) { return; } if (reverse) { group.model.moveToPrevious(); } else { group.model.moveToNext(); } // Keep DOM focus inside the dock: switching hides the previously // focused content, which would otherwise drop focus to <body> and // leave the keymap unable to see the next key. group.model.focusContent(); } _cycleGroup(reverse) { const current = this.host.activeGroup; const target = current ? this.host.adjacentGroup(current, reverse) : this.host.groups[0]; this._focusGroup(target); } _focusGroup(target) { if (!target) { return; } target.api.setActive(); target.model.focusContent(); } /** Return DOM focus to the active group's content, keeping it in the dock. */ _restoreFocus() { var _a; (_a = this.host.activeGroup) === null || _a === void 0 ? void 0 : _a.model.focusContent(); } _consume(e) { e.preventDefault(); e.stopPropagation(); } } const AccessibilityModule = dockviewCore.defineModule({ name: 'Accessibility', serviceKey: 'accessibilityService', create: (host) => new AccessibilityService(host), }); const EDGE_FROM_KEY = { ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'top', ArrowDown: 'bottom', }; const DEFAULT_KEYMAP = { focusGroupLeft: 'ctrl+shift+arrowleft', focusGroupRight: 'ctrl+shift+arrowright', focusGroupUp: 'ctrl+shift+arrowup', focusGroupDown: 'ctrl+shift+arrowdown', dock: 'ctrl+m', }; /** * Advanced keyboard control — drive the layout itself without a mouse. Opt-in * via `keyboardNavigation` (shared with the default navigation module). * * - **Spatial group focus** (`Ctrl+Shift+Arrows`) — move focus to the visually * adjacent group via the `adjacentGroupInDirection` geometry primitive. * - **Keyboard docking** (`Ctrl+M`) — arms a two-phase move of the active panel * with a live drop preview + screen-reader narration: * 1. PICK TARGET — arrows cycle the groups (incl. the panel's own, so a tab * can be split out); `Enter` selects one. * 2. PICK EDGE — arrows choose a split edge (left/right/top/bottom) or the * centre (tab-into); `Enter` commits, `Escape` steps back. * `Escape` from the target phase cancels. * * While a move is in progress it marks the root with * {@link KEYBOARD_MOVE_ATTRIBUTE} so the default navigation listener stands * down and the move keys aren't double-handled. */ class KeyboardDockingService extends dockviewCore.DockviewCompositeDisposable { constructor(host) { super(); this.host = host; this._move = null; // Capture phase, on the document — matches the navigation listener so // edge groups (outside the gridview) are covered. const doc = host.rootElement.ownerDocument; const onKeyDown = (e) => this._onKeyDown(e); doc.addEventListener('keydown', onKeyDown, true); this.addDisposables({ dispose: () => this._exit() }, { dispose: () => doc.removeEventListener('keydown', onKeyDown, true), }); } get _enabled() { return !!readKeyboardNavigation(this.host.options); } get _keymap() { var _a, _b, _c, _d, _e, _f, _g; // The public `keyboardNavigation.keymap` carries the default-navigation // bindings; this module's bindings are read from the same object when // present (so a consumer can rebind them too). const overrides = ((_b = (_a = readKeyboardNavigation(this.host.options)) === null || _a === void 0 ? void 0 : _a.keymap) !== null && _b !== void 0 ? _b : {}); return { focusGroupLeft: (_c = overrides.focusGroupLeft) !== null && _c !== void 0 ? _c : DEFAULT_KEYMAP.focusGroupLeft, focusGroupRight: (_d = overrides.focusGroupRight) !== null && _d !== void 0 ? _d : DEFAULT_KEYMAP.focusGroupRight, focusGroupUp: (_e = overrides.focusGroupUp) !== null && _e !== void 0 ? _e : DEFAULT_KEYMAP.focusGroupUp, focusGroupDown: (_f = overrides.focusGroupDown) !== null && _f !== void 0 ? _f : DEFAULT_KEYMAP.focusGroupDown, dock: (_g = overrides.dock) !== null && _g !== void 0 ? _g : DEFAULT_KEYMAP.dock, }; } get _messages() { return dockviewCore.resolveMessages(this.host.options.messages); } _onKeyDown(e) { if (!this._enabled) { return; } if (this._move) { this._onMoveKey(e); return; } // Only act on events originating inside *this* dockview. if (!(e.target instanceof Node) || !this.host.rootElement.contains(e.target)) { return; } const keymap = this._keymap; if (matchesBinding(e, keymap.dock)) { this._enterMoveMode(e); } else if (matchesBinding(e, keymap.focusGroupLeft)) { this._consume(e); this._focusGroupInDirection('left'); } else if (matchesBinding(e, keymap.focusGroupRight)) { this._consume(e); this._focusGroupInDirection('right'); } else if (matchesBinding(e, keymap.focusGroupUp)) { this._consume(e); this._focusGroupInDirection('up'); } else if (matchesBinding(e, keymap.focusGroupDown)) { this._consume(e); this._focusGroupInDirection('down'); } } // --- spatial focus --- _focusGroupInDirection(direction) { const current = this.host.activeGroup; if (!current) { return; } // Geometry lives on the host as the shared `adjacentGroupInDirection` // primitive (also public on the api), so mouse and keyboard navigation // agree on what "the group to the left" is. this._focusGroup(this.host.adjacentGroupInDirection(current, direction)); } _focusGroup(target) { if (!target) { return; } target.api.setActive(); target.model.focusContent(); } _restoreFocus() { var _a; (_a = this.host.activeGroup) === null || _a === void 0 ? void 0 : _a.model.focusContent(); } // --- keyboard docking (move mode) --- _enterMoveMode(e) { const source = this.host.activePanel; const groups = this.host.groups; if (!source || groups.length === 0) { return; } e.preventDefault(); e.stopPropagation(); // Start on the panel's own group so a single group of tabs can still // split a tab out via the edge phase. const groupIndex = Math.max(0, groups.indexOf(source.group)); this._move = { source, groups, groupIndex, phase: 'target', position: 'center', }; // Signal the navigation module to stand down while the move runs. this.host.rootElement.setAttribute(KEYBOARD_MOVE_ATTRIBUTE, ''); this._render(); } _onMoveKey(e) { const move = this._move; if (e.key === 'Escape') { this._consume(e); if (move.phase === 'edge') { move.phase = 'target'; move.position = 'center'; this._render(); } else { this._exit(); this.host.announce(this._messages.moveCancelled()); this._restoreFocus(); } return; } if (e.key === 'Enter') { this._consume(e); if (move.phase === 'target') { move.phase = 'edge'; move.position = 'center'; this._render(); } else { this._commit(); } return; } if (move.phase === 'target') { const n = move.groups.length; if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { this._consume(e); move.groupIndex = (move.groupIndex + 1) % n; this._render(); } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { this._consume(e); move.groupIndex = (move.groupIndex - 1 + n) % n; this._render(); } return; } // edge phase const edge = EDGE_FROM_KEY[e.key]; if (edge) { this._consume(e); move.position = edge; this._render(); } else if (e.key === ' ' || e.key === 'c' || e.key === 'C') { this._consume(e); move.position = 'center'; this._render(); } } _render() { var _a, _b; const move = this._move; const group = move.groups[move.groupIndex]; this._clearPreview(); this._preview = this.host.showDropPreview(group, move.position); const name = (_b = (_a = group.activePanel) === null || _a === void 0 ? void 0 : _a.title) !== null && _b !== void 0 ? _b : group.id; const m = this._messages; if (move.phase === 'target') { this.host.announce(m.movePickTarget(this._label(move.source), name, move.groupIndex + 1, move.groups.length)); } else { this.host.announce(m.movePickEdge(move.position, name)); } } _commit() { var _a, _b; const move = this._move; const group = move.groups[move.groupIndex]; const position = move.position; const source = move.source; const name = (_b = (_a = group.activePanel) === null || _a === void 0 ? void 0 : _a.title) !== null && _b !== void 0 ? _b : group.id; const m = this._messages; this._exit(); try { this.host.dockPanel(source, group, position); this.host.announce(m.moveCommitted(this._label(source), name, position)); } catch (_c) { this.host.announce(m.moveNotAllowed()); } // The move re-renders the grid; pull focus back into the dock so the // keymap keeps working without a click. this._restoreFocus(); } _exit() { this._clearPreview(); this._move = null; this.host.rootElement.removeAttribute(KEYBOARD_MOVE_ATTRIBUTE); } _clearPreview() { var _a; (_a = this._preview) === null || _a === void 0 ? void 0 : _a.dispose(); this._preview = undefined; } _consume(e) { e.preventDefault(); e.stopPropagation(); } _label(panel) { var _a; return (_a = panel.title) !== null && _a !== void 0 ? _a : panel.id; } } dockviewCore.defineModule({ name: 'KeyboardDocking', serviceKey: 'keyboardDockingService', create: (host) => new KeyboardDockingService(host), dependsOn: [AdvancedDnDModule, dockviewCore.LiveRegionModule], }); /** * The set of modules provided by this package. `dockview` registers these via * `registerModules(Modules)` so they are available to every component. */ const Modules = [ TabGroupChipsModule, ContextMenuModule, AdvancedDnDModule, AccessibilityModule, ]; /** * `dockview` is the batteries-included entry point: it re-exports the core API * and registers the separable feature modules so every component gets the full * feature set out of the box. Consumers who want only the core can depend on * `dockview-core` directly. */ dockviewCore.registerModules(Modules); // Mark the public package as loaded so `dockview-core` doesn't warn about // direct usage. Purely drives that developer warning — no functional effect. dockviewCore.markDockviewPackageLoaded(); Object.keys(dockviewCore).forEach(function (k) { if (k !== 'default' && !Object.prototype.hasOwnProperty.call(exports, k)) Object.defineProperty(exports, k, { enumerable: true, get: function () { return dockviewCore[k]; } }); });