@alikhalilll/a-skeleton 1.0.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.
@@ -0,0 +1,41 @@
1
+ import { defineComponent, type PropType, type VNode, type VNodeArrayChildren } from 'vue';
2
+ import { buildStructuralSkeleton } from '../utils/buildStructuralSkeleton';
3
+
4
+ /**
5
+ * Renders a structural skeleton derived from a slot's vnode tree. Pure render
6
+ * function — no template, no scoped styles — so the parent's class strings
7
+ * pass through unchanged and Tailwind utilities continue to drive layout.
8
+ *
9
+ * `maxNodes` is forwarded to the walker; cap defaults to 300 (see
10
+ * `buildStructuralSkeleton`). Beyond that the walk stops emitting and the cap
11
+ * propagates back as a clipped tree, keeping first-paint bounded.
12
+ */
13
+ export const StructuralSkeleton = defineComponent({
14
+ name: 'StructuralSkeleton',
15
+ props: {
16
+ vnodes: {
17
+ type: Array as unknown as PropType<VNodeArrayChildren>,
18
+ required: true,
19
+ },
20
+ animation: {
21
+ type: String as PropType<string | null>,
22
+ default: null,
23
+ },
24
+ maxDepth: {
25
+ type: Number,
26
+ default: 8,
27
+ },
28
+ maxNodes: {
29
+ type: Number,
30
+ default: 300,
31
+ },
32
+ },
33
+ setup(props) {
34
+ return (): VNode[] =>
35
+ buildStructuralSkeleton(props.vnodes, {
36
+ animationClass: props.animation,
37
+ maxDepth: props.maxDepth,
38
+ maxNodes: props.maxNodes,
39
+ });
40
+ },
41
+ });
@@ -0,0 +1,108 @@
1
+ import { onBeforeUnmount, watch } from 'vue';
2
+ import type { CachedShape } from '../types';
3
+ import { walkDom } from '../utils/walkDom';
4
+
5
+ export interface ShapeProbeOptions {
6
+ maxDepth: number;
7
+ /** Forwarded to `walkDom`. Default 500. */
8
+ maxNodes?: number;
9
+ /** Forwarded to `walkDom`. Default 4. */
10
+ minSize?: number;
11
+ /**
12
+ * Debounce window for `ResizeObserver`-triggered re-captures, in ms.
13
+ * Default 150. Prevents a re-walk every animation frame while the user is
14
+ * actively dragging the viewport edge. The first capture (initial mount) is
15
+ * always immediate via `requestAnimationFrame`.
16
+ */
17
+ resizeDebounceMs?: number;
18
+ onCapture: (shape: CachedShape) => void;
19
+ }
20
+
21
+ const DEFAULT_RESIZE_DEBOUNCE_MS = 150;
22
+
23
+ /**
24
+ * Observe `getTarget()` and capture its rendered shape whenever the element
25
+ * appears or resizes.
26
+ *
27
+ * Performance:
28
+ * - Initial capture runs via `requestAnimationFrame` so it sneaks into the
29
+ * first idle window after mount — no synchronous layout from inside the
30
+ * render queue.
31
+ * - Subsequent `ResizeObserver` callbacks are debounced (default 150 ms) so a
32
+ * drag-resize doesn't trigger a fresh DOM walk per frame.
33
+ * - `walkDom` itself enforces `maxNodes` so even a worst-case capture (10k
34
+ * descendants) returns in bounded time.
35
+ */
36
+ export function useShapeProbe(getTarget: () => HTMLElement | null, options: ShapeProbeOptions) {
37
+ let observer: ResizeObserver | undefined;
38
+ let frame: number | undefined;
39
+ let timer: ReturnType<typeof setTimeout> | undefined;
40
+ let hasCaptured = false;
41
+
42
+ const debounceMs = options.resizeDebounceMs ?? DEFAULT_RESIZE_DEBOUNCE_MS;
43
+
44
+ function cleanup() {
45
+ if (observer) {
46
+ observer.disconnect();
47
+ observer = undefined;
48
+ }
49
+ if (frame !== undefined) {
50
+ cancelAnimationFrame(frame);
51
+ frame = undefined;
52
+ }
53
+ if (timer !== undefined) {
54
+ clearTimeout(timer);
55
+ timer = undefined;
56
+ }
57
+ }
58
+
59
+ function capture(el: HTMLElement) {
60
+ const result = walkDom(el, {
61
+ maxDepth: options.maxDepth,
62
+ maxNodes: options.maxNodes,
63
+ minSize: options.minSize,
64
+ });
65
+ if (result.width > 0 && result.height > 0 && result.nodes.length > 0) {
66
+ hasCaptured = true;
67
+ options.onCapture(result);
68
+ }
69
+ }
70
+
71
+ function scheduleImmediate(el: HTMLElement) {
72
+ if (frame !== undefined) cancelAnimationFrame(frame);
73
+ frame = requestAnimationFrame(() => {
74
+ frame = undefined;
75
+ capture(el);
76
+ });
77
+ }
78
+
79
+ function scheduleDebounced(el: HTMLElement) {
80
+ if (timer !== undefined) clearTimeout(timer);
81
+ timer = setTimeout(() => {
82
+ timer = undefined;
83
+ capture(el);
84
+ }, debounceMs);
85
+ }
86
+
87
+ watch(
88
+ getTarget,
89
+ (el) => {
90
+ cleanup();
91
+ hasCaptured = false;
92
+ if (!el || typeof window === 'undefined') return;
93
+ scheduleImmediate(el);
94
+ if (typeof ResizeObserver !== 'undefined') {
95
+ observer = new ResizeObserver(() => {
96
+ /* ResizeObserver fires once on observe() — let the rAF capture above
97
+ * handle the initial measurement, then debounce everything that
98
+ * follows so a drag-resize doesn't trigger a re-walk per frame. */
99
+ if (hasCaptured) scheduleDebounced(el);
100
+ });
101
+ observer.observe(el);
102
+ }
103
+ },
104
+ { immediate: true, flush: 'post' }
105
+ );
106
+
107
+ onBeforeUnmount(cleanup);
108
+ }
@@ -0,0 +1,112 @@
1
+ import { shallowRef, type Ref } from 'vue';
2
+ import type { CachedShape } from '../types';
3
+ import { useShapeProbe } from './useShapeProbe';
4
+ import { clearCached, getCached, setCached } from './useSkeletonCache';
5
+ import { walkDom } from '../utils/walkDom';
6
+
7
+ export interface UseSkeletonOptions {
8
+ /**
9
+ * Identifier for the shape cache. Pass the same key wherever the same visual
10
+ * structure appears so the captured shape replays everywhere.
11
+ */
12
+ cacheKey: string;
13
+ /**
14
+ * Getter for the element to measure. When it returns `null` (e.g. during the
15
+ * loading state), no measurement happens. The probe re-arms automatically
16
+ * once the getter returns an element again.
17
+ *
18
+ * Typical pattern: return `null` while loading so the real content is the
19
+ * only thing ever measured, then the cache feeds the skeleton on the next
20
+ * load.
21
+ */
22
+ target?: () => HTMLElement | null;
23
+ /** Persist to `localStorage` so first-paint after reload skips the cold start. Default false. */
24
+ persist?: boolean;
25
+ /** Forwarded to `walkDom`. Default 6. */
26
+ maxDepth?: number;
27
+ /** Forwarded to `walkDom`. Default 500. */
28
+ maxNodes?: number;
29
+ /** Forwarded to `walkDom`. Default 4. */
30
+ minSize?: number;
31
+ /** Forwarded to `useShapeProbe`. Default 150 ms. */
32
+ resizeDebounceMs?: number;
33
+ }
34
+
35
+ export interface UseSkeletonReturn {
36
+ /** Reactive captured shape — `undefined` on cache miss. Replace your skeleton render path. */
37
+ shape: Readonly<Ref<CachedShape | undefined>>;
38
+ /**
39
+ * Synchronously measure the current target and write to cache. Returns the
40
+ * captured shape, or `undefined` if the target wasn't available / nothing
41
+ * worth measuring was rendered. Use when you want to force a capture outside
42
+ * the automatic `ResizeObserver` flow (e.g. after an animation settles).
43
+ */
44
+ captureNow: () => CachedShape | undefined;
45
+ /** Drop the cache entry for this `cacheKey`. The reactive `shape` flips to `undefined`. */
46
+ clear: () => void;
47
+ }
48
+
49
+ /**
50
+ * High-level building block for custom skeleton UIs. Wires up the probe + cache
51
+ * + reactivity so the consumer just renders something using the reactive shape:
52
+ *
53
+ * ```ts
54
+ * const containerRef = ref<HTMLElement | null>(null);
55
+ * const { shape } = useSkeleton({
56
+ * cacheKey: 'user-card',
57
+ * target: () => (loading.value ? null : containerRef.value),
58
+ * });
59
+ * ```
60
+ *
61
+ * ```vue
62
+ * <div ref="containerRef">
63
+ * <ASkeletonLayer v-if="loading && shape" :shape="shape" />
64
+ * <UserCard v-else :data="user" />
65
+ * </div>
66
+ * ```
67
+ *
68
+ * For more control, drop down to `useShapeProbe` + `getCached`/`setCached` and
69
+ * compose your own.
70
+ */
71
+ export function useSkeleton(options: UseSkeletonOptions): UseSkeletonReturn {
72
+ const persist = options.persist ?? false;
73
+ const maxDepth = options.maxDepth ?? 6;
74
+
75
+ const shape = shallowRef<CachedShape | undefined>(getCached(options.cacheKey, persist));
76
+
77
+ if (options.target) {
78
+ const getTarget = options.target;
79
+ useShapeProbe(getTarget, {
80
+ maxDepth,
81
+ maxNodes: options.maxNodes,
82
+ minSize: options.minSize,
83
+ resizeDebounceMs: options.resizeDebounceMs,
84
+ onCapture: (captured) => {
85
+ setCached(options.cacheKey, captured, persist);
86
+ shape.value = captured;
87
+ },
88
+ });
89
+ }
90
+
91
+ function captureNow(): CachedShape | undefined {
92
+ const el = options.target?.();
93
+ if (!el || typeof window === 'undefined') return undefined;
94
+ const captured = walkDom(el, {
95
+ maxDepth,
96
+ maxNodes: options.maxNodes,
97
+ minSize: options.minSize,
98
+ });
99
+ if (captured.width <= 0 || captured.height <= 0 || captured.nodes.length === 0)
100
+ return undefined;
101
+ setCached(options.cacheKey, captured, persist);
102
+ shape.value = captured;
103
+ return captured;
104
+ }
105
+
106
+ function clear(): void {
107
+ clearCached(options.cacheKey);
108
+ shape.value = undefined;
109
+ }
110
+
111
+ return { shape, captureNow, clear };
112
+ }
@@ -0,0 +1,141 @@
1
+ import type { CSSProperties } from 'vue';
2
+ import type { CachedShape, ShapeNode } from '../types';
3
+
4
+ const memory = new Map<string, CachedShape>();
5
+ const STORAGE_PREFIX = 'a-skeleton:';
6
+
7
+ /**
8
+ * Lookup a captured shape by key. Reads in-memory first, then `localStorage` if
9
+ * `persist` is enabled. SSR-safe — bypasses `window` access on the server.
10
+ * Rehydrates pre-computed styles for shapes deserialized from older sessions
11
+ * (Object.freeze + style/lineStyles don't survive `JSON.stringify` round-trip).
12
+ */
13
+ export function getCached(key: string, persist: boolean): CachedShape | undefined {
14
+ const hit = memory.get(key);
15
+ if (hit) return hit;
16
+ if (!persist || typeof window === 'undefined') return undefined;
17
+ try {
18
+ const raw = window.localStorage.getItem(STORAGE_PREFIX + key);
19
+ if (!raw) return undefined;
20
+ const parsed = JSON.parse(raw) as CachedShape;
21
+ const rehydrated = rehydrateShape(parsed);
22
+ memory.set(key, rehydrated);
23
+ return rehydrated;
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ }
28
+
29
+ /** Store a captured shape. `persist=true` mirrors to `localStorage`. */
30
+ export function setCached(key: string, value: CachedShape, persist: boolean): void {
31
+ memory.set(key, value);
32
+ if (!persist || typeof window === 'undefined') return;
33
+ try {
34
+ /* Only the geometry survives the round-trip; styles get rebuilt on read. */
35
+ const lean = { width: value.width, height: value.height, nodes: leanNodes(value.nodes) };
36
+ window.localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(lean));
37
+ } catch {
38
+ /* quota exceeded / disabled storage — silently degrade to in-memory only. */
39
+ }
40
+ }
41
+
42
+ /** Drop a single entry (or all entries when no key). Exposed for tests + manual invalidation. */
43
+ export function clearCached(key?: string): void {
44
+ if (!key) {
45
+ memory.clear();
46
+ if (typeof window === 'undefined') return;
47
+ try {
48
+ for (const k of Object.keys(window.localStorage)) {
49
+ if (k.startsWith(STORAGE_PREFIX)) window.localStorage.removeItem(k);
50
+ }
51
+ } catch {
52
+ /* ignore */
53
+ }
54
+ return;
55
+ }
56
+ memory.delete(key);
57
+ if (typeof window === 'undefined') return;
58
+ try {
59
+ window.localStorage.removeItem(STORAGE_PREFIX + key);
60
+ } catch {
61
+ /* ignore */
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Rebuild `style` + `lineStyles` for nodes that lost them via serialization.
67
+ * Walks the array in-place where possible and freezes the result so the render
68
+ * path stays allocation-free.
69
+ */
70
+ function rehydrateShape(shape: CachedShape): CachedShape {
71
+ const nodes = shape.nodes.map((n) => (n.style ? n : freezeNodeStyles(n)));
72
+ return Object.freeze({
73
+ nodes: Object.freeze(nodes),
74
+ width: shape.width,
75
+ height: shape.height,
76
+ truncated: shape.truncated,
77
+ }) as CachedShape;
78
+ }
79
+
80
+ function leanNodes(nodes: ReadonlyArray<ShapeNode>): Partial<ShapeNode>[] {
81
+ /* Strip the (re-derivable) style fields before persisting so the localStorage
82
+ * payload stays small — a 500-node shape would otherwise serialize ~500 frozen
83
+ * style objects redundantly. */
84
+ return nodes.map((n) => ({
85
+ type: n.type,
86
+ x: n.x,
87
+ y: n.y,
88
+ w: n.w,
89
+ h: n.h,
90
+ radius: n.radius,
91
+ lines: n.lines,
92
+ lineHeight: n.lineHeight,
93
+ }));
94
+ }
95
+
96
+ function freezeNodeStyles(node: ShapeNode): ShapeNode {
97
+ const style: CSSProperties = Object.freeze({
98
+ left: `${node.x}px`,
99
+ top: `${node.y}px`,
100
+ width: `${node.w}px`,
101
+ height: `${node.h}px`,
102
+ borderRadius: `${node.radius}px`,
103
+ });
104
+
105
+ let lineStyles: ReadonlyArray<Readonly<CSSProperties>> | undefined;
106
+ if (node.type === 'text' && node.lines && node.lines > 1) {
107
+ const lh = node.lineHeight ?? Math.round(node.h / node.lines);
108
+ const barHeight = Math.max(8, Math.round(lh * 0.7));
109
+ const widthFull = `${node.w}px`;
110
+ const widthLast = `${Math.max(40, Math.round(node.w * 0.7))}px`;
111
+ const heightStr = `${barHeight}px`;
112
+ const radiusStr = `${node.radius}px`;
113
+ const arr: Readonly<CSSProperties>[] = [];
114
+ for (let i = 1; i <= node.lines; i++) {
115
+ const isLast = i === node.lines;
116
+ arr.push(
117
+ Object.freeze<CSSProperties>({
118
+ left: `${node.x}px`,
119
+ top: `${node.y + (i - 1) * lh}px`,
120
+ width: isLast ? widthLast : widthFull,
121
+ height: heightStr,
122
+ borderRadius: radiusStr,
123
+ })
124
+ );
125
+ }
126
+ lineStyles = Object.freeze(arr);
127
+ }
128
+
129
+ return Object.freeze({
130
+ type: node.type,
131
+ x: node.x,
132
+ y: node.y,
133
+ w: node.w,
134
+ h: node.h,
135
+ radius: node.radius,
136
+ lines: node.lines,
137
+ lineHeight: node.lineHeight,
138
+ style,
139
+ lineStyles,
140
+ });
141
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ /* Components */
2
+ export { default as ASkeleton } from './components/ASkeleton.vue';
3
+ export { default as ASkeletonLayer } from './components/ASkeletonLayer.vue';
4
+ export { default as ASkeletonBlock } from './components/ASkeletonBlock.vue';
5
+ export { StructuralSkeleton } from './components/StructuralSkeleton';
6
+
7
+ /* Composables */
8
+ export { useSkeleton } from './composables/useSkeleton';
9
+ export { useShapeProbe } from './composables/useShapeProbe';
10
+ export { getCached, setCached, clearCached } from './composables/useSkeletonCache';
11
+
12
+ /* Pure utilities — for direct measurement, vnode-walking, default key derivation. */
13
+ export { walkDom } from './utils/walkDom';
14
+ export { buildStructuralSkeleton } from './utils/buildStructuralSkeleton';
15
+ export { fingerprintSlot } from './utils/fingerprint';
16
+
17
+ /* Types */
18
+ export type {
19
+ ASkeletonProps,
20
+ ASkeletonSlots,
21
+ ASkeletonLayerProps,
22
+ ASkeletonBlockProps,
23
+ CachedShape,
24
+ ShapeNode,
25
+ ShapeNodeType,
26
+ SkeletonAnimation,
27
+ SkeletonFallback,
28
+ } from './types';
29
+ export type { UseSkeletonOptions, UseSkeletonReturn } from './composables/useSkeleton';
30
+ export type { ShapeProbeOptions } from './composables/useShapeProbe';
31
+ export type { WalkOptions } from './utils/walkDom';
32
+ export type { BuildOptions } from './utils/buildStructuralSkeleton';
@@ -0,0 +1,47 @@
1
+ import { defineNuxtModule, addComponent } from '@nuxt/kit';
2
+ import type { NuxtModule } from '@nuxt/schema';
3
+
4
+ /**
5
+ * `@alikhalilll/a-skeleton/nuxt` — registers the skeleton components for Nuxt
6
+ * auto-import. The auto-imported names are the package's native names:
7
+ *
8
+ * <ASkeleton :loading>…</ASkeleton> <!-- slot wrapper, two-layer flow -->
9
+ * <ASkeletonLayer :shape="…" /> <!-- replay a CachedShape directly -->
10
+ * <ASkeletonBlock type="circle" w="64" h="64" /> <!-- hand-crafted skeleton primitive -->
11
+ *
12
+ * modules: ['@alikhalilll/a-skeleton/nuxt'],
13
+ * aSkeleton: { prefix: 'A' }, // optional
14
+ *
15
+ * Styles are not auto-injected; add `'@alikhalilll/a-skeleton/styles.css'` to
16
+ * your Nuxt `css` array.
17
+ */
18
+
19
+ export interface ModuleOptions {
20
+ /** Optional prefix prepended to the registered component name. */
21
+ prefix?: string;
22
+ }
23
+
24
+ const COMPONENTS: Record<string, string> = {
25
+ ASkeleton: '@alikhalilll/a-skeleton',
26
+ ASkeletonLayer: '@alikhalilll/a-skeleton',
27
+ ASkeletonBlock: '@alikhalilll/a-skeleton',
28
+ };
29
+
30
+ const module: NuxtModule<ModuleOptions> = defineNuxtModule<ModuleOptions>({
31
+ meta: {
32
+ name: '@alikhalilll/a-skeleton',
33
+ configKey: 'aSkeleton',
34
+ compatibility: { nuxt: '>=3.0.0' },
35
+ },
36
+ defaults: { prefix: '' },
37
+ setup(opts) {
38
+ const prefix = opts.prefix ?? '';
39
+ for (const [exportName, from] of Object.entries(COMPONENTS)) {
40
+ const baseName = exportName.startsWith('A') ? exportName.slice(1) : exportName;
41
+ const registeredName = `${prefix}${prefix ? baseName : exportName}`;
42
+ addComponent({ name: registeredName, export: exportName, filePath: from });
43
+ }
44
+ },
45
+ });
46
+
47
+ export default module;
@@ -0,0 +1,36 @@
1
+ import type { ComponentResolver } from 'unplugin-vue-components';
2
+
3
+ /**
4
+ * `@alikhalilll/a-skeleton/resolver` — auto-import resolver for non-Nuxt
5
+ * Vite/Webpack consumers via `unplugin-vue-components`.
6
+ *
7
+ * import Components from 'unplugin-vue-components/vite';
8
+ * import ASkeletonResolver from '@alikhalilll/a-skeleton/resolver';
9
+ * export default { plugins: [Components({ resolvers: [ASkeletonResolver()] })] };
10
+ *
11
+ * Registers `<ASkeleton>`, `<ASkeletonLayer>`, `<ASkeletonBlock>`.
12
+ */
13
+
14
+ export interface ResolverOptions {
15
+ /** Optional prefix the consumer uses (e.g. `'A'`). Defaults to the native `A*` names. */
16
+ prefix?: string;
17
+ }
18
+
19
+ const COMPONENT_TO_ENTRY: Record<string, string> = {
20
+ ASkeleton: '@alikhalilll/a-skeleton',
21
+ ASkeletonLayer: '@alikhalilll/a-skeleton',
22
+ ASkeletonBlock: '@alikhalilll/a-skeleton',
23
+ };
24
+
25
+ export default function ASkeletonResolver(opts: ResolverOptions = {}): ComponentResolver {
26
+ const prefix = opts.prefix ?? '';
27
+ return {
28
+ type: 'component',
29
+ resolve(name) {
30
+ const bare = prefix && name.startsWith(prefix) ? name.slice(prefix.length) : name;
31
+ const from = COMPONENT_TO_ENTRY[bare];
32
+ if (!from) return;
33
+ return { name: bare, from };
34
+ },
35
+ };
36
+ }
package/src/types.ts ADDED
@@ -0,0 +1,108 @@
1
+ import type { CSSProperties, HTMLAttributes } from 'vue';
2
+
3
+ export type ShapeNodeType = 'block' | 'text' | 'image' | 'circle';
4
+
5
+ /** A single shimmer block in a captured skeleton — positioned absolutely inside the layer. */
6
+ export interface ShapeNode {
7
+ type: ShapeNodeType;
8
+ x: number;
9
+ y: number;
10
+ w: number;
11
+ h: number;
12
+ radius: number;
13
+ /** For multi-line text leaves, how many lines to render. */
14
+ lines?: number;
15
+ /** Line-height used when expanding `lines` into stacked bars. */
16
+ lineHeight?: number;
17
+ /**
18
+ * Pre-computed (and frozen) inline style for the block. Built once during
19
+ * capture so the render path never allocates a style object per node per
20
+ * render — meaningful when a cache has hundreds of nodes.
21
+ */
22
+ style?: Readonly<CSSProperties>;
23
+ /**
24
+ * Pre-computed line styles for multi-line text. `lines` long when set. Same
25
+ * reason as `style`: avoids `textLineStyle(node, i)` calls in the template.
26
+ */
27
+ lineStyles?: ReadonlyArray<Readonly<CSSProperties>>;
28
+ }
29
+
30
+ export interface CachedShape {
31
+ nodes: ReadonlyArray<ShapeNode>;
32
+ width: number;
33
+ height: number;
34
+ /** Was the walk cut short by `maxNodes`? Surface so consumers can tune the budget. */
35
+ truncated?: boolean;
36
+ }
37
+
38
+ export type SkeletonAnimation = 'shimmer' | 'pulse' | 'none';
39
+ export type SkeletonFallback = 'shimmer' | 'block';
40
+
41
+ export interface ASkeletonProps {
42
+ /** When true, render the captured skeleton (or `fallback` slot) instead of the default slot. */
43
+ loading: boolean;
44
+ /**
45
+ * Identifier used to look up + persist the captured shape. Falls back to the
46
+ * slot's first child component name, then `'anonymous'`. Pass explicitly when
47
+ * the same component renders different shapes depending on props.
48
+ */
49
+ cacheKey?: string;
50
+ /** Max recursion depth during shape capture. Default 6. */
51
+ maxDepth?: number;
52
+ /**
53
+ * Hard cap on captured / structurally rendered nodes. Hit this and the walk
54
+ * bails out with `truncated: true`. Default 500 — enough for cards, lists,
55
+ * full forms; cut deliberately for screens with hundreds of repeated rows.
56
+ */
57
+ maxNodes?: number;
58
+ /**
59
+ * Skip elements smaller than this many CSS pixels on either axis during
60
+ * capture. Default 4. Filters out hairlines / inline padding shims that
61
+ * inflate the node count without adding visual signal.
62
+ */
63
+ minNodeSize?: number;
64
+ /** Persist captured shapes to `localStorage` so first-visit skeletons survive reloads. Default false. */
65
+ persist?: boolean;
66
+ /** Animation variant applied to skeleton blocks. Default `'shimmer'`. */
67
+ animation?: SkeletonAnimation;
68
+ /** What to render when no cached shape is available yet. Default `'shimmer'`. */
69
+ fallback?: SkeletonFallback;
70
+ /** Class on the outer wrapper. */
71
+ class?: HTMLAttributes['class'];
72
+ }
73
+
74
+ export interface ASkeletonSlots {
75
+ /** The real content. Rendered when `loading` is false; measured to build the skeleton. */
76
+ default?: () => unknown;
77
+ /** Custom UI to render on a cache miss. Defaults to a single shimmer block. */
78
+ fallback?: () => unknown;
79
+ }
80
+
81
+ export interface ASkeletonLayerProps {
82
+ /** Shape captured by `walkDom` / `useSkeleton`. Renders nothing when undefined. */
83
+ shape?: CachedShape;
84
+ /** Animation variant. Default `'shimmer'`. */
85
+ animation?: SkeletonAnimation;
86
+ /** Class on the layer wrapper. */
87
+ class?: HTMLAttributes['class'];
88
+ }
89
+
90
+ export interface ASkeletonBlockProps {
91
+ /** Visual variant. Default `'block'`. */
92
+ type?: ShapeNodeType;
93
+ /** Width as CSS length. Number = pixels. Falls back to whatever the surrounding layout gives. */
94
+ w?: number | string;
95
+ /** Height as CSS length. Number = pixels. */
96
+ h?: number | string;
97
+ /**
98
+ * Border radius as CSS length. Number = pixels. For `type='circle'`, this
99
+ * defaults to `'50%'` if not provided.
100
+ */
101
+ radius?: number | string;
102
+ /** For `type='text'`, render N stacked bars. Last bar is 70% width. Default 1. */
103
+ lines?: number;
104
+ /** Animation variant. Default `'shimmer'`. */
105
+ animation?: SkeletonAnimation;
106
+ /** Class on the root (the single block, or the stack wrapper for multi-line text). */
107
+ class?: HTMLAttributes['class'];
108
+ }