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/web/routes/uix/components/feed/+page.svelte

542 lines
22 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<script lang="ts">
import {
Feed,
type FeedSize,
type FeedVariant,
type FeedColor
} from '$uix/eidos/components/feed';
import { Button } from '$uix/eidos/components/button';
import { compileMorfo } from '$uix/morfo';
import { feedMorfo } from '@/uix/morfo/components/feed';
import { getActiveUix } from '$active-uix';
const uix = getActiveUix();
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
// ── Sample data ──────────────────────────────────────────────────────
type Reply = { id: string; author: string; body: string };
type Post = { id: string; title: string; body: string; replies?: Reply[] };
const SEED: Post[] = [
{ id: 'p1', title: 'Shipped soma TreeGrid', body: 'Hierarchical rows + columns over CSS grid. RowChildren span 1/-1 to keep alignment.', replies: [
{ id: 'r1', author: 'Ada', body: 'Nice — does it cope with deeply nested levels?' },
{ id: 'r2', author: 'Ben', body: 'Caps at 8 in the recipe, deeper trees override the indent var.' }
] },
{ id: 'p2', title: 'Auto-derived heading level for threads', body: 'Top-level articles are h3, nested h4, double-nested h5. Aria-level matches.' },
{ id: 'p3', title: 'PageDown at the end fires onLoadMore', body: 'No JS scroll listener needed — the Provider intercepts keydown on focused articles.' },
{ id: 'p4', title: 'IntersectionObserver sentinel', body: 'Drop a Sentinel inside the Feed. When it scrolls into view, onIntersect fires.', replies: [
{ id: 'r3', author: 'Cora', body: 'Defaults to the Feed\'s onLoadMore — works out of the box.' }
] }
];
let posts = $state<Post[]>(SEED);
let busy = $state(false);
let totalItems = $state<number | undefined>(undefined);
let useSentinel = $state(true);
function loadMore() {
if (busy) return;
busy = true;
setTimeout(() => {
const nextId = posts.length + 1;
posts = [
...posts,
{
id: `p${nextId}`,
title: `Lazy-loaded post #${nextId}`,
body: `Sentinel-triggered article ${nextId}. ${busy ? 'busy' : 'idle'}.`
}
];
busy = false;
}, 600);
}
// ── Visual props ─────────────────────────────────────────────────────
let size = $state<FeedSize>('md');
let variant = $state<FeedVariant>('surface');
let color = $state<FeedColor>('primary');
let block = $state(true);
let useScrollRegion = $state(false);
// ── i18n ─────────────────────────────────────────────────────────────
let locale = $state('en-US');
let dir = $state<'ltr' | 'rtl'>('ltr');
const compiled = compileMorfo(feedMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Feed } from '$uix/eidos/components/feed';",
' let posts = $state<Post[]>([]);',
' let busy = $state(false);',
' function loadMore() { /* fetch next page */ }',
'</' + 'script>',
'',
'<Feed',
' {busy}',
' onLoadMore={loadMore}',
dir !== 'ltr' && ` dir="${dir}"`,
totalItems !== undefined && ` totalItems={${totalItems}}`,
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
!block && ' block={false}',
useScrollRegion && ' maxHeight="24rem"',
'>',
' {#each posts as post (post.id)}',
' <Feed.Article>',
' <Feed.ArticleTitle>{post.title}</Feed.ArticleTitle>',
' <Feed.ArticleDescription>{post.body}</Feed.ArticleDescription>',
' </Feed.Article>',
' {/each}',
useSentinel && ' <Feed.Sentinel />',
'</Feed>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner {dir}>
<header>
<div data-uix-eyebrow>Data · Feed</div>
<h1 data-uix-page-title>Feed</h1>
<p data-uix-page-lede>
Scrollable activity stream — <code>role="feed"</code> of
<code>role="article"</code> children. PageUp/PageDown navigates
articles; the Sentinel fires <code>onLoadMore</code> when it
scrolls into view. Use Feed for activity streams, chat
histories, threaded comments — anything where the article
count grows lazily. Use <code>GridList</code> /
<code>Listbox</code> for bounded selection.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>{events.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>sizes</span>5
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>apg</span>
<a href={feedMorfo.apg} target="_blank" rel="noreferrer">feed ↗</a>
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>posts</span> {posts.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>busy</span> {busy ? 'loading' : 'idle'}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>locale</span> {locale}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>dir</span> {dir}
</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef} style="padding: var(--uix-space-3); display: flex; justify-content: center;">
<div style="inline-size: min(36rem, 100%);">
<Feed
{busy}
{totalItems}
{dir}
{size}
{variant}
{color}
{block}
aria-label="Activity stream"
maxHeight={useScrollRegion ? '24rem' : undefined}
onLoadMore={loadMore}
>
{#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>
<Feed.ArticleTitle>{r.author}</Feed.ArticleTitle>
<Feed.ArticleDescription>{r.body}</Feed.ArticleDescription>
</Feed.Article>
{/each}
</Feed.Thread>
{/if}
</Feed.Article>
{/each}
{#if useSentinel}
<Feed.Sentinel disabled={busy}>
{busy ? 'Loading more…' : 'Scroll to load more'}
</Feed.Sentinel>
{/if}
</Feed>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>focus an article + PageDown to navigate; scroll to fire sentinel…</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent ? ' · ' + entry.intent : ''}</span>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>posts</span> {posts.length}
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
API <span data-uix-tab-count>6</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
Soma owns PageUp/PageDown nav, Ctrl+Home/End jumps, auto-derived aria-level for threads, IntersectionObserver sentinel wiring. Eidos adds card chrome + thread indent + sentinel spinner.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> i18n
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>locale</span>
<select bind:value={locale}>
<option value="en-US">en-US</option>
<option value="es-ES">es-ES</option>
<option value="fr-FR">fr-FR</option>
</select>
</label>
<label data-uix-control>
<span data-uix-control-label>direction</span>
<span data-uix-chips role="radiogroup">
{#each ['ltr', 'rtl'] as v (v)}
<button data-uix-chip data-active={dir === v} onclick={() => (dir = v as 'ltr' | 'rtl')}>{v}</button>
{/each}
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> feature toggles
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>totalItems</span>
<span data-uix-chips role="radiogroup">
<button data-uix-chip data-active={totalItems === undefined} onclick={() => (totalItems = undefined)}>unknown</button>
<button data-uix-chip data-active={totalItems === 20} onclick={() => (totalItems = 20)}>20</button>
<button data-uix-chip data-active={totalItems === 100} onclick={() => (totalItems = 100)}>100</button>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>Sentinel</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={useSentinel} />
<span data-uix-switch-label>{useSentinel ? 'auto-load on scroll' : 'manual'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>busy</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={busy} />
<span data-uix-switch-label>{busy ? 'aria-busy' : 'idle'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>actions</span>
<span style="display: flex; gap: var(--uix-space-1); align-items: center;">
<Button size="sm" variant="ghost" onclick={loadMore} disabled={busy}>Load more</Button>
<Button size="sm" variant="ghost" onclick={() => (posts = SEED)}>Reset</Button>
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> visual props
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each ['surface', 'outline', 'ghost'] as v (v)}
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v as FeedVariant)}>{v}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each ['xs', 'sm', 'md', 'lg', 'xl'] as s (s)}
<button data-uix-chip data-active={size === s} onclick={() => (size = s as FeedSize)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each ['primary', 'secondary', 'neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as c (c)}
<button data-uix-chip data-active={color === c} onclick={() => (color = c as FeedColor)}>{c}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>block</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={block} />
<span data-uix-switch-label>{block ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>scrollable</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={useScrollRegion} />
<span data-uix-switch-label>{useScrollRegion ? 'maxHeight=24rem' : 'off'}</span>
</span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · Feed + Article + Sentinel</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>6 parts: <code>Provider · Article · ArticleTitle · ArticleDescription · Thread · Sentinel</code>. Provider auto-wires <code>aria-labelledby</code> / <code>aria-describedby</code> on each Article. Thread depth drives auto-derived heading level (h3 → h4 → h5 …).</p>
<div data-uix-subsection-head>Provider (Feed)</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">totalItems</td><td class="type">number | undefined</td><td class="default empty">—</td><td>Total across all pages. <code>undefined</code> = unknown (AT reads "n of unknown").</td></tr>
<tr><td class="name">busy</td><td class="type">boolean</td><td class="default">false</td><td>Sets <code>aria-busy</code> while loading.</td></tr>
<tr><td class="name">onLoadMore</td><td class="type">() => void</td><td class="default empty">—</td><td>Fired on PageDown-past-last or Sentinel intersection.</td></tr>
<tr><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'surface' | 'outline' | 'ghost'</td><td class="default">'surface'</td><td>Article card chrome.</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'xs' | 'sm' | 'md' | 'lg' | 'xl'</td><td class="default">'md'</td><td>Padding + thread indent + font scale.</td></tr>
<tr><td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ColorRole</td><td class="default">'primary'</td><td>Focus ring + sentinel spinner.</td></tr>
<tr><td class="name">block <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">boolean</td><td class="default">true</td><td>Stretch to container width.</td></tr>
<tr><td class="name">maxHeight <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">string</td><td class="default empty">—</td><td>Wraps the feed in a scroll region.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Article · ArticleTitle · Sentinel</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">Article.posinset</td><td class="type">number</td><td class="default">auto</td><td>1-based position in full set. Auto when DOM order matches feed order.</td></tr>
<tr><td class="name">ArticleTitle.level</td><td class="type">1–6</td><td class="default">3 + threadDepth</td><td>Heading level. Auto-derived from enclosing Thread depth.</td></tr>
<tr><td class="name">Sentinel.rootMargin</td><td class="type">string</td><td class="default">'200px'</td><td>IntersectionObserver rootMargin.</td></tr>
<tr><td class="name">Sentinel.onIntersect</td><td class="type">() => void</td><td class="default">Feed.onLoadMore</td><td>Defaults to firing the parent Feed's onLoadMore.</td></tr>
<tr><td class="name">Sentinel.disabled</td><td class="type">boolean</td><td class="default">false</td><td>Skip observer setup.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span>
· declarative contract
</h2>
<p data-uix-section-desc>Source: <code>src/uix/morfo/components/feed.ts</code>.</p>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>States</th><th>Optional</th></tr></thead>
<tbody>
{#each partsList as part (part.kebab)}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.archetype ?? '—'}</td>
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div data-uix-subsection-head>Events declaration</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>intent</th><th>target</th></tr></thead>
<tbody>
{#each events as action (action.name)}
{@const sem = action.semantic}
{@const intentDecl = 'intent' in sem ? sem.intent : undefined}
{@const intentStr = typeof intentDecl === 'string' ? intentDecl : intentDecl ? `fromProp:${(intentDecl as { fromProp?: string }).fromProp ?? '?'}` : '—'}
<tr>
<td class="name">{action.name}</td>
<td class="type">{sem.family}</td>
<td>{sem.verb ?? '—'}</td>
<td>{sem.sequence ?? 'pre'}</td>
<td class="default">{intentStr}</td>
<td>{action.target}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section id="sema">
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span>
· events
</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Play</th></tr></thead>
<tbody>
{#each events as action (action.name)}
{@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined}
{@const effectiveIntent = typeof intentDecl === 'string' ? intentDecl : undefined}
<tr>
<td class="name">{action.name}</td>
<td class="type">{action.semantic.family}</td>
<td>{action.semantic.verb ?? '—'}</td>
<td>{action.semantic.sequence ?? 'pre'}</td>
<td class="default">{effectiveIntent ?? '—'}</td>
<td>
<button
data-uix-play
onclick={() => {
const target = (stageRef?.querySelector('[data-feed-article]') ?? stageRef) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: action.name,
family: action.semantic.family,
target,
...(effectiveIntent ? { intent: effectiveIntent } : {})
});
}}
>▶ play</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>Selectors at <code>src/uix/eidos/components/feed/feed.css</code>.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th><th>What it paints</th></tr></thead>
<tbody>
<tr><td class="name">[data-feed-root]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Wrapper — chrome, scroll region.</td></tr>
<tr><td class="name">[data-feed-root][data-variant] [data-feed-article]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Article card chrome per variant.</td></tr>
<tr><td class="name">[data-feed-article-title][aria-level]</td><td><span data-uix-tag data-kind="soma">morfo</span></td><td>Font-size scaled by heading level.</td></tr>
<tr><td class="name">[data-feed-thread]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Nested feed with accent border indent.</td></tr>
<tr><td class="name">[data-feed][data-busy] [data-feed-sentinel]::before</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Spinner during load.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>Follows the APG <a href={feedMorfo.apg} target="_blank" rel="noreferrer">feed pattern</a>. Root is <code>role="feed"</code>, each child is <code>role="article"</code> with <code>aria-posinset</code> / <code>aria-setsize</code> auto-wired. <code>aria-setsize="-1"</code> when total is unknown (per APG).</p>
<div data-uix-subsection-head>Keyboard</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
<tr><td><span data-uix-kbd>PageDown</span> (on Article)</td><td>Focus next article. At the last one, calls <code>onLoadMore</code>.</td></tr>
<tr><td><span data-uix-kbd>PageUp</span> (on Article)</td><td>Focus previous article.</td></tr>
<tr><td><span data-uix-kbd>Ctrl+Home</span> / <span data-uix-kbd>Ctrl+End</span></td><td>First / last article.</td></tr>
<tr><td><span data-uix-kbd>Tab</span> (within Article)</td><td>Reach inner interactive elements per native tab order.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

Powered by TurnKey Linux.