staffa 0.6.1 → 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 (60) hide show
  1. package/README.md +34 -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 +1 -1
  24. package/skill/ButtonOptions.md +2 -1
  25. package/skill/SKILL.md +108 -47
  26. package/skill/ToastOptions.md +1 -1
  27. package/skill/addContextMenu.md +28 -0
  28. package/skill/addTooltip.md +6 -2
  29. package/skill/autocomplete.md +9 -2
  30. package/skill/box.md +1 -0
  31. package/skill/button.md +5 -3
  32. package/skill/buttonGroup.md +5 -3
  33. package/skill/checkbox.md +1 -0
  34. package/skill/confirm.md +1 -1
  35. package/skill/dialog.md +2 -2
  36. package/skill/form.md +3 -2
  37. package/skill/main.md +5 -1
  38. package/skill/menuButton.md +1 -1
  39. package/skill/prompt.md +1 -1
  40. package/skill/select.md +1 -0
  41. package/skill/showFloatingMenu.md +5 -6
  42. package/skill/tabs.md +2 -2
  43. package/skill/textarea.md +3 -2
  44. package/skill/textline.md +1 -0
  45. package/skill/toast.md +2 -3
  46. package/src/components/autocomplete.ts +12 -9
  47. package/src/components/box.ts +15 -10
  48. package/src/components/button.ts +23 -32
  49. package/src/components/buttonChooser.ts +1 -1
  50. package/src/components/buttonGroup.ts +3 -3
  51. package/src/components/dialog.ts +22 -11
  52. package/src/components/field.ts +7 -4
  53. package/src/components/main.ts +47 -16
  54. package/src/components/menu.ts +73 -40
  55. package/src/components/select.ts +1 -1
  56. package/src/components/tabs.ts +3 -3
  57. package/src/components/toast.ts +8 -8
  58. package/src/components/tooltip.ts +4 -3
  59. package/src/core.ts +30 -2
  60. package/src/theme.ts +152 -234
@@ -1,33 +1,38 @@
1
1
  import A from "aberdeen";
2
2
  import { drawSlot } from "../core.js";
