chore: initial Untitled UI Vite scaffold with FontAwesome Pro

This commit is contained in:
2026-03-16 14:23:23 +05:30
commit 3a338b33dd
163 changed files with 27081 additions and 0 deletions

View File

@@ -0,0 +1,150 @@
import type { FocusEventHandler, PointerEventHandler, RefAttributes, RefObject } from "react";
import { useCallback, useContext, useRef, useState } from "react";
import { SearchLg as SearchIcon } from "@untitledui/icons";
import type { ComboBoxProps as AriaComboBoxProps, GroupProps as AriaGroupProps, ListBoxProps as AriaListBoxProps } from "react-aria-components";
import { ComboBox as AriaComboBox, Group as AriaGroup, Input as AriaInput, ListBox as AriaListBox, ComboBoxStateContext } from "react-aria-components";
import { HintText } from "@/components/base/input/hint-text";
import { Label } from "@/components/base/input/label";
import { Popover } from "@/components/base/select/popover";
import { type CommonProps, SelectContext, type SelectItemType, sizes } from "@/components/base/select/select";
import { useResizeObserver } from "@/hooks/use-resize-observer";
import { cx } from "@/utils/cx";
interface ComboBoxProps extends Omit<AriaComboBoxProps<SelectItemType>, "children" | "items">, RefAttributes<HTMLDivElement>, CommonProps {
shortcut?: boolean;
items?: SelectItemType[];
popoverClassName?: string;
shortcutClassName?: string;
children: AriaListBoxProps<SelectItemType>["children"];
}
interface ComboBoxValueProps extends AriaGroupProps {
size: "sm" | "md";
shortcut: boolean;
placeholder?: string;
shortcutClassName?: string;
onFocus?: FocusEventHandler;
onPointerEnter?: PointerEventHandler;
ref?: RefObject<HTMLDivElement | null>;
}
const ComboBoxValue = ({ size, shortcut, placeholder, shortcutClassName, ...otherProps }: ComboBoxValueProps) => {
const state = useContext(ComboBoxStateContext);
const value = state?.selectedItem?.value || null;
const inputValue = state?.inputValue || null;
const first = inputValue?.split(value?.supportingText)?.[0] || "";
const last = inputValue?.split(first)[1];
return (
<AriaGroup
{...otherProps}
className={({ isFocusWithin, isDisabled }) =>
cx(
"relative flex w-full items-center gap-2 rounded-lg bg-primary shadow-xs ring-1 ring-primary outline-hidden transition-shadow duration-100 ease-linear ring-inset",
isDisabled && "cursor-not-allowed bg-disabled_subtle",
isFocusWithin && "ring-2 ring-brand",
sizes[size].root,
)
}
>
{({ isDisabled }) => (
<>
<SearchIcon className="pointer-events-none size-5 shrink-0 text-fg-quaternary" />
<div className="relative flex w-full items-center gap-2">
{inputValue && (
<span className="absolute top-1/2 z-0 inline-flex w-full -translate-y-1/2 gap-2 truncate" aria-hidden="true">
<p className={cx("text-md font-medium text-primary", isDisabled && "text-disabled")}>{first}</p>
{last && <p className={cx("-ml-0.75 text-md text-tertiary", isDisabled && "text-disabled")}>{last}</p>}
</span>
)}
<AriaInput
placeholder={placeholder}
className="z-10 w-full appearance-none bg-transparent text-md text-transparent caret-alpha-black/90 placeholder:text-placeholder focus:outline-hidden disabled:cursor-not-allowed disabled:text-disabled disabled:placeholder:text-disabled"
/>
</div>
{shortcut && (
<div
className={cx(
"absolute inset-y-0.5 right-0.5 z-10 flex items-center rounded-r-[inherit] bg-linear-to-r from-transparent to-bg-primary to-40% pl-8",
isDisabled && "to-bg-disabled_subtle",
sizes[size].shortcut,
shortcutClassName,
)}
>
<span
className={cx(
"pointer-events-none rounded px-1 py-px text-xs font-medium text-quaternary ring-1 ring-secondary select-none ring-inset",
isDisabled && "bg-transparent text-disabled",
)}
aria-hidden="true"
>
K
</span>
</div>
)}
</>
)}
</AriaGroup>
);
};
export const ComboBox = ({ placeholder = "Search", shortcut = true, size = "sm", children, items, shortcutClassName, ...otherProps }: ComboBoxProps) => {
const placeholderRef = useRef<HTMLDivElement>(null);
const [popoverWidth, setPopoverWidth] = useState("");
// Resize observer for popover width
const onResize = useCallback(() => {
if (!placeholderRef.current) return;
const divRect = placeholderRef.current?.getBoundingClientRect();
setPopoverWidth(divRect.width + "px");
}, [placeholderRef, setPopoverWidth]);
useResizeObserver({
ref: placeholderRef,
box: "border-box",
onResize,
});
return (
<SelectContext.Provider value={{ size }}>
<AriaComboBox menuTrigger="focus" {...otherProps}>
{(state) => (
<div className="flex flex-col gap-1.5">
{otherProps.label && (
<Label isRequired={state.isRequired} tooltip={otherProps.tooltip}>
{otherProps.label}
</Label>
)}
<ComboBoxValue
ref={placeholderRef}
placeholder={placeholder}
shortcut={shortcut}
shortcutClassName={shortcutClassName}
size={size}
// This is a workaround to correctly calculating the trigger width
// while using ResizeObserver wasn't 100% reliable.
onFocus={onResize}
onPointerEnter={onResize}
/>
<Popover size={size} triggerRef={placeholderRef} style={{ width: popoverWidth }} className={otherProps.popoverClassName}>
<AriaListBox items={items} className="size-full outline-hidden">
{children}
</AriaListBox>
</Popover>
{otherProps.hint && <HintText isInvalid={state.isInvalid}>{otherProps.hint}</HintText>}
</div>
)}
</AriaComboBox>
</SelectContext.Provider>
);
};

