Skip to content

fix(menus): one keyboard contract for every menu, and the phone's sheets step back one level (v0.18.1) - #322

Merged
MerciHanrim merged 1 commit into
mainfrom
fix/menu-keyboard
Oct 5, 2026
Merged

MerciHanrim merged 1 commit into
mainfrom
fix/menu-keyboard

Conversation

@MerciHanrim

@MerciHanrim MerciHanrim commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Part of #307. Version 0.18.1: one keyboard contract for every menu, and the phone's sheets take focus and step back one level at a time.

What a person notices

  • Every desktop menu button opens, moves and closes alike: Templates, Insert module, File, Data, Help, the temporary-session chip, Settings → Theme and the Distribution panel's export.
  • Enter, Space or a screen reader's activation opens a menu at its first item; Arrow Down or Arrow Up on a closed button opens it at the first or last item. A pointer open leaves focus on the button.
  • Inside, Arrow Down / Arrow Up move and wrap, Home and End jump; disabled items, hidden items and separators are skipped. There is no typeahead.
  • Escape closes the menu and returns to its button. Tab and Shift+Tab close it and move to the element after or before the button; Tab never moves between items.
  • After a keyboard choice that opens no dialog, focus returns to the button; a dialog takes focus and returns it to the button when it closes. A pointer choice never moves focus.
  • Theme's three choices are menuitemradio with aria-checked.
  • aria-controls only while the panel exists. Settings, ⋯, File, the temporary-session chip and Theme mount their panel on open, and now name it in aria-controls only while it is open. Found in the manual check with Windows Narrator on Edge and Chrome: "expanded" was never read on Settings or ⋯, even on a re-read. Measured on a local comparison page in Edge: the WAI-ARIA disclosure pattern and copies of our two buttons were read as "expanded" on a re-read; the same buttons with a panel that mounts on open and an aria-controls that names it while closed were not; with aria-controls only while open, "expanded" and "collapsed" were both read at once. The combobox in the register expression field has the same structure and is left for a separate review.
  • Settings and ⋯ are disclosures, not menus: no role="menu", aria-expanded and aria-controls on the button, a labelled role="group", Tab-only movement, focus left on the button when opened, Escape back to it. A keyboard choice in a group nested inside ⋯ returns focus to ⋯.
  • Language keeps its combobox: real focus stays in the search field with aria-activedescendant; Arrow Down or Arrow Up on its closed row opens it at the first or last language, Enter or Space at the current one.
  • Help keeps its feat(whats-new): the update notice, the What's new panel and a Help menu that says what it does (v0.15.0) #306 behaviour, now on the shared hook.
  • On a phone, a sheet takes focus when it opens and is not modal (docs/mobile.md MV-D11, MV-D18 and MV-D19 are unchanged). No aria-modal and no Tab trap: the run bar and the PWA update bar stay usable by touch, keyboard and screen reader while a sheet is open, Play runs without closing it, and Tab follows the page's order out of it. Only what the sheet's scrim covers for the pointer (the canvas, the top bar's More button, the open-file card) leaves the keyboard and screen-reader order while it is open.
  • Escape in a sheet opened from More (Templates, Export, Filters, Help, Share) goes back to More, with focus on the row that opened it; Close and the scrim still close everything. Opening MC from the run bar still closes the open sheet (MV-D11).
  • A real dialog is modal for real, on every screen. While an aria-modal dialog is the top one (the MC dialog, a confirmation, About, the guided tour and every other), everything outside it is inert: the pointer, Tab and the accessibility tree cannot reach the page, the run bar or the PWA update bar. The update bar sits behind the dialog layer meanwhile, keeps its state, and is back and usable the moment the dialog closes (docs/mobile.md MV8a, rewritten to say so). Pure live regions outside the dialog (an announcer with no control in it) stay live. One opened over a sheet is the only active layer; when it closes focus returns to the sheet row that opened it and the sheet is non-modal again.

How

  • src/ui/useMenuKeyboard.ts: useMenuTrigger (how the menu was opened) and useMenuKeyboard (entry focus, Escape, Tab with flushSync so the popup is gone before Tab's default action, arrows, Home/End); useReturnFocusAfterKeyboardChoice for the choices that unmount the item; usableItems for what is landed on.
  • src/ui/overlayStack.ts: the open dialogs and sheets in opening order, fed by useDialogFocus. Only the top one answers Escape. A sheet entry makes only the elements marked data-covered-by-sheets inert. A modal entry, at any width, makes everything outside it inert except the pure live regions, descending only into the containers that hold the dialog or such a region; a MutationObserver makes what mounts while it is open (an update arriving, a notice) inert as it arrives. With two modals only the top one is reachable. "The dialog" is its whole modal layer: the nearest data-modal-layer ancestor, set on the guided tour, whose scrim and spotlight are siblings of its popover (without it the tour's own scrim was made inert and the existing guided-tour scrim test failed in a full local run). The stack releases only the inert it set itself, and only when the entry that set it closes; an element that was inert before is never released.
  • The update bar's z-index moves from above the dialogs to between the run bar and the tour (sheet < run bar < update bar < tour < dialogs), so while a dialog is open it is covered by the dialog's scrim instead of being drawn on top and refusing the tap. Measured before this change, on this branch and on main: with About, the MC dialog or a dialog over a sheet open, Update took the pointer and was exposed in the accessibility tree, and only Tab was kept out by the trap.
  • The covered set is measured, not guessed: at 390 px with More, Export or Help open, the only focusable elements outside the sheet that the scrim covers are the canvas's and the top bar's More and Open a file buttons; the run bar's five controls and the update bar's two are on top of it.
  • useDialogFocus takes an optional kind (modal by default, sheet for MobileSheet, which does not trap Tab) and initial focus. On close it removes its entry before returning focus; when the control that opened it is inside a sheet or dialog that is still open, focus goes back to that control.
  • Mobile sheets have a key each, so moving between them re-runs focus entry.

Tests

  • New e2e/menu-keyboard.spec.ts (30 tests): the same contract on every menu above (Enter/Space entry, arrows and wrap, Home/End, Escape, closed Arrow Down/Up, Tab and Shift+Tab to the real neighbours, pointer open); focus after a keyboard choice, with and without a dialog, and never after a pointer choice; the Settings and ⋯ disclosures; the Language combobox; usableItems against separators, disabled and hidden items; and on a 390 px phone, the More sheet as non-modal (no aria-modal, the run bar and the update bar not inert, the canvas and the More button inert, Tab out to the run bar, Shift+Tab out to the update bar, Play without closing the sheet), Escape from each sub-sheet back to its row, Close, and a dialog over a sheet as the one modal layer with the update bar still reachable, including About from Help and the author dialog from Export.
  • Of those 30, five pin aria-controls on Settings, ⋯ (760 px), File, Theme and the temporary-session chip: absent while closed, naming an element that exists while open, and gone together with the panel after Escape and after pressing the button again.
  • Existing specs follow the new roles, entry points and Escape steps: theme-submenu and theme-boot (menuitemradio, aria-checked, keyboard entry), toolbar-responsive (⋯ is a disclosure), data-import-guide and storage-sessions (Enter opens at the first item), whats-new (the 0.18.1 entry), and mobile.spec.ts's two sheet-label contrast walks (Escape from Templates or Export now lands on More's row, and a second Escape closes More).
  • mobile.spec.ts's exclusive-overlay test (MC from the run bar over the Export sheet) and its update-bar test (Update, Close and Play reachable with a sheet open) pass unchanged.
  • New e2e/modal-inert.spec.ts: desktop About, an update arriving while About is open, the guided tour, the phone's MC dialog, and the Storage dialog over the More sheet. In each, while the dialog is open the pointer, Tab / Shift+Tab and the accessibility tree (Chromium's, through the DevTools protocol) reach neither Update nor Dismiss, nor Play on a phone; after Escape, focus is back on the control the dialog's owner names (the Help button, the run bar's MC button, the Storage row), the update bar's text and buttons are what they were, and all three paths reach Update again. In the tour, its own scrim is not inert and takes its click; after the Storage dialog closes over the More sheet, the More button, the open-file card and the canvas stay inert, because the sheet is still open. Two more tests drive the stack directly: with two modals only the top one is reachable, a live region stays live, an element made inert before anything opened (a direct child of <body>, where the stack walks) stays inert, a late element is inert while a modal is open and released after, and once the last modal closes an element added and given a task for an observer callback stays active; and a sheet entry makes only data-covered-by-sheets elements inert and never releases one that was inert before.

