From 233516e6b979d6a3017a3905d98bf8e38624b67c Mon Sep 17 00:00:00 2001 From: Alexandre Philibeaux Date: Thu, 28 May 2026 23:04:02 +0200 Subject: [PATCH] feat(selectable-card-group): add Label component --- .../SelectableCardGroupField.tsx | 2 + .../__stories__/WithLabel.stories.tsx | 40 ++ packages/ui/src/components/Badge/index.tsx | 2 +- .../components/SelectableCardGroup/Label.tsx | 51 +++ .../__stories__/WithLabel.stories.tsx | 48 +++ .../__snapshots__/index.test.tsx.snap | 350 ++++++++++++++++++ .../__tests__/index.test.tsx | 112 +++++- .../components/SelectableCardGroup/index.tsx | 3 + 8 files changed, 587 insertions(+), 21 deletions(-) create mode 100644 packages/form/src/components/SelectableCardGroupField/__stories__/WithLabel.stories.tsx create mode 100644 packages/ui/src/components/SelectableCardGroup/Label.tsx create mode 100644 packages/ui/src/components/SelectableCardGroup/__stories__/WithLabel.stories.tsx diff --git a/packages/form/src/components/SelectableCardGroupField/SelectableCardGroupField.tsx b/packages/form/src/components/SelectableCardGroupField/SelectableCardGroupField.tsx index 164f856d79..a987da239d 100644 --- a/packages/form/src/components/SelectableCardGroupField/SelectableCardGroupField.tsx +++ b/packages/form/src/components/SelectableCardGroupField/SelectableCardGroupField.tsx @@ -74,8 +74,10 @@ const SelectableCardGroupFieldComponent = < type SelectableCardGroupFieldType = typeof SelectableCardGroupFieldComponent & { Card: typeof SelectableCardGroup.Card + Label: typeof SelectableCardGroup.Label } export const SelectableCardGroupField: SelectableCardGroupFieldType = Object.assign(SelectableCardGroupFieldComponent, { Card: SelectableCardGroup.Card, + Label: SelectableCardGroup.Label, }) diff --git a/packages/form/src/components/SelectableCardGroupField/__stories__/WithLabel.stories.tsx b/packages/form/src/components/SelectableCardGroupField/__stories__/WithLabel.stories.tsx new file mode 100644 index 0000000000..cda176871d --- /dev/null +++ b/packages/form/src/components/SelectableCardGroupField/__stories__/WithLabel.stories.tsx @@ -0,0 +1,40 @@ +import type { StoryFn } from '@storybook/react-vite' +import type { ComponentProps } from 'react' +import { SelectableCardGroupField } from '../..' + +export const WithLabel: StoryFn> = args => ( + + + } + value="value-1" + /> + + } + value="value-2" + /> + } + value="value-3" + /> + +) + +WithLabel.args = { + legend: 'Sélectionnez une option', + name: 'withLabel', + type: 'radio', +} diff --git a/packages/ui/src/components/Badge/index.tsx b/packages/ui/src/components/Badge/index.tsx index 09db8c7f1b..d85e960016 100644 --- a/packages/ui/src/components/Badge/index.tsx +++ b/packages/ui/src/components/Badge/index.tsx @@ -8,7 +8,7 @@ import { TEXT_VARIANT } from './constant' import { badgeStyle } from './styles.css' import type { BadgeVariants } from './styles.css' -type BadgeProps = { +export type BadgeProps = { className?: string children: ReactNode 'data-testid'?: string diff --git a/packages/ui/src/components/SelectableCardGroup/Label.tsx b/packages/ui/src/components/SelectableCardGroup/Label.tsx new file mode 100644 index 0000000000..8b074ef74f --- /dev/null +++ b/packages/ui/src/components/SelectableCardGroup/Label.tsx @@ -0,0 +1,51 @@ +'use client' + +import type { ReactNode } from 'react' +import type { BadgeProps } from '../Badge' +import { Badge } from '../Badge' +import { Stack } from '../Stack' +import { Text } from '../Text' + +type SelectableCardGroupLabelProps = { + label: ReactNode + labelDescription?: ReactNode + badgeText?: ReactNode + badgeProminence?: BadgeProps['prominence'] + badgeSentiment?: BadgeProps['sentiment'] + badgeSize?: BadgeProps['size'] + sideText?: ReactNode + disabled?: boolean +} + +export const SelectableCardGroupLabel = ({ + label, + labelDescription, + badgeText, + badgeProminence = 'default', + badgeSentiment = 'neutral', + badgeSize = 'medium', + sideText, + disabled = false, +}: SelectableCardGroupLabelProps) => ( + + + {label} + {labelDescription && typeof labelDescription === 'function' ? labelDescription : null} + {labelDescription && typeof labelDescription !== 'function' ? ( + + {labelDescription} + + ) : null} + {badgeText ? ( + + {badgeText} + + ) : null} + + {sideText ? ( + + {sideText} + + ) : null} + +) diff --git a/packages/ui/src/components/SelectableCardGroup/__stories__/WithLabel.stories.tsx b/packages/ui/src/components/SelectableCardGroup/__stories__/WithLabel.stories.tsx new file mode 100644 index 0000000000..1031058816 --- /dev/null +++ b/packages/ui/src/components/SelectableCardGroup/__stories__/WithLabel.stories.tsx @@ -0,0 +1,48 @@ +import type { StoryFn } from '@storybook/react-vite' +import { useState } from 'react' +import { SelectableCardGroup } from '..' + +export const WithLabel: StoryFn = args => { + const [value, onChange] = useState('value-1') + + return ( + ) => onChange(event.currentTarget.value)} + value={value} + > + + } + value="value-1" + /> + + } + value="value-2" + /> + } + value="value-3" + /> + + ) +} + +WithLabel.args = { + legend: 'Sélectionnez une option', + name: 'with-label', + type: 'radio', +} diff --git a/packages/ui/src/components/SelectableCardGroup/__tests__/__snapshots__/index.test.tsx.snap b/packages/ui/src/components/SelectableCardGroup/__tests__/__snapshots__/index.test.tsx.snap index 98941ec89f..cfee71654e 100644 --- a/packages/ui/src/components/SelectableCardGroup/__tests__/__snapshots__/index.test.tsx.snap +++ b/packages/ui/src/components/SelectableCardGroup/__tests__/__snapshots__/index.test.tsx.snap @@ -1,5 +1,239 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`selectableCardGroup > renders Label with function as labelDescription 1`] = ` + +
+
+
+
+ + Label + +
+
+
+
+ + + + radio + + + + + + + + +
+
+
+
+
+
+
+
+
+`; + +exports[`selectableCardGroup > renders Label with sideText 1`] = ` + +
+
+
+
+ + Label + +
+
+
+
+ + + + radio + + + + + + + + +
+
+
+
+
+
+
+
+
+`; + exports[`selectableCardGroup > renders correctly 1`] = `
renders correctly required and showTick 1`] = ` `; +exports[`selectableCardGroup > renders correctly with Label component and badge 1`] = ` + +
+
+
+
+ + Label + +
+
+
+
+ + + + radio + + + + + + + + +
+
+
+
+
+
+
+
+
+`; + exports[`selectableCardGroup > renders correctly with direction multiple columns 1`] = `
{ - it('renders correctly', () => - shouldMatchSnapshot( + it('renders correctly', () => { + const { asFragment } = renderWithTheme( {}} type="checkbox" value={['value-1']}> , - )) + ) + expect(asFragment()).toMatchSnapshot() + }) - it('renders correctly with direction multiple columns', () => - shouldMatchSnapshot( + it('renders correctly with direction multiple columns', () => { + const { asFragment } = renderWithTheme( { , - )) + ) + expect(asFragment()).toMatchSnapshot() + }) - it('renders correctly with helper content', () => - shouldMatchSnapshot( + it('renders correctly with helper content', () => { + const { asFragment } = renderWithTheme( { , - )) - it('renders correctly required and showTick', () => - shouldMatchSnapshot( + ) + expect(asFragment()).toMatchSnapshot() + }) + + it('renders correctly required and showTick', () => { + const { asFragment } = renderWithTheme( { , - )) - it('renders correctly with error content', () => - shouldMatchSnapshot( + ) + expect(asFragment()).toMatchSnapshot() + }) + + it('renders correctly with error content', () => { + const { asFragment } = renderWithTheme( { , - )) - it('renders correctly as a radio', () => - shouldMatchSnapshot( + ) + expect(asFragment()).toMatchSnapshot() + }) + + it('renders correctly as a radio', () => { + const { asFragment } = renderWithTheme( { , - )) + ) + expect(asFragment()).toMatchSnapshot() + }) it('throws if SelectableCardGroup.Card is used without SelectableCardGroup', () => { expect(() => render()).toThrow( 'SelectableCardGroup.Card can only be used inside a SelectableCardGroup', ) }) + + it('renders correctly with Label component and badge', () => { + const { asFragment } = renderWithTheme( + {}} type="radio" value="value-1"> + + } + value="value-1" + /> + , + ) + expect(asFragment()).toMatchSnapshot() + }) + + it('renders Label with sideText', () => { + const { asFragment } = renderWithTheme( + {}} type="radio" value="value-1"> + } + value="value-1" + /> + , + ) + + expect(screen.getByText('Option')).toBeInTheDocument() + expect(screen.getByText('Populaire')).toBeInTheDocument() + expect(screen.getByText('5€')).toBeInTheDocument() + expect(asFragment()).toMatchSnapshot() + }) + + it('renders Label with function as labelDescription', () => { + const { asFragment } = renderWithTheme( + {}} type="radio" value="value-1"> + Custom description} + badgeText="Test" + /> + } + value="value-1" + /> + , + ) + + expect(screen.getByText('Option')).toBeInTheDocument() + expect(screen.getByText('Custom description')).toBeInTheDocument() + expect(screen.getByText('Test')).toBeInTheDocument() + expect(asFragment()).toMatchSnapshot() + }) }) diff --git a/packages/ui/src/components/SelectableCardGroup/index.tsx b/packages/ui/src/components/SelectableCardGroup/index.tsx index d6e30fc085..158e0ab503 100644 --- a/packages/ui/src/components/SelectableCardGroup/index.tsx +++ b/packages/ui/src/components/SelectableCardGroup/index.tsx @@ -9,6 +9,7 @@ import { Label } from '../Label' import { Row } from '../Row' import { Stack } from '../Stack' import { SelectableCardGroupContext } from './Context' +import { SelectableCardGroupLabel } from './Label' import { CardSelectableCard } from './SingleCard' import { selectableCardGroupStyle } from './styles.css' @@ -87,8 +88,10 @@ const SelectableCardGroupComponent = ({ type SelectableCardOptionGroupType = typeof SelectableCardGroupComponent & { Card: typeof CardSelectableCard + Label: typeof SelectableCardGroupLabel } export const SelectableCardGroup: SelectableCardOptionGroupType = Object.assign(SelectableCardGroupComponent, { Card: CardSelectableCard, + Label: SelectableCardGroupLabel, })