carbon-components-svelte 0.98.0 → 0.98.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 (129) hide show
  1. package/package.json +1 -1
  2. package/src/Checkbox/Checkbox.svelte +17 -3
  3. package/src/Checkbox/InlineCheckbox.svelte +1 -1
  4. package/src/CodeSnippet/CodeSnippet.svelte +2 -2
  5. package/src/ComboBox/ComboBox.svelte +12 -7
  6. package/src/ContainedList/ContainedList.svelte +1 -1
  7. package/src/ContentSwitcher/ContentSwitcher.svelte +15 -3
  8. package/src/ContentSwitcher/Switch.svelte +2 -8
  9. package/src/ContextMenu/ContextMenuOption.svelte +1 -1
  10. package/src/DataTable/DataTable.svelte +12 -45
  11. package/src/DataTable/DataTableTypes.d.ts +2 -2
  12. package/src/DataTable/TableHeader.svelte +1 -1
  13. package/src/DataTable/ToolbarSearch.svelte +2 -1
  14. package/src/DataTable/data-table-utils.d.ts +54 -0
  15. package/src/DataTable/data-table-utils.js +239 -0
  16. package/src/DatePicker/DatePicker.svelte +1 -1
  17. package/src/DatePicker/DatePickerInput.svelte +1 -1
  18. package/src/DatePicker/DatePickerSkeleton.svelte +1 -1
  19. package/src/Dropdown/Dropdown.svelte +1 -1
  20. package/src/FileUploader/FileUploaderButton.svelte +1 -1
  21. package/src/FileUploader/FileUploaderDropContainer.svelte +1 -1
  22. package/src/FileUploader/FileUploaderItem.svelte +1 -1
  23. package/src/FormLabel/FormLabel.svelte +1 -1
  24. package/src/ImageLoader/ImageLoader.svelte +2 -2
  25. package/src/ListBox/ListBoxField.svelte +1 -1
  26. package/src/ListBox/ListBoxMenu.svelte +1 -1
  27. package/src/Modal/Modal.svelte +1 -1
  28. package/src/MultiSelect/MultiSelect.svelte +2 -1
  29. package/src/Notification/InlineNotification.svelte +1 -1
  30. package/src/Notification/NotificationQueue.svelte +4 -4
  31. package/src/Notification/ToastNotification.svelte +1 -1
  32. package/src/NumberInput/NumberInput.svelte +1 -1
  33. package/src/OverflowMenu/OverflowMenu.svelte +6 -6
  34. package/src/OverflowMenu/OverflowMenuItem.svelte +1 -1
  35. package/src/Pagination/Pagination.svelte +1 -1
  36. package/src/ProgressBar/ProgressBar.svelte +2 -2
  37. package/src/ProgressIndicator/ProgressStep.svelte +1 -1
  38. package/src/RadioButton/RadioButton.svelte +1 -1
  39. package/src/Search/Search.svelte +1 -1
  40. package/src/Select/Select.svelte +1 -1
  41. package/src/Select/SelectItem.svelte +1 -1
  42. package/src/Slider/Slider.svelte +1 -1
  43. package/src/StructuredList/StructuredList.svelte +10 -1
  44. package/src/StructuredList/StructuredListInput.svelte +1 -1
  45. package/src/Tabs/Tab.svelte +1 -1
  46. package/src/Tabs/TabContent.svelte +1 -1
  47. package/src/Tag/Tag.svelte +1 -1
  48. package/src/TextArea/TextArea.svelte +1 -1
  49. package/src/TextInput/PasswordInput.svelte +1 -1
  50. package/src/TextInput/TextInput.svelte +1 -1
  51. package/src/Tile/ExpandableTile.svelte +1 -1
  52. package/src/Tile/RadioTile.svelte +1 -1
  53. package/src/Tile/SelectableTile.svelte +1 -1
  54. package/src/TimePicker/TimePicker.svelte +1 -1
  55. package/src/TimePicker/TimePickerSelect.svelte +1 -1
  56. package/src/Toggle/Toggle.svelte +1 -1
  57. package/src/Toggle/ToggleSkeleton.svelte +1 -1
  58. package/src/Tooltip/Tooltip.svelte +12 -5
  59. package/src/TooltipDefinition/TooltipDefinition.svelte +1 -1
  60. package/src/TooltipIcon/TooltipIcon.svelte +1 -1
  61. package/src/TreeView/TreeView.svelte +112 -74
  62. package/src/TreeView/TreeViewNode.svelte +3 -3
  63. package/src/UIShell/Header.svelte +2 -2
  64. package/src/UIShell/HeaderNavItem.svelte +1 -1
  65. package/types/Checkbox/Checkbox.svelte.d.ts +3 -1
  66. package/types/Checkbox/InlineCheckbox.svelte.d.ts +1 -1
  67. package/types/CodeSnippet/CodeSnippet.svelte.d.ts +1 -1
  68. package/types/ComboBox/ComboBox.svelte.d.ts +6 -3
  69. package/types/ContainedList/ContainedList.svelte.d.ts +5 -1
  70. package/types/ContainedList/ContainedListItem.svelte.d.ts +2 -0
  71. package/types/ContentSwitcher/ContentSwitcher.svelte.d.ts +1 -1
  72. package/types/ContentSwitcher/Switch.svelte.d.ts +1 -1
  73. package/types/ContextMenu/ContextMenuOption.svelte.d.ts +3 -1
  74. package/types/DataTable/DataTable.svelte.d.ts +11 -1
  75. package/types/DataTable/DataTableTypes.d.ts +2 -2
  76. package/types/DataTable/TableHeader.svelte.d.ts +1 -1
  77. package/types/DataTable/ToolbarBatchActions.svelte.d.ts +2 -0
  78. package/types/DataTable/data-table-utils.d.ts +54 -0
  79. package/types/DatePicker/DatePicker.svelte.d.ts +1 -1
  80. package/types/DatePicker/DatePickerInput.svelte.d.ts +3 -1
  81. package/types/DatePicker/DatePickerSkeleton.svelte.d.ts +1 -1
  82. package/types/Dropdown/Dropdown.svelte.d.ts +1 -1
  83. package/types/FileUploader/FileUploaderButton.svelte.d.ts +3 -1
  84. package/types/FileUploader/FileUploaderDropContainer.svelte.d.ts +3 -1
  85. package/types/FileUploader/FileUploaderItem.svelte.d.ts +1 -1
  86. package/types/FormLabel/FormLabel.svelte.d.ts +1 -1
  87. package/types/ImageLoader/ImageLoader.svelte.d.ts +2 -1
  88. package/types/ListBox/ListBoxField.svelte.d.ts +1 -1
  89. package/types/ListBox/ListBoxMenu.svelte.d.ts +1 -1
  90. package/types/Modal/Modal.svelte.d.ts +5 -1
  91. package/types/MultiSelect/MultiSelect.svelte.d.ts +3 -1
  92. package/types/Notification/InlineNotification.svelte.d.ts +7 -1
  93. package/types/Notification/NotificationQueue.svelte.d.ts +2 -0
  94. package/types/Notification/ToastNotification.svelte.d.ts +7 -1
  95. package/types/NumberInput/NumberInput.svelte.d.ts +3 -1
  96. package/types/OverflowMenu/OverflowMenu.svelte.d.ts +3 -1
  97. package/types/OverflowMenu/OverflowMenuItem.svelte.d.ts +1 -1
  98. package/types/Pagination/Pagination.svelte.d.ts +1 -1
  99. package/types/ProgressBar/ProgressBar.svelte.d.ts +3 -1
  100. package/types/ProgressIndicator/ProgressStep.svelte.d.ts +1 -1
  101. package/types/RadioButton/RadioButton.svelte.d.ts +3 -1
  102. package/types/RadioButtonGroup/RadioButtonGroup.svelte.d.ts +2 -0
  103. package/types/Search/Search.svelte.d.ts +3 -1
  104. package/types/Select/Select.svelte.d.ts +3 -1
  105. package/types/Slider/Slider.svelte.d.ts +3 -1
  106. package/types/StructuredList/StructuredListInput.svelte.d.ts +1 -1
  107. package/types/Tabs/Tab.svelte.d.ts +1 -1
  108. package/types/Tabs/TabContent.svelte.d.ts +1 -1
  109. package/types/Tabs/Tabs.svelte.d.ts +2 -0
  110. package/types/Tag/Tag.svelte.d.ts +1 -1
  111. package/types/TextArea/TextArea.svelte.d.ts +3 -1
  112. package/types/TextInput/PasswordInput.svelte.d.ts +3 -1
  113. package/types/TextInput/TextInput.svelte.d.ts +3 -1
  114. package/types/Tile/ExpandableTile.svelte.d.ts +5 -1
  115. package/types/Tile/RadioTile.svelte.d.ts +1 -1
  116. package/types/Tile/SelectableTile.svelte.d.ts +1 -1
  117. package/types/Tile/SelectableTileGroup.svelte.d.ts +2 -0
  118. package/types/Tile/TileGroup.svelte.d.ts +2 -0
  119. package/types/TimePicker/TimePicker.svelte.d.ts +3 -1
  120. package/types/TimePicker/TimePickerSelect.svelte.d.ts +3 -1
  121. package/types/Toggle/Toggle.svelte.d.ts +3 -1
  122. package/types/Toggle/ToggleSkeleton.svelte.d.ts +3 -1
  123. package/types/Tooltip/Tooltip.svelte.d.ts +2 -2
  124. package/types/TooltipDefinition/TooltipDefinition.svelte.d.ts +3 -1
  125. package/types/TooltipIcon/TooltipIcon.svelte.d.ts +1 -1
  126. package/types/TreeView/TreeView.svelte.d.ts +6 -2
  127. package/types/TreeView/TreeViewNode.svelte.d.ts +2 -1
  128. package/types/UIShell/Header.svelte.d.ts +6 -0
  129. package/types/UIShell/HeaderAction.svelte.d.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carbon-components-svelte",