Release, strings and docs

  • Declared user-facing: 0.18.1 and release:0.18.1, three lines dated 2026-10-06, in 18 languages. The 16 languages other than English and Korean have not been reviewed by a native speaker.
  • The per-language copy tests move their pinned counts by the three keys and declare the key names (Home, End, Tab, Escape) where each guard asks. No bound is widened.
  • pt-PT region audit: two of the three new lines are worded the same in pt-BR and pt-PT (correct in both). The keys whose wording differs from pt-BR (DELTA) move from 268 to 269; the differing keys outside the 33 password keys (restDelta) move from 240 to 241, still under the unchanged bound of a quarter of the 966 non-password keys (241.5).
  • Docs: docs/toolbar-responsive.md (the menu keyboard contract and real dialogs), docs/mobile.md §MV5, §MV8a (rewritten: usable while a sheet is open, covered and inactive while a real dialog is open, back unchanged when it closes) and MV-D19, docs/guided-tour.md (the tour's z-order and inertness), CHANGELOG.md and README.md.
  • Earlier CI evidence, kept as the record of the previous contract: run 37288542788 at fc3c779 (modal sheets; four mobile failures) and run 37296385191 at 5dd5c83 (non-modal sheets with the update bar left reachable over a real dialog; 1,910 of 1,910, union exact).

Verification (local, at the head commit)

  • npx tsc -b, oxlint (39 warnings, the existing baseline), 3,107 unit tests and every check pass; the web, portable and PWA builds and the Cloudflare Production path build with the third-party notices unchanged.
  • End-to-end, the whole mobile project (every phone spec, including mobile.spec.ts): 106 passed, 4 skipped by design (two desktop-only readouts in canvas-refresh-visual.spec.ts, two chromium-only cases in playback-a11y-background.spec.ts).
  • End-to-end, 25 related spec files: the earlier list without mobile.spec.ts, which the chromium project does not run, plus modal-inert.spec.ts and portable-file.spec.ts (menus, dialogs, the tour, What's new, storage sessions, protected share links, data import, licences, i18n, the toolbar, the portable file, and menu-keyboard.spec.ts's phone cases): 465 of 465. The whole mobile project ran separately (above). Production bundle 16 of 16, PWA 19 of 19. These ran at the commit before the release date moved to 2026-10-06; at the head, after that two-line change, the quick gates, the release-note unit tests (26) and whats-new.spec.ts (96) pass.
  • A full local run of the chromium and portable projects before the tour-layer fix (1,802 passed, 3 skipped, 1 failed) found the tour's own scrim made inert; it is kept as the diagnostic run that found that defect, and the fixed head passed the related specs above.
  • Measured on a fresh dev server: the eleven desktop controls, every row of the phone's More sheet, focus after a keyboard choice in six cases, and, with More, Export or Help open, which controls outside the sheet the pointer reaches, each as described above. The update bar's Update button: with only the More sheet open, the pointer, Tab and the accessibility tree reach it; with the phone's MC dialog, the Storage dialog over the More sheet, or desktop About open, the dialog's scrim is the top element where it is, it is inert, Tab does not reach it, and the accessibility tree exposes no Update button.
  • Correction to the first version of this description: it listed "mobile sheets" among the 437 chromium results. mobile.spec.ts runs only in the mobile project, so it had not run locally at all; this pull request's first CI run found four failures there. Two were contract conflicts with the modal sheets of that version (MV-D11, MV-D18, MV-D19), fixed by making sheets non-modal; two were the contrast walks' old Escape steps.

Before merging

  • A manual check with Windows Narrator and Microsoft Edge on this pull request's preview. Done on the previous head for the desktop menus, About, Settings, Theme, Storage and privacy and Language; the expanded-state defect above was found there. Still to do on this head: "expanded" and "collapsed" on Settings and ⋯. The phone sheets were not checked with Narrator.
  • The full five-shard suite on this pull request's CI.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Deploying cozy-loop-studio with  Cloudflare Pages  Cloudflare Pages

Latest commit: e961efe
Status: ✅  Deploy successful!
Preview URL: https://e1fc6962.cozy-loop-studio.pages.dev
Branch Preview URL: https://fix-menu-keyboard.cozy-loop-studio.pages.dev

View logs

@MerciHanrim MerciHanrim changed the title fix(menus): one keyboard contract for every menu, and the phone's sheets as one modal layer (v0.18.1) fix(menus): one keyboard contract for every menu, and the phone's sheets step back one level (v0.18.1) Oct 5, 2026
…ets step back one level (v0.18.1)

Part of #307. Version 0.18.1, release date 2026-10-06. Every desktop menu button (Templates, Insert module, File, Data, Help, the temporary-session chip, Settings > Theme and the Distribution export) shares useMenuTrigger and useMenuKeyboard in src/ui/useMenuKeyboard.ts: Enter, Space or a screen reader's activation opens at the first item, Arrow Down or Arrow Up on the closed button at the first or last; inside, the arrows wrap, Home and End jump, and disabled, hidden and separator entries are skipped; Escape closes and returns to the button; Tab and Shift+Tab close the menu (flushSync, so the popup is gone before the default action) and move to the element after or before the button, never between items; typeahead is left out. After a keyboard choice that opens no dialog, focus returns to the button; a dialog takes focus and returns it there; a pointer open or choice never moves focus. Theme's three choices are menuitemradio with aria-checked. Settings, the overflow button, File, the temporary-session chip and Theme carry aria-controls only while their panel exists (it mounts on open): measured with Narrator and Edge on a comparison page, a button whose aria-controls named an absent panel while closed was never read as expanded after opening, and with the attribute only while open both expanded and collapsed were read at once. Settings and the overflow button are disclosures: no role="menu", aria-expanded and aria-controls on the button, a labelled group, Tab-only movement, focus left on the button when opened, Escape back to it; a keyboard choice in a group nested in the overflow returns to the overflow button. Language keeps its combobox with real focus in the search field and aria-activedescendant; Arrow Down or Arrow Up on its closed row opens it at the first or last language. Help keeps its issue #306 behaviour on the shared hook. On a phone a sheet is not modal (docs/mobile.md MV-D11, MV-D18 and MV-D19 are unchanged): no aria-modal and no Tab trap, so the run bar and the PWA update bar stay usable by pointer, keyboard and screen reader while it is open, Play runs without closing it, and Tab follows the page's order out of it; only what its scrim covers for the pointer (the canvas, the top bar's More button, the open-file card, marked data-covered-by-sheets) is inert while it is open. Focus enters a sheet when it opens; Escape in a sheet opened from More (Templates, Export, Filters, Help, Share) goes back to More with focus on the row that opened it, while Close and the scrim still close everything. Every dialog and sheet registers in src/ui/overlayStack.ts through useDialogFocus: the top one alone answers Escape, and a real dialog (aria-modal) is modal for real at any width: while it is the top one everything outside it is inert, the run bar and the PWA update bar included, except pure live regions (no control inside), and what mounts meanwhile is made inert as it arrives (MutationObserver); with two modals only the top one is reachable. "The dialog" is its whole modal layer, the nearest data-modal-layer ancestor, which the guided tour sets so its own scrim and spotlight are not made inert with the page. The stack releases only the inert it set itself, and only when the entry that set it closes. The update bar's z-index moves below the tour and the dialogs (sheet < run bar < update bar < tour < dialogs), so a real dialog covers it; it keeps its state and is back and usable when the dialog closes (docs/mobile.md MV8a rewritten to say so). A dialog opened from a sheet returns focus on close to the control inside the sheet that opened it, and the sheet is non-modal again. New e2e/modal-inert.spec.ts checks pointer, Tab and the accessibility tree with About, an update arriving during About, the guided tour, the phone's MC dialog and the Storage dialog over the More sheet, before and after closing, the tour's own scrim taking its click, what a sheet's scrim covers staying inert after a dialog over it closes, and, driven through the stack, two nested modals, an element inert before anything opened, a late element, and no observer left once the last modal closes. Three release-note lines in 18 languages; the 16 other than English and Korean have not been reviewed by a native speaker. The per-language copy tests move their pinned counts by the three keys and declare the key names (Home, End, Tab, Escape) where each guard asks; two of the three pt-BR and pt-PT lines are worded the same in both, so the pt-PT audit's differing keys (DELTA) move from 268 to 269 and the differing keys outside the 33 password keys (restDelta) from 240 to 241, still under the unchanged bound of a quarter of the 966 non-password keys (241.5). New e2e/menu-keyboard.spec.ts; existing specs follow the new roles, entry points and Escape steps. Docs: docs/toolbar-responsive.md (the menu keyboard contract), docs/mobile.md §MV5, CHANGELOG.md and README.md.
@MerciHanrim
MerciHanrim merged commit d116779 into main Oct 5, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant