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.
359 lines
16 KiB
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"><{part.defaultElement}></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><div></code> — wrap interactive content with proper roles.</p>
|
|
</section>
|
|
{/if}
|
|
</div>
|