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
|
@@ -0,0 +1,1539 @@
|
|
|
1
|
+
import type { IntrinsicElements, Stateful, Stateless, WithChildren } from 'ajo'
|
|
2
|
+
import { callHandler, callRef, controlled, dom, id, listen, restore, roving, spin, statefulRootAttrs as rootAttrs } from 'ajo-cloves'
|
|
3
|
+
import { context } from 'ajo/context'
|
|
4
|
+
import { compile, type Availability, type AvailabilityMatcher } from './availability'
|
|
5
|
+
import type { ReservedPositionArg } from './position'
|
|
6
|
+
import type { FixedArgs, OmitArg, PopupPosition } from './utils'
|
|
7
|
+
import { Calendar, type CalendarArgs, type CalendarCommonArgs, type CalendarDateRange, type CalendarMatcher } from './calendar'
|
|
8
|
+
import { FieldContext } from './field'
|
|
9
|
+
import { contentAttrs, popup, type PopupView } from './popup'
|
|
10
|
+
import {
|
|
11
|
+
defaultMessage,
|
|
12
|
+
field,
|
|
13
|
+
formatValue,
|
|
14
|
+
fromISO,
|
|
15
|
+
isReversed,
|
|
16
|
+
timeRun,
|
|
17
|
+
unitLabel,
|
|
18
|
+
type FieldOptions,
|
|
19
|
+
type FieldView,
|
|
20
|
+
type Granularity,
|
|
21
|
+
type InputResult,
|
|
22
|
+
type Reason,
|
|
23
|
+
type Segment,
|
|
24
|
+
type SegmentUnit,
|
|
25
|
+
type SegmentsKind,
|
|
26
|
+
type Units,
|
|
27
|
+
} from './segments'
|
|
28
|
+
import { flag, triggerAttrs } from './utils'
|
|
29
|
+
export type { PopupPlacement, PopupPosition } from './utils'
|
|
30
|
+
|
|
31
|
+
/** Range endpoint edited by a segmented date or time field. */
|
|
32
|
+
export type InputDateSide = 'from' | 'to'
|
|
33
|
+
|
|
34
|
+
/** Serialized start and end values emitted by a range field. */
|
|
35
|
+
export type InputDateRangeValue = {
|
|
36
|
+
/** Range start in the family's own format; null while empty. */
|
|
37
|
+
from: string | null
|
|
38
|
+
/** Range end in the family's own format; null while empty. */
|
|
39
|
+
to: string | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Public value shape selected by a field's range mode. */
|
|
43
|
+
export type InputDateValue<Range extends boolean = false> = Range extends true ? InputDateRangeValue : string
|
|
44
|
+
|
|
45
|
+
/** Labelled value that may be committed from an InputDate popover. */
|
|
46
|
+
export type InputDatePreset<Range extends boolean = boolean> = {
|
|
47
|
+
/** Preset button label. */
|
|
48
|
+
label: string
|
|
49
|
+
/** Committed on pick, in the family's own value format. */
|
|
50
|
+
value: InputDateValue<Range>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type InputDateCalendarOwnedArgs = 'allowNonContiguous' | 'defaultSelected' | 'mode' | 'onSelect' | 'selected' | 'unavailable'
|
|
54
|
+
|
|
55
|
+
/** Calendar presentation arguments that remain consumer-configurable inside InputDate. */
|
|
56
|
+
export type InputDateCalendarArgs = OmitArg<CalendarCommonArgs, InputDateCalendarOwnedArgs> & {
|
|
57
|
+
/** Calendar implementation; the themed layer injects its Calendar here. */
|
|
58
|
+
component?: Stateless<CalendarArgs>
|
|
59
|
+
} & FixedArgs<InputDateCalendarOwnedArgs>
|
|
60
|
+
|
|
61
|
+
type CommonArgs<Range extends boolean> = WithChildren<OmitArg<IntrinsicElements['div'], 'children' | 'defaultValue' | 'onchange' | ReservedPositionArg> & {
|
|
62
|
+
/** Allow a range to span unavailable days without treating its interior gaps as selected. */
|
|
63
|
+
allowNonContiguous?: boolean
|
|
64
|
+
/** Range mode: two field groups, `{ from, to } | null` value. */
|
|
65
|
+
range?: Range
|
|
66
|
+
/** Controlled value; null means controlled-empty. */
|
|
67
|
+
value?: InputDateValue<Range> | null
|
|
68
|
+
/** Initial value for uncontrolled usage; what a form reset restores. */
|
|
69
|
+
defaultValue?: InputDateValue<Range>
|
|
70
|
+
/** Called on every commit; null when the field empties. */
|
|
71
|
+
onValueChange?: (value: InputDateValue<Range> | null, event?: Event) => void
|
|
72
|
+
/** BCP 47 tag or `{ code }`; falls back to `<html lang>`, then 'en-US'. Never navigator. */
|
|
73
|
+
locale?: string | { code?: string }
|
|
74
|
+
/** Lower bound in the family's format; stamps invalid, never blocks commits. */
|
|
75
|
+
min?: string
|
|
76
|
+
/** Upper bound in the family's format; stamps invalid, never blocks commits. */
|
|
77
|
+
max?: string
|
|
78
|
+
/** Unavailable dates or times; remain committable and stamp reason-coded invalid state. */
|
|
79
|
+
unavailable?: AvailabilityMatcher | AvailabilityMatcher[]
|
|
80
|
+
/** Seeds the first arrow press on an empty segment; never emitted by itself. */
|
|
81
|
+
placeholderValue?: string
|
|
82
|
+
/** Reason-coded message override; undefined falls back to the localized default. */
|
|
83
|
+
errorMessage?: (reason: Reason) => string | undefined
|
|
84
|
+
/** Screen-reader label for an empty editable segment. */
|
|
85
|
+
emptyLabel?: string
|
|
86
|
+
/** Hidden input name; range submits `name[from]` / `name[to]`. */
|
|
87
|
+
name?: string
|
|
88
|
+
/** Mirrors to aria-required only. */
|
|
89
|
+
required?: boolean
|
|
90
|
+
/** Unfocusable segments, no submission. */
|
|
91
|
+
disabled?: boolean
|
|
92
|
+
/** Focusable no-op segments. */
|
|
93
|
+
readOnly?: boolean
|
|
94
|
+
/** Additional classes. */
|
|
95
|
+
class?: string
|
|
96
|
+
}> & FixedArgs<'onchange' | ReservedPositionArg>
|
|
97
|
+
|
|
98
|
+
type PopupArgs<Range extends boolean> = {
|
|
99
|
+
/** Opt into the calendar popover; an object forwards args to InputDateCalendar. */
|
|
100
|
+
calendar?: boolean | InputDateCalendarArgs
|
|
101
|
+
/** Preset values rendered by InputDatePresets; a pick commits and closes. */
|
|
102
|
+
presets?: InputDatePreset<Range>[]
|
|
103
|
+
/** Close on a pick. Defaults to single/range-complete for dates, and false while a datetime time surface is composed. */
|
|
104
|
+
closeOnSelect?: boolean
|
|
105
|
+
/** Controlled popover state. */
|
|
106
|
+
open?: boolean
|
|
107
|
+
/** Initial popover state for uncontrolled usage. */
|
|
108
|
+
defaultOpen?: boolean
|
|
109
|
+
/** Called when the popover opens or closes. */
|
|
110
|
+
onOpenChange?: (open: boolean, event?: Event) => void
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type TimeArgs = {
|
|
114
|
+
/** Overrides the locale's resolved hour cycle. */
|
|
115
|
+
hourCycle?: 12 | 24
|
|
116
|
+
/** Segment shape for an empty field; values with seconds force the segment. */
|
|
117
|
+
granularity?: 'minute' | 'second'
|
|
118
|
+
/** Minute arrow step; typing and PageUp/Down are unaffected. */
|
|
119
|
+
step?: number
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Arguments for a segmented date field with optional Calendar composition. */
|
|
123
|
+
export type InputDateArgs<Range extends boolean = false> = CommonArgs<Range> & PopupArgs<Range> & PopupPosition
|
|
124
|
+
|
|
125
|
+
/** Arguments for a segmented wall-time field. */
|
|
126
|
+
export type InputTimeArgs<Range extends boolean = false> = CommonArgs<Range> & TimeArgs & FixedArgs<'gap' | 'placement'>
|
|
127
|
+
|
|
128
|
+
/** Arguments for a segmented date-time field with optional Calendar composition. */
|
|
129
|
+
export type InputDateTimeArgs<Range extends boolean = false> = CommonArgs<Range> & PopupArgs<Range> & TimeArgs & PopupPosition
|
|
130
|
+
|
|
131
|
+
/** Arguments for the rendered date-segment group of an InputDate family root. */
|
|
132
|
+
export type InputDateFieldArgs = OmitArg<IntrinsicElements['div'], 'children'> & {
|
|
133
|
+
/** Which range side this group edits; required in range mode, absent in single. */
|
|
134
|
+
side?: InputDateSide
|
|
135
|
+
/** Accessible group label; range sides default to Start/End date. */
|
|
136
|
+
label?: string
|
|
137
|
+
/** Class for each editable segment. */
|
|
138
|
+
segmentClass?: string
|
|
139
|
+
/** Class for literal separators. */
|
|
140
|
+
literalClass?: string
|
|
141
|
+
/** Additional classes. */
|
|
142
|
+
class?: string
|
|
143
|
+
} & FixedArgs<'children'>
|
|
144
|
+
|
|
145
|
+
/** Arguments for the secondary time-segment group of InputDateTime. */
|
|
146
|
+
export type InputDateTimeFieldArgs = OmitArg<IntrinsicElements['div'], 'children'> & {
|
|
147
|
+
/** Which range side this time surface edits; absent in single mode. */
|
|
148
|
+
side?: InputDateSide
|
|
149
|
+
/** Accessible group label; defaults to Time, Start time, or End time. */
|
|
150
|
+
label?: string
|
|
151
|
+
/** Class for each editable time segment. */
|
|
152
|
+
segmentClass?: string
|
|
153
|
+
/** Class for literal separators between time segments. */
|
|
154
|
+
literalClass?: string
|
|
155
|
+
/** Additional classes. */
|
|
156
|
+
class?: string
|
|
157
|
+
} & FixedArgs<'children'>
|
|
158
|
+
|
|
159
|
+
/** Arguments for the button that toggles an InputDate Calendar popover. */
|
|
160
|
+
export type InputDateTriggerArgs = WithChildren<IntrinsicElements['button'] & {
|
|
161
|
+
/** Additional classes. */
|
|
162
|
+
class?: string
|
|
163
|
+
iconClass?: string
|
|
164
|
+
}>
|
|
165
|
+
|
|
166
|
+
/** Arguments for positioned InputDate popover content. */
|
|
167
|
+
export type InputDateContentArgs = WithChildren<OmitArg<IntrinsicElements['div'], 'hidden' | 'id' | 'popover' | 'role' | 'tabindex' | 'tabIndex' | ReservedPositionArg> & {
|
|
168
|
+
/** Additional classes. */
|
|
169
|
+
class?: string
|
|
170
|
+
/** Inline CSS declarations composed with live positioning styles. */
|
|
171
|
+
style?: string
|
|
172
|
+
}> & FixedArgs<'gap' | 'hidden' | 'id' | 'placement' | 'popover' | 'role' | 'tabindex' | 'tabIndex' | ReservedPositionArg>
|
|
173
|
+
|
|
174
|
+
/** Arguments for the button that clears an InputDate family value. */
|
|
175
|
+
export type InputDateClearArgs = WithChildren<IntrinsicElements['button'] & {
|
|
176
|
+
/** Additional classes. */
|
|
177
|
+
class?: string
|
|
178
|
+
iconClass?: string
|
|
179
|
+
}>
|
|
180
|
+
|
|
181
|
+
/** Arguments for the list of preset values rendered in a date popover. */
|
|
182
|
+
export type InputDatePresetsArgs = WithChildren<IntrinsicElements['div'] & {
|
|
183
|
+
/** Additional classes. */
|
|
184
|
+
class?: string
|
|
185
|
+
buttonClass?: string
|
|
186
|
+
}>
|
|
187
|
+
|
|
188
|
+
type PublicValue = string | InputDateRangeValue | null
|
|
189
|
+
type InputDateSurface = 'field' | 'popover'
|
|
190
|
+
|
|
191
|
+
type InputDateRootArgs = WithChildren<{
|
|
192
|
+
allowNonContiguous?: boolean
|
|
193
|
+
calendar?: boolean | InputDateCalendarArgs
|
|
194
|
+
closeOnSelect?: boolean
|
|
195
|
+
defaultOpen?: boolean
|
|
196
|
+
defaultValue?: string | InputDateRangeValue
|
|
197
|
+
disabled?: boolean
|
|
198
|
+
emptyLabel?: string
|
|
199
|
+
errorMessage?: (reason: Reason) => string | undefined
|
|
200
|
+
granularity?: Granularity
|
|
201
|
+
hourCycle?: 12 | 24
|
|
202
|
+
kind: SegmentsKind
|
|
203
|
+
locale?: string | { code?: string }
|
|
204
|
+
max?: string
|
|
205
|
+
min?: string
|
|
206
|
+
name?: string
|
|
207
|
+
onOpenChange?: (open: boolean, event?: Event) => void
|
|
208
|
+
onValueChange?: (value: PublicValue, event?: Event) => void
|
|
209
|
+
open?: boolean
|
|
210
|
+
placeholderValue?: string
|
|
211
|
+
presets?: InputDatePreset[]
|
|
212
|
+
range?: boolean
|
|
213
|
+
readOnly?: boolean
|
|
214
|
+
required?: boolean
|
|
215
|
+
step?: number
|
|
216
|
+
unavailable?: AvailabilityMatcher | AvailabilityMatcher[]
|
|
217
|
+
value?: string | InputDateRangeValue | null
|
|
218
|
+
}> & PopupPosition
|
|
219
|
+
|
|
220
|
+
type InputDateContextValue = {
|
|
221
|
+
allowNonContiguous: boolean
|
|
222
|
+
applyPreset: (value: string | InputDateRangeValue, event: Event) => void
|
|
223
|
+
calendarDisabled: (user?: CalendarMatcher | CalendarMatcher[]) => CalendarMatcher[] | undefined
|
|
224
|
+
calendarUnavailable: AvailabilityMatcher | AvailabilityMatcher[] | undefined
|
|
225
|
+
clear: (event?: Event) => void
|
|
226
|
+
contentId: string
|
|
227
|
+
contentStyle: PopupView['contentStyle']
|
|
228
|
+
daySelected: () => Date | null
|
|
229
|
+
disabled: boolean
|
|
230
|
+
field: (side: InputDateSide) => FieldView
|
|
231
|
+
groupAttrs: (side: InputDateSide, label?: string, surface?: InputDateSurface) => Record<string, unknown>
|
|
232
|
+
hasValue: boolean
|
|
233
|
+
kind: SegmentsKind
|
|
234
|
+
locale: string
|
|
235
|
+
message: string | null
|
|
236
|
+
monthOf: () => Date | undefined
|
|
237
|
+
onMonthChange: (month: Date, event?: Event) => void
|
|
238
|
+
open: boolean
|
|
239
|
+
pickDay: (next: Date | null, event: Event) => void
|
|
240
|
+
pickRange: (next: CalendarDateRange | null, event: Event) => void
|
|
241
|
+
popup: (part: string) => boolean
|
|
242
|
+
presets: InputDatePreset[]
|
|
243
|
+
range: boolean
|
|
244
|
+
rangeSelected: () => CalendarDateRange | null
|
|
245
|
+
readOnly: boolean
|
|
246
|
+
segmentAttrs: (side: InputDateSide, segment: Segment, label?: string, surface?: InputDateSurface) => Record<string, unknown>
|
|
247
|
+
segmentText: (side: InputDateSide, segment: Segment, surface?: InputDateSurface) => string
|
|
248
|
+
setContent: (element: HTMLDivElement | null) => void
|
|
249
|
+
registerTimeSurface: (element: HTMLDivElement) => void
|
|
250
|
+
unregisterTimeSurface: (element: HTMLDivElement) => void
|
|
251
|
+
setTrigger: (element: HTMLButtonElement | null) => void
|
|
252
|
+
timeSurface: (part: string) => boolean
|
|
253
|
+
toggleOpen: (event: Event) => void
|
|
254
|
+
triggerId: string
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const InputDateContext = context<InputDateContextValue | null>(null)
|
|
258
|
+
|
|
259
|
+
// Locale resolution is gated on document, NEVER navigator: Node ships a global
|
|
260
|
+
// navigator and the ambient machine locale is banned on both passes.
|
|
261
|
+
const resolveLocale = (locale: string | { code?: string } | undefined): string => {
|
|
262
|
+
const code = typeof locale === 'string' ? locale : locale?.code
|
|
263
|
+
if (code) return code
|
|
264
|
+
if (typeof document !== 'undefined' && document.documentElement.lang) return document.documentElement.lang
|
|
265
|
+
return 'en-US'
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const InputDateRoot: Stateful<InputDateRootArgs> = function* (initial) {
|
|
269
|
+
let kind = initial.kind
|
|
270
|
+
const rootId = id('input-date')
|
|
271
|
+
const domReady = dom(this)
|
|
272
|
+
const ownerDocument = domReady ? this.ownerDocument : null
|
|
273
|
+
const node = (value: unknown): value is Node => {
|
|
274
|
+
const view = ownerDocument?.defaultView
|
|
275
|
+
return Boolean(view && value instanceof view.Node)
|
|
276
|
+
}
|
|
277
|
+
// iOS VoiceOver cannot focus spinbuttons: role textbox, no aria-value*.
|
|
278
|
+
// SSR emits spinbutton; the hydration attr rewrite is an accepted divergence.
|
|
279
|
+
const ios = domReady && typeof navigator !== 'undefined' &&
|
|
280
|
+
(/iP(ad|hone|od)/.test(navigator.userAgent) || (navigator.userAgent.includes('Mac') && navigator.maxTouchPoints > 1))
|
|
281
|
+
const warned = new Set<string>()
|
|
282
|
+
const timeSurfaces = new Set<HTMLDivElement>()
|
|
283
|
+
let closeOnSelect: boolean | undefined
|
|
284
|
+
let composing: { side: InputDateSide; unit: SegmentUnit; element: HTMLElement; text: string } | null = null
|
|
285
|
+
let controlId = `${rootId}-control`
|
|
286
|
+
let availabilitySource = initial.unavailable
|
|
287
|
+
let availability = compile(initial.unavailable)
|
|
288
|
+
let boundsSource: string | undefined
|
|
289
|
+
let boundsAvailability: Availability | undefined
|
|
290
|
+
let calendarUserSource: CalendarMatcher | CalendarMatcher[] | undefined
|
|
291
|
+
let calendarUserAvailability: Availability | undefined
|
|
292
|
+
let allowNonContiguous = Boolean(initial.allowNonContiguous)
|
|
293
|
+
let disabled = Boolean(initial.disabled)
|
|
294
|
+
let isRange = Boolean(initial.range)
|
|
295
|
+
let locale = resolveLocale(initial.locale)
|
|
296
|
+
let onOpenChange: InputDateRootArgs['onOpenChange']
|
|
297
|
+
let onValueChange: InputDateRootArgs['onValueChange']
|
|
298
|
+
let pendingFocus = false
|
|
299
|
+
let pendingReference: HTMLElement | null = null
|
|
300
|
+
let closingInside = false
|
|
301
|
+
let pop: PopupView<HTMLButtonElement, HTMLDivElement>
|
|
302
|
+
let presets: InputDatePreset[] = []
|
|
303
|
+
let readOnly = Boolean(initial.readOnly)
|
|
304
|
+
let required = Boolean(initial.required)
|
|
305
|
+
let visibleMonth: Date | undefined
|
|
306
|
+
|
|
307
|
+
const unavailableValue = (value: string) => availability?.value(kind, value) ?? false
|
|
308
|
+
|
|
309
|
+
const sideDefault = (value: string | InputDateRangeValue | undefined, side: InputDateSide): string | undefined => {
|
|
310
|
+
if (value == null) return undefined
|
|
311
|
+
if (typeof value === 'string') return side === 'from' ? value : undefined
|
|
312
|
+
return value[side] ?? undefined
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const optionsFor = (side: InputDateSide, args: InputDateRootArgs): FieldOptions => ({
|
|
316
|
+
kind,
|
|
317
|
+
locale,
|
|
318
|
+
granularity: args.granularity,
|
|
319
|
+
hourCycle: args.hourCycle,
|
|
320
|
+
step: args.step,
|
|
321
|
+
placeholderValue: args.placeholderValue,
|
|
322
|
+
defaultValue: sideDefault(args.defaultValue, side),
|
|
323
|
+
min: args.min,
|
|
324
|
+
max: args.max,
|
|
325
|
+
unavailable: availability ? unavailableValue : undefined,
|
|
326
|
+
errorMessage: args.errorMessage,
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
const fields: Record<InputDateSide, FieldView> = {
|
|
330
|
+
from: field(optionsFor('from', initial)),
|
|
331
|
+
to: field(optionsFor('to', initial)),
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const composeValue = (): PublicValue => {
|
|
335
|
+
const from = fields.from.value()
|
|
336
|
+
if (!isRange) return from
|
|
337
|
+
const to = fields.to.value()
|
|
338
|
+
return from == null && to == null ? null : { from, to }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const valueState = controlled<PublicValue>(this, {
|
|
342
|
+
fallback: initial.defaultValue ?? null,
|
|
343
|
+
onChange: (next, event) => onValueChange?.(next, event),
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
const sideValue = (side: InputDateSide): string | null | undefined => {
|
|
347
|
+
if (!valueState.controlled) return undefined
|
|
348
|
+
const value = valueState.value
|
|
349
|
+
if (value == null) return null
|
|
350
|
+
if (typeof value === 'string') return side === 'from' ? value : null
|
|
351
|
+
return value[side]
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Range emissions compose once: both sides may merge in one gesture.
|
|
355
|
+
const emitIfChanged = (results: InputResult[], event?: Event) => {
|
|
356
|
+
if (results.every(result => result.emit === undefined)) return
|
|
357
|
+
valueState.set(composeValue(), event)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const segmentsOf = (surface: InputDateSurface = 'field', rendered = true): HTMLElement[] =>
|
|
361
|
+
domReady
|
|
362
|
+
? Array.from(this.querySelectorAll<HTMLElement>('[data-segment]'))
|
|
363
|
+
.filter(item => item.dataset.surface === surface && (!rendered || item.offsetParent !== null))
|
|
364
|
+
: []
|
|
365
|
+
|
|
366
|
+
const segmentOf = (event: Event): HTMLElement | null =>
|
|
367
|
+
event.target instanceof Element ? event.target.closest<HTMLElement>('[data-segment]') : null
|
|
368
|
+
|
|
369
|
+
const sideOf = (segment: HTMLElement): InputDateSide =>
|
|
370
|
+
(segment.dataset.side as InputDateSide) ?? 'from'
|
|
371
|
+
|
|
372
|
+
const surfaceOf = (segment: HTMLElement): InputDateSurface =>
|
|
373
|
+
segment.dataset.surface === 'popover' ? 'popover' : 'field'
|
|
374
|
+
|
|
375
|
+
const unitOf = (segment: HTMLElement): SegmentUnit => segment.dataset.segment as SegmentUnit
|
|
376
|
+
|
|
377
|
+
// Each rendered surface owns one flat list. Range sides stay adjacent within
|
|
378
|
+
// the field or popover, but navigation never crosses between those surfaces.
|
|
379
|
+
const moveFocus = (from: HTMLElement, step: number) => {
|
|
380
|
+
const list = segmentsOf(surfaceOf(from))
|
|
381
|
+
list[list.indexOf(from) + step]?.focus()
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const apply = (result: InputResult, event: Event, segment: HTMLElement): boolean => {
|
|
385
|
+
if (!result.handled) return false
|
|
386
|
+
emitIfChanged([result], event)
|
|
387
|
+
if (result.advance) moveFocus(segment, 1)
|
|
388
|
+
if (result.retreat) moveFocus(segment, -1)
|
|
389
|
+
this.next()
|
|
390
|
+
return true
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const focusMemory = restore(this)
|
|
394
|
+
|
|
395
|
+
const clearFocusIntent = () => {
|
|
396
|
+
pendingFocus = false
|
|
397
|
+
pendingReference = null
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const restoreAfterClose = () => {
|
|
401
|
+
clearFocusIntent()
|
|
402
|
+
const active = ownerDocument?.activeElement
|
|
403
|
+
if (dom(active) && pop.content?.contains(active) && !closingInside) focusMemory.restore()
|
|
404
|
+
else focusMemory.capture(null)
|
|
405
|
+
closingInside = false
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
pop = popup<HTMLButtonElement, HTMLDivElement>(this, {
|
|
409
|
+
prefix: 'input-date',
|
|
410
|
+
profile: 'date',
|
|
411
|
+
initialOpen: Boolean(initial.open ?? initial.defaultOpen),
|
|
412
|
+
disabled: () => disabled,
|
|
413
|
+
onOpenChange: (next, event) => onOpenChange?.(next, event),
|
|
414
|
+
// The popover anchors to the complete field root, not a segment group or
|
|
415
|
+
// the 28px icon trigger.
|
|
416
|
+
reference: view => view.reference,
|
|
417
|
+
// Native invoker semantics still belong to the calendar button.
|
|
418
|
+
source: view => view.trigger,
|
|
419
|
+
referenceHidden: 'close',
|
|
420
|
+
dismiss: {
|
|
421
|
+
escape: false,
|
|
422
|
+
outside: true,
|
|
423
|
+
inside: view => [
|
|
424
|
+
dom(view.reference) ? view.reference : null,
|
|
425
|
+
view.trigger,
|
|
426
|
+
view.content,
|
|
427
|
+
],
|
|
428
|
+
onDismiss: event => setOpen(false, event),
|
|
429
|
+
},
|
|
430
|
+
onPosition: () => {
|
|
431
|
+
if (!pendingFocus || pop.reference !== pendingReference) return
|
|
432
|
+
clearFocusIntent()
|
|
433
|
+
if (!pop.content) return
|
|
434
|
+
// The dialog autofocuses the calendar: selected day, today, first enabled.
|
|
435
|
+
const target = pop.content.querySelector<HTMLElement>('[data-slot="calendar-day-button"][data-state="selected"]:not(:disabled)')
|
|
436
|
+
?? pop.content.querySelector<HTMLElement>('[data-slot="calendar-day-button"][data-today]:not(:disabled)')
|
|
437
|
+
?? pop.content.querySelector<HTMLElement>('[data-slot="calendar-day-button"]:not(:disabled)')
|
|
438
|
+
?? pop.content
|
|
439
|
+
target.focus()
|
|
440
|
+
},
|
|
441
|
+
onSync: opened => {
|
|
442
|
+
if (!opened) restoreAfterClose()
|
|
443
|
+
},
|
|
444
|
+
})
|
|
445
|
+
pop.setReference(this)
|
|
446
|
+
|
|
447
|
+
const setOpen = (next: boolean, event?: Event) => {
|
|
448
|
+
if (disabled && next) return
|
|
449
|
+
if (next === pop.open) return
|
|
450
|
+
if (next) {
|
|
451
|
+
// A fresh session follows the committed value again.
|
|
452
|
+
visibleMonth = undefined
|
|
453
|
+
pendingFocus = true
|
|
454
|
+
pendingReference = pop.reference as HTMLElement | null
|
|
455
|
+
} else {
|
|
456
|
+
clearFocusIntent()
|
|
457
|
+
// A pointerdown landing back on our field keeps the click's own focus.
|
|
458
|
+
closingInside = event?.type === 'pointerdown' && node(event.target) && this.contains(event.target)
|
|
459
|
+
}
|
|
460
|
+
pop.setOpen(next, event)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const toggleOpen = (event: Event) => {
|
|
464
|
+
const next = !pop.open
|
|
465
|
+
if (next) focusMemory.capture(pop.trigger)
|
|
466
|
+
setOpen(next, event)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const popupPart = (part: string) => {
|
|
470
|
+
if (kind !== 'time') return true
|
|
471
|
+
if (!warned.has(part)) {
|
|
472
|
+
warned.add(part)
|
|
473
|
+
console.warn(`[input-date] <${part}> has no effect under InputTime; popup parts require a date family root.`)
|
|
474
|
+
}
|
|
475
|
+
return false
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const timeSurface = (part: string) => {
|
|
479
|
+
if (kind === 'datetime') return true
|
|
480
|
+
if (!warned.has(part)) {
|
|
481
|
+
warned.add(part)
|
|
482
|
+
console.warn(`[input-date] <${part}> has no effect outside InputDateTime.`)
|
|
483
|
+
}
|
|
484
|
+
return false
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const hasTimeSurface = () => timeSurfaces.size > 0
|
|
488
|
+
|
|
489
|
+
// Calendar wiring:
|
|
490
|
+
|
|
491
|
+
const dateOfValue = (side: InputDateSide): Date | undefined => {
|
|
492
|
+
const units = fields[side].units
|
|
493
|
+
return units.year != null && units.month != null && units.day != null
|
|
494
|
+
? new Date(units.year, units.month - 1, units.day, 12)
|
|
495
|
+
: undefined
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const daySelected = (): Date | null => dateOfValue('from') ?? null
|
|
499
|
+
|
|
500
|
+
const rangeSelected = (): CalendarDateRange | null => {
|
|
501
|
+
const from = dateOfValue('from')
|
|
502
|
+
const to = dateOfValue('to')
|
|
503
|
+
return from || to ? { from, to } : null
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const monthOf = () => visibleMonth ?? dateOfValue('from')
|
|
507
|
+
|
|
508
|
+
const onMonthChange = (month: Date, event?: Event) => {
|
|
509
|
+
const slot = event?.target instanceof Element ? event.target.closest<HTMLElement>('[data-slot]')?.dataset.slot : undefined
|
|
510
|
+
this.next(() => visibleMonth = month)
|
|
511
|
+
// The Calendar re-creates its keyed month subtree (nav buttons included):
|
|
512
|
+
// when paging drops focus with it, land on the equivalent control.
|
|
513
|
+
if (!domReady || !slot) return
|
|
514
|
+
const active = document.activeElement
|
|
515
|
+
if (active && active !== document.body) return
|
|
516
|
+
pop.content?.querySelector<HTMLElement>(`[data-slot="${slot}"]`)?.focus()
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const dayOfBound = (value: string | undefined): Date | undefined => {
|
|
520
|
+
if (!value) return undefined
|
|
521
|
+
const units = fromISO(kind, value)
|
|
522
|
+
return units && units.year != null && units.month != null && units.day != null
|
|
523
|
+
? new Date(units.year, units.month - 1, units.day, 12)
|
|
524
|
+
: undefined
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const syncAvailability = (args: InputDateRootArgs) => {
|
|
528
|
+
if (args.unavailable !== availabilitySource) {
|
|
529
|
+
availabilitySource = args.unavailable
|
|
530
|
+
availability = compile(args.unavailable)
|
|
531
|
+
}
|
|
532
|
+
const bounds = `${args.min ?? ''}\0${args.max ?? ''}`
|
|
533
|
+
if (bounds !== boundsSource) {
|
|
534
|
+
boundsSource = bounds
|
|
535
|
+
const matcher: CalendarMatcher[] = []
|
|
536
|
+
const before = dayOfBound(args.min)
|
|
537
|
+
const after = dayOfBound(args.max)
|
|
538
|
+
if (before) matcher.push({ before })
|
|
539
|
+
if (after) matcher.push({ after })
|
|
540
|
+
boundsAvailability = compile(matcher)
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const hardDisabled = (date: Date) =>
|
|
545
|
+
Boolean(calendarUserAvailability?.day(date) || boundsAvailability?.day(date))
|
|
546
|
+
const hardDisabledMatcher: CalendarMatcher[] = [hardDisabled]
|
|
547
|
+
|
|
548
|
+
const calendarDisabled = (user?: CalendarMatcher | CalendarMatcher[]): CalendarMatcher[] | undefined => {
|
|
549
|
+
if (user !== calendarUserSource) {
|
|
550
|
+
calendarUserSource = user
|
|
551
|
+
calendarUserAvailability = compile(user)
|
|
552
|
+
}
|
|
553
|
+
return calendarUserAvailability || boundsAvailability ? hardDisabledMatcher : undefined
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const datePatch = (editor: FieldView, date: Date): Partial<Units> => {
|
|
557
|
+
const patch: Partial<Units> = { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() }
|
|
558
|
+
if (kind === 'datetime') {
|
|
559
|
+
// A picked day must commit: empty time units seed from the placeholder.
|
|
560
|
+
const units = editor.units
|
|
561
|
+
const seeded = editor.placeholder()
|
|
562
|
+
if (units.hour == null) patch.hour = seeded.hour
|
|
563
|
+
if (units.minute == null) patch.minute = seeded.minute
|
|
564
|
+
if (units.second == null && editor.segments.some(segment => segment.type === 'second')) patch.second = seeded.second
|
|
565
|
+
if (units.dayPeriod == null && editor.hourCycle === 'h12') patch.dayPeriod = seeded.dayPeriod
|
|
566
|
+
}
|
|
567
|
+
return patch
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const emptyDate: Partial<Units> = { year: null, month: null, day: null }
|
|
571
|
+
|
|
572
|
+
const pickDay = (next: Date | null, event: Event) => {
|
|
573
|
+
if (disabled || readOnly) return
|
|
574
|
+
const result = fields.from.merge(next ? datePatch(fields.from, next) : emptyDate)
|
|
575
|
+
emitIfChanged([result], event)
|
|
576
|
+
if (closeOnSelect ?? (Boolean(next) && !hasTimeSurface())) setOpen(false, event)
|
|
577
|
+
this.next()
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const pickRange = (next: CalendarDateRange | null, event: Event) => {
|
|
581
|
+
if (disabled || readOnly) return
|
|
582
|
+
const patchFor = (editor: FieldView, date: Date | undefined) => date ? datePatch(editor, date) : emptyDate
|
|
583
|
+
emitIfChanged([
|
|
584
|
+
fields.from.merge(patchFor(fields.from, next?.from)),
|
|
585
|
+
fields.to.merge(patchFor(fields.to, next?.to)),
|
|
586
|
+
], event)
|
|
587
|
+
if (closeOnSelect ?? (Boolean(next?.from && next?.to) && !hasTimeSurface())) setOpen(false, event)
|
|
588
|
+
this.next()
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const applyPreset = (value: string | InputDateRangeValue, event: Event) => {
|
|
592
|
+
if (disabled || readOnly) return
|
|
593
|
+
const mergeSide = (side: InputDateSide, next: string | null): InputResult => {
|
|
594
|
+
if (next == null) return fields[side].clear()
|
|
595
|
+
const units = fromISO(kind, next, fields[side].hourCycle)
|
|
596
|
+
if (!units) {
|
|
597
|
+
console.warn(`[input-date] invalid ${kind} preset value: "${next}"`)
|
|
598
|
+
return { handled: false }
|
|
599
|
+
}
|
|
600
|
+
return fields[side].merge(units)
|
|
601
|
+
}
|
|
602
|
+
const results = typeof value === 'string'
|
|
603
|
+
? [mergeSide('from', value)]
|
|
604
|
+
: [mergeSide('from', value.from), mergeSide('to', value.to)]
|
|
605
|
+
emitIfChanged(results, event)
|
|
606
|
+
setOpen(false, event)
|
|
607
|
+
this.next()
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const clearValue = (event?: Event) => {
|
|
611
|
+
if (disabled || readOnly) return
|
|
612
|
+
const results = [fields.from.clear()]
|
|
613
|
+
if (isRange) results.push(fields.to.clear())
|
|
614
|
+
emitIfChanged(results, event)
|
|
615
|
+
// The clear button unmounts with the value (combobox precedent:
|
|
616
|
+
// clearing refocuses the input): focus lands on the first segment.
|
|
617
|
+
if (domReady) queueMicrotask(() => segmentsOf('field')[0]?.focus())
|
|
618
|
+
this.next()
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// A sync reshape (external narrower value, hourCycle flip) can drop the
|
|
622
|
+
// focused segment: move focus to the nearest surviving one, preferring the
|
|
623
|
+
// previous in display order, so it never falls to body.
|
|
624
|
+
const relocateFocus = () => {
|
|
625
|
+
const active = document.activeElement
|
|
626
|
+
if (!(active instanceof HTMLElement) || !active.dataset.segment || !this.contains(active)) return
|
|
627
|
+
const survives = (item: HTMLElement) =>
|
|
628
|
+
fields[sideOf(item)].segments.some(segment => segment.type === unitOf(item))
|
|
629
|
+
if (survives(active)) return
|
|
630
|
+
const surface = surfaceOf(active)
|
|
631
|
+
const list = segmentsOf(surface, false)
|
|
632
|
+
const index = list.indexOf(active)
|
|
633
|
+
const target = list.slice(0, Math.max(index, 0)).reverse().find(survives) ?? list.slice(index + 1).find(survives)
|
|
634
|
+
if (!target) return
|
|
635
|
+
const side = sideOf(target)
|
|
636
|
+
const unit = unitOf(target)
|
|
637
|
+
// After the render pass removes the segment, focus its surviving neighbor.
|
|
638
|
+
queueMicrotask(() => {
|
|
639
|
+
const current = document.activeElement
|
|
640
|
+
if (current instanceof HTMLElement && current !== document.body && this.contains(current)) return
|
|
641
|
+
segmentsOf(surface).find(item => sideOf(item) === side && unitOf(item) === unit)?.focus()
|
|
642
|
+
})
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Keyboard: spin resolves the APG protocol, roving is Left/Right only.
|
|
646
|
+
const stepper = spin(this, {
|
|
647
|
+
onMove: (move, event) => {
|
|
648
|
+
const segment = segmentOf(event)
|
|
649
|
+
if (!segment || readOnly || disabled) return
|
|
650
|
+
const side = sideOf(segment)
|
|
651
|
+
apply(fields[side].spin(unitOf(segment), move), event, segment)
|
|
652
|
+
},
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
const nav = roving(this, {
|
|
656
|
+
items: () => {
|
|
657
|
+
const active = document.activeElement
|
|
658
|
+
return segmentsOf(active instanceof HTMLElement && active.dataset.segment ? surfaceOf(active) : 'field')
|
|
659
|
+
},
|
|
660
|
+
orientation: () => 'horizontal',
|
|
661
|
+
dir: () => domReady && getComputedStyle(this).direction === 'rtl' ? 'rtl' : 'ltr',
|
|
662
|
+
loop: () => false,
|
|
663
|
+
onMove: target => target.focus(),
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
listen(this, 'keydown', event => {
|
|
667
|
+
// Consumed only while open, so an ancestor Dialog does not also close;
|
|
668
|
+
// Escape never clears the value.
|
|
669
|
+
if (event.key === 'Escape') {
|
|
670
|
+
if (!pop.open) return
|
|
671
|
+
event.preventDefault()
|
|
672
|
+
setOpen(false, event)
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
const segment = segmentOf(event)
|
|
676
|
+
if (!segment) return
|
|
677
|
+
const side = sideOf(segment)
|
|
678
|
+
const unit = unitOf(segment)
|
|
679
|
+
// Pipeline contract: Alt+ArrowDown → spin.handle → roving Left/Right.
|
|
680
|
+
// Alt-combos never reach spin: without popup parts composed,
|
|
681
|
+
// Alt+ArrowDown is a pinned no-op, not a step.
|
|
682
|
+
if (event.altKey && event.key.startsWith('Arrow')) {
|
|
683
|
+
if (event.key === 'ArrowDown' && pop.content) {
|
|
684
|
+
event.preventDefault()
|
|
685
|
+
focusMemory.capture(segment)
|
|
686
|
+
setOpen(true, event)
|
|
687
|
+
}
|
|
688
|
+
return
|
|
689
|
+
}
|
|
690
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
|
|
691
|
+
event.preventDefault()
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
// Hardware delete acts here and preventDefaults, so the subsequent
|
|
695
|
+
// beforeinput never double-fires; mobile deletion arrives there instead.
|
|
696
|
+
if (event.key === 'Backspace' || event.key === 'Delete') {
|
|
697
|
+
event.preventDefault()
|
|
698
|
+
if (!readOnly && !disabled) apply(fields[side].erase(unit), event, segment)
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
if (event.key === 'Enter') {
|
|
702
|
+
event.preventDefault()
|
|
703
|
+
moveFocus(segment, 1)
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
if (!readOnly && stepper.handle(event)) return
|
|
707
|
+
if (!readOnly && (event.key === '+' || event.key === '-')) {
|
|
708
|
+
event.preventDefault()
|
|
709
|
+
apply(fields[side].spin(unit, { step: event.key === '+' ? 1 : -1 }), event, segment)
|
|
710
|
+
return
|
|
711
|
+
}
|
|
712
|
+
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') nav.handle(event)
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
listen(this, 'beforeinput', event => {
|
|
716
|
+
const segment = segmentOf(event)
|
|
717
|
+
if (!segment) return
|
|
718
|
+
// Every cancelable edit is ours (insertParagraph and paste included).
|
|
719
|
+
if (event.cancelable) event.preventDefault()
|
|
720
|
+
if (disabled || readOnly) return
|
|
721
|
+
const side = sideOf(segment)
|
|
722
|
+
const unit = unitOf(segment)
|
|
723
|
+
if (event.inputType === 'insertCompositionText') {
|
|
724
|
+
// Uncancelable: snapshot now, restore on input, feed the engine on
|
|
725
|
+
// compositionend so reconciliation and the restore never double-write.
|
|
726
|
+
composing ??= { side, unit, element: segment, text: segment.textContent ?? '' }
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
if (event.inputType === 'deleteContentBackward' || event.inputType === 'deleteContentForward') {
|
|
730
|
+
apply(fields[side].erase(unit), event, segment)
|
|
731
|
+
return
|
|
732
|
+
}
|
|
733
|
+
if (event.inputType === 'insertText' && event.data) {
|
|
734
|
+
for (const key of event.data) apply(fields[side].type(unit, key), event, segment)
|
|
735
|
+
}
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
listen(this, 'input', event => {
|
|
739
|
+
if (!composing) return
|
|
740
|
+
const segment = segmentOf(event)
|
|
741
|
+
if (segment !== composing.element) return
|
|
742
|
+
segment.textContent = composing.text
|
|
743
|
+
this.next()
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
listen(this, 'compositionend', event => {
|
|
747
|
+
if (!composing) return
|
|
748
|
+
const { side, unit, element } = composing
|
|
749
|
+
composing = null
|
|
750
|
+
if (!disabled && !readOnly && event.data) {
|
|
751
|
+
for (const key of event.data) apply(fields[side].type(unit, key), event, element)
|
|
752
|
+
}
|
|
753
|
+
this.next()
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
listen(this, 'paste', event => {
|
|
757
|
+
if (segmentOf(event)) event.preventDefault()
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
listen(this, 'pointerdown', event => {
|
|
761
|
+
if (event.button) return
|
|
762
|
+
const target = event.target instanceof Element ? event.target : null
|
|
763
|
+
if (!target) return
|
|
764
|
+
// Dismiss never sees clicks inside the root (its host containment covers
|
|
765
|
+
// everything): clicking back into the field/chrome/segments while open
|
|
766
|
+
// closes here, and setOpen skips the restore so the click's focus wins.
|
|
767
|
+
if (pop.open && !pop.content?.contains(target) && !pop.trigger?.contains(target)) setOpen(false, event)
|
|
768
|
+
const segment = target.closest<HTMLElement>('[data-segment]')
|
|
769
|
+
if (segment) {
|
|
770
|
+
// Focus is ours to place: never drop a caret into the contenteditable.
|
|
771
|
+
event.preventDefault()
|
|
772
|
+
if (!disabled) segment.focus()
|
|
773
|
+
return
|
|
774
|
+
}
|
|
775
|
+
const group = target.closest<HTMLElement>('[data-slot="input-date-field"]')
|
|
776
|
+
if (!group || !this.contains(group)) return
|
|
777
|
+
// The trigger addon (and any interactive chrome) is exempt.
|
|
778
|
+
if (target.closest('[data-slot="input-date-trigger"], a, button, input, select, textarea')) return
|
|
779
|
+
event.preventDefault()
|
|
780
|
+
if (disabled) return
|
|
781
|
+
const list = Array.from(group.querySelectorAll<HTMLElement>('[data-segment]'))
|
|
782
|
+
const filled = [...list].reverse().find(item => item.dataset.placeholder !== 'true')
|
|
783
|
+
;(filled ?? list[0])?.focus()
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
listen(this, 'focusout', event => {
|
|
787
|
+
const segment = segmentOf(event)
|
|
788
|
+
if (!segment) return
|
|
789
|
+
fields[sideOf(segment)].blur()
|
|
790
|
+
this.next()
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
if (domReady) {
|
|
794
|
+
// Segments never hold a selection: force-collapse anything that forms.
|
|
795
|
+
document.addEventListener('selectionchange', () => {
|
|
796
|
+
const selection = document.getSelection()
|
|
797
|
+
if (!selection || selection.isCollapsed || !selection.anchorNode) return
|
|
798
|
+
const anchor = selection.anchorNode instanceof Element ? selection.anchorNode : selection.anchorNode.parentElement
|
|
799
|
+
if (anchor?.closest('[data-segment]') && this.contains(anchor)) selection.collapseToEnd()
|
|
800
|
+
}, { signal: this.signal })
|
|
801
|
+
|
|
802
|
+
// No segment is labelable, so the label's `for` dangles: click focuses first.
|
|
803
|
+
document.addEventListener('click', event => {
|
|
804
|
+
const label = event.target instanceof Element ? event.target.closest('label') : null
|
|
805
|
+
if (!label || label.getAttribute('for') !== controlId) return
|
|
806
|
+
segmentsOf('field')[0]?.focus()
|
|
807
|
+
}, { signal: this.signal })
|
|
808
|
+
|
|
809
|
+
// type=hidden resets its own value silently; segment state must follow.
|
|
810
|
+
document.addEventListener('reset', event => {
|
|
811
|
+
if (!(event.target instanceof HTMLFormElement) || !event.target.contains(this)) return
|
|
812
|
+
if (valueState.controlled) return
|
|
813
|
+
fields.from.reset()
|
|
814
|
+
if (isRange) fields.to.reset()
|
|
815
|
+
valueState.init(composeValue())
|
|
816
|
+
this.next()
|
|
817
|
+
}, { signal: this.signal })
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
for (const args of this) {
|
|
821
|
+
const kindChanged = args.kind !== kind
|
|
822
|
+
kind = args.kind
|
|
823
|
+
syncAvailability(args)
|
|
824
|
+
allowNonContiguous = Boolean(args.allowNonContiguous)
|
|
825
|
+
closeOnSelect = args.closeOnSelect
|
|
826
|
+
disabled = Boolean(args.disabled)
|
|
827
|
+
isRange = Boolean(args.range)
|
|
828
|
+
locale = resolveLocale(args.locale)
|
|
829
|
+
const emptyLabel = args.emptyLabel ?? 'Empty'
|
|
830
|
+
onOpenChange = args.onOpenChange
|
|
831
|
+
onValueChange = args.onValueChange
|
|
832
|
+
presets = args.presets ?? []
|
|
833
|
+
readOnly = Boolean(args.readOnly)
|
|
834
|
+
required = Boolean(args.required)
|
|
835
|
+
|
|
836
|
+
valueState.sync(args.value)
|
|
837
|
+
const opened = pop.sync(args.open == null ? undefined : Boolean(args.open), {
|
|
838
|
+
placement: args.placement,
|
|
839
|
+
gap: args.gap,
|
|
840
|
+
})
|
|
841
|
+
if (!opened) clearFocusIntent()
|
|
842
|
+
else closingInside = false
|
|
843
|
+
if (kindChanged && args.value === undefined) {
|
|
844
|
+
fields.from.sync(sideDefault(args.defaultValue, 'from') ?? null, optionsFor('from', args))
|
|
845
|
+
fields.to.sync(sideDefault(args.defaultValue, 'to') ?? null, optionsFor('to', args))
|
|
846
|
+
valueState.init(composeValue())
|
|
847
|
+
} else {
|
|
848
|
+
fields.from.sync(sideValue('from'), optionsFor('from', args))
|
|
849
|
+
if (isRange) fields.to.sync(sideValue('to'), optionsFor('to', args))
|
|
850
|
+
}
|
|
851
|
+
if (domReady) relocateFocus()
|
|
852
|
+
|
|
853
|
+
const fieldCtx = FieldContext()
|
|
854
|
+
controlId = fieldCtx?.ids.control ?? `${rootId}-control`
|
|
855
|
+
const externalDescribedby = fieldCtx?.groupAttrs['aria-describedby']
|
|
856
|
+
|
|
857
|
+
const reasonFrom = fields.from.reason()
|
|
858
|
+
const reasonTo = isRange ? fields.to.reason() : null
|
|
859
|
+
const reversed = isRange && isReversed(fields.from.value(), fields.to.value())
|
|
860
|
+
const rangeFrom = isRange ? dateOfValue('from') : undefined
|
|
861
|
+
const rangeTo = isRange ? dateOfValue('to') : undefined
|
|
862
|
+
const unavailableRange = !allowNonContiguous && Boolean(rangeFrom && rangeTo && availability?.crosses(rangeFrom, rangeTo))
|
|
863
|
+
const rangeReason: Reason | null = reversed
|
|
864
|
+
? { code: 'reversed' }
|
|
865
|
+
: unavailableRange
|
|
866
|
+
? { code: 'unavailableRange' }
|
|
867
|
+
: null
|
|
868
|
+
const invalidOf = (side: InputDateSide) => (side === 'from' ? reasonFrom : reasonTo) != null || rangeReason != null
|
|
869
|
+
const hourCycle = fields.from.hourCycle
|
|
870
|
+
const message = fields.from.message()
|
|
871
|
+
?? (isRange ? fields.to.message() : null)
|
|
872
|
+
?? (rangeReason
|
|
873
|
+
? args.errorMessage?.(rangeReason) ?? defaultMessage(rangeReason, { kind, locale, hourCycle })
|
|
874
|
+
: null)
|
|
875
|
+
|
|
876
|
+
const descriptionId = `${rootId}-description`
|
|
877
|
+
const messageId = `${rootId}-message`
|
|
878
|
+
const human = (side: InputDateSide) => {
|
|
879
|
+
const committed = fields[side].value()
|
|
880
|
+
return committed == null ? null : formatValue(committed, { kind, locale, hourCycle })
|
|
881
|
+
}
|
|
882
|
+
const formatted = isRange ? [human('from'), human('to')].filter(Boolean).join(' – ') : human('from')
|
|
883
|
+
const descriptionText = formatted ? `${kind === 'time' ? 'Selected time' : 'Selected date'}: ${formatted}` : null
|
|
884
|
+
|
|
885
|
+
const value = composeValue()
|
|
886
|
+
const firstUnit = fields.from.segments.find(segment => segment.editable)?.type
|
|
887
|
+
|
|
888
|
+
const sideLabel = (side: InputDateSide, override?: string) =>
|
|
889
|
+
override ?? (kind === 'time' ? (side === 'from' ? 'Start time' : 'End time') : side === 'from' ? 'Start date' : 'End date')
|
|
890
|
+
|
|
891
|
+
// describedby rides the first segment only until invalid stamps every one.
|
|
892
|
+
const describedbyOf = (side: InputDateSide, first: boolean): string | undefined => {
|
|
893
|
+
const parts: string[] = []
|
|
894
|
+
if ((first || invalidOf(side)) && typeof externalDescribedby === 'string') parts.push(externalDescribedby)
|
|
895
|
+
if (first && descriptionText) parts.push(descriptionId)
|
|
896
|
+
if (invalidOf(side) && message) parts.push(messageId)
|
|
897
|
+
return parts.join(' ') || undefined
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const valuetextOf = (side: InputDateSide, unit: SegmentUnit): string => {
|
|
901
|
+
const editor = fields[side]
|
|
902
|
+
const current = editor.units[unit]
|
|
903
|
+
if (current == null) return emptyLabel
|
|
904
|
+
if (unit === 'month' && editor.monthNames.length) return `${current} – ${editor.monthNames[current - 1]}`
|
|
905
|
+
if (unit === 'hour' && editor.hourCycle === 'h12' && editor.units.dayPeriod != null) return `${current} ${editor.periods[editor.units.dayPeriod]}`
|
|
906
|
+
return editor.text(unit)
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const groupAttrs = (side: InputDateSide, label?: string, surface: InputDateSurface = 'field'): Record<string, unknown> => {
|
|
910
|
+
const invalid = invalidOf(side)
|
|
911
|
+
const record: Record<string, unknown> = {
|
|
912
|
+
'aria-disabled': flag(disabled),
|
|
913
|
+
'aria-invalid': flag(invalid),
|
|
914
|
+
'data-disabled': flag(disabled),
|
|
915
|
+
'data-invalid': flag(invalid),
|
|
916
|
+
'data-readonly': flag(readOnly),
|
|
917
|
+
'data-side': side,
|
|
918
|
+
'data-slot': surface === 'field' ? 'input-date-field' : 'input-date-time-field',
|
|
919
|
+
'data-surface': surface,
|
|
920
|
+
role: 'group',
|
|
921
|
+
}
|
|
922
|
+
if (surface === 'popover') {
|
|
923
|
+
record['aria-label'] = label
|
|
924
|
+
} else if (isRange) {
|
|
925
|
+
// Each side is its own labelled group; the root carries the field label.
|
|
926
|
+
record['aria-label'] = sideLabel(side, label)
|
|
927
|
+
} else {
|
|
928
|
+
if (label) record['aria-label'] = label
|
|
929
|
+
if (fieldCtx) {
|
|
930
|
+
record['aria-labelledby'] = fieldCtx.groupAttrs['aria-labelledby']
|
|
931
|
+
record['aria-describedby'] = fieldCtx.groupAttrs['aria-describedby']
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
return record
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const segmentAttrs = (side: InputDateSide, segment: Segment, label?: string, surface: InputDateSurface = 'field'): Record<string, unknown> => {
|
|
938
|
+
const unit = segment.type as SegmentUnit
|
|
939
|
+
const editor = fields[side]
|
|
940
|
+
const first = surface === 'field' && side === 'from' && unit === firstUnit
|
|
941
|
+
const filled = editor.units[unit] != null
|
|
942
|
+
const bounds = editor.bounds(unit)
|
|
943
|
+
const unitName = unitLabel(locale, unit)
|
|
944
|
+
const segmentId = first ? controlId : `${rootId}-${surface}-${side}-${unit}`
|
|
945
|
+
// Self-reference technique: aria-labelledby chains the segment itself
|
|
946
|
+
// (contributing its unit-name aria-label) with the field label, so
|
|
947
|
+
// every segment announces "month, Date of birth" — iOS VoiceOver
|
|
948
|
+
// does not announce groups. Range chains the outer label the same way.
|
|
949
|
+
const labelId = fieldCtx?.ids.label
|
|
950
|
+
const surfaceLabel = surface === 'popover'
|
|
951
|
+
? label
|
|
952
|
+
: isRange
|
|
953
|
+
? sideLabel(side, label)
|
|
954
|
+
: label
|
|
955
|
+
const record: Record<string, unknown> = {
|
|
956
|
+
'aria-describedby': describedbyOf(side, first),
|
|
957
|
+
'aria-invalid': flag(invalidOf(side)),
|
|
958
|
+
'aria-label': surfaceLabel ? `${unitName}, ${surfaceLabel}` : unitName,
|
|
959
|
+
'aria-labelledby': surface === 'field' && labelId ? `${segmentId} ${labelId}` : undefined,
|
|
960
|
+
'aria-readonly': readOnly ? 'true' : undefined,
|
|
961
|
+
'aria-required': first && required ? 'true' : undefined,
|
|
962
|
+
autocapitalize: 'off',
|
|
963
|
+
autocorrect: 'off',
|
|
964
|
+
'data-placeholder': flag(!editor.text(unit)),
|
|
965
|
+
'data-side': side,
|
|
966
|
+
'data-segment': unit,
|
|
967
|
+
'data-slot': 'input-date-segment',
|
|
968
|
+
'data-surface': surface,
|
|
969
|
+
enterkeyhint: 'next',
|
|
970
|
+
id: segmentId,
|
|
971
|
+
role: ios ? 'textbox' : 'spinbutton',
|
|
972
|
+
spellcheck: 'false',
|
|
973
|
+
style: 'caret-color:transparent',
|
|
974
|
+
tabindex: disabled ? undefined : '0',
|
|
975
|
+
}
|
|
976
|
+
if (!ios) {
|
|
977
|
+
// dayPeriod carries no numeric value; empty units announce no valuenow.
|
|
978
|
+
if (unit === 'dayPeriod') {
|
|
979
|
+
record['aria-valuetext'] = filled ? editor.text(unit) : emptyLabel
|
|
980
|
+
} else {
|
|
981
|
+
record['aria-valuemin'] = bounds.min
|
|
982
|
+
record['aria-valuemax'] = bounds.max
|
|
983
|
+
if (filled) record['aria-valuenow'] = editor.units[unit]
|
|
984
|
+
record['aria-valuetext'] = valuetextOf(side, unit)
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
// Editability is stamped client-side only: server-rendered contenteditable
|
|
988
|
+
// divs would be freely editable before hydration.
|
|
989
|
+
if (domReady && !disabled && !readOnly) {
|
|
990
|
+
record.contenteditable = 'true'
|
|
991
|
+
if (unit !== 'dayPeriod') record.inputmode = 'numeric'
|
|
992
|
+
}
|
|
993
|
+
return record
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const segmentText = (side: InputDateSide, segment: Segment, surface: InputDateSurface = 'field'): string => {
|
|
997
|
+
const unit = segment.type as SegmentUnit
|
|
998
|
+
// The focused segment holds its snapshot until compositionend.
|
|
999
|
+
if (composing && composing.side === side && composing.unit === unit && surfaceOf(composing.element) === surface) return composing.text
|
|
1000
|
+
return fields[side].text(unit) || segment.placeholder
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
InputDateContext({
|
|
1004
|
+
allowNonContiguous,
|
|
1005
|
+
applyPreset,
|
|
1006
|
+
calendarDisabled,
|
|
1007
|
+
calendarUnavailable: availabilitySource,
|
|
1008
|
+
clear: clearValue,
|
|
1009
|
+
contentId: pop.contentId,
|
|
1010
|
+
contentStyle: pop.contentStyle,
|
|
1011
|
+
daySelected,
|
|
1012
|
+
disabled,
|
|
1013
|
+
field: side => fields[side],
|
|
1014
|
+
groupAttrs,
|
|
1015
|
+
hasValue: value != null,
|
|
1016
|
+
kind,
|
|
1017
|
+
locale,
|
|
1018
|
+
message,
|
|
1019
|
+
monthOf,
|
|
1020
|
+
onMonthChange,
|
|
1021
|
+
open: pop.open,
|
|
1022
|
+
pickDay,
|
|
1023
|
+
pickRange,
|
|
1024
|
+
popup: popupPart,
|
|
1025
|
+
presets,
|
|
1026
|
+
range: isRange,
|
|
1027
|
+
rangeSelected,
|
|
1028
|
+
readOnly,
|
|
1029
|
+
registerTimeSurface: element => timeSurfaces.add(element),
|
|
1030
|
+
segmentAttrs,
|
|
1031
|
+
segmentText,
|
|
1032
|
+
setContent: pop.setContent,
|
|
1033
|
+
setTrigger: pop.setTrigger,
|
|
1034
|
+
timeSurface,
|
|
1035
|
+
toggleOpen,
|
|
1036
|
+
triggerId: pop.triggerId,
|
|
1037
|
+
unregisterTimeSurface: element => timeSurfaces.delete(element),
|
|
1038
|
+
})
|
|
1039
|
+
|
|
1040
|
+
yield (
|
|
1041
|
+
<>
|
|
1042
|
+
{args.name && !isRange
|
|
1043
|
+
? <input data-slot="input-date-hidden" disabled={disabled} name={args.name} set:value={fields.from.value() ?? ''} type="hidden" value={fields.from.value() ?? ''} />
|
|
1044
|
+
: null}
|
|
1045
|
+
{args.name && isRange
|
|
1046
|
+
? <>
|
|
1047
|
+
<input data-slot="input-date-hidden" disabled={disabled} name={`${args.name}[from]`} set:value={fields.from.value() ?? ''} type="hidden" value={fields.from.value() ?? ''} />
|
|
1048
|
+
<input data-slot="input-date-hidden" disabled={disabled} name={`${args.name}[to]`} set:value={fields.to.value() ?? ''} type="hidden" value={fields.to.value() ?? ''} />
|
|
1049
|
+
</>
|
|
1050
|
+
: null}
|
|
1051
|
+
{args.children ?? (
|
|
1052
|
+
<>
|
|
1053
|
+
{isRange ? <><InputDateField side="from" /><InputDateField side="to" /></> : <InputDateField />}
|
|
1054
|
+
{args.calendar && kind !== 'time' ? (
|
|
1055
|
+
<>
|
|
1056
|
+
<InputDateTrigger />
|
|
1057
|
+
<InputDateContent>
|
|
1058
|
+
<InputDateCalendar {...(typeof args.calendar === 'object' ? args.calendar : {})} />
|
|
1059
|
+
{kind === 'datetime'
|
|
1060
|
+
? isRange
|
|
1061
|
+
? <><InputDateTimeField side="from" /><InputDateTimeField side="to" /></>
|
|
1062
|
+
: <InputDateTimeField />
|
|
1063
|
+
: null}
|
|
1064
|
+
{presets.length ? <InputDatePresets /> : null}
|
|
1065
|
+
</InputDateContent>
|
|
1066
|
+
</>
|
|
1067
|
+
) : null}
|
|
1068
|
+
</>
|
|
1069
|
+
)}
|
|
1070
|
+
{descriptionText ? <span data-slot="input-date-description" hidden id={descriptionId}>{descriptionText}</span> : null}
|
|
1071
|
+
{message ? <span data-slot="input-date-message" hidden id={messageId}>{message}</span> : null}
|
|
1072
|
+
</>
|
|
1073
|
+
)
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
/** Segment-based date field; the calendar popover is an optional part. */
|
|
1079
|
+
const InputDate = <Range extends boolean = false>({
|
|
1080
|
+
allowNonContiguous,
|
|
1081
|
+
calendar,
|
|
1082
|
+
children,
|
|
1083
|
+
class: classes,
|
|
1084
|
+
closeOnSelect,
|
|
1085
|
+
defaultOpen,
|
|
1086
|
+
defaultValue,
|
|
1087
|
+
disabled,
|
|
1088
|
+
emptyLabel,
|
|
1089
|
+
errorMessage,
|
|
1090
|
+
gap,
|
|
1091
|
+
locale,
|
|
1092
|
+
max,
|
|
1093
|
+
min,
|
|
1094
|
+
name,
|
|
1095
|
+
onOpenChange,
|
|
1096
|
+
onValueChange,
|
|
1097
|
+
open,
|
|
1098
|
+
placement,
|
|
1099
|
+
placeholderValue,
|
|
1100
|
+
presets,
|
|
1101
|
+
range,
|
|
1102
|
+
readOnly,
|
|
1103
|
+
required,
|
|
1104
|
+
unavailable,
|
|
1105
|
+
value,
|
|
1106
|
+
...attrs
|
|
1107
|
+
}: InputDateArgs<Range>) => {
|
|
1108
|
+
// In range mode the root is the labelled outer group; the sides label themselves.
|
|
1109
|
+
const fieldCtx = FieldContext()
|
|
1110
|
+
|
|
1111
|
+
return (
|
|
1112
|
+
<InputDateRoot
|
|
1113
|
+
{...rootAttrs(attrs as Record<string, unknown>)}
|
|
1114
|
+
{...(range ? { 'attr:role': 'group', 'attr:aria-describedby': fieldCtx?.groupAttrs['aria-describedby'], 'attr:aria-labelledby': fieldCtx?.groupAttrs['aria-labelledby'] } : {})}
|
|
1115
|
+
allowNonContiguous={allowNonContiguous}
|
|
1116
|
+
calendar={calendar}
|
|
1117
|
+
closeOnSelect={closeOnSelect}
|
|
1118
|
+
defaultOpen={defaultOpen}
|
|
1119
|
+
defaultValue={defaultValue as string | InputDateRangeValue | undefined}
|
|
1120
|
+
disabled={disabled}
|
|
1121
|
+
emptyLabel={emptyLabel}
|
|
1122
|
+
errorMessage={errorMessage}
|
|
1123
|
+
gap={gap}
|
|
1124
|
+
kind="date"
|
|
1125
|
+
locale={locale}
|
|
1126
|
+
max={max}
|
|
1127
|
+
min={min}
|
|
1128
|
+
name={name}
|
|
1129
|
+
onOpenChange={onOpenChange}
|
|
1130
|
+
onValueChange={onValueChange as ((value: PublicValue, event?: Event) => void) | undefined}
|
|
1131
|
+
open={open}
|
|
1132
|
+
placement={placement}
|
|
1133
|
+
placeholderValue={placeholderValue}
|
|
1134
|
+
presets={presets as InputDatePreset[] | undefined}
|
|
1135
|
+
range={range}
|
|
1136
|
+
readOnly={readOnly}
|
|
1137
|
+
required={required}
|
|
1138
|
+
unavailable={unavailable}
|
|
1139
|
+
value={value as PublicValue | undefined}
|
|
1140
|
+
attr:class={classes}
|
|
1141
|
+
attr:data-slot="input-date"
|
|
1142
|
+
>
|
|
1143
|
+
{children}
|
|
1144
|
+
</InputDateRoot>
|
|
1145
|
+
)
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** Segment-based time field; canonical 24h values regardless of display cycle. */
|
|
1149
|
+
const InputTime = <Range extends boolean = false>({
|
|
1150
|
+
allowNonContiguous,
|
|
1151
|
+
children,
|
|
1152
|
+
class: classes,
|
|
1153
|
+
defaultValue,
|
|
1154
|
+
disabled,
|
|
1155
|
+
emptyLabel,
|
|
1156
|
+
errorMessage,
|
|
1157
|
+
granularity,
|
|
1158
|
+
hourCycle,
|
|
1159
|
+
locale,
|
|
1160
|
+
max,
|
|
1161
|
+
min,
|
|
1162
|
+
name,
|
|
1163
|
+
onValueChange,
|
|
1164
|
+
placeholderValue,
|
|
1165
|
+
range,
|
|
1166
|
+
readOnly,
|
|
1167
|
+
required,
|
|
1168
|
+
step,
|
|
1169
|
+
unavailable,
|
|
1170
|
+
value,
|
|
1171
|
+
...attrs
|
|
1172
|
+
}: InputTimeArgs<Range>) => {
|
|
1173
|
+
const fieldCtx = FieldContext()
|
|
1174
|
+
|
|
1175
|
+
return (
|
|
1176
|
+
<InputDateRoot
|
|
1177
|
+
{...rootAttrs(attrs as Record<string, unknown>)}
|
|
1178
|
+
{...(range ? { 'attr:role': 'group', 'attr:aria-describedby': fieldCtx?.groupAttrs['aria-describedby'], 'attr:aria-labelledby': fieldCtx?.groupAttrs['aria-labelledby'] } : {})}
|
|
1179
|
+
allowNonContiguous={allowNonContiguous}
|
|
1180
|
+
defaultValue={defaultValue as string | InputDateRangeValue | undefined}
|
|
1181
|
+
disabled={disabled}
|
|
1182
|
+
emptyLabel={emptyLabel}
|
|
1183
|
+
errorMessage={errorMessage}
|
|
1184
|
+
granularity={granularity}
|
|
1185
|
+
hourCycle={hourCycle}
|
|
1186
|
+
kind="time"
|
|
1187
|
+
locale={locale}
|
|
1188
|
+
max={max}
|
|
1189
|
+
min={min}
|
|
1190
|
+
name={name}
|
|
1191
|
+
onValueChange={onValueChange as ((value: PublicValue, event?: Event) => void) | undefined}
|
|
1192
|
+
placeholderValue={placeholderValue}
|
|
1193
|
+
range={range}
|
|
1194
|
+
readOnly={readOnly}
|
|
1195
|
+
required={required}
|
|
1196
|
+
step={step}
|
|
1197
|
+
unavailable={unavailable}
|
|
1198
|
+
value={value as PublicValue | undefined}
|
|
1199
|
+
attr:class={classes}
|
|
1200
|
+
attr:data-slot="input-time"
|
|
1201
|
+
>
|
|
1202
|
+
{children}
|
|
1203
|
+
</InputDateRoot>
|
|
1204
|
+
)
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** Segment-based date-time field; the calendar merges picked days with entered time. */
|
|
1208
|
+
const InputDateTime = <Range extends boolean = false>({
|
|
1209
|
+
allowNonContiguous,
|
|
1210
|
+
calendar,
|
|
1211
|
+
children,
|
|
1212
|
+
class: classes,
|
|
1213
|
+
closeOnSelect,
|
|
1214
|
+
defaultOpen,
|
|
1215
|
+
defaultValue,
|
|
1216
|
+
disabled,
|
|
1217
|
+
emptyLabel,
|
|
1218
|
+
errorMessage,
|
|
1219
|
+
gap,
|
|
1220
|
+
granularity,
|
|
1221
|
+
hourCycle,
|
|
1222
|
+
locale,
|
|
1223
|
+
max,
|
|
1224
|
+
min,
|
|
1225
|
+
name,
|
|
1226
|
+
onOpenChange,
|
|
1227
|
+
onValueChange,
|
|
1228
|
+
open,
|
|
1229
|
+
placement,
|
|
1230
|
+
placeholderValue,
|
|
1231
|
+
presets,
|
|
1232
|
+
range,
|
|
1233
|
+
readOnly,
|
|
1234
|
+
required,
|
|
1235
|
+
step,
|
|
1236
|
+
unavailable,
|
|
1237
|
+
value,
|
|
1238
|
+
...attrs
|
|
1239
|
+
}: InputDateTimeArgs<Range>) => {
|
|
1240
|
+
const fieldCtx = FieldContext()
|
|
1241
|
+
|
|
1242
|
+
return (
|
|
1243
|
+
<InputDateRoot
|
|
1244
|
+
{...rootAttrs(attrs as Record<string, unknown>)}
|
|
1245
|
+
{...(range ? { 'attr:role': 'group', 'attr:aria-describedby': fieldCtx?.groupAttrs['aria-describedby'], 'attr:aria-labelledby': fieldCtx?.groupAttrs['aria-labelledby'] } : {})}
|
|
1246
|
+
allowNonContiguous={allowNonContiguous}
|
|
1247
|
+
calendar={calendar}
|
|
1248
|
+
closeOnSelect={closeOnSelect}
|
|
1249
|
+
defaultOpen={defaultOpen}
|
|
1250
|
+
defaultValue={defaultValue as string | InputDateRangeValue | undefined}
|
|
1251
|
+
disabled={disabled}
|
|
1252
|
+
emptyLabel={emptyLabel}
|
|
1253
|
+
errorMessage={errorMessage}
|
|
1254
|
+
gap={gap}
|
|
1255
|
+
granularity={granularity}
|
|
1256
|
+
hourCycle={hourCycle}
|
|
1257
|
+
kind="datetime"
|
|
1258
|
+
locale={locale}
|
|
1259
|
+
max={max}
|
|
1260
|
+
min={min}
|
|
1261
|
+
name={name}
|
|
1262
|
+
onOpenChange={onOpenChange}
|
|
1263
|
+
onValueChange={onValueChange as ((value: PublicValue, event?: Event) => void) | undefined}
|
|
1264
|
+
open={open}
|
|
1265
|
+
placement={placement}
|
|
1266
|
+
placeholderValue={placeholderValue}
|
|
1267
|
+
presets={presets as InputDatePreset[] | undefined}
|
|
1268
|
+
range={range}
|
|
1269
|
+
readOnly={readOnly}
|
|
1270
|
+
required={required}
|
|
1271
|
+
step={step}
|
|
1272
|
+
unavailable={unavailable}
|
|
1273
|
+
value={value as PublicValue | undefined}
|
|
1274
|
+
attr:class={classes}
|
|
1275
|
+
attr:data-slot="input-datetime"
|
|
1276
|
+
>
|
|
1277
|
+
{children}
|
|
1278
|
+
</InputDateRoot>
|
|
1279
|
+
)
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/** Segmented group surface; renders the derived segments and literals closed. */
|
|
1283
|
+
const InputDateField: Stateless<InputDateFieldArgs> = ({
|
|
1284
|
+
class: classes,
|
|
1285
|
+
label,
|
|
1286
|
+
literalClass,
|
|
1287
|
+
ref,
|
|
1288
|
+
segmentClass,
|
|
1289
|
+
side,
|
|
1290
|
+
...attrs
|
|
1291
|
+
}) => {
|
|
1292
|
+
const ctx = InputDateContext()
|
|
1293
|
+
if (!ctx) throw new Error('InputDateField must be used within an InputDate family root.')
|
|
1294
|
+
if (ctx.range && !side) console.warn('[input-date] <InputDateField side> is required in range mode.')
|
|
1295
|
+
const current: InputDateSide = ctx.range ? side ?? 'from' : 'from'
|
|
1296
|
+
const editor = ctx.field(current)
|
|
1297
|
+
|
|
1298
|
+
return (
|
|
1299
|
+
<div {...attrs} {...ctx.groupAttrs(current, label)} class={classes} ref={ref}>
|
|
1300
|
+
{editor.segments.map((segment, index) => segment.editable ? (
|
|
1301
|
+
// Keyed by unit type: a locale flip reorders through ajo's
|
|
1302
|
+
// focus-preserving keyed path instead of repurposing the focused div.
|
|
1303
|
+
<div key={segment.type} {...ctx.segmentAttrs(current, segment, label)} class={segmentClass}>
|
|
1304
|
+
{ctx.segmentText(current, segment)}
|
|
1305
|
+
</div>
|
|
1306
|
+
) : (
|
|
1307
|
+
<div aria-hidden="true" class={literalClass} data-slot="input-date-literal" key={`literal-${index}`}>
|
|
1308
|
+
{segment.text}
|
|
1309
|
+
</div>
|
|
1310
|
+
))}
|
|
1311
|
+
</div>
|
|
1312
|
+
)
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/** Time run of the bound datetime field, rendered as a second popover surface. */
|
|
1316
|
+
const InputDateTimeField: Stateless<InputDateTimeFieldArgs> = ({
|
|
1317
|
+
class: classes,
|
|
1318
|
+
label,
|
|
1319
|
+
literalClass,
|
|
1320
|
+
ref,
|
|
1321
|
+
segmentClass,
|
|
1322
|
+
side,
|
|
1323
|
+
...attrs
|
|
1324
|
+
}) => {
|
|
1325
|
+
const ctx = InputDateContext()
|
|
1326
|
+
if (!ctx || !ctx.timeSurface('InputDateTimeField')) return null
|
|
1327
|
+
if (ctx.range && !side) console.warn('[input-date] <InputDateTimeField side> is required in range mode.')
|
|
1328
|
+
const current: InputDateSide = ctx.range ? side ?? 'from' : 'from'
|
|
1329
|
+
const resolvedLabel = label ?? (ctx.range ? current === 'from' ? 'Start time' : 'End time' : 'Time')
|
|
1330
|
+
const editor = ctx.field(current)
|
|
1331
|
+
let registered: HTMLDivElement | null = null
|
|
1332
|
+
const reference = (element: HTMLDivElement | null) => {
|
|
1333
|
+
if (registered && registered !== element) ctx.unregisterTimeSurface(registered)
|
|
1334
|
+
if (element && registered !== element) ctx.registerTimeSurface(element)
|
|
1335
|
+
registered = element
|
|
1336
|
+
callRef(ref, element)
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
return (
|
|
1340
|
+
<div {...attrs} {...ctx.groupAttrs(current, resolvedLabel, 'popover')} class={classes} ref={reference}>
|
|
1341
|
+
{timeRun(editor.segments).map((segment, index) => segment.editable ? (
|
|
1342
|
+
<div key={segment.type} {...ctx.segmentAttrs(current, segment, resolvedLabel, 'popover')} class={segmentClass}>
|
|
1343
|
+
{ctx.segmentText(current, segment, 'popover')}
|
|
1344
|
+
</div>
|
|
1345
|
+
) : (
|
|
1346
|
+
<div aria-hidden="true" class={literalClass} data-slot="input-date-time-literal" key={`literal-${index}`}>
|
|
1347
|
+
{segment.text}
|
|
1348
|
+
</div>
|
|
1349
|
+
))}
|
|
1350
|
+
</div>
|
|
1351
|
+
)
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/** Optional calendar button; the popover still anchors to the field group. */
|
|
1355
|
+
const InputDateTrigger: Stateless<InputDateTriggerArgs> = ({
|
|
1356
|
+
children,
|
|
1357
|
+
class: classes,
|
|
1358
|
+
disabled,
|
|
1359
|
+
iconClass,
|
|
1360
|
+
id: idArg,
|
|
1361
|
+
ref,
|
|
1362
|
+
type = 'button',
|
|
1363
|
+
'set:onclick': onClick,
|
|
1364
|
+
...attrs
|
|
1365
|
+
}) => {
|
|
1366
|
+
const ctx = InputDateContext()
|
|
1367
|
+
if (ctx && !ctx.popup('InputDateTrigger')) return null
|
|
1368
|
+
const disabledFlag = Boolean(disabled ?? ctx?.disabled)
|
|
1369
|
+
|
|
1370
|
+
return (
|
|
1371
|
+
<button
|
|
1372
|
+
{...attrs}
|
|
1373
|
+
{...triggerAttrs({
|
|
1374
|
+
controls: ctx?.contentId,
|
|
1375
|
+
expanded: Boolean(ctx?.open),
|
|
1376
|
+
haspopup: 'dialog',
|
|
1377
|
+
id: idArg,
|
|
1378
|
+
open: Boolean(ctx?.open),
|
|
1379
|
+
ref,
|
|
1380
|
+
setTrigger: ctx?.setTrigger,
|
|
1381
|
+
triggerId: ctx?.triggerId,
|
|
1382
|
+
})}
|
|
1383
|
+
aria-label={attrs['aria-label'] ?? 'Show calendar'}
|
|
1384
|
+
class={classes}
|
|
1385
|
+
data-slot="input-date-trigger"
|
|
1386
|
+
disabled={disabledFlag}
|
|
1387
|
+
set:onclick={(event: Event) => {
|
|
1388
|
+
callHandler(onClick, event)
|
|
1389
|
+
if (event.defaultPrevented) return
|
|
1390
|
+
ctx?.toggleOpen(event)
|
|
1391
|
+
}}
|
|
1392
|
+
type={type}
|
|
1393
|
+
>
|
|
1394
|
+
{children ?? <span aria-hidden="true" class={iconClass} data-slot="input-date-trigger-icon" />}
|
|
1395
|
+
</button>
|
|
1396
|
+
)
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/** Popover panel for the calendar; a dialog anchored to the field group. */
|
|
1400
|
+
const InputDateContent: Stateless<InputDateContentArgs> = ({ children, class: classes, ref, style, ...attrs }) => {
|
|
1401
|
+
const ctx = InputDateContext()
|
|
1402
|
+
if (ctx && !ctx.popup('InputDateContent')) return null
|
|
1403
|
+
|
|
1404
|
+
return (
|
|
1405
|
+
<div
|
|
1406
|
+
{...attrs}
|
|
1407
|
+
{...contentAttrs({
|
|
1408
|
+
id: ctx?.contentId,
|
|
1409
|
+
open: Boolean(ctx?.open),
|
|
1410
|
+
ref,
|
|
1411
|
+
setContent: ctx?.setContent,
|
|
1412
|
+
style: ctx?.contentStyle(style),
|
|
1413
|
+
tabindex: '-1',
|
|
1414
|
+
})}
|
|
1415
|
+
aria-label={attrs['aria-label'] ?? 'Calendar'}
|
|
1416
|
+
class={classes}
|
|
1417
|
+
data-slot="input-date-content"
|
|
1418
|
+
role="dialog"
|
|
1419
|
+
>
|
|
1420
|
+
{children}
|
|
1421
|
+
</div>
|
|
1422
|
+
)
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/** Calendar wired to the field: picked days fill the date units and commit. */
|
|
1426
|
+
const InputDateCalendar: Stateless<InputDateCalendarArgs> = ({ component, ...attrs }) => {
|
|
1427
|
+
const ctx = InputDateContext()
|
|
1428
|
+
const CurrentCalendar = component ?? Calendar
|
|
1429
|
+
if (!ctx) return <CurrentCalendar {...attrs as CalendarArgs} />
|
|
1430
|
+
if (!ctx.popup('InputDateCalendar')) return null
|
|
1431
|
+
|
|
1432
|
+
const common = {
|
|
1433
|
+
...attrs,
|
|
1434
|
+
allowNonContiguous: ctx.allowNonContiguous,
|
|
1435
|
+
disabled: ctx.calendarDisabled(attrs.disabled),
|
|
1436
|
+
locale: attrs.locale ?? ctx.locale,
|
|
1437
|
+
month: attrs.month ?? ctx.monthOf(),
|
|
1438
|
+
onMonthChange: (month: Date, event?: Event) => {
|
|
1439
|
+
attrs.onMonthChange?.(month, event)
|
|
1440
|
+
ctx.onMonthChange(month, event)
|
|
1441
|
+
},
|
|
1442
|
+
unavailable: ctx.calendarUnavailable,
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
if (ctx.range) {
|
|
1446
|
+
return (
|
|
1447
|
+
<CurrentCalendar {...{
|
|
1448
|
+
...common,
|
|
1449
|
+
mode: 'range',
|
|
1450
|
+
selected: ctx.rangeSelected(),
|
|
1451
|
+
onSelect: (next: CalendarDateRange | null, event: Event) => ctx.pickRange(next, event),
|
|
1452
|
+
} as CalendarArgs} />
|
|
1453
|
+
)
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
return (
|
|
1457
|
+
<CurrentCalendar {...{
|
|
1458
|
+
...common,
|
|
1459
|
+
mode: 'single',
|
|
1460
|
+
selected: ctx.daySelected(),
|
|
1461
|
+
onSelect: (next: Date | null, event: Event) => ctx.pickDay(next, event),
|
|
1462
|
+
} as CalendarArgs} />
|
|
1463
|
+
)
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/** Clear button; renders only while a value exists and emits null. */
|
|
1467
|
+
const InputDateClear: Stateless<InputDateClearArgs> = ({
|
|
1468
|
+
children,
|
|
1469
|
+
class: classes,
|
|
1470
|
+
disabled,
|
|
1471
|
+
iconClass,
|
|
1472
|
+
type = 'button',
|
|
1473
|
+
'set:onclick': onClick,
|
|
1474
|
+
...attrs
|
|
1475
|
+
}) => {
|
|
1476
|
+
const ctx = InputDateContext()
|
|
1477
|
+
if (ctx && !ctx.hasValue) return null
|
|
1478
|
+
const disabledFlag = Boolean(disabled ?? ctx?.disabled)
|
|
1479
|
+
|
|
1480
|
+
return (
|
|
1481
|
+
<button
|
|
1482
|
+
{...attrs}
|
|
1483
|
+
aria-label={attrs['aria-label'] ?? 'Clear'}
|
|
1484
|
+
class={classes}
|
|
1485
|
+
data-slot="input-date-clear"
|
|
1486
|
+
disabled={disabledFlag}
|
|
1487
|
+
set:onclick={(event: Event) => {
|
|
1488
|
+
callHandler(onClick, event)
|
|
1489
|
+
if (event.defaultPrevented) return
|
|
1490
|
+
ctx?.clear(event)
|
|
1491
|
+
}}
|
|
1492
|
+
type={type}
|
|
1493
|
+
>
|
|
1494
|
+
{children ?? <span aria-hidden="true" class={iconClass} data-slot="input-date-clear-icon" />}
|
|
1495
|
+
</button>
|
|
1496
|
+
)
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
/** Preset value buttons for the popover; a pick commits and closes. */
|
|
1500
|
+
const InputDatePresets: Stateless<InputDatePresetsArgs> = ({
|
|
1501
|
+
buttonClass,
|
|
1502
|
+
children,
|
|
1503
|
+
class: classes,
|
|
1504
|
+
...attrs
|
|
1505
|
+
}) => {
|
|
1506
|
+
const ctx = InputDateContext()
|
|
1507
|
+
if (!ctx || !ctx.popup('InputDatePresets')) return null
|
|
1508
|
+
if (!children && !ctx.presets.length) return null
|
|
1509
|
+
|
|
1510
|
+
return (
|
|
1511
|
+
<div {...attrs} class={classes} data-slot="input-date-presets">
|
|
1512
|
+
{children ?? ctx.presets.map(preset => (
|
|
1513
|
+
<button
|
|
1514
|
+
class={buttonClass}
|
|
1515
|
+
data-slot="input-date-preset"
|
|
1516
|
+
disabled={ctx.disabled}
|
|
1517
|
+
key={preset.label}
|
|
1518
|
+
set:onclick={(event: Event) => ctx.applyPreset(preset.value, event)}
|
|
1519
|
+
type="button"
|
|
1520
|
+
>
|
|
1521
|
+
{preset.label}
|
|
1522
|
+
</button>
|
|
1523
|
+
))}
|
|
1524
|
+
</div>
|
|
1525
|
+
)
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
export {
|
|
1529
|
+
InputDate,
|
|
1530
|
+
InputDateCalendar,
|
|
1531
|
+
InputDateClear,
|
|
1532
|
+
InputDateContent,
|
|
1533
|
+
InputDateField,
|
|
1534
|
+
InputDatePresets,
|
|
1535
|
+
InputDateTime,
|
|
1536
|
+
InputDateTimeField,
|
|
1537
|
+
InputDateTrigger,
|
|
1538
|
+
InputTime,
|
|
1539
|
+
}
|