Skip to content

Commit

Permalink
feat: Implement RadioTileGroup component (#2826)
Browse files Browse the repository at this point in the history
* First pass at RadioTileGroup

* Add test for selection behaviour

* Use correct blue color

* Remove unnecessary styles

* Remove space

* Add Title and Description subcomponents

* Fix tests

* Await user interactions in tests

* Add controlled input story

* Fix test

* Make item label a proper input label

* Use cn util for styles
  • Loading branch information
spalmurray-codecov committed May 3, 2024
1 parent 4ce2a59 commit 4a402db
Show file tree
Hide file tree
Showing 5 changed files with 353 additions and 6 deletions.
14 changes: 8 additions & 6 deletions src/ui/Card/Card.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
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<HTMLDivElement>,
VariantProps<typeof card> {}

const CardRoot = React.forwardRef<HTMLDivElement, CardProps>(
({ className, ...props }, ref) => (
<div ref={ref} className={card({ className })} {...props} />
<div ref={ref} className={cn(card({ className }))} {...props} />
)
)
CardRoot.displayName = 'Card'
Expand All @@ -20,7 +22,7 @@ interface HeaderProps

const Header = React.forwardRef<HTMLDivElement, HeaderProps>(
({ className, ...props }, ref) => (
<div ref={ref} className={header({ className })} {...props} />
<div ref={ref} className={cn(header({ className }))} {...props} />
)
)
Header.displayName = 'Card.Header'
Expand All @@ -42,7 +44,7 @@ interface TitleProps

const Title = React.forwardRef<HTMLParagraphElement, TitleProps>(
({ className, size, children, ...props }, ref) => (
<h3 ref={ref} className={title({ className, size })} {...props}>
<h3 ref={ref} className={cn(title({ className, size }))} {...props}>
{children}
</h3>
)
Expand All @@ -56,7 +58,7 @@ interface DescriptionProps

const Description = React.forwardRef<HTMLParagraphElement, DescriptionProps>(
({ className, ...props }, ref) => (
<p ref={ref} className={description({ className })} {...props} />
<p ref={ref} className={cn(description({ className }))} {...props} />
)
)
Description.displayName = 'Card.Description'
Expand All @@ -68,7 +70,7 @@ interface ContentProps

const Content = React.forwardRef<HTMLDivElement, ContentProps>(
({ className, ...props }, ref) => (
<div ref={ref} className={content({ className })} {...props} />
<div ref={ref} className={cn(content({ className }))} {...props} />
)
)
Content.displayName = 'Card.Content'
Expand All @@ -80,7 +82,7 @@ interface FooterProps

const Footer = React.forwardRef<HTMLDivElement, FooterProps>(
({ className, ...props }, ref) => (
<div ref={ref} className={footer({ className })} {...props} />
<div ref={ref} className={cn(footer({ className }))} {...props} />
)
)
Footer.displayName = 'Card.Footer'
Expand Down
100 changes: 100 additions & 0 deletions src/ui/RadioTileGroup/RadioTileGroup.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(
<RadioTileGroup>
<RadioTileGroup.Item value="asdf">
<RadioTileGroup.Label>Asdf</RadioTileGroup.Label>
</RadioTileGroup.Item>
<RadioTileGroup.Item value="jkl;">
<RadioTileGroup.Label>Jkl;</RadioTileGroup.Label>
</RadioTileGroup.Item>
</RadioTileGroup>
)
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(
<RadioTileGroup>
<RadioTileGroup.Item value="test">
<RadioTileGroup.Label>Label</RadioTileGroup.Label>
</RadioTileGroup.Item>
</RadioTileGroup>
)
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(<RadioTileGroup.Label>Label</RadioTileGroup.Label>)
const label = await screen.findByText('Label')
expect(label).toBeInTheDocument()
expect(label.hasAttribute('for')).toBeFalsy()
})
})

describe('item description', () => {
it('renders', async () => {
render(
<RadioTileGroup>
<RadioTileGroup.Item value="asdf">
<RadioTileGroup.Label>Asdf</RadioTileGroup.Label>
<RadioTileGroup.Description>
This is a description.
</RadioTileGroup.Description>
</RadioTileGroup.Item>
</RadioTileGroup>
)
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(
<RadioTileGroup>
<RadioTileGroup.Item value="asdf">
<RadioTileGroup.Label>Asdf</RadioTileGroup.Label>
</RadioTileGroup.Item>
<RadioTileGroup.Item value="jkl;">
<RadioTileGroup.Label>Jkl;</RadioTileGroup.Label>
</RadioTileGroup.Item>
</RadioTileGroup>
)
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()
})
})
})
101 changes: 101 additions & 0 deletions src/ui/RadioTileGroup/RadioTileGroup.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'

import { RadioTileGroup } from './RadioTileGroup'

type RadioTileGroupStory = React.ComponentProps<typeof RadioTileGroup> & {
flex: 1 | 'none'
}

const meta: Meta<RadioTileGroupStory> = {
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<RadioTileGroupStory>

export const Default: Story = {
args: {
direction: 'row',
flex: 1,
},
render: (args) => (
<RadioTileGroup className="w-full" direction={args.direction}>
<RadioTileGroup.Item value="radio" flex={args.flex}>
<RadioTileGroup.Label>Radio</RadioTileGroup.Label>
</RadioTileGroup.Item>
<RadioTileGroup.Item value="tile" flex={args.flex}>
<RadioTileGroup.Label>Tile</RadioTileGroup.Label>
</RadioTileGroup.Item>
<RadioTileGroup.Item value="group" flex={args.flex}>
<RadioTileGroup.Label>Group</RadioTileGroup.Label>
</RadioTileGroup.Item>
</RadioTileGroup>
),
}

export const WithDescription: Story = {
args: {
direction: 'row',
flex: 1,
},
render: (args) => (
<RadioTileGroup className="w-full" direction={args.direction}>
<RadioTileGroup.Item value="description" flex={args.flex}>
<RadioTileGroup.Label>Description</RadioTileGroup.Label>
<RadioTileGroup.Description>
A RadioTileGroup Item can optionally have a description
</RadioTileGroup.Description>
</RadioTileGroup.Item>
<RadioTileGroup.Item value="noDescription" flex={args.flex}>
<RadioTileGroup.Label>No Description</RadioTileGroup.Label>
</RadioTileGroup.Item>
</RadioTileGroup>
),
}

export const WithControlledInput: Story = {
args: {
direction: 'row',
flex: 1,
},
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [value, setValue] = useState<string | undefined>(undefined)

return (
<RadioTileGroup
className="w-full"
direction={args.direction}
value={value}
onValueChange={(value) => {
setValue(value)
// controlled input state isn't always required, you can also do things here ... like navigation etc.
}}
>
<RadioTileGroup.Item value="radio" flex={args.flex}>
<RadioTileGroup.Label>Radio</RadioTileGroup.Label>
</RadioTileGroup.Item>
<RadioTileGroup.Item value="tile" flex={args.flex}>
<RadioTileGroup.Label>Tile</RadioTileGroup.Label>
</RadioTileGroup.Item>
<RadioTileGroup.Item value="group" flex={args.flex}>
<RadioTileGroup.Label>Group</RadioTileGroup.Label>
</RadioTileGroup.Item>
</RadioTileGroup>
)
},
}

0 comments on commit 4a402db

Please sign in to comment.