Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(APP-3630): Update radio card and checkbox card to accept children property and render when selected/checked #310

Merged
merged 9 commits into from
Oct 14, 2024
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Added

- Add optional `children` property to `RadioCard` and `CheckboxCard` core components displayed only when component is
selected

### Changed

- Update core components `RadioCard` and `CheckboxCard` to have optional description property and correct alignment
when no description provided
- Bump `actions/checkout` from 4.2.0 to 4.2.1
- Update minor and patch NPM dependencies

Expand Down
25 changes: 24 additions & 1 deletion src/core/components/forms/checkboxCard/checkboxCard.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,35 @@ type Story = StoryObj<typeof CheckboxCard>;
* Default usage of the CheckboxCard component
*/
export const Default: Story = {
render: (props) => <CheckboxCard {...props} />,
args: {
avatar: 'https://assets-global.website-files.com/5e997428d0f2eb13a90aec8c/63f47db62df04b569e4e004e_icon_aragon.svg',
label: 'Checkbox label',
tag: { label: 'Tag', variant: 'info' },
},
};

/**
* Default usage of the CheckboxCard component
*/
export const WithDescription: Story = {
args: {
avatar: 'https://assets-global.website-files.com/5e997428d0f2eb13a90aec8c/63f47db62df04b569e4e004e_icon_aragon.svg',
label: 'Checkbox label',
description: 'Checkbox description',
tag: { label: 'Tag', variant: 'info' },
},
};

/**
* Usage of the CheckboxCard component with children when checked
*/
export const WithChildrenWhenChecked: Story = {
args: {
avatar: 'https://assets-global.website-files.com/5e997428d0f2eb13a90aec8c/63f47db62df04b569e4e004e_icon_aragon.svg',
label: 'Checkbox label',
description: 'Checkbox description',
tag: { label: 'Tag', variant: 'info' },
children: <div>Children</div>,
},
};

Expand Down
6 changes: 6 additions & 0 deletions src/core/components/forms/checkboxCard/checkboxCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,10 @@ describe('<CheckboxCard /> component', () => {
render(createTestComponent({ checked }));
expect(screen.getByTestId(IconType.CHECKBOX_INDETERMINATE)).toBeVisible();
});

