@marimo-team/islands 0.23.7-dev47 → 0.23.7-dev48

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 (27) hide show
  1. package/dist/{chat-ui-CufH8sfF.js → chat-ui-D8ZxPNTR.js} +3 -3
  2. package/dist/{code-visibility-C4KEMmUK.js → code-visibility-An0P9cL_.js} +1265 -935
  3. package/dist/{glide-data-editor-BK9s_dqy.js → glide-data-editor-DucgdjRo.js} +1 -1
  4. package/dist/{html-to-image-DxWM1HVj.js → html-to-image-DaPPaVDP.js} +1 -1
  5. package/dist/{input-Cc1Vvw9A.js → input-D4kjoQUB.js} +2 -0
  6. package/dist/main.js +8 -8
  7. package/dist/{process-output-DBYxXdrN.js → process-output-n0RJTxcC.js} +1 -1
  8. package/dist/{reveal-component-Dx7r_prC.js → reveal-component-B23qYh6r.js} +3 -3
  9. package/dist/style.css +1 -1
  10. package/package.json +1 -1
  11. package/src/components/data-table/__tests__/column-header.test.ts +3 -1
  12. package/src/components/data-table/__tests__/column-header.test.tsx +203 -0
  13. package/src/components/data-table/__tests__/filter-by-values-picker.test.tsx +112 -0
  14. package/src/components/data-table/__tests__/filter-pill-editor.test.tsx +175 -0
  15. package/src/components/data-table/__tests__/filters.test.ts +112 -36
  16. package/src/components/data-table/column-header.tsx +210 -157
  17. package/src/components/data-table/filter-by-values-picker.tsx +70 -9
  18. package/src/components/data-table/filter-pill-editor.tsx +289 -144
  19. package/src/components/data-table/filter-pills.tsx +49 -8
  20. package/src/components/data-table/filters.ts +131 -36
  21. package/src/components/data-table/header-items.tsx +8 -1
  22. package/src/components/data-table/operator-labels.ts +25 -0
  23. package/src/components/data-table/regex-input.tsx +61 -0
  24. package/src/components/ui/combobox.tsx +3 -2
  25. package/src/components/ui/number-field.tsx +2 -0
  26. package/src/plugins/impl/data-frames/forms/__tests__/__snapshots__/form.test.tsx.snap +24 -24
  27. package/src/plugins/impl/data-frames/schema.ts +4 -1
@@ -9,7 +9,7 @@ import {
9
9
  TextIcon,
10
10
  XIcon,
11
11
  } from "lucide-react";
12
- import { useRef, useState } from "react";
12
+ import { useState } from "react";
13
13
  import { useLocale } from "react-aria";
