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

@ -13,6 +13,7 @@ const VISUAL_ATTRS = [
['avatar/avatar-image.svelte', ['data-status']], ['avatar/avatar-image.svelte', ['data-status']],
['avatar/avatar-fallback.svelte', ['data-status']], ['avatar/avatar-fallback.svelte', ['data-status']],
['breadcrumb/breadcrumb.svelte', ['data-size']], ['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.svelte', ['data-size', 'data-variant', 'data-color']],
['checkbox/checkbox-group.svelte', ['data-columns']], ['checkbox/checkbox-group.svelte', ['data-columns']],
['dialog/dialog-close.svelte', ['data-position', 'data-icon-only']], ['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 Ningun componente Eidos se declara cerrado solo por envolver Soma. Antes de
implementar o revisar un componente: implementar o revisar un componente:
1. Leer Air en la rama anterior (`glm-5:src/uix/air/components/{name}`) cuando 1. Leer Air en la rama anterior (`morfo-runtime:src/uix/air/components/{name}`)
exista. Air es la primera baseline visual. cuando exista. Air es la primera baseline visual.
2. Leer Soma/Morfo actuales para separar comportamiento, ARIA, estado, 2. Leer Soma/Morfo actuales para separar comportamiento, ARIA, estado,
traducciones y data-attrs de la superficie visual Eidos. traducciones y data-attrs de la superficie visual Eidos.
3. Auditar Morfo/Sema. El morfo no se considera correcto solo porque compile: 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. 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, 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 | | accordion | ✅ multi-part | Item, Header, Trigger, Content |
| avatar | ✅ multi-part | Image, Fallback (eidos-native) | | avatar | ✅ multi-part | Image, Fallback (eidos-native) |
| breadcrumb | ✅ multi-part | List, Item, Link, Separator, Ellipsis | | 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 | | icon | ✅ single-part | + 1697 lucide glyphs |
| meter | ✅ multi-part | Indicator | | meter | ✅ multi-part | Indicator |
| number-field | ✅ multi-part | Input, IncrementTrigger, DecrementTrigger, Scrubber | | 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 ### Orden recomendado para continuar
1. Componentes grandes sólo con tabla previa de migración: 1. Componentes grandes sólo con tabla previa de migración:
`calendar`, `date-field`, `date-picker`, `date-range-picker`, `date-picker`, `date-range-picker`, `time-field`, `time-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/search-field/search-field.css';
@import './components/select/select.css'; @import './components/select/select.css';
@import './components/combobox/combobox.css'; @import './components/combobox/combobox.css';
@import './components/calendar/calendar.css';
@import './components/date-field/date-field.css'; @import './components/date-field/date-field.css';
@import './components/slider/slider.css'; @import './components/slider/slider.css';
@import './components/toolbar/toolbar.css'; @import './components/toolbar/toolbar.css';

@ -579,6 +579,75 @@ export const THEME_BASE_RECIPE_TOKENS = {
'transition-ease': 'var(--ease-default)', 'transition-ease': 'var(--ease-default)',
'disabled-opacity': '0.56' '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: { pagination: {
'gap-sm': 'var(--space-1)', 'gap-sm': 'var(--space-1)',
'gap-md': 'var(--space-1-5)', 'gap-md': 'var(--space-1-5)',

@ -4,11 +4,42 @@ import { v } from '../types';
export const calendarMorfo = { export const calendarMorfo = {
name: 'Calendar', name: 'Calendar',
kebab: 'calendar', kebab: 'calendar',
scope: ['soma'], scope: ['soma', 'sema'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/',
translations: { translations: {
label: { es: 'Calendario', en: 'Calendar' } 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: [ parts: [
{ {
name: 'Provider', 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 # 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 ## Anatomy
@ -43,7 +47,7 @@ A date-picker grid with single or multiple selection. Superior to every referenc
## Parts ## Parts
| Part | Element | Description | | Part | Element | Description |
| ------------- | ---------- | -------------------------------------------------------------- | | ------------- | ---------- | ------------------------------------------------------------------ |
| `Provider` | `<div>` | Root context. Owns value, placeholder, navigation, keyboard. | | `Provider` | `<div>` | Root context. Owns value, placeholder, navigation, keyboard. |
| `Header` | `<header>` | Structural wrapper for the title / controls row. | | `Header` | `<header>` | Structural wrapper for the title / controls row. |
| `Heading` | `<div>` | Auto-localized heading (`"April 2026"`, or range for multi-month). | | `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`) ## Props (`Provider`)
| Prop | Type | Default | Purpose | | Prop | Type | Default | Purpose |
| ------------------------- | -------------------------------------- | ------------ | ----------------------------------------------------------------- | | ------------------------------- | ------------------------------ | ----------- | ----------------------------------------------------------------- |
| `type` | `'single' \| 'multiple'` | required | Discriminator. `value` shape depends on this. | | `type` | `'single' \| 'multiple'` | required | Discriminator. `value` shape depends on this. |
| `value` | `DateValue` \| `DateValue[]` | `undefined` | Current selection. Bindable. | | `value` | `DateValue` \| `DateValue[]` | `undefined` | Current selection. Bindable. |
| `onValueChange` | callback | — | Fires on every selection change. | | `onValueChange` | callback | — | Fires on every selection change. |
| `placeholder` | `DateValue` | today | Which month(s) are visible. Bindable, independent of selection. | | `placeholder` | `DateValue` | today | Which month(s) are visible. Bindable, independent of selection. |
| `onPlaceholderChange` | callback | — | Fires on navigation. | | `onPlaceholderChange` | callback | — | Fires on navigation. |
| `preventDeselect` | `boolean` | `false` | Clicking selected date keeps it selected. | | `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. | | `disabled` | `boolean` | `false` | Whole calendar disabled. |
| `pagedNavigation` | `boolean` | `false` | Prev/Next advance by `numberOfMonths` instead of 1. | | `pagedNavigation` | `boolean` | `false` | Prev/Next advance by `numberOfMonths` instead of 1. |
| `weekStartsOn` | `0..6` | locale | Override start-of-week. | | `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 props
| Snippet | Provides | | Snippet | Provides |
| ---------------- | ---------------------------------------------------------------------------------------------------------- | | ---------------------- | ---------------------------------------------------------------------------- |
| `Provider#children` | `{ months, weekdays, dateOrder }` | | `Provider#children` | `{ months, weekdays, dateOrder }` |
| `Heading#children` | `{ headingValue }` | | `Heading#children` | `{ headingValue }` |
| `MonthSelect#children` | `{ monthItems, selectedMonthItem }` | | `MonthSelect#children` | `{ monthItems, selectedMonthItem }` |
@ -109,7 +114,7 @@ Sub-parts accept the `id` / `child` / `children` trio like every other soma comp
## ARIA ## ARIA
| Part | Attribute | Value | | Part | Attribute | Value |
| ---------- | ---------------------- | ------------------------------ | | ----------- | ----------------- | ------------------------------------------- |
| Provider | `role` | `application` | | Provider | `role` | `application` |
| Provider | `aria-label` | translated label + heading | | Provider | `aria-label` | translated label + heading |
| Provider | `dir` | `ltr` / `rtl` | | Provider | `dir` | `ltr` / `rtl` |
@ -131,7 +136,7 @@ A visually hidden live-region inside `Provider` announces the current heading wh
## Data attributes ## Data attributes
| Part | Attribute | When | | Part | Attribute | When |
| -------- | ---------------------- | ------------------------------------------ | | -------- | -------------------- | ----------------------------------------- |
| Provider | `data-calendar` | always | | Provider | `data-calendar` | always |
| Provider | `data-type` | `single` / `multiple` | | Provider | `data-type` | `single` / `multiple` |
| Provider | `data-readonly` | when `readonly` | | 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 | | Cell | `data-outside-month` | padding cell around the visible month |
| Day | same flags as Cell | +`data-focused` for the roving tabindex=0 | | 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 ## Keyboard
| Key | Action | | Key | Action |
| ---------------------------- | ----------------------------------------------------- | | ----------------------------- | ------------------------ |
| `ArrowRight` / `ArrowLeft` | ± 1 day (flipped in RTL) | | `ArrowRight` / `ArrowLeft` | ± 1 day (flipped in RTL) |
| `ArrowDown` / `ArrowUp` | ± 7 days | | `ArrowDown` / `ArrowUp` | ± 7 days |
| `Home` / `End` | Start / end of month | | `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 ## Comparison vs reference libraries
| Feature | react-aria | ark-ui | bits-ui | radix | soma | | Feature | react-aria | ark-ui | bits-ui | radix primitives | soma |
| ------------------------- | :--------: | :----: | :-----: | :---: | :--: | | ------------------------- | :------------: | :-------: | :-------: | :--------------: | :----------: |
| Single + Multiple | ✓ | ✓ | ✓ | ✓ | ✓ | | Single + Multiple | partial | ✓ | ✓ | — | ✓ |
| Multiple months | ✓ | ✓ | ✓ | ✓ | ✓ | | Multiple months | ✓ | ✓ | ✓ | — | ✓ |
| Fixed weeks | ✓ | ✓ | ✓ | ✓ | ✓ | | Fixed weeks | display option | ✓ | ✓ | — | ✓ |
| weekStartsOn / format | ✓ | ✓ | ✓ | ✓ | ✓ | | weekStartsOn / format | ✓ | ✓ | ✓ | — | ✓ |
| min/max/disabled/unavail. | ✓ | ✓ | ✓ | ✓ | ✓ | | min/max/disabled/unavail. | ✓ | ✓ | ✓ | — | ✓ |
| Full ARIA & keyboard | ✓ | ✓ | ✓ | ✓ | ✓ | | Full ARIA & keyboard | ✓ | ✓ | ✓ | — | ✓ |
| `MonthSelect` part | ✗ | ✗ | ✗ | ✗ | **✓** | | `MonthSelect` part | custom context | example | ✓ | — | ✓ |
| `YearSelect` part | ✗ | ✗ | ✗ | ✗ | **✓** | | `YearSelect` part | custom context | example | ✓ | — | ✓ |
| ISO week numbers | ✗ | ✗ | ✗ | ✗ | **✓** | | ISO week numbers | custom | ✓ | custom | — | snippet data |
| `isDateHoliday` matcher | ✗ | ✗ | ✗ | ✗ | **✓** | | `isDateHoliday` matcher | custom render | custom | custom | — | ✓ |
| `data-weekend` flag | ✗ | ✗ | ✗ | ✗ | **✓** | | `data-weekend` flag | custom render | custom | custom | — | ✓ |
| Navigation announcements | partial | ✗ | ✗ | ✗ | **✓** | | Navigation announcements | ✓ | callbacks | callbacks | — | ✓ |
## Composition example ## Composition example

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

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

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

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

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

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

@ -12,13 +12,8 @@ import type {
PrimitiveTHeadAttributes, PrimitiveTHeadAttributes,
PrimitiveTRAttributes PrimitiveTRAttributes
} from '../../types'; } from '../../types';
import type { import type { DateValue, DateMatcher, Month, WeekStartsOn, DateOrder } from '$libs/days';
DateValue, import type { CalendarBoundsValidationStatus } from './helpers';
DateMatcher,
Month,
WeekStartsOn,
DateOrder
} from '$libs/days';
// ── Snippet props ──────────────────────────────────────────────────────────── // ── Snippet props ────────────────────────────────────────────────────────────
@ -30,6 +25,10 @@ export type CalendarProviderSnippetProps = {
weekdays: string[]; weekdays: string[];
/** Locale's canonical date-field order (`'DMY' | 'MDY' | 'YMD'`). */ /** Locale's canonical date-field order (`'DMY' | 'MDY' | 'YMD'`). */
dateOrder: DateOrder; 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`. */ /** Snippet props exposed by `Calendar.Cell`. */
@ -102,10 +101,14 @@ type CalendarBaseProps = {
* @default false * @default false
*/ */
preventDeselect?: boolean; preventDeselect?: boolean;
/** Earliest date the user can select. */ /** Earliest date the user can select. Must be <= `maxValue` when both are set. */
minValue?: DateValue; minValue?: DateValue;
/** Latest date the user can select. */ /** Latest date the user can select. Must be >= `minValue` when both are set. */
maxValue?: DateValue; 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. */ /** Disables the entire calendar. */
disabled?: boolean; disabled?: boolean;
/** /**
@ -206,18 +209,31 @@ export type CalendarMultipleProps = CalendarBaseProps & {
onValueChange?: OnChangeFn<DateValue[]>; onValueChange?: OnChangeFn<DateValue[]>;
}; };
type CalendarRootConflicts = CalendarBaseProps & {
type?: unknown;
value?: unknown;
onValueChange?: unknown;
};
export type CalendarProps = WithChild< export type CalendarProps = WithChild<
CalendarSingleProps | CalendarMultipleProps, CalendarSingleProps | CalendarMultipleProps,
CalendarProviderSnippetProps CalendarProviderSnippetProps
> & > &
Without<PrimitiveDivAttributes, {}>; Without<PrimitiveDivAttributes, CalendarRootConflicts>;
// ── Child parts ────────────────────────────────────────────────────────────── // ── Child parts ──────────────────────────────────────────────────────────────
export type CalendarHeaderProps = WithChild<{ id?: string }> & export type CalendarHeaderProps = WithChild<{ id?: string }> &
Without<PrimitiveHeaderAttributes, {}>; 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, {}>; Without<PrimitiveDivAttributes, {}>;
export type CalendarPrevButtonProps = WithChild<{ id?: string }> & export type CalendarPrevButtonProps = WithChild<{ id?: string }> &
@ -254,7 +270,11 @@ export type CalendarYearSelectProps = WithChild<
> & > &
Without<PrimitiveSelectAttributes, {}>; 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, {}>; Without<PrimitiveTableAttributes, {}>;
export type CalendarGridHeadProps = WithChild<{ id?: string }> & export type CalendarGridHeadProps = WithChild<{ id?: string }> &

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

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

File diff suppressed because it is too large Load Diff

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

Loading…
Cancel
Save

Powered by TurnKey Linux.