import * as Ariakit from "@ariakit/react"; import { type SelectProps as AriaSelectProps } from "@ariakit/react"; import { SelectValue } from "@ariakit/react-core/select/select-value"; import { Link } from "@remix-run/react"; import * as React from "react"; import { Fragment, useMemo, useState } from "react"; import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys"; import { cn } from "~/utils/cn"; import { ShortcutKey } from "./ShortcutKey"; import { ChevronDown } from "lucide-react"; import { type MatchSorterOptions, matchSorter } from "match-sorter"; const sizes = { small: { button: "h-6 rounded text-xs px-2 ", }, medium: { button: "h-8 rounded px-3 text-sm", }, }; const style = { tertiary: { button: "bg-tertiary focus-custom border border-tertiary hover:text-text-bright hover:border-border-bright", }, minimal: { button: "bg-transparent focus-custom hover:bg-tertiary disabled:bg-transparent disabled:pointer-events-none", }, secondary: { button: "bg-secondary focus-custom border border-border-bright hover:text-text-bright hover:border-border-brighter text-text-bright hover:bg-surface-control", }, }; const variants = { "secondary/small": { button: cn(sizes.small.button, style.secondary.button), }, "tertiary/small": { button: cn(sizes.small.button, style.tertiary.button), }, "tertiary/medium": { button: cn(sizes.medium.button, style.tertiary.button), }, "minimal/small": { button: cn(sizes.small.button, style.minimal.button), }, "minimal/medium": { button: cn(sizes.medium.button, style.minimal.button), }, }; type Variant = keyof typeof variants; type Section = { type: "section"; title?: string; items: TItem[]; }; function isSection(data: TItem[] | Section[]): data is Section[] { const firstItem = data[0]; return ( (firstItem as Section).type === "section" && (firstItem as Section).items !== undefined && Array.isArray((firstItem as Section).items) ); } type ItemFromSection = TItemOrSection extends Section ? U : TItemOrSection; export interface SelectProps extends Omit< Ariakit.SelectProps, "children" > { icon?: React.ReactNode; text?: React.ReactNode | ((value: TValue) => React.ReactNode); placeholder?: React.ReactNode; value?: Ariakit.SelectProviderProps["value"]; setValue?: Ariakit.SelectProviderProps["setValue"]; defaultValue?: Ariakit.SelectProviderProps["defaultValue"]; label?: string | Ariakit.SelectLabelProps["render"]; heading?: string; showHeading?: boolean; items?: TItem[] | Section[]; empty?: React.ReactNode; filter?: | boolean | MatchSorterOptions | ((item: ItemFromSection, search: string, title?: string) => boolean); children: | React.ReactNode | (( items: ItemFromSection[], meta: { shortcutsEnabled?: boolean; section?: { title?: string; startIndex: number; count: number; }; } ) => React.ReactNode); variant?: Variant; open?: boolean; setOpen?: (open: boolean) => void; shortcut?: ShortcutDefinition; tooltipTitle?: string; allowItemShortcuts?: boolean; clearSearchOnSelection?: boolean; dropdownIcon?: boolean | React.ReactNode; popoverClassName?: string; placement?: Ariakit.SelectProviderProps["placement"]; } export function Select({ children, icon, text, placeholder, value, setValue, defaultValue, label, heading, showHeading = false, items, filter, empty = null, variant = "tertiary/small", open, setOpen, shortcut, tooltipTitle, allowItemShortcuts = true, disabled, clearSearchOnSelection = true, dropdownIcon, popoverClassName, placement, ...props }: SelectProps) { const [searchValue, setSearchValue] = useState(""); const searchable = items !== undefined && filter !== undefined; const matches = useMemo(() => { if (!items) return []; if (!searchValue || !filter) return items; if (typeof filter === "function") { if (isSection(items)) { return items .map((section) => ({ ...section, items: section.items.filter((item) => filter(item as ItemFromSection, searchValue, section.title) ), })) .filter((section) => section.items.length > 0); } return items.filter((item) => filter(item as ItemFromSection, searchValue)); } if (typeof filter === "boolean" && filter) { if (isSection(items)) { return items .map((section) => ({ ...section, items: matchSorter(section.items, searchValue), })) .filter((section) => section.items.length > 0); } return matchSorter(items, searchValue); } if (isSection(items)) { return items .map((section) => ({ ...section, items: matchSorter(section.items, searchValue, filter), })) .filter((section) => section.items.length > 0); } return matchSorter(items, searchValue, filter); }, [searchValue, items]); const enableItemShortcuts = allowItemShortcuts && matches.length === items?.length; const select = ( { if (clearSearchOnSelection) { setSearchValue(""); } if (setValue) { setValue(v as any); } }} defaultValue={defaultValue} > {label && {label} : label} />} {!searchable && showHeading && heading && {heading}} />} {searchable && } {typeof children === "function" ? ( matches.length > 0 ? ( isSection(matches) ? ( {children} ) : ( children(matches as ItemFromSection[], { shortcutsEnabled: enableItemShortcuts, }) ) ) : ( empty ) ) : ( children )} ); if (searchable) { return ( { React.startTransition(() => { setSearchValue(value); }); }} > {select} ); } return select; } export interface SelectTriggerProps extends AriaSelectProps { icon?: React.ReactNode; text?: React.ReactNode | ((value: TValue) => React.ReactNode); placeholder?: React.ReactNode; variant?: Variant; shortcut?: ShortcutDefinition; tooltipTitle?: string; dropdownIcon?: boolean | React.ReactNode; } export function SelectTrigger({ icon, variant = "tertiary/small", text, shortcut, tooltipTitle, disabled, placeholder, dropdownIcon = false, children, className, ...props }: SelectTriggerProps) { const ref = React.useRef(null); useShortcutKeys({ shortcut: shortcut, action: (e) => { e.preventDefault(); e.stopPropagation(); if (ref.current) { ref.current.click(); } }, disabled, }); const showTooltip = tooltipTitle || shortcut; const variantClasses = variants[variant]; let content: React.ReactNode = ""; if (children) { content = children; } else if (text !== undefined) { if (typeof text === "function") { content = {(value) => <>{text(value) ?? placeholder}}; } else { content = text; } } else { content = ( {(value) => ( <> {typeof value === "string" ? (value ?? placeholder) : value.length === 0 ? placeholder : value.join(", ")} )} ); } return ( } >
{icon &&
{icon}
}
{content}
{dropdownIcon === true ? ( ) : !dropdownIcon ? null : ( dropdownIcon )}
{showTooltip && (
{tooltipTitle ?? "Open menu"} {shortcut && ( )}
)}
); } export interface SelectProviderProps< TValue extends string | string[], > extends Ariakit.SelectProviderProps {} export function SelectProvider( props: SelectProviderProps ) { return ; } export interface ComboboxProviderProps extends Ariakit.ComboboxProviderProps {} export function ComboboxProvider(props: ComboboxProviderProps) { return ; } function SelectGroupedRenderer({ items, children, enableItemShortcuts, }: { items: Section[]; children: ( items: ItemFromSection[], meta: { shortcutsEnabled?: boolean; section?: { title?: string; startIndex: number; count: number }; } ) => React.ReactNode; enableItemShortcuts: boolean; }) { let count = 0; return ( <> {items.map((section, index) => { const previousItem = items.at(index - 1); count += previousItem ? previousItem.items.length : 0; return ( {children(section.items as ItemFromSection[], { shortcutsEnabled: enableItemShortcuts, section: { title: section.title, startIndex: count - 1, count: section.items.length, }, })} ); })} ); } export interface SelectListProps extends Omit {} export function SelectList(props: SelectListProps) { const combobox = Ariakit.useComboboxContext(); const Component = combobox ? Ariakit.ComboboxList : Ariakit.SelectList; return ( ); } export interface SelectItemProps extends Ariakit.SelectItemProps { icon?: React.ReactNode; checkIcon?: React.ReactNode; checkPosition?: "left" | "right"; shortcut?: ShortcutDefinition; // Allow the item to grow to multiple lines and wrap its content instead of // being locked to a single truncated line. Use for options with a subtitle. wrap?: boolean; } const selectItemClasses = "group cursor-pointer px-1 pt-1 text-2sm text-text-dimmed focus-custom last:pb-1"; import { CheckboxIndicator } from "./CheckboxIndicator"; export function SelectItem({ icon, checkIcon = , checkPosition = "right", shortcut, wrap = false, ...props }: SelectItemProps) { const combobox = Ariakit.useComboboxContext(); // In a Combobox context we wrap the caller's render in ComboboxItem // so combobox keyboard nav still works. Outside a Combobox we pass // the render through verbatim — without this, callers like // SelectLinkItem (which uses render to swap in a ) get their // render prop silently dropped, which is why those rows looked // clickable but didn't navigate. const render = combobox ? : props.render; const ref = React.useRef(null); const select = Ariakit.useSelectContext(); const selectValue = select?.useState("value"); const isChecked = React.useMemo(() => { if (!props.value || selectValue == null) return false; if (Array.isArray(selectValue)) return selectValue.includes(props.value); return selectValue === props.value; }, [selectValue, props.value]); useShortcutKeys({ shortcut: shortcut, action: (e) => { e.preventDefault(); e.stopPropagation(); if (ref.current) { ref.current.click(); } }, disabled: props.disabled, enabledOnInputElements: true, }); return (
{checkPosition === "left" && } {icon}
{props.children || props.value}
{checkPosition === "right" && checkIcon} {shortcut && ( )}
); } export interface SelectLinkItemProps extends Ariakit.SelectItemProps { icon?: React.ReactNode; checkIcon?: React.ReactNode; shortcut?: ShortcutDefinition; to: string; } export function SelectLinkItem({ checkIcon = , to, ...props }: SelectLinkItemProps) { const render = ; return ( ); } export interface SelectButtonItemProps extends Omit { icon?: React.ReactNode; checkIcon?: React.ReactNode; shortcut?: ShortcutDefinition; onClick: React.ComponentProps<"button">["onClick"]; } export function SelectButtonItem({ checkIcon = , onClick, ...props }: SelectButtonItemProps) { const render = (