diff --git a/apps/client/src/common/components/autocomplete-input/AutocompleteInput.module.scss b/apps/client/src/common/components/autocomplete-input/AutocompleteInput.module.scss new file mode 100644 index 000000000..1bf08813a --- /dev/null +++ b/apps/client/src/common/components/autocomplete-input/AutocompleteInput.module.scss @@ -0,0 +1,61 @@ +.positioner { + outline: 0; +} + +.popup { + font-size: 1rem; + box-sizing: border-box; + padding: 2px; + min-width: var(--anchor-width); + max-width: var(--available-width); + border: 1px solid $gray-1000; + border-radius: $component-border-radius-md; + background-color: $gray-1200; + color: $ui-white; + + &[data-empty][data-hide-empty] { + display: none; + } +} + +.list { + box-sizing: border-box; + max-height: 20rem; + max-height: var(--available-height); + overflow-y: auto; + overscroll-behavior: contain; + padding-block: 0.25rem; + scroll-padding-block: 1.5rem; + outline: 0; +} + +.item, +.empty:not(:empty) { + box-sizing: border-box; + padding: 0.25rem 0.5rem; + line-height: 1rem; +} + +.item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + outline: 0; + cursor: default; + user-select: none; + overflow-wrap: break-word; + + &[data-highlighted] { + background-color: $blue-700; + } +} + +.itemLabel { + min-width: 0; + overflow-wrap: anywhere; +} + +.empty { + color: $gray-400; +} diff --git a/apps/client/src/common/components/autocomplete-input/AutocompleteInput.tsx b/apps/client/src/common/components/autocomplete-input/AutocompleteInput.tsx new file mode 100644 index 000000000..bdf57f12b --- /dev/null +++ b/apps/client/src/common/components/autocomplete-input/AutocompleteInput.tsx @@ -0,0 +1,95 @@ +import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete'; +import type { ReactNode } from 'react'; +import { useEffect, useRef, useState } from 'react'; + +import { cx } from '../../utils/styleUtils'; +import type { InputProps } from '../input/input/Input'; +import { getScrollParent } from './autocompleteInput.utils'; + +import inputStyles from '../input/input/Input.module.scss'; +import styles from './AutocompleteInput.module.scss'; + +export interface AutocompleteInputProps extends Omit { + options: string[]; + value: string; + onValueChange: (value: string) => void; + emptyLabel?: string; + trailingElement?: (option: string) => ReactNode; +} + +export default function AutocompleteInput({ + className, + disabled, + emptyLabel, + fluid, + height = 'medium', + onValueChange, + options, + trailingElement, + value, + variant = 'subtle', + ...inputProps +}: AutocompleteInputProps) { + const inputRef = useRef(null); + const [open, setOpen] = useState(false); + + // close the popover when the parent scrollable container scrolls or the window resizes + useEffect(() => { + if (!open) { + return; + } + + const scrollTarget = getScrollParent(inputRef.current); + const handleScroll = () => setOpen(false); + + scrollTarget.addEventListener('scroll', handleScroll, { passive: true }); + window.addEventListener('resize', handleScroll, { passive: true }); + + return () => { + scrollTarget.removeEventListener('scroll', handleScroll); + window.removeEventListener('resize', handleScroll); + }; + }, [open]); + + return ( + + + + + + + + {(option: string) => ( + + {option} + {trailingElement?.(option)} + + )} + + {emptyLabel && {emptyLabel}} + + + + + + ); +} diff --git a/apps/client/src/common/components/autocomplete-input/autocompleteInput.utils.ts b/apps/client/src/common/components/autocomplete-input/autocompleteInput.utils.ts new file mode 100644 index 000000000..8506b50a8 --- /dev/null +++ b/apps/client/src/common/components/autocomplete-input/autocompleteInput.utils.ts @@ -0,0 +1,14 @@ +export function getScrollParent(node: HTMLElement | null): HTMLElement | Window { + let parent = node?.parentElement ?? null; + + while (parent) { + const { overflowY } = window.getComputedStyle(parent); + if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') { + return parent; + } + + parent = parent.parentElement; + } + + return window; +}