@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
@@ -2,6 +2,7 @@ import {
2
2
  Comment,
3
3
  Fragment,
4
4
  Text,
5
+ cloneVNode,
5
6
  h,
6
7
  type VNode,
7
8
  type VNodeArrayChildren,
@@ -9,58 +10,161 @@ import {
9
10
  } from 'vue';
10
11
 
11
12
  /**
12
- * Atomic HTML tags — rendered as a single skeleton block. Their own class/style
13
- * is preserved so Tailwind utilities (`size-16`, `rounded-full`, …) carry the
14
- * dimensions across without us needing to measure.
13
+ * Atomic tags — rendered as a single shimmer block. Their internal rendering
14
+ * is opaque to vnode walking (no meaningful child structure for us to mirror).
15
+ * Their own `class` + `style` are preserved so Tailwind utilities like
16
+ * `size-16` and `rounded-full` still drive the dimensions.
17
+ *
18
+ * Note: `button`, `a`, `label` are deliberately NOT atomic — they're treated
19
+ * as containers so their real `background-color` / `border` / shadow survive
20
+ * (we add `.a-skel-block` to atomics, which paints a skeleton gradient that
21
+ * would override Tailwind's `bg-emerald-600` and friends). Their text content
22
+ * is recursed and replaced with a shimmer span inside the real button shape.
15
23
  */
16
24
  const ATOMIC_TAGS = new Set([
17
25
  'img',
18
26
  'svg',
19
27
  'canvas',
20
28
  'video',
29
+ 'audio',
21
30
  'input',
22
31
  'textarea',
23
32
  'select',
24
- 'button',
25
33
  'progress',
26
34
  'meter',
27
35
  'hr',
36
+ 'iframe',
37
+ 'object',
38
+ 'embed',
39
+ 'picture',
28
40
  ]);
29
41
 
30
- /** Single-line text containers — produce one bar. */
31
- const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
42
+ /**
43
+ * SVG child tags that should never be walked by the structural skeleton —
44
+ * SVG interior elements use a different coordinate space (`x`/`y` are
45
+ * attributes, not CSS), so emitting `.a-skel-block` divs at their tag would
46
+ * yield non-rendering elements. When we see one of these, the parent `<svg>`
47
+ * has already been treated as an atomic block — we just return null/skip.
48
+ */
49
+ const SVG_INTERIOR_TAGS = new Set([
50
+ 'circle',
51
+ 'rect',
52
+ 'path',
53
+ 'line',
54
+ 'polyline',
55
+ 'polygon',
56
+ 'ellipse',
57
+ 'g',
58
+ 'defs',
59
+ 'clippath',
60
+ 'mask',
61
+ 'pattern',
62
+ 'lineargradient',
63
+ 'radialgradient',
64
+ 'stop',
65
+ 'use',
66
+ 'symbol',
67
+ 'foreignobject',
68
+ 'text',
69
+ 'tspan',
70
+ 'textpath',
71
+ 'marker',
72
+ 'filter',
73
+ 'feblend',
74
+ 'fecolormatrix',
75
+ 'fegaussianblur',
76
+ 'feoffset',
77
+ 'fedropshadow',
78
+ 'femerge',
79
+ 'femergenode',
80
+ ]);
32
81
 
33
- /** Multi-line text containers — produce N bars with a shortened last line. */
34
- const PARAGRAPH_TAGS = new Set(['p', 'blockquote']);
82
+ /**
83
+ * Table-structural tags — preserve them as-is. Their semantics (`display:
84
+ * table-*`) are critical for layout, and replacing them with `<div>` would
85
+ * break the grid. Children are recursed normally.
86
+ */
87
+ const TABLE_TAGS = new Set([
88
+ 'table',
89
+ 'thead',
90
+ 'tbody',
91
+ 'tfoot',
92
+ 'tr',
93
+ 'td',
94
+ 'th',
95
+ 'caption',
96
+ 'colgroup',
97
+ 'col',
98
+ ]);
99
+
100
+ /**
101
+ * List-structural tags — `<ul>`, `<ol>`, `<li>`, `<dl>`, `<dt>`, `<dd>`.
102
+ * Preserved as containers; `<li>` recurses into its text/children so each
103
+ * list item gets the right shimmer treatment.
104
+ */
105
+ const LIST_TAGS = new Set(['ul', 'ol', 'li', 'dl', 'dt', 'dd']);
35
106
 
36
- /** Inline text — single bar, but inherits parent font sizing. */
37
- const INLINE_TEXT_TAGS = new Set([
107
+ /**
108
+ * Tags whose content is conventionally text. When the author writes
109
+ * `<h3>{{ data?.name }}</h3>` and `data` is null during loading, the
110
+ * interpolation produces no children — the walker would otherwise emit an
111
+ * empty `<h3></h3>` and no skeleton bar shows. For these tags we emit a
112
+ * synthetic placeholder text-content span so the bar renders at the tag's
113
+ * natural rendered width (Tailwind sizing on the tag still drives height).
114
+ */
115
+ const TEXT_OWNER_TAGS = new Set([
116
+ 'h1',
117
+ 'h2',
118
+ 'h3',
119
+ 'h4',
120
+ 'h5',
121
+ 'h6',
122
+ 'p',
38
123
  'span',
39
124
  'a',
40
- 'small',
41
- 'strong',
42
125
  'em',
126
+ 'strong',
127
+ 'small',
43
128
  'code',
44
- 'time',
45
- 'label',
46
129
  'b',
47
130
  'i',
48
131
  'mark',
132
+ 'label',
133
+ 'caption',
134
+ 'time',
135
+ 'dt',
136
+ 'dd',
137
+ 'li',
138
+ 'th',
139
+ 'td',
140
+ 'figcaption',
141
+ 'blockquote',
142
+ 'cite',
143
+ 'q',
49
144
  ]);
50
145
 
146
+ /**
147
+ * Non-breaking-space placeholder for empty text-owners. ~24 chars wide is
148
+ * enough to read as "a line of text" in most fonts while still being short
149
+ * enough that the rendered bar doesn't wrap in narrow containers.
150
+ */
151
+ const TEXT_PLACEHOLDER = ' '.repeat(24);
152
+
51
153
  export interface BuildOptions {
154
+ /** Animation class applied to every emitted shimmer surface. `null` disables animation. */
52
155
  animationClass: string | null;
53
- /** Max recursion depth — guards runaway templates. Default 8. */
156
+ /** Max recursion depth — guards runaway templates. Default 16. */
54
157
  maxDepth?: number;
55
158
  /**
56
- * Hard cap on emitted skeleton nodes. Default 300. A 200-row table doesn't
57
- * need 200 distinct skeleton rows on first paint; cap and stop early.
159
+ * Hard cap on emitted skeleton nodes. Default 600. The walk stops emitting
160
+ * past this cap; the partial tree is still valid (it just won't include the
161
+ * leaves beyond the budget).
58
162
  */
59
163
  maxNodes?: number;
60
164
  }
61
165
 
62
- const DEFAULT_MAX_DEPTH = 8;
63
- const DEFAULT_MAX_NODES = 300;
166
+ const DEFAULT_MAX_DEPTH = 16;
167
+ const DEFAULT_MAX_NODES = 600;
64
168
 
65
169
  interface WalkState {
66
170
  emitted: number;
@@ -68,17 +172,29 @@ interface WalkState {
68
172
  }
69
173
 
70
174
  /**
71
- * Walk a slot's vnode tree and produce a skeleton that mirrors its rendered
72
- * structure: same wrapping tags, same `class` strings (so flex/grid/spacing/
73
- * sizing utilities still apply), but text/atomic leaves replaced with shimmer
74
- * blocks. The result renders correctly on the FIRST paint without any DOM
75
- * measurement, as long as the slot's template renders structure even when its
76
- * data is empty (use `v-if`/`v-else` to swap content, not to gate the wrapper).
175
+ * Walk a slot's vnode tree and produce a **DOM-mirror skeleton**: every element
176
+ * is preserved (same tag, same `class`, same inline `style`), and only the
177
+ * content is replaced. The output is structurally identical to what the real
178
+ * component would render Tailwind utilities for layout, spacing, sizing,
179
+ * backgrounds and shadows all carry through. The CSS does the work of making
180
+ * it *look* like a skeleton.
77
181
  *
78
- * Performance: `maxNodes` caps the work. When the cap is hit we stop emitting
79
- * the caller still gets a valid skeleton, just clipped at the budget. A 1000-
80
- * row list renders ~300 skeleton rows on first paint and then the measured cache
81
- * takes over for subsequent loads.
182
+ * Replacement rules:
183
+ * - **Raw text** (e.g. interpolations, static strings) is wrapped in
184
+ * `<span class="a-skel-text-content">…the real text…</span>`. The text is
185
+ * kept as the span's children so the inline layout reserves its real
186
+ * rendered width — but the glyphs are painted transparent and a skeleton
187
+ * gradient is painted in their place. Multi-line text wraps naturally,
188
+ * producing one shimmer rect per visual line at the exact rendered position.
189
+ * - **Atomic / interactive tags** (`img`, `svg`, `button`, `input`, …) are
190
+ * replaced by a `<div class="a-skel-block">` carrying the original element's
191
+ * `class` and `style`, so dimensions and shapes (size, border-radius, etc.)
192
+ * still drive the layout.
193
+ * - **Container elements** (`div`, `section`, `h1`, `p`, `a`, `span`, …) are
194
+ * preserved as the same tag with the same `class` and `style`; we recurse
195
+ * into their children.
196
+ * - **Component vnodes** can't be introspected at render time, so we emit a
197
+ * single `<div class="a-skel-block">` carrying their outer `class` / `style`.
82
198
  */
83
199
  export function buildStructuralSkeleton(
84
200
  vnodes: VNodeChild | VNodeArrayChildren | undefined | null,
@@ -110,9 +226,11 @@ function walk(
110
226
  return;
111
227
  }
112
228
 
229
+ /* Bare string / number content from interpolations. Wrap in the text-content
230
+ * span so the real text drives the rendered width. */
113
231
  if (typeof input === 'string' || typeof input === 'number') {
114
- const str = String(input).trim();
115
- if (str) push(out, textBar(opts.animationClass), state);
232
+ const str = String(input);
233
+ if (str.trim()) push(out, textContentSpan(str, opts.animationClass), state);
116
234
  return;
117
235
  }
118
236
 
@@ -122,8 +240,8 @@ function walk(
122
240
  if (type === Comment) return;
123
241
 
124
242
  if (type === Text) {
125
- const t = typeof v.children === 'string' ? v.children.trim() : '';
126
- if (t) push(out, textBar(opts.animationClass), state);
243
+ const text = typeof v.children === 'string' ? v.children : '';
244
+ if (text.trim()) push(out, textContentSpan(text, opts.animationClass), state);
127
245
  return;
128
246
  }
129
247
 
@@ -133,18 +251,66 @@ function walk(
133
251
  }
134
252
 
135
253
  if (typeof type === 'string') {
136
- push(out, transformElement(v, type.toLowerCase(), opts, depth, max, state), state);
254
+ const tag = type.toLowerCase();
255
+
256
+ /* SVG interior — bail out. The parent <svg> is treated atomically; its
257
+ * children use a non-CSS coordinate space and shouldn't be transformed. */
258
+ if (SVG_INTERIOR_TAGS.has(tag)) return;
259
+
260
+ /* Author opt-outs — applied BEFORE the tag-based dispatch so they win.
261
+ * Five attributes supported:
262
+ * data-skeleton-ignore — render verbatim (chrome).
263
+ * data-skeleton-stop — single block, no recursion.
264
+ * data-skeleton-text — force inline text-bar treatment.
265
+ * data-skeleton-block — force atomic shimmer-block treatment.
266
+ * data-skeleton-variant — render the named variant primitive. */
267
+ const props = v.props ?? {};
268
+ if (props['data-skeleton-ignore'] !== undefined) {
269
+ push(out, cloneVNode(v), state);
270
+ return;
271
+ }
272
+ if (props['data-skeleton-stop'] !== undefined) {
273
+ push(
274
+ out,
275
+ h('div', {
276
+ class: ['a-skel-block', v.props?.class, opts.animationClass],
277
+ style: v.props?.style as Record<string, string>,
278
+ 'aria-hidden': 'true',
279
+ }),
280
+ state
281
+ );
282
+ return;
283
+ }
284
+ if (props['data-skeleton-text'] !== undefined) {
285
+ /* Force this leaf into text-bar treatment regardless of its tag. */
286
+ push(out, textContentSpan(' ', opts.animationClass), state);
287
+ return;
288
+ }
289
+ if (props['data-skeleton-block'] !== undefined) {
290
+ push(
291
+ out,
292
+ h('div', {
293
+ class: ['a-skel-block', v.props?.class, opts.animationClass],
294
+ style: v.props?.style as Record<string, string>,
295
+ 'aria-hidden': 'true',
296
+ }),
297
+ state
298
+ );
299
+ return;
300
+ }
301
+ push(out, transformElement(v, tag, opts, depth, max, state), state);
137
302
  return;
138
303
  }
139
304
 
140
305
  /* Component vnode — we can't introspect its template, so render an opaque
141
- * skeleton block carrying any utility classes the user attached to it. */
306
+ * shimmer block carrying any outer class / style the user attached. */
142
307
  if (typeof type === 'object' || typeof type === 'function') {
143
308
  push(
144
309
  out,
145
310
  h('div', {
146
311
  class: ['a-skel-block', v.props?.class, opts.animationClass],
147
312
  style: v.props?.style as Record<string, string>,
313
+ 'aria-hidden': 'true',
148
314
  }),
149
315
  state
150
316
  );
@@ -157,6 +323,17 @@ function push(out: VNode[], vn: VNode, state: WalkState): void {
157
323
  state.emitted++;
158
324
  }
159
325
 
326
+ /**
327
+ * Surface-bearing tags — when these are encountered without their own explicit
328
+ * background, the skeleton paints the default fill so they still read as a
329
+ * solid element (a button without `bg-*` shouldn't look invisible).
330
+ *
331
+ * Pure layout containers (`div`, `section`, `main`, `article`, `header`,
332
+ * `footer`, `nav`, `aside`) deliberately don't get the fallback — they're
333
+ * transparent in the real DOM and should stay that way in the skeleton.
334
+ */
335
+ const SURFACE_TAGS = new Set(['button', 'a', 'label', 'summary', 'fieldset', 'legend']);
336
+
160
337
  function transformElement(
161
338
  v: VNode,
162
339
  tag: string,
@@ -168,94 +345,214 @@ function transformElement(
168
345
  const cls = v.props?.class;
169
346
  const styl = v.props?.style as Record<string, string> | string | undefined;
170
347
 
348
+ /* Atomic / interactive — replace with a sized shimmer block. The original
349
+ * element's class + style carry the dimensions, border-radius, etc., so a
350
+ * `class="size-16 rounded-full"` <img> becomes a 64×64 round shimmer block.
351
+ *
352
+ * For elements sized via HTML attributes (`<svg width="408" height="419">`,
353
+ * `<img width="…">`, etc.), we copy those attributes into an inline style
354
+ * so the replacement div takes the same dimensions — without this, a div
355
+ * doesn't honour those attributes and would render at 0×0. */
171
356
  if (ATOMIC_TAGS.has(tag)) {
172
- return h('div', { class: ['a-skel-block', cls, opts.animationClass], style: styl });
173
- }
174
-
175
- if (HEADING_TAGS.has(tag)) {
176
- return h(tag, { class: cls, style: styl }, [textBar(opts.animationClass)]);
357
+ const dimStyle = atomicDimensionStyle(v.props);
358
+ return h('div', {
359
+ class: ['a-skel-block', cls, opts.animationClass],
360
+ style: dimStyle ? mergeStyle(dimStyle, styl) : styl,
361
+ 'aria-hidden': 'true',
362
+ });
177
363
  }
178
364
 
179
- if (PARAGRAPH_TAGS.has(tag)) {
180
- const children = v.children;
181
- const recursedChildren: VNode[] = [];
182
- walk(children as VNodeArrayChildren, opts, depth + 1, max, state, recursedChildren);
183
- if (recursedChildren.length > 0) return h(tag, { class: cls, style: styl }, recursedChildren);
184
- const lines = estimateLines(children, 3);
185
- return h(tag, { class: cls, style: styl }, multiLineBars(lines, opts.animationClass));
365
+ /* Table-structural tags — preserve them as-is. Replacing a <td> with a
366
+ * <div> would break `display: table-*` layout. Children are recursed
367
+ * normally so each cell gets the right shimmer treatment. */
368
+ if (TABLE_TAGS.has(tag)) {
369
+ if (depth >= max) {
370
+ return h(tag, { class: cls, style: styl });
371
+ }
372
+ const recursed: VNode[] = [];
373
+ walk(v.children as VNodeArrayChildren, opts, depth + 1, max, state, recursed);
374
+ if (recursed.length === 0 && TEXT_OWNER_TAGS.has(tag)) {
375
+ return h(tag, { class: cls, style: styl }, [
376
+ textContentSpan(TEXT_PLACEHOLDER, opts.animationClass),
377
+ ]);
378
+ }
379
+ return h(tag, { class: cls, style: styl }, recursed.length > 0 ? recursed : undefined);
186
380
  }
187
381
 
188
- if (INLINE_TEXT_TAGS.has(tag)) {
189
- const children = v.children;
190
- const recursedChildren: VNode[] = [];
191
- walk(children as VNodeArrayChildren, opts, depth + 1, max, state, recursedChildren);
192
- if (recursedChildren.length > 0) return h(tag, { class: cls, style: styl }, recursedChildren);
193
- return h(tag, { class: cls, style: styl }, [textBar(opts.animationClass)]);
382
+ /* List-structural tags — preserve `<ul>` / `<ol>` / `<li>` / `<dl>` etc.
383
+ * `<li>` recurses into its content so each item gets the right shimmer
384
+ * treatment (text bar for text-only items, mixed content for richer ones). */
385
+ if (LIST_TAGS.has(tag)) {
386
+ if (depth >= max) {
387
+ return h(tag, { class: cls, style: styl });
388
+ }
389
+ const recursed: VNode[] = [];
390
+ walk(v.children as VNodeArrayChildren, opts, depth + 1, max, state, recursed);
391
+ if (recursed.length === 0 && TEXT_OWNER_TAGS.has(tag)) {
392
+ return h(tag, { class: cls, style: styl }, [
393
+ textContentSpan(TEXT_PLACEHOLDER, opts.animationClass),
394
+ ]);
395
+ }
396
+ return h(tag, { class: cls, style: styl }, recursed.length > 0 ? recursed : undefined);
194
397
  }
195
398
 
196
- /* Generic containerkeep its classes (flex/grid/padding/etc.) and recurse. */
399
+ /* Depth capcollapse the subtree into a single shimmer block carrying the
400
+ * container's outer dimensions / classes. */
197
401
  if (depth >= max) {
198
- return h('div', { class: ['a-skel-block', cls, opts.animationClass], style: styl });
402
+ return h('div', {
403
+ class: ['a-skel-block', cls, opts.animationClass],
404
+ style: styl,
405
+ 'aria-hidden': 'true',
406
+ });
199
407
  }
408
+
200
409
  const recursed: VNode[] = [];
201
410
  walk(v.children as VNodeArrayChildren, opts, depth + 1, max, state, recursed);
411
+
412
+ /* Empty text-owner — author wrote `<h3>{{ data?.name }}</h3>` and `data`
413
+ * is null during loading, so the interpolation produced no children.
414
+ * Inject a placeholder text-content span so a shimmer bar still renders at
415
+ * the tag's natural rendered width (tag's `class` drives font / line-height
416
+ * / size — same path the real text would take). */
417
+ if (recursed.length === 0 && TEXT_OWNER_TAGS.has(tag)) {
418
+ return h(tag, { class: cls, style: styl }, [
419
+ textContentSpan(TEXT_PLACEHOLDER, opts.animationClass),
420
+ ]);
421
+ }
422
+
423
+ /* Empty container — preserve the element so layout still reserves space.
424
+ * (Spacer divs, decorative wrappers, etc.) */
202
425
  if (recursed.length === 0) {
203
- /* Empty container in the source render as a single block so the layout
204
- * still reserves space rather than collapsing to zero height. */
205
- return h('div', { class: ['a-skel-block', cls, opts.animationClass], style: styl });
426
+ return h(tag, { class: cls, style: styl });
427
+ }
428
+
429
+ /* Surface-bearing element (button, a, label, …) — if the author didn't supply
430
+ * a background, fall back to the default skeleton fill so the element stays
431
+ * visible as a card / button / chip. Explicit `bg-*` classes or inline
432
+ * `background` keep the real surface. */
433
+ if (SURFACE_TAGS.has(tag) && !hasExplicitBackground(cls, styl)) {
434
+ return h(
435
+ tag,
436
+ {
437
+ class: ['a-skel-block', cls, opts.animationClass],
438
+ style: styl,
439
+ },
440
+ recursed
441
+ );
206
442
  }
207
- return h(tag, { class: cls, style: styl }, recursed);
443
+
444
+ return cloneTag(tag, cls, styl, recursed);
208
445
  }
209
446
 
210
- function estimateLines(children: unknown, max: number): number {
211
- if (typeof children !== 'string') return 1;
212
- const len = children.trim().length;
213
- if (len === 0)
214
- return 2; /* empty interpolation assume 2 lines so the bar looks paragraph-shaped */
215
- if (len < 40) return 1;
216
- if (len < 100) return 2;
217
- return Math.min(max, 3);
447
+ /**
448
+ * Extract width/height from HTML attributes that affect layout (`<svg
449
+ * width="408">`, `<img width="…" height="…">`) and project them into inline
450
+ * style. Without this, replacing an attribute-sized atomic element with a
451
+ * `<div>` would drop the size divs don't honour those attributes.
452
+ */
453
+ function atomicDimensionStyle(
454
+ props: Record<string, unknown> | null | undefined
455
+ ): Record<string, string> | null {
456
+ if (!props) return null;
457
+ const out: Record<string, string> = {};
458
+ const w = props.width;
459
+ const h = props.height;
460
+ if (w !== undefined && w !== null && w !== '') {
461
+ out.width = typeof w === 'number' ? `${w}px` : /^\d+$/.test(String(w)) ? `${w}px` : String(w);
462
+ }
463
+ if (h !== undefined && h !== null && h !== '') {
464
+ out.height = typeof h === 'number' ? `${h}px` : /^\d+$/.test(String(h)) ? `${h}px` : String(h);
465
+ }
466
+ return Object.keys(out).length > 0 ? out : null;
218
467
  }
219
468
 
220
- function multiLineBars(lines: number, animationClass: string | null): VNode[] {
221
- const out: VNode[] = [];
222
- for (let i = 0; i < lines; i++) {
223
- out.push(textBar(animationClass, i === lines - 1 && lines > 1 ? 0.65 : 1));
469
+ /**
470
+ * Detect whether the element has an explicit background — either via a
471
+ * Tailwind / DaisyUI / CSS-modules `bg-*` class or via an inline
472
+ * `background` / `background-color` / `background-image` style. When false,
473
+ * surface-bearing elements (button, a, label, …) fall back to the default
474
+ * skeleton fill so they don't render as a transparent gap during loading.
475
+ */
476
+ function hasExplicitBackground(cls: unknown, styl: unknown): boolean {
477
+ if (hasBackgroundInStyle(styl)) return true;
478
+ if (hasBackgroundInClass(cls)) return true;
479
+ return false;
480
+ }
481
+
482
+ const BG_CLASS_RE = /(?:^|\s|:)bg-(?!skel)(?:\[|[a-z])/i;
483
+
484
+ function hasBackgroundInClass(cls: unknown): boolean {
485
+ if (cls == null || cls === false) return false;
486
+ if (typeof cls === 'string') return BG_CLASS_RE.test(cls);
487
+ if (Array.isArray(cls)) {
488
+ for (const item of cls) {
489
+ if (hasBackgroundInClass(item)) return true;
490
+ }
491
+ return false;
224
492
  }
225
- return out;
493
+ if (typeof cls === 'object') {
494
+ for (const k of Object.keys(cls as Record<string, unknown>)) {
495
+ if ((cls as Record<string, unknown>)[k] && BG_CLASS_RE.test(k)) return true;
496
+ }
497
+ }
498
+ return false;
226
499
  }
227
500
 
228
- /* Style objects for the two common bar shapes are reused across calls so a
229
- * structural skeleton with 200 text bars doesn't allocate 200 style objects. */
230
- const BAR_STYLE_FULL = Object.freeze({
231
- display: 'inline-block',
232
- width: '100%',
233
- height: '0.75em',
234
- verticalAlign: 'middle',
235
- borderRadius: '4px',
236
- });
237
-
238
- const PARTIAL_BAR_CACHE = new Map<number, Readonly<Record<string, string>>>();
239
-
240
- function partialBarStyle(widthFraction: number): Readonly<Record<string, string>> {
241
- /* Round to one decimal so 0.65, 0.7, 0.85 each get a single cached style. */
242
- const key = Math.round(widthFraction * 10) / 10;
243
- const hit = PARTIAL_BAR_CACHE.get(key);
244
- if (hit) return hit;
245
- const made = Object.freeze({
246
- display: 'inline-block',
247
- width: `${Math.round(key * 100)}%`,
248
- height: '0.75em',
249
- verticalAlign: 'middle',
250
- borderRadius: '4px',
251
- });
252
- PARTIAL_BAR_CACHE.set(key, made);
253
- return made;
501
+ function hasBackgroundInStyle(styl: unknown): boolean {
502
+ if (!styl) return false;
503
+ if (typeof styl === 'string') return /background(?:-color|-image)?\s*:/i.test(styl);
504
+ if (typeof styl === 'object') {
505
+ const s = styl as Record<string, unknown>;
506
+ return (
507
+ 'background' in s ||
508
+ 'backgroundColor' in s ||
509
+ 'backgroundImage' in s ||
510
+ 'background-color' in s ||
511
+ 'background-image' in s
512
+ );
513
+ }
514
+ return false;
254
515
  }
255
516
 
256
- function textBar(animationClass: string | null, widthFraction = 1): VNode {
257
- return h('span', {
258
- class: ['a-skel-block', 'a-skel-block--text', animationClass],
259
- style: widthFraction === 1 ? BAR_STYLE_FULL : partialBarStyle(widthFraction),
260
- });
517
+ function mergeStyle(
518
+ a: Record<string, string>,
519
+ b: Record<string, string> | string | undefined | null
520
+ ): Record<string, string> | string {
521
+ if (!b) return a;
522
+ if (typeof b === 'string') {
523
+ /* Preserve user's inline style; prepend dim style so user values still win
524
+ * if they explicitly set the same property. */
525
+ const dimsStr = Object.entries(a)
526
+ .map(([k, v]) => `${k}: ${v}`)
527
+ .join('; ');
528
+ return `${dimsStr}; ${b}`;
529
+ }
530
+ return { ...a, ...(b as Record<string, string>) };
531
+ }
532
+
533
+ function cloneTag(
534
+ tag: string,
535
+ cls: unknown,
536
+ styl: Record<string, string> | string | undefined,
537
+ children: VNode[]
538
+ ): VNode {
539
+ /* We deliberately don't forward arbitrary props (`v.props`) — preserving
540
+ * the tag + class + style is enough for visual fidelity, and dropping the
541
+ * rest (onClick handlers, href, etc.) keeps the skeleton non-interactive. */
542
+ return h(tag, { class: cls, style: styl }, children);
543
+ }
544
+
545
+ function textContentSpan(text: string, animationClass: string | null): VNode {
546
+ return h(
547
+ 'span',
548
+ {
549
+ class: ['a-skel-text-content', animationClass],
550
+ 'aria-hidden': 'true',
551
+ },
552
+ text
553
+ );
261
554
  }
555
+
556
+ /* Re-export for advanced consumers who want to construct skeleton vnodes
557
+ * themselves (e.g. inside a render function component). */
558
+ export { cloneVNode };