mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
feat(tabs): full-row activation and tree semantics for the grouped rail
The grouped vertical rail is now an ARIA tree with a tree keyboard model, and tab rows are pinned as full-row activation targets whose controls keep their own actions and stable hit targets. Tree semantics (grouped vertical rail only): - #sessionTabs becomes role=tree while grouped and returns to its shipped role=tablist and label when grouping ends. The header strip, sidebar and flat rail keep role=tablist / role=tab exactly as before (the flat rail's markup is unchanged byte for byte). - A named group's header is a level-1 treeitem with aria-expanded that aria-owns its rows' role=group (rows are level 2). Ungrouped rows and the row a collapsed group keeps showing are level-1 items; a collapsed header owns nothing, and the "Ungrouped" heading is a visual divider hidden from assistive tech. aria-level, aria-setsize and aria-posinset are set on every item, and aria-selected follows the selection without a rebuild. - Exactly one treeitem carries tabindex=0 (roving). Controls inside rows leave the tab order, so Shift+F10 / ContextMenu open a row's actions (session action menu, web tab settings). - Up/Down walk 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. With the activity sort on, the walk follows painted order within each group; the flat list keeps its whole-list walk. - Focus survives a full re-render by identity (a row a collapse just hid hands focus to its header), but a render never pulls focus into the rail. - The group header is the treeitem itself (no nested button), still toggled by click through the same onclick and still the lineage proxy anchor. Full-row activation: - Clicking a row's status dot, mode chip, name or padding already selected it upstream; that is now pinned in real Chromium for the strip, the flat rail and the grouped rail, together with every control (gear, detach, close, overflow, web tab gear and close) running only its own action. - The close control now shows a pointer like its siblings instead of the default arrow. - Enter/Space on a focused web tab in the flat list opens it; it used to call selectSession(undefined). - The action controls are pinned to stay under the pointer when a row is hovered (no reflow-on-hover moving the gear out from under a click). New Chromium suite test/tab-activation.browser.test.ts is listed in BROWSER_TEST_GLOBS (run with npm run test:browser).
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
@@ -5104,6 +5104,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.
|
||||
@@ -5600,6 +5601,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
|
||||
@@ -5740,6 +5747,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
|
||||
@@ -5790,6 +5799,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.
|
||||
@@ -5807,19 +5822,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;
|
||||
}
|
||||
|
||||
@@ -5850,6 +5860,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('');
|
||||
|
||||
@@ -355,6 +355,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