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
@@ -1,10 +1,9 @@
1
1
  ## addTooltip · function
2
2
 
3
- Attaches a tooltip to the current element: adds hover/focus handlers via
4
- `A` so the tip appears when the element is hovered or keyboard-focused.
5
- The tip panel is rendered into `document.body` via a portal, so it is never
6
- clipped by `overflow:hidden` ancestors. Position is computed from the
7
- element's bounding rect and automatically flips when near the viewport edge.
3
+ Attaches a tooltip to the current element, shown on hover or keyboard focus.
4
+ The tip panel is portalled into `document.body`, so `overflow:hidden` ancestors
5
+ never clip it; it is placed from the element's bounding rect, flipping to the
6
+ opposite side when near the viewport edge.
8
7
 
9
8
  **Signature:** `(opts: TooltipOptions) => void`
10
9
 
@@ -0,0 +1,51 @@
1
+ ## bindKey · function
2
+
3
+ Bind a keyboard shortcut, for as long as the calling scope lives.
4
+
5
+ **The spec** is a `KeyboardEvent.key` value — `"k"`, `"f2"`, `"escape"`
6
+ (`"esc"`), `"space"`, `"arrowdown"`, a bare `"?"` — optionally prefixed by
7
+ `mod+` (⌘ on a Mac, Ctrl elsewhere) and/or `shift+`, in that order:
8
+ `"mod+k"`, `"shift+f2"`, `"mod+shift+b"`. Case doesn't matter. A character
9
+ Shift itself types is written as that character — `"?"`, never `"shift+/"` —
10
+ so a combination works on every keyboard layout. No other modifiers are
11
+ offered: Alt and the ⊞ key belong to the browser and the OS, which also
12
+ keep some `mod` combinations for themselves — T, N, W, Q and the digits
13
+ among them, while K, B, E, `/` and `.` are safely yours. Everything Staffa
14
+ takes a `key` option is spelled this way, and `formatKey` turns it
15
+ back into `"⇧⌘K"`/`"Ctrl+Shift+K"`.
16
+
17
+ `description` is what the shortcut overview (see `showKeyHelp`) lists
18
+ the binding as — a rich-text string or draw function; without one the
19
+ binding stays out of the overview. Omit `press` to merely *describe* a key
20
+ your app handles by other means, so the overview can still tell the user
21
+ about it.
22
+
23
+ A handler of the app's own that ran `preventDefault()` first always wins,
24
+ and keystrokes the focused element owns (typing into a field, Enter on a
25
+ link) are left to it. Otherwise `mode` says who else can reach the binding:
26
+
27
+ - `"normal"` (the default): works app-wide, but is silenced while a modal
28
+ dialog from outside it is up. Binding the same combination again shadows
29
+ the earlier binding until the new scope dies — so a state can take a key
30
+ over temporarily.
31
+ - `"global"`: keeps working even over a modal.
32
+ - `"local"`: only fires while the keyboard focus is inside the current
33
+ element — for a shortcut that belongs to one row or panel of many.
34
+ - an `Element`: like `"local"`, but for that element rather than the
35
+ current one.
36
+
37
+ **Signature:** `(spec: string, description?: Slot, press?: (e: KeyboardEvent) => void, mode?: "normal" | "global" | "local" | Element) => void`
38
+
39
+ **Parameters:**
40
+
41
+ - `spec: string`
42
+ - `description?: Slot`
43
+ - `press?: (e: KeyboardEvent) => void`
44
+ - `mode: "normal" | "global" | "local" | Element` (optional)
45
+
46
+ **Examples:**
47
+
48
+ ```ts
49
+ S.bindKey("mod+k", "Search", openSearch);
50
+ S.bindKey("mod+z", "Undo"); // describe only: handled by our own listener
51
+ ```
package/skill/box.md CHANGED
@@ -9,7 +9,7 @@ out as a flex container.
9
9
 
10
10
  Shortcut: pass a function to use it directly as the body content.
11
11
 
