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

567 lines
18 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 { Slider, type SliderSize, type SliderType } from '$uix/eidos/components/slider';
import { compileMorfo } from '$uix/morfo';
import { sliderMorfo } from '@/uix/morfo/components/slider';
import { getActiveUix } from '$active-uix';
import TokensPanel from '../../lib/TokensPanel.svelte';
import TokensTabCount from '../../lib/TokensTabCount.svelte';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'tokens' | 'a11y';
type Orientation = 'horizontal' | 'vertical';
type Direction = 'ltr' | 'rtl';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
const uix = getActiveUix();
let tab = $state<Tab>('live');
let type = $state<SliderType>('single');
let value = $state([40]);
let min = $state(0);
let max = $state(100);
let step = $state(1);
let orientation = $state<Orientation>('horizontal');
let disabled = $state(false);
let dirMode = $state<'auto' | Direction>('auto');
let size = $state<SliderSize>('md');
// `SecondaryRange` renders at zero size without a value, so the part stays
// mounted and this single knob drives it. Expressed as a percentage of the
// travel so it survives min / max edits.
let secondaryPct = $state(0);
// `auto` passes no prop at all, so the Slider resolves the documented chain
// `prop -> soma.prefs.getDir() -> 'ltr'` and follows the shell's direction
// switch. Picking ltr/rtl shows the prop overriding prefs.
const dir = $derived(dirMode === 'auto' ? undefined : dirMode);
const secondaryValue = $derived(
secondaryPct === 0 ? undefined : min + ((max - min) * secondaryPct) / 100
);
const compiled = compileMorfo(sliderMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
const tickValues = $derived([min, Math.round((min + max) / 2), max]);
const valueText = $derived(value.join(' – '));
const thumbLabels = $derived(type === 'multiple' ? ['Minimum', 'Maximum'] : ['Value']);
let stageRef = $state<HTMLElement | null>(null);
let trace = $state<TraceEntry[]>([]);
$effect(() => {
if (type === 'single' && value.length !== 1) value = [value[0] ?? min];
if (type === 'multiple' && value.length < 2) value = [min + 20, max - 20];
});
$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 somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as Slider from '$soma/components/slider';",
` let value = $state([${value.join(', ')}]);`,
'</' + 'script>',
'',
`<Slider.Provider bind:value min={${min}} max={${max}}${secondaryValue === undefined ? '' : ` secondaryValue={${Math.round(secondaryValue)}}`} aria-label="Volume">`,
secondaryValue !== undefined && ' <Slider.SecondaryRange />',
' <Slider.Range />',
' <Slider.Thumb />',
' <Slider.Tick value={50} />',
'</Slider.Provider>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { Slider } from '$uix/eidos/components/slider';",
` let value = $state([${value.join(', ')}]);`,
'</' + 'script>',
'',
`<Slider bind:value min={${min}} max={${max}} size="${size}"${secondaryValue === undefined ? '' : ` secondaryValue={${Math.round(secondaryValue)}}`} aria-label="Volume">`,
secondaryValue !== undefined && ' <Slider.SecondaryRange />',
' <Slider.Range />',
' <Slider.Thumb />',
' <Slider.Tick value={50} />',
'</Slider>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Controls · Slider</div>
<h1 data-uix-page-title>Slider</h1>
<p data-uix-page-lede>
Slider primitive for single or multiple thumbs. Soma owns pointer, keyboard, value and ARIA;
Eidos adds visual size.
</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>keyboard</span>8</span>
<span data-uix-meta-pill><span data-uix-meta-key>sizes</span>3</span>
</div>
</header>
<div data-uix-stage bind:this={stageRef}>
<div data-uix-stage-area>
<div style="inline-size: min(30rem, 100%);">
<Slider
bind:value
{type}
{min}
{max}
{step}
{orientation}
{disabled}
{dir}
{thumbLabels}
{size}
{secondaryValue}
aria-label="Volume"
>
<Slider.SecondaryRange />
<Slider.Range />
<Slider.Thumb index={0} />
{#if type === 'multiple'}
<Slider.Thumb index={1} />
{/if}
{#each tickValues as tick}
<Slider.Tick value={tick} />
{/each}
</Slider>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>drag or commit a value to see sema events</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>value</span>
{valueText} · {type} · {size}
</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>11</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 === 'tokens'} onclick={() => (tab = 'tokens')}>
Tokens <TokensTabCount component="slider" />
</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>
<span data-uix-layer-badge="soma">soma</span> owns range interaction.
<span data-uix-layer-badge="eidos">eidos</span> owns size.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · range model
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label
>first value <span data-uix-control-hint>{value[0]}</span></span
>
<input
type="range"
{min}
{max}
{step}
value={value[0] ?? min}
oninput={(event) =>
(value = [
Number(event.currentTarget.value),
...(type === 'multiple' ? [value[1] ?? max] : [])
])}
/>
</label>
{#if type === 'multiple'}
<label data-uix-control>
<span data-uix-control-label
>second value <span data-uix-control-hint>{value[1]}</span></span
>
<input
type="range"
{min}
{max}
{step}
value={value[1] ?? max}
oninput={(event) => (value = [value[0] ?? min, Number(event.currentTarget.value)])}
/>
</label>
{/if}
<label data-uix-control>
<span data-uix-control-label
>secondaryValue <span data-uix-control-hint
>band under the Range; mirrors in RTL. 0 = none</span
></span
>
<input type="range" min="0" max="100" step="5" bind:value={secondaryPct} />
<span data-uix-control-value>{secondaryPct === 0 ? '—' : `${secondaryPct}%`}</span>
</label>
<label data-uix-control
><span data-uix-control-label>min</span><input type="number" bind:value={min} /></label
>
<label data-uix-control
><span data-uix-control-label>max</span><input type="number" bind:value={max} /></label
>
<label data-uix-control
><span data-uix-control-label>step</span><input
type="number"
min="1"
bind:value={step}
/></label
>
<label data-uix-control>
<span data-uix-control-label>type</span>
<span data-uix-chips role="radiogroup">
{#each ['single', 'multiple'] as t}
<button
data-uix-chip
data-active={type === t}
onclick={() => (type = t as SliderType)}>{t}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>orientation</span>
<span data-uix-chips role="radiogroup">
{#each ['horizontal', 'vertical'] as o}
<button
data-uix-chip
data-active={orientation === o}
onclick={() => (orientation = o as Orientation)}>{o}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>direction <span data-uix-control-hint>auto = no prop, follows uix.prefs.direction</span
></span
>
<span data-uix-chips role="radiogroup">
{#each ['auto', 'ltr', 'rtl'] as d}
<button
data-uix-chip
data-active={dirMode === d}
data-perm-step={d === 'rtl' ? 0 : d === 'ltr' ? 1 : undefined}
data-perm-label="dir {d}"
onclick={() => (dirMode = d as typeof dirMode)}>{d}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>disabled</span>
<span data-uix-switch
><input type="checkbox" bind:checked={disabled} /><span data-uix-switch-label
>{disabled ? 'on' : 'off'}</span
></span
>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</div>
<div data-uix-controls>
<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}
<button
data-uix-chip
data-active={size === s}
onclick={() => (size = s as SliderSize)}>{s}</button
>
{/each}
</span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · slider model</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · adds size</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 surface</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Owner</th><th>Default</th><th>Notes</th></tr></thead>
<tbody>
<tr
><td class="name">type</td><td>soma</td><td class="default">single</td><td
>single | multiple</td
></tr
>
<tr
><td class="name">value</td><td>soma</td><td class="default">[0]</td><td
>Bindable numeric array</td
></tr
>
<tr
><td class="name">min / max / step</td><td>soma</td><td class="default"
>0 / 100 / 1</td
><td>Range math</td></tr
>
<tr
><td class="name">orientation / dir</td><td>soma</td><td class="default"
>horizontal / prefs.getDir()</td
><td
>Layout and keyboard direction. Omitting <code>dir</code> resolves it from
<code>uix.prefs.direction</code>, then <code>ltr</code>.</td
></tr
>
<tr
><td class="name">thumbLabels</td><td>soma</td><td class="default">—</td><td
>Per-thumb accessible labels</td
></tr
>
<tr
><td class="name">size</td><td>eidos</td><td class="default">md</td><td
>sm | md | lg</td
></tr
>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo 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}
<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>
<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>
{#each sliderMorfo.parts[2].keyboard ?? [] as key}
<tr><td class="name">{key.key}</td><td>{key.action}</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 + perceptual signature
</h2>
<p data-uix-section-desc>
Slider separates continuous drag feedback from the final value commit:
<code>handle-drag</code> is coincident, <code>commit-set</code> is post-commit.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead
><tr
><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Target</th><th>Play</th
></tr
></thead
>
<tbody>
{#each events as action}
<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>{action.target}</td>
<td>
<button
data-uix-play
onclick={() => {
const target = stageRef?.querySelector('[data-slider]') as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: action.name,
family: action.semantic.family,
target
});
}}>▶ 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 live in <code>src/uix/eidos/components/slider/slider.css</code> and consume Soma
range/thumb/tick attrs plus Eidos <code>data-size</code>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>[data-slider]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Root range model and orientation state.</td>
</tr>
<tr>
<td class="name"><code>[data-slider][data-size]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Visual scale for track, range and thumbs.</td>
</tr>
<tr>
<td class="name"><code>[data-slider-range]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Filled range segment derived from the current value.</td>
</tr>
<tr>
<td class="name"><code>[data-slider-thumb][data-state][data-dragging]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Interactive thumb state and drag feedback.</td>
</tr>
<tr>
<td class="name"><code>[data-slider-tick][data-active]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Optional tick state relative to the current value.</td>
</tr>
<tr>
<td class="name"><code>[data-slider][data-event]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Transient perceptual event styling from Sema projection.</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'tokens'}
<TokensPanel component="slider" stage={stageRef ?? undefined} />
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr
><td class="name">Group</td><td>Root exposes a named <code>role="group"</code>.</td
></tr
>
<tr><td class="name">Thumbs</td><td>Each thumb uses <code>role="slider"</code>.</td></tr
>
<tr><td class="name">Value</td><td>Thumbs expose min, max, now and orientation.</td></tr
>
<tr
><td class="name">Keyboard</td><td
>Arrows, PageUp/PageDown and Home/End are declared.</td
></tr
>
</tbody>
</table>
</div>
</section>
{/if}
</div>

Powered by TurnKey Linux.