Как делать12 октября 2026 г.7 мин чтения
ReactШаг 8 из 8

Рецепт: доступное модальное окно на React

Коротко

Возьмите нативный <dialog> и открывайте его методом showModal() из эффекта React: браузер сам выводит окно в верхний слой, делает страницу под ним инертной, закрывает окно по Esc и возвращает фокус элементу, который был в фокусе до открытия. Компоненту остаётся синхронизировать состояние через событие close, закрывать окно по клику на подложку, подписать его через aria-labelledby и заблокировать прокрутку одним CSS-правилом. Ниже — готовый Modal.tsx, стили с учётом prefers-reduced-motion и тесты в настоящем браузере.

Рецепт кодаУровень: СреднийКомпоненты ReactПрактика: ~40–90 мин

После материала вы сможете

  • подключить компонент Modal на нативном <dialog>: Esc, клик по подложке, блокировка прокрутки и возврат фокуса
  • подписать окно через aria-labelledby и aria-describedby с идентификаторами из useId
  • проверить окно тестами Vitest в браузерном режиме и сценариями Playwright с axe
  • собрать на основе компонента окно подтверждения с фокусом на безопасном действии

Проверено на

React 19.3.0TypeScript 6.0.3, 7.0.2Vite 8.3.4Vitest 5.0.3 (@vitest/browser-playwright 5.0.3)@testing-library/react 16.3.3axe-core 4.14.0, @axe-core/playwright 4.13.0Playwright 1.64.0: Chromium 156, Firefox 157, WebKit 27.2Node.js 24 LTS (24.18.0)

Сверено с документацией: 12 октября 2026 г.

Содержание
  1. 01Задача и требования к окну
  2. 02Компонент: Modal.tsx и стили
  3. 03Как подключить
  4. 04Как проверить результат
  5. 05Варианты: подтверждение и форма
  6. 06Ограничения и на чём основан рецепт

Задача и требования к окну

Задача: окно записи, подтверждения или короткой формы, которым одинаково удобно пользоваться мышью, клавиатурой и со скринридером. Требования берём из шаблона Dialog (Modal) в WAI-ARIA Authoring Practices (APG): страница под окном недоступна, при открытии фокус переходит внутрь, Esc закрывает окно, после закрытия фокус возвращается к кнопке, которая окно открыла, у окна есть подпись и видимая кнопка закрытия.

Страница под окном недоступна для мыши, клавиатуры и скринридера

Кто обеспечивает в рецепте
Браузер: showModal() делает остальной документ инертным, как атрибут inert

Фокус при открытии — внутри окна

Кто обеспечивает в рецепте
Браузер фокусирует первый фокусируемый элемент; компонент — элемент с data-autofocus

Esc закрывает окно

Кто обеспечивает в рецепте
Браузер: запрос на закрытие, затем события cancel и close

После закрытия фокус — на прежнем месте

Кто обеспечивает в рецепте
Браузер: по стандарту HTML close() возвращает фокус элементу, который был в фокусе до showModal()

У окна есть имя и описание

Кто обеспечивает в рецепте
Компонент: aria-labelledby на заголовок, aria-describedby на текст-пояснение

Клик по подложке закрывает окно

Кто обеспечивает в рецепте
Компонент: обработчики pointerdown и click на <dialog>

Страница под окном не прокручивается

Кто обеспечивает в рецепте
CSS: :root:has(.modal:modal) { overflow: hidden }

Анимация не мешает тем, кто просил меньше движения

Кто обеспечивает в рецепте
CSS: переходы только внутри prefers-reduced-motion: no-preference

Компонент: Modal.tsx и стили

Компонент управляемый: открытием владеет родитель через проп open, а о любом закрытии узнаёт из onClose. Окно открывается в эффекте — это синхронизация React с внешней системой, API браузера.

src/components/Modal.tsx
import { useEffect, useId, useRef, type ReactNode } from 'react'
import './Modal.css'

type ModalProps = {
  open: boolean
  onClose: () => void
  title: string
  description?: string
  closeOnBackdrop?: boolean
  closeLabel?: string
  children: ReactNode
}

