Skip to content

Commit 6b088ff

Browse files
authored
fix: add listbox focus border (#1821)
1 parent a82a288 commit 6b088ff

File tree

3 files changed

+66
-12
lines changed

3 files changed

+66
-12
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { styled } from '@mui/material';
2+
import React, { useCallback, useEffect, useState } from 'react';
3+
4+
const StyledBorder = styled('div')(({ theme, width, height }) => ({
5+
position: 'absolute',
6+
pointerEvents: 'none',
7+
zIndex: 1,
8+
width,
9+
height,
10+
boxShadow: `inset 0 0 0 2px ${theme.palette.custom.focusBorder}`,
11+
}));
12+
13+
export default function ListBoxFocusBorder({ width, height, isModalMode, childNode, containerNode }) {
14+
const [isOnlyContainerFocused, setIsOnlyContainerFocused] = useState(false);
15+
16+
const checkFocus = useCallback(() => {
17+
const containerFocused = containerNode && containerNode.contains(document.activeElement);
18+
const childFocused = childNode && childNode.contains(document.activeElement);
19+
setIsOnlyContainerFocused(containerFocused && !childFocused);
20+
}, [containerNode, childNode]);
21+
22+
useEffect(() => {
23+
if (!containerNode) {
24+
return undefined;
25+
}
26+
containerNode.addEventListener('focusin', checkFocus);
27+
containerNode.addEventListener('focusout', checkFocus);
28+
29+
checkFocus();
30+
31+
return () => {
32+
containerNode.removeEventListener('focusin', checkFocus);
33+
containerNode.removeEventListener('focusout', checkFocus);
34+
};
35+
}, [checkFocus, containerNode]);
36+
37+
const show = !isModalMode && isOnlyContainerFocused;
38+
if (!show) {
39+
return null;
40+
}
41+
42+
return <StyledBorder aria-hidden="true" width={width} height={height} />;
43+
}

apis/nucleus/src/components/listbox/ListBoxInline.jsx

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import useRect from '../../hooks/useRect';
1919
import isDirectQueryEnabled from './utils/is-direct-query';
2020
import getContainerPadding from './assets/list-sizes/container-padding';
2121
import ListBoxHeader from './components/ListBoxHeader';
22+
import ListBoxFocusBorder from './ListBoxFocusBorder';
2223

2324
const PREFIX = 'ListBoxInline';
2425
const classes = {
@@ -28,8 +29,8 @@ const classes = {
2829
};
2930

3031
const StyledGrid = styled(Grid, {
31-
shouldForwardProp: (p) => !['containerPadding', 'isGridMode', 'styles'].includes(p),
32-
})(({ theme, containerPadding, isGridMode, styles }) => ({
32+
shouldForwardProp: (p) => !['containerPadding', 'styles'].includes(p),
33+
})(({ containerPadding, styles }) => ({
3334
...styles.background, // sets background color and image of listbox
3435
[`& .${classes.listBoxHeader}`]: {
3536
alignSelf: 'center',
@@ -44,12 +45,6 @@ const StyledGrid = styled(Grid, {
4445
[`& .${classes.listboxWrapper}`]: {
4546
padding: containerPadding,
4647
},
47-
'&:focus': {
48-
boxShadow: `inset 0 0 0 2px ${theme.palette.custom.focusBorder} !important`,
49-
},
50-
'&:focus ::-webkit-scrollbar-track': {
51-
boxShadow: !isGridMode ? 'inset -2px -2px 0px #3F8AB3' : undefined,
52-
},
5348
'&:focus-visible': {
5449
outline: 'none',
5550
},
@@ -94,7 +89,7 @@ function ListBoxInline({ options, layout }) {
9489

9590
const containerRef = useRef();
9691
const searchInputRef = useRef();
97-
const [containerRectRef, containerRect] = useRect();
92+
const [containerRectRef, containerRect, containerNode] = useRect();
9893
const [showToolbar, setShowToolbar] = useState(false);
9994
const [showSearch, setShowSearch] = useState(false);
10095
const hovering = useRef(false);
@@ -107,6 +102,11 @@ function ListBoxInline({ options, layout }) {
107102
const isModalMode = useCallback(() => isModal({ app, appSelections }), [app, appSelections]);
108103
const isInvalid = layout?.qListObject.qDimensionInfo.qError;
109104
const errorText = isInvalid && constraints.active ? 'Visualization.Invalid.Dimension' : 'Visualization.Incomplete';
105+
const [, setHasFocus] = useState(false); // Force render on focus change to show/hide ListBoxFocusBorder
106+
const [listboxChildNode, setListboxChildNode] = useState(null);
107+
const listboxChildRef = useCallback((node) => {
108+
setListboxChildNode(node);
109+
}, []);
110110

111111
const { handleKeyDown, handleOnMouseEnter, handleOnMouseLeave, globalKeyDown } = useMemo(
112112
() =>
@@ -272,11 +272,19 @@ function ListBoxInline({ options, layout }) {
272272
onMouseLeave={handleOnMouseLeave}
273273
ref={(el) => {
274274
containerRef.current = el;
275-
containerRectRef(el);
275+
containerRectRef?.(el);
276276
}}
277-
isGridMode={isGridMode}
278277
aria-label={keyboard.active ? translator.get('Listbox.ScreenReaderInstructions') : ''}
278+
onFocus={() => setHasFocus(true)}
279+
onBlur={() => setHasFocus(false)}
279280
>
281+
<ListBoxFocusBorder
282+
width={containerRect?.width}
283+
height={containerRect?.height}
284+
isModalMode={isModalMode()}
285+
childNode={listboxChildNode}
286+
containerNode={containerNode}
287+
/>
280288
{showAttachedToolbar && listBoxHeader}
281289
<Grid
282290
item
@@ -286,6 +294,7 @@ function ListBoxInline({ options, layout }) {
286294
minHeight={listBoxMinHeight}
287295
role="region"
288296
aria-label={translator.get('Listbox.ResultFilterLabel')}
297+
ref={listboxChildRef}
289298
>
290299
<Grid item>
291300
<ListBoxSearch

apis/nucleus/src/components/listbox/__tests__/list-box-inline.test.jsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import * as ListBoxSearchModule from '../components/ListBoxSearch';
1616
import * as listboxSelectionToolbarModule from '../interactions/listbox-selection-toolbar';
1717
import * as styling from '../assets/styling';
1818
import * as isDirectQueryEnabled from '../utils/is-direct-query';
19+
import * as useAppSelection from '../../../hooks/useAppSelections';
1920

2021
const virtualizedModule = require('react-virtualized-auto-sizer');
2122
const listboxKeyboardNavigationModule = require('../interactions/keyboard-navigation/keyboard-nav-container');
@@ -94,6 +95,7 @@ describe('<ListboxInline />', () => {
9495
.spyOn(styling, 'default')
9596
.mockImplementation(() => ({ backgroundColor: '#FFFFFF', header: {}, content: {}, selections: {} }));
9697
jest.spyOn(isDirectQueryEnabled, 'default').mockImplementation(() => false);
98+
jest.spyOn(useAppSelection, 'default').mockImplementation(() => [{ isInModal: jest.fn().mockReturnValue(false) }]);
9799

98100
ActionsToolbarModule.default = ActionsToolbar;
99101
ListBoxModule.default = <div className="theListBox" />;
@@ -188,7 +190,7 @@ describe('<ListboxInline />', () => {
188190
expect(ListBoxSearch.mock.calls[0][0]).toMatchObject({
189191
visible: true,
190192
});
191-
expect(getListboxInlineKeyboardNavigation).toHaveBeenCalledTimes(2);
193+
expect(getListboxInlineKeyboardNavigation).toHaveBeenCalledTimes(3);
192194

193195
// TODO: MUIv5
194196
// expect(renderer.toJSON().props.onKeyDown).toBe('keyboard-navigation');

0 commit comments

Comments
 (0)