@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
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, type HTMLAttributes } from 'vue';
|
|
3
|
+
import { cn } from '@alikhalilll/a-ui-base';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
level?: 1 | 2 | 3 | 4 | 5 | 6;
|
|
7
|
+
width?: number | string;
|
|
8
|
+
animation?: 'pulse' | 'shimmer' | 'wave' | 'none';
|
|
9
|
+
class?: HTMLAttributes['class'];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const props = withDefaults(defineProps<Props>(), { level: 2, animation: 'pulse' });
|
|
13
|
+
|
|
14
|
+
const animClass = computed(() =>
|
|
15
|
+
props.animation === 'none' ? null : `a-skel-anim-${props.animation}`
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const HEIGHT_BY_LEVEL: Record<number, string> = {
|
|
19
|
+
1: '2.25rem',
|
|
20
|
+
2: '1.75rem',
|
|
21
|
+
3: '1.5rem',
|
|
22
|
+
4: '1.25rem',
|
|
23
|
+
5: '1.125rem',
|
|
24
|
+
6: '1rem',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const rootStyle = computed(() => ({
|
|
28
|
+
width:
|
|
29
|
+
props.width !== undefined
|
|
30
|
+
? typeof props.width === 'number'
|
|
31
|
+
? `${props.width}px`
|
|
32
|
+
: String(props.width)
|
|
33
|
+
: '60%',
|
|
34
|
+
height: HEIGHT_BY_LEVEL[props.level],
|
|
35
|
+
}));
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<template>
|
|
39
|
+
<div
|
|
40
|
+
:class="cn('a-skel a-skel-variant-heading', animClass, props.class)"
|
|
41
|
+
:style="rootStyle"
|
|
42
|
+
role="status"
|
|
43
|
+
aria-busy="true"
|
|
44
|
+
>
|
|
45
|
+
<span class="a-skel-sr-only">Loading heading…</span>
|
|
46
|
+
</div>
|
|
47
|
+
</template>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, type HTMLAttributes } from 'vue';
|
|
3
|
+
import { cn } from '@alikhalilll/a-ui-base';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
ratio?: string;
|
|
7
|
+
width?: number | string;
|
|
8
|
+
height?: number | string;
|
|
9
|
+
showIcon?: boolean;
|
|
10
|
+
animation?: 'pulse' | 'shimmer' | 'wave' | 'none';
|
|
11
|
+
class?: HTMLAttributes['class'];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
15
|
+
ratio: '16 / 9',
|
|
16
|
+
showIcon: true,
|
|
17
|
+
animation: 'pulse',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const animClass = computed(() =>
|
|
21
|
+
props.animation === 'none' ? null : `a-skel-anim-${props.animation}`
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const rootStyle = computed(() => {
|
|
25
|
+
const s: Record<string, string> = {};
|
|
26
|
+
if (props.ratio) s.aspectRatio = props.ratio;
|
|
27
|
+
if (props.width !== undefined)
|
|
28
|
+
s.width = typeof props.width === 'number' ? `${props.width}px` : String(props.width);
|
|
29
|
+
if (props.height !== undefined)
|
|
30
|
+
s.height = typeof props.height === 'number' ? `${props.height}px` : String(props.height);
|
|
31
|
+
return s;
|
|
32
|
+
});
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<div
|
|
37
|
+
:class="cn('a-skel a-skel-variant-image', animClass, props.class)"
|
|
38
|
+
:style="rootStyle"
|
|
39
|
+
role="status"
|
|
40
|
+
aria-busy="true"
|
|
41
|
+
>
|
|
42
|
+
<svg
|
|
43
|
+
v-if="props.showIcon"
|
|
44
|
+
class="size-10"
|
|
45
|
+
viewBox="0 0 24 24"
|
|
46
|
+
fill="none"
|
|
47
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
48
|
+
aria-hidden="true"
|
|
49
|
+
>
|
|
50
|
+
<path
|
|
51
|
+
d="M19 5H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2Zm-3.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM19 17H5l3.5-4.5 2.5 3 3.5-4.5L19 17Z"
|
|
52
|
+
fill="currentColor"
|
|
53
|
+
/>
|
|
54
|
+
</svg>
|
|
55
|
+
<span class="a-skel-sr-only">Loading image…</span>
|
|
56
|
+
</div>
|
|
57
|
+
</template>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, type HTMLAttributes } from 'vue';
|
|
3
|
+
import { cn } from '@alikhalilll/a-ui-base';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
width?: number | string;
|
|
7
|
+
height?: number | string;
|
|
8
|
+
animation?: 'pulse' | 'shimmer' | 'wave' | 'none';
|
|
9
|
+
class?: HTMLAttributes['class'];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const props = withDefaults(defineProps<Props>(), { width: '100%', height: 40, animation: 'pulse' });
|
|
13
|
+
|
|
14
|
+
const animClass = computed(() =>
|
|
15
|
+
props.animation === 'none' ? null : `a-skel-anim-${props.animation}`
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const rootStyle = computed(() => ({
|
|
19
|
+
width: typeof props.width === 'number' ? `${props.width}px` : String(props.width),
|
|
20
|
+
height: typeof props.height === 'number' ? `${props.height}px` : String(props.height),
|
|
21
|
+
}));
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<div
|
|
26
|
+
:class="cn('a-skel a-skel-variant-input', animClass, props.class)"
|
|
27
|
+
:style="rootStyle"
|
|
28
|
+
role="status"
|
|
29
|
+
aria-busy="true"
|
|
30
|
+
>
|
|
31
|
+
<span class="a-skel-sr-only">Loading input…</span>
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, type HTMLAttributes } from 'vue';
|
|
3
|
+
import { cn } from '@alikhalilll/a-ui-base';
|
|
4
|
+
import ASkeletonAvatar from './ASkeletonAvatar.vue';
|
|
5
|
+
import ASkeletonText from './ASkeletonText.vue';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
avatar?: boolean;
|
|
9
|
+
lines?: 1 | 2 | 3;
|
|
10
|
+
trailing?: boolean;
|
|
11
|
+
animation?: 'pulse' | 'shimmer' | 'wave' | 'none';
|
|
12
|
+
class?: HTMLAttributes['class'];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
16
|
+
avatar: true,
|
|
17
|
+
lines: 2,
|
|
18
|
+
trailing: false,
|
|
19
|
+
animation: 'pulse',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const animClass = computed(() =>
|
|
23
|
+
props.animation === 'none' ? null : `a-skel-anim-${props.animation}`
|
|
24
|
+
);
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<div :class="cn('a-skel-variant-list-item', props.class)" role="status" aria-busy="true">
|
|
29
|
+
<ASkeletonAvatar v-if="props.avatar" :size="40" :animation="props.animation" />
|
|
30
|
+
<div class="a-skel-variant-list-item__body">
|
|
31
|
+
<ASkeletonText :lines="props.lines" :animation="props.animation" />
|
|
32
|
+
</div>
|
|
33
|
+
<div
|
|
34
|
+
v-if="props.trailing"
|
|
35
|
+
:class="cn('a-skel a-skel-variant-button', animClass)"
|
|
36
|
+
:style="{ width: '40px', height: '24px' }"
|
|
37
|
+
/>
|
|
38
|
+
<span class="a-skel-sr-only">Loading list item…</span>
|
|
39
|
+
</div>
|
|
40
|
+
</template>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, type HTMLAttributes } from 'vue';
|
|
3
|
+
import { cn } from '@alikhalilll/a-ui-base';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
rows?: number;
|
|
7
|
+
columns?: number;
|
|
8
|
+
showHeader?: boolean;
|
|
9
|
+
animation?: 'pulse' | 'shimmer' | 'wave' | 'none';
|
|
10
|
+
class?: HTMLAttributes['class'];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
14
|
+
rows: 5,
|
|
15
|
+
columns: 4,
|
|
16
|
+
showHeader: true,
|
|
17
|
+
animation: 'pulse',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const animClass = computed(() =>
|
|
21
|
+
props.animation === 'none' ? null : `a-skel-anim-${props.animation}`
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const rowStyle = computed(() => ({
|
|
25
|
+
gridTemplateColumns: `repeat(${props.columns}, minmax(0, 1fr))`,
|
|
26
|
+
}));
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<template>
|
|
30
|
+
<div :class="cn('a-skel-variant-table', props.class)" role="status" aria-busy="true">
|
|
31
|
+
<div v-if="props.showHeader" class="a-skel-variant-table__row" :style="rowStyle">
|
|
32
|
+
<div
|
|
33
|
+
v-for="c in props.columns"
|
|
34
|
+
:key="`h-${c}`"
|
|
35
|
+
:class="cn('a-skel a-skel-variant-heading', animClass)"
|
|
36
|
+
:style="{ height: '1.5rem', width: '70%' }"
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
<div v-for="r in props.rows" :key="r" class="a-skel-variant-table__row" :style="rowStyle">
|
|
40
|
+
<div
|
|
41
|
+
v-for="c in props.columns"
|
|
42
|
+
:key="`${r}-${c}`"
|
|
43
|
+
:class="cn('a-skel a-skel-variant-text', animClass)"
|
|
44
|
+
:style="{ width: c === 1 ? '85%' : c === props.columns ? '50%' : '70%' }"
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
<span class="a-skel-sr-only">Loading table…</span>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, type HTMLAttributes } from 'vue';
|
|
3
|
+
import { cn } from '@alikhalilll/a-ui-base';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
lines?: number;
|
|
7
|
+
width?: number | string;
|
|
8
|
+
/** Animation variant. Default `'pulse'`. */
|
|
9
|
+
animation?: 'pulse' | 'shimmer' | 'wave' | 'none';
|
|
10
|
+
class?: HTMLAttributes['class'];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const props = withDefaults(defineProps<Props>(), { lines: 1, animation: 'pulse' });
|
|
14
|
+
|
|
15
|
+
const animClass = computed(() =>
|
|
16
|
+
props.animation === 'none' ? null : `a-skel-anim-${props.animation}`
|
|
17
|
+
);
|
|
18
|
+
const rootStyle = computed(() =>
|
|
19
|
+
props.width !== undefined
|
|
20
|
+
? { width: typeof props.width === 'number' ? `${props.width}px` : String(props.width) }
|
|
21
|
+
: undefined
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
function widthForLine(i: number): string {
|
|
25
|
+
/* Vuetify-style: last line ≈ 65 %, intermediate lines ≈ 85–100 %.
|
|
26
|
+
* This adds rhythm so a 3-line paragraph doesn't look like a brick. */
|
|
27
|
+
if (i === props.lines - 1 && props.lines > 1) return '65%';
|
|
28
|
+
const palette = ['100%', '92%', '88%', '95%'];
|
|
29
|
+
return palette[i % palette.length]!;
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<div
|
|
35
|
+
:class="cn('a-skel-variants-text', props.class)"
|
|
36
|
+
:style="rootStyle"
|
|
37
|
+
role="status"
|
|
38
|
+
aria-live="polite"
|
|
39
|
+
aria-busy="true"
|
|
40
|
+
>
|
|
41
|
+
<div
|
|
42
|
+
v-for="i in props.lines"
|
|
43
|
+
:key="i"
|
|
44
|
+
:class="cn('a-skel a-skel-variant-text', animClass)"
|
|
45
|
+
:style="{ width: widthForLine(i - 1), marginTop: i > 1 ? '0.5em' : undefined }"
|
|
46
|
+
/>
|
|
47
|
+
<span class="a-skel-sr-only">Loading…</span>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, type HTMLAttributes } from 'vue';
|
|
3
|
+
import { cn } from '@alikhalilll/a-ui-base';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
ratio?: string;
|
|
7
|
+
width?: number | string;
|
|
8
|
+
height?: number | string;
|
|
9
|
+
showIcon?: boolean;
|
|
10
|
+
animation?: 'pulse' | 'shimmer' | 'wave' | 'none';
|
|
11
|
+
class?: HTMLAttributes['class'];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
15
|
+
ratio: '16 / 9',
|
|
16
|
+
showIcon: true,
|
|
17
|
+
animation: 'pulse',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const animClass = computed(() =>
|
|
21
|
+
props.animation === 'none' ? null : `a-skel-anim-${props.animation}`
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const rootStyle = computed(() => {
|
|
25
|
+
const s: Record<string, string> = {};
|
|
26
|
+
if (props.ratio) s.aspectRatio = props.ratio;
|
|
27
|
+
if (props.width !== undefined)
|
|
28
|
+
s.width = typeof props.width === 'number' ? `${props.width}px` : String(props.width);
|
|
29
|
+
if (props.height !== undefined)
|
|
30
|
+
s.height = typeof props.height === 'number' ? `${props.height}px` : String(props.height);
|
|
31
|
+
return s;
|
|
32
|
+
});
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<div
|
|
37
|
+
:class="cn('a-skel a-skel-variant-video', animClass, props.class)"
|
|
38
|
+
:style="rootStyle"
|
|
39
|
+
role="status"
|
|
40
|
+
aria-busy="true"
|
|
41
|
+
>
|
|
42
|
+
<svg
|
|
43
|
+
v-if="props.showIcon"
|
|
44
|
+
class="size-12"
|
|
45
|
+
viewBox="0 0 24 24"
|
|
46
|
+
fill="none"
|
|
47
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
48
|
+
aria-hidden="true"
|
|
49
|
+
>
|
|
50
|
+
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="1.5" />
|
|
51
|
+
<path d="M10 8l6 4-6 4V8Z" fill="currentColor" />
|
|
52
|
+
</svg>
|
|
53
|
+
<span class="a-skel-sr-only">Loading video…</span>
|
|
54
|
+
</div>
|
|
55
|
+
</template>
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
import { onBeforeUnmount, watch } from 'vue';
|
|
2
|
-
import type { CachedShape } from '../types';
|
|
2
|
+
import type { CachedShape, StructuralShape } from '../types';
|
|
3
3
|
import { walkDom } from '../utils/walkDom';
|
|
4
4
|
|
|
5
|
-
export
|
|
5
|
+
export type ProbeShape = CachedShape | StructuralShape;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pluggable capture strategy. `walkDom` is the default (flat, absolute-
|
|
9
|
+
* positioned `CachedShape`). Recipe 3 passes `walkStructural` to produce a
|
|
10
|
+
* tree-shaped `StructuralShape` instead.
|
|
11
|
+
*/
|
|
12
|
+
export type CaptureStrategy<S extends ProbeShape> = (
|
|
13
|
+
el: HTMLElement,
|
|
14
|
+
options: { maxDepth: number; maxNodes?: number; minSize?: number }
|
|
15
|
+
) => S;
|
|
16
|
+
|
|
17
|
+
export interface ShapeProbeOptions<S extends ProbeShape = CachedShape> {
|
|
6
18
|
maxDepth: number;
|
|
7
|
-
/** Forwarded to
|
|
19
|
+
/** Forwarded to the capture strategy. Default 500. */
|
|
8
20
|
maxNodes?: number;
|
|
9
|
-
/** Forwarded to
|
|
21
|
+
/** Forwarded to the capture strategy. Default 4. */
|
|
10
22
|
minSize?: number;
|
|
11
23
|
/**
|
|
12
24
|
* Debounce window for `ResizeObserver`-triggered re-captures, in ms.
|
|
@@ -15,7 +27,12 @@ export interface ShapeProbeOptions {
|
|
|
15
27
|
* always immediate via `requestAnimationFrame`.
|
|
16
28
|
*/
|
|
17
29
|
resizeDebounceMs?: number;
|
|
18
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Capture strategy. Default: `walkDom` (flat positioned-block model).
|
|
32
|
+
* Pass `walkStructural` for the tree-shaped Recipe 3 model.
|
|
33
|
+
*/
|
|
34
|
+
capture?: CaptureStrategy<S>;
|
|
35
|
+
onCapture: (shape: S) => void;
|
|
19
36
|
}
|
|
20
37
|
|
|
21
38
|
const DEFAULT_RESIZE_DEBOUNCE_MS = 150;
|
|
@@ -30,16 +47,20 @@ const DEFAULT_RESIZE_DEBOUNCE_MS = 150;
|
|
|
30
47
|
* render queue.
|
|
31
48
|
* - Subsequent `ResizeObserver` callbacks are debounced (default 150 ms) so a
|
|
32
49
|
* drag-resize doesn't trigger a fresh DOM walk per frame.
|
|
33
|
-
* -
|
|
34
|
-
* descendants) returns in bounded time.
|
|
50
|
+
* - The capture strategy itself enforces `maxNodes` so even a worst-case
|
|
51
|
+
* capture (10k descendants) returns in bounded time.
|
|
35
52
|
*/
|
|
36
|
-
export function useShapeProbe
|
|
53
|
+
export function useShapeProbe<S extends ProbeShape = CachedShape>(
|
|
54
|
+
getTarget: () => HTMLElement | null,
|
|
55
|
+
options: ShapeProbeOptions<S>
|
|
56
|
+
): void {
|
|
37
57
|
let observer: ResizeObserver | undefined;
|
|
38
58
|
let frame: number | undefined;
|
|
39
59
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
40
60
|
let hasCaptured = false;
|
|
41
61
|
|
|
42
62
|
const debounceMs = options.resizeDebounceMs ?? DEFAULT_RESIZE_DEBOUNCE_MS;
|
|
63
|
+
const captureFn = options.capture ?? (walkDom as unknown as CaptureStrategy<S>);
|
|
43
64
|
|
|
44
65
|
function cleanup() {
|
|
45
66
|
if (observer) {
|
|
@@ -57,11 +78,14 @@ export function useShapeProbe(getTarget: () => HTMLElement | null, options: Shap
|
|
|
57
78
|
}
|
|
58
79
|
|
|
59
80
|
function capture(el: HTMLElement) {
|
|
60
|
-
const result =
|
|
81
|
+
const result = captureFn(el, {
|
|
61
82
|
maxDepth: options.maxDepth,
|
|
62
83
|
maxNodes: options.maxNodes,
|
|
63
84
|
minSize: options.minSize,
|
|
64
85
|
});
|
|
86
|
+
/* Both shape variants expose `width`, `height`, `nodes` — guard against an
|
|
87
|
+
* empty capture (target rendered with zero size or an unreachable subtree)
|
|
88
|
+
* so the cache never receives a meaningless entry. */
|
|
65
89
|
if (result.width > 0 && result.height > 0 && result.nodes.length > 0) {
|
|
66
90
|
hasCaptured = true;
|
|
67
91
|
options.onCapture(result);
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { shallowRef, type Ref } from 'vue';
|
|
2
|
-
import type {
|
|
2
|
+
import type { StructuralShape } from '../types';
|
|
3
3
|
import { useShapeProbe } from './useShapeProbe';
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
clearCachedStructural,
|
|
6
|
+
getCachedStructural,
|
|
7
|
+
setCachedStructural,
|
|
8
|
+
} from './useSkeletonCache';
|
|
9
|
+
import { walkStructural } from '../utils/walkStructural';
|
|
6
10
|
|
|
7
11
|
export interface UseSkeletonOptions {
|
|
8
12
|
/**
|
|
@@ -22,33 +26,37 @@ export interface UseSkeletonOptions {
|
|
|
22
26
|
target?: () => HTMLElement | null;
|
|
23
27
|
/** Persist to `localStorage` so first-paint after reload skips the cold start. Default false. */
|
|
24
28
|
persist?: boolean;
|
|
25
|
-
/** Forwarded to `
|
|
29
|
+
/** Forwarded to `walkStructural`. Default 12. */
|
|
26
30
|
maxDepth?: number;
|
|
27
|
-
/** Forwarded to `
|
|
31
|
+
/** Forwarded to `walkStructural`. Default 500. */
|
|
28
32
|
maxNodes?: number;
|
|
29
|
-
/** Forwarded to `
|
|
33
|
+
/** Forwarded to `walkStructural`. Default 4. */
|
|
30
34
|
minSize?: number;
|
|
31
35
|
/** Forwarded to `useShapeProbe`. Default 150 ms. */
|
|
32
36
|
resizeDebounceMs?: number;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
export interface UseSkeletonReturn {
|
|
36
|
-
/** Reactive captured shape — `undefined` on cache miss.
|
|
37
|
-
shape: Readonly<Ref<
|
|
40
|
+
/** Reactive captured shape — `undefined` on cache miss. Feed it to `<ASkeletonLayer>`. */
|
|
41
|
+
shape: Readonly<Ref<StructuralShape | undefined>>;
|
|
38
42
|
/**
|
|
39
43
|
* Synchronously measure the current target and write to cache. Returns the
|
|
40
44
|
* captured shape, or `undefined` if the target wasn't available / nothing
|
|
41
45
|
* worth measuring was rendered. Use when you want to force a capture outside
|
|
42
46
|
* the automatic `ResizeObserver` flow (e.g. after an animation settles).
|
|
43
47
|
*/
|
|
44
|
-
captureNow: () =>
|
|
48
|
+
captureNow: () => StructuralShape | undefined;
|
|
45
49
|
/** Drop the cache entry for this `cacheKey`. The reactive `shape` flips to `undefined`. */
|
|
46
50
|
clear: () => void;
|
|
47
51
|
}
|
|
48
52
|
|
|
53
|
+
const DEFAULT_MAX_DEPTH = 12;
|
|
54
|
+
|
|
49
55
|
/**
|
|
50
|
-
* High-level building block for
|
|
51
|
-
* + reactivity
|
|
56
|
+
* High-level building block for Recipe 3 — wires the structural capture +
|
|
57
|
+
* cache + reactivity around a target element. The reactive `shape` is fed to
|
|
58
|
+
* `<ASkeletonLayer>` which renders it in normal flow inside the consumer's
|
|
59
|
+
* container.
|
|
52
60
|
*
|
|
53
61
|
* ```ts
|
|
54
62
|
* const containerRef = ref<HTMLElement | null>(null);
|
|
@@ -65,46 +73,50 @@ export interface UseSkeletonReturn {
|
|
|
65
73
|
* </div>
|
|
66
74
|
* ```
|
|
67
75
|
*
|
|
68
|
-
* For more control, drop down to `useShapeProbe`
|
|
69
|
-
*
|
|
76
|
+
* For more control, drop down to `useShapeProbe` (pass `walkStructural` as the
|
|
77
|
+
* capture strategy) + `getCachedStructural` / `setCachedStructural` and compose
|
|
78
|
+
* your own orchestration.
|
|
70
79
|
*/
|
|
71
80
|
export function useSkeleton(options: UseSkeletonOptions): UseSkeletonReturn {
|
|
72
81
|
const persist = options.persist ?? false;
|
|
73
|
-
const maxDepth = options.maxDepth ??
|
|
82
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
74
83
|
|
|
75
|
-
const shape = shallowRef<
|
|
84
|
+
const shape = shallowRef<StructuralShape | undefined>(
|
|
85
|
+
getCachedStructural(options.cacheKey, persist)
|
|
86
|
+
);
|
|
76
87
|
|
|
77
88
|
if (options.target) {
|
|
78
89
|
const getTarget = options.target;
|
|
79
|
-
useShapeProbe(getTarget, {
|
|
90
|
+
useShapeProbe<StructuralShape>(getTarget, {
|
|
80
91
|
maxDepth,
|
|
81
92
|
maxNodes: options.maxNodes,
|
|
82
93
|
minSize: options.minSize,
|
|
83
94
|
resizeDebounceMs: options.resizeDebounceMs,
|
|
95
|
+
capture: walkStructural,
|
|
84
96
|
onCapture: (captured) => {
|
|
85
|
-
|
|
97
|
+
setCachedStructural(options.cacheKey, captured, persist);
|
|
86
98
|
shape.value = captured;
|
|
87
99
|
},
|
|
88
100
|
});
|
|
89
101
|
}
|
|
90
102
|
|
|
91
|
-
function captureNow():
|
|
103
|
+
function captureNow(): StructuralShape | undefined {
|
|
92
104
|
const el = options.target?.();
|
|
93
105
|
if (!el || typeof window === 'undefined') return undefined;
|
|
94
|
-
const captured =
|
|
106
|
+
const captured = walkStructural(el, {
|
|
95
107
|
maxDepth,
|
|
96
108
|
maxNodes: options.maxNodes,
|
|
97
109
|
minSize: options.minSize,
|
|
98
110
|
});
|
|
99
111
|
if (captured.width <= 0 || captured.height <= 0 || captured.nodes.length === 0)
|
|
100
112
|
return undefined;
|
|
101
|
-
|
|
113
|
+
setCachedStructural(options.cacheKey, captured, persist);
|
|
102
114
|
shape.value = captured;
|
|
103
115
|
return captured;
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
function clear(): void {
|
|
107
|
-
|
|
119
|
+
clearCachedStructural(options.cacheKey);
|
|
108
120
|
shape.value = undefined;
|
|
109
121
|
}
|
|
110
122
|
|