Storybook: диалог с нативным управлением фокусом
У «Набора» появились кнопка и поле. Теперь добавим диалог для настроек организации. Используем нативный элемент dialog и showModal, чтобы браузер участвовал в модальном поведении и управлении фокусом. Учебная задача состоит в согласовании двух состояний: React-пропса open и фактического состояния DOM-элемента. Простое добавление атрибута open не равно полноценному модальному показу.
Наш пример содержит кнопку «Открыть настройки», заголовок диалога и кнопку закрытия. Локальный React-компонент истории хранит булево значение. Диалог получает это значение и функцию onOpenChange. Внутри самого диалога нет отдельного источника истины для открытия. Такая модель позволяет потребителю явно решать, когда показать окно, и согласованно реагировать на его закрытие.
Нативная модальность
У dialog есть методы showModal и close. Модальный показ помещает элемент в верхний слой браузера и ограничивает взаимодействие с остальной страницей по нативным правилам. Это отличается от обычного абсолютно позиционированного блока поверх затемнения. В последнем варианте разработчик сам отвечает за гораздо больше деталей. Но нативный элемент не отменяет обязанность дать окну название, ясное действие и подходящий первоначальный фокус.
Полный файл src/Dialog.tsx для этого состояния:
import { useEffect, useId, useRef, type ReactNode } from 'react';
export interface DialogProps { open: boolean; title: string; children: ReactNode; onOpenChange: (open: boolean) => void; }
export function Dialog({ open, title, children, onOpenChange }: DialogProps) {
const ref = useRef<HTMLDialogElement>(null);
const titleId = useId();
useEffect(() => {
const element = ref.current;
if (!element) return;
if (open && !element.open) element.showModal();
if (!open && element.open) element.close();
}, [open]);
return <dialog ref={ref} aria-labelledby={titleId} onCancel={(event) => { event.preventDefault(); onOpenChange(false); }} onClose={() => onOpenChange(false)}>
<h2 id={titleId}>{title}</h2>{children}
<button type="button" autoFocus onClick={() => onOpenChange(false)}>Закрыть</button>
</dialog>;
}
Полный файл src/Dialog.stories.tsx для этого состояния:
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Dialog } from './Dialog';
import { Button } from './Button';
const meta = { title: 'Nabor/Dialog', component: Dialog } satisfies Meta<typeof Dialog>;
export default meta;
function Demo() { const [open,setOpen]=useState(false); return <><Button onClick={()=>setOpen(true)}>Открыть настройки</Button><Dialog open={open} onOpenChange={setOpen} title="Настройки организации"><p>Это пример локального диалога.</p></Dialog></>; }
export const Interactive: StoryObj<typeof meta> = { render: () => <Demo /> };
Хук useRef сохраняет ссылку на реальный HTMLDialogElement. В эффекте читается пропс open и фактическое поле element.open. Метод вызывается только при нужном переходе. Если безусловно выполнять showModal на каждом обновлении, можно столкнуться с недопустимым состоянием уже открытого элемента. Проверка текущего состояния делает намерение idempotent на уровне этих переходов: повторный рендер не должен повторять открытие.
При изменении пропса на false вызывается close, если элемент ещё открыт. Таким образом, внешний React-контракт управляет нативным жизненным циклом. DOM-элемент не становится просто зеркалом JSX-атрибута. Важно помнить, что эффект работает после рендера; первая структура уже существует, затем происходит модальный переход. Этот порядок позволяет получить доступ к элементу через ref, но требует понятного поведения initial open.
Заголовок и доступное название
Для h2 создаётся идентификатор через useId, а dialog получает aria-labelledby. Название окна связано с видимым текстом «Настройки организации». Если заменить его иконкой без текста, надо сохранить доступное имя другим явным способом. Мы выбираем простой видимый заголовок, который помогает всем пользователям понять контекст. Название компонента в исходниках не является названием окна для человека.
Кнопка закрытия имеет type="button", чтобы не отправлять случайную форму внутри диалога. Она вызывает onOpenChange(false), а внешний компонент меняет state. В учебной версии кнопка также получает autoFocus. Это разумно для короткого информационного окна, но не универсально для любой задачи. В диалоге ввода данных первый осмысленный фокус может принадлежать полю или вступительному тексту. Выбор должен следовать содержимому.
Для кнопки открытия важен возврат фокуса после закрытия. Нативный модальный механизм поддерживает свою модель фокуса, однако реальный пользовательский маршрут надо отдельно подтвердить в поддерживаемых браузерах. Если приложение удалило исходный элемент или перешло на другой экран, прежняя цель возврата может исчезнуть. Поэтому библиотека не обещает одним методом исправить все возможные перестройки приложения. Наш источник остаётся на странице истории весь цикл.
Escape и согласование источников истины
При нажатии Escape нативный dialog создаёт событие отмены. В обработчике мы предотвращаем автоматическое закрытие и передаём намерение во внешний onOpenChange(false). Затем React меняет пропс, эффект выполняет close. Такой порядок удерживает булево состояние приложения согласованным с фактом закрытия. Если позволить DOM закрыться, забыв обновить внешний state, приложение может продолжать считать окно открытым.
Обработчик onClose тоже сообщает false, учитывая закрытие через нативный метод. Повторное сообщение того же булева состояния не является новой бизнес-операцией. Но потребитель не должен привязывать к каждой такой функции необратимое действие вроде списания денег. onOpenChange уведомляет о жизненном цикле окна. Если нужно подтверждение удаления, его надо выразить отдельным обработчиком смыслового действия, а закрытие оставить закрытием.
Область стилей и содержимого
CSS задаёт цвет поверхности, контур, радиус и ограничение ширины. Псевдоэлемент ::backdrop затемняет фон. Это визуальное сопровождение нативной модальности, а не механизм блокировки страницы. Тематические переменные наследуются от корневой области оформления. Нахождение dialog в верхнем визуальном слое не делает его элементом другого React-дерева; он по-прежнему создан внутри соответствующей области истории.
Не делайте несколько вложенных модальных окон стандартным сценарным решением без необходимости. Каждый новый слой усложняет путь пользователя и возврат фокуса. Наш контракт может технически быть использован в разных местах, но учебная история показывает один диалог и один источник открытия. Если окно содержит сложную форму, её состояние остаётся у формы; закрытие не должно молча терять важный введённый текст без выбранной политики приложения.
Теперь диалог согласует React-намерение, нативные методы и события отмены, имеет связанный заголовок и явное закрытие. В будущем независимом просмотре читатель должен пройти открытие, Escape и возврат к исходной кнопке; эти действия ещё не выполнялись автором. Следующий урок рассмотрит ожидание операции и недоступность повторного действия. Модальность и загрузка решают разные задачи, поэтому не будем показывать диалог просто ради индикатора каждого сетевого запроса.
Смысл отмены отдельно от подтверждения
Закрыть окно и подтвердить действие — разные операции. Если внутри dialog появится удаление организации, кнопка «Удалить» должна вызывать отдельную функцию с понятным контрактом. Обработчик onOpenChange(false) не сообщает, почему окно закрыто: Escape, кнопка закрытия или завершение подтверждения. Поэтому использовать его как сигнал удаления было бы ошибкой модели, даже если текущая история выглядит простой.
Также решите, можно ли закрывать окно при несохранённом вводе. Библиотека не знает ценность содержимого children. Приложение может запросить подтверждение или сохранить данные вне окна. Нативный Escape по-прежнему должен иметь ясное поведение. Если его запрещают, нужна объяснимая пользовательская причина и доступный способ выйти; бесконечная модальность без возможности завершения не становится лучше от современного затемнения.
Нативный dialog требует подходящего браузерного профиля. Если приложение обязано поддерживать среду без нужного механизма, стратегия совместимости определяется отдельно и не появляется из TypeScript-типа. Учебная библиотека использует современный HTML API и явно оставляет практическое подтверждение браузеров следующему этапу работы.
Оглавление серии, общая подборка и полный архив исходников. Для этого урока выберите lesson-06.
Первичные источники: Storybook CSF, React Vite integration, Аргументы историй, Темы и globals, Декораторы, Accessibility addon, Автоматическая документация, React useId, HTML dialog.
Редакционный статус: локальный черновик. Учебные команды, приложение и проверки не запускались; описанные результаты ожидаемые. Профиль версий закреплён, разрешённый lockfile не создавался.