Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create RadioGroup component #1683

Merged
merged 3 commits into from
Aug 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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