shadimage

Combobox

Searchable combobox for Tile UI.

packages/react/src/components/combobox/combobox.tsx

import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { comboboxStyleKeys, filterComboboxItems, getSelectState, moveComboboxIndex } from '../lib/core';
import type { ComboboxBaseProps, ComboboxItem } from '../lib/core';
import { usePortalContainer, type PortalContainer } from '../portal';
import styles from './combobox.module.scss';

function ComboboxCheckIcon() {
	return (
		<svg
			xmlns="http://www.w3.org/2000/svg"
			width="16"
			height="16"
			viewBox="0 0 24 24"
			fill="none"
			stroke="currentColor"
			strokeWidth="2"
			strokeLinecap="round"
			strokeLinejoin="round"
			aria-hidden="true">
			<path d="M20 6 9 17l-5-5" />
		</svg>
	);
}

function ComboboxChevronIcon() {
	return (
		<svg
			className={styles[comboboxStyleKeys.triggerIcon]}
			xmlns="http://www.w3.org/2000/svg"
			width="16"
			height="16"
			viewBox="0 0 24 24"
			fill="none"
			stroke="currentColor"
			strokeWidth="2"
			strokeLinecap="round"
			strokeLinejoin="round"
			aria-hidden="true">
			<path d="m6 9 6 6 6-6" />
		</svg>
	);
}

export interface ComboboxProps extends React.HTMLAttributes<HTMLDivElement>, ComboboxBaseProps {
	container?: PortalContainer;
}

