staffa 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +99 -271
  2. package/dist/components/autocomplete.js +4 -5
  3. package/dist/components/box.js +11 -21
  4. package/dist/components/button.d.ts +20 -5
  5. package/dist/components/button.js +55 -47
  6. package/dist/components/buttonChooser.js +1 -3
  7. package/dist/components/checkbox.js +1 -2
  8. package/dist/components/dialog.d.ts +9 -2
  9. package/dist/components/dialog.js +29 -35
  10. package/dist/components/field.d.ts +5 -8
  11. package/dist/components/field.js +4 -6
  12. package/dist/components/form.d.ts +5 -7
  13. package/dist/components/form.js +6 -9
  14. package/dist/components/keyhelp.d.ts +22 -0
  15. package/dist/components/keyhelp.js +91 -0
  16. package/dist/components/main.js +190 -318
  17. package/dist/components/menu.d.ts +36 -9
  18. package/dist/components/menu.js +193 -144
  19. package/dist/components/panels.d.ts +152 -232
  20. package/dist/components/panels.js +341 -556
  21. package/dist/components/select.js +1 -3
  22. package/dist/components/tabs.d.ts +10 -13
  23. package/dist/components/tabs.js +40 -63
  24. package/dist/components/textline.d.ts +3 -5
  25. package/dist/components/textline.js +3 -5
  26. package/dist/components/toast.d.ts +1 -3
  27. package/dist/components/toast.js +3 -6
  28. package/dist/components/tooltip.d.ts +4 -5
  29. package/dist/components/tooltip.js +13 -22
  30. package/dist/core.d.ts +17 -39
  31. package/dist/core.js +13 -35
  32. package/dist/icons-helpers.d.ts +3 -3
  33. package/dist/icons-helpers.js +6 -11
  34. package/dist/index.d.ts +3 -1
  35. package/dist/index.js +5 -4
  36. package/dist/keys.d.ts +92 -0
  37. package/dist/keys.js +279 -0
  38. package/dist/staffa.esm.js +1 -1
  39. package/dist/theme.d.ts +4 -10
  40. package/dist/theme.js +58 -123
  41. package/package.json +2 -2
  42. package/skill/ButtonOptions.md +12 -0
  43. package/skill/DialogOptions.md +11 -2
  44. package/skill/FieldOptions.md +3 -5
  45. package/skill/IconButtonOptions.md +8 -0
  46. package/skill/MenuItem.md +22 -3
  47. package/skill/Panel.md +8 -0
  48. package/skill/SKILL.md +161 -294
  49. package/skill/addTooltip.md +4 -5
  50. package/skill/bindKey.md +51 -0
  51. package/skill/box.md +1 -1
  52. package/skill/form.md +5 -7
  53. package/skill/formatKey.md +21 -0
  54. package/skill/iconButton.md +4 -5
  55. package/skill/scrollStrip.md +7 -9
  56. package/skill/showFloatingMenu.md +2 -2
  57. package/skill/showKeyHelp.md +17 -0
  58. package/skill/tabs.md +3 -4
  59. package/skill/textline.md +3 -5
  60. package/src/components/autocomplete.ts +4 -5
  61. package/src/components/box.ts +11 -21
  62. package/src/components/button.ts +70 -47
  63. package/src/components/buttonChooser.ts +1 -3
  64. package/src/components/checkbox.ts +1 -2
  65. package/src/components/dialog.ts +39 -37
  66. package/src/components/field.ts +7 -11
  67. package/src/components/form.ts +6 -9
  68. package/src/components/keyhelp.ts +96 -0
  69. package/src/components/main.ts +194 -318
  70. package/src/components/menu.ts +209 -146
  71. package/src/components/panels.ts +389 -618
  72. package/src/components/select.ts +1 -3
  73. package/src/components/tabs.ts +40 -63
  74. package/src/components/textline.ts +3 -5
  75. package/src/components/toast.ts +4 -9
  76. package/src/components/tooltip.ts +13 -22
  77. package/src/core.ts +17 -43
  78. package/src/icons-helpers.ts +6 -11
  79. package/src/index.ts +5 -4
  80. package/src/keys.ts +300 -0
  81. package/src/theme.ts +58 -123
  82. package/skill/Attributes.md +0 -10
