@alikhalilll/a-skeleton 1.0.0 → 1.2.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 (53) hide show
  1. package/.media/hero.png +0 -0
  2. package/.media/hero.svg +232 -0
  3. package/README.md +459 -170
  4. package/dist/index.cjs +3696 -828
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +534 -43
  7. package/dist/index.d.ts +534 -43
  8. package/dist/index.js +3677 -830
  9. package/dist/index.js.map +1 -1
  10. package/dist/nuxt/index.cjs +16 -1
  11. package/dist/nuxt/index.cjs.map +1 -1
  12. package/dist/nuxt/index.js +16 -1
  13. package/dist/nuxt/index.js.map +1 -1
  14. package/dist/resolver/index.cjs +16 -1
  15. package/dist/resolver/index.cjs.map +1 -1
  16. package/dist/resolver/index.js +16 -1
  17. package/dist/resolver/index.js.map +1 -1
  18. package/dist/styles.css +56 -11
  19. package/package.json +8 -2
  20. package/src/components/ASkeleton.vue +216 -98
  21. package/src/components/ASkeletonClone.vue +106 -0
  22. package/src/components/ASkeletonLayer.vue +20 -32
  23. package/src/components/CloneNode.ts +161 -0
  24. package/src/components/StructuralLayerNode.ts +157 -0
  25. package/src/components/icons.ts +45 -0
  26. package/src/components/variants/ASkeletonArticle.vue +33 -0
  27. package/src/components/variants/ASkeletonAvatar.vue +42 -0
  28. package/src/components/variants/ASkeletonButton.vue +37 -0
  29. package/src/components/variants/ASkeletonCard.vue +47 -0
  30. package/src/components/variants/ASkeletonChart.vue +56 -0
  31. package/src/components/variants/ASkeletonChip.vue +32 -0
  32. package/src/components/variants/ASkeletonDivider.vue +26 -0
  33. package/src/components/variants/ASkeletonForm.vue +32 -0
  34. package/src/components/variants/ASkeletonHeading.vue +47 -0
  35. package/src/components/variants/ASkeletonImage.vue +57 -0
  36. package/src/components/variants/ASkeletonInput.vue +33 -0
  37. package/src/components/variants/ASkeletonListItem.vue +40 -0
  38. package/src/components/variants/ASkeletonTable.vue +49 -0
  39. package/src/components/variants/ASkeletonText.vue +49 -0
  40. package/src/components/variants/ASkeletonVideo.vue +55 -0
  41. package/src/composables/useShapeProbe.ts +33 -9
  42. package/src/composables/useSkeleton.ts +33 -21
  43. package/src/composables/useSkeletonCache.ts +282 -24
  44. package/src/index.ts +48 -2
  45. package/src/nuxt/index.ts +16 -0
  46. package/src/resolver/index.ts +16 -0
  47. package/src/types.ts +124 -5
  48. package/src/utils/buildStructuralSkeleton.ts +400 -103
  49. package/src/utils/captureStyles.ts +378 -0
  50. package/src/utils/domRead.ts +143 -0
  51. package/src/utils/walkDom.ts +261 -16
  52. package/src/utils/walkStructural.ts +418 -0
  53. package/web-types.json +10 -4
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Comprehensive style-capture engine.
3
+ *
4
+ * Walks the slot's rendered DOM after mount and captures **every visible CSS
5
+ * property** of every element it encounters, plus geometry, into a frozen
6
+ * snapshot tree. The snapshot is replayed by `<ASkeletonClone>` as a tree
7
+ * of positioned divs each carrying its captured inline style.
8
+ *
9
+ * Why this exists (vs the DOM-mirror walker in `buildStructuralSkeleton.ts`):
10
+ *
11
+ * - DOM-mirror preserves the slot's vnode tree and inherits styling from the
12
+ * consumer's `class` / inline `style` attributes. That works for *static*
13
+ * styles, but it can't see styles applied via:
14
+ * - JavaScript-set inline style (refs, watchers, animation libs)
15
+ * - CSS-in-JS runtimes that hash class names per instance
16
+ * - DaisyUI / shadcn / custom CSS where the computed result differs
17
+ * from what the class string implies
18
+ * - Scoped styles compiled with content-hash data attributes
19
+ *
20
+ * - `captureSnapshot` reads `getComputedStyle()` for each element — the
21
+ * **final** computed style after the cascade has resolved. Replaying that
22
+ * produces a pixel-identical surface no matter what styling system the
23
+ * consumer uses.
24
+ *
25
+ * Cost is bounded by the same `maxNodes` / `maxDepth` / `minSize` filters
26
+ * as the legacy `walkDom` capture. The whole pass is a single top-down read
27
+ * with no DOM writes between, so the browser does one layout up front.
28
+ */
29
+
30
+ import {
31
+ captureTextLines,
32
+ collectVisibleChildren,
33
+ hasDirectText,
34
+ readComputedStyles,
35
+ type TextLineRect,
36
+ } from './domRead';
37
+
38
+ export type { TextLineRect };
39
+
40
+ /** A captured element — geometry + comprehensive style snapshot + children. */
41
+ export interface CapturedNode {
42
+ /** Tag name lowercased — used by the replay to decide content-type treatment. */
43
+ tag: string;
44
+ /** Root-relative position + size in CSS pixels. */
45
+ x: number;
46
+ y: number;
47
+ w: number;
48
+ h: number;
49
+ /**
50
+ * Frozen, ready-to-apply `style` object. Only non-default visual
51
+ * properties are included — the snapshot for a default `<div>` is tiny.
52
+ */
53
+ style: Readonly<Record<string, string>>;
54
+ /**
55
+ * Content classification — drives how `<ASkeletonClone>` renders the leaf:
56
+ * - `text` → shimmer text bar (optionally with per-line rects)
57
+ * - `image` → solid surface with image-placeholder icon
58
+ * - `video` → solid surface with play-icon
59
+ * - `media` → atomic block (svg/canvas/iframe — no icon)
60
+ * - `block` → opaque shimmer block (default for unrecognised leaves)
61
+ * - `container` → has children; rendered as a positioned wrapper
62
+ */
63
+ kind: 'text' | 'image' | 'video' | 'media' | 'block' | 'container';
64
+ /**
65
+ * Per-line text rects (only set when `kind === 'text'`). Replaces the
66
+ * single-rect bar with one bar per rendered text line at the exact width
67
+ * of that line — handles wrapping, RTL, centered headings 1:1.
68
+ */
69
+ textLines?: ReadonlyArray<TextLineRect>;
70
+ /** Children (only set when `kind === 'container'`). */
71
+ children?: ReadonlyArray<CapturedNode>;
72
+ }
73
+
74
+ export interface CaptureSnapshot {
75
+ /** Overall bounding box. */
76
+ width: number;
77
+ height: number;
78
+ /** Top-level captured nodes (siblings of the root's direct children). */
79
+ nodes: ReadonlyArray<CapturedNode>;
80
+ /** True if `maxNodes` was hit and the walk bailed out early. */
81
+ truncated: boolean;
82
+ /** When the snapshot was taken (ms since epoch). For cache invalidation policies. */
83
+ capturedAt: number;
84
+ }
85
+
86
+ export interface CaptureOptions {
87
+ /** Max recursion depth. Default 12. */
88
+ maxDepth?: number;
89
+ /** Hard cap on captured nodes. Default 800. */
90
+ maxNodes?: number;
91
+ /** Skip elements smaller than this many CSS pixels (either axis). Default 4. */
92
+ minSize?: number;
93
+ /**
94
+ * When true, capture child elements even inside leaves that we'd normally
95
+ * treat atomically. Default false (atomic = single block).
96
+ */
97
+ walkAtomic?: boolean;
98
+ }
99
+
100
+ const DEFAULT_MAX_DEPTH = 12;
101
+ const DEFAULT_MAX_NODES = 800;
102
+ const DEFAULT_MIN_SIZE = 4;
103
+
104
+ /** Tags treated as atomic leaves — no recursion into their contents. */
105
+ const ATOMIC_TAGS = new Set([
106
+ 'img',
107
+ 'svg',
108
+ 'canvas',
109
+ 'video',
110
+ 'audio',
111
+ 'input',
112
+ 'textarea',
113
+ 'select',
114
+ 'progress',
115
+ 'meter',
116
+ 'hr',
117
+ 'iframe',
118
+ 'object',
119
+ 'embed',
120
+ 'picture',
121
+ 'br',
122
+ ]);
123
+
124
+ /** Tags whose content is text — captured as text bars (per-line via Range). */
125
+ const TEXT_OWNERS = new Set([
126
+ 'p',
127
+ 'h1',
128
+ 'h2',
129
+ 'h3',
130
+ 'h4',
131
+ 'h5',
132
+ 'h6',
133
+ 'span',
134
+ 'a',
135
+ 'em',
136
+ 'strong',
137
+ 'small',
138
+ 'code',
139
+ 'b',
140
+ 'i',
141
+ 'mark',
142
+ 'label',
143
+ 'caption',
144
+ 'time',
145
+ 'dt',
146
+ 'dd',
147
+ 'li',
148
+ 'th',
149
+ 'td',
150
+ 'figcaption',
151
+ 'blockquote',
152
+ 'cite',
153
+ 'q',
154
+ ]);
155
+
156
+ /**
157
+ * Visual CSS properties read from `getComputedStyle()`. Property listed here
158
+ * is included in the captured snapshot **only when its value differs from the
159
+ * skip set** — so a plain unstyled `<div>` produces an empty style object.
160
+ *
161
+ * Listed in the order they're written into the snapshot's `style` object so
162
+ * the replay is deterministic across captures.
163
+ */
164
+ const VISUAL_PROPS = [
165
+ /* Box model — padding only (margin doesn't apply to absolute children). */
166
+ 'padding-top',
167
+ 'padding-right',
168
+ 'padding-bottom',
169
+ 'padding-left',
170
+
171
+ /* Border — per edge so non-uniform borders survive. */
172
+ 'border-top-width',
173
+ 'border-right-width',
174
+ 'border-bottom-width',
175
+ 'border-left-width',
176
+ 'border-top-style',
177
+ 'border-right-style',
178
+ 'border-bottom-style',
179
+ 'border-left-style',
180
+ 'border-top-color',
181
+ 'border-right-color',
182
+ 'border-bottom-color',
183
+ 'border-left-color',
184
+ 'border-top-left-radius',
185
+ 'border-top-right-radius',
186
+ 'border-bottom-right-radius',
187
+ 'border-bottom-left-radius',
188
+
189
+ /* Background. */
190
+ 'background-color',
191
+ 'background-image',
192
+ 'background-position',
193
+ 'background-size',
194
+ 'background-repeat',
195
+ 'background-origin',
196
+ 'background-clip',
197
+
198
+ /* Effects. */
199
+ 'box-shadow',
200
+ 'opacity',
201
+ 'filter',
202
+ 'backdrop-filter',
203
+ 'transform',
204
+ 'transform-origin',
205
+ 'mix-blend-mode',
206
+
207
+ /* Typography (only meaningful for text leaves but harmless to read for all). */
208
+ 'font-family',
209
+ 'font-size',
210
+ 'font-weight',
211
+ 'font-style',
212
+ 'line-height',
213
+ 'letter-spacing',
214
+ 'text-align',
215
+ 'text-transform',
216
+ 'text-decoration-line',
217
+ 'text-decoration-color',
218
+ ];
219
+
220
+ /**
221
+ * Snapshot the rendered DOM under `root`, returning a frozen tree of every
222
+ * visible element + its computed visual styles. Replaying the snapshot
223
+ * produces a surface visually identical to `root`.
224
+ */
225
+ export function captureSnapshot(root: HTMLElement, options: CaptureOptions = {}): CaptureSnapshot {
226
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
227
+ const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
228
+ const minSize = options.minSize ?? DEFAULT_MIN_SIZE;
229
+ const walkAtomic = options.walkAtomic ?? false;
230
+
231
+ const rootRect = root.getBoundingClientRect();
232
+ const state = { count: 0, truncated: false };
233
+ const nodes: CapturedNode[] = [];
234
+
235
+ for (let i = 0; i < root.children.length; i++) {
236
+ if (state.count >= maxNodes) {
237
+ state.truncated = true;
238
+ break;
239
+ }
240
+ const node = capture(root.children[i] as HTMLElement, rootRect, 1, {
241
+ maxDepth,
242
+ maxNodes,
243
+ minSize,
244
+ walkAtomic,
245
+ state,
246
+ });
247
+ if (node) nodes.push(node);
248
+ }
249
+
250
+ return Object.freeze({
251
+ width: Math.round(rootRect.width),
252
+ height: Math.round(rootRect.height),
253
+ nodes: Object.freeze(nodes),
254
+ truncated: state.truncated,
255
+ capturedAt: Date.now(),
256
+ });
257
+ }
258
+
259
+ interface InternalCtx {
260
+ maxDepth: number;
261
+ maxNodes: number;
262
+ minSize: number;
263
+ walkAtomic: boolean;
264
+ state: { count: number; truncated: boolean };
265
+ }
266
+
267
+ function capture(
268
+ el: HTMLElement,
269
+ origin: DOMRect,
270
+ depth: number,
271
+ ctx: InternalCtx
272
+ ): CapturedNode | null {
273
+ if (ctx.state.count >= ctx.maxNodes) {
274
+ ctx.state.truncated = true;
275
+ return null;
276
+ }
277
+ if (el.dataset?.skeletonIgnore !== undefined) return null;
278
+
279
+ const cs = window.getComputedStyle(el);
280
+ if (cs.display === 'none' || cs.visibility === 'hidden') return null;
281
+ /* Opacity zero — skip (the element is invisible). */
282
+ const o = parseFloat(cs.opacity);
283
+ if (Number.isFinite(o) && o === 0) return null;
284
+
285
+ const rect = el.getBoundingClientRect();
286
+ const tag = el.tagName.toLowerCase();
287
+ const isTextOwnerTag = TEXT_OWNERS.has(tag);
288
+
289
+ /* Text-owner tags can have zero height when their interpolation is empty
290
+ * (e.g. `<h3>{{ data?.name }}</h3>` with `data === null`). The base
291
+ * `minSize` filter would drop them, leaving the heading invisible in the
292
+ * clone replay. For text-owners only, synthesize a height from the
293
+ * element's `line-height` (or `font-size * 1.4` as a fallback) so the
294
+ * fallback bar still renders at the heading's natural rendered height. */
295
+ let effectiveHeight = rect.height;
296
+ if (isTextOwnerTag && effectiveHeight < ctx.minSize) {
297
+ const lh = parseFloat(cs.lineHeight);
298
+ const fs = parseFloat(cs.fontSize);
299
+ if (Number.isFinite(lh) && lh > 0) effectiveHeight = lh;
300
+ else if (Number.isFinite(fs) && fs > 0) effectiveHeight = fs * 1.4;
301
+ }
302
+
303
+ if (rect.width < ctx.minSize || effectiveHeight < ctx.minSize) return null;
304
+
305
+ const x = Math.round(rect.left - origin.left);
306
+ const y = Math.round(rect.top - origin.top);
307
+ const w = Math.round(rect.width);
308
+ const h = Math.round(effectiveHeight);
309
+
310
+ const style = readComputedStyles(cs, VISUAL_PROPS);
311
+
312
+ /* Classify the leaf — drives how `<ASkeletonClone>` renders this node. */
313
+ const isAtomic = ATOMIC_TAGS.has(tag);
314
+ const hasOwnText = hasDirectText(el);
315
+ const childrenEls = collectVisibleChildren(el);
316
+
317
+ let kind: CapturedNode['kind'];
318
+
319
+ if (tag === 'img' || tag === 'picture') {
320
+ kind = 'image';
321
+ } else if (tag === 'video') {
322
+ kind = 'video';
323
+ } else if (isAtomic) {
324
+ kind = 'media';
325
+ } else if (childrenEls.length === 0 && (hasOwnText || isTextOwnerTag)) {
326
+ /* Text-owner tags with no children (e.g. `<h3>{{ data?.name }}</h3>` with
327
+ * null data) classify as text even though their interpolation is empty —
328
+ * the renderer falls back to a single full-box bar at the element's
329
+ * natural rendered dimensions, so the heading shimmers at the right
330
+ * height instead of rendering as a generic block. */
331
+ kind = 'text';
332
+ } else if (childrenEls.length === 0) {
333
+ kind = 'block';
334
+ } else {
335
+ kind = 'container';
336
+ }
337
+
338
+ /* For text leaves, capture per-line rects via Range API. */
339
+ let textLines: CapturedNode['textLines'];
340
+ if (kind === 'text') {
341
+ textLines = captureTextLines(el, origin);
342
+ }
343
+
344
+ /* Recurse into children for containers (and atomic if walkAtomic). */
345
+ let children: CapturedNode[] | undefined;
346
+ if (kind === 'container' && depth < ctx.maxDepth) {
347
+ children = [];
348
+ for (const c of childrenEls) {
349
+ if (ctx.state.count >= ctx.maxNodes) {
350
+ ctx.state.truncated = true;
351
+ break;
352
+ }
353
+ const childNode = capture(c, origin, depth + 1, ctx);
354
+ if (childNode) children.push(childNode);
355
+ }
356
+ if (children.length === 0) {
357
+ /* All children filtered — degrade to block so the surface still renders. */
358
+ children = undefined;
359
+ kind = style && Object.keys(style).length > 0 ? 'block' : 'container';
360
+ }
361
+ } else if ((kind === 'container' && depth >= ctx.maxDepth) || (isAtomic && ctx.walkAtomic)) {
362
+ kind = 'block';
363
+ }
364
+
365
+ ctx.state.count++;
366
+
367
+ return Object.freeze({
368
+ tag,
369
+ x,
370
+ y,
371
+ w,
372
+ h,
373
+ style,
374
+ kind,
375
+ textLines: textLines ? Object.freeze(textLines) : undefined,
376
+ children: children ? Object.freeze(children) : undefined,
377
+ });
378
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Shared DOM-read helpers used by both capture strategies:
3
+ * - `captureStyles.ts` (clone-mode comprehensive style snapshot)
4
+ * - `walkStructural.ts` (Recipe 3 tree-shaped capture)
5
+ *
6
+ * One source of truth for "what counts as a default CSS value", how to read a
7
+ * computed-style subset into a frozen camelCased object, and how to measure
8
+ * per-line text rectangles. Keeping both walkers behind these helpers prevents
9
+ * silent drift between the two pipelines.
10
+ */
11
+
12
+ /** A single rendered text-line rectangle, expressed in root-relative pixels. */
13
+ export interface TextLineRect {
14
+ x: number;
15
+ y: number;
16
+ w: number;
17
+ h: number;
18
+ }
19
+
20
+ /**
21
+ * Computed values that count as "default" and shouldn't be persisted. Stripped
22
+ * before a captured `style` object lands on a node so a plain unstyled element
23
+ * produces an empty style — keeps snapshots small and the rendered DOM clean.
24
+ */
25
+ export const SKIP_VALUES: ReadonlySet<string> = new Set([
26
+ 'none',
27
+ 'normal',
28
+ 'auto',
29
+ '0px',
30
+ '0',
31
+ '0 0',
32
+ '0% 0%',
33
+ '0px 0px',
34
+ '0deg',
35
+ '0s',
36
+ 'visible',
37
+ 'static',
38
+ 'transparent',
39
+ 'rgba(0, 0, 0, 0)',
40
+ 'rgb(0, 0, 0, 0)',
41
+ 'initial',
42
+ 'inherit',
43
+ 'unset',
44
+ 'currentcolor',
45
+ ]);
46
+
47
+ /**
48
+ * Read the subset of `props` from `cs` and return a frozen camelCased style
49
+ * map with the SKIP_VALUES entries omitted. `opacity: 1` is treated as default
50
+ * (matches CSS' initial value).
51
+ */
52
+ export function readComputedStyles(
53
+ cs: CSSStyleDeclaration,
54
+ props: ReadonlyArray<string>
55
+ ): Readonly<Record<string, string>> {
56
+ const out: Record<string, string> = {};
57
+ for (const prop of props) {
58
+ const val = cs.getPropertyValue(prop).trim();
59
+ if (!val) continue;
60
+ if (SKIP_VALUES.has(val.toLowerCase())) continue;
61
+ if (prop === 'opacity' && (val === '1' || parseFloat(val) === 1)) continue;
62
+ out[camelCase(prop)] = val;
63
+ }
64
+ return Object.freeze(out);
65
+ }
66
+
67
+ export function camelCase(prop: string): string {
68
+ return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
69
+ }
70
+
71
+ /** True when the element has at least one non-whitespace direct text-node child. */
72
+ export function hasDirectText(el: Element): boolean {
73
+ for (let i = 0; i < el.childNodes.length; i++) {
74
+ const node = el.childNodes[i];
75
+ if (node.nodeType === Node.TEXT_NODE && (node.textContent ?? '').trim().length > 0) {
76
+ return true;
77
+ }
78
+ }
79
+ return false;
80
+ }
81
+
82
+ /** Element children, with `data-skeleton-ignore` descendants filtered out. */
83
+ export function collectVisibleChildren(el: Element): HTMLElement[] {
84
+ const out: HTMLElement[] = [];
85
+ for (let i = 0; i < el.children.length; i++) {
86
+ const c = el.children[i] as HTMLElement;
87
+ if (c.dataset?.skeletonIgnore !== undefined) continue;
88
+ out.push(c);
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * Per-line text rects via `Range.getClientRects()`. Returns one rect per visual
95
+ * line — exact left/width for each line so wrapped paragraphs, RTL last-line
96
+ * positions, centered headings replay 1:1 without heuristics. Returns
97
+ * `undefined` if the element has no direct text or the Range API isn't usable.
98
+ *
99
+ * Two rects on the same baseline AND horizontally touching (gap ≤ 2 px) are
100
+ * merged so inline spans on the same line collapse to one bar. Same-baseline
101
+ * rects on different visual lines (rare; float-into-paragraph layouts) won't
102
+ * touch horizontally and stay separate.
103
+ */
104
+ export function captureTextLines(el: Element, origin: DOMRect): TextLineRect[] | undefined {
105
+ if (typeof document === 'undefined' || typeof document.createRange !== 'function')
106
+ return undefined;
107
+ let range: Range;
108
+ try {
109
+ range = document.createRange();
110
+ range.selectNodeContents(el);
111
+ } catch {
112
+ return undefined;
113
+ }
114
+ const rects = range.getClientRects();
115
+ if (!rects || rects.length === 0) return undefined;
116
+
117
+ const merged: TextLineRect[] = [];
118
+ for (let i = 0; i < rects.length; i++) {
119
+ const r = rects[i];
120
+ if (r.width <= 0 || r.height <= 0) continue;
121
+ const lr: TextLineRect = {
122
+ x: Math.round(r.left - origin.left),
123
+ y: Math.round(r.top - origin.top),
124
+ w: Math.round(r.width),
125
+ h: Math.round(r.height),
126
+ };
127
+ const last = merged[merged.length - 1];
128
+ const sameLine = last && Math.abs(last.y - lr.y) <= 1 && Math.abs(last.h - lr.h) <= 1;
129
+ /* Touching = gap between the trailing edge of `last` and the leading
130
+ * edge of `lr` is at most 2 px (one rounding slack per end). */
131
+ const touching =
132
+ sameLine && Math.max(last!.x, lr.x) - Math.min(last!.x + last!.w, lr.x + lr.w) <= 2;
133
+ if (touching) {
134
+ const leftEdge = Math.min(last!.x, lr.x);
135
+ const rightEdge = Math.max(last!.x + last!.w, lr.x + lr.w);
136
+ last!.x = leftEdge;
137
+ last!.w = rightEdge - leftEdge;
138
+ } else {
139
+ merged.push(lr);
140
+ }
141
+ }
142
+ return merged.length > 0 ? merged : undefined;
143
+ }