mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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
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:
parent
843b6fec4f
commit
88db51521d
3 changed files with 556 additions and 0 deletions
329
apps/web/src/components/CustomSelect.tsx
Normal file
329
apps/web/src/components/CustomSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
102
apps/web/tests/components/CustomSelect.test.tsx
Normal file
102
apps/web/tests/components/CustomSelect.test.tsx
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue