@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
@@ -1,138 +1,227 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, shallowRef, 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
- const resolvedKey = computed(() => props.cacheKey ?? fingerprintSlot(slots.default?.()));
44
+ const instanceId = useId();
45
+ void instanceId;
23
46
 
24
- const cached = shallowRef<CachedShape | undefined>(getCached(resolvedKey.value, props.persist));
47
+ const animationClass = computed(() =>
48
+ props.animation === 'none' ? null : `a-skel-block--anim-${props.animation}`
49
+ );
25
50
 
26
- watch(resolvedKey, (key) => {
27
- cached.value = getCached(key, props.persist);
28
- });
51
+ const cloneAnimation = computed(() => props.animation);
52
+
53
+ /* ─── `mirror` strategy state ─────────────────────────────────────────────── */
54
+ const mirrorVNodes = computed(() => (props.loading ? (slots.default?.() ?? []) : []));
55
+ const hasContent = computed(() => mirrorVNodes.value.length > 0);
56
+
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;
29
73
 
30
- const wrapperRef = ref<HTMLElement | null>(null);
31
-
32
- /* Probe runs whenever the real content is mounted (loading=false). The getter
33
- * returns null during loading so the watch tears down its ResizeObserver. */
34
- useShapeProbe(() => (props.loading ? null : wrapperRef.value), {
35
- maxDepth: props.maxDepth,
36
- maxNodes: props.maxNodes,
37
- minSize: props.minNodeSize,
38
- onCapture: (shape) => {
39
- setCached(resolvedKey.value, shape, props.persist);
40
- cached.value = shape;
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
+ }
91
+
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();
41
121
  },
42
- });
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
+ );
43
127
 
44
- const animationClass = computed(() =>
45
- 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
+ }
46
135
  );
47
136
 
48
- const layerStyle = computed<CSSProperties>(() =>
49
- 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
+ }
50
147
  );
51
148
 
52
- /* Pre-join the per-type class strings once per animation value so the render
53
- * loop doesn't allocate a new `[a, b, c]` array per node per frame — meaningful
54
- * when a cache holds hundreds of nodes. */
55
- const blockClassByType = computed<Readonly<Record<ShapeNodeType, string>>>(() => {
56
- const anim = animationClass.value;
57
- const suffix = anim ? ` ${anim}` : '';
58
- return Object.freeze({
59
- block: `a-skel-block a-skel-block--block${suffix}`,
60
- text: `a-skel-block a-skel-block--text${suffix}`,
61
- image: `a-skel-block a-skel-block--image${suffix}`,
62
- circle: `a-skel-block a-skel-block--circle${suffix}`,
63
- });
149
+ onBeforeUnmount(() => {
150
+ if (captureFrame !== undefined) cancelAnimationFrame(captureFrame);
64
151
  });
65
-
66
- /* Cache-miss fallback path: walk the slot's vnodes synchronously so the FIRST
67
- * paint already shows a skeleton that mirrors the component's HTML structure
68
- * (same tags, classes, hierarchy → same flex/grid/spacing/sizing utilities
69
- * still apply). If the slot is empty / only renders comments (e.g. the user
70
- * gates the whole template on `v-if="data"`), we get an empty array back and
71
- * fall through to the generic shimmer block. */
72
- const structuralVNodes = computed(() => (props.loading ? (slots.default?.() ?? []) : []));
73
- const hasStructure = computed(() => structuralVNodes.value.length > 0);
74
152
  </script>
75
153
 
76
154
  <template>
77
155
  <div
78
- ref="wrapperRef"
79
- :class="cn('a-skeleton', props.class)"
156
+ :class="cn('a-skeleton', `a-skeleton--mode-${props.mode}`, props.class)"
80
157
  :data-loading="props.loading ? '' : undefined"
158
+ role="status"
159
+ :aria-busy="props.loading ? 'true' : undefined"
160
+ :aria-live="props.loading ? 'polite' : undefined"
81
161
  >
82
- <template v-if="props.loading">
83
- <!-- Cache hit: pixel-aligned positioned blocks from a previous measurement.
84
- Styles are pre-computed during capture so the loop below never calls
85
- 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. -->
86
166
  <div
87
- v-if="cached"
88
- class="a-skeleton__layer"
89
- :style="layerStyle"
90
- role="status"
91
- aria-live="polite"
92
- 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"
93
171
  >
94
- <template v-for="(node, idx) in cached.nodes" :key="idx">
95
- <template v-if="node.lineStyles">
96
- <div
97
- v-for="(lineStyle, i) in node.lineStyles"
98
- :key="`${idx}-${i}`"
99
- :class="blockClassByType.text"
100
- :style="lineStyle"
101
- />
102
- </template>
103
- <div v-else :class="blockClassByType[node.type]" :style="node.style" />
104
- </template>
172
+ <slot />
105
173
  </div>
106
174
 
107
- <!-- Cache miss + slot has structure: render a structural skeleton derived
108
- 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. -->
109
180
  <div
110
- v-else-if="hasStructure"
111
- class="a-skeleton__structural"
112
- role="status"
113
- aria-live="polite"
114
- aria-busy="true"
181
+ v-if="props.loading && hasContent"
182
+ class="a-skeleton__mirror a-skeleton__mirror--fallback"
115
183
  >
116
184
  <StructuralSkeleton
117
- :vnodes="structuralVNodes"
185
+ :vnodes="mirrorVNodes"
118
186
  :animation="animationClass"
119
187
  :max-depth="maxDepth"
120
188
  :max-nodes="maxNodes"
121
189
  />
122
190
  </div>
123
191
 
124
- <!-- Cache miss + nothing to walk: generic shimmer. -->
125
- <div v-else class="a-skeleton__fallback" role="status" aria-busy="true">
126
- <slot name="fallback">
127
- <div
128
- class="a-skel-block a-skel-block--block a-skel-fallback-default"
129
- :class="animationClass"
130
- />
131
- </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" />
132
201
  </div>
133
202
  </template>
134
203
 
135
- <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>
136
225
  </div>
137
226
  </template>
138
227
 
@@ -142,20 +231,20 @@ const hasStructure = computed(() => structuralVNodes.value.length > 0);
142
231
  position: relative;
143
232
  }
144
233
 
145
- /* `.a-skeleton__layer` + `.a-skeleton__layer > .a-skel-block` layout/containment
146
- * live in `styles.src.css` so they're shared with the public `<ASkeletonLayer>`
147
- * component. */
234
+ .a-skeleton[data-loading] {
235
+ overflow: hidden;
236
+ overflow: clip;
237
+ overflow-clip-margin: 0;
238
+ }
148
239
 
149
- .a-skeleton__structural :deep(*) {
150
- /* Disable text caret/selection on the structural copy so it doesn't look
151
- * interactive. Layout (flex/grid/spacing/sizing) flows through unchanged. */
240
+ .a-skeleton__mirror :deep(*) {
152
241
  user-select: none;
153
242
  pointer-events: none;
154
243
  }
155
244
 
156
- .a-skeleton__structural :deep(button),
157
- .a-skeleton__structural :deep(input),
158
- .a-skeleton__structural :deep(a) {
245
+ .a-skeleton__mirror :deep(button),
246
+ .a-skeleton__mirror :deep(input),
247
+ .a-skeleton__mirror :deep(a) {
159
248
  cursor: default;
160
249
  }
161
250
 
@@ -164,4 +253,33 @@ const hasStructure = computed(() => structuralVNodes.value.length > 0);
164
253
  height: 4rem;
165
254
  border-radius: 0.5rem;
166
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
+ }
167
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>