@alikhalilll/a-smart-skeleton 0.0.0-stage → 2.0.0

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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +356 -2
  3. package/dist/_chunks/core.js +2314 -0
  4. package/dist/_chunks/core.js.map +1 -0
  5. package/dist/_chunks/index.d.ts +431 -0
  6. package/dist/core/index.cjs +67 -0
  7. package/dist/core/index.d.cts +2 -0
  8. package/dist/core/index.d.ts +2 -0
  9. package/dist/core/index.js +2 -0
  10. package/dist/element/index.cjs +47 -0
  11. package/dist/element/index.cjs.map +1 -0
  12. package/dist/element/index.d.cts +24 -0
  13. package/dist/element/index.d.ts +24 -0
  14. package/dist/element/index.js +46 -0
  15. package/dist/element/index.js.map +1 -0
  16. package/dist/index.cjs +512 -0
  17. package/dist/index.cjs.map +1 -0
  18. package/dist/index.d.cts +204 -0
  19. package/dist/index.d.ts +204 -0
  20. package/dist/index.js +441 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/nuxt/index.cjs +36 -0
  23. package/dist/nuxt/index.cjs.map +1 -0
  24. package/dist/nuxt/index.d.cts +30 -0
  25. package/dist/nuxt/index.d.ts +30 -0
  26. package/dist/nuxt/index.js +36 -0
  27. package/dist/nuxt/index.js.map +1 -0
  28. package/dist/resolver/index.cjs +26 -0
  29. package/dist/resolver/index.cjs.map +1 -0
  30. package/dist/resolver/index.d.cts +19 -0
  31. package/dist/resolver/index.d.ts +19 -0
  32. package/dist/resolver/index.js +26 -0
  33. package/dist/resolver/index.js.map +1 -0
  34. package/dist/styles.css +136 -0
  35. package/package.json +144 -3
  36. package/src/components/ASmartSkeleton.vue +228 -0
  37. package/src/components/ASmartSkeletonBox.vue +23 -0
  38. package/src/components/ASmartSkeletonProvider.vue +34 -0
  39. package/src/components/ASmartSkeletonSuspense.vue +142 -0
  40. package/src/composables/useSmartSkeleton.ts +20 -0
  41. package/src/composables/useStyles.ts +8 -0
  42. package/src/core/boot.ts +63 -0
  43. package/src/core/canvas.ts +7 -0
  44. package/src/core/constants.ts +76 -0
  45. package/src/core/debug.ts +154 -0
  46. package/src/core/dev.ts +53 -0
  47. package/src/core/fade.ts +490 -0
  48. package/src/core/ghost.ts +236 -0
  49. package/src/core/images.ts +28 -0
  50. package/src/core/index.ts +71 -0
  51. package/src/core/inline.generated.ts +9 -0
  52. package/src/core/measure.ts +571 -0
  53. package/src/core/memory.ts +146 -0
  54. package/src/core/motion.ts +12 -0
  55. package/src/core/plan.ts +368 -0
  56. package/src/core/reveal.ts +89 -0
  57. package/src/core/reveals.ts +39 -0
  58. package/src/core/shapes.ts +84 -0
  59. package/src/core/shimmer.ts +229 -0
  60. package/src/core/skeleton.ts +43 -0
  61. package/src/core/snapshot.ts +159 -0
  62. package/src/core/styles.ts +187 -0
  63. package/src/core/sweep.ts +203 -0
  64. package/src/core/theme.ts +162 -0
  65. package/src/element/index.ts +59 -0
  66. package/src/index.ts +27 -0
  67. package/src/nuxt/index.ts +59 -0
  68. package/src/resolver/index.ts +35 -0
  69. package/src/types.ts +100 -0
  70. package/src/vue/Skeleton.ts +275 -0
  71. package/src/vue/Suspense.ts +157 -0
  72. package/src/vue/config.ts +78 -0
  73. package/src/vue/index.ts +7 -0
  74. package/web-types.json +256 -0