View File

@@ -0,0 +1,361 @@
import type { FocusEventHandler, KeyboardEvent, PointerEventHandler, RefAttributes, RefObject } from "react";
import { createContext, useCallback, useContext, useRef, useState } from "react";
import { SearchLg } from "@untitledui/icons";
import { FocusScope, useFilter, useFocusManager } from "react-aria";
import type { ComboBoxProps as AriaComboBoxProps, GroupProps as AriaGroupProps, ListBoxProps as AriaListBoxProps, Key } from "react-aria-components";
import { ComboBox as AriaComboBox, Group as AriaGroup, Input as AriaInput, ListBox as AriaListBox, ComboBoxStateContext } from "react-aria-components";
import type { ListData } from "react-stately";
import { useListData } from "react-stately";
import { Avatar } from "@/components/base/avatar/avatar";
import type { IconComponentType } from "@/components/base/badges/badge-types";
import { HintText } from "@/components/base/input/hint-text";
import { Label } from "@/components/base/input/label";
import { Popover } from "@/components/base/select/popover";
import { type SelectItemType, sizes } from "@/components/base/select/select";
import { TagCloseX } from "@/components/base/tags/base-components/tag-close-x";
import { useResizeObserver } from "@/hooks/use-resize-observer";
import { cx } from "@/utils/cx";
import { SelectItem } from "./select-item";
interface ComboBoxValueProps extends AriaGroupProps {
size: "sm" | "md";
shortcut?: boolean;
isDisabled?: boolean;
placeholder?: string;
shortcutClassName?: string;
placeholderIcon?: IconComponentType | null;
ref?: RefObject<HTMLDivElement | null>;
onFocus?: FocusEventHandler;
onPointerEnter?: PointerEventHandler;
}
const ComboboxContext = createContext<{
size: "sm" | "md";
selectedKeys: Key[];
selectedItems: ListData<SelectItemType>;
onRemove: (keys: Set<Key>) => void;
onInputChange: (value: string) => void;
}>({
size: "sm",
selectedKeys: [],
selectedItems: {} as ListData<SelectItemType>,
onRemove: () => {},
onInputChange: () => {},
});
interface MultiSelectProps extends Omit<AriaComboBoxProps<SelectItemType>, "children" | "items">, RefAttributes<HTMLDivElement> {
hint?: string;
label?: string;
tooltip?: string;
size?: "sm" | "md";
placeholder?: string;
shortcut?: boolean;
items?: SelectItemType[];
popoverClassName?: string;
shortcutClassName?: string;
selectedItems: ListData<SelectItemType>;
placeholderIcon?: IconComponentType | null;
children: AriaListBoxProps<SelectItemType>["children"];
onItemCleared?: (key: Key) => void;
onItemInserted?: (key: Key) => void;
}
export const MultiSelectBase = ({
items,
children,
size = "sm",
selectedItems,
onItemCleared,
onItemInserted,
shortcut,
placeholder = "Search",
// Omit these props to avoid conflicts with the `Select` component
name: _name,
className: _className,
...props
}: MultiSelectProps) => {
const { contains } = useFilter({ sensitivity: "base" });
const selectedKeys = selectedItems.items.map((item) => item.id);
const filter = useCallback(
(item: SelectItemType, filterText: string) => {
return !selectedKeys.includes(item.id) && contains(item.label || item.supportingText || "", filterText);
},
[contains, selectedKeys],
);
const accessibleList = useListData({
initialItems: items,
filter,
});
const onRemove = useCallback(
(keys: Set<Key>) => {
const key = keys.values().next().value;
if (!key) return;
selectedItems.remove(key);
onItemCleared?.(key);
},
[selectedItems, onItemCleared],
);
const onSelectionChange = (id: Key | null) => {
if (!id) {
return;
}
const item = accessibleList.getItem(id);
if (!item) {
return;
}
if (!selectedKeys.includes(id as string)) {
selectedItems.append(item);
onItemInserted?.(id);
}
accessibleList.setFilterText("");
};
const onInputChange = (value: string) => {
accessibleList.setFilterText(value);
};
const placeholderRef = useRef<HTMLDivElement>(null);
const [popoverWidth, setPopoverWidth] = useState("");
// Resize observer for popover width
const onResize = useCallback(() => {
if (!placeholderRef.current) return;
let divRect = placeholderRef.current?.getBoundingClientRect();
setPopoverWidth(divRect.width + "px");
}, [placeholderRef, setPopoverWidth]);
useResizeObserver({
ref: placeholderRef,
onResize: onResize,
box: "border-box",
});
return (
<ComboboxContext.Provider
value={{
size,
selectedKeys,
selectedItems,
onInputChange,
onRemove,
}}
>
<AriaComboBox
allowsEmptyCollection
menuTrigger="focus"
items={accessibleList.items}
onInputChange={onInputChange}
inputValue={accessibleList.filterText}
// This keeps the combobox popover open and the input value unchanged when an item is selected.
selectedKey={null}
onSelectionChange={onSelectionChange}
{...props}
>
{(state) => (
<div className="flex flex-col gap-1.5">
{props.label && (
<Label isRequired={state.isRequired} tooltip={props.tooltip}>
{props.label}
</Label>
)}
<MultiSelectTagsValue
size={size}
shortcut={shortcut}
ref={placeholderRef}
placeholder={placeholder}
// This is a workaround to correctly calculating the trigger width
// while using ResizeObserver wasn't 100% reliable.
onFocus={onResize}
onPointerEnter={onResize}
/>
<Popover size={"md"} triggerRef={placeholderRef} style={{ width: popoverWidth }} className={props?.popoverClassName}>
<AriaListBox selectionMode="multiple" className="size-full outline-hidden">
{children}
</AriaListBox>
</Popover>
{props.hint && <HintText isInvalid={state.isInvalid}>{props.hint}</HintText>}
</div>
)}
</AriaComboBox>
</ComboboxContext.Provider>
);
};
const InnerMultiSelect = ({ isDisabled, shortcut, shortcutClassName, placeholder }: Omit<MultiSelectProps, "selectedItems" | "children">) => {
const focusManager = useFocusManager();
const comboBoxContext = useContext(ComboboxContext);
const comboBoxStateContext = useContext(ComboBoxStateContext);
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
const isCaretAtStart = event.currentTarget.selectionStart === 0 && event.currentTarget.selectionEnd === 0;
if (!isCaretAtStart && event.currentTarget.value !== "") {
return;
}
switch (event.key) {
case "Backspace":
case "ArrowLeft":
focusManager?.focusPrevious({ wrap: false, tabbable: false });
break;
case "ArrowRight":
focusManager?.focusNext({ wrap: false, tabbable: false });
break;
}
};
// Ensure dropdown opens on click even if input is already focused
const handleInputMouseDown = (_event: React.MouseEvent<HTMLInputElement>) => {
if (comboBoxStateContext && !comboBoxStateContext.isOpen) {
comboBoxStateContext.open();
}
};
const handleTagKeyDown = (event: KeyboardEvent<HTMLButtonElement>, value: Key) => {
// Do nothing when tab is clicked to move focus from the tag to the input element.
if (event.key === "Tab") {
return;
}
event.preventDefault();
const isFirstTag = comboBoxContext?.selectedItems?.items?.[0]?.id === value;
switch (event.key) {
case " ":
case "Enter":
case "Backspace":
if (isFirstTag) {
focusManager?.focusNext({ wrap: false, tabbable: false });
} else {
focusManager?.focusPrevious({ wrap: false, tabbable: false });
}
comboBoxContext.onRemove(new Set([value]));
break;
case "ArrowLeft":
focusManager?.focusPrevious({ wrap: false, tabbable: false });
break;
case "ArrowRight":
focusManager?.focusNext({ wrap: false, tabbable: false });
break;
case "Escape":
comboBoxStateContext?.close();
break;
}
};
const isSelectionEmpty = comboBoxContext?.selectedItems?.items?.length === 0;
return (
<div className="relative flex w-full flex-1 flex-row flex-wrap items-center justify-start gap-1.5">
{!isSelectionEmpty &&
comboBoxContext?.selectedItems?.items?.map((value) => (
<span key={value.id} className="flex items-center rounded-md bg-primary py-0.5 pr-1 pl-1.25 ring-1 ring-primary ring-inset">
<Avatar size="xxs" alt={value?.label} src={value?.avatarUrl} />
<p className="ml-1.25 truncate text-sm font-medium whitespace-nowrap text-secondary select-none">{value?.label}</p>
<TagCloseX
size="md"
isDisabled={isDisabled}
className="ml-0.75"
// For workaround, onKeyDown is added to the button
onKeyDown={(event) => handleTagKeyDown(event, value.id)}
onPress={() => comboBoxContext.onRemove(new Set([value.id]))}
/>
</span>
))}
<div className={cx("relative flex min-w-[20%] flex-1 flex-row items-center", !isSelectionEmpty && "ml-0.5", shortcut && "min-w-[30%]")}>
<AriaInput
placeholder={placeholder}
onKeyDown={handleInputKeyDown}
onMouseDown={handleInputMouseDown}
className="w-full flex-[1_0_0] appearance-none bg-transparent text-md text-ellipsis text-primary caret-alpha-black/90 outline-none placeholder:text-placeholder focus:outline-hidden disabled:cursor-not-allowed disabled:text-disabled disabled:placeholder:text-disabled"
/>
{shortcut && (
<div
aria-hidden="true"
className={cx(
"absolute inset-y-0.5 right-0.5 z-10 flex items-center rounded-r-[inherit] bg-linear-to-r from-transparent to-bg-primary to-40% pl-8",
shortcutClassName,
)}
>
<span
className={cx(
"pointer-events-none rounded px-1 py-px text-xs font-medium text-quaternary ring-1 ring-secondary select-none ring-inset",
isDisabled && "bg-transparent text-disabled",
)}
>
K
</span>
</div>
)}
</div>
</div>
);
};
export const MultiSelectTagsValue = ({
size,
shortcut,
placeholder,
shortcutClassName,
placeholderIcon: Icon = SearchLg,
// Omit this prop to avoid invalid HTML attribute warning
isDisabled: _isDisabled,
...otherProps
}: ComboBoxValueProps) => {
return (
<AriaGroup
{...otherProps}
className={({ isFocusWithin, isDisabled }) =>
cx(
"relative flex w-full items-center gap-2 rounded-lg bg-primary shadow-xs ring-1 ring-primary outline-hidden transition duration-100 ease-linear ring-inset",
isDisabled && "cursor-not-allowed bg-disabled_subtle",
isFocusWithin && "ring-2 ring-brand",
sizes[size].root,
)
}
>
{({ isDisabled }) => (
<>
{Icon && <Icon className="pointer-events-none size-5 text-fg-quaternary" />}
<FocusScope contain={false} autoFocus={false} restoreFocus={false}>
<InnerMultiSelect
isDisabled={isDisabled}
size={size}
shortcut={shortcut}
shortcutClassName={shortcutClassName}
placeholder={placeholder}
/>
</FocusScope>
</>
)}
</AriaGroup>
);
};
const MultiSelect = MultiSelectBase as typeof MultiSelectBase & {
Item: typeof SelectItem;
};
MultiSelect.Item = SelectItem;
export { MultiSelect as MultiSelect };

