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.
- package/README.md +36 -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 +3 -2
- package/skill/Attributes.md +10 -0
- package/skill/AutocompleteOptions.md +37 -0
- package/skill/BoxOptions.md +33 -0
- package/skill/ButtonChooserOptions.md +37 -0
- package/skill/ButtonGroupOptions.md +23 -0
- package/skill/ButtonOptions.md +58 -0
- package/skill/CheckboxOptions.md +27 -0
- package/skill/ContentOptions.md +16 -0
- package/skill/DialogOptions.md +70 -0
- package/skill/FieldOptions.md +63 -0
- package/skill/FloatingMenuOptions.md +21 -0
- package/skill/FormOptions.md +31 -0
- package/skill/MainOptions.md +91 -0
- package/skill/MenuItem.md +52 -0
- package/skill/MenuOptions.md +25 -0
- package/skill/SKILL.md +609 -0
- package/skill/SelectOptions.md +21 -0
- package/skill/Slot.md +13 -0
- package/skill/Tab.md +33 -0
- package/skill/TabsOptions.md +28 -0
- package/skill/TextareaOptions.md +51 -0
- package/skill/TextlineOptions.md +45 -0
- package/skill/TextlineType.md +18 -0
- package/skill/ToastOptions.md +40 -0
- package/skill/TooltipOptions.md +22 -0
- package/skill/addContextMenu.md +28 -0
- package/skill/addTooltip.md +29 -0
- package/skill/alert.md +17 -0
- package/skill/autocomplete.md +29 -0
- package/skill/box.md +26 -0
- package/skill/button.md +31 -0
- package/skill/buttonChooser.md +23 -0
- package/skill/buttonGroup.md +22 -0
- package/skill/checkbox.md +18 -0
- package/skill/confirm.md +17 -0
- package/skill/dialog.md +28 -0
- package/skill/form.md +29 -0
- package/skill/getDarkMode.md +12 -0
- package/skill/main.md +37 -0
- package/skill/menuButton.md +27 -0
- package/skill/prompt.md +19 -0
- package/skill/select.md +18 -0
- package/skill/showFloatingMenu.md +21 -0
- package/skill/tabs.md +19 -0
- package/skill/textarea.md +17 -0
- package/skill/textline.md +20 -0
- package/skill/toast.md +19 -0
- 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/dist/components/main.js
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
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-
|
|
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-
|
|
28
|
-
// min-height:0
|
|
29
|
-
// shrink to fit the bounded container
|
|
30
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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 .
|
|
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-
|
|
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
|
|
165
|
+
* button: { content: "Actions", attrs: ".neutral" },
|
|
145
166
|
* items: [
|
|
146
167
|
* { label: "Edit", icon: () => A("#✎"), click: () => edit() },
|
|
147
168
|
* { separator: true },
|
package/dist/components/menu.js
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
".s-menu-item
|
|
23
|
-
|
|
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-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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-
|
|
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
|
/**
|
package/dist/components/tabs.js
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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",
|
package/dist/components/toast.js
CHANGED
|
@@ -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;
|
|
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-
|
|
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-
|
|
18
|
-
".s-toast-close:focus-visible": "outline:none box-shadow: 0 0 0 3px $s-focus; fg:$s-
|
|
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
|
|
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
|
-
|
|
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
|
-
* -
|
|
14
|
-
* -
|
|
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
|