@trackunit/react-form-components 1.25.2 → 1.25.4
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/index.cjs.js +781 -140
- package/index.esm.js +783 -143
- package/package.json +3 -3
- package/src/components/BaseInput/BaseInput.variants.d.ts +1 -1
- package/src/components/Checkbox/Checkbox.variants.d.ts +1 -1
- package/src/components/DateField/DateBaseInput/DateBaseInput.d.ts +40 -3
- package/src/components/DateField/DateBaseInput/DateBaseInputPickerContent.d.ts +34 -0
- package/src/components/DateField/DateBaseInput/utils/dateValueUtils.d.ts +11 -0
- package/src/components/DateField/DateBaseInput/utils/useCanonicalInputEmitter.d.ts +39 -0
- package/src/components/DateField/DateBaseInput/utils/useDatePickerController.d.ts +58 -0
- package/src/components/DateField/DateField.d.ts +23 -0
- package/src/index.d.ts +1 -1
- package/src/translation.d.ts +2 -2
- package/src/utilities/parseDateFieldValue.d.ts +29 -0
- package/src/utilities/useCreateInputChangeEvent.d.ts +11 -1
- package/src/utilities/useDateFieldLocale.d.ts +24 -0
package/index.cjs.js
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
var jsxRuntime = require('react/jsx-runtime');
|
|
4
4
|
var i18nLibraryTranslation = require('@trackunit/i18n-library-translation');
|
|
5
|
-
var dateAndTimeUtils = require('@trackunit/date-and-time-utils');
|
|
6
5
|
var reactComponents = require('@trackunit/react-components');
|
|
7
6
|
var react = require('react');
|
|
8
|
-
var ReactCalendar = require('react-calendar');
|
|
9
7
|
var tailwindMerge = require('tailwind-merge');
|
|
10
|
-
var uiDesignTokens = require('@trackunit/ui-design-tokens');
|
|
11
8
|
var cssClassVarianceUtilities = require('@trackunit/css-class-variance-utilities');
|
|
9
|
+
var uiDesignTokens = require('@trackunit/ui-design-tokens');
|
|
12
10
|
var stringTs = require('string-ts');
|
|
13
11
|
var usehooksTs = require('usehooks-ts');
|
|
12
|
+
var ReactCalendar = require('react-calendar');
|
|
13
|
+
var dateAndTimeUtils = require('@trackunit/date-and-time-utils');
|
|
14
14
|
var parsePhoneNumberFromString = require('libphonenumber-js');
|
|
15
15
|
var ReactSelect = require('react-select');
|
|
16
16
|
var ReactAsyncSelect = require('react-select/async');
|
|
@@ -30,7 +30,7 @@ var defaultTranslations = {
|
|
|
30
30
|
"dateField.actions.apply": "Apply",
|
|
31
31
|
"dateField.actions.cancel": "Cancel",
|
|
32
32
|
"dateField.actions.clear": "Clear",
|
|
33
|
-
"dateField.
|
|
33
|
+
"dateField.openPicker.ariaLabel": "Open date picker",
|
|
34
34
|
"dropzone.input.title": "Drag-and-drop file input",
|
|
35
35
|
"dropzone.label.default": "<clickable>Browse</clickable> or drag files here...",
|
|
36
36
|
"emailField.error.INVALID_EMAIL": "Please enter a valid email address",
|
|
@@ -108,6 +108,45 @@ const setupLibraryTranslations = () => {
|
|
|
108
108
|
};
|
|
109
109
|
|
|
110
110
|
const ISO_DATE_REGEX = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
111
|
+
const DATE_PART_SEQUENCE = ["day", "month", "year"];
|
|
112
|
+
const PLACEHOLDER_BY_PART = {
|
|
113
|
+
day: "dd",
|
|
114
|
+
month: "mm",
|
|
115
|
+
year: "yyyy",
|
|
116
|
+
};
|
|
117
|
+
const ISO_PART_ORDER = ["year", "month", "day"];
|
|
118
|
+
// i18next's built-in key-debug mode. The "Developer" entry in `LanguageSelection` writes this
|
|
119
|
+
// value to `localStorage["i18nextLng"]`, so this is the actual token we have to filter out
|
|
120
|
+
// before it reaches `Intl.DateTimeFormat`.
|
|
121
|
+
const KEY_DEBUG_LANGUAGE = "cimode";
|
|
122
|
+
const isDatePartName = (value) => DATE_PART_SEQUENCE.some(part => part === value);
|
|
123
|
+
const getBrowserLanguage = () => typeof navigator !== "undefined" && navigator.language ? navigator.language : "en";
|
|
124
|
+
function parseAndValidateDateParts(parts) {
|
|
125
|
+
const { year: yearValue, month: monthValue, day: dayValue } = parts;
|
|
126
|
+
if (yearValue.length !== 4 ||
|
|
127
|
+
monthValue.length < 1 ||
|
|
128
|
+
monthValue.length > 2 ||
|
|
129
|
+
dayValue.length < 1 ||
|
|
130
|
+
dayValue.length > 2) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const year = Number.parseInt(yearValue, 10);
|
|
134
|
+
const month = Number.parseInt(monthValue, 10) - 1;
|
|
135
|
+
const day = Number.parseInt(dayValue, 10);
|
|
136
|
+
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
if (month < 0 || month > 11 || day < 1 || day > 31) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
const local = new Date(year, month, day);
|
|
143
|
+
if (Number.isNaN(local.getTime()))
|
|
144
|
+
return null;
|
|
145
|
+
if (local.getFullYear() !== year || local.getMonth() !== month || local.getDate() !== day) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
return { year, month: month + 1, day };
|
|
149
|
+
}
|
|
111
150
|
function parseAndValidateYYYYMMDD(value) {
|
|
112
151
|
if (value === "")
|
|
113
152
|
return null;
|
|
@@ -117,18 +156,76 @@ function parseAndValidateYYYYMMDD(value) {
|
|
|
117
156
|
const [, y, m, d] = isoMatch;
|
|
118
157
|
if (y === undefined || m === undefined || d === undefined)
|
|
119
158
|
return null;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
159
|
+
return parseAndValidateDateParts({ year: y, month: m, day: d });
|
|
160
|
+
}
|
|
161
|
+
function buildDatePartValues(partOrder, getPartAt) {
|
|
162
|
+
const values = { day: "", month: "", year: "" };
|
|
163
|
+
for (const [index, part] of partOrder.entries()) {
|
|
164
|
+
const value = getPartAt(part, index);
|
|
165
|
+
if (value === undefined) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
values[part] = value;
|
|
169
|
+
}
|
|
170
|
+
return values;
|
|
171
|
+
}
|
|
172
|
+
function parseDateByPartOrder(value, partOrder) {
|
|
173
|
+
const parts = value
|
|
174
|
+
.split(/\D+/)
|
|
175
|
+
.map(part => part.trim())
|
|
176
|
+
.filter(Boolean);
|
|
177
|
+
if (parts.length !== DATE_PART_SEQUENCE.length) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const partValues = buildDatePartValues(partOrder, (_part, index) => parts[index]);
|
|
181
|
+
if (!partValues)
|
|
124
182
|
return null;
|
|
125
|
-
const
|
|
126
|
-
if (
|
|
183
|
+
const parsed = parseAndValidateDateParts(partValues);
|
|
184
|
+
if (!parsed)
|
|
127
185
|
return null;
|
|
128
|
-
|
|
186
|
+
return new Date(parsed.year, parsed.month - 1, parsed.day);
|
|
187
|
+
}
|
|
188
|
+
function parseQuickTypedDateByPartOrder(digits, partOrder) {
|
|
189
|
+
if (!/^\d{8}$/.test(digits)) {
|
|
129
190
|
return null;
|
|
130
191
|
}
|
|
131
|
-
|
|
192
|
+
let cursor = 0;
|
|
193
|
+
const partValues = buildDatePartValues(partOrder, part => {
|
|
194
|
+
const length = part === "year" ? 4 : 2;
|
|
195
|
+
const slice = digits.slice(cursor, cursor + length);
|
|
196
|
+
cursor += length;
|
|
197
|
+
return slice;
|
|
198
|
+
});
|
|
199
|
+
if (!partValues || cursor !== digits.length) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
const parsed = parseAndValidateDateParts(partValues);
|
|
203
|
+
if (!parsed)
|
|
204
|
+
return null;
|
|
205
|
+
return new Date(parsed.year, parsed.month - 1, parsed.day);
|
|
206
|
+
}
|
|
207
|
+
const datePartOrderByLocale = new Map();
|
|
208
|
+
const datePlaceholderByLocale = new Map();
|
|
209
|
+
function getDateFieldFormatParts(locale) {
|
|
210
|
+
return new Intl.DateTimeFormat(locale, {
|
|
211
|
+
day: "2-digit",
|
|
212
|
+
month: "2-digit",
|
|
213
|
+
year: "numeric",
|
|
214
|
+
}).formatToParts(new Date(2006, 0, 22));
|
|
215
|
+
}
|
|
216
|
+
function getDateFieldPartOrder(locale) {
|
|
217
|
+
const cached = datePartOrderByLocale.get(locale);
|
|
218
|
+
if (cached !== undefined)
|
|
219
|
+
return cached;
|
|
220
|
+
const orderedParts = [];
|
|
221
|
+
for (const part of getDateFieldFormatParts(locale)) {
|
|
222
|
+
if (isDatePartName(part.type)) {
|
|
223
|
+
orderedParts.push(part.type);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const resolved = orderedParts.length === DATE_PART_SEQUENCE.length ? orderedParts : ISO_PART_ORDER;
|
|
227
|
+
datePartOrderByLocale.set(locale, resolved);
|
|
228
|
+
return resolved;
|
|
132
229
|
}
|
|
133
230
|
/**
|
|
134
231
|
* Parses the value from a DateField change event (YYYY-MM-DD string) to a Date at local midnight.
|
|
@@ -144,6 +241,88 @@ function parseDateFieldValue(value) {
|
|
|
144
241
|
return null;
|
|
145
242
|
return new Date(parsed.year, parsed.month - 1, parsed.day);
|
|
146
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* Normalises a locale tag for use with `Intl.DateTimeFormat`.
|
|
246
|
+
*
|
|
247
|
+
* Filters out tags that are not real Intl locales (the empty string and i18next's `cimode`
|
|
248
|
+
* key-debug pseudo-language, surfaced as "Developer" in `LanguageSelection`) and replaces them
|
|
249
|
+
* with the browser locale. Region-qualified and bare-language Intl tags are returned unchanged
|
|
250
|
+
* so that the caller (`useDateFieldLocale`, callers passing a manual `locale` prop, etc.) keeps
|
|
251
|
+
* full control of the policy decision.
|
|
252
|
+
*
|
|
253
|
+
* Note: prior versions of this function additionally rewrote bare `"en"` to the browser locale
|
|
254
|
+
* so that UK browsers would not see US-style dates when the Manager UI language was English.
|
|
255
|
+
* That policy now lives in `useDateFieldLocale` (browser-first), so this function no longer
|
|
256
|
+
* special-cases `"en"`.
|
|
257
|
+
*/
|
|
258
|
+
function resolveDateFieldLocale(locale) {
|
|
259
|
+
if (!locale || locale === KEY_DEBUG_LANGUAGE) {
|
|
260
|
+
return getBrowserLanguage();
|
|
261
|
+
}
|
|
262
|
+
return locale;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Returns the locale-aligned placeholder shown in the DateField. Result is memoized per locale —
|
|
266
|
+
* see `datePlaceholderByLocale` above.
|
|
267
|
+
*/
|
|
268
|
+
function getDateFieldPlaceholder(locale) {
|
|
269
|
+
const cached = datePlaceholderByLocale.get(locale);
|
|
270
|
+
if (cached !== undefined)
|
|
271
|
+
return cached;
|
|
272
|
+
const placeholder = getDateFieldFormatParts(locale)
|
|
273
|
+
.map(part => {
|
|
274
|
+
if (part.type === "literal") {
|
|
275
|
+
return part.value;
|
|
276
|
+
}
|
|
277
|
+
if (isDatePartName(part.type)) {
|
|
278
|
+
return PLACEHOLDER_BY_PART[part.type];
|
|
279
|
+
}
|
|
280
|
+
return "";
|
|
281
|
+
})
|
|
282
|
+
.join("");
|
|
283
|
+
datePlaceholderByLocale.set(locale, placeholder);
|
|
284
|
+
return placeholder;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Formats a Date for display in the DateField while keeping event payloads canonical.
|
|
288
|
+
*/
|
|
289
|
+
function formatDateFieldValueForLocale(value, locale) {
|
|
290
|
+
return new Intl.DateTimeFormat(locale, {
|
|
291
|
+
day: "2-digit",
|
|
292
|
+
month: "2-digit",
|
|
293
|
+
year: "numeric",
|
|
294
|
+
}).format(value);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Parses localized or quick-typed DateField input into a Date.
|
|
298
|
+
* Supports canonical YYYY-MM-DD, locale-ordered quick typing (e.g. DDMMYYYY), and YYYYMMDD.
|
|
299
|
+
*/
|
|
300
|
+
function parseDateFieldDisplayValue(value, locale) {
|
|
301
|
+
const trimmedValue = value.trim();
|
|
302
|
+
if (trimmedValue === "") {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
const strictIsoDate = parseDateFieldValue(trimmedValue);
|
|
306
|
+
if (strictIsoDate) {
|
|
307
|
+
return strictIsoDate;
|
|
308
|
+
}
|
|
309
|
+
const digitsOnly = trimmedValue.replace(/\D/g, "");
|
|
310
|
+
if (digitsOnly.length === 8) {
|
|
311
|
+
const quickTypedIsoDate = parseQuickTypedDateByPartOrder(digitsOnly, ISO_PART_ORDER);
|
|
312
|
+
if (quickTypedIsoDate) {
|
|
313
|
+
return quickTypedIsoDate;
|
|
314
|
+
}
|
|
315
|
+
const quickTypedLocalizedDate = parseQuickTypedDateByPartOrder(digitsOnly, getDateFieldPartOrder(locale));
|
|
316
|
+
if (quickTypedLocalizedDate) {
|
|
317
|
+
return quickTypedLocalizedDate;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const localizedDate = parseDateByPartOrder(trimmedValue, getDateFieldPartOrder(locale));
|
|
321
|
+
if (localizedDate) {
|
|
322
|
+
return localizedDate;
|
|
323
|
+
}
|
|
324
|
+
return parseDateByPartOrder(trimmedValue, ISO_PART_ORDER);
|
|
325
|
+
}
|
|
147
326
|
/**
|
|
148
327
|
* Converts a YYYY-MM-DD string (e.g. from DateField) to an ISO string at UTC midnight for that calendar day.
|
|
149
328
|
* Use this when storing or sending date-only values so that the calendar day is preserved regardless of
|
|
@@ -177,44 +356,68 @@ function dateToISODateUTC(date) {
|
|
|
177
356
|
return `${year}-${mm}-${dd}T00:00:00.000Z`;
|
|
178
357
|
}
|
|
179
358
|
|
|
180
|
-
const
|
|
181
|
-
const target = document.createElement("input");
|
|
182
|
-
target.value = value;
|
|
183
|
-
if (sourceInput) {
|
|
184
|
-
target.name = sourceInput.name;
|
|
185
|
-
target.id = sourceInput.id;
|
|
186
|
-
}
|
|
187
|
-
const native = new Event("change", { bubbles: true });
|
|
188
|
-
return {
|
|
189
|
-
target,
|
|
190
|
-
currentTarget: target,
|
|
191
|
-
type: "change",
|
|
192
|
-
bubbles: native.bubbles,
|
|
193
|
-
cancelable: native.cancelable,
|
|
194
|
-
defaultPrevented: native.defaultPrevented,
|
|
195
|
-
eventPhase: native.eventPhase,
|
|
196
|
-
isTrusted: native.isTrusted,
|
|
197
|
-
nativeEvent: native,
|
|
198
|
-
timeStamp: native.timeStamp,
|
|
199
|
-
preventDefault: () => native.preventDefault(),
|
|
200
|
-
stopPropagation: () => native.stopPropagation(),
|
|
201
|
-
persist: () => {
|
|
202
|
-
return;
|
|
203
|
-
},
|
|
204
|
-
isDefaultPrevented: () => native.defaultPrevented,
|
|
205
|
-
isPropagationStopped: () => false,
|
|
206
|
-
};
|
|
207
|
-
};
|
|
359
|
+
const LANG_STORAGE_KEY = "i18nextLng";
|
|
208
360
|
/**
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
361
|
+
* Resolves the locale used by `DateField` for placeholder, parsing, and display formatting.
|
|
362
|
+
*
|
|
363
|
+
* Date format is a *regional* preference (e.g. `dd/mm/yyyy` vs `mm/dd/yyyy`), independent of the
|
|
364
|
+
* UI translation language. We therefore prefer `navigator.language` — a region-qualified browser
|
|
365
|
+
* locale (`en-GB`, `en-US`, `fr-CA`, `da-DK`, …) is the user's explicit regional preference and
|
|
366
|
+
* wins over the user-selected Manager UI language. The selected language is only used as a
|
|
367
|
+
* fallback when the browser locale is missing or has no region.
|
|
368
|
+
*
|
|
369
|
+
* Examples (selected | browser → resolved):
|
|
370
|
+
* - `en` | `en-GB` → `en-GB` (UK customers see `dd/mm/yyyy`)
|
|
371
|
+
* - `en` | `en-US` → `en-US` (US customers see `mm/dd/yyyy`)
|
|
372
|
+
* - `da` | `en-US` → `en-US` (browser region wins for date format)
|
|
373
|
+
* - `da` | `da-DK` → `da-DK` (`dd.mm.yyyy`, dotted separator is the native Danish format)
|
|
374
|
+
* - `en` | `en` (no region) → falls through to `resolveDateFieldLocale`, which keeps `en`
|
|
375
|
+
* - `cimode` | `en-US` → `en-US` (browser wins; i18next's key-debug pseudo-language is non-Intl)
|
|
376
|
+
* - `cimode` | `en` → `en` (`resolveDateFieldLocale` filters `cimode` out and returns the browser locale)
|
|
377
|
+
*
|
|
378
|
+
* Note: this hook reads `localStorage` and `navigator.language` synchronously and does not
|
|
379
|
+
* subscribe to changes. Re-renders on same-tab language change rely on the consuming component
|
|
380
|
+
* also using `useTranslation` (which re-renders on `i18next.languageChanged`). Cross-tab sync
|
|
381
|
+
* is not supported.
|
|
216
382
|
*/
|
|
217
|
-
const
|
|
383
|
+
const useDateFieldLocale = () => {
|
|
384
|
+
const browserLocale = typeof navigator !== "undefined" ? navigator.language : "";
|
|
385
|
+
const selectedLanguage = typeof localStorage !== "undefined" ? localStorage.getItem(LANG_STORAGE_KEY) : null;
|
|
386
|
+
return react.useMemo(() => {
|
|
387
|
+
if (browserLocale.includes("-")) {
|
|
388
|
+
return browserLocale;
|
|
389
|
+
}
|
|
390
|
+
return resolveDateFieldLocale(selectedLanguage || browserLocale || "en");
|
|
391
|
+
}, [browserLocale, selectedLanguage]);
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const cvaActionButton = cssClassVarianceUtilities.cvaMerge(["drop-shadow-none", "rounded-md"], {
|
|
395
|
+
variants: {
|
|
396
|
+
size: {
|
|
397
|
+
small: ["w-6", "h-6", "min-h-0"],
|
|
398
|
+
medium: ["w-6", "h-6", "min-h-0"],
|
|
399
|
+
large: ["w-8", "h-8"],
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
defaultVariants: {
|
|
403
|
+
size: "medium",
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
const cvaActionContainer = cssClassVarianceUtilities.cvaMerge(["flex", "items-center"], {
|
|
407
|
+
variants: {
|
|
408
|
+
size: {
|
|
409
|
+
//I just measured manually the top/bottom spacing
|
|
410
|
+
//when using the action button inside an input
|
|
411
|
+
//might need tweaking in the future
|
|
412
|
+
small: ["m-[1px]"],
|
|
413
|
+
medium: ["m-[3px]"],
|
|
414
|
+
large: ["m-[7px]"],
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
defaultVariants: {
|
|
418
|
+
size: "medium",
|
|
419
|
+
},
|
|
420
|
+
});
|
|
218
421
|
|
|
219
422
|
const cvaInputBase = cssClassVarianceUtilities.cvaMerge([
|
|
220
423
|
"component-baseInput-shadow",
|
|
@@ -399,34 +602,6 @@ const AddonRenderer = ({ addon, "data-testid": dataTestId, className, fieldSize,
|
|
|
399
602
|
return (jsxRuntime.jsx("div", { className: cvaInputAddon({ size: fieldSize, position, className }), "data-testid": dataTestId ? `${dataTestId}-addon${stringTs.titleCase(position)}` : null, children: addon }));
|
|
400
603
|
};
|
|
401
604
|
|
|
402
|
-
const cvaActionButton = cssClassVarianceUtilities.cvaMerge(["drop-shadow-none", "rounded-md"], {
|
|
403
|
-
variants: {
|
|
404
|
-
size: {
|
|
405
|
-
small: ["w-6", "h-6", "min-h-0"],
|
|
406
|
-
medium: ["w-6", "h-6", "min-h-0"],
|
|
407
|
-
large: ["w-8", "h-8"],
|
|
408
|
-
},
|
|
409
|
-
},
|
|
410
|
-
defaultVariants: {
|
|
411
|
-
size: "medium",
|
|
412
|
-
},
|
|
413
|
-
});
|
|
414
|
-
const cvaActionContainer = cssClassVarianceUtilities.cvaMerge(["flex", "items-center"], {
|
|
415
|
-
variants: {
|
|
416
|
-
size: {
|
|
417
|
-
//I just measured manually the top/bottom spacing
|
|
418
|
-
//when using the action button inside an input
|
|
419
|
-
//might need tweaking in the future
|
|
420
|
-
small: ["m-[1px]"],
|
|
421
|
-
medium: ["m-[3px]"],
|
|
422
|
-
large: ["m-[7px]"],
|
|
423
|
-
},
|
|
424
|
-
},
|
|
425
|
-
defaultVariants: {
|
|
426
|
-
size: "medium",
|
|
427
|
-
},
|
|
428
|
-
});
|
|
429
|
-
|
|
430
605
|
/**
|
|
431
606
|
* The ActionButton component is a wrapper over IconButton to perform an action when the onClick event is triggered.
|
|
432
607
|
*
|
|
@@ -601,7 +776,11 @@ const BaseInput = ({ className, isInvalid = false, "data-testid": dataTestId, pr
|
|
|
601
776
|
};
|
|
602
777
|
BaseInput.displayName = "BaseInput";
|
|
603
778
|
|
|
604
|
-
|
|
779
|
+
/**
|
|
780
|
+
* Parses a heterogeneous date input (Date, ISO string, locale-formatted string, or epoch number)
|
|
781
|
+
* into a `Date`. Returns `undefined` when the input is empty or unparseable.
|
|
782
|
+
*/
|
|
783
|
+
function parseToDate(v, locale) {
|
|
605
784
|
if (v === undefined || v === "")
|
|
606
785
|
return undefined;
|
|
607
786
|
if (v instanceof Date)
|
|
@@ -611,12 +790,13 @@ function parseToDate(v) {
|
|
|
611
790
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
|
612
791
|
}
|
|
613
792
|
const str = String(v);
|
|
614
|
-
const fromField =
|
|
793
|
+
const fromField = parseDateFieldDisplayValue(str, locale);
|
|
615
794
|
if (fromField !== null)
|
|
616
795
|
return fromField;
|
|
617
796
|
const fallback = new Date(str);
|
|
618
797
|
return Number.isNaN(fallback.getTime()) ? undefined : fallback;
|
|
619
798
|
}
|
|
799
|
+
/** Formats a `Date` as a canonical `YYYY-MM-DD` ISO date string, or `""` when undefined. */
|
|
620
800
|
function formatToInputString(d) {
|
|
621
801
|
if (!d)
|
|
622
802
|
return "";
|
|
@@ -628,10 +808,11 @@ function formatToInputString(d) {
|
|
|
628
808
|
.toPlainDate()
|
|
629
809
|
.toString();
|
|
630
810
|
}
|
|
811
|
+
/** Returns the epoch millis at the start of `d`'s local calendar day. */
|
|
631
812
|
function startOfDayMs(d) {
|
|
632
813
|
return dateAndTimeUtils.toDateUtil(dateAndTimeUtils.startOfDayUtil(d)).getTime();
|
|
633
814
|
}
|
|
634
|
-
/** Clamp a date to [minDate, maxDate]
|
|
815
|
+
/** Clamp a date to `[minDate, maxDate]`; returns the same date if in range or no bounds. */
|
|
635
816
|
function clampToRange(date, minDate, maxDate) {
|
|
636
817
|
if (date === null || date === undefined)
|
|
637
818
|
return null;
|
|
@@ -642,96 +823,532 @@ function clampToRange(date, minDate, maxDate) {
|
|
|
642
823
|
return maxDate;
|
|
643
824
|
return date;
|
|
644
825
|
}
|
|
826
|
+
|
|
827
|
+
const CALENDAR_TILE_CLASS = "react-calendar__tile";
|
|
828
|
+
const DAY_TILE_CLASS = "react-calendar__month-view__days__day";
|
|
829
|
+
const CALENDAR_TILE_DATE_ATTRIBUTE = "data-date-field-picker-date";
|
|
830
|
+
const CALENDAR_TILE_DATE_SELECTOR = `[${CALENDAR_TILE_DATE_ATTRIBUTE}]`;
|
|
645
831
|
/**
|
|
646
|
-
*
|
|
832
|
+
* Renders the calendar + Clear/Cancel/Apply buttons inside the DateBaseInput popover.
|
|
647
833
|
*
|
|
648
|
-
*
|
|
834
|
+
* Action callbacks are responsible for parent state changes only — this component handles
|
|
835
|
+
* closing the popover after each action completes.
|
|
836
|
+
*/
|
|
837
|
+
const DateBaseInputPickerContent = ({ pendingDate, selectedDate, tileDisabled, closePopover, onCalendarChange, onClear, onCancel, onApply, applyDate, "data-testid": dataTestId, }) => {
|
|
838
|
+
const [t] = useTranslation();
|
|
839
|
+
const displayDate = pendingDate ?? selectedDate ?? null;
|
|
840
|
+
const [activeStartDate, setActiveStartDate] = react.useState(displayDate ?? undefined);
|
|
841
|
+
const [focusedDate, setFocusedDate] = react.useState(displayDate);
|
|
842
|
+
const [calendarView, setCalendarView] = react.useState("month");
|
|
843
|
+
// Set when Enter is pressed on a day tile so the resulting calendar `onChange` (fired
|
|
844
|
+
// by the browser-synthesised click) can apply the date in a single keystroke instead
|
|
845
|
+
// of just staging it. Without this, keyboard users have to also Tab to Apply.
|
|
846
|
+
const commitOnNextChangeRef = react.useRef(false);
|
|
847
|
+
const calendarWrapperRef = react.useRef(null);
|
|
848
|
+
const pendingFocusValueRef = react.useRef(null);
|
|
849
|
+
const allowNextOutsideFocusRef = react.useRef(false);
|
|
850
|
+
const getCalendarTileButtons = react.useCallback(() => {
|
|
851
|
+
return Array.from(calendarWrapperRef.current?.querySelectorAll(`.${CALENDAR_TILE_CLASS}`) ?? []);
|
|
852
|
+
}, []);
|
|
853
|
+
const getButtonDateValue = react.useCallback((button) => {
|
|
854
|
+
return button.querySelector(CALENDAR_TILE_DATE_SELECTOR)?.getAttribute(CALENDAR_TILE_DATE_ATTRIBUTE) ?? null;
|
|
855
|
+
}, []);
|
|
856
|
+
const getTabbablePickerButtons = react.useCallback(() => {
|
|
857
|
+
return Array.from(calendarWrapperRef.current?.querySelectorAll("button:not(:disabled)") ?? []).filter(button => button.tabIndex >= 0);
|
|
858
|
+
}, []);
|
|
859
|
+
const getFocusableDateForView = react.useCallback((date, view) => {
|
|
860
|
+
switch (view) {
|
|
861
|
+
case "month":
|
|
862
|
+
return date;
|
|
863
|
+
case "year":
|
|
864
|
+
return new Date(date.getFullYear(), date.getMonth(), 1);
|
|
865
|
+
case "decade":
|
|
866
|
+
return new Date(date.getFullYear(), 0, 1);
|
|
867
|
+
case "century":
|
|
868
|
+
return new Date(Math.floor((date.getFullYear() - 1) / 10) * 10 + 1, 0, 1);
|
|
869
|
+
default: {
|
|
870
|
+
const exhaustiveCheck = view;
|
|
871
|
+
throw new Error(`Unknown calendar view: ${exhaustiveCheck}`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}, []);
|
|
875
|
+
const getCalendarTileColumnCount = react.useCallback((view) => {
|
|
876
|
+
switch (view) {
|
|
877
|
+
case "month":
|
|
878
|
+
return 7;
|
|
879
|
+
case "year":
|
|
880
|
+
case "decade":
|
|
881
|
+
case "century":
|
|
882
|
+
return 3;
|
|
883
|
+
default: {
|
|
884
|
+
const exhaustiveCheck = view;
|
|
885
|
+
throw new Error(`Unknown calendar view: ${exhaustiveCheck}`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}, []);
|
|
889
|
+
react.useEffect(() => {
|
|
890
|
+
setFocusedDate(displayDate);
|
|
891
|
+
if (displayDate !== null) {
|
|
892
|
+
setActiveStartDate(new Date(displayDate.getFullYear(), displayDate.getMonth(), 1));
|
|
893
|
+
}
|
|
894
|
+
}, [displayDate]);
|
|
895
|
+
react.useEffect(() => {
|
|
896
|
+
const buttons = getCalendarTileButtons();
|
|
897
|
+
if (buttons.length === 0)
|
|
898
|
+
return;
|
|
899
|
+
const focusedDateValue = focusedDate ? formatToInputString(getFocusableDateForView(focusedDate, calendarView)) : "";
|
|
900
|
+
const fallbackButton = buttons.find(button => !button.disabled);
|
|
901
|
+
const focusedButton = focusedDateValue
|
|
902
|
+
? buttons.find(button => getButtonDateValue(button) === focusedDateValue && !button.disabled)
|
|
903
|
+
: undefined;
|
|
904
|
+
const rovingButton = focusedButton ?? fallbackButton;
|
|
905
|
+
buttons.forEach(button => {
|
|
906
|
+
button.tabIndex = button === rovingButton ? 0 : -1;
|
|
907
|
+
});
|
|
908
|
+
if (pendingFocusValueRef.current === null)
|
|
909
|
+
return;
|
|
910
|
+
const buttonToFocus = buttons.find(button => getButtonDateValue(button) === pendingFocusValueRef.current && !button.disabled);
|
|
911
|
+
if (buttonToFocus === undefined)
|
|
912
|
+
return;
|
|
913
|
+
pendingFocusValueRef.current = null;
|
|
914
|
+
buttonToFocus.focus({ preventScroll: true });
|
|
915
|
+
}, [activeStartDate, calendarView, focusedDate, getButtonDateValue, getCalendarTileButtons, getFocusableDateForView]);
|
|
916
|
+
react.useEffect(() => {
|
|
917
|
+
const handlePointerDown = (event) => {
|
|
918
|
+
const target = event.target;
|
|
919
|
+
allowNextOutsideFocusRef.current =
|
|
920
|
+
target instanceof Node && calendarWrapperRef.current !== null && !calendarWrapperRef.current.contains(target);
|
|
921
|
+
};
|
|
922
|
+
const handleFocusIn = (event) => {
|
|
923
|
+
if (allowNextOutsideFocusRef.current) {
|
|
924
|
+
allowNextOutsideFocusRef.current = false;
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
const target = event.target;
|
|
928
|
+
if (!(target instanceof Node) ||
|
|
929
|
+
calendarWrapperRef.current === null ||
|
|
930
|
+
calendarWrapperRef.current.contains(target)) {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
getTabbablePickerButtons()[0]?.focus({ preventScroll: true });
|
|
934
|
+
};
|
|
935
|
+
document.addEventListener("pointerdown", handlePointerDown, true);
|
|
936
|
+
document.addEventListener("focusin", handleFocusIn);
|
|
937
|
+
return () => {
|
|
938
|
+
document.removeEventListener("pointerdown", handlePointerDown, true);
|
|
939
|
+
document.removeEventListener("focusin", handleFocusIn);
|
|
940
|
+
};
|
|
941
|
+
}, [getTabbablePickerButtons]);
|
|
942
|
+
const handleClear = () => {
|
|
943
|
+
onClear();
|
|
944
|
+
closePopover();
|
|
945
|
+
};
|
|
946
|
+
const handleCancel = () => {
|
|
947
|
+
onCancel();
|
|
948
|
+
closePopover();
|
|
949
|
+
};
|
|
950
|
+
const handleApply = () => {
|
|
951
|
+
onApply();
|
|
952
|
+
closePopover();
|
|
953
|
+
};
|
|
954
|
+
const moveFocusBy = react.useCallback((target, steps) => {
|
|
955
|
+
const dateValue = getButtonDateValue(target);
|
|
956
|
+
if (dateValue === null)
|
|
957
|
+
return;
|
|
958
|
+
const currentDate = parseDateFieldValue(dateValue);
|
|
959
|
+
if (currentDate === null)
|
|
960
|
+
return;
|
|
961
|
+
const nextDate = new Date(currentDate);
|
|
962
|
+
switch (calendarView) {
|
|
963
|
+
case "month":
|
|
964
|
+
nextDate.setDate(currentDate.getDate() + steps);
|
|
965
|
+
break;
|
|
966
|
+
case "year":
|
|
967
|
+
nextDate.setMonth(currentDate.getMonth() + steps);
|
|
968
|
+
break;
|
|
969
|
+
case "decade":
|
|
970
|
+
nextDate.setFullYear(currentDate.getFullYear() + steps);
|
|
971
|
+
break;
|
|
972
|
+
case "century":
|
|
973
|
+
nextDate.setFullYear(currentDate.getFullYear() + steps * 10);
|
|
974
|
+
break;
|
|
975
|
+
default: {
|
|
976
|
+
const exhaustiveCheck = calendarView;
|
|
977
|
+
throw new Error(`Unknown calendar view: ${exhaustiveCheck}`);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
const focusableDate = getFocusableDateForView(nextDate, calendarView);
|
|
981
|
+
pendingFocusValueRef.current = formatToInputString(focusableDate);
|
|
982
|
+
setFocusedDate(focusableDate);
|
|
983
|
+
setActiveStartDate(new Date(focusableDate.getFullYear(), focusableDate.getMonth(), 1));
|
|
984
|
+
}, [calendarView, getButtonDateValue, getFocusableDateForView]);
|
|
985
|
+
const handleKeyDownCapture = (e) => {
|
|
986
|
+
const target = e.target;
|
|
987
|
+
if (!(target instanceof HTMLButtonElement) || !target.classList.contains(CALENDAR_TILE_CLASS))
|
|
988
|
+
return;
|
|
989
|
+
if (e.key === "Enter" && target.classList.contains(DAY_TILE_CLASS)) {
|
|
990
|
+
commitOnNextChangeRef.current = true;
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
if (e.key === "ArrowLeft") {
|
|
994
|
+
e.preventDefault();
|
|
995
|
+
moveFocusBy(target, -1);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
if (e.key === "ArrowRight") {
|
|
999
|
+
e.preventDefault();
|
|
1000
|
+
moveFocusBy(target, 1);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
if (e.key === "ArrowUp") {
|
|
1004
|
+
e.preventDefault();
|
|
1005
|
+
moveFocusBy(target, -getCalendarTileColumnCount(calendarView));
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
if (e.key === "ArrowDown") {
|
|
1009
|
+
e.preventDefault();
|
|
1010
|
+
moveFocusBy(target, getCalendarTileColumnCount(calendarView));
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
return (jsxRuntime.jsxs("div", { className: tailwindMerge.twMerge("flex w-min flex-col overflow-hidden rounded-md border border-neutral-300 bg-white p-0"), onKeyDownCapture: handleKeyDownCapture, ref: calendarWrapperRef, children: [jsxRuntime.jsx(ReactCalendar, { activeStartDate: activeStartDate, allowPartialRange: true, className: tailwindMerge.twMerge("custom-day-picker", "range-picker", "p-0"), onActiveStartDateChange: ({ activeStartDate: nextActiveStartDate }) => {
|
|
1014
|
+
setActiveStartDate(nextActiveStartDate ?? undefined);
|
|
1015
|
+
}, onChange: val => {
|
|
1016
|
+
const next = val instanceof Date ? val : Array.isArray(val) ? (val[0] instanceof Date ? val[0] : null) : null;
|
|
1017
|
+
if (commitOnNextChangeRef.current) {
|
|
1018
|
+
commitOnNextChangeRef.current = false;
|
|
1019
|
+
applyDate(next);
|
|
1020
|
+
closePopover();
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
onCalendarChange(next);
|
|
1024
|
+
}, onViewChange: ({ action, activeStartDate: nextActiveStartDate, view }) => {
|
|
1025
|
+
setCalendarView(view);
|
|
1026
|
+
const nextFocusedDate = getFocusableDateForView((action === "drillDown" ? nextActiveStartDate : focusedDate) ?? nextActiveStartDate ?? new Date(), view);
|
|
1027
|
+
pendingFocusValueRef.current = formatToInputString(nextFocusedDate);
|
|
1028
|
+
setFocusedDate(nextFocusedDate);
|
|
1029
|
+
setActiveStartDate(nextActiveStartDate ?? undefined);
|
|
1030
|
+
}, selectRange: false, tileContent: ({ date }) => jsxRuntime.jsx("span", { "aria-hidden": true, "data-date-field-picker-date": formatToInputString(date) }), tileDisabled: tileDisabled, value: displayDate, view: calendarView }), jsxRuntime.jsx("hr", {}), jsxRuntime.jsxs("div", { className: "flex w-full justify-between gap-2 px-4 py-3", children: [jsxRuntime.jsx(reactComponents.Button, { className: "mr-auto", "data-testid": dataTestId ? `${dataTestId}-clear-button` : undefined, onClick: handleClear, size: "small", variant: "secondary", children: t("dateField.actions.clear") }), jsxRuntime.jsxs("div", { className: "flex gap-2", children: [jsxRuntime.jsx(reactComponents.Button, { "data-testid": dataTestId ? `${dataTestId}-cancel-button` : undefined, onClick: handleCancel, size: "small", variant: "ghost-neutral", children: t("dateField.actions.cancel") }), jsxRuntime.jsx(reactComponents.Button, { "data-testid": dataTestId ? `${dataTestId}-apply-button` : undefined, onClick: handleApply, size: "small", children: t("dateField.actions.apply") })] })] })] }));
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
function buildSyntheticInputEvent(type, value, sourceInput) {
|
|
1034
|
+
const target = document.createElement("input");
|
|
1035
|
+
target.value = value;
|
|
1036
|
+
if (sourceInput) {
|
|
1037
|
+
target.name = sourceInput.name;
|
|
1038
|
+
target.id = sourceInput.id;
|
|
1039
|
+
}
|
|
1040
|
+
const native = new Event(type, { bubbles: true });
|
|
1041
|
+
return {
|
|
1042
|
+
target,
|
|
1043
|
+
currentTarget: target,
|
|
1044
|
+
type,
|
|
1045
|
+
bubbles: native.bubbles,
|
|
1046
|
+
cancelable: native.cancelable,
|
|
1047
|
+
defaultPrevented: native.defaultPrevented,
|
|
1048
|
+
eventPhase: native.eventPhase,
|
|
1049
|
+
isTrusted: native.isTrusted,
|
|
1050
|
+
nativeEvent: native,
|
|
1051
|
+
timeStamp: native.timeStamp,
|
|
1052
|
+
preventDefault: () => native.preventDefault(),
|
|
1053
|
+
stopPropagation: () => native.stopPropagation(),
|
|
1054
|
+
persist: () => undefined,
|
|
1055
|
+
isDefaultPrevented: () => native.defaultPrevented,
|
|
1056
|
+
isPropagationStopped: () => false,
|
|
1057
|
+
relatedTarget: null,
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Returns a stable function that builds a synthetic change event for an input with a given value.
|
|
1062
|
+
* Use when calling onChange from code (e.g. clear, apply, clamped value) so consumers
|
|
1063
|
+
* that use `onChange={e => setValue(e.target.value)}` still receive the expected shape.
|
|
649
1064
|
*
|
|
650
|
-
*
|
|
1065
|
+
* @example
|
|
1066
|
+
* const createInputChangeEvent = useCreateInputChangeEvent();
|
|
1067
|
+
* onChange?.(createInputChangeEvent(str, inputRef.current));
|
|
651
1068
|
*/
|
|
652
|
-
const
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
1069
|
+
const useCreateInputChangeEvent = () => react.useCallback((value, sourceInput) => buildSyntheticInputEvent("change", value, sourceInput), []);
|
|
1070
|
+
/**
|
|
1071
|
+
* Returns a stable function that builds a synthetic blur (FocusEvent) for an input with a given value.
|
|
1072
|
+
* Use when calling onBlur from code (e.g. when a typed-in value is normalized on blur) so consumers
|
|
1073
|
+
* that use `onBlur={e => doSomething(e.target.value)}` still receive the expected shape.
|
|
1074
|
+
*
|
|
1075
|
+
* @example
|
|
1076
|
+
* const createInputBlurEvent = useCreateInputBlurEvent();
|
|
1077
|
+
* onBlur?.(createInputBlurEvent(canonicalValue, inputRef.current));
|
|
1078
|
+
*/
|
|
1079
|
+
const useCreateInputBlurEvent = () => react.useCallback((value, sourceInput) => buildSyntheticInputEvent("blur", value, sourceInput), []);
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Owns the underlying `<input>` ref, ref forwarding, the DOM-value sync layout effect, and the
|
|
1083
|
+
* synthetic change/blur emitters used to surface the canonical (ISO) value to consumers while
|
|
1084
|
+
* the displayed input value remains in the locale-specific format.
|
|
1085
|
+
*
|
|
1086
|
+
* The dual format is the reason this hook exists at all: the rendered `value` is locale-formatted
|
|
1087
|
+
* (e.g. `15/03/2025`) but `e.target.value` on `onChange`/`onBlur` should be canonical ISO
|
|
1088
|
+
* (`2025-03-15`). The synthetic events carry the canonical value, and the layout effect ensures
|
|
1089
|
+
* the DOM value is reconciled back to the display string on the next paint.
|
|
1090
|
+
*/
|
|
1091
|
+
const useCanonicalInputEmitter = ({ ref, onChange, onBlur, resolvedValue, }) => {
|
|
668
1092
|
const inputRef = react.useRef(null);
|
|
669
1093
|
const createInputChangeEvent = useCreateInputChangeEvent();
|
|
670
|
-
const
|
|
671
|
-
react.
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
1094
|
+
const createInputBlurEvent = useCreateInputBlurEvent();
|
|
1095
|
+
const setInputRef = react.useCallback((node) => {
|
|
1096
|
+
inputRef.current = node;
|
|
1097
|
+
if (typeof ref === "function") {
|
|
1098
|
+
ref(node);
|
|
1099
|
+
}
|
|
1100
|
+
else if (ref !== null && ref !== undefined) {
|
|
1101
|
+
ref.current = node;
|
|
1102
|
+
}
|
|
1103
|
+
}, [ref]);
|
|
1104
|
+
react.useLayoutEffect(() => {
|
|
1105
|
+
const input = inputRef.current;
|
|
1106
|
+
if (input && input.value !== resolvedValue) {
|
|
1107
|
+
input.value = resolvedValue;
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
1110
|
+
const emitCanonicalValueChange = react.useCallback((canonicalValue) => {
|
|
1111
|
+
if (inputRef.current) {
|
|
1112
|
+
inputRef.current.value = canonicalValue;
|
|
1113
|
+
}
|
|
1114
|
+
onChange?.(createInputChangeEvent(canonicalValue, inputRef.current));
|
|
1115
|
+
}, [createInputChangeEvent, onChange]);
|
|
1116
|
+
const emitCanonicalValueBlur = react.useCallback((canonicalValue) => {
|
|
1117
|
+
if (inputRef.current) {
|
|
1118
|
+
inputRef.current.value = canonicalValue;
|
|
1119
|
+
}
|
|
1120
|
+
onBlur?.(createInputBlurEvent(canonicalValue, inputRef.current));
|
|
1121
|
+
if (inputRef.current && inputRef.current.value !== resolvedValue) {
|
|
1122
|
+
inputRef.current.value = resolvedValue;
|
|
1123
|
+
}
|
|
1124
|
+
}, [createInputBlurEvent, onBlur, resolvedValue]);
|
|
1125
|
+
return react.useMemo(() => ({
|
|
1126
|
+
inputRef,
|
|
1127
|
+
setInputRef,
|
|
1128
|
+
createInputChangeEvent,
|
|
1129
|
+
createInputBlurEvent,
|
|
1130
|
+
emitCanonicalValueChange,
|
|
1131
|
+
emitCanonicalValueBlur,
|
|
1132
|
+
}), [setInputRef, createInputChangeEvent, createInputBlurEvent, emitCanonicalValueChange, emitCanonicalValueBlur]);
|
|
1133
|
+
};
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Picker state machine for `DateBaseInput`. Owns the staged date, the popover's
|
|
1137
|
+
* "was-open" tracking, the tile-disabled predicate, and all four user-driven action handlers
|
|
1138
|
+
* plus the auto-apply behaviour for outside dismissals.
|
|
1139
|
+
*/
|
|
1140
|
+
const useDatePickerController = ({ selectedDate, minDate, maxDate, commitValue, notifyPickerClose, }) => {
|
|
1141
|
+
const [pendingDate, setPendingDate] = react.useState(null);
|
|
1142
|
+
const wasOpenRef = react.useRef(false);
|
|
675
1143
|
const tileDisabled = react.useCallback(({ date, view }) => {
|
|
676
1144
|
if (view !== "month")
|
|
677
1145
|
return false;
|
|
678
|
-
const minDate = parseToDate(min);
|
|
679
|
-
const maxDate = parseToDate(max);
|
|
680
1146
|
const dayStart = startOfDayMs(date);
|
|
681
1147
|
if (minDate !== undefined && dayStart < startOfDayMs(minDate))
|
|
682
1148
|
return true;
|
|
683
1149
|
if (maxDate !== undefined && dayStart > startOfDayMs(maxDate))
|
|
684
1150
|
return true;
|
|
685
1151
|
return false;
|
|
686
|
-
}, [
|
|
687
|
-
const
|
|
1152
|
+
}, [minDate, maxDate]);
|
|
1153
|
+
const onCalendarChange = react.useCallback((next) => {
|
|
688
1154
|
setPendingDate(next);
|
|
689
1155
|
}, []);
|
|
690
|
-
const
|
|
1156
|
+
const onClear = react.useCallback(() => {
|
|
691
1157
|
setPendingDate(null);
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
1158
|
+
commitValue("");
|
|
1159
|
+
notifyPickerClose("", "clear");
|
|
1160
|
+
}, [commitValue, notifyPickerClose]);
|
|
1161
|
+
const onCancel = react.useCallback(() => {
|
|
1162
|
+
notifyPickerClose(formatToInputString(selectedDate), "cancel");
|
|
1163
|
+
}, [notifyPickerClose, selectedDate]);
|
|
1164
|
+
const commitDate = react.useCallback((date) => {
|
|
1165
|
+
const clamped = clampToRange(date, minDate, maxDate);
|
|
1166
|
+
const canonicalStr = clamped ? formatToInputString(clamped) : "";
|
|
1167
|
+
commitValue(canonicalStr);
|
|
1168
|
+
notifyPickerClose(canonicalStr, "apply");
|
|
1169
|
+
}, [commitValue, maxDate, minDate, notifyPickerClose]);
|
|
1170
|
+
const onApply = react.useCallback(() => commitDate(pendingDate), [commitDate, pendingDate]);
|
|
1171
|
+
const applyDate = react.useCallback((next) => {
|
|
1172
|
+
setPendingDate(next);
|
|
1173
|
+
commitDate(next);
|
|
1174
|
+
}, [commitDate]);
|
|
1175
|
+
const onPopoverOpenStateChange = react.useCallback((open) => {
|
|
1176
|
+
if (open) {
|
|
1177
|
+
wasOpenRef.current = true;
|
|
1178
|
+
setPendingDate(selectedDate ?? null);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
if (!wasOpenRef.current)
|
|
1182
|
+
return;
|
|
1183
|
+
wasOpenRef.current = false;
|
|
703
1184
|
const clamped = clampToRange(pendingDate, minDate, maxDate);
|
|
704
|
-
const
|
|
1185
|
+
const canonicalStr = clamped ? formatToInputString(clamped) : "";
|
|
1186
|
+
const currentCanonical = formatToInputString(selectedDate);
|
|
1187
|
+
if (canonicalStr !== currentCanonical) {
|
|
1188
|
+
commitValue(canonicalStr);
|
|
1189
|
+
}
|
|
1190
|
+
notifyPickerClose(canonicalStr, "outside");
|
|
1191
|
+
}, [commitValue, maxDate, minDate, notifyPickerClose, pendingDate, selectedDate]);
|
|
1192
|
+
return react.useMemo(() => ({
|
|
1193
|
+
pendingDate,
|
|
1194
|
+
tileDisabled,
|
|
1195
|
+
onCalendarChange,
|
|
1196
|
+
onClear,
|
|
1197
|
+
onCancel,
|
|
1198
|
+
onApply,
|
|
1199
|
+
applyDate,
|
|
1200
|
+
onPopoverOpenStateChange,
|
|
1201
|
+
}), [pendingDate, tileDisabled, onCalendarChange, onClear, onCancel, onApply, applyDate, onPopoverOpenStateChange]);
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* A wrapper around BaseInput with a pop-up day picker using the same calendar UI as DayPicker.
|
|
1206
|
+
*
|
|
1207
|
+
* The value is formatted to an ISO date string (YYYY-MM-DD)
|
|
1208
|
+
*
|
|
1209
|
+
* NOTE: If shown with a label, please use the `DateField` component instead.
|
|
1210
|
+
*/
|
|
1211
|
+
const DateBaseInput = ({ min, max, defaultValue, value, ref, onChange, onBlur, onPickerClose, openOnFocus = false, locale: localeProp, suffix: suffixProp, "data-testid": dataTestId, ...rest }) => {
|
|
1212
|
+
const [t] = useTranslation();
|
|
1213
|
+
const autoLocale = useDateFieldLocale();
|
|
1214
|
+
const locale = localeProp ? resolveDateFieldLocale(localeProp) : autoLocale;
|
|
1215
|
+
const isControlled = value !== undefined;
|
|
1216
|
+
const [internalValue, setInternalValue] = react.useState(() => formatToInputString(parseToDate(defaultValue, locale)));
|
|
1217
|
+
const rawValue = isControlled
|
|
1218
|
+
? typeof value === "string"
|
|
1219
|
+
? value
|
|
1220
|
+
: formatToInputString(parseToDate(value, locale))
|
|
1221
|
+
: internalValue;
|
|
1222
|
+
const resolvedValue = react.useMemo(() => {
|
|
1223
|
+
const parsed = parseDateFieldValue(rawValue) ?? parseDateFieldDisplayValue(rawValue, locale);
|
|
1224
|
+
return parsed ? formatDateFieldValueForLocale(parsed, locale) : rawValue;
|
|
1225
|
+
}, [rawValue, locale]);
|
|
1226
|
+
const selectedDate = react.useMemo(() => isControlled && typeof value === "string"
|
|
1227
|
+
? (parseDateFieldDisplayValue(value, locale) ?? undefined)
|
|
1228
|
+
: parseToDate(isControlled ? value : internalValue, locale), [isControlled, value, internalValue, locale]);
|
|
1229
|
+
const minDate = react.useMemo(() => parseToDate(min, locale), [min, locale]);
|
|
1230
|
+
const maxDate = react.useMemo(() => parseToDate(max, locale), [max, locale]);
|
|
1231
|
+
const { inputRef, setInputRef, createInputChangeEvent, createInputBlurEvent, emitCanonicalValueChange, emitCanonicalValueBlur, } = useCanonicalInputEmitter({ ref, onChange, onBlur, resolvedValue });
|
|
1232
|
+
const commitValue = react.useCallback((canonicalValue) => {
|
|
705
1233
|
if (!isControlled)
|
|
706
|
-
setInternalValue(
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
1234
|
+
setInternalValue(canonicalValue);
|
|
1235
|
+
emitCanonicalValueChange(canonicalValue);
|
|
1236
|
+
}, [emitCanonicalValueChange, isControlled]);
|
|
1237
|
+
// Guards against `openOnFocus` re-opening the picker as soon as Floating UI returns focus
|
|
1238
|
+
// to the input after a close (Cancel/Apply/Escape/outside-press). The flag is set when the
|
|
1239
|
+
// picker closes and consumed by the very next focus event on the input; a setTimeout
|
|
1240
|
+
// fallback clears it so a later, user-initiated focus can still re-open the picker.
|
|
1241
|
+
const skipNextFocusOpenRef = react.useRef(false);
|
|
1242
|
+
const notifyPickerClose = react.useCallback((canonicalValue, reason) => {
|
|
1243
|
+
onPickerClose?.(createInputBlurEvent(canonicalValue, inputRef.current), reason);
|
|
1244
|
+
if (reason === "outside") {
|
|
1245
|
+
// The user dismissed the popover without an explicit action — they're done with
|
|
1246
|
+
// this field. Emit a synthetic blur so form libraries (e.g. react-hook-form) can
|
|
1247
|
+
// run validation; the underlying `<input>` may never have lost focus naturally
|
|
1248
|
+
// during the popover's lifecycle, depending on the browser and click target.
|
|
1249
|
+
emitCanonicalValueBlur(canonicalValue);
|
|
1250
|
+
}
|
|
1251
|
+
skipNextFocusOpenRef.current = true;
|
|
1252
|
+
setTimeout(() => {
|
|
1253
|
+
skipNextFocusOpenRef.current = false;
|
|
1254
|
+
}, 0);
|
|
1255
|
+
// Floating UI's `FloatingFocusManager` suppresses `returnFocus` for outside-press
|
|
1256
|
+
// dismissals, so the trigger never gets focus back. Restore focus to the input
|
|
1257
|
+
// ourselves, but only if focus was lost (active element is the body) — never steal
|
|
1258
|
+
// focus from a focusable element the user just clicked. The microtask defers this
|
|
1259
|
+
// past Floating UI's own focus handling and React's commit phase.
|
|
1260
|
+
queueMicrotask(() => {
|
|
1261
|
+
if (document.activeElement === document.body) {
|
|
1262
|
+
inputRef.current?.focus({ preventScroll: true });
|
|
1263
|
+
}
|
|
1264
|
+
});
|
|
1265
|
+
}, [createInputBlurEvent, emitCanonicalValueBlur, inputRef, onPickerClose]);
|
|
1266
|
+
const { pendingDate, tileDisabled, onCalendarChange, onClear, onCancel, onApply, applyDate, onPopoverOpenStateChange, } = useDatePickerController({ selectedDate, minDate, maxDate, commitValue, notifyPickerClose });
|
|
710
1267
|
const handleInputChange = react.useCallback((e) => {
|
|
711
1268
|
const raw = e.target.value;
|
|
712
|
-
const parsed =
|
|
713
|
-
const minDate = parseToDate(min);
|
|
714
|
-
const maxDate = parseToDate(max);
|
|
1269
|
+
const parsed = parseDateFieldDisplayValue(raw, locale);
|
|
715
1270
|
if (parsed !== null) {
|
|
716
1271
|
const clamped = clampToRange(parsed, minDate, maxDate);
|
|
717
|
-
|
|
718
|
-
if (!isControlled)
|
|
719
|
-
setInternalValue(str);
|
|
720
|
-
onChange?.(createInputChangeEvent(str, inputRef.current));
|
|
1272
|
+
commitValue(clamped ? formatToInputString(clamped) : "");
|
|
721
1273
|
}
|
|
722
1274
|
else {
|
|
723
1275
|
if (!isControlled)
|
|
724
1276
|
setInternalValue(raw);
|
|
725
1277
|
onChange?.(e);
|
|
726
1278
|
}
|
|
727
|
-
}, [
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
1279
|
+
}, [commitValue, isControlled, locale, minDate, maxDate, onChange]);
|
|
1280
|
+
const handleInputBlur = react.useCallback((e) => {
|
|
1281
|
+
const raw = e.target.value;
|
|
1282
|
+
if (raw === "") {
|
|
1283
|
+
if (!isControlled)
|
|
1284
|
+
setInternalValue("");
|
|
1285
|
+
emitCanonicalValueBlur("");
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
const parsed = parseDateFieldDisplayValue(raw, locale);
|
|
1289
|
+
if (parsed !== null) {
|
|
1290
|
+
const clamped = clampToRange(parsed, minDate, maxDate);
|
|
1291
|
+
const canonicalStr = clamped ? formatToInputString(clamped) : "";
|
|
1292
|
+
if (!isControlled)
|
|
1293
|
+
setInternalValue(canonicalStr);
|
|
1294
|
+
emitCanonicalValueBlur(canonicalStr);
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
if (!isControlled)
|
|
1298
|
+
setInternalValue("");
|
|
1299
|
+
onChange?.(createInputChangeEvent("", inputRef.current));
|
|
1300
|
+
onBlur?.(createInputBlurEvent("", inputRef.current));
|
|
1301
|
+
}, [
|
|
1302
|
+
createInputBlurEvent,
|
|
1303
|
+
createInputChangeEvent,
|
|
1304
|
+
emitCanonicalValueBlur,
|
|
1305
|
+
inputRef,
|
|
1306
|
+
isControlled,
|
|
1307
|
+
locale,
|
|
1308
|
+
maxDate,
|
|
1309
|
+
minDate,
|
|
1310
|
+
onBlur,
|
|
1311
|
+
onChange,
|
|
1312
|
+
]);
|
|
1313
|
+
const isPickerDisabled = Boolean(rest.disabled) || Boolean(rest.readOnly);
|
|
1314
|
+
return (jsxRuntime.jsx(reactComponents.Popover, { activation: { click: false, hover: false, keyboardHandlers: false }, isModal: true, onOpenStateChange: onPopoverOpenStateChange, placement: "bottom-start", children: ({ isOpen, setIsOpen }) => {
|
|
1315
|
+
// Click activation is disabled on the popover itself so that focusing or clicking
|
|
1316
|
+
// the input does not open the picker — only the calendar IconButton does. Toggling
|
|
1317
|
+
// via the IconButton bypasses Floating UI's `onOpenChange`, so we forward the state
|
|
1318
|
+
// change to the controller manually to keep `notifyPickerClose` and pending-date
|
|
1319
|
+
// staging in sync with the rest of the lifecycle.
|
|
1320
|
+
const togglePopover = () => {
|
|
1321
|
+
const next = !isOpen;
|
|
1322
|
+
setIsOpen(next);
|
|
1323
|
+
onPopoverOpenStateChange(next);
|
|
1324
|
+
};
|
|
1325
|
+
// Restores the previous keyboard affordance: pressing Space while the input is focused
|
|
1326
|
+
// opens (or toggles) the picker. Enter is intentionally not handled here so that form
|
|
1327
|
+
// submission semantics on a text input remain intact.
|
|
1328
|
+
const handleInputKeyDown = (event) => {
|
|
1329
|
+
if (event.key === " " && !isPickerDisabled) {
|
|
1330
|
+
event.preventDefault();
|
|
1331
|
+
togglePopover();
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
rest.onKeyDown?.(event);
|
|
1335
|
+
};
|
|
1336
|
+
// Opt-in: opens the picker the moment the input gains focus. The `skipNextFocusOpenRef`
|
|
1337
|
+
// guard prevents the synthetic focus event fired by FloatingFocusManager's returnFocus
|
|
1338
|
+
// (after Cancel/Apply/Escape/outside-press) from immediately reopening the picker.
|
|
1339
|
+
const handleInputFocus = (event) => {
|
|
1340
|
+
rest.onFocus?.(event);
|
|
1341
|
+
if (skipNextFocusOpenRef.current) {
|
|
1342
|
+
skipNextFocusOpenRef.current = false;
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
if (openOnFocus && !isOpen && !isPickerDisabled) {
|
|
1346
|
+
setIsOpen(true);
|
|
1347
|
+
onPopoverOpenStateChange(true);
|
|
1348
|
+
}
|
|
1349
|
+
};
|
|
1350
|
+
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(reactComponents.PopoverTrigger, { children: jsxRuntime.jsx("div", { className: "flex w-full min-w-0 items-center", children: jsxRuntime.jsx(BaseInput, { ...rest, "aria-readonly": true, className: tailwindMerge.twMerge("w-full min-w-0", rest.className), "data-testid": dataTestId ? `${dataTestId}-input` : undefined, onBlur: handleInputBlur, onChange: handleInputChange, onFocus: handleInputFocus, onKeyDown: handleInputKeyDown, placeholder: rest.placeholder ?? getDateFieldPlaceholder(locale), ref: setInputRef, suffix: suffixProp ?? (jsxRuntime.jsx("div", { className: cvaActionContainer({ size: "medium" }), children: jsxRuntime.jsx(reactComponents.IconButton, { "aria-expanded": isOpen, "aria-label": t("dateField.openPicker.ariaLabel"), className: cvaActionButton({ size: "small" }), "data-testid": dataTestId ? `${dataTestId}-calendar` : "calendar", disabled: isPickerDisabled, icon: jsxRuntime.jsx(reactComponents.Icon, { "aria-label": undefined, name: "Calendar", size: "small", type: "solid" }), onClick: togglePopover, size: "small", tabIndex: isOpen ? -1 : undefined, variant: "ghost-neutral" }) })), tabIndex: isOpen ? -1 : rest.tabIndex, type: "text", value: resolvedValue }) }) }), jsxRuntime.jsx(reactComponents.PopoverContent, { initialFocus: 1, children: closePopover => (jsxRuntime.jsx(DateBaseInputPickerContent, { applyDate: applyDate, closePopover: closePopover, "data-testid": dataTestId, onApply: onApply, onCalendarChange: onCalendarChange, onCancel: onCancel, onClear: onClear, pendingDate: pendingDate, selectedDate: selectedDate, tileDisabled: tileDisabled })) })] }));
|
|
1351
|
+
} }));
|
|
735
1352
|
};
|
|
736
1353
|
|
|
737
1354
|
/**
|
|
@@ -2568,11 +3185,34 @@ ColorField.displayName = "ColorField";
|
|
|
2568
3185
|
* );
|
|
2569
3186
|
* };
|
|
2570
3187
|
* ```
|
|
3188
|
+
* @example Server-side validation when the calendar popover closes
|
|
3189
|
+
* ```tsx
|
|
3190
|
+
* import { DateField } from "@trackunit/react-form-components";
|
|
3191
|
+
*
|
|
3192
|
+
* const ValidatedField = () => (
|
|
3193
|
+
* <DateField
|
|
3194
|
+
* label="Start date"
|
|
3195
|
+
* onChange={(e) => setLocalValue(e.target.value)}
|
|
3196
|
+
* onPickerClose={(e, reason) => {
|
|
3197
|
+
* if (reason === "cancel") return;
|
|
3198
|
+
* validateOnServer(e.target.value);
|
|
3199
|
+
* }}
|
|
3200
|
+
* />
|
|
3201
|
+
* );
|
|
3202
|
+
* ```
|
|
3203
|
+
* @example Open the picker automatically when the field is focused (e.g. via Tab)
|
|
3204
|
+
* ```tsx
|
|
3205
|
+
* import { DateField } from "@trackunit/react-form-components";
|
|
3206
|
+
*
|
|
3207
|
+
* const QuickPickField = () => (
|
|
3208
|
+
* <DateField label="Start date" openOnFocus />
|
|
3209
|
+
* );
|
|
3210
|
+
* ```
|
|
2571
3211
|
*/
|
|
2572
3212
|
const DateField = ({ label, id, tip, helpText, errorMessage, helpAddon, isInvalid = undefined, className, defaultValue, "data-testid": dataTestId, ref, required = false, ...rest }) => {
|
|
2573
3213
|
const renderAsInvalid = isInvalid === undefined ? Boolean(errorMessage) : isInvalid;
|
|
2574
3214
|
const htmlForId = id ? id : "dateField-" + sharedUtils.uuidv4();
|
|
2575
|
-
return (jsxRuntime.jsx(FormGroup, { "data-testid": dataTestId ? `${dataTestId}-FormGroup` : undefined, helpAddon: helpAddon, helpText: (renderAsInvalid && errorMessage) || helpText, htmlFor: htmlForId, isInvalid: renderAsInvalid, label: label, required: required ? !(Boolean(rest.disabled) || Boolean(rest.readOnly)) : false, tip: tip, children: jsxRuntime.jsx(DateBaseInput, { "aria-labelledby": htmlForId + "-label", defaultValue: defaultValue, id: htmlForId, isInvalid: renderAsInvalid, ref: ref, required: required
|
|
3215
|
+
return (jsxRuntime.jsx(FormGroup, { "data-testid": dataTestId ? `${dataTestId}-FormGroup` : undefined, helpAddon: helpAddon, helpText: (renderAsInvalid && errorMessage) || helpText, htmlFor: htmlForId, isInvalid: renderAsInvalid, label: label, required: required ? !(Boolean(rest.disabled) || Boolean(rest.readOnly)) : false, tip: tip, children: jsxRuntime.jsx(DateBaseInput, { ...rest, "aria-labelledby": htmlForId + "-label", className: className, "data-testid": dataTestId, defaultValue: defaultValue, id: htmlForId, isInvalid: renderAsInvalid, ref: ref, required: required }) }));
|
|
2576
3216
|
};
|
|
2577
3217
|
DateField.displayName = "DateField";
|
|
2578
3218
|
|
|
@@ -5042,6 +5682,7 @@ exports.phoneErrorMessage = phoneErrorMessage;
|
|
|
5042
5682
|
exports.serializeSchedule = serializeSchedule;
|
|
5043
5683
|
exports.toISODateStringUTC = toISODateStringUTC;
|
|
5044
5684
|
exports.useCreatableSelect = useCreatableSelect;
|
|
5685
|
+
exports.useCreateInputBlurEvent = useCreateInputBlurEvent;
|
|
5045
5686
|
exports.useCreateInputChangeEvent = useCreateInputChangeEvent;
|
|
5046
5687
|
exports.useCustomComponents = useCustomComponents;
|
|
5047
5688
|
exports.useGetPhoneValidationRules = useGetPhoneValidationRules;
|