DatePicker.MonthView / YearView require a DatePickerProvider context and
can't be used standalone. This commit introduces MonthGrid and YearGrid
as independent primitives that accept value / placeholder / minValue /
maxValue as props and work anywhere.
morfo
- New morfo/components/month-grid.ts and year-grid.ts.
- scope: ['soma','sema'], apg: grid pattern.
- 7 parts each: Provider (role=application) / Header / Heading /
PrevButton / NextButton / Grid (role=grid) / Cell (role=gridcell).
- Events: commit-set (cell click) + nav-step (prev/next).
- Cell data-attrs: data-value, data-selected, data-current, data-disabled,
data-focused.
- Keyboard map covers arrows / Home / End / PageUp/Down / Enter / Space.
soma
- New soma/components/{month-grid,year-grid}/ with provider class +
langs + types + exports + 7 thin svelte wrappers each.
- MonthGridProvider: 12-cell grid keyed to placeholder.year; localized
month names via Intl.DateTimeFormat with configurable monthFormat
('long'|'short'|'narrow'). Prev/Next step ±1 year.
- YearGridProvider: decadeSize-cell grid (default 12); page anchored at
placeholder.year - (placeholder.year % decadeSize). Prev/Next step ±
decadeSize.
- Both expose Cell sub-provider with snippetProps {selected, disabled,
current, value, label} + roving tabindex + RTL-aware keyboard nav.
eidos
- New eidos/components/{month-grid,year-grid}/ — namespace-style index
with size / variant / color visual props + dedicated CSS reusing the
--calendar-* token family.
- Each grid renders Header (Prev/Heading/Next) + 3-col Grid.
demo
- New /uix/components/month-grid and /uix/components/year-grid demo
pages with the standard live/api/morfo/sema/recipe/a11y tabs,
interactive controls for every public prop, soma+eidos snippets that
reactively rebuild from controls, and sema event play buttons.
Verified: svelte-check --threshold error -> 0 errors / 0 warnings /
4572 files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
parent
dd22cfc2bc
commit
f7831563a1
@ -0,0 +1,46 @@
|
||||
import MonthGridComponent from './month-grid.svelte'
|
||||
import Header from './month-grid-header.svelte'
|
||||
import Heading from './month-grid-heading.svelte'
|
||||
import PrevButton from './month-grid-prev-button.svelte'
|
||||
import NextButton from './month-grid-next-button.svelte'
|
||||
import Grid from './month-grid-grid.svelte'
|
||||
import Cell from './month-grid-cell.svelte'
|
||||
|
||||
type MonthGridNamespace = typeof MonthGridComponent & {
|
||||
Header: typeof Header
|
||||
Heading: typeof Heading
|
||||
PrevButton: typeof PrevButton
|
||||
NextButton: typeof NextButton
|
||||
Grid: typeof Grid
|
||||
Cell: typeof Cell
|
||||
}
|
||||
|
||||
const MonthGrid = MonthGridComponent as MonthGridNamespace
|
||||
MonthGrid.Header = Header
|
||||
MonthGrid.Heading = Heading
|
||||
MonthGrid.PrevButton = PrevButton
|
||||
MonthGrid.NextButton = NextButton
|
||||
MonthGrid.Grid = Grid
|
||||
MonthGrid.Cell = Cell
|
||||
|
||||
export { MonthGrid }
|
||||
|
||||
export default MonthGrid
|
||||
|
||||
export type {
|
||||
MonthGridProps,
|
||||
MonthGridHeaderProps as HeaderProps,
|
||||
MonthGridHeadingProps as HeadingProps,
|
||||
MonthGridPrevButtonProps as PrevButtonProps,
|
||||
MonthGridNextButtonProps as NextButtonProps,
|
||||
MonthGridGridProps as GridProps,
|
||||
MonthGridCellProps as CellProps,
|
||||
MonthGridProviderSnippetProps,
|
||||
MonthGridHeadingSnippetProps,
|
||||
MonthGridCellSnippetProps,
|
||||
MonthGridSize,
|
||||
MonthGridVariant,
|
||||
MonthGridColor,
|
||||
MonthValue,
|
||||
MonthPlaceholder
|
||||
} from './types'
|
||||
@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as MonthGrid from '$soma/components/month-grid';
|
||||
import type { MonthGridCellProps, MonthGridCellSnippetProps } from './types';
|
||||
|
||||
let { children: bodyContent, ...rest }: MonthGridCellProps = $props();
|
||||
</script>
|
||||
|
||||
<MonthGrid.Cell {...rest}>
|
||||
{#snippet children(snippetProps: MonthGridCellSnippetProps)}
|
||||
{@render bodyContent?.(snippetProps)}
|
||||
{/snippet}
|
||||
</MonthGrid.Cell>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as MonthGrid from '$soma/components/month-grid';
|
||||
import type { MonthGridGridProps } from './types';
|
||||
|
||||
let { children: bodyContent, ...rest }: MonthGridGridProps = $props();
|
||||
</script>
|
||||
|
||||
<MonthGrid.Grid {...rest}>
|
||||
{@render bodyContent?.()}
|
||||
</MonthGrid.Grid>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as MonthGrid from '$soma/components/month-grid';
|
||||
import type { MonthGridHeaderProps } from './types';
|
||||
|
||||
let { children: bodyContent, ...rest }: MonthGridHeaderProps = $props();
|
||||
</script>
|
||||
|
||||
<MonthGrid.Header {...rest}>
|
||||
{@render bodyContent?.()}
|
||||
</MonthGrid.Header>
|
||||
@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import * as MonthGrid from '$soma/components/month-grid';
|
||||
import type { MonthGridHeadingProps, MonthGridHeadingSnippetProps } from './types';
|
||||
|
||||
let { children: bodyContent, ...rest }: MonthGridHeadingProps = $props();
|
||||
</script>
|
||||
|
||||
{#if bodyContent}
|
||||
<MonthGrid.Heading {...rest}>
|
||||
{#snippet children(snippetProps: MonthGridHeadingSnippetProps)}
|
||||
{@render bodyContent(snippetProps)}
|
||||
{/snippet}
|
||||
</MonthGrid.Heading>
|
||||
{:else}
|
||||
<MonthGrid.Heading {...rest} />
|
||||
{/if}
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as MonthGrid from '$soma/components/month-grid';
|
||||
import type { MonthGridNextButtonProps } from './types';
|
||||
|
||||
let { children: bodyContent, ...rest }: MonthGridNextButtonProps = $props();
|
||||
</script>
|
||||
|
||||
<MonthGrid.NextButton {...rest}>
|
||||
{@render bodyContent?.()}
|
||||
</MonthGrid.NextButton>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as MonthGrid from '$soma/components/month-grid';
|
||||
import type { MonthGridPrevButtonProps } from './types';
|
||||
|
||||
let { children: bodyContent, ...rest }: MonthGridPrevButtonProps = $props();
|
||||
</script>
|
||||
|
||||
<MonthGrid.PrevButton {...rest}>
|
||||
{@render bodyContent?.()}
|
||||
</MonthGrid.PrevButton>
|
||||
@ -0,0 +1,265 @@
|
||||
/* MonthGrid — reuses calendar tokens for visual consistency. */
|
||||
|
||||
[data-month-grid] {
|
||||
--_month-grid-padding: var(--calendar-padding-md);
|
||||
--_month-grid-control-size: var(--calendar-control-size-md);
|
||||
--_month-grid-cell-size: var(--calendar-day-size-md);
|
||||
--_month-grid-font-size: var(--calendar-font-size-md);
|
||||
--_month-grid-accent-solid: var(--color-primary-solid);
|
||||
--_month-grid-accent-solid-hover: var(--color-primary-solid-hover);
|
||||
--_month-grid-accent-track: var(--color-primary-track);
|
||||
--_month-grid-accent-border: var(--color-primary-border);
|
||||
--_month-grid-accent-text: var(--color-primary-text);
|
||||
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: var(--calendar-gap);
|
||||
inline-size: max-content;
|
||||
max-inline-size: 100%;
|
||||
padding: var(--_month-grid-padding);
|
||||
border: var(--calendar-border-width) solid var(--calendar-border);
|
||||
border-radius: var(--calendar-radius);
|
||||
background: var(--calendar-bg);
|
||||
box-shadow: var(--calendar-shadow);
|
||||
color: var(--calendar-color);
|
||||
font-family: var(--calendar-font-family);
|
||||
font-size: var(--_month-grid-font-size);
|
||||
line-height: var(--calendar-line-height);
|
||||
}
|
||||
|
||||
[data-month-grid][data-size='sm'] {
|
||||
--_month-grid-padding: var(--calendar-padding-sm);
|
||||
--_month-grid-control-size: var(--calendar-control-size-sm);
|
||||
--_month-grid-cell-size: var(--calendar-day-size-sm);
|
||||
--_month-grid-font-size: var(--calendar-font-size-sm);
|
||||
}
|
||||
|
||||
[data-month-grid][data-size='lg'] {
|
||||
--_month-grid-padding: var(--calendar-padding-lg);
|
||||
--_month-grid-control-size: var(--calendar-control-size-lg);
|
||||
--_month-grid-cell-size: var(--calendar-day-size-lg);
|
||||
--_month-grid-font-size: var(--calendar-font-size-lg);
|
||||
}
|
||||
|
||||
[data-month-grid][data-variant='outline'] {
|
||||
background: var(--calendar-outline-bg);
|
||||
box-shadow: var(--calendar-outline-shadow);
|
||||
}
|
||||
|
||||
[data-month-grid][data-variant='ghost'] {
|
||||
padding: 0;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-month-grid][data-color='secondary'] {
|
||||
--_month-grid-accent-solid: var(--color-secondary-solid);
|
||||
--_month-grid-accent-solid-hover: var(--color-secondary-solid-hover);
|
||||
--_month-grid-accent-track: var(--color-secondary-track);
|
||||
--_month-grid-accent-border: var(--color-secondary-border);
|
||||
--_month-grid-accent-text: var(--color-secondary-text);
|
||||
}
|
||||
|
||||
[data-month-grid][data-color='neutral'] {
|
||||
--_month-grid-accent-solid: var(--color-neutral-solid);
|
||||
--_month-grid-accent-solid-hover: var(--color-neutral-solid-hover);
|
||||
--_month-grid-accent-track: var(--color-neutral-track);
|
||||
--_month-grid-accent-border: var(--color-neutral-border);
|
||||
--_month-grid-accent-text: var(--color-neutral-text);
|
||||
}
|
||||
|
||||
[data-month-grid][data-color='affirm'] {
|
||||
--_month-grid-accent-solid: var(--color-affirm-solid);
|
||||
--_month-grid-accent-solid-hover: var(--color-affirm-solid-hover);
|
||||
--_month-grid-accent-track: var(--color-affirm-track);
|
||||
--_month-grid-accent-border: var(--color-affirm-border);
|
||||
--_month-grid-accent-text: var(--color-affirm-text);
|
||||
}
|
||||
|
||||
[data-month-grid][data-color='fulfill'] {
|
||||
--_month-grid-accent-solid: var(--color-fulfill-solid);
|
||||
--_month-grid-accent-solid-hover: var(--color-fulfill-solid-hover);
|
||||
--_month-grid-accent-track: var(--color-fulfill-track);
|
||||
--_month-grid-accent-border: var(--color-fulfill-border);
|
||||
--_month-grid-accent-text: var(--color-fulfill-text);
|
||||
}
|
||||
|
||||
[data-month-grid][data-color='risk'] {
|
||||
--_month-grid-accent-solid: var(--color-risk-solid);
|
||||
--_month-grid-accent-solid-hover: var(--color-risk-solid-hover);
|
||||
--_month-grid-accent-track: var(--color-risk-track);
|
||||
--_month-grid-accent-border: var(--color-risk-border);
|
||||
--_month-grid-accent-text: var(--color-risk-text);
|
||||
}
|
||||
|
||||
[data-month-grid][data-color='threat'] {
|
||||
--_month-grid-accent-solid: var(--color-threat-solid);
|
||||
--_month-grid-accent-solid-hover: var(--color-threat-solid-hover);
|
||||
--_month-grid-accent-track: var(--color-threat-track);
|
||||
--_month-grid-accent-border: var(--color-threat-border);
|
||||
--_month-grid-accent-text: var(--color-threat-text);
|
||||
}
|
||||
|
||||
[data-month-grid][data-color='loss'] {
|
||||
--_month-grid-accent-solid: var(--color-loss-solid);
|
||||
--_month-grid-accent-solid-hover: var(--color-loss-solid-hover);
|
||||
--_month-grid-accent-track: var(--color-loss-track);
|
||||
--_month-grid-accent-border: var(--color-loss-border);
|
||||
--_month-grid-accent-text: var(--color-loss-text);
|
||||
}
|
||||
|
||||
[data-month-grid-header] {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
var(--_month-grid-control-size)
|
||||
minmax(0, 1fr)
|
||||
var(--_month-grid-control-size);
|
||||
align-items: center;
|
||||
gap: var(--calendar-header-gap);
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
[data-month-grid-heading] {
|
||||
min-inline-size: 0;
|
||||
color: var(--calendar-heading-color);
|
||||
font-size: var(--calendar-heading-font-size);
|
||||
font-weight: var(--calendar-heading-font-weight);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-month-grid-prev-button],
|
||||
[data-month-grid-next-button] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
inline-size: var(--_month-grid-control-size);
|
||||
block-size: var(--_month-grid-control-size);
|
||||
padding: 0;
|
||||
border: var(--calendar-control-border-width) solid var(--calendar-control-border);
|
||||
border-radius: var(--calendar-control-radius);
|
||||
background: var(--calendar-control-bg);
|
||||
color: var(--calendar-control-color);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--calendar-transition-duration) var(--calendar-transition-ease),
|
||||
border-color var(--calendar-transition-duration) var(--calendar-transition-ease),
|
||||
color var(--calendar-transition-duration) var(--calendar-transition-ease),
|
||||
box-shadow var(--calendar-transition-duration) var(--calendar-transition-ease);
|
||||
}
|
||||
|
||||
[data-month-grid-prev-button]:hover,
|
||||
[data-month-grid-next-button]:hover {
|
||||
border-color: var(--calendar-control-border-hover);
|
||||
background: var(--calendar-control-bg-hover);
|
||||
color: var(--calendar-control-color-hover);
|
||||
}
|
||||
|
||||
[data-month-grid-prev-button]:focus-visible,
|
||||
[data-month-grid-next-button]:focus-visible,
|
||||
[data-month-grid-cell]:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--calendar-focus-shadow);
|
||||
}
|
||||
|
||||
[data-month-grid-prev-button][data-disabled],
|
||||
[data-month-grid-next-button][data-disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: var(--calendar-disabled-opacity);
|
||||
}
|
||||
|
||||
[data-month-grid-grid] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--calendar-grid-gap);
|
||||
inline-size: max-content;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
|
||||
[data-month-grid-cell] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-inline-size: calc(var(--_month-grid-cell-size) * 2);
|
||||
block-size: var(--_month-grid-cell-size);
|
||||
padding-inline: var(--calendar-select-px, 8px);
|
||||
border: var(--calendar-day-border-width) solid var(--calendar-day-border);
|
||||
border-radius: var(--calendar-day-radius);
|
||||
background: var(--calendar-day-bg);
|
||||
color: var(--calendar-day-color);
|
||||
font: inherit;
|
||||
font-weight: var(--calendar-day-font-weight);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition:
|
||||
background var(--calendar-transition-duration) var(--calendar-transition-ease),
|
||||
border-color var(--calendar-transition-duration) var(--calendar-transition-ease),
|
||||
color var(--calendar-transition-duration) var(--calendar-transition-ease),
|
||||
box-shadow var(--calendar-transition-duration) var(--calendar-transition-ease),
|
||||
opacity var(--calendar-transition-duration) var(--calendar-transition-ease);
|
||||
}
|
||||
|
||||
[data-month-grid-cell]:hover {
|
||||
border-color: var(--calendar-day-border-hover);
|
||||
background: var(--calendar-day-bg-hover);
|
||||
color: var(--calendar-day-color-hover);
|
||||
}
|
||||
|
||||
[data-month-grid-cell][data-selected] {
|
||||
border-color: var(--_month-grid-accent-solid);
|
||||
background: var(--_month-grid-accent-solid);
|
||||
color: var(--calendar-day-selected-color);
|
||||
}
|
||||
|
||||
[data-month-grid-cell][data-selected]:hover {
|
||||
border-color: var(--_month-grid-accent-solid-hover);
|
||||
background: var(--_month-grid-accent-solid-hover);
|
||||
}
|
||||
|
||||
[data-month-grid-cell][data-focused]:not([data-selected]) {
|
||||
border-color: var(--_month-grid-accent-border);
|
||||
background: var(--_month-grid-accent-track);
|
||||
color: var(--_month-grid-accent-text);
|
||||
}
|
||||
|
||||
[data-month-grid-cell][data-current]:not([data-selected]) {
|
||||
border-color: var(--calendar-day-today-border);
|
||||
color: var(--calendar-day-today-color);
|
||||
font-weight: var(--calendar-day-today-font-weight);
|
||||
}
|
||||
|
||||
[data-month-grid-cell][data-disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: var(--calendar-disabled-opacity);
|
||||
}
|
||||
|
||||
[data-month-grid][data-disabled] {
|
||||
opacity: var(--calendar-disabled-opacity);
|
||||
}
|
||||
|
||||
[data-month-grid][data-readonly] [data-month-grid-cell]:not([data-selected]) {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
[data-month-grid][data-readonly] [data-month-grid-cell]:not([data-selected]):hover {
|
||||
background: var(--calendar-day-bg);
|
||||
border-color: var(--calendar-day-border);
|
||||
color: var(--calendar-day-color);
|
||||
}
|
||||
|
||||
[data-month-grid][data-event],
|
||||
[data-month-grid-cell][data-event],
|
||||
[data-month-grid-prev-button][data-event],
|
||||
[data-month-grid-next-button][data-event] {
|
||||
box-shadow: var(--calendar-event-shadow);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-month-grid-prev-button],
|
||||
[data-month-grid-next-button],
|
||||
[data-month-grid-cell] {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import * as MonthGrid from '$soma/components/month-grid';
|
||||
import type { MonthGridProps, MonthGridProviderSnippetProps } from './types';
|
||||
|
||||
let {
|
||||
size = 'md',
|
||||
variant = 'surface',
|
||||
color = 'primary',
|
||||
value = $bindable(),
|
||||
placeholder = $bindable(),
|
||||
children: bodyContent,
|
||||
...rest
|
||||
}: MonthGridProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const resolvedSize = $derived(eidos.resolve(size, 'md'));
|
||||
</script>
|
||||
|
||||
<MonthGrid.Provider
|
||||
{...rest}
|
||||
bind:value
|
||||
bind:placeholder
|
||||
data-size={resolvedSize}
|
||||
data-variant={variant}
|
||||
data-color={color}
|
||||
>
|
||||
{#snippet children(snippetProps: MonthGridProviderSnippetProps)}
|
||||
{@render bodyContent?.(snippetProps)}
|
||||
{/snippet}
|
||||
</MonthGrid.Provider>
|
||||
@ -0,0 +1,43 @@
|
||||
import type {
|
||||
MonthGridProps as SomaMonthGridProps,
|
||||
MonthGridHeaderProps as SomaMonthGridHeaderProps,
|
||||
MonthGridHeadingProps as SomaMonthGridHeadingProps,
|
||||
MonthGridPrevButtonProps as SomaMonthGridPrevButtonProps,
|
||||
MonthGridNextButtonProps as SomaMonthGridNextButtonProps,
|
||||
MonthGridGridProps as SomaMonthGridGridProps,
|
||||
MonthGridCellProps as SomaMonthGridCellProps,
|
||||
MonthGridProviderSnippetProps,
|
||||
MonthGridHeadingSnippetProps,
|
||||
MonthGridCellSnippetProps,
|
||||
MonthValue,
|
||||
MonthPlaceholder
|
||||
} from '$soma/components/month-grid'
|
||||
import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types'
|
||||
|
||||
export type MonthGridSize = Extract<Size, 'sm' | 'md' | 'lg'>
|
||||
export type MonthGridVariant = ControlVariant
|
||||
export type MonthGridColor = ColorRole
|
||||
|
||||
export type MonthGridProps = SomaMonthGridProps & {
|
||||
/** Visual size for cells and nav buttons. @default 'md' */
|
||||
size?: ResponsiveProp<MonthGridSize>
|
||||
/** Visual treatment of the surface. @default 'surface' */
|
||||
variant?: MonthGridVariant
|
||||
/** Intent palette used for selected month and focus affordances. @default 'primary' */
|
||||
color?: MonthGridColor
|
||||
}
|
||||
|
||||
export type MonthGridHeaderProps = SomaMonthGridHeaderProps
|
||||
export type MonthGridHeadingProps = SomaMonthGridHeadingProps
|
||||
export type MonthGridPrevButtonProps = SomaMonthGridPrevButtonProps
|
||||
export type MonthGridNextButtonProps = SomaMonthGridNextButtonProps
|
||||
export type MonthGridGridProps = SomaMonthGridGridProps
|
||||
export type MonthGridCellProps = SomaMonthGridCellProps
|
||||
|
||||
export type {
|
||||
MonthGridProviderSnippetProps,
|
||||
MonthGridHeadingSnippetProps,
|
||||
MonthGridCellSnippetProps,
|
||||
MonthValue,
|
||||
MonthPlaceholder
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
import YearGridComponent from './year-grid.svelte'
|
||||
import Header from './year-grid-header.svelte'
|
||||
import Heading from './year-grid-heading.svelte'
|
||||
import PrevButton from './year-grid-prev-button.svelte'
|
||||
import NextButton from './year-grid-next-button.svelte'
|
||||
import Grid from './year-grid-grid.svelte'
|
||||
import Cell from './year-grid-cell.svelte'
|
||||
|
||||
type YearGridNamespace = typeof YearGridComponent & {
|
||||
Header: typeof Header
|
||||
Heading: typeof Heading
|
||||
PrevButton: typeof PrevButton
|
||||
NextButton: typeof NextButton
|
||||
Grid: typeof Grid
|
||||
Cell: typeof Cell
|
||||
}
|
||||
|
||||
const YearGrid = YearGridComponent as YearGridNamespace
|
||||
YearGrid.Header = Header
|
||||
YearGrid.Heading = Heading
|
||||
YearGrid.PrevButton = PrevButton
|
||||
YearGrid.NextButton = NextButton
|
||||
YearGrid.Grid = Grid
|
||||
YearGrid.Cell = Cell
|
||||
|
||||
export { YearGrid }
|
||||
|
||||
export default YearGrid
|
||||
|
||||
export type {
|
||||
YearGridProps,
|
||||
YearGridHeaderProps as HeaderProps,
|
||||
YearGridHeadingProps as HeadingProps,
|
||||
YearGridPrevButtonProps as PrevButtonProps,
|
||||
YearGridNextButtonProps as NextButtonProps,
|
||||
YearGridGridProps as GridProps,
|
||||
YearGridCellProps as CellProps,
|
||||
YearGridProviderSnippetProps,
|
||||
YearGridHeadingSnippetProps,
|
||||
YearGridCellSnippetProps,
|
||||
YearGridSize,
|
||||
YearGridVariant,
|
||||
YearGridColor,
|
||||
YearValue,
|
||||
YearPlaceholder
|
||||
} from './types'
|
||||
@ -0,0 +1,43 @@
|
||||
import type {
|
||||
YearGridProps as SomaYearGridProps,
|
||||
YearGridHeaderProps as SomaYearGridHeaderProps,
|
||||
YearGridHeadingProps as SomaYearGridHeadingProps,
|
||||
YearGridPrevButtonProps as SomaYearGridPrevButtonProps,
|
||||
YearGridNextButtonProps as SomaYearGridNextButtonProps,
|
||||
YearGridGridProps as SomaYearGridGridProps,
|
||||
YearGridCellProps as SomaYearGridCellProps,
|
||||
YearGridProviderSnippetProps,
|
||||
YearGridHeadingSnippetProps,
|
||||
YearGridCellSnippetProps,
|
||||
YearValue,
|
||||
YearPlaceholder
|
||||
} from '$soma/components/year-grid'
|
||||
import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types'
|
||||
|
||||
export type YearGridSize = Extract<Size, 'sm' | 'md' | 'lg'>
|
||||
export type YearGridVariant = ControlVariant
|
||||
export type YearGridColor = ColorRole
|
||||
|
||||
export type YearGridProps = SomaYearGridProps & {
|
||||
/** Visual size for cells and nav buttons. @default 'md' */
|
||||
size?: ResponsiveProp<YearGridSize>
|
||||
/** Visual treatment of the surface. @default 'surface' */
|
||||
variant?: YearGridVariant
|
||||
/** Intent palette used for selected year and focus affordances. @default 'primary' */
|
||||
color?: YearGridColor
|
||||
}
|
||||
|
||||
export type YearGridHeaderProps = SomaYearGridHeaderProps
|
||||
export type YearGridHeadingProps = SomaYearGridHeadingProps
|
||||
export type YearGridPrevButtonProps = SomaYearGridPrevButtonProps
|
||||
export type YearGridNextButtonProps = SomaYearGridNextButtonProps
|
||||
export type YearGridGridProps = SomaYearGridGridProps
|
||||
export type YearGridCellProps = SomaYearGridCellProps
|
||||
|
||||
export type {
|
||||
YearGridProviderSnippetProps,
|
||||
YearGridHeadingSnippetProps,
|
||||
YearGridCellSnippetProps,
|
||||
YearValue,
|
||||
YearPlaceholder
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as YearGrid from '$soma/components/year-grid';
|
||||
import type { YearGridCellProps, YearGridCellSnippetProps } from './types';
|
||||
|
||||
let { children: bodyContent, ...rest }: YearGridCellProps = $props();
|
||||
</script>
|
||||
|
||||
<YearGrid.Cell {...rest}>
|
||||
{#snippet children(snippetProps: YearGridCellSnippetProps)}
|
||||
{@render bodyContent?.(snippetProps)}
|
||||
{/snippet}
|
||||
</YearGrid.Cell>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as YearGrid from '$soma/components/year-grid';
|
||||
import type { YearGridGridProps } from './types';
|
||||
|
||||
let { children: bodyContent, ...rest }: YearGridGridProps = $props();
|
||||
</script>
|
||||
|
||||
<YearGrid.Grid {...rest}>
|
||||
{@render bodyContent?.()}
|
||||
</YearGrid.Grid>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as YearGrid from '$soma/components/year-grid';
|
||||
import type { YearGridHeaderProps } from './types';
|
||||
|
||||
let { children: bodyContent, ...rest }: YearGridHeaderProps = $props();
|
||||
</script>
|
||||
|
||||
<YearGrid.Header {...rest}>
|
||||
{@render bodyContent?.()}
|
||||
</YearGrid.Header>
|
||||
@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import * as YearGrid from '$soma/components/year-grid';
|
||||
import type { YearGridHeadingProps, YearGridHeadingSnippetProps } from './types';
|
||||
|
||||
let { children: bodyContent, ...rest }: YearGridHeadingProps = $props();
|
||||
</script>
|
||||
|
||||
{#if bodyContent}
|
||||
<YearGrid.Heading {...rest}>
|
||||
{#snippet children(snippetProps: YearGridHeadingSnippetProps)}
|
||||
{@render bodyContent(snippetProps)}
|
||||
{/snippet}
|
||||
</YearGrid.Heading>
|
||||
{:else}
|
||||
<YearGrid.Heading {...rest} />
|
||||
{/if}
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as YearGrid from '$soma/components/year-grid';
|
||||
import type { YearGridNextButtonProps } from './types';
|
||||
|
||||
let { children: bodyContent, ...rest }: YearGridNextButtonProps = $props();
|
||||
</script>
|
||||
|
||||
<YearGrid.NextButton {...rest}>
|
||||
{@render bodyContent?.()}
|
||||
</YearGrid.NextButton>
|
||||
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import * as YearGrid from '$soma/components/year-grid';
|
||||
import type { YearGridPrevButtonProps } from './types';
|
||||
|
||||
let { children: bodyContent, ...rest }: YearGridPrevButtonProps = $props();
|
||||
</script>
|
||||
|
||||
<YearGrid.PrevButton {...rest}>
|
||||
{@render bodyContent?.()}
|
||||
</YearGrid.PrevButton>
|
||||
@ -0,0 +1,265 @@
|
||||
/* YearGrid — reuses calendar tokens for visual consistency. */
|
||||
|
||||
[data-year-grid] {
|
||||
--_year-grid-padding: var(--calendar-padding-md);
|
||||
--_year-grid-control-size: var(--calendar-control-size-md);
|
||||
--_year-grid-cell-size: var(--calendar-day-size-md);
|
||||
--_year-grid-font-size: var(--calendar-font-size-md);
|
||||
--_year-grid-accent-solid: var(--color-primary-solid);
|
||||
--_year-grid-accent-solid-hover: var(--color-primary-solid-hover);
|
||||
--_year-grid-accent-track: var(--color-primary-track);
|
||||
--_year-grid-accent-border: var(--color-primary-border);
|
||||
--_year-grid-accent-text: var(--color-primary-text);
|
||||
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: var(--calendar-gap);
|
||||
inline-size: max-content;
|
||||
max-inline-size: 100%;
|
||||
padding: var(--_year-grid-padding);
|
||||
border: var(--calendar-border-width) solid var(--calendar-border);
|
||||
border-radius: var(--calendar-radius);
|
||||
background: var(--calendar-bg);
|
||||
box-shadow: var(--calendar-shadow);
|
||||
color: var(--calendar-color);
|
||||
font-family: var(--calendar-font-family);
|
||||
font-size: var(--_year-grid-font-size);
|
||||
line-height: var(--calendar-line-height);
|
||||
}
|
||||
|
||||
[data-year-grid][data-size='sm'] {
|
||||
--_year-grid-padding: var(--calendar-padding-sm);
|
||||
--_year-grid-control-size: var(--calendar-control-size-sm);
|
||||
--_year-grid-cell-size: var(--calendar-day-size-sm);
|
||||
--_year-grid-font-size: var(--calendar-font-size-sm);
|
||||
}
|
||||
|
||||
[data-year-grid][data-size='lg'] {
|
||||
--_year-grid-padding: var(--calendar-padding-lg);
|
||||
--_year-grid-control-size: var(--calendar-control-size-lg);
|
||||
--_year-grid-cell-size: var(--calendar-day-size-lg);
|
||||
--_year-grid-font-size: var(--calendar-font-size-lg);
|
||||
}
|
||||
|
||||
[data-year-grid][data-variant='outline'] {
|
||||
background: var(--calendar-outline-bg);
|
||||
box-shadow: var(--calendar-outline-shadow);
|
||||
}
|
||||
|
||||
[data-year-grid][data-variant='ghost'] {
|
||||
padding: 0;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-year-grid][data-color='secondary'] {
|
||||
--_year-grid-accent-solid: var(--color-secondary-solid);
|
||||
--_year-grid-accent-solid-hover: var(--color-secondary-solid-hover);
|
||||
--_year-grid-accent-track: var(--color-secondary-track);
|
||||
--_year-grid-accent-border: var(--color-secondary-border);
|
||||
--_year-grid-accent-text: var(--color-secondary-text);
|
||||
}
|
||||
|
||||
[data-year-grid][data-color='neutral'] {
|
||||
--_year-grid-accent-solid: var(--color-neutral-solid);
|
||||
--_year-grid-accent-solid-hover: var(--color-neutral-solid-hover);
|
||||
--_year-grid-accent-track: var(--color-neutral-track);
|
||||
--_year-grid-accent-border: var(--color-neutral-border);
|
||||
--_year-grid-accent-text: var(--color-neutral-text);
|
||||
}
|
||||
|
||||
[data-year-grid][data-color='affirm'] {
|
||||
--_year-grid-accent-solid: var(--color-affirm-solid);
|
||||
--_year-grid-accent-solid-hover: var(--color-affirm-solid-hover);
|
||||
--_year-grid-accent-track: var(--color-affirm-track);
|
||||
--_year-grid-accent-border: var(--color-affirm-border);
|
||||
--_year-grid-accent-text: var(--color-affirm-text);
|
||||
}
|
||||
|
||||
[data-year-grid][data-color='fulfill'] {
|
||||
--_year-grid-accent-solid: var(--color-fulfill-solid);
|
||||
--_year-grid-accent-solid-hover: var(--color-fulfill-solid-hover);
|
||||
--_year-grid-accent-track: var(--color-fulfill-track);
|
||||
--_year-grid-accent-border: var(--color-fulfill-border);
|
||||
--_year-grid-accent-text: var(--color-fulfill-text);
|
||||
}
|
||||
|
||||
[data-year-grid][data-color='risk'] {
|
||||
--_year-grid-accent-solid: var(--color-risk-solid);
|
||||
--_year-grid-accent-solid-hover: var(--color-risk-solid-hover);
|
||||
--_year-grid-accent-track: var(--color-risk-track);
|
||||
--_year-grid-accent-border: var(--color-risk-border);
|
||||
--_year-grid-accent-text: var(--color-risk-text);
|
||||
}
|
||||
|
||||
[data-year-grid][data-color='threat'] {
|
||||
--_year-grid-accent-solid: var(--color-threat-solid);
|
||||
--_year-grid-accent-solid-hover: var(--color-threat-solid-hover);
|
||||
--_year-grid-accent-track: var(--color-threat-track);
|
||||
--_year-grid-accent-border: var(--color-threat-border);
|
||||
--_year-grid-accent-text: var(--color-threat-text);
|
||||
}
|
||||
|
||||
[data-year-grid][data-color='loss'] {
|
||||
--_year-grid-accent-solid: var(--color-loss-solid);
|
||||
--_year-grid-accent-solid-hover: var(--color-loss-solid-hover);
|
||||
--_year-grid-accent-track: var(--color-loss-track);
|
||||
--_year-grid-accent-border: var(--color-loss-border);
|
||||
--_year-grid-accent-text: var(--color-loss-text);
|
||||
}
|
||||
|
||||
[data-year-grid-header] {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
var(--_year-grid-control-size)
|
||||
minmax(0, 1fr)
|
||||
var(--_year-grid-control-size);
|
||||
align-items: center;
|
||||
gap: var(--calendar-header-gap);
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
[data-year-grid-heading] {
|
||||
min-inline-size: 0;
|
||||
color: var(--calendar-heading-color);
|
||||
font-size: var(--calendar-heading-font-size);
|
||||
font-weight: var(--calendar-heading-font-weight);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-year-grid-prev-button],
|
||||
[data-year-grid-next-button] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
inline-size: var(--_year-grid-control-size);
|
||||
block-size: var(--_year-grid-control-size);
|
||||
padding: 0;
|
||||
border: var(--calendar-control-border-width) solid var(--calendar-control-border);
|
||||
border-radius: var(--calendar-control-radius);
|
||||
background: var(--calendar-control-bg);
|
||||
color: var(--calendar-control-color);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--calendar-transition-duration) var(--calendar-transition-ease),
|
||||
border-color var(--calendar-transition-duration) var(--calendar-transition-ease),
|
||||
color var(--calendar-transition-duration) var(--calendar-transition-ease),
|
||||
box-shadow var(--calendar-transition-duration) var(--calendar-transition-ease);
|
||||
}
|
||||
|
||||
[data-year-grid-prev-button]:hover,
|
||||
[data-year-grid-next-button]:hover {
|
||||
border-color: var(--calendar-control-border-hover);
|
||||
background: var(--calendar-control-bg-hover);
|
||||
color: var(--calendar-control-color-hover);
|
||||
}
|
||||
|
||||
[data-year-grid-prev-button]:focus-visible,
|
||||
[data-year-grid-next-button]:focus-visible,
|
||||
[data-year-grid-cell]:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--calendar-focus-shadow);
|
||||
}
|
||||
|
||||
[data-year-grid-prev-button][data-disabled],
|
||||
[data-year-grid-next-button][data-disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: var(--calendar-disabled-opacity);
|
||||
}
|
||||
|
||||
[data-year-grid-grid] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--calendar-grid-gap);
|
||||
inline-size: max-content;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
|
||||
[data-year-grid-cell] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-inline-size: calc(var(--_year-grid-cell-size) * 1.8);
|
||||
block-size: var(--_year-grid-cell-size);
|
||||
padding-inline: var(--calendar-select-px, 8px);
|
||||
border: var(--calendar-day-border-width) solid var(--calendar-day-border);
|
||||
border-radius: var(--calendar-day-radius);
|
||||
background: var(--calendar-day-bg);
|
||||
color: var(--calendar-day-color);
|
||||
font: inherit;
|
||||
font-weight: var(--calendar-day-font-weight);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition:
|
||||
background var(--calendar-transition-duration) var(--calendar-transition-ease),
|
||||
border-color var(--calendar-transition-duration) var(--calendar-transition-ease),
|
||||
color var(--calendar-transition-duration) var(--calendar-transition-ease),
|
||||
box-shadow var(--calendar-transition-duration) var(--calendar-transition-ease),
|
||||
opacity var(--calendar-transition-duration) var(--calendar-transition-ease);
|
||||
}
|
||||
|
||||
[data-year-grid-cell]:hover {
|
||||
border-color: var(--calendar-day-border-hover);
|
||||
background: var(--calendar-day-bg-hover);
|
||||
color: var(--calendar-day-color-hover);
|
||||
}
|
||||
|
||||
[data-year-grid-cell][data-selected] {
|
||||
border-color: var(--_year-grid-accent-solid);
|
||||
background: var(--_year-grid-accent-solid);
|
||||
color: var(--calendar-day-selected-color);
|
||||
}
|
||||
|
||||
[data-year-grid-cell][data-selected]:hover {
|
||||
border-color: var(--_year-grid-accent-solid-hover);
|
||||
background: var(--_year-grid-accent-solid-hover);
|
||||
}
|
||||
|
||||
[data-year-grid-cell][data-focused]:not([data-selected]) {
|
||||
border-color: var(--_year-grid-accent-border);
|
||||
background: var(--_year-grid-accent-track);
|
||||
color: var(--_year-grid-accent-text);
|
||||
}
|
||||
|
||||
[data-year-grid-cell][data-current]:not([data-selected]) {
|
||||
border-color: var(--calendar-day-today-border);
|
||||
color: var(--calendar-day-today-color);
|
||||
font-weight: var(--calendar-day-today-font-weight);
|
||||
}
|
||||
|
||||
[data-year-grid-cell][data-disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: var(--calendar-disabled-opacity);
|
||||
}
|
||||
|
||||
[data-year-grid][data-disabled] {
|
||||
opacity: var(--calendar-disabled-opacity);
|
||||
}
|
||||
|
||||
[data-year-grid][data-readonly] [data-year-grid-cell]:not([data-selected]) {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
[data-year-grid][data-readonly] [data-year-grid-cell]:not([data-selected]):hover {
|
||||
background: var(--calendar-day-bg);
|
||||
border-color: var(--calendar-day-border);
|
||||
color: var(--calendar-day-color);
|
||||
}
|
||||
|
||||
[data-year-grid][data-event],
|
||||
[data-year-grid-cell][data-event],
|
||||
[data-year-grid-prev-button][data-event],
|
||||
[data-year-grid-next-button][data-event] {
|
||||
box-shadow: var(--calendar-event-shadow);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-year-grid-prev-button],
|
||||
[data-year-grid-next-button],
|
||||
[data-year-grid-cell] {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import * as YearGrid from '$soma/components/year-grid';
|
||||
import type { YearGridProps, YearGridProviderSnippetProps } from './types';
|
||||
|
||||
let {
|
||||
size = 'md',
|
||||
variant = 'surface',
|
||||
color = 'primary',
|
||||
value = $bindable(),
|
||||
placeholder = $bindable(),
|
||||
children: bodyContent,
|
||||
...rest
|
||||
}: YearGridProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
const resolvedSize = $derived(eidos.resolve(size, 'md'));
|
||||
</script>
|
||||
|
||||
<YearGrid.Provider
|
||||
{...rest}
|
||||
bind:value
|
||||
bind:placeholder
|
||||
data-size={resolvedSize}
|
||||
data-variant={variant}
|
||||
data-color={color}
|
||||
>
|
||||
{#snippet children(snippetProps: YearGridProviderSnippetProps)}
|
||||
{@render bodyContent?.(snippetProps)}
|
||||
{/snippet}
|
||||
</YearGrid.Provider>
|
||||
@ -0,0 +1,163 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
export const monthGridMorfo = {
|
||||
name: 'MonthGrid',
|
||||
kebab: 'month-grid',
|
||||
scope: ['soma', 'sema'],
|
||||
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/',
|
||||
texts: {
|
||||
label: '#?components.month-grid.label|Month grid',
|
||||
prevYear: '#?common.month-grid.prev-year|Previous year',
|
||||
nextYear: '#?common.month-grid.next-year|Next year'
|
||||
},
|
||||
events: [
|
||||
{
|
||||
name: 'commit-set',
|
||||
semantic: {
|
||||
family: 'commit',
|
||||
verb: 'set',
|
||||
target: v.partRef('cell'),
|
||||
intent: 'affirm',
|
||||
sequence: 'post'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'nav-step',
|
||||
semantic: {
|
||||
family: 'shift',
|
||||
verb: 'navigate',
|
||||
target: v.partRef('provider'),
|
||||
sequence: 'post'
|
||||
}
|
||||
}
|
||||
],
|
||||
parts: [
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'provider',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'application',
|
||||
optional: false,
|
||||
data: [
|
||||
{ attr: 'data-readonly', severity: 'optional' },
|
||||
{ attr: 'data-disabled', severity: 'optional' }
|
||||
],
|
||||
aria: [
|
||||
{ attr: 'aria-label', value: v.propRef('ariaLabel'), severity: 'recommended' },
|
||||
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
|
||||
],
|
||||
keyboard: [
|
||||
{ key: 'ArrowRight', action: 'next-month' },
|
||||
{ key: 'ArrowLeft', action: 'prev-month' },
|
||||
{ key: 'ArrowDown', action: 'next-row' },
|
||||
{ key: 'ArrowUp', action: 'prev-row' },
|
||||
{ key: 'PageUp', action: 'prev-year' },
|
||||
{ key: 'PageDown', action: 'next-year' },
|
||||
{ key: 'Home', action: 'first-month' },
|
||||
{ key: 'End', action: 'last-month' },
|
||||
{ key: 'Enter', action: 'select' },
|
||||
{ key: ' ', action: 'select' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Header',
|
||||
kebab: 'header',
|
||||
archetype: 'header',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'Heading',
|
||||
kebab: 'heading',
|
||||
archetype: 'header',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'PrevButton',
|
||||
kebab: 'prev-button',
|
||||
archetype: 'trigger',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: true,
|
||||
data: [{ attr: 'data-disabled', severity: 'optional' }],
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{
|
||||
attr: 'aria-label',
|
||||
value: v.commonRef('month-grid.prev-year', 'Previous year'),
|
||||
severity: 'recommended'
|
||||
},
|
||||
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'NextButton',
|
||||
kebab: 'next-button',
|
||||
archetype: 'trigger',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: true,
|
||||
data: [{ attr: 'data-disabled', severity: 'optional' }],
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{
|
||||
attr: 'aria-label',
|
||||
value: v.commonRef('month-grid.next-year', 'Next year'),
|
||||
severity: 'recommended'
|
||||
},
|
||||
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Grid',
|
||||
kebab: 'grid',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'grid',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-labelledby',
|
||||
value: v.partRef('heading'),
|
||||
condition: { when: 'part-present', part: 'heading' },
|
||||
severity: 'recommended'
|
||||
},
|
||||
{ attr: 'aria-readonly', value: v.propRef('readonly'), severity: 'optional' },
|
||||
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Cell',
|
||||
kebab: 'cell',
|
||||
archetype: 'item',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'gridcell',
|
||||
optional: false,
|
||||
data: [
|
||||
{ attr: 'data-value' },
|
||||
{ attr: 'data-selected', severity: 'optional' },
|
||||
{ attr: 'data-current', severity: 'optional' },
|
||||
{ attr: 'data-disabled', severity: 'optional' },
|
||||
{ attr: 'data-focused', severity: 'optional' }
|
||||
],
|
||||
aria: [
|
||||
{ attr: 'aria-selected', value: v.propRef('selected'), severity: 'optional' },
|
||||
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
|
||||
]
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,163 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
export const yearGridMorfo = {
|
||||
name: 'YearGrid',
|
||||
kebab: 'year-grid',
|
||||
scope: ['soma', 'sema'],
|
||||
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/',
|
||||
texts: {
|
||||
label: '#?components.year-grid.label|Year grid',
|
||||
prevPage: '#?common.year-grid.prev-page|Previous years',
|
||||
nextPage: '#?common.year-grid.next-page|Next years'
|
||||
},
|
||||
events: [
|
||||
{
|
||||
name: 'commit-set',
|
||||
semantic: {
|
||||
family: 'commit',
|
||||
verb: 'set',
|
||||
target: v.partRef('cell'),
|
||||
intent: 'affirm',
|
||||
sequence: 'post'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'nav-step',
|
||||
semantic: {
|
||||
family: 'shift',
|
||||
verb: 'navigate',
|
||||
target: v.partRef('provider'),
|
||||
sequence: 'post'
|
||||
}
|
||||
}
|
||||
],
|
||||
parts: [
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'provider',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'application',
|
||||
optional: false,
|
||||
data: [
|
||||
{ attr: 'data-readonly', severity: 'optional' },
|
||||
{ attr: 'data-disabled', severity: 'optional' }
|
||||
],
|
||||
aria: [
|
||||
{ attr: 'aria-label', value: v.propRef('ariaLabel'), severity: 'recommended' },
|
||||
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
|
||||
],
|
||||
keyboard: [
|
||||
{ key: 'ArrowRight', action: 'next-year' },
|
||||
{ key: 'ArrowLeft', action: 'prev-year' },
|
||||
{ key: 'ArrowDown', action: 'next-row' },
|
||||
{ key: 'ArrowUp', action: 'prev-row' },
|
||||
{ key: 'PageUp', action: 'prev-page' },
|
||||
{ key: 'PageDown', action: 'next-page' },
|
||||
{ key: 'Home', action: 'first-year' },
|
||||
{ key: 'End', action: 'last-year' },
|
||||
{ key: 'Enter', action: 'select' },
|
||||
{ key: ' ', action: 'select' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Header',
|
||||
kebab: 'header',
|
||||
archetype: 'header',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'Heading',
|
||||
kebab: 'heading',
|
||||
archetype: 'header',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'PrevButton',
|
||||
kebab: 'prev-button',
|
||||
archetype: 'trigger',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: true,
|
||||
data: [{ attr: 'data-disabled', severity: 'optional' }],
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{
|
||||
attr: 'aria-label',
|
||||
value: v.commonRef('year-grid.prev-page', 'Previous years'),
|
||||
severity: 'recommended'
|
||||
},
|
||||
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'NextButton',
|
||||
kebab: 'next-button',
|
||||
archetype: 'trigger',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'button',
|
||||
optional: true,
|
||||
data: [{ attr: 'data-disabled', severity: 'optional' }],
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{
|
||||
attr: 'aria-label',
|
||||
value: v.commonRef('year-grid.next-page', 'Next years'),
|
||||
severity: 'recommended'
|
||||
},
|
||||
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Grid',
|
||||
kebab: 'grid',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'grid',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-labelledby',
|
||||
value: v.partRef('heading'),
|
||||
condition: { when: 'part-present', part: 'heading' },
|
||||
severity: 'recommended'
|
||||
},
|
||||
{ attr: 'aria-readonly', value: v.propRef('readonly'), severity: 'optional' },
|
||||
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Cell',
|
||||
kebab: 'cell',
|
||||
archetype: 'item',
|
||||
kind: 'public',
|
||||
defaultElement: 'button',
|
||||
role: 'gridcell',
|
||||
optional: false,
|
||||
data: [
|
||||
{ attr: 'data-value' },
|
||||
{ attr: 'data-selected', severity: 'optional' },
|
||||
{ attr: 'data-current', severity: 'optional' },
|
||||
{ attr: 'data-disabled', severity: 'optional' },
|
||||
{ attr: 'data-focused', severity: 'optional' }
|
||||
],
|
||||
aria: [
|
||||
{ attr: 'aria-selected', value: v.propRef('selected'), severity: 'optional' },
|
||||
{ attr: 'aria-disabled', value: v.propRef('disabled'), severity: 'optional' }
|
||||
]
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,39 @@
|
||||
# Soma MonthGrid
|
||||
|
||||
Standalone month-picker primitive: 12 cells (Jan..Dec) for a single year,
|
||||
prev/next year navigation, keyboard nav and morfo-declared semantic events.
|
||||
Independent of `Calendar` and `DatePicker`.
|
||||
|
||||
## Composition
|
||||
|
||||
```svelte
|
||||
<MonthGrid.Provider bind:value bind:placeholder>
|
||||
<MonthGrid.Header>
|
||||
<MonthGrid.PrevButton><</MonthGrid.PrevButton>
|
||||
<MonthGrid.Heading />
|
||||
<MonthGrid.NextButton>></MonthGrid.NextButton>
|
||||
</MonthGrid.Header>
|
||||
<MonthGrid.Grid>
|
||||
{#each months as month (month)}
|
||||
<MonthGrid.Cell {month} />
|
||||
{/each}
|
||||
</MonthGrid.Grid>
|
||||
</MonthGrid.Provider>
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
| Event | Family | Target | Intent | When |
|
||||
| ------------ | -------- | ------ | ------ | ----------------------------------- |
|
||||
| `commit-set` | `commit` | `cell` | affirm | A month cell is clicked or Enter'd. |
|
||||
| `nav-step` | `shift` | `provider` | - | Prev/Next year or keyboard PageUp/PageDown. |
|
||||
|
||||
## Keyboard
|
||||
|
||||
| Key | Action |
|
||||
| -------------------- | ------------------------------------------- |
|
||||
| Arrow Left / Right | ±1 month (RTL-flipped horizontally) |
|
||||
| Arrow Up / Down | ±3 months (row width) |
|
||||
| Home / End | First / last month of year |
|
||||
| PageUp / PageDown | ±1 year (also moves the placeholder) |
|
||||
| Enter / Space | Select focused month |
|
||||
@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { MonthGridCellProvider } from '../month-grid-provider.svelte';
|
||||
import type { MonthGridCellProps, MonthGridCellSnippetProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'month-grid-cell'),
|
||||
month,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: MonthGridCellProps = $props();
|
||||
|
||||
const state = MonthGridCellProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
month: readableActive(() => month)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...state.snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>
|
||||
{#if children}
|
||||
{@render children(state.snippetProps as MonthGridCellSnippetProps)}
|
||||
{:else}
|
||||
{state.snippetProps.label}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { MonthGridGridProvider } from '../month-grid-provider.svelte';
|
||||
import type { MonthGridGridProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'month-grid-grid'),
|
||||
labelledBy,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: MonthGridGridProps = $props();
|
||||
|
||||
const state = MonthGridGridProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
labelledBy: readableActive(() => labelledBy)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { MonthGridHeaderProvider } from '../month-grid-provider.svelte';
|
||||
import type { MonthGridHeaderProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'month-grid-header'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: MonthGridHeaderProps = $props();
|
||||
|
||||
const state = MonthGridHeaderProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { MonthGridHeadingProvider } from '../month-grid-provider.svelte';
|
||||
import type { MonthGridHeadingProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'month-grid-heading'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: MonthGridHeadingProps = $props();
|
||||
|
||||
const state = MonthGridHeadingProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
const snippetProps = $derived({ headingValue: state.headingValue });
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{#if children}
|
||||
{@render children(snippetProps)}
|
||||
{:else}
|
||||
{state.headingValue}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { MonthGridNextButtonProvider } from '../month-grid-provider.svelte';
|
||||
import type { MonthGridNextButtonProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'month-grid-next-button'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: MonthGridNextButtonProps = $props();
|
||||
|
||||
const state = MonthGridNextButtonProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { MonthGridPrevButtonProvider } from '../month-grid-provider.svelte';
|
||||
import type { MonthGridPrevButtonProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'month-grid-prev-button'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: MonthGridPrevButtonProps = $props();
|
||||
|
||||
const state = MonthGridPrevButtonProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import {
|
||||
MonthGridProvider,
|
||||
resolveInitialMonthPlaceholder
|
||||
} from '../month-grid-provider.svelte';
|
||||
import type { MonthGridProps } from '../types';
|
||||
import { Soma } from '../../../core/soma.svelte';
|
||||
|
||||
const uid = $props.id();
|
||||
const soma = Soma.get();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'month-grid'),
|
||||
value = $bindable(),
|
||||
onValueChange = () => {},
|
||||
placeholder = $bindable(),
|
||||
onPlaceholderChange = () => {},
|
||||
minValue,
|
||||
maxValue,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
dir,
|
||||
locale,
|
||||
monthFormat = 'short',
|
||||
ariaLabel,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: MonthGridProps = $props();
|
||||
|
||||
const state = MonthGridProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
placeholder: writableActive(
|
||||
() => resolveInitialMonthPlaceholder(placeholder, value),
|
||||
(v) => {
|
||||
placeholder = v;
|
||||
onPlaceholderChange(v);
|
||||
}
|
||||
),
|
||||
value: writableActive(
|
||||
() => value,
|
||||
(v) => {
|
||||
value = v;
|
||||
onValueChange(v);
|
||||
}
|
||||
),
|
||||
minValue: readableActive(() => minValue),
|
||||
maxValue: readableActive(() => maxValue),
|
||||
disabled: readableActive(() => disabled),
|
||||
readonly: readableActive(() => readonly),
|
||||
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
|
||||
locale: readableActive(() => locale ?? ''),
|
||||
monthFormat: readableActive(() => monthFormat),
|
||||
ariaLabel: readableActive(() => ariaLabel)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
|
||||
const snippetProps = $derived({
|
||||
months: state.months,
|
||||
year: state.placeholderYear,
|
||||
headingValue: state.headingValue
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.(snippetProps)}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,22 @@
|
||||
export { default as Provider } from './components/month-grid.svelte';
|
||||
export { default as Header } from './components/month-grid-header.svelte';
|
||||
export { default as Heading } from './components/month-grid-heading.svelte';
|
||||
export { default as PrevButton } from './components/month-grid-prev-button.svelte';
|
||||
export { default as NextButton } from './components/month-grid-next-button.svelte';
|
||||
export { default as Grid } from './components/month-grid-grid.svelte';
|
||||
export { default as Cell } from './components/month-grid-cell.svelte';
|
||||
|
||||
export type {
|
||||
MonthValue,
|
||||
MonthPlaceholder,
|
||||
MonthGridProps,
|
||||
MonthGridHeaderProps,
|
||||
MonthGridHeadingProps,
|
||||
MonthGridPrevButtonProps,
|
||||
MonthGridNextButtonProps,
|
||||
MonthGridGridProps,
|
||||
MonthGridCellProps,
|
||||
MonthGridProviderSnippetProps,
|
||||
MonthGridHeadingSnippetProps,
|
||||
MonthGridCellSnippetProps
|
||||
} from './types';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,6 @@
|
||||
/** Idlangref constants for MonthGrid. Values are fallback defaults. */
|
||||
export const MONTH_GRID_LANGS = {
|
||||
LABEL: '#?components.month-grid.label|Month grid',
|
||||
PREV_YEAR: '#?common.month-grid.prev-year|Previous year',
|
||||
NEXT_YEAR: '#?common.month-grid.next-year|Next year'
|
||||
} as const;
|
||||
@ -0,0 +1,522 @@
|
||||
import { context, type WithRefOpts } from '../../provider';
|
||||
import { boolToEmptyStrOrUndef, boolToStr } from '$adom';
|
||||
import { state, type ActiveProps, type StateProps } from '$libs/reactive';
|
||||
import type { SomaKeyboardEvent, SomaMouseEvent, OnChangeFn } from '../../types';
|
||||
import { KEYS, getDirectionalKeys } from '../../keyboard';
|
||||
import { Soma } from '../../core/soma.svelte';
|
||||
import { MONTH_GRID_LANGS } from './langs';
|
||||
import { today, getLocalTimeZone } from '$libs/days';
|
||||
|
||||
import { monthGridMorfo } from '../../../morfo/components/month-grid';
|
||||
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
|
||||
import type { MonthValue, MonthPlaceholder } from './types';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function compareMonth(a: MonthValue, b: MonthValue): number {
|
||||
if (a.year !== b.year) return a.year - b.year;
|
||||
return a.month - b.month;
|
||||
}
|
||||
|
||||
function sameMonth(a: MonthValue | undefined, b: MonthValue | undefined): boolean {
|
||||
if (!a || !b) return a === b;
|
||||
return a.year === b.year && a.month === b.month;
|
||||
}
|
||||
|
||||
function inBounds(
|
||||
value: MonthValue,
|
||||
min: MonthValue | undefined,
|
||||
max: MonthValue | undefined
|
||||
): boolean {
|
||||
if (min && compareMonth(value, min) < 0) return false;
|
||||
if (max && compareMonth(value, max) > 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveInitialMonthPlaceholder(
|
||||
placeholder: MonthPlaceholder | undefined,
|
||||
value: MonthValue | undefined
|
||||
): MonthPlaceholder {
|
||||
if (placeholder) return placeholder;
|
||||
if (value) return { year: value.year };
|
||||
const t = today(getLocalTimeZone());
|
||||
return { year: t.year };
|
||||
}
|
||||
|
||||
// ── Root provider ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MonthGridOpts
|
||||
extends
|
||||
WithRefOpts,
|
||||
StateProps<{
|
||||
placeholder: MonthPlaceholder;
|
||||
value: MonthValue | undefined;
|
||||
}>,
|
||||
ActiveProps<{
|
||||
minValue: MonthValue | undefined;
|
||||
maxValue: MonthValue | undefined;
|
||||
disabled: boolean;
|
||||
readonly: boolean;
|
||||
dir: 'ltr' | 'rtl';
|
||||
locale: string;
|
||||
monthFormat: 'long' | 'short' | 'narrow';
|
||||
ariaLabel: string | undefined;
|
||||
}> {
|
||||
onValueChange?: OnChangeFn<MonthValue | undefined>;
|
||||
}
|
||||
|
||||
export class MonthGridProvider {
|
||||
readonly opts: MonthGridOpts;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
readonly soma: Soma;
|
||||
readonly runtime: SomaRuntime;
|
||||
static readonly ctx = context<MonthGridProvider>('MonthGrid');
|
||||
static get(): MonthGridProvider | undefined {
|
||||
return this.ctx.getOr(undefined) as MonthGridProvider | undefined;
|
||||
}
|
||||
static require(): MonthGridProvider {
|
||||
return this.ctx.get();
|
||||
}
|
||||
static create(opts: MonthGridOpts) {
|
||||
return new MonthGridProvider(opts);
|
||||
}
|
||||
|
||||
/** Heading id exposed so Grid can wire aria-labelledby. */
|
||||
headingId = state('');
|
||||
/** Currently focused month (for roving tabindex). */
|
||||
focusedMonth = $state(1);
|
||||
|
||||
private constructor(opts: MonthGridOpts) {
|
||||
this.opts = opts;
|
||||
this.soma = Soma.require();
|
||||
this.runtime = this.soma.runtime(monthGridMorfo, {});
|
||||
this.runtimePart = this.runtime.part('provider', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
context: MonthGridProvider.ctx
|
||||
});
|
||||
// Initial focused month follows selection or today.
|
||||
const initial = this.opts.value.current?.month ?? today(getLocalTimeZone()).month;
|
||||
this.focusedMonth = initial;
|
||||
}
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
|
||||
readonly effectiveLocale = $derived.by(
|
||||
() => this.opts.locale.current || this.soma.langs.getLocale() || 'en'
|
||||
);
|
||||
|
||||
readonly placeholderYear = $derived.by(() => this.opts.placeholder.current.year);
|
||||
|
||||
readonly months: number[] = $derived.by(() => Array.from({ length: 12 }, (_, i) => i + 1));
|
||||
|
||||
readonly headingValue = $derived.by(() =>
|
||||
new Intl.DateTimeFormat(this.effectiveLocale, { year: 'numeric' }).format(
|
||||
new Date(Date.UTC(this.placeholderYear, 5, 15))
|
||||
)
|
||||
);
|
||||
|
||||
readonly todayMonth: MonthValue = $derived.by(() => {
|
||||
const t = today(getLocalTimeZone());
|
||||
return { year: t.year, month: t.month };
|
||||
});
|
||||
|
||||
readonly fullLabel = $derived.by(() => {
|
||||
const base =
|
||||
this.opts.ariaLabel.current ??
|
||||
this.soma.langs.ts(MONTH_GRID_LANGS.LABEL) ??
|
||||
'Month grid';
|
||||
return `${base}, ${this.headingValue}`;
|
||||
});
|
||||
|
||||
formatMonth(month: number): string {
|
||||
const fmt = this.opts.monthFormat.current ?? 'short';
|
||||
const anchor = new Date(Date.UTC(2024, month - 1, 15));
|
||||
return new Intl.DateTimeFormat(this.effectiveLocale, { month: fmt }).format(anchor);
|
||||
}
|
||||
|
||||
// ── Bound checks ──────────────────────────────────────────────────────────
|
||||
|
||||
isDisabledMonth(month: number): boolean {
|
||||
if (this.opts.disabled.current) return true;
|
||||
return !inBounds(
|
||||
{ year: this.placeholderYear, month },
|
||||
this.opts.minValue.current,
|
||||
this.opts.maxValue.current
|
||||
);
|
||||
}
|
||||
|
||||
isSelectedMonth(month: number): boolean {
|
||||
const v = this.opts.value.current;
|
||||
if (!v) return false;
|
||||
return v.year === this.placeholderYear && v.month === month;
|
||||
}
|
||||
|
||||
isCurrentMonth(month: number): boolean {
|
||||
const t = this.todayMonth;
|
||||
return t.year === this.placeholderYear && t.month === month;
|
||||
}
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
private setPlaceholder(next: MonthPlaceholder, target?: HTMLElement): void {
|
||||
if (this.opts.placeholder.current.year === next.year) return;
|
||||
this.opts.placeholder.current = next;
|
||||
void this.runtime.trigger('nav-step', target ? { fallbackTarget: target } : undefined);
|
||||
}
|
||||
|
||||
prevPage(target?: HTMLElement): void {
|
||||
this.setPlaceholder({ year: this.placeholderYear - 1 }, target);
|
||||
}
|
||||
|
||||
nextPage(target?: HTMLElement): void {
|
||||
this.setPlaceholder({ year: this.placeholderYear + 1 }, target);
|
||||
}
|
||||
|
||||
// ── Selection ─────────────────────────────────────────────────────────────
|
||||
|
||||
select(month: number, target?: HTMLElement): void {
|
||||
if (this.opts.readonly.current || this.opts.disabled.current) return;
|
||||
if (this.isDisabledMonth(month)) return;
|
||||
const next: MonthValue = { year: this.placeholderYear, month };
|
||||
const prev = this.opts.value.current;
|
||||
if (sameMonth(prev, next)) return;
|
||||
this.opts.value.current = next;
|
||||
void this.runtime.trigger('commit-set', target ? { fallbackTarget: target } : undefined);
|
||||
}
|
||||
|
||||
// ── Keyboard ──────────────────────────────────────────────────────────────
|
||||
|
||||
handleCellKeydown(e: SomaKeyboardEvent<HTMLElement>, month: number): boolean {
|
||||
const { nextKey: horizNext, prevKey: horizPrev } = getDirectionalKeys(
|
||||
this.opts.dir.current,
|
||||
'horizontal'
|
||||
);
|
||||
|
||||
let nextMonth: number | undefined;
|
||||
let nextYearStep = 0;
|
||||
if (e.key === horizNext) nextMonth = month + 1;
|
||||
else if (e.key === horizPrev) nextMonth = month - 1;
|
||||
else if (e.key === KEYS.ARROW_DOWN) nextMonth = month + 3;
|
||||
else if (e.key === KEYS.ARROW_UP) nextMonth = month - 3;
|
||||
else if (e.key === KEYS.HOME) nextMonth = 1;
|
||||
else if (e.key === KEYS.END) nextMonth = 12;
|
||||
else if (e.key === KEYS.PAGE_UP) {
|
||||
nextYearStep = -1;
|
||||
nextMonth = month;
|
||||
} else if (e.key === KEYS.PAGE_DOWN) {
|
||||
nextYearStep = 1;
|
||||
nextMonth = month;
|
||||
} else if (e.key === KEYS.ENTER || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this.select(month, e.currentTarget as HTMLElement);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// Clamp month and overflow into year steps.
|
||||
if (nextMonth! < 1) {
|
||||
const overflow = Math.ceil((1 - nextMonth!) / 12);
|
||||
nextYearStep -= overflow;
|
||||
nextMonth = nextMonth! + overflow * 12;
|
||||
} else if (nextMonth! > 12) {
|
||||
const overflow = Math.ceil((nextMonth! - 12) / 12);
|
||||
nextYearStep += overflow;
|
||||
nextMonth = nextMonth! - overflow * 12;
|
||||
}
|
||||
|
||||
if (nextYearStep !== 0) {
|
||||
this.setPlaceholder(
|
||||
{ year: this.placeholderYear + nextYearStep },
|
||||
e.currentTarget as HTMLElement
|
||||
);
|
||||
}
|
||||
|
||||
this.focusedMonth = nextMonth!;
|
||||
|
||||
queueMicrotask(() => {
|
||||
const root = this.opts.ref.current;
|
||||
if (!root) return;
|
||||
const el = root.querySelector<HTMLElement>(
|
||||
`[data-month-grid-cell][data-value="${this.placeholderYear}-${nextMonth!}"]`
|
||||
);
|
||||
this.soma.dom.focus(el);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Bound prev/next ──────────────────────────────────────────────────────
|
||||
|
||||
readonly isPrevButtonDisabled = $derived.by(() => {
|
||||
if (this.opts.disabled.current) return true;
|
||||
const min = this.opts.minValue.current;
|
||||
if (!min) return false;
|
||||
// Last selectable month of the previous year.
|
||||
return compareMonth({ year: this.placeholderYear - 1, month: 12 }, min) < 0;
|
||||
});
|
||||
|
||||
readonly isNextButtonDisabled = $derived.by(() => {
|
||||
if (this.opts.disabled.current) return true;
|
||||
const max = this.opts.maxValue.current;
|
||||
if (!max) return false;
|
||||
// First selectable month of the next year.
|
||||
return compareMonth({ year: this.placeholderYear + 1, month: 1 }, max) > 0;
|
||||
});
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.runtimePart.assert({
|
||||
...this.runtimePart.props,
|
||||
role: 'application' as const,
|
||||
dir: this.opts.dir.current,
|
||||
'aria-label': this.fullLabel,
|
||||
'aria-disabled': boolToStr(this.opts.disabled.current),
|
||||
'data-readonly': boolToEmptyStrOrUndef(this.opts.readonly.current),
|
||||
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current)
|
||||
} as const)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Header ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export class MonthGridHeaderProvider {
|
||||
readonly opts: WithRefOpts;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
static create(opts: WithRefOpts) {
|
||||
return new MonthGridHeaderProvider(opts);
|
||||
}
|
||||
private constructor(opts: WithRefOpts) {
|
||||
this.opts = opts;
|
||||
const provider = MonthGridProvider.require();
|
||||
this.runtimePart = provider.runtime.part('header', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this
|
||||
});
|
||||
}
|
||||
readonly props = $derived.by(() =>
|
||||
this.runtimePart.assert({ ...this.runtimePart.props } as const)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Heading ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export class MonthGridHeadingProvider {
|
||||
readonly opts: WithRefOpts;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
readonly provider: MonthGridProvider;
|
||||
static create(opts: WithRefOpts) {
|
||||
return new MonthGridHeadingProvider(opts);
|
||||
}
|
||||
private constructor(opts: WithRefOpts) {
|
||||
this.opts = opts;
|
||||
this.provider = MonthGridProvider.require();
|
||||
this.runtimePart = this.provider.runtime.part('heading', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this
|
||||
});
|
||||
this.provider.headingId.current = opts.id.current;
|
||||
}
|
||||
|
||||
readonly headingValue = $derived.by(() => this.provider.headingValue);
|
||||
|
||||
readonly props = $derived.by(() =>
|
||||
this.runtimePart.assert({ ...this.runtimePart.props } as const)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Prev / Next Buttons ─────────────────────────────────────────────────────
|
||||
|
||||
abstract class MonthGridPaginationButtonProvider {
|
||||
readonly opts: WithRefOpts;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
readonly provider: MonthGridProvider;
|
||||
protected readonly labelRef: string;
|
||||
protected abstract disabled(): boolean;
|
||||
protected abstract trigger(target?: HTMLElement): void;
|
||||
|
||||
protected constructor(opts: WithRefOpts, part: 'prev-button' | 'next-button', labelRef: string) {
|
||||
this.opts = opts;
|
||||
this.provider = MonthGridProvider.require();
|
||||
this.runtimePart = this.provider.runtime.part(part, {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this
|
||||
});
|
||||
this.labelRef = labelRef;
|
||||
}
|
||||
|
||||
readonly onclick = (e: SomaMouseEvent<HTMLButtonElement>) => {
|
||||
if (this.disabled()) return;
|
||||
this.trigger(e.currentTarget as HTMLElement);
|
||||
};
|
||||
|
||||
readonly props = $derived.by(() => {
|
||||
const disabled = this.disabled();
|
||||
return this.runtimePart.assert({
|
||||
...this.runtimePart.props,
|
||||
type: 'button' as const,
|
||||
'aria-label': this.provider.soma.langs.ts(this.labelRef),
|
||||
'aria-disabled': boolToStr(disabled),
|
||||
'data-disabled': boolToEmptyStrOrUndef(disabled),
|
||||
disabled,
|
||||
onclick: this.onclick
|
||||
} as const);
|
||||
});
|
||||
}
|
||||
|
||||
export class MonthGridPrevButtonProvider extends MonthGridPaginationButtonProvider {
|
||||
static create(opts: WithRefOpts) {
|
||||
return new MonthGridPrevButtonProvider(opts);
|
||||
}
|
||||
private constructor(opts: WithRefOpts) {
|
||||
super(opts, 'prev-button', MONTH_GRID_LANGS.PREV_YEAR);
|
||||
}
|
||||
protected disabled() {
|
||||
return this.provider.isPrevButtonDisabled;
|
||||
}
|
||||
protected trigger(target?: HTMLElement) {
|
||||
this.provider.prevPage(target);
|
||||
}
|
||||
}
|
||||
|
||||
export class MonthGridNextButtonProvider extends MonthGridPaginationButtonProvider {
|
||||
static create(opts: WithRefOpts) {
|
||||
return new MonthGridNextButtonProvider(opts);
|
||||
}
|
||||
private constructor(opts: WithRefOpts) {
|
||||
super(opts, 'next-button', MONTH_GRID_LANGS.NEXT_YEAR);
|
||||
}
|
||||
protected disabled() {
|
||||
return this.provider.isNextButtonDisabled;
|
||||
}
|
||||
protected trigger(target?: HTMLElement) {
|
||||
this.provider.nextPage(target);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grid ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MonthGridGridOpts
|
||||
extends
|
||||
WithRefOpts,
|
||||
ActiveProps<{
|
||||
labelledBy: string | undefined;
|
||||
}> {}
|
||||
|
||||
export class MonthGridGridProvider {
|
||||
readonly opts: MonthGridGridOpts;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
readonly provider: MonthGridProvider;
|
||||
static create(opts: MonthGridGridOpts) {
|
||||
return new MonthGridGridProvider(opts);
|
||||
}
|
||||
|
||||
private constructor(opts: MonthGridGridOpts) {
|
||||
this.opts = opts;
|
||||
this.provider = MonthGridProvider.require();
|
||||
this.runtimePart = this.provider.runtime.part('grid', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this
|
||||
});
|
||||
}
|
||||
|
||||
readonly props = $derived.by(() => {
|
||||
const headingId = this.opts.labelledBy.current ?? this.provider.headingId.current;
|
||||
return this.runtimePart.assert({
|
||||
...this.runtimePart.props,
|
||||
role: 'grid' as const,
|
||||
'aria-labelledby': headingId || undefined,
|
||||
'aria-readonly': boolToStr(this.provider.opts.readonly.current),
|
||||
'aria-disabled': boolToStr(this.provider.opts.disabled.current)
|
||||
} as const);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Cell ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MonthGridCellOpts
|
||||
extends WithRefOpts, ActiveProps<{ month: number }> {}
|
||||
|
||||
export class MonthGridCellProvider {
|
||||
readonly opts: MonthGridCellOpts;
|
||||
readonly runtimePart: SomaRuntimePart;
|
||||
readonly provider: MonthGridProvider;
|
||||
static readonly ctx = context<MonthGridCellProvider>('MonthGridCell');
|
||||
static get(): MonthGridCellProvider | undefined {
|
||||
return this.ctx.getOr(undefined) as MonthGridCellProvider | undefined;
|
||||
}
|
||||
static require(): MonthGridCellProvider {
|
||||
return this.ctx.get();
|
||||
}
|
||||
static create(opts: MonthGridCellOpts) {
|
||||
return new MonthGridCellProvider(opts);
|
||||
}
|
||||
|
||||
private constructor(opts: MonthGridCellOpts) {
|
||||
this.opts = opts;
|
||||
this.provider = MonthGridProvider.require();
|
||||
this.runtimePart = this.provider.runtime.part('cell', {
|
||||
id: opts.id,
|
||||
ref: opts.ref,
|
||||
owner: this,
|
||||
context: MonthGridCellProvider.ctx
|
||||
});
|
||||
}
|
||||
|
||||
readonly month = $derived.by(() => this.opts.month.current);
|
||||
readonly selected = $derived.by(() => this.provider.isSelectedMonth(this.month));
|
||||
readonly current = $derived.by(() => this.provider.isCurrentMonth(this.month));
|
||||
readonly disabled = $derived.by(() => this.provider.isDisabledMonth(this.month));
|
||||
readonly focused = $derived.by(() => this.provider.focusedMonth === this.month);
|
||||
readonly label = $derived.by(() => this.provider.formatMonth(this.month));
|
||||
|
||||
readonly snippetProps = $derived.by(() => ({
|
||||
label: this.label,
|
||||
month: this.month,
|
||||
selected: this.selected,
|
||||
current: this.current,
|
||||
disabled: this.disabled,
|
||||
focused: this.focused
|
||||
}));
|
||||
|
||||
readonly onclick = (e: SomaMouseEvent<HTMLElement>) => {
|
||||
if (this.disabled) return;
|
||||
this.provider.focusedMonth = this.month;
|
||||
this.provider.select(this.month, e.currentTarget as HTMLElement);
|
||||
};
|
||||
|
||||
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
|
||||
this.provider.handleCellKeydown(e, this.month);
|
||||
};
|
||||
|
||||
readonly props = $derived.by(() => {
|
||||
const tabindex = this.focused && !this.disabled ? 0 : -1;
|
||||
return this.runtimePart.assert({
|
||||
...this.runtimePart.props,
|
||||
role: 'gridcell' as const,
|
||||
type: 'button' as const,
|
||||
tabindex,
|
||||
'aria-selected': boolToStr(this.selected),
|
||||
'aria-disabled': boolToStr(this.disabled),
|
||||
'data-selected': boolToEmptyStrOrUndef(this.selected),
|
||||
'data-current': boolToEmptyStrOrUndef(this.current),
|
||||
'data-disabled': boolToEmptyStrOrUndef(this.disabled),
|
||||
'data-focused': boolToEmptyStrOrUndef(this.focused),
|
||||
'data-value': `${this.provider.placeholderYear}-${this.month}`,
|
||||
disabled: this.disabled,
|
||||
onclick: this.onclick,
|
||||
onkeydown: this.onkeydown
|
||||
} as const);
|
||||
});
|
||||
}
|
||||
|
||||
export { resolveInitialMonthPlaceholder };
|
||||
@ -0,0 +1,112 @@
|
||||
import type { WithChild, Without, OnChangeFn } from '../../types';
|
||||
import type {
|
||||
PrimitiveButtonAttributes,
|
||||
PrimitiveDivAttributes,
|
||||
PrimitiveHeaderAttributes
|
||||
} from '../../types';
|
||||
|
||||
/** Value selected by MonthGrid — a year/month pair. */
|
||||
export type MonthValue = { year: number; month: number };
|
||||
|
||||
/** Visible page anchor. Only `year` matters; `month` is accepted but ignored. */
|
||||
export type MonthPlaceholder = { year: number; month?: number };
|
||||
|
||||
/** Snippet props exposed by `MonthGrid.Provider`. */
|
||||
export type MonthGridProviderSnippetProps = {
|
||||
/** The 12 month numbers (1..12) for the visible page. */
|
||||
months: number[];
|
||||
/** The current placeholder year. */
|
||||
year: number;
|
||||
/** Locale-formatted year heading (e.g. "2026"). */
|
||||
headingValue: string;
|
||||
};
|
||||
|
||||
/** Snippet props exposed by `MonthGrid.Heading`. */
|
||||
export type MonthGridHeadingSnippetProps = {
|
||||
/** Locale-formatted year heading (e.g. "2026"). */
|
||||
headingValue: string;
|
||||
};
|
||||
|
||||
/** Snippet props exposed by `MonthGrid.Cell`. */
|
||||
export type MonthGridCellSnippetProps = {
|
||||
/** Localized month label per `monthFormat`. */
|
||||
label: string;
|
||||
/** The month number (1..12). */
|
||||
month: number;
|
||||
/** True when this cell matches the current `value`. */
|
||||
selected: boolean;
|
||||
/** True when this cell matches today's year + month. */
|
||||
current: boolean;
|
||||
/** True when outside `minValue`/`maxValue` bounds or disabled. */
|
||||
disabled: boolean;
|
||||
/** True when this cell holds roving focus (tabindex=0). */
|
||||
focused: boolean;
|
||||
};
|
||||
|
||||
// ── Base provider props ──────────────────────────────────────────────────────
|
||||
|
||||
type MonthGridBaseProps = {
|
||||
/** DOM id. Auto-generated when omitted. */
|
||||
id?: string;
|
||||
/** Selected month. Bindable. */
|
||||
value?: MonthValue;
|
||||
/** Called when `value` changes. */
|
||||
onValueChange?: OnChangeFn<MonthValue | undefined>;
|
||||
/** Visible page anchor (year). Defaults to today's year. Bindable. */
|
||||
placeholder?: MonthPlaceholder;
|
||||
/** Called when `placeholder` changes (prev/next nav). */
|
||||
onPlaceholderChange?: OnChangeFn<MonthPlaceholder>;
|
||||
/** Earliest selectable month. Cells before it are disabled. */
|
||||
minValue?: MonthValue;
|
||||
/** Latest selectable month. Cells after it are disabled. */
|
||||
maxValue?: MonthValue;
|
||||
/** Disables the entire grid. */
|
||||
disabled?: boolean;
|
||||
/** Read-only grid: focusable + navigable, but selection blocked. */
|
||||
readonly?: boolean;
|
||||
/** Writing direction. Inverts horizontal arrow nav when `'rtl'`. */
|
||||
dir?: 'ltr' | 'rtl';
|
||||
/** Override the locale used to format month/year labels. */
|
||||
locale?: string;
|
||||
/**
|
||||
* Intl month-name length used for cell labels.
|
||||
* @default 'short'
|
||||
*/
|
||||
monthFormat?: 'long' | 'short' | 'narrow';
|
||||
/** `aria-label` for the provider root. Localized when omitted. */
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
export type MonthGridProps = WithChild<MonthGridBaseProps, MonthGridProviderSnippetProps> &
|
||||
Without<PrimitiveDivAttributes, MonthGridBaseProps>;
|
||||
|
||||
// ── Child parts ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type MonthGridHeaderProps = WithChild<{ id?: string }> &
|
||||
Without<PrimitiveHeaderAttributes, {}>;
|
||||
|
||||
export type MonthGridHeadingProps = WithChild<{ id?: string }, MonthGridHeadingSnippetProps> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
export type MonthGridPrevButtonProps = WithChild<{ id?: string }> &
|
||||
Without<PrimitiveButtonAttributes, {}>;
|
||||
|
||||
export type MonthGridNextButtonProps = WithChild<{ id?: string }> &
|
||||
Without<PrimitiveButtonAttributes, {}>;
|
||||
|
||||
export type MonthGridGridProps = WithChild<{
|
||||
id?: string;
|
||||
/** Explicit id of the heading that labels this grid. */
|
||||
labelledBy?: string;
|
||||
}> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
export type MonthGridCellProps = WithChild<
|
||||
{
|
||||
id?: string;
|
||||
/** The month number (1..12) this cell represents. */
|
||||
month: number;
|
||||
},
|
||||
MonthGridCellSnippetProps
|
||||
> &
|
||||
Without<PrimitiveButtonAttributes, { month?: unknown }>;
|
||||
@ -0,0 +1,39 @@
|
||||
# Soma YearGrid
|
||||
|
||||
Standalone year-picker primitive: a paged grid of years (default decade size
|
||||
12), prev/next page navigation, keyboard nav and morfo-declared semantic events.
|
||||
Independent of `Calendar` and `DatePicker`.
|
||||
|
||||
## Composition
|
||||
|
||||
```svelte
|
||||
<YearGrid.Provider bind:value bind:placeholder>
|
||||
<YearGrid.Header>
|
||||
<YearGrid.PrevButton><</YearGrid.PrevButton>
|
||||
<YearGrid.Heading />
|
||||
<YearGrid.NextButton>></YearGrid.NextButton>
|
||||
</YearGrid.Header>
|
||||
<YearGrid.Grid>
|
||||
{#each years as year (year)}
|
||||
<YearGrid.Cell {year} />
|
||||
{/each}
|
||||
</YearGrid.Grid>
|
||||
</YearGrid.Provider>
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
| Event | Family | Target | Intent | When |
|
||||
| ------------ | -------- | ------ | ------ | ------------------------------------------ |
|
||||
| `commit-set` | `commit` | `cell` | affirm | A year cell is clicked or Enter'd. |
|
||||
| `nav-step` | `shift` | `provider` | - | Prev/Next page or keyboard PageUp/PageDown. |
|
||||
|
||||
## Keyboard
|
||||
|
||||
| Key | Action |
|
||||
| -------------------- | -------------------------------------------- |
|
||||
| Arrow Left / Right | ±1 year (RTL-flipped horizontally) |
|
||||
| Arrow Up / Down | ±3 years (3-col layout) |
|
||||
| Home / End | First / last year of page |
|
||||
| PageUp / PageDown | ±`decadeSize` years (page step) |
|
||||
| Enter / Space | Select focused year |
|
||||
@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { YearGridCellProvider } from '../year-grid-provider.svelte';
|
||||
import type { YearGridCellProps, YearGridCellSnippetProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'year-grid-cell'),
|
||||
year,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: YearGridCellProps = $props();
|
||||
|
||||
const state = YearGridCellProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
year: readableActive(() => year)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...state.snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>
|
||||
{#if children}
|
||||
{@render children(state.snippetProps as YearGridCellSnippetProps)}
|
||||
{:else}
|
||||
{state.snippetProps.label}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { YearGridGridProvider } from '../year-grid-provider.svelte';
|
||||
import type { YearGridGridProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'year-grid-grid'),
|
||||
labelledBy,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: YearGridGridProps = $props();
|
||||
|
||||
const state = YearGridGridProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
labelledBy: readableActive(() => labelledBy)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { YearGridHeaderProvider } from '../year-grid-provider.svelte';
|
||||
import type { YearGridHeaderProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'year-grid-header'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: YearGridHeaderProps = $props();
|
||||
|
||||
const state = YearGridHeaderProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { YearGridHeadingProvider } from '../year-grid-provider.svelte';
|
||||
import type { YearGridHeadingProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'year-grid-heading'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: YearGridHeadingProps = $props();
|
||||
|
||||
const state = YearGridHeadingProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
const snippetProps = $derived({ headingValue: state.headingValue });
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{#if children}
|
||||
{@render children(snippetProps)}
|
||||
{:else}
|
||||
{state.headingValue}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { YearGridNextButtonProvider } from '../year-grid-provider.svelte';
|
||||
import type { YearGridNextButtonProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'year-grid-next-button'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: YearGridNextButtonProps = $props();
|
||||
|
||||
const state = YearGridNextButtonProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { YearGridPrevButtonProvider } from '../year-grid-provider.svelte';
|
||||
import type { YearGridPrevButtonProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'year-grid-prev-button'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: YearGridPrevButtonProps = $props();
|
||||
|
||||
const state = YearGridPrevButtonProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '$libs/reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import {
|
||||
YearGridProvider,
|
||||
resolveInitialYearPlaceholder
|
||||
} from '../year-grid-provider.svelte';
|
||||
import type { YearGridProps } from '../types';
|
||||
import { Soma } from '../../../core/soma.svelte';
|
||||
|
||||
const uid = $props.id();
|
||||
const soma = Soma.get();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'year-grid'),
|
||||
value = $bindable(),
|
||||
onValueChange = () => {},
|
||||
placeholder = $bindable(),
|
||||
onPlaceholderChange = () => {},
|
||||
minValue,
|
||||
maxValue,
|
||||
disabled = false,
|
||||
readonly = false,
|
||||
dir,
|
||||
locale,
|
||||
decadeSize = 12,
|
||||
ariaLabel,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: YearGridProps = $props();
|
||||
|
||||
const state = YearGridProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
placeholder: writableActive(
|
||||
() => resolveInitialYearPlaceholder(placeholder, value),
|
||||
(v) => {
|
||||
placeholder = v;
|
||||
onPlaceholderChange(v);
|
||||
}
|
||||
),
|
||||
value: writableActive(
|
||||
() => value,
|
||||
(v) => {
|
||||
value = v;
|
||||
onValueChange(v);
|
||||
}
|
||||
),
|
||||
minValue: readableActive(() => minValue),
|
||||
maxValue: readableActive(() => maxValue),
|
||||
disabled: readableActive(() => disabled),
|
||||
readonly: readableActive(() => readonly),
|
||||
dir: readableActive(() => dir ?? soma?.prefs.getDir() ?? 'ltr'),
|
||||
locale: readableActive(() => locale ?? ''),
|
||||
decadeSize: readableActive(() => decadeSize),
|
||||
ariaLabel: readableActive(() => ariaLabel)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
|
||||
const snippetProps = $derived({
|
||||
years: state.years,
|
||||
pageStart: state.pageStart,
|
||||
pageEnd: state.pageEnd,
|
||||
headingValue: state.headingValue
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.(snippetProps)}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,22 @@
|
||||
export { default as Provider } from './components/year-grid.svelte';
|
||||
export { default as Header } from './components/year-grid-header.svelte';
|
||||
export { default as Heading } from './components/year-grid-heading.svelte';
|
||||
export { default as PrevButton } from './components/year-grid-prev-button.svelte';
|
||||
export { default as NextButton } from './components/year-grid-next-button.svelte';
|
||||
export { default as Grid } from './components/year-grid-grid.svelte';
|
||||
export { default as Cell } from './components/year-grid-cell.svelte';
|
||||
|
||||
export type {
|
||||
YearValue,
|
||||
YearPlaceholder,
|
||||
YearGridProps,
|
||||
YearGridHeaderProps,
|
||||
YearGridHeadingProps,
|
||||
YearGridPrevButtonProps,
|
||||
YearGridNextButtonProps,
|
||||
YearGridGridProps,
|
||||
YearGridCellProps,
|
||||
YearGridProviderSnippetProps,
|
||||
YearGridHeadingSnippetProps,
|
||||
YearGridCellSnippetProps
|
||||
} from './types';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,6 @@
|
||||
/** Idlangref constants for YearGrid. Values are fallback defaults. */
|
||||
export const YEAR_GRID_LANGS = {
|
||||
LABEL: '#?components.year-grid.label|Year grid',
|
||||
PREV_PAGE: '#?common.year-grid.prev-page|Previous years',
|
||||
NEXT_PAGE: '#?common.year-grid.next-page|Next years'
|
||||
} as const;
|
||||
Loading…
Reference in new issue