3
- "version": "0.98.0",
3
+ "version": "0.98.2",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Svelte implementation of the Carbon Design System",
6
6
  "type": "module",
@@ -53,7 +53,7 @@
53
53
  export let title = undefined;
54
54
 
55
55
  /** Set an id for the input label */
56
- export let id = "ccs-" + Math.random().toString(36);
56
+ export let id = `ccs-${Math.random().toString(36)}`;
57
57
 
58
58
  /** Obtain a reference to the input HTML element */
59
59
  export let ref = null;
@@ -65,7 +65,17 @@
65
65
 
66
66
  $: useGroup = Array.isArray(group);
67
67
  $: if (useGroup) checked = group.includes(value);
68
- $: dispatch("check", checked);
68
+
69
+ // Track previous checked value to avoid duplicate dispatches in Svelte 5
70
+ // The reactive statement will only dispatch when checked changes externally (e.g., via bind:checked)
71
+ let previousChecked = checked;
72
+ $: {
73
+ const hasChanged = previousChecked !== checked;
74
+ if (hasChanged) {
75
+ previousChecked = checked;
76
+ dispatch("check", checked);
77
+ }
78
+ }
69
79
 
70
80
  let refLabel = null;
71
81
 
@@ -121,7 +131,11 @@
121
131
  ? group.filter((_value) => _value !== value)