export function Modal({
  open,
  onClose,
  title,
  description,
  closeOnBackdrop = true,
  closeLabel = 'Закрыть',
  children,
}: ModalProps) {
  const dialogRef = useRef<HTMLDialogElement>(null)
  const pressedOnBackdrop = useRef(false)
  const titleId = useId()
  const descriptionId = useId()

  useEffect(() => {
    const dialog = dialogRef.current
    if (!open || !dialog) return

    dialog.showModal() // верхний слой, ::backdrop, страница под окном становится inert
    dialog.querySelector<HTMLElement>('[data-autofocus]')?.focus()

    // close() возвращает фокус элементу, который был в фокусе до showModal()
    return () => dialog.close()
  }, [open])

  return (
    <dialog
      ref={dialogRef}
      className="modal"
      aria-labelledby={titleId}
      aria-describedby={description ? descriptionId : undefined}
      // любое закрытие (Esc, кнопка, подложка, form method="dialog") приходит сюда
      onClose={onClose}
      onPointerDown={(event) => {
        pressedOnBackdrop.current = event.target === event.currentTarget
      }}
      onClick={(event) => {
        // клик по подложке: и нажатие, и отпускание — вне содержимого окна
        if (closeOnBackdrop && pressedOnBackdrop.current && event.target === event.currentTarget) {
          event.currentTarget.close()
        }
      }}
    >
      <div className="modal__body">
        <div className="modal__header">
          <h2 id={titleId} className="modal__title">
            {title}
          </h2>
          <button
            type="button"
            className="modal__close"
            aria-label={closeLabel}
            onClick={() => dialogRef.current?.close()}
          >
            ×
          </button>
        </div>
        {description && <p id={descriptionId}>{description}</p>}
        {children}
      </div>
    </dialog>
  )
}
Готовый компонент: зависимостей, кроме React, нет.
  • showModal() вызывается в эффекте, функция очистки вызывает close(): окно закрывается и при open = false, и при размонтировании. Повторный close() на закрытом окне ничего не делает, поэтому двойной запуск эффекта в StrictMode безопасен.
  • Событие close приходит при любом закрытии — Esc, кнопка «×», подложка, отправка <form method="dialog">. Обработчик onClose возвращает состояние родителя в false, и React не расходится с браузером.
  • Клик по подложке браузер адресует самому <dialog>. У окна нулевые отступы, всё содержимое лежит во внутреннем .modal__body, поэтому event.target === event.currentTarget означает «мимо окна».
  • Проверка pointerdown защищает от жеста «выделил текст в поле и отпустил кнопку мыши над подложкой». Без неё в наших тестах окно в этом случае закрывалось в Chromium и WebKit.
  • useId выдаёт уникальные идентификаторы для aria-labelledby и aria-describedby, так что несколько окон на странице не конфликтуют.
src/components/Modal.css
.modal {
  width: min(32rem, calc(100vw - 2rem));
  max-height: calc(100dvh - 2rem);
  padding: 0; /* отступы — у .modal__body, чтобы клик по самому <dialog> означал подложку */
  border: none;
  border-radius: 12px;
  background: #fff;
  color: #1a1a1a;
}

.modal::backdrop {
  background: rgb(0 0 0 / 0.5);
}

.modal__body {
  padding: 1.5rem;
}

.modal__header {
  display: flex;
  align-items: start;
  justify-content: space-between;
  gap: 1rem;
}

.modal__title {
  margin: 0;
  font-size: 1.25rem;
}

.modal__close {
  min-width: 44px;
  min-height: 44px;
  border: none;
  background: transparent;
  font-size: 1.5rem;
  cursor: pointer;
}

/* блокировка прокрутки страницы, пока открыто модальное окно */
:root:has(.modal:modal) {
  overflow: hidden;
}

/* анимация — только если пользователь не просил уменьшить движение */
@media (prefers-reduced-motion: no-preference) {
  .modal,
  .modal::backdrop {
    transition:
      opacity 200ms ease-out,
      transform 200ms ease-out,
      overlay 200ms ease-out allow-discrete,
      display 200ms ease-out allow-discrete;
  }

  .modal,
  .modal::backdrop {
    opacity: 0;
  }

  .modal {
    transform: translateY(8px);
  }

  .modal[open],
  .modal[open]::backdrop {
    opacity: 1;
    transform: none;
  }

  @starting-style {
    .modal[open],
    .modal[open]::backdrop {
      opacity: 0;
    }

    .modal[open] {
      transform: translateY(8px);
    }
  }
}
Стили окна, подложки, блокировка прокрутки и анимация.

