Menus: parent row loses its highlight while its submenu is open (cold open of Files ⋯ → Sort by) #579

Open
opened 2026-10-01 05:58:25 +00:00 by kayg · 4 comments
Owner

Owner report (2026-10-01, screenshot on calternal.cloud: Files header ⋯ → Sort by)

On a cold open of the ⋯ menu, it is possible to reach a state where the Sort by submenu is open and its first item ("Name") is highlighted, while the parent row "Sort by" has no highlight. The parent loses its selected state, and the highlight jumps straight into the child.
Expected (macOS menu conventions):

  • While a submenu is open, its parent row stays highlighted (a "submenu open" state).
  • Pointer entry into the submenu does not clear it.
  • Opening a submenu by hover does not move the highlight into the child; the child gets a highlight only when the pointer enters it or the User presses → / Return.
  • Keyboard: → opens the submenu and highlights its first item; ← closes it and returns the highlight to the parent.
  • Use a safe triangle / hover intent so a diagonal pointer move to the submenu does not close it.
    Fix in the shared menu primitive (packages/ui/src/components/menu/*; bits-ui is headless only). Check every menu with submenus (Files ⋯, the context menus, Tasks, Mail).
    Test: e2e (pointer + keyboard) that asserts the parent's data-highlighted / aria-expanded state while the submenu is open, from a cold open, including a fast diagonal move. Web gates. Screenshots at 1440 in both themes.
## Owner report (2026-10-01, screenshot on calternal.cloud: Files header ⋯ → Sort by) On a cold open of the ⋯ menu, it is possible to reach a state where the **Sort by** submenu is open and its first item ("Name") is highlighted, while the parent row "Sort by" has **no highlight**. The parent loses its selected state, and the highlight jumps straight into the child. **Expected (macOS menu conventions):** - While a submenu is open, its parent row stays highlighted (a "submenu open" state). - Pointer entry into the submenu does not clear it. - Opening a submenu by hover does **not** move the highlight into the child; the child gets a highlight only when the pointer enters it or the User presses → / Return. - Keyboard: → opens the submenu and highlights its first item; ← closes it and returns the highlight to the parent. - Use a safe triangle / hover intent so a diagonal pointer move to the submenu does not close it. **Fix** in the shared menu primitive (`packages/ui/src/components/menu/*`; bits-ui is headless only). Check every menu with submenus (Files ⋯, the context menus, Tasks, Mail). **Test:** e2e (pointer + keyboard) that asserts the parent's `data-highlighted` / `aria-expanded` state while the submenu is open, from a cold open, including a fast diagonal move. Web gates. Screenshots at 1440 in both themes.
Author
Owner

Starting #579 on job/submenu-579 at cc25c441b7a974185622a1dee853cf38686d2b67. Current origin/dev before integration: 3f258302a0f2d6418ff60c9ce22cbb33e008ca99.

Starting #579 on `job/submenu-579` at `cc25c441b7a974185622a1dee853cf38686d2b67`. Current `origin/dev` before integration: `3f258302a0f2d6418ff60c9ce22cbb33e008ca99`.
Author
Owner

Finding: Menu.svelte passed active={activeItemId === node.id && !openSubmenuId}, so it removed the parent's highlight as soon as any flyout opened. The child FloatingSurface also used its default initial-focus selector on mount, and submenu focus immediately highlighted the first child row. This reproduces the reported parent/child state by code path. The shared primitive now keeps an expanded parent highlighted and lets only keyboard opens focus the child. The unit regression passes: bun run test -- src/lib/components/ThemePicker.svelte.test.ts (6 tests).

Finding: `Menu.svelte` passed `active={activeItemId === node.id && !openSubmenuId}`, so it removed the parent's highlight as soon as any flyout opened. The child `FloatingSurface` also used its default initial-focus selector on mount, and submenu focus immediately highlighted the first child row. This reproduces the reported parent/child state by code path. The shared primitive now keeps an expanded parent highlighted and lets only keyboard opens focus the child. The unit regression passes: `bun run test -- src/lib/components/ThemePicker.svelte.test.ts` (6 tests).
Author
Owner

Finding: The real Files e2e exposed an intermittent fast diagonal failure: moving from Sort by toward the last Sort submenu row closed the child before pointer entry. Menu.svelte projected a fixed 80 px from the last pointer point while a 150 ms close timer ran; that did not cover the live submenu panel on this path. The hover-intent check now projects across the panel diagonal and retries from the latest point, while still closing when movement stops or turns away. I am rerunning the same real-server e2e.

Finding: The real Files e2e exposed an intermittent fast diagonal failure: moving from Sort by toward the last Sort submenu row closed the child before pointer entry. `Menu.svelte` projected a fixed 80 px from the last pointer point while a 150 ms close timer ran; that did not cover the live submenu panel on this path. The hover-intent check now projects across the panel diagonal and retries from the latest point, while still closing when movement stops or turns away. I am rerunning the same real-server e2e.
Author
Owner

Finished — submenu behavior (#579)

Built: the shared menu now keeps an open submenu parent highlighted. Hover opens the child without moving the highlight into it; pointer entry highlights the child. ArrowRight and Enter open and focus the first child row. ArrowLeft closes the child and returns focus and highlight to its parent. The hover safe triangle projects across the full child panel, including a fast diagonal move to the last row.

Commits:

  • 931fa49c7 Keep open menu submenu parents highlighted
  • 142c063a8 Profile menu submenu interaction hot path

Head: 142c063a87206721042ae52d24140490a80627d3

Files: packages/ui/src/components/menu/Menu.svelte, packages/ui/src/components/menu/MenuItem.svelte, apps/web/src/lib/components/ThemePicker.svelte.test.ts, apps/web/e2e/submenu-579.mjs, apps/web/package.json, bench/menu-submenu-579.mjs.

Coverage: the Files production E2E covers cold pointer and keyboard opens, the parent data-highlighted and aria-expanded state, child highlight entry, ArrowLeft/ArrowRight/Enter, and a fast diagonal pointer move. The shared primitive is also exercised through ThemePicker pointer and keyboard tests. Reviewed nested menu models for Files, note and block context menus, Mail, and photo zoom; they all use this shared primitive. Tasks use the common context menu without a nested child. Screenshots are attached below for 390, 820, and 1440 px in light and dark themes.

Web gates

bun run check:

$ node scripts/check-user-storage.mjs && node scripts/check-type-tokens.mjs && node scripts/check-motion-tokens.mjs && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json
User browser caches use userStorage; only documented device/public-link exceptions remain.
Text sizes and UI shape values use shared role tokens.
UI transitions and animation options use shared motion tokens or documented exceptions.
Loading svelte-check in workspace: /home/kayg/Developer/calternal-wt/submenu-579/apps/web
Getting Svelte diagnostics...

svelte-check found 0 errors and 0 warnings

bun run test final summary:

 Test Files  148 passed (148)
      Tests  1012 passed (1012)
   Start at  16:46:03
   Duration  129.27s (transform 49%, environment 20%, import 15%, tests 11%, setup 4%)

Environment  |component| jsdom was created 46 times · 139.50s total, 27% of tracked time
             create it once per worker with pool: 'vmThreads' (keeps per-file isolation) or isolate: false (shares it across files)
             learn more: https://vitest.dev/guide/improving-performance#test-environments

Production E2E output:

Version 1.63.0
PASS Files submenu 390px light
PASS Files submenu 820px light
PASS Files submenu 1440px light
PASS Files submenu 390px dark
PASS Files submenu 820px dark
PASS Files submenu 1440px dark
SCREENSHOTS /home/kayg/Developer/calternal-wt/submenu-579/apps/web/artifacts/submenu-579

Performance profile

Local production SPA profile: 20 average opens and a 32-open burst, 5 submenu rows, 1440×900. Host load average before the run was 17.38, 16.52, 17.08. Average p50/p95 was 754.8/1036.6 ms, browser CPU 13.47 s (85.4%), mean/peak RSS 542,637,885/558,956,544 bytes. Burst p50/p95 was 680.7/1211.5 ms, browser CPU 20.02 s (80.2%), mean/peak RSS 567,187,146/575,111,168 bytes. docs/perf/baseline.json has no menu or submenu interaction metric, so there is no baseline delta.

Screenshots

Files menu, 390 px, light
Files menu, 390 px, dark
Files menu, 820 px, light
Files menu, 820 px, dark
Files menu, 1440 px, light
Files menu, 1440 px, dark

Known gaps: there is no submenu performance baseline. Production pointer/keyboard E2E is route-specific to Files; other nested call sites are covered through their common primitive, not with route-specific browser journeys. No Rust source changed, so Rust lint/test gates were not run. Build output was cleaned after verification.

Decisions not stated in DESIGN: following the issue’s macOS menu behavior, keyboard-opened children focus their first row while pointer-opened children wait for pointer entry. The safe-triangle retry uses the full panel diagonal plus 80 px and the existing 150 ms delay. The performance profile uses 20 average opens and 32 burst opens because no menu profile or baseline existed.

## Finished — submenu behavior (#579) **Built:** the shared menu now keeps an open submenu parent highlighted. Hover opens the child without moving the highlight into it; pointer entry highlights the child. ArrowRight and Enter open and focus the first child row. ArrowLeft closes the child and returns focus and highlight to its parent. The hover safe triangle projects across the full child panel, including a fast diagonal move to the last row. **Commits:** - `931fa49c7` Keep open menu submenu parents highlighted - `142c063a8` Profile menu submenu interaction hot path **Head:** `142c063a87206721042ae52d24140490a80627d3` **Files:** `packages/ui/src/components/menu/Menu.svelte`, `packages/ui/src/components/menu/MenuItem.svelte`, `apps/web/src/lib/components/ThemePicker.svelte.test.ts`, `apps/web/e2e/submenu-579.mjs`, `apps/web/package.json`, `bench/menu-submenu-579.mjs`. **Coverage:** the Files production E2E covers cold pointer and keyboard opens, the parent `data-highlighted` and `aria-expanded` state, child highlight entry, ArrowLeft/ArrowRight/Enter, and a fast diagonal pointer move. The shared primitive is also exercised through ThemePicker pointer and keyboard tests. Reviewed nested menu models for Files, note and block context menus, Mail, and photo zoom; they all use this shared primitive. Tasks use the common context menu without a nested child. Screenshots are attached below for 390, 820, and 1440 px in light and dark themes. ### Web gates `bun run check`: ```text $ node scripts/check-user-storage.mjs && node scripts/check-type-tokens.mjs && node scripts/check-motion-tokens.mjs && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json User browser caches use userStorage; only documented device/public-link exceptions remain. Text sizes and UI shape values use shared role tokens. UI transitions and animation options use shared motion tokens or documented exceptions. Loading svelte-check in workspace: /home/kayg/Developer/calternal-wt/submenu-579/apps/web Getting Svelte diagnostics... svelte-check found 0 errors and 0 warnings ``` `bun run test` final summary: ```text Test Files 148 passed (148) Tests 1012 passed (1012) Start at 16:46:03 Duration 129.27s (transform 49%, environment 20%, import 15%, tests 11%, setup 4%) Environment |component| jsdom was created 46 times · 139.50s total, 27% of tracked time create it once per worker with pool: 'vmThreads' (keeps per-file isolation) or isolate: false (shares it across files) learn more: https://vitest.dev/guide/improving-performance#test-environments ``` Production E2E output: ```text Version 1.63.0 PASS Files submenu 390px light PASS Files submenu 820px light PASS Files submenu 1440px light PASS Files submenu 390px dark PASS Files submenu 820px dark PASS Files submenu 1440px dark SCREENSHOTS /home/kayg/Developer/calternal-wt/submenu-579/apps/web/artifacts/submenu-579 ``` ### Performance profile Local production SPA profile: 20 average opens and a 32-open burst, 5 submenu rows, 1440×900. Host load average before the run was `17.38, 16.52, 17.08`. Average p50/p95 was `754.8/1036.6 ms`, browser CPU `13.47 s` (`85.4%`), mean/peak RSS `542,637,885/558,956,544 bytes`. Burst p50/p95 was `680.7/1211.5 ms`, browser CPU `20.02 s` (`80.2%`), mean/peak RSS `567,187,146/575,111,168 bytes`. `docs/perf/baseline.json` has no menu or submenu interaction metric, so there is no baseline delta. ### Screenshots ![Files menu, 390 px, light](https://git.kayg.org/attachments/44549bd4-3035-44d8-bdab-b6963cf70214) ![Files menu, 390 px, dark](https://git.kayg.org/attachments/62970262-7c8e-417f-91af-daa70a48e0db) ![Files menu, 820 px, light](https://git.kayg.org/attachments/289b428e-572d-4be2-a8c2-bd411a4e0147) ![Files menu, 820 px, dark](https://git.kayg.org/attachments/bae0c22a-476f-4800-adba-75d6c328bd06) ![Files menu, 1440 px, light](https://git.kayg.org/attachments/d3c4bd3d-4fd9-4786-be4a-7b16b9c9b8ba) ![Files menu, 1440 px, dark](https://git.kayg.org/attachments/9d7f3fbc-3c8c-4479-a026-9abd3ff318f1) **Known gaps:** there is no submenu performance baseline. Production pointer/keyboard E2E is route-specific to Files; other nested call sites are covered through their common primitive, not with route-specific browser journeys. No Rust source changed, so Rust lint/test gates were not run. Build output was cleaned after verification. **Decisions not stated in DESIGN:** following the issue’s macOS menu behavior, keyboard-opened children focus their first row while pointer-opened children wait for pointer entry. The safe-triangle retry uses the full panel diagonal plus 80 px and the existing 150 ms delay. The performance profile uses 20 average opens and 32 burst opens because no menu profile or baseline existed.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#579
No description provided.