Skip to content

Commit

Permalink
feat(AvatarStack): add component (#924)
Browse files Browse the repository at this point in the history
  • Loading branch information
ogonkov authored Jul 17, 2024
1 parent 551535c commit 862f4fb
Show file tree
Hide file tree
Showing 28 changed files with 463 additions and 1 deletion.
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/src/components/Alert @IsaevAlexandr
/src/components/ArrowToggle @Marginy605
/src/components/Avatar @DakEnviy
/src/components/AvatarStack @ogonkov
#/src/components/Breadcrumbs
/src/components/Button @amje
/src/components/Card @Lunory
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@
"lint": "run-p lint:*",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run build && npm pkg delete engines",
"playwright:install": "playwright install --with-deps",
"playwright:install": "playwright install chromium webkit --with-deps",
"playwright": "playwright test --config=playwright/playwright.config.ts",
"playwright:update": "npm run playwright -- -u",
"playwright:docker": "./scripts/playwright-docker.sh 'npm run playwright'",
Expand Down
61 changes: 61 additions & 0 deletions src/components/AvatarStack/AvatarStack.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
@use '../../../styles/mixins';
@use '../Avatar/variables' as avatar-variables;
@use '../variables';

$block: '.#{variables.$ns}avatar-stack';

#{$block} {
--_--more-button-size: #{avatar-variables.$default-size};
--_--more-button-border-width: 1px;

display: inline-flex;
justify-content: flex-end;
flex-direction: row-reverse;

margin: 0;
padding: 0;

&_overlap-size_s {
--_--overlap: var(--g-spacing-1);
}

&_overlap-size_m {
--_--overlap: var(--g-spacing-2);
}

&_overlap-size_l {
--_--overlap: var(--g-spacing-3);
}

&__item {
display: flex;
z-index: 0;
border-radius: 100%;

&:not(:first-child) {
margin-inline-end: calc(-1 * var(--_--overlap));
}
}

&__more-button {
@include mixins.button-reset;

border-radius: 100%;

width: var(--_--more-button-size);
height: var(--_--more-button-size);

&:focus-visible {
outline: var(--g-color-line-focus) solid 2px;
outline-offset: 0;
}

&_size {
@each $size-name, $size-value in avatar-variables.$sizes {
&_#{$size-name} {
--_--more-button-size: #{$size-value};
}
}
}
}
}
69 changes: 69 additions & 0 deletions src/components/AvatarStack/AvatarStack.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React from 'react';

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

import {AvatarStackItem} from './AvatarStackItem';
import {AvatarStackMoreButton} from './AvatarStackMoreButton';
import i18n from './i18n';
import type {AvatarStackProps} from './types';

import './AvatarStack.scss';

const b = block('avatar-stack');

const AvatarStackComponent = ({
max = 3,
overlapSize = 's',
size,
children,
className,
renderMore,
}: AvatarStackProps) => {
const visibleItems: React.ReactElement[] = [];
let moreItems = 0;

React.Children.forEach(children, (child) => {
if (!React.isValidElement(child)) {
return;
}

const item = <AvatarStackItem key={visibleItems.length}>{child}</AvatarStackItem>;

if (visibleItems.length <= max) {
visibleItems.unshift(item);
} else {
moreItems += 1;
}
});

const hasMoreButton = moreItems > 0;
/** Avatars + more button, or just avatars, when avatars count is equal to `max` or less */
const normalOverflow = moreItems >= 1;

return (
// Safari remove role=list with some styles, applied to li items, so we need
// to restore role manually
// eslint-disable-next-line jsx-a11y/no-redundant-roles
<ul className={b({'overlap-size': overlapSize}, className)} role={'list'}>
{hasMoreButton ? (
<AvatarStackItem key="more-button">
{renderMore ? (
renderMore({count: moreItems})
) : (
<Avatar
text={`+${moreItems}`}
aria-label={i18n('more', {count: moreItems})}
size={size}
/>
)}
</AvatarStackItem>
) : null}
{normalOverflow ? visibleItems.slice(0, max) : visibleItems}
</ul>
);
};

AvatarStackComponent.displayName = 'AvatarStack';

export const AvatarStack = Object.assign(AvatarStackComponent, {MoreButton: AvatarStackMoreButton});
13 changes: 13 additions & 0 deletions src/components/AvatarStack/AvatarStackItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';

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

const b = block('avatar-stack');

type Props = React.PropsWithChildren<{}>;

export const AvatarStackItem = ({children}: Props) => {
return <li className={b('item')}>{children}</li>;
};

AvatarStackItem.displayName = 'AvatarStack.Item';
39 changes: 39 additions & 0 deletions src/components/AvatarStack/AvatarStackMoreButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React from 'react';

import type {AvatarSize} from '../Avatar';
import {Avatar, DEFAULT_AVATAR_SIZE} from '../Avatar';
import {block} from '../utils/cn';

import i18n from './i18n';

const b = block('avatar-stack');

export type AvatarStackMoreButtonProps = Pick<
React.HTMLProps<HTMLButtonElement>,
'className' | 'onClick' | 'aria-label'
> & {
size?: AvatarSize;
count: number;
};

export const AvatarStackMoreButton = React.forwardRef<
HTMLButtonElement,
AvatarStackMoreButtonProps
>(({className, size = DEFAULT_AVATAR_SIZE, onClick, count, 'aria-label': ariaLabel}, ref) => {
return (
<button
ref={ref}
type="button"
className={b('more-button', {size}, className)}
onClick={onClick}
>
<Avatar
text={`+${count}`}
size={size}
aria-label={ariaLabel || i18n('more', {count})}
/>
</button>
);
});

