@alikhalilll/a-skeleton 1.1.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 +458 -172
- package/dist/index.cjs +3685 -840
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +527 -40
- package/dist/index.d.ts +527 -40
- package/dist/index.js +3666 -842
- 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 +212 -113
- 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 +251 -22
- package/src/index.ts +48 -2
- package/src/nuxt/index.ts +16 -0
- package/src/resolver/index.ts +16 -0
- package/src/types.ts +118 -2
- 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 +9 -3
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CloneNode` — recursive renderer for one node of a `CaptureSnapshot`.
|
|
3
|
+
*
|
|
4
|
+
* Design:
|
|
5
|
+
* - **Strategy table by `CapturedNode.kind`**. Each kind ('container' |
|
|
6
|
+
* 'text' | 'image' | 'video' | 'media' | 'block') has its own renderer
|
|
7
|
+
* function. Adding a new kind is one entry in `RENDERERS` — no edits
|
|
8
|
+
* elsewhere (Open/Closed).
|
|
9
|
+
* - **Pure render function**. No reactive state of its own; everything
|
|
10
|
+
* flows from props.
|
|
11
|
+
* - **Composition**: per-line text bars share the same shape as block
|
|
12
|
+
* leaves so there's no behaviour duplication.
|
|
13
|
+
*/
|
|
14
|
+
import { defineComponent, h, type PropType, type VNode } from 'vue';
|
|
15
|
+
import type { CapturedNode, TextLineRect } from '../utils/captureStyles';
|
|
16
|
+
import { renderImageIcon, renderPlayIcon } from './icons';
|
|
17
|
+
|
|
18
|
+
interface RendererCtx {
|
|
19
|
+
node: CapturedNode;
|
|
20
|
+
animClass: string | null;
|
|
21
|
+
/** Convert a captured node into its absolute-positioned style object. */
|
|
22
|
+
blockStyle: (n: CapturedNode) => Record<string, unknown>;
|
|
23
|
+
/** Build the inline style for a per-line text bar. */
|
|
24
|
+
lineStyle: (
|
|
25
|
+
line: TextLineRect,
|
|
26
|
+
parentStyle: Readonly<Record<string, string>>
|
|
27
|
+
) => Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Renderer = (ctx: RendererCtx) => VNode;
|
|
31
|
+
|
|
32
|
+
/* ─────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
* Strategy table — one renderer per `CapturedNode.kind`.
|
|
34
|
+
* ───────────────────────────────────────────────────────────────────────────── */
|
|
35
|
+
const RENDERERS: Record<CapturedNode['kind'], Renderer> = {
|
|
36
|
+
container: renderContainer,
|
|
37
|
+
text: renderText,
|
|
38
|
+
image: (ctx) => renderLeafWithIcon(ctx, 'image'),
|
|
39
|
+
video: (ctx) => renderLeafWithIcon(ctx, 'video'),
|
|
40
|
+
media: (ctx) => renderLeaf(ctx),
|
|
41
|
+
block: (ctx) => renderLeaf(ctx),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Container — positioned wrapper with captured background / border / shadow.
|
|
46
|
+
* No `.a-skel` class so the surface shows through exactly as in the real DOM.
|
|
47
|
+
* Recurse into children via the same `CloneNode`.
|
|
48
|
+
*
|
|
49
|
+
* `CapturedNode.{x,y}` is always root-relative, but children render inside
|
|
50
|
+
* this container — itself `position: absolute`. If we hand children the
|
|
51
|
+
* root-relative coords as-is, their `left` / `top` resolve against the
|
|
52
|
+
* container (their new offset parent), doubling the offset. Pass a
|
|
53
|
+
* `blockStyle` closure that subtracts this container's offset so each
|
|
54
|
+
* descendant lands at its captured root-relative position regardless of
|
|
55
|
+
* how deeply it's nested.
|
|
56
|
+
*/
|
|
57
|
+
function renderContainer(ctx: RendererCtx): VNode {
|
|
58
|
+
const { node } = ctx;
|
|
59
|
+
const childBlockStyle = (n: CapturedNode): Record<string, unknown> => ({
|
|
60
|
+
position: 'absolute',
|
|
61
|
+
left: `${n.x - node.x}px`,
|
|
62
|
+
top: `${n.y - node.y}px`,
|
|
63
|
+
width: `${n.w}px`,
|
|
64
|
+
height: `${n.h}px`,
|
|
65
|
+
...n.style,
|
|
66
|
+
});
|
|
67
|
+
return h(
|
|
68
|
+
'div',
|
|
69
|
+
{
|
|
70
|
+
class: 'a-skel-clone-container',
|
|
71
|
+
style: ctx.blockStyle(node),
|
|
72
|
+
'aria-hidden': 'true',
|
|
73
|
+
},
|
|
74
|
+
(node.children ?? []).map((child) =>
|
|
75
|
+
h(CloneNode, {
|
|
76
|
+
node: child,
|
|
77
|
+
animClass: ctx.animClass,
|
|
78
|
+
blockStyle: childBlockStyle,
|
|
79
|
+
lineStyle: ctx.lineStyle,
|
|
80
|
+
})
|
|
81
|
+
)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Text — container carrying captured background/border, with per-line
|
|
87
|
+
* shimmer bars positioned absolutely inside. Each text line becomes one bar
|
|
88
|
+
* at the exact rendered text rect (multi-line / wrapped / RTL all replay 1:1).
|
|
89
|
+
*/
|
|
90
|
+
function renderText(ctx: RendererCtx): VNode {
|
|
91
|
+
const { node, animClass } = ctx;
|
|
92
|
+
/* Fallback to a single full-rect bar if the Range API wasn't usable. */
|
|
93
|
+
const lines = node.textLines ?? [{ x: node.x, y: node.y, w: node.w, h: node.h }];
|
|
94
|
+
return h(
|
|
95
|
+
'div',
|
|
96
|
+
{
|
|
97
|
+
class: 'a-skel-clone-container a-skel-clone-text',
|
|
98
|
+
style: ctx.blockStyle(node),
|
|
99
|
+
'aria-hidden': 'true',
|
|
100
|
+
},
|
|
101
|
+
lines.map((line) =>
|
|
102
|
+
h('div', {
|
|
103
|
+
class: ['a-skel-clone-textbar', animClass],
|
|
104
|
+
style: ctx.lineStyle(
|
|
105
|
+
/* Convert root-relative line coords → container-relative. */
|
|
106
|
+
{ x: line.x - node.x, y: line.y - node.y, w: line.w, h: line.h },
|
|
107
|
+
node.style
|
|
108
|
+
),
|
|
109
|
+
})
|
|
110
|
+
)
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Plain shimmer leaf (block / media). */
|
|
115
|
+
function renderLeaf(ctx: RendererCtx): VNode {
|
|
116
|
+
return h('div', {
|
|
117
|
+
class: ['a-skel a-skel-clone-leaf', ctx.animClass],
|
|
118
|
+
style: ctx.blockStyle(ctx.node),
|
|
119
|
+
'aria-hidden': 'true',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Shimmer leaf with a centered placeholder icon (image / video). */
|
|
124
|
+
function renderLeafWithIcon(ctx: RendererCtx, kind: 'image' | 'video'): VNode {
|
|
125
|
+
return h(
|
|
126
|
+
'div',
|
|
127
|
+
{
|
|
128
|
+
class: ['a-skel a-skel-clone-leaf a-skel-clone-leaf--with-icon', ctx.animClass],
|
|
129
|
+
style: ctx.blockStyle(ctx.node),
|
|
130
|
+
'aria-hidden': 'true',
|
|
131
|
+
},
|
|
132
|
+
[kind === 'image' ? renderImageIcon() : renderPlayIcon()]
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const CloneNode = defineComponent({
|
|
137
|
+
name: 'CloneNode',
|
|
138
|
+
props: {
|
|
139
|
+
node: { type: Object as PropType<CapturedNode>, required: true },
|
|
140
|
+
animClass: { type: [String, null] as PropType<string | null>, default: null },
|
|
141
|
+
blockStyle: {
|
|
142
|
+
type: Function as PropType<RendererCtx['blockStyle']>,
|
|
143
|
+
required: true,
|
|
144
|
+
},
|
|
145
|
+
lineStyle: {
|
|
146
|
+
type: Function as PropType<RendererCtx['lineStyle']>,
|
|
147
|
+
required: true,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
setup(props) {
|
|
151
|
+
return () => {
|
|
152
|
+
const renderer = RENDERERS[props.node.kind];
|
|
153
|
+
return renderer({
|
|
154
|
+
node: props.node,
|
|
155
|
+
animClass: props.animClass,
|
|
156
|
+
blockStyle: props.blockStyle,
|
|
157
|
+
lineStyle: props.lineStyle,
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `StructuralLayerNode` — recursive renderer for a `StructuralNode` from a
|
|
3
|
+
* `StructuralShape` captured by `walkStructural`. Used internally by
|
|
4
|
+
* `<ASkeletonLayer>`.
|
|
5
|
+
*
|
|
6
|
+
* Design:
|
|
7
|
+
* - **Strategy table** keyed by `node.kind` + `node.leafKind`. Each kind has
|
|
8
|
+
* its own renderer function. Adding a new kind is one entry in `RENDERERS`
|
|
9
|
+
* (Open/Closed) — no edits elsewhere.
|
|
10
|
+
* - **Pure render function**: no reactive state of its own; everything flows
|
|
11
|
+
* from props.
|
|
12
|
+
* - **Container preservation**: container nodes emit `<{node.tag}
|
|
13
|
+
* :class="node.className" :style="node.style">…children…</…>`. The
|
|
14
|
+
* captured `style` carries comprehensive layout + visual CSS so the
|
|
15
|
+
* skeleton reads correctly even when the consumer's stylesheet isn't
|
|
16
|
+
* loaded at the mount point.
|
|
17
|
+
* - **Leaves**: render `<div class="a-skel + originalClass" :style="style">`.
|
|
18
|
+
* The original `class` is preserved so utility-first CSS (`mt-4`,
|
|
19
|
+
* `flex-1`, `inline-block`, …) survives; the captured style carries the
|
|
20
|
+
* same layout + visual properties used on containers plus `width` +
|
|
21
|
+
* `height` so the placeholder claims the right space.
|
|
22
|
+
*/
|
|
23
|
+
import { defineComponent, h, type PropType, type VNode } from 'vue';
|
|
24
|
+
import type { ContainerNode, LeafKind, LeafNode, StructuralNode } from '../types';
|
|
25
|
+
import { renderImageIcon, renderPlayIcon } from './icons';
|
|
26
|
+
|
|
27
|
+
interface RendererCtx {
|
|
28
|
+
node: StructuralNode;
|
|
29
|
+
animClass: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Renderer = (ctx: RendererCtx) => VNode;
|
|
33
|
+
|
|
34
|
+
/* ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
* Strategy table — one renderer per kind. Containers carry their captured
|
|
36
|
+
* structure; leaves render as a-skel placeholders.
|
|
37
|
+
* ───────────────────────────────────────────────────────────────────────────── */
|
|
38
|
+
const LEAF_RENDERERS: Record<LeafKind, (node: LeafNode, animClass: string | null) => VNode> = {
|
|
39
|
+
block: renderLeafBlock,
|
|
40
|
+
media: renderLeafBlock,
|
|
41
|
+
text: renderLeafText,
|
|
42
|
+
image: (node, animClass) => renderLeafWithIcon(node, animClass, 'image'),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function renderContainer(node: ContainerNode, animClass: string | null): VNode {
|
|
46
|
+
return h(
|
|
47
|
+
node.tag,
|
|
48
|
+
{
|
|
49
|
+
class: node.className || undefined,
|
|
50
|
+
style: node.style,
|
|
51
|
+
'aria-hidden': 'true',
|
|
52
|
+
},
|
|
53
|
+
node.children.map((child) => h(StructuralLayerNode, { node: child, animClass }))
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build the leaf's `class` payload. Always includes `a-skel` (skeleton
|
|
59
|
+
* surface) + the animation class; the original consumer class trails so it
|
|
60
|
+
* survives the cascade alongside utility-first frameworks.
|
|
61
|
+
*/
|
|
62
|
+
function leafClass(node: LeafNode, animClass: string | null, extra?: string): string[] {
|
|
63
|
+
const classes: string[] = ['a-skel'];
|
|
64
|
+
if (extra) classes.push(extra);
|
|
65
|
+
if (animClass) classes.push(animClass);
|
|
66
|
+
if (node.className) classes.push(node.className);
|
|
67
|
+
return classes;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function renderLeafBlock(node: LeafNode, animClass: string | null): VNode {
|
|
71
|
+
return h('div', {
|
|
72
|
+
class: leafClass(node, animClass),
|
|
73
|
+
style: node.style,
|
|
74
|
+
'aria-hidden': 'true',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Text leaf — host carries the captured layout + visual surface (including
|
|
80
|
+
* the leaf's `width` / `height` so it claims the right space in its parent's
|
|
81
|
+
* layout). Each captured per-line rect renders as one bar inside the host,
|
|
82
|
+
* absolutely positioned at the captured rect (rects are leaf-relative; the
|
|
83
|
+
* host's captured style includes `position: relative` via the `.a-skel-text-host`
|
|
84
|
+
* default — see styles.src.css).
|
|
85
|
+
*
|
|
86
|
+
* When `textLines` is absent (Range API unusable), the leaf renders as a
|
|
87
|
+
* single shimmer bar at the host's bounds — same as a leaf-block.
|
|
88
|
+
*/
|
|
89
|
+
function renderLeafText(node: LeafNode, animClass: string | null): VNode {
|
|
90
|
+
const lines = node.textLines;
|
|
91
|
+
if (!lines || lines.length === 0) {
|
|
92
|
+
return h('div', {
|
|
93
|
+
class: leafClass(node, animClass, 'a-skel--text'),
|
|
94
|
+
style: node.style,
|
|
95
|
+
'aria-hidden': 'true',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return h(
|
|
99
|
+
'div',
|
|
100
|
+
{
|
|
101
|
+
class: ['a-skel-text-host', node.className || undefined],
|
|
102
|
+
style: node.style,
|
|
103
|
+
'aria-hidden': 'true',
|
|
104
|
+
},
|
|
105
|
+
lines.map((line) =>
|
|
106
|
+
h('div', {
|
|
107
|
+
class: ['a-skel', 'a-skel--text-line', animClass],
|
|
108
|
+
style: {
|
|
109
|
+
position: 'absolute',
|
|
110
|
+
left: `${line.x}px`,
|
|
111
|
+
top: `${line.y}px`,
|
|
112
|
+
width: `${line.w}px`,
|
|
113
|
+
height: `${line.h}px`,
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
)
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function renderLeafWithIcon(
|
|
121
|
+
node: LeafNode,
|
|
122
|
+
animClass: string | null,
|
|
123
|
+
kind: 'image' | 'video'
|
|
124
|
+
): VNode {
|
|
125
|
+
return h(
|
|
126
|
+
'div',
|
|
127
|
+
{
|
|
128
|
+
class: leafClass(node, animClass, 'a-skel--with-icon'),
|
|
129
|
+
style: node.style,
|
|
130
|
+
'aria-hidden': 'true',
|
|
131
|
+
},
|
|
132
|
+
[kind === 'image' ? renderImageIcon() : renderPlayIcon()]
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const RENDERERS: Record<'container' | 'leaf', Renderer> = {
|
|
137
|
+
container: (ctx) => renderContainer(ctx.node as ContainerNode, ctx.animClass),
|
|
138
|
+
leaf: (ctx) => {
|
|
139
|
+
const leaf = ctx.node as LeafNode;
|
|
140
|
+
return LEAF_RENDERERS[leaf.leafKind](leaf, ctx.animClass);
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const StructuralLayerNode = defineComponent({
|
|
145
|
+
name: 'StructuralLayerNode',
|
|
146
|
+
props: {
|
|
147
|
+
node: { type: Object as PropType<StructuralNode>, required: true },
|
|
148
|
+
animClass: { type: [String, null] as PropType<string | null>, default: null },
|
|
149
|
+
},
|
|
150
|
+
setup(props) {
|
|
151
|
+
return () =>
|
|
152
|
+
RENDERERS[props.node.kind]({
|
|
153
|
+
node: props.node,
|
|
154
|
+
animClass: props.animClass,
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared placeholder icon renderers for leaf nodes (image / video). Lives at
|
|
3
|
+
* one site so both `CloneNode.ts` (clone mode) and `StructuralLayerNode.ts`
|
|
4
|
+
* (Recipe 3 structural mode) render identical glyphs.
|
|
5
|
+
*/
|
|
6
|
+
import { h, type VNode } from 'vue';
|
|
7
|
+
|
|
8
|
+
export function renderImageIcon(): VNode {
|
|
9
|
+
return h(
|
|
10
|
+
'svg',
|
|
11
|
+
{
|
|
12
|
+
class: 'a-skel-clone-icon',
|
|
13
|
+
viewBox: '0 0 24 24',
|
|
14
|
+
'aria-hidden': 'true',
|
|
15
|
+
},
|
|
16
|
+
[
|
|
17
|
+
h('path', {
|
|
18
|
+
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',
|
|
19
|
+
fill: 'currentColor',
|
|
20
|
+
}),
|
|
21
|
+
]
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function renderPlayIcon(): VNode {
|
|
26
|
+
return h(
|
|
27
|
+
'svg',
|
|
28
|
+
{
|
|
29
|
+
class: 'a-skel-clone-icon',
|
|
30
|
+
viewBox: '0 0 24 24',
|
|
31
|
+
'aria-hidden': 'true',
|
|
32
|
+
},
|
|
33
|
+
[
|
|
34
|
+
h('circle', {
|
|
35
|
+
cx: 12,
|
|
36
|
+
cy: 12,
|
|
37
|
+
r: 10,
|
|
38
|
+
stroke: 'currentColor',
|
|
39
|
+
'stroke-width': 1.5,
|
|
40
|
+
fill: 'none',
|
|
41
|
+
}),
|
|
42
|
+
h('path', { d: 'M10 8l6 4-6 4V8Z', fill: 'currentColor' }),
|
|
43
|
+
]
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { type HTMLAttributes } from 'vue';
|
|
3
|
+
import { cn } from '@alikhalilll/a-ui-base';
|
|
4
|
+
import ASkeletonHeading from './ASkeletonHeading.vue';
|
|
5
|
+
import ASkeletonText from './ASkeletonText.vue';
|
|
6
|
+
import ASkeletonImage from './ASkeletonImage.vue';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
media?: boolean;
|
|
10
|
+
paragraphs?: number;
|
|
11
|
+
linesPerParagraph?: number;
|
|
12
|
+
animation?: 'pulse' | 'shimmer' | 'wave' | 'none';
|
|
13
|
+
class?: HTMLAttributes['class'];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
withDefaults(defineProps<Props>(), {
|
|
17
|
+
media: true,
|
|
18
|
+
paragraphs: 3,
|
|
19
|
+
linesPerParagraph: 4,
|
|
20
|
+
animation: 'pulse',
|
|
21
|
+
});
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<article :class="cn('flex flex-col gap-4', $props.class)" role="status" aria-busy="true">
|
|
26
|
+
<ASkeletonHeading :level="1" :animation="$props.animation" />
|
|
27
|
+
<ASkeletonImage v-if="$props.media" :animation="$props.animation" />
|
|
28
|
+
<div v-for="i in $props.paragraphs" :key="i">
|
|
29
|
+
<ASkeletonText :lines="$props.linesPerParagraph" :animation="$props.animation" />
|
|
30
|
+
</div>
|
|
31
|
+
<span class="a-skel-sr-only">Loading article…</span>
|
|
32
|
+
</article>
|
|
33
|
+
</template>
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
size?: number | string;
|
|
7
|
+
shape?: 'circle' | 'square' | 'rounded';
|
|
8
|
+
animation?: 'pulse' | 'shimmer' | 'wave' | 'none';
|
|
9
|
+
class?: HTMLAttributes['class'];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const props = withDefaults(defineProps<Props>(), { size: 48, shape: 'circle', animation: 'pulse' });
|
|
13
|
+
|
|
14
|
+
const animClass = computed(() =>
|
|
15
|
+
props.animation === 'none' ? null : `a-skel-anim-${props.animation}`
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const sizeStyle = computed(() => {
|
|
19
|
+
const s = typeof props.size === 'number' ? `${props.size}px` : String(props.size);
|
|
20
|
+
return {
|
|
21
|
+
width: s,
|
|
22
|
+
height: s,
|
|
23
|
+
borderRadius:
|
|
24
|
+
props.shape === 'circle'
|
|
25
|
+
? '9999px'
|
|
26
|
+
: props.shape === 'rounded'
|
|
27
|
+
? 'var(--ak-skel-radius)'
|
|
28
|
+
: '0',
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<div
|
|
35
|
+
:class="cn('a-skel a-skel-variant-avatar', animClass, props.class)"
|
|
36
|
+
:style="sizeStyle"
|
|
37
|
+
role="status"
|
|
38
|
+
aria-busy="true"
|
|
39
|
+
>
|
|
40
|
+
<span class="a-skel-sr-only">Loading avatar…</span>
|
|
41
|
+
</div>
|
|
42
|
+
</template>
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
outlined?: boolean;
|
|
9
|
+
animation?: 'pulse' | 'shimmer' | 'wave' | 'none';
|
|
10
|
+
class?: HTMLAttributes['class'];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const props = withDefaults(defineProps<Props>(), { width: 120, height: 40, animation: 'pulse' });
|
|
14
|
+
|
|
15
|
+
const animClass = computed(() =>
|
|
16
|
+
props.animation === 'none' ? null : `a-skel-anim-${props.animation}`
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const rootStyle = computed(() => ({
|
|
20
|
+
width: typeof props.width === 'number' ? `${props.width}px` : String(props.width),
|
|
21
|
+
height: typeof props.height === 'number' ? `${props.height}px` : String(props.height),
|
|
22
|
+
...(props.outlined
|
|
23
|
+
? { backgroundColor: 'transparent', border: '1px solid var(--ak-skel-ring)' }
|
|
24
|
+
: {}),
|
|
25
|
+
}));
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<div
|
|
30
|
+
:class="cn('a-skel a-skel-variant-button', animClass, props.class)"
|
|
31
|
+
:style="rootStyle"
|
|
32
|
+
role="status"
|
|
33
|
+
aria-busy="true"
|
|
34
|
+
>
|
|
35
|
+
<span class="a-skel-sr-only">Loading button…</span>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { type HTMLAttributes } from 'vue';
|
|
3
|
+
import { cn } from '@alikhalilll/a-ui-base';
|
|
4
|
+
import ASkeletonImage from './ASkeletonImage.vue';
|
|
5
|
+
import ASkeletonHeading from './ASkeletonHeading.vue';
|
|
6
|
+
import ASkeletonText from './ASkeletonText.vue';
|
|
7
|
+
import ASkeletonButton from './ASkeletonButton.vue';
|
|
8
|
+
import ASkeletonAvatar from './ASkeletonAvatar.vue';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
media?: boolean;
|
|
12
|
+
heading?: boolean;
|
|
13
|
+
lines?: number;
|
|
14
|
+
actions?: boolean;
|
|
15
|
+
footerAvatar?: boolean;
|
|
16
|
+
animation?: 'pulse' | 'shimmer' | 'wave' | 'none';
|
|
17
|
+
class?: HTMLAttributes['class'];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
withDefaults(defineProps<Props>(), {
|
|
21
|
+
media: true,
|
|
22
|
+
heading: true,
|
|
23
|
+
lines: 3,
|
|
24
|
+
actions: true,
|
|
25
|
+
footerAvatar: false,
|
|
26
|
+
animation: 'pulse',
|
|
27
|
+
});
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<div :class="cn('a-skel-variant-card', $props.class)" role="status" aria-busy="true">
|
|
32
|
+
<ASkeletonImage v-if="$props.media" :animation="$props.animation" />
|
|
33
|
+
<ASkeletonHeading v-if="$props.heading" :level="3" :animation="$props.animation" />
|
|
34
|
+
<ASkeletonText :lines="$props.lines" :animation="$props.animation" />
|
|
35
|
+
<div v-if="$props.actions" class="mt-2 flex gap-2">
|
|
36
|
+
<ASkeletonButton :width="96" :height="36" :animation="$props.animation" />
|
|
37
|
+
<ASkeletonButton :width="96" :height="36" outlined :animation="$props.animation" />
|
|
38
|
+
</div>
|
|
39
|
+
<div v-if="$props.footerAvatar" class="mt-3 flex items-center gap-3">
|
|
40
|
+
<ASkeletonAvatar :size="36" :animation="$props.animation" />
|
|
41
|
+
<div style="flex: 1">
|
|
42
|
+
<ASkeletonText :lines="2" :animation="$props.animation" />
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
<span class="a-skel-sr-only">Loading card…</span>
|
|
46
|
+
</div>
|
|
47
|
+
</template>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, type HTMLAttributes } from 'vue';
|
|
3
|
+
import { cn } from '@alikhalilll/a-ui-base';
|
|
4
|
+
import ASkeletonHeading from './ASkeletonHeading.vue';
|
|
5
|
+
import ASkeletonText from './ASkeletonText.vue';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
bars?: number;
|
|
9
|
+
height?: number | string;
|
|
10
|
+
showHeader?: boolean;
|
|
11
|
+
animation?: 'pulse' | 'shimmer' | 'wave' | 'none';
|
|
12
|
+
class?: HTMLAttributes['class'];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
16
|
+
bars: 7,
|
|
17
|
+
height: '8rem',
|
|
18
|
+
showHeader: true,
|
|
19
|
+
animation: 'pulse',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const animClass = computed(() =>
|
|
23
|
+
props.animation === 'none' ? null : `a-skel-anim-${props.animation}`
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const chartStyle = computed(() => ({
|
|
27
|
+
height: typeof props.height === 'number' ? `${props.height}px` : String(props.height),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
/* Pseudo-random but deterministic bar heights so the chart looks organic but
|
|
31
|
+
* doesn't reshuffle on each render. */
|
|
32
|
+
const HEIGHTS = [62, 78, 45, 91, 68, 82, 55, 73, 39, 88, 60, 74];
|
|
33
|
+
function barHeight(i: number): string {
|
|
34
|
+
return `${HEIGHTS[i % HEIGHTS.length]}%`;
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<template>
|
|
39
|
+
<div :class="cn(props.class)" role="status" aria-busy="true">
|
|
40
|
+
<template v-if="props.showHeader">
|
|
41
|
+
<ASkeletonHeading :level="4" :animation="props.animation" :width="'45%'" />
|
|
42
|
+
<div style="margin-top: 0.4rem">
|
|
43
|
+
<ASkeletonText :lines="1" :animation="props.animation" :width="'60%'" />
|
|
44
|
+
</div>
|
|
45
|
+
</template>
|
|
46
|
+
<div class="a-skel-variant-chart" :style="chartStyle" style="margin-top: 1rem">
|
|
47
|
+
<div
|
|
48
|
+
v-for="i in props.bars"
|
|
49
|
+
:key="i"
|
|
50
|
+
:class="cn('a-skel a-skel-variant-chart__bar', animClass)"
|
|
51
|
+
:style="{ height: barHeight(i - 1) }"
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
<span class="a-skel-sr-only">Loading chart…</span>
|
|
55
|
+
</div>
|
|
56
|
+
</template>
|
|
@@ -0,0 +1,32 @@
|
|
|
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: 80, height: 24, animation: 'pulse' });
|
|
13
|
+
|
|
14
|
+
const animClass = computed(() =>
|
|
15
|
+
props.animation === 'none' ? null : `a-skel-anim-${props.animation}`
|
|
16
|
+
);
|
|
17
|
+
const rootStyle = computed(() => ({
|
|
18
|
+
width: typeof props.width === 'number' ? `${props.width}px` : String(props.width),
|
|
19
|
+
height: typeof props.height === 'number' ? `${props.height}px` : String(props.height),
|
|
20
|
+
borderRadius: '9999px',
|
|
21
|
+
display: 'inline-block',
|
|
22
|
+
}));
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<span
|
|
27
|
+
:class="cn('a-skel', animClass, props.class)"
|
|
28
|
+
:style="rootStyle"
|
|
29
|
+
role="status"
|
|
30
|
+
aria-busy="true"
|
|
31
|
+
/>
|
|
32
|
+
</template>
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
thickness?: number;
|
|
7
|
+
animation?: 'pulse' | 'shimmer' | 'wave' | 'none';
|
|
8
|
+
class?: HTMLAttributes['class'];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const props = withDefaults(defineProps<Props>(), { thickness: 1, animation: 'pulse' });
|
|
12
|
+
|
|
13
|
+
const animClass = computed(() =>
|
|
14
|
+
props.animation === 'none' ? null : `a-skel-anim-${props.animation}`
|
|
15
|
+
);
|
|
16
|
+
const rootStyle = computed(() => ({ height: `${props.thickness}px`, width: '100%' }));
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<div
|
|
21
|
+
:class="cn('a-skel', animClass, props.class)"
|
|
22
|
+
:style="rootStyle"
|
|
23
|
+
role="separator"
|
|
24
|
+
aria-hidden="true"
|
|
25
|
+
/>
|
|
26
|
+
</template>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { type HTMLAttributes } from 'vue';
|
|
3
|
+
import { cn } from '@alikhalilll/a-ui-base';
|
|
4
|
+
import ASkeletonText from './ASkeletonText.vue';
|
|
5
|
+
import ASkeletonInput from './ASkeletonInput.vue';
|
|
6
|
+
import ASkeletonButton from './ASkeletonButton.vue';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
fields?: number;
|
|
10
|
+
showSubmit?: boolean;
|
|
11
|
+
animation?: 'pulse' | 'shimmer' | 'wave' | 'none';
|
|
12
|
+
class?: HTMLAttributes['class'];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
withDefaults(defineProps<Props>(), { fields: 3, showSubmit: true, animation: 'pulse' });
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<div :class="cn('flex flex-col gap-4', $props.class)" role="status" aria-busy="true">
|
|
20
|
+
<div v-for="i in $props.fields" :key="i" class="flex flex-col gap-2">
|
|
21
|
+
<ASkeletonText :lines="1" :width="'30%'" :animation="$props.animation" />
|
|
22
|
+
<ASkeletonInput :animation="$props.animation" />
|
|
23
|
+
</div>
|
|
24
|
+
<ASkeletonButton
|
|
25
|
+
v-if="$props.showSubmit"
|
|
26
|
+
:width="120"
|
|
27
|
+
:height="40"
|
|
28
|
+
:animation="$props.animation"
|
|
29
|
+
/>
|
|
30
|
+
<span class="a-skel-sr-only">Loading form…</span>
|
|
31
|
+
</div>
|
|
32
|
+
</template>
|