mirror of
https://github.com/nocobase/nocobase
synced 2024-11-15 08:47:20 +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\"",
|
||||
"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"
|
||||
}
|
||||
|
@ -958,8 +958,9 @@
|
||||
"Search parameters": "URL 查询参数",
|
||||
"Do not concatenate search params in the URL": "查询参数不要在 URL 里拼接",
|
||||
"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 的 query string 得来的,只有当页面存在 query string 的时候,该变量才能正常使用。",
|
||||
"Edit link":"编辑链接",
|
||||
"Add parameter":"添加参数",
|
||||
"Edit link": "编辑链接",
|
||||
"Add parameter": "添加参数",
|
||||
"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 { 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,22 +77,42 @@ function IconField(props: IconPickerProps) {
|
||||
setVisible(val);
|
||||
}}
|
||||
content={
|
||||
<div style={{ width: '26em', maxHeight: '20em', overflowY: 'auto' }}>
|
||||
{[...icons.keys()].map((key) => (
|
||||
<span
|
||||
key={key}
|
||||
style={{ fontSize: 18, marginRight: 10, cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
onChange(key);
|
||||
setVisible(false);
|
||||
}}
|
||||
>
|
||||
<Icon type={key} />
|
||||
</span>
|
||||
))}
|
||||
<div className={styles.popoverContent}>
|
||||
{filteredIcons.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
filteredIcons.map((key) => (
|
||||
<span
|
||||
key={key}
|
||||
title={key.replace(/outlined|filled|twotone$/i, '')}
|
||||
style={{ fontSize: iconSize, marginRight: 10, cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
onChange(key);
|
||||
setVisible(false);
|
||||
}}
|
||||
>
|
||||
<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}>
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -9,6 +9,8 @@ interface IconPickerProps {
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
suffix?: React.ReactNode;
|
||||
iconSize?: number;
|
||||
searchable?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -9,6 +9,8 @@ interface IconPickerProps {
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
suffix?: React.ReactNode;
|
||||
iconSize?: number;
|
||||
searchable?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user