122
132
  : [...group, value];
123
133
  } else {
124
- checked = !checked;
134
+ const newChecked = !checked;
135
+ previousChecked = newChecked;
136
+ checked = newChecked;
137
+ // Dispatch directly for user-initiated changes to avoid duplicate events in Svelte 5
138
+ dispatch("check", newChecked);
125
139
  }
126
140
  }}
127
141
  on:change
@@ -12,7 +12,7 @@
12
12
  export let title = undefined;
13
13
 
14
14
  /** Set an id for the input label */
15
- export let id = "ccs-" + Math.random().toString(36);
15
+ export let id = `ccs-${Math.random().toString(36)}`;
16
16
 
17
17
  /** Obtain a reference to the input HTML element */
18
18
  export let ref = null;
@@ -103,7 +103,7 @@
103
103
  export let showMoreLess = true;
104
104
 
105
105
  /** Set an id for the code element */
106
- export let id = "ccs-" + Math.random().toString(36);
106
+ export let id = `ccs-${Math.random().toString(36)}`;
107
107
 
108
108
  /** Obtain a reference to the pre HTML element */
109
109
  export let ref = null;
@@ -127,7 +127,7 @@
127
127
 
128
128
  $: expandText = expanded ? showLessText : showMoreText;
129
129
  $: minHeight = expanded ? 16 * 15 : 48;
130
- $: maxHeight = expanded ? "none" : 16 * 15 + "px";
130
+ $: maxHeight = expanded ? "none" : `${16 * 15}px`;
131
131
 
132
132
  // Show more/less only applies to multi-line code snippets
133
133
  $: if (type !== "multi") showMoreLess = false;
@@ -120,7 +120,7 @@
120
120
  export let translateWithIdSelection = undefined;
121
121
 
122
122
  /** Set an id for the list box component */
123
- export let id = "ccs-" + Math.random().toString(36);
123
+ export let id = `ccs-${Math.random().toString(36)}`;
124
124
 
125
125
  /**
126
126
  * Specify a name attribute for the input.
@@ -200,10 +200,9 @@
200
200
  }
201
201
 
202
202
  /**
203
- * Clear the combo box programmatically
204
- * @type {(options?: { focus?: boolean; }) => void}
205
- * @param {object} [options] - Configuration options for clearing
206
- * @param {boolean} [options.focus=true] - Whether to focus the combo box after clearing
203
+ * Clear the combo box programmatically.
204
+ * By default, focuses the combo box after clearing. Set `options.focus` to `false` to prevent focusing.
205
+ * @type {(options?: { focus?: boolean; }) => Promise<void>}
207
206
  * @example
208
207
  * ```svelte
209
208
  * <ComboBox bind:this={comboBox} items={items} />
@@ -211,7 +210,7 @@
211
210
  * <button on:click={() => comboBox.clear({ focus: false })}>Clear (No Focus)</button>
212
211
  * ```
213
212
  */
