Select
Custom select dropdown for Tile UI.
packages/react/src/components/select/select.tsx
import React, { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { getSelectCheckState, getSelectPosition, getSelectState, selectStyleKeys } from '../lib/core';
import type { SelectBaseProps, SelectContentBaseProps, SelectItemBaseProps, SelectPositionResult, SelectTriggerBaseProps } from '../lib/core';
import { usePortalContainer, type PortalContainer } from '../portal';
import styles from './select.module.scss';
interface SelectContextValue {
open: boolean;
setOpen: (open: boolean) => void;
value: string | undefined;
setValue: (value: string) => void;
triggerRef: React.RefObject<HTMLButtonElement | null>;
contentId: string;
itemTexts: Record<string, string>;
registerItemText: (value: string, text: string) => void;
}
const SelectContext = createContext<SelectContextValue | null>(null);
function useSelectContext(): SelectContextValue {
const context = useContext(SelectContext);
if (!context) {
throw new Error('Select 子组件必须位于 <Select> 内部。');
}
return context;
}
interface SelectContentContextValue {
itemsRef: React.RefObject<HTMLElement[]>;
close: () => void;
}
const SelectContentContext = createContext<SelectContentContextValue | null>(null);
function useSelectContentContext(): SelectContentContextValue {
const context = useContext(SelectContentContext);
if (!context) {
throw new Error('SelectItem 必须位于 <SelectContent> 内部。');
}
return context;
}
function SelectCheckIcon() {
return (
<svg
className={styles[selectStyleKeys.checkIcon]}
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 SelectChevronIcon() {
return (
<svg
className={styles[selectStyleKeys.chevron]}
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 SelectProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'defaultValue'>, SelectBaseProps {}
const Select = React.forwardRef<HTMLDivElement, SelectProps>(
({ className = '', open, defaultOpen = false, onOpenChange, value, defaultValue, onValueChange, children, ...props }, ref) => {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isOpen = open !== undefined ? open : internalOpen;
const [internalValue, setInternalValue] = useState(defaultValue);
const resolvedValue = value !== undefined ? value : internalValue;
const triggerRef = useRef<HTMLButtonElement | null>(null);
const contentId = useId();
const [itemTexts, setItemTexts] = useState<Record<string, string>>({});
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) {
setInternalOpen(next);
}
onOpenChange?.(next);
},
[open, onOpenChange],
);
const setValue = useCallback(
(next: string) => {
if (value === undefined) {
setInternalValue(next);
}
onValueChange?.(next);
},
[value, onValueChange],
);
const registerItemText = useCallback((itemValue: string, text: string) => {
setItemTexts((prev) => (prev[itemValue] === text ? prev : { ...prev, [itemValue]: text }));
}, []);
return (
<SelectContext.Provider value={{ open: isOpen, setOpen, value: resolvedValue, setValue, triggerRef, contentId, itemTexts, registerItemText }}>
<div ref={ref} className={`${styles[selectStyleKeys.root]} ${className}`} {...props}>
{children}
</div>
</SelectContext.Provider>
);
},
);
Select.displayName = 'Select';
export interface SelectTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, SelectTriggerBaseProps {}
const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerProps>(({ className = '', size = 'default', children, onClick, onKeyDown, ...props }, ref) => {
const context = useSelectContext();
function setRef(element: HTMLButtonElement | null) {
context.triggerRef.current = element;
if (typeof ref === 'function') {
ref(element);
} else if (ref) {
ref.current = element;
}
}
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
onClick?.(event);
if (event.defaultPrevented) {
return;
}
context.setOpen(!context.open);
}
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
onKeyDown?.(event);
if (event.defaultPrevented) {
return;
}
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
context.setOpen(true);
}
}
const state = getSelectState(context.open);
const classes = [styles[selectStyleKeys.trigger], className].filter(Boolean).join(' ');
return (
<button
ref={setRef}
type="button"
role="combobox"
aria-haspopup="listbox"
aria-expanded={context.open}
aria-controls={context.contentId}
data-state={state}
data-size={size}
className={classes}
onClick={handleClick}
onKeyDown={handleKeyDown}
{...props}>
{children}
<SelectChevronIcon />
</button>
);
});
SelectTrigger.displayName = 'SelectTrigger';
export interface SelectValueProps extends React.HTMLAttributes<HTMLSpanElement> {
/** 未选中时展示的占位文本 */
placeholder?: string;
}
const SelectValue = React.forwardRef<HTMLSpanElement, SelectValueProps>(({ className = '', placeholder, children, ...props }, ref) => {
const { value, itemTexts } = useSelectContext();
const text = value !== undefined ? itemTexts[value] : undefined;
const showPlaceholder = !text;
const classes = [styles[selectStyleKeys.value], className].filter(Boolean).join(' ');
return (
<span ref={ref} data-placeholder={showPlaceholder} className={classes} {...props}>
{text ?? placeholder ?? children}
</span>
);
});
SelectValue.displayName = 'SelectValue';
export interface SelectContentProps extends React.HTMLAttributes<HTMLDivElement>, SelectContentBaseProps {
container?: PortalContainer;
}
const SelectContent = React.forwardRef<HTMLDivElement, SelectContentProps>(
({ className = '', position = 'item-aligned', align = 'center', sideOffset = 4, container, children, onKeyDown, ...props }, ref) => {
const { open, setOpen, triggerRef, contentId } = useSelectContext();
const portalContainer = usePortalContainer(container);
const [coords, setCoords] = useState<SelectPositionResult | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const itemsRef = useRef<HTMLElement[]>([]);
function setRef(element: HTMLDivElement | null) {
contentRef.current = element;
if (typeof ref === 'function') {
ref(element);
} else if (ref) {
ref.current = element;
}
}
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 };
setCoords(getSelectPosition({ triggerRect, contentSize, align, sideOffset, viewport }));
}
function highlightSelected() {
const items = itemsRef.current;
items.forEach((item) => item.removeAttribute('data-highlighted'));
const selected = items.find((item) => item.getAttribute('aria-selected') === 'true');
const target = selected ?? items[0];
if (target) {
target.setAttribute('data-highlighted', 'true');
target.focus();
}
}
updatePosition();
highlightSelected();
window.addEventListener('resize', updatePosition);
document.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
document.removeEventListener('scroll', updatePosition, true);
};
}, [open, align, sideOffset, triggerRef]);
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;
}
setOpen(false);
}
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [open, triggerRef, setOpen]);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
onKeyDown?.(event);
if (event.defaultPrevented) {
return;
}
const items = itemsRef.current.filter((item) => item.getAttribute('data-disabled') !== 'true');
if (items.length === 0) {
if (event.key === 'Escape') {
event.preventDefault();
setOpen(false);
triggerRef.current?.focus();
}
return;
}
const currentIndex = items.findIndex((item) => item.getAttribute('data-highlighted') === 'true');
const highlight = (next: number) => {
items.forEach((item) => item.removeAttribute('data-highlighted'));
items[next].setAttribute('data-highlighted', 'true');
items[next].focus();
};
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
highlight(currentIndex < 0 ? 0 : (currentIndex + 1) % items.length);
break;
case 'ArrowUp':
event.preventDefault();
highlight(currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length);
break;
case 'Home':
event.preventDefault();
highlight(0);
break;
case 'End':
event.preventDefault();
highlight(items.length - 1);
break;
case 'Enter':
case ' ':
event.preventDefault();
(currentIndex >= 0 ? items[currentIndex] : items[0]).click();
break;
case 'Escape':
event.preventDefault();
setOpen(false);
triggerRef.current?.focus();
break;
}
}
const state = getSelectState(open);
const classes = [styles[selectStyleKeys.content], className].filter(Boolean).join(' ');
const content = (
<SelectContentContext.Provider value={{ itemsRef, close: () => setOpen(false) }}>
<div
ref={setRef}
id={contentId}
role="listbox"
tabIndex={-1}
data-state={state}
data-position={position}
className={classes}
style={coords ? { top: `${coords.top}px`, left: `${coords.left}px` } : undefined}
onKeyDown={handleKeyDown}
{...props}>
{children}
</div>
</SelectContentContext.Provider>
);
if (!portalContainer) {
return null;
}
return createPortal(content, portalContainer);
},
);
SelectContent.displayName = 'SelectContent';
export interface SelectGroupProps extends React.HTMLAttributes<HTMLDivElement> {}
const SelectGroup = React.forwardRef<HTMLDivElement, SelectGroupProps>(({ className = '', children, ...props }, ref) => {
return (
<div ref={ref} role="group" className={`${styles[selectStyleKeys.group]} ${className}`} {...props}>
{children}
</div>
);
});
SelectGroup.displayName = 'SelectGroup';
export interface SelectLabelProps extends React.HTMLAttributes<HTMLDivElement> {}
const SelectLabel = React.forwardRef<HTMLDivElement, SelectLabelProps>(({ className = '', children, ...props }, ref) => {
const classes = [styles[selectStyleKeys.label], className].filter(Boolean).join(' ');
return (
<div ref={ref} className={classes} {...props}>
{children}
</div>
);
});
SelectLabel.displayName = 'SelectLabel';
export interface SelectItemProps extends React.HTMLAttributes<HTMLDivElement>, SelectItemBaseProps {}
const SelectItem = React.forwardRef<HTMLDivElement, SelectItemProps>(({ className = '', value, disabled = false, children, onClick, ...props }, ref) => {
const { itemsRef, close } = useSelectContentContext();
const { value: selectedValue, setValue, registerItemText } = useSelectContext();
const itemRef = useRef<HTMLDivElement | null>(null);
const isSelected = selectedValue === value;
useEffect(() => {
const element = itemRef.current;
if (!element) {
return;
}
itemsRef.current.push(element);
return () => {
itemsRef.current = itemsRef.current.filter((item) => item !== element);
};
}, [itemsRef]);
useLayoutEffect(() => {
const element = itemRef.current;
if (element) {
registerItemText(value, element.textContent ?? '');
}
}, [value, registerItemText, children]);
function setRef(element: HTMLDivElement | null) {
itemRef.current = element;
if (typeof ref === 'function') {
ref(element);
} else if (ref) {
ref.current = element;
}
}
function handleClick(event: React.MouseEvent<HTMLDivElement>) {
onClick?.(event);
if (event.defaultPrevented) {
return;
}
if (disabled) {
return;
}
setValue(value);
close();
}
const classes = [styles[selectStyleKeys.item], className].filter(Boolean).join(' ');
return (
<div
ref={setRef}
role="option"
tabIndex={-1}
aria-selected={isSelected}
data-checked={getSelectCheckState(isSelected)}
data-disabled={disabled}
className={classes}
onClick={handleClick}
{...props}>
<span className={styles[selectStyleKeys.indicator]}>{isSelected && <SelectCheckIcon />}</span>
{children}
</div>
);
});
SelectItem.displayName = 'SelectItem';
export interface SelectSeparatorProps extends React.HTMLAttributes<HTMLDivElement> {}
const SelectSeparator = React.forwardRef<HTMLDivElement, SelectSeparatorProps>(({ className = '', ...props }, ref) => {
const classes = [styles[selectStyleKeys.separator], className].filter(Boolean).join(' ');
return <div ref={ref} role="separator" className={classes} {...props} />;
});
SelectSeparator.displayName = 'SelectSeparator';
export interface SelectScrollUpButtonProps extends React.HTMLAttributes<HTMLDivElement> {}
const SelectScrollUpButton = React.forwardRef<HTMLDivElement, SelectScrollUpButtonProps>(({ className = '', ...props }, ref) => {
const classes = [styles[selectStyleKeys.scrollButton], className].filter(Boolean).join(' ');
return (
<div ref={ref} className={classes} {...props}>
<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="m18 15-6-6-6 6" />
</svg>
</div>
);
});
SelectScrollUpButton.displayName = 'SelectScrollUpButton';
export interface SelectScrollDownButtonProps extends React.HTMLAttributes<HTMLDivElement> {}
const SelectScrollDownButton = React.forwardRef<HTMLDivElement, SelectScrollDownButtonProps>(({ className = '', ...props }, ref) => {
const classes = [styles[selectStyleKeys.scrollButton], className].filter(Boolean).join(' ');
return (
<div ref={ref} className={classes} {...props}>
<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="m6 9 6 6 6-6" />
</svg>
</div>
);
});
SelectScrollDownButton.displayName = 'SelectScrollDownButton';
export { Select, SelectTrigger, SelectValue, SelectContent, SelectGroup, SelectLabel, SelectItem, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton };
export default Select;
- Interaction
- dropdown
DependenciesBundle weight · light
react-dom