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\"",
"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",
"Expand All": "Expand All"
"Expand All": "Expand All",
"Search": "Search"
}

View File

@ -961,5 +961,6 @@
"Edit link": "编辑链接",
"Add parameter": "添加参数",
"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 { connect, mapProps, mapReadPretty } from '@formily/react';
import { isValid } from '@formily/shared';
import { Button, Space } from 'antd';
import { Button, Empty, Space, Input, theme } from 'antd';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon, hasIcon, icons } from '../../../icon';
import { StablePopover } from '../popover';
import { debounce } from 'lodash';
import { createStyles } from 'antd-style';
const { Search } = Input;
export interface IconPickerProps {
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
suffix?: React.ReactNode;
iconSize?: number;
searchable?: boolean;
}
interface IconPickerReadPrettyProps {
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) {
const { fontSizeHeading3 } = theme.useToken().token;
const availableIcons = [...icons.keys()];
const layout = useFormLayout();
const { value, onChange, disabled } = props;
const { value, onChange, disabled, iconSize = fontSizeHeading3, searchable = true } = props;
const [visible, setVisible] = useState(false);
const [filteredIcons, setFilteredIcons] = useState(availableIcons);
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 (
<div>
<Space.Compact>
@ -46,11 +77,15 @@ function IconField(props: IconPickerProps) {
setVisible(val);
}}
content={
<div style={{ width: '26em', maxHeight: '20em', overflowY: 'auto' }}>
{[...icons.keys()].map((key) => (
<div className={styles.popoverContent}>
{filteredIcons.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
filteredIcons.map((key) => (
<span
key={key}
style={{ fontSize: 18, marginRight: 10, cursor: 'pointer' }}
title={key.replace(/outlined|filled|twotone$/i, '')}
style={{ fontSize: iconSize, marginRight: 10, cursor: 'pointer' }}
onClick={() => {
onChange(key);
setVisible(false);
@ -58,10 +93,26 @@ function IconField(props: IconPickerProps) {
>
<Icon type={key} />
</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>
}
title={t('Icon')}
trigger="click"
>
<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.
*/
import { render, screen, userEvent } from '@nocobase/test/client';
import { fireEvent, render, screen, userEvent, waitFor } from '@nocobase/test/client';
import React from 'react';
import App from '../demos/icon-picker';
@ -18,23 +18,18 @@ describe('IconPicker', () => {
const button = container.querySelector('button') as HTMLButtonElement;
await userEvent.click(button);
expect(screen.getByText('Icon')).toMatchInlineSnapshot(`
<div
class="ant-popover-title"
>
Icon
</div>
`);
expect(screen.queryAllByRole('img').length).toBe(421);
expect(screen.getByText('Icon')).toHaveTextContent(`Icon`);
expect(screen.queryAllByRole('img').length).toBe(422);
});
it.skip('should display the selected icon', async () => {
it('should display the selected icon', async () => {
const { container } = render(<App />);
const button = container.querySelector('button') as HTMLButtonElement;
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);
const icons = screen.queryAllByRole('img');
@ -47,4 +42,22 @@ describe('IconPicker', () => {
await userEvent.click(icons[1]);
expect(screen.queryAllByRole('img').length).toBe(0);
}, 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;
disabled?: boolean;
suffix?: React.ReactNode;
iconSize?: number;
searchable?: boolean;
}
```

View File

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