headplane/app/components/Select.tsx

171 lines
3.9 KiB
TypeScript
Raw Normal View History

2025-01-28 16:04:42 -05:00
import { Check, ChevronDown } from 'lucide-react';
import { useRef } from 'react';
2024-06-23 17:25:22 -04:00
import {
2025-01-28 16:04:42 -05:00
AriaComboBoxProps,
AriaListBoxOptions,
useButton,
useComboBox,
useFilter,
2025-02-04 11:27:29 -05:00
useId,
2025-01-28 16:04:42 -05:00
useListBox,
useOption,
} from 'react-aria';
import { Item, ListState, Node, useComboBoxState } from 'react-stately';
import Popover from '~/components/Popover';
import cn from '~/utils/cn';
2024-06-23 17:25:22 -04:00
2025-01-28 16:04:42 -05:00
export interface SelectProps extends AriaComboBoxProps<object> {}
2024-06-23 17:25:22 -04:00
function Select(props: SelectProps) {
2025-01-28 16:04:42 -05:00
const { contains } = useFilter({ sensitivity: 'base' });
const state = useComboBoxState({ ...props, defaultFilter: contains });
2025-02-04 11:27:29 -05:00
const id = useId(props.id);
2025-01-28 16:04:42 -05:00
const buttonRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const listBoxRef = useRef<HTMLUListElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const {
buttonProps: triggerProps,
inputProps,
listBoxProps,
labelProps,
descriptionProps,
} = useComboBox(
{
...props,
inputRef,
buttonRef,
listBoxRef,
popoverRef,
},
state,
);
const { buttonProps } = useButton(triggerProps, buttonRef);
2024-06-23 17:25:22 -04:00
return (
2025-01-28 16:04:42 -05:00
<div className="flex flex-col">
<label
{...labelProps}
2025-02-04 11:27:29 -05:00
htmlFor={id}
2024-12-31 10:30:14 +05:30
className={cn(
2025-01-28 16:04:42 -05:00
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
2024-12-31 10:30:14 +05:30
)}
2024-06-23 17:25:22 -04:00
>
2025-01-28 16:04:42 -05:00
{props.label}
</label>
<div
2024-06-23 17:25:22 -04:00
className={cn(
2025-01-28 16:04:42 -05:00
'flex rounded-xl focus:outline-none focus-within:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
2024-06-23 17:25:22 -04:00
)}
>
2025-01-28 16:04:42 -05:00
<input
{...inputProps}
ref={inputRef}
2025-02-04 11:27:29 -05:00
id={id}
2025-01-28 16:04:42 -05:00
className="outline-none px-3 py-2 rounded-l-xl w-full bg-transparent"
2025-02-04 11:27:29 -05:00
data-1p-ignore
2025-01-28 16:04:42 -05:00
/>
<button
{...buttonProps}
ref={buttonRef}
className={cn(
'flex items-center justify-center p-1 rounded-lg m-1',
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
)}
>
<ChevronDown className="p-0.5" />
</button>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{state.isOpen && (
<Popover
popoverRef={popoverRef}
triggerRef={inputRef}
state={state}
isNonModal
placement="bottom start"
className="w-full max-w-xs"
>
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
</Popover>
)}
</div>
);
}
interface ListBoxProps extends AriaListBoxOptions<object> {
listBoxRef?: React.RefObject<HTMLUListElement | null>;
state: ListState<object>;
}
function ListBox(props: ListBoxProps) {
const { listBoxRef, state } = props;
const ref = listBoxRef ?? useRef<HTMLUListElement | null>(null);
const { listBoxProps } = useListBox(props, state, ref);
return (
<ul
{...listBoxProps}
ref={listBoxRef}
className="w-full max-h-72 overflow-auto outline-none pt-1"
>
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</ul>
2024-12-31 10:30:14 +05:30
);
2024-06-23 17:25:22 -04:00
}
2025-01-28 16:04:42 -05:00
interface OptionProps {
item: Node<unknown>;
state: ListState<unknown>;
}
function Option({ item, state }: OptionProps) {
const ref = useRef<HTMLLIElement | null>(null);
const { optionProps, isDisabled, isSelected, isFocused } = useOption(
{
key: item.key,
},
state,
ref,
);
2024-06-23 17:25:22 -04:00
return (
2025-01-28 16:04:42 -05:00
<li
{...optionProps}
ref={ref}
2024-06-23 17:25:22 -04:00
className={cn(
2025-01-28 16:04:42 -05:00
'flex items-center justify-between',
'py-2 px-3 mx-1 rounded-lg mb-1',
'focus:outline-none select-none',
isFocused || isSelected
? 'bg-headplane-100/50 dark:bg-headplane-800'
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
isDisabled && 'text-headplane-300 dark:text-headplane-600',
2024-06-23 17:25:22 -04:00
)}
>
2025-01-28 16:04:42 -05:00
{item.rendered}
{isSelected && <Check className="p-0.5" />}
</li>
2024-12-31 10:30:14 +05:30
);
2024-06-23 17:25:22 -04:00
}
2024-12-31 10:30:14 +05:30
export default Object.assign(Select, { Item });