fix(menus): one keyboard contract for every menu, and the phone's sheets step back one level (v0.18.1) - #322
Merged
Merged
Conversation
Deploying cozy-loop-studio with
|
| 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 |
MerciHanrim
force-pushed
the
fix/menu-keyboard
branch
from
October 5, 2026 10:24
fc3c779 to
5dd5c83
Compare
MerciHanrim
force-pushed
the
fix/menu-keyboard
branch
from
October 5, 2026 12:03
5dd5c83 to
a408bd0
Compare
…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
force-pushed
the
fix/menu-keyboard
branch
from
October 5, 2026 15:32
a408bd0 to
e961efe
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
menuitemradiowitharia-checked.aria-controlsonly while the panel exists. Settings,⋯, File, the temporary-session chip and Theme mount their panel on open, and now name it inaria-controlsonly 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 anaria-controlsthat names it while closed were not; witharia-controlsonly 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.⋯are disclosures, not menus: norole="menu",aria-expandedandaria-controlson the button, a labelledrole="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⋯.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.aria-modaland 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.aria-modaldialog is the top one (the MC dialog, a confirmation, About, the guided tour and every other), everything outside it isinert: 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) anduseMenuKeyboard(entry focus, Escape, Tab withflushSyncso the popup is gone before Tab's default action, arrows, Home/End);useReturnFocusAfterKeyboardChoicefor the choices that unmount the item;usableItemsfor what is landed on.src/ui/overlayStack.ts: the open dialogs and sheets in opening order, fed byuseDialogFocus. Only the top one answers Escape. Asheetentry makes only the elements markeddata-covered-by-sheetsinert. Amodalentry, 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 nearestdata-modal-layerancestor, 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 theinertit set itself, and only when the entry that set it closes; an element that was inert before is never released.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 onmain: 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.useDialogFocustakes an optional kind (modalby default,sheetforMobileSheet, 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.keyeach, so moving between them re-runs focus entry.Tests
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;usableItemsagainst separators, disabled and hidden items; and on a 390 px phone, the More sheet as non-modal (noaria-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.aria-controlson 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.theme-submenuandtheme-boot(menuitemradio,aria-checked, keyboard entry),toolbar-responsive(⋯is a disclosure),data-import-guideandstorage-sessions(Enter opens at the first item),whats-new(the 0.18.1 entry), andmobile.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.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 onlydata-covered-by-sheetselements inert and never releases one that was inert before.Release, strings and docs
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.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.mdandREADME.md.fc3c779(modal sheets; fourmobilefailures) and run 37296385191 at5dd5c83(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.mobileproject (every phone spec, includingmobile.spec.ts): 106 passed, 4 skipped by design (two desktop-only readouts incanvas-refresh-visual.spec.ts, two chromium-only cases inplayback-a11y-background.spec.ts).mobile.spec.ts, which the chromium project does not run, plusmodal-inert.spec.tsandportable-file.spec.ts(menus, dialogs, the tour, What's new, storage sessions, protected share links, data import, licences, i18n, the toolbar, the portable file, andmenu-keyboard.spec.ts's phone cases): 465 of 465. The wholemobileproject 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) andwhats-new.spec.ts(96) pass.mobile.spec.tsruns only in themobileproject, 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
⋯. The phone sheets were not checked with Narrator.