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
@@ -13,8 +13,8 @@ A.insertGlobalCss({
13
13
  ".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",
14
14
  ".s-chip > button:hover": "fg:$s-text background:$s-faint",
15
15
  "input": "flex:1 min-width:6ch border:0 background:transparent color:inherit outline:none padding:0.25em",
16
- // The popup is a `.s-s.neutral.shadow` surface (see below): background, border,
17
- // radius and elevation all come from the surface.
16
+ // Background, border, radius and elevation come from the popup's
17
+ // `.s-s.neutral.shadow` surface (see below).
18
18
  "> .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",
19
19
  "> .s-menu li": "margin:0",
20
20
  ".s-option": "padding: 0.45em 0.6em; r:6px cursor:pointer transition: background 0.1s;",
@@ -235,9 +235,8 @@ export function autocomplete(opts) {
235
235
  }
236
236
  }
237
237
  else if (e.key === "Escape") {
238
- // Only consume Escape while the list is showing: it dismisses the
239
- // innermost layer, so a surrounding dialog must not also close. With the
240
- // list already closed, let it pass through to the dialog/nav handlers.
238
+ // Only consume Escape while the list is showing: it dismisses the innermost
239
+ // layer, so a surrounding dialog closes on the next press, not this one.
241
240
  if ($st.open) {
242
241
  e.preventDefault();
243
242
  $st.open = false;
@@ -2,15 +2,10 @@ import A from "aberdeen";
2
2
  import { drawSlot } from "../core.js";
3
3
  import { x as closeIcon } from "../icons.js";
4
4
  import { iconButton } from "./button.js";
5
- // The box itself is a `.neutral` surface; its header/footer are `.neutral` surfaces
6
- // too nested one level deeper, so they pick up the next elevation shade
7
- // automatically. Colours and borders come from the contextual tokens, so a box
8
- // stays legible on whatever surface it's nested in.
9
- // The box is just a `.s-s.neutral.shadow` surface: its border and shadow come from
10
- // the surface itself (see theme.ts), not from here. `.s-box` only does layout and
11
- // the header/footer dividers. Header/footer are `.neutral` surfaces too (one level
12
- // deeper, for the raised shade), so we cancel their full surface border down to a
13
- // single divider.
5
+ // Colours, border and shadow come from the `.s-s.neutral.shadow` surface itself
6
+ // (see theme.ts); `.s-box` only does layout. Header/footer are `.neutral` surfaces
7
+ // one level deeper (for the raised shade), with their surface border cancelled
8
+ // down to a single divider.
14
9
  A.insertGlobalCss({
15
10
  ".s-box": {
16
11
  // position:relative so a headerless box can hang its ✕ in the corner.
@@ -19,11 +14,8 @@ A.insertGlobalCss({
19
14
  "> header": "display:flex align-items:center gap:$2 padding: $2 $3; border:0 border-bottom: 1px solid $s-faint; r:0 font-weight:600",
20
15
  "> 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",
21
16
  "> div": "p:$3 gap:$3",
22
- // The ✕ is an `S.iconButton` (see `drawCloseButton`); this only places it.
23
- // In a header row it parks at the far end...
17
+ // The ✕ parks at the end of a header row, or floats over the body when there is none.
24
18
  "> header > .s-box-close": "margin-left:auto",
25
- // ...and without a header there is no row to sit in, so it floats over the
26
- // body's top-right corner instead.
27
19
  "> .s-box-close": "position:absolute top:$2 right:$2 z-index:1",
28
20
  },
29
21
  });
@@ -50,12 +42,11 @@ A.insertGlobalCss({
50
42
  export function box(opts = {}) {
51
43
  const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
52
44
  A("section.s-box.s-s.neutral.shadow", o.attrs, () => {
53
- // Header and footer get their own scopes so toggling them doesn't recreate
54
- // the body (which may hold focused inputs / lots of content).
45
+ // Header and footer get their own scopes, so toggling them doesn't recreate
46
+ // the body (which may hold focused inputs).
55
47
  A(() => {
56
- // The typeof guards against v0.9's `close: true` (removed API) reaching
57
- // us from unchecked JS: a ✕ whose handler isn't a function would render
58
- // but do nothing, which is worse than not rendering at all.
48
+ // typeof-guarded: v0.9's removed `close: true`, reaching us from unchecked
49
+ // JS, would otherwise render a ✕ that does nothing.
59
50
  if (o.header != null) {
60
51
  A("header.s-s.neutral", o.headerAttrs, () => {
61
52
  drawSlot(o.header);
@@ -77,9 +68,8 @@ export function box(opts = {}) {
77
68
  });
78
69
  }
79
70
  /**
80
- * The box's ✕: one definition, so the glyph, the label and the hit area are
81
- * identical whether it sits in the header row or floats over a headerless body.
82
- * The `.s-box-close` class is only a hook for the placement rules above.
71
+ * The box's ✕: one definition, so it is identical in a header row and floating
72
+ * over a headerless body. `.s-box-close` is only a hook for the placement rules.
83
73
  */
84
74
  function drawCloseButton(close) {
85
75
  iconButton({
@@ -7,6 +7,12 @@ export interface IconButtonOptions {
7
7
  ariaLabel: string;
8
8
  /** Click handler. */
9
9
  click?: (event: Event) => void;
10
+ /**
11
+ * A keyboard shortcut that presses this button — see {@link ButtonOptions.key}.
12
+ * The tooltip shows it after the `ariaLabel` (a glyph is worth naming there
13
+ * anyway), and the `?` overview lists it under that label too.
14
+ */
15
+ key?: string;
10
16
  /** Render as a link (`<a role=button>`) pointing here instead of a `<button>`. */
11
17
  href?: string;
12
18
  /** Disables it. */
@@ -34,6 +40,16 @@ export interface ButtonOptions {
34
40
  href?: string;
35
41
  /** Accessible label, when the button has only an icon. */
36
42
  ariaLabel?: string;
43
+ /**
44
+ * A keyboard shortcut that presses this button: `"mod+s"`, `"f2"` — see
45
+ * {@link bindKey} for the spelling and which keystrokes are yours to take.
46
+ * It works from anywhere while the button is drawn, shows in a tooltip,
47
+ * reaches screen readers as `aria-keyshortcuts`, and does exactly what a
48
+ * click does: a `type: "submit"` submits its form, an `href` navigates. The
49
+ * `?` overview ({@link showKeyHelp}) lists it under the button's text (or
50
+ * its `ariaLabel`).
51
+ */
52
+ key?: string;
37
53
  /**
38
54
  * Aberdeen attr/style string applied to the button. A button is a surface, so
39
55
  * pass surface modifier classes here to restyle it, e.g. `".danger"`,
@@ -48,13 +64,12 @@ export interface ButtonOptions {
48
64
  }
49
65
  /**
50
66
  * A bare glyph in a square hit area — no fill, no border, just ink that lifts on
51
- * hover. The quiet end of the button family, for chrome that has to sit beside
52
- * something more important without competing with it: a ✕ on a box, the ☰ a
53
- * routed `S.main()` puts in its top bar, the verbs in a
54
- * {@link Panel.actions | page's actions}.
67
+ * hover. For chrome that has to sit beside something more important without
68
+ * competing with it: a ✕ on a box, the ☰ a routed `S.main()` puts in its top bar,
69
+ * the verbs in a {@link Panel.actions | page's actions}.
55
70
  *
56
71
  * Reach for {@link button} instead whenever the thing has a name worth reading;
57
- * an icon alone is only unambiguous for a handful of universal actions.
72
+ * an icon alone is unambiguous only for a handful of universal actions.
58
73
  *
59
74
  * @example
60
75
  * ```ts
@@ -1,59 +1,42 @@
1
1
  import A from "aberdeen";
2
2
  import { drawSlot } from "../core.js";
3
- // The button is a `.s-s` surface (defaulting to `.primary` in button() below), so
4
- // its colours, border, and border-radius come from the surface classes in theme.ts.
5
- // This rule only handles layout, focus, hover and sizing.
3
+ import { bindKey, formatKey } from "../keys.js";
4
+ import { addTooltip } from "./tooltip.js";
5
+ // Colours, border and radius come from the `.s-s` surface classes in theme.ts;
6
+ // this rule only does layout, focus, hover and sizing.
6
7
  A.insertGlobalCss({
7
8
  ".s-btn": {
8
9
  "&": "display:inline-flex align-items:center justify-content:center gap:$2 " +
9
10
  "font-weight:450 line-height:1.1 white-space:nowrap cursor:pointer text-decoration:none " +
10
11
  "padding: $m2 $m3; " +
11
12
  "transition: background 0.15s, border-color 0.15s, color 0.15s, filter 0.15s, box-shadow 0.15s, transform 0.08s;",
12
- // Focus ring via `outline` (not box-shadow) so it survives a `.no-shadow`
13
- // (which hard-clears box-shadow). Modern browsers round it to the border-radius.
13
+ // Focus ring via `outline`, not box-shadow: `.no-shadow` hard-clears box-shadow.
14
14
  "&:focus-visible": "outline: 3px solid $s-focus; outline-offset: 1px;",
15
- // The button carries `.shadow` (added in button() below); on a filled accent
16
- // surface that resolves to the signature self-coloured glow, on a neutral
17
- // `.neutral` button to nothing, on tonal/outlined to nothing — all via theme.ts.
18
15
  "&:hover": "filter: brightness(1.06)",
19
- // Tonal/outlined hover deepen their translucent fill; a neutral `.neutral`
20
- // button (which is already near-white) darkens toward its ink instead.
21
16
  "&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-bg 24%, transparent);",
17
+ // A `.neutral` button is already near-white, so it darkens toward its ink
18
+ // instead of brightening.
22
19
  "&.neutral:hover": "filter:none background: color-mix(in srgb, $s-text 8%, $s-bg);",
23
- // The button sizes its glyph, for the same reason `.s-icon-btn` does below:
24
- // a caller can't know what the button beside it passed, and only a rule
25
- // here makes every icon in a row come out alike. It rides the font size,
26
- // so a `.small`/`.large` button scales its icon with its text.
20
+ // The button sizes its glyph rather than trusting the caller: only a rule here
21
+ // makes every icon in a row match. In `em`, so `.small`/`.large` scale it.
27
22
  "> svg": "width:1.25em height:1.25em",
28
- // Subtle press feedback.
29
23
  "&:active:not(:disabled)": "transform: translateY(1px)",
30
- // Size: set on the button itself, or inherited from a `.small`/`.large`
31
- // parent (e.g. a buttonGroup), so a container can size all its buttons at once.
24
+ // Also inherited from a `.small`/`.large` parent (e.g. a buttonGroup), so a
25
+ // container can size all its buttons at once.
32
26
  "&.small, .small > &": "padding: $m1 $m2; font-size:0.85em border-radius:$s-radius-sm",
33
27
  "&.large, .large > &": "font-size:1.4em border-radius:$s-radius-lg",
34
28
  },
35
- // A bare glyph in a square hit area: no fill and no edge, just ink that lifts
36
- // on hover. Deliberately *not* a `.s-s` surface chrome that sits beside a
37
- // title (a ✕, a ☰) should read as an affordance on the bar, not as
38
- // another button competing with it, and a filled or outlined box around a
39
- // 16px glyph is exactly what makes a top bar look busy.
29
+ // Deliberately *not* a `.s-s` surface: chrome sitting beside a title (a ✕, a ☰)
30
+ // should read as an affordance on the bar, not as another button competing with it.
40
31
  ".s-icon-btn": {
41
32
  "&": "display:inline-flex align-items:center justify-content:center flex-shrink:0 " +
42
33
  "width:2rem height:2rem p:0 border:0 background:transparent cursor:pointer " +
43
34
  "fg:$s-muted r:$s-radius-sm line-height:1 font-size:1rem text-decoration:none " +
44
35
  "transition: color 0.12s, background 0.12s;",
45
- // The container sizes the glyph, rather than trusting whatever the caller
46
- // passed: a row of icon buttons only reads as a row when every glyph in it
47
- // is the same size, and the caller of one of them can't know about the
48
- // others. CSS beats the `width`/`height` attributes the icon set writes, so
49
- // `iconButton({ icon: trash2 })` and a hand-sized glyph come out alike; an
50
- // `attrs` override still wins over this, being an inline style. The same
51
- // rule is on `.s-btn` above and on a floating menu's rows in menu.ts, so
52
- // one `1.25em` governs the lot. (`S.main`'s nav rows are deliberately out
53
- // of it — see the note there.)
36
+ // As on `.s-btn`: the container sizes the glyph so a row of icon buttons reads
37
+ // as a row. CSS beats the `width`/`height` attributes the icon set writes; an
38
+ // `attrs` override still wins over this, being an inline style.
54
39
  "> svg": "width:1.25em height:1.25em",
55
- // The ink resolves against whatever surface it sits on, so one treatment
56
- // works on the page, in a box header, and on a coloured bar alike.
57
40
  "&:hover:not(:disabled):not([aria-disabled=true])": "fg:$s-text background: color-mix(in srgb, $s-text 10%, transparent);",
58
41
  "&:focus-visible": "outline: 3px solid $s-focus; outline-offset:1px",
59
42
  // The glyph rides the font size, so it scales with the hit area.
@@ -63,13 +46,12 @@ A.insertGlobalCss({
63
46
  });
64
47
  /**
65
48
  * A bare glyph in a square hit area — no fill, no border, just ink that lifts on
66
- * hover. The quiet end of the button family, for chrome that has to sit beside
67
- * something more important without competing with it: a ✕ on a box, the ☰ a
68
- * routed `S.main()` puts in its top bar, the verbs in a
69
- * {@link Panel.actions | page's actions}.
49
+ * hover. For chrome that has to sit beside something more important without
50
+ * competing with it: a ✕ on a box, the ☰ a routed `S.main()` puts in its top bar,
51
+ * the verbs in a {@link Panel.actions | page's actions}.
70
52
  *
71
53
  * Reach for {@link button} instead whenever the thing has a name worth reading;
72
- * an icon alone is only unambiguous for a handful of universal actions.
54
+ * an icon alone is unambiguous only for a handful of universal actions.
73
55
  *
74
56
  * @example
75
57
  * ```ts
@@ -86,15 +68,16 @@ export function iconButton(opts) {
86
68
  A(`${tag}.s-icon-btn`, opts.attrs, () => {
87
69
  applyActionBehavior(opts);
88
70
  A("aria-label=", opts.ariaLabel);
71
+ if (opts.key)
72
+ applyKey(opts.key, opts.ariaLabel, undefined, opts.disabled);
89
73
  drawSlot(opts.icon);
90
74
  });
91
75
  }
92
76
  /**
93
- * The link-or-button plumbing {@link button} and {@link iconButton} share:
94
- * href/type, disabling, label and click. A disabled link keeps `role=button`
95
- * and `aria-disabled` but loses its `href` an anchor without one is out of
96
- * the tab order and follows nothing, which is what makes it as disabled as
97
- * the `<button>` form's real `disabled` attribute.
77
+ * The link-or-button plumbing {@link button} and {@link iconButton} share. A
78
+ * disabled link keeps `role=button` and `aria-disabled` but loses its `href`: an
79
+ * anchor without one is out of the tab order and follows nothing, which is what
80
+ * makes it as disabled as a `<button>`'s real `disabled` attribute.
98
81
  */
99
82
  function applyActionBehavior(o) {
100
83
  if (o.href != null) {
@@ -112,6 +95,29 @@ function applyActionBehavior(o) {
112
95
  if (o.click && !o.disabled)
113
96
  A("click=", o.click);
114
97
  }
98
+ /**
99
+ * The shortcut plumbing {@link button} and {@link iconButton} share: bind it,
100
+ * announce it as `aria-keyshortcuts`, and hint at it in a tooltip — the only
101
+ * place a button can say what its key is without shouting it beside the label.
102
+ *
103
+ * Pressing it clicks the element rather than calling `click` directly, so a
104
+ * `type=submit` still submits its form and an `href` still navigates. Call this
105
+ * inside the button's own element scope, whose element it takes and whose life
106
+ * the binding follows.
107
+ */
108
+ function applyKey(key, label, content, disabled) {
109
+ const el = A();
110
+ const tip = label ? `${label} · ${formatKey(key)}` : formatKey(key);
111
+ // A draw function, not a string: a key like `*` is markup to rich text.
112
+ addTooltip({ tip: () => A("#", tip) });
113
+ // Bound only while it can be pressed — a disabled button would otherwise
114
+ // swallow the combination rather than leave it to whoever else wants it. The
115
+ // overview names it by its visible text, or its aria label failing that.
116
+ if (!disabled) {
117
+ A("aria-keyshortcuts=", formatKey(key, true));
118
+ bindKey(key, typeof content === "string" ? content : label, () => el.click());
119
+ }
120
+ }
115
121
  /**
116
122
  * A button. Tonal and outlined variants show a border; filled variants rely on
117
123
  * their solid background for affordance.
@@ -139,14 +145,16 @@ function applyActionBehavior(o) {
139
145
  export function button(opts = {}) {
140
146
  const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
141
147
  const tag = o.href != null ? "a" : "button";
142
- // A bare `.s-s` is a filled accent surface defaulting to `.primary` (see
143
- // theme.ts) the signature CTA. The caller's `attrs` simply names another
144
- // role (`.danger`, `.neutral`, a custom `.brand`) or variant (`.outlined`); no
145
- // role detection needed, since the default lives in CSS, not here.
148
+ // A bare `.s-s` is a filled `.primary` surface (see theme.ts), so no role
149
+ // detection here: `attrs` just names another role or variant.
146
150
  A(`${tag}.s-btn.s-s.shadow`, o.attrs, () => {
147
151
  applyActionBehavior(o);
148
152
  if (o.ariaLabel)
149
153
  A("aria-label=", o.ariaLabel);
154
+ // Before the content, so a tooltip the caller adds in there is the later of
155
+ // the two and wins the hover.
156
+ if (o.key)
157
+ applyKey(o.key, o.ariaLabel, o.content, o.disabled);
150
158
  drawSlot(o.icon);
151
159
  drawSlot(o.content);
152
160
  });
@@ -23,8 +23,7 @@ export function buttonChooser(opts) {
23
23
  attrs: opts.attrs,
24
24
  buttons: Object.entries(opts.options).map(([id, label]) => ({
25
25
  content: label,
26
- // Icon-only options (draw-function labels) get the id as their
27
- // accessible name; plain-text labels speak for themselves.
26
+ // Icon-only (draw-function) labels get the id as their accessible name.
28
27
  ariaLabel: typeof label === "function" ? id : undefined,
29
28
  attrs: selected === id ? ".primary" : ".neutral",
30
29
  click: () => {
@@ -34,7 +33,6 @@ export function buttonChooser(opts) {
34
33
  });
35
34
  });
36
35
  if (opts.name) {
37
- // Hidden input carries the value into native form submission.
38
36
  A(() => A("input type=hidden name=", opts.name, "value=", opts.bind.value ?? ""));
39
37
  }
40
38
  }
@@ -5,8 +5,7 @@ A.insertGlobalCss({
5
5
  "&": "display:flex flex-direction:column gap:$1",
6
6
  "> label": "display:flex align-items:center gap:$2 cursor:pointer user-select:none",
7
7
  "> label:has(input:disabled)": "cursor:not-allowed opacity:0.45 filter:saturate(0.6)",
8
- // Native control: size and brand accent-color come from the CSS reset; here we
9
- // just strip the margin and let it inherit the label's cursor (pointer / not-allowed).
8
+ // Size and accent-color come from the CSS reset; here, the margin and the label's cursor.
10
9
  "input": "cursor:inherit m:0",
11
10
  },
12
11
  });
@@ -20,10 +20,17 @@ export interface DialogOptions {
20
20
  */
21
21
  allowCancel?: boolean;
22
22
  /**
23
- * When set to `true` (default) the model will be destroyed when the `dialog()`-calling
24
- * scope is destroyed.
23
+ * When `true` (default), the dialog is destroyed when the scope that called
24
+ * `dialog()` is destroyed.
25
25
  */
26
26
  cancelWithScope?: boolean;
27
+ /**
28
+ * Leave the keyboard shortcuts behind this dialog working, instead of owning
29
+ * the keyboard the way a modal does. For overlays that inform rather than
30
+ * interrupt — the `?` shortcut overview is one. Esc still closes it first,
31
+ * whatever it meant before.
32
+ */
33
+ keyboardTransparent?: boolean;
27
34
  /**
28
35
  * Dialog body. A {@link Slot} whose draw-function receives a `close()` function
29
36
  * — call it to dismiss the dialog programmatically. (A plain string renders as
@@ -3,20 +3,19 @@ import { drawSlot, mountPortal, focusFirst } from "../core.js";
3
3
  import { button } from "./button.js";
4
4
  import { buttonGroup } from "./buttonGroup.js";
5
5
  import { textline } from "./textline.js";
6
+ import { bindKey, claimKeyboard, keyboardOwner } from "../keys.js";
6
7
  A.insertGlobalCss({
7
8
  ".s-backdrop": {
8
9
  "&": "position:fixed inset:0 z-index:200 display:block background: rgba(0,0,0,0.55); transition: opacity 0.4s ease-in-out;",
9
10
  "&.hidden": "opacity:0 pointer-events:none",
10
11
  },
11
- // The dialog panel is a `.s-s.neutral.extra-shadow` surface: border, radius (lg)
12
- // and the deep floating shadow come from the surface itself (see theme.ts).
12
+ // Border, radius and the deep floating shadow come from the panel's
13
+ // `.s-s.neutral.extra-shadow` surface (see theme.ts).
13
14
  ".s-dialog": {
14
15
  "&": "position:fixed z-index:200 top:50% left:50% " +
15
16
  "display:flex flex-direction:column " +
16
17
  "transform:translate(-50%,-50%) " +
17
- // vw/vh divided by --s-zoom: viewport units shrink with the page-fitting
18
- // zoom (see `watchScale` in main.ts), and these caps mean the window.
19
- "min-width:20rem max-width:min(calc(90vw/var(--s-zoom,1)),44rem) max-height:min(calc(88vh/var(--s-zoom,1)),800px) " +
18
+ "min-width:min(20rem,90vw) max-width:min(90vw,44rem) max-height:min(88vh,800px) " +
20
19
  "r: $s-radius-lg; overflow:hidden " +
21
20
  "transition: opacity 0.2s ease-out, transform 0.2s ease-out;",
22
21
  "> header": "display:flex align-items:center gap:$2 padding: $2 $3; " +
@@ -42,19 +41,39 @@ mountPortal(() => {
42
41
  A.onEach(dialogs, ({ resolve, opts }, dialogId) => {
43
42
  const close = () => { delete dialogs[dialogId]; };
44
43
  A.clean(() => {
45
- // Fires when this render is torn down either because $closed became
46
- // true (normal close) or because the parent reactive scope was cleaned up.
44
+ // Fires on a normal close and on parent-scope teardown alike.
47
45
  opts.onClose?.();
48
46
  resolve();
49
47
  });
48
+ // Return focus to where it was when this dialog opened, so a keyboard
49
+ // user doesn't lose their place.
50
+ const prevFocus = document.activeElement;
51
+ A.clean(() => {
52
+ if (prevFocus instanceof HTMLElement && document.contains(prevFocus))
53
+ prevFocus.focus();
54
+ });
50
55
  // Backdrop - hide when not the top dialog
51
56
  const overlaid = A.derive(() => topDialogId.value != dialogId);
52
57
  A("div.s-backdrop create=hidden destroy=hidden .hidden=", overlaid, "click=", () => {
53
58
  if (opts.allowCancel !== false)
54
59
  close();
55
60
  });
56
- // Dialog itself
57
61
  const dialogEl = A("div.s-dialog.neutral.s-s.extra-shadow create=hidden destroy=hidden", opts.attrs, () => {
62
+ // A modal owns the keyboard: claiming makes the shortcuts drawn inside
63
+ // (the content below included) register here and silences the rest. The
64
+ // `?` overview passes `keyboardTransparent` to leave them all working.
65
+ if (!opts.keyboardTransparent)
66
+ A.clean(claimKeyboard());
67
+ // Esc closes the dialog — or, while `allowCancel` forbids it, is
68
+ // swallowed by the no-op press, so nothing below acts on it either.
69
+ // Anchored at whoever owned the keyboard as this dialog opened —
70
+ // itself, or with `keyboardTransparent` the modal beneath (or the page)
71
+ // — so it shadows whatever Esc meant so far, restored on close.
72
+ const escAt = keyboardOwner();
73
+ A(() => {
74
+ const can = opts.allowCancel !== false;
75
+ bindKey("esc", can ? "Close this dialog" : undefined, can ? close : () => { }, escAt);
76
+ });
58
77
  A(() => {
59
78
  if (opts.header != null) {
60
79
  A("header.s-s.neutral", opts.headerAttrs, () => drawSlot(opts.header));
@@ -69,8 +88,8 @@ mountPortal(() => {
69
88
  }
70
89
  });
71
90
  });
72
- // Once laid out, move focus into the dialog (first focusable element) so it's
73
- // keyboard-ready and focus doesn't linger on whatever opened it.
91
+ // Once laid out, move focus into the dialog so it's keyboard-ready and focus
92
+ // doesn't linger on whatever opened it.
74
93
  requestAnimationFrame(() => { if (document.body.contains(dialogEl))
75
94
  focusFirst(dialogEl); });
76
95
  });
@@ -97,31 +116,6 @@ mountPortal(() => {
97
116
  * ```
98
117
  */
99
118
  export function dialog(opts) {
100
- if (!dialogCount) {
101
- // Install Esc handler the first time we create a dialog
102
- document.addEventListener("keydown", (e) => {
103
- // A layer above us that consumed Escape (an open floating menu's capture
104
- // handler, or a widget inside the dialog like an open autocomplete list)
105
- // marks it with preventDefault — the dialog must not also act on it.
106
- if (e.key !== "Escape" || e.defaultPrevented)
107
- return;
108
- const ds = A.unproxy(dialogs);
109
- // Search for top-most dialog
110
- for (let i = dialogCount; i > 0; i--) {
111
- if (ds[i]) {
112
- // A dialog owns Escape, closable or not — handlers beneath it
113
- // must not also act (main()'s nav jump and cooperative page
114
- // handlers check defaultPrevented, or S.isDialogOpen()).
115
- e.preventDefault();
116
- if (ds[i].opts.allowCancel !== false) {
117
- // The clean handler should call resolve and onClose
118
- delete dialogs[i];
119
- }
120
- break;
121
- }
122
- }
123
- });
124
- }
125
119
  const dialogId = ++dialogCount;
126
120
  if (opts.cancelWithScope !== false)
127
121
  A.clean(() => { delete dialogs[dialogId]; });
@@ -1,11 +1,9 @@
1
1
  import { type Bindable, type Slot, type Attributes } from "../core.js";
2
2
  /**
3
3
  * Options shared by all *form field* components (textline, textarea, checkbox,
4
- * autocomplete, ...).
5
- *
6
- * Fields share a consistent vertical layout: an optional label, the control
7
- * itself, and optional help/error text below it. {@link form} relies on this
8
- * shared structure to align groups of fields.
4
+ * autocomplete, ...). Every field lays out the same way — optional label,
5
+ * control, optional help/error below — which is what lets {@link form} align
6
+ * groups of them.
9
7
  */
10
8
  export interface FieldOptions {
11
9
  /** Aberdeen attr/style string applied to the field's wrapper element. */
@@ -34,9 +32,8 @@ export interface FieldOptions {
34
32
  * Render the standard field chrome (label + control + help/error) around a
35
33
  * caller-supplied control.
36
34
  *
37
- * Each piece is read inside its own small reactive scope, so e.g. flipping
38
- * `error` on a proxied options object only re-renders the error line not the
39
- * control.
35
+ * Each piece gets its own reactive scope, so flipping `error` on a proxied
36
+ * options object re-renders just the error line, not the control.
40
37
  *
41
38
  * @param opts The field options.
42
39
  * @param drawControl Receives the resolved `id` and the live "invalid" getter,
@@ -9,9 +9,8 @@ A.insertGlobalCss({
9
9
  ".s-help": "font-size:0.82em fg:$s-muted",
10
10
  ".s-error": "font-size:0.82em fg:$s-danger",
11
11
  ".s-input": {
12
- // A light inset field, derived from the surrounding surface: the surface
13
- // background nudged slightly toward its ink, with the ink as text. Adapts to
14
- // whatever surface (and mode) holds the field, no fixed input colour needed.
12
+ // Derived from the surrounding surface its background nudged toward its ink —
13
+ // so the field adapts to any surface and mode, with no fixed input colour.
15
14
  "&": "w:100% background: color-mix(in oklab, $s-bg, $s-text 4%); color:$s-text border: 1px solid $s-faint; r:$s-radius padding: 0.55em 0.7em; transition: border-color 0.15s, box-shadow 0.15s;",
16
15
  "&:hover:not(:disabled)": "border-color: color-mix(in oklab, $s-text, $s-bg 55%);",
17
16
  "&:focus-visible": "border-color:$s-accent box-shadow: 0 0 0 3px $s-focus; outline:none",
@@ -22,9 +21,8 @@ A.insertGlobalCss({
22
21
  * Render the standard field chrome (label + control + help/error) around a
23
22
  * caller-supplied control.
24
23
  *
25
- * Each piece is read inside its own small reactive scope, so e.g. flipping
26
- * `error` on a proxied options object only re-renders the error line not the
27
- * control.
24
+ * Each piece gets its own reactive scope, so flipping `error` on a proxied
25
+ * options object re-renders just the error line, not the control.
28
26
  *
29
27
  * @param opts The field options.
30
28
  * @param drawControl Receives the resolved `id` and the live "invalid" getter,
@@ -19,13 +19,11 @@ export interface FormOptions extends ContentOptions {
19
19
  actions?: Slot;
20
20
  }
21
21
  /**
22
- * An opinionated `<form>` wrapper that lays its fields out consistently a clean
23
- * single column by default, or a responsive grid and provides a standard
24
- * action bar.
25
- *
26
- * Field components ({@link import("./textline").textline} et al.) drop straight
27
- * in as {@link ContentOptions.content}. Submission is wired so the browser's
28
- * native validation runs, but the page never reloads.
22
+ * An opinionated `<form>` wrapper: fields in a single column by default, or a
23
+ * responsive grid, plus a standard action bar. Field components
24
+ * ({@link import("./textline").textline} et al.) drop straight in as
25
+ * {@link ContentOptions.content}; the browser's native validation runs on submit,
26
+ * but the page never reloads.
29
27
  *
30
28
  * @example
31
29
  * ```ts
@@ -9,13 +9,11 @@ A.insertGlobalCss({
9
9
  },
10
10
  });
11
11
  /**
12
- * An opinionated `<form>` wrapper that lays its fields out consistently a clean
13
- * single column by default, or a responsive grid and provides a standard
14
- * action bar.
15
- *
16
- * Field components ({@link import("./textline").textline} et al.) drop straight
17
- * in as {@link ContentOptions.content}. Submission is wired so the browser's
18
- * native validation runs, but the page never reloads.
12
+ * An opinionated `<form>` wrapper: fields in a single column by default, or a
13
+ * responsive grid, plus a standard action bar. Field components
14
+ * ({@link import("./textline").textline} et al.) drop straight in as
15
+ * {@link ContentOptions.content}; the browser's native validation runs on submit,
16
+ * but the page never reloads.
19
17
  *
20
18
  * @example
21
19
  * ```ts
@@ -33,8 +31,7 @@ A.insertGlobalCss({
33
31
  export function form(opts = {}) {
34
32
  const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
35
33
  A(`form.s-form`, o.attrs, () => {
36
- // Toggle grid class in its own scope so changing layout doesn't recreate
37
- // the fields (which would lose focus / input state).
34
+ // Own scope, so a layout change doesn't recreate the fields (losing focus/input state).
38
35
  A(() => {
39
36
  A(".grid=", o.layout === 'grid');
40
37
  });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The shortcut overview: a dialog listing what a keypress could do *right now*
3
+ * — every described binding the keyboard focus and any open modal leave in
4
+ * effect: buttons under their label, menu items under theirs, {@link bindKey}
5
+ * bindings under the description they were given.
6
+ *
7
+ * It's a cheat-sheet, not a modal: the shortcuts it lists keep working, and any
8
+ * keypress closes it *and* still lands — so the key you just looked up can be
9
+ * pressed right there. That is also what keeps the listing honest: focus cannot
10
+ * move while it is up (a click lands on the backdrop, a key closes it), so what
11
+ * was true when it opened stays true. Bound to `?` (and `mod+?`, which also
12
+ * works while typing in a field) by default — see {@link setKeyHelp}. Calling
13
+ * this while the overview is already up closes it, which is what lets that `?`
14
+ * toggle.
15
+ */
16
+ export declare function showKeyHelp(): void;
17
+ /**
18
+ * Turn the default overview shortcuts off (or back on): `?` — free, like any
19
+ * unmodified key, whenever nothing is being typed into — and `mod+?`, which
20
+ * reaches the overview even from inside a text field. On by default.
21
+ */
22
+ export declare function setKeyHelp(enabled: boolean): void;