@alikhalilll/a-skeleton 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.media/hero.png +0 -0
  2. package/.media/hero.svg +232 -0
  3. package/README.md +459 -170
  4. package/dist/index.cjs +3696 -828
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +534 -43
  7. package/dist/index.d.ts +534 -43
  8. package/dist/index.js +3677 -830
  9. package/dist/index.js.map +1 -1
  10. package/dist/nuxt/index.cjs +16 -1
  11. package/dist/nuxt/index.cjs.map +1 -1
  12. package/dist/nuxt/index.js +16 -1
  13. package/dist/nuxt/index.js.map +1 -1
  14. package/dist/resolver/index.cjs +16 -1
  15. package/dist/resolver/index.cjs.map +1 -1
  16. package/dist/resolver/index.js +16 -1
  17. package/dist/resolver/index.js.map +1 -1
  18. package/dist/styles.css +56 -11
  19. package/package.json +8 -2
  20. package/src/components/ASkeleton.vue +216 -98
  21. package/src/components/ASkeletonClone.vue +106 -0
  22. package/src/components/ASkeletonLayer.vue +20 -32
  23. package/src/components/CloneNode.ts +161 -0
  24. package/src/components/StructuralLayerNode.ts +157 -0
  25. package/src/components/icons.ts +45 -0
  26. package/src/components/variants/ASkeletonArticle.vue +33 -0
  27. package/src/components/variants/ASkeletonAvatar.vue +42 -0
  28. package/src/components/variants/ASkeletonButton.vue +37 -0
  29. package/src/components/variants/ASkeletonCard.vue +47 -0
  30. package/src/components/variants/ASkeletonChart.vue +56 -0
  31. package/src/components/variants/ASkeletonChip.vue +32 -0
  32. package/src/components/variants/ASkeletonDivider.vue +26 -0
  33. package/src/components/variants/ASkeletonForm.vue +32 -0
  34. package/src/components/variants/ASkeletonHeading.vue +47 -0
  35. package/src/components/variants/ASkeletonImage.vue +57 -0
  36. package/src/components/variants/ASkeletonInput.vue +33 -0
  37. package/src/components/variants/ASkeletonListItem.vue +40 -0
  38. package/src/components/variants/ASkeletonTable.vue +49 -0
  39. package/src/components/variants/ASkeletonText.vue +49 -0
  40. package/src/components/variants/ASkeletonVideo.vue +55 -0
  41. package/src/composables/useShapeProbe.ts +33 -9
  42. package/src/composables/useSkeleton.ts +33 -21
  43. package/src/composables/useSkeletonCache.ts +282 -24
  44. package/src/index.ts +48 -2
  45. package/src/nuxt/index.ts +16 -0
  46. package/src/resolver/index.ts +16 -0
  47. package/src/types.ts +124 -5
  48. package/src/utils/buildStructuralSkeleton.ts +400 -103
  49. package/src/utils/captureStyles.ts +378 -0
  50. package/src/utils/domRead.ts +143 -0
  51. package/src/utils/walkDom.ts +261 -16
  52. package/src/utils/walkStructural.ts +418 -0
  53. package/web-types.json +10 -4
@@ -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>