Summary#
A menu in an all-day tool has three jobs: show which control opened it, be fast from the keyboard, and teach the
shortcuts. The kit's Menu does all three — it grows from its trigger's corner with a notch pointing back at it, the
platform moves through its items with the arrow keys and type-ahead, and a MenuItem shows its shortcut in a keycap.
CommandMenu is the launcher (⌘K) every such tool has, and shortcut: binds a key combo to any button. A key is
written ONCE, on the control it presses; every hint of it is derived from that binding, so no hint can advertise a key
that does nothing.
Signature#
Box(position: Position.Relative) {
IconButton("Actions", ToggleMenu) { Icon(Icons.Dots); }
if (open) {
Backdrop(CloseMenu); // click away, or Escape
Menu(from: MenuFrom.BelowEnd, label: "Ticket actions") {
MenuItem("Assign to me", AssignMe, icon: Icons.User); // its key chip comes from AssignMe's binding
MenuItem("Waiting on customer", SetPending, checked: status == Status.Pending);
MenuItem("Delete", Delete, tone: Tone.Danger);
}
}
}
CommandMenu(commands, onDismiss: ClosePalette) // ⌘K — a Command[] of Label/OnPress/Icon/Group
Button("Send", onPress: Send, shortcut: "Mod+Enter") // the combo presses the button from anywhere
Button("Send", onPress: Send, shortcut: "Mod+Enter", showShortcut: true) // "Send ⌘↵" on its face
KeyHint("Mod+K") // a binding as keycaps: ⌘K on a Mac, Ctrl+K elsewhere
KeyHintOf(AssignMe) // the key bound on this page to AssignMe, or nothing
KeyboardSheet() // `?` lists every key on the page (the shells mount one)Description#
A menu grows from the thing that opened it#
from: says where the menu hangs, and so which corner it grows out of: MenuFrom.BelowStart (the default),
BelowEnd for a trigger at the right edge, AboveStart for one at the foot of a panel, Beside for a rail. The
menu is placed against its nearest positioned ancestor, so put the trigger and the menu together in a
Box(position: Position.Relative). The opening is a short scale-and-slide from that corner (160ms), with a notch on
the edge facing the trigger; under reduced motion it simply fades.
The keyboard is the platform's#
Any element with role: UiRole.Menu — every kit menu is one — gets the keyboard the ARIA menu pattern describes,
with nothing written: when it opens, its first item takes focus; ↓/↑ move between items and wrap; Home/End jump;
typing a letter moves to the next item starting with it (a quick run of letters is one word); Enter presses;
Escape closes it through its Backdrop. An item is anything with role: UiRole.MenuItem (as MenuItem is) that is
not disabled; a menu nested inside keeps its own items. A field inside a menu keeps its letters — only ↓/↑ leave it.
And when it closes, the keyboard goes back to what opened it — after Escape, a picked item, or a click away, focus
is on the trigger again, so the next Tab carries on from there rather than from the top of the page. Two exceptions,
both deliberate: an item that opens a DIALOG hands the keyboard into the dialog (and it comes back to the trigger when
the dialog closes), and focus the person has already put somewhere else — a field they clicked into — stays there. In a
test: Ui.Click("Actions"); Ui.Press("Escape"); Assert.Focused("Actions");.
⌘K — the command menu#
CommandMenu(commands, onDismiss) is a filter field over every Command the app offers, grouped under each one's
Group, each row showing the key bound on the page to its OnPress — derived, so a command whose action no control
binds shows none. Typing narrows it, ↓ moves into the results, Enter runs the first match, and
a command runs with the menu already closed. Open it from ShellSearch, whose box ⌘K (Ctrl+K elsewhere) presses
from anywhere on the page.
[Route("/")]
[AllowAnonymous]
component Desk() {
bool palette = false;
string last = "";
action Open() { palette = true; }
action Close() { palette = false; }
action Assign() { last = "assigned"; }
action Snooze() { last = "snoozed"; }
render {
Stack(gap: 3, p: 6) {
Button("Search", onPress: Open, shortcut: "Mod+K");
// The command menu's "Assign to me" row shows I, because this button runs the same action and binds it.
Button("Assign to me", onPress: Assign, shortcut: "I");
Text(last);
if (palette) {
CommandMenu([
new Command { Label = "Assign to me", Group = "Ticket", Icon = Icons.User, OnPress = Assign },
new Command { Label = "Snooze until tomorrow", Group = "Ticket", Icon = Icons.Clock, OnPress = Snooze },
], onDismiss: Close);
}
}
}
}shortcut: — a combo that presses an element#
shortcut: "Mod+K" on any element a reader presses makes that key combo press it from anywhere on the page — through
its own onClick, so a shortcut can never do something its button does not. Mod is ⌘ on a Mac and Ctrl
elsewhere (both work everywhere); write other modifiers as Shift, Alt, Control, Meta, and several combos
separated by a space. A bare key ("J", "?") is ignored while the reader is typing in a field, so a list's
J/K never fires from inside a reply box. When two mounted elements of ONE owner share a combo, the one mounted
last wins — a dialog's Mod+Enter beats the page's while the dialog is open (two owners: see [[#conflicts]]). It is announced to a screen reader as
aria-keyshortcuts. A shifted symbol is its own key: "?" and "Shift+?" are the same binding, while
"Shift+A" and "A" are two. A control the reader cannot reach — on a retained route that is hidden, or inside
inert — never takes a key.
One key, two owners#
A page shows controls written by different code — the app's own and each kit's. When two OWNERS bind the same key (the app and a kit, or two kits), the platform resolves it rather than letting whichever mounted last take it:
- the app's own binding keeps the key;
- otherwise the binding that comes first on the page keeps it;
- the other is pressed with Shift added (
M→Shift+M,Mod+K→Mod+Shift+K) — and its hint chip, its tip,aria-keyshortcutsand the?sheet all say so.
A key that cannot take Shift (it already has it, a symbol such as ?, or a sequence) stays as it is, and the last one
shown wins. The owner of a binding is the code that declares the action it runs (the kit's list, the app's page).
The compiler collects every shortcut: a page can show — through the components it renders, a kit's included — and
warns SHORTCUT_CONFLICT, naming both owners and the key the platform moves to; the browser console says the same at
run time for a key set in a setting (a kit's key handed in through its setup), which the compiler cannot see. To choose
yourself, bind a different key — an app's own assignment always wins.
A sequence — "G then M"#
shortcut: "G then M" is two presses: G, let go, then M within a second and a half — the "go to" keys of an inbox. The
first press waits for the second instead of firing; a pair that binds nothing is forgotten and the second key is read
on its own (so G then J still moves down a list). A key that begins a sequence belongs to the sequence. A
NavItem takes one too (Shortcut = "G then M"), so a rail row is reached from anywhere. The ? sheet and a control's
tip show it as it is pressed, a keycap per step; ARIA has no spelling for a sequence, so it is not announced as
aria-keyshortcuts. In a test, press it the way a person does: Ui.Press("G then M").
Where the hints come from#
Every hint is DERIVED from the shortcut: on the control the key presses; none is typed a second time.
| hint | how | derived from |
|---|---|---|
| a menu row's chip | MenuItem(label, onPress) — automatic | the key bound on the page to onPress; nothing when none. A row that runs a wrapper (() => Run(c)) names the action with hintFor: c.OnPress |
| a ⌘K row's chip | Command { OnPress = Assign } — automatic | the key bound to OnPress |
| a tip on hover and keyboard focus | Button/IconButton with shortcut: — automatic | the button's own name and binding |
| the key on a button's face | Button(…, shortcut: "Mod+Enter", showShortcut: true) → "Send ⌘↵" | the button's own binding |
| a chip anywhere | KeyHintOf(action), or KeyHint("Mod+K") for a binding constant | the live binding / the binding as written |
the ? sheet | KeyboardSheet() — every work shell mounts one | every key a press would reach on the page |
One spelling, every platform. Write Mod — never ⌘ or Ctrl — and the platform draws it for the reader: ⌘K,
⇧⌘P, ⌘↵ on a Mac; Ctrl+K, Ctrl+Shift+P, Ctrl+Enter elsewhere. The atom props underneath are ambient and
usable on any element: keycap: "Mod+K", keycapOf: Assign (hidden when nothing binds the action), and
shortcutList: true (the sheet's list).
? — every key on the page#
KeyboardSheet() binds ?: from anywhere (except while typing in a field) it opens a dialog listing every key a press
would reach, each row the control's name and its key, grouped by the labelled region the control lives in (the page
header, the main navigation, a ticket, a dialog). ? again, or Escape, closes it. It is generated from the live
bindings, so it never lists a key that does nothing and never misses one that works. The sheet's own control is
invisible until it takes keyboard focus, so a keyboard reader can find it too. An app with no shell renders
KeyboardSheet() once in its layout.
Testing them#
Open a menu by its trigger and press an item by its label: Ui.Click("Priya Nandan"); Ui.Click("Account"). Press
a shortcut with a chord: Ui.Press("Mod+K"), Ui.Press("Shift+Tab"). Narrow a command menu with
Ui.Fill("Type a command or search", "stock"). Open the sheet with Ui.Press("?") and read it with
Assert.Visible("New order", within: "Keyboard shortcuts").
See also#
- Osysharp.Ui (the UI kit) — every control the kit ships
- keys —
keys:, a surface that owns keys while it has focus (a board, a canvas) - Dialog.Open / Dialog.Ask / Dialog.Confirm / Dialog.Discard — overlays that own a unit of work
- App shells — the shells whose identity menus and search box use all of this
- Ui — drive the app's UI from a test — locating, clicking and pressing in a test