feat(scroll-area): add size/radius/scrollbars props + fix scroll-type race

Reference-library parity (Radix Themes, Chakra v3):

- size: 'xs' | 'sm' | 'md' | 'lg' (Chakra t-shirt scale) → thickness
  via --scroll-area-scrollbar-size.
- radius: 'none' | 'sm' | 'md' | 'lg' | 'full' (Radix Themes) → thumb
  curvature via --scroll-area-thumb-radius.
- scrollbars: 'vertical' | 'horizontal' | 'both' (Radix Themes) →
  recipe-level sugar that auto-mounts Scrollbar / Thumb / Corner from
  the root. Explicit composition still supported (omit the prop).

Bug fix — type='scroll' + horizontal sometimes stuck hidden:

- `mounted` on ScrollAreaScrollbarProvider is now $state. Previously
  a plain field, so the `shouldShow` derived didn't invalidate when
  the requestFrame mount-flag flipped during the first frame.
- The reveal-on-scroll $effect now skips its first run via a
  `firstRun` flag. That first run was the subscription pass with
  scrollTop/scrollLeft still 0 — it was eagerly calling show() and
  setting `visible=true` before `mounted` flipped. Subsequent real
  scrolls then assigned visible=true again (no-op), so shouldShow
  didn't re-run and the bar stayed hidden until the hide timer
  expired. Now the bar only reveals on genuine scroll deltas.

Demo: chip controls for size + radius, switched to scrollbars prop
on the root (drops the explicit Scrollbar/Thumb children), eidos
snippet shows the recipe-sugar form, soma snippet still shows the
explicit Radix-style composition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 560ce8f66c
commit e11d3539ef

@ -40,5 +40,8 @@ export type {
ScrollAreaScrollbarProps as ScrollbarProps, ScrollAreaScrollbarProps as ScrollbarProps,
ScrollAreaThumbProps as ThumbProps, ScrollAreaThumbProps as ThumbProps,
ScrollAreaCornerProps as CornerProps, ScrollAreaCornerProps as CornerProps,
ScrollAreaType ScrollAreaType,
ScrollAreaSize,
ScrollAreaRadius,
ScrollAreaScrollbars
} from './types'; } from './types';

