mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-03 05:10:25 +03:00
fix(app): stop the pointer hijacking quick switcher selection (#1955)
This commit is contained in:
+96
-80
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user