From be23d4e5949ac918ac945bbf829044520c7d0106 Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Tue, 20 Aug 2024 16:55:40 +0300 Subject: [PATCH] feat(Reactions): added new Reactions component (#197) --- CODEOWNERS | 2 + src/components/Reactions/README.md | 108 +++++++++++ src/components/Reactions/Reaction.tsx | 94 ++++++++++ src/components/Reactions/Reactions.scss | 48 +++++ src/components/Reactions/Reactions.tsx | 174 ++++++++++++++++++ .../__stories__/Reactions.stories.tsx | 66 +++++++ .../Reactions/__tests__/Reactions.test.tsx | 74 ++++++++ .../Reactions/__tests__/mock/mockData.ts | 57 ++++++ .../Reactions/__tests__/mock/mockHooks.tsx | 108 +++++++++++ src/components/Reactions/context.ts | 24 +++ src/components/Reactions/hooks.ts | 111 +++++++++++ src/components/Reactions/i18n/en.json | 3 + src/components/Reactions/i18n/index.ts | 8 + src/components/Reactions/i18n/ru.json | 3 + src/components/Reactions/index.ts | 1 + 15 files changed, 881 insertions(+) create mode 100644 src/components/Reactions/README.md create mode 100644 src/components/Reactions/Reaction.tsx create mode 100644 src/components/Reactions/Reactions.scss create mode 100644 src/components/Reactions/Reactions.tsx create mode 100644 src/components/Reactions/__stories__/Reactions.stories.tsx create mode 100644 src/components/Reactions/__tests__/Reactions.test.tsx create mode 100644 src/components/Reactions/__tests__/mock/mockData.ts create mode 100644 src/components/Reactions/__tests__/mock/mockHooks.tsx create mode 100644 src/components/Reactions/context.ts create mode 100644 src/components/Reactions/hooks.ts create mode 100644 src/components/Reactions/i18n/en.json create mode 100644 src/components/Reactions/i18n/index.ts create mode 100644 src/components/Reactions/i18n/ru.json create mode 100644 src/components/Reactions/index.ts diff --git a/CODEOWNERS b/CODEOWNERS index b0894972..f95a046c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -4,9 +4,11 @@ /src/components/FilePreview @KirillDyachkovskiy /src/components/FormRow @ogonkov /src/components/HelpPopover @Raubzeug +/src/components/Notifications @Ruminat /src/components/OnboardingMenu @nikita-jpg /src/components/PlaceholderContainer @Marginy605 /src/components/PromoSheet @Avol-V +/src/components/Reactions @Ruminat /src/components/SharePopover @niktverd /src/components/StoreBadge @NikitaCG /src/components/Stories @darkgenius diff --git a/src/components/Reactions/README.md b/src/components/Reactions/README.md new file mode 100644 index 00000000..b3971ec9 --- /dev/null +++ b/src/components/Reactions/README.md @@ -0,0 +1,108 @@ +## Reactions + +Component for user reactions (e.g. ๐Ÿ‘, ๐Ÿ˜Š, ๐Ÿ˜Ž etc) as new GitHub comments for example. + +### Usage example + +```typescript +import React from 'react'; + +import {PaletteOption} from '@gravity-ui/uikit'; +import {ReactionState, Reactions} from '@gravity-ui/components'; + +const user = { + spongeBob: {name: 'Sponge Bob'}, + patrick: {name: 'Patrick'}, +}; + +const currentUser = user.spongeBob; + +const option = { + 'thumbs-up': {content: '๐Ÿ‘', value: 'thumbs-up'}, + cool: {content: '๐Ÿ˜Ž', value: 'cool'}, +} satisfies Record; + +const options = Object.values(option); + +const YourComponent = () => { + // You can set up a mapping: reaction.value -> users reacted + const [usersReacted, setUsersReacted] = React.useState({ + [option.cool.value]: [user.spongeBob], + }); + + // And then convert that mapping into an array of ReactionState + const reactions = React.useMemo( + () => + Object.entries(usersReacted).map( + ([value, users]): ReactionState => ({ + value, + counter: users.length, + selected: users.some(({name}) => name === currentUser.name), + }), + ), + [usersReacted], + ); + + // You can then handle clicking on a reaction with changing the inital mapping, + // and the array of ReactionState will change accordingly + const onToggle = React.useCallback( + (value: string) => { + if (!usersReacted[value]) { + // If the reaction is not present yet + setUsersReacted((current) => ({...current, [value]: [currentUser]})); + } else if (!usersReacted[value].some(({name}) => name === currentUser.name)) { + // If the reaction is present, but current user hasn't selected it yet + setUsersReacted((current) => ({ + ...current, + [value]: [...usersReacted[value], currentUser], + })); + } else if (usersReacted[value].length > 1) { + // If the user used that reaction, and he's not the only one who used it + setUsersReacted((current) => ({ + ...current, + [value]: usersReacted[value].filter(({name}) => name !== currentUser.name), + })); + } else { + // If the user used that reaction, and he's the only one who used it + setUsersReacted((current) => { + const newValue = {...current}; + delete newValue[value]; + return newValue; + }); + } + }, + [usersReacted], + ); + + return ( + + ); +}; +``` + +For more code examples go to [Reactions.stories.tsx](https://github.com/gravity-ui/components/blob/main/src/components/Reactions/__stories__/Reactions.stories.tsx). + +### Props + +**ReactionsProps** (main component props โ€” Reactions' list): + +| Property | Type | Required | Default | Description | +| :--------------- | :------------------------------------------ | :------: | :------ | :--------------------------------------------------------------------------------------------- | +| `className` | `string` | | | HTML `class` attribute | +| `onToggle` | `(value: string) => void` | | | Fires when a user clicks on a Reaction (in a Palette or in the Reactions' list) | +| `paletteProps` | `ReactionsPaletteProps` | `true` | | Notifications' palette props โ€” it's a `Palette` component with available reactions to the user | +| `qa` | `string` | | | `qa` attribute for testing | +| `reactions` | `PaletteOption[]` | `true` | | List of all available reactions | +| `reactionsState` | `ReactionState[]` | `true` | | List of reactions that were used | +| `readOnly` | `boolean` | | `false` | readOnly state (usage example: only signed in users can react) | +| `renderTooltip` | `(state: ReactionState) => React.ReactNode` | | | Reaction's tooltip with the list of reacted users for example | +| `size` | `ButtonSize` | | `m` | Buttons's size | +| `style` | `React.CSSProperties` | | | HTML `style` attribute | + +**ReactionState** (single reaction props): + +| Property | Type | Required | Default | Description | +| :--------- | :---------------- | :------: | :------ | :-------------------------------- | +| `counter` | `React.ReactNode` | | | How many users used this reaction | +| `selected` | `boolean` | | | Is reaction selected by the user | +| `value` | `string` | | | Reaction's unique value (ID) | diff --git a/src/components/Reactions/Reaction.tsx b/src/components/Reactions/Reaction.tsx new file mode 100644 index 00000000..2ac0092c --- /dev/null +++ b/src/components/Reactions/Reaction.tsx @@ -0,0 +1,94 @@ +import React from 'react'; + +import {Button, ButtonSize, PaletteOption, PopoverProps, Popup} from '@gravity-ui/uikit'; + +import {block} from '../utils/cn'; + +import {useReactionsContext} from './context'; +import {useReactionsPopup} from './hooks'; + +export type ReactionProps = Pick; + +export interface ReactionState { + /** + * Reaction's unique value (ID). + */ + value: string; + /** + * Should be true when the user used this reaction. + */ + selected?: boolean; + /** + * Display a number after the icon. + * Represents the number of users who used this reaction. + */ + counter?: React.ReactNode; +} + +interface ReactionInnerProps extends Pick { + reaction: ReactionState; + size: ButtonSize; + tooltip?: React.ReactNode; + onClick?: (value: string) => void; +} + +const popupDefaultPlacement: PopoverProps['placement'] = [ + 'bottom-start', + 'bottom', + 'bottom-end', + 'top-start', + 'top', + 'top-end', +]; + +const b = block('reactions'); + +export function Reaction(props: ReactionInnerProps) { + const {value, selected, counter} = props.reaction; + const {size, content, tooltip, onClick} = props; + + const onClickCallback = React.useCallback(() => onClick?.(value), [onClick, value]); + + const buttonRef = React.useRef(null); + const {onMouseEnter, onMouseLeave} = useReactionsPopup(props.reaction, buttonRef); + const {openedTooltip: currentHoveredReaction} = useReactionsContext(); + + const button = ( + + ); + + return tooltip ? ( +
+ {button} + + {currentHoveredReaction && currentHoveredReaction.reaction.value === value ? ( + + {tooltip} + + ) : null} +
+ ) : ( + button + ); +} diff --git a/src/components/Reactions/Reactions.scss b/src/components/Reactions/Reactions.scss new file mode 100644 index 00000000..5a0c3e62 --- /dev/null +++ b/src/components/Reactions/Reactions.scss @@ -0,0 +1,48 @@ +@use '../variables'; + +$block: '.#{variables.$ns}reactions'; + +#{$block} { + &__popup { + padding: 8px; + } + + &__add-reaction-popover { + max-width: unset; + } + + &__reaction-button-content_size_xs { + font-size: 12px; + } + &__reaction-button-content_size_xs#{&}__reaction-button-content_text { + font-size: var(--g-text-caption-1-font-size); + } + + &__reaction-button-content_size_s { + font-size: 16px; + } + &__reaction-button-content_size_s#{&}__reaction-button-content_text { + font-size: var(--g-text-caption-2-font-size); + } + + &__reaction-button-content_size_m { + font-size: 16px; + } + &__reaction-button-content_size_m#{&}__reaction-button-content_text { + font-size: var(--g-text-body-1-font-size); + } + + &__reaction-button-content_size_l { + font-size: 16px; + } + &__reaction-button-content_size_l#{&}__reaction-button-content_text { + font-size: var(--g-text-subheader-1-font-size); + } + + &__reaction-button-content_size_xl { + font-size: 20px; + } + &__reaction-button-content_size_xl#{&}__reaction-button-content_text { + font-size: var(--g-text-subheader-2-font-size); + } +} diff --git a/src/components/Reactions/Reactions.tsx b/src/components/Reactions/Reactions.tsx new file mode 100644 index 00000000..08e783e9 --- /dev/null +++ b/src/components/Reactions/Reactions.tsx @@ -0,0 +1,174 @@ +import React from 'react'; + +import {FaceSmile} from '@gravity-ui/icons'; +import { + Button, + DOMProps, + Flex, + Icon, + Palette, + PaletteOption, + PaletteProps, + Popover, + QAProps, +} from '@gravity-ui/uikit'; +import xor from 'lodash/xor'; + +import {block} from '../utils/cn'; + +import {Reaction, ReactionProps, ReactionState} from './Reaction'; +import {ReactionsContextProvider, ReactionsContextTooltipProps} from './context'; +import {i18n} from './i18n'; + +import './Reactions.scss'; + +const b = block('reactions'); + +export type ReactionsPaletteProps = Pick< + PaletteProps, + 'columns' | 'rowClassName' | 'optionClassName' +>; + +export interface ReactionsProps extends Pick, QAProps, DOMProps { + /** + * All available reactions. + */ + reactions: ReactionProps[]; + /** + * Users' reactions. + */ + reactionsState: ReactionState[]; + /** + * Reactions' palette props. + */ + paletteProps?: ReactionsPaletteProps; + /** + * Reactions' readonly state (when a user is unable to react for some reason). + */ + readOnly?: boolean; + /** + * If present, when a user hovers over the reaction, a popover appears with renderTooltip(state) content. + * Can be used to display users who used this reaction. + */ + renderTooltip?: (state: ReactionState) => React.ReactNode; + /** + * Callback for clicking on a reaction in the Palette or directly in the reactions' list. + */ + onToggle?: (value: string) => void; +} + +const buttonSizeToIconSize = { + xs: '12px', + s: '16px', + m: '16px', + l: '16px', + xl: '20px', +}; + +export function Reactions({ + reactions, + reactionsState, + className, + style, + size = 'm', + paletteProps, + readOnly, + qa, + renderTooltip, + onToggle, +}: ReactionsProps) { + const [currentHoveredReaction, setCurrentHoveredReaction] = React.useState< + ReactionsContextTooltipProps | undefined + >(undefined); + + const paletteOptionsMap = React.useMemo( + () => + reactions.reduce>((acc, current) => { + // eslint-disable-next-line no-param-reassign + acc[current.value] = current; + return acc; + }, {}), + [reactions], + ); + + const paletteValue = React.useMemo( + () => + reactionsState + .filter((reaction) => reaction.selected) + .map((reaction) => reaction.value), + [reactionsState], + ); + + const onUpdatePalette = React.useCallback( + (updated: string[]) => { + const diffValues = xor(paletteValue, updated); + for (const diffValue of diffValues) { + onToggle?.(diffValue); + } + }, + [onToggle, paletteValue], + ); + + const paletteContent = React.useMemo( + () => ( + + ), + [paletteProps, reactions, paletteValue, size, onUpdatePalette], + ); + + return ( + + + {/* Reactions' list */} + {reactionsState.map((reaction) => { + const content = paletteOptionsMap[reaction.value]?.content ?? '?'; + + return ( + + ); + })} + + {/* Add reaction button */} + {readOnly ? null : ( + + + + )} + + + ); +} diff --git a/src/components/Reactions/__stories__/Reactions.stories.tsx b/src/components/Reactions/__stories__/Reactions.stories.tsx new file mode 100644 index 00000000..435b4b51 --- /dev/null +++ b/src/components/Reactions/__stories__/Reactions.stories.tsx @@ -0,0 +1,66 @@ +import React from 'react'; + +import {Flex, Text} from '@gravity-ui/uikit'; +import {Meta, StoryFn} from '@storybook/react'; + +import {Reactions} from '../Reactions'; +import {useMockReactions} from '../__tests__/mock/mockHooks'; + +export default { + title: 'Components/Reactions', + component: Reactions, +} as Meta; + +export const Default: StoryFn = () => { + return ; +}; + +export const Readonly: StoryFn = () => { + const {reactions, reactionsState, renderTooltip, onToggle} = useMockReactions(); + + return ( + ( + + You must be singed in to react + {renderTooltip(state)} + + ) + : undefined + } + reactionsState={reactionsState} + onToggle={onToggle} + readOnly={true} + /> + ); +}; + +export const Size: StoryFn = () => { + return ( + + + Size XS + + + + Size S + + + + Size M + + + + Size L + + + + Size XL + + + + ); +}; diff --git a/src/components/Reactions/__tests__/Reactions.test.tsx b/src/components/Reactions/__tests__/Reactions.test.tsx new file mode 100644 index 00000000..45da5db6 --- /dev/null +++ b/src/components/Reactions/__tests__/Reactions.test.tsx @@ -0,0 +1,74 @@ +import React from 'react'; + +import {ButtonSize} from '@gravity-ui/uikit'; +import userEvent from '@testing-library/user-event'; + +import {render, screen, within} from '../../../../test-utils/utils'; + +import {reactionsPalletteMockOption as option} from './mock/mockData'; +import {TestReactions} from './mock/mockHooks'; + +const qaId = 'reactions-component'; + +describe('Reactions', () => { + test('render Reactions', async () => { + render(); + + const reactions = screen.getByTestId(qaId); + expect(reactions).toBeVisible(); + }); + + test.each(new Array('xs', 's', 'm', 'l', 'xl'))( + 'render with given "%s" size', + (size) => { + render(); + + const $component = screen.getByTestId(qaId); + const $reactions = within($component).getAllByRole('button'); + + $reactions.forEach(($reaction: HTMLElement) => { + expect($reaction).toHaveClass(`g-button_size_${size}`); + }); + }, + ); + + test('show given reaction', () => { + render(); + + const text = screen.getByText(option.cool.content as string); + + expect(text).toBeVisible(); + }); + + test('add className and style', () => { + const className = 'my-class'; + const style = {color: 'red'}; + + render(); + + const $component = screen.getByTestId(qaId); + + expect($component).toHaveClass(className); + expect($component).toHaveStyle(style); + }); + + test('can (un)select an option', async () => { + render(); + + const $component = screen.getByTestId(qaId); + const $reactions = within($component).getAllByRole('button'); + + const $firstReaction = await screen.findByText(option.cool.content as string); + const $secondReaction = await screen.findByText(option.laughing.content as string); + + expect($reactions[0].getAttribute('aria-pressed')).toBe('false'); + expect($reactions[1].getAttribute('aria-pressed')).toBe('true'); + + const user = userEvent.setup(); + await user.click($firstReaction); + await user.click($secondReaction); + + expect($reactions[0].getAttribute('aria-pressed')).toBe('true'); + expect($reactions[1].getAttribute('aria-pressed')).toBe('false'); + }); +}); diff --git a/src/components/Reactions/__tests__/mock/mockData.ts b/src/components/Reactions/__tests__/mock/mockData.ts new file mode 100644 index 00000000..1dcceb8e --- /dev/null +++ b/src/components/Reactions/__tests__/mock/mockData.ts @@ -0,0 +1,57 @@ +import {PaletteOption} from '@gravity-ui/uikit'; + +const reactionsAvatar = { + spongeBob: + 'data:image/png;base64,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', + patrick: + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAgACADAREAAhEBAxEB/8QAGgAAAQUBAAAAAAAAAAAAAAAABwACBQYIAf/EACoQAAIBAwQBBAIBBQAAAAAAAAECAwQFEQAGEiExBxNBUSJhFEJScZHR/8QAGwEAAgIDAQAAAAAAAAAAAAAABgcFCAADBAL/xAAwEQABAgUDAwEHBAMAAAAAAAABAhEAAwQFIRIxQQZRYYETFBUiMpHwM0KhsXHB0f/aAAwDAQACEQMRAD8ALdZc5nu9O9NVl6QrGywxqCkyMrlnLHvoqMYxj586lkkrmBlY/veK9gMkuMwxNxS1kE81MqtDEwUtxPf7X7H7GuWZcZEisRTLGFAfN5Jx6eYPbZ0fUXWxz7vTrdUtRGhtwkAqL984DZA7tCobtU1VWsbuqL3yzhev+/rRFOlJlyyoDMAclImLAJxHbjcp6WVfbnDIw6/uBHRyPjWUspM1J1DI+3p3j1PQJZGk4/N4FtdvtZ/cUzmoVnIDOqBSSe8Keh+We8efOun4Ks7Zy3H5v/MYFNBA9MKiz70o7hPuGrNRSRJLHEsjtTRvJ7YX25uAYEcWfA44zx8eQBXyRKk3BKKhWNHZiCCcHzjiLF9B++I6dmKo0ur2xxuGKUg4543iI3lu60WHc1dQ29uFBEy/xxhsBOIIClvyKg5AJ7wBnvTIs9OLlRIqZRcFx6jEJDqK2TbRc5tLMTp2UB4VnDcO/wDUVy5+pFOtKwjcMW6ZuXEj/H2dTiLQsGB1GTmKft70xvG4qx4WkipKYANLOX58BnoKB8+caiLj1lZqSQr2CVTFq2BdI+/ABzjLwyunuh6+9zyCoIlp+pW5zwByT5wOYOG0dqUO07PHQ0jvULzMrzTMGZ3PRb6HQxjSHuVyn3WpVVT/AKj27f7/AMxaezWWlsNGmhpAdIJLnck7k/8ABgQzcmz7Pf2E9woI6h1XgXBKuF78EfWT1rbb71cbU/uU4oByQNj6FxGXGwWu8hq+nSsgMCRkeAQxb1gH7k9PKykvVdT23jU0KSYiaVvyI/13g5GdPu0dc0U+ilqr3E392lONzkZ5HEVS6j6XVbLrOpqL9NLM5yHALeW2B5Eb5vnpNT3S1qlFGlBXoQYmMfFGGe1OPg/H0dJmulJnSw40kcwy7BcJlkqFKU5lq3SGyeCH5H8jEBW122puUEktvkpqqD3WQqxeFkYHDL2DkA576z3pUXK8TrJUGluVOUKYKDKSr5SHG2HIy24cQaL619lMKZ9OQMMygS3D8P6xA1FbeKi4x0MdBJQSzRyYNQf6VJy2ADjwQCetGZlU/wAPnXEVSFIRpDIOtWpYdKQ+hJIcagCopzgkGNlV1WTIXMp0gNy+oudsYGOclu0aLoPTiy09vpKcW2JZBGqu8hyxbAz333nU1Io6mSlprADtmFZWVi7jUKnzlalq/MeI/9k=', + squidward: + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAgACADASIAAhEBAxEB/8QAGAABAQEBAQAAAAAAAAAAAAAABgcIBAX/xAAsEAACAQMDAQcDBQAAAAAAAAABAgMEBREAEiEGEyIxQVFhoRRxgTJCYpGx/8QAGAEAAgMAAAAAAAAAAAAAAAAAAgMEBQb/xAAgEQACAgEDBQAAAAAAAAAAAAABAgADEQQTMSFBUWHw/9oADAMBAAIRAxEAPwDdHU9zlpJBRU0hgbaHllH6gD4KPTjxPuNDO3WNm2kTFlw7AkbifE5AJ+dMev6F7dJLdSjPSdiDIUBO1lB4P3AGPfWd6nr64l62okqOyiFR9HM0kJ2Us+9VEIJHdk3Oq7TzlhxkjUYsqcmZ/U7hsIIJHqV6AiVzLHKFmUdxkyGTjHJGCeRny0u6euz17/R1ZEk20tHNjBfHiCPXzz5jPpzOujLxVXGimp69WjrqRwjpJGY3Kkd1ipAx5j8ae9G26Wsuy1KqewpgxZ/IsVICj35J/HvoujCDpHcWALwY1u96oLTbZZK+XZC6sm0cs+Qe6o9dSJqKzgzxXK00lTb6+s+vq6XYFSSqDJIs59ZA8SHcTyV0/v1DT9QWp4pIxJNGrNAc4w+OP7wPjR7p22pcbvGtTTu1PGHaQOGTadpAHlzk+HtpNlVdww4zLS229LFFRx93ndYr1a7t1HV1d1ETQSRKkJm76qQ37iMgHxPPHOqBII4IdkaLHGowFQAAfYDQ+t6Hss770aeA/wAXDfLAn516k9wSGFYk4RFCgZzwBgf5o0Va0CLwI2oWLncxnzP/2Q==', +}; + +export type ReactionsMockUser = {name: string; avatar: string}; + +export const reactionsMockUser = { + spongeBob: {name: 'Sponge Bob', avatar: reactionsAvatar.spongeBob}, + patrick: {name: 'Patrick', avatar: reactionsAvatar.patrick}, + squidward: {name: 'Squidward', avatar: reactionsAvatar.squidward}, +} satisfies Record; + +const baseMockOption = { + 'smiling-face': {content: '๐Ÿ˜Š'}, + heart: {content: 'โค๏ธ'}, + 'thumbs-up': {content: '๐Ÿ‘'}, + laughing: {content: '๐Ÿ˜‚'}, + 'hearts-eyes': {content: '๐Ÿ˜'}, + cool: {content: '๐Ÿ˜Ž'}, + tongue: {content: '๐Ÿ˜›'}, + angry: {content: '๐Ÿ˜ก'}, + sad: {content: '๐Ÿ˜ข'}, + surprised: {content: '๐Ÿ˜ฏ'}, + 'face-screaming': {content: '๐Ÿ˜ฑ'}, + 'smiling-face-with-open-hands': { + content: '๐Ÿค—', + }, + nauseated: {content: '๐Ÿคข'}, + 'lying-face': {content: '๐Ÿคฅ'}, + 'star-struck': {content: '๐Ÿคฉ'}, + 'face-with-hand-over-mouth': { + content: '๐Ÿคญ', + }, + vomiting: {content: '๐Ÿคฎ'}, + partying: {content: '๐Ÿฅณ'}, + woozy: {content: '๐Ÿฅด'}, + 'cold-face': {content: '๐Ÿฅถ'}, +}; + +export const reactionsPalletteMockOption = baseMockOption as Record< + keyof typeof baseMockOption, + PaletteOption +>; + +for (const value of Object.keys(reactionsPalletteMockOption)) { + reactionsPalletteMockOption[value as keyof typeof baseMockOption].value = value; + reactionsPalletteMockOption[value as keyof typeof baseMockOption].title = value; +} + +export const reactionsPalletteMockOptions = Object.values(reactionsPalletteMockOption); diff --git a/src/components/Reactions/__tests__/mock/mockHooks.tsx b/src/components/Reactions/__tests__/mock/mockHooks.tsx new file mode 100644 index 00000000..92546ab8 --- /dev/null +++ b/src/components/Reactions/__tests__/mock/mockHooks.tsx @@ -0,0 +1,108 @@ +import React from 'react'; + +import {Flex, Text, User} from '@gravity-ui/uikit'; + +import {ReactionState} from '../../Reaction'; +import {Reactions, ReactionsProps} from '../../Reactions'; + +import { + ReactionsMockUser, + reactionsPalletteMockOption as option, + reactionsPalletteMockOptions as options, + reactionsMockUser as user, +} from './mockData'; + +const currentUser = user.spongeBob; + +const renderUserReacted = ({avatar, name}: ReactionsMockUser) => { + return ( + + {name} (you) + + ) : ( + name + ) + } + key={name} + size={'xs'} + /> + ); +}; + +const renderUsersReacted = (users: ReactionsMockUser[]) => { + return ( + + {users.map(renderUserReacted)} + + ); +}; + +const getTooltip = (users: ReactionsMockUser[]) => renderUsersReacted(users); + +export function useMockReactions(): ReactionsProps { + const [usersReacted, setUsersReacted] = React.useState({ + [option.cool.value]: [user.patrick], + [option.laughing.value]: [user.patrick, user.spongeBob], + [option['thumbs-up'].value]: [user.patrick, user.spongeBob, user.squidward], + [option['hearts-eyes'].value]: [user.spongeBob], + [option['cold-face'].value]: [user.squidward], + [option.sad.value]: [user.squidward], + }); + + const reactionsState = React.useMemo( + () => + Object.entries(usersReacted).map( + ([value, users]): ReactionState => ({ + value, + counter: users.length, + selected: users.some(({name}) => name === currentUser.name), + }), + ), + [usersReacted], + ); + + const onToggle = React.useCallback( + (value: string) => { + if (!usersReacted[value]) { + setUsersReacted((current) => ({...current, [value]: [currentUser]})); + } else if (!usersReacted[value].some(({name}) => name === currentUser.name)) { + setUsersReacted((current) => ({ + ...current, + [value]: [...usersReacted[value], currentUser], + })); + } else if (usersReacted[value].length > 1) { + setUsersReacted((current) => ({ + ...current, + [value]: usersReacted[value].filter(({name}) => name !== currentUser.name), + })); + } else { + setUsersReacted((current) => { + const newValue = {...current}; + delete newValue[value]; + return newValue; + }); + } + }, + [usersReacted], + ); + + const renderTooltip = React.useCallback( + (reaciton: ReactionState) => getTooltip(usersReacted[reaciton.value]), + [usersReacted], + ); + + return { + reactions: options, + reactionsState, + renderTooltip, + onToggle, + }; +} + +export function TestReactions(props: Partial) { + return ; +} diff --git a/src/components/Reactions/context.ts b/src/components/Reactions/context.ts new file mode 100644 index 00000000..833ed652 --- /dev/null +++ b/src/components/Reactions/context.ts @@ -0,0 +1,24 @@ +import React from 'react'; + +import type {ReactionState} from './Reaction'; + +export interface ReactionsContextTooltipProps { + reaction: ReactionState; + ref: React.RefObject; + open: boolean; +} + +export interface ReactionsContext { + openedTooltip?: ReactionsContextTooltipProps; + setOpenedTooltip: (props: ReactionsContextTooltipProps | undefined) => void; +} + +const context = React.createContext({ + setOpenedTooltip: () => undefined, +}); + +export const ReactionsContextProvider = context.Provider; + +export function useReactionsContext(): ReactionsContext { + return React.useContext(context); +} diff --git a/src/components/Reactions/hooks.ts b/src/components/Reactions/hooks.ts new file mode 100644 index 00000000..72a2a839 --- /dev/null +++ b/src/components/Reactions/hooks.ts @@ -0,0 +1,111 @@ +import React from 'react'; + +import type {ReactionState} from './Reaction'; +import {useReactionsContext} from './context'; + +const DELAY = { + focusTimeout: 600, + openTimeout: 200, + closeTimeout: 200, +} as const; + +export function useReactionsPopup( + reaction: ReactionState, + ref: React.RefObject, +) { + const {value} = reaction; + + const {openedTooltip: currentHoveredReaction, setOpenedTooltip: setCurrentHoveredReaction} = + useReactionsContext(); + + const {delayedCall: setDelayedOpen, clearTimeoutRef: clearOpenTimeout} = useTimeoutRef(); + const {delayedCall: setDelayedClose, clearTimeoutRef: clearCloseTimeout} = useTimeoutRef(); + + const open = React.useCallback(() => { + setCurrentHoveredReaction({reaction, open: true, ref}); + }, [reaction, ref, setCurrentHoveredReaction]); + + const close = React.useCallback(() => { + clearOpenTimeout(); + + if (currentHoveredReaction?.reaction.value === value && currentHoveredReaction.open) { + setCurrentHoveredReaction({...currentHoveredReaction, open: false}); + } + }, [clearOpenTimeout, currentHoveredReaction, setCurrentHoveredReaction, value]); + + const focus = React.useCallback(() => { + clearCloseTimeout(); + + // If already hovered over current reaction + if (currentHoveredReaction && currentHoveredReaction.reaction.value === reaction.value) { + // But if it's not opened yet + if (!currentHoveredReaction.open) { + setDelayedOpen(open, DELAY.openTimeout); + } + } else { + setCurrentHoveredReaction({reaction, open: false, ref}); + + setDelayedOpen(open, DELAY.openTimeout); + } + }, [ + clearCloseTimeout, + currentHoveredReaction, + open, + reaction, + ref, + setCurrentHoveredReaction, + setDelayedOpen, + ]); + + const delayedOpenPopup = React.useCallback(() => { + clearCloseTimeout(); + setDelayedOpen(focus, DELAY.focusTimeout); + }, [clearCloseTimeout, focus, setDelayedOpen]); + + const delayedClosePopup = React.useCallback(() => { + clearOpenTimeout(); + + setDelayedClose(close, DELAY.closeTimeout); + }, [clearOpenTimeout, close, setDelayedClose]); + + const onMouseEnter: React.MouseEventHandler< + HTMLDivElement | HTMLButtonElement | HTMLAnchorElement + > = delayedOpenPopup; + + const onMouseLeave: React.MouseEventHandler< + HTMLDivElement | HTMLButtonElement | HTMLAnchorElement + > = delayedClosePopup; + + React.useEffect(() => { + // When the tab gets focus we need to hide the popup, + // because the user might have changed the cursor position. + window.addEventListener('focus', close); + + return () => { + window.removeEventListener('focus', close); + }; + }, [close]); + + return {onMouseEnter, onMouseLeave}; +} + +function useTimeoutRef() { + const timeoutRef = React.useRef | null>(null); + + const clearTimeoutRef = React.useCallback(() => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }, []); + + const delayedCall = React.useCallback( + (handler: () => void, delay: number) => { + clearTimeoutRef(); + timeoutRef.current = setTimeout(handler, delay); + }, + [clearTimeoutRef], + ); + + return {delayedCall, clearTimeoutRef}; +} diff --git a/src/components/Reactions/i18n/en.json b/src/components/Reactions/i18n/en.json new file mode 100644 index 00000000..6f79bdaf --- /dev/null +++ b/src/components/Reactions/i18n/en.json @@ -0,0 +1,3 @@ +{ + "add-reaction": "Add reaction" +} diff --git a/src/components/Reactions/i18n/index.ts b/src/components/Reactions/i18n/index.ts new file mode 100644 index 00000000..b113f612 --- /dev/null +++ b/src/components/Reactions/i18n/index.ts @@ -0,0 +1,8 @@ +import {addComponentKeysets} from '@gravity-ui/uikit/i18n'; + +import {NAMESPACE} from '../../utils/cn'; + +import en from './en.json'; +import ru from './ru.json'; + +export const i18n = addComponentKeysets({en, ru}, `${NAMESPACE}reactions`); diff --git a/src/components/Reactions/i18n/ru.json b/src/components/Reactions/i18n/ru.json new file mode 100644 index 00000000..dd01278d --- /dev/null +++ b/src/components/Reactions/i18n/ru.json @@ -0,0 +1,3 @@ +{ + "add-reaction": "ะ”ะพะฑะฐะฒะธั‚ัŒ ั€ะตะฐะบั†ะธัŽ" +} diff --git a/src/components/Reactions/index.ts b/src/components/Reactions/index.ts new file mode 100644 index 00000000..1ef7c4f1 --- /dev/null +++ b/src/components/Reactions/index.ts @@ -0,0 +1 @@ +export * from './Reactions';