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.
@@ -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
@@ -114,3 +114,36 @@ export function focusFirst(container: HTMLElement, prefer?: string): boolean {
114
114
  export function mountPortal(draw: () => void): void {
115
115
  queueMicrotask(() => A(draw));
116
116
  }
117
+
118
+ /** Something scrolled, the window resized, or an animation began: an anchor may be on the move. */
119
+ const WAKE_EVENTS = ["scroll", "resize", "transitionstart", "animationstart"];
120
+
121
+ /**
122
+ * Keep a `position:fixed` overlay glued to its anchor. `place` gets the anchor's
123
+ * viewport rect right away, and again whenever it changes: frame by frame while
124
+ * the anchor is on the move — riding a dialog's opening animation or a panel's
125
+ * slide, or scrolling — and not at all once it has been at rest for half a
126
+ * second, until one of the events that precede any movement wakes the loop. So
127
+ * an overlay left standing open doesn't keep the page awake. Stops with the
128
+ * current scope; a point (a `DOMRect` of no size) is followed like an element.
129
+ *
130
+ * Returns a function to call when the overlay's own size changed (its rows
131
+ * redrawn, say), so it gets placed again.
132
+ */
133
+ export function followAnchor(anchor: Element | DOMRect, place: (rect: DOMRect) => void): () => void {
134
+ let placedAt = "", raf = 0, still = 0;
135
+ const track = () => {
136
+ const r = anchor instanceof Element ? anchor.getBoundingClientRect() : anchor;
137
+ const at = `${r.left} ${r.top} ${r.bottom} ${r.width}`;
138
+ if (at !== placedAt) { placedAt = at; place(r); still = 0; }
139
+ raf = ++still > 30 ? 0 : requestAnimationFrame(track);
140
+ };
141
+ const wake = () => { still = 0; if (!raf) track(); };
142
+ for (const ev of WAKE_EVENTS) window.addEventListener(ev, wake, true);
143
+ A.clean(() => {
144
+ cancelAnimationFrame(raf);
145
+ for (const ev of WAKE_EVENTS) window.removeEventListener(ev, wake, true);
146
+ });
147
+ track();
148
+ return () => { placedAt = ""; wake(); };
149
+ }
package/src/theme.ts CHANGED
@@ -32,9 +32,11 @@ import A from "aberdeen";
32
32
  * declaration, at the given angle. A shared constant rather than a `--s-sheen`
33
33
  * custom property: `var()`s inside a custom property resolve where it is
34
34
  * *defined*, so every surface would get the page's wash instead of its own.
35
+ * The shorthand names `$s-bg` as well as the gradient: a `background:` that
36
+ * gave only an image would reset the colour under it to transparent.
35
37
  */
36
38
  const sheen = (angle: string) =>
37
- `background: linear-gradient(${angle}, color-mix(in oklab, $s-bg, white 9%), color-mix(in oklab, $s-bg, black 9%));`;
39
+ `background: $s-bg linear-gradient(${angle}, color-mix(in oklab, $s-bg, white 9%), color-mix(in oklab, $s-bg, black 9%));`;
38
40
 
39
41
  /** The surface sheen: the wash every surface (and the page) is painted with. */
40
42
  export const SURFACE_SHEEN = sheen("170deg");
@@ -120,7 +122,14 @@ A.insertGlobalCss({
120
122
  // A lightweight reset: bare semantic HTML, with less ugly defaults.
121
123
  "*, *::before, *::after": "box-sizing:border-box",
122
124
  html: "text-size-adjust:100%",
123
- body: "m:0 p:$3 line-height:1.5 font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-font-smoothing:antialiased background-color:$s-bg text:$s-text",
125
+ // The page wears the same wash as a surface, on a page at least as tall as
126
+ // what that wash is painted on: <body>'s background is propagated to the
127
+ // canvas, which covers the whole viewport, while the gradient is still
128
+ // *sized* from the root box. So a page shorter than the viewport — a bare
129
+ // spinner while an app boots — would tile its wash down the rest of the
130
+ // canvas in bands. `dvh`, not `vh`: a mobile viewport that grows as the URL
131
+ // bar retracts would open a band again.
132
+ body: "m:0 p:$3 min-height:100dvh line-height:1.5 font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-font-smoothing:antialiased text:$s-text " + SURFACE_SHEEN,
124
133
  // The contextual link colour: `--s-link` on neutral surfaces, the ink on accent ones.
125
134
  a: "color: $s-link-fg; text-decoration:underline text-underline-offset:2px; transition: color 0.12s, filter 0.12s;",
126
135
  "a:hover": "filter: brightness(1.15)",
@@ -196,9 +205,9 @@ A.insertGlobalCss({
196
205
  "--s-muted: color-mix(in oklab, $s-text, $s-bg 42%); " +
197
206
  "--s-faint: color-mix(in oklab, $s-text, $s-bg 80%); " +
198
207
  "color:$s-text accent-color:$s-accent scrollbar-width:thin scrollbar-color: $s-faint transparent;",
199
- // Subtle single-colour gradient sheen, painted on every surface (and the page).
200
- ".s-s, body": SURFACE_SHEEN,
201
- ".s-s": "r:$s-radius",
208
+ // Subtle single-colour gradient sheen, painted on every surface (the page
209
+ // gets its own, up in the reset).
210
+ ".s-s": SURFACE_SHEEN + " r:$s-radius",
202
211
  // A neutral surface owns a hairline border, so a card reads as a card without
203
212
  // any component help. `:where()` keeps it zero-specificity, so a bar that wants
204
213
  // only a divider overrides it with a single plain rule.
@@ -220,8 +229,14 @@ A.insertGlobalCss({
220
229
 
221
230
  // Accent variants: the fill colour becomes the ink, over a soft self-tint
222
231
  // (`tonal`) or a transparent body with a colour edge (`outlined`).
223
- ".s-s:not(.neutral).tonal, .s-s:not(.neutral).outlined":
224
- "--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
+ },
225
240
  ".s-s:not(.neutral).tonal":
226
241
  "background: color-mix(in srgb, $s-bg 15%, transparent); border: 1px solid $s-faint;",
227
242
  ".s-s:not(.neutral).outlined":