mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
Merge pull request #519 from aakhter/pr/grouped-rail-tree
feat(tabs): full-row activation and tree semantics for the grouped rail
This commit is contained in:
@@ -254,7 +254,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
||||
|
||||
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend only READS it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. No frontend WRITES the layout yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
|
||||
**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend only READS it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. No frontend WRITES the layout yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts)
|
||||
|
||||
**Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
export const BROWSER_TEST_GLOBS = [
|
||||
'test/tab-rail-resize.browser.test.ts',
|
||||
'test/tab-activation.browser.test.ts',
|
||||
'test/session-sidebar-ux.browser.test.ts',
|
||||
'test/session-options-responsive.browser.test.ts',
|
||||
'test/inline-rename.test.ts',
|
||||
|
||||
@@ -303,6 +303,8 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
|
||||
- **Collapse is per-device** (`codeman:tab-groups-collapsed` in localStorage, ids of deleted groups garbage-collected on adoption, any storage failure means all-expanded). A collapsed group still SHOWS the active row, and `_updateActiveTabImmediate` falls through to a full render whenever the structure key changes, since a class toggle cannot reveal a hidden row.
|
||||
- **Lineage arcs to a collapse-hidden session anchor to its group header** (`lineage-line--proxied`); two endpoints proxied to one header draw nothing.
|
||||
- **Drag-reorder is off in the grouped rail** until grouped editing lands: a flat-order drop cannot express a group move, and the server re-ranks within the old group.
|
||||
- **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's.
|
||||
- **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk.
|
||||
|
||||
|
||||
⚠️ **`TabLayoutService` is the single mutation boundary** and every lifecycle caller (session created/removed, webview created/deleted, a legacy order PUT) describes ONE completed server action and gets AT MOST ONE versioned write; writing layout state from a route or a manager directly is what the service exists to prevent.
|
||||
|
||||
+227
-7
@@ -5215,6 +5215,7 @@ class CodemanApp {
|
||||
tab.classList.remove('active');
|
||||
}
|
||||
}
|
||||
this._syncTabTreeSelection(container);
|
||||
// #257: selection used to stop at the class toggle. On phones/tablets the
|
||||
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
|
||||
// Alt+N or a push notification could stay parked off-screen.
|
||||
@@ -5711,6 +5712,12 @@ class CodemanApp {
|
||||
const prevScrollTop = container.scrollTop;
|
||||
const prevActiveTabId = this._lastRenderedActiveTabId;
|
||||
const isFirstRender = !container.querySelector('.session-tab');
|
||||
// The rebuild below destroys the focused row. In the grouped tree, put focus
|
||||
// back on the same item (by identity) so a background render or a keyboard
|
||||
// collapse does not drop a keyboard user to <body>.
|
||||
const focusWasInside = container.contains(document.activeElement);
|
||||
const focusIdentity = this._tabFocusIdentity || (focusWasInside ? this._tabTreeIdentity(document.activeElement) : null);
|
||||
this._tabFocusIdentity = null;
|
||||
|
||||
// Build tabs HTML using array for better string concatenation performance.
|
||||
// Iterate in sessionOrder to respect the user's custom tab arrangement, on
|
||||
@@ -5851,6 +5858,8 @@ class CodemanApp {
|
||||
|
||||
container.innerHTML = parts.join('');
|
||||
container.classList.toggle('session-tabs--grouped', !!groupProjection);
|
||||
this._applyTabListRole(container, !!groupProjection);
|
||||
if (groupProjection) this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
|
||||
|
||||
// Put the strip back where the user left it, then reveal the active tab
|
||||
// only when it CHANGED (or on the first paint). Restoring unconditionally
|
||||
@@ -5901,6 +5910,12 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
this._tabKeydownHandler = (e) => {
|
||||
// The grouped rail is a tree with its own key model; everything else
|
||||
// (header strip, sidebar, flat rail) keeps the tab-strip walk below.
|
||||
if (container.getAttribute('role') === 'tree') {
|
||||
this._handleTabTreeKeydown(e, container);
|
||||
return;
|
||||
}
|
||||
// Up/Down are aliases of Left/Right, not replacements: the strip stays
|
||||
// arrow-key navigable exactly as before, the vertical sidebar just gains
|
||||
// the axis a user reaches for there.
|
||||
@@ -5918,19 +5933,14 @@ class CodemanApp {
|
||||
// sort is stable, so equal orders keep DOM order, which is the unsorted case.
|
||||
if (this.isTabRailSorted()) {
|
||||
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
|
||||
// Grouped rail: `order` only sorts WITHIN a group's own flex column, so
|
||||
// the walk sorts by group first (-1 for every row of the flat strip).
|
||||
const groups = [...container.querySelectorAll('.tab-layout-group')];
|
||||
const groupOf = (el) => groups.indexOf(el.closest('.tab-layout-group'));
|
||||
tabs.sort((a, b) => groupOf(a) - groupOf(b) || orderOf(a) - orderOf(b));
|
||||
tabs.sort((a, b) => orderOf(a) - orderOf(b));
|
||||
}
|
||||
const currentIndex = tabs.indexOf(document.activeElement);
|
||||
|
||||
// Enter or Space activates the tab
|
||||
if ((e.key === 'Enter' || e.key === ' ') && currentIndex >= 0) {
|
||||
e.preventDefault();
|
||||
const sessionId = tabs[currentIndex].dataset.id;
|
||||
this.selectSession(sessionId, { forceReload: true });
|
||||
this._activateTabRow(tabs[currentIndex]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5961,6 +5971,216 @@ class CodemanApp {
|
||||
};
|
||||
|
||||
container.addEventListener('keydown', this._tabKeydownHandler);
|
||||
|
||||
// Grouped tree: a row or header focused by pointer becomes the tab stop, so
|
||||
// Tab-ing away and back returns to it and there is still exactly one stop.
|
||||
if (!this._tabTreeFocusinHandler) {
|
||||
this._tabTreeFocusinHandler = (e) => {
|
||||
if (container.getAttribute('role') !== 'tree') return;
|
||||
const item = e.target?.closest?.('[role="treeitem"]');
|
||||
if (item && container.contains(item)) this._setTabTreeStop(container, item);
|
||||
};
|
||||
container.addEventListener('focusin', this._tabTreeFocusinHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/** Select a session row or open a web-tab row (Enter/Space, either layout). */
|
||||
_activateTabRow(row) {
|
||||
if (row?.dataset.webviewId) return this.openWebview(row.dataset.webviewId);
|
||||
if (row?.dataset.id) return this.selectSession(row.dataset.id, { forceReload: true });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Grouped rail: tree semantics and keyboard model
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Only the GROUPED vertical rail is a tree. #sessionTabs becomes role=tree;
|
||||
// named-group headers are level-1 treeitems that own their rows (level 2);
|
||||
// ungrouped rows and a collapsed group's kept selection are level 1. One item
|
||||
// carries tabindex=0 (roving), every control inside a row is removed from the
|
||||
// tab order, and focus is restored by identity across full re-renders. The
|
||||
// flat rail and the header strip keep role=tablist / role=tab untouched.
|
||||
|
||||
/** #sessionTabs is a tablist (index.html) except while it holds the grouped tree. */
|
||||
_applyTabListRole(container, grouped) {
|
||||
if (grouped) {
|
||||
container.setAttribute('role', 'tree');
|
||||
container.setAttribute('aria-label', 'Sessions');
|
||||
} else if (container.getAttribute('role') === 'tree') {
|
||||
container.setAttribute('role', 'tablist');
|
||||
container.setAttribute('aria-label', 'Session tabs');
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable identity of a tree item (or anything inside one) across re-renders. */
|
||||
_tabTreeIdentity(element) {
|
||||
const item = element?.closest?.('[data-tab-group-header], .session-tab');
|
||||
if (!item) return null;
|
||||
if (item.dataset.tabGroupHeader) return `group:${item.dataset.tabGroupHeader}`;
|
||||
if (item.dataset.webviewId) return `webview:${item.dataset.webviewId}`;
|
||||
if (item.dataset.id) return `session:${item.dataset.id}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visible tree items in the order the eye reads them: each section's header,
|
||||
* then its rows. A sorted rail paints rows with the flex `order` property
|
||||
* inside their own group column, so rows are ordered by COMPUTED order within
|
||||
* a section (stable, so the unsorted rail keeps DOM order).
|
||||
*/
|
||||
_tabTreeItems(container) {
|
||||
const sorted = this.isTabRailSorted();
|
||||
const orderOf = (el) => Number(getComputedStyle(el).order) || 0;
|
||||
const items = [];
|
||||
for (const section of container.querySelectorAll('.tab-layout-group')) {
|
||||
const header = section.querySelector(':scope > [role="treeitem"]');
|
||||
if (header) items.push(header);
|
||||
const rows = [...section.querySelectorAll('.session-tab[role="treeitem"]:not(.tab-filtered-out)')];
|
||||
if (sorted) rows.sort((a, b) => orderOf(a) - orderOf(b));
|
||||
items.push(...rows);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Move the single tab stop to `item` (every other tree item gets -1). */
|
||||
_setTabTreeStop(container, item) {
|
||||
for (const el of container.querySelectorAll('[role="treeitem"]')) el.tabIndex = el === item ? 0 : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn freshly rendered rows into tree items and place the roving tab stop.
|
||||
* Rows arrive as the flat strip's markup (role=tab, tabindex=0 on the row and
|
||||
* its controls); only their semantics change here, never their content.
|
||||
*/
|
||||
_applyTabTreeSemantics(container, { identity = null, refocus = false } = {}) {
|
||||
for (const row of container.querySelectorAll('.session-tab')) {
|
||||
row.setAttribute('role', 'treeitem');
|
||||
row.setAttribute('aria-selected', row.classList.contains('active') ? 'true' : 'false');
|
||||
row.setAttribute('aria-level', row.closest('[role="group"]') ? '2' : '1');
|
||||
// Controls stay clickable, but leave the tab order: the tree has ONE stop,
|
||||
// and a row's actions are reachable from it with Shift+F10 / ContextMenu.
|
||||
for (const control of row.querySelectorAll('[tabindex], button, a[href], input, select, textarea')) {
|
||||
control.tabIndex = -1;
|
||||
}
|
||||
}
|
||||
for (const header of container.querySelectorAll('[data-tab-group-header]')) header.setAttribute('aria-level', '1');
|
||||
// Position within each level: the level-1 run (headers, ungrouped rows, a
|
||||
// collapsed group's kept row) and each group's own rows.
|
||||
const items = this._tabTreeItems(container);
|
||||
const sets = new Map();
|
||||
for (const item of items) {
|
||||
const owner = item.getAttribute('aria-level') === '2' ? item.closest('[role="group"]') : container;
|
||||
if (!sets.has(owner)) sets.set(owner, []);
|
||||
sets.get(owner).push(item);
|
||||
}
|
||||
for (const members of sets.values()) {
|
||||
members.forEach((item, index) => {
|
||||
item.setAttribute('aria-setsize', String(members.length));
|
||||
item.setAttribute('aria-posinset', String(index + 1));
|
||||
});
|
||||
}
|
||||
|
||||
const byIdentity = (id) => (id ? items.find((item) => this._tabTreeIdentity(item) === id) : null);
|
||||
// A focused row that a collapse just hid hands focus to its group header.
|
||||
const hiddenIn = identity ? this._hiddenTabGroupByRef?.get(identity) : null;
|
||||
const target =
|
||||
byIdentity(identity) ||
|
||||
(hiddenIn ? byIdentity(`group:${hiddenIn}`) : null) ||
|
||||
items.find((item) => item.getAttribute('aria-selected') === 'true') ||
|
||||
items[0];
|
||||
if (!target) return;
|
||||
this._setTabTreeStop(container, target);
|
||||
if (refocus && document.activeElement !== target) target.focus();
|
||||
}
|
||||
|
||||
/** Keep aria-selected on the grouped tree in step with the .active class. */
|
||||
_syncTabTreeSelection(container) {
|
||||
if (container?.getAttribute('role') !== 'tree') return;
|
||||
for (const row of container.querySelectorAll('.session-tab')) {
|
||||
row.setAttribute('aria-selected', row.classList.contains('active') ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard model of the grouped tree (WAI-ARIA tree view): Up/Down walk the
|
||||
* visible items, Home/End jump, Right expands a header or enters it, Left
|
||||
* collapses a header or climbs from a row to its header, Enter/Space select a
|
||||
* row or toggle a header, Shift+F10 / ContextMenu open a row's actions.
|
||||
*/
|
||||
_handleTabTreeKeydown(e, container) {
|
||||
if (e.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
|
||||
const items = this._tabTreeItems(container);
|
||||
const current = e.target?.closest?.('[role="treeitem"]');
|
||||
const index = items.indexOf(current);
|
||||
if (index < 0) return;
|
||||
const groupId = current.dataset.tabGroupHeader || null;
|
||||
const expanded = current.getAttribute('aria-expanded') === 'true';
|
||||
const focusAt = (next) => {
|
||||
if (!next) return;
|
||||
this._setTabTreeStop(container, next);
|
||||
next.focus();
|
||||
};
|
||||
const toggle = (collapse) => {
|
||||
// The toggle re-renders the rail; keep focus on this header through it.
|
||||
this._tabFocusIdentity = `group:${groupId}`;
|
||||
this.toggleTabGroupCollapsed(groupId, collapse);
|
||||
};
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
case 'ArrowUp': {
|
||||
const step = e.key === 'ArrowDown' ? 1 : -1;
|
||||
focusAt(items[(index + step + items.length) % items.length]);
|
||||
break;
|
||||
}
|
||||
case 'Home':
|
||||
focusAt(items[0]);
|
||||
break;
|
||||
case 'End':
|
||||
focusAt(items[items.length - 1]);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (!groupId) return;
|
||||
if (!expanded) toggle(false);
|
||||
else {
|
||||
const child = items.find((item) => item.closest('[role="group"]')?.id === current.getAttribute('aria-owns'));
|
||||
if (!child) return;
|
||||
focusAt(child);
|
||||
}
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
if (groupId) {
|
||||
if (!expanded) return;
|
||||
toggle(true);
|
||||
} else {
|
||||
const group = current.closest('[role="group"]');
|
||||
const header = group ? container.querySelector(`[aria-owns="${CSS.escape(group.id)}"]`) : null;
|
||||
if (!header) return;
|
||||
focusAt(header);
|
||||
}
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
if (groupId) toggle();
|
||||
else this._activateTabRow(current);
|
||||
break;
|
||||
case 'F10':
|
||||
case 'ContextMenu':
|
||||
if (e.key === 'F10' && !e.shiftKey) return;
|
||||
if (current.dataset.id) {
|
||||
this.openTabRailActionMenu?.(
|
||||
{ preventDefault() {}, stopPropagation() {}, currentTarget: current },
|
||||
current.dataset.id
|
||||
);
|
||||
} else if (current.dataset.webviewId) {
|
||||
this.showWebviewModal?.(current.dataset.webviewId);
|
||||
} else return;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
handleSessionTabClick(event, sessionId) {
|
||||
|
||||
@@ -671,6 +671,7 @@ html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header {
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle:hover {
|
||||
@@ -1779,7 +1780,9 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
/* A control, like its gear/detach siblings: the pointer must not read as
|
||||
"nothing here" over the one button that closes the session. */
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
/* Performance: use transform for GPU acceleration */
|
||||
transition: opacity 0.05s ease-out, width 0.05s ease-out, padding 0.05s ease-out;
|
||||
|
||||
@@ -179,8 +179,15 @@
|
||||
* `escapeHtml` is the caller's escaper. Group names are user content, so they
|
||||
* are escaped and marked `data-i18n-skip`.
|
||||
*
|
||||
* The group header is a real <button> carrying `aria-expanded`; its accessible
|
||||
* name is the group name plus count, so no per-state label string is needed.
|
||||
* The caller makes the list itself the `tree` and marks rows up as treeitems
|
||||
* (app.js `_applyTabTreeSemantics`); this markup supplies the structure:
|
||||
* - a named group's header is a level-1 `treeitem` carrying `aria-expanded`.
|
||||
* Its rows are a sibling `group`, so the header OWNS it via `aria-owns`
|
||||
* (the rows sit below the header visually, not inside it).
|
||||
* - a COLLAPSED group owns nothing: the one row it still shows (the
|
||||
* selection) is a level-1 sibling, never the child of a closed node.
|
||||
* - Ungrouped rows are level-1 items. Their "Ungrouped" heading is a visual
|
||||
* divider only, hidden from assistive tech, and its rows are not a group.
|
||||
*/
|
||||
function renderProjection(projection, renderRef, escapeHtml) {
|
||||
const sections = projection && Array.isArray(projection.sections) ? projection.sections : [];
|
||||
@@ -190,19 +197,21 @@
|
||||
if (section.id === null) {
|
||||
return (
|
||||
'<section class="tab-layout-group tab-layout-ungrouped" role="presentation" data-tab-group-id="">' +
|
||||
`<div class="tab-layout-group-header tab-layout-ungrouped-header"><span class="tab-layout-group-name">Ungrouped</span><span class="tab-layout-group-count">${section.count}</span></div>` +
|
||||
`<div class="tab-layout-group-header tab-layout-ungrouped-header" aria-hidden="true"><span class="tab-layout-group-name">Ungrouped</span><span class="tab-layout-group-count">${section.count}</span></div>` +
|
||||
`<div class="tab-layout-group-refs" role="presentation">${rows}</div></section>`
|
||||
);
|
||||
}
|
||||
const id = escapeHtml(section.id);
|
||||
const refsId = `tab-layout-group-refs-${index}`;
|
||||
const nameId = `tab-layout-group-name-${index}`;
|
||||
const expanded = !section.collapsed;
|
||||
return (
|
||||
`<section class="tab-layout-group${section.collapsed ? ' tab-layout-group--collapsed' : ''}" role="presentation" data-tab-group-id="${id}">` +
|
||||
`<button type="button" class="tab-layout-group-header tab-layout-group-toggle" data-tab-group-header="${id}" aria-expanded="${section.collapsed ? 'false' : 'true'}" aria-controls="${refsId}" onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)">` +
|
||||
`<div class="tab-layout-group-header tab-layout-group-toggle" role="treeitem" tabindex="-1" data-tab-group-header="${id}" aria-expanded="${expanded ? 'true' : 'false'}"${expanded ? ` aria-owns="${refsId}"` : ''} onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)">` +
|
||||
'<span class="tab-layout-group-chevron" aria-hidden="true"></span>' +
|
||||
`<span class="tab-layout-group-name" data-i18n-skip>${escapeHtml(section.name)}</span>` +
|
||||
`<span class="tab-layout-group-count">${section.count}</span></button>` +
|
||||
`<div class="tab-layout-group-refs" id="${refsId}" role="presentation">${rows}</div></section>`
|
||||
`<span class="tab-layout-group-name" id="${nameId}" data-i18n-skip>${escapeHtml(section.name)}</span>` +
|
||||
`<span class="tab-layout-group-count">${section.count}</span></div>` +
|
||||
`<div class="tab-layout-group-refs" id="${refsId}" ${expanded ? `role="group" aria-labelledby="${nameId}"` : 'role="presentation"'}>${rows}</div></section>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
|
||||
@@ -358,6 +358,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// A web tab is active, so no session tab may also look active.
|
||||
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
|
||||
}
|
||||
this._syncTabTreeSelection?.(container);
|
||||
},
|
||||
|
||||
// ── Opening / closing ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* @fileoverview Real-Chromium coverage for tab-row activation and the grouped rail tree.
|
||||
*
|
||||
* A tab row is one activation target: its status dot, mode chip, name and unused
|
||||
* padding all select it, while its own controls (gear, detach, close, the rail's
|
||||
* overflow button, a web tab's gear and close) run only their own action. The
|
||||
* controls must also keep a stable hit target: revealing one on hover may not
|
||||
* slide its neighbours out from under a pointer already aiming at them.
|
||||
*
|
||||
* DOM emulation cannot answer either question (hit testing, hover reveal and
|
||||
* layout are Chromium's), which is why this runs in a real browser. The shipping
|
||||
* app.js, webview-tabs.js and styles.css are loaded into a page; the CodemanApp
|
||||
* instance gets stub actions that only record what ran.
|
||||
*
|
||||
* Port: none (page.setContent, no server).
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
|
||||
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
|
||||
|
||||
describe('tab row activation in Chromium', () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 });
|
||||
// A real origin, so localStorage (per-device collapse) works as it does in the app.
|
||||
await page.route('http://codeman.test/', (route) =>
|
||||
route.fulfill({ contentType: 'text/html', body: '<!doctype html><html><body></body></html>' })
|
||||
);
|
||||
await page.goto('http://codeman.test/');
|
||||
await page.setContent(`<!doctype html>
|
||||
<html data-tab-orientation="horizontal" data-tab-rail-sort="manual">
|
||||
<head><style>${read('styles.css')}</style></head>
|
||||
<body>
|
||||
<header class="header"><div id="sessionTabsHost"></div></header>
|
||||
<main class="main" style="width:100%;height:700px">
|
||||
<aside class="tab-rail" id="tabRail" aria-label="Session navigation"></aside>
|
||||
</main>
|
||||
</body>
|
||||
</html>`);
|
||||
await page.addScriptTag({
|
||||
content:
|
||||
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
|
||||
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
|
||||
read('constants.js') +
|
||||
'\n' +
|
||||
read('tab-layout-browser.js') +
|
||||
'\n' +
|
||||
read('app.js') +
|
||||
'\nwindow.CodemanApp = CodemanApp; window.__setApp = (value) => { app = value; };',
|
||||
});
|
||||
await page.addScriptTag({ content: read('webview-tabs.js') });
|
||||
await page.evaluate(() => {
|
||||
const w = window as any;
|
||||
const app = Object.create(w.CodemanApp.prototype);
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.sessions = new Map([
|
||||
['one', { id: 'one', name: 'One', status: 'idle', mode: 'shell' }],
|
||||
['two', { id: 'two', name: 'Two', status: 'idle' }],
|
||||
]);
|
||||
app.sessionOrder = ['one', 'two'];
|
||||
app.webviews = new Map([['web', { id: 'web', name: 'Web', url: 'https://example.test', icon: 'W' }]]);
|
||||
app.webviewOrder = ['web'];
|
||||
app.activeSessionId = 'one';
|
||||
app.activeWebviewId = null;
|
||||
app.tabLayout = null;
|
||||
app.collapsedTabGroupIds = new Set();
|
||||
app._hiddenTabGroupByRef = new Map();
|
||||
app._lastTabGroupStructureKey = null;
|
||||
app._inlineRenameActive = false;
|
||||
app.tabAlerts = new Map();
|
||||
app.terminalLoadStates = new Map();
|
||||
app.minimizedSubagents = new Map();
|
||||
app.hasTabDetachOverride = () => true;
|
||||
app.renderSubagentTabBadge = () => '';
|
||||
app.cancelHideSubagentDropdown = () => undefined;
|
||||
app.updateTabOverflowMode = () => undefined;
|
||||
app.updateConnectionLines = () => undefined;
|
||||
app._applyTabEntrances = () => undefined;
|
||||
app._scrollActiveTabIntoView = () => undefined;
|
||||
app.applySidebarFilter = () => undefined;
|
||||
app.isSessionSidebarActive = () => false;
|
||||
app._startSidebarRichClock = () => undefined;
|
||||
app._stopSidebarRichClock = () => undefined;
|
||||
const record = (key: string, value: string) => () => {
|
||||
w[key] = value;
|
||||
};
|
||||
app.selectSession = (id: string) => {
|
||||
w.__activation = `session:${id}`;
|
||||
};
|
||||
app.openWebview = (id: string) => {
|
||||
w.__activation = `webview:${id}`;
|
||||
};
|
||||
app.openSessionOptions = record('__action', 'settings');
|
||||
app.detachSession = record('__action', 'detach');
|
||||
app.requestCloseSession = record('__action', 'close');
|
||||
app.openTabRailActionMenu = record('__action', 'overflow');
|
||||
app.showWebviewModal = record('__action', 'web-settings');
|
||||
app.closeWebviewTab = record('__action', 'web-close');
|
||||
w.__setApp(app);
|
||||
w.__app = app;
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => browser.close());
|
||||
|
||||
/** Render the strip/rail in a given shape, from scratch. */
|
||||
async function render(orientation: 'horizontal' | 'vertical', grouped = false) {
|
||||
await page.evaluate(
|
||||
({ orientation, grouped }) => {
|
||||
const w = window as any;
|
||||
document.documentElement.setAttribute('data-tab-orientation', orientation);
|
||||
const host =
|
||||
orientation === 'vertical'
|
||||
? document.getElementById('tabRail')!
|
||||
: document.getElementById('sessionTabsHost')!;
|
||||
document.getElementById('sessionTabs')?.remove();
|
||||
host.insertAdjacentHTML(
|
||||
'afterbegin',
|
||||
'<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs"></div>'
|
||||
);
|
||||
w.__app._tabKeydownHandler = null;
|
||||
w.__app.activeSessionId = 'one';
|
||||
w.__app.activeWebviewId = null;
|
||||
w.__app.tabLayout = grouped
|
||||
? {
|
||||
version: 1,
|
||||
groups: [
|
||||
{
|
||||
id: 'g',
|
||||
name: 'Group',
|
||||
refs: [
|
||||
{ kind: 'session', id: 'one' },
|
||||
{ kind: 'webview', id: 'web' },
|
||||
],
|
||||
},
|
||||
],
|
||||
ungrouped: [{ kind: 'session', id: 'two' }],
|
||||
}
|
||||
: null;
|
||||
w.__app._fullRenderSessionTabs();
|
||||
w.__activation = null;
|
||||
w.__action = null;
|
||||
},
|
||||
{ orientation, grouped }
|
||||
);
|
||||
await page.mouse.move(1, 1);
|
||||
}
|
||||
|
||||
const result = () =>
|
||||
page.evaluate(() => ({ activation: (window as any).__activation, action: (window as any).__action }));
|
||||
const reset = () => page.evaluate(() => ((window as any).__activation = (window as any).__action = null));
|
||||
|
||||
for (const [label, orientation, grouped] of [
|
||||
['horizontal strip', 'horizontal', false],
|
||||
['flat vertical rail', 'vertical', false],
|
||||
['grouped vertical rail', 'vertical', true],
|
||||
] as const) {
|
||||
it(`activates a row from its status, mode, name and padding (${label})`, async () => {
|
||||
await render(orientation, grouped);
|
||||
for (const [selector, expected] of [
|
||||
['[data-id="two"] .tab-status', 'session:two'],
|
||||
['[data-id="two"] .tab-name', 'session:two'],
|
||||
['[data-id="one"] .tab-mode', 'session:one'],
|
||||
['[data-webview-id="web"] .tab-name', 'webview:web'],
|
||||
] as const) {
|
||||
await reset();
|
||||
await page.locator(selector).click();
|
||||
expect((await result()).activation, selector).toBe(expected);
|
||||
}
|
||||
for (const [selector, expected] of [
|
||||
['[data-id="two"]', 'session:two'],
|
||||
['[data-webview-id="web"]', 'webview:web'],
|
||||
] as const) {
|
||||
await reset();
|
||||
const box = (await page.locator(selector).boundingBox())!;
|
||||
await page.mouse.click(box.x + 2, box.y + box.height / 2);
|
||||
expect((await result()).activation, `${selector} leading padding`).toBe(expected);
|
||||
// The cursor advertises the whole row: a pointer, or the grab hand where
|
||||
// the row is also a drag-reorder handle (the flat lists).
|
||||
const { cursor, draggable } = await page
|
||||
.locator(selector)
|
||||
.evaluate((el) => ({ cursor: getComputedStyle(el).cursor, draggable: el.getAttribute('draggable') }));
|
||||
expect(cursor, selector).toBe(draggable === 'true' ? 'grab' : 'pointer');
|
||||
}
|
||||
});
|
||||
|
||||
it(`runs a control's own action without selecting the row (${label})`, async () => {
|
||||
await render(orientation, grouped);
|
||||
for (const [selector, expected] of [
|
||||
['[data-id="one"] .tab-gear', 'settings'],
|
||||
['[data-id="one"] .tab-detach', 'detach'],
|
||||
['[data-id="one"] .tab-close', 'close'],
|
||||
] as const) {
|
||||
await reset();
|
||||
const control = page.locator(selector);
|
||||
expect(await control.isVisible(), selector).toBe(true);
|
||||
expect(await control.evaluate((el) => getComputedStyle(el).cursor), selector).toBe('pointer');
|
||||
await control.click();
|
||||
expect(await result(), selector).toEqual({ activation: null, action: expected });
|
||||
}
|
||||
if (orientation === 'vertical') {
|
||||
await reset();
|
||||
await page.locator('[data-id="one"] .tab-more').click();
|
||||
expect(await result()).toEqual({ activation: null, action: 'overflow' });
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
(window as any).__app.activeWebviewId = 'web';
|
||||
(window as any).__app._fullRenderSessionTabs();
|
||||
});
|
||||
for (const [selector, expected] of [
|
||||
['[data-webview-id="web"] .tab-gear', 'web-settings'],
|
||||
['[data-webview-id="web"] .tab-close', 'web-close'],
|
||||
] as const) {
|
||||
await reset();
|
||||
await page.locator(selector).click();
|
||||
expect(await result(), selector).toEqual({ activation: null, action: expected });
|
||||
}
|
||||
});
|
||||
|
||||
it(`keeps action controls anchored under the pointer when a row is hovered (${label})`, async () => {
|
||||
// Revealing a control on hover must not reflow its row: a button that
|
||||
// toggled `display` on hover once slid the gear 28px left, so the click
|
||||
// aimed at the gear landed on the close button instead. Assert the
|
||||
// geometry, not a CSS property, so any reflow-on-hover rewrite fails here.
|
||||
await render(orientation, grouped);
|
||||
for (const selector of ['[data-id="one"] .tab-gear', '[data-id="one"] .tab-close']) {
|
||||
await page.mouse.move(1, 1);
|
||||
const control = page.locator(selector);
|
||||
const before = (await control.boundingBox())!;
|
||||
const aim = { x: Math.round(before.x + before.width / 2), y: Math.round(before.y + before.height / 2) };
|
||||
await page.mouse.move(aim.x, aim.y);
|
||||
await page.waitForTimeout(250);
|
||||
const after = (await control.boundingBox())!;
|
||||
expect(Math.abs(after.x - before.x), selector).toBeLessThanOrEqual(4);
|
||||
const stillOnTarget = await page.evaluate(
|
||||
({ point, selector }) => !!document.elementFromPoint(point.x, point.y)?.closest(selector),
|
||||
{ point: aim, selector }
|
||||
);
|
||||
expect(stillOnTarget, selector).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('toggles a group from its header without selecting anything', async () => {
|
||||
await render('vertical', true);
|
||||
const header = page.locator('[data-tab-group-header="g"]');
|
||||
await header.click();
|
||||
expect(await result()).toEqual({ activation: null, action: null });
|
||||
expect(await header.getAttribute('aria-expanded')).toBe('false');
|
||||
await page.locator('[data-tab-group-header="g"]').click();
|
||||
expect(await page.locator('[data-tab-group-header="g"]').getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('walks the grouped rail tree from the keyboard with one tab stop', async () => {
|
||||
await render('vertical', true);
|
||||
const tree = page.locator('#sessionTabs[role="tree"]');
|
||||
expect(await tree.count()).toBe(1);
|
||||
expect(await page.locator('#sessionTabs [tabindex="0"]').count()).toBe(1);
|
||||
// The roving tab stop starts on the selected row.
|
||||
await page.keyboard.press('Tab');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-id'))).toBe('one');
|
||||
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-tab-group-header'))).toBe('g');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
expect(await page.locator('[data-tab-group-header="g"]').getAttribute('aria-expanded')).toBe('false');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-tab-group-header'))).toBe('g');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
expect(await page.locator('[data-tab-group-header="g"]').getAttribute('aria-expanded')).toBe('true');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-id'))).toBe('one');
|
||||
await page.keyboard.press('End');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-id'))).toBe('two');
|
||||
await page.keyboard.press('Home');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-tab-group-header'))).toBe('g');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
expect(await page.evaluate(() => document.activeElement?.getAttribute('data-webview-id'))).toBe('web');
|
||||
await reset();
|
||||
await page.keyboard.press('Enter');
|
||||
expect((await result()).activation).toBe('webview:web');
|
||||
expect(await page.locator('#sessionTabs [tabindex="0"]').count()).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps tab semantics and no tree roles without groups', async () => {
|
||||
await render('vertical', false);
|
||||
expect(await page.locator('#sessionTabs').getAttribute('role')).toBe('tablist');
|
||||
expect(
|
||||
await page
|
||||
.locator('#sessionTabs [role="tree"], #sessionTabs [role="treeitem"], #sessionTabs [role="group"]')
|
||||
.count()
|
||||
).toBe(0);
|
||||
expect(await page.locator('#sessionTabs .session-tab[role="tab"]').count()).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -169,7 +169,7 @@ describe('per-device collapse storage', () => {
|
||||
});
|
||||
|
||||
describe('grouped markup', () => {
|
||||
it('renders escaped group sections with real toggle buttons around caller-rendered rows', () => {
|
||||
it('renders escaped group sections as a tree skeleton around caller-rendered rows', () => {
|
||||
const h = loadHelper();
|
||||
const projection = h.project(layout(), {
|
||||
liveSessionIds: ['s1', 's2', 's3', 's4'],
|
||||
@@ -189,15 +189,35 @@ describe('grouped markup', () => {
|
||||
]);
|
||||
expect(doc.querySelectorAll('.tab-layout-group-name *')).toHaveLength(0);
|
||||
expect(doc.querySelector('.tab-layout-group-name')?.hasAttribute('data-i18n-skip')).toBe(true);
|
||||
// Sections are layout only; the caller's container is the tree.
|
||||
expect([...doc.querySelectorAll('section')].every((el) => el.getAttribute('role') === 'presentation')).toBe(true);
|
||||
|
||||
// A named header is a level-1 treeitem that toggles and OWNS its rows' group.
|
||||
const g1 = doc.querySelector<HTMLElement>('[data-tab-group-header="g1"]')!;
|
||||
const g2 = doc.querySelector<HTMLElement>('[data-tab-group-header="g2"]')!;
|
||||
expect(g1.tagName).toBe('BUTTON');
|
||||
expect(g1.getAttribute('role')).toBe('treeitem');
|
||||
expect(g1.getAttribute('tabindex')).toBe('-1');
|
||||
expect(g1.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(g1.getAttribute('onclick')).toBe('app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)');
|
||||
const owned = doc.getElementById(g1.getAttribute('aria-owns')!)!;
|
||||
expect(owned.getAttribute('role')).toBe('group');
|
||||
expect(doc.getElementById(owned.getAttribute('aria-labelledby')!)?.textContent).toBe('<Core & Ops>');
|
||||
expect(owned.querySelectorAll('.row')).toHaveLength(3);
|
||||
// No interactive element nested inside a treeitem.
|
||||
expect(g1.querySelectorAll('button, [tabindex]')).toHaveLength(0);
|
||||
|
||||
// A collapsed header owns nothing, so its kept row cannot read as the child of a closed node.
|
||||
const g2 = doc.querySelector<HTMLElement>('[data-tab-group-header="g2"]')!;
|
||||
expect(g2.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(doc.getElementById(g1.getAttribute('aria-controls')!)?.querySelectorAll('.row')).toHaveLength(3);
|
||||
// The ungrouped header is a label, not a control: there is nothing to collapse.
|
||||
expect(doc.querySelector('.tab-layout-ungrouped-header')?.tagName).toBe('DIV');
|
||||
expect(g2.hasAttribute('aria-owns')).toBe(false);
|
||||
expect(g2.closest('section')!.querySelector('.tab-layout-group-refs')!.getAttribute('role')).toBe('presentation');
|
||||
|
||||
// The ungrouped heading is a visual divider: nothing to collapse, nothing to announce.
|
||||
const ungrouped = doc.querySelector('.tab-layout-ungrouped-header')!;
|
||||
expect(ungrouped.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(ungrouped.hasAttribute('role')).toBe(false);
|
||||
expect(ungrouped.closest('section')!.querySelector('.tab-layout-group-refs')!.getAttribute('role')).toBe(
|
||||
'presentation'
|
||||
);
|
||||
expect([...doc.querySelectorAll<HTMLElement>('.row')].map((el) => el.dataset.ref)).toEqual(
|
||||
ids(projection.visibleRefs)
|
||||
);
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
* owner's groups only when it is vertical AND there is at least one group (every
|
||||
* other case must be byte-for-byte the flat rail), collapse is per-device and
|
||||
* keeps the active row, a structural change escapes the incremental patch path,
|
||||
* drag-reorder is withheld, and lineage arcs to a collapse-hidden session anchor
|
||||
* to its group header.
|
||||
* drag-reorder is withheld, lineage arcs to a collapse-hidden session anchor
|
||||
* to its group header, and the grouped rail (only) is an ARIA tree with one
|
||||
* roving tab stop, a tree keyboard model and focus restored across rebuilds.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), so
|
||||
* `document`, `localStorage` and `window` below are that window's, not Node's.
|
||||
@@ -375,3 +376,228 @@ describe('lineage in the grouped rail', () => {
|
||||
expect(app._lineageEdgeCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('grouped rail tree semantics', () => {
|
||||
const tabs = () => document.getElementById('sessionTabs')!;
|
||||
const press = (key: string, init: Record<string, unknown> = {}) =>
|
||||
(document.activeElement as HTMLElement).dispatchEvent(
|
||||
new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init })
|
||||
);
|
||||
const focused = () => {
|
||||
const el = document.activeElement as HTMLElement;
|
||||
return el.dataset.tabGroupHeader ? `group:${el.dataset.tabGroupHeader}` : el.dataset.webviewId || el.dataset.id;
|
||||
};
|
||||
const row = (id: string) => document.querySelector<HTMLElement>(`[data-id="${id}"], [data-webview-id="${id}"]`)!;
|
||||
|
||||
function makeTreeApp(options: { tabLayout?: unknown } = {}) {
|
||||
const app = makeApp(options);
|
||||
// The container as index.html ships it.
|
||||
tabs().setAttribute('role', 'tablist');
|
||||
tabs().setAttribute('aria-label', 'Session tabs');
|
||||
app.selectSession = vi.fn();
|
||||
app.openWebview = vi.fn();
|
||||
app.openTabRailActionMenu = vi.fn();
|
||||
app.showWebviewModal = vi.fn();
|
||||
return app;
|
||||
}
|
||||
|
||||
it('is a tree only while grouped, and the flat list returns byte-identical as a tablist', () => {
|
||||
const app = makeTreeApp({ tabLayout: null });
|
||||
app._fullRenderSessionTabs();
|
||||
const flat = tabs().innerHTML;
|
||||
expect(tabs().querySelectorAll('[role="tree"], [role="treeitem"], [role="group"]')).toHaveLength(0);
|
||||
expect(tabs().querySelectorAll('.session-tab[role="tab"]')).toHaveLength(4);
|
||||
|
||||
app._applyTabLayout(layout);
|
||||
expect(tabs().getAttribute('role')).toBe('tree');
|
||||
expect(tabs().getAttribute('aria-label')).toBe('Sessions');
|
||||
expect(tabs().querySelectorAll('[role="tab"]')).toHaveLength(0);
|
||||
expect(tabs().querySelectorAll('.session-tab[role="treeitem"]')).toHaveLength(4);
|
||||
|
||||
app._applyTabLayout(null);
|
||||
expect(tabs().getAttribute('role')).toBe('tablist');
|
||||
expect(tabs().getAttribute('aria-label')).toBe('Session tabs');
|
||||
expect(tabs().innerHTML).toBe(flat);
|
||||
|
||||
// The horizontal strip never becomes a tree, groups or not.
|
||||
document.documentElement.setAttribute('data-tab-orientation', 'horizontal');
|
||||
app._applyTabLayout(layout);
|
||||
expect(tabs().getAttribute('role')).toBe('tablist');
|
||||
expect(tabs().querySelectorAll('[role="treeitem"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('nests group rows under their owning header with levels and positions', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const header = document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!;
|
||||
const group = document.getElementById(header.getAttribute('aria-owns')!)!;
|
||||
expect(group.getAttribute('role')).toBe('group');
|
||||
expect(
|
||||
[...group.querySelectorAll<HTMLElement>('[role="treeitem"]')].map((el) => el.dataset.webviewId || el.dataset.id)
|
||||
).toEqual(['s2', 'w1', 's1']);
|
||||
const aria = (el: HTMLElement) => ['aria-level', 'aria-posinset', 'aria-setsize'].map((a) => el.getAttribute(a));
|
||||
// Level 1: the group header and the ungrouped row.
|
||||
expect(aria(header)).toEqual(['1', '1', '2']);
|
||||
expect(aria(row('s3'))).toEqual(['1', '2', '2']);
|
||||
expect(aria(row('s2'))).toEqual(['2', '1', '3']);
|
||||
expect(aria(row('w1'))).toEqual(['2', '2', '3']);
|
||||
expect(aria(row('s1'))).toEqual(['2', '3', '3']);
|
||||
// aria-selected follows the active row, exactly once.
|
||||
expect([...tabs().querySelectorAll('[aria-selected="true"]')].map((el) => (el as HTMLElement).dataset.id)).toEqual([
|
||||
's2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('has exactly one tab stop, on the selected row, and no tabbable control inside rows', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const stops = [...tabs().querySelectorAll<HTMLElement>('[tabindex="0"]')];
|
||||
expect(stops).toEqual([row('s2')]);
|
||||
const controls = [...tabs().querySelectorAll<HTMLElement>('.session-tab button, .session-tab [tabindex]')];
|
||||
expect(controls.length).toBeGreaterThan(0);
|
||||
expect(controls.every((el) => el.tabIndex === -1)).toBe(true);
|
||||
|
||||
// Collapse keeps a single stop (the selection stays visible as a level-1 item).
|
||||
app.toggleTabGroupCollapsed('group-x', true);
|
||||
expect(tabs().querySelectorAll('[tabindex="0"]')).toHaveLength(1);
|
||||
expect(row('s2').getAttribute('aria-level')).toBe('1');
|
||||
expect(document.querySelector('[data-tab-group-header="group-x"]')!.hasAttribute('aria-owns')).toBe(false);
|
||||
});
|
||||
|
||||
it('walks Up/Down/Home/End, collapses and enters groups with Left/Right, and activates with Enter/Space', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
row('s2').focus();
|
||||
press('ArrowUp');
|
||||
expect(focused()).toBe('group:group-x');
|
||||
press('ArrowUp');
|
||||
expect(focused()).toBe('s3');
|
||||
press('Home');
|
||||
expect(focused()).toBe('group:group-x');
|
||||
press('End');
|
||||
expect(focused()).toBe('s3');
|
||||
// An ungrouped row has no parent to climb to.
|
||||
expect(press('ArrowLeft')).toBe(true);
|
||||
expect(focused()).toBe('s3');
|
||||
|
||||
row('w1').focus();
|
||||
press('Enter');
|
||||
expect(app.openWebview).toHaveBeenCalledWith('w1');
|
||||
row('s1').focus();
|
||||
press(' ');
|
||||
expect(app.selectSession).toHaveBeenCalledWith('s1', { forceReload: true });
|
||||
expect(app.selectSession).toHaveBeenCalledTimes(1);
|
||||
|
||||
press('ArrowLeft');
|
||||
expect(focused()).toBe('group:group-x');
|
||||
press('ArrowLeft');
|
||||
expect(app.collapsedTabGroupIds.has('group-x')).toBe(true);
|
||||
// The header was re-rendered; focus and the tab stop moved to the new node.
|
||||
expect(focused()).toBe('group:group-x');
|
||||
expect(document.querySelector('[data-tab-group-header="group-x"]')!.getAttribute('tabindex')).toBe('0');
|
||||
press('ArrowRight');
|
||||
expect(app.collapsedTabGroupIds.has('group-x')).toBe(false);
|
||||
expect(focused()).toBe('group:group-x');
|
||||
press('ArrowRight');
|
||||
expect(focused()).toBe('s2');
|
||||
press('Home');
|
||||
press('Enter');
|
||||
expect(app.collapsedTabGroupIds.has('group-x')).toBe(true);
|
||||
expect(tabs().querySelectorAll('[tabindex="0"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('opens row actions from the keyboard, since its controls left the tab order', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
row('s2').focus();
|
||||
press('F10', { shiftKey: true });
|
||||
expect(app.openTabRailActionMenu).toHaveBeenCalledWith(expect.objectContaining({ currentTarget: row('s2') }), 's2');
|
||||
row('w1').focus();
|
||||
press('ContextMenu');
|
||||
expect(app.showWebviewModal).toHaveBeenCalledWith('w1');
|
||||
press('F10');
|
||||
expect(app.showWebviewModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('restores focus by identity across a background rebuild and follows pointer focus', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
row('w1').focus();
|
||||
app._fullRenderSessionTabs();
|
||||
expect(focused()).toBe('w1');
|
||||
expect([...tabs().querySelectorAll('[tabindex="0"]')]).toEqual([row('w1')]);
|
||||
|
||||
// Focus arriving by pointer (or any other route) takes the tab stop with it.
|
||||
row('s3').focus();
|
||||
expect([...tabs().querySelectorAll('[tabindex="0"]')]).toEqual([row('s3')]);
|
||||
|
||||
// A rebuild never pulls focus into the rail when it was elsewhere.
|
||||
(document.activeElement as HTMLElement).blur();
|
||||
app._fullRenderSessionTabs();
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
});
|
||||
|
||||
it('keeps aria-selected in step when the selection changes without a rebuild', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
const full = vi.spyOn(app, '_fullRenderSessionTabs');
|
||||
app.activeSessionId = 's1';
|
||||
app._updateActiveTabImmediate('s1');
|
||||
expect(full).not.toHaveBeenCalled();
|
||||
expect([...tabs().querySelectorAll('[aria-selected="true"]')].map((el) => (el as HTMLElement).dataset.id)).toEqual([
|
||||
's1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('walks a sorted rail in painted order: per group in the tree, across the list when flat', () => {
|
||||
const app = makeTreeApp();
|
||||
app._fullRenderSessionTabs();
|
||||
document.documentElement.dataset.tabRailSort = 'activity';
|
||||
row('s1').style.order = '0';
|
||||
row('s2').style.order = '1';
|
||||
row('w1').style.order = '9999';
|
||||
row('s3').style.order = '2';
|
||||
document.querySelector<HTMLElement>('[data-tab-group-header="group-x"]')!.focus();
|
||||
const walk = () =>
|
||||
Array.from({ length: 4 }, () => {
|
||||
press('ArrowDown');
|
||||
return focused();
|
||||
});
|
||||
expect(walk()).toEqual(['s1', 's2', 'w1', 's3']);
|
||||
|
||||
const flat = makeTreeApp({ tabLayout: null });
|
||||
flat._fullRenderSessionTabs();
|
||||
document.documentElement.dataset.tabRailSort = 'activity';
|
||||
row('s1').style.order = '2';
|
||||
row('s2').style.order = '0';
|
||||
row('s3').style.order = '1';
|
||||
row('w1').style.order = '9999'; // styles.css pins web tabs last; JSDOM loads no stylesheet
|
||||
row('s2').focus();
|
||||
expect(
|
||||
Array.from({ length: 3 }, () => {
|
||||
press('ArrowDown');
|
||||
return focused();
|
||||
})
|
||||
).toEqual(['s3', 's1', 'w1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flat list keyboard activation', () => {
|
||||
it('opens a web tab with Enter/Space instead of selecting an undefined session', () => {
|
||||
const app = makeApp({ tabLayout: null });
|
||||
app.selectSession = vi.fn();
|
||||
app.openWebview = vi.fn();
|
||||
app._fullRenderSessionTabs();
|
||||
const web = document.querySelector<HTMLElement>('[data-webview-id="w1"]')!;
|
||||
web.focus();
|
||||
web.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||
web.dispatchEvent(new window.KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }));
|
||||
expect(app.openWebview).toHaveBeenCalledTimes(2);
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
|
||||
const s2 = document.querySelector<HTMLElement>('[data-id="s2"]')!;
|
||||
s2.focus();
|
||||
s2.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||
expect(app.selectSession).toHaveBeenCalledWith('s2', { forceReload: true });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user