Анимация сделана на CSS-переходах. @starting-style задаёт начальное состояние при появлении, а display и overlay с allow-discrete оставляют окно на экране до конца перехода при закрытии. Свойство overlay, по данным MDN, работает пока не во всех браузерах — там окно просто исчезнет без анимации закрытия. Весь блок вложен в @media (prefers-reduced-motion: no-preference): если в системе включено «уменьшить движение», окно появляется мгновенно.

Как подключить

  1. Скопируйте два файла

    Modal.tsx и Modal.css — в src/components/. Порталы и z-index не нужны: модальное окно выводится в верхний слой поверх всей страницы.

  2. Храните состояние у родителя

    const [open, setOpen] = useState(false), в окно передайте open и onClose={() => setOpen(false)}.

  3. Отметьте начальный фокус

    Атрибут data-autofocus — на поле или кнопке, с которой человек начнёт. Без него браузер сфокусирует первый фокусируемый элемент, то есть кнопку «×».

  4. Закрывайте окно после действия

    После успешной отправки формы вызовите setOpen(false): эффект вызовет close(), фокус вернётся на кнопку «Записаться».

src/App.tsx
import { useState, type FormEvent } from 'react'
import { Modal } from './components/Modal'

export default function App() {
  const [open, setOpen] = useState(false)
  const [status, setStatus] = useState('')

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault()
    const phone = String(new FormData(event.currentTarget).get('phone'))
    // здесь — отправка заявки на сервер
    setStatus(`Заявка принята, перезвоним на ${phone}`)
    setOpen(false)
  }

  return (
    <main>
      <h1>Салон красоты</h1>
      <button type="button" onClick={() => setOpen(true)}>
        Записаться
      </button>
      <p role="status">{status}</p>

      <Modal
        open={open}
        onClose={() => setOpen(false)}
        title="Запись на приём"
        description="Оставьте телефон — администратор перезвонит и подберёт время."
      >
        <form onSubmit={handleSubmit}>
          <label htmlFor="phone">Телефон</label>
          <input id="phone" name="phone" type="tel" autoComplete="tel" required data-autofocus />
          <button type="submit">Отправить</button>
        </form>
      </Modal>
    </main>
  )
}
Пример использования: запись в салон с формой внутри окна.

Как проверить результат

jsdom — привычная среда тестов Vitest — в версии 30.1.2 не реализует showModal() и close(): задача «Implement HTMLDialogElement» в трекере jsdom открыта. Поэтому компонент тестируем в браузерном режиме Vitest: тот же Testing Library, но в настоящем Chromium через Playwright, с реальной клавиатурой и фокусом.

bash
npm create vite@latest modal-demo -- --template react-ts
cd modal-demo
npm install
npm install -D vitest @vitest/browser-playwright @testing-library/react @testing-library/dom axe-core
npm install -D @playwright/test @axe-core/playwright
npx playwright install chromium firefox webkit
Временный проект: Vite-шаблон react-ts, Vitest в браузерном режиме, Playwright и axe.
vitest.config.ts
import react from '@vitejs/plugin-react'
import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vitest/config'

export default defineConfig({
  plugins: [react()],
  test: {
    include: ['src/**/*.test.tsx'],
    browser: {
      enabled: true,
      provider: playwright(),
      headless: true,
      instances: [{ browser: 'chromium' }],
    },
  },
})
Vitest запускает тесты из src/ в Chromium; в instances можно добавить firefox и webkit.
src/components/Modal.test.tsx
import { cleanup, render, screen } from '@testing-library/react'
import axe from 'axe-core'
import { useState } from 'react'
import { afterEach, expect, test } from 'vitest'
import { page, userEvent } from 'vitest/browser'
import { Modal } from './Modal'

function Demo() {
  const [open, setOpen] = useState(false)
  return (
    <main>
      <button type="button" onClick={() => setOpen(true)}>
        Записаться
      </button>
      <Modal
        open={open}
        onClose={() => setOpen(false)}
        title="Запись на приём"
        description="Оставьте телефон — администратор перезвонит."
      >
        <label htmlFor="phone">Телефон</label>
        <input id="phone" type="tel" data-autofocus />
      </Modal>
    </main>
  )
}

afterEach(cleanup)

