Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions src/components/ui/Modal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
Expand Down Expand Up @@ -70,4 +71,70 @@ describe('Modal behaviour (#797)', () => {

expect(document.body.style.overflow).toBe('')
})

it('moves focus into the dialog on open', () => {
render(
<Modal isOpen title="Delete listing" onClose={() => {}}>
body
</Modal>,
)

expect(screen.getByRole('dialog').contains(document.activeElement)).toBe(true)
})

it('returns focus to the trigger when it closes', async () => {
const user = userEvent.setup()

function Harness() {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Open
</button>
<Modal isOpen={open} title="Delete" onClose={() => setOpen(false)}>
body
</Modal>
</>
)
}

render(<Harness />)
const trigger = screen.getByRole('button', { name: 'Open' })

await user.click(trigger)
expect(screen.getByRole('dialog')).toBeInTheDocument()

await user.keyboard('{Escape}')

expect(trigger).toHaveFocus()
})

it('traps Tab within the dialog', async () => {
const user = userEvent.setup()
render(
<Modal isOpen title="Delete listing" onClose={() => {}}>
<button type="button">Confirm</button>
</Modal>,
)

const dialog = screen.getByRole('dialog')

await user.tab()
expect(dialog.contains(document.activeElement)).toBe(true)

await user.tab()
expect(dialog.contains(document.activeElement)).toBe(true)
})

it('exposes the backdrop as a real control rather than a clickable div', () => {
render(
<Modal isOpen title="Delete listing" onClose={() => {}}>
body
</Modal>,
)

const backdrop = screen.getByRole('dialog').previousElementSibling
expect(backdrop?.tagName).toBe('BUTTON')
})
})
82 changes: 76 additions & 6 deletions src/components/ui/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useId, type ReactNode } from 'react'
import { useEffect, useId, useRef, type ReactNode } from 'react'
import { cn } from '@/lib'

export interface ModalProps {
Expand All @@ -10,24 +10,84 @@ export interface ModalProps {
children: ReactNode
/** Extra classes for the dialog panel, e.g. a wider `max-w-*`. */
className?: string
/**
* Heading level for the visible title. Defaults to `h2`; raise it when the
* dialog is opened from a section that is already nested under an `h2` so the
* document outline stays monotonic.
*/
headingLevel?: 'h2' | 'h3' | 'h4'
}

/** Accessible dialog: closes on Escape or backdrop click, locks body scroll. */
/** Elements that can receive focus inside the dialog, in DOM order. */
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',')

/**
* Accessible dialog: closes on Escape or backdrop click, locks body scroll,
* moves focus into the dialog on open, traps Tab/Shift+Tab inside it, and
* returns focus to the trigger on close (#856).
*/
export default function Modal({
isOpen,
title,
description,
onClose,
children,
className,
headingLevel: Heading = 'h2',
}: ModalProps) {
const titleId = useId()
const panelRef = useRef<HTMLDivElement>(null)

useEffect(() => {
if (!isOpen) return

// Remember what had focus so it can be restored on close/unmount.
const previouslyFocused = document.activeElement as HTMLElement | null

const getFocusable = (): HTMLElement[] => {
const panel = panelRef.current
if (!panel) return []
return Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
}

// Move focus into the dialog so keyboard users are not left behind it.
const initialFocus = getFocusable()[0] ?? panelRef.current
initialFocus?.focus()

const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') onClose()
if (event.key === 'Escape') {
onClose()
return
}
if (event.key !== 'Tab') return

const focusable = getFocusable()
if (focusable.length === 0) {
event.preventDefault()
panelRef.current?.focus()
return
}

const first = focusable[0]!
const last = focusable[focusable.length - 1]!
const active = document.activeElement

if (event.shiftKey) {
if (active === first || active === panelRef.current) {
event.preventDefault()
last.focus()
}
} else if (active === last) {
event.preventDefault()
first.focus()
}
}

const previousOverflow = document.body.style.overflow
Expand All @@ -37,28 +97,38 @@ export default function Modal({
return () => {
document.body.style.overflow = previousOverflow
document.removeEventListener('keydown', handleKeyDown)
previouslyFocused?.focus?.()
}
}, [isOpen, onClose])

if (!isOpen) return null

return (
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4">
<div className="fixed inset-0 bg-neutral-950/60" onClick={onClose} aria-hidden="true" />
{/* The backdrop is a real button so dismissal is keyboard/AT exposed and
the overlay is not an interactive `div` (#856). */}
<button
type="button"
onClick={onClose}
aria-label="Close dialog"
className="fixed inset-0 cursor-default bg-neutral-950/60"
/>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
className={cn(
'relative w-full max-w-md rounded-card bg-surface p-6 shadow-elevated',
className,
)}
>
<div className="flex items-start justify-between gap-4">
<div>
<h2 id={titleId} className="text-h4">
<Heading id={titleId} className="text-h4">
{title}
</h2>
</Heading>
{description ? <p className="mt-1 text-caption text-muted">{description}</p> : null}
</div>
<button
Expand Down
Loading
Loading