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
* 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%;
}

@ -61,6 +61,14 @@ export class CarouselProvider {
viewportRef = $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).
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(() => {

@ -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<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 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<Tab>('live');
let trace = $state<TraceEntry[]>([]);
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 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 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<MinDateMode>('custom');
let maxDateMode = $state<MaxDateMode>('custom');
let weekStartsOn = $state<WeekStartControl>('1');
let locale = $state('en-US');
let size = $state<RangeCalendarSize>('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<NonNullable<typeof uix.events>['emit']>[0];
function emitSelectStart(signal: EmitSignal) {
void uix.events?.emit(signal);
}
@ -226,32 +272,39 @@
<RangeCalendar.Heading />
<RangeCalendar.NextButton aria-label="Next month">›</RangeCalendar.NextButton>
</RangeCalendar.Header>
{#each months as month (month.value.toString())}
<RangeCalendar.Grid>
<RangeCalendar.GridHead>
<RangeCalendar.GridRow>
{#snippet children(_props: RangeCalendarGridRowSnippetProps)}
{#each weekdays as wd (wd)}
<RangeCalendar.HeadCell>{wd}</RangeCalendar.HeadCell>
<!-- 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())}
<div class="range-month-panel">
<RangeCalendar.Grid>
<RangeCalendar.GridHead>
<RangeCalendar.GridRow>
{#snippet children(_props: RangeCalendarGridRowSnippetProps)}
{#each weekdays as wd, i (i)}
<RangeCalendar.HeadCell>{wd}</RangeCalendar.HeadCell>
{/each}
{/snippet}
</RangeCalendar.GridRow>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody>
{#each month.weeks as week (week[0].toString())}
<RangeCalendar.GridRow week={week}>
{#snippet children(_props: RangeCalendarGridRowSnippetProps)}
{#each week as date (date.toString())}
<RangeCalendar.Cell {date} month={month.value}>
<RangeCalendar.Day />
</RangeCalendar.Cell>
{/each}
{/snippet}
</RangeCalendar.GridRow>
{/each}
{/snippet}
</RangeCalendar.GridRow>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody>
{#each month.weeks as week (week[0].toString())}
<RangeCalendar.GridRow week={week}>
{#snippet children(_props: RangeCalendarGridRowSnippetProps)}
{#each week as date (date.toString())}
<RangeCalendar.Cell {date} month={month.value}>
<RangeCalendar.Day />
</RangeCalendar.Cell>
{/each}
{/snippet}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
</div>
{/each}
</div>
{/snippet}
</RangeCalendar>
</div>
@ -302,30 +355,30 @@
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>minValue</span>
<span data-uix-chips role="radiogroup">
{#each ['none', 'today', 'custom'] as mode (mode)}
<button data-uix-chip data-active={minDateMode === mode} onclick={() => (minDateMode = mode as MinDateMode)}>{mode}</button>
{/each}
</span>
<span data-uix-control-label-aux>{minDateLabel}</span>
<span data-uix-control-label>minValue <span data-uix-control-hint>{selectionMinValue?.toString() ?? 'none'}</span></span>
<input type="date" bind:value={minInput} />
</label>
<label data-uix-control>
<span data-uix-control-label>maxValue</span>
<span data-uix-control-label>maxValue <span data-uix-control-hint>{selectionMaxValue?.toString() ?? 'none'}</span></span>
<input type="date" bind:value={maxInput} />
</label>
<label data-uix-control>
<span data-uix-control-label>min/max presets</span>
<span data-uix-chips role="radiogroup">
{#each ['none', 'custom'] as mode (mode)}
<button data-uix-chip data-active={maxDateMode === mode} onclick={() => (maxDateMode = mode as MaxDateMode)}>{mode}</button>
{/each}
<button data-uix-chip data-active={!minInput && !maxInput} onclick={() => setBoundsPreset('none')}>none</button>
<button data-uix-chip data-active={!!minInput && !maxInput} onclick={() => setBoundsPreset('min')}>min only</button>
<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 data-uix-control-label-aux>{maxDateLabel}</span>
</label>
<label data-uix-control>
<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 data-uix-control>
<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 data-uix-control>
<span data-uix-control-label>allowSingleDay</span>
@ -679,3 +732,33 @@
</section>
{/if}
</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}
<TimeRangeField.Label>{labelText}</TimeRangeField.Label>
{/if}
<TimeRangeField.Input type="start">
<TimeRangeField.Input type="start" aria-label="Start time">
{#snippet children({ segments })}
{#each segments as seg (seg.part)}
<TimeRangeField.Segment part={seg.part}>{seg.value}</TimeRangeField.Segment>
{#each segments as { part, value }, i (`${part}-${i}`)}
<TimeRangeField.Segment {part}>{value}</TimeRangeField.Segment>
{/each}
{/snippet}
</TimeRangeField.Input>
<TimeRangeField.Input type="end">
<TimeRangeField.Input type="end" aria-label="End time">
{#snippet children({ segments })}
{#each segments as seg (seg.part)}
<TimeRangeField.Segment part={seg.part}>{seg.value}</TimeRangeField.Segment>
{#each segments as { part, value }, i (`${part}-${i}`)}
<TimeRangeField.Segment {part}>{value}</TimeRangeField.Segment>
{/each}
{/snippet}
</TimeRangeField.Input>

Loading…
Cancel
Save

Powered by TurnKey Linux.