View File

@@ -0,0 +1,32 @@
import type { RefAttributes } from "react";
import type { PopoverProps as AriaPopoverProps } from "react-aria-components";
import { Popover as AriaPopover } from "react-aria-components";
import { cx } from "@/utils/cx";
interface PopoverProps extends AriaPopoverProps, RefAttributes<HTMLElement> {
size: "sm" | "md";
}
export const Popover = (props: PopoverProps) => {
return (
<AriaPopover
placement="bottom"
containerPadding={0}
offset={4}
{...props}
className={(state) =>
cx(
"max-h-64! w-(--trigger-width) origin-(--trigger-anchor-point) overflow-x-hidden overflow-y-auto rounded-lg bg-primary py-1 shadow-lg ring-1 ring-secondary_alt outline-hidden will-change-transform",
state.isEntering &&
"duration-150 ease-out animate-in fade-in placement-right:slide-in-from-left-0.5 placement-top:slide-in-from-bottom-0.5 placement-bottom:slide-in-from-top-0.5",
state.isExiting &&
"duration-100 ease-in animate-out fade-out placement-right:slide-out-to-left-0.5 placement-top:slide-out-to-bottom-0.5 placement-bottom:slide-out-to-top-0.5",
props.size === "md" && "max-h-80!",
typeof props.className === "function" ? props.className(state) : props.className,
)
}
/>
);
};