12
- **Signature:** `(opts?: BoxOptions | Slot) => void`
12
+ **Signature:** `(opts?: Slot | BoxOptions) => void`
13
13
 
14
14
  **Parameters:**
15
15
 
package/skill/form.md CHANGED
@@ -1,12 +1,10 @@
1
1
  ## form · function
2
2
 
3
- An opinionated `<form>` wrapper that lays its fields out consistently — a clean
4
- single column by default, or a responsive grid — and provides a standard
5
- action bar.
6
-
7
- Field components (("./textline").textline et al.) drop straight
8
- in as `ContentOptions.content`. Submission is wired so the browser's
9
- native validation runs, but the page never reloads.
3
+ An opinionated `<form>` wrapper: fields in a single column by default, or a
4
+ responsive grid, plus a standard action bar. Field components
5
+ (("./textline").textline et al.) drop straight in as
6
+ `ContentOptions.content`; the browser's native validation runs on submit,
7
+ but the page never reloads.
10
8
 
11
9
  **Signature:** `(opts?: Slot | FormOptions) => void`
12
10
 
@@ -0,0 +1,21 @@
1
+ ## formatKey · function
2
+
3
+ Write a key combination the way the platform writes it: `"⇧⌘K"` on a Mac,
4
+ `"Ctrl+Shift+K"` everywhere else. What the components put in their key hints —
5
+ use it for the same hint elsewhere in your app, so both spell the shortcut the
6
+ way this machine's user expects. Pass `aria: true` for the `aria-keyshortcuts`
7
+ spelling instead: full modifier names and real key names,
8
+ `"Meta+Shift+K"`/`"Control+Shift+K"`.
9
+
10
+ **Signature:** `(spec: string, aria?: boolean) => string`
11
+
12
+ **Parameters:**
13
+
14
+ - `spec: string`
15
+ - `aria: any` (optional)
16
+
17
+ **Examples:**
18
+
19
+ ```ts
20
+ S.button({ content: `Search ${S.formatKey("mod+k")}`, click: search });
21
+ ```
@@ -1,13 +1,12 @@
1
1
  ## iconButton · function
2
2
 
3
3
  A bare glyph in a square hit area — no fill, no border, just ink that lifts on
4
- hover. The quiet end of the button family, for chrome that has to sit beside
5
- something more important without competing with it: a ✕ on a box, the ☰ a
6
- routed `S.main()` puts in its top bar, the verbs in a
7
- | page's actions.
4
+ hover. For chrome that has to sit beside something more important without
5
+ competing with it: a ✕ on a box, the ☰ a routed `S.main()` puts in its top bar,
6
+ the verbs in a | page's actions.
8
7
 
9
8
  Reach for `button` instead whenever the thing has a name worth reading;
10
- an icon alone is only unambiguous for a handful of universal actions.
9
+ an icon alone is unambiguous only for a handful of universal actions.
11
10
 
12
11
  **Signature:** `(opts: IconButtonOptions) => void`
13
12
 
@@ -1,16 +1,14 @@
1
1
  ## scrollStrip · function
2
2
 
3
3
  A horizontal row that scrolls when its content outgrows it, with a ‹ / ›
4
- button appearing over whichever end still has something left to reach — a
5
- bare scroll area says nothing about itself to a mouse, and a scrollbar under
6
- a row of chrome reads as a mistake. The row's own scrollbar is hidden, and
7
- the buttons scroll it by most of a width at a time.
4
+ button appearing over whichever end still has something left to reach — so it
5
+ isn't just a swipe target. The row's own scrollbar is hidden, and the buttons
6
+ scroll it by most of a width at a time.
8
7
 
