@ai-matrx/design-system 0.15.1 → 0.15.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.
@@ -52,7 +52,6 @@ function stringifyCellValue(value) {
52
52
 
53
53
  // src/data-table/infer-filter.ts
54
54
  var SELECT_CARDINALITY_CAP = 24;
55
- var EXPLICIT_SELECT_CAP = 500;
56
55
  function resolveFilterKind(col, sampleRows) {
57
56
  if (col.filter === false) return null;
58
57
  if (col.filter && col.filter !== "auto") return col.filter;
@@ -70,7 +69,6 @@ function resolveFilterKind(col, sampleRows) {
70
69
  return "text";
71
70
  }
72
71
  function collectSelectOptions(col, rows) {
73
- const cap = col.filter === "select" ? EXPLICIT_SELECT_CAP : SELECT_CARDINALITY_CAP;
74
72
  const seen = /* @__PURE__ */ new Set();
75
73
  const options = [];
76
74
  let hasEmpty = false;
@@ -84,7 +82,6 @@ function collectSelectOptions(col, rows) {
84
82
  if (col.filterOptions?.length || seen.has(value)) continue;
85
83
  seen.add(value);
86
84
  options.push({ value, label: value });
87
- if (options.length >= cap) break;
88
85
  }
89
86
  const base = col.filterOptions?.length ? [...col.filterOptions] : options.sort((a, b) => a.label.localeCompare(b.label));
90
87
  if (hasEmpty) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/data-table/infer-filter.ts","../../src/data-table/filter-engine.ts"],"sourcesContent":["import type { ColumnFilterKind, MatrxColumnDef } from \"./types\";\nimport {\n SELECT_EMPTY_VALUE,\n SELECT_NOT_EMPTY_VALUE,\n getCellValue,\n stringifyCellValue,\n} from \"./filter-engine\";\n\n/** Auto-inference switches to text above this many distinct values… */\nconst SELECT_CARDINALITY_CAP = 24;\n/** …but a column that EXPLICITLY declares `filter: \"select\"` lists them all\n * (capped only as a runaway guard) — never silently truncate declared intent. */\nconst EXPLICIT_SELECT_CAP = 500;\n\nexport type ResolvedFilterKind = \"text\" | \"select\" | \"boolean\" | \"number\";\n\n/**\n * Infer a filter UI from sample row values. Prefer select for low-cardinality\n * string/enum columns, boolean for booleans, number for numeric, else text.\n */\nexport function resolveFilterKind<T>(\n col: MatrxColumnDef<T>,\n sampleRows: T[],\n): ResolvedFilterKind | null {\n if (col.filter === false) return null;\n if (col.filter && col.filter !== \"auto\") return col.filter;\n\n const values = sampleRows\n .slice(0, 200)\n .map((row) => getCellValue(row, col))\n .filter((v) => v != null);\n\n if (values.length === 0) return \"text\";\n\n if (values.every((v) => typeof v === \"boolean\")) return \"boolean\";\n if (values.every((v) => typeof v === \"number\" && Number.isFinite(v))) {\n return \"number\";\n }\n\n const strings = values.map(stringifyCellValue);\n const unique = new Set(strings);\n if (\n unique.size >= 1 &&\n unique.size <= SELECT_CARDINALITY_CAP &&\n unique.size < strings.length * 0.6\n ) {\n return \"select\";\n }\n\n return \"text\";\n}\n\nexport function collectSelectOptions<T>(\n col: MatrxColumnDef<T>,\n rows: T[],\n): Array<{ value: string; label: string }> {\n const cap =\n col.filter === \"select\" ? EXPLICIT_SELECT_CAP : SELECT_CARDINALITY_CAP;\n const seen = new Set<string>();\n const options: Array<{ value: string; label: string }> = [];\n let hasEmpty = false;\n for (const row of rows) {\n const raw = getCellValue(row, col);\n const value = raw == null ? \"\" : stringifyCellValue(raw);\n if (!value) {\n hasEmpty = true;\n continue;\n }\n if (col.filterOptions?.length || seen.has(value)) continue;\n seen.add(value);\n options.push({ value, label: value });\n if (options.length >= cap) break;\n }\n const base = col.filterOptions?.length\n ? [...col.filterOptions]\n : options.sort((a, b) => a.label.localeCompare(b.label));\n // Emptiness sentinels: whenever the column has blank cells, every select\n // filter can isolate them — or exclude them — with zero per-table config.\n if (hasEmpty) {\n base.unshift(\n { value: SELECT_EMPTY_VALUE, label: \"(empty)\" },\n { value: SELECT_NOT_EMPTY_VALUE, label: \"(not empty)\" },\n );\n }\n return base;\n}\n\nexport function filterKindLabel(\n kind: ColumnFilterKind | ResolvedFilterKind,\n): string {\n switch (kind) {\n case \"text\":\n return \"Text\";\n case \"select\":\n return \"Select\";\n case \"boolean\":\n return \"Boolean\";\n case \"number\":\n return \"Number\";\n case \"auto\":\n return \"Auto\";\n case false:\n return \"Off\";\n }\n}\n\n","import type {\n ColumnFilterValue,\n ColumnFiltersState,\n MatrxColumnDef,\n SortState,\n TableSearchMatchMode,\n} from \"./types\";\nimport {\n completeLayeredFilterRules,\n layeredFilterMatchesValue,\n type LayeredFilterRule,\n} from \"./layered-filters\";\n\n/**\n * Sentinel select-filter values for \"no value\" / \"any value\" — every select\n * filter offers them automatically when the column has empty cells, so any\n * table can isolate rows missing a value (or having one) without extra config.\n */\nexport const SELECT_EMPTY_VALUE = \"__empty__\";\nexport const SELECT_NOT_EMPTY_VALUE = \"__not_empty__\";\n\nexport function columnId<T>(col: MatrxColumnDef<T>): string {\n if (col.id) return col.id;\n if (col.accessorKey) return String(col.accessorKey);\n throw new Error(\"MatrxDataTable column requires `id` or `accessorKey`\");\n}\n\nexport function getCellValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n if (col.accessorFn) return col.accessorFn(row);\n if (col.accessorKey) {\n return (row as Record<string, unknown>)[col.accessorKey];\n }\n return undefined;\n}\n\nexport function getFilterValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n return col.filterValue ? col.filterValue(row) : getCellValue(row, col);\n}\n\nexport function getSortValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n return col.sortValue ? col.sortValue(row) : getCellValue(row, col);\n}\n\nexport function stringifyCellValue(value: unknown): string {\n if (value == null) return \"\";\n if (typeof value === \"string\") return value;\n if (typeof value === \"number\" || typeof value === \"boolean\") {\n return String(value);\n }\n if (value instanceof Date) return value.toISOString();\n try {\n return JSON.stringify(value);\n } catch {\n return String(value);\n }\n}\n\n/** Unicode-aware tokenization shared by whole-word table search. */\nexport function tableSearchWords(value: string): string[] {\n return value\n .toLocaleLowerCase()\n .split(/[^\\p{L}\\p{N}]+/u)\n .filter(Boolean);\n}\n\n/**\n * Match the primary table search against one row's searchable text. In\n * whole-word mode every entered word must exist as a complete token, so\n * `are` matches `we are ready` but never `hardware`.\n */\nexport function matchesTableSearch(\n searchableText: string,\n query: string,\n mode: TableSearchMatchMode = \"contains\",\n): boolean {\n const normalizedQuery = query.trim().toLocaleLowerCase();\n if (!normalizedQuery) return true;\n if (mode === \"contains\") {\n return searchableText.toLocaleLowerCase().includes(normalizedQuery);\n }\n const actualWords = new Set(tableSearchWords(searchableText));\n const expectedWords = tableSearchWords(normalizedQuery);\n return (\n expectedWords.length > 0 &&\n expectedWords.every((word) => actualWords.has(word))\n );\n}\n\nfunction compareValues(a: unknown, b: unknown): number {\n if (a == null && b == null) return 0;\n if (a == null) return -1;\n if (b == null) return 1;\n if (typeof a === \"number\" && typeof b === \"number\") return a - b;\n if (typeof a === \"boolean\" && typeof b === \"boolean\") {\n return Number(a) - Number(b);\n }\n return stringifyCellValue(a).localeCompare(stringifyCellValue(b), undefined, {\n numeric: true,\n sensitivity: \"base\",\n });\n}\n\nexport function passesColumnFilter(\n value: unknown,\n filter: ColumnFilterValue,\n): boolean {\n const matches = (() => {\n switch (filter.kind) {\n case \"text\": {\n const s = stringifyCellValue(value);\n if (filter.mode === \"null\") return value == null;\n if (filter.mode === \"not_null\") return value != null;\n if (filter.mode === \"empty\") return s.trim() === \"\";\n if (filter.mode === \"not_empty\") return s.trim() !== \"\";\n const q = filter.value.trim().toLowerCase();\n if (!q) return true;\n return s.toLowerCase().includes(q);\n }\n case \"select\": {\n const selected =\n filter.values ??\n (filter.value && filter.value !== \"__all__\" ? [filter.value] : []);\n if (selected.length === 0) return true;\n const s = stringifyCellValue(value);\n // OR semantics across the set; sentinels compose (\"A or (empty)\").\n return selected.some((v) =>\n v === SELECT_EMPTY_VALUE\n ? s === \"\"\n : v === SELECT_NOT_EMPTY_VALUE\n ? s !== \"\"\n : s === v,\n );\n }\n case \"boolean\": {\n return Boolean(value) === filter.value;\n }\n case \"number\": {\n const n =\n typeof value === \"number\"\n ? value\n : Number(stringifyCellValue(value).replace(/[^0-9.-]/g, \"\"));\n if (Number.isNaN(n)) return false;\n if (filter.min !== undefined && n < filter.min) return false;\n if (filter.max !== undefined && n > filter.max) return false;\n return true;\n }\n }\n })();\n return filter.negated ? !matches : matches;\n}\n\nexport function filterAndSortRows<T>(\n rows: T[],\n columns: MatrxColumnDef<T>[],\n columnFilters: ColumnFiltersState,\n sort: SortState | null,\n globalSearch: string,\n anyOf?: { columnIds: string[]; query: string },\n layeredFilters?: readonly LayeredFilterRule[],\n globalSearchMode: TableSearchMatchMode = \"contains\",\n searchText?: (row: T) => string,\n): T[] {\n const colById = new Map(columns.map((c) => [columnId(c), c]));\n const q = globalSearch.trim().toLowerCase();\n const anyQ = anyOf?.query.trim().toLowerCase() ?? \"\";\n const activeLayeredFilters = completeLayeredFilterRules(layeredFilters);\n\n let result = rows.filter((row) => {\n for (const [id, filter] of Object.entries(columnFilters)) {\n if (!filter) continue;\n const col = colById.get(id);\n if (!col || col.filter === false) continue;\n if (!passesColumnFilter(getFilterValue(row, col), filter)) return false;\n }\n\n for (const rule of activeLayeredFilters) {\n const col = colById.get(rule.field);\n if (!col || !layeredFilterMatchesValue(getFilterValue(row, col), rule)) {\n return false;\n }\n }\n\n if (anyQ && anyOf) {\n const hit = anyOf.columnIds.some((id) => {\n const col = colById.get(id);\n if (!col) return false;\n return stringifyCellValue(getFilterValue(row, col))\n .toLowerCase()\n .includes(anyQ);\n });\n if (!hit) return false;\n }\n\n if (!q) return true;\n const searchableValues = columns\n .filter(\n (col) =>\n col.filter !== false || Boolean(col.accessorKey || col.accessorFn),\n )\n .map((col) => stringifyCellValue(getFilterValue(row, col)));\n const additionalSearchText = searchText?.(row);\n if (additionalSearchText) searchableValues.push(additionalSearchText);\n if (globalSearchMode === \"contains\") {\n return searchableValues.some((value) =>\n matchesTableSearch(value, q, \"contains\"),\n );\n }\n return matchesTableSearch(searchableValues.join(\" \"), q, \"whole_words\");\n });\n\n if (sort) {\n const col = colById.get(sort.id);\n if (col && col.sortable !== false) {\n const dir = sort.direction === \"asc\" ? 1 : -1;\n result = [...result].sort((a, b) => {\n const va = getSortValue(a, col);\n const vb = getSortValue(b, col);\n // Empty cells (null / \"\") sort LAST in both directions — sorting a\n // column should surface its values, never a wall of blanks.\n const ea = va == null || stringifyCellValue(va) === \"\";\n const eb = vb == null || stringifyCellValue(vb) === \"\";\n if (ea !== eb) return ea ? 1 : -1;\n return compareValues(va, vb) * dir;\n });\n }\n }\n\n return result;\n}\n\nexport function countActiveColumnFilters(filters: ColumnFiltersState): number {\n let n = 0;\n for (const f of Object.values(filters)) {\n if (!f) continue;\n switch (f.kind) {\n case \"text\":\n if (f.value.trim() || (f.mode && f.mode !== \"contains\")) n += 1;\n break;\n case \"select\":\n if (f.values ? f.values.length > 0 : f.value && f.value !== \"__all__\")\n n += 1;\n break;\n case \"boolean\":\n n += 1;\n break;\n case \"number\":\n if (f.min !== undefined || f.max !== undefined) n += 1;\n break;\n }\n }\n return n;\n}\n\nexport function isColumnFilterActive(\n filter: ColumnFilterValue | undefined,\n): boolean {\n if (!filter) return false;\n switch (filter.kind) {\n case \"text\":\n return (\n filter.value.trim().length > 0 ||\n (filter.mode !== undefined && filter.mode !== \"contains\")\n );\n case \"select\":\n return filter.values\n ? filter.values.length > 0\n : Boolean(filter.value) && filter.value !== \"__all__\";\n case \"boolean\":\n return true;\n case \"number\":\n return filter.min !== undefined || filter.max !== undefined;\n }\n}\n\n/** Apply draft edits onto a row for display (shallow field merge). */\nexport function applyRowEdits<T>(\n row: T,\n edits: Record<string, unknown> | undefined,\n): T {\n if (!edits || Object.keys(edits).length === 0) return row;\n return { ...row, ...edits };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACkBO,IAAM,qBAAqB;AAC3B,IAAM,yBAAyB;AAQ/B,SAAS,aAAgB,KAAQ,KAAiC;AACvE,MAAI,IAAI,WAAY,QAAO,IAAI,WAAW,GAAG;AAC7C,MAAI,IAAI,aAAa;AACnB,WAAQ,IAAgC,IAAI,WAAW;AAAA,EACzD;AACA,SAAO;AACT;AAUO,SAAS,mBAAmB,OAAwB;AACzD,MAAI,SAAS,KAAM,QAAO;AAC1B,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,OAAO,UAAU,YAAY,OAAO,UAAU,WAAW;AAC3D,WAAO,OAAO,KAAK;AAAA,EACrB;AACA,MAAI,iBAAiB,KAAM,QAAO,MAAM,YAAY;AACpD,MAAI;AACF,WAAO,KAAK,UAAU,KAAK;AAAA,EAC7B,QAAQ;AACN,WAAO,OAAO,KAAK;AAAA,EACrB;AACF;;;AD9CA,IAAM,yBAAyB;AAG/B,IAAM,sBAAsB;AAQrB,SAAS,kBACd,KACA,YAC2B;AAC3B,MAAI,IAAI,WAAW,MAAO,QAAO;AACjC,MAAI,IAAI,UAAU,IAAI,WAAW,OAAQ,QAAO,IAAI;AAEpD,QAAM,SAAS,WACZ,MAAM,GAAG,GAAG,EACZ,IAAI,CAAC,QAAQ,aAAa,KAAK,GAAG,CAAC,EACnC,OAAO,CAAC,MAAM,KAAK,IAAI;AAE1B,MAAI,OAAO,WAAW,EAAG,QAAO;AAEhC,MAAI,OAAO,MAAM,CAAC,MAAM,OAAO,MAAM,SAAS,EAAG,QAAO;AACxD,MAAI,OAAO,MAAM,CAAC,MAAM,OAAO,MAAM,YAAY,OAAO,SAAS,CAAC,CAAC,GAAG;AACpE,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,OAAO,IAAI,kBAAkB;AAC7C,QAAM,SAAS,IAAI,IAAI,OAAO;AAC9B,MACE,OAAO,QAAQ,KACf,OAAO,QAAQ,0BACf,OAAO,OAAO,QAAQ,SAAS,KAC/B;AACA,WAAO;AAAA,EACT;AAEA,SAAO;AACT;AAEO,SAAS,qBACd,KACA,MACyC;AACzC,QAAM,MACJ,IAAI,WAAW,WAAW,sBAAsB;AAClD,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,UAAmD,CAAC;AAC1D,MAAI,WAAW;AACf,aAAW,OAAO,MAAM;AACtB,UAAM,MAAM,aAAa,KAAK,GAAG;AACjC,UAAM,QAAQ,OAAO,OAAO,KAAK,mBAAmB,GAAG;AACvD,QAAI,CAAC,OAAO;AACV,iBAAW;AACX;AAAA,IACF;AACA,QAAI,IAAI,eAAe,UAAU,KAAK,IAAI,KAAK,EAAG;AAClD,SAAK,IAAI,KAAK;AACd,YAAQ,KAAK,EAAE,OAAO,OAAO,MAAM,CAAC;AACpC,QAAI,QAAQ,UAAU,IAAK;AAAA,EAC7B;AACA,QAAM,OAAO,IAAI,eAAe,SAC5B,CAAC,GAAG,IAAI,aAAa,IACrB,QAAQ,KAAK,CAAC,GAAG,MAAM,EAAE,MAAM,cAAc,EAAE,KAAK,CAAC;AAGzD,MAAI,UAAU;AACZ,SAAK;AAAA,MACH,EAAE,OAAO,oBAAoB,OAAO,UAAU;AAAA,MAC9C,EAAE,OAAO,wBAAwB,OAAO,cAAc;AAAA,IACxD;AAAA,EACF;AACA,SAAO;AACT;AAEO,SAAS,gBACd,MACQ;AACR,UAAQ,MAAM;AAAA,IACZ,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,EACX;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/data-table/infer-filter.ts","../../src/data-table/filter-engine.ts"],"sourcesContent":["import type { ColumnFilterKind, MatrxColumnDef } from \"./types\";\nimport {\n SELECT_EMPTY_VALUE,\n SELECT_NOT_EMPTY_VALUE,\n getCellValue,\n stringifyCellValue,\n} from \"./filter-engine\";\n\n/** Auto-inference switches to text above this many distinct values… */\nconst SELECT_CARDINALITY_CAP = 24;\n\nexport type ResolvedFilterKind = \"text\" | \"select\" | \"boolean\" | \"number\";\n\n/**\n * Infer a filter UI from sample row values. Prefer select for low-cardinality\n * string/enum columns, boolean for booleans, number for numeric, else text.\n */\nexport function resolveFilterKind<T>(\n col: MatrxColumnDef<T>,\n sampleRows: T[],\n): ResolvedFilterKind | null {\n if (col.filter === false) return null;\n if (col.filter && col.filter !== \"auto\") return col.filter;\n\n const values = sampleRows\n .slice(0, 200)\n .map((row) => getCellValue(row, col))\n .filter((v) => v != null);\n\n if (values.length === 0) return \"text\";\n\n if (values.every((v) => typeof v === \"boolean\")) return \"boolean\";\n if (values.every((v) => typeof v === \"number\" && Number.isFinite(v))) {\n return \"number\";\n }\n\n const strings = values.map(stringifyCellValue);\n const unique = new Set(strings);\n if (\n unique.size >= 1 &&\n unique.size <= SELECT_CARDINALITY_CAP &&\n unique.size < strings.length * 0.6\n ) {\n return \"select\";\n }\n\n return \"text\";\n}\n\nexport function collectSelectOptions<T>(\n col: MatrxColumnDef<T>,\n rows: T[],\n): Array<{ value: string; label: string }> {\n const seen = new Set<string>();\n const options: Array<{ value: string; label: string }> = [];\n let hasEmpty = false;\n for (const row of rows) {\n const raw = getCellValue(row, col);\n const value = raw == null ? \"\" : stringifyCellValue(raw);\n if (!value) {\n hasEmpty = true;\n continue;\n }\n if (col.filterOptions?.length || seen.has(value)) continue;\n seen.add(value);\n options.push({ value, label: value });\n }\n const base = col.filterOptions?.length\n ? [...col.filterOptions]\n : options.sort((a, b) => a.label.localeCompare(b.label));\n // Emptiness sentinels: whenever the column has blank cells, every select\n // filter can isolate them — or exclude them — with zero per-table config.\n if (hasEmpty) {\n base.unshift(\n { value: SELECT_EMPTY_VALUE, label: \"(empty)\" },\n { value: SELECT_NOT_EMPTY_VALUE, label: \"(not empty)\" },\n );\n }\n return base;\n}\n\nexport function filterKindLabel(\n kind: ColumnFilterKind | ResolvedFilterKind,\n): string {\n switch (kind) {\n case \"text\":\n return \"Text\";\n case \"select\":\n return \"Select\";\n case \"boolean\":\n return \"Boolean\";\n case \"number\":\n return \"Number\";\n case \"auto\":\n return \"Auto\";\n case false:\n return \"Off\";\n }\n}\n","import type {\n ColumnFilterValue,\n ColumnFiltersState,\n MatrxColumnDef,\n SortState,\n TableSearchMatchMode,\n} from \"./types\";\nimport {\n completeLayeredFilterRules,\n layeredFilterMatchesValue,\n type LayeredFilterRule,\n} from \"./layered-filters\";\n\n/**\n * Sentinel select-filter values for \"no value\" / \"any value\" — every select\n * filter offers them automatically when the column has empty cells, so any\n * table can isolate rows missing a value (or having one) without extra config.\n */\nexport const SELECT_EMPTY_VALUE = \"__empty__\";\nexport const SELECT_NOT_EMPTY_VALUE = \"__not_empty__\";\n\nexport function columnId<T>(col: MatrxColumnDef<T>): string {\n if (col.id) return col.id;\n if (col.accessorKey) return String(col.accessorKey);\n throw new Error(\"MatrxDataTable column requires `id` or `accessorKey`\");\n}\n\nexport function getCellValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n if (col.accessorFn) return col.accessorFn(row);\n if (col.accessorKey) {\n return (row as Record<string, unknown>)[col.accessorKey];\n }\n return undefined;\n}\n\nexport function getFilterValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n return col.filterValue ? col.filterValue(row) : getCellValue(row, col);\n}\n\nexport function getSortValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n return col.sortValue ? col.sortValue(row) : getCellValue(row, col);\n}\n\nexport function stringifyCellValue(value: unknown): string {\n if (value == null) return \"\";\n if (typeof value === \"string\") return value;\n if (typeof value === \"number\" || typeof value === \"boolean\") {\n return String(value);\n }\n if (value instanceof Date) return value.toISOString();\n try {\n return JSON.stringify(value);\n } catch {\n return String(value);\n }\n}\n\n/** Unicode-aware tokenization shared by whole-word table search. */\nexport function tableSearchWords(value: string): string[] {\n return value\n .toLocaleLowerCase()\n .split(/[^\\p{L}\\p{N}]+/u)\n .filter(Boolean);\n}\n\n/**\n * Match the primary table search against one row's searchable text. In\n * whole-word mode every entered word must exist as a complete token, so\n * `are` matches `we are ready` but never `hardware`.\n */\nexport function matchesTableSearch(\n searchableText: string,\n query: string,\n mode: TableSearchMatchMode = \"contains\",\n): boolean {\n const normalizedQuery = query.trim().toLocaleLowerCase();\n if (!normalizedQuery) return true;\n if (mode === \"contains\") {\n return searchableText.toLocaleLowerCase().includes(normalizedQuery);\n }\n const actualWords = new Set(tableSearchWords(searchableText));\n const expectedWords = tableSearchWords(normalizedQuery);\n return (\n expectedWords.length > 0 &&\n expectedWords.every((word) => actualWords.has(word))\n );\n}\n\nfunction compareValues(a: unknown, b: unknown): number {\n if (a == null && b == null) return 0;\n if (a == null) return -1;\n if (b == null) return 1;\n if (typeof a === \"number\" && typeof b === \"number\") return a - b;\n if (typeof a === \"boolean\" && typeof b === \"boolean\") {\n return Number(a) - Number(b);\n }\n return stringifyCellValue(a).localeCompare(stringifyCellValue(b), undefined, {\n numeric: true,\n sensitivity: \"base\",\n });\n}\n\nexport function passesColumnFilter(\n value: unknown,\n filter: ColumnFilterValue,\n): boolean {\n const matches = (() => {\n switch (filter.kind) {\n case \"text\": {\n const s = stringifyCellValue(value);\n if (filter.mode === \"null\") return value == null;\n if (filter.mode === \"not_null\") return value != null;\n if (filter.mode === \"empty\") return s.trim() === \"\";\n if (filter.mode === \"not_empty\") return s.trim() !== \"\";\n const q = filter.value.trim().toLowerCase();\n if (!q) return true;\n return s.toLowerCase().includes(q);\n }\n case \"select\": {\n const selected =\n filter.values ??\n (filter.value && filter.value !== \"__all__\" ? [filter.value] : []);\n if (selected.length === 0) return true;\n const s = stringifyCellValue(value);\n // OR semantics across the set; sentinels compose (\"A or (empty)\").\n return selected.some((v) =>\n v === SELECT_EMPTY_VALUE\n ? s === \"\"\n : v === SELECT_NOT_EMPTY_VALUE\n ? s !== \"\"\n : s === v,\n );\n }\n case \"boolean\": {\n return Boolean(value) === filter.value;\n }\n case \"number\": {\n const n =\n typeof value === \"number\"\n ? value\n : Number(stringifyCellValue(value).replace(/[^0-9.-]/g, \"\"));\n if (Number.isNaN(n)) return false;\n if (filter.min !== undefined && n < filter.min) return false;\n if (filter.max !== undefined && n > filter.max) return false;\n return true;\n }\n }\n })();\n return filter.negated ? !matches : matches;\n}\n\nexport function filterAndSortRows<T>(\n rows: T[],\n columns: MatrxColumnDef<T>[],\n columnFilters: ColumnFiltersState,\n sort: SortState | null,\n globalSearch: string,\n anyOf?: { columnIds: string[]; query: string },\n layeredFilters?: readonly LayeredFilterRule[],\n globalSearchMode: TableSearchMatchMode = \"contains\",\n searchText?: (row: T) => string,\n): T[] {\n const colById = new Map(columns.map((c) => [columnId(c), c]));\n const q = globalSearch.trim().toLowerCase();\n const anyQ = anyOf?.query.trim().toLowerCase() ?? \"\";\n const activeLayeredFilters = completeLayeredFilterRules(layeredFilters);\n\n let result = rows.filter((row) => {\n for (const [id, filter] of Object.entries(columnFilters)) {\n if (!filter) continue;\n const col = colById.get(id);\n if (!col || col.filter === false) continue;\n if (!passesColumnFilter(getFilterValue(row, col), filter)) return false;\n }\n\n for (const rule of activeLayeredFilters) {\n const col = colById.get(rule.field);\n if (!col || !layeredFilterMatchesValue(getFilterValue(row, col), rule)) {\n return false;\n }\n }\n\n if (anyQ && anyOf) {\n const hit = anyOf.columnIds.some((id) => {\n const col = colById.get(id);\n if (!col) return false;\n return stringifyCellValue(getFilterValue(row, col))\n .toLowerCase()\n .includes(anyQ);\n });\n if (!hit) return false;\n }\n\n if (!q) return true;\n const searchableValues = columns\n .filter(\n (col) =>\n col.filter !== false || Boolean(col.accessorKey || col.accessorFn),\n )\n .map((col) => stringifyCellValue(getFilterValue(row, col)));\n const additionalSearchText = searchText?.(row);\n if (additionalSearchText) searchableValues.push(additionalSearchText);\n if (globalSearchMode === \"contains\") {\n return searchableValues.some((value) =>\n matchesTableSearch(value, q, \"contains\"),\n );\n }\n return matchesTableSearch(searchableValues.join(\" \"), q, \"whole_words\");\n });\n\n if (sort) {\n const col = colById.get(sort.id);\n if (col && col.sortable !== false) {\n const dir = sort.direction === \"asc\" ? 1 : -1;\n result = [...result].sort((a, b) => {\n const va = getSortValue(a, col);\n const vb = getSortValue(b, col);\n // Empty cells (null / \"\") sort LAST in both directions — sorting a\n // column should surface its values, never a wall of blanks.\n const ea = va == null || stringifyCellValue(va) === \"\";\n const eb = vb == null || stringifyCellValue(vb) === \"\";\n if (ea !== eb) return ea ? 1 : -1;\n return compareValues(va, vb) * dir;\n });\n }\n }\n\n return result;\n}\n\nexport function countActiveColumnFilters(filters: ColumnFiltersState): number {\n let n = 0;\n for (const f of Object.values(filters)) {\n if (!f) continue;\n switch (f.kind) {\n case \"text\":\n if (f.value.trim() || (f.mode && f.mode !== \"contains\")) n += 1;\n break;\n case \"select\":\n if (f.values ? f.values.length > 0 : f.value && f.value !== \"__all__\")\n n += 1;\n break;\n case \"boolean\":\n n += 1;\n break;\n case \"number\":\n if (f.min !== undefined || f.max !== undefined) n += 1;\n break;\n }\n }\n return n;\n}\n\nexport function isColumnFilterActive(\n filter: ColumnFilterValue | undefined,\n): boolean {\n if (!filter) return false;\n switch (filter.kind) {\n case \"text\":\n return (\n filter.value.trim().length > 0 ||\n (filter.mode !== undefined && filter.mode !== \"contains\")\n );\n case \"select\":\n return filter.values\n ? filter.values.length > 0\n : Boolean(filter.value) && filter.value !== \"__all__\";\n case \"boolean\":\n return true;\n case \"number\":\n return filter.min !== undefined || filter.max !== undefined;\n }\n}\n\n/** Apply draft edits onto a row for display (shallow field merge). */\nexport function applyRowEdits<T>(\n row: T,\n edits: Record<string, unknown> | undefined,\n): T {\n if (!edits || Object.keys(edits).length === 0) return row;\n return { ...row, ...edits };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACkBO,IAAM,qBAAqB;AAC3B,IAAM,yBAAyB;AAQ/B,SAAS,aAAgB,KAAQ,KAAiC;AACvE,MAAI,IAAI,WAAY,QAAO,IAAI,WAAW,GAAG;AAC7C,MAAI,IAAI,aAAa;AACnB,WAAQ,IAAgC,IAAI,WAAW;AAAA,EACzD;AACA,SAAO;AACT;AAUO,SAAS,mBAAmB,OAAwB;AACzD,MAAI,SAAS,KAAM,QAAO;AAC1B,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,OAAO,UAAU,YAAY,OAAO,UAAU,WAAW;AAC3D,WAAO,OAAO,KAAK;AAAA,EACrB;AACA,MAAI,iBAAiB,KAAM,QAAO,MAAM,YAAY;AACpD,MAAI;AACF,WAAO,KAAK,UAAU,KAAK;AAAA,EAC7B,QAAQ;AACN,WAAO,OAAO,KAAK;AAAA,EACrB;AACF;;;AD9CA,IAAM,yBAAyB;AAQxB,SAAS,kBACd,KACA,YAC2B;AAC3B,MAAI,IAAI,WAAW,MAAO,QAAO;AACjC,MAAI,IAAI,UAAU,IAAI,WAAW,OAAQ,QAAO,IAAI;AAEpD,QAAM,SAAS,WACZ,MAAM,GAAG,GAAG,EACZ,IAAI,CAAC,QAAQ,aAAa,KAAK,GAAG,CAAC,EACnC,OAAO,CAAC,MAAM,KAAK,IAAI;AAE1B,MAAI,OAAO,WAAW,EAAG,QAAO;AAEhC,MAAI,OAAO,MAAM,CAAC,MAAM,OAAO,MAAM,SAAS,EAAG,QAAO;AACxD,MAAI,OAAO,MAAM,CAAC,MAAM,OAAO,MAAM,YAAY,OAAO,SAAS,CAAC,CAAC,GAAG;AACpE,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,OAAO,IAAI,kBAAkB;AAC7C,QAAM,SAAS,IAAI,IAAI,OAAO;AAC9B,MACE,OAAO,QAAQ,KACf,OAAO,QAAQ,0BACf,OAAO,OAAO,QAAQ,SAAS,KAC/B;AACA,WAAO;AAAA,EACT;AAEA,SAAO;AACT;AAEO,SAAS,qBACd,KACA,MACyC;AACzC,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,UAAmD,CAAC;AAC1D,MAAI,WAAW;AACf,aAAW,OAAO,MAAM;AACtB,UAAM,MAAM,aAAa,KAAK,GAAG;AACjC,UAAM,QAAQ,OAAO,OAAO,KAAK,mBAAmB,GAAG;AACvD,QAAI,CAAC,OAAO;AACV,iBAAW;AACX;AAAA,IACF;AACA,QAAI,IAAI,eAAe,UAAU,KAAK,IAAI,KAAK,EAAG;AAClD,SAAK,IAAI,KAAK;AACd,YAAQ,KAAK,EAAE,OAAO,OAAO,MAAM,CAAC;AAAA,EACtC;AACA,QAAM,OAAO,IAAI,eAAe,SAC5B,CAAC,GAAG,IAAI,aAAa,IACrB,QAAQ,KAAK,CAAC,GAAG,MAAM,EAAE,MAAM,cAAc,EAAE,KAAK,CAAC;AAGzD,MAAI,UAAU;AACZ,SAAK;AAAA,MACH,EAAE,OAAO,oBAAoB,OAAO,UAAU;AAAA,MAC9C,EAAE,OAAO,wBAAwB,OAAO,cAAc;AAAA,IACxD;AAAA,EACF;AACA,SAAO;AACT;AAEO,SAAS,gBACd,MACQ;AACR,UAAQ,MAAM;AAAA,IACZ,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,EACX;AACF;","names":[]}
@@ -25,7 +25,6 @@ function stringifyCellValue(value) {
25
25
 
26
26
  // src/data-table/infer-filter.ts
27
27
  var SELECT_CARDINALITY_CAP = 24;
28
- var EXPLICIT_SELECT_CAP = 500;
29
28
  function resolveFilterKind(col, sampleRows) {
30
29
  if (col.filter === false) return null;
31
30
  if (col.filter && col.filter !== "auto") return col.filter;
@@ -43,7 +42,6 @@ function resolveFilterKind(col, sampleRows) {
43
42
  return "text";
44
43
  }
45
44
  function collectSelectOptions(col, rows) {
46
- const cap = col.filter === "select" ? EXPLICIT_SELECT_CAP : SELECT_CARDINALITY_CAP;
47
45
  const seen = /* @__PURE__ */ new Set();
48
46
  const options = [];
49
47
  let hasEmpty = false;
@@ -57,7 +55,6 @@ function collectSelectOptions(col, rows) {
57
55
  if (col.filterOptions?.length || seen.has(value)) continue;
58
56
  seen.add(value);
59
57
  options.push({ value, label: value });
60
- if (options.length >= cap) break;
61
58
  }
62
59
  const base = col.filterOptions?.length ? [...col.filterOptions] : options.sort((a, b) => a.label.localeCompare(b.label));
63
60
  if (hasEmpty) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/data-table/filter-engine.ts","../../src/data-table/infer-filter.ts"],"sourcesContent":["import type {\n ColumnFilterValue,\n ColumnFiltersState,\n MatrxColumnDef,\n SortState,\n TableSearchMatchMode,\n} from \"./types\";\nimport {\n completeLayeredFilterRules,\n layeredFilterMatchesValue,\n type LayeredFilterRule,\n} from \"./layered-filters\";\n\n/**\n * Sentinel select-filter values for \"no value\" / \"any value\" — every select\n * filter offers them automatically when the column has empty cells, so any\n * table can isolate rows missing a value (or having one) without extra config.\n */\nexport const SELECT_EMPTY_VALUE = \"__empty__\";\nexport const SELECT_NOT_EMPTY_VALUE = \"__not_empty__\";\n\nexport function columnId<T>(col: MatrxColumnDef<T>): string {\n if (col.id) return col.id;\n if (col.accessorKey) return String(col.accessorKey);\n throw new Error(\"MatrxDataTable column requires `id` or `accessorKey`\");\n}\n\nexport function getCellValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n if (col.accessorFn) return col.accessorFn(row);\n if (col.accessorKey) {\n return (row as Record<string, unknown>)[col.accessorKey];\n }\n return undefined;\n}\n\nexport function getFilterValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n return col.filterValue ? col.filterValue(row) : getCellValue(row, col);\n}\n\nexport function getSortValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n return col.sortValue ? col.sortValue(row) : getCellValue(row, col);\n}\n\nexport function stringifyCellValue(value: unknown): string {\n if (value == null) return \"\";\n if (typeof value === \"string\") return value;\n if (typeof value === \"number\" || typeof value === \"boolean\") {\n return String(value);\n }\n if (value instanceof Date) return value.toISOString();\n try {\n return JSON.stringify(value);\n } catch {\n return String(value);\n }\n}\n\n/** Unicode-aware tokenization shared by whole-word table search. */\nexport function tableSearchWords(value: string): string[] {\n return value\n .toLocaleLowerCase()\n .split(/[^\\p{L}\\p{N}]+/u)\n .filter(Boolean);\n}\n\n/**\n * Match the primary table search against one row's searchable text. In\n * whole-word mode every entered word must exist as a complete token, so\n * `are` matches `we are ready` but never `hardware`.\n */\nexport function matchesTableSearch(\n searchableText: string,\n query: string,\n mode: TableSearchMatchMode = \"contains\",\n): boolean {\n const normalizedQuery = query.trim().toLocaleLowerCase();\n if (!normalizedQuery) return true;\n if (mode === \"contains\") {\n return searchableText.toLocaleLowerCase().includes(normalizedQuery);\n }\n const actualWords = new Set(tableSearchWords(searchableText));\n const expectedWords = tableSearchWords(normalizedQuery);\n return (\n expectedWords.length > 0 &&\n expectedWords.every((word) => actualWords.has(word))\n );\n}\n\nfunction compareValues(a: unknown, b: unknown): number {\n if (a == null && b == null) return 0;\n if (a == null) return -1;\n if (b == null) return 1;\n if (typeof a === \"number\" && typeof b === \"number\") return a - b;\n if (typeof a === \"boolean\" && typeof b === \"boolean\") {\n return Number(a) - Number(b);\n }\n return stringifyCellValue(a).localeCompare(stringifyCellValue(b), undefined, {\n numeric: true,\n sensitivity: \"base\",\n });\n}\n\nexport function passesColumnFilter(\n value: unknown,\n filter: ColumnFilterValue,\n): boolean {\n const matches = (() => {\n switch (filter.kind) {\n case \"text\": {\n const s = stringifyCellValue(value);\n if (filter.mode === \"null\") return value == null;\n if (filter.mode === \"not_null\") return value != null;\n if (filter.mode === \"empty\") return s.trim() === \"\";\n if (filter.mode === \"not_empty\") return s.trim() !== \"\";\n const q = filter.value.trim().toLowerCase();\n if (!q) return true;\n return s.toLowerCase().includes(q);\n }\n case \"select\": {\n const selected =\n filter.values ??\n (filter.value && filter.value !== \"__all__\" ? [filter.value] : []);\n if (selected.length === 0) return true;\n const s = stringifyCellValue(value);\n // OR semantics across the set; sentinels compose (\"A or (empty)\").\n return selected.some((v) =>\n v === SELECT_EMPTY_VALUE\n ? s === \"\"\n : v === SELECT_NOT_EMPTY_VALUE\n ? s !== \"\"\n : s === v,\n );\n }\n case \"boolean\": {\n return Boolean(value) === filter.value;\n }\n case \"number\": {\n const n =\n typeof value === \"number\"\n ? value\n : Number(stringifyCellValue(value).replace(/[^0-9.-]/g, \"\"));\n if (Number.isNaN(n)) return false;\n if (filter.min !== undefined && n < filter.min) return false;\n if (filter.max !== undefined && n > filter.max) return false;\n return true;\n }\n }\n })();\n return filter.negated ? !matches : matches;\n}\n\nexport function filterAndSortRows<T>(\n rows: T[],\n columns: MatrxColumnDef<T>[],\n columnFilters: ColumnFiltersState,\n sort: SortState | null,\n globalSearch: string,\n anyOf?: { columnIds: string[]; query: string },\n layeredFilters?: readonly LayeredFilterRule[],\n globalSearchMode: TableSearchMatchMode = \"contains\",\n searchText?: (row: T) => string,\n): T[] {\n const colById = new Map(columns.map((c) => [columnId(c), c]));\n const q = globalSearch.trim().toLowerCase();\n const anyQ = anyOf?.query.trim().toLowerCase() ?? \"\";\n const activeLayeredFilters = completeLayeredFilterRules(layeredFilters);\n\n let result = rows.filter((row) => {\n for (const [id, filter] of Object.entries(columnFilters)) {\n if (!filter) continue;\n const col = colById.get(id);\n if (!col || col.filter === false) continue;\n if (!passesColumnFilter(getFilterValue(row, col), filter)) return false;\n }\n\n for (const rule of activeLayeredFilters) {\n const col = colById.get(rule.field);\n if (!col || !layeredFilterMatchesValue(getFilterValue(row, col), rule)) {\n return false;\n }\n }\n\n if (anyQ && anyOf) {\n const hit = anyOf.columnIds.some((id) => {\n const col = colById.get(id);\n if (!col) return false;\n return stringifyCellValue(getFilterValue(row, col))\n .toLowerCase()\n .includes(anyQ);\n });\n if (!hit) return false;\n }\n\n if (!q) return true;\n const searchableValues = columns\n .filter(\n (col) =>\n col.filter !== false || Boolean(col.accessorKey || col.accessorFn),\n )\n .map((col) => stringifyCellValue(getFilterValue(row, col)));\n const additionalSearchText = searchText?.(row);\n if (additionalSearchText) searchableValues.push(additionalSearchText);\n if (globalSearchMode === \"contains\") {\n return searchableValues.some((value) =>\n matchesTableSearch(value, q, \"contains\"),\n );\n }\n return matchesTableSearch(searchableValues.join(\" \"), q, \"whole_words\");\n });\n\n if (sort) {\n const col = colById.get(sort.id);\n if (col && col.sortable !== false) {\n const dir = sort.direction === \"asc\" ? 1 : -1;\n result = [...result].sort((a, b) => {\n const va = getSortValue(a, col);\n const vb = getSortValue(b, col);\n // Empty cells (null / \"\") sort LAST in both directions — sorting a\n // column should surface its values, never a wall of blanks.\n const ea = va == null || stringifyCellValue(va) === \"\";\n const eb = vb == null || stringifyCellValue(vb) === \"\";\n if (ea !== eb) return ea ? 1 : -1;\n return compareValues(va, vb) * dir;\n });\n }\n }\n\n return result;\n}\n\nexport function countActiveColumnFilters(filters: ColumnFiltersState): number {\n let n = 0;\n for (const f of Object.values(filters)) {\n if (!f) continue;\n switch (f.kind) {\n case \"text\":\n if (f.value.trim() || (f.mode && f.mode !== \"contains\")) n += 1;\n break;\n case \"select\":\n if (f.values ? f.values.length > 0 : f.value && f.value !== \"__all__\")\n n += 1;\n break;\n case \"boolean\":\n n += 1;\n break;\n case \"number\":\n if (f.min !== undefined || f.max !== undefined) n += 1;\n break;\n }\n }\n return n;\n}\n\nexport function isColumnFilterActive(\n filter: ColumnFilterValue | undefined,\n): boolean {\n if (!filter) return false;\n switch (filter.kind) {\n case \"text\":\n return (\n filter.value.trim().length > 0 ||\n (filter.mode !== undefined && filter.mode !== \"contains\")\n );\n case \"select\":\n return filter.values\n ? filter.values.length > 0\n : Boolean(filter.value) && filter.value !== \"__all__\";\n case \"boolean\":\n return true;\n case \"number\":\n return filter.min !== undefined || filter.max !== undefined;\n }\n}\n\n/** Apply draft edits onto a row for display (shallow field merge). */\nexport function applyRowEdits<T>(\n row: T,\n edits: Record<string, unknown> | undefined,\n): T {\n if (!edits || Object.keys(edits).length === 0) return row;\n return { ...row, ...edits };\n}\n","import type { ColumnFilterKind, MatrxColumnDef } from \"./types\";\nimport {\n SELECT_EMPTY_VALUE,\n SELECT_NOT_EMPTY_VALUE,\n getCellValue,\n stringifyCellValue,\n} from \"./filter-engine\";\n\n/** Auto-inference switches to text above this many distinct values… */\nconst SELECT_CARDINALITY_CAP = 24;\n/** …but a column that EXPLICITLY declares `filter: \"select\"` lists them all\n * (capped only as a runaway guard) — never silently truncate declared intent. */\nconst EXPLICIT_SELECT_CAP = 500;\n\nexport type ResolvedFilterKind = \"text\" | \"select\" | \"boolean\" | \"number\";\n\n/**\n * Infer a filter UI from sample row values. Prefer select for low-cardinality\n * string/enum columns, boolean for booleans, number for numeric, else text.\n */\nexport function resolveFilterKind<T>(\n col: MatrxColumnDef<T>,\n sampleRows: T[],\n): ResolvedFilterKind | null {\n if (col.filter === false) return null;\n if (col.filter && col.filter !== \"auto\") return col.filter;\n\n const values = sampleRows\n .slice(0, 200)\n .map((row) => getCellValue(row, col))\n .filter((v) => v != null);\n\n if (values.length === 0) return \"text\";\n\n if (values.every((v) => typeof v === \"boolean\")) return \"boolean\";\n if (values.every((v) => typeof v === \"number\" && Number.isFinite(v))) {\n return \"number\";\n }\n\n const strings = values.map(stringifyCellValue);\n const unique = new Set(strings);\n if (\n unique.size >= 1 &&\n unique.size <= SELECT_CARDINALITY_CAP &&\n unique.size < strings.length * 0.6\n ) {\n return \"select\";\n }\n\n return \"text\";\n}\n\nexport function collectSelectOptions<T>(\n col: MatrxColumnDef<T>,\n rows: T[],\n): Array<{ value: string; label: string }> {\n const cap =\n col.filter === \"select\" ? EXPLICIT_SELECT_CAP : SELECT_CARDINALITY_CAP;\n const seen = new Set<string>();\n const options: Array<{ value: string; label: string }> = [];\n let hasEmpty = false;\n for (const row of rows) {\n const raw = getCellValue(row, col);\n const value = raw == null ? \"\" : stringifyCellValue(raw);\n if (!value) {\n hasEmpty = true;\n continue;\n }\n if (col.filterOptions?.length || seen.has(value)) continue;\n seen.add(value);\n options.push({ value, label: value });\n if (options.length >= cap) break;\n }\n const base = col.filterOptions?.length\n ? [...col.filterOptions]\n : options.sort((a, b) => a.label.localeCompare(b.label));\n // Emptiness sentinels: whenever the column has blank cells, every select\n // filter can isolate them — or exclude them — with zero per-table config.\n if (hasEmpty) {\n base.unshift(\n { value: SELECT_EMPTY_VALUE, label: \"(empty)\" },\n { value: SELECT_NOT_EMPTY_VALUE, label: \"(not empty)\" },\n );\n }\n return base;\n}\n\nexport function filterKindLabel(\n kind: ColumnFilterKind | ResolvedFilterKind,\n): string {\n switch (kind) {\n case \"text\":\n return \"Text\";\n case \"select\":\n return \"Select\";\n case \"boolean\":\n return \"Boolean\";\n case \"number\":\n return \"Number\";\n case \"auto\":\n return \"Auto\";\n case false:\n return \"Off\";\n }\n}\n\n"],"mappings":";;AAkBO,IAAM,qBAAqB;AAC3B,IAAM,yBAAyB;AAQ/B,SAAS,aAAgB,KAAQ,KAAiC;AACvE,MAAI,IAAI,WAAY,QAAO,IAAI,WAAW,GAAG;AAC7C,MAAI,IAAI,aAAa;AACnB,WAAQ,IAAgC,IAAI,WAAW;AAAA,EACzD;AACA,SAAO;AACT;AAUO,SAAS,mBAAmB,OAAwB;AACzD,MAAI,SAAS,KAAM,QAAO;AAC1B,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,OAAO,UAAU,YAAY,OAAO,UAAU,WAAW;AAC3D,WAAO,OAAO,KAAK;AAAA,EACrB;AACA,MAAI,iBAAiB,KAAM,QAAO,MAAM,YAAY;AACpD,MAAI;AACF,WAAO,KAAK,UAAU,KAAK;AAAA,EAC7B,QAAQ;AACN,WAAO,OAAO,KAAK;AAAA,EACrB;AACF;;;AC9CA,IAAM,yBAAyB;AAG/B,IAAM,sBAAsB;AAQrB,SAAS,kBACd,KACA,YAC2B;AAC3B,MAAI,IAAI,WAAW,MAAO,QAAO;AACjC,MAAI,IAAI,UAAU,IAAI,WAAW,OAAQ,QAAO,IAAI;AAEpD,QAAM,SAAS,WACZ,MAAM,GAAG,GAAG,EACZ,IAAI,CAAC,QAAQ,aAAa,KAAK,GAAG,CAAC,EACnC,OAAO,CAAC,MAAM,KAAK,IAAI;AAE1B,MAAI,OAAO,WAAW,EAAG,QAAO;AAEhC,MAAI,OAAO,MAAM,CAAC,MAAM,OAAO,MAAM,SAAS,EAAG,QAAO;AACxD,MAAI,OAAO,MAAM,CAAC,MAAM,OAAO,MAAM,YAAY,OAAO,SAAS,CAAC,CAAC,GAAG;AACpE,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,OAAO,IAAI,kBAAkB;AAC7C,QAAM,SAAS,IAAI,IAAI,OAAO;AAC9B,MACE,OAAO,QAAQ,KACf,OAAO,QAAQ,0BACf,OAAO,OAAO,QAAQ,SAAS,KAC/B;AACA,WAAO;AAAA,EACT;AAEA,SAAO;AACT;AAEO,SAAS,qBACd,KACA,MACyC;AACzC,QAAM,MACJ,IAAI,WAAW,WAAW,sBAAsB;AAClD,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,UAAmD,CAAC;AAC1D,MAAI,WAAW;AACf,aAAW,OAAO,MAAM;AACtB,UAAM,MAAM,aAAa,KAAK,GAAG;AACjC,UAAM,QAAQ,OAAO,OAAO,KAAK,mBAAmB,GAAG;AACvD,QAAI,CAAC,OAAO;AACV,iBAAW;AACX;AAAA,IACF;AACA,QAAI,IAAI,eAAe,UAAU,KAAK,IAAI,KAAK,EAAG;AAClD,SAAK,IAAI,KAAK;AACd,YAAQ,KAAK,EAAE,OAAO,OAAO,MAAM,CAAC;AACpC,QAAI,QAAQ,UAAU,IAAK;AAAA,EAC7B;AACA,QAAM,OAAO,IAAI,eAAe,SAC5B,CAAC,GAAG,IAAI,aAAa,IACrB,QAAQ,KAAK,CAAC,GAAG,MAAM,EAAE,MAAM,cAAc,EAAE,KAAK,CAAC;AAGzD,MAAI,UAAU;AACZ,SAAK;AAAA,MACH,EAAE,OAAO,oBAAoB,OAAO,UAAU;AAAA,MAC9C,EAAE,OAAO,wBAAwB,OAAO,cAAc;AAAA,IACxD;AAAA,EACF;AACA,SAAO;AACT;AAEO,SAAS,gBACd,MACQ;AACR,UAAQ,MAAM;AAAA,IACZ,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,EACX;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/data-table/filter-engine.ts","../../src/data-table/infer-filter.ts"],"sourcesContent":["import type {\n ColumnFilterValue,\n ColumnFiltersState,\n MatrxColumnDef,\n SortState,\n TableSearchMatchMode,\n} from \"./types\";\nimport {\n completeLayeredFilterRules,\n layeredFilterMatchesValue,\n type LayeredFilterRule,\n} from \"./layered-filters\";\n\n/**\n * Sentinel select-filter values for \"no value\" / \"any value\" — every select\n * filter offers them automatically when the column has empty cells, so any\n * table can isolate rows missing a value (or having one) without extra config.\n */\nexport const SELECT_EMPTY_VALUE = \"__empty__\";\nexport const SELECT_NOT_EMPTY_VALUE = \"__not_empty__\";\n\nexport function columnId<T>(col: MatrxColumnDef<T>): string {\n if (col.id) return col.id;\n if (col.accessorKey) return String(col.accessorKey);\n throw new Error(\"MatrxDataTable column requires `id` or `accessorKey`\");\n}\n\nexport function getCellValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n if (col.accessorFn) return col.accessorFn(row);\n if (col.accessorKey) {\n return (row as Record<string, unknown>)[col.accessorKey];\n }\n return undefined;\n}\n\nexport function getFilterValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n return col.filterValue ? col.filterValue(row) : getCellValue(row, col);\n}\n\nexport function getSortValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n return col.sortValue ? col.sortValue(row) : getCellValue(row, col);\n}\n\nexport function stringifyCellValue(value: unknown): string {\n if (value == null) return \"\";\n if (typeof value === \"string\") return value;\n if (typeof value === \"number\" || typeof value === \"boolean\") {\n return String(value);\n }\n if (value instanceof Date) return value.toISOString();\n try {\n return JSON.stringify(value);\n } catch {\n return String(value);\n }\n}\n\n/** Unicode-aware tokenization shared by whole-word table search. */\nexport function tableSearchWords(value: string): string[] {\n return value\n .toLocaleLowerCase()\n .split(/[^\\p{L}\\p{N}]+/u)\n .filter(Boolean);\n}\n\n/**\n * Match the primary table search against one row's searchable text. In\n * whole-word mode every entered word must exist as a complete token, so\n * `are` matches `we are ready` but never `hardware`.\n */\nexport function matchesTableSearch(\n searchableText: string,\n query: string,\n mode: TableSearchMatchMode = \"contains\",\n): boolean {\n const normalizedQuery = query.trim().toLocaleLowerCase();\n if (!normalizedQuery) return true;\n if (mode === \"contains\") {\n return searchableText.toLocaleLowerCase().includes(normalizedQuery);\n }\n const actualWords = new Set(tableSearchWords(searchableText));\n const expectedWords = tableSearchWords(normalizedQuery);\n return (\n expectedWords.length > 0 &&\n expectedWords.every((word) => actualWords.has(word))\n );\n}\n\nfunction compareValues(a: unknown, b: unknown): number {\n if (a == null && b == null) return 0;\n if (a == null) return -1;\n if (b == null) return 1;\n if (typeof a === \"number\" && typeof b === \"number\") return a - b;\n if (typeof a === \"boolean\" && typeof b === \"boolean\") {\n return Number(a) - Number(b);\n }\n return stringifyCellValue(a).localeCompare(stringifyCellValue(b), undefined, {\n numeric: true,\n sensitivity: \"base\",\n });\n}\n\nexport function passesColumnFilter(\n value: unknown,\n filter: ColumnFilterValue,\n): boolean {\n const matches = (() => {\n switch (filter.kind) {\n case \"text\": {\n const s = stringifyCellValue(value);\n if (filter.mode === \"null\") return value == null;\n if (filter.mode === \"not_null\") return value != null;\n if (filter.mode === \"empty\") return s.trim() === \"\";\n if (filter.mode === \"not_empty\") return s.trim() !== \"\";\n const q = filter.value.trim().toLowerCase();\n if (!q) return true;\n return s.toLowerCase().includes(q);\n }\n case \"select\": {\n const selected =\n filter.values ??\n (filter.value && filter.value !== \"__all__\" ? [filter.value] : []);\n if (selected.length === 0) return true;\n const s = stringifyCellValue(value);\n // OR semantics across the set; sentinels compose (\"A or (empty)\").\n return selected.some((v) =>\n v === SELECT_EMPTY_VALUE\n ? s === \"\"\n : v === SELECT_NOT_EMPTY_VALUE\n ? s !== \"\"\n : s === v,\n );\n }\n case \"boolean\": {\n return Boolean(value) === filter.value;\n }\n case \"number\": {\n const n =\n typeof value === \"number\"\n ? value\n : Number(stringifyCellValue(value).replace(/[^0-9.-]/g, \"\"));\n if (Number.isNaN(n)) return false;\n if (filter.min !== undefined && n < filter.min) return false;\n if (filter.max !== undefined && n > filter.max) return false;\n return true;\n }\n }\n })();\n return filter.negated ? !matches : matches;\n}\n\nexport function filterAndSortRows<T>(\n rows: T[],\n columns: MatrxColumnDef<T>[],\n columnFilters: ColumnFiltersState,\n sort: SortState | null,\n globalSearch: string,\n anyOf?: { columnIds: string[]; query: string },\n layeredFilters?: readonly LayeredFilterRule[],\n globalSearchMode: TableSearchMatchMode = \"contains\",\n searchText?: (row: T) => string,\n): T[] {\n const colById = new Map(columns.map((c) => [columnId(c), c]));\n const q = globalSearch.trim().toLowerCase();\n const anyQ = anyOf?.query.trim().toLowerCase() ?? \"\";\n const activeLayeredFilters = completeLayeredFilterRules(layeredFilters);\n\n let result = rows.filter((row) => {\n for (const [id, filter] of Object.entries(columnFilters)) {\n if (!filter) continue;\n const col = colById.get(id);\n if (!col || col.filter === false) continue;\n if (!passesColumnFilter(getFilterValue(row, col), filter)) return false;\n }\n\n for (const rule of activeLayeredFilters) {\n const col = colById.get(rule.field);\n if (!col || !layeredFilterMatchesValue(getFilterValue(row, col), rule)) {\n return false;\n }\n }\n\n if (anyQ && anyOf) {\n const hit = anyOf.columnIds.some((id) => {\n const col = colById.get(id);\n if (!col) return false;\n return stringifyCellValue(getFilterValue(row, col))\n .toLowerCase()\n .includes(anyQ);\n });\n if (!hit) return false;\n }\n\n if (!q) return true;\n const searchableValues = columns\n .filter(\n (col) =>\n col.filter !== false || Boolean(col.accessorKey || col.accessorFn),\n )\n .map((col) => stringifyCellValue(getFilterValue(row, col)));\n const additionalSearchText = searchText?.(row);\n if (additionalSearchText) searchableValues.push(additionalSearchText);\n if (globalSearchMode === \"contains\") {\n return searchableValues.some((value) =>\n matchesTableSearch(value, q, \"contains\"),\n );\n }\n return matchesTableSearch(searchableValues.join(\" \"), q, \"whole_words\");\n });\n\n if (sort) {\n const col = colById.get(sort.id);\n if (col && col.sortable !== false) {\n const dir = sort.direction === \"asc\" ? 1 : -1;\n result = [...result].sort((a, b) => {\n const va = getSortValue(a, col);\n const vb = getSortValue(b, col);\n // Empty cells (null / \"\") sort LAST in both directions — sorting a\n // column should surface its values, never a wall of blanks.\n const ea = va == null || stringifyCellValue(va) === \"\";\n const eb = vb == null || stringifyCellValue(vb) === \"\";\n if (ea !== eb) return ea ? 1 : -1;\n return compareValues(va, vb) * dir;\n });\n }\n }\n\n return result;\n}\n\nexport function countActiveColumnFilters(filters: ColumnFiltersState): number {\n let n = 0;\n for (const f of Object.values(filters)) {\n if (!f) continue;\n switch (f.kind) {\n case \"text\":\n if (f.value.trim() || (f.mode && f.mode !== \"contains\")) n += 1;\n break;\n case \"select\":\n if (f.values ? f.values.length > 0 : f.value && f.value !== \"__all__\")\n n += 1;\n break;\n case \"boolean\":\n n += 1;\n break;\n case \"number\":\n if (f.min !== undefined || f.max !== undefined) n += 1;\n break;\n }\n }\n return n;\n}\n\nexport function isColumnFilterActive(\n filter: ColumnFilterValue | undefined,\n): boolean {\n if (!filter) return false;\n switch (filter.kind) {\n case \"text\":\n return (\n filter.value.trim().length > 0 ||\n (filter.mode !== undefined && filter.mode !== \"contains\")\n );\n case \"select\":\n return filter.values\n ? filter.values.length > 0\n : Boolean(filter.value) && filter.value !== \"__all__\";\n case \"boolean\":\n return true;\n case \"number\":\n return filter.min !== undefined || filter.max !== undefined;\n }\n}\n\n/** Apply draft edits onto a row for display (shallow field merge). */\nexport function applyRowEdits<T>(\n row: T,\n edits: Record<string, unknown> | undefined,\n): T {\n if (!edits || Object.keys(edits).length === 0) return row;\n return { ...row, ...edits };\n}\n","import type { ColumnFilterKind, MatrxColumnDef } from \"./types\";\nimport {\n SELECT_EMPTY_VALUE,\n SELECT_NOT_EMPTY_VALUE,\n getCellValue,\n stringifyCellValue,\n} from \"./filter-engine\";\n\n/** Auto-inference switches to text above this many distinct values… */\nconst SELECT_CARDINALITY_CAP = 24;\n\nexport type ResolvedFilterKind = \"text\" | \"select\" | \"boolean\" | \"number\";\n\n/**\n * Infer a filter UI from sample row values. Prefer select for low-cardinality\n * string/enum columns, boolean for booleans, number for numeric, else text.\n */\nexport function resolveFilterKind<T>(\n col: MatrxColumnDef<T>,\n sampleRows: T[],\n): ResolvedFilterKind | null {\n if (col.filter === false) return null;\n if (col.filter && col.filter !== \"auto\") return col.filter;\n\n const values = sampleRows\n .slice(0, 200)\n .map((row) => getCellValue(row, col))\n .filter((v) => v != null);\n\n if (values.length === 0) return \"text\";\n\n if (values.every((v) => typeof v === \"boolean\")) return \"boolean\";\n if (values.every((v) => typeof v === \"number\" && Number.isFinite(v))) {\n return \"number\";\n }\n\n const strings = values.map(stringifyCellValue);\n const unique = new Set(strings);\n if (\n unique.size >= 1 &&\n unique.size <= SELECT_CARDINALITY_CAP &&\n unique.size < strings.length * 0.6\n ) {\n return \"select\";\n }\n\n return \"text\";\n}\n\nexport function collectSelectOptions<T>(\n col: MatrxColumnDef<T>,\n rows: T[],\n): Array<{ value: string; label: string }> {\n const seen = new Set<string>();\n const options: Array<{ value: string; label: string }> = [];\n let hasEmpty = false;\n for (const row of rows) {\n const raw = getCellValue(row, col);\n const value = raw == null ? \"\" : stringifyCellValue(raw);\n if (!value) {\n hasEmpty = true;\n continue;\n }\n if (col.filterOptions?.length || seen.has(value)) continue;\n seen.add(value);\n options.push({ value, label: value });\n }\n const base = col.filterOptions?.length\n ? [...col.filterOptions]\n : options.sort((a, b) => a.label.localeCompare(b.label));\n // Emptiness sentinels: whenever the column has blank cells, every select\n // filter can isolate them — or exclude them — with zero per-table config.\n if (hasEmpty) {\n base.unshift(\n { value: SELECT_EMPTY_VALUE, label: \"(empty)\" },\n { value: SELECT_NOT_EMPTY_VALUE, label: \"(not empty)\" },\n );\n }\n return base;\n}\n\nexport function filterKindLabel(\n kind: ColumnFilterKind | ResolvedFilterKind,\n): string {\n switch (kind) {\n case \"text\":\n return \"Text\";\n case \"select\":\n return \"Select\";\n case \"boolean\":\n return \"Boolean\";\n case \"number\":\n return \"Number\";\n case \"auto\":\n return \"Auto\";\n case false:\n return \"Off\";\n }\n}\n"],"mappings":";;AAkBO,IAAM,qBAAqB;AAC3B,IAAM,yBAAyB;AAQ/B,SAAS,aAAgB,KAAQ,KAAiC;AACvE,MAAI,IAAI,WAAY,QAAO,IAAI,WAAW,GAAG;AAC7C,MAAI,IAAI,aAAa;AACnB,WAAQ,IAAgC,IAAI,WAAW;AAAA,EACzD;AACA,SAAO;AACT;AAUO,SAAS,mBAAmB,OAAwB;AACzD,MAAI,SAAS,KAAM,QAAO;AAC1B,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,OAAO,UAAU,YAAY,OAAO,UAAU,WAAW;AAC3D,WAAO,OAAO,KAAK;AAAA,EACrB;AACA,MAAI,iBAAiB,KAAM,QAAO,MAAM,YAAY;AACpD,MAAI;AACF,WAAO,KAAK,UAAU,KAAK;AAAA,EAC7B,QAAQ;AACN,WAAO,OAAO,KAAK;AAAA,EACrB;AACF;;;AC9CA,IAAM,yBAAyB;AAQxB,SAAS,kBACd,KACA,YAC2B;AAC3B,MAAI,IAAI,WAAW,MAAO,QAAO;AACjC,MAAI,IAAI,UAAU,IAAI,WAAW,OAAQ,QAAO,IAAI;AAEpD,QAAM,SAAS,WACZ,MAAM,GAAG,GAAG,EACZ,IAAI,CAAC,QAAQ,aAAa,KAAK,GAAG,CAAC,EACnC,OAAO,CAAC,MAAM,KAAK,IAAI;AAE1B,MAAI,OAAO,WAAW,EAAG,QAAO;AAEhC,MAAI,OAAO,MAAM,CAAC,MAAM,OAAO,MAAM,SAAS,EAAG,QAAO;AACxD,MAAI,OAAO,MAAM,CAAC,MAAM,OAAO,MAAM,YAAY,OAAO,SAAS,CAAC,CAAC,GAAG;AACpE,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,OAAO,IAAI,kBAAkB;AAC7C,QAAM,SAAS,IAAI,IAAI,OAAO;AAC9B,MACE,OAAO,QAAQ,KACf,OAAO,QAAQ,0BACf,OAAO,OAAO,QAAQ,SAAS,KAC/B;AACA,WAAO;AAAA,EACT;AAEA,SAAO;AACT;AAEO,SAAS,qBACd,KACA,MACyC;AACzC,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,UAAmD,CAAC;AAC1D,MAAI,WAAW;AACf,aAAW,OAAO,MAAM;AACtB,UAAM,MAAM,aAAa,KAAK,GAAG;AACjC,UAAM,QAAQ,OAAO,OAAO,KAAK,mBAAmB,GAAG;AACvD,QAAI,CAAC,OAAO;AACV,iBAAW;AACX;AAAA,IACF;AACA,QAAI,IAAI,eAAe,UAAU,KAAK,IAAI,KAAK,EAAG;AAClD,SAAK,IAAI,KAAK;AACd,YAAQ,KAAK,EAAE,OAAO,OAAO,MAAM,CAAC;AAAA,EACtC;AACA,QAAM,OAAO,IAAI,eAAe,SAC5B,CAAC,GAAG,IAAI,aAAa,IACrB,QAAQ,KAAK,CAAC,GAAG,MAAM,EAAE,MAAM,cAAc,EAAE,KAAK,CAAC;AAGzD,MAAI,UAAU;AACZ,SAAK;AAAA,MACH,EAAE,OAAO,oBAAoB,OAAO,UAAU;AAAA,MAC9C,EAAE,OAAO,wBAAwB,OAAO,cAAc;AAAA,IACxD;AAAA,EACF;AACA,SAAO;AACT;AAEO,SAAS,gBACd,MACQ;AACR,UAAQ,MAAM;AAAA,IACZ,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,EACX;AACF;","names":[]}
@@ -52,7 +52,6 @@ function stringifyCellValue(value) {
52
52
 
53
53
  // src/data-table/infer-filter.ts
54
54
  var SELECT_CARDINALITY_CAP = 24;
55
- var EXPLICIT_SELECT_CAP = 500;
56
55
  function resolveFilterKind(col, sampleRows) {
57
56
  if (col.filter === false) return null;
58
57
  if (col.filter && col.filter !== "auto") return col.filter;
@@ -70,7 +69,6 @@ function resolveFilterKind(col, sampleRows) {
70
69
  return "text";
71
70
  }
72
71
  function collectSelectOptions(col, rows) {
73
- const cap = col.filter === "select" ? EXPLICIT_SELECT_CAP : SELECT_CARDINALITY_CAP;
74
72
  const seen = /* @__PURE__ */ new Set();
75
73
  const options = [];
76
74
  let hasEmpty = false;
@@ -84,7 +82,6 @@ function collectSelectOptions(col, rows) {
84
82
  if (col.filterOptions?.length || seen.has(value)) continue;
85
83
  seen.add(value);
86
84
  options.push({ value, label: value });
87
- if (options.length >= cap) break;
88
85
  }
89
86
  const base = col.filterOptions?.length ? [...col.filterOptions] : options.sort((a, b) => a.label.localeCompare(b.label));
90
87
  if (hasEmpty) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/data-table/query-control.ts","../../src/data-table/filter-engine.ts","../../src/data-table/infer-filter.ts"],"sourcesContent":["import {\n collectSelectOptions,\n resolveFilterKind,\n type ResolvedFilterKind,\n} from \"./infer-filter\";\nimport type { MatrxColumnDef, MatrxDataTableQueryState } from \"./types\";\n\nexport interface QueryFilterMeta {\n kind: ResolvedFilterKind | null;\n options: Array<{ value: string; label: string }>;\n}\n\n/**\n * Resolve header-filter metadata without inferring remote cardinality from the\n * current page. Controlled callers must declare select options explicitly;\n * local tables preserve the original inference behavior.\n */\nexport function resolveQueryFilterMeta<T>(\n column: MatrxColumnDef<T>,\n rows: T[],\n controlled: boolean,\n): QueryFilterMeta {\n if (!controlled) {\n const kind = resolveFilterKind(column, rows);\n return {\n kind,\n options: kind === \"select\" ? collectSelectOptions(column, rows) : [],\n };\n }\n if (column.filter === false) return { kind: null, options: [] };\n const declared = column.filter;\n const kind: ResolvedFilterKind =\n declared && declared !== \"auto\"\n ? declared\n : column.filterOptions?.length\n ? \"select\"\n : \"text\";\n return {\n kind,\n options: kind === \"select\" ? (column.filterOptions ?? []) : [],\n };\n}\n\nexport interface QueryStateChangeOptions {\n /** Reset to the first page after a query-shape change. */\n resetPage?: boolean;\n}\n\n/**\n * Build the next controlled query state without mutating the caller's object.\n * Search, filters, sort, facets, and page-size changes pass `resetPage: true`;\n * direct pagination passes a page patch without resetting it.\n */\nexport function nextQueryState(\n current: MatrxDataTableQueryState,\n patch: Partial<MatrxDataTableQueryState>,\n options: QueryStateChangeOptions = {},\n): MatrxDataTableQueryState {\n return {\n ...current,\n ...patch,\n page: options.resetPage ? 1 : (patch.page ?? current.page),\n };\n}\n\n/** Resolve a safe one-based page for the latest remote total. */\nexport function safeQueryPage(\n requestedPage: number,\n totalItems: number,\n pageSize: number,\n): number {\n const safeTotal = Math.max(0, totalItems);\n const safeSize = Math.max(1, pageSize);\n const pageCount = Math.max(1, Math.ceil(safeTotal / safeSize));\n return Math.min(Math.max(1, requestedPage), pageCount);\n}\n\n","import type {\n ColumnFilterValue,\n ColumnFiltersState,\n MatrxColumnDef,\n SortState,\n TableSearchMatchMode,\n} from \"./types\";\nimport {\n completeLayeredFilterRules,\n layeredFilterMatchesValue,\n type LayeredFilterRule,\n} from \"./layered-filters\";\n\n/**\n * Sentinel select-filter values for \"no value\" / \"any value\" — every select\n * filter offers them automatically when the column has empty cells, so any\n * table can isolate rows missing a value (or having one) without extra config.\n */\nexport const SELECT_EMPTY_VALUE = \"__empty__\";\nexport const SELECT_NOT_EMPTY_VALUE = \"__not_empty__\";\n\nexport function columnId<T>(col: MatrxColumnDef<T>): string {\n if (col.id) return col.id;\n if (col.accessorKey) return String(col.accessorKey);\n throw new Error(\"MatrxDataTable column requires `id` or `accessorKey`\");\n}\n\nexport function getCellValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n if (col.accessorFn) return col.accessorFn(row);\n if (col.accessorKey) {\n return (row as Record<string, unknown>)[col.accessorKey];\n }\n return undefined;\n}\n\nexport function getFilterValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n return col.filterValue ? col.filterValue(row) : getCellValue(row, col);\n}\n\nexport function getSortValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n return col.sortValue ? col.sortValue(row) : getCellValue(row, col);\n}\n\nexport function stringifyCellValue(value: unknown): string {\n if (value == null) return \"\";\n if (typeof value === \"string\") return value;\n if (typeof value === \"number\" || typeof value === \"boolean\") {\n return String(value);\n }\n if (value instanceof Date) return value.toISOString();\n try {\n return JSON.stringify(value);\n } catch {\n return String(value);\n }\n}\n\n/** Unicode-aware tokenization shared by whole-word table search. */\nexport function tableSearchWords(value: string): string[] {\n return value\n .toLocaleLowerCase()\n .split(/[^\\p{L}\\p{N}]+/u)\n .filter(Boolean);\n}\n\n/**\n * Match the primary table search against one row's searchable text. In\n * whole-word mode every entered word must exist as a complete token, so\n * `are` matches `we are ready` but never `hardware`.\n */\nexport function matchesTableSearch(\n searchableText: string,\n query: string,\n mode: TableSearchMatchMode = \"contains\",\n): boolean {\n const normalizedQuery = query.trim().toLocaleLowerCase();\n if (!normalizedQuery) return true;\n if (mode === \"contains\") {\n return searchableText.toLocaleLowerCase().includes(normalizedQuery);\n }\n const actualWords = new Set(tableSearchWords(searchableText));\n const expectedWords = tableSearchWords(normalizedQuery);\n return (\n expectedWords.length > 0 &&\n expectedWords.every((word) => actualWords.has(word))\n );\n}\n\nfunction compareValues(a: unknown, b: unknown): number {\n if (a == null && b == null) return 0;\n if (a == null) return -1;\n if (b == null) return 1;\n if (typeof a === \"number\" && typeof b === \"number\") return a - b;\n if (typeof a === \"boolean\" && typeof b === \"boolean\") {\n return Number(a) - Number(b);\n }\n return stringifyCellValue(a).localeCompare(stringifyCellValue(b), undefined, {\n numeric: true,\n sensitivity: \"base\",\n });\n}\n\nexport function passesColumnFilter(\n value: unknown,\n filter: ColumnFilterValue,\n): boolean {\n const matches = (() => {\n switch (filter.kind) {\n case \"text\": {\n const s = stringifyCellValue(value);\n if (filter.mode === \"null\") return value == null;\n if (filter.mode === \"not_null\") return value != null;\n if (filter.mode === \"empty\") return s.trim() === \"\";\n if (filter.mode === \"not_empty\") return s.trim() !== \"\";\n const q = filter.value.trim().toLowerCase();\n if (!q) return true;\n return s.toLowerCase().includes(q);\n }\n case \"select\": {\n const selected =\n filter.values ??\n (filter.value && filter.value !== \"__all__\" ? [filter.value] : []);\n if (selected.length === 0) return true;\n const s = stringifyCellValue(value);\n // OR semantics across the set; sentinels compose (\"A or (empty)\").\n return selected.some((v) =>\n v === SELECT_EMPTY_VALUE\n ? s === \"\"\n : v === SELECT_NOT_EMPTY_VALUE\n ? s !== \"\"\n : s === v,\n );\n }\n case \"boolean\": {\n return Boolean(value) === filter.value;\n }\n case \"number\": {\n const n =\n typeof value === \"number\"\n ? value\n : Number(stringifyCellValue(value).replace(/[^0-9.-]/g, \"\"));\n if (Number.isNaN(n)) return false;\n if (filter.min !== undefined && n < filter.min) return false;\n if (filter.max !== undefined && n > filter.max) return false;\n return true;\n }\n }\n })();\n return filter.negated ? !matches : matches;\n}\n\nexport function filterAndSortRows<T>(\n rows: T[],\n columns: MatrxColumnDef<T>[],\n columnFilters: ColumnFiltersState,\n sort: SortState | null,\n globalSearch: string,\n anyOf?: { columnIds: string[]; query: string },\n layeredFilters?: readonly LayeredFilterRule[],\n globalSearchMode: TableSearchMatchMode = \"contains\",\n searchText?: (row: T) => string,\n): T[] {\n const colById = new Map(columns.map((c) => [columnId(c), c]));\n const q = globalSearch.trim().toLowerCase();\n const anyQ = anyOf?.query.trim().toLowerCase() ?? \"\";\n const activeLayeredFilters = completeLayeredFilterRules(layeredFilters);\n\n let result = rows.filter((row) => {\n for (const [id, filter] of Object.entries(columnFilters)) {\n if (!filter) continue;\n const col = colById.get(id);\n if (!col || col.filter === false) continue;\n if (!passesColumnFilter(getFilterValue(row, col), filter)) return false;\n }\n\n for (const rule of activeLayeredFilters) {\n const col = colById.get(rule.field);\n if (!col || !layeredFilterMatchesValue(getFilterValue(row, col), rule)) {\n return false;\n }\n }\n\n if (anyQ && anyOf) {\n const hit = anyOf.columnIds.some((id) => {\n const col = colById.get(id);\n if (!col) return false;\n return stringifyCellValue(getFilterValue(row, col))\n .toLowerCase()\n .includes(anyQ);\n });\n if (!hit) return false;\n }\n\n if (!q) return true;\n const searchableValues = columns\n .filter(\n (col) =>\n col.filter !== false || Boolean(col.accessorKey || col.accessorFn),\n )\n .map((col) => stringifyCellValue(getFilterValue(row, col)));\n const additionalSearchText = searchText?.(row);\n if (additionalSearchText) searchableValues.push(additionalSearchText);\n if (globalSearchMode === \"contains\") {\n return searchableValues.some((value) =>\n matchesTableSearch(value, q, \"contains\"),\n );\n }\n return matchesTableSearch(searchableValues.join(\" \"), q, \"whole_words\");\n });\n\n if (sort) {\n const col = colById.get(sort.id);\n if (col && col.sortable !== false) {\n const dir = sort.direction === \"asc\" ? 1 : -1;\n result = [...result].sort((a, b) => {\n const va = getSortValue(a, col);\n const vb = getSortValue(b, col);\n // Empty cells (null / \"\") sort LAST in both directions — sorting a\n // column should surface its values, never a wall of blanks.\n const ea = va == null || stringifyCellValue(va) === \"\";\n const eb = vb == null || stringifyCellValue(vb) === \"\";\n if (ea !== eb) return ea ? 1 : -1;\n return compareValues(va, vb) * dir;\n });\n }\n }\n\n return result;\n}\n\nexport function countActiveColumnFilters(filters: ColumnFiltersState): number {\n let n = 0;\n for (const f of Object.values(filters)) {\n if (!f) continue;\n switch (f.kind) {\n case \"text\":\n if (f.value.trim() || (f.mode && f.mode !== \"contains\")) n += 1;\n break;\n case \"select\":\n if (f.values ? f.values.length > 0 : f.value && f.value !== \"__all__\")\n n += 1;\n break;\n case \"boolean\":\n n += 1;\n break;\n case \"number\":\n if (f.min !== undefined || f.max !== undefined) n += 1;\n break;\n }\n }\n return n;\n}\n\nexport function isColumnFilterActive(\n filter: ColumnFilterValue | undefined,\n): boolean {\n if (!filter) return false;\n switch (filter.kind) {\n case \"text\":\n return (\n filter.value.trim().length > 0 ||\n (filter.mode !== undefined && filter.mode !== \"contains\")\n );\n case \"select\":\n return filter.values\n ? filter.values.length > 0\n : Boolean(filter.value) && filter.value !== \"__all__\";\n case \"boolean\":\n return true;\n case \"number\":\n return filter.min !== undefined || filter.max !== undefined;\n }\n}\n\n/** Apply draft edits onto a row for display (shallow field merge). */\nexport function applyRowEdits<T>(\n row: T,\n edits: Record<string, unknown> | undefined,\n): T {\n if (!edits || Object.keys(edits).length === 0) return row;\n return { ...row, ...edits };\n}\n","import type { ColumnFilterKind, MatrxColumnDef } from \"./types\";\nimport {\n SELECT_EMPTY_VALUE,\n SELECT_NOT_EMPTY_VALUE,\n getCellValue,\n stringifyCellValue,\n} from \"./filter-engine\";\n\n/** Auto-inference switches to text above this many distinct values… */\nconst SELECT_CARDINALITY_CAP = 24;\n/** …but a column that EXPLICITLY declares `filter: \"select\"` lists them all\n * (capped only as a runaway guard) — never silently truncate declared intent. */\nconst EXPLICIT_SELECT_CAP = 500;\n\nexport type ResolvedFilterKind = \"text\" | \"select\" | \"boolean\" | \"number\";\n\n/**\n * Infer a filter UI from sample row values. Prefer select for low-cardinality\n * string/enum columns, boolean for booleans, number for numeric, else text.\n */\nexport function resolveFilterKind<T>(\n col: MatrxColumnDef<T>,\n sampleRows: T[],\n): ResolvedFilterKind | null {\n if (col.filter === false) return null;\n if (col.filter && col.filter !== \"auto\") return col.filter;\n\n const values = sampleRows\n .slice(0, 200)\n .map((row) => getCellValue(row, col))\n .filter((v) => v != null);\n\n if (values.length === 0) return \"text\";\n\n if (values.every((v) => typeof v === \"boolean\")) return \"boolean\";\n if (values.every((v) => typeof v === \"number\" && Number.isFinite(v))) {\n return \"number\";\n }\n\n const strings = values.map(stringifyCellValue);\n const unique = new Set(strings);\n if (\n unique.size >= 1 &&\n unique.size <= SELECT_CARDINALITY_CAP &&\n unique.size < strings.length * 0.6\n ) {\n return \"select\";\n }\n\n return \"text\";\n}\n\nexport function collectSelectOptions<T>(\n col: MatrxColumnDef<T>,\n rows: T[],\n): Array<{ value: string; label: string }> {\n const cap =\n col.filter === \"select\" ? EXPLICIT_SELECT_CAP : SELECT_CARDINALITY_CAP;\n const seen = new Set<string>();\n const options: Array<{ value: string; label: string }> = [];\n let hasEmpty = false;\n for (const row of rows) {\n const raw = getCellValue(row, col);\n const value = raw == null ? \"\" : stringifyCellValue(raw);\n if (!value) {\n hasEmpty = true;\n continue;\n }\n if (col.filterOptions?.length || seen.has(value)) continue;\n seen.add(value);\n options.push({ value, label: value });\n if (options.length >= cap) break;\n }\n const base = col.filterOptions?.length\n ? [...col.filterOptions]\n : options.sort((a, b) => a.label.localeCompare(b.label));\n // Emptiness sentinels: whenever the column has blank cells, every select\n // filter can isolate them — or exclude them — with zero per-table config.\n if (hasEmpty) {\n base.unshift(\n { value: SELECT_EMPTY_VALUE, label: \"(empty)\" },\n { value: SELECT_NOT_EMPTY_VALUE, label: \"(not empty)\" },\n );\n }\n return base;\n}\n\nexport function filterKindLabel(\n kind: ColumnFilterKind | ResolvedFilterKind,\n): string {\n switch (kind) {\n case \"text\":\n return \"Text\";\n case \"select\":\n return \"Select\";\n case \"boolean\":\n return \"Boolean\";\n case \"number\":\n return \"Number\";\n case \"auto\":\n return \"Auto\";\n case false:\n return \"Off\";\n }\n}\n\n"],"mappings":"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","names":["kind"]}
1
+ {"version":3,"sources":["../../src/data-table/query-control.ts","../../src/data-table/filter-engine.ts","../../src/data-table/infer-filter.ts"],"sourcesContent":["import {\n collectSelectOptions,\n resolveFilterKind,\n type ResolvedFilterKind,\n} from \"./infer-filter\";\nimport type { MatrxColumnDef, MatrxDataTableQueryState } from \"./types\";\n\nexport interface QueryFilterMeta {\n kind: ResolvedFilterKind | null;\n options: Array<{ value: string; label: string }>;\n}\n\n/**\n * Resolve header-filter metadata without inferring remote cardinality from the\n * current page. Controlled callers must declare select options explicitly;\n * local tables preserve the original inference behavior.\n */\nexport function resolveQueryFilterMeta<T>(\n column: MatrxColumnDef<T>,\n rows: T[],\n controlled: boolean,\n): QueryFilterMeta {\n if (!controlled) {\n const kind = resolveFilterKind(column, rows);\n return {\n kind,\n options: kind === \"select\" ? collectSelectOptions(column, rows) : [],\n };\n }\n if (column.filter === false) return { kind: null, options: [] };\n const declared = column.filter;\n const kind: ResolvedFilterKind =\n declared && declared !== \"auto\"\n ? declared\n : column.filterOptions?.length\n ? \"select\"\n : \"text\";\n return {\n kind,\n options: kind === \"select\" ? (column.filterOptions ?? []) : [],\n };\n}\n\nexport interface QueryStateChangeOptions {\n /** Reset to the first page after a query-shape change. */\n resetPage?: boolean;\n}\n\n/**\n * Build the next controlled query state without mutating the caller's object.\n * Search, filters, sort, facets, and page-size changes pass `resetPage: true`;\n * direct pagination passes a page patch without resetting it.\n */\nexport function nextQueryState(\n current: MatrxDataTableQueryState,\n patch: Partial<MatrxDataTableQueryState>,\n options: QueryStateChangeOptions = {},\n): MatrxDataTableQueryState {\n return {\n ...current,\n ...patch,\n page: options.resetPage ? 1 : (patch.page ?? current.page),\n };\n}\n\n/** Resolve a safe one-based page for the latest remote total. */\nexport function safeQueryPage(\n requestedPage: number,\n totalItems: number,\n pageSize: number,\n): number {\n const safeTotal = Math.max(0, totalItems);\n const safeSize = Math.max(1, pageSize);\n const pageCount = Math.max(1, Math.ceil(safeTotal / safeSize));\n return Math.min(Math.max(1, requestedPage), pageCount);\n}\n\n","import type {\n ColumnFilterValue,\n ColumnFiltersState,\n MatrxColumnDef,\n SortState,\n TableSearchMatchMode,\n} from \"./types\";\nimport {\n completeLayeredFilterRules,\n layeredFilterMatchesValue,\n type LayeredFilterRule,\n} from \"./layered-filters\";\n\n/**\n * Sentinel select-filter values for \"no value\" / \"any value\" — every select\n * filter offers them automatically when the column has empty cells, so any\n * table can isolate rows missing a value (or having one) without extra config.\n */\nexport const SELECT_EMPTY_VALUE = \"__empty__\";\nexport const SELECT_NOT_EMPTY_VALUE = \"__not_empty__\";\n\nexport function columnId<T>(col: MatrxColumnDef<T>): string {\n if (col.id) return col.id;\n if (col.accessorKey) return String(col.accessorKey);\n throw new Error(\"MatrxDataTable column requires `id` or `accessorKey`\");\n}\n\nexport function getCellValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n if (col.accessorFn) return col.accessorFn(row);\n if (col.accessorKey) {\n return (row as Record<string, unknown>)[col.accessorKey];\n }\n return undefined;\n}\n\nexport function getFilterValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n return col.filterValue ? col.filterValue(row) : getCellValue(row, col);\n}\n\nexport function getSortValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n return col.sortValue ? col.sortValue(row) : getCellValue(row, col);\n}\n\nexport function stringifyCellValue(value: unknown): string {\n if (value == null) return \"\";\n if (typeof value === \"string\") return value;\n if (typeof value === \"number\" || typeof value === \"boolean\") {\n return String(value);\n }\n if (value instanceof Date) return value.toISOString();\n try {\n return JSON.stringify(value);\n } catch {\n return String(value);\n }\n}\n\n/** Unicode-aware tokenization shared by whole-word table search. */\nexport function tableSearchWords(value: string): string[] {\n return value\n .toLocaleLowerCase()\n .split(/[^\\p{L}\\p{N}]+/u)\n .filter(Boolean);\n}\n\n/**\n * Match the primary table search against one row's searchable text. In\n * whole-word mode every entered word must exist as a complete token, so\n * `are` matches `we are ready` but never `hardware`.\n */\nexport function matchesTableSearch(\n searchableText: string,\n query: string,\n mode: TableSearchMatchMode = \"contains\",\n): boolean {\n const normalizedQuery = query.trim().toLocaleLowerCase();\n if (!normalizedQuery) return true;\n if (mode === \"contains\") {\n return searchableText.toLocaleLowerCase().includes(normalizedQuery);\n }\n const actualWords = new Set(tableSearchWords(searchableText));\n const expectedWords = tableSearchWords(normalizedQuery);\n return (\n expectedWords.length > 0 &&\n expectedWords.every((word) => actualWords.has(word))\n );\n}\n\nfunction compareValues(a: unknown, b: unknown): number {\n if (a == null && b == null) return 0;\n if (a == null) return -1;\n if (b == null) return 1;\n if (typeof a === \"number\" && typeof b === \"number\") return a - b;\n if (typeof a === \"boolean\" && typeof b === \"boolean\") {\n return Number(a) - Number(b);\n }\n return stringifyCellValue(a).localeCompare(stringifyCellValue(b), undefined, {\n numeric: true,\n sensitivity: \"base\",\n });\n}\n\nexport function passesColumnFilter(\n value: unknown,\n filter: ColumnFilterValue,\n): boolean {\n const matches = (() => {\n switch (filter.kind) {\n case \"text\": {\n const s = stringifyCellValue(value);\n if (filter.mode === \"null\") return value == null;\n if (filter.mode === \"not_null\") return value != null;\n if (filter.mode === \"empty\") return s.trim() === \"\";\n if (filter.mode === \"not_empty\") return s.trim() !== \"\";\n const q = filter.value.trim().toLowerCase();\n if (!q) return true;\n return s.toLowerCase().includes(q);\n }\n case \"select\": {\n const selected =\n filter.values ??\n (filter.value && filter.value !== \"__all__\" ? [filter.value] : []);\n if (selected.length === 0) return true;\n const s = stringifyCellValue(value);\n // OR semantics across the set; sentinels compose (\"A or (empty)\").\n return selected.some((v) =>\n v === SELECT_EMPTY_VALUE\n ? s === \"\"\n : v === SELECT_NOT_EMPTY_VALUE\n ? s !== \"\"\n : s === v,\n );\n }\n case \"boolean\": {\n return Boolean(value) === filter.value;\n }\n case \"number\": {\n const n =\n typeof value === \"number\"\n ? value\n : Number(stringifyCellValue(value).replace(/[^0-9.-]/g, \"\"));\n if (Number.isNaN(n)) return false;\n if (filter.min !== undefined && n < filter.min) return false;\n if (filter.max !== undefined && n > filter.max) return false;\n return true;\n }\n }\n })();\n return filter.negated ? !matches : matches;\n}\n\nexport function filterAndSortRows<T>(\n rows: T[],\n columns: MatrxColumnDef<T>[],\n columnFilters: ColumnFiltersState,\n sort: SortState | null,\n globalSearch: string,\n anyOf?: { columnIds: string[]; query: string },\n layeredFilters?: readonly LayeredFilterRule[],\n globalSearchMode: TableSearchMatchMode = \"contains\",\n searchText?: (row: T) => string,\n): T[] {\n const colById = new Map(columns.map((c) => [columnId(c), c]));\n const q = globalSearch.trim().toLowerCase();\n const anyQ = anyOf?.query.trim().toLowerCase() ?? \"\";\n const activeLayeredFilters = completeLayeredFilterRules(layeredFilters);\n\n let result = rows.filter((row) => {\n for (const [id, filter] of Object.entries(columnFilters)) {\n if (!filter) continue;\n const col = colById.get(id);\n if (!col || col.filter === false) continue;\n if (!passesColumnFilter(getFilterValue(row, col), filter)) return false;\n }\n\n for (const rule of activeLayeredFilters) {\n const col = colById.get(rule.field);\n if (!col || !layeredFilterMatchesValue(getFilterValue(row, col), rule)) {\n return false;\n }\n }\n\n if (anyQ && anyOf) {\n const hit = anyOf.columnIds.some((id) => {\n const col = colById.get(id);\n if (!col) return false;\n return stringifyCellValue(getFilterValue(row, col))\n .toLowerCase()\n .includes(anyQ);\n });\n if (!hit) return false;\n }\n\n if (!q) return true;\n const searchableValues = columns\n .filter(\n (col) =>\n col.filter !== false || Boolean(col.accessorKey || col.accessorFn),\n )\n .map((col) => stringifyCellValue(getFilterValue(row, col)));\n const additionalSearchText = searchText?.(row);\n if (additionalSearchText) searchableValues.push(additionalSearchText);\n if (globalSearchMode === \"contains\") {\n return searchableValues.some((value) =>\n matchesTableSearch(value, q, \"contains\"),\n );\n }\n return matchesTableSearch(searchableValues.join(\" \"), q, \"whole_words\");\n });\n\n if (sort) {\n const col = colById.get(sort.id);\n if (col && col.sortable !== false) {\n const dir = sort.direction === \"asc\" ? 1 : -1;\n result = [...result].sort((a, b) => {\n const va = getSortValue(a, col);\n const vb = getSortValue(b, col);\n // Empty cells (null / \"\") sort LAST in both directions — sorting a\n // column should surface its values, never a wall of blanks.\n const ea = va == null || stringifyCellValue(va) === \"\";\n const eb = vb == null || stringifyCellValue(vb) === \"\";\n if (ea !== eb) return ea ? 1 : -1;\n return compareValues(va, vb) * dir;\n });\n }\n }\n\n return result;\n}\n\nexport function countActiveColumnFilters(filters: ColumnFiltersState): number {\n let n = 0;\n for (const f of Object.values(filters)) {\n if (!f) continue;\n switch (f.kind) {\n case \"text\":\n if (f.value.trim() || (f.mode && f.mode !== \"contains\")) n += 1;\n break;\n case \"select\":\n if (f.values ? f.values.length > 0 : f.value && f.value !== \"__all__\")\n n += 1;\n break;\n case \"boolean\":\n n += 1;\n break;\n case \"number\":\n if (f.min !== undefined || f.max !== undefined) n += 1;\n break;\n }\n }\n return n;\n}\n\nexport function isColumnFilterActive(\n filter: ColumnFilterValue | undefined,\n): boolean {\n if (!filter) return false;\n switch (filter.kind) {\n case \"text\":\n return (\n filter.value.trim().length > 0 ||\n (filter.mode !== undefined && filter.mode !== \"contains\")\n );\n case \"select\":\n return filter.values\n ? filter.values.length > 0\n : Boolean(filter.value) && filter.value !== \"__all__\";\n case \"boolean\":\n return true;\n case \"number\":\n return filter.min !== undefined || filter.max !== undefined;\n }\n}\n\n/** Apply draft edits onto a row for display (shallow field merge). */\nexport function applyRowEdits<T>(\n row: T,\n edits: Record<string, unknown> | undefined,\n): T {\n if (!edits || Object.keys(edits).length === 0) return row;\n return { ...row, ...edits };\n}\n","import type { ColumnFilterKind, MatrxColumnDef } from \"./types\";\nimport {\n SELECT_EMPTY_VALUE,\n SELECT_NOT_EMPTY_VALUE,\n getCellValue,\n stringifyCellValue,\n} from \"./filter-engine\";\n\n/** Auto-inference switches to text above this many distinct values… */\nconst SELECT_CARDINALITY_CAP = 24;\n\nexport type ResolvedFilterKind = \"text\" | \"select\" | \"boolean\" | \"number\";\n\n/**\n * Infer a filter UI from sample row values. Prefer select for low-cardinality\n * string/enum columns, boolean for booleans, number for numeric, else text.\n */\nexport function resolveFilterKind<T>(\n col: MatrxColumnDef<T>,\n sampleRows: T[],\n): ResolvedFilterKind | null {\n if (col.filter === false) return null;\n if (col.filter && col.filter !== \"auto\") return col.filter;\n\n const values = sampleRows\n .slice(0, 200)\n .map((row) => getCellValue(row, col))\n .filter((v) => v != null);\n\n if (values.length === 0) return \"text\";\n\n if (values.every((v) => typeof v === \"boolean\")) return \"boolean\";\n if (values.every((v) => typeof v === \"number\" && Number.isFinite(v))) {\n return \"number\";\n }\n\n const strings = values.map(stringifyCellValue);\n const unique = new Set(strings);\n if (\n unique.size >= 1 &&\n unique.size <= SELECT_CARDINALITY_CAP &&\n unique.size < strings.length * 0.6\n ) {\n return \"select\";\n }\n\n return \"text\";\n}\n\nexport function collectSelectOptions<T>(\n col: MatrxColumnDef<T>,\n rows: T[],\n): Array<{ value: string; label: string }> {\n const seen = new Set<string>();\n const options: Array<{ value: string; label: string }> = [];\n let hasEmpty = false;\n for (const row of rows) {\n const raw = getCellValue(row, col);\n const value = raw == null ? \"\" : stringifyCellValue(raw);\n if (!value) {\n hasEmpty = true;\n continue;\n }\n if (col.filterOptions?.length || seen.has(value)) continue;\n seen.add(value);\n options.push({ value, label: value });\n }\n const base = col.filterOptions?.length\n ? [...col.filterOptions]\n : options.sort((a, b) => a.label.localeCompare(b.label));\n // Emptiness sentinels: whenever the column has blank cells, every select\n // filter can isolate them — or exclude them — with zero per-table config.\n if (hasEmpty) {\n base.unshift(\n { value: SELECT_EMPTY_VALUE, label: \"(empty)\" },\n { value: SELECT_NOT_EMPTY_VALUE, label: \"(not empty)\" },\n );\n }\n return base;\n}\n\nexport function filterKindLabel(\n kind: ColumnFilterKind | ResolvedFilterKind,\n): string {\n switch (kind) {\n case \"text\":\n return \"Text\";\n case \"select\":\n return \"Select\";\n case \"boolean\":\n return \"Boolean\";\n case \"number\":\n return \"Number\";\n case \"auto\":\n return \"Auto\";\n case false:\n return \"Off\";\n }\n}\n"],"mappings":"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","names":["kind"]}
@@ -25,7 +25,6 @@ function stringifyCellValue(value) {
25
25
 
26
26
  // src/data-table/infer-filter.ts
27
27
  var SELECT_CARDINALITY_CAP = 24;
28
- var EXPLICIT_SELECT_CAP = 500;
29
28
  function resolveFilterKind(col, sampleRows) {
30
29
  if (col.filter === false) return null;
31
30
  if (col.filter && col.filter !== "auto") return col.filter;
@@ -43,7 +42,6 @@ function resolveFilterKind(col, sampleRows) {
43
42
  return "text";
44
43
  }
45
44
  function collectSelectOptions(col, rows) {
46
- const cap = col.filter === "select" ? EXPLICIT_SELECT_CAP : SELECT_CARDINALITY_CAP;
47
45
  const seen = /* @__PURE__ */ new Set();
48
46
  const options = [];
49
47
  let hasEmpty = false;
@@ -57,7 +55,6 @@ function collectSelectOptions(col, rows) {
57
55
  if (col.filterOptions?.length || seen.has(value)) continue;
58
56
  seen.add(value);
59
57
  options.push({ value, label: value });
60
- if (options.length >= cap) break;
61
58
  }
62
59
  const base = col.filterOptions?.length ? [...col.filterOptions] : options.sort((a, b) => a.label.localeCompare(b.label));
63
60
  if (hasEmpty) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/data-table/filter-engine.ts","../../src/data-table/infer-filter.ts","../../src/data-table/query-control.ts"],"sourcesContent":["import type {\n ColumnFilterValue,\n ColumnFiltersState,\n MatrxColumnDef,\n SortState,\n TableSearchMatchMode,\n} from \"./types\";\nimport {\n completeLayeredFilterRules,\n layeredFilterMatchesValue,\n type LayeredFilterRule,\n} from \"./layered-filters\";\n\n/**\n * Sentinel select-filter values for \"no value\" / \"any value\" — every select\n * filter offers them automatically when the column has empty cells, so any\n * table can isolate rows missing a value (or having one) without extra config.\n */\nexport const SELECT_EMPTY_VALUE = \"__empty__\";\nexport const SELECT_NOT_EMPTY_VALUE = \"__not_empty__\";\n\nexport function columnId<T>(col: MatrxColumnDef<T>): string {\n if (col.id) return col.id;\n if (col.accessorKey) return String(col.accessorKey);\n throw new Error(\"MatrxDataTable column requires `id` or `accessorKey`\");\n}\n\nexport function getCellValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n if (col.accessorFn) return col.accessorFn(row);\n if (col.accessorKey) {\n return (row as Record<string, unknown>)[col.accessorKey];\n }\n return undefined;\n}\n\nexport function getFilterValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n return col.filterValue ? col.filterValue(row) : getCellValue(row, col);\n}\n\nexport function getSortValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n return col.sortValue ? col.sortValue(row) : getCellValue(row, col);\n}\n\nexport function stringifyCellValue(value: unknown): string {\n if (value == null) return \"\";\n if (typeof value === \"string\") return value;\n if (typeof value === \"number\" || typeof value === \"boolean\") {\n return String(value);\n }\n if (value instanceof Date) return value.toISOString();\n try {\n return JSON.stringify(value);\n } catch {\n return String(value);\n }\n}\n\n/** Unicode-aware tokenization shared by whole-word table search. */\nexport function tableSearchWords(value: string): string[] {\n return value\n .toLocaleLowerCase()\n .split(/[^\\p{L}\\p{N}]+/u)\n .filter(Boolean);\n}\n\n/**\n * Match the primary table search against one row's searchable text. In\n * whole-word mode every entered word must exist as a complete token, so\n * `are` matches `we are ready` but never `hardware`.\n */\nexport function matchesTableSearch(\n searchableText: string,\n query: string,\n mode: TableSearchMatchMode = \"contains\",\n): boolean {\n const normalizedQuery = query.trim().toLocaleLowerCase();\n if (!normalizedQuery) return true;\n if (mode === \"contains\") {\n return searchableText.toLocaleLowerCase().includes(normalizedQuery);\n }\n const actualWords = new Set(tableSearchWords(searchableText));\n const expectedWords = tableSearchWords(normalizedQuery);\n return (\n expectedWords.length > 0 &&\n expectedWords.every((word) => actualWords.has(word))\n );\n}\n\nfunction compareValues(a: unknown, b: unknown): number {\n if (a == null && b == null) return 0;\n if (a == null) return -1;\n if (b == null) return 1;\n if (typeof a === \"number\" && typeof b === \"number\") return a - b;\n if (typeof a === \"boolean\" && typeof b === \"boolean\") {\n return Number(a) - Number(b);\n }\n return stringifyCellValue(a).localeCompare(stringifyCellValue(b), undefined, {\n numeric: true,\n sensitivity: \"base\",\n });\n}\n\nexport function passesColumnFilter(\n value: unknown,\n filter: ColumnFilterValue,\n): boolean {\n const matches = (() => {\n switch (filter.kind) {\n case \"text\": {\n const s = stringifyCellValue(value);\n if (filter.mode === \"null\") return value == null;\n if (filter.mode === \"not_null\") return value != null;\n if (filter.mode === \"empty\") return s.trim() === \"\";\n if (filter.mode === \"not_empty\") return s.trim() !== \"\";\n const q = filter.value.trim().toLowerCase();\n if (!q) return true;\n return s.toLowerCase().includes(q);\n }\n case \"select\": {\n const selected =\n filter.values ??\n (filter.value && filter.value !== \"__all__\" ? [filter.value] : []);\n if (selected.length === 0) return true;\n const s = stringifyCellValue(value);\n // OR semantics across the set; sentinels compose (\"A or (empty)\").\n return selected.some((v) =>\n v === SELECT_EMPTY_VALUE\n ? s === \"\"\n : v === SELECT_NOT_EMPTY_VALUE\n ? s !== \"\"\n : s === v,\n );\n }\n case \"boolean\": {\n return Boolean(value) === filter.value;\n }\n case \"number\": {\n const n =\n typeof value === \"number\"\n ? value\n : Number(stringifyCellValue(value).replace(/[^0-9.-]/g, \"\"));\n if (Number.isNaN(n)) return false;\n if (filter.min !== undefined && n < filter.min) return false;\n if (filter.max !== undefined && n > filter.max) return false;\n return true;\n }\n }\n })();\n return filter.negated ? !matches : matches;\n}\n\nexport function filterAndSortRows<T>(\n rows: T[],\n columns: MatrxColumnDef<T>[],\n columnFilters: ColumnFiltersState,\n sort: SortState | null,\n globalSearch: string,\n anyOf?: { columnIds: string[]; query: string },\n layeredFilters?: readonly LayeredFilterRule[],\n globalSearchMode: TableSearchMatchMode = \"contains\",\n searchText?: (row: T) => string,\n): T[] {\n const colById = new Map(columns.map((c) => [columnId(c), c]));\n const q = globalSearch.trim().toLowerCase();\n const anyQ = anyOf?.query.trim().toLowerCase() ?? \"\";\n const activeLayeredFilters = completeLayeredFilterRules(layeredFilters);\n\n let result = rows.filter((row) => {\n for (const [id, filter] of Object.entries(columnFilters)) {\n if (!filter) continue;\n const col = colById.get(id);\n if (!col || col.filter === false) continue;\n if (!passesColumnFilter(getFilterValue(row, col), filter)) return false;\n }\n\n for (const rule of activeLayeredFilters) {\n const col = colById.get(rule.field);\n if (!col || !layeredFilterMatchesValue(getFilterValue(row, col), rule)) {\n return false;\n }\n }\n\n if (anyQ && anyOf) {\n const hit = anyOf.columnIds.some((id) => {\n const col = colById.get(id);\n if (!col) return false;\n return stringifyCellValue(getFilterValue(row, col))\n .toLowerCase()\n .includes(anyQ);\n });\n if (!hit) return false;\n }\n\n if (!q) return true;\n const searchableValues = columns\n .filter(\n (col) =>\n col.filter !== false || Boolean(col.accessorKey || col.accessorFn),\n )\n .map((col) => stringifyCellValue(getFilterValue(row, col)));\n const additionalSearchText = searchText?.(row);\n if (additionalSearchText) searchableValues.push(additionalSearchText);\n if (globalSearchMode === \"contains\") {\n return searchableValues.some((value) =>\n matchesTableSearch(value, q, \"contains\"),\n );\n }\n return matchesTableSearch(searchableValues.join(\" \"), q, \"whole_words\");\n });\n\n if (sort) {\n const col = colById.get(sort.id);\n if (col && col.sortable !== false) {\n const dir = sort.direction === \"asc\" ? 1 : -1;\n result = [...result].sort((a, b) => {\n const va = getSortValue(a, col);\n const vb = getSortValue(b, col);\n // Empty cells (null / \"\") sort LAST in both directions — sorting a\n // column should surface its values, never a wall of blanks.\n const ea = va == null || stringifyCellValue(va) === \"\";\n const eb = vb == null || stringifyCellValue(vb) === \"\";\n if (ea !== eb) return ea ? 1 : -1;\n return compareValues(va, vb) * dir;\n });\n }\n }\n\n return result;\n}\n\nexport function countActiveColumnFilters(filters: ColumnFiltersState): number {\n let n = 0;\n for (const f of Object.values(filters)) {\n if (!f) continue;\n switch (f.kind) {\n case \"text\":\n if (f.value.trim() || (f.mode && f.mode !== \"contains\")) n += 1;\n break;\n case \"select\":\n if (f.values ? f.values.length > 0 : f.value && f.value !== \"__all__\")\n n += 1;\n break;\n case \"boolean\":\n n += 1;\n break;\n case \"number\":\n if (f.min !== undefined || f.max !== undefined) n += 1;\n break;\n }\n }\n return n;\n}\n\nexport function isColumnFilterActive(\n filter: ColumnFilterValue | undefined,\n): boolean {\n if (!filter) return false;\n switch (filter.kind) {\n case \"text\":\n return (\n filter.value.trim().length > 0 ||\n (filter.mode !== undefined && filter.mode !== \"contains\")\n );\n case \"select\":\n return filter.values\n ? filter.values.length > 0\n : Boolean(filter.value) && filter.value !== \"__all__\";\n case \"boolean\":\n return true;\n case \"number\":\n return filter.min !== undefined || filter.max !== undefined;\n }\n}\n\n/** Apply draft edits onto a row for display (shallow field merge). */\nexport function applyRowEdits<T>(\n row: T,\n edits: Record<string, unknown> | undefined,\n): T {\n if (!edits || Object.keys(edits).length === 0) return row;\n return { ...row, ...edits };\n}\n","import type { ColumnFilterKind, MatrxColumnDef } from \"./types\";\nimport {\n SELECT_EMPTY_VALUE,\n SELECT_NOT_EMPTY_VALUE,\n getCellValue,\n stringifyCellValue,\n} from \"./filter-engine\";\n\n/** Auto-inference switches to text above this many distinct values… */\nconst SELECT_CARDINALITY_CAP = 24;\n/** …but a column that EXPLICITLY declares `filter: \"select\"` lists them all\n * (capped only as a runaway guard) — never silently truncate declared intent. */\nconst EXPLICIT_SELECT_CAP = 500;\n\nexport type ResolvedFilterKind = \"text\" | \"select\" | \"boolean\" | \"number\";\n\n/**\n * Infer a filter UI from sample row values. Prefer select for low-cardinality\n * string/enum columns, boolean for booleans, number for numeric, else text.\n */\nexport function resolveFilterKind<T>(\n col: MatrxColumnDef<T>,\n sampleRows: T[],\n): ResolvedFilterKind | null {\n if (col.filter === false) return null;\n if (col.filter && col.filter !== \"auto\") return col.filter;\n\n const values = sampleRows\n .slice(0, 200)\n .map((row) => getCellValue(row, col))\n .filter((v) => v != null);\n\n if (values.length === 0) return \"text\";\n\n if (values.every((v) => typeof v === \"boolean\")) return \"boolean\";\n if (values.every((v) => typeof v === \"number\" && Number.isFinite(v))) {\n return \"number\";\n }\n\n const strings = values.map(stringifyCellValue);\n const unique = new Set(strings);\n if (\n unique.size >= 1 &&\n unique.size <= SELECT_CARDINALITY_CAP &&\n unique.size < strings.length * 0.6\n ) {\n return \"select\";\n }\n\n return \"text\";\n}\n\nexport function collectSelectOptions<T>(\n col: MatrxColumnDef<T>,\n rows: T[],\n): Array<{ value: string; label: string }> {\n const cap =\n col.filter === \"select\" ? EXPLICIT_SELECT_CAP : SELECT_CARDINALITY_CAP;\n const seen = new Set<string>();\n const options: Array<{ value: string; label: string }> = [];\n let hasEmpty = false;\n for (const row of rows) {\n const raw = getCellValue(row, col);\n const value = raw == null ? \"\" : stringifyCellValue(raw);\n if (!value) {\n hasEmpty = true;\n continue;\n }\n if (col.filterOptions?.length || seen.has(value)) continue;\n seen.add(value);\n options.push({ value, label: value });\n if (options.length >= cap) break;\n }\n const base = col.filterOptions?.length\n ? [...col.filterOptions]\n : options.sort((a, b) => a.label.localeCompare(b.label));\n // Emptiness sentinels: whenever the column has blank cells, every select\n // filter can isolate them — or exclude them — with zero per-table config.\n if (hasEmpty) {\n base.unshift(\n { value: SELECT_EMPTY_VALUE, label: \"(empty)\" },\n { value: SELECT_NOT_EMPTY_VALUE, label: \"(not empty)\" },\n );\n }\n return base;\n}\n\nexport function filterKindLabel(\n kind: ColumnFilterKind | ResolvedFilterKind,\n): string {\n switch (kind) {\n case \"text\":\n return \"Text\";\n case \"select\":\n return \"Select\";\n case \"boolean\":\n return \"Boolean\";\n case \"number\":\n return \"Number\";\n case \"auto\":\n return \"Auto\";\n case false:\n return \"Off\";\n }\n}\n\n","import {\n collectSelectOptions,\n resolveFilterKind,\n type ResolvedFilterKind,\n} from \"./infer-filter\";\nimport type { MatrxColumnDef, MatrxDataTableQueryState } from \"./types\";\n\nexport interface QueryFilterMeta {\n kind: ResolvedFilterKind | null;\n options: Array<{ value: string; label: string }>;\n}\n\n/**\n * Resolve header-filter metadata without inferring remote cardinality from the\n * current page. Controlled callers must declare select options explicitly;\n * local tables preserve the original inference behavior.\n */\nexport function resolveQueryFilterMeta<T>(\n column: MatrxColumnDef<T>,\n rows: T[],\n controlled: boolean,\n): QueryFilterMeta {\n if (!controlled) {\n const kind = resolveFilterKind(column, rows);\n return {\n kind,\n options: kind === \"select\" ? collectSelectOptions(column, rows) : [],\n };\n }\n if (column.filter === false) return { kind: null, options: [] };\n const declared = column.filter;\n const kind: ResolvedFilterKind =\n declared && declared !== \"auto\"\n ? declared\n : column.filterOptions?.length\n ? \"select\"\n : \"text\";\n return {\n kind,\n options: kind === \"select\" ? (column.filterOptions ?? []) : [],\n };\n}\n\nexport interface QueryStateChangeOptions {\n /** Reset to the first page after a query-shape change. */\n resetPage?: boolean;\n}\n\n/**\n * Build the next controlled query state without mutating the caller's object.\n * Search, filters, sort, facets, and page-size changes pass `resetPage: true`;\n * direct pagination passes a page patch without resetting it.\n */\nexport function nextQueryState(\n current: MatrxDataTableQueryState,\n patch: Partial<MatrxDataTableQueryState>,\n options: QueryStateChangeOptions = {},\n): MatrxDataTableQueryState {\n return {\n ...current,\n ...patch,\n page: options.resetPage ? 1 : (patch.page ?? current.page),\n };\n}\n\n/** Resolve a safe one-based page for the latest remote total. */\nexport function safeQueryPage(\n requestedPage: number,\n totalItems: number,\n pageSize: number,\n): number {\n const safeTotal = Math.max(0, totalItems);\n const safeSize = Math.max(1, pageSize);\n const pageCount = Math.max(1, Math.ceil(safeTotal / safeSize));\n return Math.min(Math.max(1, requestedPage), pageCount);\n}\n\n"],"mappings":";;AAkBO,IAAM,qBAAqB;AAC3B,IAAM,yBAAyB;AAQ/B,SAAS,aAAgB,KAAQ,KAAiC;AACvE,MAAI,IAAI,WAAY,QAAO,IAAI,WAAW,GAAG;AAC7C,MAAI,IAAI,aAAa;AACnB,WAAQ,IAAgC,IAAI,WAAW;AAAA,EACzD;AACA,SAAO;AACT;AAUO,SAAS,mBAAmB,OAAwB;AACzD,MAAI,SAAS,KAAM,QAAO;AAC1B,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,OAAO,UAAU,YAAY,OAAO,UAAU,WAAW;AAC3D,WAAO,OAAO,KAAK;AAAA,EACrB;AACA,MAAI,iBAAiB,KAAM,QAAO,MAAM,YAAY;AACpD,MAAI;AACF,WAAO,KAAK,UAAU,KAAK;AAAA,EAC7B,QAAQ;AACN,WAAO,OAAO,KAAK;AAAA,EACrB;AACF;;;AC9CA,IAAM,yBAAyB;AAG/B,IAAM,sBAAsB;AAQrB,SAAS,kBACd,KACA,YAC2B;AAC3B,MAAI,IAAI,WAAW,MAAO,QAAO;AACjC,MAAI,IAAI,UAAU,IAAI,WAAW,OAAQ,QAAO,IAAI;AAEpD,QAAM,SAAS,WACZ,MAAM,GAAG,GAAG,EACZ,IAAI,CAAC,QAAQ,aAAa,KAAK,GAAG,CAAC,EACnC,OAAO,CAAC,MAAM,KAAK,IAAI;AAE1B,MAAI,OAAO,WAAW,EAAG,QAAO;AAEhC,MAAI,OAAO,MAAM,CAAC,MAAM,OAAO,MAAM,SAAS,EAAG,QAAO;AACxD,MAAI,OAAO,MAAM,CAAC,MAAM,OAAO,MAAM,YAAY,OAAO,SAAS,CAAC,CAAC,GAAG;AACpE,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,OAAO,IAAI,kBAAkB;AAC7C,QAAM,SAAS,IAAI,IAAI,OAAO;AAC9B,MACE,OAAO,QAAQ,KACf,OAAO,QAAQ,0BACf,OAAO,OAAO,QAAQ,SAAS,KAC/B;AACA,WAAO;AAAA,EACT;AAEA,SAAO;AACT;AAEO,SAAS,qBACd,KACA,MACyC;AACzC,QAAM,MACJ,IAAI,WAAW,WAAW,sBAAsB;AAClD,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,UAAmD,CAAC;AAC1D,MAAI,WAAW;AACf,aAAW,OAAO,MAAM;AACtB,UAAM,MAAM,aAAa,KAAK,GAAG;AACjC,UAAM,QAAQ,OAAO,OAAO,KAAK,mBAAmB,GAAG;AACvD,QAAI,CAAC,OAAO;AACV,iBAAW;AACX;AAAA,IACF;AACA,QAAI,IAAI,eAAe,UAAU,KAAK,IAAI,KAAK,EAAG;AAClD,SAAK,IAAI,KAAK;AACd,YAAQ,KAAK,EAAE,OAAO,OAAO,MAAM,CAAC;AACpC,QAAI,QAAQ,UAAU,IAAK;AAAA,EAC7B;AACA,QAAM,OAAO,IAAI,eAAe,SAC5B,CAAC,GAAG,IAAI,aAAa,IACrB,QAAQ,KAAK,CAAC,GAAG,MAAM,EAAE,MAAM,cAAc,EAAE,KAAK,CAAC;AAGzD,MAAI,UAAU;AACZ,SAAK;AAAA,MACH,EAAE,OAAO,oBAAoB,OAAO,UAAU;AAAA,MAC9C,EAAE,OAAO,wBAAwB,OAAO,cAAc;AAAA,IACxD;AAAA,EACF;AACA,SAAO;AACT;;;ACpEO,SAAS,uBACd,QACA,MACA,YACiB;AACjB,MAAI,CAAC,YAAY;AACf,UAAMA,QAAO,kBAAkB,QAAQ,IAAI;AAC3C,WAAO;AAAA,MACL,MAAAA;AAAA,MACA,SAASA,UAAS,WAAW,qBAAqB,QAAQ,IAAI,IAAI,CAAC;AAAA,IACrE;AAAA,EACF;AACA,MAAI,OAAO,WAAW,MAAO,QAAO,EAAE,MAAM,MAAM,SAAS,CAAC,EAAE;AAC9D,QAAM,WAAW,OAAO;AACxB,QAAM,OACJ,YAAY,aAAa,SACrB,WACA,OAAO,eAAe,SACpB,WACA;AACR,SAAO;AAAA,IACL;AAAA,IACA,SAAS,SAAS,WAAY,OAAO,iBAAiB,CAAC,IAAK,CAAC;AAAA,EAC/D;AACF;AAYO,SAAS,eACd,SACA,OACA,UAAmC,CAAC,GACV;AAC1B,SAAO;AAAA,IACL,GAAG;AAAA,IACH,GAAG;AAAA,IACH,MAAM,QAAQ,YAAY,IAAK,MAAM,QAAQ,QAAQ;AAAA,EACvD;AACF;AAGO,SAAS,cACd,eACA,YACA,UACQ;AACR,QAAM,YAAY,KAAK,IAAI,GAAG,UAAU;AACxC,QAAM,WAAW,KAAK,IAAI,GAAG,QAAQ;AACrC,QAAM,YAAY,KAAK,IAAI,GAAG,KAAK,KAAK,YAAY,QAAQ,CAAC;AAC7D,SAAO,KAAK,IAAI,KAAK,IAAI,GAAG,aAAa,GAAG,SAAS;AACvD;","names":["kind"]}
1
+ {"version":3,"sources":["../../src/data-table/filter-engine.ts","../../src/data-table/infer-filter.ts","../../src/data-table/query-control.ts"],"sourcesContent":["import type {\n ColumnFilterValue,\n ColumnFiltersState,\n MatrxColumnDef,\n SortState,\n TableSearchMatchMode,\n} from \"./types\";\nimport {\n completeLayeredFilterRules,\n layeredFilterMatchesValue,\n type LayeredFilterRule,\n} from \"./layered-filters\";\n\n/**\n * Sentinel select-filter values for \"no value\" / \"any value\" — every select\n * filter offers them automatically when the column has empty cells, so any\n * table can isolate rows missing a value (or having one) without extra config.\n */\nexport const SELECT_EMPTY_VALUE = \"__empty__\";\nexport const SELECT_NOT_EMPTY_VALUE = \"__not_empty__\";\n\nexport function columnId<T>(col: MatrxColumnDef<T>): string {\n if (col.id) return col.id;\n if (col.accessorKey) return String(col.accessorKey);\n throw new Error(\"MatrxDataTable column requires `id` or `accessorKey`\");\n}\n\nexport function getCellValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n if (col.accessorFn) return col.accessorFn(row);\n if (col.accessorKey) {\n return (row as Record<string, unknown>)[col.accessorKey];\n }\n return undefined;\n}\n\nexport function getFilterValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n return col.filterValue ? col.filterValue(row) : getCellValue(row, col);\n}\n\nexport function getSortValue<T>(row: T, col: MatrxColumnDef<T>): unknown {\n return col.sortValue ? col.sortValue(row) : getCellValue(row, col);\n}\n\nexport function stringifyCellValue(value: unknown): string {\n if (value == null) return \"\";\n if (typeof value === \"string\") return value;\n if (typeof value === \"number\" || typeof value === \"boolean\") {\n return String(value);\n }\n if (value instanceof Date) return value.toISOString();\n try {\n return JSON.stringify(value);\n } catch {\n return String(value);\n }\n}\n\n/** Unicode-aware tokenization shared by whole-word table search. */\nexport function tableSearchWords(value: string): string[] {\n return value\n .toLocaleLowerCase()\n .split(/[^\\p{L}\\p{N}]+/u)\n .filter(Boolean);\n}\n\n/**\n * Match the primary table search against one row's searchable text. In\n * whole-word mode every entered word must exist as a complete token, so\n * `are` matches `we are ready` but never `hardware`.\n */\nexport function matchesTableSearch(\n searchableText: string,\n query: string,\n mode: TableSearchMatchMode = \"contains\",\n): boolean {\n const normalizedQuery = query.trim().toLocaleLowerCase();\n if (!normalizedQuery) return true;\n if (mode === \"contains\") {\n return searchableText.toLocaleLowerCase().includes(normalizedQuery);\n }\n const actualWords = new Set(tableSearchWords(searchableText));\n const expectedWords = tableSearchWords(normalizedQuery);\n return (\n expectedWords.length > 0 &&\n expectedWords.every((word) => actualWords.has(word))\n );\n}\n\nfunction compareValues(a: unknown, b: unknown): number {\n if (a == null && b == null) return 0;\n if (a == null) return -1;\n if (b == null) return 1;\n if (typeof a === \"number\" && typeof b === \"number\") return a - b;\n if (typeof a === \"boolean\" && typeof b === \"boolean\") {\n return Number(a) - Number(b);\n }\n return stringifyCellValue(a).localeCompare(stringifyCellValue(b), undefined, {\n numeric: true,\n sensitivity: \"base\",\n });\n}\n\nexport function passesColumnFilter(\n value: unknown,\n filter: ColumnFilterValue,\n): boolean {\n const matches = (() => {\n switch (filter.kind) {\n case \"text\": {\n const s = stringifyCellValue(value);\n if (filter.mode === \"null\") return value == null;\n if (filter.mode === \"not_null\") return value != null;\n if (filter.mode === \"empty\") return s.trim() === \"\";\n if (filter.mode === \"not_empty\") return s.trim() !== \"\";\n const q = filter.value.trim().toLowerCase();\n if (!q) return true;\n return s.toLowerCase().includes(q);\n }\n case \"select\": {\n const selected =\n filter.values ??\n (filter.value && filter.value !== \"__all__\" ? [filter.value] : []);\n if (selected.length === 0) return true;\n const s = stringifyCellValue(value);\n // OR semantics across the set; sentinels compose (\"A or (empty)\").\n return selected.some((v) =>\n v === SELECT_EMPTY_VALUE\n ? s === \"\"\n : v === SELECT_NOT_EMPTY_VALUE\n ? s !== \"\"\n : s === v,\n );\n }\n case \"boolean\": {\n return Boolean(value) === filter.value;\n }\n case \"number\": {\n const n =\n typeof value === \"number\"\n ? value\n : Number(stringifyCellValue(value).replace(/[^0-9.-]/g, \"\"));\n if (Number.isNaN(n)) return false;\n if (filter.min !== undefined && n < filter.min) return false;\n if (filter.max !== undefined && n > filter.max) return false;\n return true;\n }\n }\n })();\n return filter.negated ? !matches : matches;\n}\n\nexport function filterAndSortRows<T>(\n rows: T[],\n columns: MatrxColumnDef<T>[],\n columnFilters: ColumnFiltersState,\n sort: SortState | null,\n globalSearch: string,\n anyOf?: { columnIds: string[]; query: string },\n layeredFilters?: readonly LayeredFilterRule[],\n globalSearchMode: TableSearchMatchMode = \"contains\",\n searchText?: (row: T) => string,\n): T[] {\n const colById = new Map(columns.map((c) => [columnId(c), c]));\n const q = globalSearch.trim().toLowerCase();\n const anyQ = anyOf?.query.trim().toLowerCase() ?? \"\";\n const activeLayeredFilters = completeLayeredFilterRules(layeredFilters);\n\n let result = rows.filter((row) => {\n for (const [id, filter] of Object.entries(columnFilters)) {\n if (!filter) continue;\n const col = colById.get(id);\n if (!col || col.filter === false) continue;\n if (!passesColumnFilter(getFilterValue(row, col), filter)) return false;\n }\n\n for (const rule of activeLayeredFilters) {\n const col = colById.get(rule.field);\n if (!col || !layeredFilterMatchesValue(getFilterValue(row, col), rule)) {\n return false;\n }\n }\n\n if (anyQ && anyOf) {\n const hit = anyOf.columnIds.some((id) => {\n const col = colById.get(id);\n if (!col) return false;\n return stringifyCellValue(getFilterValue(row, col))\n .toLowerCase()\n .includes(anyQ);\n });\n if (!hit) return false;\n }\n\n if (!q) return true;\n const searchableValues = columns\n .filter(\n (col) =>\n col.filter !== false || Boolean(col.accessorKey || col.accessorFn),\n )\n .map((col) => stringifyCellValue(getFilterValue(row, col)));\n const additionalSearchText = searchText?.(row);\n if (additionalSearchText) searchableValues.push(additionalSearchText);\n if (globalSearchMode === \"contains\") {\n return searchableValues.some((value) =>\n matchesTableSearch(value, q, \"contains\"),\n );\n }\n return matchesTableSearch(searchableValues.join(\" \"), q, \"whole_words\");\n });\n\n if (sort) {\n const col = colById.get(sort.id);\n if (col && col.sortable !== false) {\n const dir = sort.direction === \"asc\" ? 1 : -1;\n result = [...result].sort((a, b) => {\n const va = getSortValue(a, col);\n const vb = getSortValue(b, col);\n // Empty cells (null / \"\") sort LAST in both directions — sorting a\n // column should surface its values, never a wall of blanks.\n const ea = va == null || stringifyCellValue(va) === \"\";\n const eb = vb == null || stringifyCellValue(vb) === \"\";\n if (ea !== eb) return ea ? 1 : -1;\n return compareValues(va, vb) * dir;\n });\n }\n }\n\n return result;\n}\n\nexport function countActiveColumnFilters(filters: ColumnFiltersState): number {\n let n = 0;\n for (const f of Object.values(filters)) {\n if (!f) continue;\n switch (f.kind) {\n case \"text\":\n if (f.value.trim() || (f.mode && f.mode !== \"contains\")) n += 1;\n break;\n case \"select\":\n if (f.values ? f.values.length > 0 : f.value && f.value !== \"__all__\")\n n += 1;\n break;\n case \"boolean\":\n n += 1;\n break;\n case \"number\":\n if (f.min !== undefined || f.max !== undefined) n += 1;\n break;\n }\n }\n return n;\n}\n\nexport function isColumnFilterActive(\n filter: ColumnFilterValue | undefined,\n): boolean {\n if (!filter) return false;\n switch (filter.kind) {\n case \"text\":\n return (\n filter.value.trim().length > 0 ||\n (filter.mode !== undefined && filter.mode !== \"contains\")\n );\n case \"select\":\n return filter.values\n ? filter.values.length > 0\n : Boolean(filter.value) && filter.value !== \"__all__\";\n case \"boolean\":\n return true;\n case \"number\":\n return filter.min !== undefined || filter.max !== undefined;\n }\n}\n\n/** Apply draft edits onto a row for display (shallow field merge). */\nexport function applyRowEdits<T>(\n row: T,\n edits: Record<string, unknown> | undefined,\n): T {\n if (!edits || Object.keys(edits).length === 0) return row;\n return { ...row, ...edits };\n}\n","import type { ColumnFilterKind, MatrxColumnDef } from \"./types\";\nimport {\n SELECT_EMPTY_VALUE,\n SELECT_NOT_EMPTY_VALUE,\n getCellValue,\n stringifyCellValue,\n} from \"./filter-engine\";\n\n/** Auto-inference switches to text above this many distinct values… */\nconst SELECT_CARDINALITY_CAP = 24;\n\nexport type ResolvedFilterKind = \"text\" | \"select\" | \"boolean\" | \"number\";\n\n/**\n * Infer a filter UI from sample row values. Prefer select for low-cardinality\n * string/enum columns, boolean for booleans, number for numeric, else text.\n */\nexport function resolveFilterKind<T>(\n col: MatrxColumnDef<T>,\n sampleRows: T[],\n): ResolvedFilterKind | null {\n if (col.filter === false) return null;\n if (col.filter && col.filter !== \"auto\") return col.filter;\n\n const values = sampleRows\n .slice(0, 200)\n .map((row) => getCellValue(row, col))\n .filter((v) => v != null);\n\n if (values.length === 0) return \"text\";\n\n if (values.every((v) => typeof v === \"boolean\")) return \"boolean\";\n if (values.every((v) => typeof v === \"number\" && Number.isFinite(v))) {\n return \"number\";\n }\n\n const strings = values.map(stringifyCellValue);\n const unique = new Set(strings);\n if (\n unique.size >= 1 &&\n unique.size <= SELECT_CARDINALITY_CAP &&\n unique.size < strings.length * 0.6\n ) {\n return \"select\";\n }\n\n return \"text\";\n}\n\nexport function collectSelectOptions<T>(\n col: MatrxColumnDef<T>,\n rows: T[],\n): Array<{ value: string; label: string }> {\n const seen = new Set<string>();\n const options: Array<{ value: string; label: string }> = [];\n let hasEmpty = false;\n for (const row of rows) {\n const raw = getCellValue(row, col);\n const value = raw == null ? \"\" : stringifyCellValue(raw);\n if (!value) {\n hasEmpty = true;\n continue;\n }\n if (col.filterOptions?.length || seen.has(value)) continue;\n seen.add(value);\n options.push({ value, label: value });\n }\n const base = col.filterOptions?.length\n ? [...col.filterOptions]\n : options.sort((a, b) => a.label.localeCompare(b.label));\n // Emptiness sentinels: whenever the column has blank cells, every select\n // filter can isolate them — or exclude them — with zero per-table config.\n if (hasEmpty) {\n base.unshift(\n { value: SELECT_EMPTY_VALUE, label: \"(empty)\" },\n { value: SELECT_NOT_EMPTY_VALUE, label: \"(not empty)\" },\n );\n }\n return base;\n}\n\nexport function filterKindLabel(\n kind: ColumnFilterKind | ResolvedFilterKind,\n): string {\n switch (kind) {\n case \"text\":\n return \"Text\";\n case \"select\":\n return \"Select\";\n case \"boolean\":\n return \"Boolean\";\n case \"number\":\n return \"Number\";\n case \"auto\":\n return \"Auto\";\n case false:\n return \"Off\";\n }\n}\n","import {\n collectSelectOptions,\n resolveFilterKind,\n type ResolvedFilterKind,\n} from \"./infer-filter\";\nimport type { MatrxColumnDef, MatrxDataTableQueryState } from \"./types\";\n\nexport interface QueryFilterMeta {\n kind: ResolvedFilterKind | null;\n options: Array<{ value: string; label: string }>;\n}\n\n/**\n * Resolve header-filter metadata without inferring remote cardinality from the\n * current page. Controlled callers must declare select options explicitly;\n * local tables preserve the original inference behavior.\n */\nexport function resolveQueryFilterMeta<T>(\n column: MatrxColumnDef<T>,\n rows: T[],\n controlled: boolean,\n): QueryFilterMeta {\n if (!controlled) {\n const kind = resolveFilterKind(column, rows);\n return {\n kind,\n options: kind === \"select\" ? collectSelectOptions(column, rows) : [],\n };\n }\n if (column.filter === false) return { kind: null, options: [] };\n const declared = column.filter;\n const kind: ResolvedFilterKind =\n declared && declared !== \"auto\"\n ? declared\n : column.filterOptions?.length\n ? \"select\"\n : \"text\";\n return {\n kind,\n options: kind === \"select\" ? (column.filterOptions ?? []) : [],\n };\n}\n\nexport interface QueryStateChangeOptions {\n /** Reset to the first page after a query-shape change. */\n resetPage?: boolean;\n}\n\n/**\n * Build the next controlled query state without mutating the caller's object.\n * Search, filters, sort, facets, and page-size changes pass `resetPage: true`;\n * direct pagination passes a page patch without resetting it.\n */\nexport function nextQueryState(\n current: MatrxDataTableQueryState,\n patch: Partial<MatrxDataTableQueryState>,\n options: QueryStateChangeOptions = {},\n): MatrxDataTableQueryState {\n return {\n ...current,\n ...patch,\n page: options.resetPage ? 1 : (patch.page ?? current.page),\n };\n}\n\n/** Resolve a safe one-based page for the latest remote total. */\nexport function safeQueryPage(\n requestedPage: number,\n totalItems: number,\n pageSize: number,\n): number {\n const safeTotal = Math.max(0, totalItems);\n const safeSize = Math.max(1, pageSize);\n const pageCount = Math.max(1, Math.ceil(safeTotal / safeSize));\n return Math.min(Math.max(1, requestedPage), pageCount);\n}\n\n"],"mappings":";;AAkBO,IAAM,qBAAqB;AAC3B,IAAM,yBAAyB;AAQ/B,SAAS,aAAgB,KAAQ,KAAiC;AACvE,MAAI,IAAI,WAAY,QAAO,IAAI,WAAW,GAAG;AAC7C,MAAI,IAAI,aAAa;AACnB,WAAQ,IAAgC,IAAI,WAAW;AAAA,EACzD;AACA,SAAO;AACT;AAUO,SAAS,mBAAmB,OAAwB;AACzD,MAAI,SAAS,KAAM,QAAO;AAC1B,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,MAAI,OAAO,UAAU,YAAY,OAAO,UAAU,WAAW;AAC3D,WAAO,OAAO,KAAK;AAAA,EACrB;AACA,MAAI,iBAAiB,KAAM,QAAO,MAAM,YAAY;AACpD,MAAI;AACF,WAAO,KAAK,UAAU,KAAK;AAAA,EAC7B,QAAQ;AACN,WAAO,OAAO,KAAK;AAAA,EACrB;AACF;;;AC9CA,IAAM,yBAAyB;AAQxB,SAAS,kBACd,KACA,YAC2B;AAC3B,MAAI,IAAI,WAAW,MAAO,QAAO;AACjC,MAAI,IAAI,UAAU,IAAI,WAAW,OAAQ,QAAO,IAAI;AAEpD,QAAM,SAAS,WACZ,MAAM,GAAG,GAAG,EACZ,IAAI,CAAC,QAAQ,aAAa,KAAK,GAAG,CAAC,EACnC,OAAO,CAAC,MAAM,KAAK,IAAI;AAE1B,MAAI,OAAO,WAAW,EAAG,QAAO;AAEhC,MAAI,OAAO,MAAM,CAAC,MAAM,OAAO,MAAM,SAAS,EAAG,QAAO;AACxD,MAAI,OAAO,MAAM,CAAC,MAAM,OAAO,MAAM,YAAY,OAAO,SAAS,CAAC,CAAC,GAAG;AACpE,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,OAAO,IAAI,kBAAkB;AAC7C,QAAM,SAAS,IAAI,IAAI,OAAO;AAC9B,MACE,OAAO,QAAQ,KACf,OAAO,QAAQ,0BACf,OAAO,OAAO,QAAQ,SAAS,KAC/B;AACA,WAAO;AAAA,EACT;AAEA,SAAO;AACT;AAEO,SAAS,qBACd,KACA,MACyC;AACzC,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,UAAmD,CAAC;AAC1D,MAAI,WAAW;AACf,aAAW,OAAO,MAAM;AACtB,UAAM,MAAM,aAAa,KAAK,GAAG;AACjC,UAAM,QAAQ,OAAO,OAAO,KAAK,mBAAmB,GAAG;AACvD,QAAI,CAAC,OAAO;AACV,iBAAW;AACX;AAAA,IACF;AACA,QAAI,IAAI,eAAe,UAAU,KAAK,IAAI,KAAK,EAAG;AAClD,SAAK,IAAI,KAAK;AACd,YAAQ,KAAK,EAAE,OAAO,OAAO,MAAM,CAAC;AAAA,EACtC;AACA,QAAM,OAAO,IAAI,eAAe,SAC5B,CAAC,GAAG,IAAI,aAAa,IACrB,QAAQ,KAAK,CAAC,GAAG,MAAM,EAAE,MAAM,cAAc,EAAE,KAAK,CAAC;AAGzD,MAAI,UAAU;AACZ,SAAK;AAAA,MACH,EAAE,OAAO,oBAAoB,OAAO,UAAU;AAAA,MAC9C,EAAE,OAAO,wBAAwB,OAAO,cAAc;AAAA,IACxD;AAAA,EACF;AACA,SAAO;AACT;;;AC9DO,SAAS,uBACd,QACA,MACA,YACiB;AACjB,MAAI,CAAC,YAAY;AACf,UAAMA,QAAO,kBAAkB,QAAQ,IAAI;AAC3C,WAAO;AAAA,MACL,MAAAA;AAAA,MACA,SAASA,UAAS,WAAW,qBAAqB,QAAQ,IAAI,IAAI,CAAC;AAAA,IACrE;AAAA,EACF;AACA,MAAI,OAAO,WAAW,MAAO,QAAO,EAAE,MAAM,MAAM,SAAS,CAAC,EAAE;AAC9D,QAAM,WAAW,OAAO;AACxB,QAAM,OACJ,YAAY,aAAa,SACrB,WACA,OAAO,eAAe,SACpB,WACA;AACR,SAAO;AAAA,IACL;AAAA,IACA,SAAS,SAAS,WAAY,OAAO,iBAAiB,CAAC,IAAK,CAAC;AAAA,EAC/D;AACF;AAYO,SAAS,eACd,SACA,OACA,UAAmC,CAAC,GACV;AAC1B,SAAO;AAAA,IACL,GAAG;AAAA,IACH,GAAG;AAAA,IACH,MAAM,QAAQ,YAAY,IAAK,MAAM,QAAQ,QAAQ;AAAA,EACvD;AACF;AAGO,SAAS,cACd,eACA,YACA,UACQ;AACR,QAAM,YAAY,KAAK,IAAI,GAAG,UAAU;AACxC,QAAM,WAAW,KAAK,IAAI,GAAG,QAAQ;AACrC,QAAM,YAAY,KAAK,IAAI,GAAG,KAAK,KAAK,YAAY,QAAQ,CAAC;AAC7D,SAAO,KAAK,IAAI,KAAK,IAAI,GAAG,aAAa,GAAG,SAAS;AACvD;","names":["kind"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/design-system",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "description": "AI Matrx semantic React primitives shared across Vite, Next.js, desktop, and customer-built applications.",
5
5
  "type": "module",
6
6
  "license": "MIT",