From 157ec91effcdbb0a1a233a2d7abf5dd0160dfdee Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Tue, 5 Mar 2024 15:25:33 +0300 Subject: [PATCH 01/21] feat: added a basic Reactions component (not working yet) --- .../Reactions/AddReactionButton.tsx | 71 +++++++++++++++++++ src/components/Reactions/Reaction.tsx | 58 +++++++++++++++ src/components/Reactions/Reactions.scss | 9 +++ src/components/Reactions/Reactions.tsx | 59 +++++++++++++++ .../__stories__/Notifications.stories.tsx | 39 ++++++++++ 5 files changed, 236 insertions(+) create mode 100644 src/components/Reactions/AddReactionButton.tsx 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__/Notifications.stories.tsx diff --git a/src/components/Reactions/AddReactionButton.tsx b/src/components/Reactions/AddReactionButton.tsx new file mode 100644 index 00000000..c35993ad --- /dev/null +++ b/src/components/Reactions/AddReactionButton.tsx @@ -0,0 +1,71 @@ +import React from 'react'; + +import {FaceSmile} from '@gravity-ui/icons'; +import {Button, ButtonSize, Icon, Popover} from '@gravity-ui/uikit'; + +import {block} from '../utils/cn'; + +const b = block('reactions'); + +type ReactionsMarketProps = { + content: React.ReactNode; + size?: ButtonSize; +}; + +// const offset: [number, number] = [-4, -2]; + +export const AddReactionButton = React.memo(function AddReactionButton( + props: ReactionsMarketProps, +) { + // const [isOpened, setIsOpened] = React.useState(false); + // const buttonRef = React.useRef(null); + + // const onClick = React.useCallback(() => { + // setIsOpened(!isOpened); + // }, [isOpened]); + + // const closePopup = React.useCallback(() => { + // setIsOpened(false); + // }, []); + + return ( + + + + ); + + // return ( + // + // + // + // {props.reactionsMarket} + // + // + // ); +}); diff --git a/src/components/Reactions/Reaction.tsx b/src/components/Reactions/Reaction.tsx new file mode 100644 index 00000000..ad9a9d02 --- /dev/null +++ b/src/components/Reactions/Reaction.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import {Button, ButtonSize, Popover, Text} from '@gravity-ui/uikit'; + +export interface ReactionProps { + /** + * The reaction (emoji/icon/letter/GIF/image etc). + */ + icon: React.ReactNode; + /** + * Actual value that is sent to backend for example. + */ + value: string; + /** + * Display a number after the icon. + * Represents the number of users who used this reaction. + */ + count?: number; + /** + * Is the reaction button disabled. + */ + disabled?: boolean; + /** + * Is the reaction highlighted. + * Should be true when the user used this reaction. + */ + isHighlighted?: boolean; + /** + * If present, when a user hovers over the reaction, a popover appears with the `popoverContent`. + * Can be used to display users who used this reaction. + */ + popoverContent?: React.ReactNode; +} + +export interface ReactionInnerProps extends ReactionProps { + size: ButtonSize; +} + +export const Reaction = React.memo(function Reaction(props: ReactionInnerProps) { + const button = ( + + ); + + return props.popoverContent ? ( + + {button} + + ) : ( + button + ); +}); diff --git a/src/components/Reactions/Reactions.scss b/src/components/Reactions/Reactions.scss new file mode 100644 index 00000000..f02dc7fb --- /dev/null +++ b/src/components/Reactions/Reactions.scss @@ -0,0 +1,9 @@ +@use '../variables'; + +$block: '.#{variables.$ns}reactions'; + +#{$block} { + &__add-button { + color: var(--g-color-text-secondary); + } +} diff --git a/src/components/Reactions/Reactions.tsx b/src/components/Reactions/Reactions.tsx new file mode 100644 index 00000000..c9f58f82 --- /dev/null +++ b/src/components/Reactions/Reactions.tsx @@ -0,0 +1,59 @@ +import React from 'react'; + +import {ButtonSize, Flex} from '@gravity-ui/uikit'; + +import {block} from '../utils/cn'; + +import {AddReactionButton} from './AddReactionButton'; +import {Reaction, ReactionProps} from './Reaction'; + +import './Reactions.scss'; + +const b = block('reactions'); + +export interface ReactionsProps { + /** + * HTML class attribute. + */ + className?: string; + /** + * A set of reactions to pick from. + */ + reactionsMarket: React.ReactNode; + /** + * Users' reactions. + */ + reactions: ReactionProps[]; + /** + * Are buttons disabled and the ยซAdd reactionยป button is hidden. + * + * @default false + */ + disabled?: boolean; + /** + * Buttons' size. + * + * @default 's' + */ + size?: ButtonSize; +} + +export function Reactions(props: ReactionsProps) { + const {size = 's'} = props; + + return ( + + {props.reactions.map((reaction) => ( + + ))} + {props.disabled ? null : ( + + )} + + ); +} diff --git a/src/components/Reactions/__stories__/Notifications.stories.tsx b/src/components/Reactions/__stories__/Notifications.stories.tsx new file mode 100644 index 00000000..8038666f --- /dev/null +++ b/src/components/Reactions/__stories__/Notifications.stories.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +import {Meta, StoryFn} from '@storybook/react'; + +import {ReactionProps} from '../Reaction'; +import {Reactions} from '../Reactions'; + +export default { + title: 'Components/Reactions', + component: Reactions, +} as Meta; + +// ๐Ÿ‘๐Ÿ‘Ž๐Ÿ˜„๐Ÿ˜•โค๏ธ๐Ÿš€๐Ÿ”ฅ๐Ÿ‘Œ๐Ÿคฆโœ… + +const baseReactions: ReactionProps[] = [ + { + icon: '๐Ÿ‘', + value: 'thumbs-up', + count: 3, + }, + { + icon: '๐Ÿ‘Ž', + value: 'thumbs-down', + count: 618, + isHighlighted: true, + }, +]; + +const market =
Emojis
; + +export const Default: StoryFn = () => { + const [reactions /* setReactions */] = React.useState(baseReactions); + + return ; +}; + +export const Disabled: StoryFn = () => { + return ; +}; From a6639639945ff763abe3c44e0933a9164cf3a3bd Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Tue, 14 May 2024 16:14:27 +0300 Subject: [PATCH 02/21] fix: fixed story filename --- .../{Notifications.stories.tsx => Reactions.stories.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/components/Reactions/__stories__/{Notifications.stories.tsx => Reactions.stories.tsx} (100%) diff --git a/src/components/Reactions/__stories__/Notifications.stories.tsx b/src/components/Reactions/__stories__/Reactions.stories.tsx similarity index 100% rename from src/components/Reactions/__stories__/Notifications.stories.tsx rename to src/components/Reactions/__stories__/Reactions.stories.tsx From 327ca7b9ed01d4a3be8b1f34c24519138cb55797 Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Thu, 30 May 2024 13:36:54 +0300 Subject: [PATCH 03/21] fix: fixed reactions' popups --- .../Reactions/AddReactionButton.tsx | 71 ----------- src/components/Reactions/Reaction.tsx | 104 +++++++++++---- src/components/Reactions/Reactions.scss | 4 + src/components/Reactions/Reactions.tsx | 109 +++++++++++----- .../__stories__/Reactions.stories.tsx | 119 +++++++++++++++--- .../Reactions/__stories__/mockData.ts | 51 ++++++++ src/components/Reactions/context.ts | 24 ++++ src/components/Reactions/hooks.ts | 108 ++++++++++++++++ src/components/Reactions/index.ts | 1 + src/components/utils/useStableCallback.ts | 13 ++ 10 files changed, 456 insertions(+), 148 deletions(-) delete mode 100644 src/components/Reactions/AddReactionButton.tsx create mode 100644 src/components/Reactions/__stories__/mockData.ts create mode 100644 src/components/Reactions/context.ts create mode 100644 src/components/Reactions/hooks.ts create mode 100644 src/components/Reactions/index.ts create mode 100644 src/components/utils/useStableCallback.ts diff --git a/src/components/Reactions/AddReactionButton.tsx b/src/components/Reactions/AddReactionButton.tsx deleted file mode 100644 index c35993ad..00000000 --- a/src/components/Reactions/AddReactionButton.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react'; - -import {FaceSmile} from '@gravity-ui/icons'; -import {Button, ButtonSize, Icon, Popover} from '@gravity-ui/uikit'; - -import {block} from '../utils/cn'; - -const b = block('reactions'); - -type ReactionsMarketProps = { - content: React.ReactNode; - size?: ButtonSize; -}; - -// const offset: [number, number] = [-4, -2]; - -export const AddReactionButton = React.memo(function AddReactionButton( - props: ReactionsMarketProps, -) { - // const [isOpened, setIsOpened] = React.useState(false); - // const buttonRef = React.useRef(null); - - // const onClick = React.useCallback(() => { - // setIsOpened(!isOpened); - // }, [isOpened]); - - // const closePopup = React.useCallback(() => { - // setIsOpened(false); - // }, []); - - return ( - - - - ); - - // return ( - // - // - // - // {props.reactionsMarket} - // - // - // ); -}); diff --git a/src/components/Reactions/Reaction.tsx b/src/components/Reactions/Reaction.tsx index ad9a9d02..4927a3e7 100644 --- a/src/components/Reactions/Reaction.tsx +++ b/src/components/Reactions/Reaction.tsx @@ -1,58 +1,108 @@ import React from 'react'; -import {Button, ButtonSize, Popover, Text} from '@gravity-ui/uikit'; +import {Button, ButtonSize, PaletteOption, PopoverProps, Popup, Text} from '@gravity-ui/uikit'; -export interface ReactionProps { +import {block} from '../utils/cn'; +import {useStableCallback} from '../utils/useStableCallback'; + +import {useReactionsContext} from './context'; +import {useReactionsPopup} from './hooks'; + +export interface ReactionTooltipProps + extends Pick { /** - * The reaction (emoji/icon/letter/GIF/image etc). + * Tooltip's content. */ - icon: React.ReactNode; + content: React.ReactNode; /** - * Actual value that is sent to backend for example. + * Tooltip content's HTML class attribute. */ - value: string; + className?: string; /** - * Display a number after the icon. - * Represents the number of users who used this reaction. + * Fires when the `onMouseLeave` callback is called. + * Usage example: + * you have some popup inside a tooltip, you hover on it, you don't want the tooltip to be closed because of that. */ - count?: number; + canClosePopup?: () => boolean; +} + +export interface ReactionProps extends PaletteOption { /** - * Is the reaction button disabled. + * Should be true when the user used this reaction. */ - disabled?: boolean; + selected?: boolean; /** - * Is the reaction highlighted. - * Should be true when the user used this reaction. + * Display a number after the icon. + * Represents the number of users who used this reaction. */ - isHighlighted?: boolean; + counter?: number; /** - * If present, when a user hovers over the reaction, a popover appears with the `popoverContent`. + * If present, when a user hovers over the reaction, a popover appears with `tooltip.content`. * Can be used to display users who used this reaction. */ - popoverContent?: React.ReactNode; + tooltip?: ReactionTooltipProps; } -export interface ReactionInnerProps extends ReactionProps { +interface ReactionInnerProps { + reaction: ReactionProps; size: ButtonSize; + onClick?: (value: string) => void; } -export const Reaction = React.memo(function Reaction(props: ReactionInnerProps) { +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, disabled, selected, content, counter, tooltip} = props.reaction; + const {size, onClick} = props; + + const onClickCallback = useStableCallback(() => onClick?.(value)); + + const buttonRef = React.useRef(null); + const {onMouseEnter, onMouseLeave} = useReactionsPopup(props.reaction, buttonRef); + const {openedTooltip: currentHoveredReaction} = useReactionsContext(); + const button = ( ); - return props.popoverContent ? ( - + return tooltip ? ( +
{button} - + + {currentHoveredReaction?.reaction.value === value ? ( + + {tooltip.content} + + ) : null} +
) : ( button ); -}); +} diff --git a/src/components/Reactions/Reactions.scss b/src/components/Reactions/Reactions.scss index f02dc7fb..b3532f54 100644 --- a/src/components/Reactions/Reactions.scss +++ b/src/components/Reactions/Reactions.scss @@ -6,4 +6,8 @@ $block: '.#{variables.$ns}reactions'; &__add-button { color: var(--g-color-text-secondary); } + + &__popup { + padding: 8px; + } } diff --git a/src/components/Reactions/Reactions.tsx b/src/components/Reactions/Reactions.tsx index c9f58f82..50f1f265 100644 --- a/src/components/Reactions/Reactions.tsx +++ b/src/components/Reactions/Reactions.tsx @@ -1,59 +1,108 @@ import React from 'react'; -import {ButtonSize, Flex} from '@gravity-ui/uikit'; +import {FaceSmile} from '@gravity-ui/icons'; +import {Button, Flex, Icon, Palette, PaletteProps, Popover} from '@gravity-ui/uikit'; +import xor from 'lodash/xor'; import {block} from '../utils/cn'; +import {useStableCallback} from '../utils/useStableCallback'; -import {AddReactionButton} from './AddReactionButton'; import {Reaction, ReactionProps} from './Reaction'; +import {ReactionsContextProvider, ReactionsContextTooltipProps} from './context'; import './Reactions.scss'; const b = block('reactions'); -export interface ReactionsProps { +export interface ReactionsProps extends Pick { /** * HTML class attribute. */ className?: string; - /** - * A set of reactions to pick from. - */ - reactionsMarket: React.ReactNode; /** * Users' reactions. */ reactions: ReactionProps[]; /** - * Are buttons disabled and the ยซAdd reactionยป button is hidden. - * - * @default false + * Reactions' palette props. */ - disabled?: boolean; + palette: Omit< + PaletteProps, + 'value' | 'defaultValue' | 'onUpdate' | 'size' | 'disabled' | 'multiple' + >; /** - * Buttons' size. - * - * @default 's' + * Callback for clicking on a reaction in the Palette or directly in the reactions' list. */ - size?: ButtonSize; + onClickReaction?: (value: string) => void; } -export function Reactions(props: ReactionsProps) { - const {size = 's'} = props; +export function Reactions({ + reactions, + className, + size = 'm', + disabled, + palette, + onClickReaction, +}: ReactionsProps) { + const [currentHoveredReaction, setCurrentHoveredReaction] = React.useState< + ReactionsContextTooltipProps | undefined + >(undefined); + + const paletteValue = React.useMemo( + () => reactions.filter((reaction) => reaction.selected).map((reaction) => reaction.value), + [reactions], + ); + + const onUpdatePalette = useStableCallback((updated: string[]) => { + const diffValues = xor(paletteValue, updated); + for (const diffValue of diffValues) { + onClickReaction?.(diffValue); + } + }); return ( - - {props.reactions.map((reaction) => ( - - ))} - {props.disabled ? null : ( - - )} - + + + {/* Reactions' list */} + {reactions.map((reaction) => { + return ( + + ); + })} + + {/* Add reaction button */} + {disabled ? null : ( + + } + openOnHover={false} + hasArrow={false} + > + + + )} + + ); } diff --git a/src/components/Reactions/__stories__/Reactions.stories.tsx b/src/components/Reactions/__stories__/Reactions.stories.tsx index 8038666f..657c216a 100644 --- a/src/components/Reactions/__stories__/Reactions.stories.tsx +++ b/src/components/Reactions/__stories__/Reactions.stories.tsx @@ -1,39 +1,118 @@ import React from 'react'; +import {Flex, Text, User} from '@gravity-ui/uikit'; import {Meta, StoryFn} from '@storybook/react'; import {ReactionProps} from '../Reaction'; -import {Reactions} from '../Reactions'; +import {Reactions, ReactionsProps} from '../Reactions'; + +import { + ReactionsMockUser, + reactionsPalletteMockOption as option, + reactionsPalletteMockOptions as options, + reactionsPalletteMockOption, + reactionsMockUser as user, +} from './mockData'; export default { title: 'Components/Reactions', component: Reactions, } as Meta; -// ๐Ÿ‘๐Ÿ‘Ž๐Ÿ˜„๐Ÿ˜•โค๏ธ๐Ÿš€๐Ÿ”ฅ๐Ÿ‘Œ๐Ÿคฆโœ… +const currentUser = user.spongeBob; -const baseReactions: ReactionProps[] = [ - { - icon: '๐Ÿ‘', - value: 'thumbs-up', - count: 3, - }, - { - icon: '๐Ÿ‘Ž', - value: 'thumbs-down', - count: 618, - isHighlighted: true, - }, -]; +const renderUserReacted = ({avatar, name}: ReactionsMockUser) => { + return ( + + {name} (you) + + ) : ( + name + ) + } + key={name} + size={'xs'} + /> + ); +}; -const market =
Emojis
; +const renderUsersReacted = (users: ReactionsMockUser[]) => { + return ( + + {users.map(renderUserReacted)} + + ); +}; -export const Default: StoryFn = () => { - const [reactions /* setReactions */] = React.useState(baseReactions); +const getTooltip = (users: ReactionsMockUser[]): ReactionProps['tooltip'] => ({ + content: renderUsersReacted(users), +}); - return ; +export const Default: StoryFn = () => { + return ; }; export const Disabled: StoryFn = () => { - return ; + return ; }; + +function useReactions(): 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 reactions = React.useMemo( + () => + Object.entries(usersReacted).map( + ([value, users]): ReactionProps => ({ + ...reactionsPalletteMockOption[ + value as keyof typeof reactionsPalletteMockOption + ], + counter: users.length, + tooltip: getTooltip(users), + selected: users.some(({name}) => name === currentUser.name), + }), + ), + [usersReacted], + ); + + const onClickReaction = 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], + ); + + return { + palette: {options}, + reactions, + onClickReaction, + }; +} diff --git a/src/components/Reactions/__stories__/mockData.ts b/src/components/Reactions/__stories__/mockData.ts new file mode 100644 index 00000000..1a564f4a --- /dev/null +++ b/src/components/Reactions/__stories__/mockData.ts @@ -0,0 +1,51 @@ +import {PaletteOption} from '@gravity-ui/uikit'; + +const reactionsAvatar = { + spongeBob: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGYktHRAD/AP8A/6C9p5MAAAAHdElNRQfoBRQMOgbkiP7fAAAIDUlEQVRIx41WaWxdRxU+s9z9rbbf4jVx7KZx0rip+yOmblNKW0gLDRVKf1RFCAmEKoRUWtSKRQUJgZCohFjUCpX+aJHoArSUFtF0CRVpCCFbk9hxncRLvMR+9nt++33vLnPn8MPYfg5J4f4YzZ07c84933fON4dUKmX4fx8ipazNz6S39h06eHB7/04zEpUiAPJxZ+jHmFsdCQAAAmEM6pUKmDmHkWi0VCozynB9M7mWg2v9AK6OCACBlIgohe8xo2rX6xIY44jYsBmvZoTQa3zY6AohZJmqwuuOFyD1vEBIqSgc//dZvAIictW5wtmxU6dzy3mKsFz1RCAMAoqiICK5ysENK/8NkaQUACSCIAQD6ZuG+v6RI5995AePPfVM2NLuGOjc3hUTrqsqVMoAARElYnANhAnfCBFSGnIcn3OdMfB9HwijjL77z8mSiJ+eKvz06d9mczmqK3fsuT3WlK65SJkHoAIQIcqIhBByhUEOsLaElJgzMy+43q9V9XrA9lp9WFE2V6L39G0+8tW944vl3kOjx+3Zf01Mwu6B9PTcnF1tCoV2u+4YotfRvheoHwQBWeeVAACpVCor75SC46iXpm/p6T01Pd2kG3ZLs2fbUCzxdIJaJiuWKBA1FGblMhWiznnZ89RqtTkcLpbKYn7hyT2D3yJMSikbfaxnkZSBrmma9oVCQRsZuWFifEe9Hp2dTQi/RYJVrilMpVYoqNWCzIIol0HTmiIRsyXhRMK05t/gWcP/OPVjQJVxBVGuUovrJCMCZaDwbiEQINB1MT/vCrE0OblQq/lODQDZ+fPO/Hy+Vi8bBrNtARBMX0rl86rtd6Xa2ml86uiHP3Mdm1LWGMFqWhGQEurOmUjUGxiY6d85szCvjwzvd539L78UcMVDUjlwYNO5kQcvXtj2t4MFyyJCkC1bFkIhv1azf//qUddVRfjY9OVDCrcQ5YrZ9SxaqUpdDy8tmbGImLrMk+lX9t33KQAYHx9ezu2Vzs4nHv2rbtFqFY4c+Y7nPaUokUAGug75XHZyyu7tjvf1pkwtKVE0FhpZiwAQULqEUKfiP/vmbc+Pt3pOESDo7d2ZzT/9zMjXhxfzACIUgltv/cn85R2M1RgllYqih9s1jRaKBRO3t6cHhaiR/2CzChEiMKaWytli6U9dnT7Tndd//uexhYsTWRuAAcDYh5Ov/Oa5l8cLQd1FEKYJqnqXL1zOILtsNSXbP3/vjTv6WhmNMk4bNGpVTQkBRGSUq5oRSOLWcecDjw713NDX2b6yIZ7usjp6ksxjKifAC8VaLjft1jkhyBgAYCoZiURMz682Wt+gRVIKw4wHotdzvWicfO2u/CfNX5w982Yu5wOAauS//aX93xjaBkwrlrJTY4Mdze+4XoSQAAA5o3//4HxmoRAxOindoD18bcaYUi5nFWWYUpUo3FRe2DUgK5Vfzcz2n/xwV73y0i09e5cXn1PaErOzZwAmDD2GelUIWbTbeEwb3N0FjtWauC1Ad42ADQ5WItJ1gQiUQjIVfvtt2LYNCBkFONPTG+7d8cbEeNvE1E2XLi3G4wozayGVCgUEaXn/4HBbq9rfkyKEryYouTICRKEqsUplUzJ1AUCfnAgcBwt5wrmuqlRKcW5kq6r0l8tHbxpYaG8PESIRCQbErVcnp5eBhPo2275vmyQkQBLAFR9rWYSc88xi1nHdy/OpUkm98y4yNMQTSd9x0t2bZt36Lwv5L++68Q/p1s9t2hRISQEkAAoBmmE1R+IqqAbrsMxkEPirJOCGCKQk1WomkVjUNFfTKCGQSICmccOslKqvpFuPOc7xc2MnTPWE74WEgNHRru7urOsYFc+8f89Dgas4pYJpGXat1CAVsC57iBJA87wEY/7Jk1vKZUPKoGozC4II+2aYvNEaLiWNd5oMhymQyURHR1Plsh4QrV6LtCf6t20d5NAr0dt4JWDjjYapVEfNNqpV8/JczLYNxgCAsJCvmUk1xlncpiGdxlwCaFnuli3LyUQFsS7r0eamrlAopBsxIcQV9y5vKDSwLBaNzrQkl4ZuPZlMOgBc4T4QQCWQggAwqghCMJuNO3Vt9+CU9EXZ7tDCwbmPDgcyaI63McaFEI3dUCMHUtP0pcUv1uuHGY0vLpzV9IuISQLSjBf0MPeKRQRqGGhZnqKgDALGVNePpjrE3Nh37Qrp63tNBmvNzsYIVvggxOXKvW0dj6uq6nrFpaXTS0u+Gg5nX/qeKS+E7/u+iqbjvtXc7Mqgo5hf0NQD1Qr6pC8UvZCbT+lKWqJPyNUrmaAU3Iwtn3nr+O+etNLd8Ug0EkvZxWyOqLHjrK7eTIYGjCBIb743Eg5RJYRUjo29UeTxeiZSGns+cwn4Qwqi20jAugNEqSrWXGbYzb02O5x5+9X3fD+4/8ZE2cW/nF18tzNykWuPPXxoT3dLX2fLdDlIt0Sb4i3N4fiJs8PP/vHwvn24rfMzlFMMENYjwEaIpJRKsfA6Uyfu2fGJu3cteQJ0TbN9vPn6juJIhhC8TtdqjmeXix+cvOx4PqB85M5N/vmlTw/GHv9hYWK0kwKXIAmwKyBaaSmoL7x8dtkJKdWmC6p2i1dxmJ3RpJsOqxP9aUvnj2vSF0GA5PbrdrqS+hLzVU+LFr7ysNvTCyoXgQ+Mwwa1XuuLpCRc8Qf6f/Re8UAtOje058mWxJaPTh998YkHNXQ1TWWqvv3uBwwOTiE7l5sNigupppBf972okXFaTxw9Syj0dIOUcMXzb3LXJq1WiCChAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI0LTA1LTIwVDEyOjU4OjAzKzAwOjAwFLBKmQAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNC0wNS0yMFQxMjo1ODowMyswMDowMGXt8iUAAAAodEVYdGRhdGU6dGltZXN0YW1wADIwMjQtMDUtMjBUMTI6NTg6MDYrMDA6MDBgwPxdAAAAAElFTkSuQmCC', + 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; + +export const reactionsPalletteMockOption = { + 'smiling-face': {content: '๐Ÿ˜Š', value: 'smiling-face', title: 'smiling-face'}, + heart: {content: 'โค๏ธ', value: 'heart', title: 'heart'}, + 'thumbs-up': {content: '๐Ÿ‘', value: 'thumbs-up', title: 'thumbs-up'}, + laughing: {content: '๐Ÿ˜‚', value: 'laughing', title: 'laughing'}, + 'hearts-eyes': {content: '๐Ÿ˜', value: 'hearts-eyes', title: 'hearts-eyes'}, + cool: {content: '๐Ÿ˜Ž', value: 'cool', title: 'cool'}, + tongue: {content: '๐Ÿ˜›', value: 'tongue', title: 'tongue'}, + angry: {content: '๐Ÿ˜ก', value: 'angry', title: 'angry'}, + sad: {content: '๐Ÿ˜ข', value: 'sad', title: 'sad'}, + surprised: {content: '๐Ÿ˜ฏ', value: 'surprised', title: 'surprised'}, + 'face-screaming': {content: '๐Ÿ˜ฑ', value: 'face-screaming', title: 'face-screaming'}, + 'smiling-face-with-open-hands': { + content: '๐Ÿค—', + value: 'value-12', + title: 'smiling-face-with-open-hands', + }, + nauseated: {content: '๐Ÿคข', value: 'nauseated', title: 'nauseated'}, + 'lying-face': {content: '๐Ÿคฅ', value: 'lying-face', title: 'lying-face'}, + 'star-struck': {content: '๐Ÿคฉ', value: 'star-struck', title: 'star-struck'}, + 'face-with-hand-over-mouth': { + content: '๐Ÿคญ', + value: 'value-16', + title: 'face-with-hand-over-mouth', + }, + vomiting: {content: '๐Ÿคฎ', value: 'vomiting', title: 'vomiting'}, + partying: {content: '๐Ÿฅณ', value: 'partying', title: 'partying'}, + woozy: {content: '๐Ÿฅด', value: 'woozy', title: 'woozy'}, + 'cold-face': {content: '๐Ÿฅถ', value: 'cold-face', title: 'cold-face'}, +} satisfies Record; + +export const reactionsPalletteMockOptions = Object.values(reactionsPalletteMockOption); diff --git a/src/components/Reactions/context.ts b/src/components/Reactions/context.ts new file mode 100644 index 00000000..9f21f0da --- /dev/null +++ b/src/components/Reactions/context.ts @@ -0,0 +1,24 @@ +import React from 'react'; + +import type {ReactionProps} from './Reaction'; + +export interface ReactionsContextTooltipProps { + reaction: ReactionProps; + 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..b6e21f2a --- /dev/null +++ b/src/components/Reactions/hooks.ts @@ -0,0 +1,108 @@ +import React from 'react'; + +import {useStableCallback} from '../utils/useStableCallback'; + +import type {ReactionProps} from './Reaction'; +import {useReactionsContext} from './context'; + +const DELAY = { + focusTimeout: 600, + openTimeout: 200, + closeTimeout: 100, +} as const; + +export function useReactionsPopup( + reaction: ReactionProps, + ref: React.RefObject, +) { + const {value} = reaction; + const canClosePopup = reaction.tooltip?.canClosePopup; + + const {openedTooltip: currentHoveredReaction, setOpenedTooltip: setCurrentHoveredReaction} = + useReactionsContext(); + + const {delayedCall: setDelayedOpen, clearTimeoutRef: clearOpenTimeout} = useTimeoutRef(); + const {delayedCall: setDelayedClose, clearTimeoutRef: clearCloseTimeout} = useTimeoutRef(); + const {delayedCall: setDelayedCloseRetry, clearTimeoutRef: clearCloseRetryTimeout} = + useTimeoutRef(); + + const open = useStableCallback(() => { + setCurrentHoveredReaction({reaction, open: true, ref}); + }); + + const close = useStableCallback(() => { + clearOpenTimeout(); + + if (currentHoveredReaction?.reaction.value === value && currentHoveredReaction.open) { + setCurrentHoveredReaction({...currentHoveredReaction, open: false}); + } + }); + + const focus = useStableCallback(() => { + clearCloseRetryTimeout(); + clearCloseTimeout(); + setCurrentHoveredReaction({reaction, open: false, ref}); + + setDelayedOpen(open, DELAY.openTimeout); + }); + + const delayedOpenPopup = useStableCallback(() => { + setDelayedOpen(focus, DELAY.focusTimeout); + }); + + const delayedClosePopup = useStableCallback(() => { + clearOpenTimeout(); + + setDelayedClose(close, DELAY.closeTimeout); + }); + + const fireClosePopup = useStableCallback(() => { + clearCloseRetryTimeout(); + + if (canClosePopup ? canClosePopup() : true) { + delayedClosePopup(); + } else { + setDelayedCloseRetry(fireClosePopup, DELAY.closeTimeout); + } + }); + + const onMouseEnter: React.MouseEventHandler = delayedOpenPopup; + + const onMouseLeave: React.MouseEventHandler = fireClosePopup; + + const windowFocusCallback = useStableCallback(() => { + if (currentHoveredReaction?.reaction.value === value && currentHoveredReaction.open) { + setCurrentHoveredReaction({...currentHoveredReaction, open: false}); + } + }); + + 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', windowFocusCallback); + + return () => { + window.removeEventListener('focus', windowFocusCallback); + }; + }, [windowFocusCallback]); + + return {onMouseEnter, onMouseLeave}; +} + +function useTimeoutRef() { + const timeoutRef = React.useRef | null>(null); + + const clearTimeoutRef = useStableCallback(() => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }); + + const delayedCall = useStableCallback((handler: () => void, delay: number) => { + clearTimeoutRef(); + timeoutRef.current = setTimeout(handler, delay); + }); + + return {delayedCall, clearTimeoutRef}; +} 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'; diff --git a/src/components/utils/useStableCallback.ts b/src/components/utils/useStableCallback.ts new file mode 100644 index 00000000..792569b0 --- /dev/null +++ b/src/components/utils/useStableCallback.ts @@ -0,0 +1,13 @@ +import React from 'react'; + +export function useStableCallback, Result>( + handler: (...args: Args) => Result, +) { + const handlerRef = React.useRef(handler); + + handlerRef.current = handler; + + return React.useCallback((...args: Args) => { + return handlerRef.current(...args); + }, []); +} From 2ca5a5eb421dbb597ab4f051ade14399145633c4 Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Fri, 31 May 2024 13:28:51 +0300 Subject: [PATCH 04/21] feat: added tests to Reactions --- src/components/Reactions/Reaction.tsx | 14 +- src/components/Reactions/Reactions.scss | 38 ++++- src/components/Reactions/Reactions.tsx | 61 +++++--- .../__stories__/Reactions.stories.tsx | 131 ++++-------------- .../Reactions/__tests__/Reactions.test.tsx | 84 +++++++++++ .../{__stories__ => mock}/mockData.ts | 54 ++++---- src/components/Reactions/mock/mockHooks.tsx | 108 +++++++++++++++ 7 files changed, 341 insertions(+), 149 deletions(-) create mode 100644 src/components/Reactions/__tests__/Reactions.test.tsx rename src/components/Reactions/{__stories__ => mock}/mockData.ts (81%) create mode 100644 src/components/Reactions/mock/mockHooks.tsx diff --git a/src/components/Reactions/Reaction.tsx b/src/components/Reactions/Reaction.tsx index 4927a3e7..32c9da7c 100644 --- a/src/components/Reactions/Reaction.tsx +++ b/src/components/Reactions/Reaction.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import {Button, ButtonSize, PaletteOption, PopoverProps, Popup, Text} from '@gravity-ui/uikit'; +import {Button, ButtonSize, PaletteOption, PopoverProps, Popup} from '@gravity-ui/uikit'; import {block} from '../utils/cn'; import {useStableCallback} from '../utils/useStableCallback'; @@ -35,7 +35,7 @@ export interface ReactionProps extends PaletteOption { * Display a number after the icon. * Represents the number of users who used this reaction. */ - counter?: number; + counter?: React.ReactNode; /** * If present, when a user hovers over the reaction, a popover appears with `tooltip.content`. * Can be used to display users who used this reaction. @@ -72,15 +72,19 @@ export function Reaction(props: ReactionInnerProps) { const button = ( ); @@ -88,7 +92,7 @@ export function Reaction(props: ReactionInnerProps) {
{button} - {currentHoveredReaction?.reaction.value === value ? ( + {currentHoveredReaction && currentHoveredReaction.reaction.value === value ? ( { - /** - * HTML class attribute. - */ - className?: string; +export interface ReactionsProps extends Pick, QAProps, DOMProps { /** * Users' reactions. */ @@ -36,12 +41,22 @@ export interface ReactionsProps extends Pick onClickReaction?: (value: string) => void; } +const buttonSizeToIconSize = { + xs: '12px', + s: '16px', + m: '16px', + l: '16px', + xl: '20px', +}; + export function Reactions({ reactions, className, + style, size = 'm', disabled, palette, + qa, onClickReaction, }: ReactionsProps) { const [currentHoveredReaction, setCurrentHoveredReaction] = React.useState< @@ -60,6 +75,19 @@ export function Reactions({ } }); + const paletteContent = React.useMemo( + () => ( + + ), + [paletteValue, disabled, size, palette, onUpdatePalette], + ); + return ( - + {/* Reactions' list */} {reactions.map((reaction) => { return ( @@ -83,21 +111,18 @@ export function Reactions({ {/* Add reaction button */} {disabled ? null : ( - } + content={paletteContent} + tooltipContentClassName={b('add-reaction-popover')} openOnHover={false} hasArrow={false} > - diff --git a/src/components/Reactions/__stories__/Reactions.stories.tsx b/src/components/Reactions/__stories__/Reactions.stories.tsx index 657c216a..e3bbdc7b 100644 --- a/src/components/Reactions/__stories__/Reactions.stories.tsx +++ b/src/components/Reactions/__stories__/Reactions.stories.tsx @@ -1,118 +1,47 @@ import React from 'react'; -import {Flex, Text, User} from '@gravity-ui/uikit'; +import {Flex, Text} from '@gravity-ui/uikit'; import {Meta, StoryFn} from '@storybook/react'; -import {ReactionProps} from '../Reaction'; -import {Reactions, ReactionsProps} from '../Reactions'; - -import { - ReactionsMockUser, - reactionsPalletteMockOption as option, - reactionsPalletteMockOptions as options, - reactionsPalletteMockOption, - reactionsMockUser as user, -} from './mockData'; +import {Reactions} from '../Reactions'; +import {useMockReactions} from '../mock/mockHooks'; export default { title: 'Components/Reactions', component: Reactions, } as Meta; -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[]): ReactionProps['tooltip'] => ({ - content: renderUsersReacted(users), -}); - export const Default: StoryFn = () => { - return ; + return ; }; export const Disabled: StoryFn = () => { - return ; + return ; }; -function useReactions(): 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 reactions = React.useMemo( - () => - Object.entries(usersReacted).map( - ([value, users]): ReactionProps => ({ - ...reactionsPalletteMockOption[ - value as keyof typeof reactionsPalletteMockOption - ], - counter: users.length, - tooltip: getTooltip(users), - selected: users.some(({name}) => name === currentUser.name), - }), - ), - [usersReacted], - ); - - const onClickReaction = 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], +export const Size: StoryFn = () => { + return ( + + + Size XS + + + + Size S + + + + Size M + + + + Size L + + + + Size XL + + + ); - - return { - palette: {options}, - reactions, - onClickReaction, - }; -} +}; diff --git a/src/components/Reactions/__tests__/Reactions.test.tsx b/src/components/Reactions/__tests__/Reactions.test.tsx new file mode 100644 index 00000000..bd9d9fbe --- /dev/null +++ b/src/components/Reactions/__tests__/Reactions.test.tsx @@ -0,0 +1,84 @@ +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 $buttons = within($component).getAllByRole('button'); + + $buttons.forEach(($button: HTMLElement) => { + expect($button).toHaveClass(`g-button_size_${size}`); + }); + }, + ); + + test('all buttons are disabled when disabled=true prop is given', () => { + render(); + + const $component = screen.getByTestId(qaId); + const $buttons = within($component).getAllByRole('button'); + + $buttons.forEach(($button: HTMLElement) => { + expect($button).toBeDisabled(); + }); + }); + + 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/__stories__/mockData.ts b/src/components/Reactions/mock/mockData.ts similarity index 81% rename from src/components/Reactions/__stories__/mockData.ts rename to src/components/Reactions/mock/mockData.ts index 1a564f4a..1dcceb8e 100644 --- a/src/components/Reactions/__stories__/mockData.ts +++ b/src/components/Reactions/mock/mockData.ts @@ -17,35 +17,41 @@ export const reactionsMockUser = { squidward: {name: 'Squidward', avatar: reactionsAvatar.squidward}, } satisfies Record; -export const reactionsPalletteMockOption = { - 'smiling-face': {content: '๐Ÿ˜Š', value: 'smiling-face', title: 'smiling-face'}, - heart: {content: 'โค๏ธ', value: 'heart', title: 'heart'}, - 'thumbs-up': {content: '๐Ÿ‘', value: 'thumbs-up', title: 'thumbs-up'}, - laughing: {content: '๐Ÿ˜‚', value: 'laughing', title: 'laughing'}, - 'hearts-eyes': {content: '๐Ÿ˜', value: 'hearts-eyes', title: 'hearts-eyes'}, - cool: {content: '๐Ÿ˜Ž', value: 'cool', title: 'cool'}, - tongue: {content: '๐Ÿ˜›', value: 'tongue', title: 'tongue'}, - angry: {content: '๐Ÿ˜ก', value: 'angry', title: 'angry'}, - sad: {content: '๐Ÿ˜ข', value: 'sad', title: 'sad'}, - surprised: {content: '๐Ÿ˜ฏ', value: 'surprised', title: 'surprised'}, - 'face-screaming': {content: '๐Ÿ˜ฑ', value: 'face-screaming', title: 'face-screaming'}, +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: '๐Ÿค—', - value: 'value-12', - title: 'smiling-face-with-open-hands', }, - nauseated: {content: '๐Ÿคข', value: 'nauseated', title: 'nauseated'}, - 'lying-face': {content: '๐Ÿคฅ', value: 'lying-face', title: 'lying-face'}, - 'star-struck': {content: '๐Ÿคฉ', value: 'star-struck', title: 'star-struck'}, + nauseated: {content: '๐Ÿคข'}, + 'lying-face': {content: '๐Ÿคฅ'}, + 'star-struck': {content: '๐Ÿคฉ'}, 'face-with-hand-over-mouth': { content: '๐Ÿคญ', - value: 'value-16', - title: 'face-with-hand-over-mouth', }, - vomiting: {content: '๐Ÿคฎ', value: 'vomiting', title: 'vomiting'}, - partying: {content: '๐Ÿฅณ', value: 'partying', title: 'partying'}, - woozy: {content: '๐Ÿฅด', value: 'woozy', title: 'woozy'}, - 'cold-face': {content: '๐Ÿฅถ', value: 'cold-face', title: 'cold-face'}, -} satisfies Record; + 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/mock/mockHooks.tsx b/src/components/Reactions/mock/mockHooks.tsx new file mode 100644 index 00000000..02f041d4 --- /dev/null +++ b/src/components/Reactions/mock/mockHooks.tsx @@ -0,0 +1,108 @@ +import React from 'react'; + +import {Flex, Text, User} from '@gravity-ui/uikit'; + +import {ReactionProps} from '../Reaction'; +import {Reactions, ReactionsProps} from '../Reactions'; + +import { + ReactionsMockUser, + reactionsPalletteMockOption as option, + reactionsPalletteMockOptions as options, + reactionsPalletteMockOption, + 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[]): ReactionProps['tooltip'] => ({ + content: 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 reactions = React.useMemo( + () => + Object.entries(usersReacted).map( + ([value, users]): ReactionProps => ({ + ...reactionsPalletteMockOption[ + value as keyof typeof reactionsPalletteMockOption + ], + counter: users.length, + tooltip: getTooltip(users), + selected: users.some(({name}) => name === currentUser.name), + }), + ), + [usersReacted], + ); + + const onClickReaction = 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], + ); + + return { + palette: {options}, + reactions, + onClickReaction, + }; +} + +export function TestReactions(props: Partial) { + return ; +} From bae56295cbd8e050fe15244602efc86785d4f88e Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Fri, 31 May 2024 16:03:33 +0300 Subject: [PATCH 05/21] feat: added README --- src/components/Reactions/README.md | 114 +++++++++++++++++++++++++ src/components/Reactions/Reaction.tsx | 34 ++++---- src/components/Reactions/Reactions.tsx | 12 +-- 3 files changed, 138 insertions(+), 22 deletions(-) create mode 100644 src/components/Reactions/README.md diff --git a/src/components/Reactions/README.md b/src/components/Reactions/README.md new file mode 100644 index 00000000..d7fe1007 --- /dev/null +++ b/src/components/Reactions/README.md @@ -0,0 +1,114 @@ +## 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 {ReactionProps, 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 ReactionProps + const reactions = React.useMemo( + () => + Object.entries(usersReacted).map( + ([value, users]): ReactionProps => ({ + ...option[value as keyof typeof option], + counter: users.length, + selected: users.some(({name}) => name === currentUser.name), + }), + ), + [usersReacted], + ); + + // You can then handle clicking on a reaction with chaning the inital mapping, + // and the array of ReactionProps will change accordingly + const onClickReaction = 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 | +| :---------------- | :------------------------ | :------: | :------ | :--------------------------------------------------------------------------------------------- | +| `reactions` | `ReactionProps[]` | `true` | | List of Reactions to display | +| `palette` | `ReactionsPaletteProps` | `true` | | Notifications' palette props โ€” it's a `Palette` component with available reactions to the user | +| `onClickReaction` | `(value: string) => void` | | | Fires when a user clicks on a Reaction (in a Palette or in the Reactions' list) | +| `size` | `ButtonSize` | | `m` | Buttons's size | +| `disabled` | `boolean` | | `false` | If the buttons' are disabled | +| `qa` | `string` | | | `data-qa` html attribute | +| `className` | `string` | | | HTML class attribute | +| `style` | `React.CSSProperties` | | | HTML style attribute | + +**ReactionProps** (single reaction props) extends `Palette`'s `PaletteOption`: + +| Property | Type | Required | Default | Description | +| :--------- | :--------------------- | :------: | :------ | :------------------------------------------------------------ | +| `selected` | `boolean` | | | Is reaction selected by the user | +| `counter` | `React.ReactNode` | | | How many users used this reaction | +| `tooltip` | `ReactionTooltipProps` | | | Reaction's tooltip with the list of reacted users for example | + +**ReactionTooltipProps** โ€” notification's type extends `Pick`: + +| Property | Type | Required | Default | Description | +| :-------------- | :---------------- | :------: | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `content` | `React.ReactNode` | `true` | | Tooltip's content | +| `className` | `string` | | | Tooltip content's HTML class attribute | +| `canClosePopup` | `() => boolean` | | | Fires when the `onMouseLeave` callback is called. Usage example: you have some popup inside a tooltip, you hover on it, you don't want the tooltip to be closed because of that. | diff --git a/src/components/Reactions/Reaction.tsx b/src/components/Reactions/Reaction.tsx index 32c9da7c..ac8a7835 100644 --- a/src/components/Reactions/Reaction.tsx +++ b/src/components/Reactions/Reaction.tsx @@ -8,6 +8,23 @@ import {useStableCallback} from '../utils/useStableCallback'; import {useReactionsContext} from './context'; import {useReactionsPopup} from './hooks'; +export interface ReactionProps extends PaletteOption { + /** + * 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; + /** + * If present, when a user hovers over the reaction, a popover appears with `tooltip.content`. + * Can be used to display users who used this reaction. + */ + tooltip?: ReactionTooltipProps; +} + export interface ReactionTooltipProps extends Pick { /** @@ -26,23 +43,6 @@ export interface ReactionTooltipProps canClosePopup?: () => boolean; } -export interface ReactionProps extends PaletteOption { - /** - * 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; - /** - * If present, when a user hovers over the reaction, a popover appears with `tooltip.content`. - * Can be used to display users who used this reaction. - */ - tooltip?: ReactionTooltipProps; -} - interface ReactionInnerProps { reaction: ReactionProps; size: ButtonSize; diff --git a/src/components/Reactions/Reactions.tsx b/src/components/Reactions/Reactions.tsx index 2a1aa0e3..2e32a17c 100644 --- a/src/components/Reactions/Reactions.tsx +++ b/src/components/Reactions/Reactions.tsx @@ -23,6 +23,11 @@ import './Reactions.scss'; const b = block('reactions'); +export type ReactionsPaletteProps = Omit< + PaletteProps, + 'value' | 'defaultValue' | 'onUpdate' | 'size' | 'disabled' | 'multiple' +>; + export interface ReactionsProps extends Pick, QAProps, DOMProps { /** * Users' reactions. @@ -31,10 +36,7 @@ export interface ReactionsProps extends Pick, /** * Reactions' palette props. */ - palette: Omit< - PaletteProps, - 'value' | 'defaultValue' | 'onUpdate' | 'size' | 'disabled' | 'multiple' - >; + palette: ReactionsPaletteProps; /** * Callback for clicking on a reaction in the Palette or directly in the reactions' list. */ @@ -95,7 +97,7 @@ export function Reactions({ setOpenedTooltip: setCurrentHoveredReaction, }} > - + {/* Reactions' list */} {reactions.map((reaction) => { return ( From 7693d1a272c7f19670bf3c60bdfedb2cc1ecfe03 Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Fri, 31 May 2024 16:11:21 +0300 Subject: [PATCH 06/21] fix: fixed reaction's view --- src/components/Reactions/Reaction.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Reactions/Reaction.tsx b/src/components/Reactions/Reaction.tsx index ac8a7835..fadb583d 100644 --- a/src/components/Reactions/Reaction.tsx +++ b/src/components/Reactions/Reaction.tsx @@ -77,7 +77,7 @@ export function Reaction(props: ReactionInnerProps) { disabled={disabled} size={size} selected={selected} - view={selected ? 'outlined-info' : 'outlined'} + view="outlined" extraProps={{value}} onClick={onClickCallback} > From 7f5902ef2a8a7208a7b3405ec81fa9efa8d3b10b Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Fri, 31 May 2024 16:12:50 +0300 Subject: [PATCH 07/21] fix: $buttons -> $reactions --- .../Reactions/__tests__/Reactions.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/Reactions/__tests__/Reactions.test.tsx b/src/components/Reactions/__tests__/Reactions.test.tsx index bd9d9fbe..9fb77946 100644 --- a/src/components/Reactions/__tests__/Reactions.test.tsx +++ b/src/components/Reactions/__tests__/Reactions.test.tsx @@ -23,10 +23,10 @@ describe('Reactions', () => { render(); const $component = screen.getByTestId(qaId); - const $buttons = within($component).getAllByRole('button'); + const $reactions = within($component).getAllByRole('button'); - $buttons.forEach(($button: HTMLElement) => { - expect($button).toHaveClass(`g-button_size_${size}`); + $reactions.forEach(($reaction: HTMLElement) => { + expect($reaction).toHaveClass(`g-button_size_${size}`); }); }, ); @@ -35,10 +35,10 @@ describe('Reactions', () => { render(); const $component = screen.getByTestId(qaId); - const $buttons = within($component).getAllByRole('button'); + const $reactions = within($component).getAllByRole('button'); - $buttons.forEach(($button: HTMLElement) => { - expect($button).toBeDisabled(); + $reactions.forEach(($reaction: HTMLElement) => { + expect($reaction).toBeDisabled(); }); }); From d9c1f8e7272f17d30ba8e26aee241b13ae9d6ffb Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Fri, 31 May 2024 17:00:03 +0300 Subject: [PATCH 08/21] fix: fixed qa attribute --- src/components/Reactions/README.md | 2 +- src/components/Reactions/Reactions.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Reactions/README.md b/src/components/Reactions/README.md index d7fe1007..05279bd2 100644 --- a/src/components/Reactions/README.md +++ b/src/components/Reactions/README.md @@ -93,7 +93,7 @@ For more code examples go to [Reactions.stories.tsx](https://github.com/gravity- | `onClickReaction` | `(value: string) => void` | | | Fires when a user clicks on a Reaction (in a Palette or in the Reactions' list) | | `size` | `ButtonSize` | | `m` | Buttons's size | | `disabled` | `boolean` | | `false` | If the buttons' are disabled | -| `qa` | `string` | | | `data-qa` html attribute | +| `qa` | `string` | | | `qa` attribute for testing | | `className` | `string` | | | HTML class attribute | | `style` | `React.CSSProperties` | | | HTML style attribute | diff --git a/src/components/Reactions/Reactions.tsx b/src/components/Reactions/Reactions.tsx index 2e32a17c..2895afeb 100644 --- a/src/components/Reactions/Reactions.tsx +++ b/src/components/Reactions/Reactions.tsx @@ -97,7 +97,7 @@ export function Reactions({ setOpenedTooltip: setCurrentHoveredReaction, }} > - + {/* Reactions' list */} {reactions.map((reaction) => { return ( From 8675b225ca5e5a7018481e1f9789d0610a6465ad Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Fri, 7 Jun 2024 17:51:15 +0300 Subject: [PATCH 09/21] fix: minor PR fixes --- src/components/Reactions/Reaction.tsx | 6 +++--- src/components/Reactions/Reactions.scss | 10 +++++----- .../Reactions/__stories__/Reactions.stories.tsx | 2 +- src/components/Reactions/__tests__/Reactions.test.tsx | 5 +++-- .../Reactions/{ => __tests__}/mock/mockData.ts | 0 .../Reactions/{ => __tests__}/mock/mockHooks.tsx | 5 +++-- 6 files changed, 15 insertions(+), 13 deletions(-) rename src/components/Reactions/{ => __tests__}/mock/mockData.ts (100%) rename src/components/Reactions/{ => __tests__}/mock/mockHooks.tsx (95%) diff --git a/src/components/Reactions/Reaction.tsx b/src/components/Reactions/Reaction.tsx index fadb583d..2e129e51 100644 --- a/src/components/Reactions/Reaction.tsx +++ b/src/components/Reactions/Reaction.tsx @@ -3,12 +3,12 @@ import React from 'react'; import {Button, ButtonSize, PaletteOption, PopoverProps, Popup} from '@gravity-ui/uikit'; import {block} from '../utils/cn'; -import {useStableCallback} from '../utils/useStableCallback'; import {useReactionsContext} from './context'; import {useReactionsPopup} from './hooks'; -export interface ReactionProps extends PaletteOption { +export interface ReactionProps + extends Pick { /** * Should be true when the user used this reaction. */ @@ -64,7 +64,7 @@ export function Reaction(props: ReactionInnerProps) { const {value, disabled, selected, content, counter, tooltip} = props.reaction; const {size, onClick} = props; - const onClickCallback = useStableCallback(() => onClick?.(value)); + const onClickCallback = React.useCallback(() => onClick?.(value), [onClick, value]); const buttonRef = React.useRef(null); const {onMouseEnter, onMouseLeave} = useReactionsPopup(props.reaction, buttonRef); diff --git a/src/components/Reactions/Reactions.scss b/src/components/Reactions/Reactions.scss index 8b59a4ae..19fd75a8 100644 --- a/src/components/Reactions/Reactions.scss +++ b/src/components/Reactions/Reactions.scss @@ -32,18 +32,18 @@ $block: '.#{variables.$ns}reactions'; } &__reaction-button-text_size_xs { - font-size: 10px; + font-size: var(--g-text-caption-1-font-size); } &__reaction-button-text_size_s { - font-size: 12px; + font-size: var(--g-text-caption-2-font-size); } &__reaction-button-text_size_m { - font-size: 13px; + font-size: var(--g-text-body-1-font-size); } &__reaction-button-text_size_l { - font-size: 14px; + font-size: var(--g-text-subheader-1-font-size); } &__reaction-button-text_size_xl { - font-size: 16px; + font-size: var(--g-text-subheader-2-font-size); } } diff --git a/src/components/Reactions/__stories__/Reactions.stories.tsx b/src/components/Reactions/__stories__/Reactions.stories.tsx index e3bbdc7b..e5291948 100644 --- a/src/components/Reactions/__stories__/Reactions.stories.tsx +++ b/src/components/Reactions/__stories__/Reactions.stories.tsx @@ -4,7 +4,7 @@ import {Flex, Text} from '@gravity-ui/uikit'; import {Meta, StoryFn} from '@storybook/react'; import {Reactions} from '../Reactions'; -import {useMockReactions} from '../mock/mockHooks'; +import {useMockReactions} from '../__tests__/mock/mockHooks'; export default { title: 'Components/Reactions', diff --git a/src/components/Reactions/__tests__/Reactions.test.tsx b/src/components/Reactions/__tests__/Reactions.test.tsx index 9fb77946..e888c5ff 100644 --- a/src/components/Reactions/__tests__/Reactions.test.tsx +++ b/src/components/Reactions/__tests__/Reactions.test.tsx @@ -4,8 +4,9 @@ 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'; + +import {reactionsPalletteMockOption as option} from './mock/mockData'; +import {TestReactions} from './mock/mockHooks'; const qaId = 'reactions-component'; diff --git a/src/components/Reactions/mock/mockData.ts b/src/components/Reactions/__tests__/mock/mockData.ts similarity index 100% rename from src/components/Reactions/mock/mockData.ts rename to src/components/Reactions/__tests__/mock/mockData.ts diff --git a/src/components/Reactions/mock/mockHooks.tsx b/src/components/Reactions/__tests__/mock/mockHooks.tsx similarity index 95% rename from src/components/Reactions/mock/mockHooks.tsx rename to src/components/Reactions/__tests__/mock/mockHooks.tsx index 02f041d4..5750a042 100644 --- a/src/components/Reactions/mock/mockHooks.tsx +++ b/src/components/Reactions/__tests__/mock/mockHooks.tsx @@ -1,9 +1,10 @@ +/* eslint-disable no-param-reassign */ import React from 'react'; import {Flex, Text, User} from '@gravity-ui/uikit'; -import {ReactionProps} from '../Reaction'; -import {Reactions, ReactionsProps} from '../Reactions'; +import {ReactionProps} from '../../Reaction'; +import {Reactions, ReactionsProps} from '../../Reactions'; import { ReactionsMockUser, From 8d9686714d8184c10a40823aad7f7ade438a8283 Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Fri, 7 Jun 2024 17:54:25 +0300 Subject: [PATCH 10/21] fix: eslint --- src/components/Reactions/__tests__/mock/mockHooks.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Reactions/__tests__/mock/mockHooks.tsx b/src/components/Reactions/__tests__/mock/mockHooks.tsx index 5750a042..e6466747 100644 --- a/src/components/Reactions/__tests__/mock/mockHooks.tsx +++ b/src/components/Reactions/__tests__/mock/mockHooks.tsx @@ -1,4 +1,3 @@ -/* eslint-disable no-param-reassign */ import React from 'react'; import {Flex, Text, User} from '@gravity-ui/uikit'; From 855b5d457a50098f53dea2ee4a9f8cb11bda3983 Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Thu, 11 Jul 2024 12:44:38 +0300 Subject: [PATCH 11/21] fix: simplified `ReactionProps`'s `tooltip` + removed `content` and `title` from it --- src/components/Reactions/README.md | 6 +-- src/components/Reactions/Reaction.tsx | 37 ++++--------------- src/components/Reactions/Reactions.tsx | 19 ++++++++++ .../Reactions/__tests__/mock/mockHooks.tsx | 10 ++--- src/components/Reactions/hooks.ts | 28 ++------------ 5 files changed, 37 insertions(+), 63 deletions(-) diff --git a/src/components/Reactions/README.md b/src/components/Reactions/README.md index 05279bd2..ad5d4109 100644 --- a/src/components/Reactions/README.md +++ b/src/components/Reactions/README.md @@ -35,7 +35,7 @@ const YourComponent = () => { () => Object.entries(usersReacted).map( ([value, users]): ReactionProps => ({ - ...option[value as keyof typeof option], + value, counter: users.length, selected: users.some(({name}) => name === currentUser.name), }), @@ -43,7 +43,7 @@ const YourComponent = () => { [usersReacted], ); - // You can then handle clicking on a reaction with chaning the inital mapping, + // You can then handle clicking on a reaction with changing the inital mapping, // and the array of ReactionProps will change accordingly const onClickReaction = React.useCallback( (value: string) => { @@ -97,7 +97,7 @@ For more code examples go to [Reactions.stories.tsx](https://github.com/gravity- | `className` | `string` | | | HTML class attribute | | `style` | `React.CSSProperties` | | | HTML style attribute | -**ReactionProps** (single reaction props) extends `Palette`'s `PaletteOption`: +**ReactionProps** (single reaction props) extends `Palette`'s `PaletteOption` `disabled` and `value` props: | Property | Type | Required | Default | Description | | :--------- | :--------------------- | :------: | :------ | :------------------------------------------------------------ | diff --git a/src/components/Reactions/Reaction.tsx b/src/components/Reactions/Reaction.tsx index 2e129e51..b8fb789e 100644 --- a/src/components/Reactions/Reaction.tsx +++ b/src/components/Reactions/Reaction.tsx @@ -7,8 +7,7 @@ import {block} from '../utils/cn'; import {useReactionsContext} from './context'; import {useReactionsPopup} from './hooks'; -export interface ReactionProps - extends Pick { +export interface ReactionProps extends Pick { /** * Should be true when the user used this reaction. */ @@ -22,28 +21,10 @@ export interface ReactionProps * If present, when a user hovers over the reaction, a popover appears with `tooltip.content`. * Can be used to display users who used this reaction. */ - tooltip?: ReactionTooltipProps; + tooltip?: React.ReactNode; } -export interface ReactionTooltipProps - extends Pick { - /** - * Tooltip's content. - */ - content: React.ReactNode; - /** - * Tooltip content's HTML class attribute. - */ - className?: string; - /** - * Fires when the `onMouseLeave` callback is called. - * Usage example: - * you have some popup inside a tooltip, you hover on it, you don't want the tooltip to be closed because of that. - */ - canClosePopup?: () => boolean; -} - -interface ReactionInnerProps { +interface ReactionInnerProps extends Pick { reaction: ReactionProps; size: ButtonSize; onClick?: (value: string) => void; @@ -61,8 +42,8 @@ const popupDefaultPlacement: PopoverProps['placement'] = [ const b = block('reactions'); export function Reaction(props: ReactionInnerProps) { - const {value, disabled, selected, content, counter, tooltip} = props.reaction; - const {size, onClick} = props; + const {value, disabled, selected, counter, tooltip} = props.reaction; + const {size, content, onClick} = props; const onClickCallback = React.useCallback(() => onClick?.(value), [onClick, value]); @@ -94,15 +75,13 @@ export function Reaction(props: ReactionInnerProps) { {currentHoveredReaction && currentHoveredReaction.reaction.value === value ? ( - {tooltip.content} + {tooltip} ) : null}
diff --git a/src/components/Reactions/Reactions.tsx b/src/components/Reactions/Reactions.tsx index 2895afeb..8ad3b500 100644 --- a/src/components/Reactions/Reactions.tsx +++ b/src/components/Reactions/Reactions.tsx @@ -7,6 +7,7 @@ import { Flex, Icon, Palette, + PaletteOption, PaletteProps, Popover, QAProps, @@ -65,6 +66,21 @@ export function Reactions({ ReactionsContextTooltipProps | undefined >(undefined); + const paletteOptionsMap = React.useMemo( + () => + palette.options + ? palette.options.reduce>( + (acc, current) => { + // eslint-disable-next-line no-param-reassign + acc[current.value] = current; + return acc; + }, + {}, + ) + : {}, + [palette.options], + ); + const paletteValue = React.useMemo( () => reactions.filter((reaction) => reaction.selected).map((reaction) => reaction.value), [reactions], @@ -100,9 +116,12 @@ export function Reactions({ {/* Reactions' list */} {reactions.map((reaction) => { + const content = paletteOptionsMap[reaction.value]?.content ?? '?'; + return ( { ); }; -const getTooltip = (users: ReactionsMockUser[]): ReactionProps['tooltip'] => ({ - content: renderUsersReacted(users), -}); +const getTooltip = (users: ReactionsMockUser[]): ReactionProps['tooltip'] => + renderUsersReacted(users); export function useMockReactions(): ReactionsProps { const [usersReacted, setUsersReacted] = React.useState({ @@ -60,9 +58,7 @@ export function useMockReactions(): ReactionsProps { () => Object.entries(usersReacted).map( ([value, users]): ReactionProps => ({ - ...reactionsPalletteMockOption[ - value as keyof typeof reactionsPalletteMockOption - ], + value, counter: users.length, tooltip: getTooltip(users), selected: users.some(({name}) => name === currentUser.name), diff --git a/src/components/Reactions/hooks.ts b/src/components/Reactions/hooks.ts index b6e21f2a..355a9e86 100644 --- a/src/components/Reactions/hooks.ts +++ b/src/components/Reactions/hooks.ts @@ -16,15 +16,12 @@ export function useReactionsPopup( ref: React.RefObject, ) { const {value} = reaction; - const canClosePopup = reaction.tooltip?.canClosePopup; const {openedTooltip: currentHoveredReaction, setOpenedTooltip: setCurrentHoveredReaction} = useReactionsContext(); const {delayedCall: setDelayedOpen, clearTimeoutRef: clearOpenTimeout} = useTimeoutRef(); const {delayedCall: setDelayedClose, clearTimeoutRef: clearCloseTimeout} = useTimeoutRef(); - const {delayedCall: setDelayedCloseRetry, clearTimeoutRef: clearCloseRetryTimeout} = - useTimeoutRef(); const open = useStableCallback(() => { setCurrentHoveredReaction({reaction, open: true, ref}); @@ -39,7 +36,6 @@ export function useReactionsPopup( }); const focus = useStableCallback(() => { - clearCloseRetryTimeout(); clearCloseTimeout(); setCurrentHoveredReaction({reaction, open: false, ref}); @@ -56,35 +52,19 @@ export function useReactionsPopup( setDelayedClose(close, DELAY.closeTimeout); }); - const fireClosePopup = useStableCallback(() => { - clearCloseRetryTimeout(); - - if (canClosePopup ? canClosePopup() : true) { - delayedClosePopup(); - } else { - setDelayedCloseRetry(fireClosePopup, DELAY.closeTimeout); - } - }); - const onMouseEnter: React.MouseEventHandler = delayedOpenPopup; - const onMouseLeave: React.MouseEventHandler = fireClosePopup; - - const windowFocusCallback = useStableCallback(() => { - if (currentHoveredReaction?.reaction.value === value && currentHoveredReaction.open) { - setCurrentHoveredReaction({...currentHoveredReaction, open: false}); - } - }); + const onMouseLeave: React.MouseEventHandler = 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', windowFocusCallback); + window.addEventListener('focus', close); return () => { - window.removeEventListener('focus', windowFocusCallback); + window.removeEventListener('focus', close); }; - }, [windowFocusCallback]); + }, [close]); return {onMouseEnter, onMouseLeave}; } From 7852b9e5c16ec8f1193be150f66f0d7e25940726 Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Fri, 19 Jul 2024 15:23:17 +0300 Subject: [PATCH 12/21] fix: removed useStableCallback completely --- src/components/Reactions/Reactions.tsx | 16 ++++++---- src/components/Reactions/hooks.ts | 39 ++++++++++++----------- src/components/utils/useStableCallback.ts | 13 -------- 3 files changed, 29 insertions(+), 39 deletions(-) delete mode 100644 src/components/utils/useStableCallback.ts diff --git a/src/components/Reactions/Reactions.tsx b/src/components/Reactions/Reactions.tsx index 8ad3b500..5fccc209 100644 --- a/src/components/Reactions/Reactions.tsx +++ b/src/components/Reactions/Reactions.tsx @@ -15,7 +15,6 @@ import { import xor from 'lodash/xor'; import {block} from '../utils/cn'; -import {useStableCallback} from '../utils/useStableCallback'; import {Reaction, ReactionProps} from './Reaction'; import {ReactionsContextProvider, ReactionsContextTooltipProps} from './context'; @@ -86,12 +85,15 @@ export function Reactions({ [reactions], ); - const onUpdatePalette = useStableCallback((updated: string[]) => { - const diffValues = xor(paletteValue, updated); - for (const diffValue of diffValues) { - onClickReaction?.(diffValue); - } - }); + const onUpdatePalette = React.useCallback( + (updated: string[]) => { + const diffValues = xor(paletteValue, updated); + for (const diffValue of diffValues) { + onClickReaction?.(diffValue); + } + }, + [onClickReaction, paletteValue], + ); const paletteContent = React.useMemo( () => ( diff --git a/src/components/Reactions/hooks.ts b/src/components/Reactions/hooks.ts index 355a9e86..bafce816 100644 --- a/src/components/Reactions/hooks.ts +++ b/src/components/Reactions/hooks.ts @@ -1,7 +1,5 @@ import React from 'react'; -import {useStableCallback} from '../utils/useStableCallback'; - import type {ReactionProps} from './Reaction'; import {useReactionsContext} from './context'; @@ -23,34 +21,34 @@ export function useReactionsPopup( const {delayedCall: setDelayedOpen, clearTimeoutRef: clearOpenTimeout} = useTimeoutRef(); const {delayedCall: setDelayedClose, clearTimeoutRef: clearCloseTimeout} = useTimeoutRef(); - const open = useStableCallback(() => { + const open = React.useCallback(() => { setCurrentHoveredReaction({reaction, open: true, ref}); - }); + }, [reaction, ref, setCurrentHoveredReaction]); - const close = useStableCallback(() => { + const close = React.useCallback(() => { clearOpenTimeout(); if (currentHoveredReaction?.reaction.value === value && currentHoveredReaction.open) { setCurrentHoveredReaction({...currentHoveredReaction, open: false}); } - }); + }, [clearOpenTimeout, currentHoveredReaction, setCurrentHoveredReaction, value]); - const focus = useStableCallback(() => { + const focus = React.useCallback(() => { clearCloseTimeout(); setCurrentHoveredReaction({reaction, open: false, ref}); setDelayedOpen(open, DELAY.openTimeout); - }); + }, [clearCloseTimeout, open, reaction, ref, setCurrentHoveredReaction, setDelayedOpen]); - const delayedOpenPopup = useStableCallback(() => { + const delayedOpenPopup = React.useCallback(() => { setDelayedOpen(focus, DELAY.focusTimeout); - }); + }, [focus, setDelayedOpen]); - const delayedClosePopup = useStableCallback(() => { + const delayedClosePopup = React.useCallback(() => { clearOpenTimeout(); setDelayedClose(close, DELAY.closeTimeout); - }); + }, [clearOpenTimeout, close, setDelayedClose]); const onMouseEnter: React.MouseEventHandler = delayedOpenPopup; @@ -72,17 +70,20 @@ export function useReactionsPopup( function useTimeoutRef() { const timeoutRef = React.useRef | null>(null); - const clearTimeoutRef = useStableCallback(() => { + const clearTimeoutRef = React.useCallback(() => { if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } - }); - - const delayedCall = useStableCallback((handler: () => void, delay: number) => { - clearTimeoutRef(); - timeoutRef.current = setTimeout(handler, delay); - }); + }, []); + + const delayedCall = React.useCallback( + (handler: () => void, delay: number) => { + clearTimeoutRef(); + timeoutRef.current = setTimeout(handler, delay); + }, + [clearTimeoutRef], + ); return {delayedCall, clearTimeoutRef}; } diff --git a/src/components/utils/useStableCallback.ts b/src/components/utils/useStableCallback.ts deleted file mode 100644 index 792569b0..00000000 --- a/src/components/utils/useStableCallback.ts +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; - -export function useStableCallback, Result>( - handler: (...args: Args) => Result, -) { - const handlerRef = React.useRef(handler); - - handlerRef.current = handler; - - return React.useCallback((...args: Args) => { - return handlerRef.current(...args); - }, []); -} From e087e0353e89f0ad49aaf23fdcaee2f9249e5a64 Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Fri, 19 Jul 2024 17:27:27 +0300 Subject: [PATCH 13/21] fix: api refactoring --- src/components/Reactions/README.md | 58 +++++++-------- src/components/Reactions/Reaction.tsx | 13 ++-- src/components/Reactions/Reactions.tsx | 70 ++++++++++--------- .../__stories__/Reactions.stories.tsx | 21 +++++- .../Reactions/__tests__/Reactions.test.tsx | 11 --- .../Reactions/__tests__/mock/mockHooks.tsx | 16 ++--- src/components/Reactions/context.ts | 4 +- src/components/Reactions/hooks.ts | 4 +- 8 files changed, 104 insertions(+), 93 deletions(-) diff --git a/src/components/Reactions/README.md b/src/components/Reactions/README.md index ad5d4109..dd2c7e7e 100644 --- a/src/components/Reactions/README.md +++ b/src/components/Reactions/README.md @@ -8,7 +8,7 @@ Component for user reactions (e.g. ๐Ÿ‘, ๐Ÿ˜Š, ๐Ÿ˜Ž etc) as new GitHub comments import React from 'react'; import {PaletteOption} from '@gravity-ui/uikit'; -import {ReactionProps, Reactions} from '@gravity-ui/components'; +import {ReactionStateProps, Reactions} from '@gravity-ui/components'; const user = { spongeBob: {name: 'Sponge Bob'}, @@ -30,11 +30,11 @@ const YourComponent = () => { [option.cool.value]: [user.spongeBob], }); - // And then convert that mapping into an array of ReactionProps + // And then convert that mapping into an array of ReactionStateProps const reactions = React.useMemo( () => Object.entries(usersReacted).map( - ([value, users]): ReactionProps => ({ + ([value, users]): ReactionStateProps => ({ value, counter: users.length, selected: users.some(({name}) => name === currentUser.name), @@ -44,8 +44,8 @@ const YourComponent = () => { ); // You can then handle clicking on a reaction with changing the inital mapping, - // and the array of ReactionProps will change accordingly - const onClickReaction = React.useCallback( + // and the array of ReactionStateProps will change accordingly + const onToggle = React.useCallback( (value: string) => { if (!usersReacted[value]) { // If the reaction is not present yet @@ -75,7 +75,7 @@ const YourComponent = () => { ); return ( - + ); }; ``` @@ -86,29 +86,23 @@ For more code examples go to [Reactions.stories.tsx](https://github.com/gravity- **ReactionsProps** (main component props โ€” Reactions' list): -| Property | Type | Required | Default | Description | -| :---------------- | :------------------------ | :------: | :------ | :--------------------------------------------------------------------------------------------- | -| `reactions` | `ReactionProps[]` | `true` | | List of Reactions to display | -| `palette` | `ReactionsPaletteProps` | `true` | | Notifications' palette props โ€” it's a `Palette` component with available reactions to the user | -| `onClickReaction` | `(value: string) => void` | | | Fires when a user clicks on a Reaction (in a Palette or in the Reactions' list) | -| `size` | `ButtonSize` | | `m` | Buttons's size | -| `disabled` | `boolean` | | `false` | If the buttons' are disabled | -| `qa` | `string` | | | `qa` attribute for testing | -| `className` | `string` | | | HTML class attribute | -| `style` | `React.CSSProperties` | | | HTML style attribute | - -**ReactionProps** (single reaction props) extends `Palette`'s `PaletteOption` `disabled` and `value` props: - -| Property | Type | Required | Default | Description | -| :--------- | :--------------------- | :------: | :------ | :------------------------------------------------------------ | -| `selected` | `boolean` | | | Is reaction selected by the user | -| `counter` | `React.ReactNode` | | | How many users used this reaction | -| `tooltip` | `ReactionTooltipProps` | | | Reaction's tooltip with the list of reacted users for example | - -**ReactionTooltipProps** โ€” notification's type extends `Pick`: - -| Property | Type | Required | Default | Description | -| :-------------- | :---------------- | :------: | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `content` | `React.ReactNode` | `true` | | Tooltip's content | -| `className` | `string` | | | Tooltip content's HTML class attribute | -| `canClosePopup` | `() => boolean` | | | Fires when the `onMouseLeave` callback is called. Usage example: you have some popup inside a tooltip, you hover on it, you don't want the tooltip to be closed because of that. | +| Property | Type | Required | Default | Description | +| :--------------- | :------------------------ | :------: | :------ | :--------------------------------------------------------------------------------------------- | +| `reactions` | `PaletteOption[]` | `true` | | List of all available reactions | +| `reactionsState` | `ReactionStateProps[]` | `true` | | List of reactions that were used | +| `paletteProps` | `ReactionsPaletteProps` | `true` | | Notifications' palette props โ€” it's a `Palette` component with available reactions to the user | +| `onToggle` | `(value: string) => void` | | | Fires when a user clicks on a Reaction (in a Palette or in the Reactions' list) | +| `size` | `ButtonSize` | | `m` | Buttons's size | +| `readOnly` | `boolean` | | `false` | readOnly state (usage example: only signed in users can react) | +| `qa` | `string` | | | `qa` attribute for testing | +| `className` | `string` | | | HTML class attribute | +| `style` | `React.CSSProperties` | | | HTML style attribute | + +**ReactionStateProps** (single reaction props): + +| Property | Type | Required | Default | Description | +| :--------- | :---------------- | :------: | :------ | :------------------------------------------------------------ | +| `value` | `string` | | | Reaction's unique value (ID) | +| `selected` | `boolean` | | | Is reaction selected by the user | +| `counter` | `React.ReactNode` | | | How many users used this reaction | +| `tooltip` | `React.ReactNode` | | | Reaction's tooltip with the list of reacted users for example | diff --git a/src/components/Reactions/Reaction.tsx b/src/components/Reactions/Reaction.tsx index b8fb789e..96a01cad 100644 --- a/src/components/Reactions/Reaction.tsx +++ b/src/components/Reactions/Reaction.tsx @@ -7,7 +7,13 @@ import {block} from '../utils/cn'; import {useReactionsContext} from './context'; import {useReactionsPopup} from './hooks'; -export interface ReactionProps extends Pick { +export type ReactionProps = Pick; + +export interface ReactionStateProps { + /** + * Reaction's unique value (ID). + */ + value: string; /** * Should be true when the user used this reaction. */ @@ -25,7 +31,7 @@ export interface ReactionProps extends Pick } interface ReactionInnerProps extends Pick { - reaction: ReactionProps; + reaction: ReactionStateProps; size: ButtonSize; onClick?: (value: string) => void; } @@ -42,7 +48,7 @@ const popupDefaultPlacement: PopoverProps['placement'] = [ const b = block('reactions'); export function Reaction(props: ReactionInnerProps) { - const {value, disabled, selected, counter, tooltip} = props.reaction; + const {value, selected, counter, tooltip} = props.reaction; const {size, content, onClick} = props; const onClickCallback = React.useCallback(() => onClick?.(value), [onClick, value]); @@ -55,7 +61,6 @@ export function Reaction(props: ReactionInnerProps) { + + ); return tooltip ? ( -
+
{button} {currentHoveredReaction && currentHoveredReaction.reaction.value === value ? ( diff --git a/src/components/Reactions/Reactions.tsx b/src/components/Reactions/Reactions.tsx index 47b2fd40..2259f932 100644 --- a/src/components/Reactions/Reactions.tsx +++ b/src/components/Reactions/Reactions.tsx @@ -45,6 +45,14 @@ export interface ReactionsProps extends Pick, QAProps, DOM * Reactions' readonly state (when a user is unable to react for some reason). */ readOnly?: boolean; + /** + * How a reaction's tooltip should act: + * 1. as a tooltip (you can't hover over the contents โ€” it disappeares), + * 2. as a popover (you can hover over the contents โ€” it doesn't disappear). + * + * Default: 'tooltip'. + */ + tooltipBehavior?: 'tooltip' | 'popover'; /** * Callback for clicking on a reaction in the Palette or directly in the reactions' list. */ @@ -67,6 +75,7 @@ export function Reactions({ size = 'm', paletteProps, readOnly, + tooltipBehavior, qa, onToggle, }: ReactionsProps) { @@ -132,6 +141,7 @@ export function Reactions({ key={reaction.value} content={content} reaction={reaction} + tooltipBehavior={tooltipBehavior ?? 'tooltip'} size={size} onClick={readOnly ? undefined : onToggle} /> diff --git a/src/components/Reactions/__stories__/Reactions.stories.tsx b/src/components/Reactions/__stories__/Reactions.stories.tsx index 5cf73591..865ef28f 100644 --- a/src/components/Reactions/__stories__/Reactions.stories.tsx +++ b/src/components/Reactions/__stories__/Reactions.stories.tsx @@ -62,3 +62,18 @@ export const Size: StoryFn = () => { ); }; + +export const TooltipBehavior: StoryFn = () => { + return ( + + + Behaves as a tooltip + + + + Behaves as a popover + + + + ); +}; diff --git a/src/components/Reactions/hooks.ts b/src/components/Reactions/hooks.ts index eaf8c971..65badea3 100644 --- a/src/components/Reactions/hooks.ts +++ b/src/components/Reactions/hooks.ts @@ -6,7 +6,7 @@ import {useReactionsContext} from './context'; const DELAY = { focusTimeout: 600, openTimeout: 200, - closeTimeout: 100, + closeTimeout: 200, } as const; export function useReactionsPopup( @@ -35,14 +35,32 @@ export function useReactionsPopup( const focus = React.useCallback(() => { clearCloseTimeout(); - setCurrentHoveredReaction({reaction, open: false, ref}); - setDelayedOpen(open, DELAY.openTimeout); - }, [clearCloseTimeout, open, reaction, ref, setCurrentHoveredReaction, setDelayedOpen]); + // 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); - }, [focus, setDelayedOpen]); + }, [clearCloseTimeout, focus, setDelayedOpen]); const delayedClosePopup = React.useCallback(() => { clearOpenTimeout(); From c7cf6c4d7f29659b40943fd895b3d86c6de61d67 Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Wed, 24 Jul 2024 17:01:56 +0300 Subject: [PATCH 16/21] chore: added myself as owner of `Notifications` and `Reactions` components --- CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) 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 From 942bacc9a5bf9fb8ee014f62a02232a7c2f6e497 Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Fri, 2 Aug 2024 13:32:40 +0300 Subject: [PATCH 17/21] fix: minor PR fixes --- src/components/Reactions/Reaction.tsx | 35 +++++++++--------- src/components/Reactions/Reactions.scss | 37 ++++++++++--------- src/components/Reactions/Reactions.tsx | 9 +++-- .../Reactions/__tests__/mock/mockHooks.tsx | 6 +-- src/components/Reactions/context.ts | 4 +- src/components/Reactions/hooks.ts | 12 ++++-- src/components/Reactions/i18n/en.json | 3 ++ src/components/Reactions/i18n/index.ts | 8 ++++ src/components/Reactions/i18n/ru.json | 3 ++ 9 files changed, 70 insertions(+), 47 deletions(-) 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 diff --git a/src/components/Reactions/Reaction.tsx b/src/components/Reactions/Reaction.tsx index bd96324e..86de1e60 100644 --- a/src/components/Reactions/Reaction.tsx +++ b/src/components/Reactions/Reaction.tsx @@ -9,7 +9,7 @@ import {useReactionsPopup} from './hooks'; export type ReactionProps = Pick; -export interface ReactionStateProps { +export interface ReactionState { /** * Reaction's unique value (ID). */ @@ -31,7 +31,7 @@ export interface ReactionStateProps { } interface ReactionInnerProps extends Pick { - reaction: ReactionStateProps; + reaction: ReactionState; size: ButtonSize; tooltipBehavior: 'tooltip' | 'popover'; onClick?: (value: string) => void; @@ -59,25 +59,24 @@ export function Reaction(props: ReactionInnerProps) { const {openedTooltip: currentHoveredReaction} = useReactionsContext(); const button = ( - - - + + {content} + + {counter === undefined || counter === null ? null : ( + {counter} + )} + ); return tooltip ? ( diff --git a/src/components/Reactions/Reactions.scss b/src/components/Reactions/Reactions.scss index 19fd75a8..1d4873a1 100644 --- a/src/components/Reactions/Reactions.scss +++ b/src/components/Reactions/Reactions.scss @@ -15,35 +15,38 @@ $block: '.#{variables.$ns}reactions'; max-width: unset; } - &__reaction-button_size_xs { + &__reaction-button-content_size_xs { font-size: 12px; } - &__reaction-button_size_s { - font-size: 16px; - } - &__reaction-button_size_m { - font-size: 16px; + &__reaction-button-content_size_xs#{&}__reaction-button-content_text { + font-size: var(--g-text-caption-1-font-size); } - &__reaction-button_size_l { + + &__reaction-button-content_size_s { font-size: 16px; } - &__reaction-button_size_xl { - font-size: 20px; + &__reaction-button-content_size_s#{&}__reaction-button-content_text { + font-size: var(--g-text-caption-2-font-size); } - &__reaction-button-text_size_xs { - font-size: var(--g-text-caption-1-font-size); - } - &__reaction-button-text_size_s { - font-size: var(--g-text-caption-2-font-size); + &__reaction-button-content_size_m { + font-size: 16px; } - &__reaction-button-text_size_m { + &__reaction-button-content_size_m#{&}__reaction-button-content_text { font-size: var(--g-text-body-1-font-size); } - &__reaction-button-text_size_l { + + &__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-text_size_xl { + + &__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 index 2259f932..374ca696 100644 --- a/src/components/Reactions/Reactions.tsx +++ b/src/components/Reactions/Reactions.tsx @@ -16,8 +16,9 @@ import xor from 'lodash/xor'; import {block} from '../utils/cn'; -import {Reaction, ReactionProps, ReactionStateProps} from './Reaction'; +import {Reaction, ReactionProps, ReactionState} from './Reaction'; import {ReactionsContextProvider, ReactionsContextTooltipProps} from './context'; +import {i18n} from './i18n'; import './Reactions.scss'; @@ -36,7 +37,7 @@ export interface ReactionsProps extends Pick, QAProps, DOM /** * Users' reactions. */ - reactionsState: ReactionStateProps[]; + reactionsState: ReactionState[]; /** * Reactions' palette props. */ @@ -155,11 +156,13 @@ export function Reactions({ tooltipContentClassName={b('add-reaction-popover')} openOnHover={false} hasArrow={false} + focusTrap + autoFocus > )} From 5e8006564b7c8df6f1f774bf377276949c4e193c Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Wed, 14 Aug 2024 15:12:35 +0300 Subject: [PATCH 20/21] fix: used colorText --- src/components/Reactions/Reactions.scss | 4 ---- src/components/Reactions/Reactions.tsx | 3 ++- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/components/Reactions/Reactions.scss b/src/components/Reactions/Reactions.scss index 105f1c61..5a0c3e62 100644 --- a/src/components/Reactions/Reactions.scss +++ b/src/components/Reactions/Reactions.scss @@ -3,10 +3,6 @@ $block: '.#{variables.$ns}reactions'; #{$block} { - &__add-reaction-button-content { - color: var(--g-color-text-secondary); - } - &__popup { padding: 8px; } diff --git a/src/components/Reactions/Reactions.tsx b/src/components/Reactions/Reactions.tsx index 9a2fc2f6..ebb249fe 100644 --- a/src/components/Reactions/Reactions.tsx +++ b/src/components/Reactions/Reactions.tsx @@ -11,6 +11,7 @@ import { PaletteProps, Popover, QAProps, + colorText, } from '@gravity-ui/uikit'; import xor from 'lodash/xor'; @@ -162,7 +163,7 @@ export function Reactions({ extraProps={{'aria-label': i18n('add-reaction')}} view="flat" > - + From 2439ec1996905caf8105d8d1801652981bce4c77 Mon Sep 17 00:00:00 2001 From: Vlad Furman Date: Mon, 19 Aug 2024 13:35:37 +0300 Subject: [PATCH 21/21] fix: used flat-secondary for reaction-button --- src/components/Reactions/Reactions.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/components/Reactions/Reactions.tsx b/src/components/Reactions/Reactions.tsx index ebb249fe..08e783e9 100644 --- a/src/components/Reactions/Reactions.tsx +++ b/src/components/Reactions/Reactions.tsx @@ -11,7 +11,6 @@ import { PaletteProps, Popover, QAProps, - colorText, } from '@gravity-ui/uikit'; import xor from 'lodash/xor'; @@ -161,13 +160,11 @@ export function Reactions({ className={b('reaction-button')} size={size} extraProps={{'aria-label': i18n('add-reaction')}} - view="flat" + view="flat-secondary" > - - - - - + + + )}