diff --git a/src/components/ImageStack/ImageStack.scss b/src/components/ImageStack/ImageStack.scss new file mode 100644 index 0000000000..1aaca9d3e5 --- /dev/null +++ b/src/components/ImageStack/ImageStack.scss @@ -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; + } +} diff --git a/src/components/ImageStack/ImageStack.tsx b/src/components/ImageStack/ImageStack.tsx new file mode 100644 index 0000000000..3e80a081c4 --- /dev/null +++ b/src/components/ImageStack/ImageStack.tsx @@ -0,0 +1,72 @@ +import React from 'react'; + +import {blockNew} from '../utils/cn'; + +import {ImageStackMoreButton} from './ImageStackMoreButton'; + +import './ImageStack.scss'; + +export interface Props { + 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(items: T[], displayCount: number) { + return displayCount + 1 < items.length ? displayCount : items.length; +} + +function getVisibleItems(items: T[], displayCount: number) { + return items.slice(0, getSplitIndex(items, displayCount)).reverse(); +} + +function getRestItems(items: T[], displayCount: number) { + return items.slice(getSplitIndex(items, displayCount)); +} + +const ImageStackComponent = ({ + displayCount = 2, + className, + items, + renderItem, + renderMore, +}: Props) => { + 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 ( +
    + {restItems.length > 0 ? ( +
  • + {renderMore(restItems)} +
  • + ) : null} + + {visibleItems.map((item) => ( +
  • + {renderItem(item, {itemClassName: b('item-children')})} +
  • + ))} +
+ ); +}; + +ImageStackComponent.displayName = 'ImageStack'; + +export const ImageStack = Object.assign(ImageStackComponent, {MoreButton: ImageStackMoreButton}); diff --git a/src/components/ImageStack/ImageStackMoreButton.tsx b/src/components/ImageStack/ImageStackMoreButton.tsx new file mode 100644 index 0000000000..4b56cc1eb7 --- /dev/null +++ b/src/components/ImageStack/ImageStackMoreButton.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import {Button, type ButtonProps} from '../Button'; + +type Props = Pick & { + count: number; + 'aria-label': string; +}; + +export const ImageStackMoreButton = ({ + size = 's', + onClick, + count, + 'aria-label': ariaLabel, +}: Props) => { + return ( + + ); +}; + +ImageStackMoreButton.displayName = 'ImageStack.MoreButton'; diff --git a/src/components/ImageStack/__stories__/ImageStack.stories.tsx b/src/components/ImageStack/__stories__/ImageStack.stories.tsx new file mode 100644 index 0000000000..30d5981358 --- /dev/null +++ b/src/components/ImageStack/__stories__/ImageStack.stories.tsx @@ -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}) => ( + + ), + renderMore: (items: DemoItem[]) => ( + + {items.map((item) => ( + + {item.name} + + ))} + + } + > + + + ), + }, +} as Meta; + +const Template: StoryFn = (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, +}; diff --git a/src/components/ImageStack/index.ts b/src/components/ImageStack/index.ts new file mode 100644 index 0000000000..800b7a738e --- /dev/null +++ b/src/components/ImageStack/index.ts @@ -0,0 +1 @@ +export {ImageStack, type Props as ImageStackProps} from './ImageStack'; diff --git a/src/components/index.ts b/src/components/index.ts index 3b820c3d7a..ac09f642f8 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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';