Skip to content

Commit

Permalink
feat(ImageStack): add component
Browse files Browse the repository at this point in the history
  • Loading branch information
ogonkov committed Aug 21, 2023
1 parent cd07a1c commit 9509238
Show file tree
Hide file tree
Showing 6 changed files with 206 additions and 0 deletions.
29 changes: 29 additions & 0 deletions src/components/ImageStack/ImageStack.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
@use '../variables';

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

#{$block} {
$border-width: 1px;

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

margin: 0;
padding: 0;

&__item {
list-style: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E");
z-index: 0;
border: solid $border-width var(--g-color-line-generic-solid);
border-radius: 100%;

&:not(:first-child) {
margin-inline-end: calc(-1 * (var(--g-spacing-1) + #{$border-width}));
}
}

&__item-children {
vertical-align: top;
}
}
72 changes: 72 additions & 0 deletions src/components/ImageStack/ImageStack.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React from 'react';

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

import {ImageStackMoreButton} from './ImageStackMoreButton';

import './ImageStack.scss';

export interface Props<T extends {pk: string}> {
items: T[];
renderItem(item: T, options: {itemClassName: string}): React.ReactNode;
renderMore(items: T[]): React.ReactNode;
/** Amount of items that should be visible */
displayCount?: number;
className?: string;
}

const b = blockNew('image-stack');

function getSplitIndex<T>(items: T[], displayCount: number) {
return displayCount + 1 < items.length ? displayCount : items.length;
}

function getVisibleItems<T>(items: T[], displayCount: number) {
return items.slice(0, getSplitIndex(items, displayCount)).reverse();
}

function getRestItems<T>(items: T[], displayCount: number) {
return items.slice(getSplitIndex(items, displayCount));
}

const ImageStackComponent = <T extends {pk: string}>({
displayCount = 2,
className,
items,
renderItem,
renderMore,
}: Props<T>) => {
const [visibleItems, setVisibleItems] = React.useState(() =>
getVisibleItems(items, displayCount),
);
const [restItems, setRestItems] = React.useState(() => getRestItems(items, displayCount));

React.useEffect(() => {
setVisibleItems(getVisibleItems(items, displayCount));
setRestItems(getRestItems(items, displayCount));
}, [displayCount, items]);

if (!items.length) {
return null;
}

return (
<ul className={b(null, className)}>
{restItems.length > 0 ? (
<li key={'show-more'} className={b('item')}>
{renderMore(restItems)}
</li>
) : null}

{visibleItems.map((item) => (
<li key={item.pk} className={b('item')}>
{renderItem(item, {itemClassName: b('item-children')})}
</li>
))}
</ul>
);
};

ImageStackComponent.displayName = 'ImageStack';

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

import {Button, type ButtonProps} from '../Button';

type Props = Pick<ButtonProps, 'size' | 'onClick'> & {
count: number;
'aria-label': string;
};

export const ImageStackMoreButton = ({
size = 's',
onClick,
count,
'aria-label': ariaLabel,
}: Props) => {
return (
<Button
size={size}
pin={'circle-circle'}
onClick={onClick}
extraProps={{'aria-label': ariaLabel}}
>
<Button.Icon>+{count}</Button.Icon>
</Button>
);
};

ImageStackMoreButton.displayName = 'ImageStack.MoreButton';
75 changes: 75 additions & 0 deletions src/components/ImageStack/__stories__/ImageStack.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React from 'react';

import {faker} from '@faker-js/faker/locale/en';
import type {Meta, StoryFn} from '@storybook/react';

import {Menu} from '../../Menu';
import {Popover} from '../../Popover';
import {UserAvatar} from '../../UserAvatar';
import {ImageStack} from '../ImageStack';

type ComponentType = typeof ImageStack;

type DemoItem = {
pk: string;
image: string;
name: string;
};

function getItems(count = faker.number.int({min: 1, max: 30})) {
return faker.helpers.uniqueArray(
() => ({
pk: '',
image: faker.image.avatar(),
name: faker.internet.userName().toLowerCase(),
}),
count,
);
}

const items = getItems();

export default {
title: 'Components/ImageStack',
component: ImageStack,
args: {
items,
renderItem: (item: DemoItem, {itemClassName}) => (
<UserAvatar size={'xs'} className={itemClassName} imgUrl={item.image} />
),
renderMore: (items: DemoItem[]) => (
<Popover
placement={['top', 'top-end', 'top-start', 'bottom', 'bottom-end', 'bottom-start']}
content={
<Menu>
{items.map((item) => (
<Menu.Item
key={item.pk}
href={new URL(item.name, 'https://example.com').toString()}
>
{item.name}
</Menu.Item>
))}
</Menu>
}
>
<ImageStack.MoreButton aria-label={'Rest of the users'} count={items.length} />
</Popover>
),
},
} as Meta<ComponentType>;

const Template: StoryFn<ComponentType> = (args) => <ImageStack {...args} />;

export const Default = Template.bind({});

export const WithOneItem = Template.bind({});
WithOneItem.args = {
items: getItems(1),
};

export const WithEdgeItemsCount = Template.bind({});
WithEdgeItemsCount.args = {
items: getItems(3),
displayCount: 2,
};
1 change: 1 addition & 0 deletions src/components/ImageStack/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {ImageStack, type Props as ImageStackProps} from './ImageStack';
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export * from './Disclosure';
export * from './DropdownMenu';
export * from './Hotkey';
export * from './Icon';
export * from './ImageStack';
export * from './Label';
export * from './Link';
export * from './List';
Expand Down

0 comments on commit 9509238

Please sign in to comment.