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
@@ -38,8 +38,7 @@ A.insertGlobalCss({
38
38
  export function select(opts: SelectOptions): void {
39
39
  drawField(opts, (id, isInvalid) => {
40
40
  A("div.s-select_wrap", () => {
41
- // On the <select> itself, like every other field control — an
42
- // aria-label on the wrapper div would label nothing.
41
+ // Control attrs go on the <select>: on the wrapper div they'd label nothing.
43
42
  A("select.s-input", opts.inputAttrs, () => {
44
43
  applyControlAttrs(opts, id, isInvalid);
45
44
 
@@ -47,7 +46,6 @@ export function select(opts: SelectOptions): void {
47
46
  if (opts.bind) opts.bind.value = (e.target as HTMLSelectElement).value;
48
47
  });
49
48
 
50
- // Render options reactively; re-runs when options list or selected value changes.
51
49
  A(() => {
52
50
  const raw = typeof opts.options === "function" ? opts.options() : opts.options;
53
51
  const current = (opts.bind?.value ?? "") as string;
@@ -1,5 +1,5 @@
1
1
  import A from "aberdeen";
2
- import { cssZoom, type Bindable, type Slot, type Attributes, drawSlot, uniqueId } from "../core.js";
2
+ import { type Bindable, type Slot, type Attributes, drawSlot, uniqueId } from "../core.js";
3
3
  import { mk } from "../icons-helpers.js";
4
4
 
5
5
  /** A single tab definition. */
@@ -41,18 +41,12 @@ export interface TabsOptions {
41
41
  contentAttrs?: Attributes;
42
42
  }
43
43
 
44
- // Chevrons for the scroll buttons, as inline SVG (the icon set's own helper, so
45
- // no icon data comes along) rather than `‹`/`›` characters — matching Lucide's
46
- // `chevron-left`/`chevron-right`.
44
+ // Lucide's chevrons, inlined via the icon set's helper so no icon data tags along.
47
45
  const chevronLeft = mk('<path d="m15 18-6-6 6-6"/>');
48
46
  const chevronRight = mk('<path d="m9 18 6-6-6-6"/>');
49
47
 
50
48
  A.insertGlobalCss({
51
- // A row that scrolls sideways once its content outgrows it, with a ‹ / ›
52
- // button appearing over whichever end still has something to reach. Its own
53
- // scrollbar is hidden — a raw scrollbar under a row of chrome reads as a
54
- // mistake — so those buttons, and the fade they sit in, are the affordance.
55
- // Shared by `tabs()` and the shell's breadcrumb stack; see `scrollStrip()`.
49
+ // See `scrollStrip()`. Shared by `tabs()` and the shell's breadcrumb stack.
56
50
  ".s-strip": {
57
51
  // The positioning context the buttons overlay from.
58
52
  "&": "position:relative display:flex min-width:0",
@@ -64,8 +58,7 @@ A.insertGlobalCss({
64
58
  "overflow-x:auto overflow-y:hidden scrollbar-width:none scroll-behavior:smooth",
65
59
  "> .s-strip-row::-webkit-scrollbar": "display:none",
66
60
  // The buttons overlay the row's ends rather than sitting beside it, so no
67
- // width is reserved when there's nothing to scroll — and the content slides
68
- // out from under a fade instead of stopping at a hard edge.
61
+ // width is reserved when there's nothing to scroll.
69
62
  "> .s-strip-btn":
70
63
  "position:absolute top:0 bottom:0 z-index:1 display:none align-items:center justify-content:center " +
71
64
  "width:2.4em border:0 padding:0 cursor:pointer fg:$s-muted " +
@@ -73,20 +66,19 @@ A.insertGlobalCss({
73
66
  "> .s-strip-btn:hover": "fg:$s-text",
74
67
  "> .s-strip-btn-left": "left:0 justify-content:flex-start background: linear-gradient(to right, $s-bg 45%, transparent)",
75
68
  "> .s-strip-btn-right": "right:0 justify-content:flex-end background: linear-gradient(to left, $s-bg 45%, transparent)",
76
- // Shown only for the direction there is actually something to scroll towards,
77
- // so the pair doubles as a position indicator.
69
+ // Shown only where there is something to scroll towards, so the pair doubles
70
+ // as a position indicator.
78
71
  "&.s-can-left > .s-strip-btn-left, &.s-can-right > .s-strip-btn-right": "display:flex",
79
72
  },
80
73
  ".s-tabs": {
81
74
  "&": "display:flex flex-direction:column gap:$3",
82
- // The bar owns the hairline, so it runs the full width — under the scroll
83
- // buttons too.
75
+ // The bar, not the strip, owns the hairline, so it runs the full width.
84
76
  ".s-tabbar": "border-bottom: 1px solid $s-faint;",
85
77
  ".s-tablist":
86
78
  "gap:$1 align-items:stretch " +
87
- // Pulls the strip 1px down over the bar's hairline, so the active tab's
88
- // underline lands *on* it rather than stacking above it. On the strip, not
89
- // the tabs: a negative margin inside a scroll container is overflow.
79
+ // Pulls the strip down over the bar's hairline, so the active tab's underline
80
+ // lands *on* it. On the strip, not the tabs: a negative margin inside a
81
+ // scroll container is overflow.
90
82
  "margin-bottom:-1px",
91
83
  ".s-tab":
92
84
  "display:inline-flex align-items:center gap:$2 cursor:pointer background:transparent " +
@@ -94,8 +86,8 @@ A.insertGlobalCss({
94
86
  "border-bottom: 3px solid transparent; " +
95
87
  "transition: color 0.15s, background 0.15s, border-color 0.15s;",
96
88
  ".s-tab:hover:not(:disabled), .s-tab[aria-selected=true]": "color: $s-text;",
97
- // An inset ring: the strip is a scroll container, so it clips its own
98
- // painting, and an outset ring on the first/last tab would be shaved off.
89
+ // An inset ring: the strip clips its own painting, so an outset ring on the
90
+ // first/last tab would be shaved off.
99
91
  ".s-tab:focus-visible": "outline:none box-shadow: inset 0 0 0 2px $s-focus; r: $s-radius;",
100
92
  ".s-tab[aria-selected=true]": "border-image: $s-gradient 1;",
101
93
  ".s-tabpanel": "display:block",
@@ -104,16 +96,14 @@ A.insertGlobalCss({
104
96
 
105
97
  /**
106
98
  * A horizontal row that scrolls when its content outgrows it, with a ‹ / ›
107
- * button appearing over whichever end still has something left to reach — a
108
- * bare scroll area says nothing about itself to a mouse, and a scrollbar under
109
- * a row of chrome reads as a mistake. The row's own scrollbar is hidden, and
110
- * the buttons scroll it by most of a width at a time.
99
+ * button appearing over whichever end still has something left to reach — so it
100
+ * isn't just a swipe target. The row's own scrollbar is hidden, and the buttons
101
+ * scroll it by most of a width at a time.
111
102
  *
112
- * This is what {@link tabs} puts its tab strip in, and what the routed
113
- * {@link main} shell puts its breadcrumb stack in. Reach for it for any row of
114
- * chrome that can outgrow its space: a filter bar, a row of chips, a toolbar.
115
- * To bring one of its children into view — after selecting it from elsewhere,
116
- * say — call {@link revealInStrip} with that child.
103
+ * {@link tabs} puts its tab strip in one, and the routed {@link main} shell its
104
+ * breadcrumb stack. Reach for it for any row of chrome that can outgrow its
105
+ * space: a filter bar, a row of chips, a toolbar. {@link revealInStrip} brings
106
+ * one of its children into view.
117
107
  *
118
108
  * @example
119
109
  * ```ts
@@ -140,14 +130,11 @@ export function scrollStrip(opts: ScrollStripOptions): void {
140
130
  export function revealInStrip(el: HTMLElement): void {
141
131
  const row = el.parentElement;
142
132
  if (!row || !el.isConnected) return;
143
- // The overlays are 2.4em wide; clearing a little more than that keeps the
144
- // revealed item from sitting right against one.
133
+ // The overlays are 2.4em wide; clear a little more, so the revealed item
134
+ // doesn't sit right against one.
145
135
  const pad = parseFloat(getComputedStyle(row).fontSize) * 2.6;
146
- // The rects are in window coordinates while `scrollBy` counts in the row's
147
- // own space; their difference scales over by the row's zoom (see `cssZoom`).
148
- const z = cssZoom(row);
149
136
  const box = el.getBoundingClientRect(), strip = row.getBoundingClientRect();
150
- const left = (box.left - strip.left) / z, right = (box.right - strip.right) / z;
137
+ const left = box.left - strip.left, right = box.right - strip.right;
151
138
  if (left < pad) row.scrollBy({ left: left - pad, behavior: "smooth" });
152
139
  else if (right > -pad) row.scrollBy({ left: right + pad, behavior: "smooth" });
153
140
  }
@@ -156,10 +143,9 @@ export function revealInStrip(el: HTMLElement): void {
156
143
  * A tabbed view. Renders an ARIA `tablist` of buttons and a single live panel
157
144
  * for the selected tab. Supports keyboard navigation (left/right/home/end).
158
145
  *
159
- * More tabs than fit make the strip scroll sideways, with a ‹ / › button
160
- * appearing over whichever end still has something to reach — a bare scroll area
161
- * says nothing about itself to a mouse. Selecting a tab that's out of view
162
- * (arrow keys, or a `bind` written from elsewhere) scrolls it back in.
146
+ * More tabs than fit make the strip scroll sideways (see {@link scrollStrip});
147
+ * selecting a tab that's out of view — with the arrow keys, or a `bind` written
148
+ * from elsewhere — scrolls it back in.
163
149
  *
164
150
  * @example
165
151
  * ```ts
@@ -172,13 +158,11 @@ export function revealInStrip(el: HTMLElement): void {
172
158
  export function tabs(opts: TabsOptions): void {
173
159
  const groupId = uniqueId("tabs");
174
160
 
175
- // Resolve a tab's selection key (its id, or its index as a string).
176
161
  const keyOf = (tab: Tab, index: number) => tab.id ?? String(index);
177
162
 
178
- // Selection state: caller-provided binding, or internal.
179
163
  const $sel: Bindable<string> = opts.bind ?? A.proxy(keyOf(opts.tabs[0] ?? { label: "" }, 0));
180
164
 
181
- // If the current value isn't a valid tab key, reset to the first tab.
165
+ // A bound value that names no tab falls back to the first one.
182
166
  if (opts.tabs.length > 0 && !opts.tabs.some((t, i) => keyOf(t, i) === A.peek(() => $sel.value))) {
183
167
  $sel.value = keyOf(opts.tabs[0], 0);
184
168
  }
@@ -201,9 +185,7 @@ export function tabs(opts: TabsOptions): void {
201
185
  const selected = $sel.value === key;
202
186
  A("aria-selected=", selected ? "true" : "false");
203
187
  A("tabindex=", selected ? "0" : "-1");
204
- // Selecting a tab that's (partly) scrolled out brings it into
205
- // view, so the strip follows the selection however it was made:
206
- // a click, the arrow keys, or a `bind` written from elsewhere.
188
+ // So the strip follows the selection however it was made.
207
189
  if (selected) requestAnimationFrame(() => revealInStrip(tabEl as HTMLElement));
208
190
  });
209
191
  if (tab.disabled) A("disabled=true");
@@ -230,16 +212,14 @@ export function tabs(opts: TabsOptions): void {
230
212
  }
231
213
 
232
214
  /**
233
- * One of the two scroll buttons overlaying the ends of the row. `dir` is -1 for
234
- * the left one and 1 for the right. Most of a width at a time (not one item at a
235
- * time): the row scrolls smoothly, so a nudge that moved a single tab or crumb
236
- * would read as a twitch.
215
+ * One of the two scroll buttons overlaying the ends of the row (`dir` is -1 for
216
+ * left, 1 for right). It scrolls most of a width at a time: a nudge of a single
217
+ * tab or crumb would read as a twitch.
237
218
  */
238
219
  function drawScrollButton(row: HTMLElement, dir: -1 | 1): void {
239
220
  A(`button.s-strip-btn.s-strip-btn-${dir < 0 ? "left" : "right"} type=button`, () => {
240
- // The row's own items are the real control; this is a convenience it offers
241
- // a mouse. Keeping it out of the tab order means Tab still steps from the
242
- // row straight into whatever follows.
221
+ // A mouse convenience only — out of the tab order, so Tab still steps from
222
+ // the row straight into whatever follows.
243
223
  A("tabindex=-1 aria-hidden=true");
244
224
  A("click=", () => row.scrollBy({ left: dir * row.clientWidth * 0.8, behavior: "smooth" }));
245
225
  (dir < 0 ? chevronLeft : chevronRight)({ size: "1.1em" });
@@ -247,17 +227,16 @@ function drawScrollButton(row: HTMLElement, dir: -1 | 1): void {
247
227
  }
248
228
 
249
229
  /**
250
- * Keep the `.s-can-left` / `.s-can-right` classes on the strip in step with what
251
- * there is left to scroll towards, so each button appears exactly when it has
252
- * somewhere to go. Watches the row's own scrolling *and* its size — and its
253
- * children's, since items arriving or leaving (a pushed page's crumb, a tab)
254
- * changes the answer without any scrolling at all.
230
+ * Keep the strip's `.s-can-left` / `.s-can-right` classes in step with what there
231
+ * is left to scroll towards. Watches the row's scrolling and its size — and its
232
+ * children's, since items arriving or leaving change the answer without any
233
+ * scrolling at all.
255
234
  */
256
235
  function watchScroll(row: HTMLElement): void {
257
236
  const strip = row.parentElement;
258
237
  if (!strip || typeof ResizeObserver === "undefined") return; // No-op outside the browser.
259
238
  const update = () => {
260
- // A sub-pixel slack: fractional layout widths otherwise leave a permanent
239
+ // Sub-pixel slack: fractional layout widths otherwise leave a permanent
261
240
  // half-pixel of "scrollable" at an end that is plainly already reached.
262
241
  const max = row.scrollWidth - row.clientWidth;
263
242
  strip.classList.toggle("s-can-left", row.scrollLeft > 1);
@@ -266,11 +245,9 @@ function watchScroll(row: HTMLElement): void {
266
245
  row.addEventListener("scroll", update, { passive: true });
267
246
  const ro = new ResizeObserver(update);
268
247
  ro.observe(row);
269
- // The children are watched through one observer that follows the row's live
270
- // contents, so a strip whose items come and go keeps answering correctly.
271
- // Removed children are unobserved: a strip whose items churn (the breadcrumb
272
- // row redraws on navigation) would otherwise grow the observation list — and
273
- // retain the detached elements — without bound.
248
+ // One observer follows the row's live children. Removed ones must be
249
+ // unobserved: a strip whose items churn (the breadcrumbs, on navigation) would
250
+ // otherwise retain every detached element for as long as it lives.
274
251
  const mo = typeof MutationObserver === "undefined" ? undefined : new MutationObserver((records) => {
275
252
  for (const record of records) {
276
253
  for (const el of record.addedNodes) if (el instanceof Element) ro.observe(el);
@@ -40,11 +40,9 @@ export interface TextlineOptions extends FieldOptions {
40
40
  }
41
41
 
42
42
  /**
43
- * A single-line text input — covering text, passwords, numbers, email, dates and
44
- * the other line-oriented `<input>` types.
45
- *
46
- * Renders inside the standard {@link drawField} chrome (label, control,
47
- * help/error), so it aligns cleanly inside a {@link form}.
43
+ * A single-line text input — text, passwords, numbers, email, dates and the other
44
+ * line-oriented `<input>` types. Renders inside the standard {@link drawField}
45
+ * chrome (label, control, help/error), so it aligns cleanly inside a {@link form}.
48
46
  *
49
47
  * @example
50
48
  * ```ts
@@ -8,9 +8,7 @@ export interface ToastOptions {
8
8
  message: Slot;
9
9
  /** Optional bold title above the message. */
10
10
  title?: Slot;
11
- /**
12
- * Colour role. Defaults to `"neutral"`.
13
- */
11
+ /** Colour role. Defaults to `"neutral"`. */
14
12
  type?: "success" | "danger" | "warning" | "neutral"
15
13
  /**
16
14
  * Auto-dismiss delay in milliseconds. Defaults to `6000`.
@@ -32,9 +30,7 @@ A.insertGlobalCss({
32
30
  ".s-toasts":
33
31
  "position:fixed bottom:$3 right:$3 z-index:400 " +
34
32
  "display:flex flex-direction:column gap:$2 " +
35
- // 90vw divided by --s-zoom: viewport units shrink with the page-fitting
36
- // zoom (see `watchScale` in main.ts), and this cap means the window.
37
- "pointer-events:none max-width:min(calc(90vw/var(--s-zoom,1)),24rem) w:24rem",
33
+ "pointer-events:none max-width:min(90vw,24rem) w:24rem",
38
34
  ".s-toast": {
39
35
  "&":
40
36
  "display:flex align-items:flex-start gap:$2 " +
@@ -56,14 +52,13 @@ let toastCount = 0;
56
52
  const toasts = A.proxy({} as Record<number, ToastEntry>);
57
53
 
58
54
  mountPortal(() => {
59
- // In initial peek is here to NOT subscribe to changes once `toasts` first becomes non empty,
60
- // leaving the container in the DOM forever after. (So as not to cut of hide animations.)
55
+ // The peek keeps this scope from resubscribing once `toasts` is non-empty, so the
56
+ // container stays in the DOM afterwards and exit animations aren't cut off.
61
57
  if (A.peek(() => A.isEmpty(toasts)) && A.isEmpty(toasts)) return;
62
58
  A("div.s-toasts", () => {
63
59
  A.onEach(toasts, (entry) => {
64
60
  const { opts, id } = entry;
65
61
  const role = opts.type === "danger" || opts.type === "warning" ? "alert" : "status";
66
- // "neutral" (the default) is a neutral surface; the rest are accent surfaces.
67
62
  const surface = opts.type == null || opts.type === "neutral" ? "neutral" : opts.type;
68
63
  const duration = opts.duration ?? 6000;
69
64
 
@@ -1,5 +1,5 @@
1
1
  import A from "aberdeen";
2
- import { cssZoom, type Slot, type Attributes, drawSlot, mountPortal } from "../core.js";
2
+ import { type Slot, type Attributes, drawSlot, mountPortal } from "../core.js";
3
3
 
4
4
  /** Options for {@link addTooltip}. */
5
5
  export interface TooltipOptions {
@@ -14,9 +14,8 @@ export interface TooltipOptions {
14
14
  attrs?: Attributes;
15
15
  }
16
16
 
17
- // The tip is a `.s-s.neutral.shadow` surface (it portals to <body>, so it renders at
18
- // the page's next neutral shade), which provides its background, ink, border,
19
- // radius and elevation.
17
+ // Background, ink, border, radius and elevation come from the `.s-s.neutral.shadow`
18
+ // surface; portalled to <body>, it renders at the page's next neutral shade.
20
19
  A.insertGlobalCss({
21
20
  ".s-tt-tip": {
22
21
  "&":
@@ -29,8 +28,7 @@ A.insertGlobalCss({
29
28
 
30
29
  // ─── Global portal state ────────────────────────────────────────────────────
31
30
 
32
- // At most one tooltip is visible at a time. The anchor is the element the
33
- // handlers were attached to (its bounding rect drives positioning).
31
+ // At most one tooltip at a time; the anchor is the element whose rect positions it.
34
32
  const $ttActive = A.proxy<{ opts: TooltipOptions; anchor: HTMLElement } | undefined>(undefined);
35
33
  let hideTimer: ReturnType<typeof setTimeout> | null = null;
36
34
 
@@ -39,12 +37,10 @@ if (typeof window !== "undefined") {
39
37
  window.addEventListener("scroll", () => { $ttActive.value = undefined; }, { capture: true, passive: true });
40
38
  }
41
39
 
42
- function computePos(rect: DOMRect, tipW: number, tipH: number, placement: string, zoom: number): { x: number; y: number } {
43
- // `rect` is handed over already in the tip's own coordinate space; the
44
- // window's size has to be brought into it too (see `cssZoom`).
40
+ function computePos(rect: DOMRect, tipW: number, tipH: number, placement: string): { x: number; y: number } {
45
41
  const gap = 7;
46
- const vw = window.innerWidth / zoom;
47
- const vh = window.innerHeight / zoom;
42
+ const vw = window.innerWidth;
43
+ const vh = window.innerHeight;
48
44
  let x = 0, y = 0;
49
45
 
50
46
  if (placement === "bottom") {
@@ -98,12 +94,8 @@ mountPortal(() => {
98
94
 
99
95
  requestAnimationFrame(() => {
100
96
  if (!document.body.contains(tipEl)) return;
101
- // The anchor's rect is in window coordinates; the left/top set below live
102
- // in the tip's own space — scale it over before computing (see `cssZoom`).
103
- const z = cssZoom(tipEl);
104
- const r = anchor.getBoundingClientRect();
105
- const rect = new DOMRect(r.x / z, r.y / z, r.width / z, r.height / z);
106
- const { x, y } = computePos(rect, tipEl.offsetWidth, tipEl.offsetHeight, placement, z);
97
+ const rect = anchor.getBoundingClientRect();
98
+ const { x, y } = computePos(rect, tipEl.offsetWidth, tipEl.offsetHeight, placement);
107
99
  tipEl.style.left = x + "px";
108
100
  tipEl.style.top = y + "px";
109
101
  tipEl.style.visibility = "";
@@ -113,11 +105,10 @@ mountPortal(() => {
113
105
  // ─── Public component ────────────────────────────────────────────────────────
114
106
 
115
107
  /**
116
- * Attaches a tooltip to the current element: adds hover/focus handlers via
117
- * {@link A} so the tip appears when the element is hovered or keyboard-focused.
118
- * The tip panel is rendered into `document.body` via a portal, so it is never
119
- * clipped by `overflow:hidden` ancestors. Position is computed from the
120
- * element's bounding rect and automatically flips when near the viewport edge.
108
+ * Attaches a tooltip to the current element, shown on hover or keyboard focus.
109
+ * The tip panel is portalled into `document.body`, so `overflow:hidden` ancestors
110
+ * never clip it; it is placed from the element's bounding rect, flipping to the
111
+ * opposite side when near the viewport edge.
121
112
  *
122
113
  * @example
123
114
  * ```ts
package/src/core.ts CHANGED
@@ -1,12 +1,10 @@
1
1
  import A from "aberdeen";
2
2
 
3
3
  /**
4
- * Shared building blocks for the Staffa component library.
5
- *
6
- * Every component in Staffa is "just an Aberdeen draw function": a plain function
7
- * that takes a single, strongly typed options object and emits DOM through
8
- * Aberdeen's {@link A} function. This module defines the option-type hierarchy
9
- * that all components build on, plus a couple of tiny helpers.
4
+ * Shared building blocks for the Staffa component library: the option-type
5
+ * hierarchy every component builds on, plus a couple of tiny helpers. A
6
+ * component is just an Aberdeen draw function — one typed options object in,
7
+ * DOM out through {@link A}.
10
8
  */
11
9
 
12
10
  /**
@@ -56,31 +54,12 @@ export interface ContentOptions {
56
54
 
57
55
  /**
58
56
  * Shell width — not viewport width — at or below which the app shell goes
59
- * "narrow": the nav sidebar collapses to a hamburger, and a routed shell
60
- * has room for exactly one full-bleed column. Shared by the `@container` queries
61
- * that do the switching and by the JS that has to agree with them.
57
+ * "narrow": the nav sidebar collapses to a hamburger, and a routed shell has room
58
+ * for exactly one full-bleed column. Shared by the `@container` queries that do
59
+ * the switching and by the JS that has to agree with them.
62
60
  */
63
61
  export const NARROW_PX = 640;
64
62
 
65
- /**
66
- * The narrowest layout the library is designed for. A panel must work at this
67
- * width (panels.ts sizes its columns from it), and a window narrower than this
68
- * is shown the 360px layout scaled down to fit rather than squeezed further
69
- * (see `watchScale` in main.ts).
70
- */
71
- export const MIN_PX = 360;
72
-
73
- /**
74
- * `el`'s effective CSS zoom: the factor between its own coordinate space and
75
- * the window's — 1 wherever `zoom` isn't in play, or in browsers that predate
76
- * it. A `getBoundingClientRect()` or pointer coordinate is in window space;
77
- * divide it by this before using it as a length or scroll offset, which live
78
- * in the element's own space. (`offsetWidth` and friends already do.)
79
- */
80
- export function cssZoom(el: Element): number {
81
- return (el as { currentCSSZoom?: number }).currentCSSZoom ?? 1;
82
- }
83
-
84
63
  let idCounter = 0;
85
64
  /** Generates a process-unique id, used to wire `<label for>` to its control. */
86
65
  export function uniqueId(prefix = "s"): string {
@@ -103,13 +82,12 @@ const FOCUSABLE_SELECTOR = "a[href], button, input, select, textarea, [tabindex]
103
82
 
104
83
  /**
105
84
  * Move keyboard focus to the first focusable element inside `container`, skipping
106
- * disabled, `aria-disabled`, `tabindex=-1` and hidden ones. When `prefer` (a
107
- * selector) matches a focusable element it wins — e.g. a menu's current item — so
108
- * opening lands where the user already is. Returns whether anything was focused.
85
+ * disabled, `aria-disabled`, `tabindex=-1` and hidden ones. A `prefer` selector,
86
+ * where it matches a focusable element, wins — e.g. a menu's current item.
87
+ * Returns whether anything was focused.
109
88
  *
110
- * Shared by overlays (the floating menu, dialogs) so "open → focus the right
111
- * thing" behaves identically everywhere. Call it after the element is in the DOM
112
- * and laid out (typically inside a `requestAnimationFrame`).
89
+ * Shared by the overlays (floating menu, dialogs). Call it once the element is in
90
+ * the DOM and laid out — typically inside a `requestAnimationFrame`.
113
91
  */
114
92
  export function focusFirst(container: HTMLElement, prefer?: string): boolean {
115
93
  const ok = (el: Element): el is HTMLElement =>
@@ -127,15 +105,11 @@ export function focusFirst(container: HTMLElement, prefer?: string): boolean {
127
105
 
128
106
  /**
129
107
  * Mount a portal (tooltip, toast, menu, dialog, …) directly into `<body>`.
130
- * Must be called at module top level, where Aberdeen's root scope (whose
131
- * element is `document.body`) is current. Separate `A.mount`s sharing a parent
132
- * can't tell their nodes apart, but sibling scopes within the root scope track
133
- * their positions, so portals coexist without wrapper elements and add nothing
134
- * to the DOM while they draw nothing.
135
- *
136
- * Scope creation is deferred a microtask so that an app drawing into `<body>`
137
- * at module top level gets its content *before* the portals, keeping overlays
138
- * at the end of the document.
108
+ * Must be called at module top level, where Aberdeen's root scope (whose element
109
+ * is `document.body`) is current: sibling scopes there track their positions, so
110
+ * portals coexist without wrapper elements — separate `A.mount`s sharing a parent
111
+ * could not. Scope creation is deferred a microtask, so an app drawing into
112
+ * `<body>` gets its content first and the overlays stay at the end.
139
113
  */
140
114
  export function mountPortal(draw: () => void): void {
141
115
  queueMicrotask(() => A(draw));
@@ -55,12 +55,8 @@ export function setDefaults(opts: Partial<IconDefaults>): void {
55
55
  }
56
56
 
57
57
  /**
58
- * Draw a single icon: build one `<svg>` through Aberdeen (applying the
58
+ * The shared body behind every icon: build one `<svg>` through Aberdeen (from the
59
59
  * {@link IconOptions} or the module defaults) and fill in its inner markup.
60
- *
61
- * This is the shared body behind every icon. {@link mk} hands it the icon's
62
- * `inner` markup, so the per-icon closures stay tiny instead of each carrying
63
- * a copy of this logic.
64
60
  */
65
61
  function drawIcon(inner: string, opts: IconOptions): void {
66
62
  const size = opts.size ?? defaults.size;
@@ -74,16 +70,15 @@ function drawIcon(inner: string, opts: IconOptions): void {
74
70
  "stroke-linejoin=", opts.join ?? defaults.join,
75
71
  opts.attrs,
76
72
  ) as SVGSVGElement;
77
- // Drop the primitives in via innerHTML: setting it on the `<svg>` itself
78
- // makes the parser put the children in the SVG namespace. (Aberdeen's
79
- // `html=` builds them in the HTML namespace, leaving them non-rendering.)
73
+ // innerHTML on the `<svg>` itself puts the children in the SVG namespace;
74
+ // Aberdeen's `html=` would build them in the HTML namespace, non-rendering.
80
75
  el.innerHTML = inner;
81
76
  }
82
77
 
83
78
  /**
84
- * Turn a piece of inner-SVG markup into an icon draw-function. The returned
85
- * function emits a freshly-built `<svg>` into the current Aberdeen scope,
86
- * applying the {@link IconOptions} (or the module defaults).
79
+ * Turn a piece of inner-SVG markup into an icon draw-function: it emits a freshly
80
+ * built `<svg>` into the current Aberdeen scope, applying the {@link IconOptions}
81
+ * (or the module defaults).
87
82
  */
88
83
  export function mk(inner: string): (opts?: IconOptions) => void {
89
84
  return (opts: IconOptions = {}) => drawIcon(inner, opts);
package/src/index.ts 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, type AutocompleteOptions, type AutocompleteOptionInput } from "./components/autocomplete.js";
37
38
  export { box, type BoxOptions } from "./components/box.js";
38
39
  export { button, iconButton, type ButtonOptions, type IconButtonOptions } from "./components/button.js";