You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/morfo/components/calendar.ts

438 lines
13 KiB

import type { Morfo } from '../types';
import { v } from '../types';
export const calendarMorfo = {
name: 'Calendar',
kebab: 'calendar',
scope: ['soma', 'sema', 'eidos'],
expression: 'pack',
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/',
texts: {
label: '#?components.calendar.label|Calendar'
},
events: [
{
name: 'commit-select',
semantic: {
family: 'commit',
verb: 'select',
target: v.partRef('day'),
intent: 'affirm',
sequence: 'post'
}
},
{
name: 'commit-unselect',
semantic: {
family: 'commit',
verb: 'unselect',
target: v.partRef('day'),
intent: 'affirm',
sequence: 'post'
}
},
{
name: 'shift-navigate',
semantic: {
family: 'shift',
verb: 'navigate',
// The subject is the PAGINATED VIEW, not one of the months in it.
//
// Two corrections got us here. Until 2026-08-11 this targeted
// `provider` and the runtime redirected the stamp onto whichever
// surface CAUSED the navigation (arrow, select, spilled day).
// Measured on the prev-button: `contact-activate` stamped and
// started `press-squeeze`, then `shift-navigate` overwrote the same
// slot 8.5 ms later — half a frame — and the squeeze never painted.
// One surface, one slot (A-36): an instrument that owns its own
// event cannot also carry someone else's. So it moved to `grid`.
//
// `grid` was right for one month and wrong for two. `grid` is a
// REPEATED part, and an unanchored emit resolves to the target's
// NEWEST live instance (`resolveEmitTarget`) — measured with
// `numberOfMonths=2`: only the second table was stamped, so June
// changed its dates without moving while July crossed. A frame that
// swaps with nothing moving is the *shift invisible* of the
// antipattern catalogue (ch. 34 §14, CANON §8 rule 5) — here, half
// of one.
//
// `months` is the surface that crosses as a unit under every value
// of `numberOfMonths`, so the target is true under all of them —
// a target correct for only one parameter value is not a target.
// It is also what keeps the firma expressible: a page can slide, an
// arrow cannot. The fallback keeps a headless composition that
// renders bare `Grid`s honest rather than silent.
target: v.partRef('months'),
targetFallback: [v.partRef('grid')],
sequence: 'post'
}
}
],
direction: {},
focus: {
kind: 'roving',
parts: ['day'],
// The APG grid pattern: exactly one tabbable day (CalendarDayProvider,
// `focused ? 0 : -1`), arrows move the stop on two axes with month
// hops at the edges — 2D, which the shared 1D primitive cannot express.
orientation: 'grid',
executor: {
custom:
'calendar 2D day traversal (arrows = day/week, month-boundary hops) — executed by the shared 2D grid-walk primitive (soma/keyboard/grid-walk), not the 1D RovingFocusGroup'
}
},
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
// No role. `role='application'` was removed 2026-07-07 (checkpoint C4):
// it disabled the screen reader's virtual cursor for the whole subtree,
// and the inner Grid (`role='grid'`) already carries the interaction
// pattern — modern SRs enter focus mode on grids by themselves
// (bits-ui/Radix/M3 line; react-aria keeps it for legacy SRs).
optional: false,
data: [
// Enum declared (checkpoint S10, 2026-07-07): soma owns the write,
// the closed set lets the compiler validate and eidos select per value.
{ attr: 'data-type', values: ['single', 'multiple'], severity: 'optional' },
{ attr: 'data-readonly', severity: 'optional' },
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-invalid', severity: 'optional' }
],
aria: [
{ attr: 'aria-label', value: v.propRef('ariaLabel'), severity: 'recommended' },
// Provider emitted `boolToStr(disabled)` (always "true"/"false"), so
// `propRef` + `ariaBoolean` lets the render bag emit the same string.
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
severity: 'optional',
ariaBoolean: true
}
],
keyboard: [
{ key: 'ArrowRight', action: 'next-day' },
{ key: 'ArrowLeft', action: 'prev-day' },
{ key: 'ArrowDown', action: 'next-week' },
{ key: 'ArrowUp', action: 'prev-week' },
{ key: 'PageUp', action: 'prev-month' },
{ key: 'PageDown', action: 'next-month' },
// The year jump has shipped since the component's first version
// (`handleDayKeydown`'s `page` callback reads `ev.shiftKey`) and is what
// APG prescribes for the date-picker grid; only the declaration was
// missing. The action names are the family's own (`month-grid` declares
// `prev-year` / `next-year`).
{ key: 'Shift+PageUp', action: 'prev-year' },
{ key: 'Shift+PageDown', action: 'next-year' },
{ key: 'Home', action: 'first-day-of-week' },
{ key: 'End', action: 'last-day-of-week' },
{ key: 'Enter', action: 'select' },
{ key: ' ', action: 'select' }
]
},
{
name: 'Header',
kebab: 'header',
archetype: 'header',
kind: 'public',
defaultElement: 'header',
optional: true,
data: [],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{
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' }],
attrs: [{ attr: 'type', value: v.literal('button') }],
aria: [
{
attr: 'aria-label',
value: v.commonRef('calendar.prev-month', 'Previous month'),
severity: 'recommended'
},
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
severity: 'optional',
ariaBoolean: true
}
]
},
{
name: 'NextButton',
kebab: 'next-button',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
attrs: [{ attr: 'type', value: v.literal('button') }],
aria: [
{
attr: 'aria-label',
value: v.commonRef('calendar.next-month', 'Next month'),
severity: 'recommended'
},
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
severity: 'optional',
ariaBoolean: true
}
]
},
{
name: 'MonthSelect',
kebab: 'month-select',
archetype: 'input',
kind: 'public',
defaultElement: 'select',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{
attr: 'aria-label',
value: v.commonRef('calendar.month-select', 'Select month'),
severity: 'recommended'
},
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
severity: 'optional',
ariaBoolean: true
}
]
},
{
name: 'YearSelect',
kebab: 'year-select',
archetype: 'input',
kind: 'public',
defaultElement: 'select',
optional: true,
data: [{ attr: 'data-disabled', severity: 'optional' }],
aria: [
{
attr: 'aria-label',
value: v.commonRef('calendar.year-select', 'Select year'),
severity: 'recommended'
},
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
severity: 'optional',
ariaBoolean: true
}
]
},
{
name: 'Months',
kebab: 'months',
archetype: 'viewport',
kind: 'public',
defaultElement: 'div',
// Optional so a single-month headless composition can still render a
// bare `Grid`; `shift-navigate` falls back to it. Rendering it is what
// makes the crossing perceivable with more than one month on screen.
optional: true,
data: [],
aria: []
},
{
// One month's column — its heading plus its grid. Shipped since the
// multi-month demos existed, as a hand-made `<div
// data-calendar-month-panel>` that no morfo declared and that
// `calendar.css` styled anyway. Declaring it closes that drift: the
// recipe now selects an attr the contract promises.
name: 'MonthPanel',
kebab: 'month-panel',
archetype: 'group',
kind: 'public',
defaultElement: 'div',
optional: true,
data: [],
aria: []
},
{
name: 'Grid',
kebab: 'grid',
kind: 'public',
defaultElement: 'table',
role: 'grid',
optional: false,
data: [],
aria: [
{
attr: 'aria-labelledby',
value: v.partRef('heading'),
condition: { when: 'part-present', part: 'heading' },
severity: 'recommended'
},
// Provider emitted `boolToStr(...)` (always "true"/"false") for both;
// `propRef` + `ariaBoolean` lets the render bag emit the same string.
{
attr: 'aria-readonly',
value: v.propRef('readonly'),
severity: 'optional',
ariaBoolean: true
},
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
severity: 'optional',
ariaBoolean: true
}
]
},
{
name: 'GridHead',
kebab: 'grid-head',
archetype: 'header',
kind: 'public',
defaultElement: 'thead',
optional: false,
data: [],
aria: []
},
{
name: 'GridBody',
kebab: 'grid-body',
kind: 'public',
defaultElement: 'tbody',
optional: false,
data: [],
aria: []
},
{
name: 'GridRow',
kebab: 'grid-row',
// No archetype: a `<tr>` week row is a structural container, NOT a menu
// `item`. `data-archetype='item'` would tint the WHOLE row on hover (the
// "hover over the entire week" bug). Each Day owns its own hover.
kind: 'public',
defaultElement: 'tr',
role: 'row',
optional: false,
data: [],
aria: []
},
{
name: 'HeadCell',
kebab: 'head-cell',
archetype: 'header',
kind: 'public',
defaultElement: 'th',
optional: false,
data: [],
aria: []
},
{
name: 'Cell',
kebab: 'cell',
// No archetype: the cell is a display `gridcell` container, NOT an
// interactive menu `item`. `data-archetype='item'` would ship the
// listbox-row chrome (padding-block, cursor, hover band) — the
// clickable Day inside owns its own interaction styling.
kind: 'public',
defaultElement: 'td',
role: 'gridcell',
optional: false,
data: [
{ attr: 'data-value' },
{ attr: 'data-selected', severity: 'optional' },
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-unavailable', severity: 'optional' },
{ attr: 'data-today', severity: 'optional' },
{ attr: 'data-weekend', severity: 'optional' },
{ attr: 'data-holiday', severity: 'optional' },
{ attr: 'data-outside-month', severity: 'optional' }
],
aria: [
// Provider emitted `boolToStr(...)` (always "true"/"false") for both;
// `propRef` + `ariaBoolean` lets the render bag emit the same string.
{
attr: 'aria-selected',
value: v.propRef('selected'),
severity: 'optional',
ariaBoolean: true
},
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
severity: 'optional',
ariaBoolean: true
}
]
},
{
name: 'Day',
kebab: 'day',
// No archetype: the Day owns bespoke `[data-calendar-day]` styling for
// hover / selected / focused. `data-archetype='item'` would let the
// generic listbox-item rules (higher specificity) OVERRIDE the selected
// fill — the selected day showed the menu-item tint instead of the
// calendar accent. The day is a calendar cell, not a menu item.
kind: 'public',
defaultElement: 'div',
role: 'button',
optional: false,
data: [
{ attr: 'data-value' },
{ attr: 'data-selected', severity: 'optional' },
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-unavailable', severity: 'optional' },
{ attr: 'data-today', severity: 'optional' },
{ attr: 'data-weekend', severity: 'optional' },
{ attr: 'data-holiday', severity: 'optional' },
{ attr: 'data-outside-month', severity: 'optional' },
{ attr: 'data-focused', severity: 'optional' }
],
aria: [
{ attr: 'aria-label', value: v.propRef('dateLabel'), severity: 'recommended' },
// Provider emitted `boolToStr(disabled)` (always "true"/"false");
// `propRef` + `ariaBoolean` lets the render bag emit the same string.
// No aria-selected here: it is not supported on `role='button'`
// (checkpoint C4 fix, 2026-07-07) — the selection state lives on the
// Cell (`role='gridcell'`), which legitimately carries it.
{
attr: 'aria-disabled',
value: v.propRef('disabled'),
severity: 'optional',
ariaBoolean: true
}
]
},
{
name: 'WeekNumber',
kebab: 'week-number',
archetype: 'label',
kind: 'public',
defaultElement: 'td',
optional: true,
data: [{ attr: 'data-value' }],
aria: []
}
]
} as const satisfies Morfo;

Powered by TurnKey Linux.