fix(carousel/range-calendar/time-range-field): visual bugs across all three

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 `<input type="date">` 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) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 44514c3cc1
commit f291c08865

@ -50,6 +50,10 @@
* Vertical orientation — the root becomes the block-axis container; the * Vertical orientation — the root becomes the block-axis container; the
* viewport gets the explicit block-size so transform clipping works. * viewport gets the explicit block-size so transform clipping works.
* Without this the viewport collapses to content height and items spill. * 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'] { [data-carousel][data-orientation='vertical'] {
block-size: var(--_carousel-vertical-block-size); block-size: var(--_carousel-vertical-block-size);
@ -60,6 +64,17 @@
min-block-size: 0; 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] { [data-carousel][data-block] {
inline-size: 100%; inline-size: 100%;
} }

@ -61,6 +61,14 @@ export class CarouselProvider {
viewportRef = $state<HTMLElement | null>(null); viewportRef = $state<HTMLElement | null>(null);
itemGroupRef = $state<HTMLElement | null>(null); itemGroupRef = $state<HTMLElement | null>(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). // Drag state (px offset during active drag).
dragOffset = $state(0); 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 ───────────────────────────────────────────────────────── // ── Autoplay ─────────────────────────────────────────────────────────
$effect(() => { $effect(() => {
if (!opts.autoplay.current) return; if (!opts.autoplay.current) return;
@ -164,12 +192,18 @@ export class CarouselProvider {
// ── Geometry ───────────────────────────────────────────────────────────── // ── Geometry ─────────────────────────────────────────────────────────────
/** Size (px) of the viewport along the primary axis. */ /**
readonly viewportSize = $derived.by(() => { * Size (px) of the viewport along the primary axis. Sourced from the
const vp = this.viewportRef; * `viewportClient{Width,Height}` reactive mirrors (populated by the
if (!vp) return 0; * ResizeObserver above) so that layout-only changes — like flipping
return this.opts.orientation.current === 'horizontal' ? vp.clientWidth : vp.clientHeight; * 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. */ /** Slide size (px) along the primary axis = viewport / slidesPerView. */
readonly slideSize = $derived.by(() => { readonly slideSize = $derived.by(() => {

@ -15,27 +15,35 @@
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y'; type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number }; type TraceEntry = { event: string; family: string; intent?: string; at: number };
type WeekStartControl = 'locale' | '0' | '1'; type WeekStartControl = 'locale' | '0' | '1';
type MinDateMode = 'none' | 'today' | 'custom';
type MaxDateMode = 'none' | 'custom';
const uix = getActiveUix(); const uix = getActiveUix();
type EmitSignal = Parameters<NonNullable<typeof uix.events>['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 initialMonth = new CalendarDate(2026, 5, 1);
const start = new CalendarDate(2026, 5, 8); const sprintStart = new CalendarDate(2026, 5, 8);
const end = new CalendarDate(2026, 5, 19); const sprintEnd = new CalendarDate(2026, 5, 19);
const todayDate = new CalendarDate(2026, 5, 12); const defaultMin = new CalendarDate(2026, 5, 1);
const customMinDate = new CalendarDate(2026, 5, 5); const defaultMax = new CalendarDate(2026, 7, 31);
const customMaxDate = new CalendarDate(2026, 7, 15); const invalidMax = new CalendarDate(2026, 4, 30);
let tab = $state<Tab>('live'); let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]); let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null); let stageRef = $state<HTMLElement | null>(null);
let value = $state<DateRange>({ start, end }); let value = $state<DateRange>({ start: sprintStart, end: sprintEnd });
let placeholder = $state<DateValue>(initialMonth); let placeholder = $state<DateValue>(initialMonth);
let minDays = $state<number | undefined>(undefined);
let maxDays = $state<number | undefined>(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 allowSingleDay = $state(true);
let preventDeselect = $state(false); let preventDeselect = $state(false);
let pagedNavigation = $state(false); let pagedNavigation = $state(false);
@ -45,8 +53,6 @@
let disableDaysOutsideMonth = $state(false); let disableDaysOutsideMonth = $state(false);
let disabled = $state(false); let disabled = $state(false);
let readonly = $state(false); let readonly = $state(false);
let minDateMode = $state<MinDateMode>('custom');
let maxDateMode = $state<MaxDateMode>('custom');
let weekStartsOn = $state<WeekStartControl>('1'); let weekStartsOn = $state<WeekStartControl>('1');
let locale = $state('en-US'); let locale = $state('en-US');
let size = $state<RangeCalendarSize>('md'); let size = $state<RangeCalendarSize>('md');
@ -60,12 +66,50 @@
const resolvedWeekStartsOn = $derived( const resolvedWeekStartsOn = $derived(
weekStartsOn === 'locale' ? undefined : (Number(weekStartsOn) as WeekStartsOn) weekStartsOn === 'locale' ? undefined : (Number(weekStartsOn) as WeekStartsOn)
); );
const selectionMinValue = $derived( const selectionMinValue = $derived(parseDateInput(minInput));
minDateMode === 'today' ? todayDate : minDateMode === 'custom' ? customMinDate : undefined const selectionMaxValue = $derived(parseDateInput(maxInput));
); const minDays = $derived(parseOptionalInt(minDaysInput));
const selectionMaxValue = $derived(maxDateMode === 'custom' ? customMaxDate : undefined); const maxDays = $derived(parseOptionalInt(maxDaysInput));
const minDateLabel = $derived(selectionMinValue?.toString() ?? 'none');
const maxDateLabel = $derived(selectionMaxValue?.toString() ?? 'none'); 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(() => { const rangeLabel = $derived.by(() => {
if (!value.start && !value.end) return 'empty'; if (!value.start && !value.end) return 'empty';
@ -103,6 +147,8 @@
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`; return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
} }
type EmitSignal = Parameters<NonNullable<typeof uix.events>['emit']>[0];
function emitSelectStart(signal: EmitSignal) { function emitSelectStart(signal: EmitSignal) {
void uix.events?.emit(signal); void uix.events?.emit(signal);
} }
@ -226,12 +272,17 @@
<RangeCalendar.Heading /> <RangeCalendar.Heading />
<RangeCalendar.NextButton aria-label="Next month">›</RangeCalendar.NextButton> <RangeCalendar.NextButton aria-label="Next month">›</RangeCalendar.NextButton>
</RangeCalendar.Header> </RangeCalendar.Header>
<!-- Multi-month layout: grids sit side-by-side horizontally via
`grid-auto-flow: column` (mirrors the DateRangePicker demo).
When numberOfMonths=1 this collapses to a single grid track. -->
<div class="range-months" data-month-count={months.length}>
{#each months as month (month.value.toString())} {#each months as month (month.value.toString())}
<div class="range-month-panel">
<RangeCalendar.Grid> <RangeCalendar.Grid>
<RangeCalendar.GridHead> <RangeCalendar.GridHead>
<RangeCalendar.GridRow> <RangeCalendar.GridRow>
{#snippet children(_props: RangeCalendarGridRowSnippetProps)} {#snippet children(_props: RangeCalendarGridRowSnippetProps)}
{#each weekdays as wd (wd)} {#each weekdays as wd, i (i)}
<RangeCalendar.HeadCell>{wd}</RangeCalendar.HeadCell> <RangeCalendar.HeadCell>{wd}</RangeCalendar.HeadCell>
{/each} {/each}
{/snippet} {/snippet}
@ -251,7 +302,9 @@
{/each} {/each}
</RangeCalendar.GridBody> </RangeCalendar.GridBody>
</RangeCalendar.Grid> </RangeCalendar.Grid>
</div>
{/each} {/each}
</div>
{/snippet} {/snippet}
</RangeCalendar> </RangeCalendar>
</div> </div>
@ -302,30 +355,30 @@
</div> </div>
<div data-uix-controls> <div data-uix-controls>
<label data-uix-control> <label data-uix-control>
<span data-uix-control-label>minValue</span> <span data-uix-control-label>minValue <span data-uix-control-hint>{selectionMinValue?.toString() ?? 'none'}</span></span>
<span data-uix-chips role="radiogroup"> <input type="date" bind:value={minInput} />
{#each ['none', 'today', 'custom'] as mode (mode)} </label>
<button data-uix-chip data-active={minDateMode === mode} onclick={() => (minDateMode = mode as MinDateMode)}>{mode}</button> <label data-uix-control>
{/each} <span data-uix-control-label>maxValue <span data-uix-control-hint>{selectionMaxValue?.toString() ?? 'none'}</span></span>
</span> <input type="date" bind:value={maxInput} />
<span data-uix-control-label-aux>{minDateLabel}</span>
</label> </label>
<label data-uix-control> <label data-uix-control>
<span data-uix-control-label>maxValue</span> <span data-uix-control-label>min/max presets</span>
<span data-uix-chips role="radiogroup"> <span data-uix-chips role="radiogroup">
{#each ['none', 'custom'] as mode (mode)} <button data-uix-chip data-active={!minInput && !maxInput} onclick={() => setBoundsPreset('none')}>none</button>
<button data-uix-chip data-active={maxDateMode === mode} onclick={() => (maxDateMode = mode as MaxDateMode)}>{mode}</button> <button data-uix-chip data-active={!!minInput && !maxInput} onclick={() => setBoundsPreset('min')}>min only</button>
{/each} <button data-uix-chip data-active={!minInput && !!maxInput} onclick={() => setBoundsPreset('max')}>max only</button>
<button data-uix-chip data-active={minInput === toDateInput(defaultMin) && maxInput === toDateInput(defaultMax)} onclick={() => setBoundsPreset('window')}>May-Jul</button>
<button data-uix-chip data-active={maxInput === toDateInput(invalidMax)} onclick={() => setBoundsPreset('invalid')}>invalid</button>
</span> </span>
<span data-uix-control-label-aux>{maxDateLabel}</span>
</label> </label>
<label data-uix-control> <label data-uix-control>
<span data-uix-control-label>minDays</span> <span data-uix-control-label>minDays</span>
<input type="number" min="1" max="60" placeholder="—" bind:value={minDays} style="inline-size: 4rem;" /> <input type="number" min="1" placeholder="—" bind:value={minDaysInput} style="inline-size: 4rem;" />
</label> </label>
<label data-uix-control> <label data-uix-control>
<span data-uix-control-label>maxDays</span> <span data-uix-control-label>maxDays</span>
<input type="number" min="1" max="365" placeholder="—" bind:value={maxDays} style="inline-size: 4rem;" /> <input type="number" min="1" placeholder="—" bind:value={maxDaysInput} style="inline-size: 4rem;" />
</label> </label>
<label data-uix-control> <label data-uix-control>
<span data-uix-control-label>allowSingleDay</span> <span data-uix-control-label>allowSingleDay</span>
@ -679,3 +732,33 @@
</section> </section>
{/if} {/if}
</div> </div>
<style>
/*
* Multi-month layout — mirrors the date-range-picker demo
* (web/routes/uix/components/date-range-picker/+page.svelte). Side-by-side
* via `grid-auto-flow: column` so the grids never stack vertically; the
* single-month case collapses to one row.
*/
.range-months {
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
justify-content: center;
gap: var(--calendar-month-gap, 36px);
max-inline-size: 100%;
overflow-x: auto;
}
.range-months[data-month-count='1'] {
grid-auto-flow: row;
}
.range-month-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--calendar-grid-gap);
min-inline-size: max-content;
}
</style>

@ -188,17 +188,17 @@
{#if showLabel} {#if showLabel}
<TimeRangeField.Label>{labelText}</TimeRangeField.Label> <TimeRangeField.Label>{labelText}</TimeRangeField.Label>
{/if} {/if}
<TimeRangeField.Input type="start"> <TimeRangeField.Input type="start" aria-label="Start time">
{#snippet children({ segments })} {#snippet children({ segments })}
{#each segments as seg (seg.part)} {#each segments as { part, value }, i (`${part}-${i}`)}
<TimeRangeField.Segment part={seg.part}>{seg.value}</TimeRangeField.Segment> <TimeRangeField.Segment {part}>{value}</TimeRangeField.Segment>
{/each} {/each}
{/snippet} {/snippet}
</TimeRangeField.Input> </TimeRangeField.Input>
<TimeRangeField.Input type="end"> <TimeRangeField.Input type="end" aria-label="End time">
{#snippet children({ segments })} {#snippet children({ segments })}
{#each segments as seg (seg.part)} {#each segments as { part, value }, i (`${part}-${i}`)}
<TimeRangeField.Segment part={seg.part}>{seg.value}</TimeRangeField.Segment> <TimeRangeField.Segment {part}>{value}</TimeRangeField.Segment>
{/each} {/each}
{/snippet} {/snippet}
</TimeRangeField.Input> </TimeRangeField.Input>

Loading…
Cancel
Save

Powered by TurnKey Linux.