Skip to content

Commit 9db517f

Browse files
committed
fix(ui): align project rail focus order
Generated-by: Codex
1 parent a81719d commit 9db517f

6 files changed

Lines changed: 127 additions & 25 deletions

File tree

apps/desktop/e2e/sidebar-project-row.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ function sessionRow(sidebar: Locator, sessionId: string): Locator {
1010
return sidebar.locator(`[data-session-id*=${JSON.stringify(sessionId)}]`);
1111
}
1212

13-
test('project navigation and actions remain adjacent keyboard controls', async ({
13+
test('project navigation and actions follow their visual keyboard order', async ({
1414
projectSidebarWindow: page,
1515
}) => {
1616
await page.keyboard.press('Escape');
@@ -37,9 +37,9 @@ test('project navigation and actions remain adjacent keyboard controls', async (
3737
await expect(projectRow.locator('button button')).toHaveCount(0);
3838
await expect(navigation).toHaveAttribute('aria-expanded', 'true');
3939

40-
await action.focus();
40+
await navigation.focus();
4141
await page.keyboard.press('Tab');
42-
await expect(navigation).toBeFocused();
42+
await expect(action).toBeFocused();
4343
await page.keyboard.press('Tab');
4444
await expect(firstSessionControl).toBeFocused();
4545

apps/desktop/src/renderer/styles/sidebar.css

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -339,9 +339,10 @@
339339
}
340340
}
341341

342-
/* SideNavItem owns each row button and only supports non-interactive
343-
* endContent. Action menus occupy a reserved trailing slot visually, but stay
344-
* siblings in the DOM so neither control contains the other. */
342+
/* SideNavItem owns each row button; interactive actions use its trailingAction
343+
* sibling slot instead of non-interactive endContent. Menus occupy a reserved
344+
* trailing slot visually, but stay siblings in the DOM so neither control
345+
* contains the other. */
345346
.maka-session-row-action {
346347
position: absolute;
347348
inset-block-start: calc(
@@ -360,6 +361,7 @@
360361
* gutter + disclosure + SideNavItem gap. Empty projects have no disclosure and
361362
* use the default trailing inset above. */
362363
.maka-project-row
364+
> div
363365
> .maka-session-row-action[data-position="before-disclosure"] {
364366
inset-inline-end: calc(var(--space-2) + var(--space-6) + var(--space-2));
365367
}
@@ -369,14 +371,14 @@
369371
* feedback only while the direct project action is targeted; a plain wrapper
370372
* :hover selector would also light the header over nested session rows. */
371373
@media (hover: hover) {
372-
.maka-project-row:has(> .maka-session-row-action:hover)
374+
.maka-project-row:has(> div > .maka-session-row-action:hover)
373375
> div
374376
> .astryx-side-nav-item {
375377
background-color: var(--color-overlay-hover);
376378
}
377379
}
378380

379-
.maka-project-row:has(> .maka-session-row-action button:active)
381+
.maka-project-row:has(> div > .maka-session-row-action button:active)
380382
> div
381383
> .astryx-side-nav-item {
382384
background-color: var(--color-overlay-pressed);

packages/ui/src/__tests__/session-history-row-actions.test.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ const projectActions: ProjectRowActions = {
4949
onRestore: () => undefined,
5050
};
5151

52+
function assertNoNestedButtons(markup: string): void {
53+
const { document } = parseHTML(markup);
54+
assert.equal(
55+
document.querySelector('button button') === null,
56+
true,
57+
'navigation and action controls must stay siblings',
58+
);
59+
}
60+
5261
test('renders session navigation and row actions as sibling controls', () => {
5362
const markup = renderToStaticMarkup(
5463
<LocaleProvider locale="en">
@@ -62,7 +71,7 @@ test('renders session navigation and row actions as sibling controls', () => {
6271

6372
assert.equal((markup.match(/<button\b/g) ?? []).length, 2);
6473
assert.match(markup, /class="maka-session-row-action"/);
65-
assert.doesNotMatch(markup, /<button\b(?:(?!<\/button>)[\s\S])*<button\b/);
74+
assertNoNestedButtons(markup);
6675
});
6776

6877
test('renders collapsible project navigation and row actions as sibling controls', () => {
@@ -103,7 +112,11 @@ test('renders collapsible project navigation and row actions as sibling controls
103112
assert.equal(metadata.textContent, '1');
104113
assert.equal(controlledGroup.getAttribute('aria-hidden'), 'false');
105114
const projectButtons = [...projectRow.querySelectorAll('button')];
106-
assert.equal(projectButtons[0], action);
107-
assert.equal(projectButtons[1], navigation);
108-
assert.doesNotMatch(markup, /<button\b(?:(?!<\/button>)[\s\S])*<button\b/);
115+
assert.equal(
116+
projectButtons.indexOf(navigation),
117+
0,
118+
'project navigation precedes its auxiliary action',
119+
);
120+
assert.equal(projectButtons.indexOf(action), 1, 'project action precedes nested tasks');
121+
assertNoNestedButtons(markup);
109122
});

packages/ui/src/session-history-list.tsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -323,15 +323,6 @@ function ProjectNavRow(props: {
323323
const hasActions = props.project !== undefined && props.projectActions !== undefined;
324324
return (
325325
<div data-project-id={props.groupKey} className="maka-project-row">
326-
{props.project && props.projectActions ? (
327-
<ProjectItemActions
328-
key="actions"
329-
project={props.project}
330-
actions={props.projectActions}
331-
onStartRename={props.onStartRename}
332-
position={hasSessions ? 'before-disclosure' : 'trailing'}
333-
/>
334-
) : null}
335326
<SideNavItem
336327
key="navigation"
337328
label={props.label}
@@ -344,6 +335,16 @@ function ProjectNavRow(props: {
344335
reserveAction={hasActions}
345336
/>
346337
}
338+
trailingAction={
339+
props.project && props.projectActions ? (
340+
<ProjectItemActions
341+
project={props.project}
342+
actions={props.projectActions}
343+
onStartRename={props.onStartRename}
344+
position={hasSessions ? 'before-disclosure' : 'trailing'}
345+
/>
346+
) : undefined
347+
}
347348
>
348349
{/* Nest indent zeroed one level in sidebar.css (time-sort left edge). */}
349350
{hasSessions ? (
@@ -527,8 +528,9 @@ function ProjectItemActions(props: {
527528
})();
528529
}
529530

530-
// Projects keep a permanent MoreMenu. It is a sibling of SideNavItem so the
531-
// row's collapse button and the menu remain separate interactive controls.
531+
// Projects keep a permanent MoreMenu. SideNavItem's trailingAction slot puts
532+
// it after the collapse button and before the nested tasks, so visual and
533+
// keyboard order agree without nesting either interactive control.
532534
const menuItems = project.archivedAt !== undefined
533535
? [
534536
{

patches/@astryxdesign+core+0.4.0.patch

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,53 @@ index 867ed5a..4b8f835 100644
282282
}
283283
Markdown.displayName = 'Markdown';
284284
\ No newline at end of file
285+
diff --git a/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.d.ts b/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.d.ts
286+
index 16107cf..57aeb6c 100644
287+
--- a/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.d.ts
288+
+++ b/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.d.ts
289+
@@ -61,6 +61,12 @@ export interface SideNavItemProps extends BaseProps<HTMLElement> {
290+
* Right-side content (badges, counts).
291+
*/
292+
endContent?: ReactNode;
293+
+ /**
294+
+ * Interactive trailing control rendered after the navigation control and
295+
+ * before any nested items. Unlike `endContent`, this is a sibling rather
296+
+ * than content inside the navigation control.
297+
+ */
298+
+ trailingAction?: ReactNode;
299+
/**
300+
* Sub-items for nesting.
301+
*/
302+
@@ -107,7 +113,7 @@ export interface SideNavItemProps extends BaseProps<HTMLElement> {
303+
* </SideNavItem>
304+
* ```
305+
*/
306+
-export declare function SideNavItem({ as, label, icon, selectedIcon, isSelected, isDisabled, href, onClick, endContent, children, collapsible: itemCollapsible, size, 'data-testid': testId, ref, xstyle, }: SideNavItemProps): import("react").JSX.Element | null;
307+
+export declare function SideNavItem({ as, label, icon, selectedIcon, isSelected, isDisabled, href, onClick, endContent, trailingAction, children, collapsible: itemCollapsible, size, 'data-testid': testId, ref, xstyle, }: SideNavItemProps): import("react").JSX.Element | null;
308+
export declare namespace SideNavItem {
309+
var displayName: string;
310+
}
311+
diff --git a/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.js b/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.js
312+
index a72d264..c65bd32 100644
313+
--- a/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.js
314+
+++ b/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.js
315+
@@ -159,6 +159,7 @@ export function SideNavItem({
316+
href,
317+
onClick,
318+
endContent,
319+
+ trailingAction,
320+
children,
321+
collapsible: itemCollapsible,
322+
size = 'md',
323+
@@ -447,7 +448,7 @@ export function SideNavItem({
324+
const item = /*#__PURE__*/_jsxs("div", {
325+
ref: itemRef,
326+
...stylex.props(styles.root, xstyle),
327+
- children: [itemElement, hasChildren && !isCollapsed && /*#__PURE__*/_jsx("div", {
328+
+ children: [itemElement, trailingAction, hasChildren && !isCollapsed && /*#__PURE__*/_jsx("div", {
329+
id: `${id}-children`,
330+
role: "group",
331+
"aria-labelledby": `${id}-label`,
285332
diff --git a/node_modules/@astryxdesign/core/dist/hooks/useHotkeys.js b/node_modules/@astryxdesign/core/dist/hooks/useHotkeys.js
286333
index 65f9278..f7515e2 100644
287334
--- a/node_modules/@astryxdesign/core/dist/hooks/useHotkeys.js
@@ -602,6 +649,39 @@ index a6f850b..b6e0f2a 100644
602649
return rendered;
603650
}
604651

652+
diff --git a/node_modules/@astryxdesign/core/src/SideNav/SideNavItem.tsx b/node_modules/@astryxdesign/core/src/SideNav/SideNavItem.tsx
653+
index 41ccdb5..90b2406 100644
654+
--- a/node_modules/@astryxdesign/core/src/SideNav/SideNavItem.tsx
655+
+++ b/node_modules/@astryxdesign/core/src/SideNav/SideNavItem.tsx
656+
@@ -292,6 +292,12 @@ export interface SideNavItemProps extends BaseProps<HTMLElement> {
657+
* Right-side content (badges, counts).
658+
*/
659+
endContent?: ReactNode;
660+
+ /**
661+
+ * Interactive trailing control rendered after the navigation control and
662+
+ * before any nested items. Unlike `endContent`, this is a sibling rather
663+
+ * than content inside the navigation control.
664+
+ */
665+
+ trailingAction?: ReactNode;
666+
/**
667+
* Sub-items for nesting.
668+
*/
669+
@@ -355,6 +361,7 @@ export function SideNavItem({
670+
href,
671+
onClick,
672+
endContent,
673+
+ trailingAction,
674+
children,
675+
collapsible: itemCollapsible,
676+
size = 'md',
677+
@@ -681,6 +688,7 @@ export function SideNavItem({
678+
const item = (
679+
<div ref={itemRef} {...stylex.props(styles.root, xstyle)}>
680+
{itemElement}
681+
+ {trailingAction}
682+
{hasChildren && !isCollapsed && (
683+
<div
684+
id={`${id}-children`}
605685
diff --git a/node_modules/@astryxdesign/core/src/hooks/useStreamingText.ts b/node_modules/@astryxdesign/core/src/hooks/useStreamingText.ts
606686
index 55b441e..7aa4174 100644
607687
--- a/node_modules/@astryxdesign/core/src/hooks/useStreamingText.ts

patches/README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ Streaming tool-call association for gateways that reuse or omit `index` / `id`
2626

2727
Delete when that guard passes against an unpatched package.
2828

29-
## `@astryxdesign/core@0.3.0`
29+
## `@astryxdesign/core@0.4.0`
3030

31-
Four published component seams drop host-owned state or semantics:
31+
Five published component seams drop host-owned state or semantics:
3232

3333
- `ChatLayout` needs a conversation identity that resets scroll/unread state
3434
without remounting its composer slot and discarding the live draft.
@@ -43,6 +43,11 @@ Four published component seams drop host-owned state or semantics:
4343
`ChatLayoutContextValue` publishes the hook's existing `unlock`.
4444
- `ChatToolCalls` needs a stable row slot for product styling and E2E geometry.
4545
- `List` must forward its published `aria-label` to the rendered list element.
46+
- `SideNavItem` needs an interactive `trailingAction` sibling between its
47+
navigation control and nested items. `endContent` renders inside the primary
48+
control, while a sibling outside `SideNavItem` can only come before the
49+
project control or after all of its tasks; neither produces the visual Tab
50+
order used by the task rail.
4651

4752
Blank UA-CH `navigator.userAgentData.platform` must also not mean "not Apple".
4853
Electron builds with a rewritten identity ship `platform: ''`, which made every

0 commit comments

Comments
 (0)