feat(eidos): port range-calendar from soma + extract shared partial

Standalone eidos `<RangeCalendar>` wrapper over the soma range-calendar
primitive. Adds size / variant / color cascades; otherwise pass-through
14 compound parts (Provider, Header, Heading, PrevButton, NextButton,
MonthSelect, YearSelect, Grid, GridHead, GridBody, GridRow, HeadCell,
Cell, Day).

## Feature comparison

| Feature              | bits-ui | ark-ui | react-aria | Eidos |
|----------------------|---------|--------|------------|-------|
| Range start/end      | ✓       | ✓      | ✓          | ✓     |
| Hover preview state  | ✓       | ✓      | ✓          | ✓     |
| minDays / maxDays    | —       | —      | ✓          | ✓     |
| allowSingleDay       | ✓       | ✓      | ✓          | ✓     |
| preventDeselect      | ✓       | ✓      | —          | ✓     |
| Multi-month display  | ✓       | ✓      | ✓          | ✓     |
| Week-number column   | —       | —      | ✓          | ✓     |
| Month/Year selects   | —       | ✓      | —          | ✓     |
| Disabled / readonly  | ✓       | ✓      | ✓          | ✓     |
| isDateUnavailable    | ✓       | ✓      | ✓          | ✓     |
| Holiday markers      | —       | —      | —          | ✓     |
| Locale / dir         | ✓       | ✓      | ✓          | ✓     |
| Size cascade         | —       | —      | —          | ✓ 4   |
| Variant cascade      | —       | —      | —          | ✓ 3   |
| Color cascade        | —       | —      | —          | ✓ 8   |

## Range-cells partial extracted

