staffa 0.6.0 → 0.7.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 (85) hide show
  1. package/README.md +36 -16
  2. package/dist/components/autocomplete.js +12 -9
  3. package/dist/components/box.js +15 -10
  4. package/dist/components/button.d.ts +3 -1
  5. package/dist/components/button.js +21 -28
  6. package/dist/components/buttonChooser.js +1 -1
  7. package/dist/components/buttonGroup.d.ts +3 -3
  8. package/dist/components/buttonGroup.js +3 -3
  9. package/dist/components/dialog.d.ts +3 -1
  10. package/dist/components/dialog.js +20 -10
  11. package/dist/components/field.js +7 -4
  12. package/dist/components/main.js +52 -15
  13. package/dist/components/menu.d.ts +23 -2
  14. package/dist/components/menu.js +54 -29
  15. package/dist/components/select.js +1 -1
  16. package/dist/components/tabs.js +3 -3
  17. package/dist/components/toast.js +8 -8
  18. package/dist/components/tooltip.js +4 -3
  19. package/dist/core.d.ts +14 -2
  20. package/dist/core.js +23 -0
  21. package/dist/staffa.esm.js +1 -1
  22. package/dist/theme.js +138 -224
  23. package/package.json +3 -2
  24. package/skill/Attributes.md +10 -0
  25. package/skill/AutocompleteOptions.md +37 -0
  26. package/skill/BoxOptions.md +33 -0
  27. package/skill/ButtonChooserOptions.md +37 -0
  28. package/skill/ButtonGroupOptions.md +23 -0
  29. package/skill/ButtonOptions.md +58 -0
  30. package/skill/CheckboxOptions.md +27 -0
  31. package/skill/ContentOptions.md +16 -0
  32. package/skill/DialogOptions.md +70 -0
  33. package/skill/FieldOptions.md +63 -0
  34. package/skill/FloatingMenuOptions.md +21 -0
  35. package/skill/FormOptions.md +31 -0
  36. package/skill/MainOptions.md +91 -0
  37. package/skill/MenuItem.md +52 -0
  38. package/skill/MenuOptions.md +25 -0
  39. package/skill/SKILL.md +609 -0
  40. package/skill/SelectOptions.md +21 -0
  41. package/skill/Slot.md +13 -0
  42. package/skill/Tab.md +33 -0
  43. package/skill/TabsOptions.md +28 -0
  44. package/skill/TextareaOptions.md +51 -0
  45. package/skill/TextlineOptions.md +45 -0
  46. package/skill/TextlineType.md +18 -0
  47. package/skill/ToastOptions.md +40 -0
  48. package/skill/TooltipOptions.md +22 -0
  49. package/skill/addContextMenu.md +28 -0
  50. package/skill/addTooltip.md +29 -0
  51. package/skill/alert.md +17 -0
  52. package/skill/autocomplete.md +29 -0
  53. package/skill/box.md +26 -0
  54. package/skill/button.md +31 -0
  55. package/skill/buttonChooser.md +23 -0
  56. package/skill/buttonGroup.md +22 -0
  57. package/skill/checkbox.md +18 -0
  58. package/skill/confirm.md +17 -0
  59. package/skill/dialog.md +28 -0
  60. package/skill/form.md +29 -0
  61. package/skill/getDarkMode.md +12 -0
  62. package/skill/main.md +37 -0
  63. package/skill/menuButton.md +27 -0
  64. package/skill/prompt.md +19 -0
  65. package/skill/select.md +18 -0
  66. package/skill/showFloatingMenu.md +21 -0
  67. package/skill/tabs.md +19 -0
  68. package/skill/textarea.md +17 -0
  69. package/skill/textline.md +20 -0
  70. package/skill/toast.md +19 -0
  71. package/src/components/autocomplete.ts +12 -9
  72. package/src/components/box.ts +15 -10
  73. package/src/components/button.ts +23 -32
  74. package/src/components/buttonChooser.ts +1 -1
  75. package/src/components/buttonGroup.ts +3 -3
  76. package/src/components/dialog.ts +22 -11
  77. package/src/components/field.ts +7 -4
  78. package/src/components/main.ts +47 -16
  79. package/src/components/menu.ts +73 -40
  80. package/src/components/select.ts +1 -1
  81. package/src/components/tabs.ts +3 -3
  82. package/src/components/toast.ts +8 -8
  83. package/src/components/tooltip.ts +4 -3
  84. package/src/core.ts +30 -2
  85. package/src/theme.ts +152 -234
@@ -1,6 +1,6 @@
1
1
  import A from "aberdeen";
2
2
  import { matchCurrent } from "aberdeen/route";
3
- import { type Slot, type Attributes, drawSlot, mountPortal } from "../core.js";
3
+ import { type Slot, type Attributes, drawSlot, mountPortal, focusFirst } from "../core.js";
4
4
  import { button, type ButtonOptions } from "./button.js";
5
5
 
6
6
  /**
@@ -68,54 +68,58 @@ export interface FloatingMenuOptions {
68
68
  items: MenuEntry[];
69
69
  /** Element to anchor the menu to (positioned just below it, flips up if needed). */
70
70
  anchor: HTMLElement;
71
+ /**
72
+ * Position the menu at this viewport point (e.g. a pointer location) instead
73
+ * of just below the anchor. Used by {@link addContextMenu} to open at the
74
+ * exact click/tap target.
75
+ */
76
+ at?: { x: number; y: number };
77
+ /**
78
+ * Also close the menu when the anchor itself is clicked. By default a click on
79
+ * the anchor is ignored (so a trigger button can run its own toggle), but a
80
+ * context menu — whose anchor has no click handler — wants the click to close.
81
+ */
82
+ closeOnAnchorClick?: boolean;
71
83
  /** Aberdeen attr/style string on the floating panel. */
72
84
  dropdownAttrs?: Attributes;
73
85
  }
74
86
 
75
87
  /** Options for {@link addContextMenu} — like {@link FloatingMenuOptions}, but
76
88
  * the anchor is the element the handler is attached to. */
77
- export type ContextMenuOptions = Omit<FloatingMenuOptions, "anchor">;
89
+ export type ContextMenuOptions = Omit<FloatingMenuOptions, "anchor" | "at" | "closeOnAnchorClick">;
78
90
 
79
91
  // Styles shared by the floating dropdown and the sidebar nav, so both look
80
92
  // identical. The item styles aren't scoped to a container, so `drawMenu` can
81
93
  // render its items into either one.
82
94
  A.insertGlobalCss({
95
+ // Border comes from the `.s-s.neutral` surface; the panel only overrides the radius
96
+ // (lg) and opts into elevation via `.shadow` (added on the element below).
83
97
  ".s-menu-list":
84
98
  "position:fixed z-index:350 min-width:10rem display:flex flex-direction:column p:$1 " +
85
- "border: 1px solid $s-border; r:$s-radius-lg box-shadow:$s-shadow " +
99
+ "r:$s-radius-lg " +
86
100
  "overflow-y:auto max-height:min(80vh,28rem) " +
87
101
  "transition: opacity 0.15s, transform 0.15s;",
88
102
  ".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px)",
89
- ".s-menu-item, .s-menu-item-link":
90
- "display:flex align-items:center gap:$2 w:100% " +
91
- "padding: 0.5em 0.65em; r:$s-radius cursor:pointer text-align:left " +
92
- "font-size:0.9em border:0 background:transparent fg:$s-fg text-decoration:none " +
93
- "transition: background 0.12s, color 0.12s, transform 0.12s, box-shadow 0.12s;",
94
- // A translucent ink tint (rather than an *opaque* mix) so the hover fades in
95
- // cleanly: transitioning background from `transparent` toward an opaque colour
96
- // flashes through dark mid-tones in browsers that interpolate non-premultiplied.
97
- // Staying ink-hued at low alpha keeps the fade the right colour throughout.
98
- ".s-menu-item:hover:not([aria-disabled=true]):not([aria-current=page]), .s-menu-item-link:hover:not([aria-current=page])":
99
- "background: color-mix(in srgb, $s-fg 10%, transparent);",
100
- // Active (current page): a filled brand-gradient pill with a soft glow — the
101
- // one place the menu shows real colour, so the current page is unmistakable.
102
- ".s-menu-item[aria-current=page], .s-menu-item-link[aria-current=page]":
103
- "color:$s-on-accent font-weight:600 background: $s-gradient; box-shadow: 0 3px 10px color-mix(in srgb, $s-primary 38%, transparent);",
104
- ".s-menu-item[aria-current=page] .s-menu-icon, .s-menu-item-link[aria-current=page] .s-menu-icon":
105
- "color:$s-on-accent",
106
- ".s-menu-item[aria-current=page]:hover, .s-menu-item-link[aria-current=page]:hover":
107
- "filter:brightness(1.06)",
108
- ".s-menu-item:focus-visible, .s-menu-item-link:focus-visible":
109
- "outline:none background: color-mix(in srgb, $s-fg 10%, transparent); box-shadow: 0 0 0 2px inset $s-focus;",
110
- ".s-menu-item[aria-disabled=true], .s-menu-item-link[aria-disabled=true]":
103
+ // One class for both the `<a>` (link) and `<button>` forms — they look
104
+ // identical; the element only differs where link semantics matter (see below).
105
+ ".s-menu-item":
106
+ "display:flex align-items:center gap:$2 w:100% outline:0 " +
107
+ "padding: $m2; line-height:1.1 r:$s-radius cursor:pointer text-align:left font-weight:450 " +
108
+ "font-size:0.9em border:0 background:transparent fg:$s-text text-decoration:none " +
109
+ "transition: color 0.12s, transform 0.12s, text-shadow 0.12s;",
110
+ ".s-menu-item:focus-visible:not([aria-current=page]), .s-menu-item:hover:not([aria-disabled=true]):not([aria-current=page])":
111
+ "filter:none color: color-mix(in lab, $s-primary 33%, $s-text);",
112
+ ".s-menu-item[aria-current=page]":
113
+ "text-shadow: 0 0 2px $s-primary; color: color-mix(in lab, $s-primary 50%, $s-text); filter:brightness(1.15)",
114
+ ".s-menu-item[aria-disabled=true]":
111
115
  "opacity:0.45 cursor:not-allowed pointer-events:none",
112
- ".s-menu-icon": "fg:$s-fg-muted flex-shrink:0",
116
+ ".s-menu-icon": "flex-shrink:0",
113
117
  // A soft hairline that fades out at both ends, rather than a hard full-width
114
118
  // rule — quieter, and it reads as a grouping cue instead of a divider bar.
115
119
  // `hr.` (not just `.`) so this wins over the global hr flow-margin rule.
116
120
  "hr.s-menu-sep":
117
121
  "border:0 height:1px margin: $1 0.6rem; " +
118
- "background: linear-gradient(to right, transparent, $s-border-strong 18%, $s-border-strong 82%, transparent);",
122
+ "background: linear-gradient(to right, transparent, $s-faint 18%, $s-faint 82%, transparent);",
119
123
  });
120
124
 
