mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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,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' }));
|
||||
|
||||
Reference in New Issue
Block a user