9
- This is what `tabs` puts its tab strip in, and what the routed
10
- `main` shell puts its breadcrumb stack in. Reach for it for any row of
11
- chrome that can outgrow its space: a filter bar, a row of chips, a toolbar.
12
- To bring one of its children into view — after selecting it from elsewhere,
13
- say — call `revealInStrip` with that child.
8
+ `tabs` puts its tab strip in one, and the routed `main` shell its
9
+ breadcrumb stack. Reach for it for any row of chrome that can outgrow its
10
+ space: a filter bar, a row of chips, a toolbar. `revealInStrip` brings
11
+ one of its children into view.
14
12
 
15
13
  **Signature:** `(opts: ScrollStripOptions) => void`
16
14
 
@@ -2,8 +2,8 @@
2
2
 
3
3
  Open a floating dropdown menu anchored to an element. Portals to
4
4
  `document.body` (never clipped), positions itself (flipping up when there's
5
- no room below), and closes on Escape, Tab, item selection, or any click
6
- outside the panel and anchor. Returns a `close()` function.
5
+ no room below), and closes on Escape, Tab, item selection, a navigation, or
6
+ any click outside the panel and anchor. Returns a `close()` function.
7
7
 
8
8
  Menus are usually opened through `menuButton` or
9
9
  `addContextMenu`; reach for this primitive when you need to trigger a
@@ -0,0 +1,17 @@
1
+ ## showKeyHelp · function
2
+
3
+ The shortcut overview: a dialog listing what a keypress could do *right now*
4
+ — every described binding the keyboard focus and any open modal leave in
5
+ effect: buttons under their label, menu items under theirs, `bindKey`
6
+ bindings under the description they were given.
7
+
8
+ It's a cheat-sheet, not a modal: the shortcuts it lists keep working, and any
9
+ keypress closes it *and* still lands — so the key you just looked up can be
10
+ pressed right there. That is also what keeps the listing honest: focus cannot
11
+ move while it is up (a click lands on the backdrop, a key closes it), so what
12
+ was true when it opened stays true. Bound to `?` (and `mod+?`, which also
13
+ works while typing in a field) by default — see `setKeyHelp`. Calling
14
+ this while the overview is already up closes it, which is what lets that `?`
15
+ toggle.
16
+
17
+ **Signature:** `() => void`
package/skill/tabs.md CHANGED
@@ -3,10 +3,9 @@
3
3
  A tabbed view. Renders an ARIA `tablist` of buttons and a single live panel
4
4
  for the selected tab. Supports keyboard navigation (left/right/home/end).
5
5
 
6
- More tabs than fit make the strip scroll sideways, with a ‹ / › button
7
- appearing over whichever end still has something to reach — a bare scroll area
8
- says nothing about itself to a mouse. Selecting a tab that's out of view
9
- (arrow keys, or a `bind` written from elsewhere) scrolls it back in.
6
+ More tabs than fit make the strip scroll sideways (see `scrollStrip`);
7
+ selecting a tab that's out of view — with the arrow keys, or a `bind` written
8
+ from elsewhere — scrolls it back in.
10
9
 
11
10
  **Signature:** `(opts: TabsOptions) => void`
12
11
 
package/skill/textline.md CHANGED
@@ -1,10 +1,8 @@
1
1
  ## textline · function
2
2
 
3
- A single-line text input — covering text, passwords, numbers, email, dates and
4
- the other line-oriented `<input>` types.
5
-
6
- Renders inside the standard `drawField` chrome (label, control,
7
- help/error), so it aligns cleanly inside a `form`.
3
+ A single-line text input — text, passwords, numbers, email, dates and the other
4
+ line-oriented `<input>` types. Renders inside the standard `drawField`
5
+ chrome (label, control, help/error), so it aligns cleanly inside a `form`.
8
6
 
9
7
  **Signature:** `(opts?: TextlineOptions) => void`
10
8
 
