fix: add clear search button to listbox search (#1566)

This commit is contained in:
Jingjing Xie
2024-06-25 10:17:33 +02:00
committed by GitHub
parent f7d73d64b9
commit 1a92d5f44b
11 changed files with 1741 additions and 3781 deletions

View File

@@ -51,6 +51,10 @@
"value": "Search in listbox", "value": "Search in listbox",
"comment": "Action text to search in listbox" "comment": "Action text to search in listbox"
}, },
"Listbox.Clear.Search": {
"value": "Clear search",
"comment": "Action text to clear search text in listbox. (tew 240624)"
},
"Listbox.Lock": { "Listbox.Lock": {
"value": "Lock selections", "value": "Lock selections",
"comment": "Action text to lock selections in a listbox" "comment": "Action text to lock selections in a listbox"

View File

@@ -1,7 +1,8 @@
import React, { useContext, useState, useEffect, useRef } from 'react'; import React, { useContext, useState, useEffect, useRef } from 'react';
import { InputAdornment, OutlinedInput } from '@mui/material'; import { InputAdornment, OutlinedInput, IconButton } from '@mui/material';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import Search from '@nebula.js/ui/icons/search'; import Search from '@nebula.js/ui/icons/search';
import Close from '@nebula.js/ui/icons/close';
import InstanceContext from '../../../contexts/InstanceContext'; import InstanceContext from '../../../contexts/InstanceContext';
import useDataStore from '../hooks/useDataStore'; import useDataStore from '../hooks/useDataStore';
import { CELL_PADDING_LEFT } from '../constants'; import { CELL_PADDING_LEFT } from '../constants';
@@ -14,15 +15,16 @@ const limitSearchLength = (val) => val?.substring(0, MAX_SEARCH_LENGTH);
const StyledOutlinedInput = styled(OutlinedInput, { const StyledOutlinedInput = styled(OutlinedInput, {
shouldForwardProp: (p) => !['styles', 'dense', 'isRtl'].includes(p), shouldForwardProp: (p) => !['styles', 'dense', 'isRtl'].includes(p),
})(({ theme, styles, dense, isRtl }) => { })(({ styles, dense, isRtl }) => {
let denseProps = {}; let denseProps = {};
if (dense) { if (dense) {
denseProps = { denseProps = {
fontSize: 12, fontSize: 12,
paddingLeft: theme.spacing(1),
'& input': { '& input': {
paddingTop: '5px', paddingTop: '5px',
paddingBottom: '5px', paddingBottom: '5px',
color: styles.search.color,
textAlign: isRtl ? 'right' : 'left',
}, },
}; };
} }
@@ -33,7 +35,8 @@ const StyledOutlinedInput = styled(OutlinedInput, {
borderRadius: 0, borderRadius: 0,
backgroundColor: styles.search.backgroundColor, backgroundColor: styles.search.backgroundColor,
backdropFilter: styles.background.backgroundImage ? styles.search.backdropFilter : undefined, backdropFilter: styles.background.backgroundImage ? styles.search.backdropFilter : undefined,
paddingLeft: `${CELL_PADDING_LEFT}px`, paddingLeft: 0,
paddingRight: 0,
flexDirection: isRtl ? 'row-reverse' : 'row', flexDirection: isRtl ? 'row-reverse' : 'row',
'& fieldset': { '& fieldset': {
@@ -58,6 +61,20 @@ const StyledOutlinedInput = styled(OutlinedInput, {
}; };
}); });
const StyledIconButton = styled(IconButton)(() => ({
border: 0,
padding: '8px',
cursor: 'pointer',
lineHeight: '12px',
'&:hover': {
backgroundColor: 'transparent',
},
':focus-visible': {
borderRadius: '4px',
boxShadow: 'inset 0 0 0 2px rgb(2, 117, 217)',
},
}));
export default function ListBoxSearch({ export default function ListBoxSearch({
popoverOpen, popoverOpen,
selections, selections,
@@ -77,6 +94,8 @@ export default function ListBoxSearch({
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const [wildcardOn, setWildcardOn] = useState(false); const [wildcardOn, setWildcardOn] = useState(false);
const inputRef = useRef(); const inputRef = useRef();
const clearSearchRef = useRef();
const clearSearchText = translator.get('Listbox.Clear.Search');
const { getStoreValue, setStoreValue } = useDataStore(model); const { getStoreValue, setStoreValue } = useDataStore(model);
const isRtl = direction === 'rtl'; const isRtl = direction === 'rtl';
@@ -179,6 +198,8 @@ export default function ListBoxSearch({
case 'Tab': { case 'Tab': {
if (e.shiftKey) { if (e.shiftKey) {
keyboard.focusSelection(); keyboard.focusSelection();
} else if (clearSearchRef.current) {
clearSearchRef.current.focus();
} else { } else {
// Focus the row we last visited or the first one. // Focus the row we last visited or the first one.
focusRow(container); focusRow(container);
@@ -210,6 +231,43 @@ export default function ListBoxSearch({
return undefined; return undefined;
}; };
const focusOnInput = () => {
inputRef.current?.focus();
};
const onClearSearch = () => {
abortSearch();
focusOnInput();
};
const onKeyDownClearSearch = async (e) => {
const container = e.currentTarget.closest('.listbox-container');
switch (e.key) {
case 'Enter':
onClearSearch();
break;
case 'Tab': {
if (e.shiftKey) {
focusOnInput();
} else {
// Focus the row we last visited or the first one.
focusRow(container);
// Clean up.
container?.querySelectorAll('.last-focused').forEach((elm) => {
elm.classList.remove('last-focused');
});
}
break;
}
default:
return undefined;
}
e.preventDefault();
e.stopPropagation();
return undefined;
};
if (!visible || searchEnabled === false) { if (!visible || searchEnabled === false) {
return null; return null;
} }
@@ -220,10 +278,26 @@ export default function ListBoxSearch({
dense={dense} dense={dense}
isRtl={isRtl} isRtl={isRtl}
startAdornment={ startAdornment={
<InputAdornment position="start"> <InputAdornment position="start" sx={{ marginLeft: dense ? '8px' : `${CELL_PADDING_LEFT}px` }}>
<Search size={dense ? 'small' : 'normal'} /> <Search size={dense ? 'small' : 'normal'} />
</InputAdornment> </InputAdornment>
} }
endAdornment={
<InputAdornment position="end" sx={{ marginLeft: 0 }}>
{value !== '' && (
<StyledIconButton
tabIndex={0}
ref={clearSearchRef}
title={clearSearchText}
aria-label={clearSearchText}
onClick={onClearSearch}
onKeyDown={onKeyDownClearSearch}
>
<Close size={dense ? 'small' : 'normal'} />
</StyledIconButton>
)}
</InputAdornment>
}
className="search" className="search"
inputRef={inputRef} inputRef={inputRef}
size="small" size="small"

View File

@@ -2,7 +2,7 @@
/* eslint-disable no-import-assign */ /* eslint-disable no-import-assign */
import React from 'react'; import React from 'react';
import renderer, { act } from 'react-test-renderer'; import renderer, { act } from 'react-test-renderer';
import { OutlinedInput } from '@mui/material'; import { OutlinedInput, IconButton } from '@mui/material';
import { createTheme, ThemeProvider } from '@nebula.js/ui/theme'; import { createTheme, ThemeProvider } from '@nebula.js/ui/theme';
import * as InstanceContextModule from '../../../../contexts/InstanceContext'; import * as InstanceContextModule from '../../../../contexts/InstanceContext';
import * as useDataStore from '../../hooks/useDataStore'; import * as useDataStore from '../../hooks/useDataStore';
@@ -444,4 +444,57 @@ describe('<ListBoxSearch />', () => {
expect(model.acceptListObjectSearch).not.toHaveBeenCalled(); expect(model.acceptListObjectSearch).not.toHaveBeenCalled();
}); });
}); });
test('should render clear search button when has search text', async () => {
const testRenderer = testRender(model);
const testInstance = testRenderer.root;
const type = testInstance.findByType(OutlinedInput);
await act(async () => {
await type.props.onChange({ target: { value: 'foo' } });
});
const icon = testInstance.findAllByType(IconButton);
expect(icon).toHaveLength(1);
});
test('should not render clear search button when has no search text', async () => {
const testRenderer = testRender(model);
const testInstance = testRenderer.root;
const type = testInstance.findByType(OutlinedInput);
await act(async () => {
await type.props.onChange({ target: { value: '' } });
});
const icon = testInstance.findAllByType(IconButton);
expect(icon).toHaveLength(0);
});
test('should clear search text when clicking on clear search button', async () => {
const testRenderer = testRender(model);
const testInstance = testRenderer.root;
const type = testInstance.findByType(OutlinedInput);
await act(async () => {
await type.props.onChange({ target: { value: 'foo' } });
});
const icon = testInstance.findByType(IconButton);
await act(async () => {
await icon.props.onClick();
});
expect(model.abortListObjectSearch).toHaveBeenCalledWith('/qListObjectDef');
expect(type.props.value).toBe('');
});
test('should clear search text when pressing enter on focused clear search button', async () => {
const testRenderer = testRender(model);
const testInstance = testRenderer.root;
const type = testInstance.findByType(OutlinedInput);
await act(async () => {
await type.props.onChange({ target: { value: 'foo' } });
await type.props.onKeyDown({ ...keyEventDefaults, key: 'Tab' });
});
const icon = testInstance.findByType(IconButton);
await act(async () => {
await icon.props.onKeyDown({ ...keyEventDefaults, key: 'Enter' });
});
expect(model.abortListObjectSearch).toHaveBeenCalledWith('/qListObjectDef');
expect(type.props.value).toBe('');
});
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 25 KiB

5379
yarn.lock

File diff suppressed because it is too large Load Diff