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",
"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": {
"value": "Lock selections",
"comment": "Action text to lock selections in a listbox"

View File

@@ -1,7 +1,8 @@
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 Search from '@nebula.js/ui/icons/search';
import Close from '@nebula.js/ui/icons/close';
import InstanceContext from '../../../contexts/InstanceContext';
import useDataStore from '../hooks/useDataStore';
import { CELL_PADDING_LEFT } from '../constants';
@@ -14,15 +15,16 @@ const limitSearchLength = (val) => val?.substring(0, MAX_SEARCH_LENGTH);
const StyledOutlinedInput = styled(OutlinedInput, {
shouldForwardProp: (p) => !['styles', 'dense', 'isRtl'].includes(p),
})(({ theme, styles, dense, isRtl }) => {
})(({ styles, dense, isRtl }) => {
let denseProps = {};
if (dense) {
denseProps = {
fontSize: 12,
paddingLeft: theme.spacing(1),
'& input': {
paddingTop: '5px',
paddingBottom: '5px',
color: styles.search.color,
textAlign: isRtl ? 'right' : 'left',
},
};
}
@@ -33,7 +35,8 @@ const StyledOutlinedInput = styled(OutlinedInput, {
borderRadius: 0,
backgroundColor: styles.search.backgroundColor,
backdropFilter: styles.background.backgroundImage ? styles.search.backdropFilter : undefined,
paddingLeft: `${CELL_PADDING_LEFT}px`,
paddingLeft: 0,
paddingRight: 0,
flexDirection: isRtl ? 'row-reverse' : 'row',
'& 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({
popoverOpen,
selections,
@@ -77,6 +94,8 @@ export default function ListBoxSearch({
const [value, setValue] = useState('');
const [wildcardOn, setWildcardOn] = useState(false);
const inputRef = useRef();
const clearSearchRef = useRef();
const clearSearchText = translator.get('Listbox.Clear.Search');
const { getStoreValue, setStoreValue } = useDataStore(model);
const isRtl = direction === 'rtl';
@@ -179,6 +198,8 @@ export default function ListBoxSearch({
case 'Tab': {
if (e.shiftKey) {
keyboard.focusSelection();
} else if (clearSearchRef.current) {
clearSearchRef.current.focus();
} else {
// Focus the row we last visited or the first one.
focusRow(container);
@@ -210,6 +231,43 @@ export default function ListBoxSearch({
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) {
return null;
}
@@ -220,10 +278,26 @@ export default function ListBoxSearch({
dense={dense}
isRtl={isRtl}
startAdornment={
<InputAdornment position="start">
<InputAdornment position="start" sx={{ marginLeft: dense ? '8px' : `${CELL_PADDING_LEFT}px` }}>
<Search size={dense ? 'small' : 'normal'} />
</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"
inputRef={inputRef}
size="small"

View File

@@ -2,7 +2,7 @@
/* eslint-disable no-import-assign */
import React from 'react';
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 * as InstanceContextModule from '../../../../contexts/InstanceContext';
import * as useDataStore from '../../hooks/useDataStore';
@@ -444,4 +444,57 @@ describe('<ListBoxSearch />', () => {
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