staffa 0.2.1 → 0.3.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 +153 -127
- package/dist/components/autocomplete.js +3 -2
- package/dist/components/box.js +2 -2
- package/dist/components/button.d.ts +11 -2
- package/dist/components/button.js +36 -8
- package/dist/components/buttonChooser.d.ts +4 -5
- package/dist/components/buttonChooser.js +2 -2
- package/dist/components/buttonGroup.js +0 -3
- package/dist/components/field.js +0 -3
- package/dist/components/main.d.ts +34 -9
- package/dist/components/main.js +187 -49
- package/dist/components/menu.d.ts +118 -0
- package/dist/components/menu.js +218 -0
- package/dist/components/tabs.d.ts +0 -2
- package/dist/components/tabs.js +6 -19
- package/dist/components/toast.d.ts +37 -0
- package/dist/components/toast.js +79 -0
- package/dist/components/tooltip.d.ts +32 -0
- package/dist/components/tooltip.js +130 -0
- package/dist/core.d.ts +1 -1
- package/dist/icons-helpers.d.ts +46 -0
- package/dist/icons-helpers.js +44 -0
- package/dist/icons.d.ts +1960 -0
- package/dist/icons.js +1972 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.d.ts +1 -1
- package/dist/theme.js +114 -13
- package/package.json +10 -4
- package/src/components/autocomplete.ts +3 -2
- package/src/components/box.ts +2 -2
- package/src/components/button.ts +42 -10
- package/src/components/buttonChooser.ts +6 -7
- package/src/components/buttonGroup.ts +0 -3
- package/src/components/field.ts +0 -3
- package/src/components/main.ts +201 -45
- package/src/components/menu.ts +288 -0
- package/src/components/tabs.ts +6 -20
- package/src/components/toast.ts +115 -0
- package/src/components/tooltip.ts +139 -0
- package/src/core.ts +1 -1
- package/src/icons-helpers.ts +90 -0
- package/src/icons.ts +1977 -0
- package/src/index.ts +11 -0
- package/src/theme.ts +128 -18
- package/dist/components/modal.d.ts +0 -2
- package/dist/components/modal.js +0 -2
- package/dist/skye.esm.js +0 -1
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import { matchCurrent } from "aberdeen/route";
|
|
3
|
+
import { type Content, type Slot, type Attributes, drawSlot } from "../core.js";
|
|
4
|
+
import { button, type ButtonOptions } from "./button.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A clickable item in a menu or sidebar nav.
|
|
8
|
+
*
|
|
9
|
+
* **Tip:** set `href` and call Aberdeen's `interceptLinks()` once at app
|
|
10
|
+
* startup for SPA-style navigation. When `href` is set, the item is
|
|
11
|
+
* automatically highlighted as active whenever the current URL matches it
|
|
12
|
+
* (via {@link matchCurrent}).
|
|
13
|
+
*/
|
|
14
|
+
export interface MenuItem {
|
|
15
|
+
/** Label text or draw function. Strings are rendered as rich text. */
|
|
16
|
+
label: Slot;
|
|
17
|
+
/** Leading icon drawn before the label. */
|
|
18
|
+
icon?: Slot;
|
|
19
|
+
/** Click handler. */
|
|
20
|
+
click?: (e: Event) => void;
|
|
21
|
+
/**
|
|
22
|
+
* Render as a link (`<a>`) pointing here. Pairs naturally with
|
|
23
|
+
* `interceptLinks()` — the item is highlighted automatically when the URL
|
|
24
|
+
* matches.
|
|
25
|
+
*/
|
|
26
|
+
href?: string;
|
|
27
|
+
/** `target` for the link (`_blank`, etc.). Only meaningful with `href`. */
|
|
28
|
+
target?: string;
|
|
29
|
+
/** Disables the item. */
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
/** Aberdeen attr/style string on the item element. */
|
|
32
|
+
attrs?: Attributes;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A visual divider between groups of items. */
|
|
36
|
+
export interface MenuSeparator {
|
|
37
|
+
separator: true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* An entry in a menu or sidebar nav list. Three forms:
|
|
42
|
+
* - `MenuItem` — a clickable/linkable row with label and optional icon.
|
|
43
|
+
* - `MenuSeparator` — a visual divider (`{ separator: true }`).
|
|
44
|
+
* - A draw function `() => void` — renders custom content (section header,
|
|
45
|
+
* avatar, search box, …). Skipped by keyboard navigation.
|
|
46
|
+
*/
|
|
47
|
+
export type MenuEntry = MenuItem | MenuSeparator | Content;
|
|
48
|
+
|
|
49
|
+
/** Options for {@link menuButton} and {@link MainOptions.nav}. */
|
|
50
|
+
export interface MenuOptions {
|
|
51
|
+
/** Items shown in the dropdown or sidebar nav. */
|
|
52
|
+
items: MenuEntry[];
|
|
53
|
+
/**
|
|
54
|
+
* Customize the trigger button rendered by {@link menuButton}. Defaults to a
|
|
55
|
+
* `☰` icon button. The `click` handler is managed internally.
|
|
56
|
+
*
|
|
57
|
+
* When used as a `nav` in `S.main()`, this also customizes the hamburger
|
|
58
|
+
* button shown when the sidebar collapses.
|
|
59
|
+
*/
|
|
60
|
+
button?: ButtonOptions;
|
|
61
|
+
/** Aberdeen attr/style string on the floating dropdown panel. */
|
|
62
|
+
dropdownAttrs?: Attributes;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Options for {@link showFloatingMenu}. */
|
|
66
|
+
export interface FloatingMenuOptions {
|
|
67
|
+
/** Items to show. */
|
|
68
|
+
items: MenuEntry[];
|
|
69
|
+
/** Element to anchor the menu to (positioned just below it, flips up if needed). */
|
|
70
|
+
anchor: HTMLElement;
|
|
71
|
+
/** Aberdeen attr/style string on the floating panel. */
|
|
72
|
+
dropdownAttrs?: Attributes;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Styles shared by the floating dropdown and the sidebar nav, so both look
|
|
76
|
+
// identical. The item styles aren't scoped to a container, so `drawMenu` can
|
|
77
|
+
// render its items into either one.
|
|
78
|
+
A.insertGlobalCss({
|
|
79
|
+
".s-menu-list":
|
|
80
|
+
"position:fixed z-index:350 min-width:10rem display:flex flex-direction:column p:$1 " +
|
|
81
|
+
"border: 1px solid $s-border; r:$s-radius-lg box-shadow:$s-shadow " +
|
|
82
|
+
"overflow-y:auto max-height:min(80vh,28rem) " +
|
|
83
|
+
"transition: opacity 0.15s, transform 0.15s;",
|
|
84
|
+
".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px)",
|
|
85
|
+
".s-menu-item, .s-menu-item-link":
|
|
86
|
+
"display:flex align-items:center gap:$2 w:100% " +
|
|
87
|
+
"padding: 0.5em 0.65em; r:$s-radius cursor:pointer text-align:left " +
|
|
88
|
+
"font-size:0.9em border:0 background:transparent fg:$s-fg text-decoration:none " +
|
|
89
|
+
"transition: background 0.12s, color 0.12s, transform 0.12s, box-shadow 0.12s;",
|
|
90
|
+
// A translucent ink tint (rather than an *opaque* mix) so the hover fades in
|
|
91
|
+
// cleanly: transitioning background from `transparent` toward an opaque colour
|
|
92
|
+
// flashes through dark mid-tones in browsers that interpolate non-premultiplied.
|
|
93
|
+
// Staying ink-hued at low alpha keeps the fade the right colour throughout.
|
|
94
|
+
".s-menu-item:hover:not([aria-disabled=true]), .s-menu-item-link:hover":
|
|
95
|
+
"background: color-mix(in srgb, $s-fg 10%, transparent);",
|
|
96
|
+
// Active (current page): a filled brand-gradient pill with a soft glow — the
|
|
97
|
+
// one place the menu shows real colour, so the current page is unmistakable.
|
|
98
|
+
".s-menu-item[aria-current=page], .s-menu-item-link[aria-current=page]":
|
|
99
|
+
"color:$s-on-accent font-weight:600 background: $s-gradient; box-shadow: 0 3px 10px color-mix(in srgb, $s-primary 38%, transparent);",
|
|
100
|
+
".s-menu-item[aria-current=page] .s-menu-icon, .s-menu-item-link[aria-current=page] .s-menu-icon":
|
|
101
|
+
"color:$s-on-accent",
|
|
102
|
+
".s-menu-item[aria-current=page]:hover, .s-menu-item-link[aria-current=page]:hover":
|
|
103
|
+
"filter:brightness(1.06)",
|
|
104
|
+
".s-menu-item:focus-visible, .s-menu-item-link:focus-visible":
|
|
105
|
+
"outline:none background: color-mix(in srgb, $s-fg 10%, transparent); box-shadow: 0 0 0 2px inset $s-focus;",
|
|
106
|
+
".s-menu-item[aria-disabled=true], .s-menu-item-link[aria-disabled=true]":
|
|
107
|
+
"opacity:0.45 cursor:not-allowed pointer-events:none",
|
|
108
|
+
".s-menu-icon": "fg:$s-fg-muted flex-shrink:0",
|
|
109
|
+
// A soft hairline that fades out at both ends, rather than a hard full-width
|
|
110
|
+
// rule — quieter, and it reads as a grouping cue instead of a divider bar.
|
|
111
|
+
// `hr.` (not just `.`) so this wins over the global hr flow-margin rule.
|
|
112
|
+
"hr.s-menu-sep":
|
|
113
|
+
"border:0 height:1px margin: $1 0.6rem; " +
|
|
114
|
+
"background: linear-gradient(to right, transparent, $s-border-strong 18%, $s-border-strong 82%, transparent);",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Draw a list of {@link MenuEntry} items into the *current* element, with
|
|
119
|
+
* arrow-key / Home / End navigation between the focusable items. The single
|
|
120
|
+
* shared primitive behind both the floating dropdown ({@link showFloatingMenu})
|
|
121
|
+
* and the sidebar nav in `S.main()` — call it inside whatever container
|
|
122
|
+
* (`<nav>`, the floating panel, …) you've opened.
|
|
123
|
+
*
|
|
124
|
+
* Items are real `<a>`/`<button>` elements, so Enter/Space activate them and
|
|
125
|
+
* screen readers narrate them natively.
|
|
126
|
+
*
|
|
127
|
+
* @param items The entries to render.
|
|
128
|
+
* @param onActivate Optional — run when any item is activated (used by the
|
|
129
|
+
* floating menu to close itself on selection).
|
|
130
|
+
*/
|
|
131
|
+
export function drawMenu(items: MenuEntry[], onActivate?: () => void): void {
|
|
132
|
+
// Roving focus via the DOM: query the live item elements on each keypress.
|
|
133
|
+
A("keydown=", (e: KeyboardEvent) => {
|
|
134
|
+
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") return;
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
const container = e.currentTarget as HTMLElement;
|
|
137
|
+
const els = [...container.querySelectorAll<HTMLElement>(".s-menu-item, .s-menu-item-link")]
|
|
138
|
+
.filter((el) => el.getAttribute("aria-disabled") !== "true");
|
|
139
|
+
if (!els.length) return;
|
|
140
|
+
const cur = els.indexOf(document.activeElement as HTMLElement);
|
|
141
|
+
const dir = e.key === "ArrowUp" ? -1 : 1;
|
|
142
|
+
const next =
|
|
143
|
+
e.key === "Home" ? 0 :
|
|
144
|
+
e.key === "End" ? els.length - 1 :
|
|
145
|
+
cur < 0 ? (dir > 0 ? 0 : els.length - 1) :
|
|
146
|
+
(cur + dir + els.length) % els.length;
|
|
147
|
+
els[next].focus();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
for (const entry of items) {
|
|
151
|
+
if (typeof entry === "function") { entry(); continue; }
|
|
152
|
+
if ("separator" in entry) { A("hr.s-menu-sep"); continue; }
|
|
153
|
+
|
|
154
|
+
A(entry.href ? "a.s-menu-item-link" : "button.s-menu-item type=button", entry.attrs, () => {
|
|
155
|
+
if (entry.href) {
|
|
156
|
+
A("href=", entry.href);
|
|
157
|
+
if (entry.target) A("target=", entry.target);
|
|
158
|
+
A(() => { if (matchCurrent(entry.href!)) A("aria-current=page"); });
|
|
159
|
+
}
|
|
160
|
+
if (entry.disabled) A("aria-disabled=true");
|
|
161
|
+
A("click=", (e: Event) => {
|
|
162
|
+
if (entry.disabled) { e.preventDefault(); return; }
|
|
163
|
+
onActivate?.();
|
|
164
|
+
entry.click?.(e);
|
|
165
|
+
});
|
|
166
|
+
if (entry.icon) A("span.s-menu-icon", () => drawSlot(entry.icon));
|
|
167
|
+
drawSlot(entry.label);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Floating menu ───────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
// At most one floating menu is open at a time. The anchor lives in the options,
|
|
175
|
+
// so it's available for positioning and focus-return without extra state.
|
|
176
|
+
const $floating = A.proxy<{ opts: FloatingMenuOptions | null }>({ opts: null });
|
|
177
|
+
|
|
178
|
+
function closeFloating(): void {
|
|
179
|
+
const anchor = $floating.opts?.anchor;
|
|
180
|
+
$floating.opts = null;
|
|
181
|
+
anchor?.focus();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function positionMenu(menuEl: HTMLElement, rect: DOMRect): void {
|
|
185
|
+
const mw = menuEl.offsetWidth, mh = menuEl.offsetHeight;
|
|
186
|
+
const vw = window.innerWidth, vh = window.innerHeight;
|
|
187
|
+
const gap = 4;
|
|
188
|
+
let x = rect.left;
|
|
189
|
+
if (x + mw > vw - 8) x = Math.max(8, rect.right - mw);
|
|
190
|
+
let y = rect.bottom + gap;
|
|
191
|
+
if (y + mh > vh - 8 && rect.top - mh - gap >= 8) y = rect.top - mh - gap;
|
|
192
|
+
menuEl.style.left = Math.max(8, x) + "px";
|
|
193
|
+
menuEl.style.top = Math.max(8, y) + "px";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
A.mount(document.body, () => {
|
|
197
|
+
const f = $floating.opts;
|
|
198
|
+
if (!f) return;
|
|
199
|
+
|
|
200
|
+
const menuEl = A("div.s-menu-list.s-s.panel create=hidden destroy=hidden", f.dropdownAttrs, () => {
|
|
201
|
+
drawMenu(f.items, closeFloating);
|
|
202
|
+
}) as HTMLElement;
|
|
203
|
+
|
|
204
|
+
// Capture-phase document handlers replace an invisible backdrop element:
|
|
205
|
+
// any click outside the panel + anchor closes; Escape/Tab close.
|
|
206
|
+
const onClick = (e: MouseEvent) => {
|
|
207
|
+
const t = e.target as Node;
|
|
208
|
+
if (!menuEl.contains(t) && !f.anchor.contains(t)) closeFloating();
|
|
209
|
+
};
|
|
210
|
+
const onKey = (e: KeyboardEvent) => {
|
|
211
|
+
if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); closeFloating(); }
|
|
212
|
+
};
|
|
213
|
+
document.addEventListener("click", onClick, true);
|
|
214
|
+
document.addEventListener("keydown", onKey, true);
|
|
215
|
+
A.clean(() => {
|
|
216
|
+
document.removeEventListener("click", onClick, true);
|
|
217
|
+
document.removeEventListener("keydown", onKey, true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Position after layout, then focus the first enabled item.
|
|
221
|
+
requestAnimationFrame(() => {
|
|
222
|
+
if (!document.body.contains(menuEl)) return;
|
|
223
|
+
positionMenu(menuEl, f.anchor.getBoundingClientRect());
|
|
224
|
+
menuEl.querySelector<HTMLElement>(
|
|
225
|
+
".s-menu-item:not([aria-disabled=true]), .s-menu-item-link:not([aria-disabled=true])",
|
|
226
|
+
)?.focus();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Open a floating dropdown menu anchored to an element. Portals to
|
|
234
|
+
* `document.body` (never clipped), positions itself (flipping up when there's
|
|
235
|
+
* no room below), and closes on Escape, Tab, item selection, or any click
|
|
236
|
+
* outside the panel and anchor. Returns a `close()` function.
|
|
237
|
+
*
|
|
238
|
+
* @example
|
|
239
|
+
* ```ts
|
|
240
|
+
* // Custom context menu:
|
|
241
|
+
* el.addEventListener("contextmenu", (e) => {
|
|
242
|
+
* e.preventDefault();
|
|
243
|
+
* S.showFloatingMenu({ items, anchor: el });
|
|
244
|
+
* });
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
export function showFloatingMenu(opts: FloatingMenuOptions): () => void {
|
|
248
|
+
$floating.opts = opts;
|
|
249
|
+
return closeFloating;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* A button that opens a {@link showFloatingMenu | floating dropdown menu} on
|
|
254
|
+
* click. Keyboard navigation: Arrow Up/Down, Home, End; Escape/Tab to close;
|
|
255
|
+
* Enter/Space activate the focused item natively.
|
|
256
|
+
*
|
|
257
|
+
* **Tip:** set `href` on items and call `interceptLinks()` once at app startup
|
|
258
|
+
* for SPA navigation — active items are highlighted automatically.
|
|
259
|
+
*
|
|
260
|
+
* @example
|
|
261
|
+
* ```ts
|
|
262
|
+
* S.menuButton({
|
|
263
|
+
* button: { text: "Actions", attrs: ".neutral .outlined" },
|
|
264
|
+
* items: [
|
|
265
|
+
* { label: "Edit", icon: () => A("#✎"), click: () => edit() },
|
|
266
|
+
* { separator: true },
|
|
267
|
+
* { label: "Delete", attrs: "fg:$s-danger", click: () => del() },
|
|
268
|
+
* ],
|
|
269
|
+
* });
|
|
270
|
+
* ```
|
|
271
|
+
*/
|
|
272
|
+
export function menuButton(opts: MenuOptions): void {
|
|
273
|
+
let myEl: HTMLElement | null = null;
|
|
274
|
+
A.clean(() => { if ($floating.opts?.anchor === myEl) closeFloating(); });
|
|
275
|
+
|
|
276
|
+
button({
|
|
277
|
+
icon: () => A("span aria-hidden=true #☰"),
|
|
278
|
+
ariaLabel: "Open menu",
|
|
279
|
+
attrs: ".neutral .outlined",
|
|
280
|
+
...opts.button,
|
|
281
|
+
click: (e: Event) => {
|
|
282
|
+
myEl = e.currentTarget as HTMLElement;
|
|
283
|
+
// Toggle: a second click on the same trigger closes the menu.
|
|
284
|
+
if ($floating.opts?.anchor === myEl) { closeFloating(); return; }
|
|
285
|
+
showFloatingMenu({ items: opts.items, anchor: myEl, dropdownAttrs: opts.dropdownAttrs });
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
}
|
package/src/components/tabs.ts
CHANGED
|
@@ -26,8 +26,6 @@ export interface TabsOptions {
|
|
|
26
26
|
* its own internal selection, starting at the first tab.
|
|
27
27
|
*/
|
|
28
28
|
bind?: Bindable<string>;
|
|
29
|
-
/** Visual style of the tab strip. Defaults to `"underline"`. */
|
|
30
|
-
variant?: "underline" | "pills";
|
|
31
29
|
/** Aberdeen attr/style string applied to the active panel. */
|
|
32
30
|
contentAttrs?: Attributes;
|
|
33
31
|
}
|
|
@@ -35,25 +33,17 @@ export interface TabsOptions {
|
|
|
35
33
|
A.insertGlobalCss({
|
|
36
34
|
".s-tabs": {
|
|
37
35
|
"&": "display:flex flex-direction:column gap:$3",
|
|
38
|
-
".s-tablist": "display:flex gap:$1 align-items:stretch",
|
|
36
|
+
".s-tablist": "display:flex gap:$1 align-items:stretch overflow-x:auto scrollbar-width:none border-bottom: 1px solid $s-border;",
|
|
37
|
+
".s-tablist::-webkit-scrollbar": "display:none",
|
|
39
38
|
".s-tab":
|
|
40
39
|
"display:inline-flex align-items:center gap:$2 cursor:pointer background:transparent " +
|
|
41
40
|
"border:0 color: $s-fg-muted; font-weight:600 padding: 0.6em 0.9em; " +
|
|
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)": "color: $s-fg;",
|
|
43
|
+
".s-tab:hover:not(:disabled), .s-tab[aria-selected=true]": "color: $s-fg;",
|
|
44
44
|
".s-tab:disabled": "opacity:0.5 cursor:not-allowed",
|
|
45
45
|
".s-tab:focus-visible": "outline:none box-shadow: 0 0 0 3px $s-focus; r: $s-radius;",
|
|
46
|
-
|
|
47
|
-
"&.s-underline .s-tablist": "border-bottom: 1px solid $s-border;",
|
|
48
|
-
"&.s-underline .s-tab": "border-bottom: 2px solid transparent; margin-bottom:-1px",
|
|
49
|
-
"&.s-underline .s-tab[aria-selected=true]": "color: $s-fg; border-bottom-color: $s-accent;",
|
|
50
|
-
// Pills variant — the active pill gets the `.s-s.primary` surface (added in
|
|
51
|
-
// tabs()), so its `--s-bg`/`--s-fg` are the brand surface's; we just paint
|
|
52
|
-
// (the `.s-tab` rule's transparent background would otherwise win on it).
|
|
53
|
-
"&.s-pills .s-tab": "r: $s-radius;",
|
|
54
|
-
"&.s-pills .s-tab[aria-selected=true]": "background: $s-bg; color: $s-fg;",
|
|
55
|
-
// The panel has no enclosing box, so no default padding — its content
|
|
56
|
-
// aligns flush with the tab strip. Callers add padding/flex via `inner`.
|
|
46
|
+
".s-tab[aria-selected=true]": "border-image: $s-gradient 1;",
|
|
57
47
|
".s-tabpanel": "display:block",
|
|
58
48
|
},
|
|
59
49
|
});
|
|
@@ -71,7 +61,6 @@ A.insertGlobalCss({
|
|
|
71
61
|
* ```
|
|
72
62
|
*/
|
|
73
63
|
export function tabs(opts: TabsOptions): void {
|
|
74
|
-
const variant = opts.variant ?? "underline";
|
|
75
64
|
const groupId = uniqueId("tabs");
|
|
76
65
|
|
|
77
66
|
// Resolve a tab's selection key (its id, or its index as a string).
|
|
@@ -85,7 +74,7 @@ export function tabs(opts: TabsOptions): void {
|
|
|
85
74
|
$sel.value = keyOf(tab, index);
|
|
86
75
|
};
|
|
87
76
|
|
|
88
|
-
A(
|
|
77
|
+
A("div.s-tabs", opts.attrs, () => {
|
|
89
78
|
A("div.s-tablist role=tablist", () => {
|
|
90
79
|
opts.tabs.forEach((tab, index) => {
|
|
91
80
|
const key = keyOf(tab, index);
|
|
@@ -95,9 +84,6 @@ export function tabs(opts: TabsOptions): void {
|
|
|
95
84
|
const selected = $sel.value === key;
|
|
96
85
|
A("aria-selected=", selected ? "true" : "false");
|
|
97
86
|
A("tabindex=", selected ? "0" : "-1");
|
|
98
|
-
// The active pill is a filled brand surface; the classes flip
|
|
99
|
-
// with selection (Aberdeen removes them when this scope re-runs).
|
|
100
|
-
if (selected && variant === "pills") A(".s-s.primary");
|
|
101
87
|
});
|
|
102
88
|
if (tab.disabled) A("disabled=true");
|
|
103
89
|
A("click=", () => select(tab, index));
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import { grow, shrink } from "aberdeen/transitions";
|
|
3
|
+
import { type Slot, type Attributes, drawSlot } from "../core.js";
|
|
4
|
+
import type { SurfaceRole } from "../theme.js";
|
|
5
|
+
|
|
6
|
+
/** Options for {@link toast}. */
|
|
7
|
+
export interface ToastOptions {
|
|
8
|
+
/** Primary message. A string is rendered as rich text. */
|
|
9
|
+
message: Slot;
|
|
10
|
+
/** Optional bold title above the message. */
|
|
11
|
+
title?: Slot;
|
|
12
|
+
/**
|
|
13
|
+
* Colour role. Defaults to `"neutral"`.
|
|
14
|
+
* Use `"success"` / `"danger"` / `"warning"` for semantic feedback.
|
|
15
|
+
*/
|
|
16
|
+
type?: SurfaceRole;
|
|
17
|
+
/**
|
|
18
|
+
* Auto-dismiss delay in milliseconds. Defaults to `4000`.
|
|
19
|
+
* Pass `0` to make the toast persistent until dismissed manually.
|
|
20
|
+
*/
|
|
21
|
+
duration?: number;
|
|
22
|
+
/** Show a close button. Defaults to `true`. */
|
|
23
|
+
dismissible?: boolean;
|
|
24
|
+
/** Aberdeen attr/style string applied to the toast element. */
|
|
25
|
+
attrs?: Attributes;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ToastEntry {
|
|
29
|
+
id: number;
|
|
30
|
+
opts: ToastOptions;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
A.insertGlobalCss({
|
|
34
|
+
".s-toasts":
|
|
35
|
+
"position:fixed bottom:$3 right:$3 z-index:400 " +
|
|
36
|
+
"display:flex flex-direction:column gap:$2 " +
|
|
37
|
+
"pointer-events:none max-width:min(90vw,24rem) w:24rem",
|
|
38
|
+
".s-toast": {
|
|
39
|
+
"&":
|
|
40
|
+
"display:flex align-items:flex-start gap:$2 " +
|
|
41
|
+
"padding: $3; border: 1px solid $s-border; r:$s-radius box-shadow:$s-shadow " +
|
|
42
|
+
"pointer-events:auto",
|
|
43
|
+
".s-toast-body": "display:flex flex-direction:column gap:$1 flex:1 min-width:0",
|
|
44
|
+
".s-toast-title": "font-weight:700 line-height:1.3",
|
|
45
|
+
".s-toast-msg": "font-size:0.9em fg:$s-fg-muted line-height:1.4",
|
|
46
|
+
".s-toast-close":
|
|
47
|
+
"cursor:pointer border:0 background:transparent fg:$s-fg-muted font-size:1.1em line-height:1 " +
|
|
48
|
+
"padding: 0 0.15em; r:4px flex-shrink:0 align-self:flex-start",
|
|
49
|
+
".s-toast-close:hover": "fg:$s-fg",
|
|
50
|
+
".s-toast-close:focus-visible": "outline:none box-shadow: 0 0 0 3px $s-focus; fg:$s-fg",
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
let toastCount = 0;
|
|
55
|
+
// Keyed by stable ID so A.onEach scopes are per-toast — removing one never re-renders others.
|
|
56
|
+
const toasts = A.proxy({} as Record<number, ToastEntry>);
|
|
57
|
+
|
|
58
|
+
A.mount(document.body, () => {
|
|
59
|
+
A("div.s-toasts aria-live=polite aria-atomic=false", () => {
|
|
60
|
+
A.onEach(toasts, (entry) => {
|
|
61
|
+
const { opts } = entry;
|
|
62
|
+
const role = opts.type === "danger" || opts.type === "warning" ? "alert" : "status";
|
|
63
|
+
const surface = opts.type ?? "neutral";
|
|
64
|
+
|
|
65
|
+
A(`div.s-toast.s-s.${surface} role=${role}`, "create=", grow, "destroy=", shrink, opts.attrs, () => {
|
|
66
|
+
A("div.s-toast-body", () => {
|
|
67
|
+
A(() => {
|
|
68
|
+
if (opts.title != null) A("div.s-toast-title", () => drawSlot(opts.title));
|
|
69
|
+
});
|
|
70
|
+
A("div.s-toast-msg", () => drawSlot(opts.message));
|
|
71
|
+
});
|
|
72
|
+
A(() => {
|
|
73
|
+
if (opts.dismissible === false) return;
|
|
74
|
+
A("button.s-toast-close type=button aria-label=Dismiss", () => {
|
|
75
|
+
A("#×");
|
|
76
|
+
A("click=", () => dismiss(entry.id));
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
function dismiss(id: number): void {
|
|
85
|
+
delete toasts[id];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Show a toast notification. Returns a `dismiss()` function to remove it
|
|
90
|
+
* programmatically. Auto-dismisses after `duration` ms (default 4 000).
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```ts
|
|
94
|
+
* S.toast({ message: "Saved!", type: "success" });
|
|
95
|
+
* S.toast({ title: "Error", message: "Upload failed.", type: "danger", duration: 0 });
|
|
96
|
+
* const off = S.toast({ message: "Uploading…", duration: 0, dismissible: false });
|
|
97
|
+
* // later:
|
|
98
|
+
* off();
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export function toast(opts: ToastOptions): () => void {
|
|
102
|
+
const id = ++toastCount;
|
|
103
|
+
toasts[id] = { id, opts };
|
|
104
|
+
|
|
105
|
+
const duration = opts.duration ?? 4000;
|
|
106
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
107
|
+
if (duration > 0) {
|
|
108
|
+
timer = setTimeout(() => dismiss(id), duration);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return () => {
|
|
112
|
+
if (timer != null) clearTimeout(timer);
|
|
113
|
+
dismiss(id);
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import A from "aberdeen";
|
|
2
|
+
import { type Slot, type Attributes, drawSlot } from "../core.js";
|
|
3
|
+
|
|
4
|
+
/** Options for {@link addTooltip}. */
|
|
5
|
+
export interface TooltipOptions {
|
|
6
|
+
/** The tooltip text or draw function. A string is rendered as rich text. */
|
|
7
|
+
tip: Slot;
|
|
8
|
+
/**
|
|
9
|
+
* Which side the tooltip appears on. Defaults to `"top"`.
|
|
10
|
+
* Automatically flips to the opposite side when there isn't enough room.
|
|
11
|
+
*/
|
|
12
|
+
placement?: "top" | "bottom" | "left" | "right";
|
|
13
|
+
/** Aberdeen attr/style string applied to the tip panel. */
|
|
14
|
+
attrs?: Attributes;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
A.insertGlobalCss({
|
|
18
|
+
".s-tt-tip": {
|
|
19
|
+
"&":
|
|
20
|
+
"position:fixed z-index:500 " +
|
|
21
|
+
"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
|
+
"padding: 0.3em 0.65em; font-size:0.85em line-height:1.4 " +
|
|
25
|
+
"pointer-events:none",
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// ─── Global portal state ────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
// At most one tooltip is visible at a time. The anchor is the element the
|
|
32
|
+
// handlers were attached to (its bounding rect drives positioning).
|
|
33
|
+
const $ttActive = A.proxy<{ opts: TooltipOptions; anchor: HTMLElement } | undefined>(undefined);
|
|
34
|
+
let hideTimer: ReturnType<typeof setTimeout> | null = null;
|
|
35
|
+
|
|
36
|
+
// Hide tooltip when the page scrolls (anchor has moved).
|
|
37
|
+
if (typeof window !== "undefined") {
|
|
38
|
+
window.addEventListener("scroll", () => { $ttActive.value = undefined; }, { capture: true, passive: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function computePos(rect: DOMRect, tipW: number, tipH: number, placement: string): { x: number; y: number } {
|
|
42
|
+
const gap = 7;
|
|
43
|
+
const vw = window.innerWidth;
|
|
44
|
+
const vh = window.innerHeight;
|
|
45
|
+
let x = 0, y = 0;
|
|
46
|
+
|
|
47
|
+
if (placement === "bottom") {
|
|
48
|
+
x = rect.left + (rect.width - tipW) / 2;
|
|
49
|
+
y = rect.bottom + gap;
|
|
50
|
+
if (y + tipH > vh - 8) { y = rect.top - tipH - gap; }
|
|
51
|
+
} else if (placement === "left") {
|
|
52
|
+
x = rect.left - tipW - gap;
|
|
53
|
+
y = rect.top + (rect.height - tipH) / 2;
|
|
54
|
+
if (x < 8) { x = rect.right + gap; }
|
|
55
|
+
} else if (placement === "right") {
|
|
56
|
+
x = rect.right + gap;
|
|
57
|
+
y = rect.top + (rect.height - tipH) / 2;
|
|
58
|
+
if (x + tipW > vw - 8) { x = rect.left - tipW - gap; }
|
|
59
|
+
} else {
|
|
60
|
+
// top (default)
|
|
61
|
+
x = rect.left + (rect.width - tipW) / 2;
|
|
62
|
+
y = rect.top - tipH - gap;
|
|
63
|
+
if (y < 8) { y = rect.bottom + gap; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
x: Math.max(8, Math.min(x, vw - tipW - 8)),
|
|
68
|
+
y: Math.max(8, Math.min(y, vh - tipH - 8)),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function scheduleHide(): void {
|
|
73
|
+
if (hideTimer) clearTimeout(hideTimer);
|
|
74
|
+
hideTimer = setTimeout(() => {
|
|
75
|
+
$ttActive.value = undefined;
|
|
76
|
+
hideTimer = null;
|
|
77
|
+
}, 100);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Portal ──────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
A.mount(document.body, () => {
|
|
83
|
+
const active = $ttActive.value;
|
|
84
|
+
if (!active) return;
|
|
85
|
+
const { opts, anchor } = active;
|
|
86
|
+
const placement = opts.placement ?? "top";
|
|
87
|
+
|
|
88
|
+
const tipEl = A("div.s-tt-tip role=tooltip visibility:hidden", opts.attrs, () => {
|
|
89
|
+
A("mouseenter=", () => {
|
|
90
|
+
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
|
|
91
|
+
});
|
|
92
|
+
A("mouseleave=", scheduleHide);
|
|
93
|
+
drawSlot(opts.tip);
|
|
94
|
+
}) as HTMLElement;
|
|
95
|
+
|
|
96
|
+
requestAnimationFrame(() => {
|
|
97
|
+
if (!document.body.contains(tipEl)) return;
|
|
98
|
+
const { x, y } = computePos(anchor.getBoundingClientRect(), tipEl.offsetWidth, tipEl.offsetHeight, placement);
|
|
99
|
+
tipEl.style.left = x + "px";
|
|
100
|
+
tipEl.style.top = y + "px";
|
|
101
|
+
tipEl.style.visibility = "";
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ─── Public component ────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Attaches a tooltip to the current element: adds hover/focus handlers via
|
|
109
|
+
* {@link A} so the tip appears when the element is hovered or keyboard-focused.
|
|
110
|
+
* The tip panel is rendered into `document.body` via a portal, so it is never
|
|
111
|
+
* clipped by `overflow:hidden` ancestors. Position is computed from the
|
|
112
|
+
* element's bounding rect and automatically flips when near the viewport edge.
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* ```ts
|
|
116
|
+
* A("button #Save", () => {
|
|
117
|
+
* S.addTooltip({ tip: "Saves your work to the cloud" });
|
|
118
|
+
* });
|
|
119
|
+
*
|
|
120
|
+
* A("button #Delete", () => {
|
|
121
|
+
* S.addTooltip({ tip: "Dangerous — cannot be undone", placement: "bottom" });
|
|
122
|
+
* });
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export function addTooltip(opts: TooltipOptions): void {
|
|
126
|
+
const show = (e: Event) => {
|
|
127
|
+
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
|
|
128
|
+
$ttActive.value = { opts, anchor: e.currentTarget as HTMLElement };
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
A("mouseenter=", show);
|
|
132
|
+
A("mouseleave=", scheduleHide);
|
|
133
|
+
A("focusin=", show);
|
|
134
|
+
A("focusout=", scheduleHide);
|
|
135
|
+
|
|
136
|
+
A.clean(() => {
|
|
137
|
+
if ($ttActive.value?.opts === opts) $ttActive.value = undefined;
|
|
138
|
+
});
|
|
139
|
+
}
|
package/src/core.ts
CHANGED
|
@@ -13,7 +13,7 @@ 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` `.danger` `.success` and `.warning`
|
|
16
|
+
* - for colors: `.panel` `.raised` `.neutral` `.primary` `.secondary` `.gradient` `.danger` `.success` and `.warning`
|
|
17
17
|
* - for variant: `.filled` `.tonal` and `.outlined`
|
|
18
18
|
*
|
|
19
19
|
* These strings are passed straight through to {@link A} as positional
|