@proyecto-viviana/solid-stately 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/calendar/createDateFieldState.d.ts +56 -18
- package/dist/calendar/createRangeCalendarState.d.ts +10 -0
- package/dist/calendar/createTimeFieldState.d.ts +38 -67
- package/dist/calendar/intl/index.d.ts +10 -0
- package/dist/collections/ListCollection.d.ts +8 -0
- package/dist/collections/createListState.d.ts +12 -0
- package/dist/collections/getChildNodes.d.ts +8 -0
- package/dist/collections/index.d.ts +2 -2
- package/dist/collections/types.d.ts +12 -0
- package/dist/color/intl/index.d.ts +14 -0
- package/dist/dnd/createDroppableCollectionState.d.ts +17 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3451 -1309
- package/dist/index.js.map +1 -1
- package/dist/selection/SelectionManager.d.ts +9 -0
- package/package.json +1 -1
- package/src/calendar/createCalendarState.ts +26 -4
- package/src/calendar/createDateFieldState.ts +614 -459
- package/src/calendar/createRangeCalendarState.ts +212 -11
- package/src/calendar/createTimeFieldState.ts +179 -467
- package/src/calendar/intl/index.ts +364 -0
- package/src/collections/ListCollection.ts +40 -1
- package/src/collections/createListState.ts +105 -2
- package/src/collections/getChildNodes.ts +31 -0
- package/src/collections/index.ts +3 -0
- package/src/collections/types.ts +13 -0
- package/src/color/Color.ts +104 -133
- package/src/color/intl/index.ts +1409 -0
- package/src/combobox/createComboBoxState.ts +48 -16
- package/src/dnd/createDroppableCollectionState.ts +125 -6
- package/src/index.ts +3 -0
- package/src/selection/SelectionManager.ts +15 -0
- package/src/steplist/createStepListState.ts +27 -4
- package/src/tabs/createTabListState.ts +9 -1
|
@@ -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
|
-
*
|
|
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,
|
|
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
|
-
|
|
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?:
|
|
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
|
-
/**
|
|
115
|
-
createCalendar?: (name:
|
|
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<
|
|
123
|
-
/** The
|
|
124
|
-
dateValue: Accessor<
|
|
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
|
|
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
|
-
|
|
135
|
-
/** Decrements a segment. */
|
|
136
|
-
|
|
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:
|
|
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:
|
|
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
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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,
|
|
316
|
+
untrack(() => new IncompleteDate(calendar(), hourCycle(), untrack(calendarValue))),
|
|
207
317
|
);
|
|
208
318
|
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
612
|
+
segment.type === "year" ||
|
|
613
|
+
segment.type === "month" ||
|
|
614
|
+
segment.type === "day" ||
|
|
615
|
+
segment.type === "hour"
|
|
388
616
|
) {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
530
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
):
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
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
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
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
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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.
|