diff --git a/client/src/components/Planner/AddressInput.tsx b/client/src/components/Planner/AddressInput.tsx
index 332fca8d1..ea415beb3 100644
--- a/client/src/components/Planner/AddressInput.tsx
+++ b/client/src/components/Planner/AddressInput.tsx
@@ -89,6 +89,8 @@ export default function AddressInput({ value, onChange, placeholder, className }
value={value}
placeholder={placeholder}
onChange={e => { onChange(e.target.value); setOpen(true); search(e.target.value) }}
+ // Opens its list on focus, so a dialog must not focus it by itself (#1302).
+ data-no-autofocus
onFocus={() => setOpen(true)}
onKeyDown={onKey}
className={className}
diff --git a/client/src/components/Planner/AirportSelect.tsx b/client/src/components/Planner/AirportSelect.tsx
index 42ea3c9b8..caec9ff69 100644
--- a/client/src/components/Planner/AirportSelect.tsx
+++ b/client/src/components/Planner/AirportSelect.tsx
@@ -113,6 +113,8 @@ export default function AirportSelect({ value, onChange, placeholder, style }: P
value={query}
placeholder={placeholder ?? t('airport.searchPlaceholder')}
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (value) onChange(null) }}
+ // Opens its list on focus, so a dialog must not focus it by itself (#1302).
+ data-no-autofocus
onFocus={() => setOpen(true)}
onKeyDown={onKey}
className="bg-transparent text-content"
diff --git a/client/src/components/Planner/LocationSelect.tsx b/client/src/components/Planner/LocationSelect.tsx
index 9c45ff763..3d8dde4e5 100644
--- a/client/src/components/Planner/LocationSelect.tsx
+++ b/client/src/components/Planner/LocationSelect.tsx
@@ -115,6 +115,8 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
value={query}
placeholder={placeholder ?? t('reservations.searchLocation')}
onChange={(e) => { setQuery(e.target.value); setOpen(true); setHighlight(-1); if (value) onChange(null) }}
+ // Opens its list on focus, so a dialog must not focus it by itself (#1302).
+ data-no-autofocus
onFocus={() => setOpen(true)}
onKeyDown={onKey}
className="bg-transparent text-content"
diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx
index 5914d557d..2b137b916 100644
--- a/client/src/components/Planner/PlaceFormModal.tsx
+++ b/client/src/components/Planner/PlaceFormModal.tsx
@@ -939,7 +939,9 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
labelId={titleId}
onClose={onClose}
eyebrow={title}
- // Never focused on opening: a new place starts at the search, an edit at the dialog.
+ // Not autoFocused itself: a new place starts at the search, and otherwise the
+ // dialog's own first-field focus lands here on a desktop (#1302), so a place
+ // brought in from the map or opened to edit is ready for its name.
titleInput={{
value: form.name,
onChange: value => handleChange('name', value),
diff --git a/client/src/components/Planner/TransitSearchPanel.tsx b/client/src/components/Planner/TransitSearchPanel.tsx
index b8ddbf7b2..86d9dc821 100644
--- a/client/src/components/Planner/TransitSearchPanel.tsx
+++ b/client/src/components/Planner/TransitSearchPanel.tsx
@@ -256,6 +256,8 @@ function StopPicker({ label, value, onPick, quickPicks, near, placeholder }: {
search(e.target.value)}
+ // Opens its list on focus, so a dialog must not focus it by itself (#1302).
+ data-no-autofocus
onFocus={() => setOpen(true)}
placeholder={placeholder}
className="text-content"
diff --git a/client/src/components/Roadtrip/RoadtripStayModal.test.tsx b/client/src/components/Roadtrip/RoadtripStayModal.test.tsx
index 8b456192b..da6bca46a 100644
--- a/client/src/components/Roadtrip/RoadtripStayModal.test.tsx
+++ b/client/src/components/Roadtrip/RoadtripStayModal.test.tsx
@@ -217,7 +217,10 @@ describe('RoadtripStayModal for a stop left at a set time', () => {
it('FE-STAYMODAL-019: closing changes nothing', () => {
const { onClose, onSave } = open(leaving)
- fireEvent.click(screen.getByRole('button', { name: /^close$/i }))
+ // The footer's Close; the header's X answers to the same name and closes the same way.
+ const closeButtons = screen.getAllByRole('button', { name: /^close$/i })
+ expect(closeButtons).toHaveLength(2)
+ fireEvent.click(closeButtons[1])
expect(onClose).toHaveBeenCalled()
expect(onSave).not.toHaveBeenCalled()
expect(setAssignmentTimes).not.toHaveBeenCalled()
diff --git a/client/src/components/shared/DialogShell.tsx b/client/src/components/shared/DialogShell.tsx
index 7daae2735..4f1ec48bc 100644
--- a/client/src/components/shared/DialogShell.tsx
+++ b/client/src/components/shared/DialogShell.tsx
@@ -4,6 +4,7 @@ import { Trash2, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { lockBodyScroll } from '../../utils/bodyScrollLock'
import { Tooltip } from './Tooltip'
+import { focusDialog, trapTab } from './dialogFocus'
/** A font size that follows the user's text size setting for its tier. */
export const fs = (px: number, tier: 'caption' | 'body' | 'subtitle' = 'caption'): CSSProperties => ({ fontSize: `calc(${px}px * var(--fs-scale-${tier}, 1))` })
@@ -61,13 +62,14 @@ function DialogFrame({ onClose, labelledBy, width = 'detail', align = 'center',
return () => document.removeEventListener('keydown', onKey)
}, [blocked, onClose])
- // The page stays put behind the dialog, and whatever opened it gets the focus back.
+ // The page stays put behind the dialog, the focus moves into it (the first text
+ // field on a desktop, #1302), and whatever opened it gets the focus back.
useEffect(() => {
const panel = panelRef.current
const focusedInside = !!panel && panel.contains(document.activeElement)
const opener = focusedInside ? focusedBefore : document.activeElement
const release = lockBodyScroll()
- if (!focusedInside) panel?.focus()
+ if (!focusedInside && panel) focusDialog(panel)
return () => {
release()
if (opener instanceof HTMLElement) opener.focus()
@@ -100,6 +102,7 @@ function DialogFrame({ onClose, labelledBy, width = 'detail', align = 'center',
aria-labelledby={labelledBy}
tabIndex={-1}
onPaste={onPaste}
+ onKeyDown={e => { if (panelRef.current) trapTab(e, panelRef.current) }}
className={`trek-modal-enter flex max-h-full w-full ${PANEL_WIDTH[width]} flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl outline-none`}
>
{header}
diff --git a/client/src/components/shared/Modal.tsx b/client/src/components/shared/Modal.tsx
index 57f0bb65c..4814166db 100644
--- a/client/src/components/shared/Modal.tsx
+++ b/client/src/components/shared/Modal.tsx
@@ -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 = {
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 ? : null
+}
+
+function ModalFrame({
onClose,
title,
children,
@@ -41,28 +47,37 @@ export default function Modal({
footer,
hideCloseButton = false,
align = 'center',
-}: ModalProps) {
+}: Omit) {
+ const { t } = useTranslation()
+ const panelRef = useRef(null)
+ const mouseDownTarget = useRef(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(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(
diff --git a/client/src/components/shared/dialogFocus.test.tsx b/client/src/components/shared/dialogFocus.test.tsx
new file mode 100644
index 000000000..55e70178e
--- /dev/null
+++ b/client/src/components/shared/dialogFocus.test.tsx
@@ -0,0 +1,141 @@
+// FE-SHARED-DIALOGFOCUS-001 to FE-SHARED-DIALOGFOCUS-009
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { useState } from 'react'
+import { render, screen, fireEvent } from '../../../tests/helpers/render'
+import { DialogShell } from './DialogShell'
+import Modal from './Modal'
+import { firstTextField } from './dialogFocus'
+
+/** A desktop with a mouse: the only case the first field takes the focus. */
+function asDesktop() {
+ const original = window.matchMedia
+ window.matchMedia = vi.fn().mockImplementation((query: string) => ({
+ matches: query === '(pointer: fine)',
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ onchange: null,
+ dispatchEvent: vi.fn(),
+ })) as unknown as typeof window.matchMedia
+ return () => { window.matchMedia = original }
+}
+
+let restore: (() => void) | null = null
+afterEach(() => { restore?.(); restore = null })
+
+function Shell({ children }: { children: React.ReactNode }) {
+ return (
+ {}} labelledBy="t" header={
Title
}>
+ {children}
+
+ )
+}
+
+describe('firstTextField', () => {
+ it('FE-SHARED-DIALOGFOCUS-001: skips buttons, read-only, disabled and list-opening fields', () => {
+ const panel = document.createElement('div')
+ panel.innerHTML = `
+
+
+
+
+
+
+
+
+ `
+ expect(firstTextField(panel)?.id).toBe('target')
+ })
+
+ it('FE-SHARED-DIALOGFOCUS-002: a dialog with no field has none', () => {
+ const panel = document.createElement('div')
+ panel.innerHTML = ''
+ expect(firstTextField(panel)).toBeNull()
+ })
+})
+
+describe('DialogShell focus (#1302)', () => {
+ it('FE-SHARED-DIALOGFOCUS-003: on a desktop the first text field is ready to type into', () => {
+ restore = asDesktop()
+ render()
+ expect(screen.getByLabelText('Name')).toHaveFocus()
+ })
+
+ it('FE-SHARED-DIALOGFOCUS-004: on a touch screen the panel takes the focus, so no keyboard pops up', () => {
+ render()
+ expect(screen.getByRole('dialog')).toHaveFocus()
+ })
+
+ it('FE-SHARED-DIALOGFOCUS-005: a field that took the focus itself keeps it', () => {
+ restore = asDesktop()
+ render()
+ expect(screen.getByLabelText('Search')).toHaveFocus()
+ })
+
+ it('FE-SHARED-DIALOGFOCUS-006: Tab wraps from the last control to the first and back', () => {
+ render()
+ const one = screen.getByRole('button', { name: 'One' })
+ const two = screen.getByRole('button', { name: 'Two' })
+
+ two.focus()
+ fireEvent.keyDown(two, { key: 'Tab' })
+ expect(one).toHaveFocus()
+
+ fireEvent.keyDown(one, { key: 'Tab', shiftKey: true })
+ expect(two).toHaveFocus()
+
+ // From the panel itself, Shift+Tab goes to the last control rather than the page.
+ const panel = screen.getByRole('dialog')
+ panel.focus()
+ fireEvent.keyDown(panel, { key: 'Tab', shiftKey: true })
+ expect(two).toHaveFocus()
+ })
+
+ it('FE-SHARED-DIALOGFOCUS-007: Tab in the middle is left to the browser', () => {
+ render()
+ const two = screen.getByRole('button', { name: 'Two' })
+ two.focus()
+ const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
+ two.dispatchEvent(event)
+ expect(event.defaultPrevented).toBe(false)
+ })
+})
+
+describe('Modal focus (#1302)', () => {
+ function Harness() {
+ const [open, setOpen] = useState(false)
+ return (
+ <>
+
+ setOpen(false)} title="Edit">
+
+
+
+ >
+ )
+ }
+
+ it('FE-SHARED-DIALOGFOCUS-008: the focus moves in on open and back to the opener on close', () => {
+ restore = asDesktop()
+ render()
+ const opener = screen.getByRole('button', { name: 'Open' })
+ opener.focus()
+ fireEvent.click(opener)
+ expect(screen.getByLabelText('Title')).toHaveFocus()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }))
+ expect(screen.queryByLabelText('Title')).toBeNull()
+ expect(opener).toHaveFocus()
+ })
+
+ it('FE-SHARED-DIALOGFOCUS-009: Tab stays inside the modal', () => {
+ render()
+ fireEvent.click(screen.getByRole('button', { name: 'Open' }))
+ const save = screen.getByRole('button', { name: 'Save' })
+ save.focus()
+ fireEvent.keyDown(save, { key: 'Tab' })
+ expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus()
+ })
+})
diff --git a/client/src/components/shared/dialogFocus.ts b/client/src/components/shared/dialogFocus.ts
new file mode 100644
index 000000000..0d45fc1a0
--- /dev/null
+++ b/client/src/components/shared/dialogFocus.ts
@@ -0,0 +1,81 @@
+import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
+
+/**
+ * Keyboard focus for the app's dialogs (#1302): the first Tab lands in the dialog
+ * instead of running through the page behind it, Tab wraps around inside it, and
+ * on a desktop the first text field is ready to type into.
+ *
+ * Fields that open a list as soon as they get the focus (an address or airport
+ * search) carry role="combobox" and are skipped, so a dialog never opens with a
+ * dropdown already hanging off it. A field can opt out with data-no-autofocus.
+ */
+const TEXT_FIELD = [
+ 'input:not([type]),input[type="text"],input[type="search"],input[type="email"],input[type="url"],input[type="tel"],input[type="number"],input[type="password"]',
+ 'textarea',
+].join(',')
+
+const TABBABLE = [
+ 'a[href]', 'button', 'input:not([type="hidden"])', 'select', 'textarea',
+ '[tabindex]', '[contenteditable="true"]',
+].join(',')
+
+function usable(el: HTMLElement): boolean {
+ if ((el as HTMLInputElement).disabled) return false
+ if (el.closest('[hidden],[aria-hidden="true"],[inert]')) return false
+ return el.getAttribute('tabindex') !== '-1'
+}
+
+/** The first field a user would type into, or null when the dialog has none. */
+export function firstTextField(panel: HTMLElement): HTMLElement | null {
+ for (const el of panel.querySelectorAll(TEXT_FIELD)) {
+ if (!usable(el)) continue
+ if ((el as HTMLInputElement).readOnly) continue
+ if (el.getAttribute('role') === 'combobox' || el.hasAttribute('aria-haspopup') || el.hasAttribute('data-no-autofocus')) continue
+ return el
+ }
+ return null
+}
+
+/** Only with a mouse or trackpad: on a phone a focused field would throw the keyboard up. */
+function typingDevice(): boolean {
+ if (typeof window.matchMedia !== 'function') return false
+ return window.matchMedia('(pointer: fine)').matches && !window.matchMedia('(max-width: 767px)').matches
+}
+
+/**
+ * Puts the focus into a dialog that just opened, unless something inside already
+ * took it (a field with autoFocus, a dialog's own choice). The panel itself is
+ * the fallback, so the next Tab starts inside the dialog either way.
+ */
+export function focusDialog(panel: HTMLElement): void {
+ if (panel.contains(document.activeElement)) return
+ const field = typingDevice() ? firstTextField(panel) : null
+ ;(field ?? panel).focus({ preventScroll: true })
+}
+
+/**
+ * Keeps Tab inside the dialog: past the last control it wraps to the first, and
+ * Shift+Tab from the first (or from the panel) to the last. A key pressed in a
+ * picker list the dialog opened in a portal on the body is left alone, because
+ * that list is not inside the panel and handles its own keys.
+ */
+export function trapTab(e: ReactKeyboardEvent, panel: HTMLElement): void {
+ if (e.key !== 'Tab' || e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return
+ const target = e.target as Node
+ if (target !== panel && !panel.contains(target)) return
+ const items = Array.from(panel.querySelectorAll(TABBABLE)).filter(usable)
+ if (items.length === 0) {
+ e.preventDefault()
+ panel.focus({ preventScroll: true })
+ return
+ }
+ const first = items[0]
+ const last = items[items.length - 1]
+ if (e.shiftKey && (target === first || target === panel)) {
+ e.preventDefault()
+ last.focus()
+ } else if (!e.shiftKey && target === last) {
+ e.preventDefault()
+ first.focus()
+ }
+}