The recipe at `src/uix/eidos/components/range-calendar/range-calendar.css`
now owns ALL `[data-range-calendar-*]` selectors — the shared rules
that previously lived inside `date-range-picker.css` (lines 537-834)
moved here. Both consumers (the standalone `<RangeCalendar>` and
`<DateRangePicker>`'s popover content) stamp the same data-attrs, so the
same selectors paint without duplication.

`date-range-picker.css` drops 298 lines of duplication; it keeps only
the picker-specific scope overrides (`[data-date-range-picker-calendar]`
with no chrome, max-content sizing, transparent bg).

## Morfo

Extended `rangeCalendarMorfo.scope` to include `'eidos'` so the new
wrapper appears in the eidos-layer inventory and the demo Morfo tab
declares it correctly.

## Demo

Canonical 6-tab depth (~600L) at `/uix/components/range-calendar`. Live
tab exposes every range-specific prop: minDays / maxDays sliders,
allowSingleDay, preventDeselect, numberOfMonths chips (1 / 2 / 3),
pagedNavigation, fixedWeeks, showWeekNumbers, disableDaysOutsideMonth,
weekStartsOn, disabled, readonly, locale, plus size / variant / color
cascades.

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

@ -534,304 +534,6 @@
border-color: transparent;
}
[data-range-calendar-header] {
display: grid;
grid-template-columns: var(--_calendar-control-size) minmax(0, 1fr) var(--_calendar-control-size);
align-items: center;
gap: var(--calendar-header-gap);
inline-size: 100%;
min-inline-size: 0;
}
[data-range-calendar-header-controls] {
display: flex;
align-items: center;
justify-content: center;
gap: var(--calendar-header-gap);
min-inline-size: 0;
}
[data-range-calendar-heading] {
flex: 1 1 auto;
min-inline-size: 0;
color: var(--calendar-heading-color);
font-weight: var(--calendar-heading-font-weight);
text-align: center;
}
[data-range-calendar-prev-button],
[data-range-calendar-next-button] {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
inline-size: var(--_calendar-control-size);
block-size: var(--_calendar-control-size);
padding: 0;
border: var(--calendar-control-border-width) solid var(--calendar-control-border);
border-radius: var(--calendar-control-radius);
background: var(--calendar-control-bg);
color: var(--calendar-control-color);
cursor: pointer;
transition:
background var(--calendar-transition-duration) var(--calendar-transition-ease),
border-color var(--calendar-transition-duration) var(--calendar-transition-ease),
color var(--calendar-transition-duration) var(--calendar-transition-ease),
box-shadow var(--calendar-transition-duration) var(--calendar-transition-ease);
}
[data-range-calendar-prev-button]:hover,
[data-range-calendar-next-button]:hover {
border-color: var(--calendar-control-border-hover);
background: var(--calendar-control-bg-hover);
color: var(--calendar-control-color-hover);
}
[data-range-calendar-prev-button]:focus-visible,
[data-range-calendar-next-button]:focus-visible,
[data-range-calendar-month-select]:focus-visible,
[data-range-calendar-year-select]:focus-visible,
[data-range-calendar-day]:focus-visible {
outline: none;
box-shadow: var(--calendar-focus-shadow);
}
[data-range-calendar-prev-button][data-disabled],
[data-range-calendar-next-button][data-disabled],
[data-range-calendar-month-select][data-disabled],
[data-range-calendar-year-select][data-disabled] {
cursor: not-allowed;
opacity: var(--calendar-disabled-opacity);
}
[data-range-calendar-month-select],
[data-range-calendar-year-select] {
min-inline-size: 0;
block-size: var(--_calendar-control-size);
padding-inline: var(--calendar-select-px);
border: var(--calendar-control-border-width) solid var(--calendar-control-border);
border-radius: var(--calendar-control-radius);
background: var(--calendar-control-bg);
color: var(--calendar-control-color);
font: inherit;
cursor: pointer;
transition:
background var(--calendar-transition-duration) var(--calendar-transition-ease),
border-color var(--calendar-transition-duration) var(--calendar-transition-ease),
color var(--calendar-transition-duration) var(--calendar-transition-ease),
box-shadow var(--calendar-transition-duration) var(--calendar-transition-ease);
}
[data-range-calendar-month-select] {
min-inline-size: var(--calendar-select-min-width);
inline-size: calc(var(--_calendar-day-size) * 2.35);
}
[data-range-calendar-year-select] {
min-inline-size: var(--calendar-select-min-width);
inline-size: calc(var(--_calendar-day-size) * 2.15);
}
[data-range-calendar-month-select]:hover,
[data-range-calendar-year-select]:hover {
border-color: var(--calendar-control-border-hover);
background: var(--calendar-control-bg-hover);
color: var(--calendar-control-color-hover);
}
[data-range-calendar-grid] {
inline-size: max-content;
max-inline-size: 100%;
margin-inline: auto;
border-collapse: separate;
border-spacing: var(--calendar-grid-gap);
table-layout: fixed;
}
[data-range-calendar-head-cell] {
inline-size: var(--_calendar-day-size);
block-size: var(--calendar-weekday-size);
color: var(--calendar-weekday-color);
font-size: var(--calendar-weekday-font-size);
font-weight: var(--calendar-weekday-font-weight);
text-align: center;
}
[data-range-calendar-week-number] {
color: var(--calendar-weekday-color);
font-variant-numeric: tabular-nums;
}
[data-range-calendar-week-number][scope='row'] {
block-size: var(--_calendar-day-size);
font-size: var(--calendar-weekday-font-size);
font-weight: var(--calendar-weekday-font-weight);
}
[data-range-calendar-cell] {
padding: 0;
border-radius: var(--calendar-day-radius);
text-align: center;
vertical-align: middle;
}
[data-range-calendar-cell][data-in-range],
[data-range-calendar-cell][data-in-preview-range] {
background: var(--_calendar-accent-track);
}
[data-range-calendar-day] {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: var(--_calendar-day-size);
block-size: var(--_calendar-day-size);
border: var(--calendar-day-border-width) solid var(--calendar-day-border);
border-radius: var(--calendar-day-radius);
background: var(--calendar-day-bg);
color: var(--calendar-day-color);
font: inherit;
font-weight: var(--calendar-day-font-weight);
cursor: pointer;
user-select: none;
transition:
background var(--calendar-transition-duration) var(--calendar-transition-ease),
border-color var(--calendar-transition-duration) var(--calendar-transition-ease),
color var(--calendar-transition-duration) var(--calendar-transition-ease),
box-shadow var(--calendar-transition-duration) var(--calendar-transition-ease),
opacity var(--calendar-transition-duration) var(--calendar-transition-ease);
}
[data-range-calendar-day]:hover {
border-color: var(--calendar-day-border-hover);
background: var(--calendar-day-bg-hover);
color: var(--calendar-day-color-hover);
}
[data-range-calendar-day][data-selected] {
border-color: var(--_calendar-range-end-border);
background: var(--_calendar-range-end-solid);
color: var(--calendar-day-selected-color);
}
[data-range-calendar-day][data-range-start] {
/* Start endpoint: solid background + small left-edge accent stripe so it
reads as the *opening* of the range, never blending with the end. */
border-color: var(--_calendar-range-start-border);
background: var(--_calendar-range-start-solid);
color: var(--_calendar-range-start-text);
box-shadow: inset 2px 0 0 0 var(--_calendar-range-start-border);
font-weight: var(--font-weight-semibold);
}
[data-range-calendar-day][data-range-end] {
/* End endpoint: solid background + small right-edge accent stripe. */
border-color: var(--_calendar-range-end-border);
background: var(--_calendar-range-end-solid);
color: var(--_calendar-range-end-text);
box-shadow: inset -2px 0 0 0 var(--_calendar-range-end-border);
font-weight: var(--font-weight-semibold);
}
[data-range-calendar-day][data-range-start][data-range-end] {
border-color: var(--_calendar-range-end-border);
background: linear-gradient(
135deg,
var(--_calendar-range-start-solid) 0 50%,
var(--_calendar-range-end-solid) 50% 100%
);
color: var(--calendar-day-selected-color);
}
[data-range-calendar-day][data-selected]:hover {
border-color: var(--_calendar-range-end-solid-hover);
background: var(--_calendar-range-end-solid-hover);
}
[data-range-calendar-day][data-range-start]:hover {
border-color: var(--_calendar-range-start-solid-hover);
background: var(--_calendar-range-start-solid-hover);
}
[data-range-calendar-day][data-range-end]:hover {
border-color: var(--_calendar-range-end-solid-hover);
background: var(--_calendar-range-end-solid-hover);
}
[data-range-calendar-day][data-range-start][data-range-end]:hover {
background: linear-gradient(
135deg,
var(--_calendar-range-start-solid-hover) 0 50%,
var(--_calendar-range-end-solid-hover) 50% 100%
);
}
[data-range-calendar-day][data-in-range]:not([data-selected]) {
border-color: transparent;
background: transparent;
color: var(--_calendar-accent-text);
}
[data-range-calendar-day][data-preview-start],
[data-range-calendar-day][data-preview-end],
[data-range-calendar-day][data-in-preview-range]:not([data-selected]) {
border-color: var(--_calendar-accent-border);
background: transparent;
color: var(--_calendar-accent-text);
}
[data-range-calendar-day][data-focused]:not([data-selected]) {
border-color: var(--_calendar-accent-border);
background: var(--_calendar-accent-track);
color: var(--_calendar-accent-text);
}
[data-range-calendar-day][data-today]:not([data-selected]) {
border-color: var(--calendar-day-today-border);
color: var(--calendar-day-today-color);
font-weight: var(--calendar-day-today-font-weight);
}
[data-range-calendar-day][data-outside-month] {
color: var(--calendar-day-outside-color);
opacity: var(--calendar-day-outside-opacity);
}
[data-range-calendar-day][data-weekend]:not([data-selected]) {
color: var(--calendar-day-weekend-color);
}
[data-range-calendar-day][data-holiday]:not([data-selected]) {
box-shadow: var(--calendar-day-holiday-shadow);
}
[data-range-calendar-day][data-unavailable] {
text-decoration-line: line-through;
text-decoration-color: var(--calendar-day-unavailable-decoration);
}
[data-range-calendar-day][data-disabled],
[data-range-calendar-day][data-unavailable] {
cursor: not-allowed;
opacity: var(--calendar-disabled-opacity);
}
[data-range-calendar][data-invalid] {
border-color: var(--calendar-invalid-border);
}
[data-range-calendar][data-disabled] {
opacity: var(--calendar-disabled-opacity);
}
[data-range-calendar][data-event],
[data-range-calendar-day][data-event],
[data-range-calendar-prev-button][data-event],
[data-range-calendar-next-button][data-event],
[data-range-calendar-month-select][data-event],
[data-range-calendar-year-select][data-event] {
box-shadow: var(--calendar-event-shadow);
}
@media (max-width: 42rem) {
[data-date-range-field-input] {

@ -0,0 +1,101 @@
// RangeCalendar — eidos compound API.
//
// import { RangeCalendar } from '$uix/eidos/components/range-calendar';
//
// <RangeCalendar bind:value>
// <RangeCalendar.Header>
// <RangeCalendar.PrevButton />
// <RangeCalendar.Heading />
// <RangeCalendar.NextButton />
// </RangeCalendar.Header>
// <RangeCalendar.Grid>
// <RangeCalendar.GridHead>
// <RangeCalendar.GridRow>
// {#snippet children({ weekdays })}
// {#each weekdays as wd}
// <RangeCalendar.HeadCell>{wd}</RangeCalendar.HeadCell>
// {/each}
// {/snippet}
// </RangeCalendar.GridRow>
// </RangeCalendar.GridHead>
// <RangeCalendar.GridBody>
// ...
// </RangeCalendar.GridBody>
// </RangeCalendar.Grid>
// </RangeCalendar>
import RangeCalendarComponent from './range-calendar.svelte';
import Header from './range-calendar-header.svelte';
import Heading from './range-calendar-heading.svelte';
import PrevButton from './range-calendar-prev-button.svelte';
import NextButton from './range-calendar-next-button.svelte';
import MonthSelect from './range-calendar-month-select.svelte';
import YearSelect from './range-calendar-year-select.svelte';
import Grid from './range-calendar-grid.svelte';
import GridHead from './range-calendar-grid-head.svelte';
import GridBody from './range-calendar-grid-body.svelte';
import GridRow from './range-calendar-grid-row.svelte';
import HeadCell from './range-calendar-head-cell.svelte';
import Cell from './range-calendar-cell.svelte';
import Day from './range-calendar-day.svelte';
type RangeCalendarNamespace = typeof RangeCalendarComponent & {
Header: typeof Header;
Heading: typeof Heading;
PrevButton: typeof PrevButton;
NextButton: typeof NextButton;
MonthSelect: typeof MonthSelect;
YearSelect: typeof YearSelect;
Grid: typeof Grid;
GridHead: typeof GridHead;
GridBody: typeof GridBody;
GridRow: typeof GridRow;
HeadCell: typeof HeadCell;
Cell: typeof Cell;
Day: typeof Day;
};
const RangeCalendar = RangeCalendarComponent as RangeCalendarNamespace;
RangeCalendar.Header = Header;
RangeCalendar.Heading = Heading;
RangeCalendar.PrevButton = PrevButton;
RangeCalendar.NextButton = NextButton;
RangeCalendar.MonthSelect = MonthSelect;
RangeCalendar.YearSelect = YearSelect;
RangeCalendar.Grid = Grid;
RangeCalendar.GridHead = GridHead;
RangeCalendar.GridBody = GridBody;
RangeCalendar.GridRow = GridRow;
RangeCalendar.HeadCell = HeadCell;
RangeCalendar.Cell = Cell;
RangeCalendar.Day = Day;
export { RangeCalendar };
export default RangeCalendar;
export type {
RangeCalendarProps,
RangeCalendarHeaderProps as HeaderProps,
RangeCalendarHeadingProps as HeadingProps,
RangeCalendarPrevButtonProps as PrevButtonProps,
RangeCalendarNextButtonProps as NextButtonProps,
RangeCalendarMonthSelectProps as MonthSelectProps,
RangeCalendarYearSelectProps as YearSelectProps,
RangeCalendarGridProps as GridProps,
RangeCalendarGridHeadProps as GridHeadProps,
RangeCalendarGridBodyProps as GridBodyProps,
RangeCalendarGridRowProps as GridRowProps,
RangeCalendarHeadCellProps as HeadCellProps,
RangeCalendarCellProps as CellProps,
RangeCalendarDayProps as DayProps,
RangeCalendarProviderSnippetProps,
RangeCalendarCellSnippetProps,
RangeCalendarDaySnippetProps,
RangeCalendarHeadingSnippetProps,
RangeCalendarMonthSelectSnippetProps,
RangeCalendarYearSelectSnippetProps,
RangeCalendarGridRowSnippetProps,
RangeCalendarSize,
RangeCalendarVariant,
RangeCalendarColor
} from './types';

@ -0,0 +1,12 @@
<script lang="ts">
import * as RangeCalendar from '$soma/components/range-calendar';
import type { RangeCalendarCellProps, RangeCalendarCellSnippetProps } from './types';
let { children: bodyContent, ...rest }: RangeCalendarCellProps = $props();
</script>
<RangeCalendar.Cell {...rest}>
{#snippet children(snippetProps: RangeCalendarCellSnippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}
</RangeCalendar.Cell>

@ -0,0 +1,16 @@
<script lang="ts">
import * as RangeCalendar from '$soma/components/range-calendar';
import type { RangeCalendarDayProps, RangeCalendarDaySnippetProps } from './types';
let { children: bodyContent, ...rest }: RangeCalendarDayProps = $props();
</script>
{#if bodyContent}
<RangeCalendar.Day {...rest}>
{#snippet children(snippetProps: RangeCalendarDaySnippetProps)}
{@render bodyContent(snippetProps)}
{/snippet}
</RangeCalendar.Day>
{:else}
<RangeCalendar.Day {...rest} />
{/if}

@ -0,0 +1,10 @@
<script lang="ts">
import * as RangeCalendar from '$soma/components/range-calendar';
import type { RangeCalendarGridBodyProps } from './types';
let { children: bodyContent, ...rest }: RangeCalendarGridBodyProps = $props();
</script>
<RangeCalendar.GridBody {...rest}>
{@render bodyContent?.()}
</RangeCalendar.GridBody>

@ -0,0 +1,10 @@
<script lang="ts">
import * as RangeCalendar from '$soma/components/range-calendar';
import type { RangeCalendarGridHeadProps } from './types';
let { children: bodyContent, ...rest }: RangeCalendarGridHeadProps = $props();
</script>
<RangeCalendar.GridHead {...rest}>
{@render bodyContent?.()}
</RangeCalendar.GridHead>

@ -0,0 +1,12 @@
<script lang="ts">
import * as RangeCalendar from '$soma/components/range-calendar';
import type { RangeCalendarGridRowProps, RangeCalendarGridRowSnippetProps } from './types';
let { children: bodyContent, ...rest }: RangeCalendarGridRowProps = $props();
</script>
<RangeCalendar.GridRow {...rest}>
{#snippet children(snippetProps: RangeCalendarGridRowSnippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}
</RangeCalendar.GridRow>

@ -0,0 +1,10 @@
<script lang="ts">
import * as RangeCalendar from '$soma/components/range-calendar';
import type { RangeCalendarGridProps } from './types';
let { children: bodyContent, ...rest }: RangeCalendarGridProps = $props();
</script>
<RangeCalendar.Grid {...rest}>
{@render bodyContent?.()}
</RangeCalendar.Grid>

@ -0,0 +1,10 @@
<script lang="ts">
import * as RangeCalendar from '$soma/components/range-calendar';
import type { RangeCalendarHeadCellProps } from './types';
let { children: bodyContent, ...rest }: RangeCalendarHeadCellProps = $props();
</script>
<RangeCalendar.HeadCell {...rest}>
{@render bodyContent?.()}
</RangeCalendar.HeadCell>

@ -0,0 +1,10 @@
<script lang="ts">
import * as RangeCalendar from '$soma/components/range-calendar';
import type { RangeCalendarHeaderProps } from './types';
let { children: bodyContent, ...rest }: RangeCalendarHeaderProps = $props();
</script>
<RangeCalendar.Header {...rest}>
{@render bodyContent?.()}
</RangeCalendar.Header>

@ -0,0 +1,16 @@
<script lang="ts">
import * as RangeCalendar from '$soma/components/range-calendar';
import type { RangeCalendarHeadingProps, RangeCalendarHeadingSnippetProps } from './types';
let { children: bodyContent, ...rest }: RangeCalendarHeadingProps = $props();
</script>
{#if bodyContent}
<RangeCalendar.Heading {...rest}>
{#snippet children(snippetProps: RangeCalendarHeadingSnippetProps)}
{@render bodyContent(snippetProps)}
{/snippet}
</RangeCalendar.Heading>
{:else}
<RangeCalendar.Heading {...rest} />
{/if}

@ -0,0 +1,19 @@
<script lang="ts">
import * as RangeCalendar from '$soma/components/range-calendar';
import type {
RangeCalendarMonthSelectProps,
RangeCalendarMonthSelectSnippetProps
} from './types';
let { children: bodyContent, ...rest }: RangeCalendarMonthSelectProps = $props();
</script>
{#if bodyContent}
<RangeCalendar.MonthSelect {...rest}>
{#snippet children(snippetProps: RangeCalendarMonthSelectSnippetProps)}
{@render bodyContent(snippetProps)}
{/snippet}
</RangeCalendar.MonthSelect>
{:else}
<RangeCalendar.MonthSelect {...rest} />
{/if}

@ -0,0 +1,10 @@
<script lang="ts">
import * as RangeCalendar from '$soma/components/range-calendar';
import type { RangeCalendarNextButtonProps } from './types';
let { children: bodyContent, ...rest }: RangeCalendarNextButtonProps = $props();
</script>
<RangeCalendar.NextButton {...rest}>
{@render bodyContent?.()}
</RangeCalendar.NextButton>

@ -0,0 +1,10 @@
<script lang="ts">
import * as RangeCalendar from '$soma/components/range-calendar';
import type { RangeCalendarPrevButtonProps } from './types';
let { children: bodyContent, ...rest }: RangeCalendarPrevButtonProps = $props();
</script>
<RangeCalendar.PrevButton {...rest}>
{@render bodyContent?.()}
</RangeCalendar.PrevButton>

@ -0,0 +1,19 @@
<script lang="ts">
import * as RangeCalendar from '$soma/components/range-calendar';
import type {
RangeCalendarYearSelectProps,
RangeCalendarYearSelectSnippetProps
} from './types';
let { children: bodyContent, ...rest }: RangeCalendarYearSelectProps = $props();
</script>
{#if bodyContent}
<RangeCalendar.YearSelect {...rest}>
{#snippet children(snippetProps: RangeCalendarYearSelectSnippetProps)}
{@render bodyContent(snippetProps)}
{/snippet}
</RangeCalendar.YearSelect>
{:else}
<RangeCalendar.YearSelect {...rest} />
{/if}

@ -0,0 +1,479 @@
/*
* Range Calendar recipe.
*
* [data-range-calendar] → root container
* [data-range-calendar-header] → heading row
* [data-range-calendar-heading] → month/year label
* [data-range-calendar-prev-button] → previous-month nav
* [data-range-calendar-next-button] → next-month nav
* [data-range-calendar-month-select] → month picker dropdown
* [data-range-calendar-year-select] → year picker dropdown
* [data-range-calendar-grid] → 7-column day grid
* [data-range-calendar-head-cell] → weekday label
* [data-range-calendar-cell] → day-cell container (+ in-range bg)
* [data-range-calendar-day] → individual day button
*
* Range-specific data-attrs (lit by soma):
* - [data-range-start] first endpoint
* - [data-range-end] second endpoint
* - [data-in-range] between endpoints
* - [data-preview-start] hover preview anchor
* - [data-preview-end] hover preview anchor
* - [data-in-preview-range] between hover anchor and pivot
*
* Cross-component reuse:
* This file is consumed BOTH by the standalone eidos `<RangeCalendar>` and
* by `<DateRangePicker>` (which renders the soma RangeCalendar inside a
* popover). Both stamp the same `[data-range-calendar-*]` attrs, so the
* same selectors paint without duplication.
*/
/* ── Root token scaffolding ──────────────────────────────────────────── */
[data-range-calendar] {
--_calendar-padding: var(--calendar-padding-md);
--_calendar-control-size: var(--calendar-control-size-md);
--_calendar-day-size: var(--calendar-day-size-md);
--_calendar-font-size: var(--calendar-font-size-md);
--_calendar-accent-solid: var(--color-primary-solid);
--_calendar-accent-solid-hover: var(--color-primary-solid-hover);
--_calendar-accent-track: var(--color-primary-track);
--_calendar-accent-border: var(--color-primary-border);
--_calendar-accent-text: var(--color-primary-text);
/* Start endpoint defaults to `affirm` semantic (range opens). End
endpoint follows the `data-color` cascade. */
--_calendar-range-start-solid: var(--color-affirm-solid);
--_calendar-range-start-solid-hover: var(--color-affirm-solid-hover);
--_calendar-range-start-border: var(--color-affirm-border);
--_calendar-range-start-text: var(--color-affirm-contrast);
--_calendar-range-end-solid: var(--_calendar-accent-solid);
--_calendar-range-end-solid-hover: var(--_calendar-accent-solid-hover);
--_calendar-range-end-border: var(--_calendar-accent-solid);
--_calendar-range-end-text: var(--calendar-day-selected-color);
display: inline-flex;
flex-direction: column;
gap: var(--calendar-gap);
inline-size: max-content;
max-inline-size: 100%;
padding: var(--_calendar-padding);
border: var(--calendar-border-width) solid var(--calendar-border);
border-radius: var(--calendar-radius);
background: var(--calendar-bg);
box-shadow: var(--calendar-shadow);
color: var(--calendar-color);
font-family: var(--calendar-font-family);
font-size: var(--_calendar-font-size);
line-height: var(--calendar-line-height);
}
[data-range-calendar][data-size='xs'] {
--_calendar-padding: var(--calendar-padding-xs);
--_calendar-control-size: var(--calendar-control-size-xs);
--_calendar-day-size: var(--calendar-day-size-xs);
--_calendar-font-size: var(--calendar-font-size-xs);
}
[data-range-calendar][data-size='sm'] {
--_calendar-padding: var(--calendar-padding-sm);
--_calendar-control-size: var(--calendar-control-size-sm);
--_calendar-day-size: var(--calendar-day-size-sm);
--_calendar-font-size: var(--calendar-font-size-sm);
}
[data-range-calendar][data-size='lg'] {
--_calendar-padding: var(--calendar-padding-lg);
--_calendar-control-size: var(--calendar-control-size-lg);
--_calendar-day-size: var(--calendar-day-size-lg);
--_calendar-font-size: var(--calendar-font-size-lg);
}
[data-range-calendar][data-variant='outline'] {
background: var(--calendar-outline-bg);
box-shadow: var(--calendar-outline-shadow);
}
[data-range-calendar][data-variant='ghost'] {
padding: 0;
border-color: transparent;
background: transparent;
box-shadow: none;
}
/* ── Color cascades ─────────────────────────────────────────────────── */
[data-range-calendar][data-color='secondary'] {
--_calendar-accent-solid: var(--color-secondary-solid);
--_calendar-accent-solid-hover: var(--color-secondary-solid-hover);
--_calendar-accent-track: var(--color-secondary-track);
--_calendar-accent-border: var(--color-secondary-border);
--_calendar-accent-text: var(--color-secondary-text);
}
[data-range-calendar][data-color='neutral'] {
--_calendar-accent-solid: var(--color-neutral-solid);
--_calendar-accent-solid-hover: var(--color-neutral-solid-hover);
--_calendar-accent-track: var(--color-neutral-track);
--_calendar-accent-border: var(--color-neutral-border);
--_calendar-accent-text: var(--color-neutral-text);
}
[data-range-calendar][data-color='affirm'] {
--_calendar-accent-solid: var(--color-affirm-solid);
--_calendar-accent-solid-hover: var(--color-affirm-solid-hover);
--_calendar-accent-track: var(--color-affirm-track);
--_calendar-accent-border: var(--color-affirm-border);
--_calendar-accent-text: var(--color-affirm-text);
/* When the end accent IS `affirm`, swap the start endpoint to
secondary so the two endpoints stay visually distinct. */
--_calendar-range-start-solid: var(--color-secondary-solid);
--_calendar-range-start-solid-hover: var(--color-secondary-solid-hover);
--_calendar-range-start-border: var(--color-secondary-border);
--_calendar-range-start-text: var(--color-secondary-contrast);
}
[data-range-calendar][data-color='fulfill'] {
--_calendar-accent-solid: var(--color-fulfill-solid);
--_calendar-accent-solid-hover: var(--color-fulfill-solid-hover);
--_calendar-accent-track: var(--color-fulfill-track);
--_calendar-accent-border: var(--color-fulfill-border);
--_calendar-accent-text: var(--color-fulfill-text);
--_calendar-range-start-solid: var(--color-secondary-solid);
--_calendar-range-start-solid-hover: var(--color-secondary-solid-hover);
--_calendar-range-start-border: var(--color-secondary-border);
--_calendar-range-start-text: var(--color-secondary-contrast);
}
[data-range-calendar][data-color='risk'] {
--_calendar-accent-solid: var(--color-risk-solid);
--_calendar-accent-solid-hover: var(--color-risk-solid-hover);
--_calendar-accent-track: var(--color-risk-track);
--_calendar-accent-border: var(--color-risk-border);
--_calendar-accent-text: var(--color-risk-text);
}
[data-range-calendar][data-color='threat'] {
--_calendar-accent-solid: var(--color-threat-solid);
--_calendar-accent-solid-hover: var(--color-threat-solid-hover);
--_calendar-accent-track: var(--color-threat-track);
--_calendar-accent-border: var(--color-threat-border);
--_calendar-accent-text: var(--color-threat-text);
}
[data-range-calendar][data-color='loss'] {
--_calendar-accent-solid: var(--color-loss-solid);
--_calendar-accent-solid-hover: var(--color-loss-solid-hover);
--_calendar-accent-track: var(--color-loss-track);
--_calendar-accent-border: var(--color-loss-border);
--_calendar-accent-text: var(--color-loss-text);
}
/* ── Header + nav controls ──────────────────────────────────────────── */
[data-range-calendar-header] {
display: grid;
grid-template-columns: var(--_calendar-control-size) minmax(0, 1fr) var(--_calendar-control-size);
align-items: center;
gap: var(--calendar-header-gap);
inline-size: 100%;
min-inline-size: 0;
}
[data-range-calendar-header-controls] {
display: flex;
align-items: center;
justify-content: center;
gap: var(--calendar-header-gap);
min-inline-size: 0;
}
[data-range-calendar-heading] {
flex: 1 1 auto;
min-inline-size: 0;
color: var(--calendar-heading-color);
font-weight: var(--calendar-heading-font-weight);
text-align: center;
}
[data-range-calendar-prev-button],
[data-range-calendar-next-button] {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
inline-size: var(--_calendar-control-size);
block-size: var(--_calendar-control-size);
padding: 0;
border: var(--calendar-control-border-width) solid var(--calendar-control-border);
border-radius: var(--calendar-control-radius);
background: var(--calendar-control-bg);
color: var(--calendar-control-color);
cursor: pointer;
transition:
background var(--calendar-transition-duration) var(--calendar-transition-ease),
border-color var(--calendar-transition-duration) var(--calendar-transition-ease),
color var(--calendar-transition-duration) var(--calendar-transition-ease),
box-shadow var(--calendar-transition-duration) var(--calendar-transition-ease);
}
[data-range-calendar-prev-button]:hover,
[data-range-calendar-next-button]:hover {
border-color: var(--calendar-control-border-hover);
background: var(--calendar-control-bg-hover);
color: var(--calendar-control-color-hover);
}
[data-range-calendar-prev-button]:focus-visible,
[data-range-calendar-next-button]:focus-visible,
[data-range-calendar-month-select]:focus-visible,
[data-range-calendar-year-select]:focus-visible,
[data-range-calendar-day]:focus-visible {
outline: none;
box-shadow: var(--calendar-focus-shadow);
}
[data-range-calendar-prev-button][data-disabled],
[data-range-calendar-next-button][data-disabled],
[data-range-calendar-month-select][data-disabled],
[data-range-calendar-year-select][data-disabled] {
cursor: not-allowed;
opacity: var(--calendar-disabled-opacity);
}
[data-range-calendar-month-select],
[data-range-calendar-year-select] {
min-inline-size: 0;
block-size: var(--_calendar-control-size);
padding-inline: var(--calendar-select-px);
border: var(--calendar-control-border-width) solid var(--calendar-control-border);
border-radius: var(--calendar-control-radius);
background: var(--calendar-control-bg);
color: var(--calendar-control-color);
font: inherit;
cursor: pointer;
transition:
background var(--calendar-transition-duration) var(--calendar-transition-ease),
border-color var(--calendar-transition-duration) var(--calendar-transition-ease),
color var(--calendar-transition-duration) var(--calendar-transition-ease),
box-shadow var(--calendar-transition-duration) var(--calendar-transition-ease);
}
[data-range-calendar-month-select] {
min-inline-size: var(--calendar-select-min-width);
inline-size: calc(var(--_calendar-day-size) * 2.35);
}
[data-range-calendar-year-select] {
min-inline-size: var(--calendar-select-min-width);
inline-size: calc(var(--_calendar-day-size) * 2.15);
}
[data-range-calendar-month-select]:hover,
[data-range-calendar-year-select]:hover {
border-color: var(--calendar-control-border-hover);
background: var(--calendar-control-bg-hover);
color: var(--calendar-control-color-hover);
}
/* ── Grid ───────────────────────────────────────────────────────────── */
[data-range-calendar-grid] {
inline-size: max-content;
max-inline-size: 100%;
margin-inline: auto;
border-collapse: separate;
border-spacing: var(--calendar-grid-gap);
table-layout: fixed;
}
[data-range-calendar-head-cell] {
inline-size: var(--_calendar-day-size);
block-size: var(--calendar-weekday-size);
color: var(--calendar-weekday-color);
font-size: var(--calendar-weekday-font-size);
font-weight: var(--calendar-weekday-font-weight);
text-align: center;
}
[data-range-calendar-week-number] {
color: var(--calendar-weekday-color);
font-variant-numeric: tabular-nums;
}
[data-range-calendar-week-number][scope='row'] {
block-size: var(--_calendar-day-size);
font-size: var(--calendar-weekday-font-size);
font-weight: var(--calendar-weekday-font-weight);
}
[data-range-calendar-cell] {
padding: 0;
border-radius: var(--calendar-day-radius);
text-align: center;
vertical-align: middle;
}
[data-range-calendar-cell][data-in-range],
[data-range-calendar-cell][data-in-preview-range] {
background: var(--_calendar-accent-track);
}
/* ── Day cells + range-specific states ──────────────────────────────── */
[data-range-calendar-day] {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: var(--_calendar-day-size);
block-size: var(--_calendar-day-size);
border: var(--calendar-day-border-width) solid var(--calendar-day-border);
border-radius: var(--calendar-day-radius);
background: var(--calendar-day-bg);
color: var(--calendar-day-color);
font: inherit;
font-weight: var(--calendar-day-font-weight);
cursor: pointer;
user-select: none;
transition:
background var(--calendar-transition-duration) var(--calendar-transition-ease),
border-color var(--calendar-transition-duration) var(--calendar-transition-ease),
color var(--calendar-transition-duration) var(--calendar-transition-ease),
box-shadow var(--calendar-transition-duration) var(--calendar-transition-ease),
opacity var(--calendar-transition-duration) var(--calendar-transition-ease);
}
[data-range-calendar-day]:hover {
border-color: var(--calendar-day-border-hover);
background: var(--calendar-day-bg-hover);
color: var(--calendar-day-color-hover);
}
[data-range-calendar-day][data-selected] {
border-color: var(--_calendar-range-end-border);
background: var(--_calendar-range-end-solid);
color: var(--calendar-day-selected-color);
}
[data-range-calendar-day][data-range-start] {
/* Start endpoint: solid background + left-edge accent stripe so it reads
as the *opening* of the range, never blending with the end. */
border-color: var(--_calendar-range-start-border);
background: var(--_calendar-range-start-solid);
color: var(--_calendar-range-start-text);
box-shadow: inset 2px 0 0 0 var(--_calendar-range-start-border);
font-weight: var(--font-weight-semibold);
}
[data-range-calendar-day][data-range-end] {
/* End endpoint: solid background + right-edge accent stripe. */
border-color: var(--_calendar-range-end-border);
background: var(--_calendar-range-end-solid);
color: var(--_calendar-range-end-text);
box-shadow: inset -2px 0 0 0 var(--_calendar-range-end-border);
font-weight: var(--font-weight-semibold);
}
[data-range-calendar-day][data-range-start][data-range-end] {
border-color: var(--_calendar-range-end-border);
background: linear-gradient(
135deg,
var(--_calendar-range-start-solid) 0 50%,
var(--_calendar-range-end-solid) 50% 100%
);
color: var(--calendar-day-selected-color);
}
[data-range-calendar-day][data-selected]:hover {
border-color: var(--_calendar-range-end-solid-hover);
background: var(--_calendar-range-end-solid-hover);
}
[data-range-calendar-day][data-range-start]:hover {
border-color: var(--_calendar-range-start-solid-hover);
background: var(--_calendar-range-start-solid-hover);
}
[data-range-calendar-day][data-range-end]:hover {
border-color: var(--_calendar-range-end-solid-hover);
background: var(--_calendar-range-end-solid-hover);
}
[data-range-calendar-day][data-range-start][data-range-end]:hover {
background: linear-gradient(
135deg,
var(--_calendar-range-start-solid-hover) 0 50%,
var(--_calendar-range-end-solid-hover) 50% 100%
);
}
[data-range-calendar-day][data-in-range]:not([data-selected]) {
border-color: transparent;
background: transparent;
color: var(--_calendar-accent-text);
}
[data-range-calendar-day][data-preview-start],
[data-range-calendar-day][data-preview-end],
[data-range-calendar-day][data-in-preview-range]:not([data-selected]) {
border-color: var(--_calendar-accent-border);
background: transparent;
color: var(--_calendar-accent-text);
}
[data-range-calendar-day][data-focused]:not([data-selected]) {
border-color: var(--_calendar-accent-border);
background: var(--_calendar-accent-track);
color: var(--_calendar-accent-text);
}
[data-range-calendar-day][data-today]:not([data-selected]) {
border-color: var(--calendar-day-today-border);
color: var(--calendar-day-today-color);
font-weight: var(--calendar-day-today-font-weight);
}
[data-range-calendar-day][data-outside-month] {
color: var(--calendar-day-outside-color);
opacity: var(--calendar-day-outside-opacity);
}
[data-range-calendar-day][data-weekend]:not([data-selected]) {
color: var(--calendar-day-weekend-color);
}
[data-range-calendar-day][data-holiday]:not([data-selected]) {
box-shadow: var(--calendar-day-holiday-shadow);
}
[data-range-calendar-day][data-unavailable] {
text-decoration-line: line-through;
text-decoration-color: var(--calendar-day-unavailable-decoration);
}
[data-range-calendar-day][data-disabled],
[data-range-calendar-day][data-unavailable] {
cursor: not-allowed;
opacity: var(--calendar-disabled-opacity);
}
[data-range-calendar][data-invalid] {
border-color: var(--calendar-invalid-border);
}
[data-range-calendar][data-disabled] {
opacity: var(--calendar-disabled-opacity);
}
[data-range-calendar][data-event],
[data-range-calendar-day][data-event],
[data-range-calendar-prev-button][data-event],
[data-range-calendar-next-button][data-event],
[data-range-calendar-month-select][data-event],
[data-range-calendar-year-select][data-event] {
box-shadow: var(--calendar-event-shadow);
}
@media (prefers-reduced-motion: reduce) {
[data-range-calendar-prev-button],
[data-range-calendar-next-button],
[data-range-calendar-month-select],
[data-range-calendar-year-select],
[data-range-calendar-day] {
transition: none;
}
}

@ -0,0 +1,36 @@
<script lang="ts">
/**
* Eidos `<RangeCalendar>` — root wrapper around Soma's RangeCalendar
* provider. Adds size / variant / color cascades; otherwise pass-through.
* Range-state machine (start → preview → end) is owned by soma.
*/
import { ActiveEidos } from '$uix/eidos';
import * as RangeCalendar from '$soma/components/range-calendar';
import type { RangeCalendarProps, RangeCalendarProviderSnippetProps } from './types';
let {
size = 'md',
variant = 'surface',
color = 'primary',
value = $bindable(),
placeholder = $bindable(),
children: bodyContent,
...rest
}: RangeCalendarProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
</script>
<RangeCalendar.Provider
{...rest}
bind:value
bind:placeholder
data-size={resolvedSize}
data-variant={variant}
data-color={color}
>
{#snippet children(snippetProps: RangeCalendarProviderSnippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}
</RangeCalendar.Provider>

@ -0,0 +1,83 @@
import type {
RangeCalendarCellProps as SomaRangeCalendarCellProps,
RangeCalendarCellSnippetProps,
RangeCalendarDayProps as SomaRangeCalendarDayProps,
RangeCalendarDaySnippetProps,
RangeCalendarGridBodyProps as SomaRangeCalendarGridBodyProps,
RangeCalendarGridHeadProps as SomaRangeCalendarGridHeadProps,
RangeCalendarGridProps as SomaRangeCalendarGridProps,
RangeCalendarGridRowProps as SomaRangeCalendarGridRowProps,
RangeCalendarGridRowSnippetProps,
RangeCalendarHeadCellProps as SomaRangeCalendarHeadCellProps,
RangeCalendarHeaderProps as SomaRangeCalendarHeaderProps,
RangeCalendarHeadingProps as SomaRangeCalendarHeadingProps,
RangeCalendarHeadingSnippetProps,
RangeCalendarMonthSelectProps as SomaRangeCalendarMonthSelectProps,
RangeCalendarMonthSelectSnippetProps,
RangeCalendarNextButtonProps as SomaRangeCalendarNextButtonProps,
RangeCalendarPrevButtonProps as SomaRangeCalendarPrevButtonProps,
RangeCalendarProps as SomaRangeCalendarProps,
RangeCalendarProviderSnippetProps,
RangeCalendarYearSelectProps as SomaRangeCalendarYearSelectProps,
RangeCalendarYearSelectSnippetProps
} from '$soma/components/range-calendar';
import type { DateValue } from '$libs/days';
import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types';
/**
* RangeCalendar visual size — four-step subset of the canonical scale,
* mirrors `CalendarSize` so RangeCalendar reads identically to Calendar
* for the visual consumer.
*/
export type RangeCalendarSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg'>;
/**
* Visual treatment of the range-calendar surface. Mirrors `CalendarVariant`.
*
* - `surface` (default) — bordered card chrome with default background.
* - `outline` — bordered surface with the outline-variant background token.
* - `ghost` — no chrome, transparent surface (used when consumed inside
* a picker popover that already paints its own chrome).
*/
export type RangeCalendarVariant = ControlVariant;
/**
* Color cascade. End endpoint follows this accent; start endpoint defaults
* to the `affirm` family (range opens), except when the end accent itself
* is `affirm` or `fulfill` — those swap the start to `secondary` so the
* two endpoints stay visually distinct (see range-calendar.css).
*/
export type RangeCalendarColor = ColorRole;
export type RangeCalendarProps = SomaRangeCalendarProps & {
/** Visual size for day cells, nav buttons and select controls. @default 'md' */
size?: ResponsiveProp<RangeCalendarSize>;
/** Visual treatment of the calendar surface. @default 'surface' */
variant?: RangeCalendarVariant;
/** Intent palette used for end endpoint, in-range fill and focus affordances. @default 'primary' */
color?: RangeCalendarColor;
};
export type RangeCalendarHeaderProps = SomaRangeCalendarHeaderProps;
export type RangeCalendarHeadingProps = SomaRangeCalendarHeadingProps;
export type RangeCalendarPrevButtonProps = SomaRangeCalendarPrevButtonProps;
export type RangeCalendarNextButtonProps = SomaRangeCalendarNextButtonProps;
export type RangeCalendarMonthSelectProps = SomaRangeCalendarMonthSelectProps;
export type RangeCalendarYearSelectProps = SomaRangeCalendarYearSelectProps;
export type RangeCalendarGridProps = SomaRangeCalendarGridProps;
export type RangeCalendarGridHeadProps = SomaRangeCalendarGridHeadProps;
export type RangeCalendarGridBodyProps = SomaRangeCalendarGridBodyProps;
export type RangeCalendarGridRowProps = SomaRangeCalendarGridRowProps & { week?: DateValue[] };
export type RangeCalendarHeadCellProps = SomaRangeCalendarHeadCellProps;
export type RangeCalendarCellProps = SomaRangeCalendarCellProps;
export type RangeCalendarDayProps = SomaRangeCalendarDayProps;
export type {
RangeCalendarProviderSnippetProps,
RangeCalendarCellSnippetProps,
RangeCalendarDaySnippetProps,
RangeCalendarHeadingSnippetProps,
RangeCalendarMonthSelectSnippetProps,
RangeCalendarYearSelectSnippetProps,
RangeCalendarGridRowSnippetProps
};

@ -130,6 +130,7 @@
@import './components/select/select.css';
@import './components/combobox/combobox.css';
@import './components/calendar/calendar.css';
@import './components/range-calendar/range-calendar.css';
@import './components/month-grid/month-grid.css';
@import './components/year-grid/year-grid.css';
@import './components/date-field/date-field.css';

@ -4,7 +4,7 @@ import { v } from '../types';
export const rangeCalendarMorfo = {
name: 'RangeCalendar',
kebab: 'range-calendar',
scope: ['soma', 'sema'],
scope: ['soma', 'sema', 'eidos'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/',
texts: {
label: '#?components.range-calendar.label|Range calendar',

@ -151,6 +151,7 @@
{ slug: '/uix/components/listbox', label: 'Listbox' },
{ slug: '/uix/components/grid-list', label: 'Grid list' },
{ slug: '/uix/components/calendar', label: 'Calendar' },
{ slug: '/uix/components/range-calendar', label: 'Range calendar' },
{ slug: '/uix/components/month-grid', label: 'Month grid' },
{ slug: '/uix/components/year-grid', label: 'Year grid' },
{ slug: '/uix/components/date-field', label: 'Date field' },

@ -0,0 +1,681 @@
<script lang="ts">
import {
RangeCalendar,
type RangeCalendarColor,
type RangeCalendarGridRowSnippetProps,
type RangeCalendarProviderSnippetProps,
type RangeCalendarSize,
type RangeCalendarVariant
} from '$uix/eidos/components/range-calendar';
import { compileMorfo } from '$uix/morfo';
import { rangeCalendarMorfo } from '@/uix/morfo/components/range-calendar';
import { getActiveUix } from '$active-uix';
import { CalendarDate, type DateRange, type DateValue, type WeekStartsOn } from '$libs/days';
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];
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);
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
let value = $state<DateRange>({ start, end });
let placeholder = $state<DateValue>(initialMonth);
let minDays = $state<number | undefined>(undefined);
let maxDays = $state<number | undefined>(14);
let allowSingleDay = $state(true);
let preventDeselect = $state(false);
let pagedNavigation = $state(false);
let fixedWeeks = $state(true);
let numberOfMonths = $state(1);
let showWeekNumbers = $state(false);
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');
let variant = $state<RangeCalendarVariant>('surface');
let color = $state<RangeCalendarColor>('primary');
const compiled = compileMorfo(rangeCalendarMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
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 rangeLabel = $derived.by(() => {
if (!value.start && !value.end) return 'empty';
if (value.start && !value.end) return `${value.start.toString()} → …`;
if (value.start && value.end) return `${value.start.toString()} → ${value.end.toString()}`;
return '—';
});
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
function emitSelectStart(signal: EmitSignal) {
void uix.events?.emit(signal);
}
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { RangeCalendar } from '$uix/eidos/components/range-calendar';",
" import type { DateRange } from '$libs/days';",
' let value = $state<DateRange>({ start, end });',
'</' + 'script>',
'',
'<RangeCalendar',
' bind:value',
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
numberOfMonths !== 1 && ` numberOfMonths={${numberOfMonths}}`,
minDays !== undefined && ` minDays={${minDays}}`,
maxDays !== undefined && ` maxDays={${maxDays}}`,
!allowSingleDay && ' allowSingleDay={false}',
preventDeselect && ' preventDeselect',
pagedNavigation && ' pagedNavigation',
!fixedWeeks && ' fixedWeeks={false}',
showWeekNumbers && ' showWeekNumbers',
disableDaysOutsideMonth && ' disableDaysOutsideMonth',
disabled && ' disabled',
readonly && ' readonly',
'>',
' <RangeCalendar.Header>',
' <RangeCalendar.PrevButton />',
' <RangeCalendar.Heading />',
' <RangeCalendar.NextButton />',
' </RangeCalendar.Header>',
' <RangeCalendar.Grid>',
' <RangeCalendar.GridHead>',
' <RangeCalendar.GridRow>',
' {#snippet children({ weekdays })}',
' {#each weekdays as wd}<RangeCalendar.HeadCell>{wd}</RangeCalendar.HeadCell>{/each}',
' {/snippet}',
' </RangeCalendar.GridRow>',
' </RangeCalendar.GridHead>',
' <RangeCalendar.GridBody>',
' <!-- rows iterate via the months snippet prop -->',
' </RangeCalendar.GridBody>',
' </RangeCalendar.Grid>',
'</RangeCalendar>'
]
.filter(Boolean)
.join('\n')
);
function setNumberOfMonths(n: number) {
numberOfMonths = n;
}
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Forms · RangeCalendar</div>
<h1 data-uix-page-title>Range calendar</h1>
<p data-uix-page-lede>
Calendar primitive for picking a date range. Soma owns the state
machine (start → preview → end), the keyboard nav, the min/max
constraints (<code>minValue</code>, <code>maxValue</code>,
<code>minDays</code>, <code>maxDays</code>) and the hover-preview
signaling. Eidos paints the cells with distinct
<code>data-range-start</code> / <code>data-range-end</code> /
<code>data-in-range</code> / <code>data-in-preview-range</code>
treatments — the shared partial at
<code>range-calendar.css</code> is consumed by both this standalone
component and by <code>DateRangePicker</code>'s popover content.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>{events.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>sizes</span>4
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>apg</span>
<a href={rangeCalendarMorfo.apg} target="_blank" rel="noreferrer">grid ↗</a>
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>range</span> {rangeLabel}
</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef} style="padding: var(--uix-space-3); display: flex; justify-content: center;">
<RangeCalendar
bind:value
placeholder={placeholder as never}
{minDays}
{maxDays}
{allowSingleDay}
{preventDeselect}
{pagedNavigation}
{fixedWeeks}
{numberOfMonths}
{showWeekNumbers}
{disableDaysOutsideMonth}
{disabled}
{readonly}
minValue={selectionMinValue}
maxValue={selectionMaxValue}
weekStartsOn={resolvedWeekStartsOn}
{locale}
{size}
{variant}
{color}
>
{#snippet children({ months, weekdays }: RangeCalendarProviderSnippetProps)}
<RangeCalendar.Header>
<RangeCalendar.PrevButton aria-label="Previous month">‹</RangeCalendar.PrevButton>
<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>
{/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}
{/snippet}
</RangeCalendar>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>click a day to start; hover to preview; click second to commit</span>
{:else}
{#each trace.slice(0, 3) as entry (entry.at)}
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent ? ' · ' + entry.intent : ''}</span>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>range</span> {rangeLabel}
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
API <span data-uix-tab-count>14</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
Soma owns the range state machine, the keyboard navigation, the
min/max constraints and the hover-preview signaling. Eidos adds
variant/size/color cascades.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> range constraints
</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>
</label>
<label data-uix-control>
<span data-uix-control-label>maxValue</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}
</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;" />
</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;" />
</label>
<label data-uix-control>
<span data-uix-control-label>allowSingleDay</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={allowSingleDay} />
<span data-uix-switch-label>{allowSingleDay ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>preventDeselect</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={preventDeselect} />
<span data-uix-switch-label>{preventDeselect ? 'on' : 'off'}</span>
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> grid layout
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>numberOfMonths</span>
<span data-uix-chips role="radiogroup">
{#each [1, 2, 3] as n (n)}
<button data-uix-chip data-active={numberOfMonths === n} onclick={() => setNumberOfMonths(n)}>{n}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>pagedNavigation</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={pagedNavigation} />
<span data-uix-switch-label>{pagedNavigation ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>fixedWeeks</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={fixedWeeks} />
<span data-uix-switch-label>{fixedWeeks ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>showWeekNumbers</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showWeekNumbers} />
<span data-uix-switch-label>{showWeekNumbers ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>disableDaysOutsideMonth</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={disableDaysOutsideMonth} />
<span data-uix-switch-label>{disableDaysOutsideMonth ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>weekStartsOn</span>
<span data-uix-chips role="radiogroup">
{#each ['locale', '0', '1'] as opt (opt)}
<button data-uix-chip data-active={weekStartsOn === opt} onclick={() => (weekStartsOn = opt as WeekStartControl)}>{opt}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>disabled</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={disabled} />
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>readonly</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={readonly} />
<span data-uix-switch-label>{readonly ? 'on' : 'off'}</span>
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> visual props
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each ['surface', 'outline', 'ghost'] as v (v)}
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v as RangeCalendarVariant)}>{v}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each ['xs', 'sm', 'md', 'lg'] as s (s)}
<button data-uix-chip data-active={size === s} onclick={() => (size = s as RangeCalendarSize)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each ['primary', 'secondary', 'neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as c (c)}
<button data-uix-chip data-active={color === c} onclick={() => (color = c as RangeCalendarColor)}>{c}</button>
{/each}
</span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>composition · with bind:value</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>14 parts: Provider, Header, Heading, PrevButton, NextButton, MonthSelect, YearSelect, Grid, GridHead, GridBody, GridRow, HeadCell, Cell, Day.</p>
<div data-uix-subsection-head>Provider (RangeCalendar)</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">value <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">DateRange</td><td class="default">{`{}`}</td><td>Committed range.</td></tr>
<tr><td class="name">placeholder <span data-uix-tag data-kind="bindable">bindable</span></td><td class="type">DateValue</td><td class="default empty">today</td><td>Month currently visible.</td></tr>
<tr><td class="name">onValueChange</td><td class="type">(r: DateRange) =&gt; void</td><td class="default empty">—</td><td>Fires when end is committed.</td></tr>
<tr><td class="name">minValue · maxValue</td><td class="type">DateValue</td><td class="default empty">—</td><td>Allowed bounds.</td></tr>
<tr><td class="name">minDays · maxDays</td><td class="type">number</td><td class="default empty">—</td><td>Inclusive length constraints (days).</td></tr>
<tr><td class="name">allowSingleDay</td><td class="type">boolean</td><td class="default">true</td><td>Permit start === end.</td></tr>
<tr><td class="name">preventDeselect</td><td class="type">boolean</td><td class="default">false</td><td>Clicking the selected endpoint keeps the range.</td></tr>
<tr><td class="name">numberOfMonths</td><td class="type">number</td><td class="default">1</td><td>Visible months.</td></tr>
<tr><td class="name">pagedNavigation</td><td class="type">boolean</td><td class="default">false</td><td>Prev/Next advance by numberOfMonths.</td></tr>
<tr><td class="name">fixedWeeks</td><td class="type">boolean</td><td class="default">false</td><td>Always render 6 rows.</td></tr>
<tr><td class="name">disableDaysOutsideMonth</td><td class="type">boolean</td><td class="default">false</td><td>Skip non-current-month cells from selection.</td></tr>
<tr><td class="name">disabled · readonly</td><td class="type">boolean</td><td class="default">false</td><td>OR-merged with Field.Provider.</td></tr>
<tr><td class="name">isDateDisabled · isDateUnavailable · isDateHoliday</td><td class="type">DateMatcher</td><td class="default empty">—</td><td>Per-cell decorators.</td></tr>
<tr><td class="name">weekStartsOn · weekdayFormat · locale · dir</td><td class="type">—</td><td class="default empty">locale</td><td>Localization.</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'xs' | 'sm' | 'md' | 'lg'</td><td class="default">'md'</td><td>Day cells + nav buttons + selects.</td></tr>
<tr><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">'surface' | 'outline' | 'ghost'</td><td class="default">'surface'</td><td>Calendar chrome.</td></tr>
<tr><td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td><td class="type">ColorRole</td><td class="default">'primary'</td><td>End-endpoint + in-range fill + focus accent.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Sub-parts</div>
<p data-uix-section-desc style="margin-top: 0.25rem;">
The sub-parts (<code>Header</code>, <code>Heading</code>,
<code>PrevButton</code>, <code>NextButton</code>,
<code>MonthSelect</code>, <code>YearSelect</code>,
<code>Grid</code>, <code>GridHead</code>, <code>GridBody</code>,
<code>GridRow</code>, <code>HeadCell</code>, <code>Cell</code>,
<code>Day</code>) are pass-throughs to soma — they accept the
headless props from the soma `RangeCalendarXxxProps` types
unchanged. See the morfo table below for the per-part data
contract.
</p>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span>
· declarative contract
</h2>
<p data-uix-section-desc>Source: <code>src/uix/morfo/components/range-calendar.ts</code>.</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td class="type">"{rangeCalendarMorfo.name}"</td></tr>
<tr><td class="name">kebab</td><td class="type">"{rangeCalendarMorfo.kebab}"</td></tr>
<tr><td class="name">apg</td><td><a href={rangeCalendarMorfo.apg} target="_blank" rel="noreferrer">{rangeCalendarMorfo.apg}</a></td></tr>
<tr><td class="name">scope</td><td class="default">{rangeCalendarMorfo.scope.join(', ')}</td></tr>
<tr><td class="name">parts.length</td><td class="default">{rangeCalendarMorfo.parts.length}</td></tr>
<tr><td class="name">events.length</td><td class="default">{events.length}</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>States</th><th>Optional</th></tr></thead>
<tbody>
{#each partsList as part (part.kebab)}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.archetype ?? '—'}</td>
<td class="default">{part.states.length ? part.states.join(' | ') : '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div data-uix-subsection-head>Events declaration</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>intent</th><th>target</th></tr></thead>
<tbody>
{#each events as action (action.name)}
{@const sem = action.semantic}
{@const intentDecl = 'intent' in sem ? sem.intent : undefined}
{@const intentStr = typeof intentDecl === 'string' ? intentDecl : intentDecl ? `fromProp:${(intentDecl as { fromProp?: string }).fromProp ?? '?'}` : '—'}
<tr>
<td class="name">{action.name}</td>
<td class="type">{sem.family}</td>
<td>{sem.verb ?? '—'}</td>
<td>{sem.sequence ?? 'pre'}</td>
<td class="default">{intentStr}</td>
<td>{action.target}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section id="sema">
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span>
· events
</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Play</th></tr></thead>
<tbody>
{#each events as action (action.name)}
{@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined}
{@const effectiveIntent = typeof intentDecl === 'string' ? intentDecl : undefined}
<tr>
<td class="name">{action.name}</td>
<td class="type">{action.semantic.family}</td>
<td>{action.semantic.verb ?? '—'}</td>
<td>{action.semantic.sequence ?? 'pre'}</td>
<td class="default">{effectiveIntent ?? '—'}</td>
<td>
<button
data-uix-play
onclick={() => {
const target = (stageRef?.querySelector('[data-range-calendar]') ?? stageRef) as HTMLElement | null;
if (!target) return;
emitSelectStart({
name: action.name,
family: action.semantic.family,
target,
...(effectiveIntent ? { intent: effectiveIntent } : {})
});
}}
>▶ play</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Selectors at <code>src/uix/eidos/components/range-calendar/range-calendar.css</code>.
This file is consumed BOTH by the standalone eidos
<code>&lt;RangeCalendar&gt;</code> and by
<code>&lt;DateRangePicker&gt;</code>'s popover content — the
same <code>[data-range-calendar-*]</code> attrs land in both
environments. The picker recipe only adds scoping overrides
(no chrome, max-content sizing, transparent background).
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Source</th><th>What it paints</th></tr></thead>
<tbody>
<tr><td class="name">[data-range-calendar]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Root chrome — border + bg + padding + token scaffolding.</td></tr>
<tr><td class="name">[data-range-calendar][data-size]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Day-cell + control-button size scale (xs/sm/md/lg).</td></tr>
<tr><td class="name">[data-range-calendar][data-variant]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Surface / outline / ghost chrome.</td></tr>
<tr><td class="name">[data-range-calendar][data-color]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>End-endpoint + in-range + focus accent tokens.</td></tr>
<tr><td class="name">[data-range-calendar-day][data-range-start]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Start endpoint: affirm-family solid + left-edge stripe + semibold.</td></tr>
<tr><td class="name">[data-range-calendar-day][data-range-end]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>End endpoint: accent-family solid + right-edge stripe.</td></tr>
<tr><td class="name">[data-range-calendar-day][data-range-start][data-range-end]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Single-day range: diagonal-split gradient (start ↘ end).</td></tr>
<tr><td class="name">[data-range-calendar-cell][data-in-range]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>In-range cells get accent-track background fill on the cell.</td></tr>
<tr><td class="name">[data-range-calendar-day][data-in-preview-range]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Hover-preview state while user is choosing end (transparent + accent border).</td></tr>
<tr><td class="name">[data-range-calendar-day][data-today]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Today marker (border ring + semibold).</td></tr>
<tr><td class="name">[data-range-calendar-day][data-outside-month]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>Outside-month dimming.</td></tr>
<tr><td class="name">[data-range-calendar-day][data-disabled] · [data-unavailable]</td><td><span data-uix-tag data-kind="eidos">eidos</span></td><td>not-allowed cursor + reduced opacity (+ line-through for unavailable).</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<p data-uix-section-desc>
Follows the APG <a href={rangeCalendarMorfo.apg} target="_blank" rel="noreferrer">grid pattern</a>
with localized labels for range-start / range-end / in-range
announced via <code>aria-label</code> additions on the relevant
day cells.
</p>
<div data-uix-subsection-head>Keyboard</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
<tr><td><span data-uix-kbd>ArrowLeft</span> / <span data-uix-kbd>ArrowRight</span></td><td>Move focus ±1 day.</td></tr>
<tr><td><span data-uix-kbd>ArrowUp</span> / <span data-uix-kbd>ArrowDown</span></td><td>Move focus ±1 week.</td></tr>
<tr><td><span data-uix-kbd>PageUp</span> / <span data-uix-kbd>PageDown</span></td><td>Move focus ±1 month.</td></tr>
<tr><td><span data-uix-kbd>Shift+PageUp</span> / <span data-uix-kbd>Shift+PageDown</span></td><td>Move focus ±1 year.</td></tr>
<tr><td><span data-uix-kbd>Home</span> / <span data-uix-kbd>End</span></td><td>First / last day of the visible week.</td></tr>
<tr><td><span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span></td><td>Commit start (first press) or end (second press).</td></tr>
<tr><td><span data-uix-kbd>Escape</span></td><td>Cancel the in-progress range; revert to last committed.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>ARIA contract</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Attribute</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">provider</td><td>role</td><td class="type">"application"</td></tr>
<tr><td class="name">provider</td><td>aria-label</td><td class="type">localized "Range calendar"</td></tr>
<tr><td class="name">grid</td><td>role</td><td class="type">"grid"</td></tr>
<tr><td class="name">cell</td><td>role · aria-selected</td><td class="type">"gridcell" / "true|false"</td></tr>
<tr><td class="name">day</td><td>aria-label</td><td class="type">"date — range start | range end | in range"</td></tr>
<tr><td class="name">prev-button · next-button</td><td>aria-label</td><td class="type">"Previous month" / "Next month"</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.