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.
- package/LICENSE +15 -0
- package/README.md +126 -0
- package/dist/accordion.js +130 -0
- package/dist/avatar.js +54 -0
- package/dist/calendar.js +2 -0
- package/dist/carousel.js +239 -0
- package/dist/chart.js +790 -0
- package/dist/checkbox-group.js +70 -0
- package/dist/checkbox.js +77 -0
- package/dist/chunks/bar-CVafh6C1.js +99 -0
- package/dist/chunks/calendar-q8jZ8Cxr.js +1923 -0
- package/dist/chunks/collection-DtRB63U4.js +111 -0
- package/dist/chunks/data-table-DpkWv4y4.js +1039 -0
- package/dist/chunks/menu-B45IyHHC.js +704 -0
- package/dist/chunks/native-BJdhd9XJ.js +20 -0
- package/dist/chunks/popup-C8Bb3l_g.js +459 -0
- package/dist/chunks/popup-surface-BDCgtVU0.js +18 -0
- package/dist/chunks/position-D6_i_SRn.js +434 -0
- package/dist/chunks/virtual-list-B7hjGkjk.js +413 -0
- package/dist/collapsible.js +106 -0
- package/dist/command.js +263 -0
- package/dist/context-menu.js +112 -0
- package/dist/data-table.js +5 -0
- package/dist/dialog.js +207 -0
- package/dist/direction.js +22 -0
- package/dist/drawer.js +139 -0
- package/dist/field.js +26 -0
- package/dist/index.js +38 -0
- package/dist/input-date.js +994 -0
- package/dist/input-group.js +52 -0
- package/dist/input-otp.js +179 -0
- package/dist/menu.js +2 -0
- package/dist/menubar.js +236 -0
- package/dist/message-scroller.js +446 -0
- package/dist/navigation-menu.js +330 -0
- package/dist/popover.js +307 -0
- package/dist/progress.js +39 -0
- package/dist/radio-group.js +107 -0
- package/dist/resizable.js +172 -0
- package/dist/select.js +961 -0
- package/dist/sidebar.js +343 -0
- package/dist/slider.js +259 -0
- package/dist/switch.js +53 -0
- package/dist/tabs.js +182 -0
- package/dist/toast.js +492 -0
- package/dist/toggle-group.js +111 -0
- package/dist/toggle.js +52 -0
- package/dist/toolbar.js +127 -0
- package/dist/tooltip.js +196 -0
- package/dist/utils.js +104 -0
- package/dist/virtual-list.js +2 -0
- package/package.json +250 -0
- package/src/accordion.tsx +261 -0
- package/src/availability.ts +261 -0
- package/src/avatar.tsx +99 -0
- package/src/bar.ts +156 -0
- package/src/calendar.tsx +1441 -0
- package/src/carousel.tsx +424 -0
- package/src/chart.tsx +1194 -0
- package/src/checkbox-group.tsx +132 -0
- package/src/checkbox.tsx +130 -0
- package/src/collapsible.tsx +188 -0
- package/src/collection.ts +154 -0
- package/src/command.tsx +511 -0
- package/src/context-menu.tsx +233 -0
- package/src/data-table-contract.ts +143 -0
- package/src/data-table-model.ts +760 -0
- package/src/data-table.tsx +475 -0
- package/src/dialog.tsx +393 -0
- package/src/direction.tsx +45 -0
- package/src/drawer.tsx +251 -0
- package/src/field.tsx +61 -0
- package/src/index.ts +37 -0
- package/src/input-date.tsx +1539 -0
- package/src/input-group.tsx +142 -0
- package/src/input-otp.tsx +324 -0
- package/src/menu-cluster.ts +124 -0
- package/src/menu.tsx +1095 -0
- package/src/menubar.tsx +459 -0
- package/src/message-scroller.tsx +732 -0
- package/src/native.ts +26 -0
- package/src/navigation-menu.tsx +578 -0
- package/src/popover.tsx +519 -0
- package/src/popup-surface.tsx +31 -0
- package/src/popup.ts +569 -0
- package/src/position.ts +523 -0
- package/src/progress.tsx +70 -0
- package/src/radio-group.tsx +186 -0
- package/src/resizable.tsx +310 -0
- package/src/segments.ts +922 -0
- package/src/select.tsx +1501 -0
- package/src/sidebar.tsx +683 -0
- package/src/slider.tsx +424 -0
- package/src/switch.tsx +104 -0
- package/src/tabs.tsx +314 -0
- package/src/toast.tsx +923 -0
- package/src/toggle-group.tsx +249 -0
- package/src/toggle.tsx +91 -0
- package/src/toolbar.tsx +212 -0
- package/src/tooltip.tsx +359 -0
- package/src/utils.ts +204 -0
- package/src/virtual-list.tsx +205 -0
- package/src/virtual.ts +385 -0
package/src/segments.ts
ADDED
|
@@ -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
|
+
}
|