staffa 0.18.1 → 0.18.3
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/dist/components/autocomplete.d.ts +4 -0
- package/dist/components/autocomplete.js +83 -35
- package/dist/components/main.js +9 -7
- package/dist/components/menu.js +8 -11
- package/dist/components/panels.d.ts +22 -5
- package/dist/components/panels.js +169 -66
- package/dist/components/tooltip.js +8 -10
- package/dist/core.d.ts +13 -0
- package/dist/core.js +38 -0
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.js +22 -6
- package/package.json +1 -1
- package/skill/autocomplete.md +4 -0
- package/src/components/autocomplete.ts +102 -38
- package/src/components/main.ts +9 -7
- package/src/components/menu.ts +8 -11
- package/src/components/panels.ts +171 -66
- package/src/components/tooltip.ts +9 -11
- package/src/core.ts +33 -0
- package/src/theme.ts +22 -7
|
@@ -31,6 +31,10 @@ export interface AutocompleteOptions extends FieldOptions {
|
|
|
31
31
|
* optional free-text entry, and full keyboard control (arrows, enter, escape,
|
|
32
32
|
* backspace-to-remove). Implements the ARIA combobox/listbox pattern.
|
|
33
33
|
*
|
|
34
|
+
* The suggestion list is portalled to `document.body`, so a dialog or a
|
|
35
|
+
* scrolling column can neither clip it nor grow a scrollbar around it. It hangs
|
|
36
|
+
* off whichever side of the field has the room, and follows it as things move.
|
|
37
|
+
*
|
|
34
38
|
* @example
|
|
35
39
|
* ```ts
|
|
36
40
|
* // Single select from a fixed list
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
|
-
import { uniqueId } from "../core.js";
|
|
2
|
+
import { followAnchor, mountPortal, uniqueId } from "../core.js";
|
|
3
3
|
import { drawField } from "./field.js";
|
|
4
4
|
A.insertGlobalCss({
|
|
5
5
|
".s-ac": {
|
|
6
|
-
"&": "position:relative",
|
|
7
6
|
// Same light inset field as `.s-input` (see field.ts), derived from the surface.
|
|
8
7
|
"> .s-control": "display:flex flex-wrap:wrap align-items:center gap:$1 background: color-mix(in oklab, $s-bg, $s-text 4%); color:$s-text border: 1px solid $s-faint; r:$s-radius padding: 0.3em 0.4em; cursor:text; transition: border-color 0.15s, box-shadow 0.15s;",
|
|
9
8
|
"> .s-control:hover": "border-color: color-mix(in oklab, $s-text, $s-bg 55%);",
|
|
@@ -13,16 +12,56 @@ A.insertGlobalCss({
|
|
|
13
12
|
".s-chip > button": "cursor:pointer border:0 background:transparent fg:$s-muted font-size:1.1em line-height:1 padding: 0 0.2em; r:4px",
|
|
14
13
|
".s-chip > button:hover": "fg:$s-text background:$s-faint",
|
|
15
14
|
"input": "flex:1 min-width:6ch border:0 background:transparent color:inherit outline:none padding:0.25em",
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
},
|
|
16
|
+
// Background, border, radius and elevation come from the `.s-s.neutral.shadow`
|
|
17
|
+
// surface it carries; `place()` below sizes and positions it. Both of its
|
|
18
|
+
// classes are named here: standing in `<body>` rather than inside the field,
|
|
19
|
+
// it would otherwise lose to theme.ts's flow margins on `ul` and `li`.
|
|
20
|
+
".s-ac-menu.s-s": {
|
|
21
|
+
"&": "position:fixed max-height:15rem overflow-y:auto list-style:none p:$1 margin:0",
|
|
22
|
+
li: "margin:0",
|
|
20
23
|
".s-option": "padding: 0.45em 0.6em; r:6px cursor:pointer transition: background 0.1s;",
|
|
21
24
|
".s-option[aria-selected=true]": "background: color-mix(in srgb, $s-text 10%, transparent);",
|
|
22
25
|
".s-add": "fg:$s-accent font-style:italic",
|
|
23
26
|
".s-empty": "padding: 0.45em 0.6em; fg:$s-muted",
|
|
24
27
|
},
|
|
25
28
|
});
|
|
29
|
+
// Only one list is up at a time — it belongs to whichever field has focus — so
|
|
30
|
+
// one portal at the end of <body> serves them all. Drawn inside the field, the
|
|
31
|
+
// list would be clipped by a dialog or a scrolling column, and would stretch
|
|
32
|
+
// that scroller's bar to reach it.
|
|
33
|
+
const $popup = A.proxy({ cur: null });
|
|
34
|
+
/** Hang the list under the field — or over it, when that's where the room is. */
|
|
35
|
+
function place(el, r) {
|
|
36
|
+
const gap = 4, edge = 8;
|
|
37
|
+
// Measured at the stylesheet's own cap, so the flip is decided on the height
|
|
38
|
+
// the list wants, not on whatever the last placement clamped it to.
|
|
39
|
+
el.style.maxHeight = "";
|
|
40
|
+
const want = el.offsetHeight;
|
|
41
|
+
const below = window.innerHeight - r.bottom - gap - edge;
|
|
42
|
+
const above = r.top - gap - edge;
|
|
43
|
+
const up = want > below && above > below;
|
|
44
|
+
el.style.left = `${r.left}px`;
|
|
45
|
+
el.style.width = `${r.width}px`;
|
|
46
|
+
el.style.maxHeight = `${Math.min(want, Math.max(up ? above : below, 60))}px`;
|
|
47
|
+
el.style.top = up ? "auto" : `${r.bottom + gap}px`;
|
|
48
|
+
el.style.bottom = up ? `${window.innerHeight - r.top + gap}px` : "auto";
|
|
49
|
+
}
|
|
50
|
+
mountPortal(() => {
|
|
51
|
+
const p = $popup.cur;
|
|
52
|
+
if (!p)
|
|
53
|
+
return;
|
|
54
|
+
let sizeChanged;
|
|
55
|
+
const el = A("ul.s-ac-menu.s-s.neutral.shadow role=listbox", `id=${p.id} z-index:${p.zIndex}`, () => {
|
|
56
|
+
// A press in the list must not blur the field: the click that follows is
|
|
57
|
+
// what commits, and dragging the scrollbar has to keep it open too.
|
|
58
|
+
A("mousedown=", (e) => e.preventDefault());
|
|
59
|
+
p.draw();
|
|
60
|
+
// Re-run as you type, with the rows; the list's height changes with them.
|
|
61
|
+
sizeChanged?.();
|
|
62
|
+
});
|
|
63
|
+
sizeChanged = followAnchor(p.anchor, (r) => place(el, r));
|
|
64
|
+
});
|
|
26
65
|
function normOption(o) {
|
|
27
66
|
return typeof o === "string" ? { value: o, label: o } : { value: o.value, label: o.label ?? o.value };
|
|
28
67
|
}
|
|
@@ -31,6 +70,10 @@ function normOption(o) {
|
|
|
31
70
|
* optional free-text entry, and full keyboard control (arrows, enter, escape,
|
|
32
71
|
* backspace-to-remove). Implements the ARIA combobox/listbox pattern.
|
|
33
72
|
*
|
|
73
|
+
* The suggestion list is portalled to `document.body`, so a dialog or a
|
|
74
|
+
* scrolling column can neither clip it nor grow a scrollbar around it. It hangs
|
|
75
|
+
* off whichever side of the field has the room, and follows it as things move.
|
|
76
|
+
*
|
|
34
77
|
* @example
|
|
35
78
|
* ```ts
|
|
36
79
|
* // Single select from a fixed list
|
|
@@ -103,11 +146,36 @@ export function autocomplete(opts) {
|
|
|
103
146
|
const arr = opts.bind.value ?? [];
|
|
104
147
|
opts.bind.value = arr.filter((v) => v !== value);
|
|
105
148
|
};
|
|
149
|
+
let inputEl;
|
|
150
|
+
/** The list's rows. Runs in the body portal, on this field's state. */
|
|
151
|
+
const drawList = () => {
|
|
152
|
+
const list = filtered();
|
|
153
|
+
const q = $st.query.trim();
|
|
154
|
+
const showAdd = opts.allowCustom !== false && q !== "" && !list.some((o) => o.label.toLowerCase() === q.toLowerCase());
|
|
155
|
+
list.forEach((option, i) => {
|
|
156
|
+
A("li.s-option role=option", `id=${menuId}-opt-${i}`, () => {
|
|
157
|
+
A(() => A("aria-selected=", $st.active === i ? "true" : "false"));
|
|
158
|
+
A("#", option.label);
|
|
159
|
+
A("click=", () => commit(option.value, inputEl));
|
|
160
|
+
A("mousemove=", () => {
|
|
161
|
+
$st.active = i;
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
if (showAdd) {
|
|
166
|
+
A("li.s-option.s-add role=option", () => {
|
|
167
|
+
A("#", `Add "${q}"`);
|
|
168
|
+
A("click=", () => commit(q, inputEl));
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (list.length === 0 && !showAdd) {
|
|
172
|
+
A("li.s-empty #No matches");
|
|
173
|
+
}
|
|
174
|
+
};
|
|
106
175
|
drawField(opts, (id, isInvalid) => {
|
|
107
176
|
A("div.s-ac", opts.inputAttrs, () => {
|
|
108
177
|
A(() => A("aria-invalid=", isInvalid() ? "true" : "false"));
|
|
109
|
-
|
|
110
|
-
A("div.s-control", () => {
|
|
178
|
+
const controlEl = A("div.s-control", () => {
|
|
111
179
|
A("click=", () => inputEl?.focus());
|
|
112
180
|
// Chips for multi-select.
|
|
113
181
|
A(() => {
|
|
@@ -156,36 +224,16 @@ export function autocomplete(opts) {
|
|
|
156
224
|
A("keydown=", (e) => onKey(e, inputEl));
|
|
157
225
|
});
|
|
158
226
|
});
|
|
159
|
-
//
|
|
227
|
+
// Hand the list to the portal for as long as it is up. Its layer clears
|
|
228
|
+
// the dialog the field sits in, but stays under one that may open over
|
|
229
|
+
// it — a field on the page can't paint across a modal.
|
|
160
230
|
A(() => {
|
|
161
231
|
if (!$st.open)
|
|
162
232
|
return;
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
list.forEach((option, i) => {
|
|
168
|
-
A("li.s-option role=option", `id=${menuId}-opt-${i}`, () => {
|
|
169
|
-
A(() => A("aria-selected=", $st.active === i ? "true" : "false"));
|
|
170
|
-
A("#", option.label);
|
|
171
|
-
A("mousedown=", (e) => e.preventDefault());
|
|
172
|
-
A("click=", () => commit(option.value, inputEl));
|
|
173
|
-
A("mousemove=", () => {
|
|
174
|
-
$st.active = i;
|
|
175
|
-
});
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
if (showAdd) {
|
|
179
|
-
A("li.s-option.s-add role=option", () => {
|
|
180
|
-
A("#", `Add "${q}"`);
|
|
181
|
-
A("mousedown=", (e) => e.preventDefault());
|
|
182
|
-
A("click=", () => commit(q, inputEl));
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
if (list.length === 0 && !showAdd) {
|
|
186
|
-
A("li.s-empty #No matches");
|
|
187
|
-
}
|
|
188
|
-
});
|
|
233
|
+
const zIndex = controlEl.closest(".s-dialog") ? 350 : 150;
|
|
234
|
+
$popup.cur = { id: menuId, anchor: controlEl, zIndex, draw: drawList };
|
|
235
|
+
A.clean(() => { if ($popup.cur?.id === menuId)
|
|
236
|
+
$popup.cur = null; });
|
|
189
237
|
});
|
|
190
238
|
// Hidden inputs so the selection participates in native FormData.
|
|
191
239
|
A(() => {
|
package/dist/components/main.js
CHANGED
|
@@ -7,7 +7,7 @@ import { drawMenu, isFloatingMenuOpen, consumeBranchNav, anyCurrent, registerMen
|
|
|
7
7
|
import { menu as menuIcon, x as closeIcon } from "../icons.js";
|
|
8
8
|
import { iconButton } from "./button.js";
|
|
9
9
|
import { isDialogOpen } from "./dialog.js";
|
|
10
|
-
import { PanelStackController, SMALL_MAX_PX } from "./panels.js";
|
|
10
|
+
import { PAGE_MS, PanelStackController, SMALL_MAX_PX } from "./panels.js";
|
|
11
11
|
/** The default nav column, hairline included — see {@link MainOptions.navWidth}. */
|
|
12
12
|
const NAV_W = 200;
|
|
13
13
|
A.insertGlobalCss({
|
|
@@ -66,7 +66,7 @@ A.insertGlobalCss({
|
|
|
66
66
|
// — past the viewport edge. The transition is dormant except for the
|
|
67
67
|
// incoming half of the nav-panel hand-off (see `slideContentIn`).
|
|
68
68
|
".s-body main": "flex:1 min-width:0 min-height:0 overflow-x:hidden overflow-y:auto display:flex flex-direction:column " +
|
|
69
|
-
|
|
69
|
+
`transition: transform ${PAGE_MS}ms ease;`,
|
|
70
70
|
// A one-shot starting position: parked one screen to the right, with the
|
|
71
71
|
// transition off so it snaps there. Removing the class animates it home.
|
|
72
72
|
".s-body main.s-slide-in": "transform: translateX(100%); transition:none",
|
|
@@ -95,14 +95,16 @@ A.insertGlobalCss({
|
|
|
95
95
|
// Under the sticky header's 10: they never overlap, but the bar should win.
|
|
96
96
|
"position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
|
|
97
97
|
"overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
|
|
98
|
-
|
|
98
|
+
`transition: transform ${PAGE_MS}ms ease, visibility 0s;`,
|
|
99
99
|
// Parked one screen left: what the `create=`/`destroy=` hooks transition out
|
|
100
100
|
// of and back into. On dismissal (this rule's transition) `visibility` flips
|
|
101
|
-
// only at the slide's end
|
|
102
|
-
//
|
|
103
|
-
//
|
|
101
|
+
// only at the slide's end — a delayed zero-length transition, whose constant
|
|
102
|
+
// start value costs nothing per frame — so the dismissed page isn't
|
|
103
|
+
// reachable while it waits for Aberdeen's removal timer; on entry it flips
|
|
104
|
+
// instantly (the `0s` above), or the opening page would refuse the focus
|
|
105
|
+
// handed to it mid-slide.
|
|
104
106
|
"&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none visibility:hidden " +
|
|
105
|
-
|
|
107
|
+
`transition: transform ${PAGE_MS}ms ease, visibility 0s ${PAGE_MS}ms;`,
|
|
106
108
|
// Roomier than the dropdown's: every row here is a thumb target.
|
|
107
109
|
".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
|
|
108
110
|
},
|
package/dist/components/menu.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import A from "aberdeen";
|
|
2
2
|
import { matchCurrent, current as currentRoute, go } from "aberdeen/route";
|
|
3
|
-
import { drawSlot, mountPortal, focusFirst } from "../core.js";
|
|
3
|
+
import { drawSlot, followAnchor, mountPortal, focusFirst } from "../core.js";
|
|
4
4
|
import { menu as menuIcon, chevronRight, externalLink as newTabIcon, link as linkIcon } from "../icons.js";
|
|
5
5
|
import { button } from "./button.js";
|
|
6
6
|
import { toast } from "./toast.js";
|
|
@@ -551,17 +551,14 @@ mountPortal(() => {
|
|
|
551
551
|
document.removeEventListener("click", onClick, true);
|
|
552
552
|
document.removeEventListener("keydown", onKey, true);
|
|
553
553
|
});
|
|
554
|
-
//
|
|
554
|
+
// At the supplied point when given — the pointer location for a context menu
|
|
555
|
+
// — otherwise below the anchor.
|
|
556
|
+
followAnchor(f.at ? new DOMRect(f.at.x, f.at.y, 0, 0) : f.anchor, (rect) => positionMenu(menuEl, rect));
|
|
557
|
+
// Once it can take focus: the current-page item if there is one, else the
|
|
558
|
+
// first focusable element (covers custom slot content, not just `.s-menu-item`s).
|
|
555
559
|
requestAnimationFrame(() => {
|
|
556
|
-
if (
|
|
557
|
-
|
|
558
|
-
// Position at the supplied point (a zero-size rect) when given — e.g. the
|
|
559
|
-
// pointer location for a context menu — otherwise below the anchor.
|
|
560
|
-
const rect = f.at ? { left: f.at.x, right: f.at.x, top: f.at.y, bottom: f.at.y } : f.anchor.getBoundingClientRect();
|
|
561
|
-
positionMenu(menuEl, rect);
|
|
562
|
-
// Focus the current-page item if there is one, else the first focusable
|
|
563
|
-
// element (covers custom slot content, not just `.s-menu-item`s).
|
|
564
|
-
focusFirst(menuEl, ".s-menu-item[aria-current=page]");
|
|
560
|
+
if (document.body.contains(menuEl))
|
|
561
|
+
focusFirst(menuEl, ".s-menu-item[aria-current=page]");
|
|
565
562
|
});
|
|
566
563
|
});
|
|
567
564
|
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
@@ -287,6 +287,13 @@ export interface Panel<P = Record<string, string | number | string[]>> {
|
|
|
287
287
|
*/
|
|
288
288
|
open(href: string, how?: "push" | "replace" | "open"): Promise<boolean>;
|
|
289
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* The one duration every bit of shell motion shares: the enter/exit fades, the
|
|
292
|
+
* slides, the narrow-screen nav slide. Interpolated into every transition as a
|
|
293
|
+
* literal — the shared constant is what keeps CSS and JS in step — and also
|
|
294
|
+
* published as the `--s-panel-ms` custom property for app CSS.
|
|
295
|
+
*/
|
|
296
|
+
export declare const PAGE_MS = 250;
|
|
290
297
|
export declare const SMALL_MAX_PX = 540;
|
|
291
298
|
/** Options the stack needs from its shell. */
|
|
292
299
|
export interface PanelStackOptions {
|
|
@@ -453,6 +460,8 @@ export declare class PanelStackController implements PanelStack {
|
|
|
453
460
|
private lastGeom?;
|
|
454
461
|
private layoutQueued;
|
|
455
462
|
private timers;
|
|
463
|
+
/** Elements playing their exit fade, each riding its anchor's motion (see `layout`). */
|
|
464
|
+
private exiting;
|
|
456
465
|
/** The arrangement the navigation in flight is heading for; see {@link intended}. */
|
|
457
466
|
private intent;
|
|
458
467
|
/** The navigation the router hasn't settled yet, if any. */
|
|
@@ -530,13 +539,21 @@ export declare class PanelStackController implements PanelStack {
|
|
|
530
539
|
*/
|
|
531
540
|
private beginClose;
|
|
532
541
|
/**
|
|
533
|
-
* A closed panel's send-off, run by Aberdeen once its scope is gone: it fades
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
* panel appear to fade half-way and vanish; the timeout is only a fallback for
|
|
537
|
-
* when no `transitionend` is coming (transitions off, element never placed).
|
|
542
|
+
* A closed panel's send-off, run by Aberdeen once its scope is gone: it fades,
|
|
543
|
+
* inert, riding its anchor's slide (see `layout`), and leaves the DOM once
|
|
544
|
+
* the fade is over (see {@link afterFade}).
|
|
538
545
|
*/
|
|
539
546
|
private playExit;
|
|
547
|
+
/**
|
|
548
|
+
* Run `done` once `el`'s transition of `prop` is over. The real signal is
|
|
549
|
+
* `transitionend` — or `transitioncancel`, for one a resize snaps short —
|
|
550
|
+
* so the transition's actual length rules, however long: DevTools' slowed
|
|
551
|
+
* animations stretch it tenfold without touching any timer. The timer only
|
|
552
|
+
* stands in for a transition that never starts at all (transitions off,
|
|
553
|
+
* element never placed, nothing to travel), which is why one proving real
|
|
554
|
+
* (`transitionrun`) disarms it.
|
|
555
|
+
*/
|
|
556
|
+
private afterTransition;
|
|
540
557
|
/**
|
|
541
558
|
* The arrangement navigation works from: the one we're on the way to while a
|
|
542
559
|
* change is still settling, the one on screen otherwise. That window is common
|