UNPKG

carbon-components-svelte

Version:
277 lines (241 loc) 9.12 kB
// @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; }