mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 17:33:55 +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