staffa 0.18.2 → 0.18.4

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.
@@ -1,6 +1,6 @@
1
1
  import A from "aberdeen";
2
2
  import { matchCurrent, current as currentRoute, go } from "aberdeen/route";
3
- import { type Slot, type Attributes, drawSlot, mountPortal, focusFirst } from "../core.js";
3
+ import { type Slot, type Attributes, 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, type ButtonOptions } from "./button.js";
6
6
  import { toast } from "./toast.js";
@@ -647,7 +647,7 @@ export function closeFloatingMenu(anchor?: HTMLElement): void {
647
647
  if (isFloatingMenuOpen(anchor)) closeFloating();
648
648
  }
649
649
 
650
- function positionMenu(menuEl: HTMLElement, rect: { left: number; right: number; top: number; bottom: number }): void {
650
+ function positionMenu(menuEl: HTMLElement, rect: DOMRect): void {
651
651
  const mw = menuEl.offsetWidth, mh = menuEl.offsetHeight;
652
652
  const vw = window.innerWidth, vh = window.innerHeight;
653
653
  const gap = 4;
@@ -717,16 +717,13 @@ mountPortal(() => {
717
717
  document.removeEventListener("keydown", onKey, true);
718
718
  });
719
719
 
720
- // Position after layout, then focus the first enabled item.
720
+ // At the supplied point when given — the pointer location for a context menu
721
+ // — otherwise below the anchor.
722
+ followAnchor(f.at ? new DOMRect(f.at.x, f.at.y, 0, 0) : f.anchor, (rect) => positionMenu(menuEl, rect));
723
+ // Once it can take focus: the current-page item if there is one, else the
724
+ // first focusable element (covers custom slot content, not just `.s-menu-item`s).
721
725
  requestAnimationFrame(() => {
722
- if (!document.body.contains(menuEl)) return;
723
- // Position at the supplied point (a zero-size rect) when given — e.g. the
724
- // pointer location for a context menu — otherwise below the anchor.
725
- const rect = f.at ? { left: f.at.x, right: f.at.x, top: f.at.y, bottom: f.at.y } : f.anchor.getBoundingClientRect();
726
- positionMenu(menuEl, rect);
727
- // Focus the current-page item if there is one, else the first focusable
728
- // element (covers custom slot content, not just `.s-menu-item`s).
729
- focusFirst(menuEl, ".s-menu-item[aria-current=page]");
726
+ if (document.body.contains(menuEl)) focusFirst(menuEl, ".s-menu-item[aria-current=page]");
730
727
  });
731
728
  });
732
729
 
@@ -1,5 +1,5 @@
1
1
  import A from "aberdeen";
2
- import { type Slot, type Attributes, drawSlot, mountPortal } from "../core.js";
2
+ import { type Slot, type Attributes, drawSlot, followAnchor, mountPortal } from "../core.js";
3
3
 
4
4
  /** Options for {@link addTooltip}. */
