@prairielearn/ui 1.5.0 → 1.7.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.
- package/CHANGELOG.md +16 -0
- package/README.md +61 -4
- package/dist/components/TanstackTable.d.ts +15 -13
- package/dist/components/TanstackTable.d.ts.map +1 -1
- package/dist/components/TanstackTable.js +22 -11
- package/dist/components/TanstackTable.js.map +1 -1
- package/dist/components/TanstackTableDownloadButton.d.ts +5 -3
- package/dist/components/TanstackTableDownloadButton.d.ts.map +1 -1
- package/dist/components/TanstackTableDownloadButton.js +6 -5
- package/dist/components/TanstackTableDownloadButton.js.map +1 -1
- package/dist/components/nuqs.d.ts +52 -0
- package/dist/components/nuqs.d.ts.map +1 -0
- package/dist/components/nuqs.js +212 -0
- package/dist/components/nuqs.js.map +1 -0
- package/dist/components/nuqs.test.d.ts +2 -0
- package/dist/components/nuqs.test.d.ts.map +1 -0
- package/dist/components/nuqs.test.js +231 -0
- package/dist/components/nuqs.test.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
- package/src/components/TanstackTable.tsx +35 -21
- package/src/components/TanstackTableDownloadButton.tsx +41 -30
- package/src/components/nuqs.test.ts +276 -0
- package/src/components/nuqs.tsx +230 -0
- package/src/index.ts +7 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type { ColumnPinningState, SortingState, VisibilityState } from '@tanstack/table-core';
|
|
2
|
+
import { createParser } from 'nuqs';
|
|
3
|
+
import {
|
|
4
|
+
type unstable_AdapterInterface,
|
|
5
|
+
unstable_createAdapterProvider,
|
|
6
|
+
} from 'nuqs/adapters/custom';
|
|
7
|
+
import { NuqsAdapter as NuqsReactAdapter } from 'nuqs/adapters/react';
|
|
8
|
+
import React from 'preact/compat';
|
|
9
|
+
|
|
10
|
+
import type { NumericColumnFilterValue } from './NumericInputColumnFilter.js';
|
|
11
|
+
|
|
12
|
+
const AdapterContext = React.createContext('');
|
|
13
|
+
|
|
14
|
+
function useExpressAdapterContext(): unstable_AdapterInterface {
|
|
15
|
+
const context = React.useContext(AdapterContext);
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
searchParams: new URLSearchParams(context),
|
|
19
|
+
// This will never be called on the server, so it can be a no-op.
|
|
20
|
+
updateUrl: () => {},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const NuqsExpressAdapter = unstable_createAdapterProvider(useExpressAdapterContext);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `nuqs` needs to be aware of the current state of the URL search parameters
|
|
28
|
+
* during both server-side and client-side rendering. To make this work with
|
|
29
|
+
* our server-side rendering setup, we use a custom adapter that should be
|
|
30
|
+
* provided with the value of `new URL(...).search` on the server side. On the
|
|
31
|
+
* client, we use `NuqsReactAdapter`, which will read directly from `location.search`.
|
|
32
|
+
*/
|
|
33
|
+
export function NuqsAdapter({ children, search }: { children: React.ReactNode; search: string }) {
|
|
34
|
+
if (typeof location === 'undefined') {
|
|
35
|
+
// We're rendering on the server.
|
|
36
|
+
return (
|
|
37
|
+
<AdapterContext.Provider value={search}>
|
|
38
|
+
<NuqsExpressAdapter>{children}</NuqsExpressAdapter>
|
|
39
|
+
</AdapterContext.Provider>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// We're rendering on the client.
|
|
44
|
+
return <NuqsReactAdapter>{children}</NuqsReactAdapter>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parses and serializes TanStack Table SortingState to/from a URL query string.
|
|
49
|
+
* Used for reflecting table sort order in the URL.
|
|
50
|
+
*
|
|
51
|
+
* Example: `sort=col:asc` <-> `[{ id: 'col', desc: false }]`
|
|
52
|
+
*/
|
|
53
|
+
export const parseAsSortingState = createParser<SortingState>({
|
|
54
|
+
parse(queryValue) {
|
|
55
|
+
if (!queryValue) return [];
|
|
56
|
+
return queryValue
|
|
57
|
+
.split(',')
|
|
58
|
+
.map((part) => {
|
|
59
|
+
const [id, dir] = part.split(':');
|
|
60
|
+
if (!id) return undefined;
|
|
61
|
+
if (dir === 'asc' || dir === 'desc') {
|
|
62
|
+
return { id, desc: dir === 'desc' };
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
})
|
|
66
|
+
.filter((v): v is { id: string; desc: boolean } => !!v);
|
|
67
|
+
},
|
|
68
|
+
serialize(value): string {
|
|
69
|
+
// `null` indicates that the value should be omitted from the URL.
|
|
70
|
+
// @ts-expect-error - `null` is not assignable to type `string`.
|
|
71
|
+
if (value.length === 0) return null;
|
|
72
|
+
return value
|
|
73
|
+
.filter((v) => v.id)
|
|
74
|
+
.map((v) => `${v.id}:${v.desc ? 'desc' : 'asc'}`)
|
|
75
|
+
.join(',');
|
|
76
|
+
},
|
|
77
|
+
eq(a, b) {
|
|
78
|
+
return (
|
|
79
|
+
a.length === b.length &&
|
|
80
|
+
a.every((item, index) => item.id === b[index].id && item.desc === b[index].desc)
|
|
81
|
+
);
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Returns a parser for TanStack Table VisibilityState for a given set of columns.
|
|
87
|
+
* Parses a comma-separated list of visible columns from a query string, e.g. 'a,b'.
|
|
88
|
+
* Serializes to a comma-separated list of visible columns, omitting if all are visible.
|
|
89
|
+
* Used for reflecting column visibility in the URL.
|
|
90
|
+
*
|
|
91
|
+
* @param allColumns - Array of all column IDs
|
|
92
|
+
* @param defaultValueRef - A ref object with a `current` property that contains the default visibility state.
|
|
93
|
+
*/
|
|
94
|
+
export function parseAsColumnVisibilityStateWithColumns(
|
|
95
|
+
allColumns: string[],
|
|
96
|
+
defaultValueRef?: React.RefObject<VisibilityState>,
|
|
97
|
+
) {
|
|
98
|
+
const parser = createParser<VisibilityState>({
|
|
99
|
+
parse(queryValue: string) {
|
|
100
|
+
const shown =
|
|
101
|
+
queryValue.length > 0
|
|
102
|
+
? new Set(queryValue.split(',').filter(Boolean))
|
|
103
|
+
: new Set(allColumns);
|
|
104
|
+
const result: VisibilityState = {};
|
|
105
|
+
for (const col of allColumns) {
|
|
106
|
+
result[col] = shown.has(col);
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
},
|
|
110
|
+
serialize(value): string {
|
|
111
|
+
// We can't use `eq` to compare with the current default values from the
|
|
112
|
+
// ref. `eq` appears to be used as part of an optimization to avoid rerenders
|
|
113
|
+
// if the column set hasn't changed, so if it return `true`, we wouldn't be
|
|
114
|
+
// able to update the actual visible columns after changing the defaults if
|
|
115
|
+
// the new column set is equal to the default set of columns.
|
|
116
|
+
//
|
|
117
|
+
// Instead, we rely on the (undocumented) ability of `serialize` to return
|
|
118
|
+
// `null` to indicate that the value should be omitted from the URL.
|
|
119
|
+
// @ts-expect-error - `null` is not assignable to type `string`.
|
|
120
|
+
if (parser.eq(value, defaultValueRef?.current ?? {})) return null;
|
|
121
|
+
|
|
122
|
+
// Only output columns that are visible
|
|
123
|
+
const visible = Object.keys(value).filter((col) => value[col]);
|
|
124
|
+
return visible.join(',');
|
|
125
|
+
},
|
|
126
|
+
eq(value, defaultValue) {
|
|
127
|
+
const valueKeys = Object.keys(value);
|
|
128
|
+
const defaultValueKeys = Object.keys(defaultValue);
|
|
129
|
+
const result =
|
|
130
|
+
valueKeys.length === defaultValueKeys.length &&
|
|
131
|
+
valueKeys.every((col) => value[col] === defaultValue[col]);
|
|
132
|
+
return result;
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return parser;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Parses and serializes TanStack Table ColumnPinningState to/from a URL query string.
|
|
141
|
+
* Used for reflecting pinned columns in the URL.
|
|
142
|
+
*
|
|
143
|
+
* Right pins aren't supported; an empty array is always returned to allow
|
|
144
|
+
* this hook's value to be used directly in `state.columnPinning` in `useReactTable`.
|
|
145
|
+
*
|
|
146
|
+
* Example: `a,b` <-> `{ left: ['a', 'b'], right: [] }`
|
|
147
|
+
*/
|
|
148
|
+
export const parseAsColumnPinningState = createParser<ColumnPinningState>({
|
|
149
|
+
parse(queryValue) {
|
|
150
|
+
if (!queryValue) return { left: [], right: [] };
|
|
151
|
+
// Format: col1,col2,col3 (all left-pinned columns)
|
|
152
|
+
return {
|
|
153
|
+
left: queryValue.split(',').filter(Boolean),
|
|
154
|
+
right: [],
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
serialize(value) {
|
|
158
|
+
if (!value.left) return '';
|
|
159
|
+
return value.left.join(',');
|
|
160
|
+
},
|
|
161
|
+
eq(a, b) {
|
|
162
|
+
const aLeft = Array.isArray(a.left) ? a.left : [];
|
|
163
|
+
const bLeft = Array.isArray(b.left) ? b.left : [];
|
|
164
|
+
if (aLeft.length !== bLeft.length) return false;
|
|
165
|
+
return aLeft.every((v, i) => v === bLeft[i]);
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Parses and serializes numeric filter strings to/from URL-friendly format.
|
|
171
|
+
* Used for numeric column filters with comparison operators.
|
|
172
|
+
*
|
|
173
|
+
* Internal format: `>=5`, `<=10`, `>3`, `<7`, `=5`
|
|
174
|
+
* URL format: `gte_5`, `lte_10`, `gt_3`, `lt_7`, `eq_5`, `empty`
|
|
175
|
+
*
|
|
176
|
+
* Example: `gte_5` <-> `>=5`
|
|
177
|
+
*/
|
|
178
|
+
export const parseAsNumericFilter = createParser<NumericColumnFilterValue>({
|
|
179
|
+
parse(queryValue) {
|
|
180
|
+
if (!queryValue) return { filterValue: '', emptyOnly: false };
|
|
181
|
+
// Parse format: {operator}_{value}
|
|
182
|
+
const match = queryValue.match(/^(gte|lte|gt|lt|eq)_(.+)$/);
|
|
183
|
+
if (!match) {
|
|
184
|
+
if (queryValue === 'empty') {
|
|
185
|
+
return { filterValue: '', emptyOnly: true };
|
|
186
|
+
}
|
|
187
|
+
return { filterValue: '', emptyOnly: false };
|
|
188
|
+
}
|
|
189
|
+
const [, opCode, value] = match;
|
|
190
|
+
const opMap: Record<string, string> = {
|
|
191
|
+
gte: '>=',
|
|
192
|
+
lte: '<=',
|
|
193
|
+
gt: '>',
|
|
194
|
+
lt: '<',
|
|
195
|
+
eq: '=',
|
|
196
|
+
};
|
|
197
|
+
const operator = opMap[opCode];
|
|
198
|
+
if (!operator) return { filterValue: '', emptyOnly: false };
|
|
199
|
+
return { filterValue: `${operator}${value}`, emptyOnly: false };
|
|
200
|
+
},
|
|
201
|
+
serialize(value): string {
|
|
202
|
+
const { filterValue, emptyOnly } = value;
|
|
203
|
+
|
|
204
|
+
if (emptyOnly) return 'empty';
|
|
205
|
+
|
|
206
|
+
if (filterValue.length === 0) {
|
|
207
|
+
return 'empty';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Serialize format: internal (>=5) -> URL (gte_5)
|
|
211
|
+
const match = filterValue.match(/^(>=|<=|>|<|=)(.+)$/);
|
|
212
|
+
// @ts-expect-error - `null` is not assignable to type `string`.
|
|
213
|
+
if (!match) return null;
|
|
214
|
+
const [, operator, val] = match;
|
|
215
|
+
const opMap: Record<string, string> = {
|
|
216
|
+
'>=': 'gte',
|
|
217
|
+
'<=': 'lte',
|
|
218
|
+
'>': 'gt',
|
|
219
|
+
'<': 'lt',
|
|
220
|
+
'=': 'eq',
|
|
221
|
+
};
|
|
222
|
+
const opCode = opMap[operator];
|
|
223
|
+
// @ts-expect-error - `null` is not assignable to type `string`.
|
|
224
|
+
if (!opCode) return null;
|
|
225
|
+
return `${opCode}_${val}`;
|
|
226
|
+
},
|
|
227
|
+
eq(a, b) {
|
|
228
|
+
return a.filterValue === b.filterValue && a.emptyOnly === b.emptyOnly;
|
|
229
|
+
},
|
|
230
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -20,3 +20,10 @@ export { useShiftClickCheckbox } from './components/useShiftClickCheckbox.js';
|
|
|
20
20
|
export { useAutoSizeColumns } from './components/useAutoSizeColumns.js';
|
|
21
21
|
export { OverlayTrigger, type OverlayTriggerProps } from './components/OverlayTrigger.js';
|
|
22
22
|
export { PresetFilterDropdown } from './components/PresetFilterDropdown.js';
|
|
23
|
+
export {
|
|
24
|
+
NuqsAdapter,
|
|
25
|
+
parseAsSortingState,
|
|
26
|
+
parseAsColumnVisibilityStateWithColumns,
|
|
27
|
+
parseAsColumnPinningState,
|
|
28
|
+
parseAsNumericFilter,
|
|
29
|
+
} from './components/nuqs.js';
|