mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
feat(dialogs): the focus starts inside a dialog and Tab stays there
Every dialog, the shared frame and the older modal alike, moves the focus in when it opens: on a desktop into the first text field, on a phone onto the panel so no keyboard pops up. Tab wraps around inside instead of running through the page behind, and closing hands the focus back to what opened it. Fields that open a list on focus are skipped, and the older modal's close button got a label and a tooltip. (#1302)
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import React, { useEffect, useCallback, useRef } from 'react'
|
||||
import React, { useEffect, useCallback, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { lockBodyScroll } from '../../utils/bodyScrollLock'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { focusDialog, trapTab } from './dialogFocus'
|
||||
|
||||
const sizeClasses: Record<string, string> = {
|
||||
sm: 'max-w-sm',
|
||||
@@ -32,8 +35,11 @@ interface ModalProps {
|
||||
align?: 'center' | 'top'
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
isOpen,
|
||||
export default function Modal({ isOpen, ...frame }: ModalProps) {
|
||||
return isOpen ? <ModalFrame {...frame} /> : null
|
||||
}
|
||||
|
||||
function ModalFrame({
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
@@ -41,28 +47,37 @@ export default function Modal({
|
||||
footer,
|
||||
hideCloseButton = false,
|
||||
align = 'center',
|
||||
}: ModalProps) {
|
||||
}: Omit<ModalProps, 'isOpen'>) {
|
||||
const { t } = useTranslation()
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const mouseDownTarget = useRef<EventTarget | null>(null)
|
||||
// Read while rendering, before a field inside can take the focus with autoFocus.
|
||||
const [focusedBefore] = useState(() => document.activeElement)
|
||||
|
||||
const handleEsc = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
return () => document.removeEventListener('keydown', handleEsc)
|
||||
}, [isOpen, handleEsc])
|
||||
}, [handleEsc])
|
||||
|
||||
// Separate from the key listener so a new onClose identity does not release
|
||||
// and re-take the lock on every render. The shared lock is ref-counted: this
|
||||
// modal must not clear a lock another overlay is still holding (#1809).
|
||||
useEffect(() => lockBodyScroll(), [])
|
||||
|
||||
// The focus moves into the dialog when it opens (the first text field on a
|
||||
// desktop) and back to whatever opened it when it closes, the way DialogShell
|
||||
// does it (#1302). Without this Tab ran through the page behind first.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
return lockBodyScroll()
|
||||
}, [isOpen])
|
||||
|
||||
const mouseDownTarget = useRef<EventTarget | null>(null)
|
||||
|
||||
if (!isOpen) return null
|
||||
const panel = panelRef.current
|
||||
if (panel) focusDialog(panel)
|
||||
return () => {
|
||||
if (focusedBefore instanceof HTMLElement && focusedBefore.isConnected) focusedBefore.focus()
|
||||
}
|
||||
}, [focusedBefore])
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
@@ -79,9 +94,12 @@ export default function Modal({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="presentation"
|
||||
tabIndex={-1}
|
||||
onKeyDown={e => { if (panelRef.current) trapTab(e, panelRef.current) }}
|
||||
className={`
|
||||
trek-modal-enter
|
||||
trek-modal-enter outline-none
|
||||
rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||
flex flex-col
|
||||
max-h-[calc(100dvh-var(--bottom-nav-h)-90px)] sm:max-h-[calc(100dvh-90px)]
|
||||
@@ -93,12 +111,15 @@ export default function Modal({
|
||||
<div className="flex items-center justify-between p-6 flex-shrink-0 border-b border-edge-secondary">
|
||||
<h2 className="text-lg font-semibold text-content">{title}</h2>
|
||||
{!hideCloseButton && (
|
||||
<Tooltip label={t('common.close')}>
|
||||
<button type="button"
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors"
|
||||
aria-label={t('common.close')}
|
||||
className="p-2 rounded-lg text-content-faint hover:text-content-secondary hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user