diff --git a/CHANGELOG.md b/CHANGELOG.md index 3afd942e..3ff95dbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,18 @@ auto-generated per-PR notes; this file is the curated, human-readable history. independently scrolling login region. ### Added +- **Same-named Workbench tabs now show their source only when disambiguation is + needed** (#464). Colliding Dashboard tabs receive a compact Dashboard icon and + the shortest readable title abbreviation; Library and draft collisions receive + explicit labels, while unique tabs keep the existing compact layout. Every tab + exposes its full source through a semantic tab control and tooltip, including + Dashboard-variable tabs. Roving Arrow/Home/End navigation preserves focus + through activation and close-driven strip replacement, and accessible names + announce unsaved, externally changed, and externally deleted state. Source + presentation is derived from the canonical saved-query/Dashboard ownership + graph rather than stored as a second identity, and repaints immediately after + Dashboard renames. Dirty, conflict, deleted, and close controls retain their + established order and behavior. - **A Dashboard panel tile head now carries duplicate, widen and expand actions** (#535). All three are revealed on tile hover or focus. *Duplicate* (edit mode) places a copy of the panel immediately after the diff --git a/docs/ADR-0003-dashboard-viewing.md b/docs/ADR-0003-dashboard-viewing.md index 7b91739c..36bed7b6 100644 --- a/docs/ADR-0003-dashboard-viewing.md +++ b/docs/ADR-0003-dashboard-viewing.md @@ -920,6 +920,35 @@ option batch supports at most 1000 options. before the success bookkeeping in `runVariableSql`, so an over-cap response creates neither History nor a detached-result source. +## Addendum (#464, 2026-07-29): tab origin is derived, but its presentation is explicit + +The #471 addendum above settled query-tab identity: a Dashboard tile's dedicated +query id is already the stable document identity, and `savedId` already drives +tab reuse and Save targeting. This change adds the missing visible and accessible +source context without introducing a reverse origin field. + +- **Ownership remains the one source of truth.** Query-tab source is projected + from `savedId` through `buildQueryOwnershipIndex`; Dashboard-variable tabs use + their existing `(dashboardId, variableName)` document binding. No + `QueryTabOrigin`, saved-query back-pointer, or recovery payload field mirrors + those references. Workspace commits repaint the strip because a Dashboard + rename can change presentation without changing any tab signal. +- **Badges exist only inside a visible name collision.** Every tab in an exact + same-name group receives a source label, while unique names reserve no badge + space. Multi-word Dashboard titles start with their initialism and expand + words in place; single-word titles start with a three-character prefix. + Distinct titles expand through their readable full text before the exceptional + case of identical titles adds the shortest unique Dashboard-id prefix. +- **Full context does not depend on a visible badge.** The selectable surface is + a native, keyboard-focusable `tab` inside the tab-list, named and titled + ` / `. A roving tab stop supports + Left/Right/Home/End, and an app-scoped focus handoff survives the strip's + replacement render after activation or close (including a confirmed dirty + close). The accessible name appends unsaved/conflict/deleted state rather than + hiding those visual indicators behind the explicit source label. The close + button remains a separate sibling control, and source badges precede the + existing conflict/deleted and dirty indicators. + ## Alternatives considered - **Durable detached snapshots:** rejected because they silently diverge from diff --git a/src/dashboard/model/tab-origin-badges.ts b/src/dashboard/model/tab-origin-badges.ts new file mode 100644 index 00000000..415cf1c0 --- /dev/null +++ b/src/dashboard/model/tab-origin-badges.ts @@ -0,0 +1,194 @@ +// Collision-only editor-tab origin labels (#464). This is a pure projection of +// the established #427 ownership reference: `savedId` identifies the open +// document and Dashboard tile references identify its source. It deliberately +// stores no reverse origin on a tab or saved query. + +import { buildQueryOwnershipIndex } from './query-ownership.js'; +import type { StoredWorkspaceV5 } from '../../generated/json-schema.types.js'; + +export interface OriginBadgeTab { + id: string; + name: string; + savedId: string | null; + /** Dashboard-variable tabs have no saved query id, but their Dashboard + * binding is already the canonical document identity (#457). */ + doc?: { kind: 'query' } | { kind: 'dashboard-variable'; dashboardId: string; variableName: string }; +} + +export type TabOriginKind = 'dashboard' | 'library' | 'draft'; + +export interface TabOriginBadge { + tabId: string; + kind: TabOriginKind; + /** Full, always-available source text (not the compact collision badge). */ + context: string; + /** Present only while another visible tab has the same displayed name. */ + badge: string | null; +} + +interface ResolvedOrigin { + kind: TabOriginKind; + context: string; + dashboardId?: string; + dashboardTitle?: string; +} + +const untitledDashboard = 'Untitled Dashboard'; + +/** + * User-visible abbreviation operates on grapheme clusters, never UTF-16 code + * units. ES2022 includes `Intl.Segmenter`, which keeps emoji, combining marks, + * and zero-width-joiner sequences intact. + */ +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); +const graphemes = (value: string): string[] => Array.from( + graphemeSegmenter.segment(value), + ({ segment }) => segment, +); + +const firstGrapheme = (value: string): string => graphemes(value)[0] ?? ''; +const graphemePrefix = (value: string, length: number): string => graphemes(value).slice(0, length).join(''); + +/** Every readable abbreviation, shortest first. Multi-word names start as an + * initialism, then expand one word in place; a one-word name starts with a + * three-character prefix. */ +function abbreviationCandidates(title: string): string[] { + const words = title.trim().split(/\s+/).filter(Boolean); + if (words.length === 1) { + const length = graphemes(words[0]).length; + return Array.from( + { length: Math.max(1, length - Math.min(3, length) + 1) }, + (_, index) => graphemePrefix(words[0], Math.min(3, length) + index), + ); + } + const candidates = [words.map(firstGrapheme).join('')]; + for (let wordIndex = 0; wordIndex < words.length; wordIndex += 1) { + const wordLength = graphemes(words[wordIndex]).length; + for (let length = 2; length <= wordLength; length += 1) { + candidates.push(words.map((word, index) => ( + index < wordIndex ? word : index === wordIndex ? graphemePrefix(word, length) : firstGrapheme(word) + )).join('')); + } + } + // Initialisms can collide with a single-word title (or with another + // differently-spaced title). The readable whole title is still a source + // label, and must be tried before falling back to opaque identity text. + const readableTitle = words.join(' '); + if (!candidates.includes(readableTitle)) candidates.push(readableTitle); + return candidates; +} + +/** + * Compact, deterministic Dashboard labels. Only labels within the same visible + * name collision group compete. A duplicate Dashboard title cannot become + * unique by extension alone, so the shortest unique prefix of its stable + * Dashboard id is appended as the final, exceptional tie-breaker. + */ +function dashboardBadges(origins: readonly ResolvedOrigin[]): string[] { + const candidates = origins.map((origin) => abbreviationCandidates(origin.dashboardTitle!)); + const candidateIndexes = origins.map(() => 0); + const labels = (): string[] => candidates.map((options, index) => options[candidateIndexes[index]]); + let current = labels(); + let changed = true; + while (changed) { + const counts = new Map(); + current.forEach((label) => counts.set(label, (counts.get(label) ?? 0) + 1)); + const nextIndexes = current.map((label, index) => { + if ((counts.get(label) ?? 0) <= 1) return candidateIndexes[index]; + return Math.min(candidateIndexes[index] + 1, candidates[index].length - 1); + }); + changed = nextIndexes.some((value, index) => value !== candidateIndexes[index]); + candidateIndexes.splice(0, candidateIndexes.length, ...nextIndexes); + current = labels(); + } + const counts = new Map(); + current.forEach((label) => counts.set(label, (counts.get(label) ?? 0) + 1)); + return current.map((label, index) => { + if ((counts.get(label) ?? 0) <= 1) return label; + const peerIndexes = current + .map((candidate, peerIndex) => ({ candidate, peerIndex })) + .filter(({ candidate }) => candidate === label) + .map(({ peerIndex }) => peerIndex); + // A full readable title is in every candidate sequence, so different + // normalized titles should never reach this point. Keep that contract + // explicit: an id is only an exceptional tie-breaker for truly identical + // Dashboard titles, never a substitute for title disambiguation. + const ids = peerIndexes.map((peerIndex) => origins[peerIndex].dashboardId!); + let prefixLength = 1; + while (prefixLength < Math.max(...ids.map((id) => id.length)) + && new Set(ids.map((id) => id.slice(0, prefixLength))).size < ids.length) { + prefixLength += 1; + } + return `${label} · ${origins[index].dashboardId!.slice(0, prefixLength)}`; + }); +} + +function resolveOrigins( + tabs: readonly OriginBadgeTab[], workspace: StoredWorkspaceV5 | null, +): ResolvedOrigin[] { + if (workspace === null) { + return tabs.map((tab) => (tab.savedId === null + ? { kind: 'draft' as const, context: 'Draft' } + : { kind: 'library' as const, context: 'Library' })); + } + const ownership = buildQueryOwnershipIndex(workspace); + return tabs.map((tab) => { + const variable = tab.doc?.kind === 'dashboard-variable' ? tab.doc : null; + if (variable !== null) { + const dashboards = workspace.dashboards.filter((dashboard) => dashboard.id === variable.dashboardId); + if (dashboards.length !== 1) return { kind: 'draft' as const, context: 'Draft' }; + const title = dashboards[0].title.trim() || untitledDashboard; + return { + kind: 'dashboard' as const, + context: title, + dashboardId: variable.dashboardId, + dashboardTitle: title, + }; + } + if (tab.savedId === null) return { kind: 'draft' as const, context: 'Draft' }; + const owners = ownership.ownersByQueryId.get(tab.savedId) ?? []; + if (owners.length !== 1) return { kind: 'library' as const, context: 'Library' }; + const dashboards = workspace.dashboards.filter((dashboard) => dashboard.id === owners[0].dashboardId); + if (dashboards.length !== 1) return { kind: 'library' as const, context: 'Library' }; + const title = dashboards[0].title.trim() || untitledDashboard; + return { + kind: 'dashboard' as const, + context: title, + dashboardId: owners[0].dashboardId, + dashboardTitle: title, + }; + }); +} + +/** + * Derive every tab's full source context and collision-only compact badge. + * Names decide only whether a visible label is needed; document classification + * always comes from `savedId` and the canonical Dashboard ownership index. + */ +export function planTabOriginBadges( + tabs: readonly OriginBadgeTab[], workspace: StoredWorkspaceV5 | null, +): TabOriginBadge[] { + const origins = resolveOrigins(tabs, workspace); + const badges = tabs.map(() => null as string | null); + const byName = new Map(); + tabs.forEach((tab, index) => { + const indexes = byName.get(tab.name); + if (indexes) indexes.push(index); + else byName.set(tab.name, [index]); + }); + for (const indexes of byName.values()) { + if (indexes.length < 2) continue; + const dashboardIndexes = indexes.filter((index) => origins[index].kind === 'dashboard'); + const dashboardLabels = dashboardBadges(dashboardIndexes.map((index) => origins[index])); + const dashboardLabelByIndex = new Map(dashboardIndexes.map((index, labelIndex) => [index, dashboardLabels[labelIndex]])); + for (const index of indexes) { + const origin = origins[index]; + badges[index] = origin.kind === 'dashboard' + ? dashboardLabelByIndex.get(index)! + : origin.kind === 'library' ? 'Library' : 'Draft'; + } + } + return tabs.map((tab, index) => ({ + tabId: tab.id, kind: origins[index].kind, context: origins[index].context, badge: badges[index], + })); +} diff --git a/src/styles.css b/src/styles.css index 96ef7d12..1b5c89d6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1917,16 +1917,23 @@ body.detached-tab .graph-overlay-panel { } .qtabs-inner { display: flex; flex: 1; overflow: auto; height: 100%; } .qtab { - display: flex; align-items: center; gap: 8px; - padding: 0 8px 0 12px; + display: flex; align-items: center; height: 100%; border-right: 1px solid var(--border); - cursor: pointer; font-size: var(--text-label); color: var(--fg-mute); position: relative; white-space: nowrap; min-width: 100px; } +.qtab-select { + min-width: 0; flex: 1; + display: flex; align-items: center; gap: 8px; + align-self: stretch; + padding: 0 8px 0 12px; + border: 0; background: transparent; color: inherit; + font: inherit; text-align: left; cursor: pointer; +} +.qtab-select:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .qtab.active { background: var(--bg-editor); color: var(--fg); font-weight: var(--fw-medium); @@ -1935,7 +1942,16 @@ body.detached-tab .graph-overlay-panel { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px; background: var(--accent); } -.qtab .name { flex: 1; overflow: hidden; text-overflow: ellipsis; max-width: 200px; } +.qtab .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; max-width: 200px; } +.qtab-origin { + flex-shrink: 0; + display: inline-flex; align-items: center; gap: 3px; + padding: 1px 4px; + border: 1px solid var(--border); border-radius: var(--r-xs); + background: var(--bg-chip); color: var(--fg-mute); + font-size: var(--text-micro); font-weight: var(--fw-medium); line-height: var(--lh-flush); +} +.qtab-origin svg { flex-shrink: 0; } .qtab .dirty { width: 5px; height: 5px; border-radius: var(--r-xs); background: var(--fg-mute); flex-shrink: 0; } /* #343: the marker saying this tab's linked query changed ('!') or was deleted ('⌫') in another browser tab. Like the conflict chooser it reaches, this @@ -1962,6 +1978,7 @@ body.detached-tab .graph-overlay-panel { } .qtab .close { width: 16px; height: 16px; border-radius: var(--r-xs); padding: 0; + margin-right: 8px; border: none; background: transparent; color: var(--fg-faint); cursor: pointer; display: flex; align-items: center; justify-content: center; diff --git a/src/ui/app.ts b/src/ui/app.ts index 3132fba0..b0df5c9f 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -2288,6 +2288,11 @@ export function createApp(env: CreateAppEnv = {}): App { // and stays put rather than navigating nowhere.) cancelDashboardTreeClicks(app); invalidateDashboardTree(); + // #464: Dashboard titles and ownership are presentation inputs for the + // Query tab strip. A workspace commit can change either without changing a + // tab signal (for example, renaming a Dashboard), so repaint explicitly + // after the complete projection rather than waiting for a later tab edit. + renderTabs(app); // #425: COMPLETE the fallback, don't just record it. Rewriting the route and // leaving the Dashboard host exposed wedges the app: every path back — // `showQuerySurface`, the header switch, `g w`, a Library click — early-returns diff --git a/src/ui/tabs.ts b/src/ui/tabs.ts index 45e41659..b31b3b45 100644 --- a/src/ui/tabs.ts +++ b/src/ui/tabs.ts @@ -9,10 +9,12 @@ import { } from '../state.js'; import { cloneJson, queryName, upgradeSavedQuery } from '../core/saved-query.js'; import { queryToken } from '../workspace/workspace-sync.js'; +import { planTabOriginBadges } from '../dashboard/model/tab-origin-badges.js'; import { batch } from '@preact/signals-core'; import type { AppDom } from './app.types.js'; import type { AppState, QueryTab } from '../state.js'; import type { SavedQueryV2 } from '../generated/json-schema.types.js'; +import type { StoredWorkspaceV5 } from '../generated/json-schema.types.js'; import type { EditorPort } from '../editor/editor-port.types.js'; /** The narrow slice of the real `app` controller this module reads — not the @@ -25,6 +27,8 @@ import type { EditorPort } from '../editor/editor-port.types.js'; export interface TabsApp { dom: Pick; state: AppState; + /** The committed aggregate is the canonical source for Dashboard ownership. */ + currentWorkspace: StoredWorkspaceV5 | null; /** #447 narrowed this: `actions.setEditorMode` + `specEditor.revealOffset` * were read ONLY by the removed Filter-role badge. */ sqlEditor: Pick; @@ -33,6 +37,37 @@ export interface TabsApp { document: Document; } +// `renderTabs` deliberately replaces the strip wholesale. A native click (or +// Space/Enter's native click) therefore removes the button that received the +// event. Keep the intended selector across the signal-driven render and focus +// its replacement synchronously at the end of that render; a timeout would +// race the workbench shell's effect and leave keyboard focus on a detached +// node. Closing an active tab uses this same hand-off for its chosen neighbour. +// Key by app so two independently mounted/tested controllers cannot steal each +// other's focus request. +const pendingTabFocusIds = new WeakMap(); + +const tabStateDescription = (tab: QueryTab): string => { + const states = [ + tabSaveDirty(tab) ? 'Unsaved changes' : null, + tab.externalState === 'conflict' ? 'Changed in another tab; resolve the conflict to save' : null, + tab.externalState === 'deleted' ? 'Deleted in another tab; saving will create a new query' : null, + ].filter((state): state is string => state !== null); + return states.join('. '); +}; + +const tabAccessibleLabel = (context: string, tab: QueryTab): string => { + const state = tabStateDescription(tab); + return state ? `${context} / ${tab.name}. ${state}` : `${context} / ${tab.name}`; +}; + +/** Select a tab and preserve keyboard focus across the render replacement. */ +function activateTab(app: TabsApp, id: string): void { + if (id === app.state.activeTabId.value) return; + pendingTabFocusIds.set(app, id); + selectTab(app, id); +} + // #447 removed `filterRoleBadge` (and its `FilterRoleTarget`): it was the shared // "Filter" role badge painted next to a tab name here and next to a Library row // in saved-history.ts, and the only role it ever announced was the Filter role @@ -43,15 +78,46 @@ export interface TabsApp { export function renderTabs(app: TabsApp): void { const host = app.dom.qtabsInner; if (!host) return; + host.setAttribute('role', 'tablist'); + host.setAttribute('aria-label', 'Open query tabs'); + const origins = new Map(planTabOriginBadges(app.state.tabs.value, app.currentWorkspace) + .map((origin) => [origin.tabId, origin])); host.replaceChildren(...app.state.tabs.value.map((t) => { const isActive = t.id === app.state.activeTabId.value; - return h('div', { class: 'qtab' + (isActive ? ' active' : ''), onclick: () => selectTab(app, t.id) }, - h('span', { class: 'name' }, t.name), + const origin = origins.get(t.id)!; + const fullContext = `${origin.context} / ${t.name}`; + const select = (): void => activateTab(app, t.id); + const move = (event: KeyboardEvent): void => { + const tabs = app.state.tabs.value; + const index = tabs.findIndex((tab) => tab.id === t.id); + const targetIndex = event.key === 'ArrowLeft' ? (index + tabs.length - 1) % tabs.length + : event.key === 'ArrowRight' ? (index + 1) % tabs.length + : event.key === 'Home' ? 0 + : event.key === 'End' ? tabs.length - 1 : -1; + if (targetIndex < 0) return; + event.preventDefault(); + activateTab(app, tabs[targetIndex].id); + }; + return h('div', { class: 'qtab' + (isActive ? ' active' : '') }, + h('button', { + class: 'qtab-select', type: 'button', role: 'tab', title: fullContext, + 'aria-selected': isActive ? 'true' : 'false', + 'aria-label': tabAccessibleLabel(origin.context, t), + tabindex: isActive ? '0' : '-1', 'data-tab-id': t.id, + onclick: select, + onkeydown: move, + }, + h('span', { class: 'name' }, t.name), + origin.badge === null ? null : h('span', { + class: `qtab-origin ${origin.kind}`, + 'aria-hidden': 'true', + }, origin.kind === 'dashboard' ? Icon.dashboard() : null, origin.badge), // #343: a visible marker when this tab's linked saved query changed // ('conflict') or was deleted ('deleted') in another browser tab. t.externalState ? h('span', { class: 'qtab-external ' + t.externalState, + 'aria-hidden': 'true', title: t.externalState === 'conflict' ? 'This query changed in another tab — resolve the conflict to save' : 'This query was deleted in another tab — Save will create a new one', @@ -59,15 +125,22 @@ export function renderTabs(app: TabsApp): void { : null, // #457: `tabSaveDirty`, not `tabDirty` — the dot and the Save button must // read the SAME predicate, and a variable tab's Spec is never saved. - tabSaveDirty(t) ? h('span', { class: 'dirty' }) : null, + tabSaveDirty(t) ? h('span', { class: 'dirty', 'aria-hidden': 'true' }) : null, + ), app.state.tabs.value.length > 1 ? h('button', { - class: 'close', + class: 'close', type: 'button', title: `Close ${t.name}`, 'aria-label': `Close ${t.name}`, onclick: (e: Event) => { e.stopPropagation(); requestCloseTab(app, t.id, e.currentTarget as HTMLElement); }, }, Icon.close()) : null, ); })); + const pendingTabFocusId = pendingTabFocusIds.get(app); + if (pendingTabFocusId !== undefined) { + pendingTabFocusIds.delete(app); + Array.from(host.querySelectorAll('.qtab-select')) + .find((select) => select.dataset.tabId === pendingTabFocusId)?.focus(); + } } // No refresh() any more: an effect wired in createApp() reads `tabs`/`activeTabId` @@ -275,7 +348,7 @@ export function openVariableTab( */ export function requestCloseTab(app: TabsApp, id: string, trigger: HTMLElement): void { const tab = app.state.tabs.value.find((t) => t.id === id)!; - if (!tabSaveDirty(tab)) { closeTab(app, id); return; } + if (!tabSaveDirty(tab)) { closeTab(app, id, true); return; } openMenu({ document: app.document, trigger, @@ -286,7 +359,7 @@ export function requestCloseTab(app: TabsApp, id: string, trigger: HTMLElement): kind: 'item', label: 'Close without saving', extraClass: 'qtab-close-confirm-go', - onClick: () => closeTab(app, id), + onClick: () => closeTab(app, id, true), }, { kind: 'item', label: 'Cancel', extraClass: 'qtab-close-confirm-cancel', autofocus: true, @@ -296,15 +369,20 @@ export function requestCloseTab(app: TabsApp, id: string, trigger: HTMLElement): }); } -/** Close a tab (never the last one), re-selecting a neighbour if needed. */ -export function closeTab(app: TabsApp, id: string): void { +/** Close a tab (never the last one), re-selecting a neighbour if needed. + * UI close controls request focus restoration because their own DOM is about + * to be replaced; programmatic lifecycle callers keep their existing focus. */ +export function closeTab(app: TabsApp, id: string, restoreFocus = false): void { if (app.state.tabs.value.length <= 1) return; const idx = app.state.tabs.value.findIndex((t) => t.id === id); + const wasActive = id === app.state.activeTabId.value; batch(() => { app.state.tabs.value = app.state.tabs.value.filter((t) => t.id !== id); - if (id === app.state.activeTabId.value) { - app.state.activeTabId.value = app.state.tabs.value[Math.max(0, idx - 1)].id; - } + const selectedId = wasActive + ? app.state.tabs.value[Math.max(0, idx - 1)].id + : app.state.activeTabId.value; + if (restoreFocus) pendingTabFocusIds.set(app, selectedId); + if (wasActive) app.state.activeTabId.value = selectedId; }); } diff --git a/src/ui/workbench/workbench-shell.ts b/src/ui/workbench/workbench-shell.ts index 19c2bad4..16af7a75 100644 --- a/src/ui/workbench/workbench-shell.ts +++ b/src/ui/workbench/workbench-shell.ts @@ -125,7 +125,7 @@ export function mountWorkbenchShell(deps: WorkbenchShellDeps): () => void { save: (name, value) => prefs.save(name as PreferenceKey, value), }; - app.dom.qtabsInner = h('div', { class: 'qtabs-inner' }); + app.dom.qtabsInner = h('div', { class: 'qtabs-inner', role: 'tablist', 'aria-label': 'Open query tabs' }); const qtabsRow = h('div', { class: 'qtabs' }, app.dom.qtabsInner, h('button', { class: 'new-tab', title: 'New query', onclick: () => actions.newTab() }, Icon.plus())); diff --git a/tests/e2e/tile-open-workbench.spec.js b/tests/e2e/tile-open-workbench.spec.js index 3b7017c2..d9266987 100644 --- a/tests/e2e/tile-open-workbench.spec.js +++ b/tests/e2e/tile-open-workbench.spec.js @@ -94,6 +94,78 @@ test('two Dashboard copies with the SAME name open two tabs; re-opening selects expect(after.filter((t) => t.active).map((t) => t.savedId)).toEqual(['q-sales']); }); +test('tab keyboard activation, roving navigation, and close preserve focus', async ({ page }) => { + await open(page); + await openDashboard(page, 'sales'); + await tileAction(page, 'Live KPIs').click(); + await openDashboard(page, 'ops'); + await tileAction(page, 'Live KPIs').click(); + + const selectors = page.locator('.qtab-select'); + await expect(selectors).toHaveCount(3); + await expect(selectors.nth(2)).toHaveAttribute('tabindex', '0'); + await expect(selectors.nth(1)).toHaveAttribute('tabindex', '-1'); + + // A native Space click changes the active signal, replaces the whole strip, + // and must focus the replacement rather than the now-detached old button. + await selectors.nth(1).focus(); + await page.keyboard.press('Space'); + await expect(page.locator('.qtab-select[data-tab-id="t2"]')).toBeFocused(); + await expect(page.locator('.qtab-select[data-tab-id="t2"]')).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.qtab-select[data-tab-id="t2"]')).toHaveAttribute('tabindex', '0'); + + await page.keyboard.press('ArrowRight'); + await expect(page.locator('.qtab-select[data-tab-id="t3"]')).toBeFocused(); + await page.keyboard.press('Home'); + await expect(page.locator('.qtab-select[data-tab-id="t1"]')).toBeFocused(); + await page.keyboard.press('End'); + await expect(page.locator('.qtab-select[data-tab-id="t3"]')).toBeFocused(); + await page.keyboard.press('ArrowRight'); + await expect(page.locator('.qtab-select[data-tab-id="t1"]')).toBeFocused(); + + // Close the focused active first tab. Its next neighbour becomes the first + // remaining tab and receives both selection and focus. + await page.locator('.qtab.active .close').click(); + await expect(page.locator('.qtab-select[data-tab-id="t2"]')).toBeFocused(); + await expect(page.locator('.qtab-select[data-tab-id="t2"]')).toHaveAttribute('aria-selected', 'true'); +}); + +test('closing a clean inactive tab focuses the surviving active tab', async ({ page }) => { + await open(page); + await openDashboard(page, 'sales'); + await tileAction(page, 'Live KPIs').click(); + await openDashboard(page, 'ops'); + await tileAction(page, 'Live KPIs').click(); + + await expect(page.locator('.qtab-select[data-tab-id="t3"]')).toHaveAttribute('aria-selected', 'true'); + await page.locator('.qtab').filter({ has: page.locator('[data-tab-id="t2"]') }).locator('.close').click(); + await expect(page.locator('.qtab-select[data-tab-id="t2"]')).toHaveCount(0); + await expect(page.locator('.qtab-select[data-tab-id="t3"]')).toBeFocused(); + await expect(page.locator('.qtab-select[data-tab-id="t3"]')).toHaveAttribute('aria-selected', 'true'); +}); + +test('confirming a dirty inactive close focuses the surviving active tab', async ({ page }) => { + await open(page); + await openDashboard(page, 'sales'); + await tileAction(page, 'Live KPIs').click(); + await openDashboard(page, 'ops'); + await tileAction(page, 'Live KPIs').click(); + + // Dirty the sales copy, then return selection to the ops copy so the close + // target is inactive when its confirmation opens. + await page.locator('.qtab-select[data-tab-id="t2"]').click(); + await page.locator('.cm-content[data-language="sql"]').click(); + await page.keyboard.type(' '); + await page.locator('.qtab-select[data-tab-id="t3"]').click(); + await page.locator('.qtab').filter({ has: page.locator('[data-tab-id="t2"]') }).locator('.close').click(); + await expect(page.locator('.qtab-close-confirm')).toBeVisible(); + await page.locator('.qtab-close-confirm-go').click(); + + await expect(page.locator('.qtab-select[data-tab-id="t2"]')).toHaveCount(0); + await expect(page.locator('.qtab-select[data-tab-id="t3"]')).toBeFocused(); + await expect(page.locator('.qtab-select[data-tab-id="t3"]')).toHaveAttribute('aria-selected', 'true'); +}); + test('the action is keyboard reachable and activates on Enter', async ({ page }) => { await open(page); await openDashboard(page, 'sales'); diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index a2e5577e..f6f74592 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -6542,6 +6542,29 @@ describe('unified /sql routing', () => { .find((item) => item.querySelector('.fm-label')?.textContent === label)!.click(); }; + it('repaints tab origin context when a workspace projection renames a Dashboard', () => { + const app = createApp(env()); + const query = savedQuery({ id: 'q1', name: 'Overview', sql: 'SELECT 1' }); + const first = { + ...dashboardWorkspace([query]), + dashboards: [{ + ...dashboardWorkspace().dashboards[0], title: 'Operations', + tiles: [{ id: 'tile', queryId: 'q1' }], + }], + }; + app.applyCommittedWorkspace(first); + app.activeTab().name = 'Overview'; + app.activeTab().savedId = 'q1'; + app.renderApp(); + expect(qs(app.root, '.qtab-select').title).toBe('Operations / Overview'); + + app.applyCommittedWorkspace({ + ...first, + dashboards: [{ ...first.dashboards[0], title: 'Production' }], + }); + expect(qs(app.root, '.qtab-select').title).toBe('Production / Overview'); + }); + it('tracks mounted renderer lifetime independently from workspace loading', () => { const app = createApp(env()); const before = app.captureSurfaceGeneration(); diff --git a/tests/unit/tab-origin-badges.test.ts b/tests/unit/tab-origin-badges.test.ts new file mode 100644 index 00000000..3d8585d9 --- /dev/null +++ b/tests/unit/tab-origin-badges.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; +import { planTabOriginBadges } from '../../src/dashboard/model/tab-origin-badges.js'; +import type { StoredWorkspaceV5 } from '../../src/generated/json-schema.types.js'; + +const workspace = (dashboards: { id: string; title: string; queryIds: string[] }[]): StoredWorkspaceV5 => ({ + id: 'w1', key: 'workspace', name: 'Workspace', storageVersion: 5, + queries: dashboards.flatMap((dashboard) => dashboard.queryIds.map((id) => ({ id }))), + dashboards: dashboards.map((dashboard) => ({ + id: dashboard.id, title: dashboard.title, + tiles: dashboard.queryIds.map((queryId, index) => ({ id: `${dashboard.id}-tile-${index}`, queryId })), + })), +} as unknown as StoredWorkspaceV5); + +describe('planTabOriginBadges', () => { + it('keeps unique names compact while retaining full source context', () => { + expect(planTabOriginBadges([ + { id: 'library', name: 'Library query', savedId: 'l1' }, + { id: 'dashboard', name: 'Dashboard query', savedId: 'd1' }, + { id: 'draft', name: 'Untitled', savedId: null }, + ], workspace([{ id: 'ops', title: 'ClickHouse Operations', queryIds: ['d1'] }]))).toEqual([ + { tabId: 'library', kind: 'library', context: 'Library', badge: null }, + { tabId: 'dashboard', kind: 'dashboard', context: 'ClickHouse Operations', badge: null }, + { tabId: 'draft', kind: 'draft', context: 'Draft', badge: null }, + ]); + }); + + it('badges only same-name Library and Dashboard tabs', () => { + const plans = planTabOriginBadges([ + { id: 'library', name: 'Overview · Live KPIs', savedId: 'l1' }, + { id: 'dashboard', name: 'Overview · Live KPIs', savedId: 'd1' }, + { id: 'unique', name: 'Other', savedId: 'd2' }, + ], workspace([{ id: 'ops', title: 'ClickHouse Operations', queryIds: ['d1', 'd2'] }])); + expect(plans).toEqual([ + { tabId: 'library', kind: 'library', context: 'Library', badge: 'Library' }, + { tabId: 'dashboard', kind: 'dashboard', context: 'ClickHouse Operations', badge: 'CO' }, + { tabId: 'unique', kind: 'dashboard', context: 'ClickHouse Operations', badge: null }, + ]); + }); + + it('derives Dashboard context for same-named Dashboard-variable tabs', () => { + const plans = planTabOriginBadges([ + { + id: 'ops-variable', name: 'Region', savedId: null, + doc: { kind: 'dashboard-variable', dashboardId: 'ops', variableName: 'region' }, + }, + { + id: 'prod-variable', name: 'Region', savedId: null, + doc: { kind: 'dashboard-variable', dashboardId: 'prod', variableName: 'region' }, + }, + { id: 'draft', name: 'Region', savedId: null }, + ], workspace([ + { id: 'ops', title: 'ClickHouse Operations', queryIds: [] }, + { id: 'prod', title: 'Production', queryIds: [] }, + ])); + expect(plans).toEqual([ + { tabId: 'ops-variable', kind: 'dashboard', context: 'ClickHouse Operations', badge: 'CO' }, + { tabId: 'prod-variable', kind: 'dashboard', context: 'Production', badge: 'Pro' }, + { tabId: 'draft', kind: 'draft', context: 'Draft', badge: 'Draft' }, + ]); + expect(planTabOriginBadges([{ + id: 'missing-variable', name: 'Region', savedId: null, + doc: { kind: 'dashboard-variable', dashboardId: 'missing', variableName: 'region' }, + }], workspace([]))).toEqual([ + { tabId: 'missing-variable', kind: 'draft', context: 'Draft', badge: null }, + ]); + }); + + it('extends conflicting Dashboard initialisms deterministically', () => { + const plans = planTabOriginBadges([ + { id: 'a', name: 'Overview', savedId: 'a1' }, + { id: 'b', name: 'Overview', savedId: 'b1' }, + { id: 'c', name: 'Overview', savedId: 'c1' }, + ], workspace([ + { id: 'first', title: 'ClickHouse Operations', queryIds: ['a1'] }, + { id: 'second', title: 'Cloud Observability', queryIds: ['b1'] }, + { id: 'third', title: 'Production', queryIds: ['c1'] }, + ])); + expect(plans.map((plan) => plan.badge)).toEqual(['CliO', 'CloO', 'Pro']); + }); + + it('abbreviates Dashboard titles by grapheme, preserving emoji and combining sequences', () => { + const plans = planTabOriginBadges([ + { id: 'chart', name: 'Overview', savedId: 'chart-query' }, + { id: 'rocket', name: 'Overview', savedId: 'rocket-query' }, + { id: 'developer', name: 'Overview', savedId: 'developer-query' }, + { id: 'combining', name: 'Overview', savedId: 'combining-query' }, + ], workspace([ + { id: 'chart-dashboard', title: '📊 Operations', queryIds: ['chart-query'] }, + { id: 'rocket-dashboard', title: '🚀 Production', queryIds: ['rocket-query'] }, + { id: 'developer-dashboard', title: '👩‍💻 Analytics', queryIds: ['developer-query'] }, + { id: 'combining-dashboard', title: 'e\u0301clair Metrics', queryIds: ['combining-query'] }, + ])); + expect(plans.map((plan) => plan.badge)).toEqual(['📊O', '🚀P', '👩‍💻A', 'éM']); + expect(plans[2].badge).toBe('👩‍💻A'); + }); + + it('recomputes badges when a collision is closed or renamed', () => { + const tabs = [ + { id: 'a', name: 'Overview', savedId: 'a1' }, + { id: 'b', name: 'Overview', savedId: 'b1' }, + ]; + const current = workspace([ + { id: 'first', title: 'Analytics', queryIds: ['a1'] }, + { id: 'second', title: 'Billing', queryIds: ['b1'] }, + ]); + expect(planTabOriginBadges(tabs, current).map((plan) => plan.badge)).toEqual(['Ana', 'Bil']); + expect(planTabOriginBadges([tabs[0]], current)[0].badge).toBeNull(); + expect(planTabOriginBadges([{ ...tabs[1], name: 'Revenue' }, tabs[0]], current) + .map((plan) => plan.badge)).toEqual([null, null]); + }); + + it('fails closed to Library for malformed or unavailable ownership', () => { + expect(planTabOriginBadges([{ id: 'saved', name: 'Same', savedId: 'missing' }], null)) + .toEqual([{ tabId: 'saved', kind: 'library', context: 'Library', badge: null }]); + const malformed = workspace([ + { id: 'first', title: 'First', queryIds: ['shared'] }, + { id: 'second', title: 'Second', queryIds: ['shared'] }, + ]); + expect(planTabOriginBadges([ + { id: 'shared', name: 'Same', savedId: 'shared' }, + { id: 'draft', name: 'Same', savedId: null }, + ], malformed)).toEqual([ + { tabId: 'shared', kind: 'library', context: 'Library', badge: 'Library' }, + { tabId: 'draft', kind: 'draft', context: 'Draft', badge: 'Draft' }, + ]); + }); + + it('uses the untitled fallback and does not guess an ambiguous owner Dashboard', () => { + const current = { + ...workspace([{ id: 'blank', title: '', queryIds: ['blank-query'] }]), + dashboards: [{ id: 'blank', title: ' ', tiles: [{ id: 'tile', queryId: 'blank-query' }] }], + } as unknown as StoredWorkspaceV5; + expect(planTabOriginBadges([ + { id: 'blank', name: 'Blank', savedId: 'blank-query' }, + { id: 'ambiguous', name: 'Ambiguous', savedId: 'ambiguous-query' }, + ], current)).toEqual([ + { tabId: 'blank', kind: 'dashboard', context: 'Untitled Dashboard', badge: null }, + { tabId: 'ambiguous', kind: 'library', context: 'Library', badge: null }, + ]); + const ambiguous = { + ...current, + dashboards: [ + ...current.dashboards, + { id: 'blank', title: 'Duplicate', tiles: [{ id: 'second-tile', queryId: 'ambiguous-query' }] }, + ], + } as unknown as StoredWorkspaceV5; + expect(planTabOriginBadges([{ id: 'ambiguous', name: 'Ambiguous', savedId: 'ambiguous-query' }], ambiguous)[0]) + .toMatchObject({ kind: 'library', context: 'Library' }); + }); + + it('uses stable Dashboard identity only when duplicate titles cannot be extended', () => { + const plans = planTabOriginBadges([ + { id: 'first-tab', name: 'Same', savedId: 'a1' }, + { id: 'second-tab', name: 'Same', savedId: 'b1' }, + ], workspace([ + { id: 'alpha', title: 'Operations', queryIds: ['a1'] }, + { id: 'alpine', title: 'Operations', queryIds: ['b1'] }, + ])); + expect(plans.map((plan) => plan.badge)).toEqual(['Operations · alph', 'Operations · alpi']); + }); + + it('uses title text with word boundaries before any id fallback', () => { + const plans = planTabOriginBadges([ + { id: 'spaced', name: 'Same', savedId: 'a1' }, + { id: 'unspaced', name: 'Same', savedId: 'b1' }, + ], workspace([ + { id: 'spaced-dashboard', title: 'U S A', queryIds: ['a1'] }, + { id: 'unspaced-dashboard', title: 'USA', queryIds: ['b1'] }, + ])); + expect(plans.map((plan) => plan.badge)).toEqual(['U S A', 'USA']); + expect(plans.some((plan) => plan.badge?.includes('·'))).toBe(false); + }); +}); diff --git a/tests/unit/tabs.test.ts b/tests/unit/tabs.test.ts index 6264bb55..7b3cebc7 100644 --- a/tests/unit/tabs.test.ts +++ b/tests/unit/tabs.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { effect } from '@preact/signals-core'; import { renderTabs, selectTab, newTab, closeTab, requestCloseTab, loadIntoNewTab, openVariableTab, reconcileVariableTab, discardVariableDraft, @@ -48,6 +49,7 @@ describe('renderTabs', () => { const tabs = app.dom.qtabsInner.querySelectorAll('.qtab'); expect(tabs).toHaveLength(2); expect(tabs[0].classList.contains('active')).toBe(true); + expect(tabs[0].querySelector('.qtab-select')?.getAttribute('aria-selected')).toBe('true'); expect(tabs[0].querySelector('.dirty')).not.toBeNull(); expect(tabs[0].querySelector('.close')).not.toBeNull(); }); @@ -73,17 +75,169 @@ describe('renderTabs', () => { expect(b2.title).toContain('deleted in another tab'); expect(tabs[2].querySelector('.qtab-external')).toBeNull(); // normal tab: no badge }); + it('announces dirty and external states without losing the origin or name', () => { + const app = makeApp(); + app.state.tabs.value = [ + { id: 'dirty', name: 'Dirty', savedId: null, dirtySql: true, dirtySpec: false } as QueryTab, + { id: 'conflict', name: 'Conflict', savedId: null, dirtySql: false, dirtySpec: false, externalState: 'conflict' } as QueryTab, + { id: 'deleted', name: 'Deleted', savedId: null, dirtySql: false, dirtySpec: false, externalState: 'deleted' } as QueryTab, + { id: 'both', name: 'Both', savedId: null, dirtySql: true, dirtySpec: false, externalState: 'conflict' } as QueryTab, + { id: 'both-deleted', name: 'Both deleted', savedId: null, dirtySql: true, dirtySpec: false, externalState: 'deleted' } as QueryTab, + ]; + renderTabs(app); + const label = (id: string): string => qs(app.dom.qtabsInner, `[data-tab-id="${id}"]`).getAttribute('aria-label')!; + expect(label('dirty')).toBe('Draft / Dirty. Unsaved changes'); + expect(label('conflict')).toBe('Draft / Conflict. Changed in another tab; resolve the conflict to save'); + expect(label('deleted')).toBe('Draft / Deleted. Deleted in another tab; saving will create a new query'); + expect(label('both')).toBe('Draft / Both. Unsaved changes. Changed in another tab; resolve the conflict to save'); + expect(label('both-deleted')).toBe('Draft / Both deleted. Unsaved changes. Deleted in another tab; saving will create a new query'); + expect(app.dom.qtabsInner.querySelector('.dirty')?.getAttribute('aria-hidden')).toBe('true'); + expect(app.dom.qtabsInner.querySelector('.qtab-external')?.getAttribute('aria-hidden')).toBe('true'); + }); + it('renders collision-only origin badges while full context stays accessible', () => { + const app = makeApp(); + app.currentWorkspace = { + id: 'w1', key: 'workspace', name: 'Workspace', storageVersion: 5, + queries: [], + dashboards: [{ id: 'ops', title: 'ClickHouse Operations', tiles: [{ id: 'tile', queryId: 'd1' }] }], + } as unknown as typeof app.currentWorkspace; + app.state.tabs.value = [ + { id: 't1', name: 'Overview', savedId: 'library', dirtySql: true, dirtySpec: false } as QueryTab, + { id: 't2', name: 'Overview', savedId: 'd1', dirtySql: false, dirtySpec: false, externalState: 'conflict' } as QueryTab, + { id: 't4', name: 'Overview', savedId: null, dirtySql: true, dirtySpec: false, externalState: 'deleted' } as QueryTab, + { id: 't3', name: 'Unique', savedId: 'd1', dirtySql: false, dirtySpec: false } as QueryTab, + ]; + renderTabs(app); + const tabs = app.dom.qtabsInner.querySelectorAll('.qtab'); + expect(tabs[0].querySelector('.qtab-origin')?.textContent).toBe('Library'); + expect(tabs[1].querySelector('.qtab-origin')?.textContent).toBe('CO'); + expect(tabs[1].querySelector('.qtab-origin.dashboard svg')).not.toBeNull(); + expect(tabs[2].querySelector('.qtab-origin')?.textContent).toBe('Draft'); + expect(tabs[3].querySelector('.qtab-origin')).toBeNull(); + expect(app.dom.qtabsInner.getAttribute('role')).toBe('tablist'); + const dashboardSelect = qs(tabs[1], '.qtab-select'); + expect(dashboardSelect.getAttribute('role')).toBe('tab'); + expect(dashboardSelect.title).toBe('ClickHouse Operations / Overview'); + expect(dashboardSelect.getAttribute('aria-label')).toBe('ClickHouse Operations / Overview. Changed in another tab; resolve the conflict to save'); + expect(Array.from(tabs[1].children).map((child) => child.className)).toEqual([ + 'qtab-select', 'close', + ]); + expect(Array.from(dashboardSelect.children).map((child) => child.className)).toEqual([ + 'name', 'qtab-origin dashboard', 'qtab-external conflict', + ]); + expect(Array.from(qs(tabs[2], '.qtab-select').children).map((child) => child.className)).toEqual([ + 'name', 'qtab-origin draft', 'qtab-external deleted', 'dirty', + ]); + expect(tabs[0].querySelector('.dirty')).not.toBeNull(); + expect(tabs[0].querySelector('.close')?.getAttribute('aria-label')).toBe('Close Overview'); + }); it('clicking a tab selects it; clicking close closes it', () => { const app = makeApp(); app.state.tabs.value = [{ id: 't1', name: 'A' } as QueryTab, { id: 't2', name: 'B' } as QueryTab]; renderTabs(app); - const second = app.dom.qtabsInner.querySelectorAll('.qtab')[1]; + const second = qs(app.dom.qtabsInner.querySelectorAll('.qtab')[1], '.qtab-select'); second.dispatchEvent(new Event('click')); expect(app.state.activeTabId.value).toBe('t2'); const close = qs(app.dom.qtabsInner.querySelectorAll('.qtab')[0], '.close'); close.dispatchEvent(new Event('click', { bubbles: true })); expect(app.state.tabs.value.map((t) => t.id)).toEqual(['t2']); }); + it('exposes a focusable native tab button', () => { + const app = makeApp(); + app.state.tabs.value = [ + { id: 't1', name: 'A', dirtySql: false, dirtySpec: false } as QueryTab, + { id: 't2', name: 'B', dirtySql: false, dirtySpec: false } as QueryTab, + ]; + renderTabs(app); + document.body.append(app.dom.qtabsInner); + const second = qs(app.dom.qtabsInner.querySelectorAll('.qtab')[1], '.qtab-select'); + second.focus(); + expect(document.activeElement).toBe(second); + expect(second.type).toBe('button'); + }); + it('uses roving tabindex and restores focus to the rendered tab after click or Space activation', () => { + const app = makeApp(); + app.state.tabs.value = [ + { id: 't1', name: 'A', dirtySql: false, dirtySpec: false } as QueryTab, + { id: 't2', name: 'B', dirtySql: false, dirtySpec: false } as QueryTab, + ]; + document.body.append(app.dom.qtabsInner); + const dispose = effect(() => { + app.state.tabs.value; + app.state.activeTabId.value; + renderTabs(app); // same synchronous render contract as workbench-shell + }); + try { + let first = qs(app.dom.qtabsInner, '[data-tab-id="t1"]'); + const second = qs(app.dom.qtabsInner, '[data-tab-id="t2"]'); + expect(first.tabIndex).toBe(0); + expect(second.tabIndex).toBe(-1); + + second.focus(); + second.click(); + const clicked = qs(app.dom.qtabsInner, '[data-tab-id="t2"]'); + expect(document.activeElement).toBe(clicked); + expect(clicked.tabIndex).toBe(0); + clicked.click(); // activating the already-active selector is a focused no-op + expect(document.activeElement).toBe(clicked); + + first = qs(app.dom.qtabsInner, '[data-tab-id="t1"]'); + first.focus(); + // Native buttons turn Space into click. happy-dom does not perform that + // browser default, so dispatch the native click explicitly after keydown. + first.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); + first.click(); + const spaced = qs(app.dom.qtabsInner, '[data-tab-id="t1"]'); + expect(document.activeElement).toBe(spaced); + expect(spaced.tabIndex).toBe(0); + } finally { + dispose(); + } + }); + it('wraps Arrow keys and supports Home and End while focusing each active replacement', () => { + const app = makeApp(); + app.state.tabs.value = [ + { id: 't1', name: 'A' } as QueryTab, + { id: 't2', name: 'B' } as QueryTab, + { id: 't3', name: 'C' } as QueryTab, + ]; + document.body.append(app.dom.qtabsInner); + renderTabs(app); + const key = (id: string, value: string): void => { + const event = new KeyboardEvent('keydown', { key: value, bubbles: true, cancelable: true }); + qs(app.dom.qtabsInner, `[data-tab-id="${id}"]`).dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + renderTabs(app); // mirrors the workbench shell's signal effect + }; + + key('t1', 'ArrowLeft'); + expect(app.state.activeTabId.value).toBe('t3'); + expect(document.activeElement).toBe(qs(app.dom.qtabsInner, '[data-tab-id="t3"]')); + key('t3', 'ArrowRight'); + expect(app.state.activeTabId.value).toBe('t1'); + key('t1', 'End'); + expect(app.state.activeTabId.value).toBe('t3'); + key('t3', 'Home'); + expect(app.state.activeTabId.value).toBe('t1'); + expect(qs(app.dom.qtabsInner, '[data-tab-id="t1"]').tabIndex).toBe(0); + }); + it('focuses the selected neighbour after closing the focused active tab', () => { + const app = makeApp(); + app.state.tabs.value = [ + { id: 't1', name: 'A' } as QueryTab, + { id: 't2', name: 'B' } as QueryTab, + { id: 't3', name: 'C' } as QueryTab, + ]; + app.state.activeTabId.value = 't2'; + document.body.append(app.dom.qtabsInner); + renderTabs(app); + qs(app.dom.qtabsInner, '[data-tab-id="t2"]').focus(); + closeTab(app, 't2', true); + renderTabs(app); // mirrors the workbench shell's signal effect + const neighbour = qs(app.dom.qtabsInner, '[data-tab-id="t1"]'); + expect(app.state.activeTabId.value).toBe('t1'); + expect(document.activeElement).toBe(neighbour); + }); // #447 deleted the Filter tab badge case: `filter` is no longer a // saved-query role, so no tab can carry one. });