mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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
This commit is contained in:
parent
848386a624
commit
5c9e73f371
31 changed files with 3823 additions and 509 deletions
|
|
@ -7,6 +7,12 @@
|
|||
padding: 6px 8px 0;
|
||||
}
|
||||
|
||||
/* Focused programmatically for keyboard nav / aria-activedescendant; selection
|
||||
is shown by the roving row highlight, so suppress the container outline. */
|
||||
.list:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -27,6 +33,17 @@
|
|||
background: var(--secondary);
|
||||
}
|
||||
|
||||
/* Keyboard mode: the pointer yields to the keyboard, so a cursor resting on a
|
||||
row must not paint a second highlight. Only the keyboard-selected row keeps
|
||||
its background. */
|
||||
.keyboardOnly .row:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.keyboardOnly .row.selected:hover {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.selected .modeName {
|
||||
color: var(--agent-blue-500);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { I18nProvider } from '../../i18n';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
|
||||
DAEMON_APPROVAL_MODES: ['plan', 'default', 'yolo'],
|
||||
}));
|
||||
|
||||
const { ApprovalModeDialog } = await import('./ApprovalModeDialog');
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
function mount(node: React.ReactNode) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(<I18nProvider language="en">{node}</I18nProvider>);
|
||||
});
|
||||
}
|
||||
|
||||
function rerender(node: React.ReactNode) {
|
||||
act(() => {
|
||||
root!.render(<I18nProvider language="en">{node}</I18nProvider>);
|
||||
});
|
||||
}
|
||||
|
||||
function press(key: string) {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const activeDescendant = () =>
|
||||
container!
|
||||
.querySelector('[role="listbox"]')!
|
||||
.getAttribute('aria-activedescendant');
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('ApprovalModeDialog', () => {
|
||||
it('opens with the highlight on the current mode and confirms on Enter', () => {
|
||||
const onSelect = vi.fn();
|
||||
mount(<ApprovalModeDialog currentMode="default" onSelect={onSelect} />);
|
||||
|
||||
expect(activeDescendant()).toBe('mode-opt-1');
|
||||
|
||||
press('ArrowDown');
|
||||
expect(activeDescendant()).toBe('mode-opt-2');
|
||||
press('Enter');
|
||||
expect(onSelect).toHaveBeenCalledWith('yolo');
|
||||
});
|
||||
|
||||
it('binds aria-selected to the current mode, not the roving highlight', () => {
|
||||
mount(<ApprovalModeDialog currentMode="plan" onSelect={vi.fn()} />);
|
||||
const selected = () =>
|
||||
Array.from(container!.querySelectorAll('[aria-selected="true"]'));
|
||||
|
||||
expect(selected()).toHaveLength(1);
|
||||
expect(selected()[0].id).toBe('mode-opt-0');
|
||||
|
||||
press('ArrowDown');
|
||||
expect(selected()).toHaveLength(1);
|
||||
expect(selected()[0].id).toBe('mode-opt-0');
|
||||
});
|
||||
|
||||
it('re-syncs the highlight when the current mode changes while open', () => {
|
||||
mount(<ApprovalModeDialog currentMode="plan" onSelect={vi.fn()} />);
|
||||
expect(activeDescendant()).toBe('mode-opt-0');
|
||||
|
||||
// Another client sharing the session flips approval mode while the dialog
|
||||
// is open: the highlight (and Enter's target) must follow.
|
||||
rerender(<ApprovalModeDialog currentMode="yolo" onSelect={vi.fn()} />);
|
||||
expect(activeDescendant()).toBe('mode-opt-2');
|
||||
});
|
||||
|
||||
it('stops following once the user has navigated', () => {
|
||||
mount(<ApprovalModeDialog currentMode="plan" onSelect={vi.fn()} />);
|
||||
|
||||
press('ArrowDown');
|
||||
expect(activeDescendant()).toBe('mode-opt-1');
|
||||
|
||||
// The user owns the highlight now — a mode change must not steal it.
|
||||
rerender(<ApprovalModeDialog currentMode="yolo" onSelect={vi.fn()} />);
|
||||
expect(activeDescendant()).toBe('mode-opt-1');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
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';
|
||||
|
||||
|
|
@ -27,32 +29,71 @@ export function ApprovalModeDialog({
|
|||
description: t(`mode.desc.${id}`),
|
||||
}));
|
||||
|
||||
const selectedIdx = approvalModes.findIndex((m) => m.id === currentMode);
|
||||
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[selectedIdx] as
|
||||
const el = listRef.current?.children[activeIndex] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}, [selectedIdx]);
|
||||
}, [activeIndex]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.list}
|
||||
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) => {
|
||||
const selected = mode.id === currentMode;
|
||||
{approvalModes.map((mode, index) => {
|
||||
const selected = index === activeIndex;
|
||||
const isCurrent = mode.id === currentMode;
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={mode.id}
|
||||
type="button"
|
||||
id={`mode-opt-${index}`}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className={`${styles.row} ${selected ? styles.selected : ''}`}
|
||||
onClick={() => onSelect(mode.id)}
|
||||
// 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} />
|
||||
|
|
@ -61,7 +102,7 @@ export function ApprovalModeDialog({
|
|||
<span className={styles.modeName}>{mode.name}</span>
|
||||
<span className={styles.modeDesc}>{mode.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,255 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { I18nProvider } from '../../i18n';
|
||||
import { dp } from './dialogStyles';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
let sessions = [
|
||||
{
|
||||
sessionId: 's0',
|
||||
displayName: 'S0',
|
||||
clientCount: 1,
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
sessionId: 's1',
|
||||
displayName: 'S1',
|
||||
clientCount: 1,
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
sessionId: 'me',
|
||||
displayName: 'Current Session',
|
||||
clientCount: 1,
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const deleteSessionMock = vi.fn();
|
||||
const deleteSessionsMock = vi.fn();
|
||||
const initialSessions = sessions.slice();
|
||||
|
||||
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
|
||||
useConnection: () => ({ sessionId: 'me' }),
|
||||
useSessions: () => ({
|
||||
sessions,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
deleteSession: deleteSessionMock,
|
||||
deleteSessions: deleteSessionsMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { DeleteSessionDialog } = await import('./DeleteSessionDialog');
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
let onDeleted: ReturnType<typeof vi.fn>;
|
||||
let onError: ReturnType<typeof vi.fn>;
|
||||
let onClose: ReturnType<typeof vi.fn>;
|
||||
|
||||
function renderDialog() {
|
||||
root!.render(
|
||||
<I18nProvider language="en">
|
||||
<DeleteSessionDialog
|
||||
onDeleted={onDeleted}
|
||||
onError={onError}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function mount() {
|
||||
onDeleted = vi.fn();
|
||||
onError = vi.fn();
|
||||
onClose = vi.fn();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
renderDialog();
|
||||
});
|
||||
}
|
||||
|
||||
function rerender() {
|
||||
act(() => {
|
||||
renderDialog();
|
||||
});
|
||||
}
|
||||
|
||||
function rows(): HTMLElement[] {
|
||||
return Array.from(container!.querySelectorAll('[role="option"]'));
|
||||
}
|
||||
|
||||
function dangerButton(): HTMLButtonElement {
|
||||
return Array.from(container!.querySelectorAll('button')).find((b) =>
|
||||
b.className.includes(dp('dialog-danger-button')),
|
||||
) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function press(key: string) {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function clickRow(index: number) {
|
||||
act(() => {
|
||||
rows()[index].dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value',
|
||||
)!.set!;
|
||||
|
||||
function typeFilter(value: string) {
|
||||
act(() => {
|
||||
const el = container!.querySelector('input')!;
|
||||
nativeInputValueSetter.call(el, value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
const isCursor = (el: HTMLElement) => el.className.includes(dp('selected'));
|
||||
const isChecked = (el: HTMLElement) => el.textContent?.includes('[x]') === true;
|
||||
|
||||
beforeEach(() => {
|
||||
deleteSessionMock.mockReset();
|
||||
deleteSessionsMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
sessions = initialSessions.slice();
|
||||
});
|
||||
|
||||
describe('DeleteSessionDialog selection', () => {
|
||||
it('keeps the keyboard cursor separate from the checked set; Enter only toggles', () => {
|
||||
mount();
|
||||
|
||||
// Opens with no highlight and nothing checked; delete stays disabled.
|
||||
expect(rows().some(isCursor)).toBe(false);
|
||||
expect(rows().some(isChecked)).toBe(false);
|
||||
expect(dangerButton().disabled).toBe(true);
|
||||
|
||||
// The first ArrowDown lands the cursor on row 0 without checking it.
|
||||
press('ArrowDown');
|
||||
expect(isCursor(rows()[0])).toBe(true);
|
||||
expect(isChecked(rows()[0])).toBe(false);
|
||||
expect(dangerButton().disabled).toBe(true);
|
||||
|
||||
// Enter toggles the cursor row's checkbox — it must not delete anything.
|
||||
press('Enter');
|
||||
expect(isChecked(rows()[0])).toBe(true);
|
||||
expect(dangerButton().disabled).toBe(false);
|
||||
expect(deleteSessionsMock).not.toHaveBeenCalled();
|
||||
expect(deleteSessionMock).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
// Moving the cursor keeps prior checks intact (multi-select).
|
||||
press('ArrowDown');
|
||||
expect(isCursor(rows()[1])).toBe(true);
|
||||
expect(isChecked(rows()[0])).toBe(true);
|
||||
expect(isChecked(rows()[1])).toBe(false);
|
||||
|
||||
press('Enter');
|
||||
expect(isChecked(rows()[1])).toBe(true);
|
||||
|
||||
// Enter on an already-checked row unchecks it.
|
||||
press('Enter');
|
||||
expect(isChecked(rows()[1])).toBe(false);
|
||||
expect(isChecked(rows()[0])).toBe(true);
|
||||
});
|
||||
|
||||
it('does not check the current session row', () => {
|
||||
mount();
|
||||
|
||||
clickRow(2);
|
||||
expect(isChecked(rows()[2])).toBe(false);
|
||||
expect(dangerButton().disabled).toBe(true);
|
||||
|
||||
// Keyboard Enter on the current session row must not check it either.
|
||||
press('ArrowDown');
|
||||
press('ArrowDown');
|
||||
press('ArrowDown');
|
||||
expect(isCursor(rows()[2])).toBe(true);
|
||||
press('Enter');
|
||||
expect(isChecked(rows()[2])).toBe(false);
|
||||
expect(dangerButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('clears checked rows and disarms delete when the filter changes', () => {
|
||||
mount();
|
||||
|
||||
clickRow(0);
|
||||
clickRow(1);
|
||||
expect(isChecked(rows()[0])).toBe(true);
|
||||
expect(isChecked(rows()[1])).toBe(true);
|
||||
expect(dangerButton().disabled).toBe(false);
|
||||
|
||||
typeFilter('s1');
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(isChecked(rows()[0])).toBe(false);
|
||||
expect(rows().some(isCursor)).toBe(false);
|
||||
expect(dangerButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('prunes stale checked ids after an unfiltered session refresh', async () => {
|
||||
mount();
|
||||
|
||||
clickRow(0);
|
||||
clickRow(1);
|
||||
expect(dangerButton().disabled).toBe(false);
|
||||
|
||||
sessions = [
|
||||
{
|
||||
sessionId: 'me',
|
||||
displayName: 'Current Session',
|
||||
clientCount: 1,
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
rerender();
|
||||
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(isChecked(rows()[0])).toBe(false);
|
||||
expect(dangerButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('deletes the checked sessions via the batch API and closes', async () => {
|
||||
deleteSessionsMock.mockResolvedValue({
|
||||
removed: ['s0', 's1'],
|
||||
notFound: [],
|
||||
errors: [],
|
||||
});
|
||||
mount();
|
||||
|
||||
clickRow(0);
|
||||
clickRow(1);
|
||||
expect(dangerButton().disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
dangerButton().dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(deleteSessionsMock).toHaveBeenCalledWith(['s0', 's1']);
|
||||
expect(onDeleted).toHaveBeenCalledWith(['s0', 's1']);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||
import { dp } from './dialogStyles';
|
||||
import { useConnection, useSessions } from '@qwen-code/webui/daemon-react-sdk';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { formatRelativeTime } from '../../utils/formatRelativeTime';
|
||||
import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
|
||||
import { useFilterInput } from '../../hooks/useFilterInput';
|
||||
import { SessionRow } from './SessionRow';
|
||||
|
||||
interface DeleteSessionDialogProps {
|
||||
onDeleted: (sessionIds: string[]) => void;
|
||||
|
|
@ -10,6 +12,9 @@ interface DeleteSessionDialogProps {
|
|||
onClose: () => void;
|
||||
}
|
||||
|
||||
const LIST_ID = 'delete-session-list';
|
||||
const optionId = (index: number) => `${LIST_ID}-opt-${index}`;
|
||||
|
||||
export function DeleteSessionDialog({
|
||||
onDeleted,
|
||||
onError,
|
||||
|
|
@ -26,9 +31,15 @@ export function DeleteSessionDialog({
|
|||
} = useSessions({ autoLoad: true });
|
||||
const currentSessionId = connection.sessionId;
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||
// `selectedIdx` is the keyboard/hover cursor (roving highlight, -1 = none —
|
||||
// see ResumeDialog for the rationale); `selectedIds` is the multi-select set
|
||||
// marked for deletion (shown by the [x] checkbox).
|
||||
const [selectedIdx, setSelectedIdx] = useState(-1);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const { filterValue: filterQuery, inputProps } = useFilterInput(() => {
|
||||
setSelectedIdx(-1);
|
||||
setSelectedIds(new Set());
|
||||
});
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -39,27 +50,42 @@ export function DeleteSessionDialog({
|
|||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
searchQuery
|
||||
filterQuery
|
||||
? sessions.filter((s) => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const q = filterQuery.toLowerCase();
|
||||
return (
|
||||
(s.displayName || '').toLowerCase().includes(q) ||
|
||||
s.sessionId.toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
: sessions,
|
||||
[sessions, searchQuery],
|
||||
[sessions, filterQuery],
|
||||
);
|
||||
|
||||
const toggleSelection = useCallback(
|
||||
(sessionId: string) => {
|
||||
if (sessionId === currentSessionId) return;
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sessionId)) {
|
||||
next.delete(sessionId);
|
||||
} else {
|
||||
next.add(sessionId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[currentSessionId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery && selectedIds.size > 0) {
|
||||
const filteredSet = new Set(filtered.map((s) => s.sessionId));
|
||||
setSelectedIds((prev) => {
|
||||
const pruned = new Set([...prev].filter((id) => filteredSet.has(id)));
|
||||
return pruned.size === prev.size ? prev : pruned;
|
||||
});
|
||||
}
|
||||
}, [searchQuery, filtered, selectedIds.size]);
|
||||
if (selectedIds.size === 0) return;
|
||||
const filteredSet = new Set(filtered.map((s) => s.sessionId));
|
||||
setSelectedIds((prev) => {
|
||||
const pruned = new Set([...prev].filter((id) => filteredSet.has(id)));
|
||||
return pruned.size === prev.size ? prev : pruned;
|
||||
});
|
||||
}, [filtered, selectedIds.size]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIdx >= filtered.length && filtered.length > 0) {
|
||||
|
|
@ -78,21 +104,17 @@ export function DeleteSessionDialog({
|
|||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const toggleSelection = useCallback(
|
||||
(sessionId: string) => {
|
||||
if (sessionId === currentSessionId) return;
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sessionId)) {
|
||||
next.delete(sessionId);
|
||||
} else {
|
||||
next.add(sessionId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// Enter toggles the cursor row's checkbox; the actual (destructive) delete
|
||||
// still requires pressing the danger button — mirroring the click behaviour.
|
||||
const { keyboardMode } = useListboxKeyboard({
|
||||
itemCount: filtered.length,
|
||||
activeIndex: selectedIdx,
|
||||
onActiveIndexChange: setSelectedIdx,
|
||||
onConfirm: (index) => {
|
||||
const session = filtered[index];
|
||||
if (session) toggleSelection(session.sessionId);
|
||||
},
|
||||
[currentSessionId],
|
||||
);
|
||||
});
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (deleting) return;
|
||||
|
|
@ -191,23 +213,28 @@ export function DeleteSessionDialog({
|
|||
const canDelete = !deleting && !loading && hasSelection;
|
||||
|
||||
return (
|
||||
<div className={dp('resume-picker', 'resume-picker-in-shell')}>
|
||||
<div className={dp('resume-picker-search')}>
|
||||
<span className={dp('resume-picker-search-label')}>
|
||||
<div className={dp('picker', 'picker-in-shell')}>
|
||||
<div className={dp('picker-search')}>
|
||||
<span className={dp('picker-search-label')}>
|
||||
{t('resume.search')}:{' '}
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={dp('resume-picker-search-input')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setSelectedIdx(0);
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
className={dp('picker-search-input')}
|
||||
aria-label={t('resume.search')}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded="true"
|
||||
aria-controls={LIST_ID}
|
||||
aria-activedescendant={
|
||||
selectedIdx >= 0 && selectedIdx < filtered.length
|
||||
? optionId(selectedIdx)
|
||||
: undefined
|
||||
}
|
||||
{...inputProps}
|
||||
placeholder=""
|
||||
/>
|
||||
<span className={dp('resume-picker-search-hint')}>
|
||||
<span className={dp('picker-search-hint')}>
|
||||
{message ||
|
||||
(deleting
|
||||
? t('delete.deleting')
|
||||
|
|
@ -215,27 +242,34 @@ export function DeleteSessionDialog({
|
|||
? t('common.loading')
|
||||
: hasSelection
|
||||
? t('delete.selected', { count: selectedIds.size })
|
||||
: searchQuery
|
||||
: filterQuery
|
||||
? t('delete.matches', { count: filtered.length })
|
||||
: '')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={dp('resume-picker-sep')} />
|
||||
<div className={dp('picker-sep')} />
|
||||
|
||||
<div className={dp('resume-picker-list')} ref={listRef}>
|
||||
<div
|
||||
id={LIST_ID}
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
className={dp(
|
||||
'picker-list',
|
||||
keyboardMode ? 'picker-keyboard-only' : undefined,
|
||||
)}
|
||||
ref={listRef}
|
||||
>
|
||||
{loading && (
|
||||
<div className={dp('resume-picker-empty')}>{t('common.loading')}</div>
|
||||
<div className={dp('picker-empty')}>{t('common.loading')}</div>
|
||||
)}
|
||||
{!loading && sessionsError && (
|
||||
<div className={dp('resume-picker-empty')}>
|
||||
{sessionsError.message}
|
||||
</div>
|
||||
<div className={dp('picker-empty')}>{sessionsError.message}</div>
|
||||
)}
|
||||
{!loading && !sessionsError && filtered.length === 0 && (
|
||||
<div className={dp('resume-picker-empty')}>
|
||||
{searchQuery
|
||||
? t('delete.noMatch', { query: searchQuery })
|
||||
<div className={dp('picker-empty')}>
|
||||
{filterQuery
|
||||
? t('delete.noMatch', { query: filterQuery })
|
||||
: t('delete.none')}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -243,55 +277,43 @@ export function DeleteSessionDialog({
|
|||
filtered.map((s, i) => {
|
||||
const isCurrent = s.sessionId === currentSessionId;
|
||||
const isChecked = selectedIds.has(s.sessionId);
|
||||
const checkbox = isChecked ? '[x] ' : '[ ] ';
|
||||
return (
|
||||
<div
|
||||
<SessionRow
|
||||
key={s.sessionId}
|
||||
className={dp(
|
||||
'resume-picker-item',
|
||||
'resume-picker-session-item',
|
||||
isChecked ? 'selected' : undefined,
|
||||
isCurrent ? 'resume-picker-item-current' : undefined,
|
||||
isCurrent ? 'disabled' : undefined,
|
||||
)}
|
||||
session={s}
|
||||
optionId={optionId(i)}
|
||||
active={i === selectedIdx}
|
||||
ariaSelected={isChecked}
|
||||
current={false}
|
||||
disabled={isCurrent}
|
||||
leading={
|
||||
<span
|
||||
className={dp(
|
||||
'picker-item-checkbox',
|
||||
isChecked ? 'picker-item-checkbox-checked' : undefined,
|
||||
)}
|
||||
>
|
||||
{isChecked ? '[x] ' : '[ ] '}
|
||||
</span>
|
||||
}
|
||||
trailing={
|
||||
isCurrent ? (
|
||||
<span className={dp('picker-item-badge')}>
|
||||
{t('resume.current')}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => {
|
||||
setSelectedIdx(i);
|
||||
if (!isCurrent) toggleSelection(s.sessionId);
|
||||
}}
|
||||
>
|
||||
<div className={dp('resume-picker-item-row')}>
|
||||
<span className={dp('resume-picker-item-checkbox')}>
|
||||
{checkbox}
|
||||
</span>
|
||||
<span className={dp('resume-picker-item-title')}>
|
||||
{s.displayName || s.sessionId.slice(0, 8)}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span className={dp('resume-picker-item-badge')}>
|
||||
{t('resume.current')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={dp('resume-picker-item-meta')}>
|
||||
<span>
|
||||
{(s.updatedAt || s.createdAt) &&
|
||||
formatRelativeTime(s.updatedAt || s.createdAt || '', t)}
|
||||
</span>
|
||||
<span className={dp('resume-picker-item-detail')}>
|
||||
{t('common.clients', { count: s.clientCount ?? 0 })}
|
||||
</span>
|
||||
{s.hasActivePrompt && (
|
||||
<span className={dp('resume-picker-item-detail')}>
|
||||
{t('resume.activePrompt')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
onActivate={() => setSelectedIdx(i)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={dp('resume-picker-sep')} />
|
||||
<div className={dp('picker-sep')} />
|
||||
<div className={dp('dialog-footer-actions')}>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* Shared dialog and picker primitives reused by dialog components. */
|
||||
/* ===== Dialog Overlay & Panel ===== */
|
||||
/* ===== CLI-style Picker (Model, Mode, Resume) ===== */
|
||||
.resume-picker {
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
|
@ -12,72 +12,14 @@
|
|||
margin: 24px;
|
||||
}
|
||||
|
||||
.resume-picker-in-shell {
|
||||
.picker-in-shell {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.resume-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.resume-picker-close {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resume-picker-close:hover {
|
||||
color: var(--foreground);
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.resume-picker-close::before,
|
||||
.resume-picker-close::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 1.5px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.resume-picker-close::before {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.resume-picker-close::after {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.resume-picker-title {
|
||||
font-weight: 700;
|
||||
color: var(--foreground);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.resume-picker-count {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.resume-picker-search {
|
||||
.picker-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
|
@ -86,11 +28,11 @@
|
|||
min-height: 24px;
|
||||
}
|
||||
|
||||
.resume-picker-search-label {
|
||||
.picker-search-label {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.resume-picker-search-input {
|
||||
.picker-search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
|
@ -100,37 +42,39 @@
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
.resume-picker-search-value {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.resume-picker-search-hint {
|
||||
.picker-search-hint {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.resume-picker-sep {
|
||||
.picker-sep {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.resume-picker-list {
|
||||
.picker-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.resume-picker-list-compact {
|
||||
/* When the list itself is the focus owner (aria-activedescendant pattern),
|
||||
selection is shown by the roving row highlight — suppress the outline. */
|
||||
.picker-list:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.picker-list-compact {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.resume-picker-empty {
|
||||
.picker-empty {
|
||||
padding: 16px 12px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.resume-picker-item {
|
||||
.picker-item {
|
||||
display: flex;
|
||||
width: calc(100% - 16px);
|
||||
align-items: baseline;
|
||||
|
|
@ -146,38 +90,33 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.resume-picker-session-item {
|
||||
.picker-session-item {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.resume-picker-item.selected {
|
||||
.picker-item.selected {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.resume-picker-item:hover {
|
||||
.picker-item:hover {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.resume-picker-item-current {
|
||||
border-radius: 6px;
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.resume-picker-keyboard-only .resume-picker-item:hover {
|
||||
.picker-keyboard-only .picker-item:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.resume-picker-keyboard-only .resume-picker-item.selected:hover {
|
||||
.picker-keyboard-only .picker-item.selected:hover {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.resume-picker-item.disabled {
|
||||
.picker-item.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.resume-picker-item-row {
|
||||
.picker-item-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex: 1;
|
||||
|
|
@ -186,63 +125,42 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.resume-picker-item-prefix {
|
||||
min-width: 14px;
|
||||
flex-shrink: 0;
|
||||
color: var(--agent-blue-500);
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
margin-right: 8px;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.resume-picker-item-title {
|
||||
.picker-item-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
color: var(--foreground);
|
||||
flex: 1 1 auto;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resume-picker-item-checkbox {
|
||||
.picker-item-checkbox {
|
||||
color: var(--muted-foreground);
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.resume-picker-item.selected .resume-picker-item-checkbox {
|
||||
.picker-item.selected .picker-item-checkbox {
|
||||
color: var(--agent-blue-500);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.resume-picker-item-number {
|
||||
flex-shrink: 0;
|
||||
min-width: 28px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.resume-picker-item-provider {
|
||||
flex-shrink: 0;
|
||||
color: var(--agent-blue-500);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resume-picker-item.selected .resume-picker-item-title {
|
||||
/* Multi-select "checked" emphasis — independent of the roving `.selected`
|
||||
cursor, so ticked rows stay legible even when the cursor is elsewhere. */
|
||||
.picker-item-checkbox-checked {
|
||||
color: var(--agent-blue-500);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.resume-picker-item-meta {
|
||||
.picker-item.selected .picker-item-title {
|
||||
color: var(--agent-blue-500);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.picker-item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
|
@ -250,23 +168,53 @@
|
|||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.resume-picker-item-detail {
|
||||
.picker-item-detail {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.resume-picker-item-detail::before {
|
||||
.picker-item-detail::before {
|
||||
content: '·';
|
||||
margin-right: 8px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.resume-picker-item-check {
|
||||
color: var(--agent-blue-500, #4a9eff);
|
||||
font-weight: bold;
|
||||
margin-left: 4px;
|
||||
/* "Current value" marker: a left accent bar + a right ✓ that read on their own
|
||||
channel, independent of the roving `.selected` background highlight. Both are
|
||||
absolutely positioned and vertically centered, so they stay centered whatever
|
||||
the row height (single- or multi-line). Shared by every list dialog. */
|
||||
.dialog-current,
|
||||
.picker-item-confirmed {
|
||||
position: relative;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
.resume-picker-item-badge {
|
||||
.dialog-current::before,
|
||||
.picker-item-confirmed::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 60%;
|
||||
width: 3px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background: var(--agent-blue-500, #4a9eff);
|
||||
}
|
||||
|
||||
.dialog-current::after,
|
||||
.picker-item-confirmed::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--agent-blue-500, #4a9eff);
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.picker-item-badge {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
|
|
@ -304,7 +252,7 @@
|
|||
padding: 5px 8px 6px;
|
||||
}
|
||||
|
||||
.tools-picker-item .resume-picker-item-row {
|
||||
.tools-picker-item .picker-item-row {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
|
@ -386,32 +334,6 @@
|
|||
.mcp-status-unknown {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.resume-picker-detail-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
padding-left: 32px;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resume-picker-detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: 128px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.resume-picker-detail-label {
|
||||
color: var(--muted-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resume-picker-detail-value {
|
||||
color: var(--foreground);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dialog-inline-button,
|
||||
.dialog-primary-button,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,12 @@
|
|||
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
/* The panel receives focus programmatically for keyboard management; suppress
|
||||
the container outline since selection is shown via a roving row highlight. */
|
||||
.panel:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sizeSm {
|
||||
width: min(100%, 420px);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,455 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, useEffect, useRef } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { I18nProvider } from '../../i18n';
|
||||
import { ThemeProvider } from '../../themeContext';
|
||||
import { DialogShell } from './DialogShell';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
function mount(node: React.ReactNode) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(
|
||||
<I18nProvider language="en">
|
||||
<ThemeProvider value="dark">{node}</ThemeProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function press(key: string, options: KeyboardEventInit = {}) {
|
||||
act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key, cancelable: true, ...options }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
function AutofocusChild() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return <input ref={inputRef} type="text" />;
|
||||
}
|
||||
|
||||
describe('DialogShell', () => {
|
||||
it('closes on Escape', () => {
|
||||
const onClose = vi.fn();
|
||||
mount(
|
||||
<DialogShell title="Test" onClose={onClose}>
|
||||
<button type="button">inner</button>
|
||||
</DialogShell>,
|
||||
);
|
||||
|
||||
press('Escape');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores Escape that belongs to an IME composition', () => {
|
||||
const onClose = vi.fn();
|
||||
mount(
|
||||
<DialogShell title="Test" onClose={onClose}>
|
||||
<input type="text" />
|
||||
</DialogShell>,
|
||||
);
|
||||
|
||||
// Chrome/Firefox: Escape cancelling a composition reports isComposing.
|
||||
press('Escape', { isComposing: true });
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
// WebKit: compositionend fires first, so only keyCode 229 marks the key.
|
||||
act(() => {
|
||||
const imeEscape = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
cancelable: true,
|
||||
});
|
||||
Object.defineProperty(imeEscape, 'keyCode', { value: 229 });
|
||||
document.dispatchEvent(imeEscape);
|
||||
});
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
// A genuine Escape still closes.
|
||||
press('Escape');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps focus on the panel when Tab is pressed with nothing focusable inside', () => {
|
||||
mount(
|
||||
<DialogShell title="Test" onClose={vi.fn()}>
|
||||
<button type="button" data-testid="inner">
|
||||
inner
|
||||
</button>
|
||||
</DialogShell>,
|
||||
);
|
||||
|
||||
const panel = document.querySelector<HTMLElement>('[role="dialog"]')!;
|
||||
// Simulate content whose focusables all went away (e.g. everything became
|
||||
// disabled/hidden while an action runs).
|
||||
document.querySelector('[data-dialog-close]')!.remove();
|
||||
document.querySelector('[data-testid="inner"]')!.remove();
|
||||
|
||||
panel.focus();
|
||||
act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Tab',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
// Tab must not escape to the page behind; focus stays parked on the panel.
|
||||
expect(document.activeElement).toBe(panel);
|
||||
});
|
||||
|
||||
it('lets a dialog control consume Escape instead of closing', () => {
|
||||
const onClose = vi.fn();
|
||||
mount(
|
||||
<DialogShell title="Test" onClose={onClose}>
|
||||
<input
|
||||
type="text"
|
||||
onKeyDown={(event) => {
|
||||
// e.g. an inline editor cancelling its edit on Escape.
|
||||
if (event.key === 'Escape') event.preventDefault();
|
||||
}}
|
||||
/>
|
||||
</DialogShell>,
|
||||
);
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>('input')!;
|
||||
input.focus();
|
||||
act(() => {
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('only the topmost of stacked shells handles Escape', () => {
|
||||
const onCloseBottom = vi.fn();
|
||||
const onCloseTop = vi.fn();
|
||||
mount(
|
||||
<>
|
||||
<DialogShell title="Bottom" onClose={onCloseBottom}>
|
||||
<button type="button">bottom</button>
|
||||
</DialogShell>
|
||||
<DialogShell title="Top" onClose={onCloseTop}>
|
||||
<button type="button">top</button>
|
||||
</DialogShell>
|
||||
</>,
|
||||
);
|
||||
|
||||
// One Escape peels off one layer — the top one — not both at once.
|
||||
press('Escape');
|
||||
expect(onCloseTop).toHaveBeenCalledTimes(1);
|
||||
expect(onCloseBottom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps focus inside the top shell if a lower shell unmounts first', () => {
|
||||
const opener = document.createElement('button');
|
||||
document.body.appendChild(opener);
|
||||
opener.focus();
|
||||
|
||||
function Harness({ showBottom }: { showBottom: boolean }) {
|
||||
return (
|
||||
<>
|
||||
{showBottom ? (
|
||||
<DialogShell title="Bottom" onClose={vi.fn()}>
|
||||
<button type="button">bottom</button>
|
||||
</DialogShell>
|
||||
) : null}
|
||||
<DialogShell title="Top" onClose={vi.fn()}>
|
||||
<button type="button" data-testid="top-focus">
|
||||
top
|
||||
</button>
|
||||
</DialogShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
mount(<Harness showBottom={true} />);
|
||||
const topButton = document.querySelector<HTMLElement>(
|
||||
'[data-testid="top-focus"]',
|
||||
)!;
|
||||
expect(document.activeElement).toBe(topButton);
|
||||
|
||||
act(() => {
|
||||
root!.render(
|
||||
<I18nProvider language="en">
|
||||
<ThemeProvider value="dark">
|
||||
<Harness showBottom={false} />
|
||||
</ThemeProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
// Focus must stay inside the remaining top shell, not jump back behind it.
|
||||
expect(document.activeElement).toBe(topButton);
|
||||
opener.remove();
|
||||
});
|
||||
|
||||
it('restores focus to the remaining top shell when the lower shell unmounts after focus moved', () => {
|
||||
const opener = document.createElement('button');
|
||||
document.body.appendChild(opener);
|
||||
opener.focus();
|
||||
|
||||
function Harness({ showBottom }: { showBottom: boolean }) {
|
||||
return (
|
||||
<>
|
||||
{showBottom ? (
|
||||
<DialogShell title="Bottom" onClose={vi.fn()}>
|
||||
<button type="button">bottom</button>
|
||||
</DialogShell>
|
||||
) : null}
|
||||
<DialogShell title="Top" onClose={vi.fn()}>
|
||||
<button type="button" data-testid="top-focus">
|
||||
top
|
||||
</button>
|
||||
</DialogShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
mount(<Harness showBottom={true} />);
|
||||
const topButton = document.querySelector<HTMLElement>(
|
||||
'[data-testid="top-focus"]',
|
||||
)!;
|
||||
document.querySelector<HTMLElement>('button:not([data-testid])')!.focus();
|
||||
|
||||
act(() => {
|
||||
root!.render(
|
||||
<I18nProvider language="en">
|
||||
<ThemeProvider value="dark">
|
||||
<Harness showBottom={false} />
|
||||
</ThemeProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(topButton);
|
||||
opener.remove();
|
||||
});
|
||||
|
||||
it('closes when the backdrop is clicked but not when the panel is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
mount(
|
||||
<DialogShell title="Test" onClose={onClose}>
|
||||
<button type="button">inner</button>
|
||||
</DialogShell>,
|
||||
);
|
||||
|
||||
const backdrop = document.querySelector<HTMLElement>(
|
||||
'[data-keyboard-scope]',
|
||||
);
|
||||
const panel = document.querySelector<HTMLElement>('[role="dialog"]');
|
||||
expect(backdrop).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
panel!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
panel!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
backdrop!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
backdrop!.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||
backdrop!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not close when a drag starts in the panel and ends on the backdrop', () => {
|
||||
const onClose = vi.fn();
|
||||
mount(
|
||||
<DialogShell title="Test" onClose={onClose}>
|
||||
<button type="button">inner</button>
|
||||
</DialogShell>,
|
||||
);
|
||||
|
||||
const backdrop = document.querySelector<HTMLElement>(
|
||||
'[data-keyboard-scope]',
|
||||
)!;
|
||||
const panel = document.querySelector<HTMLElement>('[role="dialog"]')!;
|
||||
|
||||
act(() => {
|
||||
panel.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
backdrop.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||
backdrop.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not close when a press starts on the backdrop and ends on the panel', () => {
|
||||
const onClose = vi.fn();
|
||||
mount(
|
||||
<DialogShell title="Test" onClose={onClose}>
|
||||
<button type="button">inner</button>
|
||||
</DialogShell>,
|
||||
);
|
||||
|
||||
const backdrop = document.querySelector<HTMLElement>(
|
||||
'[data-keyboard-scope]',
|
||||
)!;
|
||||
const panel = document.querySelector<HTMLElement>('[role="dialog"]')!;
|
||||
|
||||
act(() => {
|
||||
backdrop.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
panel.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||
// Browsers synthesize click on the nearest common ancestor for mismatched
|
||||
// press/release targets; here that's effectively the backdrop.
|
||||
backdrop.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moves focus into the dialog on open', () => {
|
||||
mount(
|
||||
<DialogShell title="Test" onClose={vi.fn()}>
|
||||
<button type="button" data-testid="first">
|
||||
first
|
||||
</button>
|
||||
<button type="button">second</button>
|
||||
</DialogShell>,
|
||||
);
|
||||
|
||||
const first = document.querySelector<HTMLElement>('[data-testid="first"]');
|
||||
expect(document.activeElement).toBe(first);
|
||||
});
|
||||
|
||||
it('restores focus to the opener on close', () => {
|
||||
const opener = document.createElement('button');
|
||||
document.body.appendChild(opener);
|
||||
opener.focus();
|
||||
expect(document.activeElement).toBe(opener);
|
||||
|
||||
mount(
|
||||
<DialogShell title="Test" onClose={vi.fn()}>
|
||||
<button type="button">inner</button>
|
||||
</DialogShell>,
|
||||
);
|
||||
// Focus moved into the dialog.
|
||||
expect(document.activeElement).not.toBe(opener);
|
||||
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
expect(document.activeElement).toBe(opener);
|
||||
opener.remove();
|
||||
});
|
||||
|
||||
it('restores focus to the opener even if a child autofocuses first', () => {
|
||||
const opener = document.createElement('button');
|
||||
document.body.appendChild(opener);
|
||||
opener.focus();
|
||||
expect(document.activeElement).toBe(opener);
|
||||
|
||||
mount(
|
||||
<DialogShell title="Test" onClose={vi.fn()}>
|
||||
<AutofocusChild />
|
||||
</DialogShell>,
|
||||
);
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>('input');
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
expect(document.activeElement).toBe(opener);
|
||||
opener.remove();
|
||||
});
|
||||
|
||||
it('traps Tab within the dialog, wrapping at both ends', () => {
|
||||
mount(
|
||||
<DialogShell title="Test" onClose={vi.fn()}>
|
||||
<button type="button" data-testid="last">
|
||||
inner
|
||||
</button>
|
||||
</DialogShell>,
|
||||
);
|
||||
|
||||
// Focusables in DOM order: [close button, inner button].
|
||||
const close = document.querySelector<HTMLElement>('[data-dialog-close]')!;
|
||||
const last = document.querySelector<HTMLElement>('[data-testid="last"]')!;
|
||||
|
||||
// Tab from the last focusable wraps to the first (the close button).
|
||||
last.focus();
|
||||
act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(document.activeElement).toBe(close);
|
||||
|
||||
// Shift+Tab from the first focusable wraps to the last.
|
||||
close.focus();
|
||||
act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Tab',
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(document.activeElement).toBe(last);
|
||||
});
|
||||
|
||||
it('pulls focus into the dialog when Tab is pressed while the panel holds focus', () => {
|
||||
mount(
|
||||
<DialogShell title="Test" onClose={vi.fn()}>
|
||||
<button type="button" data-testid="last">
|
||||
inner
|
||||
</button>
|
||||
</DialogShell>,
|
||||
);
|
||||
|
||||
const panel = document.querySelector<HTMLElement>('[role="dialog"]')!;
|
||||
const close = document.querySelector<HTMLElement>('[data-dialog-close]')!;
|
||||
const last = document.querySelector<HTMLElement>('[data-testid="last"]')!;
|
||||
|
||||
// Focus sits on the panel itself (roving-list fallback). Tab pulls it in.
|
||||
panel.focus();
|
||||
act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(document.activeElement).toBe(close);
|
||||
|
||||
// Shift+Tab from the panel pulls in from the end instead.
|
||||
panel.focus();
|
||||
act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Tab',
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(document.activeElement).toBe(last);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,10 @@
|
|||
import { type ReactNode } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { useTheme, WebShellThemeId } from '../../themeContext';
|
||||
|
|
@ -21,6 +27,39 @@ const sizeClass: Record<DialogSize, string> = {
|
|||
xl: styles.sizeXl,
|
||||
};
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]:not([hidden])',
|
||||
'button:not([disabled]):not([hidden])',
|
||||
'input:not([disabled]):not([hidden])',
|
||||
'select:not([disabled]):not([hidden])',
|
||||
'textarea:not([disabled]):not([hidden])',
|
||||
'[tabindex]:not([tabindex="-1"]):not([hidden])',
|
||||
].join(',');
|
||||
|
||||
function getFocusable(container: HTMLElement | null): HTMLElement[] {
|
||||
if (!container) return [];
|
||||
return Array.from(
|
||||
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
||||
);
|
||||
}
|
||||
|
||||
// Mounted shells, bottom → top. Every shell listens on `document`, and
|
||||
// `stopPropagation` cannot silence sibling listeners on the same node — so
|
||||
// without this, one Escape would close every stacked dialog at once (and the
|
||||
// bottom one would win any race, since it registered first). Only the topmost
|
||||
// shell handles keys; stacked dialogs peel off one layer per Escape.
|
||||
const shellStack: object[] = [];
|
||||
|
||||
export const DialogShellIdContext = createContext<object | null>(null);
|
||||
|
||||
export function isTopDialogShellId(shellId: object | null): boolean {
|
||||
// Most production callers live inside DialogShell and get a shell id. Tests or
|
||||
// any future standalone consumer may not; in that case, preserve the original
|
||||
// single-dialog behavior and allow the hook to handle keys normally.
|
||||
if (shellId === null) return true;
|
||||
return shellStack[shellStack.length - 1] === shellId;
|
||||
}
|
||||
|
||||
export function DialogShell({
|
||||
title,
|
||||
subtitle,
|
||||
|
|
@ -32,30 +71,182 @@ export function DialogShell({
|
|||
const theme = useTheme();
|
||||
const themeClass =
|
||||
theme === WebShellThemeId.Light ? styles.themeLight : styles.themeDark;
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
// `onClose` may change identity across renders; keep the latest for the
|
||||
// once-bound key listener.
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
// Capture the opener during the dialog's first render, before any child
|
||||
// effects can move focus into an autofocused search field.
|
||||
const [previouslyFocused] = useState<HTMLElement | null>(() =>
|
||||
typeof document !== 'undefined'
|
||||
? (document.activeElement as HTMLElement | null)
|
||||
: null,
|
||||
);
|
||||
// A completed backdrop click should close, but any drag that crosses the
|
||||
// panel boundary in either direction must not. Record whether the press both
|
||||
// started and ended on the backdrop itself, then let the synthesized click
|
||||
// close only when both are true.
|
||||
const backdropPressStartedRef = useRef(false);
|
||||
const backdropPressEndedRef = useRef(false);
|
||||
// Identity token for this shell instance in the module-level stack.
|
||||
const shellIdRef = useRef<object | null>(null);
|
||||
if (shellIdRef.current === null) shellIdRef.current = {};
|
||||
|
||||
// Move focus into the dialog on open, restore it to the opener on close, and
|
||||
// trap Tab within the panel. Escape closes.
|
||||
useEffect(() => {
|
||||
const panel = panelRef.current;
|
||||
const shellId = shellIdRef.current!;
|
||||
shellStack.push(shellId);
|
||||
|
||||
// Autofocus: respect a child that already claimed focus (e.g. a search
|
||||
// input's own effect); otherwise focus the first content focusable (skipping
|
||||
// the header close button), else the panel itself. Falling back to the panel
|
||||
// rather than the close button avoids a stray focus ring when a list dialog's
|
||||
// options are managed via a roving highlight (tabIndex=-1) instead of focus.
|
||||
if (panel && !panel.contains(document.activeElement)) {
|
||||
const focusables = getFocusable(panel);
|
||||
const preferred = focusables.find(
|
||||
(el) => !el.hasAttribute('data-dialog-close'),
|
||||
);
|
||||
(preferred ?? panel).focus();
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// With stacked dialogs, only the topmost shell may handle Escape/Tab —
|
||||
// a lower shell closing or trapping focus would act "through" the one
|
||||
// covering it.
|
||||
if (shellStack[shellStack.length - 1] !== shellId) return;
|
||||
// A control inside the dialog may consume the key first (e.g. Escape to
|
||||
// cancel an inline edit) — honor that instead of dismissing the dialog.
|
||||
if (event.defaultPrevented) return;
|
||||
// Escape mid-IME-composition cancels the composition, not the dialog.
|
||||
// keyCode 229 covers WebKit, which fires compositionend before the
|
||||
// committing key's keydown (see useListboxKeyboard for the same guard).
|
||||
if (event.isComposing || event.keyCode === 229) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Tab') {
|
||||
const focusables = getFocusable(panelRef.current);
|
||||
if (focusables.length === 0) {
|
||||
// Nothing focusable inside — keep focus on the panel itself.
|
||||
event.preventDefault();
|
||||
panelRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const activeEl = document.activeElement;
|
||||
const insideList = focusables.includes(activeEl as HTMLElement);
|
||||
if (!insideList) {
|
||||
// Focus is on the panel itself (e.g. a roving-highlight list where the
|
||||
// options are tabIndex=-1) — pull it into the dialog so Tab can't
|
||||
// escape to the page behind.
|
||||
event.preventDefault();
|
||||
(event.shiftKey ? last : first).focus();
|
||||
} else if (event.shiftKey && activeEl === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && activeEl === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Bubble phase on `document`, deliberately positioned in the middle of the
|
||||
// propagation chain: controls inside the dialog run first (and can consume
|
||||
// Escape via preventDefault, honored above), while `window`-level listeners
|
||||
// — the app's global shortcuts and useListboxKeyboard — run after, so the
|
||||
// stopPropagation on Escape still shields them. Moving this listener to the
|
||||
// capture phase would steal Escape from the dialog's own controls; moving
|
||||
// it to `window` would lose the race with the app-level handlers.
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
const idx = shellStack.indexOf(shellId);
|
||||
if (idx >= 0) shellStack.splice(idx, 1);
|
||||
if (shellStack.length === 0) {
|
||||
previouslyFocused?.focus?.();
|
||||
return;
|
||||
}
|
||||
// Another modal is still stacked above the page — keep focus inside the
|
||||
// remaining top shell instead of restoring it behind the modal layer.
|
||||
const scopes = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-keyboard-scope]'),
|
||||
);
|
||||
const topPanel =
|
||||
scopes[scopes.length - 1]?.querySelector<HTMLElement>(
|
||||
'[role="dialog"]',
|
||||
);
|
||||
const topFocusables = getFocusable(topPanel);
|
||||
const preferred = topFocusables.find(
|
||||
(el) => !el.hasAttribute('data-dialog-close'),
|
||||
);
|
||||
(preferred ?? topPanel)?.focus();
|
||||
};
|
||||
}, [previouslyFocused]);
|
||||
|
||||
const handleBackdropMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
backdropPressStartedRef.current = event.target === event.currentTarget;
|
||||
backdropPressEndedRef.current = false;
|
||||
};
|
||||
|
||||
const handleBackdropMouseUp = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
backdropPressEndedRef.current = event.target === event.currentTarget;
|
||||
};
|
||||
|
||||
const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const shouldClose =
|
||||
backdropPressStartedRef.current &&
|
||||
backdropPressEndedRef.current &&
|
||||
event.target === event.currentTarget;
|
||||
backdropPressStartedRef.current = false;
|
||||
backdropPressEndedRef.current = false;
|
||||
if (shouldClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div className={`${styles.backdrop} ${themeClass}`} data-keyboard-scope>
|
||||
<section
|
||||
className={`${styles.panel} ${sizeClass[size]}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.titleWrap}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
{subtitle && <div className={styles.subtitle}>{subtitle}</div>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.close}
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close')}
|
||||
title={t('common.close')}
|
||||
/>
|
||||
</header>
|
||||
<div className={styles.body}>{children}</div>
|
||||
</section>
|
||||
<div
|
||||
className={`${styles.backdrop} ${themeClass}`}
|
||||
data-keyboard-scope
|
||||
onMouseDown={handleBackdropMouseDown}
|
||||
onMouseUp={handleBackdropMouseUp}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<DialogShellIdContext.Provider value={shellIdRef.current}>
|
||||
<section
|
||||
ref={panelRef}
|
||||
className={`${styles.panel} ${sizeClass[size]}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.titleWrap}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
{subtitle && <div className={styles.subtitle}>{subtitle}</div>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.close}
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close')}
|
||||
title={t('common.close')}
|
||||
data-dialog-close
|
||||
/>
|
||||
</header>
|
||||
<div className={styles.body}>{children}</div>
|
||||
</section>
|
||||
</DialogShellIdContext.Provider>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -176,11 +176,9 @@ export function ExtensionsDialog() {
|
|||
}, [checking, extensions.length, loading, t]);
|
||||
|
||||
return (
|
||||
<div className={dp('resume-picker', 'resume-picker-in-shell')}>
|
||||
<div className={dp('resume-picker-search', 'extensions-toolbar')}>
|
||||
<span className={dp('resume-picker-search-hint')}>
|
||||
{message || summary}
|
||||
</span>
|
||||
<div className={dp('picker', 'picker-in-shell')}>
|
||||
<div className={dp('picker-search', 'extensions-toolbar')}>
|
||||
<span className={dp('picker-search-hint')}>{message || summary}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={dp('dialog-inline-button')}
|
||||
|
|
@ -191,11 +189,11 @@ export function ExtensionsDialog() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div className={dp('resume-picker-sep')} />
|
||||
<div className={dp('picker-sep')} />
|
||||
|
||||
<div className={dp('resume-picker-list')}>
|
||||
<div className={dp('picker-list')}>
|
||||
{!loading && extensions.length === 0 && (
|
||||
<div className={dp('resume-picker-empty')}>
|
||||
<div className={dp('picker-empty')}>
|
||||
{t('extensions.manage.empty')}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -207,8 +205,8 @@ export function ExtensionsDialog() {
|
|||
<div
|
||||
key={extension.id || extension.name}
|
||||
className={dp(
|
||||
'resume-picker-item',
|
||||
'resume-picker-session-item',
|
||||
'picker-item',
|
||||
'picker-session-item',
|
||||
'tools-picker-item',
|
||||
expanded ? 'selected' : undefined,
|
||||
expanded ? 'tools-picker-item-expanded' : undefined,
|
||||
|
|
@ -223,10 +221,10 @@ export function ExtensionsDialog() {
|
|||
}}
|
||||
>
|
||||
<span className={dp('tools-item-icon')} aria-hidden="true" />
|
||||
<span className={dp('resume-picker-item-title')}>
|
||||
<span className={dp('picker-item-title')}>
|
||||
{extensionTitle(extension)}
|
||||
</span>
|
||||
<span className={dp('resume-picker-item-badge')}>
|
||||
<span className={dp('picker-item-badge')}>
|
||||
v{extension.version}
|
||||
</span>
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import type { CommandInfo } from '../../adapters/types';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { useFilterInput } from '../../hooks/useFilterInput';
|
||||
import styles from './HelpDialog.module.css';
|
||||
|
||||
type HelpTab = 'general' | 'commands' | 'custom-commands';
|
||||
|
|
@ -233,7 +234,7 @@ function CommandsHelp({
|
|||
export function HelpDialog({ commands }: HelpDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const [activeTab, setActiveTab] = useState<HelpTab>('general');
|
||||
const [query, setQuery] = useState('');
|
||||
const { filterValue: query, inputProps } = useFilterInput();
|
||||
const showSearch = activeTab !== 'general';
|
||||
|
||||
return (
|
||||
|
|
@ -256,8 +257,7 @@ export function HelpDialog({ commands }: HelpDialogProps) {
|
|||
{showSearch && (
|
||||
<input
|
||||
className={styles.search}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
{...inputProps}
|
||||
placeholder={t('help.search')}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@
|
|||
padding: 6px 8px 0;
|
||||
}
|
||||
|
||||
/* Focused programmatically for keyboard nav / aria-activedescendant; selection
|
||||
is shown by the roving row highlight, so suppress the container outline. */
|
||||
.list:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -34,6 +40,17 @@
|
|||
background: var(--secondary);
|
||||
}
|
||||
|
||||
/* Keyboard mode: the pointer yields to the keyboard, so a cursor resting on a
|
||||
row must not paint a second highlight. Only the keyboard-selected row keeps
|
||||
its background. */
|
||||
.keyboardOnly .row:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.keyboardOnly .row.selected:hover {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.selected .label {
|
||||
color: var(--agent-blue-500);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { I18nProvider } from '../../i18n';
|
||||
import { dp } from './dialogStyles';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
// ModelDialog only reads `useConnection()`; models/current come in via props here.
|
||||
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
|
||||
useConnection: () => ({}),
|
||||
}));
|
||||
|
||||
const { ModelDialog } = await import('./ModelDialog');
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
function mount(node: React.ReactNode) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(<I18nProvider language="en">{node}</I18nProvider>);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('ModelDialog current marker', () => {
|
||||
it('marks exactly one row current when two models share an id', () => {
|
||||
// Two providers expose the same model id "qwen"; `currentModel` is only an
|
||||
// id, so both used to be flagged. Only the first match should be current.
|
||||
const models = [
|
||||
{ id: 'qwen', authType: 'a', baseUrl: 'https://a' },
|
||||
{ id: 'qwen', authType: 'b', baseUrl: 'https://b' },
|
||||
{ id: 'other', authType: 'c' },
|
||||
];
|
||||
mount(
|
||||
<ModelDialog onSelect={vi.fn()} models={models} currentModelId="qwen" />,
|
||||
);
|
||||
|
||||
const options = Array.from(container!.querySelectorAll('[role="option"]'));
|
||||
expect(options).toHaveLength(3);
|
||||
const currentClass = dp('dialog-current');
|
||||
const marked = options.filter((el) => el.className.includes(currentClass));
|
||||
expect(marked).toHaveLength(1);
|
||||
expect(marked[0].textContent).toContain('1.');
|
||||
});
|
||||
|
||||
it('binds aria-selected to the current model, not the roving highlight', () => {
|
||||
const models = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||
mount(
|
||||
<ModelDialog onSelect={vi.fn()} models={models} currentModelId="b" />,
|
||||
);
|
||||
const selected = () =>
|
||||
Array.from(container!.querySelectorAll('[aria-selected="true"]'));
|
||||
|
||||
// Only the current model (b) is aria-selected on open.
|
||||
expect(selected()).toHaveLength(1);
|
||||
expect(selected()[0].textContent).toContain('b');
|
||||
|
||||
// Moving the keyboard highlight must not change which row is aria-selected.
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
||||
});
|
||||
expect(selected()).toHaveLength(1);
|
||||
expect(selected()[0].textContent).toContain('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ModelDialog keyboard confirmation', () => {
|
||||
it('confirms the highlighted model on Enter', () => {
|
||||
const onSelect = vi.fn();
|
||||
const models = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||
mount(
|
||||
<ModelDialog onSelect={onSelect} models={models} currentModelId="b" />,
|
||||
);
|
||||
|
||||
// Enter with no navigation confirms the current model's row.
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }),
|
||||
);
|
||||
});
|
||||
expect(onSelect).toHaveBeenCalledWith('b');
|
||||
|
||||
// After arrowing, Enter confirms the newly highlighted row.
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }),
|
||||
);
|
||||
});
|
||||
expect(onSelect).toHaveBeenLastCalledWith('c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ModelDialog highlight follows the current model', () => {
|
||||
const models = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||
const activeDescendant = () =>
|
||||
container!
|
||||
.querySelector('[role="listbox"]')!
|
||||
.getAttribute('aria-activedescendant');
|
||||
|
||||
it('re-syncs the highlight when the current model changes while open', () => {
|
||||
mount(
|
||||
<ModelDialog onSelect={vi.fn()} models={models} currentModelId="a" />,
|
||||
);
|
||||
expect(activeDescendant()).toBe('model-opt-0');
|
||||
|
||||
// Another client sharing the session switches the model while the dialog
|
||||
// is open: the highlight (and thus detail panel / Enter) must follow.
|
||||
act(() => {
|
||||
root!.render(
|
||||
<I18nProvider language="en">
|
||||
<ModelDialog onSelect={vi.fn()} models={models} currentModelId="c" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
expect(activeDescendant()).toBe('model-opt-2');
|
||||
});
|
||||
|
||||
it('stops following once the user has navigated', () => {
|
||||
mount(
|
||||
<ModelDialog onSelect={vi.fn()} models={models} currentModelId="a" />,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
||||
});
|
||||
expect(activeDescendant()).toBe('model-opt-1');
|
||||
|
||||
// The user owns the highlight now — a current-model change must not steal it.
|
||||
act(() => {
|
||||
root!.render(
|
||||
<I18nProvider language="en">
|
||||
<ModelDialog onSelect={vi.fn()} models={models} currentModelId="c" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
expect(activeDescendant()).toBe('model-opt-1');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useConnection } from '@qwen-code/webui/daemon-react-sdk';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
|
||||
import { dp } from './dialogStyles';
|
||||
import styles from './ModelDialog.module.css';
|
||||
|
||||
export type ModelDialogMode = 'main' | 'fast' | 'voice';
|
||||
|
|
@ -105,23 +107,65 @@ export function ModelDialog({
|
|||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const isFastMode = mode === 'fast';
|
||||
const isVoiceMode = mode === 'voice';
|
||||
const selectedIdx = availableModels.findIndex((m) => m.id === currentModel);
|
||||
const selectedModel =
|
||||
selectedIdx >= 0 ? availableModels[selectedIdx] : availableModels[0];
|
||||
const currentIdx = availableModels.findIndex((m) => m.id === currentModel);
|
||||
const [activeIndex, setActiveIndex] = useState(
|
||||
currentIdx >= 0 ? currentIdx : 0,
|
||||
);
|
||||
// Follow the current model until the user first navigates: models arrive
|
||||
// asynchronously, and the current model itself can change while the dialog
|
||||
// is open (e.g. another client sharing the session switches models) — the
|
||||
// highlight, detail panel and Enter must track it. Once the user has moved
|
||||
// the highlight themselves, it is theirs and must not be stolen.
|
||||
const userNavigatedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (userNavigatedRef.current || availableModels.length === 0) return;
|
||||
setActiveIndex(currentIdx >= 0 ? currentIdx : 0);
|
||||
}, [availableModels.length, currentIdx]);
|
||||
|
||||
const moveHighlight = (index: number) => {
|
||||
userNavigatedRef.current = true;
|
||||
setActiveIndex(index);
|
||||
};
|
||||
|
||||
// Keep the highlight in bounds if the model list refreshes/shrinks while open,
|
||||
// so aria-activedescendant, the detail panel and Enter all stay in sync.
|
||||
useEffect(() => {
|
||||
if (activeIndex >= availableModels.length && availableModels.length > 0) {
|
||||
setActiveIndex(availableModels.length - 1);
|
||||
}
|
||||
}, [availableModels.length, activeIndex]);
|
||||
|
||||
const selectedModel = availableModels[activeIndex] ?? availableModels[0];
|
||||
|
||||
const confirm = (index: number) => {
|
||||
const model = availableModels[index];
|
||||
if (model) onSelect(getModelSelectId(model, isFastMode));
|
||||
};
|
||||
|
||||
const { keyboardMode } = useListboxKeyboard({
|
||||
itemCount: availableModels.length,
|
||||
activeIndex,
|
||||
onActiveIndexChange: moveHighlight,
|
||||
onConfirm: confirm,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.children[selectedIdx] as
|
||||
const el = listRef.current?.children[activeIndex] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}, [selectedIdx]);
|
||||
}, [activeIndex]);
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<div
|
||||
className={styles.list}
|
||||
className={`${styles.list} ${keyboardMode ? styles.keyboardOnly : ''}`}
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
tabIndex={0}
|
||||
aria-activedescendant={
|
||||
availableModels.length > 0 ? `model-opt-${activeIndex}` : undefined
|
||||
}
|
||||
aria-label={
|
||||
isFastMode
|
||||
? t('model.setFast')
|
||||
|
|
@ -134,16 +178,27 @@ export function ModelDialog({
|
|||
<div className={styles.empty}>{t('model.none')}</div>
|
||||
) : null}
|
||||
{availableModels.map((model, index) => {
|
||||
const selected = model.id === currentModel;
|
||||
const selected = index === activeIndex;
|
||||
// Only the first id match is the "current" one. `currentModel` is just
|
||||
// an id string, so when two providers expose the same model id we
|
||||
// cannot tell them apart here — mark one, consistent with the initial
|
||||
// highlight (which also lands on `currentIdx`, the first match).
|
||||
const isCurrent = index === currentIdx;
|
||||
const authType = getAuthType(model);
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={getModelKey(model)}
|
||||
type="button"
|
||||
id={`model-opt-${index}`}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className={`${styles.row} ${selected ? styles.selected : ''}`}
|
||||
onClick={() => onSelect(getModelSelectId(model, isFastMode))}
|
||||
// aria-selected marks the actual current model; the roving
|
||||
// keyboard highlight is conveyed by aria-activedescendant + the
|
||||
// visual `.selected` class, not by aria-selected.
|
||||
aria-selected={isCurrent}
|
||||
className={`${styles.row} ${selected ? styles.selected : ''} ${
|
||||
isCurrent ? dp('dialog-current') : ''
|
||||
}`}
|
||||
onClick={() => confirm(index)}
|
||||
onMouseMove={() => moveHighlight(index)}
|
||||
>
|
||||
<span className={styles.number}>{index + 1}.</span>
|
||||
{authType ? (
|
||||
|
|
@ -153,7 +208,7 @@ export function ModelDialog({
|
|||
{model.isRuntime ? (
|
||||
<span className={styles.badge}>Runtime</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,267 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { I18nProvider } from '../../i18n';
|
||||
import { dp } from './dialogStyles';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
const sessions = [
|
||||
{
|
||||
sessionId: 's0',
|
||||
displayName: 'S0',
|
||||
clientCount: 1,
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
sessionId: 's1',
|
||||
displayName: 'S1',
|
||||
clientCount: 1,
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
sessionId: 'me',
|
||||
displayName: 'Current Session',
|
||||
clientCount: 1,
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
sessionId: 'inactive',
|
||||
displayName: 'Inactive Session',
|
||||
clientCount: 0,
|
||||
hasActivePrompt: false,
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
|
||||
useConnection: () => ({ sessionId: 'me' }),
|
||||
useSessions: () => ({
|
||||
sessions,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
releaseSession: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { ReleaseSessionDialog } = await import('./ReleaseSessionDialog');
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
function mount() {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(
|
||||
<I18nProvider language="en">
|
||||
<ReleaseSessionDialog
|
||||
onReleased={vi.fn()}
|
||||
onError={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function rows(): HTMLElement[] {
|
||||
return Array.from(container!.querySelectorAll('[role="option"]'));
|
||||
}
|
||||
|
||||
function dangerButton(): HTMLButtonElement {
|
||||
return Array.from(container!.querySelectorAll('button')).find((b) =>
|
||||
b.className.includes(dp('dialog-danger-button')),
|
||||
) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value',
|
||||
)!.set!;
|
||||
|
||||
function typeFilter(value: string) {
|
||||
act(() => {
|
||||
const el = container!.querySelector('input')!;
|
||||
nativeInputValueSetter.call(el, value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
const isCursor = (el: HTMLElement) => el.className.includes(dp('selected'));
|
||||
const isConfirmed = (el: HTMLElement) =>
|
||||
el.className.includes(dp('picker-item-confirmed'));
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('ReleaseSessionDialog selection', () => {
|
||||
it('keeps the cursor and the confirmed target separate', () => {
|
||||
mount();
|
||||
|
||||
// The dialog opens with no highlight at all; Enter has nothing to act on.
|
||||
expect(rows().some(isCursor)).toBe(false);
|
||||
expect(rows().some(isConfirmed)).toBe(false);
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(rows().some(isConfirmed)).toBe(false);
|
||||
expect(dangerButton().disabled).toBe(true);
|
||||
|
||||
// The first ArrowDown lands on row 0 without confirming it.
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(isCursor(rows()[0])).toBe(true);
|
||||
expect(isConfirmed(rows()[0])).toBe(false);
|
||||
expect(dangerButton().disabled).toBe(true);
|
||||
|
||||
// Arrowing to another row must not confirm/enable the destructive action.
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(isCursor(rows()[1])).toBe(true);
|
||||
expect(isConfirmed(rows()[1])).toBe(false);
|
||||
expect(dangerButton().disabled).toBe(true);
|
||||
|
||||
// Enter confirms the current cursor row and enables the action.
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(isConfirmed(rows()[1])).toBe(true);
|
||||
expect(dangerButton().disabled).toBe(false);
|
||||
|
||||
// Moving the cursor again must not steal the confirmed target.
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(isCursor(rows()[0])).toBe(true);
|
||||
expect(isConfirmed(rows()[1])).toBe(true);
|
||||
expect(isConfirmed(rows()[0])).toBe(false);
|
||||
expect(dangerButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('moves the cursor on hover, but only Enter/click confirms the target', () => {
|
||||
mount();
|
||||
|
||||
// Hover updates the cursor so the visible gray row matches Enter's target,
|
||||
// but the destructive action still stays disabled until confirmation.
|
||||
act(() => {
|
||||
rows()[1].dispatchEvent(new MouseEvent('mousemove', { bubbles: true }));
|
||||
});
|
||||
expect(isCursor(rows()[1])).toBe(true);
|
||||
expect(isConfirmed(rows()[1])).toBe(false);
|
||||
expect(dangerButton().disabled).toBe(true);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(isConfirmed(rows()[1])).toBe(true);
|
||||
expect(dangerButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('does not confirm the current or an inactive session on click', () => {
|
||||
mount();
|
||||
|
||||
// Current session row cannot become the confirmed target.
|
||||
act(() => {
|
||||
rows()[2].dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
expect(isConfirmed(rows()[2])).toBe(false);
|
||||
expect(dangerButton().disabled).toBe(true);
|
||||
|
||||
// Inactive session row cannot become the confirmed target either.
|
||||
act(() => {
|
||||
rows()[3].dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
expect(isConfirmed(rows()[3])).toBe(false);
|
||||
expect(dangerButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('clears the confirmed target and disarms release when the filter changes', () => {
|
||||
mount();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(isConfirmed(rows()[1])).toBe(true);
|
||||
expect(dangerButton().disabled).toBe(false);
|
||||
|
||||
typeFilter('s1');
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(rows().some(isConfirmed)).toBe(false);
|
||||
expect(rows().some(isCursor)).toBe(false);
|
||||
expect(dangerButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('does not confirm any row when all visible rows are non-releasable', () => {
|
||||
const original = sessions.slice();
|
||||
try {
|
||||
sessions.splice(
|
||||
0,
|
||||
sessions.length,
|
||||
{
|
||||
sessionId: 'me',
|
||||
displayName: 'Current Session',
|
||||
clientCount: 1,
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
sessionId: 'inactive',
|
||||
displayName: 'Inactive Session',
|
||||
clientCount: 0,
|
||||
hasActivePrompt: false,
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
);
|
||||
mount();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(rows().some((row) => isConfirmed(row))).toBe(false);
|
||||
expect(dangerButton().disabled).toBe(true);
|
||||
} finally {
|
||||
sessions.splice(0, sessions.length, ...original);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -6,7 +6,9 @@ import {
|
|||
type DaemonSessionSummary,
|
||||
} from '@qwen-code/webui/daemon-react-sdk';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { formatRelativeTime } from '../../utils/formatRelativeTime';
|
||||
import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
|
||||
import { useFilterInput } from '../../hooks/useFilterInput';
|
||||
import { SessionRow } from './SessionRow';
|
||||
|
||||
interface ReleaseSessionDialogProps {
|
||||
onReleased: (sessionId: string) => void;
|
||||
|
|
@ -14,6 +16,9 @@ interface ReleaseSessionDialogProps {
|
|||
onClose: () => void;
|
||||
}
|
||||
|
||||
const LIST_ID = 'release-session-list';
|
||||
const optionId = (index: number) => `${LIST_ID}-opt-${index}`;
|
||||
|
||||
export function ReleaseSessionDialog({
|
||||
onReleased,
|
||||
onError,
|
||||
|
|
@ -29,9 +34,15 @@ export function ReleaseSessionDialog({
|
|||
} = useSessions({ autoLoad: true });
|
||||
const currentSessionId = connection.sessionId;
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||
const [hasSelectedSession, setHasSelectedSession] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
// -1 = no highlight; see ResumeDialog for the rationale.
|
||||
const [cursorIdx, setCursorIdx] = useState(-1);
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const { filterValue: filterQuery, inputProps } = useFilterInput(() => {
|
||||
setCursorIdx(-1);
|
||||
setSelectedSessionId(null);
|
||||
});
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -40,9 +51,9 @@ export function ReleaseSessionDialog({
|
|||
if (sessionsError) setMessage(sessionsError.message);
|
||||
}, [sessionsError]);
|
||||
|
||||
const filtered = searchQuery
|
||||
const filtered = filterQuery
|
||||
? sessions.filter((s) => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const q = filterQuery.toLowerCase();
|
||||
return (
|
||||
(s.displayName || '').toLowerCase().includes(q) ||
|
||||
s.sessionId.toLowerCase().includes(q)
|
||||
|
|
@ -50,26 +61,49 @@ export function ReleaseSessionDialog({
|
|||
})
|
||||
: sessions;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIdx >= filtered.length && filtered.length > 0) {
|
||||
setSelectedIdx(filtered.length - 1);
|
||||
}
|
||||
}, [filtered.length, selectedIdx]);
|
||||
const confirmRow = (index: number) => {
|
||||
const session = filtered[index];
|
||||
if (!session) return;
|
||||
|
||||
const isCurrent = session.sessionId === currentSessionId;
|
||||
const isReleasable =
|
||||
(session.clientCount ?? 0) > 0 || session.hasActivePrompt === true;
|
||||
if (isCurrent || !isReleasable) return;
|
||||
|
||||
setCursorIdx(index);
|
||||
setSelectedSessionId(session.sessionId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.children[selectedIdx] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
if (cursorIdx >= filtered.length && filtered.length > 0) {
|
||||
setCursorIdx(filtered.length - 1);
|
||||
}
|
||||
}, [filtered.length, cursorIdx]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.children[cursorIdx] as HTMLElement | undefined;
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}, [selectedIdx]);
|
||||
}, [cursorIdx]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Arrows move only the roving cursor. Enter/click confirms the target row,
|
||||
// but the destructive release still stays behind the danger button.
|
||||
const { keyboardMode } = useListboxKeyboard({
|
||||
itemCount: filtered.length,
|
||||
activeIndex: cursorIdx,
|
||||
onActiveIndexChange: setCursorIdx,
|
||||
onConfirm: confirmRow,
|
||||
});
|
||||
|
||||
const handleRelease = useCallback(
|
||||
(targetSession?: DaemonSessionSummary) => {
|
||||
const session = targetSession ?? filtered[selectedIdx];
|
||||
const session =
|
||||
targetSession ??
|
||||
filtered.find((s) => s.sessionId === selectedSessionId) ??
|
||||
undefined;
|
||||
if (!session || deleting) return;
|
||||
const releasable =
|
||||
(session.clientCount ?? 0) > 0 || session.hasActivePrompt === true;
|
||||
|
|
@ -101,12 +135,13 @@ export function ReleaseSessionDialog({
|
|||
onError,
|
||||
onReleased,
|
||||
releaseSession,
|
||||
selectedIdx,
|
||||
selectedSessionId,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const selectedSession = filtered[selectedIdx];
|
||||
const selectedSession =
|
||||
filtered.find((s) => s.sessionId === selectedSessionId) ?? undefined;
|
||||
const selectedReleasable =
|
||||
selectedSession &&
|
||||
((selectedSession.clientCount ?? 0) > 0 ||
|
||||
|
|
@ -114,50 +149,62 @@ export function ReleaseSessionDialog({
|
|||
const canRelease =
|
||||
!deleting &&
|
||||
!loading &&
|
||||
hasSelectedSession &&
|
||||
!!selectedSession &&
|
||||
selectedSession.sessionId !== currentSessionId &&
|
||||
!!selectedReleasable;
|
||||
|
||||
return (
|
||||
<div className={dp('resume-picker', 'resume-picker-in-shell')}>
|
||||
<div className={dp('resume-picker-search')}>
|
||||
<span className={dp('resume-picker-search-label')}>
|
||||
<div className={dp('picker', 'picker-in-shell')}>
|
||||
<div className={dp('picker-search')}>
|
||||
<span className={dp('picker-search-label')}>
|
||||
{t('resume.search')}:{' '}
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={dp('resume-picker-search-input')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setSelectedIdx(0);
|
||||
setHasSelectedSession(false);
|
||||
}}
|
||||
className={dp('picker-search-input')}
|
||||
aria-label={t('resume.search')}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded="true"
|
||||
aria-controls={LIST_ID}
|
||||
aria-activedescendant={
|
||||
cursorIdx >= 0 && cursorIdx < filtered.length
|
||||
? optionId(cursorIdx)
|
||||
: undefined
|
||||
}
|
||||
{...inputProps}
|
||||
placeholder=""
|
||||
/>
|
||||
<span className={dp('resume-picker-search-hint')}>
|
||||
<span className={dp('picker-search-hint')}>
|
||||
{message ||
|
||||
(deleting
|
||||
? t('release.releasing')
|
||||
: loading
|
||||
? t('common.loading')
|
||||
: searchQuery
|
||||
: filterQuery
|
||||
? t('release.matches', { count: filtered.length })
|
||||
: '')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={dp('resume-picker-sep')} />
|
||||
<div className={dp('picker-sep')} />
|
||||
|
||||
<div className={dp('resume-picker-list')} ref={listRef}>
|
||||
<div
|
||||
id={LIST_ID}
|
||||
role="listbox"
|
||||
className={dp(
|
||||
'picker-list',
|
||||
keyboardMode ? 'picker-keyboard-only' : undefined,
|
||||
)}
|
||||
ref={listRef}
|
||||
>
|
||||
{loading && (
|
||||
<div className={dp('resume-picker-empty')}>{t('common.loading')}</div>
|
||||
<div className={dp('picker-empty')}>{t('common.loading')}</div>
|
||||
)}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className={dp('resume-picker-empty')}>
|
||||
{searchQuery
|
||||
? t('release.noMatch', { query: searchQuery })
|
||||
<div className={dp('picker-empty')}>
|
||||
{filterQuery
|
||||
? t('release.noMatch', { query: filterQuery })
|
||||
: t('release.none')}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -168,57 +215,37 @@ export function ReleaseSessionDialog({
|
|||
(s.clientCount ?? 0) > 0 || s.hasActivePrompt === true;
|
||||
const isDisabled = isCurrent || !isReleasable;
|
||||
return (
|
||||
<div
|
||||
<SessionRow
|
||||
key={s.sessionId}
|
||||
className={dp(
|
||||
'resume-picker-item',
|
||||
'resume-picker-session-item',
|
||||
hasSelectedSession && i === selectedIdx
|
||||
? 'selected'
|
||||
: undefined,
|
||||
isCurrent ? 'resume-picker-item-current' : undefined,
|
||||
isDisabled ? 'disabled' : undefined,
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedIdx(i);
|
||||
setHasSelectedSession(true);
|
||||
}}
|
||||
>
|
||||
<div className={dp('resume-picker-item-row')}>
|
||||
<span className={dp('resume-picker-item-title')}>
|
||||
{s.displayName || s.sessionId.slice(0, 8)}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span className={dp('resume-picker-item-badge')}>
|
||||
session={s}
|
||||
optionId={optionId(i)}
|
||||
active={i === cursorIdx}
|
||||
confirmed={s.sessionId === selectedSessionId}
|
||||
ariaSelected={s.sessionId === selectedSessionId}
|
||||
// In release/delete dialogs, "current session" is just a
|
||||
// disabled reason, not the confirmed target. Keep the stronger
|
||||
// accent bar + ✓ for the actual confirmed release target only.
|
||||
current={false}
|
||||
disabled={isDisabled}
|
||||
trailing={
|
||||
isCurrent ? (
|
||||
<span className={dp('picker-item-badge')}>
|
||||
{t('resume.current')}
|
||||
</span>
|
||||
)}
|
||||
{!isCurrent && !isReleasable && (
|
||||
<span className={dp('resume-picker-item-badge')}>
|
||||
) : !isReleasable ? (
|
||||
<span className={dp('picker-item-badge')}>
|
||||
{t('release.inactiveBadge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={dp('resume-picker-item-meta')}>
|
||||
<span>
|
||||
{(s.updatedAt || s.createdAt) &&
|
||||
formatRelativeTime(s.updatedAt || s.createdAt || '', t)}
|
||||
</span>
|
||||
<span className={dp('resume-picker-item-detail')}>
|
||||
{t('common.clients', { count: s.clientCount ?? 0 })}
|
||||
</span>
|
||||
{s.hasActivePrompt && (
|
||||
<span className={dp('resume-picker-item-detail')}>
|
||||
{t('resume.activePrompt')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => confirmRow(i)}
|
||||
onActivate={() => setCursorIdx(i)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={dp('resume-picker-sep')} />
|
||||
<div className={dp('picker-sep')} />
|
||||
<div className={dp('dialog-footer-actions')}>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { I18nProvider } from '../../i18n';
|
||||
import { dp } from './dialogStyles';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
const sessions = [
|
||||
{
|
||||
sessionId: 'alpha-id',
|
||||
displayName: 'Alpha',
|
||||
clientCount: 1,
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
sessionId: 'beta-id',
|
||||
displayName: 'Beta',
|
||||
clientCount: 1,
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
sessionId: 'me',
|
||||
displayName: 'Current Session',
|
||||
clientCount: 1,
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
|
||||
useConnection: () => ({ sessionId: 'me' }),
|
||||
useSessions: () => ({
|
||||
sessions,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { ResumeDialog } = await import('./ResumeDialog');
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
let onSelect: ReturnType<typeof vi.fn>;
|
||||
let onClose: ReturnType<typeof vi.fn>;
|
||||
|
||||
function mount() {
|
||||
onSelect = vi.fn();
|
||||
onClose = vi.fn();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(
|
||||
<I18nProvider language="en">
|
||||
<ResumeDialog onSelect={onSelect} onClose={onClose} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function rows(): HTMLElement[] {
|
||||
return Array.from(container!.querySelectorAll('[role="option"]'));
|
||||
}
|
||||
|
||||
function press(key: string) {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// React installs a value tracker on the input, so set the value through the
|
||||
// prototype's native setter for React's onChange to observe the change.
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value',
|
||||
)!.set!;
|
||||
|
||||
function typeFilter(value: string) {
|
||||
act(() => {
|
||||
const el = container!.querySelector('input')!;
|
||||
nativeInputValueSetter.call(el, value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
const isCursor = (el: HTMLElement) => el.className.includes(dp('selected'));
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('ResumeDialog', () => {
|
||||
it('opens with no highlight; Enter does not switch sessions', () => {
|
||||
mount();
|
||||
|
||||
expect(rows()).toHaveLength(3);
|
||||
expect(rows().some(isCursor)).toBe(false);
|
||||
|
||||
// A reflexive Enter in the search box must not resume anything.
|
||||
press('Enter');
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resumes the highlighted session on Enter after arrow navigation', () => {
|
||||
mount();
|
||||
|
||||
// First ArrowDown lands on row 0; a second moves to row 1.
|
||||
press('ArrowDown');
|
||||
expect(isCursor(rows()[0])).toBe(true);
|
||||
press('ArrowDown');
|
||||
expect(isCursor(rows()[1])).toBe(true);
|
||||
|
||||
press('Enter');
|
||||
expect(onSelect).toHaveBeenCalledWith('beta-id');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resets the highlight on filter edits and confirms within the filtered list', () => {
|
||||
mount();
|
||||
|
||||
// Navigate first, then type: editing the filter must clear the highlight
|
||||
// so Enter cannot confirm a row the user did not visibly re-pick.
|
||||
press('ArrowDown');
|
||||
expect(isCursor(rows()[0])).toBe(true);
|
||||
|
||||
typeFilter('beta');
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(rows().some(isCursor)).toBe(false);
|
||||
|
||||
press('Enter');
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
|
||||
// Re-navigating confirms the correct session from the filtered list.
|
||||
press('ArrowDown');
|
||||
expect(isCursor(rows()[0])).toBe(true);
|
||||
press('Enter');
|
||||
expect(onSelect).toHaveBeenCalledWith('beta-id');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,26 +2,36 @@ import { useState, useEffect, useRef } from 'react';
|
|||
import { dp } from './dialogStyles';
|
||||
import { useConnection, useSessions } from '@qwen-code/webui/daemon-react-sdk';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { formatRelativeTime } from '../../utils/formatRelativeTime';
|
||||
import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
|
||||
import { useFilterInput } from '../../hooks/useFilterInput';
|
||||
import { SessionRow } from './SessionRow';
|
||||
|
||||
interface ResumeDialogProps {
|
||||
onSelect: (sessionId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const LIST_ID = 'resume-session-list';
|
||||
const optionId = (index: number) => `${LIST_ID}-opt-${index}`;
|
||||
|
||||
export function ResumeDialog({ onSelect, onClose }: ResumeDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const connection = useConnection();
|
||||
const { sessions, loading, error } = useSessions({ autoLoad: true });
|
||||
const currentSessionId = connection.sessionId;
|
||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
// -1 = no highlight. The dialog opens with nothing highlighted and resets to
|
||||
// none on filter edits, so Enter in the search box cannot confirm a row the
|
||||
// user didn't pick — the highlight only appears once they press ↓/↑ or hover.
|
||||
const [selectedIdx, setSelectedIdx] = useState(-1);
|
||||
const { filterValue: filterQuery, inputProps } = useFilterInput(() =>
|
||||
setSelectedIdx(-1),
|
||||
);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filtered = searchQuery
|
||||
const filtered = filterQuery
|
||||
? sessions.filter((s) => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const q = filterQuery.toLowerCase();
|
||||
return (
|
||||
(s.displayName || '').toLowerCase().includes(q) ||
|
||||
s.sessionId.toLowerCase().includes(q)
|
||||
|
|
@ -44,89 +54,87 @@ export function ResumeDialog({ onSelect, onClose }: ResumeDialogProps) {
|
|||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}, [selectedIdx]);
|
||||
|
||||
const confirm = (index: number) => {
|
||||
const session = filtered[index];
|
||||
if (!session) return;
|
||||
onSelect(session.sessionId);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const { keyboardMode } = useListboxKeyboard({
|
||||
itemCount: filtered.length,
|
||||
activeIndex: selectedIdx,
|
||||
onActiveIndexChange: setSelectedIdx,
|
||||
onConfirm: confirm,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={dp('resume-picker', 'resume-picker-in-shell')}>
|
||||
<div className={dp('resume-picker-search')}>
|
||||
<span className={dp('resume-picker-search-label')}>
|
||||
<div className={dp('picker', 'picker-in-shell')}>
|
||||
<div className={dp('picker-search')}>
|
||||
<span className={dp('picker-search-label')}>
|
||||
{t('resume.search')}:{' '}
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={dp('resume-picker-search-input')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setSelectedIdx(0);
|
||||
}}
|
||||
className={dp('picker-search-input')}
|
||||
aria-label={t('resume.search')}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded="true"
|
||||
aria-controls={LIST_ID}
|
||||
aria-activedescendant={
|
||||
selectedIdx >= 0 && selectedIdx < filtered.length
|
||||
? optionId(selectedIdx)
|
||||
: undefined
|
||||
}
|
||||
{...inputProps}
|
||||
placeholder=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={dp('resume-picker-sep')} />
|
||||
<div className={dp('picker-sep')} />
|
||||
|
||||
<div className={dp('resume-picker-list')} ref={listRef}>
|
||||
<div
|
||||
id={LIST_ID}
|
||||
role="listbox"
|
||||
className={dp(
|
||||
'picker-list',
|
||||
keyboardMode ? 'picker-keyboard-only' : undefined,
|
||||
)}
|
||||
ref={listRef}
|
||||
>
|
||||
{loading && (
|
||||
<div className={dp('resume-picker-empty')}>{t('common.loading')}</div>
|
||||
<div className={dp('picker-empty')}>{t('common.loading')}</div>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<div className={dp('resume-picker-empty')}>
|
||||
<div className={dp('picker-empty')}>
|
||||
{error.message || 'Failed to load sessions'}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && filtered.length === 0 && (
|
||||
<div className={dp('resume-picker-empty')}>
|
||||
{searchQuery
|
||||
? t('resume.noMatch', { query: searchQuery })
|
||||
<div className={dp('picker-empty')}>
|
||||
{filterQuery
|
||||
? t('resume.noMatch', { query: filterQuery })
|
||||
: t('resume.none')}
|
||||
</div>
|
||||
)}
|
||||
{!loading &&
|
||||
filtered.map((s) => {
|
||||
const isCurrent = s.sessionId === currentSessionId;
|
||||
return (
|
||||
<div
|
||||
key={s.sessionId}
|
||||
className={dp(
|
||||
'resume-picker-item',
|
||||
'resume-picker-session-item',
|
||||
isCurrent ? 'resume-picker-item-current' : undefined,
|
||||
)}
|
||||
onClick={() => {
|
||||
onSelect(s.sessionId);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className={dp('resume-picker-item-row')}>
|
||||
<span className={dp('resume-picker-item-title')}>
|
||||
{s.displayName || s.sessionId.slice(0, 8)}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span className={dp('resume-picker-item-badge')}>
|
||||
{t('resume.current')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={dp('resume-picker-item-meta')}>
|
||||
<span>
|
||||
{(s.updatedAt || s.createdAt) &&
|
||||
formatRelativeTime(s.updatedAt || s.createdAt || '', t)}
|
||||
</span>
|
||||
<span className={dp('resume-picker-item-detail')}>
|
||||
{t('common.clients', { count: s.clientCount ?? 0 })}
|
||||
</span>
|
||||
{s.hasActivePrompt && (
|
||||
<span className={dp('resume-picker-item-detail')}>
|
||||
{t('resume.activePrompt')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
filtered.map((s, index) => (
|
||||
<SessionRow
|
||||
key={s.sessionId}
|
||||
session={s}
|
||||
optionId={optionId(index)}
|
||||
active={index === selectedIdx}
|
||||
current={s.sessionId === currentSessionId}
|
||||
currentLabel={t('resume.current')}
|
||||
onClick={() => confirm(index)}
|
||||
onActivate={() => setSelectedIdx(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,14 @@
|
|||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* Focused programmatically for keyboard nav / aria-activedescendant; selection
|
||||
is shown by the roving row highlight, so suppress the container outline. */
|
||||
.list:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
|
|
@ -29,12 +36,45 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Roving cursor (keyboard/hover position) — a plain gray highlight, matching
|
||||
the other list dialogs. */
|
||||
.item:hover,
|
||||
.itemSelected {
|
||||
.itemCursor {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.item:disabled {
|
||||
/* Confirmed selection: stronger than the cursor — a left accent bar plus a blue
|
||||
title, so the chosen row stays distinguishable from the row merely under the
|
||||
cursor (which only gets the gray background). */
|
||||
.itemSelected::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 60%;
|
||||
width: 3px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background: var(--agent-blue-500, #4a9eff);
|
||||
}
|
||||
|
||||
.itemSelected .prompt {
|
||||
color: var(--agent-blue-500, #4a9eff);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Keyboard mode: the pointer yields to the keyboard, so a cursor resting on a
|
||||
row must not paint the hover highlight — only the keyboard cursor row does. */
|
||||
.keyboardOnly .item:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.keyboardOnly .itemCursor:hover,
|
||||
.keyboardOnly .itemCursor {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.itemDisabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
|
@ -62,10 +102,19 @@
|
|||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 8px 0;
|
||||
}
|
||||
|
||||
/* Inline failure text: toasts can be deduplicated by the app shell, so the
|
||||
dialog must show the error itself (mirrors delete/release's message hint). */
|
||||
.footerMessage {
|
||||
margin-right: auto;
|
||||
color: var(--error-color);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dangerButton {
|
||||
border-color: var(--error-color);
|
||||
color: var(--error-color);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type {
|
||||
DaemonRewindSnapshotInfo,
|
||||
DaemonTranscriptBlock,
|
||||
} from '@qwen-code/sdk/daemon';
|
||||
import { I18nProvider } from '../../i18n';
|
||||
import { RewindDialog } from './RewindDialog';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
const blocks = [
|
||||
{ kind: 'user', text: 'first turn' },
|
||||
{ kind: 'user', text: 'second turn' },
|
||||
] as unknown as DaemonTranscriptBlock[];
|
||||
|
||||
const snapshots: DaemonRewindSnapshotInfo[] = [
|
||||
{ promptId: 'p0', turnIndex: 0, timestamp: '2026-01-01T00:00:00.000Z' },
|
||||
{ promptId: 'p1', turnIndex: 1, timestamp: '2026-01-01T00:01:00.000Z' },
|
||||
] as unknown as DaemonRewindSnapshotInfo[];
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
async function mount(rewind: (id: string) => Promise<void>) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<I18nProvider language="en">
|
||||
<RewindDialog
|
||||
blocks={blocks}
|
||||
loadSnapshots={() => Promise.resolve({ snapshots })}
|
||||
rewind={rewind}
|
||||
onError={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
// Flush the async loadSnapshots() effect.
|
||||
await act(async () => {});
|
||||
}
|
||||
|
||||
function press(key: string) {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('RewindDialog keyboard', () => {
|
||||
function rewindButton(): HTMLButtonElement {
|
||||
return Array.from(container!.querySelectorAll('button')).find((el) =>
|
||||
/rewind/i.test(el.textContent || ''),
|
||||
) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
it('does not confirm a target until Enter — the button stays disabled', async () => {
|
||||
const rewind = vi.fn().mockResolvedValue(undefined);
|
||||
await mount(rewind);
|
||||
|
||||
// Moving the cursor with arrows must not confirm anything yet.
|
||||
press('ArrowDown');
|
||||
press('ArrowUp');
|
||||
expect(rewind).not.toHaveBeenCalled();
|
||||
expect(rewindButton().disabled).toBe(true);
|
||||
|
||||
// Enter commits the cursor row; it still does not run the rewind itself.
|
||||
press('Enter');
|
||||
expect(rewind).not.toHaveBeenCalled();
|
||||
expect(rewindButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('the button rewinds the snapshot confirmed via keyboard', async () => {
|
||||
const rewind = vi.fn().mockResolvedValue(undefined);
|
||||
await mount(rewind);
|
||||
|
||||
// Move cursor to the 2nd snapshot and confirm it with Enter.
|
||||
press('ArrowDown');
|
||||
press('Enter');
|
||||
act(() => {
|
||||
rewindButton().dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
expect(rewind).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
|
||||
it('disables keyboard navigation while a rewind is in flight', async () => {
|
||||
// A rewind that never settles keeps isRewinding true.
|
||||
const rewind = vi.fn().mockReturnValue(new Promise<void>(() => {}));
|
||||
await mount(rewind);
|
||||
|
||||
const activeDescendant = () =>
|
||||
container!
|
||||
.querySelector('[role="listbox"]')!
|
||||
.getAttribute('aria-activedescendant');
|
||||
|
||||
press('ArrowDown');
|
||||
press('Enter');
|
||||
expect(activeDescendant()).toBe('rewind-snapshot-list-opt-1');
|
||||
act(() => {
|
||||
rewindButton().dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
expect(rewind).toHaveBeenCalledWith('p1');
|
||||
|
||||
// While rewinding, arrows must not move the highlight (enabled: false).
|
||||
press('ArrowUp');
|
||||
expect(activeDescendant()).toBe('rewind-snapshot-list-opt-1');
|
||||
});
|
||||
|
||||
it('shows an inline error and re-enables the button when rewind fails', async () => {
|
||||
const rewind = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
await mount(rewind);
|
||||
|
||||
press('ArrowDown');
|
||||
press('Enter');
|
||||
await act(async () => {
|
||||
rewindButton().dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
// The failure is visible in-dialog (toasts may be deduplicated upstream)
|
||||
// and the user can retry.
|
||||
expect(container!.textContent).toContain('boom');
|
||||
expect(rewindButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('pulls focus into the listbox once snapshots arrive, if parked on the panel', async () => {
|
||||
// Simulate DialogShell's fallback: nothing focusable during loading, so
|
||||
// focus sits on the dialog panel.
|
||||
const panel = document.createElement('div');
|
||||
panel.setAttribute('role', 'dialog');
|
||||
panel.tabIndex = -1;
|
||||
document.body.appendChild(panel);
|
||||
panel.focus();
|
||||
expect(document.activeElement).toBe(panel);
|
||||
|
||||
await mount(vi.fn().mockResolvedValue(undefined));
|
||||
|
||||
const listbox = container!.querySelector<HTMLElement>('[role="listbox"]');
|
||||
expect(document.activeElement).toBe(listbox);
|
||||
panel.remove();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,16 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
DaemonRewindSnapshotInfo,
|
||||
DaemonTranscriptBlock,
|
||||
} from '@qwen-code/sdk/daemon';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
|
||||
import { dp } from './dialogStyles';
|
||||
import styles from './RewindDialog.module.css';
|
||||
|
||||
const LIST_ID = 'rewind-snapshot-list';
|
||||
const optionId = (index: number) => `${LIST_ID}-opt-${index}`;
|
||||
|
||||
interface RewindDialogProps {
|
||||
blocks: readonly DaemonTranscriptBlock[];
|
||||
loadSnapshots: () => Promise<{ snapshots: DaemonRewindSnapshotInfo[] }>;
|
||||
|
|
@ -47,7 +51,15 @@ export function RewindDialog({
|
|||
const [rewindingPromptId, setRewindingPromptId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
// `cursorIdx` is the roving keyboard/hover highlight; `selectedPromptId` is
|
||||
// the confirmed target the danger button acts on. They are separate so moving
|
||||
// the highlight with the arrow keys does not change what will be rewound until
|
||||
// the user commits with Enter or a click.
|
||||
const [cursorIdx, setCursorIdx] = useState(0);
|
||||
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
|
||||
// Inline failure text. The app-level onError toast deduplicates repeats, so
|
||||
// a second identical failure would otherwise be invisible in this dialog.
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
|
@ -78,57 +90,113 @@ export function RewindDialog({
|
|||
[blocks, snapshots],
|
||||
);
|
||||
|
||||
// Keep the cursor in range as snapshots load / change.
|
||||
useEffect(() => {
|
||||
if (items.length > 0 && !selectedPromptId) {
|
||||
setSelectedPromptId(items[0]!.snapshot.promptId);
|
||||
if (cursorIdx >= items.length && items.length > 0) {
|
||||
setCursorIdx(items.length - 1);
|
||||
}
|
||||
}, [items, selectedPromptId]);
|
||||
}, [items.length, cursorIdx]);
|
||||
|
||||
const handleRewind = () => {
|
||||
const promptId = selectedPromptId;
|
||||
if (!promptId) return;
|
||||
if (rewindingPromptId) return;
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const isRewinding = rewindingPromptId !== null;
|
||||
|
||||
const handleRewind = (promptId: string | null) => {
|
||||
if (!promptId || rewindingPromptId) return;
|
||||
setRewindingPromptId(promptId);
|
||||
setMessage(null);
|
||||
rewind(promptId)
|
||||
.then(() => {
|
||||
onClose();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
onError(error);
|
||||
setMessage(
|
||||
t('rewind.failed', {
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
setRewindingPromptId(null);
|
||||
});
|
||||
};
|
||||
|
||||
// Arrows move the cursor (highlight) only; Enter/click commits the cursor row
|
||||
// as the confirmed target. The irreversible rewind stays behind the danger
|
||||
// button, consistent with the other destructive dialogs (delete / release).
|
||||
const commitRow = (index: number) => {
|
||||
const item = items[index];
|
||||
if (item) {
|
||||
setCursorIdx(index);
|
||||
setSelectedPromptId(item.snapshot.promptId);
|
||||
}
|
||||
};
|
||||
const { keyboardMode } = useListboxKeyboard({
|
||||
itemCount: items.length,
|
||||
activeIndex: cursorIdx,
|
||||
onActiveIndexChange: setCursorIdx,
|
||||
onConfirm: commitRow,
|
||||
enabled: !isRewinding,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.children[cursorIdx] as HTMLElement | undefined;
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}, [cursorIdx]);
|
||||
|
||||
// Snapshots load asynchronously: while loading, nothing in this dialog is
|
||||
// focusable, so DialogShell parks focus on the dialog panel. Once the listbox
|
||||
// mounts, pull focus into it — but only if focus is still parked on the panel
|
||||
// — so screen readers announce the active option via aria-activedescendant
|
||||
// instead of staying silent until the user tabs into the list.
|
||||
useEffect(() => {
|
||||
if (loading || items.length === 0) return;
|
||||
const active = document.activeElement;
|
||||
if (active?.getAttribute('role') === 'dialog') {
|
||||
listRef.current?.focus();
|
||||
}
|
||||
}, [loading, items.length]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={dp('resume-picker-empty')}>{t('rewind.loading')}</div>
|
||||
);
|
||||
return <div className={dp('picker-empty')}>{t('rewind.loading')}</div>;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return <div className={dp('resume-picker-empty')}>{t('rewind.empty')}</div>;
|
||||
return <div className={dp('picker-empty')}>{t('rewind.empty')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.list} role="list">
|
||||
{items.map(({ snapshot, promptText }) => {
|
||||
const selected = selectedPromptId === snapshot.promptId;
|
||||
const disabled = rewindingPromptId !== null;
|
||||
<div
|
||||
className={`${styles.list} ${keyboardMode ? styles.keyboardOnly : ''}`}
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-label={t('rewind.title')}
|
||||
tabIndex={0}
|
||||
aria-activedescendant={
|
||||
items.length > 0 ? optionId(cursorIdx) : undefined
|
||||
}
|
||||
>
|
||||
{items.map(({ snapshot, promptText }, index) => {
|
||||
const isCursor = index === cursorIdx;
|
||||
const isSelected = selectedPromptId === snapshot.promptId;
|
||||
const label =
|
||||
promptText ||
|
||||
t('rewind.promptFallback', {
|
||||
id: snapshot.promptId.slice(-8),
|
||||
});
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={snapshot.promptId}
|
||||
type="button"
|
||||
className={`${styles.item} ${
|
||||
selected ? styles.itemSelected : ''
|
||||
}`}
|
||||
disabled={disabled}
|
||||
onClick={() => setSelectedPromptId(snapshot.promptId)}
|
||||
id={optionId(index)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={isRewinding || undefined}
|
||||
className={`${styles.item} ${isCursor ? styles.itemCursor : ''} ${
|
||||
isSelected ? styles.itemSelected : ''
|
||||
} ${isRewinding ? styles.itemDisabled : ''}`}
|
||||
onClick={() => {
|
||||
if (!isRewinding) commitRow(index);
|
||||
}}
|
||||
onMouseMove={() => setCursorIdx(index)}
|
||||
>
|
||||
<div className={styles.prompt} title={label}>
|
||||
<span className={styles.turn}>#{snapshot.turnIndex + 1}</span>{' '}
|
||||
|
|
@ -137,11 +205,16 @@ export function RewindDialog({
|
|||
<div className={styles.time}>
|
||||
{formatSnapshotTime(snapshot.timestamp)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
{message && (
|
||||
<span className={styles.footerMessage} role="alert">
|
||||
{message}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={dp('dialog-inline-button')}
|
||||
|
|
@ -153,7 +226,7 @@ export function RewindDialog({
|
|||
<button
|
||||
type="button"
|
||||
className={`${dp('dialog-danger-button')} ${styles.dangerButton}`}
|
||||
onClick={handleRewind}
|
||||
onClick={() => handleRewind(selectedPromptId)}
|
||||
disabled={!selectedPromptId || rewindingPromptId !== null}
|
||||
>
|
||||
{rewindingPromptId ? t('rewind.rewinding') : t('rewind.confirm')}
|
||||
|
|
|
|||
158
packages/web-shell/client/components/dialogs/SessionRow.test.tsx
Normal file
158
packages/web-shell/client/components/dialogs/SessionRow.test.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { DaemonSessionSummary } from '@qwen-code/webui/daemon-react-sdk';
|
||||
import { I18nProvider } from '../../i18n';
|
||||
import { dp } from './dialogStyles';
|
||||
import { SessionRow } from './SessionRow';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const session = {
|
||||
sessionId: 'sess-1234abcd',
|
||||
displayName: 'My Session',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
clientCount: 2,
|
||||
hasActivePrompt: true,
|
||||
} as unknown as DaemonSessionSummary;
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
function mount(node: React.ReactNode) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(<I18nProvider language="en">{node}</I18nProvider>);
|
||||
});
|
||||
}
|
||||
|
||||
function row(): HTMLElement {
|
||||
return container!.querySelector('[role="option"]')!;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('SessionRow', () => {
|
||||
it('renders an option with the title and metadata', () => {
|
||||
mount(
|
||||
<SessionRow
|
||||
session={session}
|
||||
active={false}
|
||||
current={false}
|
||||
onClick={vi.fn()}
|
||||
onActivate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(row().getAttribute('role')).toBe('option');
|
||||
expect(row().textContent).toContain('My Session');
|
||||
// clientCount surfaces in the meta line.
|
||||
expect(row().textContent).toContain('2');
|
||||
});
|
||||
|
||||
it('defaults aria-selected to `current` (not the roving highlight), explicit value wins', () => {
|
||||
// The roving highlight must NOT be announced as "selected" — per WAI-ARIA
|
||||
// it is conveyed by aria-activedescendant, while aria-selected marks the
|
||||
// chosen value (here: the current session, also exposed via aria-current).
|
||||
mount(
|
||||
<SessionRow
|
||||
session={session}
|
||||
active={true}
|
||||
current={false}
|
||||
onClick={vi.fn()}
|
||||
onActivate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(row().getAttribute('aria-selected')).toBe('false');
|
||||
expect(row().getAttribute('aria-current')).toBeNull();
|
||||
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
mount(
|
||||
<SessionRow
|
||||
session={session}
|
||||
active={false}
|
||||
current={true}
|
||||
onClick={vi.fn()}
|
||||
onActivate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(row().getAttribute('aria-selected')).toBe('true');
|
||||
expect(row().getAttribute('aria-current')).toBe('true');
|
||||
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
// Multi-select: an explicit ariaSelected (checked state) wins over current.
|
||||
mount(
|
||||
<SessionRow
|
||||
session={session}
|
||||
active={true}
|
||||
ariaSelected={false}
|
||||
current={true}
|
||||
onClick={vi.fn()}
|
||||
onActivate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(row().getAttribute('aria-selected')).toBe('false');
|
||||
});
|
||||
|
||||
it('marks the current session and exposes the label as a tooltip', () => {
|
||||
mount(
|
||||
<SessionRow
|
||||
session={session}
|
||||
active={false}
|
||||
current={true}
|
||||
currentLabel="current"
|
||||
onClick={vi.fn()}
|
||||
onActivate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(row().className).toContain(dp('dialog-current'));
|
||||
expect(row().getAttribute('title')).toBe('current');
|
||||
});
|
||||
|
||||
it('renders leading and trailing slots', () => {
|
||||
mount(
|
||||
<SessionRow
|
||||
session={session}
|
||||
active={false}
|
||||
current={false}
|
||||
leading={<span data-testid="lead">[x]</span>}
|
||||
trailing={<span data-testid="trail">inactive</span>}
|
||||
onClick={vi.fn()}
|
||||
onActivate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(container!.querySelector('[data-testid="lead"]')).not.toBeNull();
|
||||
expect(container!.querySelector('[data-testid="trail"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('fires onClick and onActivate', () => {
|
||||
const onClick = vi.fn();
|
||||
const onActivate = vi.fn();
|
||||
mount(
|
||||
<SessionRow
|
||||
session={session}
|
||||
active={false}
|
||||
current={false}
|
||||
onClick={onClick}
|
||||
onActivate={onActivate}
|
||||
/>,
|
||||
);
|
||||
act(() => {
|
||||
row().dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
act(() => {
|
||||
row().dispatchEvent(new MouseEvent('mousemove', { bubbles: true }));
|
||||
});
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
expect(onActivate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
104
packages/web-shell/client/components/dialogs/SessionRow.tsx
Normal file
104
packages/web-shell/client/components/dialogs/SessionRow.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { type ReactNode } from 'react';
|
||||
import { type DaemonSessionSummary } from '@qwen-code/webui/daemon-react-sdk';
|
||||
import { dp } from './dialogStyles';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { formatRelativeTime } from '../../utils/formatRelativeTime';
|
||||
|
||||
interface SessionRowProps {
|
||||
session: DaemonSessionSummary;
|
||||
/** Roving keyboard/hover highlight. */
|
||||
active: boolean;
|
||||
/** The user's current session — marks it with the accent bar + ✓. */
|
||||
current: boolean;
|
||||
/** Confirmed target for a destructive action (release), distinct from cursor. */
|
||||
confirmed?: boolean;
|
||||
/** Non-actionable row (e.g. the current session, or an inactive one). */
|
||||
disabled?: boolean;
|
||||
/** Tooltip shown when `current` (the pseudo-element ✓ can't carry text). */
|
||||
currentLabel?: string;
|
||||
/** Stable id so the listbox can point `aria-activedescendant` at this row. */
|
||||
optionId?: string;
|
||||
/**
|
||||
* `aria-selected` value. Per WAI-ARIA this marks the chosen value, not the
|
||||
* roving highlight (which `aria-activedescendant` conveys) — so it defaults
|
||||
* to `current`. Multi-select (delete) passes the checked state and release
|
||||
* passes its confirmed target instead.
|
||||
*/
|
||||
ariaSelected?: boolean;
|
||||
/** Leading slot, e.g. a multi-select checkbox. */
|
||||
leading?: ReactNode;
|
||||
/** Trailing slot in the title row, e.g. a status badge. */
|
||||
trailing?: ReactNode;
|
||||
onClick: () => void;
|
||||
/**
|
||||
* Pointer moved over the row (real movement — see useListboxKeyboard). This
|
||||
* updates the roving cursor only; callers that separate cursor from confirmed
|
||||
* target (e.g. release/rewind) still keep the destructive action behind an
|
||||
* explicit Enter/click + button flow.
|
||||
*/
|
||||
onActivate?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A session list row shared by the resume / delete / release dialogs. Owns the
|
||||
* common shell (roving highlight, current marker, disabled state) and the
|
||||
* identical metadata line (relative time · client count · active prompt);
|
||||
* per-dialog affordances go through the `leading`/`trailing` slots.
|
||||
*/
|
||||
export function SessionRow({
|
||||
session,
|
||||
active,
|
||||
current,
|
||||
confirmed,
|
||||
disabled,
|
||||
currentLabel,
|
||||
optionId,
|
||||
ariaSelected,
|
||||
leading,
|
||||
trailing,
|
||||
onClick,
|
||||
onActivate,
|
||||
}: SessionRowProps) {
|
||||
const { t } = useI18n();
|
||||
const timestamp = session.updatedAt || session.createdAt;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={optionId}
|
||||
role="option"
|
||||
aria-selected={ariaSelected ?? current}
|
||||
aria-current={current ? 'true' : undefined}
|
||||
aria-disabled={disabled || undefined}
|
||||
className={dp(
|
||||
'picker-item',
|
||||
'picker-session-item',
|
||||
active ? 'selected' : undefined,
|
||||
current ? 'dialog-current' : undefined,
|
||||
confirmed ? 'picker-item-confirmed' : undefined,
|
||||
disabled ? 'disabled' : undefined,
|
||||
)}
|
||||
title={current ? currentLabel : undefined}
|
||||
onClick={onClick}
|
||||
onMouseMove={onActivate}
|
||||
>
|
||||
<div className={dp('picker-item-row')}>
|
||||
{leading}
|
||||
<span className={dp('picker-item-title')}>
|
||||
{session.displayName || session.sessionId.slice(0, 8)}
|
||||
</span>
|
||||
{trailing}
|
||||
</div>
|
||||
<div className={dp('picker-item-meta')}>
|
||||
<span>{timestamp && formatRelativeTime(timestamp, t)}</span>
|
||||
<span className={dp('picker-item-detail')}>
|
||||
{t('common.clients', { count: session.clientCount ?? 0 })}
|
||||
</span>
|
||||
{session.hasActivePrompt && (
|
||||
<span className={dp('picker-item-detail')}>
|
||||
{t('resume.activePrompt')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { I18nProvider } from '../../i18n';
|
||||
import { WebShellThemeId } from '../../themeContext';
|
||||
import { ThemeDialog } from './ThemeDialog';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
// jsdom has no layout — stub the scroll-into-view the list uses on selection.
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
function mount(node: React.ReactNode) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(<I18nProvider language="en">{node}</I18nProvider>);
|
||||
});
|
||||
}
|
||||
|
||||
function press(key: string) {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function listbox(): HTMLElement {
|
||||
return container!.querySelector('[role="listbox"]')!;
|
||||
}
|
||||
|
||||
/** The option the listbox currently points `aria-activedescendant` at. */
|
||||
function activeOption(): HTMLElement | null {
|
||||
const id = listbox().getAttribute('aria-activedescendant');
|
||||
return id ? document.getElementById(id) : null;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('ThemeDialog aria-activedescendant coupling', () => {
|
||||
it('points aria-activedescendant at an existing option, tracking arrow nav', () => {
|
||||
mount(
|
||||
<ThemeDialog
|
||||
currentTheme={WebShellThemeId.Dark}
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Opens on the current theme; the referenced id must resolve to a real
|
||||
// option element (guards the hand-built `theme-opt-<i>` id scheme).
|
||||
const initial = activeOption();
|
||||
expect(initial).not.toBeNull();
|
||||
expect(initial!.getAttribute('role')).toBe('option');
|
||||
expect(initial!.getAttribute('aria-selected')).toBe('true');
|
||||
|
||||
// Arrowing moves the reference, and it still resolves to a real option.
|
||||
press('ArrowDown');
|
||||
const next = activeOption();
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getAttribute('role')).toBe('option');
|
||||
expect(next).not.toBe(initial);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { dp } from './dialogStyles';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
|
||||
import { WEB_SHELL_THEMES, type WebShellTheme } from '../../themeContext';
|
||||
|
||||
interface ThemeDialogProps {
|
||||
|
|
@ -26,6 +27,20 @@ export function ThemeDialog({
|
|||
});
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const confirm = (index: number) => {
|
||||
const theme = themes[index];
|
||||
if (!theme) return;
|
||||
onSelect(theme.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const { keyboardMode } = useListboxKeyboard({
|
||||
itemCount: themes.length,
|
||||
activeIndex: selectedIdx,
|
||||
onActiveIndexChange: setSelectedIdx,
|
||||
onConfirm: confirm,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.children[selectedIdx] as
|
||||
| HTMLElement
|
||||
|
|
@ -35,41 +50,41 @@ export function ThemeDialog({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={dp('resume-picker-list', 'resume-picker-list-compact')}
|
||||
className={dp(
|
||||
'picker-list',
|
||||
'picker-list-compact',
|
||||
keyboardMode ? 'picker-keyboard-only' : undefined,
|
||||
)}
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-label={t('theme.title')}
|
||||
tabIndex={0}
|
||||
aria-activedescendant={
|
||||
themes.length > 0 ? `theme-opt-${selectedIdx}` : undefined
|
||||
}
|
||||
>
|
||||
{themes.map((theme, index) => {
|
||||
const selected = theme.id === currentTheme;
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={theme.id}
|
||||
type="button"
|
||||
id={`theme-opt-${index}`}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className={dp(
|
||||
'resume-picker-item',
|
||||
'resume-picker-session-item',
|
||||
index === selectedIdx || selected ? 'selected' : undefined,
|
||||
'picker-item',
|
||||
'picker-session-item',
|
||||
index === selectedIdx ? 'selected' : undefined,
|
||||
selected ? 'dialog-current' : undefined,
|
||||
)}
|
||||
onClick={() => {
|
||||
onSelect(theme.id);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIdx(index)}
|
||||
onClick={() => confirm(index)}
|
||||
onMouseMove={() => setSelectedIdx(index)}
|
||||
>
|
||||
<div className={dp('resume-picker-item-row')}>
|
||||
<span className={dp('resume-picker-item-title')}>
|
||||
{theme.label}
|
||||
</span>
|
||||
{selected && (
|
||||
<span className={dp('resume-picker-item-check')}> ✓</span>
|
||||
)}
|
||||
<div className={dp('picker-item-row')}>
|
||||
<span className={dp('picker-item-title')}>{theme.label}</span>
|
||||
</div>
|
||||
<div className={dp('resume-picker-item-meta')}>
|
||||
{theme.description}
|
||||
</div>
|
||||
</button>
|
||||
<div className={dp('picker-item-meta')}>{theme.description}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { I18nProvider } from '../../i18n';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
const tools = [
|
||||
{ name: 'tool-a', displayName: 'Tool A', enabled: true, description: 'A' },
|
||||
{ name: 'tool-b', displayName: 'Tool B', enabled: false },
|
||||
];
|
||||
|
||||
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
|
||||
useTools: () => ({
|
||||
status: { errors: [] },
|
||||
tools,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { ToolsDialog } = await import('./ToolsDialog');
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
function mount() {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(
|
||||
<I18nProvider language="en">
|
||||
<ToolsDialog />
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function press(key: string) {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('ToolsDialog a11y wiring', () => {
|
||||
it('exposes a listbox with option ids, aria-activedescendant and aria-expanded', () => {
|
||||
mount();
|
||||
|
||||
const list = container!.querySelector<HTMLElement>('[role="listbox"]');
|
||||
expect(list).not.toBeNull();
|
||||
expect(list!.getAttribute('aria-activedescendant')).toBe(
|
||||
'tools-list-opt-0',
|
||||
);
|
||||
|
||||
const options = Array.from(
|
||||
container!.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
);
|
||||
expect(options).toHaveLength(2);
|
||||
expect(options[0]!.id).toBe('tools-list-opt-0');
|
||||
expect(options[0]!.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(options[1]!.getAttribute('aria-expanded')).toBeNull();
|
||||
|
||||
press('ArrowDown');
|
||||
expect(list!.getAttribute('aria-activedescendant')).toBe(
|
||||
'tools-list-opt-1',
|
||||
);
|
||||
|
||||
// Move back and expand the first tool via Enter.
|
||||
press('ArrowUp');
|
||||
press('Enter');
|
||||
expect(options[0]!.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
|
@ -5,11 +5,15 @@ import {
|
|||
type DaemonWorkspaceToolStatus,
|
||||
} from '@qwen-code/webui/daemon-react-sdk';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
|
||||
|
||||
function toolLabel(tool: DaemonWorkspaceToolStatus): string {
|
||||
return tool.displayName || tool.name;
|
||||
}
|
||||
|
||||
const LIST_ID = 'tools-list';
|
||||
const optionId = (index: number) => `${LIST_ID}-opt-${index}`;
|
||||
|
||||
export function ToolsDialog() {
|
||||
const { t } = useI18n();
|
||||
const { status, tools, loading, error } = useTools({
|
||||
|
|
@ -47,6 +51,16 @@ export function ToolsDialog() {
|
|||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}, [selectedIdx]);
|
||||
|
||||
const { keyboardMode } = useListboxKeyboard({
|
||||
itemCount: tools.length,
|
||||
activeIndex: selectedIdx,
|
||||
onActiveIndexChange: setSelectedIdx,
|
||||
onConfirm: (index) => {
|
||||
const tool = tools[index];
|
||||
if (tool?.description) toggleDetails(tool);
|
||||
},
|
||||
});
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!status) return '';
|
||||
const enabled = tools.filter((tool) => tool.enabled).length;
|
||||
|
|
@ -54,25 +68,38 @@ export function ToolsDialog() {
|
|||
}, [status, tools, t]);
|
||||
|
||||
return (
|
||||
<div className={dp('resume-picker', 'resume-picker-in-shell')}>
|
||||
<div className={dp('picker', 'picker-in-shell')}>
|
||||
{summary && (
|
||||
<div className={dp('resume-picker-search')}>
|
||||
<span className={dp('resume-picker-search-hint')}>{summary}</span>
|
||||
<div className={dp('picker-search')}>
|
||||
<span className={dp('picker-search-hint')}>{summary}</span>
|
||||
</div>
|
||||
)}
|
||||
{(message || loading) && (
|
||||
<div className={dp('resume-picker-search')}>
|
||||
<span className={dp('resume-picker-search-hint')}>
|
||||
<div className={dp('picker-search')}>
|
||||
<span className={dp('picker-search-hint')}>
|
||||
{message || t('tools.loading')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={dp('resume-picker-sep')} />
|
||||
<div className={dp('picker-sep')} />
|
||||
|
||||
<div className={dp('resume-picker-list')} ref={listRef}>
|
||||
<div
|
||||
id={LIST_ID}
|
||||
role="listbox"
|
||||
aria-label={t('tools.title')}
|
||||
tabIndex={0}
|
||||
aria-activedescendant={
|
||||
tools.length > 0 ? optionId(selectedIdx) : undefined
|
||||
}
|
||||
className={dp(
|
||||
'picker-list',
|
||||
keyboardMode ? 'picker-keyboard-only' : undefined,
|
||||
)}
|
||||
ref={listRef}
|
||||
>
|
||||
{!loading && tools.length === 0 && (
|
||||
<div className={dp('resume-picker-empty')}>{t('tools.empty')}</div>
|
||||
<div className={dp('picker-empty')}>{t('tools.empty')}</div>
|
||||
)}
|
||||
{tools.map((tool, i) => {
|
||||
const expanded = expandedTools.has(tool.name);
|
||||
|
|
@ -80,21 +107,29 @@ export function ToolsDialog() {
|
|||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
id={optionId(i)}
|
||||
role="option"
|
||||
// Informational list — rows are expanded, never "chosen", so no
|
||||
// row is ever aria-selected; the roving highlight is conveyed by
|
||||
// aria-activedescendant on the listbox.
|
||||
aria-selected={false}
|
||||
aria-expanded={desc ? expanded : undefined}
|
||||
className={dp(
|
||||
'resume-picker-item',
|
||||
'resume-picker-session-item',
|
||||
'picker-item',
|
||||
'picker-session-item',
|
||||
'tools-picker-item',
|
||||
expanded ? 'selected' : undefined,
|
||||
i === selectedIdx ? 'selected' : undefined,
|
||||
expanded ? 'tools-picker-item-expanded' : undefined,
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedIdx(i);
|
||||
if (tool.description) toggleDetails(tool);
|
||||
}}
|
||||
onMouseMove={() => setSelectedIdx(i)}
|
||||
>
|
||||
<div className={dp('resume-picker-item-row')}>
|
||||
<div className={dp('picker-item-row')}>
|
||||
<span className={dp('tools-item-icon')} aria-hidden="true" />
|
||||
<span className={dp('resume-picker-item-title')}>
|
||||
<span className={dp('picker-item-title')}>
|
||||
{toolLabel(tool)}
|
||||
</span>
|
||||
<span
|
||||
|
|
|
|||
138
packages/web-shell/client/hooks/useFilterInput.test.tsx
Normal file
138
packages/web-shell/client/hooks/useFilterInput.test.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { useFilterInput, type UseFilterInputResult } from './useFilterInput';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
let latest: UseFilterInputResult | null = null;
|
||||
|
||||
function Harness({
|
||||
onFilterChange,
|
||||
}: {
|
||||
onFilterChange?: (value: string) => void;
|
||||
}) {
|
||||
const result = useFilterInput(onFilterChange);
|
||||
latest = result;
|
||||
return <input {...result.inputProps} />;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
function mount(onFilterChange?: (value: string) => void) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(<Harness onFilterChange={onFilterChange} />);
|
||||
});
|
||||
}
|
||||
|
||||
function input(): HTMLInputElement {
|
||||
return container!.querySelector('input')!;
|
||||
}
|
||||
|
||||
// React installs a value tracker on the input element, so assigning `.value`
|
||||
// directly is ignored by its onChange. Use the prototype's native setter so the
|
||||
// tracker observes the change and React fires onChange.
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value',
|
||||
)!.set!;
|
||||
|
||||
function type(value: string) {
|
||||
act(() => {
|
||||
const el = input();
|
||||
nativeInputValueSetter.call(el, value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
latest = null;
|
||||
});
|
||||
|
||||
describe('useFilterInput', () => {
|
||||
it('commits the filter value on plain (non-composition) input', () => {
|
||||
const onFilterChange = vi.fn();
|
||||
mount(onFilterChange);
|
||||
|
||||
type('abc');
|
||||
expect(input().value).toBe('abc');
|
||||
expect(latest!.filterValue).toBe('abc');
|
||||
expect(onFilterChange).toHaveBeenLastCalledWith('abc');
|
||||
});
|
||||
|
||||
it('holds the filter value steady during IME composition, committing on end', () => {
|
||||
const onFilterChange = vi.fn();
|
||||
mount(onFilterChange);
|
||||
|
||||
act(() => {
|
||||
input().dispatchEvent(
|
||||
new CompositionEvent('compositionstart', { bubbles: true }),
|
||||
);
|
||||
});
|
||||
|
||||
// Intermediate pinyin keystrokes update the visible value but not the filter.
|
||||
type('ni');
|
||||
type('nih');
|
||||
expect(input().value).toBe('nih');
|
||||
expect(latest!.filterValue).toBe('');
|
||||
expect(onFilterChange).not.toHaveBeenCalled();
|
||||
|
||||
// Committing the composition applies the final value once.
|
||||
act(() => {
|
||||
const el = input();
|
||||
nativeInputValueSetter.call(el, '你好');
|
||||
el.dispatchEvent(
|
||||
new CompositionEvent('compositionend', { bubbles: true, data: '你好' }),
|
||||
);
|
||||
});
|
||||
expect(latest!.filterValue).toBe('你好');
|
||||
expect(onFilterChange).toHaveBeenLastCalledWith('你好');
|
||||
// No `input` event followed the compositionend here, so this proves the
|
||||
// controlled value is synced by compositionend itself — the field must
|
||||
// never display a stale preedit while the list filters by the new value.
|
||||
expect(input().value).toBe('你好');
|
||||
});
|
||||
|
||||
it('ignores a cancelled composition that leaves the value unchanged', () => {
|
||||
const onFilterChange = vi.fn();
|
||||
mount(onFilterChange);
|
||||
|
||||
type('abc');
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Start a composition, type an intermediate pinyin char, then cancel it
|
||||
// (e.g. Esc): the visible value returns to 'abc' and compositionend fires.
|
||||
act(() => {
|
||||
input().dispatchEvent(
|
||||
new CompositionEvent('compositionstart', { bubbles: true }),
|
||||
);
|
||||
});
|
||||
type('abcn');
|
||||
act(() => {
|
||||
const el = input();
|
||||
nativeInputValueSetter.call(el, 'abc');
|
||||
el.dispatchEvent(
|
||||
new CompositionEvent('compositionend', { bubbles: true, data: '' }),
|
||||
);
|
||||
});
|
||||
|
||||
// The committed filter never changed, so consumers must not be notified —
|
||||
// a notification would reset dialog selection state (e.g. delete checks).
|
||||
expect(latest!.filterValue).toBe('abc');
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A real edit afterwards still commits normally.
|
||||
type('abcd');
|
||||
expect(latest!.filterValue).toBe('abcd');
|
||||
expect(onFilterChange).toHaveBeenLastCalledWith('abcd');
|
||||
});
|
||||
});
|
||||
95
packages/web-shell/client/hooks/useFilterInput.ts
Normal file
95
packages/web-shell/client/hooks/useFilterInput.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type CompositionEvent,
|
||||
} from 'react';
|
||||
|
||||
export interface FilterInputProps {
|
||||
value: string;
|
||||
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onCompositionStart: () => void;
|
||||
onCompositionEnd: (event: CompositionEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export interface UseFilterInputResult {
|
||||
/**
|
||||
* Committed value to filter by. Only changes once a keystroke is committed,
|
||||
* never while an IME composition is in flight, so filtering a list by this
|
||||
* value does not refilter/relayout on every intermediate pinyin character.
|
||||
*/
|
||||
filterValue: string;
|
||||
/** Spread onto the search `<input>` (carries the raw value + IME handlers). */
|
||||
inputProps: FilterInputProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search-field state for filterable list dialogs, hardened against IME
|
||||
* composition jitter: the field reflects every keystroke while the committed
|
||||
* `filterValue` (used to filter) updates only on non-composition input and on
|
||||
* `compositionend`.
|
||||
*
|
||||
* `onFilterChange` fires with the committed value whenever it changes — dialogs
|
||||
* use it to reset their own selection/cursor state.
|
||||
*/
|
||||
export function useFilterInput(
|
||||
onFilterChange?: (value: string) => void,
|
||||
): UseFilterInputResult {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [filterValue, setFilterValue] = useState('');
|
||||
const composingRef = useRef(false);
|
||||
const committedRef = useRef('');
|
||||
const onFilterChangeRef = useRef(onFilterChange);
|
||||
onFilterChangeRef.current = onFilterChange;
|
||||
|
||||
const commit = useCallback((value: string) => {
|
||||
// `compositionend` fires even when the composition was cancelled and the
|
||||
// text is back to what it was. A no-op commit must not signal a filter
|
||||
// change — dialogs reset their selection/cursor state on that signal, so
|
||||
// a cancelled composition would wipe e.g. the delete dialog's checkmarks.
|
||||
if (value === committedRef.current) return;
|
||||
committedRef.current = value;
|
||||
setFilterValue(value);
|
||||
onFilterChangeRef.current?.(value);
|
||||
}, []);
|
||||
|
||||
const onChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value;
|
||||
setInputValue(value);
|
||||
// Skip filtering while an IME composition is in flight; compositionend
|
||||
// commits the final value.
|
||||
if (!composingRef.current) commit(value);
|
||||
},
|
||||
[commit],
|
||||
);
|
||||
|
||||
const onCompositionStart = useCallback(() => {
|
||||
composingRef.current = true;
|
||||
}, []);
|
||||
|
||||
const onCompositionEnd = useCallback(
|
||||
(event: CompositionEvent<HTMLInputElement>) => {
|
||||
composingRef.current = false;
|
||||
const value = event.currentTarget.value;
|
||||
// Most browsers follow compositionend with an `input` event that syncs
|
||||
// the controlled value, but that's not guaranteed — sync it here too so
|
||||
// the field can never display a stale preedit while the list already
|
||||
// filters by the committed text.
|
||||
setInputValue(value);
|
||||
commit(value);
|
||||
},
|
||||
[commit],
|
||||
);
|
||||
|
||||
return {
|
||||
filterValue,
|
||||
inputProps: {
|
||||
value: inputValue,
|
||||
onChange,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
},
|
||||
};
|
||||
}
|
||||
406
packages/web-shell/client/hooks/useListboxKeyboard.test.tsx
Normal file
406
packages/web-shell/client/hooks/useListboxKeyboard.test.tsx
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { DialogShell } from '../components/dialogs/DialogShell';
|
||||
import { I18nProvider } from '../i18n';
|
||||
import { ThemeProvider } from '../themeContext';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { useListboxKeyboard } from './useListboxKeyboard';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
interface HarnessProps {
|
||||
itemCount: number;
|
||||
activeIndex: number;
|
||||
onActiveIndexChange: (index: number) => void;
|
||||
onConfirm: (index: number) => void;
|
||||
enabled?: boolean;
|
||||
onKeyboardMode?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function Harness({ onKeyboardMode, ...props }: HarnessProps) {
|
||||
const { keyboardMode } = useListboxKeyboard(props);
|
||||
onKeyboardMode?.(keyboardMode);
|
||||
return null;
|
||||
}
|
||||
|
||||
function ScopedHarness({
|
||||
name,
|
||||
onKeyboardMode,
|
||||
...props
|
||||
}: HarnessProps & { name: string }) {
|
||||
const { keyboardMode } = useListboxKeyboard(props);
|
||||
onKeyboardMode?.(keyboardMode);
|
||||
return (
|
||||
<div data-scope={name} tabIndex={-1}>
|
||||
{name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
function mount(props: HarnessProps) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(<Harness {...props} />);
|
||||
});
|
||||
}
|
||||
|
||||
function mountNode(node: React.ReactNode) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(node);
|
||||
});
|
||||
}
|
||||
|
||||
function press(key: string) {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('useListboxKeyboard', () => {
|
||||
it('moves the active index down and up within bounds', () => {
|
||||
const onActiveIndexChange = vi.fn();
|
||||
mount({
|
||||
itemCount: 3,
|
||||
activeIndex: 1,
|
||||
onActiveIndexChange,
|
||||
onConfirm: vi.fn(),
|
||||
});
|
||||
|
||||
press('ArrowDown');
|
||||
expect(onActiveIndexChange).toHaveBeenLastCalledWith(2);
|
||||
|
||||
press('ArrowUp');
|
||||
expect(onActiveIndexChange).toHaveBeenLastCalledWith(0);
|
||||
});
|
||||
|
||||
it('clamps at the edges', () => {
|
||||
const onActiveIndexChange = vi.fn();
|
||||
mount({
|
||||
itemCount: 3,
|
||||
activeIndex: 2,
|
||||
onActiveIndexChange,
|
||||
onConfirm: vi.fn(),
|
||||
});
|
||||
|
||||
press('ArrowDown');
|
||||
expect(onActiveIndexChange).toHaveBeenLastCalledWith(2);
|
||||
});
|
||||
|
||||
it('jumps to first/last with Home/End', () => {
|
||||
const onActiveIndexChange = vi.fn();
|
||||
mount({
|
||||
itemCount: 5,
|
||||
activeIndex: 2,
|
||||
onActiveIndexChange,
|
||||
onConfirm: vi.fn(),
|
||||
});
|
||||
|
||||
press('End');
|
||||
expect(onActiveIndexChange).toHaveBeenLastCalledWith(4);
|
||||
press('Home');
|
||||
expect(onActiveIndexChange).toHaveBeenLastCalledWith(0);
|
||||
});
|
||||
|
||||
it('yields Home/End to a focused text input for caret movement', () => {
|
||||
const onActiveIndexChange = vi.fn();
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
mount({
|
||||
itemCount: 5,
|
||||
activeIndex: 2,
|
||||
onActiveIndexChange,
|
||||
onConfirm: vi.fn(),
|
||||
});
|
||||
|
||||
press('Home');
|
||||
press('End');
|
||||
// The input keeps Home/End for its caret; the list is not moved.
|
||||
expect(onActiveIndexChange).not.toHaveBeenCalled();
|
||||
// Arrows still navigate the list even from within the input (combobox nav).
|
||||
press('ArrowDown');
|
||||
expect(onActiveIndexChange).toHaveBeenLastCalledWith(3);
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('ignores modified arrow keys (e.g. macOS Cmd+↑/↓ text navigation)', () => {
|
||||
const onActiveIndexChange = vi.fn();
|
||||
mount({
|
||||
itemCount: 5,
|
||||
activeIndex: 2,
|
||||
onActiveIndexChange,
|
||||
onConfirm: vi.fn(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', metaKey: true }),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowUp', shiftKey: true }),
|
||||
);
|
||||
});
|
||||
expect(onActiveIndexChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('confirms the active index on Enter', () => {
|
||||
const onConfirm = vi.fn();
|
||||
mount({
|
||||
itemCount: 3,
|
||||
activeIndex: 1,
|
||||
onActiveIndexChange: vi.fn(),
|
||||
onConfirm,
|
||||
});
|
||||
|
||||
press('Enter');
|
||||
expect(onConfirm).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('yields Enter to a focused button so it activates instead of confirming', () => {
|
||||
const onConfirm = vi.fn();
|
||||
const button = document.createElement('button');
|
||||
document.body.appendChild(button);
|
||||
button.focus();
|
||||
mount({
|
||||
itemCount: 3,
|
||||
activeIndex: 1,
|
||||
onActiveIndexChange: vi.fn(),
|
||||
onConfirm,
|
||||
});
|
||||
|
||||
press('Enter');
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
button.remove();
|
||||
});
|
||||
|
||||
it('does not confirm on Enter when no row is highlighted (activeIndex -1)', () => {
|
||||
const onActiveIndexChange = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
mount({
|
||||
itemCount: 3,
|
||||
activeIndex: -1,
|
||||
onActiveIndexChange,
|
||||
onConfirm,
|
||||
});
|
||||
|
||||
// Searchable dialogs open with no highlight: Enter/Space have nothing to
|
||||
// act on.
|
||||
press('Enter');
|
||||
press(' ');
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
|
||||
// The first ArrowDown lands on the first row, not the second.
|
||||
press('ArrowDown');
|
||||
expect(onActiveIndexChange).toHaveBeenLastCalledWith(0);
|
||||
});
|
||||
|
||||
it('ignores keys during IME composition, including the WebKit Enter commit', () => {
|
||||
const onActiveIndexChange = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
mount({
|
||||
itemCount: 3,
|
||||
activeIndex: 1,
|
||||
onActiveIndexChange,
|
||||
onConfirm,
|
||||
});
|
||||
|
||||
// Mid-composition keys (Chrome/Firefox report isComposing) do nothing.
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'ArrowDown',
|
||||
isComposing: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
isComposing: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(onActiveIndexChange).not.toHaveBeenCalled();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
|
||||
// WebKit fires compositionend BEFORE the committing Enter's keydown, so it
|
||||
// arrives with isComposing false — only keyCode 229 marks it as an IME key.
|
||||
act(() => {
|
||||
const imeEnter = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
cancelable: true,
|
||||
});
|
||||
Object.defineProperty(imeEnter, 'keyCode', { value: 229 });
|
||||
window.dispatchEvent(imeEnter);
|
||||
});
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
|
||||
// A genuine Enter afterwards still confirms.
|
||||
press('Enter');
|
||||
expect(onConfirm).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('confirms the active row on Space, mirroring Enter', () => {
|
||||
const onConfirm = vi.fn();
|
||||
mount({
|
||||
itemCount: 3,
|
||||
activeIndex: 1,
|
||||
onActiveIndexChange: vi.fn(),
|
||||
onConfirm,
|
||||
});
|
||||
|
||||
press(' ');
|
||||
expect(onConfirm).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('yields Space to a focused text input, where it types a space', () => {
|
||||
const onConfirm = vi.fn();
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
mount({
|
||||
itemCount: 3,
|
||||
activeIndex: 1,
|
||||
onActiveIndexChange: vi.fn(),
|
||||
onConfirm,
|
||||
});
|
||||
|
||||
press(' ');
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('confirms from a focused search combobox once a row is highlighted', () => {
|
||||
const onConfirm = vi.fn();
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.setAttribute('role', 'combobox');
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
mount({
|
||||
itemCount: 3,
|
||||
activeIndex: 1,
|
||||
onActiveIndexChange: vi.fn(),
|
||||
onConfirm,
|
||||
});
|
||||
|
||||
// Focus never leaves the filter input in searchable dialogs, so Enter
|
||||
// from it must confirm the visibly highlighted row.
|
||||
press('Enter');
|
||||
expect(onConfirm).toHaveBeenCalledWith(1);
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('does nothing when disabled or empty', () => {
|
||||
const onActiveIndexChange = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
mount({
|
||||
itemCount: 0,
|
||||
activeIndex: 0,
|
||||
onActiveIndexChange,
|
||||
onConfirm,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
press('ArrowDown');
|
||||
press('Enter');
|
||||
expect(onActiveIndexChange).not.toHaveBeenCalled();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when disabled', () => {
|
||||
const onActiveIndexChange = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
mount({
|
||||
itemCount: 3,
|
||||
activeIndex: 1,
|
||||
onActiveIndexChange,
|
||||
onConfirm,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
press('ArrowDown');
|
||||
press('Enter');
|
||||
expect(onActiveIndexChange).not.toHaveBeenCalled();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enters keyboard mode on arrow nav and exits on real mouse movement', () => {
|
||||
let mode = false;
|
||||
mount({
|
||||
itemCount: 3,
|
||||
activeIndex: 0,
|
||||
onActiveIndexChange: vi.fn(),
|
||||
onConfirm: vi.fn(),
|
||||
onKeyboardMode: (value) => {
|
||||
mode = value;
|
||||
},
|
||||
});
|
||||
|
||||
expect(mode).toBe(false);
|
||||
|
||||
press('ArrowDown');
|
||||
expect(mode).toBe(true);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }));
|
||||
});
|
||||
expect(mode).toBe(false);
|
||||
});
|
||||
|
||||
it('only the active scope handles keys when two scoped hooks are mounted', () => {
|
||||
const onBottomMove = vi.fn();
|
||||
const onTopMove = vi.fn();
|
||||
|
||||
mountNode(
|
||||
<I18nProvider language="en">
|
||||
<ThemeProvider value="dark">
|
||||
<DialogShell title="Bottom" onClose={vi.fn()}>
|
||||
<ScopedHarness
|
||||
name="bottom"
|
||||
itemCount={3}
|
||||
activeIndex={0}
|
||||
onActiveIndexChange={onBottomMove}
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
</DialogShell>
|
||||
<DialogShell title="Top" onClose={vi.fn()}>
|
||||
<ScopedHarness
|
||||
name="top"
|
||||
itemCount={3}
|
||||
activeIndex={0}
|
||||
onActiveIndexChange={onTopMove}
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
</DialogShell>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// The top shell auto-focuses its content; only it should react.
|
||||
press('ArrowDown');
|
||||
expect(onTopMove).toHaveBeenCalledWith(1);
|
||||
expect(onBottomMove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
225
packages/web-shell/client/hooks/useListboxKeyboard.ts
Normal file
225
packages/web-shell/client/hooks/useListboxKeyboard.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue