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.
438 lines
13 KiB
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;
|