@proyecto-viviana/solid-stately 0.4.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 (33) hide show
  1. package/dist/calendar/createDateFieldState.d.ts +56 -18
  2. package/dist/calendar/createRangeCalendarState.d.ts +10 -0
  3. package/dist/calendar/createTimeFieldState.d.ts +38 -67
  4. package/dist/calendar/intl/index.d.ts +10 -0
  5. package/dist/collections/ListCollection.d.ts +8 -0
  6. package/dist/collections/createListState.d.ts +12 -0
  7. package/dist/collections/getChildNodes.d.ts +8 -0
  8. package/dist/collections/index.d.ts +2 -2
  9. package/dist/collections/types.d.ts +9 -0
  10. package/dist/color/intl/index.d.ts +14 -0
  11. package/dist/dnd/createDroppableCollectionState.d.ts +17 -2
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.js +3444 -1304
  14. package/dist/index.js.map +1 -1
  15. package/dist/selection/SelectionManager.d.ts +9 -0
  16. package/package.json +1 -1
  17. package/src/calendar/createCalendarState.ts +26 -4
  18. package/src/calendar/createDateFieldState.ts +614 -459
  19. package/src/calendar/createRangeCalendarState.ts +212 -11
  20. package/src/calendar/createTimeFieldState.ts +179 -467
  21. package/src/calendar/intl/index.ts +364 -0
  22. package/src/collections/ListCollection.ts +31 -0
  23. package/src/collections/createListState.ts +92 -1
  24. package/src/collections/getChildNodes.ts +31 -0
  25. package/src/collections/index.ts +3 -0
  26. package/src/collections/types.ts +10 -0
  27. package/src/color/Color.ts +104 -133
  28. package/src/color/intl/index.ts +1409 -0
  29. package/src/combobox/createComboBoxState.ts +30 -13
  30. package/src/dnd/createDroppableCollectionState.ts +125 -6
  31. package/src/index.ts +3 -0
  32. package/src/selection/SelectionManager.ts +15 -0
  33. package/src/steplist/createStepListState.ts +27 -4
@@ -2,34 +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
6
  *
7
7
  * Editing is modeled with an {@link IncompleteDate} display value so that invalid
8
8
  * intermediate dates (e.g. February 31st, produced by editing the day before the
9
9
  * month) are held as-typed while the field is focused and only constrained on blur
10
10
  * via `confirmPlaceholder`, matching upstream behavior. Out-of-range values are