@@ -19,25 +19,20 @@ export function setDefaults(opts) {
19
19
  Object.assign(defaults, opts);
20
20
  }
21
21
  /**
22
- * Draw a single icon: build one `<svg>` through Aberdeen (applying the
22
+ * The shared body behind every icon: build one `<svg>` through Aberdeen (from the
23
23
  * {@link IconOptions} or the module defaults) and fill in its inner markup.
24
- *
25
- * This is the shared body behind every icon. {@link mk} hands it the icon's
26
- * `inner` markup, so the per-icon closures stay tiny instead of each carrying
27
- * a copy of this logic.
28
24
  */
29
25
  function drawIcon(inner, opts) {
30
26
  const size = opts.size ?? defaults.size;
31
27
  const el = A('svg.s-icon aria-hidden=true viewBox="0 0 24 24" fill=none', "width=", size, "height=", size, "stroke=", opts.color ?? defaults.color, "stroke-width=", opts.strokeWidth ?? defaults.strokeWidth, "stroke-linecap=", opts.cap ?? defaults.cap, "stroke-linejoin=", opts.join ?? defaults.join, opts.attrs);
32
- // Drop the primitives in via innerHTML: setting it on the `<svg>` itself
33
- // makes the parser put the children in the SVG namespace. (Aberdeen's
34
- // `html=` builds them in the HTML namespace, leaving them non-rendering.)
28
+ // innerHTML on the `<svg>` itself puts the children in the SVG namespace;
29
+ // Aberdeen's `html=` would build them in the HTML namespace, non-rendering.
35
30
  el.innerHTML = inner;
36
31
  }
37
32
  /**
38
- * Turn a piece of inner-SVG markup into an icon draw-function. The returned
39
- * function emits a freshly-built `<svg>` into the current Aberdeen scope,
40
- * applying the {@link IconOptions} (or the module defaults).
33
+ * Turn a piece of inner-SVG markup into an icon draw-function: it emits a freshly
34
+ * built `<svg>` into the current Aberdeen scope, applying the {@link IconOptions}
35
+ * (or the module defaults).
41
36
  */
