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/virtual-list/+page.svelte

359 lines
16 KiB

<script lang="ts">
import {
VirtualList,
type VirtualListSize,
type VirtualListVariant,
type VirtualListColor,
type VirtualListProviderSnippetProps
} from '$uix/eidos/components/virtual-list';
import { Button } from '$uix/eidos/components/button';
import { compileMorfo } from '$uix/morfo';
import { virtualListMorfo } from '@/uix/morfo/components/virtual-list';
import { getActiveUix } from '$active-uix';
const uix = getActiveUix();
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
// ── 10k items ───────────────────────────────────────────────────────
const ITEMS = Array.from({ length: 10_000 }, (_, i) => ({
id: `row-${i}`,
label: `Row #${i + 1}`,
detail: `Synthetic row data ${i + 1} — virtualised so only ~15 DOM nodes render at a time.`
}));
let count = $state(10_000);
let itemSize = $state(48);
let overscan = $state(5);
let orientation = $state<'vertical' | 'horizontal'>('vertical');
let useDynamicSize = $state(false);
// ── Visual ──────────────────────────────────────────────────────────
let size = $state<VirtualListSize>('md');
let variant = $state<VirtualListVariant>('surface');
let color = $state<VirtualListColor>('primary');
let block = $state(true);
const compiled = compileMorfo(virtualListMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
let providerSnippetProps: VirtualListProviderSnippetProps | null = $state(null);
function scrollToRandom() {
const idx = Math.floor(Math.random() * count);
providerSnippetProps?.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
}
function scrollToEnd() {
providerSnippetProps?.scrollToIndex(count - 1, { align: 'end' });
}
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { VirtualList } from '$uix/eidos/components/virtual-list';",
'</' + 'script>',
'',
'<VirtualList',
` count={${count}}`,
useDynamicSize ? ' estimateSize={(i) => 40 + (i % 3) * 20}' : ` itemSize={${itemSize}}`,
overscan !== 5 && ` overscan={${overscan}}`,
orientation !== 'vertical' && ` orientation="${orientation}"`,
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
'>',
' {#snippet children({ virtualItems, totalSize })}',
' <VirtualList.Viewport style="height: 24rem;">',
' <div style="position: relative; height: {totalSize}px;">',
' {#each virtualItems as v (v.key)}',
' <VirtualList.Item index={v.index} start={v.start} size={v.size}>',
' Row #{v.index + 1}',
' </VirtualList.Item>',
' {/each}',
' </div>',
' </VirtualList.Viewport>',
' {/snippet}',
'</VirtualList>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Data · VirtualList</div>
<h1 data-uix-page-title>VirtualList</h1>
<p data-uix-page-lede>
Windowed list rendering — given <code>count</code> and
<code>itemSize</code> (or <code>estimateSize</code> for
dynamic heights), only the rows in the visible range plus
<code>overscan</code> get mounted. 10k rows render the same
~15 DOM nodes as 100. Compose with <code>Feed</code> /
<code>GridList</code> for large lists; use bare for raw
windowed scrolling.
</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>count</span> {count.toLocaleString()}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>itemSize</span> {useDynamicSize ? 'dynamic' : `${itemSize}px`}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>orientation</span> {orientation}
</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area style="padding: var(--uix-space-3); display: flex; justify-content: center;">
<div style="inline-size: min(36rem, 100%);">
<VirtualList
{count}
itemSize={useDynamicSize ? undefined : itemSize}
estimateSize={useDynamicSize ? (i) => 40 + (i % 3) * 20 : undefined}
{overscan}
{orientation}
{size}
{variant}
{color}
{block}
getItemKey={(i) => ITEMS[i]?.id ?? i}
>
{#snippet children(snippetProps)}
{@const { virtualItems, totalSize } = snippetProps}
{(providerSnippetProps = snippetProps, '')}
<VirtualList.Viewport style={orientation === 'vertical' ? 'block-size: 24rem;' : 'block-size: 6rem; inline-size: 100%;'}>
<div style="position: relative; {orientation === 'vertical' ? `block-size: ${totalSize}px; inline-size: 100%;` : `inline-size: ${totalSize}px; block-size: 100%;`}">
{#each virtualItems as v (v.key)}
<VirtualList.Item index={v.index} start={v.start} size={v.size}>
{#if orientation === 'vertical'}
<div style="display: flex; align-items: center; gap: var(--uix-space-3); padding: var(--uix-space-2) var(--uix-space-3); border-block-end: 1px solid var(--color-border-default); block-size: 100%;">
<span style="color: var(--color-content-secondary); font-family: monospace; font-size: var(--font-size-xs); inline-size: 4rem;">{ITEMS[v.index]?.id}</span>
<strong>{ITEMS[v.index]?.label}</strong>
<span style="color: var(--color-content-secondary); font-size: var(--font-size-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{ITEMS[v.index]?.detail}</span>
</div>
{:else}
<div style="display: flex; flex-direction: column; justify-content: center; gap: 0.25rem; padding: var(--uix-space-2); border-inline-end: 1px solid var(--color-border-default); block-size: 100%; inline-size: 100%;">
<strong style="font-size: var(--font-size-sm);">{ITEMS[v.index]?.label}</strong>
<span style="color: var(--color-content-secondary); font-family: monospace; font-size: var(--font-size-xs);">{ITEMS[v.index]?.id}</span>
</div>
{/if}
</VirtualList.Item>
{/each}
</div>
</VirtualList.Viewport>
{/snippet}
</VirtualList>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>note</span>
<span>scroll the list — DOM size stays constant. <code>scrollToIndex</code> exposed via snippet props.</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>4</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 drives the windowing math, the size cache, ResizeObserver measurement (for dynamic sizes), and the imperative <code>scrollToIndex</code>. Eidos adds chrome.</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> data + sizing
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>count</span>
<span data-uix-chips role="radiogroup">
{#each [100, 1000, 10_000, 100_000] as n (n)}
<button data-uix-chip data-active={count === n} onclick={() => (count = n)}>{n.toLocaleString()}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>itemSize</span>
<span data-uix-chips role="radiogroup">
{#each [32, 48, 64, 96] as n (n)}
<button data-uix-chip data-active={itemSize === n && !useDynamicSize} onclick={() => { itemSize = n; useDynamicSize = false; }}>{n}px</button>
{/each}
<button data-uix-chip data-active={useDynamicSize} onclick={() => (useDynamicSize = !useDynamicSize)}>dynamic</button>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>overscan</span>
<span data-uix-chips role="radiogroup">
{#each [0, 2, 5, 10] as n (n)}
<button data-uix-chip data-active={overscan === n} onclick={() => (overscan = n)}>{n}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>orientation</span>
<span data-uix-chips role="radiogroup">
{#each ['vertical', 'horizontal'] as o (o)}
<button data-uix-chip data-active={orientation === o} onclick={() => (orientation = o as 'vertical' | 'horizontal')}>{o}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>imperative</span>
<span style="display: flex; gap: var(--uix-space-1);">
<Button size="sm" variant="ghost" onclick={scrollToRandom}>Scroll to random</Button>
<Button size="sm" variant="ghost" onclick={scrollToEnd}>Scroll to end</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 VirtualListVariant)}>{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 VirtualListSize)}>{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 VirtualListColor)}>{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>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · VirtualList + Viewport + Item</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>4 parts: <code>Provider · Viewport · WindowViewport · Item</code>. Use <code>Viewport</code> for internal scrolling, <code>WindowViewport</code> for window-driven scroll (full-page feeds).</p>
<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">count</td><td class="type">number</td><td class="default empty">required</td><td>Total row count.</td></tr>
<tr><td class="name">itemSize</td><td class="type">number</td><td class="default empty">—</td><td>Fixed row size (px). Fast path — skips measurement.</td></tr>
<tr><td class="name">estimateSize</td><td class="type">(i) => number</td><td class="default">() => 40</td><td>Per-index estimate for dynamic sizing. Measured rows replace the estimate.</td></tr>
<tr><td class="name">overscan</td><td class="type">number</td><td class="default">5</td><td>Extra rows rendered above/below the visible range.</td></tr>
<tr><td class="name">orientation</td><td class="type">'vertical' | 'horizontal'</td><td class="default">'vertical'</td><td>Scroll axis.</td></tr>
<tr><td class="name">getItemKey</td><td class="type">(i) => string|number</td><td class="default">(i) => i</td><td>Stable key for <code>{`{#each}`}</code> + size cache.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Snippet props (children)</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">virtualItems</td><td class="type">VirtualItem[]</td><td>Items currently in visible range + overscan.</td></tr>
<tr><td class="name">totalSize</td><td class="type">number</td><td>Total scrollable size (px).</td></tr>
<tr><td class="name">scrollToIndex</td><td class="type">fn</td><td>Imperative scroll to a specific index.</td></tr>
<tr><td class="name">isEmpty</td><td class="type">boolean</td><td><code>count === 0</code>.</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>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</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.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title><span data-uix-layer-badge="sema">sema</span> · events</h2>
<p data-uix-section-desc>VirtualList is a pure performance primitive — soma exposes no sema events (no user-driven commit / shift). Compose with Feed / GridList for evented experiences.</p>
</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/virtual-list/virtual-list.css</code>. Eidos only paints the wrapper chrome — Item positioning is driven by soma's inline styles.</p>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>VirtualList is a primitive — it imposes no role on its container. Compose with Feed / Listbox / GridList / TreeView when you need ARIA semantics. The Item is just a positioned <code>&lt;div&gt;</code> — wrap interactive content with proper roles.</p>
</section>
{/if}
</div>

Powered by TurnKey Linux.