import { Combobox as HeadlessCombobox } from "@headlessui/react"; import clsx from "clsx"; import Fuse from "fuse.js"; import * as React from "react"; import type { GearType, UserWithPlusTier } from "~/db/types"; import { useAllEventsWithMapPools, useUsers } from "~/hooks/swr"; import { useTranslation } from "~/hooks/useTranslation"; import type { MainWeaponId } from "~/modules/in-game-lists"; import { clothesGearIds, headGearIds, mainWeaponIds, shoesGearIds, subWeaponIds, weaponCategories, } from "~/modules/in-game-lists"; import { nonBombSubWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import { type SerializedMapPoolEvent } from "~/routes/calendar/map-pool-events"; import type { Unpacked } from "~/utils/types"; import { gearImageUrl, mainWeaponImageUrl, subWeaponImageUrl, } from "~/utils/urls"; import { Image } from "./Image"; const MAX_RESULTS_SHOWN = 6; interface ComboboxBaseOption { label: string; value: string; imgPath?: string; } type ComboboxOption = ComboboxBaseOption & T; interface ComboboxProps { options: ComboboxOption[]; inputName: string; placeholder: string; className?: string; id?: string; isLoading?: boolean; required?: boolean; initialValue: ComboboxOption | null; onChange?: (selectedOption: ComboboxOption | null) => void; fullWidth?: boolean; nullable?: true; fuseOptions?: Fuse.IFuseOptions>; } export function Combobox>({ options, inputName, placeholder, initialValue, onChange, required, className, id, nullable, isLoading = false, fullWidth = false, fuseOptions = {}, }: ComboboxProps) { const { t } = useTranslation(); const [selectedOption, setSelectedOption] = React.useState | null>(initialValue); const [query, setQuery] = React.useState(""); const filteredOptions = (() => { if (!query) return []; const fuse = new Fuse(options, { ...fuseOptions, keys: [...Object.keys(options[0] ?? {})], }); return fuse .search(query) .slice(0, MAX_RESULTS_SHOWN) .map((res) => res.item); })(); const noMatches = filteredOptions.length === 0; const displayValue = (option: Unpacked) => { return option?.label ?? ""; }; return (
{ onChange?.(selected); setSelectedOption(selected); }} name={inputName} disabled={!selectedOption && isLoading} // TODO: remove hack that prevents TS from freaking out. probably related: https://github.com/tailwindlabs/headlessui/issues/1895 nullable={nullable as true} > setQuery(event.target.value)} placeholder={isLoading ? t("actions.loading") : placeholder} className={clsx("combobox-input", className, { fullWidth, })} defaultValue={initialValue} displayValue={displayValue} data-testid={`${inputName}-combobox-input`} id={id} required={required} /> {isLoading ? (
{t("actions.loading")}
) : noMatches ? (
{t("forms.errors.noSearchMatches")}{" "} 🤔
) : ( filteredOptions.map((option) => ( {({ active }) => (
  • {option.imgPath && ( )} {option.label}
  • )}
    )) )}
    ); } // Reference for Fuse options: https://fusejs.io/api/options.html const USER_COMBOBOX_FUSE_OPTIONS = { threshold: 0.42, // Empirically determined value to get an exact match for a Discord ID }; export function UserCombobox({ inputName, initialUserId, onChange, userIdsToOmit, className, required, id, }: Pick< ComboboxProps>, "inputName" | "onChange" | "className" | "id" | "required" > & { userIdsToOmit?: Set; initialUserId?: number }) { const { t } = useTranslation(); const { users, isLoading, isError } = useUsers(); const options = React.useMemo(() => { if (!users) return []; const data = userIdsToOmit ? users.filter((user) => !userIdsToOmit.has(user.id)) : users; return data.map((u) => ({ label: u.discordFullName, value: String(u.id), discordId: u.discordId, plusTier: u.plusTier, })); }, [users, userIdsToOmit]); const initialValue = React.useMemo(() => { if (!initialUserId) return; return options.find((o) => o.value === String(initialUserId)); }, [options, initialUserId]); if (isError) { return (
    {t("errors.genericReload")}
    ); } return ( ); } // TODO: [object Object] flickers when server rendered with initialValue export function WeaponCombobox({ id, required, className, inputName, onChange, initialWeaponId, weaponIdsToOmit, fullWidth, nullable, }: Pick< ComboboxProps, | "inputName" | "onChange" | "className" | "id" | "required" | "fullWidth" | "nullable" > & { initialWeaponId?: (typeof mainWeaponIds)[number]; weaponIdsToOmit?: Set; }) { const { t } = useTranslation("weapons"); const idToWeapon = (id: (typeof mainWeaponIds)[number]) => ({ value: String(id), label: t(`MAIN_${id}`), imgPath: mainWeaponImageUrl(id), }); return ( !weaponIdsToOmit?.has(id)) .map(idToWeapon)} initialValue={ typeof initialWeaponId === "number" ? idToWeapon(initialWeaponId) : null } placeholder={t(`MAIN_${weaponCategories[0].weaponIds[0]}`)} onChange={onChange} className={className} id={id} required={required} fullWidth={fullWidth} nullable={nullable} /> ); } export function AllWeaponCombobox({ id, inputName, onChange, fullWidth, }: Pick< ComboboxProps, "inputName" | "onChange" | "id" | "fullWidth" >) { const { t } = useTranslation("weapons"); const options = () => { const result: ComboboxProps< Record >["options"] = []; for (const mainWeaponId of mainWeaponIds) { result.push({ value: `MAIN_${mainWeaponId}`, label: t(`MAIN_${mainWeaponId}`), imgPath: mainWeaponImageUrl(mainWeaponId), }); } for (const subWeaponId of subWeaponIds) { if (nonBombSubWeaponIds.includes(subWeaponId)) continue; result.push({ value: `SUB_${subWeaponId}`, label: t(`SUB_${subWeaponId}`), imgPath: subWeaponImageUrl(subWeaponId), }); } return result; }; return ( ); } export function GearCombobox({ id, required, className, inputName, onChange, gearType, initialGearId, }: Pick< ComboboxProps, "inputName" | "onChange" | "className" | "id" | "required" > & { gearType: GearType; initialGearId?: number }) { const { t } = useTranslation("gear"); const translationPrefix = gearType === "HEAD" ? "H" : gearType === "CLOTHES" ? "C" : "S"; const ids = gearType === "HEAD" ? headGearIds : gearType === "CLOTHES" ? clothesGearIds : shoesGearIds; const idToGear = (id: (typeof ids)[number]) => ({ value: String(id), label: t(`${translationPrefix}_${id}` as any), imgPath: gearImageUrl(gearType, id), }); return ( ); } const mapPoolEventToOption = ( e: SerializedMapPoolEvent ): ComboboxOption> => ({ serializedMapPool: e.serializedMapPool, label: e.name, value: e.id.toString(), }); type MapPoolEventsComboboxProps = Pick< ComboboxProps>, "inputName" | "className" | "id" | "required" > & { initialEvent?: SerializedMapPoolEvent; onChange: (event: SerializedMapPoolEvent | null) => void; }; export function MapPoolEventsCombobox({ id, required, className, inputName, onChange, initialEvent, }: MapPoolEventsComboboxProps) { const { t } = useTranslation(); const { events, isLoading, isError } = useAllEventsWithMapPools(); const options = React.useMemo( () => (events ? events.map(mapPoolEventToOption) : []), [events] ); // this is important so that we don't trigger the reset to the initialEvent every time const initialOption = React.useMemo( () => initialEvent && mapPoolEventToOption(initialEvent), [initialEvent] ); if (isError) { return (
    {t("errors.genericReload")}
    ); } return ( { onChange( e && { id: parseInt(e.value, 10), name: e.label, serializedMapPool: e.serializedMapPool, } ); }} className={className} id={id} required={required} isLoading={isLoading} fullWidth /> ); }