@@ -0,0 +1,146 @@
1
+ import { ATTR_REVEAL } from './constants';
2
+ import { debugging, reportReveal } from './debug';
3
+ import { isDev, sampleFrom, warnOnce } from './dev';
4
+ import { takeLoadingSize } from './ghost';
5
+ import { rememberShape, trackShape } from './shapes';
6
+ import { autoKey } from './snapshot';
7
+
8
+ export type MemoryOptions = {
9
+ id?: string;
10
+ /** What the content was rendered from, for the `sample` dev hint. */
11
+ data?: unknown;
12
+ /** The content is rendered from data by a function, which only `sample` or an `id` can stand in for. */
13
+ rendered?: boolean;
14
+ /** A `sample` was passed. */
15
+ sample?: boolean;
16
+ };
17
+
18
+ const SAVE_DELAY = 250;
19
+
20
+ // Synthetic, per-element id for `warnOnce` dedupe when the consumer didn't pass one.
21
+ // Without this every id-less skeleton shares the same `sample:null` / `jump:null` bucket,
22
+ // so only the first warning across the whole app ever fires.
23
+ const anonIds = new WeakMap<HTMLElement, string>();
24
+ let anonSeq = 0;
25
+ const anonId = (element: HTMLElement) => {
26
+ let id = anonIds.get(element);
27
+ if (!id) {
28
+ id = `anon:${++anonSeq}`;
29
+ anonIds.set(element, id);
30
+ }
31
+ return id;
32
+ };
33
+
34
+ /** Remembers the shape of shown content, so its next skeleton can be drawn from it. */
35
+ export class Memory {
36
+ private key: string | null;
37
+ private warnKey: string;
38
+ private timer: ReturnType<typeof setTimeout> | undefined;
39
+ private mutations: MutationObserver;
40
+ private resize: ResizeObserver | null;
41
+ private untrack: () => void;
42
+
43
+ constructor(
44
+ private element: HTMLElement,
45
+ private options: MemoryOptions = {}
46
+ ) {
47
+ this.key = options.id ?? autoKey(element);
48
+ this.warnKey = this.key ?? anonId(element);
49
+ this.untrack = this.key ? trackShape(this.key, () => this.save()) : () => {};
50
+
51
+ if (isDev || debugging(element)) this.check();
52
+ this.save();
53
+
54
+ this.mutations = new MutationObserver(this.schedule);
55
+ this.mutations.observe(element, {
56
+ childList: true,
57
+ subtree: true,
58
+ characterData: true,
59
+ });
60
+ // None in jsdom, which has no layout to follow
61
+ this.resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(this.schedule);
62
+ this.resize?.observe(element);
63
+ element.addEventListener('load', this.schedule, true);
64
+ }
65
+
66
+ private schedule = () => {
67
+ clearTimeout(this.timer);
68
+ this.timer = setTimeout(() => this.save(), SAVE_DELAY);
69
+ };
70
+
71
+ save() {
72
+ clearTimeout(this.timer);
73
+ if (this.key && this.element.isConnected && this.element.offsetHeight) {
74
+ rememberShape(this.key, this.element);
75
+ }
76
+ }
77
+
78
+ private check() {
79
+ const { data, id, rendered, sample } = this.options;
80
+ const name = id ? `<Skeleton id="${id}">` : '<Skeleton>';
81
+
82
+ const reveal = this.element.closest(`[${ATTR_REVEAL}]`);
83
+ const before = takeLoadingSize(reveal);
84
+ // Shown without a skeleton first, so nothing was guessed and nothing could move
85
+ if (!before) return;
86
+
87
+ if (before.guessed && rendered && !sample && data != null) {
88
+ warnOnce(
89
+ `sample:${this.warnKey}`,
90
+ `${name} has no \`sample\`, so a first load guesses its shape${id ? '' : " and it can't be remembered"}. Pass this to make it exact:\n sample={${JSON.stringify(sampleFrom(data))}}`
91
+ );
92
+ }
93
+
94
+ // Unsized images settle late; compare once they have
95
+ const images = [...this.element.querySelectorAll('img')].filter((image) => !image.complete);
96
+ Promise.all(
97
+ images.map(
98
+ (image) =>
99
+ new Promise((resolve) => {
100
+ image.addEventListener('load', resolve, { once: true });
101
+ image.addEventListener('error', resolve, { once: true });
102
+ })
103
+ )
104
+ ).then(() => {
105
+ // Gone before it settled: a detached element measures nothing
106
+ if (!this.element.isConnected) return;
107
+ const width = this.element.offsetWidth;
108
+ const height = this.element.offsetHeight;
109
+ reportReveal(reveal, {
110
+ width: width - before.width,
111
+ height: height - before.height,
112
+ });
113
+ if (Math.abs(width - before.width) <= 1 && Math.abs(height - before.height) <= 1) {
114
+ return;
115
+ }
116
+ // Flat in the skeleton, tall now: nothing sized them before they loaded
117
+ const unsized = [...this.element.querySelectorAll('img')]
118
+ .filter(
119
+ (image, i) => (before.images[i] ?? 0) < 1 && image.getBoundingClientRect().height >= 1
120
+ )
121
+ .map((image) => image.currentSrc.split('/').pop() || 'an image');
122
+ if (unsized.length) {
123
+ warnOnce(
124
+ `unsized:${this.warnKey}`,
125
+ `${name}'s skeleton had no room for ${unsized.length === 1 ? 'an image' : `${unsized.length} images`} (${unsized.slice(0, 3).join(', ')}${unsized.length > 3 ? ', …' : ''}), so the layout jumped when they loaded. Give each \`width\` and \`height\` attributes, or an \`aspect-ratio\`, so its space is known before it loads.`
126
+ );
127
+ return;
128
+ }
129
+ warnOnce(
130
+ `jump:${this.warnKey}`,
131
+ before.guessed
132
+ ? `${name}'s skeleton was ${before.width}×${before.height}px but its content is ${width}×${height}px, so the layout jumped on reveal. ${rendered ? 'Pass `sample` data shaped like the real thing' : 'Pass an `id` so its shape is remembered'}, and give images a size.`
133
+ : `${name}'s skeleton was drawn from the data on screen (${before.width}×${before.height}px), but the new data is ${width}×${height}px, so the layout jumped on reveal. When new data adds to what's shown — another page of a list — give the addition a reveal of its own.`
134
+ );
135
+ });
136
+ }
137
+
138
+ destroy() {
139
+ this.save();
140
+ clearTimeout(this.timer);
141
+ this.mutations.disconnect();
142
+ this.resize?.disconnect();
143
+ this.element.removeEventListener('load', this.schedule, true);
144
+ this.untrack();
145
+ }
146
+ }
@@ -0,0 +1,12 @@
1
+ const prefers = (query: string) => typeof matchMedia !== 'undefined' && matchMedia(query).matches;
2
+
3
+ // Not in jsdom, where every fade is instant
4
+ export const canAnimate = () =>
5
+ typeof Element !== 'undefined' && typeof Element.prototype.animate === 'function';
6
+
7
+ export const prefersReducedMotion = () =>
8
+ !canAnimate() || prefers('(prefers-reduced-motion: reduce)');
9
+
10
+ export const prefersReducedTransparency = () => prefers('(prefers-reduced-transparency: reduce)');
11
+
12
+ export const animationsOf = (element: Element): Animation[] => element.getAnimations?.() ?? [];
@@ -0,0 +1,368 @@
1
+ import { ATTR_EXITING, CASCADE_SPREAD, FADE_DURATION } from './constants';
2
+
3
+ /** Where one piece of a reveal sits: a piece of content arriving, or a rect of the skeleton leaving. */
4
+ export type Piece = {
5
+ rect: DOMRect;
6
+ /** Its row or card: the nearest ancestor with two or more siblings like it. Null for a rect outside any. */
7
+ unit: DOMRect | null;
8
+ /** Smaller text than the largest in its unit. */
9
+ secondary: boolean;
10
+ /** The whole reveal. */
11
+ reveal: DOMRect;
12
+ /** ms since the first of the reveals landing alongside this one. */
13
+ late: number;
14
+ };
15
+
16
+ /** What a reveal knows as its content lands, for `choose`. */
17
+ export type RevealContext = {
18
+ content: HTMLElement;
19
+ reveal: HTMLElement;
20
+ /** ms the skeleton was up; Infinity when it was server-rendered or there was none. */
21
+ waited: number;
22
+ /** Someone scrolled or typed in the last 400ms. */
23
+ input: boolean;
24
+ /** The skeleton was drawn from the data on screen. */
25
+ refetch: boolean;
26
+ height: number;
27
+ /** How many pieces the content arrives in. */
28
+ pieces: number;
29
+ };
30
+
31
+ /** One pane under a wipe: `rect` is its own box, `reveal` the box the front crosses, both in viewport px. */
32
+ export type WipePane = { rect: DOMRect; reveal: DOMRect };
33
+
34
+ /** A front crossing the whole reveal, as a mask drawn each frame. */
35
+ export type Wipe = {
36
+ /** The incoming pane's `mask-image` at `progress`, 0 to 1. */
37
+ mask: (progress: number, pane: WipePane) => string;
38
+ /** The outgoing pane's; the inverse of `mask` when left out. */
39
+ out?: (progress: number, pane: WipePane) => string;
40
+ /** Runs once on the incoming pane; `progress` keeps counting past 1. */
41
+ effect?: (element: HTMLElement, front: WipePane & { progress: () => number }) => void;
42
+ };
43
+
44
+ /** How content replaces its skeleton; what `choose` returns as content lands wins over the fields beside it. */
45
+ export type RevealSpec = {
46
+ order?: 'together' | ((piece: Piece) => number | null);
47
+ /** Where each piece starts from, on top of the fade: `{ filter: "blur(6px)" }`. */
48
+ enter?: Keyframe;
49
+ wipe?: Wipe;
50
+ delay?: number;
51
+ /** 0 is instant. */
52
+ duration?: number;
53
+ /** ms a skeleton waits before it shows, so data that beats it never shows one. */
54
+ hold?: number;
55
+ choose?: (context: RevealContext) => RevealSpec;
56
+ };
57
+
58
+ /** A built-in reveal by name, or one of your own. */
59
+ export type RevealMode = 'together' | 'sequence' | 'smart' | 'sweep' | 'instant' | RevealSpec;
60
+
61
+ export const SKELETON_DELAY = 150; // A skeleton up for less than this is a flash — smart holds it back
62
+ const QUICK_FADE = 120; // A refetch, already familiar
63
+ const SMALL_HEIGHT = 72;
64
+ const SMALL_PIECES = 3;
65
+ const SECONDARY_DELAY = 60; // Smaller text follows the text it sits under
66
+ export const BATCH_WINDOW = 150; // Reveals landing this close share one top-to-bottom order
67
+ const ACTIVE_WINDOW = 400; // Input this recent means someone is mid-gesture
68
+ export const MAX_SWEEP_AREA = 1.5; // in screens — a wipe bigger than this crossfades: a front across it is mostly off screen, and costly
69
+ export const MAX_DELAY = 1000; // A custom order's latest piece
70
+ export const MAX_DURATION = 1000;
71
+
72
+ // What `enter` may set: nothing that changes layout, so no reveal moves the page
73
+ const ENTER = new Set([
74
+ 'filter',
75
+ 'transform',
76
+ 'translate',
77
+ 'scale',
78
+ 'rotate',
79
+ 'clipPath',
80
+ 'easing',
81
+ 'composite',
82
+ ]);
83
+
84
+ export type SequencePlan = {
85
+ kind: 'sequence';
86
+ /** When a piece of content appears; null to appear at once. */
87
+ pieceDelay: (piece: Element) => number | null;
88
+ /** When a skeleton rect at `rect` goes; null to go at once. */
89
+ boneDelay: (rect: DOMRect) => number | null;
90
+ duration: number;
91
+ enter?: Keyframe;
92
+ };
93
+
94
+ export type WipePlan = {
95
+ kind: 'wipe';
96
+ wipe: Wipe;
97
+ /** performance.now() at progress 0. */
98
+ start: number;
99
+ duration: number;
100
+ reveal: DOMRect;
101
+ enter?: Keyframe;
102
+ /** Set when frames drop mid-wipe: both panes finish as a crossfade instead. */
103
+ stalled?: boolean;
104
+ };
105
+
106
+ export type Plan =
107
+ | { kind: 'instant' }
108
+ | { kind: 'together'; delay: number; duration: number; enter?: Keyframe }
109
+ | SequencePlan
110
+ | WipePlan;
111
+
112
+ // Input, not scroll: a page restoring its scroll position isn't someone using it. No clicks: a click doesn't move what's being read
113
+ let lastInput = -Infinity;
114
+ let listening = false;
115
+ function listen() {
116
+ if (listening || typeof addEventListener === 'undefined') return;
117
+ listening = true;
118
+ for (const type of ['wheel', 'touchmove', 'keydown']) {
119
+ addEventListener(type, () => (lastInput = performance.now()), {
120
+ passive: true,
121
+ capture: true,
122
+ });
123
+ }
124
+ }
125
+
126
+ const loading = new WeakMap<Element, { since: number; refetch: boolean }>();
127
+
128
+ /** Notes a skeleton going up in `reveal` — when, and whether it's a refetch over the data on screen. */
129
+ export function noteSkeleton(
130
+ reveal: Element,
131
+ { served, refetch }: { served: boolean; refetch: boolean }
132
+ ) {
133
+ listen();
134
+ const since = loading.get(reveal)?.since ?? performance.now();
135
+ // Server-rendered: up since the page began, so never a flash
136
+ loading.set(reveal, { since: served ? -Infinity : since, refetch });
137
+ }
138
+
139
+ // What a reveal brings in one by one: anything holding text or media of its own
140
+ export function pieces(root: Element): Element[] {
141
+ const found: Element[] = [];
142
+ const visit = (element: Element) => {
143
+ for (const child of element.children) {
144
+ const ownText = [...child.childNodes].some(
145
+ (node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim()
146
+ );
147
+ if (!child.firstElementChild || ownText || child.localName === 'svg') {
148
+ found.push(child);
149
+ } else {
150
+ visit(child);
151
+ }
152
+ }
153
+ };
154
+ visit(root);
155
+ return found;
156
+ }
157
+
158
+ // A row of a list or a card of a grid: the piece's nearest ancestor with two or more siblings like it
159
+ function unitOf(piece: Element, root: Element): Element {
160
+ for (
161
+ let element: Element | null = piece;
162
+ element && element !== root;
163
+ element = element.parentElement
164
+ ) {
165
+ const parent = element.parentElement;
166
+ if (!parent) break;
167
+ const alike = [...parent.children].filter(
168
+ (sibling) =>
169
+ sibling.localName === element!.localName && sibling.className === element!.className
170
+ );
171
+ if (alike.length >= 3) return element;
172
+ }
173
+ return piece;
174
+ }
175
+
176
+ const contains = (outer: DOMRect, inner: DOMRect) => {
177
+ const x = inner.left + inner.width / 2;
178
+ const y = inner.top + inner.height / 2;
179
+ return x >= outer.left && x <= outer.right && y >= outer.top && y <= outer.bottom;
180
+ };
181
+
182
+ /** The box a wipe crosses: the reveal, and any pane leaving it that stands taller or wider. */
183
+ export function revealBox(root: Element): DOMRect {
184
+ let { left, top, right, bottom } = root.getBoundingClientRect();
185
+ for (const pane of root.querySelectorAll(`:scope > [${ATTR_EXITING}]`)) {
186
+ const rect = pane.getBoundingClientRect();
187
+ left = Math.min(left, rect.left);
188
+ top = Math.min(top, rect.top);
189
+ right = Math.max(right, rect.right);
190
+ bottom = Math.max(bottom, rect.bottom);
191
+ }
192
+ return new DOMRect(left, top, right - left, bottom - top);
193
+ }
194
+
195
+ const clamp = (value: number, max: number) => Math.min(Math.max(value, 0), max);
196
+
197
+ let batch = -Infinity;
198
+
199
+ /** Times each piece of `content`, and each skeleton rect it replaces, by `order`. */
200
+ function sequencePlan(
201
+ content: Element,
202
+ root: Element,
203
+ order: (piece: Piece) => number | null,
204
+ spec: RevealSpec,
205
+ duration: number
206
+ ): SequencePlan {
207
+ const now = performance.now();
208
+ if (now - batch > BATCH_WINDOW) batch = now;
209
+ const late = now - batch;
210
+ const reveal = root.getBoundingClientRect();
211
+
212
+ type Unit = { rect: DOMRect; size: number };
213
+ const units = new Map<Element, Unit>();
214
+ const found = new Map<Element, { unit: Unit; size: number }>();
215
+ const sizeOf = (element: Element) => parseFloat(getComputedStyle(element).fontSize) || 16;
216
+
217
+ for (const piece of pieces(content)) {
218
+ const element = unitOf(piece, content);
219
+ let unit = units.get(element);
220
+ if (!unit) {
221
+ unit = { rect: element.getBoundingClientRect(), size: 0 };
222
+ units.set(element, unit);
223
+ }
224
+ const size = sizeOf(piece);
225
+ unit.size = Math.max(unit.size, size);
226
+ found.set(piece, { unit, size });
227
+ }
228
+
229
+ const at = (piece: Piece) => {
230
+ const delay = order(piece);
231
+ return delay === null ? null : clamp(delay, MAX_DELAY) + Math.max(spec.delay ?? 0, 0);
232
+ };
233
+
234
+ return {
235
+ kind: 'sequence',
236
+ duration,
237
+ enter: spec.enter,
238
+ pieceDelay: (element) => {
239
+ const piece = found.get(element);
240
+ if (!piece) return null;
241
+ return at({
242
+ rect: element.getBoundingClientRect(),
243
+ unit: piece.unit.rect,
244
+ secondary: piece.size < piece.unit.size,
245
+ reveal,
246
+ late,
247
+ });
248
+ },
249
+ boneDelay: (rect) => {
250
+ const unit = [...units.values()].find((unit) => contains(unit.rect, rect));
251
+ return at({
252
+ rect,
253
+ unit: unit?.rect ?? null,
254
+ // A bar shorter than its row's largest type stands for smaller text
255
+ secondary: !!unit && rect.height < unit.size * 0.8,
256
+ reveal,
257
+ late,
258
+ });
259
+ },
260
+ };
261
+ }
262
+
263
+ /** Keeps `enter` to what paints, warning in development about anything that would move the page. */
264
+ function enterOf(enter: Keyframe | undefined, warn: (key: string) => void) {
265
+ if (!enter) return undefined;
266
+ const kept: Keyframe = {};
267
+ for (const [key, value] of Object.entries(enter)) {
268
+ if (ENTER.has(key)) kept[key] = value;
269
+ else if (key !== 'opacity') warn(key);
270
+ }
271
+ return kept;
272
+ }
273
+
274
+ /** Decides how `content` replaces the skeleton in `root`, now that it has arrived. */
275
+ export function planReveal(
276
+ content: HTMLElement,
277
+ root: HTMLElement,
278
+ reveal: RevealSpec,
279
+ warn: (key: string) => void = () => {}
280
+ ): Plan {
281
+ const info = loading.get(root);
282
+ loading.delete(root);
283
+
284
+ let spec = reveal;
285
+ while (spec.choose) {
286
+ const { choose, ...rest } = spec;
287
+ spec = {
288
+ ...rest,
289
+ ...choose({
290
+ content,
291
+ reveal: root,
292
+ waited: info ? performance.now() - info.since : Infinity,
293
+ input: performance.now() - lastInput < ACTIVE_WINDOW,
294
+ refetch: !!info?.refetch,
295
+ height: content.getBoundingClientRect().height,
296
+ pieces: pieces(content).length,
297
+ }),
298
+ };
299
+ }
300
+
301
+ const duration = clamp(spec.duration ?? FADE_DURATION, MAX_DURATION);
302
+ // No floor: a wipe joining one under way starts partway, before now
303
+ spec = { ...spec, delay: Math.min(spec.delay ?? 0, MAX_DELAY) };
304
+ if (!duration) return { kind: 'instant' };
305
+ const enter = enterOf(spec.enter, warn);
306
+
307
+ if (spec.wipe) {
308
+ const rect = revealBox(root);
309
+ if (rect.width * rect.height <= innerWidth * innerHeight * MAX_SWEEP_AREA) {
310
+ return {
311
+ kind: 'wipe',
312
+ wipe: spec.wipe,
313
+ start: performance.now() + (spec.delay ?? 0),
314
+ duration,
315
+ reveal: rect,
316
+ enter,
317
+ };
318
+ }
319
+ } else if (typeof spec.order === 'function') {
320
+ return sequencePlan(content, root, spec.order, { ...spec, enter }, duration);
321
+ }
322
+ return {
323
+ kind: 'together',
324
+ delay: Math.max(spec.delay ?? 0, 0),
325
+ duration,
326
+ enter,
327
+ };
328
+ }
329
+
330
+ /** `sequence`: each piece timed by its place in the reveal, top to bottom, then a little left to right. */
331
+ export const byPosition = ({ rect, reveal }: Piece) => {
332
+ const down = (rect.top - reveal.top) / Math.max(reveal.height, 1);
333
+ const across = (rect.left - reveal.left) / Math.max(reveal.width, 1);
334
+ return clamp(down * 0.8 + across * 0.2, 1) * CASCADE_SPREAD;
335
+ };
336
+
337
+ const onScreen = (rect: DOMRect) => rect.bottom > 0 && rect.top < innerHeight;
338
+
339
+ /** `smart`'s order: rows arrive whole, in page order, on screen only, main text first. */
340
+ export const byRow = ({ rect, unit, secondary, late }: Piece) => {
341
+ if (!unit) return onScreen(rect) ? 0 : null;
342
+ if (!onScreen(unit)) return null;
343
+ // Timed against the screen, not the reveal, so reveals landing together read as one page
344
+ const at = clamp(unit.top / innerHeight, 1) * CASCADE_SPREAD;
345
+ return Math.max(at - late, 0) + (secondary ? SECONDARY_DELAY : 0);
346
+ };
347
+
348
+ export const instant: RevealSpec = { duration: 0 };
349
+ export const together: RevealSpec = { order: 'together' };
350
+ export const sequence: RevealSpec = { order: byPosition };
351
+
352
+ /** The cases `smart` settles before looking at the content: instant, or a quick crossfade. */
353
+ export const quickly = (context: RevealContext): RevealSpec | null =>
354
+ context.input || context.waited < SKELETON_DELAY
355
+ ? instant
356
+ : context.refetch
357
+ ? { order: 'together', duration: QUICK_FADE }
358
+ : null;
359
+
360
+ /** `together` for a small reveal, else `byRow`. */
361
+ export const byContent = (context: RevealContext): RevealSpec =>
362
+ context.height < SMALL_HEIGHT || context.pieces <= SMALL_PIECES ? together : { order: byRow };
363
+
364
+ /** Picks for each reveal from how it loaded and what it holds, holding its skeleton back a moment. */
365
+ export const smart: RevealSpec = {
366
+ hold: SKELETON_DELAY,
367
+ choose: (context) => quickly(context) ?? byContent(context),
368
+ };
@@ -0,0 +1,89 @@
1
+ import { ATTR_GHOST, ATTR_PANE, ATTR_REVEAL, ATTR_STATUS } from './constants';
2
+ import { fadeIn, fadeOut } from './fade';
3
+ import type { RevealMode } from './plan';
4
+ import { Ghost } from './ghost';
5
+ import { Memory } from './memory';
6
+ import { addStyles, removeStyles } from './styles';
7
+
8
+ export type RevealOptions = {
9
+ /** Remembers the shape under this key; without it, one is derived from the markup. */
10
+ id?: string;
11
+ /** How content replaces its skeleton: `smart` (the default), `together`, `sequence`, `sweep`, or a `RevealSpec` of your own. */
12
+ reveal?: RevealMode;
13
+ /** Read to screen readers while loading, in place of the skeleton — "Loading profile". */
14
+ label?: string;
15
+ /** Called once content has come in over a skeleton. */
16
+ onReveal?: () => void;
17
+ };
18
+
19
+ /** Shows a skeleton of content while it loads, then crossfades to the content itself. */
20
+ export class Reveal {
21
+ private current: HTMLElement | null = null;
22
+ private ghost: Ghost | null = null;
23
+ private memory: Memory | null = null;
24
+ private arrival: Promise<void> = Promise.resolve();
25
+
26
+ constructor(
27
+ private element: HTMLElement,
28
+ private options: RevealOptions = {}
29
+ ) {
30
+ addStyles();
31
+ element.setAttribute(ATTR_REVEAL, '');
32
+ }
33
+
34
+ /** Shows a skeleton measured from `content`, rendered from sample data, or else the shape last shown. */
35
+ load(content?: Node | string): HTMLElement {
36
+ const loading = document.createElement('div');
37
+ const ghost = document.createElement('div');
38
+ ghost.setAttribute(ATTR_GHOST, '');
39
+ if (content) ghost.append(content);
40
+ loading.append(ghost);
41
+
42
+ const pane = this.swap(loading);
43
+ // Outside the skeleton, which is hidden from screen readers
44
+ if (this.options.label) {
45
+ const status = document.createElement('span');
46
+ status.setAttribute(ATTR_STATUS, '');
47
+ status.textContent = this.options.label;
48
+ pane.prepend(status);
49
+ }
50
+ this.element.setAttribute('aria-busy', 'true');
51
+ this.ghost = new Ghost(loading, { id: this.options.id });
52
+ return pane;
53
+ }
54
+
55
+ show(content: Node | string): HTMLElement {
56
+ const loading = this.element.getAttribute('aria-busy') === 'true';
57
+ const pane = this.swap(content);
58
+ if (loading && this.options.onReveal) this.arrival.then(this.options.onReveal);
59
+ this.element.removeAttribute('aria-busy');
60
+ this.memory = new Memory(pane, { id: this.options.id });
61
+ return pane;
62
+ }
63
+
64
+ private swap(content: Node | string) {
65
+ this.memory?.destroy();
66
+ this.ghost?.destroy();
67
+ this.memory = this.ghost = null;
68
+
69
+ const pane = document.createElement('div');
70
+ pane.setAttribute(ATTR_PANE, '');
71
+ pane.append(content);
72
+
73
+ const previous = this.current;
74
+ this.current = pane;
75
+
76
+ // Popped out before the new pane lands, while it still measures where it stood
77
+ if (previous) fadeOut(previous).then(() => previous.remove());
78
+ this.element.append(pane);
79
+ this.arrival = fadeIn(pane, this.options.reveal, { initial: !previous });
80
+
81
+ return pane;
82
+ }
83
+
84
+ destroy() {
85
+ this.memory?.destroy();
86
+ this.ghost?.destroy();
87
+ removeStyles();
88
+ }
89
+ }
@@ -0,0 +1,39 @@
1
+ import { CASCADE_SPREAD, FADE_DURATION } from './constants';
2
+ import {
3
+ instant,
4
+ MAX_DELAY,
5
+ MAX_DURATION,
6
+ sequence,
7
+ smart,
8
+ together,
9
+ type RevealMode,
10
+ type RevealSpec,
11
+ } from './plan';
12
+ import { sweep, SWEEP_SPREAD } from './sweep';
13
+
14
+ // `instant` is advertised as a string mode in the README and is exported as a
15
+ // `RevealSpec`, but was previously missing from this map, so passing
16
+ // `reveal="instant"` returned `undefined` and crashed `arrive()` with
17
+ // "Cannot read properties of undefined (reading 'hold')".
18
+ const NAMED = { together, sequence, smart, sweep, instant };
19
+
20
+ // Unknown strings fall back to `smart` instead of crashing — a stale template
21
+ // or a typo should quietly do the right thing, not blow up the reveal.
22
+ export const revealSpec = (mode: RevealMode = 'smart'): RevealSpec => {
23
+ if (typeof mode !== 'string') return mode;
24
+ return (NAMED as Record<string, RevealSpec>)[mode] ?? smart;
25
+ };
26
+
27
+ /** Longest a pane can take to leave under `mode`, for a framework that has to know up front. */
28
+ export function exitDuration(mode?: RevealMode) {
29
+ const spec = revealSpec(mode);
30
+ const hold = spec.hold ?? 0;
31
+ if (spec === together) return FADE_DURATION;
32
+ if (spec === sequence) return CASCADE_SPREAD + FADE_DURATION;
33
+ if (spec === smart) return hold + CASCADE_SPREAD + FADE_DURATION;
34
+ if (spec === sweep) return hold + SWEEP_SPREAD + FADE_DURATION;
35
+ if (spec.duration === 0) return 0;
36
+ // A choice can bring its own delay, as long as the cap allows
37
+ const delay = spec.choose ? MAX_DELAY : Math.min(Math.max(spec.delay ?? 0, 0), MAX_DELAY);
38
+ return hold + delay + MAX_DELAY + MAX_DURATION;
39
+ }