AvatarStackMoreButton.displayName = 'AvatarStack.MoreButton';
52 changes: 52 additions & 0 deletions src/components/AvatarStack/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<!--GITHUB_BLOCK-->

# AvatarStack

<!--/GITHUB_BLOCK-->

```ts
import {AvatarStack} from '@gravity-ui/uikit';
```

Stack of images with overlap over next image and optional control. This is usually users avatars.

## Usage

Component is not limit you to what components to render, basic usage is:

```tsx
<AvatarStack>
<Avatar imgUrl={`https://i.pravatar.cc/150?u=login1`} />
<Avatar imgUrl={`https://i.pravatar.cc/150?u=login2`} />
<Avatar imgUrl={`https://i.pravatar.cc/150?u=login3`} />
</AvatarStack>
```

## Properties

| Name | Description | Type | Default |
| :---------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------: | :-----: |
| max | How much avatars should be visible before more button. If avatars count is only 1 short from `max`, than more button would be replaced with avatar. | `number` | 3 |
| overlapSize | How much each item should overlap next one. `s` recommended for `Avatar`'s of sizes `xs`-`m`, `m` recomended for `l` size avatars and `l` overlap for `xl` avatars | `s`, `m`, `l` | `s` |
| size | Size for control displaying extra avatars. Value same to `Avatar` size. | `AvatarSize` | |
| className | Class name of root DOM node | `string` | |
| children | List of avatars, probably with some extra wrappers | `Object[]` | |
| renderMore | Custom render for control displaying extra avatars | `function(options: {count: number}): ReactElement` | |

### AvatarStack.MoreButton

Component for overriding more button

```tsx
<AvatarStack
renderMore={({count}) => (
<Tooltip content={'More users'}>
<AvatarStack.MoreButton count={count} />
</Tooltip>
)}
>
<Avatar imgUrl={`https://i.pravatar.cc/150?u=login1`} />
<Avatar imgUrl={`https://i.pravatar.cc/150?u=login2`} />
<Avatar imgUrl={`https://i.pravatar.cc/150?u=login3`} />
</AvatarStack>
```
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
139 changes: 139 additions & 0 deletions src/components/AvatarStack/__stories__/AvatarStack.stories.tsx

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions src/components/AvatarStack/__tests__/AvatarStack.visual.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react';

import {test} from '~playwright/core';

import {AvatarStackStories} from './stories';

test.describe('AvatarStack', () => {
test('render story <SingleItem>', async ({mount, expectScreenshot}) => {
await mount(<AvatarStackStories.SingleItem randomAvatar={false} />);

await expectScreenshot();
});

test('render story <MoreButton>', async ({mount, expectScreenshot}) => {
await mount(<AvatarStackStories.MoreButton randomAvatar={false} />);

await expectScreenshot();
});

test('render story <MoreButtonOmit>', async ({mount, expectScreenshot}) => {
await mount(<AvatarStackStories.MoreButtonOmit randomAvatar={false} />);

await expectScreenshot();
});
});
5 changes: 5 additions & 0 deletions src/components/AvatarStack/__tests__/stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {composeStories} from '@storybook/react';

import * as CSFStories from '../__stories__/AvatarStack.stories';

export const AvatarStackStories = composeStories(CSFStories);
3 changes: 3 additions & 0 deletions src/components/AvatarStack/i18n/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"more": ["and {{count}} more", "and {{count}} more", "and {{count}} more"]
}
8 changes: 8 additions & 0 deletions src/components/AvatarStack/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {addComponentKeysets} from '../../utils/addComponentKeysets';

import en from './en.json';
import ru from './ru.json';

const COMPONENT = 'AvatarStack';

export default addComponentKeysets({en, ru}, COMPONENT);
3 changes: 3 additions & 0 deletions src/components/AvatarStack/i18n/ru.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"more": ["И eщё {{count}}", "И eщё {{count}}", "И eщё {{count}}"]
}
2 changes: 2 additions & 0 deletions src/components/AvatarStack/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export {AvatarStack} from './AvatarStack';
export type {AvatarStackProps, AvatarStackOverlapSize} from './types';
41 changes: 41 additions & 0 deletions src/components/AvatarStack/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type React from 'react';

import type {AvatarSize} from '../Avatar';

export type AvatarStackOverlapSize = 's' | 'm' | 'l';

export interface AvatarStackProps {
/** Amount of avatars to be shown before more button. Default 3. */
max?: number;
/**
* How much each avatar should overlap next one
* | Avatar sizes | Recommended overlap |
* | :----------: | :-----------------: |
* | `xs`-`m` | `s` |
* | `l` | `m` |
* | `xl` | `l` |
*/
overlapSize?: AvatarStackOverlapSize;
/**
* Size for control displaying count of extra avatars
*/
size?: AvatarSize;
className?: string;
/**
* Children would be wrapped for "stacking"
* @example
* <AvatarStack>
* <Avatar/>
* <Tooltip content="Some info"><Avatar/></Tooltip>
* </AvatarStack>
*/
children?: React.ReactNode;
/**
* Custom render for control displaying extra data
* @example
* <AvatarStack renderMore={({count}) => <Button>+{count}</Button>}>
* <Avatar/>
* </AvatarStack>
*/
renderMore?: (options: {count: number}) => React.ReactElement;
}
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export * from './Divider';
export * from './DropdownMenu';
export * from './Hotkey';
export * from './Icon';
export * from './AvatarStack';
export * from './Label';
export * from './Link';
export * from './List';
Expand Down

0 comments on commit 862f4fb

Please sign in to comment.