@agent-ui-kit/components 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/base-styles.css +8 -0
- package/dist/component-styles.css +147 -0
- package/dist/controls/_base/index.d.ts +3 -0
- package/dist/controls/_base/index.js +9 -0
- package/dist/controls/_base/indicator-element.d.ts +132 -0
- package/dist/controls/_base/indicator-element.js +158 -0
- package/dist/controls/_base/listbox-element.d.ts +70 -0
- package/dist/controls/_base/listbox-element.js +74 -0
- package/dist/controls/_base/range-element.d.ts +120 -0
- package/dist/controls/_base/range-element.js +158 -0
- package/dist/controls/_surface/container-box.css +301 -0
- package/dist/controls/_surface/container.css +156 -0
- package/dist/controls/_token-surface/token-surface.d.ts +35 -0
- package/dist/controls/_token-surface/token-surface.js +97 -0
- package/dist/controls/attachment/attachment-meta.d.ts +8 -0
- package/dist/controls/attachment/attachment-meta.js +84 -0
- package/dist/controls/attachment/attachment.css +91 -0
- package/dist/controls/attachment/attachment.d.ts +40 -0
- package/dist/controls/attachment/attachment.js +70 -0
- package/dist/controls/avatar/avatar-initials.d.ts +4 -0
- package/dist/controls/avatar/avatar-initials.js +30 -0
- package/dist/controls/avatar/avatar.css +87 -0
- package/dist/controls/avatar/avatar.d.ts +40 -0
- package/dist/controls/avatar/avatar.js +77 -0
- package/dist/controls/badge/badge.css +175 -0
- package/dist/controls/badge/badge.d.ts +36 -0
- package/dist/controls/badge/badge.js +71 -0
- package/dist/controls/bar-chart/bar-chart.css +110 -0
- package/dist/controls/bar-chart/bar-chart.d.ts +26 -0
- package/dist/controls/bar-chart/bar-chart.js +80 -0
- package/dist/controls/bar-chart/bar-math.d.ts +31 -0
- package/dist/controls/bar-chart/bar-math.js +106 -0
- package/dist/controls/button/button.css +263 -0
- package/dist/controls/button/button.d.ts +61 -0
- package/dist/controls/button/button.js +115 -0
- package/dist/controls/calendar/calendar.css +495 -0
- package/dist/controls/calendar/calendar.d.ts +161 -0
- package/dist/controls/calendar/calendar.js +1004 -0
- package/dist/controls/card/card-content.d.ts +24 -0
- package/dist/controls/card/card-content.js +133 -0
- package/dist/controls/card/card-footer.d.ts +3 -0
- package/dist/controls/card/card-footer.js +16 -0
- package/dist/controls/card/card-header.d.ts +3 -0
- package/dist/controls/card/card-header.js +18 -0
- package/dist/controls/card/card.css +445 -0
- package/dist/controls/card/card.d.ts +51 -0
- package/dist/controls/card/card.js +61 -0
- package/dist/controls/checkbox/checkbox.css +204 -0
- package/dist/controls/checkbox/checkbox.d.ts +114 -0
- package/dist/controls/checkbox/checkbox.js +130 -0
- package/dist/controls/code/code.css +72 -0
- package/dist/controls/code/code.d.ts +23 -0
- package/dist/controls/code/code.js +40 -0
- package/dist/controls/color-picker/color-picker.css +209 -0
- package/dist/controls/color-picker/color-picker.d.ts +84 -0
- package/dist/controls/color-picker/color-picker.js +545 -0
- package/dist/controls/color-picker/color.d.ts +40 -0
- package/dist/controls/color-picker/color.js +159 -0
- package/dist/controls/column/column.css +151 -0
- package/dist/controls/column/column.d.ts +107 -0
- package/dist/controls/column/column.js +57 -0
- package/dist/controls/combo-box/combo-box.css +397 -0
- package/dist/controls/combo-box/combo-box.d.ts +133 -0
- package/dist/controls/combo-box/combo-box.js +680 -0
- package/dist/controls/command-modal/command-modal.css +226 -0
- package/dist/controls/command-modal/command-modal.d.ts +42 -0
- package/dist/controls/command-modal/command-modal.js +315 -0
- package/dist/controls/disclosure/disclosure.css +162 -0
- package/dist/controls/disclosure/disclosure.d.ts +37 -0
- package/dist/controls/disclosure/disclosure.js +207 -0
- package/dist/controls/field/field.css +140 -0
- package/dist/controls/field/field.d.ts +27 -0
- package/dist/controls/field/field.js +228 -0
- package/dist/controls/form-provider/form-provider.css +40 -0
- package/dist/controls/form-provider/form-provider.d.ts +42 -0
- package/dist/controls/form-provider/form-provider.js +105 -0
- package/dist/controls/grid/grid.css +72 -0
- package/dist/controls/grid/grid.d.ts +60 -0
- package/dist/controls/grid/grid.js +56 -0
- package/dist/controls/icon/icon.css +37 -0
- package/dist/controls/icon/icon.d.ts +25 -0
- package/dist/controls/icon/icon.js +55 -0
- package/dist/controls/index.d.ts +57 -0
- package/dist/controls/index.js +136 -0
- package/dist/controls/ladder/ladder.css +89 -0
- package/dist/controls/ladder/ladder.d.ts +27 -0
- package/dist/controls/ladder/ladder.js +78 -0
- package/dist/controls/list/list.css +118 -0
- package/dist/controls/list/list.d.ts +84 -0
- package/dist/controls/list/list.js +48 -0
- package/dist/controls/menu/menu.css +195 -0
- package/dist/controls/menu/menu.d.ts +44 -0
- package/dist/controls/menu/menu.js +248 -0
- package/dist/controls/modal/modal.css +99 -0
- package/dist/controls/modal/modal.d.ts +61 -0
- package/dist/controls/modal/modal.js +155 -0
- package/dist/controls/popover/popover.css +83 -0
- package/dist/controls/popover/popover.d.ts +44 -0
- package/dist/controls/popover/popover.js +164 -0
- package/dist/controls/progress/progress.css +106 -0
- package/dist/controls/progress/progress.d.ts +28 -0
- package/dist/controls/progress/progress.js +80 -0
- package/dist/controls/radio/radio-group.css +84 -0
- package/dist/controls/radio/radio-group.d.ts +152 -0
- package/dist/controls/radio/radio-group.js +350 -0
- package/dist/controls/radio/radio.css +219 -0
- package/dist/controls/radio/radio.d.ts +19 -0
- package/dist/controls/radio/radio.js +75 -0
- package/dist/controls/ramp/ramp.css +75 -0
- package/dist/controls/ramp/ramp.d.ts +29 -0
- package/dist/controls/ramp/ramp.js +72 -0
- package/dist/controls/row/row.css +152 -0
- package/dist/controls/row/row.d.ts +95 -0
- package/dist/controls/row/row.js +45 -0
- package/dist/controls/segment/segment.css +66 -0
- package/dist/controls/segment/segment.d.ts +3 -0
- package/dist/controls/segment/segment.js +26 -0
- package/dist/controls/segmented-control/segmented-control.css +189 -0
- package/dist/controls/segmented-control/segmented-control.d.ts +20 -0
- package/dist/controls/segmented-control/segmented-control.js +64 -0
- package/dist/controls/select/select.css +467 -0
- package/dist/controls/select/select.d.ts +147 -0
- package/dist/controls/select/select.js +538 -0
- package/dist/controls/slider/slider.css +231 -0
- package/dist/controls/slider/slider.d.ts +111 -0
- package/dist/controls/slider/slider.js +60 -0
- package/dist/controls/slider-multi/slider-multi.css +197 -0
- package/dist/controls/slider-multi/slider-multi.d.ts +149 -0
- package/dist/controls/slider-multi/slider-multi.js +380 -0
- package/dist/controls/sparkline/sparkline-math.d.ts +23 -0
- package/dist/controls/sparkline/sparkline-math.js +79 -0
- package/dist/controls/sparkline/sparkline.css +61 -0
- package/dist/controls/sparkline/sparkline.d.ts +27 -0
- package/dist/controls/sparkline/sparkline.js +74 -0
- package/dist/controls/split/constrain.d.ts +44 -0
- package/dist/controls/split/constrain.js +126 -0
- package/dist/controls/split/split-pane.css +63 -0
- package/dist/controls/split/split-pane.d.ts +49 -0
- package/dist/controls/split/split-pane.js +42 -0
- package/dist/controls/split/split.css +127 -0
- package/dist/controls/split/split.d.ts +42 -0
- package/dist/controls/split/split.js +364 -0
- package/dist/controls/stat/stat-model.d.ts +22 -0
- package/dist/controls/stat/stat-model.js +69 -0
- package/dist/controls/stat/stat.css +101 -0
- package/dist/controls/stat/stat.d.ts +30 -0
- package/dist/controls/stat/stat.js +73 -0
- package/dist/controls/status-stream/status-stream.css +151 -0
- package/dist/controls/status-stream/status-stream.d.ts +108 -0
- package/dist/controls/status-stream/status-stream.js +417 -0
- package/dist/controls/swatch/swatch.css +66 -0
- package/dist/controls/swatch/swatch.d.ts +28 -0
- package/dist/controls/swatch/swatch.js +74 -0
- package/dist/controls/swiper/swiper-item.css +17 -0
- package/dist/controls/swiper/swiper-item.d.ts +31 -0
- package/dist/controls/swiper/swiper-item.js +47 -0
- package/dist/controls/swiper/swiper-label.css +10 -0
- package/dist/controls/swiper/swiper-label.d.ts +4 -0
- package/dist/controls/swiper/swiper-label.js +20 -0
- package/dist/controls/swiper/swiper-paddles.css +26 -0
- package/dist/controls/swiper/swiper-paddles.d.ts +10 -0
- package/dist/controls/swiper/swiper-paddles.js +62 -0
- package/dist/controls/swiper/swiper-pagination.css +60 -0
- package/dist/controls/swiper/swiper-pagination.d.ts +31 -0
- package/dist/controls/swiper/swiper-pagination.js +78 -0
- package/dist/controls/swiper/swiper.css +153 -0
- package/dist/controls/swiper/swiper.d.ts +158 -0
- package/dist/controls/swiper/swiper.js +659 -0
- package/dist/controls/switch/switch.css +194 -0
- package/dist/controls/switch/switch.d.ts +5 -0
- package/dist/controls/switch/switch.js +18 -0
- package/dist/controls/table/table-model.d.ts +43 -0
- package/dist/controls/table/table-model.js +124 -0
- package/dist/controls/table/table.css +90 -0
- package/dist/controls/table/table.d.ts +29 -0
- package/dist/controls/table/table.js +178 -0
- package/dist/controls/tabs/tab-panel.d.ts +10 -0
- package/dist/controls/tabs/tab-panel.js +89 -0
- package/dist/controls/tabs/tab.d.ts +26 -0
- package/dist/controls/tabs/tab.js +67 -0
- package/dist/controls/tabs/tabs.css +181 -0
- package/dist/controls/tabs/tabs.d.ts +61 -0
- package/dist/controls/tabs/tabs.js +176 -0
- package/dist/controls/text/href.d.ts +26 -0
- package/dist/controls/text/href.js +48 -0
- package/dist/controls/text/text.css +328 -0
- package/dist/controls/text/text.d.ts +85 -0
- package/dist/controls/text/text.js +273 -0
- package/dist/controls/text-field/text-field.css +498 -0
- package/dist/controls/text-field/text-field.d.ts +226 -0
- package/dist/controls/text-field/text-field.js +905 -0
- package/dist/controls/textarea/textarea.css +251 -0
- package/dist/controls/textarea/textarea.d.ts +136 -0
- package/dist/controls/textarea/textarea.js +312 -0
- package/dist/controls/theme-provider/theme-provider.css +48 -0
- package/dist/controls/theme-provider/theme-provider.d.ts +59 -0
- package/dist/controls/theme-provider/theme-provider.js +40 -0
- package/dist/controls/timeline/timeline.css +33 -0
- package/dist/controls/timeline/timeline.d.ts +39 -0
- package/dist/controls/timeline/timeline.js +52 -0
- package/dist/controls/timeline-item/timeline-item.css +495 -0
- package/dist/controls/timeline-item/timeline-item.d.ts +79 -0
- package/dist/controls/timeline-item/timeline-item.js +360 -0
- package/dist/controls/toast/toast-region.css +56 -0
- package/dist/controls/toast/toast-region.d.ts +20 -0
- package/dist/controls/toast/toast-region.js +113 -0
- package/dist/controls/toast/toast.css +96 -0
- package/dist/controls/toast/toast.d.ts +28 -0
- package/dist/controls/toast/toast.js +191 -0
- package/dist/controls/toolbar/toolbar.css +125 -0
- package/dist/controls/toolbar/toolbar.d.ts +108 -0
- package/dist/controls/toolbar/toolbar.js +68 -0
- package/dist/controls/tooltip/tooltip.css +80 -0
- package/dist/controls/tooltip/tooltip.d.ts +57 -0
- package/dist/controls/tooltip/tooltip.js +205 -0
- package/dist/descriptor/component-descriptor.d.ts +112 -0
- package/dist/descriptor/component-descriptor.js +492 -0
- package/dist/descriptor/index.d.ts +1 -0
- package/dist/descriptor/index.js +5 -0
- package/dist/dom/container.d.ts +45 -0
- package/dist/dom/container.js +60 -0
- package/dist/dom/element.d.ts +98 -0
- package/dist/dom/element.js +221 -0
- package/dist/dom/form.d.ts +240 -0
- package/dist/dom/form.js +399 -0
- package/dist/dom/index.d.ts +10 -0
- package/dist/dom/index.js +31 -0
- package/dist/dom/props.d.ts +56 -0
- package/dist/dom/props.js +231 -0
- package/dist/dom/repeat.d.ts +10 -0
- package/dist/dom/repeat.js +202 -0
- package/dist/dom/template.d.ts +161 -0
- package/dist/dom/template.js +634 -0
- package/dist/dom/watch.d.ts +11 -0
- package/dist/dom/watch.js +82 -0
- package/dist/foundation-styles.css +9 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/reactive/graph.d.ts +65 -0
- package/dist/reactive/graph.js +310 -0
- package/dist/reactive/index.d.ts +3 -0
- package/dist/reactive/index.js +4 -0
- package/dist/reactive/scheduler.d.ts +13 -0
- package/dist/reactive/scheduler.js +49 -0
- package/dist/traits/area-drag.d.ts +12 -0
- package/dist/traits/area-drag.js +70 -0
- package/dist/traits/form-registry.d.ts +36 -0
- package/dist/traits/form-registry.js +108 -0
- package/dist/traits/index.d.ts +24 -0
- package/dist/traits/index.js +19 -0
- package/dist/traits/overlay.d.ts +50 -0
- package/dist/traits/overlay.js +277 -0
- package/dist/traits/pane-resize.d.ts +26 -0
- package/dist/traits/pane-resize.js +103 -0
- package/dist/traits/press-activation.d.ts +11 -0
- package/dist/traits/press-activation.js +55 -0
- package/dist/traits/roving-focus.d.ts +52 -0
- package/dist/traits/roving-focus.js +214 -0
- package/dist/traits/scroll-fade.d.ts +28 -0
- package/dist/traits/scroll-fade.js +120 -0
- package/dist/traits/selection-commit.d.ts +30 -0
- package/dist/traits/selection-commit.js +146 -0
- package/dist/traits/tabbable.d.ts +12 -0
- package/dist/traits/tabbable.js +59 -0
- package/dist/traits/track-user-invalid.d.ts +36 -0
- package/dist/traits/track-user-invalid.js +52 -0
- package/dist/traits/value-codec.d.ts +120 -0
- package/dist/traits/value-codec.js +497 -0
- package/dist/traits/value-drag.d.ts +18 -0
- package/dist/traits/value-drag.js +84 -0
- package/package.json +263 -0
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
// calendar.ts — UICalendarElement, the Wave-5B standalone month-grid date picker control.
|
|
2
|
+
// (control-suite-wave5-input-codecs-pickers.decomp.md 5B-1 · ADR-0048; range mode: ADR-0093 /
|
|
3
|
+
// calendar-range.decomp-v2.json)
|
|
4
|
+
//
|
|
5
|
+
// A FACE form control (extends UIFormElement) that contributes a selected ISO date (YYYY-MM-DD)
|
|
6
|
+
// to a form AND will serve as the popup body for type=date on ui-text-field (lazily imported
|
|
7
|
+
// there in slice 5B-3 — this slice builds the standalone control only).
|
|
8
|
+
//
|
|
9
|
+
// MODE FLAG (ADR-0093) — `mode: 'single'|'range'`, default 'single'. ONE calendar, mode-flagged
|
|
10
|
+
// (Kim's F1 ruling; no sibling component, no `_base/` extraction — the machinery below stays
|
|
11
|
+
// #-private). The ONE-LIVE-VALUE-SURFACE rule (ADR-0093 clause 1) is the load-bearing safety net:
|
|
12
|
+
// in mode="single" `value` is live and `valueStart`/`valueEnd` are INERT (held + reflected,
|
|
13
|
+
// contribute NOTHING to render/formValue/formValidity/events); in mode="range" the pair is live
|
|
14
|
+
// and `value` is inert. The control NEVER writes an off-mode prop — every write site below is
|
|
15
|
+
// branched by `this.mode` so a mode switch never migrates data. Range interaction (clause 3):
|
|
16
|
+
// single grid, two picks, SWAP-COMPLETE — the second commit gesture always completes as
|
|
17
|
+
// `[min,max]` of {pick, pending start} (lexicographic ISO compare, zero Date construction), so a
|
|
18
|
+
// pick earlier than the pending start reorders rather than restarting (Kim's F2 ruling). The pick
|
|
19
|
+
// STATE ITSELF is derived from the props (idle = both '' · selecting-end = start set, end '' ·
|
|
20
|
+
// complete = both set, any order) — no extra private state field, so declarative markup
|
|
21
|
+
// (`<ui-calendar mode="range" value-start="…" value-end="…">`) is upgrade-order-safe by
|
|
22
|
+
// construction. Escape is NOT intercepted (ADR-0045 — dismissal stays the overlay's).
|
|
23
|
+
//
|
|
24
|
+
// ANATOMY (parts created ONCE — idempotent across disconnect/reconnect):
|
|
25
|
+
// <ui-calendar>
|
|
26
|
+
// <div data-part="panel" data-box>
|
|
27
|
+
// <header data-part="nav">
|
|
28
|
+
// <button data-part="prev" type="button" aria-label="Previous month"><svg>…caret-left…</svg></button>
|
|
29
|
+
// <span data-part="title" id="uid-N" aria-live="polite">July 2026</span>
|
|
30
|
+
// <button data-part="next" type="button" aria-label="Next month"><svg>…caret-right…</svg></button>
|
|
31
|
+
// </header>
|
|
32
|
+
// <div data-part="grid" role="grid" aria-labelledby="uid-N">
|
|
33
|
+
// <div role="row"> <!-- weekday column-header row -->
|
|
34
|
+
// <span role="columnheader" aria-label="Sunday">Su</span>…7 cols…
|
|
35
|
+
// </div>
|
|
36
|
+
// <div role="row"> <!-- one week (6 such rows) -->
|
|
37
|
+
// <button role="gridcell" type="button" tabindex="-1|0"
|
|
38
|
+
// data-date="YYYY-MM-DD" aria-label="Month D, YYYY"
|
|
39
|
+
// aria-selected="true|false" [aria-disabled="true"]
|
|
40
|
+
// [data-today] [data-outside]>D</button>
|
|
41
|
+
// </div>
|
|
42
|
+
// </div>
|
|
43
|
+
// </div>
|
|
44
|
+
// </ui-calendar>
|
|
45
|
+
//
|
|
46
|
+
// BESPOKE 2D GRID KEYBOARD (ADR-0048 decision 2 — NOT a roving-focus fork; model is distinct):
|
|
47
|
+
// ←/→ ±1 day · ↑/↓ ±7 days · Home/End week-start/-end (Sun/Sat) ·
|
|
48
|
+
// PageUp/Down ±1 month (REGENERATES the grid) · Shift+PageUp/Down ±1 year ·
|
|
49
|
+
// Enter/Space commit (disabled / out-of-range = no-op, enters preventDefault regardless).
|
|
50
|
+
// Roving tabindex=0: #focusIso cursor → selected day (if in month) → today (if in month)
|
|
51
|
+
// → first day of month. Adjacent-month cells shown as [data-outside]; navigating/clicking
|
|
52
|
+
// them rebuilds the grid for that month (standard calendar UX).
|
|
53
|
+
//
|
|
54
|
+
// DATE MATH: all ISO parsing uses explicit y/m/d extraction to avoid new Date('YYYY-MM-DD')
|
|
55
|
+
// UTC-midnight parsing traps. Arithmetic uses new Date(y, m-1, d ± delta) LOCAL construction
|
|
56
|
+
// which handles month/year boundaries correctly without any string parsing.
|
|
57
|
+
//
|
|
58
|
+
// TOKENS CONSUMED (with sensible CSS fallbacks until the tokens-specialist delivers them):
|
|
59
|
+
// --ui-calendar-cell-size · --ui-calendar-gap · --ui-calendar-selected-fill ·
|
|
60
|
+
// --ui-calendar-selected-ink · --ui-calendar-today-ring · --ui-calendar-disabled-ink ·
|
|
61
|
+
// --ui-calendar-outside-ink · --ui-calendar-range-fill · --ui-calendar-range-ink (ADR-0093,
|
|
62
|
+
// realized as the --md-sys-color-primary-surface-highest / -primary-on-surface AA pair —
|
|
63
|
+
// repointed off -primary-surface per the 2026-07-07 erratum, see ADR-0093).
|
|
64
|
+
//
|
|
65
|
+
// FORM SEAMS: mode="single" — formValue() = ISO string (null when none); formValidity() =
|
|
66
|
+
// valueMissing + rangeUnderflow/Overflow; formReset() → initial value attribute;
|
|
67
|
+
// formStateRestore(string). mode="range" (ADR-0093 clause 2) — formValue() = FormData with TWO
|
|
68
|
+
// entries under `name` (start first) when the pair is complete AND not inverted, else null (a
|
|
69
|
+
// range is atomic); formValidity() adds half-open + inverted (programmatic-only — interaction
|
|
70
|
+
// always swap-completes) + per-endpoint min/max; formReset() → the initial value-start/value-end
|
|
71
|
+
// attributes; formStateRestore(FormData) reads the two same-name entries.
|
|
72
|
+
//
|
|
73
|
+
// Layer: controls/ → dom + reactive (inward-only ✓). No overlay, roving-focus, or
|
|
74
|
+
// selectionCommit traits: this control owns its bespoke navigation model (ADR-0048 §2).
|
|
75
|
+
// erasableSyntaxOnly ✓ (no enum/namespace/decorators). verbatimModuleSyntax ✓ (import type).
|
|
76
|
+
import { UIFormElement } from "../../dom/index.js";
|
|
77
|
+
import { prop } from "../../dom/index.js";
|
|
78
|
+
import { trackUserInvalid } from "../../traits/track-user-invalid.js";
|
|
79
|
+
import { setIcon } from '@agent-ui-kit/icons';
|
|
80
|
+
// ── Module-level stable-id counter (one per title/grid pair, never reused) ─────────────────
|
|
81
|
+
let _nextCalendarId = 0;
|
|
82
|
+
// ── Static date-math helpers (pure functions — timezone-safe) ────────────────────────────────
|
|
83
|
+
/**
|
|
84
|
+
* Parse an ISO 'YYYY-MM-DD' string into { y, m, d } integers. Returns null on any malformed
|
|
85
|
+
* input. Does NOT use new Date('YYYY-MM-DD'), which parses as UTC midnight and can shift the
|
|
86
|
+
* calendar day by ±1 in negative-offset timezones.
|
|
87
|
+
*/
|
|
88
|
+
function parseDateStr(s) {
|
|
89
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
|
90
|
+
if (!match)
|
|
91
|
+
return null;
|
|
92
|
+
return { y: Number(match[1]), m: Number(match[2]), d: Number(match[3]) };
|
|
93
|
+
}
|
|
94
|
+
/** Format integer y/m/d as ISO 'YYYY-MM-DD'. */
|
|
95
|
+
function dateStr(y, m, d) {
|
|
96
|
+
return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Advance a LOCAL date by `delta` days. Uses `new Date(y, m-1, d + delta)` (LOCAL month/day
|
|
100
|
+
* construction — no string parsing) so month/year rollovers are handled by the Date engine.
|
|
101
|
+
*/
|
|
102
|
+
function advanceDate(y, m, d, delta) {
|
|
103
|
+
const dt = new Date(y, m - 1, d + delta);
|
|
104
|
+
return { y: dt.getFullYear(), m: dt.getMonth() + 1, d: dt.getDate() };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Add `n` months to a (y, m) pair (1-based month). Handles year rollovers via LOCAL Date.
|
|
108
|
+
* Result is always normalized: no month < 1 or > 12.
|
|
109
|
+
*/
|
|
110
|
+
function addMonths(y, m, n) {
|
|
111
|
+
const dt = new Date(y, m - 1 + n, 1);
|
|
112
|
+
return { y: dt.getFullYear(), m: dt.getMonth() + 1 };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The number of days in (y, m) (1-based month). Correctly handles leap February.
|
|
116
|
+
* `new Date(y, m, 0)` — month `m` is 0-based, so this is day 0 of the NEXT month = last day
|
|
117
|
+
* of month m (1-based). Example: m=2 (Feb) → `new Date(y, 2, 0)` = Feb's last day.
|
|
118
|
+
*/
|
|
119
|
+
function daysInMonth(y, m) {
|
|
120
|
+
return new Date(y, m, 0).getDate();
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* True when `iso` is outside the [min, max] range. ISO 'YYYY-MM-DD' strings are
|
|
124
|
+
* lexicographically ordered when zero-padded, so string comparison IS chronological.
|
|
125
|
+
* Empty min/max = unbounded.
|
|
126
|
+
*/
|
|
127
|
+
function isOutOfRange(iso, min, max) {
|
|
128
|
+
if (min !== '' && iso < min)
|
|
129
|
+
return true;
|
|
130
|
+
if (max !== '' && iso > max)
|
|
131
|
+
return true;
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* ADR-0093 clause 5 — the range in-range/normalize mechanism, reusing `isOutOfRange`'s proof that
|
|
136
|
+
* zero-padded ISO strings compare chronologically: lexicographic min/max of two NON-empty ISO
|
|
137
|
+
* strings. Used for both the swap-complete commit and the normalized preview — the same function,
|
|
138
|
+
* so the committed band is provably identical to what was previewed (ADR-0093 clause 3).
|
|
139
|
+
*/
|
|
140
|
+
function normalizeRange(a, b) {
|
|
141
|
+
return a <= b ? [a, b] : [b, a];
|
|
142
|
+
}
|
|
143
|
+
/** Format an ISO date long-form for AT announcements ("July 15, 2026"); `iso` unchanged if malformed. */
|
|
144
|
+
function formatIsoLong(iso) {
|
|
145
|
+
const p = parseDateStr(iso);
|
|
146
|
+
return p ? `${MONTH_NAMES[p.m - 1]} ${p.d}, ${p.y}` : iso;
|
|
147
|
+
}
|
|
148
|
+
/** One cell's range-selection facts (ADR-0093 clause 4) — a pure function of the pair + the preview
|
|
149
|
+
* candidate, ZERO Date construction (grep-provable). Single mode never calls this.
|
|
150
|
+
*
|
|
151
|
+
* `a` is always the anchor (`valueStart`, whenever it is set); `b` is the completed `valueEnd`
|
|
152
|
+
* when the pair is complete, else the live preview candidate (hover/keyboard-focus) while
|
|
153
|
+
* selecting-end. `a` keeps ITS OWN endpoint mark regardless of chronological order (ADR-0093
|
|
154
|
+
* clause 3: "the pending start keeping its endpoint mark") — the interior wash alone is
|
|
155
|
+
* normalized via `normalizeRange`. */
|
|
156
|
+
function rangeSelectionFor(iso, valueStart, valueEnd, previewIso) {
|
|
157
|
+
const a = valueStart;
|
|
158
|
+
const b = valueEnd !== '' ? valueEnd : previewIso;
|
|
159
|
+
if (a === '')
|
|
160
|
+
return { selected: false, rangeStart: false, rangeEnd: false, inRange: false };
|
|
161
|
+
const rangeStart = iso === a;
|
|
162
|
+
const rangeEnd = b !== '' && iso === b;
|
|
163
|
+
let inBand = rangeStart;
|
|
164
|
+
if (b !== '') {
|
|
165
|
+
const [lo, hi] = normalizeRange(a, b);
|
|
166
|
+
inBand = iso >= lo && iso <= hi;
|
|
167
|
+
}
|
|
168
|
+
return { selected: rangeStart || rangeEnd || inBand, rangeStart, rangeEnd, inRange: inBand && !rangeStart && !rangeEnd };
|
|
169
|
+
}
|
|
170
|
+
// ── Label data (English, static — calendar spec doesn't require Intl localization) ──────────
|
|
171
|
+
const MONTH_NAMES = [
|
|
172
|
+
'January', 'February', 'March', 'April', 'May', 'June',
|
|
173
|
+
'July', 'August', 'September', 'October', 'November', 'December',
|
|
174
|
+
];
|
|
175
|
+
const WEEKDAY_SHORT = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
|
176
|
+
const WEEKDAY_FULL = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
177
|
+
// ── Props ────────────────────────────────────────────────────────────────────────────────────
|
|
178
|
+
const props = {
|
|
179
|
+
// Universal form attributes from the spreadable formProps (name / disabled / required).
|
|
180
|
+
// All three reflect: name for submission keying, disabled/required for attribute-selector styling.
|
|
181
|
+
...UIFormElement.formProps,
|
|
182
|
+
// `mode` — ADR-0093: 'single' (default) keeps `value` live; 'range' keeps `valueStart`/
|
|
183
|
+
// `valueEnd` live instead (the ONE-LIVE-VALUE-SURFACE rule — clause 1). An enum, not a bare
|
|
184
|
+
// boolean, leaving room for foreseeable future modes (matches the fleet's `size` enum pattern).
|
|
185
|
+
mode: { ...prop.enum(['single', 'range'], 'single'), reflect: true },
|
|
186
|
+
// `value` — the selected date as an ISO 'YYYY-MM-DD' string. '' = nothing selected.
|
|
187
|
+
// Reflected so `<ui-calendar value="2026-07-01">` works declaratively AND the renderer
|
|
188
|
+
// two-way-binds it (value:{prop:'value', event:'change'}). INERT in mode="range" (ADR-0093
|
|
189
|
+
// clause 1) — held + reflected, but contributes nothing to render/formValue/formValidity/events.
|
|
190
|
+
value: { ...prop.string(''), reflect: true },
|
|
191
|
+
// `valueStart` / `valueEnd` — ADR-0093 clause 1: the range-mode value pair, mirroring
|
|
192
|
+
// ui-slider-multi's `valueLo`/`valueHi` shape exactly (kebab attrs via explicit `attribute:`
|
|
193
|
+
// overrides; '' = unset). INERT in mode="single" — held + reflected, contribute nothing.
|
|
194
|
+
valueStart: { ...prop.string(''), reflect: true, attribute: 'value-start' },
|
|
195
|
+
valueEnd: { ...prop.string(''), reflect: true, attribute: 'value-end' },
|
|
196
|
+
// `min`/`max` — ISO date bounds ('' = unbounded). Drive formValidity() range checks and
|
|
197
|
+
// aria-disabled on out-of-range day cells. Reflected for native attribute-IDL parity with
|
|
198
|
+
// text-field min/max (ADR-0047 fleet consistency ruling).
|
|
199
|
+
min: { ...prop.string(''), reflect: true },
|
|
200
|
+
max: { ...prop.string(''), reflect: true },
|
|
201
|
+
// `size` — presentational cell-size tier. Reflected so [size=sm/lg] CSS rules engage on
|
|
202
|
+
// both JS-set and HTML-authored values (fleet standard; same pattern as UIIndicatorElement.props).
|
|
203
|
+
size: { ...prop.enum(['sm', 'md', 'lg'], 'md'), reflect: true },
|
|
204
|
+
};
|
|
205
|
+
export class UICalendarElement extends UIFormElement {
|
|
206
|
+
static props = props;
|
|
207
|
+
// Shell parts created ONCE (idempotent across disconnect/reconnect).
|
|
208
|
+
#panelEl = null;
|
|
209
|
+
#gridEl = null;
|
|
210
|
+
#titleEl = null;
|
|
211
|
+
#prevBtn = null;
|
|
212
|
+
#nextBtn = null;
|
|
213
|
+
#statusEl = null; // ADR-0093 — visually-hidden aria-live status (range mode only)
|
|
214
|
+
// The HTML-authored initial value captured at FIRST connect (before any reactive updates). Since
|
|
215
|
+
// `value` has `reflect: true`, setting `el.value = x` synchronously updates the content attribute —
|
|
216
|
+
// so `getAttribute('value')` at formReset time always returns the CURRENT value, not the original.
|
|
217
|
+
// Capturing here gives us the `<ui-calendar value="…">` default for form-reset parity.
|
|
218
|
+
#initialValue = '';
|
|
219
|
+
// ADR-0093 — the range pair's own HTML-authored initial values, captured the same way (and
|
|
220
|
+
// independently of #initialValue — mode-aware formReset() restores only the currently-live surface).
|
|
221
|
+
#initialValueStart = '';
|
|
222
|
+
#initialValueEnd = '';
|
|
223
|
+
// ADR-0093 clause 3 — the live hover/keyboard-focus PREVIEW candidate while selecting-end
|
|
224
|
+
// (valueStart set, valueEnd ''). NOT a reactive prop (purely ephemeral UI state); rendering reads
|
|
225
|
+
// it directly. '' = no candidate (nothing hovered/focused since the anchor was picked).
|
|
226
|
+
#previewIso = '';
|
|
227
|
+
// The currently displayed month (1-based). Updated only by navigation actions, which call
|
|
228
|
+
// #rebuildGrid() directly. Not reactive signals — the effect handles prop-change updates.
|
|
229
|
+
#displayYear = 0;
|
|
230
|
+
#displayMonth = 0;
|
|
231
|
+
// ISO date of the cell that currently holds tabindex=0 (the roving keyboard cursor).
|
|
232
|
+
// '' = unset; on first connect it is computed from value/today/first (see #computeFocusTarget).
|
|
233
|
+
#focusIso = '';
|
|
234
|
+
// The user-invalid TIMING controller (ADR-0051), created per connection (re-arms on reconnect;
|
|
235
|
+
// released on disconnect) — the text-field/select precedent.
|
|
236
|
+
#userInvalid = null;
|
|
237
|
+
// ── Form seams (UIFormElement hooks) ─────────────────────────────────────────────────────
|
|
238
|
+
formValue() {
|
|
239
|
+
if (this.mode === 'range') {
|
|
240
|
+
// ADR-0093 clause 2: a range is ATOMIC — a half-open or inverted pair contributes nothing.
|
|
241
|
+
const start = this.valueStart;
|
|
242
|
+
const end = this.valueEnd;
|
|
243
|
+
if (start === '' || end === '' || start > end)
|
|
244
|
+
return null;
|
|
245
|
+
const fd = new FormData();
|
|
246
|
+
const name = this.name || '';
|
|
247
|
+
fd.append(name, start); // start FIRST — the ui-slider-multi formValue() precedent
|
|
248
|
+
fd.append(name, end);
|
|
249
|
+
return fd;
|
|
250
|
+
}
|
|
251
|
+
// Null when nothing is selected — no form-data entry contributed. `value` is INERT in
|
|
252
|
+
// mode="range" (never read here — the branch above never falls through to it).
|
|
253
|
+
return this.value !== '' ? this.value : null;
|
|
254
|
+
}
|
|
255
|
+
formValidity() {
|
|
256
|
+
if (this.mode === 'range') {
|
|
257
|
+
const start = this.valueStart;
|
|
258
|
+
const end = this.valueEnd;
|
|
259
|
+
const hasStart = start !== '';
|
|
260
|
+
const hasEnd = end !== '';
|
|
261
|
+
if (!hasStart && !hasEnd) {
|
|
262
|
+
if (this.required) {
|
|
263
|
+
return { valid: false, flags: { valueMissing: true }, message: 'Please select a date range.' };
|
|
264
|
+
}
|
|
265
|
+
return { valid: true };
|
|
266
|
+
}
|
|
267
|
+
if (hasStart !== hasEnd) {
|
|
268
|
+
// Half-open (clause 2) — a range's value is atomic; exactly one endpoint is never a valid
|
|
269
|
+
// submission, `required` or not (there is nothing coherent to submit yet).
|
|
270
|
+
return { valid: false, flags: { valueMissing: true }, message: 'Please select an end date.' };
|
|
271
|
+
}
|
|
272
|
+
// Both endpoints set.
|
|
273
|
+
if (start > end) {
|
|
274
|
+
// Inverted (clause 2) — reachable ONLY by a programmatic set; interaction always
|
|
275
|
+
// swap-completes (clause 3). The platform's ValidityStateFlags has no "inverted" flag;
|
|
276
|
+
// rangeUnderflow (start "underflows" past end) is the closest structural fit — the
|
|
277
|
+
// MESSAGE, not the flag, carries the real diagnosis.
|
|
278
|
+
return { valid: false, flags: { rangeUnderflow: true }, message: 'The start date must be on or before the end date.' };
|
|
279
|
+
}
|
|
280
|
+
if (this.min !== '' && start < this.min) {
|
|
281
|
+
return { valid: false, flags: { rangeUnderflow: true }, message: `Value must be on or after ${this.min}.` };
|
|
282
|
+
}
|
|
283
|
+
if (this.max !== '' && end > this.max) {
|
|
284
|
+
return { valid: false, flags: { rangeOverflow: true }, message: `Value must be on or before ${this.max}.` };
|
|
285
|
+
}
|
|
286
|
+
return { valid: true };
|
|
287
|
+
}
|
|
288
|
+
if (this.required && this.value === '') {
|
|
289
|
+
return { valid: false, flags: { valueMissing: true }, message: 'Please select a date.' };
|
|
290
|
+
}
|
|
291
|
+
if (this.value !== '') {
|
|
292
|
+
if (this.min !== '' && this.value < this.min) {
|
|
293
|
+
return {
|
|
294
|
+
valid: false,
|
|
295
|
+
flags: { rangeUnderflow: true },
|
|
296
|
+
message: `Value must be on or after ${this.min}.`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
if (this.max !== '' && this.value > this.max) {
|
|
300
|
+
return {
|
|
301
|
+
valid: false,
|
|
302
|
+
flags: { rangeOverflow: true },
|
|
303
|
+
message: `Value must be on or before ${this.max}.`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return { valid: true };
|
|
308
|
+
}
|
|
309
|
+
formReset() {
|
|
310
|
+
// ADR-0093 clause 1: reset acts on the CURRENTLY-LIVE surface only — never writes the
|
|
311
|
+
// off-mode prop. Restore to the HTML-authored initial value(s) captured at first connect.
|
|
312
|
+
if (this.mode === 'range') {
|
|
313
|
+
this.valueStart = this.#initialValueStart;
|
|
314
|
+
this.valueEnd = this.#initialValueEnd;
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
this.value = this.#initialValue;
|
|
318
|
+
}
|
|
319
|
+
// ADR-0051 — a reset must not leave a required-empty calendar showing :state(user-invalid)
|
|
320
|
+
// until the user re-interacts (the text-field formReset() precedent).
|
|
321
|
+
this.#userInvalid?.reset();
|
|
322
|
+
}
|
|
323
|
+
formStateRestore(state) {
|
|
324
|
+
if (this.mode === 'range') {
|
|
325
|
+
if (!(state instanceof FormData))
|
|
326
|
+
return;
|
|
327
|
+
const name = this.name || '';
|
|
328
|
+
const vals = state.getAll(name).filter((v) => typeof v === 'string');
|
|
329
|
+
const ISO = /^\d{4}-\d{2}-\d{2}$/;
|
|
330
|
+
if (vals.length !== 2 || !ISO.test(vals[0]) || !ISO.test(vals[1]))
|
|
331
|
+
return;
|
|
332
|
+
const [start, end] = vals;
|
|
333
|
+
const p = parseDateStr(start);
|
|
334
|
+
if (!p)
|
|
335
|
+
return;
|
|
336
|
+
this.valueStart = start;
|
|
337
|
+
this.valueEnd = end;
|
|
338
|
+
this.#displayYear = p.y;
|
|
339
|
+
this.#displayMonth = p.m;
|
|
340
|
+
this.#focusIso = start;
|
|
341
|
+
this.#rebuildGrid();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (typeof state === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(state)) {
|
|
345
|
+
const p = parseDateStr(state);
|
|
346
|
+
if (!p)
|
|
347
|
+
return;
|
|
348
|
+
this.value = state;
|
|
349
|
+
// Navigate the displayed grid to the restored value's month so the selection is visible.
|
|
350
|
+
// (Setting `this.value` alone only updates aria-selected on the existing cells, which may be
|
|
351
|
+
// a different month entirely.) Does NOT steal focus — no #focusCurrentCell() call here.
|
|
352
|
+
this.#displayYear = p.y;
|
|
353
|
+
this.#displayMonth = p.m;
|
|
354
|
+
this.#focusIso = state;
|
|
355
|
+
this.#rebuildGrid();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// ── Connection lifecycle ──────────────────────────────────────────────────────────────────
|
|
359
|
+
connected() {
|
|
360
|
+
const { grid, prev, next } = this.#ensureShell();
|
|
361
|
+
// Capture the HTML-authored initial value(s) BEFORE any reactive updates (first connect
|
|
362
|
+
// only). `reflect: true` means el.value = x updates the attribute synchronously, so we
|
|
363
|
+
// cannot read getAttribute later and expect the original HTML value. Capture once here
|
|
364
|
+
// instead — both surfaces, independently of `mode` (ADR-0093: attribute-processing order
|
|
365
|
+
// must not matter, so BOTH captures always run regardless of which mode is authored).
|
|
366
|
+
if (this.#initialValue === '' && this.getAttribute('value')) {
|
|
367
|
+
this.#initialValue = this.getAttribute('value');
|
|
368
|
+
}
|
|
369
|
+
if (this.#initialValueStart === '' && this.getAttribute('value-start')) {
|
|
370
|
+
this.#initialValueStart = this.getAttribute('value-start');
|
|
371
|
+
}
|
|
372
|
+
if (this.#initialValueEnd === '' && this.getAttribute('value-end')) {
|
|
373
|
+
this.#initialValueEnd = this.getAttribute('value-end');
|
|
374
|
+
}
|
|
375
|
+
// Seed the displayed month from the currently-LIVE value surface (or today if none set) —
|
|
376
|
+
// mode-aware so `<ui-calendar mode="range" value-start="…">` shows the right month regardless
|
|
377
|
+
// of attribute order (ADR-0093 clause 1).
|
|
378
|
+
const today = this.#today();
|
|
379
|
+
const seedIso = this.mode === 'range'
|
|
380
|
+
? (this.valueStart !== '' ? this.valueStart : this.valueEnd)
|
|
381
|
+
: this.value;
|
|
382
|
+
const seeded = parseDateStr(seedIso) ?? today;
|
|
383
|
+
this.#displayYear = seeded.y;
|
|
384
|
+
this.#displayMonth = seeded.m;
|
|
385
|
+
// Seed the keyboard cursor: selected date(s) → today (if in month) → first day of month.
|
|
386
|
+
if (this.mode === 'range') {
|
|
387
|
+
if (this.valueStart !== '') {
|
|
388
|
+
this.#focusIso = this.valueStart;
|
|
389
|
+
}
|
|
390
|
+
else if (this.valueEnd !== '') {
|
|
391
|
+
this.#focusIso = this.valueEnd;
|
|
392
|
+
}
|
|
393
|
+
else if (seeded.y === today.y && seeded.m === today.m) {
|
|
394
|
+
this.#focusIso = dateStr(today.y, today.m, today.d);
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
this.#focusIso = dateStr(seeded.y, seeded.m, 1);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
else if (this.value !== '') {
|
|
401
|
+
this.#focusIso = this.value;
|
|
402
|
+
}
|
|
403
|
+
else if (seeded.y === today.y && seeded.m === today.m) {
|
|
404
|
+
this.#focusIso = dateStr(today.y, today.m, today.d);
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
this.#focusIso = dateStr(seeded.y, seeded.m, 1);
|
|
408
|
+
}
|
|
409
|
+
// Build the initial grid.
|
|
410
|
+
this.#rebuildGrid();
|
|
411
|
+
// ── ADR-0051 — the user-invalid TIMING controller ─────────────────────────────────────
|
|
412
|
+
// `blur` is captured at the host (never bubbles; the capture phase reaches this ancestor
|
|
413
|
+
// before whichever gridcell button held focus — the track-user-invalid.ts precedent);
|
|
414
|
+
// `change` is emitted directly on `this` by #commitDate/#commitRangeDate, so it lands here
|
|
415
|
+
// regardless of which commit path fired. Reflects :state(user-invalid) + aria-invalid on the
|
|
416
|
+
// grid part (the role-carrying part — role='grid' rides the part, not internals.role).
|
|
417
|
+
const invalidController = trackUserInvalid(this, { invalid: () => !this.formValidity().valid });
|
|
418
|
+
this.#userInvalid = invalidController;
|
|
419
|
+
this.effect(() => {
|
|
420
|
+
if (invalidController.userInvalid()) {
|
|
421
|
+
this.internals.states?.add('user-invalid');
|
|
422
|
+
grid.setAttribute('aria-invalid', 'true');
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
this.internals.states?.delete('user-invalid');
|
|
426
|
+
grid.removeAttribute('aria-invalid');
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
// Reactive effect: when value/valueStart/valueEnd/mode/min/max/disabled change, update
|
|
430
|
+
// existing cells' ARIA + range data attributes without rebuilding the DOM. Reading these
|
|
431
|
+
// signals here registers them as dependencies — the effect re-runs whenever any changes.
|
|
432
|
+
this.effect(() => {
|
|
433
|
+
const v = this.value;
|
|
434
|
+
const min = this.min;
|
|
435
|
+
const max = this.max;
|
|
436
|
+
const dis = this.effectiveDisabled();
|
|
437
|
+
const mode = this.mode;
|
|
438
|
+
const vs = this.valueStart;
|
|
439
|
+
const ve = this.valueEnd;
|
|
440
|
+
this.#updateCellStates(v, min, max, dis, mode, vs, ve, this.#previewIso);
|
|
441
|
+
});
|
|
442
|
+
// Prev/Next month navigation buttons.
|
|
443
|
+
this.listen(prev, 'click', () => {
|
|
444
|
+
const nm = addMonths(this.#displayYear, this.#displayMonth, -1);
|
|
445
|
+
this.#navigateToMonth(nm.y, nm.m);
|
|
446
|
+
});
|
|
447
|
+
this.listen(next, 'click', () => {
|
|
448
|
+
const nm = addMonths(this.#displayYear, this.#displayMonth, 1);
|
|
449
|
+
this.#navigateToMonth(nm.y, nm.m);
|
|
450
|
+
});
|
|
451
|
+
// Bespoke 2D grid keyboard handler (ADR-0048 decision 2; range preview: ADR-0093 clause 3).
|
|
452
|
+
this.listen(grid, 'keydown', (event) => {
|
|
453
|
+
this.#handleGridKey(event);
|
|
454
|
+
});
|
|
455
|
+
// Grid click handler — commit via event delegation, navigate adjacent-month cells.
|
|
456
|
+
this.listen(grid, 'click', (event) => {
|
|
457
|
+
this.#handleGridClick(event);
|
|
458
|
+
});
|
|
459
|
+
// ADR-0093 clause 3 — hover preview while selecting-end. `pointerover` bubbles (delegation,
|
|
460
|
+
// like click); `pointerleave` on the grid itself (does NOT bubble — fires only when the
|
|
461
|
+
// pointer truly leaves the grid's box, not on cell-to-cell movement within it) clears the
|
|
462
|
+
// candidate. Both are no-ops outside mode="range" and outside the selecting-end state.
|
|
463
|
+
this.listen(grid, 'pointerover', (event) => {
|
|
464
|
+
if (this.mode !== 'range')
|
|
465
|
+
return;
|
|
466
|
+
if (this.valueStart === '' || this.valueEnd !== '')
|
|
467
|
+
return; // not selecting-end
|
|
468
|
+
const hit = event.target;
|
|
469
|
+
const cell = hit?.closest('[role="gridcell"]');
|
|
470
|
+
if (!cell || !this.#gridEl?.contains(cell))
|
|
471
|
+
return;
|
|
472
|
+
const iso = cell.dataset['date'];
|
|
473
|
+
if (!iso || iso === this.#previewIso)
|
|
474
|
+
return;
|
|
475
|
+
this.#previewIso = iso;
|
|
476
|
+
this.#refreshCellVisuals();
|
|
477
|
+
});
|
|
478
|
+
this.listen(grid, 'pointerleave', () => {
|
|
479
|
+
if (this.#previewIso === '')
|
|
480
|
+
return;
|
|
481
|
+
this.#previewIso = '';
|
|
482
|
+
this.#refreshCellVisuals();
|
|
483
|
+
});
|
|
484
|
+
// Blur clears too — focus leaving the grid ENTIRELY (relatedTarget outside it) ends the
|
|
485
|
+
// preview; focus moving BETWEEN cells inside the grid (arrow-key nav) keeps previewing (that
|
|
486
|
+
// path already re-set #previewIso itself, above). No-op whenever previewIso is already ''
|
|
487
|
+
// (i.e. always, in mode="single").
|
|
488
|
+
this.listen(grid, 'focusout', (event) => {
|
|
489
|
+
const to = event.relatedTarget;
|
|
490
|
+
if (to && this.#gridEl?.contains(to))
|
|
491
|
+
return;
|
|
492
|
+
if (this.#previewIso === '')
|
|
493
|
+
return;
|
|
494
|
+
this.#previewIso = '';
|
|
495
|
+
this.#refreshCellVisuals();
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
disconnected() {
|
|
499
|
+
this.#userInvalid?.release(); // idempotent — the listeners already die with the connection scope
|
|
500
|
+
this.#userInvalid = null;
|
|
501
|
+
}
|
|
502
|
+
/** Feeds `FormConnectDetail.userInvalid` (ADR-0050) — the `trackUserInvalid` tracker IS the one
|
|
503
|
+
* timing source; this override just exposes its gate (the text-field/select precedent). */
|
|
504
|
+
formUserInvalid() {
|
|
505
|
+
return this.#userInvalid?.userInvalid() ?? false;
|
|
506
|
+
}
|
|
507
|
+
// ── ADR-0051 — the field-labelling seam wire (the calendar-merge override) ───────────────────
|
|
508
|
+
/**
|
|
509
|
+
* The part-role override (ADR-0051 cl.2/cl.Consequences "calendar is a follow-up too") — the
|
|
510
|
+
* grid's `role='grid'` rides a light-DOM PART attribute, not `internals.role`, so the base's
|
|
511
|
+
* guarded internals-reflection default (dom/form.ts) never fires for this control. UNLIKE
|
|
512
|
+
* text-field/select, the grid ALREADY self-labels to its own month title
|
|
513
|
+
* (`aria-labelledby=titleId`, set once at `#ensureShell()`) — this override must MERGE the
|
|
514
|
+
* field's label ref into that existing relationship, never clobber it (the calendar always
|
|
515
|
+
* needs its own month/year context alongside whatever name the field supplies).
|
|
516
|
+
*
|
|
517
|
+
* `aria-describedby` has no other owner (the grid carries no internal validity-message node,
|
|
518
|
+
* unlike text-field's editor) — written from `[refs.description, refs.error]` when fielded,
|
|
519
|
+
* cleared on dissociation, in both branches below (the combo-box precedent, which has the same
|
|
520
|
+
* no-competing-owner shape).
|
|
521
|
+
*
|
|
522
|
+
* Guards a not-yet-created grid/title (the LLD-C2 override contract) — cannot happen in practice
|
|
523
|
+
* (`#ensureShell()` runs synchronously at the top of `connected()`, before the base's forwarding
|
|
524
|
+
* effect installs), but the guard costs nothing and documents the contract.
|
|
525
|
+
*/
|
|
526
|
+
applyFieldLabelling(refs) {
|
|
527
|
+
const grid = this.#gridEl;
|
|
528
|
+
const title = this.#titleEl;
|
|
529
|
+
if (!grid || !title)
|
|
530
|
+
return;
|
|
531
|
+
grid.setAttribute('aria-labelledby', refs?.label ? `${refs.label.id} ${title.id}` : title.id);
|
|
532
|
+
if (refs === null) {
|
|
533
|
+
grid.removeAttribute('aria-describedby');
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const described = [refs.description, refs.error].filter((el) => el !== null);
|
|
537
|
+
if (described.length > 0)
|
|
538
|
+
grid.setAttribute('aria-describedby', described.map((el) => el.id).join(' '));
|
|
539
|
+
else
|
|
540
|
+
grid.removeAttribute('aria-describedby');
|
|
541
|
+
}
|
|
542
|
+
// ── Shell creation (idempotent) ───────────────────────────────────────────────────────────
|
|
543
|
+
#ensureShell() {
|
|
544
|
+
if (this.#panelEl && this.#gridEl && this.#prevBtn && this.#nextBtn && this.#titleEl && this.#statusEl) {
|
|
545
|
+
return { grid: this.#gridEl, prev: this.#prevBtn, next: this.#nextBtn };
|
|
546
|
+
}
|
|
547
|
+
// Panel — the [data-box] container (adopts the shared box-model: inset margins + sticky nav).
|
|
548
|
+
const panel = document.createElement('div');
|
|
549
|
+
panel.setAttribute('data-part', 'panel');
|
|
550
|
+
panel.setAttribute('data-box', '');
|
|
551
|
+
// Nav header — <header> is sticky + flex-row by container-box.css; we add `justify-content`.
|
|
552
|
+
const nav = document.createElement('header');
|
|
553
|
+
nav.setAttribute('data-part', 'nav');
|
|
554
|
+
const prev = document.createElement('button');
|
|
555
|
+
prev.setAttribute('data-part', 'prev');
|
|
556
|
+
prev.setAttribute('type', 'button');
|
|
557
|
+
prev.setAttribute('aria-label', 'Previous month');
|
|
558
|
+
setIcon(prev, 'caret-left'); // Phosphor, via @agent-ui-kit/icons
|
|
559
|
+
// Stable title id so [data-part=grid] can reference it via aria-labelledby.
|
|
560
|
+
const titleId = `ui-calendar-title-${++_nextCalendarId}`;
|
|
561
|
+
const title = document.createElement('span');
|
|
562
|
+
title.setAttribute('data-part', 'title');
|
|
563
|
+
title.setAttribute('id', titleId);
|
|
564
|
+
title.setAttribute('aria-live', 'polite');
|
|
565
|
+
// textContent filled by #rebuildGrid() on each display-month change.
|
|
566
|
+
const next = document.createElement('button');
|
|
567
|
+
next.setAttribute('data-part', 'next');
|
|
568
|
+
next.setAttribute('type', 'button');
|
|
569
|
+
next.setAttribute('aria-label', 'Next month');
|
|
570
|
+
setIcon(next, 'caret-right'); // Phosphor, via @agent-ui-kit/icons
|
|
571
|
+
nav.appendChild(prev);
|
|
572
|
+
nav.appendChild(title);
|
|
573
|
+
nav.appendChild(next);
|
|
574
|
+
// Grid container — host of the weekday header row + 6 week rows (all populated by #rebuildGrid).
|
|
575
|
+
const grid = document.createElement('div');
|
|
576
|
+
grid.setAttribute('data-part', 'grid');
|
|
577
|
+
grid.setAttribute('role', 'grid');
|
|
578
|
+
grid.setAttribute('aria-labelledby', titleId);
|
|
579
|
+
// ADR-0093 clause 3 — visually-hidden aria-live status region (range mode only). Always
|
|
580
|
+
// created (the idempotent-parts precedent — ui-select's aria-label span) so connected() never
|
|
581
|
+
// conditionally mints DOM; text stays empty (never written by #announce) in mode="single".
|
|
582
|
+
// Position:absolute (calendar.css) takes it out of flow — it does not disturb the [data-box]
|
|
583
|
+
// direct-child inset the panel/nav/grid share.
|
|
584
|
+
const status = document.createElement('div');
|
|
585
|
+
status.setAttribute('data-part', 'status');
|
|
586
|
+
status.setAttribute('aria-live', 'polite');
|
|
587
|
+
panel.appendChild(nav);
|
|
588
|
+
panel.appendChild(grid);
|
|
589
|
+
panel.appendChild(status);
|
|
590
|
+
this.appendChild(panel);
|
|
591
|
+
this.#panelEl = panel;
|
|
592
|
+
this.#gridEl = grid;
|
|
593
|
+
this.#titleEl = title;
|
|
594
|
+
this.#prevBtn = prev;
|
|
595
|
+
this.#nextBtn = next;
|
|
596
|
+
this.#statusEl = status;
|
|
597
|
+
return { grid, prev, next };
|
|
598
|
+
}
|
|
599
|
+
// ── Grid population ───────────────────────────────────────────────────────────────────────
|
|
600
|
+
/**
|
|
601
|
+
* Rebuild the entire grid for the currently-displayed month. Clears old rows and creates
|
|
602
|
+
* fresh DOM (weekday header row + 6 week rows × 7 cells). The focus cursor (#focusIso) is
|
|
603
|
+
* used to assign tabindex=0 via #computeFocusTarget.
|
|
604
|
+
*
|
|
605
|
+
* Called on: initial connect · prev/next nav · PageUp/Down · Arrow keys that cross month
|
|
606
|
+
* boundaries · click on an adjacent-month cell.
|
|
607
|
+
*/
|
|
608
|
+
#rebuildGrid() {
|
|
609
|
+
const grid = this.#gridEl;
|
|
610
|
+
const title = this.#titleEl;
|
|
611
|
+
if (!grid || !title)
|
|
612
|
+
return;
|
|
613
|
+
const y = this.#displayYear;
|
|
614
|
+
const m = this.#displayMonth;
|
|
615
|
+
const today = this.#today();
|
|
616
|
+
const v = this.value;
|
|
617
|
+
const min = this.min;
|
|
618
|
+
const max = this.max;
|
|
619
|
+
const dis = this.effectiveDisabled();
|
|
620
|
+
const mode = this.mode;
|
|
621
|
+
const vs = this.valueStart;
|
|
622
|
+
const ve = this.valueEnd;
|
|
623
|
+
const preview = this.#previewIso;
|
|
624
|
+
const focusTarget = this.#computeFocusTarget();
|
|
625
|
+
// Update the month/year title (aria-live=polite announces to AT on change).
|
|
626
|
+
title.textContent = `${MONTH_NAMES[m - 1]} ${y}`;
|
|
627
|
+
// Clear existing content (idempotent: always rebuilds from scratch).
|
|
628
|
+
while (grid.firstChild)
|
|
629
|
+
grid.removeChild(grid.firstChild);
|
|
630
|
+
// Weekday column-header row: Su Mo Tu We Th Fr Sa.
|
|
631
|
+
const headerRow = document.createElement('div');
|
|
632
|
+
headerRow.setAttribute('role', 'row');
|
|
633
|
+
for (let i = 0; i < 7; i++) {
|
|
634
|
+
const th = document.createElement('span');
|
|
635
|
+
th.setAttribute('role', 'columnheader');
|
|
636
|
+
th.setAttribute('aria-label', WEEKDAY_FULL[i]);
|
|
637
|
+
th.setAttribute('abbr', WEEKDAY_FULL[i]);
|
|
638
|
+
th.textContent = WEEKDAY_SHORT[i];
|
|
639
|
+
headerRow.appendChild(th);
|
|
640
|
+
}
|
|
641
|
+
grid.appendChild(headerRow);
|
|
642
|
+
// Compute the first cell in the 6×7 grid. `firstDow` = day-of-week of the month's 1st
|
|
643
|
+
// (0=Sunday). `startDate` = that 1st minus its DOW offset (may be in the previous month).
|
|
644
|
+
// LOCAL Date construction — no timezone pitfall here (m is 1-based, m-1 is 0-based).
|
|
645
|
+
const firstDow = new Date(y, m - 1, 1).getDay();
|
|
646
|
+
const startDate = advanceDate(y, m, 1, -firstDow);
|
|
647
|
+
// 6 week rows × 7 day cells.
|
|
648
|
+
for (let row = 0; row < 6; row++) {
|
|
649
|
+
const rowEl = document.createElement('div');
|
|
650
|
+
rowEl.setAttribute('role', 'row');
|
|
651
|
+
for (let col = 0; col < 7; col++) {
|
|
652
|
+
const idx = row * 7 + col;
|
|
653
|
+
const { y: cy, m: cm, d: cd } = advanceDate(startDate.y, startDate.m, startDate.d, idx);
|
|
654
|
+
const iso = dateStr(cy, cm, cd);
|
|
655
|
+
const isOutside = (cy !== y || cm !== m);
|
|
656
|
+
const isToday = (cy === today.y && cm === today.m && cd === today.d);
|
|
657
|
+
const isDisabled = dis || isOutOfRange(iso, min, max);
|
|
658
|
+
// ADR-0093 clause 4 — range mode computes selection from the pair (+ preview candidate);
|
|
659
|
+
// single mode keeps the original single-value check. `range` is null in single mode —
|
|
660
|
+
// its data attributes are then never set below (grep-provable: single mode stamps none).
|
|
661
|
+
const range = mode === 'range' ? rangeSelectionFor(iso, vs, ve, preview) : null;
|
|
662
|
+
const isSelected = mode === 'range' ? range.selected : (iso === v && v !== '');
|
|
663
|
+
const cell = document.createElement('button');
|
|
664
|
+
cell.setAttribute('role', 'gridcell');
|
|
665
|
+
cell.setAttribute('type', 'button');
|
|
666
|
+
cell.setAttribute('tabindex', iso === focusTarget ? '0' : '-1');
|
|
667
|
+
cell.setAttribute('aria-label', `${MONTH_NAMES[cm - 1]} ${cd}, ${cy}`);
|
|
668
|
+
cell.setAttribute('aria-selected', isSelected ? 'true' : 'false');
|
|
669
|
+
if (isDisabled)
|
|
670
|
+
cell.setAttribute('aria-disabled', 'true');
|
|
671
|
+
if (isToday)
|
|
672
|
+
cell.dataset['today'] = '';
|
|
673
|
+
if (isOutside)
|
|
674
|
+
cell.dataset['outside'] = '';
|
|
675
|
+
if (range?.rangeStart)
|
|
676
|
+
cell.dataset['rangeStart'] = '';
|
|
677
|
+
if (range?.rangeEnd)
|
|
678
|
+
cell.dataset['rangeEnd'] = '';
|
|
679
|
+
if (range?.inRange)
|
|
680
|
+
cell.dataset['inRange'] = '';
|
|
681
|
+
cell.dataset['date'] = iso;
|
|
682
|
+
cell.textContent = String(cd);
|
|
683
|
+
rowEl.appendChild(cell);
|
|
684
|
+
}
|
|
685
|
+
grid.appendChild(rowEl);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Update ARIA + range data attributes (aria-selected / aria-disabled / data-today /
|
|
690
|
+
* data-range-start / data-range-end / data-in-range) on the existing cells in the current grid
|
|
691
|
+
* when a prop OR the preview candidate changes. No DOM rebuild — pure attribute mutation. Also
|
|
692
|
+
* re-syncs tabindex=0 to the computed focus target.
|
|
693
|
+
*
|
|
694
|
+
* Called from the scope-owned reactive effect (value/valueStart/valueEnd/mode/min/max/disabled)
|
|
695
|
+
* AND imperatively from the hover/keyboard preview paths (#refreshCellVisuals) — `mode`/
|
|
696
|
+
* `valueStart`/`valueEnd`/`previewIso` are passed explicitly rather than re-read internally so
|
|
697
|
+
* every call site is an honest snapshot of what it is rendering.
|
|
698
|
+
*/
|
|
699
|
+
#updateCellStates(v, min, max, disabled, mode, valueStart, valueEnd, previewIso) {
|
|
700
|
+
const grid = this.#gridEl;
|
|
701
|
+
if (!grid)
|
|
702
|
+
return;
|
|
703
|
+
const today = this.#today();
|
|
704
|
+
const cells = grid.querySelectorAll('[role="gridcell"]');
|
|
705
|
+
const focusTarget = this.#computeFocusTarget();
|
|
706
|
+
for (const cell of cells) {
|
|
707
|
+
const iso = cell.dataset['date'];
|
|
708
|
+
if (!iso)
|
|
709
|
+
continue;
|
|
710
|
+
// ADR-0093 clause 4 — `range` is null in single mode, so every data-range-*/data-in-range
|
|
711
|
+
// branch below takes its `false` arm and the attribute is deleted (single mode stamps none).
|
|
712
|
+
const range = mode === 'range' ? rangeSelectionFor(iso, valueStart, valueEnd, previewIso) : null;
|
|
713
|
+
const isSelected = mode === 'range' ? range.selected : (iso === v && v !== '');
|
|
714
|
+
const isDisabled = disabled || isOutOfRange(iso, min, max);
|
|
715
|
+
const parsed = parseDateStr(iso);
|
|
716
|
+
const isToday = parsed
|
|
717
|
+
? (parsed.y === today.y && parsed.m === today.m && parsed.d === today.d)
|
|
718
|
+
: false;
|
|
719
|
+
cell.setAttribute('aria-selected', isSelected ? 'true' : 'false');
|
|
720
|
+
if (isDisabled) {
|
|
721
|
+
cell.setAttribute('aria-disabled', 'true');
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
cell.removeAttribute('aria-disabled');
|
|
725
|
+
}
|
|
726
|
+
if (isToday) {
|
|
727
|
+
cell.dataset['today'] = '';
|
|
728
|
+
}
|
|
729
|
+
else {
|
|
730
|
+
delete cell.dataset['today'];
|
|
731
|
+
}
|
|
732
|
+
if (range?.rangeStart)
|
|
733
|
+
cell.dataset['rangeStart'] = '';
|
|
734
|
+
else
|
|
735
|
+
delete cell.dataset['rangeStart'];
|
|
736
|
+
if (range?.rangeEnd)
|
|
737
|
+
cell.dataset['rangeEnd'] = '';
|
|
738
|
+
else
|
|
739
|
+
delete cell.dataset['rangeEnd'];
|
|
740
|
+
if (range?.inRange)
|
|
741
|
+
cell.dataset['inRange'] = '';
|
|
742
|
+
else
|
|
743
|
+
delete cell.dataset['inRange'];
|
|
744
|
+
cell.setAttribute('tabindex', iso === focusTarget ? '0' : '-1');
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
/** Recompute + apply cell visuals from the CURRENT prop/preview state (the hover/keyboard
|
|
748
|
+
* preview call site — outside the reactive effect, since `#previewIso` is not a signal). */
|
|
749
|
+
#refreshCellVisuals() {
|
|
750
|
+
this.#updateCellStates(this.value, this.min, this.max, this.effectiveDisabled(), this.mode, this.valueStart, this.valueEnd, this.#previewIso);
|
|
751
|
+
}
|
|
752
|
+
// ── Month navigation ──────────────────────────────────────────────────────────────────────
|
|
753
|
+
/**
|
|
754
|
+
* Navigate to a different month: update display state, update the focus cursor to the same
|
|
755
|
+
* day number (clamped to the new month's last day), rebuild the grid, restore DOM focus.
|
|
756
|
+
*
|
|
757
|
+
* Called by the prev/next buttons. Keyboard PageUp/Down navigates imperatively (they know
|
|
758
|
+
* the exact target day and call #rebuildGrid + #focusCurrentCell directly).
|
|
759
|
+
*/
|
|
760
|
+
#navigateToMonth(y, m) {
|
|
761
|
+
const prevFocus = parseDateStr(this.#focusIso);
|
|
762
|
+
const day = prevFocus ? prevFocus.d : 1;
|
|
763
|
+
const clamped = Math.min(day, daysInMonth(y, m));
|
|
764
|
+
this.#displayYear = y;
|
|
765
|
+
this.#displayMonth = m;
|
|
766
|
+
this.#focusIso = dateStr(y, m, clamped);
|
|
767
|
+
this.#rebuildGrid();
|
|
768
|
+
this.#focusCurrentCell();
|
|
769
|
+
}
|
|
770
|
+
/** DOM-focus the cell that holds tabindex=0. No-op when the grid isn't connected. */
|
|
771
|
+
#focusCurrentCell() {
|
|
772
|
+
const cell = this.#gridEl?.querySelector('[tabindex="0"]');
|
|
773
|
+
cell?.focus();
|
|
774
|
+
}
|
|
775
|
+
// ── Bespoke 2D keyboard handler ───────────────────────────────────────────────────────────
|
|
776
|
+
#handleGridKey(event) {
|
|
777
|
+
// Only intercept grid-navigation / commit keys.
|
|
778
|
+
const key = event.key;
|
|
779
|
+
const NAV = new Set([
|
|
780
|
+
'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown',
|
|
781
|
+
'Home', 'End', 'PageUp', 'PageDown', 'Enter', ' ',
|
|
782
|
+
]);
|
|
783
|
+
if (!NAV.has(key))
|
|
784
|
+
return;
|
|
785
|
+
// Active cell must be inside the grid with role=gridcell.
|
|
786
|
+
const active = document.activeElement;
|
|
787
|
+
if (!active || active.getAttribute('role') !== 'gridcell')
|
|
788
|
+
return;
|
|
789
|
+
if (!this.#gridEl?.contains(active))
|
|
790
|
+
return;
|
|
791
|
+
const iso = active.dataset['date'];
|
|
792
|
+
if (!iso)
|
|
793
|
+
return;
|
|
794
|
+
const cur = parseDateStr(iso);
|
|
795
|
+
if (!cur)
|
|
796
|
+
return;
|
|
797
|
+
// preventDefault for ALL navigation/commit keys (suppresses scroll, form-submit,
|
|
798
|
+
// and — for Enter — the re-activation of an overlay anchor button, ADR-0048 §2).
|
|
799
|
+
event.preventDefault();
|
|
800
|
+
// ── Commit path (Enter / Space) ──────────────────────────────────────────────────────
|
|
801
|
+
if (key === 'Enter' || key === ' ') {
|
|
802
|
+
// No-op when the cell is disabled or out-of-range (aria-disabled is authoritative).
|
|
803
|
+
if (active.getAttribute('aria-disabled') !== 'true') {
|
|
804
|
+
this.#commitDate(cur.y, cur.m, cur.d);
|
|
805
|
+
}
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
// ── Navigation path — compute the target date ────────────────────────────────────────
|
|
809
|
+
let target;
|
|
810
|
+
switch (key) {
|
|
811
|
+
case 'ArrowLeft':
|
|
812
|
+
target = advanceDate(cur.y, cur.m, cur.d, -1);
|
|
813
|
+
break;
|
|
814
|
+
case 'ArrowRight':
|
|
815
|
+
target = advanceDate(cur.y, cur.m, cur.d, 1);
|
|
816
|
+
break;
|
|
817
|
+
case 'ArrowUp':
|
|
818
|
+
target = advanceDate(cur.y, cur.m, cur.d, -7);
|
|
819
|
+
break;
|
|
820
|
+
case 'ArrowDown':
|
|
821
|
+
target = advanceDate(cur.y, cur.m, cur.d, 7);
|
|
822
|
+
break;
|
|
823
|
+
case 'Home': {
|
|
824
|
+
// Sunday of the current week.
|
|
825
|
+
const dow = new Date(cur.y, cur.m - 1, cur.d).getDay();
|
|
826
|
+
target = advanceDate(cur.y, cur.m, cur.d, -dow);
|
|
827
|
+
break;
|
|
828
|
+
}
|
|
829
|
+
case 'End': {
|
|
830
|
+
// Saturday of the current week.
|
|
831
|
+
const dow = new Date(cur.y, cur.m - 1, cur.d).getDay();
|
|
832
|
+
target = advanceDate(cur.y, cur.m, cur.d, 6 - dow);
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
case 'PageUp': {
|
|
836
|
+
const delta = event.shiftKey ? -12 : -1;
|
|
837
|
+
const nm = addMonths(cur.y, cur.m, delta);
|
|
838
|
+
target = { ...nm, d: Math.min(cur.d, daysInMonth(nm.y, nm.m)) };
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
case 'PageDown': {
|
|
842
|
+
const delta = event.shiftKey ? 12 : 1;
|
|
843
|
+
const nm = addMonths(cur.y, cur.m, delta);
|
|
844
|
+
target = { ...nm, d: Math.min(cur.d, daysInMonth(nm.y, nm.m)) };
|
|
845
|
+
break;
|
|
846
|
+
}
|
|
847
|
+
default:
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
// Update the keyboard cursor.
|
|
851
|
+
this.#focusIso = dateStr(target.y, target.m, target.d);
|
|
852
|
+
// ADR-0093 clause 3 — keyboard-focus ALSO previews the in-progress band while selecting-end
|
|
853
|
+
// (the cursor IS the "which end am I editing" answer, same as hover). No-op outside
|
|
854
|
+
// mode="range" / outside selecting-end.
|
|
855
|
+
if (this.mode === 'range' && this.valueStart !== '' && this.valueEnd === '') {
|
|
856
|
+
this.#previewIso = this.#focusIso;
|
|
857
|
+
}
|
|
858
|
+
if (target.y === this.#displayYear && target.m === this.#displayMonth) {
|
|
859
|
+
// Target is in the current displayed month — refresh cell attributes (tabindex sync +
|
|
860
|
+
// aria-selected/range-preview) without a DOM rebuild.
|
|
861
|
+
this.#refreshCellVisuals();
|
|
862
|
+
this.#focusCurrentCell();
|
|
863
|
+
}
|
|
864
|
+
else {
|
|
865
|
+
// Different month — rebuild the grid for the new month then restore focus.
|
|
866
|
+
this.#displayYear = target.y;
|
|
867
|
+
this.#displayMonth = target.m;
|
|
868
|
+
this.#rebuildGrid();
|
|
869
|
+
this.#focusCurrentCell();
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
// ── Click handler ─────────────────────────────────────────────────────────────────────────
|
|
873
|
+
#handleGridClick(event) {
|
|
874
|
+
// Resolve the clicked cell via delegation (the click target may be the button or a child).
|
|
875
|
+
const hit = event.target;
|
|
876
|
+
const cell = hit?.closest('[role="gridcell"]');
|
|
877
|
+
if (!cell || !this.#gridEl?.contains(cell))
|
|
878
|
+
return;
|
|
879
|
+
const iso = cell.dataset['date'];
|
|
880
|
+
if (!iso)
|
|
881
|
+
return;
|
|
882
|
+
const parsed = parseDateStr(iso);
|
|
883
|
+
if (!parsed)
|
|
884
|
+
return;
|
|
885
|
+
// Update the keyboard cursor.
|
|
886
|
+
this.#focusIso = iso;
|
|
887
|
+
if (parsed.y !== this.#displayYear || parsed.m !== this.#displayMonth) {
|
|
888
|
+
// Adjacent-month cell: navigate to that month first (standard calendar UX).
|
|
889
|
+
this.#displayYear = parsed.y;
|
|
890
|
+
this.#displayMonth = parsed.m;
|
|
891
|
+
this.#rebuildGrid();
|
|
892
|
+
// Re-query the live cell after rebuild (old reference is gone).
|
|
893
|
+
const liveCell = this.#gridEl?.querySelector(`[data-date="${iso}"]`);
|
|
894
|
+
if (liveCell && liveCell.getAttribute('aria-disabled') !== 'true') {
|
|
895
|
+
this.#commitDate(parsed.y, parsed.m, parsed.d);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
else {
|
|
899
|
+
// Same month: commit if not disabled.
|
|
900
|
+
if (cell.getAttribute('aria-disabled') !== 'true') {
|
|
901
|
+
this.#commitDate(parsed.y, parsed.m, parsed.d);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
// ── Commit ────────────────────────────────────────────────────────────────────────────────
|
|
906
|
+
/**
|
|
907
|
+
* Commit a date selection — the ONE choke point both the click and Enter/Space paths call,
|
|
908
|
+
* which is exactly what makes it mode-aware without touching either caller (ADR-0093 clause 6:
|
|
909
|
+
* "`#commitDate` becomes mode-aware"). mode="single" runs the ORIGINAL, byte-identical path;
|
|
910
|
+
* mode="range" delegates to `#commitRangeDate` and NEVER touches `this.value` (clause 1).
|
|
911
|
+
*
|
|
912
|
+
* Emit semantics (ADR-0048 decision 2, reusing selectionCommit's learned semantics):
|
|
913
|
+
* · `change` — the value-binding event (value:{prop:'value', event:'change'} contract).
|
|
914
|
+
* · `select` — the selection event (detail = ISO string; usable for type=date overlay wiring).
|
|
915
|
+
* · Enter already called preventDefault() in #handleGridKey, so a calendar-in-an-overlay
|
|
916
|
+
* does not re-trigger the anchor button (the ADR-0045 / ADR-0048 commit semantics).
|
|
917
|
+
*/
|
|
918
|
+
#commitDate(y, m, d) {
|
|
919
|
+
const iso = dateStr(y, m, d);
|
|
920
|
+
if (this.mode === 'range') {
|
|
921
|
+
this.#commitRangeDate(iso);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const changed = iso !== this.value;
|
|
925
|
+
this.value = iso;
|
|
926
|
+
if (changed) {
|
|
927
|
+
this.emit('change');
|
|
928
|
+
this.emit('select', iso);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Range-mode commit (ADR-0093 clause 3 — swap-complete, single grid, two picks):
|
|
933
|
+
* · idle (both '') OR complete (both set, any order) → begin a FRESH anchor: valueStart=iso,
|
|
934
|
+
* valueEnd=''. A pick on an already-complete range starts a new one, discarding the old pair.
|
|
935
|
+
* · selecting-end (valueStart set, valueEnd '') → the SECOND commit ALWAYS completes as
|
|
936
|
+
* `[min,max]` of {iso, pendingStart} — a pick earlier than the pending start SWAP-completes
|
|
937
|
+
* (Kim's F2 ruling) via `normalizeRange` (lexicographic ISO compare, zero Date construction;
|
|
938
|
+
* the SAME function the preview uses, so the committed band is provably identical to what
|
|
939
|
+
* was previewed). Same-day second pick ⇒ lo===hi, a valid single-day range.
|
|
940
|
+
*
|
|
941
|
+
* `select` (detail = the raw picked iso) fires on EVERY commit; `change` fires ONLY on
|
|
942
|
+
* completion (mirrors the single-date `value` two-way-binding contract — clause 2). NEVER
|
|
943
|
+
* writes `this.value` — mode-inert by construction (clause 1).
|
|
944
|
+
*/
|
|
945
|
+
#commitRangeDate(iso) {
|
|
946
|
+
const start = this.valueStart;
|
|
947
|
+
const end = this.valueEnd;
|
|
948
|
+
const isComplete = start !== '' && end !== '';
|
|
949
|
+
if (start === '' || isComplete) {
|
|
950
|
+
this.valueStart = iso;
|
|
951
|
+
this.valueEnd = '';
|
|
952
|
+
this.#previewIso = iso;
|
|
953
|
+
this.emit('select', iso);
|
|
954
|
+
this.#announce('Start date set — choose an end date.');
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
const [lo, hi] = normalizeRange(iso, start);
|
|
958
|
+
this.valueStart = lo;
|
|
959
|
+
this.valueEnd = hi;
|
|
960
|
+
this.#previewIso = '';
|
|
961
|
+
this.emit('select', iso);
|
|
962
|
+
this.emit('change');
|
|
963
|
+
// Completion announcement names BOTH dates in order — a swapped completion is audible as the
|
|
964
|
+
// resulting (already-normalized) range (ADR-0093 clause 3).
|
|
965
|
+
this.#announce(`${formatIsoLong(lo)} to ${formatIsoLong(hi)} selected.`);
|
|
966
|
+
}
|
|
967
|
+
/** Write to the visually-hidden aria-live status region (ADR-0093 clause 3; range mode only —
|
|
968
|
+
* single mode's #commitDate path never calls this, so the region stays silent there). */
|
|
969
|
+
#announce(text) {
|
|
970
|
+
if (this.#statusEl)
|
|
971
|
+
this.#statusEl.textContent = text;
|
|
972
|
+
}
|
|
973
|
+
// ── Private helpers ───────────────────────────────────────────────────────────────────────
|
|
974
|
+
/** Today's local date (y/m/d integers). New Date() uses local timezone — correct for a date calendar. */
|
|
975
|
+
#today() {
|
|
976
|
+
const t = new Date();
|
|
977
|
+
return { y: t.getFullYear(), m: t.getMonth() + 1, d: t.getDate() };
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Compute the ISO date that should hold tabindex=0. Priority (spec §Grid-decision):
|
|
981
|
+
* 1. #focusIso cursor, if it refers to a date in the currently displayed month.
|
|
982
|
+
* 2. The selected value, if it is in the currently displayed month.
|
|
983
|
+
* 3. Today, if today is in the currently displayed month.
|
|
984
|
+
* 4. The first day of the displayed month (fallback).
|
|
985
|
+
*/
|
|
986
|
+
#computeFocusTarget() {
|
|
987
|
+
const y = this.#displayYear;
|
|
988
|
+
const m = this.#displayMonth;
|
|
989
|
+
const fp = parseDateStr(this.#focusIso);
|
|
990
|
+
if (fp && fp.y === y && fp.m === m)
|
|
991
|
+
return this.#focusIso;
|
|
992
|
+
if (this.value !== '') {
|
|
993
|
+
const vp = parseDateStr(this.value);
|
|
994
|
+
if (vp && vp.y === y && vp.m === m)
|
|
995
|
+
return this.value;
|
|
996
|
+
}
|
|
997
|
+
const today = this.#today();
|
|
998
|
+
if (today.y === y && today.m === m)
|
|
999
|
+
return dateStr(today.y, today.m, today.d);
|
|
1000
|
+
return dateStr(y, m, 1);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
if (!customElements.get('ui-calendar'))
|
|
1004
|
+
customElements.define('ui-calendar', UICalendarElement);
|