Files
helix-engage/src/components/base/select/select.tsx
2026-03-23 16:41:58 +05:30

147 lines
5.9 KiB
TypeScript

import type { FC, ReactNode, Ref, RefAttributes } from "react";
import { createContext, isValidElement } from "react";
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { SelectProps as AriaSelectProps } from "react-aria-components";
import { Button as AriaButton, ListBox as AriaListBox, Select as AriaSelect, SelectValue as AriaSelectValue } from "react-aria-components";
import { Avatar } from "@/components/base/avatar/avatar";
import { HintText } from "@/components/base/input/hint-text";
import { Label } from "@/components/base/input/label";
import { cx } from "@/utils/cx";
import { isReactComponent } from "@/utils/is-react-component";
import { ComboBox } from "./combobox";
import { Popover } from "./popover";
import { SelectItem } from "./select-item";
export type SelectItemType = {
id: string;
label?: string;
avatarUrl?: string;
isDisabled?: boolean;
supportingText?: string;
icon?: FC | ReactNode;
};
export interface CommonProps {
hint?: string;
label?: string;
tooltip?: string;
size?: "sm" | "md";
placeholder?: string;
}
interface SelectProps extends Omit<AriaSelectProps<SelectItemType>, "children" | "items">, RefAttributes<HTMLDivElement>, CommonProps {
items?: SelectItemType[];
popoverClassName?: string;
placeholderIcon?: FC | ReactNode;
children: ReactNode | ((item: SelectItemType) => ReactNode);
}
interface SelectValueProps {
isOpen: boolean;
size: "sm" | "md";
isFocused: boolean;
isDisabled: boolean;
placeholder?: string;
ref?: Ref<HTMLButtonElement>;
placeholderIcon?: FC | ReactNode;
}
export const sizes = {
sm: { root: "py-2 px-3", shortcut: "pr-2.5" },
md: { root: "py-2.5 px-3.5", shortcut: "pr-3" },
};
const SelectValue = ({ isOpen, isFocused, isDisabled, size, placeholder, placeholderIcon, ref }: SelectValueProps) => {
return (
<AriaButton
ref={ref}
className={cx(
"relative flex w-full cursor-pointer items-center rounded-lg bg-primary shadow-xs ring-1 ring-primary outline-hidden transition duration-100 ease-linear ring-inset",
(isFocused || isOpen) && "ring-2 ring-brand",
isDisabled && "cursor-not-allowed bg-disabled_subtle text-disabled",
)}
>
<AriaSelectValue<SelectItemType>
className={cx(
"flex h-max w-full items-center justify-start gap-2 truncate text-left align-middle",
// Icon styles
"*:data-icon:size-5 *:data-icon:shrink-0 *:data-icon:text-fg-quaternary in-disabled:*:data-icon:text-fg-disabled",
sizes[size].root,
)}
>
{(state) => {
const Icon = state.selectedItem?.icon || placeholderIcon;
return (
<>
{state.selectedItem?.avatarUrl ? (
<Avatar size="xs" src={state.selectedItem.avatarUrl} alt={state.selectedItem.label} />
) : isReactComponent(Icon) ? (
<Icon data-icon aria-hidden="true" />
) : isValidElement(Icon) ? (
Icon
) : null}
{state.selectedItem ? (
<section className="flex w-full gap-2 truncate">
<p className="truncate text-md font-medium text-primary">{state.selectedItem?.label}</p>
{state.selectedItem?.supportingText && <p className="text-md text-tertiary">{state.selectedItem?.supportingText}</p>}
</section>
) : (
<p className={cx("text-md text-placeholder", isDisabled && "text-disabled")}>{placeholder}</p>
)}
<FontAwesomeIcon
icon={faChevronDown}
aria-hidden="true"
className={cx("ml-auto shrink-0 text-fg-quaternary", size === "sm" ? "size-4" : "size-5")}
/>
</>
);
}}
</AriaSelectValue>
</AriaButton>
);
};
export const SelectContext = createContext<{ size: "sm" | "md" }>({ size: "sm" });
const Select = ({ placeholder = "Select", placeholderIcon, size = "sm", children, items, label, hint, tooltip, className, ...rest }: SelectProps) => {
return (
<SelectContext.Provider value={{ size }}>
<AriaSelect {...rest} className={(state) => cx("flex flex-col gap-1.5", typeof className === "function" ? className(state) : className)}>
{(state) => (
<>
{label && (
<Label isRequired={state.isRequired} tooltip={tooltip}>
{label}
</Label>
)}
<SelectValue {...state} {...{ size, placeholder }} placeholderIcon={placeholderIcon} />
<Popover size={size} className={rest.popoverClassName}>
<AriaListBox items={items} className="size-full outline-hidden">
{children}
</AriaListBox>
</Popover>
{hint && <HintText isInvalid={state.isInvalid}>{hint}</HintText>}
</>
)}
</AriaSelect>
</SelectContext.Provider>
);
};
const _Select = Select as typeof Select & {
ComboBox: typeof ComboBox;
Item: typeof SelectItem;
};
_Select.ComboBox = ComboBox;
_Select.Item = SelectItem;
export { _Select as Select };