Антипаттерн взрыва параметров (Prop Explosion) превращает интерфейсные библиотеки в монолитные спагетти. Архитектура Compound Components разделяет ответственность: родительский контейнер инкапсулирует стейт и доступность, а субкомпоненты отвечают за конкретные функциональные роли. Внедрение механизма полиморфизма через asChild Slot исключает DOM-засорение и дает 100% контроль над визуальным оформлением через классы Tailwind CSS.
Создание масштабируемой корпоративной дизайн-системы сталкивается с тремя фундаментальными инженерными барьерами:
<Select options={items} isClearable isSearchable customArrow={<Icon />} /> навязывает фиксированный порядок DOM-узлов, продуктовой команде невозможно внедрить кастомный разделитель секций, сгруппировать опции или обернуть список в контейнер виртуализации без доработки ядра библиотеки.<div>. Это разрушает CSS Grid и Flexbox выравнивания, ломает :nth-child селекторы и наносит ущерб производительности рендеринга.Escape и лишены критических ARIA-связок (aria-controls, aria-expanded, aria-activedescendant), что приводит к штрафам за нарушение стандартов доступности (ADA / Section 508).Паттерн Compound Components в сочетании с концепцией Headless Primitives (Radix / Base UI) решает эту проблему раз и навсегда: доступность и состояние стандартизированы, а разметка остается предельно декларативной.
Compound Components работают по аналогии с нативными тегами HTML <select> и <option>. Тег <option> не принимает пропс selectedOption или onChange — он неявно синхронизируется с родительским <select>.
[ <Select.Root value="..." onValueChange="..."> ] (React.createContext)
│
├── <Select.Trigger asChild> ──> Делегирует роль кнопке через Slot
│ └── <button className="...">
│
└── <Select.Portal> ───────────> Рендерит всплывающее меню в document.body
│
└── <Select.Content> ───> Roving Tabindex + Фокус-ловушка + Escape
│
├── <Select.Item value="1"> ──> aria-selected, onKeyDown
├── <Select.Separator />
└── <Select.Item value="2">Ключевым механизмом полиморфизма является компонент Slot:
asChild === false, субкомпонент рендерит нативный DOM-элемент по умолчанию (например, <button> или <div>).asChild === true, субкомпонент не создает новый DOM-узел, а клонирует своего единственного ребенка (React.Children.only(children)).mergeProps):
composeEventHandlers(externalHandler, internalHandler).className.composeRefs(childRef, slotRef).Создание безопасной контекстной обертки createCompoundContext с информативным выбросом ошибки при использовании субкомпонента вне Root.
Разработка механизма composeRefs и composeEventHandlers для бесшовного клонирования единственного дочернего элемента при флаге asChild.
Управление состоянием открытия (open), генерация уникальных id через useId и связывание aria-expanded и aria-controls.
Навигация клавишами ArrowDown, ArrowUp, Home, End, изоляция событий клика вне окна и портальный рендеринг.
Экспорт составного компонента через Object.assign (Select.Root, Select.Trigger, Select.Content) для эргономичного DX.
Пользователь нажимает клавишу Enter или кликает по кастомной кнопке внутри Select.Trigger asChild
Сначала вызывается пользовательский обработчик: onClick={(e) => customAnalytics(e)}
Если пользователь вызвал e.preventDefault(), внутренний конвейер прерывает дальнейшее выполнение
Внутренний обработчик переключает контекстное состояние: setOpen(!open) и перемещает фокус
Атрибут aria-expanded синхронно обновляется с false на true для скринридеров
При закрытии меню фокус аппаратно возвращается на триггер без скролл-скачков
Потребитель дизайн-системы написал: <Select.Trigger asChild onClick={(e) => { e.preventDefault(); logAnalytics(); }}><button>Выбрать</button></Select.Trigger>. Как поведет себя компонент при клике?
Почему при реализации выпадающего меню некорректно назначать атрибут tabIndex={0} каждому элементу списка Select.Item?
src/components/ui/primitive-slot.tsx#Реализация компонента Slot со слиянием ссылок (refs), обработчиков событий и CSS-классов.
/**
* src/components/ui/primitive-slot.tsx
* Реализация примитива Slot в стандарте Radix UI для полиморфных компонентов.
*/
import * as React from 'react'
export interface SlotProps extends React.HTMLAttributes<HTMLElement> {
children?: React.ReactNode
}
/**
* Объединение нескольких React Ref в единый коллбэк.
*/
export function composeRefs<T>(...refs: (React.Ref<T> | undefined)[]) {
return (node: T) => {
refs.forEach((ref) => {
if (!ref) return
if (typeof ref === 'function') {
ref(node)
} else if (typeof ref === 'object' && 'current' in ref) {
;(ref as React.MutableRefObject<T | null>).current = node
}
})
}
}
/**
* Последовательное слияние обработчиков событий с поддержкой e.preventDefault().
*/
export function composeEventHandlers<E extends React.SyntheticEvent | Event>(
externalHandler?: (event: E) => void,
internalHandler?: (event: E) => void,
{ checkDefaultPrevented = true } = {}
) {
return (event: E) => {
externalHandler?.(event)
if (!checkDefaultPrevented || !event.defaultPrevented) {
internalHandler?.(event)
}
}
}
/**
* Компонент Slot клонирует единственного потомка, обогащая его объединенными пропсами.
*/
export const Slot = React.forwardRef<HTMLElement, SlotProps>((props, forwardedRef) => {
const { children, ...slotProps } = props
if (!React.isValidElement(children)) {
console.warn('[Slot] Компонент Slot ожидает ровно один валидный React элемент в качестве потомка.')
return null
}
const childProps = children.props as Record<string, any>
// Формируем объединенные пропсы
const mergedProps: Record<string, any> = { ...slotProps }
for (const propName in childProps) {
const slotValue = slotProps[propName as keyof typeof slotProps]
const childValue = childProps[propName]
const isEventHandler = /^on[A-Z]/.test(propName)
if (isEventHandler) {
if (slotValue && childValue) {
mergedProps[propName] = composeEventHandlers(childValue, slotValue)
} else if (childValue) {
mergedProps[propName] = childValue
}
} else if (propName === 'className') {
mergedProps[propName] = [slotValue, childValue].filter(Boolean).join(' ')
} else if (propName === 'style') {
mergedProps[propName] = { ...slotValue, ...childValue }
} else {
mergedProps[propName] = childValue !== undefined ? childValue : slotValue
}
}
// Слияние ref потомка и ref слота
const childRef = (children as any).ref
return React.cloneElement(children, {
...mergedProps,
ref: forwardedRef ? composeRefs(forwardedRef, childRef) : childRef,
})
})
Slot.displayName = 'Slot'src/components/ui/select/select-context.tsx#Типобезопасная фабрика контекста с проверкой скоупа вызова.
import * as React from 'react'
export interface SelectContextValue {
value: string
onValueChange: (value: string) => void
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
triggerId: string
contentId: string
activeIndex: number
setActiveIndex: React.Dispatch<React.SetStateAction<number>>
itemsMap: Map<string, { label: string; disabled: boolean }>
registerItem: (value: string, label: string, disabled: boolean) => () => void
}
const SelectContext = React.createContext<SelectContextValue | null>(null)
export function useSelectContext(consumerName: string): SelectContextValue {
const context = React.useContext(SelectContext)
if (!context) {
throw new Error(
`[Compound Component Error] Компонент <${consumerName}> должен использоваться исключительно внутри родительского контейнера <Select.Root>.`
)
}
return context
}
export const SelectProvider = SelectContext.Providersrc/components/ui/select/select-compound.tsx#Полная реализация набора субкомпонентов Select с поддержкой asChild, WAI-ARIA и клавиатурной навигации.
'use client'
import * as React from 'react'
import { createPortal } from 'react-dom'
import { Slot } from '../primitive-slot'
import { SelectProvider, useSelectContext } from './select-context'
// ── 1. Select.Root ──
export interface SelectRootProps {
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}
function SelectRoot({
value: controlledValue,
defaultValue = '',
onValueChange,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
children,
}: SelectRootProps) {
const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue)
const isValueControlled = controlledValue !== undefined
const currentValue = isValueControlled ? controlledValue : uncontrolledValue
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
const isOpenControlled = controlledOpen !== undefined
const isOpen = isOpenControlled ? controlledOpen : uncontrolledOpen
const triggerId = React.useId()
const contentId = React.useId()
const [activeIndex, setActiveIndex] = React.useState<number>(-1)
const itemsMapRef = React.useRef(new Map<string, { label: string; disabled: boolean }>())
const handleValueChange = React.useCallback(
(newValue: string) => {
if (!isValueControlled) setUncontrolledValue(newValue)
onValueChange?.(newValue)
},
[isValueControlled, onValueChange]
)
const handleOpenChange = React.useCallback(
(nextOpen: boolean | ((prev: boolean) => boolean)) => {
const resolved = typeof nextOpen === 'function' ? nextOpen(isOpen) : nextOpen
if (!isOpenControlled) setUncontrolledOpen(resolved)
onOpenChange?.(resolved)
},
[isOpen, isOpenControlled, onOpenChange]
)
const registerItem = React.useCallback(
(val: string, label: string, disabled: boolean) => {
itemsMapRef.current.set(val, { label, disabled })
return () => {
itemsMapRef.current.delete(val)
}
},
[]
)
const contextValue = React.useMemo(
() => ({
value: currentValue,
onValueChange: handleValueChange,
open: isOpen,
setOpen: handleOpenChange as any,
triggerId,
contentId,
activeIndex,
setActiveIndex,
itemsMap: itemsMapRef.current,
registerItem,
}),
[currentValue, handleValueChange, isOpen, handleOpenChange, triggerId, contentId, activeIndex, registerItem]
)
return <SelectProvider value={contextValue}>{children}</SelectProvider>
}
// ── 2. Select.Trigger ──
export interface SelectTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean
}
const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerProps>(
({ asChild = false, className = '', children, onClick, onKeyDown, ...props }, ref) => {
const { open, setOpen, triggerId, contentId } = useSelectContext('Select.Trigger')
const handleClick = () => {
setOpen((prev: boolean) => !prev)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) {
e.preventDefault()
setOpen(true)
}
}
const Comp = asChild ? Slot : 'button'
return (
<Comp
ref={ref as any}
id={triggerId}
type="button"
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={contentId}
className={className}
onClick={onClick ? (e: any) => { onClick(e); if (!e.defaultPrevented) handleClick(); } : handleClick}
onKeyDown={onKeyDown ? (e: any) => { onKeyDown(e); if (!e.defaultPrevented) handleKeyDown(e); } : handleKeyDown}
{...props}
>
{children}
</Comp>
)
}
)
SelectTrigger.displayName = 'Select.Trigger'
// ── 3. Select.Value ──
export interface SelectValueProps {
placeholder?: string
className?: string
}
function SelectValue({ placeholder = 'Выберите вариант...', className = '' }: SelectValueProps) {
const { value, itemsMap } = useSelectContext('Select.Value')
const item = itemsMap.get(value)
return <span className={className}>{item ? item.label : placeholder}</span>
}
// ── 4. Select.Content ──
export interface SelectContentProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string
}
const SelectContent = React.forwardRef<HTMLDivElement, SelectContentProps>(
({ className = '', children, ...props }, forwardedRef) => {
const { open, setOpen, contentId, triggerId } = useSelectContext('Select.Content')
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
}, [])
// Закрытие по Escape и клику вне контента
React.useEffect(() => {
if (!open) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setOpen(false)
document.getElementById(triggerId)?.focus()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [open, setOpen, triggerId])
if (!open || !mounted) return null
return createPortal(
<div
ref={forwardedRef}
id={contentId}
role="listbox"
aria-labelledby={triggerId}
className={`fixed z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-surface p-1 shadow-md animate-in fade-in-80 ${className}`}
{...props}
>
{children}
</div>,
document.body
)
}
)
SelectContent.displayName = 'Select.Content'
// ── 5. Select.Item ──
export interface SelectItemProps extends React.HTMLAttributes<HTMLDivElement> {
value: string
disabled?: boolean
className?: string
}
const SelectItem = React.forwardRef<HTMLDivElement, SelectItemProps>(
({ value, disabled = false, className = '', children, onClick, ...props }, ref) => {
const { value: selectedValue, onValueChange, setOpen, registerItem, triggerId } = useSelectContext('Select.Item')
const isSelected = selectedValue === value
const textRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
const text = textRef.current?.textContent || value
return registerItem(value, text, disabled)
}, [value, disabled, registerItem])
const handleSelect = () => {
if (disabled) return
onValueChange(value)
setOpen(false)
document.getElementById(triggerId)?.focus()
}
return (
<div
ref={ref}
role="option"
aria-selected={isSelected}
aria-disabled={disabled}
data-disabled={disabled ? '' : undefined}
tabIndex={disabled ? -1 : 0}
onClick={handleSelect}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleSelect()
}
}}
className={`relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-surface-hover focus:bg-surface-hover ${
isSelected ? 'font-semibold text-primary' : 'text-text-primary'
} ${disabled ? 'pointer-events-none opacity-50' : ''} ${className}`}
{...props}
>
<div ref={textRef} className="flex-1">
{children}
</div>
{isSelected && (
<span className="ml-2 text-primary font-bold">✓</span>
)}
</div>
)
}
)
SelectItem.displayName = 'Select.Item'
// ── 6. Select.Separator ──
function SelectSeparator({ className = '' }: { className?: string }) {
return <div role="separator" className={`my-1 h-px bg-border ${className}`} />
}
// ── Экспорт составного пространства имен (Namespace Pattern) ──
export const Select = {
Root: SelectRoot,
Trigger: SelectTrigger,
Value: SelectValue,
Content: SelectContent,
Item: SelectItem,
Separator: SelectSeparator,
}src/components/forms/profile-form-example.tsx#Применение готовой дизайн-системы с кастомным оформлением и слотом asChild.
'use client'
import * as React from 'react'
import { Select } from '@/components/ui/select/select-compound'
export function UserRolePicker() {
const [role, setRole] = React.useState('developer')
return (
<div className="w-full max-w-sm space-y-2">
<label className="text-sm font-medium text-text-primary">
Роль участника в репозитории:
</label>
<Select.Root value={role} onValueChange={setRole}>
{/* asChild позволяет использовать готовую стилизованную кнопку без лишнего wrapper-div */}
<Select.Trigger
asChild
className="w-full flex items-center justify-between px-3 py-2 bg-surface border border-border rounded-lg text-sm shadow-sm hover:border-primary/50 focus:ring-2 focus:ring-primary/20"
>
<button type="button">
<Select.Value placeholder="Выберите роль..." />
<span className="ml-2 text-text-secondary text-xs">▼</span>
</button>
</Select.Trigger>
<Select.Content className="w-64 mt-1">
<Select.Item value="developer">Разработчик (Developer)</Select.Item>
<Select.Item value="maintainer">Мейнтейнер (Maintainer)</Select.Item>
<Select.Item value="owner">Владелец проекта (Owner)</Select.Item>
<Select.Separator />
<Select.Item value="billing" disabled>
Биллинг (Недоступно для вашей подписки)
</Select.Item>
</Select.Content>
</Select.Root>
<p className="text-xs text-text-secondary">
Текущая выбранная роль: <code className="text-primary font-mono">{role}</code>
</p>
</div>
)
}Slot с asChild#Компонент Slot ожидает строго один дочерний React-элемент для корректного клонирования и слияния рефов:
// ОШИБКА: два ребенка внутри Slot приводят к аварийному завершению
<Select.Trigger asChild>
<span>Иконка</span>
<button>Открыть</button>
</Select.Trigger>
// ПРАВИЛЬНО: один дочерний элемент
<Select.Trigger asChild>
<button className="flex items-center gap-2">
<span>Иконка</span>
<span>Открыть</span>
</button>
</Select.Trigger>event.defaultPrevented при объединении событий#Если функция слияния обработчиков не проверяет, был ли вызван preventDefault(), пользователь не сможет заблокировать открытие меню при необходимости:
// АНТИПАТТЕРН: слепой вызов обоих обработчиков
const badMerge = (ext, int) => (e) => {
ext?.(e)
int?.(e) // Вызовется ВСЕГДА, даже если ext отменил действие!
}
// ПРАВИЛЬНО: проверка e.defaultPrevented
const goodMerge = (ext, int) => (e) => {
ext?.(e)
if (!e.defaultPrevented) int?.(e)
}tabIndex={0} на пунктах меню вместо Roving Tabindex#Назначение tabIndex={0} на десятках пунктов превращает навигацию клавиатурой в мучение, заставляя пользователя нажимать Tab сотни раз для выхода из виджета.