121
125
  /**
@@ -135,10 +139,18 @@ A.insertGlobalCss({
135
139
  export function drawMenu(items: MenuEntry[], onActivate?: () => void): void {
136
140
  // Roving focus via the DOM: query the live item elements on each keypress.
137
141
  A("keydown=", (e: KeyboardEvent) => {
142
+ // Link items navigate through interceptLinks' own Enter handler, which
143
+ // preventDefault()s the activation — so no synthetic `click` fires, and the
144
+ // click-bound `onActivate` (which closes a floating menu) never runs. Close it
145
+ // ourselves, deferred so this keydown finishes dispatching (and navigates) first.
146
+ if (e.key === "Enter" && (e.target as HTMLElement).tagName === "A") {
147
+ queueMicrotask(() => onActivate?.());
148
+ return;
149
+ }
138
150
  if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") return;
139
151
  e.preventDefault();
140
152
  const container = e.currentTarget as HTMLElement;
141
- const els = [...container.querySelectorAll<HTMLElement>(".s-menu-item, .s-menu-item-link")]
153
+ const els = [...container.querySelectorAll<HTMLElement>(".s-menu-item")]
142
154
  .filter((el) => el.getAttribute("aria-disabled") !== "true");
143
155
  if (!els.length) return;
144
156
  const cur = els.indexOf(document.activeElement as HTMLElement);
@@ -155,7 +167,7 @@ export function drawMenu(items: MenuEntry[], onActivate?: () => void): void {
155
167
  if (typeof entry === "string" || typeof entry === "function") { drawSlot(entry); continue; }
156
168
  if ("separator" in entry) { A("hr.s-menu-sep"); continue; }
157
169
 
158
- A(entry.href ? "a.s-menu-item-link" : "button.s-menu-item type=button", entry.attrs, () => {
170
+ A(entry.href ? "a.s-menu-item" : "button.s-menu-item type=button", entry.attrs, () => {
159
171
  if (entry.href) {
160
172
  A("href=", entry.href);
161
173
  if (entry.target) A("target=", entry.target);
@@ -185,7 +197,16 @@ function closeFloating(): void {
185
197
  anchor?.focus();
186
198
  }
187
199
 
188
- function positionMenu(menuEl: HTMLElement, rect: DOMRect): void {
200
+ /**
201
+ * Whether a floating menu is currently open. Reflects live state (cleared the
202
+ * instant it closes), unlike the DOM — the panel lingers briefly while its
203
+ * `destroy=` transition plays out.
204
+ */
205
+ export function isFloatingMenuOpen(): boolean {
206
+ return $floating.opts != null;
207
+ }
208
+
209
+ function positionMenu(menuEl: HTMLElement, rect: { left: number; right: number; top: number; bottom: number }): void {
189
210
  const mw = menuEl.offsetWidth, mh = menuEl.offsetHeight;
190
211
  const vw = window.innerWidth, vh = window.innerHeight;
191
212
  const gap = 4;
@@ -201,7 +222,7 @@ mountPortal(() => {
201
222
  const f = $floating.opts;
202
223
  if (!f) return;
203
224
 
204
- const menuEl = A("div.s-menu-list.s-s.panel create=hidden destroy=hidden", f.dropdownAttrs, () => {
225
+ const menuEl = A("div.s-menu-list.s-s.neutral.shadow create=hidden destroy=hidden", f.dropdownAttrs, () => {
205
226
  drawMenu(f.items, closeFloating);
206
227
  }) as HTMLElement;
207
228
 
@@ -209,7 +230,7 @@ mountPortal(() => {
209
230
  // any click outside the panel + anchor closes; Escape/Tab close.
210
231
  const onClick = (e: MouseEvent) => {
211
232
  const t = e.target as Node;
212
- if (!menuEl.contains(t) && !f.anchor.contains(t)) closeFloating();
233
+ if (!menuEl.contains(t) && (f.closeOnAnchorClick || !f.anchor.contains(t))) closeFloating();
213
234
  };
214
235
  const onKey = (e: KeyboardEvent) => {
215
236
  if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); closeFloating(); }
@@ -224,10 +245,14 @@ mountPortal(() => {
224
245
  // Position after layout, then focus the first enabled item.
225
246
  requestAnimationFrame(() => {
226
247
  if (!document.body.contains(menuEl)) return;
227
- positionMenu(menuEl, f.anchor.getBoundingClientRect());
228
- menuEl.querySelector<HTMLElement>(
229
- ".s-menu-item:not([aria-disabled=true]), .s-menu-item-link:not([aria-disabled=true])",
230
- )?.focus();
248
+ // Position at the supplied point (a zero-size rect) when given — e.g. the
249
+ // pointer location for a context menu — otherwise below the anchor.
250
+ const rect = f.at ? { left: f.at.x, right: f.at.x, top: f.at.y, bottom: f.at.y } : f.anchor.getBoundingClientRect();
251
+ positionMenu(menuEl, rect);
252
+ // Focus the active (current-page) item if there is one — so opening lands
253
+ // where you are — else the first focusable element (covers custom slot
254
+ // content, like a settings dropdown, not just `.s-menu-item`s).
255
+ focusFirst(menuEl, ".s-menu-item[aria-current=page]");
231
256
  });
232
257
  });
233
258
 
@@ -288,10 +313,18 @@ export function addContextMenu(opts: ContextMenuOptions): void {
288
313
  let myEl: HTMLElement | null = null;
289
314
  A.clean(() => { if ($floating.opts?.anchor === myEl) closeFloating(); });
290
315
 
291
- A("contextmenu=", (e: Event) => {
316
+ A("contextmenu=", (e: MouseEvent) => {
292
317
  e.preventDefault();
293
318
  myEl = e.currentTarget as HTMLElement;
294
- showFloatingMenu({ items: opts.items, anchor: myEl, dropdownAttrs: opts.dropdownAttrs });
319
+ // Anchor at the exact click/tap point, and close on a plain click of the
320
+ // element (it has no toggle handler of its own).
321
+ showFloatingMenu({
322
+ items: opts.items,
323
+ anchor: myEl,
324
+ at: { x: e.clientX, y: e.clientY },
325
+ closeOnAnchorClick: true,
326
+ dropdownAttrs: opts.dropdownAttrs,
327
+ });
295
328
  });
296
329
  }
297
330
 
@@ -306,7 +339,7 @@ export function addContextMenu(opts: ContextMenuOptions): void {
306
339
  * @example
307
340
  * ```ts
308
341
  * S.menuButton({
309
- * button: { content: "Actions", attrs: ".neutral .outlined" },
342
+ * button: { content: "Actions", attrs: ".neutral" },
310
343
  * items: [
311
344
  * { label: "Edit", icon: () => A("#✎"), click: () => edit() },
312
345
  * { separator: true },
@@ -324,7 +357,7 @@ export function menuButton(opts: MenuOptions): void {
324
357
  // Only label the trigger "Open menu" when it has no visible text of its
325
358
  // own — an aria-label would otherwise *hide* that text from AT.
326
359
  ...(opts.button?.content == null ? { ariaLabel: "Open menu" } : null),
327
- attrs: ".neutral .outlined",
360
+ attrs: ".neutral",
328
361
  ...opts.button,
329
362
  click: (e: Event) => {
330
363
  myEl = e.currentTarget as HTMLElement;
@@ -20,7 +20,7 @@ A.insertGlobalCss({
20
20
  ".s-select_wrap": {
21
21
  "&": "position:relative display:block",
22
22
  "select": "w:100% cursor:pointer padding-right:2.2em; appearance:none",
23
- "&::after": "content: '▾'; position:absolute right:0.7em top:50%; transform: translateY(-50%); pointer-events:none fg:$s-fg-muted font-size:0.85em",
23
+ "&::after": "content: '▾'; position:absolute right:0.7em top:50%; transform: translateY(-50%); pointer-events:none fg:$s-muted font-size:0.85em",
24
24
  },
25
25
  });
26
26
 
@@ -33,14 +33,14 @@ export interface TabsOptions {
33
33
  A.insertGlobalCss({
34
34
  ".s-tabs": {
35
35
  "&": "display:flex flex-direction:column gap:$3",
36
- ".s-tablist": "display:flex gap:$1 align-items:stretch overflow-x:auto scrollbar-width:none border-bottom: 1px solid $s-border;",
36
+ ".s-tablist": "display:flex gap:$1 align-items:stretch overflow-x:auto scrollbar-width:none border-bottom: 1px solid $s-faint;",
37
37
  ".s-tablist::-webkit-scrollbar": "display:none",
38
38
  ".s-tab":
39
39
  "display:inline-flex align-items:center gap:$2 cursor:pointer background:transparent " +
40
- "border:0 color: $s-fg-muted; font-weight:600 padding: 0.6em 0.9em; " +
40
+ "border:0 color: $s-muted; font-weight:600 padding: 0.6em 0.9em; " +
41
41
  "border-bottom: 3px solid transparent; margin-bottom:-1px " +
42
42
  "transition: color 0.15s, background 0.15s, border-color 0.15s;",
43
- ".s-tab:hover:not(:disabled), .s-tab[aria-selected=true]": "color: $s-fg;",
43
+ ".s-tab:hover:not(:disabled), .s-tab[aria-selected=true]": "color: $s-text;",
44
44
  ".s-tab:focus-visible": "outline:none box-shadow: 0 0 0 3px $s-focus; r: $s-radius;",
45
45
  ".s-tab[aria-selected=true]": "border-image: $s-gradient 1;",
46
46
  ".s-tabpanel": "display:block",
@@ -36,16 +36,15 @@ A.insertGlobalCss({
36
36
  ".s-toast": {
37
37
  "&":
38
38
  "display:flex align-items:flex-start gap:$2 " +
39
- "padding: $3; border: 1px solid $s-border; r:$s-radius box-shadow:$s-shadow " +
39
+ "padding: $3; " +
40
40
  "pointer-events:auto position:relative overflow:hidden",
41
41
  ".s-toast-body": "display:flex flex-direction:column gap:$1 flex:1 min-width:0",
42
42
  ".s-toast-title": "font-weight:700 line-height:1.3",
43
- ".s-toast-msg": "font-size:0.9em fg:$s-fg-muted line-height:1.4",
44
43
  ".s-toast-close":
45
- "cursor:pointer border:0 background:transparent fg:$s-fg-muted font-size:1.1em line-height:1 " +
44
+ "cursor:pointer border:0 background:transparent fg:$s-muted font-size:1.1em line-height:1 " +
46
45
  "padding: 0 0.15em; r:4px flex-shrink:0 align-self:flex-start",
47
- ".s-toast-close:hover": "fg:$s-fg",
48
- ".s-toast-close:focus-visible": "outline:none box-shadow: 0 0 0 3px $s-focus; fg:$s-fg",
46
+ ".s-toast-close:hover": "fg:$s-text",
47
+ ".s-toast-close:focus-visible": "outline:none box-shadow: 0 0 0 3px $s-focus; fg:$s-text",
49
48
  ".s-toast-progress": "position:absolute bottom:0 left:0 right:0 height:2px background:$s-accent width:100%",
50
49
  },
51
50
  });
@@ -58,11 +57,12 @@ mountPortal(() => {
58
57
  // In initial peek is here to NOT subscribe to changes once `toasts` first becomes non empty,
59
58
  // leaving the container in the DOM forever after. (So as not to cut of hide animations.)
60
59
  if (A.peek(() => A.isEmpty(toasts)) && A.isEmpty(toasts)) return;
61
- A("div.s-toasts aria-live=polite aria-atomic=false", () => {
60
+ A("div.s-toasts", () => {
62
61
  A.onEach(toasts, (entry) => {
63
62
  const { opts, id } = entry;
64
63
  const role = opts.type === "danger" || opts.type === "warning" ? "alert" : "status";
65
- const surface = opts.type ?? "neutral";
64
+ // "neutral" (the default) is a neutral surface; the rest are accent surfaces.
65
+ const surface = opts.type == null || opts.type === "neutral" ? "neutral" : opts.type;
66
66
  const duration = opts.duration ?? 6000;
67
67
 
68
68
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -91,7 +91,7 @@ mountPortal(() => {
91
91
 
92
92
  A.clean(() => clearTimeout(timer));
93
93
 
94
- A(`div.s-toast.s-s.${surface} role=${role}`, "create=", grow, "destroy=", shrink, opts.attrs, () => {
94
+ A(`div.s-toast.s-s.${surface}.extra-shadow aria-live=polite role=${role}`, "create=", grow, "destroy=", shrink, opts.attrs, () => {
95
95
  if (duration > 0) {
96
96
  A("mouseenter=", stopCountdown);
97
97
  A("mouseleave=", startCountdown);
@@ -14,13 +14,14 @@ 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
20
  A.insertGlobalCss({
18
21
  ".s-tt-tip": {
19
22
  "&":
20
23
  "position:fixed z-index:500 " +
21
24
  "max-width:20rem w:max-content " +
22
- "bg:$s-raised fg:$s-fg border: 1px solid $s-border-strong; " +
23
- "r:$s-radius box-shadow:$s-shadow " +
24
25
  "padding: 0.3em 0.65em; font-size:0.85em line-height:1.4 " +
25
26
  "pointer-events:none",
26
27
  },
@@ -85,7 +86,7 @@ mountPortal(() => {
85
86
  const { opts, anchor } = active;
86
87
  const placement = opts.placement ?? "top";
87
88
 
88
- const tipEl = A("div.s-tt-tip role=tooltip visibility:hidden", opts.attrs, () => {
89
+ const tipEl = A("div.s-tt-tip.s-s.neutral.shadow role=tooltip visibility:hidden", opts.attrs, () => {
89
90
  A("mouseenter=", () => {
90
91
  if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
91
92
  });
package/src/core.ts CHANGED
@@ -13,8 +13,9 @@ import A from "aberdeen";
13
13
  * An Aberdeen attribute/style/class string, e.g. `"display:flex gap:$3 .my-class"`.
14
14
  *
15
15
  * Common values are our surface modifier classes:
16
- * - for colors: `.panel` `.raised` `.neutral` `.primary` `.secondary` `.gradient` `.danger` `.success` and `.warning`
17
- * - for variant: `.filled` `.tonal` and `.outlined`
16
+ * - neutral surface: `.neutral` (shade steps with nesting depth)
17
+ * - accent surface: `.primary` `.danger` `.success` `.warning` and `.link`
18
+ * - accent variant: `.filled` `.tonal` and `.outlined`
18
19
  *
19
20
  * These strings are passed straight through to {@link A} as positional
20
21
  * arguments, so they accept the full Aberdeen shorthand syntax: CSS shortcuts
@@ -70,6 +71,33 @@ export function drawSlot<Args extends unknown[] = []>(slot: Slot<Args> | undefin
70
71
  else A("rich=", slot);
71
72
  }
72
73
 
74
+ /** Selector matching the natively focusable elements we care about. */
75
+ const FOCUSABLE_SELECTOR = "a[href], button, input, select, textarea, [tabindex]";
76
+
77
+ /**
78
+ * Move keyboard focus to the first focusable element inside `container`, skipping
79
+ * disabled, `aria-disabled`, `tabindex=-1` and hidden ones. When `prefer` (a
80
+ * selector) matches a focusable element it wins — e.g. a menu's current item — so
81
+ * opening lands where the user already is. Returns whether anything was focused.
82
+ *
83
+ * Shared by overlays (the floating menu, dialogs) so "open → focus the right
84
+ * thing" behaves identically everywhere. Call it after the element is in the DOM
85
+ * and laid out (typically inside a `requestAnimationFrame`).
86
+ */
87
+ export function focusFirst(container: HTMLElement, prefer?: string): boolean {
88
+ const ok = (el: Element): el is HTMLElement =>
89
+ el instanceof HTMLElement &&
90
+ !el.hasAttribute("disabled") &&
91
+ el.getAttribute("aria-disabled") !== "true" &&
92
+ el.tabIndex >= 0 &&
93
+ el.getClientRects().length > 0;
94
+ const target =
95
+ (prefer ? [...container.querySelectorAll(prefer)].find(ok) : undefined) ??
96
+ [...container.querySelectorAll(FOCUSABLE_SELECTOR)].find(ok);
97
+ target?.focus();
98
+ return target != null;
99
+ }
100
+
73
101
  /**
74
102
  * Mount a portal (tooltip, toast, menu, dialog, …) directly into `<body>`.
75
103
  * Must be called at module top level, where Aberdeen's root scope (whose