gbs-add-block 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/.gbs/skills/gbs-components/SKILL.md +190 -134
  2. package/.gbs/skills/gbs-components/references/install.md +25 -3
  3. package/.gbs/skills/gbs-components/references/styling.md +246 -207
  4. package/CHANGELOG.md +74 -0
  5. package/README.md +154 -23
  6. package/index.cjs +212 -3
  7. package/package.json +41 -10
  8. package/schema/passport-v1.schema.json +204 -0
  9. package/source/beta-components/accordion/passport.json +259 -0
  10. package/source/beta-components/accordion/styles.css +207 -208
  11. package/source/beta-components/alert/passport.json +250 -0
  12. package/source/beta-components/alert/styles.css +154 -155
  13. package/source/beta-components/avatar/passport.json +294 -0
  14. package/source/beta-components/avatar/styles.css +225 -226
  15. package/source/beta-components/badge/passport.json +332 -0
  16. package/source/beta-components/badge/styles.css +203 -204
  17. package/source/beta-components/breadcrumb/passport.json +243 -0
  18. package/source/beta-components/breadcrumb/styles.css +138 -140
  19. package/source/beta-components/button/passport.json +402 -0
  20. package/source/beta-components/button/passport.manual.json +31 -0
  21. package/source/beta-components/button/styles.css +232 -234
  22. package/source/beta-components/card/passport.json +337 -0
  23. package/source/beta-components/card/styles.css +230 -232
  24. package/source/beta-components/checkbox/passport.json +456 -0
  25. package/source/beta-components/checkbox/styles.css +211 -213
  26. package/source/beta-components/combobox/passport.json +456 -0
  27. package/source/beta-components/combobox/styles.css +419 -417
  28. package/source/beta-components/data-grid/agent/coerce.ts +368 -0
  29. package/source/beta-components/data-grid/agent/contract.ts +410 -0
  30. package/source/beta-components/data-grid/agent/dataset.ts +92 -0
  31. package/source/beta-components/data-grid/agent/engine.ts +470 -0
  32. package/source/beta-components/data-grid/agent/executors.ts +155 -0
  33. package/source/beta-components/data-grid/agent/index.ts +79 -0
  34. package/source/beta-components/data-grid/agent/intent.ts +324 -0
  35. package/source/beta-components/data-grid/agent/operations.ts +335 -0
  36. package/source/beta-components/data-grid/agent/validate.ts +630 -0
  37. package/source/beta-components/data-grid/agent/webmcp.ts +107 -0
  38. package/source/beta-components/data-grid/index.ts +14 -7
  39. package/source/beta-components/data-grid/passport.json +1051 -0
  40. package/source/beta-components/data-grid/passport.manual.json +255 -0
  41. package/source/beta-components/data-grid/react/AskGrid.tsx +164 -0
  42. package/source/beta-components/data-grid/react/DataGrid.tsx +39 -0
  43. package/source/beta-components/data-grid/styles.css +874 -716
  44. package/source/beta-components/date-picker/passport.json +407 -0
  45. package/source/beta-components/date-picker/styles.css +445 -446
  46. package/source/beta-components/dialog/passport.json +344 -0
  47. package/source/beta-components/dialog/styles.css +280 -279
  48. package/source/beta-components/file-uploader/passport.json +518 -0
  49. package/source/beta-components/file-uploader/styles.css +394 -396
  50. package/source/beta-components/input/passport.json +536 -0
  51. package/source/beta-components/input/styles.css +295 -297
  52. package/source/beta-components/menu/passport.json +322 -0
  53. package/source/beta-components/menu/styles.css +224 -223
  54. package/source/beta-components/modal/passport.json +289 -0
  55. package/source/beta-components/modal/styles.css +241 -240
  56. package/source/beta-components/number-input/passport.json +541 -0
  57. package/source/beta-components/number-input/styles.css +230 -231
  58. package/source/beta-components/popover/passport.json +238 -0
  59. package/source/beta-components/popover/styles.css +148 -147
  60. package/source/beta-components/progress/passport.json +270 -0
  61. package/source/beta-components/progress/styles.css +200 -201
  62. package/source/beta-components/radio-group/passport.json +477 -0
  63. package/source/beta-components/radio-group/styles.css +269 -270
  64. package/source/beta-components/shared/core/agent/adapter.ts +65 -0
  65. package/source/beta-components/shared/core/agent/history.ts +120 -0
  66. package/source/beta-components/shared/core/agent/index.ts +46 -0
  67. package/source/beta-components/shared/core/agent/numbers.ts +217 -0
  68. package/source/beta-components/shared/core/agent/schema.ts +180 -0
  69. package/source/beta-components/shared/core/agent/types.ts +169 -0
  70. package/source/beta-components/shared/core/agent/webmcp.ts +328 -0
  71. package/source/beta-components/shared/index.ts +9 -0
  72. package/source/beta-components/shared/react/GramproAIProvider.tsx +50 -0
  73. package/source/beta-components/shared/react/useAskAgent.ts +217 -0
  74. package/source/beta-components/shared/styles.css +79 -0
  75. package/source/beta-components/shared/version.json +4 -4
  76. package/source/beta-components/shared/version.ts +6 -6
  77. package/source/beta-components/skeleton/passport.json +251 -0
  78. package/source/beta-components/skeleton/styles.css +185 -187
  79. package/source/beta-components/spinner/passport.json +245 -0
  80. package/source/beta-components/spinner/styles.css +173 -174
  81. package/source/beta-components/switch/passport.json +421 -0
  82. package/source/beta-components/switch/styles.css +227 -229
  83. package/source/beta-components/tabs/passport.json +315 -0
  84. package/source/beta-components/tabs/styles.css +263 -264
  85. package/source/beta-components/textarea/passport.json +382 -0
  86. package/source/beta-components/textarea/styles.css +158 -160
  87. package/source/beta-components/toaster/passport.json +221 -0
  88. package/source/beta-components/toaster/styles.css +282 -282
  89. package/source/beta-components/tooltip/passport.json +170 -0
  90. package/source/beta-components/tooltip/styles.css +71 -73
  91. package/tools/env.cjs +61 -0
  92. package/tools/passport/cli.cjs +79 -0
  93. package/tools/passport/extract.cjs +493 -0
  94. package/tools/passport/index.cjs +185 -0
  95. package/tools/passport/merge.cjs +131 -0
  96. package/tools/passport/policy.cjs +65 -0
  97. package/tools/passport/validate.cjs +277 -0
  98. package/tools/ts-require.cjs +79 -0
  99. package/source/beta-components/accordion/__tests__/core.test.ts +0 -58
  100. package/source/beta-components/alert/__tests__/core.test.ts +0 -17
  101. package/source/beta-components/avatar/__tests__/core.test.ts +0 -88
  102. package/source/beta-components/badge/__tests__/core.test.ts +0 -46
  103. package/source/beta-components/breadcrumb/__tests__/core.test.ts +0 -58
  104. package/source/beta-components/button/__tests__/core.test.ts +0 -31
  105. package/source/beta-components/card/__tests__/core.test.ts +0 -57
  106. package/source/beta-components/checkbox/__tests__/core.test.ts +0 -40
  107. package/source/beta-components/combobox/__tests__/core.test.ts +0 -134
  108. package/source/beta-components/data-grid/__tests__/core.test.ts +0 -356
  109. package/source/beta-components/data-grid/__tests__/export.test.ts +0 -70
  110. package/source/beta-components/data-grid/__tests__/pdf.test.ts +0 -209
  111. package/source/beta-components/date-picker/__tests__/core.test.ts +0 -273
  112. package/source/beta-components/dialog/__tests__/core.test.ts +0 -86
  113. package/source/beta-components/file-uploader/__tests__/core.test.ts +0 -395
  114. package/source/beta-components/input/__tests__/core.test.ts +0 -75
  115. package/source/beta-components/menu/__tests__/core.test.ts +0 -120
  116. package/source/beta-components/modal/__tests__/core.test.ts +0 -55
  117. package/source/beta-components/number-input/__tests__/core.test.ts +0 -151
  118. package/source/beta-components/progress/__tests__/core.test.ts +0 -56
  119. package/source/beta-components/radio-group/__tests__/core.test.ts +0 -64
  120. package/source/beta-components/shared/__tests__/boundaries.test.ts +0 -95
  121. package/source/beta-components/shared/__tests__/core.test.ts +0 -55
  122. package/source/beta-components/shared/__tests__/position.test.ts +0 -143
  123. package/source/beta-components/skeleton/__tests__/core.test.ts +0 -41
  124. package/source/beta-components/spinner/__tests__/core.test.ts +0 -48
  125. package/source/beta-components/switch/__tests__/core.test.ts +0 -64
  126. package/source/beta-components/tabs/__tests__/core.test.ts +0 -51
  127. package/source/beta-components/textarea/__tests__/core.test.ts +0 -38
  128. package/source/beta-components/toaster/__tests__/core.test.ts +0 -256
  129. package/source/beta-components/tooltip/__tests__/core.test.ts +0 -42
