Skip to content

Commit

Permalink
Add alpha Radio component (#1658)
Browse files Browse the repository at this point in the history
  • Loading branch information
rezrah committed Dec 6, 2021
1 parent 2380b66 commit edc48ba
Show file tree
Hide file tree
Showing 9 changed files with 492 additions and 3 deletions.
5 changes: 5 additions & 0 deletions .changeset/giant-timers-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/components': minor
---

Adds a Radio component
90 changes: 90 additions & 0 deletions docs/content/Radio.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
title: Radio
description: Use radios when a user needs to select one option from a list
status: Alpha
source: https://github.com/primer/react/blob/main/src/Radio.tsx
storybook: '/react/storybook?path=/story/forms-radio-button--default'
---

## Default example

```jsx live
<>
<Radio value="one" name="radio-group-name" />
<Radio value="two" name="radio-group-name" />
<Radio disabled value="three" name="radio-group-name" />
</>
```

<Note>
Please use a [Checkbox](/Checkbox) if the user needs to select more than one option in a list
</Note>
<Note variant="warning">
Radio components should always be accompanied by a corresponding label to improve support for assistive technologies.
</Note>

## Grouping Radio components

Use the `name` prop to group together related `Radio` components in a list.

```jsx live
<form>
<Box sx={{p: 1, display: 'flex', alignItems: 'center'}}>
<Radio id="radio-0" value="1" name="radio-example" />
<Text as="label" htmlFor="radio-0" sx={{fontSize: 1, marginLeft: 1}}>
<Text sx={{display: 'block'}}>Radio 1</Text>
</Text>
</Box>
<Box sx={{p: 1, display: 'flex', alignItems: 'center'}}>
<Radio id="radio-1" value="2" name="radio-example" />
<Text as="label" htmlFor="radio-1" sx={{fontSize: 1, marginLeft: 1}}>
<Text sx={{display: 'block'}}>Radio 2</Text>
</Text>
</Box>
<Box sx={{p: 1, display: 'flex', alignItems: 'center'}}>
<Radio id="radio-2" value="3" name="radio-example" />
<Text as="label" htmlFor="radio-2" sx={{fontSize: 1, marginLeft: 1}}>
<Text sx={{display: 'block'}}>Radio 3</Text>
</Text>
</Box>
<Box sx={{p: 1, display: 'flex', alignItems: 'center'}}>
<Radio id="radio-3" value="4" name="radio-example" />
<Text as="label" htmlFor="radio-3" sx={{fontSize: 1, marginLeft: 1}}>
<Text sx={{display: 'block'}}>Radio 4</Text>
</Text>
</Box>
</form>
```

## Component props

Native `<input>` attributes are forwarded to the underlying React `input` component and are not listed below.

| Name | Type | Default | Description |
| :------------- | :---------- | :-------: | :--------------------------------------------------------------------------------------- |
| value | String | undefined | Required. A unique value that is never shown to the user. |
| name | String | undefined | Required. Used for grouping multiple radios |
| checked | Boolean | undefined | Optional. Modifies true/false value of the native radio |
| defaultChecked | Boolean | undefined | Optional. Selects the radio by default in uncontrolled mode |
| onChange | ChangeEvent | undefined | Optional. A callback function that is triggered when the checked state has been changed. |
| disabled | Boolean | undefined | Optional. Modifies the native disabled state of the native radio |

## Component status

<ComponentChecklist
items={{
propsDocumented: true,
noUnnecessaryDeps: true,
adaptsToThemes: true,
adaptsToScreenSizes: true,
fullTestCoverage: true,
usedInProduction: false,
usageExamplesDocumented: false,
designReviewed: false,
a11yReviewed: false,
stableApi: false,
addressedApiFeedback: false,
hasDesignGuidelines: false,
hasFigmaComponent: false
}}
/>
2 changes: 2 additions & 0 deletions docs/src/@primer/gatsby-theme-doctocat/nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@
# url: /Portal
- title: ProgressBar
url: /ProgressBar
- title: Radio
url: /Radio
- title: SelectMenu
url: /SelectMenu
- title: SideNav
Expand Down
76 changes: 76 additions & 0 deletions src/Radio.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import styled from 'styled-components'
import React, {InputHTMLAttributes, ReactElement} from 'react'
import sx, {SxProp} from './sx'

export type RadioProps = {
/**
* A unique value that is never shown to the user.
* Used during form submission and to identify which radio button in a group is selected
*/
value: string
/**
* Name attribute of the input element. Required for grouping radio inputs
*/
name: string
/**
* Apply inactive visual appearance to the radio button
*/
disabled?: boolean
/**
* Indicates whether the radio button is selected
*/
checked?: boolean
/**
* Forward a ref to the underlying input element
*/
ref?: React.RefObject<HTMLInputElement>
/**
* Indicates whether the radio button must be checked before the form can be submitted
*/
required?: boolean
/**
* Indicates whether the radio button validation state is non-standard
*/
validationStatus?: 'error' | 'success' // TODO: hoist to Validation typings
} & InputHTMLAttributes<HTMLInputElement> &
SxProp

const StyledRadio = styled.input`
cursor: pointer;
${props => props.disabled && `cursor: not-allowed;`}
${sx}
`

/**
* An accessible, native radio component for selecting one option from a list.
*/
const Radio = React.forwardRef<HTMLInputElement, RadioProps>(
(
{checked, disabled, sx: sxProp, required, validationStatus, value, name, ...rest}: RadioProps,
ref
): ReactElement => {
return (
<StyledRadio
type="radio"
value={value}
name={name}
ref={ref}
disabled={disabled}
aria-disabled={disabled ? 'true' : 'false'}
checked={checked}
aria-checked={checked ? 'true' : 'false'}
required={required}
aria-required={required ? 'true' : 'false'}
aria-invalid={validationStatus === 'error' ? 'true' : 'false'}
sx={sxProp}
{...rest}
/>
)
}
)

Radio.displayName = 'Radio'

export default Radio
174 changes: 174 additions & 0 deletions src/__tests__/Radio.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import React from 'react'
import {Radio} from '..'
import {behavesAsComponent, checkExports} from '../utils/testing'
import {render, cleanup, fireEvent} from '@testing-library/react'
import {toHaveNoViolations} from 'jest-axe'
import 'babel-polyfill'
import '@testing-library/jest-dom'

expect.extend(toHaveNoViolations)

describe('Radio', () => {
const defaultProps = {
name: 'mock',
value: 'mock value'
}

beforeEach(() => {
jest.resetAllMocks()
cleanup()
})

behavesAsComponent({Component: Radio})

checkExports('Radio', {
default: Radio
})

it('renders a valid radio input', () => {
const {getByRole} = render(<Radio {...defaultProps} />)

const radio = getByRole('radio')

expect(radio).toBeDefined()
})

it('renders an unchecked radio by default', () => {
const {getByRole} = render(<Radio {...defaultProps} />)

const radio = getByRole('radio') as HTMLInputElement

expect(radio.checked).toEqual(false)
})

it('accepts and applies value and name attributes', () => {
const {getByRole} = render(<Radio {...defaultProps} />)

const radio = getByRole('radio') as HTMLInputElement

expect(radio).toHaveAttribute('name', defaultProps.name)
expect(radio).toHaveAttribute('value', defaultProps.value)
})

it('renders an active radio when checked attribute is passed', () => {
const handleChange = jest.fn()
const {getByRole} = render(<Radio {...defaultProps} checked onChange={handleChange} />)

const radio = getByRole('radio') as HTMLInputElement

expect(radio.checked).toEqual(true)
})

it('accepts a change handler that can alter a single radio state', () => {
const handleChange = jest.fn()
const {getByRole} = render(<Radio {...defaultProps} onChange={handleChange} />)

const radio = getByRole('radio') as HTMLInputElement

expect(radio.checked).toEqual(false)

fireEvent.click(radio)
expect(handleChange).toHaveBeenCalled()
expect(radio.checked).toEqual(true)
})

it('renders correct behavior for multiple radio buttons in a group', () => {
const handleChange = jest.fn()
const RadioGroup = () => (
<form>
<Radio {...defaultProps} value="radio-one" onChange={handleChange} />
<Radio {...defaultProps} value="radio-two" onChange={handleChange} />
</form>
)
const {getByDisplayValue} = render(<RadioGroup />)

const radioOne = getByDisplayValue('radio-one') as HTMLInputElement
const radioTwo = getByDisplayValue('radio-two') as HTMLInputElement

expect(radioOne).not.toBeChecked()
expect(radioTwo).not.toBeChecked()

fireEvent.click(radioOne)

expect(radioOne).toBeChecked()
expect(radioTwo).not.toBeChecked()

fireEvent.click(radioTwo)

expect(radioOne).not.toBeChecked()
expect(radioTwo).toBeChecked()
})

it('renders an inactive radio state correctly', () => {
const handleChange = jest.fn()
const {getByRole, rerender} = render(<Radio {...defaultProps} disabled onChange={handleChange} />)

const radio = getByRole('radio') as HTMLInputElement

expect(radio.disabled).toEqual(true)
expect(radio).not.toBeChecked()
expect(radio).toHaveAttribute('aria-disabled', 'true')

fireEvent.change(radio)

expect(radio.disabled).toEqual(true)
expect(radio).not.toBeChecked()
expect(radio).toHaveAttribute('aria-disabled', 'true')

// remove disabled attribute and retest
rerender(<Radio {...defaultProps} onChange={handleChange} />)

expect(radio).toHaveAttribute('aria-disabled', 'false')
})

it('renders an uncontrolled component correctly', () => {
const {getByRole} = render(<Radio {...defaultProps} defaultChecked />)

const radio = getByRole('radio') as HTMLInputElement

expect(radio.checked).toEqual(true)
})

it('renders an aria-checked attribute correctly', () => {
const handleChange = jest.fn()
const {getByRole, rerender} = render(<Radio {...defaultProps} checked={false} onChange={handleChange} />)

const radio = getByRole('radio') as HTMLInputElement

expect(radio).toHaveAttribute('aria-checked', 'false')

rerender(<Radio {...defaultProps} checked={true} onChange={handleChange} />)

expect(radio).toHaveAttribute('aria-checked', 'true')
})

it('renders an invalid aria state when validation prop indicates an error', () => {
const handleChange = jest.fn()
const {getByRole, rerender} = render(<Radio {...defaultProps} onChange={handleChange} />)

const radio = getByRole('radio') as HTMLInputElement

expect(radio).toHaveAttribute('aria-invalid', 'false')

rerender(<Radio {...defaultProps} onChange={handleChange} validationStatus="success" />)

expect(radio).toHaveAttribute('aria-invalid', 'false')

rerender(<Radio {...defaultProps} onChange={handleChange} validationStatus="error" />)

expect(radio).toHaveAttribute('aria-invalid', 'true')
})

it('renders an aria state indicating the field is required', () => {
const handleChange = jest.fn()
const {getByRole, rerender} = render(<Radio {...defaultProps} onChange={handleChange} />)

const radio = getByRole('radio') as HTMLInputElement

expect(radio).toHaveAttribute('aria-required', 'false')

rerender(<Radio {...defaultProps} onChange={handleChange} required />)

expect(radio).toHaveAttribute('aria-required', 'true')
})
})
16 changes: 16 additions & 0 deletions src/__tests__/__snapshots__/Radio.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Radio renders consistently 1`] = `
.c0 {
cursor: pointer;
}
<input
aria-checked="false"
aria-disabled="false"
aria-invalid="false"
aria-required="false"
className="c0"
type="radio"
/>
`;
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export {useOverlay} from './hooks/useOverlay'
export {useConfirm} from './Dialog/ConfirmationDialog'

// Components
export {default as Radio} from './Radio'
export type {RadioProps} from './Radio'
export {ActionList} from './ActionList'
export {ActionMenu} from './ActionMenu'
export type {ActionMenuProps} from './ActionMenu'
Expand Down
Loading

0 comments on commit edc48ba

Please sign in to comment.