@classic-homes/theme-svelte 1.22.1 → 1.22.3
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/lib/components/Combobox.svelte +2 -2
- package/dist/lib/components/DataTable.svelte +15 -6
- package/dist/lib/components/DataTable.svelte.d.ts +1 -1
- package/dist/lib/components/DateTimePicker.svelte +31 -34
- package/dist/lib/components/DropdownMenu.svelte +52 -30
- package/dist/lib/components/DropdownMenu.svelte.d.ts +5 -0
- package/dist/lib/components/FileUpload.svelte +5 -19
- package/dist/lib/components/MultiSelect.svelte +2 -2
- package/dist/lib/components/NumberInput.svelte +15 -2
- package/dist/lib/components/Select.svelte +19 -5
- package/dist/lib/components/Select.svelte.d.ts +4 -0
- package/dist/lib/components/Signature.svelte +15 -4
- package/dist/lib/components/Signature.svelte.d.ts +1 -1
- package/dist/lib/components/Slider.svelte +8 -6
- package/dist/lib/components/Tabs.svelte +10 -12
- package/dist/lib/components/Toast.svelte +12 -12
- package/dist/lib/components/data-table/DataTableColumnVisibility.svelte +24 -26
- package/dist/lib/components/data-table/DataTableEditableCell.svelte +31 -18
- package/dist/lib/components/data-table/DataTableEditableCell.svelte.d.ts +0 -7
- package/dist/lib/components/data-table/DataTablePagination.svelte +5 -1
- package/dist/lib/components/data-table/DataTableVirtual.svelte +4 -7
- package/dist/lib/components/data-table/DataTableVirtual.svelte.d.ts +3 -0
- package/dist/lib/components/data-table/createDataTable.svelte.d.ts +6 -1
- package/dist/lib/components/data-table/createDataTable.svelte.js +22 -9
- package/dist/lib/components/layout/Sidebar.svelte +28 -0
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +66 -50
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte.d.ts +2 -0
- package/dist/lib/composables/useAsync.svelte.d.ts +1 -1
- package/dist/lib/composables/useAsync.svelte.js +33 -14
- package/dist/lib/composables/useForm.svelte.js +19 -7
- package/dist/lib/composables/usePersistedForm.svelte.js +37 -10
- package/dist/lib/stores/sidebar.svelte.d.ts +6 -0
- package/dist/lib/stores/sidebar.svelte.js +15 -0
- package/dist/lib/stores/toast.svelte.js +12 -1
- package/dist/lib/utils/date.d.ts +3 -1
- package/dist/lib/utils/date.js +54 -34
- package/dist/lib/utils/form.d.ts +2 -1
- package/dist/lib/utils/form.js +3 -2
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - Error handling with toast integration
|
|
7
7
|
* - Success/error callbacks
|
|
8
8
|
* - Retry functionality
|
|
9
|
-
* -
|
|
9
|
+
* - Overlapping calls: only the latest call (and none after reset) updates state
|
|
10
10
|
* - Debounce support
|
|
11
11
|
*
|
|
12
12
|
* @example
|
|
@@ -51,21 +51,35 @@ export function useAsync(options) {
|
|
|
51
51
|
// Derived state
|
|
52
52
|
const isSuccess = $derived(isExecuted && !error && !isLoading);
|
|
53
53
|
const isError = $derived(isExecuted && !!error);
|
|
54
|
-
// Debounce timer
|
|
54
|
+
// Debounce timer, and the resolver of the call it is holding back
|
|
55
55
|
let debounceTimer;
|
|
56
|
+
let pendingResolve;
|
|
57
|
+
// Id of the latest call; results from older calls are ignored
|
|
58
|
+
let callId = 0;
|
|
56
59
|
/**
|
|
57
|
-
*
|
|
60
|
+
* Cancel a pending debounced call, settling its promise with undefined
|
|
58
61
|
*/
|
|
59
|
-
|
|
60
|
-
// Clear any pending debounce
|
|
62
|
+
function cancelPending() {
|
|
61
63
|
if (debounceTimer) {
|
|
62
64
|
clearTimeout(debounceTimer);
|
|
63
65
|
debounceTimer = undefined;
|
|
64
66
|
}
|
|
67
|
+
pendingResolve?.(undefined);
|
|
68
|
+
pendingResolve = undefined;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Execute the async function
|
|
72
|
+
*/
|
|
73
|
+
async function execute(...args) {
|
|
74
|
+
// Clear any pending debounce
|
|
75
|
+
cancelPending();
|
|
65
76
|
// If debounce is set, delay execution
|
|
66
77
|
if (debounceMs && debounceMs > 0) {
|
|
67
78
|
return new Promise((resolve) => {
|
|
79
|
+
pendingResolve = resolve;
|
|
68
80
|
debounceTimer = setTimeout(async () => {
|
|
81
|
+
debounceTimer = undefined;
|
|
82
|
+
pendingResolve = undefined;
|
|
69
83
|
const result = await executeImmediate(...args);
|
|
70
84
|
resolve(result);
|
|
71
85
|
}, debounceMs);
|
|
@@ -77,12 +91,16 @@ export function useAsync(options) {
|
|
|
77
91
|
* Execute immediately without debounce
|
|
78
92
|
*/
|
|
79
93
|
async function executeImmediate(...args) {
|
|
94
|
+
const id = ++callId;
|
|
80
95
|
lastArgs = args;
|
|
81
96
|
isLoading = true;
|
|
82
97
|
error = undefined;
|
|
83
98
|
isExecuted = true;
|
|
84
99
|
try {
|
|
85
100
|
const result = await fn(...args);
|
|
101
|
+
// A newer call (or reset) superseded this one: leave state alone
|
|
102
|
+
if (id !== callId)
|
|
103
|
+
return result;
|
|
86
104
|
data = result;
|
|
87
105
|
if (onSuccess) {
|
|
88
106
|
onSuccess(result);
|
|
@@ -93,6 +111,8 @@ export function useAsync(options) {
|
|
|
93
111
|
return result;
|
|
94
112
|
}
|
|
95
113
|
catch (err) {
|
|
114
|
+
if (id !== callId)
|
|
115
|
+
return undefined;
|
|
96
116
|
const normalizedError = err instanceof Error ? err : new Error(String(err));
|
|
97
117
|
error = normalizedError;
|
|
98
118
|
if (onError) {
|
|
@@ -104,7 +124,8 @@ export function useAsync(options) {
|
|
|
104
124
|
return undefined;
|
|
105
125
|
}
|
|
106
126
|
finally {
|
|
107
|
-
|
|
127
|
+
if (id === callId)
|
|
128
|
+
isLoading = false;
|
|
108
129
|
}
|
|
109
130
|
}
|
|
110
131
|
/**
|
|
@@ -121,11 +142,9 @@ export function useAsync(options) {
|
|
|
121
142
|
* Reset state to initial values
|
|
122
143
|
*/
|
|
123
144
|
function reset() {
|
|
124
|
-
// Clear any pending debounce
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
debounceTimer = undefined;
|
|
128
|
-
}
|
|
145
|
+
// Clear any pending debounce, and drop the result of any call in flight
|
|
146
|
+
cancelPending();
|
|
147
|
+
callId++;
|
|
129
148
|
data = initialData;
|
|
130
149
|
error = undefined;
|
|
131
150
|
isLoading = false;
|
|
@@ -153,11 +172,11 @@ export function useAsync(options) {
|
|
|
153
172
|
function clearError() {
|
|
154
173
|
error = undefined;
|
|
155
174
|
}
|
|
156
|
-
// Execute immediately if requested
|
|
157
|
-
if (immediate
|
|
175
|
+
// Execute immediately if requested (zero-argument functions need no immediateArgs)
|
|
176
|
+
if (immediate) {
|
|
158
177
|
// Use $effect-like behavior by queueing the execution
|
|
159
178
|
queueMicrotask(() => {
|
|
160
|
-
execute(...immediateArgs);
|
|
179
|
+
execute(...(immediateArgs ?? []));
|
|
161
180
|
});
|
|
162
181
|
}
|
|
163
182
|
return {
|
|
@@ -46,8 +46,9 @@ import { toastStore } from '../stores/toast.svelte.js';
|
|
|
46
46
|
*/
|
|
47
47
|
export function useForm(options) {
|
|
48
48
|
const { schema, initialValues, onSubmit, onError, showToastOnError = true, resetOnSuccess = false, errorMessage, successMessage, } = options;
|
|
49
|
-
// Deep clone to avoid reference issues
|
|
50
|
-
|
|
49
|
+
// Deep clone to avoid reference issues. $state.snapshot keeps Date, Map, Set
|
|
50
|
+
// and File values (a JSON round-trip would turn a Date into a string).
|
|
51
|
+
const cloneInitialValues = () => $state.snapshot(initialValues);
|
|
51
52
|
// Reactive state using Svelte 5 runes
|
|
52
53
|
let data = $state(cloneInitialValues());
|
|
53
54
|
let errors = $state({});
|
|
@@ -145,14 +146,20 @@ export function useForm(options) {
|
|
|
145
146
|
* Validate the entire form
|
|
146
147
|
*/
|
|
147
148
|
function validate() {
|
|
149
|
+
return parseForm() !== null;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Validate the entire form, returning the parsed (transformed) data or null
|
|
153
|
+
*/
|
|
154
|
+
function parseForm() {
|
|
148
155
|
const result = schema.safeParse(data);
|
|
149
156
|
if (result.success) {
|
|
150
157
|
errors = {};
|
|
151
|
-
return
|
|
158
|
+
return result.data;
|
|
152
159
|
}
|
|
153
160
|
else {
|
|
154
161
|
errors = parseZodErrors(result.error);
|
|
155
|
-
return
|
|
162
|
+
return null;
|
|
156
163
|
}
|
|
157
164
|
}
|
|
158
165
|
/**
|
|
@@ -160,12 +167,17 @@ export function useForm(options) {
|
|
|
160
167
|
*/
|
|
161
168
|
async function handleSubmit(event) {
|
|
162
169
|
event?.preventDefault();
|
|
170
|
+
// Ignore a second submit (e.g. a double click) while one is in flight
|
|
171
|
+
if (isSubmitting)
|
|
172
|
+
return;
|
|
163
173
|
// Clear previous errors
|
|
164
174
|
errors = {};
|
|
165
175
|
globalError = '';
|
|
166
176
|
isSubmitted = true;
|
|
167
|
-
// Validate all fields
|
|
168
|
-
|
|
177
|
+
// Validate all fields. onSubmit gets the schema's output, so transforms,
|
|
178
|
+
// coercions and defaults apply, as a plain object rather than the proxy.
|
|
179
|
+
const parsed = parseForm();
|
|
180
|
+
if (parsed === null) {
|
|
169
181
|
const errorCount = Object.keys(errors).length;
|
|
170
182
|
if (showToastOnError) {
|
|
171
183
|
toastStore.error(errorMessage || `Please fix ${errorCount} validation error${errorCount > 1 ? 's' : ''}`);
|
|
@@ -174,7 +186,7 @@ export function useForm(options) {
|
|
|
174
186
|
}
|
|
175
187
|
isSubmitting = true;
|
|
176
188
|
try {
|
|
177
|
-
await onSubmit(
|
|
189
|
+
await onSubmit(parsed);
|
|
178
190
|
if (successMessage) {
|
|
179
191
|
toastStore.success(successMessage);
|
|
180
192
|
}
|
|
@@ -75,8 +75,11 @@ export function usePersistedForm(options) {
|
|
|
75
75
|
let draftRestored = $state(false);
|
|
76
76
|
let lastSubmitTime = $state(0);
|
|
77
77
|
let debounceTimer = null;
|
|
78
|
+
// Serialized persistable data last written (or deliberately not written).
|
|
79
|
+
// null after clearDraft, so the next edit is always saved.
|
|
80
|
+
let lastPersisted = null;
|
|
78
81
|
// Store original initialValues for reset (before draft merge)
|
|
79
|
-
const originalInitialValues =
|
|
82
|
+
const originalInitialValues = $state.snapshot(initialValues);
|
|
80
83
|
/**
|
|
81
84
|
* Load draft from localStorage
|
|
82
85
|
*/
|
|
@@ -104,6 +107,12 @@ export function usePersistedForm(options) {
|
|
|
104
107
|
* Clear draft from localStorage
|
|
105
108
|
*/
|
|
106
109
|
function clearDraft() {
|
|
110
|
+
// Drop a pending save, or it would write the draft back after clearing
|
|
111
|
+
if (debounceTimer) {
|
|
112
|
+
clearTimeout(debounceTimer);
|
|
113
|
+
debounceTimer = null;
|
|
114
|
+
}
|
|
115
|
+
lastPersisted = null;
|
|
107
116
|
if (!isStorageAvailable())
|
|
108
117
|
return;
|
|
109
118
|
try {
|
|
@@ -127,9 +136,9 @@ export function usePersistedForm(options) {
|
|
|
127
136
|
});
|
|
128
137
|
}
|
|
129
138
|
// Merge draft with initial values (keeps excluded fields at defaults)
|
|
130
|
-
return {
|
|
139
|
+
return { ...$state.snapshot(initialValues), ...draft };
|
|
131
140
|
}
|
|
132
|
-
return
|
|
141
|
+
return $state.snapshot(initialValues);
|
|
133
142
|
}
|
|
134
143
|
/**
|
|
135
144
|
* Wrapped onSubmit with cooldown and draft clearing
|
|
@@ -144,8 +153,10 @@ export function usePersistedForm(options) {
|
|
|
144
153
|
try {
|
|
145
154
|
await onSubmit(data);
|
|
146
155
|
lastSubmitTime = now;
|
|
147
|
-
// Clear draft on successful submission
|
|
156
|
+
// Clear draft on successful submission. Treat the start values as
|
|
157
|
+
// persisted so a resetOnSuccess back to them does not save a new draft.
|
|
148
158
|
clearDraft();
|
|
159
|
+
lastPersisted = startSerialized;
|
|
149
160
|
}
|
|
150
161
|
catch (error) {
|
|
151
162
|
// Reset cooldown on error to allow retry
|
|
@@ -154,16 +165,17 @@ export function usePersistedForm(options) {
|
|
|
154
165
|
}
|
|
155
166
|
}
|
|
156
167
|
// Create base form with potentially restored initial values
|
|
168
|
+
const startValues = getInitialValues();
|
|
157
169
|
const baseForm = useForm({
|
|
158
170
|
...baseOptions,
|
|
159
|
-
initialValues:
|
|
171
|
+
initialValues: startValues,
|
|
160
172
|
onSubmit: wrappedOnSubmit,
|
|
161
173
|
});
|
|
162
174
|
/**
|
|
163
175
|
* Get data to persist (excludes specified fields)
|
|
164
176
|
*/
|
|
165
|
-
function getDataToPersist() {
|
|
166
|
-
const dataCopy = { ...
|
|
177
|
+
function getDataToPersist(values = baseForm.data) {
|
|
178
|
+
const dataCopy = { ...values };
|
|
167
179
|
for (const field of excludeFields) {
|
|
168
180
|
delete dataCopy[field];
|
|
169
181
|
}
|
|
@@ -192,13 +204,27 @@ export function usePersistedForm(options) {
|
|
|
192
204
|
}
|
|
193
205
|
}, debounceMs);
|
|
194
206
|
}
|
|
207
|
+
// Edits made with bind:value never mark fields dirty, so changes are
|
|
208
|
+
// detected by content rather than by isDirty.
|
|
209
|
+
const startSerialized = JSON.stringify(getDataToPersist(startValues));
|
|
210
|
+
const originalSerialized = JSON.stringify(getDataToPersist(originalInitialValues));
|
|
211
|
+
lastPersisted = startSerialized;
|
|
195
212
|
// Watch for form changes and save draft
|
|
196
213
|
$effect(() => {
|
|
197
214
|
// Access data to create dependency
|
|
198
|
-
JSON.stringify(
|
|
199
|
-
if (
|
|
215
|
+
const serialized = JSON.stringify(getDataToPersist());
|
|
216
|
+
if (serialized === lastPersisted)
|
|
217
|
+
return;
|
|
218
|
+
if (serialized === originalSerialized) {
|
|
219
|
+
// Edited back to the initial values: there is nothing to restore, so
|
|
220
|
+
// drop the stored draft (and any pending save) instead of leaving a
|
|
221
|
+
// stale one behind.
|
|
222
|
+
clearDraft();
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
200
225
|
saveDraft();
|
|
201
226
|
}
|
|
227
|
+
lastPersisted = serialized;
|
|
202
228
|
});
|
|
203
229
|
/**
|
|
204
230
|
* Extended reset that also clears the draft
|
|
@@ -209,9 +235,10 @@ export function usePersistedForm(options) {
|
|
|
209
235
|
baseForm.reset();
|
|
210
236
|
// Then overwrite data with original initialValues (not the draft-merged ones)
|
|
211
237
|
// We modify the data object directly to avoid triggering markDirty
|
|
212
|
-
Object.assign(baseForm.data,
|
|
238
|
+
Object.assign(baseForm.data, $state.snapshot(originalInitialValues));
|
|
213
239
|
draftRestored = false;
|
|
214
240
|
clearDraft();
|
|
241
|
+
lastPersisted = JSON.stringify(getDataToPersist());
|
|
215
242
|
}
|
|
216
243
|
/**
|
|
217
244
|
* Dismiss the draft restored notification
|
|
@@ -98,6 +98,12 @@ declare class SidebarStore {
|
|
|
98
98
|
* This bypasses the persisted state check but doesn't persist the changes
|
|
99
99
|
*/
|
|
100
100
|
forceExpandSections(sectionIds: string[]): void;
|
|
101
|
+
/**
|
|
102
|
+
* Expand the sections and items that hold the current page so its active
|
|
103
|
+
* item is visible. Like a search expansion it is not persisted; unlike one
|
|
104
|
+
* it is kept when a search clears. A no-op when all are already expanded.
|
|
105
|
+
*/
|
|
106
|
+
revealExpansion(sectionIds: string[], itemIds: string[]): void;
|
|
101
107
|
/**
|
|
102
108
|
* Undo search force-expansions, restoring the expansion state from before the
|
|
103
109
|
* search (including any toggles made while it was active). Call when the
|
|
@@ -250,6 +250,21 @@ class SidebarStore {
|
|
|
250
250
|
this.expandedSections = newSet;
|
|
251
251
|
// Don't persist - this is temporary for search
|
|
252
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* Expand the sections and items that hold the current page so its active
|
|
255
|
+
* item is visible. Like a search expansion it is not persisted; unlike one
|
|
256
|
+
* it is kept when a search clears. A no-op when all are already expanded.
|
|
257
|
+
*/
|
|
258
|
+
revealExpansion(sectionIds, itemIds) {
|
|
259
|
+
const withAll = (set, ids) => ids.every((id) => set.has(id)) ? set : new Set([...set, ...ids]);
|
|
260
|
+
this.expandedSections = withAll(this.expandedSections, sectionIds);
|
|
261
|
+
this.expandedItems = withAll(this.expandedItems, itemIds);
|
|
262
|
+
const snap = __classPrivateFieldGet(this, _SidebarStore_searchSnapshot, "f");
|
|
263
|
+
if (snap) {
|
|
264
|
+
snap.sections = withAll(snap.sections, sectionIds);
|
|
265
|
+
snap.items = withAll(snap.items, itemIds);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
253
268
|
/**
|
|
254
269
|
* Undo search force-expansions, restoring the expansion state from before the
|
|
255
270
|
* search (including any toggles made while it was active). Call when the
|
|
@@ -7,6 +7,17 @@
|
|
|
7
7
|
* - Proper timeout cleanup to prevent memory leaks
|
|
8
8
|
* - Convenience methods for common toast types
|
|
9
9
|
*/
|
|
10
|
+
let fallbackIdCounter = 0;
|
|
11
|
+
/**
|
|
12
|
+
* crypto.randomUUID only exists in secure contexts (https, localhost), so
|
|
13
|
+
* fall back on plain-http origins such as a LAN IP or a staging host.
|
|
14
|
+
*/
|
|
15
|
+
function createToastId() {
|
|
16
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
17
|
+
return crypto.randomUUID();
|
|
18
|
+
}
|
|
19
|
+
return `toast-${Date.now()}-${++fallbackIdCounter}`;
|
|
20
|
+
}
|
|
10
21
|
class ToastStore {
|
|
11
22
|
constructor() {
|
|
12
23
|
this.toasts = $state([]);
|
|
@@ -19,7 +30,7 @@ class ToastStore {
|
|
|
19
30
|
* @returns The generated toast id
|
|
20
31
|
*/
|
|
21
32
|
add(toast) {
|
|
22
|
-
const id =
|
|
33
|
+
const id = createToastId();
|
|
23
34
|
const newToast = { ...toast, id };
|
|
24
35
|
this.toasts = [...this.toasts, newToast];
|
|
25
36
|
// Auto-dismiss unless duration is explicitly 0 (persistent)
|
package/dist/lib/utils/date.d.ts
CHANGED
|
@@ -77,7 +77,9 @@ export interface ParseResult {
|
|
|
77
77
|
* - ISO: `2024-03-15`, `2024-03-15T14:30`
|
|
78
78
|
* - US: `03/15/2024`, `3/15/24`, `03-15-2024`
|
|
79
79
|
* - Natural: `Mar 15, 2024`, `March 15 2024`
|
|
80
|
-
* - With time: `03/15/2024 2:30 PM`, `03/15/2024 14:30`
|
|
80
|
+
* - With time: `03/15/2024 2:30 PM`, `03/15/2024 14:30`, `Mar 15, 2024 at 2:30 PM`
|
|
81
|
+
*
|
|
82
|
+
* Out-of-range parts (`02/30/2024`, `25:99`) are rejected, not rolled over.
|
|
81
83
|
*
|
|
82
84
|
* @param input - The user-typed date string
|
|
83
85
|
* @param options - Parsing options
|
package/dist/lib/utils/date.js
CHANGED
|
@@ -104,6 +104,37 @@ export function isSameDay(date1, date2) {
|
|
|
104
104
|
date1.getMonth() === date2.getMonth() &&
|
|
105
105
|
date1.getDate() === date2.getDate());
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Build a local Date, or null when a part is out of range. `new Date()` rolls
|
|
109
|
+
* overflow into the next unit (Feb 30 -> Mar 1, 25:00 -> next day), so the
|
|
110
|
+
* date parts must round-trip and the time parts must be in range.
|
|
111
|
+
*/
|
|
112
|
+
function buildDate(year, monthIndex, day, hour, minute) {
|
|
113
|
+
if (hour > 23 || minute > 59)
|
|
114
|
+
return null;
|
|
115
|
+
const date = new Date(year, monthIndex, day, hour, minute);
|
|
116
|
+
if (date.getFullYear() !== year || date.getMonth() !== monthIndex || date.getDate() !== day) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
return date;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Convert a typed hour (with optional AM/PM) to 0-23, or null when out of
|
|
123
|
+
* range for its clock (e.g. `13 PM`, `0 AM`).
|
|
124
|
+
*/
|
|
125
|
+
function toHour24(hour, meridiem) {
|
|
126
|
+
if (!hour)
|
|
127
|
+
return 0;
|
|
128
|
+
const parsed = parseInt(hour);
|
|
129
|
+
if (!meridiem)
|
|
130
|
+
return parsed;
|
|
131
|
+
if (parsed < 1 || parsed > 12)
|
|
132
|
+
return null;
|
|
133
|
+
const isPm = meridiem.toUpperCase() === 'PM';
|
|
134
|
+
if (parsed === 12)
|
|
135
|
+
return isPm ? 12 : 0;
|
|
136
|
+
return isPm ? parsed + 12 : parsed;
|
|
137
|
+
}
|
|
107
138
|
/**
|
|
108
139
|
* Parse a user-typed date input string into a Date object.
|
|
109
140
|
*
|
|
@@ -111,7 +142,9 @@ export function isSameDay(date1, date2) {
|
|
|
111
142
|
* - ISO: `2024-03-15`, `2024-03-15T14:30`
|
|
112
143
|
* - US: `03/15/2024`, `3/15/24`, `03-15-2024`
|
|
113
144
|
* - Natural: `Mar 15, 2024`, `March 15 2024`
|
|
114
|
-
* - With time: `03/15/2024 2:30 PM`, `03/15/2024 14:30`
|
|
145
|
+
* - With time: `03/15/2024 2:30 PM`, `03/15/2024 14:30`, `Mar 15, 2024 at 2:30 PM`
|
|
146
|
+
*
|
|
147
|
+
* Out-of-range parts (`02/30/2024`, `25:99`) are rejected, not rolled over.
|
|
115
148
|
*
|
|
116
149
|
* @param input - The user-typed date string
|
|
117
150
|
* @param options - Parsing options
|
|
@@ -128,17 +161,17 @@ export function parseDateInput(input, options) {
|
|
|
128
161
|
}
|
|
129
162
|
// Try parsing with various patterns
|
|
130
163
|
let date = null;
|
|
131
|
-
let
|
|
164
|
+
let matched = false;
|
|
132
165
|
// Pattern 1: ISO format (2024-03-15 or 2024-03-15T14:30)
|
|
133
166
|
const isoMatch = trimmed.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/);
|
|
134
167
|
if (isoMatch) {
|
|
135
168
|
const [, year, month, day, hour, minute] = isoMatch;
|
|
136
|
-
|
|
137
|
-
|
|
169
|
+
matched = true;
|
|
170
|
+
date = buildDate(parseInt(year), parseInt(month) - 1, parseInt(day), hour ? parseInt(hour) : 0, minute ? parseInt(minute) : 0);
|
|
138
171
|
}
|
|
139
172
|
// Pattern 2: US format with slashes or dashes (03/15/2024, 3/15/24, 03-15-2024)
|
|
140
|
-
if (!
|
|
141
|
-
const usMatch = trimmed.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{2,4})(?:\s+(\d{1,2}):(\d{2})(?:\s*(AM|PM))?)?$/i);
|
|
173
|
+
if (!matched) {
|
|
174
|
+
const usMatch = trimmed.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{2,4})(?:\s+(?:at\s+)?(\d{1,2}):(\d{2})(?:\s*(AM|PM))?)?$/i);
|
|
142
175
|
if (usMatch) {
|
|
143
176
|
const [, month, day, yearStr, hour, minute, meridiem] = usMatch;
|
|
144
177
|
let year = parseInt(yearStr);
|
|
@@ -146,22 +179,16 @@ export function parseDateInput(input, options) {
|
|
|
146
179
|
if (year < 100) {
|
|
147
180
|
year += year < 50 ? 2000 : 1900;
|
|
148
181
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
else if (upper === 'AM' && parsedHour === 12) {
|
|
156
|
-
parsedHour = 0;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
date = new Date(year, parseInt(month) - 1, parseInt(day), parsedHour, minute ? parseInt(minute) : 0);
|
|
160
|
-
hasTime = !!hour;
|
|
182
|
+
matched = true;
|
|
183
|
+
const parsedHour = toHour24(hour, meridiem);
|
|
184
|
+
date =
|
|
185
|
+
parsedHour === null
|
|
186
|
+
? null
|
|
187
|
+
: buildDate(year, parseInt(month) - 1, parseInt(day), parsedHour, minute ? parseInt(minute) : 0);
|
|
161
188
|
}
|
|
162
189
|
}
|
|
163
190
|
// Pattern 3: Natural format (Mar 15, 2024 or March 15 2024)
|
|
164
|
-
if (!
|
|
191
|
+
if (!matched) {
|
|
165
192
|
const monthNames = {
|
|
166
193
|
jan: 0,
|
|
167
194
|
january: 0,
|
|
@@ -188,23 +215,16 @@ export function parseDateInput(input, options) {
|
|
|
188
215
|
dec: 11,
|
|
189
216
|
december: 11,
|
|
190
217
|
};
|
|
191
|
-
const naturalMatch = trimmed.match(/^([a-zA-Z]+)\s+(\d{1,2}),?\s+(\d{4})(?:\s+(\d{1,2}):(\d{2})(?:\s*(AM|PM))?)?$/i);
|
|
218
|
+
const naturalMatch = trimmed.match(/^([a-zA-Z]+)\s+(\d{1,2}),?\s+(\d{4})(?:\s+(?:at\s+)?(\d{1,2}):(\d{2})(?:\s*(AM|PM))?)?$/i);
|
|
192
219
|
if (naturalMatch) {
|
|
193
220
|
const [, monthStr, day, year, hour, minute, meridiem] = naturalMatch;
|
|
194
221
|
const monthNum = monthNames[monthStr.toLowerCase()];
|
|
195
222
|
if (monthNum !== undefined) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
parsedHour
|
|
201
|
-
}
|
|
202
|
-
else if (upper === 'AM' && parsedHour === 12) {
|
|
203
|
-
parsedHour = 0;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
date = new Date(parseInt(year), monthNum, parseInt(day), parsedHour, minute ? parseInt(minute) : 0);
|
|
207
|
-
hasTime = !!hour;
|
|
223
|
+
const parsedHour = toHour24(hour, meridiem);
|
|
224
|
+
date =
|
|
225
|
+
parsedHour === null
|
|
226
|
+
? null
|
|
227
|
+
: buildDate(parseInt(year), monthNum, parseInt(day), parsedHour, minute ? parseInt(minute) : 0);
|
|
208
228
|
}
|
|
209
229
|
}
|
|
210
230
|
}
|
|
@@ -216,8 +236,8 @@ export function parseDateInput(input, options) {
|
|
|
216
236
|
error: 'Invalid date format. Try MM/DD/YYYY or YYYY-MM-DD',
|
|
217
237
|
};
|
|
218
238
|
}
|
|
219
|
-
//
|
|
220
|
-
if (options?.dateOnly
|
|
239
|
+
// In dateOnly mode a typed time is accepted but reset to midnight
|
|
240
|
+
if (options?.dateOnly) {
|
|
221
241
|
date.setHours(0, 0, 0, 0);
|
|
222
242
|
}
|
|
223
243
|
return { date, isValid: true };
|
package/dist/lib/utils/form.d.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
export declare function generateId(prefix?: string): string;
|
|
12
12
|
/**
|
|
13
13
|
* Computes the aria-describedby attribute value based on hint and error states.
|
|
14
|
-
* Returns undefined if neither hint nor error is present.
|
|
14
|
+
* Returns undefined if neither hint nor error is present. The hint is left out
|
|
15
|
+
* while an error shows, since form fields render the error in its place.
|
|
15
16
|
*
|
|
16
17
|
* @param hintId - The ID of the hint element
|
|
17
18
|
* @param errorId - The ID of the error element
|
package/dist/lib/utils/form.js
CHANGED
|
@@ -13,7 +13,8 @@ export function generateId(prefix = 'field') {
|
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
15
|
* Computes the aria-describedby attribute value based on hint and error states.
|
|
16
|
-
* Returns undefined if neither hint nor error is present.
|
|
16
|
+
* Returns undefined if neither hint nor error is present. The hint is left out
|
|
17
|
+
* while an error shows, since form fields render the error in its place.
|
|
17
18
|
*
|
|
18
19
|
* @param hintId - The ID of the hint element
|
|
19
20
|
* @param errorId - The ID of the error element
|
|
@@ -23,7 +24,7 @@ export function generateId(prefix = 'field') {
|
|
|
23
24
|
*/
|
|
24
25
|
export function computeDescribedBy(hintId, errorId, hasHint, hasError) {
|
|
25
26
|
const ids = [];
|
|
26
|
-
if (hasHint && hintId)
|
|
27
|
+
if (hasHint && !hasError && hintId)
|
|
27
28
|
ids.push(hintId);
|
|
28
29
|
if (hasError && errorId)
|
|
29
30
|
ids.push(errorId);
|