@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.
- package/LICENSE +21 -0
- package/README.md +324 -0
- package/dist/index.cjs +4513 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +351 -0
- package/dist/index.d.ts +351 -0
- package/dist/index.js +4501 -0
- package/dist/index.js.map +1 -0
- package/dist/nuxt/index.cjs +30 -0
- package/dist/nuxt/index.cjs.map +1 -0
- package/dist/nuxt/index.d.cts +25 -0
- package/dist/nuxt/index.d.ts +25 -0
- package/dist/nuxt/index.js +30 -0
- package/dist/nuxt/index.js.map +1 -0
- package/dist/resolver/index.cjs +25 -0
- package/dist/resolver/index.cjs.map +1 -0
- package/dist/resolver/index.d.cts +21 -0
- package/dist/resolver/index.d.ts +21 -0
- package/dist/resolver/index.js +25 -0
- package/dist/resolver/index.js.map +1 -0
- package/dist/styles.css +35 -0
- package/package.json +120 -0
- package/src/components/ASkeleton.vue +167 -0
- package/src/components/ASkeletonBlock.vue +74 -0
- package/src/components/ASkeletonLayer.vue +52 -0
- package/src/components/StructuralSkeleton.ts +41 -0
- package/src/composables/useShapeProbe.ts +108 -0
- package/src/composables/useSkeleton.ts +112 -0
- package/src/composables/useSkeletonCache.ts +141 -0
- package/src/index.ts +32 -0
- package/src/nuxt/index.ts +47 -0
- package/src/resolver/index.ts +36 -0
- package/src/types.ts +108 -0
- package/src/utils/buildStructuralSkeleton.ts +261 -0
- package/src/utils/fingerprint.ts +42 -0
- package/src/utils/walkDom.ts +226 -0
- package/web-types.json +172 -0
|
@@ -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
|
+
}
|