sendou.ink/app/components/elements/Select.tsx

208 lines
5.1 KiB
TypeScript

import clsx from "clsx";
import * as React from "react";
import type { ListBoxItemProps, SelectProps } from "react-aria-components";
import {
Autocomplete,
Button,
Header,
Input,
Label,
ListBox,
ListBoxItem,
ListBoxSection,
ListLayout,
Popover,
SearchField,
Select,
SelectStateContext,
SelectValue,
useFilter,
Virtualizer,
} from "react-aria-components";
import { useTranslation } from "react-i18next";
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
import { SendouButton } from "~/components/elements/Button";
import { ChevronUpDownIcon } from "~/components/icons/ChevronUpDown";
import { Image } from "../Image";
import { CrossIcon } from "../icons/Cross";
import { SearchIcon } from "../icons/Search";
import styles from "./Select.module.css";
export interface SendouSelectProps<T extends object>
extends Omit<SelectProps<T>, "children"> {
label?: string;
description?: string;
errorText?: string;
bottomText?: string;
items?: Iterable<T>;
children: React.ReactNode | ((item: T) => React.ReactNode);
search?: {
placeholder?: string;
};
popoverClassName?: string;
/** Value of the search input, used for controlled components */
searchInputValue?: string;
/** Callback for when the search input value changes. When defined `items` has to be filtered on the caller side (automatic filtering in component disabled). */
onSearchInputChange?: (value: string) => void;
clearable?: boolean;
}
/**
* A customizable select component with optional search functionality. Virtualizes the list of items for performance.
*
* @example
* ```tsx
* <SendouSelect items={items} search={{ placeholder: "Search for items..." }}>
* {({ key, ...item }) => (
* <SendouSelectItem key={key} {...item}>
* {item.name}
* </SendouSelectItem>
* )}
* </SendouSelect>
* ```
*/
export function SendouSelect<T extends object>({
label,
description,
errorText,
bottomText,
children,
items,
search,
popoverClassName,
searchInputValue,
onSearchInputChange,
clearable = false,
className,
...props
}: SendouSelectProps<T>) {
const { t } = useTranslation(["common"]);
const { contains } = useFilter({ sensitivity: "base" });
const isControlled = !!onSearchInputChange;
const handleOpenChange = (isOpen: boolean) => {
if (!isControlled) return;
if (!isOpen) {
onSearchInputChange("");
}
};
return (
<Select
{...props}
className={clsx(className, styles.select)}
onOpenChange={handleOpenChange}
>
{label ? <Label>{label}</Label> : null}
<Button className={styles.button}>
<SelectValue className={styles.selectValue} />
<span aria-hidden="true">
<ChevronUpDownIcon className={styles.icon} />
</span>
</Button>
{clearable ? <SelectClearButton /> : null}
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
<Popover className={clsx(popoverClassName, styles.popover)}>
<Autocomplete
filter={isControlled ? undefined : contains}
inputValue={searchInputValue}
onInputChange={onSearchInputChange}
>
{search ? (
<SearchField
aria-label="Search"
autoFocus
className={styles.searchField}
>
<SearchIcon aria-hidden className={styles.smallIcon} />
<Input
placeholder={search.placeholder}
className={clsx("plain", styles.searchInput)}
/>
<Button className={styles.searchClearButton}>
<CrossIcon className={styles.smallIcon} />
</Button>
</SearchField>
) : null}
<Virtualizer layout={ListLayout} layoutOptions={{ rowHeight: 33 }}>
<ListBox
items={items}
className={styles.listBox}
renderEmptyState={() => (
<div className={styles.noResults}>{t("common:noResults")}</div>
)}
>
{children}
</ListBox>
</Virtualizer>
</Autocomplete>
</Popover>
</Select>
);
}
interface SendouSelectItemProps extends ListBoxItemProps {}
export function SendouSelectItem(props: SendouSelectItemProps) {
return (
<ListBoxItem
{...props}
className={({ isFocused, isSelected }) =>
clsx(styles.item, {
[styles.itemFocused]: isFocused,
[styles.itemSelected]: isSelected,
})
}
/>
);
}
interface SendouSelectItemSectionProps {
heading: string;
headingImgPath?: string;
children: React.ReactNode;
className?: string;
}
export function SendouSelectItemSection({
heading,
headingImgPath,
children,
className,
}: SendouSelectItemSectionProps) {
return (
<ListBoxSection>
<Header className={clsx(className, styles.categoryHeading)}>
{headingImgPath ? (
<Image path={headingImgPath} size={28} alt="" />
) : null}
{heading}
<div className={styles.categoryDivider} />
</Header>
{children}
</ListBoxSection>
);
}
function SelectClearButton() {
const state = React.useContext(SelectStateContext);
if (!state?.selectedKey) return null;
return (
<SendouButton
// Don't inherit behavior from Select.
slot={null}
variant="minimal-destructive"
size="miniscule"
icon={<CrossIcon />}
onPress={() => state?.setSelectedKey(null)}
className={styles.clearButton}
>
Clear
</SendouButton>
);
}