async function openModal() {
  render(<Demo />)
  const opener = screen.getByRole('button', { name: 'Записаться' })
  opener.focus() // открываем с клавиатуры: так проверяется возврат фокуса
  await userEvent.keyboard('{Enter}')
  const dialog = screen.getByRole<HTMLDialogElement>('dialog', { name: 'Запись на приём' })
  return { opener, dialog }
}

test('открывается модально, с подписью, описанием и фокусом внутри', async () => {
  const { dialog } = await openModal()
  expect(dialog.matches(':modal')).toBe(true)
  expect(dialog).toHaveAccessibleDescription('Оставьте телефон — администратор перезвонит.')
  expect(document.activeElement).toBe(screen.getByLabelText('Телефон'))
  expect(getComputedStyle(document.documentElement).overflow).toBe('hidden')
})

test('Esc закрывает окно и возвращает фокус на кнопку', async () => {
  const { opener, dialog } = await openModal()
  await userEvent.keyboard('{Escape}')
  await expect.poll(() => dialog.open).toBe(false)
  await expect.poll(() => document.activeElement).toBe(opener)
  expect(getComputedStyle(document.documentElement).overflow).not.toBe('hidden')

  // состояние React синхронизировано: окно открывается повторно
  await userEvent.keyboard('{Enter}')
  expect(dialog.open).toBe(true)
})

test('кнопка «Закрыть» закрывает окно', async () => {
  const { opener, dialog } = await openModal()
  await userEvent.click(screen.getByRole('button', { name: 'Закрыть' }))
  await expect.poll(() => dialog.open).toBe(false)
  expect(document.activeElement).toBe(opener)
})

test('клик по подложке закрывает, клик внутри — нет', async () => {
  const { dialog } = await openModal()
  await userEvent.click(screen.getByText('Оставьте телефон — администратор перезвонит.'))
  expect(dialog.open).toBe(true)

  // точка в 10 px левее и выше окна — это ::backdrop
  await userEvent.click(page.elementLocator(dialog), { position: { x: -10, y: -10 } })
  await expect.poll(() => dialog.open).toBe(false)
})

test('axe: нарушений доступности в открытом окне нет', async () => {
  const { dialog } = await openModal()
  // ждём конца анимации появления: на полупрозрачном тексте axe видит плохой контраст
  await Promise.all(dialog.getAnimations({ subtree: true }).map((animation) => animation.finished))
  const results = await axe.run(dialog)
  expect(results.violations).toEqual([])
})
Пять проверок: модальность, имя и описание, начальный фокус, Esc, кнопка, подложка, axe.

Тесты действительно ловят поломки: мы по очереди убирали onClose, проверку подложки, data-autofocus, aria-describedby, правило блокировки прокрутки, меняли showModal() на show() — каждый раз падал хотя бы один тест. Ожидание конца анимации перед axe не случайно: без него axe иногда застаёт полупрозрачный текст и сообщает о недостаточном контрасте.

Сценарии целиком — клавиатура, прокрутка колесом, prefers-reduced-motion, отправка формы и axe по всей странице — проверяем end-to-end в Playwright сразу в трёх движках. Dev-сервер Vite рендерит приложение в StrictMode, так что двойной запуск эффекта тоже проверен.

playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  use: { baseURL: 'http://localhost:5180' },
  webServer: { command: 'npm run dev -- --port 5180 --strictPort', url: 'http://localhost:5180' },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
})
Playwright сам поднимает dev-сервер и прогоняет сценарии в Chromium, Firefox и WebKit.
e2e/modal.spec.ts
import { AxeBuilder } from '@axe-core/playwright'
import { expect, test } from '@playwright/test'

test.beforeEach(async ({ page }) => {
  await page.goto('/')
})

test('клавиатура: фокус внутрь, Tab не уходит на страницу, Esc возвращает фокус', async ({ page }) => {
  const opener = page.getByRole('button', { name: 'Записаться' })
  await opener.focus()
  await page.keyboard.press('Enter')

  const dialog = page.getByRole('dialog', { name: 'Запись на приём' })
  await expect(dialog).toBeVisible()
  await expect(page.getByLabel('Телефон')).toBeFocused()

  for (let i = 0; i < 6; i++) {
    await page.keyboard.press('Tab')
    const leaked = await page.evaluate(() => {
      const active = document.activeElement
      return active !== null && active !== document.body && !document.querySelector('dialog')!.contains(active)
    })
    expect(leaked).toBe(false)
  }

  await page.keyboard.press('Escape')
  await expect(dialog).toBeHidden()
  await expect(opener).toBeFocused()
})

