← React/Продвинутые паттерны: Headless UI и Compound Components#300 из 383← ПредыдущийСледующий →+30 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksМаршрут: старт с нуля

Продвинутые паттерны: Headless UI и Compound Components

Что такое Headless UI

Headless UI — компоненты, которые предоставляют логику и доступность без какого-либо стиля. Вы получаете поведение (состояние, клавиатурная навигация, ARIA), а стиль — полностью ваш.

Зачем нужен Headless UI:

  • Любой дизайн без борьбы с чужими CSS
  • Встроенная доступность (a11y)
  • Многократное использование логики
  • Легко тестировать отдельно логику и отдельно вид
  • Популярные Headless библиотеки:

  • Radix UI — самая полная коллекция
  • Headless UI (Tailwind Labs) — для Tailwind
  • React Aria (Adobe) — упор на доступность
  • TanStack Table — headless таблицы
  • // Radix UI: Dropdown без стилей
    import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
    
    function MyDropdown() {
      return (
        <DropdownMenu.Root>
          <DropdownMenu.Trigger asChild>
            <button className="my-trigger-styles">Опции ▾</button>
          </DropdownMenu.Trigger>
    
          <DropdownMenu.Portal>
            <DropdownMenu.Content className="my-menu-styles">
              <DropdownMenu.Item className="my-item-styles" onSelect={() => edit()}>
                Редактировать
              </DropdownMenu.Item>
              <DropdownMenu.Separator />
              <DropdownMenu.Item className="my-danger-styles" onSelect={() => del()}>
                Удалить
              </DropdownMenu.Item>
            </DropdownMenu.Content>
          </DropdownMenu.Portal>
        </DropdownMenu.Root>
      )
    }

    Compound Components паттерн

    Compound Components — компоненты, которые работают вместе и неявно делят состояние через Context:

    // Пример: Tab компонент в стиле Compound
    
    // Контекст для передачи состояния между компонентами
    const TabContext = createContext(null)
    
    // Корневой компонент
    function Tabs({ defaultValue, children }) {
      const [active, setActive] = useState(defaultValue)
      return (
        <TabContext.Provider value={{ active, setActive }}>
          <div className="tabs">{children}</div>
        </TabContext.Provider>
      )
    }
    
    // Подкомпоненты через точечную нотацию
    function TabList({ children }) {
      return <div role="tablist">{children}</div>
    }
    
    function Tab({ value, children }) {
      const { active, setActive } = useContext(TabContext)
      return (
        <button
          role="tab"
          aria-selected={active === value}
          onClick={() => setActive(value)}
        >
          {children}
        </button>
      )
    }
    
    function TabPanel({ value, children }) {
      const { active } = useContext(TabContext)
      if (active !== value) return null
      return <div role="tabpanel">{children}</div>
    }
    
    // Точечная нотация — удобный API
    Tabs.List = TabList
    Tabs.Tab = Tab
    Tabs.Panel = TabPanel
    
    // Использование — декларативно и понятно!
    function App() {
      return (
        <Tabs defaultValue="overview">
          <Tabs.List>
            <Tabs.Tab value="overview">Обзор</Tabs.Tab>
            <Tabs.Tab value="details">Детали</Tabs.Tab>
          </Tabs.List>
          <Tabs.Panel value="overview">Обзорная информация...</Tabs.Panel>
          <Tabs.Panel value="details">Детальная информация...</Tabs.Panel>
        </Tabs>
      )
    }

    Context + Children API

    Compound Components строятся на Context для передачи состояния вниз:

    // Accordion с полным контролем над разметкой
    const AccordionContext = createContext(null)
    
    function Accordion({ children, allowMultiple = false }) {
      const [openItems, setOpenItems] = useState(new Set())
    
      const toggle = (id) => {
        setOpenItems(prev => {
          const next = new Set(allowMultiple ? prev : [])
          prev.has(id) ? next.delete(id) : next.add(id)
          return next
        })
      }
    
      return (
        <AccordionContext.Provider value={{ openItems, toggle }}>
          <div className="accordion">{children}</div>
        </AccordionContext.Provider>
      )
    }
    
    function AccordionItem({ id, children }) {
      const { openItems, toggle } = useContext(AccordionContext)
      const isOpen = openItems.has(id)
    
      return (
        <div className="accordion-item">
          <button
            aria-expanded={isOpen}
            aria-controls={`panel-${id}`}
            onClick={() => toggle(id)}
          >
            {children[0]}  {/* заголовок */}
          </button>
          <div
            id={`panel-${id}`}
            role="region"
            hidden={!isOpen}
          >
            {children[1]}  {/* содержимое */}
          </div>
        </div>
      )
    }

    Slot паттерн

    Slots — способ передать именованный контент в разные места компонента:

    // Компонент Card со слотами
    function Card({ slots }) {
      return (
        <div className="card">
          <div className="card__header">{slots.header}</div>
          <div className="card__body">{slots.body}</div>
          <div className="card__footer">{slots.footer}</div>
        </div>
      )
    }
    
    // Использование
    <Card slots={{
      header: <h2>Заголовок</h2>,
      body: <p>Основной контент</p>,
      footer: <button>Действие</button>,
    }} />
    
    // Или через children.filter() / React.Children.toArray
    function Dialog({ children }) {
      const title = React.Children.toArray(children).find(
        c => c.type === Dialog.Title
      )
      const body = React.Children.toArray(children).find(
        c => c.type === Dialog.Body
      )
      return (
        <div role="dialog">
          <header>{title}</header>
          <main>{body}</main>
        </div>
      )
    }

    Controlled vs Uncontrolled компоненты

    // Uncontrolled: состояние внутри компонента
    function UncontrolledInput() {
      const [value, setValue] = useState('')
      return <input value={value} onChange={e => setValue(e.target.value)} />
    }
    
    // Controlled: состояние снаружи, полный контроль
    function ControlledInput({ value, onChange }) {
      return <input value={value} onChange={e => onChange(e.target.value)} />
    }
    
    // Паттерн "управляемое с откатом" — поддерживает оба варианта
    function SmartDropdown({ value: controlledValue, onChange, defaultValue }) {
      const isControlled = controlledValue !== undefined
      const [internalValue, setInternalValue] = useState(defaultValue)
    
      const value = isControlled ? controlledValue : internalValue
    
      const handleChange = (newValue) => {
        if (!isControlled) setInternalValue(newValue)
        onChange?.(newValue)
      }
    
      return <select value={value} onChange={e => handleChange(e.target.value)} />
    }

    Примеры

    Headless Dropdown на ванильном JS: управление состоянием открытия/закрытия, выбор элементов, разделение триггера и содержимого

    // Реализуем Headless Dropdown: только логика, без стилей.
    // Паттерн Compound Components через возвращаемые части.
    
    function createDropdown(items) {
      let isOpen = false
      let selectedItem = null
      const listeners = { change: [], open: [], close: [] }
    
      function emit(event, data) {
        ;(listeners[event] || []).forEach(fn => fn(data))
      }
    
      // Состояние
      const state = {
        getIsOpen: () => isOpen,
        getSelected: () => selectedItem,
        getItems: () => items,
      }
    
      // Trigger: кнопка-триггер (получает обработчики для прикрепления)
      const trigger = {
        // Атрибуты для кнопки (aria и т.д.)
        getProps() {
          return {
            'aria-haspopup': 'listbox',
            'aria-expanded': isOpen,
            onClick: trigger.toggle,
          }
        },
        toggle() {
          isOpen ? trigger.close() : trigger.open()
        },
        open() {
          isOpen = true
          emit('open', null)
          console.log('[Trigger] Открыт')
        },
        close() {
          isOpen = false
          emit('close', null)
          console.log('[Trigger] Закрыт')
        },
      }
    
      // Content: список элементов
      const content = {
        getProps() {
          return {
            role: 'listbox',
            'aria-hidden': !isOpen,
          }
        },
        getItemProps(item) {
          return {
            role: 'option',
            'aria-selected': selectedItem === item,
            onClick: () => content.selectItem(item),
          }
        },
        selectItem(item) {
          selectedItem = item
          isOpen = false
          emit('change', item)
          emit('close', null)
          console.log('[Content] Выбрано:', item.label)
        },
      }
    
      // Поиск по клавиатуре
      function handleKeyDown(key) {
        if (!isOpen) {
          if (key === 'Enter' || key === ' ') trigger.open()
          return
        }
        if (key === 'Escape') { trigger.close(); return }
        if (key === 'Enter' && selectedItem) { content.selectItem(selectedItem); return }
    
        const currentIndex = items.indexOf(selectedItem)
        if (key === 'ArrowDown') {
          const next = items[Math.min(currentIndex + 1, items.length - 1)]
          selectedItem = next
          console.log('[Keyboard] Фокус на:', next?.label)
        }
        if (key === 'ArrowUp') {
          const prev = items[Math.max(currentIndex - 1, 0)]
          selectedItem = prev
          console.log('[Keyboard] Фокус на:', prev?.label)
        }
      }
    
      return {
        state,
        trigger,
        content,
        handleKeyDown,
        on(event, fn) { listeners[event] = [...(listeners[event] || []), fn]; return this },
      }
    }
    
    // --- Использование ---
    
    const dropdown = createDropdown([
      { id: 1, label: 'React' },
      { id: 2, label: 'Vue' },
      { id: 3, label: 'Angular' },
      { id: 4, label: 'Svelte' },
    ])
    
    dropdown
      .on('change', item => console.log('onChange:', item.label))
      .on('open', () => console.log('onOpen'))
      .on('close', () => console.log('onClose'))
    
    console.log('=== Открытие через триггер ===')
    dropdown.trigger.toggle()
    console.log('isOpen:', dropdown.state.getIsOpen())   // true
    
    console.log('
    === Навигация клавиатурой ===')
    dropdown.handleKeyDown('ArrowDown')  // React -> Vue
    dropdown.handleKeyDown('ArrowDown')  // Vue -> Angular
    dropdown.handleKeyDown('ArrowUp')    // Angular -> Vue
    
    console.log('
    === Выбор элемента ===')
    const items = dropdown.state.getItems()
    dropdown.content.selectItem(items[0])
    console.log('Выбрано:', dropdown.state.getSelected()?.label)  // React
    console.log('isOpen после выбора:', dropdown.state.getIsOpen())  // false
    
    console.log('
    === Props для рендеринга ===')
    console.log('Trigger props:', dropdown.trigger.getProps())
    dropdown.trigger.open()
    console.log('Content props:', dropdown.content.getProps())
    console.log('Item props:', dropdown.content.getItemProps(items[0]))

    Продвинутые паттерны: Headless UI и Compound Components

    Что такое Headless UI

    Headless UI — компоненты, которые предоставляют логику и доступность без какого-либо стиля. Вы получаете поведение (состояние, клавиатурная навигация, ARIA), а стиль — полностью ваш.

    Зачем нужен Headless UI:

  • Любой дизайн без борьбы с чужими CSS
  • Встроенная доступность (a11y)
  • Многократное использование логики
  • Легко тестировать отдельно логику и отдельно вид
  • Популярные Headless библиотеки:

  • Radix UI — самая полная коллекция
  • Headless UI (Tailwind Labs) — для Tailwind
  • React Aria (Adobe) — упор на доступность
  • TanStack Table — headless таблицы
  • // Radix UI: Dropdown без стилей
    import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
    
    function MyDropdown() {
      return (
        <DropdownMenu.Root>
          <DropdownMenu.Trigger asChild>
            <button className="my-trigger-styles">Опции ▾</button>
          </DropdownMenu.Trigger>
    
          <DropdownMenu.Portal>
            <DropdownMenu.Content className="my-menu-styles">
              <DropdownMenu.Item className="my-item-styles" onSelect={() => edit()}>
                Редактировать
              </DropdownMenu.Item>
              <DropdownMenu.Separator />
              <DropdownMenu.Item className="my-danger-styles" onSelect={() => del()}>
                Удалить
              </DropdownMenu.Item>
            </DropdownMenu.Content>
          </DropdownMenu.Portal>
        </DropdownMenu.Root>
      )
    }

    Compound Components паттерн

    Compound Components — компоненты, которые работают вместе и неявно делят состояние через Context:

    // Пример: Tab компонент в стиле Compound
    
    // Контекст для передачи состояния между компонентами
    const TabContext = createContext(null)
    
    // Корневой компонент
    function Tabs({ defaultValue, children }) {
      const [active, setActive] = useState(defaultValue)
      return (
        <TabContext.Provider value={{ active, setActive }}>
          <div className="tabs">{children}</div>
        </TabContext.Provider>
      )
    }
    
    // Подкомпоненты через точечную нотацию
    function TabList({ children }) {
      return <div role="tablist">{children}</div>
    }
    
    function Tab({ value, children }) {
      const { active, setActive } = useContext(TabContext)
      return (
        <button
          role="tab"
          aria-selected={active === value}
          onClick={() => setActive(value)}
        >
          {children}
        </button>
      )
    }
    
    function TabPanel({ value, children }) {
      const { active } = useContext(TabContext)
      if (active !== value) return null
      return <div role="tabpanel">{children}</div>
    }
    
    // Точечная нотация — удобный API
    Tabs.List = TabList
    Tabs.Tab = Tab
    Tabs.Panel = TabPanel
    
    // Использование — декларативно и понятно!
    function App() {
      return (
        <Tabs defaultValue="overview">
          <Tabs.List>
            <Tabs.Tab value="overview">Обзор</Tabs.Tab>
            <Tabs.Tab value="details">Детали</Tabs.Tab>
          </Tabs.List>
          <Tabs.Panel value="overview">Обзорная информация...</Tabs.Panel>
          <Tabs.Panel value="details">Детальная информация...</Tabs.Panel>
        </Tabs>
      )
    }

    Context + Children API

    Compound Components строятся на Context для передачи состояния вниз:

    // Accordion с полным контролем над разметкой
    const AccordionContext = createContext(null)
    
    function Accordion({ children, allowMultiple = false }) {
      const [openItems, setOpenItems] = useState(new Set())
    
      const toggle = (id) => {
        setOpenItems(prev => {
          const next = new Set(allowMultiple ? prev : [])
          prev.has(id) ? next.delete(id) : next.add(id)
          return next
        })
      }
    
      return (
        <AccordionContext.Provider value={{ openItems, toggle }}>
          <div className="accordion">{children}</div>
        </AccordionContext.Provider>
      )
    }
    
    function AccordionItem({ id, children }) {
      const { openItems, toggle } = useContext(AccordionContext)
      const isOpen = openItems.has(id)
    
      return (
        <div className="accordion-item">
          <button
            aria-expanded={isOpen}
            aria-controls={`panel-${id}`}
            onClick={() => toggle(id)}
          >
            {children[0]}  {/* заголовок */}
          </button>
          <div
            id={`panel-${id}`}
            role="region"
            hidden={!isOpen}
          >
            {children[1]}  {/* содержимое */}
          </div>
        </div>
      )
    }

    Slot паттерн

    Slots — способ передать именованный контент в разные места компонента:

    // Компонент Card со слотами
    function Card({ slots }) {
      return (
        <div className="card">
          <div className="card__header">{slots.header}</div>
          <div className="card__body">{slots.body}</div>
          <div className="card__footer">{slots.footer}</div>
        </div>
      )
    }
    
    // Использование
    <Card slots={{
      header: <h2>Заголовок</h2>,
      body: <p>Основной контент</p>,
      footer: <button>Действие</button>,
    }} />
    
    // Или через children.filter() / React.Children.toArray
    function Dialog({ children }) {
      const title = React.Children.toArray(children).find(
        c => c.type === Dialog.Title
      )
      const body = React.Children.toArray(children).find(
        c => c.type === Dialog.Body
      )
      return (
        <div role="dialog">
          <header>{title}</header>
          <main>{body}</main>
        </div>
      )
    }

    Controlled vs Uncontrolled компоненты

    // Uncontrolled: состояние внутри компонента
    function UncontrolledInput() {
      const [value, setValue] = useState('')
      return <input value={value} onChange={e => setValue(e.target.value)} />
    }
    
    // Controlled: состояние снаружи, полный контроль
    function ControlledInput({ value, onChange }) {
      return <input value={value} onChange={e => onChange(e.target.value)} />
    }
    
    // Паттерн "управляемое с откатом" — поддерживает оба варианта
    function SmartDropdown({ value: controlledValue, onChange, defaultValue }) {
      const isControlled = controlledValue !== undefined
      const [internalValue, setInternalValue] = useState(defaultValue)
    
      const value = isControlled ? controlledValue : internalValue
    
      const handleChange = (newValue) => {
        if (!isControlled) setInternalValue(newValue)
        onChange?.(newValue)
      }
    
      return <select value={value} onChange={e => handleChange(e.target.value)} />
    }

    Примеры

    Headless Dropdown на ванильном JS: управление состоянием открытия/закрытия, выбор элементов, разделение триггера и содержимого

    // Реализуем Headless Dropdown: только логика, без стилей.
    // Паттерн Compound Components через возвращаемые части.
    
    function createDropdown(items) {
      let isOpen = false
      let selectedItem = null
      const listeners = { change: [], open: [], close: [] }
    
      function emit(event, data) {
        ;(listeners[event] || []).forEach(fn => fn(data))
      }
    
      // Состояние
      const state = {
        getIsOpen: () => isOpen,
        getSelected: () => selectedItem,
        getItems: () => items,
      }
    
      // Trigger: кнопка-триггер (получает обработчики для прикрепления)
      const trigger = {
        // Атрибуты для кнопки (aria и т.д.)
        getProps() {
          return {
            'aria-haspopup': 'listbox',
            'aria-expanded': isOpen,
            onClick: trigger.toggle,
          }
        },
        toggle() {
          isOpen ? trigger.close() : trigger.open()
        },
        open() {
          isOpen = true
          emit('open', null)
          console.log('[Trigger] Открыт')
        },
        close() {
          isOpen = false
          emit('close', null)
          console.log('[Trigger] Закрыт')
        },
      }
    
      // Content: список элементов
      const content = {
        getProps() {
          return {
            role: 'listbox',
            'aria-hidden': !isOpen,
          }
        },
        getItemProps(item) {
          return {
            role: 'option',
            'aria-selected': selectedItem === item,
            onClick: () => content.selectItem(item),
          }
        },
        selectItem(item) {
          selectedItem = item
          isOpen = false
          emit('change', item)
          emit('close', null)
          console.log('[Content] Выбрано:', item.label)
        },
      }
    
      // Поиск по клавиатуре
      function handleKeyDown(key) {
        if (!isOpen) {
          if (key === 'Enter' || key === ' ') trigger.open()
          return
        }
        if (key === 'Escape') { trigger.close(); return }
        if (key === 'Enter' && selectedItem) { content.selectItem(selectedItem); return }
    
        const currentIndex = items.indexOf(selectedItem)
        if (key === 'ArrowDown') {
          const next = items[Math.min(currentIndex + 1, items.length - 1)]
          selectedItem = next
          console.log('[Keyboard] Фокус на:', next?.label)
        }
        if (key === 'ArrowUp') {
          const prev = items[Math.max(currentIndex - 1, 0)]
          selectedItem = prev
          console.log('[Keyboard] Фокус на:', prev?.label)
        }
      }
    
      return {
        state,
        trigger,
        content,
        handleKeyDown,
        on(event, fn) { listeners[event] = [...(listeners[event] || []), fn]; return this },
      }
    }
    
    // --- Использование ---
    
    const dropdown = createDropdown([
      { id: 1, label: 'React' },
      { id: 2, label: 'Vue' },
      { id: 3, label: 'Angular' },
      { id: 4, label: 'Svelte' },
    ])
    
    dropdown
      .on('change', item => console.log('onChange:', item.label))
      .on('open', () => console.log('onOpen'))
      .on('close', () => console.log('onClose'))
    
    console.log('=== Открытие через триггер ===')
    dropdown.trigger.toggle()
    console.log('isOpen:', dropdown.state.getIsOpen())   // true
    
    console.log('
    === Навигация клавиатурой ===')
    dropdown.handleKeyDown('ArrowDown')  // React -> Vue
    dropdown.handleKeyDown('ArrowDown')  // Vue -> Angular
    dropdown.handleKeyDown('ArrowUp')    // Angular -> Vue
    
    console.log('
    === Выбор элемента ===')
    const items = dropdown.state.getItems()
    dropdown.content.selectItem(items[0])
    console.log('Выбрано:', dropdown.state.getSelected()?.label)  // React
    console.log('isOpen после выбора:', dropdown.state.getIsOpen())  // false
    
    console.log('
    === Props для рендеринга ===')
    console.log('Trigger props:', dropdown.trigger.getProps())
    dropdown.trigger.open()
    console.log('Content props:', dropdown.content.getProps())
    console.log('Item props:', dropdown.content.getItemProps(items[0]))

    Задание

    Создай React Compound Component для Accordion с использованием Context. Accordion должен включать: корневой компонент Accordion с провайдером контекста, компонент AccordionItem который читает контекст и показывает/скрывает содержимое. Используй паттерн composition с точечной нотацией (Accordion.Item).

    Подсказка

    В toggle: next.delete(id) и next.add(id). В isOpen: openItems.has(id). Provider value: { toggle, isOpen }. useContext(AccordionContext). isOpen(id) для проверки открытия. onClick={() => toggle(id). aria-expanded={open}. Показ содержимого: {open && ...}. Accordion.Item = AccordionItem.

    Загружаем среду выполнения...
    Загружаем AI-помощника...