14
14
  import {
15
15
  DropdownMenu,
@@ -26,25 +26,31 @@ import type { CalculateTopKRows } from "@/plugins/impl/DataTablePlugin";
26
26
  import type { OperatorType } from "@/plugins/impl/data-frames/utils/operators";
27
27
  import { logNever } from "@/utils/assertNever";
28
28
  import { cn } from "@/utils/cn";
29
- import { capitalize } from "@/utils/strings";
30
29
  import { Button } from "../ui/button";
31
30
  import { DraggablePopover } from "../ui/draggable-popover";
32
31
  import { Input } from "../ui/input";
32
+ import { RegexInput } from "./regex-input";
33
33
  import { NumberField } from "../ui/number-field";
34
34
  import { PopoverClose } from "../ui/popover";
35
35
  import {
36
36
  Select,
37
37
  SelectContent,
38
38
  SelectItem,
39
- SelectSeparator,
40
39
  SelectTrigger,
41
40
  SelectValue,
42
41
  } from "../ui/select";
43
42
  import { FilterByValuesList } from "./filter-by-values-picker";
43
+ import { OPERATOR_LABELS } from "./operator-labels";
44
44
  import {
45
45
  type ColumnFilterForType,
46
46
  type ColumnFilterValue,
47
47
  Filter,
48
+ NUMBER_COMPARISON_OPS,
49
+ type NumberComparisonOp,
50
+ NUMBER_OPS,
51
+ TEXT_OPS,
52
+ TEXT_SCALAR_OPS,
53
+ type TextScalarOp,
48
54
  } from "./filters";
49
55
  import {
50
56
  ClearFilterMenuItem,
@@ -148,7 +154,7 @@ export const DataTableColumnHeader = <TData, TValue>({
148
154
  {renderColumnWrapping(column)}
149
155
  {renderFormatOptions(column, locale)}
150
156
  <DropdownMenuSeparator />
151
- {renderMenuItemFilter(column)}
157
+ {renderMenuItemFilter(column, calculateTopKRows)}
152
158
  {renderFilterByValues(column, setIsFilterValueOpen)}
153
159
  {hasFilter && <ClearFilterMenuItem column={column} />}
154
160
  </DropdownMenuContent>
@@ -211,6 +217,7 @@ const SortButton = <TData, TValue>({
211
217
 
212
218
  export function renderMenuItemFilter<TData, TValue>(
213
219
  column: Column<TData, TValue>,
220
+ calculateTopKRows?: CalculateTopKRows,
214
221
  ) {
215
222
  const canFilter = column.getCanFilter();
216
223
  if (!canFilter) {
@@ -248,7 +255,10 @@ export function renderMenuItemFilter<TData, TValue>(
248
255
  {filterMenuItem}
249
256
  <DropdownMenuPortal>
250
257
  <DropdownMenuSubContent>
251
- <TextFilter column={column} />
258
+ <TextFilterMenu
259
+ column={column}
260
+ calculateTopKRows={calculateTopKRows}
261
+ />
252
262
  </DropdownMenuSubContent>
253
263
  </DropdownMenuPortal>
254
264
  </DropdownMenuSub>
@@ -261,7 +271,7 @@ export function renderMenuItemFilter<TData, TValue>(
261
271
  {filterMenuItem}
262
272
  <DropdownMenuPortal>
263
273
  <DropdownMenuSubContent>
264
- <NumberRangeFilter column={column} />
274
+ <NumberFilterMenu column={column} />
265
275
  </DropdownMenuSubContent>
266
276
  </DropdownMenuPortal>
267
277
  </DropdownMenuSub>
@@ -292,58 +302,28 @@ export function renderMenuItemFilter<TData, TValue>(
292
302
  return null;
293
303
  }
294
304
 
295
- // Type-safe constants for null filter operators
296
- const NULL_FILTER_OPERATORS = {
297
- is_null: "is_null",
298
- is_not_null: "is_not_null",
299
- } satisfies Record<string, OperatorType>;
300
-
301
- const NullFilter = <TData, TValue>({
302
- column,
303
- defaultItem,
305
+ const OperatorSelect = ({
304
306
  operator,
305
- setOperator,
307
+ options,
308
+ onChange,
306
309
  }: {
307
- column: Column<TData, TValue>;
308
- defaultItem?: OperatorType | "between";
309
- operator: OperatorType | "between";
310
- setOperator: (operator: OperatorType) => void;
311
- }) => {
312
- const handleValueChange = (value: OperatorType) => {
313
- setOperator(value);
314
- if (value === "is_null" || value === "is_not_null") {
315
- column.setFilterValue(Filter.text({ operator: value }));
316
- }
317
- };
318
-
319
- const isNullOrNotNull = operator === "is_null" || operator === "is_not_null";
320
-
321
- return (
322
- <Select
323
- value={operator}
324
- onValueChange={(value) => handleValueChange(value as OperatorType)}
325
- >
326
- <SelectTrigger
327
- className={cn(
328
- "border-border shadow-none! ring-0! w-full mb-0.5",
329
- isNullOrNotNull && "mb-2",
330
- )}
331
- >
332
- <SelectValue defaultValue={operator} />
333
- </SelectTrigger>
334
- <SelectContent>
335
- {defaultItem && (
336
- <SelectItem value={defaultItem}>{capitalize(defaultItem)}</SelectItem>
337
- )}
338
- <SelectSeparator />
339
- <SelectItem value={NULL_FILTER_OPERATORS.is_null}>Is null</SelectItem>
340
- <SelectItem value={NULL_FILTER_OPERATORS.is_not_null}>
341
- Is not null
310
+ operator: OperatorType;
311
+ options: readonly OperatorType[];
312
+ onChange: (next: OperatorType) => void;
313
+ }) => (
314
+ <Select value={operator} onValueChange={(v) => onChange(v as OperatorType)}>
315
+ <SelectTrigger className="border-border shadow-none! ring-0! w-full mb-0.5">
316
+ <SelectValue />
317
+ </SelectTrigger>
318
+ <SelectContent>
319
+ {options.map((op) => (
320
+ <SelectItem key={op} value={op}>
321
+ {OPERATOR_LABELS[op]}
342
322
  </SelectItem>
343
- </SelectContent>
344
- </Select>
345
- );
346
- };
323
+ ))}
324
+ </SelectContent>
325
+ </Select>
326
+ );
347
327
 
348
328
  const BooleanFilter = <TData, TValue>({
349
329
  column,
@@ -389,7 +369,21 @@ const BooleanFilter = <TData, TValue>({
389
369
  );
390
370
  };
391
371
 
392
- const NumberRangeFilter = <TData, TValue>({
372
+ const NUMBER_COMPARISON_SET: ReadonlySet<OperatorType> = new Set(
373
+ NUMBER_COMPARISON_OPS,
374
+ );
375
+ const isNumberComparisonOp = (op: OperatorType): op is NumberComparisonOp =>
376
+ NUMBER_COMPARISON_SET.has(op);
377
+
378
+ type NumberComparisonFilter = Extract<
379
+ ColumnFilterForType<"number">,
380
+ { value: number }
381
+ >;
382
+ const isNumberComparisonFilter = (
383
+ filter: ColumnFilterForType<"number">,
384
+ ): filter is NumberComparisonFilter => isNumberComparisonOp(filter.operator);
385
+
386
+ export const NumberFilterMenu = <TData, TValue>({
393
387
  column,
394
388
  }: {
395
389
  column: Column<TData, TValue>;
@@ -399,149 +393,208 @@ const NumberRangeFilter = <TData, TValue>({
399
393
  | undefined;
400
394
  const hasFilter = currentFilter !== undefined;
401
395
 
402
- const [operator, setOperator] = useState<OperatorType | "between">(
396
+ const [operator, setOperator] = useState<OperatorType>(
403
397
  currentFilter?.operator ?? "between",
404
398
  );
405
- const [min, setMin] = useState<number | undefined>(currentFilter?.min);
406
- const [max, setMax] = useState<number | undefined>(currentFilter?.max);
407
- const minRef = useRef<HTMLInputElement>(null);
408
- const maxRef = useRef<HTMLInputElement>(null);
399
+ const [min, setMin] = useState<number | undefined>(
400
+ currentFilter?.operator === "between" ? currentFilter.min : undefined,
401
+ );
402
+ const [max, setMax] = useState<number | undefined>(
403
+ currentFilter?.operator === "between" ? currentFilter.max : undefined,
404
+ );
405
+ const [value, setValue] = useState<number | undefined>(
406
+ currentFilter !== undefined && isNumberComparisonFilter(currentFilter)
407
+ ? currentFilter.value
408
+ : undefined,
409
+ );
409
410
 
410
- const handleApply = (opts: { min?: number; max?: number } = {}) => {
411
- column.setFilterValue(
412
- Filter.number({
413
- min: opts.min ?? min,
414
- max: opts.max ?? max,
415
- operator: operator === "between" ? undefined : operator,
416
- }),
417
- );
411
+ const isComparison = isNumberComparisonOp(operator);
412
+ const isNullish = operator === "is_null" || operator === "is_not_null";
413
+
414
+ const applyDisabled =
415
+ (operator === "between" && (min === undefined || max === undefined)) ||
416
+ (isComparison && value === undefined);
417
+
418
+ const handleApply = () => {
419
+ if (isNullish) {
420
+ column.setFilterValue(Filter.number({ operator }));
421
+ return;
422
+ }
423
+ if (operator === "between" && min !== undefined && max !== undefined) {
424
+ column.setFilterValue(Filter.number({ operator: "between", min, max }));
425
+ return;
426
+ }
427
+ if (isComparison && value !== undefined) {
428
+ column.setFilterValue(Filter.number({ operator, value }));
429
+ }
430
+ };
431
+
432
+ const handleClear = () => {
433
+ setMin(undefined);
434
+ setMax(undefined);
435
+ setValue(undefined);
436
+ column.setFilterValue(undefined);
437
+ };
438
+
439
+ const handleOperatorChange = (next: OperatorType) => {
440
+ setOperator(next);
418
441
  };
419
442
 
420
443
  return (
421
444
  <div className="flex flex-col gap-1 pt-3 px-2">
422
- <NullFilter
423
- column={column}
424
- defaultItem="between"
445
+ <OperatorSelect
425
446
  operator={operator}
426
- setOperator={setOperator}
447
+ options={NUMBER_OPS}
448
+ onChange={handleOperatorChange}
427
449
  />
428
450
  {operator === "between" && (
429
- <>
430
- <div className="flex gap-1 items-center">
431
- <NumberField
432
- ref={minRef}
433
- value={min}
434
- onChange={(value) => setMin(value)}
435
- aria-label="min"
436
- placeholder="min"
437
- onKeyDown={(e) => {
438
- if (e.key === "Enter") {
439
- handleApply({
440
- min: Number.parseFloat(e.currentTarget.value),
441
- });
442
- }
443
- if (e.key === "Tab") {
444
- maxRef.current?.focus();
445
- }
446
- }}
447
- className="shadow-none! border-border hover:shadow-none!"
448
- />
449
- <MinusIcon className="h-5 w-5 text-muted-foreground" />
450
- <NumberField
451
- ref={maxRef}
452
- value={max}
453
- onChange={(value) => setMax(value)}
454
- aria-label="max"
455
- onKeyDown={(e) => {
456
- if (e.key === "Enter") {
457
- handleApply({
458
- max: Number.parseFloat(e.currentTarget.value),
459
- });
460
- }
461
- if (e.key === "Tab") {
462
- minRef.current?.focus();
463
- }
464
- }}
465
- placeholder="max"
466
- className="shadow-none! border-border hover:shadow-none!"
467
- />
468
- </div>
469
- <FilterButtons
470
- onApply={handleApply}
471
- onClear={() => {
472
- setMin(undefined);
473
- setMax(undefined);
474
- column.setFilterValue(undefined);
475
- }}
476
- clearButtonDisabled={!hasFilter}
451
+ <div className="flex gap-1 items-center">
452
+ <NumberField
453
+ value={min}
454
+ onChange={setMin}
455
+ aria-label="min"
456
+ placeholder="min"
457
+ className="shadow-none! border-border hover:shadow-none!"
458
+ />
459
+ <MinusIcon className="h-5 w-5 text-muted-foreground" />
460
+ <NumberField
461
+ value={max}
462
+ onChange={setMax}
463
+ aria-label="max"
464
+ placeholder="max"
465
+ className="shadow-none! border-border hover:shadow-none!"
477
466
  />
478
- </>
467
+ </div>
468
+ )}
469
+ {isComparison && (
470
+ <NumberField
471
+ value={value}
472
+ onChange={setValue}
473
+ aria-label="value"
474
+ placeholder="value"
475
+ className="shadow-none! border-border hover:shadow-none!"
476
+ />
479
477
  )}
478
+ <FilterButtons
479
+ onApply={handleApply}
480
+ onClear={handleClear}
481
+ clearButtonDisabled={!hasFilter}
482
+ applyButtonDisabled={applyDisabled}
483
+ />
480
484
  </div>
481
485
  );
482
486
  };
483
487
 
484
- const TextFilter = <TData, TValue>({
488
+ const TEXT_SCALAR_SET: ReadonlySet<OperatorType> = new Set(TEXT_SCALAR_OPS);
489
+ const isTextScalarOp = (op: OperatorType): op is TextScalarOp =>
490
+ TEXT_SCALAR_SET.has(op);
491
+
492
+ export const TextFilterMenu = <TData, TValue>({
485
493
  column,
494
+ calculateTopKRows,
486
495
  }: {
487
496
  column: Column<TData, TValue>;
497
+ calculateTopKRows?: CalculateTopKRows;
488
498
  }) => {
489
499
  const currentFilter = column.getFilterValue() as
490
500
  | ColumnFilterForType<"text">
491
501
  | undefined;
492
502
  const hasFilter = currentFilter !== undefined;
493
- const [value, setValue] = useState<string>(currentFilter?.text ?? "");
503
+
494
504
  const [operator, setOperator] = useState<OperatorType>(
495
505
  currentFilter?.operator ?? "contains",
496
506
  );
507
+ const [text, setText] = useState<string>(
508
+ currentFilter && "text" in currentFilter ? currentFilter.text : "",
509
+ );
510
+ const [values, setValues] = useState<string[]>(
511
+ currentFilter && "values" in currentFilter ? [...currentFilter.values] : [],
512
+ );
513
+
514
+ const isScalar = isTextScalarOp(operator);
515
+ const isMulti = operator === "in" || operator === "not_in";
516
+ const isNullish =
517
+ operator === "is_null" ||
518
+ operator === "is_not_null" ||
519
+ operator === "is_empty";
520
+
521
+ const applyDisabled =
522
+ (isScalar && text === "") || (isMulti && values.length === 0);
497
523
 
498
524
  const handleApply = () => {
499
- if (operator !== "contains") {
525
+ if (isNullish) {
500
526
  column.setFilterValue(Filter.text({ operator }));
501
527
  return;
502
528
  }
503
-
504
- if (value === "") {
505
- column.setFilterValue(undefined);
529
+ if (isScalar && text !== "") {
530
+ column.setFilterValue(Filter.text({ operator, text }));
506
531
  return;
507
532
  }
533
+ if (isMulti && values.length > 0) {
534
+ column.setFilterValue(Filter.text({ operator, values }));
535
+ }
536
+ };
537
+
538
+ const handleClear = () => {
539
+ setText("");
540
+ setValues([]);
541
+ column.setFilterValue(undefined);
542
+ };
508
543
 
509
- column.setFilterValue(Filter.text({ text: value, operator }));
544
+ const handleOperatorChange = (next: OperatorType) => {
545
+ setOperator(next);
510
546
  };
511
547
 
512
548
  return (
513
549
  <div className="flex flex-col gap-1 pt-3 px-2">
514
- <NullFilter
515
- column={column}
516
- defaultItem="contains"
550
+ <OperatorSelect
517
551
  operator={operator}
518
- setOperator={setOperator}
552
+ options={TEXT_OPS}
553
+ onChange={handleOperatorChange}
519
554
  />
520
- {operator === "contains" && (
521
- <>
522
- <Input
523
- type="text"
524
- icon={<TextIcon className="h-3 w-3 text-muted-foreground mb-1" />}
525
- value={value ?? ""}
526
- onChange={(e) => setValue(e.target.value)}
527
- placeholder="Text..."
528
- onKeyDown={(e) => {
529
- if (e.key === "Enter") {
530
- handleApply();
531
- }
532
- }}
533
- className="shadow-none! border-border hover:shadow-none!"
534
- />
535
- <FilterButtons
536
- onApply={handleApply}
537
- onClear={() => {
538
- setValue("");
539
- column.setFilterValue(undefined);
540
- }}
541
- clearButtonDisabled={!hasFilter}
542
- />
543
- </>
555
+ {isScalar && operator === "regex" && (
556
+ <RegexInput
557
+ value={text}
558
+ onChange={setText}
559
+ onKeyDown={(e) => {
560
+ e.stopPropagation();
561
+ if (e.key === "Enter") {
562
+ handleApply();
563
+ }
564
+ }}
565
+ />
544
566
  )}
567
+ {isScalar && operator !== "regex" && (
568
+ <Input
569
+ type="text"
570
+ icon={<TextIcon className="h-3 w-3 text-muted-foreground mb-1" />}
571
+ value={text}
572
+ onChange={(e) => setText(e.target.value)}
573
+ placeholder="Text..."
574
+ onKeyDown={(e) => {
575
+ e.stopPropagation();
576
+ if (e.key === "Enter") {
577
+ handleApply();
578
+ }
579
+ }}
580
+ className="shadow-none! border-border hover:shadow-none!"
581
+ />
582
+ )}
583
+ {isMulti && (
584
+ <FilterByValuesList
585
+ column={column}
586
+ calculateTopKRows={calculateTopKRows}
587
+ chosenValues={new Set(values)}
588
+ onChange={(next) => setValues(next.map(String))}
589
+ creatable={true}
590
+ />
591
+ )}
592
+ <FilterButtons
593
+ onApply={handleApply}
594
+ onClear={handleClear}
595
+ clearButtonDisabled={!hasFilter}
596
+ applyButtonDisabled={applyDisabled}
597
+ />
545
598
  </div>
546
599
  );
547
600
  };
@@ -32,6 +32,7 @@ interface Props<TData, TValue> {
32
32
  calculateTopKRows?: CalculateTopKRows;
33
33
  chosenValues: unknown[];
34
34
  onChange: (values: unknown[]) => void;
35
+ creatable?: boolean;
35
36
  }
36
37
 
37
38
  export const FilterByValuesPicker = <TData, TValue>({
@@ -39,6 +40,7 @@ export const FilterByValuesPicker = <TData, TValue>({
39
40
  calculateTopKRows,
40
41
  chosenValues,
41
42
  onChange,
43
+ creatable = false,
42
44
  }: Props<TData, TValue>) => {
43
45
  const [open, setOpen] = useState(false);
44
46
 
@@ -58,6 +60,7 @@ export const FilterByValuesPicker = <TData, TValue>({
58
60
  <Popover open={open} onOpenChange={setOpen}>
59
61
  <PopoverTrigger asChild={true}>
60
62
  <Button
63
+ type="button"
61
64
  variant="outline"
62
65
  size="xs"
63
66
  className="h-6 mb-1 w-full justify-between font-normal"
@@ -79,6 +82,7 @@ export const FilterByValuesPicker = <TData, TValue>({
79
82
  calculateTopKRows={calculateTopKRows}
80
83
  chosenValues={chosenValuesSet}
81
84
  onChange={onChange}
85
+ creatable={creatable}
82
86
  />
83
87
  </PopoverContent>
84
88
  </Popover>
@@ -90,6 +94,7 @@ interface FilterByValuesListProps<TData, TValue> {
90
94
  calculateTopKRows?: CalculateTopKRows;
91
95
  chosenValues: Set<unknown>;
92
96
  onChange: (values: unknown[]) => void;
97
+ creatable?: boolean;
93
98
  }
94
99
 
95
100
  /**
@@ -100,6 +105,7 @@ export const FilterByValuesList = <TData, TValue>({
100
105
  calculateTopKRows,
101
106
  chosenValues,
102
107
  onChange,
108
+ creatable = false,
103
109
  }: FilterByValuesListProps<TData, TValue>) => {
104
110
  const [query, setQuery] = useState<string>("");
105
111
 
@@ -133,13 +139,48 @@ export const FilterByValuesList = <TData, TValue>({
133
139
  }
134
140
  }, [data, query]);
135
141
 
142
+ // Surface chosen values that aren't in the top-K so they stay visible/uncheckable.
143
+ // Count is undefined for these rows; the cell renders an em-dash.
144
+ const mergedData = useMemo<Array<[unknown, number | undefined]>>(() => {
145
+ const seen = new Set(filteredData.map(([v]) => v));
146
+ const extras: Array<[unknown, number | undefined]> = [];
147
+ for (const chosen of chosenValues) {
148
+ if (seen.has(chosen)) {
149
+ continue;
150
+ }
151
+ const str = String(chosen);
152
+ const matches =
153
+ query.length === 0 ||
154
+ smartMatch(query, str) ||
155
+ str.toLowerCase().includes(query.toLowerCase());
156
+ if (matches) {
157
+ extras.push([chosen, undefined]);
158
+ }
159
+ }
160
+ return [...filteredData, ...extras];
161
+ }, [filteredData, chosenValues, query]);
162
+
136
163
  const handleToggle = (value: unknown) => {
137
164
  onChange([...Sets.toggle(chosenValues, value)]);
138
165
  };
139
166
 
167
+ const trimmedQuery = query.trim();
168
+ const canCreate =
169
+ creatable &&
170
+ trimmedQuery !== "" &&
171
+ !mergedData.some(([v]) => String(v) === trimmedQuery);
172
+
173
+ const commitCreate = () => {
174
+ if (!canCreate) {
175
+ return;
176
+ }
177
+ onChange([...chosenValues, trimmedQuery]);
178
+ setQuery("");
179
+ };
180
+
140
181
  const allVisibleChecked =
141
- filteredData.length > 0 &&
142
- filteredData.every(([value]) => chosenValues.has(value));
182
+ mergedData.length > 0 &&
183
+ mergedData.every(([value]) => chosenValues.has(value));
143
184
 
144
185
  const selectAllState: boolean | "indeterminate" = allVisibleChecked
145
186
  ? true
@@ -153,11 +194,11 @@ export const FilterByValuesList = <TData, TValue>({
153
194
  }
154
195
  const next = new Set(chosenValues);
155
196
  if (allVisibleChecked) {
156
- for (const [value] of filteredData) {
197
+ for (const [value] of mergedData) {
157
198
  next.delete(value);
158
199
  }
159
200
  } else {
160
- for (const [value] of filteredData) {
201
+ for (const [value] of mergedData) {
161
202
  next.add(value);
162
203
  }
163
204
  }
@@ -183,13 +224,24 @@ export const FilterByValuesList = <TData, TValue>({
183
224
  return (
184
225
  <Command className="text-sm outline-hidden" shouldFilter={false}>
185
226
  <CommandInput
186
- placeholder={`Search among the top ${data.length} values`}
227
+ placeholder={
228
+ creatable
229
+ ? "Search or add a value…"
230
+ : `Search among the top ${data.length} values`
231
+ }
187
232
  autoFocus={true}
188
- onValueChange={(value) => setQuery(value.trim())}
233
+ value={query}
234
+ onValueChange={setQuery}
235
+ onKeyDown={(e) => {
236
+ if (e.key === "Enter" && canCreate) {
237
+ e.preventDefault();
238
+ commitCreate();
239
+ }
240
+ }}
189
241
  />
190
242
  <CommandEmpty>No results found.</CommandEmpty>
191
243
  <CommandList>
192
- {filteredData.length > 0 && (
244
+ {mergedData.length > 0 && (
193
245
  <CommandItem
194
246
  value="__select-all__"
195
247
  className="border-b rounded-none px-3"
@@ -204,7 +256,7 @@ export const FilterByValuesList = <TData, TValue>({
204
256
  <span className="font-bold">Count</span>
205
257
  </CommandItem>
206
258
  )}
207
- {filteredData.map(([value, count]) => {
259
+ {mergedData.map(([value, count]) => {
208
260
  const isSelected = chosenValues.has(value);
209
261
  const valueString = stringifyUnknownValue({ value });
210
262
  const sentinel = detectSentinel(
@@ -226,10 +278,19 @@ export const FilterByValuesList = <TData, TValue>({
226
278
  <span className="flex-1 overflow-hidden max-h-20 line-clamp-3">
227
279
  {sentinel ? <SentinelCell sentinel={sentinel} /> : valueString}
228
280
  </span>
229
- <span className="ml-3">{count}</span>
281
+ <span className="ml-3">{count === undefined ? "—" : count}</span>
230
282
  </CommandItem>
231
283
  );
232
284
  })}
285
+ {canCreate && (
286
+ <CommandItem
287
+ value={`__create__:${trimmedQuery}`}
288
+ className="border-t rounded-none px-3 italic"
289
+ onSelect={commitCreate}
290
+ >
291
+ + Add "{trimmedQuery}"
292
+ </CommandItem>
293
+ )}
233
294
  </CommandList>
234
295
  {data.length === TOP_K_ROWS && (
235
296
  <span className="text-xs text-muted-foreground py-1.5 text-center">