test('прокрутка страницы заблокирована, пока окно открыто', async ({ page }) => {
  await page.evaluate(() => (document.body.style.minHeight = '300vh'))
  await page.getByRole('button', { name: 'Записаться' }).click()
  await page.mouse.move(20, 20)
  await page.mouse.wheel(0, 600)
  await page.waitForTimeout(300)
  expect(await page.evaluate(() => window.scrollY)).toBe(0)

  await page.keyboard.press('Escape')
  await page.mouse.wheel(0, 600)
  await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(0)
})

test('prefers-reduced-motion: reduce отключает переход', async ({ page }) => {
  const duration = () => page.locator('dialog').evaluate((el) => getComputedStyle(el).transitionDuration)
  expect(await duration()).not.toBe('0s')
  await page.emulateMedia({ reducedMotion: 'reduce' })
  expect(await duration()).toBe('0s')
})

test('форма: отправка закрывает окно и возвращает фокус', async ({ page }) => {
  const opener = page.getByRole('button', { name: 'Записаться' })
  await opener.focus()
  await page.keyboard.press('Enter')
  await page.getByLabel('Телефон').fill('+7 700 000 00 00')
  await page.keyboard.press('Enter')
  await expect(page.getByRole('dialog')).toBeHidden()
  await expect(page.getByRole('status')).toHaveText('Заявка принята, перезвоним на +7 700 000 00 00')
  await expect(opener).toBeFocused()
})

test('axe: страница с открытым окном без нарушений', async ({ page }) => {
  await page.getByRole('button', { name: 'Записаться' }).click()
  const dialog = page.getByRole('dialog', { name: 'Запись на приём' })
  await expect(dialog).toBeVisible()
  await dialog.evaluate((el) => Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)))
  const results = await new AxeBuilder({ page }).analyze()
  expect(results.violations).toEqual([])
})
Сценарии в браузере: клавиатура, блокировка прокрутки, уменьшенное движение, форма, axe.
bash
npx vitest run        # компонентные тесты в Chromium
npx playwright test    # сценарии в Chromium, Firefox и WebKit
У нас: Vitest — 18 из 18 в Chromium, Firefox и WebKit (вместе с тестом окна подтверждения), Playwright — 15 из 15.
  • Tab до «Записаться», Enter — окно открылось, фокус в поле «Телефон»
  • Tab по кругу — фокус не попадает на элементы страницы под окном
  • Esc — окно закрылось, фокус снова на «Записаться»
  • Колесо мыши при открытом окне не прокручивает страницу
  • В панели Accessibility браузера у окна роль dialog, имя «Запись на приём» и описание
  • С включённым «уменьшить движение» окно появляется без анимации

Варианты: подтверждение и форма

Для шага, который трудно отменить, APG советует ставить начальный фокус на наименее разрушительное действие. Окно подтверждения собирается из того же Modal: фокус — на «Оставить запись», клик по подложке окно не закрывает.

src/components/CancelBookingButton.tsx
import { useState } from 'react'
import { Modal } from './Modal'

export function CancelBookingButton({ onConfirm }: { onConfirm: () => void }) {
  const [open, setOpen] = useState(false)

  return (
    <>
      <button type="button" onClick={() => setOpen(true)}>
        Отменить запись
      </button>
      <Modal
        open={open}
        onClose={() => setOpen(false)}
        title="Отменить запись?"
        description="Время освободится, и его сможет занять другой клиент."
        closeOnBackdrop={false}
      >
        {/* фокус — на безопасное действие (рекомендация APG для необратимых шагов) */}
        <button type="button" data-autofocus onClick={() => setOpen(false)}>
          Оставить запись
        </button>
        <button
          type="button"
          onClick={() => {
            onConfirm()
            setOpen(false)
          }}
        >
          Да, отменить
        </button>
      </Modal>
    </>
  )
}
Подтверждение отмены записи; тест к нему проверяет фокус, подложку и вызов onConfirm.
  • Короткие критичные подтверждения APG описывает отдельным шаблоном Alert Dialog с ролью alertdialog. Если он нужен, добавьте компоненту проп role и передавайте role="alertdialog".
  • Длинная форма с несохранёнными данными: closeOnBackdrop={false}, чтобы случайный клик мимо окна не стёр введённое. Esc оставьте — MDN напоминает, что пользователи клавиатуры его ожидают.
  • Окно без своей логики отправки: <form method="dialog"> закрывает его при отправке, а value нажатой кнопки попадает в returnValue. С компонентом это работает — закрытие приходит через то же событие close, мы проверили это отдельным тестом.
  • Атрибут closedby="any" включает закрытие по клику снаружи без JavaScript, но, по данным MDN, пока поддерживается не во всех основных браузерах — поэтому подложка обработана вручную.

