@alikhalilll/a-smart-skeleton 0.0.0-stage → 2.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 +356 -2
- package/dist/_chunks/core.js +2314 -0
- package/dist/_chunks/core.js.map +1 -0
- package/dist/_chunks/index.d.ts +431 -0
- package/dist/core/index.cjs +67 -0
- package/dist/core/index.d.cts +2 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/element/index.cjs +47 -0
- package/dist/element/index.cjs.map +1 -0
- package/dist/element/index.d.cts +24 -0
- package/dist/element/index.d.ts +24 -0
- package/dist/element/index.js +46 -0
- package/dist/element/index.js.map +1 -0
- package/dist/index.cjs +512 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +204 -0
- package/dist/index.d.ts +204 -0
- package/dist/index.js +441 -0
- package/dist/index.js.map +1 -0
- package/dist/nuxt/index.cjs +36 -0
- package/dist/nuxt/index.cjs.map +1 -0
- package/dist/nuxt/index.d.cts +30 -0
- package/dist/nuxt/index.d.ts +30 -0
- package/dist/nuxt/index.js +36 -0
- package/dist/nuxt/index.js.map +1 -0
- package/dist/resolver/index.cjs +26 -0
- package/dist/resolver/index.cjs.map +1 -0
- package/dist/resolver/index.d.cts +19 -0
- package/dist/resolver/index.d.ts +19 -0
- package/dist/resolver/index.js +26 -0
- package/dist/resolver/index.js.map +1 -0
- package/dist/styles.css +136 -0
- package/package.json +144 -3
- package/src/components/ASmartSkeleton.vue +228 -0
- package/src/components/ASmartSkeletonBox.vue +23 -0
- package/src/components/ASmartSkeletonProvider.vue +34 -0
- package/src/components/ASmartSkeletonSuspense.vue +142 -0
- package/src/composables/useSmartSkeleton.ts +20 -0
- package/src/composables/useStyles.ts +8 -0
- package/src/core/boot.ts +63 -0
- package/src/core/canvas.ts +7 -0
- package/src/core/constants.ts +76 -0
- package/src/core/debug.ts +154 -0
- package/src/core/dev.ts +53 -0
- package/src/core/fade.ts +490 -0
- package/src/core/ghost.ts +236 -0
- package/src/core/images.ts +28 -0
- package/src/core/index.ts +71 -0
- package/src/core/inline.generated.ts +9 -0
- package/src/core/measure.ts +571 -0
- package/src/core/memory.ts +146 -0
- package/src/core/motion.ts +12 -0
- package/src/core/plan.ts +368 -0
- package/src/core/reveal.ts +89 -0
- package/src/core/reveals.ts +39 -0
- package/src/core/shapes.ts +84 -0
- package/src/core/shimmer.ts +229 -0
- package/src/core/skeleton.ts +43 -0
- package/src/core/snapshot.ts +159 -0
- package/src/core/styles.ts +187 -0
- package/src/core/sweep.ts +203 -0
- package/src/core/theme.ts +162 -0
- package/src/element/index.ts +59 -0
- package/src/index.ts +27 -0
- package/src/nuxt/index.ts +59 -0
- package/src/resolver/index.ts +35 -0
- package/src/types.ts +100 -0
- package/src/vue/Skeleton.ts +275 -0
- package/src/vue/Suspense.ts +157 -0
- package/src/vue/config.ts +78 -0
- package/src/vue/index.ts +7 -0
- package/web-types.json +256 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ComponentResolver } from 'unplugin-vue-components';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `@alikhalilll/a-smart-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 ASmartSkeletonResolver from '@alikhalilll/a-smart-skeleton/resolver';
|
|
9
|
+
* export default { plugins: [Components({ resolvers: [ASmartSkeletonResolver()] })] };
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ResolverOptions {
|
|
13
|
+
/** Optional prefix the consumer uses (e.g. `'A'`). Defaults to the native `A*` names. */
|
|
14
|
+
prefix?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const COMPONENT_TO_ENTRY: Record<string, string> = {
|
|
18
|
+
ASmartSkeleton: '@alikhalilll/a-smart-skeleton',
|
|
19
|
+
ASmartSkeletonBox: '@alikhalilll/a-smart-skeleton',
|
|
20
|
+
ASmartSkeletonSuspense: '@alikhalilll/a-smart-skeleton',
|
|
21
|
+
ASmartSkeletonProvider: '@alikhalilll/a-smart-skeleton',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default function ASmartSkeletonResolver(opts: ResolverOptions = {}): ComponentResolver {
|
|
25
|
+
const prefix = opts.prefix ?? '';
|
|
26
|
+
return {
|
|
27
|
+
type: 'component',
|
|
28
|
+
resolve(name) {
|
|
29
|
+
const bare = prefix && name.startsWith(prefix) ? name.slice(prefix.length) : name;
|
|
30
|
+
const from = COMPONENT_TO_ENTRY[bare];
|
|
31
|
+
if (!from) return;
|
|
32
|
+
return { name: bare, from };
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { VNodeChild } from 'vue';
|
|
2
|
+
import type {
|
|
3
|
+
RevealMode,
|
|
4
|
+
SkeletonBoxOptions,
|
|
5
|
+
SkeletonMode,
|
|
6
|
+
SkeletonText,
|
|
7
|
+
SkeletonTheme,
|
|
8
|
+
} from './core';
|
|
9
|
+
|
|
10
|
+
export interface ASmartSkeletonBoxProps extends SkeletonBoxOptions {
|
|
11
|
+
class?: string;
|
|
12
|
+
style?: Record<string, string | number>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Slots: `default` (with `{ data }` when `data` is bound), `skeleton`, `empty`,
|
|
17
|
+
* `error` (with `{ error }`).
|
|
18
|
+
*/
|
|
19
|
+
export interface ASmartSkeletonProps<T = unknown> {
|
|
20
|
+
/** Shows the skeleton, even over `data`. With `data`, it also shows while there is none. */
|
|
21
|
+
loading?: boolean;
|
|
22
|
+
/** Loading while `null` or `undefined`; handed to the default slot as `{ data }`. */
|
|
23
|
+
data?: T | null;
|
|
24
|
+
/** Stands in for `data` to draw the skeleton before any has loaded. */
|
|
25
|
+
sample?: T;
|
|
26
|
+
/** Remembers the shape under this key across visits. */
|
|
27
|
+
id?: string;
|
|
28
|
+
/** Read to screen readers while loading, in place of the skeleton — "Loading profile". */
|
|
29
|
+
label?: string;
|
|
30
|
+
/** Renders the `empty` slot instead of the content. */
|
|
31
|
+
empty?: boolean;
|
|
32
|
+
/** When set, renders the `error` slot with `{ error }` instead of the content. */
|
|
33
|
+
error?: unknown;
|
|
34
|
+
/** Overrides the provider's reveal for this skeleton alone. */
|
|
35
|
+
reveal?: RevealMode;
|
|
36
|
+
/** Called once content has come in over a skeleton; `@reveal` in a template. */
|
|
37
|
+
onReveal?: () => void;
|
|
38
|
+
class?: string;
|
|
39
|
+
style?: Record<string, string | number>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ASmartSkeletonSlots<T = unknown> {
|
|
43
|
+
/** The content. Receives `{ data }` when `data` is bound. */
|
|
44
|
+
default?: (props: { data: T }) => VNodeChild;
|
|
45
|
+
/** Shown while loading instead of the measured skeleton. */
|
|
46
|
+
skeleton?: () => VNodeChild;
|
|
47
|
+
/** Shown when `empty` is set. Falls back to the content. */
|
|
48
|
+
empty?: () => VNodeChild;
|
|
49
|
+
/** Shown when `error` is set. Receives `{ error }`. */
|
|
50
|
+
error?: (props: { error: unknown }) => VNodeChild;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ASmartSkeletonEmits {
|
|
54
|
+
/** Content has come in over a skeleton. */
|
|
55
|
+
reveal: () => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Slots: `default`, the content that suspends; `skeleton`, the content rendered from sample data. */
|
|
59
|
+
export interface ASmartSkeletonSuspenseProps {
|
|
60
|
+
/** Remembers the shape under this key across visits. */
|
|
61
|
+
id?: string;
|
|
62
|
+
/** Read to screen readers while suspended, in place of the skeleton. */
|
|
63
|
+
label?: string;
|
|
64
|
+
/** Overrides the provider's reveal for this boundary alone. */
|
|
65
|
+
reveal?: RevealMode;
|
|
66
|
+
/** Called once content has come in over the skeleton; `@reveal` in a template. */
|
|
67
|
+
onReveal?: () => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ASmartSkeletonSuspenseSlots {
|
|
71
|
+
default?: () => VNodeChild;
|
|
72
|
+
skeleton?: () => VNodeChild;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ASmartSkeletonSuspenseEmits {
|
|
76
|
+
/** Content has come in over the skeleton. */
|
|
77
|
+
reveal: () => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Settings for every skeleton inside. Per request on the server, as a nonce must be. */
|
|
81
|
+
export interface ASmartSkeletonProviderProps {
|
|
82
|
+
/** Set on the inline script and stylesheets rendered, for a Content Security Policy. */
|
|
83
|
+
nonce?: string;
|
|
84
|
+
/** Colours for light and dark; `mode` picks between them. */
|
|
85
|
+
theme?: SkeletonTheme;
|
|
86
|
+
/** `system` follows `prefers-color-scheme`; the others pin one set of colours. */
|
|
87
|
+
mode?: SkeletonMode;
|
|
88
|
+
/** Where text bars run: from `top` (ascender, cap-height, x-height) down to `bottom` (baseline, descender). */
|
|
89
|
+
text?: SkeletonText;
|
|
90
|
+
/** How content replaces its skeleton: `smart` (the default), `together`, `sequence`, `sweep`, or a `RevealSpec` of your own. */
|
|
91
|
+
reveal?: RevealMode;
|
|
92
|
+
/** Outlines every reveal with where its skeleton came from and whether anything moved. */
|
|
93
|
+
debug?: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface ASmartSkeletonProviderSlots {
|
|
97
|
+
default?: () => VNodeChild;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type SmartSkeletonConfig = ASmartSkeletonProviderProps;
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineComponent,
|
|
3
|
+
getCurrentInstance,
|
|
4
|
+
h,
|
|
5
|
+
onBeforeMount,
|
|
6
|
+
onBeforeUnmount,
|
|
7
|
+
onMounted,
|
|
8
|
+
onUnmounted,
|
|
9
|
+
ref,
|
|
10
|
+
Transition,
|
|
11
|
+
watch,
|
|
12
|
+
type PropType,
|
|
13
|
+
type VNodeChild,
|
|
14
|
+
} from 'vue';
|
|
15
|
+
import {
|
|
16
|
+
addStyles,
|
|
17
|
+
ATTR_BAR,
|
|
18
|
+
ATTR_GHOST,
|
|
19
|
+
ATTR_LAYER,
|
|
20
|
+
ATTR_LOADING,
|
|
21
|
+
ATTR_OWN,
|
|
22
|
+
ATTR_PANE,
|
|
23
|
+
ATTR_REVEAL,
|
|
24
|
+
ATTR_SNAPSHOT,
|
|
25
|
+
ATTR_STATUS,
|
|
26
|
+
checkNonce,
|
|
27
|
+
fadeIn,
|
|
28
|
+
fadeOut,
|
|
29
|
+
Ghost,
|
|
30
|
+
INLINE_SCRIPT,
|
|
31
|
+
INLINE_STUB,
|
|
32
|
+
Memory,
|
|
33
|
+
removeStyles,
|
|
34
|
+
shimmer,
|
|
35
|
+
skeletonBoxStyle,
|
|
36
|
+
type MemoryOptions,
|
|
37
|
+
type RevealMode,
|
|
38
|
+
} from '../core';
|
|
39
|
+
import { claimStub, useSmartSkeleton } from './config';
|
|
40
|
+
|
|
41
|
+
const length = [String, Number] as PropType<string | number>;
|
|
42
|
+
|
|
43
|
+
/** Adds the stylesheet on mount and removes it on unmount — reference counted, so siblings share one copy. */
|
|
44
|
+
export function useStyles() {
|
|
45
|
+
onBeforeMount(addStyles);
|
|
46
|
+
onUnmounted(removeStyles);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One shimmering bar. Numbers are read as pixels.
|
|
51
|
+
*
|
|
52
|
+
* ```vue
|
|
53
|
+
* <ASmartSkeletonBox width="60%" height="1em" radius="0.25em" />
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export const ASmartSkeletonBox = defineComponent({
|
|
57
|
+
name: 'ASmartSkeletonBox',
|
|
58
|
+
props: {
|
|
59
|
+
height: length,
|
|
60
|
+
width: length,
|
|
61
|
+
radius: length,
|
|
62
|
+
},
|
|
63
|
+
setup(props) {
|
|
64
|
+
const element = ref<HTMLElement | null>(null);
|
|
65
|
+
useStyles();
|
|
66
|
+
onMounted(() => {
|
|
67
|
+
if (element.value) shimmer(element.value);
|
|
68
|
+
});
|
|
69
|
+
return () =>
|
|
70
|
+
h('span', {
|
|
71
|
+
ref: element,
|
|
72
|
+
[ATTR_BAR]: '',
|
|
73
|
+
style: skeletonBoxStyle(props),
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The measured loading pane. Internal — `<ASmartSkeleton>` renders it.
|
|
80
|
+
*
|
|
81
|
+
* The ghost is laid out but never seen; the layer holds bones in a shadow root,
|
|
82
|
+
* so hydration never touches what was measured.
|
|
83
|
+
*/
|
|
84
|
+
export const Loading = defineComponent({
|
|
85
|
+
name: 'ASmartSkeletonLoading',
|
|
86
|
+
props: {
|
|
87
|
+
id: String,
|
|
88
|
+
exact: Boolean,
|
|
89
|
+
refetch: Boolean,
|
|
90
|
+
label: String,
|
|
91
|
+
},
|
|
92
|
+
setup(props, { slots }) {
|
|
93
|
+
const element = ref<HTMLElement | null>(null);
|
|
94
|
+
const config = useSmartSkeleton();
|
|
95
|
+
const script = claimStub() ? INLINE_STUB : INLINE_SCRIPT;
|
|
96
|
+
let ghost: Ghost | null = null;
|
|
97
|
+
|
|
98
|
+
const optionsOf = () => ({ id: props.id, exact: props.exact, refetch: props.refetch });
|
|
99
|
+
onMounted(() => {
|
|
100
|
+
checkNonce(config.nonce);
|
|
101
|
+
if (element.value) ghost = new Ghost(element.value, optionsOf());
|
|
102
|
+
});
|
|
103
|
+
// Options-only changes update the live ghost in place; the observers and shadow sheet
|
|
104
|
+
// outlive a prop flip, which happens every data arrival in data-driven mode.
|
|
105
|
+
watch(
|
|
106
|
+
() => [props.id, props.exact, props.refetch],
|
|
107
|
+
() => ghost?.update(optionsOf())
|
|
108
|
+
);
|
|
109
|
+
onBeforeUnmount(() => ghost?.destroy());
|
|
110
|
+
|
|
111
|
+
// Snapshot and bones are Ghost's to fill, never Vue's.
|
|
112
|
+
return () => [
|
|
113
|
+
props.label ? h('span', { [ATTR_STATUS]: '' }, props.label) : null,
|
|
114
|
+
h(
|
|
115
|
+
'div',
|
|
116
|
+
{
|
|
117
|
+
ref: element,
|
|
118
|
+
[ATTR_LOADING]: '',
|
|
119
|
+
'aria-hidden': 'true',
|
|
120
|
+
inert: '',
|
|
121
|
+
},
|
|
122
|
+
[
|
|
123
|
+
h('div', { [ATTR_GHOST]: '' }, slots.default?.()),
|
|
124
|
+
h('div', { [ATTR_SNAPSHOT]: '' }),
|
|
125
|
+
h('div', { [ATTR_LAYER]: '' }),
|
|
126
|
+
]
|
|
127
|
+
),
|
|
128
|
+
h('script', { nonce: config.nonce, innerHTML: script }),
|
|
129
|
+
];
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const Content = defineComponent({
|
|
134
|
+
name: 'ASmartSkeletonContent',
|
|
135
|
+
props: {
|
|
136
|
+
memory: { type: Object as PropType<MemoryOptions>, required: true },
|
|
137
|
+
},
|
|
138
|
+
setup(props, { slots }) {
|
|
139
|
+
const element = ref<HTMLElement | null>(null);
|
|
140
|
+
let memory: Memory | null = null;
|
|
141
|
+
|
|
142
|
+
onMounted(() => {
|
|
143
|
+
if (element.value) memory = new Memory(element.value, props.memory);
|
|
144
|
+
});
|
|
145
|
+
onBeforeUnmount(() => memory?.destroy());
|
|
146
|
+
|
|
147
|
+
return () => h('div', { ref: element }, slots.default?.());
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Shows a skeleton measured from the content it wraps, then reveals the content.
|
|
153
|
+
*
|
|
154
|
+
* ```vue
|
|
155
|
+
* <ASmartSkeleton :data="user" :sample="SAMPLE_USER">
|
|
156
|
+
* <template #default="{ data }">
|
|
157
|
+
* <UserCard :user="data" />
|
|
158
|
+
* </template>
|
|
159
|
+
* </ASmartSkeleton>
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
export const ASmartSkeleton = defineComponent({
|
|
163
|
+
name: 'ASmartSkeleton',
|
|
164
|
+
props: {
|
|
165
|
+
/** Shows the skeleton, even over `data`. With `data`, it also shows while there is none. */
|
|
166
|
+
loading: { type: Boolean, default: undefined },
|
|
167
|
+
/** Loading while `null` or `undefined`; handed to the default slot as `{ data }`. */
|
|
168
|
+
data: { type: null as unknown as PropType<unknown>, required: false },
|
|
169
|
+
/** Stands in for `data` to draw the skeleton before any has loaded. */
|
|
170
|
+
sample: { type: null as unknown as PropType<unknown>, required: false },
|
|
171
|
+
/** Remembers the shape under this key across visits. */
|
|
172
|
+
id: String,
|
|
173
|
+
/** Read to screen readers while loading, in place of the skeleton — "Loading profile". */
|
|
174
|
+
label: String,
|
|
175
|
+
empty: Boolean,
|
|
176
|
+
error: { type: null as unknown as PropType<unknown>, required: false },
|
|
177
|
+
/** Overrides the provider's reveal for this skeleton alone. */
|
|
178
|
+
reveal: [String, Object] as PropType<RevealMode>,
|
|
179
|
+
},
|
|
180
|
+
/** `reveal`: content has come in over a skeleton. */
|
|
181
|
+
emits: ['reveal'],
|
|
182
|
+
setup(props, { slots, emit }) {
|
|
183
|
+
const instance = getCurrentInstance();
|
|
184
|
+
const config = useSmartSkeleton();
|
|
185
|
+
// The last data shown, so a reload's skeleton has its exact shape — of the same `id` only.
|
|
186
|
+
let last: { id?: string; data: unknown } | null = null;
|
|
187
|
+
// The pane on screen and the one before it, so content knows whether it replaced a skeleton.
|
|
188
|
+
let shown: string | null = null;
|
|
189
|
+
let from: string | null = null;
|
|
190
|
+
useStyles();
|
|
191
|
+
|
|
192
|
+
return () => {
|
|
193
|
+
// Data-driven when `data` is bound at all, even to null.
|
|
194
|
+
const driven = !!instance?.vnode.props && 'data' in instance.vnode.props;
|
|
195
|
+
const data = props.data;
|
|
196
|
+
if (data != null) last = { id: props.id, data };
|
|
197
|
+
|
|
198
|
+
const show = (value: unknown): VNodeChild =>
|
|
199
|
+
driven
|
|
200
|
+
? value != null
|
|
201
|
+
? slots.default?.({ data: value })
|
|
202
|
+
: null
|
|
203
|
+
: slots.default?.({ data: undefined });
|
|
204
|
+
|
|
205
|
+
// A slot fed data has nothing to render without it, so that's a skeleton too.
|
|
206
|
+
const loading = !!props.loading || (driven && data == null && !props.error && !props.empty);
|
|
207
|
+
|
|
208
|
+
let key: string;
|
|
209
|
+
let content: VNodeChild;
|
|
210
|
+
if (loading) {
|
|
211
|
+
const previous = last as { id?: string; data: unknown } | null;
|
|
212
|
+
const real = data ?? (previous && previous.id === props.id ? previous.data : null);
|
|
213
|
+
key = 'skeleton';
|
|
214
|
+
content = slots.skeleton
|
|
215
|
+
? // Marked as drawing its own skeleton, so the reveal treats it as one.
|
|
216
|
+
h('div', { [ATTR_OWN]: '', 'aria-hidden': 'true' }, slots.skeleton())
|
|
217
|
+
: h(
|
|
218
|
+
Loading,
|
|
219
|
+
{
|
|
220
|
+
id: props.id,
|
|
221
|
+
exact: real != null,
|
|
222
|
+
refetch: data != null,
|
|
223
|
+
label: props.label,
|
|
224
|
+
},
|
|
225
|
+
() => show(real ?? props.sample)
|
|
226
|
+
);
|
|
227
|
+
} else if (props.error) {
|
|
228
|
+
key = 'error';
|
|
229
|
+
content = slots.error?.({ error: props.error });
|
|
230
|
+
} else if (props.empty) {
|
|
231
|
+
key = 'empty';
|
|
232
|
+
content = slots.empty?.() ?? show(data);
|
|
233
|
+
} else {
|
|
234
|
+
key = 'content';
|
|
235
|
+
content = h(
|
|
236
|
+
Content,
|
|
237
|
+
{
|
|
238
|
+
memory: {
|
|
239
|
+
id: props.id,
|
|
240
|
+
data,
|
|
241
|
+
rendered: driven,
|
|
242
|
+
sample: props.sample !== undefined,
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
() => show(data)
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (key !== shown) {
|
|
250
|
+
from = shown;
|
|
251
|
+
shown = key;
|
|
252
|
+
}
|
|
253
|
+
const revealing = key === 'content' && from === 'skeleton';
|
|
254
|
+
|
|
255
|
+
const reveal = props.reveal ?? config.reveal;
|
|
256
|
+
return h('div', { [ATTR_REVEAL]: '', 'aria-busy': loading || undefined }, [
|
|
257
|
+
h(
|
|
258
|
+
Transition,
|
|
259
|
+
{
|
|
260
|
+
css: false,
|
|
261
|
+
appear: true,
|
|
262
|
+
onAppear: (el, done) => fadeIn(el as HTMLElement, reveal, { initial: true }).then(done),
|
|
263
|
+
onEnter: (el, done) =>
|
|
264
|
+
fadeIn(el as HTMLElement, reveal).then(() => {
|
|
265
|
+
done();
|
|
266
|
+
if (revealing) emit('reveal');
|
|
267
|
+
}),
|
|
268
|
+
onLeave: (el, done) => fadeOut(el as HTMLElement).then(done),
|
|
269
|
+
},
|
|
270
|
+
() => h('div', { key, [ATTR_PANE]: '' }, [content])
|
|
271
|
+
),
|
|
272
|
+
]);
|
|
273
|
+
};
|
|
274
|
+
},
|
|
275
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { defineComponent, h, onBeforeUnmount, onMounted, ref, Suspense, type PropType } from 'vue';
|
|
2
|
+
import {
|
|
3
|
+
ATTR_LOADING,
|
|
4
|
+
ATTR_PANE,
|
|
5
|
+
ATTR_REVEAL,
|
|
6
|
+
boxOf,
|
|
7
|
+
fadeIn,
|
|
8
|
+
fadeOut,
|
|
9
|
+
Ghost,
|
|
10
|
+
Memory,
|
|
11
|
+
type RevealMode,
|
|
12
|
+
} from '../core';
|
|
13
|
+
import { useSmartSkeleton } from './config';
|
|
14
|
+
import { Loading, useStyles } from './Skeleton';
|
|
15
|
+
|
|
16
|
+
type State = { waited: boolean; exit: (() => void) | null };
|
|
17
|
+
const state = { type: Object as PropType<State>, required: true } as const;
|
|
18
|
+
|
|
19
|
+
const Fallback = defineComponent({
|
|
20
|
+
name: 'ASmartSkeletonFallback',
|
|
21
|
+
props: { id: String, label: String, reveal: [String, Object] as PropType<RevealMode>, state },
|
|
22
|
+
setup(props, { slots }) {
|
|
23
|
+
const element = ref<HTMLElement | null>(null);
|
|
24
|
+
const config = useSmartSkeleton();
|
|
25
|
+
|
|
26
|
+
onMounted(() => {
|
|
27
|
+
const pane = element.value;
|
|
28
|
+
const reveal = pane?.parentElement;
|
|
29
|
+
if (!pane || !reveal) return;
|
|
30
|
+
/* eslint-disable-next-line vue/no-mutating-props -- intentional shared-state coordination */
|
|
31
|
+
props.state.waited = true;
|
|
32
|
+
fadeIn(pane, props.reveal ?? config.reveal, { initial: true });
|
|
33
|
+
reveal.setAttribute('aria-busy', 'true');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
onBeforeUnmount(() => {
|
|
37
|
+
const pane = element.value;
|
|
38
|
+
const reveal = pane?.parentElement;
|
|
39
|
+
if (!pane || !reveal) return;
|
|
40
|
+
reveal.removeAttribute('aria-busy');
|
|
41
|
+
|
|
42
|
+
// Measured now, alone in flow; the copy fades out once the content has decided how it arrives.
|
|
43
|
+
const box = boxOf(pane);
|
|
44
|
+
const copy = pane.cloneNode(true) as HTMLElement;
|
|
45
|
+
/* eslint-disable-next-line vue/no-mutating-props -- intentional shared-state coordination */
|
|
46
|
+
props.state.exit = () => {
|
|
47
|
+
Object.assign(copy.style, {
|
|
48
|
+
position: 'absolute',
|
|
49
|
+
top: `${box.top}px`,
|
|
50
|
+
left: `${box.left}px`,
|
|
51
|
+
width: `${box.width}px`,
|
|
52
|
+
});
|
|
53
|
+
reveal.prepend(copy);
|
|
54
|
+
|
|
55
|
+
// A clone leaves its skeleton's shadow root behind; draw it again.
|
|
56
|
+
const loading = copy.querySelector<HTMLElement>(`[${ATTR_LOADING}]`);
|
|
57
|
+
if (loading) new Ghost(loading, { id: props.id, exact: true }).destroy();
|
|
58
|
+
fadeOut(copy, box).then(() => copy.remove());
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// A skeleton given is what the content will be — drawn as given, not swapped for a remembered shape.
|
|
63
|
+
return () =>
|
|
64
|
+
h('div', { ref: element, [ATTR_PANE]: '' }, [
|
|
65
|
+
h(Loading, { id: props.id, exact: !!slots.default, label: props.label }, slots.default),
|
|
66
|
+
]);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const Content = defineComponent({
|
|
71
|
+
name: 'ASmartSkeletonSuspenseContent',
|
|
72
|
+
props: {
|
|
73
|
+
id: String,
|
|
74
|
+
reveal: [String, Object] as PropType<RevealMode>,
|
|
75
|
+
state,
|
|
76
|
+
},
|
|
77
|
+
emits: ['reveal'],
|
|
78
|
+
setup(props, { slots, emit }) {
|
|
79
|
+
const element = ref<HTMLElement | null>(null);
|
|
80
|
+
const config = useSmartSkeleton();
|
|
81
|
+
let memory: Memory | null = null;
|
|
82
|
+
|
|
83
|
+
onMounted(() => {
|
|
84
|
+
const pane = element.value;
|
|
85
|
+
if (!pane) return;
|
|
86
|
+
const { waited } = props.state;
|
|
87
|
+
const arrival = fadeIn(pane, props.reveal ?? config.reveal, {
|
|
88
|
+
initial: !waited,
|
|
89
|
+
});
|
|
90
|
+
if (waited) arrival.then(() => emit('reveal'));
|
|
91
|
+
|
|
92
|
+
props.state.exit?.();
|
|
93
|
+
/* eslint-disable-next-line vue/no-mutating-props -- intentional shared-state coordination */
|
|
94
|
+
props.state.exit = null;
|
|
95
|
+
memory = new Memory(pane, { id: props.id });
|
|
96
|
+
});
|
|
97
|
+
onBeforeUnmount(() => memory?.destroy());
|
|
98
|
+
|
|
99
|
+
return () => h('div', { ref: element, [ATTR_PANE]: '' }, slots.default?.());
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A Suspense boundary whose fallback is a measured skeleton, crossfading into the content.
|
|
105
|
+
*
|
|
106
|
+
* ```vue
|
|
107
|
+
* <ASmartSkeletonSuspense>
|
|
108
|
+
* <LoadedQuote />
|
|
109
|
+
* <template #skeleton><Quote :quote="SAMPLE_QUOTE" /></template>
|
|
110
|
+
* </ASmartSkeletonSuspense>
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
export const ASmartSkeletonSuspense = defineComponent({
|
|
114
|
+
name: 'ASmartSkeletonSuspense',
|
|
115
|
+
props: {
|
|
116
|
+
/** Remembers the shape under this key across visits. */
|
|
117
|
+
id: String,
|
|
118
|
+
/** Read to screen readers while suspended, in place of the skeleton. */
|
|
119
|
+
label: String,
|
|
120
|
+
/** Overrides the provider's reveal for this boundary alone. */
|
|
121
|
+
reveal: [String, Object] as PropType<RevealMode>,
|
|
122
|
+
},
|
|
123
|
+
/** `reveal`: content has come in over the skeleton. */
|
|
124
|
+
emits: ['reveal'],
|
|
125
|
+
setup(props, { slots, emit }) {
|
|
126
|
+
useStyles();
|
|
127
|
+
const shared: State = { waited: false, exit: null };
|
|
128
|
+
|
|
129
|
+
return () =>
|
|
130
|
+
h('div', { [ATTR_REVEAL]: '' }, [
|
|
131
|
+
h(Suspense, null, {
|
|
132
|
+
default: () =>
|
|
133
|
+
h(
|
|
134
|
+
Content,
|
|
135
|
+
{
|
|
136
|
+
id: props.id,
|
|
137
|
+
reveal: props.reveal,
|
|
138
|
+
state: shared,
|
|
139
|
+
onReveal: () => emit('reveal'),
|
|
140
|
+
},
|
|
141
|
+
slots.default
|
|
142
|
+
),
|
|
143
|
+
fallback: () =>
|
|
144
|
+
h(
|
|
145
|
+
Fallback,
|
|
146
|
+
{
|
|
147
|
+
id: props.id,
|
|
148
|
+
label: props.label,
|
|
149
|
+
reveal: props.reveal,
|
|
150
|
+
state: shared,
|
|
151
|
+
},
|
|
152
|
+
slots.skeleton
|
|
153
|
+
),
|
|
154
|
+
}),
|
|
155
|
+
]);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { defineComponent, h, inject, provide, watch, type InjectionKey, type PropType } from 'vue';
|
|
2
|
+
import {
|
|
3
|
+
setDebug,
|
|
4
|
+
skeletonCSS,
|
|
5
|
+
type RevealMode,
|
|
6
|
+
type SkeletonMode,
|
|
7
|
+
type SkeletonText,
|
|
8
|
+
type SkeletonTheme,
|
|
9
|
+
} from '../core';
|
|
10
|
+
|
|
11
|
+
export type SmartSkeletonConfig = {
|
|
12
|
+
/** Set on the inline script and stylesheets rendered, for a Content Security Policy. */
|
|
13
|
+
nonce?: string;
|
|
14
|
+
/** Colours for light and dark; `mode` picks between them. */
|
|
15
|
+
theme?: SkeletonTheme;
|
|
16
|
+
mode?: SkeletonMode;
|
|
17
|
+
/** Where text bars run: from `top` (ascender, cap-height, x-height) down to `bottom` (baseline, descender). */
|
|
18
|
+
text?: SkeletonText;
|
|
19
|
+
/** How content replaces its skeleton: `smart` (the default), `together`, `sequence`, `sweep`, or a `RevealSpec` of your own. */
|
|
20
|
+
reveal?: RevealMode;
|
|
21
|
+
/** Outlines every reveal with where its skeleton came from and whether anything moved. */
|
|
22
|
+
debug?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const key: InjectionKey<SmartSkeletonConfig> = Symbol('a-smart-skeleton');
|
|
26
|
+
|
|
27
|
+
/** Per server render: whether the full inline script has gone out yet, so the skeletons after it send a stub. */
|
|
28
|
+
const bootKey: InjectionKey<{ sent: boolean }> = Symbol('a-smart-skeleton.boot');
|
|
29
|
+
|
|
30
|
+
/** The provider settings in scope for this component. Empty outside a provider. */
|
|
31
|
+
export const useSmartSkeleton = (): SmartSkeletonConfig => inject(key, {});
|
|
32
|
+
|
|
33
|
+
/** Whether this skeleton should send the stub — claiming the full script if it's the first. */
|
|
34
|
+
export function claimStub() {
|
|
35
|
+
const boot = inject(bootKey, null);
|
|
36
|
+
const stub = !!boot?.sent;
|
|
37
|
+
if (boot) boot.sent = true;
|
|
38
|
+
return stub;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Settings for every skeleton inside — per request on the server, as a nonce must be.
|
|
43
|
+
*
|
|
44
|
+
* ```vue
|
|
45
|
+
* <ASmartSkeletonProvider :theme="theme" reveal="smart" :nonce="nonce">
|
|
46
|
+
* <App />
|
|
47
|
+
* </ASmartSkeletonProvider>
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export const ASmartSkeletonProvider = defineComponent({
|
|
51
|
+
name: 'ASmartSkeletonProvider',
|
|
52
|
+
props: {
|
|
53
|
+
nonce: String,
|
|
54
|
+
theme: Object as PropType<SkeletonTheme>,
|
|
55
|
+
mode: String as PropType<SkeletonMode>,
|
|
56
|
+
text: Object as PropType<SkeletonText>,
|
|
57
|
+
reveal: [String, Object] as PropType<RevealMode>,
|
|
58
|
+
debug: { type: Boolean, default: undefined },
|
|
59
|
+
},
|
|
60
|
+
setup(props, { slots }) {
|
|
61
|
+
// The props object itself, so components inside read every change.
|
|
62
|
+
provide(key, props);
|
|
63
|
+
provide(bootKey, { sent: false });
|
|
64
|
+
|
|
65
|
+
watch(
|
|
66
|
+
() => props.debug,
|
|
67
|
+
(debug) => {
|
|
68
|
+
if (debug !== undefined && typeof window !== 'undefined') setDebug(debug);
|
|
69
|
+
},
|
|
70
|
+
{ immediate: true }
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return () => {
|
|
74
|
+
const css = skeletonCSS(props);
|
|
75
|
+
return [css ? h('style', { nonce: props.nonce }, css) : null, slots.default?.()];
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
});
|
package/src/vue/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { default as ASmartSkeleton } from '../components/ASmartSkeleton.vue';
|
|
2
|
+
export { default as ASmartSkeletonBox } from '../components/ASmartSkeletonBox.vue';
|
|
3
|
+
export { default as ASmartSkeletonSuspense } from '../components/ASmartSkeletonSuspense.vue';
|
|
4
|
+
export { default as ASmartSkeletonProvider } from '../components/ASmartSkeletonProvider.vue';
|
|
5
|
+
export { useSmartSkeleton, claimStub } from '../composables/useSmartSkeleton';
|
|
6
|
+
export { useStyles } from '../composables/useStyles';
|
|
7
|
+
export type { SmartSkeletonConfig } from '../types';
|