fix(ui): improve composer focus, keyboard navigation, and settings (#3376)
* fix(ui): make composer keyboard interactions consistent * docs(settings): refine description visibility guidance --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
1306b1124c
commit
5ae1a949c8
@@ -51,6 +51,7 @@ import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||
import { CONTEXT_METADATA_KEY, draftFromContextPayload } from '@/lib/messages/contextParts';
|
||||
import { ComposerStatusBar } from './ComposerStatusBar';
|
||||
import { shouldSubmitEnter } from './composer/keyboardPolicy';
|
||||
import { getDropdownNavigationKey } from '@/components/ui/dropdown-navigation';
|
||||
import { PendingChangesBar } from './PendingChangesBar';
|
||||
import { useChatColumnSession } from './chatColumnSession';
|
||||
import { useChatSurfaceMode } from './useChatSurfaceMode';
|
||||
@@ -1873,40 +1874,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (openAutocomplete === 'command' && commandRef.current) {
|
||||
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
commandRef.current.handleKeyDown(e.key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (openAutocomplete === 'skill' && skillRef.current) {
|
||||
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
skillRef.current.handleKeyDown(e.key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (openAutocomplete === 'snippet' && snippetRef.current) {
|
||||
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
snippetRef.current.handleKeyDown(e.key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (openAutocomplete === 'mention' && mentionRef.current) {
|
||||
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
mentionRef.current.handleKeyDown(e.key);
|
||||
return;
|
||||
}
|
||||
const autocomplete = openAutocomplete === 'command' ? commandRef.current
|
||||
: openAutocomplete === 'skill' ? skillRef.current
|
||||
: openAutocomplete === 'snippet' ? snippetRef.current
|
||||
: openAutocomplete === 'mention' ? mentionRef.current
|
||||
: null;
|
||||
const autocompleteKey = getDropdownNavigationKey(e) ?? e.key;
|
||||
if (autocomplete && (autocompleteKey === 'Enter' || autocompleteKey === 'ArrowUp' || autocompleteKey === 'ArrowDown' || autocompleteKey === 'Escape' || autocompleteKey === 'Tab')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
autocomplete.handleKeyDown(autocompleteKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDesktopExpanded && e.key === 'Escape') {
|
||||
|
||||
@@ -200,9 +200,13 @@ and the send path reading the same grammar.
|
||||
result as transient local state that resets on every close, and commits
|
||||
through the existing project-change flow only on explicit activation.
|
||||
Filtering changes the result area below the anchored input without moving
|
||||
the search field. The
|
||||
worktree Select and the mobile bottom sheets are unchanged. The selectors only
|
||||
consume their shared prefix while the draft target UI is mounted.
|
||||
the search field. The worktree picker remains a Select; mobile keeps its
|
||||
bottom sheets. The selectors only consume their shared prefix while the
|
||||
draft target UI is mounted.
|
||||
Keyboard selection returns focus to the current form's composer, including
|
||||
when the selected value is unchanged.
|
||||
- `ChatInput.tsx` maps Ctrl+N/P to the active command, skill, snippet, or
|
||||
mention picker after its IME guard.
|
||||
|
||||
## Input recall ownership
|
||||
|
||||
@@ -259,10 +263,13 @@ suites that install module mocks are order-dependent.
|
||||
|
||||
## Enter preference
|
||||
|
||||
`keyboardPolicy.ts` owns the submission decision. Until the Chat setting is
|
||||
changed, desktop Enter sends, mobile and focus mode require Ctrl/Cmd+Enter,
|
||||
and Shift-modified Enter does not send. An explicit choice applies across
|
||||
shared composers; Ctrl/Cmd+Enter sends in either configured mode.
|
||||
`keyboardPolicy.ts` owns the submission decision. The expanded desktop composer
|
||||
always inserts a newline with Enter, including Shift+Enter, and sends with
|
||||
Ctrl/Cmd+Enter; it ignores the Enter-to-send preference. Outside expanded mode,
|
||||
until the Chat setting is changed, desktop Enter sends, mobile requires
|
||||
Ctrl/Cmd+Enter, and Shift-modified Enter does not send. An explicit choice
|
||||
applies across the other shared composers; Ctrl/Cmd+Enter sends in either
|
||||
configured mode.
|
||||
|
||||
CodeMirror's deferred mobile Enter loses modifier information. Untouched
|
||||
settings restore Shift to keep the original policy. Once configured, with mobile
|
||||
|
||||
@@ -26,6 +26,10 @@ const enterPolicyCases: Array<[string, Partial<EnterKeyPolicyInput>, boolean]> =
|
||||
['configured enabled Shift+Enter inserts a newline', { enterToSendConfigured: true, enterToSend: true, shiftKey: true }, false],
|
||||
['configured disabled Enter inserts a newline', { enterToSendConfigured: true, enterToSend: false }, false],
|
||||
['configured disabled Shift+Enter sends', { enterToSendConfigured: true, enterToSend: false, shiftKey: true }, true],
|
||||
['expanded composer Enter inserts a newline when Enter-to-send is enabled', { isDesktopExpanded: true, enterToSendConfigured: true, enterToSend: true }, false],
|
||||
['expanded composer Shift+Enter inserts a newline when Enter-to-send is disabled', { isDesktopExpanded: true, enterToSendConfigured: true, enterToSend: false, shiftKey: true }, false],
|
||||
['expanded composer Ctrl+Enter sends despite Enter-to-send being disabled', { isDesktopExpanded: true, enterToSendConfigured: true, ctrlKey: true }, true],
|
||||
['expanded composer Cmd+Enter sends despite Enter-to-send being enabled', { isDesktopExpanded: true, enterToSendConfigured: true, enterToSend: true, metaKey: true }, true],
|
||||
['configured Ctrl+Enter always sends', { enterToSendConfigured: true, isMobile: true, isDesktopExpanded: true, shiftKey: true, ctrlKey: true }, true],
|
||||
['configured Meta+Enter always sends', { enterToSendConfigured: true, isMobile: true, isDesktopExpanded: true, shiftKey: true, metaKey: true }, true],
|
||||
];
|
||||
@@ -33,8 +37,9 @@ const enterPolicyCases: Array<[string, Partial<EnterKeyPolicyInput>, boolean]> =
|
||||
describe('Enter key policy', () => {
|
||||
for (const surface of [{}, { isMobile: true }, { isDesktopExpanded: true }]) {
|
||||
for (const modifiers of [{}, { ctrlKey: true }, { metaKey: true }, { ctrlKey: true, metaKey: true }]) {
|
||||
test(`untouched Shift+Enter does not submit: ${JSON.stringify({ ...surface, ...modifiers })}`, () => {
|
||||
expect(shouldSubmitEnter(policy({ ...surface, ...modifiers, shiftKey: true }))).toBe(false);
|
||||
test(`untouched Shift+Enter only submits with a modifier in expanded mode: ${JSON.stringify({ ...surface, ...modifiers })}`, () => {
|
||||
expect(shouldSubmitEnter(policy({ ...surface, ...modifiers, shiftKey: true })))
|
||||
.toBe(Boolean(surface.isDesktopExpanded && (modifiers.ctrlKey || modifiers.metaKey)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ export interface EnterKeyPolicyInput {
|
||||
}
|
||||
|
||||
export const shouldSubmitEnter = (input: EnterKeyPolicyInput): boolean => {
|
||||
const enterSendsByDefault = !input.isMobile && !input.isDesktopExpanded;
|
||||
const isCtrlEnter = input.ctrlKey || input.metaKey;
|
||||
if (input.isDesktopExpanded) return isCtrlEnter;
|
||||
|
||||
const enterSendsByDefault = !input.isMobile;
|
||||
if (!input.enterToSendConfigured) {
|
||||
return !input.shiftKey && (enterSendsByDefault || isCtrlEnter);
|
||||
}
|
||||
|
||||
@@ -163,6 +163,10 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
const [projectFocusReturn, setProjectFocusReturn] = React.useState(false);
|
||||
const projectTriggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
const worktreeTriggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
// Controlled Select closes can omit finalFocus's interaction type.
|
||||
const keyboardCloseRef = React.useRef(false);
|
||||
const getComposerInput = () => projectTriggerRef.current?.closest('form')?.querySelector<HTMLElement>('[data-chat-input="true"] .cm-content');
|
||||
const getFinalFocus = () => keyboardCloseRef.current ? getComposerInput() : true;
|
||||
const projectSearchRef = React.useRef<HTMLInputElement>(null);
|
||||
// Preserve Select's dialog portal and main-area containment.
|
||||
const [projectPortalContainer, setProjectPortalContainer] = React.useState<HTMLElement | null>(null);
|
||||
@@ -190,6 +194,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
if (openPicker === null || !shouldDismissDropdown(event)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
keyboardCloseRef.current = true;
|
||||
setOpenPicker(null);
|
||||
};
|
||||
|
||||
@@ -279,10 +284,10 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
setOpenPicker(null);
|
||||
}}
|
||||
onOpenChangeComplete={(open) => {
|
||||
// Return focus after Base UI finishes closing so the
|
||||
// trigger itself, not document body, keeps keyboard flow.
|
||||
// Return focus after Base UI finishes closing so typing
|
||||
// continues in this form's composer, including reselection.
|
||||
if (!open && projectFocusReturn) {
|
||||
projectTriggerRef.current?.focus();
|
||||
(getComposerInput() ?? projectTriggerRef.current)?.focus();
|
||||
setProjectFocusReturn(false);
|
||||
}
|
||||
// Focus the search once the popup mounts; the opening
|
||||
@@ -403,7 +408,10 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
<Select
|
||||
value={selectedDirectory ?? branchItems[0]?.value ?? normalizePath(selectedProject.path) ?? ''}
|
||||
open={openPicker === 'worktree'}
|
||||
onOpenChange={(open) => setOpenPicker(open ? 'worktree' : null)}
|
||||
onOpenChange={(open, details) => {
|
||||
keyboardCloseRef.current = !open && details.event.type === 'keydown';
|
||||
setOpenPicker(open ? 'worktree' : null);
|
||||
}}
|
||||
onValueChange={handleDirectoryChange}
|
||||
disableGlobalShortcuts
|
||||
>
|
||||
@@ -433,7 +441,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
|
||||
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown} finalFocus={getFinalFocus}>
|
||||
{projectRootBranchOption ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { matchesRankQuery } from '@/lib/search/fuzzySearch';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { handleDropdownNavigationKey } from '@/components/ui/dropdown-navigation';
|
||||
import { getCurrentIntlLocale } from '@/lib/i18n';
|
||||
import { mergeModelMetadataWithLiveModel } from '@/lib/modelMetadata';
|
||||
import { getModelDisplayName as getSharedModelDisplayName } from '@/lib/modelDisplay';
|
||||
@@ -585,9 +586,17 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
filteredFavorites.map((entry) => [`${entry.providerID}:${entry.modelID}`, entry] as const),
|
||||
), [filteredFavorites]);
|
||||
|
||||
React.useEffect(() => {
|
||||
selectionStore.set(0);
|
||||
}, [searchQuery, selectionStore]);
|
||||
const initialSelectionIndex = searchQuery.trim() || !selectedModel ? 0 : Math.max(0,
|
||||
flatModelList.findIndex((entry) => entry.providerID === selectedModel.providerID && entry.modelID === selectedModel.modelID),
|
||||
);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
selectionStore.set(initialSelectionIndex);
|
||||
// Opening or scrolling the list must not let a stationary pointer replace the current model.
|
||||
keyboardOwnsSelectionRef.current = true;
|
||||
lastMousePositionRef.current = null;
|
||||
scrollIntoView(scrollRef.current, itemRefs.current[initialSelectionIndex]);
|
||||
}, [initialSelectionIndex, searchQuery, selectedModel?.providerID, selectedModel?.modelID, selectionStore]);
|
||||
|
||||
const selectIndex = React.useCallback((index: number) => {
|
||||
selectionStore.set(index);
|
||||
@@ -608,10 +617,13 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
|
||||
React.useEffect(() => {
|
||||
onActiveEntryChange?.(flatModelList[selectionStore.getSnapshot()]);
|
||||
}, [flatModelList, onActiveEntryChange, selectionStore]);
|
||||
}, [flatModelList, initialSelectionIndex, onActiveEntryChange, selectionStore]);
|
||||
|
||||
const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return;
|
||||
if (handleDropdownNavigationKey(event, (navigationKey) => {
|
||||
moveSelection(navigationKey === 'ArrowDown' ? 1 : -1);
|
||||
})) return;
|
||||
event.stopPropagation();
|
||||
if ((event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
|
||||
const selected = flatModelList[selectionStore.getSnapshot()];
|
||||
|
||||
@@ -405,7 +405,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const setEnterToSend = useUIStore(state => state.setEnterToSend);
|
||||
const enterToSendConfigured = useUIStore(state => state.enterToSendConfigured);
|
||||
const setEnterToSendConfigured = useUIStore(state => state.setEnterToSendConfigured);
|
||||
const isExpandedInput = useUIStore(state => state.isExpandedInput);
|
||||
const enterSendSelected = enterToSendConfigured ? enterToSend : !isMobile;
|
||||
const showToolFileIcons = useUIStore(state => state.showToolFileIcons);
|
||||
const setShowToolFileIcons = useUIStore(state => state.setShowToolFileIcons);
|
||||
const showTurnChangedFiles = useUIStore(state => state.showTurnChangedFiles);
|
||||
@@ -2097,8 +2097,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<SettingsSection
|
||||
title={t('settings.openchamber.visual.section.composer')}
|
||||
settingsItem="chat.composer"
|
||||
contentClassName={SETTINGS_OPTION_STACK_CLASS}
|
||||
contentClassName="space-y-6"
|
||||
>
|
||||
{(shouldShow('persistDraft') || (!isMobile && shouldShow('inputSpellcheck'))) && (
|
||||
<div className={SETTINGS_OPTION_STACK_CLASS}>
|
||||
{shouldShow('persistDraft') && (
|
||||
<SettingsCheckboxRow
|
||||
checked={persistChatDraft}
|
||||
@@ -2118,11 +2120,13 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
settingsItem="chat.spellcheck"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShow('largeTextPaste') && (
|
||||
<SettingsControlGroup
|
||||
title={t('settings.openchamber.visual.field.largeTextPaste')}
|
||||
info={t('settings.openchamber.visual.field.largeTextPasteHint')}
|
||||
description={t('settings.openchamber.visual.field.largeTextPasteHint')}
|
||||
settingsItem="chat.large-text-paste"
|
||||
>
|
||||
<SettingsRadioGroup aria-label={t('settings.openchamber.visual.field.largeTextPasteAria')}>
|
||||
@@ -2139,14 +2143,26 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
</SettingsControlGroup>
|
||||
)}
|
||||
{shouldShow('enterToSend') && (
|
||||
<SettingsCheckboxRow
|
||||
checked={enterToSendConfigured ? enterToSend : !isMobile && !isExpandedInput}
|
||||
onChange={handleEnterToSendChange}
|
||||
label={t('settings.openchamber.visual.field.enterToSend')}
|
||||
info={t('settings.openchamber.visual.field.enterToSendHint')}
|
||||
ariaLabel={t('settings.openchamber.visual.field.enterToSend')}
|
||||
<SettingsControlGroup
|
||||
title={t('settings.openchamber.visual.field.enterToSend')}
|
||||
description={t('settings.openchamber.visual.field.enterToSendHint')}
|
||||
settingsItem="chat.enter-to-send"
|
||||
/>
|
||||
>
|
||||
<SettingsRadioGroup aria-label={t('settings.openchamber.visual.field.enterToSend')}>
|
||||
<SettingsRadioOption
|
||||
selected={enterSendSelected}
|
||||
onSelect={() => handleEnterToSendChange(true)}
|
||||
label={t('settings.openchamber.visual.option.enterToSend.enter.label')}
|
||||
ariaLabel={t('settings.openchamber.visual.option.enterToSend.enter.label')}
|
||||
/>
|
||||
<SettingsRadioOption
|
||||
selected={!enterSendSelected}
|
||||
onSelect={() => handleEnterToSendChange(false)}
|
||||
label={t('settings.openchamber.visual.option.enterToSend.modifier.label')}
|
||||
ariaLabel={t('settings.openchamber.visual.option.enterToSend.modifier.label')}
|
||||
/>
|
||||
</SettingsRadioGroup>
|
||||
</SettingsControlGroup>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
dropdownMenuPopupClass,
|
||||
dropdownMenuSeparatorClass,
|
||||
} from "./dropdown-menu.styles";
|
||||
import { handleDropdownNavigationKey } from "./dropdown-navigation";
|
||||
|
||||
function ContextMenu({ ...props }: React.ComponentProps<typeof BaseContextMenu.Root>) {
|
||||
return <BaseContextMenu.Root {...props} />;
|
||||
@@ -22,7 +23,18 @@ type ContentProps = {
|
||||
children?: React.ReactNode;
|
||||
} & React.ComponentProps<typeof BaseContextMenu.Popup>;
|
||||
|
||||
function ContextMenuContent({ className, positionerClassName, children, style, ...props }: ContentProps) {
|
||||
function ContextMenuContent({ className, positionerClassName, children, style, onKeyDown, ...props }: ContentProps) {
|
||||
const handleKeyDown: NonNullable<React.ComponentProps<typeof BaseContextMenu.Popup>["onKeyDown"]> = (event) => {
|
||||
onKeyDown?.(event);
|
||||
handleDropdownNavigationKey(event, (navigationKey) => {
|
||||
event.currentTarget.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: navigationKey,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseContextMenu.Portal>
|
||||
<BaseContextMenu.Positioner className={cn("app-region-no-drag z-50", positionerClassName)}>
|
||||
@@ -34,6 +46,7 @@ function ContextMenuContent({ className, positionerClassName, children, style, .
|
||||
}}
|
||||
className={cn(dropdownMenuPopupClass, className)}
|
||||
{...props}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{children}
|
||||
</BaseContextMenu.Popup>
|
||||
|
||||
@@ -322,9 +322,21 @@ function DropdownMenuSubTrigger({
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
children,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseMenu.Popup>) {
|
||||
const portalContext = React.useContext(DropdownPortalContext);
|
||||
const handleKeyDown: NonNullable<React.ComponentProps<typeof BaseMenu.Popup>['onKeyDown']> = (event) => {
|
||||
onKeyDown?.(event);
|
||||
handleDropdownNavigationKey(event, (navigationKey) => {
|
||||
event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: navigationKey,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseMenu.Portal container={portalContext?.portalContainer || undefined}>
|
||||
<BaseMenu.Positioner className="z-50">
|
||||
@@ -338,6 +350,7 @@ function DropdownMenuSubContent({
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{children}
|
||||
</BaseMenu.Popup>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { getDropdownNavigationKey, handleDropdownNavigationKey } from './dropdown-navigation';
|
||||
|
||||
function createEvent(overrides: Partial<KeyboardEvent> = {}) {
|
||||
let defaultPrevented = false;
|
||||
let propagationStopped = false;
|
||||
return {
|
||||
key: 'n',
|
||||
code: 'KeyN',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
get defaultPrevented() { return defaultPrevented; },
|
||||
isPropagationStopped: () => propagationStopped,
|
||||
preventDefault: () => { defaultPrevented = true; },
|
||||
stopPropagation: () => { propagationStopped = true; },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('dropdown Ctrl+N/P navigation', () => {
|
||||
test('requires an exact Ctrl modifier chord', () => {
|
||||
expect(getDropdownNavigationKey(createEvent())).toBe('ArrowDown');
|
||||
expect(getDropdownNavigationKey(createEvent({ key: 'p', code: 'KeyP' }))).toBe('ArrowUp');
|
||||
expect(getDropdownNavigationKey(createEvent({ ctrlKey: false }))).toBeNull();
|
||||
expect(getDropdownNavigationKey(createEvent({ metaKey: true }))).toBeNull();
|
||||
expect(getDropdownNavigationKey(createEvent({ altKey: true }))).toBeNull();
|
||||
expect(getDropdownNavigationKey(createEvent({ shiftKey: true }))).toBeNull();
|
||||
});
|
||||
|
||||
test('uses the physical key for non-Latin layouts', () => {
|
||||
expect(getDropdownNavigationKey(createEvent({ key: 'т', code: 'KeyN' }))).toBe('ArrowDown');
|
||||
});
|
||||
|
||||
test('respects cancellation and otherwise navigates exactly once', () => {
|
||||
const cancelled = createEvent({ defaultPrevented: true });
|
||||
expect(handleDropdownNavigationKey(cancelled, () => { throw new Error('should not navigate'); })).toBe(false);
|
||||
|
||||
const stopped = createEvent();
|
||||
stopped.stopPropagation();
|
||||
expect(handleDropdownNavigationKey(stopped, () => { throw new Error('should not navigate'); })).toBe(false);
|
||||
|
||||
const event = createEvent();
|
||||
const steps: string[] = [];
|
||||
expect(handleDropdownNavigationKey(event, (key) => steps.push(key))).toBe(true);
|
||||
expect(steps).toEqual(['ArrowDown']);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(event.isPropagationStopped()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import type React from 'react';
|
||||
|
||||
import { isIMECompositionEvent } from '@/lib/ime';
|
||||
|
||||
function getDropdownNavigationKey(event: Pick<KeyboardEvent, 'key' | 'code' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>): 'ArrowDown' | 'ArrowUp' | null {
|
||||
export function getDropdownNavigationKey(event: Pick<KeyboardEvent, 'key' | 'code' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>): 'ArrowDown' | 'ArrowUp' | null {
|
||||
if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null;
|
||||
// `code` covers non-Latin layouts, where `key` is the layout's own letter.
|
||||
if (event.key.toLowerCase() === 'n' || event.code === 'KeyN') return 'ArrowDown';
|
||||
|
||||
Reference in New Issue
Block a user