Add Eidos calendar component

active-uix
dev 5 months ago
parent fb5dd0919c
commit d6f550554d

@ -11,7 +11,7 @@ capa. Esta es la guia que debe leerse antes de tocar cualquier componente.
### Flujo obligatorio por componente
1. Leer Air en la rama anterior:
`git show gita/glm-5:src/uix/air/components/{name}/...` cuando exista.
`git show morfo-runtime:src/uix/air/components/{name}/...` cuando exista.
Air es la primera baseline visual local.
2. Leer Morfo/Soma actuales del componente:
- `src/uix/morfo/components/{name}.ts`
@ -343,7 +343,7 @@ capa. Esta es la guia que debe leerse antes de tocar cualquier componente.
hotspots.
- Regla nueva para continuar Eidos componente por componente:
- La primera referencia es Air en la rama anterior
(`glm-5:src/uix/air/components/{name}`) cuando exista.
(`morfo-runtime:src/uix/air/components/{name}`) cuando exista.
- Despues se compara con Soma/Morfo actuales y con referentes externos
relevantes (Radix/Radix Themes, Ark UI, Bits UI, shadcn-svelte y React Aria
si aplica).
@ -359,7 +359,7 @@ capa. Esta es la guia que debe leerse antes de tocar cualquier componente.
`height`, `minHeight`, `maxHeight` en `Dialog.Content`, serializados como
variables privadas del recipe. Soma sigue siendo dueno de comportamiento.
- Segundo componente auditado con el nuevo protocolo:
- `Drawer`: comparado contra Air (`glm-5`), Soma/Morfo actuales, Vaul,
- `Drawer`: comparado contra Air (`morfo-runtime`), Soma/Morfo actuales, Vaul,
shadcn-svelte y Ark UI Dialog como referencia de dialog/layer.
- Decision: no se crea `Drawer.Positioner`; la geometria edge-anchored vive
en `data-side` + `data-variant` y en la receta.
@ -370,7 +370,7 @@ capa. Esta es la guia que debe leerse antes de tocar cualquier componente.
variables privadas del recipe. El size canonico `sm/md/lg/full` sigue como
preset principal.
- Tercer componente auditado con el nuevo protocolo:
- `Popover`: Air no tenia equivalente en `glm-5`, por tanto la referencia
- `Popover`: Air no tenia equivalente en `morfo-runtime`, por tanto la referencia
local pasa a ser Soma/Morfo y la comparativa externa Radix, Ark UI, Bits UI
y shadcn-svelte.
- Decision: no se crea `Popover.Positioner`; la posicion pertenece a Soma
@ -383,7 +383,7 @@ capa. Esta es la guia que debe leerse antes de tocar cualquier componente.
variables privadas. La receta usa transform-origin y available-height de
Soma Floating.
- Cuarto componente auditado con el nuevo protocolo:
- `Tooltip`: Air no tenia equivalente en `glm-5`; referencia local
- `Tooltip`: Air no tenia equivalente en `morfo-runtime`; referencia local
Soma/Morfo, comparativa externa Radix, Ark UI, Bits UI y shadcn-svelte.
- Decision: no se crea `Tooltip.Positioner` ni `Tooltip.ArrowTip`; Soma
Floating y Soma Arrow son los duenos de esa geometria.
@ -1747,7 +1747,7 @@ a su `portalTo`. No usar nombres como `somaPortalTo` en `ActiveUix`.
Eidos fuera de tests/guardias documentales.
- Siguiente foco:
1. Continuar migracion componente por componente desde Soma a Eidos.
2. Seguir el protocolo obligatorio: Air (`gita/glm-5`) -> Morfo/Soma ->
2. Seguir el protocolo obligatorio: Air (`morfo-runtime`) -> Morfo/Soma ->
Morfo/Sema -> referencias externas -> README -> wrapper/recipe/demo.
3. La tanda en curso venia por componentes relacionados con forms; el primer
candidato natural no presente aun en Eidos es `form`.

