samling 0.13.1

App for managing apparel collections
Documentation
import { Combobox } from "@headlessui/react";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/20/solid";
import { Trans } from "@lingui/macro";
import { Dispatch, SetStateAction, useMemo, useRef, useState } from "react";
import { cloudflareImageUrl } from "../../images";
import { classNames } from "../../utils";

const DEFAULT_NUM_SELECTED_VISIBLE = 3;

interface FilterItem {
  id?: number | string | null;
  title: string;
  subtitle?: string | null;
  bubblePrefix?: string | null;
  imageUrl?: string | null;
}

interface MultipleComboboxProps<T> {
  title?: string;
  description?: string;
  allItems: T[];
  selectedItems: T[];
  setSelectedItems: Dispatch<SetStateAction<T[]>>;
  toFilterItem: (item: T) => FilterItem;
  numSelectedVisible?: number;
}

export default function MultipleCombobox<T>({
  title,
  description,
  allItems,
  selectedItems,
  setSelectedItems,
  toFilterItem,
  numSelectedVisible = DEFAULT_NUM_SELECTED_VISIBLE,
}: MultipleComboboxProps<T>) {
  const [query, setQuery] = useState("");
  const comboboxButton = useRef<HTMLButtonElement>(null);
  const allItemsMap = useMemo(() => {
    const map: Map<T, FilterItem> = new Map();
    allItems.forEach((item) => {
      map.set(item, toFilterItem(item));
    });
    return map;
  }, [allItems, toFilterItem]);
  const allFilterItemsMap = useMemo(() => {
    return new Map(
      Array.from(allItemsMap.entries()).map(([item, filterItem]) => [
        filterItem,
        item,
      ]),
    );
  }, [allItemsMap]);
  const allFilterItems = useMemo(
    () => Array.from(allItemsMap.values()),
    [allItemsMap],
  );
  const selectedFilterItems = useMemo(
    () =>
      selectedItems
        .map((item) => allItemsMap.get(item))
        .filter((x) => x !== undefined) as FilterItem[],
    [allItemsMap, selectedItems],
  );

  const anyImage = useMemo(() => {
    return allFilterItems.some((i) => !!i.imageUrl);
  }, [allFilterItems]);

  const filteredResults = useMemo(() => {
    if (query === "") {
      return allFilterItems.filter((_, idx) => idx < 50);
    } else {
      return allFilterItems.reduce((results, item) => {
        if (results.length > 50) {
          return results;
        }
        if (
          [item.title, item.subtitle || ""]
            .join(" ")
            .toLowerCase()
            .includes(query.toLowerCase())
        ) {
          results.push(item);
        }
        return results;
      }, [] as FilterItem[]);
    }
  }, [query, allFilterItems]);

  function remove(filterItem: FilterItem) {
    const item = allFilterItemsMap.get(filterItem);
    if (item !== undefined) {
      setSelectedItems(selectedItems.filter((otherItem) => otherItem !== item));
    }
  }

  return (
    <Combobox
      as="div"
      value={selectedFilterItems}
      onChange={(filterItems) => {
        const items = filterItems.flatMap((filterItem) => {
          const found = allFilterItemsMap.get(filterItem);
          // This can happen on module hot reloads for some reason
          return found === undefined ? [] : [found];
        });
        setSelectedItems(items);
        setQuery("");
        comboboxButton.current?.click();
      }}
      multiple
    >
      {title && (
        <Combobox.Label className="block text-sm font-medium text-gray-700">
          {title}
        </Combobox.Label>
      )}
      {description && (
        <p className="block text-xs text-gray-700">
          {description}
        </p>
      )}
      <div className="relative mt-1">
        <Combobox.Input
          className="w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-10 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm"
          displayValue={() => query}
          onChange={(event) => setQuery(event.target.value)}
        />
        <div className="absolute inset-y-0 right-0 flex items-center px-2">
          {selectedFilterItems
            .filter((_, idx) => idx < numSelectedVisible)
            .map((filterItem) => (
              <span
                key={filterItem.id || filterItem.title}
                className="inline-flex items-center rounded-full bg-indigo-100 mx-0.5 px-2 py-0.5 text-xs text-indigo-800"
              >
                {filterItem.bubblePrefix && (
                <span className="overflow-hidden whitespace-nowrap text-indigo-400 pr-1.5">{filterItem.bubblePrefix}</span>
                )}
                <span
                  style={{ maxWidth: 150 }}
                  className="overflow-hidden whitespace-nowrap"
                >
                  {filterItem.title}
                </span>
                <button
                  type="button"
                  onClick={() => remove(filterItem)}
                  className="ml-0.5 inline-flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full text-gray-400 hover:bg-indigo-200 hover:text-gray-500 focus:bg-indigo-500 focus:text-white focus:outline-none"
                >
                  <span className="sr-only">
                    <Trans>Remove filter</Trans>
                  </span>
                  <svg
                    className="h-2 w-2"
                    stroke="currentColor"
                    fill="none"
                    viewBox="0 0 8 8"
                  >
                    <path
                      strokeLinecap="round"
                      strokeWidth="1.5"
                      d="M1 1l6 6m0-6L1 7"
                    />
                  </svg>
                </button>
              </span>
            ))}
          {selectedFilterItems.length > numSelectedVisible && (
            <span className="mx-0.5 px-2 py-0.5 text-xs text-indigo-800 overflow-hidden whitespace-nowrap">{`+ ${
              selectedFilterItems.length - numSelectedVisible
            }`}</span>
          )}
          <Combobox.Button
            ref={comboboxButton}
            className="flex items-center rounded-r-md focus:outline-none"
          >
            <ChevronUpDownIcon
              className="h-5 w-5 text-gray-400"
              aria-hidden="true"
            />
          </Combobox.Button>
        </div>

        {filteredResults.length > 0 && (
          <Combobox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
            {filteredResults.map((item) => (
              <Combobox.Option
                key={item.id || item.title}
                value={item}
                className={({ active }) =>
                  classNames(
                    "relative cursor-default select-none py-2 pl-3 pr-9",
                    active ? "bg-indigo-600 text-white" : "text-gray-900",
                  )
                }
              >
                {({ active, selected }) => (
                  <>
                    <div className="flex items-center">
                      {anyImage ? (
                        item.imageUrl ? (
                          <img
                            src={cloudflareImageUrl(item.imageUrl, "thumbnail")}
                            loading="lazy"
                            alt=""
                            className="h-12 w-12 flex-shrink-0 rounded-full"
                          />
                        ) : (
                          <span className="h-12 w-12"></span>
                        )
                      ) : (
                        ""
                      )}
                      <span
                        className={classNames(
                          "ml-3 truncate",
                          selected ? "font-semibold" : "",
                        )}
                      >
                        {item.title}
                      </span>
                      {item.subtitle ? (
                        <span
                          className={classNames(
                            "ml-3 truncate text-gray-300",
                            selected ? "font-semibold" : "",
                          )}
                        >
                          {item.subtitle}
                        </span>
                      ) : (
                        ""
                      )}
                    </div>

                    {selected && (
                      <span
                        className={classNames(
                          "absolute inset-y-0 right-0 flex items-center pr-4",
                          active ? "text-white" : "text-indigo-600",
                        )}
                      >
                        <CheckIcon className="h-5 w-5" aria-hidden="true" />
                      </span>
                    )}
                  </>
                )}
              </Combobox.Option>
            ))}
          </Combobox.Options>
        )}
      </div>
    </Combobox>
  );
}