fix: add listbox focus border (#1821)

This commit is contained in:
Daniel Sjöstrand
2025-10-15 08:24:45 +02:00
committed by GitHub
parent a82a288778
commit 6b088ff1c0
3 changed files with 66 additions and 12 deletions

View File

@@ -0,0 +1,43 @@
import { styled } from '@mui/material';
import React, { useCallback, useEffect, useState } from 'react';
const StyledBorder = styled('div')(({ theme, width, height }) => ({
position: 'absolute',
pointerEvents: 'none',
zIndex: 1,
width,
height,
boxShadow: `inset 0 0 0 2px ${theme.palette.custom.focusBorder}`,
}));
export default function ListBoxFocusBorder({ width, height, isModalMode, childNode, containerNode }) {
const [isOnlyContainerFocused, setIsOnlyContainerFocused] = useState(false);
const checkFocus = useCallback(() => {
const containerFocused = containerNode && containerNode.contains(document.activeElement);
const childFocused = childNode && childNode.contains(document.activeElement);
setIsOnlyContainerFocused(containerFocused && !childFocused);
}, [containerNode, childNode]);
useEffect(() => {
if (!containerNode) {
return undefined;
}
containerNode.addEventListener('focusin', checkFocus);
containerNode.addEventListener('focusout', checkFocus);
checkFocus();
return () => {
containerNode.removeEventListener('focusin', checkFocus);
containerNode.removeEventListener('focusout', checkFocus);
};
}, [checkFocus, containerNode]);
const show = !isModalMode && isOnlyContainerFocused;
if (!show) {
return null;
}
return <StyledBorder aria-hidden="true" width={width} height={height} />;
}

View File

@@ -19,6 +19,7 @@ import useRect from '../../hooks/useRect';
import isDirectQueryEnabled from './utils/is-direct-query';
import getContainerPadding from './assets/list-sizes/container-padding';
import ListBoxHeader from './components/ListBoxHeader';
import ListBoxFocusBorder from './ListBoxFocusBorder';
const PREFIX = 'ListBoxInline';
const classes = {
@@ -28,8 +29,8 @@ const classes = {
};
const StyledGrid = styled(Grid, {
shouldForwardProp: (p) => !['containerPadding', 'isGridMode', 'styles'].includes(p),
})(({ theme, containerPadding, isGridMode, styles }) => ({
shouldForwardProp: (p) => !['containerPadding', 'styles'].includes(p),
})(({ containerPadding, styles }) => ({
...styles.background, // sets background color and image of listbox
[`& .${classes.listBoxHeader}`]: {
alignSelf: 'center',
@@ -44,12 +45,6 @@ const StyledGrid = styled(Grid, {
[`& .${classes.listboxWrapper}`]: {
padding: containerPadding,
},
'&:focus': {
boxShadow: `inset 0 0 0 2px ${theme.palette.custom.focusBorder} !important`,
},
'&:focus ::-webkit-scrollbar-track': {
boxShadow: !isGridMode ? 'inset -2px -2px 0px #3F8AB3' : undefined,
},
'&:focus-visible': {
outline: 'none',
},
@@ -94,7 +89,7 @@ function ListBoxInline({ options, layout }) {
const containerRef = useRef();
const searchInputRef = useRef();
const [containerRectRef, containerRect] = useRect();
const [containerRectRef, containerRect, containerNode] = useRect();
const [showToolbar, setShowToolbar] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const hovering = useRef(false);
@@ -107,6 +102,11 @@ function ListBoxInline({ options, layout }) {
const isModalMode = useCallback(() => isModal({ app, appSelections }), [app, appSelections]);
const isInvalid = layout?.qListObject.qDimensionInfo.qError;
const errorText = isInvalid && constraints.active ? 'Visualization.Invalid.Dimension' : 'Visualization.Incomplete';
const [, setHasFocus] = useState(false); // Force render on focus change to show/hide ListBoxFocusBorder
const [listboxChildNode, setListboxChildNode] = useState(null);
const listboxChildRef = useCallback((node) => {
setListboxChildNode(node);
}, []);
const { handleKeyDown, handleOnMouseEnter, handleOnMouseLeave, globalKeyDown } = useMemo(
() =>
@@ -272,11 +272,19 @@ function ListBoxInline({ options, layout }) {
onMouseLeave={handleOnMouseLeave}
ref={(el) => {
containerRef.current = el;
containerRectRef(el);
containerRectRef?.(el);
}}
isGridMode={isGridMode}
aria-label={keyboard.active ? translator.get('Listbox.ScreenReaderInstructions') : ''}
onFocus={() => setHasFocus(true)}
onBlur={() => setHasFocus(false)}
>
<ListBoxFocusBorder
width={containerRect?.width}
height={containerRect?.height}
isModalMode={isModalMode()}
childNode={listboxChildNode}
containerNode={containerNode}
/>
{showAttachedToolbar && listBoxHeader}
<Grid
item
@@ -286,6 +294,7 @@ function ListBoxInline({ options, layout }) {
minHeight={listBoxMinHeight}
role="region"
aria-label={translator.get('Listbox.ResultFilterLabel')}
ref={listboxChildRef}
>
<Grid item>
<ListBoxSearch

View File

@@ -16,6 +16,7 @@ import * as ListBoxSearchModule from '../components/ListBoxSearch';
import * as listboxSelectionToolbarModule from '../interactions/listbox-selection-toolbar';
import * as styling from '../assets/styling';
import * as isDirectQueryEnabled from '../utils/is-direct-query';
import * as useAppSelection from '../../../hooks/useAppSelections';
const virtualizedModule = require('react-virtualized-auto-sizer');
const listboxKeyboardNavigationModule = require('../interactions/keyboard-navigation/keyboard-nav-container');
@@ -94,6 +95,7 @@ describe('<ListboxInline />', () => {
.spyOn(styling, 'default')
.mockImplementation(() => ({ backgroundColor: '#FFFFFF', header: {}, content: {}, selections: {} }));
jest.spyOn(isDirectQueryEnabled, 'default').mockImplementation(() => false);
jest.spyOn(useAppSelection, 'default').mockImplementation(() => [{ isInModal: jest.fn().mockReturnValue(false) }]);
ActionsToolbarModule.default = ActionsToolbar;
ListBoxModule.default = <div className="theListBox" />;
@@ -188,7 +190,7 @@ describe('<ListboxInline />', () => {
expect(ListBoxSearch.mock.calls[0][0]).toMatchObject({
visible: true,
});
expect(getListboxInlineKeyboardNavigation).toHaveBeenCalledTimes(2);
expect(getListboxInlineKeyboardNavigation).toHaveBeenCalledTimes(3);
// TODO: MUIv5
// expect(renderer.toJSON().props.onKeyDown).toBe('keyboard-navigation');