Skip to content

Commit

Permalink
Create RadioGroup component
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Aug 29, 2024
1 parent 5a1de46 commit 23e4d45
Show file tree
Hide file tree
Showing 12 changed files with 780 additions and 0 deletions.
153 changes: 153 additions & 0 deletions src/components/input/RadioGroup.tsx
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;
11 changes: 11 additions & 0 deletions src/components/input/RadioGroupContext.ts
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;
188 changes: 188 additions & 0 deletions src/components/input/test/RadioGroup-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
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());
container.remove();
});

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) {
getRadio(wrapper, index).simulate('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 }),
);
});
Loading

0 comments on commit 23e4d45

Please sign in to comment.