@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.
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 +527 -40
  7. package/dist/index.d.ts +527 -40
  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 +8 -2
  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
@@ -1,157 +1,227 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, shallowRef, useId, useSlots, watch, type CSSProperties } from 'vue';
2
+ import { computed, onBeforeUnmount, ref, shallowRef, useId, useSlots, watch } from 'vue';
3
3
  import { cn } from '@alikhalilll/a-ui-base';
4
- import type { ASkeletonProps, ASkeletonSlots, CachedShape, ShapeNodeType } from '../types';
5
- import { useShapeProbe } from '../composables/useShapeProbe';
6
- import { getCached, setCached } from '../composables/useSkeletonCache';
7
- import { fingerprintSlot } from '../utils/fingerprint';
4
+ import type { ASkeletonProps, ASkeletonSlots } from '../types';
8
5
  import { StructuralSkeleton } from './StructuralSkeleton';
6
+ import ASkeletonClone from './ASkeletonClone.vue';
7
+ import { captureSnapshot, type CaptureSnapshot } from '../utils/captureStyles';
8
+
9
+ /**
10
+ * `<ASkeleton>` — the package's headline wrapper.
11
+ *
12
+ * Two rendering strategies, selected via `mode`:
13
+ * - `mirror` (default): walks the slot's vnode tree (`buildStructuralSkeleton`)
14
+ * and preserves every element with its real `class` / inline `style`. Pure
15
+ * Vue, SSR-safe, no DOM read.
16
+ * - `clone`: mounts the slot off-screen once, snapshots every leaf's
17
+ * `getComputedStyle()` (`captureSnapshot`), then replays the snapshot via
18
+ * `<ASkeletonClone>` as positioned divs carrying every captured CSS
19
+ * property. Pixel-identical regardless of styling system.
20
+ *
21
+ * The strategies are exclusive — picking one decides the entire loading-state
22
+ * render path. The wrapper itself only orchestrates: it doesn't decide
23
+ * per-element treatment (that's the strategies' job). Single Responsibility:
24
+ * orchestration + a11y + containment.
25
+ */
9
26
 
10
27
  const props = withDefaults(defineProps<ASkeletonProps>(), {
11
- maxDepth: 6,
12
- maxNodes: 500,
28
+ maxDepth: 16,
29
+ maxNodes: 600,
13
30
  minNodeSize: 4,
14
31
  persist: false,
15
32
  animation: 'shimmer',
16
33
  fallback: 'shimmer',
34
+ /* Default is `clone` — comprehensive computed-style snapshot + replay,
35
+ * works correctly regardless of how styles reach the DOM (Tailwind, inline
36
+ * style, CSS-in-JS hashes, scoped styles). Switch to `mirror` for SSR-safe,
37
+ * vnode-tree-based skeletons that don't need a client-side measurement pass. */
38
+ mode: 'clone',
17
39
  });
18
40
  defineSlots<ASkeletonSlots>();
19
41
 
20
42
  const slots = useSlots();
21
43
 
22
- /* Per-instance suffix from Vue's useId() — deterministic across SSR/hydration
23
- * and stable across re-renders, but distinct per <ASkeleton> instance. Two
24
- * <ASkeleton><UserCard/></ASkeleton> on the same page therefore never collide
25
- * on the auto-generated key. Pass an explicit `cacheKey` to share a shape
26
- * across instances on purpose. */
27
44
  const instanceId = useId();
45
+ void instanceId;
28
46
 