View File

@@ -0,0 +1,95 @@
import { isValidElement, useContext } from "react";
import { Check } from "@untitledui/icons";
import type { ListBoxItemProps as AriaListBoxItemProps } from "react-aria-components";
import { ListBoxItem as AriaListBoxItem, Text as AriaText } from "react-aria-components";
import { Avatar } from "@/components/base/avatar/avatar";
import { cx } from "@/utils/cx";
import { isReactComponent } from "@/utils/is-react-component";
import type { SelectItemType } from "./select";
import { SelectContext } from "./select";
const sizes = {
sm: "p-2 pr-2.5",
md: "p-2.5 pl-2",
};
interface SelectItemProps extends Omit<AriaListBoxItemProps<SelectItemType>, "id">, SelectItemType {}
export const SelectItem = ({ label, id, value, avatarUrl, supportingText, isDisabled, icon: Icon, className, children, ...props }: SelectItemProps) => {
const { size } = useContext(SelectContext);
const labelOrChildren = label || (typeof children === "string" ? children : "");
const textValue = supportingText ? labelOrChildren + " " + supportingText : labelOrChildren;
return (
<AriaListBoxItem
id={id}
value={
value ?? {
id,
label: labelOrChildren,
avatarUrl,
supportingText,
isDisabled,
icon: Icon,
}
}
textValue={textValue}
isDisabled={isDisabled}
{...props}
className={(state) => cx("w-full px-1.5 py-px outline-hidden", typeof className === "function" ? className(state) : className)}
>
{(state) => (
<div
className={cx(
"flex cursor-pointer items-center gap-2 rounded-md outline-hidden select-none",
state.isSelected && "bg-active",
state.isDisabled && "cursor-not-allowed",
state.isFocused && "bg-primary_hover",
state.isFocusVisible && "ring-2 ring-focus-ring ring-inset",
// Icon styles
"*:data-icon:size-5 *:data-icon:shrink-0 *:data-icon:text-fg-quaternary",
state.isDisabled && "*:data-icon:text-fg-disabled",
sizes[size],
)}
>
{avatarUrl ? (
<Avatar aria-hidden="true" size="xs" src={avatarUrl} alt={label} />
) : isReactComponent(Icon) ? (
<Icon data-icon aria-hidden="true" />
) : isValidElement(Icon) ? (
Icon
) : null}
<div className="flex w-full min-w-0 flex-1 flex-wrap gap-x-2">
<AriaText
slot="label"
className={cx("truncate text-md font-medium whitespace-nowrap text-primary", state.isDisabled && "text-disabled")}
>
{label || (typeof children === "function" ? children(state) : children)}
</AriaText>
{supportingText && (
<AriaText slot="description" className={cx("text-md whitespace-nowrap text-tertiary", state.isDisabled && "text-disabled")}>
{supportingText}
</AriaText>
)}
</div>
{state.isSelected && (
<Check
aria-hidden="true"
className={cx(
"ml-auto text-fg-brand-primary",
size === "sm" ? "size-4 stroke-[2.5px]" : "size-5",
state.isDisabled && "text-fg-disabled",
)}
/>
)}
</div>
)}
</AriaListBoxItem>
);
};

