` | Nested sub-feed (`role="feed"`). Child articles inherit level + 1. |
+| `Sentinel` | `
` | Invisible IntersectionObserver probe. Fires `onLoadMore` when visible. |
+
+## Props
+
+### `Provider`
+
+| Prop | Type | Default | Description |
+| ----------------- | --------------------- | ---------- | ------------------------------------------------------------------------ |
+| `id` | `string` | auto | DOM id. |
+| `totalItems` | `number \| undefined` | — | Total across all pages. `undefined` when unknown (infinite / unbounded). |
+| `busy` | `boolean` | `false` | Whether more items are loading. Sets `aria-busy`. |
+| `onLoadMore` | `() => void` | — | Called when the user PageDown's past the last article. |
+| `aria-label` | `string` | translated | Accessible name (default `'Feed'`). |
+| `aria-labelledby` | `string` | — | External label id. |
+
+Snippet props: `{ busy }`.
+
+### `Article`
+
+| Prop | Type | Default | Description |
+| ----------- | -------- | ------------- | --------------------------------------------------------------------------- |
+| `id` | `string` | auto | DOM id. |
+| `posinset` | `number` | auto (1-based DOM index) | Explicit position in the full set (use when DOM order ≠ feed order). |
+
+Snippet props: `{ index, total }` (0-based `index`, `total` is `totalItems` or `undefined`).
+
+### `ArticleTitle`
+
+| Prop | Type | Default | Description |
+| ------- | ------------------------ | ---------------------------------------------- | -------------------------------------------------- |
+| `id` | `string` | auto | DOM id. |
+| `level` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | derived from thread depth (3 top, 4 nested, …) | Heading level. Explicit value wins. |
+
+### `ArticleDescription`
+
+| Prop | Type | Default | Description |
+| ---- | -------- | ------- | ----------- |
+| `id` | `string` | auto | DOM id. |
+
+### `Thread`
+
+| Prop | Type | Default | Description |
+| ---- | -------- | ------- | ----------- |
+| `id` | `string` | auto | DOM id. |
+
+### `Sentinel`
+
+| Prop | Type | Default | Description |
+| ------------- | --------------------------------------- | ------------------------ | ----------------------------------------------------------------------------- |
+| `id` | `string` | auto | DOM id. |
+| `rootMargin` | `string` | `'200px'` | IntersectionObserver rootMargin. |
+| `threshold` | `number \| number[]` | `0` | IntersectionObserver threshold. |
+| `root` | `Element \| Document \| null` | `null` | Observer root (`null` = viewport). |
+| `onIntersect` | `() => void` | parent Feed's `onLoadMore` | Callback when the sentinel enters the observed zone. |
+| `disabled` | `boolean` | `false` | Skip observer setup. |
+
+## ARIA
+
+| Part | Attribute | Value |
+| ------------------- | ------------------ | ---------------------------------------- |
+| Provider | `role` | `feed` |
+| Provider | `aria-busy` | `true` when `busy` |
+| Provider | `aria-label` | Translated default or override |
+| Article | `role` | `article` |
+| Article | `tabindex` | `0` (focusable for PageUp/PageDown nav) |
+| Article | `aria-posinset` | Explicit `posinset` or auto-computed |
+| Article | `aria-setsize` | `totalItems`, or `-1` when unknown (per APG) |
+| Article | `aria-labelledby` | Auto-wired to `ArticleTitle` id |
+| Article | `aria-describedby` | Auto-wired to `ArticleDescription` id |
+| ArticleTitle | `role` | `heading` |
+| ArticleTitle | `aria-level` | As prop (`3` by default) |
+
+Per [the APG](https://www.w3.org/WAI/ARIA/apg/patterns/feed/):
+- `aria-setsize="-1"` means "total unknown" (soma emits this when `totalItems` is undefined).
+- `aria-busy="true"` signals that new articles are being loaded; AT may defer announcements.
+
+## Data Attributes
+
+| Part | Attribute | Values |
+| ------------------- | --------------------------------- | ----------------------------------- |
+| Provider | `data-feed` | Always present |
+| Provider | `data-busy` | Present when busy |
+| Article | `data-feed-article` | Always present |
+| Article | `data-posinset` | Numeric position |
+| Article | `data-level` | Nesting depth (1 = top, 2 = Thread) |
+| ArticleTitle | `data-feed-article-title` | Always present |
+| ArticleDescription | `data-feed-article-description` | Always present |
+| Thread | `data-feed-thread` | Always present |
+| Thread | `data-level` | Thread depth |
+| Sentinel | `data-feed-sentinel` | Always present |
+| Sentinel | `data-intersecting` | Present while in the observed zone |
+
+## Keyboard
+
+| Key | Action |
+| ------------------------ | --------------------------------------------------------------------- |
+| `PageDown` (on Article) | Focus next Article. At the last one, calls `onLoadMore()`. |
+| `PageUp` (on Article) | Focus previous Article. |
+| `Ctrl+Home` | Focus first Article. |
+| `Ctrl+End` | Focus last Article. |
+| `Tab` (within Article) | Enters inner interactive elements per native tab order. |
+
+## Virtualization
+
+Compose with [`VirtualList`](../virtual-list/README.md) for long streams:
+
+```svelte
+
+ posts[i].id}>
+ {#snippet children({ virtualItems, totalSize })}
+
+
+ {#each virtualItems as v (v.key)}
+
+
+ {posts[v.index].title}
+ {posts[v.index].body}
+
+
+ {/each}
+
+
+ {/snippet}
+
+
+
+```
+
+PageUp/PageDown still work — the Provider's `getArticles()` queries the current DOM, so only mounted articles participate. Pair with `Feed.Sentinel` for auto-load at the viewport's tail.
+
+## Comparison
+
+| Feature | Soma | Radix | Ark UI | react-aria | APG |
+| ---------------------------------- | :--: | :---: | :----: | :--------: | :-: |
+| `role="feed"` root | ✅ | ❌ | ❌ | ❌¹ | ✅ |
+| `role="article"` children | ✅ | — | — | — | ✅ |
+| `aria-posinset` / `aria-setsize` | ✅ | — | — | — | ✅ |
+| `aria-busy` during load | ✅ | — | — | — | ✅ |
+| PageUp / PageDown navigation | ✅ | — | — | — | ✅ |
+| `onLoadMore` callback | ✅ | — | — | — | — |
+| Ctrl+Home / Ctrl+End jumps | ✅ | — | — | — | ✅ |
+| Auto-wired title/description | ✅ | — | — | — | — |
+| Nested threads (`Feed.Thread`) | ✅ | — | — | ❌ | — |
+| Auto-derived heading level | ✅ | — | — | ❌ | — |
+| IntersectionObserver auto-load | ✅ | — | — | ✅ | — |
+| Virtualization (via compose) | ✅² | — | — | ✅ | — |
+
+¹ react-aria recommends `GridList` or `ListBox` rather than `role=feed`. Soma ships Feed because WAI-ARIA defines the pattern and it's the correct semantic for activity streams.
+² Compose with `VirtualList` — see the section above. No coupling between soma and the virtualizer.
+
+## Usage
+
+### Basic infinite feed
+
+```svelte
+
+
+
+ {#each posts as post (post.id)}
+
+ {post.title}
+ {post.body}
+
+ {/each}
+
+```
+
+### Known total (paginated feed)
+
+```svelte
+
+ {#each visible as c (c.id)}
+
+
+
+ {/each}
+
+```
+
+### IntersectionObserver auto-load
+
+Drop a `Feed.Sentinel` inside the Provider. When it scrolls into view it fires the Provider's `onLoadMore` automatically:
+
+```svelte
+
+ {#each posts as p (p.id)}
+ ...
+ {/each}
+
+
+```
+
+Keyboard users still trigger `onLoadMore` via `PageDown` at the last article — the Sentinel is additive for pointer/scroll users.
+
+### Threaded replies
+
+Wrap replies in a `Feed.Thread` inside the parent Article. Nested articles automatically inherit:
+
+- `aria-setsize` scoped to the thread (not the root feed).
+- `aria-level` on the heading bumped by one per nesting level.
+- `data-level` attribute on both Thread and Article.
+
+```svelte
+
+ {#each posts as post (post.id)}
+
+ {post.title}
+ {post.body}
+
+ {#if post.replies?.length}
+
+ {#each post.replies as r (r.id)}
+
+
+ Reply from {r.author}
+ {r.body}
+
+ {/each}
+
+ {/if}
+
+ {/each}
+
+```
+
+Threads nest to any depth — the level computation walks up through enclosing Threads. Each Thread is a fresh `role="feed"` with its own posinset/setsize.
diff --git a/src/uix/soma/components/feed/components/feed-article-description.svelte b/src/uix/soma/components/feed/components/feed-article-description.svelte
new file mode 100644
index 000000000..0cbb1b195
--- /dev/null
+++ b/src/uix/soma/components/feed/components/feed-article-description.svelte
@@ -0,0 +1,35 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/src/uix/soma/components/feed/components/feed-article-title.svelte b/src/uix/soma/components/feed/components/feed-article-title.svelte
new file mode 100644
index 000000000..7ca99f403
--- /dev/null
+++ b/src/uix/soma/components/feed/components/feed-article-title.svelte
@@ -0,0 +1,37 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/src/uix/soma/components/feed/components/feed-article.svelte b/src/uix/soma/components/feed/components/feed-article.svelte
new file mode 100644
index 000000000..c3d35aa73
--- /dev/null
+++ b/src/uix/soma/components/feed/components/feed-article.svelte
@@ -0,0 +1,37 @@
+
+
+{#if child}
+ {@render child({ ...provider.snippetProps, props: mergedProps })}
+{:else}
+
+ {@render children?.(provider.snippetProps)}
+
+{/if}
diff --git a/src/uix/soma/components/feed/components/feed-sentinel.svelte b/src/uix/soma/components/feed/components/feed-sentinel.svelte
new file mode 100644
index 000000000..55f1b03e5
--- /dev/null
+++ b/src/uix/soma/components/feed/components/feed-sentinel.svelte
@@ -0,0 +1,45 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/src/uix/soma/components/feed/components/feed-thread.svelte b/src/uix/soma/components/feed/components/feed-thread.svelte
new file mode 100644
index 000000000..58d56afc0
--- /dev/null
+++ b/src/uix/soma/components/feed/components/feed-thread.svelte
@@ -0,0 +1,35 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/src/uix/soma/components/feed/components/feed.svelte b/src/uix/soma/components/feed/components/feed.svelte
new file mode 100644
index 000000000..a434b996d
--- /dev/null
+++ b/src/uix/soma/components/feed/components/feed.svelte
@@ -0,0 +1,45 @@
+
+
+{#if child}
+ {@render child({ ...provider.snippetProps, props: mergedProps })}
+{:else}
+
+ {@render children?.(provider.snippetProps)}
+
+{/if}
diff --git a/src/uix/soma/components/feed/exports.ts b/src/uix/soma/components/feed/exports.ts
new file mode 100644
index 000000000..499c09ab4
--- /dev/null
+++ b/src/uix/soma/components/feed/exports.ts
@@ -0,0 +1,17 @@
+export { default as Provider } from './components/feed.svelte';
+export { default as Article } from './components/feed-article.svelte';
+export { default as ArticleTitle } from './components/feed-article-title.svelte';
+export { default as ArticleDescription } from './components/feed-article-description.svelte';
+export { default as Thread } from './components/feed-thread.svelte';
+export { default as Sentinel } from './components/feed-sentinel.svelte';
+
+export type {
+ FeedProps as ProviderProps,
+ FeedArticleProps as ArticleProps,
+ FeedArticleTitleProps as ArticleTitleProps,
+ FeedArticleDescriptionProps as ArticleDescriptionProps,
+ FeedThreadProps as ThreadProps,
+ FeedSentinelProps as SentinelProps,
+ FeedProviderSnippetProps as ProviderSnippetProps,
+ FeedArticleSnippetProps as ArticleSnippetProps
+} from './types';
diff --git a/src/uix/soma/components/feed/feed-provider.svelte.ts b/src/uix/soma/components/feed/feed-provider.svelte.ts
new file mode 100644
index 000000000..42641830f
--- /dev/null
+++ b/src/uix/soma/components/feed/feed-provider.svelte.ts
@@ -0,0 +1,434 @@
+import { Provider, context, type WithRefOpts } from '../../provider';
+import {
+ createAttrs,
+ registerContract,
+ boolToEmptyStrOrUndef
+} from '../../attrs';
+import { readableActive, state, type Active, type ActiveProps } from '../../reactive';
+import type { SomaKeyboardEvent } from '../../types';
+import { KEYS } from '../../keyboard';
+import { Soma } from '../../core/soma.svelte';
+import { FEED_LANGS } from './langs';
+
+const attrs = createAttrs({
+ component: 'feed',
+ parts: [
+ 'root',
+ 'article',
+ 'article-title',
+ 'article-description',
+ 'thread',
+ 'sentinel'
+ ] as const
+});
+
+registerContract({
+ name: 'feed',
+ version: 1,
+ parts: {
+ root: [{ attr: 'data-busy', description: 'Loading more items' }],
+ article: [
+ { attr: 'data-posinset', description: 'Position in the full set' },
+ { attr: 'data-level', description: 'Nesting depth (1 = top level)' }
+ ],
+ 'article-title': [],
+ 'article-description': [],
+ thread: [{ attr: 'data-level', description: 'Nesting depth (1 = top level)' }],
+ sentinel: [
+ { attr: 'data-intersecting', description: 'Currently in viewport' }
+ ]
+ }
+});
+
+// ── Root provider ──────────────────────────────────────────────────────────
+
+interface FeedOpts
+ extends WithRefOpts,
+ ActiveProps<{
+ totalItems: number | undefined;
+ busy: boolean;
+ ariaLabel: string | undefined;
+ ariaLabelledby: string | undefined;
+ onLoadMore: (() => void) | undefined;
+ }> {}
+
+export class FeedProvider extends Provider
{
+ static readonly ctx = context('Feed');
+ static get(): FeedProvider | undefined {
+ return this.ctx.getOr(undefined) as FeedProvider | undefined;
+ }
+ static require(): FeedProvider {
+ return this.ctx.get();
+ }
+
+ static create(opts: FeedOpts) {
+ return new FeedProvider(opts);
+ }
+
+ readonly soma = Soma.get();
+
+ private constructor(opts: FeedOpts) {
+ super(opts, 'Feed', 'root', attrs.root, FeedProvider.ctx);
+ }
+
+ readonly resolvedAriaLabel: Active = readableActive(() => {
+ if (this.opts.ariaLabelledby.current) return undefined;
+ return (
+ this.opts.ariaLabel.current ||
+ this.soma?.langs.ts(FEED_LANGS.LABEL) ||
+ undefined
+ );
+ });
+
+ /** All article elements, in DOM order. */
+ getArticles(): HTMLElement[] {
+ const root = this.opts.ref.current;
+ if (!root) return [];
+ return Array.from(root.querySelectorAll(`[${attrs.article}]`)).filter(
+ (el) => el.closest(`[${attrs.root}]`) === root
+ );
+ }
+
+ /**
+ * Articles that belong directly to the given feed container (root or
+ * thread). Used to compute posinset/setsize per nesting level.
+ */
+ getArticlesInContainer(container: HTMLElement): HTMLElement[] {
+ return Array.from(container.querySelectorAll(`[${attrs.article}]`)).filter(
+ (el) => {
+ // Closest enclosing feed container (root OR thread).
+ const enclosing = el.parentElement?.closest(
+ `[${attrs.root}], [${attrs.thread}]`
+ );
+ return enclosing === container;
+ }
+ );
+ }
+
+ private focusAt(index: number) {
+ const articles = this.getArticles();
+ if (articles.length === 0) return;
+ const clamped = Math.max(0, Math.min(articles.length - 1, index));
+ articles[clamped]?.focus();
+ }
+
+ /** Fired on the feed root (capture Page nav) and on articles (for end-of-feed). */
+ handleArticleKeydown(e: SomaKeyboardEvent) {
+ const articles = this.getArticles();
+ if (articles.length === 0) return;
+
+ const current = e.currentTarget;
+ const currentIndex = articles.indexOf(current);
+
+ if (e.key === KEYS.PAGE_DOWN) {
+ e.preventDefault();
+ if (currentIndex >= articles.length - 1) {
+ // End of feed — request more.
+ this.opts.onLoadMore.current?.();
+ return;
+ }
+ this.focusAt(currentIndex + 1);
+ return;
+ }
+ if (e.key === KEYS.PAGE_UP) {
+ e.preventDefault();
+ this.focusAt(currentIndex - 1);
+ return;
+ }
+ if (e.key === KEYS.HOME && e.ctrlKey) {
+ e.preventDefault();
+ this.focusAt(0);
+ return;
+ }
+ if (e.key === KEYS.END && e.ctrlKey) {
+ e.preventDefault();
+ this.focusAt(articles.length - 1);
+ return;
+ }
+ }
+
+ readonly snippetProps = $derived.by(() => ({
+ busy: this.opts.busy.current
+ }));
+
+ readonly props = $derived.by(() =>
+ this.assertProps({
+ ...this.baseProps,
+ role: 'feed' as const,
+ 'aria-label': this.resolvedAriaLabel.current,
+ 'aria-labelledby': this.opts.ariaLabelledby.current,
+ 'aria-busy': this.opts.busy.current ? true : undefined,
+ 'data-busy': boolToEmptyStrOrUndef(this.opts.busy.current)
+ } as const)
+ );
+}
+
+// ── Thread (nested article container) ──────────────────────────────────────
+
+interface FeedThreadOpts extends WithRefOpts {}
+
+/**
+ * Contains a nested sub-feed of articles (replies, sub-comments). Emits
+ * `role="feed"` so assistive tech recognises the nested stream. Children
+ * `Feed.Article`s inherit the correct nesting level automatically — their
+ * heading defaults become `aria-level={parent_level + 1}`.
+ */
+export class FeedThreadProvider extends Provider {
+ static readonly ctx = context('FeedThread');
+ static get(): FeedThreadProvider | undefined {
+ return this.ctx.getOr(undefined) as FeedThreadProvider | undefined;
+ }
+
+ static create(opts: FeedThreadOpts) {
+ return new FeedThreadProvider(opts);
+ }
+
+ readonly parentArticle: FeedArticleProvider;
+ readonly level: number;
+
+ private constructor(opts: FeedThreadOpts) {
+ super(opts, 'Feed', 'thread', attrs.thread, FeedThreadProvider.ctx);
+ this.parentArticle = FeedArticleProvider.require();
+ this.level = this.parentArticle.level + 1;
+ }
+
+ readonly props = $derived.by(() =>
+ this.assertProps({
+ ...this.baseProps,
+ role: 'feed' as const,
+ 'aria-labelledby': this.parentArticle.titleId.current || undefined,
+ 'data-level': this.level
+ } as const)
+ );
+}
+
+// ── Article ────────────────────────────────────────────────────────────────
+
+interface FeedArticleOpts
+ extends WithRefOpts,
+ ActiveProps<{ posinset: number | undefined }> {}
+
+export class FeedArticleProvider extends Provider {
+ static readonly ctx = context('FeedArticle');
+ static get(): FeedArticleProvider | undefined {
+ return this.ctx.getOr(undefined) as FeedArticleProvider | undefined;
+ }
+ static require(): FeedArticleProvider {
+ return this.ctx.get();
+ }
+
+ static create(opts: FeedArticleOpts) {
+ return new FeedArticleProvider(opts);
+ }
+
+ readonly provider: FeedProvider;
+ readonly thread: FeedThreadProvider | undefined;
+ /** 1 for top-level, +1 for each enclosing `Feed.Thread`. */
+ readonly level: number;
+
+ /** Registered by child parts on mount. */
+ titleId = state('');
+ descriptionId = state('');
+
+ private constructor(opts: FeedArticleOpts) {
+ super(opts, 'Feed', 'article', attrs.article, FeedArticleProvider.ctx);
+ this.provider = FeedProvider.require();
+ this.thread = FeedThreadProvider.get();
+ this.level = this.thread ? this.thread.level : 1;
+ }
+
+ /** Enclosing feed container — either the root or a `Feed.Thread`. */
+ readonly containerEl = $derived.by(() => {
+ const el = this.opts.ref.current;
+ if (!el) return undefined;
+ return el.parentElement?.closest(
+ `[${attrs.root}], [${attrs.thread}]`
+ ) ?? undefined;
+ });
+
+ /** DOM-order index of this article in its container (1-based). */
+ readonly autoPosinset = $derived.by(() => {
+ const el = this.opts.ref.current;
+ const container = this.containerEl;
+ if (!el || !container) return undefined;
+ const siblings = this.provider.getArticlesInContainer(container);
+ const idx = siblings.indexOf(el);
+ return idx >= 0 ? idx + 1 : undefined;
+ });
+
+ readonly resolvedPosinset = $derived.by(
+ () => this.opts.posinset.current ?? this.autoPosinset
+ );
+
+ readonly resolvedSetsize = $derived.by(() => {
+ // Only the root feed uses the consumer-supplied totalItems; nested
+ // threads always get -1 (unknown) or the sibling count.
+ if (this.level === 1) {
+ const total = this.provider.opts.totalItems.current;
+ if (total !== undefined) return total;
+ }
+ return -1;
+ });
+
+ readonly snippetProps = $derived.by(() => ({
+ index: (this.resolvedPosinset ?? 1) - 1,
+ total:
+ this.provider.opts.totalItems.current !== undefined
+ ? this.provider.opts.totalItems.current
+ : undefined
+ }));
+
+ readonly onkeydown = (e: KeyboardEvent) => {
+ this.provider.handleArticleKeydown(e as SomaKeyboardEvent);
+ };
+
+ readonly props = $derived.by(() => {
+ const posinset = this.resolvedPosinset;
+ return this.assertProps({
+ ...this.baseProps,
+ role: 'article' as const,
+ tabindex: 0,
+ 'aria-posinset': posinset,
+ 'aria-setsize': this.resolvedSetsize,
+ 'aria-labelledby': this.titleId.current || undefined,
+ 'aria-describedby': this.descriptionId.current || undefined,
+ 'data-posinset': posinset,
+ 'data-level': this.level,
+ onkeydown: this.onkeydown
+ } as const);
+ });
+}
+
+// ── ArticleTitle ───────────────────────────────────────────────────────────
+
+interface FeedArticleTitleOpts
+ extends WithRefOpts,
+ ActiveProps<{ level: number | undefined }> {}
+
+export class FeedArticleTitleProvider extends Provider {
+ static create(opts: FeedArticleTitleOpts) {
+ return new FeedArticleTitleProvider(opts);
+ }
+
+ readonly article: FeedArticleProvider;
+
+ private constructor(opts: FeedArticleTitleOpts) {
+ super(opts, 'Feed', 'article-title', attrs['article-title']);
+ this.article = FeedArticleProvider.require();
+ // A30: direct assignment.
+ this.article.titleId.current = opts.id.current;
+ }
+
+ /**
+ * Heading level: explicit prop wins; otherwise derive from the article's
+ * nesting depth (top-level → h3, nested once → h4, etc.). Clamped to 1–6.
+ */
+ readonly resolvedLevel = $derived.by(() => {
+ const explicit = this.opts.level.current;
+ if (explicit !== undefined && explicit !== null) return explicit;
+ const derived = 2 + this.article.level; // level=1 → 3; level=2 → 4; …
+ return Math.min(6, Math.max(1, derived));
+ });
+
+ readonly props = $derived.by(() =>
+ this.assertProps({
+ ...this.baseProps,
+ role: 'heading' as const,
+ 'aria-level': this.resolvedLevel
+ } as const)
+ );
+}
+
+// ── ArticleDescription ─────────────────────────────────────────────────────
+
+interface FeedArticleDescriptionOpts extends WithRefOpts {}
+
+export class FeedArticleDescriptionProvider extends Provider {
+ static create(opts: FeedArticleDescriptionOpts) {
+ return new FeedArticleDescriptionProvider(opts);
+ }
+
+ readonly article: FeedArticleProvider;
+
+ private constructor(opts: FeedArticleDescriptionOpts) {
+ super(opts, 'Feed', 'article-description', attrs['article-description']);
+ this.article = FeedArticleProvider.require();
+ this.article.descriptionId.current = opts.id.current;
+ }
+
+ readonly props = $derived.by(() =>
+ this.assertProps({
+ ...this.baseProps
+ } as const)
+ );
+}
+
+// ── Sentinel ───────────────────────────────────────────────────────────────
+
+interface FeedSentinelOpts
+ extends WithRefOpts,
+ ActiveProps<{
+ rootMargin: string;
+ threshold: number | number[];
+ root: Element | Document | null;
+ onIntersect: (() => void) | undefined;
+ disabled: boolean;
+ }> {}
+
+/**
+ * Invisible probe element that fires a callback when scrolled into view.
+ * When nested inside a `Feed.Provider`, defaults to calling the Feed's
+ * `onLoadMore`. When disabled or unmounted, the observer is torn down.
+ */
+export class FeedSentinelProvider extends Provider {
+ static create(opts: FeedSentinelOpts) {
+ return new FeedSentinelProvider(opts);
+ }
+
+ /** Optional — sentinel can live outside Feed.Provider (e.g. custom streams). */
+ readonly feed: FeedProvider | undefined;
+ intersecting = $state(false);
+
+ private constructor(opts: FeedSentinelOpts) {
+ super(opts, 'Feed', 'sentinel', attrs.sentinel);
+ this.feed = FeedProvider.get();
+
+ $effect(() => {
+ const el = opts.ref.current;
+ const disabled = opts.disabled.current;
+ if (!el || disabled || typeof IntersectionObserver === 'undefined') return;
+ const rootMargin = opts.rootMargin.current;
+ const threshold = opts.threshold.current;
+ const rootOpt = opts.root.current;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.target !== el) continue;
+ this.intersecting = entry.isIntersecting;
+ if (entry.isIntersecting) {
+ const handler = opts.onIntersect.current ?? this.feed?.opts.onLoadMore.current;
+ handler?.();
+ }
+ }
+ },
+ {
+ // `root: Document` isn't spec'd for IO — coerce to null.
+ root: rootOpt instanceof Document ? null : rootOpt,
+ rootMargin,
+ threshold
+ }
+ );
+ observer.observe(el);
+ return () => observer.disconnect();
+ });
+ }
+
+ readonly props = $derived.by(() =>
+ this.assertProps({
+ ...this.baseProps,
+ 'aria-hidden': true as const,
+ 'data-intersecting': boolToEmptyStrOrUndef(this.intersecting)
+ } as const)
+ );
+}
diff --git a/src/uix/soma/components/feed/index.ts b/src/uix/soma/components/feed/index.ts
new file mode 100644
index 000000000..8570a426a
--- /dev/null
+++ b/src/uix/soma/components/feed/index.ts
@@ -0,0 +1 @@
+export * from './exports';
diff --git a/src/uix/soma/components/feed/langs.ts b/src/uix/soma/components/feed/langs.ts
new file mode 100644
index 000000000..fd63888ce
--- /dev/null
+++ b/src/uix/soma/components/feed/langs.ts
@@ -0,0 +1,4 @@
+export const FEED_LANGS = {
+ LABEL: '#?components.feed.label|Feed',
+ ARTICLE_LABEL: '#?components.feed.article-label|Article'
+} as const;
diff --git a/src/uix/soma/components/feed/types.ts b/src/uix/soma/components/feed/types.ts
new file mode 100644
index 000000000..c677b9a4f
--- /dev/null
+++ b/src/uix/soma/components/feed/types.ts
@@ -0,0 +1,154 @@
+import type { Snippet } from 'svelte';
+import type { WithChild, Without } from '../../types';
+import type { PrimitiveDivAttributes } from '../../types';
+
+/** Snippet props for `Feed.Provider`. */
+export type FeedProviderSnippetProps = {
+ busy: boolean;
+};
+
+/** Snippet props for `Feed.Article`. */
+export type FeedArticleSnippetProps = {
+ index: number;
+ total: number | undefined;
+};
+
+// ── Root provider ──────────────────────────────────────────────────────────
+
+/**
+ * Props for `Feed.Provider`.
+ *
+ * Implements the WAI-ARIA Feed pattern (`role="feed"`) — a section of
+ * scrollable content where assistive tech navigates between `role="article"`
+ * children using PageUp / PageDown. Each article exposes its position via
+ * `aria-posinset` / `aria-setsize`.
+ *
+ * Use Feed for content streams (activity feeds, chat histories, threaded
+ * comments) where the article count can grow (lazy-loaded). Use `GridList`
+ * or `Listbox` for bounded item selection.
+ */
+export type FeedProps = WithChild<
+ {
+ /** DOM id. Auto-generated if omitted. */
+ id?: string;
+
+ /**
+ * Total number of items across all pages. Used for each article's
+ * `aria-setsize`. `undefined` means unknown (AT reads "n of unknown").
+ */
+ totalItems?: number | undefined;
+
+ /** Whether more items are currently loading. Sets `aria-busy`. @default false */
+ busy?: boolean;
+
+ /**
+ * Fires when the user reaches the end of the loaded items (PageDown on
+ * the last article, or infinite-scroll observer hit). Consumer loads
+ * more items and sets `busy` to `true` until the response arrives.
+ */
+ onLoadMore?: () => void;
+
+ /**
+ * Accessible name for the feed landmark. @default translated `'Feed'`
+ */
+ 'aria-label'?: string;
+ /** ID of an external label. */
+ 'aria-labelledby'?: string;
+
+ children?: Snippet<[FeedProviderSnippetProps]>;
+ },
+ FeedProviderSnippetProps
+> &
+ Without;
+
+// ── Article ────────────────────────────────────────────────────────────────
+
+/**
+ * Props for `Feed.Article`.
+ *
+ * Each article gets `role="article"`, `aria-posinset`, `aria-setsize`, and
+ * `aria-labelledby` wired to the `Feed.ArticleTitle` (when present).
+ *
+ * Articles are focusable (`tabindex=0`) so PageUp/PageDown navigation jumps
+ * between them. The focus handler on the Provider intercepts PageUp/PageDown.
+ */
+export type FeedArticleProps = WithChild<
+ {
+ id?: string;
+ /**
+ * 1-based position of this article in the full set (including
+ * unloaded pages). @default `index + 1` where `index` is the article's
+ * order in the Feed at mount time (auto-computed).
+ */
+ posinset?: number;
+ children?: Snippet<[FeedArticleSnippetProps]>;
+ },
+ FeedArticleSnippetProps
+> &
+ Without<
+ PrimitiveDivAttributes,
+ { role?: unknown; 'aria-posinset'?: unknown; 'aria-setsize'?: unknown }
+ >;
+
+// ── ArticleTitle ───────────────────────────────────────────────────────────
+
+/**
+ * Props for `Feed.ArticleTitle`. Linked via `aria-labelledby` on the
+ * enclosing `Article`. When `level` is omitted the heading level is derived
+ * from the enclosing `Feed.Thread` depth (top level → 3, nested → 4, etc.).
+ */
+export type FeedArticleTitleProps = WithChild<{
+ id?: string;
+ /** Heading level (1–6). Auto-derived from thread depth when omitted. */
+ level?: 1 | 2 | 3 | 4 | 5 | 6;
+}> &
+ Without;
+
+// ── Thread ─────────────────────────────────────────────────────────────────
+
+/**
+ * Props for `Feed.Thread` — a nested sub-feed of articles (replies /
+ * sub-comments). Renders as `role="feed"` so assistive tech recognises the
+ * nested stream. Child articles automatically inherit the correct nesting
+ * level.
+ */
+export type FeedThreadProps = WithChild<{
+ id?: string;
+}> &
+ Without;
+
+// ── Sentinel ───────────────────────────────────────────────────────────────
+
+/**
+ * Props for `Feed.Sentinel` — an invisible probe element that fires
+ * `onIntersect` when scrolled into view. Use to trigger `onLoadMore` when
+ * the user approaches the end of the feed.
+ */
+export type FeedSentinelProps = WithChild<{
+ id?: string;
+ /** Root margin for the IntersectionObserver. @default '200px' */
+ rootMargin?: string;
+ /** Intersection threshold 0–1. @default 0 */
+ threshold?: number | number[];
+ /**
+ * Observer root. `null` → viewport. @default null
+ */
+ root?: Element | Document | null;
+ /**
+ * Callback invoked when the sentinel enters the observed zone. When the
+ * sentinel is inside a `Feed.Provider`, defaults to firing the Feed's
+ * `onLoadMore`.
+ */
+ onIntersect?: () => void;
+ /** Disable the observer without unmounting. @default false */
+ disabled?: boolean;
+}> &
+ Without;
+
+// ── ArticleDescription ─────────────────────────────────────────────────────
+
+/** Props for `Feed.ArticleDescription` — linked via `aria-describedby`. */
+export type FeedArticleDescriptionProps = WithChild<{
+ id?: string;
+}> &
+ Without;
diff --git a/src/uix/soma/components/grid-list/README.md b/src/uix/soma/components/grid-list/README.md
new file mode 100644
index 000000000..ff2f79b9f
--- /dev/null
+++ b/src/uix/soma/components/grid-list/README.md
@@ -0,0 +1,239 @@
+# GridList
+
+A selectable list with grid semantics (`role="grid"`). Rows are focusable via roving tabindex; cells host content.
+
+## When to use GridList vs Listbox vs Table vs TreeGrid
+
+| Component | ARIA role | Use when |
+| -------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------- |
+| **GridList** (this) | `grid` | Flat selectable list where rows have multiple interactive elements (actions, links, per-row checkboxes). |
+| [`Listbox`](../listbox/README.md) | `listbox` | Flat selectable list where rows are atomic options (no inner actions). |
+| [`Table`](../table/README.md) | `table` | Tabular data with sorting / filtering / paginating. "Expandable rows" → `Table.RowDetail`. |
+| [`TreeGrid`](../tree-grid/README.md) | `treegrid` | Hierarchy + columns (file manager, org chart). `aria-level` auto-derived. |
+
+## Anatomy
+
+```svelte
+
+ {#each users as user (user.id)}
+
+
+
+
+ {user.name}
+ {user.role}
+
+ {/each}
+
+```
+
+## Parts
+
+| Part | Element | Description |
+| -------------------- | ---------- | ---------------------------------------------------------------------- |
+| `Provider` | `` | `role="grid"`, roving tabindex host, selection state. |
+| `Row` | `
` | `role="row"`. One per item. Focusable. |
+| `Cell` | `
` | `role="gridcell"`. Content slot within a row. |
+| `SelectionCheckbox` | `
` | `role="checkbox"`. Toggles the enclosing row's selection. |
+
+## Props
+
+### `Provider`
+
+| Prop | Type | Default | Description |
+| ----------------- | ------------------------------------- | ------------ | ------------------------------------------------------------------------ |
+| `id` | `string` | auto | DOM id. |
+| `value` | `string[]` | `[]` | Selected row values. Bindable. |
+| `onValueChange` | `(value: string[]) => void` | — | Fires on selection change. |
+| `selectionMode` | `'none' \| 'single' \| 'multiple'` | `'single'` | How many rows can be selected. |
+| `loop` | `boolean` | `false` | Whether arrow navigation wraps. |
+| `typeahead` | `boolean` | `true` | Whether pressing characters jumps to a matching row. |
+| `typeaheadTimeout`| `number` | `500` | Ms before the typeahead buffer resets. |
+| `disabled` | `boolean` | `false` | OR-merged with `Field.disabled`. |
+| `readonly` | `boolean` | `false` | OR-merged with `Field.readonly`. |
+| `required` | `boolean` | `false` | OR-merged with `Field.required`. |
+| `invalid` | `boolean` | `false` | OR-merged with `Field.invalid`. |
+| `name` | `string` | — | Name for form submission. Emits hidden inputs for each selected value. |
+| `aria-label` | `string` | translated | Accessible name. |
+| `aria-labelledby` | `string` | — | External label id. |
+
+Snippet props: `{ value, isEmpty, selectAll, clear }`.
+
+### `Row`
+
+| Prop | Type | Default | Description |
+| ------------ | --------- | ------- | ---------------------------------------------------------------- |
+| `id` | `string` | auto | DOM id. |
+| `value` | `string` | — | **Required.** Unique row value. |
+| `textValue` | `string` | — | Text used by typeahead. Falls back to `textContent`. |
+| `disabled` | `boolean` | `false` | Whether this row is disabled (skipped by keyboard navigation). |
+
+Snippet props: `{ selected, disabled }`.
+
+### `SelectionCheckbox`
+
+| Prop | Type | Default | Description |
+| ------------- | -------- | ---------- | ------------------------------------------------- |
+| `id` | `string` | auto | DOM id. |
+| `aria-label` | `string` | translated | Accessible name (defaults to `'Select row'`). |
+
+## ARIA
+
+| Part | Attribute | Value |
+| ------------------ | --------------------- | ----------------------------------------------------------- |
+| Provider | `role` | `grid` |
+| Provider | `aria-multiselectable`| `true` when `selectionMode='multiple'` |
+| Provider | `aria-disabled` | When disabled |
+| Provider | `aria-readonly` | When readonly |
+| Provider | `aria-invalid` | When invalid |
+| Provider | `aria-required` | When required |
+| Row | `role` | `row` |
+| Row | `aria-selected` | `true`/`false` (omitted in `none` mode) |
+| Row | `aria-disabled` | When disabled |
+| Row | `tabindex` | `0` for roving target, `-1` for others |
+| Cell | `role` | `gridcell` |
+| SelectionCheckbox | `role` | `checkbox` |
+| SelectionCheckbox | `aria-checked` | Matches the enclosing row's selection state |
+
+## Data Attributes
+
+| Part | Attribute | Values |
+| ------------------ | --------------------------------- | ------------------------------------------ |
+| Provider | `data-grid-list` | Always present |
+| Provider | `data-disabled` / `data-readonly` / `data-invalid` / `data-required` / `data-empty` | Flags |
+| Provider | `data-selection-mode` | `none \| single \| multiple` |
+| Row | `data-grid-list-row` | Always present |
+| Row | `data-state` | `selected \| unselected` |
+| Row | `data-highlighted` | Present when this is the roving target |
+| Row | `data-disabled` | When disabled |
+| Row | `data-value` / `data-text-value` | As props |
+| Cell | `data-grid-list-cell` | Always present |
+| SelectionCheckbox | `data-grid-list-selection-checkbox` | Always present |
+| SelectionCheckbox | `data-state` | `checked \| unchecked` |
+
+## Keyboard
+
+| Key | Action |
+| ---------------------------- | --------------------------------------------------------------------------------------- |
+| `ArrowDown` / `ArrowUp` | Move focus between rows (RTL-aware). |
+| `ArrowRight` (on row) | Enter the row — focus the first interactive descendant (button, link, checkbox). |
+| `ArrowRight` / `ArrowLeft` (on cell) | Move between focusable descendants within the row (RTL-aware). |
+| `ArrowLeft` (on first cell) | Return focus to the row. |
+| `Home` / `End` | Jump to first / last row. |
+| `PageUp` / `PageDown` | Jump 10 rows. |
+| `Space` | Toggle/replace selection on the focused row. |
+| `Shift+Space` / `Shift+Click`| Extend selection as a range (in `multiple` mode). |
+| `Ctrl/Meta + Click` | Toggle selection without clearing others (in `multiple` mode). |
+| `Ctrl/Meta + A` | Select all (in `multiple` mode). |
+| `Escape` | Clear selection. |
+| `a–z` / `0–9` | Typeahead to the next row matching the prefix. |
+
+### Cell-level 2D navigation
+
+Rows with interactive descendants (buttons, links, checkboxes) support a WAI-ARIA `grid`-pattern 2D focus model:
+
+1. Focus starts on the row (roving tabindex).
+2. `ArrowRight` enters the row — focus moves to the first interactive descendant.
+3. Within the row, `ArrowRight` / `ArrowLeft` cycle through interactive descendants (RTL-aware).
+4. `ArrowLeft` from the first descendant returns focus to the row.
+5. `ArrowUp` / `ArrowDown` always navigate between rows, regardless of whether focus is on a row or on a cell descendant.
+
+This matches [react-aria's GridList pattern](https://react-spectrum.adobe.com/react-aria/GridList.html) and the APG `grid` keyboard contract.
+
+## Virtualization
+
+Compose with [`VirtualList`](../virtual-list/README.md) when the dataset is large:
+
+```svelte
+
+ rows[i].id}>
+ {#snippet children({ virtualItems, totalSize })}
+
+
+ {#each virtualItems as v (v.key)}
+
+
+
+ {rows[v.index].name}
+
+
+ {/each}
+
+
+ {/snippet}
+
+
+```
+
+Keyboard navigation still works — the Provider's `getRows()` queries the DOM, so only mounted rows participate. The user can still `Home`/`End` to jump to the first/last mounted row; `PageUp/PageDown` jump a screenful.
+
+## Comparison
+
+| Feature | Soma | Radix | Ark UI | bits-ui | react-aria |
+| ----------------------------------- | :--: | :---: | :----: | :-----: | :--------: |
+| Dedicated `role=grid` list | ✅ | ❌ | ❌ | ❌ | ✅ |
+| Single / multiple / none selection | ✅ | — | — | — | ✅ |
+| Row-level actions (checkbox etc.) | ✅ | — | — | — | ✅ |
+| Typeahead | ✅ | — | — | — | ✅ |
+| Shift-range, Ctrl-toggle | ✅ | — | — | — | ✅ |
+| `Select all` via Ctrl+A | ✅ | — | — | — | ✅ |
+| Field OR-merge (disabled/invalid) | ✅ | — | — | — | ⚠️¹ |
+| Cell-level 2D navigation | ✅ | — | — | — | ✅ |
+| Virtualization (via compose) | ✅² | — | — | — | ✅ |
+| Drag-and-drop reorder | ✅³ | — | — | — | ✅ |
+
+¹ react-aria uses its own FieldContext; soma shares `Field.Provider` with all form primitives.
+² Compose with `VirtualList` — see the section above. No coupling between soma and the virtualizer.
+³ Compose with [`DragDrop`](../drag-drop/README.md) — wrap rows in `` and gap slots in ``. See the DragDrop README's "Sortable list" example.
+
+## Usage
+
+### Single-select
+
+```svelte
+
+ {#each options as option (option.id)}
+
+ {option.label}
+
+ {/each}
+
+```
+
+### Multi-select with checkboxes
+
+```svelte
+
+ {#snippet children({ selectAll, clear })}
+
+ Select all
+ Clear
+
+ {/snippet}
+ {#each users as user (user.id)}
+
+
+ {user.name}
+
+ removeUser(user.id)}>Remove
+
+
+ {/each}
+
+```
+
+Because the row has `role="row"` and the inner button is a real focusable element, Tab within a selected row reaches the "Remove" button while arrow keys keep navigating rows.
+
+### Field integration
+
+```svelte
+
+ Team members
+
+
+
+
+
+ {#if error}{error} {/if}
+
+```
diff --git a/src/uix/soma/components/grid-list/components/grid-list-cell.svelte b/src/uix/soma/components/grid-list/components/grid-list-cell.svelte
new file mode 100644
index 000000000..d9eca683a
--- /dev/null
+++ b/src/uix/soma/components/grid-list/components/grid-list-cell.svelte
@@ -0,0 +1,35 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/src/uix/soma/components/grid-list/components/grid-list-row.svelte b/src/uix/soma/components/grid-list/components/grid-list-row.svelte
new file mode 100644
index 000000000..7e95069fb
--- /dev/null
+++ b/src/uix/soma/components/grid-list/components/grid-list-row.svelte
@@ -0,0 +1,41 @@
+
+
+{#if child}
+ {@render child({ ...provider.snippetProps, props: mergedProps })}
+{:else}
+
+ {@render children?.(provider.snippetProps)}
+
+{/if}
diff --git a/src/uix/soma/components/grid-list/components/grid-list-selection-checkbox.svelte b/src/uix/soma/components/grid-list/components/grid-list-selection-checkbox.svelte
new file mode 100644
index 000000000..a57b5a782
--- /dev/null
+++ b/src/uix/soma/components/grid-list/components/grid-list-selection-checkbox.svelte
@@ -0,0 +1,37 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/src/uix/soma/components/grid-list/components/grid-list.svelte b/src/uix/soma/components/grid-list/components/grid-list.svelte
new file mode 100644
index 000000000..a333a9dd2
--- /dev/null
+++ b/src/uix/soma/components/grid-list/components/grid-list.svelte
@@ -0,0 +1,72 @@
+
+
+{#if child}
+ {@render child({ ...provider.snippetProps, props: mergedProps })}
+{:else}
+
+ {@render children?.(provider.snippetProps)}
+ {#if name}
+ {#each value as v (v)}
+
+ {/each}
+ {/if}
+
+{/if}
diff --git a/src/uix/soma/components/grid-list/exports.ts b/src/uix/soma/components/grid-list/exports.ts
new file mode 100644
index 000000000..efc112821
--- /dev/null
+++ b/src/uix/soma/components/grid-list/exports.ts
@@ -0,0 +1,14 @@
+export { default as Provider } from './components/grid-list.svelte';
+export { default as Row } from './components/grid-list-row.svelte';
+export { default as Cell } from './components/grid-list-cell.svelte';
+export { default as SelectionCheckbox } from './components/grid-list-selection-checkbox.svelte';
+
+export type {
+ GridListProps as ProviderProps,
+ GridListRowProps as RowProps,
+ GridListCellProps as CellProps,
+ GridListSelectionCheckboxProps as SelectionCheckboxProps,
+ GridListProviderSnippetProps as ProviderSnippetProps,
+ GridListRowSnippetProps as RowSnippetProps,
+ GridListSelectionMode
+} from './types';
diff --git a/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts b/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts
new file mode 100644
index 000000000..cfb68f745
--- /dev/null
+++ b/src/uix/soma/components/grid-list/grid-list-provider.svelte.ts
@@ -0,0 +1,650 @@
+import { Provider, context, type WithRefOpts } from '../../provider';
+import { createAttrs, registerContract, boolToEmptyStrOrUndef } from '../../attrs';
+import {
+ readableActive,
+ type Active,
+ type ActiveProps,
+ type StateProps
+} from '../../reactive';
+import type {
+ OnChangeFn,
+ Direction,
+ SomaKeyboardEvent,
+ SomaMouseEvent
+} from '../../types';
+import { KEYS, getDirectionalKeys } from '../../keyboard';
+import { Soma } from '../../core/soma.svelte';
+import { FieldProvider } from '../field/field-provider.svelte';
+import { GRID_LIST_LANGS } from './langs';
+import type { GridListSelectionMode } from './types';
+
+const attrs = createAttrs({
+ component: 'grid-list',
+ parts: ['root', 'row', 'cell', 'selection-checkbox'] as const
+});
+
+registerContract({
+ name: 'grid-list',
+ version: 1,
+ parts: {
+ root: [
+ { attr: 'data-disabled', description: 'Disabled' },
+ { attr: 'data-readonly', description: 'Read-only' },
+ { attr: 'data-invalid', description: 'Invalid' },
+ { attr: 'data-required', description: 'Required' },
+ { attr: 'data-empty', description: 'No rows selected' },
+ {
+ attr: 'data-selection-mode',
+ values: ['none', 'single', 'multiple'],
+ description: 'Selection mode'
+ }
+ ],
+ row: [
+ {
+ attr: 'data-state',
+ values: ['selected', 'unselected'],
+ description: 'Selection state'
+ },
+ { attr: 'data-highlighted', description: 'Currently focused row' },
+ { attr: 'data-disabled', description: 'Row disabled' }
+ ],
+ cell: [],
+ 'selection-checkbox': [
+ {
+ attr: 'data-state',
+ values: ['checked', 'unchecked'],
+ description: 'Matches parent row selection'
+ },
+ { attr: 'data-disabled', description: 'Disabled' }
+ ]
+ }
+});
+
+// ── Root provider ──────────────────────────────────────────────────────────
+
+interface GridListOpts
+ extends WithRefOpts,
+ StateProps<{ value: string[] }>,
+ ActiveProps<{
+ selectionMode: GridListSelectionMode;
+ loop: boolean;
+ typeahead: boolean;
+ typeaheadTimeout: number;
+ disabled: boolean;
+ readonly: boolean;
+ required: boolean;
+ invalid: boolean;
+ name: string | undefined;
+ ariaLabel: string | undefined;
+ ariaLabelledby: string | undefined;
+ onValueChange: OnChangeFn | undefined;
+ }> {}
+
+export class GridListProvider extends Provider {
+ static readonly ctx = context('GridList');
+ static get(): GridListProvider | undefined {
+ return this.ctx.getOr(undefined) as GridListProvider | undefined;
+ }
+ static require(): GridListProvider {
+ return this.ctx.get();
+ }
+
+ static create(opts: GridListOpts) {
+ return new GridListProvider(opts);
+ }
+
+ readonly soma = Soma.get();
+ readonly field = FieldProvider.get();
+
+ private typeaheadBuffer = '';
+ private typeaheadTimer: ReturnType | null = null;
+ /** Anchor for Shift+Click range selection. */
+ private anchor: string | null = null;
+
+ private constructor(opts: GridListOpts) {
+ super(opts, 'GridList', 'root', attrs.root, GridListProvider.ctx);
+
+ $effect(() => {
+ return () => {
+ if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
+ };
+ });
+ }
+
+ // ── Field-aware flags ───────────────────────────────────────────────────
+
+ readonly isDisabled = $derived.by(
+ () => this.opts.disabled.current || (this.field?.isDisabled ?? false)
+ );
+ readonly isReadonly = $derived.by(
+ () => this.opts.readonly.current || (this.field?.isReadonly ?? false)
+ );
+ readonly isRequired = $derived.by(
+ () => this.opts.required.current || (this.field?.isRequired ?? false)
+ );
+ readonly isInvalid = $derived.by(
+ () => this.opts.invalid.current || (this.field?.isInvalid ?? false)
+ );
+
+ readonly resolvedDir: Active = readableActive(
+ () => this.soma?.presentation.getDir() ?? 'ltr'
+ );
+
+ readonly resolvedAriaLabel: Active = readableActive(() =>
+ this.opts.ariaLabelledby.current
+ ? undefined
+ : this.opts.ariaLabel.current ||
+ this.soma?.langs.ts(GRID_LIST_LANGS.LABEL) ||
+ undefined
+ );
+
+ readonly isEmpty = $derived.by(() => this.opts.value.current.length === 0);
+
+ // ── DOM queries ─────────────────────────────────────────────────────────
+
+ /** Enabled row elements in DOM order, scoped to this root. */
+ getRows(): HTMLElement[] {
+ const root = this.opts.ref.current;
+ if (!root) return [];
+ return Array.from(
+ root.querySelectorAll(`[${attrs.row}]:not([data-disabled])`)
+ ).filter((el) => el.closest(`[${attrs.root}]`) === root);
+ }
+
+ /** All rows including disabled — used for range-select over contiguous rows. */
+ private getAllRows(): HTMLElement[] {
+ const root = this.opts.ref.current;
+ if (!root) return [];
+ return Array.from(root.querySelectorAll(`[${attrs.row}]`)).filter(
+ (el) => el.closest(`[${attrs.root}]`) === root
+ );
+ }
+
+ /** Single lifted derivation for the roving-tabindex target — O(1) per row. */
+ readonly rovingTargetEl = $derived.by(() => {
+ const rows = this.getRows();
+ if (rows.length === 0) return undefined;
+ const selected = new Set(this.opts.value.current);
+ return (
+ rows.find((el) => selected.has(el.getAttribute('data-value') ?? '')) ??
+ rows[0]
+ );
+ });
+
+ // ── Selection ───────────────────────────────────────────────────────────
+
+ isSelected(value: string): boolean {
+ return this.opts.value.current.includes(value);
+ }
+
+ select(value: string, mode: 'replace' | 'toggle' | 'range') {
+ if (this.isDisabled || this.isReadonly) return;
+ const selectionMode = this.opts.selectionMode.current;
+ if (selectionMode === 'none') return;
+ const current = this.opts.value.current;
+
+ let next: string[];
+ if (mode === 'range' && selectionMode === 'multiple' && this.anchor) {
+ next = this.computeRange(this.anchor, value);
+ } else if (mode === 'toggle' && selectionMode === 'multiple') {
+ next = current.includes(value)
+ ? current.filter((v) => v !== value)
+ : [...current, value];
+ } else {
+ // 'replace' or 'toggle' on single mode
+ next = current.length === 1 && current[0] === value ? current : [value];
+ }
+
+ if (next !== current) {
+ this.opts.value.current = next;
+ this.opts.onValueChange.current?.(next);
+ }
+ if (mode !== 'range') this.anchor = value;
+ }
+
+ private computeRange(from: string, to: string): string[] {
+ const all = this.getAllRows().map((el) => el.getAttribute('data-value') ?? '');
+ const i = all.indexOf(from);
+ const j = all.indexOf(to);
+ if (i < 0 || j < 0) return [to];
+ const [lo, hi] = i <= j ? [i, j] : [j, i];
+ return all.slice(lo, hi + 1);
+ }
+
+ selectAll() {
+ if (
+ this.isDisabled ||
+ this.isReadonly ||
+ this.opts.selectionMode.current !== 'multiple'
+ )
+ return;
+ const next = this.getRows().map((el) => el.getAttribute('data-value') ?? '');
+ this.opts.value.current = next;
+ this.opts.onValueChange.current?.(next);
+ }
+
+ clear() {
+ if (this.isDisabled || this.isReadonly) return;
+ if (this.opts.value.current.length === 0) return;
+ this.opts.value.current = [];
+ this.opts.onValueChange.current?.([]);
+ this.anchor = null;
+ }
+
+ // ── Keyboard navigation ─────────────────────────────────────────────────
+
+ private focusAt(index: number) {
+ const rows = this.getRows();
+ if (rows.length === 0) return;
+ const clamped = Math.max(0, Math.min(rows.length - 1, index));
+ rows[clamped]?.focus();
+ }
+
+ /**
+ * Interactive descendants of a row, in tab order. Used for cell-level
+ * 2D navigation (ArrowLeft/Right within a row).
+ */
+ private getFocusableCellsInRow(row: HTMLElement): HTMLElement[] {
+ const selector =
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([data-grid-list-row])';
+ return Array.from(row.querySelectorAll(selector)).filter(
+ (el) => el.closest(`[${attrs.row}]`) === row && !el.hasAttribute('data-grid-list-row')
+ );
+ }
+
+ /** ArrowRight on a row: move focus to the first focusable cell descendant. */
+ private focusFirstCellInRow(row: HTMLElement): boolean {
+ const cells = this.getFocusableCellsInRow(row);
+ if (cells.length === 0) return false;
+ cells[0]!.focus();
+ return true;
+ }
+
+ /** ArrowLeft/Right within a row: cycle between focusable descendants. */
+ handleCellKeydown(e: SomaKeyboardEvent) {
+ if (this.isDisabled) return;
+ const dir = this.resolvedDir.current;
+ const { nextKey, prevKey } = getDirectionalKeys(dir, 'horizontal');
+ const target = e.target as HTMLElement | null;
+ if (!target) return;
+ const row = target.closest(`[${attrs.row}]`);
+ if (!row) return;
+
+ if (e.key === nextKey) {
+ const cells = this.getFocusableCellsInRow(row);
+ const idx = cells.indexOf(target);
+ if (idx < 0) return;
+ if (idx < cells.length - 1) {
+ e.preventDefault();
+ e.stopPropagation();
+ cells[idx + 1]!.focus();
+ }
+ return;
+ }
+ if (e.key === prevKey) {
+ const cells = this.getFocusableCellsInRow(row);
+ const idx = cells.indexOf(target);
+ if (idx < 0) return;
+ e.preventDefault();
+ e.stopPropagation();
+ if (idx === 0) {
+ // Return focus to the row itself.
+ row.focus();
+ } else {
+ cells[idx - 1]!.focus();
+ }
+ return;
+ }
+ // ArrowUp/Down / Home / End bubble up to the row handler so vertical
+ // navigation still works while focus is inside a cell.
+ }
+
+ handleRowKeydown(e: SomaKeyboardEvent) {
+ if (this.isDisabled) return;
+
+ const dir = this.resolvedDir.current;
+ // GridList is always vertical for navigation (grid pattern).
+ const { nextKey, prevKey } = getDirectionalKeys(dir, 'vertical');
+ const { nextKey: hNextKey, prevKey: hPrevKey } = getDirectionalKeys(dir, 'horizontal');
+ const rows = this.getRows();
+ if (rows.length === 0) return;
+
+ const currentIndex = rows.indexOf(e.currentTarget);
+ const loop = this.opts.loop.current;
+
+ // Horizontal — only handled when focus is on the row itself (not a cell).
+ // From the row, ArrowRight enters the first focusable cell. ArrowLeft
+ // is a no-op (you're already at the row level).
+ if (e.target === e.currentTarget && e.key === hNextKey) {
+ if (this.focusFirstCellInRow(e.currentTarget)) {
+ e.preventDefault();
+ return;
+ }
+ }
+ if (e.target === e.currentTarget && e.key === hPrevKey) {
+ // no-op; consume so the event doesn't scroll the viewport.
+ return;
+ }
+
+ if (e.key === nextKey) {
+ e.preventDefault();
+ const next = loop
+ ? (currentIndex + 1) % rows.length
+ : Math.min(currentIndex + 1, rows.length - 1);
+ this.focusAt(next);
+ return;
+ }
+ if (e.key === prevKey) {
+ e.preventDefault();
+ const prev = loop
+ ? (currentIndex - 1 + rows.length) % rows.length
+ : Math.max(currentIndex - 1, 0);
+ this.focusAt(prev);
+ return;
+ }
+ if (e.key === KEYS.HOME) {
+ e.preventDefault();
+ this.focusAt(0);
+ return;
+ }
+ if (e.key === KEYS.END) {
+ e.preventDefault();
+ this.focusAt(rows.length - 1);
+ return;
+ }
+ if (e.key === KEYS.PAGE_UP) {
+ e.preventDefault();
+ this.focusAt(Math.max(currentIndex - 10, 0));
+ return;
+ }
+ if (e.key === KEYS.PAGE_DOWN) {
+ e.preventDefault();
+ this.focusAt(Math.min(currentIndex + 10, rows.length - 1));
+ return;
+ }
+
+ if (e.key === KEYS.SPACE) {
+ e.preventDefault();
+ const value = e.currentTarget.getAttribute('data-value') ?? undefined;
+ if (value !== undefined) {
+ const mode =
+ e.shiftKey && this.opts.selectionMode.current === 'multiple'
+ ? 'range'
+ : e.ctrlKey || e.metaKey
+ ? 'toggle'
+ : this.opts.selectionMode.current === 'multiple'
+ ? 'toggle'
+ : 'replace';
+ this.select(value, mode);
+ }
+ return;
+ }
+
+ if (
+ (e.ctrlKey || e.metaKey) &&
+ (e.key === 'a' || e.key === 'A') &&
+ this.opts.selectionMode.current === 'multiple'
+ ) {
+ e.preventDefault();
+ this.selectAll();
+ return;
+ }
+
+ if (e.key === KEYS.ESCAPE) {
+ if (this.opts.value.current.length > 0) {
+ e.preventDefault();
+ this.clear();
+ }
+ return;
+ }
+
+ if (
+ this.opts.typeahead.current &&
+ !e.ctrlKey &&
+ !e.metaKey &&
+ !e.altKey &&
+ e.key.length === 1 &&
+ /\S/.test(e.key)
+ ) {
+ this.handleTypeahead(e.key, currentIndex);
+ }
+ }
+
+ private handleTypeahead(char: string, fromIndex: number) {
+ this.typeaheadBuffer += char.toLowerCase();
+ if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
+ this.typeaheadTimer = setTimeout(() => {
+ this.typeaheadBuffer = '';
+ }, this.opts.typeaheadTimeout.current);
+
+ const rows = this.getRows();
+ if (rows.length === 0) return;
+ const needle = this.typeaheadBuffer;
+ const start = needle.length === 1 ? fromIndex + 1 : fromIndex;
+ for (let i = 0; i < rows.length; i++) {
+ const idx = (start + i) % rows.length;
+ const el = rows[idx];
+ if (!el) continue;
+ const text = (el.getAttribute('data-text-value') || el.textContent || '')
+ .trim()
+ .toLowerCase();
+ if (text.startsWith(needle)) {
+ el.focus();
+ return;
+ }
+ }
+ }
+
+ handleRowClick(value: string, e: SomaMouseEvent) {
+ if (this.isDisabled || this.isReadonly) return;
+ const mode =
+ e.shiftKey && this.opts.selectionMode.current === 'multiple'
+ ? 'range'
+ : (e.ctrlKey || e.metaKey) && this.opts.selectionMode.current === 'multiple'
+ ? 'toggle'
+ : 'replace';
+ this.select(value, mode);
+ e.currentTarget.focus();
+ }
+
+ // ── Root props ──────────────────────────────────────────────────────────
+
+ readonly snippetProps = $derived.by(() => ({
+ value: this.opts.value.current,
+ isEmpty: this.isEmpty,
+ selectAll: () => this.selectAll(),
+ clear: () => this.clear()
+ }));
+
+ readonly props = $derived.by(() =>
+ this.assertProps({
+ ...this.baseProps,
+ role: 'grid' as const,
+ 'aria-label': this.resolvedAriaLabel.current,
+ 'aria-labelledby': this.opts.ariaLabelledby.current,
+ 'aria-multiselectable':
+ this.opts.selectionMode.current === 'multiple' ? true : undefined,
+ 'aria-disabled': this.isDisabled ? true : undefined,
+ 'aria-readonly': this.isReadonly ? true : undefined,
+ 'aria-invalid': this.isInvalid ? true : undefined,
+ 'aria-required': this.isRequired ? true : undefined,
+ 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
+ 'data-readonly': boolToEmptyStrOrUndef(this.isReadonly),
+ 'data-invalid': boolToEmptyStrOrUndef(this.isInvalid),
+ 'data-required': boolToEmptyStrOrUndef(this.isRequired),
+ 'data-empty': boolToEmptyStrOrUndef(this.isEmpty),
+ 'data-selection-mode': this.opts.selectionMode.current
+ } as const)
+ );
+}
+
+// ── Row ────────────────────────────────────────────────────────────────────
+
+interface GridListRowOpts
+ extends WithRefOpts,
+ ActiveProps<{
+ value: string;
+ textValue: string | undefined;
+ disabled: boolean;
+ }> {}
+
+export class GridListRowProvider extends Provider {
+ static create(opts: GridListRowOpts) {
+ return new GridListRowProvider(opts);
+ }
+
+ readonly provider: GridListProvider;
+
+ private constructor(opts: GridListRowOpts) {
+ super(opts, 'GridList', 'row', attrs.row);
+ this.provider = GridListProvider.require();
+ }
+
+ readonly isSelected = $derived.by(() =>
+ this.provider.isSelected(this.opts.value.current)
+ );
+ readonly isDisabled = $derived.by(
+ () => this.opts.disabled.current || this.provider.isDisabled
+ );
+ readonly isRovingTarget = $derived.by(
+ () => this.opts.ref.current === this.provider.rovingTargetEl
+ );
+
+ readonly onclick = (e: MouseEvent) => {
+ if (this.isDisabled) return;
+ this.provider.handleRowClick(this.opts.value.current, e as SomaMouseEvent);
+ };
+
+ readonly onkeydown = (e: KeyboardEvent) => {
+ this.provider.handleRowKeydown(e as SomaKeyboardEvent);
+ };
+
+ readonly snippetProps = $derived.by(() => ({
+ selected: this.isSelected,
+ disabled: this.isDisabled
+ }));
+
+ readonly props = $derived.by(() =>
+ this.assertProps({
+ ...this.baseProps,
+ role: 'row' as const,
+ tabindex: this.isDisabled ? -1 : this.isRovingTarget ? 0 : -1,
+ 'aria-selected':
+ this.provider.opts.selectionMode.current === 'none'
+ ? undefined
+ : this.isSelected,
+ 'aria-disabled': this.isDisabled ? true : undefined,
+ 'data-value': this.opts.value.current,
+ 'data-text-value': this.opts.textValue.current,
+ 'data-state': this.isSelected ? ('selected' as const) : ('unselected' as const),
+ 'data-highlighted': boolToEmptyStrOrUndef(this.isRovingTarget),
+ 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
+ onclick: this.onclick,
+ onkeydown: this.onkeydown
+ } as const)
+ );
+}
+
+// ── Cell ───────────────────────────────────────────────────────────────────
+
+interface GridListCellOpts extends WithRefOpts {}
+
+export class GridListCellProvider extends Provider {
+ static create(opts: GridListCellOpts) {
+ return new GridListCellProvider(opts);
+ }
+
+ readonly provider: GridListProvider;
+
+ private constructor(opts: GridListCellOpts) {
+ super(opts, 'GridList', 'cell', attrs.cell);
+ this.provider = GridListProvider.require();
+ }
+
+ /**
+ * Delegate horizontal arrow navigation to the Provider so the user can
+ * move between focusable descendants of a row with ArrowLeft/Right, and
+ * ArrowLeft from the first cell returns focus to the row.
+ */
+ readonly onkeydown = (e: KeyboardEvent) => {
+ this.provider.handleCellKeydown(e as SomaKeyboardEvent);
+ };
+
+ readonly props = $derived.by(() =>
+ this.assertProps({
+ ...this.baseProps,
+ role: 'gridcell' as const,
+ onkeydown: this.onkeydown
+ } as const)
+ );
+}
+
+// ── SelectionCheckbox ──────────────────────────────────────────────────────
+
+interface GridListSelectionCheckboxOpts
+ extends WithRefOpts,
+ ActiveProps<{ ariaLabel: string | undefined }> {}
+
+export class GridListSelectionCheckboxProvider extends Provider {
+ static create(opts: GridListSelectionCheckboxOpts) {
+ return new GridListSelectionCheckboxProvider(opts);
+ }
+
+ readonly provider: GridListProvider;
+ /** Resolved from the nearest enclosing Row's value. */
+ private rowValue: string | null = null;
+
+ private constructor(opts: GridListSelectionCheckboxOpts) {
+ super(opts, 'GridList', 'selection-checkbox', attrs['selection-checkbox']);
+ this.provider = GridListProvider.require();
+ }
+
+ /** Read the enclosing row's value lazily (on click). */
+ private findRowValue(el: HTMLElement | null): string | null {
+ if (!el) return null;
+ const row = el.closest(`[${attrs.row}]`);
+ return row?.getAttribute('data-value') ?? null;
+ }
+
+ readonly isChecked = $derived.by(() => {
+ if (!this.rowValue) return false;
+ return this.provider.isSelected(this.rowValue);
+ });
+
+ readonly onclick = (e: MouseEvent) => {
+ e.stopPropagation();
+ const target = e.currentTarget as HTMLElement;
+ this.rowValue = this.findRowValue(target);
+ if (!this.rowValue) return;
+ this.provider.select(this.rowValue, 'toggle');
+ };
+
+ readonly resolvedAriaLabel = $derived.by(
+ () =>
+ this.opts.ariaLabel.current ||
+ this.provider.soma?.langs.ts(GRID_LIST_LANGS.SELECT_ROW) ||
+ undefined
+ );
+
+ readonly props = $derived.by(() => {
+ // Read current row association via DOM for aria-state — safe on every render.
+ const el = this.opts.ref.current;
+ this.rowValue = this.findRowValue(el);
+ const checked = this.rowValue
+ ? this.provider.isSelected(this.rowValue)
+ : false;
+ return this.assertProps({
+ ...this.baseProps,
+ type: 'button' as const,
+ role: 'checkbox' as const,
+ 'aria-checked': checked,
+ 'aria-label': this.resolvedAriaLabel,
+ 'aria-disabled': this.provider.isDisabled || this.provider.isReadonly || undefined,
+ 'data-state': checked ? ('checked' as const) : ('unchecked' as const),
+ 'data-disabled': boolToEmptyStrOrUndef(
+ this.provider.isDisabled || this.provider.isReadonly
+ ),
+ onclick: this.onclick
+ } as const);
+ });
+}
diff --git a/src/uix/soma/components/grid-list/index.ts b/src/uix/soma/components/grid-list/index.ts
new file mode 100644
index 000000000..8570a426a
--- /dev/null
+++ b/src/uix/soma/components/grid-list/index.ts
@@ -0,0 +1 @@
+export * from './exports';
diff --git a/src/uix/soma/components/grid-list/langs.ts b/src/uix/soma/components/grid-list/langs.ts
new file mode 100644
index 000000000..4b484459f
--- /dev/null
+++ b/src/uix/soma/components/grid-list/langs.ts
@@ -0,0 +1,5 @@
+export const GRID_LIST_LANGS = {
+ LABEL: '#?components.grid-list.label|Grid list',
+ SELECT_ROW: '#?components.grid-list.select-row|Select row',
+ SELECT_ALL: '#?components.grid-list.select-all|Select all'
+} as const;
diff --git a/src/uix/soma/components/grid-list/types.ts b/src/uix/soma/components/grid-list/types.ts
new file mode 100644
index 000000000..be3195240
--- /dev/null
+++ b/src/uix/soma/components/grid-list/types.ts
@@ -0,0 +1,132 @@
+import type { Snippet } from 'svelte';
+import type { WithChild, Without, OnChangeFn } from '../../types';
+import type {
+ PrimitiveDivAttributes,
+ PrimitiveButtonAttributes
+} from '../../types';
+
+/** Selection mode. */
+export type GridListSelectionMode = 'none' | 'single' | 'multiple';
+
+/** Snippet props for `GridList.Provider`. */
+export type GridListProviderSnippetProps = {
+ value: string[];
+ isEmpty: boolean;
+ selectAll: () => void;
+ clear: () => void;
+};
+
+/** Snippet props for `GridList.Row`. */
+export type GridListRowSnippetProps = {
+ selected: boolean;
+ disabled: boolean;
+};
+
+// ── Root provider ──────────────────────────────────────────────────────────
+
+/**
+ * Props for `GridList.Provider`.
+ *
+ * A selectable list with grid semantics (`role="grid"`) — rows are focusable,
+ * cells are the content slots. Supports keyboard navigation, multi-select via
+ * Shift/Ctrl click, typeahead, and form integration via a hidden input.
+ *
+ * Use `GridList` over `Listbox` when rows contain multiple interactive
+ * elements (actions, links, checkboxes) — the `grid` role lets assistive
+ * tech navigate those with Tab / cell-level commands. Use `Listbox` when
+ * rows are atomic options.
+ */
+export type GridListProps = WithChild<
+ {
+ /** DOM id. Auto-generated if omitted. */
+ id?: string;
+
+ /** Selected row values. Bindable. @default [] */
+ value?: string[];
+ /** Fires on selection change. */
+ onValueChange?: OnChangeFn;
+
+ /** Selection mode. @default 'single' */
+ selectionMode?: GridListSelectionMode;
+
+ /** Whether arrow navigation wraps at the ends. @default false */
+ loop?: boolean;
+ /** Whether typing characters jumps focus to matching row. @default true */
+ typeahead?: boolean;
+ /** Ms before the typeahead buffer resets. @default 500 */
+ typeaheadTimeout?: number;
+
+ // Flags — OR-merged with enclosing `Field.Provider`
+ /** @default false */
+ disabled?: boolean;
+ /** @default false */
+ readonly?: boolean;
+ /** @default false */
+ required?: boolean;
+ /** @default false */
+ invalid?: boolean;
+
+ /** Name for form submission. */
+ name?: string;
+
+ /**
+ * Accessible name for the grid. Ignored when `aria-labelledby` is set
+ * or when inside a `Field.Provider` with a `Field.Label`.
+ * @default translated `'Grid list'`
+ */
+ 'aria-label'?: string;
+ /** ID of an external element that labels this grid. */
+ 'aria-labelledby'?: string;
+
+ children?: Snippet<[GridListProviderSnippetProps]>;
+ },
+ GridListProviderSnippetProps
+> &
+ Without;
+
+// ── Row ────────────────────────────────────────────────────────────────────
+
+/**
+ * Props for `GridList.Row`.
+ *
+ * Rendered as `role="row"`. One row per `value`. Participates in roving
+ * tabindex — only the selected (or first) row has `tabindex=0`.
+ */
+export type GridListRowProps = WithChild<
+ {
+ id?: string;
+ /** Unique value for this row. Used for selection + typeahead. */
+ value: string;
+ /**
+ * Optional text used by typeahead search. Falls back to the row's
+ * `textContent`. Useful when the visible text is stylized (icons only, etc).
+ */
+ textValue?: string;
+ /** Whether the row is disabled. @default false */
+ disabled?: boolean;
+ children?: Snippet<[GridListRowSnippetProps]>;
+ },
+ GridListRowSnippetProps
+> &
+ Without;
+
+// ── Cell ───────────────────────────────────────────────────────────────────
+
+/** Props for `GridList.Cell` — rendered as `role="gridcell"`. */
+export type GridListCellProps = WithChild<{
+ id?: string;
+}> &
+ Without;
+
+// ── SelectionCheckbox ──────────────────────────────────────────────────────
+
+/**
+ * Props for `GridList.SelectionCheckbox` — toggles row selection, with an
+ * accessible label that matches the row's text.
+ */
+export type GridListSelectionCheckboxProps = WithChild<{
+ id?: string;
+ /** Accessible name override. @default translated `'Select row'` */
+ 'aria-label'?: string;
+}> &
+ Without;
diff --git a/src/uix/soma/components/index.ts b/src/uix/soma/components/index.ts
index a82da0bb2..0d62c47b3 100644
--- a/src/uix/soma/components/index.ts
+++ b/src/uix/soma/components/index.ts
@@ -1,9 +1,12 @@
export * as Accordion from './accordion';
export * as AlertDialog from './alert-dialog';
+export * as Announce from './announce';
+export * as Avatar from './avatar';
export * as Breadcrumb from './breadcrumb';
export * as Calendar from './calendar';
export * as Carousel from './carousel';
export * as Checkbox from './checkbox';
+export * as Clipboard from './clipboard';
export * as Collapsible from './collapsible';
export * as ColorField from './color-field';
export * as ColorPicker from './color-picker';
@@ -15,23 +18,29 @@ export * as DatePicker from './date-picker';
export * as DateRangeField from './date-range-field';
export * as DateRangePicker from './date-range-picker';
export * as Dialog from './dialog';
+export * as DragDrop from './drag-drop';
export * as DropdownMenu from './dropdown-menu';
export * as Editable from './editable';
+export * as Feed from './feed';
export * as Field from './field';
export * as FileUpload from './file-upload';
export * as Form from './form';
+export * as GridList from './grid-list';
export * as LinkPreview from './link-preview';
export * as Listbox from './listbox';
export * as Menubar from './menubar';
+export * as Meter from './meter';
export * as NavigationMenu from './navigation-menu';
export * as NumberField from './number-field';
export * as Pagination from './pagination';
export * as PinInput from './pin-input';
export * as Popover from './popover';
+export * as Progress from './progress';
export * as RadioGroup from './radio-group';
export * as RangeCalendar from './range-calendar';
export * as RatingGroup from './rating-group';
export * as ScrollArea from './scroll-area';
+export * as SearchField from './search-field';
export * as Select from './select';
export * as Slider from './slider';
export * as Splitter from './splitter';
@@ -39,6 +48,7 @@ export * as Stepper from './stepper';
export * as Switch from './switch';
export * as Table from './table';
export * as Tabs from './tabs';
+export * as TagGroup from './tag-group';
export * as TagsInput from './tags-input';
export * as TimeField from './time-field';
export * as TimePicker from './time-picker';
@@ -49,6 +59,7 @@ export * as Toggle from './toggle';
export * as ToggleGroup from './toggle-group';
export * as Toolbar from './toolbar';
export * as Tooltip from './tooltip';
+export * as TreeGrid from './tree-grid';
export * as TreeView from './tree-view';
export * as VirtualGrid from './virtual-grid';
export * as VirtualList from './virtual-list';
diff --git a/src/uix/soma/components/meter/README.md b/src/uix/soma/components/meter/README.md
new file mode 100644
index 000000000..8ccc418ce
--- /dev/null
+++ b/src/uix/soma/components/meter/README.md
@@ -0,0 +1,143 @@
+# Meter
+
+A `role="meter"` for a known-range value — disk usage, battery, score, rating strength. Unlike `Progress`, the value is static (not tied to a duration), and the component exposes zone semantics via `low` / `high` / `optimum` so themes can color each zone differently.
+
+Rule of thumb: use `Progress` for "how much of this task is done", use `Meter` for "where does this value sit in the range".
+
+## Anatomy
+
+```svelte
+
+
+
+```
+
+## Parts
+
+| Part | Element | Description |
+| ----------- | -------- | ------------------------------------------------------------------------ |
+| `Provider` | `` | `role="meter"`. Emits ARIA value attrs, `data-state`, `--soma-meter-value-pct`. |
+| `Indicator` | `
` | Decorative fill. Inherits `data-state` for per-zone styling. |
+
+## Props
+
+### `Provider`
+
+| Prop | Type | Default | Description |
+| ----------------- | -------- | ------------------------- | -------------------------------------------------------------------------------- |
+| `id` | `string` | auto | DOM id. |
+| `value` | `number` | `0` | Current value. |
+| `min` | `number` | `0` | Lower bound. |
+| `max` | `number` | `100` | Upper bound. |
+| `low` | `number` | `min` | Upper edge of the "below" zone. |
+| `high` | `number` | `max` | Lower edge of the "above" zone. |
+| `optimum` | `number` | `(low + high) / 2` | Optimum target. Read-only ARIA hint; doesn't affect zone computation. |
+| `valueText` | `string` | `${value} / ${max}` | Override for `aria-valuetext`. |
+| `aria-label` | `string` | translated `Meter` | Accessible name. |
+| `aria-labelledby` | `string` | — | External label. |
+
+## ARIA
+
+| Part | Attribute | Value |
+| -------- | ----------------- | ------------------------------- |
+| Provider | `role` | `meter` |
+| Provider | `aria-valuemin` | Value of `min` |
+| Provider | `aria-valuemax` | Value of `max` |
+| Provider | `aria-valuenow` | Value of `value` |
+| Provider | `aria-valuetext` | Resolved value text |
+| Provider | `aria-label` | Translated default or override |
+
+Note: `aria-valuelow`/`aria-valuehigh`/`aria-valueoptimum` are **not** emitted — those were part of the original HTML `
` element only, not the `role="meter"` ARIA semantics. Zones are expressed via `data-state` for styling instead.
+
+## Data Attributes
+
+| Part | Attribute | Values |
+| --------- | --------------------- | ------------------------------------- |
+| Provider | `data-meter` | Always present |
+| Provider | `data-state` | `below` \| `optimum` \| `above` |
+| Provider | `data-value` | Numeric value |
+| Provider | `data-min` | Numeric min |
+| Provider | `data-max` | Numeric max |
+| Indicator | `data-meter-indicator`| Always present |
+| Indicator | `data-state` | Inherited from provider |
+
+Zone resolution:
+- `value < low` → `below`
+- `value > high` → `above`
+- otherwise → `optimum`
+
+## CSS Variables
+
+| Variable | Part | Description |
+| -------------------------- | -------- | -------------------------------------------------- |
+| `--soma-meter-value-pct` | Provider | 0–100 percentage, clamped to `[min, max]`. |
+
+## Comparison
+
+| Feature | Soma | Radix | Ark UI | bits-ui | react-aria |
+| ------------------------------------ | :--: | :---: | :----: | :-----: | :--------: |
+| Dedicated component | ✅ | ❌¹ | ✅ | ❌ | ✅ |
+| `role="meter"` | ✅ | — | ✅ | — | ✅ |
+| `low` / `high` / `optimum` props | ✅ | — | ✅ | — | ✅² |
+| `data-state` zone attribute | ✅ | — | ✅ | — | ❌ |
+| `--soma-meter-value-pct` CSS var | ✅ | — | ✅ | — | ❌ |
+| Translated default `aria-label` | ✅ | — | ❌ | — | ❌ |
+| Indicator part | ✅ | — | ✅ | — | ❌ |
+| `aria-valuetext` override | ✅ | — | ✅ | — | ✅ |
+
+¹ Radix does not ship a Meter primitive.
+² react-aria's `Meter` returns `formatOptions` for percentage/unit formatting. Soma pushes that to `valueText` so the consumer controls exact text.
+
+## Usage
+
+### Battery indicator
+
+```svelte
+
+
+
+```
+
+### Password strength (3 zones → 3 colors)
+
+```svelte
+
+
+
+```
+
+```css
+[data-meter] {
+ position: relative;
+ height: 8px;
+ border-radius: 4px;
+ background: #e2e8f0;
+ overflow: hidden;
+}
+[data-meter-indicator] {
+ position: absolute;
+ inset: 0;
+ width: calc(var(--soma-meter-value-pct, 0) * 1%);
+ transition: width 150ms ease-out;
+}
+[data-meter][data-state='below'] [data-meter-indicator] { background: #dc2626; }
+[data-meter][data-state='optimum'] [data-meter-indicator] { background: #16a34a; }
+[data-meter][data-state='above'] [data-meter-indicator] { background: #f59e0b; }
+```
+
+### Disk usage (`above` is bad)
+
+```svelte
+
+
+
+```
+
+Zone coloring is inverted here — `above` (near-full) should be red, `optimum` (plenty free) should be green. Because soma emits `data-state` values instead of baking in a semantics, the theme is free to assign colors as the domain requires.
diff --git a/src/uix/soma/components/meter/components/meter-indicator.svelte b/src/uix/soma/components/meter/components/meter-indicator.svelte
new file mode 100644
index 000000000..4ac7f42c3
--- /dev/null
+++ b/src/uix/soma/components/meter/components/meter-indicator.svelte
@@ -0,0 +1,35 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/src/uix/soma/components/meter/components/meter.svelte b/src/uix/soma/components/meter/components/meter.svelte
new file mode 100644
index 000000000..907fa5a4c
--- /dev/null
+++ b/src/uix/soma/components/meter/components/meter.svelte
@@ -0,0 +1,53 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/src/uix/soma/components/meter/exports.ts b/src/uix/soma/components/meter/exports.ts
new file mode 100644
index 000000000..40d738749
--- /dev/null
+++ b/src/uix/soma/components/meter/exports.ts
@@ -0,0 +1,8 @@
+export { default as Provider } from './components/meter.svelte';
+export { default as Indicator } from './components/meter-indicator.svelte';
+
+export type {
+ MeterProps as ProviderProps,
+ MeterIndicatorProps as IndicatorProps,
+ MeterZone
+} from './types';
diff --git a/src/uix/soma/components/meter/index.ts b/src/uix/soma/components/meter/index.ts
new file mode 100644
index 000000000..8570a426a
--- /dev/null
+++ b/src/uix/soma/components/meter/index.ts
@@ -0,0 +1 @@
+export * from './exports';
diff --git a/src/uix/soma/components/meter/langs.ts b/src/uix/soma/components/meter/langs.ts
new file mode 100644
index 000000000..969677c1f
--- /dev/null
+++ b/src/uix/soma/components/meter/langs.ts
@@ -0,0 +1,4 @@
+/** Idlangref constants for the Meter component. */
+export const METER_LANGS = {
+ LABEL: '#?components.meter.label|Meter',
+} as const;
diff --git a/src/uix/soma/components/meter/meter-provider.svelte.ts b/src/uix/soma/components/meter/meter-provider.svelte.ts
new file mode 100644
index 000000000..d47793554
--- /dev/null
+++ b/src/uix/soma/components/meter/meter-provider.svelte.ts
@@ -0,0 +1,163 @@
+import { Provider, context, type WithRefOpts } from '../../provider';
+import { createAttrs, registerContract } from '../../attrs';
+import { readableActive, type Active, type ActiveProps } from '../../reactive';
+import { Soma } from '../../core/soma.svelte';
+import { METER_LANGS } from './langs';
+import type { MeterZone } from './types';
+
+const attrs = createAttrs({
+ component: 'meter',
+ parts: ['root', 'indicator'] as const
+});
+
+registerContract({
+ name: 'meter',
+ version: 1,
+ parts: {
+ root: [
+ {
+ attr: 'data-state',
+ values: ['below', 'optimum', 'above'],
+ description: 'Zone relative to low/high thresholds'
+ },
+ { attr: 'data-value', description: 'Current value' },
+ { attr: 'data-min', description: 'Min value' },
+ { attr: 'data-max', description: 'Max value' }
+ ],
+ indicator: [
+ {
+ attr: 'data-state',
+ values: ['below', 'optimum', 'above'],
+ description: 'Zone (inherited from root)'
+ }
+ ]
+ }
+});
+
+function computeZone(
+ value: number,
+ low: number,
+ high: number
+): MeterZone {
+ if (value < low) return 'below';
+ if (value > high) return 'above';
+ return 'optimum';
+}
+
+// ── Root provider ──────────────────────────────────────────────────────────
+
+interface MeterOpts
+ extends WithRefOpts,
+ ActiveProps<{
+ value: number;
+ min: number;
+ max: number;
+ low: number | undefined;
+ high: number | undefined;
+ optimum: number | undefined;
+ ariaLabel: string | undefined;
+ ariaLabelledby: string | undefined;
+ valueText: string | undefined;
+ }> {}
+
+export class MeterProvider extends Provider {
+ static readonly ctx = context('Meter');
+ static get(): MeterProvider | undefined {
+ return this.ctx.getOr(undefined) as MeterProvider | undefined;
+ }
+ static require(): MeterProvider {
+ return this.ctx.get();
+ }
+
+ static create(opts: MeterOpts) {
+ return new MeterProvider(opts);
+ }
+
+ readonly soma = Soma.get();
+
+ private constructor(opts: MeterOpts) {
+ super(opts, 'Meter', 'root', attrs.root, MeterProvider.ctx);
+ }
+
+ readonly resolvedLow = $derived.by(
+ () => this.opts.low.current ?? this.opts.min.current
+ );
+ readonly resolvedHigh = $derived.by(
+ () => this.opts.high.current ?? this.opts.max.current
+ );
+ readonly resolvedOptimum = $derived.by(
+ () => this.opts.optimum.current ?? (this.resolvedLow + this.resolvedHigh) / 2
+ );
+
+ readonly state: MeterZone = $derived.by(() =>
+ computeZone(this.opts.value.current, this.resolvedLow, this.resolvedHigh)
+ );
+
+ /** 0–100 percentage for CSS vars. Clamped to the min/max range. */
+ readonly percentage = $derived.by(() => {
+ const v = this.opts.value.current;
+ const min = this.opts.min.current;
+ const max = this.opts.max.current;
+ if (max <= min) return 0;
+ return Math.max(0, Math.min(100, ((v - min) / (max - min)) * 100));
+ });
+
+ readonly resolvedAriaLabel: Active = readableActive(() => {
+ if (this.opts.ariaLabelledby.current) return undefined;
+ return (
+ this.opts.ariaLabel.current ||
+ this.soma?.langs.ts(METER_LANGS.LABEL) ||
+ undefined
+ );
+ });
+
+ readonly resolvedValueText = $derived.by(() => {
+ const explicit = this.opts.valueText.current;
+ if (explicit) return explicit;
+ return `${this.opts.value.current} / ${this.opts.max.current}`;
+ });
+
+ readonly props = $derived.by(() =>
+ this.assertProps({
+ ...this.baseProps,
+ role: 'meter' as const,
+ 'aria-valuemin': this.opts.min.current,
+ 'aria-valuemax': this.opts.max.current,
+ 'aria-valuenow': this.opts.value.current,
+ 'aria-valuetext': this.resolvedValueText,
+ 'aria-label': this.resolvedAriaLabel.current,
+ 'aria-labelledby': this.opts.ariaLabelledby.current,
+ 'data-state': this.state,
+ 'data-value': this.opts.value.current,
+ 'data-min': this.opts.min.current,
+ 'data-max': this.opts.max.current,
+ style: {
+ '--soma-meter-value-pct': `${this.percentage}`
+ }
+ } as const)
+ );
+}
+
+// ── Indicator ──────────────────────────────────────────────────────────────
+
+interface MeterIndicatorOpts extends WithRefOpts {}
+
+export class MeterIndicatorProvider extends Provider {
+ static create(opts: MeterIndicatorOpts) {
+ return new MeterIndicatorProvider(opts);
+ }
+
+ readonly provider: MeterProvider;
+
+ private constructor(opts: MeterIndicatorOpts) {
+ super(opts, 'Meter', 'indicator', attrs.indicator);
+ this.provider = MeterProvider.require();
+ }
+
+ readonly props = $derived.by(() =>
+ this.assertProps({
+ ...this.baseProps,
+ 'data-state': this.provider.state
+ } as const)
+ );
+}
diff --git a/src/uix/soma/components/meter/types.ts b/src/uix/soma/components/meter/types.ts
new file mode 100644
index 000000000..71e6fc92a
--- /dev/null
+++ b/src/uix/soma/components/meter/types.ts
@@ -0,0 +1,72 @@
+import type { WithChild, Without } from '../../types';
+import type { PrimitiveDivAttributes } from '../../types';
+
+/** Zone derived from value vs low/high/optimum thresholds. */
+export type MeterZone = 'below' | 'above' | 'optimum';
+
+// ── Root provider ──────────────────────────────────────────────────────────
+
+/**
+ * Props for the root `Meter.Provider`.
+ *
+ * Renders `` with the full ARIA valuenow/min/max triplet.
+ * Unlike `Progress` (which tracks completion over time), `Meter` represents
+ * a fixed-point measurement within a range — disk usage, password strength,
+ * battery level, CPU load.
+ *
+ * The `low` / `high` / `optimum` thresholds classify the current value into
+ * one of three zones (`below` / `optimum` / `above`), exposed via
+ * `data-state` for CSS styling.
+ *
+ * The `Indicator` sub-part is a scaled fill, positioned via the
+ * `--soma-meter-value-pct` CSS variable.
+ */
+export type MeterProps = WithChild<{
+ /** DOM id. Auto-generated if omitted. */
+ id?: string;
+
+ /** Current value. Required. @default 0 */
+ value?: number;
+ /** Minimum. @default 0 */
+ min?: number;
+ /** Maximum. @default 100 */
+ max?: number;
+
+ /**
+ * Lower threshold. When `value < low` → `data-state='below'`.
+ * Defaults to `min` (no below zone).
+ */
+ low?: number;
+ /**
+ * Upper threshold. When `value > high` → `data-state='above'`.
+ * Defaults to `max` (no above zone).
+ */
+ high?: number;
+ /**
+ * Optimum target within `[low, high]`. Used by assistive tech to
+ * describe whether the current value is trending toward or away from
+ * the ideal. Defaults to the midpoint of `[low, high]`.
+ */
+ optimum?: number;
+
+ /** Override the default accessible label. Defaults to translated `'Meter'`. */
+ 'aria-label'?: string;
+ /** External element that labels the Meter. */
+ 'aria-labelledby'?: string;
+
+ /** Optional value text for screen readers. Falls back to `value / max`. */
+ valueText?: string;
+}> &
+ Without
;
+
+// ── Indicator ──────────────────────────────────────────────────────────────
+
+/**
+ * Props for `Meter.Indicator` — decorative scaled fill. Style via the
+ * `--soma-meter-value-pct` CSS variable and the `data-state` attribute
+ * (`below` / `optimum` / `above`) on the root.
+ */
+export type MeterIndicatorProps = WithChild<{
+ id?: string;
+}> &
+ Without;
diff --git a/src/uix/soma/components/pagination/langs.ts b/src/uix/soma/components/pagination/langs.ts
index d2939748a..4a2d4f768 100644
--- a/src/uix/soma/components/pagination/langs.ts
+++ b/src/uix/soma/components/pagination/langs.ts
@@ -3,4 +3,5 @@ export const PAGINATION_LANGS = {
LABEL: '#?components.pagination.label|Pagination',
PREV: '#?common.buttons.prev|Previous page',
NEXT: '#?common.buttons.next|Next page',
+ PAGE: '#?components.pagination.page|Page {{value}}',
} as const;
diff --git a/src/uix/soma/components/pagination/pagination-provider.svelte.ts b/src/uix/soma/components/pagination/pagination-provider.svelte.ts
index c2e11c2fd..5c823b65c 100644
--- a/src/uix/soma/components/pagination/pagination-provider.svelte.ts
+++ b/src/uix/soma/components/pagination/pagination-provider.svelte.ts
@@ -322,7 +322,9 @@ export class PaginationItemProvider extends Provider {
type: 'button' as const,
'aria-current': this.isSelected ? ('page' as const) : undefined,
'aria-label':
- this.provider.soma?.langs.t('soma.pagination.page', { page: this.opts.value.current }) || `Page ${this.opts.value.current}`,
+ this.provider.soma?.langs.t(PAGINATION_LANGS.PAGE, {
+ value: String(this.opts.value.current)
+ }) || `Page ${this.opts.value.current}`,
'data-selected': boolToEmptyStrOrUndef(this.isSelected),
'data-value': this.opts.value.current,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
diff --git a/src/uix/soma/components/progress/README.md b/src/uix/soma/components/progress/README.md
new file mode 100644
index 000000000..ff826dbcf
--- /dev/null
+++ b/src/uix/soma/components/progress/README.md
@@ -0,0 +1,136 @@
+# Progress
+
+A `role="progressbar"` that shows the completion of a known-duration task. Supports determinate (numeric) and indeterminate (`value=null`) modes. The decorative Indicator is driven by the `--soma-progress-value-pct` CSS custom property.
+
+For a value rendered inside a known range (disk usage, score), use `Meter` instead.
+
+## Anatomy
+
+```svelte
+
+
+
+```
+
+## Parts
+
+| Part | Element | Description |
+| ----------- | -------- | ------------------------------------------------------------------------------------- |
+| `Provider` | `` | `role="progressbar"`. Emits ARIA value attrs + `--soma-progress-value-pct`. |
+| `Indicator` | `
` | Decorative fill. Read `--soma-progress-value-pct` and size accordingly. |
+
+## Props
+
+### `Provider`
+
+| Prop | Type | Default | Description |
+| ------------------ | ------------------------ | -------------------- | ---------------------------------------------------------------------------------------- |
+| `id` | `string` | auto | DOM id. |
+| `value` | `number \| null` | `0` | Current progress. `null` → indeterminate (no ARIA value, `data-state='indeterminate'`). |
+| `min` | `number` | `0` | Lower bound. |
+| `max` | `number` | `100` | Upper bound. |
+| `valueText` | `string` | `${value} / ${max}` | Override for `aria-valuetext`. Provide when the percentage would be misleading (e.g. `"75 of 100 MB"`). |
+| `aria-label` | `string` | translated `Loading` | Accessible name. Ignored when `aria-labelledby` is set. |
+| `aria-labelledby` | `string` | — | External label. |
+
+## ARIA
+
+| Part | Attribute | Value |
+| -------- | ----------------- | -------------------------------------------------- |
+| Provider | `role` | `progressbar` |
+| Provider | `aria-valuemin` | Value of `min` |
+| Provider | `aria-valuemax` | Value of `max` |
+| Provider | `aria-valuenow` | Value of `value` (omitted when indeterminate) |
+| Provider | `aria-valuetext` | Resolved value text (omitted when indeterminate) |
+| Provider | `aria-label` | Translated default or override |
+
+Indeterminate mode omits `aria-valuenow` / `aria-valuetext`, matching the WAI-ARIA spec (unknown value).
+
+## Data Attributes
+
+| Part | Attribute | Values |
+| --------- | ----------------------- | --------------------------------------------- |
+| Provider | `data-progress` | Always present |
+| Provider | `data-state` | `indeterminate` \| `loading` \| `loaded` |
+| Provider | `data-value` | Numeric value (omitted when indeterminate) |
+| Provider | `data-min` | Numeric min |
+| Provider | `data-max` | Numeric max |
+| Indicator | `data-progress-indicator` | Always present |
+| Indicator | `data-state` | Inherited from provider |
+
+State resolution:
+- `value === null` → `indeterminate`
+- `value >= max` → `loaded`
+- otherwise → `loading`
+
+## CSS Variables
+
+| Variable | Part | Description |
+| ---------------------------- | -------- | -------------------------------------------------------- |
+| `--soma-progress-value-pct` | Provider | 0–100 percentage, clamped. Omitted when indeterminate. |
+
+## Comparison
+
+| Feature | Soma | Radix | Ark UI | bits-ui | react-aria |
+| ---------------------------------------- | :--: | :---: | :----: | :-----: | :--------: |
+| `role="progressbar"` | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Indeterminate (`value=null`) | ✅ | ✅ | ✅ | ✅ | ✅ |
+| `aria-valuetext` override | ✅ | ❌ | ❌ | ❌ | ✅ |
+| `--soma-progress-value-pct` CSS var | ✅ | ⚠️¹ | ✅ | ✅ | ❌ |
+| `data-state` reflects loading/loaded | ✅ | ✅ | ✅ | ✅ | ❌ |
+| Translated default `aria-label` | ✅ | ❌ | ❌ | ❌ | ❌ |
+| `min` configurable | ✅ | ❌² | ✅ | ✅ | ✅ |
+
+¹ Radix uses `--radix-progress-indicator-max-width`; same shape, different name.
+² Radix hardcodes `min=0`.
+
+## Usage
+
+### Determinate
+
+```svelte
+
+
+
+```
+
+### Indeterminate
+
+```svelte
+
+
+
+```
+
+```css
+[data-progress][data-state='indeterminate'] [data-progress-indicator] {
+ width: 40%;
+ animation: slide 1s infinite linear;
+}
+@keyframes slide {
+ from { transform: translateX(-100%); }
+ to { transform: translateX(250%); }
+}
+```
+
+### Styling the fill
+
+```css
+[data-progress] {
+ position: relative;
+ height: 12px;
+ border-radius: 6px;
+ background: #e2e8f0;
+ overflow: hidden;
+}
+[data-progress-indicator] {
+ position: absolute;
+ inset: 0;
+ width: calc(var(--soma-progress-value-pct, 0) * 1%);
+ background: #0070f3;
+ transition: width 150ms ease-out;
+}
+[data-progress][data-state='loaded'] [data-progress-indicator] {
+ background: #16a34a;
+}
+```
diff --git a/src/uix/soma/components/progress/components/progress-indicator.svelte b/src/uix/soma/components/progress/components/progress-indicator.svelte
new file mode 100644
index 000000000..be10436da
--- /dev/null
+++ b/src/uix/soma/components/progress/components/progress-indicator.svelte
@@ -0,0 +1,35 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/src/uix/soma/components/progress/components/progress.svelte b/src/uix/soma/components/progress/components/progress.svelte
new file mode 100644
index 000000000..3dc0c1db7
--- /dev/null
+++ b/src/uix/soma/components/progress/components/progress.svelte
@@ -0,0 +1,47 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/src/uix/soma/components/progress/exports.ts b/src/uix/soma/components/progress/exports.ts
new file mode 100644
index 000000000..7dc884595
--- /dev/null
+++ b/src/uix/soma/components/progress/exports.ts
@@ -0,0 +1,8 @@
+export { default as Provider } from './components/progress.svelte';
+export { default as Indicator } from './components/progress-indicator.svelte';
+
+export type {
+ ProgressProps as ProviderProps,
+ ProgressIndicatorProps as IndicatorProps,
+ ProgressState
+} from './types';
diff --git a/src/uix/soma/components/progress/index.ts b/src/uix/soma/components/progress/index.ts
new file mode 100644
index 000000000..8570a426a
--- /dev/null
+++ b/src/uix/soma/components/progress/index.ts
@@ -0,0 +1 @@
+export * from './exports';
diff --git a/src/uix/soma/components/progress/langs.ts b/src/uix/soma/components/progress/langs.ts
new file mode 100644
index 000000000..5f7813d11
--- /dev/null
+++ b/src/uix/soma/components/progress/langs.ts
@@ -0,0 +1,4 @@
+/** Idlangref constants for the Progress component. */
+export const PROGRESS_LANGS = {
+ LABEL: '#?components.progress.label|Loading',
+} as const;
diff --git a/src/uix/soma/components/progress/progress-provider.svelte.ts b/src/uix/soma/components/progress/progress-provider.svelte.ts
new file mode 100644
index 000000000..3a30f257b
--- /dev/null
+++ b/src/uix/soma/components/progress/progress-provider.svelte.ts
@@ -0,0 +1,154 @@
+import { Provider, context, type WithRefOpts } from '../../provider';
+import { createAttrs, registerContract } from '../../attrs';
+import { readableActive, type Active, type ActiveProps } from '../../reactive';
+import { Soma } from '../../core/soma.svelte';
+import { PROGRESS_LANGS } from './langs';
+import type { ProgressState } from './types';
+
+const attrs = createAttrs({
+ component: 'progress',
+ parts: ['root', 'indicator'] as const
+});
+
+registerContract({
+ name: 'progress',
+ version: 1,
+ parts: {
+ root: [
+ {
+ attr: 'data-state',
+ values: ['indeterminate', 'loading', 'loaded'],
+ description: 'Progress state derived from value'
+ },
+ { attr: 'data-value', description: 'Current value, omitted when indeterminate' },
+ { attr: 'data-max', description: 'Max value' },
+ { attr: 'data-min', description: 'Min value' }
+ ],
+ indicator: [
+ {
+ attr: 'data-state',
+ values: ['indeterminate', 'loading', 'loaded'],
+ description: 'Progress state (inherited from root)'
+ }
+ ]
+ }
+});
+
+function computeState(value: number | null, min: number, max: number): ProgressState {
+ if (value === null) return 'indeterminate';
+ if (value >= max) return 'loaded';
+ // Treat "below min" still as loading; consumers can clamp externally.
+ return 'loading';
+}
+
+// ── Root provider ──────────────────────────────────────────────────────────
+
+interface ProgressOpts
+ extends WithRefOpts,
+ ActiveProps<{
+ value: number | null;
+ max: number;
+ min: number;
+ ariaLabel: string | undefined;
+ ariaLabelledby: string | undefined;
+ valueText: string | undefined;
+ }> {}
+
+export class ProgressProvider extends Provider
{
+ static readonly ctx = context('Progress');
+ static get(): ProgressProvider | undefined {
+ return this.ctx.getOr(undefined) as ProgressProvider | undefined;
+ }
+ static require(): ProgressProvider {
+ return this.ctx.get();
+ }
+
+ static create(opts: ProgressOpts) {
+ return new ProgressProvider(opts);
+ }
+
+ readonly soma = Soma.get();
+
+ private constructor(opts: ProgressOpts) {
+ super(opts, 'Progress', 'root', attrs.root, ProgressProvider.ctx);
+ }
+
+ readonly state: ReadonlyState = $derived.by(() => {
+ const v = this.opts.value.current;
+ return computeState(v, this.opts.min.current, this.opts.max.current);
+ });
+
+ /** 0–100 percentage for CSS vars. Clamped to the min/max range. */
+ readonly percentage = $derived.by(() => {
+ const v = this.opts.value.current;
+ if (v === null) return 0;
+ const min = this.opts.min.current;
+ const max = this.opts.max.current;
+ if (max <= min) return 0;
+ return Math.max(0, Math.min(100, ((v - min) / (max - min)) * 100));
+ });
+
+ readonly resolvedAriaLabel: Active = readableActive(() => {
+ if (this.opts.ariaLabelledby.current) return undefined;
+ return (
+ this.opts.ariaLabel.current ||
+ this.soma?.langs.ts(PROGRESS_LANGS.LABEL) ||
+ undefined
+ );
+ });
+
+ readonly resolvedValueText = $derived.by(() => {
+ const explicit = this.opts.valueText.current;
+ if (explicit) return explicit;
+ const v = this.opts.value.current;
+ if (v === null) return undefined;
+ return `${v} / ${this.opts.max.current}`;
+ });
+
+ readonly props = $derived.by(() => {
+ const value = this.opts.value.current;
+ return this.assertProps({
+ ...this.baseProps,
+ role: 'progressbar' as const,
+ 'aria-valuemin': this.opts.min.current,
+ 'aria-valuemax': this.opts.max.current,
+ 'aria-valuenow': value ?? undefined,
+ 'aria-valuetext': this.resolvedValueText,
+ 'aria-label': this.resolvedAriaLabel.current,
+ 'aria-labelledby': this.opts.ariaLabelledby.current,
+ 'data-state': this.state,
+ 'data-value': value ?? undefined,
+ 'data-max': this.opts.max.current,
+ 'data-min': this.opts.min.current,
+ style: {
+ '--soma-progress-value-pct': `${this.percentage}`
+ }
+ } as const);
+ });
+}
+
+type ReadonlyState = ProgressState;
+
+// ── Indicator ──────────────────────────────────────────────────────────────
+
+interface ProgressIndicatorOpts extends WithRefOpts {}
+
+export class ProgressIndicatorProvider extends Provider {
+ static create(opts: ProgressIndicatorOpts) {
+ return new ProgressIndicatorProvider(opts);
+ }
+
+ readonly provider: ProgressProvider;
+
+ private constructor(opts: ProgressIndicatorOpts) {
+ super(opts, 'Progress', 'indicator', attrs.indicator);
+ this.provider = ProgressProvider.require();
+ }
+
+ readonly props = $derived.by(() =>
+ this.assertProps({
+ ...this.baseProps,
+ 'data-state': this.provider.state
+ } as const)
+ );
+}
diff --git a/src/uix/soma/components/progress/types.ts b/src/uix/soma/components/progress/types.ts
new file mode 100644
index 000000000..bf5262429
--- /dev/null
+++ b/src/uix/soma/components/progress/types.ts
@@ -0,0 +1,62 @@
+import type { WithChild, Without } from '../../types';
+import type { PrimitiveDivAttributes } from '../../types';
+
+/** Progress state derived from value + min + max. */
+export type ProgressState = 'indeterminate' | 'loading' | 'loaded';
+
+// ── Root provider ──────────────────────────────────────────────────────────
+
+/**
+ * Props for the root `Progress.Provider`.
+ *
+ * Renders `` with the full ARIA valuenow/min/max
+ * triplet. Supports indeterminate mode (`value=null`) for unknown-duration
+ * operations like initial data fetches.
+ *
+ * The `Indicator` sub-part is a scaled fill bar, positioned via the
+ * `--soma-progress-value-pct` CSS variable (0–100).
+ */
+export type ProgressProps = WithChild<{
+ /** DOM id. Auto-generated if omitted. */
+ id?: string;
+
+ /**
+ * Current progress value. `null` renders the bar in indeterminate mode
+ * (the Indicator is still present but has no meaningful width, and
+ * `data-state='indeterminate'`).
+ * @default null
+ */
+ value?: number | null;
+ /** Maximum value. @default 100 */
+ max?: number;
+ /** Minimum value. @default 0 */
+ min?: number;
+
+ /**
+ * Override the default accessible label. When omitted, the Progress uses
+ * a translated `'Loading'`.
+ */
+ 'aria-label'?: string;
+ /** External element that labels the Progress. */
+ 'aria-labelledby'?: string;
+
+ /**
+ * Optional value text for screen readers (e.g. `"75 of 100 MB"` instead
+ * of the bare number). React Aria ships this as `valueLabel` — we surface
+ * the same hook via `valueText`. Falls back to `value / max` when unset.
+ */
+ valueText?: string;
+}> &
+ Without
;
+
+// ── Indicator ──────────────────────────────────────────────────────────────
+
+/**
+ * Props for `Progress.Indicator` — decorative scaled fill. Style its width
+ * (or transform) via the `--soma-progress-value-pct` CSS variable emitted
+ * on the root.
+ */
+export type ProgressIndicatorProps = WithChild<{
+ id?: string;
+}> &
+ Without;
diff --git a/src/uix/soma/components/search-field/README.md b/src/uix/soma/components/search-field/README.md
new file mode 100644
index 000000000..dcd2455fe
--- /dev/null
+++ b/src/uix/soma/components/search-field/README.md
@@ -0,0 +1,155 @@
+# SearchField
+
+Text input tuned for search: ` ` with an integrated clear button, Escape-to-clear, Enter-to-submit, and `Field.Provider` OR-merge for `disabled` / `readonly` / `required` / `invalid`.
+
+## Anatomy
+
+```svelte
+ runSearch(v)}>
+
+ ×
+
+```
+
+## Parts
+
+| Part | Element | Description |
+| -------------- | ----------- | -------------------------------------------------------------------------- |
+| `Provider` | `` | Root context. Holds value, flags (disabled/readonly/required/invalid). |
+| `Input` | `
` | `type="search" role="searchbox"`. Reads OR-merged state from Field. |
+| `ClearTrigger` | `
` | Clears the value on click. Hidden via `data-empty` when value is empty. |
+
+## Props
+
+### `Provider`
+
+| Prop | Type | Default | Description |
+| ----------------- | --------------------------------------- | ----------------------------- | --------------------------------------------------------------------------- |
+| `id` | `string` | auto | DOM id. |
+| `value` | `string` | `''` | Controlled input value. `bind:value` supported. |
+| `onValueChange` | `(value: string) => void` | — | Fires on every keystroke. |
+| `onSubmit` | `(value: string) => void` | — | Fires on `Enter` in the Input. |
+| `onClear` | `() => void` | — | Fires on Escape, ClearTrigger click, or programmatic `clear()`. |
+| `clearOnEscape` | `boolean` | `true` | If `false`, Escape is a no-op (leave dismissal to the surrounding dialog). |
+| `disabled` | `boolean` | `false` | OR-merged with `Field.disabled`. |
+| `readonly` | `boolean` | `false` | OR-merged with `Field.readonly`. |
+| `required` | `boolean` | `false` | OR-merged with `Field.required`. |
+| `invalid` | `boolean` | `false` | OR-merged with `Field.invalid`. |
+| `placeholder` | `string` | — | Forwarded to the Input element. |
+| `aria-label` | `string` | translated `Search` | Accessible name when no Field.Label is present. |
+
+### `ClearTrigger`
+
+| Prop | Type | Default | Description |
+| ------------- | -------- | ------------------------ | ------------------- |
+| `id` | `string` | auto | DOM id. |
+| `aria-label` | `string` | translated `Clear search` | Accessible name. |
+
+## ARIA
+
+| Part | Attribute | Value |
+| ------------ | ----------------- | ------------------------------------------------------- |
+| Input | `role` | `searchbox` |
+| Input | `type` | `search` |
+| Input | `aria-invalid` | When `invalid` is true |
+| Input | `aria-required` | When `required` is true |
+| Input | `aria-labelledby` | Set by `Field.Label` when composed inside Field |
+| ClearTrigger | `type` | `button` |
+| ClearTrigger | `aria-label` | Translated default unless overridden |
+| ClearTrigger | `tabindex` | `-1` — not in the tab order (clear via Escape instead) |
+
+## Data Attributes
+
+| Part | Attribute | Values |
+| ------------ | --------------------------------- | --------------------------------------------------- |
+| Provider | `data-search-field` | Always present |
+| Provider | `data-empty` | Present when `value === ''` |
+| Provider | `data-focused` | Present while Input has focus |
+| Provider | `data-disabled` | Present when `disabled` (merged) |
+| Provider | `data-readonly` | Present when `readonly` (merged) |
+| Provider | `data-required` | Present when `required` (merged) |
+| Provider | `data-invalid` | Present when `invalid` (merged) |
+| Input | `data-search-field-input` | Always present |
+| Input | `data-empty` / `data-focused` / …| Mirrors Provider for direct styling |
+| ClearTrigger | `data-search-field-clear-trigger` | Always present |
+| ClearTrigger | `data-empty` | Present when value is empty (hook for `visibility`) |
+
+## Keyboard
+
+| Key | Action |
+| --------- | ---------------------------------------------------------------------- |
+| `Enter` | Fires `onSubmit(value)` |
+| `Escape` | Clears the value (when `clearOnEscape`), fires `onClear` + `onValueChange` |
+
+## i18n
+
+| Key | English | Spanish |
+| ------- | -------------- | ---------------- |
+| `label` | `Search` | `Buscar` |
+| `clear` | `Clear search` | `Borrar búsqueda` |
+
+Override per-instance via `aria-label` on `Provider` / `ClearTrigger`.
+
+## Comparison
+
+| Feature | Soma | Radix | Ark UI | bits-ui | react-aria |
+| ---------------------------------------- | :--: | :---: | :----: | :-----: | :--------: |
+| Dedicated component | ✅ | ❌ | ❌ | ❌ | ✅ |
+| `role="searchbox"` | ✅ | — | — | — | ✅ |
+| Integrated ClearTrigger part | ✅ | — | — | — | ✅ |
+| Escape-to-clear | ✅ | — | — | — | ✅ |
+| Enter-to-submit callback | ✅ | — | — | — | ✅ |
+| `Field` OR-merge (disabled/invalid/…) | ✅ | — | — | — | ⚠️¹ |
+| Translated default `aria-label` | ✅ | — | — | — | ❌ |
+| `data-empty` on root for clear visibility| ✅ | — | — | — | ❌ |
+| Debounce prop | ❌² | — | — | — | ❌ |
+
+¹ react-aria couples SearchField with its own `FieldContext`; soma participates in the shared `Field.Provider` that all form primitives use.
+² Debouncing belongs on the consumer: `onValueChange` → your own `debounce(value, 300)` hook. No built-in, by design.
+
+## Usage
+
+### Standalone
+
+```svelte
+
+
+ goto(`/search?q=${v}`)}>
+
+ ×
+
+```
+
+### Inside a Field (label + helper + error)
+
+```svelte
+
+ Find a product
+
+
+
+ ×
+
+
+ Press Enter to search, Escape to clear.
+ {#if error}{error} {/if}
+
+```
+
+### Debounced live search
+
+```svelte
+
+
+
+
+ ×
+
+```
diff --git a/src/uix/soma/components/search-field/components/search-field-clear-trigger.svelte b/src/uix/soma/components/search-field/components/search-field-clear-trigger.svelte
new file mode 100644
index 000000000..ca66fc43a
--- /dev/null
+++ b/src/uix/soma/components/search-field/components/search-field-clear-trigger.svelte
@@ -0,0 +1,41 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {#if children}
+ {@render children()}
+ {:else}
+ ✕
+ {/if}
+
+{/if}
diff --git a/src/uix/soma/components/search-field/components/search-field-input.svelte b/src/uix/soma/components/search-field/components/search-field-input.svelte
new file mode 100644
index 000000000..39cdfd823
--- /dev/null
+++ b/src/uix/soma/components/search-field/components/search-field-input.svelte
@@ -0,0 +1,30 @@
+
+
+
diff --git a/src/uix/soma/components/search-field/components/search-field.svelte b/src/uix/soma/components/search-field/components/search-field.svelte
new file mode 100644
index 000000000..3eb16f5f4
--- /dev/null
+++ b/src/uix/soma/components/search-field/components/search-field.svelte
@@ -0,0 +1,64 @@
+
+
+{#if child}
+ {@render child({ ...state.snippetProps, props: mergedProps })}
+{:else}
+
+ {@render children?.(state.snippetProps)}
+
+{/if}
diff --git a/src/uix/soma/components/search-field/exports.ts b/src/uix/soma/components/search-field/exports.ts
new file mode 100644
index 000000000..11c95fb95
--- /dev/null
+++ b/src/uix/soma/components/search-field/exports.ts
@@ -0,0 +1,10 @@
+export { default as Provider } from './components/search-field.svelte';
+export { default as Input } from './components/search-field-input.svelte';
+export { default as ClearTrigger } from './components/search-field-clear-trigger.svelte';
+
+export type {
+ SearchFieldProps as ProviderProps,
+ SearchFieldInputProps as InputProps,
+ SearchFieldClearTriggerProps as ClearTriggerProps,
+ SearchFieldProviderSnippetProps as ProviderSnippetProps
+} from './types';
diff --git a/src/uix/soma/components/search-field/index.ts b/src/uix/soma/components/search-field/index.ts
new file mode 100644
index 000000000..8570a426a
--- /dev/null
+++ b/src/uix/soma/components/search-field/index.ts
@@ -0,0 +1 @@
+export * from './exports';
diff --git a/src/uix/soma/components/search-field/langs.ts b/src/uix/soma/components/search-field/langs.ts
new file mode 100644
index 000000000..417e6850c
--- /dev/null
+++ b/src/uix/soma/components/search-field/langs.ts
@@ -0,0 +1,5 @@
+/** Idlangref constants for the SearchField component. */
+export const SEARCH_FIELD_LANGS = {
+ LABEL: '#?components.search-field.label|Search',
+ CLEAR: '#?components.search-field.clear|Clear search',
+} as const;
diff --git a/src/uix/soma/components/search-field/search-field-provider.svelte.ts b/src/uix/soma/components/search-field/search-field-provider.svelte.ts
new file mode 100644
index 000000000..13e2c5fd0
--- /dev/null
+++ b/src/uix/soma/components/search-field/search-field-provider.svelte.ts
@@ -0,0 +1,303 @@
+import { Provider, context, type WithRefOpts } from '../../provider';
+import { createAttrs, registerContract, boolToEmptyStrOrUndef } from '../../attrs';
+import {
+ readableActive,
+ state,
+ type Active,
+ type ActiveProps,
+ type StateProps
+} from '../../reactive';
+import type {
+ OnChangeFn,
+ SomaInputEvent,
+ SomaKeyboardEvent,
+ SomaMouseEvent,
+ SomaFocusEvent
+} from '../../types';
+import { KEYS } from '../../keyboard';
+import { Soma } from '../../core/soma.svelte';
+import { FieldProvider } from '../field/field-provider.svelte';
+import { SEARCH_FIELD_LANGS } from './langs';
+
+const attrs = createAttrs({
+ component: 'search-field',
+ parts: ['root', 'input', 'clear-trigger'] as const
+});
+
+registerContract({
+ name: 'search-field',
+ version: 1,
+ parts: {
+ root: [
+ { attr: 'data-disabled', description: 'Disabled flag' },
+ { attr: 'data-readonly', description: 'Read-only flag' },
+ { attr: 'data-invalid', description: 'Invalid flag' },
+ { attr: 'data-required', description: 'Required flag' },
+ { attr: 'data-focused', description: 'Input has focus' },
+ { attr: 'data-empty', description: 'Value is empty' }
+ ],
+ input: [
+ { attr: 'data-disabled', description: 'Disabled flag' },
+ { attr: 'data-readonly', description: 'Read-only flag' }
+ ],
+ 'clear-trigger': [{ attr: 'data-disabled', description: 'Disabled flag' }]
+ }
+});
+
+// ── Root provider ──────────────────────────────────────────────────────────
+
+interface SearchFieldOpts
+ extends
+ WithRefOpts,
+ StateProps<{ value: string }>,
+ ActiveProps<{
+ inputId: string;
+ disabled: boolean;
+ readonly: boolean;
+ required: boolean;
+ invalid: boolean;
+ name: string | undefined;
+ placeholder: string | undefined;
+ clearOnEscape: boolean;
+ ariaLabel: string | undefined;
+ onValueChange: OnChangeFn | undefined;
+ onSubmit: ((value: string) => void) | undefined;
+ onClear: (() => void) | undefined;
+ }> {}
+
+export class SearchFieldProvider extends Provider {
+ static readonly ctx = context('SearchField');
+ static get(): SearchFieldProvider | undefined {
+ return this.ctx.getOr(undefined) as SearchFieldProvider | undefined;
+ }
+ static require(): SearchFieldProvider {
+ return this.ctx.get();
+ }
+
+ static create(opts: SearchFieldOpts) {
+ return new SearchFieldProvider(opts);
+ }
+
+ readonly soma = Soma.get();
+ readonly field = FieldProvider.get();
+
+ inputRef = state(null);
+ focused = $state(false);
+
+ private constructor(opts: SearchFieldOpts) {
+ super(opts, 'SearchField', 'root', attrs.root, SearchFieldProvider.ctx);
+ // Register the Input's id with the parent Field so `Field.Label`'s
+ // `for` attribute lands on the interactive element. Direct assign —
+ // NOT $effect (A30).
+ if (this.field) this.field.inputId.current = opts.inputId.current;
+ }
+
+ // ── Field-aware flags (OR-merge) ─────────────────────────────────────────
+
+ readonly isDisabled = $derived.by(
+ () => this.opts.disabled.current || (this.field?.isDisabled ?? false)
+ );
+ readonly isReadonly = $derived.by(
+ () => this.opts.readonly.current || (this.field?.isReadonly ?? false)
+ );
+ readonly isRequired = $derived.by(
+ () => this.opts.required.current || (this.field?.isRequired ?? false)
+ );
+ readonly isInvalid = $derived.by(
+ () => this.opts.invalid.current || (this.field?.isInvalid ?? false)
+ );
+
+ readonly isEmpty = $derived.by(() => this.opts.value.current === '');
+
+ // ── Resolved aria-label ─────────────────────────────────────────────────
+
+ readonly resolvedAriaLabel: Active = readableActive(() => {
+ // If a Field.Label is present (labelId registered), it takes over via
+ // aria-labelledby — no need for aria-label on the input.
+ if (this.field?.labelId.current) return undefined;
+ return (
+ this.opts.ariaLabel.current ||
+ this.soma?.langs.ts(SEARCH_FIELD_LANGS.LABEL) ||
+ undefined
+ );
+ });
+
+ setInputRef(el: HTMLInputElement | null) {
+ this.inputRef.current = el;
+ }
+
+ // ── Mutations ───────────────────────────────────────────────────────────
+
+ commit(next: string) {
+ if (this.isDisabled || this.isReadonly) return;
+ if (next === this.opts.value.current) return;
+ this.opts.value.current = next;
+ this.opts.onValueChange.current?.(next);
+ }
+
+ clear = () => {
+ if (this.isDisabled || this.isReadonly) return;
+ const wasNonEmpty = this.opts.value.current !== '';
+ this.opts.value.current = '';
+ if (wasNonEmpty) {
+ this.opts.onValueChange.current?.('');
+ this.opts.onClear.current?.();
+ }
+ this.inputRef.current?.focus();
+ };
+
+ submit() {
+ if (this.isDisabled) return;
+ this.opts.onSubmit.current?.(this.opts.value.current);
+ }
+
+ // ── Input events ────────────────────────────────────────────────────────
+
+ readonly oninput = (e: SomaInputEvent) => {
+ if (this.isDisabled || this.isReadonly) {
+ e.preventDefault();
+ return;
+ }
+ this.commit(e.currentTarget.value);
+ };
+
+ readonly onkeydown = (e: SomaKeyboardEvent) => {
+ if (e.key === KEYS.ENTER) {
+ e.preventDefault();
+ this.submit();
+ return;
+ }
+ if (e.key === KEYS.ESCAPE && this.opts.clearOnEscape.current && !this.isEmpty) {
+ e.preventDefault();
+ this.clear();
+ }
+ };
+
+ readonly onfocus = (_e: SomaFocusEvent) => {
+ this.focused = true;
+ };
+
+ readonly onblur = (_e: SomaFocusEvent) => {
+ this.focused = false;
+ };
+
+ // ── Snippet + root props ────────────────────────────────────────────────
+
+ readonly snippetProps = $derived.by(() => ({
+ value: this.opts.value.current,
+ isEmpty: this.isEmpty,
+ isFocused: this.focused,
+ clear: this.clear
+ }));
+
+ readonly props = $derived.by(() =>
+ this.assertProps({
+ ...this.baseProps,
+ 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
+ 'data-readonly': boolToEmptyStrOrUndef(this.isReadonly),
+ 'data-invalid': boolToEmptyStrOrUndef(this.isInvalid),
+ 'data-required': boolToEmptyStrOrUndef(this.isRequired),
+ 'data-focused': boolToEmptyStrOrUndef(this.focused),
+ 'data-empty': boolToEmptyStrOrUndef(this.isEmpty)
+ } as const)
+ );
+}
+
+// ── Input ──────────────────────────────────────────────────────────────────
+
+interface SearchFieldInputOpts extends WithRefOpts {}
+
+export class SearchFieldInputProvider extends Provider {
+ static create(opts: SearchFieldInputOpts) {
+ return new SearchFieldInputProvider(opts);
+ }
+
+ readonly provider: SearchFieldProvider;
+
+ private constructor(opts: SearchFieldInputOpts) {
+ super(opts, 'SearchField', 'input', attrs.input, undefined, (el) => {
+ this.provider.setInputRef(el as HTMLInputElement | null);
+ });
+ this.provider = SearchFieldProvider.require();
+ }
+
+ readonly props = $derived.by(() =>
+ this.assertProps({
+ ...this.baseProps,
+ type: 'search' as const,
+ role: 'searchbox' as const,
+ value: this.provider.opts.value.current,
+ name: this.provider.opts.name.current,
+ placeholder: this.provider.opts.placeholder.current,
+ autocomplete: 'off' as const,
+ spellcheck: false,
+ required: this.provider.isRequired || undefined,
+ disabled: this.provider.isDisabled || undefined,
+ readonly: this.provider.isReadonly || undefined,
+ 'aria-label': this.provider.resolvedAriaLabel.current,
+ 'aria-labelledby': this.provider.field?.labelId.current || undefined,
+ 'aria-invalid': this.provider.isInvalid || undefined,
+ 'aria-describedby':
+ [
+ this.provider.field?.helperId.current,
+ this.provider.field?.errorId.current
+ ]
+ .filter(Boolean)
+ .join(' ') || undefined,
+ 'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled),
+ 'data-readonly': boolToEmptyStrOrUndef(this.provider.isReadonly),
+ oninput: this.provider.oninput,
+ onkeydown: this.provider.onkeydown,
+ onfocus: this.provider.onfocus,
+ onblur: this.provider.onblur
+ } as const)
+ );
+}
+
+// ── ClearTrigger ───────────────────────────────────────────────────────────
+
+interface SearchFieldClearTriggerOpts
+ extends WithRefOpts,
+ ActiveProps<{ ariaLabel: string | undefined }> {}
+
+export class SearchFieldClearTriggerProvider extends Provider {
+ static create(opts: SearchFieldClearTriggerOpts) {
+ return new SearchFieldClearTriggerProvider(opts);
+ }
+
+ readonly provider: SearchFieldProvider;
+
+ private constructor(opts: SearchFieldClearTriggerOpts) {
+ super(opts, 'SearchField', 'clear-trigger', attrs['clear-trigger']);
+ this.provider = SearchFieldProvider.require();
+ }
+
+ readonly onclick = (_e: SomaMouseEvent) => {
+ this.provider.clear();
+ };
+
+ readonly resolvedAriaLabel: Active = readableActive(
+ () =>
+ this.opts.ariaLabel.current ||
+ this.provider.soma?.langs.ts(SEARCH_FIELD_LANGS.CLEAR) ||
+ undefined
+ );
+
+ readonly props = $derived.by(() =>
+ this.assertProps({
+ ...this.baseProps,
+ type: 'button' as const,
+ 'aria-label': this.resolvedAriaLabel.current,
+ disabled:
+ this.provider.isDisabled ||
+ this.provider.isReadonly ||
+ this.provider.isEmpty ||
+ undefined,
+ 'data-disabled': boolToEmptyStrOrUndef(
+ this.provider.isDisabled || this.provider.isReadonly || this.provider.isEmpty
+ ),
+ tabindex: -1,
+ onclick: this.onclick
+ } as const)
+ );
+}
diff --git a/src/uix/soma/components/search-field/types.ts b/src/uix/soma/components/search-field/types.ts
new file mode 100644
index 000000000..a080c9418
--- /dev/null
+++ b/src/uix/soma/components/search-field/types.ts
@@ -0,0 +1,114 @@
+import type { Snippet } from 'svelte';
+import type { WithChild, Without, OnChangeFn } from '../../types';
+import type {
+ PrimitiveDivAttributes,
+ PrimitiveInputAttributes,
+ PrimitiveButtonAttributes
+} from '../../types';
+
+/** Snippet props exposed by `SearchField.Provider`. */
+export type SearchFieldProviderSnippetProps = {
+ value: string;
+ /** `true` when `value === ''`. */
+ isEmpty: boolean;
+ /** `true` while the internal Input has focus. */
+ isFocused: boolean;
+ /** Clears the value and refocuses the Input. */
+ clear: () => void;
+};
+
+// ── Root provider ──────────────────────────────────────────────────────────
+
+/**
+ * Props for the root `SearchField.Provider`.
+ *
+ * A `` container with a `searchbox`-roled `
` and an
+ * optional integrated `ClearTrigger` button. Participates in `Field.Provider`
+ * — `disabled` / `readonly` / `required` / `invalid` are OR-merged.
+ *
+ * Two callback paths: `onValueChange` fires on every keystroke (live
+ * filtering), `onSubmit` fires on Enter (formal search). They are independent
+ * — consumers can use either or both.
+ */
+export type SearchFieldProps = WithChild<
+ {
+ /** DOM id for the root container. Auto-generated if omitted. */
+ id?: string;
+ /** DOM id for the inner input. Auto-generated if omitted. */
+ inputId?: string;
+
+ /** Current value. Bindable. @default '' */
+ value?: string;
+ /** Fired on every value change (each keystroke, paste, clear). */
+ onValueChange?: OnChangeFn
;
+ /** Fired when the user presses Enter in the input. */
+ onSubmit?: (value: string) => void;
+ /** Fired after the user clears the value (via ClearTrigger or Escape). */
+ onClear?: () => void;
+
+ /** When `true`, Escape in the Input clears the value. @default true */
+ clearOnEscape?: boolean;
+
+ // Flags — OR-merged with the enclosing `Field.Provider`
+ /** @default false */
+ disabled?: boolean;
+ /** @default false */
+ readonly?: boolean;
+ /** @default false */
+ required?: boolean;
+ /** External invalid flag. @default false */
+ invalid?: boolean;
+
+ // Form
+ /** Name for native form submission. */
+ name?: string;
+ /** Placeholder forwarded to the Input. */
+ placeholder?: string;
+ /**
+ * Accessible name for the search input. Ignored when inside a
+ * `Field.Provider` with a `Field.Label` (the Label wins via
+ * `aria-labelledby`). Defaults to a translated `'Search'`.
+ */
+ 'aria-label'?: string;
+
+ children?: Snippet<[SearchFieldProviderSnippetProps]>;
+ },
+ SearchFieldProviderSnippetProps
+> &
+ Without;
+
+// ── Input ──────────────────────────────────────────────────────────────────
+
+/**
+ * Props for `SearchField.Input` — the real ` `. Consumer
+ * never passes `value` / `onchange` directly; the Provider drives both.
+ */
+export type SearchFieldInputProps = WithChild<
+ { id?: string },
+ { _default: never },
+ HTMLInputElement
+> &
+ Without<
+ PrimitiveInputAttributes,
+ { value?: unknown; type?: unknown; onchange?: unknown; oninput?: unknown }
+ >;
+
+// ── ClearTrigger ───────────────────────────────────────────────────────────
+
+/**
+ * Props for `SearchField.ClearTrigger` — button that clears the Input and
+ * restores focus to it. Emits `data-empty` on the root so consumers can
+ * hide the trigger via CSS when the field is empty:
+ *
+ * ```css
+ * [data-search-field][data-empty] [data-search-field-clear-trigger] {
+ * display: none;
+ * }
+ * ```
+ */
+export type SearchFieldClearTriggerProps = WithChild<{
+ id?: string;
+ /** Accessible name. @default translated `'Clear search'` */
+ 'aria-label'?: string;
+}> &
+ Without;
diff --git a/src/uix/soma/components/table/README.md b/src/uix/soma/components/table/README.md
index 4af426122..62f5cd29a 100644
--- a/src/uix/soma/components/table/README.md
+++ b/src/uix/soma/components/table/README.md
@@ -1,6 +1,20 @@
# Table
-Headless table with sorting, filtering, row selection, pagination, column pinning, column sizing, and expandable rows. Provides a reactive state manager (`createTable`) and Svelte components with ARIA, keyboard support, and `data-*` attributes for styling.
+Headless table with sorting, filtering, row selection, pagination, column pinning, column sizing, and **row detail panels** (disclosure pattern). Provides a reactive state manager (`createTable`) and Svelte components with ARIA, keyboard support, and `data-*` attributes for styling.
+
+Table is for **flat tabular data**. Emits `role="table"` with grid-cell semantics; Tab + click for interaction, no arrow-key navigation between rows.
+
+## When to use Table vs TreeGrid vs GridList
+
+soma ships three WAI-ARIA patterns for tabular / list display. They are **not interchangeable** — pick by semantic intent:
+
+| Component | ARIA role | Keyboard contract | Use when |
+| -------------------------------------------- | ------------ | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
+| **Table** (this) | `table` | Tab + click. No arrow-key row nav. | Flat tabular data. Sort/filter/paginate large lists. Row expansion is a **detail panel** (disclosure), not hierarchy. |
+| [`TreeGrid`](../tree-grid/README.md) | `treegrid` | Roving tabindex + ArrowUp/Down rows + ArrowRight/Left expand/collapse. | **Hierarchical** rows (parent + children of the same shape): file browsers, org charts, nested tasks. `aria-level` auto-derived. |
+| [`GridList`](../grid-list/README.md) | `grid` | Roving tabindex + ArrowUp/Down rows + ArrowLeft/Right between focusable cells. | Flat **selectable** lists where rows contain multiple interactive elements (actions, links, per-row checkboxes). |
+
+**"Expandable rows" in Table = row detail panel**, not sub-rows. If you need true hierarchy → TreeGrid. Table emits `role="table"` and cannot honestly carry `aria-level` semantics.
## Anatomy
@@ -56,15 +70,17 @@ Headless table with sorting, filtering, row selection, pagination, column pinnin
## Parts
-| Part | Element | Description |
-| -------------- | --------- | ---------------------------------------------------------------------------------- |
-| `Provider` | `` | Root. Renders `` with translated `aria-label`. |
-| `Header` | `` | Table header section. |
-| `Body` | ` ` | Table body section. |
-| `Footer` | ` ` | Table footer section. |
-| `ColumnHeader` | `` | Column header. Emits `data-sortable`, `data-sorted`, `data-pinned`, sizing styles. |
-| `Row` | ` ` | Table row. Emits `data-selected`, `data-expanded`, `data-depth`, `aria-expanded`. |
-| `Cell` | `` | Table cell. Emits `data-pinned`, sizing styles, `aria-colindex`. |
+| Part | Element | Description |
+| -------------------- | ---------- | ---------------------------------------------------------------------------------- |
+| `Provider` | `` | Root. Renders `` with translated `aria-label`. |
+| `Header` | `` | Table header section. |
+| `Body` | ` ` | Table body section. |
+| `Footer` | ` ` | Table footer section. |
+| `ColumnHeader` | `` | Column header. Emits `data-sortable`, `data-sorted`, `data-pinned`, sizing styles. |
+| `Row` | ` ` | Table row. Emits `data-selected`. |
+| `Cell` | `` | Table cell. Emits `data-pinned`, sizing styles, `aria-colindex`. |
+| `RowDetailTrigger` | `` | Inside a Cell — toggles the row's detail panel. `aria-expanded` + `aria-controls`. |
+| `RowDetail` | `` | Full-width disclosure panel rendered below the row when open. `` inside. |
## State Manager (`createTable`)
@@ -93,9 +109,9 @@ const table = createTable({
pageSize: 10,
},
- // Expansion
- getRowSubRows?: (row) => row.children, // sub-rows for tree expansion
- getRowCanExpand?: (row) => boolean, // override expand check (detail panels)
+ // Row detail (disclosure pattern — NOT hierarchy; use TreeGrid for that)
+ getRowCanShowDetail?: (row) => boolean, // defaults to `() => true`
+ initialRowDetailOpen?: { [rowId: string]: boolean },
// Total row count for server-side pagination
rowCount?: number,
@@ -109,7 +125,7 @@ const table = createTable({
onColumnVisibilityChange?: (visibility) => void,
onColumnPinningChange?: (pinning) => void,
onColumnSizingChange?: (sizing) => void,
- onExpandedChange?: (expanded) => void,
+ onRowDetailOpenChange?: (state) => void,
});
// Row counts
@@ -160,12 +176,14 @@ interface ColumnDef {
| Provider (``) | `aria-label` | Translated label (default: 'Data table') |
| ColumnHeader | `role` | `columnheader` |
| ColumnHeader | `aria-sort` | `ascending` \| `descending` \| `none` (only when sortable) |
-| Row | `role` | `row` |
-| Row | `aria-rowindex` | 1-based row index |
-| Row | `aria-selected` | `true` \| `false` (when selection enabled) |
-| Row | `aria-expanded` | `true` \| `false` (when expandable) |
-| Cell | `role` | `gridcell` |
-| Cell | `aria-colindex` | 1-based column index |
+| Row | `role` | `row` |
+| Row | `aria-rowindex` | 1-based row index |
+| Row | `aria-selected` | `true` \| `false` (when selection enabled) |
+| Cell | `role` | `gridcell` |
+| Cell | `aria-colindex` | 1-based column index |
+| RowDetailTrigger | `aria-expanded` | `true` \| `false` — open state of the detail panel |
+| RowDetailTrigger | `aria-controls` | id of the sibling `Table.RowDetail` |
+| RowDetailTrigger | `aria-label` | Translated `Show details` / `Hide details` swap |
## Data Attributes
@@ -175,15 +193,17 @@ interface ColumnDef {
| ColumnHeader | `data-sortable` | Present when `enableSorting: true` |
| ColumnHeader | `data-sorted` | `asc` \| `desc` (when actively sorted) |
| ColumnHeader | `data-pinned` | `left` \| `right` (when pinned) |
-| Row | `data-table-row` | Always present |
-| Row | `data-selected` | Present when selected |
-| Row | `data-expanded` | Present when expanded |
-| Row | `data-depth` | Nesting depth as string (`"1"`, `"2"`, ...) for sub-rows |
-| Cell | `data-table-cell` | Always present |
-| Cell | `data-pinned` | `left` \| `right` (when column is pinned) |
-| Header | `data-table-header` | Always present |
-| Body | `data-table-body` | Always present |
-| Footer | `data-table-footer` | Always present |
+| Row | `data-table-row` | Always present |
+| Row | `data-selected` | Present when selected |
+| Cell | `data-table-cell` | Always present |
+| Cell | `data-pinned` | `left` \| `right` (when column is pinned) |
+| Header | `data-table-header` | Always present |
+| Body | `data-table-body` | Always present |
+| Footer | `data-table-footer` | Always present |
+| RowDetailTrigger | `data-table-row-detail-trigger` | Always present |
+| RowDetailTrigger | `data-state` | `open \| closed` |
+| RowDetail | `data-table-row-detail` | Always present |
+| RowDetail | `data-state` | `open \| closed` (hidden from DOM when closed) |
## Sorting
@@ -316,84 +336,55 @@ table.getIsColumnVisible('email');
table.setColumnVisibility({ email: false, age: false });
```
-## Expandable Rows
-
-Two patterns supported:
+## Row Detail Panels (disclosure pattern)
-### Sub-rows (tree/hierarchy)
+A row can show a **detail panel** — a full-width disclosure region rendered below it with any content (bio, invoice line-items, a nested form, a sub-Table). This is **not** hierarchy: the parent row and its detail have different shapes. For true hierarchical data where children are rows of the same schema, use [`TreeGrid`](../tree-grid/README.md) instead.
-Provide `getRowSubRows` to define hierarchical data. Expanded sub-rows are flattened into `table.rows`.
-
-```ts
-const table = createTable({
- data: () => orgData,
- columns,
- getRowSubRows: (row) => row.children ?? []
-});
-```
+The disclosure wiring is fully handled by the components: `Table.RowDetailTrigger` emits `aria-expanded` + `aria-controls` pointing at the `Table.RowDetail`, which is hidden from the DOM (via `hidden`) when closed.
```svelte
-{#each table.rows as row (row.id)}
-
-
- {#if row.getCanExpand}
- table.toggleRowExpanded(row.id)}>
- {row.getIsExpanded ? '▼' : '▶'}
-
- {/if}
-
- {#each row.cells as cell, i}
-
- {cell.value}
-
- {/each}
-
-{/each}
+
+ {#each table.rows as row (row.id)}
+
+
+ ▶
+
+ {#each row.cells as cell, i}
+ {cell.value}
+ {/each}
+
+
+
+ {row.original.name} — {row.original.email}
+
+
+ {/each}
+
```
-### Detail panels (expandable content)
+Both parts take `{row}` explicitly. `Table.RowDetail` is a **sibling ``** of `Table.Row` (HTML forbids nested ` `); the Trigger's `aria-controls` points at the Detail via a DOM id derived deterministically from `row.id`.
-Use `getRowCanExpand: () => true` without `getRowSubRows`. The consumer renders a detail ` ` when expanded.
+### Imperative API
```ts
-const table = createTable({
- data: () => people,
- columns,
- getRowCanExpand: () => true
-});
+table.toggleRowDetail(rowId);
+table.setRowDetailOpen({ '1': true, '3': true });
+table.getIsRowDetailOpen(rowId);
+table.getCanShowRowDetail(rowId); // honors getRowCanShowDetail config
+table.closeAllRowDetails();
+table.rowDetailOpen; // current state (read-only)
```
-```svelte
-{#each table.rows as row (row.id)}
-
-
- table.toggleRowExpanded(row.id)}>
- {row.getIsExpanded ? '▼' : '▶'}
-
-
- {#each row.cells as cell, i}
- {cell.value}
- {/each}
-
- {#if row.getIsExpanded}
-
-
-
-
- {row.original.name} — {row.original.email}
-
-
- {/if}
-{/each}
-```
+### Opting rows out
-### Expansion API
+By default every row can show a detail. Pass `getRowCanShowDetail` to opt some rows out — the consumer should also conditionally render `` to match.
```ts
-table.toggleRowExpanded(rowId);
-table.getIsRowExpanded(rowId);
-table.getCanRowExpand(rowId);
-table.toggleAllRowsExpanded();
+const table = createTable({
+ data: () => people,
+ columns,
+ getRowCanShowDetail: (row) => row.role !== 'placeholder'
+});
```
## Comparison with reference libraries
@@ -418,7 +409,7 @@ table.toggleAllRowsExpanded();
| Column visibility | Yes | No | Yes |
| Column resizing | Yes | Partial | Yes (programmatic, no drag) |
| Column pinning | Yes | No | Yes (left/right, sticky) |
-| Row expansion (sub-rows) | Yes | No | Yes |
-| Row expansion (detail) | Yes | No | Yes |
+| Row expansion (hierarchy) | Yes | No | **Use TreeGrid instead** |
+| Row expansion (detail panel)| Yes | No | Yes (`RowDetail`) |
| Grouping / aggregation | Yes | No | No |
-| Virtual scrolling | Via TanStack Virtual | No | No |
+| Virtual scrolling | Via TanStack Virtual | No | Via composition with VirtualList |
diff --git a/src/uix/soma/components/table/components/table-row-detail-trigger.svelte b/src/uix/soma/components/table/components/table-row-detail-trigger.svelte
new file mode 100644
index 000000000..cca9f6dd0
--- /dev/null
+++ b/src/uix/soma/components/table/components/table-row-detail-trigger.svelte
@@ -0,0 +1,39 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+ {@render children?.()}
+
+{/if}
diff --git a/src/uix/soma/components/table/components/table-row-detail.svelte b/src/uix/soma/components/table/components/table-row-detail.svelte
new file mode 100644
index 000000000..b9b480b38
--- /dev/null
+++ b/src/uix/soma/components/table/components/table-row-detail.svelte
@@ -0,0 +1,39 @@
+
+
+{#if child}
+ {@render child({ props: mergedProps })}
+{:else}
+
+
+ {@render children?.()}
+
+
+{/if}
diff --git a/src/uix/soma/components/table/exports.ts b/src/uix/soma/components/table/exports.ts
index c7cf9ec08..ffc45c6c7 100644
--- a/src/uix/soma/components/table/exports.ts
+++ b/src/uix/soma/components/table/exports.ts
@@ -5,6 +5,8 @@ export { default as Footer } from './components/table-footer.svelte';
export { default as ColumnHeader } from './components/table-column-header.svelte';
export { default as Row } from './components/table-row.svelte';
export { default as Cell } from './components/table-cell.svelte';
+export { default as RowDetail } from './components/table-row-detail.svelte';
+export { default as RowDetailTrigger } from './components/table-row-detail-trigger.svelte';
export { createTable, filterFns } from './table-core.svelte';
export type { BuiltInFilterFn } from './table-core.svelte';
@@ -20,7 +22,7 @@ export type {
ColumnPinningState,
ColumnPinningPosition,
ColumnSizingState,
- ExpandedState,
+ RowDetailState,
ColumnFilterEntry,
ColumnFiltersState,
TableConfig,
@@ -37,5 +39,7 @@ export type {
TableFooterProps as FooterProps,
TableColumnHeaderProps as ColumnHeaderProps,
TableRowProps as RowProps,
- TableCellProps as CellProps
+ TableCellProps as CellProps,
+ TableRowDetailProps as RowDetailProps,
+ TableRowDetailTriggerProps as RowDetailTriggerProps
} from './types';
diff --git a/src/uix/soma/components/table/langs.ts b/src/uix/soma/components/table/langs.ts
index ce4161c5a..bbf3a3c9d 100644
--- a/src/uix/soma/components/table/langs.ts
+++ b/src/uix/soma/components/table/langs.ts
@@ -1,3 +1,5 @@
export const TABLE_LANGS = {
- LABEL: '#?components.table.label|Data table'
+ LABEL: '#?components.table.label|Data table',
+ ROW_DETAIL_EXPAND: '#?components.table.row-detail-expand|Show details',
+ ROW_DETAIL_COLLAPSE: '#?components.table.row-detail-collapse|Hide details'
} as const;
diff --git a/src/uix/soma/components/table/table-core.svelte.ts b/src/uix/soma/components/table/table-core.svelte.ts
index c4c099dbe..99b546113 100644
--- a/src/uix/soma/components/table/table-core.svelte.ts
+++ b/src/uix/soma/components/table/table-core.svelte.ts
@@ -80,9 +80,14 @@ export interface ColumnPinningState {
export type ColumnSizingState = Record;
-// ── Expansion ────────────────────────────────────────────────────────────────
+// ── Row detail (disclosure pattern — NOT hierarchy) ─────────────────────────
+//
+// Tracks which rows currently have their detail panel open. Use TreeGrid for
+// true hierarchical data (`role="treegrid"` with `aria-level`/`aria-expanded`
+// on rows). This is the table-disclosure pattern: a row with a "show more"
+// toggle that reveals a full-width panel of extra content below.
-export type ExpandedState = Record;
+export type RowDetailState = Record;
export type ColumnPinningPosition = 'left' | 'right';
@@ -230,16 +235,6 @@ export interface TableRow {
index: number;
original: TData;
cells: TableCell[];
- /** Depth in the expanded tree. @default 0 */
- depth: number;
- /** Parent row ID. Undefined for root rows. */
- parentId?: string;
- /** Sub-rows when expanded. Empty if no sub-rows or not expanded. */
- subRows: TableRow[];
- /** Whether this row can be expanded (has sub-rows). */
- getCanExpand: boolean;
- /** Whether this row is currently expanded. */
- getIsExpanded: boolean;
}
// ── Table instance ───────────────────────────────────────────────────────────
@@ -306,13 +301,17 @@ export interface TableConfig {
initialColumnSizing?: ColumnSizingState;
onColumnSizingChange?: OnChangeFn;
- // ── Expansion ────────────────────────────────────────────────────────
- /** Function to get sub-rows for a row. Enables expandable rows. */
- getRowSubRows?: (row: TData) => TData[];
- /** Function to determine if a row can expand. Default: checks getRowSubRows. */
- getRowCanExpand?: (row: TData) => boolean;
- initialExpanded?: ExpandedState;
- onExpandedChange?: OnChangeFn;
+ // ── Row detail (disclosure pattern) ──────────────────────────────────
+ /**
+ * Predicate — when `true`, the row shows a detail disclosure. The consumer
+ * renders the detail content via ``. Default: every row
+ * can have a detail (consumers opt in per row via the template).
+ */
+ getRowCanShowDetail?: (row: TData) => boolean;
+ /** Initially-open row-detail map keyed by row id. */
+ initialRowDetailOpen?: RowDetailState;
+ /** Fires on any row-detail open/close change. */
+ onRowDetailOpenChange?: OnChangeFn;
/** Total row count (for server-side pagination). */
rowCount?: number;
@@ -383,12 +382,13 @@ export interface TableInstance