feat(web): add custom select primitive (#1714)
Some checks failed
ci / Packaged mac smoke (push) Blocked by required conditions
ci / Packaged windows smoke (push) Blocked by required conditions
ci / Detect PR change scopes (push) Failing after 2s
ci / Validate workspace (push) Has been skipped
nix-check / build (push) Failing after 1s

* feat(web): add custom select primitive

* fix(web): harden custom select active option state
This commit is contained in:
Quang Do 2026-05-15 13:43:18 +07:00 committed by GitHub
parent 843b6fec4f
commit 88db51521d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 556 additions and 0 deletions

View file

@ -0,0 +1,329 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent } from 'react';
import { createPortal } from 'react-dom';
import { Icon } from './Icon';
export interface CustomSelectOption {
value: string;
label: string;
disabled?: boolean;
}
export interface CustomSelectGroup {
label: string;
options: CustomSelectOption[];
}
export type CustomSelectItem = CustomSelectOption | CustomSelectGroup;
interface Props {
value: string;
options: CustomSelectItem[];
onChange: (value: string) => void;
ariaLabel: string;
labelledBy?: string;
className?: string;
triggerClassName?: string;
menuClassName?: string;
disabled?: boolean;
placeholder?: string;
portal?: boolean;
title?: string;
onFocus?: () => void;
}
interface FlatOption extends CustomSelectOption {
group?: string;
}
interface MenuPosition {
top: number;
left: number;
width: number;
maxHeight: number;
}
function isGroup(item: CustomSelectItem): item is CustomSelectGroup {
return 'options' in item;
}
function flattenOptions(items: CustomSelectItem[]): FlatOption[] {
return items.flatMap((item) =>
isGroup(item)
? item.options.map((option) => ({ ...option, group: item.label }))
: [item],
);
}
export function CustomSelect({
value,
options,
onChange,
ariaLabel,
labelledBy,
className,
triggerClassName,
menuClassName,
disabled = false,
placeholder,
portal = true,
title,
onFocus,
}: Props) {
const reactId = useId();
const idBase = reactId.replace(/:/g, '');
const buttonRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const wasOpenRef = useRef(false);
const activeSourceValueRef = useRef(value);
const [open, setOpen] = useState(false);
const [activeValue, setActiveValue] = useState(value);
const [position, setPosition] = useState<MenuPosition | null>(null);
const flatOptions = useMemo(() => flattenOptions(options), [options]);
const selected = flatOptions.find((option) => option.value === value);
const selectedLabel = selected?.label ?? placeholder ?? value;
const enabledOptions = useMemo(
() => flatOptions.filter((option) => !option.disabled),
[flatOptions],
);
const flatOptionsRef = useRef(flatOptions);
const enabledOptionsRef = useRef(enabledOptions);
flatOptionsRef.current = flatOptions;
enabledOptionsRef.current = enabledOptions;
const optionIdByValue = useMemo(
() => new Map(flatOptions.map((option, index) => [option.value, `${idBase}-option-${index}`])),
[flatOptions, idBase],
);
const activeOptionId = open && activeValue ? optionIdByValue.get(activeValue) : undefined;
const updatePosition = useCallback(() => {
if (!buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect();
const gap = 4;
const viewportPad = 12;
const below = window.innerHeight - rect.bottom - viewportPad;
const above = rect.top - viewportPad;
const maxHeight = Math.max(160, Math.min(300, Math.max(below, above) - gap));
const openAbove = below < 180 && above > below;
setPosition({
top: openAbove ? Math.max(viewportPad, rect.top - maxHeight - gap) : rect.bottom + gap,
left: Math.min(
Math.max(viewportPad, rect.left),
Math.max(viewportPad, window.innerWidth - rect.width - viewportPad),
),
width: rect.width,
maxHeight,
});
}, []);
useEffect(() => {
if (!portal) return;
if (!open) {
setPosition(null);
return;
}
updatePosition();
}, [open, portal, updatePosition]);
useEffect(() => {
if (!open) {
wasOpenRef.current = false;
activeSourceValueRef.current = value;
return;
}
if (wasOpenRef.current && activeSourceValueRef.current === value) return;
const selectedOption = flatOptionsRef.current.find((option) => option.value === value && !option.disabled);
setActiveValue(selectedOption?.value ?? enabledOptionsRef.current[0]?.value ?? '');
wasOpenRef.current = true;
activeSourceValueRef.current = value;
}, [open, value]);
useEffect(() => {
if (!open) return;
const onPointerDown = (event: MouseEvent) => {
const target = event.target as Node;
if (buttonRef.current?.contains(target) || menuRef.current?.contains(target)) return;
setOpen(false);
};
const onScrollOrResize = () => {
if (portal) updatePosition();
};
document.addEventListener('mousedown', onPointerDown);
window.addEventListener('resize', onScrollOrResize);
window.addEventListener('scroll', onScrollOrResize, true);
return () => {
document.removeEventListener('mousedown', onPointerDown);
window.removeEventListener('resize', onScrollOrResize);
window.removeEventListener('scroll', onScrollOrResize, true);
};
}, [open, portal]);
const choose = (nextValue: string) => {
const next = flatOptions.find((option) => option.value === nextValue);
if (!next || next.disabled) return;
onChange(next.value);
setOpen(false);
buttonRef.current?.focus();
};
const moveActive = (direction: 1 | -1) => {
if (!enabledOptions.length) return;
const currentIndex = enabledOptions.findIndex((option) => option.value === activeValue);
const nextIndex =
currentIndex < 0
? 0
: (currentIndex + direction + enabledOptions.length) % enabledOptions.length;
setActiveValue(enabledOptions[nextIndex]!.value);
};
const onButtonKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
if (!open) {
setOpen(true);
return;
}
moveActive(event.key === 'ArrowDown' ? 1 : -1);
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (open) {
choose(activeValue || value);
} else {
setOpen(true);
}
return;
}
if (event.key === 'Escape' && open) {
event.preventDefault();
event.stopPropagation();
setOpen(false);
}
};
const menu = (
<div
ref={menuRef}
id={`${idBase}-menu`}
className={[
'od-select-menu',
portal ? 'portal' : 'inline',
menuClassName,
].filter(Boolean).join(' ')}
role="listbox"
aria-label={ariaLabel}
style={
portal && position
? {
top: position.top,
left: position.left,
width: position.width,
maxHeight: position.maxHeight,
}
: undefined
}
>
{options.map((item) => {
if (isGroup(item)) {
return (
<div className="od-select-group" key={`group:${item.label}`}>
<div className="od-select-group-label">{item.label}</div>
{item.options.map((option) => (
<SelectOptionButton
key={option.value}
option={option}
selected={option.value === value}
active={option.value === activeValue}
id={optionIdByValue.get(option.value)}
onChoose={choose}
onActive={setActiveValue}
/>
))}
</div>
);
}
return (
<SelectOptionButton
key={item.value}
option={item}
selected={item.value === value}
active={item.value === activeValue}
id={optionIdByValue.get(item.value)}
onChoose={choose}
onActive={setActiveValue}
/>
);
})}
</div>
);
return (
<div className={['od-select', className].filter(Boolean).join(' ')}>
<button
ref={buttonRef}
type="button"
className={['od-select-trigger', triggerClassName].filter(Boolean).join(' ')}
role="combobox"
value={value}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={`${idBase}-menu`}
aria-activedescendant={activeOptionId}
aria-describedby={labelledBy}
aria-label={`${ariaLabel}: ${selectedLabel}`}
disabled={disabled}
title={title}
onClick={() => setOpen((current) => !current)}
onKeyDown={onButtonKeyDown}
onFocus={onFocus}
>
<span id={`${idBase}-value`} className="od-select-value">
{selectedLabel}
</span>
<Icon name="chevron-down" size={14} />
</button>
{open ? (portal ? (position ? createPortal(menu, document.body) : null) : menu) : null}
</div>
);
}
function SelectOptionButton({
option,
selected,
active,
id,
onChoose,
onActive,
}: {
option: CustomSelectOption;
selected: boolean;
active: boolean;
id?: string;
onChoose: (value: string) => void;
onActive: (value: string) => void;
}) {
return (
<button
id={id}
type="button"
className={[
'od-select-option',
selected ? 'selected' : '',
active ? 'active' : '',
].filter(Boolean).join(' ')}
role="option"
aria-selected={selected}
tabIndex={-1}
disabled={option.disabled}
onMouseEnter={() => onActive(option.value)}
onClick={() => onChoose(option.value)}
>
<span className="od-select-option-label">{option.label}</span>
<span className="od-select-option-check" aria-hidden>
<Icon name="check" size={13} />
</span>
</button>
);
}

