feat(month-grid, year-grid): standalone primitives parallel to date-picker views

DatePicker.MonthView / YearView require a DatePickerProvider context and
can't be used standalone. This commit introduces MonthGrid and YearGrid
as independent primitives that accept value / placeholder / minValue /
maxValue as props and work anywhere.

morfo
- New morfo/components/month-grid.ts and year-grid.ts.
- scope: ['soma','sema'], apg: grid pattern.
- 7 parts each: Provider (role=application) / Header / Heading /
  PrevButton / NextButton / Grid (role=grid) / Cell (role=gridcell).
- Events: commit-set (cell click) + nav-step (prev/next).
- Cell data-attrs: data-value, data-selected, data-current, data-disabled,
  data-focused.
- Keyboard map covers arrows / Home / End / PageUp/Down / Enter / Space.

soma
- New soma/components/{month-grid,year-grid}/ with provider class +
  langs + types + exports + 7 thin svelte wrappers each.
- MonthGridProvider: 12-cell grid keyed to placeholder.year; localized
  month names via Intl.DateTimeFormat with configurable monthFormat
  ('long'|'short'|'narrow'). Prev/Next step ±1 year.
- YearGridProvider: decadeSize-cell grid (default 12); page anchored at
  placeholder.year - (placeholder.year % decadeSize). Prev/Next step ±
  decadeSize.
- Both expose Cell sub-provider with snippetProps {selected, disabled,
  current, value, label} + roving tabindex + RTL-aware keyboard nav.

eidos
- New eidos/components/{month-grid,year-grid}/ — namespace-style index
  with size / variant / color visual props + dedicated CSS reusing the
  --calendar-* token family.
- Each grid renders Header (Prev/Heading/Next) + 3-col Grid.

demo
- New /uix/components/month-grid and /uix/components/year-grid demo
  pages with the standard live/api/morfo/sema/recipe/a11y tabs,
  interactive controls for every public prop, soma+eidos snippets that
  reactively rebuild from controls, and sema event play buttons.

Verified: svelte-check --threshold error -> 0 errors / 0 warnings /
4572 files.

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

@ -0,0 +1,36 @@
# Eidos MonthGrid
Visual wrapper for Soma MonthGrid. Standalone month-picker primitive — a 12-cell
grid for a single year. Reuses calendar tokens (`--calendar-*`) for consistent
chrome with the inline Calendar.
```svelte
<script lang="ts">
import { MonthGrid } from '$uix/eidos/components/month-grid';
let value = $state<{ year: number; month: number } | undefined>();
</script>
<MonthGrid bind:value size="md" variant="surface" color="primary">
{#snippet children({ months, headingValue })}
<MonthGrid.Header>
<MonthGrid.PrevButton>‹</MonthGrid.PrevButton>
<MonthGrid.Heading />
<MonthGrid.NextButton>›</MonthGrid.NextButton>
</MonthGrid.Header>
<MonthGrid.Grid>
{#each months as month (month)}
<MonthGrid.Cell {month} />
{/each}
</MonthGrid.Grid>
{/snippet}
</MonthGrid>
```
Parts: `Provider`, `Header`, `Heading`, `PrevButton`, `NextButton`, `Grid`,
`Cell`. Eidos exposes `size`, `variant`, `color` on the root.
## Sema events
- `commit-set` — `commit.set` on `cell`, `intent: 'affirm'`, sequence `post`.
- `nav-step` — `shift.navigate` on `provider`, sequence `post`.

@ -0,0 +1,46 @@
import MonthGridComponent from './month-grid.svelte'
import Header from './month-grid-header.svelte'
import Heading from './month-grid-heading.svelte'
import PrevButton from './month-grid-prev-button.svelte'
import NextButton from './month-grid-next-button.svelte'
import Grid from './month-grid-grid.svelte'
import Cell from './month-grid-cell.svelte'
type MonthGridNamespace = typeof MonthGridComponent & {
Header: typeof Header
Heading: typeof Heading
PrevButton: typeof PrevButton
NextButton: typeof NextButton
Grid: typeof Grid
Cell: typeof Cell
}
const MonthGrid = MonthGridComponent as MonthGridNamespace
MonthGrid.Header = Header
MonthGrid.Heading = Heading
MonthGrid.PrevButton = PrevButton
MonthGrid.NextButton = NextButton
MonthGrid.Grid = Grid
MonthGrid.Cell = Cell
export { MonthGrid }
export default MonthGrid
export type {
MonthGridProps,
MonthGridHeaderProps as HeaderProps,
MonthGridHeadingProps as HeadingProps,
MonthGridPrevButtonProps as PrevButtonProps,
MonthGridNextButtonProps as NextButtonProps,
MonthGridGridProps as GridProps,
MonthGridCellProps as CellProps,
MonthGridProviderSnippetProps,
MonthGridHeadingSnippetProps,
MonthGridCellSnippetProps,
MonthGridSize,
MonthGridVariant,
MonthGridColor,
MonthValue,
MonthPlaceholder
} from './types'

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

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

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

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

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

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

@ -0,0 +1,265 @@
/* MonthGrid — reuses calendar tokens for visual consistency. */
[data-month-grid] {
--_month-grid-padding: var(--calendar-padding-md);
--_month-grid-control-size: var(--calendar-control-size-md);
--_month-grid-cell-size: var(--calendar-day-size-md);
--_month-grid-font-size: var(--calendar-font-size-md);
--_month-grid-accent-solid: var(--color-primary-solid);
--_month-grid-accent-solid-hover: var(--color-primary-solid-hover);
--_month-grid-accent-track: var(--color-primary-track);
--_month-grid-accent-border: var(--color-primary-border);
--_month-grid-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(--_month-grid-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(--_month-grid-font-size);
line-height: var(--calendar-line-height);
}
[data-month-grid][data-size='sm'] {
--_month-grid-padding: var(--calendar-padding-sm);
--_month-grid-control-size: var(--calendar-control-size-sm);
--_month-grid-cell-size: var(--calendar-day-size-sm);
--_month-grid-font-size: var(--calendar-font-size-sm);
}
[data-month-grid][data-size='lg'] {
--_month-grid-padding: var(--calendar-padding-lg);
--_month-grid-control-size: var(--calendar-control-size-lg);
--_month-grid-cell-size: var(--calendar-day-size-lg);
--_month-grid-font-size: var(--calendar-font-size-lg);
}
[data-month-grid][data-variant='outline'] {
background: var(--calendar-outline-bg);
box-shadow: var(--calendar-outline-shadow);
}
[data-month-grid][data-variant='ghost'] {
padding: 0;
border-color: transparent;
background: transparent;
box-shadow: none;
}
[data-month-grid][data-color='secondary'] {
--_month-grid-accent-solid: var(--color-secondary-solid);
--_month-grid-accent-solid-hover: var(--color-secondary-solid-hover);
--_month-grid-accent-track: var(--color-secondary-track);
--_month-grid-accent-border: var(--color-secondary-border);
--_month-grid-accent-text: var(--color-secondary-text);
}
[data-month-grid][data-color='neutral'] {
--_month-grid-accent-solid: var(--color-neutral-solid);
--_month-grid-accent-solid-hover: var(--color-neutral-solid-hover);
--_month-grid-accent-track: var(--color-neutral-track);
--_month-grid-accent-border: var(--color-neutral-border);
--_month-grid-accent-text: var(--color-neutral-text);
}
[data-month-grid][data-color='affirm'] {
--_month-grid-accent-solid: var(--color-affirm-solid);
--_month-grid-accent-solid-hover: var(--color-affirm-solid-hover);
--_month-grid-accent-track: var(--color-affirm-track);
--_month-grid-accent-border: var(--color-affirm-border);
--_month-grid-accent-text: var(--color-affirm-text);
}
[data-month-grid][data-color='fulfill'] {
--_month-grid-accent-solid: var(--color-fulfill-solid);
--_month-grid-accent-solid-hover: var(--color-fulfill-solid-hover);
--_month-grid-accent-track: var(--color-fulfill-track);
--_month-grid-accent-border: var(--color-fulfill-border);
--_month-grid-accent-text: var(--color-fulfill-text);
}
[data-month-grid][data-color='risk'] {
--_month-grid-accent-solid: var(--color-risk-solid);
--_month-grid-accent-solid-hover: var(--color-risk-solid-hover);
--_month-grid-accent-track: var(--color-risk-track);
--_month-grid-accent-border: var(--color-risk-border);
--_month-grid-accent-text: var(--color-risk-text);
}
[data-month-grid][data-color='threat'] {
--_month-grid-accent-solid: var(--color-threat-solid);
--_month-grid-accent-solid-hover: var(--color-threat-solid-hover);
--_month-grid-accent-track: var(--color-threat-track);
--_month-grid-accent-border: var(--color-threat-border);
--_month-grid-accent-text: var(--color-threat-text);
}
[data-month-grid][data-color='loss'] {
--_month-grid-accent-solid: var(--color-loss-solid);
--_month-grid-accent-solid-hover: var(--color-loss-solid-hover);
--_month-grid-accent-track: var(--color-loss-track);
--_month-grid-accent-border: var(--color-loss-border);
--_month-grid-accent-text: var(--color-loss-text);
}
[data-month-grid-header] {
display: grid;
grid-template-columns:
var(--_month-grid-control-size)
minmax(0, 1fr)
var(--_month-grid-control-size);
align-items: center;
gap: var(--calendar-header-gap);
inline-size: 100%;
min-inline-size: 0;
}
[data-month-grid-heading] {
min-inline-size: 0;
color: var(--calendar-heading-color);
font-size: var(--calendar-heading-font-size);
font-weight: var(--calendar-heading-font-weight);
text-align: center;
}
[data-month-grid-prev-button],
[data-month-grid-next-button] {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
inline-size: var(--_month-grid-control-size);
block-size: var(--_month-grid-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-month-grid-prev-button]:hover,
[data-month-grid-next-button]:hover {
border-color: var(--calendar-control-border-hover);
background: var(--calendar-control-bg-hover);
color: var(--calendar-control-color-hover);
}
[data-month-grid-prev-button]:focus-visible,
[data-month-grid-next-button]:focus-visible,
[data-month-grid-cell]:focus-visible {
outline: none;
box-shadow: var(--calendar-focus-shadow);
}
[data-month-grid-prev-button][data-disabled],
[data-month-grid-next-button][data-disabled] {
cursor: not-allowed;
opacity: var(--calendar-disabled-opacity);
}
[data-month-grid-grid] {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--calendar-grid-gap);
inline-size: max-content;
max-inline-size: 100%;
}
[data-month-grid-cell] {
display: inline-flex;
align-items: center;
justify-content: center;
min-inline-size: calc(var(--_month-grid-cell-size) * 2);
block-size: var(--_month-grid-cell-size);
padding-inline: var(--calendar-select-px, 8px);
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-month-grid-cell]:hover {
border-color: var(--calendar-day-border-hover);
background: var(--calendar-day-bg-hover);
color: var(--calendar-day-color-hover);
}
[data-month-grid-cell][data-selected] {
border-color: var(--_month-grid-accent-solid);
background: var(--_month-grid-accent-solid);
color: var(--calendar-day-selected-color);
}
[data-month-grid-cell][data-selected]:hover {
border-color: var(--_month-grid-accent-solid-hover);
background: var(--_month-grid-accent-solid-hover);
}
[data-month-grid-cell][data-focused]:not([data-selected]) {
border-color: var(--_month-grid-accent-border);
background: var(--_month-grid-accent-track);
color: var(--_month-grid-accent-text);
}
[data-month-grid-cell][data-current]: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-month-grid-cell][data-disabled] {
cursor: not-allowed;
opacity: var(--calendar-disabled-opacity);
}
[data-month-grid][data-disabled] {
opacity: var(--calendar-disabled-opacity);
}
[data-month-grid][data-readonly] [data-month-grid-cell]:not([data-selected]) {
cursor: default;
}
[data-month-grid][data-readonly] [data-month-grid-cell]:not([data-selected]):hover {
background: var(--calendar-day-bg);
border-color: var(--calendar-day-border);
color: var(--calendar-day-color);
}
[data-month-grid][data-event],
[data-month-grid-cell][data-event],
[data-month-grid-prev-button][data-event],
[data-month-grid-next-button][data-event] {
box-shadow: var(--calendar-event-shadow);
}
@media (prefers-reduced-motion: reduce) {
[data-month-grid-prev-button],
[data-month-grid-next-button],
[data-month-grid-cell] {
transition: none;
}
}

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

@ -0,0 +1,43 @@
import type {
MonthGridProps as SomaMonthGridProps,
MonthGridHeaderProps as SomaMonthGridHeaderProps,
MonthGridHeadingProps as SomaMonthGridHeadingProps,
MonthGridPrevButtonProps as SomaMonthGridPrevButtonProps,
MonthGridNextButtonProps as SomaMonthGridNextButtonProps,
MonthGridGridProps as SomaMonthGridGridProps,
MonthGridCellProps as SomaMonthGridCellProps,
MonthGridProviderSnippetProps,
MonthGridHeadingSnippetProps,
MonthGridCellSnippetProps,
MonthValue,
MonthPlaceholder
} from '$soma/components/month-grid'
import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types'
export type MonthGridSize = Extract<Size, 'sm' | 'md' | 'lg'>
export type MonthGridVariant = ControlVariant
export type MonthGridColor = ColorRole
export type MonthGridProps = SomaMonthGridProps & {
/** Visual size for cells and nav buttons. @default 'md' */
size?: ResponsiveProp<MonthGridSize>
/** Visual treatment of the surface. @default 'surface' */
variant?: MonthGridVariant
/** Intent palette used for selected month and focus affordances. @default 'primary' */
color?: MonthGridColor
}
export type MonthGridHeaderProps = SomaMonthGridHeaderProps
export type MonthGridHeadingProps = SomaMonthGridHeadingProps
export type MonthGridPrevButtonProps = SomaMonthGridPrevButtonProps
export type MonthGridNextButtonProps = SomaMonthGridNextButtonProps
export type MonthGridGridProps = SomaMonthGridGridProps
export type MonthGridCellProps = SomaMonthGridCellProps
export type {
MonthGridProviderSnippetProps,
MonthGridHeadingSnippetProps,
MonthGridCellSnippetProps,
MonthValue,
MonthPlaceholder
}