const Combobox = React.forwardRef<HTMLDivElement, ComboboxProps>(
	(
		{
			className = '',
			items,
			value,
			defaultValue,
			onValueChange,
			placeholder = 'Select...',
			searchPlaceholder = 'Search...',
			emptyText = 'No items.',
			notFoundText = 'No results found.',
			maxItems,
			disabled = false,
			filter,
			container,
			...props
		},
		ref,
	) => {
		const portalContainer = usePortalContainer(container);
		const [open, setOpen] = useState(false);
		const [query, setQuery] = useState('');
		const [activeValue, setActiveValue] = useState<string | null>(null);
		const [internalValue, setInternalValue] = useState(defaultValue);
		const triggerRef = useRef<HTMLButtonElement | null>(null);
		const contentRef = useRef<HTMLDivElement | null>(null);
		const contentId = useId();

		const currentValue = value !== undefined ? value : internalValue;

		const filteredItems = useMemo(() => filterComboboxItems(items, query, maxItems, filter), [items, query, maxItems, filter]);

		const selectedItem = useMemo(() => items.find((item) => item.value === currentValue), [items, currentValue]);

		function setOpenState(next: boolean) {
			setOpen(next);
			if (!next) {
				setQuery('');
				setActiveValue(null);
			}
		}

		const handleTriggerClick = useCallback(() => {
			if (disabled) {
				return;
			}
			setOpenState(!open);
		}, [disabled, open]);

		const handleSelect = useCallback(
			(item: ComboboxItem) => {
				if (item.disabled) {
					return;
				}
				if (value === undefined) {
					setInternalValue(item.value);
				}
				onValueChange?.(item.value);
				setOpenState(false);
			},
			[value, onValueChange],
		);

		function handleTriggerKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
			if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
				event.preventDefault();
				if (disabled) {
					return;
				}
				setOpenState(true);
			}
		}

		function handleContentKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
			const enabledItems = filteredItems.filter((item) => !item.disabled);
			if (enabledItems.length === 0) {
				if (event.key === 'Escape') {
					event.preventDefault();
					setOpenState(false);
				}
				return;
			}

			const currentIndex = enabledItems.findIndex((item) => item.value === activeValue);

			switch (event.key) {
				case 'ArrowDown':
					event.preventDefault();
					setActiveValue(enabledItems[moveComboboxIndex(currentIndex, 1, enabledItems.length)].value);
					break;
				case 'ArrowUp':
					event.preventDefault();
					setActiveValue(enabledItems[moveComboboxIndex(currentIndex, -1, enabledItems.length)].value);
					break;
				case 'Home':
					event.preventDefault();
					setActiveValue(enabledItems[0].value);
					break;
				case 'End':
					event.preventDefault();
					setActiveValue(enabledItems[enabledItems.length - 1].value);
					break;
				case 'Enter':
				case ' ':
					event.preventDefault();
					handleSelect(enabledItems[currentIndex >= 0 ? currentIndex : 0]);
					break;
				case 'Escape':
					event.preventDefault();
					setOpenState(false);
					triggerRef.current?.focus();
					break;
			}
		}

		useLayoutEffect(() => {
			if (!open) {
				return;
			}

			function updatePosition() {
				const trigger = triggerRef.current;
				const content = contentRef.current;
				if (!trigger || !content) {
					return;
				}
				const triggerRect = trigger.getBoundingClientRect();
				const contentSize = { width: content.offsetWidth, height: content.offsetHeight };
				const viewport = { width: window.innerWidth, height: window.innerHeight };
				const margin = 8;
				const left = Math.min(Math.max(triggerRect.left, margin), Math.max(margin, viewport.width - contentSize.width - margin));
				const top = Math.min(Math.max(triggerRect.bottom + 4, margin), Math.max(margin, viewport.height - contentSize.height - margin));
				content.style.top = `${top}px`;
				content.style.left = `${left}px`;
			}

			updatePosition();
			window.addEventListener('resize', updatePosition);
			document.addEventListener('scroll', updatePosition, true);

			return () => {
				window.removeEventListener('resize', updatePosition);
				document.removeEventListener('scroll', updatePosition, true);
			};
		}, [open]);

		useEffect(() => {
			if (!open) {
				return;
			}

			function handlePointerDown(event: PointerEvent) {
				const target = event.target as Node | null;
				if (!target) {
					return;
				}
				const content = contentRef.current;
				const trigger = triggerRef.current;
				if (content && content.contains(target)) {
					return;
				}
				if (trigger && trigger.contains(target)) {
					return;
				}
				setOpenState(false);
			}

			document.addEventListener('pointerdown', handlePointerDown);
			return () => document.removeEventListener('pointerdown', handlePointerDown);
		}, [open]);

		const state = getSelectState(open);
		const showEmpty = filteredItems.length === 0;
		const emptyMessage = query ? notFoundText : emptyText;

		const content = open ? (
			<div ref={contentRef} id={contentId} role="listbox" tabIndex={-1} data-state={state} className={styles[comboboxStyleKeys.content]} onKeyDown={handleContentKeyDown}>
				<div className={styles[comboboxStyleKeys.search]}>
					<input
						role="combobox"
						aria-expanded={open}
						aria-controls={contentId}
						aria-autocomplete="list"
						className={styles[comboboxStyleKeys.searchInput]}
						value={query}
						placeholder={searchPlaceholder}
						autoFocus
						onChange={(event) => {
							setQuery(event.target.value);
							setActiveValue(null);
						}}
					/>
				</div>
				<div className={styles[comboboxStyleKeys.list]}>
					{showEmpty ? (
						<div className={styles[comboboxStyleKeys.empty]}>{emptyMessage}</div>
					) : (
						filteredItems.map((item) => {
							const isActive = activeValue === item.value;
							const isSelected = item.value === currentValue;
							return (
								<div
									key={item.value}
									role="option"
									tabIndex={-1}
									aria-selected={isSelected}
									data-highlighted={isActive}
									data-disabled={item.disabled}
									className={styles[comboboxStyleKeys.item]}
									onMouseEnter={() => {
										if (!item.disabled) {
											setActiveValue(item.value);
										}
									}}
									onClick={() => handleSelect(item)}>
									<span className={styles[comboboxStyleKeys.itemIndicator]}>{isSelected && <ComboboxCheckIcon />}</span>
									{item.label}
								</div>
							);
						})
					)}
				</div>
			</div>
		) : null;

		return (
			<div ref={ref} className={`${styles[comboboxStyleKeys.root]} ${className}`} {...props}>
				<button
					ref={triggerRef}
					type="button"
					aria-haspopup="listbox"
					aria-expanded={open}
					aria-controls={contentId}
					data-disabled={disabled}
					className={styles[comboboxStyleKeys.trigger]}
					onClick={handleTriggerClick}
					onKeyDown={handleTriggerKeyDown}>
					<span data-placeholder={!selectedItem} className={styles[comboboxStyleKeys.triggerValue]}>
						{selectedItem?.label ?? placeholder}
					</span>
					<ComboboxChevronIcon />
				</button>
				{open && portalContainer ? createPortal(content, portalContainer) : null}
			</div>
		);
	},
);
Combobox.displayName = 'Combobox';

export { Combobox };
export default Combobox;
Interaction
dropdown

DependenciesBundle weight · light

react-dom