ajo-ui 0.1.0

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.
Files changed (103) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +126 -0
  3. package/dist/accordion.js +130 -0
  4. package/dist/avatar.js +54 -0
  5. package/dist/calendar.js +2 -0
  6. package/dist/carousel.js +239 -0
  7. package/dist/chart.js +790 -0
  8. package/dist/checkbox-group.js +70 -0
  9. package/dist/checkbox.js +77 -0
  10. package/dist/chunks/bar-CVafh6C1.js +99 -0
  11. package/dist/chunks/calendar-q8jZ8Cxr.js +1923 -0
  12. package/dist/chunks/collection-DtRB63U4.js +111 -0
  13. package/dist/chunks/data-table-DpkWv4y4.js +1039 -0
  14. package/dist/chunks/menu-B45IyHHC.js +704 -0
  15. package/dist/chunks/native-BJdhd9XJ.js +20 -0
  16. package/dist/chunks/popup-C8Bb3l_g.js +459 -0
  17. package/dist/chunks/popup-surface-BDCgtVU0.js +18 -0
  18. package/dist/chunks/position-D6_i_SRn.js +434 -0
  19. package/dist/chunks/virtual-list-B7hjGkjk.js +413 -0
  20. package/dist/collapsible.js +106 -0
  21. package/dist/command.js +263 -0
  22. package/dist/context-menu.js +112 -0
  23. package/dist/data-table.js +5 -0
  24. package/dist/dialog.js +207 -0
  25. package/dist/direction.js +22 -0
  26. package/dist/drawer.js +139 -0
  27. package/dist/field.js +26 -0
  28. package/dist/index.js +38 -0
  29. package/dist/input-date.js +994 -0
  30. package/dist/input-group.js +52 -0
  31. package/dist/input-otp.js +179 -0
  32. package/dist/menu.js +2 -0
  33. package/dist/menubar.js +236 -0
  34. package/dist/message-scroller.js +446 -0
  35. package/dist/navigation-menu.js +330 -0
  36. package/dist/popover.js +307 -0
  37. package/dist/progress.js +39 -0
  38. package/dist/radio-group.js +107 -0
  39. package/dist/resizable.js +172 -0
  40. package/dist/select.js +961 -0
  41. package/dist/sidebar.js +343 -0
  42. package/dist/slider.js +259 -0
  43. package/dist/switch.js +53 -0
  44. package/dist/tabs.js +182 -0
  45. package/dist/toast.js +492 -0
  46. package/dist/toggle-group.js +111 -0
  47. package/dist/toggle.js +52 -0
  48. package/dist/toolbar.js +127 -0
  49. package/dist/tooltip.js +196 -0
  50. package/dist/utils.js +104 -0
  51. package/dist/virtual-list.js +2 -0
  52. package/package.json +250 -0
  53. package/src/accordion.tsx +261 -0
  54. package/src/availability.ts +261 -0
  55. package/src/avatar.tsx +99 -0
  56. package/src/bar.ts +156 -0
  57. package/src/calendar.tsx +1441 -0
  58. package/src/carousel.tsx +424 -0
  59. package/src/chart.tsx +1194 -0
  60. package/src/checkbox-group.tsx +132 -0
  61. package/src/checkbox.tsx +130 -0
  62. package/src/collapsible.tsx +188 -0
  63. package/src/collection.ts +154 -0
  64. package/src/command.tsx +511 -0
  65. package/src/context-menu.tsx +233 -0
  66. package/src/data-table-contract.ts +143 -0
  67. package/src/data-table-model.ts +760 -0
  68. package/src/data-table.tsx +475 -0
  69. package/src/dialog.tsx +393 -0
  70. package/src/direction.tsx +45 -0
  71. package/src/drawer.tsx +251 -0
  72. package/src/field.tsx +61 -0
  73. package/src/index.ts +37 -0
  74. package/src/input-date.tsx +1539 -0
  75. package/src/input-group.tsx +142 -0
  76. package/src/input-otp.tsx +324 -0
  77. package/src/menu-cluster.ts +124 -0
  78. package/src/menu.tsx +1095 -0
  79. package/src/menubar.tsx +459 -0
  80. package/src/message-scroller.tsx +732 -0
  81. package/src/native.ts +26 -0
  82. package/src/navigation-menu.tsx +578 -0
  83. package/src/popover.tsx +519 -0
  84. package/src/popup-surface.tsx +31 -0
  85. package/src/popup.ts +569 -0
  86. package/src/position.ts +523 -0
  87. package/src/progress.tsx +70 -0
  88. package/src/radio-group.tsx +186 -0
  89. package/src/resizable.tsx +310 -0
  90. package/src/segments.ts +922 -0
  91. package/src/select.tsx +1501 -0
  92. package/src/sidebar.tsx +683 -0
  93. package/src/slider.tsx +424 -0
  94. package/src/switch.tsx +104 -0
  95. package/src/tabs.tsx +314 -0
  96. package/src/toast.tsx +923 -0
  97. package/src/toggle-group.tsx +249 -0
  98. package/src/toggle.tsx +91 -0
  99. package/src/toolbar.tsx +212 -0
  100. package/src/tooltip.tsx +359 -0
  101. package/src/utils.ts +204 -0
  102. package/src/virtual-list.tsx +205 -0
  103. package/src/virtual.ts +385 -0
