feat: add IconPicker (#1730)
* feat: add IconPicker Closes #1657 * fix: fix front lint errors * refactor: rename selectedIconName to selectedIconKey
This commit is contained in:
@ -1,4 +1,4 @@
|
|||||||
import React, { MouseEvent } from 'react';
|
import { ComponentProps, MouseEvent } from 'react';
|
||||||
import { useTheme } from '@emotion/react';
|
import { useTheme } from '@emotion/react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
@ -17,7 +17,7 @@ export type LightIconButtonProps = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
focus?: boolean;
|
focus?: boolean;
|
||||||
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||||
};
|
} & Pick<ComponentProps<'button'>, 'aria-label'>;
|
||||||
|
|
||||||
const StyledButton = styled.button<
|
const StyledButton = styled.button<
|
||||||
Pick<LightIconButtonProps, 'accent' | 'active' | 'size' | 'focus'>
|
Pick<LightIconButtonProps, 'accent' | 'active' | 'size' | 'focus'>
|
||||||
@ -79,6 +79,7 @@ const StyledButton = styled.button<
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export const LightIconButton = ({
|
export const LightIconButton = ({
|
||||||
|
'aria-label': ariaLabel,
|
||||||
className,
|
className,
|
||||||
Icon,
|
Icon,
|
||||||
active = false,
|
active = false,
|
||||||
@ -91,6 +92,7 @@ export const LightIconButton = ({
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
return (
|
return (
|
||||||
<StyledButton
|
<StyledButton
|
||||||
|
aria-label={ariaLabel}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
focus={focus && !disabled}
|
focus={focus && !disabled}
|
||||||
|
|||||||
80
front/src/modules/ui/input/components/IconPicker.tsx
Normal file
80
front/src/modules/ui/input/components/IconPicker.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
|
import { LightIconButton } from '@/ui/button/components/LightIconButton';
|
||||||
|
import { DropdownMenuSearchInput } from '@/ui/dropdown/components/DropdownMenuSearchInput';
|
||||||
|
import { StyledDropdownMenu } from '@/ui/dropdown/components/StyledDropdownMenu';
|
||||||
|
import { StyledDropdownMenuItemsContainer } from '@/ui/dropdown/components/StyledDropdownMenuItemsContainer';
|
||||||
|
import { StyledDropdownMenuSeparator } from '@/ui/dropdown/components/StyledDropdownMenuSeparator';
|
||||||
|
import { IconComponent } from '@/ui/icon/types/IconComponent';
|
||||||
|
|
||||||
|
type IconPickerProps = {
|
||||||
|
icons: Record<string, IconComponent>;
|
||||||
|
onChange: (iconName: string) => void;
|
||||||
|
selectedIconKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StyledIconPickerDropdownMenu = styled(StyledDropdownMenu)`
|
||||||
|
width: 176px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledMenuIconItemsContainer = styled(StyledDropdownMenuItemsContainer)`
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
height: auto;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledLightIconButton = styled(LightIconButton)<{ isSelected?: boolean }>`
|
||||||
|
background: ${({ theme, isSelected }) =>
|
||||||
|
isSelected ? theme.background.transparent.light : 'transparent'};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const convertIconKeyToLabel = (iconName: string) =>
|
||||||
|
iconName.replace(/[A-Z]/g, (letter) => ` ${letter}`).trim();
|
||||||
|
|
||||||
|
export const IconPicker = ({
|
||||||
|
icons,
|
||||||
|
onChange,
|
||||||
|
selectedIconKey,
|
||||||
|
}: IconPickerProps) => {
|
||||||
|
const [searchString, setSearchString] = useState('');
|
||||||
|
|
||||||
|
const iconKeys = useMemo(() => {
|
||||||
|
const filteredIconKeys = Object.keys(icons).filter(
|
||||||
|
(iconKey) =>
|
||||||
|
iconKey !== selectedIconKey &&
|
||||||
|
(!searchString ||
|
||||||
|
convertIconKeyToLabel(iconKey)
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchString.toLowerCase())),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
selectedIconKey
|
||||||
|
? [selectedIconKey, ...filteredIconKeys]
|
||||||
|
: filteredIconKeys
|
||||||
|
).slice(0, 25);
|
||||||
|
}, [icons, searchString, selectedIconKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledIconPickerDropdownMenu>
|
||||||
|
<DropdownMenuSearchInput
|
||||||
|
placeholder="Search icon"
|
||||||
|
autoFocus
|
||||||
|
onChange={(event) => setSearchString(event.target.value)}
|
||||||
|
/>
|
||||||
|
<StyledDropdownMenuSeparator />
|
||||||
|
<StyledMenuIconItemsContainer>
|
||||||
|
{iconKeys.map((iconKey) => (
|
||||||
|
<StyledLightIconButton
|
||||||
|
aria-label={convertIconKeyToLabel(iconKey)}
|
||||||
|
isSelected={selectedIconKey === iconKey}
|
||||||
|
size="medium"
|
||||||
|
Icon={icons[iconKey]}
|
||||||
|
onClick={() => onChange(iconKey)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</StyledMenuIconItemsContainer>
|
||||||
|
</StyledIconPickerDropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
import { expect } from '@storybook/jest';
|
||||||
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { userEvent, within } from '@storybook/testing-library';
|
||||||
|
|
||||||
|
import * as icons from '@/ui/icon';
|
||||||
|
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||||
|
|
||||||
|
import { IconPicker } from '../IconPicker';
|
||||||
|
|
||||||
|
const meta: Meta<typeof IconPicker> = {
|
||||||
|
title: 'UI/Input/IconPicker',
|
||||||
|
component: IconPicker,
|
||||||
|
decorators: [ComponentDecorator],
|
||||||
|
args: { icons },
|
||||||
|
argTypes: {
|
||||||
|
icons: { control: false },
|
||||||
|
selectedIconKey: {
|
||||||
|
options: Object.keys(icons),
|
||||||
|
control: { type: 'select' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof IconPicker>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const WithSelectedIcon: Story = {
|
||||||
|
args: { selectedIconKey: 'IconCalendarEvent' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithSearch: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
const searchInput = canvas.getByRole('textbox');
|
||||||
|
|
||||||
|
await userEvent.type(searchInput, 'Building skyscraper');
|
||||||
|
|
||||||
|
const searchedIcon = canvas.getByRole('button', {
|
||||||
|
name: 'Icon Building Skyscraper',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(searchedIcon).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user