gbs-add-block 1.2.7 → 1.2.8
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.
- package/README.md +3 -3
- package/index.cjs +9 -9
- package/package.json +1 -1
- package/source/components/datagridbeta/README.md +232 -0
- package/source/components/datagridbeta/__tests__/core.test.ts +341 -0
- package/source/components/datagridbeta/__tests__/export.test.ts +70 -0
- package/source/components/datagridbeta/core/columnHelper.ts +21 -0
- package/source/components/datagridbeta/core/columns.ts +288 -0
- package/source/components/datagridbeta/core/filtering.ts +237 -0
- package/source/components/datagridbeta/core/grid.ts +616 -0
- package/source/components/datagridbeta/core/index.ts +14 -0
- package/source/components/datagridbeta/core/rows.ts +64 -0
- package/source/components/datagridbeta/core/sorting.ts +111 -0
- package/source/components/datagridbeta/core/state.ts +82 -0
- package/source/components/datagridbeta/core/store.ts +52 -0
- package/source/components/datagridbeta/core/types.ts +266 -0
- package/source/components/datagridbeta/core/values.ts +98 -0
- package/source/components/datagridbeta/core/virtual.ts +85 -0
- package/source/components/datagridbeta/export/csv.ts +32 -0
- package/source/components/datagridbeta/export/download.ts +12 -0
- package/source/components/datagridbeta/export/pdf.ts +61 -0
- package/source/components/datagridbeta/export/table.ts +73 -0
- package/source/components/datagridbeta/export/xlsx.ts +145 -0
- package/source/components/datagridbeta/export/zip.ts +87 -0
- package/source/components/datagridbeta/index.ts +7 -0
- package/source/components/datagridbeta/react/Cell.tsx +282 -0
- package/source/components/datagridbeta/react/ColumnMenu.tsx +135 -0
- package/source/components/datagridbeta/react/DataGrid.tsx +296 -0
- package/source/components/datagridbeta/react/FilterForm.tsx +142 -0
- package/source/components/datagridbeta/react/HeaderRow.tsx +306 -0
- package/source/components/datagridbeta/react/Pagination.tsx +107 -0
- package/source/components/datagridbeta/react/Popover.tsx +79 -0
- package/source/components/datagridbeta/react/Row.tsx +85 -0
- package/source/components/datagridbeta/react/Toolbar.tsx +266 -0
- package/source/components/datagridbeta/react/Viewport.tsx +167 -0
- package/source/components/datagridbeta/react/context.ts +57 -0
- package/source/components/datagridbeta/react/hooks.ts +80 -0
- package/source/components/datagridbeta/react/icons.tsx +72 -0
- package/source/components/datagridbeta/react/keyboard.ts +110 -0
- package/source/components/datagridbeta/react/locale.ts +128 -0
- package/source/components/datagridbeta/styles.css +717 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { GridRow, ResolvedColumn, SortItem } from "./types";
|
|
2
|
+
import { toTime } from "./values";
|
|
3
|
+
|
|
4
|
+
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
|
5
|
+
|
|
6
|
+
type SortKey = number | null;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Converts a column's values into numbers once, so the sort itself only does
|
|
10
|
+
* numeric comparisons. Strings become their collation rank.
|
|
11
|
+
*/
|
|
12
|
+
function buildKeys<T>(rows: GridRow<T>[], column: ResolvedColumn<T>): SortKey[] {
|
|
13
|
+
const values = rows.map((row) => column.getValue(row.original));
|
|
14
|
+
|
|
15
|
+
switch (column.type) {
|
|
16
|
+
case "number":
|
|
17
|
+
return values.map((v) => {
|
|
18
|
+
if (v === null || v === undefined || v === "") return null;
|
|
19
|
+
const n = Number(v);
|
|
20
|
+
return Number.isNaN(n) ? null : n;
|
|
21
|
+
});
|
|
22
|
+
case "date":
|
|
23
|
+
return values.map(toTime);
|
|
24
|
+
case "boolean":
|
|
25
|
+
return values.map((v) => (v === null || v === undefined ? null : v ? 1 : 0));
|
|
26
|
+
default: {
|
|
27
|
+
const { options } = column.def;
|
|
28
|
+
const labels = options && new Map(options.map((o) => [o.value, o.label]));
|
|
29
|
+
const strings = values.map((v) =>
|
|
30
|
+
v === null || v === undefined || v === ""
|
|
31
|
+
? null
|
|
32
|
+
: String(labels?.get(v as string | number) ?? v),
|
|
33
|
+
);
|
|
34
|
+
const unique = Array.from(new Set(strings.filter((s) => s !== null)));
|
|
35
|
+
unique.sort(collator.compare);
|
|
36
|
+
const rank = new Map<string, number>();
|
|
37
|
+
let current = 0;
|
|
38
|
+
unique.forEach((s, i) => {
|
|
39
|
+
if (i > 0 && collator.compare(unique[i - 1], s) !== 0) current = i;
|
|
40
|
+
rank.set(s, current);
|
|
41
|
+
});
|
|
42
|
+
return strings.map((s) => (s === null ? null : rank.get(s)!));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Stable multi-column sort. Empty values always sort last. */
|
|
48
|
+
export function sortRows<T>(
|
|
49
|
+
rows: GridRow<T>[],
|
|
50
|
+
columns: readonly ResolvedColumn<T>[],
|
|
51
|
+
sorting: readonly SortItem[],
|
|
52
|
+
): GridRow<T>[] {
|
|
53
|
+
const byId = new Map(columns.map((c) => [c.id, c]));
|
|
54
|
+
const active = sorting
|
|
55
|
+
.map((item) => ({ column: byId.get(item.columnId), desc: item.desc }))
|
|
56
|
+
.filter(
|
|
57
|
+
(item): item is { column: ResolvedColumn<T>; desc: boolean } =>
|
|
58
|
+
item.column !== undefined && item.column.hasValue,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (active.length === 0 || rows.length < 2) return rows;
|
|
62
|
+
|
|
63
|
+
const comparators = active.map(({ column, desc }) => {
|
|
64
|
+
const direction = desc ? -1 : 1;
|
|
65
|
+
const { sortFn } = column.def;
|
|
66
|
+
if (sortFn) {
|
|
67
|
+
const values = rows.map((row) => column.getValue(row.original));
|
|
68
|
+
return (i: number, j: number) =>
|
|
69
|
+
direction * sortFn(values[i], values[j], rows[i].original, rows[j].original);
|
|
70
|
+
}
|
|
71
|
+
const keys = buildKeys(rows, column);
|
|
72
|
+
return (i: number, j: number) => {
|
|
73
|
+
const a = keys[i];
|
|
74
|
+
const b = keys[j];
|
|
75
|
+
if (a === b) return 0;
|
|
76
|
+
if (a === null) return 1;
|
|
77
|
+
if (b === null) return -1;
|
|
78
|
+
return a < b ? -direction : direction;
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const indices = rows.map((_, i) => i);
|
|
83
|
+
indices.sort((i, j) => {
|
|
84
|
+
for (const compare of comparators) {
|
|
85
|
+
const result = compare(i, j);
|
|
86
|
+
if (result !== 0) return result;
|
|
87
|
+
}
|
|
88
|
+
return i - j;
|
|
89
|
+
});
|
|
90
|
+
return indices.map((i) => rows[i]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Click cycles asc → desc → off. With `multi`, other sorts are kept. */
|
|
94
|
+
export function toggleSorting(
|
|
95
|
+
sorting: readonly SortItem[],
|
|
96
|
+
columnId: string,
|
|
97
|
+
multi: boolean,
|
|
98
|
+
): SortItem[] {
|
|
99
|
+
const existing = sorting.find((s) => s.columnId === columnId);
|
|
100
|
+
const next: SortItem | null = !existing
|
|
101
|
+
? { columnId, desc: false }
|
|
102
|
+
: !existing.desc
|
|
103
|
+
? { columnId, desc: true }
|
|
104
|
+
: null;
|
|
105
|
+
|
|
106
|
+
if (!multi) return next ? [next] : [];
|
|
107
|
+
if (!existing) return [...sorting, next!];
|
|
108
|
+
return next
|
|
109
|
+
? sorting.map((s) => (s.columnId === columnId ? next : s))
|
|
110
|
+
: sorting.filter((s) => s.columnId !== columnId);
|
|
111
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { getColumnId } from "./columns";
|
|
2
|
+
import type { GridOptions, GridQuery, GridState } from "./types";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_PAGE_SIZE = 50;
|
|
5
|
+
|
|
6
|
+
export const STATE_KEYS = [
|
|
7
|
+
"sorting",
|
|
8
|
+
"filters",
|
|
9
|
+
"globalFilter",
|
|
10
|
+
"pagination",
|
|
11
|
+
"rowSelection",
|
|
12
|
+
"columnOrder",
|
|
13
|
+
"columnVisibility",
|
|
14
|
+
"columnSizing",
|
|
15
|
+
"columnPinning",
|
|
16
|
+
"density",
|
|
17
|
+
] as const satisfies readonly (keyof GridState)[];
|
|
18
|
+
|
|
19
|
+
const QUERY_KEYS = ["sorting", "filters", "globalFilter", "pagination"] as const;
|
|
20
|
+
|
|
21
|
+
/** Initial state, seeded from `initialState` and column-level `pin` / `hidden`. */
|
|
22
|
+
export function createInitialState<T>(options: GridOptions<T>): GridState {
|
|
23
|
+
const init = options.initialState ?? {};
|
|
24
|
+
const left: string[] = [];
|
|
25
|
+
const right: string[] = [];
|
|
26
|
+
const visibility: Record<string, boolean> = {};
|
|
27
|
+
|
|
28
|
+
for (const def of options.columns) {
|
|
29
|
+
const id = getColumnId(def);
|
|
30
|
+
if (def.pin === "left") left.push(id);
|
|
31
|
+
if (def.pin === "right") right.push(id);
|
|
32
|
+
if (def.hidden) visibility[id] = false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
sorting: init.sorting ?? [],
|
|
37
|
+
filters: init.filters ?? [],
|
|
38
|
+
globalFilter: init.globalFilter ?? "",
|
|
39
|
+
pagination: { pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE, ...init.pagination },
|
|
40
|
+
rowSelection: init.rowSelection ?? {},
|
|
41
|
+
columnOrder: init.columnOrder ?? [],
|
|
42
|
+
columnVisibility: init.columnVisibility ?? visibility,
|
|
43
|
+
columnSizing: init.columnSizing ?? {},
|
|
44
|
+
columnPinning: init.columnPinning ?? { left, right },
|
|
45
|
+
density: init.density ?? "standard",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Controlled keys (not undefined) win over internal state. */
|
|
50
|
+
export function mergeState(
|
|
51
|
+
internal: GridState,
|
|
52
|
+
controlled: Partial<GridState> | undefined,
|
|
53
|
+
): GridState {
|
|
54
|
+
if (!controlled) return internal;
|
|
55
|
+
let merged: GridState | null = null;
|
|
56
|
+
for (const key of STATE_KEYS) {
|
|
57
|
+
const value = controlled[key];
|
|
58
|
+
if (value !== undefined && value !== internal[key]) {
|
|
59
|
+
merged ??= { ...internal };
|
|
60
|
+
(merged as unknown as Record<string, unknown>)[key] = value;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return merged ?? internal;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function pickQuery(state: GridState): GridQuery {
|
|
67
|
+
return {
|
|
68
|
+
sorting: state.sorting,
|
|
69
|
+
filters: state.filters,
|
|
70
|
+
globalFilter: state.globalFilter,
|
|
71
|
+
pagination: state.pagination,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function queryChanged(a: GridState, b: GridState): boolean {
|
|
76
|
+
return QUERY_KEYS.some((key) => a[key] !== b[key]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Filters, search and sort changes move the grid back to the first page. */
|
|
80
|
+
export function resetsPage(a: GridState, b: GridState): boolean {
|
|
81
|
+
return a.filters !== b.filters || a.globalFilter !== b.globalFilter || a.sorting !== b.sorting;
|
|
82
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface ReadableStore<S> {
|
|
2
|
+
getSnapshot(): S;
|
|
3
|
+
subscribe(listener: () => void): () => void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface Store<S> extends ReadableStore<S> {
|
|
7
|
+
setState(updater: (prev: S) => S): void;
|
|
8
|
+
notify(): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Minimal external store for `useSyncExternalStore`. */
|
|
12
|
+
export function createStore<S>(initial: S): Store<S> {
|
|
13
|
+
let state = initial;
|
|
14
|
+
const listeners = new Set<() => void>();
|
|
15
|
+
|
|
16
|
+
const notify = () => {
|
|
17
|
+
for (const listener of listeners) listener();
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
getSnapshot: () => state,
|
|
22
|
+
subscribe(listener) {
|
|
23
|
+
listeners.add(listener);
|
|
24
|
+
return () => listeners.delete(listener);
|
|
25
|
+
},
|
|
26
|
+
setState(updater) {
|
|
27
|
+
const next = updater(state);
|
|
28
|
+
if (Object.is(next, state)) return;
|
|
29
|
+
state = next;
|
|
30
|
+
notify();
|
|
31
|
+
},
|
|
32
|
+
notify,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function shallowEqual<T>(a: T, b: T): boolean {
|
|
37
|
+
if (Object.is(a, b)) return true;
|
|
38
|
+
if (
|
|
39
|
+
typeof a !== "object" ||
|
|
40
|
+
typeof b !== "object" ||
|
|
41
|
+
a === null ||
|
|
42
|
+
b === null
|
|
43
|
+
) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const keysA = Object.keys(a) as (keyof T)[];
|
|
47
|
+
if (keysA.length !== Object.keys(b).length) return false;
|
|
48
|
+
for (const key of keysA) {
|
|
49
|
+
if (!Object.is(a[key], b[key])) return false;
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import type { MouseEvent, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type RowId = string;
|
|
4
|
+
|
|
5
|
+
export type ColumnType = "string" | "number" | "date" | "boolean";
|
|
6
|
+
|
|
7
|
+
export type Density = "compact" | "standard" | "comfortable";
|
|
8
|
+
|
|
9
|
+
export type PinSide = "left" | "right";
|
|
10
|
+
|
|
11
|
+
export interface SortItem {
|
|
12
|
+
columnId: string;
|
|
13
|
+
desc: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type FilterOperator =
|
|
17
|
+
| "contains"
|
|
18
|
+
| "notContains"
|
|
19
|
+
| "equals"
|
|
20
|
+
| "notEquals"
|
|
21
|
+
| "startsWith"
|
|
22
|
+
| "endsWith"
|
|
23
|
+
| "gt"
|
|
24
|
+
| "gte"
|
|
25
|
+
| "lt"
|
|
26
|
+
| "lte"
|
|
27
|
+
| "between"
|
|
28
|
+
| "before"
|
|
29
|
+
| "after"
|
|
30
|
+
| "in"
|
|
31
|
+
| "isEmpty"
|
|
32
|
+
| "isNotEmpty";
|
|
33
|
+
|
|
34
|
+
export type FilterValue =
|
|
35
|
+
| string
|
|
36
|
+
| number
|
|
37
|
+
| boolean
|
|
38
|
+
| ReadonlyArray<string | number>
|
|
39
|
+
| null;
|
|
40
|
+
|
|
41
|
+
export interface ColumnFilter {
|
|
42
|
+
columnId: string;
|
|
43
|
+
operator: FilterOperator;
|
|
44
|
+
value?: FilterValue;
|
|
45
|
+
/** Upper bound for the `between` operator. */
|
|
46
|
+
value2?: FilterValue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PaginationState {
|
|
50
|
+
pageIndex: number;
|
|
51
|
+
pageSize: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ColumnPinningState {
|
|
55
|
+
left: string[];
|
|
56
|
+
right: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Every piece of grid state that can be controlled from the outside. */
|
|
60
|
+
export interface GridState {
|
|
61
|
+
sorting: SortItem[];
|
|
62
|
+
filters: ColumnFilter[];
|
|
63
|
+
globalFilter: string;
|
|
64
|
+
pagination: PaginationState;
|
|
65
|
+
rowSelection: Record<RowId, boolean>;
|
|
66
|
+
columnOrder: string[];
|
|
67
|
+
columnVisibility: Record<string, boolean>;
|
|
68
|
+
columnSizing: Record<string, number>;
|
|
69
|
+
columnPinning: ColumnPinningState;
|
|
70
|
+
density: Density;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The subset of state a server needs to produce a page of rows. */
|
|
74
|
+
export interface GridQuery {
|
|
75
|
+
sorting: SortItem[];
|
|
76
|
+
filters: ColumnFilter[];
|
|
77
|
+
globalFilter: string;
|
|
78
|
+
pagination: PaginationState;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface GridRow<T> {
|
|
82
|
+
id: RowId;
|
|
83
|
+
/** Position in the original `data` array. */
|
|
84
|
+
index: number;
|
|
85
|
+
original: T;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface CellContext<T, V = unknown> {
|
|
89
|
+
row: T;
|
|
90
|
+
rowId: RowId;
|
|
91
|
+
/** Index within the rows currently displayed (the page). */
|
|
92
|
+
rowIndex: number;
|
|
93
|
+
value: V;
|
|
94
|
+
column: ResolvedColumn<T>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface EditorProps<T, V = unknown> {
|
|
98
|
+
value: V;
|
|
99
|
+
row: T;
|
|
100
|
+
column: ResolvedColumn<T>;
|
|
101
|
+
error: string | null;
|
|
102
|
+
onChange(value: V): void;
|
|
103
|
+
/** Commits the current draft, or `value` when given. */
|
|
104
|
+
commit(value?: V): void;
|
|
105
|
+
cancel(): void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type BuiltInEditor = "text" | "number" | "date" | "select" | "checkbox";
|
|
109
|
+
|
|
110
|
+
export interface ColumnOption {
|
|
111
|
+
label: string;
|
|
112
|
+
value: string | number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type ExportCellValue = string | number | boolean | Date | null | undefined;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Column definition. Callbacks use method syntax so a `ColumnDef<T, number>`
|
|
119
|
+
* is assignable to `ColumnDef<T>`.
|
|
120
|
+
*/
|
|
121
|
+
export interface ColumnDef<T, V = unknown> {
|
|
122
|
+
/** Unique id. Defaults to `field`. Required for accessor and display columns. */
|
|
123
|
+
id?: string;
|
|
124
|
+
field?: Extract<keyof T, string>;
|
|
125
|
+
accessor?(row: T): V;
|
|
126
|
+
header?: string;
|
|
127
|
+
type?: ColumnType;
|
|
128
|
+
width?: number;
|
|
129
|
+
minWidth?: number;
|
|
130
|
+
maxWidth?: number;
|
|
131
|
+
align?: "start" | "center" | "end";
|
|
132
|
+
/** Initial pin side. */
|
|
133
|
+
pin?: PinSide;
|
|
134
|
+
/** Initially hidden. */
|
|
135
|
+
hidden?: boolean;
|
|
136
|
+
sortable?: boolean;
|
|
137
|
+
filterable?: boolean;
|
|
138
|
+
resizable?: boolean;
|
|
139
|
+
hideable?: boolean;
|
|
140
|
+
reorderable?: boolean;
|
|
141
|
+
pinnable?: boolean;
|
|
142
|
+
/** Included in the global search. Default true for data columns. */
|
|
143
|
+
searchable?: boolean;
|
|
144
|
+
/** Fixed choices: enables the `in` filter and the select editor. */
|
|
145
|
+
options?: ReadonlyArray<ColumnOption>;
|
|
146
|
+
/** Display text for the value; also used by search, CSV, PDF and copy. */
|
|
147
|
+
format?(value: V, row: T): string;
|
|
148
|
+
/** Custom cell renderer. */
|
|
149
|
+
cell?(ctx: CellContext<T, V>): ReactNode;
|
|
150
|
+
sortFn?(a: V, b: V, rowA: T, rowB: T): number;
|
|
151
|
+
filterFn?(value: V, filter: ColumnFilter, row: T): boolean;
|
|
152
|
+
editable?: boolean | ((row: T) => boolean);
|
|
153
|
+
editor?: BuiltInEditor | ((props: EditorProps<T, V>) => ReactNode);
|
|
154
|
+
/** Return an error message to reject an edit. */
|
|
155
|
+
validate?(value: V, row: T): string | null | undefined;
|
|
156
|
+
exportable?: boolean;
|
|
157
|
+
exportValue?(row: T): ExportCellValue;
|
|
158
|
+
headerClassName?: string;
|
|
159
|
+
cellClassName?: string | ((ctx: CellContext<T, V>) => string | undefined);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface ResolvedColumn<T> {
|
|
163
|
+
id: string;
|
|
164
|
+
def: ColumnDef<T>;
|
|
165
|
+
header: string;
|
|
166
|
+
type: ColumnType;
|
|
167
|
+
/** False for display-only columns (no field or accessor). */
|
|
168
|
+
hasValue: boolean;
|
|
169
|
+
getValue(row: T): unknown;
|
|
170
|
+
width: number;
|
|
171
|
+
minWidth: number;
|
|
172
|
+
maxWidth: number;
|
|
173
|
+
align: "start" | "center" | "end";
|
|
174
|
+
sortable: boolean;
|
|
175
|
+
filterable: boolean;
|
|
176
|
+
resizable: boolean;
|
|
177
|
+
hideable: boolean;
|
|
178
|
+
reorderable: boolean;
|
|
179
|
+
pinnable: boolean;
|
|
180
|
+
searchable: boolean;
|
|
181
|
+
exportable: boolean;
|
|
182
|
+
/** Internal columns (row selection) are never exported or listed in menus. */
|
|
183
|
+
internal: boolean;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface ColumnLayoutItem<T> {
|
|
187
|
+
column: ResolvedColumn<T>;
|
|
188
|
+
width: number;
|
|
189
|
+
pinned: PinSide | false;
|
|
190
|
+
/** Offset from the start of its section (left, center or right). */
|
|
191
|
+
offset: number;
|
|
192
|
+
/** Position in visual order, 0-based; also the CSS variable index. */
|
|
193
|
+
visibleIndex: number;
|
|
194
|
+
/** Last left-pinned or first right-pinned column (draws the pin shadow). */
|
|
195
|
+
pinEdge: boolean;
|
|
196
|
+
/** Stable style object for cells of this column. */
|
|
197
|
+
style: Record<string, string>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface ColumnLayout<T> {
|
|
201
|
+
columns: ColumnLayoutItem<T>[];
|
|
202
|
+
left: ColumnLayoutItem<T>[];
|
|
203
|
+
center: ColumnLayoutItem<T>[];
|
|
204
|
+
right: ColumnLayoutItem<T>[];
|
|
205
|
+
leftWidth: number;
|
|
206
|
+
centerWidth: number;
|
|
207
|
+
rightWidth: number;
|
|
208
|
+
totalWidth: number;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface CellEditEvent<T> {
|
|
212
|
+
row: T;
|
|
213
|
+
rowId: RowId;
|
|
214
|
+
columnId: string;
|
|
215
|
+
value: unknown;
|
|
216
|
+
previousValue: unknown;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export type ExportScope = "filtered" | "all" | "selected" | "page";
|
|
220
|
+
|
|
221
|
+
export interface ExportOptions<T> {
|
|
222
|
+
fileName?: string;
|
|
223
|
+
scope?: ExportScope;
|
|
224
|
+
/** Export these rows instead of a scope, e.g. a full result set fetched from the server. */
|
|
225
|
+
rows?: readonly T[];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface PdfExportOptions<T> extends ExportOptions<T> {
|
|
229
|
+
title?: string;
|
|
230
|
+
orientation?: "portrait" | "landscape";
|
|
231
|
+
paperSize?: "A3" | "A4" | "A5" | "letter" | "legal";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface GridOptions<T> {
|
|
235
|
+
data: readonly T[];
|
|
236
|
+
columns: readonly ColumnDef<T>[];
|
|
237
|
+
/**
|
|
238
|
+
* Stable row id: a property name (recommended) or a function.
|
|
239
|
+
* Defaults to `row.id`, falling back to the array index.
|
|
240
|
+
*/
|
|
241
|
+
getRowId?: Extract<keyof T, string> | ((row: T, index: number) => RowId);
|
|
242
|
+
/** `client`: the grid sorts, filters and paginates. `server`: `data` is the current page. */
|
|
243
|
+
mode?: "client" | "server";
|
|
244
|
+
/** Total rows on the server (server mode). */
|
|
245
|
+
rowCount?: number;
|
|
246
|
+
state?: Partial<GridState>;
|
|
247
|
+
initialState?: Partial<GridState>;
|
|
248
|
+
onStateChange?(next: GridState, prev: GridState): void;
|
|
249
|
+
/** Fires when sorting, filters, search or pagination change. */
|
|
250
|
+
onQueryChange?(query: GridQuery): void;
|
|
251
|
+
enablePagination?: boolean;
|
|
252
|
+
enableSorting?: boolean;
|
|
253
|
+
enableMultiSort?: boolean;
|
|
254
|
+
enableFiltering?: boolean;
|
|
255
|
+
enableColumnResizing?: boolean;
|
|
256
|
+
enableColumnReordering?: boolean;
|
|
257
|
+
enableColumnPinning?: boolean;
|
|
258
|
+
enableColumnHiding?: boolean;
|
|
259
|
+
enableRowSelection?: boolean | ((row: T) => boolean);
|
|
260
|
+
selectionMode?: "single" | "multiple";
|
|
261
|
+
onCellEdit?(event: CellEditEvent<T>): void | Promise<void>;
|
|
262
|
+
/** Not fired for clicks on interactive content (buttons, inputs, links, checkboxes). */
|
|
263
|
+
onRowClick?(row: T, event: MouseEvent<HTMLElement>): void;
|
|
264
|
+
onRowDoubleClick?(row: T, event: MouseEvent<HTMLElement>): void;
|
|
265
|
+
exportFileName?: string;
|
|
266
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { ResolvedColumn } from "./types";
|
|
2
|
+
|
|
3
|
+
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
4
|
+
|
|
5
|
+
export interface Formatters {
|
|
6
|
+
date: Intl.DateTimeFormat;
|
|
7
|
+
yes: string;
|
|
8
|
+
no: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createFormatters(
|
|
12
|
+
locale?: string,
|
|
13
|
+
labels: { yes: string; no: string } = { yes: "Yes", no: "No" },
|
|
14
|
+
): Formatters {
|
|
15
|
+
return {
|
|
16
|
+
date: new Intl.DateTimeFormat(locale, { dateStyle: "medium" }),
|
|
17
|
+
...labels,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Milliseconds since epoch, or null. Date-only strings are read as local dates. */
|
|
22
|
+
export function toTime(value: unknown): number | null {
|
|
23
|
+
if (value instanceof Date) {
|
|
24
|
+
const time = value.getTime();
|
|
25
|
+
return Number.isNaN(time) ? null : time;
|
|
26
|
+
}
|
|
27
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
28
|
+
if (typeof value === "string" && value !== "") {
|
|
29
|
+
const match = DATE_ONLY.exec(value);
|
|
30
|
+
const time = match
|
|
31
|
+
? new Date(+match[1], +match[2] - 1, +match[3]).getTime()
|
|
32
|
+
: Date.parse(value);
|
|
33
|
+
return Number.isNaN(time) ? null : time;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function startOfNextDay(time: number): number {
|
|
39
|
+
const date = new Date(time);
|
|
40
|
+
date.setHours(0, 0, 0, 0);
|
|
41
|
+
date.setDate(date.getDate() + 1);
|
|
42
|
+
return date.getTime();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function startOfDay(time: number): number {
|
|
46
|
+
const date = new Date(time);
|
|
47
|
+
date.setHours(0, 0, 0, 0);
|
|
48
|
+
return date.getTime();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isEmptyValue(value: unknown): boolean {
|
|
52
|
+
return (
|
|
53
|
+
value === null ||
|
|
54
|
+
value === undefined ||
|
|
55
|
+
(typeof value === "string" && value.trim() === "") ||
|
|
56
|
+
(typeof value === "number" && Number.isNaN(value)) ||
|
|
57
|
+
(Array.isArray(value) && value.length === 0)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `YYYY-MM-DD` in local time, for `<input type="date">`. */
|
|
62
|
+
export function toDateInputValue(value: unknown): string {
|
|
63
|
+
const time = toTime(value);
|
|
64
|
+
if (time === null) return "";
|
|
65
|
+
const date = new Date(time);
|
|
66
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
67
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Display text for a cell; also used by search, CSV, PDF and clipboard copy. */
|
|
71
|
+
export function formatCellValue<T>(
|
|
72
|
+
column: ResolvedColumn<T>,
|
|
73
|
+
value: unknown,
|
|
74
|
+
row: T,
|
|
75
|
+
formatters: Formatters,
|
|
76
|
+
): string {
|
|
77
|
+
if (column.def.format) return column.def.format(value, row);
|
|
78
|
+
if (value === null || value === undefined) return "";
|
|
79
|
+
|
|
80
|
+
const { options } = column.def;
|
|
81
|
+
if (options) {
|
|
82
|
+
const option = options.find((o) => o.value === value);
|
|
83
|
+
if (option) return option.label;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
switch (column.type) {
|
|
87
|
+
case "date": {
|
|
88
|
+
const time = toTime(value);
|
|
89
|
+
return time === null ? String(value) : formatters.date.format(time);
|
|
90
|
+
}
|
|
91
|
+
case "boolean":
|
|
92
|
+
return value ? formatters.yes : formatters.no;
|
|
93
|
+
default:
|
|
94
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
95
|
+
if (value instanceof Date) return formatters.date.format(value);
|
|
96
|
+
return String(value);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ColumnLayout } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface Viewport {
|
|
4
|
+
scrollTop: number;
|
|
5
|
+
scrollLeft: number;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Half-open range: `start` inclusive, `end` exclusive. */
|
|
11
|
+
export interface Range {
|
|
12
|
+
start: number;
|
|
13
|
+
end: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface VisibleRange {
|
|
17
|
+
rows: Range;
|
|
18
|
+
columns: Range;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getRowRange(
|
|
22
|
+
viewport: Viewport,
|
|
23
|
+
rowCount: number,
|
|
24
|
+
rowHeight: number,
|
|
25
|
+
headerHeight: number,
|
|
26
|
+
overscan = 6,
|
|
27
|
+
): Range {
|
|
28
|
+
if (rowCount === 0) return { start: 0, end: 0 };
|
|
29
|
+
const bodyHeight = Math.max(0, viewport.height - headerHeight);
|
|
30
|
+
const first = Math.floor(viewport.scrollTop / rowHeight);
|
|
31
|
+
const last = Math.ceil((viewport.scrollTop + bodyHeight) / rowHeight);
|
|
32
|
+
return {
|
|
33
|
+
start: Math.max(0, Math.min(rowCount, first) - overscan),
|
|
34
|
+
end: Math.min(rowCount, last + overscan),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Range of center (unpinned) columns intersecting the viewport. */
|
|
39
|
+
export function getColumnRange<T>(
|
|
40
|
+
layout: ColumnLayout<T>,
|
|
41
|
+
viewport: Viewport,
|
|
42
|
+
overscan = 2,
|
|
43
|
+
): Range {
|
|
44
|
+
const { center } = layout;
|
|
45
|
+
const count = center.length;
|
|
46
|
+
// Width is unknown before the first measurement (and during SSR): render everything.
|
|
47
|
+
if (count === 0 || viewport.width === 0) return { start: 0, end: count };
|
|
48
|
+
|
|
49
|
+
// Browsers report negative scrollLeft in RTL.
|
|
50
|
+
const windowStart = Math.abs(viewport.scrollLeft);
|
|
51
|
+
const windowEnd =
|
|
52
|
+
windowStart + Math.max(0, viewport.width - layout.leftWidth - layout.rightWidth);
|
|
53
|
+
|
|
54
|
+
// First column whose right edge is past windowStart.
|
|
55
|
+
let lo = 0;
|
|
56
|
+
let hi = count;
|
|
57
|
+
while (lo < hi) {
|
|
58
|
+
const mid = (lo + hi) >> 1;
|
|
59
|
+
if (center[mid].offset + center[mid].width <= windowStart) lo = mid + 1;
|
|
60
|
+
else hi = mid;
|
|
61
|
+
}
|
|
62
|
+
const start = lo;
|
|
63
|
+
|
|
64
|
+
// First column starting at or past windowEnd.
|
|
65
|
+
hi = count;
|
|
66
|
+
while (lo < hi) {
|
|
67
|
+
const mid = (lo + hi) >> 1;
|
|
68
|
+
if (center[mid].offset < windowEnd) lo = mid + 1;
|
|
69
|
+
else hi = mid;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
start: Math.max(0, start - overscan),
|
|
74
|
+
end: Math.min(count, Math.max(lo, start + 1) + overscan),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function visibleRangeEqual(a: VisibleRange, b: VisibleRange): boolean {
|
|
79
|
+
return (
|
|
80
|
+
a.rows.start === b.rows.start &&
|
|
81
|
+
a.rows.end === b.rows.end &&
|
|
82
|
+
a.columns.start === b.columns.start &&
|
|
83
|
+
a.columns.end === b.columns.end
|
|
84
|
+
);
|
|
85
|
+
}
|