11
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`.
12
17
  */
13
18
 
14
- import { createSignal, createMemo, createEffect, type Accessor } from "solid-js";
19
+ import { createSignal, createMemo, createComputed, untrack, type Accessor } from "solid-js";
15
20
  import {
16
- type AnyDateTime,
17
21
  type Calendar,
18
22
  type CalendarDateTime,
23
+ type CalendarIdentifier,
19
24
  type DateValue,
20
25
  GregorianCalendar,
21
- today,
22
- getLocalTimeZone,
23
26
  DateFormatter,
24
- toCalendarDate as intlToCalendarDate,
27
+ Time,
28
+ now,
29
+ getLocalTimeZone,
30
+ toCalendar,
31
+ toCalendarDate,
25
32
  toCalendarDateTime,
33
+ isEqualCalendar,
34
+ createCalendar as intlCreateCalendar,
26
35
  } from "@internationalized/date";
27
36
  import { access, type MaybeAccessor } from "../utils";
28
37
  import type { ValidationState } from "./createCalendarState";
29
38
  import { IncompleteDate } from "./IncompleteDate";
39
+ import { formatDateFieldError, getPlaceholder, type DateFieldErrorKey } from "./intl";
30
40
  import {
31
41
  createFormValidationState,
32
- DEFAULT_VALIDATION_RESULT,
33
42
  VALID_VALIDITY_STATE,
34
43
  type ValidationBehavior,
35
44
  type ValidationFunction,
@@ -49,6 +58,9 @@ export type DateSegmentType =
49
58
  | "timeZoneName"
50
59
  | "literal";
51
60
 
61
+ type Granularity = "year" | "month" | "day" | "hour" | "minute" | "second";
62
+ type FieldGranularity = "day" | "hour" | "minute" | "second";
63
+
52
64
  export interface DateSegment {
53
65
  /** The type of segment. */
54
66
  type: DateSegmentType;
@@ -90,7 +102,9 @@ export interface DateFieldStateProps<T extends DateValue = DateValue> {
90
102
  /** The locale to use for formatting. */
91
103
  locale?: string;
92
104
  /** The granularity of the date/time (day, hour, minute, second). */
93
- 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";
94
108
  /** Whether to show the hour in 12 or 24 hour format. */
95
109
  hourCycle?: 12 | 24;
96
110
  /** Whether to hide the time zone. */
@@ -111,29 +125,43 @@ export interface DateFieldStateProps<T extends DateValue = DateValue> {
111
125
  errorMessage?: string;
112
126
  /** Whether dates outside the min/max range are allowed. */
113
127
  allowsNonContiguousRanges?: boolean;
114
- /** Whether to create a date or datetime. */
115
- createCalendar?: (name: string) => unknown;
128
+ /** Resolves a calendar system by its Unicode identifier. */
129
+ createCalendar?: (name: CalendarIdentifier) => Calendar;
116
130
  /** Whether a date is unavailable. */
117
131
  isDateUnavailable?: (date: DateValue) => boolean;
118
132
  }
119
133
 
120
134
  export interface DateFieldState<T extends DateValue = DateValue> {
121
- /** The current value. */
122
- value: Accessor<T | null>;
123
- /** The date value (may be partial during editing). */
124
- 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;
125
141
  /** Sets the date value. */
126
142
  setValue: (value: T | null) => void;
127
143
  /** The segments that make up the date. */
128
144
  segments: Accessor<DateSegment[]>;
129
- /** 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. */
130
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;
131
151
  /** Sets a segment value. */
132
152
  setSegment: (type: DateSegmentType, value: number) => void;
133
- /** Increments a segment. */
134
- incrementSegment: (type: DateSegmentType) => void;
135
- /** Decrements a segment. */
136
- 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;
137
165
  /** Clears a segment. */
138
166
  clearSegment: (type: DateSegmentType) => void;
139
167
  /** Confirms the value (after typing). */
@@ -147,7 +175,9 @@ export interface DateFieldState<T extends DateValue = DateValue> {
147
175
  /** The validation state. */
148
176
  validationState: Accessor<ValidationState | undefined>;
149
177
  /** The granularity. */
150
- granularity: "day" | "hour" | "minute" | "second";
178
+ readonly granularity: FieldGranularity;
179
+ /** The maximum granularity. */
180
+ readonly maxGranularity: "year" | "month" | "day" | "hour" | "minute";
151
181
  /** Whether the value is invalid. */
152
182
  isInvalid: Accessor<boolean>;
153
183
  /** Realtime validation results, including native and custom constraints. */
@@ -161,106 +191,279 @@ export interface DateFieldState<T extends DateValue = DateValue> {
161
191
  /** Commits realtime validation to displayed validation. */
162
192
  commitValidation: () => void;
163
193
  /** The locale. */
164
- locale: string;
194
+ readonly locale: string;
165
195
  /** The time zone. */
166
- timeZone: string;
196
+ readonly timeZone: string;
167
197
  /** The default value. */
168
- defaultValue: T | null;
198
+ defaultValue: DateValue | null;
169
199
  }
170
200
 
171
201
  type HourCycle = "h11" | "h12" | "h23" | "h24";
172
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
+
173
232
  /**
174
233
  * Provides state management for a date field component.
175
234
  */
176
235
  export function createDateFieldState<T extends DateValue = DateValue>(
177
236
  props: DateFieldStateProps<T> = {},
178
237
  ): DateFieldState<T> {
179
- const timeZone = getLocalTimeZone();
180
- const locale = props.locale ?? "en-US";
181
- const granularity = props.granularity ?? "day";
182
- const calendar: Calendar = new GregorianCalendar();
183
- const hourCycle = resolveHourCycle(locale, props.hourCycle);
184
- const displaySegments = computeDisplaySegments(granularity, hourCycle);
185
-
186
- const getPlaceholderValue = (): DateValue => {
187
- const base = props.placeholderValue ?? today(timeZone);
188
- return granularity === "day" ? intlToCalendarDate(base) : toCalendarDateTime(base);
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;
189
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];
190
297
 
191
- const toSource = (v: DateValue | null): Partial<Omit<AnyDateTime, "copy">> | null =>
192
- (v as Partial<Omit<AnyDateTime, "copy">> | null) ?? null;
193
-
194
- // State signals
195
- const initialValue = props.defaultValue ?? null;
196
- const [internalValue, setInternalValue] = createSignal<T | null>(initialValue);
197
-
198
- // Controlled vs uncontrolled committed value
199
- 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>(() => {
200
301
  const controlled = access(props.value);
201
302
  return controlled !== undefined ? controlled : internalValue();
202
303
  });
304
+ const setDate = (v: T | null) => {
305
+ if (access(props.value) === undefined) setInternalValue(() => v);
306
+ props.onChange?.(v);
307
+ };
308
+
309
+ // The current value, converted to the display calendar.
310
+ const calendarValue = createMemo<DateValue | null>(() => convertValue(rawValue(), calendar()) ?? null);
311
+
312
+ const initialValue = untrack(calendarValue);
203
313
 
204
314
  // The display override: holds the (possibly invalid/incomplete) value being edited.
205
315
  const [displayValue, setDisplayValue] = createSignal<IncompleteDate>(
206
- new IncompleteDate(calendar, hourCycle, toSource(value())),
316
+ untrack(() => new IncompleteDate(calendar(), hourCycle(), untrack(calendarValue))),
207
317
  );
208
318
 
209
- // Reset the display override whenever the committed value changes from the outside
210
- // (e.g. a controlled `value` prop update).
211
- let lastSyncedValue = value();
212
- createEffect(() => {
213
- const v = value();
214
- if (v !== lastSyncedValue) {
215
- lastSyncedValue = v;
216
- setDisplayValue(new IncompleteDate(calendar, hourCycle, toSource(v)));
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));
217
334
  }
218
335
  });
219
336
 
220
- // Derived states
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
+ });
377
+
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());
382
+ });
383
+
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
+
221
458
  const isDisabled = createMemo(() => access(props.isDisabled) ?? false);
222
459
  const isReadOnly = createMemo(() => access(props.isReadOnly) ?? false);
223
460
  const isRequired = createMemo(() => access(props.isRequired) ?? false);
224
461
  const explicitValidationState = createMemo(() => access(props.validationState));
225
462
  const validationBehavior = () => props.validationBehavior ?? "native";
226
- const builtinValidation = createMemo<ValidationResult>(() => {
227
- const v = value();
228
- if (!v) return DEFAULT_VALIDATION_RESULT;
229
-
230
- const minValue = access(props.minValue);
231
- const maxValue = access(props.maxValue);
232
- const validationDetails: ValidityState = { ...VALID_VALIDITY_STATE };
233
- const validationErrors: string[] = [];
234
-
235
- if (minValue && v.compare(minValue) < 0) {
236
- validationDetails.rangeUnderflow = true;
237
- validationErrors.push("Value is below the minimum date.");
238
- }
239
-
240
- if (maxValue && v.compare(maxValue) > 0) {
241
- validationDetails.rangeOverflow = true;
242
- validationErrors.push("Value is above the maximum date.");
243
- }
244
-
245
- if (props.isDateUnavailable?.(v)) {
246
- validationDetails.customError = true;
247
- validationErrors.push("Date is unavailable.");
248
- }
249
-
250
- if (validationErrors.length === 0) {
251
- return DEFAULT_VALIDATION_RESULT;
252
- }
253
463
 
254
- validationDetails.valid = false;
255
- return {
256
- isInvalid: true,
257
- validationDetails,
258
- validationErrors,
259
- };
260
- });
261
464
  const validation = createFormValidationState<T>({
262
465
  get value() {
263
- return value();
466
+ return rawValue();
264
467
  },
265
468
  get isInvalid() {
266
469
  return props.isInvalid;
@@ -283,415 +486,367 @@ export function createDateFieldState<T extends DateValue = DateValue>(
283
486
  () => explicitValidationState() ?? (isInvalid() ? "invalid" : undefined),
284
487
  );
285
488
 
286
- // The effective date value, derived from the display override.
287
- const dateValue = createMemo<DateValue | null>(() => {
288
- const v = value();
289
- if (v) return v;
290
- const dv = displayValue();
291
- if (dv.isCleared(displaySegments)) return null;
292
- return dv.toValue(getPlaceholderValue());
293
- });
294
-
295
- // Generate segments from the display override (so invalid intermediate values render as typed).
296
- const segments = createMemo<DateSegment[]>(() => {
297
- const dv = displayValue();
298
- const base = value() ?? getPlaceholderValue();
299
- const resolved = dv.toValue(base);
300
- const jsDate = resolved.toDate(timeZone);
301
-
302
- const shouldForceLeadingZeros = props.shouldForceLeadingZeros === true;
303
- const formatOptions = buildFormatOptions(granularity, shouldForceLeadingZeros, props.hourCycle);
304
- const formatter = new DateFormatter(locale, formatOptions);
305
- const resolvedOptions = formatter.resolvedOptions() as unknown as Record<string, string>;
306
- const parts = formatter.formatToParts(jsDate);
307
-
308
- const numberFormatter = new Intl.NumberFormat(locale, { useGrouping: false });
309
- const twoDigitFormatter = new Intl.NumberFormat(locale, {
310
- useGrouping: false,
311
- minimumIntegerDigits: 2,
312
- });
313
-
314
- const result: DateSegment[] = [];
315
- for (const part of parts) {
316
- const type = mapPartType(part.type);
317
-
318
- if (type === "literal") {
319
- result.push({
320
- type: "literal",
321
- text: part.value,
322
- isEditable: false,
323
- isPlaceholder: false,
324
- placeholder: part.value,
325
- });
326
- continue;
327
- }
328
- if (!type) continue;
329
-
330
- const editable = isEditableSegment(type);
331
- const rawValue = rawSegmentValue(dv, type);
332
- const isPlaceholder = editable && rawValue == null;
333
- const limits = dv.getSegmentLimits(type);
334
- const placeholderText = getPlaceholderText(
335
- type,
336
- locale,
337
- granularity,
338
- shouldForceLeadingZeros,
339
- );
340
-
341
- // For numeric date/time segments, render the raw edited value (e.g. "31" even
342
- // when the resolved date is Feb 28) using a NumberFormatter, mirroring upstream.
343
- let text = part.value;
344
- if (
345
- (type === "year" || type === "month" || type === "day" || type === "hour") &&
346
- typeof rawValue === "number"
347
- ) {
348
- const numeric = rawValue ?? 0;
349
- text =
350
- resolvedOptions[type] === "2-digit"
351
- ? twoDigitFormatter.format(numeric)
352
- : numberFormatter.format(numeric);
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));
353
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
+ }
354
582
 
355
- result.push({
356
- type,
357
- text: isPlaceholder ? placeholderText : text,
358
- value: limits?.value,
359
- minValue: limits?.minValue,
360
- maxValue: limits?.maxValue,
361
- isEditable: editable && !isDisabled() && !isReadOnly(),
362
- isPlaceholder,
363
- placeholder: placeholderText,
364
- });
365
- }
583
+ // ---------------------------------------------------------------------------
584
+ // Segment processing
585
+ // ---------------------------------------------------------------------------
366
586
 
367
- return result;
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,
368
608
  });
369
609
 
370
- // Commit a concrete value (updates the controlled/uncontrolled value and fires onChange).
371
- const commitDate = (newValue: T | null) => {
372
- const controlled = access(props.value);
373
- if (controlled === undefined) {
374
- lastSyncedValue = newValue;
375
- setInternalValue(() => newValue);
376
- }
377
- props.onChange?.(newValue);
378
- };
379
-
380
- // Set value with the upstream split: complete + valid commits eagerly; an incomplete
381
- // or invalid (e.g. Feb 31) value is held as a display override until blur.
382
- const setValue = (newValue: T | IncompleteDate | null) => {
383
- if (isDisabled() || isReadOnly()) return;
384
-
610
+ for (const segment of parts) {
385
611
  if (
386
- newValue == null ||
387
- (newValue instanceof IncompleteDate && newValue.isCleared(displaySegments))
612
+ segment.type === "year" ||
613
+ segment.type === "month" ||
614
+ segment.type === "day" ||
615
+ segment.type === "hour"
388
616
  ) {
389
- commitDate(null);
390
- setDisplayValue(new IncompleteDate(calendar, hourCycle, null));
391
- return;
392
- }
393
-
394
- if (!(newValue instanceof IncompleteDate)) {
395
- commitDate(newValue);
396
- setDisplayValue(new IncompleteDate(calendar, hourCycle, toSource(newValue)));
397
- return;
398
- }
399
-
400
- // If the new value is complete and valid, trigger onChange eagerly. If it represents
401
- // an incomplete or invalid value (e.g. February 30th), wait until blur to trigger onChange.
402
- if (newValue.isComplete(displaySegments)) {
403
- const resolved = newValue.toValue(value() ?? getPlaceholderValue());
404
- if (newValue.validate(resolved, displaySegments)) {
405
- const committed = value();
406
- if (!committed || resolved.compare(committed) !== 0) {
407
- setDisplayValue(new IncompleteDate(calendar, hourCycle, toSource(resolved)));
408
- commitDate(resolved as T);
409
- return;
410
- }
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);
411
623
  }
412
624
  }
625
+ }
413
626
 
414
- // Incomplete/invalid value. Set temporary display override.
415
- setDisplayValue(newValue);
416
- };
417
-
418
- const adjustSegment = (type: DateSegmentType, amount: number) => {
419
- if (isDisabled() || isReadOnly()) return;
420
- setValue(displayValue().cycle(type, amount, getPlaceholderValue(), displaySegments));
421
- };
422
-
423
- // Set a specific segment value
424
- const setSegment = (type: DateSegmentType, newValue: number) => {
425
- if (isDisabled() || isReadOnly()) return;
426
- setValue(displayValue().set(type, newValue, getPlaceholderValue()));
427
- };
428
-
429
- const incrementSegment = (type: DateSegmentType) => adjustSegment(type, 1);
430
- const decrementSegment = (type: DateSegmentType) => adjustSegment(type, -1);
431
-
432
- // Clear a segment, reverting it to its placeholder.
433
- const clearSegment = (type: DateSegmentType) => {
434
- if (isDisabled() || isReadOnly()) return;
435
- let dv = displayValue();
436
- if (type !== "timeZoneName" && type !== "literal") {
437
- dv = dv.clear(type);
438
- }
439
- setValue(dv);
440
- };
441
-
442
- // When the field is blurred, constrain a complete-but-invalid display value
443
- // (e.g. February 31st -> February 28th) and emit onChange. Out-of-range values
444
- // are left as-is and reported via validation rather than being snapped.
445
- const confirmPlaceholder = () => {
446
- if (isDisabled() || isReadOnly()) return;
447
- const dv = displayValue();
448
- if (!dv.isComplete(displaySegments)) return;
449
-
450
- const resolved = dv.toValue(value() ?? getPlaceholderValue());
451
- const committed = value();
452
- const needsCommit = !committed || resolved.compare(committed) !== 0;
453
- if (needsCommit) {
454
- commitDate(resolved as T);
455
- }
456
-
457
- // Reset the display override so a constrained value (e.g. February 31st ->
458
- // February 28th) renders. Only touch the signal when the displayed raw value
459
- // diverged from the resolved value, or when we committed — otherwise leave it
460
- // untouched so blur (which fires on every focus move) does not churn the
461
- // segments and drop focus.
462
- if (needsCommit || !dv.validate(resolved, displaySegments)) {
463
- setDisplayValue(new IncompleteDate(calendar, hourCycle, toSource(value() ?? resolved)));
464
- }
465
- };
466
-
467
- // Format the current value
468
- const formatValue = (fieldOptions?: Intl.DateTimeFormatOptions): string => {
469
- const v = value();
470
- if (!v) return "";
471
-
472
- const options: Intl.DateTimeFormatOptions = fieldOptions ?? {
473
- year: "numeric",
474
- month: props.shouldForceLeadingZeros === true ? "2-digit" : "numeric",
475
- day: props.shouldForceLeadingZeros === true ? "2-digit" : "numeric",
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,
476
648
  };
477
649
 
478
- if (granularity !== "day") {
479
- options.hour = "2-digit";
480
- options.minute = "2-digit";
481
- if (granularity === "second") {
482
- options.second = "2-digit";
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({
666
+ type: "literal",
667
+ text: "⁩",
668
+ value: undefined,
669
+ isPlaceholder: false,
670
+ placeholder: "",
671
+ isEditable: false,
672
+ });
483
673
  }
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,
683
+ });
684
+ } else {
685
+ processedSegments.push(dateSegment);
484
686
  }
687
+ }
485
688
 
486
- const formatter = new DateFormatter(locale, options);
487
- return formatter.format(v.toDate(timeZone));
488
- };
489
-
490
- return {
491
- value,
492
- defaultValue: initialValue,
493
- dateValue,
494
- setValue,
495
- segments,
496
- formatValue,
497
- setSegment,
498
- incrementSegment,
499
- decrementSegment,
500
- clearSegment,
501
- confirmPlaceholder,
502
- isDisabled,
503
- isReadOnly,
504
- isRequired,
505
- validationState,
506
- granularity,
507
- isInvalid,
508
- realtimeValidation: validation.realtimeValidation,
509
- displayValidation: validation.displayValidation,
510
- updateValidation: validation.updateValidation,
511
- resetValidation: validation.resetValidation,
512
- commitValidation: validation.commitValidation,
513
- locale,
514
- timeZone,
515
- };
689
+ return processedSegments;
516
690
  }
517
691
 
518
- const EDITABLE_SEGMENTS: Record<string, boolean> = {
519
- year: true,
520
- month: true,
521
- day: true,
522
- hour: true,
523
- minute: true,
524
- second: true,
525
- dayPeriod: true,
526
- era: true,
527
- };
692
+ // ---------------------------------------------------------------------------
693
+ // Utilities ported from @react-stately/datepicker utils
694
+ // ---------------------------------------------------------------------------
528
695
 
529
- function isEditableSegment(type: DateSegmentType): boolean {
530
- return EDITABLE_SEGMENTS[type] === true;
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;
531
704
  }
532
705
 
533
- function rawSegmentValue(dv: IncompleteDate, type: DateSegmentType): number | string | null {
534
- switch (type) {
535
- case "era":
536
- return dv.era;
537
- case "year":
538
- return dv.year;
539
- case "month":
540
- return dv.month;
541
- case "day":
542
- return dv.day;
543
- case "hour":
544
- return dv.hour;
545
- case "minute":
546
- return dv.minute;
547
- case "second":
548
- return dv.second;
549
- case "dayPeriod":
550
- return dv.dayPeriod;
551
- default:
552
- return null;
553
- }
554
- }
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
+ };
555
714
 
556
- function resolveHourCycle(locale: string, hourCycle?: 12 | 24): HourCycle {
557
- const formatter = new DateFormatter(locale, {
558
- hour: "numeric",
559
- hour12: hourCycle != null ? hourCycle === 12 : undefined,
560
- });
561
- return (formatter.resolvedOptions().hourCycle as HourCycle) ?? "h23";
562
- }
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
+ };
563
723
 
564
- function computeDisplaySegments(
565
- granularity: "day" | "hour" | "minute" | "second",
566
- hourCycle: HourCycle,
567
- ): DateSegmentType[] {
568
- const is12HourClock = hourCycle === "h11" || hourCycle === "h12";
569
- const segments: DateSegmentType[] = [
570
- "era",
571
- "year",
572
- "month",
573
- "day",
574
- "hour",
575
- ...(is12HourClock ? (["dayPeriod"] as const) : []),
576
- "minute",
577
- "second",
578
- ];
579
- // We never expose era as an editable segment, so the maximum displayed unit is the year.
580
- const minIndex = segments.indexOf("era");
581
- const maxIndex = segments.indexOf(
582
- granularity === "hour" && is12HourClock ? "dayPeriod" : granularity,
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
+ {},
583
745
  );
584
- return segments.slice(minIndex, maxIndex + 1);
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;
585
752
  }
586
753
 
587
- function buildFormatOptions(
588
- granularity: "day" | "hour" | "minute" | "second",
589
- shouldForceLeadingZeros: boolean,
590
- hourCycle?: 12 | 24,
591
- ): Intl.DateTimeFormatOptions {
592
- const formatOptions: Intl.DateTimeFormatOptions = {
593
- year: "numeric",
594
- month: shouldForceLeadingZeros ? "2-digit" : "numeric",
595
- day: shouldForceLeadingZeros ? "2-digit" : "numeric",
596
- };
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);
758
+ }
597
759
 
598
- if (granularity !== "day") {
599
- formatOptions.hour = "2-digit";
600
- formatOptions.minute = "2-digit";
601
- if (granularity === "second") {
602
- formatOptions.second = "2-digit";
603
- }
604
- if (hourCycle) {
605
- formatOptions.hourCycle = hourCycle === 12 ? "h12" : "h23";
606
- }
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);
607
773
  }
774
+ if (!timeZone) return toCalendarDateTime(date);
775
+ return date;
776
+ }
608
777
 
609
- return formatOptions;
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();
610
782
  }
611
783
 
612
- function mapPartType(type: Intl.DateTimeFormatPartTypes): DateSegmentType | null {
613
- switch (type) {
614
- case "year":
615
- return "year";
616
- case "month":
617
- return "month";
618
- case "day":
619
- return "day";
620
- case "hour":
621
- return "hour";
622
- case "minute":
623
- return "minute";
624
- case "second":
625
- return "second";
626
- case "dayPeriod":
627
- return "dayPeriod";
628
- case "era":
629
- return "era";
630
- case "timeZoneName":
631
- return "timeZoneName";
632
- case "literal":
633
- return "literal";
634
- default:
635
- return null;
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";
636
794
  }
795
+ return locale;
637
796
  }
638
797
 
639
- function getPlaceholderText(
640
- type: DateSegmentType,
641
- locale: string,
642
- granularity: "day" | "hour" | "minute" | "second",
643
- shouldForceLeadingZeros = false,
644
- ): string {
645
- if (type === "literal") return "";
646
- if (type === "dayPeriod") return "AM";
647
-
648
- const formatOptions: Intl.DateTimeFormatOptions = {
649
- year: "numeric",
650
- month: shouldForceLeadingZeros ? "2-digit" : "numeric",
651
- day: shouldForceLeadingZeros ? "2-digit" : "numeric",
652
- };
653
-
654
- if (granularity !== "day") {
655
- formatOptions.hour = "2-digit";
656
- formatOptions.minute = "2-digit";
657
- if (granularity === "second") {
658
- 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
+ );
659
820
  }
660
- }
661
-
662
- const formatter = new DateFormatter(locale, formatOptions);
663
- // Use a reference date with recognizable digits
664
- const refDate = new Date(2222, 10, 22, 22, 22, 22);
665
- const parts = formatter.formatToParts(refDate);
666
-
667
- for (const part of parts) {
668
- const mappedType = mapPartType(part.type);
669
- if (mappedType === type) {
670
- return part.value.replace(/\d/g, (_d) => {
671
- if (type === "year") return "y";
672
- if (type === "month") return "m";
673
- if (type === "day") return "d";
674
- return "–";
675
- });
821
+ if (rangeOverflow && maxValue != null) {
822
+ errors.push(
823
+ formatError(locale, "rangeOverflow", {
824
+ maxValue: dateFormatter.format(maxValue.toDate(timeZone)),
825
+ }),
826
+ );
676
827
  }
828
+ if (isUnavailable) errors.push(formatError(locale, "unavailableDate"));
677
829
  }
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
+ };
842
+ }
678
843
 
679
- switch (type) {
680
- case "year":
681
- return "yyyy";
682
- case "month":
683
- return "mm";
684
- case "day":
685
- return "dd";
686
- case "hour":
687
- return "––";
688
- case "minute":
689
- return "––";
690
- case "second":
691
- return "––";
692
- default:
693
- return "";
694
- }
844
+ function formatError(
845
+ locale: string,
846
+ key: DateFieldErrorKey,
847
+ args?: { minValue?: string; maxValue?: string },
848
+ ): string {
849
+ return formatDateFieldError(locale, key, args);
695
850
  }
696
851
 
697
852
  // Re-export so existing imports of CalendarDateTime keep resolving through this module.