Skip to content

Commit

Permalink
feat(SelectableCards): new component thumbnail variant
Browse files Browse the repository at this point in the history
  • Loading branch information
marcinsawicki committed Nov 28, 2024
1 parent 75e7e14 commit 7877a0d
Show file tree
Hide file tree
Showing 16 changed files with 421 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
$base-class: 'selectable-card';

.#{$base-class} {
display: flex;
transition: border-color var(--transition-duration-fast-2) ease-in-out;
border: 1px solid var(--border-basic-secondary);
border-radius: var(--radius-3);
background: var(--surface-primary-default);
width: max-content;

&:hover {
border-color: var(--border-basic-hover);
cursor: pointer;
}

&:focus-visible {
outline: 0;
box-shadow: var(--shadow-focus);
}

&--thumbnail {
padding: var(--spacing-3) var(--spacing-5);
}

&--gallery {
padding: var(--spacing-5);
}

&--interactive {
border-radius: var(--radius-4);
padding: var(--spacing-9) var(--spacing-8);
}

&--selected,
&--selected:hover {
border-color: var(--action-primary-default);
}

&__select-indicator {
display: flex;
align-items: center;
margin-right: var(--spacing-2);
height: 21px;

&__checkbox {
height: 16px;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import * as React from 'react';

import { Building } from '@livechat/design-system-icons';

import { Icon } from '../Icon';

import { ThumbnailSelectableCard } from './components';

interface IRadioCardsProps {
withDescription?: boolean;
withIcon?: boolean;
withCustomElement?: boolean;
}

export const RadioCards: React.FC<IRadioCardsProps> = ({
withDescription,
withIcon,
withCustomElement,
}) => {
const [selectedIndex, setSelectedIndex] = React.useState<number>(0);

return [...Array(4)].map((_, index) => (
<ThumbnailSelectableCard
key={index}
label={`Card ${index + 1}`}
selectionType="radio"
isSelected={selectedIndex === index}
onClick={() => setSelectedIndex(index)}
{...(withDescription && {
description: `Description for card ${index + 1}`,
})}
{...(withIcon && { icon: <Icon source={Building} /> })}
{...(withCustomElement && {
customElement: (
<div className="custom-element">
<Icon size="small" source={Building} />
<div>{`Custom element ${index + 1}`}</div>
</div>
),
})}
/>
));
};

interface ICheckboxCardsProps {
withDescription?: boolean;
withIcon?: boolean;
withCustomElement?: boolean;
}

export const CheckboxCards: React.FC<ICheckboxCardsProps> = ({
withDescription,
withIcon,
withCustomElement,
}) => {
const [selectedIndex, setSelectedIndex] = React.useState<number[]>([]);

const handleCardClick = (index: number) => {
if (selectedIndex.includes(index)) {
setSelectedIndex(selectedIndex.filter((selected) => selected !== index));
} else {
setSelectedIndex([...selectedIndex, index]);
}
};

return [...Array(4)].map((_, index) => (
<ThumbnailSelectableCard
key={index}
label={`Card ${index + 1}`}
selectionType="checkbox"
isSelected={selectedIndex.includes(index)}
onClick={() => handleCardClick(index)}
{...(withDescription && {
description: `Description for card ${index + 1}`,
})}
{...(withIcon && { icon: <Icon source={Building} /> })}
{...(withCustomElement && {
customElement: (
<div className="custom-element">
<Icon size="small" source={Building} />
<div>{`Custom element ${index + 1}`}</div>
</div>
),
})}
/>
));
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.custom-element {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 1px dashed var(--border-default);
border-radius: var(--radius-1);
background-color: var(--surface-basic-subtle);
padding: var(--spacing-2);
color: var(--content-subtle);
font-size: 12px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Meta } from '@storybook/react';

import { StoryDescriptor } from '../../stories/components/StoryDescriptor';

import { SelectableCard } from './SelectableCard';
import { CheckboxCards, RadioCards } from './SelectableCard.stories.components';

import './SelectableCard.stories.css';

export default {
title: 'Components/SelectableCard',
component: SelectableCard,
} as Meta<typeof SelectableCard>;

export const ThumbnailSelectableCardAsRadioType = () => (
<>
<StoryDescriptor title="With Label">
<RadioCards />
</StoryDescriptor>
<StoryDescriptor title="With Label, Description">
<RadioCards withDescription />
</StoryDescriptor>
<StoryDescriptor title="With Label, Description and Icon">
<RadioCards withDescription withIcon />
</StoryDescriptor>
<StoryDescriptor title="With Custom element">
<RadioCards withDescription withIcon withCustomElement />
</StoryDescriptor>
</>
);

export const ThumbnailSelectableCardAsCheckboxType = () => (
<>
<StoryDescriptor title="With Label">
<CheckboxCards />
</StoryDescriptor>
<StoryDescriptor title="With Label, Description">
<CheckboxCards withDescription />
</StoryDescriptor>
<StoryDescriptor title="With Label, Description and Icon">
<CheckboxCards withDescription withIcon />
</StoryDescriptor>
<StoryDescriptor title="With Custom element">
<CheckboxCards withDescription withIcon withCustomElement />
</StoryDescriptor>
</>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { FC, KeyboardEvent } from 'react';

import cx from 'clsx';

import { useInteractive } from '../../hooks';
import { Checkbox } from '../Checkbox';
import { RadioButton } from '../RadioButton';

import { ISelectableCardProps } from './types';

import styles from './SelectableCard.module.scss';

const baseClass = 'selectable-card';

export const SelectableCard: FC<ISelectableCardProps> = ({
children,
className,
kind,
selectionType,
isSelected = false,
onClick,
}) => {
const mergedClassName = cx(
styles[baseClass],
styles[`${baseClass}--${kind}`],
{
[styles[`${baseClass}--selected`]]: isSelected,
},
className
);
const { handleInteractiveClick } = useInteractive({ onClick });

const handleOnKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.currentTarget !== document.activeElement) {
return;
}

if (
e.key === 'Enter' ||
e.key === ' ' ||
e.key === 'Spacebar' ||
e.key === 'Space'
) {
e.preventDefault();

onClick?.();
}
};

return (
<div
role="button"
tabIndex={0}
className={mergedClassName}
onClick={handleInteractiveClick}
onKeyDown={handleOnKeyDown}
>
<div className={styles[`${baseClass}__select-indicator`]}>
{selectionType === 'radio' ? (
<RadioButton tabIndex={-1} onClick={onClick} checked={isSelected} />
) : (
<Checkbox
tabIndex={-1}
onClick={onClick}
className={styles[`${baseClass}__select-indicator__checkbox`]}
checked={isSelected}
/>
)}
</div>
{children}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
$base-class: 'thumbnail-selectable-card';

.#{$base-class} {
display: flex;
gap: var(--spacing-2);

&__icon {
display: flex;
height: 21px;
}

&__content {
display: flex;
flex-direction: column;

&__description {
color: var(--content-basic-secondary);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { FC } from 'react';

import { Text } from '../../../Typography';
import { SelectableCard } from '../../SelectableCard';

import { IThumbnailSelectableCardProps } from './types';

import styles from './ThumbnailSelectableCard.module.scss';

const baseClass = `thumbnail-selectable-card`;

export const ThumbnailSelectableCard: FC<IThumbnailSelectableCardProps> = ({
label,
description,
icon,
customElement,
...props
}) => (
<SelectableCard {...props} kind="thumbnail">
<div className={styles[baseClass]}>
{icon && <div className={styles[`${baseClass}__icon`]}>{icon}</div>}
{!customElement && (
<div className={styles[`${baseClass}__content`]}>
<Text as="span" className={styles[`${baseClass}__content__label`]}>
{label}
</Text>
<Text
size="sm"
as="span"
className={styles[`${baseClass}__content__description`]}
>
{description}
</Text>
</div>
)}
{customElement}
</div>
</SelectableCard>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as React from 'react';

import { ISelectableCardCoreProps } from '../../types';

export interface IThumbnailSelectableCardProps
extends ISelectableCardCoreProps {
/**
* The label of the card
*/
label: string;
/**
* The description of the card
*/
description?: string;
/**
* The icon of the card
*/
icon?: React.ReactNode;
/**
* The custom element of the card
*/
customElement?: React.ReactNode;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ThumbnailSelectableCard } from './ThumbnailSelectableCard/ThumbnailSelectableCard';
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { SelectableCard } from './SelectableCard';
export { ThumbnailSelectableCard } from './components/';
25 changes: 25 additions & 0 deletions packages/react-components/src/components/SelectableCard/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as React from 'react';

import { ComponentCoreProps } from '../../utils/types';

export interface ISelectableCardCoreProps extends ComponentCoreProps {
/**
* Set the selection type of the card
*/
selectionType: 'radio' | 'checkbox';
/**
* Set the card selection state
*/
isSelected?: boolean;
/**
* Set the card onClick handler
*/
onClick: (e?: React.MouseEvent<HTMLElement, MouseEvent>) => void;
}

export interface ISelectableCardProps extends ISelectableCardCoreProps {
/**
* Set the card type
*/
kind?: 'thumbnail' | 'gallery' | 'interactive';
}
1 change: 1 addition & 0 deletions packages/react-components/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { useAnimations } from './useAnimations';
export { useHeightResizer } from './useHeightResizer';
export { useMobileViewDetector } from './useMobileViewDetector';
export { useInteractive } from './useInteractive';
Loading

0 comments on commit 7877a0d

Please sign in to comment.