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

This commit is contained in:
Hampus
2026-08-24 12:52:36 +02:00
committed by GitHub
parent af5bee9149
commit 317b4e26c0
5 changed files with 270 additions and 237 deletions
@@ -12,6 +12,7 @@ import QuickSwitcher from '@app/features/search/state/QuickSwitcher';
import type {QuickSwitcherExecutableResult, QuickSwitcherResult} from '@app/features/search/state/QuickSwitcherTypes';
import {
createSections,
dismissQuickSwitcher,
getQuickSwitcherResultAccessibilityMetadata,
getQuickSwitcherTabs,
getResultKey,
@@ -313,7 +314,8 @@ export const QuickSwitcherBottomSheet: React.FC<QuickSwitcherBottomSheetProps> =
break;
case 'Escape':
event.preventDefault();
QuickSwitcherCommands.hide();
event.stopPropagation();
dismissQuickSwitcher({isEscape: true});
break;
default:
break;
@@ -375,6 +377,7 @@ export const QuickSwitcherBottomSheet: React.FC<QuickSwitcherBottomSheetProps> =
() => results.filter((result) => result.type !== QuickSwitcherResultTypes.HEADER).length,
[results],
);
const hasSearchIntent = query.trim().length > 0;
const activeDescendant =
isSearchTab && keyboardFocusIndex >= 0 && results[keyboardFocusIndex]?.type !== QuickSwitcherResultTypes.HEADER
? getQuickSwitcherOptionId(listboxId, keyboardFocusIndex)
@@ -382,7 +385,9 @@ export const QuickSwitcherBottomSheet: React.FC<QuickSwitcherBottomSheetProps> =
const resultStatus = QuickSwitcher.isLoadingMemberResults
? i18n._(SEARCHING_PEOPLE_DESCRIPTOR)
: resultCount === 0
? i18n._(NO_MATCHES_FOUND_DESCRIPTOR)
? hasSearchIntent
? i18n._(NO_MATCHES_FOUND_DESCRIPTOR)
: ''
: resultCount === 1
? i18n._(MESSAGE_1_RESULT_AVAILABLE_DESCRIPTOR)
: i18n._(RESULTS_AVAILABLE_DESCRIPTOR, {resultCount});
@@ -392,7 +397,7 @@ export const QuickSwitcherBottomSheet: React.FC<QuickSwitcherBottomSheetProps> =
}
const result = results[keyboardFocusIndex];
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 trimmedQuery = query.trim();
@@ -404,7 +409,16 @@ export const QuickSwitcherBottomSheet: React.FC<QuickSwitcherBottomSheetProps> =
return selectedOptionPosition > 0
? i18n._(AUTOCOMPLETE_SUGGESTION_OF_DESCRIPTOR, {label, selectedOptionPosition, resultCount})
: 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') => {
setActiveTab(tab);
}, []);
@@ -526,85 +540,87 @@ export const QuickSwitcherBottomSheet: React.FC<QuickSwitcherBottomSheetProps> =
{activeSuggestionStatus}
</div>
</div>
<Scroller
ref={scrollerRef}
className={styles.scroller}
key="quick_switcher-sheet-scroller"
data-flx="search.quick-switcher-bottom-sheet.scroller"
>
<div className={styles.scrollContent} data-flx="search.quick-switcher-bottom-sheet.scroll-content">
{results.length === 0 ? (
<div className={styles.emptyState} data-flx="search.quick-switcher-bottom-sheet.empty-state">
<div
className={styles.emptyStateTitle}
data-flx="search.quick-switcher-bottom-sheet.empty-state-title"
>
{i18n._(NO_MATCHES_FOUND_DESCRIPTOR)}
</div>
<div
className={styles.emptyStateHint}
data-flx="search.quick-switcher-bottom-sheet.empty-state-hint"
>
{i18n._(TRY_A_DIFFERENT_NAME_OR_USE_PREFIXES_TO_DESCRIPTOR)}
</div>
</div>
) : (
<div
id={listboxId}
role="listbox"
aria-label={i18n._(QUICK_SWITCHER_RESULTS_DESCRIPTOR)}
data-flx="search.quick-switcher-bottom-sheet.listbox"
>
{sections.map((section: QuickSwitcherSection, sidx: number) => (
{results.length === 0 && !hasSearchIntent ? null : (
<Scroller
ref={scrollerRef}
className={styles.scroller}
key="quick_switcher-sheet-scroller"
data-flx="search.quick-switcher-bottom-sheet.scroller"
>
<div className={styles.scrollContent} data-flx="search.quick-switcher-bottom-sheet.scroll-content">
{results.length === 0 ? (
<div className={styles.emptyState} data-flx="search.quick-switcher-bottom-sheet.empty-state">
<div
key={`section-${sidx}`}
className={styles.section}
role={section.header ? 'group' : 'presentation'}
data-flx="search.quick-switcher-bottom-sheet.section"
{...(section.header ? {'aria-labelledby': `${listboxId}-section-${sidx}`} : {})}
className={styles.emptyStateTitle}
data-flx="search.quick-switcher-bottom-sheet.empty-state-title"
>
{section.header && (
<div
id={`${listboxId}-section-${sidx}`}
className={styles.sectionHeader}
data-flx="search.quick-switcher-bottom-sheet.section-header"
>
{section.header.title}
</div>
)}
<div
className={styles.sectionList}
role="presentation"
data-flx="search.quick-switcher-bottom-sheet.section-list"
>
{section.rows.map(
({result, index}: {result: QuickSwitcherExecutableResult; index: number}) => (
<ResultRow
key={getResultKey(result)}
result={result}
index={index}
isKeyboardSelected={index === keyboardFocusIndex}
isHovered={index === hoverIndexForRender}
onHover={handleHover}
onMouseLeave={handleMouseLeave}
onConfirm={handleConfirm}
optionId={getQuickSwitcherOptionId(listboxId, index)}
positionInSet={selectableIndices.indexOf(index) + 1}
setSize={resultCount}
innerRef={(node) => {
rowRefs.current[index] = node;
}}
data-flx="search.quick-switcher-bottom-sheet.result-row"
/>
),
)}
</div>
{i18n._(NO_MATCHES_FOUND_DESCRIPTOR)}
</div>
))}
</div>
)}
</div>
</Scroller>
<div
className={styles.emptyStateHint}
data-flx="search.quick-switcher-bottom-sheet.empty-state-hint"
>
{i18n._(TRY_A_DIFFERENT_NAME_OR_USE_PREFIXES_TO_DESCRIPTOR)}
</div>
</div>
) : (
<div
id={listboxId}
role="listbox"
aria-label={i18n._(QUICK_SWITCHER_RESULTS_DESCRIPTOR)}
data-flx="search.quick-switcher-bottom-sheet.listbox"
>
{sections.map((section: QuickSwitcherSection) => (
<div
key={section.key}
className={styles.section}
role={section.header ? 'group' : 'presentation'}
data-flx="search.quick-switcher-bottom-sheet.section"
{...(section.header ? {'aria-labelledby': `${listboxId}-${section.key}`} : {})}
>
{section.header && (
<div
id={`${listboxId}-${section.key}`}
className={styles.sectionHeader}
data-flx="search.quick-switcher-bottom-sheet.section-header"
>
{section.header.title}
</div>
)}
<div
className={styles.sectionList}
role="presentation"
data-flx="search.quick-switcher-bottom-sheet.section-list"
>
{section.rows.map(
({result, index}: {result: QuickSwitcherExecutableResult; index: number}) => (
<ResultRow
key={getResultKey(result)}
result={result}
index={index}
isKeyboardSelected={index === keyboardFocusIndex}
isHovered={index === hoverIndexForRender}
onHover={handleHover}
onMouseLeave={handleMouseLeave}
onConfirm={handleConfirm}
optionId={getQuickSwitcherOptionId(listboxId, index)}
positionInSet={selectableIndices.indexOf(index) + 1}
setSize={resultCount}
innerRef={(node) => {
rowRefs.current[index] = node;
}}
data-flx="search.quick-switcher-bottom-sheet.result-row"
/>
),
)}
</div>
</div>
))}
</div>
)}
</div>
</Scroller>
)}
</div>
{!directMessagesDisabled && (
<div
@@ -14,18 +14,10 @@
color: var(--qs-text-primary, var(--text-primary));
}
.option:hover {
background-color: var(--qs-row-hover, var(--background-modifier-accent));
}
.optionActive {
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 {
background-color: var(--qs-row-hover-light, var(--background-modifier-hover));
}
@@ -1,16 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
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 {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils';
import * as QuickSwitcherCommands from '@app/features/search/commands/QuickSwitcherCommands';
import {QuickSwitcherBottomSheet} from '@app/features/search/components/bottomsheets/QuickSwitcherBottomSheet';
import quickStyles from '@app/features/search/components/quick_switcher/QuickSwitcherModal.module.css';
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 {
createSections,
dismissQuickSwitcher,
getQuickSwitcherResultAccessibilityMetadata,
getResultKey,
getViewContext,
@@ -18,6 +19,7 @@ import {
PREFIX_HINTS,
type QuickSwitcherSection,
renderIcon,
useQuickSwitcherEscapeIntent,
useQuickSwitcherInputFocus,
useQuickSwitcherKeyboardHandling,
} from '@app/features/search/utils/QuickSwitcherModalUtils';
@@ -34,7 +36,7 @@ import {Trans, useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
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({
message: 'No autocomplete suggestion',
@@ -99,6 +101,7 @@ const QUICK_SWITCHER_RESULTS_DESCRIPTOR = msg({
message: 'Quick switcher results',
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}`;
type PrefixHint = (typeof PREFIX_HINTS)[number];
const PrefixHintToken: React.FC<{
@@ -121,9 +124,7 @@ const ResultRow = observer(
result,
index,
isKeyboardSelected,
isHovered,
onHover,
onMouseLeave,
onConfirm,
optionId,
positionInSet,
@@ -133,9 +134,7 @@ const ResultRow = observer(
result: QuickSwitcherResult;
index: number;
isKeyboardSelected: boolean;
isHovered: boolean;
onHover: (index: number) => void;
onMouseLeave: () => void;
onConfirm: (result: QuickSwitcherExecutableResult) => void;
optionId: string;
positionInSet: number;
@@ -158,7 +157,7 @@ const ResultRow = observer(
const resultMetadata = getQuickSwitcherResultAccessibilityMetadata(executableResult, i18n);
const {guildName, isChannel, isUser, label: optionLabel, mentionCount, subtext, unreadCount} = resultMetadata;
const hasUnread = unreadCount > 0 || mentionCount > 0;
const isActive = isKeyboardSelected || isHovered;
const isActive = isKeyboardSelected;
const isHighlight =
hasUnread &&
(executableResult.type === QuickSwitcherResultTypes.TEXT_CHANNEL ||
@@ -197,7 +196,6 @@ const ResultRow = observer(
className={clsx(quickStyles.option, isActive && quickStyles.optionActive)}
ref={innerRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={onMouseLeave}
onMouseDown={(event) => {
if (event.button === 0) event.preventDefault();
}}
@@ -290,56 +288,20 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
const inputRef = useRef<HTMLInputElement>(null);
const scrollerRef = useRef<ScrollerHandle>(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 inputId = `${quickSwitcherId}-input`;
const listboxId = `${quickSwitcherId}-results`;
const statusId = `${quickSwitcherId}-status`;
const suggestionStatusId = `${quickSwitcherId}-suggestion-status`;
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) {
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);
useQuickSwitcherInputFocus(isOpen, isMobile, undefined, inputRef);
useEffect(() => {
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 escapeIntentRef = useQuickSwitcherEscapeIntent(isOpen && !isMobile);
const handleChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
QuickSwitcherCommands.search(event.target.value);
}, []);
@@ -347,22 +309,18 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
if (isIMEComposing(event)) {
return;
}
setIsKeyboardNavigating(true);
switch (event.key) {
case 'ArrowDown':
case 'ArrowUp':
event.preventDefault();
event.stopPropagation();
shouldScrollToSelection.current = true;
QuickSwitcherCommands.moveSelection(event.key === 'ArrowDown' ? 'down' : 'up');
break;
case 'Enter':
event.preventDefault();
await QuickSwitcherCommands.confirmSelection();
break;
case 'Escape':
event.preventDefault();
QuickSwitcherCommands.hide();
break;
default:
break;
}
@@ -371,29 +329,41 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
if (LayerManager.hasType('contextmenu')) {
return;
}
QuickSwitcherCommands.hide();
dismissQuickSwitcher({isEscape: consumeEscapeIntent(escapeIntentRef)});
}, [escapeIntentRef]);
const handlePointerMove = useCallback(() => {
setIsKeyboardNavigating(false);
}, []);
const handleHover = useCallback(
(index: number) => {
shouldScrollToSelection.current = false;
handleHoverIndex(index);
if (isKeyboardNavigating) {
return;
}
QuickSwitcherCommands.select(index);
},
[handleHoverIndex],
[isKeyboardNavigating],
);
const handleConfirm = useCallback((result: QuickSwitcherExecutableResult) => {
void QuickSwitcherCommands.switchTo(result);
}, []);
useEffect(() => {
if (!shouldScrollToSelection.current || keyboardFocusIndex < 0) {
shouldScrollToSelection.current = false;
const scroller = scrollerRef.current;
if (!scroller) {
return;
}
shouldScrollToSelection.current = false;
const node = rowRefs.current[keyboardFocusIndex];
if (node) {
scrollerRef.current?.revealElement({node: node as HTMLElement, padding: 32});
if (previousQueryRef.current !== query) {
previousQueryRef.current = query;
scroller.scrollTo({to: 0});
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 selectableIndices = useMemo(
() =>
@@ -405,19 +375,20 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
}, []),
[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(
() => results.filter((result) => result.type !== QuickSwitcherResultTypes.HEADER).length,
[results],
);
const activeDescendant =
keyboardFocusIndex >= 0 && results[keyboardFocusIndex]?.type !== QuickSwitcherResultTypes.HEADER
? getQuickSwitcherOptionId(listboxId, keyboardFocusIndex)
selectedIndex >= 0 && results[selectedIndex]?.type !== QuickSwitcherResultTypes.HEADER
? getQuickSwitcherOptionId(listboxId, selectedIndex)
: undefined;
const activeSuggestionStatus = useMemo(() => {
const result = results[keyboardFocusIndex];
const result = results[selectedIndex];
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 trimmedQuery = query.trim();
@@ -429,11 +400,13 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
return selectedOptionPosition > 0
? i18n._(AUTOCOMPLETE_SUGGESTION_OF_DESCRIPTOR, {label, selectedOptionPosition, resultCount})
: i18n._(AUTOCOMPLETE_SUGGESTION_DESCRIPTOR, {label});
}, [i18n.locale, keyboardFocusIndex, query, resultCount, results, selectedOptionPosition]);
}, [hasSearchIntent, i18n.locale, query, resultCount, results, selectedIndex, selectedOptionPosition]);
const resultStatus = QuickSwitcher.isLoadingMemberResults
? i18n._(SEARCHING_PEOPLE_DESCRIPTOR)
: resultCount === 0
? i18n._(NO_MATCHES_FOUND_DESCRIPTOR)
? hasSearchIntent
? i18n._(NO_MATCHES_FOUND_DESCRIPTOR)
: ''
: resultCount === 1
? i18n._(MESSAGE_1_RESULT_AVAILABLE_DESCRIPTOR)
: i18n._(RESULTS_AVAILABLE_DESCRIPTOR, {resultCount});
@@ -455,7 +428,7 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
className={quickStyles.modalRoot}
onClose={handleClose}
initialFocusRef={inputRef}
transitionPreset="instant"
transitionPreset="quick"
data-quick-switcher-modal="true"
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)}
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
className={quickStyles.container}
onMouseMove={handlePointerMove}
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.div"
>
<div
@@ -522,86 +497,86 @@ const QuickSwitcherModalComponent: React.FC = observer(() => {
>
{activeSuggestionStatus}
</div>
<Scroller
ref={scrollerRef}
className={quickStyles.scrollerContainer}
overflow="scroll"
key="quick_switcher-desktop-scroller"
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.scroller"
>
{results.length === 0 ? (
<div
className={quickStyles.emptyState}
role="status"
aria-live="polite"
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.status--3"
>
{results.length === 0 && !hasSearchIntent ? null : (
<Scroller
ref={scrollerRef}
className={quickStyles.scrollerContainer}
overflow="scroll"
key="quick_switcher-desktop-scroller"
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.scroller"
>
{results.length === 0 ? (
<div
className={quickStyles.emptyStateTitle}
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.div--5"
className={quickStyles.emptyState}
role="status"
aria-live="polite"
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.status--3"
>
{i18n._(NO_MATCHES_FOUND_DESCRIPTOR)}
<div
className={quickStyles.emptyStateTitle}
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.div--5"
>
{i18n._(NO_MATCHES_FOUND_DESCRIPTOR)}
</div>
<div
className={quickStyles.emptyStateHint}
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.div--6"
>
{i18n._(TRY_A_DIFFERENT_NAME_OR_USE_PREFIXES_TO_DESCRIPTOR)}
</div>
</div>
) : (
<div
className={quickStyles.emptyStateHint}
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.div--6"
id={listboxId}
role="listbox"
aria-label={i18n._(QUICK_SWITCHER_RESULTS_DESCRIPTOR)}
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.listbox"
>
{i18n._(TRY_A_DIFFERENT_NAME_OR_USE_PREFIXES_TO_DESCRIPTOR)}
{sections.map((section: QuickSwitcherSection) => {
const sectionHeaderId = section.header ? `${listboxId}-${section.key}` : undefined;
const sectionAriaProps = sectionHeaderId
? ({role: 'group', 'aria-labelledby': sectionHeaderId} as const)
: ({role: 'presentation'} as const);
return (
<div
key={section.key}
className={quickStyles.section}
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.div--7"
{...sectionAriaProps}
>
{section.header && (
<div
id={sectionHeaderId}
className={quickStyles.sectionHeader}
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.div--8"
>
{section.header.title}
</div>
)}
{section.rows.map(({result, index}: {result: QuickSwitcherExecutableResult; index: number}) => (
<ResultRow
key={getResultKey(result)}
result={result}
index={index}
isKeyboardSelected={index === selectedIndex}
onHover={handleHover}
onConfirm={handleConfirm}
optionId={getQuickSwitcherOptionId(listboxId, index)}
positionInSet={selectableIndices.indexOf(index) + 1}
setSize={resultCount}
innerRef={(node) => {
rowRefs.current[index] = node;
}}
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.result-row"
/>
))}
</div>
);
})}
</div>
</div>
) : (
<div
id={listboxId}
role="listbox"
aria-label={i18n._(QUICK_SWITCHER_RESULTS_DESCRIPTOR)}
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.listbox"
>
{sections.map((section: QuickSwitcherSection, sidx: number) => {
const sectionHeaderId = section.header ? `${listboxId}-section-${sidx}` : undefined;
const sectionAriaProps = sectionHeaderId
? ({role: 'group', 'aria-labelledby': sectionHeaderId} as const)
: ({role: 'presentation'} as const);
return (
<div
key={`section-${sidx}`}
className={quickStyles.section}
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.div--7"
{...sectionAriaProps}
>
{section.header && (
<div
id={sectionHeaderId}
className={quickStyles.sectionHeader}
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.div--8"
>
{section.header.title}
</div>
)}
{section.rows.map(({result, index}: {result: QuickSwitcherExecutableResult; index: number}) => (
<ResultRow
key={getResultKey(result)}
result={result}
index={index}
isKeyboardSelected={index === keyboardFocusIndex}
isHovered={index === hoverIndexForRender}
onHover={handleHover}
onMouseLeave={handleMouseLeave}
onConfirm={handleConfirm}
optionId={getQuickSwitcherOptionId(listboxId, index)}
positionInSet={selectableIndices.indexOf(index) + 1}
setSize={resultCount}
innerRef={(node) => {
rowRefs.current[index] = node;
}}
data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.result-row"
/>
))}
</div>
);
})}
</div>
)}
</Scroller>
)}
</Scroller>
)}
</div>
<div
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 ReadStates from '@app/features/read_state/state/ReadStates';
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 {
GroupDMResult,
GuildResult,
@@ -38,7 +40,7 @@ import {msg} from '@lingui/core/macro';
import {ArrowRightIcon, HashIcon, HouseIcon, SpeakerHighIcon, StarIcon, UsersIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import type React from 'react';
import {useEffect, useLayoutEffect} from 'react';
import {useEffect, useLayoutEffect, useRef} from 'react';
const SEARCH_DESCRIPTOR = msg({
message: 'Search',
@@ -75,6 +77,7 @@ const UNREAD_DESCRIPTOR = msg({
});
export interface QuickSwitcherSection {
key: string;
header?: HeaderResult;
rows: Array<{result: QuickSwitcherExecutableResult; index: number}>;
}
@@ -415,12 +418,13 @@ export function createSections(results: Array<QuickSwitcherResult>): Array<Quick
let current: QuickSwitcherSection | null = null;
results.forEach((r, index) => {
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);
return;
}
if (!current) {
current = {rows: []};
current = {key: 'leading', rows: []};
acc.push(current);
}
current.rows.push({result: r as QuickSwitcherExecutableResult, index});
@@ -488,6 +492,25 @@ export function useQuickSwitcherKeyboardHandling(
}, [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(
isOpen: boolean,
isMobile: boolean,
@@ -500,7 +523,7 @@ export function useQuickSwitcherInputFocus(
return;
}
const key = QuickSwitcherCommands.getModalKey();
LayerManager.addLayer('modal', key, () => QuickSwitcherCommands.hide());
LayerManager.addLayer('modal', key, () => dismissQuickSwitcher({isEscape: true}));
const focusInput = () => {
inputRef?.current?.focus();
inputRef?.current?.select();