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/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..47832820 --- /dev/null +++ b/docs/components/skeleton/Skeleton.svelte @@ -0,0 +1,138 @@ +

Skeleton

+ +

Animated placeholder for loading states.

+ + +

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/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.
  • 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..d2efc1d8 --- /dev/null +++ b/tests/Skeleton.svelte.test.ts @@ -0,0 +1,315 @@ +import { beforeEach, expect, test } from 'vitest'; +import { mount, unmount } from 'svelte'; +import { + SkeletonRect, + SkeletonCircle, + SkeletonText, + SkeletonImage, + SkeletonHStack, + SkeletonVStack, + SkeletonGrid, +} from '../src'; +import SkeletonLayout from './helpers/Skeleton.svelte'; + + +beforeEach(() => { + document.body.innerHTML = ''; +}); + + +test('SkeletonRect renders with default classes', async () => { + const component = mount(SkeletonRect, { target: document.body }); + + 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); +}); + + +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('SkeletonText applies custom class on the wrapper', async () => { + const component = mount(SkeletonText, { + target: document.body, + props: { class: 'paragraph' }, + }); + + const wrapper = document.body.querySelector('.skeleton-text'); + expect(wrapper).toHaveClass('paragraph'); + + await unmount(component); +}); + + +test('SkeletonImage renders a rect with photo icon and default dimensions', async () => { + const component = mount(SkeletonImage, { target: document.body }); + + const el = document.body.querySelector('.skeleton-rect.skeleton-img'); + expect(el).toBeInTheDocument(); + expect(el?.style.width).toBe('100%'); + expect(el?.style.height).toBe('120px'); + + const icon = el.querySelector('svg'); + expect(icon).toBeInTheDocument(); + + await unmount(component); +}); + + +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); +}); + + +test('SkeletonImage applies custom class', async () => { + const component = mount(SkeletonImage, { + target: document.body, + props: { class: 'hero' }, + }); + + const el = document.body.querySelector('.skeleton-img'); + expect(el).toHaveClass('hero'); + + await unmount(component); +}); + + +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 children = el.querySelectorAll(':scope > .child'); + expect(children.length).toBe(2); + + await unmount(component); +}); + + +test('SkeletonGrid renders with default 150px min column width', async () => { + const component = mount(SkeletonGrid, { target: document.body }); + + const el = document.body.querySelector('.skeleton-grid'); + expect(el).toBeInTheDocument(); + expect(el?.getAttribute('style')).toContain('minmax(150px, 1fr)'); + + await unmount(component); +}); + + +test('SkeletonGrid accepts numeric size as pixels', async () => { + const component = mount(SkeletonGrid, { + target: document.body, + props: { size: 220 }, + }); + + const el = document.body.querySelector('.skeleton-grid'); + expect(el?.getAttribute('style')).toContain('minmax(220px, 1fr)'); + + await unmount(component); +}); + + +test('SkeletonGrid accepts string size as-is', async () => { + const component = mount(SkeletonGrid, { + target: document.body, + props: { size: '12rem' }, + }); + + const el = document.body.querySelector('.skeleton-grid'); + expect(el?.getAttribute('style')).toContain('minmax(12rem, 1fr)'); + + await unmount(component); +}); + + +test('SkeletonGrid renders its children', async () => { + const component = mount(SkeletonLayout, { + target: document.body, + props: { layout: 'grid', class: 'gallery', size: 120 }, + }); + + const el = document.body.querySelector('.skeleton-grid.gallery'); + expect(el).toBeInTheDocument(); + + 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}