From c622d261dcd6948051972ffe2051d78d81a5a466 Mon Sep 17 00:00:00 2001
From: Dziad Borowy
Date: Tue, 21 Apr 2026 00:01:09 +0100
Subject: [PATCH 1/4] add skeleton component (fix #271)
---
docs/components/index.ts | 1 +
docs/components/skeleton/Skeleton.css | 4 +
docs/components/skeleton/Skeleton.svelte | 140 ++++++++++++++++++++++
docs/components/skeleton/index.ts | 1 +
docs/nav/Nav.svelte | 1 +
src/icon/icons.ts | 1 +
src/index.ts | 1 +
src/skeleton/index.ts | 3 +
src/skeleton/layouts/Grid.svelte | 30 +++++
src/skeleton/layouts/HStack.svelte | 25 ++++
src/skeleton/layouts/VStack.svelte | 25 ++++
src/skeleton/layouts/index.ts | 3 +
src/skeleton/layouts/layouts.css | 7 ++
src/skeleton/shapes/Circle.svelte | 26 +++++
src/skeleton/shapes/Image.svelte | 29 +++++
src/skeleton/shapes/Rect.svelte | 30 +++++
src/skeleton/shapes/Text.svelte | 32 +++++
src/skeleton/shapes/index.ts | 4 +
src/skeleton/shapes/shapes.css | 65 +++++++++++
src/skeleton/types.ts | 31 +++++
tests/Skeleton.svelte.test.ts | 142 +++++++++++++++++++++++
21 files changed, 601 insertions(+)
create mode 100644 docs/components/skeleton/Skeleton.css
create mode 100644 docs/components/skeleton/Skeleton.svelte
create mode 100644 docs/components/skeleton/index.ts
create mode 100644 src/skeleton/index.ts
create mode 100644 src/skeleton/layouts/Grid.svelte
create mode 100644 src/skeleton/layouts/HStack.svelte
create mode 100644 src/skeleton/layouts/VStack.svelte
create mode 100644 src/skeleton/layouts/index.ts
create mode 100644 src/skeleton/layouts/layouts.css
create mode 100644 src/skeleton/shapes/Circle.svelte
create mode 100644 src/skeleton/shapes/Image.svelte
create mode 100644 src/skeleton/shapes/Rect.svelte
create mode 100644 src/skeleton/shapes/Text.svelte
create mode 100644 src/skeleton/shapes/index.ts
create mode 100644 src/skeleton/shapes/shapes.css
create mode 100644 src/skeleton/types.ts
create mode 100644 tests/Skeleton.svelte.test.ts
diff --git a/docs/components/index.ts b/docs/components/index.ts
index 3521bcea..d4d4e21e 100644
--- a/docs/components/index.ts
+++ b/docs/components/index.ts
@@ -21,6 +21,7 @@ export { ColorPalette } from './color-palette';
export { Icon } from './icon';
export { Menu } from './menu';
export { Shortcut } from './shortcut';
+export { Skeleton } from './skeleton';
export { Splitter } from './splitter';
export { Tag } from './tag';
export { ThemeSwitcher } from './theme-switcher';
diff --git a/docs/components/skeleton/Skeleton.css b/docs/components/skeleton/Skeleton.css
new file mode 100644
index 00000000..ec59a5aa
--- /dev/null
+++ b/docs/components/skeleton/Skeleton.css
@@ -0,0 +1,4 @@
+
+.skeleton-cards {
+ gap: 2rem;
+}
diff --git a/docs/components/skeleton/Skeleton.svelte b/docs/components/skeleton/Skeleton.svelte
new file mode 100644
index 00000000..37154123
--- /dev/null
+++ b/docs/components/skeleton/Skeleton.svelte
@@ -0,0 +1,140 @@
+Skeleton
+
+Animated SVG placeholder for loading states. The component is responsive and fills its
+container — give the wrapper a width and height and the skeleton stretches to match.
+Six built-in templates cover common cases; drop down to template="custom" with
+the primitive and template components for anything bespoke.
+
+Skeleton Shapes
+
+Rectangle
+
+
+Circle
+
+
+Text
+
+
+Image
+
+
+
+
+Skeleton Layouts
+
+SkeletonHStack
+
+
+
+
+
+
+
+
+SkeletonVStack
+
+
+
+
+
+
+
+SkeletonGrid
+
+
+
+
+
+
+
+
+
+
+
+
+Custom build
+
+Cards list
+
+
+
+
+
+
+
+
+
+
+
+
+Cards grid
+{#snippet card()}
+
+
+
+
+
+
+
+{/snippet}
+
+
+ {@render card()}
+ {@render card()}
+ {@render card()}
+ {@render card()}
+ {@render card()}
+ {@render card()}
+
+
+
+
+{`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`}
+
+
+
+
+
diff --git a/docs/components/skeleton/index.ts b/docs/components/skeleton/index.ts
new file mode 100644
index 00000000..9a7f3572
--- /dev/null
+++ b/docs/components/skeleton/index.ts
@@ -0,0 +1 @@
+export { default as Skeleton } from './Skeleton.svelte';
diff --git a/docs/nav/Nav.svelte b/docs/nav/Nav.svelte
index c2d6007b..bcf3ed3e 100644
--- a/docs/nav/Nav.svelte
+++ b/docs/nav/Nav.svelte
@@ -53,6 +53,7 @@
+
diff --git a/src/icon/icons.ts b/src/icon/icons.ts
index d3a3b067..9948546e 100644
--- a/src/icon/icons.ts
+++ b/src/icon/icons.ts
@@ -53,6 +53,7 @@ export const icons = {
meatballs: 'dots-vertical">
+```
+-->
+
+
+ {@render children?.()}
+
+
+
diff --git a/src/skeleton/layouts/HStack.svelte b/src/skeleton/layouts/HStack.svelte
new file mode 100644
index 00000000..e97e9295
--- /dev/null
+++ b/src/skeleton/layouts/HStack.svelte
@@ -0,0 +1,25 @@
+
+
+
+ {@render children?.()}
+
+
+
diff --git a/src/skeleton/layouts/VStack.svelte b/src/skeleton/layouts/VStack.svelte
new file mode 100644
index 00000000..460e192d
--- /dev/null
+++ b/src/skeleton/layouts/VStack.svelte
@@ -0,0 +1,25 @@
+
+
+
+ {@render children?.()}
+
+
+
diff --git a/src/skeleton/layouts/index.ts b/src/skeleton/layouts/index.ts
new file mode 100644
index 00000000..55037d2f
--- /dev/null
+++ b/src/skeleton/layouts/index.ts
@@ -0,0 +1,3 @@
+export { default as SkeletonHStack } from './HStack.svelte';
+export { default as SkeletonVStack } from './VStack.svelte';
+export { default as SkeletonGrid } from './Grid.svelte';
diff --git a/src/skeleton/layouts/layouts.css b/src/skeleton/layouts/layouts.css
new file mode 100644
index 00000000..a22ed093
--- /dev/null
+++ b/src/skeleton/layouts/layouts.css
@@ -0,0 +1,7 @@
+.skeleton-hstack,
+.skeleton-vstack { display: flex; gap: 0.7em; flex: 1; }
+
+.skeleton-hstack { flex-flow: row nowrap; }
+.skeleton-vstack { flex-flow: column nowrap; }
+
+.skeleton-grid { display: grid; gap: 0.7em; }
diff --git a/src/skeleton/shapes/Circle.svelte b/src/skeleton/shapes/Circle.svelte
new file mode 100644
index 00000000..f30505ef
--- /dev/null
+++ b/src/skeleton/shapes/Circle.svelte
@@ -0,0 +1,26 @@
+
+
+
+
diff --git a/src/skeleton/shapes/Image.svelte b/src/skeleton/shapes/Image.svelte
new file mode 100644
index 00000000..622b6323
--- /dev/null
+++ b/src/skeleton/shapes/Image.svelte
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
diff --git a/src/skeleton/shapes/Rect.svelte b/src/skeleton/shapes/Rect.svelte
new file mode 100644
index 00000000..f785b26d
--- /dev/null
+++ b/src/skeleton/shapes/Rect.svelte
@@ -0,0 +1,30 @@
+
+
+{@render children?.()}
+
+
diff --git a/src/skeleton/shapes/Text.svelte b/src/skeleton/shapes/Text.svelte
new file mode 100644
index 00000000..684e0a15
--- /dev/null
+++ b/src/skeleton/shapes/Text.svelte
@@ -0,0 +1,32 @@
+
+
+
+ {#each Array(size) as _, i (i)}
+
+ {/each}
+
+
+
diff --git a/src/skeleton/shapes/index.ts b/src/skeleton/shapes/index.ts
new file mode 100644
index 00000000..17a566cb
--- /dev/null
+++ b/src/skeleton/shapes/index.ts
@@ -0,0 +1,4 @@
+export { default as SkeletonRect } from './Rect.svelte';
+export { default as SkeletonCircle } from './Circle.svelte';
+export { default as SkeletonImage } from './Image.svelte';
+export { default as SkeletonText } from './Text.svelte';
diff --git a/src/skeleton/shapes/shapes.css b/src/skeleton/shapes/shapes.css
new file mode 100644
index 00000000..25d72374
--- /dev/null
+++ b/src/skeleton/shapes/shapes.css
@@ -0,0 +1,65 @@
+.skeleton-shape {
+ position: relative;
+ border-radius: var(--ui-border-radius-l);
+ background-color: var(--ui-color-secondary-semi);
+ overflow: clip;
+}
+
+.skeleton-shape::after {
+ content: '';
+ display: block;
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ border-radius: inherit;
+ background-attachment: fixed;
+ background-repeat: no-repeat;
+ background-image: linear-gradient(119deg, #fff0 0%, #fff0 25%, #fffa 50%, #fff0 75%, #fff0 100%);
+ background-size: 100% 100%;
+ background-position: -100vw 0;
+ animation: skeleton-animation 2000ms infinite;
+}
+
+@keyframes skeleton-animation {
+ to { background-position: 100vw 0; }
+}
+
+
+/* vertical:
+.skeleton-item2::after {
+ background-image: linear-gradient(90deg, #fff0 0%, #fff9 50%, #fff0 100%);
+ background-size: 50% 100%;
+ background-position: -100% 0;
+ animation: skeleton-animation2 2000ms infinite;
+}
+@keyframes skeleton-animation2 {
+ to { background-position: 200% 0; }
+} */
+
+
+.skeleton-circle { border-radius: 50%; aspect-ratio: 1; width: 3em; }
+.skeleton-rect { width: auto; }
+
+
+.skeleton-img {
+ display: grid;
+ place-items: center;
+ min-height: 3em;
+ aspect-ratio: 16/9;
+}
+
+.skeleton-img svg {
+ width: 30%;
+ height: 30%;
+ color: var(--ui-color-text-2);
+ opacity: 0.3;
+}
+
+
+.skeleton-line { height: 0.8em; width: 75%; min-height: unset; }
+.skeleton-line:nth-child(6n+1) { width: 100%; }
+.skeleton-line:nth-child(6n+2) { width: 92%; }
+.skeleton-line:nth-child(6n+3) { width: 85%; }
+.skeleton-line:nth-child(6n+4) { width: 78%; }
+.skeleton-line:nth-child(6n+5) { width: 95%; }
+.skeleton-line:nth-child(6n+6) { width: 88%; }
diff --git a/src/skeleton/types.ts b/src/skeleton/types.ts
new file mode 100644
index 00000000..683b15ca
--- /dev/null
+++ b/src/skeleton/types.ts
@@ -0,0 +1,31 @@
+import type { ComponentProps } from '../types';
+
+
+// Layouts & Containers
+export type SkeletonContainer = Pick;
+
+export interface SkeletonGridProps extends SkeletonContainer {
+ size?: number | string;
+}
+
+export interface SkeletonRectProps extends SkeletonContainer {
+ width?: number | string;
+ height?: number | string;
+}
+
+
+// Shapes
+type SkeletonShape = Pick;
+
+export interface SkeletonCircleProps extends SkeletonShape {
+ size?: number | string;
+}
+
+export interface SkeletonTextProps extends SkeletonShape {
+ size?: number | string;
+}
+
+export interface SkeletonImageProps extends SkeletonShape {
+ width?: number | string;
+ height?: number | string;
+}
diff --git a/tests/Skeleton.svelte.test.ts b/tests/Skeleton.svelte.test.ts
new file mode 100644
index 00000000..27429119
--- /dev/null
+++ b/tests/Skeleton.svelte.test.ts
@@ -0,0 +1,142 @@
+import { beforeEach, expect, test } from 'vitest';
+import { flushSync, mount, unmount } from 'svelte';
+import { SkeletonRect, SkeletonText, SkeletonCircle } from '../src';
+
+
+beforeEach(() => {
+ document.body.innerHTML = '';
+});
+
+
+test('Skeleton renders default text template', async () => {
+ const component = mount(Skeleton, { target: document.body });
+
+ const svg = document.body.querySelector('svg.skeleton');
+ expect(svg).toBeInTheDocument();
+ const clipPath = svg.querySelector('clipPath');
+ expect(clipPath).toBeInTheDocument();
+ expect(svg.querySelector('linearGradient')).toBeInTheDocument();
+
+ expect(clipPath.querySelectorAll('rect').length).toBe(3); // default size = 3 lines
+
+ const filledRect = svg.querySelector('rect[clip-path]');
+ expect(filledRect).toBeInTheDocument();
+ expect(filledRect.getAttribute('fill')).toMatch(/url\(#skeleton-grad-/);
+
+ await unmount(component);
+});
+
+
+test('Skeleton has accessibility attributes', async () => {
+ const component = mount(Skeleton, { target: document.body });
+
+ const svg = document.body.querySelector('svg.skeleton');
+ expect(svg.getAttribute('role')).toBe('img');
+ expect(svg.getAttribute('aria-label')).toBe('Loading...');
+ expect(svg.getAttribute('aria-hidden')).toBeNull();
+ expect(svg.querySelector('title')?.textContent).toBe('Loading...');
+
+ await unmount(component);
+});
+
+
+test('Skeleton reacts to template changes', async () => {
+ const props = $state<{ template: any; size?: number }>({ template: 'text', size: 4 });
+ const component = mount(Skeleton, { target: document.body, props });
+
+ const svg = document.body.querySelector('svg.skeleton');
+ const clipPath = svg.querySelector('clipPath');
+ expect(clipPath.querySelectorAll('rect').length).toBe(4);
+ expect(clipPath.querySelector('circle')).toBeNull();
+
+ props.template = 'card';
+ flushSync();
+ expect(clipPath.querySelector('circle')).toBeInTheDocument();
+
+ await unmount(component);
+});
+
+
+test('Skeleton instances have unique IDs', async () => {
+ const a = mount(Skeleton, { target: document.body });
+ const b = mount(Skeleton, { target: document.body });
+
+ const svgs = document.body.querySelectorAll('svg.skeleton');
+ expect(svgs.length).toBe(2);
+
+ const idA = svgs[0].querySelector('clipPath').getAttribute('id');
+ const idB = svgs[1].querySelector('clipPath').getAttribute('id');
+ expect(idA).not.toBe(idB);
+
+ const gradA = svgs[0].querySelector('linearGradient').getAttribute('id');
+ const gradB = svgs[1].querySelector('linearGradient').getAttribute('id');
+ expect(gradA).not.toBe(gradB);
+
+ await unmount(a);
+ await unmount(b);
+});
+
+
+test('Skeleton duration prop sets animation duration', async () => {
+ const component = mount(Skeleton, { target: document.body, props: { duration: 1.5 } });
+
+ const anim = document.body.querySelector('animate');
+ expect(anim).toBeInTheDocument();
+ expect(anim.getAttribute('dur')).toBe('1.5s');
+
+ await unmount(component);
+});
+
+
+test('SkeletonRect standalone wraps itself in a Skeleton', async () => {
+ const component = mount(SkeletonRect, { target: document.body, props: { width: 100, height: 20 } });
+
+ const svg = document.body.querySelector('svg.skeleton');
+ expect(svg).toBeInTheDocument();
+ expect(svg.querySelector('clipPath rect, clipPath > rect') ?? svg.querySelector('clipPath')?.querySelector('rect')).toBeInTheDocument();
+
+ await unmount(component);
+});
+
+
+test('SkeletonCircle standalone wraps itself in a Skeleton', async () => {
+ const component = mount(SkeletonCircle, { target: document.body, props: { size: 40 } });
+
+ const svg = document.body.querySelector('svg.skeleton');
+ expect(svg).toBeInTheDocument();
+ const circle = svg.querySelector('clipPath')?.querySelector('circle');
+ expect(circle).toBeInTheDocument();
+ expect(circle.getAttribute('r')).toBe('20');
+
+ await unmount(component);
+});
+
+
+test('SkeletonText standalone renders lines inside its own skeleton', async () => {
+ const component = mount(SkeletonText, { target: document.body, props: { size: 5 } });
+
+ const svg = document.body.querySelector('svg.skeleton');
+ expect(svg).toBeInTheDocument();
+ const rects = svg.querySelector('clipPath').querySelectorAll('rect');
+ expect(rects.length).toBe(5);
+
+ await unmount(component);
+});
+
+
+test('SkeletonRect renders raw rect when used inside a Skeleton', async () => {
+ document.body.innerHTML = '';
+ const host = document.getElementById('host');
+
+ // mount a Skeleton with custom children that uses SkeletonRect
+ // we can't easily pass children via mount, so mount the primitive inside a skeleton context by
+ // rendering Skeleton with a snippet... simplest: verify rect has `rx` attribute set from internal radius calc
+ const component = mount(SkeletonRect, { target: host, props: { width: 100, height: 20 } });
+
+ const rect = host.querySelector('svg.skeleton clipPath')?.querySelector('rect');
+ expect(rect).toBeInTheDocument();
+ // naturalRadius(100, 20) = min(min(100,20)/4, 4) = min(5, 4) = 4
+ expect(rect.getAttribute('rx')).toBe('4');
+
+ await unmount(component);
+});
From 2da37161a81e25a2732eaca0aff77005fab93204 Mon Sep 17 00:00:00 2001
From: Dziad Borowy
Date: Tue, 21 Apr 2026 00:08:45 +0100
Subject: [PATCH 2/4] add skeleton component (fix #271)
---
docs/components/skeleton/Skeleton.svelte | 62 ++---
tests/Skeleton.svelte.test.ts | 327 +++++++++++++++++------
tests/helpers/Skeleton.svelte | 34 +++
3 files changed, 315 insertions(+), 108 deletions(-)
create mode 100644 tests/helpers/Skeleton.svelte
diff --git a/docs/components/skeleton/Skeleton.svelte b/docs/components/skeleton/Skeleton.svelte
index 37154123..4bde1a06 100644
--- a/docs/components/skeleton/Skeleton.svelte
+++ b/docs/components/skeleton/Skeleton.svelte
@@ -8,16 +8,16 @@ the primitive and template components for anything bespoke.
Skeleton Shapes
Rectangle
-
+
Circle
-
+
Text
-
+
Image
-
+
@@ -25,32 +25,32 @@ the primitive and template components for anything bespoke.
SkeletonHStack
-
-
-
-
+
+
+
+
SkeletonVStack
-
-
-
+
+
+
SkeletonGrid
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
Custom build
@@ -58,12 +58,12 @@ the primitive and template components for anything bespoke.
Cards list
-
-
+
+
-
-
+
+
@@ -72,21 +72,21 @@ the primitive and template components for anything bespoke.
{#snippet card()}
-
-
+
+
-
+
{/snippet}
-
+
{@render card()}
{@render card()}
{@render card()}
{@render card()}
{@render card()}
{@render card()}
-
+
@@ -127,7 +127,7 @@ import type { ApiProp } from '../../api-table/types';
import { API, PROPS } from '../../api-table';
import { Code } from '../../code-example';
-import { SkeletonRectProps, SkeletonCircleProps, SkeletonTextProps, SkeletonImageProps, SkeletonHStack, SkeletonVStack, SkeletonGridProps } from '../../../src';
+import { SkeletonRect, SkeletonCircle, SkeletonText, SkeletonImage, SkeletonHStack, SkeletonVStack, SkeletonGrid } from '../../../src';
const shapeProps = [
diff --git a/tests/Skeleton.svelte.test.ts b/tests/Skeleton.svelte.test.ts
index 27429119..d2efc1d8 100644
--- a/tests/Skeleton.svelte.test.ts
+++ b/tests/Skeleton.svelte.test.ts
@@ -1,6 +1,15 @@
import { beforeEach, expect, test } from 'vitest';
-import { flushSync, mount, unmount } from 'svelte';
-import { SkeletonRect, SkeletonText, SkeletonCircle } from '../src';
+import { mount, unmount } from 'svelte';
+import {
+ SkeletonRect,
+ SkeletonCircle,
+ SkeletonText,
+ SkeletonImage,
+ SkeletonHStack,
+ SkeletonVStack,
+ SkeletonGrid,
+} from '../src';
+import SkeletonLayout from './helpers/Skeleton.svelte';
beforeEach(() => {
@@ -8,135 +17,299 @@ beforeEach(() => {
});
-test('Skeleton renders default text template', async () => {
- const component = mount(Skeleton, { target: document.body });
+test('SkeletonRect renders with default classes', async () => {
+ const component = mount(SkeletonRect, { target: document.body });
- const svg = document.body.querySelector('svg.skeleton');
- expect(svg).toBeInTheDocument();
- const clipPath = svg.querySelector('clipPath');
- expect(clipPath).toBeInTheDocument();
- expect(svg.querySelector('linearGradient')).toBeInTheDocument();
+ const el = document.body.querySelector('.skeleton-shape.skeleton-rect');
+ expect(el).toBeInTheDocument();
+
+ await unmount(component);
+});
+
+
+test('SkeletonRect applies numeric width/height as pixels', async () => {
+ const component = mount(SkeletonRect, {
+ target: document.body,
+ props: { width: 200, height: 40 },
+ });
+
+ const el = document.body.querySelector('.skeleton-rect');
+ expect(el?.style.width).toBe('200px');
+ expect(el?.style.height).toBe('40px');
+
+ await unmount(component);
+});
+
+
+test('SkeletonRect applies string width/height as-is', async () => {
+ const component = mount(SkeletonRect, {
+ target: document.body,
+ props: { width: '50%', height: '4em' },
+ });
+
+ const el = document.body.querySelector('.skeleton-rect');
+ expect(el?.style.width).toBe('50%');
+ expect(el?.style.height).toBe('4em');
+
+ await unmount(component);
+});
+
+
+test('SkeletonRect applies custom class', async () => {
+ const component = mount(SkeletonRect, {
+ target: document.body,
+ props: { class: 'my-rect' },
+ });
+
+ const el = document.body.querySelector('.skeleton-rect');
+ expect(el).toHaveClass('my-rect');
+
+ await unmount(component);
+});
+
+
+test('SkeletonCircle renders with default size 60px', async () => {
+ const component = mount(SkeletonCircle, { target: document.body });
+
+ const el = document.body.querySelector('.skeleton-shape.skeleton-circle');
+ expect(el).toBeInTheDocument();
+ expect(el?.style.width).toBe('60px');
+ expect(el?.style.height).toBe('60px');
+
+ await unmount(component);
+});
+
+
+test('SkeletonCircle applies numeric size as pixels', async () => {
+ const component = mount(SkeletonCircle, {
+ target: document.body,
+ props: { size: 80 },
+ });
+
+ const el = document.body.querySelector('.skeleton-circle');
+ expect(el?.style.width).toBe('80px');
+ expect(el?.style.height).toBe('80px');
+
+ await unmount(component);
+});
+
+
+test('SkeletonCircle applies string size as-is', async () => {
+ const component = mount(SkeletonCircle, {
+ target: document.body,
+ props: { size: '5em' },
+ });
+
+ const el = document.body.querySelector('.skeleton-circle');
+ expect(el?.style.width).toBe('5em');
+ expect(el?.style.height).toBe('5em');
+
+ await unmount(component);
+});
- expect(clipPath.querySelectorAll('rect').length).toBe(3); // default size = 3 lines
- const filledRect = svg.querySelector('rect[clip-path]');
- expect(filledRect).toBeInTheDocument();
- expect(filledRect.getAttribute('fill')).toMatch(/url\(#skeleton-grad-/);
+test('SkeletonCircle applies custom class', async () => {
+ const component = mount(SkeletonCircle, {
+ target: document.body,
+ props: { class: 'avatar' },
+ });
+
+ const el = document.body.querySelector('.skeleton-circle');
+ expect(el).toHaveClass('avatar');
+
+ await unmount(component);
+});
+
+
+test('SkeletonText renders default 3 lines wrapped in VStack', async () => {
+ const component = mount(SkeletonText, { target: document.body });
+
+ const wrapper = document.body.querySelector('.skeleton-vstack.skeleton-text');
+ expect(wrapper).toBeInTheDocument();
+
+ const lines = wrapper.querySelectorAll('.skeleton-rect.skeleton-line');
+ expect(lines.length).toBe(3);
+
+ await unmount(component);
+});
+
+
+test('SkeletonText renders the requested number of lines', async () => {
+ const component = mount(SkeletonText, {
+ target: document.body,
+ props: { size: 7 },
+ });
+
+ const lines = document.body.querySelectorAll('.skeleton-text .skeleton-line');
+ expect(lines.length).toBe(7);
await unmount(component);
});
-test('Skeleton has accessibility attributes', async () => {
- const component = mount(Skeleton, { target: document.body });
+test('SkeletonText applies custom class on the wrapper', async () => {
+ const component = mount(SkeletonText, {
+ target: document.body,
+ props: { class: 'paragraph' },
+ });
- const svg = document.body.querySelector('svg.skeleton');
- expect(svg.getAttribute('role')).toBe('img');
- expect(svg.getAttribute('aria-label')).toBe('Loading...');
- expect(svg.getAttribute('aria-hidden')).toBeNull();
- expect(svg.querySelector('title')?.textContent).toBe('Loading...');
+ const wrapper = document.body.querySelector('.skeleton-text');
+ expect(wrapper).toHaveClass('paragraph');
await unmount(component);
});
-test('Skeleton reacts to template changes', async () => {
- const props = $state<{ template: any; size?: number }>({ template: 'text', size: 4 });
- const component = mount(Skeleton, { target: document.body, props });
+test('SkeletonImage renders a rect with photo icon and default dimensions', async () => {
+ const component = mount(SkeletonImage, { target: document.body });
- const svg = document.body.querySelector('svg.skeleton');
- const clipPath = svg.querySelector('clipPath');
- expect(clipPath.querySelectorAll('rect').length).toBe(4);
- expect(clipPath.querySelector('circle')).toBeNull();
+ const el = document.body.querySelector('.skeleton-rect.skeleton-img');
+ expect(el).toBeInTheDocument();
+ expect(el?.style.width).toBe('100%');
+ expect(el?.style.height).toBe('120px');
- props.template = 'card';
- flushSync();
- expect(clipPath.querySelector('circle')).toBeInTheDocument();
+ const icon = el.querySelector('svg');
+ expect(icon).toBeInTheDocument();
await unmount(component);
});
-test('Skeleton instances have unique IDs', async () => {
- const a = mount(Skeleton, { target: document.body });
- const b = mount(Skeleton, { target: document.body });
+test('SkeletonImage applies custom width and height', async () => {
+ const component = mount(SkeletonImage, {
+ target: document.body,
+ props: { width: 240, height: '10em' },
+ });
+
+ const el = document.body.querySelector('.skeleton-img');
+ expect(el?.style.width).toBe('240px');
+ expect(el?.style.height).toBe('10em');
+
+ await unmount(component);
+});
- const svgs = document.body.querySelectorAll('svg.skeleton');
- expect(svgs.length).toBe(2);
- const idA = svgs[0].querySelector('clipPath').getAttribute('id');
- const idB = svgs[1].querySelector('clipPath').getAttribute('id');
- expect(idA).not.toBe(idB);
+test('SkeletonImage applies custom class', async () => {
+ const component = mount(SkeletonImage, {
+ target: document.body,
+ props: { class: 'hero' },
+ });
- const gradA = svgs[0].querySelector('linearGradient').getAttribute('id');
- const gradB = svgs[1].querySelector('linearGradient').getAttribute('id');
- expect(gradA).not.toBe(gradB);
+ const el = document.body.querySelector('.skeleton-img');
+ expect(el).toHaveClass('hero');
- await unmount(a);
- await unmount(b);
+ await unmount(component);
});
-test('Skeleton duration prop sets animation duration', async () => {
- const component = mount(Skeleton, { target: document.body, props: { duration: 1.5 } });
+test('SkeletonHStack renders container with custom class', async () => {
+ const component = mount(SkeletonHStack, {
+ target: document.body,
+ props: { class: 'row' },
+ });
+
+ const el = document.body.querySelector('.skeleton-hstack');
+ expect(el).toBeInTheDocument();
+ expect(el).toHaveClass('row');
+
+ await unmount(component);
+});
+
+
+test('SkeletonHStack renders its children', async () => {
+ const component = mount(SkeletonLayout, {
+ target: document.body,
+ props: { layout: 'hstack', class: 'row' },
+ });
+
+ const el = document.body.querySelector('.skeleton-hstack.row');
+ expect(el).toBeInTheDocument();
+
+ const children = el.querySelectorAll(':scope > .child');
+ expect(children.length).toBe(2);
+
+ await unmount(component);
+});
+
+
+test('SkeletonVStack renders container with custom class', async () => {
+ const component = mount(SkeletonVStack, {
+ target: document.body,
+ props: { class: 'col' },
+ });
+
+ const el = document.body.querySelector('.skeleton-vstack');
+ expect(el).toBeInTheDocument();
+ expect(el).toHaveClass('col');
+
+ await unmount(component);
+});
+
+
+test('SkeletonVStack renders its children', async () => {
+ const component = mount(SkeletonLayout, {
+ target: document.body,
+ props: { layout: 'vstack', class: 'col' },
+ });
+
+ const el = document.body.querySelector('.skeleton-vstack.col');
+ expect(el).toBeInTheDocument();
- const anim = document.body.querySelector('animate');
- expect(anim).toBeInTheDocument();
- expect(anim.getAttribute('dur')).toBe('1.5s');
+ const children = el.querySelectorAll(':scope > .child');
+ expect(children.length).toBe(2);
await unmount(component);
});
-test('SkeletonRect standalone wraps itself in a Skeleton', async () => {
- const component = mount(SkeletonRect, { target: document.body, props: { width: 100, height: 20 } });
+test('SkeletonGrid renders with default 150px min column width', async () => {
+ const component = mount(SkeletonGrid, { target: document.body });
- const svg = document.body.querySelector('svg.skeleton');
- expect(svg).toBeInTheDocument();
- expect(svg.querySelector('clipPath rect, clipPath > rect') ?? svg.querySelector('clipPath')?.querySelector('rect')).toBeInTheDocument();
+ const el = document.body.querySelector('.skeleton-grid');
+ expect(el).toBeInTheDocument();
+ expect(el?.getAttribute('style')).toContain('minmax(150px, 1fr)');
await unmount(component);
});
-test('SkeletonCircle standalone wraps itself in a Skeleton', async () => {
- const component = mount(SkeletonCircle, { target: document.body, props: { size: 40 } });
+test('SkeletonGrid accepts numeric size as pixels', async () => {
+ const component = mount(SkeletonGrid, {
+ target: document.body,
+ props: { size: 220 },
+ });
- const svg = document.body.querySelector('svg.skeleton');
- expect(svg).toBeInTheDocument();
- const circle = svg.querySelector('clipPath')?.querySelector('circle');
- expect(circle).toBeInTheDocument();
- expect(circle.getAttribute('r')).toBe('20');
+ const el = document.body.querySelector('.skeleton-grid');
+ expect(el?.getAttribute('style')).toContain('minmax(220px, 1fr)');
await unmount(component);
});
-test('SkeletonText standalone renders lines inside its own skeleton', async () => {
- const component = mount(SkeletonText, { target: document.body, props: { size: 5 } });
+test('SkeletonGrid accepts string size as-is', async () => {
+ const component = mount(SkeletonGrid, {
+ target: document.body,
+ props: { size: '12rem' },
+ });
- const svg = document.body.querySelector('svg.skeleton');
- expect(svg).toBeInTheDocument();
- const rects = svg.querySelector('clipPath').querySelectorAll('rect');
- expect(rects.length).toBe(5);
+ const el = document.body.querySelector('.skeleton-grid');
+ expect(el?.getAttribute('style')).toContain('minmax(12rem, 1fr)');
await unmount(component);
});
-test('SkeletonRect renders raw rect when used inside a Skeleton', async () => {
- document.body.innerHTML = '';
- const host = document.getElementById('host');
+test('SkeletonGrid renders its children', async () => {
+ const component = mount(SkeletonLayout, {
+ target: document.body,
+ props: { layout: 'grid', class: 'gallery', size: 120 },
+ });
- // mount a Skeleton with custom children that uses SkeletonRect
- // we can't easily pass children via mount, so mount the primitive inside a skeleton context by
- // rendering Skeleton with a snippet... simplest: verify rect has `rx` attribute set from internal radius calc
- const component = mount(SkeletonRect, { target: host, props: { width: 100, height: 20 } });
+ const el = document.body.querySelector('.skeleton-grid.gallery');
+ expect(el).toBeInTheDocument();
- const rect = host.querySelector('svg.skeleton clipPath')?.querySelector('rect');
- expect(rect).toBeInTheDocument();
- // naturalRadius(100, 20) = min(min(100,20)/4, 4) = min(5, 4) = 4
- expect(rect.getAttribute('rx')).toBe('4');
+ const children = el.querySelectorAll(':scope > .child');
+ expect(children.length).toBe(3);
await unmount(component);
});
diff --git a/tests/helpers/Skeleton.svelte b/tests/helpers/Skeleton.svelte
new file mode 100644
index 00000000..42b7437d
--- /dev/null
+++ b/tests/helpers/Skeleton.svelte
@@ -0,0 +1,34 @@
+
+
+{#if layout === 'hstack'}
+
+
+
+
+{:else if layout === 'vstack'}
+
+
+
+
+{:else}
+
+
+
+
+
+{/if}
From 016d3c58140cc0c4c58b26d7e28d249133fde5cf Mon Sep 17 00:00:00 2001
From: Dziad Borowy
Date: Tue, 21 Apr 2026 00:11:04 +0100
Subject: [PATCH 3/4] add skeleton component (fix #271)
---
docs/components/skeleton/Skeleton.svelte | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/docs/components/skeleton/Skeleton.svelte b/docs/components/skeleton/Skeleton.svelte
index 4bde1a06..47832820 100644
--- a/docs/components/skeleton/Skeleton.svelte
+++ b/docs/components/skeleton/Skeleton.svelte
@@ -1,9 +1,7 @@
Skeleton
-Animated SVG placeholder for loading states. The component is responsive and fills its
-container — give the wrapper a width and height and the skeleton stretches to match.
-Six built-in templates cover common cases; drop down to template="custom" with
-the primitive and template components for anything bespoke.
+Animated placeholder for loading states.
+
Skeleton Shapes
From bb96e7610a6ebe02ffeb22eae656b45d3f9229ac Mon Sep 17 00:00:00 2001
From: Dziad Borowy
Date: Tue, 21 Apr 2026 00:13:52 +0100
Subject: [PATCH 4/4]
---
CHANGELOG.md | 5 +++++
docs/pages/changelog.html | 5 +++++
2 files changed, 10 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f9ed920..5672d40a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,11 @@ Changelog
=========
+## v10.1.5 *(2026-03-21)*
+- New: skeleton loader components.
+- New: icon `photo`.
+
+
## v10.1.4 *(2026-03-19)*
- Grid column cell renderer now accepts svelte snippets.
diff --git a/docs/pages/changelog.html b/docs/pages/changelog.html
index 1a313d61..667efc55 100644
--- a/docs/pages/changelog.html
+++ b/docs/pages/changelog.html
@@ -1,4 +1,9 @@
Changelog
+v10.1.5 (2026-03-21)
+
+- New: skeleton loader components.
+- New: icon
photo.
+
v10.1.4 (2026-03-19)
- Grid column cell renderer now accepts svelte snippets.