diff --git a/fluxer_app/src/features/search/components/bottomsheets/QuickSwitcherBottomSheet.tsx b/fluxer_app/src/features/search/components/bottomsheets/QuickSwitcherBottomSheet.tsx index 614fce866..8a31c66f5 100644 --- a/fluxer_app/src/features/search/components/bottomsheets/QuickSwitcherBottomSheet.tsx +++ b/fluxer_app/src/features/search/components/bottomsheets/QuickSwitcherBottomSheet.tsx @@ -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 = break; case 'Escape': event.preventDefault(); - QuickSwitcherCommands.hide(); + event.stopPropagation(); + dismissQuickSwitcher({isEscape: true}); break; default: break; @@ -375,6 +377,7 @@ export const QuickSwitcherBottomSheet: React.FC = () => 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 = 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 = } 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 = 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 = {activeSuggestionStatus} - -
- {results.length === 0 ? ( -
-
- {i18n._(NO_MATCHES_FOUND_DESCRIPTOR)} -
-
- {i18n._(TRY_A_DIFFERENT_NAME_OR_USE_PREFIXES_TO_DESCRIPTOR)} -
-
- ) : ( -
- {sections.map((section: QuickSwitcherSection, sidx: number) => ( + {results.length === 0 && !hasSearchIntent ? null : ( + +
+ {results.length === 0 ? ( +
- {section.header && ( -
- {section.header.title} -
- )} -
- {section.rows.map( - ({result, index}: {result: QuickSwitcherExecutableResult; index: number}) => ( - { - rowRefs.current[index] = node; - }} - data-flx="search.quick-switcher-bottom-sheet.result-row" - /> - ), - )} -
+ {i18n._(NO_MATCHES_FOUND_DESCRIPTOR)}
- ))} -
- )} -
-
+
+ {i18n._(TRY_A_DIFFERENT_NAME_OR_USE_PREFIXES_TO_DESCRIPTOR)} +
+
+ ) : ( +
+ {sections.map((section: QuickSwitcherSection) => ( +
+ {section.header && ( +
+ {section.header.title} +
+ )} +
+ {section.rows.map( + ({result, index}: {result: QuickSwitcherExecutableResult; index: number}) => ( + { + rowRefs.current[index] = node; + }} + data-flx="search.quick-switcher-bottom-sheet.result-row" + /> + ), + )} +
+
+ ))} +
+ )} +
+
+ )} {!directMessagesDisabled && (
`${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(null); const scrollerRef = useRef(null); const rowRefs = useRef>([]); - 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) => { 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. */}
{ > {activeSuggestionStatus}
- - {results.length === 0 ? ( -
+ {results.length === 0 && !hasSearchIntent ? null : ( + + {results.length === 0 ? (
- {i18n._(NO_MATCHES_FOUND_DESCRIPTOR)} +
+ {i18n._(NO_MATCHES_FOUND_DESCRIPTOR)} +
+
+ {i18n._(TRY_A_DIFFERENT_NAME_OR_USE_PREFIXES_TO_DESCRIPTOR)} +
+ ) : (
- {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 ( +
+ {section.header && ( +
+ {section.header.title} +
+ )} + {section.rows.map(({result, index}: {result: QuickSwitcherExecutableResult; index: number}) => ( + { + rowRefs.current[index] = node; + }} + data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.result-row" + /> + ))} +
+ ); + })}
-
- ) : ( -
- {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 ( -
- {section.header && ( -
- {section.header.title} -
- )} - {section.rows.map(({result, index}: {result: QuickSwitcherExecutableResult; index: number}) => ( - { - rowRefs.current[index] = node; - }} - data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.result-row" - /> - ))} -
- ); - })} -
- )} -
+ )} + + )}
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; +} diff --git a/fluxer_app/src/features/search/utils/QuickSwitcherModalUtils.tsx b/fluxer_app/src/features/search/utils/QuickSwitcherModalUtils.tsx index 2aa4beaa0..d26a50fbe 100644 --- a/fluxer_app/src/features/search/utils/QuickSwitcherModalUtils.tsx +++ b/fluxer_app/src/features/search/utils/QuickSwitcherModalUtils.tsx @@ -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): Array { 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();