@ -0,0 +1,36 @@
# Eidos YearGrid
Visual wrapper for Soma YearGrid. Standalone year-picker primitive — a paged
grid of years (default `decadeSize=12`). Reuses calendar tokens (`--calendar-*`)
for consistent chrome with the inline Calendar.
```svelte
<script lang="ts">
import { YearGrid } from '$uix/eidos/components/year-grid';
let value = $state<{ year: number } | undefined>();
</script>
<YearGrid bind:value size="md" variant="surface" color="primary">
{#snippet children({ years, headingValue })}
<YearGrid.Header>
<YearGrid.PrevButton>‹</YearGrid.PrevButton>
<YearGrid.Heading />
<YearGrid.NextButton>›</YearGrid.NextButton>
</YearGrid.Header>
<YearGrid.Grid>
{#each years as year (year)}
<YearGrid.Cell {year} />
{/each}
</YearGrid.Grid>
{/snippet}
</YearGrid>
```
Parts: `Provider`, `Header`, `Heading`, `PrevButton`, `NextButton`, `Grid`,
`Cell`. Eidos exposes `size`, `variant`, `color` on the root.
## Sema events
- `commit-set` — `commit.set` on `cell`, `intent: 'affirm'`, sequence `post`.
- `nav-step` — `shift.navigate` on `provider`, sequence `post`.

@ -0,0 +1,46 @@
import YearGridComponent from './year-grid.svelte'
import Header from './year-grid-header.svelte'
import Heading from './year-grid-heading.svelte'
import PrevButton from './year-grid-prev-button.svelte'
import NextButton from './year-grid-next-button.svelte'
import Grid from './year-grid-grid.svelte'
import Cell from './year-grid-cell.svelte'
type YearGridNamespace = typeof YearGridComponent & {
Header: typeof Header
Heading: typeof Heading
PrevButton: typeof PrevButton
NextButton: typeof NextButton
Grid: typeof Grid
Cell: typeof Cell
}
const YearGrid = YearGridComponent as YearGridNamespace
YearGrid.Header = Header
YearGrid.Heading = Heading
YearGrid.PrevButton = PrevButton
YearGrid.NextButton = NextButton
YearGrid.Grid = Grid
YearGrid.Cell = Cell
export { YearGrid }
export default YearGrid
export type {
YearGridProps,
YearGridHeaderProps as HeaderProps,
YearGridHeadingProps as HeadingProps,
YearGridPrevButtonProps as PrevButtonProps,
YearGridNextButtonProps as NextButtonProps,
YearGridGridProps as GridProps,
YearGridCellProps as CellProps,
YearGridProviderSnippetProps,
YearGridHeadingSnippetProps,
YearGridCellSnippetProps,
YearGridSize,
YearGridVariant,
YearGridColor,
YearValue,
YearPlaceholder
} from './types'

@ -0,0 +1,43 @@
import type {
YearGridProps as SomaYearGridProps,
YearGridHeaderProps as SomaYearGridHeaderProps,
YearGridHeadingProps as SomaYearGridHeadingProps,
YearGridPrevButtonProps as SomaYearGridPrevButtonProps,
YearGridNextButtonProps as SomaYearGridNextButtonProps,
YearGridGridProps as SomaYearGridGridProps,
YearGridCellProps as SomaYearGridCellProps,
YearGridProviderSnippetProps,
YearGridHeadingSnippetProps,
YearGridCellSnippetProps,
YearValue,
YearPlaceholder
} from '$soma/components/year-grid'
import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types'
export type YearGridSize = Extract<Size, 'sm' | 'md' | 'lg'>
export type YearGridVariant = ControlVariant
export type YearGridColor = ColorRole
export type YearGridProps = SomaYearGridProps & {
/** Visual size for cells and nav buttons. @default 'md' */
size?: ResponsiveProp<YearGridSize>
/** Visual treatment of the surface. @default 'surface' */
variant?: YearGridVariant
/** Intent palette used for selected year and focus affordances. @default 'primary' */
color?: YearGridColor
}
export type YearGridHeaderProps = SomaYearGridHeaderProps
export type YearGridHeadingProps = SomaYearGridHeadingProps
export type YearGridPrevButtonProps = SomaYearGridPrevButtonProps
export type YearGridNextButtonProps = SomaYearGridNextButtonProps
export type YearGridGridProps = SomaYearGridGridProps
export type YearGridCellProps = SomaYearGridCellProps
export type {
YearGridProviderSnippetProps,
YearGridHeadingSnippetProps,
YearGridCellSnippetProps,
YearValue,
YearPlaceholder
}

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

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

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

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

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

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

@ -0,0 +1,265 @@
/* YearGrid — reuses calendar tokens for visual consistency. */
[data-year-grid] {
--_year-grid-padding: var(--calendar-padding-md);
--_year-grid-control-size: var(--calendar-control-size-md);
--_year-grid-cell-size: var(--calendar-day-size-md);
--_year-grid-font-size: var(--calendar-font-size-md);
--_year-grid-accent-solid: var(--color-primary-solid);
--_year-grid-accent-solid-hover: var(--color-primary-solid-hover);
--_year-grid-accent-track: var(--color-primary-track);
--_year-grid-accent-border: var(--color-primary-border);
--_year-grid-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(--_year-grid-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(--_year-grid-font-size);
line-height: var(--calendar-line-height);
}
[data-year-grid][data-size='sm'] {
--_year-grid-padding: var(--calendar-padding-sm);
--_year-grid-control-size: var(--calendar-control-size-sm);
--_year-grid-cell-size: var(--calendar-day-size-sm);
--_year-grid-font-size: var(--calendar-font-size-sm);
}
[data-year-grid][data-size='lg'] {
--_year-grid-padding: var(--calendar-padding-lg);
--_year-grid-control-size: var(--calendar-control-size-lg);
--_year-grid-cell-size: var(--calendar-day-size-lg);
--_year-grid-font-size: var(--calendar-font-size-lg);
}
[data-year-grid][data-variant='outline'] {
background: var(--calendar-outline-bg);
box-shadow: var(--calendar-outline-shadow);
}
[data-year-grid][data-variant='ghost'] {
padding: 0;
border-color: transparent;
background: transparent;
box-shadow: none;
}
[data-year-grid][data-color='secondary'] {
--_year-grid-accent-solid: var(--color-secondary-solid);
--_year-grid-accent-solid-hover: var(--color-secondary-solid-hover);
--_year-grid-accent-track: var(--color-secondary-track);
--_year-grid-accent-border: var(--color-secondary-border);
--_year-grid-accent-text: var(--color-secondary-text);
}
[data-year-grid][data-color='neutral'] {
--_year-grid-accent-solid: var(--color-neutral-solid);
--_year-grid-accent-solid-hover: var(--color-neutral-solid-hover);
--_year-grid-accent-track: var(--color-neutral-track);
--_year-grid-accent-border: var(--color-neutral-border);
--_year-grid-accent-text: var(--color-neutral-text);
}
[data-year-grid][data-color='affirm'] {
--_year-grid-accent-solid: var(--color-affirm-solid);
--_year-grid-accent-solid-hover: var(--color-affirm-solid-hover);
--_year-grid-accent-track: var(--color-affirm-track);
--_year-grid-accent-border: var(--color-affirm-border);
--_year-grid-accent-text: var(--color-affirm-text);
}
[data-year-grid][data-color='fulfill'] {
--_year-grid-accent-solid: var(--color-fulfill-solid);
--_year-grid-accent-solid-hover: var(--color-fulfill-solid-hover);
--_year-grid-accent-track: var(--color-fulfill-track);
--_year-grid-accent-border: var(--color-fulfill-border);
--_year-grid-accent-text: var(--color-fulfill-text);
}
[data-year-grid][data-color='risk'] {
--_year-grid-accent-solid: var(--color-risk-solid);
--_year-grid-accent-solid-hover: var(--color-risk-solid-hover);
--_year-grid-accent-track: var(--color-risk-track);
--_year-grid-accent-border: var(--color-risk-border);
--_year-grid-accent-text: var(--color-risk-text);
}
[data-year-grid][data-color='threat'] {
--_year-grid-accent-solid: var(--color-threat-solid);
--_year-grid-accent-solid-hover: var(--color-threat-solid-hover);
--_year-grid-accent-track: var(--color-threat-track);
--_year-grid-accent-border: var(--color-threat-border);
--_year-grid-accent-text: var(--color-threat-text);
}
[data-year-grid][data-color='loss'] {
--_year-grid-accent-solid: var(--color-loss-solid);
--_year-grid-accent-solid-hover: var(--color-loss-solid-hover);
--_year-grid-accent-track: var(--color-loss-track);
--_year-grid-accent-border: var(--color-loss-border);
--_year-grid-accent-text: var(--color-loss-text);
}
[data-year-grid-header] {
display: grid;
grid-template-columns:
var(--_year-grid-control-size)
minmax(0, 1fr)
var(--_year-grid-control-size);
align-items: center;
gap: var(--calendar-header-gap);
inline-size: 100%;
min-inline-size: 0;
}
[data-year-grid-heading] {
min-inline-size: 0;
color: var(--calendar-heading-color);
font-size: var(--calendar-heading-font-size);
font-weight: var(--calendar-heading-font-weight);
text-align: center;
}
[data-year-grid-prev-button],
[data-year-grid-next-button] {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
inline-size: var(--_year-grid-control-size);
block-size: var(--_year-grid-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-year-grid-prev-button]:hover,
[data-year-grid-next-button]:hover {
border-color: var(--calendar-control-border-hover);
background: var(--calendar-control-bg-hover);
color: var(--calendar-control-color-hover);
}
[data-year-grid-prev-button]:focus-visible,
[data-year-grid-next-button]:focus-visible,
[data-year-grid-cell]:focus-visible {
outline: none;
box-shadow: var(--calendar-focus-shadow);
}
[data-year-grid-prev-button][data-disabled],
[data-year-grid-next-button][data-disabled] {
cursor: not-allowed;
opacity: var(--calendar-disabled-opacity);
}
[data-year-grid-grid] {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--calendar-grid-gap);
inline-size: max-content;
max-inline-size: 100%;
}
[data-year-grid-cell] {
display: inline-flex;
align-items: center;
justify-content: center;
min-inline-size: calc(var(--_year-grid-cell-size) * 1.8);
block-size: var(--_year-grid-cell-size);
padding-inline: var(--calendar-select-px, 8px);
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-year-grid-cell]:hover {
border-color: var(--calendar-day-border-hover);
background: var(--calendar-day-bg-hover);
color: var(--calendar-day-color-hover);
}
[data-year-grid-cell][data-selected] {
border-color: var(--_year-grid-accent-solid);
background: var(--_year-grid-accent-solid);
color: var(--calendar-day-selected-color);
}
[data-year-grid-cell][data-selected]:hover {
border-color: var(--_year-grid-accent-solid-hover);
background: var(--_year-grid-accent-solid-hover);
}
[data-year-grid-cell][data-focused]:not([data-selected]) {
border-color: var(--_year-grid-accent-border);
background: var(--_year-grid-accent-track);
color: var(--_year-grid-accent-text);
}
[data-year-grid-cell][data-current]: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-year-grid-cell][data-disabled] {
cursor: not-allowed;
opacity: var(--calendar-disabled-opacity);
}
[data-year-grid][data-disabled] {
opacity: var(--calendar-disabled-opacity);
}
[data-year-grid][data-readonly] [data-year-grid-cell]:not([data-selected]) {
cursor: default;
}
[data-year-grid][data-readonly] [data-year-grid-cell]:not([data-selected]):hover {
background: var(--calendar-day-bg);
border-color: var(--calendar-day-border);
color: var(--calendar-day-color);
}
[data-year-grid][data-event],
[data-year-grid-cell][data-event],
[data-year-grid-prev-button][data-event],
[data-year-grid-next-button][data-event] {
box-shadow: var(--calendar-event-shadow);
}
@media (prefers-reduced-motion: reduce) {
[data-year-grid-prev-button],
[data-year-grid-next-button],
[data-year-grid-cell] {
transition: none;
}
}

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

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