@@ -0,0 +1,922 @@
1
+ /**
2
+ * segments.ts — the pure engine behind the InputDate family (ai/ui.md).
3
+ *
4
+ * No JSX, no DOM; Intl is the only platform dependency. `input-date.tsx`
5
+ * owns rendering, focus, and events, and consumes this surface:
6
+ *
7
+ * - `derive(opts)` — fixed-probe Intl derivation → ordered `Segment`
8
+ * descriptors (editable units + verbatim literals), resolved hour cycle,
9
+ * dayPeriod display strings, and localized month names.
10
+ * - `fromISO` / `toISO` — strict parse/serialize of the native normalized
11
+ * forms (zero-padded, no milliseconds, no 24:00); year 1–9999.
12
+ * - `typeDigit` / `matchName` / `eraseDigit` / `stepValue` — one pure
13
+ * segment mutation each (digit buffer, name prefix match, backspace,
14
+ * spin protocol).
15
+ * - `validate` / `defaultMessage` / `constrain` / `isReversed` — reason-coded
16
+ * validation with localized default messages, the day clamp, and the range
17
+ * cross-check (`reversed` is a consumer-raised reason: validate never
18
+ * returns it).
19
+ * - `formatValue` — human formatting of a committed value (message bounds,
20
+ * the field's hidden value description).
21
+ * - `inferGranularity` — value → defaultValue → placeholderValue →
22
+ * granularity arg → 'minute'.
23
+ * - `reconciler()` — lastEmitted/lastObserved external-change detection, so
24
+ * echoes of self-emitted values never clobber edits.
25
+ * - `field(options)` — the stateful editing record wiring all of the above:
26
+ * independently nullable units (hour kept in the display cycle, dayPeriod
27
+ * its own 0/1 unit), the typing buffer, and the eager-constrain commit
28
+ * pipeline. Every mutation returns an `InputResult` telling the consumer
29
+ * whether to move focus and what to emit; `emit` is `undefined` when the
30
+ * public value did not change.
31
+ */
32
+
33
+ // Types:
34
+
35
+ import { clamp, remember } from 'ajo-cloves'
36
+
37
+ export type SegmentsKind = 'date' | 'datetime' | 'time'
38
+
39
+ export type Granularity = 'day' | 'minute' | 'second'
40
+
41
+ export type HourCycle = 'h12' | 'h23'
42
+
43
+ export type SegmentUnit = 'day' | 'dayPeriod' | 'hour' | 'minute' | 'month' | 'second' | 'year'
44
+
45
+ export type Segment = {
46
+ /** Editable unit name, or 'literal' for verbatim passthrough (separators, era). */
47
+ type: SegmentUnit | 'literal'
48
+ /** Literal text; empty on editable segments. */
49
+ text: string
50
+ editable: boolean
51
+ /** Shown while the unit is empty; native-input vocabulary. */
52
+ placeholder: string
53
+ /** Static bounds; the day max is the static 31 — `field.bounds` carries the dynamic cap. */
54
+ min: number
55
+ max: number
56
+ /** Maximum digits the unit accepts (auto-advance limit); 0 = non-numeric. */
57
+ digits: number
58
+ /** Zero-pad width, from the probe's formatted length. */
59
+ width: number
60
+ }
61
+
62
+ export type Units = {
63
+ year: number | null
64
+ month: number | null
65
+ day: number | null
66
+ /** Kept in the display cycle: 1–12 under h12 (with dayPeriod), 0–23 under h23. */
67
+ hour: number | null
68
+ minute: number | null
69
+ second: number | null
70
+ /** 0 = AM, 1 = PM; null under h23. */
71
+ dayPeriod: number | null
72
+ }
73
+
74
+ export type Derivation = {
75
+ segments: Segment[]
76
+ /** Editable units actually rendered, in display order. */
77
+ units: SegmentUnit[]
78
+ hourCycle: HourCycle
79
+ /** [AM, PM] display strings, derived by formatting hours 0 and 12. */
80
+ periods: [string, string]
81
+ /** Localized long month names, January first; empty for time fields. */
82
+ monthNames: string[]
83
+ withSeconds: boolean
84
+ }
85
+
86
+ export type DeriveOptions = {
87
+ kind: SegmentsKind
88
+ locale: string
89
+ granularity?: Granularity
90
+ /** Overrides the locale's resolved cycle. */
91
+ hourCycle?: 12 | 24
92
+ }
93
+
94
+ export type Reason =
95
+ | { code: 'impossible' }
96
+ | { code: 'incomplete'; unit: SegmentUnit }
97
+ | { code: 'rangeOverflow'; max: string }
98
+ | { code: 'rangeUnderflow'; min: string }
99
+ | { code: 'reversed' }
100
+ | { code: 'unavailable' }
101
+ | { code: 'unavailableRange' }
102
+
103
+ export type SpinAction = { step: 1 | -1 } | { page: 1 | -1 } | { edge: 'max' | 'min' }
104
+
105
+ export type InputResult = {
106
+ /** The key produced a mutation: consumer preventDefaults and invalidates. */
107
+ handled: boolean
108
+ /** Move focus to the next editable segment. */
109
+ advance?: boolean
110
+ /** Move focus to the previous editable segment (backspace on an empty segment). */
111
+ retreat?: boolean
112
+ /** Commit decision: undefined = no emission; string or null = call onValueChange. */
113
+ emit?: string | null
114
+ }
115
+
116
+ // Derivation:
117
+
118
+ // Fixed probe with single-digit units: distinguishes numeric from 2-digit
119
+ // widths and keeps SSR/client output identical across midnight. 2224 is a
120
+ // leap year, so Feb 29 stays reachable while the year is unknown.
121
+ const PROBE_YEAR = 2224
122
+ const PROBE = new Date(PROBE_YEAR, 2, 4, 5, 6, 7)
123
+
124
+ const EDITABLE: readonly SegmentUnit[] = ['year', 'month', 'day', 'hour', 'minute', 'second', 'dayPeriod']
125
+
126
+ // Native-input placeholder vocabulary [year, month, day]; English fallback.
127
+ const VOCABULARY: Record<string, [string, string, string]> = {
128
+ de: ['jjjj', 'mm', 'tt'],
129
+ en: ['yyyy', 'mm', 'dd'],
130
+ es: ['aaaa', 'mm', 'dd'],
131
+ fr: ['aaaa', 'mm', 'jj'],
132
+ ja: ['年', '月', '日'],
133
+ }
134
+
135
+ const TIME_PLACEHOLDER = '––'
136
+
137
+ const boundsOf = (unit: SegmentUnit, hourCycle: HourCycle): [number, number] => {
138
+ switch (unit) {
139
+ case 'year': return [1, 9999]
140
+ case 'month': return [1, 12]
141
+ case 'day': return [1, 31]
142
+ case 'hour': return hourCycle === 'h12' ? [1, 12] : [0, 23]
143
+ case 'dayPeriod': return [0, 1]
144
+ default: return [0, 59]
145
+ }
146
+ }
147
+
148
+ // h11 renders "0:00 AM" and h24 "24:00": clamp to the two sane cycles.
149
+ const resolveHourCycle = (locale: string, override?: 12 | 24): HourCycle => {
150
+ if (override) return override === 12 ? 'h12' : 'h23'
151
+ const cycle = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hourCycle
152
+ return cycle === 'h11' || cycle === 'h12' ? 'h12' : 'h23'
153
+ }
154
+
155
+ /** Derives ordered segment descriptors for a locale via a fixed-probe formatToParts. */
156
+ export const derive = (opts: DeriveOptions): Derivation => {
157
+ const { kind, locale } = opts
158
+ const hourCycle = kind === 'date' ? 'h23' : resolveHourCycle(locale, opts.hourCycle)
159
+ const withSeconds = kind !== 'date' && opts.granularity === 'second'
160
+
161
+ let format: Intl.DateTimeFormatOptions | undefined
162
+ if (kind !== 'date') {
163
+ format = { hour: 'numeric', minute: 'numeric', hourCycle }
164
+ if (withSeconds) format.second = 'numeric'
165
+ if (kind === 'datetime') Object.assign(format, { year: 'numeric', month: 'numeric', day: 'numeric' })
166
+ }
167
+
168
+ const language = locale.split('-')[0].toLowerCase()
169
+ const vocabulary = VOCABULARY[language] ?? VOCABULARY.en
170
+ const placeholderOf = (unit: SegmentUnit) =>
171
+ unit === 'year' ? vocabulary[0] : unit === 'month' ? vocabulary[1] : unit === 'day' ? vocabulary[2] : TIME_PLACEHOLDER
172
+
173
+ const segments: Segment[] = []
174
+ for (const part of new Intl.DateTimeFormat(locale, format).formatToParts(PROBE)) {
175
+ if ((EDITABLE as readonly string[]).includes(part.type)) {
176
+ const unit = part.type as SegmentUnit
177
+ const [min, max] = boundsOf(unit, hourCycle)
178
+ segments.push({
179
+ type: unit,
180
+ text: '',
181
+ editable: true,
182
+ placeholder: placeholderOf(unit),
183
+ min,
184
+ max,
185
+ digits: unit === 'dayPeriod' ? 0 : String(max).length,
186
+ width: part.value.length,
187
+ })
188
+ } else {
189
+ // Passthrough: unmapped part types (era, yearName, …) render verbatim.
190
+ segments.push({ type: 'literal', text: part.value, editable: false, placeholder: '', min: 0, max: 0, digits: 0, width: part.value.length })
191
+ }
192
+ }
193
+
194
+ // The dayPeriod OPTION is a trap (flexible day periods, broken Safari):
195
+ // derive the two strings by formatting hours 0 and 12.
196
+ let periods: [string, string] = ['', '']
197
+ if (kind !== 'date' && hourCycle === 'h12') {
198
+ const formatter = new Intl.DateTimeFormat(locale, { hour: 'numeric', hourCycle: 'h12' })
199
+ const periodOf = (hour: number) =>
200
+ formatter.formatToParts(new Date(PROBE_YEAR, 2, 4, hour)).find(part => part.type === 'dayPeriod')?.value ?? (hour ? 'PM' : 'AM')
201
+ periods = [periodOf(0), periodOf(12)]
202
+ }
203
+
204
+ let monthNames: string[] = []
205
+ if (kind !== 'time') {
206
+ const formatter = new Intl.DateTimeFormat(locale, { month: 'long' })
207
+ monthNames = Array.from({ length: 12 }, (_, month) => formatter.format(new Date(PROBE_YEAR, month, 4, 12)))
208
+ }
209
+
210
+ return {
211
+ segments,
212
+ units: segments.filter(segment => segment.editable).map(segment => segment.type as SegmentUnit),
213
+ hourCycle,
214
+ periods,
215
+ monthNames,
216
+ withSeconds,
217
+ }
218
+ }
219
+
220
+ const TIME_UNITS = new Set<Segment['type']>(['dayPeriod', 'hour', 'minute', 'second'])
221
+
222
+ /** Returns the contiguous time portion of a derived segment sequence. */
223
+ export const timeRun = (segments: readonly Segment[]): Segment[] => {
224
+ let first = -1
225
+ let last = -1
226
+
227
+ for (let index = 0; index < segments.length; index++) {
228
+ if (!TIME_UNITS.has(segments[index]!.type)) continue
229
+ if (first < 0) first = index
230
+ last = index
231
+ }
232
+
233
+ return first < 0 ? [] : segments.slice(first, last + 1)
234
+ }
235
+
236
+ // ISO value model:
237
+
238
+ const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/
239
+ const TIME_RE = /^(\d{2}):(\d{2})(?::(\d{2}))?$/
240
+
241
+ /** Month length; unknown month falls back to 31, unknown year keeps Feb 29 typable. */
242
+ export const daysInMonth = (year: number | null, month: number | null): number => {
243
+ if (month == null) return 31
244
+ const date = new Date(PROBE_YEAR, month, 0)
245
+ // setFullYear, not the constructor: years 0–99 must not map to 1900+.
246
+ if (year != null) date.setFullYear(year, month, 0)
247
+ return date.getDate()
248
+ }
249
+
250
+ const parseDate = (text: string) => {
251
+ const match = DATE_RE.exec(text)
252
+ if (!match) return null
253
+ const year = Number(match[1])
254
+ const month = Number(match[2])
255
+ const day = Number(match[3])
256
+ if (year < 1 || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return null
257
+ return { year, month, day }
258
+ }
259
+
260
+ const parseTime = (text: string) => {
261
+ const match = TIME_RE.exec(text)
262
+ if (!match) return null
263
+ const hour = Number(match[1])
264
+ const minute = Number(match[2])
265
+ const second = match[3] == null ? null : Number(match[3])
266
+ if (hour > 23 || minute > 59 || (second != null && second > 59)) return null
267
+ return { hour, minute, second }
268
+ }
269
+
270
+ /** A fresh editing record: every unit independently empty. */
271
+ export const emptyUnits = (): Units => ({ year: null, month: null, day: null, hour: null, minute: null, second: null, dayPeriod: null })
272
+
273
+ /** Strict parse of the native normalized forms into the editing record; null on anything else. */
274
+ export const fromISO = (kind: SegmentsKind, value: string, hourCycle: HourCycle = 'h23'): Units | null => {
275
+ let date: ReturnType<typeof parseDate> = null
276
+ let time: ReturnType<typeof parseTime> = null
277
+
278
+ if (kind === 'date') {
279
+ date = parseDate(value)
280
+ if (!date) return null
281
+ } else if (kind === 'time') {
282
+ time = parseTime(value)
283
+ if (!time) return null
284
+ } else {
285
+ const at = value.indexOf('T')
286
+ if (at < 0) return null
287
+ date = parseDate(value.slice(0, at))
288
+ time = parseTime(value.slice(at + 1))
289
+ if (!date || !time) return null
290
+ }
291
+
292
+ const units = emptyUnits()
293
+ if (date) Object.assign(units, date)
294
+ if (time) {
295
+ units.minute = time.minute
296
+ units.second = time.second
297
+ if (hourCycle === 'h12') {
298
+ units.dayPeriod = time.hour < 12 ? 0 : 1
299
+ units.hour = time.hour % 12 || 12
300
+ } else {
301
+ units.hour = time.hour
302
+ }
303
+ }
304
+ return units
305
+ }
306
+
307
+ const pad = (value: number, width: number) => String(value).padStart(width, '0')
308
+
309
+ // NaN is as missing as null: a serialized NaN would commit as a real value.
310
+ const filled = (value: number | null): value is number => value != null && !Number.isNaN(value)
311
+
312
+ /** Serializes complete units to the family's normalized form; null while a required unit is missing. */
313
+ export const toISO = (kind: SegmentsKind, units: Units, opts: { hourCycle?: HourCycle; seconds?: boolean } = {}): string | null => {
314
+ const parts: string[] = []
315
+ if (kind !== 'time') {
316
+ if (!filled(units.year) || !filled(units.month) || !filled(units.day)) return null
317
+ parts.push(`${pad(units.year, 4)}-${pad(units.month, 2)}-${pad(units.day, 2)}`)
318
+ }
319
+ if (kind !== 'date') {
320
+ if (!filled(units.hour) || !filled(units.minute)) return null
321
+ if (opts.seconds && !filled(units.second)) return null
322
+ const h12 = (opts.hourCycle ?? 'h23') === 'h12'
323
+ if (h12 && !filled(units.dayPeriod)) return null
324
+ const hour = h12
325
+ ? units.dayPeriod === 1 ? (units.hour === 12 ? 12 : units.hour + 12) : units.hour % 12
326
+ : units.hour
327
+ parts.push(`${pad(hour, 2)}:${pad(units.minute, 2)}${opts.seconds ? `:${pad(units.second!, 2)}` : ''}`)
328
+ }
329
+ return parts.join('T')
330
+ }
331
+
332
+ // Typing:
333
+
334
+ export type TypeState = {
335
+ /** New unit value; null = leave the unit untouched (a buffered zero). */
336
+ value: number | null
337
+ buffer: string
338
+ /** No further digit could fit: focus moves on. */
339
+ advance: boolean
340
+ }
341
+
342
+ /** Digit protocol: the buffer accumulates, overflow restarts from the last key, full advances. */
343
+ export const typeDigit = (buffer: string, key: string, min: number, max: number, digits: number): TypeState => {
344
+ const typed = buffer + key
345
+ let value = Number(typed)
346
+ if (Number.isNaN(value) || value > max) value = Number(key)
347
+ // A zero only sets units whose range starts at 0; otherwise it waits in the buffer.
348
+ const set = value !== 0 || min === 0
349
+ const full = Number(typed) * 10 > max || typed.length >= digits
350
+ return {
351
+ value: set ? value : null,
352
+ buffer: full ? '' : typed,
353
+ advance: full && set,
354
+ }
355
+ }
356
+
357
+ export type NameMatch = {
358
+ index: number
359
+ buffer: string
360
+ /** The prefix identifies exactly one name: focus can move on. */
361
+ unique: boolean
362
+ }
363
+
364
+ const nameCollators = new Map<string, Intl.Collator>()
365
+
366
+ /** Prefix-matches locale names (Collator sensitivity 'base'); a repeated first letter cycles its matches. */
367
+ export const matchName = (locale: string, names: string[], current: number | null, buffer: string, key: string): NameMatch | null => {
368
+ let collator = nameCollators.get(locale)
369
+ if (!collator) {
370
+ collator = new Intl.Collator(locale, { sensitivity: 'base', usage: 'search' })
371
+ remember(nameCollators, locale, collator)
372
+ }
373
+ const starts = (name: string, query: string) => collator.compare(name.slice(0, query.length), query) === 0
374
+
375
+ for (const query of buffer ? [buffer + key, key] : [key]) {
376
+ const matches: number[] = []
377
+ for (let index = 0; index < names.length; index++) {
378
+ if (starts(names[index], query)) matches.push(index)
379
+ }
380
+ if (!matches.length) continue
381
+ const cycle = query.length === 1 && current != null && matches.includes(current)
382
+ return {
383
+ index: cycle ? matches[(matches.indexOf(current!) + 1) % matches.length] : matches[0],
384
+ buffer: query,
385
+ unique: matches.length === 1,
386
+ }
387
+ }
388
+ return null
389
+ }
390
+
391
+ // Stepping:
392
+
393
+ // Rounded page grids (react-aria parity; hour rides its 2-step).
394
+ const PAGE_STEP: Record<SegmentUnit, number> = { year: 5, month: 2, day: 7, hour: 2, minute: 15, second: 15, dayPeriod: 1 }
395
+
396
+ const cycle = (value: number, amount: number, min: number, max: number, round: boolean): number => {
397
+ if (!round) {
398
+ value += amount
399
+ return value < min ? max - (min - value - 1) : value > max ? min + (value - max - 1) : value
400
+ }
401
+ value += Math.sign(amount)
402
+ if (value < min) value = max
403
+ const grid = Math.abs(amount)
404
+ value = amount > 0 ? Math.ceil(value / grid) * grid : Math.floor(value / grid) * grid
405
+ if (value > max) value = min
406
+ return clamp(value, min, max)
407
+ }
408
+
409
+ /** Spin protocol: ±1 wraps at the bounds without carrying, pages round to their grid, edges land on min/max. */
410
+ export const stepValue = (unit: SegmentUnit, value: number, action: SpinAction, min: number, max: number, minuteStep = 1): number => {
411
+ if ('edge' in action) return action.edge === 'min' ? min : max
412
+ if ('page' in action) return cycle(value, PAGE_STEP[unit] * action.page, min, max, true)
413
+ const step = unit === 'minute' ? minuteStep : 1
414
+ return cycle(value, action.step * step, min, max, step > 1)
415
+ }
416
+
417
+ // Deletion:
418
+
419
+ export type EraseState = {
420
+ value: number | null
421
+ buffer: string
422
+ /** The segment was already empty: focus moves to the previous segment. */
423
+ retreat: boolean
424
+ }
425
+
426
+ /** Backspace semantics: strip the last digit, empty shows the placeholder, empty again retreats. */
427
+ export const eraseDigit = (value: number | null, buffer: string): EraseState => {
428
+ const text = buffer || (value == null ? '' : String(value))
429
+ if (!text) return { value: null, buffer: '', retreat: true }
430
+ const stripped = text.slice(0, -1)
431
+ const parsed = Number(stripped)
432
+ // A non-numeric remainder (a letter buffer that fell through) clears:
433
+ // NaN must never reach the unit record.
434
+ if (Number.isNaN(parsed)) return { value: null, buffer: '', retreat: false }
435
+ return { value: stripped && parsed !== 0 ? parsed : null, buffer: stripped, retreat: false }
436
+ }
437
+
438
+ // Validation:
439
+
440
+ export type ValidateOptions = {
441
+ min?: string
442
+ max?: string
443
+ unavailable?: (value: string) => boolean
444
+ hourCycle?: HourCycle
445
+ seconds?: boolean
446
+ }
447
+
448
+ /** Reason-coded validation; null = valid. An all-empty record is valid — required is the consumer's. */
449
+ export const validate = (kind: SegmentsKind, units: Units, order: SegmentUnit[], opts: ValidateOptions = {}): Reason | null => {
450
+ if (order.every(unit => units[unit] == null)) return null
451
+ for (const unit of order) {
452
+ if (units[unit] == null) return { code: 'incomplete', unit }
453
+ }
454
+ if (units.day != null && units.day > daysInMonth(units.year, units.month)) return { code: 'impossible' }
455
+ const value = toISO(kind, units, { hourCycle: opts.hourCycle, seconds: opts.seconds })
456
+ if (value == null) return { code: 'impossible' }
457
+ if (opts.min != null && value < opts.min) return { code: 'rangeUnderflow', min: opts.min }
458
+ if (opts.max != null && value > opts.max) return { code: 'rangeOverflow', max: opts.max }
459
+ if (opts.unavailable?.(value)) return { code: 'unavailable' }
460
+ return null
461
+ }
462
+
463
+ /** Eager constrain: clamps the day into the real month length, in place. */
464
+ export const constrain = (units: Units): Units => {
465
+ if (units.day != null) units.day = Math.min(units.day, daysInMonth(units.year, units.month))
466
+ return units
467
+ }
468
+
469
+ /** Range cross-check: from > to, in the family's own string format. */
470
+ export const isReversed = (from: string | null | undefined, to: string | null | undefined): boolean =>
471
+ from != null && to != null && from > to
472
+
473
+ export type MessageOptions = {
474
+ kind: SegmentsKind
475
+ locale: string
476
+ hourCycle?: HourCycle
477
+ }
478
+
479
+ const displayNames = new Map<string, Intl.DisplayNames>()
480
+
481
+ /** Localized date/time segment label, shared by field ARIA and validation messages. */
482
+ export const unitLabel = (locale: string, unit: SegmentUnit): string => {
483
+ let names = displayNames.get(locale)
484
+ if (!names) {
485
+ names = new Intl.DisplayNames([locale], { type: 'dateTimeField' })
486
+ remember(displayNames, locale, names)
487
+ }
488
+ return names.of(unit) ?? unit
489
+ }
490
+
491
+ const valueFormatters = new Map<string, Intl.DateTimeFormat>()
492
+
493
+ const valueFormatter = (opts: MessageOptions, seconds: boolean) => {
494
+ const key = [opts.locale, opts.kind, seconds ? 'second' : '', opts.kind === 'date' ? '' : opts.hourCycle ?? ''].join('\0')
495
+ let formatter = valueFormatters.get(key)
496
+ if (!formatter) {
497
+ const format: Intl.DateTimeFormatOptions = {}
498
+ if (opts.kind !== 'time') format.dateStyle = 'medium'
499
+ if (opts.kind !== 'date') {
500
+ format.timeStyle = seconds ? 'medium' : 'short'
501
+ if (opts.hourCycle) format.hourCycle = opts.hourCycle
502
+ }
503
+ formatter = new Intl.DateTimeFormat(opts.locale, format)
504
+ remember(valueFormatters, key, formatter)
505
+ }
506
+ return formatter
507
+ }
508
+
509
+ /** Formats a committed value for humans: message bounds, the hidden value description. */
510
+ export const formatValue = (value: string, opts: MessageOptions): string => {
511
+ const units = fromISO(opts.kind, value)
512
+ if (!units) return value
513
+ const date = new Date(PROBE_YEAR, (units.month ?? 3) - 1, units.day ?? 4, units.hour ?? 12, units.minute ?? 0, units.second ?? 0)
514
+ date.setFullYear(units.year ?? PROBE_YEAR)
515
+ return valueFormatter(opts, units.second != null).format(date)
516
+ }
517
+
518
+ /** Default message builders (GOV.UK hierarchy): localized unit names and bounds, overridable per field. */
519
+ export const defaultMessage = (reason: Reason, opts: MessageOptions): string => {
520
+ switch (reason.code) {
521
+ case 'incomplete': {
522
+ return `Must include a ${unitLabel(opts.locale, reason.unit)}`
523
+ }
524
+ case 'impossible':
525
+ return opts.kind === 'time' ? 'Must be a real time' : 'Must be a real date'
526
+ case 'rangeUnderflow':
527
+ return `Must be ${formatValue(reason.min, opts)} or later`
528
+ case 'rangeOverflow':
529
+ return `Must be ${formatValue(reason.max, opts)} or earlier`
530
+ case 'reversed':
531
+ return opts.kind === 'time' ? 'Start time must be before end time' : 'Start date must be before end date'
532
+ case 'unavailable':
533
+ return opts.kind === 'time' ? 'This time is unavailable' : 'This date is unavailable'
534
+ case 'unavailableRange':
535
+ return 'Range includes unavailable dates'
536
+ }
537
+ }
538
+
539
+ // Reconciliation:
540
+
541
+ export type Reconciler = {
542
+ /** True when the synced value is an external change: re-derive units, drop the buffer. */
543
+ observe(value: string | null | undefined): boolean
544
+ /** Records an emission so the matching echo never re-derives. */
545
+ emit(value: string | null): void
546
+ }
547
+
548
+ /** Controlled ↔ editing-state reconciliation: only genuinely external values clobber edits. */
549
+ export const reconciler = (): Reconciler => {
550
+ let lastEmitted: string | null | undefined
551
+ let lastObserved: string | null | undefined
552
+ return {
553
+ emit(value) {
554
+ // The owner is expected to hold this value now: an acceptance echo
555
+ // matches, a rejection or external push differs and re-derives.
556
+ lastEmitted = lastObserved = value
557
+ },
558
+ observe(value) {
559
+ if (value === undefined) return false
560
+ if (value === lastEmitted || value === lastObserved) {
561
+ lastObserved = value
562
+ return false
563
+ }
564
+ lastEmitted = lastObserved = value
565
+ return true
566
+ },
567
+ }
568
+ }
569
+
570
+ // Granularity:
571
+
572
+ export type InferOptions = {
573
+ value?: string | null
574
+ defaultValue?: string | null
575
+ placeholderValue?: string | null
576
+ granularity?: Granularity
577
+ }
578
+
579
+ /** value → defaultValue → placeholderValue → granularity arg → 'minute'; seconds in a value force the segment. */
580
+ export const inferGranularity = (kind: SegmentsKind, opts: InferOptions): Granularity => {
581
+ if (kind === 'date') return 'day'
582
+ for (const source of [opts.value, opts.defaultValue, opts.placeholderValue]) {
583
+ if (typeof source !== 'string') continue
584
+ const time = kind === 'time' ? source : source.indexOf('T') < 0 ? '' : source.slice(source.indexOf('T') + 1)
585
+ if (TIME_RE.test(time)) return time.length > 5 ? 'second' : 'minute'
586
+ }
587
+ return opts.granularity ?? 'minute'
588
+ }
589
+
590
+ // Field:
591
+
592
+ export type FieldOptions = {
593
+ kind: SegmentsKind
594
+ /** BCP 47 tag; the consumer resolves it (locale arg → <html lang> → 'en-US'). */
595
+ locale?: string
596
+ granularity?: Granularity
597
+ hourCycle?: 12 | 24
598
+ /** Minute arrow step (booking granularity); typing and PageUp/Down are unaffected. */
599
+ step?: number
600
+ /** Seeds the first spin on an empty segment; never emitted by itself. Default today/now. */
601
+ placeholderValue?: string
602
+ /** Uncontrolled initial value; what a form reset restores. */
603
+ defaultValue?: string
604
+ min?: string
605
+ max?: string
606
+ unavailable?: (value: string) => boolean
607
+ /** Reason-coded message override; undefined falls back to the localized default. */
608
+ errorMessage?: (reason: Reason) => string | undefined
609
+ }
610
+
611
+ export type FieldView = {
612
+ /** Ordered descriptors for rendering; rebuilt when locale or shape options change. */
613
+ readonly segments: Segment[]
614
+ /** The live editing record. */
615
+ readonly units: Units
616
+ readonly hourCycle: HourCycle
617
+ readonly periods: [string, string]
618
+ readonly monthNames: string[]
619
+ /** Per-render reconcile: re-derives on option changes, adopts external values only. */
620
+ sync(value: string | null | undefined, opts?: FieldOptions): void
621
+ /** Display text for one unit: typing buffer, else the padded value, else '' (placeholder). */
622
+ text(unit: SegmentUnit): string
623
+ /** Live bounds: the day max follows known month/year (Feb 28/29), 31 otherwise. */
624
+ bounds(unit: SegmentUnit): { min: number; max: number }
625
+ /** Digit or letter key on a segment. */
626
+ type(unit: SegmentUnit, key: string): InputResult
627
+ /** Backspace/Delete on a segment. */
628
+ erase(unit: SegmentUnit): InputResult
629
+ /** Spin protocol (±1, page, edge); an empty segment seeds from placeholderValue. */
630
+ spin(unit: SegmentUnit, action: SpinAction): InputResult
631
+ /** Seed units (placeholderValue, else now): what empty units adopt on first spin or a calendar merge. */
632
+ placeholder(): Units
633
+ /** Calendar pick / preset merge: assigns the given units through the commit pipeline. */
634
+ merge(next: Partial<Units>): InputResult
635
+ /** Empties every unit (clear button). */
636
+ clear(): InputResult
637
+ /** Restores defaultValue (form reset); never emits. */
638
+ reset(): void
639
+ /** Drops the typing buffer (segment blur). */
640
+ blur(): void
641
+ /** The field's current public value. */
642
+ value(): string | null
643
+ complete(): boolean
644
+ reason(): Reason | null
645
+ /** Message for the current reason; null while valid. */
646
+ message(): string | null
647
+ }
648
+
649
+ // Hour is stored in the display cycle, so a cycle flip converts in place.
650
+ const convertHour = (units: Units, hourCycle: HourCycle) => {
651
+ if (units.hour == null) {
652
+ units.dayPeriod = null
653
+ return
654
+ }
655
+ if (hourCycle === 'h12') {
656
+ units.dayPeriod = units.hour < 12 ? 0 : 1
657
+ units.hour = units.hour % 12 || 12
658
+ } else {
659
+ units.hour = units.dayPeriod === 1 ? (units.hour === 12 ? 12 : units.hour + 12) : units.hour % 12
660
+ units.dayPeriod = null
661
+ }
662
+ }
663
+
664
+ /** The stateful editing record consumed by the InputDate family; one per field side. */
665
+ export const field = (options: FieldOptions): FieldView => {
666
+ const units = emptyUnits()
667
+ const rec = reconciler()
668
+ let opts = options
669
+ let derivation!: Derivation
670
+ let buffer = ''
671
+ let bufferUnit: SegmentUnit | null = null
672
+ let committed: string | null = null
673
+ let cacheKey = ''
674
+ let granularity: Granularity
675
+
676
+ const locale = () => opts.locale ?? 'en-US'
677
+
678
+ const inferFor = (next: FieldOptions, value?: string | null) => inferGranularity(next.kind, {
679
+ value,
680
+ defaultValue: next.defaultValue,
681
+ placeholderValue: next.placeholderValue,
682
+ granularity: next.granularity,
683
+ })
684
+
685
+ // The inference inputs the consumer controls; a changed key legitimizes re-inference.
686
+ const inferKey = (next: FieldOptions) =>
687
+ [next.granularity ?? '', next.defaultValue ?? '', next.placeholderValue ?? ''].join('|')
688
+
689
+ const configure = (next: FieldOptions) => {
690
+ opts = next
691
+ const key = [next.kind, locale(), granularity, next.hourCycle ?? 0].join('|')
692
+ if (key === cacheKey) return
693
+ cacheKey = key
694
+ const previous = derivation?.hourCycle
695
+ derivation = derive({ kind: next.kind, locale: locale(), granularity, hourCycle: next.hourCycle })
696
+ if (previous && previous !== derivation.hourCycle) convertHour(units, derivation.hourCycle)
697
+ buffer = ''
698
+ bufferUnit = null
699
+ }
700
+
701
+ const adopt = (value: string | null) => {
702
+ const parsed = value == null ? null : fromISO(opts.kind, value, derivation.hourCycle)
703
+ if (value != null && !parsed) console.warn(`[segments] invalid ${opts.kind} value: "${value}"`)
704
+ Object.assign(units, parsed ?? emptyUnits())
705
+ buffer = ''
706
+ bufferUnit = null
707
+ committed = parsed ? value : null
708
+ }
709
+
710
+ granularity = inferFor(options)
711
+ configure(options)
712
+ if (options.defaultValue != null) adopt(options.defaultValue)
713
+ // Prime with the initial value so a controlled sync that merely agrees with
714
+ // the pristine state (a range echo syncing the untouched side) never clobbers.
715
+ rec.emit(committed)
716
+
717
+ const segmentOf = (unit: SegmentUnit) => derivation.segments.find(segment => segment.type === unit)
718
+
719
+ const boundsFor = (unit: SegmentUnit) => {
720
+ const segment = segmentOf(unit)
721
+ return {
722
+ min: segment?.min ?? 0,
723
+ max: unit === 'day' ? daysInMonth(units.year, units.month) : segment?.max ?? 0,
724
+ }
725
+ }
726
+
727
+ const complete = () => derivation.units.every(unit => units[unit] != null)
728
+
729
+ // Commit machinery: transient invalids stay representable while entering;
730
+ // the completing keystroke — and every mutation of a complete field —
731
+ // constrains eagerly, so screen, value, and hidden input never diverge.
732
+ // Complete → incomplete emits null. min/max/unavailable never block.
733
+ const resolve = (advance?: boolean, retreat?: boolean): InputResult => {
734
+ let emit: string | null | undefined
735
+ if (complete()) {
736
+ constrain(units)
737
+ const value = toISO(opts.kind, units, { hourCycle: derivation.hourCycle, seconds: derivation.withSeconds })
738
+ if (value != null && value !== committed) {
739
+ committed = value
740
+ rec.emit(value)
741
+ emit = value
742
+ }
743
+ } else if (committed != null) {
744
+ committed = null
745
+ rec.emit(null)
746
+ emit = null
747
+ }
748
+ return { handled: true, advance, retreat, emit }
749
+ }
750
+
751
+ const seed = (): Units => {
752
+ if (opts.placeholderValue != null) {
753
+ const parsed = fromISO(opts.kind, opts.placeholderValue, derivation.hourCycle)
754
+ if (parsed) return parsed
755
+ }
756
+ // Default today/now, client-resolved (the local clock is an Intl edge).
757
+ const now = new Date()
758
+ const values: Units = {
759
+ year: now.getFullYear(),
760
+ month: now.getMonth() + 1,
761
+ day: now.getDate(),
762
+ hour: now.getHours(),
763
+ minute: now.getMinutes(),
764
+ second: now.getSeconds(),
765
+ dayPeriod: null,
766
+ }
767
+ if (derivation.hourCycle === 'h12') convertHour(values, 'h12')
768
+ return values
769
+ }
770
+
771
+ const retarget = (unit: SegmentUnit) => {
772
+ if (bufferUnit !== unit) {
773
+ buffer = ''
774
+ bufferUnit = unit
775
+ }
776
+ }
777
+
778
+ const reason = () => validate(opts.kind, units, derivation.units, {
779
+ min: opts.min,
780
+ max: opts.max,
781
+ unavailable: opts.unavailable,
782
+ hourCycle: derivation.hourCycle,
783
+ seconds: derivation.withSeconds,
784
+ })
785
+
786
+ return {
787
+ get segments() { return derivation.segments },
788
+ get units() { return units },
789
+ get hourCycle() { return derivation.hourCycle },
790
+ get periods() { return derivation.periods },
791
+ get monthNames() { return derivation.monthNames },
792
+
793
+ sync(value, next) {
794
+ const target = next ?? opts
795
+ // Granularity is latched: only a genuinely EXTERNAL value (after the
796
+ // reconciler check) or changed inference inputs re-infer it. A null
797
+ // echo of complete→incomplete must not narrow the shape — that
798
+ // strands units (seconds) and silently truncates the next commit.
799
+ const external = rec.observe(value)
800
+ if (external) granularity = inferFor(target, value as string | null)
801
+ else if (inferKey(target) !== inferKey(opts)) {
802
+ granularity = inferFor(target, committed)
803
+ // The latch holds while the would-be-dropped segment has a value.
804
+ if (granularity !== 'second' && units.second != null) granularity = 'second'
805
+ }
806
+ configure(target)
807
+ if (external) adopt(value as string | null)
808
+ },
809
+
810
+ text(unit) {
811
+ if (bufferUnit === unit && buffer) return buffer
812
+ const value = units[unit]
813
+ if (value == null) return ''
814
+ if (unit === 'dayPeriod') return derivation.periods[value] ?? ''
815
+ return pad(value, segmentOf(unit)?.width ?? 1)
816
+ },
817
+
818
+ bounds: boundsFor,
819
+
820
+ type(unit, key) {
821
+ if (!derivation.units.includes(unit)) return { handled: false }
822
+ retarget(unit)
823
+ if (/^\d$/.test(key) && unit !== 'dayPeriod') {
824
+ if (!/^\d*$/.test(buffer)) buffer = ''
825
+ const { min, max } = boundsFor(unit)
826
+ const state = typeDigit(buffer, key, min, max, segmentOf(unit)?.digits ?? 2)
827
+ buffer = state.buffer
828
+ if (state.value == null) return { handled: true }
829
+ units[unit] = state.value
830
+ return resolve(state.advance)
831
+ }
832
+ if (unit === 'month' && derivation.monthNames.length) {
833
+ const match = matchName(locale(), derivation.monthNames, units.month == null ? null : units.month - 1, buffer, key)
834
+ if (!match) return { handled: false }
835
+ buffer = match.buffer
836
+ units.month = match.index + 1
837
+ return resolve(match.unique)
838
+ }
839
+ if (unit === 'dayPeriod') {
840
+ const match = matchName(locale(), derivation.periods, units.dayPeriod, '', key)
841
+ if (!match) return { handled: false }
842
+ units.dayPeriod = match.index
843
+ return resolve(match.unique)
844
+ }
845
+ return { handled: false }
846
+ },
847
+
848
+ erase(unit) {
849
+ if (!derivation.units.includes(unit)) return { handled: false }
850
+ retarget(unit)
851
+ if (unit === 'dayPeriod') {
852
+ buffer = ''
853
+ if (units.dayPeriod == null) return { handled: true, retreat: true }
854
+ units.dayPeriod = null
855
+ return resolve()
856
+ }
857
+ // An active letter buffer (month-name typing) erases by letter:
858
+ // strip one, re-match the names, placeholder when it empties.
859
+ if (unit === 'month' && buffer && !/^\d*$/.test(buffer)) {
860
+ buffer = buffer.slice(0, -1)
861
+ const match = buffer ? matchName(locale(), derivation.monthNames, null, '', buffer) : null
862
+ units.month = match ? match.index + 1 : null
863
+ return resolve()
864
+ }
865
+ const state = eraseDigit(units[unit], buffer)
866
+ if (state.retreat) return { handled: true, retreat: true }
867
+ buffer = state.buffer
868
+ units[unit] = state.value
869
+ return resolve()
870
+ },
871
+
872
+ spin(unit, action) {
873
+ if (!derivation.units.includes(unit)) return { handled: false }
874
+ buffer = ''
875
+ bufferUnit = unit
876
+ const { min, max } = boundsFor(unit)
877
+ const current = units[unit]
878
+ if ('edge' in action) units[unit] = action.edge === 'min' ? min : max
879
+ else if (current == null) units[unit] = clamp(seed()[unit] ?? min, min, max)
880
+ else units[unit] = stepValue(unit, current, action, min, max, opts.step ?? 1)
881
+ return resolve()
882
+ },
883
+
884
+ merge(next) {
885
+ buffer = ''
886
+ bufferUnit = null
887
+ for (const unit of EDITABLE) {
888
+ const value = next[unit]
889
+ if (value !== undefined) units[unit] = value
890
+ }
891
+ return resolve()
892
+ },
893
+
894
+ placeholder: () => seed(),
895
+
896
+ clear() {
897
+ buffer = ''
898
+ bufferUnit = null
899
+ Object.assign(units, emptyUnits())
900
+ return resolve()
901
+ },
902
+
903
+ reset() {
904
+ adopt(opts.defaultValue ?? null)
905
+ },
906
+
907
+ blur() {
908
+ buffer = ''
909
+ bufferUnit = null
910
+ },
911
+
912
+ value: () => committed,
913
+ complete,
914
+ reason,
915
+
916
+ message() {
917
+ const why = reason()
918
+ if (!why) return null
919
+ return opts.errorMessage?.(why) ?? defaultMessage(why, { kind: opts.kind, locale: locale(), hourCycle: derivation.hourCycle })
920
+ },
921
+ }
922
+ }