mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
240 lines
6.0 KiB
TypeScript
240 lines
6.0 KiB
TypeScript
import clsx from "clsx";
|
|
import { format, sub } from "date-fns";
|
|
import * as React from "react";
|
|
import {
|
|
Autocomplete,
|
|
Button,
|
|
Input,
|
|
type Key,
|
|
ListBox,
|
|
ListBoxItem,
|
|
Popover,
|
|
SearchField,
|
|
Select,
|
|
type SelectProps,
|
|
SelectValue,
|
|
} from "react-aria-components";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useFetcher } from "react-router";
|
|
import { useDebounce } from "react-use";
|
|
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
|
|
import { SendouLabel } from "~/components/elements/Label";
|
|
import { ChevronUpDownIcon } from "~/components/icons/ChevronUpDown";
|
|
import { CrossIcon } from "~/components/icons/Cross";
|
|
import type { TournamentSearchLoaderData } from "~/features/tournament/routes/to.search";
|
|
import { databaseTimestampToDate } from "~/utils/dates";
|
|
import { SearchIcon } from "../icons/Search";
|
|
|
|
import selectStyles from "./Select.module.css";
|
|
import tournamentSearchStyles from "./TournamentSearch.module.css";
|
|
|
|
type TournamentSearchItem = NonNullable<
|
|
Extract<TournamentSearchLoaderData, { tournaments: unknown }>
|
|
>["tournaments"][number];
|
|
|
|
interface TournamentSearchProps<T extends object>
|
|
extends Omit<SelectProps<T>, "children" | "onChange"> {
|
|
name?: string;
|
|
label?: string;
|
|
bottomText?: string;
|
|
errorText?: string;
|
|
initialTournamentId?: number;
|
|
onChange?: (tournament: TournamentSearchItem | null) => void;
|
|
}
|
|
|
|
export const TournamentSearch = React.forwardRef(function TournamentSearch<
|
|
T extends object,
|
|
>(
|
|
{
|
|
name,
|
|
label,
|
|
bottomText,
|
|
errorText,
|
|
initialTournamentId,
|
|
onChange,
|
|
...rest
|
|
}: TournamentSearchProps<T>,
|
|
ref?: React.Ref<HTMLButtonElement>,
|
|
) {
|
|
const [selectedKey, setSelectedKey] = React.useState(
|
|
initialTournamentId ?? null,
|
|
);
|
|
const list = useTournamentSearch(setSelectedKey);
|
|
|
|
const onSelectionChange = (tournamentId: number) => {
|
|
setSelectedKey(tournamentId);
|
|
const tournament = list.items.find(
|
|
(tournament) =>
|
|
typeof tournament.id === "number" && tournament.id === tournamentId,
|
|
);
|
|
if (tournament && typeof tournament.id === "number") {
|
|
onChange?.(tournament as TournamentSearchItem);
|
|
}
|
|
};
|
|
|
|
// clear if selected user is not in the new filtered items
|
|
React.useEffect(() => {
|
|
if (
|
|
selectedKey &&
|
|
selectedKey !== initialTournamentId &&
|
|
!list.items.some(
|
|
(tournament) =>
|
|
typeof tournament.id === "number" && tournament.id === selectedKey,
|
|
)
|
|
) {
|
|
setSelectedKey(null);
|
|
onChange?.(null);
|
|
}
|
|
}, [list.items, selectedKey, onChange, initialTournamentId]);
|
|
|
|
return (
|
|
<Select
|
|
name={name}
|
|
placeholder=""
|
|
selectedKey={selectedKey}
|
|
onSelectionChange={onSelectionChange as (key: Key | null) => void}
|
|
aria-label="Tournament search"
|
|
{...rest}
|
|
>
|
|
{label ? (
|
|
<SendouLabel required={rest.isRequired}>{label}</SendouLabel>
|
|
) : null}
|
|
<Button className={selectStyles.button} ref={ref}>
|
|
<SelectValue className={tournamentSearchStyles.selectValue} />
|
|
<span aria-hidden="true">
|
|
<ChevronUpDownIcon className={selectStyles.icon} />
|
|
</span>
|
|
</Button>
|
|
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
|
|
<Popover
|
|
className={clsx(selectStyles.popover, tournamentSearchStyles.popover)}
|
|
>
|
|
<Autocomplete
|
|
inputValue={list.filterText}
|
|
onInputChange={list.setFilterText}
|
|
>
|
|
<SearchField
|
|
aria-label="Search"
|
|
autoFocus
|
|
className={selectStyles.searchField}
|
|
>
|
|
<SearchIcon aria-hidden className={selectStyles.smallIcon} />
|
|
<Input
|
|
className={clsx("plain", selectStyles.searchInput)}
|
|
data-testid="tournament-search-input"
|
|
/>
|
|
<Button className={selectStyles.searchClearButton}>
|
|
<CrossIcon className={selectStyles.smallIcon} />
|
|
</Button>
|
|
</SearchField>
|
|
<ListBox
|
|
items={list.items.filter((tournament) => tournament !== undefined)}
|
|
className={selectStyles.listBox}
|
|
>
|
|
{(item) => <TournamentItem item={item as TournamentSearchItem} />}
|
|
</ListBox>
|
|
</Autocomplete>
|
|
</Popover>
|
|
</Select>
|
|
);
|
|
});
|
|
|
|
function TournamentItem({
|
|
item,
|
|
}: {
|
|
item:
|
|
| TournamentSearchItem
|
|
| {
|
|
id: "NO_RESULTS";
|
|
}
|
|
| {
|
|
id: "PLACEHOLDER";
|
|
};
|
|
}) {
|
|
const { t } = useTranslation(["common"]);
|
|
|
|
if (typeof item.id === "string") {
|
|
return (
|
|
<ListBoxItem
|
|
textValue="PLACEHOLDER"
|
|
isDisabled
|
|
className={tournamentSearchStyles.placeholder}
|
|
>
|
|
{item.id === "PLACEHOLDER"
|
|
? t("common:forms.tournamentSearch.placeholder")
|
|
: t("common:forms.tournamentSearch.noResults")}
|
|
</ListBoxItem>
|
|
);
|
|
}
|
|
|
|
const additionalText = () => {
|
|
const date = databaseTimestampToDate(item.startTime);
|
|
return format(date, "MMM d, yyyy");
|
|
};
|
|
|
|
return (
|
|
<ListBoxItem
|
|
id={item.id}
|
|
textValue={item.name}
|
|
className={({ isFocused, isSelected }) =>
|
|
clsx(tournamentSearchStyles.item, {
|
|
[selectStyles.itemFocused]: isFocused,
|
|
[selectStyles.itemSelected]: isSelected,
|
|
})
|
|
}
|
|
data-testid="tournament-search-item"
|
|
>
|
|
<img src={item.logoUrl} alt="" className={tournamentSearchStyles.logo} />
|
|
<div className={tournamentSearchStyles.itemTextsContainer}>
|
|
<span>{item.name}</span>
|
|
{additionalText() ? (
|
|
<div className={tournamentSearchStyles.itemAdditionalText}>
|
|
{additionalText()}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</ListBoxItem>
|
|
);
|
|
}
|
|
|
|
function useTournamentSearch(
|
|
setSelectedKey: (tournamentId: number | null) => void,
|
|
) {
|
|
const [filterText, setFilterText] = React.useState("");
|
|
|
|
const queryFetcher = useFetcher<TournamentSearchLoaderData>();
|
|
|
|
useDebounce(
|
|
() => {
|
|
if (!filterText) return;
|
|
queryFetcher.load(
|
|
`/to/search?q=${filterText}&limit=6&minStartTime=${sub(new Date(), { days: 7 }).toISOString()}`,
|
|
);
|
|
setSelectedKey(null);
|
|
},
|
|
500,
|
|
[filterText],
|
|
);
|
|
|
|
const items = () => {
|
|
if (
|
|
queryFetcher.data &&
|
|
!Array.isArray(queryFetcher.data) &&
|
|
queryFetcher.data.query === filterText
|
|
) {
|
|
if (queryFetcher.data.tournaments.length === 0) {
|
|
return [{ id: "NO_RESULTS" }];
|
|
}
|
|
return queryFetcher.data.tournaments;
|
|
}
|
|
|
|
return [{ id: "PLACEHOLDER" }];
|
|
};
|
|
|
|
return {
|
|
filterText,
|
|
setFilterText,
|
|
items: items(),
|
|
};
|
|
}
|