@ -0,0 +1,163 @@
import type { Morfo } from '../types';
import { v } from '../types';
export const monthGridMorfo = {
name: 'MonthGrid',
kebab: 'month-grid',
scope: ['soma', 'sema'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/',
texts: {
label: '#?components.month-grid.label|Month grid',
prevYear: '#?common.month-grid.prev-year|Previous year',
nextYear: '#?common.month-grid.next-year|Next year'
},
events: [
{
name: 'commit-set',
semantic: {
family: 'commit',
verb: 'set',
target: v.partRef('cell'),
intent: 'affirm',
sequence: 'post'
}
},
{
name: 'nav-step',
semantic: {
family: 'shift',
verb: 'navigate',
target: v.partRef('provider'),
sequence: 'post'
}
}
],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
role: 'application',
optional: false,
data: [
{ attr: 'data-readonly', severity: 'optional' },
{ attr: 'data-disabled', severity: 'optional' }
],
aria: [
{ attr: 'aria-label', value: v.propRef('ariaLabel'), severity: 'recommended' },
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
],
keyboard: [
{ key: 'ArrowRight', action: 'next-month' },
{ key: 'ArrowLeft', action: 'prev-month' },
{ key: 'ArrowDown', action: 'next-row' },
{ key: 'ArrowUp', action: 'prev-row' },
{ key: 'PageUp', action: 'prev-year' },
{ key: 'PageDown', action: 'next-year' },
{ key: 'Home', action: 'first-month' },
{ key: 'End', action: 'last-month' },
{ key: 'Enter', action: 'select' },
{ key: ' ', action: 'select' }
]
},
{
name: 'Header',
kebab: 'header',
archetype: 'header',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
name: 'Heading',
kebab: 'heading',
archetype: 'header',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
name: 'PrevButton',
kebab: 'prev-button',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{ attr: 'type', value: v.literal('button') },
{
attr: 'aria-label',
value: v.commonRef('month-grid.prev-year', 'Previous year'),
severity: 'recommended'
},
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
]
},
{
name: 'NextButton',
kebab: 'next-button',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{ attr: 'type', value: v.literal('button') },
{
attr: 'aria-label',
value: v.commonRef('month-grid.next-year', 'Next year'),
severity: 'recommended'
},
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
]
},
{
name: 'Grid',
kebab: 'grid',
kind: 'public',
defaultElement: 'div',
role: 'grid',
optional: false,
data: [],
aria: [
{
attr: 'aria-labelledby',
value: v.partRef('heading'),
condition: { when: 'part-present', part: 'heading' },
severity: 'recommended'
},
{ attr: 'aria-readonly', value: v.propRef('readonly'), severity: 'optional' },
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
]
},
{
name: 'Cell',
kebab: 'cell',
archetype: 'item',
kind: 'public',
defaultElement: 'button',
role: 'gridcell',
optional: false,
data: [
{ attr: 'data-value' },
{ attr: 'data-selected', severity: 'optional' },
{ attr: 'data-current', severity: 'optional' },
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-focused', severity: 'optional' }
],
aria: [
{ attr: 'aria-selected', value: v.propRef('selected'), severity: 'optional' },
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
]
}
]
} as const satisfies Morfo;

@ -0,0 +1,163 @@
import type { Morfo } from '../types';
import { v } from '../types';
export const yearGridMorfo = {
name: 'YearGrid',
kebab: 'year-grid',
scope: ['soma', 'sema'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/',
texts: {
label: '#?components.year-grid.label|Year grid',
prevPage: '#?common.year-grid.prev-page|Previous years',
nextPage: '#?common.year-grid.next-page|Next years'
},
events: [
{
name: 'commit-set',
semantic: {
family: 'commit',
verb: 'set',
target: v.partRef('cell'),
intent: 'affirm',
sequence: 'post'
}
},
{
name: 'nav-step',
semantic: {
family: 'shift',
verb: 'navigate',
target: v.partRef('provider'),
sequence: 'post'
}
}
],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
role: 'application',
optional: false,
data: [
{ attr: 'data-readonly', severity: 'optional' },
{ attr: 'data-disabled', severity: 'optional' }
],
aria: [
{ attr: 'aria-label', value: v.propRef('ariaLabel'), severity: 'recommended' },
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
],
keyboard: [
{ key: 'ArrowRight', action: 'next-year' },
{ key: 'ArrowLeft', action: 'prev-year' },
{ key: 'ArrowDown', action: 'next-row' },
{ key: 'ArrowUp', action: 'prev-row' },
{ key: 'PageUp', action: 'prev-page' },
{ key: 'PageDown', action: 'next-page' },
{ key: 'Home', action: 'first-year' },
{ key: 'End', action: 'last-year' },
{ key: 'Enter', action: 'select' },
{ key: ' ', action: 'select' }
]
},
{
name: 'Header',
kebab: 'header',
archetype: 'header',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
name: 'Heading',
kebab: 'heading',
archetype: 'header',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
name: 'PrevButton',
kebab: 'prev-button',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{ attr: 'type', value: v.literal('button') },
{
attr: 'aria-label',
value: v.commonRef('year-grid.prev-page', 'Previous years'),
severity: 'recommended'
},
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
]
},
{
name: 'NextButton',
kebab: 'next-button',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{ attr: 'type', value: v.literal('button') },
{
attr: 'aria-label',
value: v.commonRef('year-grid.next-page', 'Next years'),
severity: 'recommended'
},
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
]
},
{
name: 'Grid',
kebab: 'grid',
kind: 'public',
defaultElement: 'div',
role: 'grid',
optional: false,
data: [],
aria: [
{
attr: 'aria-labelledby',
value: v.partRef('heading'),
condition: { when: 'part-present', part: 'heading' },
severity: 'recommended'
},
{ attr: 'aria-readonly', value: v.propRef('readonly'), severity: 'optional' },
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
]
},
{
name: 'Cell',
kebab: 'cell',
archetype: 'item',
kind: 'public',
defaultElement: 'button',
role: 'gridcell',
optional: false,
data: [
{ attr: 'data-value' },
{ attr: 'data-selected', severity: 'optional' },
{ attr: 'data-current', severity: 'optional' },
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-focused', severity: 'optional' }
],
aria: [
{ attr: 'aria-selected', value: v.propRef('selected'), severity: 'optional' },
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
]
}
]
} as const satisfies Morfo;

@ -30,6 +30,7 @@ export * as LinkPreview from './link-preview';
export * as Listbox from './listbox';
export * as Menubar from './menubar';
export * as Meter from './meter';
export * as MonthGrid from './month-grid';
export * as NavigationMenu from './navigation-menu';
export * as NumberField from './number-field';
export * as Pagination from './pagination';
@ -63,3 +64,4 @@ export * as TreeGrid from './tree-grid';
export * as TreeView from './tree-view';
export * as VirtualGrid from './virtual-grid';
export * as VirtualList from './virtual-list';
export * as YearGrid from './year-grid';

@ -0,0 +1,39 @@
# Soma MonthGrid
Standalone month-picker primitive: 12 cells (Jan..Dec) for a single year,
prev/next year navigation, keyboard nav and morfo-declared semantic events.
Independent of `Calendar` and `DatePicker`.
## Composition
```svelte
<MonthGrid.Provider bind:value bind:placeholder>
<MonthGrid.Header>
<MonthGrid.PrevButton>&lt;</MonthGrid.PrevButton>
<MonthGrid.Heading />
<MonthGrid.NextButton>&gt;</MonthGrid.NextButton>
</MonthGrid.Header>
<MonthGrid.Grid>
{#each months as month (month)}
<MonthGrid.Cell {month} />
{/each}
</MonthGrid.Grid>
</MonthGrid.Provider>
```
## Events
| Event | Family | Target | Intent | When |
| ------------ | -------- | ------ | ------ | ----------------------------------- |
| `commit-set` | `commit` | `cell` | affirm | A month cell is clicked or Enter'd. |
| `nav-step` | `shift` | `provider` | - | Prev/Next year or keyboard PageUp/PageDown. |
## Keyboard
| Key | Action |
| -------------------- | ------------------------------------------- |
| Arrow Left / Right | ±1 month (RTL-flipped horizontally) |
| Arrow Up / Down | ±3 months (row width) |
| Home / End | First / last month of year |
| PageUp / PageDown | ±1 year (also moves the placeholder) |
| Enter / Space | Select focused month |

