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.
Files changed (57) hide show
  1. package/README.md +12 -3
  2. package/index.cjs +123 -79
  3. package/package.json +1 -1
  4. package/source/beta-components/combobox/README.md +170 -0
  5. package/source/beta-components/combobox/__tests__/core.test.ts +134 -0
  6. package/source/beta-components/combobox/core/filter.ts +155 -0
  7. package/source/beta-components/combobox/core/index.ts +16 -0
  8. package/source/beta-components/combobox/core/types.ts +48 -0
  9. package/source/beta-components/combobox/core/virtual.ts +67 -0
  10. package/source/beta-components/combobox/index.ts +13 -0
  11. package/source/beta-components/combobox/react/Listbox.tsx +302 -0
  12. package/source/beta-components/combobox/react/MultiSelect.tsx +263 -0
  13. package/source/beta-components/combobox/react/Popover.tsx +70 -0
  14. package/source/beta-components/combobox/react/Select.tsx +198 -0
  15. package/source/beta-components/combobox/react/icons.tsx +34 -0
  16. package/source/beta-components/combobox/react/locale.ts +16 -0
  17. package/source/beta-components/combobox/react/props.ts +86 -0
  18. package/source/beta-components/combobox/react/useCombobox.ts +399 -0
  19. package/source/beta-components/combobox/styles.css +417 -0
  20. /package/source/{components/datagridbeta → beta-components/datagrid}/README.md +0 -0
  21. /package/source/{components/datagridbeta → beta-components/datagrid}/__tests__/core.test.ts +0 -0
  22. /package/source/{components/datagridbeta → beta-components/datagrid}/__tests__/export.test.ts +0 -0
  23. /package/source/{components/datagridbeta → beta-components/datagrid}/core/columnHelper.ts +0 -0
  24. /package/source/{components/datagridbeta → beta-components/datagrid}/core/columns.ts +0 -0
  25. /package/source/{components/datagridbeta → beta-components/datagrid}/core/filtering.ts +0 -0
  26. /package/source/{components/datagridbeta → beta-components/datagrid}/core/grid.ts +0 -0
  27. /package/source/{components/datagridbeta → beta-components/datagrid}/core/index.ts +0 -0
  28. /package/source/{components/datagridbeta → beta-components/datagrid}/core/rows.ts +0 -0
  29. /package/source/{components/datagridbeta → beta-components/datagrid}/core/sorting.ts +0 -0
  30. /package/source/{components/datagridbeta → beta-components/datagrid}/core/state.ts +0 -0
  31. /package/source/{components/datagridbeta → beta-components/datagrid}/core/store.ts +0 -0
  32. /package/source/{components/datagridbeta → beta-components/datagrid}/core/types.ts +0 -0
  33. /package/source/{components/datagridbeta → beta-components/datagrid}/core/values.ts +0 -0
  34. /package/source/{components/datagridbeta → beta-components/datagrid}/core/virtual.ts +0 -0
  35. /package/source/{components/datagridbeta → beta-components/datagrid}/export/csv.ts +0 -0
  36. /package/source/{components/datagridbeta → beta-components/datagrid}/export/download.ts +0 -0
  37. /package/source/{components/datagridbeta → beta-components/datagrid}/export/pdf.ts +0 -0
  38. /package/source/{components/datagridbeta → beta-components/datagrid}/export/table.ts +0 -0
  39. /package/source/{components/datagridbeta → beta-components/datagrid}/export/xlsx.ts +0 -0
  40. /package/source/{components/datagridbeta → beta-components/datagrid}/export/zip.ts +0 -0
  41. /package/source/{components/datagridbeta → beta-components/datagrid}/index.ts +0 -0
  42. /package/source/{components/datagridbeta → beta-components/datagrid}/react/Cell.tsx +0 -0
  43. /package/source/{components/datagridbeta → beta-components/datagrid}/react/ColumnMenu.tsx +0 -0
  44. /package/source/{components/datagridbeta → beta-components/datagrid}/react/DataGrid.tsx +0 -0
  45. /package/source/{components/datagridbeta → beta-components/datagrid}/react/FilterForm.tsx +0 -0
  46. /package/source/{components/datagridbeta → beta-components/datagrid}/react/HeaderRow.tsx +0 -0
  47. /package/source/{components/datagridbeta → beta-components/datagrid}/react/Pagination.tsx +0 -0
  48. /package/source/{components/datagridbeta → beta-components/datagrid}/react/Popover.tsx +0 -0
  49. /package/source/{components/datagridbeta → beta-components/datagrid}/react/Row.tsx +0 -0
  50. /package/source/{components/datagridbeta → beta-components/datagrid}/react/Toolbar.tsx +0 -0
  51. /package/source/{components/datagridbeta → beta-components/datagrid}/react/Viewport.tsx +0 -0
  52. /package/source/{components/datagridbeta → beta-components/datagrid}/react/context.ts +0 -0
  53. /package/source/{components/datagridbeta → beta-components/datagrid}/react/hooks.ts +0 -0
  54. /package/source/{components/datagridbeta → beta-components/datagrid}/react/icons.tsx +0 -0
  55. /package/source/{components/datagridbeta → beta-components/datagrid}/react/keyboard.ts +0 -0
  56. /package/source/{components/datagridbeta → beta-components/datagrid}/react/locale.ts +0 -0
  57. /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.8)
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
- ## What's New 🎉 (Ver 1.2.8)
9
+ ## Beta Components
10
10
 
11
- - Canditate update for next major change 2.0.0
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
- "DataGridBeta",
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 DEFAULT_DEST_PATH = path.join(process.cwd(), "component-lib");
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(SOURCE_PATH, component.toLowerCase());
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(`Component ${component} not found in source directory.`);
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 = Array.from(componentsToInstall).filter(
100
- (comp) => !checkComponentExists(comp, destPath),
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 = Array.from(allComponentsToInstall).filter(
137
- (comp) => !checkComponentExists(comp, destPath),
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
- CONFIG.components.forEach((component, index) => {
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
- CONFIG.components.length - 1,
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 = CONFIG.components[currentIndex];
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 invalidComponents = components.filter(
269
- (comp) => !CONFIG.components.includes(comp),
270
- );
271
- if (invalidComponents.length > 0) {
272
- console.error(`Invalid components: ${invalidComponents.join(", ")}`);
273
- console.log("\nAvailable components:");
274
- CONFIG.components.forEach((comp) => console.log(`- ${comp}`));
275
- return false;
276
- }
277
- return true;
278
- };
279
-
280
- const main = async () => {
281
- const argv = yargs(hideBin(process.argv))
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
- .example("$0 -a Button", "Install a single component")
299
- .example("$0 -a Button,Card,Modal", "Install multiple components")
300
- .example("$0 -i", "Interactive selection mode")
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
- console.log("\nAvailable components:");
306
- CONFIG.components.forEach((comp) => {
307
- const deps = CONFIG.dependencies[comp]
308
- ? ` (requires: ${CONFIG.dependencies[comp].join(", ")})`
309
- : "";
310
- console.log(`- ${comp}${deps}`);
311
- });
312
- return;
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
- // Validate all components
351
- if (!validateComponents(components)) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gbs-add-block",
3
- "version": "1.2.8",
3
+ "version": "1.2.10",
4
4
  "description": "React Component Library",
5
5
  "type": "module",
6
6
  "files": [
@@ -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
+ });