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.
@@ -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
- // Background, border, radius and elevation come from the popup's
17
- // `.s-s.neutral.shadow` surface (see below).
18
- "> .s-menu": "position:absolute top:100% left:0 right:0 z-index:20 margin-top:4px max-height:15rem overflow-y:auto list-style:none p:$1 margin-bottom:0",
19
- "> .s-menu li": "margin:0",
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
- let inputEl;
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
- // The suggestions popup.
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 list = filtered();
164
- const q = $st.query.trim();
165
- const showAdd = opts.allowCustom !== false && q !== "" && !list.some((o) => o.label.toLowerCase() === q.toLowerCase());
166
- A("ul.s-menu.s-s.neutral.shadow role=listbox", `id=${menuId}`, () => {
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(() => {
@@ -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
- "transition: transform var(--s-panel-ms) ease;",
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
- "transition: transform var(--s-panel-ms) ease, visibility 0s;",
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, so the dismissed page isn't reachable while it
102
- // waits for Aberdeen's removal timer; on entry it flips instantly (the `0s`
103
- // above), or the opening page would refuse the focus handed to it mid-slide.
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
- "transition: transform var(--s-panel-ms) ease, visibility var(--s-panel-ms);",
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
  },
@@ -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
- // Position after layout, then focus the first enabled item.
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 (!document.body.contains(menuEl))
557
- return;
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
- * where it stands, inert, and leaves the DOM on `transitionend`. A fixed timer
535
- * would race the transition and pull the element a frame early, making the
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