Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

Menus, the command menu, and keyboard shortcuts

Menu(from: MenuFrom.BelowEnd) { MenuItem(label, onPress, icon:, checked:, hintFor:) } · CommandMenu(commands, onDismiss) · Button(…, shortcut: "Mod+K", showShortcut:) · KeyHint("Mod+K") · KeyHintOf(action) · KeyboardSheet()

The kit's menus grow out of the control that opened them and keep a notch pointing back at it, carry icons, key hints and a checked mark, and are driven from the keyboard by the platform (arrows, Home/End, type-ahead). `CommandMenu` is ⌘K. `shortcut:` on any pressable element makes a key combo press it from anywhere on the page, and every hint of that key — a menu row's chip, a button's tip, the `?` sheet — is derived from that one binding.

preview1 example compiled by CIuikitmenuskeyboard

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-keyshortcuts and 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.

hinthowderived from
a menu row's chipMenuItem(label, onPress) — automaticthe 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 chipCommand { OnPress = Assign } — automaticthe key bound to OnPress
a tip on hover and keyboard focusButton/IconButton with shortcut: — automaticthe button's own name and binding
the key on a button's faceButton(…, shortcut: "Mod+Enter", showShortcut: true) → "Send ⌘↵"the button's own binding
a chip anywhereKeyHintOf(action), or KeyHint("Mod+K") for a binding constantthe live binding / the binding as written
the ? sheetKeyboardSheet() — every work shell mounts oneevery 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#

Related

Osysharp.Ui (the UI kit)

The bundled UI kit — ready-made styled controls like `Button`, the shared design-system vocabularies (`Tone`, `Size`)…

Dialog.Open / Dialog.Ask / Dialog.Confirm / Dialog.Discard

Opens a component in an overlay above the current screen, with a unit of work you choose: `Inherit` makes its edits a…

keys

`keys:` declares that an element owns a set of keys: it becomes focusable, those keys stop scrolling the page, and…

animation — keyframe motion, on entry, on exit, in a loop, or as it scrolls into view

An `animation` block declares reusable keyframe motion — an entrance, a shimmer, a pulse, a flash that decays. Its…

App shells

An app shell is the frame around every page: a brand, a navigation tree, the signed-in person and a menu behind them, a…

Ui — drive the app's UI from a test

Drive the real UI from a test: navigate to a route, click what a person would click, and assert on what the screen…