@c9up/nebula 0.1.4 → 0.1.6

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.
@@ -19,7 +19,12 @@ export function presence(initiallyOpen = false) {
19
19
  const state = signal(initiallyOpen ? "open" : "closed");
20
20
  let element = null;
21
21
  let pendingUnmount = false;
22
+ let deadline;
22
23
  function finishClose() {
24
+ if (deadline !== undefined) {
25
+ clearTimeout(deadline);
26
+ deadline = undefined;
27
+ }
23
28
  if (!pendingUnmount)
24
29
  return;
25
30
  pendingUnmount = false;
@@ -34,9 +39,38 @@ export function presence(initiallyOpen = false) {
34
39
  function onAnimationEnd(event) {
35
40
  if (event.target !== element)
36
41
  return;
42
+ // The FIRST event used to end the close, so a surface running a fade and
43
+ // a slide together was unmounted when the shorter one finished — the
44
+ // other visibly cut off. Each declared name reports for itself, and the
45
+ // close waits until none is outstanding.
46
+ const name = reportedName(event);
47
+ if (typeof name === "string" && outstanding.size > 0) {
48
+ reportName(outstanding, name);
49
+ if (outstanding.size > 0)
50
+ return;
51
+ }
37
52
  finishClose();
38
53
  }
54
+ /** Declared animations and transitions still waiting to report. */
55
+ let outstanding = new Map();
56
+ /**
57
+ * Arm the wait for `el`'s exit, replacing whatever the last one armed.
58
+ *
59
+ * Overwriting `deadline` without clearing it left the older timer running:
60
+ * it fired mid-way through a LATER close and unmounted a surface that was
61
+ * still animating, cutting the exit off at the previous close's schedule.
62
+ */
63
+ function armDeadline(el) {
64
+ if (deadline !== undefined)
65
+ clearTimeout(deadline);
66
+ outstanding = declaredNames(el);
67
+ deadline = setTimeout(finishClose, declaredDuration(el) + SAFETY_MARGIN_MS);
68
+ }
39
69
  function detach() {
70
+ if (deadline !== undefined) {
71
+ clearTimeout(deadline);
72
+ deadline = undefined;
73
+ }
40
74
  if (element === null)
41
75
  return;
42
76
  element.removeEventListener("animationend", onAnimationEnd);
@@ -49,7 +83,16 @@ export function presence(initiallyOpen = false) {
49
83
  mounted,
50
84
  state,
51
85
  open() {
86
+ // The close in progress is CANCELLED, not just un-pended. Clearing
87
+ // `pendingUnmount` alone left its deadline armed and its names
88
+ // outstanding, so the timer from a close the user had already undone
89
+ // went on to unmount the NEXT one part-way through.
52
90
  pendingUnmount = false;
91
+ if (deadline !== undefined) {
92
+ clearTimeout(deadline);
93
+ deadline = undefined;
94
+ }
95
+ outstanding.clear();
53
96
  mounted(true);
54
97
  state("open");
55
98
  },
@@ -64,14 +107,35 @@ export function presence(initiallyOpen = false) {
64
107
  return;
65
108
  }
66
109
  pendingUnmount = true;
67
- if (!isAnimating(element))
110
+ if (!isAnimating(element)) {
68
111
  finishClose();
112
+ return;
113
+ }
114
+ // The same deadline `onExitFinished` has, and for the same reason:
115
+ // `animationend` is not promised by anything. Without it a declared
116
+ // animation the browser never runs left this mounted for good —
117
+ // `onExitFinished` was bounded and the public presence API was not.
118
+ armDeadline(element);
69
119
  },
70
120
  attach(next) {
121
+ const wasClosing = pendingUnmount;
71
122
  detach();
72
123
  element = next;
73
- if (element === null)
124
+ if (element === null) {
125
+ // Nothing left to wait for, and nothing to wait WITH: a close
126
+ // still pending would otherwise never complete.
127
+ if (wasClosing)
128
+ finishClose();
74
129
  return;
130
+ }
131
+ if (wasClosing) {
132
+ // `detach()` cleared the deadline. Handing over a new element
133
+ // mid-close without arming another left the surface mounted for
134
+ // good — the exact failure the deadline exists to prevent,
135
+ // reintroduced by the handover.
136
+ pendingUnmount = true;
137
+ armDeadline(element);
138
+ }
75
139
  element.addEventListener("animationend", onAnimationEnd);
76
140
  element.addEventListener("animationcancel", onAnimationEnd);
77
141
  element.addEventListener("transitionend", onAnimationEnd);
@@ -98,19 +162,49 @@ export function onExitFinished(element, done) {
98
162
  done();
99
163
  return () => { };
100
164
  }
165
+ // Which declared animations and transitions have yet to report. The FIRST
166
+ // event used to end the wait, so a surface running a fade and a slide
167
+ // together had its node removed when the shorter one finished and the other
168
+ // was visibly cut off. `presence()` already waited for all of them; the
169
+ // portalled surfaces — Dialog, Popover, Select, Tooltip — go through here
170
+ // instead, and did not.
171
+ const outstanding = declaredNames(element);
101
172
  function finish(event) {
102
173
  // Bubbled events from children would cut the parent's exit short.
103
174
  if (event.target !== element)
104
175
  return;
176
+ const name = reportedName(event);
177
+ if (typeof name === "string" && outstanding.size > 0) {
178
+ reportName(outstanding, name);
179
+ if (outstanding.size > 0)
180
+ return;
181
+ }
105
182
  cancel();
106
183
  done();
107
184
  }
108
185
  function cancel() {
186
+ if (safety !== undefined)
187
+ clearTimeout(safety);
109
188
  element.removeEventListener("animationend", finish);
110
189
  element.removeEventListener("animationcancel", finish);
111
190
  element.removeEventListener("transitionend", finish);
112
191
  element.removeEventListener("transitioncancel", finish);
113
192
  }
193
+ // A deadline, because `animationName` is a DECLARATION, not a promise.
194
+ //
195
+ // The computed style reads back the declared name whether or not those
196
+ // keyframes exist anywhere — an application that has not imported the
197
+ // stylesheet, or that scopes it away, declares an animation the browser
198
+ // will never run. `animationend` then never fires, `done()` never runs, and
199
+ // the node stays in the document: every closed Dialog, Select, Popover and
200
+ // Tooltip piles up as an invisible layer swallowing the clicks underneath.
201
+ //
202
+ // That turns a missing stylesheet — cosmetic — into a page that stops
203
+ // responding, which reads as "the floating layer does not work".
204
+ const safety = setTimeout(() => {
205
+ cancel();
206
+ done();
207
+ }, declaredDuration(element) + SAFETY_MARGIN_MS);
114
208
  element.addEventListener("animationend", finish);
115
209
  element.addEventListener("animationcancel", finish);
116
210
  element.addEventListener("transitionend", finish);
@@ -125,13 +219,315 @@ export function onExitFinished(element, done) {
125
219
  * mean there is nothing to wait for, and waiting anyway would strand the node
126
220
  * in the DOM forever — the failure mode this check exists to prevent.
127
221
  */
222
+ /**
223
+ * Slack added to the declared duration before the deadline fires.
224
+ *
225
+ * Long enough that a real animation always wins the race — the listener is what
226
+ * should end the wait — and short enough that a stuck overlay clears within a
227
+ * frame or two of when it should have.
228
+ */
229
+ const SAFETY_MARGIN_MS = 100;
230
+ /**
231
+ * How long the element SAYS its exit lasts: the longest declared animation or
232
+ * transition, plus its delay. Capped, because a stylesheet is free to declare
233
+ * minutes and the deadline exists to bound the wait, not to honour it.
234
+ */
235
+ function declaredDuration(element) {
236
+ if (typeof getComputedStyle !== "function")
237
+ return 0;
238
+ const style = getComputedStyle(element);
239
+ let longest = 0;
240
+ const consider = (total) => {
241
+ if (total > longest)
242
+ longest = total;
243
+ };
244
+ // PER ENTRY. The longest duration and the longest delay used to be taken
245
+ // independently, so a short-but-late animation beside a long-but-immediate
246
+ // one produced a deadline neither of them needed — and the iteration count
247
+ // was not read at all, so 120ms played twice was cut off at 220.
248
+ const names = splitList(style.animationName);
249
+ const durations = splitList(style.animationDuration);
250
+ const delays = splitList(style.animationDelay);
251
+ const counts = splitList(style.animationIterationCount);
252
+ for (const [index, name] of names.entries()) {
253
+ if (name === "" || name === "none")
254
+ continue;
255
+ consider(parseTime(atIndex(durations, index)) *
256
+ parseIterations(atIndex(counts, index)) +
257
+ parseTime(atIndex(delays, index)));
258
+ }
259
+ // A property named twice in `transition-property` is ONE transition — the
260
+ // last entry is the one that runs (CSS Transitions Level 1) — so its
261
+ // duration and delay are read from that index.
262
+ const properties = splitList(style.transitionProperty);
263
+ const transitionDurations = splitList(style.transitionDuration);
264
+ const transitionDelays = splitList(style.transitionDelay);
265
+ const lastEntry = new Map();
266
+ for (const [index, property] of properties.entries()) {
267
+ if (property === "" || property === "none")
268
+ continue;
269
+ lastEntry.set(property, index);
270
+ }
271
+ for (const index of lastEntry.values()) {
272
+ consider(parseTime(atIndex(transitionDurations, index)) +
273
+ parseTime(atIndex(transitionDelays, index)));
274
+ }
275
+ return Math.min(longest, 5000);
276
+ }
277
+ /**
278
+ * A comma-separated CSS list, entry by entry.
279
+ *
280
+ * Tolerates an absent longhand: a computed style is not always the complete
281
+ * one — a test double carries what its test cares about, and not every engine
282
+ * exposes every longhand.
283
+ */
284
+ function splitList(value) {
285
+ if (typeof value !== "string")
286
+ return [];
287
+ return value.split(",").map((part) => part.trim());
288
+ }
289
+ /**
290
+ * The entry at `index`, the way CSS reads one: a list shorter than the
291
+ * animation list repeats to cover it.
292
+ */
293
+ function atIndex(list, index) {
294
+ if (list.length === 0)
295
+ return "";
296
+ return list[index % list.length] ?? "";
297
+ }
298
+ /** One CSS time, in milliseconds. */
299
+ function parseTime(value) {
300
+ const numeric = Number.parseFloat(value);
301
+ if (Number.isNaN(numeric))
302
+ return 0;
303
+ return value.endsWith("ms") ? numeric : numeric * 1000;
304
+ }
305
+ /**
306
+ * How many times one animation plays.
307
+ *
308
+ * `infinite` counts as one: it never fires `animationend`, so the deadline is
309
+ * the only way out and there is nothing to be gained by waiting longer.
310
+ */
311
+ function parseIterations(value) {
312
+ const numeric = Number.parseFloat(value);
313
+ if (!Number.isFinite(numeric) || numeric <= 0)
314
+ return 1;
315
+ return numeric;
316
+ }
317
+ /**
318
+ * Is a `@keyframes` of this name defined anywhere in the document?
319
+ *
320
+ * `animationName` is a DECLARATION. It reads back whatever the stylesheet said,
321
+ * whether or not the keyframes behind it exist — so an application that has not
322
+ * imported nebula's stylesheet declares animations the browser will never run,
323
+ * and the only honest answer comes from looking for the rule itself.
324
+ *
325
+ * Answers `true` when it cannot tell. A cross-origin stylesheet throws on
326
+ * `cssRules`, and refusing to animate because a font sheet was unreadable would
327
+ * be a worse trade than waiting: the deadline in `onExitFinished` already bounds
328
+ * the cost of being wrong here.
329
+ */
330
+ function keyframesExist(doc, name) {
331
+ const cache = cacheFor(doc);
332
+ const cached = cache.get(name);
333
+ if (cached !== undefined)
334
+ return cached;
335
+ let found = false;
336
+ let readable = false;
337
+ for (const sheet of Array.from(doc.styleSheets)) {
338
+ let rules;
339
+ try {
340
+ const own = sheet.cssRules;
341
+ if (own === null)
342
+ continue;
343
+ rules = own;
344
+ }
345
+ catch {
346
+ // Cross-origin: not ours to read, and not evidence of anything.
347
+ continue;
348
+ }
349
+ readable = true;
350
+ if (containsKeyframes(rules, name)) {
351
+ found = true;
352
+ break;
353
+ }
354
+ }
355
+ const answer = found || !readable;
356
+ // Cached only when it was FOUND. A negative is re-checked, because a
357
+ // stylesheet arriving later is exactly what turns it positive.
358
+ if (answer)
359
+ cache.set(name, true);
360
+ return answer;
361
+ }
362
+ /**
363
+ * Walk a rule list, descending into group rules.
364
+ *
365
+ * `@keyframes` inside `@media`, `@supports` or `@layer` is a nested rule, not a
366
+ * top-level one — a flat scan of the sheet reported it missing and the caller
367
+ * concluded the stylesheet was absent.
368
+ */
369
+ function containsKeyframes(rules, name) {
370
+ for (const rule of Array.from(rules)) {
371
+ if (isKeyframesNamed(rule, name))
372
+ return true;
373
+ // `cssRules` on a group rule (media, supports, layer); absent on others.
374
+ const nested = Reflect.get(rule, "cssRules");
375
+ if (nested !== null &&
376
+ typeof nested === "object" &&
377
+ typeof Reflect.get(nested, "length") === "number" &&
378
+ containsKeyframes(nested, name)) {
379
+ return true;
380
+ }
381
+ }
382
+ return false;
383
+ }
384
+ /**
385
+ * Are all the animations this element declares actually defined?
386
+ *
387
+ * `animation-name` is a LIST: `fade-out, slide-out` is two names, and looking
388
+ * the whole string up as one found nothing and reported both missing. Every
389
+ * name has to resolve, because waiting on any undefined one is what strands the
390
+ * node.
391
+ */
392
+ function splitNames(animationName) {
393
+ return animationName
394
+ .split(",")
395
+ .map((part) => part.trim())
396
+ .filter((part) => part !== "" && part !== "none");
397
+ }
398
+ /** Which of the declared animations have no `@keyframes` anywhere. */
399
+ function undefinedKeyframes(doc, animationName) {
400
+ return splitNames(animationName).filter((name) => !keyframesExist(doc, name));
401
+ }
402
+ /**
403
+ * Per-DOCUMENT memo, invalidated when the stylesheets change.
404
+ *
405
+ * A single global map was wrong three ways. An answer cached before the
406
+ * stylesheet finished loading stayed wrong for the life of the page; an iframe
407
+ * and its parent share neither styles nor documents but shared the cache; and
408
+ * nothing ever expired, so a sheet added later never took effect.
409
+ *
410
+ * Keyed on the document and on how many sheets it had when the answer was
411
+ * computed: a new stylesheet changes the count and the answers are recomputed.
412
+ * A `WeakMap` so a detached document does not keep its cache alive.
413
+ */
414
+ const KEYFRAME_CACHE = new WeakMap();
415
+ /**
416
+ * The memo for this document.
417
+ *
418
+ * Only POSITIVE answers are kept. A "found" is durable — keyframes do not
419
+ * usually disappear — while a "missing" is exactly the answer a later
420
+ * stylesheet can change, and keying on the sheet COUNT missed every way that
421
+ * happens without one being added: `insertRule`, `replaceSync`, HMR, or editing
422
+ * an existing `<style>`. Re-walking on a negative costs a scan only when
423
+ * something is already wrong.
424
+ */
425
+ function cacheFor(doc) {
426
+ const existing = KEYFRAME_CACHE.get(doc);
427
+ if (existing !== undefined)
428
+ return existing;
429
+ const answers = new Map();
430
+ KEYFRAME_CACHE.set(doc, answers);
431
+ return answers;
432
+ }
433
+ function isKeyframesNamed(rule, name) {
434
+ // `instanceof CSSKeyframesRule` is unreliable across documents (an iframe
435
+ // has its own constructors), so the shape is checked instead.
436
+ const named = Reflect.get(rule, "name");
437
+ return typeof named === "string" && named === name;
438
+ }
439
+ /**
440
+ * Say once that the stylesheet is missing, and what to do about it.
441
+ *
442
+ * The symptom without this is not "my overlays do not animate" — which would
443
+ * point straight at a missing sheet — but "the floating layer behaves oddly",
444
+ * which points everywhere else. Warned rather than thrown: a missing stylesheet
445
+ * is a cosmetic dependency, and taking an application down over one is a worse
446
+ * trade than a line in the console.
447
+ */
448
+ function warnMissingKeyframes(name) {
449
+ if (WARNED.has(name))
450
+ return;
451
+ WARNED.add(name);
452
+ console.warn(`[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\`.`);
453
+ }
454
+ const WARNED = new Set();
455
+ /** Mark one report against `name`, if it is one we are waiting for. */
456
+ function reportName(outstanding, name) {
457
+ const left = outstanding.get(name);
458
+ if (left === undefined)
459
+ return;
460
+ if (left <= 1)
461
+ outstanding.delete(name);
462
+ else
463
+ outstanding.set(name, left - 1);
464
+ }
465
+ /**
466
+ * Which animation or transition an end event is reporting for.
467
+ *
468
+ * `undefined` when the event carries neither — jsdom's plain `Event`, and any
469
+ * synthetic one — in which case the caller falls back to treating it as the end
470
+ * of the whole exit.
471
+ */
472
+ function reportedName(event) {
473
+ if ("animationName" in event)
474
+ return event.animationName;
475
+ if ("propertyName" in event)
476
+ return event.propertyName;
477
+ return undefined;
478
+ }
479
+ function declaredNames(element) {
480
+ const names = new Map();
481
+ if (typeof getComputedStyle !== "function")
482
+ return names;
483
+ const style = getComputedStyle(element);
484
+ // An animation named twice IS two animations, and reports twice.
485
+ for (const name of splitList(style.animationName)) {
486
+ if (name === "" || name === "none" || name === "all")
487
+ continue;
488
+ names.set(name, (names.get(name) ?? 0) + 1);
489
+ }
490
+ // A transition property named twice is ONE transition — the last entry
491
+ // wins (CSS Transitions Level 1) — so it reports once. Counting the
492
+ // duplicate made every such exit run to the deadline instead of ending
493
+ // when the browser said it had.
494
+ if (parseDuration(style.transitionDuration) > 0) {
495
+ const seen = new Set();
496
+ for (const property of splitList(style.transitionProperty)) {
497
+ if (property === "" || property === "none" || property === "all") {
498
+ continue;
499
+ }
500
+ if (seen.has(property))
501
+ continue;
502
+ seen.add(property);
503
+ names.set(property, (names.get(property) ?? 0) + 1);
504
+ }
505
+ }
506
+ return names;
507
+ }
128
508
  function isAnimating(element) {
129
509
  if (typeof getComputedStyle !== "function")
130
510
  return false;
131
511
  const style = getComputedStyle(element);
132
- const hasAnimation = style.animationName !== "" && style.animationName !== "none";
133
- if (hasAnimation)
134
- return true;
512
+ const declared = style.animationName;
513
+ const hasAnimation = declared !== "" && declared !== "none";
514
+ if (hasAnimation) {
515
+ // Declared is not the same as defined. Waiting on an animation whose
516
+ // keyframes exist nowhere is what left every closed overlay in the
517
+ // document; the deadline now bounds that, but there is no reason to
518
+ // wait at all when the answer is knowable — and every reason to say so.
519
+ // ANY defined animation is a reason to wait: refusing because a second
520
+ // one is missing truncated the first, which was running perfectly well.
521
+ // The missing ones are still named, because they are still a mistake.
522
+ const missing = undefinedKeyframes(element.ownerDocument, declared);
523
+ if (missing.length < splitNames(declared).length) {
524
+ for (const name of missing)
525
+ warnMissingKeyframes(name);
526
+ return true;
527
+ }
528
+ warnMissingKeyframes(declared);
529
+ return parseDuration(style.transitionDuration) > 0;
530
+ }
135
531
  return parseDuration(style.transitionDuration) > 0;
136
532
  }
137
533
  /** Longest duration in a comma-separated CSS time list, in milliseconds. */