|
|
5 months ago | |
|---|---|---|
| .. | ||
| components | 5 months ago | |
| README.md | 6 months ago | |
| exports.ts | 6 months ago | |
| feed-provider.svelte.test.ts | 5 months ago | |
| feed-provider.svelte.ts | 5 months ago | |
| index.ts | 6 months ago | |
| langs.ts | 5 months ago | |
| types.ts | 6 months ago | |
README.md
Feed
Implements the WAI-ARIA Feed pattern — a scrollable stream of role="article" children where assistive tech navigates between articles with PageUp / PageDown. Each article exposes its position via aria-posinset / aria-setsize so the user always knows where they are.
Use Feed for activity streams, chat histories, threaded comments — anything where the article count can grow via lazy loading. Use GridList or Listbox instead when items are bounded and the user picks one.
Anatomy
<Feed.Provider {totalItems} {busy} {onLoadMore}>
{#each posts as post (post.id)}
<Feed.Article>
<Feed.ArticleTitle level={3}>{post.title}</Feed.ArticleTitle>
<Feed.ArticleDescription>{post.body}</Feed.ArticleDescription>
</Feed.Article>
{/each}
</Feed.Provider>
Parts
| Part | Element | Description |
|---|---|---|
Provider |
<div> |
role="feed". Coordinates PageUp/PageDown navigation. |
Article |
<div> |
role="article". Focusable, auto-computed aria-posinset / aria-setsize / level. |
ArticleTitle |
<div> |
role="heading" + auto-derived aria-level from thread depth. |
ArticleDescription |
<div> |
Linked via aria-describedby on the enclosing Article. |
Thread |
<div> |
Nested sub-feed (role="feed"). Child articles inherit level + 1. |
Sentinel |
<div> |
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:
aria-setsize="-1"means "total unknown" (soma emits this whentotalItemsis 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 for long streams:
<Feed.Provider aria-label="Activity">
<VirtualList.Provider count={posts.length} itemSize={120} getItemKey={(i) => posts[i].id}>
{#snippet children({ virtualItems, totalSize })}
<VirtualList.Viewport class="feed-viewport">
<div style:height="{totalSize}px" style:position="relative">
{#each virtualItems as v (v.key)}
<VirtualList.Item index={v.index} start={v.start} size={v.size}>
<Feed.Article>
<Feed.ArticleTitle>{posts[v.index].title}</Feed.ArticleTitle>
<Feed.ArticleDescription>{posts[v.index].body}</Feed.ArticleDescription>
</Feed.Article>
</VirtualList.Item>
{/each}
</div>
</VirtualList.Viewport>
{/snippet}
</VirtualList.Provider>
<Feed.Sentinel onIntersect={loadMore} />
</Feed.Provider>
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
<script>
let posts = $state<Post[]>([]);
let busy = $state(false);
let done = $state(false);
async function loadMore() {
if (busy || done) return;
busy = true;
const page = await fetchPosts({ after: posts.at(-1)?.id });
if (page.length === 0) done = true;
else posts = [...posts, ...page];
busy = false;
}
</script>
<Feed.Provider {busy} onLoadMore={loadMore} aria-label="Activity">
{#each posts as post (post.id)}
<Feed.Article>
<Feed.ArticleTitle>{post.title}</Feed.ArticleTitle>
<Feed.ArticleDescription>{post.body}</Feed.ArticleDescription>
</Feed.Article>
{/each}
</Feed.Provider>
Known total (paginated feed)
<Feed.Provider totalItems={500} aria-label="Comments">
{#each visible as c (c.id)}
<Feed.Article posinset={c.index + 1}>
<!-- index/total available via snippet props -->
</Feed.Article>
{/each}
</Feed.Provider>
IntersectionObserver auto-load
Drop a Feed.Sentinel inside the Provider. When it scrolls into view it fires the Provider's onLoadMore automatically:
<Feed.Provider {busy} onLoadMore={loadMore}>
{#each posts as p (p.id)}
<Feed.Article>...</Feed.Article>
{/each}
<Feed.Sentinel rootMargin="400px" disabled={busy} />
</Feed.Provider>
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-setsizescoped to the thread (not the root feed).aria-levelon the heading bumped by one per nesting level.data-levelattribute on both Thread and Article.
<Feed.Provider aria-label="Discussion">
{#each posts as post (post.id)}
<Feed.Article>
<Feed.ArticleTitle>{post.title}</Feed.ArticleTitle>
<Feed.ArticleDescription>{post.body}</Feed.ArticleDescription>
{#if post.replies?.length}
<Feed.Thread>
{#each post.replies as r (r.id)}
<Feed.Article>
<!-- aria-level is auto: 4 when the parent was level-3 -->
<Feed.ArticleTitle>Reply from {r.author}</Feed.ArticleTitle>
<Feed.ArticleDescription>{r.body}</Feed.ArticleDescription>
</Feed.Article>
{/each}
</Feed.Thread>
{/if}
</Feed.Article>
{/each}
</Feed.Provider>
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.