14 KiB
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
| 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. |
Comparativa
| 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 |
months (fallback grid) |
prevPage, nextPage, setMonth, setYear, keyboard month boundary |
family default slide (arc, gain 0.06) + the directional shift motion firma |
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
<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>
Decisiones
- No se expone
Calendar.Provideren Eidos. El componente raíz monta el provider internamente y estampadata-size/data-variant/data-color. Los consumidores manipulan estado vía props (bind:value,bind:placeholder). commit-selectllevaintent: 'affirm'porque seleccionar una fecha es un compromiso evaluativamente positivo (suave). Un commit destructivo correspondería acommit-unselectconintent: 'neutral'.shift-navigatecubre todo el movimiento de mes/año (prev/next, selects, atajos de teclado). No se subdivide en variantes de verbo: el sentido del paso viaja POR EMISIÓN endata-event-direction(forward/backward), y lo pone quien da el paso — la paginación y el teclado saben hacia dónde fueron; un salto desde el select no pasa ninguno.- El sello de
shift-navigatecae enmonths, no en el provider, ni en el botón, ni en ungrid.Monthses la vista paginada: el sujeto del cruce, y lo único que puede LUCIR una firma deshift(una página se desliza, un botón no). Llegó ahí en dos correcciones. La primera, el 2026-08-11, lo sacó del botón: medido sobre la flecha «anterior», recibía sucontact-activate, arrancaba elpress-squeezey 8,5 ms después —media trama—shift-navigatepisaba la misma ranura y el squeeze moría sin pintar. Una superficie, una ranura. La segunda, el 2026-08-12, lo sacó delgrid:grides una parte REPETIDA, y un emit sin ancla resuelve a la instancia viva más reciente, así que connumberOfMonths=2sólo cruzaba el segundo mes y el primero cambiaba de fechas sin moverse — el shift invisible (cap. 34 §14) sobre la mitad del componente. Un destino que sólo es correcto para un valor del parámetro no es un destino. Medido tras el cambio: los dos grids se desplazan 10 px a los 40 ms del cruce, y −10 px en RTL. - El anillo de evento perdió el provider en la receta
(
box-shadow: var(--calendar-event-shadow)): nada apunta ya a[data-calendar]y la regla era una selección muerta.monthstampoco entra en la lista: su firma es el cruce, no un anillo. MonthPaneles una parte, no un div del consumidor. Los cuatro demos multi-mes fabricaban a mano un<div data-calendar-month-panel>que ningún morfo declaraba y que esta receta estilizaba igualmente; el contenedor lo montaban con unstyleen línea que rederivaba el número de meses en cada página. Ambos son partes desde el 2026-08-12, y la disposición vive aquí (grid-auto-flow: column, sin contar meses). Se quedan el día (lo alimentacommit-select) y las flechas y los dos selects, que componen<Button>y conservan el anillo por su propiocontact-activate. Verificado en navegador.- Las flechas de teclado son focus moves puros: mueven el día
enfocado pero no mutan
value. Por eso el morfo declara 10 keyboards y sólo 3 eventos: Enter/Space (mutación) →commit-select, los 8 restantes no necesitan evento propio. data-readonlyes estado, no atributo de morfo input: el grid marca elaria-readonlyy la receta lo tinta con tono atenuado pero permite seguir leyendo y navegando con teclado.
Talla y tema
Contrato en lib/recipes/base.ts > calendar — y esa entrada es la de la
FAMILIA, no sólo la de este componente: RangeCalendar, MonthGrid,
YearGrid y los calendarios de los dos pickers leen el mismo vocabulario
--calendar-* (medido 2026-08-21: 114 / 77 / 77 referencias y CERO tokens
propios). Alcance del censo 84 %.
La resolución por talla vive en
lib/calendar-surface.css, enganchada al
hook data-calendar-surface que este wrapper estampa: cuatro coordenadas
(padding, control-size, day-size, font-size), xs..lg — la familia no
tiene xl — y la celda dos pasos por debajo del bundle de control. Antes ese
bloque estaba copiado en cuatro recetas con un privado de nombre distinto en
cada una.
El anillo de evento y la marca de festivo también son de la capa: la FORMA
allí, el ACENTO desde aquí (--_calendar-surface-accent ← el forward de paleta
THM-2 de cada superficie).
Gaps
| Gap | Disposición | Detalle |
|---|---|---|
Public Calendar.WeekNumber part |
diferir | Morfo declara el part, pero Soma expone los números de semana a través del snippet data de GridRow en vez de un componente DOM. Requiere refactor en Soma antes de exponer en Eidos. |
| Range selection | descartar | Vive en range-calendar / componentes de range, no en Calendar. |
| Popover date picker shell | descartar | Vive en date-picker; Calendar es inline por diseño. |
| Modal + footer (clear / cancel / close) compartido con pickers | diferir | Calendar inline no lo necesita; los pickers lo aportan en su capa. Si llega un caso de Calendar como contenido de Drawer, se reevalúa. |
| Año/mes con scroll vertical estilo iOS | diferir | Patrón nuevo, no cubierto por ninguna referencia que mantengamos. Sin caso de uso real. |