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