@toolbox-web/grid 3.9.1 → 3.9.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 (77) hide show
  1. package/all.js +1 -1
  2. package/all.js.map +1 -1
  3. package/custom-elements.json +2 -2
  4. package/index.js +1 -1
  5. package/index.js.map +1 -1
  6. package/lib/core/grid.d.ts +8 -4
  7. package/lib/core/plugin/base-plugin.d.ts +7 -0
  8. package/lib/core/types.d.ts +2 -0
  9. package/lib/features/registry.js +1 -1
  10. package/lib/plugins/context-menu/context-menu-plugin.d.ts +9 -4
  11. package/lib/plugins/context-menu/index.js +1 -1
  12. package/lib/plugins/context-menu/index.js.map +1 -1
  13. package/lib/plugins/context-menu/types.d.ts +8 -4
  14. package/lib/plugins/editing/index.js +1 -1
  15. package/lib/plugins/editing/index.js.map +1 -1
  16. package/lib/plugins/editing/internal/dirty-tracking-manager.d.ts +2 -1
  17. package/lib/plugins/filtering/filter-model.d.ts +2 -1
  18. package/lib/plugins/filtering/filtering-plugin.d.ts +5 -2
  19. package/lib/plugins/filtering/index.js +1 -1
  20. package/lib/plugins/filtering/index.js.map +1 -1
  21. package/lib/plugins/grouping-rows/grouping-rows-plugin.d.ts +7 -14
  22. package/lib/plugins/grouping-rows/grouping-rows.d.ts +48 -0
  23. package/lib/plugins/grouping-rows/index.d.ts +1 -1
  24. package/lib/plugins/grouping-rows/index.js +1 -1
  25. package/lib/plugins/grouping-rows/index.js.map +1 -1
  26. package/lib/plugins/grouping-rows/types.d.ts +21 -0
  27. package/lib/plugins/master-detail/index.d.ts +1 -1
  28. package/lib/plugins/master-detail/index.js +1 -1
  29. package/lib/plugins/master-detail/index.js.map +1 -1
  30. package/lib/plugins/master-detail/master-detail-plugin.d.ts +28 -6
  31. package/lib/plugins/master-detail/master-detail.d.ts +0 -8
  32. package/lib/plugins/master-detail/types.d.ts +25 -1
  33. package/lib/plugins/multi-sort/index.js +1 -1
  34. package/lib/plugins/multi-sort/index.js.map +1 -1
  35. package/lib/plugins/selection/index.js +1 -1
  36. package/lib/plugins/selection/index.js.map +1 -1
  37. package/lib/plugins/selection/selection-plugin.d.ts +8 -1
  38. package/lib/plugins/server-side/datasource-types.d.ts +28 -0
  39. package/lib/plugins/server-side/datasource.d.ts +7 -0
  40. package/lib/plugins/server-side/index.js +1 -1
  41. package/lib/plugins/server-side/index.js.map +1 -1
  42. package/lib/plugins/server-side/server-side-plugin.d.ts +61 -8
  43. package/lib/plugins/server-side/types.d.ts +9 -1
  44. package/lib/plugins/tree/index.js +1 -1
  45. package/lib/plugins/tree/index.js.map +1 -1
  46. package/lib/plugins/tree/tree-plugin.d.ts +5 -11
  47. package/lib/plugins/tree/types.d.ts +22 -6
  48. package/lib/plugins/undo-redo/history.d.ts +2 -1
  49. package/lib/plugins/undo-redo/index.js +1 -1
  50. package/lib/plugins/undo-redo/index.js.map +1 -1
  51. package/lib/plugins/undo-redo/types.d.ts +13 -1
  52. package/lib/plugins/undo-redo/undo-redo-plugin.d.ts +15 -2
  53. package/package.json +1 -1
  54. package/umd/grid.all.umd.js +1 -1
  55. package/umd/grid.all.umd.js.map +1 -1
  56. package/umd/grid.umd.js +1 -1
  57. package/umd/grid.umd.js.map +1 -1
  58. package/umd/plugins/context-menu.umd.js +1 -1
  59. package/umd/plugins/context-menu.umd.js.map +1 -1
  60. package/umd/plugins/editing.umd.js +1 -1
  61. package/umd/plugins/editing.umd.js.map +1 -1
  62. package/umd/plugins/filtering.umd.js +1 -1
  63. package/umd/plugins/filtering.umd.js.map +1 -1
  64. package/umd/plugins/grouping-rows.umd.js +1 -1
  65. package/umd/plugins/grouping-rows.umd.js.map +1 -1
  66. package/umd/plugins/master-detail.umd.js +1 -1
  67. package/umd/plugins/master-detail.umd.js.map +1 -1
  68. package/umd/plugins/multi-sort.umd.js +1 -1
  69. package/umd/plugins/multi-sort.umd.js.map +1 -1
  70. package/umd/plugins/selection.umd.js +1 -1
  71. package/umd/plugins/selection.umd.js.map +1 -1
  72. package/umd/plugins/server-side.umd.js +1 -1
  73. package/umd/plugins/server-side.umd.js.map +1 -1
  74. package/umd/plugins/tree.umd.js +1 -1
  75. package/umd/plugins/tree.umd.js.map +1 -1
  76. package/umd/plugins/undo-redo.umd.js +1 -1
  77. package/umd/plugins/undo-redo.umd.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"filtering.umd.js","names":[],"sources":["../../../../../libs/grid/src/lib/plugins/filtering/filter-model.ts","../../../../../libs/grid/src/lib/core/internal/aria.ts","../../../../../libs/grid/src/lib/core/plugin/expander-column.ts","../../../../../libs/grid/src/lib/plugins/filtering/filter-panel-date.ts","../../../../../libs/grid/src/lib/core/internal/virtualization.ts","../../../../../libs/grid/src/lib/plugins/filtering/filter-panel-default.ts","../../../../../libs/grid/src/lib/plugins/filtering/filter-panel-number.ts","../../../../../libs/grid/src/lib/plugins/filtering/filtering.css?inline","../../../../../libs/grid/src/lib/plugins/filtering/FilteringPlugin.css?inline","../../../../../libs/grid/src/lib/plugins/filtering/filtering-plugin.ts"],"sourcesContent":["/**\n * Filter Model Core Logic\n *\n * Pure functions for filtering operations.\n */\n\nimport { createFieldReader, isPlainField } from '../../core/internal/value-accessor';\nimport type { FilterModel } from './types';\n\n/**\n * Sentinel value used in set-filter unique values to represent rows with\n * no value (null, undefined, empty array via filterValue extractor).\n * Exported so server-side implementations can use the same constant.\n * @since 1.18.0\n */\nexport const BLANK_FILTER_VALUE = '(Blank)';\n\n/** A row object as seen by the filter engine. */\ntype RowRecord = Record<string, unknown>;\n\n/** A compiled filter predicate. */\ntype RowPredicate = (row: RowRecord) => boolean;\n\n/** Reads one column's raw value from a row (nested dotted paths included). */\ntype FieldReader = (row: RowRecord) => unknown;\n\n/** Resolves the comparable cell value for a row (direct or via extractor). */\ntype ValueGetter = (row: RowRecord) => unknown;\n\n/** Optional extractor for complex cell values (arrays, objects, computed columns). */\ntype FilterValueExtractor = (value: unknown, row: RowRecord) => unknown | unknown[];\n\n/**\n * Reject blanks before coercion. A value is blank when it is null, undefined,\n * '' or NaN (NaN is strictly an error state but is treated as \"no value\" here).\n * Kept intentionally loose (doesn't force a number type) so numeric strings,\n * Date objects and ISO date strings keep flowing through the coercion in\n * `>`/`<`/`toNumeric`.\n */\nfunction isBlank(value: unknown): boolean {\n return value == null || value === '' || (typeof value === 'number' && isNaN(value));\n}\n\n/**\n * Convert a value to a comparable number.\n * Handles Date objects, numeric values, and date/ISO strings.\n */\nfunction toNumeric(value: unknown): number {\n if (typeof value === 'number') return value;\n if (value instanceof Date) return value.getTime();\n const n = Number(value);\n if (!isNaN(n)) return n;\n // Try parsing as a date string (ISO 8601, etc.)\n const d = new Date(value as string);\n return d.getTime(); // NaN if unparseable\n}\n\n/**\n * Check if a single row matches a filter condition.\n *\n * Delegates to `compileFilter` to avoid duplicating operator logic. The compiled\n * predicate is created per call, so prefer `filterRows` (which pre-compiles once)\n * for bulk filtering.\n *\n * @param row - The row data object\n * @param filter - The filter to apply\n * @param caseSensitive - Whether text comparisons are case sensitive\n * @param filterValue - Optional extractor for complex cell values (arrays, objects)\n * @returns True if the row matches the filter\n */\nexport function matchesFilter(\n row: Record<string, unknown>,\n filter: FilterModel,\n caseSensitive = false,\n filterValue?: (value: unknown, row: Record<string, unknown>) => unknown | unknown[],\n): boolean {\n return compileFilter(filter, caseSensitive, filterValue)(row);\n}\n\n/**\n * Compile a single filter into a specialized predicate with pre-resolved values.\n * Avoids repeated type coercion and string conversion inside the hot loop.\n *\n * Delegates to one operator-family compiler at a time; the first that claims the\n * operator wins. Unknown operators pass every row through.\n */\nfunction compileFilter(filter: FilterModel, caseSensitive: boolean, filterValue?: FilterValueExtractor): RowPredicate {\n const field = filter.field;\n\n // Precompiled once per filter: resolves the plain / unsafe / dotted-path\n // decision up front so the per-row loop is a single monomorphic call.\n // Using it (rather than a raw `row[field]`) is what makes nested dotted\n // fields (`address.city`) filterable at all — see issue #438.\n const read = createFieldReader(field);\n\n // When a filterValue extractor is provided, use it instead of the direct\n // field read. This supports virtual/computed columns whose field doesn't\n // exist on the row data object.\n const getValue: ValueGetter = filterValue ? (row) => filterValue(read(row), row) : read;\n\n return (\n compileBlankPredicate(filter, getValue) ??\n compileSetPredicate(filter, read, filterValue) ??\n compileNumericPredicate(filter, field, getValue, filterValue === undefined) ??\n compileTextPredicate(filter, caseSensitive, getValue) ??\n // Unknown operator — pass row through\n (() => true)\n );\n}\n\n/**\n * `blank` / `notBlank` — no filter value needed.\n *\n * The blank test is inlined rather than delegating to {@link isBlank}: these are\n * the only predicates where the check IS the whole body, so the extra call would\n * be pure overhead in the per-row hot loop.\n */\nfunction compileBlankPredicate(filter: FilterModel, getValue: ValueGetter): RowPredicate | undefined {\n if (filter.operator === 'blank')\n return (row) => {\n const v = getValue(row);\n return v == null || v === '' || (typeof v === 'number' && isNaN(v));\n };\n if (filter.operator === 'notBlank')\n return (row) => {\n const v = getValue(row);\n return v != null && v !== '' && !(typeof v === 'number' && isNaN(v));\n };\n return undefined;\n}\n\n/**\n * Set operators (`in` / `notIn`) — pre-convert the filter value to a Set for\n * O(1) lookups. With a `filterValue` extractor the extracted value may be an\n * array (multi-value cells); a row that yields no values matches the\n * {@link BLANK_FILTER_VALUE} sentinel.\n */\nfunction compileSetPredicate(\n filter: FilterModel,\n read: FieldReader,\n filterValue?: FilterValueExtractor,\n): RowPredicate | undefined {\n const op = filter.operator;\n if (op !== 'in' && op !== 'notIn') return undefined;\n\n const negate = op === 'notIn';\n if (!Array.isArray(filter.value)) return negate ? () => true : () => false;\n const lookup = new Set(filter.value);\n\n if (filterValue) {\n return (row) => {\n const extracted = filterValue(read(row), row);\n const values = Array.isArray(extracted) ? extracted : extracted != null ? [extracted] : [];\n const matched = values.length === 0 ? lookup.has(BLANK_FILTER_VALUE) : values.some((v) => lookup.has(v));\n return negate ? !matched : matched;\n };\n }\n return (row) => {\n const v = read(row);\n const matched = v == null || v === '' ? lookup.has(BLANK_FILTER_VALUE) : lookup.has(v);\n return negate ? !matched : matched;\n };\n}\n\n/**\n * Numeric / date operators — pre-resolve the threshold(s) once.\n *\n * When the filter type is `'number'` and there's no custom extractor, row values\n * are expected to already be JS numbers — emit a tighter predicate that skips\n * `toNumeric()`. When a `filterValue` extractor is present, always use the\n * cautious path since the extracted value may need conversion.\n *\n * Numeric comparisons must always exclude blank values, otherwise JS coercion\n * leaks them through (e.g. `null >= 0` is `true`, `Number('') === 0`). Blank rows\n * are only matched by the explicit `blank` operator.\n */\nfunction compileNumericPredicate(\n filter: FilterModel,\n field: string,\n getValue: ValueGetter,\n noExtractor: boolean,\n): RowPredicate | undefined {\n const op = filter.operator;\n const isNumType = filter.type === 'number' && noExtractor;\n // Hot path: a declared-numeric column with no extractor and a plain top-level\n // key reads the cell **inline**. These predicate bodies are two operations\n // long, so routing the read through the reader closure measurably dominates\n // them (~8% on `filter-model.bench`). Dotted paths keep the closure — they\n // have to walk the path anyway. `plainField` non-null implies `isNumType`.\n const plainField = isNumType && isPlainField(field) ? field : null;\n\n if (op === 'greaterThan') {\n const threshold = toNumeric(filter.value);\n if (plainField)\n return (row) => {\n const v = row[plainField];\n return !isBlank(v) && (v as number) > threshold;\n };\n return isNumType\n ? (row) => {\n const v = getValue(row);\n return !isBlank(v) && (v as number) > threshold;\n }\n : (row) => {\n const v = getValue(row);\n if (isBlank(v)) return false;\n const n = toNumeric(v);\n return !isNaN(n) && n > threshold;\n };\n }\n if (op === 'greaterThanOrEqual') {\n const threshold = toNumeric(filter.value);\n if (plainField)\n return (row) => {\n const v = row[plainField];\n return !isBlank(v) && (v as number) >= threshold;\n };\n return isNumType\n ? (row) => {\n const v = getValue(row);\n return !isBlank(v) && (v as number) >= threshold;\n }\n : (row) => {\n const v = getValue(row);\n if (isBlank(v)) return false;\n const n = toNumeric(v);\n return !isNaN(n) && n >= threshold;\n };\n }\n if (op === 'lessThan') {\n const threshold = toNumeric(filter.value);\n if (plainField)\n return (row) => {\n const v = row[plainField];\n return !isBlank(v) && (v as number) < threshold;\n };\n return isNumType\n ? (row) => {\n const v = getValue(row);\n return !isBlank(v) && (v as number) < threshold;\n }\n : (row) => {\n const v = getValue(row);\n if (isBlank(v)) return false;\n const n = toNumeric(v);\n return !isNaN(n) && n < threshold;\n };\n }\n if (op === 'lessThanOrEqual') {\n const threshold = toNumeric(filter.value);\n if (plainField)\n return (row) => {\n const v = row[plainField];\n return !isBlank(v) && (v as number) <= threshold;\n };\n return isNumType\n ? (row) => {\n const v = getValue(row);\n return !isBlank(v) && (v as number) <= threshold;\n }\n : (row) => {\n const v = getValue(row);\n if (isBlank(v)) return false;\n const n = toNumeric(v);\n return !isNaN(n) && n <= threshold;\n };\n }\n if (op === 'between') {\n const lo = toNumeric(filter.value);\n const hi = toNumeric(filter.valueTo);\n if (plainField)\n return (row) => {\n const v = row[plainField];\n if (isBlank(v)) return false;\n const n = v as number;\n return n >= lo && n <= hi;\n };\n return isNumType\n ? (row) => {\n const v = getValue(row);\n if (isBlank(v)) return false;\n const n = v as number;\n return n >= lo && n <= hi;\n }\n : (row) => {\n const v = getValue(row);\n if (isBlank(v)) return false;\n const n = toNumeric(v);\n return !isNaN(n) && n >= lo && n <= hi;\n };\n }\n return undefined;\n}\n\n/**\n * Text operators — pre-resolve the filter comparison value (and its lower-cased\n * form) once, then emit a case-sensitive or case-insensitive specialization.\n */\nfunction compileTextPredicate(\n filter: FilterModel,\n caseSensitive: boolean,\n getValue: ValueGetter,\n): RowPredicate | undefined {\n const op = filter.operator;\n const compareFilterValue = caseSensitive ? String(filter.value) : String(filter.value).toLowerCase();\n\n if (op === 'contains') {\n return caseSensitive\n ? (row) => {\n const v = getValue(row);\n return v != null && String(v).includes(compareFilterValue);\n }\n : (row) => {\n const v = getValue(row);\n return v != null && String(v).toLowerCase().includes(compareFilterValue);\n };\n }\n if (op === 'notContains') {\n return caseSensitive\n ? (row) => {\n const v = getValue(row);\n return v != null && !String(v).includes(compareFilterValue);\n }\n : (row) => {\n const v = getValue(row);\n return v != null && !String(v).toLowerCase().includes(compareFilterValue);\n };\n }\n if (op === 'equals') {\n return caseSensitive\n ? (row) => {\n const v = getValue(row);\n return v != null && String(v) === compareFilterValue;\n }\n : (row) => {\n const v = getValue(row);\n return v != null && String(v).toLowerCase() === compareFilterValue;\n };\n }\n if (op === 'notEquals') {\n return caseSensitive\n ? (row) => {\n const v = getValue(row);\n return v != null && String(v) !== compareFilterValue;\n }\n : (row) => {\n const v = getValue(row);\n return v != null && String(v).toLowerCase() !== compareFilterValue;\n };\n }\n if (op === 'startsWith') {\n return caseSensitive\n ? (row) => {\n const v = getValue(row);\n return v != null && String(v).startsWith(compareFilterValue);\n }\n : (row) => {\n const v = getValue(row);\n return v != null && String(v).toLowerCase().startsWith(compareFilterValue);\n };\n }\n if (op === 'endsWith') {\n return caseSensitive\n ? (row) => {\n const v = getValue(row);\n return v != null && String(v).endsWith(compareFilterValue);\n }\n : (row) => {\n const v = getValue(row);\n return v != null && String(v).toLowerCase().endsWith(compareFilterValue);\n };\n }\n return undefined;\n}\n\n/**\n * Filter rows based on multiple filter conditions (AND logic).\n * All filters must match for a row to be included.\n *\n * Pre-compiles each filter into a specialized predicate to avoid\n * repeated type coercion and branch checks inside the hot loop.\n *\n * @param rows - The rows to filter\n * @param filters - Array of filters to apply\n * @param caseSensitive - Whether text comparisons are case sensitive\n * @param filterValues - Optional map of field → value extractor for complex columns\n * @returns Filtered rows\n */\nexport function filterRows<T extends Record<string, unknown>>(\n rows: readonly T[],\n filters: FilterModel[],\n caseSensitive = false,\n filterValues?: Map<string, (value: unknown, row: T) => unknown | unknown[]>,\n): T[] {\n if (!filters.length) return rows as T[];\n\n // Pre-compile all filters into specialized predicates\n const predicates = filters.map((f) =>\n compileFilter(\n f,\n caseSensitive,\n filterValues?.get(f.field) as ((value: unknown, row: Record<string, unknown>) => unknown | unknown[]) | undefined,\n ),\n );\n\n // Single-filter fast path avoids per-row loop overhead\n if (predicates.length === 1) {\n return rows.filter(predicates[0]);\n }\n\n return rows.filter((row) => {\n for (let i = 0; i < predicates.length; i++) {\n if (!predicates[i](row)) return false;\n }\n return true;\n });\n}\n\n/**\n * Compute a cache key for a set of filters.\n * Used for memoization of filter results.\n *\n * @param filters - Array of filters\n * @returns Stable string key for the filter set\n */\nexport function computeFilterCacheKey(filters: FilterModel[]): string {\n return JSON.stringify(\n filters.map((f) => ({\n field: f.field,\n operator: f.operator,\n value: f.value,\n valueTo: f.valueTo,\n })),\n );\n}\n\n/**\n * Extract unique values from a field across all rows.\n * Useful for populating \"set\" filter dropdowns.\n *\n * When `filterValue` is provided, the extractor is called for each row's cell value.\n * If it returns an array, each element is added individually (flattened).\n * This enables complex-valued cells (e.g., arrays of objects) to expose\n * their individual filterable values.\n *\n * @param rows - The rows to extract values from\n * @param field - The field name\n * @param filterValue - Optional extractor for complex cell values\n * @returns Sorted array of unique non-null values\n */\nexport function getUniqueValues<T extends Record<string, unknown>>(\n rows: T[],\n field: string,\n filterValue?: (value: unknown, row: T) => unknown | unknown[],\n): unknown[] {\n const values = new Set<unknown>();\n let hasBlank = false;\n const read = createFieldReader(field);\n for (const row of rows) {\n const cellValue = read(row);\n if (filterValue) {\n const extracted = filterValue(cellValue, row);\n if (Array.isArray(extracted)) {\n if (extracted.length === 0) {\n hasBlank = true;\n }\n for (const v of extracted) {\n if (v != null) values.add(v);\n }\n } else if (extracted != null) {\n values.add(extracted);\n } else {\n hasBlank = true;\n }\n } else {\n if (cellValue != null && cellValue !== '') {\n values.add(cellValue);\n } else {\n hasBlank = true;\n }\n }\n }\n // When some rows have no values (null, undefined, or empty string),\n // include a \"(Blank)\" sentinel so users can explicitly filter empty rows.\n if (hasBlank) {\n values.add(BLANK_FILTER_VALUE);\n }\n return [...values].sort((a, b) => {\n // Handle mixed types gracefully\n if (typeof a === 'number' && typeof b === 'number') {\n return a - b;\n }\n return String(a).localeCompare(String(b));\n });\n}\n\n/**\n * Extract unique values for multiple fields in a single pass through the rows.\n * This is more efficient than calling `getUniqueValues` N times when\n * computing derived state for several set filters at once.\n *\n * @param rows - The rows to extract values from\n * @param fields - Array of { field, filterValue? } descriptors\n * @returns Map of field → sorted unique values (same contract as `getUniqueValues`)\n */\nexport function getUniqueValuesBatch<T extends Record<string, unknown>>(\n rows: T[],\n fields: { field: string; filterValue?: (value: unknown, row: T) => unknown | unknown[] }[],\n): Map<string, unknown[]> {\n // Per-field accumulators\n const acc = new Map<string, { values: Set<unknown>; hasBlank: boolean; hasExtractor: boolean }>();\n const readers = new Map<string, FieldReader>();\n for (const { field, filterValue } of fields) {\n acc.set(field, { values: new Set(), hasBlank: false, hasExtractor: !!filterValue });\n readers.set(field, createFieldReader(field));\n }\n\n // Single pass through all rows\n for (const row of rows) {\n for (const { field, filterValue } of fields) {\n const entry = acc.get(field)!;\n const cellValue = readers.get(field)!(row);\n if (filterValue) {\n const extracted = filterValue(cellValue, row);\n if (Array.isArray(extracted)) {\n if (extracted.length === 0) entry.hasBlank = true;\n for (const v of extracted) {\n if (v != null) entry.values.add(v);\n }\n } else if (extracted != null) {\n entry.values.add(extracted);\n } else {\n entry.hasBlank = true;\n }\n } else {\n if (cellValue != null && cellValue !== '') {\n entry.values.add(cellValue);\n } else {\n entry.hasBlank = true;\n }\n }\n }\n }\n\n // Build sorted output\n const result = new Map<string, unknown[]>();\n for (const [field, { values, hasBlank }] of acc) {\n if (hasBlank) values.add(BLANK_FILTER_VALUE);\n result.set(\n field,\n [...values].sort((a, b) => {\n if (typeof a === 'number' && typeof b === 'number') return a - b;\n return String(a).localeCompare(String(b));\n }),\n );\n }\n return result;\n}\n","/**\n * ARIA Accessibility Helpers\n *\n * Pure functions for managing ARIA attributes on grid elements.\n * Implements caching to avoid redundant DOM writes during scroll.\n *\n * @module internal/aria\n */\n\nimport { DEFAULT_A11Y_MESSAGES } from '../defaults';\nimport type { A11yMessages, GridConfig } from '../types';\n\n// #region Types\n\n/**\n * State for caching ARIA attributes to avoid redundant DOM writes.\n */\nexport interface AriaState {\n /** Last set row count */\n rowCount: number;\n /** Last set column count */\n colCount: number;\n /** Last set aria-label */\n ariaLabel: string | undefined;\n /** Last set aria-labelledby */\n ariaLabelledBy: string | undefined;\n /** Last set aria-describedby */\n ariaDescribedBy: string | undefined;\n /** Last set aria-roledescription */\n ariaRoleDescription: string | undefined;\n /** Last source row count announced via `dataLoaded`; used to suppress duplicate announcements */\n lastAnnouncedSourceCount: number;\n}\n\n/**\n * Create initial ARIA state.\n */\nexport function createAriaState(): AriaState {\n return {\n rowCount: -1,\n colCount: -1,\n ariaLabel: undefined,\n ariaLabelledBy: undefined,\n ariaDescribedBy: undefined,\n ariaRoleDescription: undefined,\n lastAnnouncedSourceCount: -1,\n };\n}\n\n// #endregion\n\n// #region Count Updates\n\n/**\n * Update ARIA row and column counts on grid elements.\n * Uses caching to avoid redundant DOM writes on every scroll frame.\n *\n * @param state - ARIA state for caching\n * @param rowsBodyEl - Element to set aria-rowcount/aria-colcount on\n * @param bodyEl - Element to set role=\"rowgroup\" on\n * @param rowCount - Current row count\n * @param colCount - Current column count\n * @returns true if anything was updated\n */\nexport function updateAriaCounts(\n state: AriaState,\n rowsBodyEl: HTMLElement | null,\n bodyEl: HTMLElement | null,\n rowCount: number,\n colCount: number,\n): boolean {\n // Skip if nothing changed (hot path optimization for scroll)\n if (rowCount === state.rowCount && colCount === state.colCount) {\n return false;\n }\n\n const prevRowCount = state.rowCount;\n state.rowCount = rowCount;\n state.colCount = colCount;\n\n // Update ARIA counts on inner grid element\n if (rowsBodyEl) {\n rowsBodyEl.setAttribute('aria-rowcount', String(rowCount));\n rowsBodyEl.setAttribute('aria-colcount', String(colCount));\n }\n\n // Set role=\"rowgroup\" on .rows only when there are rows (ARIA compliance)\n if (rowCount !== prevRowCount && bodyEl) {\n if (rowCount > 0) {\n bodyEl.setAttribute('role', 'rowgroup');\n } else {\n bodyEl.removeAttribute('role');\n }\n }\n\n return true;\n}\n\n// #endregion\n\n// #region Label Updates\n\n/**\n * Determine the effective aria-label for the grid.\n * Priority: explicit config > shell title > nothing\n *\n * The shell title is read from `config.shell.header.title`. The shell plugin\n * folds any light-DOM `<tbw-grid-header title>` into that field via its\n * `processConfig` hook (extraction #370), so core needs no plugin state here.\n *\n * @param config - Grid configuration (the merged effective config)\n * @returns The aria-label to use, or undefined\n */\nexport function getEffectiveAriaLabel<T>(config: GridConfig<T> | undefined): string | undefined {\n const explicitLabel = config?.gridAriaLabel;\n if (explicitLabel) return explicitLabel;\n\n return config?.shell?.header?.title ?? undefined;\n}\n\n/**\n * Update ARIA label and describedby attributes on the grid container.\n * Uses caching to avoid redundant DOM writes.\n *\n * @param state - ARIA state for caching\n * @param rowsBodyEl - Element to set aria-label/aria-describedby on\n * @param config - Grid configuration (the merged effective config)\n * @returns true if anything was updated\n */\nexport function updateAriaLabels<T>(\n state: AriaState,\n rowsBodyEl: HTMLElement | null,\n config: GridConfig<T> | undefined,\n): boolean {\n if (!rowsBodyEl) return false;\n\n let updated = false;\n\n // aria-labelledby wins per WAI-ARIA accessible-name precedence — when set,\n // `aria-label` is suppressed even if explicitly configured to avoid two\n // competing names being computed by AT.\n const ariaLabelledBy = config?.gridAriaLabelledBy;\n const effectiveLabel = ariaLabelledBy ? undefined : getEffectiveAriaLabel(config);\n\n // Update aria-labelledby only if changed\n if (ariaLabelledBy !== state.ariaLabelledBy) {\n state.ariaLabelledBy = ariaLabelledBy;\n if (ariaLabelledBy) {\n rowsBodyEl.setAttribute('aria-labelledby', ariaLabelledBy);\n } else {\n rowsBodyEl.removeAttribute('aria-labelledby');\n }\n updated = true;\n }\n\n // Update aria-label only if changed\n if (effectiveLabel !== state.ariaLabel) {\n state.ariaLabel = effectiveLabel;\n if (effectiveLabel) {\n rowsBodyEl.setAttribute('aria-label', effectiveLabel);\n } else {\n rowsBodyEl.removeAttribute('aria-label');\n }\n updated = true;\n }\n\n // Update aria-describedby only if changed\n const ariaDescribedBy = config?.gridAriaDescribedBy;\n if (ariaDescribedBy !== state.ariaDescribedBy) {\n state.ariaDescribedBy = ariaDescribedBy;\n if (ariaDescribedBy) {\n rowsBodyEl.setAttribute('aria-describedby', ariaDescribedBy);\n } else {\n rowsBodyEl.removeAttribute('aria-describedby');\n }\n updated = true;\n }\n\n // Update aria-roledescription only if changed. Apps can override the\n // announced role name (e.g. localized \"Tabell\" instead of generic \"grid\").\n const ariaRoleDescription = config?.gridAriaRoleDescription;\n if (ariaRoleDescription !== state.ariaRoleDescription) {\n state.ariaRoleDescription = ariaRoleDescription;\n if (ariaRoleDescription) {\n rowsBodyEl.setAttribute('aria-roledescription', ariaRoleDescription);\n } else {\n rowsBodyEl.removeAttribute('aria-roledescription');\n }\n updated = true;\n }\n\n return updated;\n}\n\n// #endregion\n\n// #region Live Announcements\n\n/**\n * Announce a message to screen readers via the grid's aria-live region.\n * Clears then sets text to ensure repeated messages are re-announced.\n *\n * Respects `effectiveConfig.a11y.announcements` — if set to `false`,\n * the announcement is silently skipped.\n *\n * @param gridEl - The grid host element (or any ancestor containing `.tbw-sr-only`)\n * @param message - The message to announce\n */\nexport function announce(gridEl: HTMLElement, message: string): void {\n // Check if announcements are disabled via config.\n // gridEl is always a DataGridElement at runtime — use property access safely.\n if (!gridEl) return;\n const config =\n 'effectiveConfig' in gridEl\n ? (gridEl as HTMLElement & { effectiveConfig?: GridConfig }).effectiveConfig\n : undefined;\n if (config?.a11y?.announcements === false) return;\n\n const el = gridEl.querySelector?.('.tbw-sr-only');\n if (!el) return;\n // Clear first so identical consecutive messages are still announced\n el.textContent = '';\n // `requestAnimationFrame` can be undefined if a debounced announcement fires\n // after the DOM/host environment is torn down (notably in unit tests). Fall\n // back to a direct assignment so `announce` never throws out of band.\n if (typeof requestAnimationFrame === 'function') {\n requestAnimationFrame(() => {\n el.textContent = message;\n });\n } else {\n el.textContent = message;\n }\n}\n\n/**\n * Get an announcement message, preferring custom overrides from config.\n * Falls back to default English messages when no override is provided.\n *\n * @param gridEl - The grid host element\n * @param key - The message key from A11yMessages\n * @param args - Arguments to pass to the message function\n */\nexport function getA11yMessage<K extends keyof A11yMessages>(\n gridEl: HTMLElement,\n key: K,\n ...args: Parameters<Required<A11yMessages>[K]>\n): string {\n type MessageFn = (...a: Parameters<Required<A11yMessages>[K]>) => string;\n const config =\n gridEl && 'effectiveConfig' in gridEl\n ? (gridEl as HTMLElement & { effectiveConfig?: GridConfig }).effectiveConfig\n : undefined;\n const customFn = config?.a11y?.messages?.[key] as MessageFn | undefined;\n if (customFn) return customFn(...args);\n return (DEFAULT_A11Y_MESSAGES[key] as MessageFn)(...args);\n}\n\n/**\n * Announce a `dataLoaded` message — but only when the source row count has\n * actually changed since the last announcement. This prevents announcement\n * spam from internal `data-change` emits triggered by sort/filter/edit, which\n * have their own dedicated announcements.\n *\n * @param gridEl - The grid host element\n * @param state - ARIA state (tracks last announced count)\n * @param sourceRowCount - Current source row count\n */\nexport function announceDataLoaded(gridEl: HTMLElement, state: AriaState, sourceRowCount: number): void {\n if (sourceRowCount === state.lastAnnouncedSourceCount) return;\n const previousAnnouncedSourceCount = state.lastAnnouncedSourceCount;\n state.lastAnnouncedSourceCount = sourceRowCount;\n // Skip only the initial sentinel → 0 transition (empty grid mount). A later\n // transition to 0 (e.g. data cleared / reloaded to an empty result set) should\n // still announce so screen-reader users hear that the grid is now empty.\n if (sourceRowCount === 0 && previousAnnouncedSourceCount < 0) return;\n announce(gridEl, getA11yMessage(gridEl, 'dataLoaded', sourceRowCount));\n}\n\n// #endregion\n","/**\n * Shared Expander Column Utilities\n *\n * Provides a fixed expander column for plugins that need expand/collapse icons\n * (MasterDetail, Tree, RowGrouping). The column is:\n * - Always first in the grid\n * - Cannot be reordered (lockPosition: true)\n * - Has no header (empty string)\n * - Has no right border (borderless styling)\n * - Narrow width (just fits the icon)\n */\n\nimport type { ColumnConfig } from '../types';\n\n/** Special field name for the expander column */\nexport const EXPANDER_COLUMN_FIELD = '__tbw_expander';\n\n/** Default width for the expander column (pixels) */\nexport const EXPANDER_COLUMN_WIDTH = 32;\n\n/**\n * Marker interface for expander column renderers.\n * Used to detect if expander column is already present.\n */\nexport interface ExpanderColumnRenderer {\n (ctx: any): HTMLElement;\n __expanderColumn?: true;\n /** Plugin name that created this expander */\n __expanderPlugin?: string;\n}\n\n/**\n * Check if a column is an expander column.\n */\nexport function isExpanderColumn(column: ColumnConfig): boolean {\n return column.field === EXPANDER_COLUMN_FIELD;\n}\n\n/**\n * Check if a column is a utility column (excluded from selection, clipboard, etc.).\n * Utility columns are non-data columns like expander columns.\n */\nexport function isUtilityColumn(column: ColumnConfig): boolean {\n return column.utility === true;\n}\n\n/**\n * Find an existing expander column in the column array.\n */\nexport function findExpanderColumn(columns: readonly ColumnConfig[]): ColumnConfig | undefined {\n return columns.find(isExpanderColumn);\n}\n\n/**\n * Create the base expander column config.\n * Plugins should add their own renderer to customize the expand icon behavior.\n *\n * @param pluginName - Name of the plugin creating the expander (for debugging)\n * @returns Base column config for the expander column\n */\nexport function createExpanderColumnConfig(pluginName: string): ColumnConfig {\n return {\n field: EXPANDER_COLUMN_FIELD as any,\n header: '', // No header text - visually merges with next column\n width: EXPANDER_COLUMN_WIDTH,\n resizable: false,\n sortable: false,\n filterable: false, // No filter button for expander column\n lockPosition: true,\n utility: true, // Marks this as a utility column (excluded from selection, clipboard, etc.)\n meta: {\n expanderColumn: true,\n expanderPlugin: pluginName,\n },\n };\n}\n","/**\n * Date Filter Panel Renderer\n *\n * Renders a date range filter panel with from/to date inputs,\n * min/max constraints, a blank-value checkbox, and apply/clear buttons.\n */\n\nimport type { FilterModel, FilterPanelParams } from './types';\n\n// #region Helpers\n\n/**\n * Format a Date as YYYY-MM-DD for `<input type=\"date\">`.\n */\nfunction formatDateForInput(date: Date | null): string {\n if (!date) return '';\n return date.toISOString().split('T')[0];\n}\n\n/**\n * Parse a filter parameter value to a date string suitable for `<input type=\"date\">`.\n */\nfunction parseFilterParam(value: unknown): string {\n if (!value) return '';\n if (typeof value === 'string') return value;\n if (typeof value === 'number') return formatDateForInput(new Date(value));\n return '';\n}\n\n// #endregion\n\n// #region Panel Rendering\n\n/**\n * Render a date range filter panel with from/to inputs and a blank checkbox.\n *\n * Computes min/max date constraints from the data when not specified via\n * `filterParams` or `editorParams` on the column.\n *\n * @param panel - The panel container element\n * @param params - Filter panel parameters\n * @param uniqueValues - All unique values for this field\n * @param currentFilters - Map of field → FilterModel for current filter state\n */\nexport function renderDateFilterPanel(\n panel: HTMLElement,\n params: FilterPanelParams,\n uniqueValues: unknown[],\n currentFilters: Map<string, FilterModel>,\n): void {\n const { field, column } = params;\n\n // Get range configuration from filterParams, editorParams, or compute from data\n const filterParams = column.filterParams;\n const editorParams = column.editorParams as { min?: string; max?: string } | undefined;\n\n // Compute min/max from data if not specified\n const dateValues = uniqueValues\n .filter((v) => v instanceof Date || (typeof v === 'string' && !isNaN(Date.parse(v))))\n .map((v) => (v instanceof Date ? v : new Date(v as string)))\n .filter((d) => !isNaN(d.getTime()));\n\n const dataMin = dateValues.length > 0 ? new Date(Math.min(...dateValues.map((d) => d.getTime()))) : null;\n const dataMax = dateValues.length > 0 ? new Date(Math.max(...dateValues.map((d) => d.getTime()))) : null;\n\n // `inputMin` / `inputMax` constrain what the user can enter (the HTML\n // `<input type=\"date\">` min/max attributes). `filterParams` wins here so\n // consumers can widen the picker beyond what's currently in the data.\n const inputMin =\n parseFilterParam(filterParams?.min) || parseFilterParam(editorParams?.min) || formatDateForInput(dataMin);\n const inputMax =\n parseFilterParam(filterParams?.max) || parseFilterParam(editorParams?.max) || formatDateForInput(dataMax);\n\n // `defaultFrom` / `defaultTo` are the values that pre-populate the inputs\n // when no filter is active, AND the comparators used to detect a default-\n // equal submission on Apply. These must reflect the *actual data range* so\n // narrowing the inputs by a small amount actually filters rows. Falling\n // back to filterParams/editorParams only when there's no data ensures the\n // panel still functions when uniqueValues is empty (e.g. async data).\n const defaultFrom =\n formatDateForInput(dataMin) || parseFilterParam(filterParams?.min) || parseFilterParam(editorParams?.min);\n const defaultTo =\n formatDateForInput(dataMax) || parseFilterParam(filterParams?.max) || parseFilterParam(editorParams?.max);\n\n // Get current filter values if any. When no filter is set, pre-populate the\n // inputs with the data-derived defaults so the panel shows the actual data\n // range. For single-bound filters (gte/lte), keep the other input at its\n // default rather than blanking it — matches the number panel's behaviour.\n const currentFilter = currentFilters.get(field);\n let currentFrom = defaultFrom;\n let currentTo = defaultTo;\n const isBlankFilter = currentFilter?.operator === 'blank';\n if (currentFilter?.operator === 'between') {\n currentFrom = parseFilterParam(currentFilter.value) || defaultFrom;\n currentTo = parseFilterParam(currentFilter.valueTo) || defaultTo;\n } else if (currentFilter?.operator === 'greaterThanOrEqual') {\n currentFrom = parseFilterParam(currentFilter.value) || defaultFrom;\n } else if (currentFilter?.operator === 'lessThanOrEqual') {\n currentTo = parseFilterParam(currentFilter.value) || defaultTo;\n }\n // For 'blank' (and any other operator we don't recognise), keep the defaults\n // populated — the blank checkbox already conveys the active state and the\n // date inputs are disabled while it's checked.\n\n // Date range inputs container\n const rangeContainer = document.createElement('div');\n rangeContainer.className = 'tbw-filter-date-range';\n\n // From input\n const fromGroup = document.createElement('div');\n fromGroup.className = 'tbw-filter-date-group';\n\n const fromLabel = document.createElement('label');\n fromLabel.textContent = params.t('filter.from', 'From');\n fromLabel.className = 'tbw-filter-range-label';\n\n const fromInput = document.createElement('input');\n fromInput.type = 'date';\n fromInput.className = 'tbw-filter-date-input';\n if (inputMin) fromInput.min = inputMin;\n if (inputMax) fromInput.max = inputMax;\n fromInput.value = currentFrom;\n\n fromGroup.appendChild(fromLabel);\n fromGroup.appendChild(fromInput);\n rangeContainer.appendChild(fromGroup);\n\n // Separator\n const separator = document.createElement('span');\n separator.className = 'tbw-filter-range-separator';\n separator.textContent = '–';\n rangeContainer.appendChild(separator);\n\n // To input\n const toGroup = document.createElement('div');\n toGroup.className = 'tbw-filter-date-group';\n\n const toLabel = document.createElement('label');\n toLabel.textContent = 'To';\n toLabel.className = 'tbw-filter-range-label';\n\n const toInput = document.createElement('input');\n toInput.type = 'date';\n toInput.className = 'tbw-filter-date-input';\n if (inputMin) toInput.min = inputMin;\n if (inputMax) toInput.max = inputMax;\n toInput.value = currentTo;\n\n toGroup.appendChild(toLabel);\n toGroup.appendChild(toInput);\n rangeContainer.appendChild(toGroup);\n\n panel.appendChild(rangeContainer);\n\n // \"Show only blank\" checkbox\n const blankRow = document.createElement('label');\n blankRow.className = 'tbw-filter-blank-option';\n\n const blankCheckbox = document.createElement('input');\n blankCheckbox.type = 'checkbox';\n blankCheckbox.className = 'tbw-filter-blank-checkbox';\n blankCheckbox.checked = isBlankFilter;\n\n const blankLabel = document.createTextNode('Show only blank');\n blankRow.appendChild(blankCheckbox);\n blankRow.appendChild(blankLabel);\n\n // Toggle date inputs disabled state when blank is checked\n const toggleDateInputs = (disabled: boolean): void => {\n fromInput.disabled = disabled;\n toInput.disabled = disabled;\n rangeContainer.classList.toggle('tbw-filter-disabled', disabled);\n };\n toggleDateInputs(isBlankFilter);\n\n blankCheckbox.addEventListener('change', () => {\n toggleDateInputs(blankCheckbox.checked);\n });\n\n panel.appendChild(blankRow);\n\n // Apply/Clear buttons\n const buttonRow = document.createElement('div');\n buttonRow.className = 'tbw-filter-buttons';\n\n const applyBtn = document.createElement('button');\n applyBtn.type = 'button';\n applyBtn.className = 'tbw-filter-apply-btn';\n applyBtn.textContent = params.t('filter.apply', 'Apply');\n applyBtn.addEventListener('click', () => {\n if (blankCheckbox.checked) {\n params.applyTextFilter('blank', '');\n return;\n }\n\n const from = fromInput.value;\n const to = toInput.value;\n\n // Treat each bound that equals the data-derived default as \"no constraint\n // on that side\". A `from` equal to the default min is no narrower than the\n // open lower bound; same for `to` equal to the default max. Collapsing\n // these to empty here means a Both-Defaults submission becomes Clear, and\n // a Single-Defaults submission becomes a one-sided gte/lte — mirroring\n // the number panel's behaviour and avoiding silently-stored no-op filters.\n const effectiveFrom = from && from !== defaultFrom ? from : '';\n const effectiveTo = to && to !== defaultTo ? to : '';\n\n if (effectiveFrom && effectiveTo) {\n params.applyTextFilter('between', effectiveFrom, effectiveTo);\n } else if (effectiveFrom) {\n params.applyTextFilter('greaterThanOrEqual', effectiveFrom);\n } else if (effectiveTo) {\n params.applyTextFilter('lessThanOrEqual', effectiveTo);\n } else {\n params.clearFilter();\n }\n });\n buttonRow.appendChild(applyBtn);\n\n const clearBtn = document.createElement('button');\n clearBtn.type = 'button';\n clearBtn.className = 'tbw-filter-clear-btn';\n clearBtn.textContent = params.t('filter.clear', 'Clear Filter');\n clearBtn.addEventListener('click', () => {\n params.clearFilter();\n });\n buttonRow.appendChild(clearBtn);\n\n panel.appendChild(buttonRow);\n}\n\n// #endregion\n","/**\n * Row Virtualization Module\n *\n * Provides all virtualization-related functionality for the grid:\n *\n * 1. **Position Cache** (index-based): Maps row index → {offset, height, measured}\n * - Rebuilt when row count changes (expand/collapse, filter)\n * - Used for scroll position → row index lookups (binary search)\n *\n * 2. **Height Cache** (identity-based): Maps row identity → height\n * - Persists across expand/collapse\n * - Uses rowId string keys when available, WeakMap otherwise\n * - Synthetic rows use __rowCacheKey for stable identity\n *\n * 3. **Virtual Window**: Computes which rows to render based on scroll position\n *\n * 4. **Row Measurement**: Measures rendered row heights from DOM\n */\n\n// #region Types\n\n/**\n * Position entry for a single row in the position cache.\n * @category Plugin Development\n */\nexport interface RowPosition {\n /** Cumulative offset from top in pixels */\n offset: number;\n /** Row height in pixels */\n height: number;\n /** Whether this is a measured value (true) or estimate (false) */\n measured: boolean;\n}\n\n/**\n * Height cache that persists row heights across position cache rebuilds.\n * Uses dual storage: Map for string keys (rowId, __rowCacheKey) and WeakMap for object refs.\n * @category Plugin Development\n */\nexport interface HeightCache {\n /** Heights keyed by string (for synthetic rows with __rowCacheKey or rowId-keyed rows) */\n byKey: Map<string, number>;\n /** Heights keyed by object reference (for data rows without rowId) */\n byRef: WeakMap<object, number>;\n}\n\n/**\n * Configuration for the position cache.\n */\nexport interface PositionCacheConfig<T = unknown> {\n /** Function to get row ID (if configured) */\n rowId?: (row: T) => string | number;\n}\n\n// #endregion\n\n// #region Height Cache Operations\n\n/**\n * Create a new empty height cache.\n */\nexport function createHeightCache(): HeightCache {\n return {\n byKey: new Map(),\n byRef: new WeakMap(),\n };\n}\n\n/**\n * Get the cache key for a row.\n * Returns string for synthetic rows (__rowCacheKey) or rowId-keyed rows,\n * or the row object itself for WeakMap lookup.\n */\nexport function getRowCacheKey<T>(row: T, rowId?: (row: T) => string | number): string | T {\n if (!row || typeof row !== 'object') return row;\n\n // 1. Synthetic rows: plugins MUST add __rowCacheKey\n if ('__rowCacheKey' in row) {\n return (row as { __rowCacheKey: string }).__rowCacheKey;\n }\n\n // 2. rowId property directly on the row (common pattern)\n if ('rowId' in row && (row as { rowId: string | number }).rowId != null) {\n return `id:${(row as { rowId: string | number }).rowId}`;\n }\n\n // 3. User-provided rowId function (if configured)\n if (rowId) {\n return `id:${rowId(row)}`;\n }\n\n // 4. Object identity (for WeakMap)\n return row;\n}\n\n/**\n * Get cached height for a row.\n * @returns Cached height or undefined if not cached\n */\nexport function getCachedHeight<T>(\n cache: HeightCache,\n row: T,\n rowId?: (row: T) => string | number,\n): number | undefined {\n const key = getRowCacheKey(row, rowId);\n\n if (typeof key === 'string') {\n return cache.byKey.get(key);\n }\n\n // Object key - use WeakMap\n if (key && typeof key === 'object') {\n return cache.byRef.get(key);\n }\n\n return undefined;\n}\n\n/**\n * Set cached height for a row.\n */\nexport function setCachedHeight<T>(\n cache: HeightCache,\n row: T,\n height: number,\n rowId?: (row: T) => string | number,\n): void {\n const key = getRowCacheKey(row, rowId);\n\n if (typeof key === 'string') {\n cache.byKey.set(key, height);\n } else if (key && typeof key === 'object') {\n cache.byRef.set(key, height);\n }\n}\n\n// #endregion\n\n// #region Position Cache Operations\n\n/**\n * Rebuild position cache preserving known heights from height cache.\n * Called when row count changes (expand/collapse, filter, data change).\n *\n * @param rows - Array of row data\n * @param heightCache - Height cache with persisted measurements\n * @param estimatedHeight - Estimated height for unmeasured rows\n * @param config - Position cache configuration\n * @param getPluginHeight - Optional function to get height from plugins\n * @returns New position cache\n */\nexport function rebuildPositionCache<T>(\n rows: T[],\n heightCache: HeightCache,\n estimatedHeight: number,\n config: PositionCacheConfig<T>,\n getPluginHeight?: (row: T, index: number) => number | undefined,\n): RowPosition[] {\n const cache: RowPosition[] = new Array(rows.length);\n let offset = 0;\n\n for (let i = 0; i < rows.length; i++) {\n const row = rows[i];\n\n // Height resolution order:\n // 1. Plugin's getRowHeight() (for synthetic rows with known heights)\n let height = getPluginHeight?.(row, i);\n let measured = height !== undefined;\n\n // 2. Cached height from previous measurements\n if (height === undefined) {\n height = getCachedHeight(heightCache, row, config.rowId);\n measured = height !== undefined;\n }\n\n // 3. Fall back to estimate\n if (height === undefined) {\n height = estimatedHeight;\n measured = false;\n }\n\n cache[i] = { offset, height, measured };\n offset += height;\n }\n\n return cache;\n}\n\n/**\n * Update a single row's height in the position cache.\n * Recalculates offsets for all subsequent rows.\n *\n * @param cache - Position cache to update\n * @param index - Row index to update\n * @param newHeight - New measured height\n */\nexport function updateRowHeight(cache: RowPosition[], index: number, newHeight: number): void {\n if (index < 0 || index >= cache.length) return;\n\n const entry = cache[index];\n const heightDiff = newHeight - entry.height;\n\n // Latch `measured` even when the height is unchanged, otherwise callers that enqueue on\n // `!measured` re-measure the same row on every render pass.\n entry.measured = true;\n\n if (heightDiff === 0) return;\n\n // Update this row\n entry.height = newHeight;\n\n // Recalculate offsets for all subsequent rows\n for (let i = index + 1; i < cache.length; i++) {\n cache[i].offset += heightDiff;\n }\n}\n\nexport interface RowHeightChange {\n /** Row index into the position cache. */\n index: number;\n /** Measured height in pixels. */\n height: number;\n}\n\n/**\n * Update multiple row heights while recalculating the affected cache suffix once.\n *\n * @param cache - Position cache to update (mutated in place)\n * @param changes - Height changes in any order; each row index should occur at most once\n */\nexport function updateRowHeights(cache: RowPosition[], changes: RowHeightChange[]): void {\n if (changes.length === 0 || cache.length === 0) return;\n\n // Callers usually collect in DOM order, which is already ascending — skip the copy then.\n let sortedChanges = changes;\n for (let i = 1; i < changes.length; i++) {\n if (changes[i].index < changes[i - 1].index) {\n sortedChanges = [...changes].sort((a, b) => a.index - b.index);\n break;\n }\n }\n\n let changeIndex = 0;\n while (changeIndex < sortedChanges.length && sortedChanges[changeIndex].index < 0) changeIndex++;\n if (changeIndex >= sortedChanges.length || sortedChanges[changeIndex].index >= cache.length) return;\n\n let cumulativeDiff = 0;\n const firstChangedIndex = sortedChanges[changeIndex].index;\n for (let index = firstChangedIndex; index < cache.length; index++) {\n const entry = cache[index];\n entry.offset += cumulativeDiff;\n\n while (changeIndex < sortedChanges.length && sortedChanges[changeIndex].index === index) {\n const height = sortedChanges[changeIndex].height;\n const heightDiff = height - entry.height;\n entry.measured = true;\n if (heightDiff !== 0) {\n entry.height = height;\n cumulativeDiff += heightDiff;\n }\n changeIndex++;\n }\n }\n}\n\n/**\n * Get total content height from position cache.\n *\n * @param cache - Position cache\n * @returns Total height in pixels\n */\nexport function getTotalHeight(cache: RowPosition[]): number {\n if (cache.length === 0) return 0;\n const last = cache[cache.length - 1];\n return last.offset + last.height;\n}\n\n// #endregion\n\n// #region Binary Search\n\n/**\n * Find the row index at a given scroll offset using binary search.\n * Returns the index of the row that contains the given pixel offset.\n *\n * @param cache - Position cache\n * @param targetOffset - Scroll offset in pixels\n * @returns Row index at that offset, or -1 if cache is empty\n */\nexport function getRowIndexAtOffset(cache: RowPosition[], targetOffset: number): number {\n if (cache.length === 0) return -1;\n if (targetOffset <= 0) return 0;\n\n let low = 0;\n let high = cache.length - 1;\n\n while (low <= high) {\n const mid = Math.floor((low + high) / 2);\n const entry = cache[mid];\n const entryEnd = entry.offset + entry.height;\n\n if (targetOffset < entry.offset) {\n high = mid - 1;\n } else if (targetOffset >= entryEnd) {\n low = mid + 1;\n } else {\n // targetOffset is within this row\n return mid;\n }\n }\n\n // Return the closest row (low will be just past the target)\n return Math.max(0, Math.min(low, cache.length - 1));\n}\n\n// #endregion\n\n// #region Statistics\n\n/**\n * Calculate the average measured height.\n * Used for estimating unmeasured rows.\n *\n * @param cache - Position cache\n * @param defaultHeight - Default height to use if no measurements\n * @returns Average measured height\n */\nexport function calculateAverageHeight(cache: RowPosition[], defaultHeight: number): number {\n let totalHeight = 0;\n let measuredCount = 0;\n\n for (const entry of cache) {\n if (entry.measured) {\n totalHeight += entry.height;\n measuredCount++;\n }\n }\n\n return measuredCount > 0 ? totalHeight / measuredCount : defaultHeight;\n}\n\n/**\n * Count how many rows have been measured.\n *\n * @param cache - Position cache\n * @returns Number of measured rows\n */\nexport function countMeasuredRows(cache: RowPosition[]): number {\n let count = 0;\n for (const entry of cache) {\n if (entry.measured) count++;\n }\n return count;\n}\n\n// #endregion\n// #region Row Measurement\n\n/**\n * Result of measuring rendered row heights.\n */\nexport interface RowMeasurementResult {\n /** Whether any heights changed */\n hasChanges: boolean;\n /** Updated measured row count */\n measuredCount: number;\n /** Updated average height */\n averageHeight: number;\n}\n\n/**\n * Context for measuring rendered rows.\n */\nexport interface RowMeasurementContext<T> {\n /** Position cache to update */\n positionCache: RowPosition[];\n /** Height cache for persistence */\n heightCache: HeightCache;\n /** Row data array */\n rows: T[];\n /** Default row height */\n defaultHeight: number;\n /** Start index of rendered window */\n start: number;\n /** End index of rendered window (exclusive) */\n end: number;\n /** Header rows above the data rows — offsets `aria-rowindex` (row index + headerRowCount + 1) */\n headerRowCount: number;\n /** Function to get plugin height for a row */\n getPluginHeight?: (row: T, index: number) => number | undefined;\n /** Function to get row ID for height cache keying */\n getRowId?: (row: T) => string | number;\n}\n\n/**\n * Measure rendered row heights from DOM elements and update caches.\n * Returns measurement statistics for updating virtualization state.\n *\n * @param context - Measurement context with all dependencies\n * @param rowElements - NodeList of rendered row elements\n * @returns Measurement result with flags and statistics\n */\nexport function measureRenderedRowHeights<T>(\n context: RowMeasurementContext<T>,\n rowElements: NodeListOf<Element>,\n): RowMeasurementResult {\n const { positionCache, heightCache, rows, start, end, headerRowCount, getPluginHeight, getRowId } = context;\n\n let hasChanges = false;\n const heightChanges: RowHeightChange[] = [];\n\n rowElements.forEach((rowEl) => {\n // The render loop writes `aria-rowindex` on every data row; clones without it (sticky rows) are skipped.\n const ariaRowIndex = rowEl.getAttribute('aria-rowindex');\n if (!ariaRowIndex) return;\n\n const rowIndex = parseInt(ariaRowIndex, 10) - headerRowCount - 1;\n if (rowIndex < start || rowIndex >= end || rowIndex >= rows.length) return;\n\n const row = rows[rowIndex];\n\n // Check if a plugin provides the height for this row\n const pluginHeight = getPluginHeight?.(row, rowIndex);\n\n if (pluginHeight !== undefined) {\n // Plugin provides height - use it for position cache\n const currentEntry = positionCache[rowIndex];\n if (!currentEntry.measured || Math.abs(currentEntry.height - pluginHeight) > 1) {\n heightChanges.push({ index: rowIndex, height: pluginHeight });\n hasChanges = true;\n }\n return; // Don't measure DOM for plugin-managed rows\n }\n\n // No plugin height - use DOM measurement\n const measuredHeight = (rowEl as HTMLElement).offsetHeight;\n\n if (measuredHeight > 0) {\n const currentEntry = positionCache[rowIndex];\n\n // Only update if height differs significantly (> 1px to avoid oscillation)\n if (!currentEntry.measured || Math.abs(currentEntry.height - measuredHeight) > 1) {\n heightChanges.push({ index: rowIndex, height: measuredHeight });\n setCachedHeight(heightCache, row, measuredHeight, getRowId);\n hasChanges = true;\n }\n }\n });\n\n updateRowHeights(positionCache, heightChanges);\n\n // Recompute stats\n const measuredCount = hasChanges ? countMeasuredRows(positionCache) : 0;\n const averageHeight = hasChanges ? calculateAverageHeight(positionCache, context.defaultHeight) : 0;\n\n return { hasChanges, measuredCount, averageHeight };\n}\n\n/**\n * Compute measurement statistics for a position cache, excluding plugin-managed rows.\n * Used after rebuilding position cache to get accurate averages for non-plugin rows.\n *\n * @param cache - Position cache\n * @param rows - Row data array\n * @param defaultHeight - Default height if no measurements\n * @param getPluginHeight - Function to check if plugin manages row height\n * @returns Object with measured count and average height\n */\nexport function computeAverageExcludingPluginRows<T>(\n cache: RowPosition[],\n rows: T[],\n defaultHeight: number,\n getPluginHeight?: (row: T, index: number) => number | undefined,\n): { measuredCount: number; averageHeight: number } {\n let measuredCount = 0;\n let totalMeasured = 0;\n\n for (let i = 0; i < cache.length; i++) {\n const entry = cache[i];\n if (entry.measured) {\n // Only include in average if plugin doesn't provide height for this row\n const pluginHeight = getPluginHeight?.(rows[i], i);\n if (pluginHeight === undefined) {\n totalMeasured += entry.height;\n measuredCount++;\n }\n }\n }\n\n return {\n measuredCount,\n averageHeight: measuredCount > 0 ? totalMeasured / measuredCount : defaultHeight,\n };\n}\n\n// #endregion\n\n// #region Scroll Mapping (max element-height cap)\n\n/**\n * Maximum spacer element height (px) before browsers cap the rendered height.\n *\n * Chromium's hard cap is `2^25 = 33,554,432` px; Firefox/Safari are similar or larger.\n * We pick `33,500,000` — ~54 KB of headroom under Chromium's cap so that floating-point\n * drift in the fractional mapping math (`scrollTop * rMax / sMax`) cannot push a computed\n * spacer position past the engine cap and silently truncate.\n *\n * For datasets where `totalRows * rowHeight` exceeds this value, the faux-vscroll\n * spacer would silently truncate, making the tail of the dataset unreachable via\n * the native scrollbar / `Ctrl+End`. The grid switches to fractional scroll mapping\n * above this threshold — see {@link computeScrollMapping}.\n *\n * @category Plugin Development\n * @since 2.13.0\n */\nexport const MAX_ELEMENT_HEIGHT_PX = 33_500_000;\n\n/**\n * Mapping between native `scrollTop` (clamped spacer space) and \"virtual\" scroll\n * position (raw row-content space). See {@link computeScrollMapping}.\n *\n * @category Plugin Development\n * @since 2.13.0\n */\nexport interface ScrollMapping {\n /** Raw row-content height in pixels (totalRows * rowHeight or sum of variable heights). */\n rawContentHeight: number;\n /** Effective spacer DOM height — `min(rawContentHeight, maxSpacerHeight)`. */\n spacerHeight: number;\n /** Viewport height used for computing scrollable extents. */\n viewportHeight: number;\n /** True when `rawContentHeight > maxSpacerHeight` — fractional mapping is active. */\n capped: boolean;\n}\n\n/**\n * Compute a mapping between the spacer's native scrollTop and the virtual\n * row-content scroll offset.\n *\n * For datasets within `maxSpacerHeight`, mapping is identity (no transform).\n * Above the cap, the spacer is clamped and `scrollTop` units no longer equal\n * row-content units — call {@link toVirtualScrollTop} / {@link fromVirtualScrollTop}\n * to translate between the two coordinate spaces.\n *\n * @category Plugin Development\n * @since 2.13.0\n */\nexport function computeScrollMapping(\n rawContentHeight: number,\n viewportHeight: number,\n maxSpacerHeight = MAX_ELEMENT_HEIGHT_PX,\n): ScrollMapping {\n const spacerHeight = Math.min(rawContentHeight, maxSpacerHeight);\n return {\n rawContentHeight,\n spacerHeight,\n viewportHeight,\n capped: rawContentHeight > maxSpacerHeight,\n };\n}\n\n/**\n * Translate a native `scrollTop` (spacer space) into a virtual scroll offset\n * in raw row-content space. Identity when the mapping is not capped.\n *\n * The result is clamped to `[0, rawContentHeight - viewportHeight]` so callers\n * never index past the dataset, even when the spacer's actual scrollable extent\n * slightly exceeds `spacerHeight - viewportHeight` (e.g. when a horizontal scrollbar\n * adds bottom padding to the faux spacer).\n *\n * @category Plugin Development\n * @since 2.13.0\n */\nexport function toVirtualScrollTop(scrollTop: number, mapping: ScrollMapping): number {\n if (!mapping.capped) return scrollTop;\n const sMax = mapping.spacerHeight - mapping.viewportHeight;\n const rMax = mapping.rawContentHeight - mapping.viewportHeight;\n if (sMax <= 0 || rMax <= 0) return scrollTop;\n const virtual = (scrollTop * rMax) / sMax;\n if (virtual < 0) return 0;\n if (virtual > rMax) return rMax;\n return virtual;\n}\n\n/**\n * Translate a virtual scroll offset (raw row-content space) into a native\n * `scrollTop` value (spacer space). Identity when the mapping is not capped.\n *\n * @category Plugin Development\n * @since 2.13.0\n */\nexport function fromVirtualScrollTop(virtualTop: number, mapping: ScrollMapping): number {\n if (!mapping.capped) return virtualTop;\n const sMax = mapping.spacerHeight - mapping.viewportHeight;\n const rMax = mapping.rawContentHeight - mapping.viewportHeight;\n if (sMax <= 0 || rMax <= 0) return virtualTop;\n return (virtualTop * sMax) / rMax;\n}\n\n/**\n * Identity scroll mapping (no cap applied). Useful as a default in places that\n * don't yet know the dataset extents. `viewportHeight` is `0` until the first\n * `calculateTotalSpacerHeight` call populates the real extents — safe because\n * `capped: false` short-circuits both translation helpers before they read it.\n *\n * @category Plugin Development\n * @since 2.13.0\n */\nexport function createIdentityScrollMapping(): ScrollMapping {\n return { rawContentHeight: 0, spacerHeight: 0, viewportHeight: 0, capped: false };\n}\n\n// #endregion\n\n// #region Scroll Transform\n\n/**\n * Sub-pixel `translateY` for the `.rows` element at a given native scroll position.\n *\n * Bypass mode (all rows rendered) tracks the raw scroll offset directly. Virtualized mode\n * offsets only the remainder past the window's first row, and rounds that start down to an\n * even index so DOM `nth-child(even)` zebra parity keeps matching data-row parity.\n *\n * @param v Virtual state (structural subset of `VirtualState`).\n * @param rawScrollTop Native `scrollTop` of the faux scrollbar (clamped spacer space).\n */\nexport function computeRowsTranslateY(\n v: {\n rowHeight: number;\n bypassThreshold: number;\n variableHeights: boolean;\n positionCache: RowPosition[] | null;\n scrollMapping: ScrollMapping;\n },\n totalRows: number,\n rawScrollTop: number,\n): number {\n if (totalRows <= v.bypassThreshold) return -rawScrollTop;\n\n const currentScrollTop = toVirtualScrollTop(rawScrollTop, v.scrollMapping);\n const cache = v.positionCache;\n let startRowOffset: number;\n\n if (v.variableHeights && cache && cache.length > 0) {\n let rawStart = getRowIndexAtOffset(cache, currentScrollTop);\n if (rawStart === -1) rawStart = 0;\n const evenAlignedStart = rawStart - (rawStart % 2);\n startRowOffset = cache[evenAlignedStart]?.offset ?? evenAlignedStart * v.rowHeight;\n } else {\n const rawStart = Math.floor(currentScrollTop / v.rowHeight);\n startRowOffset = (rawStart - (rawStart % 2)) * v.rowHeight;\n }\n\n return -(currentScrollTop - startRowOffset);\n}\n\n// #endregion\n\n// #region Fixed-Height Virtual Window\n\n/**\n * Result of computing a virtual window for fixed-height rows.\n * Used by plugins like FilteringPlugin for virtualized dropdowns.\n */\nexport interface VirtualWindow {\n /** First row index to render (inclusive) */\n start: number;\n /** Last row index to render (exclusive) */\n end: number;\n /** Pixel offset to apply to the rows container (translateY) */\n offsetY: number;\n /**\n * Effective spacer DOM height — use this for the scrollable spacer element.\n * Equal to `min(totalRows * rowHeight, maxSpacerHeight)`. Above the cap this\n * is clamped; use {@link VirtualWindow.rawContentHeight} for the un-clamped value.\n */\n totalHeight: number;\n /**\n * Raw, un-clamped row-content height (`totalRows * rowHeight`). Equal to\n * `totalHeight` when the dataset is below `maxSpacerHeight`.\n *\n * @since 2.13.0\n */\n rawContentHeight: number;\n}\n\n/** Parameters for computing the virtual window */\nexport interface VirtualWindowParams {\n /** Total number of rows */\n totalRows: number;\n /** Height of the viewport in pixels */\n viewportHeight: number;\n /** Current scroll top position */\n scrollTop: number;\n /** Height of each row in pixels */\n rowHeight: number;\n /** Number of extra rows to render above/below viewport */\n overscan: number;\n /**\n * Optional cap on the spacer's DOM height. When `totalRows * rowHeight` exceeds\n * this value, `scrollTop` is treated as a spacer-space position and translated\n * to virtual row-content space via fractional mapping. Defaults to {@link MAX_ELEMENT_HEIGHT_PX}.\n * Pass `Infinity` to disable.\n *\n * @since 2.13.0\n */\n maxSpacerHeight?: number;\n}\n\n/**\n * Compute the virtual row window based on scroll position and viewport.\n * Simple fixed-height implementation for plugins needing basic virtualization.\n *\n * For datasets where `totalRows * rowHeight > maxSpacerHeight`, scroll position\n * is mapped fractionally so the entire dataset stays reachable past the browser's\n * single-element height cap (Chromium ~33.5M px). See {@link computeScrollMapping}.\n *\n * @param params - Parameters for computing the window\n * @returns VirtualWindow with start/end indices and transforms\n */\nexport function computeVirtualWindow(params: VirtualWindowParams): VirtualWindow {\n const { totalRows, viewportHeight, scrollTop, rowHeight, overscan, maxSpacerHeight = MAX_ELEMENT_HEIGHT_PX } = params;\n\n const visibleRows = Math.ceil(viewportHeight / rowHeight);\n const rawContentHeight = totalRows * rowHeight;\n const mapping = computeScrollMapping(rawContentHeight, viewportHeight, maxSpacerHeight);\n const virtualScrollTop = toVirtualScrollTop(scrollTop, mapping);\n\n // Render overscan rows in each direction (total window = visible + 2*overscan)\n let start = Math.floor(virtualScrollTop / rowHeight) - overscan;\n if (start < 0) start = 0;\n\n let end = start + visibleRows + overscan * 2;\n if (end > totalRows) end = totalRows;\n\n // Ensure start is adjusted if we hit the end\n if (end === totalRows && start > 0) {\n start = Math.max(0, end - visibleRows - overscan * 2);\n }\n\n return {\n start,\n end,\n offsetY: start * rowHeight,\n totalHeight: mapping.spacerHeight,\n rawContentHeight,\n };\n}\n\n/**\n * Determine if virtualization should be bypassed for small datasets.\n * When there are very few items, the overhead of virtualization isn't worth it.\n *\n * @param totalRows - Total number of items\n * @param threshold - Bypass threshold (skip virtualization if totalRows <= threshold)\n * @returns True if virtualization should be bypassed\n */\nexport function shouldBypassVirtualization(totalRows: number, threshold: number): boolean {\n return totalRows <= threshold;\n}\n\n// #endregion\n","/**\n * Default Filter Panel Renderer\n *\n * Renders the default set filter panel with search, virtualized checkbox list,\n * select-all tristate checkbox, and apply/clear buttons.\n */\n\nimport { computeVirtualWindow, shouldBypassVirtualization } from '../../core/internal/virtualization';\nimport type { FilterPanelParams } from './types';\n\n// #region Constants\n\n/** Default height for filter list items in pixels */\nconst DEFAULT_LIST_ITEM_HEIGHT = 28;\n/** Number of extra items to render above/below the viewport */\nconst LIST_OVERSCAN = 3;\n/** Don't virtualize if fewer than this many items */\nconst LIST_BYPASS_THRESHOLD = 50;\n\n// #endregion\n\n// #region Helpers\n\n/**\n * Get the item height from CSS variable or fallback to default.\n * Reads --tbw-filter-item-height from the panel element.\n */\nexport function getListItemHeight(panelElement: HTMLElement | null): number {\n if (panelElement) {\n const cssValue = getComputedStyle(panelElement).getPropertyValue('--tbw-filter-item-height');\n if (cssValue && cssValue.trim()) {\n const parsed = parseFloat(cssValue);\n if (!isNaN(parsed) && parsed > 0) {\n return parsed;\n }\n }\n }\n return DEFAULT_LIST_ITEM_HEIGHT;\n}\n\n// #endregion\n\n// #region Panel Rendering\n\n/**\n * Render the default set-filter panel content.\n *\n * Creates search input, select-all checkbox, virtualized value list, and apply/clear buttons.\n *\n * @param panel - The panel container element\n * @param params - Filter panel parameters\n * @param uniqueValues - All unique values for this field\n * @param excludedValues - Currently excluded values\n * @param config - Plugin config (caseSensitive, debounceMs)\n * @param searchTextMap - Map of field → current search text\n */\nexport function renderDefaultFilterPanel(\n panel: HTMLElement,\n params: FilterPanelParams,\n uniqueValues: unknown[],\n excludedValues: Set<unknown>,\n config: { caseSensitive?: boolean; debounceMs?: number },\n searchTextMap: Map<string, string>,\n): void {\n const { field, column } = params;\n const itemHeight = getListItemHeight(panel);\n\n // Helper: format a value using the column's format function (for ID-to-name translation, etc.)\n // When filterValue is set, unique values are already extracted primitives — skip format.\n const formatValue = (value: unknown): string => {\n if (value == null) return '(Blank)';\n if (column.format && !column.filterValue) {\n const formatted = column.format(value, undefined as never);\n if (formatted) return formatted;\n }\n return String(value);\n };\n\n // Sort unique values — numbers numerically, everything else by display name\n uniqueValues = uniqueValues.slice().sort((a, b) => {\n if (typeof a === 'number' && typeof b === 'number') return a - b;\n return formatValue(a).localeCompare(formatValue(b));\n });\n\n // Search input\n const searchContainer = document.createElement('div');\n searchContainer.className = 'tbw-filter-search';\n\n const searchInput = document.createElement('input');\n searchInput.type = 'text';\n searchInput.placeholder = params.t('filter.search', 'Search...');\n searchInput.className = 'tbw-filter-search-input';\n searchInput.value = searchTextMap.get(field) ?? '';\n searchContainer.appendChild(searchInput);\n panel.appendChild(searchContainer);\n\n // Select All tristate checkbox\n const actionsRow = document.createElement('div');\n actionsRow.className = 'tbw-filter-actions';\n\n const selectAllLabel = document.createElement('label');\n selectAllLabel.className = 'tbw-filter-value-item';\n selectAllLabel.style.padding = '0';\n selectAllLabel.style.margin = '0';\n\n const selectAllCheckbox = document.createElement('input');\n selectAllCheckbox.type = 'checkbox';\n selectAllCheckbox.className = 'tbw-filter-checkbox';\n\n const selectAllText = document.createElement('span');\n selectAllText.textContent = params.t('filter.selectAll', 'Select All');\n\n selectAllLabel.appendChild(selectAllCheckbox);\n selectAllLabel.appendChild(selectAllText);\n actionsRow.appendChild(selectAllLabel);\n\n // Track current check state for values (persists across virtualizations)\n const checkState = new Map<string, boolean>();\n uniqueValues.forEach((value) => {\n const key = value == null ? '__null__' : String(value);\n checkState.set(key, !excludedValues.has(value));\n });\n\n // Update tristate checkbox based on checkState\n const updateSelectAllState = () => {\n const values = [...checkState.values()];\n const allChecked = values.every((v) => v);\n const noneChecked = values.every((v) => !v);\n\n selectAllCheckbox.checked = allChecked;\n selectAllCheckbox.indeterminate = !allChecked && !noneChecked;\n };\n\n // Toggle all on click\n selectAllCheckbox.addEventListener('change', () => {\n const newState = selectAllCheckbox.checked;\n for (const key of checkState.keys()) {\n checkState.set(key, newState);\n }\n updateSelectAllState();\n renderVisibleItems();\n });\n\n // Initialize select all state\n updateSelectAllState();\n\n panel.appendChild(actionsRow);\n\n // Values container with virtualization support\n const valuesContainer = document.createElement('div');\n valuesContainer.className = 'tbw-filter-values';\n\n // Spacer for virtual height\n const spacer = document.createElement('div');\n spacer.className = 'tbw-filter-values-spacer';\n valuesContainer.appendChild(spacer);\n\n // Content container positioned absolutely\n const contentContainer = document.createElement('div');\n contentContainer.className = 'tbw-filter-values-content';\n valuesContainer.appendChild(contentContainer);\n\n // Filtered values cache\n let filteredValues: unknown[] = [];\n\n // Create a single checkbox item element\n const createItem = (value: unknown, index: number): HTMLElement => {\n const displayValue = formatValue(value);\n const key = value == null ? '__null__' : String(value);\n\n const item = document.createElement('label');\n item.className = 'tbw-filter-value-item';\n item.style.position = 'absolute';\n item.style.top = `calc(var(--tbw-filter-item-height, 28px) * ${index})`;\n item.style.left = '0';\n item.style.right = '0';\n item.style.boxSizing = 'border-box';\n\n const checkbox = document.createElement('input');\n checkbox.type = 'checkbox';\n checkbox.className = 'tbw-filter-checkbox';\n checkbox.checked = checkState.get(key) ?? true;\n checkbox.dataset.value = key;\n\n // Sync check state on change and update tristate checkbox\n checkbox.addEventListener('change', () => {\n checkState.set(key, checkbox.checked);\n updateSelectAllState();\n });\n\n const label = document.createElement('span');\n label.textContent = displayValue;\n\n item.appendChild(checkbox);\n item.appendChild(label);\n return item;\n };\n\n // Render visible items using virtualization\n const renderVisibleItems = () => {\n const totalItems = filteredValues.length;\n const viewportHeight = valuesContainer.clientHeight;\n const scrollTop = valuesContainer.scrollTop;\n\n // Set total height for scrollbar\n spacer.style.height = `${totalItems * itemHeight}px`;\n\n // Bypass virtualization for small lists\n if (shouldBypassVirtualization(totalItems, LIST_BYPASS_THRESHOLD / 3)) {\n contentContainer.innerHTML = '';\n contentContainer.style.transform = 'translateY(0px)';\n filteredValues.forEach((value, idx) => {\n contentContainer.appendChild(createItem(value, idx));\n });\n return;\n }\n\n // Use computeVirtualWindow for real-scroll virtualization\n const window = computeVirtualWindow({\n totalRows: totalItems,\n viewportHeight,\n scrollTop,\n rowHeight: itemHeight,\n overscan: LIST_OVERSCAN,\n });\n\n // Position content container\n contentContainer.style.transform = `translateY(${window.offsetY}px)`;\n\n // Clear and render visible items\n contentContainer.innerHTML = '';\n for (let i = window.start; i < window.end; i++) {\n contentContainer.appendChild(createItem(filteredValues[i], i - window.start));\n }\n };\n\n // Filter and re-render values\n const renderValues = (filterText: string) => {\n const caseSensitive = config.caseSensitive ?? false;\n const compareFilter = caseSensitive ? filterText : filterText.toLowerCase();\n\n // Filter the unique values - search against formatted display name\n filteredValues = uniqueValues.filter((value) => {\n const displayStr = formatValue(value);\n const compareValue = caseSensitive ? displayStr : displayStr.toLowerCase();\n return !filterText || compareValue.includes(compareFilter);\n });\n\n // Sort selected (checked) items first, then alphabetically within each group\n filteredValues.sort((a, b) => {\n const keyA = a == null ? '__null__' : String(a);\n const keyB = b == null ? '__null__' : String(b);\n const checkedA = checkState.get(keyA) ?? true;\n const checkedB = checkState.get(keyB) ?? true;\n if (checkedA !== checkedB) return checkedA ? -1 : 1;\n if (typeof a === 'number' && typeof b === 'number') return a - b;\n return formatValue(a).localeCompare(formatValue(b));\n });\n\n if (filteredValues.length === 0) {\n spacer.style.height = '0px';\n contentContainer.innerHTML = '';\n const noMatch = document.createElement('div');\n noMatch.className = 'tbw-filter-no-match';\n noMatch.textContent = params.t('filter.noMatches', 'No matching values');\n contentContainer.appendChild(noMatch);\n return;\n }\n\n renderVisibleItems();\n };\n\n // Scroll handler for virtualization\n valuesContainer.addEventListener(\n 'scroll',\n () => {\n if (filteredValues.length > 0) {\n renderVisibleItems();\n }\n },\n { passive: true },\n );\n\n renderValues(searchInput.value);\n panel.appendChild(valuesContainer);\n\n // Debounced search\n let debounceTimer: ReturnType<typeof setTimeout>;\n searchInput.addEventListener('input', () => {\n clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n searchTextMap.set(field, searchInput.value);\n renderValues(searchInput.value);\n }, config.debounceMs ?? 150);\n });\n\n // Apply/Clear buttons\n const buttonRow = document.createElement('div');\n buttonRow.className = 'tbw-filter-buttons';\n\n const applyBtn = document.createElement('button');\n applyBtn.type = 'button';\n applyBtn.className = 'tbw-filter-apply-btn';\n applyBtn.textContent = params.t('filter.apply', 'Apply');\n applyBtn.addEventListener('click', () => {\n // Read from checkState map (works with virtualization)\n const excluded: unknown[] = [];\n for (const [key, isChecked] of checkState) {\n if (!isChecked) {\n if (key === '__null__') {\n excluded.push(null);\n } else {\n // Try to match original value type\n const original = uniqueValues.find((v) => String(v) === key);\n excluded.push(original !== undefined ? original : key);\n }\n }\n }\n params.applySetFilter(excluded);\n });\n buttonRow.appendChild(applyBtn);\n\n const clearBtn = document.createElement('button');\n clearBtn.type = 'button';\n clearBtn.className = 'tbw-filter-clear-btn';\n clearBtn.textContent = params.t('filter.clear', 'Clear Filter');\n clearBtn.addEventListener('click', () => {\n params.clearFilter();\n });\n buttonRow.appendChild(clearBtn);\n\n panel.appendChild(buttonRow);\n}\n\n// #endregion\n","/**\n * Number Filter Panel Renderer\n *\n * Renders a number range filter panel with min/max inputs,\n * a dual-thumb range slider, and a blank-value checkbox.\n */\n\nimport type { FilterModel, FilterPanelParams } from './types';\n\n// #region Helpers\n\n/**\n * Convert a value to a number with a fallback.\n */\nfunction toNumber(val: unknown, fallback: number): number {\n if (typeof val === 'number') return val;\n if (typeof val === 'string') {\n const num = parseFloat(val);\n return isNaN(num) ? fallback : num;\n }\n return fallback;\n}\n\n// #endregion\n\n// #region Panel Rendering\n\n/**\n * Render a number range filter panel with min/max inputs and slider.\n *\n * Creates number inputs, a dual-thumb range slider, a blank checkbox,\n * and apply/clear buttons.\n *\n * @param panel - The panel container element\n * @param params - Filter panel parameters\n * @param uniqueValues - All unique values for this field\n * @param currentFilters - Map of field → FilterModel for current filter state\n */\nexport function renderNumberFilterPanel(\n panel: HTMLElement,\n params: FilterPanelParams,\n uniqueValues: unknown[],\n currentFilters: Map<string, FilterModel>,\n): void {\n const { field, column } = params;\n\n // Get range configuration from filterParams, editorParams, or compute from data\n const filterParams = column.filterParams;\n const editorParams = column.editorParams as { min?: number; max?: number; step?: number } | undefined;\n\n // Compute min/max from data if not specified\n const numericValues = uniqueValues.filter((v) => typeof v === 'number' && !isNaN(v)) as number[];\n const dataMin = numericValues.length > 0 ? Math.min(...numericValues) : 0;\n const dataMax = numericValues.length > 0 ? Math.max(...numericValues) : 100;\n\n // `min` / `max` define the slider/input domain (HTML min/max attributes).\n // `filterParams` wins here so consumers can widen the picker beyond the data.\n const min = toNumber(filterParams?.min ?? editorParams?.min, dataMin);\n const max = toNumber(filterParams?.max ?? editorParams?.max, dataMax);\n const step = filterParams?.step ?? editorParams?.step ?? 1;\n\n // `defaultMin` / `defaultMax` are the pre-populated values when no filter is\n // active AND the comparators used to detect a default-equal Apply. These\n // reflect the *actual data range* (clamped into the slider domain) so\n // narrowing the inputs by a small amount actually filters rows. When there's\n // no data, fall back to the slider domain.\n const defaultMin = numericValues.length > 0 ? Math.max(min, dataMin) : min;\n const defaultMax = numericValues.length > 0 ? Math.min(max, dataMax) : max;\n\n // Get current filter values if any\n const currentFilter = currentFilters.get(field);\n let currentMin = defaultMin;\n let currentMax = defaultMax;\n const isBlankFilter = currentFilter?.operator === 'blank';\n if (currentFilter?.operator === 'between') {\n currentMin = toNumber(currentFilter.value, defaultMin);\n currentMax = toNumber(currentFilter.valueTo, defaultMax);\n } else if (currentFilter?.operator === 'greaterThanOrEqual') {\n currentMin = toNumber(currentFilter.value, defaultMin);\n } else if (currentFilter?.operator === 'lessThanOrEqual') {\n currentMax = toNumber(currentFilter.value, defaultMax);\n }\n\n // Range inputs container\n const rangeContainer = document.createElement('div');\n rangeContainer.className = 'tbw-filter-range-inputs';\n\n // Min input\n const minGroup = document.createElement('div');\n minGroup.className = 'tbw-filter-range-group';\n\n const minLabel = document.createElement('label');\n minLabel.textContent = params.t('filter.min', 'Min');\n minLabel.className = 'tbw-filter-range-label';\n\n const minInput = document.createElement('input');\n minInput.type = 'number';\n minInput.className = 'tbw-filter-range-input';\n minInput.min = String(min);\n minInput.max = String(max);\n minInput.step = String(step);\n minInput.value = String(currentMin);\n\n minGroup.appendChild(minLabel);\n minGroup.appendChild(minInput);\n rangeContainer.appendChild(minGroup);\n\n // Separator\n const separator = document.createElement('span');\n separator.className = 'tbw-filter-range-separator';\n separator.textContent = '–';\n rangeContainer.appendChild(separator);\n\n // Max input\n const maxGroup = document.createElement('div');\n maxGroup.className = 'tbw-filter-range-group';\n\n const maxLabel = document.createElement('label');\n maxLabel.textContent = params.t('filter.max', 'Max');\n maxLabel.className = 'tbw-filter-range-label';\n\n const maxInput = document.createElement('input');\n maxInput.type = 'number';\n maxInput.className = 'tbw-filter-range-input';\n maxInput.min = String(min);\n maxInput.max = String(max);\n maxInput.step = String(step);\n maxInput.value = String(currentMax);\n\n maxGroup.appendChild(maxLabel);\n maxGroup.appendChild(maxInput);\n rangeContainer.appendChild(maxGroup);\n\n panel.appendChild(rangeContainer);\n\n // Range slider (dual thumb using two range inputs)\n const sliderContainer = document.createElement('div');\n sliderContainer.className = 'tbw-filter-range-slider';\n\n const sliderTrack = document.createElement('div');\n sliderTrack.className = 'tbw-filter-range-track';\n\n const sliderFill = document.createElement('div');\n sliderFill.className = 'tbw-filter-range-fill';\n\n const minSlider = document.createElement('input');\n minSlider.type = 'range';\n minSlider.className = 'tbw-filter-range-thumb tbw-filter-range-thumb-min';\n minSlider.min = String(min);\n minSlider.max = String(max);\n minSlider.step = String(step);\n minSlider.value = String(currentMin);\n\n const maxSlider = document.createElement('input');\n maxSlider.type = 'range';\n maxSlider.className = 'tbw-filter-range-thumb tbw-filter-range-thumb-max';\n maxSlider.min = String(min);\n maxSlider.max = String(max);\n maxSlider.step = String(step);\n maxSlider.value = String(currentMax);\n\n sliderContainer.appendChild(sliderTrack);\n sliderContainer.appendChild(sliderFill);\n sliderContainer.appendChild(minSlider);\n sliderContainer.appendChild(maxSlider);\n panel.appendChild(sliderContainer);\n\n // \"Blank\" checkbox — filter rows with no value in this column\n const blankRow = document.createElement('label');\n blankRow.className = 'tbw-filter-blank-option';\n\n const blankCheckbox = document.createElement('input');\n blankCheckbox.type = 'checkbox';\n blankCheckbox.className = 'tbw-filter-blank-checkbox';\n blankCheckbox.checked = isBlankFilter;\n\n const blankLabel = document.createTextNode('Blank');\n blankRow.appendChild(blankCheckbox);\n blankRow.appendChild(blankLabel);\n\n // Toggle range inputs disabled state when blank is checked\n const toggleRangeInputs = (disabled: boolean): void => {\n minInput.disabled = disabled;\n maxInput.disabled = disabled;\n minSlider.disabled = disabled;\n maxSlider.disabled = disabled;\n rangeContainer.classList.toggle('tbw-filter-disabled', disabled);\n sliderContainer.classList.toggle('tbw-filter-disabled', disabled);\n };\n toggleRangeInputs(isBlankFilter);\n\n blankCheckbox.addEventListener('change', () => {\n toggleRangeInputs(blankCheckbox.checked);\n });\n\n panel.appendChild(blankRow);\n\n // Update fill position\n const updateFill = () => {\n const minVal = parseFloat(minSlider.value);\n const maxVal = parseFloat(maxSlider.value);\n const range = max - min;\n const leftPercent = ((minVal - min) / range) * 100;\n const rightPercent = ((maxVal - min) / range) * 100;\n sliderFill.style.left = `${leftPercent}%`;\n sliderFill.style.width = `${rightPercent - leftPercent}%`;\n };\n\n // Sync inputs with sliders\n minSlider.addEventListener('input', () => {\n const val = Math.min(parseFloat(minSlider.value), parseFloat(maxSlider.value));\n minSlider.value = String(val);\n minInput.value = String(val);\n updateFill();\n });\n\n maxSlider.addEventListener('input', () => {\n const val = Math.max(parseFloat(maxSlider.value), parseFloat(minSlider.value));\n maxSlider.value = String(val);\n maxInput.value = String(val);\n updateFill();\n });\n\n // Sync sliders with inputs.\n // Use Number.isFinite(…) as the guard rather than `|| min` / `|| max` — the\n // latter silently rewrites a legitimate `0` input to the panel's lower bound\n // when the data range straddles zero (e.g. min=-100).\n minInput.addEventListener('input', () => {\n const parsed = parseFloat(minInput.value);\n let val = Number.isFinite(parsed) ? parsed : min;\n val = Math.max(min, Math.min(val, parseFloat(maxInput.value)));\n minSlider.value = String(val);\n updateFill();\n });\n\n maxInput.addEventListener('input', () => {\n const parsed = parseFloat(maxInput.value);\n let val = Number.isFinite(parsed) ? parsed : max;\n val = Math.min(max, Math.max(val, parseFloat(minInput.value)));\n maxSlider.value = String(val);\n updateFill();\n });\n\n // Initialize fill\n updateFill();\n\n // Apply/Clear buttons\n const buttonRow = document.createElement('div');\n buttonRow.className = 'tbw-filter-buttons';\n\n const applyBtn = document.createElement('button');\n applyBtn.type = 'button';\n applyBtn.className = 'tbw-filter-apply-btn';\n applyBtn.textContent = params.t('filter.apply', 'Apply');\n applyBtn.addEventListener('click', () => {\n if (blankCheckbox.checked) {\n params.applyTextFilter('blank', '');\n return;\n }\n const minVal = parseFloat(minInput.value);\n const maxVal = parseFloat(maxInput.value);\n // An empty input parses to NaN; treat that as \"not constrained\" and fall\n // back to the data-derived default. Then, when neither side has actually\n // been moved from the defaults, Apply means \"no filter\" — applying a\n // `between` over the full range would silently drop blank rows.\n const effectiveMin = Number.isFinite(minVal) ? minVal : defaultMin;\n const effectiveMax = Number.isFinite(maxVal) ? maxVal : defaultMax;\n if (effectiveMin === defaultMin && effectiveMax === defaultMax) {\n params.clearFilter();\n return;\n }\n params.applyTextFilter('between', effectiveMin, effectiveMax);\n });\n buttonRow.appendChild(applyBtn);\n\n const clearBtn = document.createElement('button');\n clearBtn.type = 'button';\n clearBtn.className = 'tbw-filter-clear-btn';\n clearBtn.textContent = params.t('filter.clear', 'Clear Filter');\n clearBtn.addEventListener('click', () => {\n params.clearFilter();\n });\n buttonRow.appendChild(clearBtn);\n\n panel.appendChild(buttonRow);\n}\n\n// #endregion\n","/**\n * Filtering Plugin Styles\n *\n * Scoped to tbw-grid using CSS nesting for light DOM compatibility.\n */\n@layer tbw-plugins {\n tbw-grid {\n /* Quick filter input in header */\n .tbw-quick-filter-input {\n flex: 1;\n max-width: 300px;\n height: var(--tbw-input-height, 1.75rem);\n padding: var(--tbw-input-padding, 0 0.5rem);\n border: 1px solid var(--tbw-color-border);\n border-radius: var(--tbw-border-radius);\n background: var(--tbw-color-bg);\n color: var(--tbw-color-fg);\n font-size: var(--tbw-font-size-sm, 0.8125rem);\n\n &:focus {\n outline: none;\n border-color: var(--tbw-color-accent);\n }\n }\n\n /* Header cell indicator when filtered */\n .header-cell.filtered::before {\n content: '';\n position: absolute;\n top: var(--tbw-spacing-xs, 0.25rem);\n right: var(--tbw-spacing-xs, 0.25rem);\n width: var(--tbw-indicator-size, 0.375rem);\n height: var(--tbw-indicator-size, 0.375rem);\n background: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n border-radius: 50%;\n }\n\n .tbw-filter-btn {\n display: var(--tbw-filter-btn-display, inline-flex);\n visibility: var(--tbw-filter-btn-visibility, visible);\n align-items: center;\n justify-content: center;\n background: transparent;\n border: none;\n cursor: pointer;\n padding: 2px;\n margin-left: var(--tbw-spacing-xs, 0.25rem);\n opacity: 0.4;\n transition:\n opacity 0.15s,\n visibility 0s,\n display 0s allow-discrete;\n color: inherit;\n vertical-align: middle;\n transition-behavior: allow-discrete;\n\n &:hover,\n &.active {\n opacity: 1;\n visibility: visible;\n display: inline-flex;\n }\n\n &.active {\n color: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n }\n }\n\n /* Show filter button on header cell hover or when filtered */\n .header-row .cell:hover .tbw-filter-btn,\n .header-row .cell.filtered .tbw-filter-btn {\n display: inline-flex;\n visibility: visible;\n }\n\n /* Coarse pointer (touch): filter button always visible and large enough to tap */\n @media (hover: none) {\n .header-row .cell .tbw-filter-btn {\n display: inline-flex;\n visibility: visible;\n }\n }\n\n /* WCAG 2.2 SC 2.5.8 Target Size (Minimum) is not pointer-conditional — the\n button carries its 24px target on a mouse too. It sits in a ~30px header,\n so this costs 4px of label width and no height. */\n .tbw-filter-btn {\n min-width: var(--tbw-touch-target-min, 24px);\n min-height: var(--tbw-touch-target-min, 24px);\n }\n }\n} /* End @layer tbw-plugins */\n","@layer tbw-plugins {\n .tbw-filter-panel {\n position: fixed;\n background: var(--tbw-filter-panel-bg, var(--tbw-color-panel-bg, light-dark(#eeeeee, #222222)));\n color: var(--tbw-filter-panel-fg, var(--tbw-color-fg, light-dark(#222222, #eeeeee)));\n border: 1px solid var(--tbw-filter-panel-border, var(--tbw-color-border, light-dark(#d0d0d4, #454545)));\n border-radius: var(--tbw-filter-panel-radius, var(--tbw-border-radius, 0.25rem));\n box-shadow: 0 4px 16px\n var(--tbw-filter-panel-shadow, var(--tbw-color-shadow, light-dark(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.3))));\n padding: var(--tbw-panel-padding, var(--tbw-spacing-lg, 0.75rem));\n z-index: 10000;\n min-width: 200px;\n max-width: 280px;\n max-height: 350px;\n display: flex;\n flex-direction: column;\n font-family: var(--tbw-font-family, system-ui, sans-serif);\n font-size: var(--tbw-font-size, 0.8125rem);\n /* Transform origin at top for drawer effect (panel below header) */\n transform-origin: top center;\n }\n\n /* When panel is positioned above header, animate from bottom */\n .tbw-filter-panel.tbw-filter-panel-above {\n transform-origin: bottom center;\n }\n\n /* Panel enter animation - controlled by class added via JS */\n .tbw-filter-panel.tbw-filter-panel-animated {\n animation: tbw-filter-panel-enter var(--tbw-animation-duration, 150ms) var(--tbw-animation-easing, ease-out);\n }\n\n /* When above header, use upward animation */\n .tbw-filter-panel.tbw-filter-panel-above.tbw-filter-panel-animated {\n animation: tbw-filter-panel-enter-above var(--tbw-animation-duration, 150ms) var(--tbw-animation-easing, ease-out);\n }\n\n /* Drawer-like animation: scales down vertically + fades, like opening from header */\n @keyframes tbw-filter-panel-enter {\n from {\n opacity: 0;\n transform: scaleY(0.3) translateY(-10px);\n }\n to {\n opacity: 1;\n transform: scaleY(1) translateY(0);\n }\n }\n\n /* Upward animation for when panel is above header */\n @keyframes tbw-filter-panel-enter-above {\n from {\n opacity: 0;\n transform: scaleY(0.3) translateY(10px);\n }\n to {\n opacity: 1;\n transform: scaleY(1) translateY(0);\n }\n }\n\n /* CSS Anchor Positioning - modern browsers */\n @supports (anchor-name: --test) {\n .tbw-filter-panel {\n position-anchor: --tbw-filter-anchor;\n /* Position below header row, right-aligned to the filter button */\n top: anchor(bottom);\n right: anchor(right);\n /* Explicitly reset the inset sides we do not use. Required because the\n browser UA stylesheet for [popover] sets `inset: 0; margin: auto`,\n leaving left: 0 and margin-inline: auto active unless we override them\n here. Without these resets the panel is incorrectly stretched/centered\n across the viewport when shown as a popover (used for top-layer\n isolation so anchor positioning works inside CDK dialogs). */\n left: auto;\n bottom: auto;\n margin-inline: 0;\n margin-top: 4px;\n /* Fallback positions when overflowing viewport */\n position-try-fallbacks:\n /* Try aligning to left edge instead */\n flip-inline,\n /* Try flipping to above the anchor */ flip-block,\n /* Try both flips */ flip-block flip-inline;\n }\n }\n\n .tbw-filter-search {\n margin-bottom: var(--tbw-panel-gap, var(--tbw-spacing-md, 0.5rem));\n min-height: var(--tbw-filter-item-height, 28px);\n }\n\n .tbw-filter-search-input {\n height: var(--tbw-filter-item-height, 28px);\n width: 100%;\n padding: var(--tbw-filter-search-padding, var(--tbw-spacing-sm, 0.375rem) var(--tbw-spacing-md, 0.5rem));\n background: var(--tbw-filter-input-bg, var(--tbw-color-bg, transparent));\n color: inherit;\n border: 1px solid var(--tbw-filter-input-border, var(--tbw-color-border, light-dark(#d0d0d4, #454545)));\n border-radius: var(--tbw-filter-input-radius, var(--tbw-border-radius, 0.25rem));\n font-size: inherit;\n box-sizing: border-box;\n }\n\n .tbw-filter-search-input:focus {\n outline: none;\n border-color: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n box-shadow: 0 0 0 2px rgba(from var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6)) r g b / 15%);\n }\n\n .tbw-filter-actions {\n display: flex;\n padding: var(--tbw-button-padding-sm, 0.25rem 0.125rem);\n margin-bottom: var(--tbw-panel-gap, var(--tbw-spacing-md, 0.5rem));\n border-bottom: 1px solid var(--tbw-filter-divider, var(--tbw-color-border, light-dark(#d0d0d4, #454545)));\n min-height: var(--tbw-filter-item-height, 28px);\n }\n .tbw-filter-actions .tbw-filter-value-item {\n flex: 1;\n }\n\n .tbw-filter-values {\n flex: 1;\n overflow-y: auto;\n margin-bottom: var(--tbw-panel-gap, var(--tbw-spacing-md, 0.5rem));\n max-height: 180px;\n position: relative;\n }\n\n .tbw-filter-values-spacer {\n width: 1px;\n }\n\n .tbw-filter-values-content {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n }\n\n .tbw-filter-value-item {\n display: flex;\n align-items: center;\n gap: var(--tbw-panel-gap, var(--tbw-spacing-md, 0.5rem));\n padding: var(--tbw-button-padding-sm, 0.25rem 0.125rem);\n cursor: pointer;\n border-radius: 3px;\n /* Item height for virtualization - used by JS for positioning calculations */\n height: var(--tbw-filter-item-height, 28px);\n }\n\n .tbw-filter-value-item:hover {\n background: var(--tbw-filter-hover, var(--tbw-color-row-hover, light-dark(#f0f6ff, #1c1c1c)));\n }\n\n .tbw-filter-checkbox {\n margin: 0;\n cursor: pointer;\n accent-color: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n }\n\n .tbw-filter-no-match {\n color: var(--tbw-filter-muted, var(--tbw-color-fg-muted, light-dark(#555555, #aaaaaa)));\n padding: var(--tbw-panel-gap, var(--tbw-spacing-md, 0.5rem)) 0;\n text-align: center;\n font-style: italic;\n }\n\n .tbw-filter-buttons {\n display: flex;\n gap: var(--tbw-panel-gap, var(--tbw-spacing-md, 0.5rem));\n padding-top: var(--tbw-panel-gap, var(--tbw-spacing-md, 0.5rem));\n border-top: 1px solid var(--tbw-filter-divider, var(--tbw-color-border, light-dark(#d0d0d4, #454545)));\n }\n\n .tbw-filter-apply-btn {\n flex: 1;\n padding: var(--tbw-filter-btn-padding, var(--tbw-button-padding, 0.375rem 0.75rem));\n background: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n color: var(--tbw-filter-accent-fg, var(--tbw-color-accent-fg, light-dark(#ffffff, #000000)));\n border: none;\n border-radius: var(--tbw-border-radius, 0.25rem);\n cursor: pointer;\n font-size: var(--tbw-font-size-sm, 0.8125rem);\n font-weight: var(--tbw-filter-btn-font-weight, 500);\n min-height: var(--tbw-filter-btn-min-height, auto);\n }\n\n .tbw-filter-apply-btn:hover {\n filter: brightness(0.9);\n }\n\n .tbw-filter-clear-btn {\n flex: 1;\n padding: var(--tbw-filter-btn-padding, var(--tbw-button-padding, 0.375rem 0.75rem));\n background: transparent;\n color: var(--tbw-filter-muted, var(--tbw-color-fg-muted, light-dark(#555555, #aaaaaa)));\n border: 1px solid var(--tbw-filter-input-border, var(--tbw-color-border, light-dark(#d0d0d4, #454545)));\n border-radius: var(--tbw-border-radius, 0.25rem);\n cursor: pointer;\n font-size: var(--tbw-font-size-sm, 0.8125rem);\n font-weight: var(--tbw-filter-btn-font-weight, 500);\n min-height: var(--tbw-filter-btn-min-height, auto);\n }\n\n .tbw-filter-clear-btn:hover {\n background: var(--tbw-filter-hover, var(--tbw-color-row-hover, light-dark(#f0f6ff, #1c1c1c)));\n }\n\n /* Range filter panel (number columns) */\n .tbw-filter-range-inputs,\n .tbw-filter-date-range {\n display: flex;\n align-items: flex-end;\n gap: var(--tbw-spacing-sm, 0.375rem);\n margin-bottom: var(--tbw-panel-gap, var(--tbw-spacing-md, 0.5rem));\n }\n\n .tbw-filter-range-group,\n .tbw-filter-date-group {\n display: flex;\n flex-direction: column;\n gap: var(--tbw-spacing-xs, 0.25rem);\n flex: 1;\n }\n\n .tbw-filter-range-label {\n font-size: var(--tbw-font-size-xs, 0.75rem);\n color: var(--tbw-filter-muted, var(--tbw-color-fg-muted, light-dark(#555555, #aaaaaa)));\n }\n\n .tbw-filter-range-input,\n .tbw-filter-date-input {\n width: 100%;\n height: var(--tbw-filter-item-height, 28px);\n padding: var(--tbw-spacing-xs, 0.25rem) var(--tbw-spacing-sm, 0.375rem);\n background: var(--tbw-filter-input-bg, var(--tbw-color-bg, transparent));\n color: inherit;\n border: 1px solid var(--tbw-filter-input-border, var(--tbw-color-border, light-dark(#d0d0d4, #454545)));\n border-radius: var(--tbw-filter-input-radius, var(--tbw-border-radius, 0.25rem));\n font-size: inherit;\n box-sizing: border-box;\n }\n\n .tbw-filter-range-input:focus,\n .tbw-filter-date-input:focus {\n outline: none;\n border-color: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n box-shadow: 0 0 0 2px rgba(from var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6)) r g b / 15%);\n }\n\n .tbw-filter-range-separator {\n color: var(--tbw-filter-muted, var(--tbw-color-fg-muted, light-dark(#555555, #aaaaaa)));\n padding-bottom: var(--tbw-spacing-xs, 0.25rem);\n }\n\n /* Blank option checkbox */\n .tbw-filter-blank-option {\n display: flex;\n align-items: center;\n gap: var(--tbw-spacing-sm, 0.375rem);\n padding: var(--tbw-spacing-xs, 0.25rem) 0;\n margin-bottom: var(--tbw-spacing-xs, 0.25rem);\n font-size: var(--tbw-font-size-sm, 0.8125rem);\n cursor: pointer;\n user-select: none;\n }\n\n .tbw-filter-blank-checkbox {\n accent-color: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n margin: 0;\n cursor: pointer;\n }\n\n /* Disabled state for date range / number range when blank is checked */\n .tbw-filter-date-range.tbw-filter-disabled,\n .tbw-filter-range-inputs.tbw-filter-disabled,\n .tbw-filter-range-slider.tbw-filter-disabled {\n opacity: 0.4;\n pointer-events: none;\n }\n\n /* Dual-thumb range slider */\n .tbw-filter-range-slider {\n position: relative;\n height: 24px;\n margin: var(--tbw-spacing-md, 0.5rem) 0 var(--tbw-panel-gap, var(--tbw-spacing-md, 0.5rem));\n }\n\n .tbw-filter-range-track {\n position: absolute;\n top: 50%;\n left: 0;\n right: 0;\n height: 4px;\n background: var(--tbw-filter-input-border, var(--tbw-color-border, light-dark(#d0d0d4, #454545)));\n border-radius: 2px;\n transform: translateY(-50%);\n }\n\n .tbw-filter-range-fill {\n position: absolute;\n top: 50%;\n height: 4px;\n background: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n border-radius: 2px;\n transform: translateY(-50%);\n }\n\n .tbw-filter-range-thumb {\n position: absolute;\n top: 0;\n width: 100%;\n height: 100%;\n background: none;\n pointer-events: none;\n -webkit-appearance: none;\n appearance: none;\n }\n\n .tbw-filter-range-thumb::-webkit-slider-thumb {\n -webkit-appearance: none;\n appearance: none;\n width: 16px;\n height: 16px;\n background: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n border: 2px solid var(--tbw-filter-panel-bg, var(--tbw-color-panel-bg, light-dark(#eeeeee, #222222)));\n border-radius: 50%;\n cursor: pointer;\n pointer-events: all;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);\n }\n\n .tbw-filter-range-thumb::-moz-range-thumb {\n width: 16px;\n height: 16px;\n background: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n border: 2px solid var(--tbw-filter-panel-bg, var(--tbw-color-panel-bg, light-dark(#eeeeee, #222222)));\n border-radius: 50%;\n cursor: pointer;\n pointer-events: all;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);\n }\n\n .tbw-filter-range-thumb::-webkit-slider-thumb:hover {\n transform: scale(1.1);\n }\n\n .tbw-filter-range-thumb::-moz-range-thumb:hover {\n transform: scale(1.1);\n }\n} /* End @layer tbw-plugins */\n","/**\n * Filtering Plugin (Class-based)\n *\n * Provides comprehensive filtering functionality for tbw-grid.\n * Supports text, number, date, set, and boolean filters with caching.\n * Includes UI with filter buttons in headers and dropdown filter panels.\n */\n\nimport { announce, getA11yMessage } from '../../core/internal/aria';\nimport { resolveCellValue } from '../../core/internal/value-accessor';\nimport { BaseGridPlugin, type GridElement, type PluginManifest, type PluginQuery } from '../../core/plugin/base-plugin';\nimport { isUtilityColumn } from '../../core/plugin/expander-column';\nimport type { ColumnConfig, ColumnState } from '../../core/types';\nimport type { ContextMenuParams, HeaderContextMenuItem } from '../context-menu/types';\nimport {\n BLANK_FILTER_VALUE,\n computeFilterCacheKey,\n filterRows,\n getUniqueValues,\n getUniqueValuesBatch,\n} from './filter-model';\nimport { renderDateFilterPanel } from './filter-panel-date';\nimport { renderDefaultFilterPanel } from './filter-panel-default';\nimport { renderNumberFilterPanel } from './filter-panel-number';\nimport styles from './filtering.css?inline';\nimport filterPanelStyles from './FilteringPlugin.css?inline';\nimport type { BlankMode, FilterChangeDetail, FilterConfig, FilterModel, FilterPanelParams } from './types';\n\n/**\n * Filtering Plugin for tbw-grid\n *\n * Adds column header filters with text search, dropdown options, and custom filter panels.\n * Supports both **local filtering** for small datasets and **async handlers** for server-side\n * filtering on large datasets.\n *\n * ## Installation\n *\n * ```ts\n * import { FilteringPlugin } from '@toolbox-web/grid/plugins/filtering';\n * ```\n *\n * ## Column Configuration\n *\n * | Property | Type | Description |\n * |----------|------|-------------|\n * | `filterable` | `boolean` | Enable filtering for this column |\n * | `filterType` | `'text' \\| 'select' \\| 'number' \\| 'date'` | Filter UI type |\n * | `filterOptions` | `unknown[]` | Predefined options for select filters |\n *\n * ## CSS Custom Properties\n *\n * | Property | Default | Description |\n * |----------|---------|-------------|\n * | `--tbw-filter-panel-bg` | `var(--tbw-color-panel-bg)` | Panel background |\n * | `--tbw-filter-panel-fg` | `var(--tbw-color-fg)` | Panel text color |\n * | `--tbw-filter-panel-border` | `var(--tbw-color-border)` | Panel border |\n * | `--tbw-filter-active-color` | `var(--tbw-color-accent)` | Active filter indicator |\n * | `--tbw-filter-input-bg` | `var(--tbw-color-bg)` | Input background |\n * | `--tbw-filter-input-focus` | `var(--tbw-color-accent)` | Input focus border |\n *\n * @example Basic Usage with Filterable Columns\n * ```ts\n * import { queryGrid } from '@toolbox-web/grid';\n * import { FilteringPlugin } from '@toolbox-web/grid/plugins/filtering';\n *\n * const grid = queryGrid('tbw-grid');\n * grid.gridConfig = {\n * columns: [\n * { field: 'name', header: 'Name', filterable: true },\n * { field: 'status', header: 'Status', filterable: true, filterType: 'select' },\n * { field: 'email', header: 'Email', filterable: true },\n * ],\n * plugins: [new FilteringPlugin({ debounceMs: 300 })],\n * };\n * grid.rows = data;\n * ```\n *\n * @example Column Formatters in Filter Panel\n * When a column defines a `format` function, the built-in set filter panel\n * displays formatted labels instead of raw values. Search within the panel\n * also matches against the formatted text.\n * ```ts\n * grid.gridConfig = {\n * columns: [\n * {\n * field: 'price',\n * filterable: true,\n * format: (value) => `$${Number(value).toFixed(2)}`,\n * // Filter checkboxes show \"$9.99\" instead of \"9.99\"\n * },\n * ],\n * plugins: [new FilteringPlugin()],\n * };\n * ```\n *\n * @example Server-Side Filtering with Async Handlers\n * ```ts\n * new FilteringPlugin({\n * // Fetch unique values from server for filter dropdown\n * valuesHandler: async (field, column) => {\n * const response = await fetch(`/api/distinct-values?field=${field}`);\n * return response.json();\n * },\n * // Apply filters on the server\n * filterHandler: async (filters, currentRows) => {\n * const response = await fetch('/api/data', {\n * method: 'POST',\n * body: JSON.stringify({ filters }),\n * });\n * return response.json();\n * },\n * });\n * ```\n *\n * @see {@link FilterConfig} for all configuration options\n * @see {@link FilterModel} for filter data structure\n * @see {@link FilterPanelParams} for custom panel renderer parameters\n *\n * @internal Extends BaseGridPlugin\n * @since 0.1.1\n */\nexport class FilteringPlugin extends BaseGridPlugin<FilterConfig> {\n /**\n * Plugin manifest - declares events emitted by this plugin.\n * @internal\n */\n static override readonly manifest: PluginManifest = {\n events: [\n {\n type: 'filter-change',\n description: 'Emitted when filter criteria change. Broadcast to both DOM consumers and plugin bus.',\n },\n ],\n queries: [\n {\n type: 'getContextMenuItems',\n description: 'Contributes filter-related items to the header context menu',\n },\n ],\n };\n\n /** @internal */\n readonly name = 'filtering';\n /** @internal */\n override readonly styles = styles;\n\n /** @internal */\n protected override get defaultConfig(): Partial<FilterConfig> {\n return {\n debounceMs: 300,\n caseSensitive: false,\n };\n }\n\n // #region Helpers\n\n /**\n * Check if filtering is enabled at the grid level.\n * Grid-wide `filterable: false` disables filtering for all columns.\n */\n private isFilteringEnabled(): boolean {\n return this.grid.effectiveConfig?.filterable !== false;\n }\n\n /**\n * Check if a specific column is filterable, respecting both grid-level and column-level settings.\n */\n private isColumnFilterable(col: { filterable?: boolean; field?: string }): boolean {\n if (!this.isFilteringEnabled()) return false;\n return col.filterable !== false;\n }\n\n /**\n * Resolve the row set used to compute filter unique values.\n *\n * Default source is `sourceRows` (raw user input — `grid.#rows`). When a plugin\n * owns the data instead (e.g. `ServerSidePlugin` returns its own `managedNodes`\n * from `processRows`), `sourceRows` is empty and we fall back to the processed\n * `grid.rows` with placeholder rows excluded. This lets users filter by values\n * that have actually been loaded so far without having to wire a `valuesHandler`.\n */\n private getDataRows(): Record<string, unknown>[] {\n const source = this.sourceRows as Record<string, unknown>[];\n if (source.length > 0) return source;\n const processed = this.rows as Record<string, unknown>[];\n if (processed.length === 0) return processed;\n // Skip placeholder rows (e.g. ServerSidePlugin's `{ __loading: true, __index }`).\n return processed.filter((r) => r != null && (r as { __loading?: unknown }).__loading !== true);\n }\n\n /**\n * Build a map of field → filterValue extractor for columns that have one.\n * Falls back to {@link ColumnConfig.valueAccessor} when `filterValue` is\n * absent, so accessor-defined columns filter correctly out of the box.\n * Documented precedence: filterValue → valueAccessor → row[field].\n * Used to pass array-aware value extraction to the pure filter functions.\n */\n private getFilterValues():\n Map<string, (value: unknown, row: Record<string, unknown>) => unknown | unknown[]> | undefined {\n const columns = this.grid.effectiveConfig?.columns;\n if (!columns) return undefined;\n\n let map: Map<string, (value: unknown, row: Record<string, unknown>) => unknown | unknown[]> | undefined;\n for (const col of columns) {\n if (!col.field) continue;\n const extractor = this.getFilterExtractor(col);\n if (extractor) {\n if (!map) map = new Map();\n map.set(col.field, extractor);\n }\n }\n return map;\n }\n\n /**\n * Resolve a per-column filter-value extractor with documented precedence:\n * `filterValue` → `valueAccessor` → undefined (callers fall back to `row[field]`).\n */\n private getFilterExtractor(\n col: ColumnConfig,\n ): ((value: unknown, row: Record<string, unknown>) => unknown | unknown[]) | undefined {\n const { filterValue } = col;\n // Loading placeholders (ServerSide under `filterMode: 'local'`) hold no data; give\n // them the same `undefined` a plain field read would, without calling consumer code.\n if (filterValue) return (v, row) => (row.__loading === true ? undefined : filterValue(v, row));\n if (col.valueAccessor) return (_v, row) => resolveCellValue(row, col);\n return undefined;\n }\n\n // #endregion\n\n // #region Internal State\n private filters: Map<string, FilterModel> = new Map();\n private cachedResult: unknown[] | null = null;\n private cacheKey: string | null = null;\n /**\n * Reference to the input rows used to compute {@link cachedResult}.\n * Upstream plugins (e.g. sort) produce a new array on every change, so\n * reference inequality is a sufficient invalidation signal.\n */\n private cachedInputRows: readonly unknown[] | null = null;\n private openPanelField: string | null = null;\n private panelElement: HTMLElement | null = null;\n private panelAnchorElement: HTMLElement | null = null; // For CSS anchor positioning cleanup\n private panelButtonElement: HTMLElement | null = null; // Filter button that opened the panel\n private searchText: Map<string, string> = new Map();\n private excludedValues: Map<string, Set<unknown>> = new Map();\n private panelAbortController: AbortController | null = null; // For panel-scoped listeners\n private globalStylesInjected = false;\n\n /**\n * Cache of `field -> ColumnConfig` lookups, built lazily on first use after\n * the columns array reference changes. Replaces O(n) `.find((c) => c.field === field)`\n * scans on every filter operation, panel render, and unique-value extraction — noticeable\n * on grids with many columns (50+).\n */\n private columnLookup: Map<string, ColumnConfig> | null = null;\n private columnLookupRef: readonly ColumnConfig[] | null = null;\n\n /**\n * O(1) column lookup by field name. Rebuilds the cache when the underlying\n * `effectiveConfig.columns` array reference changes (the merge step always\n * produces a new array, so reference identity is a sufficient invalidation\n * signal — no need for explicit hooks).\n */\n private getColumnByField(field: string): ColumnConfig | undefined {\n const cols = this.grid.effectiveConfig?.columns as readonly ColumnConfig[] | undefined;\n if (!cols) return undefined;\n let lookup = this.columnLookup;\n if (cols !== this.columnLookupRef || !lookup) {\n lookup = new Map<string, ColumnConfig>();\n for (const c of cols) {\n if (c.field) lookup.set(c.field, c);\n }\n this.columnLookup = lookup;\n this.columnLookupRef = cols;\n }\n return lookup.get(field);\n }\n\n /**\n * Compute the inclusion (selected) map from the current filters and excluded values.\n * For `notIn` filters this is: uniqueValues \\ excludedValues.\n * For `in` filters this is the filter's own value array (no data scan needed).\n * Only includes entries for fields that have a set filter.\n */\n private computeSelected(): Record<string, unknown[]> {\n const selected: Record<string, unknown[]> = {};\n\n // Collect `notIn` fields that need unique value lookups\n const notInFields: {\n field: string;\n filterValue?: (value: unknown, row: Record<string, unknown>) => unknown | unknown[];\n }[] = [];\n\n for (const [field, filter] of this.filters) {\n if (filter.type !== 'set') continue;\n if (filter.operator === 'in' && Array.isArray(filter.value)) {\n // For `in` filters, the selected values ARE the filter values — no data scan needed\n selected[field] = filter.value;\n continue;\n }\n if (filter.operator === 'notIn') {\n const col = this.getColumnByField(field);\n notInFields.push({ field, filterValue: col ? this.getFilterExtractor(col) : undefined });\n }\n }\n\n if (notInFields.length > 0) {\n // Single pass through the resolved data rows for notIn fields only\n const uniqueMap = getUniqueValuesBatch(this.getDataRows(), notInFields);\n for (const { field } of notInFields) {\n const excluded = this.excludedValues.get(field);\n const unique = uniqueMap.get(field) ?? [];\n selected[field] = excluded ? unique.filter((v) => !excluded.has(v)) : unique;\n }\n }\n\n return selected;\n }\n\n /**\n * Sync excludedValues map from a filter model (for set filters).\n * For `notIn` filters, the excluded values are stored directly.\n * For `in` filters, the complement is computed (allUnique - inValues)\n * so that the filter panel UI can render checked/unchecked states correctly.\n */\n /**\n * Drop the memoized filtered result. Called whenever filter state or input\n * data changes, so the next {@link processRows} call recomputes.\n */\n private invalidateCache(): void {\n this.cachedResult = null;\n this.cacheKey = null;\n this.cachedInputRows = null;\n }\n\n /**\n * Emit `filter-change` to consumers and plugins, then optionally request a\n * column-state save. `silent` skips the public DOM event but still notifies\n * plugins via the internal plugin-event bus so they stay in sync.\n */\n private notifyFilterChange(filters: FilterModel[], filteredRowCount: number, silent: boolean | undefined): void {\n if (silent) {\n this.emitPluginEvent('filter-change', { filters });\n return;\n }\n this.broadcast<FilterChangeDetail>('filter-change', {\n filters,\n filteredRowCount,\n selected: this.computeSelected(),\n });\n if (this.config.trackColumnState) {\n this.grid.requestStateChange?.();\n }\n }\n\n private syncExcludedValues(field: string, filter: FilterModel | null): void {\n if (!filter) {\n this.excludedValues.delete(field);\n } else if (filter.type === 'set' && filter.operator === 'notIn' && Array.isArray(filter.value)) {\n this.excludedValues.set(field, new Set(filter.value));\n } else if (filter.type === 'set' && filter.operator === 'in' && Array.isArray(filter.value)) {\n // For `in` filters we need the complement (excluded = allUnique - included)\n // so the filter panel can render correct checkbox states.\n // This requires data to be loaded. If no rows are available yet, defer —\n // the complement will be computed lazily when the panel opens.\n const sourceRows = this.getDataRows();\n if (!sourceRows || sourceRows.length === 0) {\n this.excludedValues.delete(field);\n return;\n }\n const inValues = filter.value as unknown[];\n const included: Set<unknown> = new Set(inValues.map((v) => (v == null ? BLANK_FILTER_VALUE : v)));\n const unique = this.getUniqueValues(field);\n const excluded: Set<unknown> = new Set(unique.filter((v) => !included.has(v)));\n this.excludedValues.set(field, excluded);\n } else if (filter.type === 'set') {\n // Other set operators may have different semantics; clear for safety\n this.excludedValues.delete(field);\n }\n }\n // #endregion\n\n // #region Lifecycle\n\n /** @internal */\n override attach(grid: GridElement): void {\n super.attach(grid);\n this.injectGlobalStyles();\n }\n\n /** @internal */\n override detach(): void {\n this.filters.clear();\n this.invalidateCache();\n this.columnLookup = null;\n this.columnLookupRef = null;\n this.openPanelField = null;\n if (this.panelElement) {\n this.dismissTopLayer(this.panelElement);\n this.panelElement.remove();\n this.panelElement = null;\n }\n this.searchText.clear();\n this.excludedValues.clear();\n // Abort panel-scoped listeners (document click handler, etc.)\n this.panelAbortController?.abort();\n this.panelAbortController = null;\n }\n // #endregion\n\n // #region Query Handlers\n\n /**\n * Handle inter-plugin queries.\n * Contributes filter-related items to the header context menu.\n * @internal\n */\n override handleQuery(query: PluginQuery): unknown {\n if (query.type === 'filter:get-model') {\n // ServerSidePlugin enrichment: expose the active filter map keyed by field\n // for inclusion in `GetRowsParams.filterModel`. Returning `undefined` lets\n // the caller treat \"no filter\" identically to \"plugin not loaded\".\n if (!this.filters.size) return undefined;\n return Object.fromEntries(this.filters);\n }\n\n if (query.type === 'getContextMenuItems') {\n const params = query.context as ContextMenuParams;\n if (!params.isHeader) return undefined;\n\n const column = params.column as ColumnConfig;\n if (!column?.field) return undefined;\n\n // Only contribute items if filtering is enabled for this column\n if (!this.isFilteringEnabled()) return undefined;\n if (!this.isColumnFilterable(column)) return undefined;\n\n const items: HeaderContextMenuItem[] = [];\n const fieldFiltered = this.isFieldFiltered(column.field);\n const hasAnyFilter = this.filters.size > 0;\n\n if (fieldFiltered) {\n items.push({\n id: 'filtering/clear-column-filter',\n label: `Clear Filter`,\n icon: '✕',\n order: 20,\n action: () => this.clearFieldFilter(column.field),\n });\n }\n\n if (hasAnyFilter) {\n items.push({\n id: 'filtering/clear-all-filters',\n label: this.t('filter.clearAll', 'Clear All Filters'),\n icon: '✕',\n order: 21,\n disabled: !hasAnyFilter,\n action: () => this.clearAllFilters(),\n });\n }\n\n return items.length > 0 ? items : undefined;\n }\n return undefined;\n }\n // #endregion\n\n // #region Hooks\n\n /** @internal */\n override processRows(rows: readonly unknown[]): unknown[] {\n if (!this.filters.size) return rows as unknown[];\n const filterList = [...this.filters.values()];\n\n // If using async filterHandler, processRows becomes a passthrough\n // Actual filtering happens in applyFiltersAsync and rows are set directly on grid\n if (this.config.filterHandler) {\n // Return cached result if available (set by async handler)\n if (this.cachedResult) return this.cachedResult;\n // Otherwise return rows as-is (filtering happens async)\n return rows as unknown[];\n }\n\n // Check cache — also verify input rows haven't changed (e.g. due to sort).\n // Upstream plugins return a new array on every change, so reference\n // equality is a sufficient invalidation signal.\n const newCacheKey = computeFilterCacheKey(filterList);\n const inputUnchanged = rows === this.cachedInputRows;\n\n if (this.cacheKey === newCacheKey && this.cachedResult && inputUnchanged) {\n return this.cachedResult;\n }\n\n // When input rows changed, recompute excludedValues for `in` filters\n // so the filter panel checkboxes reflect newly appeared unique values.\n if (!inputUnchanged) {\n for (const [field, filter] of this.filters) {\n if (filter.type === 'set' && filter.operator === 'in') {\n this.syncExcludedValues(field, filter);\n }\n }\n }\n\n // Filter rows synchronously (worker support can be added later)\n const result = filterRows(\n rows as Record<string, unknown>[],\n filterList,\n this.config.caseSensitive,\n this.getFilterValues(),\n );\n\n // Update cache\n this.cachedResult = result;\n this.cacheKey = newCacheKey;\n this.cachedInputRows = rows;\n\n return result;\n }\n\n /** @internal */\n override afterRender(): void {\n const gridEl = this.gridElement;\n if (!gridEl) return;\n\n // Find all header cells (using part attribute, not class)\n const headerCells = gridEl.querySelectorAll('[part~=\"header-cell\"]');\n headerCells.forEach((cell) => {\n const colIndex = cell.getAttribute('data-col');\n if (colIndex === null) return;\n\n // Use visibleColumns since data-col is the index within _visibleColumns\n const col = this.visibleColumns[parseInt(colIndex, 10)] as ColumnConfig;\n if (!col || !this.isColumnFilterable(col)) return;\n\n // Skip utility columns (expander, selection checkbox, etc.)\n if (isUtilityColumn(col)) return;\n\n const field = col.field;\n if (!field) return;\n\n const hasFilter = this.filters.has(field);\n\n // Check if button already exists\n let filterBtn = cell.querySelector('.tbw-filter-btn') as HTMLButtonElement | null;\n\n if (filterBtn) {\n // Update active state and icon of existing button\n const wasActive = filterBtn.classList.contains('active');\n filterBtn.classList.toggle('active', hasFilter);\n (cell as HTMLElement).classList.toggle('filtered', hasFilter);\n // Update aria-description for screen readers\n if (hasFilter) {\n (cell as HTMLElement).setAttribute('aria-description', 'Filtered');\n } else {\n (cell as HTMLElement).removeAttribute('aria-description');\n }\n // Update icon if active state changed\n if (wasActive !== hasFilter) {\n this.setIcon(filterBtn, hasFilter ? 'filterActive' : 'filter');\n }\n return;\n }\n\n // Create filter button\n filterBtn = document.createElement('button');\n filterBtn.type = 'button';\n filterBtn.className = 'tbw-filter-btn';\n filterBtn.setAttribute('aria-label', `Filter ${col.header ?? field}`);\n // Use grid icons configuration\n this.setIcon(filterBtn, hasFilter ? 'filterActive' : 'filter');\n\n // Mark button as active if filter exists\n if (hasFilter) {\n filterBtn.classList.add('active');\n (cell as HTMLElement).classList.add('filtered');\n (cell as HTMLElement).setAttribute('aria-description', 'Filtered');\n }\n\n filterBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n this.toggleFilterPanel(field, col, filterBtn!);\n });\n\n // Insert before resize handle to maintain order: [label, sort-indicator, filter-btn, resize-handle]\n const resizeHandle = cell.querySelector('.resize-handle');\n if (resizeHandle) {\n cell.insertBefore(filterBtn, resizeHandle);\n } else {\n cell.appendChild(filterBtn);\n }\n });\n }\n // #endregion\n\n // #region Public API\n\n /**\n * Set a filter on a specific field.\n * Pass null to remove the filter.\n * @param options - `{ silent: true }` applies the filter without emitting `filter-change`\n */\n setFilter(field: string, filter: Omit<FilterModel, 'field'> | null, options?: { silent?: boolean }): void {\n if (filter === null) {\n this.filters.delete(field);\n this.syncExcludedValues(field, null);\n } else {\n const fullFilter = { ...filter, field };\n this.filters.set(field, fullFilter);\n this.syncExcludedValues(field, fullFilter);\n }\n this.invalidateCache();\n // filteredRowCount = 0 here; processRows will produce the accurate count.\n this.notifyFilterChange([...this.filters.values()], 0, options?.silent);\n if (!options?.silent) {\n const header = this.getColumnByField(field)?.header ?? field;\n announce(\n this.gridElement!,\n filter === null\n ? getA11yMessage(this.gridElement!, 'filterCleared', header)\n : getA11yMessage(this.gridElement!, 'filterApplied', header),\n );\n }\n this.requestRender();\n }\n\n /**\n * Get the current filter for a field.\n */\n getFilter(field: string): FilterModel | undefined {\n return this.filters.get(field);\n }\n\n /**\n * Get all active filters.\n */\n getFilters(): FilterModel[] {\n return [...this.filters.values()];\n }\n\n /**\n * Alias for getFilters() to match functional API naming.\n */\n getFilterModel(): FilterModel[] {\n return this.getFilters();\n }\n\n /**\n * Set filters from an array (replaces all existing filters).\n * @param options - `{ silent: true }` applies filters without emitting `filter-change`\n */\n setFilterModel(filters: FilterModel[], options?: { silent?: boolean }): void {\n this.filters.clear();\n this.excludedValues.clear();\n for (const filter of filters) {\n this.filters.set(filter.field, filter);\n this.syncExcludedValues(filter.field, filter);\n }\n this.invalidateCache();\n this.notifyFilterChange([...this.filters.values()], 0, options?.silent);\n this.requestRender();\n }\n\n /**\n * Clear all filters.\n * @param options - `{ silent: true }` clears filters without emitting `filter-change`\n */\n clearAllFilters(options?: { silent?: boolean }): void {\n this.filters.clear();\n this.excludedValues.clear();\n this.searchText.clear();\n\n this.applyFiltersInternal(options?.silent);\n if (!options?.silent) announce(this.gridElement!, getA11yMessage(this.gridElement!, 'allFiltersCleared'));\n }\n\n /**\n * Clear filter for a specific field.\n * @param options - `{ silent: true }` clears filter without emitting `filter-change`\n */\n clearFieldFilter(field: string, options?: { silent?: boolean }): void {\n this.filters.delete(field);\n this.excludedValues.delete(field);\n this.searchText.delete(field);\n\n this.applyFiltersInternal(options?.silent);\n if (!options?.silent) {\n const header = this.getColumnByField(field)?.header ?? field;\n announce(this.gridElement!, getA11yMessage(this.gridElement!, 'filterCleared', header));\n }\n }\n\n /**\n * Check if a field has an active filter.\n */\n isFieldFiltered(field: string): boolean {\n return this.filters.has(field);\n }\n\n /**\n * Get the count of filtered rows (from cache).\n */\n getFilteredRowCount(): number {\n return this.cachedResult?.length ?? this.rows.length;\n }\n\n /**\n * Get all active filters (alias for getFilters).\n */\n getActiveFilters(): FilterModel[] {\n return this.getFilters();\n }\n\n /**\n * Get unique values for a field (for set filter dropdowns).\n *\n * Prefers `sourceRows` (raw user input). When the user does not own the data\n * (e.g. `ServerSidePlugin` is active and `sourceRows` is empty), falls back\n * to the processed `rows` with placeholder/loading rows excluded so users get\n * filter values for whatever has been loaded so far.\n *\n * When a column has `filterValue`, individual extracted values are returned.\n */\n getUniqueValues(field: string): unknown[] {\n const col = this.getColumnByField(field);\n const getter = col?.filterValue;\n return getUniqueValues(this.getDataRows(), field, getter);\n }\n\n // #region Stale Filter Detection (#166)\n\n /**\n * Get set filters whose exclusion/inclusion list no longer matches any values\n * in the current data, effectively selecting zero rows.\n *\n * - `notIn`: stale when every current unique value is in the exclusion list (selected count = 0)\n * - `in`: stale when none of the included values exist in the current data\n */\n getStaleFilters(): FilterModel[] {\n const stale: FilterModel[] = [];\n for (const [field, filter] of this.filters) {\n if (filter.type !== 'set') continue;\n const unique = this.getUniqueValues(field);\n\n if (filter.operator === 'notIn' && Array.isArray(filter.value)) {\n const excluded = new Set(filter.value);\n const selectedCount = unique.filter((v) => !excluded.has(v)).length;\n if (selectedCount === 0) stale.push(filter);\n } else if (filter.operator === 'in' && Array.isArray(filter.value)) {\n const included = new Set(filter.value);\n const matchCount = unique.filter((v) => included.has(v)).length;\n if (matchCount === 0) stale.push(filter);\n }\n }\n return stale;\n }\n\n // #endregion\n\n // #region Blank Filter Toggle Helpers (#169)\n\n /**\n * Get the current blank mode for a field.\n * - `'all'` — no blank filter active\n * - `'blanksOnly'` — `blank` operator active\n * - `'nonBlanksOnly'` — `notBlank` operator active\n */\n getBlankMode(field: string): BlankMode {\n const filter = this.filters.get(field);\n if (filter?.operator === 'blank') return 'blanksOnly';\n if (filter?.operator === 'notBlank') return 'nonBlanksOnly';\n return 'all';\n }\n\n /**\n * Toggle blank filter mode for a field.\n *\n * Handles transitions:\n * - `'all'` → clears the filter entirely\n * - `'blanksOnly'` → sets `blank` operator, stashing any active filter in `valueTo`\n * - `'nonBlanksOnly'` → sets `notBlank` operator, stashing any active filter in `valueTo`\n *\n * When switching back to `'all'`, the stashed filter (if any) is restored.\n */\n toggleBlankFilter(field: string, mode: BlankMode): void {\n if (mode === 'all') {\n // Restore stashed filter if one was preserved\n const currentFilter = this.filters.get(field);\n if (currentFilter?.valueTo && typeof currentFilter.valueTo === 'object') {\n const stashed = currentFilter.valueTo as Omit<FilterModel, 'field'>;\n this.setFilter(field, stashed);\n } else {\n this.setFilter(field, null);\n }\n return;\n }\n\n const currentFilter = this.filters.get(field);\n // Stash the current non-blank filter so it can be restored\n const stash =\n currentFilter && currentFilter.operator !== 'blank' && currentFilter.operator !== 'notBlank'\n ? {\n type: currentFilter.type,\n operator: currentFilter.operator,\n value: currentFilter.value,\n valueTo: currentFilter.valueTo,\n }\n : currentFilter?.valueTo; // Preserve existing stash when toggling between blank modes\n\n this.setFilter(field, {\n type: currentFilter?.type ?? 'text',\n operator: mode === 'blanksOnly' ? 'blank' : 'notBlank',\n value: '',\n valueTo: stash,\n });\n }\n\n // #endregion\n\n // #endregion\n\n // #region Private Methods\n\n /**\n * Copy CSS classes and data attributes from grid to filter panel.\n * This ensures theme classes (e.g., .eds-theme) cascade to the panel.\n */\n private copyGridThemeContext(panel: HTMLElement): void {\n const gridEl = this.gridElement;\n if (!gridEl) return;\n\n // Copy all CSS classes from grid to panel (except internal ones)\n for (const className of gridEl.classList) {\n // Skip internal classes that shouldn't be copied\n if (className.startsWith('tbw-') || className === 'selecting') continue;\n panel.classList.add(className);\n }\n\n // Copy data-theme attribute if present\n const theme = gridEl.dataset.theme;\n if (theme) {\n panel.dataset.theme = theme;\n }\n }\n\n /**\n * Inject global styles for filter panel (rendered in document.body)\n */\n private injectGlobalStyles(): void {\n if (this.globalStylesInjected) return;\n if (document.getElementById('tbw-filter-panel-styles')) {\n this.globalStylesInjected = true;\n return;\n }\n // Only inject if we have valid CSS text (Vite's ?inline import)\n // When importing from source without Vite, the import is a module object, not a string\n if (typeof filterPanelStyles !== 'string' || !filterPanelStyles) {\n this.globalStylesInjected = true;\n return;\n }\n const style = document.createElement('style');\n style.id = 'tbw-filter-panel-styles';\n style.textContent = filterPanelStyles;\n document.head.appendChild(style);\n this.globalStylesInjected = true;\n }\n\n /**\n * Toggle the filter panel for a field\n */\n private toggleFilterPanel(field: string, column: ColumnConfig, buttonEl: HTMLElement): void {\n // Close if already open\n if (this.openPanelField === field) {\n this.closeFilterPanel();\n return;\n }\n\n // Close any existing panel\n this.closeFilterPanel();\n\n // Create panel\n const panel = document.createElement('div');\n panel.className = 'tbw-filter-panel';\n // Copy theme classes from grid to panel for proper theming\n this.copyGridThemeContext(panel);\n // Add animation class if animations are enabled\n if (this.isAnimationEnabled) {\n panel.classList.add('tbw-filter-panel-animated');\n }\n this.panelElement = panel;\n this.openPanelField = field;\n // Keep the filter button visible while the panel is open\n buttonEl.classList.add('active');\n this.panelButtonElement = buttonEl;\n\n // If using async valuesHandler, show loading state and fetch values\n if (this.config.valuesHandler) {\n panel.innerHTML = '<div class=\"tbw-filter-loading\">Loading...</div>';\n document.body.appendChild(panel);\n this.promoteToTopLayer(panel);\n this.positionPanel(panel, buttonEl);\n this.setupPanelCloseHandler(panel, buttonEl);\n\n this.config.valuesHandler(field, column).then((values) => {\n // Check if panel is still open for this field\n if (this.openPanelField !== field || !this.panelElement) return;\n panel.innerHTML = '';\n this.renderPanelContent(field, column, panel, values);\n });\n return;\n }\n\n // Sync path: get unique values from locally available rows (raw input or\n // plugin-managed processed rows when a plugin owns the data, e.g. ServerSide).\n const uniqueValues = getUniqueValues(this.getDataRows(), field, this.getFilterExtractor(column));\n\n // Position and append to body BEFORE rendering content\n // so getListItemHeight() can read CSS variables from computed styles.\n document.body.appendChild(panel);\n this.promoteToTopLayer(panel);\n this.positionPanel(panel, buttonEl);\n\n this.renderPanelContent(field, column, panel, uniqueValues);\n this.setupPanelCloseHandler(panel, buttonEl);\n }\n\n /**\n * Render filter panel content with given values\n */\n private renderPanelContent(field: string, column: ColumnConfig, panel: HTMLElement, uniqueValues: unknown[]): void {\n // For `in` filters, recompute excludedValues from current data if not yet populated.\n // This handles the case where setFilterModel was called before rows were loaded.\n const currentFilter = this.filters.get(field);\n if (\n currentFilter?.operator === 'in' &&\n currentFilter.type === 'set' &&\n Array.isArray(currentFilter.value) &&\n !this.excludedValues.has(field)\n ) {\n const inValues = currentFilter.value as unknown[];\n const included = new Set<unknown>(inValues.map((v) => (v == null ? BLANK_FILTER_VALUE : v)));\n const excluded = new Set<unknown>(uniqueValues.filter((v) => !included.has(v)));\n this.excludedValues.set(field, excluded);\n }\n\n // Get current excluded values or initialize empty\n let excludedSet = this.excludedValues.get(field);\n if (!excludedSet) {\n excludedSet = new Set();\n this.excludedValues.set(field, excludedSet);\n }\n\n // Get current search text\n const currentSearchText = this.searchText.get(field) ?? '';\n\n // Create panel params for custom renderer\n const params: FilterPanelParams = {\n field,\n column,\n uniqueValues,\n excludedValues: excludedSet,\n searchText: currentSearchText,\n currentFilter: this.filters.get(field),\n applySetFilter: (excluded: unknown[], valueTo?: unknown) => {\n this.applySetFilter(field, excluded, valueTo);\n this.closeFilterPanel();\n },\n applyTextFilter: (operator, value, valueTo) => {\n this.applyTextFilter(field, operator, value, valueTo);\n this.closeFilterPanel();\n },\n clearFilter: () => {\n this.clearFieldFilter(field);\n this.closeFilterPanel();\n },\n closePanel: () => this.closeFilterPanel(),\n t: this.translate,\n };\n\n // Use custom renderer or default\n // Custom renderer can return undefined to fall back to default panel for specific columns\n // Resolution order: plugin config → typeDefaults → built-in\n let usedCustomRenderer = false;\n\n // 1. Check plugin-level filterPanelRenderer\n if (this.config.filterPanelRenderer) {\n this.config.filterPanelRenderer(panel, params);\n // If renderer added content to panel, it handled rendering\n usedCustomRenderer = panel.children.length > 0;\n }\n\n // 2. Check typeDefaults for this column's type\n if (!usedCustomRenderer && column.type) {\n const typeDefault = this.grid.effectiveConfig.typeDefaults?.[column.type];\n if (typeDefault?.filterPanelRenderer) {\n typeDefault.filterPanelRenderer(panel, params);\n usedCustomRenderer = panel.children.length > 0;\n }\n }\n\n // 3. Fall back to built-in type-specific panel renderers\n // filterType takes precedence over column.type so consumers can\n // e.g. show a set filter for a numeric column.\n if (!usedCustomRenderer) {\n const columnType = column.filterType ?? column.type;\n if (columnType === 'number') {\n renderNumberFilterPanel(panel, params, uniqueValues, this.filters);\n } else if (columnType === 'date') {\n renderDateFilterPanel(panel, params, uniqueValues, this.filters);\n } else {\n renderDefaultFilterPanel(\n panel,\n params,\n uniqueValues,\n excludedSet,\n { caseSensitive: this.config.caseSensitive, debounceMs: this.config.debounceMs },\n this.searchText,\n );\n }\n }\n }\n\n /**\n * Setup click-outside handler to close the panel\n */\n private setupPanelCloseHandler(panel: HTMLElement, buttonEl: HTMLElement): void {\n // Create abort controller for panel-scoped listeners\n // This allows cleanup when panel closes OR when grid disconnects\n this.panelAbortController = new AbortController();\n const { signal } = this.panelAbortController;\n\n // Add global click handler to close on outside click\n // Defer to next tick to avoid immediate close from the click that opened the panel.\n // Capture `signal` eagerly so detach() → abort() works even if the timeout fires after\n // panelAbortController has been nullified.\n setTimeout(() => {\n if (signal.aborted) return;\n document.addEventListener(\n 'click',\n (e: MouseEvent) => {\n if (!panel.contains(e.target as Node) && e.target !== buttonEl) {\n this.closeFilterPanel();\n }\n },\n { signal },\n );\n }, 0);\n }\n\n /**\n * Promote the (already-appended) panel into the top layer via the Popover\n * API when available. Top-layer elements can resolve anchor-name from\n * anywhere in the document (including inside dialog/overlay stacking\n * contexts), fixing CSS anchor positioning when the grid is inside a CDK\n * dialog. Uses `popover=\"manual\"` so light-dismiss stays disabled and the\n * plugin's own outside-click handler remains authoritative.\n */\n private promoteToTopLayer(panel: HTMLElement): void {\n if (typeof panel.showPopover !== 'function') return;\n panel.setAttribute('popover', 'manual');\n try {\n panel.showPopover();\n } catch {\n // Not connected or already open — ignore.\n }\n }\n\n /** Hide a panel previously promoted with {@link promoteToTopLayer}. */\n private dismissTopLayer(panel: HTMLElement): void {\n if (panel.hasAttribute('popover') && typeof panel.hidePopover === 'function') {\n try {\n panel.hidePopover();\n } catch {\n // Not currently shown — ignore.\n }\n }\n }\n\n /**\n * Close the filter panel\n */\n private closeFilterPanel(): void {\n const panel = this.panelElement;\n if (panel) {\n this.dismissTopLayer(panel);\n panel.remove();\n this.panelElement = null;\n }\n // Clean up anchor name from anchor element\n if (this.panelAnchorElement) {\n (this.panelAnchorElement.style as any).anchorName = '';\n this.panelAnchorElement = null;\n }\n // Remove forced-active from the filter button (unless the column has an actual filter)\n if (this.panelButtonElement) {\n const field = this.openPanelField;\n if (!field || !this.filters.has(field)) {\n this.panelButtonElement.classList.remove('active');\n }\n this.panelButtonElement = null;\n }\n this.openPanelField = null;\n // Abort panel-scoped listeners (document click handler)\n this.panelAbortController?.abort();\n this.panelAbortController = null;\n }\n\n /** Cache for CSS anchor positioning support check */\n private static supportsAnchorPositioning: boolean | null = null;\n\n /**\n * Check if browser supports CSS Anchor Positioning\n */\n private static checkAnchorPositioningSupport(): boolean {\n if (FilteringPlugin.supportsAnchorPositioning === null) {\n FilteringPlugin.supportsAnchorPositioning = CSS.supports('anchor-name', '--test');\n }\n return FilteringPlugin.supportsAnchorPositioning;\n }\n\n /**\n * Position the panel below the filter button\n * Uses CSS Anchor Positioning if supported, falls back to JS positioning\n */\n private positionPanel(panel: HTMLElement, buttonEl: HTMLElement): void {\n // Use the header row as the vertical anchor so the panel appears\n // right below the full header, and align horizontally to the\n // filter button's right edge.\n const headerRow = buttonEl.closest('.header-row') as HTMLElement | null;\n const anchorEl = headerRow ?? buttonEl;\n\n // Set anchor name for CSS anchor positioning\n (anchorEl.style as any).anchorName = '--tbw-filter-anchor';\n this.panelAnchorElement = anchorEl; // Store for cleanup\n\n // If CSS Anchor Positioning is supported, CSS handles positioning\n // but we need to detect if it flipped above to adjust animation\n if (FilteringPlugin.checkAnchorPositioningSupport()) {\n // CSS `right: anchor(right)` needs the button as the inline anchor.\n // Use the header row for the block axis via margin offset.\n (anchorEl.style as any).anchorName = '';\n (buttonEl.style as any).anchorName = '--tbw-filter-anchor';\n this.panelAnchorElement = buttonEl;\n\n // Offset: panel top should sit at the header row bottom, not the button bottom\n const headerBottom = anchorEl.getBoundingClientRect().bottom;\n const buttonBottom = buttonEl.getBoundingClientRect().bottom;\n const offset = headerBottom - buttonBottom;\n panel.style.marginTop = `${offset + 4}px`;\n\n requestAnimationFrame(() => {\n const panelRect = panel.getBoundingClientRect();\n const anchorRect = buttonEl.getBoundingClientRect();\n if (panelRect.top < anchorRect.top) {\n panel.classList.add('tbw-filter-panel-above');\n }\n });\n return;\n }\n\n // Fallback: JS-based positioning for older browsers\n const headerRect = anchorEl.getBoundingClientRect();\n const btnRect = buttonEl.getBoundingClientRect();\n\n panel.style.position = 'fixed';\n panel.style.top = `${headerRect.bottom + 4}px`;\n\n // Right-align: panel right edge = button right edge\n // We set left initially, then adjust after measuring panel width\n panel.style.left = `${btnRect.right}px`;\n\n requestAnimationFrame(() => {\n const panelRect = panel.getBoundingClientRect();\n\n // Right-align panel to button\n panel.style.left = `${btnRect.right - panelRect.width}px`;\n\n // Check horizontal overflow - flip to left-aligned if needed\n const adjustedLeft = btnRect.right - panelRect.width;\n if (adjustedLeft < 8) {\n panel.style.left = `${btnRect.left}px`;\n }\n\n // Check vertical overflow - flip to above header row\n if (panelRect.bottom > window.innerHeight - 8) {\n panel.style.top = `${headerRect.top - panelRect.height - 4}px`;\n panel.classList.add('tbw-filter-panel-above');\n }\n });\n }\n\n /**\n * Apply a set filter from the panel's excluded (unchecked) values.\n * Preserves the original operator: if the current filter uses `in`,\n * the included values are computed and stored as `in`; otherwise `notIn` is used.\n */\n private applySetFilter(field: string, excluded: unknown[], valueTo?: unknown): void {\n // Store excluded values for panel checkbox state\n this.excludedValues.set(field, new Set(excluded));\n\n if (excluded.length === 0) {\n // No exclusions = no filter\n this.filters.delete(field);\n } else {\n // Preserve the original operator through the panel round-trip\n const currentFilter = this.filters.get(field);\n if (currentFilter?.operator === 'in') {\n // Convert excluded → included to maintain `in` semantics\n const unique = this.getUniqueValues(field);\n const excludedSet = new Set(excluded);\n const included = unique.filter((v) => !excludedSet.has(v));\n this.filters.set(field, {\n field,\n type: 'set',\n operator: 'in',\n value: included,\n ...(valueTo !== undefined && { valueTo }),\n });\n } else {\n // Default: create \"notIn\" filter\n this.filters.set(field, {\n field,\n type: 'set',\n operator: 'notIn',\n value: excluded,\n ...(valueTo !== undefined && { valueTo }),\n });\n }\n }\n\n this.applyFiltersInternal();\n }\n\n /**\n * Apply a text/number/date filter.\n * Resolves the filter type from the column definition so that numeric and\n * date operators use the correct type in the filter model.\n */\n private applyTextFilter(\n field: string,\n operator: FilterModel['operator'],\n value: string | number,\n valueTo?: string | number,\n ): void {\n const col = this.getColumnByField(field);\n const type = col?.filterType ?? (col?.type as FilterModel['type']) ?? 'text';\n\n this.filters.set(field, {\n field,\n type,\n operator,\n value,\n valueTo,\n });\n\n this.applyFiltersInternal();\n }\n\n /**\n * Internal method to apply filters (sync or async based on config)\n * @param silent - When true, suppress the `filter-change` consumer event\n */\n private applyFiltersInternal(silent?: boolean): void {\n this.invalidateCache();\n\n const filterList = [...this.filters.values()];\n\n // If using async filterHandler, delegate to server\n if (this.config.filterHandler) {\n this.gridElement.setAttribute('aria-busy', 'true');\n\n const result = this.config.filterHandler(filterList, this.sourceRows as unknown[]);\n\n // Handle async or sync result\n const handleResult = (rows: unknown[]) => {\n this.gridElement.removeAttribute('aria-busy');\n this.cachedResult = rows;\n\n // Update grid rows directly for async filtering\n this.grid.rows = rows;\n\n this.notifyFilterChange(filterList, rows.length, silent);\n\n // Trigger afterRender to update filter button active state\n this.requestRender();\n };\n\n if (result && typeof (result as Promise<unknown[]>).then === 'function') {\n (result as Promise<unknown[]>).then(handleResult);\n } else {\n handleResult(result as unknown[]);\n }\n return;\n }\n\n // Sync path: emit event and re-render (processRows will handle filtering)\n this.notifyFilterChange(filterList, 0, silent);\n this.requestRender();\n }\n // #endregion\n\n // #region Column State Hooks\n\n /**\n * Return filter state for a column if it has an active filter.\n * Only contributes state when `trackColumnState` is enabled.\n * @internal\n */\n override getColumnState(field: string): Partial<ColumnState> | undefined {\n if (!this.config.trackColumnState) return undefined;\n\n const filterModel = this.filters.get(field);\n if (!filterModel) return undefined;\n\n return {\n filter: {\n type: filterModel.type,\n operator: filterModel.operator,\n value: filterModel.value,\n valueTo: filterModel.valueTo,\n },\n };\n }\n\n /**\n * Apply filter state from column state.\n * Only applies state when `trackColumnState` is enabled.\n * @internal\n */\n override applyColumnState(field: string, state: ColumnState): void {\n if (!this.config.trackColumnState) return;\n\n // Only process if the column has filter state\n if (!state.filter) {\n this.filters.delete(field);\n return;\n }\n\n // Reconstruct the FilterModel from the stored state\n const filterModel: FilterModel = {\n field,\n type: state.filter.type,\n operator: state.filter.operator as FilterModel['operator'],\n value: state.filter.value,\n valueTo: state.filter.valueTo,\n };\n\n this.filters.set(field, filterModel);\n // Invalidate cache so filter is reapplied\n this.invalidateCache();\n }\n // #endregion\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"filtering.umd.js","names":[],"sources":["../../../../../libs/grid/src/lib/plugins/filtering/filter-model.ts","../../../../../libs/grid/src/lib/core/internal/aria.ts","../../../../../libs/grid/src/lib/core/plugin/expander-column.ts","../../../../../libs/grid/src/lib/plugins/filtering/filter-panel-date.ts","../../../../../libs/grid/src/lib/core/internal/virtualization.ts","../../../../../libs/grid/src/lib/plugins/filtering/filter-panel-default.ts","../../../../../libs/grid/src/lib/plugins/filtering/filter-panel-number.ts","../../../../../libs/grid/src/lib/plugins/filtering/filtering.css?inline","../../../../../libs/grid/src/lib/plugins/filtering/FilteringPlugin.css?inline","../../../../../libs/grid/src/lib/plugins/filtering/filtering-plugin.ts"],"sourcesContent":["/**\n * Filter Model Core Logic\n *\n * Pure functions for filtering operations.\n */\n\nimport { createFieldReader, isPlainField } from '../../core/internal/value-accessor';\nimport type { FilterModel } from './types';\n\n/**\n * Sentinel value used in set-filter unique values to represent rows with\n * no value (null, undefined, empty array via filterValue extractor).\n * Exported so server-side implementations can use the same constant.\n * @since 1.18.0\n */\nexport const BLANK_FILTER_VALUE = '(Blank)';\n\n/** A row object as seen by the filter engine. */\ntype RowRecord = Record<string, unknown>;\n\n/** A compiled filter predicate. */\ntype RowPredicate = (row: RowRecord) => boolean;\n\n/** Reads one column's raw value from a row (nested dotted paths included). */\ntype FieldReader = (row: RowRecord) => unknown;\n\n/** Resolves the comparable cell value for a row (direct or via extractor). */\ntype ValueGetter = (row: RowRecord) => unknown;\n\n/** Optional extractor for complex cell values (arrays, objects, computed columns). */\ntype FilterValueExtractor = (value: unknown, row: RowRecord) => unknown | unknown[];\n\n/**\n * Reject blanks before coercion. A value is blank when it is null, undefined,\n * '' or NaN (NaN is strictly an error state but is treated as \"no value\" here).\n * Kept intentionally loose (doesn't force a number type) so numeric strings,\n * Date objects and ISO date strings keep flowing through the coercion in\n * `>`/`<`/`toNumeric`.\n */\nfunction isBlank(value: unknown): boolean {\n return value == null || value === '' || (typeof value === 'number' && isNaN(value));\n}\n\n/**\n * Convert a value to a comparable number.\n * Handles Date objects, numeric values, and date/ISO strings.\n */\nfunction toNumeric(value: unknown): number {\n if (typeof value === 'number') return value;\n if (value instanceof Date) return value.getTime();\n const n = Number(value);\n if (!isNaN(n)) return n;\n // Try parsing as a date string (ISO 8601, etc.)\n const d = new Date(value as string);\n return d.getTime(); // NaN if unparseable\n}\n\n/**\n * Check if a single row matches a filter condition.\n *\n * Delegates to `compileFilter` to avoid duplicating operator logic. The compiled\n * predicate is created per call, so prefer `filterRows` (which pre-compiles once)\n * for bulk filtering.\n *\n * @param row - The row data object\n * @param filter - The filter to apply\n * @param caseSensitive - Whether text comparisons are case sensitive\n * @param filterValue - Optional extractor for complex cell values (arrays, objects)\n * @returns True if the row matches the filter\n */\nexport function matchesFilter(\n row: Record<string, unknown>,\n filter: FilterModel,\n caseSensitive = false,\n filterValue?: (value: unknown, row: Record<string, unknown>) => unknown | unknown[],\n): boolean {\n return compileFilter(filter, caseSensitive, filterValue)(row);\n}\n\n/**\n * Compile a single filter into a specialized predicate with pre-resolved values.\n * Avoids repeated type coercion and string conversion inside the hot loop.\n *\n * Delegates to one operator-family compiler at a time; the first that claims the\n * operator wins. Unknown operators pass every row through.\n */\nfunction compileFilter(filter: FilterModel, caseSensitive: boolean, filterValue?: FilterValueExtractor): RowPredicate {\n const field = filter.field;\n\n // Precompiled once per filter: resolves the plain / unsafe / dotted-path\n // decision up front so the per-row loop is a single monomorphic call.\n // Using it (rather than a raw `row[field]`) is what makes nested dotted\n // fields (`address.city`) filterable at all — see issue #438.\n const read = createFieldReader(field);\n\n // When a filterValue extractor is provided, use it instead of the direct\n // field read. This supports virtual/computed columns whose field doesn't\n // exist on the row data object.\n const getValue: ValueGetter = filterValue ? (row) => filterValue(read(row), row) : read;\n\n return (\n compileBlankPredicate(filter, getValue) ??\n compileSetPredicate(filter, read, filterValue) ??\n compileNumericPredicate(filter, field, getValue, filterValue === undefined) ??\n compileTextPredicate(filter, caseSensitive, getValue) ??\n // Unknown operator — pass row through\n (() => true)\n );\n}\n\n/**\n * `blank` / `notBlank` — no filter value needed.\n *\n * The blank test is inlined rather than delegating to {@link isBlank}: these are\n * the only predicates where the check IS the whole body, so the extra call would\n * be pure overhead in the per-row hot loop.\n */\nfunction compileBlankPredicate(filter: FilterModel, getValue: ValueGetter): RowPredicate | undefined {\n if (filter.operator === 'blank')\n return (row) => {\n const v = getValue(row);\n return v == null || v === '' || (typeof v === 'number' && isNaN(v));\n };\n if (filter.operator === 'notBlank')\n return (row) => {\n const v = getValue(row);\n return v != null && v !== '' && !(typeof v === 'number' && isNaN(v));\n };\n return undefined;\n}\n\n/**\n * Set operators (`in` / `notIn`) — pre-convert the filter value to a Set for\n * O(1) lookups. With a `filterValue` extractor the extracted value may be an\n * array (multi-value cells); a row that yields no values matches the\n * {@link BLANK_FILTER_VALUE} sentinel.\n */\nfunction compileSetPredicate(\n filter: FilterModel,\n read: FieldReader,\n filterValue?: FilterValueExtractor,\n): RowPredicate | undefined {\n const op = filter.operator;\n if (op !== 'in' && op !== 'notIn') return undefined;\n\n const negate = op === 'notIn';\n if (!Array.isArray(filter.value)) return negate ? () => true : () => false;\n const lookup = new Set(filter.value);\n\n if (filterValue) {\n return (row) => {\n const extracted = filterValue(read(row), row);\n const values = Array.isArray(extracted) ? extracted : extracted != null ? [extracted] : [];\n const matched = values.length === 0 ? lookup.has(BLANK_FILTER_VALUE) : values.some((v) => lookup.has(v));\n return negate ? !matched : matched;\n };\n }\n return (row) => {\n const v = read(row);\n const matched = v == null || v === '' ? lookup.has(BLANK_FILTER_VALUE) : lookup.has(v);\n return negate ? !matched : matched;\n };\n}\n\n/**\n * Numeric / date operators — pre-resolve the threshold(s) once.\n *\n * When the filter type is `'number'` and there's no custom extractor, row values\n * are expected to already be JS numbers — emit a tighter predicate that skips\n * `toNumeric()`. When a `filterValue` extractor is present, always use the\n * cautious path since the extracted value may need conversion.\n *\n * Numeric comparisons must always exclude blank values, otherwise JS coercion\n * leaks them through (e.g. `null >= 0` is `true`, `Number('') === 0`). Blank rows\n * are only matched by the explicit `blank` operator.\n */\nfunction compileNumericPredicate(\n filter: FilterModel,\n field: string,\n getValue: ValueGetter,\n noExtractor: boolean,\n): RowPredicate | undefined {\n const op = filter.operator;\n const isNumType = filter.type === 'number' && noExtractor;\n // Hot path: a declared-numeric column with no extractor and a plain top-level\n // key reads the cell **inline**. These predicate bodies are two operations\n // long, so routing the read through the reader closure measurably dominates\n // them (~8% on `filter-model.bench`). Dotted paths keep the closure — they\n // have to walk the path anyway. `plainField` non-null implies `isNumType`.\n const plainField = isNumType && isPlainField(field) ? field : null;\n\n if (op === 'greaterThan') {\n const threshold = toNumeric(filter.value);\n if (plainField)\n return (row) => {\n const v = row[plainField];\n return !isBlank(v) && (v as number) > threshold;\n };\n return isNumType\n ? (row) => {\n const v = getValue(row);\n return !isBlank(v) && (v as number) > threshold;\n }\n : (row) => {\n const v = getValue(row);\n if (isBlank(v)) return false;\n const n = toNumeric(v);\n return !isNaN(n) && n > threshold;\n };\n }\n if (op === 'greaterThanOrEqual') {\n const threshold = toNumeric(filter.value);\n if (plainField)\n return (row) => {\n const v = row[plainField];\n return !isBlank(v) && (v as number) >= threshold;\n };\n return isNumType\n ? (row) => {\n const v = getValue(row);\n return !isBlank(v) && (v as number) >= threshold;\n }\n : (row) => {\n const v = getValue(row);\n if (isBlank(v)) return false;\n const n = toNumeric(v);\n return !isNaN(n) && n >= threshold;\n };\n }\n if (op === 'lessThan') {\n const threshold = toNumeric(filter.value);\n if (plainField)\n return (row) => {\n const v = row[plainField];\n return !isBlank(v) && (v as number) < threshold;\n };\n return isNumType\n ? (row) => {\n const v = getValue(row);\n return !isBlank(v) && (v as number) < threshold;\n }\n : (row) => {\n const v = getValue(row);\n if (isBlank(v)) return false;\n const n = toNumeric(v);\n return !isNaN(n) && n < threshold;\n };\n }\n if (op === 'lessThanOrEqual') {\n const threshold = toNumeric(filter.value);\n if (plainField)\n return (row) => {\n const v = row[plainField];\n return !isBlank(v) && (v as number) <= threshold;\n };\n return isNumType\n ? (row) => {\n const v = getValue(row);\n return !isBlank(v) && (v as number) <= threshold;\n }\n : (row) => {\n const v = getValue(row);\n if (isBlank(v)) return false;\n const n = toNumeric(v);\n return !isNaN(n) && n <= threshold;\n };\n }\n if (op === 'between') {\n const lo = toNumeric(filter.value);\n const hi = toNumeric(filter.valueTo);\n if (plainField)\n return (row) => {\n const v = row[plainField];\n if (isBlank(v)) return false;\n const n = v as number;\n return n >= lo && n <= hi;\n };\n return isNumType\n ? (row) => {\n const v = getValue(row);\n if (isBlank(v)) return false;\n const n = v as number;\n return n >= lo && n <= hi;\n }\n : (row) => {\n const v = getValue(row);\n if (isBlank(v)) return false;\n const n = toNumeric(v);\n return !isNaN(n) && n >= lo && n <= hi;\n };\n }\n return undefined;\n}\n\n/**\n * Text operators — pre-resolve the filter comparison value (and its lower-cased\n * form) once, then emit a case-sensitive or case-insensitive specialization.\n */\nfunction compileTextPredicate(\n filter: FilterModel,\n caseSensitive: boolean,\n getValue: ValueGetter,\n): RowPredicate | undefined {\n const op = filter.operator;\n const compareFilterValue = caseSensitive ? String(filter.value) : String(filter.value).toLowerCase();\n\n if (op === 'contains') {\n return caseSensitive\n ? (row) => {\n const v = getValue(row);\n return v != null && String(v).includes(compareFilterValue);\n }\n : (row) => {\n const v = getValue(row);\n return v != null && String(v).toLowerCase().includes(compareFilterValue);\n };\n }\n if (op === 'notContains') {\n return caseSensitive\n ? (row) => {\n const v = getValue(row);\n return v != null && !String(v).includes(compareFilterValue);\n }\n : (row) => {\n const v = getValue(row);\n return v != null && !String(v).toLowerCase().includes(compareFilterValue);\n };\n }\n if (op === 'equals') {\n return caseSensitive\n ? (row) => {\n const v = getValue(row);\n return v != null && String(v) === compareFilterValue;\n }\n : (row) => {\n const v = getValue(row);\n return v != null && String(v).toLowerCase() === compareFilterValue;\n };\n }\n if (op === 'notEquals') {\n return caseSensitive\n ? (row) => {\n const v = getValue(row);\n return v != null && String(v) !== compareFilterValue;\n }\n : (row) => {\n const v = getValue(row);\n return v != null && String(v).toLowerCase() !== compareFilterValue;\n };\n }\n if (op === 'startsWith') {\n return caseSensitive\n ? (row) => {\n const v = getValue(row);\n return v != null && String(v).startsWith(compareFilterValue);\n }\n : (row) => {\n const v = getValue(row);\n return v != null && String(v).toLowerCase().startsWith(compareFilterValue);\n };\n }\n if (op === 'endsWith') {\n return caseSensitive\n ? (row) => {\n const v = getValue(row);\n return v != null && String(v).endsWith(compareFilterValue);\n }\n : (row) => {\n const v = getValue(row);\n return v != null && String(v).toLowerCase().endsWith(compareFilterValue);\n };\n }\n return undefined;\n}\n\n/**\n * Filter rows based on multiple filter conditions (AND logic).\n * All filters must match for a row to be included.\n *\n * Pre-compiles each filter into a specialized predicate to avoid\n * repeated type coercion and branch checks inside the hot loop.\n *\n * @param rows - The rows to filter\n * @param filters - Array of filters to apply\n * @param caseSensitive - Whether text comparisons are case sensitive\n * @param filterValues - Optional map of field → value extractor for complex columns\n * @param keep - Optional predicate for rows that bypass the filters (e.g. loading placeholders)\n * @returns Filtered rows\n */\nexport function filterRows<T extends Record<string, unknown>>(\n rows: readonly T[],\n filters: FilterModel[],\n caseSensitive = false,\n filterValues?: Map<string, (value: unknown, row: T) => unknown | unknown[]>,\n keep?: (row: T) => boolean,\n): T[] {\n if (!filters.length) return rows as T[];\n\n // Pre-compile all filters into specialized predicates\n const predicates = filters.map((f) =>\n compileFilter(\n f,\n caseSensitive,\n filterValues?.get(f.field) as ((value: unknown, row: Record<string, unknown>) => unknown | unknown[]) | undefined,\n ),\n );\n\n // Single-filter fast path avoids per-row loop overhead\n const matchAll =\n predicates.length === 1\n ? predicates[0]\n : (row: T) => {\n for (let i = 0; i < predicates.length; i++) {\n if (!predicates[i](row)) return false;\n }\n return true;\n };\n\n if (keep) return rows.filter((row) => keep(row) || matchAll(row));\n return rows.filter(matchAll);\n}\n\n/**\n * Compute a cache key for a set of filters.\n * Used for memoization of filter results.\n *\n * @param filters - Array of filters\n * @returns Stable string key for the filter set\n */\nexport function computeFilterCacheKey(filters: FilterModel[]): string {\n return JSON.stringify(\n filters.map((f) => ({\n field: f.field,\n operator: f.operator,\n value: f.value,\n valueTo: f.valueTo,\n })),\n );\n}\n\n/**\n * Extract unique values from a field across all rows.\n * Useful for populating \"set\" filter dropdowns.\n *\n * When `filterValue` is provided, the extractor is called for each row's cell value.\n * If it returns an array, each element is added individually (flattened).\n * This enables complex-valued cells (e.g., arrays of objects) to expose\n * their individual filterable values.\n *\n * @param rows - The rows to extract values from\n * @param field - The field name\n * @param filterValue - Optional extractor for complex cell values\n * @returns Sorted array of unique non-null values\n */\nexport function getUniqueValues<T extends Record<string, unknown>>(\n rows: T[],\n field: string,\n filterValue?: (value: unknown, row: T) => unknown | unknown[],\n): unknown[] {\n const values = new Set<unknown>();\n let hasBlank = false;\n const read = createFieldReader(field);\n for (const row of rows) {\n const cellValue = read(row);\n if (filterValue) {\n const extracted = filterValue(cellValue, row);\n if (Array.isArray(extracted)) {\n if (extracted.length === 0) {\n hasBlank = true;\n }\n for (const v of extracted) {\n if (v != null) values.add(v);\n }\n } else if (extracted != null) {\n values.add(extracted);\n } else {\n hasBlank = true;\n }\n } else {\n if (cellValue != null && cellValue !== '') {\n values.add(cellValue);\n } else {\n hasBlank = true;\n }\n }\n }\n // When some rows have no values (null, undefined, or empty string),\n // include a \"(Blank)\" sentinel so users can explicitly filter empty rows.\n if (hasBlank) {\n values.add(BLANK_FILTER_VALUE);\n }\n return [...values].sort((a, b) => {\n // Handle mixed types gracefully\n if (typeof a === 'number' && typeof b === 'number') {\n return a - b;\n }\n return String(a).localeCompare(String(b));\n });\n}\n\n/**\n * Extract unique values for multiple fields in a single pass through the rows.\n * This is more efficient than calling `getUniqueValues` N times when\n * computing derived state for several set filters at once.\n *\n * @param rows - The rows to extract values from\n * @param fields - Array of { field, filterValue? } descriptors\n * @returns Map of field → sorted unique values (same contract as `getUniqueValues`)\n */\nexport function getUniqueValuesBatch<T extends Record<string, unknown>>(\n rows: T[],\n fields: { field: string; filterValue?: (value: unknown, row: T) => unknown | unknown[] }[],\n): Map<string, unknown[]> {\n // Per-field accumulators\n const acc = new Map<string, { values: Set<unknown>; hasBlank: boolean; hasExtractor: boolean }>();\n const readers = new Map<string, FieldReader>();\n for (const { field, filterValue } of fields) {\n acc.set(field, { values: new Set(), hasBlank: false, hasExtractor: !!filterValue });\n readers.set(field, createFieldReader(field));\n }\n\n // Single pass through all rows\n for (const row of rows) {\n for (const { field, filterValue } of fields) {\n const entry = acc.get(field)!;\n const cellValue = readers.get(field)!(row);\n if (filterValue) {\n const extracted = filterValue(cellValue, row);\n if (Array.isArray(extracted)) {\n if (extracted.length === 0) entry.hasBlank = true;\n for (const v of extracted) {\n if (v != null) entry.values.add(v);\n }\n } else if (extracted != null) {\n entry.values.add(extracted);\n } else {\n entry.hasBlank = true;\n }\n } else {\n if (cellValue != null && cellValue !== '') {\n entry.values.add(cellValue);\n } else {\n entry.hasBlank = true;\n }\n }\n }\n }\n\n // Build sorted output\n const result = new Map<string, unknown[]>();\n for (const [field, { values, hasBlank }] of acc) {\n if (hasBlank) values.add(BLANK_FILTER_VALUE);\n result.set(\n field,\n [...values].sort((a, b) => {\n if (typeof a === 'number' && typeof b === 'number') return a - b;\n return String(a).localeCompare(String(b));\n }),\n );\n }\n return result;\n}\n","/**\n * ARIA Accessibility Helpers\n *\n * Pure functions for managing ARIA attributes on grid elements.\n * Implements caching to avoid redundant DOM writes during scroll.\n *\n * @module internal/aria\n */\n\nimport { DEFAULT_A11Y_MESSAGES } from '../defaults';\nimport type { A11yMessages, GridConfig } from '../types';\n\n// #region Types\n\n/**\n * State for caching ARIA attributes to avoid redundant DOM writes.\n */\nexport interface AriaState {\n /** Last set row count */\n rowCount: number;\n /** Last set column count */\n colCount: number;\n /** Last set aria-label */\n ariaLabel: string | undefined;\n /** Last set aria-labelledby */\n ariaLabelledBy: string | undefined;\n /** Last set aria-describedby */\n ariaDescribedBy: string | undefined;\n /** Last set aria-roledescription */\n ariaRoleDescription: string | undefined;\n /** Last source row count announced via `dataLoaded`; used to suppress duplicate announcements */\n lastAnnouncedSourceCount: number;\n}\n\n/**\n * Create initial ARIA state.\n */\nexport function createAriaState(): AriaState {\n return {\n rowCount: -1,\n colCount: -1,\n ariaLabel: undefined,\n ariaLabelledBy: undefined,\n ariaDescribedBy: undefined,\n ariaRoleDescription: undefined,\n lastAnnouncedSourceCount: -1,\n };\n}\n\n// #endregion\n\n// #region Count Updates\n\n/**\n * Update ARIA row and column counts on grid elements.\n * Uses caching to avoid redundant DOM writes on every scroll frame.\n *\n * @param state - ARIA state for caching\n * @param rowsBodyEl - Element to set aria-rowcount/aria-colcount on\n * @param bodyEl - Element to set role=\"rowgroup\" on\n * @param rowCount - Current row count\n * @param colCount - Current column count\n * @returns true if anything was updated\n */\nexport function updateAriaCounts(\n state: AriaState,\n rowsBodyEl: HTMLElement | null,\n bodyEl: HTMLElement | null,\n rowCount: number,\n colCount: number,\n): boolean {\n // Skip if nothing changed (hot path optimization for scroll)\n if (rowCount === state.rowCount && colCount === state.colCount) {\n return false;\n }\n\n const prevRowCount = state.rowCount;\n state.rowCount = rowCount;\n state.colCount = colCount;\n\n // Update ARIA counts on inner grid element\n if (rowsBodyEl) {\n rowsBodyEl.setAttribute('aria-rowcount', String(rowCount));\n rowsBodyEl.setAttribute('aria-colcount', String(colCount));\n }\n\n // Set role=\"rowgroup\" on .rows only when there are rows (ARIA compliance)\n if (rowCount !== prevRowCount && bodyEl) {\n if (rowCount > 0) {\n bodyEl.setAttribute('role', 'rowgroup');\n } else {\n bodyEl.removeAttribute('role');\n }\n }\n\n return true;\n}\n\n// #endregion\n\n// #region Label Updates\n\n/**\n * Determine the effective aria-label for the grid.\n * Priority: explicit config > shell title > nothing\n *\n * The shell title is read from `config.shell.header.title`. The shell plugin\n * folds any light-DOM `<tbw-grid-header title>` into that field via its\n * `processConfig` hook (extraction #370), so core needs no plugin state here.\n *\n * @param config - Grid configuration (the merged effective config)\n * @returns The aria-label to use, or undefined\n */\nexport function getEffectiveAriaLabel<T>(config: GridConfig<T> | undefined): string | undefined {\n const explicitLabel = config?.gridAriaLabel;\n if (explicitLabel) return explicitLabel;\n\n return config?.shell?.header?.title ?? undefined;\n}\n\n/**\n * Update ARIA label and describedby attributes on the grid container.\n * Uses caching to avoid redundant DOM writes.\n *\n * @param state - ARIA state for caching\n * @param rowsBodyEl - Element to set aria-label/aria-describedby on\n * @param config - Grid configuration (the merged effective config)\n * @returns true if anything was updated\n */\nexport function updateAriaLabels<T>(\n state: AriaState,\n rowsBodyEl: HTMLElement | null,\n config: GridConfig<T> | undefined,\n): boolean {\n if (!rowsBodyEl) return false;\n\n let updated = false;\n\n // aria-labelledby wins per WAI-ARIA accessible-name precedence — when set,\n // `aria-label` is suppressed even if explicitly configured to avoid two\n // competing names being computed by AT.\n const ariaLabelledBy = config?.gridAriaLabelledBy;\n const effectiveLabel = ariaLabelledBy ? undefined : getEffectiveAriaLabel(config);\n\n // Update aria-labelledby only if changed\n if (ariaLabelledBy !== state.ariaLabelledBy) {\n state.ariaLabelledBy = ariaLabelledBy;\n if (ariaLabelledBy) {\n rowsBodyEl.setAttribute('aria-labelledby', ariaLabelledBy);\n } else {\n rowsBodyEl.removeAttribute('aria-labelledby');\n }\n updated = true;\n }\n\n // Update aria-label only if changed\n if (effectiveLabel !== state.ariaLabel) {\n state.ariaLabel = effectiveLabel;\n if (effectiveLabel) {\n rowsBodyEl.setAttribute('aria-label', effectiveLabel);\n } else {\n rowsBodyEl.removeAttribute('aria-label');\n }\n updated = true;\n }\n\n // Update aria-describedby only if changed\n const ariaDescribedBy = config?.gridAriaDescribedBy;\n if (ariaDescribedBy !== state.ariaDescribedBy) {\n state.ariaDescribedBy = ariaDescribedBy;\n if (ariaDescribedBy) {\n rowsBodyEl.setAttribute('aria-describedby', ariaDescribedBy);\n } else {\n rowsBodyEl.removeAttribute('aria-describedby');\n }\n updated = true;\n }\n\n // Update aria-roledescription only if changed. Apps can override the\n // announced role name (e.g. localized \"Tabell\" instead of generic \"grid\").\n const ariaRoleDescription = config?.gridAriaRoleDescription;\n if (ariaRoleDescription !== state.ariaRoleDescription) {\n state.ariaRoleDescription = ariaRoleDescription;\n if (ariaRoleDescription) {\n rowsBodyEl.setAttribute('aria-roledescription', ariaRoleDescription);\n } else {\n rowsBodyEl.removeAttribute('aria-roledescription');\n }\n updated = true;\n }\n\n return updated;\n}\n\n// #endregion\n\n// #region Live Announcements\n\n/**\n * Announce a message to screen readers via the grid's aria-live region.\n * Clears then sets text to ensure repeated messages are re-announced.\n *\n * Respects `effectiveConfig.a11y.announcements` — if set to `false`,\n * the announcement is silently skipped.\n *\n * @param gridEl - The grid host element (or any ancestor containing `.tbw-sr-only`)\n * @param message - The message to announce\n */\nexport function announce(gridEl: HTMLElement, message: string): void {\n // Check if announcements are disabled via config.\n // gridEl is always a DataGridElement at runtime — use property access safely.\n if (!gridEl) return;\n const config =\n 'effectiveConfig' in gridEl\n ? (gridEl as HTMLElement & { effectiveConfig?: GridConfig }).effectiveConfig\n : undefined;\n if (config?.a11y?.announcements === false) return;\n\n const el = gridEl.querySelector?.('.tbw-sr-only');\n if (!el) return;\n // Clear first so identical consecutive messages are still announced\n el.textContent = '';\n // `requestAnimationFrame` can be undefined if a debounced announcement fires\n // after the DOM/host environment is torn down (notably in unit tests). Fall\n // back to a direct assignment so `announce` never throws out of band.\n if (typeof requestAnimationFrame === 'function') {\n requestAnimationFrame(() => {\n el.textContent = message;\n });\n } else {\n el.textContent = message;\n }\n}\n\n/**\n * Get an announcement message, preferring custom overrides from config.\n * Falls back to default English messages when no override is provided.\n *\n * @param gridEl - The grid host element\n * @param key - The message key from A11yMessages\n * @param args - Arguments to pass to the message function\n */\nexport function getA11yMessage<K extends keyof A11yMessages>(\n gridEl: HTMLElement,\n key: K,\n ...args: Parameters<Required<A11yMessages>[K]>\n): string {\n type MessageFn = (...a: Parameters<Required<A11yMessages>[K]>) => string;\n const config =\n gridEl && 'effectiveConfig' in gridEl\n ? (gridEl as HTMLElement & { effectiveConfig?: GridConfig }).effectiveConfig\n : undefined;\n const customFn = config?.a11y?.messages?.[key] as MessageFn | undefined;\n if (customFn) return customFn(...args);\n return (DEFAULT_A11Y_MESSAGES[key] as MessageFn)(...args);\n}\n\n/**\n * Announce a `dataLoaded` message — but only when the source row count has\n * actually changed since the last announcement. This prevents announcement\n * spam from internal `data-change` emits triggered by sort/filter/edit, which\n * have their own dedicated announcements.\n *\n * @param gridEl - The grid host element\n * @param state - ARIA state (tracks last announced count)\n * @param sourceRowCount - Current source row count\n */\nexport function announceDataLoaded(gridEl: HTMLElement, state: AriaState, sourceRowCount: number): void {\n if (sourceRowCount === state.lastAnnouncedSourceCount) return;\n const previousAnnouncedSourceCount = state.lastAnnouncedSourceCount;\n state.lastAnnouncedSourceCount = sourceRowCount;\n // Skip only the initial sentinel → 0 transition (empty grid mount). A later\n // transition to 0 (e.g. data cleared / reloaded to an empty result set) should\n // still announce so screen-reader users hear that the grid is now empty.\n if (sourceRowCount === 0 && previousAnnouncedSourceCount < 0) return;\n announce(gridEl, getA11yMessage(gridEl, 'dataLoaded', sourceRowCount));\n}\n\n// #endregion\n","/**\n * Shared Expander Column Utilities\n *\n * Provides a fixed expander column for plugins that need expand/collapse icons\n * (MasterDetail, Tree, RowGrouping). The column is:\n * - Always first in the grid\n * - Cannot be reordered (lockPosition: true)\n * - Has no header (empty string)\n * - Has no right border (borderless styling)\n * - Narrow width (just fits the icon)\n */\n\nimport type { ColumnConfig } from '../types';\n\n/** Special field name for the expander column */\nexport const EXPANDER_COLUMN_FIELD = '__tbw_expander';\n\n/** Default width for the expander column (pixels) */\nexport const EXPANDER_COLUMN_WIDTH = 32;\n\n/**\n * Marker interface for expander column renderers.\n * Used to detect if expander column is already present.\n */\nexport interface ExpanderColumnRenderer {\n (ctx: any): HTMLElement;\n __expanderColumn?: true;\n /** Plugin name that created this expander */\n __expanderPlugin?: string;\n}\n\n/**\n * Check if a column is an expander column.\n */\nexport function isExpanderColumn(column: ColumnConfig): boolean {\n return column.field === EXPANDER_COLUMN_FIELD;\n}\n\n/**\n * Check if a column is a utility column (excluded from selection, clipboard, etc.).\n * Utility columns are non-data columns like expander columns.\n */\nexport function isUtilityColumn(column: ColumnConfig): boolean {\n return column.utility === true;\n}\n\n/**\n * Find an existing expander column in the column array.\n */\nexport function findExpanderColumn(columns: readonly ColumnConfig[]): ColumnConfig | undefined {\n return columns.find(isExpanderColumn);\n}\n\n/**\n * Create the base expander column config.\n * Plugins should add their own renderer to customize the expand icon behavior.\n *\n * @param pluginName - Name of the plugin creating the expander (for debugging)\n * @returns Base column config for the expander column\n */\nexport function createExpanderColumnConfig(pluginName: string): ColumnConfig {\n return {\n field: EXPANDER_COLUMN_FIELD as any,\n header: '', // No header text - visually merges with next column\n width: EXPANDER_COLUMN_WIDTH,\n resizable: false,\n sortable: false,\n filterable: false, // No filter button for expander column\n lockPosition: true,\n utility: true, // Marks this as a utility column (excluded from selection, clipboard, etc.)\n meta: {\n expanderColumn: true,\n expanderPlugin: pluginName,\n },\n };\n}\n","/**\n * Date Filter Panel Renderer\n *\n * Renders a date range filter panel with from/to date inputs,\n * min/max constraints, a blank-value checkbox, and apply/clear buttons.\n */\n\nimport type { FilterModel, FilterPanelParams } from './types';\n\n// #region Helpers\n\n/**\n * Format a Date as YYYY-MM-DD for `<input type=\"date\">`.\n */\nfunction formatDateForInput(date: Date | null): string {\n if (!date) return '';\n return date.toISOString().split('T')[0];\n}\n\n/**\n * Parse a filter parameter value to a date string suitable for `<input type=\"date\">`.\n */\nfunction parseFilterParam(value: unknown): string {\n if (!value) return '';\n if (typeof value === 'string') return value;\n if (typeof value === 'number') return formatDateForInput(new Date(value));\n return '';\n}\n\n// #endregion\n\n// #region Panel Rendering\n\n/**\n * Render a date range filter panel with from/to inputs and a blank checkbox.\n *\n * Computes min/max date constraints from the data when not specified via\n * `filterParams` or `editorParams` on the column.\n *\n * @param panel - The panel container element\n * @param params - Filter panel parameters\n * @param uniqueValues - All unique values for this field\n * @param currentFilters - Map of field → FilterModel for current filter state\n */\nexport function renderDateFilterPanel(\n panel: HTMLElement,\n params: FilterPanelParams,\n uniqueValues: unknown[],\n currentFilters: Map<string, FilterModel>,\n): void {\n const { field, column } = params;\n\n // Get range configuration from filterParams, editorParams, or compute from data\n const filterParams = column.filterParams;\n const editorParams = column.editorParams as { min?: string; max?: string } | undefined;\n\n // Compute min/max from data if not specified\n const dateValues = uniqueValues\n .filter((v) => v instanceof Date || (typeof v === 'string' && !isNaN(Date.parse(v))))\n .map((v) => (v instanceof Date ? v : new Date(v as string)))\n .filter((d) => !isNaN(d.getTime()));\n\n const dataMin = dateValues.length > 0 ? new Date(Math.min(...dateValues.map((d) => d.getTime()))) : null;\n const dataMax = dateValues.length > 0 ? new Date(Math.max(...dateValues.map((d) => d.getTime()))) : null;\n\n // `inputMin` / `inputMax` constrain what the user can enter (the HTML\n // `<input type=\"date\">` min/max attributes). `filterParams` wins here so\n // consumers can widen the picker beyond what's currently in the data.\n const inputMin =\n parseFilterParam(filterParams?.min) || parseFilterParam(editorParams?.min) || formatDateForInput(dataMin);\n const inputMax =\n parseFilterParam(filterParams?.max) || parseFilterParam(editorParams?.max) || formatDateForInput(dataMax);\n\n // `defaultFrom` / `defaultTo` are the values that pre-populate the inputs\n // when no filter is active, AND the comparators used to detect a default-\n // equal submission on Apply. These must reflect the *actual data range* so\n // narrowing the inputs by a small amount actually filters rows. Falling\n // back to filterParams/editorParams only when there's no data ensures the\n // panel still functions when uniqueValues is empty (e.g. async data).\n const defaultFrom =\n formatDateForInput(dataMin) || parseFilterParam(filterParams?.min) || parseFilterParam(editorParams?.min);\n const defaultTo =\n formatDateForInput(dataMax) || parseFilterParam(filterParams?.max) || parseFilterParam(editorParams?.max);\n\n // Get current filter values if any. When no filter is set, pre-populate the\n // inputs with the data-derived defaults so the panel shows the actual data\n // range. For single-bound filters (gte/lte), keep the other input at its\n // default rather than blanking it — matches the number panel's behaviour.\n const currentFilter = currentFilters.get(field);\n let currentFrom = defaultFrom;\n let currentTo = defaultTo;\n const isBlankFilter = currentFilter?.operator === 'blank';\n if (currentFilter?.operator === 'between') {\n currentFrom = parseFilterParam(currentFilter.value) || defaultFrom;\n currentTo = parseFilterParam(currentFilter.valueTo) || defaultTo;\n } else if (currentFilter?.operator === 'greaterThanOrEqual') {\n currentFrom = parseFilterParam(currentFilter.value) || defaultFrom;\n } else if (currentFilter?.operator === 'lessThanOrEqual') {\n currentTo = parseFilterParam(currentFilter.value) || defaultTo;\n }\n // For 'blank' (and any other operator we don't recognise), keep the defaults\n // populated — the blank checkbox already conveys the active state and the\n // date inputs are disabled while it's checked.\n\n // Date range inputs container\n const rangeContainer = document.createElement('div');\n rangeContainer.className = 'tbw-filter-date-range';\n\n // From input\n const fromGroup = document.createElement('div');\n fromGroup.className = 'tbw-filter-date-group';\n\n const fromLabel = document.createElement('label');\n fromLabel.textContent = params.t('filter.from', 'From');\n fromLabel.className = 'tbw-filter-range-label';\n\n const fromInput = document.createElement('input');\n fromInput.type = 'date';\n fromInput.className = 'tbw-filter-date-input';\n if (inputMin) fromInput.min = inputMin;\n if (inputMax) fromInput.max = inputMax;\n fromInput.value = currentFrom;\n\n fromGroup.appendChild(fromLabel);\n fromGroup.appendChild(fromInput);\n rangeContainer.appendChild(fromGroup);\n\n // Separator\n const separator = document.createElement('span');\n separator.className = 'tbw-filter-range-separator';\n separator.textContent = '–';\n rangeContainer.appendChild(separator);\n\n // To input\n const toGroup = document.createElement('div');\n toGroup.className = 'tbw-filter-date-group';\n\n const toLabel = document.createElement('label');\n toLabel.textContent = 'To';\n toLabel.className = 'tbw-filter-range-label';\n\n const toInput = document.createElement('input');\n toInput.type = 'date';\n toInput.className = 'tbw-filter-date-input';\n if (inputMin) toInput.min = inputMin;\n if (inputMax) toInput.max = inputMax;\n toInput.value = currentTo;\n\n toGroup.appendChild(toLabel);\n toGroup.appendChild(toInput);\n rangeContainer.appendChild(toGroup);\n\n panel.appendChild(rangeContainer);\n\n // \"Show only blank\" checkbox\n const blankRow = document.createElement('label');\n blankRow.className = 'tbw-filter-blank-option';\n\n const blankCheckbox = document.createElement('input');\n blankCheckbox.type = 'checkbox';\n blankCheckbox.className = 'tbw-filter-blank-checkbox';\n blankCheckbox.checked = isBlankFilter;\n\n const blankLabel = document.createTextNode('Show only blank');\n blankRow.appendChild(blankCheckbox);\n blankRow.appendChild(blankLabel);\n\n // Toggle date inputs disabled state when blank is checked\n const toggleDateInputs = (disabled: boolean): void => {\n fromInput.disabled = disabled;\n toInput.disabled = disabled;\n rangeContainer.classList.toggle('tbw-filter-disabled', disabled);\n };\n toggleDateInputs(isBlankFilter);\n\n blankCheckbox.addEventListener('change', () => {\n toggleDateInputs(blankCheckbox.checked);\n });\n\n panel.appendChild(blankRow);\n\n // Apply/Clear buttons\n const buttonRow = document.createElement('div');\n buttonRow.className = 'tbw-filter-buttons';\n\n const applyBtn = document.createElement('button');\n applyBtn.type = 'button';\n applyBtn.className = 'tbw-filter-apply-btn';\n applyBtn.textContent = params.t('filter.apply', 'Apply');\n applyBtn.addEventListener('click', () => {\n if (blankCheckbox.checked) {\n params.applyTextFilter('blank', '');\n return;\n }\n\n const from = fromInput.value;\n const to = toInput.value;\n\n // Treat each bound that equals the data-derived default as \"no constraint\n // on that side\". A `from` equal to the default min is no narrower than the\n // open lower bound; same for `to` equal to the default max. Collapsing\n // these to empty here means a Both-Defaults submission becomes Clear, and\n // a Single-Defaults submission becomes a one-sided gte/lte — mirroring\n // the number panel's behaviour and avoiding silently-stored no-op filters.\n const effectiveFrom = from && from !== defaultFrom ? from : '';\n const effectiveTo = to && to !== defaultTo ? to : '';\n\n if (effectiveFrom && effectiveTo) {\n params.applyTextFilter('between', effectiveFrom, effectiveTo);\n } else if (effectiveFrom) {\n params.applyTextFilter('greaterThanOrEqual', effectiveFrom);\n } else if (effectiveTo) {\n params.applyTextFilter('lessThanOrEqual', effectiveTo);\n } else {\n params.clearFilter();\n }\n });\n buttonRow.appendChild(applyBtn);\n\n const clearBtn = document.createElement('button');\n clearBtn.type = 'button';\n clearBtn.className = 'tbw-filter-clear-btn';\n clearBtn.textContent = params.t('filter.clear', 'Clear Filter');\n clearBtn.addEventListener('click', () => {\n params.clearFilter();\n });\n buttonRow.appendChild(clearBtn);\n\n panel.appendChild(buttonRow);\n}\n\n// #endregion\n","/**\n * Row Virtualization Module\n *\n * Provides all virtualization-related functionality for the grid:\n *\n * 1. **Position Cache** (index-based): Maps row index → {offset, height, measured}\n * - Rebuilt when row count changes (expand/collapse, filter)\n * - Used for scroll position → row index lookups (binary search)\n *\n * 2. **Height Cache** (identity-based): Maps row identity → height\n * - Persists across expand/collapse\n * - Uses rowId string keys when available, WeakMap otherwise\n * - Synthetic rows use __rowCacheKey for stable identity\n *\n * 3. **Virtual Window**: Computes which rows to render based on scroll position\n *\n * 4. **Row Measurement**: Measures rendered row heights from DOM\n */\n\n// #region Types\n\n/**\n * Position entry for a single row in the position cache.\n * @category Plugin Development\n */\nexport interface RowPosition {\n /** Cumulative offset from top in pixels */\n offset: number;\n /** Row height in pixels */\n height: number;\n /** Whether this is a measured value (true) or estimate (false) */\n measured: boolean;\n}\n\n/**\n * Height cache that persists row heights across position cache rebuilds.\n * Uses dual storage: Map for string keys (rowId, __rowCacheKey) and WeakMap for object refs.\n * @category Plugin Development\n */\nexport interface HeightCache {\n /** Heights keyed by string (for synthetic rows with __rowCacheKey or rowId-keyed rows) */\n byKey: Map<string, number>;\n /** Heights keyed by object reference (for data rows without rowId) */\n byRef: WeakMap<object, number>;\n}\n\n/**\n * Configuration for the position cache.\n */\nexport interface PositionCacheConfig<T = unknown> {\n /** Function to get row ID (if configured) */\n rowId?: (row: T) => string | number;\n}\n\n// #endregion\n\n// #region Height Cache Operations\n\n/**\n * Create a new empty height cache.\n */\nexport function createHeightCache(): HeightCache {\n return {\n byKey: new Map(),\n byRef: new WeakMap(),\n };\n}\n\n/**\n * Get the cache key for a row.\n * Returns string for synthetic rows (__rowCacheKey) or rowId-keyed rows,\n * or the row object itself for WeakMap lookup.\n */\nexport function getRowCacheKey<T>(row: T, rowId?: (row: T) => string | number): string | T {\n if (!row || typeof row !== 'object') return row;\n\n // 1. Synthetic rows: plugins MUST add __rowCacheKey\n if ('__rowCacheKey' in row) {\n return (row as { __rowCacheKey: string }).__rowCacheKey;\n }\n\n // 2. rowId property directly on the row (common pattern)\n if ('rowId' in row && (row as { rowId: string | number }).rowId != null) {\n return `id:${(row as { rowId: string | number }).rowId}`;\n }\n\n // 3. User-provided rowId function (if configured)\n if (rowId) {\n return `id:${rowId(row)}`;\n }\n\n // 4. Object identity (for WeakMap)\n return row;\n}\n\n/**\n * Get cached height for a row.\n * @returns Cached height or undefined if not cached\n */\nexport function getCachedHeight<T>(\n cache: HeightCache,\n row: T,\n rowId?: (row: T) => string | number,\n): number | undefined {\n const key = getRowCacheKey(row, rowId);\n\n if (typeof key === 'string') {\n return cache.byKey.get(key);\n }\n\n // Object key - use WeakMap\n if (key && typeof key === 'object') {\n return cache.byRef.get(key);\n }\n\n return undefined;\n}\n\n/**\n * Set cached height for a row.\n */\nexport function setCachedHeight<T>(\n cache: HeightCache,\n row: T,\n height: number,\n rowId?: (row: T) => string | number,\n): void {\n const key = getRowCacheKey(row, rowId);\n\n if (typeof key === 'string') {\n cache.byKey.set(key, height);\n } else if (key && typeof key === 'object') {\n cache.byRef.set(key, height);\n }\n}\n\n// #endregion\n\n// #region Position Cache Operations\n\n/**\n * Rebuild position cache preserving known heights from height cache.\n * Called when row count changes (expand/collapse, filter, data change).\n *\n * @param rows - Array of row data\n * @param heightCache - Height cache with persisted measurements\n * @param estimatedHeight - Estimated height for unmeasured rows\n * @param config - Position cache configuration\n * @param getPluginHeight - Optional function to get height from plugins\n * @returns New position cache\n */\nexport function rebuildPositionCache<T>(\n rows: T[],\n heightCache: HeightCache,\n estimatedHeight: number,\n config: PositionCacheConfig<T>,\n getPluginHeight?: (row: T, index: number) => number | undefined,\n): RowPosition[] {\n const cache: RowPosition[] = new Array(rows.length);\n let offset = 0;\n\n for (let i = 0; i < rows.length; i++) {\n const row = rows[i];\n\n // Height resolution order:\n // 1. Plugin's getRowHeight() (for synthetic rows with known heights)\n let height = getPluginHeight?.(row, i);\n let measured = height !== undefined;\n\n // 2. Cached height from previous measurements\n if (height === undefined) {\n height = getCachedHeight(heightCache, row, config.rowId);\n measured = height !== undefined;\n }\n\n // 3. Fall back to estimate\n if (height === undefined) {\n height = estimatedHeight;\n measured = false;\n }\n\n cache[i] = { offset, height, measured };\n offset += height;\n }\n\n return cache;\n}\n\n/**\n * Update a single row's height in the position cache.\n * Recalculates offsets for all subsequent rows.\n *\n * @param cache - Position cache to update\n * @param index - Row index to update\n * @param newHeight - New measured height\n */\nexport function updateRowHeight(cache: RowPosition[], index: number, newHeight: number): void {\n if (index < 0 || index >= cache.length) return;\n\n const entry = cache[index];\n const heightDiff = newHeight - entry.height;\n\n // Latch `measured` even when the height is unchanged, otherwise callers that enqueue on\n // `!measured` re-measure the same row on every render pass.\n entry.measured = true;\n\n if (heightDiff === 0) return;\n\n // Update this row\n entry.height = newHeight;\n\n // Recalculate offsets for all subsequent rows\n for (let i = index + 1; i < cache.length; i++) {\n cache[i].offset += heightDiff;\n }\n}\n\nexport interface RowHeightChange {\n /** Row index into the position cache. */\n index: number;\n /** Measured height in pixels. */\n height: number;\n}\n\n/**\n * Update multiple row heights while recalculating the affected cache suffix once.\n *\n * @param cache - Position cache to update (mutated in place)\n * @param changes - Height changes in any order; each row index should occur at most once\n */\nexport function updateRowHeights(cache: RowPosition[], changes: RowHeightChange[]): void {\n if (changes.length === 0 || cache.length === 0) return;\n\n // Callers usually collect in DOM order, which is already ascending — skip the copy then.\n let sortedChanges = changes;\n for (let i = 1; i < changes.length; i++) {\n if (changes[i].index < changes[i - 1].index) {\n sortedChanges = [...changes].sort((a, b) => a.index - b.index);\n break;\n }\n }\n\n let changeIndex = 0;\n while (changeIndex < sortedChanges.length && sortedChanges[changeIndex].index < 0) changeIndex++;\n if (changeIndex >= sortedChanges.length || sortedChanges[changeIndex].index >= cache.length) return;\n\n let cumulativeDiff = 0;\n const firstChangedIndex = sortedChanges[changeIndex].index;\n for (let index = firstChangedIndex; index < cache.length; index++) {\n const entry = cache[index];\n entry.offset += cumulativeDiff;\n\n while (changeIndex < sortedChanges.length && sortedChanges[changeIndex].index === index) {\n const height = sortedChanges[changeIndex].height;\n const heightDiff = height - entry.height;\n entry.measured = true;\n if (heightDiff !== 0) {\n entry.height = height;\n cumulativeDiff += heightDiff;\n }\n changeIndex++;\n }\n }\n}\n\n/**\n * Get total content height from position cache.\n *\n * @param cache - Position cache\n * @returns Total height in pixels\n */\nexport function getTotalHeight(cache: RowPosition[]): number {\n if (cache.length === 0) return 0;\n const last = cache[cache.length - 1];\n return last.offset + last.height;\n}\n\n// #endregion\n\n// #region Binary Search\n\n/**\n * Find the row index at a given scroll offset using binary search.\n * Returns the index of the row that contains the given pixel offset.\n *\n * @param cache - Position cache\n * @param targetOffset - Scroll offset in pixels\n * @returns Row index at that offset, or -1 if cache is empty\n */\nexport function getRowIndexAtOffset(cache: RowPosition[], targetOffset: number): number {\n if (cache.length === 0) return -1;\n if (targetOffset <= 0) return 0;\n\n let low = 0;\n let high = cache.length - 1;\n\n while (low <= high) {\n const mid = Math.floor((low + high) / 2);\n const entry = cache[mid];\n const entryEnd = entry.offset + entry.height;\n\n if (targetOffset < entry.offset) {\n high = mid - 1;\n } else if (targetOffset >= entryEnd) {\n low = mid + 1;\n } else {\n // targetOffset is within this row\n return mid;\n }\n }\n\n // Return the closest row (low will be just past the target)\n return Math.max(0, Math.min(low, cache.length - 1));\n}\n\n// #endregion\n\n// #region Statistics\n\n/**\n * Calculate the average measured height.\n * Used for estimating unmeasured rows.\n *\n * @param cache - Position cache\n * @param defaultHeight - Default height to use if no measurements\n * @returns Average measured height\n */\nexport function calculateAverageHeight(cache: RowPosition[], defaultHeight: number): number {\n let totalHeight = 0;\n let measuredCount = 0;\n\n for (const entry of cache) {\n if (entry.measured) {\n totalHeight += entry.height;\n measuredCount++;\n }\n }\n\n return measuredCount > 0 ? totalHeight / measuredCount : defaultHeight;\n}\n\n/**\n * Count how many rows have been measured.\n *\n * @param cache - Position cache\n * @returns Number of measured rows\n */\nexport function countMeasuredRows(cache: RowPosition[]): number {\n let count = 0;\n for (const entry of cache) {\n if (entry.measured) count++;\n }\n return count;\n}\n\n// #endregion\n// #region Row Measurement\n\n/**\n * Result of measuring rendered row heights.\n */\nexport interface RowMeasurementResult {\n /** Whether any heights changed */\n hasChanges: boolean;\n /** Updated measured row count */\n measuredCount: number;\n /** Updated average height */\n averageHeight: number;\n}\n\n/**\n * Context for measuring rendered rows.\n */\nexport interface RowMeasurementContext<T> {\n /** Position cache to update */\n positionCache: RowPosition[];\n /** Height cache for persistence */\n heightCache: HeightCache;\n /** Row data array */\n rows: T[];\n /** Default row height */\n defaultHeight: number;\n /** Start index of rendered window */\n start: number;\n /** End index of rendered window (exclusive) */\n end: number;\n /** Header rows above the data rows — offsets `aria-rowindex` (row index + headerRowCount + 1) */\n headerRowCount: number;\n /** Function to get plugin height for a row */\n getPluginHeight?: (row: T, index: number) => number | undefined;\n /** Function to get row ID for height cache keying */\n getRowId?: (row: T) => string | number;\n}\n\n/**\n * Measure rendered row heights from DOM elements and update caches.\n * Returns measurement statistics for updating virtualization state.\n *\n * @param context - Measurement context with all dependencies\n * @param rowElements - NodeList of rendered row elements\n * @returns Measurement result with flags and statistics\n */\nexport function measureRenderedRowHeights<T>(\n context: RowMeasurementContext<T>,\n rowElements: NodeListOf<Element>,\n): RowMeasurementResult {\n const { positionCache, heightCache, rows, start, end, headerRowCount, getPluginHeight, getRowId } = context;\n\n let hasChanges = false;\n const heightChanges: RowHeightChange[] = [];\n\n rowElements.forEach((rowEl) => {\n // The render loop writes `aria-rowindex` on every data row; clones without it (sticky rows) are skipped.\n const ariaRowIndex = rowEl.getAttribute('aria-rowindex');\n if (!ariaRowIndex) return;\n\n const rowIndex = parseInt(ariaRowIndex, 10) - headerRowCount - 1;\n if (rowIndex < start || rowIndex >= end || rowIndex >= rows.length) return;\n\n const row = rows[rowIndex];\n\n // Check if a plugin provides the height for this row\n const pluginHeight = getPluginHeight?.(row, rowIndex);\n\n if (pluginHeight !== undefined) {\n // Plugin provides height - use it for position cache\n const currentEntry = positionCache[rowIndex];\n if (!currentEntry.measured || Math.abs(currentEntry.height - pluginHeight) > 1) {\n heightChanges.push({ index: rowIndex, height: pluginHeight });\n hasChanges = true;\n }\n return; // Don't measure DOM for plugin-managed rows\n }\n\n // No plugin height - use DOM measurement\n const measuredHeight = (rowEl as HTMLElement).offsetHeight;\n\n if (measuredHeight > 0) {\n const currentEntry = positionCache[rowIndex];\n\n // Only update if height differs significantly (> 1px to avoid oscillation)\n if (!currentEntry.measured || Math.abs(currentEntry.height - measuredHeight) > 1) {\n heightChanges.push({ index: rowIndex, height: measuredHeight });\n setCachedHeight(heightCache, row, measuredHeight, getRowId);\n hasChanges = true;\n }\n }\n });\n\n updateRowHeights(positionCache, heightChanges);\n\n // Recompute stats\n const measuredCount = hasChanges ? countMeasuredRows(positionCache) : 0;\n const averageHeight = hasChanges ? calculateAverageHeight(positionCache, context.defaultHeight) : 0;\n\n return { hasChanges, measuredCount, averageHeight };\n}\n\n/**\n * Compute measurement statistics for a position cache, excluding plugin-managed rows.\n * Used after rebuilding position cache to get accurate averages for non-plugin rows.\n *\n * @param cache - Position cache\n * @param rows - Row data array\n * @param defaultHeight - Default height if no measurements\n * @param getPluginHeight - Function to check if plugin manages row height\n * @returns Object with measured count and average height\n */\nexport function computeAverageExcludingPluginRows<T>(\n cache: RowPosition[],\n rows: T[],\n defaultHeight: number,\n getPluginHeight?: (row: T, index: number) => number | undefined,\n): { measuredCount: number; averageHeight: number } {\n let measuredCount = 0;\n let totalMeasured = 0;\n\n for (let i = 0; i < cache.length; i++) {\n const entry = cache[i];\n if (entry.measured) {\n // Only include in average if plugin doesn't provide height for this row\n const pluginHeight = getPluginHeight?.(rows[i], i);\n if (pluginHeight === undefined) {\n totalMeasured += entry.height;\n measuredCount++;\n }\n }\n }\n\n return {\n measuredCount,\n averageHeight: measuredCount > 0 ? totalMeasured / measuredCount : defaultHeight,\n };\n}\n\n// #endregion\n\n// #region Scroll Mapping (max element-height cap)\n\n/**\n * Maximum spacer element height (px) before browsers cap the rendered height.\n *\n * Chromium's hard cap is `2^25 = 33,554,432` px; Firefox/Safari are similar or larger.\n * We pick `33,500,000` — ~54 KB of headroom under Chromium's cap so that floating-point\n * drift in the fractional mapping math (`scrollTop * rMax / sMax`) cannot push a computed\n * spacer position past the engine cap and silently truncate.\n *\n * For datasets where `totalRows * rowHeight` exceeds this value, the faux-vscroll\n * spacer would silently truncate, making the tail of the dataset unreachable via\n * the native scrollbar / `Ctrl+End`. The grid switches to fractional scroll mapping\n * above this threshold — see {@link computeScrollMapping}.\n *\n * @category Plugin Development\n * @since 2.13.0\n */\nexport const MAX_ELEMENT_HEIGHT_PX = 33_500_000;\n\n/**\n * Mapping between native `scrollTop` (clamped spacer space) and \"virtual\" scroll\n * position (raw row-content space). See {@link computeScrollMapping}.\n *\n * @category Plugin Development\n * @since 2.13.0\n */\nexport interface ScrollMapping {\n /** Raw row-content height in pixels (totalRows * rowHeight or sum of variable heights). */\n rawContentHeight: number;\n /** Effective spacer DOM height — `min(rawContentHeight, maxSpacerHeight)`. */\n spacerHeight: number;\n /** Viewport height used for computing scrollable extents. */\n viewportHeight: number;\n /** True when `rawContentHeight > maxSpacerHeight` — fractional mapping is active. */\n capped: boolean;\n}\n\n/**\n * Compute a mapping between the spacer's native scrollTop and the virtual\n * row-content scroll offset.\n *\n * For datasets within `maxSpacerHeight`, mapping is identity (no transform).\n * Above the cap, the spacer is clamped and `scrollTop` units no longer equal\n * row-content units — call {@link toVirtualScrollTop} / {@link fromVirtualScrollTop}\n * to translate between the two coordinate spaces.\n *\n * @category Plugin Development\n * @since 2.13.0\n */\nexport function computeScrollMapping(\n rawContentHeight: number,\n viewportHeight: number,\n maxSpacerHeight = MAX_ELEMENT_HEIGHT_PX,\n): ScrollMapping {\n const spacerHeight = Math.min(rawContentHeight, maxSpacerHeight);\n return {\n rawContentHeight,\n spacerHeight,\n viewportHeight,\n capped: rawContentHeight > maxSpacerHeight,\n };\n}\n\n/**\n * Translate a native `scrollTop` (spacer space) into a virtual scroll offset\n * in raw row-content space. Identity when the mapping is not capped.\n *\n * The result is clamped to `[0, rawContentHeight - viewportHeight]` so callers\n * never index past the dataset, even when the spacer's actual scrollable extent\n * slightly exceeds `spacerHeight - viewportHeight` (e.g. when a horizontal scrollbar\n * adds bottom padding to the faux spacer).\n *\n * @category Plugin Development\n * @since 2.13.0\n */\nexport function toVirtualScrollTop(scrollTop: number, mapping: ScrollMapping): number {\n if (!mapping.capped) return scrollTop;\n const sMax = mapping.spacerHeight - mapping.viewportHeight;\n const rMax = mapping.rawContentHeight - mapping.viewportHeight;\n if (sMax <= 0 || rMax <= 0) return scrollTop;\n const virtual = (scrollTop * rMax) / sMax;\n if (virtual < 0) return 0;\n if (virtual > rMax) return rMax;\n return virtual;\n}\n\n/**\n * Translate a virtual scroll offset (raw row-content space) into a native\n * `scrollTop` value (spacer space). Identity when the mapping is not capped.\n *\n * @category Plugin Development\n * @since 2.13.0\n */\nexport function fromVirtualScrollTop(virtualTop: number, mapping: ScrollMapping): number {\n if (!mapping.capped) return virtualTop;\n const sMax = mapping.spacerHeight - mapping.viewportHeight;\n const rMax = mapping.rawContentHeight - mapping.viewportHeight;\n if (sMax <= 0 || rMax <= 0) return virtualTop;\n return (virtualTop * sMax) / rMax;\n}\n\n/**\n * Identity scroll mapping (no cap applied). Useful as a default in places that\n * don't yet know the dataset extents. `viewportHeight` is `0` until the first\n * `calculateTotalSpacerHeight` call populates the real extents — safe because\n * `capped: false` short-circuits both translation helpers before they read it.\n *\n * @category Plugin Development\n * @since 2.13.0\n */\nexport function createIdentityScrollMapping(): ScrollMapping {\n return { rawContentHeight: 0, spacerHeight: 0, viewportHeight: 0, capped: false };\n}\n\n// #endregion\n\n// #region Scroll Transform\n\n/**\n * Sub-pixel `translateY` for the `.rows` element at a given native scroll position.\n *\n * Bypass mode (all rows rendered) tracks the raw scroll offset directly. Virtualized mode\n * offsets only the remainder past the window's first row, and rounds that start down to an\n * even index so DOM `nth-child(even)` zebra parity keeps matching data-row parity.\n *\n * @param v Virtual state (structural subset of `VirtualState`).\n * @param rawScrollTop Native `scrollTop` of the faux scrollbar (clamped spacer space).\n */\nexport function computeRowsTranslateY(\n v: {\n rowHeight: number;\n bypassThreshold: number;\n variableHeights: boolean;\n positionCache: RowPosition[] | null;\n scrollMapping: ScrollMapping;\n },\n totalRows: number,\n rawScrollTop: number,\n): number {\n if (totalRows <= v.bypassThreshold) return -rawScrollTop;\n\n const currentScrollTop = toVirtualScrollTop(rawScrollTop, v.scrollMapping);\n const cache = v.positionCache;\n let startRowOffset: number;\n\n if (v.variableHeights && cache && cache.length > 0) {\n let rawStart = getRowIndexAtOffset(cache, currentScrollTop);\n if (rawStart === -1) rawStart = 0;\n const evenAlignedStart = rawStart - (rawStart % 2);\n startRowOffset = cache[evenAlignedStart]?.offset ?? evenAlignedStart * v.rowHeight;\n } else {\n const rawStart = Math.floor(currentScrollTop / v.rowHeight);\n startRowOffset = (rawStart - (rawStart % 2)) * v.rowHeight;\n }\n\n return -(currentScrollTop - startRowOffset);\n}\n\n// #endregion\n\n// #region Fixed-Height Virtual Window\n\n/**\n * Result of computing a virtual window for fixed-height rows.\n * Used by plugins like FilteringPlugin for virtualized dropdowns.\n */\nexport interface VirtualWindow {\n /** First row index to render (inclusive) */\n start: number;\n /** Last row index to render (exclusive) */\n end: number;\n /** Pixel offset to apply to the rows container (translateY) */\n offsetY: number;\n /**\n * Effective spacer DOM height — use this for the scrollable spacer element.\n * Equal to `min(totalRows * rowHeight, maxSpacerHeight)`. Above the cap this\n * is clamped; use {@link VirtualWindow.rawContentHeight} for the un-clamped value.\n */\n totalHeight: number;\n /**\n * Raw, un-clamped row-content height (`totalRows * rowHeight`). Equal to\n * `totalHeight` when the dataset is below `maxSpacerHeight`.\n *\n * @since 2.13.0\n */\n rawContentHeight: number;\n}\n\n/** Parameters for computing the virtual window */\nexport interface VirtualWindowParams {\n /** Total number of rows */\n totalRows: number;\n /** Height of the viewport in pixels */\n viewportHeight: number;\n /** Current scroll top position */\n scrollTop: number;\n /** Height of each row in pixels */\n rowHeight: number;\n /** Number of extra rows to render above/below viewport */\n overscan: number;\n /**\n * Optional cap on the spacer's DOM height. When `totalRows * rowHeight` exceeds\n * this value, `scrollTop` is treated as a spacer-space position and translated\n * to virtual row-content space via fractional mapping. Defaults to {@link MAX_ELEMENT_HEIGHT_PX}.\n * Pass `Infinity` to disable.\n *\n * @since 2.13.0\n */\n maxSpacerHeight?: number;\n}\n\n/**\n * Compute the virtual row window based on scroll position and viewport.\n * Simple fixed-height implementation for plugins needing basic virtualization.\n *\n * For datasets where `totalRows * rowHeight > maxSpacerHeight`, scroll position\n * is mapped fractionally so the entire dataset stays reachable past the browser's\n * single-element height cap (Chromium ~33.5M px). See {@link computeScrollMapping}.\n *\n * @param params - Parameters for computing the window\n * @returns VirtualWindow with start/end indices and transforms\n */\nexport function computeVirtualWindow(params: VirtualWindowParams): VirtualWindow {\n const { totalRows, viewportHeight, scrollTop, rowHeight, overscan, maxSpacerHeight = MAX_ELEMENT_HEIGHT_PX } = params;\n\n const visibleRows = Math.ceil(viewportHeight / rowHeight);\n const rawContentHeight = totalRows * rowHeight;\n const mapping = computeScrollMapping(rawContentHeight, viewportHeight, maxSpacerHeight);\n const virtualScrollTop = toVirtualScrollTop(scrollTop, mapping);\n\n // Render overscan rows in each direction (total window = visible + 2*overscan)\n let start = Math.floor(virtualScrollTop / rowHeight) - overscan;\n if (start < 0) start = 0;\n\n let end = start + visibleRows + overscan * 2;\n if (end > totalRows) end = totalRows;\n\n // Ensure start is adjusted if we hit the end\n if (end === totalRows && start > 0) {\n start = Math.max(0, end - visibleRows - overscan * 2);\n }\n\n return {\n start,\n end,\n offsetY: start * rowHeight,\n totalHeight: mapping.spacerHeight,\n rawContentHeight,\n };\n}\n\n/**\n * Determine if virtualization should be bypassed for small datasets.\n * When there are very few items, the overhead of virtualization isn't worth it.\n *\n * @param totalRows - Total number of items\n * @param threshold - Bypass threshold (skip virtualization if totalRows <= threshold)\n * @returns True if virtualization should be bypassed\n */\nexport function shouldBypassVirtualization(totalRows: number, threshold: number): boolean {\n return totalRows <= threshold;\n}\n\n// #endregion\n","/**\n * Default Filter Panel Renderer\n *\n * Renders the default set filter panel with search, virtualized checkbox list,\n * select-all tristate checkbox, and apply/clear buttons.\n */\n\nimport { computeVirtualWindow, shouldBypassVirtualization } from '../../core/internal/virtualization';\nimport type { FilterPanelParams } from './types';\n\n// #region Constants\n\n/** Default height for filter list items in pixels */\nconst DEFAULT_LIST_ITEM_HEIGHT = 28;\n/** Number of extra items to render above/below the viewport */\nconst LIST_OVERSCAN = 3;\n/** Don't virtualize if fewer than this many items */\nconst LIST_BYPASS_THRESHOLD = 50;\n\n// #endregion\n\n// #region Helpers\n\n/**\n * Get the item height from CSS variable or fallback to default.\n * Reads --tbw-filter-item-height from the panel element.\n */\nexport function getListItemHeight(panelElement: HTMLElement | null): number {\n if (panelElement) {\n const cssValue = getComputedStyle(panelElement).getPropertyValue('--tbw-filter-item-height');\n if (cssValue && cssValue.trim()) {\n const parsed = parseFloat(cssValue);\n if (!isNaN(parsed) && parsed > 0) {\n return parsed;\n }\n }\n }\n return DEFAULT_LIST_ITEM_HEIGHT;\n}\n\n// #endregion\n\n// #region Panel Rendering\n\n/**\n * Render the default set-filter panel content.\n *\n * Creates search input, select-all checkbox, virtualized value list, and apply/clear buttons.\n *\n * @param panel - The panel container element\n * @param params - Filter panel parameters\n * @param uniqueValues - All unique values for this field\n * @param excludedValues - Currently excluded values\n * @param config - Plugin config (caseSensitive, debounceMs)\n * @param searchTextMap - Map of field → current search text\n */\nexport function renderDefaultFilterPanel(\n panel: HTMLElement,\n params: FilterPanelParams,\n uniqueValues: unknown[],\n excludedValues: Set<unknown>,\n config: { caseSensitive?: boolean; debounceMs?: number },\n searchTextMap: Map<string, string>,\n): void {\n const { field, column } = params;\n const itemHeight = getListItemHeight(panel);\n\n // Helper: format a value using the column's format function (for ID-to-name translation, etc.)\n // When filterValue is set, unique values are already extracted primitives — skip format.\n const formatValue = (value: unknown): string => {\n if (value == null) return '(Blank)';\n if (column.format && !column.filterValue) {\n const formatted = column.format(value, undefined as never);\n if (formatted) return formatted;\n }\n return String(value);\n };\n\n // Sort unique values — numbers numerically, everything else by display name\n uniqueValues = uniqueValues.slice().sort((a, b) => {\n if (typeof a === 'number' && typeof b === 'number') return a - b;\n return formatValue(a).localeCompare(formatValue(b));\n });\n\n // Search input\n const searchContainer = document.createElement('div');\n searchContainer.className = 'tbw-filter-search';\n\n const searchInput = document.createElement('input');\n searchInput.type = 'text';\n searchInput.placeholder = params.t('filter.search', 'Search...');\n searchInput.className = 'tbw-filter-search-input';\n searchInput.value = searchTextMap.get(field) ?? '';\n searchContainer.appendChild(searchInput);\n panel.appendChild(searchContainer);\n\n // Select All tristate checkbox\n const actionsRow = document.createElement('div');\n actionsRow.className = 'tbw-filter-actions';\n\n const selectAllLabel = document.createElement('label');\n selectAllLabel.className = 'tbw-filter-value-item';\n selectAllLabel.style.padding = '0';\n selectAllLabel.style.margin = '0';\n\n const selectAllCheckbox = document.createElement('input');\n selectAllCheckbox.type = 'checkbox';\n selectAllCheckbox.className = 'tbw-filter-checkbox';\n\n const selectAllText = document.createElement('span');\n selectAllText.textContent = params.t('filter.selectAll', 'Select All');\n\n selectAllLabel.appendChild(selectAllCheckbox);\n selectAllLabel.appendChild(selectAllText);\n actionsRow.appendChild(selectAllLabel);\n\n // Track current check state for values (persists across virtualizations)\n const checkState = new Map<string, boolean>();\n uniqueValues.forEach((value) => {\n const key = value == null ? '__null__' : String(value);\n checkState.set(key, !excludedValues.has(value));\n });\n\n // Update tristate checkbox based on checkState\n const updateSelectAllState = () => {\n const values = [...checkState.values()];\n const allChecked = values.every((v) => v);\n const noneChecked = values.every((v) => !v);\n\n selectAllCheckbox.checked = allChecked;\n selectAllCheckbox.indeterminate = !allChecked && !noneChecked;\n };\n\n // Toggle all on click\n selectAllCheckbox.addEventListener('change', () => {\n const newState = selectAllCheckbox.checked;\n for (const key of checkState.keys()) {\n checkState.set(key, newState);\n }\n updateSelectAllState();\n renderVisibleItems();\n });\n\n // Initialize select all state\n updateSelectAllState();\n\n panel.appendChild(actionsRow);\n\n // Values container with virtualization support\n const valuesContainer = document.createElement('div');\n valuesContainer.className = 'tbw-filter-values';\n\n // Spacer for virtual height\n const spacer = document.createElement('div');\n spacer.className = 'tbw-filter-values-spacer';\n valuesContainer.appendChild(spacer);\n\n // Content container positioned absolutely\n const contentContainer = document.createElement('div');\n contentContainer.className = 'tbw-filter-values-content';\n valuesContainer.appendChild(contentContainer);\n\n // Filtered values cache\n let filteredValues: unknown[] = [];\n\n // Create a single checkbox item element\n const createItem = (value: unknown, index: number): HTMLElement => {\n const displayValue = formatValue(value);\n const key = value == null ? '__null__' : String(value);\n\n const item = document.createElement('label');\n item.className = 'tbw-filter-value-item';\n item.style.position = 'absolute';\n item.style.top = `calc(var(--tbw-filter-item-height, 28px) * ${index})`;\n item.style.left = '0';\n item.style.right = '0';\n item.style.boxSizing = 'border-box';\n\n const checkbox = document.createElement('input');\n checkbox.type = 'checkbox';\n checkbox.className = 'tbw-filter-checkbox';\n checkbox.checked = checkState.get(key) ?? true;\n checkbox.dataset.value = key;\n\n // Sync check state on change and update tristate checkbox\n checkbox.addEventListener('change', () => {\n checkState.set(key, checkbox.checked);\n updateSelectAllState();\n });\n\n const label = document.createElement('span');\n label.textContent = displayValue;\n\n item.appendChild(checkbox);\n item.appendChild(label);\n return item;\n };\n\n // Render visible items using virtualization\n const renderVisibleItems = () => {\n const totalItems = filteredValues.length;\n const viewportHeight = valuesContainer.clientHeight;\n const scrollTop = valuesContainer.scrollTop;\n\n // Set total height for scrollbar\n spacer.style.height = `${totalItems * itemHeight}px`;\n\n // Bypass virtualization for small lists\n if (shouldBypassVirtualization(totalItems, LIST_BYPASS_THRESHOLD / 3)) {\n contentContainer.innerHTML = '';\n contentContainer.style.transform = 'translateY(0px)';\n filteredValues.forEach((value, idx) => {\n contentContainer.appendChild(createItem(value, idx));\n });\n return;\n }\n\n // Use computeVirtualWindow for real-scroll virtualization\n const window = computeVirtualWindow({\n totalRows: totalItems,\n viewportHeight,\n scrollTop,\n rowHeight: itemHeight,\n overscan: LIST_OVERSCAN,\n });\n\n // Position content container\n contentContainer.style.transform = `translateY(${window.offsetY}px)`;\n\n // Clear and render visible items\n contentContainer.innerHTML = '';\n for (let i = window.start; i < window.end; i++) {\n contentContainer.appendChild(createItem(filteredValues[i], i - window.start));\n }\n };\n\n // Filter and re-render values\n const renderValues = (filterText: string) => {\n const caseSensitive = config.caseSensitive ?? false;\n const compareFilter = caseSensitive ? filterText : filterText.toLowerCase();\n\n // Filter the unique values - search against formatted display name\n filteredValues = uniqueValues.filter((value) => {\n const displayStr = formatValue(value);\n const compareValue = caseSensitive ? displayStr : displayStr.toLowerCase();\n return !filterText || compareValue.includes(compareFilter);\n });\n\n // Sort selected (checked) items first, then alphabetically within each group\n filteredValues.sort((a, b) => {\n const keyA = a == null ? '__null__' : String(a);\n const keyB = b == null ? '__null__' : String(b);\n const checkedA = checkState.get(keyA) ?? true;\n const checkedB = checkState.get(keyB) ?? true;\n if (checkedA !== checkedB) return checkedA ? -1 : 1;\n if (typeof a === 'number' && typeof b === 'number') return a - b;\n return formatValue(a).localeCompare(formatValue(b));\n });\n\n if (filteredValues.length === 0) {\n spacer.style.height = '0px';\n contentContainer.innerHTML = '';\n const noMatch = document.createElement('div');\n noMatch.className = 'tbw-filter-no-match';\n noMatch.textContent = params.t('filter.noMatches', 'No matching values');\n contentContainer.appendChild(noMatch);\n return;\n }\n\n renderVisibleItems();\n };\n\n // Scroll handler for virtualization\n valuesContainer.addEventListener(\n 'scroll',\n () => {\n if (filteredValues.length > 0) {\n renderVisibleItems();\n }\n },\n { passive: true },\n );\n\n renderValues(searchInput.value);\n panel.appendChild(valuesContainer);\n\n // Debounced search\n let debounceTimer: ReturnType<typeof setTimeout>;\n searchInput.addEventListener('input', () => {\n clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n searchTextMap.set(field, searchInput.value);\n renderValues(searchInput.value);\n }, config.debounceMs ?? 150);\n });\n\n // Apply/Clear buttons\n const buttonRow = document.createElement('div');\n buttonRow.className = 'tbw-filter-buttons';\n\n const applyBtn = document.createElement('button');\n applyBtn.type = 'button';\n applyBtn.className = 'tbw-filter-apply-btn';\n applyBtn.textContent = params.t('filter.apply', 'Apply');\n applyBtn.addEventListener('click', () => {\n // Read from checkState map (works with virtualization)\n const excluded: unknown[] = [];\n for (const [key, isChecked] of checkState) {\n if (!isChecked) {\n if (key === '__null__') {\n excluded.push(null);\n } else {\n // Try to match original value type\n const original = uniqueValues.find((v) => String(v) === key);\n excluded.push(original !== undefined ? original : key);\n }\n }\n }\n params.applySetFilter(excluded);\n });\n buttonRow.appendChild(applyBtn);\n\n const clearBtn = document.createElement('button');\n clearBtn.type = 'button';\n clearBtn.className = 'tbw-filter-clear-btn';\n clearBtn.textContent = params.t('filter.clear', 'Clear Filter');\n clearBtn.addEventListener('click', () => {\n params.clearFilter();\n });\n buttonRow.appendChild(clearBtn);\n\n panel.appendChild(buttonRow);\n}\n\n// #endregion\n","/**\n * Number Filter Panel Renderer\n *\n * Renders a number range filter panel with min/max inputs,\n * a dual-thumb range slider, and a blank-value checkbox.\n */\n\nimport type { FilterModel, FilterPanelParams } from './types';\n\n// #region Helpers\n\n/**\n * Convert a value to a number with a fallback.\n */\nfunction toNumber(val: unknown, fallback: number): number {\n if (typeof val === 'number') return val;\n if (typeof val === 'string') {\n const num = parseFloat(val);\n return isNaN(num) ? fallback : num;\n }\n return fallback;\n}\n\n// #endregion\n\n// #region Panel Rendering\n\n/**\n * Render a number range filter panel with min/max inputs and slider.\n *\n * Creates number inputs, a dual-thumb range slider, a blank checkbox,\n * and apply/clear buttons.\n *\n * @param panel - The panel container element\n * @param params - Filter panel parameters\n * @param uniqueValues - All unique values for this field\n * @param currentFilters - Map of field → FilterModel for current filter state\n */\nexport function renderNumberFilterPanel(\n panel: HTMLElement,\n params: FilterPanelParams,\n uniqueValues: unknown[],\n currentFilters: Map<string, FilterModel>,\n): void {\n const { field, column } = params;\n\n // Get range configuration from filterParams, editorParams, or compute from data\n const filterParams = column.filterParams;\n const editorParams = column.editorParams as { min?: number; max?: number; step?: number } | undefined;\n\n // Compute min/max from data if not specified\n const numericValues = uniqueValues.filter((v) => typeof v === 'number' && !isNaN(v)) as number[];\n const dataMin = numericValues.length > 0 ? Math.min(...numericValues) : 0;\n const dataMax = numericValues.length > 0 ? Math.max(...numericValues) : 100;\n\n // `min` / `max` define the slider/input domain (HTML min/max attributes).\n // `filterParams` wins here so consumers can widen the picker beyond the data.\n const min = toNumber(filterParams?.min ?? editorParams?.min, dataMin);\n const max = toNumber(filterParams?.max ?? editorParams?.max, dataMax);\n const step = filterParams?.step ?? editorParams?.step ?? 1;\n\n // `defaultMin` / `defaultMax` are the pre-populated values when no filter is\n // active AND the comparators used to detect a default-equal Apply. These\n // reflect the *actual data range* (clamped into the slider domain) so\n // narrowing the inputs by a small amount actually filters rows. When there's\n // no data, fall back to the slider domain.\n const defaultMin = numericValues.length > 0 ? Math.max(min, dataMin) : min;\n const defaultMax = numericValues.length > 0 ? Math.min(max, dataMax) : max;\n\n // Get current filter values if any\n const currentFilter = currentFilters.get(field);\n let currentMin = defaultMin;\n let currentMax = defaultMax;\n const isBlankFilter = currentFilter?.operator === 'blank';\n if (currentFilter?.operator === 'between') {\n currentMin = toNumber(currentFilter.value, defaultMin);\n currentMax = toNumber(currentFilter.valueTo, defaultMax);\n } else if (currentFilter?.operator === 'greaterThanOrEqual') {\n currentMin = toNumber(currentFilter.value, defaultMin);\n } else if (currentFilter?.operator === 'lessThanOrEqual') {\n currentMax = toNumber(currentFilter.value, defaultMax);\n }\n\n // Range inputs container\n const rangeContainer = document.createElement('div');\n rangeContainer.className = 'tbw-filter-range-inputs';\n\n // Min input\n const minGroup = document.createElement('div');\n minGroup.className = 'tbw-filter-range-group';\n\n const minLabel = document.createElement('label');\n minLabel.textContent = params.t('filter.min', 'Min');\n minLabel.className = 'tbw-filter-range-label';\n\n const minInput = document.createElement('input');\n minInput.type = 'number';\n minInput.className = 'tbw-filter-range-input';\n minInput.min = String(min);\n minInput.max = String(max);\n minInput.step = String(step);\n minInput.value = String(currentMin);\n\n minGroup.appendChild(minLabel);\n minGroup.appendChild(minInput);\n rangeContainer.appendChild(minGroup);\n\n // Separator\n const separator = document.createElement('span');\n separator.className = 'tbw-filter-range-separator';\n separator.textContent = '–';\n rangeContainer.appendChild(separator);\n\n // Max input\n const maxGroup = document.createElement('div');\n maxGroup.className = 'tbw-filter-range-group';\n\n const maxLabel = document.createElement('label');\n maxLabel.textContent = params.t('filter.max', 'Max');\n maxLabel.className = 'tbw-filter-range-label';\n\n const maxInput = document.createElement('input');\n maxInput.type = 'number';\n maxInput.className = 'tbw-filter-range-input';\n maxInput.min = String(min);\n maxInput.max = String(max);\n maxInput.step = String(step);\n maxInput.value = String(currentMax);\n\n maxGroup.appendChild(maxLabel);\n maxGroup.appendChild(maxInput);\n rangeContainer.appendChild(maxGroup);\n\n panel.appendChild(rangeContainer);\n\n // Range slider (dual thumb using two range inputs)\n const sliderContainer = document.createElement('div');\n sliderContainer.className = 'tbw-filter-range-slider';\n\n const sliderTrack = document.createElement('div');\n sliderTrack.className = 'tbw-filter-range-track';\n\n const sliderFill = document.createElement('div');\n sliderFill.className = 'tbw-filter-range-fill';\n\n const minSlider = document.createElement('input');\n minSlider.type = 'range';\n minSlider.className = 'tbw-filter-range-thumb tbw-filter-range-thumb-min';\n minSlider.min = String(min);\n minSlider.max = String(max);\n minSlider.step = String(step);\n minSlider.value = String(currentMin);\n\n const maxSlider = document.createElement('input');\n maxSlider.type = 'range';\n maxSlider.className = 'tbw-filter-range-thumb tbw-filter-range-thumb-max';\n maxSlider.min = String(min);\n maxSlider.max = String(max);\n maxSlider.step = String(step);\n maxSlider.value = String(currentMax);\n\n sliderContainer.appendChild(sliderTrack);\n sliderContainer.appendChild(sliderFill);\n sliderContainer.appendChild(minSlider);\n sliderContainer.appendChild(maxSlider);\n panel.appendChild(sliderContainer);\n\n // \"Blank\" checkbox — filter rows with no value in this column\n const blankRow = document.createElement('label');\n blankRow.className = 'tbw-filter-blank-option';\n\n const blankCheckbox = document.createElement('input');\n blankCheckbox.type = 'checkbox';\n blankCheckbox.className = 'tbw-filter-blank-checkbox';\n blankCheckbox.checked = isBlankFilter;\n\n const blankLabel = document.createTextNode('Blank');\n blankRow.appendChild(blankCheckbox);\n blankRow.appendChild(blankLabel);\n\n // Toggle range inputs disabled state when blank is checked\n const toggleRangeInputs = (disabled: boolean): void => {\n minInput.disabled = disabled;\n maxInput.disabled = disabled;\n minSlider.disabled = disabled;\n maxSlider.disabled = disabled;\n rangeContainer.classList.toggle('tbw-filter-disabled', disabled);\n sliderContainer.classList.toggle('tbw-filter-disabled', disabled);\n };\n toggleRangeInputs(isBlankFilter);\n\n blankCheckbox.addEventListener('change', () => {\n toggleRangeInputs(blankCheckbox.checked);\n });\n\n panel.appendChild(blankRow);\n\n // Update fill position\n const updateFill = () => {\n const minVal = parseFloat(minSlider.value);\n const maxVal = parseFloat(maxSlider.value);\n const range = max - min;\n const leftPercent = ((minVal - min) / range) * 100;\n const rightPercent = ((maxVal - min) / range) * 100;\n sliderFill.style.left = `${leftPercent}%`;\n sliderFill.style.width = `${rightPercent - leftPercent}%`;\n };\n\n // Sync inputs with sliders\n minSlider.addEventListener('input', () => {\n const val = Math.min(parseFloat(minSlider.value), parseFloat(maxSlider.value));\n minSlider.value = String(val);\n minInput.value = String(val);\n updateFill();\n });\n\n maxSlider.addEventListener('input', () => {\n const val = Math.max(parseFloat(maxSlider.value), parseFloat(minSlider.value));\n maxSlider.value = String(val);\n maxInput.value = String(val);\n updateFill();\n });\n\n // Sync sliders with inputs.\n // Use Number.isFinite(…) as the guard rather than `|| min` / `|| max` — the\n // latter silently rewrites a legitimate `0` input to the panel's lower bound\n // when the data range straddles zero (e.g. min=-100).\n minInput.addEventListener('input', () => {\n const parsed = parseFloat(minInput.value);\n let val = Number.isFinite(parsed) ? parsed : min;\n val = Math.max(min, Math.min(val, parseFloat(maxInput.value)));\n minSlider.value = String(val);\n updateFill();\n });\n\n maxInput.addEventListener('input', () => {\n const parsed = parseFloat(maxInput.value);\n let val = Number.isFinite(parsed) ? parsed : max;\n val = Math.min(max, Math.max(val, parseFloat(minInput.value)));\n maxSlider.value = String(val);\n updateFill();\n });\n\n // Initialize fill\n updateFill();\n\n // Apply/Clear buttons\n const buttonRow = document.createElement('div');\n buttonRow.className = 'tbw-filter-buttons';\n\n const applyBtn = document.createElement('button');\n applyBtn.type = 'button';\n applyBtn.className = 'tbw-filter-apply-btn';\n applyBtn.textContent = params.t('filter.apply', 'Apply');\n applyBtn.addEventListener('click', () => {\n if (blankCheckbox.checked) {\n params.applyTextFilter('blank', '');\n return;\n }\n const minVal = parseFloat(minInput.value);\n const maxVal = parseFloat(maxInput.value);\n // An empty input parses to NaN; treat that as \"not constrained\" and fall\n // back to the data-derived default. Then, when neither side has actually\n // been moved from the defaults, Apply means \"no filter\" — applying a\n // `between` over the full range would silently drop blank rows.\n const effectiveMin = Number.isFinite(minVal) ? minVal : defaultMin;\n const effectiveMax = Number.isFinite(maxVal) ? maxVal : defaultMax;\n if (effectiveMin === defaultMin && effectiveMax === defaultMax) {\n params.clearFilter();\n return;\n }\n params.applyTextFilter('between', effectiveMin, effectiveMax);\n });\n buttonRow.appendChild(applyBtn);\n\n const clearBtn = document.createElement('button');\n clearBtn.type = 'button';\n clearBtn.className = 'tbw-filter-clear-btn';\n clearBtn.textContent = params.t('filter.clear', 'Clear Filter');\n clearBtn.addEventListener('click', () => {\n params.clearFilter();\n });\n buttonRow.appendChild(clearBtn);\n\n panel.appendChild(buttonRow);\n}\n\n// #endregion\n","/**\n * Filtering Plugin Styles\n *\n * Scoped to tbw-grid using CSS nesting for light DOM compatibility.\n */\n@layer tbw-plugins {\n tbw-grid {\n /* Quick filter input in header */\n .tbw-quick-filter-input {\n flex: 1;\n max-width: 300px;\n height: var(--tbw-input-height, 1.75rem);\n padding: var(--tbw-input-padding, 0 0.5rem);\n border: 1px solid var(--tbw-color-border);\n border-radius: var(--tbw-border-radius);\n background: var(--tbw-color-bg);\n color: var(--tbw-color-fg);\n font-size: var(--tbw-font-size-sm, 0.8125rem);\n\n &:focus {\n outline: none;\n border-color: var(--tbw-color-accent);\n }\n }\n\n /* Header cell indicator when filtered */\n .header-cell.filtered::before {\n content: '';\n position: absolute;\n top: var(--tbw-spacing-xs, 0.25rem);\n right: var(--tbw-spacing-xs, 0.25rem);\n width: var(--tbw-indicator-size, 0.375rem);\n height: var(--tbw-indicator-size, 0.375rem);\n background: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n border-radius: 50%;\n }\n\n .tbw-filter-btn {\n display: var(--tbw-filter-btn-display, inline-flex);\n visibility: var(--tbw-filter-btn-visibility, visible);\n align-items: center;\n justify-content: center;\n background: transparent;\n border: none;\n cursor: pointer;\n padding: 2px;\n margin-left: var(--tbw-spacing-xs, 0.25rem);\n opacity: 0.4;\n transition:\n opacity 0.15s,\n visibility 0s,\n display 0s allow-discrete;\n color: inherit;\n vertical-align: middle;\n transition-behavior: allow-discrete;\n\n &:hover,\n &.active {\n opacity: 1;\n visibility: visible;\n display: inline-flex;\n }\n\n &.active {\n color: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n }\n }\n\n /* Show filter button on header cell hover or when filtered */\n .header-row .cell:hover .tbw-filter-btn,\n .header-row .cell.filtered .tbw-filter-btn {\n display: inline-flex;\n visibility: visible;\n }\n\n /* Coarse pointer (touch): filter button always visible and large enough to tap */\n @media (hover: none) {\n .header-row .cell .tbw-filter-btn {\n display: inline-flex;\n visibility: visible;\n }\n }\n\n /* WCAG 2.2 SC 2.5.8 Target Size (Minimum) is not pointer-conditional — the\n button carries its 24px target on a mouse too. It sits in a ~30px header,\n so this costs 4px of label width and no height. */\n .tbw-filter-btn {\n min-width: var(--tbw-touch-target-min, 24px);\n min-height: var(--tbw-touch-target-min, 24px);\n }\n }\n} /* End @layer tbw-plugins */\n","@layer tbw-plugins {\n .tbw-filter-panel {\n position: fixed;\n background: var(--tbw-filter-panel-bg, var(--tbw-color-panel-bg, light-dark(#eeeeee, #222222)));\n color: var(--tbw-filter-panel-fg, var(--tbw-color-fg, light-dark(#222222, #eeeeee)));\n border: 1px solid var(--tbw-filter-panel-border, var(--tbw-color-border, light-dark(#d0d0d4, #454545)));\n border-radius: var(--tbw-filter-panel-radius, var(--tbw-border-radius, 0.25rem));\n box-shadow: 0 4px 16px\n var(--tbw-filter-panel-shadow, var(--tbw-color-shadow, light-dark(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.3))));\n padding: var(--tbw-panel-padding, var(--tbw-spacing-lg, 0.75rem));\n z-index: 10000;\n min-width: 200px;\n max-width: 280px;\n max-height: 350px;\n display: flex;\n flex-direction: column;\n font-family: var(--tbw-font-family, system-ui, sans-serif);\n font-size: var(--tbw-font-size, 0.8125rem);\n /* Transform origin at top for drawer effect (panel below header) */\n transform-origin: top center;\n }\n\n /* When panel is positioned above header, animate from bottom */\n .tbw-filter-panel.tbw-filter-panel-above {\n transform-origin: bottom center;\n }\n\n /* Panel enter animation - controlled by class added via JS */\n .tbw-filter-panel.tbw-filter-panel-animated {\n animation: tbw-filter-panel-enter var(--tbw-animation-duration, 150ms) var(--tbw-animation-easing, ease-out);\n }\n\n /* When above header, use upward animation */\n .tbw-filter-panel.tbw-filter-panel-above.tbw-filter-panel-animated {\n animation: tbw-filter-panel-enter-above var(--tbw-animation-duration, 150ms) var(--tbw-animation-easing, ease-out);\n }\n\n /* Drawer-like animation: scales down vertically + fades, like opening from header */\n @keyframes tbw-filter-panel-enter {\n from {\n opacity: 0;\n transform: scaleY(0.3) translateY(-10px);\n }\n to {\n opacity: 1;\n transform: scaleY(1) translateY(0);\n }\n }\n\n /* Upward animation for when panel is above header */\n @keyframes tbw-filter-panel-enter-above {\n from {\n opacity: 0;\n transform: scaleY(0.3) translateY(10px);\n }\n to {\n opacity: 1;\n transform: scaleY(1) translateY(0);\n }\n }\n\n /* CSS Anchor Positioning - modern browsers */\n @supports (anchor-name: --test) {\n .tbw-filter-panel {\n position-anchor: --tbw-filter-anchor;\n /* Position below header row, right-aligned to the filter button */\n top: anchor(bottom);\n right: anchor(right);\n /* Explicitly reset the inset sides we do not use. Required because the\n browser UA stylesheet for [popover] sets `inset: 0; margin: auto`,\n leaving left: 0 and margin-inline: auto active unless we override them\n here. Without these resets the panel is incorrectly stretched/centered\n across the viewport when shown as a popover (used for top-layer\n isolation so anchor positioning works inside CDK dialogs). */\n left: auto;\n bottom: auto;\n margin-inline: 0;\n margin-top: 4px;\n /* Fallback positions when overflowing viewport */\n position-try-fallbacks:\n /* Try aligning to left edge instead */\n flip-inline,\n /* Try flipping to above the anchor */ flip-block,\n /* Try both flips */ flip-block flip-inline;\n }\n }\n\n .tbw-filter-search {\n margin-bottom: var(--tbw-panel-gap, var(--tbw-spacing-md, 0.5rem));\n min-height: var(--tbw-filter-item-height, 28px);\n }\n\n .tbw-filter-search-input {\n height: var(--tbw-filter-item-height, 28px);\n width: 100%;\n padding: var(--tbw-filter-search-padding, var(--tbw-spacing-sm, 0.375rem) var(--tbw-spacing-md, 0.5rem));\n background: var(--tbw-filter-input-bg, var(--tbw-color-bg, transparent));\n color: inherit;\n border: 1px solid var(--tbw-filter-input-border, var(--tbw-color-border, light-dark(#d0d0d4, #454545)));\n border-radius: var(--tbw-filter-input-radius, var(--tbw-border-radius, 0.25rem));\n font-size: inherit;\n box-sizing: border-box;\n }\n\n .tbw-filter-search-input:focus {\n outline: none;\n border-color: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n box-shadow: 0 0 0 2px rgba(from var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6)) r g b / 15%);\n }\n\n .tbw-filter-actions {\n display: flex;\n padding: var(--tbw-button-padding-sm, 0.25rem 0.125rem);\n margin-bottom: var(--tbw-panel-gap, var(--tbw-spacing-md, 0.5rem));\n border-bottom: 1px solid var(--tbw-filter-divider, var(--tbw-color-border, light-dark(#d0d0d4, #454545)));\n min-height: var(--tbw-filter-item-height, 28px);\n }\n .tbw-filter-actions .tbw-filter-value-item {\n flex: 1;\n }\n\n .tbw-filter-values {\n flex: 1;\n overflow-y: auto;\n margin-bottom: var(--tbw-panel-gap, var(--tbw-spacing-md, 0.5rem));\n max-height: 180px;\n position: relative;\n }\n\n .tbw-filter-values-spacer {\n width: 1px;\n }\n\n .tbw-filter-values-content {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n }\n\n .tbw-filter-value-item {\n display: flex;\n align-items: center;\n gap: var(--tbw-panel-gap, var(--tbw-spacing-md, 0.5rem));\n padding: var(--tbw-button-padding-sm, 0.25rem 0.125rem);\n cursor: pointer;\n border-radius: 3px;\n /* Item height for virtualization - used by JS for positioning calculations */\n height: var(--tbw-filter-item-height, 28px);\n }\n\n .tbw-filter-value-item:hover {\n background: var(--tbw-filter-hover, var(--tbw-color-row-hover, light-dark(#f0f6ff, #1c1c1c)));\n }\n\n .tbw-filter-checkbox {\n margin: 0;\n cursor: pointer;\n accent-color: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n }\n\n .tbw-filter-no-match {\n color: var(--tbw-filter-muted, var(--tbw-color-fg-muted, light-dark(#555555, #aaaaaa)));\n padding: var(--tbw-panel-gap, var(--tbw-spacing-md, 0.5rem)) 0;\n text-align: center;\n font-style: italic;\n }\n\n .tbw-filter-buttons {\n display: flex;\n gap: var(--tbw-panel-gap, var(--tbw-spacing-md, 0.5rem));\n padding-top: var(--tbw-panel-gap, var(--tbw-spacing-md, 0.5rem));\n border-top: 1px solid var(--tbw-filter-divider, var(--tbw-color-border, light-dark(#d0d0d4, #454545)));\n }\n\n .tbw-filter-apply-btn {\n flex: 1;\n padding: var(--tbw-filter-btn-padding, var(--tbw-button-padding, 0.375rem 0.75rem));\n background: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n color: var(--tbw-filter-accent-fg, var(--tbw-color-accent-fg, light-dark(#ffffff, #000000)));\n border: none;\n border-radius: var(--tbw-border-radius, 0.25rem);\n cursor: pointer;\n font-size: var(--tbw-font-size-sm, 0.8125rem);\n font-weight: var(--tbw-filter-btn-font-weight, 500);\n min-height: var(--tbw-filter-btn-min-height, auto);\n }\n\n .tbw-filter-apply-btn:hover {\n filter: brightness(0.9);\n }\n\n .tbw-filter-clear-btn {\n flex: 1;\n padding: var(--tbw-filter-btn-padding, var(--tbw-button-padding, 0.375rem 0.75rem));\n background: transparent;\n color: var(--tbw-filter-muted, var(--tbw-color-fg-muted, light-dark(#555555, #aaaaaa)));\n border: 1px solid var(--tbw-filter-input-border, var(--tbw-color-border, light-dark(#d0d0d4, #454545)));\n border-radius: var(--tbw-border-radius, 0.25rem);\n cursor: pointer;\n font-size: var(--tbw-font-size-sm, 0.8125rem);\n font-weight: var(--tbw-filter-btn-font-weight, 500);\n min-height: var(--tbw-filter-btn-min-height, auto);\n }\n\n .tbw-filter-clear-btn:hover {\n background: var(--tbw-filter-hover, var(--tbw-color-row-hover, light-dark(#f0f6ff, #1c1c1c)));\n }\n\n /* Range filter panel (number columns) */\n .tbw-filter-range-inputs,\n .tbw-filter-date-range {\n display: flex;\n align-items: flex-end;\n gap: var(--tbw-spacing-sm, 0.375rem);\n margin-bottom: var(--tbw-panel-gap, var(--tbw-spacing-md, 0.5rem));\n }\n\n .tbw-filter-range-group,\n .tbw-filter-date-group {\n display: flex;\n flex-direction: column;\n gap: var(--tbw-spacing-xs, 0.25rem);\n flex: 1;\n }\n\n .tbw-filter-range-label {\n font-size: var(--tbw-font-size-xs, 0.75rem);\n color: var(--tbw-filter-muted, var(--tbw-color-fg-muted, light-dark(#555555, #aaaaaa)));\n }\n\n .tbw-filter-range-input,\n .tbw-filter-date-input {\n width: 100%;\n height: var(--tbw-filter-item-height, 28px);\n padding: var(--tbw-spacing-xs, 0.25rem) var(--tbw-spacing-sm, 0.375rem);\n background: var(--tbw-filter-input-bg, var(--tbw-color-bg, transparent));\n color: inherit;\n border: 1px solid var(--tbw-filter-input-border, var(--tbw-color-border, light-dark(#d0d0d4, #454545)));\n border-radius: var(--tbw-filter-input-radius, var(--tbw-border-radius, 0.25rem));\n font-size: inherit;\n box-sizing: border-box;\n }\n\n .tbw-filter-range-input:focus,\n .tbw-filter-date-input:focus {\n outline: none;\n border-color: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n box-shadow: 0 0 0 2px rgba(from var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6)) r g b / 15%);\n }\n\n .tbw-filter-range-separator {\n color: var(--tbw-filter-muted, var(--tbw-color-fg-muted, light-dark(#555555, #aaaaaa)));\n padding-bottom: var(--tbw-spacing-xs, 0.25rem);\n }\n\n /* Blank option checkbox */\n .tbw-filter-blank-option {\n display: flex;\n align-items: center;\n gap: var(--tbw-spacing-sm, 0.375rem);\n padding: var(--tbw-spacing-xs, 0.25rem) 0;\n margin-bottom: var(--tbw-spacing-xs, 0.25rem);\n font-size: var(--tbw-font-size-sm, 0.8125rem);\n cursor: pointer;\n user-select: none;\n }\n\n .tbw-filter-blank-checkbox {\n accent-color: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n margin: 0;\n cursor: pointer;\n }\n\n /* Disabled state for date range / number range when blank is checked */\n .tbw-filter-date-range.tbw-filter-disabled,\n .tbw-filter-range-inputs.tbw-filter-disabled,\n .tbw-filter-range-slider.tbw-filter-disabled {\n opacity: 0.4;\n pointer-events: none;\n }\n\n /* Dual-thumb range slider */\n .tbw-filter-range-slider {\n position: relative;\n height: 24px;\n margin: var(--tbw-spacing-md, 0.5rem) 0 var(--tbw-panel-gap, var(--tbw-spacing-md, 0.5rem));\n }\n\n .tbw-filter-range-track {\n position: absolute;\n top: 50%;\n left: 0;\n right: 0;\n height: 4px;\n background: var(--tbw-filter-input-border, var(--tbw-color-border, light-dark(#d0d0d4, #454545)));\n border-radius: 2px;\n transform: translateY(-50%);\n }\n\n .tbw-filter-range-fill {\n position: absolute;\n top: 50%;\n height: 4px;\n background: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n border-radius: 2px;\n transform: translateY(-50%);\n }\n\n .tbw-filter-range-thumb {\n position: absolute;\n top: 0;\n width: 100%;\n height: 100%;\n background: none;\n pointer-events: none;\n -webkit-appearance: none;\n appearance: none;\n }\n\n .tbw-filter-range-thumb::-webkit-slider-thumb {\n -webkit-appearance: none;\n appearance: none;\n width: 16px;\n height: 16px;\n background: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n border: 2px solid var(--tbw-filter-panel-bg, var(--tbw-color-panel-bg, light-dark(#eeeeee, #222222)));\n border-radius: 50%;\n cursor: pointer;\n pointer-events: all;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);\n }\n\n .tbw-filter-range-thumb::-moz-range-thumb {\n width: 16px;\n height: 16px;\n background: var(--tbw-filter-accent, var(--tbw-color-accent, #3b82f6));\n border: 2px solid var(--tbw-filter-panel-bg, var(--tbw-color-panel-bg, light-dark(#eeeeee, #222222)));\n border-radius: 50%;\n cursor: pointer;\n pointer-events: all;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);\n }\n\n .tbw-filter-range-thumb::-webkit-slider-thumb:hover {\n transform: scale(1.1);\n }\n\n .tbw-filter-range-thumb::-moz-range-thumb:hover {\n transform: scale(1.1);\n }\n} /* End @layer tbw-plugins */\n","/**\n * Filtering Plugin (Class-based)\n *\n * Provides comprehensive filtering functionality for tbw-grid.\n * Supports text, number, date, set, and boolean filters with caching.\n * Includes UI with filter buttons in headers and dropdown filter panels.\n */\n\nimport { announce, getA11yMessage } from '../../core/internal/aria';\nimport { resolveCellValue } from '../../core/internal/value-accessor';\nimport { BaseGridPlugin, type GridElement, type PluginManifest, type PluginQuery } from '../../core/plugin/base-plugin';\nimport { isUtilityColumn } from '../../core/plugin/expander-column';\nimport type { ColumnConfig, ColumnState } from '../../core/types';\nimport type { ContextMenuParams, HeaderContextMenuItem } from '../context-menu/types';\nimport {\n BLANK_FILTER_VALUE,\n computeFilterCacheKey,\n filterRows,\n getUniqueValues,\n getUniqueValuesBatch,\n} from './filter-model';\nimport { renderDateFilterPanel } from './filter-panel-date';\nimport { renderDefaultFilterPanel } from './filter-panel-default';\nimport { renderNumberFilterPanel } from './filter-panel-number';\nimport styles from './filtering.css?inline';\nimport filterPanelStyles from './FilteringPlugin.css?inline';\nimport type { BlankMode, FilterChangeDetail, FilterConfig, FilterModel, FilterPanelParams } from './types';\n\n/** A data-source loading placeholder (e.g. ServerSide's `{ __loading: true, __index }`). */\nconst isLoadingRow = (row: Record<string, unknown>): boolean => row.__loading === true;\n\n/**\n * Filtering Plugin for tbw-grid\n *\n * Adds column header filters with text search, dropdown options, and custom filter panels.\n * Supports both **local filtering** for small datasets and **async handlers** for server-side\n * filtering on large datasets.\n *\n * ## Installation\n *\n * ```ts\n * import { FilteringPlugin } from '@toolbox-web/grid/plugins/filtering';\n * ```\n *\n * ## Column Configuration\n *\n * | Property | Type | Description |\n * |----------|------|-------------|\n * | `filterable` | `boolean` | Enable filtering for this column |\n * | `filterType` | `'text' \\| 'select' \\| 'number' \\| 'date'` | Filter UI type |\n * | `filterOptions` | `unknown[]` | Predefined options for select filters |\n *\n * ## CSS Custom Properties\n *\n * | Property | Default | Description |\n * |----------|---------|-------------|\n * | `--tbw-filter-panel-bg` | `var(--tbw-color-panel-bg)` | Panel background |\n * | `--tbw-filter-panel-fg` | `var(--tbw-color-fg)` | Panel text color |\n * | `--tbw-filter-panel-border` | `var(--tbw-color-border)` | Panel border |\n * | `--tbw-filter-active-color` | `var(--tbw-color-accent)` | Active filter indicator |\n * | `--tbw-filter-input-bg` | `var(--tbw-color-bg)` | Input background |\n * | `--tbw-filter-input-focus` | `var(--tbw-color-accent)` | Input focus border |\n *\n * @example Basic Usage with Filterable Columns\n * ```ts\n * import { queryGrid } from '@toolbox-web/grid';\n * import { FilteringPlugin } from '@toolbox-web/grid/plugins/filtering';\n *\n * const grid = queryGrid('tbw-grid');\n * grid.gridConfig = {\n * columns: [\n * { field: 'name', header: 'Name', filterable: true },\n * { field: 'status', header: 'Status', filterable: true, filterType: 'select' },\n * { field: 'email', header: 'Email', filterable: true },\n * ],\n * plugins: [new FilteringPlugin({ debounceMs: 300 })],\n * };\n * grid.rows = data;\n * ```\n *\n * @example Column Formatters in Filter Panel\n * When a column defines a `format` function, the built-in set filter panel\n * displays formatted labels instead of raw values. Search within the panel\n * also matches against the formatted text.\n * ```ts\n * grid.gridConfig = {\n * columns: [\n * {\n * field: 'price',\n * filterable: true,\n * format: (value) => `$${Number(value).toFixed(2)}`,\n * // Filter checkboxes show \"$9.99\" instead of \"9.99\"\n * },\n * ],\n * plugins: [new FilteringPlugin()],\n * };\n * ```\n *\n * @example Server-Side Filtering with Async Handlers\n * ```ts\n * new FilteringPlugin({\n * // Fetch unique values from server for filter dropdown\n * valuesHandler: async (field, column) => {\n * const response = await fetch(`/api/distinct-values?field=${field}`);\n * return response.json();\n * },\n * // Apply filters on the server\n * filterHandler: async (filters, currentRows) => {\n * const response = await fetch('/api/data', {\n * method: 'POST',\n * body: JSON.stringify({ filters }),\n * });\n * return response.json();\n * },\n * });\n * ```\n *\n * @see {@link FilterConfig} for all configuration options\n * @see {@link FilterModel} for filter data structure\n * @see {@link FilterPanelParams} for custom panel renderer parameters\n *\n * @internal Extends BaseGridPlugin\n * @since 0.1.1\n */\nexport class FilteringPlugin extends BaseGridPlugin<FilterConfig> {\n /**\n * Plugin manifest - declares events emitted by this plugin.\n * @internal\n */\n static override readonly manifest: PluginManifest = {\n events: [\n {\n type: 'filter-change',\n description: 'Emitted when filter criteria change. Broadcast to both DOM consumers and plugin bus.',\n },\n ],\n queries: [\n {\n type: 'getContextMenuItems',\n description: 'Contributes filter-related items to the header context menu',\n },\n ],\n };\n\n /** @internal */\n readonly name = 'filtering';\n /** @internal */\n override readonly styles = styles;\n\n /** @internal */\n protected override get defaultConfig(): Partial<FilterConfig> {\n return {\n debounceMs: 300,\n caseSensitive: false,\n };\n }\n\n // #region Helpers\n\n /**\n * Check if filtering is enabled at the grid level.\n * Grid-wide `filterable: false` disables filtering for all columns.\n */\n private isFilteringEnabled(): boolean {\n return this.grid.effectiveConfig?.filterable !== false;\n }\n\n /**\n * Check if a specific column is filterable, respecting both grid-level and column-level settings.\n */\n private isColumnFilterable(col: { filterable?: boolean; field?: string }): boolean {\n if (!this.isFilteringEnabled()) return false;\n return col.filterable !== false;\n }\n\n /**\n * Resolve the row set used to compute filter unique values.\n *\n * Default source is `sourceRows` (raw user input — `grid.#rows`). When a plugin\n * owns the data instead (e.g. `ServerSidePlugin` returns its own `managedNodes`\n * from `processRows`), `sourceRows` is empty and we fall back to the processed\n * `grid.rows` with placeholder rows excluded. This lets users filter by values\n * that have actually been loaded so far without having to wire a `valuesHandler`.\n */\n private getDataRows(): Record<string, unknown>[] {\n const source = this.sourceRows as Record<string, unknown>[];\n if (source.length > 0) return source;\n const processed = this.rows as Record<string, unknown>[];\n if (processed.length === 0) return processed;\n // Skip placeholder rows (e.g. ServerSidePlugin's `{ __loading: true, __index }`).\n return processed.filter((r) => r != null && (r as { __loading?: unknown }).__loading !== true);\n }\n\n /**\n * Build a map of field → filterValue extractor for columns that have one.\n * Falls back to {@link ColumnConfig.valueAccessor} when `filterValue` is\n * absent, so accessor-defined columns filter correctly out of the box.\n * Documented precedence: filterValue → valueAccessor → row[field].\n * Used to pass array-aware value extraction to the pure filter functions.\n */\n private getFilterValues():\n Map<string, (value: unknown, row: Record<string, unknown>) => unknown | unknown[]> | undefined {\n const columns = this.grid.effectiveConfig?.columns;\n if (!columns) return undefined;\n\n let map: Map<string, (value: unknown, row: Record<string, unknown>) => unknown | unknown[]> | undefined;\n for (const col of columns) {\n if (!col.field) continue;\n const extractor = this.getFilterExtractor(col);\n if (extractor) {\n if (!map) map = new Map();\n map.set(col.field, extractor);\n }\n }\n return map;\n }\n\n /**\n * Resolve a per-column filter-value extractor with documented precedence:\n * `filterValue` → `valueAccessor` → undefined (callers fall back to `row[field]`).\n */\n private getFilterExtractor(\n col: ColumnConfig,\n ): ((value: unknown, row: Record<string, unknown>) => unknown | unknown[]) | undefined {\n const { filterValue } = col;\n // Loading placeholders (ServerSide under `filterMode: 'local'`) hold no data; give\n // them the same `undefined` a plain field read would, without calling consumer code.\n if (filterValue) return (v, row) => (row.__loading === true ? undefined : filterValue(v, row));\n if (col.valueAccessor) return (_v, row) => resolveCellValue(row, col);\n return undefined;\n }\n\n // #endregion\n\n // #region Internal State\n private filters: Map<string, FilterModel> = new Map();\n private cachedResult: unknown[] | null = null;\n private cacheKey: string | null = null;\n /**\n * Reference to the input rows used to compute {@link cachedResult}.\n * Upstream stages produce a new array when they reorder or reshape rows, but\n * the SAME array is passed through when nothing upstream changed it (core with\n * no sort, MultiSort with no model, ServerSide's `managedNodes`) — including\n * after an in-place mutation, so edit/revert/`cell-change` signals clear this\n * reference (see `attach`).\n */\n private cachedInputRows: readonly unknown[] | null = null;\n private openPanelField: string | null = null;\n private panelElement: HTMLElement | null = null;\n private panelAnchorElement: HTMLElement | null = null; // For CSS anchor positioning cleanup\n private panelButtonElement: HTMLElement | null = null; // Filter button that opened the panel\n private searchText: Map<string, string> = new Map();\n private excludedValues: Map<string, Set<unknown>> = new Map();\n private panelAbortController: AbortController | null = null; // For panel-scoped listeners\n private globalStylesInjected = false;\n\n /**\n * Cache of `field -> ColumnConfig` lookups, built lazily on first use after\n * the columns array reference changes. Replaces O(n) `.find((c) => c.field === field)`\n * scans on every filter operation, panel render, and unique-value extraction — noticeable\n * on grids with many columns (50+).\n */\n private columnLookup: Map<string, ColumnConfig> | null = null;\n private columnLookupRef: readonly ColumnConfig[] | null = null;\n\n /**\n * O(1) column lookup by field name. Rebuilds the cache when the underlying\n * `effectiveConfig.columns` array reference changes (the merge step always\n * produces a new array, so reference identity is a sufficient invalidation\n * signal — no need for explicit hooks).\n */\n private getColumnByField(field: string): ColumnConfig | undefined {\n const cols = this.grid.effectiveConfig?.columns as readonly ColumnConfig[] | undefined;\n if (!cols) return undefined;\n let lookup = this.columnLookup;\n if (cols !== this.columnLookupRef || !lookup) {\n lookup = new Map<string, ColumnConfig>();\n for (const c of cols) {\n if (c.field) lookup.set(c.field, c);\n }\n this.columnLookup = lookup;\n this.columnLookupRef = cols;\n }\n return lookup.get(field);\n }\n\n /**\n * Compute the inclusion (selected) map from the current filters and excluded values.\n * For `notIn` filters this is: uniqueValues \\ excludedValues.\n * For `in` filters this is the filter's own value array (no data scan needed).\n * Only includes entries for fields that have a set filter.\n */\n private computeSelected(): Record<string, unknown[]> {\n const selected: Record<string, unknown[]> = {};\n\n // Collect `notIn` fields that need unique value lookups\n const notInFields: {\n field: string;\n filterValue?: (value: unknown, row: Record<string, unknown>) => unknown | unknown[];\n }[] = [];\n\n for (const [field, filter] of this.filters) {\n if (filter.type !== 'set') continue;\n if (filter.operator === 'in' && Array.isArray(filter.value)) {\n // For `in` filters, the selected values ARE the filter values — no data scan needed\n selected[field] = filter.value;\n continue;\n }\n if (filter.operator === 'notIn') {\n const col = this.getColumnByField(field);\n notInFields.push({ field, filterValue: col ? this.getFilterExtractor(col) : undefined });\n }\n }\n\n if (notInFields.length > 0) {\n // Single pass through the resolved data rows for notIn fields only\n const uniqueMap = getUniqueValuesBatch(this.getDataRows(), notInFields);\n for (const { field } of notInFields) {\n const excluded = this.excludedValues.get(field);\n const unique = uniqueMap.get(field) ?? [];\n selected[field] = excluded ? unique.filter((v) => !excluded.has(v)) : unique;\n }\n }\n\n return selected;\n }\n\n /**\n * Sync excludedValues map from a filter model (for set filters).\n * For `notIn` filters, the excluded values are stored directly.\n * For `in` filters, the complement is computed (allUnique - inValues)\n * so that the filter panel UI can render checked/unchecked states correctly.\n */\n /**\n * Drop the memoized filtered result. Called whenever filter state or input\n * data changes, so the next {@link processRows} call recomputes.\n */\n private invalidateCache(): void {\n this.cachedResult = null;\n this.cacheKey = null;\n this.cachedInputRows = null;\n }\n\n /**\n * Emit `filter-change` to consumers and plugins, then optionally request a\n * column-state save. `silent` skips the public DOM event but still notifies\n * plugins via the internal plugin-event bus so they stay in sync.\n */\n private notifyFilterChange(filters: FilterModel[], filteredRowCount: number, silent: boolean | undefined): void {\n if (silent) {\n this.emitPluginEvent('filter-change', { filters });\n return;\n }\n this.broadcast<FilterChangeDetail>('filter-change', {\n filters,\n filteredRowCount,\n selected: this.computeSelected(),\n });\n if (this.config.trackColumnState) {\n this.grid.requestStateChange?.();\n }\n }\n\n private syncExcludedValues(field: string, filter: FilterModel | null): void {\n if (!filter) {\n this.excludedValues.delete(field);\n } else if (filter.type === 'set' && filter.operator === 'notIn' && Array.isArray(filter.value)) {\n this.excludedValues.set(field, new Set(filter.value));\n } else if (filter.type === 'set' && filter.operator === 'in' && Array.isArray(filter.value)) {\n // For `in` filters we need the complement (excluded = allUnique - included)\n // so the filter panel can render correct checkbox states.\n // This requires data to be loaded. If no rows are available yet, defer —\n // the complement will be computed lazily when the panel opens.\n const sourceRows = this.getDataRows();\n if (!sourceRows || sourceRows.length === 0) {\n this.excludedValues.delete(field);\n return;\n }\n const inValues = filter.value as unknown[];\n const included: Set<unknown> = new Set(inValues.map((v) => (v == null ? BLANK_FILTER_VALUE : v)));\n const unique = this.getUniqueValues(field);\n const excluded: Set<unknown> = new Set(unique.filter((v) => !included.has(v)));\n this.excludedValues.set(field, excluded);\n } else if (filter.type === 'set') {\n // Other set operators may have different semantics; clear for safety\n this.excludedValues.delete(field);\n }\n }\n // #endregion\n\n // #region Lifecycle\n\n /** @internal */\n override attach(grid: GridElement): void {\n super.attach(grid);\n this.injectGlobalStyles();\n // A row mutated in place keeps the input array's identity, so the input-identity\n // cache would keep showing a row that no longer matches. Forget only the input\n // ref — the async `filterHandler` path still serves `cachedResult`. Covers edits\n // (`cell-edit-committed`), rollbacks (`cell-edit-reverted`) and every\n // `updateRow`/`updateRows` (core `cell-change`, incl. undo/redo's `'history'`).\n const forgetInput = () => {\n this.cachedInputRows = null;\n };\n this.on('cell-edit-committed', forgetInput);\n this.on('cell-edit-reverted', forgetInput);\n this.gridElement?.addEventListener('cell-change', forgetInput, { signal: this.disconnectSignal });\n }\n\n /** @internal */\n override detach(): void {\n super.detach();\n this.filters.clear();\n this.invalidateCache();\n this.columnLookup = null;\n this.columnLookupRef = null;\n this.openPanelField = null;\n if (this.panelElement) {\n this.dismissTopLayer(this.panelElement);\n this.panelElement.remove();\n this.panelElement = null;\n }\n this.searchText.clear();\n this.excludedValues.clear();\n // Abort panel-scoped listeners (document click handler, etc.)\n this.panelAbortController?.abort();\n this.panelAbortController = null;\n }\n // #endregion\n\n // #region Query Handlers\n\n /**\n * Handle inter-plugin queries.\n * Contributes filter-related items to the header context menu.\n * @internal\n */\n override handleQuery(query: PluginQuery): unknown {\n if (query.type === 'filter:get-model') {\n // ServerSidePlugin enrichment: expose the active filter map keyed by field\n // for inclusion in `GetRowsParams.filterModel`. Returning `undefined` lets\n // the caller treat \"no filter\" identically to \"plugin not loaded\".\n if (!this.filters.size) return undefined;\n return Object.fromEntries(this.filters);\n }\n\n if (query.type === 'getContextMenuItems') {\n const params = query.context as ContextMenuParams;\n if (!params.isHeader) return undefined;\n\n const column = params.column as ColumnConfig;\n if (!column?.field) return undefined;\n\n // Only contribute items if filtering is enabled for this column\n if (!this.isFilteringEnabled()) return undefined;\n if (!this.isColumnFilterable(column)) return undefined;\n\n const items: HeaderContextMenuItem[] = [];\n const fieldFiltered = this.isFieldFiltered(column.field);\n const hasAnyFilter = this.filters.size > 0;\n\n if (fieldFiltered) {\n items.push({\n id: 'filtering/clear-column-filter',\n label: `Clear Filter`,\n icon: '✕',\n order: 20,\n action: () => this.clearFieldFilter(column.field),\n });\n }\n\n if (hasAnyFilter) {\n items.push({\n id: 'filtering/clear-all-filters',\n label: this.t('filter.clearAll', 'Clear All Filters'),\n icon: '✕',\n order: 21,\n disabled: !hasAnyFilter,\n action: () => this.clearAllFilters(),\n });\n }\n\n return items.length > 0 ? items : undefined;\n }\n return undefined;\n }\n // #endregion\n\n // #region Hooks\n\n /** @internal */\n override processRows(rows: readonly unknown[]): unknown[] {\n if (!this.filters.size) return rows as unknown[];\n const filterList = [...this.filters.values()];\n\n // If using async filterHandler, processRows becomes a passthrough\n // Actual filtering happens in applyFiltersAsync and rows are set directly on grid\n if (this.config.filterHandler) {\n // Return cached result if available (set by async handler)\n if (this.cachedResult) return this.cachedResult;\n // Otherwise return rows as-is (filtering happens async)\n return rows as unknown[];\n }\n\n // A ServerSide data source in `filterMode: 'server'` already applied the\n // filter (via `getRows({ filterModel })`) with its own semantics, and its\n // loading placeholders must keep their slots. In `'local'` mode the loaded\n // rows are filtered here, but placeholders pass until their block loads so\n // the scroll height (and the fetch of the next block) survives (#520).\n const dataSourceMode = this.grid.query?.<'server' | 'local'>('datasource:filter-mode', null)?.[0];\n if (dataSourceMode === 'server') return rows as unknown[];\n const keep = dataSourceMode === 'local' ? isLoadingRow : undefined;\n\n // Check cache — also verify input rows haven't changed (e.g. due to sort).\n // Reference equality covers reorders/reshapes upstream; in-place mutations keep\n // the reference, so edit/revert/`cell-change` signals clear `cachedInputRows` (see attach).\n const newCacheKey = computeFilterCacheKey(filterList);\n const inputUnchanged = rows === this.cachedInputRows;\n\n if (this.cacheKey === newCacheKey && this.cachedResult && inputUnchanged) {\n return this.cachedResult;\n }\n\n // When input rows changed, recompute excludedValues for `in` filters\n // so the filter panel checkboxes reflect newly appeared unique values.\n if (!inputUnchanged) {\n for (const [field, filter] of this.filters) {\n if (filter.type === 'set' && filter.operator === 'in') {\n this.syncExcludedValues(field, filter);\n }\n }\n }\n\n // Filter rows synchronously (worker support can be added later)\n const result = filterRows(\n rows as Record<string, unknown>[],\n filterList,\n this.config.caseSensitive,\n this.getFilterValues(),\n keep,\n );\n\n // Update cache\n this.cachedResult = result;\n this.cacheKey = newCacheKey;\n this.cachedInputRows = rows;\n\n return result;\n }\n\n /** @internal */\n override afterRender(): void {\n const gridEl = this.gridElement;\n if (!gridEl) return;\n\n // Find all header cells (using part attribute, not class)\n const headerCells = gridEl.querySelectorAll('[part~=\"header-cell\"]');\n headerCells.forEach((cell) => {\n const colIndex = cell.getAttribute('data-col');\n if (colIndex === null) return;\n\n // Use visibleColumns since data-col is the index within _visibleColumns\n const col = this.visibleColumns[parseInt(colIndex, 10)] as ColumnConfig;\n if (!col || !this.isColumnFilterable(col)) return;\n\n // Skip utility columns (expander, selection checkbox, etc.)\n if (isUtilityColumn(col)) return;\n\n const field = col.field;\n if (!field) return;\n\n const hasFilter = this.filters.has(field);\n\n // Check if button already exists\n let filterBtn = cell.querySelector('.tbw-filter-btn') as HTMLButtonElement | null;\n\n if (filterBtn) {\n // Update active state and icon of existing button\n const wasActive = filterBtn.classList.contains('active');\n filterBtn.classList.toggle('active', hasFilter);\n (cell as HTMLElement).classList.toggle('filtered', hasFilter);\n // Update aria-description for screen readers\n if (hasFilter) {\n (cell as HTMLElement).setAttribute('aria-description', 'Filtered');\n } else {\n (cell as HTMLElement).removeAttribute('aria-description');\n }\n // Update icon if active state changed\n if (wasActive !== hasFilter) {\n this.setIcon(filterBtn, hasFilter ? 'filterActive' : 'filter');\n }\n return;\n }\n\n // Create filter button\n filterBtn = document.createElement('button');\n filterBtn.type = 'button';\n filterBtn.className = 'tbw-filter-btn';\n filterBtn.setAttribute('aria-label', `Filter ${col.header ?? field}`);\n // Use grid icons configuration\n this.setIcon(filterBtn, hasFilter ? 'filterActive' : 'filter');\n\n // Mark button as active if filter exists\n if (hasFilter) {\n filterBtn.classList.add('active');\n (cell as HTMLElement).classList.add('filtered');\n (cell as HTMLElement).setAttribute('aria-description', 'Filtered');\n }\n\n filterBtn.addEventListener('click', (e) => {\n e.stopPropagation();\n this.toggleFilterPanel(field, col, filterBtn!);\n });\n\n // Insert before resize handle to maintain order: [label, sort-indicator, filter-btn, resize-handle]\n const resizeHandle = cell.querySelector('.resize-handle');\n if (resizeHandle) {\n cell.insertBefore(filterBtn, resizeHandle);\n } else {\n cell.appendChild(filterBtn);\n }\n });\n }\n // #endregion\n\n // #region Public API\n\n /**\n * Set a filter on a specific field.\n * Pass null to remove the filter.\n * @param options - `{ silent: true }` applies the filter without emitting `filter-change`\n */\n setFilter(field: string, filter: Omit<FilterModel, 'field'> | null, options?: { silent?: boolean }): void {\n if (filter === null) {\n this.filters.delete(field);\n this.syncExcludedValues(field, null);\n } else {\n const fullFilter = { ...filter, field };\n this.filters.set(field, fullFilter);\n this.syncExcludedValues(field, fullFilter);\n }\n this.invalidateCache();\n // filteredRowCount = 0 here; processRows will produce the accurate count.\n this.notifyFilterChange([...this.filters.values()], 0, options?.silent);\n if (!options?.silent) {\n const header = this.getColumnByField(field)?.header ?? field;\n announce(\n this.gridElement!,\n filter === null\n ? getA11yMessage(this.gridElement!, 'filterCleared', header)\n : getA11yMessage(this.gridElement!, 'filterApplied', header),\n );\n }\n this.requestRender();\n }\n\n /**\n * Get the current filter for a field.\n */\n getFilter(field: string): FilterModel | undefined {\n return this.filters.get(field);\n }\n\n /**\n * Get all active filters.\n */\n getFilters(): FilterModel[] {\n return [...this.filters.values()];\n }\n\n /**\n * Alias for getFilters() to match functional API naming.\n */\n getFilterModel(): FilterModel[] {\n return this.getFilters();\n }\n\n /**\n * Set filters from an array (replaces all existing filters).\n * @param options - `{ silent: true }` applies filters without emitting `filter-change`\n */\n setFilterModel(filters: FilterModel[], options?: { silent?: boolean }): void {\n this.filters.clear();\n this.excludedValues.clear();\n for (const filter of filters) {\n this.filters.set(filter.field, filter);\n this.syncExcludedValues(filter.field, filter);\n }\n this.invalidateCache();\n this.notifyFilterChange([...this.filters.values()], 0, options?.silent);\n this.requestRender();\n }\n\n /**\n * Clear all filters.\n * @param options - `{ silent: true }` clears filters without emitting `filter-change`\n */\n clearAllFilters(options?: { silent?: boolean }): void {\n this.filters.clear();\n this.excludedValues.clear();\n this.searchText.clear();\n\n this.applyFiltersInternal(options?.silent);\n if (!options?.silent) announce(this.gridElement!, getA11yMessage(this.gridElement!, 'allFiltersCleared'));\n }\n\n /**\n * Clear filter for a specific field.\n * @param options - `{ silent: true }` clears filter without emitting `filter-change`\n */\n clearFieldFilter(field: string, options?: { silent?: boolean }): void {\n this.filters.delete(field);\n this.excludedValues.delete(field);\n this.searchText.delete(field);\n\n this.applyFiltersInternal(options?.silent);\n if (!options?.silent) {\n const header = this.getColumnByField(field)?.header ?? field;\n announce(this.gridElement!, getA11yMessage(this.gridElement!, 'filterCleared', header));\n }\n }\n\n /**\n * Check if a field has an active filter.\n */\n isFieldFiltered(field: string): boolean {\n return this.filters.has(field);\n }\n\n /**\n * Get the count of filtered rows (from cache).\n */\n getFilteredRowCount(): number {\n return this.cachedResult?.length ?? this.rows.length;\n }\n\n /**\n * Get all active filters (alias for getFilters).\n */\n getActiveFilters(): FilterModel[] {\n return this.getFilters();\n }\n\n /**\n * Get unique values for a field (for set filter dropdowns).\n *\n * Prefers `sourceRows` (raw user input). When the user does not own the data\n * (e.g. `ServerSidePlugin` is active and `sourceRows` is empty), falls back\n * to the processed `rows` with placeholder/loading rows excluded so users get\n * filter values for whatever has been loaded so far.\n *\n * When a column has `filterValue`, individual extracted values are returned.\n */\n getUniqueValues(field: string): unknown[] {\n const col = this.getColumnByField(field);\n const getter = col?.filterValue;\n return getUniqueValues(this.getDataRows(), field, getter);\n }\n\n // #region Stale Filter Detection (#166)\n\n /**\n * Get set filters whose exclusion/inclusion list no longer matches any values\n * in the current data, effectively selecting zero rows.\n *\n * - `notIn`: stale when every current unique value is in the exclusion list (selected count = 0)\n * - `in`: stale when none of the included values exist in the current data\n */\n getStaleFilters(): FilterModel[] {\n const stale: FilterModel[] = [];\n for (const [field, filter] of this.filters) {\n if (filter.type !== 'set') continue;\n const unique = this.getUniqueValues(field);\n\n if (filter.operator === 'notIn' && Array.isArray(filter.value)) {\n const excluded = new Set(filter.value);\n const selectedCount = unique.filter((v) => !excluded.has(v)).length;\n if (selectedCount === 0) stale.push(filter);\n } else if (filter.operator === 'in' && Array.isArray(filter.value)) {\n const included = new Set(filter.value);\n const matchCount = unique.filter((v) => included.has(v)).length;\n if (matchCount === 0) stale.push(filter);\n }\n }\n return stale;\n }\n\n // #endregion\n\n // #region Blank Filter Toggle Helpers (#169)\n\n /**\n * Get the current blank mode for a field.\n * - `'all'` — no blank filter active\n * - `'blanksOnly'` — `blank` operator active\n * - `'nonBlanksOnly'` — `notBlank` operator active\n */\n getBlankMode(field: string): BlankMode {\n const filter = this.filters.get(field);\n if (filter?.operator === 'blank') return 'blanksOnly';\n if (filter?.operator === 'notBlank') return 'nonBlanksOnly';\n return 'all';\n }\n\n /**\n * Toggle blank filter mode for a field.\n *\n * Handles transitions:\n * - `'all'` → clears the filter entirely\n * - `'blanksOnly'` → sets `blank` operator, stashing any active filter in `valueTo`\n * - `'nonBlanksOnly'` → sets `notBlank` operator, stashing any active filter in `valueTo`\n *\n * When switching back to `'all'`, the stashed filter (if any) is restored.\n */\n toggleBlankFilter(field: string, mode: BlankMode): void {\n if (mode === 'all') {\n // Restore stashed filter if one was preserved\n const currentFilter = this.filters.get(field);\n if (currentFilter?.valueTo && typeof currentFilter.valueTo === 'object') {\n const stashed = currentFilter.valueTo as Omit<FilterModel, 'field'>;\n this.setFilter(field, stashed);\n } else {\n this.setFilter(field, null);\n }\n return;\n }\n\n const currentFilter = this.filters.get(field);\n // Stash the current non-blank filter so it can be restored\n const stash =\n currentFilter && currentFilter.operator !== 'blank' && currentFilter.operator !== 'notBlank'\n ? {\n type: currentFilter.type,\n operator: currentFilter.operator,\n value: currentFilter.value,\n valueTo: currentFilter.valueTo,\n }\n : currentFilter?.valueTo; // Preserve existing stash when toggling between blank modes\n\n this.setFilter(field, {\n type: currentFilter?.type ?? 'text',\n operator: mode === 'blanksOnly' ? 'blank' : 'notBlank',\n value: '',\n valueTo: stash,\n });\n }\n\n // #endregion\n\n // #endregion\n\n // #region Private Methods\n\n /**\n * Copy CSS classes and data attributes from grid to filter panel.\n * This ensures theme classes (e.g., .eds-theme) cascade to the panel.\n */\n private copyGridThemeContext(panel: HTMLElement): void {\n const gridEl = this.gridElement;\n if (!gridEl) return;\n\n // Copy all CSS classes from grid to panel (except internal ones)\n for (const className of gridEl.classList) {\n // Skip internal classes that shouldn't be copied\n if (className.startsWith('tbw-') || className === 'selecting') continue;\n panel.classList.add(className);\n }\n\n // Copy data-theme attribute if present\n const theme = gridEl.dataset.theme;\n if (theme) {\n panel.dataset.theme = theme;\n }\n }\n\n /**\n * Inject global styles for filter panel (rendered in document.body)\n */\n private injectGlobalStyles(): void {\n if (this.globalStylesInjected) return;\n if (document.getElementById('tbw-filter-panel-styles')) {\n this.globalStylesInjected = true;\n return;\n }\n // Only inject if we have valid CSS text (Vite's ?inline import)\n // When importing from source without Vite, the import is a module object, not a string\n if (typeof filterPanelStyles !== 'string' || !filterPanelStyles) {\n this.globalStylesInjected = true;\n return;\n }\n const style = document.createElement('style');\n style.id = 'tbw-filter-panel-styles';\n style.textContent = filterPanelStyles;\n document.head.appendChild(style);\n this.globalStylesInjected = true;\n }\n\n /**\n * Toggle the filter panel for a field\n */\n private toggleFilterPanel(field: string, column: ColumnConfig, buttonEl: HTMLElement): void {\n // Close if already open\n if (this.openPanelField === field) {\n this.closeFilterPanel();\n return;\n }\n\n // Close any existing panel\n this.closeFilterPanel();\n\n // Create panel\n const panel = document.createElement('div');\n panel.className = 'tbw-filter-panel';\n // Copy theme classes from grid to panel for proper theming\n this.copyGridThemeContext(panel);\n // Add animation class if animations are enabled\n if (this.isAnimationEnabled) {\n panel.classList.add('tbw-filter-panel-animated');\n }\n this.panelElement = panel;\n this.openPanelField = field;\n // Keep the filter button visible while the panel is open\n buttonEl.classList.add('active');\n this.panelButtonElement = buttonEl;\n\n // If using async valuesHandler, show loading state and fetch values\n if (this.config.valuesHandler) {\n panel.innerHTML = '<div class=\"tbw-filter-loading\">Loading...</div>';\n document.body.appendChild(panel);\n this.promoteToTopLayer(panel);\n this.positionPanel(panel, buttonEl);\n this.setupPanelCloseHandler(panel, buttonEl);\n\n this.config.valuesHandler(field, column).then((values) => {\n // Check if panel is still open for this field\n if (this.openPanelField !== field || !this.panelElement) return;\n panel.innerHTML = '';\n this.renderPanelContent(field, column, panel, values);\n });\n return;\n }\n\n // Sync path: get unique values from locally available rows (raw input or\n // plugin-managed processed rows when a plugin owns the data, e.g. ServerSide).\n const uniqueValues = getUniqueValues(this.getDataRows(), field, this.getFilterExtractor(column));\n\n // Position and append to body BEFORE rendering content\n // so getListItemHeight() can read CSS variables from computed styles.\n document.body.appendChild(panel);\n this.promoteToTopLayer(panel);\n this.positionPanel(panel, buttonEl);\n\n this.renderPanelContent(field, column, panel, uniqueValues);\n this.setupPanelCloseHandler(panel, buttonEl);\n }\n\n /**\n * Render filter panel content with given values\n */\n private renderPanelContent(field: string, column: ColumnConfig, panel: HTMLElement, uniqueValues: unknown[]): void {\n // For `in` filters, recompute excludedValues from current data if not yet populated.\n // This handles the case where setFilterModel was called before rows were loaded.\n const currentFilter = this.filters.get(field);\n if (\n currentFilter?.operator === 'in' &&\n currentFilter.type === 'set' &&\n Array.isArray(currentFilter.value) &&\n !this.excludedValues.has(field)\n ) {\n const inValues = currentFilter.value as unknown[];\n const included = new Set<unknown>(inValues.map((v) => (v == null ? BLANK_FILTER_VALUE : v)));\n const excluded = new Set<unknown>(uniqueValues.filter((v) => !included.has(v)));\n this.excludedValues.set(field, excluded);\n }\n\n // Get current excluded values or initialize empty\n let excludedSet = this.excludedValues.get(field);\n if (!excludedSet) {\n excludedSet = new Set();\n this.excludedValues.set(field, excludedSet);\n }\n\n // Get current search text\n const currentSearchText = this.searchText.get(field) ?? '';\n\n // Create panel params for custom renderer\n const params: FilterPanelParams = {\n field,\n column,\n uniqueValues,\n excludedValues: excludedSet,\n searchText: currentSearchText,\n currentFilter: this.filters.get(field),\n applySetFilter: (excluded: unknown[], valueTo?: unknown) => {\n this.applySetFilter(field, excluded, valueTo);\n this.closeFilterPanel();\n },\n applyTextFilter: (operator, value, valueTo) => {\n this.applyTextFilter(field, operator, value, valueTo);\n this.closeFilterPanel();\n },\n clearFilter: () => {\n this.clearFieldFilter(field);\n this.closeFilterPanel();\n },\n closePanel: () => this.closeFilterPanel(),\n t: this.translate,\n };\n\n // Use custom renderer or default\n // Custom renderer can return undefined to fall back to default panel for specific columns\n // Resolution order: plugin config → typeDefaults → built-in\n let usedCustomRenderer = false;\n\n // 1. Check plugin-level filterPanelRenderer\n if (this.config.filterPanelRenderer) {\n this.config.filterPanelRenderer(panel, params);\n // If renderer added content to panel, it handled rendering\n usedCustomRenderer = panel.children.length > 0;\n }\n\n // 2. Check typeDefaults for this column's type\n if (!usedCustomRenderer && column.type) {\n const typeDefault = this.grid.effectiveConfig.typeDefaults?.[column.type];\n if (typeDefault?.filterPanelRenderer) {\n typeDefault.filterPanelRenderer(panel, params);\n usedCustomRenderer = panel.children.length > 0;\n }\n }\n\n // 3. Fall back to built-in type-specific panel renderers\n // filterType takes precedence over column.type so consumers can\n // e.g. show a set filter for a numeric column.\n if (!usedCustomRenderer) {\n const columnType = column.filterType ?? column.type;\n if (columnType === 'number') {\n renderNumberFilterPanel(panel, params, uniqueValues, this.filters);\n } else if (columnType === 'date') {\n renderDateFilterPanel(panel, params, uniqueValues, this.filters);\n } else {\n renderDefaultFilterPanel(\n panel,\n params,\n uniqueValues,\n excludedSet,\n { caseSensitive: this.config.caseSensitive, debounceMs: this.config.debounceMs },\n this.searchText,\n );\n }\n }\n }\n\n /**\n * Setup click-outside handler to close the panel\n */\n private setupPanelCloseHandler(panel: HTMLElement, buttonEl: HTMLElement): void {\n // Create abort controller for panel-scoped listeners\n // This allows cleanup when panel closes OR when grid disconnects\n this.panelAbortController = new AbortController();\n const { signal } = this.panelAbortController;\n\n // Add global click handler to close on outside click\n // Defer to next tick to avoid immediate close from the click that opened the panel.\n // Capture `signal` eagerly so detach() → abort() works even if the timeout fires after\n // panelAbortController has been nullified.\n setTimeout(() => {\n if (signal.aborted) return;\n document.addEventListener(\n 'click',\n (e: MouseEvent) => {\n if (!panel.contains(e.target as Node) && e.target !== buttonEl) {\n this.closeFilterPanel();\n }\n },\n { signal },\n );\n }, 0);\n }\n\n /**\n * Promote the (already-appended) panel into the top layer via the Popover\n * API when available. Top-layer elements can resolve anchor-name from\n * anywhere in the document (including inside dialog/overlay stacking\n * contexts), fixing CSS anchor positioning when the grid is inside a CDK\n * dialog. Uses `popover=\"manual\"` so light-dismiss stays disabled and the\n * plugin's own outside-click handler remains authoritative.\n */\n private promoteToTopLayer(panel: HTMLElement): void {\n if (typeof panel.showPopover !== 'function') return;\n panel.setAttribute('popover', 'manual');\n try {\n panel.showPopover();\n } catch {\n // Not connected or already open — ignore.\n }\n }\n\n /** Hide a panel previously promoted with {@link promoteToTopLayer}. */\n private dismissTopLayer(panel: HTMLElement): void {\n if (panel.hasAttribute('popover') && typeof panel.hidePopover === 'function') {\n try {\n panel.hidePopover();\n } catch {\n // Not currently shown — ignore.\n }\n }\n }\n\n /**\n * Close the filter panel\n */\n private closeFilterPanel(): void {\n const panel = this.panelElement;\n if (panel) {\n this.dismissTopLayer(panel);\n panel.remove();\n this.panelElement = null;\n }\n // Clean up anchor name from anchor element\n if (this.panelAnchorElement) {\n (this.panelAnchorElement.style as any).anchorName = '';\n this.panelAnchorElement = null;\n }\n // Remove forced-active from the filter button (unless the column has an actual filter)\n if (this.panelButtonElement) {\n const field = this.openPanelField;\n if (!field || !this.filters.has(field)) {\n this.panelButtonElement.classList.remove('active');\n }\n this.panelButtonElement = null;\n }\n this.openPanelField = null;\n // Abort panel-scoped listeners (document click handler)\n this.panelAbortController?.abort();\n this.panelAbortController = null;\n }\n\n /** Cache for CSS anchor positioning support check */\n private static supportsAnchorPositioning: boolean | null = null;\n\n /**\n * Check if browser supports CSS Anchor Positioning\n */\n private static checkAnchorPositioningSupport(): boolean {\n if (FilteringPlugin.supportsAnchorPositioning === null) {\n FilteringPlugin.supportsAnchorPositioning = CSS.supports('anchor-name', '--test');\n }\n return FilteringPlugin.supportsAnchorPositioning;\n }\n\n /**\n * Position the panel below the filter button\n * Uses CSS Anchor Positioning if supported, falls back to JS positioning\n */\n private positionPanel(panel: HTMLElement, buttonEl: HTMLElement): void {\n // Use the header row as the vertical anchor so the panel appears\n // right below the full header, and align horizontally to the\n // filter button's right edge.\n const headerRow = buttonEl.closest('.header-row') as HTMLElement | null;\n const anchorEl = headerRow ?? buttonEl;\n\n // Set anchor name for CSS anchor positioning\n (anchorEl.style as any).anchorName = '--tbw-filter-anchor';\n this.panelAnchorElement = anchorEl; // Store for cleanup\n\n // If CSS Anchor Positioning is supported, CSS handles positioning\n // but we need to detect if it flipped above to adjust animation\n if (FilteringPlugin.checkAnchorPositioningSupport()) {\n // CSS `right: anchor(right)` needs the button as the inline anchor.\n // Use the header row for the block axis via margin offset.\n (anchorEl.style as any).anchorName = '';\n (buttonEl.style as any).anchorName = '--tbw-filter-anchor';\n this.panelAnchorElement = buttonEl;\n\n // Offset: panel top should sit at the header row bottom, not the button bottom\n const headerBottom = anchorEl.getBoundingClientRect().bottom;\n const buttonBottom = buttonEl.getBoundingClientRect().bottom;\n const offset = headerBottom - buttonBottom;\n panel.style.marginTop = `${offset + 4}px`;\n\n requestAnimationFrame(() => {\n const panelRect = panel.getBoundingClientRect();\n const anchorRect = buttonEl.getBoundingClientRect();\n if (panelRect.top < anchorRect.top) {\n panel.classList.add('tbw-filter-panel-above');\n }\n });\n return;\n }\n\n // Fallback: JS-based positioning for older browsers\n const headerRect = anchorEl.getBoundingClientRect();\n const btnRect = buttonEl.getBoundingClientRect();\n\n panel.style.position = 'fixed';\n panel.style.top = `${headerRect.bottom + 4}px`;\n\n // Right-align: panel right edge = button right edge\n // We set left initially, then adjust after measuring panel width\n panel.style.left = `${btnRect.right}px`;\n\n requestAnimationFrame(() => {\n const panelRect = panel.getBoundingClientRect();\n\n // Right-align panel to button\n panel.style.left = `${btnRect.right - panelRect.width}px`;\n\n // Check horizontal overflow - flip to left-aligned if needed\n const adjustedLeft = btnRect.right - panelRect.width;\n if (adjustedLeft < 8) {\n panel.style.left = `${btnRect.left}px`;\n }\n\n // Check vertical overflow - flip to above header row\n if (panelRect.bottom > window.innerHeight - 8) {\n panel.style.top = `${headerRect.top - panelRect.height - 4}px`;\n panel.classList.add('tbw-filter-panel-above');\n }\n });\n }\n\n /**\n * Apply a set filter from the panel's excluded (unchecked) values.\n * Preserves the original operator: if the current filter uses `in`,\n * the included values are computed and stored as `in`; otherwise `notIn` is used.\n */\n private applySetFilter(field: string, excluded: unknown[], valueTo?: unknown): void {\n // Store excluded values for panel checkbox state\n this.excludedValues.set(field, new Set(excluded));\n\n if (excluded.length === 0) {\n // No exclusions = no filter\n this.filters.delete(field);\n } else {\n // Preserve the original operator through the panel round-trip\n const currentFilter = this.filters.get(field);\n if (currentFilter?.operator === 'in') {\n // Convert excluded → included to maintain `in` semantics\n const unique = this.getUniqueValues(field);\n const excludedSet = new Set(excluded);\n const included = unique.filter((v) => !excludedSet.has(v));\n this.filters.set(field, {\n field,\n type: 'set',\n operator: 'in',\n value: included,\n ...(valueTo !== undefined && { valueTo }),\n });\n } else {\n // Default: create \"notIn\" filter\n this.filters.set(field, {\n field,\n type: 'set',\n operator: 'notIn',\n value: excluded,\n ...(valueTo !== undefined && { valueTo }),\n });\n }\n }\n\n this.applyFiltersInternal();\n }\n\n /**\n * Apply a text/number/date filter.\n * Resolves the filter type from the column definition so that numeric and\n * date operators use the correct type in the filter model.\n */\n private applyTextFilter(\n field: string,\n operator: FilterModel['operator'],\n value: string | number,\n valueTo?: string | number,\n ): void {\n const col = this.getColumnByField(field);\n const type = col?.filterType ?? (col?.type as FilterModel['type']) ?? 'text';\n\n this.filters.set(field, {\n field,\n type,\n operator,\n value,\n valueTo,\n });\n\n this.applyFiltersInternal();\n }\n\n /**\n * Internal method to apply filters (sync or async based on config)\n * @param silent - When true, suppress the `filter-change` consumer event\n */\n private applyFiltersInternal(silent?: boolean): void {\n this.invalidateCache();\n\n const filterList = [...this.filters.values()];\n\n // If using async filterHandler, delegate to server\n if (this.config.filterHandler) {\n this.gridElement.setAttribute('aria-busy', 'true');\n\n const result = this.config.filterHandler(filterList, this.sourceRows as unknown[]);\n\n // Handle async or sync result\n const handleResult = (rows: unknown[]) => {\n this.gridElement.removeAttribute('aria-busy');\n this.cachedResult = rows;\n\n // Update grid rows directly for async filtering\n this.grid.rows = rows;\n\n this.notifyFilterChange(filterList, rows.length, silent);\n\n // Trigger afterRender to update filter button active state\n this.requestRender();\n };\n\n if (result && typeof (result as Promise<unknown[]>).then === 'function') {\n (result as Promise<unknown[]>).then(handleResult);\n } else {\n handleResult(result as unknown[]);\n }\n return;\n }\n\n // Sync path: emit event and re-render (processRows will handle filtering)\n this.notifyFilterChange(filterList, 0, silent);\n this.requestRender();\n }\n // #endregion\n\n // #region Column State Hooks\n\n /**\n * Return filter state for a column if it has an active filter.\n * Only contributes state when `trackColumnState` is enabled.\n * @internal\n */\n override getColumnState(field: string): Partial<ColumnState> | undefined {\n if (!this.config.trackColumnState) return undefined;\n\n const filterModel = this.filters.get(field);\n if (!filterModel) return undefined;\n\n return {\n filter: {\n type: filterModel.type,\n operator: filterModel.operator,\n value: filterModel.value,\n valueTo: filterModel.valueTo,\n },\n };\n }\n\n /**\n * Apply filter state from column state.\n * Only applies state when `trackColumnState` is enabled.\n * @internal\n */\n override applyColumnState(field: string, state: ColumnState): void {\n if (!this.config.trackColumnState) return;\n\n // Only process if the column has filter state\n if (!state.filter) {\n this.filters.delete(field);\n return;\n }\n\n // Reconstruct the FilterModel from the stored state\n const filterModel: FilterModel = {\n field,\n type: state.filter.type,\n operator: state.filter.operator as FilterModel['operator'],\n value: state.filter.value,\n valueTo: state.filter.valueTo,\n };\n\n this.filters.set(field, filterModel);\n // Invalidate cache so filter is reapplied\n this.invalidateCache();\n }\n // #endregion\n}\n"],"mappings":"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"}