214
- export function clear(options = {}) {
213
+ export async function clear(options = {}) {
215
214
  prevSelectedId = null;
216
215
  highlightedIndex = -1;
217
216
  highlightedId = undefined;
@@ -219,6 +218,8 @@
219
218
  selectedItem = undefined;
220
219
  open = false;
221
220
  value = "";
221
+ // Ensure binding updates are complete before focusing.
222
+ await tick();
222
223
  if (options?.focus !== false) ref?.focus();
223
224
  }
224
225
 
@@ -262,6 +263,8 @@
262
263
 
263
264
  $: if (selectedId !== undefined) {
264
265
  if (prevSelectedId !== selectedId) {
266
+ // Only dispatch select event if not initial render (prevSelectedId was not null)
267
+ const isInitialRender = prevSelectedId === null;
265
268
  prevSelectedId = selectedId;
266
269
  if (filteredItems?.length === 1 && open) {
267
270
  selectedId = filteredItems[0].id;
@@ -271,7 +274,9 @@
271
274
  } else {
272
275
  selectedItem = items.find((item) => item.id === selectedId);
273
276
  }
274
- dispatch("select", { selectedId, selectedItem });
277
+ if (!isInitialRender) {
278
+ dispatch("select", { selectedId, selectedItem });
279
+ }
275
280
  }
276
281
  } else {
277
282
  prevSelectedId = selectedId;
@@ -21,7 +21,7 @@
21
21
  export let inset = false;
22
22
 
23
23
  /** Set an id for the list element */
24
- export let id = "ccs-" + Math.random().toString(36);
24
+ export let id = `ccs-${Math.random().toString(36)}`;
25
25
 
26
26
  $: labelId = `label-${id}`;
27
27
  </script>
@@ -12,7 +12,7 @@
12
12
  */
13
13
  export let size = undefined;
14
14
 
15
- import { afterUpdate, createEventDispatcher, setContext } from "svelte";
15
+ import { afterUpdate, createEventDispatcher, setContext, tick } from "svelte";
16
16
  import { writable } from "svelte/store";
17
17
 
18
18
  const dispatch = createEventDispatcher();
@@ -21,6 +21,9 @@
21
21
  */
22
22
  const currentId = writable(null);
23
23
 
24
+ /** @type {HTMLDivElement | null} */
25
+ let refContainer = null;
26
+
24
27
  $: currentIndex = -1;
25
28
  $: switches = [];
26
29
  $: if (switches[currentIndex]) {
@@ -47,9 +50,9 @@
47
50
  };
48
51
 
49
52
  /**
50
- * @type {(direction: number) => void}
53
+ * @type {(direction: number) => Promise<void>}
51
54
  */
52
- const change = (direction) => {
55
+ const change = async (direction) => {
53
56
  let index = currentIndex + direction;
54
57
 
55
58
  if (index < 0) {
@@ -59,6 +62,14 @@
59
62
  }
60
63
 
61
64
  selectedIndex = index;
65
+
66
+ await tick();
67
+ const tabs = refContainer?.querySelectorAll("[role='tab']");
68
+ const tab = tabs?.[index];
69
+
70
+ if (tab instanceof HTMLElement) {
71
+ tab.focus();
72
+ }
62
73
  };
63
74
 
64
75
  setContext("ContentSwitcher", {
@@ -78,6 +89,7 @@
78
89
  <!-- svelte-ignore a11y-mouse-events-have-key-events -->
79
90
  <!-- svelte-ignore a11y-interactive-supports-focus -->
80
91
  <div
92
+ bind:this={refContainer}
81
93
  role="tablist"
82
94
  class:bx--content-switcher={true}
83
95
  class:bx--content-switcher--sm={size === "sm"}
@@ -18,12 +18,12 @@
18
18
  export let disabled = false;
19
19
 
20
20
  /** Set an id for the button element */
21
- export let id = "ccs-" + Math.random().toString(36);
21
+ export let id = `ccs-${Math.random().toString(36)}`;
22
22
 
23
23
  /** Obtain a reference to the button HTML element */
24
24
  export let ref = null;
25
25
 
26
- import { afterUpdate, getContext, onMount } from "svelte";
26
+ import { getContext, onMount } from "svelte";
27
27
 
28
28
  const ctx = getContext("ContentSwitcher");
29
29
 
@@ -33,12 +33,6 @@
33
33
  selected = currentId === id;
34
34
  });
35
35
 
36
- afterUpdate(() => {
37
- if (selected) {
38
- ref.focus();
39
- }
40
- });
41
-
42
36
  onMount(() => {
43
37
  return () => unsubscribe();
44
38
  });
@@ -55,7 +55,7 @@
55
55
  * Specify the id.
56
56
  * It's recommended to provide an id as a value to bind to within a selectable/radio menu group.
57
57
  */
58
- export let id = "ccs-" + Math.random().toString(36);
58
+ export let id = `ccs-${Math.random().toString(36)}`;
59
59
 
60
60
  /** Obtain a reference to the list item HTML element */
61
61
  export let ref = null;
@@ -102,7 +102,7 @@
102
102
  * When the table is inside a form, this name will
103
103
  * be included in the form data on submit.
104
104
  */
105
- export let inputName = "ccs-" + Math.random().toString(36);
105
+ export let inputName = `ccs-${Math.random().toString(36)}`;
106
106
 
107
107
  /** Set to `true` to use zebra styles */
108
108
  export let zebra = false;
@@ -192,6 +192,12 @@
192
192
  import InlineCheckbox from "../Checkbox/InlineCheckbox.svelte";
193
193
  import ChevronRight from "../icons/ChevronRight.svelte";
194
194
  import RadioButton from "../RadioButton/RadioButton.svelte";
195
+ import {
196
+ compareValues,
197
+ formatHeaderWidth,
198
+ getDisplayedRows,
199
+ resolvePath,
200
+ } from "./data-table-utils.js";
195
201
  import Table from "./Table.svelte";
196
202
  import TableBody from "./TableBody.svelte";
197
203
  import TableCell from "./TableCell.svelte";
@@ -200,8 +206,6 @@
200
206
  import TableHeader from "./TableHeader.svelte";
201
207
  import TableRow from "./TableRow.svelte";
202
208
 
203
- const PATH_SPLIT_REGEX = /[.[\]'"]/;
204
-
205
209
  const sortDirectionMap = {
206
210
  none: "ascending",
207
211
  ascending: "descending",
@@ -219,7 +223,7 @@
219
223
 
220
224
  // Internal ID prefix for radio buttons, checkboxes, etc.
221
225
  // since there may be multiple `DataTable` instances that have overlapping row ids.
222
- const id = "ccs-" + Math.random().toString(36);
226
+ const id = `ccs-${Math.random().toString(36)}`;
223
227
 
224
228
  // Store a copy of the original rows for filter restoration.
225
229
  $: originalRows = [...rows];
@@ -228,13 +232,6 @@
228
232
  a[c.key] = c.key;
229
233
  return a;
230
234
  }, {});
231
- const resolvePath = (object, path) => {
232
- if (path in object) return object[path];
233
- return path
234
- .split(PATH_SPLIT_REGEX)
235
- .filter((p) => p)
236
- .reduce((o, p) => (o && typeof o === "object" ? o[p] : o), object);
237
- };
238
235
 
239
236
  /**
240
237
  * @type {() => void}
@@ -275,7 +272,7 @@
275
272
  return searchableKeys.some((key) => {
276
273
  const _value = resolvePath(row, key);
277
274
  if (typeof _value === "string" || typeof _value === "number") {
278
- return (_value + "")?.toLowerCase().includes(value);
275
+ return `${_value}`?.toLowerCase().includes(value);
279
276
  }
280
277
  return false;
281
278
  });
@@ -342,48 +339,18 @@
342
339
  sortedRows = $tableRows;
343
340
  } else {
344
341
  sortedRows = [...$tableRows].sort((a, b) => {
345
- const itemA = ascending
346
- ? resolvePath(a, sortKey)
347
- : resolvePath(b, sortKey);
348
- const itemB = ascending
349
- ? resolvePath(b, sortKey)
350
- : resolvePath(a, sortKey);
351
-
352
- if (sortingHeader?.sort) return sortingHeader.sort(itemA, itemB);
353
-
354
- if (typeof itemA === "number" && typeof itemB === "number")
355
- return itemA - itemB;
356
-
357
- if ([itemA, itemB].every((item) => !item && item !== 0)) return 0;
358
- if (!itemA && itemA !== 0) return ascending ? 1 : -1;
359
- if (!itemB && itemB !== 0) return ascending ? -1 : 1;
360
-
361
- return itemA
362
- .toString()
363
- .localeCompare(itemB.toString(), "en", { numeric: true });
342
+ const itemA = resolvePath(a, sortKey);
343
+ const itemB = resolvePath(b, sortKey);
344
+ return compareValues(itemA, itemB, ascending, sortingHeader?.sort);
364
345
  });
365
346
  }
366
347
  }
367
- const getDisplayedRows = (rows, page, pageSize) =>
368
- page && pageSize
369
- ? rows.slice((page - 1) * pageSize, page * pageSize)
370
- : rows;
371
348
  $: displayedRows = getDisplayedRows($tableRows, page, pageSize);
372
349
  $: displayedSortedRows = getDisplayedRows(sortedRows, page, pageSize);
373
350
 
374
351
  $: hasCustomHeaderWidth = headers.some(
375
352
  (header) => header.width || header.minWidth,
376
353
  );
377
-
378
- /** @type {(header: DataTableHeader) => undefined | string} */
379
- const formatHeaderWidth = (header) => {
380
- const styles = [
381
- header.width && `width: ${header.width}`,
382
- header.minWidth && `min-width: ${header.minWidth}`,
383
- ].filter(Boolean);
384
- if (styles.length === 0) return undefined;
385
- return styles.join(";");
386
- };
387
354
  </script>
388
355
 
389
356
  <TableContainer {useStaticWidth} {...$$restProps}>
@@ -6,8 +6,8 @@ type Join<K, P> = K extends string | number
6
6
  : never
7
7
  : never;
8
8
 
9
- // For performance, the maximum traversal depth is 10.
10
- export type PropertyPath<T, D extends number = 10> = [D] extends [never]
9
+ // For performance, the maximum traversal depth is 3.
10
+ export type PropertyPath<T, D extends number = 3> = [D] extends [never]
11
11
  ? never
12
12
  : T extends object
13
13
  ? {
@@ -31,7 +31,7 @@
31
31
  export let translateWithId = (id) => defaultTranslations[id];
32
32
 
33
33
  /** Set an id for the top-level element */
34
- export let id = "ccs-" + Math.random().toString(36);
34
+ export let id = `ccs-${Math.random().toString(36)}`;
35
35
 
36
36
  import ArrowsVertical from "../icons/ArrowsVertical.svelte";
37
37
  import ArrowUp from "../icons/ArrowUp.svelte";
@@ -48,6 +48,7 @@
48
48
 
49
49
  import { afterUpdate, getContext, onMount, tick } from "svelte";
50
50
  import Search from "../Search/Search.svelte";
51
+ import { rowsEqual } from "./data-table-utils.js";
51
52
 
52
53
  const ctx = getContext("DataTable") ?? {};
53
54
 
@@ -58,7 +59,7 @@
58
59
  unsubscribe = ctx?.tableRows.subscribe((tableRows) => {
59
60
  // Only update if the rows have actually changed.
60
61
  // This approach works in both Svelte 4 and Svelte 5.
61
- if (JSON.stringify(tableRows) !== JSON.stringify(rows)) {
62
+ if (!rowsEqual(tableRows, rows)) {
62
63
  rows = tableRows;
63
64
  }
64
65
  });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Lightweight deep equality check optimized for DataTable rows.
3
+ * Compares arrays of row objects by first checking IDs (fast path),
4
+ * then falling back to deep object comparison to handle nested structures.
5
+ */
6
+ export function rowsEqual<Row extends Record<string, any>>(
7
+ a: ReadonlyArray<Row> | null,
8
+ b: ReadonlyArray<Row> | null,
9
+ ): boolean;
10
+
11
+ /**
12
+ * Resolves a nested property path in an object.
13
+ * Supports both direct property access and nested paths like "contact.company".
14
+ */
15
+ export function resolvePath<T extends Record<string, unknown>>(
16
+ object: T,
17
+ path: string,
18
+ ): unknown;
19
+
20
+ /**
21
+ * Paginates an array of rows based on page number and page size.
22
+ */
23
+ export function getDisplayedRows<Row extends Record<string, unknown>>(
24
+ rows: ReadonlyArray<Row>,
25
+ page: number,
26
+ pageSize: number,
27
+ ): ReadonlyArray<Row>;
28
+
29
+ /**
30
+ * Formats header width styles for table headers.
31
+ * Combines width and minWidth into a CSS style string.
32
+ */
33
+ export function formatHeaderWidth<
34
+ Header extends {
35
+ width?: string | null | number;
36
+ minWidth?: string | null | number;
37
+ [key: string]: unknown;
38
+ } = {
39
+ width?: string | null | number;
40
+ minWidth?: string | null | number;
41
+ [key: string]: unknown;
42
+ },
43
+ >(header: Header): string | undefined;
44
+
45
+ /**
46
+ * Compares two values for sorting in a data table.
47
+ * Handles numbers, strings, null/undefined values, and custom sort functions.
48
+ */
49
+ export function compareValues<T = unknown>(
50
+ itemA: T,
51
+ itemB: T,
52
+ ascending: boolean,
53
+ customSort?: ((a: T, b: T) => number) | false | undefined,
54
+ ): number;
@@ -0,0 +1,239 @@
1
+ // @ts-check
2
+ /**
3
+ * Deep equality check for values (nested objects and arrays).
4
+ * @param {*} a - First value to compare
5
+ * @param {*} b - Second value to compare
6
+ * @param {WeakMap<*, Set<*>>} [stack] - WeakMap used to track circular references
7
+ * @returns {boolean} True if values are deeply equal, false otherwise
8
+ */
9
+ function deepEqual(a, b, stack = new WeakMap()) {
10
+ // Fast path: reference equality.
11
+ if (a === b) return true;
12
+
13
+ // Handle null/undefined.
14
+ if (a == null || b == null) return a === b;
15
+
16
+ // Handle NaN: NaN is the only value where NaN !== NaN is true in JavaScript
17
+ // Without this check, two NaN values would incorrectly be considered unequal.
18
+ if (Number.isNaN(a) && Number.isNaN(b)) return true;
19
+ if (Number.isNaN(a) || Number.isNaN(b)) return false;
20
+
21
+ if (typeof a !== typeof b) return false;
22
+
23
+ if (a instanceof Date && b instanceof Date) {
24
+ return a.getTime() === b.getTime();
25
+ }
26
+
27
+ if (a instanceof RegExp && b instanceof RegExp) {
28
+ return a.source === b.source && a.flags === b.flags;
29
+ }
30
+
31
+ if (typeof a === "function" && typeof b === "function") {
32
+ return a === b;
33
+ }
34
+
35
+ if (Array.isArray(a) && Array.isArray(b)) {
36
+ if (a.length !== b.length) return false;
37
+ for (let i = 0; i < a.length; i++) {
38
+ // Pass the stack to handle nested arrays and prevent infinite recursion.
39
+ if (!deepEqual(a[i], b[i], stack)) return false;
40
+ }
41
+ return true;
42
+ }
43
+
44
+ if (typeof a === "object" && typeof b === "object") {
45
+ const aVisited = stack.get(a);
46
+
47
+ if (aVisited?.has(b)) {
48
+ // Circular reference: if we've already seen this (a, b) pair, they're equal.
49
+ return true;
50
+ }
51
+
52
+ // WeakMap entries are auto-removed when keys are garbage collected.
53
+ if (aVisited) {
54
+ aVisited.add(b);
55
+ } else {
56
+ stack.set(a, new Set([b]));
57
+ }
58
+
59
+ // Compare string keys: objects must have the same enumerable properties.
60
+ const keysA = Object.keys(a);
61
+ const keysB = Object.keys(b);
62
+ if (keysA.length !== keysB.length) {
63
+ stack.get(a)?.delete(b);
64
+ return false;
65
+ }
66
+
67
+ for (const key of keysA) {
68
+ // Pass the stack to handle nested objects and prevent infinite recursion.
69
+ if (!(key in b) || !deepEqual(a[key], b[key], stack)) {
70
+ stack.get(a)?.delete(b);
71
+ return false;
72
+ }
73
+ }
74
+
75
+ const symKeysA = Object.getOwnPropertySymbols(a);
76
+ const symKeysB = Object.getOwnPropertySymbols(b);
77
+ if (symKeysA.length !== symKeysB.length) {
78
+ stack.get(a)?.delete(b);
79
+ return false;
80
+ }
81
+
82
+ // Recursively compare Symbol property values.
83
+ for (const key of symKeysA) {
84
+ if (!symKeysB.includes(key) || !deepEqual(a[key], b[key], stack)) {
85
+ stack.get(a)?.delete(b);
86
+ return false;
87
+ }
88
+ }
89
+
90
+ // All checks passed: remove (a, b) from tracking before returning
91
+ // Cleanup is for correctness (not GC): allows same objects to be
92
+ // compared again without false circular reference detection.
93
+ stack.get(a)?.delete(b);
94
+ return true;
95
+ }
96
+
97
+ // Finally, use strict equality for primitives.
98
+ return a === b;
99
+ }
100
+
101
+ /**
102
+ * Lightweight deep equality check optimized for DataTable rows.
103
+ * Compares arrays of row objects by first checking IDs (fast path),
104
+ * then falling back to deep object comparison to handle nested structures.
105
+ * @template {Record<string, any>} Row - Row type with at least an optional `id` property
106
+ * @param {ReadonlyArray<Row> | null} a - First array of rows to compare
107
+ * @param {ReadonlyArray<Row> | null} b - Second array of rows to compare
108
+ * @returns {boolean} True if row arrays are deeply equal, false otherwise
109
+ */
110
+ export function rowsEqual(a, b) {
111
+ if (a === b) return true;
112
+ if (a === null || b === null) return false;
113
+ if (!Array.isArray(a) || !Array.isArray(b)) return false;
114
+
115
+ if (a.length !== b.length) return false;
116
+
117
+ // Fast path: compare by row IDs first, assuming rows have stable IDs.
118
+ for (let i = 0; i < a.length; i++) {
119
+ if (a[i]?.id !== b[i]?.id) return false;
120
+ }
121
+
122
+ // If IDs match, do deep comparison of row objects
123
+ // This catches cases where row data changed but ID stayed the same,
124
+ // including changes in nested objects (e.g., "contact.company")
125
+ for (let i = 0; i < a.length; i++) {
126
+ const rowA = a[i];
127
+ const rowB = b[i];
128
+
129
+ // Fast path: same reference
130
+ if (rowA === rowB) continue;
131
+
132
+ // Deep comparison to handle nested objects and arrays
133
+ if (!deepEqual(rowA, rowB)) return false;
134
+ }
135
+
136
+ return true;
137
+ }
138
+
139
+ const PATH_SPLIT_REGEX = /[.[\]'"]/;
140
+ const MAX_PATH_CACHE_SIZE = 1000;
141
+ const pathCache = new Map();
142
+
143
+ /**
144
+ * Resolves a nested property path in an object.
145
+ * Supports both direct property access and nested paths like "contact.company".
146
+ * @template {Record<string, unknown>} T
147
+ * @param {T} object - The object to resolve the path from
148
+ * @param {string} path - The property path (e.g., "name" or "contact.company")
149
+ * @returns {unknown} The resolved value, or undefined if the path doesn't exist
150
+ */
151
+ export function resolvePath(object, path) {
152
+ if (path in object) return object[path];
153
+
154
+ let segments = pathCache.get(path);
155
+ if (!segments) {
156
+ segments = path.split(PATH_SPLIT_REGEX).filter((p) => p);
157
+ if (segments.length > 1) {
158
+ if (pathCache.size >= MAX_PATH_CACHE_SIZE) {
159
+ const firstKey = pathCache.keys().next().value;
160
+ pathCache.delete(firstKey);
161
+ }
162
+ pathCache.set(path, segments);
163
+ }
164
+ }
165
+
166
+ return segments.reduce(
167
+ (o, p) => (o && typeof o === "object" ? o[p] : o),
168
+ object,
169
+ );
170
+ }
171
+
172
+ /**
173
+ * Paginates an array of rows based on page number and page size.
174
+ * @template {Record<string, unknown>} Row
175
+ * @param {ReadonlyArray<Row>} rows - The rows to paginate
176
+ * @param {number} page - The current page number (1-indexed)
177
+ * @param {number} pageSize - The number of items per page
178
+ * @returns {ReadonlyArray<Row>} The paginated rows, or all rows if pagination is disabled
179
+ */
180
+ export function getDisplayedRows(rows, page, pageSize) {
181
+ if (page && pageSize) {
182
+ return rows.slice((page - 1) * pageSize, page * pageSize);
183
+ }
184
+ return rows;
185
+ }
186
+
187
+ /**
188
+ * Formats header width styles for table headers.
189
+ * Combines width and minWidth into a CSS style string.
190
+ * @template {object} Header
191
+ * @param {Header & { width?: string | null | number; minWidth?: string | null | number; [key: string]: unknown }} header - The header object
192
+ * @returns {string | undefined} The formatted style string, or undefined if no width styles
193
+ */
194
+ export function formatHeaderWidth(header) {
195
+ const styles = [
196
+ header.width && `width: ${header.width}`,
197
+ header.minWidth && `min-width: ${header.minWidth}`,
198
+ ].filter(Boolean);
199
+ if (styles.length === 0) return undefined;
200
+ return styles.join(";");
201
+ }
202
+
203
+ /**
204
+ * Compares two values for sorting in a data table.
205
+ * Handles numbers, strings, null/undefined values, and custom sort functions.
206
+ * @template T
207
+ * @param {T} itemA - First value to compare
208
+ * @param {T} itemB - Second value to compare
209
+ * @param {boolean} ascending - Whether to sort in ascending order
210
+ * @param {((a: T, b: T) => number) | false | undefined} customSort - Optional custom sort function
211
+ * @returns {number} Negative if a < b (ascending) or a > b (descending), positive if a > b (ascending) or a < b (descending), 0 if equal
212
+ */
213
+ export function compareValues(itemA, itemB, ascending, customSort) {
214
+ if (customSort) return customSort(itemA, itemB);
215
+
216
+ let result;
217
+
218
+ // Fast path: numeric comparison
219
+ if (typeof itemA === "number" && typeof itemB === "number") {
220
+ result = itemA - itemB;
221
+ } else {
222
+ // Handle null/undefined values
223
+ if ([itemA, itemB].every((item) => !item && item !== 0)) {
224
+ result = 0;
225
+ } else if (!itemA && itemA !== 0) {
226
+ result = 1;
227
+ } else if (!itemB && itemB !== 0) {
228
+ result = -1;
229
+ } else {
230
+ // String comparison with locale-aware numeric sorting
231
+ result = String(itemA).localeCompare(String(itemB), "en", {
232
+ numeric: true,
233
+ });
234
+ }
235
+ }
236
+
237
+ // Reverse result for descending order
238
+ return ascending ? result : -result;
239
+ }