mirror of
https://github.com/qlik-oss/nebula.js.git
synced 2025-12-19 17:58:43 -05:00
fix: add listbox focus border (#1821)
This commit is contained in:
43
apis/nucleus/src/components/listbox/ListBoxFocusBorder.jsx
Normal file
43
apis/nucleus/src/components/listbox/ListBoxFocusBorder.jsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import useRect from '../../hooks/useRect';
|
|||||||
import isDirectQueryEnabled from './utils/is-direct-query';
|
import isDirectQueryEnabled from './utils/is-direct-query';
|
||||||
import getContainerPadding from './assets/list-sizes/container-padding';
|
import getContainerPadding from './assets/list-sizes/container-padding';
|
||||||
import ListBoxHeader from './components/ListBoxHeader';
|
import ListBoxHeader from './components/ListBoxHeader';
|
||||||
|
import ListBoxFocusBorder from './ListBoxFocusBorder';
|
||||||
|
|
||||||
const PREFIX = 'ListBoxInline';
|
const PREFIX = 'ListBoxInline';
|
||||||
const classes = {
|
const classes = {
|
||||||
@@ -28,8 +29,8 @@ const classes = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const StyledGrid = styled(Grid, {
|
const StyledGrid = styled(Grid, {
|
||||||
shouldForwardProp: (p) => !['containerPadding', 'isGridMode', 'styles'].includes(p),
|
shouldForwardProp: (p) => !['containerPadding', 'styles'].includes(p),
|
||||||
})(({ theme, containerPadding, isGridMode, styles }) => ({
|
})(({ containerPadding, styles }) => ({
|
||||||
...styles.background, // sets background color and image of listbox
|
...styles.background, // sets background color and image of listbox
|
||||||
[`& .${classes.listBoxHeader}`]: {
|
[`& .${classes.listBoxHeader}`]: {
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
@@ -44,12 +45,6 @@ const StyledGrid = styled(Grid, {
|
|||||||
[`& .${classes.listboxWrapper}`]: {
|
[`& .${classes.listboxWrapper}`]: {
|
||||||
padding: containerPadding,
|
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': {
|
'&:focus-visible': {
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
},
|
},
|
||||||
@@ -94,7 +89,7 @@ function ListBoxInline({ options, layout }) {
|
|||||||
|
|
||||||
const containerRef = useRef();
|
const containerRef = useRef();
|
||||||
const searchInputRef = useRef();
|
const searchInputRef = useRef();
|
||||||
const [containerRectRef, containerRect] = useRect();
|
const [containerRectRef, containerRect, containerNode] = useRect();
|
||||||
const [showToolbar, setShowToolbar] = useState(false);
|
const [showToolbar, setShowToolbar] = useState(false);
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
const hovering = useRef(false);
|
const hovering = useRef(false);
|
||||||
@@ -107,6 +102,11 @@ function ListBoxInline({ options, layout }) {
|
|||||||
const isModalMode = useCallback(() => isModal({ app, appSelections }), [app, appSelections]);
|
const isModalMode = useCallback(() => isModal({ app, appSelections }), [app, appSelections]);
|
||||||
const isInvalid = layout?.qListObject.qDimensionInfo.qError;
|
const isInvalid = layout?.qListObject.qDimensionInfo.qError;
|
||||||
const errorText = isInvalid && constraints.active ? 'Visualization.Invalid.Dimension' : 'Visualization.Incomplete';
|
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(
|
const { handleKeyDown, handleOnMouseEnter, handleOnMouseLeave, globalKeyDown } = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -272,11 +272,19 @@ function ListBoxInline({ options, layout }) {
|
|||||||
onMouseLeave={handleOnMouseLeave}
|
onMouseLeave={handleOnMouseLeave}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
containerRef.current = el;
|
containerRef.current = el;
|
||||||
containerRectRef(el);
|
containerRectRef?.(el);
|
||||||
}}
|
}}
|
||||||
isGridMode={isGridMode}
|
|
||||||
aria-label={keyboard.active ? translator.get('Listbox.ScreenReaderInstructions') : ''}
|
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}
|
{showAttachedToolbar && listBoxHeader}
|
||||||
<Grid
|
<Grid
|
||||||
item
|
item
|
||||||
@@ -286,6 +294,7 @@ function ListBoxInline({ options, layout }) {
|
|||||||
minHeight={listBoxMinHeight}
|
minHeight={listBoxMinHeight}
|
||||||
role="region"
|
role="region"
|
||||||
aria-label={translator.get('Listbox.ResultFilterLabel')}
|
aria-label={translator.get('Listbox.ResultFilterLabel')}
|
||||||
|
ref={listboxChildRef}
|
||||||
>
|
>
|
||||||
<Grid item>
|
<Grid item>
|
||||||
<ListBoxSearch
|
<ListBoxSearch
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import * as ListBoxSearchModule from '../components/ListBoxSearch';
|
|||||||
import * as listboxSelectionToolbarModule from '../interactions/listbox-selection-toolbar';
|
import * as listboxSelectionToolbarModule from '../interactions/listbox-selection-toolbar';
|
||||||
import * as styling from '../assets/styling';
|
import * as styling from '../assets/styling';
|
||||||
import * as isDirectQueryEnabled from '../utils/is-direct-query';
|
import * as isDirectQueryEnabled from '../utils/is-direct-query';
|
||||||
|
import * as useAppSelection from '../../../hooks/useAppSelections';
|
||||||
|
|
||||||
const virtualizedModule = require('react-virtualized-auto-sizer');
|
const virtualizedModule = require('react-virtualized-auto-sizer');
|
||||||
const listboxKeyboardNavigationModule = require('../interactions/keyboard-navigation/keyboard-nav-container');
|
const listboxKeyboardNavigationModule = require('../interactions/keyboard-navigation/keyboard-nav-container');
|
||||||
@@ -94,6 +95,7 @@ describe('<ListboxInline />', () => {
|
|||||||
.spyOn(styling, 'default')
|
.spyOn(styling, 'default')
|
||||||
.mockImplementation(() => ({ backgroundColor: '#FFFFFF', header: {}, content: {}, selections: {} }));
|
.mockImplementation(() => ({ backgroundColor: '#FFFFFF', header: {}, content: {}, selections: {} }));
|
||||||
jest.spyOn(isDirectQueryEnabled, 'default').mockImplementation(() => false);
|
jest.spyOn(isDirectQueryEnabled, 'default').mockImplementation(() => false);
|
||||||
|
jest.spyOn(useAppSelection, 'default').mockImplementation(() => [{ isInModal: jest.fn().mockReturnValue(false) }]);
|
||||||
|
|
||||||
ActionsToolbarModule.default = ActionsToolbar;
|
ActionsToolbarModule.default = ActionsToolbar;
|
||||||
ListBoxModule.default = <div className="theListBox" />;
|
ListBoxModule.default = <div className="theListBox" />;
|
||||||
@@ -188,7 +190,7 @@ describe('<ListboxInline />', () => {
|
|||||||
expect(ListBoxSearch.mock.calls[0][0]).toMatchObject({
|
expect(ListBoxSearch.mock.calls[0][0]).toMatchObject({
|
||||||
visible: true,
|
visible: true,
|
||||||
});
|
});
|
||||||
expect(getListboxInlineKeyboardNavigation).toHaveBeenCalledTimes(2);
|
expect(getListboxInlineKeyboardNavigation).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
// TODO: MUIv5
|
// TODO: MUIv5
|
||||||
// expect(renderer.toJSON().props.onKeyDown).toBe('keyboard-navigation');
|
// expect(renderer.toJSON().props.onKeyDown).toBe('keyboard-navigation');
|
||||||
|
|||||||
Reference in New Issue
Block a user