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