3
- import { menuButton, drawMenu } from "./menu.js";
3
+ import { menuButton, drawMenu, isFloatingMenuOpen } from "./menu.js";
4
+ import { isDialogOpen } from "./dialog.js";
4
5
  A.insertGlobalCss({
5
6
  ".s-main": {
6
7
  // container-type so @container queries below can respond to shell width.
7
8
  "&": "display:flex flex-direction:column min-height:100vh max-height:100vh container-type:inline-size",
8
9
  // Header/footer stretch their background the full shell width; their inner
9
10
  // `.s-bar` caps to maxWidth and centres, so chrome aligns with the content.
10
- "> header": "border-bottom: 1px solid $s-border; position:sticky top:0 z-index:10",
11
- "> footer": "border-top: 1px solid $s-border; fg:$s-fg-muted",
11
+ // The top bar is a full-width `.neutral` surface; cancel its surface border and
12
+ // radius down to just the bottom divider (it spans edge to edge).
13
+ "> header": "border:0 border-bottom: 1px solid $s-faint; r:0 position:sticky top:0 z-index:10",
14
+ "> footer": "border-top: 1px solid $s-faint; fg:$s-muted",
12
15
  "> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
13
16
  "> header .s-header-icon": "display:flex align-items:center font-size:1.4em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent;",
14
17
  "> header .s-titles": "display:flex flex-direction:column min-width:0 flex:1",
15
18
  "> header .s-title": "font-weight:800 font-size:1.1em line-height:1.2 overflow:hidden text-overflow:ellipsis white-space:nowrap letter-spacing:-0.01em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent; width:fit-content max-width:100%",
16
- "> header .s-subtitle": "fg:$s-fg-muted font-size:0.85em overflow:hidden text-overflow:ellipsis white-space:nowrap",
19
+ "> header .s-subtitle": "fg:$s-muted font-size:0.85em overflow:hidden text-overflow:ellipsis white-space:nowrap",
17
20
  "> header .s-menu": "display:flex align-items:center gap:$2",
18
21
  // Body always wraps <main> (with or without a sidebar) so max-width centering
19
22
  // and scrollbar alignment work identically in both cases.
20
23
  // .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
21
24
  ".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center",
22
- ".s-body-inner": "flex:1 display:flex flex-direction:row min-height:0",
25
+ ".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
23
26
  // Put the sidebar on the right (content fills the left) for right-hand navs.
24
27
  "&.s-nav-right .s-body-inner": "flex-direction:row-reverse",
25
28
  // A vertical hairline between sidebar and content, fading out at both ends —
26
29
  // the vertical sibling of the menu's `hr.s-menu-sep`.
27
- ".s-nav-sep": "width:1px flex-shrink:0 align-self:stretch margin: 0.6rem 0; border:0 background: linear-gradient(to bottom, transparent, $s-border-strong 18%, $s-border-strong 82%, transparent);",
28
- // min-height:0 overrides the flex default of min-height:auto so <main> can
29
- // shrink to fit the bounded container and show its own scrollbar.
30
- ".s-body main": "flex:1 min-height:0 overflow-y:auto display:flex flex-direction:column",
30
+ ".s-nav-sep": "width:1px flex-shrink:0 align-self:stretch margin: 0.6rem 0; border:0 background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
31
+ // min-height:0 / min-width:0 override the flex default of min-*:auto so <main>
32
+ // can shrink to fit the bounded container (rather than letting wide content push
33
+ // the whole body and any sidebar past the viewport edge). overflow-x:hidden
34
+ // clips overlong content on the right; vertically it scrolls.
35
+ ".s-body main": "flex:1 min-width:0 min-height:0 overflow-x:hidden overflow-y:auto display:flex flex-direction:column",
31
36
  // The content area fills the scroll region with comfortable padding.
32
37
  // It is deliberately NOT a boxed "sheet" — content brings its own boxes.
33
38
  ".s-body main > .s-content": "width:100% flex:1 p:$3",
@@ -39,7 +44,7 @@ A.insertGlobalCss({
39
44
  // there's no margin, so the content keeps its single $3 edge — not 2×$3.
40
45
  ".s-body main.s-scroll-y": "margin-right:$3",
41
46
  },
42
- // Sidebar nav panel. Items reuse the shared `.s-menu-item[-link]` /
47
+ // Sidebar nav panel. Items reuse the shared `.s-menu-item` /
43
48
  // `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
44
49
  // dropdown stay visually identical.
45
50
  // Borderless and transparent so the page's aurora shows through — an airy,
@@ -48,7 +53,7 @@ A.insertGlobalCss({
48
53
  // Extra horizontal padding leaves room for the active pill's glow, which the
49
54
  // vertical scroll (overflow-y:auto, which also clips overflow-x) would
50
55
  // otherwise cut off at the panel edges.
51
- "&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1 background:transparent",
56
+ "&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1",
52
57
  },
53
58
  // In button-only mode (or always-button navPosition), hide the sidebar and
54
59
  // show the trigger. In sidebar mode, show the panel and hide the trigger.
@@ -103,7 +108,7 @@ export function main(opts = {}) {
103
108
  const navPos = opts.navPosition ?? "left";
104
109
  const hasNav = nav != null && nav.items.length > 0;
105
110
  const navCls = hasNav ? (navPos === "button" ? ".s-nav-btn-only" : `.s-nav-${navPos}`) : "";
106
- A(`div.s-main.s-s.base${navCls}`, opts.attrs, () => {
111
+ const root = A(`div.s-main${navCls}`, opts.attrs, () => {
107
112
  // Top bar.
108
113
  A(() => {
109
114
  const hasBar = opts.title != null ||
@@ -113,7 +118,7 @@ export function main(opts = {}) {
113
118
  hasNav;
114
119
  if (!hasBar)
115
120
  return;
116
- A("header.s-s.raised", opts.topbarAttrs, () => {
121
+ A("header.s-s.neutral", opts.topbarAttrs, () => {
117
122
  A("div.s-bar", () => {
118
123
  // Cap the bar's content to maxWidth and centre it within the full-width header.
119
124
  A(() => {
@@ -131,7 +136,7 @@ export function main(opts = {}) {
131
136
  button: {
132
137
  icon: () => A("span aria-hidden=true #☰"),
133
138
  ariaLabel: "Open navigation",
134
- attrs: ".neutral .outlined .small",
139
+ attrs: ".neutral .small",
135
140
  ...nav.button,
136
141
  },
137
142
  });
@@ -167,7 +172,7 @@ export function main(opts = {}) {
167
172
  A("max-width:", opts.maxWidth);
168
173
  });
169
174
  if (hasNav && navPos !== "button") {
170
- A(`nav.s-nav-panel.s-s.raised.s-nav-${navPos}`, opts.navAttrs, () => {
175
+ A(`nav.s-nav-panel.s-nav-${navPos}`, opts.navAttrs, () => {
171
176
  drawMenu(nav.items);
172
177
  });
173
178
  A("div.s-nav-sep aria-hidden=true");
@@ -190,6 +195,38 @@ export function main(opts = {}) {
190
195
  }
191
196
  });
192
197
  });
198
+ // Escape jumps to the navigation: into the sidebar's current item when the
199
+ // sidebar is showing, or — when collapsed to (or always) a button — open the
200
+ // dropdown (which focuses its current item). Listens on `document` so it works
201
+ // wherever focus is, but bows out while another overlay (a dialog, or an
202
+ // already-open menu) is up — those handle Escape themselves.
203
+ if (hasNav) {
204
+ const onKey = (e) => {
205
+ if (e.key !== "Escape" || e.defaultPrevented)
206
+ return;
207
+ // An open dialog or menu owns Escape itself — don't also jump to the nav.
208
+ if (isDialogOpen() || isFloatingMenuOpen())
209
+ return;
210
+ // `offsetParent` is null when the sidebar is hidden (display:none).
211
+ const panel = root.querySelector(".s-nav-panel");
212
+ if (panel?.offsetParent != null) {
213
+ const item = panel.querySelector("[aria-current=page]") ??
214
+ panel.querySelector(".s-menu-item:not([aria-disabled=true])");
215
+ if (item) {
216
+ e.preventDefault();
217
+ item.focus();
218
+ }
219
+ return;
220
+ }
221
+ const trigger = root.querySelector(".s-nav-trigger button");
222
+ if (trigger) {
223
+ e.preventDefault();
224
+ trigger.click();
225
+ }
226
+ };
227
+ document.addEventListener("keydown", onKey);
228
+ A.clean(() => document.removeEventListener("keydown", onKey));
229
+ }
193
230
  }
194
231
  function drawMainContent(opts) {
195
232
  const mainEl = A("main", () => {
@@ -61,12 +61,27 @@ export interface FloatingMenuOptions {
61
61
  items: MenuEntry[];
62
62
  /** Element to anchor the menu to (positioned just below it, flips up if needed). */
63
63
  anchor: HTMLElement;
64
+ /**
65
+ * Position the menu at this viewport point (e.g. a pointer location) instead
66
+ * of just below the anchor. Used by {@link addContextMenu} to open at the
67
+ * exact click/tap target.
68
+ */
69
+ at?: {
70
+ x: number;
71
+ y: number;
72
+ };
73
+ /**
74
+ * Also close the menu when the anchor itself is clicked. By default a click on
75
+ * the anchor is ignored (so a trigger button can run its own toggle), but a
76
+ * context menu — whose anchor has no click handler — wants the click to close.
77
+ */
78
+ closeOnAnchorClick?: boolean;
64
79
  /** Aberdeen attr/style string on the floating panel. */
65
80
  dropdownAttrs?: Attributes;
66
81
  }
67
82
  /** Options for {@link addContextMenu} — like {@link FloatingMenuOptions}, but
68
83
  * the anchor is the element the handler is attached to. */
69
- export type ContextMenuOptions = Omit<FloatingMenuOptions, "anchor">;
84
+ export type ContextMenuOptions = Omit<FloatingMenuOptions, "anchor" | "at" | "closeOnAnchorClick">;
70
85
  /**
71
86
  * Draw a list of {@link MenuEntry} items into the *current* element, with
72
87
  * arrow-key / Home / End navigation between the focusable items. The single
@@ -82,6 +97,12 @@ export type ContextMenuOptions = Omit<FloatingMenuOptions, "anchor">;
82
97
  * floating menu to close itself on selection).
83
98
  */
84
99
  export declare function drawMenu(items: MenuEntry[], onActivate?: () => void): void;
100
+ /**
101
+ * Whether a floating menu is currently open. Reflects live state (cleared the
102
+ * instant it closes), unlike the DOM — the panel lingers briefly while its
103
+ * `destroy=` transition plays out.
104
+ */
105
+ export declare function isFloatingMenuOpen(): boolean;
85
106
  /**
86
107
  * Open a floating dropdown menu anchored to an element. Portals to
87
108
  * `document.body` (never clipped), positions itself (flipping up when there's
@@ -141,7 +162,7 @@ export declare function addContextMenu(opts: ContextMenuOptions): void;
141
162
  * @example
142
163
  * ```ts
143
164
  * S.menuButton({
144
- * button: { content: "Actions", attrs: ".neutral .outlined" },
165
+ * button: { content: "Actions", attrs: ".neutral" },
145
166
  * items: [
146
167
  * { label: "Edit", icon: () => A("#✎"), click: () => edit() },
147
168
  * { separator: true },
@@ -1,38 +1,33 @@
1
1
  import A from "aberdeen";
2
2
  import { matchCurrent } from "aberdeen/route";
3
- import { drawSlot, mountPortal } from "../core.js";
3
+ import { drawSlot, mountPortal, focusFirst } from "../core.js";
4
4
  import { button } from "./button.js";
5
5
  // Styles shared by the floating dropdown and the sidebar nav, so both look
6
6
  // identical. The item styles aren't scoped to a container, so `drawMenu` can
7
7
  // render its items into either one.
8
8
  A.insertGlobalCss({
9
+ // Border comes from the `.s-s.neutral` surface; the panel only overrides the radius
10
+ // (lg) and opts into elevation via `.shadow` (added on the element below).
9
11
  ".s-menu-list": "position:fixed z-index:350 min-width:10rem display:flex flex-direction:column p:$1 " +
10
- "border: 1px solid $s-border; r:$s-radius-lg box-shadow:$s-shadow " +
12
+ "r:$s-radius-lg " +
11
13
  "overflow-y:auto max-height:min(80vh,28rem) " +
12
14
  "transition: opacity 0.15s, transform 0.15s;",
13
15
  ".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px)",
14
- ".s-menu-item, .s-menu-item-link": "display:flex align-items:center gap:$2 w:100% " +
15
- "padding: 0.5em 0.65em; r:$s-radius cursor:pointer text-align:left " +
16
- "font-size:0.9em border:0 background:transparent fg:$s-fg text-decoration:none " +
17
- "transition: background 0.12s, color 0.12s, transform 0.12s, box-shadow 0.12s;",
18
- // A translucent ink tint (rather than an *opaque* mix) so the hover fades in
19
- // cleanly: transitioning background from `transparent` toward an opaque colour
20
- // flashes through dark mid-tones in browsers that interpolate non-premultiplied.
21
- // Staying ink-hued at low alpha keeps the fade the right colour throughout.
22
- ".s-menu-item:hover:not([aria-disabled=true]):not([aria-current=page]), .s-menu-item-link:hover:not([aria-current=page])": "background: color-mix(in srgb, $s-fg 10%, transparent);",
23
- // Active (current page): a filled brand-gradient pill with a soft glow — the
24
- // one place the menu shows real colour, so the current page is unmistakable.
25
- ".s-menu-item[aria-current=page], .s-menu-item-link[aria-current=page]": "color:$s-on-accent font-weight:600 background: $s-gradient; box-shadow: 0 3px 10px color-mix(in srgb, $s-primary 38%, transparent);",
26
- ".s-menu-item[aria-current=page] .s-menu-icon, .s-menu-item-link[aria-current=page] .s-menu-icon": "color:$s-on-accent",
27
- ".s-menu-item[aria-current=page]:hover, .s-menu-item-link[aria-current=page]:hover": "filter:brightness(1.06)",
28
- ".s-menu-item:focus-visible, .s-menu-item-link:focus-visible": "outline:none background: color-mix(in srgb, $s-fg 10%, transparent); box-shadow: 0 0 0 2px inset $s-focus;",
29
- ".s-menu-item[aria-disabled=true], .s-menu-item-link[aria-disabled=true]": "opacity:0.45 cursor:not-allowed pointer-events:none",
30
- ".s-menu-icon": "fg:$s-fg-muted flex-shrink:0",
16
+ // One class for both the `<a>` (link) and `<button>` forms they look
17
+ // identical; the element only differs where link semantics matter (see below).
18
+ ".s-menu-item": "display:flex align-items:center gap:$2 w:100% outline:0 " +
19
+ "padding: $m2; line-height:1.1 r:$s-radius cursor:pointer text-align:left font-weight:450 " +
20
+ "font-size:0.9em border:0 background:transparent fg:$s-text text-decoration:none " +
21
+ "transition: color 0.12s, transform 0.12s, text-shadow 0.12s;",
22
+ ".s-menu-item:focus-visible:not([aria-current=page]), .s-menu-item:hover:not([aria-disabled=true]):not([aria-current=page])": "filter:none color: color-mix(in lab, $s-primary 33%, $s-text);",
23
+ ".s-menu-item[aria-current=page]": "text-shadow: 0 0 2px $s-primary; color: color-mix(in lab, $s-primary 50%, $s-text); filter:brightness(1.15)",
24
+ ".s-menu-item[aria-disabled=true]": "opacity:0.45 cursor:not-allowed pointer-events:none",
25
+ ".s-menu-icon": "flex-shrink:0",
31
26
  // A soft hairline that fades out at both ends, rather than a hard full-width
32
27
  // rule — quieter, and it reads as a grouping cue instead of a divider bar.
33
28
  // `hr.` (not just `.`) so this wins over the global hr flow-margin rule.
34
29
  "hr.s-menu-sep": "border:0 height:1px margin: $1 0.6rem; " +
35
- "background: linear-gradient(to right, transparent, $s-border-strong 18%, $s-border-strong 82%, transparent);",
30
+ "background: linear-gradient(to right, transparent, $s-faint 18%, $s-faint 82%, transparent);",
36
31
  });
37
32
  /**
38
33
  * Draw a list of {@link MenuEntry} items into the *current* element, with
@@ -51,11 +46,19 @@ A.insertGlobalCss({
51
46
  export function drawMenu(items, onActivate) {
52
47
  // Roving focus via the DOM: query the live item elements on each keypress.
53
48
  A("keydown=", (e) => {
49
+ // Link items navigate through interceptLinks' own Enter handler, which
50
+ // preventDefault()s the activation — so no synthetic `click` fires, and the
51
+ // click-bound `onActivate` (which closes a floating menu) never runs. Close it
52
+ // ourselves, deferred so this keydown finishes dispatching (and navigates) first.
53
+ if (e.key === "Enter" && e.target.tagName === "A") {
54
+ queueMicrotask(() => onActivate?.());
55
+ return;
56
+ }
54
57
  if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End")
55
58
  return;
56
59
  e.preventDefault();
57
60
  const container = e.currentTarget;
58
- const els = [...container.querySelectorAll(".s-menu-item, .s-menu-item-link")]
61
+ const els = [...container.querySelectorAll(".s-menu-item")]
59
62
  .filter((el) => el.getAttribute("aria-disabled") !== "true");
60
63
  if (!els.length)
61
64
  return;
@@ -76,7 +79,7 @@ export function drawMenu(items, onActivate) {
76
79
  A("hr.s-menu-sep");
77
80
  continue;
78
81
  }
79
- A(entry.href ? "a.s-menu-item-link" : "button.s-menu-item type=button", entry.attrs, () => {
82
+ A(entry.href ? "a.s-menu-item" : "button.s-menu-item type=button", entry.attrs, () => {
80
83
  if (entry.href) {
81
84
  A("href=", entry.href);
82
85
  if (entry.target)
@@ -109,6 +112,14 @@ function closeFloating() {
109
112
  $floating.opts = null;
110
113
  anchor?.focus();
111
114
  }
115
+ /**
116
+ * Whether a floating menu is currently open. Reflects live state (cleared the
117
+ * instant it closes), unlike the DOM — the panel lingers briefly while its
118
+ * `destroy=` transition plays out.
119
+ */
120
+ export function isFloatingMenuOpen() {
121
+ return $floating.opts != null;
122
+ }
112
123
  function positionMenu(menuEl, rect) {
113
124
  const mw = menuEl.offsetWidth, mh = menuEl.offsetHeight;
114
125
  const vw = window.innerWidth, vh = window.innerHeight;
@@ -126,14 +137,14 @@ mountPortal(() => {
126
137
  const f = $floating.opts;
127
138
  if (!f)
128
139
  return;
129
- const menuEl = A("div.s-menu-list.s-s.panel create=hidden destroy=hidden", f.dropdownAttrs, () => {
140
+ const menuEl = A("div.s-menu-list.s-s.neutral.shadow create=hidden destroy=hidden", f.dropdownAttrs, () => {
130
141
  drawMenu(f.items, closeFloating);
131
142
  });
132
143
  // Capture-phase document handlers replace an invisible backdrop element:
133
144
  // any click outside the panel + anchor closes; Escape/Tab close.
134
145
  const onClick = (e) => {
135
146
  const t = e.target;
136
- if (!menuEl.contains(t) && !f.anchor.contains(t))
147
+ if (!menuEl.contains(t) && (f.closeOnAnchorClick || !f.anchor.contains(t)))
137
148
  closeFloating();
138
149
  };
139
150
  const onKey = (e) => {
@@ -152,8 +163,14 @@ mountPortal(() => {
152
163
  requestAnimationFrame(() => {
153
164
  if (!document.body.contains(menuEl))
154
165
  return;
155
- positionMenu(menuEl, f.anchor.getBoundingClientRect());
156
- menuEl.querySelector(".s-menu-item:not([aria-disabled=true]), .s-menu-item-link:not([aria-disabled=true])")?.focus();
166
+ // Position at the supplied point (a zero-size rect) when given — e.g. the
167
+ // pointer location for a context menu — otherwise below the anchor.
168
+ const rect = f.at ? { left: f.at.x, right: f.at.x, top: f.at.y, bottom: f.at.y } : f.anchor.getBoundingClientRect();
169
+ positionMenu(menuEl, rect);
170
+ // Focus the active (current-page) item if there is one — so opening lands
171
+ // where you are — else the first focusable element (covers custom slot
172
+ // content, like a settings dropdown, not just `.s-menu-item`s).
173
+ focusFirst(menuEl, ".s-menu-item[aria-current=page]");
157
174
  });
158
175
  });
159
176
  // ─── Public API ──────────────────────────────────────────────────────────────
@@ -214,7 +231,15 @@ export function addContextMenu(opts) {
214
231
  A("contextmenu=", (e) => {
215
232
  e.preventDefault();
216
233
  myEl = e.currentTarget;
217
- showFloatingMenu({ items: opts.items, anchor: myEl, dropdownAttrs: opts.dropdownAttrs });
234
+ // Anchor at the exact click/tap point, and close on a plain click of the
235
+ // element (it has no toggle handler of its own).
236
+ showFloatingMenu({
237
+ items: opts.items,
238
+ anchor: myEl,
239
+ at: { x: e.clientX, y: e.clientY },
240
+ closeOnAnchorClick: true,
241
+ dropdownAttrs: opts.dropdownAttrs,
242
+ });
218
243
  });
219
244
  }
220
245
  /**
@@ -228,7 +253,7 @@ export function addContextMenu(opts) {
228
253
  * @example
229
254
  * ```ts
230
255
  * S.menuButton({
231
- * button: { content: "Actions", attrs: ".neutral .outlined" },
256
+ * button: { content: "Actions", attrs: ".neutral" },
232
257
  * items: [
233
258
  * { label: "Edit", icon: () => A("#✎"), click: () => edit() },
234
259
  * { separator: true },
@@ -246,7 +271,7 @@ export function menuButton(opts) {
246
271
  // Only label the trigger "Open menu" when it has no visible text of its
247
272
  // own — an aria-label would otherwise *hide* that text from AT.
248
273
  ...(opts.button?.content == null ? { ariaLabel: "Open menu" } : null),
249
- attrs: ".neutral .outlined",
274
+ attrs: ".neutral",
250
275
  ...opts.button,
251
276
  click: (e) => {
252
277
  myEl = e.currentTarget;
@@ -5,7 +5,7 @@ A.insertGlobalCss({
5
5
  ".s-select_wrap": {
6
6
  "&": "position:relative display:block",
7
7
  "select": "w:100% cursor:pointer padding-right:2.2em; appearance:none",
8
- "&::after": "content: '▾'; position:absolute right:0.7em top:50%; transform: translateY(-50%); pointer-events:none fg:$s-fg-muted font-size:0.85em",
8
+ "&::after": "content: '▾'; position:absolute right:0.7em top:50%; transform: translateY(-50%); pointer-events:none fg:$s-muted font-size:0.85em",
9
9
  },
10
10
  });
11
11
  /**
@@ -3,13 +3,13 @@ import { drawSlot, uniqueId } from "../core.js";
3
3
  A.insertGlobalCss({
4
4
  ".s-tabs": {
5
5
  "&": "display:flex flex-direction:column gap:$3",
6
- ".s-tablist": "display:flex gap:$1 align-items:stretch overflow-x:auto scrollbar-width:none border-bottom: 1px solid $s-border;",
6
+ ".s-tablist": "display:flex gap:$1 align-items:stretch overflow-x:auto scrollbar-width:none border-bottom: 1px solid $s-faint;",
7
7
  ".s-tablist::-webkit-scrollbar": "display:none",
8
8
  ".s-tab": "display:inline-flex align-items:center gap:$2 cursor:pointer background:transparent " +
9
- "border:0 color: $s-fg-muted; font-weight:600 padding: 0.6em 0.9em; " +
9
+ "border:0 color: $s-muted; font-weight:600 padding: 0.6em 0.9em; " +
10
10
  "border-bottom: 3px solid transparent; margin-bottom:-1px " +
11
11
  "transition: color 0.15s, background 0.15s, border-color 0.15s;",
12
- ".s-tab:hover:not(:disabled), .s-tab[aria-selected=true]": "color: $s-fg;",
12
+ ".s-tab:hover:not(:disabled), .s-tab[aria-selected=true]": "color: $s-text;",
13
13
  ".s-tab:focus-visible": "outline:none box-shadow: 0 0 0 3px $s-focus; r: $s-radius;",
14
14
  ".s-tab[aria-selected=true]": "border-image: $s-gradient 1;",
15
15
  ".s-tabpanel": "display:block",
@@ -7,15 +7,14 @@ A.insertGlobalCss({
7
7
  "pointer-events:none max-width:min(90vw,24rem) w:24rem",
8
8
  ".s-toast": {
9
9
  "&": "display:flex align-items:flex-start gap:$2 " +
10
- "padding: $3; border: 1px solid $s-border; r:$s-radius box-shadow:$s-shadow " +
10
+ "padding: $3; " +
11
11
  "pointer-events:auto position:relative overflow:hidden",
12
12
  ".s-toast-body": "display:flex flex-direction:column gap:$1 flex:1 min-width:0",
13
13
  ".s-toast-title": "font-weight:700 line-height:1.3",
14
- ".s-toast-msg": "font-size:0.9em fg:$s-fg-muted line-height:1.4",
15
- ".s-toast-close": "cursor:pointer border:0 background:transparent fg:$s-fg-muted font-size:1.1em line-height:1 " +
14
+ ".s-toast-close": "cursor:pointer border:0 background:transparent fg:$s-muted font-size:1.1em line-height:1 " +
16
15
  "padding: 0 0.15em; r:4px flex-shrink:0 align-self:flex-start",
17
- ".s-toast-close:hover": "fg:$s-fg",
18
- ".s-toast-close:focus-visible": "outline:none box-shadow: 0 0 0 3px $s-focus; fg:$s-fg",
16
+ ".s-toast-close:hover": "fg:$s-text",
17
+ ".s-toast-close:focus-visible": "outline:none box-shadow: 0 0 0 3px $s-focus; fg:$s-text",
19
18
  ".s-toast-progress": "position:absolute bottom:0 left:0 right:0 height:2px background:$s-accent width:100%",
20
19
  },
21
20
  });
@@ -27,11 +26,12 @@ mountPortal(() => {
27
26
  // leaving the container in the DOM forever after. (So as not to cut of hide animations.)
28
27
  if (A.peek(() => A.isEmpty(toasts)) && A.isEmpty(toasts))
29
28
  return;
30
- A("div.s-toasts aria-live=polite aria-atomic=false", () => {
29
+ A("div.s-toasts", () => {
31
30
  A.onEach(toasts, (entry) => {
32
31
  const { opts, id } = entry;
33
32
  const role = opts.type === "danger" || opts.type === "warning" ? "alert" : "status";
34
- const surface = opts.type ?? "neutral";
33
+ // "neutral" (the default) is a neutral surface; the rest are accent surfaces.
34
+ const surface = opts.type == null || opts.type === "neutral" ? "neutral" : opts.type;
35
35
  const duration = opts.duration ?? 6000;
36
36
  let timer;
37
37
  let progressEl = null;
@@ -55,7 +55,7 @@ mountPortal(() => {
55
55
  }
56
56
  };
57
57
  A.clean(() => clearTimeout(timer));
58
- A(`div.s-toast.s-s.${surface} role=${role}`, "create=", grow, "destroy=", shrink, opts.attrs, () => {
58
+ A(`div.s-toast.s-s.${surface}.extra-shadow aria-live=polite role=${role}`, "create=", grow, "destroy=", shrink, opts.attrs, () => {
59
59
  if (duration > 0) {
60
60
  A("mouseenter=", stopCountdown);
61
61
  A("mouseleave=", startCountdown);
@@ -1,11 +1,12 @@
1
1
  import A from "aberdeen";
2
2
  import { 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.
3
6
  A.insertGlobalCss({
4
7
  ".s-tt-tip": {
5
8
  "&": "position:fixed z-index:500 " +
6
9
  "max-width:20rem w:max-content " +
7
- "bg:$s-raised fg:$s-fg border: 1px solid $s-border-strong; " +
8
- "r:$s-radius box-shadow:$s-shadow " +
9
10
  "padding: 0.3em 0.65em; font-size:0.85em line-height:1.4 " +
10
11
  "pointer-events:none",
11
12
  },
@@ -73,7 +74,7 @@ mountPortal(() => {
73
74
  return;
74
75
  const { opts, anchor } = active;
75
76
  const placement = opts.placement ?? "top";
76
- const tipEl = A("div.s-tt-tip role=tooltip visibility:hidden", opts.attrs, () => {
77
+ const tipEl = A("div.s-tt-tip.s-s.neutral.shadow role=tooltip visibility:hidden", opts.attrs, () => {
77
78
  A("mouseenter=", () => {
78
79
  if (hideTimer) {
79
80
  clearTimeout(hideTimer);
package/dist/core.d.ts CHANGED
@@ -10,8 +10,9 @@
10
10
  * An Aberdeen attribute/style/class string, e.g. `"display:flex gap:$3 .my-class"`.
11
11
  *
12
12
  * Common values are our surface modifier classes:
13
- * - for colors: `.panel` `.raised` `.neutral` `.primary` `.secondary` `.gradient` `.danger` `.success` and `.warning`
14
- * - for variant: `.filled` `.tonal` and `.outlined`
13
+ * - neutral surface: `.neutral` (shade steps with nesting depth)
14
+ * - accent surface: `.primary` `.danger` `.success` `.warning` and `.link`
15
+ * - accent variant: `.filled` `.tonal` and `.outlined`
15
16
  *
16
17
  * These strings are passed straight through to {@link A} as positional
17
18
  * arguments, so they accept the full Aberdeen shorthand syntax: CSS shortcuts
@@ -56,6 +57,17 @@ export declare function uniqueId(prefix?: string): string;
56
57
  * `rich` markup (`*italic*`, `**bold**`, `` `code` ``, `[link](/path)`).
57
58
  */
58
59
  export declare function drawSlot<Args extends unknown[] = []>(slot: Slot<Args> | undefined, ...args: Args): void;
60
+ /**
61
+ * Move keyboard focus to the first focusable element inside `container`, skipping
62
+ * disabled, `aria-disabled`, `tabindex=-1` and hidden ones. When `prefer` (a
63
+ * selector) matches a focusable element it wins — e.g. a menu's current item — so
64
+ * opening lands where the user already is. Returns whether anything was focused.
65
+ *
66
+ * Shared by overlays (the floating menu, dialogs) so "open → focus the right
67
+ * thing" behaves identically everywhere. Call it after the element is in the DOM
68
+ * and laid out (typically inside a `requestAnimationFrame`).
69
+ */
70
+ export declare function focusFirst(container: HTMLElement, prefer?: string): boolean;
59
71
  /**
60
72
  * Mount a portal (tooltip, toast, menu, dialog, …) directly into `<body>`.
61
73
  * Must be called at module top level, where Aberdeen's root scope (whose
package/dist/core.js CHANGED
@@ -17,6 +17,29 @@ export function drawSlot(slot, ...args) {
17
17
  else
18
18
  A("rich=", slot);
19
19
  }
20
+ /** Selector matching the natively focusable elements we care about. */
21
+ const FOCUSABLE_SELECTOR = "a[href], button, input, select, textarea, [tabindex]";
22
+ /**
23
+ * Move keyboard focus to the first focusable element inside `container`, skipping
24
+ * disabled, `aria-disabled`, `tabindex=-1` and hidden ones. When `prefer` (a
25
+ * selector) matches a focusable element it wins — e.g. a menu's current item — so
26
+ * opening lands where the user already is. Returns whether anything was focused.
27
+ *
28
+ * Shared by overlays (the floating menu, dialogs) so "open → focus the right
29
+ * thing" behaves identically everywhere. Call it after the element is in the DOM
30
+ * and laid out (typically inside a `requestAnimationFrame`).
31
+ */
32
+ export function focusFirst(container, prefer) {
33
+ const ok = (el) => el instanceof HTMLElement &&
34
+ !el.hasAttribute("disabled") &&
35
+ el.getAttribute("aria-disabled") !== "true" &&
36
+ el.tabIndex >= 0 &&
37
+ el.getClientRects().length > 0;
38
+ const target = (prefer ? [...container.querySelectorAll(prefer)].find(ok) : undefined) ??
39
+ [...container.querySelectorAll(FOCUSABLE_SELECTOR)].find(ok);
40
+ target?.focus();
41
+ return target != null;
42
+ }
20
43
  /**
21
44
  * Mount a portal (tooltip, toast, menu, dialog, …) directly into `<body>`.
22
45
  * Must be called at module top level, where Aberdeen's root scope (whose