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.
- package/README.md +34 -16
- package/dist/components/autocomplete.js +12 -9
- package/dist/components/box.js +15 -10
- package/dist/components/button.d.ts +3 -1
- package/dist/components/button.js +21 -28
- package/dist/components/buttonChooser.js +1 -1
- package/dist/components/buttonGroup.d.ts +3 -3
- package/dist/components/buttonGroup.js +3 -3
- package/dist/components/dialog.d.ts +3 -1
- package/dist/components/dialog.js +20 -10
- package/dist/components/field.js +7 -4
- package/dist/components/main.js +52 -15
- package/dist/components/menu.d.ts +23 -2
- package/dist/components/menu.js +54 -29
- package/dist/components/select.js +1 -1
- package/dist/components/tabs.js +3 -3
- package/dist/components/toast.js +8 -8
- package/dist/components/tooltip.js +4 -3
- package/dist/core.d.ts +14 -2
- package/dist/core.js +23 -0
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.js +138 -224
- package/package.json +1 -1
- package/skill/ButtonOptions.md +2 -1
- package/skill/SKILL.md +108 -47
- package/skill/ToastOptions.md +1 -1
- package/skill/addContextMenu.md +28 -0
- package/skill/addTooltip.md +6 -2
- package/skill/autocomplete.md +9 -2
- package/skill/box.md +1 -0
- package/skill/button.md +5 -3
- package/skill/buttonGroup.md +5 -3
- package/skill/checkbox.md +1 -0
- package/skill/confirm.md +1 -1
- package/skill/dialog.md +2 -2
- package/skill/form.md +3 -2
- package/skill/main.md +5 -1
- package/skill/menuButton.md +1 -1
- package/skill/prompt.md +1 -1
- package/skill/select.md +1 -0
- package/skill/showFloatingMenu.md +5 -6
- package/skill/tabs.md +2 -2
- package/skill/textarea.md +3 -2
- package/skill/textline.md +1 -0
- package/skill/toast.md +2 -3
- package/src/components/autocomplete.ts +12 -9
- package/src/components/box.ts +15 -10
- package/src/components/button.ts +23 -32
- package/src/components/buttonChooser.ts +1 -1
- package/src/components/buttonGroup.ts +3 -3
- package/src/components/dialog.ts +22 -11
- package/src/components/field.ts +7 -4
- package/src/components/main.ts +47 -16
- package/src/components/menu.ts +73 -40
- package/src/components/select.ts +1 -1
- package/src/components/tabs.ts +3 -3
- package/src/components/toast.ts +8 -8
- package/src/components/tooltip.ts +4 -3
- package/src/core.ts +30 -2
- package/src/theme.ts +152 -234
package/src/components/menu.ts
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
"
|
|
93
|
-
"
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
".s-menu-item
|
|
99
|
-
"
|
|
100
|
-
|
|
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": "
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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:
|
|
316
|
+
A("contextmenu=", (e: MouseEvent) => {
|
|
292
317
|
e.preventDefault();
|
|
293
318
|
myEl = e.currentTarget as HTMLElement;
|
|
294
|
-
|
|
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
|
|
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
|
|
360
|
+
attrs: ".neutral",
|
|
328
361
|
...opts.button,
|
|
329
362
|
click: (e: Event) => {
|
|
330
363
|
myEl = e.currentTarget as HTMLElement;
|
package/src/components/select.ts
CHANGED
|
@@ -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-
|
|
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
|
|
package/src/components/tabs.ts
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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",
|
package/src/components/toast.ts
CHANGED
|
@@ -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;
|
|
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-
|
|
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-
|
|
48
|
-
".s-toast-close:focus-visible": "outline:none box-shadow: 0 0 0 3px $s-focus; fg:$s-
|
|
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
|
|
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
|
-
|
|
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
|
-
* -
|
|
17
|
-
* -
|
|
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
|