@proyecto-viviana/solid-stately 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/calendar/IncompleteDate.d.ts +61 -0
  2. package/dist/calendar/createCalendarState.d.ts +20 -10
  3. package/dist/calendar/createDateFieldState.d.ts +65 -20
  4. package/dist/calendar/createRangeCalendarState.d.ts +20 -2
  5. package/dist/calendar/createTimeFieldState.d.ts +38 -67
  6. package/dist/calendar/index.d.ts +1 -1
  7. package/dist/calendar/intl/index.d.ts +10 -0
  8. package/dist/collections/ListCollection.d.ts +8 -0
  9. package/dist/collections/createListState.d.ts +16 -1
  10. package/dist/collections/createSelectionState.d.ts +17 -5
  11. package/dist/collections/getChildNodes.d.ts +19 -0
  12. package/dist/collections/index.d.ts +3 -2
  13. package/dist/collections/types.d.ts +9 -0
  14. package/dist/color/intl/index.d.ts +14 -0
  15. package/dist/combobox/createComboBoxState.d.ts +7 -0
  16. package/dist/dnd/createDroppableCollectionState.d.ts +17 -2
  17. package/dist/dnd/index.d.ts +1 -1
  18. package/dist/dnd/types.d.ts +3 -1
  19. package/dist/grid/types.d.ts +17 -3
  20. package/dist/index.d.ts +6 -6
  21. package/dist/index.js +4372 -1087
  22. package/dist/index.js.map +1 -1
  23. package/dist/select/createSelectState.d.ts +6 -0
  24. package/dist/selection/Selection.d.ts +16 -0
  25. package/dist/selection/SelectionManager.d.ts +119 -0
  26. package/dist/selection/createMultipleSelectionState.d.ts +59 -0
  27. package/dist/selection/index.d.ts +1 -0
  28. package/dist/table/TableCollection.d.ts +30 -0
  29. package/dist/table/createTreeGridState.d.ts +15 -0
  30. package/dist/table/index.d.ts +2 -1
  31. package/dist/table/types.d.ts +58 -0
  32. package/dist/toast/createToastState.d.ts +16 -0
  33. package/dist/toast/index.d.ts +1 -1
  34. package/dist/tree/types.d.ts +14 -6
  35. package/package.json +1 -1
  36. package/src/calendar/IncompleteDate.ts +430 -0
  37. package/src/calendar/createCalendarState.ts +129 -40
  38. package/src/calendar/createDateFieldState.ts +633 -477
  39. package/src/calendar/createRangeCalendarState.ts +343 -30
  40. package/src/calendar/createTimeFieldState.ts +179 -467
  41. package/src/calendar/index.ts +2 -0
  42. package/src/calendar/intl/index.ts +364 -0
  43. package/src/collections/ListCollection.ts +31 -0
  44. package/src/collections/createListState.ts +139 -17
  45. package/src/collections/createMenuState.ts +3 -0
  46. package/src/collections/createSelectionState.ts +38 -28
  47. package/src/collections/getChildNodes.ts +129 -0
  48. package/src/collections/index.ts +12 -0
  49. package/src/collections/types.ts +10 -0
  50. package/src/color/Color.ts +104 -133
  51. package/src/color/intl/index.ts +1409 -0
  52. package/src/combobox/createComboBoxState.ts +38 -13
  53. package/src/dnd/createDroppableCollectionState.ts +125 -6
  54. package/src/dnd/index.d.ts +1 -0
  55. package/src/dnd/index.ts +1 -0
  56. package/src/dnd/types.d.ts +3 -1
  57. package/src/dnd/types.ts +4 -1
  58. package/src/grid/createGridState.ts +36 -2
  59. package/src/grid/types.ts +17 -3
  60. package/src/index.d.ts +1 -0
  61. package/src/index.ts +14 -0
  62. package/src/select/createSelectState.ts +15 -2
  63. package/src/selection/Selection.ts +28 -0
  64. package/src/selection/SelectionManager.ts +541 -0
  65. package/src/selection/createMultipleSelectionState.ts +247 -0
  66. package/src/selection/index.ts +6 -0
  67. package/src/steplist/createStepListState.ts +27 -4
  68. package/src/table/TableCollection.ts +213 -43
  69. package/src/table/createTableState.ts +20 -0
  70. package/src/table/createTreeGridState.ts +146 -0
  71. package/src/table/index.ts +3 -0
  72. package/src/table/types.ts +66 -0
  73. package/src/tabs/createTabListState.ts +11 -9
  74. package/src/toast/createToastState.ts +43 -10
  75. package/src/toast/index.ts +1 -0
  76. package/src/tree/createTreeState.ts +37 -6
  77. package/src/tree/types.ts +14 -6
@@ -2,26 +2,43 @@
2
2
  * DateFieldState for Solid-Stately
3
3
  *
4
4
  * Provides state management for date field components with segment-based editing.
5
- * Based on @react-stately/datepicker useDateFieldState
5
+ * Faithful port of @react-stately/datepicker useDateFieldState.
6
+ *
7
+ * Editing is modeled with an {@link IncompleteDate} display value so that invalid
8
+ * intermediate dates (e.g. February 31st, produced by editing the day before the
9
+ * month) are held as-typed while the field is focused and only constrained on blur
10
+ * via `confirmPlaceholder`, matching upstream behavior. Out-of-range values are
11
+ * never snapped to `minValue`/`maxValue`; they are kept and reported as invalid.
12
+ *
13
+ * The upstream "derived state during render" resync (which re-mints the display
14
+ * value when the committed value / calendar / hour cycle changes) is ported to a
15
+ * Solid `createComputed` so it runs synchronously before render effects — mirroring
16
+ * React's render-phase setState rather than a post-render `createEffect`.
6
17
  */
7
18
 
8
- import { createSignal, createMemo, type Accessor } from "solid-js";
19
+ import { createSignal, createMemo, createComputed, untrack, type Accessor } from "solid-js";
9
20
  import {
10
- CalendarDate,
21
+ type Calendar,
11
22
  type CalendarDateTime,
23
+ type CalendarIdentifier,
12
24
  type DateValue,
13
- today,
14
- getLocalTimeZone,
25
+ GregorianCalendar,
15
26
  DateFormatter,
16
- toCalendarDate as intlToCalendarDate,
27
+ Time,
28
+ now,
29
+ getLocalTimeZone,
30
+ toCalendar,
31
+ toCalendarDate,
17
32
  toCalendarDateTime,
18
- endOfMonth,
33
+ isEqualCalendar,
34
+ createCalendar as intlCreateCalendar,
19
35
  } from "@internationalized/date";
20
36
  import { access, type MaybeAccessor } from "../utils";
21
37
  import type { ValidationState } from "./createCalendarState";