View File

@@ -0,0 +1,67 @@
import { type SelectHTMLAttributes, useId } from "react";
import { ChevronDown } from "@untitledui/icons";
import { HintText } from "@/components/base/input/hint-text";
import { Label } from "@/components/base/input/label";
import { cx } from "@/utils/cx";
interface NativeSelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
hint?: string;
selectClassName?: string;
options: { label: string; value: string; disabled?: boolean }[];
}
export const NativeSelect = ({ label, hint, options, className, selectClassName, ...props }: NativeSelectProps) => {
const id = useId();
const selectId = `select-native-${id}`;
const hintId = `select-native-hint-${id}`;
return (
<div className={cx("w-full in-data-input-wrapper:w-max", className)}>
{label && (
<Label htmlFor={selectId} id={selectId} className="mb-1.5">
{label}
</Label>
)}
<div className="relative grid w-full items-center">
<select
{...props}
id={selectId}
aria-describedby={hintId}
aria-labelledby={selectId}
className={cx(
"appearance-none rounded-lg bg-primary px-3.5 py-2.5 text-md font-medium text-primary shadow-xs ring-1 ring-primary outline-hidden transition duration-100 ease-linear ring-inset placeholder:text-fg-quaternary focus-visible:ring-2 focus-visible:ring-brand disabled:cursor-not-allowed disabled:bg-disabled_subtle disabled:text-disabled",
// Styles when the select is within an `InputGroup`
"in-data-input-wrapper:flex in-data-input-wrapper:h-full in-data-input-wrapper:gap-1 in-data-input-wrapper:bg-inherit in-data-input-wrapper:px-3 in-data-input-wrapper:py-2 in-data-input-wrapper:font-normal in-data-input-wrapper:text-tertiary in-data-input-wrapper:shadow-none in-data-input-wrapper:ring-transparent",
// Styles for the select when `TextField` is disabled
"in-data-input-wrapper:group-disabled:pointer-events-none in-data-input-wrapper:group-disabled:cursor-not-allowed in-data-input-wrapper:group-disabled:bg-transparent in-data-input-wrapper:group-disabled:text-disabled",
// Common styles for sizes and border radius within `InputGroup`
"in-data-input-wrapper:in-data-leading:rounded-r-none in-data-input-wrapper:in-data-trailing:rounded-l-none in-data-input-wrapper:in-data-[input-size=md]:py-2.5 in-data-input-wrapper:in-data-leading:in-data-[input-size=md]:pl-3.5 in-data-input-wrapper:in-data-[input-size=sm]:py-2 in-data-input-wrapper:in-data-[input-size=sm]:pl-3",
// For "leading" dropdown within `InputGroup`
"in-data-input-wrapper:in-data-leading:in-data-[input-size=md]:pr-4.5 in-data-input-wrapper:in-data-leading:in-data-[input-size=sm]:pr-4.5",
// For "trailing" dropdown within `InputGroup`
"in-data-input-wrapper:in-data-trailing:in-data-[input-size=md]:pr-8 in-data-input-wrapper:in-data-trailing:in-data-[input-size=sm]:pr-7.5",
selectClassName,
)}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<ChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-3.5 size-5 text-fg-quaternary in-data-input-wrapper:right-0 in-data-input-wrapper:size-4 in-data-input-wrapper:stroke-[2.625px] in-data-input-wrapper:in-data-trailing:in-data-[input-size=sm]:right-3"
/>
</div>
{hint && (
<HintText className="mt-2" id={hintId}>
{hint}
</HintText>
)}
</div>
);
};

View File

@@ -0,0 +1,144 @@
import type { FC, ReactNode, Ref, RefAttributes } from "react";
import { createContext, isValidElement } from "react";
import { ChevronDown } from "@untitledui/icons";
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>
)}
<ChevronDown
aria-hidden="true"
className={cx("ml-auto shrink-0 text-fg-quaternary", size === "sm" ? "size-4 stroke-[2.5px]" : "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 };