-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
12 changed files
with
783 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,153 @@ | ||
import classnames from 'classnames'; | ||
import type { ComponentChildren } from 'preact'; | ||
import { useContext, useRef } from 'preact/hooks'; | ||
|
||
import { useArrowKeyNavigation } from '../../hooks/use-arrow-key-navigation'; | ||
import { RadioCheckedIcon, RadioIcon } from '../icons'; | ||
import RadioGroupContext from './RadioGroupContext'; | ||
|
||
type RadioValue = string | number; | ||
|
||
export type RadioProps<T extends RadioValue> = { | ||
value: T; | ||
/** Content provided as children is vertically aligned with the radio icon */ | ||
children: ComponentChildren; | ||
|
||
/** | ||
* Allows to provide extra content to be displayed under the children, in a | ||
* smaller and more subtle font color. | ||
*/ | ||
subtitle?: ComponentChildren; | ||
|
||
disabled?: boolean; | ||
}; | ||
|
||
function Radio<T extends RadioValue>({ | ||
value, | ||
children, | ||
subtitle, | ||
disabled: radioDisabled, | ||
}: RadioProps<T>) { | ||
const radioGroupContext = useContext(RadioGroupContext); | ||
if (!radioGroupContext) { | ||
throw new Error('RadioGroup.Radio can only be used as RadioGroup child'); | ||
} | ||
|
||
const { selected, disabled = radioDisabled, onChange } = radioGroupContext; | ||
const isSelected = !disabled && selected === value; | ||
|
||
return ( | ||
<div | ||
role="radio" | ||
aria-checked={isSelected} | ||
aria-disabled={disabled} | ||
className={classnames('focus-visible-ring rounded-lg px-3 py-2 grow', { | ||
'bg-grey-2': isSelected, | ||
'hover:bg-grey-1': !isSelected && !disabled, | ||
'opacity-70': disabled, | ||
'cursor-pointer': !disabled, | ||
})} | ||
data-value={value} | ||
onClick={!disabled ? () => onChange(value) : undefined} | ||
onKeyDown={ | ||
disabled | ||
? undefined | ||
: e => { | ||
if (['Enter', ' '].includes(e.key)) { | ||
e.preventDefault(); | ||
onChange(value); | ||
} | ||
} | ||
} | ||
tabIndex={-1} | ||
> | ||
<div className="flex items-center gap-x-1.5"> | ||
{isSelected ? <RadioCheckedIcon /> : <RadioIcon />} | ||
{children} | ||
</div> | ||
{subtitle && ( | ||
<div className="pl-4 ml-1.5 mt-1 text-grey-6 text-sm">{subtitle}</div> | ||
)} | ||
</div> | ||
); | ||
} | ||
|
||
Radio.displayName = 'RadioGroup.Radio'; | ||
|
||
export type RadioGroup<T extends RadioValue> = { | ||
children: ComponentChildren; | ||
selected?: T; | ||
onChange: (newSelected: T) => void; | ||
|
||
/** | ||
* Determines the direction in which radios are stacked. | ||
* Defaults to 'horizontal'. | ||
*/ | ||
direction?: 'vertical' | 'horizontal'; | ||
|
||
disabled?: boolean; | ||
'aria-label'?: string; | ||
'aria-labelledby'?: string; | ||
|
||
/** | ||
* If provided, adds a hidden form control with the given name and the value | ||
* set to the selected radio's value, for use in form submissions. | ||
*/ | ||
name?: string; | ||
}; | ||
|
||
function RadioGroupMain<T extends RadioValue>({ | ||
direction = 'horizontal', | ||
children, | ||
selected, | ||
onChange, | ||
disabled, | ||
'aria-label': label, | ||
'aria-labelledby': labelledBy, | ||
name, | ||
}: RadioGroup<T>) { | ||
const containerRef = useRef<HTMLDivElement | null>(null); | ||
|
||
useArrowKeyNavigation(containerRef, { | ||
loop: false, | ||
selector: '[role="radio"]:not([aria-disabled="true"])', | ||
focusElement: el => { | ||
onChange(el.dataset.value as T); | ||
el.focus(); | ||
}, | ||
}); | ||
|
||
return ( | ||
<RadioGroupContext.Provider | ||
value={{ selected, disabled, onChange: onChange as any }} | ||
> | ||
<div | ||
aria-label={label} | ||
aria-labelledby={labelledBy} | ||
ref={containerRef} | ||
role="radiogroup" | ||
className={classnames('w-full flex gap-1.5', { | ||
'flex-col': direction === 'vertical', | ||
})} | ||
> | ||
{children} | ||
</div> | ||
{name && ( | ||
<input | ||
type="hidden" | ||
data-testid="hidden-input" | ||
name={name} | ||
value={selected} | ||
disabled={disabled} | ||
/> | ||
)} | ||
</RadioGroupContext.Provider> | ||
); | ||
} | ||
|
||
const RadioGroup = Object.assign(RadioGroupMain, { | ||
Radio, | ||
displayName: 'RadioGroup', | ||
}); | ||
|
||
export default RadioGroup; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
import { createContext } from 'preact'; | ||
|
||
export type RadioGroupContextType<T = unknown> = { | ||
selected: T | undefined; | ||
disabled?: boolean; | ||
onChange: (newSelected: T) => void; | ||
}; | ||
|
||
const RadioGroupContext = createContext<RadioGroupContextType | null>(null); | ||
|
||
export default RadioGroupContext; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,191 @@ | ||
import { checkAccessibility } from '@hypothesis/frontend-testing'; | ||
import { mount } from 'enzyme'; | ||
|
||
import RadioGroup from '../RadioGroup'; | ||
|
||
describe('RadioGroup', () => { | ||
let container; | ||
let wrappers; | ||
|
||
beforeEach(() => { | ||
wrappers = []; | ||
container = document.createElement('div'); | ||
document.body.appendChild(container); | ||
}); | ||
|
||
afterEach(() => { | ||
wrappers.forEach(wrapper => wrapper.unmount()); | ||
}); | ||
|
||
const createComponent = (props = {}) => { | ||
const wrapper = mount( | ||
<RadioGroup aria-label="Radio group" onChange={sinon.stub()} {...props}> | ||
<RadioGroup.Radio value="one">One</RadioGroup.Radio> | ||
<RadioGroup.Radio value="two">Two</RadioGroup.Radio> | ||
<RadioGroup.Radio value="three" disabled> | ||
Three | ||
</RadioGroup.Radio> | ||
<RadioGroup.Radio value="four" subtitle="This has a subtitle"> | ||
Four | ||
</RadioGroup.Radio> | ||
</RadioGroup>, | ||
{ attachTo: container }, | ||
); | ||
wrappers.push(wrapper); | ||
|
||
return wrapper; | ||
}; | ||
|
||
function getRadio(wrapper, index) { | ||
return wrapper.find('[role="radio"]').at(index); | ||
} | ||
|
||
function clickRadio(wrapper, index) { | ||
getRadio(wrapper, index).simulate('click'); | ||
} | ||
|
||
function pressKeyOnRadio(wrapper, index, key) { | ||
wrapper | ||
.find('[role="radio"]') | ||
.at(index) | ||
.getDOMNode() | ||
.dispatchEvent(new KeyboardEvent('keydown', { key })); | ||
} | ||
|
||
function pressKeyOnRadioGroup(wrapper, key) { | ||
wrapper | ||
.find('[role="radiogroup"]') | ||
.getDOMNode() | ||
.dispatchEvent(new KeyboardEvent('keydown', { key })); | ||
} | ||
|
||
[ | ||
{ index: 0, expectedValue: 'one' }, | ||
{ index: 1, expectedValue: 'two' }, | ||
{ index: 3, expectedValue: 'four' }, | ||
].forEach(({ index, expectedValue }) => { | ||
it('allows selected option to be changed by clicking a radio', () => { | ||
const onChange = sinon.stub(); | ||
const wrapper = createComponent({ onChange }); | ||
|
||
clickRadio(wrapper, index); | ||
assert.calledWith(onChange, expectedValue); | ||
}); | ||
|
||
[ | ||
{ | ||
keyName: 'Enter', | ||
key: 'Enter', | ||
}, | ||
{ | ||
keyName: 'Space', | ||
key: ' ', | ||
}, | ||
].forEach(({ keyName, key }) => { | ||
it(`allows selected option to be changed via ${keyName}`, () => { | ||
const onChange = sinon.stub(); | ||
const wrapper = createComponent({ onChange }); | ||
|
||
pressKeyOnRadio(wrapper, index, key); | ||
assert.calledWith(onChange, expectedValue); | ||
}); | ||
}); | ||
}); | ||
|
||
it('ignores clicks on disabled options', () => { | ||
const onChange = sinon.stub(); | ||
const wrapper = createComponent({ onChange }); | ||
|
||
clickRadio(wrapper, 2); // Second option is disabled | ||
assert.notCalled(onChange); | ||
}); | ||
|
||
[ | ||
{ | ||
keyName: 'Enter', | ||
key: 'Enter', | ||
}, | ||
{ | ||
keyName: 'Space', | ||
key: ' ', | ||
}, | ||
].forEach(({ keyName, key }) => { | ||
it(`ignores ${keyName} press on disabled options`, () => { | ||
const onChange = sinon.stub(); | ||
const wrapper = createComponent({ onChange }); | ||
|
||
pressKeyOnRadio(wrapper, 2, key); // Second option is disabled | ||
assert.notCalled(onChange); | ||
}); | ||
}); | ||
|
||
it('shows expected radio icons in selected and non-selected options', () => { | ||
const wrapper = createComponent({ selected: 'two' }); | ||
|
||
// First item is not selected | ||
assert.isFalse(getRadio(wrapper, 0).exists('RadioCheckedIcon')); | ||
assert.isTrue(getRadio(wrapper, 0).exists('RadioIcon')); | ||
|
||
// Second item is selected | ||
assert.isTrue(getRadio(wrapper, 1).exists('RadioCheckedIcon')); | ||
assert.isFalse(getRadio(wrapper, 1).exists('RadioIcon')); | ||
}); | ||
|
||
[ | ||
{ nextKey: 'ArrowRight', prevKey: 'ArrowLeft' }, | ||
{ nextKey: 'ArrowDown', prevKey: 'ArrowUp' }, | ||
].forEach(({ nextKey, prevKey }) => { | ||
it('selects option when focused via arrow keys', () => { | ||
const onChange = sinon.stub(); | ||
const wrapper = createComponent({ onChange }); | ||
|
||
// When pressing next key for the first time, it will select and focus | ||
// the second radio | ||
pressKeyOnRadioGroup(wrapper, nextKey); | ||
assert.calledWith(onChange.lastCall, 'two'); | ||
|
||
// When pressing next key again, it will select and focus the fourth | ||
// radio, because the third one is disabled | ||
pressKeyOnRadioGroup(wrapper, nextKey); | ||
assert.calledWith(onChange.lastCall, 'four'); | ||
|
||
// Pressing prev key twice will select first radio again | ||
pressKeyOnRadioGroup(wrapper, prevKey); | ||
pressKeyOnRadioGroup(wrapper, prevKey); | ||
assert.calledWith(onChange.lastCall, 'one'); | ||
}); | ||
}); | ||
|
||
[ | ||
{ | ||
name: 'some-name', | ||
shouldHaveHiddenInput: true, | ||
}, | ||
{ | ||
name: undefined, | ||
shouldHaveHiddenInput: false, | ||
}, | ||
].forEach(({ name, shouldHaveHiddenInput }) => { | ||
it('renders a hidden input when name is provided', () => { | ||
const wrapper = createComponent({ name }); | ||
assert.equal( | ||
wrapper.exists('[data-testid="hidden-input"]'), | ||
shouldHaveHiddenInput, | ||
); | ||
}); | ||
}); | ||
|
||
context('when RadioGroup.Radio is used outside of RadioGroup', () => { | ||
it('throws an error', () => { | ||
assert.throws( | ||
() => mount(<RadioGroup.Radio value="1">One</RadioGroup.Radio>), | ||
'RadioGroup.Radio can only be used as RadioGroup child', | ||
); | ||
}); | ||
}); | ||
|
||
it( | ||
'should pass a11y checks', | ||
checkAccessibility({ content: createComponent }), | ||
); | ||
}); |
Oops, something went wrong.