it('renders the children when checkbox is checked', () => {
shan8851 marked this conversation as resolved.
Show resolved Hide resolved
const checked = true;
render(createTestComponent({ checked, children: <div data-testid="children" /> }));
expect(screen.getByTestId('children')).toBeVisible();
});
});
92 changes: 61 additions & 31 deletions src/core/components/forms/checkboxCard/checkboxCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as RadixCheckbox from '@radix-ui/react-checkbox';
import classNames from 'classnames';
import { forwardRef, type ComponentProps } from 'react';
import { forwardRef, type ComponentProps, type ReactNode } from 'react';
import { useRandomId } from '../../../hooks';
import { Avatar } from '../../avatars';
import { Icon, IconType } from '../../icon';
Expand All @@ -19,7 +19,7 @@ export interface ICheckboxCardProps extends ComponentProps<'button'> {
/**
* Description of the checkbox.
*/
description: string;
description?: string;
/**
* Optional tag for the checkbox.
*/
Expand All @@ -40,10 +40,26 @@ export interface ICheckboxCardProps extends ComponentProps<'button'> {
* Id of the checkbox.
*/
id?: string;
/**
* Additional children to render when the checkbox is checked.
*/
children?: ReactNode;
shan8851 marked this conversation as resolved.
Show resolved Hide resolved
}

export const CheckboxCard = forwardRef<HTMLButtonElement, ICheckboxCardProps>((props, ref) => {
const { id, avatar, label, description, tag, className, checked, onCheckedChange, disabled, ...otherProps } = props;
const {
id,
avatar,
label,
description,
tag,
className,
checked,
onCheckedChange,
disabled,
children,
...otherProps
} = props;

const randomId = useRandomId(id);
const labelId = `${randomId}-label`;
Expand All @@ -57,7 +73,7 @@ export const CheckboxCard = forwardRef<HTMLButtonElement, ICheckboxCardProps>((p
onCheckedChange={onCheckedChange}
disabled={disabled}
className={classNames(
'group flex h-16 min-w-0 flex-row items-center gap-3 outline-none transition-all md:h-20', // Layout
'group flex min-w-0 flex-col gap-3 outline-none transition-all', // Layout
'rounded-xl border bg-neutral-0 px-4 py-3 md:gap-4 md:px-6 md:py-4', // Style
'focus:outline-none focus-visible:ring focus-visible:ring-primary focus-visible:ring-offset', // Focus
'border-primary-400 shadow-primary hover:shadow-primary-md', // Checked/indeterminate & hover
Expand All @@ -69,37 +85,51 @@ export const CheckboxCard = forwardRef<HTMLButtonElement, ICheckboxCardProps>((p
)}
{...otherProps}
>
{avatar && <Avatar size="sm" responsiveSize={{ md: 'md' }} src={avatar} />}
<div className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-sm font-normal leading-tight md:gap-1 md:text-base">
<p
id={randomId}
className={classNames(
'max-w-full cursor-pointer truncate text-neutral-800 group-data-[state=unchecked]:text-neutral-800',
'group-data-[disabled]:cursor-default group-data-[disabled]:group-data-[state=unchecked]:text-neutral-300',
<div className={classNames('flex w-full min-w-0 flex-row gap-3', { 'items-center': !description })}>
{avatar && <Avatar size="sm" responsiveSize={{ md: 'md' }} src={avatar} />}
<div className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-sm font-normal leading-tight md:gap-1 md:text-base">
<p
id={randomId}
className={classNames(
'max-w-full cursor-pointer truncate text-neutral-800 group-data-[state=unchecked]:text-neutral-800',
'group-data-[disabled]:cursor-default group-data-[disabled]:group-data-[state=unchecked]:text-neutral-300',
)}
>
{label}
</p>
{description && (
<p className="max-w-full truncate text-neutral-500 group-data-[disabled]:text-neutral-300">
{description}
</p>
)}
>
{label}
</p>
<p className="max-w-full truncate text-neutral-500 group-data-[disabled]:text-neutral-300">
{description}
</p>
</div>
{tag && <Tag {...tag} className={classNames('self-start', tag.className)} />}
<Icon
icon={IconType.CHECKBOX}
size="md"
className={classNames(
'mt-0.5 hidden self-start text-neutral-400 group-data-[state=unchecked]:block group-data-[disabled]:text-neutral-300 md:mt-1',
)}
/>
<RadixCheckbox.Indicator className="mt-0.5 self-start text-primary-400 group-data-[disabled]:text-neutral-500 md:mt-1">
<Icon icon={IconType.CHECKBOX_SELECTED} size="md" className="hidden group-data-[state=checked]:block" />
</div>
{tag && <Tag {...tag} className={classNames(tag.className, { 'self-start': description })} />}
<Icon
icon={IconType.CHECKBOX_INDETERMINATE}
icon={IconType.CHECKBOX}
size="md"
className="hidden group-data-[state=indeterminate]:block"
className={classNames(
'mt-0.5 hidden text-neutral-400 group-data-[state=unchecked]:block group-data-[disabled]:text-neutral-300 md:mt-1',
{ 'self-start': description },
)}
/>
</RadixCheckbox.Indicator>
<RadixCheckbox.Indicator
className={classNames('mt-0.5 text-primary-400 group-data-[disabled]:text-neutral-500 md:mt-1', {
'self-start': description,
})}
>
<Icon
icon={IconType.CHECKBOX_SELECTED}
size="md"
className="hidden group-data-[state=checked]:block"
/>
<Icon
icon={IconType.CHECKBOX_INDETERMINATE}
size="md"
className="hidden group-data-[state=indeterminate]:block"
/>
</RadixCheckbox.Indicator>
</div>
{children && <div className="hidden group-data-[state=checked]:block">{children}</div>}
</RadixCheckbox.Root>
);
});
Expand Down
36 changes: 36 additions & 0 deletions src/core/components/forms/radioCard/radioCard.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,41 @@ type Story = StoryObj<typeof RadioCard>;
* Default usage of the `RadioCard` component
*/
export const Default: Story = {
render: (props) => (
<RadioGroup>
<RadioCard {...props} />
</RadioGroup>
),
args: {
avatar: 'https://assets-global.website-files.com/5e997428d0f2eb13a90aec8c/63f47db62df04b569e4e004e_icon_aragon.svg',
value: '1',
label: 'Option one',
tag: { label: 'Platinum' },
},
};

/**
* Default usage of the `RadioCard` component with description
*/
export const WithDescription: Story = {
render: (props) => (
<RadioGroup>
<RadioCard {...props} />
</RadioGroup>
),
args: {
avatar: 'https://assets-global.website-files.com/5e997428d0f2eb13a90aec8c/63f47db62df04b569e4e004e_icon_aragon.svg',
value: '1',
label: 'Option one',
description: 'The best option ever',
tag: { label: 'Platinum' },
},
};

/**
* Usage of the `RadioCard` component with children when selected
*/
export const WithChildrenWhenSelected: Story = {
render: (props) => (
<RadioGroup>
<RadioCard {...props} />
Expand All @@ -33,6 +68,7 @@ export const Default: Story = {
label: 'Option one',
description: 'The best option ever',
tag: { label: 'Platinum' },
children: <div>Children</div>,
},
};

Expand Down
13 changes: 13 additions & 0 deletions src/core/components/forms/radioCard/radioCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,17 @@ describe('<RadioCard/> component', () => {

expect(screen.getByRole('radio')).toHaveValue(value);
});

it('renders children when radio button is checked', async () => {
const user = userEvent.setup();

const children = 'test-children';
render(createTestComponent({ children }));

const radioButton = screen.getByRole('radio');

await user.click(radioButton);
expect(screen.getByText(children)).toBeVisible();
expect(screen.getByRole('radio')).toBeChecked();
});
});
25 changes: 17 additions & 8 deletions src/core/components/forms/radioCard/radioCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { RadioGroupIndicator, RadioGroupItem } from '@radix-ui/react-radio-group';
import classNames from 'classnames';
import { forwardRef, type ComponentProps } from 'react';
import { forwardRef, type ComponentProps, type ReactNode } from 'react';
import { useRandomId } from '../../../hooks';
import { Avatar } from '../../avatars';
import { Icon, IconType } from '../../icon';
Expand All @@ -14,7 +14,7 @@ export interface IRadioCardProps extends ComponentProps<'button'> {
/**
* Description
*/
description: string;
description?: string;
/**
* Radio label
*/
Expand All @@ -31,16 +31,20 @@ export interface IRadioCardProps extends ComponentProps<'button'> {
* Indicates if the radio is disabled.
*/
disabled?: boolean;
/**
* Additional children to render when the radio is selected.
*/
children?: ReactNode;
}

export const RadioCard = forwardRef<HTMLButtonElement, IRadioCardProps>((props, ref) => {
const { value, id, className, tag, avatar, label, description, disabled, ...rest } = props;
const { value, id, className, tag, avatar, label, description, disabled, children, ...rest } = props;

const randomId = useRandomId(id);
const labelId = `${randomId}-label`;

const containerClasses = classNames(
'group h-16 rounded-xl border border-neutral-100 bg-neutral-0 px-4 py-3 shadow-neutral-sm outline-none transition-all md:h-20 md:rounded-2xl md:px-6 md:py-4', // default
'group flex w-full flex-col gap-3 rounded-xl border border-neutral-100 bg-neutral-0 px-4 py-3 shadow-neutral-sm outline-none transition-all md:rounded-2xl md:px-6 md:py-4', // default
'data-[state=checked]:border-primary-400 data-[state=checked]:shadow-primary', // checked
'focus:outline-none focus-visible:ring focus-visible:ring-primary focus-visible:ring-offset', // focus
'hover:border-neutral-200 hover:shadow-neutral hover:data-[state=checked]:shadow-primary-md', // hover
Expand All @@ -67,18 +71,22 @@ export const RadioCard = forwardRef<HTMLButtonElement, IRadioCardProps>((props,
aria-labelledby={labelId}
{...rest}
>
<div className="flex h-full items-center gap-x-3 md:gap-x-4">
<div className="flex size-full items-center gap-x-3 md:gap-x-4">
{avatar && <Avatar size="sm" responsiveSize={{ md: 'md' }} src={avatar} />}
<div className="flex min-w-0 flex-1 gap-x-0.5 md:gap-x-4">
<div
className={classNames('flex min-w-0 flex-1 gap-x-0.5 md:gap-x-4', {
'items-center': !description,
})}
>
<div className="flex min-w-0 flex-1 flex-col gap-y-0.5 md:gap-y-1">
<p className={labelClasses} id={labelId}>
{label}
</p>
<p className={baseTextClasses}>{description}</p>
{description && <p className={baseTextClasses}>{description}</p>}
</div>
{tag && <Tag {...tag} />}
</div>
<span className="h-full">
<span className={classNames({ 'h-full': description })}>
<Icon icon={IconType.RADIO} className="text-neutral-300 group-data-[state=checked]:hidden" />
<RadioGroupIndicator>
<Icon
Expand All @@ -88,6 +96,7 @@ export const RadioCard = forwardRef<HTMLButtonElement, IRadioCardProps>((props,
</RadioGroupIndicator>
</span>
</div>
{children && <div className="hidden group-data-[state=checked]:block">{children}</div>}
</RadioGroupItem>
);
});
Expand Down