fix: add clear search button to listbox search (#1566)
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 25 KiB |