carbon-components-svelte
Version:
Svelte implementation of the Carbon Design System
277 lines (241 loc) • 9.12 kB
JavaScript
// @ts-check
/**
* Deep equality check for values (nested objects and arrays).
* @param {*} a - First value to compare
* @param {*} b - Second value to compare
* @param {WeakMap<*, Set<*>>} [stack] - WeakMap used to track circular references
* @returns {boolean} True if values are deeply equal, false otherwise
*/
function deepEqual(a, b, stack = new WeakMap()) {
// Fast path: reference equality.
if (a === b) return true;
// Handle null/undefined.
if (a == null || b == null) return a === b;
// Handle NaN: NaN is the only value where NaN !== NaN is true in JavaScript
// Without this check, two NaN values would incorrectly be considered unequal.
if (Number.isNaN(a) && Number.isNaN(b)) return true;
if (Number.isNaN(a) || Number.isNaN(b)) return false;
if (typeof a !== typeof b) return false;
if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime();
}
if (a instanceof RegExp && b instanceof RegExp) {
return a.source === b.source && a.flags === b.flags;
}
if (typeof a === "function" && typeof b === "function") {
return a === b;
}
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
// Pass the stack to handle nested arrays and prevent infinite recursion.
if (!deepEqual(a[i], b[i], stack)) return false;
}
return true;
}
if (typeof a === "object" && typeof b === "object") {
const aVisited = stack.get(a);
if (aVisited?.has(b)) {
// Circular reference: if we've already seen this (a, b) pair, they're equal.
return true;
}
// WeakMap entries are auto-removed when keys are garbage collected.
if (aVisited) {
aVisited.add(b);
} else {
stack.set(a, new Set([b]));
}
// Compare string keys: objects must have the same enumerable properties.
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) {
stack.get(a)?.delete(b);
return false;
}
for (const key of keysA) {
// Pass the stack to handle nested objects and prevent infinite recursion.
if (!(key in b) || !deepEqual(a[key], b[key], stack)) {
stack.get(a)?.delete(b);
return false;
}
}
const symKeysA = Object.getOwnPropertySymbols(a);
const symKeysB = Object.getOwnPropertySymbols(b);
if (symKeysA.length !== symKeysB.length) {
stack.get(a)?.delete(b);
return false;
}
// Recursively compare Symbol property values.
for (const key of symKeysA) {
if (!symKeysB.includes(key) || !deepEqual(a[key], b[key], stack)) {
stack.get(a)?.delete(b);
return false;
}
}
// All checks passed: remove (a, b) from tracking before returning
// Cleanup is for correctness (not GC): allows same objects to be
// compared again without false circular reference detection.
stack.get(a)?.delete(b);
return true;
}
// Finally, use strict equality for primitives.
return a === b;
}
/**
* Lightweight deep equality check optimized for DataTable rows.
* Compares arrays of row objects by first checking IDs (fast path),
* then falling back to deep object comparison to handle nested structures.
* @template T
* @param {ReadonlyArray<T> | null} a - First array of rows to compare
* @param {ReadonlyArray<T> | null} b - Second array of rows to compare
* @returns {boolean} True if row arrays are deeply equal, false otherwise
*/
export function rowsEqual(a, b) {
if (a === b) return true;
if (a === null || b === null) return false;
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
// Fast path: compare by row IDs first, assuming rows have stable IDs.
for (let i = 0; i < a.length; i++) {
if (a[i]?.id !== b[i]?.id) return false;
}
// If IDs match, do deep comparison of row objects
// This catches cases where row data changed but ID stayed the same,
// including changes in nested objects (e.g., "contact.company")
for (let i = 0; i < a.length; i++) {
const rowA = a[i];
const rowB = b[i];
// Fast path: same reference
if (rowA === rowB) continue;
// Deep comparison to handle nested objects and arrays
if (!deepEqual(rowA, rowB)) return false;
}
return true;
}
const RE_IGNORE_ROW_CLICK = /^bx--(overflow-menu|checkbox|radio-button)/;
/**
* Returns true if the element's class list indicates the click target
* is an overflow menu, checkbox, or radio button (row click should be ignored).
* @param {EventTarget | null} target - The event target (e.g., from a click event)
* @returns {boolean}
*/
export function shouldIgnoreRowClick(target) {
if (!target || !("classList" in target)) return false;
const el = /** @type {HTMLElement} */ (target);
return [...el.classList].some((name) => RE_IGNORE_ROW_CLICK.test(name));
}
const PATH_SPLIT_REGEX = /[.[\]'"]/;
const MAX_PATH_CACHE_SIZE = 1000;
/** @type {Map<string, string[]>} */
const pathCache = new Map();
/**
* Resolves a nested property path in an object.
* Supports both direct property access and nested paths like "contact.company".
* @template {Record<string, unknown>} T
* @param {T} object - The object to resolve the path from
* @param {string} path - The property path (e.g., "name" or "contact.company")
* @returns {unknown} The resolved value, or undefined if the path doesn't exist
*/
export function resolvePath(object, path) {
if (path in object) return object[path];
let segments = pathCache.get(path);
if (!segments) {
segments = path.split(PATH_SPLIT_REGEX).filter((p) => p);
if (segments.length > 1) {
if (pathCache.size >= MAX_PATH_CACHE_SIZE) {
const firstKey = pathCache.keys().next().value;
if (firstKey !== undefined) {
pathCache.delete(firstKey);
}
}
pathCache.set(path, segments);
}
}
return (segments ?? []).reduce(
/**
* @param {unknown} acc
* @param {string} p
* @returns {unknown}
*/
(acc, p) =>
acc && typeof acc === "object"
? /** @type {Record<string, unknown>} */ (acc)[p]
: acc,
object,
);
}
/**
* Paginates an array of rows based on page number and page size.
* @template {Record<string, unknown>} Row
* @param {ReadonlyArray<Row>} rows - The rows to paginate
* @param {number} page - The current page number (1-indexed)
* @param {number} pageSize - The number of items per page
* @returns {ReadonlyArray<Row>} The paginated rows, or all rows if pagination is disabled
*/
export function getDisplayedRows(rows, page, pageSize) {
if (page && pageSize) {
return rows.slice((page - 1) * pageSize, page * pageSize);
}
return rows;
}
/**
* Formats header width styles for table headers.
* Combines width and minWidth into a CSS style string.
* @template {object} Header
* @param {Header & { width?: string | null | number; minWidth?: string | null | number; [key: string]: unknown }} header - The header object
* @returns {string | undefined} The formatted style string, or undefined if no width styles
*/
export function formatHeaderWidth(header) {
const styles = [
header.width && `width: ${header.width}`,
header.minWidth && `min-width: ${header.minWidth}`,
].filter(Boolean);
if (styles.length === 0) return undefined;
return styles.join(";");
}
/**
* Compares two values for sorting in a data table.
* Handles numbers, strings, null/undefined values, and custom sort functions.
* @template T
* @param {T} itemA - First value to compare
* @param {T} itemB - Second value to compare
* @param {boolean} ascending - Whether to sort in ascending order
* @param {((a: T, b: T) => number) | false | undefined} customSort - Optional custom sort function
* @returns {number} Negative if a < b (ascending) or a > b (descending), positive if a > b (ascending) or a < b (descending), 0 if equal
*/
export function compareValues(itemA, itemB, ascending, customSort) {
if (customSort) {
const result = customSort(itemA, itemB);
return ascending ? result : -result;
}
let result;
// Fast path: numeric comparison
if (typeof itemA === "number" && typeof itemB === "number") {
result = itemA - itemB;
} else {
// Handle null/undefined values
if ([itemA, itemB].every((item) => !item && item !== 0)) {
result = 0;
} else if (!itemA && itemA !== 0) {
result = 1;
} else if (!itemB && itemB !== 0) {
result = -1;
} else {
result = String(itemA).localeCompare(
String(itemB),
// Use undefined to default to user's locale
undefined,
{
// Enable numeric sorting for strings that look like numbers
// E.g., "10" should come after "2"
numeric: true,
// Comparison is case- and accent-insensitive
// E.g., "apple" == "Apple", "café" == "cafe"
sensitivity: "base",
},
);
}
}
// Reverse result for descending order
return ascending ? result : -result;
}