42
37
  export function mk(inner) {
43
38
  return (opts = {}) => drawIcon(inner, opts);
package/dist/index.d.ts CHANGED
@@ -27,9 +27,11 @@
27
27
  * Every component takes a single typed options object (see each function's
28
28
  * docs). The options object — or parts of it — may be an Aberdeen proxy, in
29
29
  * which case the component re-renders the affected parts in place when you
30
- * mutate it. See `AGENTS.md` for the design philosophy.
30
+ * mutate it. See `README.md` for the design philosophy.
31
31
  */
32
32
  export { setDarkMode, getDarkMode } from "./theme.js";
33
+ export { formatKey, bindKey } from "./keys.js";
34
+ export { showKeyHelp, setKeyHelp } from "./components/keyhelp.js";
33
35
  export { autocomplete, type AutocompleteOptions, type AutocompleteOptionInput } from "./components/autocomplete.js";
34
36
  export { box, type BoxOptions } from "./components/box.js";
35
37
  export { button, iconButton, type ButtonOptions, type IconButtonOptions } from "./components/button.js";
package/dist/index.js CHANGED
@@ -27,12 +27,13 @@
27
27
  * Every component takes a single typed options object (see each function's
28
28
  * docs). The options object — or parts of it — may be an Aberdeen proxy, in
29
29
  * which case the component re-renders the affected parts in place when you
30
- * mutate it. See `AGENTS.md` for the design philosophy.
30
+ * mutate it. See `README.md` for the design philosophy.
31
31
  */
32
- // Importing the theme module installs spacing vars, the reactive theme and the
33
- // base stylesheet. Customise it from your app with A.insertGlobalCss (see
34
- // theme.ts); toggle modes with setDarkMode / getDarkMode.
32
+ // Importing theme.js installs the spacing vars, the reactive theme and the base
33
+ // stylesheet.
35
34
  export { setDarkMode, getDarkMode } from "./theme.js";
35
+ export { formatKey, bindKey } from "./keys.js";
36
+ export { showKeyHelp, setKeyHelp } from "./components/keyhelp.js";
36
37
  export { autocomplete } from "./components/autocomplete.js";
37
38
  export { box } from "./components/box.js";
38
39
  export { button, iconButton } from "./components/button.js";
package/dist/keys.d.ts ADDED
@@ -0,0 +1,92 @@
1
+ import type { Slot } from "./core.js";
2
+ /** One registered shortcut, as {@link getActiveKeyBindings} hands them out. */
3
+ export interface KeyBinding {
4
+ /**
5
+ * What it does — a rich-text string or draw function, shown in the shortcut
6
+ * overview. Without one, the binding stays out of the overview.
7
+ */
8
+ description?: Slot;
9
+ /**
10
+ * Runs on the keystroke, after `preventDefault()`. Without one, the binding
11
+ * only *describes* the key (which is handled elsewhere) — it is listed and
12
+ * shadows same-key bindings further out, but the keystroke passes untouched.
13
+ */
14
+ press?: (e: KeyboardEvent) => void;
15
+ /** Keeps working while a modal owns the keyboard. */
16
+ global?: boolean;
17
+ /** The same-key binding this one shadows, restored when this one is removed. */
18
+ prev?: KeyBinding;
19
+ }
20
+ /**
21
+ * Give the current element the keyboard, until the returned release function is
22
+ * called: normal bindings drawn inside it register at it, so they die with it
23
+ * and never fire once focus (and the walk up from it) has moved to a later
24
+ * claim — while bindings from outside any claim are silenced, the `global`
25
+ * ones excepted. What a modal dialog does while it is up.
26
+ */
27
+ export declare function claimKeyboard(): () => void;
28
+ /** The element whose claim owns the keyboard right now: the top claim, or the body. */
29
+ export declare function keyboardOwner(): Element;
30
+ /**
31
+ * What a keypress aimed at `target` — the focused element, typically — could do
32
+ * right now: for each combination, the binding the walk up from `target` would
33
+ * find, minus the keystrokes `target` keeps for itself. Innermost first, as
34
+ * `[keyStr, binding]` pairs. A snapshot, not reactive.
35
+ */
36
+ export declare function getActiveKeyBindings(target: Element | null): Array<[string, KeyBinding]>;
37
+ /**
38
+ * Bind a keyboard shortcut, for as long as the calling scope lives.
39
+ *
40
+ * **The spec** is a `KeyboardEvent.key` value — `"k"`, `"f2"`, `"escape"`
41
+ * (`"esc"`), `"space"`, `"arrowdown"`, a bare `"?"` — optionally prefixed by
42
+ * `mod+` (⌘ on a Mac, Ctrl elsewhere) and/or `shift+`, in that order:
43
+ * `"mod+k"`, `"shift+f2"`, `"mod+shift+b"`. Case doesn't matter. A character
44
+ * Shift itself types is written as that character — `"?"`, never `"shift+/"` —
45
+ * so a combination works on every keyboard layout. No other modifiers are
46
+ * offered: Alt and the ⊞ key belong to the browser and the OS, which also
47
+ * keep some `mod` combinations for themselves — T, N, W, Q and the digits
48
+ * among them, while K, B, E, `/` and `.` are safely yours. Everything Staffa
49
+ * takes a `key` option is spelled this way, and {@link formatKey} turns it
50
+ * back into `"⇧⌘K"`/`"Ctrl+Shift+K"`.
51
+ *
52
+ * `description` is what the shortcut overview (see {@link showKeyHelp}) lists
53
+ * the binding as — a rich-text string or draw function; without one the
54
+ * binding stays out of the overview. Omit `press` to merely *describe* a key
55
+ * your app handles by other means, so the overview can still tell the user
56
+ * about it.
57
+ *
58
+ * A handler of the app's own that ran `preventDefault()` first always wins,
59
+ * and keystrokes the focused element owns (typing into a field, Enter on a
60
+ * link) are left to it. Otherwise `mode` says who else can reach the binding:
61
+ *
62
+ * - `"normal"` (the default): works app-wide, but is silenced while a modal
63
+ * dialog from outside it is up. Binding the same combination again shadows
64
+ * the earlier binding until the new scope dies — so a state can take a key
65
+ * over temporarily.
66
+ * - `"global"`: keeps working even over a modal.
67
+ * - `"local"`: only fires while the keyboard focus is inside the current
68
+ * element — for a shortcut that belongs to one row or panel of many.
69
+ * - an `Element`: like `"local"`, but for that element rather than the
70
+ * current one.
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * S.bindKey("mod+k", "Search", openSearch);
75
+ * S.bindKey("mod+z", "Undo"); // describe only: handled by our own listener
76
+ * ```
77
+ */
78
+ export declare function bindKey(spec: string, description?: Slot, press?: (e: KeyboardEvent) => void, mode?: "normal" | "global" | "local" | Element): void;
79
+ /**
80
+ * Write a key combination the way the platform writes it: `"⇧⌘K"` on a Mac,
81
+ * `"Ctrl+Shift+K"` everywhere else. What the components put in their key hints —
82
+ * use it for the same hint elsewhere in your app, so both spell the shortcut the
83
+ * way this machine's user expects. Pass `aria: true` for the `aria-keyshortcuts`
84
+ * spelling instead: full modifier names and real key names,
85
+ * `"Meta+Shift+K"`/`"Control+Shift+K"`.
86
+ *
87
+ * @example
88
+ * ```ts
89
+ * S.button({ content: `Search ${S.formatKey("mod+k")}`, click: search });
90
+ * ```
91
+ */
92
+ export declare function formatKey(spec: string, aria?: boolean): string;
package/dist/keys.js ADDED
@@ -0,0 +1,279 @@
1
+ import A from "aberdeen";
2
+ /**
3
+ * Keyboard shortcuts: a registry of live bindings served by one document-level
4
+ * `keydown` listener, and writing a combination back out the way this platform
5
+ * writes it. The spelling of a combination is documented on {@link bindKey}.
6
+ */
7
+ /**
8
+ * Whether this is an Apple platform, where the modifier is ⌘ rather than Ctrl.
9
+ * `platform` is deprecated but frozen rather than removed, and the user agent
10
+ * behind it says Macintosh anyway — an iPad asking for the desktop site
11
+ * included, which is the right answer here.
12
+ */
13
+ const IS_APPLE = typeof navigator !== "undefined" && /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent);
14
+ /** Spellings people reach for that aren't `KeyboardEvent.key` values. */
15
+ const ALIASES = { esc: "escape", space: " " };
16
+ /** How a key is written in a hint, where its canonical name won't do. */
17
+ const GLYPHS = { " ": "Space", escape: "Esc", arrowup: "↑", arrowdown: "↓", arrowleft: "←", arrowright: "→" };
18
+ /**
19
+ * Live bindings, kept per element per canonical key string. The element a
20
+ * binding is stored at decides when it applies: the keydown handler walks up
21
+ * the tree from the focused element and takes the first match.
22
+ */
23
+ const bindings = new WeakMap();
24
+ /** Elements that claimed the keyboard, in claiming order. The last one rules. */
25
+ const modalStack = [];
26
+ /**
27
+ * Give the current element the keyboard, until the returned release function is
28
+ * called: normal bindings drawn inside it register at it, so they die with it
29
+ * and never fire once focus (and the walk up from it) has moved to a later
30
+ * claim — while bindings from outside any claim are silenced, the `global`
31
+ * ones excepted. What a modal dialog does while it is up.
32
+ */
33
+ export function claimKeyboard() {
34
+ const el = A();
35
+ if (!el)
36
+ throw new Error("Staffa: claimKeyboard needs a current element");
37
+ modalStack.push(el);
38
+ return () => {
39
+ const i = modalStack.indexOf(el);
40
+ if (i >= 0)
41
+ modalStack.splice(i, 1);
42
+ };
43
+ }
44
+ /**
45
+ * A spec reduced to its canonical form, the registry's index — simply the spec
46
+ * lower-cased, aliases resolved: an optional `mod+`, an optional `shift+`,
47
+ * then the `KeyboardEvent.key` value. Throws on anything else, loudly: a
48
+ * shortcut is invisible until it fails to fire, so a typo must not wait for
49
+ * the keystroke that needed it.
50
+ */
51
+ function canonKey(spec) {
52
+ const [, mod, shift, name] = /^(mod\+)?(shift\+)?(.*)$/i.exec(spec);
53
+ let key = name.toLowerCase();
54
+ key = ALIASES[key] ?? key;
55
+ if (!key || (key.length > 1 && /[-+]/.test(key))) {
56
+ throw new Error(`Staffa: can't parse key "${spec}" — write "k", "f2", "mod+k" or "mod+shift+f2"`);
57
+ }
58
+ // The typed character is a combination's one name: `?` is what shift-/ types.
59
+ if (shift && key.toUpperCase() === key) {
60
+ throw new Error(`Staffa: "${spec}" — write the shifted character itself ("?", not "shift+/")`);
61
+ }
62
+ return (mod ? "mod+" : "") + (shift ? "shift+" : "") + key;
63
+ }
64
+ /** The canonical key string for a keystroke, or `null` for one that can't be a shortcut. */
65
+ function canonEvent(e) {
66
+ // Alt is never bound, nor is Ctrl on a Mac: a keystroke holding one down
67
+ // belongs to the app, the browser or the OS — not to us.
68
+ if (e.altKey || (IS_APPLE ? e.ctrlKey : e.metaKey))
69
+ return null;
70
+ const key = e.key.toLowerCase();
71
+ // Shift counts only where it doesn't already shape the typed character: it
72
+ // turns k into K and holds during F2, but *is* the difference between / and
73
+ // ? — and Caps Lock's capitals don't register.
74
+ const shift = e.shiftKey && (key.length > 1 || e.key.toUpperCase() !== key);
75
+ return ((IS_APPLE ? e.metaKey : e.ctrlKey) ? "mod+" : "") + (shift ? "shift+" : "") + key;
76
+ }
77
+ /**
78
+ * Whether the focused element keeps this keystroke for itself: anything being
79
+ * typed into keeps the unmodified keys (Escape excepted — it never types), a
80
+ * button-like control keeps its activation keys, and a link keeps Enter even
81
+ * modified — that one is the keyboard's own open-in-a-new-tab, the counterpart
82
+ * of a ctrl-click. The one answer the matcher and the `?` overview share, so
83
+ * what is listed and what fires can never disagree.
84
+ */
85
+ function keptByTarget(keyStr, target) {
86
+ if (!(target instanceof HTMLElement))
87
+ return false;
88
+ const mod = keyStr.startsWith("mod+");
89
+ // Modifiers stripped: a shifted Enter is still the link's new-window Enter.
90
+ const key = keyStr.replace(/^(mod\+)?(shift\+)?/, "");
91
+ if (key === "enter" && target.closest("a[href]") != null)
92
+ return true;
93
+ if (!mod && (key === "enter" || key === " ") && target.closest("button, summary, [role=button]") != null)
94
+ return true;
95
+ const tag = target.tagName;
96
+ return !mod && key !== "escape" &&
97
+ (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable);
98
+ }
99
+ /** Whether a binding found at `el` applies, given the current keyboard claim. */
100
+ function reachable(el, b) {
101
+ const modal = modalStack[modalStack.length - 1];
102
+ return b.global === true || !modal || modal.contains(el);
103
+ }
104
+ /** The innermost claiming element containing `el`, if any. */
105
+ function claimFor(el) {
106
+ for (let i = modalStack.length - 1; i >= 0; i--) {
107
+ if (modalStack[i].contains(el))
108
+ return modalStack[i];
109
+ }
110
+ }
111
+ /** The element whose claim owns the keyboard right now: the top claim, or the body. */
112
+ export function keyboardOwner() {
113
+ return modalStack[modalStack.length - 1] ?? document.body;
114
+ }
115
+ /**
116
+ * Where the binding search starts: the focused element — or the claiming modal
117
+ * itself when focus has strayed outside it (a dialog holding nothing
118
+ * focusable, a click that blurred to the body), so a modal never loses its own
119
+ * keys.
120
+ */
121
+ function walkStart(target) {
122
+ const modal = modalStack[modalStack.length - 1];
123
+ return modal && !(target && modal.contains(target)) ? modal : target ?? document.body;
124
+ }
125
+ let listening = false;
126
+ function onKeydown(e) {
127
+ // Something already answered for this keystroke — a handler of the app's own,
128
+ // a menu's or an autocomplete's; element listeners run before this one.
129
+ if (e.defaultPrevented || e.repeat || e.isComposing)
130
+ return;
131
+ const keyStr = canonEvent(e);
132
+ const target = e.target instanceof Element ? e.target : null;
133
+ if (keyStr == null || keptByTarget(keyStr, target))
134
+ return;
135
+ for (let el = walkStart(target); el; el = el.parentElement) {
136
+ const b = bindings.get(el)?.get(keyStr);
137
+ if (b && reachable(el, b)) {
138
+ // A describe-only binding still ends the search: the key is somebody
139
+ // else's, and the keystroke passes untouched.
140
+ if (b.press) {
141
+ e.preventDefault();
142
+ b.press(e);
143
+ }
144
+ return;
145
+ }
146
+ }
147
+ }
148
+ /**
149
+ * What a keypress aimed at `target` — the focused element, typically — could do
150
+ * right now: for each combination, the binding the walk up from `target` would
151
+ * find, minus the keystrokes `target` keeps for itself. Innermost first, as
152
+ * `[keyStr, binding]` pairs. A snapshot, not reactive.
153
+ */
154
+ export function getActiveKeyBindings(target) {
155
+ const found = new Map();
156
+ for (let el = walkStart(target); el; el = el.parentElement) {
157
+ const map = bindings.get(el);
158
+ if (map) {
159
+ for (const [keyStr, b] of map) {
160
+ if (!found.has(keyStr) && reachable(el, b) && !keptByTarget(keyStr, target))
161
+ found.set(keyStr, b);
162
+ }
163
+ }
164
+ }
165
+ return [...found];
166
+ }
167
+ /**
168
+ * Bind a keyboard shortcut, for as long as the calling scope lives.
169
+ *
170
+ * **The spec** is a `KeyboardEvent.key` value — `"k"`, `"f2"`, `"escape"`
171
+ * (`"esc"`), `"space"`, `"arrowdown"`, a bare `"?"` — optionally prefixed by
172
+ * `mod+` (⌘ on a Mac, Ctrl elsewhere) and/or `shift+`, in that order:
173
+ * `"mod+k"`, `"shift+f2"`, `"mod+shift+b"`. Case doesn't matter. A character
174
+ * Shift itself types is written as that character — `"?"`, never `"shift+/"` —
175
+ * so a combination works on every keyboard layout. No other modifiers are
176
+ * offered: Alt and the ⊞ key belong to the browser and the OS, which also
177
+ * keep some `mod` combinations for themselves — T, N, W, Q and the digits
178
+ * among them, while K, B, E, `/` and `.` are safely yours. Everything Staffa
179
+ * takes a `key` option is spelled this way, and {@link formatKey} turns it
180
+ * back into `"⇧⌘K"`/`"Ctrl+Shift+K"`.
181
+ *
182
+ * `description` is what the shortcut overview (see {@link showKeyHelp}) lists
183
+ * the binding as — a rich-text string or draw function; without one the
184
+ * binding stays out of the overview. Omit `press` to merely *describe* a key
185
+ * your app handles by other means, so the overview can still tell the user
186
+ * about it.
187
+ *
188
+ * A handler of the app's own that ran `preventDefault()` first always wins,
189
+ * and keystrokes the focused element owns (typing into a field, Enter on a
190
+ * link) are left to it. Otherwise `mode` says who else can reach the binding:
191
+ *
192
+ * - `"normal"` (the default): works app-wide, but is silenced while a modal
193
+ * dialog from outside it is up. Binding the same combination again shadows
194
+ * the earlier binding until the new scope dies — so a state can take a key
195
+ * over temporarily.
196
+ * - `"global"`: keeps working even over a modal.
197
+ * - `"local"`: only fires while the keyboard focus is inside the current
198
+ * element — for a shortcut that belongs to one row or panel of many.
199
+ * - an `Element`: like `"local"`, but for that element rather than the
200
+ * current one.
201
+ *
202
+ * @example
203
+ * ```ts
204
+ * S.bindKey("mod+k", "Search", openSearch);
205
+ * S.bindKey("mod+z", "Undo"); // describe only: handled by our own listener
206
+ * ```
207
+ */
208
+ export function bindKey(spec, description, press, mode = "normal") {
209
+ const cur = A();
210
+ // A normal binding is anchored by containment, not by whatever claim is top
211
+ // at call time: a scope redrawn elsewhere while a dialog is up must not
212
+ // hitch its keys to that dialog and die with it.
213
+ const el = mode === "global" ? document.body
214
+ : mode === "local" ? cur
215
+ : mode === "normal" ? (cur && claimFor(cur)) ?? document.body
216
+ : mode;
217
+ if (!el)
218
+ throw new Error("Staffa: a local key binding needs a current element");
219
+ const keyStr = canonKey(spec);
220
+ let map = bindings.get(el);
221
+ if (!map)
222
+ bindings.set(el, map = new Map());
223
+ // Shadow (not replace) any same-key binding already at this element; the
224
+ // scope's cleanup below restores it.
225
+ const binding = { description, press, global: mode === "global", prev: map.get(keyStr) };
226
+ map.set(keyStr, binding);
227
+ if (!listening) {
228
+ listening = true;
229
+ document.addEventListener("keydown", onKeydown);
230
+ }
231
+ A.clean(() => {
232
+ // Unlink, wherever in the shadow chain the binding sits by now.
233
+ let b = map.get(keyStr);
234
+ if (b === binding) {
235
+ if (binding.prev)
236
+ map.set(keyStr, binding.prev);
237
+ else
238
+ map.delete(keyStr);
239
+ }
240
+ else {
241
+ for (; b; b = b.prev) {
242
+ if (b.prev === binding) {
243
+ b.prev = binding.prev;
244
+ break;
245
+ }
246
+ }
247
+ }
248
+ });
249
+ }
250
+ /**
251
+ * Write a key combination the way the platform writes it: `"⇧⌘K"` on a Mac,
252
+ * `"Ctrl+Shift+K"` everywhere else. What the components put in their key hints —
253
+ * use it for the same hint elsewhere in your app, so both spell the shortcut the
254
+ * way this machine's user expects. Pass `aria: true` for the `aria-keyshortcuts`
255
+ * spelling instead: full modifier names and real key names,
256
+ * `"Meta+Shift+K"`/`"Control+Shift+K"`.
257
+ *
258
+ * @example
259
+ * ```ts
260
+ * S.button({ content: `Search ${S.formatKey("mod+k")}`, click: search });
261
+ * ```
262
+ */
263
+ export function formatKey(spec, aria = false) {
264
+ const keyStr = canonKey(spec);
265
+ const mod = keyStr.startsWith("mod+");
266
+ const rest = mod ? keyStr.slice(4) : keyStr;
267
+ const shift = rest.startsWith("shift+");
268
+ const key = shift ? rest.slice(6) : rest;
269
+ const cap = key.length === 1 ? key.toUpperCase() : key[0].toUpperCase() + key.slice(1);
270
+ if (aria) {
271
+ const name = key === " " ? "Space" : shift || key.length > 1 ? cap : key;
272
+ return (mod ? (IS_APPLE ? "Meta+" : "Control+") : "") + (shift ? "Shift+" : "") + name;
273
+ }
274
+ // Apple writes ⇧ before ⌘, and nothing between the glyphs.
275
+ const name = GLYPHS[key] ?? cap;
276
+ return IS_APPLE
277
+ ? (shift ? "⇧" : "") + (mod ? "⌘" : "") + name
278
+ : (mod ? "Ctrl+" : "") + (shift ? "Shift+" : "") + name;
279
+ }