@alikhalilll/a-skeleton 1.1.0 → 1.2.1

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 +458 -172
  4. package/dist/index.cjs +3685 -840
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +530 -43
  7. package/dist/index.d.ts +530 -43
  8. package/dist/index.js +3666 -842
  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 +9 -3
  20. package/src/components/ASkeleton.vue +212 -113
  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 +251 -22
  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 +118 -2
  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 +9 -3
@@ -0,0 +1,418 @@
1
+ /**
2
+ * `walkStructural` — tree-shaped DOM capture for Recipe 3
3
+ * (`useSkeleton()` + `<ASkeletonLayer>`).
4
+ *
5
+ * Walks the rendered DOM top-down once. Every element is classified as either:
6
+ *
7
+ * - **container** — has element children. Output preserves its tag + original
8
+ * `class` + a comprehensive capture of layout + visual CSS. Children
9
+ * recurse via the same walker. The result *flows* through the consumer's
10
+ * own layout rules instead of being absolutely positioned to fixed
11
+ * coordinates, so the skeleton stays correct when the viewport / parent
12
+ * reflows.
13
+ * - **leaf-{block, text, image, media}** — atomic or childless element.
14
+ * Output is a `<div class="a-skel">` with captured `width`, `height`,
15
+ * plus the **same** layout + visual capture used for containers. No
16
+ * `position: absolute` — the leaf sits inside whatever space its parent
17
+ * reserves for it. Original class is preserved so utility-first CSS
18
+ * (`mt-4`, `flex-1`, `inline-block`, …) survives.
19
+ *
20
+ * Generic property set — containers and leaves use one shared list
21
+ * (`LAYOUT_PROPS` + `VISUAL_PROPS`). This mirrors clone-mode's design: every
22
+ * captured node carries the same layout context, so any element type (flex
23
+ * item, inline-block, positioned overlay, sticky bar) renders correctly
24
+ * without bespoke per-kind logic. The only leaf-specific transform is
25
+ * normalising `display: inline` → `inline-block`, since width / height don't
26
+ * apply to inline boxes.
27
+ *
28
+ * Performance: all `getBoundingClientRect()` + `getComputedStyle()` reads
29
+ * happen in a single top-down pass with no intervening writes, so the browser
30
+ * does one layout up front and serves cached values from then on (no layout
31
+ * thrashing). Same `maxDepth` / `maxNodes` / `minSize` budget as `walkDom` and
32
+ * `captureSnapshot`.
33
+ */
34
+ import type { CSSProperties } from 'vue';
35
+ import type {
36
+ ContainerNode,
37
+ LeafKind,
38
+ LeafNode,
39
+ StructuralNode,
40
+ StructuralShape,
41
+ StructuralTextLineRect,
42
+ } from '../types';
43
+ import {
44
+ captureTextLines,
45
+ collectVisibleChildren,
46
+ hasDirectText,
47
+ readComputedStyles,
48
+ } from './domRead';
49
+
50
+ export interface WalkStructuralOptions {
51
+ /** Max recursion depth. Default 12. */
52
+ maxDepth?: number;
53
+ /** Hard cap on captured nodes. Default 500. */
54
+ maxNodes?: number;
55
+ /** Skip elements smaller than this many CSS pixels (either axis). Default 4. */
56
+ minSize?: number;
57
+ }
58
+
59
+ const DEFAULT_MAX_DEPTH = 12;
60
+ const DEFAULT_MAX_NODES = 500;
61
+ const DEFAULT_MIN_SIZE = 4;
62
+
63
+ /** Tags treated as atomic leaves — never recursed into, rendered as one block. */
64
+ const ATOMIC_TAGS: ReadonlySet<string> = new Set([
65
+ 'img',
66
+ 'picture',
67
+ 'svg',
68
+ 'canvas',
69
+ 'video',
70
+ 'audio',
71
+ 'input',
72
+ 'textarea',
73
+ 'select',
74
+ 'button',
75
+ 'progress',
76
+ 'meter',
77
+ 'hr',
78
+ 'iframe',
79
+ 'object',
80
+ 'embed',
81
+ 'br',
82
+ ]);
83
+
84
+ /**
85
+ * Tags whose content is conventionally text. When the author writes
86
+ * `<h3>{{ data?.name }}</h3>` and `data` is null during loading, the
87
+ * interpolation is empty and `hasDirectText` returns false — without this
88
+ * fallback the element classifies as `leaf-block` (a generic shimmer rect)
89
+ * instead of `leaf-text` (a shimmer text bar at the element's natural
90
+ * rendered dimensions).
91
+ */
92
+ const TEXT_OWNERS: ReadonlySet<string> = new Set([
93
+ 'h1',
94
+ 'h2',
95
+ 'h3',
96
+ 'h4',
97
+ 'h5',
98
+ 'h6',
99
+ 'p',
100
+ 'span',
101
+ 'a',
102
+ 'em',
103
+ 'strong',
104
+ 'small',
105
+ 'code',
106
+ 'b',
107
+ 'i',
108
+ 'mark',
109
+ 'label',
110
+ 'caption',
111
+ 'time',
112
+ 'dt',
113
+ 'dd',
114
+ 'li',
115
+ 'th',
116
+ 'td',
117
+ 'figcaption',
118
+ 'blockquote',
119
+ 'cite',
120
+ 'q',
121
+ ]);
122
+
123
+ /**
124
+ * Layout-affecting CSS captured on **every** node. Includes display + box
125
+ * model + flex + grid + positioning so any element type renders correctly
126
+ * regardless of its role in the parent's layout (flex item, inline-block,
127
+ * absolutely-positioned overlay, sticky bar, grid cell).
128
+ *
129
+ * Width / height are intentionally NOT here — containers get sized by their
130
+ * children; leaves get explicit width / height from their `getBoundingClientRect`
131
+ * in `emitLeaf` below.
132
+ */
133
+ const LAYOUT_PROPS: ReadonlyArray<string> = [
134
+ /* Box model & flow. */
135
+ 'display',
136
+ 'position',
137
+ 'top',
138
+ 'right',
139
+ 'bottom',
140
+ 'left',
141
+ 'z-index',
142
+ 'vertical-align',
143
+ 'float',
144
+ 'clear',
145
+ 'box-sizing',
146
+
147
+ /* Margins (outer-box spacing — survives the structural layout). */
148
+ 'margin-top',
149
+ 'margin-right',
150
+ 'margin-bottom',
151
+ 'margin-left',
152
+
153
+ /* Padding (inner inset — matters for containers carrying their own surface). */
154
+ 'padding-top',
155
+ 'padding-right',
156
+ 'padding-bottom',
157
+ 'padding-left',
158
+
159
+ /* Flex — both as a container AND as an item (align-self, justify-self, flex). */
160
+ 'flex',
161
+ 'flex-direction',
162
+ 'flex-wrap',
163
+ 'flex-grow',
164
+ 'flex-shrink',
165
+ 'flex-basis',
166
+ 'justify-content',
167
+ 'align-items',
168
+ 'align-content',
169
+ 'align-self',
170
+ 'justify-self',
171
+ 'gap',
172
+ 'row-gap',
173
+ 'column-gap',
174
+ 'order',
175
+
176
+ /* Grid — both as a container AND as a cell. */
177
+ 'grid-template-columns',
178
+ 'grid-template-rows',
179
+ 'grid-template-areas',
180
+ 'grid-auto-flow',
181
+ 'grid-auto-columns',
182
+ 'grid-auto-rows',
183
+ 'grid-column',
184
+ 'grid-row',
185
+ 'grid-area',
186
+ ];
187
+
188
+ /**
189
+ * Visual signals captured on **every** node. These give each rendered
190
+ * placeholder the same surface identity the real element had — fill,
191
+ * outline, elevation, transparency, transforms.
192
+ */
193
+ const VISUAL_PROPS: ReadonlyArray<string> = [
194
+ /* Borders per edge — non-uniform borders (e.g. `border-b`) survive. */
195
+ 'border-top-width',
196
+ 'border-right-width',
197
+ 'border-bottom-width',
198
+ 'border-left-width',
199
+ 'border-top-style',
200
+ 'border-right-style',
201
+ 'border-bottom-style',
202
+ 'border-left-style',
203
+ 'border-top-color',
204
+ 'border-right-color',
205
+ 'border-bottom-color',
206
+ 'border-left-color',
207
+ 'border-top-left-radius',
208
+ 'border-top-right-radius',
209
+ 'border-bottom-right-radius',
210
+ 'border-bottom-left-radius',
211
+
212
+ /* Background. */
213
+ 'background-color',
214
+ 'background-image',
215
+ 'background-position',
216
+ 'background-size',
217
+ 'background-repeat',
218
+ 'background-origin',
219
+ 'background-clip',
220
+
221
+ /* Effects. */
222
+ 'box-shadow',
223
+ 'opacity',
224
+ 'filter',
225
+ 'backdrop-filter',
226
+ 'transform',
227
+ 'transform-origin',
228
+ 'mix-blend-mode',
229
+ ];
230
+
231
+ const ALL_PROPS: ReadonlyArray<string> = [...LAYOUT_PROPS, ...VISUAL_PROPS];
232
+
233
+ /**
234
+ * Walk `root`'s descendants and produce a frozen tree-shaped capture.
235
+ * Direct children of `root` become top-level `nodes`; recursive children
236
+ * live on their respective `ContainerNode`s.
237
+ */
238
+ export function walkStructural(
239
+ root: HTMLElement,
240
+ options: WalkStructuralOptions = {}
241
+ ): StructuralShape {
242
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
243
+ const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
244
+ const minSize = options.minSize ?? DEFAULT_MIN_SIZE;
245
+
246
+ const rootRect = root.getBoundingClientRect();
247
+ const state = { count: 0, truncated: false };
248
+ const ctx: InternalCtx = { maxDepth, maxNodes, minSize, state };
249
+
250
+ const nodes: StructuralNode[] = [];
251
+ for (let i = 0; i < root.children.length; i++) {
252
+ if (state.count >= maxNodes) {
253
+ state.truncated = true;
254
+ break;
255
+ }
256
+ const node = capture(root.children[i] as HTMLElement, rootRect, 1, ctx);
257
+ if (node) nodes.push(node);
258
+ }
259
+
260
+ return Object.freeze({
261
+ width: Math.round(rootRect.width),
262
+ height: Math.round(rootRect.height),
263
+ nodes: Object.freeze(nodes),
264
+ truncated: state.truncated,
265
+ capturedAt: Date.now(),
266
+ v: 3,
267
+ }) as StructuralShape;
268
+ }
269
+
270
+ interface InternalCtx {
271
+ maxDepth: number;
272
+ maxNodes: number;
273
+ minSize: number;
274
+ state: { count: number; truncated: boolean };
275
+ }
276
+
277
+ function capture(
278
+ el: HTMLElement,
279
+ origin: DOMRect,
280
+ depth: number,
281
+ ctx: InternalCtx
282
+ ): StructuralNode | null {
283
+ if (ctx.state.count >= ctx.maxNodes) {
284
+ ctx.state.truncated = true;
285
+ return null;
286
+ }
287
+ if (el.dataset?.skeletonIgnore !== undefined) return null;
288
+
289
+ const cs = window.getComputedStyle(el);
290
+ if (cs.display === 'none' || cs.visibility === 'hidden') return null;
291
+ const opacity = parseFloat(cs.opacity);
292
+ if (Number.isFinite(opacity) && opacity === 0) return null;
293
+
294
+ const rect = el.getBoundingClientRect();
295
+ const tag = el.tagName.toLowerCase();
296
+ const isTextOwner = TEXT_OWNERS.has(tag);
297
+
298
+ /* Text-owner tags can have zero height when their interpolation is empty
299
+ * (`<h3>{{ data?.name }}</h3>` with null data). For those, synthesize a
300
+ * height from line-height / font-size so the heading still shimmers at its
301
+ * natural rendered height instead of being filtered as sub-minSize. */
302
+ let effectiveHeight = rect.height;
303
+ if (isTextOwner && effectiveHeight < ctx.minSize) {
304
+ const lh = parseFloat(cs.lineHeight);
305
+ const fs = parseFloat(cs.fontSize);
306
+ if (Number.isFinite(lh) && lh > 0) effectiveHeight = lh;
307
+ else if (Number.isFinite(fs) && fs > 0) effectiveHeight = fs * 1.4;
308
+ }
309
+
310
+ if (rect.width < ctx.minSize || effectiveHeight < ctx.minSize) return null;
311
+
312
+ const hasStop = el.dataset?.skeletonStop !== undefined;
313
+ const isAtomic = ATOMIC_TAGS.has(tag);
314
+ const reachedDepth = depth >= ctx.maxDepth;
315
+ const childrenEls = collectVisibleChildren(el);
316
+
317
+ /* Classification — order matters: stop > atomic > childless > container. */
318
+ if (hasStop || isAtomic || reachedDepth || childrenEls.length === 0) {
319
+ return emitLeaf(el, tag, cs, rect, effectiveHeight, isAtomic, ctx);
320
+ }
321
+
322
+ /* Container path — recurse into children first; if every child was
323
+ * filtered (sub-minSize, hidden, …) we demote this node to a leaf so the
324
+ * container's own visual surface still renders. */
325
+ const children: StructuralNode[] = [];
326
+ for (const child of childrenEls) {
327
+ if (ctx.state.count >= ctx.maxNodes) {
328
+ ctx.state.truncated = true;
329
+ break;
330
+ }
331
+ const node = capture(child, origin, depth + 1, ctx);
332
+ if (node) children.push(node);
333
+ }
334
+
335
+ if (children.length === 0) {
336
+ return emitLeaf(el, tag, cs, rect, effectiveHeight, /* isAtomic */ false, ctx);
337
+ }
338
+
339
+ ctx.state.count++;
340
+ const node: ContainerNode = {
341
+ kind: 'container',
342
+ tag,
343
+ className: el.getAttribute('class') ?? '',
344
+ style: readComputedStyles(cs, ALL_PROPS),
345
+ children: Object.freeze(children),
346
+ };
347
+ return Object.freeze(node);
348
+ }
349
+
350
+ function emitLeaf(
351
+ el: HTMLElement,
352
+ tag: string,
353
+ cs: CSSStyleDeclaration,
354
+ rect: DOMRect,
355
+ effectiveHeight: number,
356
+ isAtomic: boolean,
357
+ ctx: InternalCtx
358
+ ): LeafNode | null {
359
+ const w = Math.round(rect.width);
360
+ const h = Math.round(effectiveHeight);
361
+
362
+ const leafKind = classifyLeaf(tag, el, isAtomic);
363
+
364
+ /* Same comprehensive capture used for containers, plus width/height inlined
365
+ * from the rect so the placeholder claims the right space in its parent's
366
+ * layout. Captured `display: inline` is upgraded to `inline-block` — inline
367
+ * boxes ignore width/height, and we need both to take effect on the div
368
+ * placeholder. */
369
+ const captured: Record<string, string> = { ...readComputedStyles(cs, ALL_PROPS) };
370
+ if (captured.display === 'inline') captured.display = 'inline-block';
371
+
372
+ const style: CSSProperties = {
373
+ ...captured,
374
+ width: `${w}px`,
375
+ height: `${h}px`,
376
+ };
377
+
378
+ let textLines: ReadonlyArray<StructuralTextLineRect> | undefined;
379
+ if (leafKind === 'text') {
380
+ const rawLines = captureTextLines(el, rect);
381
+ if (rawLines && rawLines.length > 0) {
382
+ /* `captureTextLines` returns origin-relative rects — here the origin
383
+ * *is* the leaf box, so each rect's coordinates are already box-local.
384
+ * Clamp to non-negative so sub-pixel rounding can't sneak a negative
385
+ * `left` into the rendered style. */
386
+ textLines = Object.freeze(
387
+ rawLines.map((r) => ({
388
+ x: Math.max(0, r.x),
389
+ y: Math.max(0, r.y),
390
+ w: r.w,
391
+ h: r.h,
392
+ }))
393
+ );
394
+ }
395
+ }
396
+
397
+ ctx.state.count++;
398
+ const node: LeafNode = {
399
+ kind: 'leaf',
400
+ leafKind,
401
+ className: el.getAttribute('class') ?? '',
402
+ style: Object.freeze(style),
403
+ textLines,
404
+ };
405
+ return Object.freeze(node);
406
+ }
407
+
408
+ function classifyLeaf(tag: string, el: HTMLElement, isAtomic: boolean): LeafKind {
409
+ if (tag === 'img' || tag === 'picture') return 'image';
410
+ if (tag === 'video') return 'image'; /* play-icon decorated via the renderer */
411
+ if (isAtomic) return 'media';
412
+ if (hasDirectText(el)) return 'text';
413
+ /* Empty text-owner (`<p>{{ data?.price }}</p>` with null data) — classify
414
+ * as text so the renderer falls back to a full-box bar instead of a
415
+ * generic shimmer block. */
416
+ if (TEXT_OWNERS.has(tag)) return 'text';
417
+ return 'block';
418
+ }
package/web-types.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/web-types",
3
3
  "name": "@alikhalilll/a-skeleton",
