From 2d60f23673ed6b178184448db48895ae96e2a370 Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Fri, 2 Feb 2024 16:14:23 +0300 Subject: [PATCH 1/6] feat(EmojiPalette): added the EmojiPalette component (and EmojiControl as well) --- src/components/EmojiPalette/EmojiControl.tsx | 67 +++++ src/components/EmojiPalette/EmojiPalette.scss | 21 ++ src/components/EmojiPalette/EmojiPalette.tsx | 177 ++++++++++++ src/components/EmojiPalette/README.md | 264 ++++++++++++++++++ .../EmojiPalette/__stories__/Docs.mdx | 7 + .../__stories__/EmojiPalette.stories.tsx | 109 ++++++++ .../__tests__/EmojiPalette.test.tsx | 152 ++++++++++ src/components/EmojiPalette/definitions.ts | 9 + src/components/EmojiPalette/hooks.ts | 38 +++ src/components/EmojiPalette/index.ts | 2 + src/components/index.ts | 1 + 11 files changed, 847 insertions(+) create mode 100644 src/components/EmojiPalette/EmojiControl.tsx create mode 100644 src/components/EmojiPalette/EmojiPalette.scss create mode 100644 src/components/EmojiPalette/EmojiPalette.tsx create mode 100644 src/components/EmojiPalette/README.md create mode 100644 src/components/EmojiPalette/__stories__/Docs.mdx create mode 100644 src/components/EmojiPalette/__stories__/EmojiPalette.stories.tsx create mode 100644 src/components/EmojiPalette/__tests__/EmojiPalette.test.tsx create mode 100644 src/components/EmojiPalette/definitions.ts create mode 100644 src/components/EmojiPalette/hooks.ts create mode 100644 src/components/EmojiPalette/index.ts diff --git a/src/components/EmojiPalette/EmojiControl.tsx b/src/components/EmojiPalette/EmojiControl.tsx new file mode 100644 index 000000000..cf7f1727d --- /dev/null +++ b/src/components/EmojiPalette/EmojiControl.tsx @@ -0,0 +1,67 @@ +import React from 'react'; + +import {Button, ButtonSize} from '../Button'; +import type {ControlProps, DOMProps, QAProps} from '../types'; + +export interface EmojiControlProps extends Pick, DOMProps, QAProps { + icon: React.ReactNode; + value?: string; + size?: ButtonSize; + title?: string; + iconClassName?: string; + checked?: boolean; + + onUpdate?: (updated: boolean) => void; + onFocus?: (event: React.FocusEvent) => void; + onBlur?: (event: React.FocusEvent) => void; +} + +export const EmojiControl = React.forwardRef( + function EmojiControl(props, ref) { + const { + value, + size = 's', + disabled = false, + title, + icon, + style, + className, + iconClassName, + qa, + checked, + + onUpdate, + onFocus, + onBlur, + } = props; + + const extraProps: React.ButtonHTMLAttributes = React.useMemo( + () => ({value}), + [value], + ); + + const onClick = React.useCallback(() => onUpdate?.(!checked), [checked, onUpdate]); + + return ( + + ); + }, +); + +EmojiControl.displayName = 'EmojiControl'; diff --git a/src/components/EmojiPalette/EmojiPalette.scss b/src/components/EmojiPalette/EmojiPalette.scss new file mode 100644 index 000000000..0ab7555b4 --- /dev/null +++ b/src/components/EmojiPalette/EmojiPalette.scss @@ -0,0 +1,21 @@ +@use '../variables'; + +$block: '.#{variables.$ns}emoji-palette'; + +#{$block} { + display: inline-flex; + flex-wrap: wrap; + + &_size_s &__option { + font-size: 18px; + } + &_size_m &__option { + font-size: 20px; + } + &_size_l &__option { + font-size: 24px; + } + &_size_xl &__option { + font-size: 28px; + } +} diff --git a/src/components/EmojiPalette/EmojiPalette.tsx b/src/components/EmojiPalette/EmojiPalette.tsx new file mode 100644 index 000000000..6a7770026 --- /dev/null +++ b/src/components/EmojiPalette/EmojiPalette.tsx @@ -0,0 +1,177 @@ +import React from 'react'; + +import {useForkRef} from '../../hooks/useForkRef/useForkRef'; +import type {ControlGroupProps, DOMProps, QAProps} from '../types'; + +import {EmojiControl, EmojiControlProps} from './EmojiControl'; +import {emojiPaletteClassNames} from './definitions'; +import {useEmojiPaletteColumns} from './hooks'; + +import './EmojiPalette.scss'; + +export type EmojiValue = string | number; +export type EmojiOption = { + value: EmojiValue; + icon?: React.ReactNode; + title?: string; + disabled?: boolean; +}; + +export interface EmojiPaletteProps + extends Pick, + Pick, + DOMProps, + QAProps { + value?: EmojiValue[]; + defaultValue?: EmojiValue[]; + + options?: EmojiOption[]; + columns?: number; + optionClassName?: string; + iconClassName?: string; + + onUpdate?: (value: EmojiValue[]) => void; +} + +interface EmojiPaletteComponent + extends React.ForwardRefExoticComponent< + EmojiPaletteProps & React.RefAttributes + > {} + +export const EmojiPalette = React.forwardRef( + function EmojiPalette(props, ref) { + const { + size = 's', + defaultValue, + value, + options = [], + columns = 6, + disabled, + style, + className, + optionClassName, + iconClassName, + qa, + + onUpdate, + onFocus, + onBlur, + } = props; + + const innerRef = React.useRef(null); + const handleRef = useForkRef(ref, innerRef); + + const [valueState, setValueState] = React.useState(defaultValue); + const isControlled = value !== undefined; + const currentValue = isControlled ? value : valueState; + + const containerProps: React.ButtonHTMLAttributes = { + role: 'group', + 'aria-disabled': disabled, + 'aria-label': props['aria-label'], + 'aria-labelledby': props['aria-labelledby'], + }; + + const onUpdateOptions = React.useCallback( + (newValue: EmojiValue[]) => { + if (!isControlled) { + setValueState(newValue); + } + + if (onUpdate) { + onUpdate(newValue); + } + }, + [isControlled, onUpdate], + ); + + const finalStyles = useEmojiPaletteColumns(innerRef, style, columns); + + return ( +
+ {options.map((option) => ( + + ))} +
+ ); + }, +) as EmojiPaletteComponent; + +EmojiPalette.displayName = 'EmojiPalette'; + +function EmojiOptionItem({ + value, + option, + checked, + disabled, + size, + optionClassName, + iconClassName, + + onUpdate, + onFocus, + onBlur, +}: {option: EmojiOption; checked: boolean} & Pick< + EmojiPaletteProps, + | 'value' + | 'disabled' + | 'size' + | 'optionClassName' + | 'iconClassName' + | 'onUpdate' + | 'onFocus' + | 'onBlur' +>) { + const onUpdateOption = React.useCallback(() => { + if (!onUpdate) return; + + if (value) { + const newValue = value.includes(option.value) + ? value.filter((v) => v !== option.value) + : [...value, option.value]; + + onUpdate(newValue); + } else { + onUpdate([option.value]); + } + }, [onUpdate, option.value, value]); + + return ( + + ); +} + +function getOptionIcon(option: EmojiOption): React.ReactNode { + if (option.icon) return option.icon; + return typeof option.value === 'string' ? option.value : String.fromCodePoint(option.value); +} diff --git a/src/components/EmojiPalette/README.md b/src/components/EmojiPalette/README.md new file mode 100644 index 000000000..1838b78db --- /dev/null +++ b/src/components/EmojiPalette/README.md @@ -0,0 +1,264 @@ + + +# EmojiPalette + + + +```tsx +import {EmojiPalette} from '@gravity-ui/uikit'; +``` + +The `EmojiPalette` component is used display a grid of emojis which you can select or unselect. + + + +### Disabled state + +You can disable all of the emojis with the `disabled` property. If you want to disable only a portion of emojis, you can change the `disabled` property of some of the `options` (`EmojiOption[]`). + + + + + +```tsx +const options: EmojiOption[] = [ + // disable a single item + {icon: '๐Ÿ˜Ž', value: 'ID-cool', disabled: true}, + {icon: '๐Ÿฅด', value: 'ID-woozy'}, +]; +// or disable all of them +; +``` + + + +### Size + +To control the size of the `EmojiPalette`, use the `size` property. The default size is `s`. + + + + + +```tsx +const options: EmojiOption[] = [ + {icon: '๐Ÿ˜Ž', value: 'ID-cool'}, + {icon: '๐Ÿฅด', value: 'ID-woozy'}, +]; + // ยซsยป is the default + + + +``` + + + +### Columns + +You can change the number of columns in the grid by changing the `columns` property (default is 6). + + + + + +```tsx +const options: EmojiOption[] = [ + {icon: '๐Ÿ˜Ž', value: 'ID-cool'}, + {icon: '๐Ÿฅด', value: 'ID-woozy'}, +]; +; +``` + + + +### Custom emojis + +You can use your own emojis/icons/images/GIFs by simply changing the `icon` property of `options` (`EmojiOption[]`) + + + + + +```tsx +const options: EmojiOption[] = [ + { + icon: {':)'}, + value: 'happy', + }, + { + icon: {':('}, + value: 'sad', + }, +]; +; +``` + + + +### Properties + +`EmojiValue = string | number`. + +`EmojiPaletteProps`: + +| Name | Description | Type | Default | +| :-------------- | :-------------------------------------------------------------------------------------- | :----------------------------------------------------: | :-----: | +| aria-label | HTML `aria-label` attribute. | `string` | | +| aria-labelledby | ID of the visible `EmojiPalette` caption element | `string` | | +| className | HTML `class` attribute. | `string` | | +| columns | Number of emojis per row. | `number` | `6` | +| defaultValue | Sets the initial value state when the component is mounted. | `EmojiValue[]` | | +| disabled | Disables the emojis. | `boolean` | false | +| iconClassName | HTML `class` attribute for the emoji icon. | `string` | | +| onBlur | `onBlur` event handler. | `(event: React.FocusEvent) => void` | | +| onFocus | `onFocus` event handler. | `(event: React.FocusEvent) => void` | | +| onUpdate | Fires when the user changes the state. Provides the new value as a callback's argument. | `(value: EmojiValue[]) => void` | | +| optionClassName | HTML `class` attribute for the emoji button. | `string` | | +| options | List of the emojis. | `EmojiOption[]` | `[]` | +| qa | HTML `data-qa` attribute, used in tests. | `string` | | +| size | Sets the size of the emojis. | `s` `m` `l` `xl` | `s` | +| style | HTML `style` attribute. | `React.CSSProperties` | | +| value | Current value for controlled usage of the component. | `EmojiValue[]` | | + +`EmojiOption`: + +| Name | Description | Type | Default | +| :------- | :---------------------- | :----------: | :-----: | +| disabled | Disables the button. | `boolean` | false | +| icon | HTML `class` attribute. | `ReactNode` | | +| title | HTML `title` attribute. | `string` | | +| value | Control value. | `EmojiValue` | | + +`EmojiControlProps`: + +| Name | Description | Type | Default | +| :------------ | :----------------------------------------- | :----------------------------------------------------: | :-----: | +| checked | Whether the emoji is selected or not. | `boolean` | `false` | +| className | HTML `class` attribute. | `string` | | +| disabled | Disables the button. | `boolean` | `false` | +| iconClassName | HTML `class` attribute for the emoji icon. | `string` | | +| value | HTML `value` attribute. | `string` | | +| onBlur | `onBlur` event handler. | `(event: React.FocusEvent) => void` | | +| onFocus | `onFocus` event handler. | `(event: React.FocusEvent) => void` | | +| onUpdate | Fires when the user (un)selects the emoji. | `(value: boolean) => void` | | +| qa | HTML `data-qa` attribute, used in tests. | `string` | | +| size | Size of the button. | `s` `m` `l` `xl` | `s` | +| style | HTML `style` attribute. | `React.CSSProperties` | | diff --git a/src/components/EmojiPalette/__stories__/Docs.mdx b/src/components/EmojiPalette/__stories__/Docs.mdx new file mode 100644 index 000000000..44f723f60 --- /dev/null +++ b/src/components/EmojiPalette/__stories__/Docs.mdx @@ -0,0 +1,7 @@ +import {Meta, Markdown} from '@storybook/addon-docs'; +import * as Stories from './EmojiPalette.stories'; +import Readme from '../README.md?raw'; + + + +{Readme} diff --git a/src/components/EmojiPalette/__stories__/EmojiPalette.stories.tsx b/src/components/EmojiPalette/__stories__/EmojiPalette.stories.tsx new file mode 100644 index 000000000..91b9685d6 --- /dev/null +++ b/src/components/EmojiPalette/__stories__/EmojiPalette.stories.tsx @@ -0,0 +1,109 @@ +import React from 'react'; + +import * as icons from '@gravity-ui/icons'; +import type {Meta} from '@storybook/react'; + +import {Showcase} from '../../../demo/Showcase'; +import {ShowcaseItem} from '../../../demo/ShowcaseItem/ShowcaseItem'; +import {Icon} from '../../Icon/Icon'; +import {EmojiOption, EmojiPalette, EmojiPaletteProps, EmojiValue} from '../EmojiPalette'; + +export default { + title: 'Components/Inputs/EmojiPalette', + component: EmojiPalette, +} as Meta; + +const options: EmojiOption[] = [ + {icon: 'โ˜บ๏ธ', value: 'emoji-1', title: 'smiling-face'}, + {icon: 'โค๏ธ', value: 'emoji-2', title: 'heart'}, + {icon: '๐Ÿ‘', value: 'emoji-3', title: 'thumbs-up'}, + {icon: '๐Ÿ˜‚', value: 'emoji-4', title: 'laughing'}, + {icon: '๐Ÿ˜', value: 'emoji-5', title: 'hearts-eyes'}, + {icon: '๐Ÿ˜Ž', value: 'emoji-6', title: 'cool'}, + {icon: '๐Ÿ˜›', value: 'emoji-7', title: 'tongue'}, + {icon: '๐Ÿ˜ก', value: 'emoji-8', title: 'angry'}, + {icon: '๐Ÿ˜ข', value: 'emoji-9', title: 'sad'}, + {icon: '๐Ÿ˜ฏ', value: 'emoji-10', title: 'surprised'}, + {icon: '๐Ÿ˜ฑ', value: 'emoji-11', title: 'face-screaming'}, + {icon: '๐Ÿค—', value: 'emoji-12', title: 'smiling-face-with-open-hands'}, + {icon: '๐Ÿคข', value: 'emoji-13', title: 'nauseated'}, + {icon: '๐Ÿคฅ', value: 'emoji-14', title: 'lying-face'}, + {icon: '๐Ÿคฉ', value: 'emoji-15', title: 'star-struck'}, + {icon: '๐Ÿคญ', value: 'emoji-16', title: 'face-with-hand-over-mouth'}, + {icon: '๐Ÿคฎ', value: 'emoji-17', title: 'vomiting'}, + {icon: '๐Ÿฅณ', value: 'emoji-18', title: 'partying'}, + {icon: '๐Ÿฅด', value: 'emoji-19', title: 'woozy'}, + {icon: '๐Ÿฅถ', value: 'emoji-20', title: 'cold-face'}, +]; + +export const Default = () => { + return ; +}; + +export const Disabled = () => { + return ( + + + + + + + i < 5 ? {...option, disabled: true} : option, + )} + /> + + + ); +}; + +export const Sizes = () => { + return ( + + + + + + + + + + + + + + + ); +}; + +export const Columns = () => { + return ( + + + + + + + + + + + + ); +}; + +const customOptions: EmojiOption[] = Object.entries(icons).map(([key, icon]) => ({ + icon: , + value: key, + title: key, +})); + +export const Custom = () => { + return ; +}; + +function PaletteBase(props: EmojiPaletteProps) { + const [value, setValue] = React.useState([]); + + return ; +} diff --git a/src/components/EmojiPalette/__tests__/EmojiPalette.test.tsx b/src/components/EmojiPalette/__tests__/EmojiPalette.test.tsx new file mode 100644 index 000000000..d86e2bf45 --- /dev/null +++ b/src/components/EmojiPalette/__tests__/EmojiPalette.test.tsx @@ -0,0 +1,152 @@ +import React from 'react'; + +import {render, screen, within} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type {ButtonSize} from '../../Button/Button'; +import {EmojiOption, EmojiPalette} from '../EmojiPalette'; + +const qaId = 'emoji-palette-component'; + +const options: EmojiOption[] = [ + {icon: '๐Ÿ˜Ž', value: 'ID-cool'}, + {icon: '๐Ÿฅด', value: 'ID-woozy'}, +]; + +describe('EmojiPalette', () => { + test('render EmojiPalette by default', () => { + render(); + + const component = screen.getByTestId(qaId); + + expect(component).toBeVisible(); + }); + + test.each(new Array('s', 'm', 'l', 'xl'))('render with given "%s" size', (size) => { + render(); + + const component = screen.getByTestId(qaId); + + expect(component).toHaveClass(`yc-emoji-palette_size_${size}`); + }); + + test('all children are disabled when disabled=true prop is given', () => { + render(); + + const component = screen.getByTestId(qaId); + const emojis = within(component).getAllByRole('button'); + + emojis.forEach((radio: HTMLElement) => { + expect(radio).toBeDisabled(); + }); + }); + + test('all children are not disabled when disabled=false prop is given', () => { + render(); + + const component = screen.getByTestId(qaId); + const radios = within(component).getAllByRole('button'); + + radios.forEach((radio: HTMLElement) => { + expect(radio).not.toBeDisabled(); + }); + }); + + test('a proper emoji is disabled when disabled=false prop is given to one of the options', () => { + const customOptions: EmojiOption[] = [ + {icon: '๐Ÿฅถ', value: 'ID-cold-face', disabled: true}, + ...options, + ]; + + render(); + + const component = screen.getByTestId(qaId); + const emojis = within(component).getAllByRole('button'); + + emojis.forEach((emoji: HTMLElement) => { + const value = emoji.getAttribute('value'); + + if (value === customOptions[0].value) { + expect(emoji).toBeDisabled(); + } else { + expect(emoji).not.toBeDisabled(); + } + }); + }); + + test('show given emoji', () => { + render(); + + const text = screen.getByText(options[0].icon as string); + + expect(text).toBeVisible(); + }); + + test('add className', () => { + const className = 'my-class'; + + render(); + + const component = screen.getByTestId(qaId); + + expect(component).toHaveClass(className); + }); + + test('add style', () => { + const style = {color: 'red'}; + + render(); + + const component = screen.getByTestId(qaId); + + expect(component).toHaveStyle(style); + }); + + test('can (un)select an emoji', async () => { + const user = userEvent.setup(); + + render(); + + const component = screen.getByTestId(qaId); + const emojis = within(component).getAllByRole('button'); + + const firstEmoji = await screen.findByText(options[0].icon as string); + const secondEmoji = await screen.findByText(options[1].icon as string); + + // Check initial state [selected, unselected] + + emojis.forEach((emoji: HTMLElement) => { + const value = emoji.getAttribute('value'); + const isSelected = emoji.getAttribute('aria-pressed'); + + if (value === options[0].value) { + expect(isSelected).toBe('true'); + } else { + expect(isSelected).toBe('false'); + } + }); + + // Click on both: [selected, unselected] -> [unselected, selected] + await user.click(firstEmoji); + await user.click(secondEmoji); + + emojis.forEach((emoji: HTMLElement) => { + const value = emoji.getAttribute('value'); + const isSelected = emoji.getAttribute('aria-pressed'); + + if (value === options[0].value) { + expect(isSelected).toBe('false'); + } else { + expect(isSelected).toBe('true'); + } + }); + + // Click on the second emoji: [unselected, selected] -> [unselected, unselected] + await user.click(secondEmoji); + + emojis.forEach((emoji: HTMLElement) => { + const isSelected = emoji.getAttribute('aria-pressed'); + expect(isSelected).toBe('false'); + }); + }); +}); diff --git a/src/components/EmojiPalette/definitions.ts b/src/components/EmojiPalette/definitions.ts new file mode 100644 index 000000000..dfa4ee598 --- /dev/null +++ b/src/components/EmojiPalette/definitions.ts @@ -0,0 +1,9 @@ +import type {ButtonSize} from '../Button/Button'; +import {block} from '../utils/cn'; + +const b = block('emoji-palette'); + +export const emojiPaletteClassNames = { + palette: ({size}: {size: ButtonSize}, className?: string) => b({size}, className), + option: (className?: string) => b('option', className), +}; diff --git a/src/components/EmojiPalette/hooks.ts b/src/components/EmojiPalette/hooks.ts new file mode 100644 index 000000000..6d095e3c8 --- /dev/null +++ b/src/components/EmojiPalette/hooks.ts @@ -0,0 +1,38 @@ +import React from 'react'; + +import {emojiPaletteClassNames} from './definitions'; + +const optionsGap = 8; /* px */ + +export function useEmojiPaletteColumns( + ref: React.RefObject, + style: React.CSSProperties | undefined, + columns: number, +) { + const [layoutStyles, setLayoutStyles] = React.useState({ + gap: `${optionsGap}px`, + }); + + const finalStyles: React.CSSProperties = React.useMemo( + () => ({...layoutStyles, ...style}), + [style, layoutStyles], + ); + + React.useLayoutEffect(() => { + if (!ref.current) return; + + const option = ref.current.querySelector(`.${emojiPaletteClassNames.option()}`); + if (!option) return; + + const {width} = option.getBoundingClientRect(); + + const gaps = optionsGap * (columns - 1); + const maxWidth = `${columns * width + gaps}px`; + + if (layoutStyles.maxWidth !== maxWidth) { + setLayoutStyles((current) => ({...current, maxWidth})); + } + }, [columns, layoutStyles.maxWidth, ref]); + + return finalStyles; +} diff --git a/src/components/EmojiPalette/index.ts b/src/components/EmojiPalette/index.ts new file mode 100644 index 000000000..9b32df8ae --- /dev/null +++ b/src/components/EmojiPalette/index.ts @@ -0,0 +1,2 @@ +export * from './EmojiControl'; +export * from './EmojiPalette'; diff --git a/src/components/index.ts b/src/components/index.ts index b8a55dee9..b52e3f671 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -15,6 +15,7 @@ export * from './CopyToClipboard'; export * from './Dialog'; export * from './Disclosure'; export * from './DropdownMenu'; +export * from './EmojiPalette'; export * from './Hotkey'; export * from './Icon'; export * from './Label'; From 4bb82f41d0b8d356d84d2dd10a5cee3554695a82 Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Wed, 7 Feb 2024 18:20:01 +0300 Subject: [PATCH 2/6] fix: eslint --- src/components/EmojiPalette/EmojiControl.tsx | 3 ++- src/components/EmojiPalette/EmojiPalette.tsx | 3 ++- .../EmojiPalette/__stories__/EmojiPalette.stories.tsx | 3 ++- .../EmojiPalette/__tests__/EmojiPalette.test.tsx | 7 ++++--- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/components/EmojiPalette/EmojiControl.tsx b/src/components/EmojiPalette/EmojiControl.tsx index cf7f1727d..62573bf05 100644 --- a/src/components/EmojiPalette/EmojiControl.tsx +++ b/src/components/EmojiPalette/EmojiControl.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import {Button, ButtonSize} from '../Button'; +import type {ButtonSize} from '../Button'; +import {Button} from '../Button'; import type {ControlProps, DOMProps, QAProps} from '../types'; export interface EmojiControlProps extends Pick, DOMProps, QAProps { diff --git a/src/components/EmojiPalette/EmojiPalette.tsx b/src/components/EmojiPalette/EmojiPalette.tsx index 6a7770026..58cc7a8bf 100644 --- a/src/components/EmojiPalette/EmojiPalette.tsx +++ b/src/components/EmojiPalette/EmojiPalette.tsx @@ -3,7 +3,8 @@ import React from 'react'; import {useForkRef} from '../../hooks/useForkRef/useForkRef'; import type {ControlGroupProps, DOMProps, QAProps} from '../types'; -import {EmojiControl, EmojiControlProps} from './EmojiControl'; +import type {EmojiControlProps} from './EmojiControl'; +import {EmojiControl} from './EmojiControl'; import {emojiPaletteClassNames} from './definitions'; import {useEmojiPaletteColumns} from './hooks'; diff --git a/src/components/EmojiPalette/__stories__/EmojiPalette.stories.tsx b/src/components/EmojiPalette/__stories__/EmojiPalette.stories.tsx index 91b9685d6..60b682bfb 100644 --- a/src/components/EmojiPalette/__stories__/EmojiPalette.stories.tsx +++ b/src/components/EmojiPalette/__stories__/EmojiPalette.stories.tsx @@ -6,7 +6,8 @@ import type {Meta} from '@storybook/react'; import {Showcase} from '../../../demo/Showcase'; import {ShowcaseItem} from '../../../demo/ShowcaseItem/ShowcaseItem'; import {Icon} from '../../Icon/Icon'; -import {EmojiOption, EmojiPalette, EmojiPaletteProps, EmojiValue} from '../EmojiPalette'; +import type {EmojiOption, EmojiPaletteProps, EmojiValue} from '../EmojiPalette'; +import {EmojiPalette} from '../EmojiPalette'; export default { title: 'Components/Inputs/EmojiPalette', diff --git a/src/components/EmojiPalette/__tests__/EmojiPalette.test.tsx b/src/components/EmojiPalette/__tests__/EmojiPalette.test.tsx index d86e2bf45..f8ec82c18 100644 --- a/src/components/EmojiPalette/__tests__/EmojiPalette.test.tsx +++ b/src/components/EmojiPalette/__tests__/EmojiPalette.test.tsx @@ -1,10 +1,11 @@ import React from 'react'; -import {render, screen, within} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import {render, screen, within} from '../../../../test-utils/utils'; import type {ButtonSize} from '../../Button/Button'; -import {EmojiOption, EmojiPalette} from '../EmojiPalette'; +import type {EmojiOption} from '../EmojiPalette'; +import {EmojiPalette} from '../EmojiPalette'; const qaId = 'emoji-palette-component'; @@ -27,7 +28,7 @@ describe('EmojiPalette', () => { const component = screen.getByTestId(qaId); - expect(component).toHaveClass(`yc-emoji-palette_size_${size}`); + expect(component).toHaveClass(`g-emoji-palette_size_${size}`); }); test('all children are disabled when disabled=true prop is given', () => { From a0e8be4111dae028799e50df9ba4966d6b292267 Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Tue, 13 Feb 2024 13:34:53 +0300 Subject: [PATCH 3/6] fix: turned EmojiPalette component into Palette --- src/components/EmojiPalette/EmojiPalette.tsx | 178 ----------- src/components/EmojiPalette/README.md | 264 ---------------- .../__stories__/EmojiPalette.stories.tsx | 110 ------- .../__tests__/EmojiPalette.test.tsx | 153 ---------- src/components/EmojiPalette/hooks.ts | 38 --- src/components/EmojiPalette/index.ts | 2 - .../Palette.scss} | 15 +- src/components/Palette/Palette.tsx | 287 ++++++++++++++++++ .../PaletteControl.tsx} | 20 +- src/components/Palette/README.md | 219 +++++++++++++ .../__stories__/Docs.mdx | 2 +- .../Palette/__stories__/Palette.stories.tsx | 125 ++++++++ .../Palette/__tests__/Palette.test.tsx | 157 ++++++++++ .../{EmojiPalette => Palette}/definitions.ts | 5 +- src/components/Palette/index.ts | 2 + src/components/index.ts | 2 +- 16 files changed, 819 insertions(+), 760 deletions(-) delete mode 100644 src/components/EmojiPalette/EmojiPalette.tsx delete mode 100644 src/components/EmojiPalette/README.md delete mode 100644 src/components/EmojiPalette/__stories__/EmojiPalette.stories.tsx delete mode 100644 src/components/EmojiPalette/__tests__/EmojiPalette.test.tsx delete mode 100644 src/components/EmojiPalette/hooks.ts delete mode 100644 src/components/EmojiPalette/index.ts rename src/components/{EmojiPalette/EmojiPalette.scss => Palette/Palette.scss} (57%) create mode 100644 src/components/Palette/Palette.tsx rename src/components/{EmojiPalette/EmojiControl.tsx => Palette/PaletteControl.tsx} (72%) create mode 100644 src/components/Palette/README.md rename src/components/{EmojiPalette => Palette}/__stories__/Docs.mdx (74%) create mode 100644 src/components/Palette/__stories__/Palette.stories.tsx create mode 100644 src/components/Palette/__tests__/Palette.test.tsx rename src/components/{EmojiPalette => Palette}/definitions.ts (66%) create mode 100644 src/components/Palette/index.ts diff --git a/src/components/EmojiPalette/EmojiPalette.tsx b/src/components/EmojiPalette/EmojiPalette.tsx deleted file mode 100644 index 58cc7a8bf..000000000 --- a/src/components/EmojiPalette/EmojiPalette.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import React from 'react'; - -import {useForkRef} from '../../hooks/useForkRef/useForkRef'; -import type {ControlGroupProps, DOMProps, QAProps} from '../types'; - -import type {EmojiControlProps} from './EmojiControl'; -import {EmojiControl} from './EmojiControl'; -import {emojiPaletteClassNames} from './definitions'; -import {useEmojiPaletteColumns} from './hooks'; - -import './EmojiPalette.scss'; - -export type EmojiValue = string | number; -export type EmojiOption = { - value: EmojiValue; - icon?: React.ReactNode; - title?: string; - disabled?: boolean; -}; - -export interface EmojiPaletteProps - extends Pick, - Pick, - DOMProps, - QAProps { - value?: EmojiValue[]; - defaultValue?: EmojiValue[]; - - options?: EmojiOption[]; - columns?: number; - optionClassName?: string; - iconClassName?: string; - - onUpdate?: (value: EmojiValue[]) => void; -} - -interface EmojiPaletteComponent - extends React.ForwardRefExoticComponent< - EmojiPaletteProps & React.RefAttributes - > {} - -export const EmojiPalette = React.forwardRef( - function EmojiPalette(props, ref) { - const { - size = 's', - defaultValue, - value, - options = [], - columns = 6, - disabled, - style, - className, - optionClassName, - iconClassName, - qa, - - onUpdate, - onFocus, - onBlur, - } = props; - - const innerRef = React.useRef(null); - const handleRef = useForkRef(ref, innerRef); - - const [valueState, setValueState] = React.useState(defaultValue); - const isControlled = value !== undefined; - const currentValue = isControlled ? value : valueState; - - const containerProps: React.ButtonHTMLAttributes = { - role: 'group', - 'aria-disabled': disabled, - 'aria-label': props['aria-label'], - 'aria-labelledby': props['aria-labelledby'], - }; - - const onUpdateOptions = React.useCallback( - (newValue: EmojiValue[]) => { - if (!isControlled) { - setValueState(newValue); - } - - if (onUpdate) { - onUpdate(newValue); - } - }, - [isControlled, onUpdate], - ); - - const finalStyles = useEmojiPaletteColumns(innerRef, style, columns); - - return ( -
- {options.map((option) => ( - - ))} -
- ); - }, -) as EmojiPaletteComponent; - -EmojiPalette.displayName = 'EmojiPalette'; - -function EmojiOptionItem({ - value, - option, - checked, - disabled, - size, - optionClassName, - iconClassName, - - onUpdate, - onFocus, - onBlur, -}: {option: EmojiOption; checked: boolean} & Pick< - EmojiPaletteProps, - | 'value' - | 'disabled' - | 'size' - | 'optionClassName' - | 'iconClassName' - | 'onUpdate' - | 'onFocus' - | 'onBlur' ->) { - const onUpdateOption = React.useCallback(() => { - if (!onUpdate) return; - - if (value) { - const newValue = value.includes(option.value) - ? value.filter((v) => v !== option.value) - : [...value, option.value]; - - onUpdate(newValue); - } else { - onUpdate([option.value]); - } - }, [onUpdate, option.value, value]); - - return ( - - ); -} - -function getOptionIcon(option: EmojiOption): React.ReactNode { - if (option.icon) return option.icon; - return typeof option.value === 'string' ? option.value : String.fromCodePoint(option.value); -} diff --git a/src/components/EmojiPalette/README.md b/src/components/EmojiPalette/README.md deleted file mode 100644 index 1838b78db..000000000 --- a/src/components/EmojiPalette/README.md +++ /dev/null @@ -1,264 +0,0 @@ - - -# EmojiPalette - - - -```tsx -import {EmojiPalette} from '@gravity-ui/uikit'; -``` - -The `EmojiPalette` component is used display a grid of emojis which you can select or unselect. - - - -### Disabled state - -You can disable all of the emojis with the `disabled` property. If you want to disable only a portion of emojis, you can change the `disabled` property of some of the `options` (`EmojiOption[]`). - - - - - -```tsx -const options: EmojiOption[] = [ - // disable a single item - {icon: '๐Ÿ˜Ž', value: 'ID-cool', disabled: true}, - {icon: '๐Ÿฅด', value: 'ID-woozy'}, -]; -// or disable all of them -; -``` - - - -### Size - -To control the size of the `EmojiPalette`, use the `size` property. The default size is `s`. - - - - - -```tsx -const options: EmojiOption[] = [ - {icon: '๐Ÿ˜Ž', value: 'ID-cool'}, - {icon: '๐Ÿฅด', value: 'ID-woozy'}, -]; - // ยซsยป is the default - - - -``` - - - -### Columns - -You can change the number of columns in the grid by changing the `columns` property (default is 6). - - - - - -```tsx -const options: EmojiOption[] = [ - {icon: '๐Ÿ˜Ž', value: 'ID-cool'}, - {icon: '๐Ÿฅด', value: 'ID-woozy'}, -]; -; -``` - - - -### Custom emojis - -You can use your own emojis/icons/images/GIFs by simply changing the `icon` property of `options` (`EmojiOption[]`) - - - - - -```tsx -const options: EmojiOption[] = [ - { - icon: {':)'}, - value: 'happy', - }, - { - icon: {':('}, - value: 'sad', - }, -]; -; -``` - - - -### Properties - -`EmojiValue = string | number`. - -`EmojiPaletteProps`: - -| Name | Description | Type | Default | -| :-------------- | :-------------------------------------------------------------------------------------- | :----------------------------------------------------: | :-----: | -| aria-label | HTML `aria-label` attribute. | `string` | | -| aria-labelledby | ID of the visible `EmojiPalette` caption element | `string` | | -| className | HTML `class` attribute. | `string` | | -| columns | Number of emojis per row. | `number` | `6` | -| defaultValue | Sets the initial value state when the component is mounted. | `EmojiValue[]` | | -| disabled | Disables the emojis. | `boolean` | false | -| iconClassName | HTML `class` attribute for the emoji icon. | `string` | | -| onBlur | `onBlur` event handler. | `(event: React.FocusEvent) => void` | | -| onFocus | `onFocus` event handler. | `(event: React.FocusEvent) => void` | | -| onUpdate | Fires when the user changes the state. Provides the new value as a callback's argument. | `(value: EmojiValue[]) => void` | | -| optionClassName | HTML `class` attribute for the emoji button. | `string` | | -| options | List of the emojis. | `EmojiOption[]` | `[]` | -| qa | HTML `data-qa` attribute, used in tests. | `string` | | -| size | Sets the size of the emojis. | `s` `m` `l` `xl` | `s` | -| style | HTML `style` attribute. | `React.CSSProperties` | | -| value | Current value for controlled usage of the component. | `EmojiValue[]` | | - -`EmojiOption`: - -| Name | Description | Type | Default | -| :------- | :---------------------- | :----------: | :-----: | -| disabled | Disables the button. | `boolean` | false | -| icon | HTML `class` attribute. | `ReactNode` | | -| title | HTML `title` attribute. | `string` | | -| value | Control value. | `EmojiValue` | | - -`EmojiControlProps`: - -| Name | Description | Type | Default | -| :------------ | :----------------------------------------- | :----------------------------------------------------: | :-----: | -| checked | Whether the emoji is selected or not. | `boolean` | `false` | -| className | HTML `class` attribute. | `string` | | -| disabled | Disables the button. | `boolean` | `false` | -| iconClassName | HTML `class` attribute for the emoji icon. | `string` | | -| value | HTML `value` attribute. | `string` | | -| onBlur | `onBlur` event handler. | `(event: React.FocusEvent) => void` | | -| onFocus | `onFocus` event handler. | `(event: React.FocusEvent) => void` | | -| onUpdate | Fires when the user (un)selects the emoji. | `(value: boolean) => void` | | -| qa | HTML `data-qa` attribute, used in tests. | `string` | | -| size | Size of the button. | `s` `m` `l` `xl` | `s` | -| style | HTML `style` attribute. | `React.CSSProperties` | | diff --git a/src/components/EmojiPalette/__stories__/EmojiPalette.stories.tsx b/src/components/EmojiPalette/__stories__/EmojiPalette.stories.tsx deleted file mode 100644 index 60b682bfb..000000000 --- a/src/components/EmojiPalette/__stories__/EmojiPalette.stories.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React from 'react'; - -import * as icons from '@gravity-ui/icons'; -import type {Meta} from '@storybook/react'; - -import {Showcase} from '../../../demo/Showcase'; -import {ShowcaseItem} from '../../../demo/ShowcaseItem/ShowcaseItem'; -import {Icon} from '../../Icon/Icon'; -import type {EmojiOption, EmojiPaletteProps, EmojiValue} from '../EmojiPalette'; -import {EmojiPalette} from '../EmojiPalette'; - -export default { - title: 'Components/Inputs/EmojiPalette', - component: EmojiPalette, -} as Meta; - -const options: EmojiOption[] = [ - {icon: 'โ˜บ๏ธ', value: 'emoji-1', title: 'smiling-face'}, - {icon: 'โค๏ธ', value: 'emoji-2', title: 'heart'}, - {icon: '๐Ÿ‘', value: 'emoji-3', title: 'thumbs-up'}, - {icon: '๐Ÿ˜‚', value: 'emoji-4', title: 'laughing'}, - {icon: '๐Ÿ˜', value: 'emoji-5', title: 'hearts-eyes'}, - {icon: '๐Ÿ˜Ž', value: 'emoji-6', title: 'cool'}, - {icon: '๐Ÿ˜›', value: 'emoji-7', title: 'tongue'}, - {icon: '๐Ÿ˜ก', value: 'emoji-8', title: 'angry'}, - {icon: '๐Ÿ˜ข', value: 'emoji-9', title: 'sad'}, - {icon: '๐Ÿ˜ฏ', value: 'emoji-10', title: 'surprised'}, - {icon: '๐Ÿ˜ฑ', value: 'emoji-11', title: 'face-screaming'}, - {icon: '๐Ÿค—', value: 'emoji-12', title: 'smiling-face-with-open-hands'}, - {icon: '๐Ÿคข', value: 'emoji-13', title: 'nauseated'}, - {icon: '๐Ÿคฅ', value: 'emoji-14', title: 'lying-face'}, - {icon: '๐Ÿคฉ', value: 'emoji-15', title: 'star-struck'}, - {icon: '๐Ÿคญ', value: 'emoji-16', title: 'face-with-hand-over-mouth'}, - {icon: '๐Ÿคฎ', value: 'emoji-17', title: 'vomiting'}, - {icon: '๐Ÿฅณ', value: 'emoji-18', title: 'partying'}, - {icon: '๐Ÿฅด', value: 'emoji-19', title: 'woozy'}, - {icon: '๐Ÿฅถ', value: 'emoji-20', title: 'cold-face'}, -]; - -export const Default = () => { - return ; -}; - -export const Disabled = () => { - return ( - - - - - - - i < 5 ? {...option, disabled: true} : option, - )} - /> - - - ); -}; - -export const Sizes = () => { - return ( - - - - - - - - - - - - - - - ); -}; - -export const Columns = () => { - return ( - - - - - - - - - - - - ); -}; - -const customOptions: EmojiOption[] = Object.entries(icons).map(([key, icon]) => ({ - icon: , - value: key, - title: key, -})); - -export const Custom = () => { - return ; -}; - -function PaletteBase(props: EmojiPaletteProps) { - const [value, setValue] = React.useState([]); - - return ; -} diff --git a/src/components/EmojiPalette/__tests__/EmojiPalette.test.tsx b/src/components/EmojiPalette/__tests__/EmojiPalette.test.tsx deleted file mode 100644 index f8ec82c18..000000000 --- a/src/components/EmojiPalette/__tests__/EmojiPalette.test.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import React from 'react'; - -import userEvent from '@testing-library/user-event'; - -import {render, screen, within} from '../../../../test-utils/utils'; -import type {ButtonSize} from '../../Button/Button'; -import type {EmojiOption} from '../EmojiPalette'; -import {EmojiPalette} from '../EmojiPalette'; - -const qaId = 'emoji-palette-component'; - -const options: EmojiOption[] = [ - {icon: '๐Ÿ˜Ž', value: 'ID-cool'}, - {icon: '๐Ÿฅด', value: 'ID-woozy'}, -]; - -describe('EmojiPalette', () => { - test('render EmojiPalette by default', () => { - render(); - - const component = screen.getByTestId(qaId); - - expect(component).toBeVisible(); - }); - - test.each(new Array('s', 'm', 'l', 'xl'))('render with given "%s" size', (size) => { - render(); - - const component = screen.getByTestId(qaId); - - expect(component).toHaveClass(`g-emoji-palette_size_${size}`); - }); - - test('all children are disabled when disabled=true prop is given', () => { - render(); - - const component = screen.getByTestId(qaId); - const emojis = within(component).getAllByRole('button'); - - emojis.forEach((radio: HTMLElement) => { - expect(radio).toBeDisabled(); - }); - }); - - test('all children are not disabled when disabled=false prop is given', () => { - render(); - - const component = screen.getByTestId(qaId); - const radios = within(component).getAllByRole('button'); - - radios.forEach((radio: HTMLElement) => { - expect(radio).not.toBeDisabled(); - }); - }); - - test('a proper emoji is disabled when disabled=false prop is given to one of the options', () => { - const customOptions: EmojiOption[] = [ - {icon: '๐Ÿฅถ', value: 'ID-cold-face', disabled: true}, - ...options, - ]; - - render(); - - const component = screen.getByTestId(qaId); - const emojis = within(component).getAllByRole('button'); - - emojis.forEach((emoji: HTMLElement) => { - const value = emoji.getAttribute('value'); - - if (value === customOptions[0].value) { - expect(emoji).toBeDisabled(); - } else { - expect(emoji).not.toBeDisabled(); - } - }); - }); - - test('show given emoji', () => { - render(); - - const text = screen.getByText(options[0].icon as string); - - expect(text).toBeVisible(); - }); - - test('add className', () => { - const className = 'my-class'; - - render(); - - const component = screen.getByTestId(qaId); - - expect(component).toHaveClass(className); - }); - - test('add style', () => { - const style = {color: 'red'}; - - render(); - - const component = screen.getByTestId(qaId); - - expect(component).toHaveStyle(style); - }); - - test('can (un)select an emoji', async () => { - const user = userEvent.setup(); - - render(); - - const component = screen.getByTestId(qaId); - const emojis = within(component).getAllByRole('button'); - - const firstEmoji = await screen.findByText(options[0].icon as string); - const secondEmoji = await screen.findByText(options[1].icon as string); - - // Check initial state [selected, unselected] - - emojis.forEach((emoji: HTMLElement) => { - const value = emoji.getAttribute('value'); - const isSelected = emoji.getAttribute('aria-pressed'); - - if (value === options[0].value) { - expect(isSelected).toBe('true'); - } else { - expect(isSelected).toBe('false'); - } - }); - - // Click on both: [selected, unselected] -> [unselected, selected] - await user.click(firstEmoji); - await user.click(secondEmoji); - - emojis.forEach((emoji: HTMLElement) => { - const value = emoji.getAttribute('value'); - const isSelected = emoji.getAttribute('aria-pressed'); - - if (value === options[0].value) { - expect(isSelected).toBe('false'); - } else { - expect(isSelected).toBe('true'); - } - }); - - // Click on the second emoji: [unselected, selected] -> [unselected, unselected] - await user.click(secondEmoji); - - emojis.forEach((emoji: HTMLElement) => { - const isSelected = emoji.getAttribute('aria-pressed'); - expect(isSelected).toBe('false'); - }); - }); -}); diff --git a/src/components/EmojiPalette/hooks.ts b/src/components/EmojiPalette/hooks.ts deleted file mode 100644 index 6d095e3c8..000000000 --- a/src/components/EmojiPalette/hooks.ts +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; - -import {emojiPaletteClassNames} from './definitions'; - -const optionsGap = 8; /* px */ - -export function useEmojiPaletteColumns( - ref: React.RefObject, - style: React.CSSProperties | undefined, - columns: number, -) { - const [layoutStyles, setLayoutStyles] = React.useState({ - gap: `${optionsGap}px`, - }); - - const finalStyles: React.CSSProperties = React.useMemo( - () => ({...layoutStyles, ...style}), - [style, layoutStyles], - ); - - React.useLayoutEffect(() => { - if (!ref.current) return; - - const option = ref.current.querySelector(`.${emojiPaletteClassNames.option()}`); - if (!option) return; - - const {width} = option.getBoundingClientRect(); - - const gaps = optionsGap * (columns - 1); - const maxWidth = `${columns * width + gaps}px`; - - if (layoutStyles.maxWidth !== maxWidth) { - setLayoutStyles((current) => ({...current, maxWidth})); - } - }, [columns, layoutStyles.maxWidth, ref]); - - return finalStyles; -} diff --git a/src/components/EmojiPalette/index.ts b/src/components/EmojiPalette/index.ts deleted file mode 100644 index 9b32df8ae..000000000 --- a/src/components/EmojiPalette/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './EmojiControl'; -export * from './EmojiPalette'; diff --git a/src/components/EmojiPalette/EmojiPalette.scss b/src/components/Palette/Palette.scss similarity index 57% rename from src/components/EmojiPalette/EmojiPalette.scss rename to src/components/Palette/Palette.scss index 0ab7555b4..1d4289ecc 100644 --- a/src/components/EmojiPalette/EmojiPalette.scss +++ b/src/components/Palette/Palette.scss @@ -1,10 +1,21 @@ @use '../variables'; -$block: '.#{variables.$ns}emoji-palette'; +$block: '.#{variables.$ns}palette'; #{$block} { display: inline-flex; - flex-wrap: wrap; + flex-flow: column wrap; + gap: 8px; + + &:focus { + border: none; + outline: none; + } + + &__row { + display: inline-flex; + gap: 8px; + } &_size_s &__option { font-size: 18px; diff --git a/src/components/Palette/Palette.tsx b/src/components/Palette/Palette.tsx new file mode 100644 index 000000000..83f88d5cf --- /dev/null +++ b/src/components/Palette/Palette.tsx @@ -0,0 +1,287 @@ +import React from 'react'; + +import {useControlledState} from '../../hooks/useControlledState/useControlledState'; +import {useForkRef} from '../../hooks/useForkRef/useForkRef'; +import type {ControlGroupProps, DOMProps, QAProps} from '../types'; + +import type {PaletteControlProps} from './PaletteControl'; +import {PaletteControl} from './PaletteControl'; +import {paletteClassNames} from './definitions'; + +import './Palette.scss'; + +export type PaletteValue = string | number; +export type PaletteOption = { + value: PaletteValue; + content?: React.ReactNode; + title?: string; // HTML title + disabled?: boolean; +}; + +export interface PaletteProps + extends Pick, + Pick, + DOMProps, + QAProps { + value?: PaletteValue[]; + defaultValue?: PaletteValue[]; + options?: PaletteOption[]; + columns?: number; + rowClassName?: string; + optionClassName?: string; + iconClassName?: string; + onUpdate?: (value: PaletteValue[]) => void; +} + +interface PaletteComponent + extends React.ForwardRefExoticComponent> {} + +export const Palette = React.forwardRef(function Palette(props, ref) { + const { + size = 's', + defaultValue, + value, + options = [], + columns = 6, + disabled, + style, + className, + rowClassName, + optionClassName, + iconClassName, + qa, + onUpdate, + onFocus, + onBlur, + } = props; + + const [focusedOptionIndex, setFocusedOptionIndex] = React.useState( + undefined, + ); + const focusedRow = + focusedOptionIndex === undefined ? undefined : Math.floor(focusedOptionIndex / columns); + const focusedColumn = + focusedOptionIndex === undefined ? undefined : focusedOptionIndex % columns; + + const innerRef = React.useRef(null); + const handleRef = useForkRef(ref, innerRef); + + const [currentValue, setCurrentValue] = useControlledState(value, defaultValue ?? [], onUpdate); + + const containerProps: React.ButtonHTMLAttributes = { + 'aria-disabled': disabled, + 'aria-label': props['aria-label'], + 'aria-labelledby': props['aria-labelledby'], + }; + + const rows = React.useMemo(() => getRows(options, columns), [columns, options]); + + const focusOnOptionWithIndex = React.useCallback((index: number) => { + if (!innerRef.current) return; + + const $options: HTMLButtonElement[] = Array.from( + innerRef.current.querySelectorAll(`.${paletteClassNames.option()}`), + ); + + $options[index].focus(); + + setFocusedOptionIndex(index); + }, []); + + const onKeyDown = React.useCallback( + (event: KeyboardEvent) => { + if ( + focusedOptionIndex === undefined || + focusedRow === undefined || + focusedColumn === undefined + ) { + return; + } + + let newIndex = focusedOptionIndex; + + if (event.code === 'ArrowUp') { + event.preventDefault(); + newIndex = focusedOptionIndex - columns; + } else if (event.code === 'ArrowRight') { + event.preventDefault(); + newIndex = focusedOptionIndex + 1; + } else if (event.code === 'ArrowDown') { + event.preventDefault(); + newIndex = focusedOptionIndex + columns; + } else if (event.code === 'ArrowLeft') { + event.preventDefault(); + newIndex = focusedOptionIndex - 1; + } + + if (newIndex === focusedOptionIndex || newIndex < 0 || newIndex >= options.length) { + return; + } + + focusOnOptionWithIndex(newIndex); + }, + [ + focusedOptionIndex, + focusedRow, + focusedColumn, + options.length, + focusOnOptionWithIndex, + columns, + ], + ); + + React.useEffect(() => { + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [onKeyDown]); + + const onFocusGrid = React.useCallback(() => { + focusOnOptionWithIndex(0); + }, [focusOnOptionWithIndex]); + + const onBlurGrid = React.useCallback(() => { + setFocusedOptionIndex(undefined); + }, []); + + const onFocusOption = React.useCallback( + (event: React.FocusEvent) => { + const newIndex = options.findIndex((option) => option.value === event.target.value); + if (newIndex >= 0) { + focusOnOptionWithIndex(newIndex); + } + + onFocus?.(event); + event.stopPropagation(); + }, + [options, focusOnOptionWithIndex, onFocus], + ); + + const onBlurOption = React.useCallback( + (event: React.FocusEvent) => { + onBlur?.(event); + setFocusedOptionIndex(undefined); + event.stopPropagation(); + }, + [onBlur], + ); + + return ( +
+ {rows.map((row, rowNumber) => ( +
+ {row.map((option) => ( + + ))} +
+ ))} +
+ ); +}) as PaletteComponent; + +Palette.displayName = 'Palette'; + +function getRows(options: PaletteOption[], columns: number): PaletteOption[][] { + const rows: PaletteOption[][] = []; + let row: PaletteOption[] = []; + + let column = 0; + for (const option of options) { + row.push(option); + column += 1; + if (column === columns) { + rows.push(row); + row = []; + column = 0; + } + } + + if (row.length > 0) { + rows.push(row); + } + + return rows; +} + +function PaletteOptionItem({ + value, + option, + checked, + disabled, + size, + optionClassName, + iconClassName, + onUpdate, + onFocus, + onBlur, +}: {option: PaletteOption; checked: boolean} & Pick< + PaletteProps, + | 'value' + | 'disabled' + | 'size' + | 'optionClassName' + | 'iconClassName' + | 'onUpdate' + | 'onFocus' + | 'onBlur' +>) { + const onUpdateOption = React.useCallback(() => { + if (!onUpdate) return; + + if (value) { + const newValue = value.includes(option.value) + ? value.filter((v) => v !== option.value) + : [...value, option.value]; + + onUpdate(newValue); + } else { + onUpdate([option.value]); + } + }, [onUpdate, option.value, value]); + + return ( + + ); +} + +function getOptionIcon(option: PaletteOption): React.ReactNode { + if (option.content) return option.content; + return typeof option.value === 'string' ? option.value : String.fromCodePoint(option.value); +} diff --git a/src/components/EmojiPalette/EmojiControl.tsx b/src/components/Palette/PaletteControl.tsx similarity index 72% rename from src/components/EmojiPalette/EmojiControl.tsx rename to src/components/Palette/PaletteControl.tsx index 62573bf05..8e6f8408b 100644 --- a/src/components/EmojiPalette/EmojiControl.tsx +++ b/src/components/Palette/PaletteControl.tsx @@ -4,27 +4,28 @@ import type {ButtonSize} from '../Button'; import {Button} from '../Button'; import type {ControlProps, DOMProps, QAProps} from '../types'; -export interface EmojiControlProps extends Pick, DOMProps, QAProps { - icon: React.ReactNode; +export interface PaletteControlProps extends Pick, DOMProps, QAProps { + content: React.ReactNode; value?: string; size?: ButtonSize; title?: string; iconClassName?: string; checked?: boolean; + focused?: boolean; onUpdate?: (updated: boolean) => void; onFocus?: (event: React.FocusEvent) => void; onBlur?: (event: React.FocusEvent) => void; } -export const EmojiControl = React.forwardRef( - function EmojiControl(props, ref) { +export const PaletteControl = React.forwardRef( + function PaletteControl(props, ref) { const { value, size = 's', disabled = false, title, - icon, + content, style, className, iconClassName, @@ -37,8 +38,8 @@ export const EmojiControl = React.forwardRef = React.useMemo( - () => ({value}), - [value], + () => ({value, role: 'checkbox', 'aria-checked': checked ? 'true' : 'false'}), + [value, checked], ); const onClick = React.useCallback(() => onUpdate?.(!checked), [checked, onUpdate]); @@ -46,6 +47,7 @@ export const EmojiControl = React.forwardRef - {icon} + {content} ); }, ); -EmojiControl.displayName = 'EmojiControl'; +PaletteControl.displayName = 'PaletteControl'; diff --git a/src/components/Palette/README.md b/src/components/Palette/README.md new file mode 100644 index 000000000..b21076d8a --- /dev/null +++ b/src/components/Palette/README.md @@ -0,0 +1,219 @@ + + +# Palette + + + +```tsx +import {Palette} from '@gravity-ui/uikit'; +``` + +The `Palette` component is used display a grid of icons/emojis/reactions/symbols which you can select or unselect. + + + +### Disabled state + +You can disable every option with the `disabled` property. If you want to disable only a portion of options, you can change the `disabled` property of some of the `options` (`PaletteOption[]`). + + + + + +```tsx +const options: PaletteOption[] = [ + // disable a single item + {content: '๐Ÿ˜Ž', value: 'ID-cool', disabled: true}, + {content: '๐Ÿฅด', value: 'ID-woozy'}, +]; +// or disable all of them +; +``` + + + +### Size + +To control the size of the `Palette`, use the `size` property. The default size is `s`. + + + + + +```tsx +const options: PaletteOption[] = [ + {content: '๐Ÿ˜Ž', value: 'ID-cool'}, + {content: '๐Ÿฅด', value: 'ID-woozy'}, +]; + + // ยซsยป is the default + + + +``` + + + +### Columns + +You can change the number of columns in the grid by changing the `columns` property (default is `6`). + + + + + +```tsx +const options: PaletteOption[] = [ + {content: '๐Ÿ˜Ž', value: 'ID-cool'}, + {content: '๐Ÿฅด', value: 'ID-woozy'}, +]; +; +``` + + + +### Properties + +`PaletteValue = string | number`. + +`PaletteProps`: + +| Name | Description | Type | Default | +| :-------------- | :-------------------------------------------------------------------------------------- | :----------------------------------------------------: | :-----: | +| aria-label | HTML `aria-label` attribute. | `string` | | +| aria-labelledby | ID of the visible `Palette` caption element | `string` | | +| className | HTML `class` attribute. | `string` | | +| columns | Number of elements per row. | `number` | `6` | +| defaultValue | Sets the initial value state when the component is mounted. | `PaletteValue[]` | | +| disabled | Disables the options. | `boolean` | `false` | +| iconClassName | HTML `class` attribute for the icon inside button. | `string` | | +| onBlur | `onBlur` event handler. | `(event: React.FocusEvent) => void` | | +| onFocus | `onFocus` event handler. | `(event: React.FocusEvent) => void` | | +| onUpdate | Fires when the user changes the state. Provides the new value as a callback's argument. | `(value: PaletteValue[]) => void` | | +| optionClassName | HTML `class` attribute for the palette button. | `string` | | +| options | List of options (palette elements). | `PaletteOption[]` | `[]` | +| qa | HTML `data-qa` attribute, used in tests. | `string` | | +| rowClassName | HTML `class` attribute for a palette row. | `string` | | +| size | Sets the size of the elements. | `s` `m` `l` `xl` | `s` | +| style | HTML `style` attribute. | `React.CSSProperties` | | +| value | Current value for controlled usage of the component. | `PaletteValue[]` | | + +`PaletteOption`: + +| Name | Description | Type | Default | +| :------- | :---------------------- | :------------: | :-----: | +| content | HTML `class` attribute. | `ReactNode` | | +| disabled | Disables the button. | `boolean` | `false` | +| title | HTML `title` attribute. | `string` | | +| value | Control value. | `PaletteValue` | | + +`PaletteControlProps`: + +| Name | Description | Type | Default | +| :------------ | :------------------------------------------------- | :----------------------------------------------------: | :-----: | +| checked | Whether the option is selected or not. | `boolean` | `false` | +| className | HTML `class` attribute. | `string` | | +| disabled | Disables the button. | `boolean` | `false` | +| iconClassName | HTML `class` attribute for the icon inside button. | `string` | | +| onBlur | `onBlur` event handler. | `(event: React.FocusEvent) => void` | | +| onFocus | `onFocus` event handler. | `(event: React.FocusEvent) => void` | | +| onUpdate | Fires when the user (un)selects the element. | `(value: boolean) => void` | | +| qa | HTML `data-qa` attribute, used in tests. | `string` | | +| size | Size of the button. | `s` `m` `l` `xl` | `s` | +| style | HTML `style` attribute. | `React.CSSProperties` | | +| value | HTML `value` attribute. | `string` | | diff --git a/src/components/EmojiPalette/__stories__/Docs.mdx b/src/components/Palette/__stories__/Docs.mdx similarity index 74% rename from src/components/EmojiPalette/__stories__/Docs.mdx rename to src/components/Palette/__stories__/Docs.mdx index 44f723f60..2cb4d6591 100644 --- a/src/components/EmojiPalette/__stories__/Docs.mdx +++ b/src/components/Palette/__stories__/Docs.mdx @@ -1,5 +1,5 @@ import {Meta, Markdown} from '@storybook/addon-docs'; -import * as Stories from './EmojiPalette.stories'; +import * as Stories from './Palette.stories'; import Readme from '../README.md?raw'; diff --git a/src/components/Palette/__stories__/Palette.stories.tsx b/src/components/Palette/__stories__/Palette.stories.tsx new file mode 100644 index 000000000..3c5ef3c90 --- /dev/null +++ b/src/components/Palette/__stories__/Palette.stories.tsx @@ -0,0 +1,125 @@ +import React from 'react'; + +import * as icons from '@gravity-ui/icons'; +import type {Meta} from '@storybook/react'; + +import {Showcase} from '../../../demo/Showcase'; +import {ShowcaseItem} from '../../../demo/ShowcaseItem/ShowcaseItem'; +import {Icon} from '../../Icon/Icon'; +import type {PaletteOption, PaletteProps, PaletteValue} from '../Palette'; +import {Palette} from '../Palette'; + +export default { + title: 'Components/Inputs/Palette', + component: Palette, +} as Meta; + +const options: PaletteOption[] = [ + {content: '๐Ÿ˜Š', value: 'value-1', title: 'smiling-face'}, + {content: 'โค๏ธ', value: 'value-2', title: 'heart'}, + {content: '๐Ÿ‘', value: 'value-3', title: 'thumbs-up'}, + {content: '๐Ÿ˜‚', value: 'value-4', title: 'laughing'}, + {content: '๐Ÿ˜', value: 'value-5', title: 'hearts-eyes'}, + {content: '๐Ÿ˜Ž', value: 'value-6', title: 'cool'}, + {content: '๐Ÿ˜›', value: 'value-7', title: 'tongue'}, + {content: '๐Ÿ˜ก', value: 'value-8', title: 'angry'}, + {content: '๐Ÿ˜ข', value: 'value-9', title: 'sad'}, + {content: '๐Ÿ˜ฏ', value: 'value-10', title: 'surprised'}, + {content: '๐Ÿ˜ฑ', value: 'value-11', title: 'face-screaming'}, + {content: '๐Ÿค—', value: 'value-12', title: 'smiling-face-with-open-hands'}, + {content: '๐Ÿคข', value: 'value-13', title: 'nauseated'}, + {content: '๐Ÿคฅ', value: 'value-14', title: 'lying-face'}, + {content: '๐Ÿคฉ', value: 'value-15', title: 'star-struck'}, + {content: '๐Ÿคญ', value: 'value-16', title: 'face-with-hand-over-mouth'}, + {content: '๐Ÿคฎ', value: 'value-17', title: 'vomiting'}, + {content: '๐Ÿฅณ', value: 'value-18', title: 'partying'}, + {content: '๐Ÿฅด', value: 'value-19', title: 'woozy'}, + {content: '๐Ÿฅถ', value: 'value-20', title: 'cold-face'}, +]; + +export const Default = () => { + return ; +}; + +export const Disabled = () => { + return ( + + + + + + + i < 5 ? {...option, disabled: true} : option, + )} + /> + + + ); +}; + +export const Sizes = () => { + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export const Columns = () => { + return ( + + + + + + + + + + + + ); +}; + +const iconsOptions = Object.entries(icons).map( + ([key, icon]): PaletteOption => ({ + content: , + value: key, + title: key, + }), +); + +export const Icons = () => { + return ; +}; + +const alphabetOptions = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map( + (letter): PaletteOption => ({ + value: letter, + }), +); + +export const Alphabet = () => { + return ; +}; + +function PaletteBase(props: PaletteProps) { + const [value, setValue] = React.useState([]); + + return ; +} diff --git a/src/components/Palette/__tests__/Palette.test.tsx b/src/components/Palette/__tests__/Palette.test.tsx new file mode 100644 index 000000000..3abf8add2 --- /dev/null +++ b/src/components/Palette/__tests__/Palette.test.tsx @@ -0,0 +1,157 @@ +import React from 'react'; + +import userEvent from '@testing-library/user-event'; + +import {render, screen, within} from '../../../../test-utils/utils'; +import type {ButtonSize} from '../../Button/Button'; +import type {PaletteOption} from '../Palette'; +import {Palette} from '../Palette'; + +const qaId = 'palette-component'; + +const defaultOptions: PaletteOption[] = [ + {content: '๐Ÿ˜Ž', value: 'ID-cool'}, + {content: '๐Ÿฅด', value: 'ID-woozy'}, +]; + +describe('Palette', () => { + test('render Palette by default', () => { + render(); + + const $component = screen.getByTestId(qaId); + + expect($component).toBeVisible(); + }); + + test.each(new Array('s', 'm', 'l', 'xl'))('render with given "%s" size', (size) => { + render(); + + const $component = screen.getByTestId(qaId); + + expect($component).toHaveClass(`g-palette_size_${size}`); + }); + + test('all children are disabled when disabled=true prop is given', () => { + render(); + + const $component = screen.getByTestId(qaId); + const $options = within($component).getAllByRole('checkbox'); + + $options.forEach(($option: HTMLElement) => { + expect($option).toBeDisabled(); + }); + }); + + test('all children are not disabled when disabled=false prop is given', () => { + render(); + + const $component = screen.getByTestId(qaId); + const $options = within($component).getAllByRole('checkbox'); + + $options.forEach(($option: HTMLElement) => { + expect($option).not.toBeDisabled(); + }); + }); + + test('a proper option is disabled when disabled=false prop is given to one of the options', () => { + const customOptions: PaletteOption[] = [ + {content: '๐Ÿฅถ', value: 'ID-cold-face', disabled: true}, + ...defaultOptions, + ]; + + render(); + + const $component = screen.getByTestId(qaId); + const $options = within($component).getAllByRole('checkbox'); + + $options.forEach(($option: HTMLElement) => { + const value = $option.getAttribute('value'); + + if (value === customOptions[0].value) { + expect($option).toBeDisabled(); + } else { + expect($option).not.toBeDisabled(); + } + }); + }); + + test('show given option', () => { + render(); + + const text = screen.getByText(defaultOptions[0].content as string); + + expect(text).toBeVisible(); + }); + + test('add className', () => { + const className = 'my-class'; + + render( + , + ); + + const $component = screen.getByTestId(qaId); + + expect($component).toHaveClass(className); + }); + + test('add style', () => { + const style = {color: 'red'}; + + render(); + + const $component = screen.getByTestId(qaId); + + expect($component).toHaveStyle(style); + }); + + test('can (un)select an option', async () => { + const user = userEvent.setup(); + + render( + , + ); + + const $component = screen.getByTestId(qaId); + const $options = within($component).getAllByRole('checkbox'); + + const $firstOption = await screen.findByText(defaultOptions[0].content as string); + const $secondOption = await screen.findByText(defaultOptions[1].content as string); + + // Check initial state [selected, unselected] + + $options.forEach(($option: HTMLElement) => { + const value = $option.getAttribute('value'); + const isSelected = $option.getAttribute('aria-pressed'); + + if (value === defaultOptions[0].value) { + expect(isSelected).toBe('true'); + } else { + expect(isSelected).toBe('false'); + } + }); + + // Click on both: [selected, unselected] -> [unselected, selected] + await user.click($firstOption); + await user.click($secondOption); + + $options.forEach(($option: HTMLElement) => { + const value = $option.getAttribute('value'); + const isSelected = $option.getAttribute('aria-pressed'); + + if (value === defaultOptions[0].value) { + expect(isSelected).toBe('false'); + } else { + expect(isSelected).toBe('true'); + } + }); + + // Click on the second option: [unselected, selected] -> [unselected, unselected] + await user.click($secondOption); + + $options.forEach(($option: HTMLElement) => { + const isSelected = $option.getAttribute('aria-pressed'); + expect(isSelected).toBe('false'); + }); + }); +}); diff --git a/src/components/EmojiPalette/definitions.ts b/src/components/Palette/definitions.ts similarity index 66% rename from src/components/EmojiPalette/definitions.ts rename to src/components/Palette/definitions.ts index dfa4ee598..ac7a9c70c 100644 --- a/src/components/EmojiPalette/definitions.ts +++ b/src/components/Palette/definitions.ts @@ -1,9 +1,10 @@ import type {ButtonSize} from '../Button/Button'; import {block} from '../utils/cn'; -const b = block('emoji-palette'); +const b = block('palette'); -export const emojiPaletteClassNames = { +export const paletteClassNames = { + row: (className?: string) => b('row', className), palette: ({size}: {size: ButtonSize}, className?: string) => b({size}, className), option: (className?: string) => b('option', className), }; diff --git a/src/components/Palette/index.ts b/src/components/Palette/index.ts new file mode 100644 index 000000000..90d49d9ea --- /dev/null +++ b/src/components/Palette/index.ts @@ -0,0 +1,2 @@ +export * from './PaletteControl'; +export * from './Palette'; diff --git a/src/components/index.ts b/src/components/index.ts index 21f9281d8..9f9fb933e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -16,7 +16,6 @@ export * from './CopyToClipboard'; export * from './Dialog'; export * from './Disclosure'; export * from './DropdownMenu'; -export * from './EmojiPalette'; export * from './Hotkey'; export * from './Icon'; export * from './Label'; @@ -26,6 +25,7 @@ export * from './Loader'; export * from './Menu'; export * from './Modal'; export * from './Pagination'; +export * from './Palette'; export * from './UserLabel'; export * from './Popover'; export * from './Popup'; From b2ddfd38987819f3f4f76e4a2f565f484907a1fe Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Mon, 26 Feb 2024 17:11:22 +0300 Subject: [PATCH 4/6] fix: god rid of the component, added property, added the hook --- package-lock.json | 2 +- src/components/Palette/Palette.scss | 3 + src/components/Palette/Palette.tsx | 344 +++++++----------- src/components/Palette/PaletteControl.tsx | 70 ---- src/components/Palette/README.md | 105 +++--- .../Palette/__stories__/Palette.stories.tsx | 18 +- .../Palette/__tests__/Palette.test.tsx | 8 +- src/components/Palette/definitions.ts | 10 - src/components/Palette/hooks.ts | 57 +++ src/components/Palette/index.ts | 1 - src/components/Palette/utils.ts | 23 ++ 11 files changed, 299 insertions(+), 342 deletions(-) delete mode 100644 src/components/Palette/PaletteControl.tsx delete mode 100644 src/components/Palette/definitions.ts create mode 100644 src/components/Palette/hooks.ts create mode 100644 src/components/Palette/utils.ts diff --git a/package-lock.json b/package-lock.json index 61923dc02..bd48140e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,8 @@ "blueimp-md5": "^2.19.0", "focus-trap": "^7.5.4", "lodash": "^4.17.21", - "react-beautiful-dnd": "^13.1.1", "rc-slider": "^10.5.0", + "react-beautiful-dnd": "^13.1.1", "react-copy-to-clipboard": "^5.1.0", "react-popper": "^2.3.0", "react-transition-group": "^4.4.5", diff --git a/src/components/Palette/Palette.scss b/src/components/Palette/Palette.scss index 1d4289ecc..e4226c6f6 100644 --- a/src/components/Palette/Palette.scss +++ b/src/components/Palette/Palette.scss @@ -17,6 +17,9 @@ $block: '.#{variables.$ns}palette'; gap: 8px; } + &_size_xs &__option { + font-size: 16px; + } &_size_s &__option { font-size: 18px; } diff --git a/src/components/Palette/Palette.tsx b/src/components/Palette/Palette.tsx index 83f88d5cf..aa42c69f4 100644 --- a/src/components/Palette/Palette.tsx +++ b/src/components/Palette/Palette.tsx @@ -1,36 +1,87 @@ import React from 'react'; -import {useControlledState} from '../../hooks/useControlledState/useControlledState'; +import {useSelect} from '../../hooks'; import {useForkRef} from '../../hooks/useForkRef/useForkRef'; +import type {ButtonProps} from '../Button'; +import {Button} from '../Button'; import type {ControlGroupProps, DOMProps, QAProps} from '../types'; +import {block} from '../utils/cn'; -import type {PaletteControlProps} from './PaletteControl'; -import {PaletteControl} from './PaletteControl'; -import {paletteClassNames} from './definitions'; +import {usePaletteGrid} from './hooks'; +import {getPaletteRows} from './utils'; import './Palette.scss'; -export type PaletteValue = string | number; -export type PaletteOption = { - value: PaletteValue; +const b = block('palette'); + +export type PaletteOption = Pick & { + /** + * Option value, which you can use in state or send to back-end and so on. + */ + value: string; + /** + * Content inside the option (emoji/image/GIF/symbol etc). + * + * Uses `value` as default, if `value` is a number, then it is treated as a unicode symbol (emoji for example). + * + * @default props.value + */ content?: React.ReactNode; - title?: string; // HTML title - disabled?: boolean; }; export interface PaletteProps extends Pick, - Pick, + Pick, DOMProps, QAProps { - value?: PaletteValue[]; - defaultValue?: PaletteValue[]; + /** + * Allows selecting multiple options. + * + * @default true + */ + multiple?: boolean; + /** + * Current value (which options are selected). + */ + value?: string[]; + /** + * The control's default value. Use when the component is not controlled. + */ + defaultValue?: string[]; + /** + * List of Palette options (the grid). + */ options?: PaletteOption[]; + /** + * How many options are there per row. + * + * @default 6 + */ columns?: number; + /** + * HTML class attribute for a grid row. + */ rowClassName?: string; + /** + * HTML class attribute for a grid option. + */ optionClassName?: string; + /** + * HTML class attribute for the `` inside a grid option. + */ iconClassName?: string; - onUpdate?: (value: PaletteValue[]) => void; + /** + * Fires when a user (un)selects an option. + */ + onUpdate?: (value: string[]) => void; + /** + * Fires when a user focuses on the Palette. + */ + onFocus?: (event: React.FocusEvent) => void; + /** + * Fires when a user blurs from the Palette. + */ + onBlur?: (event: React.FocusEvent) => void; } interface PaletteComponent @@ -39,8 +90,7 @@ interface PaletteComponent export const Palette = React.forwardRef(function Palette(props, ref) { const { size = 's', - defaultValue, - value, + multiple = true, options = [], columns = 6, disabled, @@ -50,7 +100,6 @@ export const Palette = React.forwardRef(function P optionClassName, iconClassName, qa, - onUpdate, onFocus, onBlur, } = props; @@ -58,146 +107,107 @@ export const Palette = React.forwardRef(function P const [focusedOptionIndex, setFocusedOptionIndex] = React.useState( undefined, ); - const focusedRow = - focusedOptionIndex === undefined ? undefined : Math.floor(focusedOptionIndex / columns); - const focusedColumn = - focusedOptionIndex === undefined ? undefined : focusedOptionIndex % columns; + const focusedOption = + focusedOptionIndex === undefined ? undefined : options[focusedOptionIndex]; const innerRef = React.useRef(null); const handleRef = useForkRef(ref, innerRef); - const [currentValue, setCurrentValue] = useControlledState(value, defaultValue ?? [], onUpdate); + const {value, handleSelection} = useSelect({ + value: props.value, + defaultValue: props.defaultValue, + multiple, + onUpdate: props.onUpdate, + }); - const containerProps: React.ButtonHTMLAttributes = { - 'aria-disabled': disabled, - 'aria-label': props['aria-label'], - 'aria-labelledby': props['aria-labelledby'], - }; - - const rows = React.useMemo(() => getRows(options, columns), [columns, options]); + const rows = React.useMemo(() => getPaletteRows(options, columns), [columns, options]); const focusOnOptionWithIndex = React.useCallback((index: number) => { if (!innerRef.current) return; - const $options: HTMLButtonElement[] = Array.from( - innerRef.current.querySelectorAll(`.${paletteClassNames.option()}`), - ); + const $options = Array.from( + innerRef.current.querySelectorAll(`.${b('option')}`), + ) as HTMLButtonElement[]; + + if (!$options[index]) return; $options[index].focus(); setFocusedOptionIndex(index); }, []); - const onKeyDown = React.useCallback( - (event: KeyboardEvent) => { - if ( - focusedOptionIndex === undefined || - focusedRow === undefined || - focusedColumn === undefined - ) { - return; - } - - let newIndex = focusedOptionIndex; - - if (event.code === 'ArrowUp') { - event.preventDefault(); - newIndex = focusedOptionIndex - columns; - } else if (event.code === 'ArrowRight') { - event.preventDefault(); - newIndex = focusedOptionIndex + 1; - } else if (event.code === 'ArrowDown') { - event.preventDefault(); - newIndex = focusedOptionIndex + columns; - } else if (event.code === 'ArrowLeft') { - event.preventDefault(); - newIndex = focusedOptionIndex - 1; - } - - if (newIndex === focusedOptionIndex || newIndex < 0 || newIndex >= options.length) { - return; - } - - focusOnOptionWithIndex(newIndex); - }, - [ - focusedOptionIndex, - focusedRow, - focusedColumn, - options.length, - focusOnOptionWithIndex, - columns, - ], - ); - - React.useEffect(() => { - document.addEventListener('keydown', onKeyDown); - return () => document.removeEventListener('keydown', onKeyDown); - }, [onKeyDown]); - - const onFocusGrid = React.useCallback(() => { - focusOnOptionWithIndex(0); - }, [focusOnOptionWithIndex]); - - const onBlurGrid = React.useCallback(() => { - setFocusedOptionIndex(undefined); - }, []); + const tryToFocus = (newIndex: number) => { + if (newIndex === focusedOptionIndex || newIndex < 0 || newIndex >= options.length) { + return; + } - const onFocusOption = React.useCallback( - (event: React.FocusEvent) => { - const newIndex = options.findIndex((option) => option.value === event.target.value); - if (newIndex >= 0) { - focusOnOptionWithIndex(newIndex); - } + focusOnOptionWithIndex(newIndex); + }; + const gridProps = usePaletteGrid({ + disabled, + onFocus: (event) => { + focusOnOptionWithIndex(0); onFocus?.(event); - event.stopPropagation(); }, - [options, focusOnOptionWithIndex, onFocus], - ); - - const onBlurOption = React.useCallback( - (event: React.FocusEvent) => { - onBlur?.(event); + onBlur: (event) => { setFocusedOptionIndex(undefined); - event.stopPropagation(); + onBlur?.(event); }, - [onBlur], - ); + whenFocused: + focusedOptionIndex !== undefined && focusedOption + ? { + selectItem: () => handleSelection(focusedOption), + nextItem: () => tryToFocus(focusedOptionIndex + 1), + previousItem: () => tryToFocus(focusedOptionIndex - 1), + nextRow: () => tryToFocus(focusedOptionIndex + columns), + previousRow: () => tryToFocus(focusedOptionIndex - columns), + } + : undefined, + }); return (
{rows.map((row, rowNumber) => ( -
- {row.map((option) => ( - - ))} +
+ {row.map((option) => { + const isSelected = Boolean(value.includes(option.value)); + const focused = option === focusedOption; + + return ( +
+ +
+ ); + })}
))}
@@ -205,83 +215,3 @@ export const Palette = React.forwardRef(function P }) as PaletteComponent; Palette.displayName = 'Palette'; - -function getRows(options: PaletteOption[], columns: number): PaletteOption[][] { - const rows: PaletteOption[][] = []; - let row: PaletteOption[] = []; - - let column = 0; - for (const option of options) { - row.push(option); - column += 1; - if (column === columns) { - rows.push(row); - row = []; - column = 0; - } - } - - if (row.length > 0) { - rows.push(row); - } - - return rows; -} - -function PaletteOptionItem({ - value, - option, - checked, - disabled, - size, - optionClassName, - iconClassName, - onUpdate, - onFocus, - onBlur, -}: {option: PaletteOption; checked: boolean} & Pick< - PaletteProps, - | 'value' - | 'disabled' - | 'size' - | 'optionClassName' - | 'iconClassName' - | 'onUpdate' - | 'onFocus' - | 'onBlur' ->) { - const onUpdateOption = React.useCallback(() => { - if (!onUpdate) return; - - if (value) { - const newValue = value.includes(option.value) - ? value.filter((v) => v !== option.value) - : [...value, option.value]; - - onUpdate(newValue); - } else { - onUpdate([option.value]); - } - }, [onUpdate, option.value, value]); - - return ( - - ); -} - -function getOptionIcon(option: PaletteOption): React.ReactNode { - if (option.content) return option.content; - return typeof option.value === 'string' ? option.value : String.fromCodePoint(option.value); -} diff --git a/src/components/Palette/PaletteControl.tsx b/src/components/Palette/PaletteControl.tsx deleted file mode 100644 index 8e6f8408b..000000000 --- a/src/components/Palette/PaletteControl.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react'; - -import type {ButtonSize} from '../Button'; -import {Button} from '../Button'; -import type {ControlProps, DOMProps, QAProps} from '../types'; - -export interface PaletteControlProps extends Pick, DOMProps, QAProps { - content: React.ReactNode; - value?: string; - size?: ButtonSize; - title?: string; - iconClassName?: string; - checked?: boolean; - focused?: boolean; - - onUpdate?: (updated: boolean) => void; - onFocus?: (event: React.FocusEvent) => void; - onBlur?: (event: React.FocusEvent) => void; -} - -export const PaletteControl = React.forwardRef( - function PaletteControl(props, ref) { - const { - value, - size = 's', - disabled = false, - title, - content, - style, - className, - iconClassName, - qa, - checked, - - onUpdate, - onFocus, - onBlur, - } = props; - - const extraProps: React.ButtonHTMLAttributes = React.useMemo( - () => ({value, role: 'checkbox', 'aria-checked': checked ? 'true' : 'false'}), - [value, checked], - ); - - const onClick = React.useCallback(() => onUpdate?.(!checked), [checked, onUpdate]); - - return ( - - ); - }, -); - -PaletteControl.displayName = 'PaletteControl'; diff --git a/src/components/Palette/README.md b/src/components/Palette/README.md index b21076d8a..0a321d993 100644 --- a/src/components/Palette/README.md +++ b/src/components/Palette/README.md @@ -20,13 +20,13 @@ You can disable every option with the `disabled` property. If you want to disabl + `} > - // ยซsยป is the default - - - + + // ยซsยป is the default + + + `} > ; @@ -137,14 +137,52 @@ You can change the number of columns in the grid by changing the `columns` prope +`} +> + +; + +LANDING_BLOCK--> + + + +```tsx const options: PaletteOption[] = [ + {content: '๐Ÿ˜Ž', value: 'ID-cool'}, + {content: '๐Ÿฅด', value: 'ID-woozy'}, +]; +; +``` + + + +### Multiple + +By default you can (un)select multiple option, but in case you want only one option to be selected, you can disable the `multiple` property. + +