base-combobox-creatable

PreviousNext
Docs
reuicomponent

Preview

Loading preview…
registry/default/components/base-combobox/creatable.tsx
'use client';

import * as React from 'react';
import { Button } from '@/registry/default/ui/base-button';
import {
  Combobox,
  ComboboxChip,
  ComboboxChipRemove,
  ComboboxChips,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxItemIndicator,
  ComboboxList,
  ComboboxValue,
} from '@/registry/default/ui/base-combobox';
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from '@/registry/default/ui/base-dialog';
import { Input } from '@/registry/default/ui/base-input';
import { Label } from '@/registry/default/ui/base-label';
import { PlusIcon } from 'lucide-react';

export default function ComboboxCreatableDemo() {
  const id = React.useId();

  const [labels, setLabels] = React.useState<LabelItem[]>(initialLabels);
  const [selected, setSelected] = React.useState<LabelItem[]>([]);
  const [query, setQuery] = React.useState('');
  const [openDialog, setOpenDialog] = React.useState(false);

  const containerRef = React.useRef<HTMLDivElement | null>(null);
  const createInputRef = React.useRef<HTMLInputElement | null>(null);
  const comboboxInputRef = React.useRef<HTMLInputElement | null>(null);
  const pendingQueryRef = React.useRef('');

  function handleCreate() {
    const input = createInputRef.current || comboboxInputRef.current;
    const value = input ? input.value.trim() : '';
    if (!value) {
      return;
    }

    const normalized = value.toLocaleLowerCase();
    const baseId = normalized.replace(/\s+/g, '-');
    const existing = labels.find((l) => l.value.trim().toLocaleLowerCase() === normalized);

    if (existing) {
      setSelected((prev) => (prev.some((i) => i.id === existing.id) ? prev : [...prev, existing]));
      setOpenDialog(false);
      setQuery('');
      return;
    }

    const existingIds = new Set(labels.map((l) => l.id));
    let uniqueId = baseId;
    if (existingIds.has(uniqueId)) {
      let i = 2;
      while (existingIds.has(`${baseId}-${i}`)) {
        i += 1;
      }
      uniqueId = `${baseId}-${i}`;
    }

    const newItem: LabelItem = { id: uniqueId, value };

    if (!selected.find((item) => item.id === newItem.id)) {
      setLabels((prev) => [...prev, newItem]);
      setSelected((prev) => [...prev, newItem]);
    }

    setOpenDialog(false);
    setQuery('');
  }

  function handleCreateSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    handleCreate();
  }

  const trimmed = query.trim();
  const lowered = trimmed.toLocaleLowerCase();
  const exactExists = labels.some((l) => l.value.trim().toLocaleLowerCase() === lowered);
  const itemsForView: Array<LabelItem> =
    trimmed !== '' && !exactExists
      ? [
          ...labels,
          {
            creatable: trimmed,
            id: `create:${lowered}`,
            value: `Create "${trimmed}"`,
          },
        ]
      : labels;

  return (
    <React.Fragment>
      <Combobox
        items={itemsForView}
        multiple
        onValueChange={(items) => {
          const selectedItems = items as LabelItem[];
          const last = selectedItems[selectedItems.length - 1];
          if (last && last.creatable) {
            pendingQueryRef.current = last.creatable;
            setOpenDialog(true);
            return;
          }
          const clean = selectedItems.filter((i) => !i.creatable);
          setSelected(clean);
          setQuery('');
        }}
        value={selected}
        inputValue={query}
        onInputValueChange={setQuery}
        onOpenChange={(_open, details) => {
          if ('key' in details.event && details.event.key === 'Enter') {
            if (trimmed === '') {
              return;
            }

            const existing = labels.find((l) => l.value.trim().toLocaleLowerCase() === lowered);

            if (existing) {
              setSelected((prev) => (prev.some((i) => i.id === existing.id) ? prev : [...prev, existing]));
              setQuery('');
              return;
            }

            pendingQueryRef.current = trimmed;
            setOpenDialog(true);
          }
        }}
      >
        <div className="max-w-xs w-full flex flex-col gap-1">
          <Label htmlFor={id}>Labels</Label>
          <ComboboxChips className="w-full" ref={containerRef}>
            <ComboboxValue>
              {(value: LabelItem[]) => (
                <React.Fragment>
                  {value.map((label) => (
                    <ComboboxChip key={label.id} aria-label={label.value}>
                      {label.value}
                      <ComboboxChipRemove />
                    </ComboboxChip>
                  ))}
                  <ComboboxInput ref={comboboxInputRef} id={id} placeholder={value.length > 0 ? '' : 'e.g. bug'} />
                </React.Fragment>
              )}
            </ComboboxValue>
          </ComboboxChips>
        </div>

        <ComboboxContent anchor={containerRef}>
          <ComboboxEmpty>No labels found.</ComboboxEmpty>
          <ComboboxList>
            {(item: LabelItem) =>
              item.creatable ? (
                <ComboboxItem key={item.id} value={item}>
                  <span className="col-start-1">
                    <PlusIcon className="size-3" />
                  </span>
                  <div className="col-start-2">Create &quot;{item.creatable}&quot;</div>
                </ComboboxItem>
              ) : (
                <ComboboxItem key={item.id} value={item}>
                  <ComboboxItemIndicator />
                  <div className="col-start-2">{item.value}</div>
                </ComboboxItem>
              )
            }
          </ComboboxList>
        </ComboboxContent>
      </Combobox>

      <Dialog open={openDialog} onOpenChange={setOpenDialog}>
        <DialogContent initialFocus={createInputRef} className="sm:max-w-md">
          <DialogTitle>Create new label</DialogTitle>
          <DialogDescription>Add a new label to select.</DialogDescription>
          <form onSubmit={handleCreateSubmit}>
            <Input ref={createInputRef} placeholder="Label name" defaultValue={pendingQueryRef.current} />
            <div className="mt-4 flex justify-end gap-4">
              <DialogClose>Cancel</DialogClose>
              <Button type="submit">Create</Button>
            </div>
          </form>
        </DialogContent>
      </Dialog>
    </React.Fragment>
  );
}

interface LabelItem {
  creatable?: string;
  id: string;
  value: string;
}

const initialLabels: LabelItem[] = [
  { id: 'bug', value: 'bug' },
  { id: 'docs', value: 'documentation' },
  { id: 'enhancement', value: 'enhancement' },
  { id: 'help-wanted', value: 'help wanted' },
  { id: 'good-first-issue', value: 'good first issue' },
];

Installation

npx shadcn@latest add @reui/base-combobox-creatable

Usage

import { BaseComboboxCreatable } from "@/components/base-combobox-creatable"
<BaseComboboxCreatable />