4
- "version": "1.1.0",
4
+ "version": "1.2.1",
5
5
  "js-types-syntax": "typescript",
6
6
  "description-markup": "markdown",
7
7
  "framework": "vue",
@@ -70,6 +70,12 @@
70
70
  "required": false,
71
71
  "description": "Skip elements smaller than this many CSS pixels on either axis during\ncapture. Default 4. Filters out hairlines / inline padding shims that\ninflate the node count without adding visual signal."
72
72
  },
73
+ {
74
+ "name": "mode",
75
+ "type": "ASkeletonMode",
76
+ "required": false,
77
+ "description": "Rendering strategy. Default `'mirror'`.\n - `'mirror'`: walks the slot's vnode tree and preserves every element\n with its real class / inline style. Pure-Vue, SSR-safe, no DOM read.\n - `'clone'`: mounts the slot off-screen once, takes a comprehensive\n `getComputedStyle()` snapshot of every leaf, then replays the\n snapshot as positioned divs carrying every captured CSS property.\n Pixel-identical to the real render, regardless of styling system\n (Tailwind, CSS-in-JS, DaisyUI, computed inline styles). Requires\n a DOM (client-side only)."
78
+ },
73
79
  {
74
80
  "name": "persist",
75
81
  "type": "boolean",
@@ -109,9 +115,9 @@
109
115
  },
110
116
  {
111
117
  "name": "shape",
112
- "type": "CachedShape",
118
+ "type": "StructuralShape",
113
119
  "required": false,
114
- "description": "Shape captured by `walkDom` / `useSkeleton`. Renders nothing when undefined."
120
+ "description": "Structural shape captured by `useSkeleton()` (Recipe 3) or `walkStructural()`\ndirectly. Renders nothing when `undefined`. The layer drops into the\nconsumer's container as a transparent shell — captured containers preserve\ntheir tag/class/layout so the skeleton flows naturally inside the\nsurrounding layout rather than being absolutely positioned."
115
121
  }
116
122
  ]
117
123
  },