@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.
Files changed (42) hide show
  1. package/dist/adapters/tailwind.d.ts +7 -0
  2. package/dist/adapters/tailwind.js +11 -1
  3. package/dist/adapters/unocss.js +11 -2
  4. package/dist/atoms/Image.d.ts +106 -0
  5. package/dist/atoms/Image.js +139 -0
  6. package/dist/atoms/NativeSelect.js +27 -2
  7. package/dist/atoms/index.d.ts +1 -0
  8. package/dist/atoms/index.js +1 -0
  9. package/dist/lib/image.d.ts +213 -0
  10. package/dist/lib/image.js +331 -0
  11. package/dist/lib/index.d.ts +1 -0
  12. package/dist/lib/index.js +1 -0
  13. package/dist/lib/motion.d.ts +25 -16
  14. package/dist/lib/motion.js +30 -24
  15. package/dist/molecules/Picture.d.ts +46 -0
  16. package/dist/molecules/Picture.js +62 -0
  17. package/dist/molecules/index.d.ts +1 -0
  18. package/dist/molecules/index.js +1 -0
  19. package/dist/organisms/Dialog.d.ts +11 -4
  20. package/dist/organisms/Dialog.js +2 -2
  21. package/dist/organisms/Sidebar.d.ts +9 -0
  22. package/dist/organisms/Sidebar.js +5 -2
  23. package/dist/primitives/floatingSurface.js +17 -8
  24. package/dist/primitives/presence.js +401 -5
  25. package/nebula.css +1 -1
  26. package/package.json +7 -6
  27. package/registry.json +25 -0
  28. package/src/adapters/tailwind.ts +11 -1
  29. package/src/adapters/unocss.ts +11 -2
  30. package/src/atoms/Image.ts +226 -0
  31. package/src/atoms/NativeSelect.ts +27 -2
  32. package/src/atoms/index.ts +10 -0
  33. package/src/lib/image.ts +470 -0
  34. package/src/lib/index.ts +25 -0
  35. package/src/lib/motion.ts +30 -26
  36. package/src/molecules/Picture.ts +92 -0
  37. package/src/molecules/index.ts +1 -0
  38. package/src/organisms/Dialog.ts +13 -6
  39. package/src/organisms/Sidebar.ts +15 -2
  40. package/src/primitives/floatingSurface.ts +18 -9
  41. package/src/primitives/presence.ts +428 -5
  42. package/theme.css +0 -35
@@ -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
- /** Announced on open. Hide it visually with `srOnlyTitle`. */
40
- title: Child;
41
- description?: Child;
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)}
@@ -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="${classes}"
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="${classes}"
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)) finishClose();
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) return;
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 hasAnimation =
167
- style.animationName !== "" && style.animationName !== "none";
168
- if (hasAnimation) return true;
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
- }