@ -13,6 +13,7 @@ const VISUAL_ATTRS = [
['avatar/avatar-image.svelte', ['data-status']],
['avatar/avatar-fallback.svelte', ['data-status']],
['breadcrumb/breadcrumb.svelte', ['data-size']],
['calendar/calendar.svelte', ['data-size', 'data-variant', 'data-color']],
['checkbox/checkbox.svelte', ['data-size', 'data-variant', 'data-color']],
['checkbox/checkbox-group.svelte', ['data-columns']],
['dialog/dialog-close.svelte', ['data-position', 'data-icon-only']],

@ -400,8 +400,8 @@ si un alias publico queda sin consumidor real, el test
Ningun componente Eidos se declara cerrado solo por envolver Soma. Antes de
implementar o revisar un componente:
1. Leer Air en la rama anterior (`glm-5:src/uix/air/components/{name}`) cuando
exista. Air es la primera baseline visual.
1. Leer Air en la rama anterior (`morfo-runtime:src/uix/air/components/{name}`)
cuando exista. Air es la primera baseline visual.
2. Leer Soma/Morfo actuales para separar comportamiento, ARIA, estado,
traducciones y data-attrs de la superficie visual Eidos.
3. Auditar Morfo/Sema. El morfo no se considera correcto solo porque compile:
@ -455,7 +455,7 @@ su subdirectorio.
---
## Estado actual de la migración (2026-05-17)
## Estado actual de la migración (2026-05-20)
La tanda 2026-05-17 reanuda la migración Soma -> Eidos por orden explícita.
Los wrappers nuevos siguen el mismo criterio: envolver partes públicas de Soma,
@ -476,6 +476,7 @@ estado, ARIA, traducciones y escritura headless en Soma/Morfo.
| accordion | ✅ multi-part | Item, Header, Trigger, Content |
| avatar | ✅ multi-part | Image, Fallback (eidos-native) |
| breadcrumb | ✅ multi-part | List, Item, Link, Separator, Ellipsis |
| calendar | ✅ multi-part | Header, Heading, Prev/Next, Month/YearSelect, Grid, Cell, Day |
| icon | ✅ single-part | + 1697 lucide glyphs |
| meter | ✅ multi-part | Indicator |
| number-field | ✅ multi-part | Input, IncrementTrigger, DecrementTrigger, Scrubber |
@ -500,5 +501,4 @@ estado, ARIA, traducciones y escritura headless en Soma/Morfo.
### Orden recomendado para continuar
1. Componentes grandes sólo con tabla previa de migración:
`calendar`, `date-field`, `date-picker`, `date-range-picker`,
`time-field`, `time-picker`.
`date-picker`, `date-range-picker`, `time-field`, `time-picker`.

@ -0,0 +1,96 @@
# Eidos Calendar
Visual wrapper for Soma Calendar. Calendar follows the compound Eidos surface:
`Calendar` is the public root and parts hang from it (`Calendar.Day`,
`Calendar.Grid`, etc.). There is no public `Provider` export.
## Baseline audit
| Source | Relevant coverage | Decision |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| Air `morfo-runtime:src/uix/air/components/calendar` | Full visual shell over Terra: root size, header, heading, prev/next, month/year selects, grid, cell/day states; direct Air sound on selection/navigation. | Keep the full part coverage and `size`. Do not copy direct sound; selection/navigation move to Morfo/Sema. |
| Soma current | Single/multiple value, placeholder navigation, min/max, disabled/unavailable/holiday matchers, fixed weeks, multi-month, week numbers, locale, RTL and live announcements. | Eidos wraps every public Soma part and adds only `size`, `variant`, `color`. |
| Morfo current | 15 declared parts including `WeekNumber` contract, grid APG, day/cell state data. | Add semantic events because Calendar is interactive. `WeekNumber` remains contract-only until Soma exposes a component. |
| Sema current | No calendar pack before this migration. | Add `calendarSema` for select/unselect and navigation. |
## Reference comparison
| Capability | Air | React Aria | Ark UI | Bits UI | shadcn-svelte | UIX decision |
| --------------------- | ---------------------------------------- | -------------------------------------------------------------------------- | --------------------------------- | ----------------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------- |
| Single date | yes | yes | yes | yes | yes | Soma owns. |
| Multiple dates | inherited Terra | RangeCalendar separate in React Aria; non-contiguous supported by API docs | `selectionMode="multiple"` | `type="multiple"` | via Bits Calendar | Soma supports `type="multiple"`. |
| Multiple months | yes | `visibleDuration` | `numOfMonths` | `numberOfMonths` | via Bits Calendar | Soma supports `numberOfMonths`; Eidos styles without assuming layout. |
| Fixed weeks | yes | display option | `fixedWeeks` | `fixedWeeks` | via Bits Calendar | Soma supports. |
| Month/year select | yes | documented as custom dropdowns | documented example | public `MonthSelect`/`YearSelect` | generated wrappers around Bits | Eidos exposes `MonthSelect` and `YearSelect`. |
| Min/max/unavailable | yes | `minValue`, `maxValue`, `isDateUnavailable` | `min`, `max`, `isDateUnavailable` | min/max/unavailable/disabled | via Bits Calendar | Soma supports selection and view bounds; invalid pairs surface as invalid + logger error. |
| Week numbers | no Air-specific part | not a first-class Calendar part | `showWeekNumbers` | documented separately through composition | not primary API | Soma exposes `showWeekNumbers` snippet data; visual support is included. |
| Holiday/weekend flags | Air styled some date states only | custom render state | custom machine state possible | custom render state possible | via custom code | Soma emits `data-holiday` and `data-weekend`; Eidos styles both. |
| Semantic feedback | Air called interaction sounds in wrapper | library-specific announcements | callbacks | callbacks | none in wrapper | Morfo/Sema owns `commit-select`, `commit-unselect`, `shift-navigate`. |
Reference docs checked: React Aria Calendar, Ark UI Date Picker, Bits UI
Calendar, shadcn-svelte Calendar, Radix Primitives components list and WAI-ARIA
APG Date Picker Dialog/Grid.
## Morfo / Sema
| Event | Family | Target | Runtime source | Sema default |
| ----------------- | ---------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------- |
| `commit-select` | `commit.select` | `day` | `CalendarProvider.select(date, target)` after value changes | soft form commit + tap |
| `commit-unselect` | `commit.remove` | `day` | same, when a date is removed/cleared | subtle form commit + tap |
| `shift-navigate` | `shift.navigate` | provider fallback, actual button/select/day target when known | `prevPage`, `nextPage`, `setMonth`, `setYear`, keyboard month boundary | subtle ascending navigation sound |
Calendar is not passive: a 0-event contract would hide committed selection and
visible-range navigation from the semantic layer. This migration closes that gap
before adding the Eidos recipe.
## Eidos API
```svelte
<script lang="ts">
import { Calendar } from '$uix/eidos/components/calendar';
import { CalendarDate, type DateValue } from '$libs/days';
const placeholder = new CalendarDate(2026, 5, 1);
let value = $state<DateValue | undefined>(placeholder);
</script>
<Calendar type="single" bind:value {placeholder} size="md" variant="surface" color="primary">
{#snippet children({ months, weekdays })}
<Calendar.Header>
<Calendar.PrevButton>Previous</Calendar.PrevButton>
<Calendar.Heading />
<Calendar.NextButton>Next</Calendar.NextButton>
</Calendar.Header>
{#each months as month (month.value.toString())}
<Calendar.Grid>
<Calendar.GridHead>
<Calendar.GridRow>
{#each weekdays as day}
<Calendar.HeadCell>{day}</Calendar.HeadCell>
{/each}
</Calendar.GridRow>
</Calendar.GridHead>
<Calendar.GridBody>
{#each month.weeks as week}
<Calendar.GridRow>
{#each week as date}
<Calendar.Cell {date} month={month.value}>
<Calendar.Day />
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
{/each}
{/snippet}
</Calendar>
```
## Gaps
| Gap | Owner | Decision |
| --------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| Public `Calendar.WeekNumber` part | Soma | Deferred. Morfo declares the part, but Soma currently exposes week numbers through `GridRow` snippet data instead of a DOM component. |
| Range selection | Soma | Deferred to `range-calendar` / date range components, not Eidos Calendar. |
| Popover date picker shell | Soma/Eidos date-picker | Deferred to `date-picker`; Calendar remains inline. |

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,362 @@
[data-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);
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-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-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-calendar][data-variant='outline'] {
background: var(--calendar-outline-bg);
box-shadow: var(--calendar-outline-shadow);
}
[data-calendar][data-variant='ghost'] {
padding: 0;
border-color: transparent;
background: transparent;
box-shadow: none;
}
[data-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-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-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);
}
[data-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);
}
[data-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-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-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);
}
[data-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-calendar-header-controls] {
display: flex;
align-items: center;
justify-content: center;
gap: var(--calendar-header-gap);
min-inline-size: 0;
}
[data-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-calendar-month-panel] {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--calendar-grid-gap);
min-inline-size: max-content;
}
[data-calendar-month-panel] > [data-calendar-heading] {
flex: 0 0 auto;
font-size: var(--calendar-heading-font-size, 0.95em);
text-align: center;
}
[data-calendar-prev-button],
[data-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-calendar-prev-button]:hover,
[data-calendar-next-button]:hover {
border-color: var(--calendar-control-border-hover);
background: var(--calendar-control-bg-hover);
color: var(--calendar-control-color-hover);
}
[data-calendar-prev-button]:focus-visible,
[data-calendar-next-button]:focus-visible,
[data-calendar-month-select]:focus-visible,
[data-calendar-year-select]:focus-visible,
[data-calendar-day]:focus-visible {
outline: none;
box-shadow: var(--calendar-focus-shadow);
}
[data-calendar-prev-button][data-disabled],
[data-calendar-next-button][data-disabled],
[data-calendar-month-select][data-disabled],
[data-calendar-year-select][data-disabled] {
cursor: not-allowed;
opacity: var(--calendar-disabled-opacity);
}
[data-calendar-month-select],
[data-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-calendar-month-select] {
inline-size: calc(var(--_calendar-day-size) * 2.35);
}
[data-calendar-year-select] {
inline-size: calc(var(--_calendar-day-size) * 2.15);
}
[data-calendar-month-select]:hover,
[data-calendar-year-select]:hover {
border-color: var(--calendar-control-border-hover);
background: var(--calendar-control-bg-hover);
color: var(--calendar-control-color-hover);
}
[data-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-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-calendar-week-number] {
color: var(--calendar-weekday-color);
font-variant-numeric: tabular-nums;
}
[data-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-calendar-cell] {
padding: 0;
text-align: center;
vertical-align: middle;
}
[data-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-calendar-day]:hover {
border-color: var(--calendar-day-border-hover);
background: var(--calendar-day-bg-hover);
color: var(--calendar-day-color-hover);
}
[data-calendar-day][data-selected] {
border-color: var(--_calendar-accent-solid);
background: var(--_calendar-accent-solid);
color: var(--calendar-day-selected-color);
}
[data-calendar-day][data-selected]:hover {
border-color: var(--_calendar-accent-solid-hover);
background: var(--_calendar-accent-solid-hover);
}
[data-calendar-day][data-focused]:not([data-selected]) {
border-color: var(--_calendar-accent-border);
background: var(--_calendar-accent-track);
color: var(--_calendar-accent-text);
}
[data-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-calendar-day][data-outside-month] {
color: var(--calendar-day-outside-color);
opacity: var(--calendar-day-outside-opacity);
}
[data-calendar-day][data-weekend]:not([data-selected]) {
color: var(--calendar-day-weekend-color);
}
[data-calendar-day][data-holiday]:not([data-selected]) {
box-shadow: var(--calendar-day-holiday-shadow);
}
[data-calendar-day][data-unavailable] {
text-decoration-line: line-through;
text-decoration-color: var(--calendar-day-unavailable-decoration);
}
[data-calendar-day][data-disabled],
[data-calendar-day][data-unavailable] {
cursor: not-allowed;
opacity: var(--calendar-disabled-opacity);
}
[data-calendar][data-invalid] {
border-color: var(--calendar-invalid-border);
}
[data-calendar][data-disabled] {
opacity: var(--calendar-disabled-opacity);
}
[data-calendar][data-event],
[data-calendar-day][data-event],
[data-calendar-prev-button][data-event],
[data-calendar-next-button][data-event],
[data-calendar-month-select][data-event],
[data-calendar-year-select][data-event] {
box-shadow: var(--calendar-event-shadow);
}
@media (prefers-reduced-motion: reduce) {
[data-calendar-prev-button],
[data-calendar-next-button],
[data-calendar-month-select],
[data-calendar-year-select],
[data-calendar-day] {
transition: none;
}
}