@ -1,23 +1,81 @@
<script lang="ts"> <script lang="ts">
/** /**
* Eidos `<ScrollArea>` — root component. Wraps Soma's * Eidos `<ScrollArea>` — root component. Wraps Soma's
* `<ScrollArea.Provider>` to set up the headless context; children * `<ScrollArea.Provider>` to set up the headless context.
* compose via the named members attached in `index.ts`:
* *
* <ScrollArea type="hover"> * <ScrollArea type="hover" size="md" radius="md" scrollbars="vertical">
* <ScrollArea.Viewport>{content}</ScrollArea.Viewport> * <ScrollArea.Viewport>{content}</ScrollArea.Viewport>
* <ScrollArea.Scrollbar orientation="vertical">
* <ScrollArea.Thumb />
* </ScrollArea.Scrollbar>
* <ScrollArea.Corner />
* </ScrollArea> * </ScrollArea>
*
* Two composition modes:
*
* - Explicit (default, Radix-style) — `scrollbars` omitted; user
* composes Viewport + Scrollbar + Thumb + Corner manually.
* - Sugar (Radix-Themes style) — `scrollbars="vertical|horizontal|both"`
* auto-mounts the right parts; user only composes Viewport.
*
* `size` / `radius` are pure CSS-variable bindings; they affect
* recipe paint only, not Soma's scroll math.
*/ */
import * as ScrollArea from '$soma/components/scroll-area'; import * as ScrollArea from '$soma/components/scroll-area';
import type { ScrollAreaProps } from './types'; import Viewport from './scroll-area-viewport.svelte';
import Scrollbar from './scroll-area-scrollbar.svelte';
import Thumb from './scroll-area-thumb.svelte';
import Corner from './scroll-area-corner.svelte';
import type { ScrollAreaProps, ScrollAreaSize, ScrollAreaRadius } from './types';
let {
children,
size,
radius,
scrollbars,
style,
...rest
}: ScrollAreaProps = $props();
// Map t-shirt size → `--space-*` token. Matches Chakra's xs/sm/md/lg
// scale. The CSS recipe consumes `--scroll-area-scrollbar-size` for
// both track thickness and corner dimensions.
const SIZE_TOKEN: Record<ScrollAreaSize, string> = {
xs: 'var(--space-1-5)',
sm: 'var(--space-2)',
md: 'var(--space-2-5)',
lg: 'var(--space-3)'
};
const RADIUS_TOKEN: Record<ScrollAreaRadius, string> = {
none: '0',
sm: 'var(--radius-sm)',
md: 'var(--radius-md)',
lg: 'var(--radius-lg)',
full: 'var(--radius-full)'
};
const recipeStyle = $derived(() => {
const parts: string[] = [];
if (size) parts.push(`--scroll-area-scrollbar-size: ${SIZE_TOKEN[size]};`);
if (radius) parts.push(`--scroll-area-thumb-radius: ${RADIUS_TOKEN[radius]};`);
return parts.join(' ');
});
let { children, ...rest }: ScrollAreaProps = $props(); const mergedStyle = $derived(
[recipeStyle(), typeof style === 'string' ? style : ''].filter(Boolean).join(' ')
);
</script> </script>
<ScrollArea.Provider {...rest}> <ScrollArea.Provider {...rest} style={mergedStyle || undefined}>
{@render children?.()} {@render children?.()}
{#if scrollbars === 'vertical' || scrollbars === 'both'}
<Scrollbar orientation="vertical">
<Thumb />
</Scrollbar>
{/if}
{#if scrollbars === 'horizontal' || scrollbars === 'both'}
<Scrollbar orientation="horizontal">
<Thumb />
</Scrollbar>
{/if}
{#if scrollbars === 'both'}
<Corner />
{/if}
</ScrollArea.Provider> </ScrollArea.Provider>

@ -10,28 +10,62 @@ import type {
/** /**
* Eidos ScrollArea types. * Eidos ScrollArea types.
* *
* Compound shape mirrors Radix Primitives: * Compound shape mirrors Radix Primitives, with three recipe-level
* sugar props promoted on the root (`size`, `radius`, `scrollbars`)
* that match Radix Themes / Chakra UI conventions:
* *
* <ScrollArea size="md" radius="md" scrollbars="vertical">
* <ScrollArea.Viewport>{content}</ScrollArea.Viewport>
* </ScrollArea>
*
* <!-- Full explicit composition (default; `scrollbars` omitted) -->
* <ScrollArea> * <ScrollArea>
* <ScrollArea.Viewport>{content}</ScrollArea.Viewport> * <ScrollArea.Viewport>{content}</ScrollArea.Viewport>
* <ScrollArea.Scrollbar orientation="vertical"> * <ScrollArea.Scrollbar orientation="vertical">
* <ScrollArea.Thumb /> * <ScrollArea.Thumb />
* </ScrollArea.Scrollbar> * </ScrollArea.Scrollbar>
* <ScrollArea.Scrollbar orientation="horizontal">
* <ScrollArea.Thumb />
* </ScrollArea.Scrollbar>
* <ScrollArea.Corner /> * <ScrollArea.Corner />
* </ScrollArea> * </ScrollArea>
* *
* Eidos adds no evaluative props — geometry and scrollbar timing live * Soma still owns geometry + timer; Eidos adds size / radius / axis
* fully in the Soma headless layer. The recipe consumes the * sugar on top.
* `data-state` / `data-orientation` markers emitted by the morfo */
* runtime so visibility transitions and orientation swaps require no
* additional wiring from Eidos. /** Scrollbar thickness — t-shirt scale (Chakra-style). Maps to a `--space-*` token. */
export type ScrollAreaSize = 'xs' | 'sm' | 'md' | 'lg';
/** Thumb / corner radius — maps to a `--radius-*` token. */
export type ScrollAreaRadius = 'none' | 'sm' | 'md' | 'lg' | 'full';
/**
* Which scrollbars the recipe auto-mounts. When set, the user does
* NOT compose `<ScrollArea.Scrollbar>` / `<ScrollArea.Thumb>` /
* `<ScrollArea.Corner>` manually — the root wires them. Leave
* undefined for the canonical Radix-style explicit composition.
*
* Mirrors Radix Themes' `scrollbars` prop.
*/ */
export type ScrollAreaScrollbars = 'vertical' | 'horizontal' | 'both';
/** Props for the `<ScrollArea>` root. Pass-through from headless. */ /** Props for the `<ScrollArea>` root. Soma props + eidos recipe sugar. */
export type ScrollAreaProps = ProviderProps; export type ScrollAreaProps = ProviderProps & {
/**
* Scrollbar thickness. Maps to `--scroll-area-scrollbar-size`.
* @default 'md'
*/
size?: ScrollAreaSize;
/**
* Thumb / corner radius. Maps to `--scroll-area-thumb-radius`.
* @default 'full'
*/
radius?: ScrollAreaRadius;
/**
* Which scrollbars the recipe auto-mounts. When omitted, the user
* composes Scrollbar / Thumb / Corner parts manually inside
* `children` (Radix Primitives style).
*/
scrollbars?: ScrollAreaScrollbars;
};
/** Props for `<ScrollArea.Viewport>`. Pass-through from headless. */ /** Props for `<ScrollArea.Viewport>`. Pass-through from headless. */
export type ScrollAreaViewportProps = ViewportProps; export type ScrollAreaViewportProps = ViewportProps;
/** Props for `<ScrollArea.Scrollbar>`. Pass-through from headless. */ /** Props for `<ScrollArea.Scrollbar>`. Pass-through from headless. */

@ -230,7 +230,12 @@ export class ScrollAreaScrollbarProvider {
// Visibility state // Visibility state
visible = $state(false); visible = $state(false);
private hideTimer: TimerHandle | null = null; private hideTimer: TimerHandle | null = null;
private mounted = false; // `mounted` MUST be reactive — `shouldShow` reads it to suppress the
// initial-mount flash, so flipping it from false→true during the
// first frame has to invalidate the derived. Without `$state` the
// derived caches the false value and `visible` flips inside the
// `shouldShow` derivation lose the race.
private mounted = $state(false);
hovering = $state(false); hovering = $state(false);
dragging = $state(false); dragging = $state(false);
@ -259,10 +264,20 @@ export class ScrollAreaScrollbarProvider {
}); });
// Reveal the scrollbar on parent scroll for 'scroll' and 'hover' types. // Reveal the scrollbar on parent scroll for 'scroll' and 'hover' types.
// Previously lived in the wrapper as an ad-hoc effect. // The first run of an `$effect` is a subscription-setup pass, not a real
// scroll event — without the `firstRun` guard the initial run would call
// `show()` immediately, setting `visible=true` before `mounted` flips.
// Subsequent real-scroll runs would then see `visible` already at `true`
// and the no-op assignment wouldn't invalidate `shouldShow`, leaving the
// bar stuck hidden until the hide timer expired.
let firstRun = true;
$effect(() => { $effect(() => {
void this.provider.scrollTop; void this.provider.scrollTop;
void this.provider.scrollLeft; void this.provider.scrollLeft;
if (firstRun) {
firstRun = false;
return;
}
const type = this.provider.opts.type.current; const type = this.provider.opts.type.current;
if (type === 'scroll' || type === 'hover') { if (type === 'scroll' || type === 'hover') {
this.show(); this.show();

@ -2,7 +2,10 @@
import { import {
ScrollArea, ScrollArea,
type ScrollAreaProps, type ScrollAreaProps,
type ScrollAreaType type ScrollAreaType,
type ScrollAreaSize,
type ScrollAreaRadius,
type ScrollAreaScrollbars
} from '$uix/eidos/components/scroll-area'; } from '$uix/eidos/components/scroll-area';
import { compileMorfo } from '$uix/morfo'; import { compileMorfo } from '$uix/morfo';
import { scrollAreaMorfo } from '@/uix/morfo/components/scroll-area'; import { scrollAreaMorfo } from '@/uix/morfo/components/scroll-area';
@ -17,14 +20,23 @@
const types: ScrollAreaType[] = ['hover', 'scroll', 'auto', 'always']; const types: ScrollAreaType[] = ['hover', 'scroll', 'auto', 'always'];
let type = $state<ScrollAreaType>('hover'); let type = $state<ScrollAreaType>('hover');
let scrollHideDelay = $state<number>(600); let scrollHideDelay = $state<number>(600);
type Axis = 'vertical' | 'horizontal' | 'both';
const axes = ['vertical', 'horizontal', 'both'] as const; 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 axis = $state<Axis>('vertical');
let itemCount = $state<number>(40); let itemCount = $state<number>(40);
const areaProps = $derived<Partial<ScrollAreaProps>>({ const areaProps = $derived<Partial<ScrollAreaProps>>({
type, type,
scrollHideDelay scrollHideDelay,
size,
radius
}); });
// ── Compiled morfo ─────────────────────────────────────────────────── // ── Compiled morfo ───────────────────────────────────────────────────
@ -61,6 +73,8 @@
); );
// ── Snippets ───────────────────────────────────────────────────────── // ── Snippets ─────────────────────────────────────────────────────────
// Soma is headless — no `size` / `radius` / `scrollbars` sugar; the user
// composes Scrollbar / Thumb / Corner parts directly.
const somaSnippet = $derived( const somaSnippet = $derived(
[ [
"<script lang='ts'>", "<script lang='ts'>",
@ -77,7 +91,8 @@
(axis === 'vertical' || axis === 'both') && ' <ScrollArea.Scrollbar orientation="vertical">', (axis === 'vertical' || axis === 'both') && ' <ScrollArea.Scrollbar orientation="vertical">',
(axis === 'vertical' || axis === 'both') && ' <ScrollArea.Thumb />', (axis === 'vertical' || axis === 'both') && ' <ScrollArea.Thumb />',
(axis === 'vertical' || axis === 'both') && ' </ScrollArea.Scrollbar>', (axis === 'vertical' || axis === 'both') && ' </ScrollArea.Scrollbar>',
(axis === 'horizontal' || axis === 'both') && ' <ScrollArea.Scrollbar orientation="horizontal">', (axis === 'horizontal' || axis === 'both') &&
' <ScrollArea.Scrollbar orientation="horizontal">',
(axis === 'horizontal' || axis === 'both') && ' <ScrollArea.Thumb />', (axis === 'horizontal' || axis === 'both') && ' <ScrollArea.Thumb />',
(axis === 'horizontal' || axis === 'both') && ' </ScrollArea.Scrollbar>', (axis === 'horizontal' || axis === 'both') && ' </ScrollArea.Scrollbar>',
axis === 'both' && ' <ScrollArea.Corner />', axis === 'both' && ' <ScrollArea.Corner />',
@ -87,6 +102,9 @@
.join('\n') .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( const eidosSnippet = $derived(
[ [
"<script lang='ts'>", "<script lang='ts'>",
@ -96,17 +114,13 @@
'<ScrollArea', '<ScrollArea',
type !== 'hover' && ` type="${type}"`, type !== 'hover' && ` type="${type}"`,
scrollHideDelay !== 600 && ` scrollHideDelay={${scrollHideDelay}}`, scrollHideDelay !== 600 && ` scrollHideDelay={${scrollHideDelay}}`,
size !== 'md' && ` size="${size}"`,
radius !== 'full' && ` radius="${radius}"`,
` scrollbars="${axis}"`,
'>', '>',
' <ScrollArea.Viewport>', ' <ScrollArea.Viewport>',
' <!-- scrollable content -->', ' <!-- scrollable content -->',
' </ScrollArea.Viewport>', ' </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>' '</ScrollArea>'
] ]
.filter(Boolean) .filter(Boolean)
@ -148,7 +162,7 @@
<div <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;" 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} style="inline-size: 100%; block-size: 100%;"> <ScrollArea {...areaProps} scrollbars={axis} style="inline-size: 100%; block-size: 100%;">
<ScrollArea.Viewport style="inline-size: 100%; block-size: 100%;"> <ScrollArea.Viewport style="inline-size: 100%; block-size: 100%;">
<div <div
style={`padding: var(--space-4); ${axis === 'horizontal' || axis === 'both' ? 'inline-size: 48rem;' : ''}`} style={`padding: var(--space-4); ${axis === 'horizontal' || axis === 'both' ? 'inline-size: 48rem;' : ''}`}
@ -172,19 +186,6 @@
</ul> </ul>
</div> </div>
</ScrollArea.Viewport> </ScrollArea.Viewport>
{#if axis === 'vertical' || axis === 'both'}
<ScrollArea.Scrollbar orientation="vertical">
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
{/if}
{#if axis === 'horizontal' || axis === 'both'}
<ScrollArea.Scrollbar orientation="horizontal">
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
{/if}
{#if axis === 'both'}
<ScrollArea.Corner />
{/if}
</ScrollArea> </ScrollArea>
</div> </div>
</div> </div>
@ -252,10 +253,44 @@
</label> </label>
</div> </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-subsection-head>Composition · which scrollbars to mount</div>
<div data-uix-controls> <div data-uix-controls>
<label data-uix-control> <label data-uix-control>
<span data-uix-control-label>axis</span> <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"> <span data-uix-chips role="radiogroup">
{#each axes as opt} {#each axes as opt}
<button data-uix-chip data-active={axis === opt} onclick={() => (axis = opt)} <button data-uix-chip data-active={axis === opt} onclick={() => (axis = opt)}

Loading…
Cancel
Save

Powered by TurnKey Linux.