>}
+
+{:else if svg || svgUrl || children}
+
+ {#if children}
+ {@render children()}
+ {:else}
+
+ {@html svg ?? ''}
+ {/if}
+
+{:else}
+
+ {#if title}
+ {title}
+ {/if}
+ {#if desc}
+ {desc}
+ {/if}
+
+ {#each Array.isArray(path) ? path : [path] as d, i (i)}
+
+ {/each}
+
+{/if}
+
+
diff --git a/packages/ui/src/lib/components/Icon.svelte.test.ts b/packages/ui/src/lib/components/Icon.svelte.test.ts
new file mode 100644
index 0000000..d03a3f4
--- /dev/null
+++ b/packages/ui/src/lib/components/Icon.svelte.test.ts
@@ -0,0 +1,249 @@
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+
+import Icon from './Icon.svelte';
+import IconChildrenHarness from './tests/IconChildrenHarness.svelte';
+
+/** A minimal Font Awesome `IconDefinition` (`[width, height, ligatures, unicode, path]`) */
+const faIcon = {
+ prefix: 'fas',
+ iconName: 'house',
+ icon: [576, 512, [], 'f015', 'M100 200L300 400'] as [number, number, string[], string, string],
+};
+
+const svgPath = 'M12 2L2 7l10 5 10-5-10-5z';
+
+function svgOf(container: HTMLElement) {
+ return container.querySelector('svg.Icon') as SVGSVGElement | null;
+}
+
+describe('Icon', () => {
+ describe('path data', () => {
+ it('renders a `path` prop as an svg path', async () => {
+ const { container } = render(Icon, { path: svgPath });
+
+ const svg = svgOf(container)!;
+ expect(svg).not.toBeNull();
+ expect(svg.getAttribute('viewBox')).toBe('0 0 24 24');
+ expect([...svg.querySelectorAll('path')].map((p) => p.getAttribute('d'))).toEqual([svgPath]);
+ });
+
+ it('renders each entry of a `path` array', async () => {
+ const { container } = render(Icon, { path: [svgPath, 'M0 0L1 1'] });
+
+ const paths = [...svgOf(container)!.querySelectorAll('path')];
+ expect(paths.map((p) => p.getAttribute('d'))).toEqual([svgPath, 'M0 0L1 1']);
+ });
+
+ it('accepts a path string as `data`', async () => {
+ const { container } = render(Icon, { data: svgPath });
+
+ expect(svgOf(container)!.querySelector('path')?.getAttribute('d')).toBe(svgPath);
+ });
+ });
+
+ describe('sizing', () => {
+ it('defaults to `1.2em`', async () => {
+ const { container } = render(Icon, { path: svgPath });
+
+ const svg = svgOf(container)!;
+ expect(svg.getAttribute('width')).toBe('1.2em');
+ expect(svg.getAttribute('height')).toBe('1.2em');
+ });
+
+ it('applies `size` to both dimensions', async () => {
+ const { container } = render(Icon, { path: svgPath, size: '2rem' });
+
+ const svg = svgOf(container)!;
+ expect(svg.getAttribute('width')).toBe('2rem');
+ expect(svg.getAttribute('height')).toBe('2rem');
+ });
+
+ it('lets explicit `width`/`height` win over `size`', async () => {
+ const { container } = render(Icon, { path: svgPath, size: '2rem', width: '10px' });
+
+ const svg = svgOf(container)!;
+ expect(svg.getAttribute('width')).toBe('10px');
+ expect(svg.getAttribute('height')).toBe('2rem');
+ });
+ });
+
+ describe('font awesome data', () => {
+ it('derives `viewBox`, `path`, and size', async () => {
+ const { container } = render(Icon, { data: faIcon });
+
+ const svg = svgOf(container)!;
+ expect(svg.getAttribute('viewBox')).toBe('0 0 576 512');
+ expect(svg.querySelector('path')?.getAttribute('d')).toBe('M100 200L300 400');
+ expect(svg.getAttribute('width')).toBe('1.0rem');
+ expect(svg.getAttribute('height')).toBe('1.0rem');
+ });
+
+ it('still honors an explicit `width`', async () => {
+ const { container } = render(Icon, { data: faIcon, width: '3em' });
+
+ expect(svgOf(container)!.getAttribute('width')).toBe('3em');
+ });
+ });
+
+ describe('inline svg', () => {
+ it('renders an `svg` prop inside a span', async () => {
+ const { container } = render(Icon, { svg: ' ' });
+
+ const span = container.querySelector('span.Icon')!;
+ expect(span).not.toBeNull();
+ expect(span.querySelector('[data-testid="inline"]')).not.toBeNull();
+ });
+
+ it('accepts inline svg markup as `data`', async () => {
+ const { container } = render(Icon, { data: ' ' });
+
+ expect(container.querySelector('span.Icon [data-testid="inline"]')).not.toBeNull();
+ });
+
+ it('sizes font awesome svg markup in `rem`', async () => {
+ const { container } = render(Icon, {
+ svg: ' ',
+ });
+
+ const span = container.querySelector('span.Icon') as HTMLElement;
+ expect(span.style.width).toBe('1rem');
+ expect(span.style.height).toBe('1rem');
+ });
+ });
+
+ describe('children', () => {
+ it('renders children instead of `svg` markup', async () => {
+ const { container } = render(IconChildrenHarness, {});
+
+ expect(container.querySelector('span.Icon [data-testid="custom-child"]')).not.toBeNull();
+ });
+ });
+
+ describe('svgUrl', () => {
+ beforeEach(() => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response(' '))
+ );
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('fetches and renders remote svg markup', async () => {
+ const { container } = render(Icon, { svgUrl: 'https://example.test/icon-a.svg' });
+
+ await vi.waitFor(() => {
+ expect(container.querySelector('span.Icon [data-testid="fetched"]')).not.toBeNull();
+ });
+ expect(fetch).toHaveBeenCalledWith('https://example.test/icon-a.svg');
+ });
+
+ it('accepts a url as `data`', async () => {
+ const { container } = render(Icon, { data: 'https://example.test/icon-b.svg' });
+
+ await vi.waitFor(() => {
+ expect(container.querySelector('span.Icon [data-testid="fetched"]')).not.toBeNull();
+ });
+ });
+
+ it('reuses the cached request across instances', async () => {
+ const url = 'https://example.test/icon-cached.svg';
+ render(Icon, { svgUrl: url });
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
+
+ render(Icon, { svgUrl: url });
+ await vi.waitFor(() => {
+ expect(document.querySelectorAll('[data-testid="fetched"]').length).toBeGreaterThan(1);
+ });
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('accessibility', () => {
+ it('is presentational without a title or desc', async () => {
+ const { container } = render(Icon, { path: svgPath });
+
+ const svg = svgOf(container)!;
+ expect(svg.getAttribute('role')).toBe('presentation');
+ expect(svg.getAttribute('aria-labelledby')).toBeNull();
+ });
+
+ it('renders a title and labels the icon', async () => {
+ const { container } = render(Icon, { path: svgPath, title: 'Home' });
+
+ const svg = svgOf(container)!;
+ expect(svg.getAttribute('role')).toBe('img');
+ const title = svg.querySelector('title')!;
+ expect(title.textContent).toBe('Home');
+ expect(svg.getAttribute('aria-labelledby')).toContain(title.id);
+ });
+
+ it('renders a desc and labels the icon', async () => {
+ const { container } = render(Icon, { path: svgPath, desc: 'Return home' });
+
+ const svg = svgOf(container)!;
+ const desc = svg.querySelector('desc')!;
+ expect(desc.textContent).toBe('Return home');
+ expect(svg.getAttribute('aria-labelledby')).toContain(desc.id);
+ });
+
+ it('honors explicit ids', async () => {
+ const { container } = render(Icon, { path: svgPath, title: 'Home', titleId: 'my-title' });
+
+ const svg = svgOf(container)!;
+ expect(svg.querySelector('title')!.id).toBe('my-title');
+ expect(svg.getAttribute('aria-labelledby')).toContain('my-title');
+ });
+ });
+
+ describe('classes', () => {
+ it('always applies the `Icon` class and merges `class`', async () => {
+ const { container } = render(Icon, { path: svgPath, class: 'text-red-500' });
+
+ const svg = svgOf(container)!;
+ expect(svg.classList.contains('Icon')).toBe(true);
+ expect(svg.classList.contains('text-red-500')).toBe(true);
+ });
+
+ it('applies `classes.path` per path', async () => {
+ const { container } = render(Icon, {
+ path: [svgPath, 'M0 0L1 1'],
+ classes: { path: ['first', 'second'] },
+ });
+
+ const paths = [...svgOf(container)!.querySelectorAll('path')];
+ expect(paths[0].classList.contains('first')).toBe(true);
+ expect(paths[1].classList.contains('second')).toBe(true);
+ });
+
+ it('applies a single `classes.path` to every path', async () => {
+ const { container } = render(Icon, {
+ path: [svgPath, 'M0 0L1 1'],
+ classes: { path: 'shared' },
+ });
+
+ for (const path of svgOf(container)!.querySelectorAll('path')) {
+ expect(path.classList.contains('shared')).toBe(true);
+ }
+ });
+ });
+
+ describe('rest props', () => {
+ it('forwards attributes to the svg', async () => {
+ const { container } = render(Icon, { path: svgPath, 'data-foo': 'bar' });
+
+ expect(svgOf(container)!.getAttribute('data-foo')).toBe('bar');
+ });
+
+ it('forwards click handlers', async () => {
+ const onclick = vi.fn();
+ const { container } = render(Icon, { path: svgPath, onclick });
+
+ svgOf(container)!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ expect(onclick).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/packages/ui/src/lib/components/InfiniteScroll.svelte b/packages/ui/src/lib/components/InfiniteScroll.svelte
new file mode 100644
index 0000000..931cf78
--- /dev/null
+++ b/packages/ui/src/lib/components/InfiniteScroll.svelte
@@ -0,0 +1,46 @@
+
+
+
+
+{@render children?.({ visibleItems })}
+
+{#if !disabled}
+
+ {
+ if (entry.isIntersecting) {
+ page += 1;
+ }
+ onIntersecting?.(entry);
+ },
+ })}
+ >
+{/if}
diff --git a/packages/ui/src/lib/components/Input.svelte b/packages/ui/src/lib/components/Input.svelte
new file mode 100644
index 0000000..c325b97
--- /dev/null
+++ b/packages/ui/src/lib/components/Input.svelte
@@ -0,0 +1,195 @@
+
+
+
+
+ {
+ backspace = e.key === 'Backspace';
+ restProps.onkeydown?.(e);
+ }}
+ oninput={(e) => {
+ applyMask(e.currentTarget, mask);
+ onChange?.(value);
+ restProps.oninput?.(e);
+ }}
+ onfocus={(e) => {
+ isFocused = true;
+ restProps.onfocus?.(e);
+ }}
+ onblur={(e) => {
+ isFocused = false;
+ // Reset the value if it still contains mask placeholders, to ensure complete entries
+ if (mask && value) {
+ const partialMaskMatch = [...value].some((char) => replaceSet.has(char));
+ if (partialMaskMatch) {
+ value = '';
+ onChange?.(value);
+ }
+ }
+ restProps.onblur?.(e);
+ }}
+/>
+
+
diff --git a/packages/ui/src/lib/components/Input.svelte.test.ts b/packages/ui/src/lib/components/Input.svelte.test.ts
new file mode 100644
index 0000000..b52bd56
--- /dev/null
+++ b/packages/ui/src/lib/components/Input.svelte.test.ts
@@ -0,0 +1,167 @@
+import { describe, expect, it, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+
+import Input from './Input.svelte';
+import InputHarness from './tests/InputHarness.svelte';
+
+function inputOf(container: HTMLElement) {
+ return container.querySelector('input.Input') as HTMLInputElement;
+}
+
+/** Type into an input the way a user would, so the component's `oninput` handler runs */
+function type(el: HTMLInputElement, value: string) {
+ el.value = value;
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+}
+
+describe('Input', () => {
+ it('renders a text input by default', async () => {
+ const { container } = render(Input, {});
+
+ const el = inputOf(container);
+ expect(el.type).toBe('text');
+ expect(el.value).toBe('');
+ });
+
+ it('binds the value', async () => {
+ const { container, getByTestId } = render(InputHarness, { value: 'hello' });
+
+ expect(inputOf(container).value).toBe('hello');
+
+ type(inputOf(container), 'world');
+ await vi.waitFor(() => {
+ expect(getByTestId('bound-value').element().textContent).toBe('world');
+ });
+ });
+
+ it('calls `onChange` on input', async () => {
+ const onChange = vi.fn();
+ const { container } = render(Input, { onChange });
+
+ type(inputOf(container), 'abc');
+ expect(onChange).toHaveBeenCalledWith('abc');
+ });
+
+ it('forwards native handlers alongside its own', async () => {
+ const oninput = vi.fn();
+ const onfocus = vi.fn();
+ const { container } = render(Input, { oninput, onfocus });
+
+ const el = inputOf(container);
+ type(el, 'x');
+ expect(oninput).toHaveBeenCalledTimes(1);
+
+ el.dispatchEvent(new FocusEvent('focus'));
+ expect(onfocus).toHaveBeenCalledTimes(1);
+ });
+
+ it('passes through standard attributes', async () => {
+ const { container } = render(Input, {
+ name: 'phone',
+ type: 'number',
+ min: 1,
+ max: 10,
+ step: 2,
+ required: true,
+ disabled: true,
+ placeholder: 'Enter',
+ });
+
+ const el = inputOf(container);
+ expect(el.name).toBe('phone');
+ expect(el.type).toBe('number');
+ expect(el.min).toBe('1');
+ expect(el.max).toBe('10');
+ expect(el.step).toBe('2');
+ expect(el.required).toBe(true);
+ expect(el.disabled).toBe(true);
+ expect(el.placeholder).toBe('Enter');
+ });
+
+ describe('mask', () => {
+ const mask = '(___) ___-____';
+
+ it('uses the mask as the placeholder', async () => {
+ const { container } = render(Input, { mask });
+
+ expect(inputOf(container).placeholder).toBe(mask);
+ });
+
+ it('formats digits into the mask', async () => {
+ const { container, getByTestId } = render(InputHarness, { mask });
+
+ type(inputOf(container), '5551234567');
+ await vi.waitFor(() => {
+ expect(getByTestId('bound-value').element().textContent).toBe('(555) 123-4567');
+ });
+ });
+
+ it('formats an initial value on mount', async () => {
+ const { getByTestId } = render(InputHarness, { mask, value: '5551234567' });
+
+ await vi.waitFor(() => {
+ expect(getByTestId('bound-value').element().textContent).toBe('(555) 123-4567');
+ });
+ });
+
+ it('ignores characters the mask does not accept', async () => {
+ const { container, getByTestId } = render(InputHarness, { mask });
+
+ type(inputOf(container), 'abc555');
+ await vi.waitFor(() => {
+ expect(getByTestId('bound-value').element().textContent).toContain('(555');
+ });
+ });
+
+ it('clears a partially entered value on blur', async () => {
+ const onChange = vi.fn();
+ const { container } = render(Input, { mask, onChange });
+
+ const el = inputOf(container);
+ type(el, '555');
+ onChange.mockClear();
+
+ el.dispatchEvent(new FocusEvent('blur'));
+ expect(onChange).toHaveBeenCalledWith('');
+ });
+
+ it('keeps a fully entered value on blur', async () => {
+ const onChange = vi.fn();
+ const { container } = render(Input, { mask, onChange });
+
+ const el = inputOf(container);
+ type(el, '5551234567');
+ onChange.mockClear();
+
+ el.dispatchEvent(new FocusEvent('blur'));
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it('accepts a custom `accept` pattern', async () => {
+ const { container, getByTestId } = render(InputHarness, {
+ mask: '___',
+ accept: '[a-z]',
+ });
+
+ type(inputOf(container), 'a1b2c3');
+ await vi.waitFor(() => {
+ expect(getByTestId('bound-value').element().textContent).toBe('abc');
+ });
+ });
+ });
+
+ it('merges a custom class', async () => {
+ const { container } = render(Input, { class: 'custom' });
+
+ expect(inputOf(container).classList.contains('custom')).toBe(true);
+ });
+
+ it('exposes the element via `inputEl`', async () => {
+ const { container } = render(InputHarness, {});
+
+ // the harness binds `value`; assert the input is reachable and focusable
+ const el = inputOf(container);
+ el.focus();
+ expect(document.activeElement).toBe(el);
+ });
+});
diff --git a/packages/ui/src/lib/components/Kbd.svelte b/packages/ui/src/lib/components/Kbd.svelte
new file mode 100644
index 0000000..e81d006
--- /dev/null
+++ b/packages/ui/src/lib/components/Kbd.svelte
@@ -0,0 +1,60 @@
+
+
+
+
+
+ {#if control}
+ ⌃
+ {/if}
+
+ {#if option}
+ ⌥
+ {/if}
+
+ {#if shift}
+ ⇧
+ {/if}
+
+ {#if command}
+ ⌘
+ {/if}
+
+ {@render children?.()}
+
diff --git a/packages/ui/src/lib/components/LanguageSelect.svelte b/packages/ui/src/lib/components/LanguageSelect.svelte
new file mode 100644
index 0000000..a4e45a7
--- /dev/null
+++ b/packages/ui/src/lib/components/LanguageSelect.svelte
@@ -0,0 +1,75 @@
+
+
+
+
+ (open = !open)}
+>
+ {selected?.code ?? settings.locale}
+
+
+
+ {#each languages as language (language.code)}
+ {
+ onLanguageSet?.(language);
+ settings.setLocale(language.code);
+ }}
+ class={cls(
+ 'bg-surface-100 text-surface-content font-semibold border shadow-sm',
+ selected === language && 'ring-2 ring-surface-content'
+ )}
+ >
+ {language.flag} - {language.name}
+
+ {/each}
+
+
+
+ Affects date & number formats
+
+
+
diff --git a/packages/ui/src/lib/components/Lazy.svelte b/packages/ui/src/lib/components/Lazy.svelte
new file mode 100644
index 0000000..b0f30c8
--- /dev/null
+++ b/packages/ui/src/lib/components/Lazy.svelte
@@ -0,0 +1,61 @@
+
+
+
+
+ {
+ if (entry.isIntersecting) {
+ show = true;
+ } else if (unmount) {
+ height = entry.boundingClientRect.height;
+ show = false;
+ }
+ onIntersecting?.(entry);
+ },
+ })}
+>
+ {#if show}
+ {@render children?.()}
+ {/if}
+
diff --git a/packages/ui/src/lib/components/ListItem.svelte b/packages/ui/src/lib/components/ListItem.svelte
new file mode 100644
index 0000000..2f2c950
--- /dev/null
+++ b/packages/ui/src/lib/components/ListItem.svelte
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+ {#if loading}
+
+
+
+ {/if}
+
+ {#if avatarSnippet}
+ {@render avatarSnippet()}
+ {:else if icon != null}
+ {#if avatar}
+
+
+
+ {:else}
+
+ {/if}
+ {/if}
+
+
+ {#if titleSnippet}
+ {@render titleSnippet()}
+ {:else if title != null}
+
{title}
+ {/if}
+
+ {#if subheadingSnippet}
+ {@render subheadingSnippet()}
+ {:else if subheading != null}
+
+ {subheading}
+
+ {/if}
+
+
+ {@render actions?.()}
+
diff --git a/packages/ui/src/lib/components/Maybe.svelte b/packages/ui/src/lib/components/Maybe.svelte
new file mode 100644
index 0000000..a4cde21
--- /dev/null
+++ b/packages/ui/src/lib/components/Maybe.svelte
@@ -0,0 +1,22 @@
+
+
+
+
+{#if self}
+ {@const Wrapper = self}
+
+ {@render children?.()}
+
+{:else}
+ {@render children?.()}
+{/if}
diff --git a/packages/ui/src/lib/components/Menu.svelte b/packages/ui/src/lib/components/Menu.svelte
new file mode 100644
index 0000000..658bdbf
--- /dev/null
+++ b/packages/ui/src/lib/components/Menu.svelte
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/lib/components/Menu.svelte.test.ts b/packages/ui/src/lib/components/Menu.svelte.test.ts
new file mode 100644
index 0000000..0c482d4
--- /dev/null
+++ b/packages/ui/src/lib/components/Menu.svelte.test.ts
@@ -0,0 +1,226 @@
+import { describe, expect, it, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+
+import PopoverHarness from './tests/PopoverHarness.svelte';
+import MenuHarness from './tests/MenuHarness.svelte';
+import MenuItemSettingsHarness from './tests/MenuItemSettingsHarness.svelte';
+
+/** Popover/Menu portal their content out of the container, so query the document */
+function popoverEl() {
+ return document.querySelector('.Popover') as HTMLElement | null;
+}
+function menuEl() {
+ return document.querySelector('.Menu') as HTMLElement | null;
+}
+function openState(container: HTMLElement) {
+ return container.querySelector('[data-testid="open-state"]')!.textContent;
+}
+
+/** A full mousedown/mouseup pair — the outside-click detection requires both on the same target */
+function clickOutside(target: EventTarget = document.body) {
+ target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
+ target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
+}
+
+describe('Popover', () => {
+ it('renders nothing while closed', async () => {
+ render(PopoverHarness, {});
+
+ expect(popoverEl()).toBeNull();
+ });
+
+ it('renders content when opened', async () => {
+ const { container } = render(PopoverHarness, { open: true });
+
+ await vi.waitFor(() => {
+ expect(popoverEl()).not.toBeNull();
+ });
+ expect(document.querySelector('[data-testid="popover-content"]')).not.toBeNull();
+ expect(openState(container)).toBe('true');
+ });
+
+ it('portals out of its container', async () => {
+ const { container } = render(PopoverHarness, { open: true });
+
+ await vi.waitFor(() => expect(popoverEl()).not.toBeNull());
+ expect(container.contains(popoverEl())).toBe(false);
+ });
+
+ it('positions itself against the anchor', async () => {
+ render(PopoverHarness, { open: true, placement: 'bottom-start' });
+
+ await vi.waitFor(() => {
+ const el = popoverEl()!;
+ expect(el.style.left).not.toBe('');
+ expect(el.style.top).not.toBe('');
+ });
+ });
+
+ it('closes on Escape and reports the reason', async () => {
+ const onClose = vi.fn();
+ const { container } = render(PopoverHarness, { open: true, onClose });
+
+ await vi.waitFor(() => expect(popoverEl()).not.toBeNull());
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+
+ await vi.waitFor(() => {
+ expect(popoverEl()).toBeNull();
+ expect(openState(container)).toBe('false');
+ });
+ expect(onClose).toHaveBeenCalledWith('escape');
+ });
+
+ it('closes on an outside click', async () => {
+ const onClose = vi.fn();
+ render(PopoverHarness, { open: true, onClose });
+
+ await vi.waitFor(() => expect(popoverEl()).not.toBeNull());
+
+ clickOutside();
+
+ await vi.waitFor(() => {
+ expect(onClose).toHaveBeenCalledWith('clickOutside');
+ expect(popoverEl()).toBeNull();
+ });
+ });
+
+ it('stays open when clicking inside', async () => {
+ const onClose = vi.fn();
+ render(PopoverHarness, { open: true, onClose });
+
+ await vi.waitFor(() => expect(popoverEl()).not.toBeNull());
+
+ const content = document.querySelector('[data-testid="popover-content"]')!;
+ clickOutside(content);
+
+ expect(onClose).not.toHaveBeenCalled();
+ expect(popoverEl()).not.toBeNull();
+ });
+
+ it('closes via the `close` callback given to children', async () => {
+ const { container } = render(PopoverHarness, { open: true });
+
+ await vi.waitFor(() => expect(popoverEl()).not.toBeNull());
+
+ (document.querySelector('[data-testid="close"]') as HTMLButtonElement).click();
+
+ await vi.waitFor(() => {
+ expect(openState(container)).toBe('false');
+ });
+ });
+
+ it('matches the anchor width when asked', async () => {
+ render(PopoverHarness, { open: true, matchWidth: true });
+
+ await vi.waitFor(() => {
+ expect(popoverEl()!.style.width).not.toBe('');
+ });
+ });
+});
+
+describe('Menu', () => {
+ it('renders its items when open', async () => {
+ render(MenuHarness, { open: true });
+
+ await vi.waitFor(() => expect(menuEl()).not.toBeNull());
+
+ const items = document.querySelectorAll('.MenuItem');
+ expect(items).toHaveLength(3);
+ expect([...items].map((i) => i.textContent?.trim())).toEqual(['One', 'Two', 'Three']);
+ });
+
+ it('closes when an item is clicked', async () => {
+ const onItemClick = vi.fn();
+ const onClose = vi.fn();
+ const { container } = render(MenuHarness, { open: true, onItemClick, onClose });
+
+ await vi.waitFor(() => expect(menuEl()).not.toBeNull());
+
+ (document.querySelectorAll('.MenuItem')[1] as HTMLButtonElement).click();
+
+ await vi.waitFor(() => {
+ expect(openState(container)).toBe('false');
+ });
+ expect(onItemClick).toHaveBeenCalledWith('Two');
+ expect(onClose).toHaveBeenCalledWith('item');
+ });
+
+ it('stays open on item click with `explicitClose`', async () => {
+ const { container } = render(MenuHarness, { open: true, explicitClose: true });
+
+ await vi.waitFor(() => expect(menuEl()).not.toBeNull());
+
+ (document.querySelectorAll('.MenuItem')[0] as HTMLButtonElement).click();
+
+ expect(openState(container)).toBe('true');
+ expect(menuEl()).not.toBeNull();
+ });
+
+ it('closes via the `close` callback given to children', async () => {
+ const { container } = render(MenuHarness, { open: true, explicitClose: true });
+
+ await vi.waitFor(() => expect(menuEl()).not.toBeNull());
+
+ (document.querySelector('[data-testid="explicit-close"]') as HTMLButtonElement).click();
+
+ await vi.waitFor(() => expect(openState(container)).toBe('false'));
+ });
+
+ it('moves focus into the menu by default', async () => {
+ render(MenuHarness, { open: true });
+
+ await vi.waitFor(() => {
+ expect(document.activeElement).toBe(document.querySelector('.menu-items'));
+ });
+ });
+
+ it('leaves focus alone when `moveFocus` is false', async () => {
+ render(MenuHarness, { open: true, moveFocus: false });
+
+ await vi.waitFor(() => expect(menuEl()).not.toBeNull());
+ expect(document.activeElement).not.toBe(document.querySelector('.menu-items'));
+ });
+
+ it('reopens after closing', async () => {
+ const { container } = render(MenuHarness, {});
+
+ const anchor = container.querySelector('[data-testid="anchor"]') as HTMLButtonElement;
+ anchor.click();
+ await vi.waitFor(() => expect(menuEl()).not.toBeNull());
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ await vi.waitFor(() => expect(menuEl()).toBeNull());
+
+ anchor.click();
+ await vi.waitFor(() => expect(menuEl()).not.toBeNull());
+ });
+});
+
+describe('MenuItem', () => {
+ it('marks the selected item', async () => {
+ render(MenuHarness, { open: true, selectedIndex: 1 });
+
+ await vi.waitFor(() => expect(menuEl()).not.toBeNull());
+
+ const items = document.querySelectorAll('.MenuItem');
+ expect(items[1].className).toContain('font-semibold');
+ expect(items[0].className).not.toContain('font-semibold');
+ });
+
+ it('opts its Button out of app-wide Button settings', async () => {
+ const { container } = render(MenuItemSettingsHarness, {
+ options: { components: { Button: { variant: 'fill', classes: 'app-button' } } },
+ });
+
+ const outside = container.querySelector('.Button:not(.MenuItem)')!;
+ const inside = container.querySelector('.MenuItem')!;
+
+ expect(outside.classList.contains('app-button')).toBe(true);
+ expect(outside.className).toContain('variant-fill');
+
+ // MenuItem sets its own `variant="none"` and clears the subtree's component settings
+ expect(inside.classList.contains('app-button')).toBe(false);
+ expect(inside.className).toContain('variant-none');
+ });
+});
diff --git a/packages/ui/src/lib/components/MenuButton.svelte b/packages/ui/src/lib/components/MenuButton.svelte
new file mode 100644
index 0000000..55bc53f
--- /dev/null
+++ b/packages/ui/src/lib/components/MenuButton.svelte
@@ -0,0 +1,125 @@
+
+
+
+
+ (open = !open)}
+ class={cls('MenuButton', settingsClasses.root, classes.root, className)}
+>
+ {#if selection}
+ {@render selection({ value: selected })}
+ {:else}
+
+ {selected?.label ?? 'No selection'}
+
+ {/if}
+
+ {#if resolvedMenuIcon}
+
+ {/if}
+
+
+ {#if children}
+ {@render children({ options, selected, close: () => (open = false), setValue })}
+ {:else}
+
+ {#each options as option (option.value)}
+ {
+ value = option.value;
+ onChange?.({ option, value });
+ }}
+ >
+ {option.label}
+
+ {/each}
+
+ {/if}
+
+
diff --git a/packages/ui/src/lib/components/MenuField.svelte b/packages/ui/src/lib/components/MenuField.svelte
new file mode 100644
index 0000000..e459a06
--- /dev/null
+++ b/packages/ui/src/lib/components/MenuField.svelte
@@ -0,0 +1,209 @@
+
+
+
+
+ (open = !open)}
+>
+ {#snippet children()}
+ {#if selection}
+ {@render selection({ selected: resolvedSelected })}
+ {:else}
+
+ {resolvedSelected?.label ?? 'No selection'}
+
+ {/if}
+ {/snippet}
+
+ {#snippet prepend()}
+
+ {#if stepper}
+ (value = previous())}
+ class="mr-2"
+ size="sm"
+ />
+ {/if}
+ {@render prependSnippet?.()}
+
+ {/snippet}
+
+ {#snippet append()}
+
+ {@render appendSnippet?.()}
+
+
+
+ {
+ e.stopPropagation();
+ open = !open;
+ }}
+ >
+
+
+
+ {#if stepper}
+ (value = next())} class="mr-2" size="sm" />
+ {/if}
+
+ {/snippet}
+
+ {#snippet root()}
+
+ {#if childrenSnippet}
+ {@render childrenSnippet({
+ options,
+ selected: resolvedSelected,
+ close: () => (open = false),
+ setValue,
+ })}
+ {:else}
+
+ {#each options as option, index (`${option.group}-${option.value}`)}
+ {@const previousOption = options[index - 1]}
+ {#if option.group && option.group !== previousOption?.group}
+
+ {option.group}
+
+ {/if}
+
+ (value = option.value)}
+ >
+ {option.label}
+
+ {/each}
+
+ {/if}
+
+ {/snippet}
+
diff --git a/packages/ui/src/lib/components/MenuItem.svelte b/packages/ui/src/lib/components/MenuItem.svelte
new file mode 100644
index 0000000..24dddf9
--- /dev/null
+++ b/packages/ui/src/lib/components/MenuItem.svelte
@@ -0,0 +1,79 @@
+
+
+
+
+
+ {@render children?.()}
+
diff --git a/packages/ui/src/lib/components/Month.svelte b/packages/ui/src/lib/components/Month.svelte
new file mode 100644
index 0000000..6ea98aa
--- /dev/null
+++ b/packages/ui/src/lib/components/Month.svelte
@@ -0,0 +1,159 @@
+
+
+
+
+{#if showMonthSelect}
+
+ {
+ startOfMonth = date;
+ showMonthSelect = false;
+ }}
+ />
+
+{:else}
+ {#if !hideControls}
+
+
(startOfMonth = intervalOffset(monthInterval, displayedMonth, -1))}
+ />
+
+
+ (showMonthSelect = true)}>
+ {settings.format(displayedMonth, PeriodType.MonthYear, { utc })}
+
+
+
+ (startOfMonth = intervalOffset(monthInterval, displayedMonth, 1))}
+ />
+
+ {/if}
+
+
+ {#each monthDaysByWeek[0] ?? [] as day (day.getDate())}
+
+
+ {settings.format(day, PeriodType.Day, { custom: 'eee', utc })}
+
+
+ {/each}
+
+
+
+ {#each monthDaysByWeek ?? [] as week, weekIndex (weekIndex)}
+ {#each week ?? [] as day (day.valueOf())}
+
+ {/each}
+ {/each}
+
+{/if}
diff --git a/packages/ui/src/lib/components/MonthList.svelte b/packages/ui/src/lib/components/MonthList.svelte
new file mode 100644
index 0000000..206ff57
--- /dev/null
+++ b/packages/ui/src/lib/components/MonthList.svelte
@@ -0,0 +1,79 @@
+
+
+
+
+{#each months ?? [] as month (month.valueOf())}
+
+{/each}
diff --git a/packages/ui/src/lib/components/MonthListByYear.svelte b/packages/ui/src/lib/components/MonthListByYear.svelte
new file mode 100644
index 0000000..4266e80
--- /dev/null
+++ b/packages/ui/src/lib/components/MonthListByYear.svelte
@@ -0,0 +1,63 @@
+
+
+
+
+
+
(minYear = resolvedMinYear - 10)}>More
+
+ {#each years ?? [] as year (year)}
+
+ {/each}
+
+
(maxYear = resolvedMaxYear + 10)}>More
+
diff --git a/packages/ui/src/lib/components/MultiSelect.svelte b/packages/ui/src/lib/components/MultiSelect.svelte
new file mode 100644
index 0000000..ce3a20a
--- /dev/null
+++ b/packages/ui/src/lib/components/MultiSelect.svelte
@@ -0,0 +1,401 @@
+
+
+
+
+{#if usingSearch}
+
+ searchText, (next) => (searchText = String(next ?? ''))}
+ autofocus={{ delay: 100, disabled: !autoFocusSearch }}
+ />
+
+{/if}
+
+
+ {@render beforeOptions?.({ selection })}
+
+
+
+ {#snippet children({ visibleItems })}
+ {#each visibleItems as option (option.value)}
+ {@const ctx = {
+ option,
+ label: option.label,
+ value: option.value,
+ checked: selection.isSelected(option.value),
+ indeterminate: indeterminate.current.has(option.value),
+ disabled: selection.isDisabled(option.value),
+ onChange: () => toggleOption(option.value),
+ }}
+
+ {#if optionSnippet}
+ {@render optionSnippet(ctx)}
+ {:else}
+
+ {ctx.label}
+
+ {/if}
+
+ {:else}
+ {#if maintainOrder && !filteredOptions.length}
+
+ There are no matching items.
+
+ {/if}
+ {/each}
+ {/snippet}
+
+
+ {#if !maintainOrder}
+ {#if filteredSelectedOptions.length && filteredUnselectedOptions.length}
+
+
+ {/if}
+
+
+ {#snippet children({ visibleItems })}
+ {#each visibleItems as option (option.value)}
+ {@const ctx = {
+ option,
+ label: option.label,
+ value: option.value,
+ checked: selection.isSelected(option.value),
+ indeterminate: indeterminate.current.has(option.value),
+ disabled: selection.isDisabled(option.value),
+ onChange: () => toggleOption(option.value),
+ }}
+
+ {#if optionSnippet}
+ {@render optionSnippet(ctx)}
+ {:else}
+
+ {ctx.label}
+
+ {/if}
+
+ {:else}
+ {#if !filteredSelectedOptions.length}
+
+ There are no matching items.
+
+ {/if}
+ {/each}
+ {/snippet}
+
+ {/if}
+
+ {@render afterOptions?.({ selection })}
+
+
+:first-child))]:hidden border-t border-surface-content/10 pt-2',
+ settingsClasses.actions,
+ classes.actions
+ )}
+>
+ {@render actions?.({ selection, searchText })}
+
+ {#if mode === 'actions'}
+
+ {
+ selection.current = appliedSelection;
+ onCancel?.();
+ }}
+ {...cancelButtonProps}
+ >
+ Cancel
+
+
+ applyChange()}
+ {...applyButtonProps}
+ >
+ Apply
+
+
+ {/if}
+
diff --git a/packages/ui/src/lib/components/MultiSelectField.svelte b/packages/ui/src/lib/components/MultiSelectField.svelte
new file mode 100644
index 0000000..fbfd9ef
--- /dev/null
+++ b/packages/ui/src/lib/components/MultiSelectField.svelte
@@ -0,0 +1,229 @@
+
+
+
+
+
+
+ show()}
+>
+
searchText, (next) => (searchText = String(next ?? ''))}
+ bind:inputEl
+ onfocus={() => show()}
+ onChange={({ inputValue }) => (searchText = String(inputValue ?? ''))}
+ classes={clsMerge(
+ { root: 'h-full' },
+ normalizeClasses(settingsClasses.field),
+ normalizeClasses(typeof classes.field === 'string' ? { root: classes.field } : classes.field)
+ )}
+ {...defaults}
+ {...restProps}
+ >
+ {#snippet prepend()}
+ {@render prependSnippet?.()}
+ {/snippet}
+
+ {#snippet append()}
+
+ {@render appendSnippet?.()}
+
+ {#if loading}
+
+
+
+ {:else if value?.length && clearable}
+ {
+ e.stopPropagation();
+ clear();
+ hide();
+ }}
+ />
+ {:else}
+ {
+ e.stopPropagation();
+ open ? hide() : show();
+ }}
+ />
+ {/if}
+
+ {/snippet}
+
+
+
{
+ value = ctx.value;
+ onChange?.({ value });
+ }}
+ onClose={hide}
+ {beforeOptions}
+ {afterOptions}
+ {actions}
+ option={optionSnippet}
+ {...menuProps}
+ />
+
diff --git a/packages/ui/src/lib/components/MultiSelectMenu.svelte b/packages/ui/src/lib/components/MultiSelectMenu.svelte
new file mode 100644
index 0000000..be8b757
--- /dev/null
+++ b/packages/ui/src/lib/components/MultiSelectMenu.svelte
@@ -0,0 +1,166 @@
+
+
+
+
+
+ {#snippet children({ close })}
+ {
+ if (mode !== 'immediate') close();
+ onCancel?.();
+ }}
+ onChange={(ctx) => {
+ if (mode !== 'immediate') close();
+ onChange?.(ctx);
+ }}
+ {beforeOptions}
+ {afterOptions}
+ {actions}
+ >
+ {#snippet option(ctx)}
+ {#if optionSnippet}
+ {@render optionSnippet(ctx)}
+ {:else}
+
+ {ctx.label}
+
+ {/if}
+ {/snippet}
+
+ {/snippet}
+
diff --git a/packages/ui/src/lib/components/MultiSelectOption.svelte b/packages/ui/src/lib/components/MultiSelectOption.svelte
new file mode 100644
index 0000000..04d404c
--- /dev/null
+++ b/packages/ui/src/lib/components/MultiSelectOption.svelte
@@ -0,0 +1,124 @@
+
+
+
+
+
+ {#if variant === 'checkbox'}
+
onChange?.()}
+ {disabled}
+ classes={{
+ root: 'px-2 rounded-sm hover:bg-surface-content/5',
+ label: 'py-2',
+ ...settingsClasses.checkbox,
+ ...classes.checkbox,
+ }}
+ >
+
+ {@render children?.()}
+
+
+ {:else if variant === 'checkmark'}
+
onChange?.()}
+ >
+ {@render children?.()}
+
+ {:else if variant === 'fill'}
+
onChange?.()}
+ >
+ {@render children?.()}
+
+ {/if}
+
+ {@render actions?.()}
+
diff --git a/packages/ui/src/lib/components/NavItem.svelte b/packages/ui/src/lib/components/NavItem.svelte
new file mode 100644
index 0000000..cacc90f
--- /dev/null
+++ b/packages/ui/src/lib/components/NavItem.svelte
@@ -0,0 +1,98 @@
+
+
+
+
+ {
+ // Close the drawer when it is temporary (narrow viewports)
+ if (!media.mdScreen.current) {
+ settings.showDrawer = false;
+ }
+ onclick?.(e);
+ }}
+ {@attach scrollIntoView({
+ condition: isPathActive,
+ onlyIfNeeded: true,
+ delay: 500,
+ })}
+>
+ {@render avatar?.()}
+
+ {#if icon}
+ {#if typeof icon === 'function' || typeof icon === 'string' || 'icon' in icon}
+
+ {:else}
+
+ {/if}
+ {/if}
+
+ {text}
+
+ {@render children?.()}
+
diff --git a/packages/ui/src/lib/components/Notification.svelte b/packages/ui/src/lib/components/Notification.svelte
new file mode 100644
index 0000000..6ccd8e9
--- /dev/null
+++ b/packages/ui/src/lib/components/Notification.svelte
@@ -0,0 +1,272 @@
+
+
+
+
+{#if open}
+
+
+ onClose?.()}
+ onclick={(e) => {
+ if (!(e.target instanceof Element)) return;
+ // Close when an action is clicked (but not the container). Opt out with `e.stopPropagation()`
+ if (e.target !== actionsEl && actionsEl?.contains(e.target)) {
+ open = false;
+ }
+ }}
+ >
+
+
+ {#if iconSnippet}
+ {@render iconSnippet()}
+ {:else if icon}
+
+ {/if}
+
+
+ {#if titleSnippet}
+
{@render titleSnippet()}
+ {:else if title}
+
{title}
+ {/if}
+
+ {#if descriptionSnippet || description}
+
+ {#if descriptionSnippet}
+ {@render descriptionSnippet()}
+ {:else}
+ {description}
+ {/if}
+
+ {/if}
+
+ {#if hasActions && actionsPlacement === 'below'}
+
+ {#if actionsSnippet}
+ {@render actionsSnippet()}
+ {:else}
+ {#each Object.entries(actions) as [name, fn], i (name)}
+ fn()}
+ >
+ {name}
+
+ {/each}
+ {/if}
+
+ {/if}
+
+
+ {#if hasActions && actionsPlacement === 'inline'}
+
+ {#if actionsSnippet}
+ {@render actionsSnippet()}
+ {:else}
+ {#each Object.entries(actions) as [name, fn], i (name)}
+ fn()}
+ >
+ {name}
+
+ {/each}
+ {/if}
+
+ {/if}
+
+ {#if closeIcon}
+
(open = false)}
+ class={cls(
+ 'self-start',
+ {
+ fill: {
+ primary: 'text-primary-content/25',
+ secondary: 'text-secondary-content/25',
+ accent: 'text-accent-content/25',
+ neutral: 'text-neutral-content/25',
+ info: 'text-info-content/25',
+ success: 'text-success-content/25',
+ warning: 'text-warning-content/25',
+ danger: 'text-danger-content/25',
+ }[color],
+ default: 'text-surface-content/25',
+ }[variant]
+ )}
+ />
+ {/if}
+
+
+ {#if hasActions && actionsPlacement === 'split'}
+
+ {#if actionsSnippet}
+ {@render actionsSnippet()}
+ {:else}
+ {#each Object.entries(actions) as [name, fn], i (name)}
+ fn()}
+ >
+ {name}
+
+ {/each}
+ {/if}
+
+ {/if}
+
+
+{/if}
diff --git a/packages/ui/src/lib/components/NumberStepper.svelte b/packages/ui/src/lib/components/NumberStepper.svelte
new file mode 100644
index 0000000..b246711
--- /dev/null
+++ b/packages/ui/src/lib/components/NumberStepper.svelte
@@ -0,0 +1,95 @@
+
+
+
+
+ value, (next) => (value = Number(next ?? 0))}
+ {min}
+ {max}
+ {step}
+ align="center"
+ class={cls('NumberStepper w-24', settingsClasses.root, className)}
+ {@attach selectOnFocus()}
+>
+ {#snippet prepend()}
+ (value = stepUtil(value, -step))}
+ size="sm"
+ disabled={min != null && value <= min}
+ />
+ {/snippet}
+
+ {#snippet append()}
+ (value = stepUtil(value, step))}
+ size="sm"
+ disabled={max != null && value >= max}
+ />
+ {/snippet}
+
+ {#snippet prefix()}
+ {@render prefixSnippet?.()}
+ {/snippet}
+
+ {#snippet suffix()}
+ {@render suffixSnippet?.()}
+ {/snippet}
+
diff --git a/packages/ui/src/lib/components/Overflow.svelte b/packages/ui/src/lib/components/Overflow.svelte
new file mode 100644
index 0000000..cb5a0e8
--- /dev/null
+++ b/packages/ui/src/lib/components/Overflow.svelte
@@ -0,0 +1,40 @@
+
+
+
+
+ {
+ overflowX = detail.overflowX;
+ overflowY = detail.overflowY;
+ },
+ })}
+>
+ {@render children?.({ overflowX, overflowY })}
+
diff --git a/packages/ui/src/lib/components/Overlay.svelte b/packages/ui/src/lib/components/Overlay.svelte
new file mode 100644
index 0000000..0fb6495
--- /dev/null
+++ b/packages/ui/src/lib/components/Overlay.svelte
@@ -0,0 +1,50 @@
+
+
+
+
+
+ {@render children?.()}
+
diff --git a/packages/ui/src/lib/components/Paginate.svelte b/packages/ui/src/lib/components/Paginate.svelte
new file mode 100644
index 0000000..a9b128e
--- /dev/null
+++ b/packages/ui/src/lib/components/Paginate.svelte
@@ -0,0 +1,29 @@
+
+
+
+
+{@render children?.({ pagination, pageData })}
diff --git a/packages/ui/src/lib/components/Pagination.svelte b/packages/ui/src/lib/components/Pagination.svelte
new file mode 100644
index 0000000..761040f
--- /dev/null
+++ b/packages/ui/src/lib/components/Pagination.svelte
@@ -0,0 +1,160 @@
+
+
+
+
+{#if pagination.totalPages > 1 || !hideSinglePage}
+
+ {#each show as component (component)}
+ {#if component === 'actions'}
+ {@render actions?.()}
+ {:else if component === 'firstPage'}
+
+
+
+ {:else if component === 'prevPage'}
+
+
+
+ {:else if component === 'nextPage'}
+
+
+
+ {:else if component === 'lastPage'}
+
+
+
+ {:else if component === 'perPage'}
+
+ Per page:
+
+ (perPageOpen = !perPageOpen)}>
+ {pagination.perPage}
+
+
+
+
+ {#each perPageOptions ?? [] as option (option)}
+ (pagination.perPage = option)}
+ >
+ {settings.format(option, 'integer')}
+
+ {/each}
+
+
+
+ {:else if component === 'pagination'}
+ {#if paginationSnippet}
+ {@render paginationSnippet({ pagination })}
+ {:else}
+
+ {format(pagination)}
+
+ {/if}
+ {/if}
+ {/each}
+
+{/if}
diff --git a/packages/ui/src/lib/components/Popover.svelte b/packages/ui/src/lib/components/Popover.svelte
new file mode 100644
index 0000000..90c5d2b
--- /dev/null
+++ b/packages/ui/src/lib/components/Popover.svelte
@@ -0,0 +1,97 @@
+
+
+
+
+ {
+ if (open && e.key === 'Escape') {
+ e.stopPropagation();
+ close('escape');
+ }
+ }}
+/>
+
+{#if open}
+ close('clickOutside'),
+ })}
+ >
+ {@render children?.({ close: () => close() })}
+
+{/if}
diff --git a/packages/ui/src/lib/components/Progress.svelte b/packages/ui/src/lib/components/Progress.svelte
new file mode 100644
index 0000000..5d0aaeb
--- /dev/null
+++ b/packages/ui/src/lib/components/Progress.svelte
@@ -0,0 +1,52 @@
+
+
+
+
+
diff --git a/packages/ui/src/lib/components/ProgressCircle.svelte b/packages/ui/src/lib/components/ProgressCircle.svelte
new file mode 100644
index 0000000..9a88305
--- /dev/null
+++ b/packages/ui/src/lib/components/ProgressCircle.svelte
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+ {#if track}
+
+ {/if}
+
+
+
+
+ {@render children?.()}
+
+
+
+
diff --git a/packages/ui/src/lib/components/ProgressCircle.svelte.test.ts b/packages/ui/src/lib/components/ProgressCircle.svelte.test.ts
new file mode 100644
index 0000000..15fac45
--- /dev/null
+++ b/packages/ui/src/lib/components/ProgressCircle.svelte.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+
+import ProgressCircle from './ProgressCircle.svelte';
+
+function rootOf(container: HTMLElement) {
+ return container.querySelector('.ProgressCircle') as HTMLElement;
+}
+
+describe('ProgressCircle', () => {
+ it('is indeterminate without a value', async () => {
+ const { container } = render(ProgressCircle, {});
+
+ const root = rootOf(container);
+ expect(root.classList.contains('indeterminate')).toBe(true);
+ expect(root.getAttribute('aria-valuenow')).toBeNull();
+ });
+
+ it('exposes progress to assistive technology', async () => {
+ const { container } = render(ProgressCircle, { value: 42 });
+
+ const root = rootOf(container);
+ expect(root.classList.contains('indeterminate')).toBe(false);
+ expect(root.getAttribute('role')).toBe('progressbar');
+ expect(root.getAttribute('aria-valuenow')).toBe('42');
+ expect(root.getAttribute('aria-valuemin')).toBe('0');
+ expect(root.getAttribute('aria-valuemax')).toBe('100');
+ });
+
+ it('sizes the container', async () => {
+ const { container } = render(ProgressCircle, { size: 64 });
+
+ const root = rootOf(container);
+ expect(root.style.width).toBe('64px');
+ expect(root.style.height).toBe('64px');
+ });
+
+ it('renders the track only when requested', async () => {
+ const without = render(ProgressCircle, { value: 50 });
+ expect(without.container.querySelector('circle.track')).toBeNull();
+
+ const withTrack = render(ProgressCircle, { value: 50, track: true });
+ expect(withTrack.container.querySelector('circle.track')).not.toBeNull();
+ });
+
+ it('offsets the stroke in proportion to the value', async () => {
+ const full = render(ProgressCircle, { value: 100 });
+ const empty = render(ProgressCircle, { value: 0 });
+
+ const offsetOf = (c: HTMLElement) =>
+ c.querySelector('circle.path')!.getAttribute('stroke-dashoffset');
+
+ expect(offsetOf(full.container)).toBe('0px');
+ // an empty circle is offset by the full circumference (2 * PI * 20)
+ expect(parseFloat(offsetOf(empty.container)!)).toBeCloseTo(2 * Math.PI * 20, 3);
+ });
+
+ it('rotates determinate circles to start at the top', async () => {
+ const determinate = render(ProgressCircle, { value: 50 });
+ const indeterminate = render(ProgressCircle, {});
+
+ expect(determinate.container.querySelector('svg')!.style.transform).toContain('-90deg');
+ expect(indeterminate.container.querySelector('svg')!.style.transform).toContain('0deg');
+ });
+
+ it('merges a custom class', async () => {
+ const { container } = render(ProgressCircle, { class: 'text-primary' });
+
+ expect(rootOf(container).classList.contains('text-primary')).toBe(true);
+ });
+});
diff --git a/packages/ui/src/lib/components/QuickSearch.svelte b/packages/ui/src/lib/components/QuickSearch.svelte
new file mode 100644
index 0000000..6ec1192
--- /dev/null
+++ b/packages/ui/src/lib/components/QuickSearch.svelte
@@ -0,0 +1,110 @@
+
+
+
+
+ {
+ if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ open = !open;
+ } else if (open && e.key === 'Escape') {
+ e.preventDefault();
+ open = false;
+ }
+ }}
+/>
+
+ (open = true)}
+ class={cls(
+ 'QuickSearch',
+ 'sm:bg-black/10 sm:hover:bg-black/20 rounded-full sm:w-56 justify-start',
+ settingsClasses.button,
+ classes.button
+ )}
+>
+ Search
+ K
+
+
+
+ {
+ onChange?.(detail);
+ open = false;
+ }}
+ classes={{
+ root: 'w-[420px] max-w-[95vw] py-1',
+ field: {
+ container: 'border-none hover:shadow-none group-focus-within:shadow-none',
+ },
+ options: 'overflow-auto max-h-[min(90dvh,380px)]',
+ group: 'capitalize',
+ }}
+ {@attach (node) => {
+ // Focus and select the search input as the dialog opens
+ const input = (node as HTMLElement).querySelector('input');
+ if (!input) return;
+ const cleanupFocus = autoFocus({ delay: 100 })(input);
+ const cleanupSelect = selectOnFocus()(input);
+ return () => {
+ cleanupFocus?.();
+ cleanupSelect?.();
+ };
+ }}
+ {...restProps}
+ />
+
diff --git a/packages/ui/src/lib/components/Radio.svelte b/packages/ui/src/lib/components/Radio.svelte
new file mode 100644
index 0000000..0740350
--- /dev/null
+++ b/packages/ui/src/lib/components/Radio.svelte
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+
+
+
+ {#if children}
+
+ {@render children()}
+
+ {/if}
+
diff --git a/packages/ui/src/lib/components/RangeField.svelte b/packages/ui/src/lib/components/RangeField.svelte
new file mode 100644
index 0000000..67b1e1d
--- /dev/null
+++ b/packages/ui/src/lib/components/RangeField.svelte
@@ -0,0 +1,79 @@
+
+
+
+
+
+ {#snippet prepend()}
+ (value -= value > min ? step : 0)}
+ class="mr-2"
+ size="sm"
+ />
+ {/snippet}
+
+ {#snippet children({ id })}
+
+
+
+
+ {settings.format(min, format)}
+ {settings.format(value, format)}
+ {settings.format(max, format)}
+
+ {/snippet}
+
+ {#snippet append()}
+ (value += value < max ? step : 0)}
+ class="ml-2"
+ size="sm"
+ />
+ {/snippet}
+
diff --git a/packages/ui/src/lib/components/RangeSlider.svelte b/packages/ui/src/lib/components/RangeSlider.svelte
new file mode 100644
index 0000000..4a0c80f
--- /dev/null
+++ b/packages/ui/src/lib/components/RangeSlider.svelte
@@ -0,0 +1,284 @@
+
+
+
+
+
+
+ {
+ const target = e.target as HTMLDivElement;
+ // Focus for keyboard input
+ target.focus();
+
+ if (ignoreClickEvents) return;
+
+ let sliderRect: DOMRect;
+ if (target.classList.contains('RangeSlider')) {
+ sliderRect = target.getBoundingClientRect();
+ } else if (target.classList.contains('range')) {
+ sliderRect = target.parentElement!.getBoundingClientRect();
+ } else {
+ // Ignore clicks on thumbs and value bubbles
+ return;
+ }
+
+ const deltaPercent = (e.clientX - sliderRect.x) / sliderRect.width;
+ const newValue = min + (max - min) * deltaPercent;
+
+ // Move whichever end is closest to the clicked point
+ if (Math.abs(value[0] - newValue) < Math.abs(value[1] - newValue)) {
+ value = [round(newValue, stepDecimals), value[1]];
+ lastMoved = 'start';
+ } else {
+ value = [value[0], round(newValue, stepDecimals)];
+ lastMoved = 'end';
+ }
+ }}
+ onkeydown={(e) => {
+ if (e.key === 'ArrowLeft') {
+ applyMove(lastMoved, -step);
+ } else if (e.key === 'ArrowRight') {
+ applyMove(lastMoved, step);
+ }
+ }}
+>
+
+
onMouseEnter('range')}
+ onmouseleave={onMouseLeave}
+ style="
+ left: calc(var(--start) * 100%);
+ right: calc((1 - var(--end)) * 100%);
+ "
+ class="range absolute top-0 bottom-0 bg-primary"
+ >
+
+
+
onMouseEnter('range')}
+ onmouseleave={onMouseLeave}
+ ondblclick={() => (value = [min, max])}
+ style="left: calc((((var(--end) - var(--start)) / 2 ) + var(--start)) * 100%);"
+ class={cls(
+ 'range-thumb',
+ 'absolute top-1/2 w-8 h-4 -translate-x-1/2 -translate-y-1/2',
+ 'rounded-full',
+ 'flex items-center justify-center',
+ showStartValue || showEndValue ? 'opacity-100' : 'opacity-0',
+ 'transition-opacity'
+ )}
+ {@attach movable({ axis: 'x', stepPercent, ...moveHandlers('range') })}
+ >
+
+
+
+
+
onMouseEnter('start')}
+ onmouseleave={onMouseLeave}
+ ondblclick={() => (value = [min, value[1]])}
+ style="left: calc(var(--start) * 100%);"
+ class={cls(
+ 'thumb',
+ 'absolute top-1/2 w-4 h-4 -translate-x-1/2 -translate-y-1/2',
+ 'border bg-white rounded-full outline-4',
+ 'hover:outline hover:outline-primary/20',
+ (lastMoved === 'start' || lastMoved === 'range') &&
+ 'group-focus:outline group-focus:outline-primary/40'
+ )}
+ {@attach movable({ axis: 'x', stepPercent, ...moveHandlers('start') })}
+ >
+
+
+
onMouseEnter('end')}
+ onmouseleave={onMouseLeave}
+ ondblclick={() => (value = [value[0], max])}
+ style="left: calc(var(--end) * 100%);"
+ class={cls(
+ 'thumb',
+ 'absolute top-1/2 w-4 h-4 -translate-x-1/2 -translate-y-1/2',
+ 'border bg-white rounded-full outline-4',
+ 'outline-primary/20',
+ 'hover:outline hover:outline-primary/20',
+ (lastMoved === 'end' || lastMoved === 'range') &&
+ 'group-focus:outline group-focus:outline-primary/40'
+ )}
+ {@attach movable({ axis: 'x', stepPercent, ...moveHandlers('end') })}
+ >
+
+ {#if showStartValue && !disableTooltips}
+
+ {value[0]}
+
+ {/if}
+
+ {#if showEndValue && !disableTooltips}
+
+ {value[1]}
+
+ {/if}
+
diff --git a/packages/ui/src/lib/components/ResponsiveMenu.svelte b/packages/ui/src/lib/components/ResponsiveMenu.svelte
new file mode 100644
index 0000000..2788008
--- /dev/null
+++ b/packages/ui/src/lib/components/ResponsiveMenu.svelte
@@ -0,0 +1,64 @@
+
+
+
+
+{#if isLargeScreen.current}
+
+ onClose?.()}
+ {...menuProps}
+ class={cls('ResponsiveMenu', className, menuProps?.class)}
+ >
+ {@render children?.({ open })}
+
+{:else}
+
+{/if}
diff --git a/packages/ui/src/lib/components/ScrollContainer.svelte b/packages/ui/src/lib/components/ScrollContainer.svelte
new file mode 100644
index 0000000..a37df1a
--- /dev/null
+++ b/packages/ui/src/lib/components/ScrollContainer.svelte
@@ -0,0 +1,27 @@
+
+
+
+
+
+ {@render children?.({ scrollIntoView })}
+
diff --git a/packages/ui/src/lib/components/ScrollingValue.svelte b/packages/ui/src/lib/components/ScrollingValue.svelte
new file mode 100644
index 0000000..0798b01
--- /dev/null
+++ b/packages/ui/src/lib/components/ScrollingValue.svelte
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+ {#if children}
+ {@render children({ value: nextDisplayValue })}
+ {:else}
+ {format(nextDisplayValue)}
+ {/if}
+
+
+ {#if children}
+ {@render children({ value: currentDisplayValue })}
+ {:else}
+ {format(currentDisplayValue)}
+ {/if}
+
+
diff --git a/packages/ui/src/lib/components/SectionDivider.svelte b/packages/ui/src/lib/components/SectionDivider.svelte
new file mode 100644
index 0000000..cbb02eb
--- /dev/null
+++ b/packages/ui/src/lib/components/SectionDivider.svelte
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+ {@render children?.()}
+
+
+
diff --git a/packages/ui/src/lib/components/SelectField.svelte b/packages/ui/src/lib/components/SelectField.svelte
new file mode 100644
index 0000000..afce773
--- /dev/null
+++ b/packages/ui/src/lib/components/SelectField.svelte
@@ -0,0 +1,660 @@
+
+
+
+
+{#snippet listOptions()}
+
+ {
+ e.stopPropagation();
+ if (!(e.target instanceof HTMLElement) || !menuOptionsEl) return;
+
+ // Find the option wrapper the click landed in, falling back to the target itself.
+ // `.options > *` handles a snippet that renders nested markup.
+ const optionEl = e.target.closest('.options > *') ?? e.target;
+
+ // Index among the options, ignoring group headers
+ const optionIndex = [...menuOptionsEl.children]
+ .filter((el) => !el.classList.contains('group-header'))
+ .indexOf(optionEl);
+
+ if (optionIndex !== -1) {
+ selectIndex(optionIndex);
+ }
+ }}
+ onkeydown={onKeyDown}
+ onkeypress={onKeyPress}
+ >
+ {#each filteredOptions ?? [] as option, index (JSON.stringify(option))}
+ {@const previousOption = filteredOptions[index - 1]}
+ {#if option.group && option.group !== previousOption?.group}
+
+ {option.group}
+
+ {/if}
+
+ {#if optionSnippet}
+ {@render optionSnippet({ option, index, selected, value, highlightIndex })}
+ {:else}
+
&]:bg-surface-content/5',
+ isSameOption(selected, option) && (classes.selected || 'font-semibold'),
+ option.group ? 'px-4' : 'px-2',
+ settingsClasses.option,
+ classes.option
+ )}
+ scrollIntoView={{
+ condition: index === highlightIndex,
+ onlyIfNeeded: inlineOptions,
+ ...scrollIntoView,
+ }}
+ role="option"
+ aria-selected={isSameOption(selected, option) ? 'true' : 'false'}
+ disabled={option.disabled}
+ >
+ {option.label}
+
+ {/if}
+ {:else}
+ {#if emptySnippet}
+ {@render emptySnippet({ loading: loading ?? false })}
+ {:else}
+
+ {loading ? 'Loading...' : 'No options found'}
+
+ {/if}
+ {/each}
+
+{/snippet}
+
+
+
+ show()}
+ tabindex="-1"
+>
+
searchText, (next) => (searchText = String(next ?? ''))}
+ onChange={({ inputValue }) => {
+ searchText = String(inputValue ?? '');
+ onInputChange?.(searchText);
+ show();
+ }}
+ onfocus={() => show()}
+ onblur={onBlur}
+ onkeydown={onKeyDown}
+ onkeypress={onKeyPress}
+ classes={clsMerge(
+ {
+ root: 'h-full',
+ container: inlineOptions
+ ? 'border-none shadow-none hover:shadow-none group-focus-within:shadow-none'
+ : undefined,
+ },
+ normalizeClasses(settingsClasses.field),
+ normalizeClasses(fieldClasses)
+ )}
+ role="combobox"
+ aria-expanded={open ? 'true' : 'false'}
+ aria-autocomplete={!inlineOptions ? 'list' : undefined}
+ {@attach focusAttachment}
+ {...restProps}
+ >
+ {#snippet prepend()}
+
+
+
+ {#if stepper}
+ {
+ e.stopPropagation();
+ selectValue(previousValue());
+ }}
+ class="mr-2"
+ size="sm"
+ />
+ {/if}
+ {@render prependSnippet?.()}
+
+ {/snippet}
+
+ {#snippet append()}
+
+ {@render appendSnippet?.()}
+
+ {#if loading}
+
+
+
+ {:else if readonly}
+
+ {:else if value && clearable}
+ {
+ e.stopPropagation();
+ clear();
+ }}
+ />
+ {:else if resolvedToggleIcon}
+ {
+ e.stopPropagation();
+ open ? hide() : show();
+ }}
+ />
+ {/if}
+
+ {#if stepper}
+ {
+ e.stopPropagation();
+ selectValue(nextValue());
+ }}
+ class="mr-2"
+ size="sm"
+ />
+ {/if}
+
+ {/snippet}
+
+
+
+ {#if options?.length > 0 || loading !== true}
+ {#if inlineOptions}
+ {@render listOptions()}
+ {:else}
+
hide()}
+ {...menuProps}
+ >
+ {@render beforeOptions?.({ hide })}
+ {@render listOptions()}
+ {@render afterOptions?.({ hide })}
+ {@render actions?.({ hide })}
+
+ {/if}
+ {/if}
+
diff --git a/packages/ui/src/lib/components/Selection.svelte b/packages/ui/src/lib/components/Selection.svelte
new file mode 100644
index 0000000..9beab54
--- /dev/null
+++ b/packages/ui/src/lib/components/Selection.svelte
@@ -0,0 +1,69 @@
+
+
+
+
+{@render children?.({
+ get selected() {
+ return selected;
+ },
+ isSelected: (value: T) => selection.isSelected(value),
+ isDisabled: (value: T) => selection.isDisabled(value),
+ isAllSelected: () => selection.isAllSelected(),
+ isAnySelected: () => selection.isAnySelected(),
+ toggleSelected: (value: T) => selection.toggle(value),
+ toggleAll: () => selection.toggleAll(),
+ clear: () => selection.clear(),
+ state: selection,
+})}
diff --git a/packages/ui/src/lib/components/Settings.svelte b/packages/ui/src/lib/components/Settings.svelte
new file mode 100644
index 0000000..bddd554
--- /dev/null
+++ b/packages/ui/src/lib/components/Settings.svelte
@@ -0,0 +1,28 @@
+
+
+
+
+{#if themeInit}
+
+{/if}
+
+{@render children?.()}
diff --git a/packages/ui/src/lib/components/Shine.svelte b/packages/ui/src/lib/components/Shine.svelte
new file mode 100644
index 0000000..fe40765
--- /dev/null
+++ b/packages/ui/src/lib/components/Shine.svelte
@@ -0,0 +1,103 @@
+
+
+
+
+ {
+ wrapperBox = wrapperEl?.getBoundingClientRect() ?? { left: 0, top: 0 };
+ mouse = { x: e.clientX, y: e.clientY };
+ }}
+ onscroll={() => {
+ wrapperBox = wrapperEl?.getBoundingClientRect() ?? { left: 0, top: 0 };
+ }}
+/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@render children?.()}
+
diff --git a/packages/ui/src/lib/components/SpringValue.svelte b/packages/ui/src/lib/components/SpringValue.svelte
new file mode 100644
index 0000000..da835b6
--- /dev/null
+++ b/packages/ui/src/lib/components/SpringValue.svelte
@@ -0,0 +1,39 @@
+
+
+
+
+{#if children}
+ {@render children({ value: displayValue })}
+{:else}
+ {settings.format(displayValue, format)}
+{/if}
diff --git a/packages/ui/src/lib/components/Stack.svelte b/packages/ui/src/lib/components/Stack.svelte
new file mode 100644
index 0000000..7ba9228
--- /dev/null
+++ b/packages/ui/src/lib/components/Stack.svelte
@@ -0,0 +1,44 @@
+
+
+
+
+{#if vertical}
+
+ {@render children?.()}
+
+{:else if horizontal}
+
+ {@render children?.()}
+
+{:else if stack}
+
+ {@render children?.()}
+
+{/if}
diff --git a/packages/ui/src/lib/components/Step.svelte b/packages/ui/src/lib/components/Step.svelte
new file mode 100644
index 0000000..40eebf8
--- /dev/null
+++ b/packages/ui/src/lib/components/Step.svelte
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+ {@render children?.()}
+
+
+
+ {#if pointSnippet}
+ {@render pointSnippet()}
+ {:else if icon}
+
+ {:else}
+ {point ?? ''}
+ {/if}
+
+
diff --git a/packages/ui/src/lib/components/Steps.svelte b/packages/ui/src/lib/components/Steps.svelte
new file mode 100644
index 0000000..f1b8cd4
--- /dev/null
+++ b/packages/ui/src/lib/components/Steps.svelte
@@ -0,0 +1,90 @@
+
+
+
+
+
+ {#if children}
+ {@render children({ data })}
+ {:else}
+ {#each data as item (item.label)}
+
+ {item.label}
+
+ {/each}
+ {/if}
+
diff --git a/packages/ui/src/lib/components/Switch.svelte b/packages/ui/src/lib/components/Switch.svelte
new file mode 100644
index 0000000..8cb92c6
--- /dev/null
+++ b/packages/ui/src/lib/components/Switch.svelte
@@ -0,0 +1,125 @@
+
+
+
+
+
+
{
+ checked = e.currentTarget.checked;
+ onchange?.(e);
+ }}
+ />
+
+
+
+ {@render children?.({ checked, value })}
+
+
+
diff --git a/packages/ui/src/lib/components/Tab.svelte b/packages/ui/src/lib/components/Tab.svelte
new file mode 100644
index 0000000..1a630a6
--- /dev/null
+++ b/packages/ui/src/lib/components/Tab.svelte
@@ -0,0 +1,61 @@
+
+
+
+
+
+ {@render children?.()}
+
diff --git a/packages/ui/src/lib/components/Table.ssr.test.ts b/packages/ui/src/lib/components/Table.ssr.test.ts
new file mode 100644
index 0000000..0e6a460
--- /dev/null
+++ b/packages/ui/src/lib/components/Table.ssr.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest';
+import { render } from 'svelte/server';
+
+import Table from './Table.svelte';
+
+const columns = [
+ { name: 'name', header: 'Name' },
+ { name: 'value', header: 'Value' },
+];
+const data = [
+ { name: 'Alice', value: 1 },
+ { name: 'Bob', value: 2 },
+];
+
+describe('Table (SSR)', () => {
+ it('renders headers and rows', () => {
+ const { body } = render(Table, { props: { columns, data } });
+
+ expect(body).toContain('class="Table');
+ expect(body).toContain(' {
+ const { body } = render(Table, { props: { columns, data: null } });
+
+ expect(body).toContain(' {
+ const { body } = render(Table, {
+ props: { columns: [{ name: 'value', format: (v: number) => `#${v}` }], data },
+ });
+
+ expect(body).toContain('#1');
+ });
+});
diff --git a/packages/ui/src/lib/components/Table.svelte b/packages/ui/src/lib/components/Table.svelte
new file mode 100644
index 0000000..a2a34b0
--- /dev/null
+++ b/packages/ui/src/lib/components/Table.svelte
@@ -0,0 +1,188 @@
+
+
+
+
+
+
+
+ {#if headersSnippet}
+ {@render headersSnippet({ headers, getCellHeader: getCellHeaderUtil })}
+ {:else}
+
+ {#each headers ?? [] as headerRow, rowIndex (rowIndex)}
+
+ {#each headerRow ?? [] as column (column.name)}
+
+ onHeaderClick?.({ column })}
+ {@attach fromAction(tableCell, () => ({ column, order }))}
+ >
+ {getCellHeaderUtil(column)}
+ {#if order}
+
+ {/if}
+
+ {/each}
+
+ {/each}
+
+ {/if}
+
+ {@render children?.()}
+
+ {#if body}
+ {@render body({
+ data,
+ columns: rowColumns,
+ getCellValue: getCellValueUtil,
+ getCellContent,
+ })}
+ {:else}
+
+ {#each data ?? [] as rowData, rowIndex (rowIndex)}
+
+ {#each rowColumns ?? [] as column (column.name)}
+
+ onCellClick?.({ column, rowData, rowIndex })}
+ {@attach fromAction(tableCell, () => ({
+ column,
+ rowData,
+ rowIndex,
+ tableData: data,
+ }))}
+ >
+ {#if column.html}
+
+ {@html getCellContent(column, rowData, rowIndex)}
+ {:else}
+ {getCellContent(column, rowData, rowIndex)}
+ {/if}
+
+ {/each}
+
+ {/each}
+
+ {/if}
+
+
+
diff --git a/packages/ui/src/lib/components/Table.svelte.test.ts b/packages/ui/src/lib/components/Table.svelte.test.ts
new file mode 100644
index 0000000..d29e2ab
--- /dev/null
+++ b/packages/ui/src/lib/components/Table.svelte.test.ts
@@ -0,0 +1,183 @@
+import { describe, expect, it, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+
+import Table from './Table.svelte';
+import TableOrderHarness from './tests/TableOrderHarness.svelte';
+
+type Row = { name: string; value: number };
+
+const columns = [
+ { name: 'name', header: 'Name' },
+ { name: 'value', header: 'Value' },
+];
+
+const data: Row[] = [
+ { name: 'Charlie', value: 3 },
+ { name: 'Alice', value: 1 },
+ { name: 'Bob', value: 2 },
+];
+
+const cellText = (container: HTMLElement, selector: string) =>
+ [...container.querySelectorAll(selector)].map((el) => el.textContent?.trim());
+
+describe('Table', () => {
+ it('renders headers and rows', async () => {
+ const { container } = render(Table, { columns, data });
+
+ expect(cellText(container, 'thead th')).toEqual(['Name', 'Value']);
+ expect(container.querySelectorAll('tbody tr')).toHaveLength(3);
+ expect(cellText(container, 'tbody tr:first-child td')).toEqual(['Charlie', '3']);
+ });
+
+ it('renders an empty body for null data', async () => {
+ const { container } = render(Table, { columns, data: null });
+
+ expect(container.querySelectorAll('tbody tr')).toHaveLength(0);
+ expect(container.querySelectorAll('thead th')).toHaveLength(2);
+ });
+
+ it('adds a per-column class to cells', async () => {
+ const { container } = render(Table, { columns, data });
+
+ expect(container.querySelector('thead th')!.classList.contains('column-name')).toBe(true);
+ expect(container.querySelector('tbody td')!.classList.contains('column-name')).toBe(true);
+ });
+
+ it('derives a header from the column name when none is given', async () => {
+ const { container } = render(Table, { columns: [{ name: 'firstName' }], data: [] });
+
+ // `getCellHeader` splits camelCase but does not capitalize
+ expect(container.querySelector('thead th')!.textContent?.trim()).toBe('first Name');
+ });
+
+ describe('formatting', () => {
+ it('applies a `format` function', async () => {
+ const { container } = render(Table, {
+ columns: [{ name: 'value', format: (v: number) => `#${v}` }],
+ data,
+ });
+
+ expect(cellText(container, 'tbody td')).toEqual(['#3', '#1', '#2']);
+ });
+
+ it('resolves a nested `value` accessor', async () => {
+ const { container } = render(Table, {
+ columns: [{ name: 'total', value: (row: unknown) => (row as Row).value * 10 }],
+ data,
+ });
+
+ expect(cellText(container, 'tbody td')).toEqual(['30', '10', '20']);
+ });
+
+ it('renders html when the column opts in', async () => {
+ const { container } = render(Table, {
+ columns: [{ name: 'name', html: true, format: (v: string) => `${v} ` }],
+ data: [data[0]],
+ });
+
+ expect(container.querySelector('tbody td em')).not.toBeNull();
+ });
+ });
+
+ describe('callbacks', () => {
+ it('calls `onHeaderClick` with the column', async () => {
+ const onHeaderClick = vi.fn();
+ const { container } = render(Table, { columns, data, onHeaderClick });
+
+ (container.querySelectorAll('thead th')[1] as HTMLElement).click();
+
+ expect(onHeaderClick).toHaveBeenCalledWith(
+ expect.objectContaining({ column: expect.objectContaining({ name: 'value' }) })
+ );
+ });
+
+ it('calls `onCellClick` with the column, row, and index', async () => {
+ const onCellClick = vi.fn();
+ const { container } = render(Table, { columns, data, onCellClick });
+
+ (container.querySelectorAll('tbody tr')[1].querySelector('td') as HTMLElement).click();
+
+ expect(onCellClick).toHaveBeenCalledWith(
+ expect.objectContaining({
+ column: expect.objectContaining({ name: 'name' }),
+ rowData: data[1],
+ rowIndex: 1,
+ })
+ );
+ });
+ });
+
+ describe('ordering', () => {
+ it('sorts when a header is clicked and shows the indicator', async () => {
+ const { container } = render(TableOrderHarness, { columns, data });
+
+ expect(container.querySelector('.TableOrderIcon')).toBeNull();
+
+ (container.querySelector('thead th') as HTMLElement).click();
+
+ await vi.waitFor(() => {
+ expect(cellText(container, 'tbody tr td:first-child')).toEqual(['Alice', 'Bob', 'Charlie']);
+ });
+ expect(container.querySelector('.TableOrderIcon')).not.toBeNull();
+ expect(container.querySelector('[data-testid="order-state"]')!.textContent).toBe('name:asc');
+ });
+
+ it('reverses direction on a second click', async () => {
+ const { container } = render(TableOrderHarness, { columns, data });
+
+ const header = container.querySelector('thead th') as HTMLElement;
+ header.click();
+ await vi.waitFor(() =>
+ expect(container.querySelector('[data-testid="order-state"]')!.textContent).toBe('name:asc')
+ );
+
+ header.click();
+ await vi.waitFor(() => {
+ expect(container.querySelector('[data-testid="order-state"]')!.textContent).toBe(
+ 'name:desc'
+ );
+ expect(cellText(container, 'tbody tr td:first-child')).toEqual(['Charlie', 'Bob', 'Alice']);
+ });
+ });
+ });
+
+ describe('classes and styles', () => {
+ it('applies `classes` per part', async () => {
+ const { container } = render(Table, {
+ columns,
+ data,
+ classes: {
+ container: 'container-c',
+ wrapper: 'wrapper-c',
+ table: 'table-c',
+ thead: 'thead-c',
+ tbody: 'tbody-c',
+ tr: 'tr-c',
+ th: 'th-c',
+ td: 'td-c',
+ },
+ });
+
+ expect(container.querySelector('.Table')!.classList.contains('container-c')).toBe(true);
+ expect(container.querySelector('.table-wrapper')!.classList.contains('wrapper-c')).toBe(true);
+ expect(container.querySelector('table')!.classList.contains('table-c')).toBe(true);
+ expect(container.querySelector('thead')!.classList.contains('thead-c')).toBe(true);
+ expect(container.querySelector('tbody')!.classList.contains('tbody-c')).toBe(true);
+ expect(container.querySelector('thead tr')!.classList.contains('tr-c')).toBe(true);
+ // `th`/`td` classes are applied by the `tableCell` attachment
+ expect(container.querySelector('thead th')!.classList.contains('th-c')).toBe(true);
+ expect(container.querySelector('tbody td')!.classList.contains('td-c')).toBe(true);
+ });
+
+ it('applies `styles` per part', async () => {
+ const { container } = render(Table, {
+ columns,
+ data,
+ styles: { container: 'color: red;', table: 'color: blue;' },
+ });
+
+ expect((container.querySelector('.Table') as HTMLElement).style.color).toBe('red');
+ expect((container.querySelector('table') as HTMLElement).style.color).toBe('blue');
+ });
+ });
+});
diff --git a/packages/ui/src/lib/components/TableOfContents.svelte b/packages/ui/src/lib/components/TableOfContents.svelte
new file mode 100644
index 0000000..6f8d82b
--- /dev/null
+++ b/packages/ui/src/lib/components/TableOfContents.svelte
@@ -0,0 +1,145 @@
+
+
+
+
+
+ {#snippet children({ node })}
+ {@const resolvedProps = typeof props.a === 'function' ? props.a(node) : props.a}
+ {@const resolvedClass = typeof classes.a === 'function' ? classes.a(node) : classes.a}
+
+ {#if itemSnippet}
+ {@render itemSnippet({ node, activeHeadingId })}
+ {:else}
+ onNodeClick?.(node)}
+ >
+ {#if link}
+ {@render link({ node, activeHeadingId })}
+ {:else}
+
+ {@html node.name}
+ {/if}
+
+ {/if}
+ {/snippet}
+
diff --git a/packages/ui/src/lib/components/TableOrderIcon.svelte b/packages/ui/src/lib/components/TableOrderIcon.svelte
new file mode 100644
index 0000000..6fe500b
--- /dev/null
+++ b/packages/ui/src/lib/components/TableOrderIcon.svelte
@@ -0,0 +1,32 @@
+
+
+
+
+{#if $order.by && ($order.by === column.value || $order.by === column.name || $order.by === column.orderBy)}
+
+
+
+{/if}
diff --git a/packages/ui/src/lib/components/Tabs.svelte b/packages/ui/src/lib/components/Tabs.svelte
new file mode 100644
index 0000000..fc4322e
--- /dev/null
+++ b/packages/ui/src/lib/components/Tabs.svelte
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+ {#if children}
+ {@render children()}
+ {:else}
+ {#each options as tab (tab.value)}
+ (value = tab.value)}
+ classes={{ ...settingsClasses.tab, ...classes.tab }}
+ >
+ {tab.label}
+
+ {/each}
+ {/if}
+
+
+
+ {@render content?.({ value })}
+
+
diff --git a/packages/ui/src/lib/components/TextField.ssr.test.ts b/packages/ui/src/lib/components/TextField.ssr.test.ts
new file mode 100644
index 0000000..b153aa1
--- /dev/null
+++ b/packages/ui/src/lib/components/TextField.ssr.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from 'vitest';
+import { render } from 'svelte/server';
+
+import TextField from './TextField.svelte';
+import Field from './Field.svelte';
+import Input from './Input.svelte';
+
+describe('TextField (SSR)', () => {
+ it('renders a labelled input', () => {
+ const { body } = render(TextField, { props: { label: 'Name' } });
+
+ expect(body).toContain('class="TextField');
+ expect(body).toContain(' {
+ const { body } = render(TextField, { props: { multiline: true } });
+
+ expect(body).toContain('