From e731838318635659183a05feb0ef630683ec7d67 Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Fri, 15 Mar 2024 14:44:16 +0300 Subject: [PATCH] feat: add Palette component (#1304) --- src/components/Palette/Palette.scss | 35 +++ src/components/Palette/Palette.tsx | 210 +++++++++++++++ src/components/Palette/README.md | 239 ++++++++++++++++++ src/components/Palette/__stories__/Docs.mdx | 7 + .../Palette/__stories__/Palette.stories.tsx | 157 ++++++++++++ .../Palette/__tests__/Palette.test.tsx | 157 ++++++++++++ .../Palette/__tests__/utils.test.ts | 41 +++ src/components/Palette/hooks.ts | 68 +++++ src/components/Palette/index.ts | 1 + src/components/Palette/utils.ts | 27 ++ src/components/index.ts | 1 + 11 files changed, 943 insertions(+) create mode 100644 src/components/Palette/Palette.scss create mode 100644 src/components/Palette/Palette.tsx create mode 100644 src/components/Palette/README.md create mode 100644 src/components/Palette/__stories__/Docs.mdx create mode 100644 src/components/Palette/__stories__/Palette.stories.tsx create mode 100644 src/components/Palette/__tests__/Palette.test.tsx create mode 100644 src/components/Palette/__tests__/utils.test.ts create mode 100644 src/components/Palette/hooks.ts create mode 100644 src/components/Palette/index.ts create mode 100644 src/components/Palette/utils.ts diff --git a/src/components/Palette/Palette.scss b/src/components/Palette/Palette.scss new file mode 100644 index 0000000000..52d26d4359 --- /dev/null +++ b/src/components/Palette/Palette.scss @@ -0,0 +1,35 @@ +@use '../variables'; + +$block: '.#{variables.$ns}palette'; + +#{$block} { + display: inline-flex; + flex-flow: column wrap; + gap: 8px; + + &:focus { + border: none; + outline: none; + } + + &__row { + display: inline-flex; + gap: 8px; + } + + &_size_xs &__option { + font-size: 12px; + } + &_size_s &__option { + font-size: 16px; + } + &_size_m &__option { + font-size: 16px; + } + &_size_l &__option { + font-size: 16px; + } + &_size_xl &__option { + font-size: 20px; + } +} diff --git a/src/components/Palette/Palette.tsx b/src/components/Palette/Palette.tsx new file mode 100644 index 0000000000..22908e9906 --- /dev/null +++ b/src/components/Palette/Palette.tsx @@ -0,0 +1,210 @@ +import React from 'react'; + +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 {usePaletteGrid} from './hooks'; +import {getPaletteRows} from './utils'; + +import './Palette.scss'; + +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; +}; + +export interface PaletteProps + extends Pick, + Pick, + DOMProps, + QAProps { + /** + * 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; + /** + * 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 + extends React.ForwardRefExoticComponent> {} + +export const Palette = React.forwardRef(function Palette(props, ref) { + const { + size = 'm', + multiple = true, + options = [], + columns = 6, + disabled, + style, + className, + rowClassName, + optionClassName, + qa, + onFocus, + onBlur, + } = props; + + const [focusedOptionIndex, setFocusedOptionIndex] = React.useState( + undefined, + ); + const focusedOption = + focusedOptionIndex === undefined ? undefined : options[focusedOptionIndex]; + + const innerRef = React.useRef(null); + const handleRef = useForkRef(ref, innerRef); + + const {value, handleSelection} = useSelect({ + value: props.value, + defaultValue: props.defaultValue, + multiple, + onUpdate: props.onUpdate, + }); + + const rows = React.useMemo(() => getPaletteRows(options, columns), [columns, options]); + + const focusOnOptionWithIndex = React.useCallback((index: number) => { + if (!innerRef.current) return; + + const $options = Array.from( + innerRef.current.querySelectorAll(`.${b('option')}`), + ) as HTMLButtonElement[]; + + if (!$options[index]) return; + + $options[index].focus(); + + setFocusedOptionIndex(index); + }, []); + + const tryToFocus = (newIndex: number) => { + if (newIndex === focusedOptionIndex || newIndex < 0 || newIndex >= options.length) { + return; + } + + focusOnOptionWithIndex(newIndex); + }; + + const gridProps = usePaletteGrid({ + disabled, + onFocus: (event) => { + focusOnOptionWithIndex(0); + onFocus?.(event); + }, + onBlur: (event) => { + setFocusedOptionIndex(undefined); + onBlur?.(event); + }, + 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) => { + const isSelected = Boolean(value.includes(option.value)); + const focused = option === focusedOption; + + return ( +
+ +
+ ); + })} +
+ ))} +
+ ); +}) as PaletteComponent; + +Palette.displayName = 'Palette'; diff --git a/src/components/Palette/README.md b/src/components/Palette/README.md new file mode 100644 index 0000000000..73490cf416 --- /dev/null +++ b/src/components/Palette/README.md @@ -0,0 +1,239 @@ + + +# 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'}, +]; +; +``` + + + +### 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. + + + + + +```tsx +const options: PaletteOption[] = [ + {content: '๐Ÿ˜Ž', value: 'ID-cool'}, + {content: '๐Ÿฅด', value: 'ID-woozy'}, +]; +; +``` + + + +### Properties + +`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. | `string[]` | | +| disabled | Disables the options. | `boolean` | `false` | +| multiple | Allows selecting multiple options. | `boolean` | `true` | +| 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: string[]) => 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. | `xs` `s` `m` `l` `xl` | `m` | +| style | HTML `style` attribute. | `React.CSSProperties` | | +| value | Current value for controlled usage of the component. | `string[]` | | + +`PaletteOption`: + +| Name | Description | Type | Default | +| :------- | :---------------------- | :---------: | :-----: | +| content | HTML `class` attribute. | `ReactNode` | | +| disabled | Disables the button. | `boolean` | `false` | +| title | HTML `title` attribute. | `string` | | +| value | Control value. | `string` | | diff --git a/src/components/Palette/__stories__/Docs.mdx b/src/components/Palette/__stories__/Docs.mdx new file mode 100644 index 0000000000..2cb4d6591e --- /dev/null +++ b/src/components/Palette/__stories__/Docs.mdx @@ -0,0 +1,7 @@ +import {Meta, Markdown} from '@storybook/addon-docs'; +import * as Stories from './Palette.stories'; +import Readme from '../README.md?raw'; + + + +{Readme} diff --git a/src/components/Palette/__stories__/Palette.stories.tsx b/src/components/Palette/__stories__/Palette.stories.tsx new file mode 100644 index 0000000000..6f7d60f663 --- /dev/null +++ b/src/components/Palette/__stories__/Palette.stories.tsx @@ -0,0 +1,157 @@ +import React from 'react'; + +import { + ArrowDown, + ArrowDownFromLine, + ArrowDownToLine, + ArrowDownToSquare, + ArrowLeft, + ArrowLeftFromLine, + ArrowLeftToLine, + ArrowRight, + ArrowRightArrowLeft, + ArrowRightFromLine, + ArrowRightFromSquare, + ArrowRightToLine, + ArrowRightToSquare, + ArrowRotateLeft, + ArrowRotateRight, +} from '@gravity-ui/icons'; +import type {Meta, StoryObj} from '@storybook/react'; + +import {Showcase} from '../../../demo/Showcase'; +import {ShowcaseItem} from '../../../demo/ShowcaseItem/ShowcaseItem'; +import {Icon} from '../../Icon/Icon'; +import type {PaletteOption} from '../Palette'; +import {Palette} from '../Palette'; + +export default { + title: 'Components/Inputs/Palette', + component: Palette, +} as Meta; + +type Story = StoryObj; + +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: Story = {args: {options}}; + +export const SingleSelect: Story = { + args: {...Default.args, multiple: false}, +}; + +export const Disabled: Story = { + render: (args) => ( + + + + + + + i < 5 ? {...option, disabled: true} : option, + )} + /> + + + ), +}; + +export const Sizes: Story = { + render: (args) => ( + + + + + + + + + + + + + + + + + + ), +}; + +export const Columns: Story = { + render: (args) => ( + + + + + + + + + + + + ), +}; + +const icons = { + ArrowDown, + ArrowDownFromLine, + ArrowDownToLine, + ArrowDownToSquare, + ArrowLeft, + ArrowLeftFromLine, + ArrowLeftToLine, + ArrowRight, + ArrowRightArrowLeft, + ArrowRightFromLine, + ArrowRightFromSquare, + ArrowRightToLine, + ArrowRightToSquare, + ArrowRotateLeft, + ArrowRotateRight, +}; +const iconsOptions = Object.entries(icons).map( + ([key, icon]): PaletteOption => ({ + content: , + value: key, + title: key, + }), +); + +export const Icons: Story = { + args: {...Default.args, options: iconsOptions}, +}; + +const alphabetOptions = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map( + (letter): PaletteOption => ({ + value: letter, + }), +); + +export const Alphabet: Story = { + args: {...Default.args, options: alphabetOptions}, +}; diff --git a/src/components/Palette/__tests__/Palette.test.tsx b/src/components/Palette/__tests__/Palette.test.tsx new file mode 100644 index 0000000000..7a0565abac --- /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('button'); + + $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('button'); + + $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('button'); + + $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('button'); + + 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/Palette/__tests__/utils.test.ts b/src/components/Palette/__tests__/utils.test.ts new file mode 100644 index 0000000000..fa171d2944 --- /dev/null +++ b/src/components/Palette/__tests__/utils.test.ts @@ -0,0 +1,41 @@ +import type {PaletteOption} from '../Palette'; +import {getPaletteRows} from '../utils'; + +const A: PaletteOption = {content: '๐Ÿ˜Ž', value: 'ID-1'}; +const B: PaletteOption = {content: '๐Ÿฅด', value: 'ID-2'}; +const C: PaletteOption = {content: '๐Ÿ˜ฑ', value: 'ID-3'}; +const D: PaletteOption = {content: '๐Ÿค—', value: 'ID-4'}; + +const options: PaletteOption[] = [A, B, C, D]; + +describe('Palette utils', () => { + describe('getPaletteRows', () => { + it('[A][B][C][D] when columns = 1', () => { + expect(getPaletteRows(options, 1)).toEqual([[A], [B], [C], [D]]); + }); + it('[[AB][CD]] when columns = 2', () => { + expect(getPaletteRows(options, 2)).toEqual([ + [A, B], + [C, D], + ]); + }); + it('[[ABC][D]] when columns = 3', () => { + expect(getPaletteRows(options, 3)).toEqual([[A, B, C], [D]]); + }); + it('[[ABCD]] when columns = 4', () => { + expect(getPaletteRows(options, 4)).toEqual([[A, B, C, D]]); + }); + it('error when columns <= 0', () => { + let hasThrownError = false; + try { + getPaletteRows(options, 0); + } catch (error) { + hasThrownError = true; + } + expect(hasThrownError).toEqual(true); + }); + it('empty array when no options passed', () => { + expect(getPaletteRows([], 1)).toEqual([]); + }); + }); +}); diff --git a/src/components/Palette/hooks.ts b/src/components/Palette/hooks.ts new file mode 100644 index 0000000000..5544c27374 --- /dev/null +++ b/src/components/Palette/hooks.ts @@ -0,0 +1,68 @@ +import {useFocusWithin} from '../../hooks/useFocusWithin/useFocusWithin'; +import {useDirection} from '../theme/useDirection'; + +export interface PaletteGridProps { + disabled?: boolean; + onFocus?: (event: React.FocusEvent) => void; + onBlur?: (event: React.FocusEvent) => void; + whenFocused?: { + selectItem: () => void; + nextItem: () => void; + previousItem: () => void; + nextRow: () => void; + previousRow: () => void; + }; +} + +export function usePaletteGrid(props: PaletteGridProps): React.HTMLAttributes { + const direction = useDirection(); + + const {focusWithinProps} = useFocusWithin({ + onFocusWithin: (event) => props.onFocus?.(event), + onBlurWithin: (event) => props.onBlur?.(event), + }); + + const whenFocused = props.whenFocused; + + const base: React.ButtonHTMLAttributes = { + role: 'grid', + 'aria-disabled': props.disabled, + 'aria-readonly': props.disabled, + tabIndex: whenFocused ? -1 : 0, + ...focusWithinProps, + }; + + if (!whenFocused) { + return base; + } + + return { + ...base, + onKeyDown: (event) => { + if (event.code === 'ArrowRight') { + event.preventDefault(); + if (direction === 'ltr') { + whenFocused.nextItem(); + } else { + whenFocused.previousItem(); + } + } else if (event.code === 'ArrowLeft') { + event.preventDefault(); + if (direction === 'ltr') { + whenFocused.previousItem(); + } else { + whenFocused.nextItem(); + } + } else if (event.code === 'ArrowDown') { + event.preventDefault(); + whenFocused.nextRow(); + } else if (event.code === 'ArrowUp') { + event.preventDefault(); + whenFocused.previousRow(); + } else if (event.code === 'Space' || event.code === 'Enter') { + event.preventDefault(); + whenFocused.selectItem(); + } + }, + }; +} diff --git a/src/components/Palette/index.ts b/src/components/Palette/index.ts new file mode 100644 index 0000000000..b9e2b23437 --- /dev/null +++ b/src/components/Palette/index.ts @@ -0,0 +1 @@ +export * from './Palette'; diff --git a/src/components/Palette/utils.ts b/src/components/Palette/utils.ts new file mode 100644 index 0000000000..1ee9b16088 --- /dev/null +++ b/src/components/Palette/utils.ts @@ -0,0 +1,27 @@ +import type {PaletteOption} from './Palette'; + +export function getPaletteRows(options: PaletteOption[], columns: number): PaletteOption[][] { + if (columns <= 0) { + throw new Error('Palette.getPaletteRows: number of columns must greater than 0'); + } + + 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; +} diff --git a/src/components/index.ts b/src/components/index.ts index 9d4e235702..34146e1cf1 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -25,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';