carbon-components-svelte 0.98.0 → 0.98.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carbon-components-svelte",
3
- "version": "0.98.0",
3
+ "version": "0.98.1",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Svelte implementation of the Carbon Design System",
6
6
  "type": "module",
@@ -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
@@ -201,7 +201,7 @@
201
201
 
202
202
  /**
203
203
  * Clear the combo box programmatically
204
- * @type {(options?: { focus?: boolean; }) => void}
204
+ * @type {(options?: { focus?: boolean; }) => Promise<void>}
205
205
  * @param {object} [options] - Configuration options for clearing
206
206
  * @param {boolean} [options.focus=true] - Whether to focus the combo box after clearing
207
207
  * @example
@@ -211,7 +211,7 @@
211
211
  * <button on:click={() => comboBox.clear({ focus: false })}>Clear (No Focus)</button>
212
212
  * ```
213
213
  */
214
- export function clear(options = {}) {
214
+ export async function clear(options = {}) {
215
215
  prevSelectedId = null;
216
216
  highlightedIndex = -1;
217
217
  highlightedId = undefined;
@@ -219,6 +219,8 @@
219
219
  selectedItem = undefined;
220
220
  open = false;
221
221
  value = "";
222
+ // Ensure binding updates are complete before focusing.
223
+ await tick();
222
224
  if (options?.focus !== false) ref?.focus();
223
225
  }
224
226
 
@@ -262,6 +264,8 @@
262
264
 
263
265
  $: if (selectedId !== undefined) {
264
266
  if (prevSelectedId !== selectedId) {
267
+ // Only dispatch select event if not initial render (prevSelectedId was not null)
268
+ const isInitialRender = prevSelectedId === null;
265
269
  prevSelectedId = selectedId;
266
270
  if (filteredItems?.length === 1 && open) {
267
271
  selectedId = filteredItems[0].id;
@@ -271,7 +275,9 @@
271
275
  } else {
272
276
  selectedItem = items.find((item) => item.id === selectedId);
273
277
  }
274
- dispatch("select", { selectedId, selectedItem });
278
+ if (!isInitialRender) {
279
+ dispatch("select", { selectedId, selectedItem });
280
+ }
275
281
  }
276
282
  } else {
277
283
  prevSelectedId = selectedId;
@@ -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"}
@@ -23,7 +23,7 @@
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
  });
@@ -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",
@@ -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}
@@ -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
  ? {
@@ -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
+ }
@@ -27,6 +27,9 @@
27
27
  */
28
28
  const selectedValue = writable(selected);
29
29
 
30
+ let prevSelectedValue = undefined;
31
+ let isInitialRender = true;
32
+
30
33
  /**
31
34
  * @type {(value: string) => void}
32
35
  */
@@ -40,7 +43,13 @@
40
43
  });
41
44
 
42
45
  $: selected = $selectedValue;
43
- $: dispatch("change", $selectedValue);
46
+ $: {
47
+ if (!isInitialRender && prevSelectedValue !== $selectedValue) {
48
+ dispatch("change", $selectedValue);
49
+ }
50
+ prevSelectedValue = $selectedValue;
51
+ isInitialRender = false;
52
+ }
44
53
  </script>
45
54
 
46
55
  <!-- svelte-ignore a11y-mouse-events-have-key-events -->
@@ -78,6 +78,8 @@
78
78
  */
79
79
  const tooltipOpen = writable(open);
80
80
 
81
+ let prevOpen = undefined;
82
+
81
83
  setContext("Tooltip", { tooltipOpen });
82
84
 
83
85
  function onKeydown(e) {
@@ -175,7 +177,12 @@
175
177
  });
176
178
 
177
179
  $: tooltipOpen.set(open);
178
- $: dispatch(open ? "open" : "close");
180
+ $: {
181
+ if (prevOpen !== undefined) {
182
+ dispatch(open ? "open" : "close");
183
+ }
184
+ prevOpen = open;
185
+ }
179
186
  $: buttonProps = {
180
187
  role: "button",
181
188
  "aria-haspopup": "true",
@@ -25,6 +25,38 @@
25
25
 
26
26
  return null;
27
27
  }
