@spaethtech/svelte-ui 0.17.1-dev.80.6b8fb45 → 0.17.1-dev.82.2748ef4

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.
@@ -184,7 +184,12 @@ examples):
184
184
  For a start–end range use **`DatePicker range`** (`bind:value` = `{ start, end }`) — it composes
185
185
  `Calendar mode="range"`.
186
186
  - **Data:** `DataTable` `Query` — driven by the headless layer at **`@spaethtech/svelte-ui/data`**
187
- (query-language parser/AST, `createGrid`, `DataGrid<T>`, `DataSet`). · **`Pagination`** (standalone
187
+ (query-language parser/AST via `parseQuery`, `createGrid`, `DataGrid<T>`, `DataSet`). For a
188
+ **client-held array**, use **`localFetch(rows, columns, opts?)`** as the grid's `fetch` — it parses
189
+ the query, filters via **`matchesRow(row, ast)`** (the in-memory AST evaluator: cmp/and/or/not/
190
+ match/in/range/search, honouring column `id`/`searchable`/`sortKey`), then sorts + paginates. Only a
191
+ remote/DB backend needs to compile the AST itself; do NOT hand-roll a "strip `$` + substring" filter.
192
+ · **`Pagination`** (standalone
188
193
  pager styled like the DataTable footer: tinted band, per-page `Select`, range total, and a
189
194
  `First·Prev·[page]/N·Next·Last` stepper whose indicator is an editable `NumberInput` quick-jump;
190
195
  `bind:page`/`bind:perPage`, `total`, `perPageOptions`, `showEdges`/`showTotal`, `borderless`;
@@ -103,10 +103,11 @@
103
103
  /** Font size for the WHOLE table. Applied as `font-size` on the
104
104
  * outer container; every descendant inherits via standard CSS
105
105
  * cascade — headers, cells, footer text all follow. */
106
- // DataTable's own density is set via inline-style CSS vars + cell padding that already bakes in its
107
- // own `md:` responsive step neither can be re-prefixed per size tier. So the TABLE'S density keys
108
- // off the base tier of a responsive `size` (via resolveScalar); the responsive `size` is still passed
109
- // through to child controls (Button/Select/Checkbox), which DO resize at every breakpoint.
106
+ // DataTable's own density (font size + row height + cell padding) is a pure function of `size` — it
107
+ // NEVER changes with the viewport. It keys off the base tier of a responsive `size` (via
108
+ // resolveScalar) because its font-size/height are single inline-style CSS vars that can't carry
109
+ // per-breakpoint variants; the responsive `size` is still passed through to child controls
110
+ // (Button/Select/Checkbox), which DO resize at every breakpoint.
110
111
  const sSize = $derived(resolveScalar(size, "md"));
111
112
  const textVar = $derived(
112
113
  sSize === "lg" ? "var(--ui-text-lg)" : sSize === "md" ? "var(--ui-text)" : "var(--ui-text-sm)",
@@ -121,15 +122,10 @@
121
122
  ? "var(--ui-height)"
122
123
  : "var(--ui-height-sm)",
123
124
  );
124
- /** Cell padding scales with size. Mobile-first: base padding is
125
- * tight; the md: variant applies at 768px so tables feel
126
- * chunkier on desktop but stay dense on phones. */
125
+ /** Cell padding a pure function of `size` (no viewport step), so a table's density is exactly its
126
+ * `size` at every width. The values pair with `heightVar` (sm 24 / md 32 / lg 40px). */
127
127
  const cellPad = $derived(
128
- sSize === "lg"
129
- ? "py-3 px-4 md:py-4 md:px-5"
130
- : sSize === "md"
131
- ? "py-2 px-3 md:py-3 md:px-4"
132
- : "py-1 px-2 md:py-2 md:px-3",
128
+ sSize === "lg" ? "py-3 px-4" : sSize === "md" ? "py-2 px-3" : "py-1 px-2",
133
129
  );
134
130
  /** Inter-column gap. Tracks the same size axis so lead/cell/tail
135
131
  * spacing stays proportional to cell padding. */
@@ -0,0 +1,16 @@
1
+ import { type QueryNode } from "./ast.js";
2
+ export interface RowEvalOptions {
3
+ /** Resolve a row's value for a column id. Default: `row[colId]`. */
4
+ getValue?: (row: unknown, colId: string) => unknown;
5
+ /** Column ids scanned by a bare-text `search` node (the columns marked `searchable`). */
6
+ searchColumns?: string[];
7
+ /** Instant used to resolve `now()` values. Default: `new Date()` at call time. */
8
+ now?: Date;
9
+ /** Case-insensitive string comparison + search. Default: `true`. */
10
+ caseInsensitive?: boolean;
11
+ }
12
+ /**
13
+ * Test a single `row` against a parsed query `ast`. `null`/`undefined` ast matches every row (an empty
14
+ * query is "no filter"). See {@link RowEvalOptions} for value access, searchable columns, and `now()`.
15
+ */
16
+ export declare function matchesRow(row: unknown, ast: QueryNode | null | undefined, opts?: RowEvalOptions): boolean;
@@ -0,0 +1,156 @@
1
+ // In-memory evaluator for the query AST — tests a plain row object against a parsed `QueryNode`.
2
+ // Framework-agnostic (no Svelte deps), the client-side counterpart to a server `toConditions`:
3
+ // where a DB backend compiles the AST to SQL, an in-memory grid filters an array with `matchesRow`.
4
+ // Handles all node kinds — and/or/not · cmp · match · in · range · search.
5
+ import { isNow, resolveNow, } from "./ast.js";
6
+ const getProp = (row, col) => row == null ? undefined : row[col];
7
+ const toStr = (v) => v == null ? "" : v instanceof Date ? v.toISOString() : String(v);
8
+ const isNumericLike = (v) => typeof v === "number"
9
+ ? Number.isFinite(v)
10
+ : typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v));
11
+ const toBool = (v) => v === true || v === 1 || v === "1" || (typeof v === "string" && v.toLowerCase() === "true");
12
+ const toDateMs = (v) => v instanceof Date ? v.getTime() : new Date(v).getTime();
13
+ /** Resolve a query `Value` (possibly a `now()` marker) to a concrete comparand. */
14
+ const resolveVal = (v, now) => (isNow(v) ? resolveNow(v, now) : v);
15
+ /** Total order for a cell vs a comparand under an optional cast. Returns -1/0/1, or `NaN` if either
16
+ * side can't be coerced to the compared type (an incomparable pair never satisfies an ordering op). */
17
+ function order(cell, val, cast, _now) {
18
+ if (cast === "date" || val instanceof Date) {
19
+ const a = toDateMs(cell);
20
+ const b = val instanceof Date ? val.getTime() : toDateMs(val);
21
+ if (Number.isNaN(a) || Number.isNaN(b))
22
+ return NaN;
23
+ return a === b ? 0 : a < b ? -1 : 1;
24
+ }
25
+ if (cast === "bool")
26
+ return (toBool(cell) ? 1 : 0) - (toBool(val) ? 1 : 0);
27
+ if (cast === "number" || (cast == null && isNumericLike(cell) && isNumericLike(val))) {
28
+ const a = Number(cell);
29
+ const b = Number(val);
30
+ if (Number.isNaN(a) || Number.isNaN(b))
31
+ return NaN;
32
+ return a === b ? 0 : a < b ? -1 : 1;
33
+ }
34
+ const cmp = toStr(cell).localeCompare(toStr(val));
35
+ return cmp < 0 ? -1 : cmp > 0 ? 1 : 0;
36
+ }
37
+ /** Equality with the same coercion rules as `order`, but string-equality honours `caseInsensitive`. */
38
+ function eq(cell, val, cast, ci) {
39
+ if (cast === "date" || val instanceof Date)
40
+ return toDateMs(cell) === (val instanceof Date ? val.getTime() : toDateMs(val));
41
+ if (cast === "bool")
42
+ return toBool(cell) === toBool(val);
43
+ if (cast === "number" || (cast == null && isNumericLike(cell) && isNumericLike(val)))
44
+ return Number(cell) === Number(val);
45
+ const a = toStr(cell);
46
+ const b = toStr(val);
47
+ return ci ? a.toLowerCase() === b.toLowerCase() : a === b;
48
+ }
49
+ function safeRegExp(source, flags) {
50
+ try {
51
+ return new RegExp(source, flags);
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ /**
58
+ * Test a single `row` against a parsed query `ast`. `null`/`undefined` ast matches every row (an empty
59
+ * query is "no filter"). See {@link RowEvalOptions} for value access, searchable columns, and `now()`.
60
+ */
61
+ export function matchesRow(row, ast, opts = {}) {
62
+ if (!ast)
63
+ return true;
64
+ const get = opts.getValue ?? getProp;
65
+ const ci = opts.caseInsensitive ?? true;
66
+ const now = opts.now ?? new Date();
67
+ const searchCols = opts.searchColumns ?? [];
68
+ const strOp = (cell, val, op) => {
69
+ let s = toStr(cell);
70
+ let t = toStr(val);
71
+ if (ci) {
72
+ s = s.toLowerCase();
73
+ t = t.toLowerCase();
74
+ }
75
+ return op === "contains"
76
+ ? s.includes(t)
77
+ : op === "ncontains"
78
+ ? !s.includes(t)
79
+ : op === "starts"
80
+ ? s.startsWith(t)
81
+ : s.endsWith(t); // "ends"
82
+ };
83
+ const inInterval = (x, interval, cast) => {
84
+ const { lower, upper } = interval;
85
+ if (lower) {
86
+ const o = order(x, resolveVal(lower.value, now), cast, now);
87
+ if (Number.isNaN(o) || (lower.inclusive ? o < 0 : o <= 0))
88
+ return false;
89
+ }
90
+ if (upper) {
91
+ const o = order(x, resolveVal(upper.value, now), cast, now);
92
+ if (Number.isNaN(o) || (upper.inclusive ? o > 0 : o >= 0))
93
+ return false;
94
+ }
95
+ return true;
96
+ };
97
+ const ev = (node) => {
98
+ switch (node.kind) {
99
+ case "and":
100
+ return node.children.every(ev);
101
+ case "or":
102
+ return node.children.some(ev);
103
+ case "not":
104
+ return !ev(node.child);
105
+ case "cmp": {
106
+ const cell = get(row, node.col);
107
+ const op = node.op;
108
+ if (op === "contains" || op === "ncontains" || op === "starts" || op === "ends")
109
+ return strOp(cell, node.value, op);
110
+ const val = resolveVal(node.value, now);
111
+ if (op === "eq")
112
+ return eq(cell, val, node.cast, ci);
113
+ if (op === "ne")
114
+ return !eq(cell, val, node.cast, ci);
115
+ const o = order(cell, val, node.cast, now);
116
+ if (Number.isNaN(o))
117
+ return false;
118
+ return op === "gt" ? o > 0 : op === "gte" ? o >= 0 : op === "lt" ? o < 0 : o <= 0;
119
+ }
120
+ case "match": {
121
+ const re = safeRegExp(node.source, node.flags);
122
+ if (!re)
123
+ return false;
124
+ const hit = re.test(toStr(get(row, node.col)));
125
+ return node.negated ? !hit : hit;
126
+ }
127
+ case "in": {
128
+ const cell = get(row, node.col);
129
+ const members = node.values.map((v) => resolveVal(v, now));
130
+ if (Array.isArray(cell)) {
131
+ const inCell = (m) => cell.some((c) => eq(c, m, node.cast, ci));
132
+ return node.mode === "all" ? members.every(inCell) : members.some(inCell);
133
+ }
134
+ const hits = members.map((m) => eq(cell, m, node.cast, ci));
135
+ return node.mode === "all" ? hits.every(Boolean) : hits.some(Boolean);
136
+ }
137
+ case "range": {
138
+ const cell = get(row, node.col);
139
+ const test = (x) => inInterval(x, node.interval, node.cast);
140
+ if (Array.isArray(cell))
141
+ return node.mode === "all" ? cell.every(test) : cell.some(test);
142
+ return test(cell);
143
+ }
144
+ case "search": {
145
+ const needle = ci ? node.text.toLowerCase() : node.text;
146
+ if (!needle)
147
+ return true;
148
+ return searchCols.some((c) => {
149
+ const s = toStr(get(row, c));
150
+ return (ci ? s.toLowerCase() : s).includes(needle);
151
+ });
152
+ }
153
+ }
154
+ };
155
+ return ev(ast);
156
+ }
@@ -1,3 +1,4 @@
1
1
  export * from "./ast";
2
2
  export { parseQuery, tryParseQuery, type ParseResult } from "./parser";
3
+ export { matchesRow, type RowEvalOptions } from "./eval";
3
4
  export { lex, type Token } from "./lexer";
@@ -1,3 +1,4 @@
1
1
  export * from "./ast";
2
2
  export { parseQuery, tryParseQuery } from "./parser";
3
+ export { matchesRow } from "./eval";
3
4
  export { lex } from "./lexer";
@@ -1,2 +1,3 @@
1
1
  export * from "./types";
2
2
  export { createGrid, DataGrid, type GridOptions } from "./grid.svelte";
3
+ export { localFetch, type LocalFetchOptions } from "./local";
@@ -1,2 +1,3 @@
1
1
  export * from "./types";
2
2
  export { createGrid, DataGrid } from "./grid.svelte";
3
+ export { localFetch } from "./local";
@@ -0,0 +1,21 @@
1
+ import type { Column, FetchFn } from "./types.js";
2
+ export interface LocalFetchOptions<T> {
3
+ /** Resolve a row's value for a column id (filter + sort + search). Default: `row[colId]`. */
4
+ getValue?: (row: T, colId: string) => unknown;
5
+ /** Case-insensitive filtering/search. Default: `true`. */
6
+ caseInsensitive?: boolean;
7
+ /** Instant for resolving `now()` in the query. Called per fetch. Default: `new Date()`. */
8
+ now?: () => Date;
9
+ }
10
+ /**
11
+ * Build a `FetchFn` that runs entirely in memory over `source`.
12
+ *
13
+ * ```ts
14
+ * const grid = createGrid({ columns, getRowId: (r) => r.id, fetch: localFetch(() => rows, columns) });
15
+ * ```
16
+ *
17
+ * Pass a getter (`() => rows`) rather than a bare array if the data is reactive (`$state`) and should
18
+ * re-filter when it changes. Filtering honours the columns' `id` (`$token`) and `searchable` flags; a
19
+ * bare-text search scans the `searchable` columns (or every column if none are marked).
20
+ */
21
+ export declare function localFetch<T>(source: T[] | (() => T[]), columns: Column<T>[], opts?: LocalFetchOptions<T>): FetchFn<T>;
@@ -0,0 +1,63 @@
1
+ // In-memory `FetchFn` for `createGrid`/`DataTable` — the client-side counterpart to a remote
2
+ // `query()`. Parses the grid's query string, filters the array with `matchesRow`, then sorts and
3
+ // paginates. Drop the whole "compile the AST to your storage" step for client-held data.
4
+ import { parseQuery } from "../query/index.js";
5
+ import { matchesRow } from "../query/eval.js";
6
+ const naturalCompare = (a, b) => {
7
+ if (a == null && b == null)
8
+ return 0;
9
+ if (a == null)
10
+ return 1; // nullish sorts last (asc)
11
+ if (b == null)
12
+ return -1;
13
+ const an = typeof a === "number" || (typeof a === "string" && a.trim() !== "" && !Number.isNaN(Number(a)));
14
+ const bn = typeof b === "number" || (typeof b === "string" && b.trim() !== "" && !Number.isNaN(Number(b)));
15
+ if (an && bn)
16
+ return Number(a) - Number(b);
17
+ return String(a).localeCompare(String(b));
18
+ };
19
+ /**
20
+ * Build a `FetchFn` that runs entirely in memory over `source`.
21
+ *
22
+ * ```ts
23
+ * const grid = createGrid({ columns, getRowId: (r) => r.id, fetch: localFetch(() => rows, columns) });
24
+ * ```
25
+ *
26
+ * Pass a getter (`() => rows`) rather than a bare array if the data is reactive (`$state`) and should
27
+ * re-filter when it changes. Filtering honours the columns' `id` (`$token`) and `searchable` flags; a
28
+ * bare-text search scans the `searchable` columns (or every column if none are marked).
29
+ */
30
+ export function localFetch(source, columns, opts = {}) {
31
+ const getRows = typeof source === "function" ? source : () => source;
32
+ const marked = columns.filter((c) => c.searchable).map((c) => c.id);
33
+ const searchColumns = marked.length ? marked : columns.map((c) => c.id);
34
+ const sortKeyOf = (id) => columns.find((c) => c.id === id)?.sortKey ?? id;
35
+ const value = (row, col) => opts.getValue ? opts.getValue(row, col) : row[col];
36
+ return (state) => {
37
+ const all = getRows();
38
+ let ast = null;
39
+ try {
40
+ ast = parseQuery(state.q);
41
+ }
42
+ catch {
43
+ ast = null; // invalid query → show everything (the Query input surfaces the error separately)
44
+ }
45
+ const filtered = ast
46
+ ? all.filter((r) => matchesRow(r, ast, {
47
+ getValue: (row, col) => value(row, col),
48
+ searchColumns,
49
+ caseInsensitive: opts.caseInsensitive,
50
+ now: opts.now?.(),
51
+ }))
52
+ : all.slice();
53
+ if (state.sort) {
54
+ const key = sortKeyOf(state.sort.col);
55
+ const dir = state.sort.dir === "desc" ? -1 : 1;
56
+ filtered.sort((a, b) => naturalCompare(value(a, key), value(b, key)) * dir);
57
+ }
58
+ const total = filtered.length;
59
+ const start = (state.page - 1) * state.perPage;
60
+ const rows = filtered.slice(start, start + state.perPage);
61
+ return { current: { rows, total }, loading: false, error: undefined };
62
+ };
63
+ }
@@ -367,7 +367,9 @@ under the active tab. Fully demoed at `/tabstrip`.
367
367
  ## Data Components
368
368
 
369
369
  These pair with the headless **`@spaethtech/svelte-ui/data`** layer (query-language parser/AST, `createGrid`,
370
- `DataSet`). See `docs/usage.md`.
370
+ `DataSet`). For a **client-held array**, `fetch: localFetch(rows, columns)` gives full query support in
371
+ memory (`matchesRow` evaluates the AST — filter + sort + page); only a DB backend compiles the query
372
+ itself. See `docs/usage.md`.
371
373
 
372
374
  ### DataTable
373
375
 
package/docs/usage.md CHANGED
@@ -1103,24 +1103,31 @@ bar with `Query` over a `DataSet`.
1103
1103
  import { createGrid } from "@spaethtech/svelte-ui/data";
1104
1104
  import IconPencil from "~icons/mdi/pencil";
1105
1105
 
1106
- const grid = createGrid({
1107
- columns: [
1108
- { id: "name", label: "Name" },
1109
- { id: "email", label: "Email" },
1110
- { id: "status", label: "Status", sortKey: "status" },
1111
- ],
1112
- fetch: (request) => /* a reactive query returning { rows, total } */ myQuery(request),
1113
- });
1106
+ const columns = [
1107
+ { id: "name", label: "Name", searchable: true, cell: nameCell },
1108
+ { id: "email", label: "Email", searchable: true, cell: emailCell },
1109
+ { id: "status", label: "Status", filter: { kind: "enum", options: ["active", "invited"] }, cell: statusCell },
1110
+ ];
1111
+
1112
+ // REMOTE (DB): compile request.q to your storage.
1113
+ // const grid = createGrid({ columns, fetch: (request) => myQuery(request) });
1114
+
1115
+ // CLIENT array: `localFetch` parses the query, filters (matchesRow), sorts + paginates for you —
1116
+ // full query support ($status == active, $name ~= /re/, ranges, bare search…). No hand-rolled filter.
1117
+ const grid = createGrid({ columns, getRowId: (r) => r.id, fetch: localFetch(() => rows, columns) });
1114
1118
  </script>
1115
1119
 
1116
1120
  <DataTable
1117
1121
  {grid}
1122
+ {columns}
1118
1123
  selectable
1119
1124
  rowActions={(row) => [{ label: "Edit", icon: IconPencil, onclick: () => edit(row) }]}
1120
1125
  />
1121
1126
  ```
1122
1127
 
1123
- See the live demo for the full DataTable + Query walkthrough — http://localhost:5173.
1128
+ `import { createGrid, localFetch, matchesRow } from "@spaethtech/svelte-ui/data";` `localFetch` is the
1129
+ client-side counterpart to a remote `query()`; `matchesRow(row, ast)` is the underlying AST evaluator if
1130
+ you need to filter an array yourself. See the live demo for the full DataTable + Query walkthrough.
1124
1131
 
1125
1132
  ## Theme Integration
1126
1133
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.17.1-dev.80.6b8fb45",
3
+ "version": "0.17.1-dev.82.2748ef4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"