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
@@ -3,6 +3,7 @@ import { type Slot, type Attributes, drawSlot, mountPortal, focusFirst } from ".
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
 
7
8
  /** Options for {@link dialog}. */
8
9
  export interface DialogOptions {
@@ -25,10 +26,17 @@ export interface DialogOptions {
25
26
  */
26
27
  allowCancel?: boolean;
27
28
  /**
28
- * When set to `true` (default) the model will be destroyed when the `dialog()`-calling
29
- * scope is destroyed.
29
+ * When `true` (default), the dialog is destroyed when the scope that called
30
+ * `dialog()` is destroyed.
30
31
  */
31
32
  cancelWithScope?: boolean;
33
+ /**
34
+ * Leave the keyboard shortcuts behind this dialog working, instead of owning
35
+ * the keyboard the way a modal does. For overlays that inform rather than
36
+ * interrupt — the `?` shortcut overview is one. Esc still closes it first,
37
+ * whatever it meant before.
38
+ */
39
+ keyboardTransparent?: boolean;
32
40
  /**
33
41
  * Dialog body. A {@link Slot} whose draw-function receives a `close()` function
34
42
  * — call it to dismiss the dialog programmatically. (A plain string renders as
@@ -48,16 +56,14 @@ A.insertGlobalCss({
48
56
  "&": "position:fixed inset:0 z-index:200 display:block background: rgba(0,0,0,0.55); transition: opacity 0.4s ease-in-out;",
49
57
  "&.hidden": "opacity:0 pointer-events:none",
50
58
  },
51
- // The dialog panel is a `.s-s.neutral.extra-shadow` surface: border, radius (lg)
52
- // and the deep floating shadow come from the surface itself (see theme.ts).
59
+ // Border, radius and the deep floating shadow come from the panel's
60
+ // `.s-s.neutral.extra-shadow` surface (see theme.ts).
53
61
  ".s-dialog": {
54
62
  "&":
55
63
  "position:fixed z-index:200 top:50% left:50% " +
56
64
  "display:flex flex-direction:column " +
57
65
  "transform:translate(-50%,-50%) " +
58
- // vw/vh divided by --s-zoom: viewport units shrink with the page-fitting
59
- // zoom (see `watchScale` in main.ts), and these caps mean the window.
60
- "min-width:20rem max-width:min(calc(90vw/var(--s-zoom,1)),44rem) max-height:min(calc(88vh/var(--s-zoom,1)),800px) " +
66
+ "min-width:min(20rem,90vw) max-width:min(90vw,44rem) max-height:min(88vh,800px) " +
61
67
  "r: $s-radius-lg; overflow:hidden " +
62
68
  "transition: opacity 0.2s ease-out, transform 0.2s ease-out;",
63
69
  "> header":
@@ -71,7 +77,7 @@ A.insertGlobalCss({
71
77
  },
72
78
  });
73
79
 
74
- const dialogs = A.proxy({} as Record<number,{resolve: (value: void | PromiseLike<void>) => void, opts: DialogOptions}>);
80
+ const dialogs = A.proxy({} as Record<string,{resolve: (value: void | PromiseLike<void>) => void, opts: DialogOptions}>);
75
81
  let dialogCount = 0;
76
82
 
77
83
  const topDialogId = A.derive(() => {
@@ -89,20 +95,40 @@ mountPortal(() => {
89
95
  const close = () => { delete dialogs[dialogId]; };
90
96
 
91
97
  A.clean(() => {
92
- // Fires when this render is torn down — either because $closed became
93
- // true (normal close) or because the parent reactive scope was cleaned up.
98
+ // Fires on a normal close and on parent-scope teardown alike.
94
99
  opts.onClose?.();
95
100
  resolve();
96
101
  });
97
102
 
103
+ // Return focus to where it was when this dialog opened, so a keyboard
104
+ // user doesn't lose their place.
105
+ const prevFocus = document.activeElement;
106
+ A.clean(() => {
107
+ if (prevFocus instanceof HTMLElement && document.contains(prevFocus)) prevFocus.focus();
108
+ });
109
+
98
110
  // Backdrop - hide when not the top dialog
99
111
  const overlaid = A.derive(() => topDialogId.value != dialogId);
100
112
  A("div.s-backdrop create=hidden destroy=hidden .hidden=", overlaid, "click=", () => {
101
113
  if (opts.allowCancel !== false) close();
102
114
  });
103
115
 
104
- // Dialog itself
105
116
  const dialogEl = A("div.s-dialog.neutral.s-s.extra-shadow create=hidden destroy=hidden", opts.attrs, () => {
117
+ // A modal owns the keyboard: claiming makes the shortcuts drawn inside
118
+ // (the content below included) register here and silences the rest. The
119
+ // `?` overview passes `keyboardTransparent` to leave them all working.
120
+ if (!opts.keyboardTransparent) A.clean(claimKeyboard());
121
+ // Esc closes the dialog — or, while `allowCancel` forbids it, is
122
+ // swallowed by the no-op press, so nothing below acts on it either.
123
+ // Anchored at whoever owned the keyboard as this dialog opened —
124
+ // itself, or with `keyboardTransparent` the modal beneath (or the page)
125
+ // — so it shadows whatever Esc meant so far, restored on close.
126
+ const escAt = keyboardOwner();
127
+ A(() => {
128
+ const can = opts.allowCancel !== false;
129
+ bindKey("esc", can ? "Close this dialog" : undefined, can ? close : () => {}, escAt);
130
+ });
131
+
106
132
  A(() => {
107
133
  if (opts.header != null) {
108
134
  A("header.s-s.neutral", opts.headerAttrs, () => drawSlot(opts.header));
@@ -120,8 +146,8 @@ mountPortal(() => {
120
146
  });
121
147
  }) as HTMLElement;
122
148
 
123
- // Once laid out, move focus into the dialog (first focusable element) so it's
124
- // keyboard-ready and focus doesn't linger on whatever opened it.
149
+ // Once laid out, move focus into the dialog so it's keyboard-ready and focus
150
+ // doesn't linger on whatever opened it.
125
151
  requestAnimationFrame(() => { if (document.body.contains(dialogEl)) focusFirst(dialogEl); });
126
152
  });
127
153
  })
@@ -148,30 +174,6 @@ mountPortal(() => {
148
174
  * ```
149
175
  */
150
176
  export function dialog(opts: DialogOptions): Promise<void> {
151
- if (!dialogCount) {
152
- // Install Esc handler the first time we create a dialog
153
- document.addEventListener("keydown", (e: KeyboardEvent) => {
154
- // A layer above us that consumed Escape (an open floating menu's capture
155
- // handler, or a widget inside the dialog like an open autocomplete list)
156
- // marks it with preventDefault — the dialog must not also act on it.
157
- if (e.key !== "Escape" || e.defaultPrevented) return;
158
- const ds = A.unproxy(dialogs);
159
- // Search for top-most dialog
160
- for(let i=dialogCount; i>0; i--) {
161
- if (ds[i]) {
162
- // A dialog owns Escape, closable or not — handlers beneath it
163
- // must not also act (main()'s nav jump and cooperative page
164
- // handlers check defaultPrevented, or S.isDialogOpen()).
165
- e.preventDefault();
166
- if (ds[i].opts.allowCancel !== false) {
167
- // The clean handler should call resolve and onClose
168
- delete dialogs[i];
169
- }
170
- break;
171
- }
172
- }
173
- });
174
- }
175
177
  const dialogId = ++dialogCount;
176
178
  if (opts.cancelWithScope !== false) A.clean(() => { delete dialogs[dialogId]; });
177
179
  return new Promise<void>((resolve) => {
@@ -3,11 +3,9 @@ import { type Bindable, type Slot, type Attributes, drawSlot, uniqueId } from ".
3
3
 
4
4
  /**
5
5
  * Options shared by all *form field* components (textline, textarea, checkbox,
6
- * autocomplete, ...).
7
- *
8
- * Fields share a consistent vertical layout: an optional label, the control
9
- * itself, and optional help/error text below it. {@link form} relies on this
10
- * shared structure to align groups of fields.
6
+ * autocomplete, ...). Every field lays out the same way — optional label,
7
+ * control, optional help/error below — which is what lets {@link form} align
8
+ * groups of them.
11
9
  */
12
10
  export interface FieldOptions {
13
11
  /** Aberdeen attr/style string applied to the field's wrapper element. */
@@ -42,9 +40,8 @@ A.insertGlobalCss({
42
40
  ".s-help": "font-size:0.82em fg:$s-muted",
43
41
  ".s-error": "font-size:0.82em fg:$s-danger",
44
42
  ".s-input": {
45
- // A light inset field, derived from the surrounding surface: the surface
46
- // background nudged slightly toward its ink, with the ink as text. Adapts to
47
- // whatever surface (and mode) holds the field, no fixed input colour needed.
43
+ // Derived from the surrounding surface — its background nudged toward its ink —
44
+ // so the field adapts to any surface and mode, with no fixed input colour.
48
45
  "&": "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;",
49
46
  "&:hover:not(:disabled)": "border-color: color-mix(in oklab, $s-text, $s-bg 55%);",
50
47
  "&:focus-visible": "border-color:$s-accent box-shadow: 0 0 0 3px $s-focus; outline:none",
@@ -56,9 +53,8 @@ A.insertGlobalCss({
56
53
  * Render the standard field chrome (label + control + help/error) around a
57
54
  * caller-supplied control.
58
55
  *
59
- * Each piece is read inside its own small reactive scope, so e.g. flipping
60
- * `error` on a proxied options object only re-renders the error line — not the
61
- * control.
56
+ * Each piece gets its own reactive scope, so flipping `error` on a proxied
57
+ * options object re-renders just the error line, not the control.
62
58
  *
63
59
  * @param opts The field options.
64
60
  * @param drawControl Receives the resolved `id` and the live "invalid" getter,
@@ -31,13 +31,11 @@ A.insertGlobalCss({
31
31
  });
32
32
 
33
33
  /**
34
- * An opinionated `<form>` wrapper that lays its fields out consistently — a clean
35
- * single column by default, or a responsive grid — and provides a standard
36
- * action bar.
37
- *
38
- * Field components ({@link import("./textline").textline} et al.) drop straight
39
- * in as {@link ContentOptions.content}. Submission is wired so the browser's
40
- * native validation runs, but the page never reloads.
34
+ * An opinionated `<form>` wrapper: fields in a single column by default, or a
35
+ * responsive grid, plus a standard action bar. Field components
36
+ * ({@link import("./textline").textline} et al.) drop straight in as
37
+ * {@link ContentOptions.content}; the browser's native validation runs on submit,
38
+ * but the page never reloads.
41
39
  *
42
40
  * @example
43
41
  * ```ts
@@ -56,8 +54,7 @@ export function form(opts: FormOptions | Slot = {}): void {
56
54
  const o: FormOptions = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
57
55
 
58
56
  A(`form.s-form`, o.attrs, () => {
59
- // Toggle grid class in its own scope so changing layout doesn't recreate
60
- // the fields (which would lose focus / input state).
57
+ // Own scope, so a layout change doesn't recreate the fields (losing focus/input state).
61
58
  A(() => {
62
59
  A(".grid=", o.layout === 'grid');
63
60
  });
@@ -0,0 +1,96 @@
1
+ import A from "aberdeen";
2
+ import { drawSlot } from "../core.js";
3
+ import { bindKey, formatKey, getActiveKeyBindings } from "../keys.js";
4
+ import { dialog } from "./dialog.js";
5
+
6
+ A.insertGlobalCss({
7
+ ".s-keyhelp": {
8
+ "&": "display:flex flex-direction:column gap:$1 min-width:14rem",
9
+ "> div": "display:flex align-items:baseline justify-content:space-between gap:$4",
10
+ "kbd": "font-family:inherit font-size:0.85em fg:$s-muted white-space:nowrap border: 1px solid $s-faint; r:$s-radius-sm padding: 0 0.4em;",
11
+ },
12
+ });
13
+
14
+ /** Non-null while the overview is up; calling it closes the dialog. */
15
+ let closeHelp: (() => void) | null = null;
16
+
17
+ /**
18
+ * The shortcut overview: a dialog listing what a keypress could do *right now*
19
+ * — every described binding the keyboard focus and any open modal leave in
20
+ * effect: buttons under their label, menu items under theirs, {@link bindKey}
21
+ * bindings under the description they were given.
22
+ *
23
+ * It's a cheat-sheet, not a modal: the shortcuts it lists keep working, and any
24
+ * keypress closes it *and* still lands — so the key you just looked up can be
25
+ * pressed right there. That is also what keeps the listing honest: focus cannot
26
+ * move while it is up (a click lands on the backdrop, a key closes it), so what
27
+ * was true when it opened stays true. Bound to `?` (and `mod+?`, which also
28
+ * works while typing in a field) by default — see {@link setKeyHelp}. Calling
29
+ * this while the overview is already up closes it, which is what lets that `?`
30
+ * toggle.
31
+ */
32
+ export function showKeyHelp(): void {
33
+ if (closeHelp) {
34
+ closeHelp();
35
+ return;
36
+ }
37
+ // What a keypress could do at the moment of asking, snapshotted before the
38
+ // overview's own dialog (whose Esc shadows the Esc row shown here) exists.
39
+ // Focus cannot move while the overview is up, so the listing stays true —
40
+ // except for Esc itself, which dismisses the overview first.
41
+ const entries = getActiveKeyBindings(document.activeElement);
42
+ void dialog({
43
+ header: "Keyboard shortcuts",
44
+ // Usually pressed into being from the global `?` handler, where there is
45
+ // no scope to tie the dialog's life to.
46
+ cancelWithScope: false,
47
+ // The whole point: the keys it lists stay pressable while it is up.
48
+ keyboardTransparent: true,
49
+ onClose: () => { closeHelp = null; },
50
+ content: (close) => {
51
+ closeHelp = close;
52
+
53
+ // Any keypress closes the overview, in the capture phase so the key
54
+ // then travels on to whoever handles it. Except: bare modifiers (the
55
+ // combination they start still needs reading), Esc (the dialog's own
56
+ // close, handled there) and `?` (the toggle binding's).
57
+ const closer = (e: KeyboardEvent) => {
58
+ if (e.repeat || ["Control", "Shift", "Alt", "Meta", "Escape", "?"].includes(e.key)) return;
59
+ close();
60
+ };
61
+ document.addEventListener("keydown", closer, true);
62
+ A.clean(() => document.removeEventListener("keydown", closer, true));
63
+
64
+ A("div.s-keyhelp", () => {
65
+ for (const [keyStr, binding] of entries) {
66
+ if (binding.description === undefined) continue;
67
+ A("div", () => {
68
+ A("span", () => drawSlot(binding.description));
69
+ A("kbd text=", formatKey(keyStr));
70
+ });
71
+ }
72
+ });
73
+ },
74
+ });
75
+ }
76
+
77
+ /** Whether the default `?` / `mod+?` bindings are on. */
78
+ const $enabled = A.proxy(true);
79
+
80
+ /**
81
+ * Turn the default overview shortcuts off (or back on): `?` — free, like any
82
+ * unmodified key, whenever nothing is being typed into — and `mod+?`, which
83
+ * reaches the overview even from inside a text field. On by default.
84
+ */
85
+ export function setKeyHelp(enabled: boolean): void {
86
+ $enabled.value = enabled;
87
+ }
88
+
89
+ // Global bindings, so they keep working while a dialog is up — the overview
90
+ // should answer anywhere. It lists itself under the `mod+?` spelling, the one
91
+ // that is true everywhere, a text field included; the bare `?` stays quiet.
92
+ A(() => {
93
+ if (!$enabled.value) return;
94
+ bindKey("?", undefined, showKeyHelp, "global");
95
+ bindKey("mod+?", "This overview", showKeyHelp, "global");
96
+ });