gbs-add-block 1.2.8 → 1.2.10
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 +12 -3
- package/index.cjs +123 -79
- package/package.json +1 -1
- package/source/beta-components/combobox/README.md +170 -0
- package/source/beta-components/combobox/__tests__/core.test.ts +134 -0
- package/source/beta-components/combobox/core/filter.ts +155 -0
- package/source/beta-components/combobox/core/index.ts +16 -0
- package/source/beta-components/combobox/core/types.ts +48 -0
- package/source/beta-components/combobox/core/virtual.ts +67 -0
- package/source/beta-components/combobox/index.ts +13 -0
- package/source/beta-components/combobox/react/Listbox.tsx +302 -0
- package/source/beta-components/combobox/react/MultiSelect.tsx +263 -0
- package/source/beta-components/combobox/react/Popover.tsx +70 -0
- package/source/beta-components/combobox/react/Select.tsx +198 -0
- package/source/beta-components/combobox/react/icons.tsx +34 -0
- package/source/beta-components/combobox/react/locale.ts +16 -0
- package/source/beta-components/combobox/react/props.ts +86 -0
- package/source/beta-components/combobox/react/useCombobox.ts +399 -0
- package/source/beta-components/combobox/styles.css +417 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/README.md +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/__tests__/core.test.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/__tests__/export.test.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/columnHelper.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/columns.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/filtering.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/grid.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/index.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/rows.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/sorting.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/state.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/store.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/types.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/values.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/core/virtual.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/export/csv.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/export/download.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/export/pdf.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/export/table.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/export/xlsx.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/export/zip.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/index.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/Cell.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/ColumnMenu.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/DataGrid.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/FilterForm.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/HeaderRow.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/Pagination.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/Popover.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/Row.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/Toolbar.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/Viewport.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/context.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/hooks.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/icons.tsx +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/keyboard.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/react/locale.ts +0 -0
- /package/source/{components/datagridbeta → beta-components/datagrid}/styles.css +0 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# GBS Building Blocks 2.0 (v1.2.
|
|
1
|
+
# GBS Building Blocks 2.0 (v1.2.10)
|
|
2
2
|
|
|
3
3
|
Latest and upgraded version of GBS building blocks with headless UI and removed dependencies.
|
|
4
4
|
|
|
@@ -6,9 +6,18 @@ Latest and upgraded version of GBS building blocks with headless UI and removed
|
|
|
6
6
|
|
|
7
7
|
For detailed documentation on usage and props, Please visit: [Building Block Documentation v2.0](https://gramprokit.vercel.app)
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## Beta Components
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
DataGrid and Combobox use the redesigned core API and design system. Install them with the beta flag:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx gbs-add-block -a DataGrid -beta
|
|
15
|
+
npx gbs-add-block -a Combobox -beta
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## What's New 🎉 (Ver 1.2.10)
|
|
19
|
+
|
|
20
|
+
- Update Candidate for next major change 2.0.0
|
|
12
21
|
|
|
13
22
|
## Authors
|
|
14
23
|
|
package/index.cjs
CHANGED
|
@@ -32,10 +32,13 @@ const CONFIG = {
|
|
|
32
32
|
"DataGrid",
|
|
33
33
|
"BreadCrumb",
|
|
34
34
|
"Bargraph",
|
|
35
|
-
"UsePaginatedData",
|
|
36
|
-
"UseUploader",
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
"UsePaginatedData",
|
|
36
|
+
"UseUploader",
|
|
37
|
+
],
|
|
38
|
+
betaComponents: [
|
|
39
|
+
"DataGrid",
|
|
40
|
+
"Combobox",
|
|
41
|
+
],
|
|
39
42
|
// Define component dependencies
|
|
40
43
|
dependencies: {
|
|
41
44
|
FormRenderer: ["Select", "MultiSelect", "Input", "DatePicker"],
|
|
@@ -43,8 +46,19 @@ const CONFIG = {
|
|
|
43
46
|
docs: "https://gramprokit.vercel.app/",
|
|
44
47
|
};
|
|
45
48
|
|
|
46
|
-
const SOURCE_PATH = path.join(__dirname, "source", "components");
|
|
47
|
-
const
|
|
49
|
+
const SOURCE_PATH = path.join(__dirname, "source", "components");
|
|
50
|
+
const BETA_SOURCE_PATH = path.join(__dirname, "source", "beta-components");
|
|
51
|
+
const DEFAULT_DEST_PATH = path.join(process.cwd(), "component-lib");
|
|
52
|
+
|
|
53
|
+
const normalizeComponent = (component, availableComponents) =>
|
|
54
|
+
availableComponents.find(
|
|
55
|
+
(available) => available.toLowerCase() === component.toLowerCase(),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const getAvailableComponents = (beta = false) =>
|
|
59
|
+
beta ? CONFIG.betaComponents : CONFIG.components;
|
|
60
|
+
|
|
61
|
+
const getSourcePath = (beta = false) => (beta ? BETA_SOURCE_PATH : SOURCE_PATH);
|
|
48
62
|
|
|
49
63
|
const copyCommonFiles = async (destPath) => {
|
|
50
64
|
const commonFiles = [
|
|
@@ -67,14 +81,16 @@ const checkComponentExists = (component, destPath) => {
|
|
|
67
81
|
return fs.existsSync(componentPath);
|
|
68
82
|
};
|
|
69
83
|
|
|
70
|
-
const copyComponent = async (component, destPath) => {
|
|
71
|
-
try {
|
|
72
|
-
const componentSrc = path.join(
|
|
84
|
+
const copyComponent = async (component, destPath, beta = false) => {
|
|
85
|
+
try {
|
|
86
|
+
const componentSrc = path.join(getSourcePath(beta), component.toLowerCase());
|
|
73
87
|
const componentDest = path.join(destPath, component.toLowerCase());
|
|
74
88
|
|
|
75
|
-
if (!fs.existsSync(componentSrc)) {
|
|
76
|
-
throw new Error(
|
|
77
|
-
|
|
89
|
+
if (!fs.existsSync(componentSrc)) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Component ${component} not found in ${beta ? "beta " : ""}source directory.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
78
94
|
|
|
79
95
|
await fs.copy(componentSrc, componentDest, { overwrite: true });
|
|
80
96
|
console.log(
|
|
@@ -90,15 +106,17 @@ const copyComponent = async (component, destPath) => {
|
|
|
90
106
|
}
|
|
91
107
|
};
|
|
92
108
|
|
|
93
|
-
const installComponentWithDependencies = async (component, destPath) => {
|
|
109
|
+
const installComponentWithDependencies = async (component, destPath, beta = false) => {
|
|
94
110
|
// Get dependencies for the component
|
|
95
111
|
const dependencies = CONFIG.dependencies[component] || [];
|
|
96
112
|
const componentsToInstall = new Set([component, ...dependencies]);
|
|
97
113
|
|
|
98
114
|
// Check which components need to be installed
|
|
99
|
-
const pendingInstalls =
|
|
100
|
-
|
|
101
|
-
|
|
115
|
+
const pendingInstalls = beta
|
|
116
|
+
? Array.from(componentsToInstall)
|
|
117
|
+
: Array.from(componentsToInstall).filter(
|
|
118
|
+
(comp) => !checkComponentExists(comp, destPath),
|
|
119
|
+
);
|
|
102
120
|
|
|
103
121
|
if (pendingInstalls.length === 0) {
|
|
104
122
|
console.log(
|
|
@@ -109,7 +127,7 @@ const installComponentWithDependencies = async (component, destPath) => {
|
|
|
109
127
|
|
|
110
128
|
// Install all pending components
|
|
111
129
|
for (const comp of pendingInstalls) {
|
|
112
|
-
await copyComponent(comp, destPath);
|
|
130
|
+
await copyComponent(comp, destPath, beta);
|
|
113
131
|
}
|
|
114
132
|
|
|
115
133
|
if (dependencies.length > 0) {
|
|
@@ -122,7 +140,7 @@ const installComponentWithDependencies = async (component, destPath) => {
|
|
|
122
140
|
console.log(`\nFor documentation visit: ${CONFIG.docs}`);
|
|
123
141
|
};
|
|
124
142
|
|
|
125
|
-
const installMultipleComponents = async (components, destPath) => {
|
|
143
|
+
const installMultipleComponents = async (components, destPath, beta = false) => {
|
|
126
144
|
const allComponentsToInstall = new Set();
|
|
127
145
|
|
|
128
146
|
// Collect all components and their dependencies
|
|
@@ -133,9 +151,11 @@ const installMultipleComponents = async (components, destPath) => {
|
|
|
133
151
|
});
|
|
134
152
|
|
|
135
153
|
// Filter out already installed components
|
|
136
|
-
const pendingInstalls =
|
|
137
|
-
|
|
138
|
-
|
|
154
|
+
const pendingInstalls = beta
|
|
155
|
+
? Array.from(allComponentsToInstall)
|
|
156
|
+
: Array.from(allComponentsToInstall).filter(
|
|
157
|
+
(comp) => !checkComponentExists(comp, destPath),
|
|
158
|
+
);
|
|
139
159
|
|
|
140
160
|
if (pendingInstalls.length === 0) {
|
|
141
161
|
console.log(
|
|
@@ -148,7 +168,7 @@ const installMultipleComponents = async (components, destPath) => {
|
|
|
148
168
|
|
|
149
169
|
// Install all pending components
|
|
150
170
|
for (const comp of pendingInstalls) {
|
|
151
|
-
await copyComponent(comp, destPath);
|
|
171
|
+
await copyComponent(comp, destPath, beta);
|
|
152
172
|
}
|
|
153
173
|
|
|
154
174
|
// Show dependency information
|
|
@@ -168,7 +188,7 @@ const installMultipleComponents = async (components, destPath) => {
|
|
|
168
188
|
console.log(`\nFor documentation visit: ${CONFIG.docs}`);
|
|
169
189
|
};
|
|
170
190
|
|
|
171
|
-
const interactiveComponentSelector = async () => {
|
|
191
|
+
const interactiveComponentSelector = async (beta = false) => {
|
|
172
192
|
return new Promise((resolve) => {
|
|
173
193
|
const rl = readline.createInterface({
|
|
174
194
|
input: process.stdin,
|
|
@@ -185,7 +205,9 @@ const interactiveComponentSelector = async () => {
|
|
|
185
205
|
"Use ↑/↓ arrow keys to navigate, SPACE to select/deselect, ENTER to install\n",
|
|
186
206
|
);
|
|
187
207
|
|
|
188
|
-
|
|
208
|
+
const availableComponents = getAvailableComponents(beta);
|
|
209
|
+
|
|
210
|
+
availableComponents.forEach((component, index) => {
|
|
189
211
|
const isSelected = selectedComponents.has(component);
|
|
190
212
|
const isCurrentIndex = index === currentIndex;
|
|
191
213
|
const deps = CONFIG.dependencies[component]
|
|
@@ -215,13 +237,13 @@ const interactiveComponentSelector = async () => {
|
|
|
215
237
|
break;
|
|
216
238
|
case "\u001b[B": // Down arrow
|
|
217
239
|
currentIndex = Math.min(
|
|
218
|
-
|
|
240
|
+
getAvailableComponents(beta).length - 1,
|
|
219
241
|
currentIndex + 1,
|
|
220
242
|
);
|
|
221
243
|
renderMenu();
|
|
222
244
|
break;
|
|
223
245
|
case " ": // Space bar
|
|
224
|
-
const component =
|
|
246
|
+
const component = getAvailableComponents(beta)[currentIndex];
|
|
225
247
|
if (selectedComponents.has(component)) {
|
|
226
248
|
selectedComponents.delete(component);
|
|
227
249
|
} else {
|
|
@@ -264,21 +286,29 @@ const parseMultipleComponents = (componentString) => {
|
|
|
264
286
|
.filter((comp) => comp.length > 0);
|
|
265
287
|
};
|
|
266
288
|
|
|
267
|
-
const validateComponents = (components) => {
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
console.
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
};
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
289
|
+
const validateComponents = (components, beta = false) => {
|
|
290
|
+
const availableComponents = getAvailableComponents(beta);
|
|
291
|
+
const invalidComponents = components.filter(
|
|
292
|
+
(comp) => !availableComponents.includes(comp),
|
|
293
|
+
);
|
|
294
|
+
if (invalidComponents.length > 0) {
|
|
295
|
+
console.error(`Invalid components: ${invalidComponents.join(", ")}`);
|
|
296
|
+
console.log(`\nAvailable ${beta ? "beta " : ""}components:`);
|
|
297
|
+
availableComponents.forEach((comp) => console.log(`- ${comp}`));
|
|
298
|
+
if (!beta) {
|
|
299
|
+
console.log("\nRedesigned beta components (install with -beta):");
|
|
300
|
+
CONFIG.betaComponents.forEach((comp) => console.log(`- ${comp}`));
|
|
301
|
+
}
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
return true;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const main = async () => {
|
|
308
|
+
const args = hideBin(process.argv).map((arg) =>
|
|
309
|
+
arg === "-beta" ? "--beta" : arg,
|
|
310
|
+
);
|
|
311
|
+
const argv = yargs(args)
|
|
282
312
|
.option("add", {
|
|
283
313
|
alias: "a",
|
|
284
314
|
describe:
|
|
@@ -290,32 +320,44 @@ const main = async () => {
|
|
|
290
320
|
describe: "Interactive component selection mode",
|
|
291
321
|
type: "boolean",
|
|
292
322
|
})
|
|
293
|
-
.option("list", {
|
|
294
|
-
alias: "l",
|
|
295
|
-
describe: "List available components",
|
|
296
|
-
type: "boolean",
|
|
297
|
-
})
|
|
298
|
-
.
|
|
299
|
-
|
|
300
|
-
|
|
323
|
+
.option("list", {
|
|
324
|
+
alias: "l",
|
|
325
|
+
describe: "List available components",
|
|
326
|
+
type: "boolean",
|
|
327
|
+
})
|
|
328
|
+
.option("beta", {
|
|
329
|
+
describe: "Install redesigned beta components",
|
|
330
|
+
type: "boolean",
|
|
331
|
+
default: false,
|
|
332
|
+
})
|
|
333
|
+
.example("$0 -a Button", "Install a single component")
|
|
334
|
+
.example("$0 -a Button,Card,Modal", "Install multiple components")
|
|
335
|
+
.example("$0 -a DataGrid -beta", "Install the redesigned beta DataGrid")
|
|
336
|
+
.example("$0 -a Combobox -beta", "Install the redesigned beta Combobox")
|
|
337
|
+
.example("$0 -i", "Interactive selection mode")
|
|
301
338
|
.help().argv;
|
|
302
339
|
|
|
303
|
-
// List components if requested
|
|
304
|
-
if (argv.list) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
: ""
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
340
|
+
// List components if requested
|
|
341
|
+
if (argv.list) {
|
|
342
|
+
const availableComponents = getAvailableComponents(argv.beta);
|
|
343
|
+
console.log(`\nAvailable ${argv.beta ? "beta " : ""}components:`);
|
|
344
|
+
availableComponents.forEach((comp) => {
|
|
345
|
+
const deps = CONFIG.dependencies[comp]
|
|
346
|
+
? ` (requires: ${CONFIG.dependencies[comp].join(", ")})`
|
|
347
|
+
: "";
|
|
348
|
+
console.log(`- ${comp}${deps}`);
|
|
349
|
+
});
|
|
350
|
+
if (!argv.beta) {
|
|
351
|
+
console.log("\nRedesigned beta components (install with -beta):");
|
|
352
|
+
CONFIG.betaComponents.forEach((comp) => console.log(`- ${comp}`));
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
314
356
|
|
|
315
357
|
// Interactive mode
|
|
316
|
-
if (argv.interactive) {
|
|
317
|
-
console.log("Starting interactive component selector...\n");
|
|
318
|
-
const selectedComponents = await interactiveComponentSelector();
|
|
358
|
+
if (argv.interactive) {
|
|
359
|
+
console.log("Starting interactive component selector...\n");
|
|
360
|
+
const selectedComponents = await interactiveComponentSelector(argv.beta);
|
|
319
361
|
|
|
320
362
|
if (selectedComponents.length === 0) {
|
|
321
363
|
console.log("No components selected. Exiting...");
|
|
@@ -331,8 +373,8 @@ const main = async () => {
|
|
|
331
373
|
await copyCommonFiles(destPath);
|
|
332
374
|
}
|
|
333
375
|
|
|
334
|
-
// Install selected components
|
|
335
|
-
await installMultipleComponents(selectedComponents, destPath);
|
|
376
|
+
// Install selected components
|
|
377
|
+
await installMultipleComponents(selectedComponents, destPath, argv.beta);
|
|
336
378
|
return;
|
|
337
379
|
}
|
|
338
380
|
|
|
@@ -343,12 +385,14 @@ const main = async () => {
|
|
|
343
385
|
process.exit(1);
|
|
344
386
|
}
|
|
345
387
|
|
|
346
|
-
// Parse components (single or multiple)
|
|
347
|
-
const componentInput = argv.add;
|
|
348
|
-
const components = parseMultipleComponents(componentInput)
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
388
|
+
// Parse components (single or multiple)
|
|
389
|
+
const componentInput = argv.add;
|
|
390
|
+
const components = parseMultipleComponents(componentInput).map((component) =>
|
|
391
|
+
normalizeComponent(component, getAvailableComponents(argv.beta)) || component,
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
// Validate all components
|
|
395
|
+
if (!validateComponents(components, argv.beta)) {
|
|
352
396
|
process.exit(1);
|
|
353
397
|
}
|
|
354
398
|
|
|
@@ -362,14 +406,14 @@ const main = async () => {
|
|
|
362
406
|
}
|
|
363
407
|
|
|
364
408
|
// Install components
|
|
365
|
-
if (components.length === 1) {
|
|
366
|
-
// Single component installation (existing behavior)
|
|
367
|
-
await installComponentWithDependencies(components[0], destPath);
|
|
368
|
-
} else {
|
|
369
|
-
// Multiple components installation
|
|
370
|
-
await installMultipleComponents(components, destPath);
|
|
371
|
-
}
|
|
372
|
-
};
|
|
409
|
+
if (components.length === 1) {
|
|
410
|
+
// Single component installation (existing behavior)
|
|
411
|
+
await installComponentWithDependencies(components[0], destPath, argv.beta);
|
|
412
|
+
} else {
|
|
413
|
+
// Multiple components installation
|
|
414
|
+
await installMultipleComponents(components, destPath, argv.beta);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
373
417
|
|
|
374
418
|
main().catch((error) => {
|
|
375
419
|
console.error("Error:", error.message);
|
package/package.json
CHANGED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Combobox: Select and MultiSelect
|
|
2
|
+
|
|
3
|
+
Two value pickers with option search, built on one engine and styled to match
|
|
4
|
+
the DataGrid. No runtime dependencies besides React 19.
|
|
5
|
+
|
|
6
|
+
- **`<Select>`** — one value.
|
|
7
|
+
- **`<MultiSelect>`** — many values, shown as tags.
|
|
8
|
+
|
|
9
|
+
Both support client or server options, grouping, descriptions, disabled
|
|
10
|
+
options, virtualization for long lists, creating new options, forms, full
|
|
11
|
+
keyboard control, dark mode and right-to-left layouts.
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { MultiSelect, Select } from "@/components/combobox";
|
|
17
|
+
import "@/components/combobox/styles.css";
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The stylesheet reuses the DataGrid's `--dg-*` variables when that stylesheet is
|
|
21
|
+
loaded, so both components share a theme, and falls back to the same palette
|
|
22
|
+
when used on its own.
|
|
23
|
+
|
|
24
|
+
## Basic usage
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
const countries = [
|
|
28
|
+
{ value: "in", label: "India", group: "Asia" },
|
|
29
|
+
{ value: "de", label: "Germany", group: "Europe", description: "Berlin" },
|
|
30
|
+
{ value: "mx", label: "Mexico", group: "Americas", disabled: true },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const [country, setCountry] = useState<string | null>(null);
|
|
34
|
+
|
|
35
|
+
<Select label="Country" options={countries} value={country} onChange={setCountry} />;
|
|
36
|
+
|
|
37
|
+
const [tags, setTags] = useState<string[]>([]);
|
|
38
|
+
<MultiSelect label="Tags" options={tagOptions} value={tags} onChange={setTags} max={5} />;
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Both are controlled with `value` + `onChange`, or uncontrolled with
|
|
42
|
+
`defaultValue`. `onChange` also receives the full option objects:
|
|
43
|
+
`onChange={(value, option) => …}` (single) and `(values, options) => …` (multi).
|
|
44
|
+
|
|
45
|
+
## Options
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
interface ComboboxOption<V = string> {
|
|
49
|
+
value: V; // string or number
|
|
50
|
+
label: string;
|
|
51
|
+
description?: string; // second line
|
|
52
|
+
group?: string; // heading; groups appear in first-seen order
|
|
53
|
+
disabled?: boolean;
|
|
54
|
+
icon?: ReactNode;
|
|
55
|
+
keywords?: string[]; // extra search terms
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Search matches the label (and highlights it), the description and keywords.
|
|
60
|
+
|
|
61
|
+
## Props
|
|
62
|
+
|
|
63
|
+
Shared by both components:
|
|
64
|
+
|
|
65
|
+
| Prop | Type | Default | Description |
|
|
66
|
+
| --- | --- | --- | --- |
|
|
67
|
+
| `options` | `ComboboxOption<V>[]` | required | Options to show. |
|
|
68
|
+
| `mode` | `"client"` \| `"server"` | `"client"` | `server` skips local filtering; `options` are the current results. |
|
|
69
|
+
| `loading` | `boolean` | `false` | Shows a spinner; keeps current options visible. |
|
|
70
|
+
| `onSearchChange` | `(search: string) => void` | — | Search text; debounced in server mode, and fired on open. |
|
|
71
|
+
| `searchDebounce` | `number` | `250` | Debounce in milliseconds (server mode). |
|
|
72
|
+
| `searchable` | `boolean` | `true` | Show the search box. When `false`, typing jumps to a matching option. |
|
|
73
|
+
| `hasMore` / `onLoadMore` | `boolean` / `() => void` | — | Paging: called when the list nears its end, plus a "Load more" button. |
|
|
74
|
+
| `filterFn` | `(option, search) => boolean` | — | Replaces the built-in matching (client mode). |
|
|
75
|
+
| `renderOption` | `(option, { selected, active }) => ReactNode` | — | Custom option content. |
|
|
76
|
+
| `allowCreate` / `onCreate` | `boolean` / `(label: string) => void` | — | Offers "Create …" when nothing matches. |
|
|
77
|
+
| `label`, `description`, `error` | `ReactNode` | — | Field label, hint and error message. `error` also marks the control invalid. |
|
|
78
|
+
| `placeholder` | `string` | `"Select…"` | Shown when nothing is selected. |
|
|
79
|
+
| `required`, `disabled` | `boolean` | `false` | Field states. |
|
|
80
|
+
| `clearable` | `boolean` | `true` | Show the clear button. |
|
|
81
|
+
| `size` | `"sm"` \| `"md"` \| `"lg"` | `"md"` | Control height and font size. |
|
|
82
|
+
| `name` | `string` | — | Posts hidden inputs for forms (one per value). |
|
|
83
|
+
| `maxHeight` | `number` | `280` | Max height of the list. |
|
|
84
|
+
| `virtualize` | `boolean` \| `number` | `true` | Virtualizes above 80 options; pass a number to change the threshold. |
|
|
85
|
+
| `emptyMessage` | `ReactNode` | "No options found" | Shown when nothing matches. |
|
|
86
|
+
| `className`, `classNames`, `style` | — | — | Styling hooks. Slots: `root`, `label`, `control`, `value`, `tag`, `popover`, `search`, `list`, `option`, `footer`. |
|
|
87
|
+
| `localeText` | `Partial<ComboboxLocaleText>` | English | Overrides UI text. |
|
|
88
|
+
| `onOpenChange` | `(open: boolean) => void` | — | Fires when the list opens or closes. |
|
|
89
|
+
| `ref` | `Ref<ComboboxHandle<V>>` | — | `open()`, `close()`, `toggle()`, `focus()`, `clear()`, `getValue()`, `getSelectedOptions()`. |
|
|
90
|
+
|
|
91
|
+
`Select` adds `value` / `defaultValue` (`V | null`) and `closeOnSelect`
|
|
92
|
+
(default `true`). `MultiSelect` adds `value` / `defaultValue` (`V[]`), `max`,
|
|
93
|
+
`maxVisibleTags` (default 3), `showSelectAll` (default `true`) and
|
|
94
|
+
`closeOnSelect` (default `false`).
|
|
95
|
+
|
|
96
|
+
## Server options
|
|
97
|
+
|
|
98
|
+
```tsx
|
|
99
|
+
const [search, setSearch] = useState("");
|
|
100
|
+
const [page, setPage] = useState(0);
|
|
101
|
+
const { data, isFetching } = useQuery({
|
|
102
|
+
queryKey: ["people", search, page],
|
|
103
|
+
queryFn: ({ signal }) => fetchPeople({ search, page, signal }),
|
|
104
|
+
placeholderData: keepPreviousData,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
<MultiSelect
|
|
108
|
+
mode="server"
|
|
109
|
+
options={data?.options ?? []}
|
|
110
|
+
hasMore={data?.hasMore}
|
|
111
|
+
loading={isFetching}
|
|
112
|
+
onSearchChange={(term) => { setPage(0); setSearch(term); }}
|
|
113
|
+
onLoadMore={() => setPage((p) => p + 1)}
|
|
114
|
+
value={selected}
|
|
115
|
+
onChange={setSelected}
|
|
116
|
+
/>;
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- `onSearchChange` fires once when the list opens (so you can load a first page)
|
|
120
|
+
and then debounced as the user types.
|
|
121
|
+
- Labels of chosen options are remembered, so tags stay readable after the
|
|
122
|
+
results change. If you set `value` from outside before the matching options
|
|
123
|
+
have loaded, the raw value is shown until they arrive.
|
|
124
|
+
|
|
125
|
+
## Keyboard
|
|
126
|
+
|
|
127
|
+
| Keys | Action |
|
|
128
|
+
| --- | --- |
|
|
129
|
+
| **Enter**, **Space**, **↓**, **↑** | Open the list. |
|
|
130
|
+
| **↓** / **↑** | Move between options (disabled options are skipped). |
|
|
131
|
+
| **Home** / **End** | First / last option. |
|
|
132
|
+
| **Page Down** / **Page Up** | Move ten options. |
|
|
133
|
+
| **Enter** | Choose the highlighted option, or create. |
|
|
134
|
+
| **Escape** | Close and return focus to the control. |
|
|
135
|
+
| **Tab** | Close and move to the next field. |
|
|
136
|
+
| **Backspace** | MultiSelect: remove the last tag (when the search box is empty). |
|
|
137
|
+
| typing | Types into the search box, or jumps to a matching option when `searchable={false}`. |
|
|
138
|
+
|
|
139
|
+
The control is a `role="combobox"` that owns a `role="listbox"`; the active
|
|
140
|
+
option is tracked with `aria-activedescendant`, so focus stays in the search
|
|
141
|
+
box. The list renders in the top layer through the native Popover API, so it is
|
|
142
|
+
never clipped by a scrolling parent (including inside a DataGrid cell).
|
|
143
|
+
|
|
144
|
+
## Theming
|
|
145
|
+
|
|
146
|
+
Override `--cb-*` variables (they default to the grid's `--dg-*`):
|
|
147
|
+
|
|
148
|
+
```css
|
|
149
|
+
.cb-root { --cb-accent: #7c3aed; --cb-radius: 12px; }
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
State attributes for styling: `data-state="open"`, `data-size`, `data-invalid`,
|
|
153
|
+
`data-disabled` on the root and control; `data-active`, `data-selected`,
|
|
154
|
+
`data-disabled` on options.
|
|
155
|
+
|
|
156
|
+
## Headless use
|
|
157
|
+
|
|
158
|
+
`useCombobox()` holds the whole engine (filtering, active option, keyboard,
|
|
159
|
+
selection, search requests) and is exported if you want to build a different UI
|
|
160
|
+
on top. The framework-free helpers — `filterOptions`, `buildListItems`,
|
|
161
|
+
`nextEnabledIndex`, `toggleValue`, `measureItems`, `getVisibleRange` — are
|
|
162
|
+
exported from `@/components/combobox/core` and can also run on a server.
|
|
163
|
+
|
|
164
|
+
## Known limits
|
|
165
|
+
|
|
166
|
+
- Option rows have a fixed height per `size` (taller when any option has a
|
|
167
|
+
description), because the list is virtualized.
|
|
168
|
+
- No async "load option by value": pass options that include the selected
|
|
169
|
+
values, or select them through the UI at least once.
|
|
170
|
+
- Tree or multi-level options are not supported.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildListItems,
|
|
4
|
+
filterOptions,
|
|
5
|
+
findByPrefix,
|
|
6
|
+
firstEnabledIndex,
|
|
7
|
+
lastEnabledIndex,
|
|
8
|
+
matchRanges,
|
|
9
|
+
nextEnabledIndex,
|
|
10
|
+
toggleValue,
|
|
11
|
+
} from "../core/filter";
|
|
12
|
+
import type { ComboboxOption } from "../core/types";
|
|
13
|
+
import { getVisibleRange, measureItems, scrollToItem } from "../core/virtual";
|
|
14
|
+
|
|
15
|
+
const options: ComboboxOption[] = [
|
|
16
|
+
{ value: "in", label: "India", group: "Asia", keywords: ["bharat"] },
|
|
17
|
+
{ value: "jp", label: "Japan", group: "Asia" },
|
|
18
|
+
{ value: "kr", label: "South Korea", group: "Asia", disabled: true },
|
|
19
|
+
{ value: "de", label: "Germany", group: "Europe", description: "Berlin" },
|
|
20
|
+
{ value: "fr", label: "France", group: "Europe" },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const labels = (list: { option: ComboboxOption }[]) => list.map((entry) => entry.option.label);
|
|
24
|
+
|
|
25
|
+
describe("matching", () => {
|
|
26
|
+
it("returns merged, case-insensitive ranges for every term", () => {
|
|
27
|
+
expect(matchRanges("South Korea", ["korea"])).toEqual([[6, 11]]);
|
|
28
|
+
expect(matchRanges("Germany", ["ger", "man"])).toEqual([[0, 6]]);
|
|
29
|
+
expect(matchRanges("Germany", ["ger", "xyz"])).toBeNull();
|
|
30
|
+
expect(matchRanges("Germany", [])).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("filters on label, description and keywords", () => {
|
|
34
|
+
expect(labels(filterOptions(options, ""))).toHaveLength(5);
|
|
35
|
+
expect(labels(filterOptions(options, "an"))).toEqual(["Japan", "Germany", "France"]);
|
|
36
|
+
expect(labels(filterOptions(options, "berlin"))).toEqual(["Germany"]);
|
|
37
|
+
expect(labels(filterOptions(options, "bharat"))).toEqual(["India"]);
|
|
38
|
+
expect(labels(filterOptions(options, "nothing"))).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("highlights the label only", () => {
|
|
42
|
+
const [india] = filterOptions(options, "ind");
|
|
43
|
+
expect(india.matches).toEqual([[0, 3]]);
|
|
44
|
+
const [germany] = filterOptions(options, "berlin");
|
|
45
|
+
expect(germany.matches).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("uses a custom filter when given", () => {
|
|
49
|
+
const entries = filterOptions(options, "x", (option) => option.value === "jp");
|
|
50
|
+
expect(labels(entries)).toEqual(["Japan"]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("list building", () => {
|
|
55
|
+
it("adds group headings and numbers options across groups", () => {
|
|
56
|
+
const items = buildListItems(filterOptions(options, ""));
|
|
57
|
+
expect(items.map((item) => (item.kind === "group" ? `# ${item.label}` : item.entry.option.label))).toEqual([
|
|
58
|
+
"# Asia",
|
|
59
|
+
"India",
|
|
60
|
+
"Japan",
|
|
61
|
+
"South Korea",
|
|
62
|
+
"# Europe",
|
|
63
|
+
"Germany",
|
|
64
|
+
"France",
|
|
65
|
+
]);
|
|
66
|
+
const optionItems = items.filter((item) => item.kind === "option");
|
|
67
|
+
expect(optionItems.map((item) => (item.kind === "option" ? item.index : -1))).toEqual([0, 1, 2, 3, 4]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("skips headings when no option has a group", () => {
|
|
71
|
+
const flat = buildListItems(filterOptions([{ value: "a", label: "A" }], ""));
|
|
72
|
+
expect(flat).toHaveLength(1);
|
|
73
|
+
expect(flat[0].kind).toBe("option");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("keyboard helpers", () => {
|
|
78
|
+
const entries = filterOptions(options, "");
|
|
79
|
+
|
|
80
|
+
it("moves over enabled options and wraps", () => {
|
|
81
|
+
expect(nextEnabledIndex(entries, 0, 1)).toBe(1);
|
|
82
|
+
expect(nextEnabledIndex(entries, 1, 1)).toBe(3); // skips the disabled option
|
|
83
|
+
expect(nextEnabledIndex(entries, 4, 1)).toBe(0);
|
|
84
|
+
expect(nextEnabledIndex(entries, 0, -1)).toBe(4);
|
|
85
|
+
expect(nextEnabledIndex([], 0, 1)).toBe(-1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("finds the first and last enabled option", () => {
|
|
89
|
+
expect(firstEnabledIndex(entries)).toBe(0);
|
|
90
|
+
expect(firstEnabledIndex(entries, 2)).toBe(3);
|
|
91
|
+
expect(lastEnabledIndex(entries)).toBe(4);
|
|
92
|
+
expect(lastEnabledIndex([{ option: options[2], matches: [] }])).toBe(-1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("jumps to a typed prefix", () => {
|
|
96
|
+
expect(findByPrefix(entries, "ja", -1)).toBe(1);
|
|
97
|
+
expect(findByPrefix(entries, "s", -1)).toBe(-1); // South Korea is disabled
|
|
98
|
+
expect(findByPrefix(entries, "zz", -1)).toBe(-1);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("selection", () => {
|
|
103
|
+
it("adds, removes and respects max", () => {
|
|
104
|
+
expect(toggleValue(["a"], "b")).toEqual(["a", "b"]);
|
|
105
|
+
expect(toggleValue(["a", "b"], "a")).toEqual(["b"]);
|
|
106
|
+
expect(toggleValue(["a", "b"], "c", 2)).toEqual(["a", "b"]);
|
|
107
|
+
expect(toggleValue(["a", "b"], "b", 2)).toEqual(["a"]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("virtualization", () => {
|
|
112
|
+
const items = buildListItems(filterOptions(options, ""));
|
|
113
|
+
const metrics = measureItems(items, { option: 34, group: 26 });
|
|
114
|
+
|
|
115
|
+
it("measures group and option rows", () => {
|
|
116
|
+
expect(metrics.total).toBe(2 * 26 + 5 * 34);
|
|
117
|
+
expect(metrics.offsets[0]).toBe(0);
|
|
118
|
+
expect(metrics.offsets[1]).toBe(26);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns the visible window with overscan", () => {
|
|
122
|
+
expect(getVisibleRange(metrics, 0, 60, 0)).toEqual({ start: 0, end: 3 });
|
|
123
|
+
expect(getVisibleRange(metrics, 0, 0, 0)).toEqual({ start: 0, end: 7 });
|
|
124
|
+
const range = getVisibleRange(metrics, 100, 60, 1);
|
|
125
|
+
expect(range.start).toBeLessThanOrEqual(3);
|
|
126
|
+
expect(range.end).toBeGreaterThanOrEqual(5);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("scrolls an item into view only when needed", () => {
|
|
130
|
+
expect(scrollToItem(metrics, 0, 0, 100)).toBeNull();
|
|
131
|
+
expect(scrollToItem(metrics, 6, 0, 100)).toBe(metrics.offsets[7] - 100);
|
|
132
|
+
expect(scrollToItem(metrics, 1, 40, 100)).toBe(26);
|
|
133
|
+
});
|
|
134
|
+
});
|