5
5
  export interface TooltipOptions {
@@ -32,11 +32,6 @@ A.insertGlobalCss({
32
32
  const $ttActive = A.proxy<{ opts: TooltipOptions; anchor: HTMLElement } | undefined>(undefined);
33
33
  let hideTimer: ReturnType<typeof setTimeout> | null = null;
34
34
 
35
- // Hide tooltip when the page scrolls (anchor has moved).
36
- if (typeof window !== "undefined") {
37
- window.addEventListener("scroll", () => { $ttActive.value = undefined; }, { capture: true, passive: true });
38
- }
39
-
40
35
  function computePos(rect: DOMRect, tipW: number, tipH: number, placement: string): { x: number; y: number } {
41
36
  const gap = 7;
42
37
  const vw = window.innerWidth;
@@ -84,7 +79,7 @@ mountPortal(() => {
84
79
  const { opts, anchor } = active;
85
80
  const placement = opts.placement ?? "top";
86
81
 
87
- const tipEl = A("div.s-tt-tip.s-s.neutral.shadow role=tooltip visibility:hidden", opts.attrs, () => {
82
+ const tipEl = A("div.s-tt-tip.s-s.neutral.shadow role=tooltip", opts.attrs, () => {
88
83
  A("mouseenter=", () => {
89
84
  if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
90
85
  });
@@ -92,13 +87,16 @@ mountPortal(() => {
92
87
  drawSlot(opts.tip);
93
88
  }) as HTMLElement;
94
89
 
95
- requestAnimationFrame(() => {
96
- if (!document.body.contains(tipEl)) return;
97
- const rect = anchor.getBoundingClientRect();
90
+ followAnchor(anchor, (rect) => {
91
+ // Out of the viewport, or on a panel that slid off and went inert: nothing
92
+ // left to explain, and no `mouseleave` is coming to say so.
93
+ if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth || anchor.closest("[inert]")) {
94
+ $ttActive.value = undefined;
95
+ return;
96
+ }
98
97
  const { x, y } = computePos(rect, tipEl.offsetWidth, tipEl.offsetHeight, placement);
99
98
  tipEl.style.left = x + "px";
100
99
  tipEl.style.top = y + "px";
101
- tipEl.style.visibility = "";
102
100
  });
103
101
  });
104
102
 
package/src/core.ts CHANGED
@@ -80,6 +80,22 @@ export function drawSlot<Args extends unknown[] = []>(slot: Slot<Args> | undefin
80
80
  /** Selector matching the natively focusable elements we care about. */
81
81
  const FOCUSABLE_SELECTOR = "a[href], button, input, select, textarea, [tabindex]";
82
82
 
83
+ /** Whether this element can actually take focus right now. */
84
+ const isFocusable = (el: Element): el is HTMLElement =>
85
+ el instanceof HTMLElement &&
86
+ !el.hasAttribute("disabled") &&
87
+ el.getAttribute("aria-disabled") !== "true" &&
88
+ el.tabIndex >= 0 &&
89
+ el.getClientRects().length > 0;
90
+
91
+ /**
92
+ * The focusable elements inside `container`, in tab order — disabled,
93
+ * `aria-disabled`, `tabindex=-1` and hidden ones left out.
94
+ */
95
+ export function focusables(container: HTMLElement): HTMLElement[] {
96
+ return [...container.querySelectorAll(FOCUSABLE_SELECTOR)].filter(isFocusable);
97
+ }
98
+
83
99
  /**
84
100
  * Move keyboard focus to the first focusable element inside `container`, skipping
85
101
  * disabled, `aria-disabled`, `tabindex=-1` and hidden ones. A `prefer` selector,
@@ -90,15 +106,9 @@ const FOCUSABLE_SELECTOR = "a[href], button, input, select, textarea, [tabindex]
90
106
  * the DOM and laid out — typically inside a `requestAnimationFrame`.
91
107
  */
92
108
  export function focusFirst(container: HTMLElement, prefer?: string): boolean {
93
- const ok = (el: Element): el is HTMLElement =>
94
- el instanceof HTMLElement &&
95
- !el.hasAttribute("disabled") &&
96
- el.getAttribute("aria-disabled") !== "true" &&
97
- el.tabIndex >= 0 &&
98
- el.getClientRects().length > 0;
99
109
  const target =
100
- (prefer ? [...container.querySelectorAll(prefer)].find(ok) : undefined) ??
101
- [...container.querySelectorAll(FOCUSABLE_SELECTOR)].find(ok);
110
+ (prefer ? [...container.querySelectorAll(prefer)].find(isFocusable) : undefined) ??
111
+ focusables(container)[0];
102
112
  target?.focus();
103
113
  return target != null;
104
114
  }
@@ -114,3 +124,36 @@ export function focusFirst(container: HTMLElement, prefer?: string): boolean {
114
124
  export function mountPortal(draw: () => void): void {
115
125
  queueMicrotask(() => A(draw));
116
126
  }
127
+
128
+ /** Something scrolled, the window resized, or an animation began: an anchor may be on the move. */
129
+ const WAKE_EVENTS = ["scroll", "resize", "transitionstart", "animationstart"];
130
+
131
+ /**
132
+ * Keep a `position:fixed` overlay glued to its anchor. `place` gets the anchor's
133
+ * viewport rect right away, and again whenever it changes: frame by frame while
134
+ * the anchor is on the move — riding a dialog's opening animation or a panel's
135
+ * slide, or scrolling — and not at all once it has been at rest for half a
136
+ * second, until one of the events that precede any movement wakes the loop. So
137
+ * an overlay left standing open doesn't keep the page awake. Stops with the
138
+ * current scope; a point (a `DOMRect` of no size) is followed like an element.
139
+ *
140
+ * Returns a function to call when the overlay's own size changed (its rows
141
+ * redrawn, say), so it gets placed again.
142
+ */
143
+ export function followAnchor(anchor: Element | DOMRect, place: (rect: DOMRect) => void): () => void {
144
+ let placedAt = "", raf = 0, still = 0;
145
+ const track = () => {
146
+ const r = anchor instanceof Element ? anchor.getBoundingClientRect() : anchor;
147
+ const at = `${r.left} ${r.top} ${r.bottom} ${r.width}`;
148
+ if (at !== placedAt) { placedAt = at; place(r); still = 0; }
149
+ raf = ++still > 30 ? 0 : requestAnimationFrame(track);
150
+ };
151
+ const wake = () => { still = 0; if (!raf) track(); };
152
+ for (const ev of WAKE_EVENTS) window.addEventListener(ev, wake, true);
153
+ A.clean(() => {
154
+ cancelAnimationFrame(raf);
155
+ for (const ev of WAKE_EVENTS) window.removeEventListener(ev, wake, true);
156
+ });
157
+ track();
158
+ return () => { placedAt = ""; wake(); };
159
+ }
package/src/theme.ts CHANGED
@@ -229,8 +229,14 @@ A.insertGlobalCss({
229
229
 
230
230
  // Accent variants: the fill colour becomes the ink, over a soft self-tint
231
231
  // (`tonal`) or a transparent body with a colour edge (`outlined`).
232
- ".s-s:not(.neutral).tonal, .s-s:not(.neutral).outlined":
233
- "--s-text:$s-bg --s-accent:$s-bg --s-link-fg:$s-bg --s-faint: color-mix(in srgb, $s-bg 30%, transparent); --s-muted: color-mix(in srgb, $s-bg 70%, transparent);",
232
+ ".s-s:not(.neutral).tonal, .s-s:not(.neutral).outlined": {
233
+ "&": "--s-text:$s-bg --s-accent:$s-bg --s-link-fg:$s-bg --s-faint: color-mix(in srgb, $s-bg 30%, transparent); --s-muted: color-mix(in srgb, $s-bg 70%, transparent);",
234
+ // The ink *is* the surface colour here, so the fill `code` and `pre` mix
235
+ // from their text/background pair would land invisibly on itself. The
236
+ // monospace face carries inline code on its own; a block keeps a hairline.
237
+ code: "background:transparent padding:0",
238
+ pre: "background:transparent border: 1px solid $s-faint;",
239
+ },
234
240
  ".s-s:not(.neutral).tonal":
235
241
  "background: color-mix(in srgb, $s-bg 15%, transparent); border: 1px solid $s-faint;",
236
242
  ".s-s:not(.neutral).outlined":