@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.
- package/.media/hero.png +0 -0
- package/.media/hero.svg +232 -0
- package/README.md +459 -170
- package/dist/index.cjs +3696 -828
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +534 -43
- package/dist/index.d.ts +534 -43
- package/dist/index.js +3677 -830
- 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 +216 -98
- 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 +282 -24
- package/src/index.ts +48 -2
- package/src/nuxt/index.ts +16 -0
- package/src/resolver/index.ts +16 -0
- package/src/types.ts +124 -5
- 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 +10 -4
|
@@ -1,138 +1,227 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, ref, shallowRef, useSlots, watch
|
|
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
|
|
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:
|
|
12
|
-
maxNodes:
|
|
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
|
|
44
|
+
const instanceId = useId();
|
|
45
|
+
void instanceId;
|
|
23
46
|
|
|
24
|
-
const
|
|
47
|
+
const animationClass = computed(() =>
|
|
48
|
+
props.animation === 'none' ? null : `a-skel-block--anim-${props.animation}`
|
|
49
|
+
);
|
|
25
50
|
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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.
|
|
83
|
-
<!--
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
class="a-
|
|
89
|
-
:
|
|
90
|
-
|
|
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
|
-
<
|
|
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
|
-
<!--
|
|
108
|
-
|
|
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-
|
|
111
|
-
class="a-
|
|
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="
|
|
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
|
-
<!--
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
234
|
+
.a-skeleton[data-loading] {
|
|
235
|
+
overflow: hidden;
|
|
236
|
+
overflow: clip;
|
|
237
|
+
overflow-clip-margin: 0;
|
|
238
|
+
}
|
|
148
239
|
|
|
149
|
-
.a-
|
|
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-
|
|
157
|
-
.a-
|
|
158
|
-
.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
|
|
2
|
+
import { computed } from 'vue';
|
|
3
3
|
import { cn } from '@alikhalilll/a-ui-base';
|
|
4
|
-
import type { ASkeletonLayerProps
|
|
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-
|
|
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
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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>
|