Files
triggerdotdev--trigger.dev/apps/webapp/app/components/primitives/Select.tsx
T
Chris Arderne 7ea02716fc fix(webapp): avoid mutating render inputs (#4716)
## Summary

Keeps render inputs and shared regular expressions immutable. Grouped
selects now compute each section's shortcut offset directly from
preceding sections, which also makes numeric shortcuts follow the
displayed item order reliably.
2026-08-20 09:59:39 +01:00

719 lines
20 KiB
TypeScript

import * as Ariakit from "@ariakit/react";
import { type SelectProps as AriaSelectProps } from "@ariakit/react";
import { SelectValue } from "@ariakit/react-core/select/select-value";
import { useStoreState } from "@ariakit/react-core/utils/store";
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/50 shadow-xs hover:text-text-bright text-text-bright hover:bg-background-raised",
},
};
const variants = {
"secondary/small": {
button: cn(sizes.small.button, style.secondary.button),
},
"secondary/medium": {
button: cn(sizes.medium.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<TItem> = {
type: "section";
title?: string;
items: TItem[];
};
function isSection<TItem>(data: TItem[] | Section<TItem>[]): data is Section<TItem>[] {
const firstItem = data[0];
if (!firstItem) return false;
return (
(firstItem as Section<TItem>).type === "section" &&
(firstItem as Section<TItem>).items !== undefined &&
Array.isArray((firstItem as Section<TItem>).items)
);
}
type ItemFromSection<TItemOrSection> = TItemOrSection extends Section<infer U> ? U : TItemOrSection;
export interface SelectProps<TValue extends string | string[], TItem> extends Omit<
Ariakit.SelectProps,
"children"
> {
icon?: React.ReactNode;
text?: React.ReactNode | ((value: TValue) => React.ReactNode);
placeholder?: React.ReactNode;
value?: Ariakit.SelectProviderProps<TValue>["value"];
setValue?: Ariakit.SelectProviderProps<TValue>["setValue"];
defaultValue?: Ariakit.SelectProviderProps<TValue>["defaultValue"];
label?: string | Ariakit.SelectLabelProps["render"];
heading?: string;
showHeading?: boolean;
items?: TItem[] | Section<TItem>[];
empty?: React.ReactNode;
filter?:
| boolean
| MatchSorterOptions<TItem>
| ((item: ItemFromSection<TItem>, search: string, title?: string) => boolean);
children:
| React.ReactNode
| ((
items: ItemFromSection<TItem>[],
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<TValue>["placement"];
}
export function Select<TValue extends string | string[], TItem>({
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<TValue, TItem>) {
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<TItem>, searchValue, section.title)
),
}))
.filter((section) => section.items.length > 0);
}
return items.filter((item) => filter(item as ItemFromSection<TItem>, 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, filter]);
const enableItemShortcuts = allowItemShortcuts && matches.length === items?.length;
const select = (
<SelectProvider
open={open}
setOpen={setOpen}
virtualFocus={searchable}
placement={placement}
value={value}
setValue={(v) => {
if (clearSearchOnSelection) {
setSearchValue("");
}
if (setValue) {
setValue(v as any);
}
}}
defaultValue={defaultValue}
>
{label && <SelectLabel render={typeof label === "string" ? <div>{label}</div> : label} />}
<SelectTrigger
icon={icon}
variant={variant}
text={text}
placeholder={placeholder}
shortcut={shortcut}
tooltipTitle={tooltipTitle}
disabled={disabled}
dropdownIcon={dropdownIcon}
{...props}
/>
<SelectPopover className={popoverClassName}>
{!searchable && showHeading && heading && <SelectHeading render={<span>{heading}</span>} />}
{searchable && <ComboBox placeholder={heading} shortcut={shortcut} value={searchValue} />}
<SelectList>
{typeof children === "function" ? (
matches.length > 0 ? (
isSection(matches) ? (
<SelectGroupedRenderer items={matches} enableItemShortcuts={enableItemShortcuts}>
{children}
</SelectGroupedRenderer>
) : (
children(matches as ItemFromSection<TItem>[], {
shortcutsEnabled: enableItemShortcuts,
})
)
) : (
empty
)
) : (
children
)}
</SelectList>
</SelectPopover>
</SelectProvider>
);
if (searchable) {
return (
<ComboboxProvider
resetValueOnHide
setValue={(value) => {
React.startTransition(() => {
setSearchValue(value);
});
}}
>
{select}
</ComboboxProvider>
);
}
return select;
}
export interface SelectTriggerProps<TValue = any> 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<HTMLButtonElement>(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 = <SelectValue>{(value) => text(value) ?? placeholder}</SelectValue>;
} else {
content = text;
}
} else {
content = (
<SelectValue>
{(value) =>
typeof value === "string"
? (value ?? placeholder)
: value.length === 0
? placeholder
: value.join(", ")
}
</SelectValue>
);
}
return (
<Ariakit.TooltipProvider timeout={200}>
<Ariakit.TooltipAnchor
className="button"
render={
<Ariakit.Select
className={cn(
"group flex items-center gap-1 focus-custom disabled:cursor-not-allowed disabled:opacity-50",
variantClasses.button,
className
)}
ref={ref}
disabled={disabled}
{...props}
/>
}
>
<div className="flex min-w-0 grow items-center gap-0.5 overflow-hidden">
{icon && <div className="flex-none">{icon}</div>}
<div className="min-w-0 truncate">{content}</div>
</div>
{dropdownIcon === true ? (
<ChevronDown
className={cn(
"size-4 flex-none text-text-dimmed transition group-hover:text-text-bright group-focus:text-text-bright"
)}
/>
) : !dropdownIcon ? null : (
dropdownIcon
)}
</Ariakit.TooltipAnchor>
{showTooltip && (
<Ariakit.Tooltip
disabled={!tooltipTitle && !shortcut}
className="z-40 cursor-default rounded border border-grid-bright bg-background-bright px-2 py-1.5 text-xs"
>
<div className="flex items-center gap-2">
<span>{tooltipTitle ?? "Open menu"}</span>
{shortcut && (
<ShortcutKey
className={cn("size-4 flex-none")}
shortcut={shortcut}
variant={"small"}
/>
)}
</div>
</Ariakit.Tooltip>
)}
</Ariakit.TooltipProvider>
);
}
export interface SelectProviderProps<
TValue extends string | string[],
> extends Ariakit.SelectProviderProps<TValue> {}
export function SelectProvider<TValue extends string | string[]>(
props: SelectProviderProps<TValue>
) {
return <Ariakit.SelectProvider {...props} />;
}
export interface ComboboxProviderProps extends Ariakit.ComboboxProviderProps {}
export function ComboboxProvider(props: ComboboxProviderProps) {
return <Ariakit.ComboboxProvider {...props} />;
}
function SelectGroupedRenderer<TItem>({
items,
children,
enableItemShortcuts,
}: {
items: Section<TItem>[];
children: (
items: ItemFromSection<TItem>[],
meta: {
shortcutsEnabled?: boolean;
section?: { title?: string; startIndex: number; count: number };
}
) => React.ReactNode;
enableItemShortcuts: boolean;
}) {
return (
<>
{items.map((section, index) => {
const startIndex = items
.slice(0, index)
.reduce((count, previousSection) => count + previousSection.items.length, 0);
return (
<Fragment key={index}>
{children(section.items as ItemFromSection<TItem>[], {
shortcutsEnabled: enableItemShortcuts,
section: {
title: section.title,
startIndex,
count: section.items.length,
},
})}
</Fragment>
);
})}
</>
);
}
export interface SelectListProps extends Omit<Ariakit.SelectListProps, "store"> {}
export function SelectList(props: SelectListProps) {
const combobox = Ariakit.useComboboxContext();
const Component = combobox ? Ariakit.ComboboxList : Ariakit.SelectList;
return (
<Component
{...props}
className={cn(
"overflow-y-auto overflow-x-hidden overscroll-contain scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control focus-custom",
props.className
)}
/>
);
}
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 = <Ariakit.SelectItemCheck className="size-8 flex-none text-text-bright" />,
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 <Link>) get their
// render prop silently dropped, which is why those rows looked
// clickable but didn't navigate.
const render = combobox ? <Ariakit.ComboboxItem render={props.render} /> : props.render;
const ref = React.useRef<HTMLDivElement>(null);
const select = Ariakit.useSelectContext();
const selectValue = useStoreState(select, "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 (
<Ariakit.SelectItem
{...props}
render={render}
blurOnHoverEnd={false}
className={cn(
selectItemClasses,
"[--padding-block:0.5rem] sm:[--padding-block:0.25rem]",
props.className
)}
ref={ref}
>
<div
className={cn(
"flex w-full items-center rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary",
wrap ? "min-h-8" : "h-8",
checkPosition === "left" ? "gap-2" : "gap-1"
)}
>
{checkPosition === "left" && <CheckboxIndicator checked={isChecked} />}
{icon}
<div className={cn("grow", wrap ? "min-w-0 break-words py-1.5" : "truncate")}>
{props.children || props.value}
</div>
{checkPosition === "right" && checkIcon}
{shortcut && (
<ShortcutKey
className={cn(
"size-4 flex-none transition duration-0 group-hover:border-border-bright"
)}
shortcut={shortcut}
variant={"small"}
/>
)}
</div>
</Ariakit.SelectItem>
);
}
export interface SelectLinkItemProps extends Ariakit.SelectItemProps {
icon?: React.ReactNode;
checkIcon?: React.ReactNode;
shortcut?: ShortcutDefinition;
to: string;
}
export function SelectLinkItem({
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-white" />,
to,
...props
}: SelectLinkItemProps) {
const render = <Link to={to} className={cn("block", selectItemClasses, props.className)} />;
return (
<SelectItem
{...props}
render={render}
blurOnHoverEnd={false}
className={cn(selectItemClasses, props.className)}
/>
);
}
export interface SelectButtonItemProps extends Omit<Ariakit.SelectItemProps, "onClick"> {
icon?: React.ReactNode;
checkIcon?: React.ReactNode;
shortcut?: ShortcutDefinition;
accessibleLabel: string;
onClick: React.ComponentProps<"button">["onClick"];
}
export function SelectButtonItem({
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-white" />,
accessibleLabel,
onClick,
...props
}: SelectButtonItemProps) {
const render = (
<button
type="button"
aria-label={accessibleLabel}
onClick={onClick}
className={cn("block w-full text-left", selectItemClasses, props.className)}
/>
);
return (
<SelectItem
{...props}
render={render}
blurOnHoverEnd={false}
className={cn(selectItemClasses, props.className)}
/>
);
}
export function shortcutFromIndex(
index: number,
meta: {
shortcutsEnabled?: boolean;
section?: { startIndex: number };
}
): ShortcutDefinition | undefined {
if (!meta.shortcutsEnabled) return;
let adjustedIndex = index + (meta.section?.startIndex ?? 0);
if (adjustedIndex > 9) return;
if (adjustedIndex === 9) {
adjustedIndex = -1;
}
return { key: String(adjustedIndex + 1) };
}
export interface SelectGroupProps extends Ariakit.SelectGroupProps {}
export function SelectGroup(props: SelectGroupProps) {
return <Ariakit.SelectGroup {...props} />;
}
export interface SelectGroupLabelProps extends Ariakit.SelectGroupLabelProps {}
export function SelectGroupLabel(props: SelectGroupLabelProps) {
return (
<Ariakit.SelectGroupLabel
{...props}
className={cn(
"flex h-5.5 items-center border-b border-grid-bright bg-background-hover px-2.5 text-xxs uppercase text-text-bright",
props.className
)}
/>
);
}
interface SelectHeadingProps extends Ariakit.SelectHeadingProps {}
function SelectHeading({ render, ...props }: SelectHeadingProps) {
return (
<div className="flex h-5.5 flex-none cursor-default items-center gap-2 border-b border-grid-bright bg-background-hover px-2.5 text-xxs uppercase text-text-bright">
<Ariakit.SelectHeading render={render} />
</div>
);
}
export interface SelectPopoverProps extends Ariakit.SelectPopoverProps {}
export function SelectPopover({
gutter = 5,
shift = 0,
unmountOnHide = true,
className,
...props
}: SelectPopoverProps) {
return (
<Ariakit.SelectPopover
gutter={gutter}
shift={shift}
unmountOnHide={unmountOnHide}
className={cn(
"z-50 flex flex-col overflow-clip rounded border border-grid-bright bg-background-bright shadow-md outline-hidden animate-in fade-in-40",
"min-w-[max(180px,var(--popover-anchor-width))]",
"max-w-[min(480px,var(--popover-available-width))]",
"max-h-[min(600px,var(--popover-available-height))]",
"origin-(--popover-transform-origin)",
className
)}
{...props}
/>
);
}
interface SelectLabelProps extends Ariakit.SelectLabelProps {}
//currently unstyled
function SelectLabel(props: SelectLabelProps) {
return <Ariakit.SelectLabel {...props} />;
}
export interface ComboBoxProps extends Ariakit.ComboboxProps {
shortcut?: ShortcutDefinition;
}
export function ComboBox({
autoSelect = true,
placeholder = "Filter options",
shortcut,
...props
}: ComboBoxProps) {
const combobox = Ariakit.useComboboxContext();
const open = useStoreState(combobox, "open");
const input = useStoreState(combobox, "baseElement");
React.useEffect(() => {
if (!open || !input) return;
input.focus();
}, [open, input]);
return (
<div className="flex h-9 w-full flex-none items-center border-b border-grid-dimmed bg-transparent pl-0 pr-3 text-xs text-text-dimmed outline-hidden">
<Ariakit.Combobox
autoSelect={autoSelect}
render={<input placeholder={placeholder} />}
className="flex-1 border-0 bg-transparent text-xs text-text-dimmed outline-hidden focus:border-0 focus:ring-0"
{...props}
/>
{shortcut && (
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
)}
</div>
);
}