PT-4532: Menu section headings and keyboard shortcut hints - #2830
katherinejensen00 wants to merge 3 commits into
Conversation
mattgetgen
left a comment
There was a problem hiding this comment.
Review — PT-4532 menu section headings and shortcut hints
Reviewed 92d77b5b0d0...3ee579b5f44 (62 files), skipping the generated lib/**/dist/** and lib/papi-dts/papi.d.ts — I spot-checked those three to confirm they were regenerated consistently (showSectionHeadings, MenuItemContainingCommand.shortcut and the unicode-bidi class are all present).
The change is in good shape, and the menu-document-combiner.ts spread is a real latent-bug fix rather than tidy-up — web view menus were never awaited, so a column label could be served as a raw %localize_key%, which is exactly what the new headings would have put on screen.
Three findings, all low-to-medium and none blocking. Each has a proposed patch inline; I applied and tested all three locally, including the revert test, so the details below are measured rather than guessed.
Checked and found sound
parseShortcutKeycapsagainst every string in the catalog:⌘F8,⌃Space,⌘],Ctrl++,Ctrl+-,Alt+←,↑ / ↓ / ← / →,\,— (no equivalent)all split correctly, and no combination or alternative produces duplicate React keys.- The
unicode-bidi: plaintextreasoning in the three shadcn components is right: it changes the bidi paragraph direction without changingdirection, soms-autostill resolves to the inline end in RTL. - Both new
commandjoins match their handlers (⌥⌘M/Ctrl+Shift+Natplatform-scripture-editor.web-view.tsx:2279-2285;⌃F/Ctrl+FviauseOpenFindShortcut, mounted in all four editor/panel web views), and the two newstructure-protection-buttonentries match the(ctrlKey||metaKey)+shift(±alt)+Lhandler. addShortcutHintsnever mutates the cached menu objects, sogetUnlocalizedMainMenugenuinely stays hint-free — worth confirming, since the constructor aliasesmainMenuandunlocalizedMainMenuto the same array.- Every bundled renderer of
MenuItemContainingCommandnow showsshortcut(menubar, tab dropdown, tab-title context menu, overlay converter); a grep acrosslib/platform-bible-react/src,src/rendererandextensions/srcfound no missed site. - ADR slugs are in correct
LC_ALL=Cbyte order.@shared/*resolves in Storybook viaTsconfigPathsPlugin. The only stale reference to the oldsrc/stories/keyboard-shortcuts.data.tspath is in a historical doc under.context/designs/.
If you take the menu.util.ts patch
platform-bible-react ships through its committed dist/, and the root npm run build does not rebuild it — the fix needs npm run build:pbr and a committed dist/ to actually ship, and CI cannot see that skew.
Verification
With all three patches applied: menu.util / tab-dropdown-menu / platform-menubar 17 passing, and keyboard-shortcut-hint.util / keyboard-shortcuts.data / keyboard-shortcut-keycaps.util / keyboard-shortcuts-catalog.component / menu-data.service-host 72 passing. ESLint and tsc clean on every touched file.
Two things I could not verify, so you can weigh them yourself. My worktree resolves node_modules from a checkout of a different branch, so a repo-wide npm run typecheck reports pre-existing shortcut does not exist errors out of a stale platform-bible-utils/dist — unrelated to this PR, and the same artifact makes one assertion in platform-tab-title.move-menu.test.tsx fail there while passing against this branch's own dist. And the E2E specs are comment-only changes that I did not run.
| // The item's accessible name is its label "Find" (%webView_platformScriptureEditor_openFind%) | ||
| // followed by a keyboard shortcut hint, so the match is anchored to a word boundary rather than | ||
| // to the end of the name. A bare prefix match would also select an item like "Find and replace…". | ||
| const findMenuItem = editorFrame.getByRole('menuitem', { name: /^find(\s|$)/i }); |
There was a problem hiding this comment.
The comment says the (\s|$) anchor keeps this locator from selecting "Find and replace…", but it does not: /^find(\s|$)/i matches "Find and replace…", because \s matches the space right after Find. Playwright tests the regex against the whole accessible name, so if such an item is ever added to this hamburger menu the locator resolves to two elements and the click fails with a strict-mode violation — the precise case the comment claims is handled.
The anchor's real effect is narrower: it excludes a longer single word, e.g. "Finder". That is worth keeping, so the smallest honest fix is to say what it actually does. replace.spec.ts:104 carries the identical comment and pattern and needs the same edit.
| const findMenuItem = editorFrame.getByRole('menuitem', { name: /^find(\s|$)/i }); | |
| // The item's accessible name is its label "Find" (%webView_platformScriptureEditor_openFind%) | |
| // followed by a keyboard shortcut hint, so the match cannot be anchored to the end of the name. | |
| // The trailing `(\s|$)` rules out only a longer single word, e.g. "Finder"; a sibling item whose | |
| // label begins "Find " — "Find and replace…" — matches too, and the click then fails with a | |
| // strict-mode violation rather than opening the wrong panel. That failure is the signal to narrow | |
| // this locator to the label element instead of the whole accessible name. | |
| const findMenuItem = editorFrame.getByRole('menuitem', { name: /^find(\s|$)/i }); |
The alternative is to tighten the pattern so it genuinely excludes the sibling — something like /^find(\s+\S*[⌃⌘⇧⌥+]\S*)?$/i. I would not: it encodes an assumption about the hint's spelling, which this PR has already changed once, and the strict-mode violation is a loud failure rather than a silent wrong-item click.
There was a problem hiding this comment.
Done, in both find.spec.ts and replace.spec.ts, using your wording.
| let keys = entry.keys.linux; | ||
| if (platform === 'darwin') keys = entry.keys.macOS; | ||
| else if (platform === 'win32') keys = entry.keys.windows; | ||
| return splitShortcutAlternatives(keys)[0]; |
There was a problem hiding this comment.
getShortcutHintForCommand returns splitShortcutAlternatives(keys)[0] with no handling for the catalog's no-equivalent marker, so an entry whose keys for one OS read — (no equivalent) would render that literal string in the menu's shortcut slot on that OS.
Nothing triggers it today, but the catalog already carries such an entry (tab-context-menu, macOS), and keyboard-shortcuts.model.ts invites exactly this combination — it forbids a command only for focus-blind and view-gated chords, not for a chord one OS simply lacks. keyboard-shortcut-keycaps.util.ts special-cases the same marker via NO_EQUIVALENT_PREFIX; this function is the other reader of keys and should agree.
Suggested fix — move the marker here so there is one definition, and rule it out:
| return splitShortcutAlternatives(keys)[0]; | |
| // An OS with no equivalent for the chord gets no hint, rather than a menu row reading | |
| // `— (no equivalent)` where a chord belongs | |
| if (startsWith(keys, NO_EQUIVALENT_PREFIX)) return undefined; | |
| return splitShortcutAlternatives(keys)[0]; |
That needs startsWith added to the platform-bible-utils import, and this exported alongside splitShortcutAlternatives:
/**
* Marker an operating system's `keys` uses when that OS has no equivalent for the shortcut, e.g. `—
* (no equivalent)`. It is prose for the catalog page, so a reader of `keys` must rule it out before
* treating the string as a chord.
*/
export const NO_EQUIVALENT_PREFIX = '—';with keyboard-shortcut-keycaps.util.ts importing it from here instead of declaring its own copy. The @returns line wants widening too: "or the entry has no chord on this platform".
A regression test that fails without the guard (expected '— (no equivalent)' to be undefined):
it('gives no hint on an operating system the catalog marks as having no equivalent', () => {
expect(
getShortcutHintForCommand('platform.openBookChapterControl', 'darwin', ENTRIES),
).toBeUndefined();
expect(getShortcutHintForCommand('platform.openBookChapterControl', 'win32', ENTRIES)).toBe(
'Shift+F10',
);
});There was a problem hiding this comment.
Done, with the marker moved here and the keycaps util importing it. One change: the test uses platform.openUserSettings, since the entry's command has to be a real PAPI command. I confirmed it fails without the guard.
| const columnKeysWithItems = new Set( | ||
| Object.entries(menuData.groups).flatMap(([groupKey, group]) => | ||
| // A group with no `column` belongs to a submenu, so it contributes to no column of this menu | ||
| 'column' in group && groupKeysWithItems.has(groupKey) ? [group.column] : [], |
There was a problem hiding this comment.
This decides "does the column have items" with only the column half of the predicate, but the renderer uses both halves. getGroupContent (tab-dropdown-menu.component.tsx:47) also renders any group whose key equals the column key:
('column' in group && group.column === columnOrSubMenuKey) || key === columnOrSubMenuKeySo for menu data where a column key coincides with a group key — a column ext.tools alongside a group keyed ext.tools — the two disagree: getGroupContent would render that group's items under the column, but getMenuSectionsWithItems reports the column as empty, so the whole section is dropped and the items disappear with no heading and no separator. Before this PR that column rendered; after it, it does not.
I scanned src/extension-host/data/menu.data.json and every extensions/src/*/contributions/menus.json and found zero column/group key overlaps, so this is latent, not live. It is cheap to remove though — the two sites just need one predicate:
/**
* Whether a group's items render under `columnOrSubMenuKey`: either the group names it as its
* `column`, or the group is the one keyed by it, which is how a submenu addresses its own group.
*
* `TabDropdownMenu` picks a column's groups with this and {@link getMenuSectionsWithItems} decides
* which columns have something to show with it, so "this column renders nothing" can never mean two
* different things.
*/
export function isGroupUnderColumnOrSubMenu(
groupKey: string,
group: Localized<MenuGroupDetailsInColumn | MenuGroupDetailsInSubMenu>,
columnOrSubMenuKey: string,
): boolean {
return (
('column' in group && group.column === columnOrSubMenuKey) || groupKey === columnOrSubMenuKey
);
}with this function becoming:
| 'column' in group && groupKeysWithItems.has(groupKey) ? [group.column] : [], | |
| export function getMenuSectionsWithItems(menuData: Localized<MultiColumnMenu>): MenuSection[] { | |
| const groupKeysWithItems = new Set(menuData.items.map((item) => item.group)); | |
| const groupsWithItems = Object.entries(menuData.groups).filter(([groupKey]) => | |
| groupKeysWithItems.has(groupKey), | |
| ); | |
| return getSortedMenuColumns(menuData.columns) | |
| .filter(({ columnKey }) => | |
| groupsWithItems.some(([groupKey, group]) => | |
| isGroupUnderColumnOrSubMenu(groupKey, group, columnKey), | |
| ), | |
| ) | |
| .map(({ columnKey, column }) => ({ columnKey, label: column.label })); | |
| } |
and getGroupContent's filter collapsing to .filter(([key, group]) => isGroupUnderColumnOrSubMenu(key, group, columnOrSubMenuKey)).
Your existing "gives no section of its own to a submenu group" test still passes — a submenu group key is not a column key, so that case is unaffected. Two tests pin the new behavior, and both go red against the current predicate; the component one fails with Unable to find an accessible element with the role "group" and name "Tools", which is the user-visible symptom:
it('gives a section to a column that a group of the same key fills, as the menu renders it', () => {
// `TabDropdownMenu` renders a group whose KEY is the column key under that column, so a column
// filled only that way still has something to show
const sections = getMenuSectionsWithItems({
...MENU_WITH_SUBMENU,
groups: {
...MENU_WITH_SUBMENU.groups,
'platformScriptureEditor.tools': {
order: 2,
menuItem: 'platformScriptureEditor.unusedMenuItem',
},
},
items: [
...MENU_WITH_SUBMENU.items.filter(
(item) => item.group !== 'platformScriptureEditor.inventories',
),
{
label: 'Open Markers Inventory…',
localizeNotes: '',
group: 'platformScriptureEditor.tools',
order: 1,
command: 'platformScripture.openMarkersInventory',
},
],
});
expect(sections.map(({ columnKey }) => columnKey)).toEqual([
'platform.app',
'platformScriptureEditor.tools',
]);
});There was a problem hiding this comment.
Done. getMenuSectionsWithItems and TabDropdownMenu now both use isGroupUnderColumnOrSubMenu, and I rebuilt dist. I confirmed the new test fails against the old check.
Menus built from contributed menu data now show each column's label as a section heading, and a menu item shows the keyboard shortcut that runs the same command. - TabDropdownMenu heads each non-empty column when two or more sections remain, for callers that opt in with `showSectionHeadings`. Platform.Bible's tab chrome (TabToolbar, TabFloatingMenu) turns it on, so a library consumer rendering its own menu data keeps the previous unlabeled look. A column with no items renders neither a heading nor a separator. - The keyboard shortcuts catalog moves to src/shared/data/ and gains an optional `command`. The extension-host menu data service sets MenuItemContainingCommand.shortcut on the localized menus it serves, written for the operating system it runs on, and the tab dropdown, app menubar, overlay context menu and tab menu display it. - Only chords that work everywhere their menu item appears carry a hint: Find and Insert comment. Insert footnote and Insert cross-reference are withheld because their chords are Standard-view only. keyboard-shortcuts.data.test.ts pins each hint and the menus that show it. - getCurrentMenus now awaits web view menu localization, so a heading cannot render as a raw localize key. - Shortcut hints keep their key order in right-to-left layouts. - Hint key names are English catalog strings; PT-4629 tracks localizing them. - The Reference -> Keyboard shortcuts page renders one Kbd per key inside a KbdGroup instead of one Kbd per combination, using a tested helper that reads the catalog's per-OS spellings (macOS symbols adjacent, Windows and Linux joined by a literal +, and the "no equivalent" marker as plain text). This lands the Kbd/KbdGroup guideline from PR #2583 alongside the first code to follow it, updated for the catalog's new path and for the Storybook deep-link convention. - Adds the catalog entries the structure-lock shortcuts were missing. Co-authored-by: Alex Mercado <alex_mercado@sil.org> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011vjExfhzr9ecFkGqtzbbAg
Give the no-equivalent marker one definition and rule it out of menu hints. An OS whose catalogued `keys` read `— (no equivalent)` now gets no hint rather than that literal string in the shortcut slot. `NO_EQUIVALENT_PREFIX` moves to `keyboard-shortcut-hint.util.ts`, which the keycaps util now imports instead of declaring its own copy, so both readers of `keys` agree. Give `getMenuSectionsWithItems` and `getGroupContent` one predicate. The former asked only whether a group named the column as its `column`, while the latter also renders a group whose KEY is the column key. For menu data where a column key coincides with a group key, the column rendered items but was reported empty, dropping the whole section. `isGroupUnderColumnOrSubMenu` is now the single answer to "does this group render under this column". Correct the Find e2e locator comments. `/^find(\s|$)/i` does not exclude "Find and replace…" — `\s` matches the space after "Find". The anchor rules out only a longer single word such as "Finder"; say that instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3ee579b to
1b19a82
Compare
…headings-shortcut-hints # Conflicts: # lib/platform-bible-react/dist/experimental.cjs # lib/platform-bible-react/dist/index.cjs # lib/platform-bible-react/dist/index.cjs.map # lib/platform-bible-react/dist/index.js # lib/platform-bible-react/dist/index.js.map # src/stories/keyboard-shortcuts-catalog/keyboard-shortcuts-catalog.component.tsx
Summary
Menus built from contributed menu data now show each column's label as a section heading, and a menu item shows the keyboard shortcut that runs its command.
RTL:


LTR:
Two chords get hints today — Find (
Ctrl+F) and Insert comment (Ctrl+Shift+N). Insert footnote and Insert cross-reference deliberately get none: their chords only work in Standard view, and a hint that lies is worse than no hint.62 files, but 14 are generated
platform-bible-react/dist— skip those. The real change is about 20 source files; the rest are tests, stories and docs.Where to look
Read these three first — they are the whole design:
src/extension-host/services/menu-data.service-host.tsshortcutonto every menu it serves, for the OS it is running on.src/shared/data/keyboard-shortcuts.data.tssrc/stories/; an entry's new optionalcommandis what opts it into a hint.lib/platform-bible-react/src/components/advanced/menus/tab-dropdown-menu.component.tsxThen, if you want the rest:
src/shared/utils/keyboard-shortcut-hint.util.tslib/platform-bible-utils/.../menus.model.tsMenuItemContainingCommand.shortcut. Display-only,@experimental, and the schema rejects it frommenus.jsonon purpose.lib/platform-bible-react/.../menus/menu.util.tsgetMenuSectionsWithItems/getSortedMenuColumns— which columns become sections, in what order.src/shared/utils/menu-document-combiner.ts%localize_key%. One-line spread fix + regression test.shadcn-ui/{dropdown-menu,context-menu,menubar}.tsxsrc/shared/data/keyboard-shortcuts.data.test.tscommandcan't silently add a hint somewhere new.Three decisions worth your scrutiny
Why the extension host does the join. The scripture editor's Project menu renders inside the editor's iframe, from an extension bundle that cannot import
src/shared— so the lookup can never reach it. Anything the renderer knows and the iframe doesn't has to arrive as data, and menu data is the pipe it already travels down. It is also the only choke point that covers all four menu surfaces at once.Section headings are opt-in (
showSectionHeadings, default off).TabDropdownMenuis exported fromplatform-bible-reactand its docs used to say column labels were ignored.TabToolbar/TabFloatingMenuturn it on, so the app gets headings while out-of-repo consumers are unaffected.Two catalog entries are outside the ticket's scope. The structure-lock shortcuts were missing from the catalog; they are accurate and verified against the handler, but they do widen the diff.
Also in here
Kbdper key in aKbdGroupinstead of oneKbdper whole combination, with a tested helper for the catalog's per-OS spellings..mdxlinks, which don't resolve in built Storybook.Known limits (deliberate, documented in the ADRs)
TODOat the lookup.commandis typed against this repo'sCommandNames.Testing
platform-bible-reactunit suite: 671 passing. Root suites touched: 108 passing.typecheck:e2eclean;build:typesregeneratespapi.d.tsbyte-identical;build:pbrclean.command.One honest caveat: the
'column' in groupguard inmenu.util.tsis a TypeScript narrowing, not a runtime gate — removing it produces identical output for every input, so no test can pin it. The compiler is what protects it. Its sibling guard is pinned.Risk
Low-to-moderate. Additive: menus that have no matching catalog entry render exactly as before. The behavior change is visual (headings + hints) and gated behind an opt-in prop for library consumers.
AI Involvement
AI-assisted — session. Claude Code drafted the implementation, the review fixes and this description; reviewed by the author.
🤖 Generated with Claude Code
This change is