+
An account for the whole team, with one bill.
+
+
Each member keeps a workspace of their own.
+
+ Edit
+
+ Share
+
+ Delete
+
+
diff --git a/docs/src/content/separator/demos/splitter.svelte b/docs/src/content/separator/demos/splitter.svelte
new file mode 100644
index 00000000..e37e9a23
--- /dev/null
+++ b/docs/src/content/separator/demos/splitter.svelte
@@ -0,0 +1,25 @@
+
+
+
+
+
Files
+
{Math.round(value)}% of the row
+
+
+
+
diff --git a/docs/src/content/separator/index.md b/docs/src/content/separator/index.md
new file mode 100644
index 00000000..6deb2d6e
--- /dev/null
+++ b/docs/src/content/separator/index.md
@@ -0,0 +1,64 @@
+---
+title: Separator
+description: A line between two groups of content, which a screen reader reads as a break, horizontal or vertical, decorative and skipped, or a window splitter the keyboard and the pointer move.
+---
+
+
+
+# Separator
+
+`Separator` is a line between two groups of content. A screen reader reads it as a break, the same as a native `` with `role="separator"`.
+
+```svelte
+
+
+
+```
+
+## Orientation
+
+A separator is horizontal by default: it sits between two blocks, one above the other. Give `orientation="vertical"` for a line between two items side by side, such as in a toolbar. The element then has `aria-orientation="vertical"`, and `data-orientation` is on it for your styles in both cases.
+
+## Decorative
+
+Some lines repeat a break the content already makes, such as a border between two sections with headings. A screen reader would read two breaks. Give `decorative` to such a line: it becomes `role="none"`, and the screen reader skips it.
+
+## A window splitter
+
+Give `value` for a separator the user moves: the size of the pane before it, as a number between `min` and `max`. Use `bind:value`, and give the pane its size from it. The splitter is then a tab stop with `aria-valuenow`, and it has a name from `aria-label`. Give `aria-controls` the id of the pane.
+
+The arrow keys of its axis move it by `step`, and by `largeStep` with `Shift`. `Home` and `End` send it to the ends. `Enter` folds the pane to `min`, and unfolds it to where it was. The pointer drags it along its parent: a move of a fifth of the parent is a fifth of the range. `data-dragging` is on it through the drag, and `data-focus-visible` with the keyboard focus.
+
+
+
+## Usage guidelines
+
+- Give the line a size with CSS: a height and a width, or a border.
+- Use `decorative` when the content around the line already tells the reader where one group ends.
+- Give a splitter a name with `aria-label`, such as "Resize the file list", and a width the pointer can find.
+
+## Accessibility
+
+- The element is `role="separator"`. A vertical one has `aria-orientation="vertical"`: a separator is horizontal unless it says otherwise.
+- `decorative` makes it `role="none"`, out of the accessibility tree.
+- A plain separator has no tab stop. A splitter has one, with `aria-valuenow`, `aria-valuemin`, `aria-valuemax`, `aria-controls` and a name. The keyboard moves it: the arrows, `Home`, `End` and `Enter`.
+- On a right-to-left page the horizontal arrows flip. `ArrowLeft` grows the pane before a vertical splitter, because that pane is on the right.
+
+## API reference
+
+
diff --git a/docs/src/lib/docs/nav.ts b/docs/src/lib/docs/nav.ts
index fbf75799..130f45b7 100644
--- a/docs/src/lib/docs/nav.ts
+++ b/docs/src/lib/docs/nav.ts
@@ -63,8 +63,11 @@ export const nav: NavGroup[] = [
label: 'Structure',
items: [
{ slug: 'accordion', title: 'Accordion' },
+ { slug: 'avatar', title: 'Avatar' },
+ { slug: 'breadcrumbs', title: 'Breadcrumbs' },
{ slug: 'collapsible', title: 'Collapsible' },
{ slug: 'progress', title: 'Progress' },
+ { slug: 'separator', title: 'Separator' },
{ slug: 'table', title: 'Table' },
{ slug: 'tabs', title: 'Tabs' },
{ slug: 'tree', title: 'Tree' }
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 48d083bb..21b4257e 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -203,6 +203,21 @@
"svelte": "./dist/tree/index.js",
"default": "./dist/tree/index.js"
},
+ "./avatar": {
+ "types": "./dist/avatar/index.d.ts",
+ "svelte": "./dist/avatar/index.js",
+ "default": "./dist/avatar/index.js"
+ },
+ "./breadcrumbs": {
+ "types": "./dist/breadcrumbs/index.d.ts",
+ "svelte": "./dist/breadcrumbs/index.js",
+ "default": "./dist/breadcrumbs/index.js"
+ },
+ "./separator": {
+ "types": "./dist/separator/index.d.ts",
+ "svelte": "./dist/separator/index.js",
+ "default": "./dist/separator/index.js"
+ },
"./input": {
"types": "./dist/input/index.d.ts",
"svelte": "./dist/input/index.js",
diff --git a/packages/ui/src/lib/avatar/README.md b/packages/ui/src/lib/avatar/README.md
new file mode 100644
index 00000000..d0d713cc
--- /dev/null
+++ b/packages/ui/src/lib/avatar/README.md
@@ -0,0 +1,52 @@
+# Avatar
+
+## Description
+
+`Avatar` is a picture of a person or a thing, with a fallback for when the picture is not there. The image loads off the screen first, and it shows once it is there: a broken image icon never shows. The fallback shows in its place, at once or after a delay.
+
+## Anatomy
+
+- `Avatar.Root`
+- `Avatar.Image`
+- `Avatar.Fallback`
+- `Avatar.Group`
+- `Avatar.Count`
+
+```svelte
+
+
+ AL
+
+```
+
+## Usage guidelines
+
+- Give `alt` the name of the person or the thing. Give an empty `alt` when the name is beside the avatar already. The fallback follows the `alt`: it takes the name, or it hides.
+- Give `delay` to the fallback, such as 600, thus a fast image does not flash initials first.
+- Style the root by `data-status`: `loading`, `loaded` or `error`.
+- Read the status with `onStatusChange` on the root.
+- Give `loading="lazy"` to the image on a long list: the load starts when the avatar comes into view.
+- Put a row of avatars in `Avatar.Group` with an `aria-label` and a `max`, and an `Avatar.Count` for the ones past the limit.
+
+## API reference
+
+- `Avatar.Root`
+ - `onStatusChange?: (status: 'loading' | 'loaded' | 'error') => void`
+ - `element?: HTMLSpanElement | null` (bindable)
+- `Avatar.Image`
+ - `src?: string | null`, `alt: string`
+ - `element?: HTMLImageElement | null` (bindable)
+- `Avatar.Fallback`
+ - `delay?: number` (`0`)
+ - `element?: HTMLSpanElement | null` (bindable)
+- `Avatar.Group`
+ - `max?: number`, `aria-label?: string`
+- `Avatar.Count`
+ - `children?: Snippet<[{ overflow, count, max }]>`, `aria-label?: string` ("N more", localized)
+
+## Accessibility
+
+- The image is an `
` with your `alt`. The root has no role of its own.
+- The fallback takes the name of the image: it is a `role="img"` named with the `alt`. A screen reader hears the name, and not the letters of the initials. An empty `alt` hides it. Without an image, it is plain content.
+- `Avatar.Group` is a `role="group"` named with your `aria-label`. `Avatar.Count` is a `role="img"` named "N more".
+- Nothing in the avatar is focusable.
diff --git a/packages/ui/src/lib/avatar/TODO.md b/packages/ui/src/lib/avatar/TODO.md
new file mode 100644
index 00000000..7db71f82
--- /dev/null
+++ b/packages/ui/src/lib/avatar/TODO.md
@@ -0,0 +1,15 @@
+# Avatar TODO
+
+## Goal
+
+Track Avatar work with a single mandatory TODO format.
+
+## Backlog
+
+- [x] [M][P0][Area: Architecture][Owner: Unassigned][Target: Done] Create the `root`, `image` and `fallback` parts with namespace exports and a shared status.
+- [x] [S][P0][Area: State][Owner: Unassigned][Target: Done] Load the image off the screen, show the `
` once it is there, and show the fallback at once for a failure or a missing `src`.
+- [x] [S][P1][Area: UX][Owner: Unassigned][Target: Done] Hold the fallback back for `delay` milliseconds while the image loads.
+- [x] [M][P0][Area: Testing][Owner: Unassigned][Target: Done] Add coverage for the load, the failure, the missing source, the delay, a change of source, `onStatusChange`, and SSR.
+- [x] [S][P1][Area: Accessibility][Owner: Unassigned][Target: Done] Name the fallback with the `alt` of the image, and hide it for an empty `alt`.
+- [x] [S][P2][Area: API][Owner: Unassigned][Target: Done] Add `Avatar.Group` and `Avatar.Count` for a row of avatars with a limit and a count of the rest.
+- [x] [S][P2][Area: Performance][Owner: Unassigned][Target: Done] Honor `loading="lazy"`: start the load when the root comes into view, and not on mount.
diff --git a/packages/ui/src/lib/avatar/count/avatar-count.svelte b/packages/ui/src/lib/avatar/count/avatar-count.svelte
new file mode 100644
index 00000000..6cddc510
--- /dev/null
+++ b/packages/ui/src/lib/avatar/count/avatar-count.svelte
@@ -0,0 +1,56 @@
+
+
+{#if group.overflow > 0}
+
+ {#if children}
+ {@render children({ overflow: group.overflow, count: group.count, max: group.max })}
+ {:else}
+ +{group.overflow}
+ {/if}
+
+{/if}
diff --git a/packages/ui/src/lib/avatar/fallback/avatar-fallback.svelte b/packages/ui/src/lib/avatar/fallback/avatar-fallback.svelte
new file mode 100644
index 00000000..216f5947
--- /dev/null
+++ b/packages/ui/src/lib/avatar/fallback/avatar-fallback.svelte
@@ -0,0 +1,67 @@
+
+
+{#if shown}
+
+ {@render children?.()}
+
+{/if}
diff --git a/packages/ui/src/lib/avatar/group/avatar-group-test.svelte b/packages/ui/src/lib/avatar/group/avatar-group-test.svelte
new file mode 100644
index 00000000..cd2d86e3
--- /dev/null
+++ b/packages/ui/src/lib/avatar/group/avatar-group-test.svelte
@@ -0,0 +1,24 @@
+
+
+
+
+
+ {#each names as name (name)}
+
+
+ {name.slice(0, 1)}
+
+ {/each}
+
+
+
diff --git a/packages/ui/src/lib/avatar/group/avatar-group.svelte b/packages/ui/src/lib/avatar/group/avatar-group.svelte
new file mode 100644
index 00000000..d581b387
--- /dev/null
+++ b/packages/ui/src/lib/avatar/group/avatar-group.svelte
@@ -0,0 +1,65 @@
+
+
+
0 || undefined}
+>
+ {@render children?.()}
+
diff --git a/packages/ui/src/lib/avatar/group/avatar-group.test.ts b/packages/ui/src/lib/avatar/group/avatar-group.test.ts
new file mode 100644
index 00000000..70beaceb
--- /dev/null
+++ b/packages/ui/src/lib/avatar/group/avatar-group.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import AvatarGroupTest from './avatar-group-test.svelte';
+
+function avatars() {
+ return Array.from(document.querySelectorAll
('[data-testid="avatar"]'));
+}
+
+function count() {
+ return document.querySelector('[data-testid="count"]');
+}
+
+describe('Avatar.Group', () => {
+ it('is a named group that shows every avatar without a limit, and no count', () => {
+ render(AvatarGroupTest);
+ const group = document.querySelector('[data-testid="group"]');
+
+ expect(group?.getAttribute('role')).toBe('group');
+ expect(group?.getAttribute('aria-label')).toBe('Assignees');
+ expect(group?.hasAttribute('data-overflow')).toBe(false);
+ expect(avatars()).toHaveLength(5);
+ expect(avatars().map((avatar) => avatar.getAttribute('data-index'))).toEqual([
+ '0',
+ '1',
+ '2',
+ '3',
+ '4'
+ ]);
+ expect(count()).toBeNull();
+ });
+
+ it('shows the first max avatars, and counts the rest for the screen reader', () => {
+ render(AvatarGroupTest, { max: 3 });
+
+ expect(avatars()).toHaveLength(3);
+ expect(
+ avatars().map((avatar) => avatar.querySelector('[data-avatar-fallback]')?.textContent?.trim())
+ ).toEqual(['A', 'G', 'K']);
+ expect(document.querySelector('[data-testid="group"]')?.getAttribute('data-overflow')).toBe(
+ 'true'
+ );
+ expect(count()?.textContent?.trim()).toBe('+2');
+ expect(count()?.getAttribute('role')).toBe('img');
+ expect(count()?.getAttribute('aria-label')).toBe('2 more');
+ });
+
+ it('speaks the locale', () => {
+ render(AvatarGroupTest, { max: 4, locale: 'es' });
+ expect(count()?.getAttribute('aria-label')).toBe('1 más');
+ });
+
+ it('follows the list: an avatar that leaves lets the next one in', async () => {
+ const screen = render(AvatarGroupTest, { max: 3 });
+ expect(count()?.textContent?.trim()).toBe('+2');
+
+ await screen.rerender({ max: 3, names: ['Grace', 'Katherine', 'Mary', 'Dorothy'] });
+ expect(
+ avatars().map((avatar) => avatar.querySelector('[data-avatar-fallback]')?.textContent?.trim())
+ ).toEqual(['G', 'K', 'M']);
+ expect(count()?.textContent?.trim()).toBe('+1');
+
+ await screen.rerender({ max: 3, names: ['Grace', 'Katherine'] });
+ expect(avatars()).toHaveLength(2);
+ expect(count()).toBeNull();
+ });
+});
diff --git a/packages/ui/src/lib/avatar/group/context.ts b/packages/ui/src/lib/avatar/group/context.ts
new file mode 100644
index 00000000..495605f4
--- /dev/null
+++ b/packages/ui/src/lib/avatar/group/context.ts
@@ -0,0 +1,32 @@
+import { getContext, setContext } from 'svelte';
+
+const KEY = Symbol('avatar-group');
+
+export type AvatarGroupContext = {
+ /** The count of avatars in the group, on the screen or not. */
+ readonly count: number;
+ /** The count of avatars on the screen. `Infinity` without a limit. */
+ readonly max: number;
+ /** The count of avatars past the limit. */
+ readonly overflow: number;
+ /** Registers an avatar, and answers its place in the group. The returned function unregisters it. */
+ register: (id: string) => () => void;
+ /** The place of an avatar in the group, or -1 when it is not registered. */
+ indexOf: (id: string) => number;
+};
+
+export function setAvatarGroupContext(context: AvatarGroupContext) {
+ setContext(KEY, context);
+}
+
+export function getAvatarGroupContext(): AvatarGroupContext | undefined {
+ return getContext(KEY);
+}
+
+export function useAvatarGroupContext(part = 'Avatar'): AvatarGroupContext {
+ const context = getAvatarGroupContext();
+ if (!context) {
+ throw new Error(`${part} must be used within Avatar.Group.`);
+ }
+ return context;
+}
diff --git a/packages/ui/src/lib/avatar/image/avatar-image.svelte b/packages/ui/src/lib/avatar/image/avatar-image.svelte
new file mode 100644
index 00000000..4544b4f7
--- /dev/null
+++ b/packages/ui/src/lib/avatar/image/avatar-image.svelte
@@ -0,0 +1,104 @@
+
+
+{#if avatar.status === 'loaded' && src}
+
+{/if}
diff --git a/packages/ui/src/lib/avatar/index.parts.ts b/packages/ui/src/lib/avatar/index.parts.ts
new file mode 100644
index 00000000..b366f45b
--- /dev/null
+++ b/packages/ui/src/lib/avatar/index.parts.ts
@@ -0,0 +1,5 @@
+export { default as Root } from './root/avatar-root.svelte';
+export { default as Image } from './image/avatar-image.svelte';
+export { default as Fallback } from './fallback/avatar-fallback.svelte';
+export { default as Group } from './group/avatar-group.svelte';
+export { default as Count } from './count/avatar-count.svelte';
diff --git a/packages/ui/src/lib/avatar/index.ts b/packages/ui/src/lib/avatar/index.ts
new file mode 100644
index 00000000..65e2e5b7
--- /dev/null
+++ b/packages/ui/src/lib/avatar/index.ts
@@ -0,0 +1,36 @@
+import type { ComponentProps } from 'svelte';
+import type AvatarCountComponent from './count/avatar-count.svelte';
+import type AvatarFallbackComponent from './fallback/avatar-fallback.svelte';
+import type AvatarGroupComponent from './group/avatar-group.svelte';
+import type AvatarImageComponent from './image/avatar-image.svelte';
+import type AvatarRootComponent from './root/avatar-root.svelte';
+
+export * as Avatar from './index.parts.js';
+
+export { default as AvatarRoot } from './root/avatar-root.svelte';
+export { default as AvatarImage } from './image/avatar-image.svelte';
+export { default as AvatarFallback } from './fallback/avatar-fallback.svelte';
+export { default as AvatarGroup } from './group/avatar-group.svelte';
+export { default as AvatarCount } from './count/avatar-count.svelte';
+export type AvatarRootProps = ComponentProps;
+export type AvatarImageProps = ComponentProps;
+export type AvatarFallbackProps = ComponentProps;
+export type AvatarGroupProps = ComponentProps;
+export type AvatarCountProps = ComponentProps;
+export type { AvatarCountState } from './types.js';
+export {
+ getAvatarGroupContext,
+ setAvatarGroupContext,
+ useAvatarGroupContext,
+ type AvatarGroupContext
+} from './group/context.js';
+export {
+ getAvatarContext,
+ setAvatarContext,
+ useAvatarContext,
+ type AvatarContext,
+ type AvatarStatus
+} from './root/context.js';
+
+import * as AvatarParts from './index.parts.js';
+export default AvatarParts;
diff --git a/packages/ui/src/lib/avatar/root/avatar-root.svelte b/packages/ui/src/lib/avatar/root/avatar-root.svelte
new file mode 100644
index 00000000..b4d052bc
--- /dev/null
+++ b/packages/ui/src/lib/avatar/root/avatar-root.svelte
@@ -0,0 +1,79 @@
+
+
+{#if !hidden}
+
+ {@render children?.()}
+
+{/if}
diff --git a/packages/ui/src/lib/avatar/root/avatar-ssr.test.ts b/packages/ui/src/lib/avatar/root/avatar-ssr.test.ts
new file mode 100644
index 00000000..6d70ff7e
--- /dev/null
+++ b/packages/ui/src/lib/avatar/root/avatar-ssr.test.ts
@@ -0,0 +1,20 @@
+// @vitest-environment node
+
+import { describe, expect, it } from 'vitest';
+import { render } from 'svelte/server';
+import AvatarTest from './avatar-test.svelte';
+
+describe('Avatar SSR', () => {
+ it('renders the fallback and no image before hydration', () => {
+ const { body } = render(AvatarTest, { props: { src: '/ada.jpg' } });
+ expect(body).toContain('data-status="loading"');
+ expect(body).toContain('data-avatar-fallback="true"');
+ expect(body).not.toContain(' {
+ const { body } = render(AvatarTest, { props: { src: '/ada.jpg', delay: 600 } });
+ expect(body).not.toContain('data-avatar-fallback');
+ expect(body).not.toContain('
+ import { Avatar } from '../index';
+ import type { AvatarStatus } from '../index';
+
+ let {
+ src = undefined,
+ alt = 'Ada Lovelace',
+ delay = 0,
+ lazy = false,
+ onStatusChange
+ }: {
+ src?: string | null;
+ alt?: string;
+ delay?: number;
+ lazy?: boolean;
+ onStatusChange?: (status: AvatarStatus) => void;
+ } = $props();
+
+ let image = $state(null);
+ let fallback = $state(null);
+
+
+{#if lazy}
+
+{/if}
+
+
+
+ AL
+
+
+{image?.tagName ?? 'none'}
+{fallback?.tagName ?? 'none'}
diff --git a/packages/ui/src/lib/avatar/root/avatar.test.ts b/packages/ui/src/lib/avatar/root/avatar.test.ts
new file mode 100644
index 00000000..20224f8f
--- /dev/null
+++ b/packages/ui/src/lib/avatar/root/avatar.test.ts
@@ -0,0 +1,119 @@
+import { describe, expect, it, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import AvatarTest from './avatar-test.svelte';
+
+// A 1 by 1 PNG. The browser loads it at once, from memory.
+const PIXEL =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
+const BROKEN = 'data:image/png;base64,not-an-image';
+
+function byTestId(id: string): T | null {
+ return document.querySelector(`[data-testid="${id}"]`);
+}
+
+describe('Avatar', () => {
+ it('shows the image once it loads, and no fallback after', async () => {
+ const onStatusChange = vi.fn();
+ render(AvatarTest, { src: PIXEL, onStatusChange });
+
+ await expect.poll(() => byTestId('root')?.getAttribute('data-status')).toBe('loaded');
+ const image = byTestId('image');
+ expect(image?.tagName).toBe('IMG');
+ expect(image?.getAttribute('src')).toBe(PIXEL);
+ expect(image?.getAttribute('alt')).toBe('Ada Lovelace');
+ expect(image?.getAttribute('data-avatar-image')).toBe('true');
+ expect(byTestId('fallback')).toBeNull();
+ expect(byTestId('image-tag')?.textContent).toBe('IMG');
+ expect(byTestId('fallback-tag')?.textContent).toBe('none');
+ expect(onStatusChange).toHaveBeenLastCalledWith('loaded');
+ });
+
+ it('shows the fallback, and no image, when the image fails', async () => {
+ const onStatusChange = vi.fn();
+ render(AvatarTest, { src: BROKEN, onStatusChange });
+
+ await expect.poll(() => byTestId('root')?.getAttribute('data-status')).toBe('error');
+ expect(byTestId('image')).toBeNull();
+ expect(byTestId('fallback')?.textContent?.trim()).toBe('AL');
+ expect(byTestId('fallback')?.getAttribute('data-status')).toBe('error');
+ expect(onStatusChange).toHaveBeenLastCalledWith('error');
+ });
+
+ it('gives the fallback the name of the image, and hides it for an empty alt', async () => {
+ render(AvatarTest, { src: BROKEN });
+ await expect.poll(() => byTestId('root')?.getAttribute('data-status')).toBe('error');
+ const fallback = byTestId('fallback');
+ expect(fallback?.getAttribute('role')).toBe('img');
+ expect(fallback?.getAttribute('aria-label')).toBe('Ada Lovelace');
+ expect(fallback?.hasAttribute('aria-hidden')).toBe(false);
+
+ document.body.innerHTML = '';
+ render(AvatarTest, { src: BROKEN, alt: '' });
+ await expect.poll(() => byTestId('root')?.getAttribute('data-status')).toBe('error');
+ const decorative = byTestId('fallback');
+ expect(decorative?.hasAttribute('role')).toBe(false);
+ expect(decorative?.hasAttribute('aria-label')).toBe(false);
+ expect(decorative?.getAttribute('aria-hidden')).toBe('true');
+ });
+
+ it('shows the fallback at once without a source', async () => {
+ render(AvatarTest, { src: null });
+
+ await expect.poll(() => byTestId('root')?.getAttribute('data-status')).toBe('error');
+ expect(byTestId('image')).toBeNull();
+ expect(byTestId('fallback')).not.toBeNull();
+ });
+
+ it('holds the fallback back for the delay while the image loads, and not on a failure', async () => {
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ try {
+ // A source that never answers: the load stays pending.
+ render(AvatarTest, { src: 'http://127.0.0.1:9/never.png', delay: 500 });
+ expect(byTestId('root')?.getAttribute('data-status')).toBe('loading');
+ expect(byTestId('fallback')).toBeNull();
+
+ await vi.advanceTimersByTimeAsync(400);
+ expect(byTestId('fallback')).toBeNull();
+ await vi.advanceTimersByTimeAsync(100);
+ await expect.poll(() => byTestId('fallback')).not.toBeNull();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('shows the fallback at once on a failure, delay or not', async () => {
+ render(AvatarTest, { src: BROKEN, delay: 10000 });
+ await expect.poll(() => byTestId('root')?.getAttribute('data-status')).toBe('error');
+ expect(byTestId('fallback')).not.toBeNull();
+ });
+
+ it('loads again when the source changes', async () => {
+ const screen = render(AvatarTest, { src: BROKEN });
+ await expect.poll(() => byTestId('root')?.getAttribute('data-status')).toBe('error');
+
+ await screen.rerender({ src: PIXEL });
+ await expect.poll(() => byTestId('root')?.getAttribute('data-status')).toBe('loaded');
+ expect(byTestId('image')).not.toBeNull();
+ expect(byTestId('fallback')).toBeNull();
+ });
+
+ it('waits for the avatar to come into view with loading lazy', async () => {
+ render(AvatarTest, { src: PIXEL, lazy: true });
+ // The avatar is below a tall block: out of view until a scroll.
+ expect(byTestId('root')?.getAttribute('data-status')).toBe('loading');
+ await new Promise((done) => setTimeout(done, 150));
+ expect(byTestId('root')?.getAttribute('data-status')).toBe('loading');
+ expect(byTestId('image')).toBeNull();
+
+ byTestId('root')?.scrollIntoView();
+ await expect.poll(() => byTestId('root')?.getAttribute('data-status')).toBe('loaded');
+ expect(byTestId('image')).not.toBeNull();
+ });
+
+ it('is not in the tab order', () => {
+ render(AvatarTest, { src: null });
+ const root = byTestId('root');
+ expect(root?.hasAttribute('tabindex')).toBe(false);
+ expect(root?.hasAttribute('role')).toBe(false);
+ });
+});
diff --git a/packages/ui/src/lib/avatar/root/context.ts b/packages/ui/src/lib/avatar/root/context.ts
new file mode 100644
index 00000000..2f44e6eb
--- /dev/null
+++ b/packages/ui/src/lib/avatar/root/context.ts
@@ -0,0 +1,32 @@
+import { getContext, setContext } from 'svelte';
+
+const KEY = Symbol('avatar');
+
+/** `loading` until the image answers, then `loaded` or `error`. `error` without a `src` too. */
+export type AvatarStatus = 'loading' | 'loaded' | 'error';
+
+export type AvatarContext = {
+ status: AvatarStatus;
+ setStatus: (status: AvatarStatus) => void;
+ /** The `alt` of the image, thus the fallback can take its name. `undefined` without an image. */
+ alt: string | undefined;
+ setAlt: (alt: string | undefined) => void;
+ /** The root element, for a lazy image that waits for it to come into view. */
+ readonly element: HTMLElement | null;
+};
+
+export function setAvatarContext(context: AvatarContext) {
+ setContext(KEY, context);
+}
+
+export function getAvatarContext(): AvatarContext | undefined {
+ return getContext(KEY);
+}
+
+export function useAvatarContext(part = 'Avatar'): AvatarContext {
+ const context = getAvatarContext();
+ if (!context) {
+ throw new Error(`${part} must be used within Avatar.Root.`);
+ }
+ return context;
+}
diff --git a/packages/ui/src/lib/avatar/types.ts b/packages/ui/src/lib/avatar/types.ts
new file mode 100644
index 00000000..f329f57f
--- /dev/null
+++ b/packages/ui/src/lib/avatar/types.ts
@@ -0,0 +1,97 @@
+import type { Snippet } from 'svelte';
+import type { HTMLAttributes, HTMLImgAttributes } from 'svelte/elements';
+import type { AvatarStatus } from './root/context.js';
+
+export type { AvatarContext, AvatarStatus } from './root/context.js';
+export type { AvatarGroupContext } from './group/context.js';
+
+export type AvatarGroupProps = Omit<
+ HTMLAttributes,
+ 'children' | 'class' | 'role'
+> & {
+ /** The avatars, and an `Avatar.Count` for the ones past `max`. */
+ children?: Snippet;
+ /** The CSS class names of the element. */
+ class?: string;
+ /**
+ * The count of avatars on the screen. The ones past it render nothing, and `Avatar.Count`
+ * says how many they are. Without it, every avatar is on the screen.
+ */
+ max?: number;
+ /**
+ * The name of the group, such as "Assignees". A group with no name is a group a screen
+ * reader cannot tell apart from the content around it.
+ */
+ 'aria-label'?: string;
+ /** The group element. Use `bind:element` to read it. */
+ element?: HTMLDivElement | null;
+};
+
+export type AvatarCountState = {
+ /** The count of avatars past `max`. */
+ overflow: number;
+ /** The count of avatars in the group. */
+ count: number;
+ /** The limit of the group. */
+ max: number;
+};
+
+export type AvatarCountProps = Omit<
+ HTMLAttributes,
+ 'children' | 'class' | 'aria-label'
+> & {
+ /** The content in place of `+N`. It gets the overflow, the count and the limit. */
+ children?: Snippet<[AvatarCountState]>;
+ /** The CSS class names of the element. */
+ class?: string;
+ /** The name for the screen reader. Without it, "N more" in the locale of `LocaleProvider`. */
+ 'aria-label'?: string;
+ /** The count element, while it is on the screen. Use `bind:element` to read it. */
+ element?: HTMLSpanElement | null;
+};
+
+export type AvatarRootProps = Omit, 'children' | 'class'> & {
+ /** `Avatar.Image` and `Avatar.Fallback`. */
+ children?: Snippet;
+ /** The CSS class names of the element. */
+ class?: string;
+ /** The root element. Use `bind:element` to read it. */
+ element?: HTMLSpanElement | null;
+ /** Called when the image starts to load, loads, or fails. */
+ onStatusChange?: (status: AvatarStatus) => void;
+};
+
+export type AvatarImageProps = Omit & {
+ /** The address of the image. Without it, the fallback shows. */
+ src?: string | null;
+ /**
+ * `lazy` starts the load when the avatar comes into view, and not on mount. Use it on a long
+ * list of avatars, thus the ones below the fold do not all load at once.
+ */
+ loading?: 'eager' | 'lazy';
+ /**
+ * The text in place of the image. Give the name of the person or the thing. Give an empty
+ * string when the name is beside the avatar already, thus a screen reader does not read it
+ * twice.
+ */
+ alt: string;
+ /** The CSS class names of the element. */
+ class?: string;
+ /** The image element, once the image is on the screen. Use `bind:element` to read it. */
+ element?: HTMLImageElement | null;
+};
+
+export type AvatarFallbackProps = Omit, 'children' | 'class'> & {
+ /** The content in place of the image, such as initials or an icon. */
+ children?: Snippet;
+ /** The CSS class names of the element. */
+ class?: string;
+ /**
+ * The time in milliseconds before the fallback shows while the image loads. A fallback that
+ * flashes before a fast image is noise: 300 or 600 hides it on a good connection. It shows at
+ * once when the image fails, or when there is no image.
+ */
+ delay?: number;
+ /** The fallback element, while it is on the screen. Use `bind:element` to read it. */
+ element?: HTMLSpanElement | null;
+};
diff --git a/packages/ui/src/lib/breadcrumbs/README.md b/packages/ui/src/lib/breadcrumbs/README.md
new file mode 100644
index 00000000..5ed69a2d
--- /dev/null
+++ b/packages/ui/src/lib/breadcrumbs/README.md
@@ -0,0 +1,65 @@
+# Breadcrumbs
+
+## Description
+
+`Breadcrumbs` is the trail of pages above the one the user is on. It is a `` landmark named "Breadcrumb" with an ordered list of links, and the last one says it is the current page.
+
+## Anatomy
+
+- `Breadcrumbs.Root`
+- `Breadcrumbs.List`
+- `Breadcrumbs.Item`
+- `Breadcrumbs.Link`
+- `Breadcrumbs.Ellipsis`
+- `Breadcrumbs.Separator`
+
+```svelte
+
+
+
+ Home
+
+
+
+ Docs
+
+
+
+ Breadcrumbs
+
+
+
+```
+
+## Usage guidelines
+
+- Set `current` on the last link. It can keep its `href`: a link to the page the user is on is a way to reload it.
+- Put the separator in the item, after the link, on every item but the last.
+- Give the root an `aria-label` when the page has two sets of breadcrumbs.
+- Use `disabled` for a page the user cannot open now. The text stays, the link goes.
+- Give `maxItems` to fold a long trail, and put a `Breadcrumbs.Ellipsis` after the first item.
+
+## API reference
+
+- `Breadcrumbs.Root`
+ - `aria-label?: string` ("Breadcrumb", localized)
+ - `maxItems?: number`, `expanded?: boolean` (bindable), `onExpandedChange?: (expanded: boolean) => void`
+ - `element?: HTMLElement | null` (bindable)
+- `Breadcrumbs.List`, `Breadcrumbs.Item`
+ - `element` (bindable)
+- `Breadcrumbs.Link`
+ - `href?: string`, `current?: boolean`, `disabled?: boolean`
+ - `element?: HTMLAnchorElement | HTMLSpanElement | null` (bindable)
+- `Breadcrumbs.Ellipsis`
+ - `children` (three dots without children), `itemClass?: string`, `separator?: Snippet`, `aria-label?: string` ("Show N more pages", localized)
+- `Breadcrumbs.Separator`
+ - `children` (a slash without children)
+
+## Accessibility
+
+- `Breadcrumbs.Root` is a `` with `aria-label="Breadcrumb"`, in the locale of `LocaleProvider`. A screen reader user finds it in the list of landmarks.
+- `Breadcrumbs.List` is an `` of ``: a screen reader says the count and the position of each page. It has `role="list"` in writing, for the browsers that drop a list with `list-style: none`.
+- `Breadcrumbs.Link` with `current` has `aria-current="page"`. Without an `href`, or with `disabled`, it is a ``: not a link, and not a tab stop.
+- `Breadcrumbs.Separator` is `aria-hidden`. The list already separates the items.
+- `Breadcrumbs.Ellipsis` is a button named "Show N more pages". After the press, the focus lands on the first page that comes into view.
+- The keyboard moves through the links with `Tab`. Breadcrumbs are links on a page, not a widget with arrow keys.
diff --git a/packages/ui/src/lib/breadcrumbs/TODO.md b/packages/ui/src/lib/breadcrumbs/TODO.md
new file mode 100644
index 00000000..d1e89b54
--- /dev/null
+++ b/packages/ui/src/lib/breadcrumbs/TODO.md
@@ -0,0 +1,14 @@
+# Breadcrumbs TODO
+
+## Goal
+
+Track Breadcrumbs work with a single mandatory TODO format.
+
+## Backlog
+
+- [x] [M][P0][Area: Architecture][Owner: Unassigned][Target: Done] Create the `root`, `list`, `item`, `link` and `separator` parts with namespace exports.
+- [x] [S][P0][Area: Accessibility][Owner: Unassigned][Target: Done] Render a `` named "Breadcrumb" in six languages, an `` of ``, `aria-current="page"` on the current link, and `aria-hidden` separators.
+- [x] [S][P1][Area: Accessibility][Owner: Unassigned][Target: Done] Render a `` for a link without an `href` or with `disabled`, out of the tab order.
+- [x] [M][P0][Area: Testing][Owner: Unassigned][Target: Done] Add coverage for the landmark, the locale, the current page, the separators, the disabled link, the tab order, and SSR.
+- [x] [S][P0][Area: Accessibility][Owner: Unassigned][Target: Done] Keep `role="list"` on the `` in writing, for the browsers that drop a list with `list-style: none`.
+- [x] [M][P2][Area: UX][Owner: Unassigned][Target: Done] Fold the middle of a long trail behind `Breadcrumbs.Ellipsis`, with the first and the last pages in view, and the focus on the first page that comes into view after the press.
diff --git a/packages/ui/src/lib/breadcrumbs/ellipsis/breadcrumbs-ellipsis.svelte b/packages/ui/src/lib/breadcrumbs/ellipsis/breadcrumbs-ellipsis.svelte
new file mode 100644
index 00000000..daee5031
--- /dev/null
+++ b/packages/ui/src/lib/breadcrumbs/ellipsis/breadcrumbs-ellipsis.svelte
@@ -0,0 +1,62 @@
+
+
+{#if breadcrumbs.collapsed}
+
+
+ {#if children}{@render children()}{:else}…{/if}
+
+ {@render separator?.()}
+
+{/if}
diff --git a/packages/ui/src/lib/breadcrumbs/index.parts.ts b/packages/ui/src/lib/breadcrumbs/index.parts.ts
new file mode 100644
index 00000000..d1dc85e7
--- /dev/null
+++ b/packages/ui/src/lib/breadcrumbs/index.parts.ts
@@ -0,0 +1,6 @@
+export { default as Root } from './root/breadcrumbs-root.svelte';
+export { default as List } from './list/breadcrumbs-list.svelte';
+export { default as Item } from './item/breadcrumbs-item.svelte';
+export { default as Link } from './link/breadcrumbs-link.svelte';
+export { default as Ellipsis } from './ellipsis/breadcrumbs-ellipsis.svelte';
+export { default as Separator } from './separator/breadcrumbs-separator.svelte';
diff --git a/packages/ui/src/lib/breadcrumbs/index.ts b/packages/ui/src/lib/breadcrumbs/index.ts
new file mode 100644
index 00000000..62bc7a4b
--- /dev/null
+++ b/packages/ui/src/lib/breadcrumbs/index.ts
@@ -0,0 +1,31 @@
+import type { ComponentProps } from 'svelte';
+import type BreadcrumbsEllipsisComponent from './ellipsis/breadcrumbs-ellipsis.svelte';
+import type BreadcrumbsItemComponent from './item/breadcrumbs-item.svelte';
+import type BreadcrumbsLinkComponent from './link/breadcrumbs-link.svelte';
+import type BreadcrumbsListComponent from './list/breadcrumbs-list.svelte';
+import type BreadcrumbsRootComponent from './root/breadcrumbs-root.svelte';
+import type BreadcrumbsSeparatorComponent from './separator/breadcrumbs-separator.svelte';
+
+export * as Breadcrumbs from './index.parts.js';
+
+export { default as BreadcrumbsRoot } from './root/breadcrumbs-root.svelte';
+export { default as BreadcrumbsList } from './list/breadcrumbs-list.svelte';
+export { default as BreadcrumbsItem } from './item/breadcrumbs-item.svelte';
+export { default as BreadcrumbsLink } from './link/breadcrumbs-link.svelte';
+export { default as BreadcrumbsEllipsis } from './ellipsis/breadcrumbs-ellipsis.svelte';
+export { default as BreadcrumbsSeparator } from './separator/breadcrumbs-separator.svelte';
+export type BreadcrumbsRootProps = ComponentProps;
+export type BreadcrumbsListProps = ComponentProps;
+export type BreadcrumbsItemProps = ComponentProps;
+export type BreadcrumbsLinkProps = ComponentProps;
+export type BreadcrumbsEllipsisProps = ComponentProps;
+export {
+ getBreadcrumbsContext,
+ setBreadcrumbsContext,
+ useBreadcrumbsContext,
+ type BreadcrumbsContext
+} from './root/context.js';
+export type BreadcrumbsSeparatorProps = ComponentProps;
+
+import * as BreadcrumbsParts from './index.parts.js';
+export default BreadcrumbsParts;
diff --git a/packages/ui/src/lib/breadcrumbs/item/breadcrumbs-item.svelte b/packages/ui/src/lib/breadcrumbs/item/breadcrumbs-item.svelte
new file mode 100644
index 00000000..775860d0
--- /dev/null
+++ b/packages/ui/src/lib/breadcrumbs/item/breadcrumbs-item.svelte
@@ -0,0 +1,42 @@
+
+
+{#if !hidden}
+
+ {@render children?.()}
+
+{/if}
diff --git a/packages/ui/src/lib/breadcrumbs/link/breadcrumbs-link.svelte b/packages/ui/src/lib/breadcrumbs/link/breadcrumbs-link.svelte
new file mode 100644
index 00000000..4d5c7839
--- /dev/null
+++ b/packages/ui/src/lib/breadcrumbs/link/breadcrumbs-link.svelte
@@ -0,0 +1,47 @@
+
+
+
+
+ {@render children?.()}
+
diff --git a/packages/ui/src/lib/breadcrumbs/list/breadcrumbs-list.svelte b/packages/ui/src/lib/breadcrumbs/list/breadcrumbs-list.svelte
new file mode 100644
index 00000000..c45d7a97
--- /dev/null
+++ b/packages/ui/src/lib/breadcrumbs/list/breadcrumbs-list.svelte
@@ -0,0 +1,31 @@
+
+
+
+ {@render children?.()}
+
diff --git a/packages/ui/src/lib/breadcrumbs/root/breadcrumbs-collapse-test.svelte b/packages/ui/src/lib/breadcrumbs/root/breadcrumbs-collapse-test.svelte
new file mode 100644
index 00000000..ae8fe486
--- /dev/null
+++ b/packages/ui/src/lib/breadcrumbs/root/breadcrumbs-collapse-test.svelte
@@ -0,0 +1,36 @@
+
+
+
+
+
+ {#each pages as page, index (page)}
+
+
+ {page}
+
+
+ {#if index === 0}
+
+ {/if}
+ {/each}
+
+
+
+{expanded ? 'yes' : 'no'}
diff --git a/packages/ui/src/lib/breadcrumbs/root/breadcrumbs-collapse.test.ts b/packages/ui/src/lib/breadcrumbs/root/breadcrumbs-collapse.test.ts
new file mode 100644
index 00000000..c88afe43
--- /dev/null
+++ b/packages/ui/src/lib/breadcrumbs/root/breadcrumbs-collapse.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import { userEvent } from 'vitest/browser';
+import BreadcrumbsCollapseTest from './breadcrumbs-collapse-test.svelte';
+
+function pages() {
+ return Array.from(document.querySelectorAll('[data-testid="item"]')).map((item) =>
+ item.textContent?.trim()
+ );
+}
+
+function ellipsis() {
+ return document.querySelector('[data-testid="ellipsis"]');
+}
+
+describe('Breadcrumbs collapse', () => {
+ it('shows every page without a limit, and no ellipsis', () => {
+ render(BreadcrumbsCollapseTest);
+ expect(pages()).toEqual(['Home', 'Docs', 'Components', 'Navigation', 'Breadcrumbs']);
+ expect(ellipsis()).toBeNull();
+ expect(document.querySelector('[data-testid="nav"]')?.hasAttribute('data-collapsed')).toBe(
+ false
+ );
+ });
+
+ it('folds the middle past maxItems: the first page, the ellipsis, and the last ones', () => {
+ render(BreadcrumbsCollapseTest, { maxItems: 3 });
+
+ expect(pages()).toEqual(['Home', 'Navigation', 'Breadcrumbs']);
+ const button = ellipsis();
+ expect(button?.tagName).toBe('BUTTON');
+ expect(button?.getAttribute('aria-label')).toBe('Show 2 more pages');
+ expect(button?.textContent?.trim()).toBe('…');
+ expect(button?.closest('li')?.getAttribute('data-breadcrumbs-ellipsis')).toBe('true');
+ // The ellipsis sits between the first page and the rest.
+ const list = document.querySelector('[data-testid="list"]');
+ const children = Array.from(list?.children ?? []).map(
+ (child) =>
+ child.getAttribute('data-testid') ?? child.getAttribute('data-breadcrumbs-ellipsis')
+ );
+ expect(children).toEqual(['item', 'true', 'item', 'item']);
+ expect(document.querySelector('[data-testid="nav"]')?.getAttribute('data-collapsed')).toBe(
+ 'true'
+ );
+ });
+
+ it('does not fold a trail that fits', () => {
+ render(BreadcrumbsCollapseTest, { maxItems: 5 });
+ expect(pages()).toHaveLength(5);
+ expect(ellipsis()).toBeNull();
+ });
+
+ it('keeps two pages in view at least', () => {
+ render(BreadcrumbsCollapseTest, { maxItems: 1 });
+ expect(pages()).toEqual(['Home', 'Breadcrumbs']);
+ expect(ellipsis()?.getAttribute('aria-label')).toBe('Show 3 more pages');
+ });
+
+ it('unfolds on a press, and moves the focus to the first page that comes into view', async () => {
+ const onExpandedChange = vi.fn();
+ render(BreadcrumbsCollapseTest, { maxItems: 3, onExpandedChange });
+
+ ellipsis()?.focus();
+ await userEvent.keyboard('{Enter}');
+
+ await expect
+ .poll(() => pages())
+ .toEqual(['Home', 'Docs', 'Components', 'Navigation', 'Breadcrumbs']);
+ expect(ellipsis()).toBeNull();
+ expect(document.activeElement?.textContent?.trim()).toBe('Docs');
+ expect(document.querySelector('[data-testid="expanded"]')?.textContent).toBe('yes');
+ expect(onExpandedChange).toHaveBeenCalledWith(true);
+ });
+
+ it('speaks the locale', () => {
+ render(BreadcrumbsCollapseTest, { maxItems: 3, locale: 'es' });
+ expect(ellipsis()?.getAttribute('aria-label')).toBe('Mostrar 2 páginas más');
+ });
+});
diff --git a/packages/ui/src/lib/breadcrumbs/root/breadcrumbs-root.svelte b/packages/ui/src/lib/breadcrumbs/root/breadcrumbs-root.svelte
new file mode 100644
index 00000000..5f6519ec
--- /dev/null
+++ b/packages/ui/src/lib/breadcrumbs/root/breadcrumbs-root.svelte
@@ -0,0 +1,112 @@
+
+
+
+ {@render children?.()}
+
diff --git a/packages/ui/src/lib/breadcrumbs/root/breadcrumbs-ssr.test.ts b/packages/ui/src/lib/breadcrumbs/root/breadcrumbs-ssr.test.ts
new file mode 100644
index 00000000..fa2bc98d
--- /dev/null
+++ b/packages/ui/src/lib/breadcrumbs/root/breadcrumbs-ssr.test.ts
@@ -0,0 +1,16 @@
+// @vitest-environment node
+
+import { describe, expect, it } from 'vitest';
+import { render } from 'svelte/server';
+import BreadcrumbsTest from './breadcrumbs-test.svelte';
+
+describe('Breadcrumbs SSR', () => {
+ it('renders the landmark, the list and the current page before hydration', () => {
+ const { body } = render(BreadcrumbsTest, { props: { locale: 'fr-FR' } });
+ expect(body).toContain('
+ import { Breadcrumbs } from '../index';
+ import LocaleProvider from '../../locale-provider/index';
+
+ let {
+ locale = undefined,
+ label = undefined,
+ disabled = false,
+ currentHref = true
+ }: { locale?: string; label?: string; disabled?: boolean; currentHref?: boolean } = $props();
+
+ let nav = $state(null);
+
+
+Before
+
+
+
+
+ Home
+
+
+
+ Docs
+ ›
+
+
+
+ Breadcrumbs
+
+
+
+
+
+{nav?.tagName ?? 'none'}
diff --git a/packages/ui/src/lib/breadcrumbs/root/breadcrumbs.test.ts b/packages/ui/src/lib/breadcrumbs/root/breadcrumbs.test.ts
new file mode 100644
index 00000000..b27fad9b
--- /dev/null
+++ b/packages/ui/src/lib/breadcrumbs/root/breadcrumbs.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import { userEvent } from 'vitest/browser';
+import BreadcrumbsTest from './breadcrumbs-test.svelte';
+
+function byTestId(id: string): T {
+ const element = document.querySelector(`[data-testid="${id}"]`);
+ if (!element) throw new Error(`No element with data-testid="${id}"`);
+ return element;
+}
+
+describe('Breadcrumbs', () => {
+ it('is a nav landmark named Breadcrumb, with an ordered list of items', () => {
+ render(BreadcrumbsTest);
+ const nav = byTestId('nav');
+
+ expect(nav.tagName).toBe('NAV');
+ expect(nav.getAttribute('aria-label')).toBe('Breadcrumb');
+ expect(nav.className).toBe('trail');
+ const list = byTestId('list');
+ expect(list.tagName).toBe('OL');
+ expect(list.getAttribute('role')).toBe('list');
+ expect(list.parentElement).toBe(nav);
+ const items = list.querySelectorAll(':scope > li');
+ expect(items).toHaveLength(3);
+ expect(byTestId('nav-tag').textContent).toBe('NAV');
+ });
+
+ it('takes the name from the locale, and from aria-label first', () => {
+ render(BreadcrumbsTest, { locale: 'es-AR' });
+ expect(byTestId('nav').getAttribute('aria-label')).toBe('Ruta de navegación');
+
+ document.body.innerHTML = '';
+ render(BreadcrumbsTest, { locale: 'es-AR', label: 'Ubicación' });
+ expect(byTestId('nav').getAttribute('aria-label')).toBe('Ubicación');
+ });
+
+ it('marks the current page, and keeps it a link with an href', () => {
+ render(BreadcrumbsTest);
+ const current = byTestId('current');
+
+ expect(current.tagName).toBe('A');
+ expect(current.getAttribute('href')).toBe('/docs/breadcrumbs');
+ expect(current.getAttribute('aria-current')).toBe('page');
+ expect(current.getAttribute('data-current')).toBe('true');
+ expect(byTestId('home').hasAttribute('aria-current')).toBe(false);
+ });
+
+ it('is plain text without an href, and says it is current', () => {
+ render(BreadcrumbsTest, { currentHref: false });
+ const current = byTestId('current');
+
+ expect(current.tagName).toBe('SPAN');
+ expect(current.getAttribute('aria-current')).toBe('page');
+ expect(current.hasAttribute('href')).toBe(false);
+ });
+
+ it('hides the separators from the screen reader, with a slash by default', () => {
+ render(BreadcrumbsTest);
+ const separators = document.querySelectorAll('[data-breadcrumbs-separator]');
+
+ expect(separators).toHaveLength(2);
+ expect(separators[0].getAttribute('aria-hidden')).toBe('true');
+ expect(separators[0].textContent?.trim()).toBe('/');
+ expect(separators[1].textContent?.trim()).toBe('›');
+ // In the item, after the link: the list holds items only.
+ expect(separators[0].parentElement?.tagName).toBe('LI');
+ expect(separators[0].previousElementSibling).toBe(byTestId('home'));
+ });
+
+ it('takes a disabled link out of the tab order, and keeps its text', async () => {
+ render(BreadcrumbsTest, { disabled: true });
+ const docs = byTestId('docs');
+
+ expect(docs.tagName).toBe('SPAN');
+ expect(docs.hasAttribute('href')).toBe(false);
+ expect(docs.getAttribute('aria-disabled')).toBe('true');
+ expect(docs.getAttribute('data-disabled')).toBe('true');
+ expect(docs.textContent?.trim()).toBe('Docs');
+
+ byTestId('before').focus();
+ await userEvent.keyboard('{Tab}');
+ expect(document.activeElement).toBe(byTestId('home'));
+ await userEvent.keyboard('{Tab}');
+ expect(document.activeElement).toBe(byTestId('current'));
+ });
+
+ it('moves through the links with Tab, in the order of the trail', async () => {
+ render(BreadcrumbsTest);
+ byTestId('before').focus();
+
+ await userEvent.keyboard('{Tab}');
+ expect(document.activeElement).toBe(byTestId('home'));
+ await userEvent.keyboard('{Tab}');
+ expect(document.activeElement).toBe(byTestId('docs'));
+ await userEvent.keyboard('{Tab}');
+ expect(document.activeElement).toBe(byTestId('current'));
+ });
+});
diff --git a/packages/ui/src/lib/breadcrumbs/root/context.ts b/packages/ui/src/lib/breadcrumbs/root/context.ts
new file mode 100644
index 00000000..105acb15
--- /dev/null
+++ b/packages/ui/src/lib/breadcrumbs/root/context.ts
@@ -0,0 +1,36 @@
+import { getContext, setContext } from 'svelte';
+
+const KEY = Symbol('breadcrumbs');
+
+export type BreadcrumbsContext = {
+ /** The count of items in the trail, on the screen or not. */
+ readonly count: number;
+ /** The count of items past which the middle of the trail folds. `Infinity` without a limit. */
+ readonly maxItems: number;
+ /** True while the middle of the trail is out of view. */
+ readonly collapsed: boolean;
+ /** The count of items out of view. */
+ readonly hiddenCount: number;
+ /** Registers an item, and answers a function that unregisters it. */
+ register: (id: string) => () => void;
+ /** True for an item in the folded middle of the trail. */
+ isHidden: (id: string) => boolean;
+ /** Unfolds the trail, and moves the focus to the first item that comes into view. */
+ expand: () => void;
+};
+
+export function setBreadcrumbsContext(context: BreadcrumbsContext) {
+ setContext(KEY, context);
+}
+
+export function getBreadcrumbsContext(): BreadcrumbsContext | undefined {
+ return getContext(KEY);
+}
+
+export function useBreadcrumbsContext(part = 'Breadcrumbs'): BreadcrumbsContext {
+ const context = getBreadcrumbsContext();
+ if (!context) {
+ throw new Error(`${part} must be used within Breadcrumbs.Root.`);
+ }
+ return context;
+}
diff --git a/packages/ui/src/lib/breadcrumbs/separator/breadcrumbs-separator.svelte b/packages/ui/src/lib/breadcrumbs/separator/breadcrumbs-separator.svelte
new file mode 100644
index 00000000..5823db08
--- /dev/null
+++ b/packages/ui/src/lib/breadcrumbs/separator/breadcrumbs-separator.svelte
@@ -0,0 +1,35 @@
+
+
+
+ {#if children}{@render children()}{:else}/{/if}
+
diff --git a/packages/ui/src/lib/breadcrumbs/types.ts b/packages/ui/src/lib/breadcrumbs/types.ts
new file mode 100644
index 00000000..c66095a9
--- /dev/null
+++ b/packages/ui/src/lib/breadcrumbs/types.ts
@@ -0,0 +1,101 @@
+import type { Snippet } from 'svelte';
+import type {
+ HTMLAnchorAttributes,
+ HTMLAttributes,
+ HTMLButtonAttributes,
+ HTMLOlAttributes
+} from 'svelte/elements';
+
+export type { BreadcrumbsContext } from './root/context.js';
+
+export type BreadcrumbsRootProps = Omit, 'children' | 'class'> & {
+ /** `Breadcrumbs.List`. */
+ children?: Snippet;
+ /** The CSS class names of the element. */
+ class?: string;
+ /**
+ * The name of the landmark. Without it, the name is "Breadcrumb" in the locale of
+ * `LocaleProvider`. Give one when the page has two sets of breadcrumbs.
+ */
+ 'aria-label'?: string;
+ /**
+ * The count of items past which the middle of the trail folds behind a `Breadcrumbs.Ellipsis`.
+ * The first item and the last `maxItems - 1` stay in view. Without it, every item is in view.
+ */
+ maxItems?: number;
+ /** True once the user unfolds the trail. Use `bind:expanded` to read it, or to fold it again. */
+ expanded?: boolean;
+ /** Called when the trail unfolds or folds. */
+ onExpandedChange?: (expanded: boolean) => void;
+ /** The `` element. Use `bind:element` to read it. */
+ element?: HTMLElement | null;
+};
+
+export type BreadcrumbsListProps = Omit & {
+ /** The items. */
+ children?: Snippet;
+ /** The CSS class names of the element. */
+ class?: string;
+ /** The `` element. Use `bind:element` to read it. */
+ element?: HTMLOListElement | null;
+};
+
+export type BreadcrumbsItemProps = Omit, 'children' | 'class'> & {
+ /** `Breadcrumbs.Link`, and a `Breadcrumbs.Separator` after it. */
+ children?: Snippet;
+ /** The CSS class names of the element. */
+ class?: string;
+ /** The `` element. Use `bind:element` to read it. */
+ element?: HTMLLIElement | null;
+};
+
+export type BreadcrumbsLinkProps = Omit<
+ HTMLAnchorAttributes,
+ 'children' | 'class' | 'href' | 'aria-current' | 'aria-disabled'
+> & {
+ /** The text of the link. */
+ children?: Snippet;
+ /** The CSS class names of the element. */
+ class?: string;
+ /** The address of the page. Without it, the link is plain text. */
+ href?: string;
+ /**
+ * Marks the page the user is on. The link gets `aria-current="page"`. Set it on the last
+ * item: a screen reader user then knows where the trail ends.
+ */
+ current?: boolean;
+ /** Takes the link out of use: it keeps its text, and it loses its `href` and its tab stop. */
+ disabled?: boolean;
+ /** The `` element, or the `` without an `href`. Use `bind:element` to read it. */
+ element?: HTMLAnchorElement | HTMLSpanElement | null;
+};
+
+export type BreadcrumbsEllipsisProps = Omit<
+ HTMLButtonAttributes,
+ 'children' | 'class' | 'type' | 'aria-label'
+> & {
+ /** The content of the button. Three dots without children. */
+ children?: Snippet;
+ /** The CSS class names of the button. */
+ class?: string;
+ /** The CSS class names of the `` around the button. */
+ itemClass?: string;
+ /** The sign after the button, such as a `Breadcrumbs.Separator`. */
+ separator?: Snippet;
+ /** The name of the button. Without it, "Show N more pages" in the locale of `LocaleProvider`. */
+ 'aria-label'?: string;
+ /** The button element, while the trail is folded. Use `bind:element` to read it. */
+ element?: HTMLButtonElement | null;
+};
+
+export type BreadcrumbsSeparatorProps = Omit<
+ HTMLAttributes,
+ 'children' | 'class' | 'aria-hidden'
+> & {
+ /** The sign between two items. A slash without children. */
+ children?: Snippet;
+ /** The CSS class names of the element. */
+ class?: string;
+ /** The `` element. Use `bind:element` to read it. */
+ element?: HTMLSpanElement | null;
+};
diff --git a/packages/ui/src/lib/index.ts b/packages/ui/src/lib/index.ts
index 2bb81e4c..57f372f9 100644
--- a/packages/ui/src/lib/index.ts
+++ b/packages/ui/src/lib/index.ts
@@ -3,6 +3,8 @@
// Components (namespace exports)
export { Accordion } from './accordion/index.js';
export { Autocomplete } from './autocomplete/index.js';
+export { Avatar } from './avatar/index.js';
+export { Breadcrumbs } from './breadcrumbs/index.js';
export { Button } from './button/index.js';
export { Checkbox } from './checkbox/index.js';
export { CheckboxGroup } from './checkbox-group/index.js';
@@ -35,6 +37,7 @@ export { Tree } from './tree/index.js';
// Simple components
export { default as Input } from './input/index.js';
export { default as Label } from './label/index.js';
+export { default as Separator } from './separator/index.js';
export { default as Dropzone } from './dropzone/index.js';
export { default as LocaleProvider } from './locale-provider/index.js';
export { Portal } from './portal/index.js';
@@ -42,6 +45,7 @@ export { OverflowRow, type OverflowRowState } from './overflow-row/index.js';
export { default as TextArea } from './textarea/index.js';
export * from './locale-provider/index.js';
+export type { SeparatorOrientation, SeparatorProps } from './separator/index.js';
// Re-export named exports from components
export * from './accordion/index.js';
@@ -49,6 +53,8 @@ export * from './autocomplete/index.js';
export * from './button/index.js';
export * from './checkbox/index.js';
export * from './checkbox-group/index.js';
+export * from './avatar/index.js';
+export * from './breadcrumbs/index.js';
export * from './collapsible/index.js';
export * from './combobox/index.js';
export * from './calendar/index.js';
diff --git a/packages/ui/src/lib/internal/localized-strings.ts b/packages/ui/src/lib/internal/localized-strings.ts
index 505bea76..a0db04f8 100644
--- a/packages/ui/src/lib/internal/localized-strings.ts
+++ b/packages/ui/src/lib/internal/localized-strings.ts
@@ -338,6 +338,30 @@ const LOCALIZED_STRINGS = {
de: '{count} Elemente nach {label} verschoben',
it: '{count} elementi spostati in {label}'
},
+ 'avatar.more': {
+ en: '{count} more',
+ es: '{count} más',
+ pt: '{count} mais',
+ fr: '{count} de plus',
+ de: '{count} weitere',
+ it: '{count} altri'
+ },
+ 'breadcrumbs.label': {
+ en: 'Breadcrumb',
+ es: 'Ruta de navegación',
+ pt: 'Trilha de navegação',
+ fr: "Fil d'Ariane",
+ de: 'Brotkrumennavigation',
+ it: 'Percorso di navigazione'
+ },
+ 'breadcrumbs.showMore': {
+ en: 'Show {count} more pages',
+ es: 'Mostrar {count} páginas más',
+ pt: 'Mostrar mais {count} páginas',
+ fr: 'Afficher {count} pages de plus',
+ de: '{count} weitere Seiten anzeigen',
+ it: 'Mostra altre {count} pagine'
+ },
'segment.empty': {
en: 'Empty',
es: 'Vacío',
diff --git a/packages/ui/src/lib/separator/README.md b/packages/ui/src/lib/separator/README.md
new file mode 100644
index 00000000..fe18869d
--- /dev/null
+++ b/packages/ui/src/lib/separator/README.md
@@ -0,0 +1,35 @@
+# Separator
+
+## Description
+
+`Separator` is a line between two groups of content. A screen reader reads it as a break, the same as a native ` `. It has no size of its own: give it a border or a background, and a width or a height. With a `value`, it is a window splitter the keyboard and the pointer move.
+
+## Anatomy
+
+- `Separator`
+
+```svelte
+
+
+```
+
+## Usage guidelines
+
+- Use `orientation="vertical"` for a line between two items side by side, such as in a toolbar.
+- Use `decorative` for a line that repeats a break the content already makes, such as a border between two sections with headings. A screen reader then skips it.
+- Give the line a size with CSS. `data-orientation` is on the element for your styles.
+- Give `value` with `bind:value` for a window splitter, and size the pane before it from the value. Name it with `aria-label`, and point `aria-controls` at the pane.
+
+## API reference
+
+- `Separator`
+ - `orientation?: 'horizontal' | 'vertical'` (`'horizontal'`)
+ - `decorative?: boolean` (`false`)
+ - `value?: number` (bindable), `min?: number` (`0`), `max?: number` (`100`), `step?: number` (`1`), `largeStep?: number` (`10`), `onValueChange?: (value: number) => void`, `aria-controls?: string`, `disabled?: boolean`
+ - `element?: HTMLDivElement | null` (bindable)
+
+## Accessibility
+
+- The element is `role="separator"`. A vertical one has `aria-orientation="vertical"`: a separator is horizontal unless it says otherwise.
+- `decorative` makes it `role="none"`, which a screen reader skips.
+- A plain separator is not focusable. A splitter is a tab stop with `aria-valuenow`, `aria-valuemin`, `aria-valuemax` and a name. The arrows of its axis move it by `step`, or by `largeStep` with `Shift`. `Home` and `End` send it to the ends, and `Enter` folds and unfolds the pane. The horizontal arrows flip on a right-to-left page.
diff --git a/packages/ui/src/lib/separator/TODO.md b/packages/ui/src/lib/separator/TODO.md
new file mode 100644
index 00000000..f90ee865
--- /dev/null
+++ b/packages/ui/src/lib/separator/TODO.md
@@ -0,0 +1,11 @@
+# Separator TODO
+
+## Goal
+
+Track Separator work with a single mandatory TODO format.
+
+## Backlog
+
+- [x] [S][P0][Area: Accessibility][Owner: Unassigned][Target: Done] Render `role="separator"` with `aria-orientation="vertical"` for a vertical line, and `role="none"` for a decorative one.
+- [x] [S][P0][Area: Testing][Owner: Unassigned][Target: Done] Add coverage for the roles, the orientation, `bind:element` and SSR.
+- [x] [M][P3][Area: API][Owner: Unassigned][Target: Done] Add the window splitter behind `value`: a tab stop with `aria-valuenow`, the arrow keys, `Home`, `End`, `Enter`, and a pointer drag along the parent.
diff --git a/packages/ui/src/lib/separator/index.ts b/packages/ui/src/lib/separator/index.ts
new file mode 100644
index 00000000..2743b880
--- /dev/null
+++ b/packages/ui/src/lib/separator/index.ts
@@ -0,0 +1,8 @@
+import type { ComponentProps } from 'svelte';
+import type SeparatorComponent from './separator.svelte';
+import Separator from './separator.svelte';
+
+export type SeparatorProps = ComponentProps;
+export type { SeparatorOrientation } from './types.js';
+export { Separator };
+export default Separator;
diff --git a/packages/ui/src/lib/separator/separator-ssr.test.ts b/packages/ui/src/lib/separator/separator-ssr.test.ts
new file mode 100644
index 00000000..b01df43c
--- /dev/null
+++ b/packages/ui/src/lib/separator/separator-ssr.test.ts
@@ -0,0 +1,20 @@
+// @vitest-environment node
+
+import { describe, expect, it } from 'vitest';
+import { render } from 'svelte/server';
+import SeparatorTest from './separator-test.svelte';
+
+describe('Separator SSR', () => {
+ it('renders the role and the orientation before hydration', () => {
+ const { body } = render(SeparatorTest, { props: { orientation: 'vertical' } });
+ expect(body).toContain('role="separator"');
+ expect(body).toContain('aria-orientation="vertical"');
+ expect(body).toContain('data-orientation="vertical"');
+ });
+
+ it('renders no role for a decorative line', () => {
+ const { body } = render(SeparatorTest, { props: { decorative: true } });
+ expect(body).toContain('role="none"');
+ expect(body).not.toContain('aria-orientation');
+ });
+});
diff --git a/packages/ui/src/lib/separator/separator-test.svelte b/packages/ui/src/lib/separator/separator-test.svelte
new file mode 100644
index 00000000..98a72bad
--- /dev/null
+++ b/packages/ui/src/lib/separator/separator-test.svelte
@@ -0,0 +1,20 @@
+
+
+Before
+{#if show}
+
+{/if}
+After
+{element?.tagName ?? 'none'}
+ (show = false)}>Unmount
diff --git a/packages/ui/src/lib/separator/separator.svelte b/packages/ui/src/lib/separator/separator.svelte
new file mode 100644
index 00000000..3a1e5a1d
--- /dev/null
+++ b/packages/ui/src/lib/separator/separator.svelte
@@ -0,0 +1,254 @@
+
+
+
+
+
diff --git a/packages/ui/src/lib/separator/separator.test.ts b/packages/ui/src/lib/separator/separator.test.ts
new file mode 100644
index 00000000..789a7e53
--- /dev/null
+++ b/packages/ui/src/lib/separator/separator.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import { userEvent } from 'vitest/browser';
+import SeparatorTest from './separator-test.svelte';
+
+function separator() {
+ return document.querySelector('[data-separator-root="true"]')!;
+}
+
+describe('Separator', () => {
+ it('is a horizontal separator by default, with no aria-orientation', () => {
+ render(SeparatorTest);
+ const element = separator();
+
+ expect(element.getAttribute('role')).toBe('separator');
+ expect(element.hasAttribute('aria-orientation')).toBe(false);
+ expect(element.getAttribute('data-orientation')).toBe('horizontal');
+ expect(element.hasAttribute('data-decorative')).toBe(false);
+ expect(element.className).toBe('line');
+ expect(element.getAttribute('data-testid')).toBe('separator');
+ });
+
+ it('says it is vertical', () => {
+ render(SeparatorTest, { orientation: 'vertical' });
+ const element = separator();
+
+ expect(element.getAttribute('role')).toBe('separator');
+ expect(element.getAttribute('aria-orientation')).toBe('vertical');
+ expect(element.getAttribute('data-orientation')).toBe('vertical');
+ });
+
+ it('is out of the accessibility tree when decorative', () => {
+ render(SeparatorTest, { orientation: 'vertical', decorative: true });
+ const element = separator();
+
+ expect(element.getAttribute('role')).toBe('none');
+ expect(element.hasAttribute('aria-orientation')).toBe(false);
+ expect(element.getAttribute('data-decorative')).toBe('true');
+ expect(element.getAttribute('data-orientation')).toBe('vertical');
+ });
+
+ it('is not a tab stop', async () => {
+ render(SeparatorTest);
+ await userEvent.keyboard('{Tab}');
+ expect(document.activeElement?.tagName).toBe('BUTTON');
+ });
+
+ it('gives its element to bind:element, and takes it back on unmount', async () => {
+ const screen = render(SeparatorTest);
+ await expect
+ .poll(() => document.querySelector('[data-testid="element-tag"]')?.textContent)
+ .toBe('DIV');
+
+ await userEvent.click(screen.getByRole('button', { name: 'Unmount' }));
+ await expect
+ .poll(() => document.querySelector('[data-testid="element-tag"]')?.textContent)
+ .toBe('none');
+ });
+});
diff --git a/packages/ui/src/lib/separator/splitter-test.svelte b/packages/ui/src/lib/separator/splitter-test.svelte
new file mode 100644
index 00000000..ff816083
--- /dev/null
+++ b/packages/ui/src/lib/separator/splitter-test.svelte
@@ -0,0 +1,53 @@
+
+
+Before
+
+{value}
diff --git a/packages/ui/src/lib/separator/splitter.test.ts b/packages/ui/src/lib/separator/splitter.test.ts
new file mode 100644
index 00000000..08cb8b90
--- /dev/null
+++ b/packages/ui/src/lib/separator/splitter.test.ts
@@ -0,0 +1,159 @@
+import { tick } from 'svelte';
+import { describe, expect, it, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import { userEvent } from 'vitest/browser';
+import SplitterTest from './splitter-test.svelte';
+
+function splitter() {
+ return document.querySelector('[data-testid="splitter"]')!;
+}
+
+function value() {
+ return document.querySelector('[data-testid="value"]')?.textContent;
+}
+
+function pointer(target: Element, type: string, x: number, y: number) {
+ target.dispatchEvent(
+ new PointerEvent(type, {
+ pointerType: 'mouse',
+ pointerId: 1,
+ isPrimary: true,
+ button: 0,
+ buttons: 1,
+ clientX: x,
+ clientY: y,
+ bubbles: true,
+ cancelable: true
+ })
+ );
+}
+
+describe('Separator as a window splitter', () => {
+ it('is a focusable separator with a value, a range and a name', () => {
+ render(SplitterTest);
+ const element = splitter();
+
+ expect(element.getAttribute('role')).toBe('separator');
+ expect(element.getAttribute('aria-orientation')).toBe('vertical');
+ expect(element.getAttribute('tabindex')).toBe('0');
+ expect(element.getAttribute('aria-valuenow')).toBe('40');
+ expect(element.getAttribute('aria-valuemin')).toBe('0');
+ expect(element.getAttribute('aria-valuemax')).toBe('100');
+ expect(element.getAttribute('aria-label')).toBe('Resize the pane');
+ expect(element.getAttribute('aria-controls')).toBe('pane');
+ expect(element.getAttribute('data-movable')).toBe('true');
+ expect(element.style.touchAction).toBe('none');
+ });
+
+ it('moves with the arrow keys of its axis, by step and by largeStep with Shift', async () => {
+ const onValueChange = vi.fn();
+ render(SplitterTest, { onValueChange });
+ splitter().focus();
+
+ await userEvent.keyboard('{ArrowRight}');
+ expect(value()).toBe('41');
+ await userEvent.keyboard('{ArrowLeft}{ArrowLeft}');
+ expect(value()).toBe('39');
+ await userEvent.keyboard('{Shift>}{ArrowRight}{/Shift}');
+ expect(value()).toBe('49');
+ // The other axis does nothing to a vertical line.
+ await userEvent.keyboard('{ArrowUp}{ArrowDown}');
+ expect(value()).toBe('49');
+ expect(onValueChange).toHaveBeenLastCalledWith(49);
+ expect(splitter().getAttribute('aria-valuenow')).toBe('49');
+ expect(splitter().getAttribute('data-focus-visible')).toBe('true');
+ });
+
+ it('uses the vertical arrows for a horizontal line', async () => {
+ render(SplitterTest, { orientation: 'horizontal' });
+ splitter().focus();
+
+ await userEvent.keyboard('{ArrowDown}');
+ expect(value()).toBe('41');
+ await userEvent.keyboard('{ArrowRight}');
+ expect(value()).toBe('41');
+ });
+
+ it('goes to the ends with Home and End, and folds and unfolds with Enter', async () => {
+ render(SplitterTest);
+ splitter().focus();
+
+ await userEvent.keyboard('{End}');
+ expect(value()).toBe('100');
+ await userEvent.keyboard('{Home}');
+ expect(value()).toBe('0');
+ await userEvent.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}');
+ expect(value()).toBe('3');
+ await userEvent.keyboard('{Enter}');
+ expect(value()).toBe('0');
+ await userEvent.keyboard('{Enter}');
+ expect(value()).toBe('3');
+ });
+
+ it('stays in the range', async () => {
+ render(SplitterTest, { min: 20, max: 60 });
+ splitter().focus();
+
+ await userEvent.keyboard('{End}{ArrowRight}');
+ expect(value()).toBe('60');
+ await userEvent.keyboard('{Home}{ArrowLeft}');
+ expect(value()).toBe('20');
+ });
+
+ it('flips the horizontal arrows on a right-to-left page', async () => {
+ render(SplitterTest, { dir: 'rtl' });
+ splitter().focus();
+
+ await userEvent.keyboard('{ArrowLeft}');
+ expect(value()).toBe('41');
+ await userEvent.keyboard('{ArrowRight}');
+ expect(value()).toBe('40');
+ });
+
+ it('follows a drag along its parent', async () => {
+ render(SplitterTest);
+ const element = splitter();
+ const rect = element.getBoundingClientRect();
+ const x = rect.left + 4;
+ const y = rect.top + 100;
+
+ pointer(element, 'pointerdown', x, y);
+ await tick();
+ expect(element.getAttribute('data-dragging')).toBe('true');
+ expect(document.activeElement).toBe(element);
+ // The parent is 400 px wide: 80 px is a fifth of the range.
+ pointer(element, 'pointermove', x + 80, y);
+ await tick();
+ expect(value()).toBe('60');
+ pointer(element, 'pointermove', x - 400, y);
+ await tick();
+ expect(value()).toBe('0');
+ pointer(element, 'pointerup', x - 400, y);
+ await tick();
+ expect(element.hasAttribute('data-dragging')).toBe(false);
+ expect(element.hasAttribute('data-focus-visible')).toBe(false);
+ });
+
+ it('holds still when disabled, and says so', async () => {
+ render(SplitterTest, { disabled: true });
+ const element = splitter();
+ expect(element.getAttribute('aria-disabled')).toBe('true');
+ expect(element.getAttribute('tabindex')).toBe('0');
+
+ element.focus();
+ await userEvent.keyboard('{ArrowRight}');
+ expect(value()).toBe('40');
+ const rect = element.getBoundingClientRect();
+ pointer(element, 'pointerdown', rect.left + 4, rect.top + 100);
+ pointer(element, 'pointermove', rect.left + 84, rect.top + 100);
+ await tick();
+ expect(value()).toBe('40');
+ });
+
+ it('is a tab stop after the button before it', async () => {
+ render(SplitterTest);
+ document.querySelector('[data-testid="before"]')?.focus();
+ await userEvent.keyboard('{Tab}');
+ expect(document.activeElement).toBe(splitter());
+ });
+});
diff --git a/packages/ui/src/lib/separator/types.ts b/packages/ui/src/lib/separator/types.ts
new file mode 100644
index 00000000..c824ed1e
--- /dev/null
+++ b/packages/ui/src/lib/separator/types.ts
@@ -0,0 +1 @@
+export type SeparatorOrientation = 'horizontal' | 'vertical';