29
- const resolvedKey = computed(
30
- () => props.cacheKey ?? `${fingerprintSlot(slots.default?.())}:${instanceId}`
47
+ const animationClass = computed(() =>
48
+ props.animation === 'none' ? null : `a-skel-block--anim-${props.animation}`
31
49
  );
32
50
 
33
- const warnedKeys = new Set<string>();
51
+ const cloneAnimation = computed(() => props.animation);
34
52
 
35
- const cached = shallowRef<CachedShape | undefined>(getCached(resolvedKey.value, props.persist));
53
+ /* ─── `mirror` strategy state ─────────────────────────────────────────────── */
54
+ const mirrorVNodes = computed(() => (props.loading ? (slots.default?.() ?? []) : []));
55
+ const hasContent = computed(() => mirrorVNodes.value.length > 0);
36
56
 
37
- watch(resolvedKey, (key) => {
38
- cached.value = getCached(key, props.persist);
39
- });
57
+ /* ─── `clone` strategy state ──────────────────────────────────────────────── */
58
+ const captureRef = ref<HTMLElement | null>(null);
59
+ const snapshot = shallowRef<CaptureSnapshot | undefined>(undefined);
60
+ const snapshotValid = computed(
61
+ () => !!snapshot.value && snapshot.value.width > 0 && snapshot.value.height > 0
62
+ );
63
+ /* `snapshotRenderable` — only true when the snapshot has dimensions AND at
64
+ * least one captured node. An empty `nodes` array would render a transparent
65
+ * overlay that masks the mirror backdrop, so we hold the replay layer back
66
+ * until there's actually something to draw. */
67
+ const snapshotRenderable = computed(
68
+ () => snapshotValid.value && (snapshot.value as CaptureSnapshot).nodes.length > 0
69
+ );
70
+ let captureFrame: number | undefined;
71
+ let retryAttempts = 0;
72
+ const MAX_RETRY_ATTEMPTS = 5;
73
+
74
+ /* Take a snapshot once the off-screen mount is in the DOM. We schedule via a
75
+ * **double** `requestAnimationFrame` so the browser has time to (1) commit the
76
+ * mount and (2) run layout — without this, the first frame fires before the
77
+ * slot's geometry is measurable when `loading=true` is the initial state, the
78
+ * snapshot comes back with 0×0 dimensions, `<ASkeletonClone>` collapses to
79
+ * nothing, and the user sees a blank skeleton.
80
+ *
81
+ * If the second-frame snapshot still has zero dimensions (the slot is genuinely
82
+ * empty or the layout hasn't settled yet), we retry up to MAX_RETRY_ATTEMPTS
83
+ * times with an exponential-ish backoff via repeated rAF. Past that we give up
84
+ * and the `mirror--fallback` branch keeps showing — see the v-if/v-else-if
85
+ * chain in the template. */
86
+ function takeSnapshot() {
87
+ if (captureFrame !== undefined) cancelAnimationFrame(captureFrame);
88
+ retryAttempts = 0;
89
+ scheduleCapture();
90
+ }
40
91
 
41
- const wrapperRef = ref<HTMLElement | null>(null);
42
-
43
- /* Probe runs whenever the real content is mounted (loading=false). The getter
44
- * returns null during loading so the watch tears down its ResizeObserver. */
45
- useShapeProbe(() => (props.loading ? null : wrapperRef.value), {
46
- maxDepth: props.maxDepth,
47
- maxNodes: props.maxNodes,
48
- minSize: props.minNodeSize,
49
- onCapture: (shape) => {
50
- setCached(resolvedKey.value, shape, props.persist);
51
- cached.value = shape;
52
- if (shape.truncated && !warnedKeys.has(resolvedKey.value)) {
53
- warnedKeys.add(resolvedKey.value);
54
- console.warn(
55
- `[ASkeleton] Capture truncated at maxNodes=${props.maxNodes} for cacheKey="${resolvedKey.value}". ` +
56
- `The replayed skeleton will be missing nodes. Raise \`max-nodes\` or mark dense subtrees with ` +
57
- `\`data-skeleton-stop\` to collapse them into a single block.`
58
- );
59
- }
92
+ function scheduleCapture(): void {
93
+ captureFrame = requestAnimationFrame(() => {
94
+ captureFrame = requestAnimationFrame(() => {
95
+ captureFrame = undefined;
96
+ if (!captureRef.value) return;
97
+ const result = captureSnapshot(captureRef.value, {
98
+ maxDepth: props.maxDepth,
99
+ maxNodes: props.maxNodes,
100
+ minSize: props.minNodeSize,
101
+ });
102
+ if (result.width > 0 && result.height > 0) {
103
+ snapshot.value = result;
104
+ return;
105
+ }
106
+ /* Zero-size capture slot likely hasn't laid out yet. Retry on a later
107
+ * frame, capped so we don't loop forever on a genuinely-empty slot. */
108
+ if (retryAttempts < MAX_RETRY_ATTEMPTS) {
109
+ retryAttempts++;
110
+ scheduleCapture();
111
+ }
112
+ });
113
+ });
114
+ }
115
+
116
+ watch(
117
+ captureRef,
118
+ (el) => {
119
+ if (props.mode !== 'clone') return;
120
+ if (el) takeSnapshot();
60
121
  },
61
- });
122
+ /* `flush: 'post'` so the watcher fires *after* Vue has committed the DOM
123
+ * update — the captureRef is set, but the surrounding layout state is also
124
+ * settled, which double-rAF then waits for completion of. */
125
+ { flush: 'post' }
126
+ );
62
127
 
63
- const animationClass = computed(() =>
64
- props.animation === 'none' ? null : `a-skel-block--anim-${props.animation}`
128
+ /* Re-capture when the mode flips into clone OR when the slot's vnodes change
129
+ * shape (the watcher above runs on mount; this one covers in-place updates). */
130
+ watch(
131
+ () => props.mode,
132
+ (mode) => {
133
+ if (mode === 'clone' && captureRef.value) takeSnapshot();
134
+ }
65
135
  );
66
136
 
67
- const layerStyle = computed<CSSProperties>(() =>
68
- cached.value ? { width: `${cached.value.width}px`, height: `${cached.value.height}px` } : {}
137
+ /* Re-capture when the slot's content shape changes (e.g. data arrives and
138
+ * `loading` flips false true again with new vnodes). Without this the first
139
+ * snapshot is the only snapshot, and a second load shows the cached stale
140
+ * geometry from the very first mount. */
141
+ watch(
142
+ () => props.loading,
143
+ (next, prev) => {
144
+ if (props.mode !== 'clone') return;
145
+ if (next && !prev && captureRef.value) takeSnapshot();
146
+ }
69
147
  );
70
148
 
71
- /* Pre-join the per-type class strings once per animation value so the render
72
- * loop doesn't allocate a new `[a, b, c]` array per node per frame — meaningful
73
- * when a cache holds hundreds of nodes. */
74
- const blockClassByType = computed<Readonly<Record<ShapeNodeType, string>>>(() => {
75
- const anim = animationClass.value;
76
- const suffix = anim ? ` ${anim}` : '';
77
- return Object.freeze({
78
- block: `a-skel-block a-skel-block--block${suffix}`,
79
- text: `a-skel-block a-skel-block--text${suffix}`,
80
- image: `a-skel-block a-skel-block--image${suffix}`,
81
- circle: `a-skel-block a-skel-block--circle${suffix}`,
82
- });
149
+ onBeforeUnmount(() => {
150
+ if (captureFrame !== undefined) cancelAnimationFrame(captureFrame);
83
151
  });
84
-
85
- /* Cache-miss fallback path: walk the slot's vnodes synchronously so the FIRST
86
- * paint already shows a skeleton that mirrors the component's HTML structure
87
- * (same tags, classes, hierarchy → same flex/grid/spacing/sizing utilities
88
- * still apply). If the slot is empty / only renders comments (e.g. the user
89
- * gates the whole template on `v-if="data"`), we get an empty array back and
90
- * fall through to the generic shimmer block. */
91
- const structuralVNodes = computed(() => (props.loading ? (slots.default?.() ?? []) : []));
92
- const hasStructure = computed(() => structuralVNodes.value.length > 0);
93
152
  </script>
94
153
 
95
154
  <template>
96
155
  <div
97
- ref="wrapperRef"
98
- :class="cn('a-skeleton', props.class)"
156
+ :class="cn('a-skeleton', `a-skeleton--mode-${props.mode}`, props.class)"
99
157
  :data-loading="props.loading ? '' : undefined"
158
+ role="status"
159
+ :aria-busy="props.loading ? 'true' : undefined"
160
+ :aria-live="props.loading ? 'polite' : undefined"
100
161
  >
101
- <template v-if="props.loading">
102
- <!-- Cache hit: pixel-aligned positioned blocks from a previous measurement.
103
- Styles are pre-computed during capture so the loop below never calls
104
- a function or allocates a style object per node. -->
162
+ <template v-if="props.mode === 'clone'">
163
+ <!-- Off-screen capture mount: the slot is always rendered (so we have a
164
+ live target to snapshot from) but visually suppressed while the
165
+ skeleton is showing. `aria-hidden` keeps it out of AT trees too. -->
105
166
  <div
106
- v-if="cached"
107
- class="a-skeleton__layer"
108
- :style="layerStyle"
109
- role="status"
110
- aria-live="polite"
111
- aria-busy="true"
167
+ ref="captureRef"
168
+ class="a-skeleton__capture"
169
+ :class="props.loading ? 'a-skeleton__capture--hidden' : null"
170
+ :aria-hidden="props.loading ? 'true' : undefined"
112
171
  >
113
- <template v-for="(node, idx) in cached.nodes" :key="idx">
114
- <template v-if="node.lineStyles">
115
- <div
116
- v-for="(lineStyle, i) in node.lineStyles"
117
- :key="`${idx}-${i}`"
118
- :class="blockClassByType.text"
119
- :style="lineStyle"
120
- />
121
- </template>
122
- <div v-else :class="blockClassByType[node.type]" :style="node.style" />
123
- </template>
172
+ <slot />
124
173
  </div>
125
174
 
126
- <!-- Cache miss + slot has structure: render a structural skeleton derived
127
- from the slot's vnode tree. First paint already looks right. -->
175
+ <!-- Mirror fallback **always rendered as a backdrop** while loading,
176
+ regardless of snapshot state. The replay layer below sits on top of
177
+ it (higher z-index). If the snapshot turns out to be empty / zero-
178
+ sized / otherwise unrenderable, the mirror underneath still gives the
179
+ user a structural skeleton instead of a blank wrapper. -->
128
180
  <div
129
- v-else-if="hasStructure"
130
- class="a-skeleton__structural"
131
- role="status"
132
- aria-live="polite"
133
- aria-busy="true"
181
+ v-if="props.loading && hasContent"
182
+ class="a-skeleton__mirror a-skeleton__mirror--fallback"
134
183
  >
135
184
  <StructuralSkeleton
136
- :vnodes="structuralVNodes"
185
+ :vnodes="mirrorVNodes"
137
186
  :animation="animationClass"
138
187
  :max-depth="maxDepth"
139
188
  :max-nodes="maxNodes"
140
189
  />
141
190
  </div>
142
191
 
143
- <!-- Cache miss + nothing to walk: generic shimmer. -->
144
- <div v-else class="a-skeleton__fallback" role="status" aria-busy="true">
145
- <slot name="fallback">
146
- <div
147
- class="a-skel-block a-skel-block--block a-skel-fallback-default"
148
- :class="animationClass"
149
- />
150
- </slot>
192
+ <!-- Replay layer wrapped in a positioning div whose scoped styles
193
+ are GUARANTEED to apply (a child-component root only inherits the
194
+ parent's scope when no inner div is present; placing the absolute-
195
+ positioning on a regular div in the parent template removes that
196
+ ambiguity). Gated on `snapshotValid` (width AND height > 0) AND
197
+ snapshot.nodes.length > 0 so an empty capture doesn't render a
198
+ transparent overlay that masks the mirror underneath. -->
199
+ <div v-if="props.loading && snapshotRenderable" class="a-skeleton__replay">
200
+ <ASkeletonClone :shape="snapshot!" :animation="cloneAnimation" />
151
201
  </div>
152
202
  </template>
153
203
 
154
- <slot v-else />
204
+ <template v-else>
205
+ <template v-if="props.loading">
206
+ <div v-if="hasContent" class="a-skeleton__mirror">
207
+ <StructuralSkeleton
208
+ :vnodes="mirrorVNodes"
209
+ :animation="animationClass"
210
+ :max-depth="maxDepth"
211
+ :max-nodes="maxNodes"
212
+ />
213
+ </div>
214
+ <div v-else class="a-skeleton__fallback">
215
+ <slot name="fallback">
216
+ <div
217
+ class="a-skel-block a-skel-block--block a-skel-fallback-default"
218
+ :class="animationClass"
219
+ />
220
+ </slot>
221
+ </div>
222
+ </template>
223
+ <slot v-else />
224
+ </template>
155
225
  </div>
156
226
  </template>
157
227
 
@@ -161,20 +231,20 @@ const hasStructure = computed(() => structuralVNodes.value.length > 0);
161
231
  position: relative;
162
232
  }
163
233
 
164
- /* `.a-skeleton__layer` + `.a-skeleton__layer > .a-skel-block` layout/containment
165
- * live in `styles.src.css` so they're shared with the public `<ASkeletonLayer>`
166
- * component. */
234
+ .a-skeleton[data-loading] {
235
+ overflow: hidden;
236
+ overflow: clip;
237
+ overflow-clip-margin: 0;
238
+ }
167
239
 
168
- .a-skeleton__structural :deep(*) {
169
- /* Disable text caret/selection on the structural copy so it doesn't look
170
- * interactive. Layout (flex/grid/spacing/sizing) flows through unchanged. */
240
+ .a-skeleton__mirror :deep(*) {
171
241
  user-select: none;
172
242
  pointer-events: none;
173
243
  }
174
244
 
175
- .a-skeleton__structural :deep(button),
176
- .a-skeleton__structural :deep(input),
177
- .a-skeleton__structural :deep(a) {
245
+ .a-skeleton__mirror :deep(button),
246
+ .a-skeleton__mirror :deep(input),
247
+ .a-skeleton__mirror :deep(a) {
178
248
  cursor: default;
179
249
  }
180
250
 
@@ -183,4 +253,33 @@ const hasStructure = computed(() => structuralVNodes.value.length > 0);
183
253
  height: 4rem;
184
254
  border-radius: 0.5rem;
185
255
  }
256
+
257
+ /* Clone-mode capture mount: needs to be in the DOM so we can read computed
258
+ * styles from it, but invisible while the snapshot replay is showing. We use
259
+ * `visibility: hidden` (not `display: none`) so layout is preserved — the
260
+ * snapshot needs real geometry. `pointer-events: none` keeps it inert. */
261
+ .a-skeleton__capture--hidden {
262
+ visibility: hidden;
263
+ pointer-events: none;
264
+ user-select: none;
265
+ /* The capture mount sits behind the replay (z-index 0 vs 1) so the replay
266
+ * paints over it. We keep it in normal flow so its `getBoundingClientRect`
267
+ * still reports the real layout (off-flow positioning would zero it). */
268
+ }
269
+
270
+ .a-skeleton__replay {
271
+ position: absolute;
272
+ inset: 0;
273
+ z-index: 1;
274
+ }
275
+
276
+ .a-skeleton__mirror--fallback {
277
+ position: absolute;
278
+ inset: 0;
279
+ /* Sits BEHIND the clone replay (z-index: 0) so when the replay mounts on top
280
+ * it covers the mirror. If the replay turns out to be empty / sized wrong,
281
+ * the mirror underneath still gives the user a structural skeleton instead
282
+ * of a blank wrapper. */
283
+ z-index: 0;
284
+ }
186
285
  </style>
@@ -0,0 +1,106 @@
1
+ <script setup lang="ts">
2
+ import { computed, type CSSProperties } from 'vue';
3
+ import { cn } from '@alikhalilll/a-ui-base';
4
+ import type { CaptureSnapshot, CapturedNode, TextLineRect } from '../utils/captureStyles';
5
+ import { CloneNode } from './CloneNode';
6
+
7
+ /**
8
+ * `<ASkeletonClone>` — replays a `CaptureSnapshot` produced by
9
+ * `captureSnapshot()` as a tree of positioned divs each carrying its
10
+ * captured inline style. Pure render component; the recursive per-node
11
+ * rendering lives in `CloneNode` to keep the strategy table isolated from
12
+ * the layer-level concerns (sizing, animation, a11y).
13
+ */
14
+ interface Props {
15
+ shape: CaptureSnapshot;
16
+ animation?: 'pulse' | 'shimmer' | 'wave' | 'none';
17
+ class?: string | string[] | Record<string, boolean>;
18
+ }
19
+
20
+ const props = withDefaults(defineProps<Props>(), { animation: 'pulse' });
21
+
22
+ const animClass = computed(() =>
23
+ props.animation === 'none' ? null : `a-skel-anim-${props.animation}`
24
+ );
25
+
26
+ const layerStyle = computed<CSSProperties>(() => ({
27
+ position: 'relative',
28
+ width: `${props.shape.width}px`,
29
+ height: `${props.shape.height}px`,
30
+ }));
31
+
32
+ /* Pure style builders passed down to `CloneNode`. Kept here so the snapshot's
33
+ * coordinate system (root-relative) stays the layer's concern; the renderer
34
+ * just consumes ready-to-apply style objects. */
35
+ function blockStyle(n: CapturedNode): Record<string, unknown> {
36
+ return {
37
+ position: 'absolute',
38
+ left: `${n.x}px`,
39
+ top: `${n.y}px`,
40
+ width: `${n.w}px`,
41
+ height: `${n.h}px`,
42
+ ...n.style,
43
+ };
44
+ }
45
+
46
+ function lineStyle(
47
+ line: TextLineRect,
48
+ parentStyle: Readonly<Record<string, string>>
49
+ ): Record<string, unknown> {
50
+ const radius =
51
+ (parentStyle as Record<string, string>).borderTopLeftRadius ?? 'var(--ak-skel-radius-sm)';
52
+ return {
53
+ position: 'absolute',
54
+ left: `${line.x}px`,
55
+ top: `${line.y}px`,
56
+ width: `${line.w}px`,
57
+ height: `${line.h}px`,
58
+ backgroundColor: 'var(--ak-skel-base)',
59
+ borderRadius: radius,
60
+ };
61
+ }
62
+ </script>
63
+
64
+ <template>
65
+ <div
66
+ :class="cn('a-skeleton__clone', props.class)"
67
+ :style="layerStyle"
68
+ role="status"
69
+ aria-busy="true"
70
+ aria-live="polite"
71
+ >
72
+ <CloneNode
73
+ v-for="(node, idx) in props.shape.nodes"
74
+ :key="idx"
75
+ :node="node"
76
+ :anim-class="animClass"
77
+ :block-style="blockStyle"
78
+ :line-style="lineStyle"
79
+ />
80
+ <span class="a-skel-sr-only">Loading…</span>
81
+ </div>
82
+ </template>
83
+
84
+ <style scoped>
85
+ .a-skeleton__clone {
86
+ overflow: hidden;
87
+ isolation: isolate;
88
+ }
89
+
90
+ .a-skeleton__clone :deep(.a-skel-clone-leaf) {
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: center;
94
+ color: var(--ak-skel-icon);
95
+ }
96
+
97
+ .a-skeleton__clone :deep(.a-skel-clone-icon) {
98
+ width: clamp(20px, 30%, 56px);
99
+ height: clamp(20px, 30%, 56px);
100
+ opacity: 0.6;
101
+ }
102
+
103
+ .a-skeleton__clone :deep(.a-skel-clone-textbar) {
104
+ pointer-events: none;
105
+ }
106
+ </style>
@@ -1,52 +1,40 @@
1
1
  <script setup lang="ts">
2
- import { computed, type CSSProperties } from 'vue';
2
+ import { computed } from 'vue';
3
3
  import { cn } from '@alikhalilll/a-ui-base';
4
- import type { ASkeletonLayerProps, ShapeNodeType } from '../types';
4
+ import type { ASkeletonLayerProps } from '../types';
5
+ import { StructuralLayerNode } from './StructuralLayerNode';
5
6
 
7
+ /**
8
+ * `<ASkeletonLayer>` — replays a `StructuralShape` produced by
9
+ * `walkStructural` / `useSkeleton()` as a tree of preserved containers +
10
+ * a-skel leaf placeholders. The layer is a transparent shell with no
11
+ * width / height / position constraints, so it drops into the consumer's
12
+ * own container and lets the captured tree's flex/grid/spacing dictate
13
+ * how the skeleton lays itself out.
14
+ */
6
15
  const props = withDefaults(defineProps<ASkeletonLayerProps>(), {
7
16
  animation: 'shimmer',
8
17
  });
9
18
 
10
- const animationClass = computed(() =>
11
- props.animation === 'none' ? null : `a-skel-block--anim-${props.animation}`
19
+ const animationClass = computed<string | null>(() =>
20
+ props.animation === 'none' ? null : `a-skel-anim-${props.animation}`
12
21
  );
13
-
14
- const layerStyle = computed<CSSProperties>(() =>
15
- props.shape ? { width: `${props.shape.width}px`, height: `${props.shape.height}px` } : {}
16
- );
17
-
18
- /* Pre-joined per-type class strings — see ASkeleton.vue for the rationale. */
19
- const blockClassByType = computed<Readonly<Record<ShapeNodeType, string>>>(() => {
20
- const anim = animationClass.value;
21
- const suffix = anim ? ` ${anim}` : '';
22
- return Object.freeze({
23
- block: `a-skel-block a-skel-block--block${suffix}`,
24
- text: `a-skel-block a-skel-block--text${suffix}`,
25
- image: `a-skel-block a-skel-block--image${suffix}`,
26
- circle: `a-skel-block a-skel-block--circle${suffix}`,
27
- });
28
- });
29
22
  </script>
30
23
 
31
24
  <template>
32
25
  <div
33
26
  v-if="shape"
34
27
  :class="cn('a-skeleton__layer', props.class)"
35
- :style="layerStyle"
36
28
  role="status"
37
29
  aria-live="polite"
38
30
  aria-busy="true"
39
31
  >
40
- <template v-for="(node, idx) in shape.nodes" :key="idx">
41
- <template v-if="node.lineStyles">
42
- <div
43
- v-for="(lineStyle, i) in node.lineStyles"
44
- :key="`${idx}-${i}`"
45
- :class="blockClassByType.text"
46
- :style="lineStyle"
47
- />
48
- </template>
49
- <div v-else :class="blockClassByType[node.type]" :style="node.style" />
50
- </template>
32
+ <StructuralLayerNode
33
+ v-for="(node, idx) in shape.nodes"
34
+ :key="idx"
35
+ :node="node"
36
+ :anim-class="animationClass"
37
+ />
38
+ <span class="a-skel-sr-only">Loading…</span>
51
39
  </div>
52
40
  </template>