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

703 lines
25 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 {
ScrollArea,
type ScrollAreaProps,
type ScrollAreaType,
type ScrollAreaSize,
type ScrollAreaRadius,
type ScrollAreaScrollbars
} from '$uix/eidos/components/scroll-area';
import { compileMorfo } from '$uix/morfo';
import { scrollAreaMorfo } from '@/uix/morfo/components/scroll-area';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
// ── Live state ───────────────────────────────────────────────────────
const types: ScrollAreaType[] = ['hover', 'scroll', 'auto', 'always'];
let type = $state<ScrollAreaType>('hover');
let scrollHideDelay = $state<number>(600);
const sizes: ScrollAreaSize[] = ['xs', 'sm', 'md', 'lg'];
let size = $state<ScrollAreaSize>('md');
const radii: ScrollAreaRadius[] = ['none', 'sm', 'md', 'lg', 'full'];
let radius = $state<ScrollAreaRadius>('full');
type Axis = ScrollAreaScrollbars;
const axes: Axis[] = ['vertical', 'horizontal', 'both'];
let axis = $state<Axis>('vertical');
let itemCount = $state<number>(40);
const areaProps = $derived<Partial<ScrollAreaProps>>({
type,
scrollHideDelay,
size,
radius
});
// ── Compiled morfo ───────────────────────────────────────────────────
const compiled = compileMorfo(scrollAreaMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
$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();
});
const sampleItems = $derived(
Array.from({ length: itemCount }, (_, i) => `Item ${String(i + 1).padStart(2, '0')}`)
);
// ── Snippets ─────────────────────────────────────────────────────────
// Soma is headless — no `size` / `radius` / `scrollbars` sugar; the user
// composes Scrollbar / Thumb / Corner parts directly.
const somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as ScrollArea from '$soma/components/scroll-area';",
'</' + 'script>',
'',
'<ScrollArea.Provider',
type !== 'hover' && ` type="${type}"`,
scrollHideDelay !== 600 && ` scrollHideDelay={${scrollHideDelay}}`,
'>',
' <ScrollArea.Viewport>',
' <!-- scrollable content -->',
' </ScrollArea.Viewport>',
(axis === 'vertical' || axis === 'both') && ' <ScrollArea.Scrollbar orientation="vertical">',
(axis === 'vertical' || axis === 'both') && ' <ScrollArea.Thumb />',
(axis === 'vertical' || axis === 'both') && ' </ScrollArea.Scrollbar>',
(axis === 'horizontal' || axis === 'both') &&
' <ScrollArea.Scrollbar orientation="horizontal">',
(axis === 'horizontal' || axis === 'both') && ' <ScrollArea.Thumb />',
(axis === 'horizontal' || axis === 'both') && ' </ScrollArea.Scrollbar>',
axis === 'both' && ' <ScrollArea.Corner />',
'</ScrollArea.Provider>'
]
.filter(Boolean)
.join('\n')
);
// Eidos exposes `size` / `radius` / `scrollbars` sugar. With
// `scrollbars` set, the recipe auto-mounts Scrollbar / Thumb /
// Corner parts — children carry only the Viewport.
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { ScrollArea } from '$uix/eidos/components/scroll-area';",
'</' + 'script>',
'',
'<ScrollArea',
type !== 'hover' && ` type="${type}"`,
scrollHideDelay !== 600 && ` scrollHideDelay={${scrollHideDelay}}`,
size !== 'md' && ` size="${size}"`,
radius !== 'full' && ` radius="${radius}"`,
` scrollbars="${axis}"`,
'>',
' <ScrollArea.Viewport>',
' <!-- scrollable content -->',
' </ScrollArea.Viewport>',
'</ScrollArea>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Layout · ScrollArea</div>
<h1 data-uix-page-title>ScrollArea</h1>
<p data-uix-page-lede>
Custom-scrollbar container. Hides the native scrollbar and renders a custom-painted track +
thumb on top of a scrollable viewport. The headless <span data-uix-layer-badge="soma">soma</span>
layer owns the scroll math, the visibility timer (<code>type</code>) and the thumb
position; Eidos paints chrome only. Compound API mirrors Radix Primitives: explicit
<code>Viewport</code> / <code>Scrollbar</code> / <code>Thumb</code> / <code>Corner</code>
composition.
</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>type</span>{type}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>scope</span>{scrollAreaMorfo.scope.join(', ')}
</span>
</div>
</header>
<!-- Live preview always rendered -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<div
style="inline-size: 100%; max-inline-size: 32rem; block-size: 16rem; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-default); overflow: hidden;"
>
<ScrollArea {...areaProps} scrollbars={axis} style="inline-size: 100%; block-size: 100%;">
<ScrollArea.Viewport style="inline-size: 100%; block-size: 100%;">
<div
style={`padding: var(--space-4); ${axis === 'horizontal' || axis === 'both' ? 'inline-size: 48rem;' : ''}`}
>
<h3
style="margin: 0 0 var(--space-2) 0; color: var(--color-content-primary); font-weight: 600;"
>
Sample list — {itemCount} items
</h3>
<p
style="margin: 0 0 var(--space-3) 0; color: var(--color-content-secondary); font-size: var(--font-size-sm);"
>
Scroll inside the framed box to observe the custom scrollbar painted by Eidos.
The thumb follows the soma-tracked scroll position; the track hover state is
driven by the morfo's <code>data-hover</code> marker.
</p>
<ul style="margin: 0; padding-inline-start: var(--space-5); color: var(--color-content-primary);">
{#each sampleItems as item}
<li style="padding: var(--space-1) 0;">{item} — body text continues across the line for horizontal scroll demos when the axis is wide enough.</li>
{/each}
</ul>
</div>
</ScrollArea.Viewport>
</ScrollArea>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
<span>{trace.length === 0 ? 'no semantic events' : `${trace.length} event(s)`}</span>
<span style="color: var(--uix-text-faint)">·</span>
<span data-uix-stage-trace-key>type</span>
<span>{type}</span>
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>axis</span> {axis} ·
<span data-uix-stage-trace-key>hideDelay</span> {scrollHideDelay}ms
</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>3</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>
All knobs are <span data-uix-layer-badge="soma">soma</span> props — the headless layer
owns visibility timing and scroll math. The <span data-uix-layer-badge="eidos">eidos</span>
layer paints chrome with no additional props; tweak the recipe via the
<code>--scroll-area-*</code> custom properties.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · headless behavior
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>type</span>
<span data-uix-chips role="radiogroup">
{#each types as opt}
<button data-uix-chip data-active={type === opt} onclick={() => (type = opt)}
>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>scrollHideDelay
<span data-uix-control-hint>ms before hiding (hover / scroll only)</span></span
>
<input type="range" min="100" max="2000" step="100" bind:value={scrollHideDelay} />
<span data-uix-control-value>{scrollHideDelay}ms</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · recipe sugar
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label
>size <span data-uix-control-hint>scrollbar thickness (Chakra-style)</span></span
>
<span data-uix-chips role="radiogroup">
{#each sizes as opt}
<button data-uix-chip data-active={size === opt} onclick={() => (size = opt)}
>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>radius <span data-uix-control-hint>thumb curvature</span></span
>
<span data-uix-chips role="radiogroup">
{#each radii as opt}
<button data-uix-chip data-active={radius === opt} onclick={() => (radius = opt)}
>{opt}</button
>
{/each}
</span>
</label>
</div>
<div data-uix-subsection-head>Composition · which scrollbars to mount</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label
>scrollbars <span data-uix-control-hint
>axes auto-mounted by the recipe (Radix Themes-style sugar)</span
></span
>
<span data-uix-chips role="radiogroup">
{#each axes as opt}
<button data-uix-chip data-active={axis === opt} onclick={() => (axis = opt)}
>{opt}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label
>itemCount <span data-uix-control-hint>list length — drives overflow</span></span
>
<input type="range" min="5" max="120" step="5" bind:value={itemCount} />
<span data-uix-control-value>{itemCount}</span>
</label>
</div>
<!-- ── Code snippets per layer ─────────────────────────────────── -->
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · scroll math + visibility timer</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 · pure pass-through of soma; recipe paints chrome</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>
Pass-through from soma — no evaluative additions on the eidos layer. Visual tuning lives
in the recipe via <code>--scroll-area-*</code> custom properties.
</p>
<div data-uix-subsection-head>ScrollArea (root)</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">type</td>
<td class="type">{types.map((t) => `'${t}'`).join(' | ')}</td>
<td class="default">'hover'</td>
<td>Visibility behavior of the scrollbars.</td>
</tr>
<tr>
<td class="name">scrollHideDelay</td>
<td class="type">number</td>
<td class="default">600</td>
<td>ms before hiding (only for 'hover' and 'scroll').</td>
</tr>
<tr>
<td class="name">dir</td>
<td class="type">'ltr' | 'rtl'</td>
<td class="default">from Soma</td>
<td>Text direction. Affects horizontal scrollbar behavior.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>ScrollArea.Scrollbar</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">orientation</td>
<td class="type">'vertical' | 'horizontal'</td>
<td class="default empty">—</td>
<td>Required. Drives the recipe's <code>data-orientation</code> selector.</td>
</tr>
<tr>
<td class="name">forceMount</td>
<td class="type">boolean</td>
<td class="default">false</td>
<td>Keep in DOM when hidden. Enables CSS transitions.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>CSS tokens</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Token</th><th>Default</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td class="name">--scroll-area-scrollbar-size</td>
<td class="type">var(--space-2-5)</td>
<td>Inline-axis size of the track (and block-axis for horizontal).</td>
</tr>
<tr>
<td class="name">--scroll-area-track-bg</td>
<td class="type">color-mix(--color-neutral-track)</td>
<td>Track background, also used by the Corner.</td>
</tr>
<tr>
<td class="name">--scroll-area-thumb-bg</td>
<td class="type">var(--color-content-muted)</td>
<td>Thumb fill; hover state lifts to <code>--color-content-secondary</code>.</td>
</tr>
<tr>
<td class="name">--scroll-area-thumb-min-size</td>
<td class="type">var(--space-3)</td>
<td>Floor on the thumb so it stays grabable even on long content.</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr><th>Library</th><th>Closest equivalent</th><th>Difference</th></tr>
</thead>
<tbody>
<tr>
<td class="name">radix-primitives</td>
<td><code>&lt;ScrollArea&gt;</code></td>
<td>Identical compound shape. UIX adds bare token names + soma layer.</td>
</tr>
<tr>
<td class="name">bits-ui</td>
<td><code>&lt;ScrollArea&gt;</code></td>
<td>Same — UIX matches the Svelte-native shape.</td>
</tr>
<tr>
<td class="name">react-aria-components</td>
<td>n/a (uses native scrolling + virtualization)</td>
<td>UIX paints chrome; React Aria leaves it to the OS / browser.</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<p data-uix-section-desc>
Source: <code>src/uix/morfo/components/scroll-area.ts</code>. Scope is
<code>['soma']</code> — the headless layer is the source of state. Five parts; no
events (see Sema tab).
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td>{scrollAreaMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{scrollAreaMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{scrollAreaMorfo.scope.join(', ')}</td></tr>
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
<tr><td class="name">events</td><td>0</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr>
<th>kebab</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}
<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="type">{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>
{#each scrollAreaMorfo.parts as rawPart}
{@const partAny = rawPart as unknown as {
kebab: string;
aria?: ReadonlyArray<{
attr: string;
value: { kind: string };
condition?: { when: string; prop?: string; part?: string };
severity?: string;
}>;
}}
{@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []}
{@const ariaAttrs = partAny.aria ?? []}
{#if dataAttrs.length || ariaAttrs.length}
<div data-uix-subsection-head>{partAny.kebab}</div>
{#if dataAttrs.length}
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>data-attr</th><th>Values</th><th>Source kind</th></tr></thead>
<tbody>
{#each dataAttrs as attr}
<tr>
<td class="name">{attr.attr}</td>
<td class="type">{attr.values ? attr.values.join(' | ') : '—'}</td>
<td class="default"
>{'value' in attr && attr.value
? (attr as { value: { kind: string } }).value.kind
: '—'}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if ariaAttrs.length}
<div data-uix-table-wrap style="margin-top: var(--uix-space-2);">
<table data-uix-table>
<thead><tr><th>aria-attr</th><th>Source kind</th><th>Severity</th></tr></thead>
<tbody>
{#each ariaAttrs as a}
<tr>
<td class="name">{a.attr}</td>
<td class="type">{a.value.kind}</td>
<td class="default">{a.severity ?? 'required'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
{/each}
</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>
ScrollArea declares no semantic events. Scrolling is continuous and non-evaluative —
there is no commit / emerge / contact verb to project onto a perceptual channel. The
Scrollbar's <code>data-state</code> visibility transition is a CSS concern, not a sema
event. Consumers that need to react to scroll position read <code>data-at-top</code> /
<code>data-at-bottom</code> / <code>data-at-left</code> / <code>data-at-right</code> on
the Viewport — see the morfo declaration above.
</p>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Recipe lives in <code>src/uix/eidos/components/scroll-area/scroll-area.css</code>. The
<code>data-orientation</code> attribute drives the geometry of the scrollbar; the
visibility states (<code>data-state='visible'</code> / <code>'hidden'</code>) come from
the soma timer.
</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-scroll-area]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Provider marker. Sets up the relative-positioned shell.</td>
</tr>
<tr>
<td class="name"><code>[data-scroll-area-viewport]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Viewport marker. Receives <code>overflow: auto</code> from soma.</td>
</tr>
<tr>
<td class="name"
><code>[data-scroll-area-scrollbar][data-orientation='vertical']</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Vertical track sizing.</td>
</tr>
<tr>
<td class="name"
><code>[data-scroll-area-scrollbar][data-orientation='horizontal']</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Horizontal track sizing.</td>
</tr>
<tr>
<td class="name"
><code>[data-scroll-area-scrollbar][data-state='hidden']</code></td
>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Visibility fade for the auto-hide timer.</td>
</tr>
<tr>
<td class="name"><code>[data-scroll-area-thumb]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Painted thumb; position is set inline by soma.</td>
</tr>
<tr>
<td class="name"><code>[data-scroll-area-corner]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Square between the two scrollbars when both axes scroll.</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>
The custom scrollbar must remain reachable to keyboard and screen-reader users. The morfo
stamps the WAI-ARIA <code>scrollbar</code> role on every scrollbar with
<code>aria-valuenow</code> / <code>aria-valuemin</code> / <code>aria-valuemax</code>
bound to the soma-tracked scroll percentage.
</p>
<div data-uix-subsection-head>ARIA contract</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">scrollbar</td><td>role</td><td class="type">'scrollbar'</td></tr>
<tr>
<td class="name">scrollbar</td><td>aria-controls</td>
<td class="type">id of viewport</td>
</tr>
<tr>
<td class="name">scrollbar</td><td>aria-orientation</td>
<td class="type">'vertical' | 'horizontal'</td>
</tr>
<tr>
<td class="name">scrollbar</td><td>aria-valuenow / min / max</td>
<td class="type">scroll percent (0–100)</td>
</tr>
<tr>
<td class="name">scrollbar</td><td>aria-label</td>
<td class="type">'Vertical scrollbar' / 'Horizontal scrollbar'</td>
</tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Other concerns</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr>
<td class="name">Keyboard</td>
<td>
The viewport accepts native scroll keys (Arrow / Page / Home / End) when
focused. Browser keyboard handling is preserved; UIX does not steal arrow keys.
</td>
</tr>
<tr>
<td class="name">Focus visible</td>
<td>
<code>:focus-visible</code> on the scrollbar exposes the canonical UIX focus
ring.
</td>
</tr>
<tr>
<td class="name">Touch</td>
<td>
The Scrollbar declares <code>touch-action: none</code> so touch drags on the
custom thumb don't conflict with browser-native gestures.
</td>
</tr>
<tr>
<td class="name">RTL</td>
<td>
Soma's <code>dir</code> prop is honoured: in RTL, the vertical scrollbar moves
to the inline-start side, matching native scrollbar positioning.
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

Powered by TurnKey Linux.