You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/soma/components/feed
dev 3e1cd3148d
Add feed provider coverage
5 months ago
..
components remove backward-compat re-export shims 5 months ago
README.md morfo: cross-layer contract + 14 new WAI-ARIA components + Dialog wired 6 months ago
exports.ts morfo: cross-layer contract + 14 new WAI-ARIA components + Dialog wired 6 months ago
feed-provider.svelte.test.ts Add feed provider coverage 5 months ago
feed-provider.svelte.ts Drive Feed attrs from Soma runtime 5 months ago
index.ts morfo: cross-layer contract + 14 new WAI-ARIA components + Dialog wired 6 months ago
langs.ts Move simple component translations to morfo 5 months ago
types.ts morfo: cross-layer contract + 14 new WAI-ARIA components + Dialog wired 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 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 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-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.
<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.

Powered by TurnKey Linux.