fix(assistant): the phone chat holds the focus as a dialog, and its lists close on Escape

The full screen chat is a modal dialog but left the focus on the trip behind
it. It now takes the focus when shown, keeps Tab inside and hands the focus
back when set aside or closed, through the helper MSheet now shares. The
dropdown panels take the focus onto their first entry, close on Escape and
give the focus back to their button; the history and menu buttons say
whether their panel is open, and the open chat is announced as current.
This commit is contained in:
Maurice
2026-10-24 01:21:28 +00:00
parent 4d00858b8a
commit 508e17bc11
7 changed files with 153 additions and 36 deletions
@@ -1,4 +1,4 @@
import { ReactNode } from 'react'
import { ReactNode, useEffect, useLayoutEffect, useRef } from 'react'
interface MDropdownPanelProps {
open: boolean
@@ -12,9 +12,36 @@ interface MDropdownPanelProps {
* Floating popover panel (user menu, filter dropdowns): heavy glass, r20,
* closed by a transparent full-screen backdrop — no dim, unlike sheets.
* Layered above the scrolling content but below the bottom nav (z-40) and
* sheets (z-60), matching the design's stacking order.
* sheets (z-60), matching the design's stacking order. Opened, it takes the
* focus onto its first button and Escape closes it; closed, the focus goes
* back to the button it was opened from, unless a pick moved it on.
*/
export default function MDropdownPanel({ open, onClose, className = '', children }: MDropdownPanelProps) {
const panelRef = useRef<HTMLDivElement>(null)
const onCloseRef = useRef(onClose)
useLayoutEffect(() => {
onCloseRef.current = onClose
})
useEffect(() => {
if (!open) return
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
panelRef.current?.querySelector<HTMLElement>('button:not(:disabled)')?.focus({ preventScroll: true })
// Captured and stopped, so the key closes the panel and nothing under it.
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.stopPropagation()
onCloseRef.current()
}
document.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('keydown', onKey, true)
// The focused button left with the panel: the focus fell back to the page, and goes to the opener.
const lost = document.activeElement === null || document.activeElement === document.body
if (lost && opener?.isConnected) opener.focus({ preventScroll: true })
}
}, [open])
if (!open) return null
return (
@@ -23,6 +50,7 @@ export default function MDropdownPanel({ open, onClose, className = '', children
no role of its own; the panel's own controls are the keyboard path. */}
<div className="fixed inset-0 z-[35]" role="presentation" onClick={onClose} />
<div
ref={panelRef}
className={`m-pop-in fixed z-[36] w-[228px] rounded-[20px] border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[6px] text-m-ink shadow-[0_24px_60px_-20px_rgba(0,0,0,.5)] backdrop-blur-[30px] backdrop-saturate-[1.8] ${className}`}
>
{children}
+9 -1
View File
@@ -1,4 +1,4 @@
import { ReactNode } from 'react'
import { type AriaAttributes, ReactNode } from 'react'
interface MIconBtnProps {
onClick?: () => void
@@ -10,6 +10,10 @@ interface MIconBtnProps {
className?: string
/** Dimmed and inert. For a button whose position should stay put even when it cannot act. */
disabled?: boolean
/** For a button that opens a panel: whether it is open now, said to a screen reader. */
ariaExpanded?: boolean
/** For a button that opens a panel: what kind it is (`menu` for a menu). */
ariaHaspopup?: AriaAttributes['aria-haspopup']
children: ReactNode
}
@@ -21,6 +25,8 @@ export default function MIconBtn({
size = 38,
className = '',
disabled = false,
ariaExpanded,
ariaHaspopup,
children,
}: MIconBtnProps) {
return (
@@ -28,6 +34,8 @@ export default function MIconBtn({
type="button"
onClick={onClick}
aria-label={ariaLabel}
aria-expanded={ariaExpanded}
aria-haspopup={ariaHaspopup}
disabled={disabled}
className={`relative flex flex-none items-center justify-center rounded-full text-m-ink disabled:opacity-35 ${
variant === 'glass'
@@ -8,6 +8,8 @@ interface MListRowProps {
danger?: boolean
onClick?: () => void
className?: string
/** The row is the one open now (a chat in a history list), said so to a screen reader too. */
current?: boolean
}
/** Menu/list row (user menu style): icon 16 in --m-muted, 13.5px semibold label. */
@@ -18,11 +20,13 @@ export default function MListRow({
danger = false,
onClick,
className = '',
current = false,
}: MListRowProps) {
return (
<button
type="button"
onClick={onClick}
aria-current={current || undefined}
className={`flex w-full items-center gap-[11px] rounded-xl px-[10px] py-[11px] text-start text-[0.84375rem] font-semibold active:bg-[color:var(--m-ic)] ${
danger ? 'text-[color:var(--m-st-danger)]' : 'text-m-ink'
} ${className}`}
+3 -28
View File
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
import { lockBodyScroll } from '../../utils/bodyScrollLock'
import { useTranslation } from '../../i18n'
import { useDiscardGuard } from '../../components/shared/useDiscardGuard'
import { keepTabInside, useModalFocus } from './modalFocus'
export type MSheetVariant = 'card' | 'bottom' | 'drawer'
export type MSheetMaterial = 'glass' | 'bar-glass' | 'opaque'
@@ -34,9 +35,6 @@ const EXIT_MS = 280
const DISCARD_BUTTON = 'rounded-full bg-danger px-4 py-2 text-body font-semibold text-white' // theme-lint-disable: white on the danger fill, as ConfirmDialog draws it
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
const POSITION: Record<MSheetVariant, string> = {
card: 'absolute inset-x-[14px] top-1/2 -translate-y-1/2',
bottom: 'absolute inset-x-4 bottom-[calc(var(--bottom-nav-h,84px)+16px)]',
@@ -128,12 +126,7 @@ export default function MSheet({
}, [open])
// Move focus into the dialog on open, hand it back on close.
useEffect(() => {
if (!open || !rendered) return
const previous = document.activeElement as HTMLElement | null
panelRef.current?.focus()
return () => previous?.focus()
}, [open, rendered])
useModalFocus(panelRef, open && rendered)
if (!rendered) return null
@@ -164,25 +157,7 @@ export default function MSheet({
}
// Keep Tab inside the dialog while it is open.
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== 'Tab') return
const panel = panelRef.current
if (!panel) return
const focusable = panel.querySelectorAll<HTMLElement>(FOCUSABLE)
if (focusable.length === 0) {
e.preventDefault()
return
}
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && (document.activeElement === first || document.activeElement === panel)) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
const handleKeyDown = (e: React.KeyboardEvent) => keepTabInside(e, panelRef.current)
const enterClass = variant === 'drawer' ? 'm-drawer-in' : 'm-sheet-in'
const exitClass = variant === 'drawer' ? 'm-drawer-out' : 'm-sheet-out'
@@ -0,0 +1,41 @@
import { type KeyboardEvent, type RefObject, useEffect } from 'react';
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
/**
* Keeps Tab inside a modal panel: past its last control it goes round to the
* first, and Shift+Tab from the first one (or from the panel itself) to the
* last. A panel with nothing to focus keeps the focus where it is.
*/
export function keepTabInside(e: KeyboardEvent, panel: HTMLElement | null): void {
if (e.key !== 'Tab' || !panel) return;
const focusable = panel.querySelectorAll<HTMLElement>(FOCUSABLE);
if (focusable.length === 0) {
e.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && (document.activeElement === first || document.activeElement === panel)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
/**
* While `shown`, the focus sits on the modal panel (which takes it with
* `tabIndex={-1}`); once it is no longer shown, the focus goes back to where
* it was before.
*/
export function useModalFocus(panel: RefObject<HTMLElement | null>, shown: boolean): void {
useEffect(() => {
if (!shown) return;
const previous = document.activeElement as HTMLElement | null;
panel.current?.focus();
return () => previous?.focus();
}, [panel, shown]);
}
@@ -32,6 +32,7 @@ import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
import MDropdownPanel from '../../../components/MDropdownPanel';
import MIconBtn from '../../../components/MIconBtn';
import MListRow from '../../../components/MListRow';
import { keepTabInside, useModalFocus } from '../../../components/modalFocus';
import MConfirmSheet from '../../settings/MConfirmSheet';
import type { MTripSheetsProps } from '../MTripShell';
@@ -165,6 +166,8 @@ interface ScreenProps {
* The chat screen itself, the same over a trip and (the home chat) on the
* pages outside one: the head with the history and the menu, and the chat.
* The trip notes are a trip's, so the home chat's menu goes without them.
* A modal dialog over the page: shown, it takes the focus and keeps Tab
* inside; set aside or closed, it hands the focus back.
*/
export function AssistantScreen({ tripId, tripTitle, hidden, hops, onOpenTripChat }: ScreenProps) {
const { t, locale } = useTranslation();
@@ -178,7 +181,9 @@ export function AssistantScreen({ tripId, tripTitle, hidden, hops, onOpenTripCha
const memory = useTripMemory(tripId);
const tripChat = assistantScopeOf(tripId).kind === 'trip';
const hints = useAssistantHintsSetting();
const sectionRef = useRef<HTMLElement>(null);
useBackCloses(!hidden, close);
useModalFocus(sectionRef, !hidden);
const pick = (run: () => void) => {
setPanel(null);
@@ -192,11 +197,14 @@ export function AssistantScreen({ tripId, tripTitle, hidden, hops, onOpenTripCha
return (
<section
ref={sectionRef}
role="dialog"
aria-modal="true"
aria-label={t('assistant.window.title')}
hidden={hidden}
className={`fixed inset-0 z-[var(--z-bar)] ${hidden ? 'hidden' : 'flex'} flex-col bg-[var(--m-bg)] bg-[image:var(--m-scr)] text-m-ink`}
tabIndex={-1}
onKeyDown={(e) => keepTabInside(e, sectionRef.current)}
className={`fixed inset-0 z-[var(--z-bar)] ${hidden ? 'hidden' : 'flex'} flex-col bg-[var(--m-bg)] bg-[image:var(--m-scr)] text-m-ink outline-none`}
style={{ paddingBottom: `max(env(safe-area-inset-bottom, 0px), ${keyboard}px)` }}
>
<header className="flex flex-none items-center gap-2 px-4 pb-2 pt-[var(--m-safe-top,12px)]">
@@ -213,7 +221,7 @@ export function AssistantScreen({ tripId, tripTitle, hidden, hops, onOpenTripCha
</div>
)}
</div>
<MIconBtn ariaLabel={t('assistant.history.open')} onClick={openHistory}>
<MIconBtn ariaLabel={t('assistant.history.open')} ariaExpanded={panel === 'history'} onClick={openHistory}>
<History size={16} strokeWidth={2} />
</MIconBtn>
<MIconBtn
@@ -223,7 +231,12 @@ export function AssistantScreen({ tripId, tripTitle, hidden, hops, onOpenTripCha
>
<Plus size={17} strokeWidth={2.2} />
</MIconBtn>
<MIconBtn ariaLabel={t('assistant.window.menu')} onClick={() => setPanel('menu')}>
<MIconBtn
ariaLabel={t('assistant.window.menu')}
ariaExpanded={panel === 'menu'}
ariaHaspopup="menu"
onClick={() => setPanel('menu')}
>
<Ellipsis size={17} strokeWidth={2.2} />
</MIconBtn>
</header>
@@ -242,6 +255,7 @@ export function AssistantScreen({ tripId, tripTitle, hidden, hops, onOpenTripCha
key={th.id}
label={th.title}
className={th.id === thread.threadId ? 'bg-[color:var(--m-ic)]' : ''}
current={th.id === thread.threadId}
onClick={() => pick(() => thread.openThread(th.id))}
trailing={
<span className="flex-none text-caption text-m-muted">
@@ -8,11 +8,11 @@ import { chatOf, useAssistantStore } from '../../../../src/store/assistantStore'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip';
import { act, cleanup, fireEvent, render, screen } from '../../../helpers/render';
// FE-MOB-ASSIST-001 to FE-MOB-ASSIST-015
// FE-MOB-ASSIST-001 to FE-MOB-ASSIST-017
//
// The phone's full-screen chat around the shared chat surface, which is
// replaced here: what is tested is the frame, the header and the way to the
// map and back.
// map and back, and where the focus goes as the chat and its lists open.
const surface = vi.hoisted(() => ({
onOpen: null as ((target: EntityTarget) => void) | null,
@@ -129,6 +129,53 @@ describe('MAssistantScreen', () => {
expect(thread.openThread).toHaveBeenCalledWith(3);
});
it('FE-MOB-ASSIST-016: the chat takes the focus when it opens, keeps Tab inside and hands the focus back', () => {
const outside = document.createElement('button');
document.body.appendChild(outside);
outside.focus();
try {
renderScreen();
const dialog = screen.getByRole('dialog', { name: 'Assistant' });
expect(dialog).toHaveFocus();
fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true });
const more = screen.getByRole('button', { name: 'More options' });
expect(more).toHaveFocus();
fireEvent.keyDown(more, { key: 'Tab' });
expect(screen.getByRole('button', { name: 'Back' })).toHaveFocus();
// Set aside for the map, the chat gives the focus back to where it was.
act(() => useAssistantStore.getState().setMinimized(true));
expect(outside).toHaveFocus();
} finally {
outside.remove();
}
});
it('FE-MOB-ASSIST-017: the recent chats and the menu say whether they are open, and Escape closes them onto their button', () => {
renderScreen();
const history = screen.getByRole('button', { name: 'Recent chats' });
expect(history).toHaveAttribute('aria-expanded', 'false');
history.focus();
fireEvent.click(history);
expect(history).toHaveAttribute('aria-expanded', 'true');
// The list takes the focus onto its first entry and says which chat is open.
expect(screen.getAllByRole('button', { name: 'New chat' })[1]).toHaveFocus();
expect(screen.getByRole('button', { name: /Temples on day 2/ })).toHaveAttribute('aria-current', 'true');
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
expect(screen.queryByText('Chats are deleted after 90 days.')).not.toBeInTheDocument();
expect(history).toHaveFocus();
expect(history).toHaveAttribute('aria-expanded', 'false');
const more = screen.getByRole('button', { name: 'More options' });
expect(more).toHaveAttribute('aria-haspopup', 'menu');
more.focus();
fireEvent.click(more);
expect(more).toHaveAttribute('aria-expanded', 'true');
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
expect(screen.queryByRole('button', { name: 'Delete chat' })).not.toBeInTheDocument();
expect(more).toHaveFocus();
});
it('FE-MOB-ASSIST-006: deleting the chat asks first', () => {
renderScreen();
fireEvent.click(screen.getByRole('button', { name: 'More options' }));