@ -0,0 +1,41 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { MonthGridCellProvider } from '../month-grid-provider.svelte';
import type { MonthGridCellProps, MonthGridCellSnippetProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'month-grid-cell'),
month,
children,
child,
...restProps
}: MonthGridCellProps = $props();
const state = MonthGridCellProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
month: readableActive(() => month)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ ...state.snippetProps, props: mergedProps })}
{:else}
<button {...mergedProps}>
{#if children}
{@render children(state.snippetProps as MonthGridCellSnippetProps)}
{:else}
{state.snippetProps.label}
{/if}
</button>
{/if}

@ -0,0 +1,37 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { MonthGridGridProvider } from '../month-grid-provider.svelte';
import type { MonthGridGridProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'month-grid-grid'),
labelledBy,
children,
child,
...restProps
}: MonthGridGridProps = $props();
const state = MonthGridGridProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
labelledBy: readableActive(() => labelledBy)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { MonthGridHeaderProvider } from '../month-grid-provider.svelte';
import type { MonthGridHeaderProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'month-grid-header'),
children,
child,
...restProps
}: MonthGridHeaderProps = $props();
const state = MonthGridHeaderProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,40 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { MonthGridHeadingProvider } from '../month-grid-provider.svelte';
import type { MonthGridHeadingProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'month-grid-heading'),
children,
child,
...restProps
}: MonthGridHeadingProps = $props();
const state = MonthGridHeadingProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
const snippetProps = $derived({ headingValue: state.headingValue });
</script>
{#if child}
{@render child({ ...snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{#if children}
{@render children(snippetProps)}
{:else}
{state.headingValue}
{/if}
</div>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { MonthGridNextButtonProvider } from '../month-grid-provider.svelte';
import type { MonthGridNextButtonProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'month-grid-next-button'),
children,
child,
...restProps
}: MonthGridNextButtonProps = $props();
const state = MonthGridNextButtonProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { MonthGridPrevButtonProvider } from '../month-grid-provider.svelte';
import type { MonthGridPrevButtonProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'month-grid-prev-button'),
children,
child,
...restProps
}: MonthGridPrevButtonProps = $props();
const state = MonthGridPrevButtonProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,80 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import {
MonthGridProvider,
resolveInitialMonthPlaceholder
} from '../month-grid-provider.svelte';
import type { MonthGridProps } from '../types';
import { Soma } from '../../../core/soma.svelte';
const uid = $props.id();
const soma = Soma.get();
let {
ref = $bindable(null),
id = createId(uid, 'month-grid'),
value = $bindable(),
onValueChange = () => {},
placeholder = $bindable(),
onPlaceholderChange = () => {},
minValue,
maxValue,
disabled = false,
readonly = false,
dir,
locale,
monthFormat = 'short',
ariaLabel,
children,
child,
...restProps
}: MonthGridProps = $props();
const state = MonthGridProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
placeholder: writableActive(
() => resolveInitialMonthPlaceholder(placeholder, value),
(v) => {
placeholder = v;
onPlaceholderChange(v);
}
),
value: writableActive(
() => value,
(v) => {
value = v;
onValueChange(v);
}
),
minValue: readableActive(() => minValue),
maxValue: readableActive(() => maxValue),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
locale: readableActive(() => locale ?? ''),
monthFormat: readableActive(() => monthFormat),
ariaLabel: readableActive(() => ariaLabel)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
const snippetProps = $derived({
months: state.months,
year: state.placeholderYear,
headingValue: state.headingValue
});
</script>
{#if child}
{@render child({ ...snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(snippetProps)}
</div>
{/if}

@ -0,0 +1,22 @@
export { default as Provider } from './components/month-grid.svelte';
export { default as Header } from './components/month-grid-header.svelte';
export { default as Heading } from './components/month-grid-heading.svelte';
export { default as PrevButton } from './components/month-grid-prev-button.svelte';
export { default as NextButton } from './components/month-grid-next-button.svelte';
export { default as Grid } from './components/month-grid-grid.svelte';
export { default as Cell } from './components/month-grid-cell.svelte';
export type {
MonthValue,
MonthPlaceholder,
MonthGridProps,
MonthGridHeaderProps,
MonthGridHeadingProps,
MonthGridPrevButtonProps,
MonthGridNextButtonProps,
MonthGridGridProps,
MonthGridCellProps,
MonthGridProviderSnippetProps,
MonthGridHeadingSnippetProps,
MonthGridCellSnippetProps
} from './types';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,6 @@
/** Idlangref constants for MonthGrid. Values are fallback defaults. */
export const MONTH_GRID_LANGS = {
LABEL: '#?components.month-grid.label|Month grid',
PREV_YEAR: '#?common.month-grid.prev-year|Previous year',
NEXT_YEAR: '#?common.month-grid.next-year|Next year'
} as const;

@ -0,0 +1,522 @@
import { context, type WithRefOpts } from '../../provider';
import { boolToEmptyStrOrUndef, boolToStr } from '$adom';
import { state, type ActiveProps, type StateProps } from '$libs/reactive';
import type { SomaKeyboardEvent, SomaMouseEvent, OnChangeFn } from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { MONTH_GRID_LANGS } from './langs';
import { today, getLocalTimeZone } from '$libs/days';
import { monthGridMorfo } from '../../../morfo/components/month-grid';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import type { MonthValue, MonthPlaceholder } from './types';
// ── Helpers ──────────────────────────────────────────────────────────────────
function compareMonth(a: MonthValue, b: MonthValue): number {
if (a.year !== b.year) return a.year - b.year;
return a.month - b.month;
}
function sameMonth(a: MonthValue | undefined, b: MonthValue | undefined): boolean {
if (!a || !b) return a === b;
return a.year === b.year && a.month === b.month;
}
function inBounds(
value: MonthValue,
min: MonthValue | undefined,
max: MonthValue | undefined
): boolean {
if (min && compareMonth(value, min) < 0) return false;
if (max && compareMonth(value, max) > 0) return false;
return true;
}
function resolveInitialMonthPlaceholder(
placeholder: MonthPlaceholder | undefined,
value: MonthValue | undefined
): MonthPlaceholder {
if (placeholder) return placeholder;
if (value) return { year: value.year };
const t = today(getLocalTimeZone());
return { year: t.year };
}
// ── Root provider ────────────────────────────────────────────────────────────
export interface MonthGridOpts
extends
WithRefOpts,
StateProps<{
placeholder: MonthPlaceholder;
value: MonthValue | undefined;
}>,
ActiveProps<{
minValue: MonthValue | undefined;
maxValue: MonthValue | undefined;
disabled: boolean;
readonly: boolean;
dir: 'ltr' | 'rtl';
locale: string;
monthFormat: 'long' | 'short' | 'narrow';
ariaLabel: string | undefined;
}> {
onValueChange?: OnChangeFn<MonthValue | undefined>;
}
export class MonthGridProvider {
readonly opts: MonthGridOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
static readonly ctx = context<MonthGridProvider>('MonthGrid');
static get(): MonthGridProvider | undefined {
return this.ctx.getOr(undefined) as MonthGridProvider | undefined;
}
static require(): MonthGridProvider {
return this.ctx.get();
}
static create(opts: MonthGridOpts) {
return new MonthGridProvider(opts);
}
/** Heading id exposed so Grid can wire aria-labelledby. */
headingId = state('');
/** Currently focused month (for roving tabindex). */
focusedMonth = $state(1);
private constructor(opts: MonthGridOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(monthGridMorfo, {});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: MonthGridProvider.ctx
});
// Initial focused month follows selection or today.
const initial = this.opts.value.current?.month ?? today(getLocalTimeZone()).month;
this.focusedMonth = initial;
}
// ── Derived ────────────────────────────────────────────────────────────────
readonly effectiveLocale = $derived.by(
() => this.opts.locale.current || this.soma.langs.getLocale() || 'en'
);
readonly placeholderYear = $derived.by(() => this.opts.placeholder.current.year);
readonly months: number[] = $derived.by(() => Array.from({ length: 12 }, (_, i) => i + 1));
readonly headingValue = $derived.by(() =>
new Intl.DateTimeFormat(this.effectiveLocale, { year: 'numeric' }).format(
new Date(Date.UTC(this.placeholderYear, 5, 15))
)
);
readonly todayMonth: MonthValue = $derived.by(() => {
const t = today(getLocalTimeZone());
return { year: t.year, month: t.month };
});
readonly fullLabel = $derived.by(() => {
const base =
this.opts.ariaLabel.current ??
this.soma.langs.ts(MONTH_GRID_LANGS.LABEL) ??
'Month grid';
return `${base}, ${this.headingValue}`;
});
formatMonth(month: number): string {
const fmt = this.opts.monthFormat.current ?? 'short';
const anchor = new Date(Date.UTC(2024, month - 1, 15));
return new Intl.DateTimeFormat(this.effectiveLocale, { month: fmt }).format(anchor);
}
// ── Bound checks ──────────────────────────────────────────────────────────
isDisabledMonth(month: number): boolean {
if (this.opts.disabled.current) return true;
return !inBounds(
{ year: this.placeholderYear, month },
this.opts.minValue.current,
this.opts.maxValue.current
);
}
isSelectedMonth(month: number): boolean {
const v = this.opts.value.current;
if (!v) return false;
return v.year === this.placeholderYear && v.month === month;
}
isCurrentMonth(month: number): boolean {
const t = this.todayMonth;
return t.year === this.placeholderYear && t.month === month;
}
// ── Navigation ────────────────────────────────────────────────────────────
private setPlaceholder(next: MonthPlaceholder, target?: HTMLElement): void {
if (this.opts.placeholder.current.year === next.year) return;
this.opts.placeholder.current = next;
void this.runtime.trigger('nav-step', target ? { fallbackTarget: target } : undefined);
}
prevPage(target?: HTMLElement): void {
this.setPlaceholder({ year: this.placeholderYear - 1 }, target);
}
nextPage(target?: HTMLElement): void {
this.setPlaceholder({ year: this.placeholderYear + 1 }, target);
}
// ── Selection ─────────────────────────────────────────────────────────────
select(month: number, target?: HTMLElement): void {
if (this.opts.readonly.current || this.opts.disabled.current) return;
if (this.isDisabledMonth(month)) return;
const next: MonthValue = { year: this.placeholderYear, month };
const prev = this.opts.value.current;
if (sameMonth(prev, next)) return;
this.opts.value.current = next;
void this.runtime.trigger('commit-set', target ? { fallbackTarget: target } : undefined);
}
// ── Keyboard ──────────────────────────────────────────────────────────────
handleCellKeydown(e: SomaKeyboardEvent<HTMLElement>, month: number): boolean {
const { nextKey: horizNext, prevKey: horizPrev } = getDirectionalKeys(
this.opts.dir.current,
'horizontal'
);
let nextMonth: number | undefined;
let nextYearStep = 0;
if (e.key === horizNext) nextMonth = month + 1;
else if (e.key === horizPrev) nextMonth = month - 1;
else if (e.key === KEYS.ARROW_DOWN) nextMonth = month + 3;
else if (e.key === KEYS.ARROW_UP) nextMonth = month - 3;
else if (e.key === KEYS.HOME) nextMonth = 1;
else if (e.key === KEYS.END) nextMonth = 12;
else if (e.key === KEYS.PAGE_UP) {
nextYearStep = -1;
nextMonth = month;
} else if (e.key === KEYS.PAGE_DOWN) {
nextYearStep = 1;
nextMonth = month;
} else if (e.key === KEYS.ENTER || e.key === ' ') {
e.preventDefault();
this.select(month, e.currentTarget as HTMLElement);
return true;
} else {
return false;
}
e.preventDefault();
// Clamp month and overflow into year steps.
if (nextMonth! < 1) {
const overflow = Math.ceil((1 - nextMonth!) / 12);
nextYearStep -= overflow;
nextMonth = nextMonth! + overflow * 12;
} else if (nextMonth! > 12) {
const overflow = Math.ceil((nextMonth! - 12) / 12);
nextYearStep += overflow;
nextMonth = nextMonth! - overflow * 12;
}
if (nextYearStep !== 0) {
this.setPlaceholder(
{ year: this.placeholderYear + nextYearStep },
e.currentTarget as HTMLElement
);
}
this.focusedMonth = nextMonth!;
queueMicrotask(() => {
const root = this.opts.ref.current;
if (!root) return;
const el = root.querySelector<HTMLElement>(
`[data-month-grid-cell][data-value="${this.placeholderYear}-${nextMonth!}"]`
);
this.soma.dom.focus(el);
});
return true;
}
// ── Bound prev/next ──────────────────────────────────────────────────────
readonly isPrevButtonDisabled = $derived.by(() => {
if (this.opts.disabled.current) return true;
const min = this.opts.minValue.current;
if (!min) return false;
// Last selectable month of the previous year.
return compareMonth({ year: this.placeholderYear - 1, month: 12 }, min) < 0;
});
readonly isNextButtonDisabled = $derived.by(() => {
if (this.opts.disabled.current) return true;
const max = this.opts.maxValue.current;
if (!max) return false;
// First selectable month of the next year.
return compareMonth({ year: this.placeholderYear + 1, month: 1 }, max) > 0;
});
// ── Props ─────────────────────────────────────────────────────────────────
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'application' as const,
dir: this.opts.dir.current,
'aria-label': this.fullLabel,
'aria-disabled': boolToStr(this.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.opts.readonly.current),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current)
} as const)
);
}
// ── Header ───────────────────────────────────────────────────────────────────
export class MonthGridHeaderProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: WithRefOpts) {
return new MonthGridHeaderProvider(opts);
}
private constructor(opts: WithRefOpts) {
this.opts = opts;
const provider = MonthGridProvider.require();
this.runtimePart = provider.runtime.part('header', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props } as const)
);
}
// ── Heading ──────────────────────────────────────────────────────────────────
export class MonthGridHeadingProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: MonthGridProvider;
static create(opts: WithRefOpts) {
return new MonthGridHeadingProvider(opts);
}
private constructor(opts: WithRefOpts) {
this.opts = opts;
this.provider = MonthGridProvider.require();
this.runtimePart = this.provider.runtime.part('heading', {
id: opts.id,
ref: opts.ref,
owner: this
});
this.provider.headingId.current = opts.id.current;
}
readonly headingValue = $derived.by(() => this.provider.headingValue);
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props } as const)
);
}
// ── Prev / Next Buttons ─────────────────────────────────────────────────────
abstract class MonthGridPaginationButtonProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: MonthGridProvider;
protected readonly labelRef: string;
protected abstract disabled(): boolean;
protected abstract trigger(target?: HTMLElement): void;
protected constructor(opts: WithRefOpts, part: 'prev-button' | 'next-button', labelRef: string) {
this.opts = opts;
this.provider = MonthGridProvider.require();
this.runtimePart = this.provider.runtime.part(part, {
id: opts.id,
ref: opts.ref,
owner: this
});
this.labelRef = labelRef;
}
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.disabled()) return;
this.trigger(e.currentTarget as HTMLElement);
};
readonly props = $derived.by(() => {
const disabled = this.disabled();
return this.runtimePart.assert({
...this.runtimePart.props,
type: 'button' as const,
'aria-label': this.provider.soma.langs.ts(this.labelRef),
'aria-disabled': boolToStr(disabled),
'data-disabled': boolToEmptyStrOrUndef(disabled),
disabled,
onclick: this.onclick
} as const);
});
}
export class MonthGridPrevButtonProvider extends MonthGridPaginationButtonProvider {
static create(opts: WithRefOpts) {
return new MonthGridPrevButtonProvider(opts);
}
private constructor(opts: WithRefOpts) {
super(opts, 'prev-button', MONTH_GRID_LANGS.PREV_YEAR);
}
protected disabled() {
return this.provider.isPrevButtonDisabled;
}
protected trigger(target?: HTMLElement) {
this.provider.prevPage(target);
}
}
export class MonthGridNextButtonProvider extends MonthGridPaginationButtonProvider {
static create(opts: WithRefOpts) {
return new MonthGridNextButtonProvider(opts);
}
private constructor(opts: WithRefOpts) {
super(opts, 'next-button', MONTH_GRID_LANGS.NEXT_YEAR);
}
protected disabled() {
return this.provider.isNextButtonDisabled;
}
protected trigger(target?: HTMLElement) {
this.provider.nextPage(target);
}
}
// ── Grid ─────────────────────────────────────────────────────────────────────
export interface MonthGridGridOpts
extends
WithRefOpts,
ActiveProps<{
labelledBy: string | undefined;
}> {}
export class MonthGridGridProvider {
readonly opts: MonthGridGridOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: MonthGridProvider;
static create(opts: MonthGridGridOpts) {
return new MonthGridGridProvider(opts);
}
private constructor(opts: MonthGridGridOpts) {
this.opts = opts;
this.provider = MonthGridProvider.require();
this.runtimePart = this.provider.runtime.part('grid', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() => {
const headingId = this.opts.labelledBy.current ?? this.provider.headingId.current;
return this.runtimePart.assert({
...this.runtimePart.props,
role: 'grid' as const,
'aria-labelledby': headingId || undefined,
'aria-readonly': boolToStr(this.provider.opts.readonly.current),
'aria-disabled': boolToStr(this.provider.opts.disabled.current)
} as const);
});
}
// ── Cell ─────────────────────────────────────────────────────────────────────
export interface MonthGridCellOpts
extends WithRefOpts, ActiveProps<{ month: number }> {}
export class MonthGridCellProvider {
readonly opts: MonthGridCellOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: MonthGridProvider;
static readonly ctx = context<MonthGridCellProvider>('MonthGridCell');
static get(): MonthGridCellProvider | undefined {
return this.ctx.getOr(undefined) as MonthGridCellProvider | undefined;
}
static require(): MonthGridCellProvider {
return this.ctx.get();
}
static create(opts: MonthGridCellOpts) {
return new MonthGridCellProvider(opts);
}
private constructor(opts: MonthGridCellOpts) {
this.opts = opts;
this.provider = MonthGridProvider.require();
this.runtimePart = this.provider.runtime.part('cell', {
id: opts.id,
ref: opts.ref,
owner: this,
context: MonthGridCellProvider.ctx
});
}
readonly month = $derived.by(() => this.opts.month.current);
readonly selected = $derived.by(() => this.provider.isSelectedMonth(this.month));
readonly current = $derived.by(() => this.provider.isCurrentMonth(this.month));
readonly disabled = $derived.by(() => this.provider.isDisabledMonth(this.month));
readonly focused = $derived.by(() => this.provider.focusedMonth === this.month);
readonly label = $derived.by(() => this.provider.formatMonth(this.month));
readonly snippetProps = $derived.by(() => ({
label: this.label,
month: this.month,
selected: this.selected,
current: this.current,
disabled: this.disabled,
focused: this.focused
}));
readonly onclick = (e: SomaMouseEvent<HTMLElement>) => {
if (this.disabled) return;
this.provider.focusedMonth = this.month;
this.provider.select(this.month, e.currentTarget as HTMLElement);
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
this.provider.handleCellKeydown(e, this.month);
};
readonly props = $derived.by(() => {
const tabindex = this.focused && !this.disabled ? 0 : -1;
return this.runtimePart.assert({
...this.runtimePart.props,
role: 'gridcell' as const,
type: 'button' as const,
tabindex,
'aria-selected': boolToStr(this.selected),
'aria-disabled': boolToStr(this.disabled),
'data-selected': boolToEmptyStrOrUndef(this.selected),
'data-current': boolToEmptyStrOrUndef(this.current),
'data-disabled': boolToEmptyStrOrUndef(this.disabled),
'data-focused': boolToEmptyStrOrUndef(this.focused),
'data-value': `${this.provider.placeholderYear}-${this.month}`,
disabled: this.disabled,
onclick: this.onclick,
onkeydown: this.onkeydown
} as const);
});
}
export { resolveInitialMonthPlaceholder };

@ -0,0 +1,112 @@
import type { WithChild, Without, OnChangeFn } from '../../types';
import type {
PrimitiveButtonAttributes,
PrimitiveDivAttributes,
PrimitiveHeaderAttributes
} from '../../types';
/** Value selected by MonthGrid — a year/month pair. */
export type MonthValue = { year: number; month: number };
/** Visible page anchor. Only `year` matters; `month` is accepted but ignored. */
export type MonthPlaceholder = { year: number; month?: number };
/** Snippet props exposed by `MonthGrid.Provider`. */
export type MonthGridProviderSnippetProps = {
/** The 12 month numbers (1..12) for the visible page. */
months: number[];
/** The current placeholder year. */
year: number;
/** Locale-formatted year heading (e.g. "2026"). */
headingValue: string;
};
/** Snippet props exposed by `MonthGrid.Heading`. */
export type MonthGridHeadingSnippetProps = {
/** Locale-formatted year heading (e.g. "2026"). */
headingValue: string;
};
/** Snippet props exposed by `MonthGrid.Cell`. */
export type MonthGridCellSnippetProps = {
/** Localized month label per `monthFormat`. */
label: string;
/** The month number (1..12). */
month: number;
/** True when this cell matches the current `value`. */
selected: boolean;
/** True when this cell matches today's year + month. */
current: boolean;
/** True when outside `minValue`/`maxValue` bounds or disabled. */
disabled: boolean;
/** True when this cell holds roving focus (tabindex=0). */
focused: boolean;
};
// ── Base provider props ──────────────────────────────────────────────────────
type MonthGridBaseProps = {
/** DOM id. Auto-generated when omitted. */
id?: string;
/** Selected month. Bindable. */
value?: MonthValue;
/** Called when `value` changes. */
onValueChange?: OnChangeFn<MonthValue | undefined>;
/** Visible page anchor (year). Defaults to today's year. Bindable. */
placeholder?: MonthPlaceholder;
/** Called when `placeholder` changes (prev/next nav). */
onPlaceholderChange?: OnChangeFn<MonthPlaceholder>;
/** Earliest selectable month. Cells before it are disabled. */
minValue?: MonthValue;
/** Latest selectable month. Cells after it are disabled. */
maxValue?: MonthValue;
/** Disables the entire grid. */
disabled?: boolean;
/** Read-only grid: focusable + navigable, but selection blocked. */
readonly?: boolean;
/** Writing direction. Inverts horizontal arrow nav when `'rtl'`. */
dir?: 'ltr' | 'rtl';
/** Override the locale used to format month/year labels. */
locale?: string;
/**
* Intl month-name length used for cell labels.
* @default 'short'
*/
monthFormat?: 'long' | 'short' | 'narrow';
/** `aria-label` for the provider root. Localized when omitted. */
ariaLabel?: string;
};
export type MonthGridProps = WithChild<MonthGridBaseProps, MonthGridProviderSnippetProps> &
Without<PrimitiveDivAttributes, MonthGridBaseProps>;
// ── Child parts ──────────────────────────────────────────────────────────────
export type MonthGridHeaderProps = WithChild<{ id?: string }> &
Without<PrimitiveHeaderAttributes, {}>;
export type MonthGridHeadingProps = WithChild<{ id?: string }, MonthGridHeadingSnippetProps> &
Without<PrimitiveDivAttributes, {}>;
export type MonthGridPrevButtonProps = WithChild<{ id?: string }> &
Without<PrimitiveButtonAttributes, {}>;
export type MonthGridNextButtonProps = WithChild<{ id?: string }> &
Without<PrimitiveButtonAttributes, {}>;
export type MonthGridGridProps = WithChild<{
id?: string;
/** Explicit id of the heading that labels this grid. */
labelledBy?: string;
}> &
Without<PrimitiveDivAttributes, {}>;
export type MonthGridCellProps = WithChild<
{
id?: string;
/** The month number (1..12) this cell represents. */
month: number;
},
MonthGridCellSnippetProps
> &
Without<PrimitiveButtonAttributes, { month?: unknown }>;

@ -0,0 +1,39 @@
# Soma YearGrid
Standalone year-picker primitive: a paged grid of years (default decade size
12), prev/next page navigation, keyboard nav and morfo-declared semantic events.
Independent of `Calendar` and `DatePicker`.
## Composition
```svelte
<YearGrid.Provider bind:value bind:placeholder>
<YearGrid.Header>
<YearGrid.PrevButton>&lt;</YearGrid.PrevButton>
<YearGrid.Heading />
<YearGrid.NextButton>&gt;</YearGrid.NextButton>
</YearGrid.Header>
<YearGrid.Grid>
{#each years as year (year)}
<YearGrid.Cell {year} />
{/each}
</YearGrid.Grid>
</YearGrid.Provider>
```
## Events
| Event | Family | Target | Intent | When |
| ------------ | -------- | ------ | ------ | ------------------------------------------ |
| `commit-set` | `commit` | `cell` | affirm | A year cell is clicked or Enter'd. |
| `nav-step` | `shift` | `provider` | - | Prev/Next page or keyboard PageUp/PageDown. |
## Keyboard
| Key | Action |
| -------------------- | -------------------------------------------- |
| Arrow Left / Right | ±1 year (RTL-flipped horizontally) |
| Arrow Up / Down | ±3 years (3-col layout) |
| Home / End | First / last year of page |
| PageUp / PageDown | ±`decadeSize` years (page step) |
| Enter / Space | Select focused year |

@ -0,0 +1,41 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { YearGridCellProvider } from '../year-grid-provider.svelte';
import type { YearGridCellProps, YearGridCellSnippetProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'year-grid-cell'),
year,
children,
child,
...restProps
}: YearGridCellProps = $props();
const state = YearGridCellProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
year: readableActive(() => year)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ ...state.snippetProps, props: mergedProps })}
{:else}
<button {...mergedProps}>
{#if children}
{@render children(state.snippetProps as YearGridCellSnippetProps)}
{:else}
{state.snippetProps.label}
{/if}
</button>
{/if}

@ -0,0 +1,37 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { YearGridGridProvider } from '../year-grid-provider.svelte';
import type { YearGridGridProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'year-grid-grid'),
labelledBy,
children,
child,
...restProps
}: YearGridGridProps = $props();
const state = YearGridGridProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
labelledBy: readableActive(() => labelledBy)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { YearGridHeaderProvider } from '../year-grid-provider.svelte';
import type { YearGridHeaderProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'year-grid-header'),
children,
child,
...restProps
}: YearGridHeaderProps = $props();
const state = YearGridHeaderProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,40 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { YearGridHeadingProvider } from '../year-grid-provider.svelte';
import type { YearGridHeadingProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'year-grid-heading'),
children,
child,
...restProps
}: YearGridHeadingProps = $props();
const state = YearGridHeadingProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
const snippetProps = $derived({ headingValue: state.headingValue });
</script>
{#if child}
{@render child({ ...snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{#if children}
{@render children(snippetProps)}
{:else}
{state.headingValue}
{/if}
</div>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { YearGridNextButtonProvider } from '../year-grid-provider.svelte';
import type { YearGridNextButtonProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'year-grid-next-button'),
children,
child,
...restProps
}: YearGridNextButtonProps = $props();
const state = YearGridNextButtonProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { YearGridPrevButtonProvider } from '../year-grid-provider.svelte';
import type { YearGridPrevButtonProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'year-grid-prev-button'),
children,
child,
...restProps
}: YearGridPrevButtonProps = $props();
const state = YearGridPrevButtonProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,81 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import {
YearGridProvider,
resolveInitialYearPlaceholder
} from '../year-grid-provider.svelte';
import type { YearGridProps } from '../types';
import { Soma } from '../../../core/soma.svelte';
const uid = $props.id();
const soma = Soma.get();
let {
ref = $bindable(null),
id = createId(uid, 'year-grid'),
value = $bindable(),
onValueChange = () => {},
placeholder = $bindable(),
onPlaceholderChange = () => {},
minValue,
maxValue,
disabled = false,
readonly = false,
dir,
locale,
decadeSize = 12,
ariaLabel,
children,
child,
...restProps
}: YearGridProps = $props();
const state = YearGridProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
placeholder: writableActive(
() => resolveInitialYearPlaceholder(placeholder, value),
(v) => {
placeholder = v;
onPlaceholderChange(v);
}
),
value: writableActive(
() => value,
(v) => {
value = v;
onValueChange(v);
}
),
minValue: readableActive(() => minValue),
maxValue: readableActive(() => maxValue),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
locale: readableActive(() => locale ?? ''),
decadeSize: readableActive(() => decadeSize),
ariaLabel: readableActive(() => ariaLabel)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
const snippetProps = $derived({
years: state.years,
pageStart: state.pageStart,
pageEnd: state.pageEnd,
headingValue: state.headingValue
});
</script>
{#if child}
{@render child({ ...snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(snippetProps)}
</div>
{/if}

@ -0,0 +1,22 @@
export { default as Provider } from './components/year-grid.svelte';
export { default as Header } from './components/year-grid-header.svelte';
export { default as Heading } from './components/year-grid-heading.svelte';
export { default as PrevButton } from './components/year-grid-prev-button.svelte';
export { default as NextButton } from './components/year-grid-next-button.svelte';
export { default as Grid } from './components/year-grid-grid.svelte';
export { default as Cell } from './components/year-grid-cell.svelte';
export type {
YearValue,
YearPlaceholder,
YearGridProps,
YearGridHeaderProps,
YearGridHeadingProps,
YearGridPrevButtonProps,
YearGridNextButtonProps,
YearGridGridProps,
YearGridCellProps,
YearGridProviderSnippetProps,
YearGridHeadingSnippetProps,
YearGridCellSnippetProps
} from './types';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,6 @@
/** Idlangref constants for YearGrid. Values are fallback defaults. */
export const YEAR_GRID_LANGS = {
LABEL: '#?components.year-grid.label|Year grid',
PREV_PAGE: '#?common.year-grid.prev-page|Previous years',
NEXT_PAGE: '#?common.year-grid.next-page|Next years'
} as const;

@ -0,0 +1,114 @@
import type { WithChild, Without, OnChangeFn } from '../../types';
import type {
PrimitiveButtonAttributes,
PrimitiveDivAttributes,
PrimitiveHeaderAttributes
} from '../../types';
/** Value selected by YearGrid — a year. */
export type YearValue = { year: number };
/** Visible page anchor. Only `year` is read. */
export type YearPlaceholder = { year: number };
/** Snippet props exposed by `YearGrid.Provider`. */
export type YearGridProviderSnippetProps = {
/** The years rendered in the current page. */
years: number[];
/** First year of the visible page. */
pageStart: number;
/** Last year of the visible page (`pageStart + decadeSize - 1`). */
pageEnd: number;
/** Locale-formatted page heading (e.g. "2020–2031"). */
headingValue: string;
};
/** Snippet props exposed by `YearGrid.Heading`. */
export type YearGridHeadingSnippetProps = {
/** Locale-formatted page heading (e.g. "2020–2031"). */
headingValue: string;
};
/** Snippet props exposed by `YearGrid.Cell`. */
export type YearGridCellSnippetProps = {
/** Locale-formatted year label. */
label: string;
/** The year number this cell represents. */
year: number;
/** True when this cell matches the current `value`. */
selected: boolean;
/** True when this cell matches today's year. */
current: boolean;
/** True when outside `minValue`/`maxValue` bounds or disabled. */
disabled: boolean;
/** True when this cell holds roving focus (tabindex=0). */
focused: boolean;
};
// ── Base provider props ──────────────────────────────────────────────────────
type YearGridBaseProps = {
/** DOM id. Auto-generated when omitted. */
id?: string;
/** Selected year. Bindable. */
value?: YearValue;
/** Called when `value` changes. */
onValueChange?: OnChangeFn<YearValue | undefined>;
/** Visible page anchor (year). Defaults to today's year. Bindable. */
placeholder?: YearPlaceholder;
/** Called when `placeholder` changes (prev/next nav). */
onPlaceholderChange?: OnChangeFn<YearPlaceholder>;
/** Earliest selectable year. Cells before it are disabled. */
minValue?: YearValue;
/** Latest selectable year. Cells after it are disabled. */
maxValue?: YearValue;
/** Disables the entire grid. */
disabled?: boolean;
/** Read-only grid: focusable + navigable, but selection blocked. */
readonly?: boolean;
/** Writing direction. Inverts horizontal arrow nav when `'rtl'`. */
dir?: 'ltr' | 'rtl';
/** Override the locale used to format year labels. */
locale?: string;
/**
* Page size in years. The heading and PageUp/PageDown step by this.
* @default 12
*/
decadeSize?: number;
/** `aria-label` for the provider root. Localized when omitted. */
ariaLabel?: string;
};
export type YearGridProps = WithChild<YearGridBaseProps, YearGridProviderSnippetProps> &
Without<PrimitiveDivAttributes, YearGridBaseProps>;
// ── Child parts ──────────────────────────────────────────────────────────────
export type YearGridHeaderProps = WithChild<{ id?: string }> &
Without<PrimitiveHeaderAttributes, {}>;
export type YearGridHeadingProps = WithChild<{ id?: string }, YearGridHeadingSnippetProps> &
Without<PrimitiveDivAttributes, {}>;
export type YearGridPrevButtonProps = WithChild<{ id?: string }> &
Without<PrimitiveButtonAttributes, {}>;
export type YearGridNextButtonProps = WithChild<{ id?: string }> &
Without<PrimitiveButtonAttributes, {}>;
export type YearGridGridProps = WithChild<{
id?: string;
/** Explicit id of the heading that labels this grid. */
labelledBy?: string;
}> &
Without<PrimitiveDivAttributes, {}>;
export type YearGridCellProps = WithChild<
{
id?: string;
/** The year this cell represents. */
year: number;
},
YearGridCellSnippetProps
> &
Without<PrimitiveButtonAttributes, { year?: unknown }>;

@ -0,0 +1,482 @@
import { context, type WithRefOpts } from '../../provider';
import { boolToEmptyStrOrUndef, boolToStr } from '$adom';
import { state, type ActiveProps, type StateProps } from '$libs/reactive';
import type { SomaKeyboardEvent, SomaMouseEvent, OnChangeFn } from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { YEAR_GRID_LANGS } from './langs';
import { today, getLocalTimeZone } from '$libs/days';
import { yearGridMorfo } from '../../../morfo/components/year-grid';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import type { YearValue, YearPlaceholder } from './types';
// ── Helpers ──────────────────────────────────────────────────────────────────
function inBounds(year: number, min: YearValue | undefined, max: YearValue | undefined): boolean {
if (min && year < min.year) return false;
if (max && year > max.year) return false;
return true;
}
function resolveInitialYearPlaceholder(
placeholder: YearPlaceholder | undefined,
value: YearValue | undefined
): YearPlaceholder {
if (placeholder) return placeholder;
if (value) return { year: value.year };
const t = today(getLocalTimeZone());
return { year: t.year };
}
// ── Root provider ────────────────────────────────────────────────────────────
export interface YearGridOpts
extends
WithRefOpts,
StateProps<{
placeholder: YearPlaceholder;
value: YearValue | undefined;
}>,
ActiveProps<{
minValue: YearValue | undefined;
maxValue: YearValue | undefined;
disabled: boolean;
readonly: boolean;
dir: 'ltr' | 'rtl';
locale: string;
decadeSize: number;
ariaLabel: string | undefined;
}> {
onValueChange?: OnChangeFn<YearValue | undefined>;
}
export class YearGridProvider {
readonly opts: YearGridOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
static readonly ctx = context<YearGridProvider>('YearGrid');
static get(): YearGridProvider | undefined {
return this.ctx.getOr(undefined) as YearGridProvider | undefined;
}
static require(): YearGridProvider {
return this.ctx.get();
}
static create(opts: YearGridOpts) {
return new YearGridProvider(opts);
}
/** Heading id exposed so Grid can wire aria-labelledby. */
headingId = state('');
/** Currently focused year (for roving tabindex). */
focusedYear = $state(0);
private constructor(opts: YearGridOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(yearGridMorfo, {});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: YearGridProvider.ctx
});
// Initial focused year follows selection or placeholder.
this.focusedYear = this.opts.value.current?.year ?? this.opts.placeholder.current.year;
}
// ── Derived ────────────────────────────────────────────────────────────────
readonly effectiveLocale = $derived.by(
() => this.opts.locale.current || this.soma.langs.getLocale() || 'en'
);
readonly decadeSize = $derived.by(() => Math.max(1, this.opts.decadeSize.current ?? 12));
readonly pageStart = $derived.by(() => {
const placeholder = this.opts.placeholder.current.year;
const size = this.decadeSize;
return placeholder - ((placeholder % size + size) % size);
});
readonly pageEnd = $derived.by(() => this.pageStart + this.decadeSize - 1);
readonly years: number[] = $derived.by(() =>
Array.from({ length: this.decadeSize }, (_, i) => this.pageStart + i)
);
readonly headingValue = $derived.by(() => `${this.pageStart}–${this.pageEnd}`);
readonly todayYear = $derived.by(() => today(getLocalTimeZone()).year);
readonly fullLabel = $derived.by(() => {
const base =
this.opts.ariaLabel.current ??
this.soma.langs.ts(YEAR_GRID_LANGS.LABEL) ??
'Year grid';
return `${base}, ${this.headingValue}`;
});
formatYear(year: number): string {
return new Intl.DateTimeFormat(this.effectiveLocale, { year: 'numeric' }).format(
new Date(Date.UTC(year, 5, 15))
);
}
// ── Bound checks ──────────────────────────────────────────────────────────
isDisabledYear(year: number): boolean {
if (this.opts.disabled.current) return true;
return !inBounds(year, this.opts.minValue.current, this.opts.maxValue.current);
}
isSelectedYear(year: number): boolean {
return this.opts.value.current?.year === year;
}
isCurrentYear(year: number): boolean {
return this.todayYear === year;
}
// ── Navigation ────────────────────────────────────────────────────────────
private setPlaceholder(next: YearPlaceholder, target?: HTMLElement): void {
if (this.opts.placeholder.current.year === next.year) return;
this.opts.placeholder.current = next;
void this.runtime.trigger('nav-step', target ? { fallbackTarget: target } : undefined);
}
prevPage(target?: HTMLElement): void {
this.setPlaceholder({ year: this.opts.placeholder.current.year - this.decadeSize }, target);
}
nextPage(target?: HTMLElement): void {
this.setPlaceholder({ year: this.opts.placeholder.current.year + this.decadeSize }, target);
}
// ── Selection ─────────────────────────────────────────────────────────────
select(year: number, target?: HTMLElement): void {
if (this.opts.readonly.current || this.opts.disabled.current) return;
if (this.isDisabledYear(year)) return;
const prev = this.opts.value.current;
if (prev?.year === year) return;
this.opts.value.current = { year };
void this.runtime.trigger('commit-set', target ? { fallbackTarget: target } : undefined);
}
// ── Keyboard ──────────────────────────────────────────────────────────────
handleCellKeydown(e: SomaKeyboardEvent<HTMLElement>, year: number): boolean {
const { nextKey: horizNext, prevKey: horizPrev } = getDirectionalKeys(
this.opts.dir.current,
'horizontal'
);
let nextYear: number | undefined;
if (e.key === horizNext) nextYear = year + 1;
else if (e.key === horizPrev) nextYear = year - 1;
else if (e.key === KEYS.ARROW_DOWN) nextYear = year + 3;
else if (e.key === KEYS.ARROW_UP) nextYear = year - 3;
else if (e.key === KEYS.HOME) nextYear = this.pageStart;
else if (e.key === KEYS.END) nextYear = this.pageEnd;
else if (e.key === KEYS.PAGE_UP) nextYear = year - this.decadeSize;
else if (e.key === KEYS.PAGE_DOWN) nextYear = year + this.decadeSize;
else if (e.key === KEYS.ENTER || e.key === ' ') {
e.preventDefault();
this.select(year, e.currentTarget as HTMLElement);
return true;
} else {
return false;
}
e.preventDefault();
// If the next year falls outside the visible page, move the placeholder.
if (nextYear! < this.pageStart || nextYear! > this.pageEnd) {
this.setPlaceholder({ year: nextYear! }, e.currentTarget as HTMLElement);
}
this.focusedYear = nextYear!;
queueMicrotask(() => {
const root = this.opts.ref.current;
if (!root) return;
const el = root.querySelector<HTMLElement>(
`[data-year-grid-cell][data-value="${nextYear!}"]`
);
this.soma.dom.focus(el);
});
return true;
}
// ── Bound prev/next ──────────────────────────────────────────────────────
readonly isPrevButtonDisabled = $derived.by(() => {
if (this.opts.disabled.current) return true;
const min = this.opts.minValue.current;
if (!min) return false;
return this.pageStart - 1 < min.year;
});
readonly isNextButtonDisabled = $derived.by(() => {
if (this.opts.disabled.current) return true;
const max = this.opts.maxValue.current;
if (!max) return false;
return this.pageEnd + 1 > max.year;
});
// ── Props ─────────────────────────────────────────────────────────────────
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'application' as const,
dir: this.opts.dir.current,
'aria-label': this.fullLabel,
'aria-disabled': boolToStr(this.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.opts.readonly.current),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current)
} as const)
);
}
// ── Header ───────────────────────────────────────────────────────────────────
export class YearGridHeaderProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: WithRefOpts) {
return new YearGridHeaderProvider(opts);
}
private constructor(opts: WithRefOpts) {
this.opts = opts;
const provider = YearGridProvider.require();
this.runtimePart = provider.runtime.part('header', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props } as const)
);
}
// ── Heading ──────────────────────────────────────────────────────────────────
export class YearGridHeadingProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: YearGridProvider;
static create(opts: WithRefOpts) {
return new YearGridHeadingProvider(opts);
}
private constructor(opts: WithRefOpts) {
this.opts = opts;
this.provider = YearGridProvider.require();
this.runtimePart = this.provider.runtime.part('heading', {
id: opts.id,
ref: opts.ref,
owner: this
});
this.provider.headingId.current = opts.id.current;
}
readonly headingValue = $derived.by(() => this.provider.headingValue);
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props } as const)
);
}
// ── Prev / Next Buttons ─────────────────────────────────────────────────────
abstract class YearGridPaginationButtonProvider {
readonly opts: WithRefOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: YearGridProvider;
protected readonly labelRef: string;
protected abstract disabled(): boolean;
protected abstract trigger(target?: HTMLElement): void;
protected constructor(opts: WithRefOpts, part: 'prev-button' | 'next-button', labelRef: string) {
this.opts = opts;
this.provider = YearGridProvider.require();
this.runtimePart = this.provider.runtime.part(part, {
id: opts.id,
ref: opts.ref,
owner: this
});
this.labelRef = labelRef;
}
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.disabled()) return;
this.trigger(e.currentTarget as HTMLElement);
};
readonly props = $derived.by(() => {
const disabled = this.disabled();
return this.runtimePart.assert({
...this.runtimePart.props,
type: 'button' as const,
'aria-label': this.provider.soma.langs.ts(this.labelRef),
'aria-disabled': boolToStr(disabled),
'data-disabled': boolToEmptyStrOrUndef(disabled),
disabled,
onclick: this.onclick
} as const);
});
}
export class YearGridPrevButtonProvider extends YearGridPaginationButtonProvider {
static create(opts: WithRefOpts) {
return new YearGridPrevButtonProvider(opts);
}
private constructor(opts: WithRefOpts) {
super(opts, 'prev-button', YEAR_GRID_LANGS.PREV_PAGE);
}
protected disabled() {
return this.provider.isPrevButtonDisabled;
}
protected trigger(target?: HTMLElement) {
this.provider.prevPage(target);
}
}
export class YearGridNextButtonProvider extends YearGridPaginationButtonProvider {
static create(opts: WithRefOpts) {
return new YearGridNextButtonProvider(opts);
}
private constructor(opts: WithRefOpts) {
super(opts, 'next-button', YEAR_GRID_LANGS.NEXT_PAGE);
}
protected disabled() {
return this.provider.isNextButtonDisabled;
}
protected trigger(target?: HTMLElement) {
this.provider.nextPage(target);
}
}
// ── Grid ─────────────────────────────────────────────────────────────────────
export interface YearGridGridOpts
extends
WithRefOpts,
ActiveProps<{
labelledBy: string | undefined;
}> {}
export class YearGridGridProvider {
readonly opts: YearGridGridOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: YearGridProvider;
static create(opts: YearGridGridOpts) {
return new YearGridGridProvider(opts);
}
private constructor(opts: YearGridGridOpts) {
this.opts = opts;
this.provider = YearGridProvider.require();
this.runtimePart = this.provider.runtime.part('grid', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() => {
const headingId = this.opts.labelledBy.current ?? this.provider.headingId.current;
return this.runtimePart.assert({
...this.runtimePart.props,
role: 'grid' as const,
'aria-labelledby': headingId || undefined,
'aria-readonly': boolToStr(this.provider.opts.readonly.current),
'aria-disabled': boolToStr(this.provider.opts.disabled.current)
} as const);
});
}
// ── Cell ─────────────────────────────────────────────────────────────────────
export interface YearGridCellOpts
extends WithRefOpts, ActiveProps<{ year: number }> {}
export class YearGridCellProvider {
readonly opts: YearGridCellOpts;
readonly runtimePart: SomaRuntimePart;
readonly provider: YearGridProvider;
static readonly ctx = context<YearGridCellProvider>('YearGridCell');
static get(): YearGridCellProvider | undefined {
return this.ctx.getOr(undefined) as YearGridCellProvider | undefined;
}
static require(): YearGridCellProvider {
return this.ctx.get();
}
static create(opts: YearGridCellOpts) {
return new YearGridCellProvider(opts);
}
private constructor(opts: YearGridCellOpts) {
this.opts = opts;
this.provider = YearGridProvider.require();
this.runtimePart = this.provider.runtime.part('cell', {
id: opts.id,
ref: opts.ref,
owner: this,
context: YearGridCellProvider.ctx
});
}
readonly year = $derived.by(() => this.opts.year.current);
readonly selected = $derived.by(() => this.provider.isSelectedYear(this.year));
readonly current = $derived.by(() => this.provider.isCurrentYear(this.year));
readonly disabled = $derived.by(() => this.provider.isDisabledYear(this.year));
readonly focused = $derived.by(() => this.provider.focusedYear === this.year);
readonly label = $derived.by(() => this.provider.formatYear(this.year));
readonly snippetProps = $derived.by(() => ({
label: this.label,
year: this.year,
selected: this.selected,
current: this.current,
disabled: this.disabled,
focused: this.focused
}));
readonly onclick = (e: SomaMouseEvent<HTMLElement>) => {
if (this.disabled) return;
this.provider.focusedYear = this.year;
this.provider.select(this.year, e.currentTarget as HTMLElement);
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
this.provider.handleCellKeydown(e, this.year);
};
readonly props = $derived.by(() => {
const tabindex = this.focused && !this.disabled ? 0 : -1;
return this.runtimePart.assert({
...this.runtimePart.props,
role: 'gridcell' as const,
type: 'button' as const,
tabindex,
'aria-selected': boolToStr(this.selected),
'aria-disabled': boolToStr(this.disabled),
'data-selected': boolToEmptyStrOrUndef(this.selected),
'data-current': boolToEmptyStrOrUndef(this.current),
'data-disabled': boolToEmptyStrOrUndef(this.disabled),
'data-focused': boolToEmptyStrOrUndef(this.focused),
'data-value': `${this.year}`,
disabled: this.disabled,
onclick: this.onclick,
onkeydown: this.onkeydown
} as const);
});
}
export { resolveInitialYearPlaceholder };

@ -146,6 +146,8 @@
{ slug: '/uix/components/select', label: 'Select' },
{ slug: '/uix/components/combobox', label: 'Combobox' },
{ slug: '/uix/components/calendar', label: 'Calendar' },
{ slug: '/uix/components/month-grid', label: 'Month grid' },
{ slug: '/uix/components/year-grid', label: 'Year grid' },
{ slug: '/uix/components/date-field', label: 'Date field' },
{ slug: '/uix/components/date-picker', label: 'Date picker' },
{ slug: '/uix/components/date-range-picker', label: 'Date range picker' },

@ -0,0 +1,495 @@
<script lang="ts">
import {
MonthGrid,
type MonthGridColor,
type MonthGridProviderSnippetProps,
type MonthGridSize,
type MonthGridVariant,
type MonthValue
} from '$uix/eidos/components/month-grid';
import { compileMorfo } from '$uix/morfo';
import { monthGridMorfo } from '@/uix/morfo/components/month-grid';
import { getActiveUix } from '$active-uix';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
type MorfoEventView = {
name: string;
target: string;
semantic: { family: string; verb?: string; sequence?: string; intent?: unknown };
};
const uix = getActiveUix();
type EmitSignal = Parameters<NonNullable<typeof uix.events>['emit']>[0];
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
let value = $state<MonthValue | undefined>({ year: 2026, month: 5 });
let placeholder = $state<{ year: number; month?: number }>({ year: 2026 });
let disabled = $state(false);
let readonly = $state(false);
let monthFormat = $state<'long' | 'short' | 'narrow'>('short');
let locale = $state('en-US');
let dir = $state<'ltr' | 'rtl'>('ltr');
let size = $state<MonthGridSize>('md');
let variant = $state<MonthGridVariant>('surface');
let color = $state<MonthGridColor>('primary');
let useMin = $state(false);
let useMax = $state(false);
const compiled = compileMorfo(monthGridMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()] as MorfoEventView[]);
const minValue = $derived<MonthValue | undefined>(useMin ? { year: 2026, month: 3 } : undefined);
const maxValue = $derived<MonthValue | undefined>(useMax ? { year: 2026, month: 10 } : undefined);
const valueLabel = $derived(value ? `${value.year}-${value.month}` : 'empty');
const placeholderLabel = $derived(`${placeholder.year}`);
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
function playEvent(action: MorfoEventView): void {
const intent = typeof action.semantic.intent === 'string' ? action.semantic.intent : undefined;
const signal: EmitSignal = {
name: action.name,
family: action.semantic.family as EmitSignal['family'],
target: stageRef ?? document.body
};
if (intent) (signal as unknown as { intent: string }).intent = intent;
uix.events?.emit(signal);
}
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '-',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
const somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as MonthGrid from '$soma/components/month-grid';",
'',
` let value = $state<{ year: number; month: number } | undefined>(${value ? `{ year: ${value.year}, month: ${value.month} }` : 'undefined'});`,
` let placeholder = $state({ year: ${placeholder.year} });`,
'</' + 'script>',
'',
'<MonthGrid.Provider',
' bind:value',
' bind:placeholder',
disabled && ' disabled',
readonly && ' readonly',
minValue && ` minValue={{ year: ${minValue.year}, month: ${minValue.month} }}`,
maxValue && ` maxValue={{ year: ${maxValue.year}, month: ${maxValue.month} }}`,
monthFormat !== 'short' && ` monthFormat="${monthFormat}"`,
locale !== 'en-US' && ` locale="${locale}"`,
dir !== 'ltr' && ` dir="${dir}"`,
'>',
' {#snippet children({ months })}',
' <MonthGrid.Header>',
' <MonthGrid.PrevButton>‹</MonthGrid.PrevButton>',
' <MonthGrid.Heading />',
' <MonthGrid.NextButton>›</MonthGrid.NextButton>',
' </MonthGrid.Header>',
' <MonthGrid.Grid>',
' {#each months as month (month)}',
' <MonthGrid.Cell {month} />',
' {/each}',
' </MonthGrid.Grid>',
' {/snippet}',
'</MonthGrid.Provider>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { MonthGrid } from '$uix/eidos/components/month-grid';",
'',
` let value = $state<{ year: number; month: number } | undefined>(${value ? `{ year: ${value.year}, month: ${value.month} }` : 'undefined'});`,
` let placeholder = $state({ year: ${placeholder.year} });`,
'</' + 'script>',
'',
'<MonthGrid',
' bind:value',
' bind:placeholder',
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
disabled && ' disabled',
readonly && ' readonly',
minValue && ` minValue={{ year: ${minValue.year}, month: ${minValue.month} }}`,
maxValue && ` maxValue={{ year: ${maxValue.year}, month: ${maxValue.month} }}`,
monthFormat !== 'short' && ` monthFormat="${monthFormat}"`,
locale !== 'en-US' && ` locale="${locale}"`,
dir !== 'ltr' && ` dir="${dir}"`,
'>',
' {#snippet children({ months })}',
' <MonthGrid.Header>',
' <MonthGrid.PrevButton>‹</MonthGrid.PrevButton>',
' <MonthGrid.Heading />',
' <MonthGrid.NextButton>›</MonthGrid.NextButton>',
' </MonthGrid.Header>',
' <MonthGrid.Grid>',
' {#each months as month (month)}',
' <MonthGrid.Cell {month} />',
' {/each}',
' </MonthGrid.Grid>',
' {/snippet}',
'</MonthGrid>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Forms · MonthGrid</div>
<h1 data-uix-page-title>MonthGrid</h1>
<p data-uix-page-lede>
Standalone month picker — 12 cells (Jan..Dec) for a single year, prev/next year navigation,
full keyboard support and morfo-declared semantic events. Parallel to the inline
Calendar's month view but works without a calendar context.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>{events.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>value</span>{valueLabel}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>year</span>{placeholderLabel}
</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<MonthGrid
bind:value
bind:placeholder
{disabled}
{readonly}
{minValue}
{maxValue}
{monthFormat}
{locale}
{dir}
{size}
{variant}
{color}
>
{#snippet children({ months }: MonthGridProviderSnippetProps)}
<MonthGrid.Header>
<MonthGrid.PrevButton>&lt;</MonthGrid.PrevButton>
<MonthGrid.Heading />
<MonthGrid.NextButton>&gt;</MonthGrid.NextButton>
</MonthGrid.Header>
<MonthGrid.Grid>
{#each months as month (month)}
<MonthGrid.Cell {month} />
{/each}
</MonthGrid.Grid>
{/snippet}
</MonthGrid>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>click a month or use prev/next</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent
? ' · ' + entry.intent
: ''}</span>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>value</span>
{valueLabel}
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · value, placeholder, bounds
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>value <span data-uix-control-hint>{valueLabel}</span></span>
<span data-uix-chips role="group">
<button data-uix-chip data-active={value?.month === 5} onclick={() => (value = { year: 2026, month: 5 })}>May 2026</button>
<button data-uix-chip data-active={value?.month === 6} onclick={() => (value = { year: 2026, month: 6 })}>Jun 2026</button>
<button data-uix-chip data-active={value === undefined} onclick={() => (value = undefined)}>empty</button>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>placeholder year</span>
<span data-uix-chips role="radiogroup">
{#each [2025, 2026, 2027] as y (y)}
<button data-uix-chip data-active={placeholder.year === y} onclick={() => (placeholder = { year: y })}>{y}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>state flags</span>
<span data-uix-switch><input type="checkbox" bind:checked={disabled} /><span data-uix-switch-label>disabled</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={readonly} /><span data-uix-switch-label>readonly</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>bounds</span>
<span data-uix-switch><input type="checkbox" bind:checked={useMin} /><span data-uix-switch-label>min=Mar 2026</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={useMax} /><span data-uix-switch-label>max=Oct 2026</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>monthFormat</span>
<span data-uix-chips role="radiogroup">
{#each ['long', 'short', 'narrow'] as v (v)}
<button data-uix-chip data-active={monthFormat === v} onclick={() => (monthFormat = v as typeof monthFormat)}>{v}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>locale</span>
<select bind:value={locale}>
<option value="en-US">en-US</option>
<option value="es-ES">es-ES</option>
<option value="fr-FR">fr-FR</option>
</select>
</label>
<label data-uix-control>
<span data-uix-control-label>direction</span>
<span data-uix-chips role="radiogroup">
{#each ['ltr', 'rtl'] as v (v)}
<button data-uix-chip data-active={dir === v} onclick={() => (dir = v as 'ltr' | 'rtl')}>{v}</button>
{/each}
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each ['sm', 'md', 'lg'] as v (v)}
<button data-uix-chip data-active={size === v} onclick={() => (size = v as MonthGridSize)}>{v}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each ['surface', 'outline', 'ghost'] as v (v)}
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v as MonthGridVariant)}>{v}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each ['primary', 'secondary', 'neutral', 'affirm', 'risk'] as v (v)}
<button data-uix-chip data-active={color === v} onclick={() => (color = v as MonthGridColor)}>{v}</button>
{/each}
</span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · standalone month picker</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · root plus parts</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Owner</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">value <span data-uix-tag data-kind="bindable">bindable</span></td><td>soma</td><td class="default">undefined</td><td>Selected `{`{ year, month }`}` pair.</td></tr>
<tr><td class="name">placeholder <span data-uix-tag data-kind="bindable">bindable</span></td><td>soma</td><td class="default">today's year</td><td>Anchor year for the 12-month grid.</td></tr>
<tr><td class="name">minValue / maxValue</td><td>soma</td><td class="default empty">-</td><td>Disables cells outside the bound.</td></tr>
<tr><td class="name">disabled / readonly</td><td>soma</td><td class="default">false</td><td>Root state.</td></tr>
<tr><td class="name">monthFormat</td><td>soma</td><td class="default">'short'</td><td>Intl month-name length.</td></tr>
<tr><td class="name">locale / dir</td><td>soma</td><td class="default">services</td><td>Label formatting and directional arrow keys.</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td>eidos</td><td class="default">md</td><td>Visual scale.</td></tr>
<tr><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td>eidos</td><td class="default">surface</td><td>Surface treatment.</td></tr>
<tr><td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td><td>eidos</td><td class="default">primary</td><td>Intent palette for selected month.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Feature</th><th>Ark UI</th><th>React Aria</th><th>Chakra</th><th>Bits UI</th><th>UIX</th></tr></thead>
<tbody>
<tr><td class="name">Standalone month grid</td><td>part of DatePicker</td><td>via composition</td><td>no</td><td>via Calendar</td><td>first-class</td></tr>
<tr><td class="name">Bounds (min/max)</td><td>yes</td><td>yes</td><td>-</td><td>yes</td><td>yes</td></tr>
<tr><td class="name">Keyboard nav</td><td>yes</td><td>yes</td><td>-</td><td>yes</td><td>yes</td></tr>
<tr><td class="name">Locale labels</td><td>yes</td><td>yes</td><td>-</td><td>yes</td><td>yes</td></tr>
<tr><td class="name">Semantic events</td><td>callbacks</td><td>callbacks</td><td>-</td><td>callbacks</td><td>commit-set / nav-step</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span> · declarative contract
</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>Optional</th></tr></thead>
<tbody>
{#each partsList as part (part.kebab)}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '-'}</td>
<td class="default">{part.archetype ?? '-'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Target</th><th>Play</th></tr></thead>
<tbody>
{#each events as action (action.name)}
{@const intentDecl = action.semantic.intent}
<tr>
<td class="name">{action.name}</td>
<td class="type">{action.semantic.family}</td>
<td>{action.semantic.verb ?? '-'}</td>
<td>{action.semantic.sequence ?? 'pre'}</td>
<td class="default">{typeof intentDecl === 'string' ? intentDecl : intentDecl ? 'fromProp' : '-'}</td>
<td class="type">{action.target}</td>
<td><button data-uix-chip onclick={() => playEvent(action)}>▶ play</button></td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td class="name"><code>[data-month-grid]</code></td><td>Root surface, size and variant tokens.</td></tr>
<tr><td class="name"><code>[data-month-grid-header]</code></td><td>Three-column nav header (prev · heading · next).</td></tr>
<tr><td class="name"><code>[data-month-grid-grid]</code></td><td>3×4 month grid.</td></tr>
<tr><td class="name"><code>[data-month-grid-cell][data-selected]</code></td><td>Selected month tile.</td></tr>
<tr><td class="name"><code>[data-month-grid-cell][data-current]</code></td><td>Today's month accent.</td></tr>
<tr><td class="name"><code>[data-month-grid-cell][data-disabled]</code></td><td>Out of bounds / disabled.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-subsection-head>Keyboard</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
<tr><td><span data-uix-kbd>←</span> / <span data-uix-kbd>→</span></td><td>Move focus ±1 month (RTL-flipped).</td></tr>
<tr><td><span data-uix-kbd>↑</span> / <span data-uix-kbd>↓</span></td><td>Move focus ±3 months (row width).</td></tr>
<tr><td><span data-uix-kbd>Home</span> / <span data-uix-kbd>End</span></td><td>First / last month of year.</td></tr>
<tr><td><span data-uix-kbd>PageUp</span> / <span data-uix-kbd>PageDown</span></td><td>±1 year (also moves placeholder).</td></tr>
<tr><td><span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span></td><td>Select focused month.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>APG</div>
<p>Follows <a href="https://www.w3.org/WAI/ARIA/apg/patterns/grid/" rel="noreferrer" target="_blank">WAI-ARIA APG Grid</a> pattern: <code>role="grid"</code> on the grid container, <code>role="gridcell"</code> + roving <code>tabindex</code> on cells.</p>
</section>
{/if}
</div>

@ -0,0 +1,492 @@
<script lang="ts">
import {
YearGrid,
type YearGridColor,
type YearGridProviderSnippetProps,
type YearGridSize,
type YearGridVariant,
type YearValue
} from '$uix/eidos/components/year-grid';
import { compileMorfo } from '$uix/morfo';
import { yearGridMorfo } from '@/uix/morfo/components/year-grid';
import { getActiveUix } from '$active-uix';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
type MorfoEventView = {
name: string;
target: string;
semantic: { family: string; verb?: string; sequence?: string; intent?: unknown };
};
const uix = getActiveUix();
type EmitSignal = Parameters<NonNullable<typeof uix.events>['emit']>[0];
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
let value = $state<YearValue | undefined>({ year: 2026 });
let placeholder = $state<{ year: number }>({ year: 2026 });
let disabled = $state(false);
let readonly = $state(false);
let decadeSize = $state(12);
let locale = $state('en-US');
let dir = $state<'ltr' | 'rtl'>('ltr');
let size = $state<YearGridSize>('md');
let variant = $state<YearGridVariant>('surface');
let color = $state<YearGridColor>('primary');
let useMin = $state(false);
let useMax = $state(false);
const compiled = compileMorfo(yearGridMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()] as MorfoEventView[]);
const minValue = $derived<YearValue | undefined>(useMin ? { year: 2024 } : undefined);
const maxValue = $derived<YearValue | undefined>(useMax ? { year: 2030 } : undefined);
const valueLabel = $derived(value ? `${value.year}` : 'empty');
const placeholderLabel = $derived(`${placeholder.year}`);
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
function playEvent(action: MorfoEventView): void {
const intent = typeof action.semantic.intent === 'string' ? action.semantic.intent : undefined;
const signal: EmitSignal = {
name: action.name,
family: action.semantic.family as EmitSignal['family'],
target: stageRef ?? document.body
};
if (intent) (signal as unknown as { intent: string }).intent = intent;
uix.events?.emit(signal);
}
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '-',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
const somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as YearGrid from '$soma/components/year-grid';",
'',
` let value = $state<{ year: number } | undefined>(${value ? `{ year: ${value.year} }` : 'undefined'});`,
` let placeholder = $state({ year: ${placeholder.year} });`,
'</' + 'script>',
'',
'<YearGrid.Provider',
' bind:value',
' bind:placeholder',
disabled && ' disabled',
readonly && ' readonly',
minValue && ` minValue={{ year: ${minValue.year} }}`,
maxValue && ` maxValue={{ year: ${maxValue.year} }}`,
decadeSize !== 12 && ` decadeSize={${decadeSize}}`,
locale !== 'en-US' && ` locale="${locale}"`,
dir !== 'ltr' && ` dir="${dir}"`,
'>',
' {#snippet children({ years })}',
' <YearGrid.Header>',
' <YearGrid.PrevButton>‹</YearGrid.PrevButton>',
' <YearGrid.Heading />',
' <YearGrid.NextButton>›</YearGrid.NextButton>',
' </YearGrid.Header>',
' <YearGrid.Grid>',
' {#each years as year (year)}',
' <YearGrid.Cell {year} />',
' {/each}',
' </YearGrid.Grid>',
' {/snippet}',
'</YearGrid.Provider>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { YearGrid } from '$uix/eidos/components/year-grid';",
'',
` let value = $state<{ year: number } | undefined>(${value ? `{ year: ${value.year} }` : 'undefined'});`,
` let placeholder = $state({ year: ${placeholder.year} });`,
'</' + 'script>',
'',
'<YearGrid',
' bind:value',
' bind:placeholder',
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
disabled && ' disabled',
readonly && ' readonly',
minValue && ` minValue={{ year: ${minValue.year} }}`,
maxValue && ` maxValue={{ year: ${maxValue.year} }}`,
decadeSize !== 12 && ` decadeSize={${decadeSize}}`,
locale !== 'en-US' && ` locale="${locale}"`,
dir !== 'ltr' && ` dir="${dir}"`,
'>',
' {#snippet children({ years })}',
' <YearGrid.Header>',
' <YearGrid.PrevButton>‹</YearGrid.PrevButton>',
' <YearGrid.Heading />',
' <YearGrid.NextButton>›</YearGrid.NextButton>',
' </YearGrid.Header>',
' <YearGrid.Grid>',
' {#each years as year (year)}',
' <YearGrid.Cell {year} />',
' {/each}',
' </YearGrid.Grid>',
' {/snippet}',
'</YearGrid>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Forms · YearGrid</div>
<h1 data-uix-page-title>YearGrid</h1>
<p data-uix-page-lede>
Standalone year picker — a paged grid of years (default decade size 12) with prev/next
page navigation, full keyboard support and morfo-declared semantic events. Parallel to the
DatePicker's year view but works without a date-picker context.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>{events.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>value</span>{valueLabel}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>anchor</span>{placeholderLabel}
</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<YearGrid
bind:value
bind:placeholder
{disabled}
{readonly}
{minValue}
{maxValue}
{decadeSize}
{locale}
{dir}
{size}
{variant}
{color}
>
{#snippet children({ years }: YearGridProviderSnippetProps)}
<YearGrid.Header>
<YearGrid.PrevButton>&lt;</YearGrid.PrevButton>
<YearGrid.Heading />
<YearGrid.NextButton>&gt;</YearGrid.NextButton>
</YearGrid.Header>
<YearGrid.Grid>
{#each years as year (year)}
<YearGrid.Cell {year} />
{/each}
</YearGrid.Grid>
{/snippet}
</YearGrid>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>click a year or use prev/next</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent
? ' · ' + entry.intent
: ''}</span>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>value</span>
{valueLabel}
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · value, placeholder, bounds
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>value <span data-uix-control-hint>{valueLabel}</span></span>
<span data-uix-chips role="group">
{#each [2024, 2025, 2026, 2027] as y (y)}
<button data-uix-chip data-active={value?.year === y} onclick={() => (value = { year: y })}>{y}</button>
{/each}
<button data-uix-chip data-active={value === undefined} onclick={() => (value = undefined)}>empty</button>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>placeholder year</span>
<span data-uix-chips role="radiogroup">
{#each [2020, 2026, 2032] as y (y)}
<button data-uix-chip data-active={placeholder.year === y} onclick={() => (placeholder = { year: y })}>{y}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>state flags</span>
<span data-uix-switch><input type="checkbox" bind:checked={disabled} /><span data-uix-switch-label>disabled</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={readonly} /><span data-uix-switch-label>readonly</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>bounds</span>
<span data-uix-switch><input type="checkbox" bind:checked={useMin} /><span data-uix-switch-label>min=2024</span></span>
<span data-uix-switch><input type="checkbox" bind:checked={useMax} /><span data-uix-switch-label>max=2030</span></span>
</label>
<label data-uix-control>
<span data-uix-control-label>decadeSize</span>
<input type="number" min="3" max="24" bind:value={decadeSize} />
</label>
<label data-uix-control>
<span data-uix-control-label>locale</span>
<select bind:value={locale}>
<option value="en-US">en-US</option>
<option value="es-ES">es-ES</option>
<option value="ar-EG">ar-EG</option>
</select>
</label>
<label data-uix-control>
<span data-uix-control-label>direction</span>
<span data-uix-chips role="radiogroup">
{#each ['ltr', 'rtl'] as v (v)}
<button data-uix-chip data-active={dir === v} onclick={() => (dir = v as 'ltr' | 'rtl')}>{v}</button>
{/each}
</span>
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each ['sm', 'md', 'lg'] as v (v)}
<button data-uix-chip data-active={size === v} onclick={() => (size = v as YearGridSize)}>{v}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each ['surface', 'outline', 'ghost'] as v (v)}
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v as YearGridVariant)}>{v}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each ['primary', 'secondary', 'neutral', 'affirm', 'risk'] as v (v)}
<button data-uix-chip data-active={color === v} onclick={() => (color = v as YearGridColor)}>{v}</button>
{/each}
</span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · standalone year picker</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · root plus parts</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Owner</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td class="name">value <span data-uix-tag data-kind="bindable">bindable</span></td><td>soma</td><td class="default">undefined</td><td>Selected `{`{ year }`}`.</td></tr>
<tr><td class="name">placeholder <span data-uix-tag data-kind="bindable">bindable</span></td><td>soma</td><td class="default">today's year</td><td>Anchor year for the visible page.</td></tr>
<tr><td class="name">minValue / maxValue</td><td>soma</td><td class="default empty">-</td><td>Disables cells outside the bound.</td></tr>
<tr><td class="name">decadeSize</td><td>soma</td><td class="default">12</td><td>Years per page.</td></tr>
<tr><td class="name">disabled / readonly</td><td>soma</td><td class="default">false</td><td>Root state.</td></tr>
<tr><td class="name">locale / dir</td><td>soma</td><td class="default">services</td><td>Year formatting and directional arrow keys.</td></tr>
<tr><td class="name">size <span data-uix-tag data-kind="eidos">eidos</span></td><td>eidos</td><td class="default">md</td><td>Visual scale.</td></tr>
<tr><td class="name">variant <span data-uix-tag data-kind="eidos">eidos</span></td><td>eidos</td><td class="default">surface</td><td>Surface treatment.</td></tr>
<tr><td class="name">color <span data-uix-tag data-kind="eidos">eidos</span></td><td>eidos</td><td class="default">primary</td><td>Intent palette for selected year.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Feature</th><th>Ark UI</th><th>React Aria</th><th>Chakra</th><th>Bits UI</th><th>UIX</th></tr></thead>
<tbody>
<tr><td class="name">Standalone year grid</td><td>part of DatePicker</td><td>via composition</td><td>no</td><td>via Calendar</td><td>first-class</td></tr>
<tr><td class="name">Configurable page size</td><td>fixed 12</td><td>fixed 12</td><td>-</td><td>fixed 12</td><td>decadeSize</td></tr>
<tr><td class="name">Bounds (min/max)</td><td>yes</td><td>yes</td><td>-</td><td>yes</td><td>yes</td></tr>
<tr><td class="name">Keyboard nav</td><td>yes</td><td>yes</td><td>-</td><td>yes</td><td>yes</td></tr>
<tr><td class="name">Semantic events</td><td>callbacks</td><td>callbacks</td><td>-</td><td>callbacks</td><td>commit-set / nav-step</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="morfo">morfo</span> · declarative contract
</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Archetype</th><th>Optional</th></tr></thead>
<tbody>
{#each partsList as part (part.kebab)}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '-'}</td>
<td class="default">{part.archetype ?? '-'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Intent</th><th>Target</th><th>Play</th></tr></thead>
<tbody>
{#each events as action (action.name)}
{@const intentDecl = action.semantic.intent}
<tr>
<td class="name">{action.name}</td>
<td class="type">{action.semantic.family}</td>
<td>{action.semantic.verb ?? '-'}</td>
<td>{action.semantic.sequence ?? 'pre'}</td>
<td class="default">{typeof intentDecl === 'string' ? intentDecl : intentDecl ? 'fromProp' : '-'}</td>
<td class="type">{action.target}</td>
<td><button data-uix-chip onclick={() => playEvent(action)}>▶ play</button></td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td class="name"><code>[data-year-grid]</code></td><td>Root surface, size and variant tokens.</td></tr>
<tr><td class="name"><code>[data-year-grid-header]</code></td><td>Three-column nav header (prev · heading · next).</td></tr>
<tr><td class="name"><code>[data-year-grid-grid]</code></td><td>3-column year grid.</td></tr>
<tr><td class="name"><code>[data-year-grid-cell][data-selected]</code></td><td>Selected year tile.</td></tr>
<tr><td class="name"><code>[data-year-grid-cell][data-current]</code></td><td>Current year accent.</td></tr>
<tr><td class="name"><code>[data-year-grid-cell][data-disabled]</code></td><td>Out of bounds / disabled.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-subsection-head>Keyboard</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
<tr><td><span data-uix-kbd>←</span> / <span data-uix-kbd>→</span></td><td>Move focus ±1 year (RTL-flipped).</td></tr>
<tr><td><span data-uix-kbd>↑</span> / <span data-uix-kbd>↓</span></td><td>Move focus ±3 years (3-col layout).</td></tr>
<tr><td><span data-uix-kbd>Home</span> / <span data-uix-kbd>End</span></td><td>First / last year of page.</td></tr>
<tr><td><span data-uix-kbd>PageUp</span> / <span data-uix-kbd>PageDown</span></td><td>±decadeSize years (page step).</td></tr>
<tr><td><span data-uix-kbd>Enter</span> / <span data-uix-kbd>Space</span></td><td>Select focused year.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>APG</div>
<p>Follows <a href="https://www.w3.org/WAI/ARIA/apg/patterns/grid/" rel="noreferrer" target="_blank">WAI-ARIA APG Grid</a> pattern: <code>role="grid"</code> on the grid container, <code>role="gridcell"</code> + roving <code>tabindex</code> on cells.</p>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.