fix(app): stop the pointer hijacking quick switcher selection (#1955)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:52:36 +02:00
1 parent af5bee9149
commit 317b4e26c0
5 files changed
+128 -95

No files matched your search

@@ -12,6 +12,7 @@ import QuickSwitcher from '@app/features/search/state/QuickSwitcher';
import type {QuickSwitcherExecutableResult, QuickSwitcherResult} from '@app/features/search/state/QuickSwitcherTypes'; import type {QuickSwitcherExecutableResult, QuickSwitcherResult} from '@app/features/search/state/QuickSwitcherTypes';
import { import {
createSections, createSections,
dismissQuickSwitcher,
getQuickSwitcherResultAccessibilityMetadata, getQuickSwitcherResultAccessibilityMetadata,
getQuickSwitcherTabs, getQuickSwitcherTabs,
getResultKey, getResultKey,
@@ -313,7 +314,8 @@ export const QuickSwitcherBottomSheet: React.FC<QuickSwitcherBottomSheetProps> =
break; break;
case 'Escape': case 'Escape':
event.preventDefault(); event.preventDefault();
QuickSwitcherCommands.hide(); event.stopPropagation();
dismissQuickSwitcher({isEscape: true});
break; break;
default: default:
break; break;
@@ -375,6 +377,7 @@ export const QuickSwitcherBottomSheet: React.FC<QuickSwitcherBottomSheetProps> =
() => results.filter((result) => result.type !== QuickSwitcherResultTypes.HEADER).length, () => results.filter((result) => result.type !== QuickSwitcherResultTypes.HEADER).length,
[results], [results],
); );
const hasSearchIntent = query.trim().length > 0;
const activeDescendant = const activeDescendant =
isSearchTab && keyboardFocusIndex >= 0 && results[keyboardFocusIndex]?.type !== QuickSwitcherResultTypes.HEADER isSearchTab && keyboardFocusIndex >= 0 && results[keyboardFocusIndex]?.type !== QuickSwitcherResultTypes.HEADER
? getQuickSwitcherOptionId(listboxId, keyboardFocusIndex) ? getQuickSwitcherOptionId(listboxId, keyboardFocusIndex)
@@ -382,7 +385,9 @@ export const QuickSwitcherBottomSheet: React.FC<QuickSwitcherBottomSheetProps> =
const resultStatus = QuickSwitcher.isLoadingMemberResults const resultStatus = QuickSwitcher.isLoadingMemberResults
? i18n._(SEARCHING_PEOPLE_DESCRIPTOR) ? i18n._(SEARCHING_PEOPLE_DESCRIPTOR)
: resultCount === 0 : resultCount === 0
? hasSearchIntent
? i18n._(NO_MATCHES_FOUND_DESCRIPTOR) ? i18n._(NO_MATCHES_FOUND_DESCRIPTOR)
: ''
: resultCount === 1 : resultCount === 1
? i18n._(MESSAGE_1_RESULT_AVAILABLE_DESCRIPTOR) ? i18n._(MESSAGE_1_RESULT_AVAILABLE_DESCRIPTOR)
: i18n._(RESULTS_AVAILABLE_DESCRIPTOR, {resultCount}); : i18n._(RESULTS_AVAILABLE_DESCRIPTOR, {resultCount});
@@ -392,7 +397,7 @@ export const QuickSwitcherBottomSheet: React.FC<QuickSwitcherBottomSheetProps> =
} }
const result = results[keyboardFocusIndex]; const result = results[keyboardFocusIndex];
if (!result || result.type === QuickSwitcherResultTypes.HEADER) { if (!result || result.type === QuickSwitcherResultTypes.HEADER) {
return resultCount === 0 ? i18n._(NO_AUTOCOMPLETE_SUGGESTION_DESCRIPTOR) : ''; return resultCount === 0 && hasSearchIntent ? i18n._(NO_AUTOCOMPLETE_SUGGESTION_DESCRIPTOR) : '';
} }
const label = getQuickSwitcherResultAccessibilityMetadata(result as QuickSwitcherExecutableResult, i18n).label; const label = getQuickSwitcherResultAccessibilityMetadata(result as QuickSwitcherExecutableResult, i18n).label;
const trimmedQuery = query.trim(); const trimmedQuery = query.trim();
@@ -404,7 +409,16 @@ export const QuickSwitcherBottomSheet: React.FC<QuickSwitcherBottomSheetProps> =
return selectedOptionPosition > 0 return selectedOptionPosition > 0
? i18n._(AUTOCOMPLETE_SUGGESTION_OF_DESCRIPTOR, {label, selectedOptionPosition, resultCount}) ? i18n._(AUTOCOMPLETE_SUGGESTION_OF_DESCRIPTOR, {label, selectedOptionPosition, resultCount})
: i18n._(AUTOCOMPLETE_SUGGESTION_DESCRIPTOR, {label}); : i18n._(AUTOCOMPLETE_SUGGESTION_DESCRIPTOR, {label});
}, [i18n.locale, isSearchTab, keyboardFocusIndex, query, resultCount, results, selectedOptionPosition]); }, [
hasSearchIntent,
i18n.locale,
isSearchTab,
keyboardFocusIndex,
query,
resultCount,
results,
selectedOptionPosition,
]);
const handleTabChange = useCallback((tab: 'search' | 'friends') => { const handleTabChange = useCallback((tab: 'search' | 'friends') => {
setActiveTab(tab); setActiveTab(tab);
}, []); }, []);
@@ -526,6 +540,7 @@ export const QuickSwitcherBottomSheet: React.FC<QuickSwitcherBottomSheetProps> =
{activeSuggestionStatus} {activeSuggestionStatus}
</div> </div>
</div> </div>
{results.length === 0 && !hasSearchIntent ? null : (
<Scroller <Scroller
ref={scrollerRef} ref={scrollerRef}
className={styles.scroller} className={styles.scroller}
@@ -555,17 +570,17 @@ export const QuickSwitcherBottomSheet: React.FC<QuickSwitcherBottomSheetProps> =
aria-label={i18n._(QUICK_SWITCHER_RESULTS_DESCRIPTOR)} aria-label={i18n._(QUICK_SWITCHER_RESULTS_DESCRIPTOR)}
data-flx="search.quick-switcher-bottom-sheet.listbox" data-flx="search.quick-switcher-bottom-sheet.listbox"
> >
{sections.map((section: QuickSwitcherSection, sidx: number) => ( {sections.map((section: QuickSwitcherSection) => (
<div <div
key={`section-${sidx}`} key={section.key}
className={styles.section} className={styles.section}
role={section.header ? 'group' : 'presentation'} role={section.header ? 'group' : 'presentation'}
data-flx="search.quick-switcher-bottom-sheet.section" data-flx="search.quick-switcher-bottom-sheet.section"
{...(section.header ? {'aria-labelledby': `${listboxId}-section-${sidx}`} : {})} {...(section.header ? {'aria-labelledby': `${listboxId}-${section.key}`} : {})}
> >
{section.header && ( {section.header && (
<div <div
id={`${listboxId}-section-${sidx}`} id={`${listboxId}-${section.key}`}
className={styles.sectionHeader} className={styles.sectionHeader}
data-flx="search.quick-switcher-bottom-sheet.section-header" data-flx="search.quick-switcher-bottom-sheet.section-header"
> >
@@ -605,6 +620,7 @@ export const QuickSwitcherBottomSheet: React.FC<QuickSwitcherBottomSheetProps> =
)} )}
</div> </div>
</Scroller> </Scroller>
)}
</div> </div>
{!directMessagesDisabled && ( {!directMessagesDisabled && (
<div <div
@@ -14,18 +14,10 @@
color: var(--qs-text-primary, var(--text-primary)); color: var(--qs-text-primary, var(--text-primary));
} }
.option:hover {
background-color: var(--qs-row-hover, var(--background-modifier-accent));
}
.optionActive { .optionActive {
background-color: var(--qs-row-hover, var(--background-modifier-accent)); background-color: var(--qs-row-hover, var(--background-modifier-accent));
} }
:global(html.theme-light) .option:hover {
background-color: var(--qs-row-hover-light, var(--background-modifier-hover));
}
:global(html.theme-light) .optionActive { :global(html.theme-light) .optionActive {
background-color: var(--qs-row-hover-light, var(--background-modifier-hover)); background-color: var(--qs-row-hover-light, var(--background-modifier-hover));
} }
@@ -1,16 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import * as Modal from '@app/features/app/components/dialogs/Modal'; import * as Modal from '@app/features/app/components/dialogs/Modal';
import {useListNavigation} from '@app/features/app/hooks/useListNavigation';
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils'; import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils'; import {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils';
import * as QuickSwitcherCommands from '@app/features/search/commands/QuickSwitcherCommands'; import * as QuickSwitcherCommands from '@app/features/search/commands/QuickSwitcherCommands';
import {QuickSwitcherBottomSheet} from '@app/features/search/components/bottomsheets/QuickSwitcherBottomSheet'; import {QuickSwitcherBottomSheet} from '@app/features/search/components/bottomsheets/QuickSwitcherBottomSheet';
import quickStyles from '@app/features/search/components/quick_switcher/QuickSwitcherModal.module.css'; import quickStyles from '@app/features/search/components/quick_switcher/QuickSwitcherModal.module.css';
import QuickSwitcher from '@app/features/search/state/QuickSwitcher'; import QuickSwitcher from '@app/features/search/state/QuickSwitcher';
import {consumeEscapeIntent} from '@app/features/search/state/QuickSwitcherEscapeIntent';
import type {QuickSwitcherExecutableResult, QuickSwitcherResult} from '@app/features/search/state/QuickSwitcherTypes'; import type {QuickSwitcherExecutableResult, QuickSwitcherResult} from '@app/features/search/state/QuickSwitcherTypes';
import { import {
createSections, createSections,
dismissQuickSwitcher,
getQuickSwitcherResultAccessibilityMetadata, getQuickSwitcherResultAccessibilityMetadata,
getResultKey, getResultKey,
getViewContext, getViewContext,
@@ -18,6 +19,7 @@ import {
PREFIX_HINTS, PREFIX_HINTS,
type QuickSwitcherSection, type QuickSwitcherSection,
renderIcon, renderIcon,
useQuickSwitcherEscapeIntent,
useQuickSwitcherInputFocus, useQuickSwitcherInputFocus,
useQuickSwitcherKeyboardHandling, useQuickSwitcherKeyboardHandling,
} from '@app/features/search/utils/QuickSwitcherModalUtils'; } from '@app/features/search/utils/QuickSwitcherModalUtils';
@@ -34,7 +36,7 @@ import {Trans, useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx'; import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import type React from 'react'; import type React from 'react';
import {useCallback, useEffect, useId, useMemo, useRef} from 'react'; import {useCallback, useEffect, useId, useMemo, useRef, useState} from 'react';
const NO_AUTOCOMPLETE_SUGGESTION_DESCRIPTOR = msg({ const NO_AUTOCOMPLETE_SUGGESTION_DESCRIPTOR = msg({
message: 'No autocomplete suggestion', message: 'No autocomplete suggestion',
@@ -99,6 +101,7 @@ const QUICK_SWITCHER_RESULTS_DESCRIPTOR = msg({
message: 'Quick switcher results', message: 'Quick switcher results',
comment: 'Accessible label for the desktop quick switcher result listbox.', comment: 'Accessible label for the desktop quick switcher result listbox.',
}); });
const SELECTED_ROW_SCROLL_PADDING = 10;
const getQuickSwitcherOptionId = (baseId: string, index: number): string => `${baseId}-option-${index}`; const getQuickSwitcherOptionId = (baseId: string, index: number): string => `${baseId}-option-${index}`;
type PrefixHint = (typeof PREFIX_HINTS)[number]; type PrefixHint = (typeof PREFIX_HINTS)[number];
const PrefixHintToken: React.FC<{ const PrefixHintToken: React.FC<{
@@ -121,9 +124,7 @@ const ResultRow = observer(
result, result,
index, index,
isKeyboardSelected, isKeyboardSelected,
isHovered,
onHover, onHover,
onMouseLeave,
onConfirm, onConfirm,
optionId, optionId,
positionInSet, positionInSet,
@@ -133,9 +134,7 @@ const ResultRow = observer(
result: QuickSwitcherResult; result: QuickSwitcherResult;
index: number; index: number;
isKeyboardSelected: boolean; isKeyboardSelected: boolean;
isHovered: boolean;
onHover: (index: number) => void; onHover: (index: number) => void;
onMouseLeave: () => void;
onConfirm: (result: QuickSwitcherExecutableResult) => void; onConfirm: (result: QuickSwitcherExecutableResult) => void;
optionId: string; optionId: string;
positionInSet: number; positionInSet: number;
@@ -158,7 +157,7 @@ const ResultRow = observer(
const resultMetadata = getQuickSwitcherResultAccessibilityMetadata(executableResult, i18n); const resultMetadata = getQuickSwitcherResultAccessibilityMetadata(executableResult, i18n);
const {guildName, isChannel, isUser, label: optionLabel, mentionCount, subtext, unreadCount} = resultMetadata; const {guildName, isChannel, isUser, label: optionLabel, mentionCount, subtext, unreadCount} = resultMetadata;
const hasUnread = unreadCount > 0 || mentionCount > 0; const hasUnread = unreadCount > 0 || mentionCount > 0;
const isActive = isKeyboardSelected || isHovered; const isActive = isKeyboardSelected;
const isHighlight = const isHighlight =
hasUnread && hasUnread &&
(executableResult.type === QuickSwitcherResultTypes.TEXT_CHANNEL || (executableResult.type === QuickSwitcherResultTypes.TEXT_CHANNEL ||
@@ -197,7 +196,6 @@ const ResultRow = observer(
className={clsx(quickStyles.option, isActive && quickStyles.optionActive)} className={clsx(quickStyles.option, isActive && quickStyles.optionActive)}
ref={innerRef} ref={innerRef}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={onMouseLeave}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button === 0) event.preventDefault(); if (event.button === 0) event.preventDefault();
}} }}
@@ -290,56 +288,20 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const scrollerRef = useRef<ScrollerHandle>(null); const scrollerRef = useRef<ScrollerHandle>(null);
const rowRefs = useRef<Array<HTMLDivElement | null>>([]); const rowRefs = useRef<Array<HTMLDivElement | null>>([]);
const shouldScrollToSelection = useRef(false); const previousQueryRef = useRef(query);
const [isKeyboardNavigating, setIsKeyboardNavigating] = useState(true);
const isMobile = MobileLayout.isMobileLayout(); const isMobile = MobileLayout.isMobileLayout();
const inputId = `${quickSwitcherId}-input`; const inputId = `${quickSwitcherId}-input`;
const listboxId = `${quickSwitcherId}-results`; const listboxId = `${quickSwitcherId}-results`;
const statusId = `${quickSwitcherId}-status`; const statusId = `${quickSwitcherId}-status`;
const suggestionStatusId = `${quickSwitcherId}-suggestion-status`; const suggestionStatusId = `${quickSwitcherId}-suggestion-status`;
const hintId = `${quickSwitcherId}-hint`; const hintId = `${quickSwitcherId}-hint`;
const {
keyboardFocusIndex,
hoverIndexForRender,
handleMouseEnter: handleHoverIndex,
handleMouseLeave,
setSelectedIndex: setKeyboardIndex,
} = useListNavigation({
itemCount: results.length,
initialIndex: selectedIndex >= 0 ? selectedIndex : 0,
loop: true,
});
if (rowRefs.current.length !== results.length) { if (rowRefs.current.length !== results.length) {
rowRefs.current = Array(results.length).fill(null); rowRefs.current = Array(results.length).fill(null);
} }
useEffect(() => {
if (results.length === 0) {
setKeyboardIndex(-1);
handleMouseLeave();
return;
}
const clamped = selectedIndex >= 0 ? Math.min(selectedIndex, results.length - 1) : 0;
setKeyboardIndex(clamped);
}, [handleMouseLeave, results.length, selectedIndex, setKeyboardIndex]);
useEffect(() => {
handleMouseLeave();
}, [handleMouseLeave, results.length]);
useQuickSwitcherKeyboardHandling(isOpen, isMobile, inputRef, query); useQuickSwitcherKeyboardHandling(isOpen, isMobile, inputRef, query);
useQuickSwitcherInputFocus(isOpen, isMobile, undefined, inputRef); useQuickSwitcherInputFocus(isOpen, isMobile, undefined, inputRef);
useEffect(() => { const escapeIntentRef = useQuickSwitcherEscapeIntent(isOpen && !isMobile);
if (!isOpen || isMobile) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') {
return;
}
event.preventDefault();
shouldScrollToSelection.current = true;
QuickSwitcherCommands.moveSelection(event.key === 'ArrowDown' ? 'down' : 'up');
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isMobile, isOpen]);
const handleChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
QuickSwitcherCommands.search(event.target.value); QuickSwitcherCommands.search(event.target.value);
}, []); }, []);
@@ -347,22 +309,18 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
if (isIMEComposing(event)) { if (isIMEComposing(event)) {
return; return;
} }
setIsKeyboardNavigating(true);
switch (event.key) { switch (event.key) {
case 'ArrowDown': case 'ArrowDown':
case 'ArrowUp': case 'ArrowUp':
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
shouldScrollToSelection.current = true;
QuickSwitcherCommands.moveSelection(event.key === 'ArrowDown' ? 'down' : 'up'); QuickSwitcherCommands.moveSelection(event.key === 'ArrowDown' ? 'down' : 'up');
break; break;
case 'Enter': case 'Enter':
event.preventDefault(); event.preventDefault();
await QuickSwitcherCommands.confirmSelection(); await QuickSwitcherCommands.confirmSelection();
break; break;
case 'Escape':
event.preventDefault();
QuickSwitcherCommands.hide();
break;
default: default:
break; break;
} }
@@ -371,29 +329,41 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
if (LayerManager.hasType('contextmenu')) { if (LayerManager.hasType('contextmenu')) {
return; return;
} }
QuickSwitcherCommands.hide(); dismissQuickSwitcher({isEscape: consumeEscapeIntent(escapeIntentRef)});
}, [escapeIntentRef]);
const handlePointerMove = useCallback(() => {
setIsKeyboardNavigating(false);
}, []); }, []);
const handleHover = useCallback( const handleHover = useCallback(
(index: number) => { (index: number) => {
shouldScrollToSelection.current = false; if (isKeyboardNavigating) {
handleHoverIndex(index); return;
}
QuickSwitcherCommands.select(index);
}, },
[handleHoverIndex], [isKeyboardNavigating],
); );
const handleConfirm = useCallback((result: QuickSwitcherExecutableResult) => { const handleConfirm = useCallback((result: QuickSwitcherExecutableResult) => {
void QuickSwitcherCommands.switchTo(result); void QuickSwitcherCommands.switchTo(result);
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!shouldScrollToSelection.current || keyboardFocusIndex < 0) { const scroller = scrollerRef.current;
shouldScrollToSelection.current = false; if (!scroller) {
return; return;
} }
shouldScrollToSelection.current = false; if (previousQueryRef.current !== query) {
const node = rowRefs.current[keyboardFocusIndex]; previousQueryRef.current = query;
if (node) { scroller.scrollTo({to: 0});
scrollerRef.current?.revealElement({node: node as HTMLElement, padding: 32}); return;
} }
}, [keyboardFocusIndex]); if (!isKeyboardNavigating || selectedIndex < 0) {
return;
}
const node = rowRefs.current[selectedIndex];
if (node) {
scroller.revealElement({node, padding: SELECTED_ROW_SCROLL_PADDING});
}
}, [isKeyboardNavigating, query, results, selectedIndex]);
const sections = useMemo(() => createSections(results), [results]); const sections = useMemo(() => createSections(results), [results]);
const selectableIndices = useMemo( const selectableIndices = useMemo(
() => () =>
@@ -405,19 +375,20 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
}, []), }, []),
[results], [results],
); );
const selectedOptionPosition = keyboardFocusIndex >= 0 ? selectableIndices.indexOf(keyboardFocusIndex) + 1 : 0; const selectedOptionPosition = selectedIndex >= 0 ? selectableIndices.indexOf(selectedIndex) + 1 : 0;
const hasSearchIntent = query.trim().length > 0;
const resultCount = useMemo( const resultCount = useMemo(
() => results.filter((result) => result.type !== QuickSwitcherResultTypes.HEADER).length, () => results.filter((result) => result.type !== QuickSwitcherResultTypes.HEADER).length,
[results], [results],
); );
const activeDescendant = const activeDescendant =
keyboardFocusIndex >= 0 && results[keyboardFocusIndex]?.type !== QuickSwitcherResultTypes.HEADER selectedIndex >= 0 && results[selectedIndex]?.type !== QuickSwitcherResultTypes.HEADER
? getQuickSwitcherOptionId(listboxId, keyboardFocusIndex) ? getQuickSwitcherOptionId(listboxId, selectedIndex)
: undefined; : undefined;
const activeSuggestionStatus = useMemo(() => { const activeSuggestionStatus = useMemo(() => {
const result = results[keyboardFocusIndex]; const result = results[selectedIndex];
if (!result || result.type === QuickSwitcherResultTypes.HEADER) { if (!result || result.type === QuickSwitcherResultTypes.HEADER) {
return resultCount === 0 ? i18n._(NO_AUTOCOMPLETE_SUGGESTION_DESCRIPTOR) : ''; return resultCount === 0 && hasSearchIntent ? i18n._(NO_AUTOCOMPLETE_SUGGESTION_DESCRIPTOR) : '';
} }
const label = getQuickSwitcherResultAccessibilityMetadata(result as QuickSwitcherExecutableResult, i18n).label; const label = getQuickSwitcherResultAccessibilityMetadata(result as QuickSwitcherExecutableResult, i18n).label;
const trimmedQuery = query.trim(); const trimmedQuery = query.trim();
@@ -429,11 +400,13 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
return selectedOptionPosition > 0 return selectedOptionPosition > 0
? i18n._(AUTOCOMPLETE_SUGGESTION_OF_DESCRIPTOR, {label, selectedOptionPosition, resultCount}) ? i18n._(AUTOCOMPLETE_SUGGESTION_OF_DESCRIPTOR, {label, selectedOptionPosition, resultCount})
: i18n._(AUTOCOMPLETE_SUGGESTION_DESCRIPTOR, {label}); : i18n._(AUTOCOMPLETE_SUGGESTION_DESCRIPTOR, {label});
}, [i18n.locale, keyboardFocusIndex, query, resultCount, results, selectedOptionPosition]); }, [hasSearchIntent, i18n.locale, query, resultCount, results, selectedIndex, selectedOptionPosition]);
const resultStatus = QuickSwitcher.isLoadingMemberResults const resultStatus = QuickSwitcher.isLoadingMemberResults
? i18n._(SEARCHING_PEOPLE_DESCRIPTOR) ? i18n._(SEARCHING_PEOPLE_DESCRIPTOR)
: resultCount === 0 : resultCount === 0
? hasSearchIntent
? i18n._(NO_MATCHES_FOUND_DESCRIPTOR) ? i18n._(NO_MATCHES_FOUND_DESCRIPTOR)
: ''
: resultCount === 1 : resultCount === 1
? i18n._(MESSAGE_1_RESULT_AVAILABLE_DESCRIPTOR) ? i18n._(MESSAGE_1_RESULT_AVAILABLE_DESCRIPTOR)
: i18n._(RESULTS_AVAILABLE_DESCRIPTOR, {resultCount}); : i18n._(RESULTS_AVAILABLE_DESCRIPTOR, {resultCount});
@@ -455,7 +428,7 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
className={quickStyles.modalRoot} className={quickStyles.modalRoot}
onClose={handleClose} onClose={handleClose}
initialFocusRef={inputRef} initialFocusRef={inputRef}
transitionPreset="instant" transitionPreset="quick"
data-quick-switcher-modal="true" data-quick-switcher-modal="true"
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.modal-root" data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.modal-root"
> >
@@ -463,8 +436,10 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
text={i18n._(QUICK_SWITCHER_DESCRIPTOR)} text={i18n._(QUICK_SWITCHER_DESCRIPTOR)}
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.modal-screen-reader-label" data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.modal-screen-reader-label"
/> />
{/* biome-ignore lint/a11y/noStaticElementInteractions: pointer arbitration surface for the result rows; it carries no affordance of its own. */}
<div <div
className={quickStyles.container} className={quickStyles.container}
onMouseMove={handlePointerMove}
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.div" data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.div"
> >
<div <div
@@ -522,6 +497,7 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
> >
{activeSuggestionStatus} {activeSuggestionStatus}
</div> </div>
{results.length === 0 && !hasSearchIntent ? null : (
<Scroller <Scroller
ref={scrollerRef} ref={scrollerRef}
className={quickStyles.scrollerContainer} className={quickStyles.scrollerContainer}
@@ -556,14 +532,14 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
aria-label={i18n._(QUICK_SWITCHER_RESULTS_DESCRIPTOR)} aria-label={i18n._(QUICK_SWITCHER_RESULTS_DESCRIPTOR)}
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.listbox" data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.listbox"
> >
{sections.map((section: QuickSwitcherSection, sidx: number) => { {sections.map((section: QuickSwitcherSection) => {
const sectionHeaderId = section.header ? `${listboxId}-section-${sidx}` : undefined; const sectionHeaderId = section.header ? `${listboxId}-${section.key}` : undefined;
const sectionAriaProps = sectionHeaderId const sectionAriaProps = sectionHeaderId
? ({role: 'group', 'aria-labelledby': sectionHeaderId} as const) ? ({role: 'group', 'aria-labelledby': sectionHeaderId} as const)
: ({role: 'presentation'} as const); : ({role: 'presentation'} as const);
return ( return (
<div <div
key={`section-${sidx}`} key={section.key}
className={quickStyles.section} className={quickStyles.section}
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.div--7" data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.div--7"
{...sectionAriaProps} {...sectionAriaProps}
@@ -582,10 +558,8 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
key={getResultKey(result)} key={getResultKey(result)}
result={result} result={result}
index={index} index={index}
isKeyboardSelected={index === keyboardFocusIndex} isKeyboardSelected={index === selectedIndex}
isHovered={index === hoverIndexForRender}
onHover={handleHover} onHover={handleHover}
onMouseLeave={handleMouseLeave}
onConfirm={handleConfirm} onConfirm={handleConfirm}
optionId={getQuickSwitcherOptionId(listboxId, index)} optionId={getQuickSwitcherOptionId(listboxId, index)}
positionInSet={selectableIndices.indexOf(index) + 1} positionInSet={selectableIndices.indexOf(index) + 1}
@@ -602,6 +576,7 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
</div> </div>
)} )}
</Scroller> </Scroller>
)}
</div> </div>
<div <div
className={quickStyles.footer} className={quickStyles.footer}
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface EscapeIntent {
current: boolean;
}
export function trackEscapeIntentUntilNextTask(intent: EscapeIntent): () => void {
const recordEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') {
return;
}
intent.current = true;
window.setTimeout(() => {
intent.current = false;
}, 0);
};
window.addEventListener('keydown', recordEscape, {capture: true});
return () => {
window.removeEventListener('keydown', recordEscape, {capture: true});
};
}
export function consumeEscapeIntent(intent: EscapeIntent): boolean {
const wasEscape = intent.current;
intent.current = false;
return wasEscape;
}
@@ -8,6 +8,8 @@ import {shouldDisableAutofocusOnMobile} from '@app/features/platform/utils/Autof
import {isTextInputKeyEvent} from '@app/features/platform/utils/IsTextInputKeyEvent'; import {isTextInputKeyEvent} from '@app/features/platform/utils/IsTextInputKeyEvent';
import ReadStates from '@app/features/read_state/state/ReadStates'; import ReadStates from '@app/features/read_state/state/ReadStates';
import * as QuickSwitcherCommands from '@app/features/search/commands/QuickSwitcherCommands'; import * as QuickSwitcherCommands from '@app/features/search/commands/QuickSwitcherCommands';
import QuickSwitcher from '@app/features/search/state/QuickSwitcher';
import {type EscapeIntent, trackEscapeIntentUntilNextTask} from '@app/features/search/state/QuickSwitcherEscapeIntent';
import type { import type {
GroupDMResult, GroupDMResult,
GuildResult, GuildResult,
@@ -38,7 +40,7 @@ import {msg} from '@lingui/core/macro';
import {ArrowRightIcon, HashIcon, HouseIcon, SpeakerHighIcon, StarIcon, UsersIcon} from '@phosphor-icons/react'; import {ArrowRightIcon, HashIcon, HouseIcon, SpeakerHighIcon, StarIcon, UsersIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx'; import {clsx} from 'clsx';
import type React from 'react'; import type React from 'react';
import {useEffect, useLayoutEffect} from 'react'; import {useEffect, useLayoutEffect, useRef} from 'react';
const SEARCH_DESCRIPTOR = msg({ const SEARCH_DESCRIPTOR = msg({
message: 'Search', message: 'Search',
@@ -75,6 +77,7 @@ const UNREAD_DESCRIPTOR = msg({
}); });
export interface QuickSwitcherSection { export interface QuickSwitcherSection {
key: string;
header?: HeaderResult; header?: HeaderResult;
rows: Array<{result: QuickSwitcherExecutableResult; index: number}>; rows: Array<{result: QuickSwitcherExecutableResult; index: number}>;
} }
@@ -415,12 +418,13 @@ export function createSections(results: Array<QuickSwitcherResult>): Array<Quick
let current: QuickSwitcherSection | null = null; let current: QuickSwitcherSection | null = null;
results.forEach((r, index) => { results.forEach((r, index) => {
if (r.type === QuickSwitcherResultTypes.HEADER) { if (r.type === QuickSwitcherResultTypes.HEADER) {
current = {header: r as HeaderResult, rows: []}; const header = r as HeaderResult;
current = {key: `header-${header.id}`, header, rows: []};
acc.push(current); acc.push(current);
return; return;
} }
if (!current) { if (!current) {
current = {rows: []}; current = {key: 'leading', rows: []};
acc.push(current); acc.push(current);
} }
current.rows.push({result: r as QuickSwitcherExecutableResult, index}); current.rows.push({result: r as QuickSwitcherExecutableResult, index});
@@ -488,6 +492,25 @@ export function useQuickSwitcherKeyboardHandling(
}, [isMobile, isOpen, query, inputRef]); }, [isMobile, isOpen, query, inputRef]);
} }
export function dismissQuickSwitcher({isEscape}: {isEscape: boolean}): void {
if (isEscape && QuickSwitcher.query.length > 0) {
QuickSwitcherCommands.search('');
return;
}
QuickSwitcherCommands.hide();
}
export function useQuickSwitcherEscapeIntent(isActive: boolean): EscapeIntent {
const escapeIntentRef = useRef(false);
useEffect(() => {
if (!isActive) {
return;
}
return trackEscapeIntentUntilNextTask(escapeIntentRef);
}, [isActive]);
return escapeIntentRef;
}
export function useQuickSwitcherInputFocus( export function useQuickSwitcherInputFocus(
isOpen: boolean, isOpen: boolean,
isMobile: boolean, isMobile: boolean,
@@ -500,7 +523,7 @@ export function useQuickSwitcherInputFocus(
return; return;
} }
const key = QuickSwitcherCommands.getModalKey(); const key = QuickSwitcherCommands.getModalKey();
LayerManager.addLayer('modal', key, () => QuickSwitcherCommands.hide()); LayerManager.addLayer('modal', key, () => dismissQuickSwitcher({isEscape: true}));
const focusInput = () => { const focusInput = () => {
inputRef?.current?.focus(); inputRef?.current?.focus();
inputRef?.current?.select(); inputRef?.current?.select();