mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-24 16:39:13 +00:00
feat: create autocomplete component
This commit is contained in:
committed by
Carlos Valente
parent
2f95aa0c4f
commit
8985c8604f
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<InputProps, 'value' | 'defaultValue' | 'onChange'> {
|
||||||
|
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<HTMLInputElement | null>(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 (
|
||||||
|
<BaseAutocomplete.Root
|
||||||
|
items={options}
|
||||||
|
autoHighlight
|
||||||
|
highlightItemOnHover
|
||||||
|
disabled={disabled}
|
||||||
|
open={open}
|
||||||
|
value={value}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
>
|
||||||
|
<BaseAutocomplete.Input
|
||||||
|
ref={inputRef}
|
||||||
|
className={cx([
|
||||||
|
inputStyles.input,
|
||||||
|
inputStyles[variant],
|
||||||
|
inputStyles[height],
|
||||||
|
fluid && inputStyles.fluid,
|
||||||
|
className,
|
||||||
|
])}
|
||||||
|
{...inputProps}
|
||||||
|
/>
|
||||||
|
<BaseAutocomplete.Portal>
|
||||||
|
<BaseAutocomplete.Positioner side='bottom' align='start' className={styles.positioner}>
|
||||||
|
<BaseAutocomplete.Popup className={styles.popup} data-hide-empty={emptyLabel ? undefined : ''}>
|
||||||
|
<BaseAutocomplete.List className={styles.list}>
|
||||||
|
<BaseAutocomplete.Collection>
|
||||||
|
{(option: string) => (
|
||||||
|
<BaseAutocomplete.Item key={option} value={option} className={styles.item}>
|
||||||
|
<span className={styles.itemLabel}>{option}</span>
|
||||||
|
{trailingElement?.(option)}
|
||||||
|
</BaseAutocomplete.Item>
|
||||||
|
)}
|
||||||
|
</BaseAutocomplete.Collection>
|
||||||
|
{emptyLabel && <BaseAutocomplete.Empty className={styles.empty}>{emptyLabel}</BaseAutocomplete.Empty>}
|
||||||
|
</BaseAutocomplete.List>
|
||||||
|
</BaseAutocomplete.Popup>
|
||||||
|
</BaseAutocomplete.Positioner>
|
||||||
|
</BaseAutocomplete.Portal>
|
||||||
|
</BaseAutocomplete.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user