@@ -44,8 +44,8 @@ A.insertGlobalCss({
44
44
  ".s-chip > button": "cursor:pointer border:0 background:transparent fg:$s-muted font-size:1.1em line-height:1 padding: 0 0.2em; r:4px",
45
45
  ".s-chip > button:hover": "fg:$s-text background:$s-faint",
46
46
  "input": "flex:1 min-width:6ch border:0 background:transparent color:inherit outline:none padding:0.25em",
47
- // The popup is a `.s-s.neutral.shadow` surface (see below): background, border,
48
- // radius and elevation all come from the surface.
47
+ // Background, border, radius and elevation come from the popup's
48
+ // `.s-s.neutral.shadow` surface (see below).
49
49
  "> .s-menu": "position:absolute top:100% left:0 right:0 z-index:20 margin-top:4px max-height:15rem overflow-y:auto list-style:none p:$1 margin-bottom:0",
50
50
  "> .s-menu li": "margin:0",
51
51
  ".s-option": "padding: 0.45em 0.6em; r:6px cursor:pointer transition: background 0.1s;",
@@ -261,9 +261,8 @@ export function autocomplete(opts: AutocompleteOptions): void {
261
261
  $st.open = false;
262
262
  }
263
263
  } else if (e.key === "Escape") {
264
- // Only consume Escape while the list is showing: it dismisses the
265
- // innermost layer, so a surrounding dialog must not also close. With the
266
- // list already closed, let it pass through to the dialog/nav handlers.
264
+ // Only consume Escape while the list is showing: it dismisses the innermost
265
+ // layer, so a surrounding dialog closes on the next press, not this one.
267
266
  if ($st.open) {
268
267
  e.preventDefault();
269
268
  $st.open = false;
@@ -28,15 +28,10 @@ export interface BoxOptions extends ContentOptions {
28
28
  footerAttrs?: Attributes;
29
29
  }
30
30
 
31
- // The box itself is a `.neutral` surface; its header/footer are `.neutral` surfaces
32
- // too — nested one level deeper, so they pick up the next elevation shade
33
- // automatically. Colours and borders come from the contextual tokens, so a box
34
- // stays legible on whatever surface it's nested in.
35
- // The box is just a `.s-s.neutral.shadow` surface: its border and shadow come from
36
- // the surface itself (see theme.ts), not from here. `.s-box` only does layout and
37
- // the header/footer dividers. Header/footer are `.neutral` surfaces too (one level
38
- // deeper, for the raised shade), so we cancel their full surface border down to a
39
- // single divider.
31
+ // Colours, border and shadow come from the `.s-s.neutral.shadow` surface itself
32
+ // (see theme.ts); `.s-box` only does layout. Header/footer are `.neutral` surfaces
33
+ // one level deeper (for the raised shade), with their surface border cancelled
34
+ // down to a single divider.
40
35
  A.insertGlobalCss({
41
36
  ".s-box": {
42
37
  // position:relative so a headerless box can hang its ✕ in the corner.
@@ -45,11 +40,8 @@ A.insertGlobalCss({
45
40
  "> header": "display:flex align-items:center gap:$2 padding: $2 $3; border:0 border-bottom: 1px solid $s-faint; r:0 font-weight:600",
46
41
  "> footer": "display:flex align-items:center justify-content:flex-end gap:$2 padding: $2 $3; border:0 border-top: 1px solid $s-faint; r:0",
47
42
  "> div": "p:$3 gap:$3",
48
- // The ✕ is an `S.iconButton` (see `drawCloseButton`); this only places it.
49
- // In a header row it parks at the far end...
43
+ // The ✕ parks at the end of a header row, or floats over the body when there is none.
50
44
  "> header > .s-box-close": "margin-left:auto",
51
- // ...and without a header there is no row to sit in, so it floats over the
52
- // body's top-right corner instead.
53
45
  "> .s-box-close": "position:absolute top:$2 right:$2 z-index:1",
54
46
  },
55
47
  });
@@ -78,12 +70,11 @@ export function box(opts: BoxOptions | Slot = {}): void {
78
70
  const o: BoxOptions = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
79
71
 
80
72
  A("section.s-box.s-s.neutral.shadow", o.attrs, () => {
81
- // Header and footer get their own scopes so toggling them doesn't recreate
82
- // the body (which may hold focused inputs / lots of content).
73
+ // Header and footer get their own scopes, so toggling them doesn't recreate
74
+ // the body (which may hold focused inputs).
83
75
  A(() => {
84
- // The typeof guards against v0.9's `close: true` (removed API) reaching
85
- // us from unchecked JS: a ✕ whose handler isn't a function would render
86
- // but do nothing, which is worse than not rendering at all.
76
+ // typeof-guarded: v0.9's removed `close: true`, reaching us from unchecked
77
+ // JS, would otherwise render a ✕ that does nothing.
87
78
  if (o.header != null) {
88
79
  A("header.s-s.neutral", o.headerAttrs, () => {
89
80
  drawSlot(o.header);
@@ -105,9 +96,8 @@ export function box(opts: BoxOptions | Slot = {}): void {
105
96
  }
106
97
 
107
98
  /**
108
- * The box's ✕: one definition, so the glyph, the label and the hit area are
109
- * identical whether it sits in the header row or floats over a headerless body.
110
- * The `.s-box-close` class is only a hook for the placement rules above.
99
+ * The box's ✕: one definition, so it is identical in a header row and floating
100
+ * over a headerless body. `.s-box-close` is only a hook for the placement rules.
111
101
  */
112
102
  function drawCloseButton(close: () => void): void {
113
103
  iconButton({
@@ -1,5 +1,7 @@
1
1
  import A from "aberdeen";
2
2
  import { type Slot, type Attributes, drawSlot } from "../core.js";
3
+ import { bindKey, formatKey } from "../keys.js";
4
+ import { addTooltip } from "./tooltip.js";
3
5
 
4
6
  /** Options for {@link iconButton}. */
5
7
  export interface IconButtonOptions {
@@ -9,6 +11,12 @@ export interface IconButtonOptions {
9
11
  ariaLabel: string;
10
12
  /** Click handler. */
11
13
  click?: (event: Event) => void;
14
+ /**
15
+ * A keyboard shortcut that presses this button — see {@link ButtonOptions.key}.
16
+ * The tooltip shows it after the `ariaLabel` (a glyph is worth naming there
17
+ * anyway), and the `?` overview lists it under that label too.
18
+ */
19
+ key?: string;
12
20
  /** Render as a link (`<a role=button>`) pointing here instead of a `<button>`. */
13
21
  href?: string;
14
22
  /** Disables it. */
@@ -37,6 +45,16 @@ export interface ButtonOptions {
37
45
  href?: string;
38
46
  /** Accessible label, when the button has only an icon. */
39
47
  ariaLabel?: string;
48
+ /**
49
+ * A keyboard shortcut that presses this button: `"mod+s"`, `"f2"` — see
50
+ * {@link bindKey} for the spelling and which keystrokes are yours to take.
51
+ * It works from anywhere while the button is drawn, shows in a tooltip,
52
+ * reaches screen readers as `aria-keyshortcuts`, and does exactly what a
53
+ * click does: a `type: "submit"` submits its form, an `href` navigates. The
54
+ * `?` overview ({@link showKeyHelp}) lists it under the button's text (or
55
+ * its `ariaLabel`).
56
+ */
57
+ key?: string;
40
58
  /**
41
59
  * Aberdeen attr/style string applied to the button. A button is a surface, so
42
60
  * pass surface modifier classes here to restyle it, e.g. `".danger"`,
@@ -50,9 +68,8 @@ export interface ButtonOptions {
50
68
  attrs?: Attributes;
51
69
  }
52
70
 
53
- // The button is a `.s-s` surface (defaulting to `.primary` in button() below), so
54
- // its colours, border, and border-radius come from the surface classes in theme.ts.
55
- // This rule only handles layout, focus, hover and sizing.
71
+ // Colours, border and radius come from the `.s-s` surface classes in theme.ts;
72
+ // this rule only does layout, focus, hover and sizing.
56
73
  A.insertGlobalCss({
57
74
  ".s-btn": {
58
75
  "&":
@@ -60,52 +77,34 @@ A.insertGlobalCss({
60
77
  "font-weight:450 line-height:1.1 white-space:nowrap cursor:pointer text-decoration:none " +
61
78
  "padding: $m2 $m3; " +
62
79
  "transition: background 0.15s, border-color 0.15s, color 0.15s, filter 0.15s, box-shadow 0.15s, transform 0.08s;",
63
- // Focus ring via `outline` (not box-shadow) so it survives a `.no-shadow`
64
- // (which hard-clears box-shadow). Modern browsers round it to the border-radius.
80
+ // Focus ring via `outline`, not box-shadow: `.no-shadow` hard-clears box-shadow.
65
81
  "&:focus-visible": "outline: 3px solid $s-focus; outline-offset: 1px;",
66
- // The button carries `.shadow` (added in button() below); on a filled accent
67
- // surface that resolves to the signature self-coloured glow, on a neutral
68
- // `.neutral` button to nothing, on tonal/outlined to nothing — all via theme.ts.
69
82
  "&:hover": "filter: brightness(1.06)",
70
- // Tonal/outlined hover deepen their translucent fill; a neutral `.neutral`
71
- // button (which is already near-white) darkens toward its ink instead.
72
83
  "&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-bg 24%, transparent);",
84
+ // A `.neutral` button is already near-white, so it darkens toward its ink
85
+ // instead of brightening.
73
86
  "&.neutral:hover": "filter:none background: color-mix(in srgb, $s-text 8%, $s-bg);",
74
- // The button sizes its glyph, for the same reason `.s-icon-btn` does below:
75
- // a caller can't know what the button beside it passed, and only a rule
76
- // here makes every icon in a row come out alike. It rides the font size,
77
- // so a `.small`/`.large` button scales its icon with its text.
87
+ // The button sizes its glyph rather than trusting the caller: only a rule here
88
+ // makes every icon in a row match. In `em`, so `.small`/`.large` scale it.
78
89
  "> svg": "width:1.25em height:1.25em",
79
- // Subtle press feedback.
80
90
  "&:active:not(:disabled)": "transform: translateY(1px)",
81
- // Size: set on the button itself, or inherited from a `.small`/`.large`
82
- // parent (e.g. a buttonGroup), so a container can size all its buttons at once.
91
+ // Also inherited from a `.small`/`.large` parent (e.g. a buttonGroup), so a
92
+ // container can size all its buttons at once.
83
93
  "&.small, .small > &": "padding: $m1 $m2; font-size:0.85em border-radius:$s-radius-sm",
84
94
  "&.large, .large > &": "font-size:1.4em border-radius:$s-radius-lg",
85
95
  },
86
- // A bare glyph in a square hit area: no fill and no edge, just ink that lifts
87
- // on hover. Deliberately *not* a `.s-s` surface — chrome that sits beside a
88
- // title (a ✕, a ☰) should read as an affordance on the bar, not as
89
- // another button competing with it, and a filled or outlined box around a
90
- // 16px glyph is exactly what makes a top bar look busy.
96
+ // Deliberately *not* a `.s-s` surface: chrome sitting beside a title (a ✕, a ☰)
97
+ // should read as an affordance on the bar, not as another button competing with it.
91
98
  ".s-icon-btn": {
92
99
  "&":
93
100
  "display:inline-flex align-items:center justify-content:center flex-shrink:0 " +
94
101
  "width:2rem height:2rem p:0 border:0 background:transparent cursor:pointer " +
95
102
  "fg:$s-muted r:$s-radius-sm line-height:1 font-size:1rem text-decoration:none " +
96
103
  "transition: color 0.12s, background 0.12s;",
97
- // The container sizes the glyph, rather than trusting whatever the caller
98
- // passed: a row of icon buttons only reads as a row when every glyph in it
99
- // is the same size, and the caller of one of them can't know about the
100
- // others. CSS beats the `width`/`height` attributes the icon set writes, so
101
- // `iconButton({ icon: trash2 })` and a hand-sized glyph come out alike; an
102
- // `attrs` override still wins over this, being an inline style. The same
103
- // rule is on `.s-btn` above and on a floating menu's rows in menu.ts, so
104
- // one `1.25em` governs the lot. (`S.main`'s nav rows are deliberately out
105
- // of it — see the note there.)
104
+ // As on `.s-btn`: the container sizes the glyph so a row of icon buttons reads
105
+ // as a row. CSS beats the `width`/`height` attributes the icon set writes; an
106
+ // `attrs` override still wins over this, being an inline style.
106
107
  "> svg": "width:1.25em height:1.25em",
107
- // The ink resolves against whatever surface it sits on, so one treatment
108
- // works on the page, in a box header, and on a coloured bar alike.
109
108
  "&:hover:not(:disabled):not([aria-disabled=true])":
110
109
  "fg:$s-text background: color-mix(in srgb, $s-text 10%, transparent);",
111
110
  "&:focus-visible": "outline: 3px solid $s-focus; outline-offset:1px",
@@ -117,13 +116,12 @@ A.insertGlobalCss({
117
116
 
118
117
  /**
119
118
  * A bare glyph in a square hit area — no fill, no border, just ink that lifts on
120
- * hover. The quiet end of the button family, for chrome that has to sit beside
121
- * something more important without competing with it: a ✕ on a box, the ☰ a
122
- * routed `S.main()` puts in its top bar, the verbs in a
123
- * {@link Panel.actions | page's actions}.
119
+ * hover. For chrome that has to sit beside something more important without
120
+ * competing with it: a ✕ on a box, the ☰ a routed `S.main()` puts in its top bar,
121
+ * the verbs in a {@link Panel.actions | page's actions}.
124
122
  *
125
123
  * Reach for {@link button} instead whenever the thing has a name worth reading;
126
- * an icon alone is only unambiguous for a handful of universal actions.
124
+ * an icon alone is unambiguous only for a handful of universal actions.
127
125
  *
128
126
  * @example
129
127
  * ```ts
@@ -140,16 +138,16 @@ export function iconButton(opts: IconButtonOptions): void {
140
138
  A(`${tag}.s-icon-btn`, opts.attrs, () => {
141
139
  applyActionBehavior(opts);
142
140
  A("aria-label=", opts.ariaLabel);
141
+ if (opts.key) applyKey(opts.key, opts.ariaLabel, undefined, opts.disabled);
143
142
  drawSlot(opts.icon);
144
143
  });
145
144
  }
146
145
 
147
146
  /**
148
- * The link-or-button plumbing {@link button} and {@link iconButton} share:
149
- * href/type, disabling, label and click. A disabled link keeps `role=button`
150
- * and `aria-disabled` but loses its `href` — an anchor without one is out of
151
- * the tab order and follows nothing, which is what makes it as disabled as
152
- * the `<button>` form's real `disabled` attribute.
147
+ * The link-or-button plumbing {@link button} and {@link iconButton} share. A
148
+ * disabled link keeps `role=button` and `aria-disabled` but loses its `href`: an
149
+ * anchor without one is out of the tab order and follows nothing, which is what
150
+ * makes it as disabled as a `<button>`'s real `disabled` attribute.
153
151
  */
154
152
  function applyActionBehavior(o: {
155
153
  href?: string;
@@ -168,6 +166,30 @@ function applyActionBehavior(o: {
168
166
  if (o.click && !o.disabled) A("click=", o.click);
169
167
  }
170
168
 
169
+ /**
170
+ * The shortcut plumbing {@link button} and {@link iconButton} share: bind it,
171
+ * announce it as `aria-keyshortcuts`, and hint at it in a tooltip — the only
172
+ * place a button can say what its key is without shouting it beside the label.
173
+ *
174
+ * Pressing it clicks the element rather than calling `click` directly, so a
175
+ * `type=submit` still submits its form and an `href` still navigates. Call this
176
+ * inside the button's own element scope, whose element it takes and whose life
177
+ * the binding follows.
178
+ */
179
+ function applyKey(key: string, label: string | undefined, content: Slot | undefined, disabled?: boolean): void {
180
+ const el = A() as HTMLElement;
181
+ const tip = label ? `${label} · ${formatKey(key)}` : formatKey(key);
182
+ // A draw function, not a string: a key like `*` is markup to rich text.
183
+ addTooltip({ tip: () => A("#", tip) });
184
+ // Bound only while it can be pressed — a disabled button would otherwise
185
+ // swallow the combination rather than leave it to whoever else wants it. The
186
+ // overview names it by its visible text, or its aria label failing that.
187
+ if (!disabled) {
188
+ A("aria-keyshortcuts=", formatKey(key, true));
189
+ bindKey(key, typeof content === "string" ? content : label, () => el.click());
190
+ }
191
+ }
192
+
171
193
  /**
172
194
  * A button. Tonal and outlined variants show a border; filled variants rely on
173
195
  * their solid background for affordance.
@@ -197,13 +219,14 @@ export function button(opts: ButtonOptions | Slot = {}): void {
197
219
 
198
220
  const tag = o.href != null ? "a" : "button";
199
221
 
200
- // A bare `.s-s` is a filled accent surface defaulting to `.primary` (see
201
- // theme.ts) — the signature CTA. The caller's `attrs` simply names another
202
- // role (`.danger`, `.neutral`, a custom `.brand`) or variant (`.outlined`); no
203
- // role detection needed, since the default lives in CSS, not here.
222
+ // A bare `.s-s` is a filled `.primary` surface (see theme.ts), so no role
223
+ // detection here: `attrs` just names another role or variant.
204
224
  A(`${tag}.s-btn.s-s.shadow`, o.attrs, () => {
205
225
  applyActionBehavior(o);
206
226
  if (o.ariaLabel) A("aria-label=", o.ariaLabel);
227
+ // Before the content, so a tooltip the caller adds in there is the later of
228
+ // the two and wins the hover.
229
+ if (o.key) applyKey(o.key, o.ariaLabel, o.content, o.disabled);
207
230
 
208
231
  drawSlot(o.icon);
209
232
  drawSlot(o.content);
@@ -49,8 +49,7 @@ export function buttonChooser(opts: ButtonChooserOptions): void {
49
49
  attrs: opts.attrs,
50
50
  buttons: Object.entries(opts.options).map(([id, label]) => ({
51
51
  content: label,
52
- // Icon-only options (draw-function labels) get the id as their
53
- // accessible name; plain-text labels speak for themselves.
52
+ // Icon-only (draw-function) labels get the id as their accessible name.
54
53
  ariaLabel: typeof label === "function" ? id : undefined,
55
54
  attrs: selected === id ? ".primary" : ".neutral",
56
55
  click: () => {
@@ -61,7 +60,6 @@ export function buttonChooser(opts: ButtonChooserOptions): void {
61
60
  });
62
61
 
63
62
  if (opts.name) {
64
- // Hidden input carries the value into native form submission.
65
63
  A(() => A("input type=hidden name=", opts.name, "value=", opts.bind.value ?? ""));
66
64
  }
67
65
  }
@@ -19,8 +19,7 @@ A.insertGlobalCss({
19
19
  "&": "display:flex flex-direction:column gap:$1",
20
20
  "> label": "display:flex align-items:center gap:$2 cursor:pointer user-select:none",
21
21
  "> label:has(input:disabled)": "cursor:not-allowed opacity:0.45 filter:saturate(0.6)",
22
- // Native control: size and brand accent-color come from the CSS reset; here we
23
- // just strip the margin and let it inherit the label's cursor (pointer / not-allowed).
22
+ // Size and accent-color come from the CSS reset; here, the margin and the label's cursor.
24
23
  "input": "cursor:inherit m:0",
25
24
  },
26
25
  });