qwen-code/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx
carffuca 5c9e73f371
feat(web-shell): overhaul list-dialog interaction, keyboard nav & a11y (#6128)
* feat(web-shell): add keyboard-nav and IME-safe filter hooks

Two reusable hooks for list-style dialogs:

- useListboxKeyboard: Arrow/Home/End/Enter navigation driven by an active
  index, with a "keyboard mode" flag to suppress hover. Yields modified-key
  combos (Cmd/Ctrl/Alt/Shift), Home/End in text inputs, and Enter on focused
  buttons/links to native handling.
- useFilterInput: IME-composition-safe search state so a filtered list does
  not refire on every intermediate pinyin character (commits on compositionend).

* feat(web-shell): DialogShell Escape/backdrop close and focus management

Give every dialog shared, accessible dismissal and focus behaviour:
- Escape closes (guarded during IME composition so it cancels the
  composition, not the dialog)
- click on the backdrop closes
- Tab is trapped within the panel, wrapping at both ends
- focus moves into the dialog on open and is restored to the opener on close

* feat(web-shell): overhaul list-dialog interaction and accessibility

Unify interaction across the model, theme, approval, resume, tools, delete,
release and rewind dialogs:

- keyboard navigation via useListboxKeyboard, with a roving highlight that
  opens on the current value and does not fight the mouse
- consistent selection visuals: a single roving highlight plus a persistent
  "current" accent bar + checkmark; options are role=option divs (no stray
  focus ring)
- IME-safe search via useFilterInput; fix Chinese-input jitter in the
  resume/delete/release search boxes
- accessibility: role=listbox/option, aria-activedescendant, and aria-selected
  bound to the current value rather than the roving highlight
- destructive dialogs keep Enter non-destructive where a confirm button is the
  commit (delete/release); rewind confirms on Enter like a single-select picker

Refactors:
- extract shared SessionRow used by resume/delete/release
- rename resume-picker-* CSS primitives to picker-* (they are shared by all
  list dialogs, not resume-specific)

Adds regression tests for the model duplicate-current fix, release hover
selection, rewind Enter, the listbox/aria wiring, and the shared hooks.

* fix(web-shell): address dialog interaction review feedback

Follow-up fixes from upstream review:
- DialogShell closes on completed backdrop clicks instead of mousedown, and its
  Tab trap now also catches the panel-focused fallback case
- ToolsDialog now has full listbox semantics (ids, aria-activedescendant,
  aria-expanded)
- Rewind keeps the roving cursor separate from the confirmed target; Enter only
  confirms, and the danger button executes the rewind
- Model/Approval aria-selected now reflects the actual current value; model
  highlights stay in bounds when the model list shrinks
- Home/End and modified arrow-key combos yield to native text navigation in
  search inputs; Escape yields to IME composition in DialogShell
- Dead picker CSS and duplicate declarations removed; extra regression tests
  added for reviewer-raised edge cases

* fix(web-shell): harden shared dialog keyboard and IME handling

* fix(web-shell): tighten dialog shell focus, stacking, and backdrop behavior

* fix(web-shell): align list dialog selection semantics and add coverage
2026-07-02 12:19:17 +00:00

110 lines
3.4 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { DAEMON_APPROVAL_MODES } from '@qwen-code/webui/daemon-react-sdk';
import { useI18n } from '../../i18n';
import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
import { dp } from './dialogStyles';
import { ModeIcon } from '../ModeIcon';
import styles from './ApprovalModeDialog.module.css';
interface ApprovalModeDialogProps {
currentMode: string;
onSelect: (modeId: string) => void;
}
interface ModeItem {
id: string;
name: string;
description: string;
}
export function ApprovalModeDialog({
currentMode,
onSelect,
}: ApprovalModeDialogProps) {
const { t } = useI18n();
const listRef = useRef<HTMLDivElement>(null);
const approvalModes: ModeItem[] = DAEMON_APPROVAL_MODES.map((id) => ({
id,
name: t(`mode.listLabel.${id}`),
description: t(`mode.desc.${id}`),
}));
const currentIdx = approvalModes.findIndex((m) => m.id === currentMode);
const [activeIndex, setActiveIndex] = useState(
currentIdx >= 0 ? currentIdx : 0,
);
// Follow the current mode until the user first navigates: it can change
// while the dialog is open (e.g. another client sharing the session flips
// approval mode). Once the user has moved the highlight, don't steal it.
const userNavigatedRef = useRef(false);
useEffect(() => {
if (userNavigatedRef.current || currentIdx < 0) return;
setActiveIndex(currentIdx);
}, [currentIdx]);
const moveHighlight = (index: number) => {
userNavigatedRef.current = true;
setActiveIndex(index);
};
const confirm = (index: number) => {
const mode = approvalModes[index];
if (mode) onSelect(mode.id);
};
const { keyboardMode } = useListboxKeyboard({
itemCount: approvalModes.length,
activeIndex,
onActiveIndexChange: moveHighlight,
onConfirm: confirm,
});
useEffect(() => {
const el = listRef.current?.children[activeIndex] as
| HTMLElement
| undefined;
el?.scrollIntoView({ block: 'nearest' });
}, [activeIndex]);
return (
<div
className={`${styles.list} ${keyboardMode ? styles.keyboardOnly : ''}`}
ref={listRef}
role="listbox"
tabIndex={0}
aria-activedescendant={
approvalModes.length > 0 ? `mode-opt-${activeIndex}` : undefined
}
aria-label={t('mode.select')}
>
{approvalModes.map((mode, index) => {
const selected = index === activeIndex;
const isCurrent = mode.id === currentMode;
return (
<div
key={mode.id}
id={`mode-opt-${index}`}
role="option"
// aria-selected marks the actual current mode; the roving keyboard
// highlight is conveyed by aria-activedescendant + `.selected`.
aria-selected={isCurrent}
className={`${styles.row} ${selected ? styles.selected : ''} ${
isCurrent ? dp('dialog-current') : ''
}`}
onClick={() => confirm(index)}
onMouseMove={() => moveHighlight(index)}
>
<span className={styles.modeIcon}>
<ModeIcon mode={mode.id} />
</span>
<span className={styles.modeText}>
<span className={styles.modeName}>{mode.name}</span>
<span className={styles.modeDesc}>{mode.description}</span>
</span>
</div>
);
})}
</div>
);
}