From f291c088653cf52321900861ec36fffb636a1436 Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 23 May 2026 21:42:14 +0200 Subject: [PATCH] fix(carousel/range-calendar/time-range-field): visual bugs across all three MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real visual audit caught three classes of bugs that the prior commits missed because they were never opened in the browser. ## Carousel ### Vertical orientation overflow Recipe gave the root no block-size in vertical, so items spilled. Plus the soma `viewportSize` derivation only ran on ref / orientation-prop changes, not on layout reshape — so flipping horizontal → vertical kept reading the old clientWidth and the translate maths used the wrong slide size (observed -214 instead of -156 after a flip). Fix: - Recipe: `[data-carousel][data-orientation='vertical']` gets explicit `block-size`, and the viewport / item-group / item each get `100%` along the block axis. Default vertical size lives on `--_carousel-vertical-block-size` (16rem), overridable via the new eidos `verticalBlockSize` prop or the custom property directly. - Soma: replaced the synchronous `clientWidth/Height` reads with `viewportClientWidth/Height` `$state` mirrors populated by a ResizeObserver on the viewport. Layout-only changes (orientation flip, container resize, font scaling) now propagate to translate calc on the next reactive tick. ## RangeCalendar ### Multi-month stacked vertically `numberOfMonths > 1` rendered grids stacked in a column because the eidos `[data-range-calendar]` recipe is `inline-flex; column`. Copied the side-by-side grid layout from the existing date-range-picker demo: `.range-months { display: grid; grid-auto-flow: column; }` with `grid-auto-flow: row` collapse for `data-month-count='1'`. ### Min/max controls were nonsense The prior demo had a custom "today | custom" mode that conflated fixtures with real today, and required two separate enum states. Rewrote the controls to mirror the date-range-picker demo exactly: two `` bindings on string state, parsed by `parseDateInput`, plus a preset chip row (none / min only / max only / window / invalid). ### Weekday key duplicate The narrow-format weekday strings repeat letters ('T' for Tuesday and Thursday, 'S' for Saturday and Sunday), so keying by `wd` raised `each_key_duplicate`. Changed to key by index. ## TimeRangeField ### Start endpoint rendered no segments Keying the segments `#each` by `seg.part` collapsed when segment parts repeat. Copied the canonical key shape from the time-range-picker demo: `(${part}-${i})`. Verified both endpoints now render their four segments (`09:30` — `17:45` for the default fixture). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../eidos/components/carousel/carousel.css | 15 ++ .../carousel/carousel-provider.svelte.ts | 46 +++- .../components/range-calendar/+page.svelte | 199 +++++++++++++----- .../components/time-range-field/+page.svelte | 12 +- 4 files changed, 202 insertions(+), 70 deletions(-) diff --git a/src/uix/eidos/components/carousel/carousel.css b/src/uix/eidos/components/carousel/carousel.css index 8bd55fb73..16958dc51 100644 --- a/src/uix/eidos/components/carousel/carousel.css +++ b/src/uix/eidos/components/carousel/carousel.css @@ -50,6 +50,10 @@ * Vertical orientation — the root becomes the block-axis container; the * viewport gets the explicit block-size so transform clipping works. * Without this the viewport collapses to content height and items spill. + * + * The block-size cascade is: root → viewport → item-group → item. Each + * level needs `100%` (or the token at the root) so the basis-100% along + * the column main axis resolves to viewport block-size, not content. */ [data-carousel][data-orientation='vertical'] { block-size: var(--_carousel-vertical-block-size); @@ -60,6 +64,17 @@ min-block-size: 0; } +[data-carousel][data-orientation='vertical'] [data-carousel-item-group] { + block-size: 100%; + min-block-size: 0; +} + +[data-carousel][data-orientation='vertical'] [data-carousel-item] { + /* Items already get flex: 0 0 100% from soma; the explicit block-size + ensures the basis resolves correctly when slidesPerView=1. */ + block-size: 100%; +} + [data-carousel][data-block] { inline-size: 100%; } diff --git a/src/uix/soma/components/carousel/carousel-provider.svelte.ts b/src/uix/soma/components/carousel/carousel-provider.svelte.ts index 1a32dabc7..25183a902 100644 --- a/src/uix/soma/components/carousel/carousel-provider.svelte.ts +++ b/src/uix/soma/components/carousel/carousel-provider.svelte.ts @@ -61,6 +61,14 @@ export class CarouselProvider { viewportRef = $state(null); itemGroupRef = $state(null); + // Live viewport dimensions — kept as $state so a ResizeObserver can + // push updates whenever the layout reshapes (orientation flip, container + // resize, font scaling). The geometry derivations below read these + // instead of calling `clientWidth/Height` directly, which would only + // recompute when an unrelated reactive dep changes. + viewportClientWidth = $state(0); + viewportClientHeight = $state(0); + // Drag state (px offset during active drag). dragOffset = $state(0); @@ -101,6 +109,26 @@ export class CarouselProvider { } }); + // ── Viewport size tracking ─────────────────────────────────────────── + // Use ResizeObserver because we need to react to layout changes + // caused by ANY external cause: orientation flip (the recipe rewrites + // the viewport's block-size), container resize, font-size change, + // device rotation. A pure `clientWidth/Height` derivation would only + // re-run on its own deps, missing layout-only changes. + $effect(() => { + const vp = this.viewportRef; + if (!vp) return; + // Seed once synchronously so the first translate isn't 0/0. + this.viewportClientWidth = vp.clientWidth; + this.viewportClientHeight = vp.clientHeight; + const ro = new ResizeObserver(() => { + this.viewportClientWidth = vp.clientWidth; + this.viewportClientHeight = vp.clientHeight; + }); + ro.observe(vp); + return () => ro.disconnect(); + }); + // ── Autoplay ───────────────────────────────────────────────────────── $effect(() => { if (!opts.autoplay.current) return; @@ -164,12 +192,18 @@ export class CarouselProvider { // ── Geometry ───────────────────────────────────────────────────────────── - /** Size (px) of the viewport along the primary axis. */ - readonly viewportSize = $derived.by(() => { - const vp = this.viewportRef; - if (!vp) return 0; - return this.opts.orientation.current === 'horizontal' ? vp.clientWidth : vp.clientHeight; - }); + /** + * Size (px) of the viewport along the primary axis. Sourced from the + * `viewportClient{Width,Height}` reactive mirrors (populated by the + * ResizeObserver above) so that layout-only changes — like flipping + * orientation, which rewrites the viewport's block-size via the eidos + * recipe — propagate to translate calc on the next reactive tick. + */ + readonly viewportSize = $derived.by(() => + this.opts.orientation.current === 'horizontal' + ? this.viewportClientWidth + : this.viewportClientHeight + ); /** Slide size (px) along the primary axis = viewport / slidesPerView. */ readonly slideSize = $derived.by(() => { diff --git a/web/routes/uix/components/range-calendar/+page.svelte b/web/routes/uix/components/range-calendar/+page.svelte index d23e6624e..506a31fcb 100644 --- a/web/routes/uix/components/range-calendar/+page.svelte +++ b/web/routes/uix/components/range-calendar/+page.svelte @@ -15,27 +15,35 @@ type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y'; type TraceEntry = { event: string; family: string; intent?: string; at: number }; type WeekStartControl = 'locale' | '0' | '1'; - type MinDateMode = 'none' | 'today' | 'custom'; - type MaxDateMode = 'none' | 'custom'; const uix = getActiveUix(); - type EmitSignal = Parameters['emit']>[0]; + // Fixed anchor dates so the demo is deterministic across reloads. Same + // pattern as the date-range-picker demo (which this view mirrors), with + // presets letting consumers see the picker's behaviour under common + // real-world configurations. const initialMonth = new CalendarDate(2026, 5, 1); - const start = new CalendarDate(2026, 5, 8); - const end = new CalendarDate(2026, 5, 19); - const todayDate = new CalendarDate(2026, 5, 12); - const customMinDate = new CalendarDate(2026, 5, 5); - const customMaxDate = new CalendarDate(2026, 7, 15); + const sprintStart = new CalendarDate(2026, 5, 8); + const sprintEnd = new CalendarDate(2026, 5, 19); + const defaultMin = new CalendarDate(2026, 5, 1); + const defaultMax = new CalendarDate(2026, 7, 31); + const invalidMax = new CalendarDate(2026, 4, 30); let tab = $state('live'); let trace = $state([]); let stageRef = $state(null); - let value = $state({ start, end }); + let value = $state({ start: sprintStart, end: sprintEnd }); let placeholder = $state(initialMonth); - let minDays = $state(undefined); - let maxDays = $state(14); + + // Bounds + span constraints — text inputs are the cleanest UX (no + // hand-rolled "today | custom" modes). Each text becomes a DateValue + // via parseDateInput; presets reset both at once. + let minInput = $state(toDateInput(defaultMin)); + let maxInput = $state(toDateInput(defaultMax)); + let minDaysInput = $state(''); + let maxDaysInput = $state('14'); + let allowSingleDay = $state(true); let preventDeselect = $state(false); let pagedNavigation = $state(false); @@ -45,8 +53,6 @@ let disableDaysOutsideMonth = $state(false); let disabled = $state(false); let readonly = $state(false); - let minDateMode = $state('custom'); - let maxDateMode = $state('custom'); let weekStartsOn = $state('1'); let locale = $state('en-US'); let size = $state('md'); @@ -60,12 +66,50 @@ const resolvedWeekStartsOn = $derived( weekStartsOn === 'locale' ? undefined : (Number(weekStartsOn) as WeekStartsOn) ); - const selectionMinValue = $derived( - minDateMode === 'today' ? todayDate : minDateMode === 'custom' ? customMinDate : undefined - ); - const selectionMaxValue = $derived(maxDateMode === 'custom' ? customMaxDate : undefined); - const minDateLabel = $derived(selectionMinValue?.toString() ?? 'none'); - const maxDateLabel = $derived(selectionMaxValue?.toString() ?? 'none'); + const selectionMinValue = $derived(parseDateInput(minInput)); + const selectionMaxValue = $derived(parseDateInput(maxInput)); + const minDays = $derived(parseOptionalInt(minDaysInput)); + const maxDays = $derived(parseOptionalInt(maxDaysInput)); + + function toDateInput(date: DateValue): string { + return [ + String(date.year).padStart(4, '0'), + String(date.month).padStart(2, '0'), + String(date.day).padStart(2, '0') + ].join('-'); + } + + function parseDateInput(text: string): DateValue | undefined { + if (!text) return undefined; + const [year, month, day] = text.split('-').map(Number); + if (!year || !month || !day) return undefined; + return new CalendarDate(year, month, day); + } + + function parseOptionalInt(text: string): number | undefined { + if (!text) return undefined; + const value = Number.parseInt(text, 10); + return Number.isFinite(value) && value > 0 ? value : undefined; + } + + function setBoundsPreset(preset: 'none' | 'min' | 'max' | 'window' | 'invalid'): void { + if (preset === 'none') { + minInput = ''; + maxInput = ''; + } else if (preset === 'min') { + minInput = toDateInput(defaultMin); + maxInput = ''; + } else if (preset === 'max') { + minInput = ''; + maxInput = toDateInput(defaultMax); + } else if (preset === 'window') { + minInput = toDateInput(defaultMin); + maxInput = toDateInput(defaultMax); + } else if (preset === 'invalid') { + minInput = toDateInput(defaultMin); + maxInput = toDateInput(invalidMax); + } + } const rangeLabel = $derived.by(() => { if (!value.start && !value.end) return 'empty'; @@ -103,6 +147,8 @@ return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`; } + type EmitSignal = Parameters['emit']>[0]; + function emitSelectStart(signal: EmitSignal) { void uix.events?.emit(signal); } @@ -226,32 +272,39 @@ › - {#each months as month (month.value.toString())} - - - - {#snippet children(_props: RangeCalendarGridRowSnippetProps)} - {#each weekdays as wd (wd)} - {wd} + +
+ {#each months as month (month.value.toString())} +
+ + + + {#snippet children(_props: RangeCalendarGridRowSnippetProps)} + {#each weekdays as wd, i (i)} + {wd} + {/each} + {/snippet} + + + + {#each month.weeks as week (week[0].toString())} + + {#snippet children(_props: RangeCalendarGridRowSnippetProps)} + {#each week as date (date.toString())} + + + + {/each} + {/snippet} + {/each} - {/snippet} - - - - {#each month.weeks as week (week[0].toString())} - - {#snippet children(_props: RangeCalendarGridRowSnippetProps)} - {#each week as date (date.toString())} - - - - {/each} - {/snippet} - - {/each} - - - {/each} + + +
+ {/each} +
{/snippet} @@ -302,30 +355,30 @@
+
+ + diff --git a/web/routes/uix/components/time-range-field/+page.svelte b/web/routes/uix/components/time-range-field/+page.svelte index 5857e1850..58e59296f 100644 --- a/web/routes/uix/components/time-range-field/+page.svelte +++ b/web/routes/uix/components/time-range-field/+page.svelte @@ -188,17 +188,17 @@ {#if showLabel} {labelText} {/if} - + {#snippet children({ segments })} - {#each segments as seg (seg.part)} - {seg.value} + {#each segments as { part, value }, i (`${part}-${i}`)} + {value} {/each} {/snippet} - + {#snippet children({ segments })} - {#each segments as seg (seg.part)} - {seg.value} + {#each segments as { part, value }, i (`${part}-${i}`)} + {value} {/each} {/snippet}