Skip to content

Commit 9371804

Browse files
authored
fix: enhance hotkeys behavior (#750)
* fix: fix clashing handlers for select and arrow keys * fix: enhance search hotkeys * fix: search hotkey hint should not block input from focusing
1 parent de56b67 commit 9371804

File tree

10 files changed

+239
-34
lines changed

10 files changed

+239
-34
lines changed

lib/static/new-ui/components/AdaptiveSelect/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {ChangedDot} from '../ChangedDot';
77
interface AdaptiveSelectProps {
88
currentValue: string[];
99
label: string;
10+
qa?: string;
1011
// Determines whether select should show dot in its compact view
1112
showDot?: boolean;
1213
labelIcon: ReactNode;
@@ -36,6 +37,7 @@ export function AdaptiveSelect(props: AdaptiveSelectProps): ReactNode {
3637
{/* This wrapper is crucial for the tooltip to position correctly */}
3738
<div className={styles.tooltip}>
3839
<Select
40+
qa={props.qa}
3941
ref={selectRef}
4042
renderSelectedOption={(option): ReactElement => option.title ? <span className={styles.selectedOption}>{option.title}</span> : <></>}
4143
className={styles.select}

lib/static/new-ui/components/BrowsersSelect/index.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,16 @@ export function BrowsersSelect(): ReactNode {
113113
const isInitialized = useSelector(getIsInitialized);
114114

115115
const renderControl = ({ref, triggerProps: {onClick, onKeyDown}}: SelectRenderControlProps<HTMLElement>): React.JSX.Element => {
116-
return <IconButton ref={ref as Ref<HTMLButtonElement>} onClick={onClick} onKeyDown={onKeyDown} view={'outlined'} disabled={!isInitialized} icon={<Icon data={PlanetEarth}/>} tooltip={'Filter by browser'} />;
116+
return <IconButton
117+
ref={ref as Ref<HTMLButtonElement>}
118+
onClick={onClick}
119+
onKeyDown={onKeyDown}
120+
view={'outlined'}
121+
disabled={!isInitialized}
122+
icon={<Icon data={PlanetEarth}/>}
123+
tooltip={'Filter by browser'}
124+
qa='browsers-select'
125+
/>;
117126
};
118127

119128
const selected = selectedBrowsers.flatMap(browser => browser.versions.map(version => serializeBrowserData(browser.id, version)));

lib/static/new-ui/components/NameFilter/index.module.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@
2626
flex-direction: row;
2727
padding: 2px 2px 2px 0;
2828
align-items: center;
29+
pointer-events: none;
2930
}
3031

3132
.buttons-wrapper :global(.g-button) {
3233
color: rgb(113, 113, 122);
34+
pointer-events: auto;
3335
}
3436

3537
.buttons-wrapper :global(.g-button):hover {

lib/static/new-ui/components/NameFilter/index.tsx

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, {ChangeEvent, ReactNode, useCallback, useMemo, useRef, useState, useEffect} from 'react';
1+
import React, {ChangeEvent, ReactNode, useCallback, useMemo, useRef, useState, useEffect, forwardRef, useImperativeHandle} from 'react';
22
import {debounce} from 'lodash';
33
import {useDispatch, useSelector} from 'react-redux';
44
import {Hotkey, Icon, TextInput} from '@gravity-ui/uikit';
@@ -12,30 +12,71 @@ import {usePage} from '@/static/new-ui/hooks/usePage';
1212
import {useHotkey} from '@/static/new-ui/hooks/useHotkey';
1313
import {search} from '@/static/modules/search';
1414

15-
export const NameFilter = (): ReactNode => {
15+
export interface NameFilterHandle {
16+
focus: () => void;
17+
}
18+
19+
export interface NameFilterProps {
20+
/** Called when user navigates down out of the input (Enter or ArrowDown at end) */
21+
onNavigateDown?: () => void;
22+
}
23+
24+
export const NameFilter = forwardRef<NameFilterHandle, NameFilterProps>(function NameFilter(props, ref): ReactNode {
1625
const dispatch = useDispatch();
1726
const page = usePage();
1827
const nameFilter = useSelector((state) => state.app[page].nameFilter);
1928
const useRegexFilter = useSelector((state) => state.app[page].useRegexFilter);
2029
const useMatchCaseFilter = useSelector((state) => state.app[page].useMatchCaseFilter);
2130
const [testNameFilter, setNameFilter] = useState(nameFilter);
2231
const [isFocused, setIsFocused] = useState(false);
32+
const [isAllSelected, setIsAllSelected] = useState(false);
2333
const inputRef = useRef<HTMLInputElement>(null);
2434

25-
const focusSearch = useCallback(() => inputRef.current?.focus(), []);
35+
const focusSearch = useCallback(() => {
36+
inputRef.current?.focus();
37+
const length = inputRef.current?.value.length ?? 0;
38+
inputRef.current?.setSelectionRange(length, length);
39+
}, []);
40+
41+
useImperativeHandle(ref, () => ({
42+
focus: focusSearch
43+
}), [focusSearch]);
44+
2645
useHotkey('mod+k', focusSearch, {allowInInput: true});
2746

2847
const onKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>): void => {
48+
const input = inputRef.current;
49+
2950
if (event.key === 'Escape') {
3051
event.preventDefault();
31-
if (testNameFilter) {
32-
setNameFilter('');
33-
search('', useMatchCaseFilter, useRegexFilter, page, false, dispatch);
52+
if (isAllSelected || !testNameFilter) {
53+
input?.blur();
54+
setIsAllSelected(false);
3455
} else {
35-
inputRef.current?.blur();
56+
input?.select();
57+
setIsAllSelected(true);
58+
}
59+
return;
60+
}
61+
62+
setIsAllSelected(false);
63+
64+
if (event.key === 'Enter') {
65+
event.preventDefault();
66+
input?.blur();
67+
props.onNavigateDown?.();
68+
return;
69+
}
70+
71+
if (event.key === 'ArrowDown') {
72+
const cursorAtEnd = input && input.selectionStart === input.value.length && input.selectionEnd === input.value.length;
73+
if (cursorAtEnd) {
74+
event.preventDefault();
75+
input?.blur();
76+
props.onNavigateDown?.();
3677
}
3778
}
38-
}, [testNameFilter, useMatchCaseFilter, useRegexFilter, page, dispatch]);
79+
}, [testNameFilter, isAllSelected, props.onNavigateDown]);
3980

4081
const updateNameFilter = useCallback(debounce(
4182
(text) => {
@@ -153,4 +194,4 @@ export const NameFilter = (): ReactNode => {
153194
</div>
154195
</div>
155196
);
156-
};
197+
});

lib/static/new-ui/components/SideBar/index.tsx

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,60 @@
1-
import React, {ReactNode, Ref} from 'react';
1+
import React, {forwardRef, ReactNode, Ref, useCallback, useImperativeHandle, useRef} from 'react';
22
import {useSelector} from 'react-redux';
33
import classNames from 'classnames';
44
import styles from './index.module.css';
55
import {ErrorHandler} from '@/static/new-ui/features/error-handling/components/ErrorHandling';
66
import {Flex, Text} from '@gravity-ui/uikit';
7-
import {NameFilter} from '../NameFilter';
7+
import {NameFilter, NameFilterHandle} from '../NameFilter';
88
import {BrowsersSelect} from '../BrowsersSelect';
99
import {TabsSelect, TabsSelectItem} from '../TabsSelect';
1010
import {TreeActionsToolbar} from '../TreeActionsToolbar';
1111
import {TreeView, TreeViewProps, TreeViewHandle} from '../TreeView';
1212
import {TreeViewSkeleton} from '@/static/new-ui/components/TreeView/TreeViewSkeleton';
1313
import {UiCard} from '@/static/new-ui/components/Card/UiCard';
1414

15+
export interface SideBarHandle {
16+
focusSearch: () => void;
17+
}
18+
1519
interface SideBarProps extends TreeViewProps {
1620
title: string;
1721
isInitialized: boolean;
1822
onHighlightCurrentTest?: () => void;
1923
treeViewRef: Ref<TreeViewHandle>;
24+
onSelectFirstTreeItem?: () => void;
2025
statusList: TabsSelectItem[];
2126
statusValue: string;
2227
onStatusChange: (value: string) => void;
2328
}
2429

25-
export function SideBar({
30+
export const SideBar = forwardRef<SideBarHandle, SideBarProps>(function SideBar({
2631
title,
2732
isInitialized,
2833
onHighlightCurrentTest,
2934
treeViewRef,
35+
onSelectFirstTreeItem,
3036
statusList,
3137
statusValue,
3238
onStatusChange,
3339
...props
34-
}: SideBarProps): ReactNode {
40+
}, ref): ReactNode {
3541
const isSearchLoading = useSelector((state) => state.app.isSearchLoading);
42+
const nameFilterRef = useRef<NameFilterHandle>(null);
43+
44+
const focusSearch = useCallback(() => {
45+
nameFilterRef.current?.focus();
46+
}, []);
47+
48+
useImperativeHandle(ref, () => ({
49+
focusSearch
50+
}), [focusSearch]);
3651

3752
return (
3853
<UiCard className={classNames(styles.card, styles.treeViewCard)} key='tree-view' qa='suites-tree-card'>
3954
<ErrorHandler.Boundary fallback={<ErrorHandler.FallbackCardCrash recommendedAction={'Try to reload page'}/>}>
4055
<Text variant="header-2" className={styles['card__title']} qa="sidebar-title">{title}</Text>
4156
<Flex gap={2} className={styles['filters-container']}>
42-
<NameFilter />
57+
<NameFilter ref={nameFilterRef} onNavigateDown={onSelectFirstTreeItem} />
4358
<BrowsersSelect/>
4459
</Flex>
4560
<TabsSelect
@@ -62,4 +77,4 @@ export function SideBar({
6277
</ErrorHandler.Boundary>
6378
</UiCard>
6479
);
65-
}
80+
});

lib/static/new-ui/features/suites/components/SortBySelect/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export function SortBySelect(): ReactNode {
8787

8888
return <AdaptiveSelect
8989
label={'Sort by'}
90+
qa='sort-by-select'
9091
labelIcon={<Icon data={currentDirection === SortDirection.Asc ? BarsAscendingAlignLeftArrowUp : BarsDescendingAlignLeftArrowDown} />}
9192
currentValue={[sortByExpressionId, currentDirection]}
9293
autoClose={false}

lib/static/new-ui/features/suites/components/SuitesPage/index.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from '@/static/new-ui/features/suites/selectors';
1313
import {SplitViewLayout} from '@/static/new-ui/components/SplitViewLayout';
1414
import {TreeViewHandle} from '@/static/new-ui/components/TreeView';
15+
import {SideBarHandle} from '@/static/new-ui/components/SideBar';
1516
import {SuiteTitle} from '@/static/new-ui/components/SuiteTitle';
1617
import * as actions from '@/static/modules/actions';
1718
import {getIsInitialized} from '@/static/new-ui/store/selectors';
@@ -115,6 +116,7 @@ export function SuitesPage(): ReactNode {
115116
}, [isInitialized, params]);
116117

117118
const suitesTreeViewRef = useRef<TreeViewHandle>(null);
119+
const sideBarRef = useRef<SideBarHandle>(null);
118120

119121
useEffect(() => {
120122
suitesTreeViewRef?.current?.scrollToId(currentTreeNodeId as string);
@@ -188,10 +190,28 @@ export function SuitesPage(): ReactNode {
188190
}, [currentBrowser, attempt, totalAttempts, currentTreeNodeId, dispatch]);
189191

190192
const goToNextSuite = useCallback(() => onPrevNextSuiteHandler(1), [onPrevNextSuiteHandler]);
191-
const goToPrevSuite = useCallback(() => onPrevNextSuiteHandler(-1), [onPrevNextSuiteHandler]);
193+
const goToPrevSuite = useCallback(() => {
194+
if (currentIndex === 0) {
195+
sideBarRef.current?.focusSearch();
196+
return;
197+
}
198+
onPrevNextSuiteHandler(-1);
199+
}, [onPrevNextSuiteHandler, currentIndex]);
192200
const goToNextAttempt = useCallback(() => onPrevNextAttemptHandler(1), [onPrevNextAttemptHandler]);
193201
const goToPrevAttempt = useCallback(() => onPrevNextAttemptHandler(-1), [onPrevNextAttemptHandler]);
194202

203+
const onSelectFirstResult = useCallback(() => {
204+
if (visibleTreeNodeIds.length > 0) {
205+
const firstTreeNodeId = visibleTreeNodeIds[0];
206+
const firstTreeNode = findTreeNodeById(tree, firstTreeNodeId);
207+
if (firstTreeNode) {
208+
const groupId = getGroupId(firstTreeNode as TreeViewItemData);
209+
dispatch(actions.setCurrentTreeNode({treeNodeId: firstTreeNodeId, browserId: firstTreeNode.entityId, groupId}));
210+
suitesTreeViewRef?.current?.scrollToId(firstTreeNodeId);
211+
}
212+
}
213+
}, [visibleTreeNodeIds, tree, dispatch]);
214+
195215
useHotkey('ArrowDown', goToNextSuite);
196216
useHotkey('ArrowUp', goToPrevSuite);
197217
useHotkey('ArrowRight', goToNextAttempt);
@@ -262,10 +282,12 @@ export function SuitesPage(): ReactNode {
262282
<div className={styles.container}>
263283
<SplitViewLayout sizes={sectionSizes} onSizesChange={onSectionSizesChange}>
264284
<SideBar
285+
ref={sideBarRef}
265286
title="Suites"
266287
onHighlightCurrentTest={onHighlightCurrentTest}
267288
isInitialized={isInitialized}
268289
treeViewRef={suitesTreeViewRef}
290+
onSelectFirstTreeItem={onSelectFirstResult}
269291
treeData={treeData}
270292
treeViewExpandedById={treeViewExpandedById}
271293
currentTreeNodeId={currentTreeNodeId}

lib/static/new-ui/features/visual-checks/components/VisualChecksPage/index.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {VisualChecksStickyHeader} from './VisualChecksStickyHeader';
1919
import {ErrorHandler} from '../../../error-handling/components/ErrorHandling';
2020
import * as actions from '@/static/modules/actions';
2121
import {changeTestRetry, visualChecksPageSetCurrentNamedImage} from '@/static/modules/actions';
22-
import {SideBar} from '@/static/new-ui/components/SideBar';
22+
import {SideBar, SideBarHandle} from '@/static/new-ui/components/SideBar';
2323
import {getCurrentImageSuiteHash, getVisualChecksViewMode, getVisualTreeViewData} from './selectors';
2424
import {TreeViewHandle} from '@/static/new-ui/components/TreeView';
2525
import {TreeViewItemData} from '@/static/new-ui/features/suites/components/SuitesPage/types';
@@ -64,6 +64,7 @@ export function VisualChecksPage(): ReactNode {
6464

6565
const treeData = useSelector(getVisualTreeViewData);
6666
const suitesTreeViewRef = useRef<TreeViewHandle>(null);
67+
const sideBarRef = useRef<SideBarHandle>(null);
6768

6869
useEffect(() => {
6970
suitesTreeViewRef?.current?.scrollToId(currentTreeNodeId as string);
@@ -110,10 +111,25 @@ export function VisualChecksPage(): ReactNode {
110111
}, [currentBrowser, attempt, totalAttempts, dispatch]);
111112

112113
const goToNextImage = useCallback(() => onPrevNextImageHandler(1), [onPrevNextImageHandler]);
113-
const goToPrevImage = useCallback(() => onPrevNextImageHandler(-1), [onPrevNextImageHandler]);
114+
const goToPrevImage = useCallback(() => {
115+
if (currentNamedImageIndex === 0) {
116+
sideBarRef.current?.focusSearch();
117+
return;
118+
}
119+
onPrevNextImageHandler(-1);
120+
}, [onPrevNextImageHandler, currentNamedImageIndex]);
114121
const goToNextAttempt = useCallback(() => onPrevNextAttemptHandler(1), [onPrevNextAttemptHandler]);
115122
const goToPrevAttempt = useCallback(() => onPrevNextAttemptHandler(-1), [onPrevNextAttemptHandler]);
116123

124+
const onSelectFirstResult = useCallback(() => {
125+
if (treeData.tree.length > 0) {
126+
const firstItem = treeData.tree[0]?.data;
127+
if (firstItem) {
128+
onImageChange(firstItem);
129+
}
130+
}
131+
}, [treeData.tree, onImageChange]);
132+
117133
useHotkey('ArrowDown', goToNextImage);
118134
useHotkey('ArrowUp', goToPrevImage);
119135
useHotkey('ArrowRight', goToNextAttempt);
@@ -208,9 +224,11 @@ export function VisualChecksPage(): ReactNode {
208224
<div className={styles.container}>
209225
<SplitViewLayout sizes={sectionSizes} onSizesChange={onSectionSizesChange}>
210226
<SideBar
227+
ref={sideBarRef}
211228
title="Visual Checks"
212229
isInitialized={isInitialized}
213230
treeViewRef={suitesTreeViewRef}
231+
onSelectFirstTreeItem={onSelectFirstResult}
214232
treeData={treeData}
215233
treeViewExpandedById={{}}
216234
currentTreeNodeId={currentTreeNodeId}

lib/static/new-ui/hooks/useHotkey.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ function isInputFocused(): boolean {
8383
const activeElement = document.activeElement;
8484
return activeElement instanceof HTMLInputElement ||
8585
activeElement instanceof HTMLTextAreaElement ||
86-
activeElement?.getAttribute('contenteditable') === 'true';
86+
activeElement?.getAttribute('contenteditable') === 'true'
87+
|| document.querySelector('[data-floating-ui-status="open"]') !== null;
8788
}
8889

8990
function matchesModifiers(event: KeyboardEvent, parsed: ParsedKey): boolean {
@@ -153,13 +154,15 @@ export function useHotkey(
153154
}
154155

155156
event.preventDefault();
157+
event.stopPropagation();
158+
(document.activeElement as HTMLElement)?.blur();
156159
callback();
157160
}, [keyString, callback, enabled, allowInInput]);
158161

159162
useEffect(() => {
160-
document.addEventListener('keydown', handleKeyDown);
163+
document.addEventListener('keydown', handleKeyDown, true);
161164
return () => {
162-
document.removeEventListener('keydown', handleKeyDown);
165+
document.removeEventListener('keydown', handleKeyDown, true);
163166
};
164167
}, [handleKeyDown]);
165168
}

0 commit comments

Comments
 (0)