mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-03 05:14:52 +00:00
* 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
225 lines
9.2 KiB
TypeScript
225 lines
9.2 KiB
TypeScript
import { useContext, useEffect, useRef, useState } from 'react';
|
|
import {
|
|
DialogShellIdContext,
|
|
isTopDialogShellId,
|
|
} from '../components/dialogs/DialogShell';
|
|
|
|
export interface ListboxKeyboardOptions {
|
|
/** Number of selectable items in the list. */
|
|
itemCount: number;
|
|
/** Currently highlighted index, or -1 when no row is highlighted. */
|
|
activeIndex: number;
|
|
/** Called with the next index when the user moves the highlight. */
|
|
onActiveIndexChange: (index: number) => void;
|
|
/** Called with an index when the user confirms it (Enter). */
|
|
onConfirm: (index: number) => void;
|
|
/** Disable the listener without unmounting the host component. */
|
|
enabled?: boolean;
|
|
}
|
|
|
|
export interface ListboxKeyboardResult {
|
|
/**
|
|
* True while the user is navigating by keyboard. Dialogs use this to suppress
|
|
* the CSS `:hover` highlight so a cursor that happens to rest over a row —
|
|
* e.g. when the dialog opens under the pointer — does not fight the keyboard
|
|
* highlight. It flips back to false on a real `mousemove` (which never fires
|
|
* from a dialog merely appearing under a stationary cursor).
|
|
*/
|
|
keyboardMode: boolean;
|
|
}
|
|
|
|
function clamp(index: number, itemCount: number): number {
|
|
if (itemCount <= 0) return 0;
|
|
if (index < 0) return 0;
|
|
if (index > itemCount - 1) return itemCount - 1;
|
|
return index;
|
|
}
|
|
|
|
const BUTTON_INPUT_TYPES = new Set(['button', 'submit', 'reset']);
|
|
|
|
const NON_TEXT_INPUT_TYPES = new Set([
|
|
'checkbox',
|
|
'radio',
|
|
'button',
|
|
'submit',
|
|
'reset',
|
|
'range',
|
|
'color',
|
|
'file',
|
|
]);
|
|
|
|
/**
|
|
* True when the focused element natively acts on Enter (a dialog button, link,
|
|
* textarea, etc.). In that case list confirmation must yield so, e.g., Enter on
|
|
* a focused "Delete" button triggers the button rather than toggling a row.
|
|
* Text inputs are intentionally NOT listed here: in the searchable dialogs
|
|
* focus never leaves the filter input, so Enter from it must still confirm —
|
|
* but only a row the user visibly highlighted first (see the `active < 0`
|
|
* guard in the Enter case).
|
|
*/
|
|
function focusOwnsEnter(): boolean {
|
|
const el = typeof document !== 'undefined' ? document.activeElement : null;
|
|
if (!el) return false;
|
|
const tag = el.tagName;
|
|
if (tag === 'BUTTON' || tag === 'A' || tag === 'TEXTAREA') return true;
|
|
if (tag === 'INPUT') {
|
|
return BUTTON_INPUT_TYPES.has((el as HTMLInputElement).type);
|
|
}
|
|
const role = el.getAttribute('role');
|
|
return role === 'button' || role === 'link' || role === 'menuitem';
|
|
}
|
|
|
|
/**
|
|
* True when focus is in an editable text field, where Home/End must keep their
|
|
* native caret behaviour (jump to start/end of the text) instead of being
|
|
* hijacked to move the list highlight.
|
|
*/
|
|
function focusOwnsHomeEnd(): boolean {
|
|
const el = typeof document !== 'undefined' ? document.activeElement : null;
|
|
if (!el) return false;
|
|
const tag = el.tagName;
|
|
if (tag === 'TEXTAREA') return true;
|
|
if (tag === 'INPUT') {
|
|
return !NON_TEXT_INPUT_TYPES.has((el as HTMLInputElement).type);
|
|
}
|
|
return (el as HTMLElement).isContentEditable === true;
|
|
}
|
|
|
|
/**
|
|
* Keyboard navigation for listbox-style dialogs (model/theme/approval/resume/…).
|
|
*
|
|
* Selection is driven by `activeIndex` state rather than DOM focus, so it works
|
|
* whether focus sits on the dialog panel/listbox or on a search input. The
|
|
* visual highlight + `scrollIntoView` already implemented by each dialog
|
|
* reflects the active index; this hook only moves that index and confirms it.
|
|
*
|
|
* Enter confirms the active row, unless focus is on a control that owns Enter
|
|
* (see {@link focusOwnsEnter}) — so, e.g., Enter on a focused "Delete" button
|
|
* activates the button — or no row is highlighted (`activeIndex < 0`).
|
|
* Searchable dialogs rely on the latter: they open with no highlight and reset
|
|
* to none whenever the filter text changes, so a reflexive Enter in the search
|
|
* box never confirms a row the user didn't visibly pick first; the first
|
|
* ArrowDown lands on the first row. Space mirrors Enter (per the ARIA listbox
|
|
* pattern) but additionally yields to text fields, where it types a space.
|
|
* Escape is intentionally NOT handled here — {@link DialogShell} owns dialog
|
|
* dismissal.
|
|
*/
|
|
export function useListboxKeyboard({
|
|
itemCount,
|
|
activeIndex,
|
|
onActiveIndexChange,
|
|
onConfirm,
|
|
enabled = true,
|
|
}: ListboxKeyboardOptions): ListboxKeyboardResult {
|
|
const dialogShellId = useContext(DialogShellIdContext);
|
|
// Keep latest values in a ref so the listener is bound once, not per keystroke.
|
|
const stateRef = useRef({
|
|
itemCount,
|
|
activeIndex,
|
|
onActiveIndexChange,
|
|
onConfirm,
|
|
});
|
|
stateRef.current = { itemCount, activeIndex, onActiveIndexChange, onConfirm };
|
|
|
|
const [keyboardMode, setKeyboardMode] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
|
|
const enterKeyboardMode = () => setKeyboardMode(true);
|
|
const exitKeyboardMode = () => setKeyboardMode(false);
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
// Only the active/topmost dialog should react. In stacked dialogs, a
|
|
// background listbox must ignore Arrow/Enter/Space even though it also
|
|
// has a global listener. The shell id comes from DialogShell context, so
|
|
// focus may sit on either the search input or the list itself.
|
|
if (!isTopDialogShellId(dialogShellId)) return;
|
|
// keyCode 229 is the cross-browser "this key belongs to the IME" marker.
|
|
// WebKit fires `compositionend` BEFORE the committing Enter's keydown,
|
|
// so that keydown arrives with isComposing === false — only the legacy
|
|
// keyCode identifies it as an IME commit rather than a real Enter.
|
|
if (event.defaultPrevented || event.isComposing || event.keyCode === 229)
|
|
return;
|
|
// Only plain keypresses drive list navigation. Modified combos are OS/text
|
|
// shortcuts (e.g. Cmd+↑/↓ = text start/end on macOS, Shift+↑/↓ = extend
|
|
// selection) and must reach the focused input untouched.
|
|
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
|
|
return;
|
|
}
|
|
const { itemCount: count, activeIndex: active } = stateRef.current;
|
|
if (count <= 0) return;
|
|
|
|
switch (event.key) {
|
|
case 'ArrowDown':
|
|
event.preventDefault();
|
|
enterKeyboardMode();
|
|
stateRef.current.onActiveIndexChange(clamp(active + 1, count));
|
|
break;
|
|
case 'ArrowUp':
|
|
event.preventDefault();
|
|
enterKeyboardMode();
|
|
stateRef.current.onActiveIndexChange(clamp(active - 1, count));
|
|
break;
|
|
case 'Home':
|
|
// Let an editable field keep Home for caret-to-start.
|
|
if (focusOwnsHomeEnd()) return;
|
|
event.preventDefault();
|
|
enterKeyboardMode();
|
|
stateRef.current.onActiveIndexChange(0);
|
|
break;
|
|
case 'End':
|
|
if (focusOwnsHomeEnd()) return;
|
|
event.preventDefault();
|
|
enterKeyboardMode();
|
|
stateRef.current.onActiveIndexChange(count - 1);
|
|
break;
|
|
case 'Enter': {
|
|
// Let a focused button/link/etc. handle its own Enter activation.
|
|
if (focusOwnsEnter()) return;
|
|
// No highlighted row → nothing to confirm. Searchable dialogs open
|
|
// with activeIndex -1 and reset to -1 on filter edits, so Enter in
|
|
// the search box only ever acts on a row the user visibly chose.
|
|
if (active < 0) return;
|
|
event.preventDefault();
|
|
stateRef.current.onConfirm(clamp(active, count));
|
|
break;
|
|
}
|
|
case ' ': {
|
|
// The ARIA listbox pattern selects on Space as well as Enter (the
|
|
// option rows used to be native <button>s, which gave this for
|
|
// free). Space types a space in a text field and activates a
|
|
// focused button natively, so only treat it as "select" when the
|
|
// list truly owns the key. preventDefault also stops the browser's
|
|
// default page-scroll when focus sits on the scrollable list.
|
|
if (focusOwnsEnter() || focusOwnsHomeEnd()) return;
|
|
if (active < 0) return;
|
|
event.preventDefault();
|
|
stateRef.current.onConfirm(clamp(active, count));
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
};
|
|
|
|
// `window` bubble phase — the last stop in the propagation chain. This
|
|
// ordering is a contract with DialogShell: its `document`-level listener
|
|
// runs first and stops propagation on Escape/Tab, so those keys never
|
|
// arrive here (and the defaultPrevented check above covers anything a
|
|
// dialog control consumed). We additionally scope handling to the active
|
|
// dialog via DialogShell context so stacked list dialogs can't drive the
|
|
// background one. Don't move this to `document` or capture.
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
// `mousemove` (not `mouseenter`) marks the switch back to pointer control:
|
|
// it only fires on genuine cursor movement, so a dialog opening under a
|
|
// stationary pointer never yanks control away from the keyboard.
|
|
window.addEventListener('mousemove', exitKeyboardMode);
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
window.removeEventListener('mousemove', exitKeyboardMode);
|
|
};
|
|
}, [dialogShellId, enabled]);
|
|
|
|
return { keyboardMode };
|
|
}
|