@ -0,0 +1,31 @@
<script lang="ts">
import { ActiveEidos } from '$uix/eidos';
import * as Calendar from '$soma/components/calendar';
import type { CalendarProps, CalendarProviderSnippetProps } from './types';
let {
size = 'md',
variant = 'surface',
color = 'primary',
value = $bindable(),
placeholder = $bindable(),
children: bodyContent,
...rest
}: CalendarProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
</script>
<Calendar.Provider
{...rest}
bind:value
bind:placeholder
data-size={resolvedSize}
data-variant={variant}
data-color={color}
>
{#snippet children(snippetProps: CalendarProviderSnippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}
</Calendar.Provider>

@ -0,0 +1,76 @@
import CalendarComponent from './calendar.svelte'
import Header from './calendar-header.svelte'
import Heading from './calendar-heading.svelte'
import PrevButton from './calendar-prev-button.svelte'
import NextButton from './calendar-next-button.svelte'
import MonthSelect from './calendar-month-select.svelte'
import YearSelect from './calendar-year-select.svelte'
import Grid from './calendar-grid.svelte'
import GridHead from './calendar-grid-head.svelte'
import GridBody from './calendar-grid-body.svelte'
import GridRow from './calendar-grid-row.svelte'
import HeadCell from './calendar-head-cell.svelte'
import Cell from './calendar-cell.svelte'
import Day from './calendar-day.svelte'
type CalendarNamespace = typeof CalendarComponent & {
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 Calendar = CalendarComponent as CalendarNamespace
Calendar.Header = Header
Calendar.Heading = Heading
Calendar.PrevButton = PrevButton
Calendar.NextButton = NextButton
Calendar.MonthSelect = MonthSelect
Calendar.YearSelect = YearSelect
Calendar.Grid = Grid
Calendar.GridHead = GridHead
Calendar.GridBody = GridBody
Calendar.GridRow = GridRow
Calendar.HeadCell = HeadCell
Calendar.Cell = Cell
Calendar.Day = Day
export { Calendar }
export default Calendar
export type {
CalendarProps,
CalendarHeaderProps as HeaderProps,
CalendarHeadingProps as HeadingProps,
CalendarPrevButtonProps as PrevButtonProps,
CalendarNextButtonProps as NextButtonProps,
CalendarMonthSelectProps as MonthSelectProps,
CalendarYearSelectProps as YearSelectProps,
CalendarGridProps as GridProps,
CalendarGridHeadProps as GridHeadProps,
CalendarGridBodyProps as GridBodyProps,
CalendarGridRowProps as GridRowProps,
CalendarHeadCellProps as HeadCellProps,
CalendarCellProps as CellProps,
CalendarDayProps as DayProps,
CalendarProviderSnippetProps,
CalendarCellSnippetProps,
CalendarDaySnippetProps,
CalendarHeadingSnippetProps,
CalendarMonthSelectSnippetProps,
CalendarYearSelectSnippetProps,
CalendarGridRowSnippetProps,
CalendarSize,
CalendarVariant,
CalendarColor
} from './types'

@ -0,0 +1,62 @@
import type {
CalendarCellProps as SomaCalendarCellProps,
CalendarCellSnippetProps,
CalendarDayProps as SomaCalendarDayProps,
CalendarDaySnippetProps,
CalendarGridBodyProps as SomaCalendarGridBodyProps,
CalendarGridHeadProps as SomaCalendarGridHeadProps,
CalendarGridProps as SomaCalendarGridProps,
CalendarGridRowProps as SomaCalendarGridRowProps,
CalendarGridRowSnippetProps,
CalendarHeadCellProps as SomaCalendarHeadCellProps,
CalendarHeaderProps as SomaCalendarHeaderProps,
CalendarHeadingProps as SomaCalendarHeadingProps,
CalendarHeadingSnippetProps,
CalendarMonthSelectProps as SomaCalendarMonthSelectProps,
CalendarMonthSelectSnippetProps,
CalendarNextButtonProps as SomaCalendarNextButtonProps,
CalendarPrevButtonProps as SomaCalendarPrevButtonProps,
CalendarProps as SomaCalendarProps,
CalendarProviderSnippetProps,
CalendarYearSelectProps as SomaCalendarYearSelectProps,
CalendarYearSelectSnippetProps
} from '$soma/components/calendar'
import type { DateValue } from '$libs/days'
import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types'
export type CalendarSize = Extract<Size, 'sm' | 'md' | 'lg'>
export type CalendarVariant = ControlVariant
export type CalendarColor = ColorRole
export type CalendarProps = SomaCalendarProps & {
/** Visual size for day cells, nav buttons and select controls. @default 'md' */
size?: ResponsiveProp<CalendarSize>
/** Visual treatment of the calendar surface. @default 'surface' */
variant?: CalendarVariant
/** Intent palette used for selected days and focus affordances. @default 'primary' */
color?: CalendarColor
}
export type CalendarHeaderProps = SomaCalendarHeaderProps
export type CalendarHeadingProps = SomaCalendarHeadingProps
export type CalendarPrevButtonProps = SomaCalendarPrevButtonProps
export type CalendarNextButtonProps = SomaCalendarNextButtonProps
export type CalendarMonthSelectProps = SomaCalendarMonthSelectProps
export type CalendarYearSelectProps = SomaCalendarYearSelectProps
export type CalendarGridProps = SomaCalendarGridProps
export type CalendarGridHeadProps = SomaCalendarGridHeadProps
export type CalendarGridBodyProps = SomaCalendarGridBodyProps
export type CalendarGridRowProps = SomaCalendarGridRowProps & { week?: DateValue[] }
export type CalendarHeadCellProps = SomaCalendarHeadCellProps
export type CalendarCellProps = SomaCalendarCellProps
export type CalendarDayProps = SomaCalendarDayProps
export type {
CalendarProviderSnippetProps,
CalendarCellSnippetProps,
CalendarDaySnippetProps,
CalendarHeadingSnippetProps,
CalendarMonthSelectSnippetProps,
CalendarYearSelectSnippetProps,
CalendarGridRowSnippetProps
}

@ -85,6 +85,7 @@
@import './components/search-field/search-field.css';
@import './components/select/select.css';
@import './components/combobox/combobox.css';
@import './components/calendar/calendar.css';
@import './components/date-field/date-field.css';
@import './components/slider/slider.css';
@import './components/toolbar/toolbar.css';

@ -579,6 +579,75 @@ export const THEME_BASE_RECIPE_TOKENS = {
'transition-ease': 'var(--ease-default)',
'disabled-opacity': '0.56'
},
calendar: {
gap: 'var(--space-3)',
'padding-sm': 'var(--space-3)',
'padding-md': 'var(--space-4)',
'padding-lg': 'var(--space-5)',
radius: 'var(--radius-lg)',
'border-width': 'var(--border-width)',
border: 'var(--color-border-subtle)',
'invalid-border': 'var(--color-risk-border)',
bg: 'var(--color-surface-raised)',
'outline-bg': 'var(--color-surface-default)',
'outline-shadow': 'none',
color: 'var(--color-content-primary)',
shadow: 'var(--shadow-sm)',
'font-family': 'var(--font-ui)',
'font-size-sm': 'var(--font-size-xs)',
'font-size-md': 'var(--font-size-sm)',
'font-size-lg': 'var(--font-size-sm)',
'line-height': 'var(--leading-ui)',
'header-gap': 'var(--space-2)',
'heading-color': 'var(--color-content-primary)',
'heading-font-weight': 'var(--font-weight-semibold)',
'control-size-sm': 'var(--control-height-xs)',
'control-size-md': 'var(--control-height-sm)',
'control-size-lg': 'var(--control-height-md)',
'control-radius': 'var(--radius-md)',
'control-border-width': 'var(--border-width)',
'control-border': 'transparent',
'control-border-hover': 'var(--color-border-subtle)',
'control-bg': 'transparent',
'control-bg-hover': 'var(--color-surface-overlay)',
'control-color': 'var(--color-content-secondary)',
'control-color-hover': 'var(--color-content-primary)',
'select-min-width': '8ch',
'select-px': 'var(--space-2)',
'grid-gap': 'var(--space-0-5)',
'weekday-size': 'var(--space-5)',
'weekday-color': 'var(--color-content-muted)',
'weekday-font-size': 'var(--font-size-xs)',
'weekday-font-weight': 'var(--font-weight-medium)',
'day-size-sm': 'var(--control-height-xs)',
'day-size-md': 'var(--control-height-sm)',
'day-size-lg': 'var(--control-height-md)',
'day-radius': 'var(--radius-md)',
'day-border-width': 'var(--border-width)',
'day-border': 'transparent',
'day-border-hover': 'var(--color-border-subtle)',
'day-bg': 'transparent',
'day-bg-hover': 'var(--color-surface-overlay)',
'day-color': 'var(--color-content-primary)',
'day-color-hover': 'var(--color-content-primary)',
'day-font-weight': 'var(--font-weight-regular)',
'day-selected-color': 'var(--color-content-on-solid)',
'day-today-border': 'var(--color-border-strong)',
'day-today-color': 'var(--color-content-primary)',
'day-today-font-weight': 'var(--font-weight-semibold)',
'day-outside-color': 'var(--color-content-muted)',
'day-outside-opacity': '0.62',
'day-weekend-color': 'var(--color-content-secondary)',
'day-holiday-shadow': 'inset 0 -2px 0 var(--_calendar-accent-border)',
'day-unavailable-decoration': 'var(--color-risk-border)',
'focus-shadow':
'0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'event-shadow':
'0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--_calendar-accent-border)',
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)',
'disabled-opacity': '0.52'
},
pagination: {
'gap-sm': 'var(--space-1)',
'gap-md': 'var(--space-1-5)',

@ -4,11 +4,42 @@ import { v } from '../types';
export const calendarMorfo = {
name: 'Calendar',
kebab: 'calendar',
scope: ['soma'],
scope: ['soma', 'sema'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/',
translations: {
label: { es: 'Calendario', en: 'Calendar' }
},
events: [
{
name: 'commit-select',
semantic: {
family: 'commit',
verb: 'select',
target: v.partRef('day'),
intent: 'affirm',
sequence: 'post'
}
},
{
name: 'commit-unselect',
semantic: {
family: 'commit',
verb: 'remove',
target: v.partRef('day'),
intent: 'neutral',
sequence: 'post'
}
},
{
name: 'shift-navigate',
semantic: {
family: 'shift',
verb: 'navigate',
target: v.partRef('provider'),
sequence: 'post'
}
}
],
parts: [
{
name: 'Provider',

@ -0,0 +1,48 @@
import { semaSelector } from '$uix/morfo'
import { calendarMorfo } from '$uix/morfo/components/calendar'
import { soundTuning } from '../sounds'
import type { Sema } from '../sema-map'
const onPart = (
part:
| 'provider'
| 'prev-button'
| 'next-button'
| 'month-select'
| 'year-select'
| 'day',
matchers?: Parameters<typeof semaSelector<typeof calendarMorfo>>[2]
) => semaSelector(calendarMorfo, part, matchers)
export const calendarSema: Sema = {
name: 'calendar',
cascade: [
{
selector: onPart('day', { eventName: 'commit-select' }),
sound: soundTuning('form.commit.soft'),
haptic: { kind: 'tap' }
},
{
selector: onPart('day', { eventName: 'commit-unselect' }),
sound: soundTuning('form.commit.subtle'),
haptic: { kind: 'tap' }
},
...(
[
'provider',
'prev-button',
'next-button',
'month-select',
'year-select',
'day'
] as const
).map((part) => ({
selector: onPart(part, { eventName: 'shift-navigate' }),
sound: soundTuning('form.commit.subtle', {
gain: 0.18,
contour: 'ascending',
pitch: { op: 'add', value: -90 }
})
}))
]
}

@ -1,6 +1,10 @@
# Calendar
A date-picker grid with single or multiple selection. Superior to every reference library (ark-ui, bits-ui, radix, react-aria) via built-in `MonthSelect`, `YearSelect`, ISO week numbers, a `holiday` matcher, weekend decoration, and navigation announcements. 14 parts, fully headless.
A date-picker grid with single or multiple selection. Soma owns the headless
model: `MonthSelect`, `YearSelect`, fixed weeks, multi-month grids, week-number
snippet data, holiday/weekend flags, RTL keyboard navigation and live navigation
announcements. It exposes 14 public components; Morfo also declares a future
`WeekNumber` part.
## Anatomy
@ -43,7 +47,7 @@ A date-picker grid with single or multiple selection. Superior to every referenc
## Parts
| Part | Element | Description |
| ------------- | ---------- | -------------------------------------------------------------- |
| ------------- | ---------- | ------------------------------------------------------------------ |
| `Provider` | `<div>` | Root context. Owns value, placeholder, navigation, keyboard. |
| `Header` | `<header>` | Structural wrapper for the title / controls row. |
| `Heading` | `<div>` | Auto-localized heading (`"April 2026"`, or range for multi-month). |
@ -62,14 +66,15 @@ A date-picker grid with single or multiple selection. Superior to every referenc
## Props (`Provider`)
| Prop | Type | Default | Purpose |
| ------------------------- | -------------------------------------- | ------------ | ----------------------------------------------------------------- |
| ------------------------------- | ------------------------------ | ----------- | ----------------------------------------------------------------- |
| `type` | `'single' \| 'multiple'` | required | Discriminator. `value` shape depends on this. |
| `value` | `DateValue` \| `DateValue[]` | `undefined` | Current selection. Bindable. |
| `onValueChange` | callback | — | Fires on every selection change. |
| `placeholder` | `DateValue` | today | Which month(s) are visible. Bindable, independent of selection. |
| `onPlaceholderChange` | callback | — | Fires on navigation. |
| `preventDeselect` | `boolean` | `false` | Clicking selected date keeps it selected. |
| `minValue` / `maxValue` | `DateValue` | — | Bounds. |
| `minValue` / `maxValue` | `DateValue` | — | Selectable bounds; invalid pairs mark root invalid and log. |
| `minViewValue` / `maxViewValue` | `DateValue` | — | Visible month navigation bounds; invalid pairs mark root invalid. |
| `disabled` | `boolean` | `false` | Whole calendar disabled. |
| `pagedNavigation` | `boolean` | `false` | Prev/Next advance by `numberOfMonths` instead of 1. |
| `weekStartsOn` | `0..6` | locale | Override start-of-week. |
@ -97,7 +102,7 @@ Sub-parts accept the `id` / `child` / `children` trio like every other soma comp
## Snippet props
| Snippet | Provides |
| ---------------- | ---------------------------------------------------------------------------------------------------------- |
| ---------------------- | ---------------------------------------------------------------------------- |
| `Provider#children` | `{ months, weekdays, dateOrder }` |
| `Heading#children` | `{ headingValue }` |
| `MonthSelect#children` | `{ monthItems, selectedMonthItem }` |
@ -109,7 +114,7 @@ Sub-parts accept the `id` / `child` / `children` trio like every other soma comp
## ARIA
| Part | Attribute | Value |
| ---------- | ---------------------- | ------------------------------ |
| ----------- | ----------------- | ------------------------------------------- |
| Provider | `role` | `application` |
| Provider | `aria-label` | translated label + heading |
| Provider | `dir` | `ltr` / `rtl` |
@ -131,7 +136,7 @@ A visually hidden live-region inside `Provider` announces the current heading wh
## Data attributes
| Part | Attribute | When |
| -------- | ---------------------- | ------------------------------------------ |
| -------- | -------------------- | ----------------------------------------- |
| Provider | `data-calendar` | always |
| Provider | `data-type` | `single` / `multiple` |
| Provider | `data-readonly` | when `readonly` |
@ -146,10 +151,18 @@ A visually hidden live-region inside `Provider` announces the current heading wh
| Cell | `data-outside-month` | padding cell around the visible month |
| Day | same flags as Cell | +`data-focused` for the roving tabindex=0 |
## Semantic events
| Event | Family | Target | When |
| ----------------- | ---------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------ |
| `commit-select` | `commit.select` | `Day` | A selectable date becomes selected. |
| `commit-unselect` | `commit.remove` | `Day` | A selected date is removed or the single value is cleared. |
| `shift-navigate` | `shift.navigate` | Provider fallback, concrete control when known | Visible month/year changes through buttons, selects or keyboard boundary navigation. |
## Keyboard
| Key | Action |
| ---------------------------- | ----------------------------------------------------- |
| ----------------------------- | ------------------------ |
| `ArrowRight` / `ArrowLeft` | ± 1 day (flipped in RTL) |
| `ArrowDown` / `ArrowUp` | ± 7 days |
| `Home` / `End` | Start / end of month |
@ -161,20 +174,20 @@ Focus lives on the placeholder day by default. Keyboard nav advances the placeho
## Comparison vs reference libraries
| Feature | react-aria | ark-ui | bits-ui | radix | soma |
| ------------------------- | :--------: | :----: | :-----: | :---: | :--: |
| Single + Multiple | ✓ | ✓ | ✓ | ✓ | ✓ |
| Multiple months | ✓ | ✓ | ✓ | ✓ | ✓ |
| Fixed weeks | ✓ | ✓ | ✓ | ✓ | ✓ |
| weekStartsOn / format | ✓ | ✓ | ✓ | ✓ | ✓ |
| min/max/disabled/unavail. | ✓ | ✓ | ✓ | ✓ | ✓ |
| Full ARIA & keyboard | ✓ | ✓ | ✓ | ✓ | ✓ |
| `MonthSelect` part | ✗ | ✗ | ✗ | ✗ | **✓** |
| `YearSelect` part | ✗ | ✗ | ✗ | ✗ | **✓** |
| ISO week numbers | ✗ | ✗ | ✗ | ✗ | **✓** |
| `isDateHoliday` matcher | ✗ | ✗ | ✗ | ✗ | **✓** |
| `data-weekend` flag | ✗ | ✗ | ✗ | ✗ | **✓** |
| Navigation announcements | partial | ✗ | ✗ | ✗ | **✓** |
| Feature | react-aria | ark-ui | bits-ui | radix primitives | soma |
| ------------------------- | :------------: | :-------: | :-------: | :--------------: | :----------: |
| Single + Multiple | partial | ✓ | ✓ | — | ✓ |
| Multiple months | ✓ | ✓ | ✓ | — | ✓ |
| Fixed weeks | display option | ✓ | ✓ | — | ✓ |
| weekStartsOn / format | ✓ | ✓ | ✓ | — | ✓ |
| min/max/disabled/unavail. | ✓ | ✓ | ✓ | — | ✓ |
| Full ARIA & keyboard | ✓ | ✓ | ✓ | — | ✓ |
| `MonthSelect` part | custom context | example | ✓ | — | ✓ |
| `YearSelect` part | custom context | example | ✓ | — | ✓ |
| ISO week numbers | custom | ✓ | custom | — | snippet data |
| `isDateHoliday` matcher | custom render | custom | custom | — | ✓ |
| `data-weekend` flag | custom render | custom | custom | — | ✓ |
| Navigation announcements | ✓ | callbacks | callbacks | — | ✓ |
## Composition example

@ -1,18 +1,104 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CalendarDate, CalendarDateTime } from '$libs/days';
import { createActiveDom } from '$adom';
import { CalendarDate, CalendarDateTime, type DateMatcher, type DateValue } from '$libs/days';
import { state } from '$libs/reactive';
import type { Morfo } from '$uix/morfo';
import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { resolveInitialCalendarPlaceholder } from './calendar-provider.svelte';
import { CalendarProvider, resolveInitialCalendarPlaceholder } from './calendar-provider.svelte';
import {
applyMultipleSelection,
applySingleSelection,
computeDateFlags,
createMonths
createMonths,
validateCalendarBounds
} from './helpers';
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T;
const cleanup = $effect.root(() => {
result = fn();
});
return { result, cleanup };
}
function installSomaHarness() {
const dom = createActiveDom();
const logger = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn()
};
const soma = {
dom,
logger,
langs: {
getLocale: () => 'en-US',
ts: (key: string) => key
},
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
createSomaRuntime(morfo, {
dom,
translate: (key) => key,
...sources
})
} as unknown as Soma;
vi.spyOn(Soma, 'require').mockReturnValue(soma);
vi.spyOn(CalendarProvider.ctx, 'set').mockImplementation((value) => value);
return { dom, logger };
}
function createCalendarOpts(root = document.createElement('div')) {
return {
id: state('calendar-root'),
ref: state<HTMLElement | null>(root),
placeholder: state(new CalendarDate(2026, 5, 1)),
value: state<DateValue | DateValue[] | undefined>(undefined),
type: state<'single' | 'multiple'>('single'),
preventDeselect: state(false),
minValue: state<DateValue | undefined>(undefined),
maxValue: state<DateValue | undefined>(undefined),
minViewValue: state<DateValue | undefined>(undefined),
maxViewValue: state<DateValue | undefined>(undefined),
disabled: state(false),
pagedNavigation: state(false),
weekStartsOn: state<0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined>(1),
weekdayFormat: state<Intl.DateTimeFormatOptions['weekday']>('short'),
isDateDisabled: state<DateMatcher | undefined>(undefined),
isDateUnavailable: state<DateMatcher | undefined>(undefined),
isDateHoliday: state<DateMatcher | undefined>(undefined),
fixedWeeks: state(true),
numberOfMonths: state(1),
calendarLabel: state<string | undefined>(undefined),
readonly: state(false),
disableDaysOutsideMonth: state(false),
maxDays: state<number | undefined>(undefined),
monthFormat: state<Intl.DateTimeFormatOptions['month']>('long'),
yearFormat: state<Intl.DateTimeFormatOptions['year']>('numeric'),
showWeekNumbers: state(false),
weekNumberFormat: state<'iso' | 'simple'>('iso'),
announceNavigation: state(true),
initialFocus: state(false),
dir: state<'ltr' | 'rtl'>('ltr'),
locale: state('en-US')
};
}
describe('CalendarProvider helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('resolves the initial placeholder from selection before falling back to today', () => {
const selected = new CalendarDate(2026, 5, 15);
@ -65,4 +151,67 @@ describe('CalendarProvider helpers', () => {
expect(flags).toEqual({ disabled: false, unavailable: true, holiday: true });
});
it('validates selection and view min/max pairs', () => {
const minValue = new CalendarDate(2026, 5, 10);
const maxValue = new CalendarDate(2026, 5, 5);
expect(
validateCalendarBounds({
minValue,
maxValue,
minViewValue: undefined,
maxViewValue: undefined
})
).toMatchObject({
reason: 'selection-bounds',
minValue,
maxValue
});
const minViewValue = new CalendarDate(2026, 8, 1);
const maxViewValue = new CalendarDate(2026, 7, 31);
expect(
validateCalendarBounds({
minValue: undefined,
maxValue: undefined,
minViewValue,
maxViewValue
})
).toMatchObject({
reason: 'view-bounds',
minViewValue,
maxViewValue
});
});
it('marks invalid bounds on the root and reports them through the UIX logger', async () => {
const { dom, logger } = installSomaHarness();
const opts = createCalendarOpts();
opts.minValue.current = new CalendarDate(2026, 5, 10);
opts.maxValue.current = new CalendarDate(2026, 5, 5);
const { result: provider, cleanup } = withEffectRoot(() => CalendarProvider.create(opts));
expect(provider.validationStatus).toMatchObject({ reason: 'selection-bounds' });
expect(provider.validationMessage).toContain('minValue');
expect(provider.isInvalid).toBe(true);
expect(provider.props['data-invalid']).toBe('');
await new Promise((resolve) => setTimeout(resolve, 0));
expect(logger.error).toHaveBeenCalledWith(
'soma.calendar',
expect.stringContaining('minValue'),
expect.objectContaining({
context: expect.objectContaining({
reason: 'selection-bounds',
minValue: '2026-05-10',
maxValue: '2026-05-05'
})
})
);
cleanup();
dom.dispose();
});
});

@ -30,7 +30,8 @@ import {
isOutsideMonth,
isToday,
isWeekend,
shiftDate
shiftDate,
validateCalendarBounds
} from './helpers';
import type {
CalendarCellSnippetProps,
@ -60,6 +61,8 @@ export interface CalendarOpts
preventDeselect: boolean;
minValue: DateValue | undefined;
maxValue: DateValue | undefined;
minViewValue: DateValue | undefined;
maxViewValue: DateValue | undefined;
disabled: boolean;
pagedNavigation: boolean;
weekStartsOn: WeekStartsOn | undefined;
@ -104,6 +107,11 @@ export function resolveInitialCalendarPlaceholder(
return new CalendarDate(t.year, t.month, t.day);
}
function sameDateArray(a: DateValue[], b: DateValue[]): boolean {
if (a.length !== b.length) return false;
return a.every((date, index) => isSameDay(date, b[index]));
}
export class CalendarProvider {
readonly opts: CalendarOpts;
readonly runtimePart: SomaRuntimePart;
@ -124,6 +132,7 @@ export class CalendarProvider {
headingId = state('');
/** Live-region text for announcements. */
announcement = $state('');
private lastLoggedValidationKey: string | null = null;
private constructor(opts: CalendarOpts) {
this.opts = opts;
@ -147,6 +156,34 @@ export class CalendarProvider {
this.soma.dom.focus(el);
});
});
$effect(() => {
const status = this.validationStatus;
if (status === false) {
this.lastLoggedValidationKey = null;
return;
}
const key =
status.reason === 'selection-bounds'
? `${status.reason}:${status.minValue.toString()}:${status.maxValue.toString()}`
: `${status.reason}:${status.minViewValue.toString()}:${status.maxViewValue.toString()}`;
if (key === this.lastLoggedValidationKey) return;
this.lastLoggedValidationKey = key;
this.soma.logger.error('soma.calendar', status.message, {
context:
status.reason === 'selection-bounds'
? {
reason: status.reason,
minValue: status.minValue.toString(),
maxValue: status.maxValue.toString()
}
: {
reason: status.reason,
minViewValue: status.minViewValue.toString(),
maxViewValue: status.maxViewValue.toString()
}
});
});
}
// ── Derived reactive model ────────────────────────────────────────────────
@ -197,7 +234,22 @@ export class CalendarProvider {
return Array.isArray(v) ? v : [v];
});
readonly isInvalid = $derived.by(() => {
readonly boundsValidationStatus = $derived.by(() =>
validateCalendarBounds({
minValue: this.opts.minValue.current,
maxValue: this.opts.maxValue.current,
minViewValue: this.opts.minViewValue.current,
maxViewValue: this.opts.maxViewValue.current
})
);
readonly validationStatus = $derived.by(() => this.boundsValidationStatus);
readonly validationMessage = $derived.by(() =>
this.validationStatus === false ? undefined : this.validationStatus.message
);
readonly selectionInvalid = $derived.by(() => {
for (const d of this.selectedDates) {
const flags = this.computeFlags(d);
if (flags.disabled || flags.unavailable) return true;
@ -205,6 +257,8 @@ export class CalendarProvider {
return false;
});
readonly isInvalid = $derived.by(() => this.validationStatus !== false || this.selectionInvalid);
readonly headingValue = $derived.by(() => {
const first = this.visibleMonths[0];
if (!first) return '';
@ -227,44 +281,55 @@ export class CalendarProvider {
// ── Navigation ────────────────────────────────────────────────────────────
prevPage(): void {
private canShowPlaceholder(next: DateValue): boolean {
const min = this.opts.minViewValue.current;
if (min && endOfMonth(next).compare(min) < 0) return false;
const max = this.opts.maxViewValue.current;
if (!max) return true;
const last = next.add({ months: Math.max(1, this.opts.numberOfMonths.current) - 1 });
return last.compare(max) <= 0;
}
private setPlaceholder(next: DateValue, target?: HTMLElement): void {
const nextMonth = startOfMonth(next);
if (!this.canShowPlaceholder(nextMonth)) return;
if (this.opts.placeholder.current.compare(nextMonth) === 0) return;
this.opts.placeholder.current = nextMonth;
this.announceNavigation();
void this.runtime.trigger('shift-navigate', target ? { fallbackTarget: target } : undefined);
}
prevPage(target?: HTMLElement): void {
const step = this.opts.pagedNavigation.current
? Math.max(1, this.opts.numberOfMonths.current)
: 1;
this.opts.placeholder.current = startOfMonth(
this.opts.placeholder.current.subtract({ months: step })
this.setPlaceholder(
startOfMonth(this.opts.placeholder.current.subtract({ months: step })),
target
);
this.announceNavigation();
}
nextPage(): void {
nextPage(target?: HTMLElement): void {
const step = this.opts.pagedNavigation.current
? Math.max(1, this.opts.numberOfMonths.current)
: 1;
this.opts.placeholder.current = startOfMonth(
this.opts.placeholder.current.add({ months: step })
);
this.announceNavigation();
this.setPlaceholder(startOfMonth(this.opts.placeholder.current.add({ months: step })), target);
}
prevYear(): void {
this.opts.placeholder.current = this.opts.placeholder.current.subtract({ years: 1 });
this.announceNavigation();
prevYear(target?: HTMLElement): void {
this.setPlaceholder(this.opts.placeholder.current.subtract({ years: 1 }), target);
}
nextYear(): void {
this.opts.placeholder.current = this.opts.placeholder.current.add({ years: 1 });
this.announceNavigation();
nextYear(target?: HTMLElement): void {
this.setPlaceholder(this.opts.placeholder.current.add({ years: 1 }), target);
}
setMonth(month: number): void {
this.opts.placeholder.current = this.opts.placeholder.current.set({ month });
this.announceNavigation();
setMonth(month: number, target?: HTMLElement): void {
this.setPlaceholder(this.opts.placeholder.current.set({ month }), target);
}
setYear(year: number): void {
this.opts.placeholder.current = this.opts.placeholder.current.set({ year });
this.announceNavigation();
setYear(year: number, target?: HTMLElement): void {
this.setPlaceholder(this.opts.placeholder.current.set({ year }), target);
}
private announceNavigation(): void {
@ -276,7 +341,7 @@ export class CalendarProvider {
readonly isPrevButtonDisabled = $derived.by(() => {
if (this.opts.disabled.current) return true;
const min = this.opts.minValue.current;
const min = this.opts.minViewValue.current;
if (!min) return false;
const prev = this.visibleMonths[0]?.subtract({ months: 1 });
return prev ? endOfMonth(prev).compare(min) < 0 : false;
@ -284,7 +349,7 @@ export class CalendarProvider {
readonly isNextButtonDisabled = $derived.by(() => {
if (this.opts.disabled.current) return true;
const max = this.opts.maxValue.current;
const max = this.opts.maxViewValue.current;
if (!max) return false;
const last = this.visibleMonths[this.visibleMonths.length - 1];
const next = last?.add({ months: 1 });
@ -298,22 +363,29 @@ export class CalendarProvider {
* readonly, disabled, unavailable. Dispatches to single/multiple handler
* and calls the `onValueChange` callback configured in the wrapper.
*/
select(date: DateValue): void {
select(date: DateValue, target?: HTMLElement): void {
if (this.opts.readonly.current || this.opts.disabled.current) return;
const flags = this.computeFlags(date);
if (flags.disabled || flags.unavailable) return;
const type = this.opts.type.current;
const preventDeselect = this.opts.preventDeselect.current;
let eventName: 'commit-select' | 'commit-unselect';
if (type === 'single') {
const prev = this.opts.value.current as DateValue | undefined;
const next = applySingleSelection(prev, date, preventDeselect);
if (prev && next && isSameDay(prev, next)) return;
if (!prev && !next) return;
this.opts.value.current = next;
eventName = next ? 'commit-select' : 'commit-unselect';
} else {
const prev = (this.opts.value.current as DateValue[] | undefined) ?? [];
const next = applyMultipleSelection(prev, date, preventDeselect, this.opts.maxDays.current);
if (sameDateArray(prev, next)) return;
this.opts.value.current = next;
eventName = next.some((d) => isSameDay(d, date)) ? 'commit-select' : 'commit-unselect';
}
void this.runtime.trigger(eventName, target ? { fallbackTarget: target } : undefined);
}
isSelected(date: DateValue): boolean {
@ -360,7 +432,7 @@ export class CalendarProvider {
target = e.shiftKey ? date.add({ years: 1 }) : date.add({ months: 1 });
} else if (e.key === KEYS.ENTER || e.key === ' ') {
e.preventDefault();
this.select(date);
this.select(date, e.currentTarget as HTMLElement);
return true;
}
@ -375,7 +447,7 @@ export class CalendarProvider {
target.compare(this.visibleMonths[this.visibleMonths.length - 1]) < 0 ||
target.compare(this.visibleMonths[0]) < 0)
) {
this.opts.placeholder.current = startOfMonth(target);
this.setPlaceholder(startOfMonth(target), e.currentTarget as HTMLElement);
}
// Move focus after the DOM updates.
@ -464,16 +536,23 @@ export class CalendarHeaderProvider {
// ── Heading ──────────────────────────────────────────────────────────────────
export interface CalendarHeadingOpts
extends
WithRefOpts,
ActiveProps<{
value: DateValue | undefined;
}> {}
export class CalendarHeadingProvider {
readonly opts: WithRefOpts;
readonly opts: CalendarHeadingOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: WithRefOpts) {
static create(opts: CalendarHeadingOpts) {
return new CalendarHeadingProvider(opts);
}
readonly provider: CalendarProvider;
private constructor(opts: WithRefOpts) {
private constructor(opts: CalendarHeadingOpts) {
this.opts = opts;
this.provider = CalendarProvider.require();
this.runtimePart = this.provider.runtime.part('heading', {
@ -481,10 +560,14 @@ export class CalendarHeadingProvider {
ref: opts.ref,
owner: this
});
this.provider.headingId.current = opts.id.current;
if (!opts.value.current) this.provider.headingId.current = opts.id.current;
}
readonly headingValue = $derived.by(() => this.provider.headingValue);
readonly headingValue = $derived.by(() => {
const value = this.opts.value.current;
if (!value) return this.provider.headingValue;
return `${this.provider.formatMonth(value.month)} ${this.provider.formatYear(value.year)}`;
});
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props, 'aria-hidden': true as const } as const)
@ -499,7 +582,7 @@ abstract class CalendarPaginationButtonProvider {
readonly provider: CalendarProvider;
protected readonly labelRef: string;
protected abstract disabled(): boolean;
protected abstract trigger(): void;
protected abstract trigger(target?: HTMLElement): void;
protected constructor(opts: WithRefOpts, part: 'prev-button' | 'next-button', labelRef: string) {
this.opts = opts;
@ -512,9 +595,9 @@ abstract class CalendarPaginationButtonProvider {
this.labelRef = labelRef;
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.disabled()) return;
this.trigger();
this.trigger(e.currentTarget as HTMLElement);
};
readonly props = $derived.by(() => {
@ -541,8 +624,8 @@ export class CalendarPrevButtonProvider extends CalendarPaginationButtonProvider
protected disabled() {
return this.provider.isPrevButtonDisabled;
}
protected trigger() {
this.provider.prevPage();
protected trigger(target?: HTMLElement) {
this.provider.prevPage(target);
}
}
@ -556,8 +639,8 @@ export class CalendarNextButtonProvider extends CalendarPaginationButtonProvider
protected disabled() {
return this.provider.isNextButtonDisabled;
}
protected trigger() {
this.provider.nextPage();
protected trigger(target?: HTMLElement) {
this.provider.nextPage(target);
}
}
@ -617,7 +700,7 @@ export class CalendarMonthSelectProvider {
readonly onchange = (e: Event) => {
const target = e.currentTarget as HTMLSelectElement;
const month = Number.parseInt(target.value, 10);
if (!Number.isNaN(month)) this.provider.setMonth(month);
if (!Number.isNaN(month)) this.provider.setMonth(month, target);
};
readonly props = $derived.by(() => {
@ -690,7 +773,7 @@ export class CalendarYearSelectProvider {
readonly onchange = (e: Event) => {
const target = e.currentTarget as HTMLSelectElement;
const year = Number.parseInt(target.value, 10);
if (!Number.isNaN(year)) this.provider.setYear(year);
if (!Number.isNaN(year)) this.provider.setYear(year, target);
};
readonly props = $derived.by(() => {
@ -709,16 +792,23 @@ export class CalendarYearSelectProvider {
// ── Grid / GridHead / GridBody / GridRow / HeadCell (presentational) ────────
export interface CalendarGridOpts
extends
WithRefOpts,
ActiveProps<{
labelledBy: string | undefined;
}> {}
export class CalendarGridProvider {
readonly opts: WithRefOpts;
readonly opts: CalendarGridOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: WithRefOpts) {
static create(opts: CalendarGridOpts) {
return new CalendarGridProvider(opts);
}
readonly provider: CalendarProvider;
private constructor(opts: WithRefOpts) {
private constructor(opts: CalendarGridOpts) {
this.opts = opts;
this.provider = CalendarProvider.require();
this.runtimePart = this.provider.runtime.part('grid', {
@ -729,7 +819,7 @@ export class CalendarGridProvider {
}
readonly props = $derived.by(() => {
const headingId = this.provider.headingId.current;
const headingId = this.opts.labelledBy.current ?? this.provider.headingId.current;
return this.runtimePart.assert({
...this.runtimePart.props,
role: 'grid' as const,
@ -928,9 +1018,9 @@ export class CalendarDayProvider {
day: this.day
}));
readonly onclick = (_e: SomaMouseEvent<HTMLElement>) => {
readonly onclick = (e: SomaMouseEvent<HTMLElement>) => {
const date = this.cell.opts.date.current;
this.provider.select(date);
this.provider.select(date, e.currentTarget as HTMLElement);
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {

@ -1,8 +1,5 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CalendarGridRowProvider, CalendarProvider } from '../calendar-provider.svelte';
@ -14,6 +11,7 @@
let {
ref = $bindable(null),
id = createId(uid, 'calendar-grid-row'),
week,
children,
child,
...restProps
@ -31,9 +29,8 @@
// consumer passes `week`. Purely informational; headless.
const provider = CalendarProvider.require();
const weekNumber = $derived.by(() => {
const wk = (restProps as { week?: DateValue[] }).week;
if (!wk?.length || !provider.opts.showWeekNumbers.current) return undefined;
return provider.getWeekNumber(wk[0]);
if (!week?.length || !provider.opts.showWeekNumbers.current) return undefined;
return provider.getWeekNumber(week[0]);
});
const mergedProps = $derived(mergeProps(restProps, state.props));

@ -1,8 +1,5 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CalendarGridProvider } from '../calendar-provider.svelte';
@ -13,6 +10,7 @@
let {
ref = $bindable(null),
id = createId(uid, 'calendar-grid'),
labelledBy,
children,
child,
...restProps
@ -23,7 +21,8 @@
ref: writableActive(
() => ref,
(v) => (ref = v)
)
),
labelledBy: readableActive(() => labelledBy)
});
const mergedProps = $derived(mergeProps(restProps, state.props));

@ -1,8 +1,5 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CalendarHeadingProvider } from '../calendar-provider.svelte';
@ -13,6 +10,7 @@
let {
ref = $bindable(null),
id = createId(uid, 'calendar-heading'),
value,
children,
child,
...restProps
@ -23,7 +21,8 @@
ref: writableActive(
() => ref,
(v) => (ref = v)
)
),
value: readableActive(() => value)
});
const mergedProps = $derived(mergeProps(restProps, state.props));

@ -1,14 +1,8 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import {
CalendarProvider,
resolveInitialCalendarPlaceholder
} from '../calendar-provider.svelte';
import { CalendarProvider, resolveInitialCalendarPlaceholder } from '../calendar-provider.svelte';
import type { CalendarProps } from '../types';
import { Soma } from '../../../core/soma.svelte';
@ -26,6 +20,8 @@
preventDeselect = false,
minValue,
maxValue,
minViewValue,
maxViewValue,
disabled = false,
pagedNavigation = false,
weekStartsOn,
@ -76,6 +72,8 @@
preventDeselect: readableActive(() => preventDeselect),
minValue: readableActive(() => minValue),
maxValue: readableActive(() => maxValue),
minViewValue: readableActive(() => minViewValue),
maxViewValue: readableActive(() => maxViewValue),
disabled: readableActive(() => disabled),
pagedNavigation: readableActive(() => pagedNavigation),
weekStartsOn: readableActive(() => weekStartsOn),
@ -104,7 +102,9 @@
const snippetProps = $derived({
months: state.months,
weekdays: state.weekdays,
dateOrder: state.dateOrder
dateOrder: state.dateOrder,
validationStatus: state.validationStatus,
validationMessage: state.validationMessage
});
</script>

@ -35,10 +35,7 @@ export interface CreateMonthsOpts {
* rows of 7. When `fixedWeeks` is on, the grid is padded up to 42 cells
* (6 rows) so layout height stays constant.
*/
export function createMonths(
placeholder: DateValue,
opts: CreateMonthsOpts
): Month<DateValue>[] {
export function createMonths(placeholder: DateValue, opts: CreateMonthsOpts): Month<DateValue>[] {
const months: Month<DateValue>[] = [];
for (let i = 0; i < opts.numberOfMonths; i++) {
const monthStart = startOfMonth(addMonthsPreserving(placeholder, i));
@ -62,7 +59,8 @@ function buildMonth(
// Leading padding: days from the previous month that complete the first
// visible week.
const firstCell = opts.weekStartsOn === undefined
const firstCell =
opts.weekStartsOn === undefined
? getLastFirstDayOfWeek(monthStart, 0, opts.locale)
: getLastFirstDayOfWeek(monthStart, opts.weekStartsOn, 'en-US');
const lead: DateValue[] = [];
@ -73,7 +71,8 @@ function buildMonth(
}
// Trailing padding to complete the last visible week.
const lastCell = opts.weekStartsOn === undefined
const lastCell =
opts.weekStartsOn === undefined
? getNextLastDayOfWeek(monthEnd, 0, opts.locale)
: getNextLastDayOfWeek(monthEnd, opts.weekStartsOn, 'en-US');
const trail: DateValue[] = [];
@ -128,11 +127,7 @@ export function getIsoWeekNumber(date: DateValue): number {
* Simple week number: 1-based count starting with the week containing
* January 1, counted from the user's `weekStartsOn` (0 = Sunday).
*/
export function getSimpleWeekNumber(
date: DateValue,
weekStartsOn: number,
locale: string
): number {
export function getSimpleWeekNumber(date: DateValue, weekStartsOn: number, locale: string): number {
const yearStart = new CalendarDate(date.year, 1, 1);
const firstCell = getLastFirstDayOfWeek(yearStart, weekStartsOn, locale);
const d = date.toDate(getLocalTimeZone());
@ -158,6 +153,56 @@ export interface DateFlags {
holiday: boolean;
}
export type CalendarBoundsValidationStatus =
| false
| {
reason: 'selection-bounds';
message: string;
minValue: DateValue;
maxValue: DateValue;
}
| {
reason: 'view-bounds';
message: string;
minViewValue: DateValue;
maxViewValue: DateValue;
};
export interface ValidateCalendarBoundsOpts {
minValue: DateValue | undefined;
maxValue: DateValue | undefined;
minViewValue: DateValue | undefined;
maxViewValue: DateValue | undefined;
}
export function validateCalendarBounds(
opts: ValidateCalendarBoundsOpts
): CalendarBoundsValidationStatus {
if (opts.minValue && opts.maxValue && opts.minValue.compare(opts.maxValue) > 0) {
return {
reason: 'selection-bounds',
message: `Calendar minValue (${opts.minValue.toString()}) must be on or before maxValue (${opts.maxValue.toString()}).`,
minValue: opts.minValue,
maxValue: opts.maxValue
};
}
if (opts.minViewValue && opts.maxViewValue) {
const minViewMonth = startOfMonth(opts.minViewValue);
const maxViewMonth = startOfMonth(opts.maxViewValue);
if (minViewMonth.compare(maxViewMonth) > 0) {
return {
reason: 'view-bounds',
message: `Calendar minViewValue (${opts.minViewValue.toString()}) must be on or before maxViewValue (${opts.maxViewValue.toString()}).`,
minViewValue: opts.minViewValue,
maxViewValue: opts.maxViewValue
};
}
}
return false;
}
/**
* Compute per-date flags. Order of precedence:
* 1. root `disabled` → every date disabled.
@ -201,10 +246,7 @@ export function applySingleSelection(
* Preserve time-of-day when switching selected date, so a calendar+time
* combo does not reset the hour on every date pick.
*/
export function getDateWithPreviousTime(
prev: DateValue | undefined,
next: DateValue
): DateValue {
export function getDateWithPreviousTime(prev: DateValue | undefined, next: DateValue): DateValue {
if (!prev) return next;
if (prev instanceof CalendarDateTime && next instanceof CalendarDateTime) return next;
if (prev instanceof CalendarDateTime && next instanceof CalendarDate) {

@ -12,13 +12,8 @@ import type {
PrimitiveTHeadAttributes,
PrimitiveTRAttributes
} from '../../types';
import type {
DateValue,
DateMatcher,
Month,
WeekStartsOn,
DateOrder
} from '$libs/days';
import type { DateValue, DateMatcher, Month, WeekStartsOn, DateOrder } from '$libs/days';
import type { CalendarBoundsValidationStatus } from './helpers';
// ── Snippet props ────────────────────────────────────────────────────────────
@ -30,6 +25,10 @@ export type CalendarProviderSnippetProps = {
weekdays: string[];
/** Locale's canonical date-field order (`'DMY' | 'MDY' | 'YMD'`). */
dateOrder: DateOrder;
/** Invalid min/max configuration, if any. Logged through the UIX logger by the provider. */
validationStatus: CalendarBoundsValidationStatus;
/** Human-readable validation message for demos or custom error surfaces. */
validationMessage: string | undefined;
};
/** Snippet props exposed by `Calendar.Cell`. */
@ -102,10 +101,14 @@ type CalendarBaseProps = {
* @default false
*/
preventDeselect?: boolean;
/** Earliest date the user can select. */
/** Earliest date the user can select. Must be <= `maxValue` when both are set. */
minValue?: DateValue;
/** Latest date the user can select. */
/** Latest date the user can select. Must be >= `minValue` when both are set. */
maxValue?: DateValue;
/** Earliest visible month. Must be <= `maxViewValue`; selection still uses `minValue`. */
minViewValue?: DateValue;
/** Latest visible month. Must be >= `minViewValue`; selection still uses `maxValue`. */
maxViewValue?: DateValue;
/** Disables the entire calendar. */
disabled?: boolean;
/**
@ -206,18 +209,31 @@ export type CalendarMultipleProps = CalendarBaseProps & {
onValueChange?: OnChangeFn<DateValue[]>;
};
type CalendarRootConflicts = CalendarBaseProps & {
type?: unknown;
value?: unknown;
onValueChange?: unknown;
};
export type CalendarProps = WithChild<
CalendarSingleProps | CalendarMultipleProps,
CalendarProviderSnippetProps
> &
Without<PrimitiveDivAttributes, {}>;
Without<PrimitiveDivAttributes, CalendarRootConflicts>;
// ── Child parts ──────────────────────────────────────────────────────────────
export type CalendarHeaderProps = WithChild<{ id?: string }> &
Without<PrimitiveHeaderAttributes, {}>;
export type CalendarHeadingProps = WithChild<{ id?: string }, CalendarHeadingSnippetProps> &
export type CalendarHeadingProps = WithChild<
{
id?: string;
/** Month to label. When omitted, renders the visible range heading. */
value?: DateValue;
},
CalendarHeadingSnippetProps
> &
Without<PrimitiveDivAttributes, {}>;
export type CalendarPrevButtonProps = WithChild<{ id?: string }> &
@ -254,7 +270,11 @@ export type CalendarYearSelectProps = WithChild<
> &
Without<PrimitiveSelectAttributes, {}>;
export type CalendarGridProps = WithChild<{ id?: string }> &
export type CalendarGridProps = WithChild<{
id?: string;
/** Explicit id of the heading that labels this grid. */
labelledBy?: string;
}> &
Without<PrimitiveTableAttributes, {}>;
export type CalendarGridHeadProps = WithChild<{ id?: string }> &

@ -1,8 +1,5 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CalendarProvider } from '../../calendar/calendar-provider.svelte';
@ -82,6 +79,8 @@
preventDeselect: provider.opts.preventDeselect,
minValue: provider.opts.minValue,
maxValue: provider.opts.maxValue,
minViewValue: readableActive(() => undefined),
maxViewValue: readableActive(() => undefined),
disabled: provider.opts.disabled,
readonly: provider.opts.readonly,
pagedNavigation: provider.opts.pagedNavigation,
@ -106,7 +105,10 @@
});
const mergedProps = $derived(
mergeProps(restProps, state.props, { [datePickerAttrs.calendar]: '' } as Record<string, unknown>)
mergeProps(restProps, state.props, { [datePickerAttrs.calendar]: '' } as Record<
string,
unknown
>)
);
const snippetProps = $derived({

@ -26,6 +26,7 @@
import { toolbarSema } from '$uix/sema/components/toolbar';
import { accordionSema } from '$uix/sema/components/accordion';
import { checkboxSema } from '$uix/sema/components/checkbox';
import { calendarSema } from '$uix/sema/components/calendar';
import { comboboxSema } from '$uix/sema/components/combobox';
import { dateFieldSema } from '$uix/sema/components/date-field';
import { Popover } from '$uix/eidos/components/popover';
@ -58,6 +59,7 @@
haptic: true,
components: [
accordionSema,
calendarSema,
checkboxSema,
comboboxSema,
dateFieldSema,
@ -141,6 +143,7 @@
{ slug: '/uix/components/search-field', label: 'Search field' },
{ slug: '/uix/components/select', label: 'Select' },
{ slug: '/uix/components/combobox', label: 'Combobox' },
{ slug: '/uix/components/calendar', label: 'Calendar' },
{ slug: '/uix/components/date-field', label: 'Date field' },
{ slug: '/uix/components/stepper', label: 'Stepper' },
{ slug: '/uix/components/tag-group', label: 'Tag group' },

File diff suppressed because it is too large Load Diff

@ -415,6 +415,7 @@ component.
| accordion | ✓ |
| avatar | ✓ (0 events, pending audit) |
| breadcrumb | ✓ (0 events, structural) |
| calendar | ✓ (3 events) |
| checkbox | ✓ |
| collapsible | ✓ |
| dialog | ✓ |

Loading…
Cancel
Save

Powered by TurnKey Linux.