Skip to content

Commit

Permalink
feat(Reactions): added new Reactions component (#197)
Browse files Browse the repository at this point in the history
  • Loading branch information
Ruminat authored Aug 20, 2024
1 parent 8dcc87c commit be23d4e
Show file tree
Hide file tree
Showing 15 changed files with 881 additions and 0 deletions.
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
108 changes: 108 additions & 0 deletions src/components/Reactions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
## Reactions

Component for user reactions (e.g. 👍, 😊, 😎 etc) as new GitHub comments for example.

### Usage example

```typescript
import React from 'react';

import {PaletteOption} from '@gravity-ui/uikit';
import {ReactionState, Reactions} from '@gravity-ui/components';

const user = {
spongeBob: {name: 'Sponge Bob'},
patrick: {name: 'Patrick'},
};

const currentUser = user.spongeBob;

const option = {
'thumbs-up': {content: '👍', value: 'thumbs-up'},
cool: {content: '😎', value: 'cool'},
} satisfies Record<string, PaletteOption>;

const options = Object.values(option);

const YourComponent = () => {
// You can set up a mapping: reaction.value -> users reacted
const [usersReacted, setUsersReacted] = React.useState({
[option.cool.value]: [user.spongeBob],
});

// And then convert that mapping into an array of ReactionState
const reactions = React.useMemo(
() =>
Object.entries(usersReacted).map(
([value, users]): ReactionState => ({
value,
counter: users.length,
selected: users.some(({name}) => name === currentUser.name),
}),
),
[usersReacted],
);

// You can then handle clicking on a reaction with changing the inital mapping,
// and the array of ReactionState will change accordingly
const onToggle = React.useCallback(
(value: string) => {
if (!usersReacted[value]) {
// If the reaction is not present yet
setUsersReacted((current) => ({...current, [value]: [currentUser]}));
} else if (!usersReacted[value].some(({name}) => name === currentUser.name)) {
// If the reaction is present, but current user hasn't selected it yet
setUsersReacted((current) => ({
...current,
[value]: [...usersReacted[value], currentUser],
}));
} else if (usersReacted[value].length > 1) {
// If the user used that reaction, and he's not the only one who used it
setUsersReacted((current) => ({
...current,
[value]: usersReacted[value].filter(({name}) => name !== currentUser.name),
}));
} else {
// If the user used that reaction, and he's the only one who used it
setUsersReacted((current) => {
const newValue = {...current};
delete newValue[value];
return newValue;
});
}
},
[usersReacted],
);

return (
<Reactions palette={{options}} reactions={reactions} onToggle={onToggle} />
);
};
```

For more code examples go to [Reactions.stories.tsx](https://github.com/gravity-ui/components/blob/main/src/components/Reactions/__stories__/Reactions.stories.tsx).

### Props

**ReactionsProps** (main component props — Reactions' list):

| Property | Type | Required | Default | Description |
| :--------------- | :------------------------------------------ | :------: | :------ | :--------------------------------------------------------------------------------------------- |
| `className` | `string` | | | HTML `class` attribute |
| `onToggle` | `(value: string) => void` | | | Fires when a user clicks on a Reaction (in a Palette or in the Reactions' list) |
| `paletteProps` | `ReactionsPaletteProps` | `true` | | Notifications' palette props — it's a `Palette` component with available reactions to the user |
| `qa` | `string` | | | `qa` attribute for testing |
| `reactions` | `PaletteOption[]` | `true` | | List of all available reactions |
| `reactionsState` | `ReactionState[]` | `true` | | List of reactions that were used |
| `readOnly` | `boolean` | | `false` | readOnly state (usage example: only signed in users can react) |
| `renderTooltip` | `(state: ReactionState) => React.ReactNode` | | | Reaction's tooltip with the list of reacted users for example |
| `size` | `ButtonSize` | | `m` | Buttons's size |
| `style` | `React.CSSProperties` | | | HTML `style` attribute |

**ReactionState** (single reaction props):

| Property | Type | Required | Default | Description |
| :--------- | :---------------- | :------: | :------ | :-------------------------------- |
| `counter` | `React.ReactNode` | | | How many users used this reaction |
| `selected` | `boolean` | | | Is reaction selected by the user |
| `value` | `string` | | | Reaction's unique value (ID) |
94 changes: 94 additions & 0 deletions src/components/Reactions/Reaction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import React from 'react';

import {Button, ButtonSize, PaletteOption, PopoverProps, Popup} from '@gravity-ui/uikit';

import {block} from '../utils/cn';

import {useReactionsContext} from './context';
import {useReactionsPopup} from './hooks';

export type ReactionProps = Pick<PaletteOption, 'value' | 'content' | 'title'>;

export interface ReactionState {
/**
* Reaction's unique value (ID).
*/
value: string;
/**
* Should be true when the user used this reaction.
*/
selected?: boolean;
/**
* Display a number after the icon.
* Represents the number of users who used this reaction.
*/
counter?: React.ReactNode;
}

interface ReactionInnerProps extends Pick<PaletteOption, 'content'> {
reaction: ReactionState;
size: ButtonSize;
tooltip?: React.ReactNode;
onClick?: (value: string) => void;
}

const popupDefaultPlacement: PopoverProps['placement'] = [
'bottom-start',
'bottom',
'bottom-end',
'top-start',
'top',
'top-end',
];

const b = block('reactions');

export function Reaction(props: ReactionInnerProps) {
const {value, selected, counter} = props.reaction;
const {size, content, tooltip, onClick} = props;

const onClickCallback = React.useCallback(() => onClick?.(value), [onClick, value]);

const buttonRef = React.useRef<HTMLButtonElement>(null);
const {onMouseEnter, onMouseLeave} = useReactionsPopup(props.reaction, buttonRef);
const {openedTooltip: currentHoveredReaction} = useReactionsContext();

const button = (
<Button
className={b('reaction-button', {size})}
ref={buttonRef}
size={size}
selected={selected}
view="outlined"
extraProps={{value}}
onClick={onClickCallback}
>
<Button.Icon>
<span className={b('reaction-button-content', {size})}>{content}</span>
</Button.Icon>
{counter === undefined || counter === null ? null : (
<span className={b('reaction-button-content', {size, text: true})}>{counter}</span>
)}
</Button>
);

return tooltip ? (
<div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
{button}

{currentHoveredReaction && currentHoveredReaction.reaction.value === value ? (
<Popup
contentClassName={b('popup')}
anchorRef={currentHoveredReaction.ref}
placement={popupDefaultPlacement}
open={currentHoveredReaction.open}
hasArrow
>
{tooltip}
</Popup>
) : null}
</div>
) : (
button
);
}
48 changes: 48 additions & 0 deletions src/components/Reactions/Reactions.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
@use '../variables';

$block: '.#{variables.$ns}reactions';

#{$block} {
&__popup {
padding: 8px;
}

&__add-reaction-popover {
max-width: unset;
}

&__reaction-button-content_size_xs {
font-size: 12px;
}
&__reaction-button-content_size_xs#{&}__reaction-button-content_text {
font-size: var(--g-text-caption-1-font-size);
}

&__reaction-button-content_size_s {
font-size: 16px;
}
&__reaction-button-content_size_s#{&}__reaction-button-content_text {
font-size: var(--g-text-caption-2-font-size);
}

&__reaction-button-content_size_m {
font-size: 16px;
}
&__reaction-button-content_size_m#{&}__reaction-button-content_text {
font-size: var(--g-text-body-1-font-size);
}

&__reaction-button-content_size_l {
font-size: 16px;
}
&__reaction-button-content_size_l#{&}__reaction-button-content_text {
font-size: var(--g-text-subheader-1-font-size);
}

&__reaction-button-content_size_xl {
font-size: 20px;
}
&__reaction-button-content_size_xl#{&}__reaction-button-content_text {
font-size: var(--g-text-subheader-2-font-size);
}
}
Loading

0 comments on commit be23d4e

Please sign in to comment.