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
|
@@ -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
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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 (
|
|
200
|
-
|
|
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":
|