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 1874a2b
Show file tree
Hide file tree
Showing 9 changed files with 538 additions and 0 deletions.
117 changes: 117 additions & 0 deletions src/components/input/RadioGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import classnames from 'classnames';
import { useRef } from 'preact/hooks';

import { useArrowKeyNavigation } from '../../hooks/use-arrow-key-navigation';
import { RadioCheckedIcon, RadioIcon } from '../icons';

export type RadioInput<T extends string | number> = {
value: T;
label: string;
subtitle?: string;
disabled?: boolean;
};

export type RadioGroup<T extends string | number> = {
inputs: RadioInput<T>[];
selected?: T;
onChange: (newSelected: T) => void;

/** Determines the direction in which radios are stacked */
direction?: 'vertical' | 'horizontal';

disabled?: boolean;
'aria-label'?: string;
'aria-labelledby'?: string;

/** It adds an actual input if provided */
name?: string;
};

function Radio<T extends string | number>({
input: { value, label, subtitle, disabled },
isSelected,
onChange,
}: {
input: RadioInput<T>;
isSelected: boolean;
onChange: (newSelected: T) => void;
}) {
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,
})}
onClick={() => !disabled && onChange(value)}
onKeyDown={e => {
if (!disabled && ['Enter', ' '].includes(e.key)) {
e.preventDefault();
onChange(value);
}
}}
tabIndex={-1}
>
<div className="flex items-center gap-x-1.5">
{isSelected ? <RadioCheckedIcon /> : <RadioIcon />}
{label}
</div>
{subtitle && (
<div className="pl-4 ml-1.5 mt-1 text-grey-6 text-sm">{subtitle}</div>
)}
</div>
);
}

export default function RadioGroup<T extends string | number>({
direction = 'horizontal',
inputs,
selected,
onChange,
disabled,
'aria-label': label,
'aria-labelledby': labelledBy,
name,
}: RadioGroup<T>) {
const containerRef = useRef<HTMLDivElement | null>(null);

useArrowKeyNavigation(containerRef, {
horizontal: direction === 'horizontal',
vertical: direction === 'vertical',
loop: false,
selector: '[role="radio"]:not([aria-disabled="true"])',
});

return (
<>
<div
aria-label={label}
aria-labelledby={labelledBy}
ref={containerRef}
role="radiogroup"
className={classnames('w-full flex gap-1.5', {
'flex-col': direction === 'vertical',
})}
>
{inputs.map((input, index) => {
const isDisabled = disabled ?? input.disabled;
return (
<Radio
key={`${input.value}${index}`}
input={{ ...input, disabled: isDisabled }}
isSelected={!isDisabled && input.value === selected}
onChange={onChange}
/>
);
})}
</div>
{name && (
<input type="hidden" name={name} value={selected} disabled={disabled} />
)}
</>
);
}
189 changes: 189 additions & 0 deletions src/pattern-library/components/patterns/input/RadioGroupPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import Library from '../../Library';

export default function RadioGroupPage() {
return (
<Library.Page
title="Radio group"
intro={
<p>
<code>RadioGroup</code> is a <code>radiogroup</code> composite
component that includes a list of <code>radio</code>s in it.
</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="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="inputs">
<Library.Info>
<Library.InfoItem label="description">
The list of inputs to render.
</Library.InfoItem>
<Library.InfoItem label="type">
<code>
Array{'<{'} value: T; label: string; subtitle?: string;
disabled?: boolean {'}>'}
</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.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="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.
</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="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="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.Pattern>
</Library.Page>
);
}
29 changes: 29 additions & 0 deletions src/pattern-library/examples/radio-group-aria-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useState } from 'preact/hooks';

import RadioGroup from '../../components/input/RadioGroup';

export default function App() {
const [value, setValue] = useState<'one' | 'two'>('one');

return (
<div className="w-full">
<RadioGroup
aria-label="Items labelled with aria-label"
selected={value}
onChange={setValue}
inputs={[
{
value: 'one',
label: 'First',
subtitle: 'This is the first item',
},
{
value: 'two',
label: 'Second',
subtitle: 'This is the second item',
},
]}
/>
</div>
);
}
31 changes: 31 additions & 0 deletions src/pattern-library/examples/radio-group-aria-labelledby.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useId, useState } from 'preact/hooks';

import RadioGroup from '../../components/input/RadioGroup';

export default function App() {
const [value, setValue] = useState<'one' | 'two'>('one');
const labelId = useId();

return (
<div className="w-full flex flex-col gap-2">
<p id={labelId}>Items labelled with aria-labelledby</p>
<RadioGroup
aria-labelledby={labelId}
selected={value}
onChange={setValue}
inputs={[
{
value: 'one',
label: 'First',
subtitle: 'This is the first item',
},
{
value: 'two',
label: 'Second',
subtitle: 'This is the second item',
},
]}
/>
</div>
);
}
Loading

0 comments on commit 1874a2b

Please sign in to comment.