Headless UI предоставляет неоформленные интерактивные компоненты React для диалоговых окон, списков вариантов и переключателей. В этом руководстве показано, как сочетать их нативное управление фокусом, выбором и всплывающими элементами с обычным CSS на примере процесса предоставления доступа к документу.
Руководство OpenDesign9 глав27 справочных записейАвторское руководство по приложению
01
Предварительный просмотр интерфейса дизайн-системы Headless UI
Откройте диалоговое окно общего доступа, проверьте адрес электронной почты, выберите разрешение с клавиатуры, добавьте приглашение в локальном режиме и переключите состояние публичной ссылки.
Нативный @headlessui/react 2.2.10 с React 19. Все цвета, шрифты, интервалы и макет — оригинальные значения примера CSS, а не официальные токены Headless UI. · Справочник по реализации
02
Справочник дизайн-системы Headless UI
У Headless UI нет официальной шкалы визуальных токенов. Все числовые значения и цвета ниже относятся к этому примеру OpenDesign; в связанной документации описаны поведение и API состояний. Проверенный пакет: @headlessui/react 2.2.10.
Дизайн-система Headless UI: сначала составьте модель взаимодействия, затем оформляйте экран
Сначала составьте модель взаимодействия, затем оформляйте экран
Dialog, Listbox и Switch представляют разные виды взаимодействия. Dialog управляет ограниченной задачей и контекстом фокуса; Listbox представляет один зафиксированный выбор; Switch отвечает за независимый логический параметр. Их можно объединить, не объединяя состояние: черновик приглашения, выбранное разрешение и настройка публичной ссылки остаются раздельными.
В примере используется компонент React API с прямым импортом, например DialogPanel и ListboxButton. В диалоговом окне находится список Listbox, поэтому позиционирование всплывающего окна и его закрытие должны работать внутри уже открытого оверлея. Сначала реализуйте эту композицию, а затем добавьте вокруг неё панель документа и сводку по доступу. Переданный CSS задаёт единое оформление этих нативных элементов.
Анатомия Dialog, Listbox и Switch в дизайн-системе Headless UI
Продумайте границы слоя
DialogBackdrop — соседний элемент контейнера с фиксированным позиционированием. DialogPanel — ограниченная белая панель внутри этого контейнера; она не заполняет область просмотра. DialogTitle называет задачу, а Description объясняет последствия закрытия окна. Нативный элемент ввода содержит data-autofocus, а Headless UI управляет поведением фокуса в окружающем диалоговом окне.
ListboxButton показывает выбранную роль. В ListboxOptions задан anchor="bottom" и ширина, основанная на --button-width, поэтому всплывающее меню выравнивается относительно кнопки, которая его открывает. У каждого ListboxOption есть постоянное строковое значение. Нативные атрибуты data-focus и data-selected задают два разных варианта оформления CSS.
Переключатель публичной ссылки размещён в Field вместе с Label, а не представлен как стилизованная кнопка без подписи. При изменении его состояния меняются и доступный элемент управления, и видимый поясняющий текст. Локальное приглашение не зависит от настройки публичной ссылки.
Дизайн-система Headless UI: разделяйте выбор, закрытие и локальное подтверждение
Используемая здесь модель состояния
При открытии создаётся новый черновик приглашения и назначается роль «Может просматривать». Кнопка «Отмена», клавиша Escape и щелчок за пределами окна закрывают диалог; ни одно из этих действий не добавляет получателя. При отправке проверяется адрес электронной почты и отклоняется адрес, который уже есть в списке. Затем приглашение добавляется в локальное состояние, а диалог закрывается. В подтверждении прямо указано, что письмо не отправлено.
Список выбора разрешений меняет роль в черновике, но не права доступа уже добавленного получателя. Состояние общедоступной ссылки меняет отдельное логическое значение. Так предотвращается распространённая ошибка: считать, что каждое событие закрытия сохраняет разрешения.
Некорректный ввод: оставьте диалог открытым и свяжите сообщение об ошибке с полем адреса электронной почты.
Повторный адрес: не изменяйте существующий список доступа.
Локальное приглашение принято: обновите список и сообщите, что результат отображается только в предварительном просмотре.
Дизайн-система Headless UI: независимый выбор стиля и положения фокуса при навигации с клавиатуры
Независимый выбор стиля и положения фокуса при навигации с клавиатуры
ListboxOption предоставляет атрибуты data-focus и data-selected для разных состояний. Для элемента в фокусе используется светлый акцентный стиль, а выбранное разрешение отмечается галочкой. Поэтому пользователь, работающий с клавиатуры, может перемещаться по вариантам, не теряя из виду текущий выбор.
Это различие сохраняется и за пределами всплывающего окна: роль для ошибки относится к полю некорректного адреса получателя, а состояние Switch «включено» — к доступу по общедоступной ссылке. Ограничивайте CSS состоянием компонента, которое он описывает. Широкий селектор, одинаково окрашивающий все элементы в фокусе или включённом состоянии, скрыл бы различия в их значении.
Дизайн-система Headless UI: понятное отображение сведений о документе и прав доступа
Настройки текста, которые легко воспроизвести
Размер заголовка документа — 28px, межстрочный интервал — 1.25; заголовка диалога — 20px и 1.3 соответственно. Основной текст набран системным шрифтом платформы размером 16px с межстрочным интервалом 1.5. Подписи полей и вспомогательный текст имеют размер 14px. Точные значения указаны в style.css — читателям не нужно определять их по снимку экрана.
Для адреса электронной почты в списке доступа задано overflow-wrap:anywhere, чтобы он не расширял столбец. Разрешение отображается на отдельной строке. В диалоговом окне полное название документа и выбранное разрешение видны в момент добавления приглашения.
Headless UI Полное позиционирование оверлея в дизайн-системе и адаптивность CSS
Макет не спрятан за предварительным условием
Предоставленная таблица стилей охватывает весь оверлей: фиксированное позиционирование по границам окна, непрозрачность подложки, z-index, максимальную ширину, вертикальную прокрутку и отступы. При ширине менее 700px панели документа располагаются одна под другой, а внутренние отступы диалогового окна уменьшаются. Сборка Tailwind не предполагается.
Не смешивайте контейнер позиционирования с DialogPanel. Контейнер занимает доступную область окна и задаёт отступы от краёв; панель формирует ограниченную по размеру поверхность с содержимым. Для длинного содержимого диалогового окна предусмотрите прокручиваемую область, чтобы при узкой ширине экрана оставались доступны поле получателя, выбор разрешения и элементы закрытия.
Headless UI Используйте встроенное поведение состояния и фокуса компонентов дизайн-системы
Проверка с клавиатуры
Откройте диалоговое окно и проверьте, что начальный фокус установлен на поле получателя. Нажимая Tab, перейдите к кнопке выбора разрешения; с помощью клавиш со стрелками и Enter выберите роль. Клавиша Escape закрывает активное всплывающее окно или диалоговое окно в соответствии с текущим контекстом фокуса нативного компонента. После закрытия диалогового окна фокус должен вернуться к кнопке открытия панели общего доступа.
В поле электронной почты для пользовательского сообщения об ошибке используются aria-invalid и aria-describedby. Галочка в варианте выбора носит декоративный характер, поскольку Listbox уже предоставляет семантику выбора. У элемента управления публичной ссылкой есть видимая подпись, а поясняющее предложение остаётся на месте после переключения. При уменьшении движения CSS убирается декоративная анимация переключателя.
Дизайн-система Headless UI Последовательно используйте одно поколение React API
Последовательно используйте одно поколение React API
Полная запись React монтирует App, таблица стилей которого задаёт геометрию наложения и селекторы состояний. В проверенном примере версии 2.2.10 напрямую импортируются DialogPanel, DialogBackdrop, ListboxButton и ListboxOptions. Старые варианты составных компонентов следует заменить осознанно, а не смешивать с этой композицией.
ListboxOptions использует anchor="bottom" и встроенную переменную --button-width, чтобы выровнять список параметров относительно кнопки-триггера. Эти детали — часть интеграции, а не необязательное оформление: если удалить контейнер позиционирования или заменить ширину несвязанным фиксированным значением, вложенное всплывающее окно может работать неправильно, даже если закрытое поле выглядит корректно.
Установите @headlessui/react в проект React.
Сохраните примеры App.jsx и style.css, затем замените main.jsx, как показано.
Запустите сервер разработки и проверьте закрытие диалогового окна, выбор уровня доступа и обработку повторного адреса электронной почты.
Дизайн-система Headless UI: проверьте реальное взаимодействие с настройками доступа
Условия приёмки
Начните проверку с документа и уже добавленного получателя, а не с пустой галереи компонентов. Добавьте корректное приглашение с уровнем доступа «Может комментировать», затем откройте диалог снова и попробуйте указать тот же адрес электронной почты. Убедитесь, что исходная строка получателя сохранена, а при повторном добавлении отображается конкретная ошибка. Включите публичную ссылку и проверьте отдельное пояснение к ней.
Пример демонстрирует только локальное состояние интерфейса. В нём нет ни поиска получателей, ни серверной части для управления доступом. Для подключения реального сервиса потребуются модель состояний ожидания и ошибки, а также явная политика обработки одновременных изменений прав доступа; встроенные примитивы не определяют правила авторизации.
Примеры компонентов дизайн-системы Headless UI
App.jsx — композиция для управления доступом на встроенных примитивах
Полноценные управляемые компоненты Dialog и Listbox для выбора уровня доступа, Switch с меткой и локальная проверка данных.
jsx
import React, { useState } from 'react';
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle, Description, Listbox, ListboxButton, ListboxOptions, ListboxOption, Switch, Field, Label } from '@headlessui/react';
import './style.css';
const roles = ['Can view', 'Can comment', 'Can edit'];
export default function App() {
const [open,setOpen]=useState(false),[role,setRole]=useState(roles[0]),[publicLink,setPublicLink]=useState(false),[email,setEmail]=useState(''),[error,setError]=useState(''),[notice,setNotice]=useState('');
const [people,setPeople]=useState([{email:'[email protected]',role:'Can edit'}]);
const start=()=>{setEmail('');setError('');setRole(roles[0]);setOpen(true)};
const submit=e=>{e.preventDefault();if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)){setError('Enter a complete email address.');return;}if(people.some(p=>p.email===email)){setError('This person already appears in the access list.');return;}setPeople([...people,{email,role}]);setOpen(false);setNotice('Invitation added to this local example only. No email was sent.');};
return <main className='sharing-shell'>
<header><div><span className='eyebrow'>HEADLESS UI / CUSTOM SURFACE</span><h1>Quarterly brief</h1><p className='muted'>Research · Updated today</p></div><button className='primary' onClick={start}>Share document</button></header>
<div className='document-grid'><article className='paper'><span className='tag'>WORKING DOCUMENT</span><h2>Make the next decision clearer.</h2><p>Three research priorities for the coming quarter, shared with the people who need to review them.</p><div className='rule'/><h3>01 / Understand the handoff</h3><p>Compare where context is lost between research notes and the next product decision.</p><h3>02 / Review access deliberately</h3><p>Keep editing rights separate from the ability to read a document.</p></article>
<aside className='access'><h2>People with access</h2><p className='muted'>The list below is local demonstration state.</p>{people.map((p,i)=><div className='person' key={p.email}><span className='avatar'>{i===0?'AK':'+'}</span><div><strong>{p.email}</strong><small>{p.role}</small></div></div>)}<Field className='link-setting'><Label>Public link</Label><Switch checked={publicLink} onChange={setPublicLink} className='switch'><span /></Switch></Field><p className='link-detail'>{publicLink?'Anyone with the example link could view this document. This changes only the local preview.':'Restricted to the named people in this example.'}</p><p role='status' className='notice'>{notice}</p></aside></div>
<Dialog open={open} onClose={()=>setOpen(false)} className='dialog-root'><DialogBackdrop className='backdrop'/><div className='dialog-position'><DialogPanel className='dialog-panel'><DialogTitle as='h2'>Share quarterly brief</DialogTitle><Description className='muted'>Choose one recipient and a permission. Closing discards this invitation draft.</Description><form noValidate onSubmit={submit}><label className='field-label' htmlFor='recipient'>Recipient email</label><input id='recipient' data-autofocus type='email' value={email} onChange={e=>{setEmail(e.target.value);setError('')}} aria-invalid={!!error} aria-describedby={error?'email-error':undefined} placeholder='[email protected]'/>{error&&<p id='email-error' role='alert' className='error'>{error}</p>}<Listbox value={role} onChange={setRole}><Label className='field-label'>Permission</Label><ListboxButton className='role-button'>{role}<span aria-hidden='true'>⌄</span></ListboxButton><ListboxOptions anchor='bottom' className='role-options'>{roles.map(value=><ListboxOption key={value} value={value} className='role-option'><span>{value}</span><span className='selected' aria-hidden='true'>✓</span></ListboxOption>)}</ListboxOptions></Listbox><p className='muted permission-help'>{role==='Can edit'?'May change document content in this example.':role==='Can comment'?'May add comments without editing content.':'May read the document without changing it.'}</p><div className='dialog-actions'><button type='button' onClick={()=>setOpen(false)}>Cancel</button><button className='primary' type='submit'>Add invitation</button></div></form></DialogPanel></div></Dialog>
<footer>Native Dialog, Listbox, and Switch behavior. Original CSS below supplies all visual values; Headless UI does not ship this palette.</footer>
</main>;
}
Адрес электронной почты не отправляется, запрос не выполняется.
Escape и кнопка отмены отбрасывают несохранённое приглашение.
style.css — полная исходная визуальная система
Все визуальные токены, селекторы состояний, расположение наложений и правила адаптивности, необходимые для App.jsx. Это примеры решений, а не официальные настройки по умолчанию Headless UI.
Полные файлы стилей и компонентов приведены выше. Требуется версия Node, поддерживаемая выбранным выпуском Vite. В демоверсию локально включён тот же пакет нативных компонентов.
текст
# Terminal
npm create vite@latest sharing-demo -- --template react
cd sharing-demo
npm install
npm install @headlessui/[email protected]
# Save the complete examples as src/App.jsx and src/style.css.
# Replace src/main.jsx with:
import React from 'react';
import {createRoot} from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')).render(<App/>);
# Terminal
npm run dev
Запрос для вашего проекта
Промпт для AI-агента
Используйте предоставленные App.jsx и style.css из Headless UI. Сохраните композицию нативных Dialog, Listbox и Switch, разделяйте черновые разрешения и подтверждённый доступ, а также оставьте все селекторы состояния фокуса и выбора. Замените локальное добавление приглашения на нашу авторизованную API, предусмотрев состояния ожидания и ошибки; никогда не сохраняйте данные при вызове onClose. Не представляйте пользовательскую палитру CSS как официальную тему Headless UI.
05
Источники и область применения дизайн-системы Headless UI
Исходная визуальная конфигурация OpenDesign и пример React с использованием нативного поведения Headless UI 2.2.10. Значения CSS явно указаны как решения приложения; Headless UI не предоставляет эту тему. Приглашения и изменения разрешений хранятся только в памяти локального предпросмотра.