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:
David Fecke 2024-06-28 05:12:56 +02:00 committed by GitHub
parent a86315de61
commit 2141e6274d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 101 additions and 31 deletions

View File

@ -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"
} }

View File

@ -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": "搜索"
} }

View File

@ -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}>

View File

@ -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();
});
});
}); });

View File

@ -9,6 +9,8 @@ interface IconPickerProps {
value?: string; value?: string;
disabled?: boolean; disabled?: boolean;
suffix?: React.ReactNode; suffix?: React.ReactNode;
iconSize?: number;
searchable?: boolean;
} }
``` ```

View File

@ -9,6 +9,8 @@ interface IconPickerProps {
value?: string; value?: string;
disabled?: boolean; disabled?: boolean;
suffix?: React.ReactNode; suffix?: React.ReactNode;
iconSize?: number;
searchable?: boolean;
} }
``` ```