View file

@ -333,6 +333,131 @@ select::-ms-expand { display: none; }
}
textarea { resize: vertical; font-family: inherit; }
.od-select {
position: relative;
width: 100%;
min-width: 0;
}
.od-select-trigger {
width: 100%;
min-width: 0;
min-height: 36px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 7px 10px;
font: inherit;
color: var(--text);
text-align: left;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease;
}
.od-select-trigger:hover:not(:disabled),
.od-select-trigger[aria-expanded='true'] {
border-color: var(--border-strong);
background: var(--bg-subtle);
}
.od-select-trigger:focus-visible,
.od-select-trigger[aria-expanded='true'] {
outline: none;
border-color: var(--selected);
box-shadow: 0 0 0 3px var(--selected-soft);
}
.od-select-trigger:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.od-select-trigger svg {
color: var(--text-muted);
transition: transform 120ms ease;
}
.od-select-trigger[aria-expanded='true'] svg {
transform: rotate(180deg);
}
.od-select-value,
.od-select-option-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.od-select-menu {
z-index: 9000;
padding: 4px;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
max-width: calc(100vw - 24px);
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
}
.od-select-menu.portal {
position: fixed;
}
.od-select-menu.inline {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: min(280px, 48vh);
}
.od-select-option {
width: 100%;
min-width: 0;
min-height: 30px;
display: grid;
grid-template-columns: minmax(0, 1fr) 16px;
align-items: center;
gap: 8px;
padding: 6px 8px;
color: var(--text);
background: transparent;
border: 0;
border-radius: 6px;
font: inherit;
font-size: 12.5px;
text-align: left;
cursor: pointer;
}
.od-select-option:hover:not(:disabled),
.od-select-option.active:not(:disabled) {
background: var(--bg-subtle);
}
.od-select-option.selected {
color: var(--text);
font-weight: 600;
}
.od-select-option:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.od-select-option-check {
display: inline-flex;
justify-content: center;
color: var(--selected);
opacity: 0;
}
.od-select-option.selected .od-select-option-check {
opacity: 1;
}
.od-select-group + .od-select-group {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--border-soft);
}
.od-select-group-label {
padding: 6px 8px 4px;
color: var(--text-faint);
font-size: 11px;
font-weight: 600;
}
code {
font-family: var(--mono);
background: var(--bg-subtle);

View file

@ -0,0 +1,102 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CustomSelect } from '../../src/components/CustomSelect';
afterEach(() => cleanup());
describe('CustomSelect', () => {
it('renders the selected label and chooses an option from the portal menu', () => {
const onChange = vi.fn();
render(
<CustomSelect
ariaLabel="Model"
value="gpt-image-2"
options={[
{ value: 'gpt-image-2', label: 'GPT Image 2' },
{ value: 'seedance', label: 'Seedance' },
]}
onChange={onChange}
/>,
);
const trigger = screen.getByRole('combobox', { name: 'Model: GPT Image 2' });
expect(trigger.getAttribute('aria-expanded')).toBe('false');
fireEvent.click(trigger);
expect(trigger.getAttribute('aria-expanded')).toBe('true');
fireEvent.click(screen.getByRole('option', { name: /Seedance/ }));
expect(onChange).toHaveBeenCalledWith('seedance');
});
it('skips disabled options and supports keyboard selection', () => {
const onChange = vi.fn();
render(
<CustomSelect
ariaLabel="Provider"
value="openai"
options={[
{ value: 'openai', label: 'OpenAI' },
{ value: 'disabled', label: 'Disabled', disabled: true },
{ value: 'custom', label: 'Custom' },
]}
onChange={onChange}
/>,
);
const trigger = screen.getByRole('combobox', { name: 'Provider: OpenAI' });
fireEvent.keyDown(trigger, { key: 'ArrowDown' });
fireEvent.keyDown(trigger, { key: 'ArrowDown' });
expect(trigger.getAttribute('aria-activedescendant')).toBe(
screen.getByRole('option', { name: /Custom/ }).id,
);
expect(trigger.getAttribute('aria-activedescendant')).not.toBe(
screen.getByRole('option', { name: /Disabled/ }).id,
);
fireEvent.keyDown(trigger, { key: 'Enter' });
expect(onChange).toHaveBeenCalledWith('custom');
expect(onChange).not.toHaveBeenCalledWith('disabled');
});
it('keeps keyboard navigation active state across parent rerenders with fresh options', () => {
const onChange = vi.fn();
const options = () => [
{ value: 'first', label: 'First' },
{ value: 'second', label: 'Second' },
{ value: 'third', label: 'Third' },
];
const { rerender } = render(
<CustomSelect
ariaLabel="Template"
value="first"
options={options()}
onChange={onChange}
/>,
);
const trigger = screen.getByRole('combobox', { name: 'Template: First' });
fireEvent.keyDown(trigger, { key: 'ArrowDown' });
fireEvent.keyDown(trigger, { key: 'ArrowDown' });
expect(trigger.getAttribute('aria-activedescendant')).toBe(
screen.getByRole('option', { name: /Second/ }).id,
);
rerender(
<CustomSelect
ariaLabel="Template"
value="first"
options={options()}
onChange={onChange}
/>,
);
const rerenderedTrigger = screen.getByRole('combobox', { name: 'Template: First' });
expect(rerenderedTrigger.getAttribute('aria-activedescendant')).toBe(
screen.getByRole('option', { name: /Second/ }).id,
);
});
});