mirror of
https://github.com/nocobase/nocobase
synced 2024-11-15 14:16:26 +00:00
feat(client): add searchbar and increase ux of select popover in icon-picker component (#4609)
* feat(client): add searchbar to select popover of icon-picker component * feat(client): increase ux of icon selection in icon-picker component * feat(client): add iconSize and searchable as props for icon-picker component * feat(client): show antd empty component when icon search has no results * feat(client): make icon-searchbar sticky and lock popover height - To prevent content jumpung when searching for icons the popover need a fixed height. * fix: styles & l10n & tests * chore: remove it.only * fix: test --------- Co-authored-by: xilesun <2013xile@gmail.com>
This commit is contained in:
parent
a86315de61
commit
2141e6274d
@ -830,5 +830,6 @@
|
|||||||
"This variable has been deprecated and can be replaced with \"Current form\"": "This variable has been deprecated and can be replaced with \"Current form\"",
|
"This variable has been deprecated and can be replaced with \"Current form\"": "This variable has been deprecated and can be replaced with \"Current form\"",
|
||||||
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.",
|
"The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.": "The value of this variable is derived from the query string of the page URL. This variable can only be used normally when the page has a query string.",
|
||||||
"URL search params": "URL search params",
|
"URL search params": "URL search params",
|
||||||
"Expand All": "Expand All"
|
"Expand All": "Expand All",
|
||||||
|
"Search": "Search"
|
||||||
}
|
}
|
||||||
|
@ -961,5 +961,6 @@
|
|||||||
"Edit link": "编辑链接",
|
"Edit link": "编辑链接",
|
||||||
"Add parameter": "添加参数",
|
"Add parameter": "添加参数",
|
||||||
"URL search params": "URL 查询参数",
|
"URL search params": "URL 查询参数",
|
||||||
"Expand All": "展开全部"
|
"Expand All": "展开全部",
|
||||||
|
"Search": "搜索"
|
||||||
}
|
}
|
||||||
|
@ -11,28 +11,59 @@ import { CloseOutlined, LoadingOutlined } from '@ant-design/icons';
|
|||||||
import { useFormLayout } from '@formily/antd-v5';
|
import { useFormLayout } from '@formily/antd-v5';
|
||||||
import { connect, mapProps, mapReadPretty } from '@formily/react';
|
import { connect, mapProps, mapReadPretty } from '@formily/react';
|
||||||
import { isValid } from '@formily/shared';
|
import { isValid } from '@formily/shared';
|
||||||
import { Button, Space } from 'antd';
|
import { Button, Empty, Space, Input, theme } from 'antd';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Icon, hasIcon, icons } from '../../../icon';
|
import { Icon, hasIcon, icons } from '../../../icon';
|
||||||
import { StablePopover } from '../popover';
|
import { StablePopover } from '../popover';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
|
import { createStyles } from 'antd-style';
|
||||||
|
|
||||||
|
const { Search } = Input;
|
||||||
|
|
||||||
export interface IconPickerProps {
|
export interface IconPickerProps {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
suffix?: React.ReactNode;
|
suffix?: React.ReactNode;
|
||||||
|
iconSize?: number;
|
||||||
|
searchable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IconPickerReadPrettyProps {
|
interface IconPickerReadPrettyProps {
|
||||||
value?: string;
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const useStyle = (isSearchable: IconPickerProps['searchable']) =>
|
||||||
|
createStyles(({ css }) => {
|
||||||
|
return {
|
||||||
|
popoverContent: css`
|
||||||
|
width: 26em;
|
||||||
|
${!isSearchable && 'max-'}height: 20em;
|
||||||
|
overflow-y: auto;
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
function IconField(props: IconPickerProps) {
|
function IconField(props: IconPickerProps) {
|
||||||
|
const { fontSizeHeading3 } = theme.useToken().token;
|
||||||
|
const availableIcons = [...icons.keys()];
|
||||||
const layout = useFormLayout();
|
const layout = useFormLayout();
|
||||||
const { value, onChange, disabled } = props;
|
const { value, onChange, disabled, iconSize = fontSizeHeading3, searchable = true } = props;
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [filteredIcons, setFilteredIcons] = useState(availableIcons);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { styles } = useStyle(searchable);
|
||||||
|
|
||||||
|
const filterIcons = debounce((value) => {
|
||||||
|
const searchValue = value?.trim() ?? '';
|
||||||
|
setFilteredIcons(
|
||||||
|
searchValue.length
|
||||||
|
? availableIcons.filter((i) => i.split(' ').some((val) => val.includes(searchValue)))
|
||||||
|
: availableIcons,
|
||||||
|
);
|
||||||
|
}, 250);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space.Compact>
|
<Space.Compact>
|
||||||
@ -46,11 +77,15 @@ function IconField(props: IconPickerProps) {
|
|||||||
setVisible(val);
|
setVisible(val);
|
||||||
}}
|
}}
|
||||||
content={
|
content={
|
||||||
<div style={{ width: '26em', maxHeight: '20em', overflowY: 'auto' }}>
|
<div className={styles.popoverContent}>
|
||||||
{[...icons.keys()].map((key) => (
|
{filteredIcons.length === 0 ? (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
) : (
|
||||||
|
filteredIcons.map((key) => (
|
||||||
<span
|
<span
|
||||||
key={key}
|
key={key}
|
||||||
style={{ fontSize: 18, marginRight: 10, cursor: 'pointer' }}
|
title={key.replace(/outlined|filled|twotone$/i, '')}
|
||||||
|
style={{ fontSize: iconSize, marginRight: 10, cursor: 'pointer' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange(key);
|
onChange(key);
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
@ -58,10 +93,26 @@ function IconField(props: IconPickerProps) {
|
|||||||
>
|
>
|
||||||
<Icon type={key} />
|
<Icon type={key} />
|
||||||
</span>
|
</span>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<div>
|
||||||
|
<div>{t('Icon')}</div>
|
||||||
|
{searchable && (
|
||||||
|
<Search
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
role="search"
|
||||||
|
name="icon-search"
|
||||||
|
placeholder={t('Search')}
|
||||||
|
allowClear
|
||||||
|
onSearch={filterIcons}
|
||||||
|
onChange={(event) => filterIcons(event.target?.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
title={t('Icon')}
|
|
||||||
trigger="click"
|
trigger="click"
|
||||||
>
|
>
|
||||||
<Button size={layout.size as any} disabled={disabled}>
|
<Button size={layout.size as any} disabled={disabled}>
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { render, screen, userEvent } from '@nocobase/test/client';
|
import { fireEvent, render, screen, userEvent, waitFor } from '@nocobase/test/client';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import App from '../demos/icon-picker';
|
import App from '../demos/icon-picker';
|
||||||
|
|
||||||
@ -18,23 +18,18 @@ describe('IconPicker', () => {
|
|||||||
const button = container.querySelector('button') as HTMLButtonElement;
|
const button = container.querySelector('button') as HTMLButtonElement;
|
||||||
await userEvent.click(button);
|
await userEvent.click(button);
|
||||||
|
|
||||||
expect(screen.getByText('Icon')).toMatchInlineSnapshot(`
|
expect(screen.getByText('Icon')).toHaveTextContent(`Icon`);
|
||||||
<div
|
expect(screen.queryAllByRole('img').length).toBe(422);
|
||||||
class="ant-popover-title"
|
|
||||||
>
|
|
||||||
Icon
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
expect(screen.queryAllByRole('img').length).toBe(421);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.skip('should display the selected icon', async () => {
|
it('should display the selected icon', async () => {
|
||||||
const { container } = render(<App />);
|
const { container } = render(<App />);
|
||||||
|
|
||||||
const button = container.querySelector('button') as HTMLButtonElement;
|
const button = container.querySelector('button') as HTMLButtonElement;
|
||||||
await userEvent.click(button);
|
await userEvent.click(button);
|
||||||
|
|
||||||
const icon = screen.queryAllByRole('img')[0];
|
// [0] is the icon of search input
|
||||||
|
const icon = screen.queryAllByRole('img')[1];
|
||||||
await userEvent.click(icon);
|
await userEvent.click(icon);
|
||||||
|
|
||||||
const icons = screen.queryAllByRole('img');
|
const icons = screen.queryAllByRole('img');
|
||||||
@ -47,4 +42,22 @@ describe('IconPicker', () => {
|
|||||||
await userEvent.click(icons[1]);
|
await userEvent.click(icons[1]);
|
||||||
expect(screen.queryAllByRole('img').length).toBe(0);
|
expect(screen.queryAllByRole('img').length).toBe(0);
|
||||||
}, 300000);
|
}, 300000);
|
||||||
|
|
||||||
|
it('should filter the displayed icons when changing the value of search input', async () => {
|
||||||
|
const { container } = render(<App />);
|
||||||
|
|
||||||
|
const button = container.querySelector('button') as HTMLButtonElement;
|
||||||
|
await userEvent.click(button);
|
||||||
|
|
||||||
|
const searchInput = screen.queryByRole('search') as HTMLInputElement;
|
||||||
|
await waitFor(() => expect(searchInput).toBeInTheDocument());
|
||||||
|
expect(screen.queryAllByRole('img').length).toBe(422);
|
||||||
|
await userEvent.type(searchInput, 'left');
|
||||||
|
await waitFor(() => expect(screen.queryAllByRole('img').length).toBeLessThan(422));
|
||||||
|
await userEvent.clear(searchInput);
|
||||||
|
await userEvent.type(searchInput, 'abcd');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('No data')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -9,6 +9,8 @@ interface IconPickerProps {
|
|||||||
value?: string;
|
value?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
suffix?: React.ReactNode;
|
suffix?: React.ReactNode;
|
||||||
|
iconSize?: number;
|
||||||
|
searchable?: boolean;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -9,6 +9,8 @@ interface IconPickerProps {
|
|||||||
value?: string;
|
value?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
suffix?: React.ReactNode;
|
suffix?: React.ReactNode;
|
||||||
|
iconSize?: number;
|
||||||
|
searchable?: boolean;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user