28
+
29
+ /**
30
+ * Creates a TreeWalker instance for keyboard navigation.
31
+ * @param {HTMLElement} root - The root element to traverse
32
+ * @returns {TreeWalker} A TreeWalker configured to navigate tree nodes
33
+ */
34
+ function createTreeWalkerInstance(root) {
35
+ return document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
36
+ acceptNode: (node) => {
37
+ if (node.classList.contains("bx--tree-node--disabled"))
38
+ return NodeFilter.FILTER_REJECT;
39
+ if (node.matches("li.bx--tree-node")) return NodeFilter.FILTER_ACCEPT;
40
+ return NodeFilter.FILTER_SKIP;
41
+ },
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Recursively flattens a tree of nodes into a single array
47
+ * @template {object} Node
48
+ * @param {ReadonlyArray<Node & { nodes?: Node[] }>} nodes
49
+ * @returns {Array<Node>}
50
+ */
51
+ function traverse(nodes) {
52
+ return nodes.reduce((acc, node) => {
53
+ acc.push(node);
54
+ if (Array.isArray(node.nodes) && node.nodes.length > 0) {
55
+ acc.push(...traverse(node.nodes));
56
+ }
57
+ return acc;
58
+ }, []);
59
+ }
28
60
  </script>
29
61
 
30
62
  <script>
@@ -98,7 +130,9 @@
98
130
  * ```
99
131
  */
100
132
  export function expandAll() {
101
- expandedIds = [...nodeIds];
133
+ expandedIdsSet = new Set(nodeIds);
134
+ expandedIds = Array.from(expandedIdsSet);
135
+ lastExpandedIdsRef = expandedIds;
102
136
  }
103
137
 
104
138
  /**
@@ -111,7 +145,9 @@
111
145
  * ```
112
146
  */
113
147
  export function collapseAll() {
148
+ expandedIdsSet.clear();
114
149
  expandedIds = [];
150
+ lastExpandedIdsRef = expandedIds;
115
151
  }
116
152
 
117
153
  /**
@@ -128,13 +164,16 @@
128
164
  * ```
129
165
  */
130
166
  export function expandNodes(filterNode = (node) => false) {
131
- expandedIds = flattenedNodes
167
+ const nodesToExpand = flattenedNodes
132
168
  .filter(
133
169
  (node) =>
134
170
  filterNode(node) ||
135
171
  node.nodes?.some((child) => filterNode(child) && child.nodes),
136
172
  )
137
173
  .map((node) => node.id);
174
+ nodesToExpand.forEach((id) => expandedIdsSet.add(id));
175
+ expandedIds = Array.from(expandedIdsSet);
176
+ lastExpandedIdsRef = expandedIds;
138
177
  }
139
178
 
140
179
  /**
@@ -151,9 +190,13 @@
151
190
  * ```
152
191
  */
153
192
  export function collapseNodes(filterNode = (node) => true) {
154
- expandedIds = flattenedNodes
155
- .filter((node) => expandedIds.includes(node.id) && !filterNode(node))
156
- .map((node) => node.id);
193
+ flattenedNodes.forEach((node) => {
194
+ if (expandedIdsSet.has(node.id) && filterNode(node)) {
195
+ expandedIdsSet.delete(node.id);
196
+ }
197
+ });
198
+ expandedIds = Array.from(expandedIdsSet);
199
+ lastExpandedIdsRef = expandedIds;
157
200
  }
158
201
 
159
202
  /**
@@ -181,18 +224,21 @@
181
224
  const { expand = true, select = true, focus = true } = options;
182
225
 
183
226
  for (const child of nodes) {
184
- const nodes = findNodeById(child, id);
227
+ const path = findNodeById(child, id);
185
228
 
186
- if (nodes) {
187
- const ids = nodes.map((node) => node.id);
188
- const nodeIds = new Set(ids);
229
+ if (path) {
230
+ const ids = path.map((node) => node.id);
231
+ const lastId = ids[ids.length - 1];
189
232
 
190
233
  if (expand) {
191
- expandNodes((node) => nodeIds.has(node.id));
234
+ const ancestorIds = ids.slice(0, -1);
235
+ for (const ancestorId of ancestorIds) {
236
+ expandedIdsSet.add(ancestorId);
237
+ }
238
+ expandedIds = Array.from(expandedIdsSet);
239
+ lastExpandedIdsRef = expandedIds;
192
240
  }
193
241
 
194
- const lastId = ids[ids.length - 1];
195
-
196
242
  if (select) {
197
243
  activeId = lastId;
198
244
  selectedIds = [lastId];
@@ -215,57 +261,58 @@
215
261
 
216
262
  const dispatch = createEventDispatcher();
217
263
  const labelId = `label-${Math.random().toString(36)}`;
218
- /**
219
- * @type {import("svelte/store").Writable<TreeNodeId>}
220
- */
264
+
265
+ /** @type {import("svelte/store").Writable<TreeNodeId>} */
221
266
  const activeNodeId = writable(activeId);
222
- /**
223
- * @type {import("svelte/store").Writable<ReadonlyArray<TreeNodeId>>}
224
- */
267
+ /** @type {import("svelte/store").Writable<ReadonlyArray<TreeNodeId>>} */
225
268
  const selectedNodeIds = writable(selectedIds);
226
- /**
227
- * @type {import("svelte/store").Writable<ReadonlyArray<TreeNodeId>>}
228
- */
269
+ /** @type {import("svelte/store").Writable<ReadonlyArray<TreeNodeId>>} */
229
270
  const expandedNodeIds = writable(expandedIds);
230
271
 
272
+ /** @type {HTMLElement | null} */
231
273
  let ref = null;
274
+ /** @type {TreeWalker | null} */
232
275
  let treeWalker = null;
233
276
 
234
- /**
235
- * @type {(node: TreeNode) => void}
236
- */
277
+ /** @type {ReadonlyArray<Node> | null} */
278
+ let cachedNodes = null;
279
+ /** @type {Array<Node> | null} */
280
+ let cachedFlattenedNodes = null;
281
+ /** @type {Array<TreeNodeId> | null} */
282
+ let cachedNodeIds = null;
283
+
284
+ /** @type {Set<TreeNodeId>} */
285
+ let expandedIdsSet = new Set(expandedIds);
286
+ /** @type {ReadonlyArray<TreeNodeId>} */
287
+ let lastExpandedIdsRef = expandedIds;
288
+
289
+ /** @type {(node: TreeNode) => void} */
237
290
  const clickNode = (node) => {
238
291
  activeId = node.id;
239
292
  selectedIds = [node.id];
240
293
  dispatch("select", node);
241
294
  };
242
295
 
243
- /**
244
- * @type {(node: TreeNode) => void}
245
- */
296
+ /** @type {(node: TreeNode) => void} */
246
297
  const selectNode = (node) => {
247
298
  selectedIds = [node.id];
248
299
  };
249
300
 
250
- /**
251
- * @type {(node: TreeNode, expanded: boolean) => void}
252
- */
301
+ /** @type {(node: TreeNode, expanded: boolean) => void} */
253
302
  const expandNode = (node, expanded) => {
254
303
  if (expanded) {
255
- expandedIds = [...expandedIds, node.id];
304
+ expandedIdsSet.add(node.id);
256
305
  } else {
257
- expandedIds = expandedIds.filter((_id) => _id !== node.id);
306
+ expandedIdsSet.delete(node.id);
258
307
  }
308
+ expandedIds = Array.from(expandedIdsSet);
309
+ lastExpandedIdsRef = expandedIds;
259
310
  };
260
311
 
261
- /**
262
- * @type {(node: TreeNode) => void}
263
- */
312
+ /** @type {(node: TreeNode) => void} */
264
313
  const focusNode = (node) => dispatch("focus", node);
265
314
 
266
- /**
267
- * @type {(node: TreeNode) => void}
268
- */
315
+ /** @type {(node: TreeNode) => void} */
269
316
  const toggleNode = (node) => dispatch("toggle", node);
270
317
 
271
318
  setContext("TreeView", {
@@ -302,37 +349,33 @@
302
349
  if (firstFocusableNode != null) {
303
350
  firstFocusableNode.tabIndex = "0";
304
351
  }
352
+
353
+ if (ref && !treeWalker) {
354
+ treeWalker = createTreeWalkerInstance(ref);
355
+ }
305
356
  });
306
357
 
307
- /**
308
- * Recursively flattens a tree of nodes into a single array
309
- * @param {ReadonlyArray<Node & { nodes?: Node[] }>} nodes
310
- * @returns {Array<Node>}
311
- */
312
- function traverse(nodes) {
313
- return nodes.reduce((acc, node) => {
314
- acc.push(node);
315
- if (Array.isArray(node.nodes) && node.nodes.length > 0) {
316
- acc.push(...traverse(node.nodes));
317
- }
318
- return acc;
319
- }, []);
358
+ $: if (nodes !== cachedNodes) {
359
+ cachedNodes = nodes;
360
+ cachedFlattenedNodes = traverse(nodes);
361
+ cachedNodeIds = cachedFlattenedNodes.map((node) => node.id);
320
362
  }
321
363
 
322
- $: flattenedNodes = traverse(nodes);
323
- $: nodeIds = flattenedNodes.map((node) => node.id);
324
- $: activeNodeId.set(activeId);
325
- $: selectedNodeIds.set(selectedIds);
326
- $: expandedNodeIds.set(expandedIds);
327
- $: if (ref) {
328
- treeWalker = document.createTreeWalker(ref, NodeFilter.SHOW_ELEMENT, {
329
- acceptNode: (node) => {
330
- if (node.classList.contains("bx--tree-node--disabled"))
331
- return NodeFilter.FILTER_REJECT;
332
- if (node.matches("li.bx--tree-node")) return NodeFilter.FILTER_ACCEPT;
333
- return NodeFilter.FILTER_SKIP;
334
- },
335
- });
364
+ $: flattenedNodes = cachedFlattenedNodes ?? [];
365
+ $: nodeIds = cachedNodeIds ?? [];
366
+
367
+ $: {
368
+ if (expandedIds !== lastExpandedIdsRef) {
369
+ expandedIdsSet = new Set(expandedIds);
370
+ lastExpandedIdsRef = expandedIds;
371
+ }
372
+ activeNodeId.set(activeId);
373
+ selectedNodeIds.set(selectedIds);
374
+ expandedNodeIds.set(expandedIds);
375
+ }
376
+
377
+ $: if (ref && (nodes !== cachedNodes || !treeWalker)) {
378
+ treeWalker = createTreeWalkerInstance(ref);
336
379
  }
337
380
  </script>
338
381
 
@@ -216,5 +216,5 @@ export default class ComboBox<
216
216
  * <button on:click={() => comboBox.clear({ focus: false })}>Clear (No Focus)</button>
217
217
  * ```
218
218
  */
219
- clear: (options?: { focus?: boolean }) => void;
219
+ clear: (options?: { focus?: boolean }) => Promise<void>;
220
220
  }
@@ -5,7 +5,7 @@ export type ContentSwitcherContext = {
5
5
  currentId: import("svelte/store").Writable<string | null>;
6
6
  add: (data: { id: string; text: string; selected: boolean }) => void;
7
7
  update: (id: string) => void;
8
- change: (direction: number) => void;
8
+ change: (direction: number) => Promise<void>;
9
9
  };
10
10
 
11
11
  type $RestProps = SvelteHTMLElements["div"];
@@ -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
  ? {
@@ -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;