38
+ import { IncompleteDate } from "./IncompleteDate";
39
+ import { formatDateFieldError, getPlaceholder, type DateFieldErrorKey } from "./intl";
22
40
  import {
23
41
  createFormValidationState,
24
- DEFAULT_VALIDATION_RESULT,
25
42
  VALID_VALIDITY_STATE,
26
43
  type ValidationBehavior,
27
44
  type ValidationFunction,
@@ -41,13 +58,16 @@ export type DateSegmentType =
41
58
  | "timeZoneName"
42
59
  | "literal";
43
60
 
61
+ type Granularity = "year" | "month" | "day" | "hour" | "minute" | "second";
62
+ type FieldGranularity = "day" | "hour" | "minute" | "second";
63
+
44
64
  export interface DateSegment {
45
65
  /** The type of segment. */
46
66
  type: DateSegmentType;
47
67
  /** The text content of the segment. */
48
68
  text: string;
49
69
  /** The numeric value of the segment (if applicable). */
50
- value?: number;
70
+ value?: number | null;
51
71
  /** The minimum value for the segment. */
52
72
  minValue?: number;
53
73
  /** The maximum value for the segment. */
@@ -82,7 +102,9 @@ export interface DateFieldStateProps<T extends DateValue = DateValue> {
82
102
  /** The locale to use for formatting. */
83
103
  locale?: string;
84
104
  /** The granularity of the date/time (day, hour, minute, second). */
85
- granularity?: "day" | "hour" | "minute" | "second";
105
+ granularity?: FieldGranularity;
106
+ /** The maximum granularity (largest editable unit). Defaults to `year`. */
107
+ maxGranularity?: "year" | "month" | "day" | "hour" | "minute";
86
108
  /** Whether to show the hour in 12 or 24 hour format. */
87
109
  hourCycle?: 12 | 24;
88
110
  /** Whether to hide the time zone. */
@@ -103,29 +125,43 @@ export interface DateFieldStateProps<T extends DateValue = DateValue> {
103
125
  errorMessage?: string;
104
126
  /** Whether dates outside the min/max range are allowed. */
105
127
  allowsNonContiguousRanges?: boolean;
106
- /** Whether to create a date or datetime. */
107
- createCalendar?: (name: string) => unknown;
128
+ /** Resolves a calendar system by its Unicode identifier. */
129
+ createCalendar?: (name: CalendarIdentifier) => Calendar;
108
130
  /** Whether a date is unavailable. */
109
131
  isDateUnavailable?: (date: DateValue) => boolean;
110
132
  }
111
133
 
112
134
  export interface DateFieldState<T extends DateValue = DateValue> {
113
- /** The current value. */
114
- value: Accessor<T | null>;
115
- /** The date value (may be partial during editing). */
116
- dateValue: Accessor<DateValue | null>;
135
+ /** The current value, converted to the display calendar. */
136
+ value: Accessor<DateValue | null>;
137
+ /** The current value as a native JavaScript Date (may be partial during editing). */
138
+ dateValue: Accessor<Date>;
139
+ /** The display calendar system. */
140
+ readonly calendar: Calendar;
117
141
  /** Sets the date value. */
118
142
  setValue: (value: T | null) => void;
119
143
  /** The segments that make up the date. */
120
144
  segments: Accessor<DateSegment[]>;
121
- /** The format string. */
145
+ /** The formatter used to render the field. */
146
+ readonly dateFormatter: DateFormatter;
147
+ /** Formats the current value with the given field options. */
122
148
  formatValue: (fieldOptions?: Intl.DateTimeFormatOptions) => string;
149
+ /** Returns a formatter with the field's resolved options merged with the given ones. */
150
+ getDateFormatter: (locale: string, formatOptions: FormatterOptions) => DateFormatter;
123
151
  /** Sets a segment value. */
124
152
  setSegment: (type: DateSegmentType, value: number) => void;
125
- /** Increments a segment. */
126
- incrementSegment: (type: DateSegmentType) => void;
127
- /** Decrements a segment. */
128
- decrementSegment: (type: DateSegmentType) => void;
153
+ /** Increments a segment by one. */
154
+ increment: (type: DateSegmentType) => void;
155
+ /** Decrements a segment by one. */
156
+ decrement: (type: DateSegmentType) => void;
157
+ /** Increments a segment by a larger page step. */
158
+ incrementPage: (type: DateSegmentType) => void;
159
+ /** Decrements a segment by a larger page step. */
160
+ decrementPage: (type: DateSegmentType) => void;
161
+ /** Sets a segment to its maximum value. */
162
+ incrementToMax: (type: DateSegmentType) => void;
163
+ /** Sets a segment to its minimum value. */
164
+ decrementToMin: (type: DateSegmentType) => void;
129
165
  /** Clears a segment. */
130
166
  clearSegment: (type: DateSegmentType) => void;
131
167
  /** Confirms the value (after typing). */
@@ -139,7 +175,9 @@ export interface DateFieldState<T extends DateValue = DateValue> {
139
175
  /** The validation state. */
140
176
  validationState: Accessor<ValidationState | undefined>;
141
177
  /** The granularity. */
142
- granularity: "day" | "hour" | "minute" | "second";
178
+ readonly granularity: FieldGranularity;
179
+ /** The maximum granularity. */
180
+ readonly maxGranularity: "year" | "month" | "day" | "hour" | "minute";
143
181
  /** Whether the value is invalid. */
144
182
  isInvalid: Accessor<boolean>;
145
183
  /** Realtime validation results, including native and custom constraints. */
@@ -153,115 +191,279 @@ export interface DateFieldState<T extends DateValue = DateValue> {
153
191
  /** Commits realtime validation to displayed validation. */
154
192
  commitValidation: () => void;
155
193
  /** The locale. */
156
- locale: string;
194
+ readonly locale: string;
157
195
  /** The time zone. */
158
- timeZone: string;
196
+ readonly timeZone: string;
159
197
  /** The default value. */
160
- defaultValue: T | null;
198
+ defaultValue: DateValue | null;
161
199
  }
162
200
 
201
+ type HourCycle = "h11" | "h12" | "h23" | "h24";
202
+
203
+ const EDITABLE_SEGMENTS: Record<string, boolean> = {
204
+ year: true,
205
+ month: true,
206
+ day: true,
207
+ hour: true,
208
+ minute: true,
209
+ second: true,
210
+ dayPeriod: true,
211
+ era: true,
212
+ };
213
+
214
+ const PAGE_STEP: Record<string, number> = {
215
+ year: 5,
216
+ month: 2,
217
+ day: 7,
218
+ hour: 2,
219
+ minute: 15,
220
+ second: 15,
221
+ };
222
+
223
+ const TYPE_MAPPING: Record<string, string> = {
224
+ // Node seems to convert everything to lowercase...
225
+ dayperiod: "dayPeriod",
226
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/formatToParts#named_years
227
+ relatedYear: "year",
228
+ yearName: "literal",
229
+ unknown: "literal",
230
+ };
231
+
163
232
  /**
164
233
  * Provides state management for a date field component.
165
234
  */
166
- export function createDateFieldState<T extends DateValue = CalendarDate>(
235
+ export function createDateFieldState<T extends DateValue = DateValue>(
167
236
  props: DateFieldStateProps<T> = {},
168
237
  ): DateFieldState<T> {
169
- const timeZone = getLocalTimeZone();
170
- const locale = props.locale ?? "en-US";
171
- const granularity = props.granularity ?? "day";
172
-
173
- // State signals
174
- const initialValue = props.defaultValue ?? null;
175
- const [internalValue, setInternalValue] = createSignal<T | null>(initialValue);
176
-
177
- // Track partial values during editing
178
- const [placeholderDate, setPlaceholderDate] = createSignal<Partial<DateParts>>({});
238
+ const locale = (): string => props.locale ?? "en-US";
239
+ const createCalendarFn = (name: CalendarIdentifier): Calendar =>
240
+ (props.createCalendar ?? intlCreateCalendar)(name);
241
+
242
+ // The initial resolved value (used only for its calendar identity and defaults).
243
+ const v0 = (): DateValue | null =>
244
+ access(props.value) ?? props.defaultValue ?? props.placeholderValue ?? null;
245
+
246
+ // Compute default granularity and time zone from the value, keeping the last
247
+ // values when the value becomes null (mirrors useDefaultProps).
248
+ const initialDefaults = untrack(() => {
249
+ const v = v0();
250
+ return {
251
+ granularity: (v && "minute" in v ? "minute" : "day") as Granularity,
252
+ timeZone: v && "timeZone" in v ? (v as { timeZone: string }).timeZone : undefined,
253
+ };
254
+ });
255
+ const [lastDefaults, setLastDefaults] = createSignal<{
256
+ granularity: Granularity;
257
+ timeZone: string | undefined;
258
+ }>(initialDefaults);
259
+ createComputed(() => {
260
+ const v = v0();
261
+ if (!v) return;
262
+ const defaultGranularity = ("minute" in v ? "minute" : "day") as Granularity;
263
+ const defaultTimeZone = "timeZone" in v ? (v as { timeZone: string }).timeZone : undefined;
264
+ const last = untrack(lastDefaults);
265
+ if (last.granularity !== defaultGranularity || last.timeZone !== defaultTimeZone) {
266
+ setLastDefaults({ granularity: defaultGranularity, timeZone: defaultTimeZone });
267
+ }
268
+ });
269
+ const granularity = (): FieldGranularity => {
270
+ if (props.granularity) return props.granularity;
271
+ const v = v0();
272
+ if (v) return ("minute" in v ? "minute" : "day") as FieldGranularity;
273
+ return lastDefaults().granularity as FieldGranularity;
274
+ };
275
+ const defaultTimeZone = (): string | undefined => {
276
+ const v = v0();
277
+ return v
278
+ ? "timeZone" in v
279
+ ? (v as { timeZone: string }).timeZone
280
+ : undefined
281
+ : lastDefaults().timeZone;
282
+ };
283
+ const timeZone = (): string => defaultTimeZone() || "UTC";
284
+
285
+ // Resolve default hour cycle and calendar system.
286
+ const calendarAndHourCycle = createMemo<[Calendar, HourCycle]>(() => {
287
+ const formatter = new DateFormatter(locale(), {
288
+ dateStyle: "short",
289
+ timeStyle: "short",
290
+ hour12: props.hourCycle != null ? props.hourCycle === 12 : undefined,
291
+ });
292
+ const opts = formatter.resolvedOptions();
293
+ return [createCalendarFn(opts.calendar as CalendarIdentifier), opts.hourCycle as HourCycle];
294
+ });
295
+ const calendar = (): Calendar => calendarAndHourCycle()[0];
296
+ const hourCycle = (): HourCycle => calendarAndHourCycle()[1];
179
297
 
180
- // Controlled vs uncontrolled value
181
- const value = createMemo<T | null>(() => {
298
+ // Controlled vs uncontrolled committed value.
299
+ const [internalValue, setInternalValue] = createSignal<T | null>(props.defaultValue ?? null);
300
+ const rawValue = createMemo<T | null>(() => {
182
301
  const controlled = access(props.value);
183
302
  return controlled !== undefined ? controlled : internalValue();
184
303
  });
304
+ const setDate = (v: T | null) => {
305
+ if (access(props.value) === undefined) setInternalValue(() => v);
306
+ props.onChange?.(v);
307
+ };
185
308
 
186
- // The effective date value
187
- const dateValue = createMemo<DateValue | null>(() => {
188
- const v = value();
189
- if (v) return v;
190
-
191
- // Build from placeholder parts
192
- const parts = placeholderDate();
193
- const placeholder = props.placeholderValue ?? today(timeZone);
309
+ // The current value, converted to the display calendar.
310
+ const calendarValue = createMemo<DateValue | null>(() => convertValue(rawValue(), calendar()) ?? null);
194
311
 
195
- if (Object.keys(parts).length === 0) return null;
312
+ const initialValue = untrack(calendarValue);
196
313
 
197
- // Create a date from the parts
198
- const year = parts.year ?? placeholder.year;
199
- const month = parts.month ?? placeholder.month;
200
- const day = parts.day ?? placeholder.day;
314
+ // The display override: holds the (possibly invalid/incomplete) value being edited.
315
+ const [displayValue, setDisplayValue] = createSignal<IncompleteDate>(
316
+ untrack(() => new IncompleteDate(calendar(), hourCycle(), untrack(calendarValue))),
317
+ );
201
318
 
202
- if (granularity === "day") {
203
- return intlToCalendarDate(placeholder).set({ year, month, day });
319
+ // Render-phase resync: whenever the committed value, calendar, or hour cycle changes
320
+ // from the outside, re-mint the display override. Ported to createComputed so it runs
321
+ // synchronously (like React's render-phase setState), never leaving a stale frame.
322
+ let lastValue = untrack(calendarValue);
323
+ let lastCalendar = untrack(calendar);
324
+ let lastHourCycle = untrack(hourCycle);
325
+ createComputed(() => {
326
+ const cv = calendarValue();
327
+ const cal = calendar();
328
+ const hc = hourCycle();
329
+ if (cv !== lastValue || hc !== lastHourCycle || !isEqualCalendar(cal, lastCalendar)) {
330
+ lastValue = cv;
331
+ lastCalendar = cal;
332
+ lastHourCycle = hc;
333
+ setDisplayValue(new IncompleteDate(cal, hc, cv));
204
334
  }
335
+ });
336
+
337
+ const showEra = (): boolean => calendar().identifier === "gregory" && displayValue().era === "BC";
338
+ const maxGranularity = (): "year" | "month" | "day" | "hour" | "minute" =>
339
+ props.maxGranularity ?? "year";
340
+
341
+ const placeholderDate = createMemo<DateValue>(() =>
342
+ createPlaceholderDate(props.placeholderValue, granularity(), calendar(), defaultTimeZone()),
343
+ );
344
+
345
+ const formatOpts = createMemo<FormatterOptions>(() => ({
346
+ granularity: granularity(),
347
+ maxGranularity: maxGranularity(),
348
+ timeZone: defaultTimeZone(),
349
+ hideTimeZone: props.hideTimeZone,
350
+ hourCycle: props.hourCycle,
351
+ showEra: showEra(),
352
+ shouldForceLeadingZeros: props.shouldForceLeadingZeros,
353
+ }));
354
+
355
+ const dateFormatter = createMemo(() => new DateFormatter(locale(), getFormatOptions({}, formatOpts())));
356
+ const resolvedOptions = createMemo(() => dateFormatter().resolvedOptions());
357
+
358
+ const displaySegments = createMemo<DateSegmentType[]>(() => {
359
+ const hc = hourCycle();
360
+ const is12HourClock = hc === "h11" || hc === "h12";
361
+ const segments: DateSegmentType[] = [
362
+ "era",
363
+ "year",
364
+ "month",
365
+ "day",
366
+ "hour",
367
+ ...(is12HourClock ? (["dayPeriod"] as const) : []),
368
+ "minute",
369
+ "second",
370
+ ];
371
+ const minIndex = segments.indexOf((maxGranularity() as DateSegmentType) || "era");
372
+ const maxIndex = segments.indexOf(
373
+ granularity() === "hour" && is12HourClock ? "dayPeriod" : granularity(),
374
+ );
375
+ return segments.slice(minIndex, maxIndex + 1);
376
+ });
205
377
 
206
- // For time granularities
207
- const hour = parts.hour ?? 0;
208
- const minute = parts.minute ?? 0;
209
- const second = parts.second ?? 0;
210
-
211
- return toCalendarDateTime(placeholder).set({
212
- year,
213
- month,
214
- day,
215
- hour,
216
- minute,
217
- second,
218
- }) as unknown as T;
378
+ // The effective date value as a native Date, derived from the display override.
379
+ const dateValue = createMemo<Date>(() => {
380
+ const v = displayValue().toValue(calendarValue() ?? placeholderDate());
381
+ return v.toDate(timeZone());
219
382
  });
220
383
 
221
- // Derived states
384
+ const segments = createMemo<DateSegment[]>(() =>
385
+ processSegments(
386
+ dateValue(),
387
+ displayValue(),
388
+ dateFormatter(),
389
+ resolvedOptions() as unknown as Record<string, string>,
390
+ calendar(),
391
+ locale(),
392
+ granularity(),
393
+ ),
394
+ );
395
+
396
+ // Set value with the upstream split: complete + valid commits eagerly; an incomplete
397
+ // or invalid (e.g. Feb 31) value is held as a display override until blur.
398
+ const setValue = (newValue: T | IncompleteDate | null) => {
399
+ if (access(props.isDisabled) || access(props.isReadOnly)) return;
400
+
401
+ const cv = calendarValue();
402
+ const cal = calendar();
403
+ const hc = hourCycle();
404
+ const v = v0();
405
+
406
+ if (
407
+ newValue == null ||
408
+ (newValue instanceof IncompleteDate && newValue.isCleared(displaySegments()))
409
+ ) {
410
+ setDisplayValue(new IncompleteDate(cal, hc, cv));
411
+ setDate(null);
412
+ } else if (!(newValue instanceof IncompleteDate)) {
413
+ // The display calendar should not have any effect on the emitted value.
414
+ // Emit dates in the same calendar as the original value, if any, otherwise gregorian.
415
+ const emitted = toCalendar(
416
+ newValue as DateValue,
417
+ (v as { calendar?: Calendar } | null)?.calendar || new GregorianCalendar(),
418
+ );
419
+ setDisplayValue(new IncompleteDate(cal, hc, cv));
420
+ setDate(emitted as T);
421
+ } else {
422
+ // If the new value is complete and valid, trigger onChange eagerly. If it represents
423
+ // an incomplete or invalid value (e.g. February 30th), wait until blur to trigger onChange.
424
+ if (newValue.isComplete(displaySegments())) {
425
+ const dv = newValue.toValue(cv ?? placeholderDate());
426
+ if (newValue.validate(dv, displaySegments())) {
427
+ const newDateValue = toCalendar(
428
+ dv,
429
+ (v as { calendar?: Calendar } | null)?.calendar || new GregorianCalendar(),
430
+ );
431
+ const committed = rawValue();
432
+ if (!committed || newDateValue.compare(committed) !== 0) {
433
+ setDisplayValue(new IncompleteDate(cal, hc, cv)); // reset in case prop isn't updated
434
+ setDate(newDateValue as T);
435
+ return;
436
+ }
437
+ }
438
+ }
439
+ // Incomplete/invalid value. Set temporary display override.
440
+ setDisplayValue(newValue);
441
+ }
442
+ };
443
+
444
+ const adjustSegment = (type: DateSegmentType, amount: number) => {
445
+ setValue(displayValue().cycle(type, amount, placeholderDate(), displaySegments()));
446
+ };
447
+
448
+ const builtinValidation = createMemo<ValidationResult>(() =>
449
+ getValidationResult(
450
+ rawValue(),
451
+ access(props.minValue),
452
+ access(props.maxValue),
453
+ props.isDateUnavailable,
454
+ formatOpts(),
455
+ ),
456
+ );
457
+
222
458
  const isDisabled = createMemo(() => access(props.isDisabled) ?? false);
223
459
  const isReadOnly = createMemo(() => access(props.isReadOnly) ?? false);
224
460
  const isRequired = createMemo(() => access(props.isRequired) ?? false);
225
461
  const explicitValidationState = createMemo(() => access(props.validationState));
226
462
  const validationBehavior = () => props.validationBehavior ?? "native";
227
- const builtinValidation = createMemo<ValidationResult>(() => {
228
- const v = value();
229
- if (!v) return DEFAULT_VALIDATION_RESULT;
230
-
231
- const minValue = access(props.minValue);
232
- const maxValue = access(props.maxValue);
233
- const validationDetails: ValidityState = { ...VALID_VALIDITY_STATE };
234
- const validationErrors: string[] = [];
235
-
236
- if (minValue && v.compare(minValue) < 0) {
237
- validationDetails.rangeUnderflow = true;
238
- validationErrors.push("Value is below the minimum date.");
239
- }
240
-
241
- if (maxValue && v.compare(maxValue) > 0) {
242
- validationDetails.rangeOverflow = true;
243
- validationErrors.push("Value is above the maximum date.");
244
- }
245
463
 
246
- if (props.isDateUnavailable?.(v)) {
247
- validationDetails.customError = true;
248
- validationErrors.push("Date is unavailable.");
249
- }
250
-
251
- if (validationErrors.length === 0) {
252
- return DEFAULT_VALIDATION_RESULT;
253
- }
254
-
255
- validationDetails.valid = false;
256
- return {
257
- isInvalid: true,
258
- validationDetails,
259
- validationErrors,
260
- };
261
- });
262
464
  const validation = createFormValidationState<T>({
263
465
  get value() {
264
- return value();
466
+ return rawValue();
265
467
  },
266
468
  get isInvalid() {
267
469
  return props.isInvalid;
@@ -284,414 +486,368 @@ export function createDateFieldState<T extends DateValue = CalendarDate>(
284
486
  () => explicitValidationState() ?? (isInvalid() ? "invalid" : undefined),
285
487
  );
286
488
 
287
- // Generate segments based on granularity and locale
288
- const segments = createMemo<DateSegment[]>(() => {
289
- const v = value();
290
- const placeholder = props.placeholderValue ?? today(timeZone);
291
- const parts = placeholderDate();
489
+ return {
490
+ ...validation,
491
+ value: calendarValue,
492
+ defaultValue: props.defaultValue ?? initialValue,
493
+ dateValue,
494
+ get calendar() {
495
+ return calendar();
496
+ },
497
+ setValue,
498
+ segments,
499
+ get dateFormatter() {
500
+ return dateFormatter();
501
+ },
502
+ validationState,
503
+ isInvalid,
504
+ get granularity() {
505
+ return granularity();
506
+ },
507
+ get maxGranularity() {
508
+ return maxGranularity();
509
+ },
510
+ isDisabled,
511
+ isReadOnly,
512
+ isRequired,
513
+ increment(part) {
514
+ adjustSegment(part, 1);
515
+ },
516
+ decrement(part) {
517
+ adjustSegment(part, -1);
518
+ },
519
+ incrementPage(part) {
520
+ adjustSegment(part, PAGE_STEP[part] || 1);
521
+ },
522
+ decrementPage(part) {
523
+ adjustSegment(part, -(PAGE_STEP[part] || 1));
524
+ },
525
+ incrementToMax(part) {
526
+ const max =
527
+ part === "hour" && hourCycle() === "h12"
528
+ ? 11
529
+ : displayValue().getSegmentLimits(part)?.maxValue;
530
+ if (max != null) setValue(displayValue().set(part, max, placeholderDate()));
531
+ },
532
+ decrementToMin(part) {
533
+ const min =
534
+ part === "hour" && hourCycle() === "h12"
535
+ ? 12
536
+ : displayValue().getSegmentLimits(part)?.minValue;
537
+ if (min != null) setValue(displayValue().set(part, min, placeholderDate()));
538
+ },
539
+ setSegment(part, value) {
540
+ setValue(displayValue().set(part, value, placeholderDate()));
541
+ },
542
+ confirmPlaceholder() {
543
+ if (access(props.isDisabled) || access(props.isReadOnly)) return;
544
+ // If the display value is complete but invalid, constrain it and emit onChange on blur.
545
+ if (displayValue().isComplete(displaySegments())) {
546
+ const cv = calendarValue();
547
+ const dv = displayValue().toValue(cv ?? placeholderDate());
548
+ const v = v0();
549
+ const newDateValue = toCalendar(
550
+ dv,
551
+ (v as { calendar?: Calendar } | null)?.calendar || new GregorianCalendar(),
552
+ );
553
+ const committed = rawValue();
554
+ if (!committed || newDateValue.compare(committed) !== 0) setDate(newDateValue as T);
555
+ setDisplayValue(new IncompleteDate(calendar(), hourCycle(), cv));
556
+ }
557
+ },
558
+ clearSegment(part) {
559
+ let value = displayValue();
560
+ if (part !== "timeZoneName" && part !== "literal") value = displayValue().clear(part);
561
+ setValue(value);
562
+ },
563
+ formatValue(fieldOptions) {
564
+ if (!calendarValue()) return "";
565
+ const formatOptions = getFormatOptions(fieldOptions, formatOpts());
566
+ const formatter = new DateFormatter(locale(), formatOptions);
567
+ return formatter.format(dateValue());
568
+ },
569
+ getDateFormatter(loc, formatOptions) {
570
+ const newOptions = { ...formatOpts(), ...formatOptions };
571
+ const newFormatOptions = getFormatOptions({}, newOptions);
572
+ return new DateFormatter(loc, newFormatOptions);
573
+ },
574
+ get locale() {
575
+ return locale();
576
+ },
577
+ get timeZone() {
578
+ return timeZone();
579
+ },
580
+ };
581
+ }
292
582
 
293
- const segs: DateSegment[] = [];
583
+ // ---------------------------------------------------------------------------
584
+ // Segment processing
585
+ // ---------------------------------------------------------------------------
294
586
 
295
- // Determine format options based on granularity
296
- const shouldForceLeadingZeros = props.shouldForceLeadingZeros === true;
297
- const formatOptions: Intl.DateTimeFormatOptions = {
298
- year: "numeric",
299
- month: shouldForceLeadingZeros ? "2-digit" : "numeric",
300
- day: shouldForceLeadingZeros ? "2-digit" : "numeric",
301
- };
587
+ function processSegments(
588
+ dateValue: Date,
589
+ displayValue: IncompleteDate,
590
+ dateFormatter: DateFormatter,
591
+ resolvedOptions: Record<string, string>,
592
+ calendar: Calendar,
593
+ locale: string,
594
+ granularity: FieldGranularity,
595
+ ): DateSegment[] {
596
+ const timeValue = ["hour", "minute", "second"];
597
+ const parts = dateFormatter.formatToParts(dateValue) as Array<{
598
+ type: string;
599
+ value: string;
600
+ }>;
601
+
602
+ // Allow formatting temporarily invalid dates during editing (e.g. February 30th) by
603
+ // rendering numeric segments directly from raw numbers. On blur they are constrained.
604
+ const numberFormatter = new Intl.NumberFormat(locale, { useGrouping: false });
605
+ const twoDigitFormatter = new Intl.NumberFormat(locale, {
606
+ useGrouping: false,
607
+ minimumIntegerDigits: 2,
608
+ });
302
609
 
303
- if (granularity !== "day") {
304
- formatOptions.hour = "2-digit";
305
- formatOptions.minute = "2-digit";
306
- if (granularity === "second") {
307
- formatOptions.second = "2-digit";
308
- }
309
- if (props.hourCycle) {
310
- formatOptions.hourCycle = props.hourCycle === 12 ? "h12" : "h23";
610
+ for (const segment of parts) {
611
+ if (
612
+ segment.type === "year" ||
613
+ segment.type === "month" ||
614
+ segment.type === "day" ||
615
+ segment.type === "hour"
616
+ ) {
617
+ const value =
618
+ (displayValue[segment.type as keyof IncompleteDate] as number | null | undefined) ?? 0;
619
+ if (resolvedOptions[segment.type] === "2-digit") {
620
+ segment.value = twoDigitFormatter.format(value);
621
+ } else {
622
+ segment.value = numberFormatter.format(value);
311
623
  }
312
624
  }
625
+ }
313
626
 
314
- const formatter = new DateFormatter(locale, formatOptions);
315
- const dateToFormat = v ?? placeholder;
316
- const formattedParts = formatter.formatToParts(dateToFormat.toDate(timeZone));
317
-
318
- for (const part of formattedParts) {
319
- const type = mapPartType(part.type);
627
+ const processedSegments: DateSegment[] = [];
628
+ for (const segment of parts) {
629
+ const type = (TYPE_MAPPING[segment.type] || segment.type) as DateSegmentType;
630
+ let isEditable = EDITABLE_SEGMENTS[type] === true;
631
+ if (type === "era" && calendar.getEras().length === 1) isEditable = false;
632
+ const isPlaceholder =
633
+ EDITABLE_SEGMENTS[type] === true &&
634
+ displayValue[segment.type as keyof IncompleteDate] == null;
635
+ const placeholder = EDITABLE_SEGMENTS[type]
636
+ ? getPlaceholder(type, segment.value, locale)
637
+ : "";
638
+ const limits = displayValue.getSegmentLimits(type);
639
+ const dateSegment: DateSegment = {
640
+ type,
641
+ text: isPlaceholder ? placeholder : segment.value,
642
+ value: limits?.value ?? undefined,
643
+ minValue: limits?.minValue,
644
+ maxValue: limits?.maxValue,
645
+ isPlaceholder,
646
+ placeholder,
647
+ isEditable,
648
+ };
320
649
 
321
- if (type === "literal") {
322
- segs.push({
650
+ // There is an issue in RTL languages where time fields render (minute:hour) instead of
651
+ // (hour:minute). To force LTR on the time field, wrap the time segments in LRI/PDI
652
+ // (left-to-right isolate) unicode marks. See
653
+ // https://www.w3.org/International/questions/qa-bidi-unicode-controls.
654
+ if (type === "hour") {
655
+ processedSegments.push({
656
+ type: "literal",
657
+ text: "⁦",
658
+ value: undefined,
659
+ isPlaceholder: false,
660
+ placeholder: "",
661
+ isEditable: false,
662
+ });
663
+ processedSegments.push(dateSegment);
664
+ if (type === granularity) {
665
+ processedSegments.push({
323
666
  type: "literal",
324
- text: part.value,
325
- isEditable: false,
667
+ text: "⁩",
668
+ value: undefined,
326
669
  isPlaceholder: false,
327
- placeholder: part.value,
328
- });
329
- } else if (type) {
330
- const segValue = getSegmentValue(v, type);
331
- const partValue = parts[type as keyof DateParts];
332
- const hasValue = v !== null || partValue !== undefined;
333
-
334
- segs.push({
335
- type,
336
- text: hasValue
337
- ? part.value
338
- : getPlaceholderText(type, locale, granularity, shouldForceLeadingZeros),
339
- value: segValue ?? partValue,
340
- minValue: getMinValue(type),
341
- maxValue: getMaxValue(type, v ?? placeholder),
342
- isEditable: !isDisabled() && !isReadOnly(),
343
- isPlaceholder: !hasValue,
344
- placeholder: getPlaceholderText(type, locale, granularity, shouldForceLeadingZeros),
670
+ placeholder: "",
671
+ isEditable: false,
345
672
  });
346
673
  }
347
- }
348
-
349
- return segs;
350
- });
351
-
352
- // Set value with onChange callback
353
- const setValue = (newValue: T | null) => {
354
- if (isDisabled() || isReadOnly()) return;
355
-
356
- const controlled = access(props.value);
357
- if (controlled === undefined) {
358
- setInternalValue(() => newValue);
359
- }
360
-
361
- if (props.onChange) {
362
- props.onChange(newValue);
363
- }
364
-
365
- // Clear placeholder parts
366
- setPlaceholderDate({});
367
- };
368
-
369
- // Set a specific segment value
370
- const setSegment = (type: DateSegmentType, newValue: number) => {
371
- if (isDisabled() || isReadOnly()) return;
372
-
373
- const v = value();
374
-
375
- if (v) {
376
- // Update existing value
377
- const updated = updateDatePart(v, type, newValue);
378
- setValue(updated as T);
379
- } else {
380
- // Update placeholder parts
381
- setPlaceholderDate((prev) => ({
382
- ...prev,
383
- [type]: newValue,
384
- }));
385
- }
386
- };
387
-
388
- // Increment a segment
389
- const incrementSegment = (type: DateSegmentType) => {
390
- if (isDisabled() || isReadOnly()) return;
391
-
392
- const v = value();
393
- const current = v ? getSegmentValue(v, type) : placeholderDate()[type as keyof DateParts];
394
- const max = getMaxValue(type, v ?? props.placeholderValue ?? today(timeZone));
395
- const min = getMinValue(type);
396
-
397
- const newValue = current !== undefined ? current + 1 : min;
398
- setSegment(type, newValue > max ? min : newValue);
399
- };
400
-
401
- // Decrement a segment
402
- const decrementSegment = (type: DateSegmentType) => {
403
- if (isDisabled() || isReadOnly()) return;
404
-
405
- const v = value();
406
- const current = v ? getSegmentValue(v, type) : placeholderDate()[type as keyof DateParts];
407
- const max = getMaxValue(type, v ?? props.placeholderValue ?? today(timeZone));
408
- const min = getMinValue(type);
409
-
410
- const newValue = current !== undefined ? current - 1 : max;
411
- setSegment(type, newValue < min ? max : newValue);
412
- };
413
-
414
- // Clear a segment
415
- const clearSegment = (type: DateSegmentType) => {
416
- if (isDisabled() || isReadOnly()) return;
417
- if (type === "literal") return;
418
-
419
- const v = value();
420
- if (v) {
421
- // Reset only the target segment to its minimum value instead of clearing the entire value
422
- const min = getMinValue(type);
423
- const updated = updateDatePart(v, type, min);
424
- setValue(updated as T);
425
- } else {
426
- setPlaceholderDate((prev) => {
427
- const next = { ...prev };
428
- delete next[type as keyof DateParts];
429
- return next;
674
+ } else if (timeValue.includes(type) && type === granularity) {
675
+ processedSegments.push(dateSegment);
676
+ processedSegments.push({
677
+ type: "literal",
678
+ text: "⁩",
679
+ value: undefined,
680
+ isPlaceholder: false,
681
+ placeholder: "",
682
+ isEditable: false,
430
683
  });
684
+ } else {
685
+ processedSegments.push(dateSegment);
431
686
  }
432
- };
433
-
434
- // Confirm placeholder value
435
- const confirmPlaceholder = () => {
436
- if (isDisabled() || isReadOnly()) return;
437
-
438
- const minValue = access(props.minValue);
439
- const maxValue = access(props.maxValue);
440
- const constrain = (candidate: DateValue): DateValue => {
441
- if (minValue && candidate.compare(minValue) < 0) {
442
- return minValue;
443
- }
444
- if (maxValue && candidate.compare(maxValue) > 0) {
445
- return maxValue;
446
- }
447
- return candidate;
448
- };
449
-
450
- const parts = placeholderDate();
451
- if (Object.keys(parts).length > 0) {
452
- const dv = dateValue();
453
- if (dv) {
454
- const candidate = constrain(dv);
455
- // Commit even if unavailable so isInvalid can flag it; don't leave field stuck
456
- setValue(candidate as T);
457
- }
458
- return;
459
- }
460
-
461
- const current = value();
462
- if (current) {
463
- const constrained = constrain(current);
464
- // Apply min/max constraints even if unavailable; isInvalid will flag unavailable dates
465
- if (constrained.compare(current) !== 0) {
466
- setValue(constrained as T);
467
- }
468
- }
469
- };
470
-
471
- // Format the current value
472
- const formatValue = (fieldOptions?: Intl.DateTimeFormatOptions): string => {
473
- const v = value();
474
- if (!v) return "";
475
-
476
- const options: Intl.DateTimeFormatOptions = fieldOptions ?? {
477
- year: "numeric",
478
- month: props.shouldForceLeadingZeros === true ? "2-digit" : "numeric",
479
- day: props.shouldForceLeadingZeros === true ? "2-digit" : "numeric",
480
- };
687
+ }
481
688
 
482
- if (granularity !== "day") {
483
- options.hour = "2-digit";
484
- options.minute = "2-digit";
485
- if (granularity === "second") {
486
- options.second = "2-digit";
487
- }
488
- }
689
+ return processedSegments;
690
+ }
489
691
 
490
- const formatter = new DateFormatter(locale, options);
491
- return formatter.format(v.toDate(timeZone));
492
- };
692
+ // ---------------------------------------------------------------------------
693
+ // Utilities ported from @react-stately/datepicker utils
694
+ // ---------------------------------------------------------------------------
493
695
 
494
- return {
495
- value,
496
- defaultValue: initialValue,
497
- dateValue,
498
- setValue,
499
- segments,
500
- formatValue,
501
- setSegment,
502
- incrementSegment,
503
- decrementSegment,
504
- clearSegment,
505
- confirmPlaceholder,
506
- isDisabled,
507
- isReadOnly,
508
- isRequired,
509
- validationState,
510
- granularity,
511
- isInvalid,
512
- realtimeValidation: validation.realtimeValidation,
513
- displayValidation: validation.displayValidation,
514
- updateValidation: validation.updateValidation,
515
- resetValidation: validation.resetValidation,
516
- commitValidation: validation.commitValidation,
517
- locale,
518
- timeZone,
519
- };
696
+ interface FormatterOptions {
697
+ timeZone?: string;
698
+ hideTimeZone?: boolean;
699
+ granularity?: Granularity;
700
+ maxGranularity?: "year" | "month" | "day" | "hour" | "minute";
701
+ hourCycle?: 12 | 24;
702
+ showEra?: boolean;
703
+ shouldForceLeadingZeros?: boolean;
520
704
  }
521
705
 
522
- interface DateParts {
523
- year?: number;
524
- month?: number;
525
- day?: number;
526
- hour?: number;
527
- minute?: number;
528
- second?: number;
706
+ const DEFAULT_FIELD_OPTIONS: Intl.DateTimeFormatOptions = {
707
+ year: "numeric",
708
+ month: "numeric",
709
+ day: "numeric",
710
+ hour: "numeric",
711
+ minute: "2-digit",
712
+ second: "2-digit",
713
+ };
714
+
715
+ const TWO_DIGIT_FIELD_OPTIONS: Intl.DateTimeFormatOptions = {
716
+ year: "numeric",
717
+ month: "2-digit",
718
+ day: "2-digit",
719
+ hour: "2-digit",
720
+ minute: "2-digit",
721
+ second: "2-digit",
722
+ };
723
+
724
+ export function getFormatOptions(
725
+ fieldOptions: Intl.DateTimeFormatOptions | undefined,
726
+ options: FormatterOptions,
727
+ ): Intl.DateTimeFormatOptions {
728
+ const defaultFieldOptions = options.shouldForceLeadingZeros
729
+ ? TWO_DIGIT_FIELD_OPTIONS
730
+ : DEFAULT_FIELD_OPTIONS;
731
+ const merged: Record<string, unknown> = { ...defaultFieldOptions, ...fieldOptions };
732
+ const granularity = options.granularity || "minute";
733
+ const keys = Object.keys(merged);
734
+ let startIdx = keys.indexOf(options.maxGranularity ?? "year");
735
+ if (startIdx < 0) startIdx = 0;
736
+ let endIdx = keys.indexOf(granularity);
737
+ if (endIdx < 0) endIdx = 2;
738
+ if (startIdx > endIdx) throw new Error("maxGranularity must be greater than granularity");
739
+ const opts: Record<string, unknown> = keys.slice(startIdx, endIdx + 1).reduce(
740
+ (acc: Record<string, unknown>, key) => {
741
+ acc[key] = merged[key];
742
+ return acc;
743
+ },
744
+ {},
745
+ );
746
+ if (options.hourCycle != null) opts.hour12 = options.hourCycle === 12;
747
+ opts.timeZone = options.timeZone || "UTC";
748
+ const hasTime = granularity === "hour" || granularity === "minute" || granularity === "second";
749
+ if (hasTime && options.timeZone && !options.hideTimeZone) opts.timeZoneName = "short";
750
+ if (options.showEra && startIdx === 0) opts.era = "short";
751
+ return opts as Intl.DateTimeFormatOptions;
529
752
  }
530
753
 
531
- function mapPartType(type: Intl.DateTimeFormatPartTypes): DateSegmentType | null {
532
- switch (type) {
533
- case "year":
534
- return "year";
535
- case "month":
536
- return "month";
537
- case "day":
538
- return "day";
539
- case "hour":
540
- return "hour";
541
- case "minute":
542
- return "minute";
543
- case "second":
544
- return "second";
545
- case "dayPeriod":
546
- return "dayPeriod";
547
- case "era":
548
- return "era";
549
- case "timeZoneName":
550
- return "timeZoneName";
551
- case "literal":
552
- return "literal";
553
- default:
554
- return null;
555
- }
754
+ function convertValue(value: DateValue | null | undefined, calendar: Calendar): DateValue | null | undefined {
755
+ if (value === null) return null;
756
+ if (!value) return undefined;
757
+ return toCalendar(value, calendar);
556
758
  }
557
759
 
558
- function getSegmentValue(date: DateValue | null, type: DateSegmentType): number | undefined {
559
- if (!date) return undefined;
560
-
561
- switch (type) {
562
- case "year":
563
- return date.year;
564
- case "month":
565
- return date.month;
566
- case "day":
567
- return date.day;
568
- case "hour":
569
- return "hour" in date ? (date as CalendarDateTime).hour : undefined;
570
- case "minute":
571
- return "minute" in date ? (date as CalendarDateTime).minute : undefined;
572
- case "second":
573
- return "second" in date ? (date as CalendarDateTime).second : undefined;
574
- default:
575
- return undefined;
760
+ function createPlaceholderDate(
761
+ placeholderValue: DateValue | undefined,
762
+ granularity: string,
763
+ calendar: Calendar,
764
+ timeZone: string | undefined,
765
+ ): DateValue {
766
+ if (placeholderValue) return convertValue(placeholderValue, calendar) as DateValue;
767
+ const date = toCalendar(
768
+ now(timeZone ?? getLocalTimeZone()).set({ hour: 0, minute: 0, second: 0, millisecond: 0 }),
769
+ calendar,
770
+ );
771
+ if (granularity === "year" || granularity === "month" || granularity === "day") {
772
+ return toCalendarDate(date);
576
773
  }
774
+ if (!timeZone) return toCalendarDateTime(date);
775
+ return date;
577
776
  }
578
777
 
579
- function getMinValue(type: DateSegmentType): number {
580
- switch (type) {
581
- case "year":
582
- return 1;
583
- case "month":
584
- return 1;
585
- case "day":
586
- return 1;
587
- case "hour":
588
- return 0;
589
- case "minute":
590
- return 0;
591
- case "second":
592
- return 0;
593
- default:
594
- return 0;
595
- }
778
+ /** The placeholder time, used by time-only fields. */
779
+ export function getPlaceholderTime(placeholderValue?: DateValue): Time | DateValue {
780
+ if (placeholderValue && "hour" in placeholderValue) return placeholderValue;
781
+ return new Time();
596
782
  }
597
783
 
598
- function getMaxValue(type: DateSegmentType, date: DateValue): number {
599
- switch (type) {
600
- case "year":
601
- return 9999;
602
- case "month":
603
- return 12;
604
- case "day":
605
- // Get days in month
606
- return endOfMonth(date).day;
607
- case "hour":
608
- return 23;
609
- case "minute":
610
- return 59;
611
- case "second":
612
- return 59;
613
- default:
614
- return 0;
784
+ function getValidationLocale(): string {
785
+ let locale =
786
+ (typeof navigator !== "undefined" &&
787
+ ((navigator.language as string) ||
788
+ (navigator as unknown as { userLanguage?: string }).userLanguage)) ||
789
+ "en-US";
790
+ try {
791
+ Intl.DateTimeFormat.supportedLocalesOf([locale]);
792
+ } catch {
793
+ locale = "en-US";
615
794
  }
795
+ return locale;
616
796
  }
617
797
 
618
- function getPlaceholderText(
619
- type: DateSegmentType,
620
- locale: string,
621
- granularity: "day" | "hour" | "minute" | "second",
622
- shouldForceLeadingZeros = false,
623
- ): string {
624
- if (type === "literal") return "";
625
- if (type === "dayPeriod") return "AM";
626
-
627
- const formatOptions: Intl.DateTimeFormatOptions = {
628
- year: "numeric",
629
- month: shouldForceLeadingZeros ? "2-digit" : "numeric",
630
- day: shouldForceLeadingZeros ? "2-digit" : "numeric",
631
- };
632
-
633
- if (granularity !== "day") {
634
- formatOptions.hour = "2-digit";
635
- formatOptions.minute = "2-digit";
636
- if (granularity === "second") {
637
- formatOptions.second = "2-digit";
798
+ export function getValidationResult(
799
+ value: DateValue | null,
800
+ minValue: DateValue | undefined,
801
+ maxValue: DateValue | undefined,
802
+ isDateUnavailable: ((date: DateValue) => boolean) | undefined,
803
+ options: FormatterOptions,
804
+ ): ValidationResult {
805
+ const rangeOverflow = value != null && maxValue != null && value.compare(maxValue) > 0;
806
+ const rangeUnderflow = value != null && minValue != null && value.compare(minValue) < 0;
807
+ const isUnavailable = (value != null && isDateUnavailable?.(value)) || false;
808
+ const isInvalid = rangeOverflow || rangeUnderflow || isUnavailable;
809
+ const errors: string[] = [];
810
+ if (isInvalid) {
811
+ const locale = getValidationLocale();
812
+ const dateFormatter = new DateFormatter(locale, getFormatOptions({}, options));
813
+ const timeZone = dateFormatter.resolvedOptions().timeZone;
814
+ if (rangeUnderflow && minValue != null) {
815
+ errors.push(
816
+ formatError(locale, "rangeUnderflow", {
817
+ minValue: dateFormatter.format(minValue.toDate(timeZone)),
818
+ }),
819
+ );
638
820
  }
639
- }
640
-
641
- const formatter = new DateFormatter(locale, formatOptions);
642
- // Use a reference date with recognizable digits
643
- const refDate = new Date(2222, 10, 22, 22, 22, 22);
644
- const parts = formatter.formatToParts(refDate);
645
-
646
- for (const part of parts) {
647
- const mappedType = mapPartType(part.type);
648
- if (mappedType === type) {
649
- return part.value.replace(/\d/g, (d) => {
650
- if (type === "year") return "y";
651
- if (type === "month") return "m";
652
- if (type === "day") return "d";
653
- return "–";
654
- });
821
+ if (rangeOverflow && maxValue != null) {
822
+ errors.push(
823
+ formatError(locale, "rangeOverflow", {
824
+ maxValue: dateFormatter.format(maxValue.toDate(timeZone)),
825
+ }),
826
+ );
655
827
  }
828
+ if (isUnavailable) errors.push(formatError(locale, "unavailableDate"));
656
829
  }
657
-
658
- switch (type) {
659
- case "year":
660
- return "yyyy";
661
- case "month":
662
- return "mm";
663
- case "day":
664
- return "dd";
665
- case "hour":
666
- return "––";
667
- case "minute":
668
- return "––";
669
- case "second":
670
- return "––";
671
- default:
672
- return "";
673
- }
830
+ const validationDetails: ValidityState = {
831
+ ...VALID_VALIDITY_STATE,
832
+ badInput: isUnavailable,
833
+ rangeOverflow,
834
+ rangeUnderflow,
835
+ valid: !isInvalid,
836
+ };
837
+ return {
838
+ isInvalid,
839
+ validationErrors: errors,
840
+ validationDetails,
841
+ };
674
842
  }
675
843
 
676
- function updateDatePart(date: DateValue, type: DateSegmentType, value: number): DateValue {
677
- if (typeof (date as { set?: unknown }).set !== "function") {
678
- return date;
679
- }
680
-
681
- switch (type) {
682
- case "year":
683
- return date.set({ year: value });
684
- case "month":
685
- return date.set({ month: value });
686
- case "day":
687
- return date.set({ day: value });
688
- case "hour":
689
- return date.set({ hour: value });
690
- case "minute":
691
- return date.set({ minute: value });
692
- case "second":
693
- return date.set({ second: value });
694
- default:
695
- return date;
696
- }
844
+ function formatError(
845
+ locale: string,
846
+ key: DateFieldErrorKey,
847
+ args?: { minValue?: string; maxValue?: string },
848
+ ): string {
849
+ return formatDateFieldError(locale, key, args);
697
850
  }
851
+
852
+ // Re-export so existing imports of CalendarDateTime keep resolving through this module.
853
+ export type { CalendarDateTime };