UNPKG

@baseplate-dev/ui-components

Version:

Shared UI component library

175 lines 9.77 kB
'use client'; import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; import { Command } from 'cmdk'; import { Popover, ScrollArea as ScrollAreaPrimitive } from 'radix-ui'; import * as React from 'react'; import { MdCheck, MdUnfoldMore } from 'react-icons/md'; import { useControlledState } from '#src/hooks/use-controlled-state.js'; import { inputVariants, selectCheckVariants, selectContentVariants, selectItemVariants, } from '#src/styles/index.js'; import { cn, mergeRefs } from '#src/utils/index.js'; import { Button } from '../button/button.js'; import { ScrollBar } from '../scroll-area/scroll-area.js'; const ComboboxContext = React.createContext(null); const DEFAULT_OPTION = { value: null, label: '' }; /** * A control that allows users to select an option from a list of options and type to search. */ function Combobox({ children, value: controlledValue, onChange, searchQuery: defaultSearchQuery, onSearchQueryChange, label, disabled = false, }) { const [isOpen, setIsOpen] = React.useState(false); const [value, setValue] = useControlledState(controlledValue === null ? DEFAULT_OPTION : controlledValue, onChange, DEFAULT_OPTION); const [searchQuery, setSearchQuery] = useControlledState(defaultSearchQuery, onSearchQueryChange, ''); // the value of the combobox that is currently active const [activeValue, setActiveValue] = React.useState(value.value ?? ''); // Caches the filter query so we can maintain // the query when animating the combobox open/close const [filterQuery, setFilterQuery] = React.useState(searchQuery); const inputRef = React.useRef(null); const inputId = React.useId(); const listRef = React.useRef(null); const contextValue = React.useMemo(() => ({ selectedLabel: value.label ?? '', selectedValue: value.value, onSelect: (val, lab) => { setValue({ value: val, label: lab }); setFilterQuery(searchQuery); setSearchQuery(''); setIsOpen(false); }, searchQuery, setSearchQuery: (query) => { setFilterQuery(query); setSearchQuery(query); setIsOpen(true); }, setIsOpen: (open) => { setFilterQuery(searchQuery); if (!open) { setActiveValue(value.value ?? ''); setSearchQuery(''); } setIsOpen(open); }, isOpen, inputId, inputRef, listRef, shouldShowItem: (label) => { if (!filterQuery) { return true; } if (!label) { return false; } return label.toLowerCase().includes(filterQuery.toLowerCase()); }, disabled, }), [ value, inputId, searchQuery, setSearchQuery, setValue, isOpen, filterQuery, disabled, ]); return (_jsx(ComboboxContext.Provider, { value: contextValue, children: _jsx(Popover.Root, { open: isOpen, onOpenChange: contextValue.setIsOpen, children: _jsx(Command, { "aria-disabled": disabled, shouldFilter: false, value: activeValue, onValueChange: (val) => { setActiveValue(val); }, label: label, children: children }) }) })); } export function useComboboxContext() { const value = React.useContext(ComboboxContext); if (!value) { throw new Error(`useComboboxContext must be used inside a ComboboxContext provider`); } return value; } function ComboboxInput({ className, placeholder, ref, ...rest }) { const { setIsOpen, isOpen, inputId, searchQuery, setSearchQuery, selectedLabel, disabled, } = useComboboxContext(); const selectedLabelId = React.useId(); const inputRef = React.useRef(null); const handleKeydown = React.useCallback((e) => { const specialKeys = ['ArrowDown', 'ArrowUp', 'Home', 'End', 'Enter']; if (e.key === 'Escape') { setIsOpen(false); } else if (specialKeys.includes(e.key)) { setIsOpen(true); } }, [setIsOpen]); return (_jsx(Popover.Anchor, { children: _jsxs("div", { className: "relative", "data-cmdk-input-id": inputId, children: [_jsx(Command.Input, { asChild: true, onKeyDown: handleKeydown, disabled: disabled, onBlur: (e) => { if (e.relatedTarget && e.relatedTarget instanceof Element && e.relatedTarget.closest(`[data-combobox-content=""]`)) { e.target.focus(); } }, value: searchQuery, onValueChange: setSearchQuery, className: cn(inputVariants(), 'pr-8', className), placeholder: selectedLabel ? undefined : placeholder, onClick: () => { if (disabled) { return; } if (!isOpen) { setIsOpen(true); } else if (inputRef.current) { // avoid closing the combobox if the user is selecting text const hasSelectedEnd = inputRef.current.selectionStart === inputRef.current.selectionEnd && inputRef.current.selectionEnd === inputRef.current.value.length; if (hasSelectedEnd) { setIsOpen(false); } } }, ...rest, "aria-describedby": `${rest['aria-describedby'] ?? ''} ${selectedLabelId}`, ref: mergeRefs(ref, inputRef), children: _jsx("input", { ...(rest['aria-labelledby'] ? { 'aria-labelledby': rest['aria-labelledby'] } : undefined) }) }), _jsx("div", { className: "pointer-events-none absolute inset-0 flex items-center pr-8", children: _jsx("div", { id: selectedLabelId, className: cn(disabled ? 'opacity-50' : '', searchQuery ? 'hidden' : '', 'pointer-events-none truncate py-1 pl-3 text-base md:text-sm'), children: selectedLabel }) }), _jsx(Button, { className: "absolute top-1/2 right-2 -translate-y-1/2 opacity-50", type: "button", variant: "ghost", size: "icon", disabled: disabled, "aria-label": `${isOpen ? 'Close' : 'Open'} combobox`, onClick: () => { if (disabled) { return; } setIsOpen(!isOpen); }, onKeyDown: (e) => { if (!isOpen) { e.stopPropagation(); } }, children: _jsx(MdUnfoldMore, { className: "size-4" }) })] }) })); } function ComboboxContent({ children, className, maxHeight = '320px', style, ...rest }) { const { inputId, listRef } = useComboboxContext(); return (_jsx(Popover.Portal, { children: _jsx(Popover.Content, { onOpenAutoFocus: (e) => { e.preventDefault(); }, onInteractOutside: (e) => { if (e.target && e.target instanceof Element && e.target.closest(`[data-cmdk-input-id="${inputId}"]`)) { e.preventDefault(); } }, className: cn(selectContentVariants({ popper: 'active' }), className), style: { '--max-popover-height': maxHeight, ...style, }, "data-combobox-content": "", ...rest, children: _jsxs(ScrollAreaPrimitive.Root, { type: "auto", className: "relative overflow-hidden", children: [_jsx(ScrollAreaPrimitive.Viewport, { className: cn('h-full w-full rounded-[inherit] p-1', 'max-h-[min(var(--max-popover-height),var(--radix-popover-content-available-height))] w-full min-w-(--radix-popover-trigger-width)'), style: { '--max-popper-height': maxHeight, }, children: _jsx(Command.List, { ref: listRef, children: children }) }), _jsx(ScrollBar, {}), _jsx(ScrollAreaPrimitive.Corner, {})] }) }) })); } function ComboboxEmpty({ className, ...props }) { return _jsx(Command.Empty, { className: cn('p-2 text-sm', className), ...props }); } function ComboboxLoading({ className, ...props }) { return (_jsx(Command.Loading, { className: cn('flex items-center justify-center p-4 text-sm text-muted-foreground', className), ...props })); } const ComboboxGroup = Command.Group; function ComboboxItem({ value, className, label, children, ref, ...rest }) { const { selectedValue, onSelect, shouldShowItem } = useComboboxContext(); const itemRef = React.useRef(null); const extractedLabel = label ?? (typeof children === 'string' ? children.trim() : undefined); if (!shouldShowItem(extractedLabel ?? value)) { return _jsx(_Fragment, {}); } return (_jsxs(Command.Item, { value: value ?? '', onSelect: () => { onSelect(value, extractedLabel); }, className: cn(selectItemVariants(), className), ...rest, ref: mergeRefs(ref, itemRef), children: [children, _jsx(MdCheck, { className: cn(selectCheckVariants(), value === selectedValue ? 'opacity-100' : 'opacity-0') })] })); } function ComboboxAction({ value, className, children, onClick, ref, ...rest }) { const itemRef = React.useRef(null); return (_jsx(Command.Item, { value: value, onSelect: onClick, className: cn(selectItemVariants(), className), ...rest, ref: mergeRefs(ref, itemRef), children: children })); } export { Combobox, ComboboxAction, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxLoading, }; //# sourceMappingURL=combobox.js.map