diff --git a/src/ui/Card/Card.tsx b/src/ui/Card/Card.tsx index 80746718f9..a50719597f 100644 --- a/src/ui/Card/Card.tsx +++ b/src/ui/Card/Card.tsx @@ -1,6 +1,8 @@ import { cva, VariantProps } from 'cva' import React from 'react' +import { cn } from 'shared/utils/cn' + const card = cva(['border border-ds-gray-secondary']) interface CardProps extends React.HTMLAttributes, @@ -8,7 +10,7 @@ interface CardProps const CardRoot = React.forwardRef( ({ className, ...props }, ref) => ( -
+
) ) CardRoot.displayName = 'Card' @@ -20,7 +22,7 @@ interface HeaderProps const Header = React.forwardRef( ({ className, ...props }, ref) => ( -
+
) ) Header.displayName = 'Card.Header' @@ -42,7 +44,7 @@ interface TitleProps const Title = React.forwardRef( ({ className, size, children, ...props }, ref) => ( -

+

{children}

) @@ -56,7 +58,7 @@ interface DescriptionProps const Description = React.forwardRef( ({ className, ...props }, ref) => ( -

+

) ) Description.displayName = 'Card.Description' @@ -68,7 +70,7 @@ interface ContentProps const Content = React.forwardRef( ({ className, ...props }, ref) => ( -

+
) ) Content.displayName = 'Card.Content' @@ -80,7 +82,7 @@ interface FooterProps const Footer = React.forwardRef( ({ className, ...props }, ref) => ( -
+
) ) Footer.displayName = 'Card.Footer' diff --git a/src/ui/RadioTileGroup/RadioTileGroup.spec.tsx b/src/ui/RadioTileGroup/RadioTileGroup.spec.tsx new file mode 100644 index 0000000000..3ca9e473e5 --- /dev/null +++ b/src/ui/RadioTileGroup/RadioTileGroup.spec.tsx @@ -0,0 +1,100 @@ +import { render, screen } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' + +import { RadioTileGroup } from './RadioTileGroup' + +describe('RadioTileGroup', () => { + function setup() { + return { + user: userEvent.setup(), + } + } + + it('renders', async () => { + render( + + + Asdf + + + Jkl; + + + ) + const item1 = await screen.findByText('Asdf') + expect(item1).toBeInTheDocument() + const item2 = await screen.findByText('Jkl;') + expect(item2).toBeInTheDocument() + }) + + describe('item title', () => { + it('has htmlFor attribute when used inside Item', async () => { + render( + + + Label + + + ) + const label = await screen.findByText('Label') + expect(label).toBeInTheDocument() + expect(label.hasAttribute('for')).toBeTruthy() + }) + + it('does not have htmlFor attribute when used outside of Item', async () => { + render(Label) + const label = await screen.findByText('Label') + expect(label).toBeInTheDocument() + expect(label.hasAttribute('for')).toBeFalsy() + }) + }) + + describe('item description', () => { + it('renders', async () => { + render( + + + Asdf + + This is a description. + + + + ) + const description = await screen.findByText('This is a description.') + expect(description).toBeInTheDocument() + }) + }) + + describe('when an item is clicked', () => { + it('toggles selected circle', async () => { + const { user } = setup() + render( + + + Asdf + + + Jkl; + + + ) + const tile = await screen.findByText('Asdf') + const tile2 = await screen.findByText('Jkl;') + + await user.click(tile) + + const selected = await screen.findByTestId('radio-button-circle-selected') + expect(selected).toBeInTheDocument() + + await user.click(tile2) + + expect(selected).not.toBeInTheDocument() + + const otherSelected = await screen.findByTestId( + 'radio-button-circle-selected' + ) + expect(otherSelected).toBeInTheDocument() + }) + }) +}) diff --git a/src/ui/RadioTileGroup/RadioTileGroup.stories.tsx b/src/ui/RadioTileGroup/RadioTileGroup.stories.tsx new file mode 100644 index 0000000000..44015f3a70 --- /dev/null +++ b/src/ui/RadioTileGroup/RadioTileGroup.stories.tsx @@ -0,0 +1,101 @@ +import { Meta, StoryObj } from '@storybook/react' +import { useState } from 'react' + +import { RadioTileGroup } from './RadioTileGroup' + +type RadioTileGroupStory = React.ComponentProps & { + flex: 1 | 'none' +} + +const meta: Meta = { + title: 'Components/RadioTileGroup', + component: RadioTileGroup, + argTypes: { + direction: { + description: 'Controls the flex direction of the RadioTileGroup', + control: 'radio', + options: ['row', 'col'], + }, + flex: { + description: 'Toggles between the item flexing and not', + control: 'radio', + options: [1, 'none'], + }, + }, +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + direction: 'row', + flex: 1, + }, + render: (args) => ( + + + Radio + + + Tile + + + Group + + + ), +} + +export const WithDescription: Story = { + args: { + direction: 'row', + flex: 1, + }, + render: (args) => ( + + + Description + + A RadioTileGroup Item can optionally have a description + + + + No Description + + + ), +} + +export const WithControlledInput: Story = { + args: { + direction: 'row', + flex: 1, + }, + render: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [value, setValue] = useState(undefined) + + return ( + { + setValue(value) + // controlled input state isn't always required, you can also do things here ... like navigation etc. + }} + > + + Radio + + + Tile + + + Group + + + ) + }, +} diff --git a/src/ui/RadioTileGroup/RadioTileGroup.tsx b/src/ui/RadioTileGroup/RadioTileGroup.tsx new file mode 100644 index 0000000000..d710301a4f --- /dev/null +++ b/src/ui/RadioTileGroup/RadioTileGroup.tsx @@ -0,0 +1,143 @@ +import * as LabelPrimitive from '@radix-ui/react-label' +import * as RadioGroupPrimitive from '@radix-ui/react-radio-group' +import { cva, VariantProps } from 'cva' +import React, { createContext, useContext, useId } from 'react' + +import { cn } from 'shared/utils/cn' + +const group = cva(['flex', 'gap-4'], { + variants: { + direction: { + row: 'flex-row', + col: 'flex-col', + }, + }, + defaultVariants: { + direction: 'row', + }, +}) +interface GroupProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const Group = React.forwardRef< + React.ElementRef, + GroupProps +>(({ className, direction, ...props }, ref) => { + return ( + + ) +}) +Group.displayName = 'RadioTileGroup' + +const item = cva(['relative'], { + variants: { + flex: { + 1: 'flex-1', + none: 'flex-none', + }, + }, + defaultVariants: { + flex: 1, + }, +}) +interface ItemProps + extends React.ComponentPropsWithoutRef, + VariantProps {} +const ItemContext = createContext(null) + +const Item = React.forwardRef< + React.ElementRef, + ItemProps +>(({ children, className, flex, ...props }, ref) => { + const itemId = useId() + return ( + + +
+ {children} +
+ +
+
+
+ +
+
+ + + + ) +}) +Item.displayName = 'RadioTileGroup.Item' + +const label = cva(['font-medium']) +interface LabelProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const Label = React.forwardRef< + React.ElementRef, + LabelProps +>(({ className, ...props }, ref) => { + const itemId = useContext(ItemContext) + return ( +
+ + +
+ ) +}) +Label.displayName = 'RadioTileGroup.Label' + +const description = cva(['text-left text-ds-gray-quinary']) +interface DescriptionProps + extends React.HTMLAttributes, + VariantProps {} + +const Description = React.forwardRef( + ({ children, className, ...props }, ref) => { + return ( +

+ {children} +

+ ) + } +) +Description.displayName = 'RadioTileGroup.Description' + +function RadioButtonCircle({ selected = false }: { selected?: boolean }) { + return selected ? ( +
+
+
+ ) : ( +
+ ) +} + +export const RadioTileGroup = Object.assign(Group, { + Item, + Label, + Description, +}) diff --git a/src/ui/RadioTileGroup/index.ts b/src/ui/RadioTileGroup/index.ts new file mode 100644 index 0000000000..b7ba3c5dbe --- /dev/null +++ b/src/ui/RadioTileGroup/index.ts @@ -0,0 +1 @@ +export { RadioTileGroup } from './RadioTileGroup'