export function sortData>( data: T[], key: K, direction: "asc" | "desc", locale?: string, ): T[] { const get = (obj: T, k: K): unknown => (obj as Record)[k]; const collator = new Intl.Collator(locale, { numeric: true, sensitivity: "base", }); return [...data].sort((a, b) => { const aVal = get(a, key); const bVal = get(b, key); // Handle nulls if (aVal == null && bVal == null) return 0; if (aVal == null) return 1; if (bVal == null) return -1; // Type-specific comparison // Numbers if (typeof aVal === "number" && typeof bVal === "number") { return direction === "asc" ? aVal - bVal : bVal - aVal; } // Dates (Date instances) if (aVal instanceof Date && bVal instanceof Date) { const diff = aVal.getTime() - bVal.getTime(); return direction === "asc" ? diff : -diff; } // Booleans: false < true if (typeof aVal === "boolean" && typeof bVal === "boolean") { const diff = aVal === bVal ? 0 : aVal ? 1 : -1; return direction === "asc" ? diff : -diff; } // Arrays: compare length if (Array.isArray(aVal) && Array.isArray(bVal)) { const diff = aVal.length - bVal.length; return direction === "asc" ? diff : -diff; } // Strings that look like numbers -> numeric compare if (typeof aVal === "string" && typeof bVal === "string") { const numA = parseNumericLike(aVal); const numB = parseNumericLike(bVal); if (numA != null && numB != null) { const diff = numA - numB; return direction === "asc" ? diff : -diff; } // ISO-like date strings if (/^\d{4}-\d{2}-\d{2}/.test(aVal) && /^\d{4}-\d{2}-\d{2}/.test(bVal)) { const da = new Date(aVal).getTime(); const db = new Date(bVal).getTime(); const diff = da - db; return direction === "asc" ? diff : -diff; } } // Fallback: locale-aware string compare with numeric collation const aStr = String(aVal); const bStr = String(bVal); const comparison = collator.compare(aStr, bStr); return direction === "asc" ? comparison : -comparison; }); } /** * Return a human-friendly identifier for a row using common keys * * Accepts any JSON-serializable primitive or array of primitives. * Arrays are converted to comma-separated strings. */ function getRowIdentifier( row: Record< string, string | number | boolean | null | (string | number | boolean | null)[] >, identifierKey?: string, ): string { const candidate = (identifierKey ? row[identifierKey] : undefined) ?? (row as Record).name ?? (row as Record).title ?? (row as Record).id; if (candidate == null) { return ""; } // Handle arrays by joining them if (Array.isArray(candidate)) { return candidate.map((v) => (v === null ? "null" : String(v))).join(", "); } return String(candidate).trim(); } function stableStringify(value: unknown): string { if (value == null) return "null"; if (typeof value === "string") return JSON.stringify(value); if ( typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" ) { return String(value); } if (Array.isArray(value)) { return `[${value.map((item) => stableStringify(item)).join(",")}]`; } if (typeof value === "object") { const entries = Object.entries(value as Record).sort( ([a], [b]) => a.localeCompare(b), ); return `{${entries .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`) .join(",")}}`; } return JSON.stringify(String(value)); } function hashString(value: string): string { let hash = 5381; for (let i = 0; i < value.length; i++) { hash = (hash * 33) ^ value.charCodeAt(i); } return (hash >>> 0).toString(36); } /** * Create deterministic, reorder-stable React keys for DataTable rows. * * - Uses `identifierKey` or common identifier fields as the primary base. * - Falls back to stable content fingerprints when no identifier exists. * - Disambiguates duplicates without relying on array index. */ export function createDataTableRowKeys( rows: Array>, identifierKey?: string, ): string[] { const canonicalRows = rows.map((row) => stableStringify(row)); const baseKeys = rows.map((row, index) => { const identifier = getRowIdentifier( row as Record< string, string | number | boolean | null | (string | number | boolean | null)[] >, identifierKey, ); if (identifier) { return `id:${identifier}`; } return `row:${hashString(canonicalRows[index])}`; }); const baseCounts = new Map(); baseKeys.forEach((key) => { baseCounts.set(key, (baseCounts.get(key) ?? 0) + 1); }); const usedKeys = new Map(); return rows.map((row, index) => { const baseKey = baseKeys[index]; if ((baseCounts.get(baseKey) ?? 0) === 1) { return baseKey; } const rowFingerprint = hashString(canonicalRows[index]); let disambiguatedKey = `${baseKey}::${rowFingerprint}`; const seenCount = usedKeys.get(disambiguatedKey) ?? 0; usedKeys.set(disambiguatedKey, seenCount + 1); if (seenCount > 0) { disambiguatedKey = `${disambiguatedKey}::d${seenCount + 1}`; } return disambiguatedKey; }); } function sanitizeDomIdToken(value: string): string { return encodeURIComponent(value).replace(/%/g, "_"); } export function getDataTableMobileDescriptionId(surfaceId: string): string { return `${sanitizeDomIdToken(surfaceId)}-mobile-table-description`; } /** * Parse a string that represents a numeric value, handling various formats: * - Currency symbols: $, €, £, ¥, etc. * - Percent symbols: % * - Accounting negatives: (1234) → -1234 * - Thousands/decimal separators: 1,234.56 or 1.234,56 * - Compact notation: 2.8T (trillion), 1.5M (million), 500K (thousand) * - Byte suffixes: 768B (bytes), 1.5KB, 2GB, 1TB * * Note: Single "B" is disambiguated - integers < 1024 are bytes, otherwise billions. * * @param input - String to parse * @returns Parsed number or null if unparseable * * @example * parseNumericLike("$1,234.56") // 1234.56 * parseNumericLike("2.8T") // 2800000000000 * parseNumericLike("768B") // 768 * parseNumericLike("50%") // 50 * parseNumericLike("(1234)") // -1234 */ function parseNumericLike(input: string): number | null { // Normalize whitespace (spaces, NBSPs, thin spaces) let s = input.replace(/[\u00A0\u202F\s]/g, "").trim(); if (!s) return null; // Accounting negatives: (1234) -> -1234 s = s.replace(/^\((.*)\)$/g, "-$1"); // Strip common currency and percent symbols s = s.replace(/[%$€£¥₩₹₽₺₪₫฿₦₴₡₲₵₸]/g, ""); function hasGroupedThousands(value: string, sep: "," | "."): boolean { const unsigned = value.replace(/^[+-]/, ""); const parts = unsigned.split(sep); if (parts.length < 2) return false; if (parts.some((part) => part.length === 0)) return false; if (!/^\d{1,3}$/.test(parts[0])) return false; if (parts[0] === "0") return false; return parts.slice(1).every((part) => /^\d{3}$/.test(part)); } const lastComma = s.lastIndexOf(","); const lastDot = s.lastIndexOf("."); if (lastComma !== -1 && lastDot !== -1) { // Decide decimal by whichever occurs last const decimalSep = lastComma > lastDot ? "," : "."; const thousandSep = decimalSep === "," ? "." : ","; s = s.split(thousandSep).join(""); s = s.replace(decimalSep, "."); } else if (lastComma !== -1) { // Only comma present if (hasGroupedThousands(s, ",")) { s = s.replace(/,/g, ""); } else { const frac = s.length - lastComma - 1; if (frac >= 1 && frac <= 3) s = s.replace(/,/g, "."); else s = s.replace(/,/g, ""); } } else if (lastDot !== -1) { // Only dot present; normalize grouped thousands separators. if (hasGroupedThousands(s, ".")) { s = s.replace(/\./g, ""); } else if ((s.match(/\./g) || []).length > 1) { s = s.replace(/\./g, ""); } } // Handle compact notation (K, M, B, T, P, G) and byte suffixes (KB, MB, GB, TB, PB) const compactMatch = s.match(/^([+-]?\d+\.?\d*|\d*\.\d+)([KMBTPG]B?|B)$/i); if (compactMatch) { const baseNum = Number(compactMatch[1]); if (Number.isNaN(baseNum)) return null; const suffix = compactMatch[2].toUpperCase(); // Disambiguate single "B" (bytes vs billions) // If whole number < 1024, treat as bytes. Otherwise, billions. if (suffix === "B") { const isLikelyBytes = Number.isInteger(baseNum) && baseNum < 1024; return isLikelyBytes ? baseNum : baseNum * 1e9; } const multipliers: Record = { K: 1e3, KB: 1024, // Kilo: metric vs binary M: 1e6, MB: 1024 ** 2, // Mega G: 1e9, GB: 1024 ** 3, // Giga T: 1e12, TB: 1024 ** 4, // Tera P: 1e15, PB: 1024 ** 5, // Peta }; return baseNum * (multipliers[suffix] ?? 1); } if (/^[+-]?(?:\d+\.?\d*|\d*\.\d+)$/.test(s)) { const n = Number(s); return Number.isNaN(n) ? null : n; } return null; }