mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-12 02:38:15 +00:00
chore: initial Untitled UI Vite scaffold with FontAwesome Pro
This commit is contained in:
150
src/components/base/select/combobox.tsx
Normal file
150
src/components/base/select/combobox.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
361
src/components/base/select/multi-select.tsx
Normal file
361
src/components/base/select/multi-select.tsx
Normal 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 };
|
||||
32
src/components/base/select/popover.tsx
Normal file
32
src/components/base/select/popover.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
95
src/components/base/select/select-item.tsx
Normal file
95
src/components/base/select/select-item.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
67
src/components/base/select/select-native.tsx
Normal file
67
src/components/base/select/select-native.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
144
src/components/base/select/select.tsx
Normal file
144
src/components/base/select/select.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user