@alikhalilll/a-skeleton 1.1.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.
- package/.media/hero.png +0 -0
- package/.media/hero.svg +232 -0
- package/README.md +458 -172
- package/dist/index.cjs +3685 -840
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +527 -40
- package/dist/index.d.ts +527 -40
- package/dist/index.js +3666 -842
- package/dist/index.js.map +1 -1
- package/dist/nuxt/index.cjs +16 -1
- package/dist/nuxt/index.cjs.map +1 -1
- package/dist/nuxt/index.js +16 -1
- package/dist/nuxt/index.js.map +1 -1
- package/dist/resolver/index.cjs +16 -1
- package/dist/resolver/index.cjs.map +1 -1
- package/dist/resolver/index.js +16 -1
- package/dist/resolver/index.js.map +1 -1
- package/dist/styles.css +56 -11
- package/package.json +8 -2
- package/src/components/ASkeleton.vue +212 -113
- package/src/components/ASkeletonClone.vue +106 -0
- package/src/components/ASkeletonLayer.vue +20 -32
- package/src/components/CloneNode.ts +161 -0
- package/src/components/StructuralLayerNode.ts +157 -0
- package/src/components/icons.ts +45 -0
- package/src/components/variants/ASkeletonArticle.vue +33 -0
- package/src/components/variants/ASkeletonAvatar.vue +42 -0
- package/src/components/variants/ASkeletonButton.vue +37 -0
- package/src/components/variants/ASkeletonCard.vue +47 -0
- package/src/components/variants/ASkeletonChart.vue +56 -0
- package/src/components/variants/ASkeletonChip.vue +32 -0
- package/src/components/variants/ASkeletonDivider.vue +26 -0
- package/src/components/variants/ASkeletonForm.vue +32 -0
- package/src/components/variants/ASkeletonHeading.vue +47 -0
- package/src/components/variants/ASkeletonImage.vue +57 -0
- package/src/components/variants/ASkeletonInput.vue +33 -0
- package/src/components/variants/ASkeletonListItem.vue +40 -0
- package/src/components/variants/ASkeletonTable.vue +49 -0
- package/src/components/variants/ASkeletonText.vue +49 -0
- package/src/components/variants/ASkeletonVideo.vue +55 -0
- package/src/composables/useShapeProbe.ts +33 -9
- package/src/composables/useSkeleton.ts +33 -21
- package/src/composables/useSkeletonCache.ts +251 -22
- package/src/index.ts +48 -2
- package/src/nuxt/index.ts +16 -0
- package/src/resolver/index.ts +16 -0
- package/src/types.ts +118 -2
- package/src/utils/buildStructuralSkeleton.ts +400 -103
- package/src/utils/captureStyles.ts +378 -0
- package/src/utils/domRead.ts +143 -0
- package/src/utils/walkDom.ts +261 -16
- package/src/utils/walkStructural.ts +418 -0
- package/web-types.json +9 -3
|
@@ -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
|
|
13
|
-
* is
|
|
14
|
-
*
|
|
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
|
-
/**
|
|
31
|
-
|
|
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
|
-
/**
|
|
34
|
-
|
|
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
|
-
/**
|
|
37
|
-
|
|
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
|
|
156
|
+
/** Max recursion depth — guards runaway templates. Default 16. */
|
|
54
157
|
maxDepth?: number;
|
|
55
158
|
/**
|
|
56
|
-
* Hard cap on emitted skeleton nodes. Default
|
|
57
|
-
*
|
|
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 =
|
|
63
|
-
const DEFAULT_MAX_NODES =
|
|
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
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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)
|
|
115
|
-
if (str) push(out,
|
|
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
|
|
126
|
-
if (
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
|
|
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
|
-
/*
|
|
399
|
+
/* Depth cap — collapse the subtree into a single shimmer block carrying the
|
|
400
|
+
* container's outer dimensions / classes. */
|
|
197
401
|
if (depth >= max) {
|
|
198
|
-
return h('div', {
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
443
|
+
|
|
444
|
+
return cloneTag(tag, cls, styl, recursed);
|
|
208
445
|
}
|
|
209
446
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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 };
|