From e11d3539eff6d6cac93db36f4b87aa0655a19008 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 22 May 2026 21:24:14 +0200 Subject: [PATCH] feat(scroll-area): add size/radius/scrollbars props + fix scroll-type race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/uix/eidos/components/scroll-area/index.ts | 5 +- .../components/scroll-area/scroll-area.svelte | 78 +++++++++++++--- src/uix/eidos/components/scroll-area/types.ts | 56 +++++++++--- .../scroll-area-provider.svelte.ts | 19 +++- .../uix/components/scroll-area/+page.svelte | 89 +++++++++++++------ 5 files changed, 196 insertions(+), 51 deletions(-) diff --git a/src/uix/eidos/components/scroll-area/index.ts b/src/uix/eidos/components/scroll-area/index.ts index a8229c0bc..052ef86e3 100644 --- a/src/uix/eidos/components/scroll-area/index.ts +++ b/src/uix/eidos/components/scroll-area/index.ts @@ -40,5 +40,8 @@ export type { ScrollAreaScrollbarProps as ScrollbarProps, ScrollAreaThumbProps as ThumbProps, ScrollAreaCornerProps as CornerProps, - ScrollAreaType + ScrollAreaType, + ScrollAreaSize, + ScrollAreaRadius, + ScrollAreaScrollbars } from './types'; diff --git a/src/uix/eidos/components/scroll-area/scroll-area.svelte b/src/uix/eidos/components/scroll-area/scroll-area.svelte index a702a3d7f..7a22e424c 100644 --- a/src/uix/eidos/components/scroll-area/scroll-area.svelte +++ b/src/uix/eidos/components/scroll-area/scroll-area.svelte @@ -1,23 +1,81 @@ - + {@render children?.()} + {#if scrollbars === 'vertical' || scrollbars === 'both'} + + + + {/if} + {#if scrollbars === 'horizontal' || scrollbars === 'both'} + + + + {/if} + {#if scrollbars === 'both'} + + {/if} diff --git a/src/uix/eidos/components/scroll-area/types.ts b/src/uix/eidos/components/scroll-area/types.ts index d56a8ff92..0705211e3 100644 --- a/src/uix/eidos/components/scroll-area/types.ts +++ b/src/uix/eidos/components/scroll-area/types.ts @@ -10,28 +10,62 @@ import type { /** * 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: * + * + * {content} + * + * + * * * {content} * * * - * - * - * * * * - * Eidos adds no evaluative props — geometry and scrollbar timing live - * fully in the Soma headless layer. The recipe consumes the - * `data-state` / `data-orientation` markers emitted by the morfo - * runtime so visibility transitions and orientation swaps require no - * additional wiring from Eidos. + * Soma still owns geometry + timer; Eidos adds size / radius / axis + * sugar on top. + */ + +/** 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 `` / `` / + * `` 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 `` root. Pass-through from headless. */ -export type ScrollAreaProps = ProviderProps; +/** Props for the `` root. Soma props + eidos recipe sugar. */ +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 ``. Pass-through from headless. */ export type ScrollAreaViewportProps = ViewportProps; /** Props for ``. Pass-through from headless. */ diff --git a/src/uix/soma/components/scroll-area/scroll-area-provider.svelte.ts b/src/uix/soma/components/scroll-area/scroll-area-provider.svelte.ts index 88d2534a0..cac5f254b 100644 --- a/src/uix/soma/components/scroll-area/scroll-area-provider.svelte.ts +++ b/src/uix/soma/components/scroll-area/scroll-area-provider.svelte.ts @@ -230,7 +230,12 @@ export class ScrollAreaScrollbarProvider { // Visibility state visible = $state(false); 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); dragging = $state(false); @@ -259,10 +264,20 @@ export class ScrollAreaScrollbarProvider { }); // 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(() => { void this.provider.scrollTop; void this.provider.scrollLeft; + if (firstRun) { + firstRun = false; + return; + } const type = this.provider.opts.type.current; if (type === 'scroll' || type === 'hover') { this.show(); diff --git a/web/routes/uix/components/scroll-area/+page.svelte b/web/routes/uix/components/scroll-area/+page.svelte index 25f7435c7..641c5ad4e 100644 --- a/web/routes/uix/components/scroll-area/+page.svelte +++ b/web/routes/uix/components/scroll-area/+page.svelte @@ -2,7 +2,10 @@ import { ScrollArea, type ScrollAreaProps, - type ScrollAreaType + 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'; @@ -17,14 +20,23 @@ const types: ScrollAreaType[] = ['hover', 'scroll', 'auto', 'always']; let type = $state('hover'); let scrollHideDelay = $state(600); - type Axis = 'vertical' | 'horizontal' | 'both'; - const axes = ['vertical', 'horizontal', 'both'] as const; + + const sizes: ScrollAreaSize[] = ['xs', 'sm', 'md', 'lg']; + let size = $state('md'); + + const radii: ScrollAreaRadius[] = ['none', 'sm', 'md', 'lg', 'full']; + let radius = $state('full'); + + type Axis = ScrollAreaScrollbars; + const axes: Axis[] = ['vertical', 'horizontal', 'both']; let axis = $state('vertical'); let itemCount = $state(40); const areaProps = $derived>({ type, - scrollHideDelay + scrollHideDelay, + size, + radius }); // ── Compiled morfo ─────────────────────────────────────────────────── @@ -61,6 +73,8 @@ ); // ── Snippets ───────────────────────────────────────────────────────── + // Soma is headless — no `size` / `radius` / `scrollbars` sugar; the user + // composes Scrollbar / Thumb / Corner parts directly. const somaSnippet = $derived( [ "