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 eb2270f commit 8b48357
Show file tree
Hide file tree
Showing 11 changed files with 592 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;
213 changes: 213 additions & 0 deletions src/pattern-library/components/patterns/input/RadioGroupPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { Link } from '../../../../components/navigation';
import Library from '../../Library';

export default function RadioGroupPage() {
return (
<Library.Page
title="Radio group"
intro={
<p>
<code>RadioGroup</code> is a component implementing the{' '}
<Link
href="https://www.w3.org/WAI/ARIA/apg/patterns/radio/"
target="_blank"
>
Radio Group Pattern
</Link>
.
</p>
}
>
<Library.Pattern>
<Library.Usage componentName="RadioGroup" />
<Library.Example>
<Library.Demo
title="Basic RadioGroup"
withSource
exampleFile="radio-group-horizontal"
/>
</Library.Example>
</Library.Pattern>

<Library.Pattern title="Working with RadioGroups">
<p>
<code>RadioGroup</code> can render a list of radios arranged
horizontally or vertically. Each radio can optionally render a
subtitle, and be individually disabled.
</p>
<p>
Radios can be focused via arrow keys. Right/Left for horizontal{' '}
<code>RadioGroup</code>s, and Up/Down for vertical ones.
</p>

<Library.Example title="RadioGroup direction">
<Library.Demo
title="Vertical RadioGroup"
withSource
exampleFile="radio-group-vertical"
/>
</Library.Example>

<Library.Example title="RadioGroup with complex layout">
<p>
If the <code>RadioGroup</code> <code>direction</code> does not fit
your needs, you can provide a more complex container around radios,
like a responsive set of columns or a grid layout.
</p>

<Library.Demo
title="RadioGroup grid layout"
withSource
exampleFile="radio-group-grid-layout"
/>
</Library.Example>

<Library.Example title="Labelling RadioGroups">
<p>
There are two ways to label a <code>RadioGroup</code>. Make sure to
use at least one of them.
</p>

<Library.Demo
title="Via aria-label"
withSource
exampleFile="radio-group-aria-label"
/>
<Library.Demo
title="Via aria-labelledby"
withSource
exampleFile="radio-group-aria-labelledby"
/>
</Library.Example>
</Library.Pattern>

<Library.Pattern title="Component API">
<Library.Example title="aria-label">
<Library.Info>
<Library.InfoItem label="description">
Sets the <code>aria-label</code> attribute in the{' '}
<code>RadioGroup</code>. Make sure either this or{' '}
<code>aria-labelledby</code> is used.
</Library.InfoItem>
<Library.InfoItem label="type">
<code>string | undefined</code>
</Library.InfoItem>
<Library.InfoItem label="default">
<code>undefined</code>
</Library.InfoItem>
</Library.Info>
</Library.Example>
<Library.Example title="aria-labelledby">
<Library.Info>
<Library.InfoItem label="description">
Sets the <code>aria-labelledby</code> attribute in the{' '}
<code>RadioGroup</code>. Make sure either this or{' '}
<code>aria-label</code> is used.
</Library.InfoItem>
<Library.InfoItem label="type">
<code>string | undefined</code>
</Library.InfoItem>
<Library.InfoItem label="default">
<code>undefined</code>
</Library.InfoItem>
</Library.Info>
</Library.Example>
<Library.Example title="children">
<Library.Info>
<Library.InfoItem label="description">
The content to render inside the <code>RadioGroup</code>,
typically a list of <code>RadioGroup.Radio</code> components.
</Library.InfoItem>
<Library.InfoItem label="type">
<code>ComponentChildren</code>
</Library.InfoItem>
<Library.InfoItem label="default">
<code>undefined</code>
</Library.InfoItem>
</Library.Info>
</Library.Example>
<Library.Example title="direction">
<Library.Info>
<Library.InfoItem label="description">
Whether the radios should be stacked horizontally or vertically.
</Library.InfoItem>
<Library.InfoItem label="type">
<code>
{"'"}vertical{"'"} | {"'"}horizontal{"'"}
</code>
</Library.InfoItem>
<Library.InfoItem label="default">
<code>
{"'"}horizontal{"'"}
</code>
</Library.InfoItem>
</Library.Info>
</Library.Example>
<Library.Example title="disabled">
<Library.Info>
<Library.InfoItem label="description">
If true, it will disable all radios, regardless of the value of
their own <code>disabled</code> prop.
<br />
Disabled radios are never marked as selected.
</Library.InfoItem>
<Library.InfoItem label="type">
<code>boolean</code>
</Library.InfoItem>
<Library.InfoItem label="default">
<code>false</code>
</Library.InfoItem>
</Library.Info>
<Library.Demo
title="Disabled RadioGroup"
withSource
exampleFile="radio-group-disabled"
/>
</Library.Example>
<Library.Example title="name">
<Library.Info>
<Library.InfoItem label="description">
When provided, a hidden <code>input</code> will be included, with
this name and selected value, allowing <code>RadioGroup</code> to
be used as a regular form control.
</Library.InfoItem>
<Library.InfoItem label="type">
<code>string | undefined</code>
</Library.InfoItem>
<Library.InfoItem label="default">
<code>undefined</code>
</Library.InfoItem>
</Library.Info>
<Library.Demo
title="Named RadioGroup in form"
withSource
exampleFile="radio-group-in-form"
/>
</Library.Example>
<Library.Example title="onChange">
<Library.Info>
<Library.InfoItem label="description">
A callback invoked when selected radio changes.
</Library.InfoItem>
<Library.InfoItem label="type">
<code>(newValue: T) {'=>'} void</code>
</Library.InfoItem>
</Library.Info>
</Library.Example>
<Library.Example title="selected">
<Library.Info>
<Library.InfoItem label="description">
The value for currently selected radio.
</Library.InfoItem>
<Library.InfoItem label="type">
<code>T | undefined</code>
</Library.InfoItem>
<Library.InfoItem label="default">
<code>undefined</code>
</Library.InfoItem>
</Library.Info>
</Library.Example>
</Library.Pattern>
</Library.Page>
);
}
Loading

0 comments on commit 8b48357

Please sign in to comment.