From f7831563a1a7f72c5dbeaa3211d9d1f3eba2900d Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 22 May 2026 00:46:20 +0200 Subject: [PATCH] feat(month-grid, year-grid): standalone primitives parallel to date-picker views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/uix/eidos/components/month-grid/README.md | 36 ++ src/uix/eidos/components/month-grid/index.ts | 46 ++ .../month-grid/month-grid-cell.svelte | 12 + .../month-grid/month-grid-grid.svelte | 10 + .../month-grid/month-grid-header.svelte | 10 + .../month-grid/month-grid-heading.svelte | 16 + .../month-grid/month-grid-next-button.svelte | 10 + .../month-grid/month-grid-prev-button.svelte | 10 + .../components/month-grid/month-grid.css | 265 +++++++++ .../components/month-grid/month-grid.svelte | 31 ++ src/uix/eidos/components/month-grid/types.ts | 43 ++ src/uix/eidos/components/year-grid/README.md | 36 ++ src/uix/eidos/components/year-grid/index.ts | 46 ++ src/uix/eidos/components/year-grid/types.ts | 43 ++ .../year-grid/year-grid-cell.svelte | 12 + .../year-grid/year-grid-grid.svelte | 10 + .../year-grid/year-grid-header.svelte | 10 + .../year-grid/year-grid-heading.svelte | 16 + .../year-grid/year-grid-next-button.svelte | 10 + .../year-grid/year-grid-prev-button.svelte | 10 + .../eidos/components/year-grid/year-grid.css | 265 +++++++++ .../components/year-grid/year-grid.svelte | 31 ++ src/uix/eidos/index.css | 2 + src/uix/morfo/components/month-grid.ts | 163 ++++++ src/uix/morfo/components/year-grid.ts | 163 ++++++ src/uix/soma/components/index.ts | 2 + src/uix/soma/components/month-grid/README.md | 39 ++ .../components/month-grid-cell.svelte | 41 ++ .../components/month-grid-grid.svelte | 37 ++ .../components/month-grid-header.svelte | 35 ++ .../components/month-grid-heading.svelte | 40 ++ .../components/month-grid-next-button.svelte | 35 ++ .../components/month-grid-prev-button.svelte | 35 ++ .../month-grid/components/month-grid.svelte | 80 +++ src/uix/soma/components/month-grid/exports.ts | 22 + src/uix/soma/components/month-grid/index.ts | 1 + src/uix/soma/components/month-grid/langs.ts | 6 + .../month-grid/month-grid-provider.svelte.ts | 522 ++++++++++++++++++ src/uix/soma/components/month-grid/types.ts | 112 ++++ src/uix/soma/components/year-grid/README.md | 39 ++ .../components/year-grid-cell.svelte | 41 ++ .../components/year-grid-grid.svelte | 37 ++ .../components/year-grid-header.svelte | 35 ++ .../components/year-grid-heading.svelte | 40 ++ .../components/year-grid-next-button.svelte | 35 ++ .../components/year-grid-prev-button.svelte | 35 ++ .../year-grid/components/year-grid.svelte | 81 +++ src/uix/soma/components/year-grid/exports.ts | 22 + src/uix/soma/components/year-grid/index.ts | 1 + src/uix/soma/components/year-grid/langs.ts | 6 + src/uix/soma/components/year-grid/types.ts | 114 ++++ .../year-grid/year-grid-provider.svelte.ts | 482 ++++++++++++++++ web/routes/uix/+layout@.svelte | 2 + .../uix/components/month-grid/+page.svelte | 495 +++++++++++++++++ .../uix/components/year-grid/+page.svelte | 492 +++++++++++++++++ 55 files changed, 4270 insertions(+) create mode 100644 src/uix/eidos/components/month-grid/README.md create mode 100644 src/uix/eidos/components/month-grid/index.ts create mode 100644 src/uix/eidos/components/month-grid/month-grid-cell.svelte create mode 100644 src/uix/eidos/components/month-grid/month-grid-grid.svelte create mode 100644 src/uix/eidos/components/month-grid/month-grid-header.svelte create mode 100644 src/uix/eidos/components/month-grid/month-grid-heading.svelte create mode 100644 src/uix/eidos/components/month-grid/month-grid-next-button.svelte create mode 100644 src/uix/eidos/components/month-grid/month-grid-prev-button.svelte create mode 100644 src/uix/eidos/components/month-grid/month-grid.css create mode 100644 src/uix/eidos/components/month-grid/month-grid.svelte create mode 100644 src/uix/eidos/components/month-grid/types.ts create mode 100644 src/uix/eidos/components/year-grid/README.md create mode 100644 src/uix/eidos/components/year-grid/index.ts create mode 100644 src/uix/eidos/components/year-grid/types.ts create mode 100644 src/uix/eidos/components/year-grid/year-grid-cell.svelte create mode 100644 src/uix/eidos/components/year-grid/year-grid-grid.svelte create mode 100644 src/uix/eidos/components/year-grid/year-grid-header.svelte create mode 100644 src/uix/eidos/components/year-grid/year-grid-heading.svelte create mode 100644 src/uix/eidos/components/year-grid/year-grid-next-button.svelte create mode 100644 src/uix/eidos/components/year-grid/year-grid-prev-button.svelte create mode 100644 src/uix/eidos/components/year-grid/year-grid.css create mode 100644 src/uix/eidos/components/year-grid/year-grid.svelte create mode 100644 src/uix/morfo/components/month-grid.ts create mode 100644 src/uix/morfo/components/year-grid.ts create mode 100644 src/uix/soma/components/month-grid/README.md create mode 100644 src/uix/soma/components/month-grid/components/month-grid-cell.svelte create mode 100644 src/uix/soma/components/month-grid/components/month-grid-grid.svelte create mode 100644 src/uix/soma/components/month-grid/components/month-grid-header.svelte create mode 100644 src/uix/soma/components/month-grid/components/month-grid-heading.svelte create mode 100644 src/uix/soma/components/month-grid/components/month-grid-next-button.svelte create mode 100644 src/uix/soma/components/month-grid/components/month-grid-prev-button.svelte create mode 100644 src/uix/soma/components/month-grid/components/month-grid.svelte create mode 100644 src/uix/soma/components/month-grid/exports.ts create mode 100644 src/uix/soma/components/month-grid/index.ts create mode 100644 src/uix/soma/components/month-grid/langs.ts create mode 100644 src/uix/soma/components/month-grid/month-grid-provider.svelte.ts create mode 100644 src/uix/soma/components/month-grid/types.ts create mode 100644 src/uix/soma/components/year-grid/README.md create mode 100644 src/uix/soma/components/year-grid/components/year-grid-cell.svelte create mode 100644 src/uix/soma/components/year-grid/components/year-grid-grid.svelte create mode 100644 src/uix/soma/components/year-grid/components/year-grid-header.svelte create mode 100644 src/uix/soma/components/year-grid/components/year-grid-heading.svelte create mode 100644 src/uix/soma/components/year-grid/components/year-grid-next-button.svelte create mode 100644 src/uix/soma/components/year-grid/components/year-grid-prev-button.svelte create mode 100644 src/uix/soma/components/year-grid/components/year-grid.svelte create mode 100644 src/uix/soma/components/year-grid/exports.ts create mode 100644 src/uix/soma/components/year-grid/index.ts create mode 100644 src/uix/soma/components/year-grid/langs.ts create mode 100644 src/uix/soma/components/year-grid/types.ts create mode 100644 src/uix/soma/components/year-grid/year-grid-provider.svelte.ts create mode 100644 web/routes/uix/components/month-grid/+page.svelte create mode 100644 web/routes/uix/components/year-grid/+page.svelte diff --git a/src/uix/eidos/components/month-grid/README.md b/src/uix/eidos/components/month-grid/README.md new file mode 100644 index 000000000..32c1bbf5c --- /dev/null +++ b/src/uix/eidos/components/month-grid/README.md @@ -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 + + + + {#snippet children({ months, headingValue })} + + ‹ + + › + + + {#each months as month (month)} + + {/each} + + {/snippet} + +``` + +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`. diff --git a/src/uix/eidos/components/month-grid/index.ts b/src/uix/eidos/components/month-grid/index.ts new file mode 100644 index 000000000..74a98c53f --- /dev/null +++ b/src/uix/eidos/components/month-grid/index.ts @@ -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' diff --git a/src/uix/eidos/components/month-grid/month-grid-cell.svelte b/src/uix/eidos/components/month-grid/month-grid-cell.svelte new file mode 100644 index 000000000..a028e181b --- /dev/null +++ b/src/uix/eidos/components/month-grid/month-grid-cell.svelte @@ -0,0 +1,12 @@ + + + + {#snippet children(snippetProps: MonthGridCellSnippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/components/month-grid/month-grid-grid.svelte b/src/uix/eidos/components/month-grid/month-grid-grid.svelte new file mode 100644 index 000000000..fb901ba90 --- /dev/null +++ b/src/uix/eidos/components/month-grid/month-grid-grid.svelte @@ -0,0 +1,10 @@ + + + + {@render bodyContent?.()} + diff --git a/src/uix/eidos/components/month-grid/month-grid-header.svelte b/src/uix/eidos/components/month-grid/month-grid-header.svelte new file mode 100644 index 000000000..ec17cbad5 --- /dev/null +++ b/src/uix/eidos/components/month-grid/month-grid-header.svelte @@ -0,0 +1,10 @@ + + + + {@render bodyContent?.()} + diff --git a/src/uix/eidos/components/month-grid/month-grid-heading.svelte b/src/uix/eidos/components/month-grid/month-grid-heading.svelte new file mode 100644 index 000000000..c64bbb122 --- /dev/null +++ b/src/uix/eidos/components/month-grid/month-grid-heading.svelte @@ -0,0 +1,16 @@ + + +{#if bodyContent} + + {#snippet children(snippetProps: MonthGridHeadingSnippetProps)} + {@render bodyContent(snippetProps)} + {/snippet} + +{:else} + +{/if} diff --git a/src/uix/eidos/components/month-grid/month-grid-next-button.svelte b/src/uix/eidos/components/month-grid/month-grid-next-button.svelte new file mode 100644 index 000000000..725309d4f --- /dev/null +++ b/src/uix/eidos/components/month-grid/month-grid-next-button.svelte @@ -0,0 +1,10 @@ + + + + {@render bodyContent?.()} + diff --git a/src/uix/eidos/components/month-grid/month-grid-prev-button.svelte b/src/uix/eidos/components/month-grid/month-grid-prev-button.svelte new file mode 100644 index 000000000..7918aa300 --- /dev/null +++ b/src/uix/eidos/components/month-grid/month-grid-prev-button.svelte @@ -0,0 +1,10 @@ + + + + {@render bodyContent?.()} + diff --git a/src/uix/eidos/components/month-grid/month-grid.css b/src/uix/eidos/components/month-grid/month-grid.css new file mode 100644 index 000000000..0398fdbf1 --- /dev/null +++ b/src/uix/eidos/components/month-grid/month-grid.css @@ -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; + } +} diff --git a/src/uix/eidos/components/month-grid/month-grid.svelte b/src/uix/eidos/components/month-grid/month-grid.svelte new file mode 100644 index 000000000..a6ebcb56f --- /dev/null +++ b/src/uix/eidos/components/month-grid/month-grid.svelte @@ -0,0 +1,31 @@ + + + + {#snippet children(snippetProps: MonthGridProviderSnippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/components/month-grid/types.ts b/src/uix/eidos/components/month-grid/types.ts new file mode 100644 index 000000000..c97de1097 --- /dev/null +++ b/src/uix/eidos/components/month-grid/types.ts @@ -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 +export type MonthGridVariant = ControlVariant +export type MonthGridColor = ColorRole + +export type MonthGridProps = SomaMonthGridProps & { + /** Visual size for cells and nav buttons. @default 'md' */ + size?: ResponsiveProp + /** 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 +} diff --git a/src/uix/eidos/components/year-grid/README.md b/src/uix/eidos/components/year-grid/README.md new file mode 100644 index 000000000..8b08eb312 --- /dev/null +++ b/src/uix/eidos/components/year-grid/README.md @@ -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 + + + + {#snippet children({ years, headingValue })} + + ‹ + + › + + + {#each years as year (year)} + + {/each} + + {/snippet} + +``` + +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`. diff --git a/src/uix/eidos/components/year-grid/index.ts b/src/uix/eidos/components/year-grid/index.ts new file mode 100644 index 000000000..d1efc8737 --- /dev/null +++ b/src/uix/eidos/components/year-grid/index.ts @@ -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' diff --git a/src/uix/eidos/components/year-grid/types.ts b/src/uix/eidos/components/year-grid/types.ts new file mode 100644 index 000000000..77c71f8cc --- /dev/null +++ b/src/uix/eidos/components/year-grid/types.ts @@ -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 +export type YearGridVariant = ControlVariant +export type YearGridColor = ColorRole + +export type YearGridProps = SomaYearGridProps & { + /** Visual size for cells and nav buttons. @default 'md' */ + size?: ResponsiveProp + /** 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 +} diff --git a/src/uix/eidos/components/year-grid/year-grid-cell.svelte b/src/uix/eidos/components/year-grid/year-grid-cell.svelte new file mode 100644 index 000000000..615b60b2b --- /dev/null +++ b/src/uix/eidos/components/year-grid/year-grid-cell.svelte @@ -0,0 +1,12 @@ + + + + {#snippet children(snippetProps: YearGridCellSnippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/components/year-grid/year-grid-grid.svelte b/src/uix/eidos/components/year-grid/year-grid-grid.svelte new file mode 100644 index 000000000..11ba378a8 --- /dev/null +++ b/src/uix/eidos/components/year-grid/year-grid-grid.svelte @@ -0,0 +1,10 @@ + + + + {@render bodyContent?.()} + diff --git a/src/uix/eidos/components/year-grid/year-grid-header.svelte b/src/uix/eidos/components/year-grid/year-grid-header.svelte new file mode 100644 index 000000000..3a99bc3dd --- /dev/null +++ b/src/uix/eidos/components/year-grid/year-grid-header.svelte @@ -0,0 +1,10 @@ + + + + {@render bodyContent?.()} + diff --git a/src/uix/eidos/components/year-grid/year-grid-heading.svelte b/src/uix/eidos/components/year-grid/year-grid-heading.svelte new file mode 100644 index 000000000..001685e36 --- /dev/null +++ b/src/uix/eidos/components/year-grid/year-grid-heading.svelte @@ -0,0 +1,16 @@ + + +{#if bodyContent} + + {#snippet children(snippetProps: YearGridHeadingSnippetProps)} + {@render bodyContent(snippetProps)} + {/snippet} + +{:else} + +{/if} diff --git a/src/uix/eidos/components/year-grid/year-grid-next-button.svelte b/src/uix/eidos/components/year-grid/year-grid-next-button.svelte new file mode 100644 index 000000000..5015fe3b9 --- /dev/null +++ b/src/uix/eidos/components/year-grid/year-grid-next-button.svelte @@ -0,0 +1,10 @@ + + + + {@render bodyContent?.()} + diff --git a/src/uix/eidos/components/year-grid/year-grid-prev-button.svelte b/src/uix/eidos/components/year-grid/year-grid-prev-button.svelte new file mode 100644 index 000000000..bd576efad --- /dev/null +++ b/src/uix/eidos/components/year-grid/year-grid-prev-button.svelte @@ -0,0 +1,10 @@ + + + + {@render bodyContent?.()} + diff --git a/src/uix/eidos/components/year-grid/year-grid.css b/src/uix/eidos/components/year-grid/year-grid.css new file mode 100644 index 000000000..2aa2a8d87 --- /dev/null +++ b/src/uix/eidos/components/year-grid/year-grid.css @@ -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; + } +} diff --git a/src/uix/eidos/components/year-grid/year-grid.svelte b/src/uix/eidos/components/year-grid/year-grid.svelte new file mode 100644 index 000000000..2a484db3d --- /dev/null +++ b/src/uix/eidos/components/year-grid/year-grid.svelte @@ -0,0 +1,31 @@ + + + + {#snippet children(snippetProps: YearGridProviderSnippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/index.css b/src/uix/eidos/index.css index a6ee62161..586dddf40 100644 --- a/src/uix/eidos/index.css +++ b/src/uix/eidos/index.css @@ -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'; diff --git a/src/uix/morfo/components/month-grid.ts b/src/uix/morfo/components/month-grid.ts new file mode 100644 index 000000000..31b54540c --- /dev/null +++ b/src/uix/morfo/components/month-grid.ts @@ -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; diff --git a/src/uix/morfo/components/year-grid.ts b/src/uix/morfo/components/year-grid.ts new file mode 100644 index 000000000..fdd2cb80f --- /dev/null +++ b/src/uix/morfo/components/year-grid.ts @@ -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; diff --git a/src/uix/soma/components/index.ts b/src/uix/soma/components/index.ts index 26d5bcf70..2bf1912a4 100644 --- a/src/uix/soma/components/index.ts +++ b/src/uix/soma/components/index.ts @@ -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'; diff --git a/src/uix/soma/components/month-grid/README.md b/src/uix/soma/components/month-grid/README.md new file mode 100644 index 000000000..a8df6d843 --- /dev/null +++ b/src/uix/soma/components/month-grid/README.md @@ -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 + + + < + + > + + + {#each months as month (month)} + + {/each} + + +``` + +## 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 | diff --git a/src/uix/soma/components/month-grid/components/month-grid-cell.svelte b/src/uix/soma/components/month-grid/components/month-grid-cell.svelte new file mode 100644 index 000000000..cc4ccddeb --- /dev/null +++ b/src/uix/soma/components/month-grid/components/month-grid-cell.svelte @@ -0,0 +1,41 @@ + + +{#if child} + {@render child({ ...state.snippetProps, props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/month-grid/components/month-grid-grid.svelte b/src/uix/soma/components/month-grid/components/month-grid-grid.svelte new file mode 100644 index 000000000..2ea88fdf6 --- /dev/null +++ b/src/uix/soma/components/month-grid/components/month-grid-grid.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/month-grid/components/month-grid-header.svelte b/src/uix/soma/components/month-grid/components/month-grid-header.svelte new file mode 100644 index 000000000..a0261a38f --- /dev/null +++ b/src/uix/soma/components/month-grid/components/month-grid-header.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/month-grid/components/month-grid-heading.svelte b/src/uix/soma/components/month-grid/components/month-grid-heading.svelte new file mode 100644 index 000000000..e6c62e28c --- /dev/null +++ b/src/uix/soma/components/month-grid/components/month-grid-heading.svelte @@ -0,0 +1,40 @@ + + +{#if child} + {@render child({ ...snippetProps, props: mergedProps })} +{:else} +
+ {#if children} + {@render children(snippetProps)} + {:else} + {state.headingValue} + {/if} +
+{/if} diff --git a/src/uix/soma/components/month-grid/components/month-grid-next-button.svelte b/src/uix/soma/components/month-grid/components/month-grid-next-button.svelte new file mode 100644 index 000000000..456a880fa --- /dev/null +++ b/src/uix/soma/components/month-grid/components/month-grid-next-button.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/month-grid/components/month-grid-prev-button.svelte b/src/uix/soma/components/month-grid/components/month-grid-prev-button.svelte new file mode 100644 index 000000000..a13c52318 --- /dev/null +++ b/src/uix/soma/components/month-grid/components/month-grid-prev-button.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/month-grid/components/month-grid.svelte b/src/uix/soma/components/month-grid/components/month-grid.svelte new file mode 100644 index 000000000..3a3bc0125 --- /dev/null +++ b/src/uix/soma/components/month-grid/components/month-grid.svelte @@ -0,0 +1,80 @@ + + +{#if child} + {@render child({ ...snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/month-grid/exports.ts b/src/uix/soma/components/month-grid/exports.ts new file mode 100644 index 000000000..fc868e941 --- /dev/null +++ b/src/uix/soma/components/month-grid/exports.ts @@ -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'; diff --git a/src/uix/soma/components/month-grid/index.ts b/src/uix/soma/components/month-grid/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/month-grid/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/month-grid/langs.ts b/src/uix/soma/components/month-grid/langs.ts new file mode 100644 index 000000000..d43f0fef3 --- /dev/null +++ b/src/uix/soma/components/month-grid/langs.ts @@ -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; diff --git a/src/uix/soma/components/month-grid/month-grid-provider.svelte.ts b/src/uix/soma/components/month-grid/month-grid-provider.svelte.ts new file mode 100644 index 000000000..103b5f94b --- /dev/null +++ b/src/uix/soma/components/month-grid/month-grid-provider.svelte.ts @@ -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; +} + +export class MonthGridProvider { + readonly opts: MonthGridOpts; + readonly runtimePart: SomaRuntimePart; + readonly soma: Soma; + readonly runtime: SomaRuntime; + static readonly ctx = context('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, 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( + `[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) => { + 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('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) => { + if (this.disabled) return; + this.provider.focusedMonth = this.month; + this.provider.select(this.month, e.currentTarget as HTMLElement); + }; + + readonly onkeydown = (e: SomaKeyboardEvent) => { + 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 }; diff --git a/src/uix/soma/components/month-grid/types.ts b/src/uix/soma/components/month-grid/types.ts new file mode 100644 index 000000000..5c9d13f88 --- /dev/null +++ b/src/uix/soma/components/month-grid/types.ts @@ -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; + /** Visible page anchor (year). Defaults to today's year. Bindable. */ + placeholder?: MonthPlaceholder; + /** Called when `placeholder` changes (prev/next nav). */ + onPlaceholderChange?: OnChangeFn; + /** 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 & + Without; + +// ── Child parts ────────────────────────────────────────────────────────────── + +export type MonthGridHeaderProps = WithChild<{ id?: string }> & + Without; + +export type MonthGridHeadingProps = WithChild<{ id?: string }, MonthGridHeadingSnippetProps> & + Without; + +export type MonthGridPrevButtonProps = WithChild<{ id?: string }> & + Without; + +export type MonthGridNextButtonProps = WithChild<{ id?: string }> & + Without; + +export type MonthGridGridProps = WithChild<{ + id?: string; + /** Explicit id of the heading that labels this grid. */ + labelledBy?: string; +}> & + Without; + +export type MonthGridCellProps = WithChild< + { + id?: string; + /** The month number (1..12) this cell represents. */ + month: number; + }, + MonthGridCellSnippetProps +> & + Without; diff --git a/src/uix/soma/components/year-grid/README.md b/src/uix/soma/components/year-grid/README.md new file mode 100644 index 000000000..75019a79b --- /dev/null +++ b/src/uix/soma/components/year-grid/README.md @@ -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 + + + < + + > + + + {#each years as year (year)} + + {/each} + + +``` + +## 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 | diff --git a/src/uix/soma/components/year-grid/components/year-grid-cell.svelte b/src/uix/soma/components/year-grid/components/year-grid-cell.svelte new file mode 100644 index 000000000..69617f6c9 --- /dev/null +++ b/src/uix/soma/components/year-grid/components/year-grid-cell.svelte @@ -0,0 +1,41 @@ + + +{#if child} + {@render child({ ...state.snippetProps, props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/year-grid/components/year-grid-grid.svelte b/src/uix/soma/components/year-grid/components/year-grid-grid.svelte new file mode 100644 index 000000000..0dd800d1d --- /dev/null +++ b/src/uix/soma/components/year-grid/components/year-grid-grid.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/year-grid/components/year-grid-header.svelte b/src/uix/soma/components/year-grid/components/year-grid-header.svelte new file mode 100644 index 000000000..74e6738c6 --- /dev/null +++ b/src/uix/soma/components/year-grid/components/year-grid-header.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/src/uix/soma/components/year-grid/components/year-grid-heading.svelte b/src/uix/soma/components/year-grid/components/year-grid-heading.svelte new file mode 100644 index 000000000..251dfc359 --- /dev/null +++ b/src/uix/soma/components/year-grid/components/year-grid-heading.svelte @@ -0,0 +1,40 @@ + + +{#if child} + {@render child({ ...snippetProps, props: mergedProps })} +{:else} +
+ {#if children} + {@render children(snippetProps)} + {:else} + {state.headingValue} + {/if} +
+{/if} diff --git a/src/uix/soma/components/year-grid/components/year-grid-next-button.svelte b/src/uix/soma/components/year-grid/components/year-grid-next-button.svelte new file mode 100644 index 000000000..00959c674 --- /dev/null +++ b/src/uix/soma/components/year-grid/components/year-grid-next-button.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/year-grid/components/year-grid-prev-button.svelte b/src/uix/soma/components/year-grid/components/year-grid-prev-button.svelte new file mode 100644 index 000000000..0df31fd7e --- /dev/null +++ b/src/uix/soma/components/year-grid/components/year-grid-prev-button.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/year-grid/components/year-grid.svelte b/src/uix/soma/components/year-grid/components/year-grid.svelte new file mode 100644 index 000000000..4acebb6b2 --- /dev/null +++ b/src/uix/soma/components/year-grid/components/year-grid.svelte @@ -0,0 +1,81 @@ + + +{#if child} + {@render child({ ...snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/year-grid/exports.ts b/src/uix/soma/components/year-grid/exports.ts new file mode 100644 index 000000000..22b03df3d --- /dev/null +++ b/src/uix/soma/components/year-grid/exports.ts @@ -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'; diff --git a/src/uix/soma/components/year-grid/index.ts b/src/uix/soma/components/year-grid/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/year-grid/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/year-grid/langs.ts b/src/uix/soma/components/year-grid/langs.ts new file mode 100644 index 000000000..c89ada30f --- /dev/null +++ b/src/uix/soma/components/year-grid/langs.ts @@ -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; diff --git a/src/uix/soma/components/year-grid/types.ts b/src/uix/soma/components/year-grid/types.ts new file mode 100644 index 000000000..bb92b2cdb --- /dev/null +++ b/src/uix/soma/components/year-grid/types.ts @@ -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; + /** Visible page anchor (year). Defaults to today's year. Bindable. */ + placeholder?: YearPlaceholder; + /** Called when `placeholder` changes (prev/next nav). */ + onPlaceholderChange?: OnChangeFn; + /** 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 & + Without; + +// ── Child parts ────────────────────────────────────────────────────────────── + +export type YearGridHeaderProps = WithChild<{ id?: string }> & + Without; + +export type YearGridHeadingProps = WithChild<{ id?: string }, YearGridHeadingSnippetProps> & + Without; + +export type YearGridPrevButtonProps = WithChild<{ id?: string }> & + Without; + +export type YearGridNextButtonProps = WithChild<{ id?: string }> & + Without; + +export type YearGridGridProps = WithChild<{ + id?: string; + /** Explicit id of the heading that labels this grid. */ + labelledBy?: string; +}> & + Without; + +export type YearGridCellProps = WithChild< + { + id?: string; + /** The year this cell represents. */ + year: number; + }, + YearGridCellSnippetProps +> & + Without; diff --git a/src/uix/soma/components/year-grid/year-grid-provider.svelte.ts b/src/uix/soma/components/year-grid/year-grid-provider.svelte.ts new file mode 100644 index 000000000..9d73f3507 --- /dev/null +++ b/src/uix/soma/components/year-grid/year-grid-provider.svelte.ts @@ -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; +} + +export class YearGridProvider { + readonly opts: YearGridOpts; + readonly runtimePart: SomaRuntimePart; + readonly soma: Soma; + readonly runtime: SomaRuntime; + static readonly ctx = context('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, 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( + `[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) => { + 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('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) => { + if (this.disabled) return; + this.provider.focusedYear = this.year; + this.provider.select(this.year, e.currentTarget as HTMLElement); + }; + + readonly onkeydown = (e: SomaKeyboardEvent) => { + 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 }; diff --git a/web/routes/uix/+layout@.svelte b/web/routes/uix/+layout@.svelte index 3034f0959..3a990f2c8 100644 --- a/web/routes/uix/+layout@.svelte +++ b/web/routes/uix/+layout@.svelte @@ -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' }, diff --git a/web/routes/uix/components/month-grid/+page.svelte b/web/routes/uix/components/month-grid/+page.svelte new file mode 100644 index 000000000..59f300fd6 --- /dev/null +++ b/web/routes/uix/components/month-grid/+page.svelte @@ -0,0 +1,495 @@ + + +
+
+
Forms · MonthGrid
+

MonthGrid

+

+ 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. +

+
+ + parts{compiled.parts.order.length} + + + events{events.length} + + + value{valueLabel} + + + year{placeholderLabel} + +
+
+ +
+
+ + {#snippet children({ months }: MonthGridProviderSnippetProps)} + + < + + > + + + {#each months as month (month)} + + {/each} + + {/snippet} + +
+
+ trace + {#if trace.length === 0} + click a month or use prev/next + {:else} + {#each trace.slice(0, 3) as entry} + {entry.event} · {entry.family}{entry.intent + ? ' · ' + entry.intent + : ''} + {fmtTime(entry.at)} + {/each} + {/if} + + value + {valueLabel} + +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+
+ soma props · value, placeholder, bounds +
+
+ + + + + + + +
+ +
+ eidos props · visual treatment +
+
+ + + +
+ +
+
+ soma + headless · standalone month picker + svelte +
+
{somaSnippet}
+
+
+
+ eidos + visual · root plus parts + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+
+ + + + + + + + + + + + + +
PropOwnerDefaultDescription
value bindablesomaundefinedSelected `{`{ year, month }`}` pair.
placeholder bindablesomatoday's yearAnchor year for the 12-month grid.
minValue / maxValuesoma-Disables cells outside the bound.
disabled / readonlysomafalseRoot state.
monthFormatsoma'short'Intl month-name length.
locale / dirsomaservicesLabel formatting and directional arrow keys.
size eidoseidosmdVisual scale.
variant eidoseidossurfaceSurface treatment.
color eidoseidosprimaryIntent palette for selected month.
+
+ +
Reference comparison
+
+ + + + + + + + + +
FeatureArk UIReact AriaChakraBits UIUIX
Standalone month gridpart of DatePickervia compositionnovia Calendarfirst-class
Bounds (min/max)yesyes-yesyes
Keyboard navyesyes-yesyes
Locale labelsyesyes-yesyes
Semantic eventscallbackscallbacks-callbackscommit-set / nav-step
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

+ morfo · declarative contract +

+
+ + + + {#each partsList as part (part.kebab)} + + + + + + + + + {/each} + +
PartMarkerElementRoleArchetypeOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '-'}{part.archetype ?? '-'}{part.optional ? 'yes' : 'no'}
+
+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema · events +

+
+ + + + {#each events as action (action.name)} + {@const intentDecl = action.semantic.intent} + + + + + + + + + + {/each} + +
NameFamilyVerbSequenceIntentTargetPlay
{action.name}{action.semantic.family}{action.semantic.verb ?? '-'}{action.semantic.sequence ?? 'pre'}{typeof intentDecl === 'string' ? intentDecl : intentDecl ? 'fromProp' : '-'}{action.target}
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+
+ + + + + + + + + + +
SelectorPurpose
[data-month-grid]Root surface, size and variant tokens.
[data-month-grid-header]Three-column nav header (prev · heading · next).
[data-month-grid-grid]3×4 month grid.
[data-month-grid-cell][data-selected]Selected month tile.
[data-month-grid-cell][data-current]Today's month accent.
[data-month-grid-cell][data-disabled]Out of bounds / disabled.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
Keyboard
+
+ + + + + + + + + +
KeyAction
← / →Move focus ±1 month (RTL-flipped).
↑ / ↓Move focus ±3 months (row width).
Home / EndFirst / last month of year.
PageUp / PageDown±1 year (also moves placeholder).
Enter / SpaceSelect focused month.
+
+
APG
+

Follows WAI-ARIA APG Grid pattern: role="grid" on the grid container, role="gridcell" + roving tabindex on cells.

+
+ {/if} +
diff --git a/web/routes/uix/components/year-grid/+page.svelte b/web/routes/uix/components/year-grid/+page.svelte new file mode 100644 index 000000000..88923e210 --- /dev/null +++ b/web/routes/uix/components/year-grid/+page.svelte @@ -0,0 +1,492 @@ + + +
+
+
Forms · YearGrid
+

YearGrid

+

+ 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. +

+
+ + parts{compiled.parts.order.length} + + + events{events.length} + + + value{valueLabel} + + + anchor{placeholderLabel} + +
+
+ +
+
+ + {#snippet children({ years }: YearGridProviderSnippetProps)} + + < + + > + + + {#each years as year (year)} + + {/each} + + {/snippet} + +
+
+ trace + {#if trace.length === 0} + click a year or use prev/next + {:else} + {#each trace.slice(0, 3) as entry} + {entry.event} · {entry.family}{entry.intent + ? ' · ' + entry.intent + : ''} + {fmtTime(entry.at)} + {/each} + {/if} + + value + {valueLabel} + +
+
+ +
+ + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+
+ soma props · value, placeholder, bounds +
+
+ + + + + + + +
+ +
+ eidos props · visual treatment +
+
+ + + +
+ +
+
+ soma + headless · standalone year picker + svelte +
+
{somaSnippet}
+
+
+
+ eidos + visual · root plus parts + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+
+ + + + + + + + + + + + + +
PropOwnerDefaultDescription
value bindablesomaundefinedSelected `{`{ year }`}`.
placeholder bindablesomatoday's yearAnchor year for the visible page.
minValue / maxValuesoma-Disables cells outside the bound.
decadeSizesoma12Years per page.
disabled / readonlysomafalseRoot state.
locale / dirsomaservicesYear formatting and directional arrow keys.
size eidoseidosmdVisual scale.
variant eidoseidossurfaceSurface treatment.
color eidoseidosprimaryIntent palette for selected year.
+
+ +
Reference comparison
+
+ + + + + + + + + +
FeatureArk UIReact AriaChakraBits UIUIX
Standalone year gridpart of DatePickervia compositionnovia Calendarfirst-class
Configurable page sizefixed 12fixed 12-fixed 12decadeSize
Bounds (min/max)yesyes-yesyes
Keyboard navyesyes-yesyes
Semantic eventscallbackscallbacks-callbackscommit-set / nav-step
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

+ morfo · declarative contract +

+
+ + + + {#each partsList as part (part.kebab)} + + + + + + + + + {/each} + +
PartMarkerElementRoleArchetypeOptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '-'}{part.archetype ?? '-'}{part.optional ? 'yes' : 'no'}
+
+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema · events +

+
+ + + + {#each events as action (action.name)} + {@const intentDecl = action.semantic.intent} + + + + + + + + + + {/each} + +
NameFamilyVerbSequenceIntentTargetPlay
{action.name}{action.semantic.family}{action.semantic.verb ?? '-'}{action.semantic.sequence ?? 'pre'}{typeof intentDecl === 'string' ? intentDecl : intentDecl ? 'fromProp' : '-'}{action.target}
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+
+ + + + + + + + + + +
SelectorPurpose
[data-year-grid]Root surface, size and variant tokens.
[data-year-grid-header]Three-column nav header (prev · heading · next).
[data-year-grid-grid]3-column year grid.
[data-year-grid-cell][data-selected]Selected year tile.
[data-year-grid-cell][data-current]Current year accent.
[data-year-grid-cell][data-disabled]Out of bounds / disabled.
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
Keyboard
+
+ + + + + + + + + +
KeyAction
← / →Move focus ±1 year (RTL-flipped).
↑ / ↓Move focus ±3 years (3-col layout).
Home / EndFirst / last year of page.
PageUp / PageDown±decadeSize years (page step).
Enter / SpaceSelect focused year.
+
+
APG
+

Follows WAI-ARIA APG Grid pattern: role="grid" on the grid container, role="gridcell" + roving tabindex on cells.

+
+ {/if} +