Skip to content

Commit 19f2535

Browse files
authored
fix(compass-components): Keep the focus in the grid item that received the event (#2920)
* fix(compass-components): Keep the focus in the grid item that received the event * chore(saved-aggregations-queries, databases-collections): Keep active item index on blur
1 parent 36a0955 commit 19f2535

File tree

8 files changed

+174
-102
lines changed

8 files changed

+174
-102
lines changed

packages/compass-components/src/components/virtual-grid.tsx

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,7 @@ import React, {
99
import { css, cx } from '@leafygreen-ui/emotion';
1010
import { FixedSizeList } from 'react-window';
1111
import { useDOMRect } from '../hooks/use-dom-rect';
12-
import {
13-
useVirtualGridArrowNavigation,
14-
useVirtualRovingTabIndex,
15-
} from '../hooks/use-virtual-grid';
12+
import { useVirtualGridArrowNavigation } from '../hooks/use-virtual-grid';
1613
import { mergeProps } from '../utils/merge-props';
1714

1815
type RenderItem = React.FunctionComponent<
@@ -49,6 +46,10 @@ type VirtualGridProps = {
4946
* correctly
5047
*/
5148
renderItem: RenderItem;
49+
/**
50+
* Custom grid item key (default is item index)
51+
*/
52+
itemKey?: (index: number) => React.Key | null | undefined;
5253
/**
5354
* Header content height
5455
*/
@@ -77,7 +78,11 @@ type VirtualGridProps = {
7778
cell?: string;
7879
};
7980

80-
itemKey?: (index: number) => React.Key | null | undefined;
81+
/**
82+
* Set to `false` of you want the last focused item to be preserved between
83+
* focus / blur (default: true)
84+
*/
85+
resetActiveItemOnBlur?: boolean;
8186
};
8287

8388
const GridContext = createContext<
@@ -130,10 +135,10 @@ const GridWithHeader = forwardRef<
130135
}}
131136
{...props}
132137
>
133-
<div className={classNames?.header}>
138+
<div style={{ height: headerHeight }} className={classNames?.header}>
134139
{React.createElement(renderHeader, {})}
135140
</div>
136-
<div {...gridProps}>
141+
<div style={{ height: style.height }} {...gridProps}>
137142
{itemsCount === 0 && renderEmptyList
138143
? React.createElement(renderEmptyList, {})
139144
: children}
@@ -245,6 +250,7 @@ export const VirtualGrid = forwardRef<
245250
overscanCount = 3,
246251
classNames,
247252
itemKey,
253+
resetActiveItemOnBlur,
248254
...containerProps
249255
},
250256
ref
@@ -269,13 +275,10 @@ export const VirtualGrid = forwardRef<
269275
itemsCount,
270276
colCount,
271277
rowCount,
278+
onFocusMove,
279+
resetActiveItemOnBlur,
272280
});
273281

274-
const rovingFocusProps = useVirtualRovingTabIndex<HTMLDivElement>({
275-
currentTabbable,
276-
onFocusMove,
277-
});
278-
279282
const gridContainerProps = mergeProps(
280283
{ ref, className: cx(container, classNames?.container) },
281284
containerProps,
@@ -299,8 +302,7 @@ export const VirtualGrid = forwardRef<
299302
'aria-rowcount': rowCount,
300303
className: cx(grid, classNames?.grid),
301304
},
302-
navigationProps,
303-
rovingFocusProps
305+
navigationProps
304306
),
305307
itemKey,
306308
renderEmptyList,

packages/compass-components/src/hooks/use-default-action.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,6 @@ import { useCallback } from 'react';
1111
export function useDefaultAction<T>(
1212
onDefaultAction: (evt: React.KeyboardEvent<T> | React.MouseEvent<T>) => void
1313
): React.HTMLAttributes<T> {
14-
// Prevent event from possibly causing bubbled focus on parent element, if
15-
// something is interacting with this component using mouse, we want to
16-
// prevent anything from bubbling
17-
const onMouseDown = useCallback((evt: React.MouseEvent<T>) => {
18-
evt.preventDefault();
19-
evt.stopPropagation();
20-
}, []);
21-
2214
const onClick = useCallback(
2315
(evt: React.MouseEvent<T>) => {
2416
evt.stopPropagation();
@@ -42,5 +34,5 @@ export function useDefaultAction<T>(
4234
[onDefaultAction]
4335
);
4436

45-
return { onMouseDown, onClick, onKeyDown };
37+
return { onClick, onKeyDown };
4638
}

packages/compass-components/src/hooks/use-virtual-grid.test.tsx

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
1-
/* eslint-disable react/prop-types */
21
import React from 'react';
32
import { render, screen, cleanup } from '@testing-library/react';
43
import userEvent from '@testing-library/user-event';
54
import { expect } from 'chai';
6-
import { mergeProps } from '../utils/merge-props';
7-
import {
8-
useVirtualGridArrowNavigation,
9-
useVirtualRovingTabIndex,
10-
} from './use-virtual-grid';
5+
import { useVirtualGridArrowNavigation } from './use-virtual-grid';
116

127
const TestGrid: React.FunctionComponent<{
138
rowCount?: number;
@@ -28,17 +23,11 @@ const TestGrid: React.FunctionComponent<{
2823
colCount,
2924
itemsCount: rowCount * colCount,
3025
defaultCurrentTabbable,
26+
onFocusMove,
3127
});
3228

33-
const rovingTabIndexProps = useVirtualRovingTabIndex<HTMLDivElement>({
34-
currentTabbable,
35-
onFocusMove,
36-
});
37-
38-
const props = mergeProps(arrowNavigationProps, rovingTabIndexProps);
39-
4029
return (
41-
<div role="grid" aria-rowcount={rowCount} {...props}>
30+
<div role="grid" aria-rowcount={rowCount} {...arrowNavigationProps}>
4231
{Array.from({ length: rowCount }, (_, row) => (
4332
<div key={row} role="row" aria-rowindex={row + 1}>
4433
{Array.from({ length: colCount }, (_, col) => {
@@ -258,4 +247,11 @@ describe('virtual grid keyboard navigation', function () {
258247
expect(screen.getByText('5-3')).to.eq(document.activeElement);
259248
});
260249
});
250+
251+
it('should keep focus on the element that was interacted with', function () {
252+
render(<TestGrid></TestGrid>);
253+
expect(document.body).to.eq(document.activeElement);
254+
userEvent.click(screen.getByText('2-3'));
255+
expect(screen.getByText('2-3')).to.eq(document.activeElement);
256+
});
261257
});

packages/compass-components/src/hooks/use-virtual-grid.ts

Lines changed: 126 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,43 @@
1+
import type React from 'react';
12
import { useRef, useState, useEffect, useCallback } from 'react';
2-
import { useFocusState, FocusState } from './use-focus-hover';
33

4+
function closest(
5+
node: HTMLElement | null,
6+
cond: ((node: HTMLElement) => boolean) | string
7+
): HTMLElement | null {
8+
if (typeof cond === 'string') {
9+
return node?.closest(cond) ?? null;
10+
}
11+
12+
let parent: HTMLElement | null = node;
13+
while (parent) {
14+
if (cond(parent)) {
15+
return parent;
16+
}
17+
parent = parent.parentElement;
18+
}
19+
return null;
20+
}
21+
22+
function vgridItemSelector(idx?: number): string {
23+
return idx ? `[data-vlist-item-idx="${idx}"]` : '[data-vlist-item-idx]';
24+
}
25+
26+
function getItemIndex(node: HTMLElement): number {
27+
if (!node.dataset.vlistItemIdx) {
28+
throw new Error('Trying to get vgrid item index from an non-item element');
29+
}
30+
return Number(node.dataset.vlistItemIdx);
31+
}
32+
33+
/**
34+
* Hook that adds support for the grid keyboard navigation while handling the
35+
* focus using the roving tab index
36+
*
37+
* {@link https://www.w3.org/TR/wai-aria-1.1/#grid}
38+
* {@link https://www.w3.org/TR/wai-aria-1.1/#gridcell}
39+
* {@link https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_roving_tabindex}
40+
*/
441
export function useVirtualGridArrowNavigation<
542
T extends HTMLElement = HTMLElement
643
>({
@@ -10,25 +47,78 @@ export function useVirtualGridArrowNavigation<
1047
resetActiveItemOnBlur = true,
1148
pageSize = 3,
1249
defaultCurrentTabbable = 0,
50+
onFocusMove,
1351
}: {
1452
colCount: number;
1553
rowCount: number;
1654
itemsCount: number;
1755
resetActiveItemOnBlur?: boolean;
1856
pageSize?: number;
1957
defaultCurrentTabbable?: number;
58+
onFocusMove(idx: number): void;
2059
}): [React.HTMLProps<T>, number] {
2160
const rootNode = useRef<T | null>(null);
22-
const [focusProps, focusState] = useFocusState();
61+
const [tabIndex, setTabIndex] = useState<0 | -1>(0);
2362
const [currentTabbable, setCurrentTabbable] = useState(
2463
defaultCurrentTabbable
2564
);
2665

27-
useEffect(() => {
28-
if (resetActiveItemOnBlur && focusState === FocusState.NoFocus) {
29-
setCurrentTabbable(defaultCurrentTabbable);
66+
const onFocus = useCallback(
67+
(evt: React.FocusEvent) => {
68+
// If we received focus on the grid container itself, this is a keyboard
69+
// navigation, disable focus on the container to trigger a focus effect
70+
// for the currentTabbable element
71+
if (evt.target === evt.currentTarget) {
72+
setTabIndex(-1);
73+
} else {
74+
const focusedItem = closest(
75+
evt.target as HTMLElement,
76+
vgridItemSelector()
77+
);
78+
79+
// If focus was received somewhere inside grid item, disable focus on
80+
// the container and mark item that got the interaction as the
81+
// `currentTabbable` item
82+
if (focusedItem) {
83+
setTabIndex(-1);
84+
setCurrentTabbable(getItemIndex(focusedItem));
85+
}
86+
}
87+
},
88+
[defaultCurrentTabbable]
89+
);
90+
91+
const onBlur = useCallback(() => {
92+
const isFocusInside =
93+
closest(
94+
document.activeElement as HTMLElement,
95+
(node) => node === rootNode.current
96+
) !== null;
97+
98+
// If focus is outside of the grid container, make the whole container
99+
// focusable again and reset tabbable item if needed
100+
if (!isFocusInside) {
101+
setTabIndex(0);
102+
if (resetActiveItemOnBlur) {
103+
setCurrentTabbable(defaultCurrentTabbable);
104+
}
30105
}
31-
}, [resetActiveItemOnBlur, focusState, defaultCurrentTabbable]);
106+
}, [resetActiveItemOnBlur, defaultCurrentTabbable]);
107+
108+
const onMouseDown = useCallback((evt: React.MouseEvent) => {
109+
const gridItem = closest(evt.target as HTMLElement, vgridItemSelector());
110+
// If mousedown didn't originate in one of the grid items (we just clicked
111+
// some empty space in the grid container), prevent default behavior to stop
112+
// focus on the grid container from happening
113+
if (!gridItem) {
114+
evt.preventDefault();
115+
// Simulate active element blur that normally happens when clicking a
116+
// non-focusable element
117+
(document.activeElement as HTMLElement)?.blur();
118+
}
119+
}, []);
120+
121+
const focusProps = { tabIndex, onFocus, onBlur, onMouseDown };
32122

33123
const onKeyDown = useCallback(
34124
(evt: React.KeyboardEvent<T>) => {
@@ -116,53 +206,38 @@ export function useVirtualGridArrowNavigation<
116206
[currentTabbable, itemsCount, rowCount, colCount, pageSize]
117207
);
118208

119-
return [{ ref: rootNode, onKeyDown, ...focusProps }, currentTabbable];
120-
}
121-
122-
export function useVirtualRovingTabIndex<T extends HTMLElement = HTMLElement>({
123-
currentTabbable,
124-
onFocusMove,
125-
}: {
126-
currentTabbable: number;
127-
onFocusMove(idx: number): void;
128-
}): React.HTMLProps<T> {
129-
const rootNode = useRef<T | null>(null);
130-
// We will set tabIndex on the parent element so that it can catch focus even
131-
// if the currentTabbable is not rendered
132-
const [tabIndex, setTabIndex] = useState<0 | -1>(0);
133-
const [focusProps, focusState] = useFocusState();
134-
135-
// Focuses vlist item by id or falls back to the first focusable element in
136-
// the container
137-
const focusTabbable = useCallback(() => {
138-
const selector =
139-
currentTabbable >= 0
140-
? `[data-vlist-item-idx="${currentTabbable}"]`
141-
: '[tabindex=0]';
142-
rootNode.current?.querySelector<T>(selector)?.focus();
143-
}, [rootNode, currentTabbable]);
209+
const activeCurrentTabbable = tabIndex === 0 ? -1 : currentTabbable;
144210

145211
useEffect(() => {
146-
if (
147-
[
148-
FocusState.Focus,
149-
FocusState.FocusVisible,
150-
FocusState.FocusWithin,
151-
FocusState.FocusWithinVisible,
152-
].includes(focusState)
153-
) {
154-
setTabIndex(-1);
155-
onFocusMove(currentTabbable);
156-
const frame = requestAnimationFrame(() => {
157-
focusTabbable();
158-
});
159-
return () => {
160-
cancelAnimationFrame(frame);
161-
};
162-
} else {
163-
setTabIndex(0);
212+
// If we have an active current tabbable item (there is a focus somewhere in
213+
// the grid container) ...
214+
if (activeCurrentTabbable >= 0) {
215+
const gridItem = closest(
216+
document.activeElement as HTMLElement,
217+
vgridItemSelector()
218+
);
219+
const shouldMoveFocus =
220+
!gridItem || getItemIndex(gridItem) !== activeCurrentTabbable;
221+
222+
// ... and this item is currently not focused ...
223+
if (shouldMoveFocus) {
224+
// ... communicate that there will be a focus change happening (this is
225+
// needed so that we can scroll invisible virtual item into view if
226+
// needed) ...
227+
onFocusMove(activeCurrentTabbable);
228+
// ... and trigger a focus on the element after a frame delay, so that
229+
// the item has time to scroll into view and render if needed
230+
const frameId = requestAnimationFrame(() => {
231+
rootNode.current
232+
?.querySelector<HTMLElement>(vgridItemSelector(currentTabbable))
233+
?.focus();
234+
});
235+
return () => {
236+
cancelAnimationFrame(frameId);
237+
};
238+
}
164239
}
165-
}, [focusState, onFocusMove, focusTabbable, currentTabbable]);
240+
}, [activeCurrentTabbable]);
166241

167-
return { ref: rootNode, tabIndex, ...focusProps };
242+
return [{ ref: rootNode, onKeyDown, ...focusProps }, activeCurrentTabbable];
168243
}

packages/compass-saved-aggregations-queries/src/components/aggregations-queries-list.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ const sortBy: { name: keyof Item; label: string }[] = [
6363
];
6464

6565
const headerStyles = css({
66-
margin: spacing[3],
66+
padding: spacing[3],
6767
display: 'flex',
6868
justifyContent: 'space-between',
6969
});
@@ -221,6 +221,7 @@ const AggregationsQueriesList = ({
221221
headerHeight={spacing[5] + 36}
222222
renderEmptyList={NoSearchResults}
223223
classNames={{ row: rowStyles }}
224+
resetActiveItemOnBlur={false}
224225
></VirtualGrid>
225226
<OpenItemModal></OpenItemModal>
226227
<EditItemModal></EditItemModal>

packages/compass-saved-aggregations-queries/src/components/saved-item-card.test.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ describe('SavedItemCard', function () {
5555
);
5656

5757
userEvent.click(screen.getByText('My Awesome Query'));
58-
userEvent.tab();
5958
userEvent.keyboard('{space}');
6059
userEvent.keyboard('{enter}');
6160

0 commit comments

Comments
 (0)