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 { 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 ──────────────────────────────────────────────────────────────
@@ -1,5 +1,5 @@
1
1
  import A from "aberdeen";
2
- import { drawSlot, mountPortal } from "../core.js";
2
+ import { drawSlot, followAnchor, mountPortal } from "../core.js";
3
3
  // Background, ink, border, radius and elevation come from the `.s-s.neutral.shadow`
4
4
  // surface; portalled to <body>, it renders at the page's next neutral shade.
5
5
  A.insertGlobalCss({
@@ -14,10 +14,6 @@ A.insertGlobalCss({
14
14
  // At most one tooltip at a time; the anchor is the element whose rect positions it.
15
15
  const $ttActive = A.proxy(undefined);
16
16
  let hideTimer = null;
17
- // Hide tooltip when the page scrolls (anchor has moved).
18
- if (typeof window !== "undefined") {
19
- window.addEventListener("scroll", () => { $ttActive.value = undefined; }, { capture: true, passive: true });
20
- }
21
17
  function computePos(rect, tipW, tipH, placement) {
22
18
  const gap = 7;
23
19
  const vw = window.innerWidth;
@@ -72,7 +68,7 @@ mountPortal(() => {
72
68
  return;
73
69
  const { opts, anchor } = active;
74
70
  const placement = opts.placement ?? "top";
75
- const tipEl = A("div.s-tt-tip.s-s.neutral.shadow role=tooltip visibility:hidden", opts.attrs, () => {
71
+ const tipEl = A("div.s-tt-tip.s-s.neutral.shadow role=tooltip", opts.attrs, () => {
76
72
  A("mouseenter=", () => {
77
73
  if (hideTimer) {
78
74
  clearTimeout(hideTimer);
@@ -82,14 +78,16 @@ mountPortal(() => {
82
78
  A("mouseleave=", scheduleHide);
83
79
  drawSlot(opts.tip);
84
80
  });
85
- requestAnimationFrame(() => {
86
- if (!document.body.contains(tipEl))
81
+ followAnchor(anchor, (rect) => {
82
+ // Out of the viewport, or on a panel that slid off and went inert: nothing
83
+ // left to explain, and no `mouseleave` is coming to say so.
84
+ if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth || anchor.closest("[inert]")) {
85
+ $ttActive.value = undefined;
87
86
  return;
88
- const rect = anchor.getBoundingClientRect();
87
+ }
89
88
  const { x, y } = computePos(rect, tipEl.offsetWidth, tipEl.offsetHeight, placement);
90
89
  tipEl.style.left = x + "px";
91
90
  tipEl.style.top = y + "px";
92
- tipEl.style.visibility = "";
93
91
  });
94
92
  });
95
93
  // ─── Public component ────────────────────────────────────────────────────────
package/dist/core.d.ts CHANGED
@@ -62,6 +62,11 @@ export declare function uniqueId(prefix?: string): string;
62
62
  * `rich` markup (`*italic*`, `**bold**`, `` `code` ``, `[link](/path)`).
63
63
  */
64
64
  export declare function drawSlot<Args extends unknown[] = []>(slot: Slot<Args> | undefined, ...args: Args): void;
65
+ /**
66
+ * The focusable elements inside `container`, in tab order — disabled,
67
+ * `aria-disabled`, `tabindex=-1` and hidden ones left out.
68
+ */
69
+ export declare function focusables(container: HTMLElement): HTMLElement[];
65
70
  /**
66
71
  * Move keyboard focus to the first focusable element inside `container`, skipping
67
72
  * disabled, `aria-disabled`, `tabindex=-1` and hidden ones. A `prefer` selector,
@@ -81,3 +86,16 @@ export declare function focusFirst(container: HTMLElement, prefer?: string): boo
81
86
  * `<body>` gets its content first and the overlays stay at the end.
82
87
  */
83
88
  export declare function mountPortal(draw: () => void): void;
89
+ /**
90
+ * Keep a `position:fixed` overlay glued to its anchor. `place` gets the anchor's
91
+ * viewport rect right away, and again whenever it changes: frame by frame while
92
+ * the anchor is on the move — riding a dialog's opening animation or a panel's
93
+ * slide, or scrolling — and not at all once it has been at rest for half a
94
+ * second, until one of the events that precede any movement wakes the loop. So
95
+ * an overlay left standing open doesn't keep the page awake. Stops with the
96
+ * current scope; a point (a `DOMRect` of no size) is followed like an element.
97
+ *
98
+ * Returns a function to call when the overlay's own size changed (its rows
99
+ * redrawn, say), so it gets placed again.
100
+ */
101
+ export declare function followAnchor(anchor: Element | DOMRect, place: (rect: DOMRect) => void): () => void;
package/dist/core.js CHANGED
@@ -26,6 +26,19 @@ export function drawSlot(slot, ...args) {
26
26
  }
27
27
  /** Selector matching the natively focusable elements we care about. */
28
28
  const FOCUSABLE_SELECTOR = "a[href], button, input, select, textarea, [tabindex]";
29
+ /** Whether this element can actually take focus right now. */
30
+ const isFocusable = (el) => el instanceof HTMLElement &&
31
+ !el.hasAttribute("disabled") &&
32
+ el.getAttribute("aria-disabled") !== "true" &&
33
+ el.tabIndex >= 0 &&
34
+ el.getClientRects().length > 0;
35
+ /**
36
+ * The focusable elements inside `container`, in tab order — disabled,
37
+ * `aria-disabled`, `tabindex=-1` and hidden ones left out.
38
+ */
39
+ export function focusables(container) {
40
+ return [...container.querySelectorAll(FOCUSABLE_SELECTOR)].filter(isFocusable);
41
+ }
29
42
  /**
30
43
  * Move keyboard focus to the first focusable element inside `container`, skipping
31
44
  * disabled, `aria-disabled`, `tabindex=-1` and hidden ones. A `prefer` selector,
@@ -36,13 +49,8 @@ const FOCUSABLE_SELECTOR = "a[href], button, input, select, textarea, [tabindex]
36
49
  * the DOM and laid out — typically inside a `requestAnimationFrame`.
37
50
  */
38
51
  export function focusFirst(container, prefer) {
39
- const ok = (el) => el instanceof HTMLElement &&
40
- !el.hasAttribute("disabled") &&
41
- el.getAttribute("aria-disabled") !== "true" &&
42
- el.tabIndex >= 0 &&
43
- el.getClientRects().length > 0;
44
- const target = (prefer ? [...container.querySelectorAll(prefer)].find(ok) : undefined) ??
45
- [...container.querySelectorAll(FOCUSABLE_SELECTOR)].find(ok);
52
+ const target = (prefer ? [...container.querySelectorAll(prefer)].find(isFocusable) : undefined) ??
53
+ focusables(container)[0];
46
54
  target?.focus();
47
55
  return target != null;
48
56
  }
@@ -57,3 +65,41 @@ export function focusFirst(container, prefer) {
57
65
  export function mountPortal(draw) {
58
66
  queueMicrotask(() => A(draw));
59
67
  }
68
+ /** Something scrolled, the window resized, or an animation began: an anchor may be on the move. */
69
+ const WAKE_EVENTS = ["scroll", "resize", "transitionstart", "animationstart"];
70
+ /**
71
+ * Keep a `position:fixed` overlay glued to its anchor. `place` gets the anchor's
72
+ * viewport rect right away, and again whenever it changes: frame by frame while
73
+ * the anchor is on the move — riding a dialog's opening animation or a panel's
74
+ * slide, or scrolling — and not at all once it has been at rest for half a
75
+ * second, until one of the events that precede any movement wakes the loop. So
76
+ * an overlay left standing open doesn't keep the page awake. Stops with the
77
+ * current scope; a point (a `DOMRect` of no size) is followed like an element.
78
+ *
79
+ * Returns a function to call when the overlay's own size changed (its rows
80
+ * redrawn, say), so it gets placed again.
81
+ */
82
+ export function followAnchor(anchor, place) {
83
+ let placedAt = "", raf = 0, still = 0;
84
+ const track = () => {
85
+ const r = anchor instanceof Element ? anchor.getBoundingClientRect() : anchor;
86
+ const at = `${r.left} ${r.top} ${r.bottom} ${r.width}`;
87
+ if (at !== placedAt) {
88
+ placedAt = at;
89
+ place(r);
90
+ still = 0;
91
+ }
92
+ raf = ++still > 30 ? 0 : requestAnimationFrame(track);
93
+ };
94
+ const wake = () => { still = 0; if (!raf)
95
+ track(); };
96
+ for (const ev of WAKE_EVENTS)
97
+ window.addEventListener(ev, wake, true);
98
+ A.clean(() => {
99
+ cancelAnimationFrame(raf);
100
+ for (const ev of WAKE_EVENTS)
101
+ window.removeEventListener(ev, wake, true);
102
+ });
103
+ track();
104
+ return () => { placedAt = ""; wake(); };
105
+ }