@c9up/nebula 0.1.5 → 0.1.7
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/adapters/tailwind.d.ts +7 -0
- package/dist/adapters/tailwind.js +11 -1
- package/dist/adapters/unocss.js +11 -2
- package/dist/atoms/Image.d.ts +106 -0
- package/dist/atoms/Image.js +139 -0
- package/dist/atoms/NativeSelect.js +27 -2
- package/dist/atoms/index.d.ts +1 -0
- package/dist/atoms/index.js +1 -0
- package/dist/lib/image.d.ts +213 -0
- package/dist/lib/image.js +331 -0
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +1 -0
- package/dist/lib/motion.d.ts +25 -16
- package/dist/lib/motion.js +30 -24
- package/dist/molecules/Picture.d.ts +46 -0
- package/dist/molecules/Picture.js +62 -0
- package/dist/molecules/index.d.ts +1 -0
- package/dist/molecules/index.js +1 -0
- package/dist/organisms/Dialog.d.ts +11 -4
- package/dist/organisms/Dialog.js +2 -2
- package/dist/organisms/Sidebar.d.ts +9 -0
- package/dist/organisms/Sidebar.js +5 -2
- package/dist/primitives/floatingSurface.js +17 -8
- package/dist/primitives/presence.js +401 -5
- package/nebula.css +1 -1
- package/package.json +7 -6
- package/registry.json +25 -0
- package/src/adapters/tailwind.ts +11 -1
- package/src/adapters/unocss.ts +11 -2
- package/src/atoms/Image.ts +226 -0
- package/src/atoms/NativeSelect.ts +27 -2
- package/src/atoms/index.ts +10 -0
- package/src/lib/image.ts +470 -0
- package/src/lib/index.ts +25 -0
- package/src/lib/motion.ts +30 -26
- package/src/molecules/Picture.ts +92 -0
- package/src/molecules/index.ts +1 -0
- package/src/organisms/Dialog.ts +13 -6
- package/src/organisms/Sidebar.ts +15 -2
- package/src/primitives/floatingSurface.ts +18 -9
- package/src/primitives/presence.ts +428 -5
- package/theme.css +0 -35
package/src/organisms/Dialog.ts
CHANGED
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { component, html, signal } from "@c9up/aurora";
|
|
21
|
-
import type { Child } from "../lib/children.js";
|
|
22
21
|
import { type Slot, slot } from "../lib/children.js";
|
|
23
22
|
import { cn } from "../lib/cn.js";
|
|
24
23
|
import { XIcon } from "../lib/icons.js";
|
|
@@ -36,9 +35,17 @@ export const dialogPanelClasses =
|
|
|
36
35
|
export interface DialogProps {
|
|
37
36
|
/** Rendered inside the trigger button. Omit to drive `open` yourself. */
|
|
38
37
|
trigger?: Slot;
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Announced on open. Hide it visually with `srOnlyTitle`.
|
|
40
|
+
*
|
|
41
|
+
* A `Slot`, so it may be an accessor: one Dialog driven between "create" and
|
|
42
|
+
* "edit" needs a title that follows. It already behaved that way — the
|
|
43
|
+
* renderer binds whatever it is given — while the type said `Child` and
|
|
44
|
+
* refused the function, so the working call did not compile and the type
|
|
45
|
+
* disagreed with `children` beside it for no reason.
|
|
46
|
+
*/
|
|
47
|
+
title: Slot;
|
|
48
|
+
description?: Slot;
|
|
42
49
|
children?: Slot;
|
|
43
50
|
/** Actions, laid out bottom-right. */
|
|
44
51
|
footer?: Slot;
|
|
@@ -92,7 +99,7 @@ export const Dialog = component<DialogProps>((props) => {
|
|
|
92
99
|
"text-lg leading-none font-semibold",
|
|
93
100
|
props.srOnlyTitle === true ? "sr-only" : "",
|
|
94
101
|
)}"
|
|
95
|
-
>${props.title}</h2>
|
|
102
|
+
>${slot(props.title)}</h2>
|
|
96
103
|
${
|
|
97
104
|
props.description === undefined
|
|
98
105
|
? null
|
|
@@ -100,7 +107,7 @@ export const Dialog = component<DialogProps>((props) => {
|
|
|
100
107
|
id="${descriptionId}"
|
|
101
108
|
data-slot="dialog-description"
|
|
102
109
|
class="text-muted-foreground text-sm"
|
|
103
|
-
>${props.description}</p>`
|
|
110
|
+
>${slot(props.description)}</p>`
|
|
104
111
|
}
|
|
105
112
|
</div>
|
|
106
113
|
${slot(props.children)}
|
package/src/organisms/Sidebar.ts
CHANGED
|
@@ -230,6 +230,15 @@ export interface SidebarMenuItemProps {
|
|
|
230
230
|
tooltip?: string;
|
|
231
231
|
/** A trailing control — the count of unread items, a status dot. */
|
|
232
232
|
badge?: Child;
|
|
233
|
+
/**
|
|
234
|
+
* Extra classes, merged over the defaults.
|
|
235
|
+
*
|
|
236
|
+
* Every other component in this library takes one; this one did not, and a
|
|
237
|
+
* caller who wanted something as ordinary as dimming a disabled entry had
|
|
238
|
+
* to work around it. `cn` is tailwind-merge, so an override wins over the
|
|
239
|
+
* default it collides with rather than fighting it on specificity.
|
|
240
|
+
*/
|
|
241
|
+
class?: Reactive<string>;
|
|
233
242
|
onClick?: () => void;
|
|
234
243
|
}
|
|
235
244
|
|
|
@@ -261,19 +270,23 @@ export const SidebarMenuItem = component<SidebarMenuItemProps>((props) => {
|
|
|
261
270
|
class="text-sidebar-foreground/70 ml-auto flex h-5 min-w-5 shrink-0 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums"
|
|
262
271
|
>${props.badge}</span>`}`;
|
|
263
272
|
|
|
273
|
+
// Reactive, because `props.class` may be a signal — a caller dimming an
|
|
274
|
+
// entry as its state changes should not have to remount it.
|
|
275
|
+
const merged = () => cn(classes, read(props.class));
|
|
276
|
+
|
|
264
277
|
const entry =
|
|
265
278
|
props.href !== undefined
|
|
266
279
|
? html`<a
|
|
267
280
|
data-slot="sidebar-menu-item"
|
|
268
281
|
href="${props.href}"
|
|
269
282
|
aria-current="${() => (read(props.active) === true ? "page" : undefined)}"
|
|
270
|
-
class="${
|
|
283
|
+
class="${merged}"
|
|
271
284
|
>${body}</a>`
|
|
272
285
|
: html`<button
|
|
273
286
|
type="button"
|
|
274
287
|
data-slot="sidebar-menu-item"
|
|
275
288
|
aria-current="${() => (read(props.active) === true ? "page" : undefined)}"
|
|
276
|
-
class="${
|
|
289
|
+
class="${merged}"
|
|
277
290
|
@click="${props.onClick}"
|
|
278
291
|
>${body}</button>`;
|
|
279
292
|
|
|
@@ -92,18 +92,27 @@ export function floatingSurface(options: FloatingSurfaceOptions): void {
|
|
|
92
92
|
let live: Live | null = null;
|
|
93
93
|
|
|
94
94
|
function show(): void {
|
|
95
|
-
if (live !== null) {
|
|
96
|
-
// Already open and mid-exit: cancel the teardown and reuse the node
|
|
97
|
-
// rather than stacking a second copy on top of the one fading out.
|
|
98
|
-
live.cancelExit?.();
|
|
99
|
-
live.cancelExit = null;
|
|
100
|
-
live.element.setAttribute("data-state", "open");
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
95
|
const anchor = options.anchor();
|
|
105
96
|
if (anchor === null) return;
|
|
106
97
|
|
|
98
|
+
if (live !== null) {
|
|
99
|
+
if (live.anchor === anchor) {
|
|
100
|
+
// The SAME anchor, already open and possibly mid-exit: cancel
|
|
101
|
+
// the teardown and reuse the node rather than stacking a second
|
|
102
|
+
// copy on top of the one fading out.
|
|
103
|
+
live.cancelExit?.();
|
|
104
|
+
live.cancelExit = null;
|
|
105
|
+
live.element.setAttribute("data-state", "open");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// A DIFFERENT anchor. Content is built once per open, so reusing
|
|
109
|
+
// this node would show the previous anchor's content — one surface
|
|
110
|
+
// shared by the rows of a table showed the first row's entries
|
|
111
|
+
// however many rows were clicked. Its position, dismissal listeners
|
|
112
|
+
// and focus trap all belong to the old anchor too.
|
|
113
|
+
teardown();
|
|
114
|
+
}
|
|
115
|
+
|
|
107
116
|
const mount = portal(options.content());
|
|
108
117
|
const element = mount.host.firstElementChild;
|
|
109
118
|
if (!(element instanceof HTMLElement)) {
|
|
@@ -42,8 +42,13 @@ export function presence(initiallyOpen = false): Presence {
|
|
|
42
42
|
|
|
43
43
|
let element: HTMLElement | null = null;
|
|
44
44
|
let pendingUnmount = false;
|
|
45
|
+
let deadline: ReturnType<typeof setTimeout> | undefined;
|
|
45
46
|
|
|
46
47
|
function finishClose(): void {
|
|
48
|
+
if (deadline !== undefined) {
|
|
49
|
+
clearTimeout(deadline);
|
|
50
|
+
deadline = undefined;
|
|
51
|
+
}
|
|
47
52
|
if (!pendingUnmount) return;
|
|
48
53
|
pendingUnmount = false;
|
|
49
54
|
mounted(false);
|
|
@@ -57,10 +62,39 @@ export function presence(initiallyOpen = false): Presence {
|
|
|
57
62
|
*/
|
|
58
63
|
function onAnimationEnd(event: AnimationEvent | TransitionEvent): void {
|
|
59
64
|
if (event.target !== element) return;
|
|
65
|
+
// The FIRST event used to end the close, so a surface running a fade and
|
|
66
|
+
// a slide together was unmounted when the shorter one finished — the
|
|
67
|
+
// other visibly cut off. Each declared name reports for itself, and the
|
|
68
|
+
// close waits until none is outstanding.
|
|
69
|
+
const name = reportedName(event);
|
|
70
|
+
if (typeof name === "string" && outstanding.size > 0) {
|
|
71
|
+
reportName(outstanding, name);
|
|
72
|
+
if (outstanding.size > 0) return;
|
|
73
|
+
}
|
|
60
74
|
finishClose();
|
|
61
75
|
}
|
|
62
76
|
|
|
77
|
+
/** Declared animations and transitions still waiting to report. */
|
|
78
|
+
let outstanding: Outstanding = new Map();
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Arm the wait for `el`'s exit, replacing whatever the last one armed.
|
|
82
|
+
*
|
|
83
|
+
* Overwriting `deadline` without clearing it left the older timer running:
|
|
84
|
+
* it fired mid-way through a LATER close and unmounted a surface that was
|
|
85
|
+
* still animating, cutting the exit off at the previous close's schedule.
|
|
86
|
+
*/
|
|
87
|
+
function armDeadline(el: HTMLElement): void {
|
|
88
|
+
if (deadline !== undefined) clearTimeout(deadline);
|
|
89
|
+
outstanding = declaredNames(el);
|
|
90
|
+
deadline = setTimeout(finishClose, declaredDuration(el) + SAFETY_MARGIN_MS);
|
|
91
|
+
}
|
|
92
|
+
|
|
63
93
|
function detach(): void {
|
|
94
|
+
if (deadline !== undefined) {
|
|
95
|
+
clearTimeout(deadline);
|
|
96
|
+
deadline = undefined;
|
|
97
|
+
}
|
|
64
98
|
if (element === null) return;
|
|
65
99
|
element.removeEventListener("animationend", onAnimationEnd);
|
|
66
100
|
element.removeEventListener("animationcancel", onAnimationEnd);
|
|
@@ -74,7 +108,16 @@ export function presence(initiallyOpen = false): Presence {
|
|
|
74
108
|
state,
|
|
75
109
|
|
|
76
110
|
open(): void {
|
|
111
|
+
// The close in progress is CANCELLED, not just un-pended. Clearing
|
|
112
|
+
// `pendingUnmount` alone left its deadline armed and its names
|
|
113
|
+
// outstanding, so the timer from a close the user had already undone
|
|
114
|
+
// went on to unmount the NEXT one part-way through.
|
|
77
115
|
pendingUnmount = false;
|
|
116
|
+
if (deadline !== undefined) {
|
|
117
|
+
clearTimeout(deadline);
|
|
118
|
+
deadline = undefined;
|
|
119
|
+
}
|
|
120
|
+
outstanding.clear();
|
|
78
121
|
mounted(true);
|
|
79
122
|
state("open");
|
|
80
123
|
},
|
|
@@ -91,13 +134,35 @@ export function presence(initiallyOpen = false): Presence {
|
|
|
91
134
|
}
|
|
92
135
|
|
|
93
136
|
pendingUnmount = true;
|
|
94
|
-
if (!isAnimating(element))
|
|
137
|
+
if (!isAnimating(element)) {
|
|
138
|
+
finishClose();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// The same deadline `onExitFinished` has, and for the same reason:
|
|
142
|
+
// `animationend` is not promised by anything. Without it a declared
|
|
143
|
+
// animation the browser never runs left this mounted for good —
|
|
144
|
+
// `onExitFinished` was bounded and the public presence API was not.
|
|
145
|
+
armDeadline(element);
|
|
95
146
|
},
|
|
96
147
|
|
|
97
148
|
attach(next: HTMLElement | null): void {
|
|
149
|
+
const wasClosing = pendingUnmount;
|
|
98
150
|
detach();
|
|
99
151
|
element = next;
|
|
100
|
-
if (element === null)
|
|
152
|
+
if (element === null) {
|
|
153
|
+
// Nothing left to wait for, and nothing to wait WITH: a close
|
|
154
|
+
// still pending would otherwise never complete.
|
|
155
|
+
if (wasClosing) finishClose();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (wasClosing) {
|
|
159
|
+
// `detach()` cleared the deadline. Handing over a new element
|
|
160
|
+
// mid-close without arming another left the surface mounted for
|
|
161
|
+
// good — the exact failure the deadline exists to prevent,
|
|
162
|
+
// reintroduced by the handover.
|
|
163
|
+
pendingUnmount = true;
|
|
164
|
+
armDeadline(element);
|
|
165
|
+
}
|
|
101
166
|
element.addEventListener("animationend", onAnimationEnd);
|
|
102
167
|
element.addEventListener("animationcancel", onAnimationEnd);
|
|
103
168
|
element.addEventListener("transitionend", onAnimationEnd);
|
|
@@ -130,20 +195,50 @@ export function onExitFinished(
|
|
|
130
195
|
return () => {};
|
|
131
196
|
}
|
|
132
197
|
|
|
198
|
+
// Which declared animations and transitions have yet to report. The FIRST
|
|
199
|
+
// event used to end the wait, so a surface running a fade and a slide
|
|
200
|
+
// together had its node removed when the shorter one finished and the other
|
|
201
|
+
// was visibly cut off. `presence()` already waited for all of them; the
|
|
202
|
+
// portalled surfaces — Dialog, Popover, Select, Tooltip — go through here
|
|
203
|
+
// instead, and did not.
|
|
204
|
+
const outstanding = declaredNames(element);
|
|
205
|
+
|
|
133
206
|
function finish(event: AnimationEvent | TransitionEvent): void {
|
|
134
207
|
// Bubbled events from children would cut the parent's exit short.
|
|
135
208
|
if (event.target !== element) return;
|
|
209
|
+
const name = reportedName(event);
|
|
210
|
+
if (typeof name === "string" && outstanding.size > 0) {
|
|
211
|
+
reportName(outstanding, name);
|
|
212
|
+
if (outstanding.size > 0) return;
|
|
213
|
+
}
|
|
136
214
|
cancel();
|
|
137
215
|
done();
|
|
138
216
|
}
|
|
139
217
|
|
|
140
218
|
function cancel(): void {
|
|
219
|
+
if (safety !== undefined) clearTimeout(safety);
|
|
141
220
|
element.removeEventListener("animationend", finish);
|
|
142
221
|
element.removeEventListener("animationcancel", finish);
|
|
143
222
|
element.removeEventListener("transitionend", finish);
|
|
144
223
|
element.removeEventListener("transitioncancel", finish);
|
|
145
224
|
}
|
|
146
225
|
|
|
226
|
+
// A deadline, because `animationName` is a DECLARATION, not a promise.
|
|
227
|
+
//
|
|
228
|
+
// The computed style reads back the declared name whether or not those
|
|
229
|
+
// keyframes exist anywhere — an application that has not imported the
|
|
230
|
+
// stylesheet, or that scopes it away, declares an animation the browser
|
|
231
|
+
// will never run. `animationend` then never fires, `done()` never runs, and
|
|
232
|
+
// the node stays in the document: every closed Dialog, Select, Popover and
|
|
233
|
+
// Tooltip piles up as an invisible layer swallowing the clicks underneath.
|
|
234
|
+
//
|
|
235
|
+
// That turns a missing stylesheet — cosmetic — into a page that stops
|
|
236
|
+
// responding, which reads as "the floating layer does not work".
|
|
237
|
+
const safety = setTimeout(() => {
|
|
238
|
+
cancel();
|
|
239
|
+
done();
|
|
240
|
+
}, declaredDuration(element) + SAFETY_MARGIN_MS);
|
|
241
|
+
|
|
147
242
|
element.addEventListener("animationend", finish);
|
|
148
243
|
element.addEventListener("animationcancel", finish);
|
|
149
244
|
element.addEventListener("transitionend", finish);
|
|
@@ -159,13 +254,341 @@ export function onExitFinished(
|
|
|
159
254
|
* mean there is nothing to wait for, and waiting anyway would strand the node
|
|
160
255
|
* in the DOM forever — the failure mode this check exists to prevent.
|
|
161
256
|
*/
|
|
257
|
+
/**
|
|
258
|
+
* Slack added to the declared duration before the deadline fires.
|
|
259
|
+
*
|
|
260
|
+
* Long enough that a real animation always wins the race — the listener is what
|
|
261
|
+
* should end the wait — and short enough that a stuck overlay clears within a
|
|
262
|
+
* frame or two of when it should have.
|
|
263
|
+
*/
|
|
264
|
+
const SAFETY_MARGIN_MS = 100;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* How long the element SAYS its exit lasts: the longest declared animation or
|
|
268
|
+
* transition, plus its delay. Capped, because a stylesheet is free to declare
|
|
269
|
+
* minutes and the deadline exists to bound the wait, not to honour it.
|
|
270
|
+
*/
|
|
271
|
+
function declaredDuration(element: HTMLElement): number {
|
|
272
|
+
if (typeof getComputedStyle !== "function") return 0;
|
|
273
|
+
const style = getComputedStyle(element);
|
|
274
|
+
let longest = 0;
|
|
275
|
+
const consider = (total: number): void => {
|
|
276
|
+
if (total > longest) longest = total;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// PER ENTRY. The longest duration and the longest delay used to be taken
|
|
280
|
+
// independently, so a short-but-late animation beside a long-but-immediate
|
|
281
|
+
// one produced a deadline neither of them needed — and the iteration count
|
|
282
|
+
// was not read at all, so 120ms played twice was cut off at 220.
|
|
283
|
+
const names = splitList(style.animationName);
|
|
284
|
+
const durations = splitList(style.animationDuration);
|
|
285
|
+
const delays = splitList(style.animationDelay);
|
|
286
|
+
const counts = splitList(style.animationIterationCount);
|
|
287
|
+
for (const [index, name] of names.entries()) {
|
|
288
|
+
if (name === "" || name === "none") continue;
|
|
289
|
+
consider(
|
|
290
|
+
parseTime(atIndex(durations, index)) *
|
|
291
|
+
parseIterations(atIndex(counts, index)) +
|
|
292
|
+
parseTime(atIndex(delays, index)),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// A property named twice in `transition-property` is ONE transition — the
|
|
297
|
+
// last entry is the one that runs (CSS Transitions Level 1) — so its
|
|
298
|
+
// duration and delay are read from that index.
|
|
299
|
+
const properties = splitList(style.transitionProperty);
|
|
300
|
+
const transitionDurations = splitList(style.transitionDuration);
|
|
301
|
+
const transitionDelays = splitList(style.transitionDelay);
|
|
302
|
+
const lastEntry = new Map<string, number>();
|
|
303
|
+
for (const [index, property] of properties.entries()) {
|
|
304
|
+
if (property === "" || property === "none") continue;
|
|
305
|
+
lastEntry.set(property, index);
|
|
306
|
+
}
|
|
307
|
+
for (const index of lastEntry.values()) {
|
|
308
|
+
consider(
|
|
309
|
+
parseTime(atIndex(transitionDurations, index)) +
|
|
310
|
+
parseTime(atIndex(transitionDelays, index)),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return Math.min(longest, 5000);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* A comma-separated CSS list, entry by entry.
|
|
319
|
+
*
|
|
320
|
+
* Tolerates an absent longhand: a computed style is not always the complete
|
|
321
|
+
* one — a test double carries what its test cares about, and not every engine
|
|
322
|
+
* exposes every longhand.
|
|
323
|
+
*/
|
|
324
|
+
function splitList(value: string | undefined): string[] {
|
|
325
|
+
if (typeof value !== "string") return [];
|
|
326
|
+
return value.split(",").map((part) => part.trim());
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The entry at `index`, the way CSS reads one: a list shorter than the
|
|
331
|
+
* animation list repeats to cover it.
|
|
332
|
+
*/
|
|
333
|
+
function atIndex(list: string[], index: number): string {
|
|
334
|
+
if (list.length === 0) return "";
|
|
335
|
+
return list[index % list.length] ?? "";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** One CSS time, in milliseconds. */
|
|
339
|
+
function parseTime(value: string): number {
|
|
340
|
+
const numeric = Number.parseFloat(value);
|
|
341
|
+
if (Number.isNaN(numeric)) return 0;
|
|
342
|
+
return value.endsWith("ms") ? numeric : numeric * 1000;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* How many times one animation plays.
|
|
347
|
+
*
|
|
348
|
+
* `infinite` counts as one: it never fires `animationend`, so the deadline is
|
|
349
|
+
* the only way out and there is nothing to be gained by waiting longer.
|
|
350
|
+
*/
|
|
351
|
+
function parseIterations(value: string): number {
|
|
352
|
+
const numeric = Number.parseFloat(value);
|
|
353
|
+
if (!Number.isFinite(numeric) || numeric <= 0) return 1;
|
|
354
|
+
return numeric;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Is a `@keyframes` of this name defined anywhere in the document?
|
|
359
|
+
*
|
|
360
|
+
* `animationName` is a DECLARATION. It reads back whatever the stylesheet said,
|
|
361
|
+
* whether or not the keyframes behind it exist — so an application that has not
|
|
362
|
+
* imported nebula's stylesheet declares animations the browser will never run,
|
|
363
|
+
* and the only honest answer comes from looking for the rule itself.
|
|
364
|
+
*
|
|
365
|
+
* Answers `true` when it cannot tell. A cross-origin stylesheet throws on
|
|
366
|
+
* `cssRules`, and refusing to animate because a font sheet was unreadable would
|
|
367
|
+
* be a worse trade than waiting: the deadline in `onExitFinished` already bounds
|
|
368
|
+
* the cost of being wrong here.
|
|
369
|
+
*/
|
|
370
|
+
function keyframesExist(doc: Document, name: string): boolean {
|
|
371
|
+
const cache = cacheFor(doc);
|
|
372
|
+
const cached = cache.get(name);
|
|
373
|
+
if (cached !== undefined) return cached;
|
|
374
|
+
|
|
375
|
+
let found = false;
|
|
376
|
+
let readable = false;
|
|
377
|
+
for (const sheet of Array.from(doc.styleSheets)) {
|
|
378
|
+
let rules: CSSRuleList;
|
|
379
|
+
try {
|
|
380
|
+
const own = sheet.cssRules;
|
|
381
|
+
if (own === null) continue;
|
|
382
|
+
rules = own;
|
|
383
|
+
} catch {
|
|
384
|
+
// Cross-origin: not ours to read, and not evidence of anything.
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
readable = true;
|
|
388
|
+
if (containsKeyframes(rules, name)) {
|
|
389
|
+
found = true;
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const answer = found || !readable;
|
|
394
|
+
// Cached only when it was FOUND. A negative is re-checked, because a
|
|
395
|
+
// stylesheet arriving later is exactly what turns it positive.
|
|
396
|
+
if (answer) cache.set(name, true);
|
|
397
|
+
return answer;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Walk a rule list, descending into group rules.
|
|
402
|
+
*
|
|
403
|
+
* `@keyframes` inside `@media`, `@supports` or `@layer` is a nested rule, not a
|
|
404
|
+
* top-level one — a flat scan of the sheet reported it missing and the caller
|
|
405
|
+
* concluded the stylesheet was absent.
|
|
406
|
+
*/
|
|
407
|
+
function containsKeyframes(rules: CSSRuleList, name: string): boolean {
|
|
408
|
+
for (const rule of Array.from(rules)) {
|
|
409
|
+
if (isKeyframesNamed(rule, name)) return true;
|
|
410
|
+
// `cssRules` on a group rule (media, supports, layer); absent on others.
|
|
411
|
+
const nested = Reflect.get(rule, "cssRules");
|
|
412
|
+
if (
|
|
413
|
+
nested !== null &&
|
|
414
|
+
typeof nested === "object" &&
|
|
415
|
+
typeof Reflect.get(nested, "length") === "number" &&
|
|
416
|
+
containsKeyframes(nested as CSSRuleList, name)
|
|
417
|
+
) {
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Are all the animations this element declares actually defined?
|
|
426
|
+
*
|
|
427
|
+
* `animation-name` is a LIST: `fade-out, slide-out` is two names, and looking
|
|
428
|
+
* the whole string up as one found nothing and reported both missing. Every
|
|
429
|
+
* name has to resolve, because waiting on any undefined one is what strands the
|
|
430
|
+
* node.
|
|
431
|
+
*/
|
|
432
|
+
function splitNames(animationName: string): string[] {
|
|
433
|
+
return animationName
|
|
434
|
+
.split(",")
|
|
435
|
+
.map((part) => part.trim())
|
|
436
|
+
.filter((part) => part !== "" && part !== "none");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Which of the declared animations have no `@keyframes` anywhere. */
|
|
440
|
+
function undefinedKeyframes(doc: Document, animationName: string): string[] {
|
|
441
|
+
return splitNames(animationName).filter((name) => !keyframesExist(doc, name));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Per-DOCUMENT memo, invalidated when the stylesheets change.
|
|
446
|
+
*
|
|
447
|
+
* A single global map was wrong three ways. An answer cached before the
|
|
448
|
+
* stylesheet finished loading stayed wrong for the life of the page; an iframe
|
|
449
|
+
* and its parent share neither styles nor documents but shared the cache; and
|
|
450
|
+
* nothing ever expired, so a sheet added later never took effect.
|
|
451
|
+
*
|
|
452
|
+
* Keyed on the document and on how many sheets it had when the answer was
|
|
453
|
+
* computed: a new stylesheet changes the count and the answers are recomputed.
|
|
454
|
+
* A `WeakMap` so a detached document does not keep its cache alive.
|
|
455
|
+
*/
|
|
456
|
+
const KEYFRAME_CACHE = new WeakMap<Document, Map<string, boolean>>();
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* The memo for this document.
|
|
460
|
+
*
|
|
461
|
+
* Only POSITIVE answers are kept. A "found" is durable — keyframes do not
|
|
462
|
+
* usually disappear — while a "missing" is exactly the answer a later
|
|
463
|
+
* stylesheet can change, and keying on the sheet COUNT missed every way that
|
|
464
|
+
* happens without one being added: `insertRule`, `replaceSync`, HMR, or editing
|
|
465
|
+
* an existing `<style>`. Re-walking on a negative costs a scan only when
|
|
466
|
+
* something is already wrong.
|
|
467
|
+
*/
|
|
468
|
+
function cacheFor(doc: Document): Map<string, boolean> {
|
|
469
|
+
const existing = KEYFRAME_CACHE.get(doc);
|
|
470
|
+
if (existing !== undefined) return existing;
|
|
471
|
+
const answers = new Map<string, boolean>();
|
|
472
|
+
KEYFRAME_CACHE.set(doc, answers);
|
|
473
|
+
return answers;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function isKeyframesNamed(rule: CSSRule, name: string): boolean {
|
|
477
|
+
// `instanceof CSSKeyframesRule` is unreliable across documents (an iframe
|
|
478
|
+
// has its own constructors), so the shape is checked instead.
|
|
479
|
+
const named = Reflect.get(rule, "name");
|
|
480
|
+
return typeof named === "string" && named === name;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Say once that the stylesheet is missing, and what to do about it.
|
|
485
|
+
*
|
|
486
|
+
* The symptom without this is not "my overlays do not animate" — which would
|
|
487
|
+
* point straight at a missing sheet — but "the floating layer behaves oddly",
|
|
488
|
+
* which points everywhere else. Warned rather than thrown: a missing stylesheet
|
|
489
|
+
* is a cosmetic dependency, and taking an application down over one is a worse
|
|
490
|
+
* trade than a line in the console.
|
|
491
|
+
*/
|
|
492
|
+
function warnMissingKeyframes(name: string): void {
|
|
493
|
+
if (WARNED.has(name)) return;
|
|
494
|
+
WARNED.add(name);
|
|
495
|
+
console.warn(
|
|
496
|
+
`[nebula] the animation '${name}' is declared but its @keyframes are defined nowhere, so this element closes without animating. Overlay animations come from tw-animate-css (UnoCSS: unocss-preset-animations) — check your stylesheet imports it, or generate one with \`nebula init\`.`,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const WARNED = new Set<string>();
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Every animation name and transition property the element declares.
|
|
504
|
+
*
|
|
505
|
+
* What the close waits on. `animationend` and `transitionend` each name what
|
|
506
|
+
* finished, so a surface running two of them at once is only done when both
|
|
507
|
+
* have reported — waiting for the first cut the longer one off mid-flight.
|
|
508
|
+
*/
|
|
509
|
+
/**
|
|
510
|
+
* What is still expected to report, COUNTED.
|
|
511
|
+
*
|
|
512
|
+
* `animation-name: a, a` with two different durations is two animations, and a
|
|
513
|
+
* plain set of names collapsed them into one: the first `animationend("a")`
|
|
514
|
+
* emptied it and the node went away while the longer one was still running.
|
|
515
|
+
*/
|
|
516
|
+
type Outstanding = Map<string, number>;
|
|
517
|
+
|
|
518
|
+
/** Mark one report against `name`, if it is one we are waiting for. */
|
|
519
|
+
function reportName(outstanding: Outstanding, name: string): void {
|
|
520
|
+
const left = outstanding.get(name);
|
|
521
|
+
if (left === undefined) return;
|
|
522
|
+
if (left <= 1) outstanding.delete(name);
|
|
523
|
+
else outstanding.set(name, left - 1);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Which animation or transition an end event is reporting for.
|
|
528
|
+
*
|
|
529
|
+
* `undefined` when the event carries neither — jsdom's plain `Event`, and any
|
|
530
|
+
* synthetic one — in which case the caller falls back to treating it as the end
|
|
531
|
+
* of the whole exit.
|
|
532
|
+
*/
|
|
533
|
+
function reportedName(
|
|
534
|
+
event: AnimationEvent | TransitionEvent,
|
|
535
|
+
): string | undefined {
|
|
536
|
+
if ("animationName" in event) return event.animationName;
|
|
537
|
+
if ("propertyName" in event) return event.propertyName;
|
|
538
|
+
return undefined;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function declaredNames(element: HTMLElement): Outstanding {
|
|
542
|
+
const names: Outstanding = new Map();
|
|
543
|
+
if (typeof getComputedStyle !== "function") return names;
|
|
544
|
+
const style = getComputedStyle(element);
|
|
545
|
+
|
|
546
|
+
// An animation named twice IS two animations, and reports twice.
|
|
547
|
+
for (const name of splitList(style.animationName)) {
|
|
548
|
+
if (name === "" || name === "none" || name === "all") continue;
|
|
549
|
+
names.set(name, (names.get(name) ?? 0) + 1);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// A transition property named twice is ONE transition — the last entry
|
|
553
|
+
// wins (CSS Transitions Level 1) — so it reports once. Counting the
|
|
554
|
+
// duplicate made every such exit run to the deadline instead of ending
|
|
555
|
+
// when the browser said it had.
|
|
556
|
+
if (parseDuration(style.transitionDuration) > 0) {
|
|
557
|
+
const seen = new Set<string>();
|
|
558
|
+
for (const property of splitList(style.transitionProperty)) {
|
|
559
|
+
if (property === "" || property === "none" || property === "all") {
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (seen.has(property)) continue;
|
|
563
|
+
seen.add(property);
|
|
564
|
+
names.set(property, (names.get(property) ?? 0) + 1);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return names;
|
|
568
|
+
}
|
|
569
|
+
|
|
162
570
|
function isAnimating(element: HTMLElement): boolean {
|
|
163
571
|
if (typeof getComputedStyle !== "function") return false;
|
|
164
572
|
|
|
165
573
|
const style = getComputedStyle(element);
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
if (hasAnimation)
|
|
574
|
+
const declared = style.animationName;
|
|
575
|
+
const hasAnimation = declared !== "" && declared !== "none";
|
|
576
|
+
if (hasAnimation) {
|
|
577
|
+
// Declared is not the same as defined. Waiting on an animation whose
|
|
578
|
+
// keyframes exist nowhere is what left every closed overlay in the
|
|
579
|
+
// document; the deadline now bounds that, but there is no reason to
|
|
580
|
+
// wait at all when the answer is knowable — and every reason to say so.
|
|
581
|
+
// ANY defined animation is a reason to wait: refusing because a second
|
|
582
|
+
// one is missing truncated the first, which was running perfectly well.
|
|
583
|
+
// The missing ones are still named, because they are still a mistake.
|
|
584
|
+
const missing = undefinedKeyframes(element.ownerDocument, declared);
|
|
585
|
+
if (missing.length < splitNames(declared).length) {
|
|
586
|
+
for (const name of missing) warnMissingKeyframes(name);
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
warnMissingKeyframes(declared);
|
|
590
|
+
return parseDuration(style.transitionDuration) > 0;
|
|
591
|
+
}
|
|
169
592
|
|
|
170
593
|
return parseDuration(style.transitionDuration) > 0;
|
|
171
594
|
}
|
package/theme.css
CHANGED
|
@@ -114,38 +114,3 @@
|
|
|
114
114
|
--sidebar-border: oklch(1 0 0 / 10%);
|
|
115
115
|
--sidebar-ring: oklch(0.556 0 0);
|
|
116
116
|
}
|
|
117
|
-
|
|
118
|
-
/*
|
|
119
|
-
* Keyframes for the enter/exit animations the overlay components reference
|
|
120
|
-
* through `data-[state=open]:animate-in` and friends.
|
|
121
|
-
*
|
|
122
|
-
* These live in nebula rather than in the adapter because `presence()` will
|
|
123
|
-
* hold a node in the DOM waiting for an animation that the stylesheet has to
|
|
124
|
-
* actually define. Ship the behaviour without the keyframes and every overlay
|
|
125
|
-
* unmounts a frame after it is told to close, silently losing the exit.
|
|
126
|
-
*/
|
|
127
|
-
@keyframes nebula-fade-in {
|
|
128
|
-
from {
|
|
129
|
-
opacity: 0;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
@keyframes nebula-fade-out {
|
|
134
|
-
to {
|
|
135
|
-
opacity: 0;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
@keyframes nebula-zoom-in {
|
|
140
|
-
from {
|
|
141
|
-
opacity: 0;
|
|
142
|
-
transform: scale(0.95);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
@keyframes nebula-zoom-out {
|
|
147
|
-
to {
|
|
148
|
-
opacity: 0;
|
|
149
|
-
transform: scale(0.95);
|
|
150
|
-
}
|
|
151
|
-
}
|