Ограничения и на чём основан рецепт

  • Tab: до содержимого страницы фокус не доходит — она инертна. Но в Chromium и WebKit после последнего элемента окна фокус на шаг уходит из документа, в интерфейс браузера, и затем возвращается к первому элементу. APG описывает строгое зацикливание внутри окна; если нужно именно оно, понадобится собственная ловушка фокуса.
  • Блокировку прокрутки мы проверили в Chromium 156, Firefox 157 и WebKit 27.2 под Playwright. На реальных iOS- и Android-устройствах не проверяли.
  • Если кнопка, открывшая окно, исчезает вместе с действием (например, удалена строка таблицы), браузеру некуда вернуть фокус. По APG его нужно перевести на логически следующий элемент — вручную, в onClose.
  • Второе окно, открытое поверх первого, по MDN оказывается над ним в верхнем слое. Такой сценарий мы отдельно не тестировали.
  • Не открывайте окно атрибутом open и не ставьте tabindex на <dialog>: MDN рекомендует showModal(), а tabindex на этом элементе запрещает.

Основы, на которых стоит рецепт: эффекты, очистка и StrictMode — в уроке «useEffect: синхронизация с внешним миром и когда он не нужен»; нативные элементы, управление фокусом и ARIA — в «Доступность в коде: семантика, фокус, ARIA»; события pointerdown и click, разница target и currentTarget — в «JavaScript в браузере: DOM и события».

Как проверено5 · пройденосверено 12 октября 2026 г.
  • запущено, работает

    src/components/Modal.tsx, Modal.css, src/App.tsx

    временный проект create vite --template react-ts: npm run build (tsc -b на TypeScript 6.0.3 + vite build 8.3.4) — успешно; tsc -b на TypeScript 7.0.2 — 0 ошибок; oxlint — 0 замечаний

  • запущено, работает

    vitest.config.ts, src/components/Modal.test.tsx, CancelBookingButton.tsx (+ тест)

    Vitest 5.0.3 в браузерном режиме (@vitest/browser-playwright): 18/18 в Chromium 156, Firefox 157, WebKit 27.2 (тесты Modal — 5 прогонов подряд без падений после ожидания анимации перед axe; до него axe нестабильно ловил color-contrast). Мутационная проверка: без onClose, без проверки подложки, без data-autofocus, без aria-describedby, без :root:has, show() вместо showModal(), без closeOnBackdrop — падает хотя бы один тест

  • запущено, работает

    playwright.config.ts, e2e/modal.spec.ts

    Playwright 1.64.0 против dev-сервера Vite (StrictMode): 15/15 в Chromium, Firefox, WebKit; @axe-core/playwright 4.13.0 — 0 нарушений; без правила блокировки прокрутки тест падает (scrollY 600)

  • запущено, работает

    Утверждения статьи о поведении браузеров

    пробные тесты: jsdom 30.1.2 — showModal/close не функции; React 19.3 не выводит атрибут autofocus; возврат фокуса нативный во всех трёх движках (тесты проходят без ручного focus()); в WebKit после клика мышью фокус не на кнопке; Tab: Chromium/WebKit после последнего элемента уходят на body (вне документа) и возвращаются, Firefox headless остаётся на последнем; перетаскивание из поля на подложку без проверки pointerdown закрывает окно в Chromium и WebKit, не в Firefox; form method=dialog закрывает окно, returnValue = value кнопки, состояние синхронизируется

  • запущено, работает

    EN-версия кода

    копия modal-demo-en с переведёнными строками и комментариями: build, tsc -b 7.0.2, oxlint — чисто; Vitest 18/18, Playwright 15/15

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.