@@ -1,356 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { computeLayout, getLayoutVars, moveColumn, resolveColumns } from "../core/columns";
3
- import { filterRows } from "../core/filtering";
4
- import { createGridEngine, type GridModel } from "../core/grid";
5
- import { buildRows, createRowIdGetter, paginate } from "../core/rows";
6
- import { sortRows, toggleSorting } from "../core/sorting";
7
- import { createInitialState, mergeState } from "../core/state";
8
- import type { ColumnDef, ColumnFilter, GridOptions, GridRow, SortItem } from "../core/types";
9
- import { createFormatters } from "../core/values";
10
- import { getColumnRange, getRowRange } from "../core/virtual";
11
-
12
- interface Person {
13
- id: number;
14
- name: string;
15
- age: number | null;
16
- city: string | null;
17
- joined: string;
18
- active: boolean;
19
- status: "new" | "active" | "gone";
20
- }
21
-
22
- const people: Person[] = [
23
- { id: 1, name: "Alice", age: 34, city: "Paris", joined: "2024-01-15", active: true, status: "active" },
24
- { id: 2, name: "bob", age: null, city: "Berlin", joined: "2023-06-01", active: false, status: "new" },
25
- { id: 3, name: "Carol", age: 28, city: null, joined: "2024-01-16", active: true, status: "gone" },
26
- { id: 4, name: "Dave", age: 41, city: "Paris", joined: "2022-11-30", active: false, status: "active" },
27
- { id: 5, name: "Item10", age: 28, city: "Rome", joined: "2024-03-01", active: true, status: "new" },
28
- { id: 6, name: "Item2", age: 50, city: "Berlin", joined: "2021-07-07", active: true, status: "active" },
29
- ];
30
-
31
- const defs: ColumnDef<Person>[] = [
32
- { field: "id", type: "number" },
33
- { field: "name" },
34
- { field: "age", type: "number" },
35
- { field: "city" },
36
- { field: "joined", type: "date" },
37
- { field: "active", type: "boolean" },
38
- {
39
- field: "status",
40
- options: [
41
- { label: "New", value: "new" },
42
- { label: "Active", value: "active" },
43
- { label: "Gone", value: "gone" },
44
- ],
45
- },
46
- ];
47
-
48
- const columns = resolveColumns(defs);
49
- const formatters = createFormatters("en-US");
50
- const rows = buildRows(people, createRowIdGetter<Person>("id"));
51
- const ids = (list: GridRow<Person>[]) => list.map((row) => row.original.id);
52
-
53
- describe("columns", () => {
54
- it("derives ids and readable headers", () => {
55
- expect(columns[0].id).toBe("id");
56
- expect(columns[1].header).toBe("Name");
57
- expect(resolveColumns<{ firstName: string }>([{ field: "firstName" }])[0].header).toBe("First Name");
58
- expect(resolveColumns<{ start_date: string }>([{ field: "start_date" }])[0].header).toBe("Start date");
59
- });
60
-
61
- it("rejects duplicate ids", () => {
62
- expect(() => resolveColumns<Person>([{ field: "name" }, { field: "name" }])).toThrow(/Duplicate/);
63
- });
64
-
65
- it("lays out pinned, ordered, sized and hidden columns", () => {
66
- const layout = computeLayout(columns, {
67
- columnOrder: ["city", "name"],
68
- columnVisibility: { age: false },
69
- columnSizing: { name: 200 },
70
- columnPinning: { left: ["id"], right: ["status"] },
71
- });
72
- expect(layout.left.map((i) => i.column.id)).toEqual(["id"]);
73
- expect(layout.center.map((i) => i.column.id)).toEqual(["city", "name", "joined", "active"]);
74
- expect(layout.right.map((i) => i.column.id)).toEqual(["status"]);
75
- expect(layout.center[1]).toMatchObject({ offset: 160, width: 200 });
76
- expect(layout.left[0].pinEdge).toBe(true);
77
- expect(layout.right[0].pinEdge).toBe(true);
78
- expect(layout.totalWidth).toBe(160 * 5 + 200);
79
- });
80
-
81
- it("offsets right-pinned columns from the end, including resize previews", () => {
82
- const layout = computeLayout(columns, {
83
- columnOrder: [],
84
- columnVisibility: {},
85
- columnSizing: {},
86
- columnPinning: { left: [], right: ["city", "status"] },
87
- });
88
- const [city, status] = layout.right;
89
- expect(getLayoutVars(layout)[`--dg-c${status.visibleIndex}-o`]).toBe("0px");
90
- expect(getLayoutVars(layout)[`--dg-c${city.visibleIndex}-o`]).toBe("160px");
91
- expect(getLayoutVars(layout, { status: 300 })[`--dg-c${city.visibleIndex}-o`]).toBe("300px");
92
- });
93
-
94
- it("moves a column into the target's pin section and back", () => {
95
- const pinned = moveColumn(columns, { columnOrder: [], columnPinning: { left: ["id"], right: [] } }, "city", "id", "after");
96
- expect(pinned.columnPinning.left).toEqual(["id", "city"]);
97
-
98
- const unpinned = moveColumn(columns, pinned, "city", "name", "before");
99
- expect(unpinned.columnPinning.left).toEqual(["id"]);
100
- expect(unpinned.columnOrder.indexOf("city")).toBe(unpinned.columnOrder.indexOf("name") - 1);
101
- });
102
- });
103
-
104
- describe("filtering", () => {
105
- const run = (filters: ColumnFilter[], search = "") =>
106
- ids(filterRows(rows, columns, filters, search, formatters));
107
-
108
- it("returns the input array when nothing filters", () => {
109
- expect(filterRows(rows, columns, [], " ", formatters)).toBe(rows);
110
- expect(filterRows(rows, columns, [{ columnId: "name", operator: "contains", value: "" }], "", formatters)).toBe(rows);
111
- });
112
-
113
- it("matches text case-insensitively", () => {
114
- expect(run([{ columnId: "name", operator: "contains", value: "B" }])).toEqual([2]);
115
- });
116
-
117
- it("applies every column filter together", () => {
118
- expect(
119
- run([
120
- { columnId: "city", operator: "equals", value: "paris" },
121
- { columnId: "active", operator: "equals", value: false },
122
- ]),
123
- ).toEqual([4]);
124
- });
125
-
126
- it("handles empty cell values", () => {
127
- expect(run([{ columnId: "city", operator: "startsWith", value: "p" }])).toEqual([1, 4]);
128
- expect(run([{ columnId: "age", operator: "isEmpty" }])).toEqual([2]);
129
- });
130
-
131
- it("filters numbers", () => {
132
- expect(run([{ columnId: "age", operator: "between", value: 28, value2: 34 }])).toEqual([1, 3, 5]);
133
- expect(run([{ columnId: "age", operator: "gt", value: 40 }])).toEqual([4, 6]);
134
- });
135
-
136
- it("filters dates by local calendar day", () => {
137
- expect(run([{ columnId: "joined", operator: "equals", value: "2024-01-15" }])).toEqual([1]);
138
- expect(run([{ columnId: "joined", operator: "before", value: "2023-01-01" }])).toEqual([4, 6]);
139
- expect(run([{ columnId: "joined", operator: "after", value: "2024-01-15" }])).toEqual([3, 5]);
140
- expect(run([{ columnId: "joined", operator: "between", value: "2024-01-15", value2: "2024-01-16" }])).toEqual([1, 3]);
141
- });
142
-
143
- it("filters option sets", () => {
144
- expect(run([{ columnId: "status", operator: "in", value: ["new", "gone"] }])).toEqual([2, 3, 5]);
145
- });
146
-
147
- it("searches display values and requires every term", () => {
148
- expect(run([], "paris active")).toEqual([1, 4]);
149
- expect(run([], "Gone")).toEqual([3]);
150
- });
151
- });
152
-
153
- describe("sorting", () => {
154
- const sorted = (sorting: SortItem[]) => ids(sortRows(rows, columns, sorting));
155
-
156
- it("sorts numbers with empty values last in both directions", () => {
157
- expect(sorted([{ columnId: "age", desc: false }])).toEqual([3, 5, 1, 4, 6, 2]);
158
- expect(sorted([{ columnId: "age", desc: true }])).toEqual([6, 4, 1, 3, 5, 2]);
159
- });
160
-
161
- it("uses natural, case-insensitive string order", () => {
162
- expect(sorted([{ columnId: "name", desc: false }])).toEqual([1, 2, 3, 4, 6, 5]);
163
- });
164
-
165
- it("orders strings by the locale it is given", () => {
166
- // Swedish puts ä after z; German sorts it with a. Same rows, same column,
167
- // different order — so the locale has to reach the collator.
168
- const names: GridRow<{ name: string }>[] = [
169
- { id: "angel", index: 0, original: { name: "ängel" } },
170
- { id: "zebra", index: 1, original: { name: "zebra" } },
171
- ];
172
- const nameColumn = resolveColumns<{ name: string }>([{ field: "name" }]);
173
- const order = (locale: string) =>
174
- sortRows(names, nameColumn, [{ columnId: "name", desc: false }], locale).map((row) => row.id);
175
-
176
- expect(order("sv")).toEqual(["zebra", "angel"]);
177
- expect(order("de")).toEqual(["angel", "zebra"]);
178
- });
179
-
180
- it("applies sorts in priority order", () => {
181
- expect(
182
- sorted([
183
- { columnId: "city", desc: false },
184
- { columnId: "age", desc: true },
185
- ]),
186
- ).toEqual([6, 2, 4, 1, 5, 3]);
187
- });
188
-
189
- it("sorts dates and option labels", () => {
190
- expect(sorted([{ columnId: "joined", desc: false }])).toEqual([6, 4, 2, 1, 3, 5]);
191
- expect(sorted([{ columnId: "status", desc: false }])).toEqual([1, 4, 6, 3, 2, 5]);
192
- });
193
-
194
- it("cycles ascending, descending, off", () => {
195
- const asc = toggleSorting([], "a", false);
196
- expect(asc).toEqual([{ columnId: "a", desc: false }]);
197
- const desc = toggleSorting(asc, "a", false);
198
- expect(desc).toEqual([{ columnId: "a", desc: true }]);
199
- expect(toggleSorting(desc, "a", false)).toEqual([]);
200
- expect(toggleSorting(desc, "b", true)).toEqual([
201
- { columnId: "a", desc: true },
202
- { columnId: "b", desc: false },
203
- ]);
204
- expect(toggleSorting(desc, "b", false)).toEqual([{ columnId: "b", desc: false }]);
205
- });
206
- });
207
-
208
- describe("pagination and virtualization", () => {
209
- it("clamps the page index", () => {
210
- const page = paginate(rows, { pageIndex: 9, pageSize: 4 }, { enabled: true, server: false });
211
- expect(page).toMatchObject({ pageIndex: 1, pageCount: 2, pageOffset: 4 });
212
- expect(ids(page.rows)).toEqual([5, 6]);
213
- });
214
-
215
- it("uses the server row count", () => {
216
- const page = paginate(rows.slice(0, 2), { pageIndex: 3, pageSize: 2 }, { enabled: true, server: true, rowCount: 101 });
217
- expect(page).toMatchObject({ pageCount: 51, pageOffset: 6, rowCount: 101 });
218
- expect(page.rows).toHaveLength(2);
219
- });
220
-
221
- it("computes the visible row window", () => {
222
- const viewport = { scrollTop: 400, scrollLeft: 0, width: 800, height: 440 };
223
- expect(getRowRange(viewport, 1000, 40, 40, 2)).toEqual({ start: 8, end: 22 });
224
- });
225
-
226
- it("computes the visible column window", () => {
227
- const many = resolveColumns<Record<string, number>>(
228
- Array.from({ length: 50 }, (_, i) => ({ id: `c${i}`, accessor: (row) => row[`c${i}`], width: 100 })),
229
- );
230
- const layout = computeLayout(many, {
231
- columnOrder: [],
232
- columnVisibility: {},
233
- columnSizing: {},
234
- columnPinning: { left: ["c0"], right: [] },
235
- });
236
- expect(getColumnRange(layout, { scrollTop: 0, scrollLeft: 1000, width: 500, height: 300 }, 0)).toEqual({ start: 10, end: 14 });
237
- expect(getColumnRange(layout, { scrollTop: 0, scrollLeft: 0, width: 0, height: 0 })).toEqual({ start: 0, end: 49 });
238
- });
239
- });
240
-
241
- describe("state", () => {
242
- it("seeds pinning and visibility from column definitions", () => {
243
- const state = createInitialState<Person>({
244
- data: [],
245
- columns: [{ field: "id", pin: "left" }, { field: "name", hidden: true }],
246
- });
247
- expect(state.columnPinning).toEqual({ left: ["id"], right: [] });
248
- expect(state.columnVisibility).toEqual({ name: false });
249
- });
250
-
251
- it("prefers defined controlled values", () => {
252
- const state = createInitialState<Person>({ data: [], columns: [] });
253
- expect(mergeState(state, undefined)).toBe(state);
254
- expect(mergeState(state, { globalFilter: undefined })).toBe(state);
255
- expect(mergeState(state, { globalFilter: "x" }).globalFilter).toBe("x");
256
- });
257
- });
258
-
259
- function setup(overrides: Partial<GridOptions<Person>> = {}) {
260
- const options: GridOptions<Person> = {
261
- data: people,
262
- columns: defs,
263
- getRowId: "id",
264
- enableRowSelection: true,
265
- ...overrides,
266
- };
267
- const engine = createGridEngine(options);
268
- const resolved = resolveColumns(options.columns);
269
- const state = engine.api.getState();
270
- const filtered = filterRows(rows, resolved, state.filters, state.globalFilter, formatters);
271
- const sortedRows = sortRows(filtered, resolved, state.sorting);
272
- const model: GridModel<Person> = {
273
- columns: resolved,
274
- coreRows: rows,
275
- sortedRows,
276
- page: paginate(sortedRows, state.pagination, { enabled: true, server: false }),
277
- layout: computeLayout(resolved, state),
278
- rowHeight: 40,
279
- headerHeight: 40,
280
- formatters,
281
- };
282
- engine.sync(options, model);
283
- return engine;
284
- }
285
-
286
- describe("engine", () => {
287
- it("returns to the first page and reports the query when filters change", () => {
288
- const onQueryChange = vi.fn();
289
- const engine = setup({ onQueryChange, initialState: { pagination: { pageIndex: 2, pageSize: 2 } } });
290
-
291
- engine.api.setFilter("city", { operator: "equals", value: "paris" });
292
-
293
- expect(engine.api.getState().pagination.pageIndex).toBe(0);
294
- expect(onQueryChange).toHaveBeenCalledWith(
295
- expect.objectContaining({
296
- filters: [{ columnId: "city", operator: "equals", value: "paris" }],
297
- pagination: { pageIndex: 0, pageSize: 2 },
298
- }),
299
- );
300
- });
301
-
302
- it("leaves controlled state to the parent", () => {
303
- const onStateChange = vi.fn();
304
- const engine = setup({ state: { sorting: [] }, onStateChange });
305
-
306
- engine.api.toggleSort("name");
307
-
308
- expect(engine.api.getState().sorting).toEqual([]);
309
- expect(onStateChange.mock.calls[0][0].sorting).toEqual([{ columnId: "name", desc: false }]);
310
- });
311
-
312
- it("selects ranges and skips rows that cannot be selected", () => {
313
- const engine = setup({ enableRowSelection: (row) => row.id !== 3 });
314
-
315
- engine.api.toggleRowSelected("1");
316
- engine.api.toggleRowSelected("4", { range: true });
317
- expect(engine.api.getSelectedRowIds()).toEqual(["1", "2", "4"]);
318
-
319
- engine.api.toggleAllRowsSelected(true);
320
- expect(engine.api.getSelectedRows().map((p) => p.id)).toEqual([1, 2, 4, 5, 6]);
321
- });
322
-
323
- it("keeps one row selected in single mode", () => {
324
- const engine = setup({ selectionMode: "single" });
325
- engine.api.toggleRowSelected("1");
326
- engine.api.toggleRowSelected("2");
327
- expect(engine.api.getSelectedRowIds()).toEqual(["2"]);
328
- });
329
-
330
- it("validates edits and tracks async saves", async () => {
331
- let finishSave!: () => void;
332
- const onCellEdit = vi.fn(() => new Promise<void>((resolve) => (finishSave = resolve)));
333
- const engine = setup({
334
- columns: [
335
- { field: "id", type: "number" },
336
- { field: "name", editable: true, validate: (value) => (String(value).trim() ? null : "Required") },
337
- ],
338
- onCellEdit,
339
- });
340
-
341
- engine.api.startEditing("1", "name");
342
- expect(engine.store.getSnapshot().ui.editing).toEqual({ rowId: "1", columnId: "name" });
343
-
344
- expect(engine.commitEdit("1", "name", " ")).toBe("Required");
345
- expect(engine.commitEdit("1", "name", "Alicia")).toBeNull();
346
- expect(onCellEdit).toHaveBeenCalledWith(
347
- expect.objectContaining({ rowId: "1", columnId: "name", value: "Alicia", previousValue: "Alice" }),
348
- );
349
- expect(engine.store.getSnapshot().ui).toMatchObject({ editing: null, pending: { "1": { name: "Alicia" } } });
350
-
351
- finishSave();
352
- await Promise.resolve();
353
- await Promise.resolve();
354
- expect(engine.store.getSnapshot().ui.pending).toEqual({});
355
- });
356
- });
@@ -1,70 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { computeLayout, resolveColumns } from "../core/columns";
3
- import { createInitialState } from "../core/state";
4
- import type { ColumnDef } from "../core/types";
5
- import { createFormatters } from "../core/values";
6
- import { toCsv } from "../export/csv";
7
- import { buildPrintHtml } from "../export/pdf";
8
- import { buildExportTable } from "../export/table";
9
- import { columnName, createXlsxBlob, escapeXml } from "../export/xlsx";
10
- import { crc32 } from "../export/zip";
11
-
12
- interface Person {
13
- id: number;
14
- name: string;
15
- age: number | null;
16
- joined: string;
17
- active: boolean;
18
- status: string;
19
- }
20
-
21
- const defs: ColumnDef<Person>[] = [
22
- { field: "id", type: "number" },
23
- { field: "name" },
24
- { field: "age", type: "number" },
25
- { field: "joined", type: "date" },
26
- { field: "active", type: "boolean" },
27
- { field: "status", options: [{ label: "Active", value: "active" }, { label: "New", value: "new" }] },
28
- ];
29
-
30
- const people: Person[] = [
31
- { id: 1, name: '=HYPERLINK("x"), "quoted"', age: 34, joined: "2024-01-15", active: true, status: "active" },
32
- { id: 2, name: "bob", age: null, joined: "2023-06-01", active: false, status: "new" },
33
- ];
34
-
35
- const columns = resolveColumns(defs);
36
- const layout = computeLayout(columns, createInitialState({ data: people, columns: defs }));
37
- const table = buildExportTable(people, layout.columns, createFormatters("en-US"));
38
-
39
- describe("export", () => {
40
- it("escapes CSV fields and neutralizes formulas", () => {
41
- const [header, first, second] = toCsv(table).split("\r\n");
42
- expect(header).toBe("Id,Name,Age,Joined,Active,Status");
43
- expect(first).toBe(`1,"'=HYPERLINK(""x""), ""quoted""",34,"Jan 15, 2024",Yes,Active`);
44
- expect(second).toBe(`2,bob,,"Jun 1, 2023",No,New`);
45
- });
46
-
47
- it("writes a typed, escaped XLSX package", async () => {
48
- const bytes = new Uint8Array(await createXlsxBlob(table, "People & Co").arrayBuffer());
49
- expect(String.fromCharCode(...bytes.slice(0, 4))).toBe("PK\x03\x04");
50
-
51
- const text = new TextDecoder().decode(bytes);
52
- expect(text).toContain("&quot;quoted&quot;");
53
- expect(text).toContain('<c r="C2"><v>34</v></c>');
54
- expect(text).toContain('<c r="E3" t="b"><v>0</v></c>');
55
- expect(text).toContain('<c r="D2" s="2">');
56
- expect(text).toContain('name="People &amp; Co"');
57
- });
58
-
59
- it("names columns, escapes XML and checksums like the spec", () => {
60
- expect([0, 25, 26, 701, 702].map(columnName)).toEqual(["A", "Z", "AA", "ZZ", "AAA"]);
61
- expect(escapeXml("a<b>&")).toBe("a&lt;b&gt;&amp;");
62
- expect(crc32([new TextEncoder().encode("hello")])).toBe(0x3610a686);
63
- });
64
-
65
- it("escapes HTML in the print view", () => {
66
- const html = buildPrintHtml(table, { title: "<Report>", orientation: "landscape", paperSize: "A4" });
67
- expect(html).toContain("&#60;Report&#62;");
68
- expect(html).not.toContain("<Report>");
69
- });
70
- });
@@ -1,209 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { computeLayout, resolveColumns } from "../core/columns";
3
- import { createInitialState } from "../core/state";
4
- import type { ColumnDef } from "../core/types";
5
- import { createFormatters } from "../core/values";
6
- import { encode, fit, literal, widthOf } from "../export/pdf-font";
7
- import { buildPdf, pdfDate, textString } from "../export/pdf-writer";
8
- import { createPdfBlob, defaultPdfTheme, renderPages } from "../export/pdf";
9
- import { buildExportTable } from "../export/table";
10
-
11
- interface Row {
12
- id: number;
13
- name: string;
14
- salary: number;
15
- }
16
-
17
- const defs: ColumnDef<Row>[] = [
18
- { field: "id", type: "number", width: 80 },
19
- { field: "name", width: 220 },
20
- { field: "salary", type: "number", width: 120 },
21
- ];
22
-
23
- function tableOf(count: number) {
24
- const rows = Array.from({ length: count }, (_, index) => ({
25
- id: index + 1,
26
- name: `Person (${index + 1})`,
27
- salary: 1000 * (index + 1),
28
- }));
29
-
30
- const columns = resolveColumns(defs);
31
- const layout = computeLayout(columns, createInitialState({ data: rows, columns: defs }));
32
- return buildExportTable(rows, layout.columns, createFormatters("en-US"));
33
- }
34
-
35
- const options = {
36
- title: "Report",
37
- orientation: "landscape",
38
- paperSize: "A4",
39
- margin: 36,
40
- fontSize: 9,
41
- header: { left: "{title}" },
42
- footer: { right: "Page {page} of {pages}" },
43
- theme: defaultPdfTheme,
44
- } as const;
45
-
46
- /** Reads the file back the way it was written: one character, one byte. */
47
- async function latin1(blob: Blob): Promise<string> {
48
- const bytes = new Uint8Array(await blob.arrayBuffer());
49
- let out = "";
50
- for (const byte of bytes) out += String.fromCharCode(byte);
51
- return out;
52
- }
53
-
54
- describe("pdf fonts", () => {
55
- it("encodes WinAnsi, replacing what the encoding cannot carry", () => {
56
- // Latin-1 passes through; the 0x80-0x9F specials are remapped.
57
- expect(encode("Müller")).toEqual([77, 0xfc, 108, 108, 101, 114]);
58
- expect(encode("“quoted” — €5")).toEqual([0x93, 113, 117, 111, 116, 101, 100, 0x94, 32, 0x97, 32, 0x80, 53]);
59
-
60
- // Malayalam and emoji have no WinAnsi code, so they read as "?".
61
- expect(encode("ത")).toEqual([0x3f]);
62
- expect(encode("a\tb")).toEqual([97, 32, 98]);
63
- });
64
-
65
- it("measures with the real Helvetica advance widths", () => {
66
- // "A" is 667/1000 em, "i" is 222/1000, at 10pt.
67
- expect(widthOf(encode("A"), "regular", 10)).toBeCloseTo(6.67, 5);
68
- expect(widthOf(encode("i"), "regular", 10)).toBeCloseTo(2.22, 5);
69
- // Bold "A" is wider at 722/1000.
70
- expect(widthOf(encode("A"), "bold", 10)).toBeCloseTo(7.22, 5);
71
- });
72
-
73
- it("truncates to an ellipsis and never overruns the cell", () => {
74
- const long = encode("Engineering Department");
75
- const clipped = fit(long, "regular", 9, 40);
76
-
77
- expect(clipped.length).toBeLessThan(long.length);
78
- expect(clipped[clipped.length - 1]).toBe(0x85);
79
- expect(widthOf(clipped, "regular", 9)).toBeLessThanOrEqual(40);
80
-
81
- // Text that already fits is returned untouched.
82
- expect(fit(encode("ok"), "regular", 9, 400)).toEqual(encode("ok"));
83
- // Not even the ellipsis fits.
84
- expect(fit(long, "regular", 9, 1)).toEqual([]);
85
- });
86
-
87
- it("escapes only the three string delimiters", () => {
88
- expect(literal(encode("a(b)c\\d"))).toBe("a\\(b\\)c\\\\d");
89
- expect(literal(encode("plain"))).toBe("plain");
90
- });
91
- });
92
-
93
- describe("pdf writer", () => {
94
- it("writes a header, an xref whose offsets are right, and a trailer", async () => {
95
- const blob = buildPdf(["<</Type/Catalog/Pages 2 0 R>>", { stream: "hello" }], {
96
- root: 1,
97
- info: 1,
98
- });
99
- const text = await latin1(blob);
100
-
101
- expect(text.startsWith("%PDF-1.7\n")).toBe(true);
102
- expect(text.trimEnd().endsWith("%%EOF")).toBe(true);
103
- expect(text).toContain("<</Length 5>>\nstream\nhello\nendstream");
104
-
105
- // Every xref offset must land exactly on its object header.
106
- const start = Number(text.slice(text.lastIndexOf("startxref") + 10).split("\n")[0]);
107
- expect(text.startsWith("xref\n", start)).toBe(true);
108
-
109
- // Skip "xref" and the "0 N" subsection line to reach the 20-byte entries.
110
- const first = text.indexOf("\n", text.indexOf("\n", start) + 1) + 1;
111
-
112
- for (let id = 1; id <= 2; id++) {
113
- const at = Number(text.slice(first + id * 20, first + id * 20 + 10));
114
- expect(text.startsWith(`${id} 0 obj`, at)).toBe(true);
115
- }
116
- });
117
-
118
- it("writes document info as UTF-16BE, which /Title needs", () => {
119
- // A bare literal would be read as PDFDocEncoding, where 0x97 is S-caron
120
- // rather than the em dash the caller passed.
121
- expect(textString("A—B")).toBe("<FEFF004120140042>");
122
- expect(textString("Hi")).toBe("<FEFF00480069>");
123
- });
124
-
125
- it("formats dates the way /CreationDate wants them", () => {
126
- expect(pdfDate(new Date(Date.UTC(2026, 8, 22, 9, 5, 3)))).toBe("D:20260922090503+00'00'");
127
- });
128
- });
129
-
130
- describe("pdf layout", () => {
131
- it("repeats the header row on every page and fills page tokens", () => {
132
- const pages = renderPages(tableOf(120), options);
133
-
134
- expect(pages.length).toBeGreaterThan(1);
135
-
136
- for (const page of pages) {
137
- expect(page).toContain("(Name) Tj");
138
- expect(page).toContain("/F2 9 Tf");
139
- }
140
-
141
- expect(pages[0]).toContain(`(Page 1 of ${pages.length}) Tj`);
142
- expect(pages[1]).toContain(`(Page 2 of ${pages.length}) Tj`);
143
- expect(pages[0]).toContain("(Report) Tj");
144
- });
145
-
146
- it("puts every row on exactly one page, in order", () => {
147
- const table = tableOf(120);
148
- const pages = renderPages(table, options);
149
- const drawn = pages.flatMap((page) => [...page.matchAll(/\(Person \\\((\d+)\\\)\) Tj/g)].map((m) => Number(m[1])));
150
-
151
- expect(drawn).toEqual(table.text.map((_, index) => index + 1));
152
- });
153
-
154
- it("right-aligns numbers and left-aligns text", () => {
155
- const [page] = renderPages(tableOf(1), options);
156
- const at = (label: string) => {
157
- const match = page.match(new RegExp(`([\\d.]+) [\\d.]+ Td \\(${label}\\) Tj`));
158
- return Number(match![1]);
159
- };
160
-
161
- // Compare each cell against its own column header, which is always drawn
162
- // from the left edge. A numeric cell is pushed right; a text cell is not.
163
- expect(at("1")).toBeGreaterThan(at("Id"));
164
- expect(at("1000")).toBeGreaterThan(at("Salary"));
165
- expect(at("Person \\\\\\(1\\\\\\)")).toBeCloseTo(at("Name"), 5);
166
- });
167
-
168
- it("honours paper size and orientation", async () => {
169
- const table = tableOf(3);
170
- const portrait = await latin1(await createPdfBlob(table, { ...options, orientation: "portrait" }));
171
- const landscape = await latin1(await createPdfBlob(table, { ...options, orientation: "landscape" }));
172
- const a5 = await latin1(await createPdfBlob(table, { ...options, paperSize: "A5" }));
173
-
174
- expect(portrait).toContain("/MediaBox[0 0 595.28 841.89]");
175
- expect(landscape).toContain("/MediaBox[0 0 841.89 595.28]");
176
- expect(a5).toContain("/MediaBox[0 0 595.28 419.53]");
177
- });
178
-
179
- it("drops the bands when they are null, and the stripe when it is null", () => {
180
- const bare = renderPages(tableOf(2), {
181
- ...options,
182
- header: null,
183
- footer: null,
184
- theme: { ...defaultPdfTheme, stripe: null },
185
- });
186
-
187
- expect(bare[0]).not.toContain("(Report) Tj");
188
- expect(bare[0]).not.toContain("Page 1 of");
189
- // One `re f` remains: the header row's fill.
190
- expect(bare[0].match(/re f/g)).toHaveLength(1);
191
- });
192
-
193
- it("produces a single valid file for the whole table", async () => {
194
- const blob = await createPdfBlob(tableOf(60), options);
195
- const text = await latin1(blob);
196
-
197
- expect(blob.type).toBe("application/pdf");
198
- expect(text.startsWith("%PDF-1.7")).toBe(true);
199
- expect(text).toContain("/BaseFont/Helvetica/Encoding/WinAnsiEncoding");
200
- expect(text).toContain("/BaseFont/Helvetica-Bold/Encoding/WinAnsiEncoding");
201
- expect(text).toContain(`/Title ${textString("Report")}`);
202
-
203
- const count = Number(text.match(/\/Type\/Pages\/Count (\d+)/)![1]);
204
- expect(text.match(/\/Type\/Page\//g)).toHaveLength(count);
205
-
206
- // Node has CompressionStream, so the streams should be Flate encoded.
207
- expect(text).toContain("/Filter/FlateDecode");
208
- });
209
- });