@classic-homes/theme-svelte 1.22.1 → 1.22.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/lib/components/Combobox.svelte +2 -2
  2. package/dist/lib/components/DataTable.svelte +15 -6
  3. package/dist/lib/components/DataTable.svelte.d.ts +1 -1
  4. package/dist/lib/components/DateTimePicker.svelte +31 -34
  5. package/dist/lib/components/DropdownMenu.svelte +52 -30
  6. package/dist/lib/components/DropdownMenu.svelte.d.ts +5 -0
  7. package/dist/lib/components/FileUpload.svelte +5 -19
  8. package/dist/lib/components/MultiSelect.svelte +2 -2
  9. package/dist/lib/components/NumberInput.svelte +15 -2
  10. package/dist/lib/components/Select.svelte +19 -5
  11. package/dist/lib/components/Select.svelte.d.ts +4 -0
  12. package/dist/lib/components/Signature.svelte +15 -4
  13. package/dist/lib/components/Signature.svelte.d.ts +1 -1
  14. package/dist/lib/components/Slider.svelte +8 -6
  15. package/dist/lib/components/Tabs.svelte +10 -12
  16. package/dist/lib/components/Toast.svelte +12 -12
  17. package/dist/lib/components/data-table/DataTableColumnVisibility.svelte +24 -26
  18. package/dist/lib/components/data-table/DataTableEditableCell.svelte +31 -18
  19. package/dist/lib/components/data-table/DataTableEditableCell.svelte.d.ts +0 -7
  20. package/dist/lib/components/data-table/DataTablePagination.svelte +5 -1
  21. package/dist/lib/components/data-table/DataTableVirtual.svelte +4 -7
  22. package/dist/lib/components/data-table/DataTableVirtual.svelte.d.ts +3 -0
  23. package/dist/lib/components/data-table/createDataTable.svelte.d.ts +6 -1
  24. package/dist/lib/components/data-table/createDataTable.svelte.js +22 -9
  25. package/dist/lib/components/layout/Sidebar.svelte +28 -0
  26. package/dist/lib/composables/useAsync.svelte.d.ts +1 -1
  27. package/dist/lib/composables/useAsync.svelte.js +33 -14
  28. package/dist/lib/composables/useForm.svelte.js +19 -7
  29. package/dist/lib/composables/usePersistedForm.svelte.js +37 -10
  30. package/dist/lib/stores/sidebar.svelte.d.ts +6 -0
  31. package/dist/lib/stores/sidebar.svelte.js +15 -0
  32. package/dist/lib/stores/toast.svelte.js +12 -1
  33. package/dist/lib/utils/date.d.ts +3 -1
  34. package/dist/lib/utils/date.js +54 -34
  35. package/dist/lib/utils/form.d.ts +2 -1
  36. package/dist/lib/utils/form.js +3 -2
  37. package/package.json +1 -1
@@ -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 = JSON.parse(JSON.stringify(initialValues));
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 { ...JSON.parse(JSON.stringify(initialValues)), ...draft };
139
+ return { ...$state.snapshot(initialValues), ...draft };
131
140
  }
132
- return JSON.parse(JSON.stringify(initialValues));
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: getInitialValues(),
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 = { ...baseForm.data };
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(baseForm.data);
199
- if (baseForm.isDirty) {
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, JSON.parse(JSON.stringify(originalInitialValues)));
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 = crypto.randomUUID();
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)
@@ -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
@@ -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 hasTime = false;
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
- date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day), hour ? parseInt(hour) : 0, minute ? parseInt(minute) : 0);
137
- hasTime = !!hour;
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 (!date) {
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
- let parsedHour = hour ? parseInt(hour) : 0;
150
- if (meridiem) {
151
- const upper = meridiem.toUpperCase();
152
- if (upper === 'PM' && parsedHour !== 12) {
153
- parsedHour += 12;
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 (!date) {
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
- let parsedHour = hour ? parseInt(hour) : 0;
197
- if (meridiem) {
198
- const upper = meridiem.toUpperCase();
199
- if (upper === 'PM' && parsedHour !== 12) {
200
- parsedHour += 12;
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
- // If dateOnly mode and time was provided, still accept it but set time to midnight
220
- if (options?.dateOnly && !hasTime) {
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 };
@@ -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
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.22.1",
3
+ "version": "1.22.2",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",