Skip to content

Commit

Permalink
Add RadioButton component
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Aug 28, 2024
1 parent c7e9a82 commit 84ab1e6
Show file tree
Hide file tree
Showing 10 changed files with 299 additions and 34 deletions.
46 changes: 40 additions & 6 deletions src/components/input/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import type { IconComponent } from '../../types';
import type { JSX } from 'preact';
import { useState } from 'preact/hooks';

import type { CompositeProps, IconComponent } from '../../types';
import { CheckboxCheckedIcon, CheckboxOutlineIcon } from '../icons';
import ToggleInput, { type ToggleInputProps } from './ToggleInput';
import ToggleInput from './ToggleInput';

type ComponentProps = {
/** Current checked state. Used when the Checkbox is controlled. */
checked?: boolean;

export type CheckboxProps = Omit<
ToggleInputProps,
'icon' | 'checkedIcon' | 'type'
> & {
/**
* Default checked state. Used to set initial state when the Checkbox is not
* controlled.
*/
defaultChecked?: boolean;
/** Custom icon to show when input is unchecked */
icon?: IconComponent;
/** Custom icon to show when input is checked */
Expand All @@ -14,21 +22,47 @@ export type CheckboxProps = Omit<
type?: never;
};

export type CheckboxProps = CompositeProps &
ComponentProps &
Omit<JSX.HTMLAttributes<HTMLInputElement>, 'size' | 'icon'>;

/**
* Render a labeled checkbox input. The checkbox is styled with two icons:
* one for the unchecked state and one for the checked state. The input itself
* is positioned exactly on top of the icon, but is non-visible.
*/
export default function Checkbox({
checked,
defaultChecked = false,
icon = CheckboxOutlineIcon,
checkedIcon = CheckboxCheckedIcon,
onChange,
...rest
}: CheckboxProps) {
// If `checked` is present, treat this as a controlled component
const isControlled = typeof checked === 'boolean';
// Only use this local state if checkbox is uncontrolled
const [uncontrolledChecked, setUncontrolledChecked] =
useState(defaultChecked);
const isChecked = isControlled ? checked : uncontrolledChecked;

function handleChange(
this: void,
event: JSX.TargetedEvent<HTMLInputElement>,
) {
onChange?.call(this, event);
if (!isControlled) {
setUncontrolledChecked((event.target as HTMLInputElement).checked);
}
}

return (
<ToggleInput
icon={icon}
checkedIcon={checkedIcon}
type="checkbox"
checked={isChecked}
onChange={handleChange}
{...rest}
/>
);
Expand Down
35 changes: 35 additions & 0 deletions src/components/input/RadioButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { JSX } from 'preact';

import type { CompositeProps, IconComponent } from '../../types';
import { RadioCheckedIcon, RadioIcon } from '../icons';
import ToggleInput from './ToggleInput';

type ComponentProps = {
checked?: boolean;

/** Custom icon to show when input is unchecked */
icon?: IconComponent;
/** Custom icon to show when input is checked */
checkedIcon?: IconComponent;
/** type is always `radio` */
type?: never;
};

export type RadioButtonProps = CompositeProps &
ComponentProps &
Omit<JSX.HTMLAttributes<HTMLInputElement>, 'size' | 'icon'>;

/**
* Render a labeled radio input. The radio is styled with two icons: one for the
* unchecked state and one for the checked state. The input itself is positioned
* exactly on top of the icon, but is non-visible.
*/
export default function RadioButton({
icon = RadioIcon,
checkedIcon = RadioCheckedIcon,
...rest
}: RadioButtonProps) {
return (
<ToggleInput icon={icon} checkedIcon={checkedIcon} type="radio" {...rest} />
);
}
31 changes: 3 additions & 28 deletions src/components/input/ToggleInput.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
import classnames from 'classnames';
import type { JSX } from 'preact';
import { useState } from 'preact/hooks';

import type { CompositeProps, IconComponent } from '../../types';
import { downcastRef } from '../../util/typing';

type ComponentProps = {
/** Current checked state. Used when the input is controlled. */
checked?: boolean;

/**
* Default checked state. Used to set initial state when the input is not
* controlled.
*/
defaultChecked?: boolean;
/** Custom icon to show when input is unchecked */
icon: IconComponent;
/** Custom icon to show when input is checked */
Expand All @@ -36,7 +29,6 @@ export default function ToggleInput({
elementRef,

checked,
defaultChecked = false,
icon: UncheckedIcon,
checkedIcon: CheckedIcon,

Expand All @@ -46,24 +38,7 @@ export default function ToggleInput({
type,
...htmlAttributes
}: ToggleInputProps) {
// If `checked` is present, treat this as a controlled component
const isControlled = typeof checked === 'boolean';
// Only use this local state if checkbox is uncontrolled
const [uncontrolledChecked, setUncontrolledChecked] =
useState(defaultChecked);
const isChecked = isControlled ? checked : uncontrolledChecked;

function handleChange(
this: void,
event: JSX.TargetedEvent<HTMLInputElement>,
) {
onChange?.call(this, event);
if (!isControlled) {
setUncontrolledChecked((event.target as HTMLInputElement).checked);
}
}

const Icon = isChecked ? CheckedIcon : UncheckedIcon;
const Icon = checked ? CheckedIcon : UncheckedIcon;

return (
<label
Expand Down Expand Up @@ -93,10 +68,10 @@ export default function ToggleInput({
'cursor-pointer': !disabled,
},
)}
checked={isChecked}
checked={checked}
disabled={disabled}
id={id}
onChange={handleChange}
onChange={onChange}
/>
<Icon
className={classnames(
Expand Down
2 changes: 2 additions & 0 deletions src/components/input/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export { default as IconButton } from './IconButton';
export { default as Input } from './Input';
export { default as InputGroup } from './InputGroup';
export { default as OptionButton } from './OptionButton';
export { default as RadioButton } from './RadioButton';
export { Select, MultiSelect } from './Select';
export { default as Textarea } from './Textarea';

Expand All @@ -15,5 +16,6 @@ export type { IconButtonProps } from './IconButton';
export type { InputProps } from './Input';
export type { InputGroupProps } from './InputGroup';
export type { OptionButtonProps } from './OptionButton';
export type { RadioButtonProps } from './RadioButton';
export type { MultiSelectProps, SelectProps } from './Select';
export type { TextareaProps } from './Textarea';
28 changes: 28 additions & 0 deletions src/components/input/test/RadioButton-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { mount } from 'enzyme';

import { testCompositeComponent } from '../../test/common-tests';
import RadioButton from '../RadioButton';

// Relatively simple test, as most of the logic is shared with Checkbox, and
// covered by Checkbox-test
describe('RadioButton', () => {
const createComponent = (props = {}) => {
return mount(<RadioButton {...props}>This is child content</RadioButton>);
};

testCompositeComponent(RadioButton, {
elementSelector: 'input[type="radio"]',
});

it('shows an icon representing radio state', () => {
const wrapper = createComponent();

assert.isTrue(wrapper.exists('RadioIcon'));
assert.isFalse(wrapper.exists('RadioCheckedIcon'));

wrapper.setProps({ checked: true });

assert.isFalse(wrapper.exists('RadioIcon'));
assert.isTrue(wrapper.exists('RadioCheckedIcon'));
});
});
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export {
InputGroup,
MultiSelect,
OptionButton,
RadioButton,
Select,
Textarea,
} from './components/input';
Expand Down Expand Up @@ -120,6 +121,7 @@ export type {
InputGroupProps,
MultiSelectProps,
OptionButtonProps,
RadioButtonProps,
SelectProps,
TextareaProps,
} from './components/input';
Expand Down
106 changes: 106 additions & 0 deletions src/pattern-library/components/patterns/input/RadioButtonPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import Library from '../../Library';

export default function RadioButtonPage() {
return (
<Library.Page
title="Radio button"
intro={
<p>
<code>RadioButton</code> is a composite component that includes a
radio input and label.
</p>
}
>
<Library.Pattern>
<Library.Usage componentName="RadioButton" />
<Library.Example>
<Library.Demo
title="Basic RadioButton"
withSource
exampleFile="radio-button-basic"
/>
</Library.Example>
</Library.Pattern>

<Library.Pattern title="Working with RadioButtons">
<Library.Example title="Controlled RadioButton">
<p>
<code>RadioButton</code>s are always expected to be controlled,
because only one in a group should be checked at once.
</p>
<p>
Because of this, there should be a parent component handling the
state for all of them, as <code>RadioButton</code>s do not know
about each other.
</p>
</Library.Example>

<Library.Example title="Customizing RadioButton icons">
<p>
<code>RadioButton</code> uses icons to style the radio, in unchecked
and checked states. Custom icons may be provided if desired.
</p>
<Library.Demo
withSource
title="RadioButton with custom icon and checkedIcon"
exampleFile="radio-button-custom-icons"
/>
</Library.Example>
</Library.Pattern>

<Library.Pattern title="Component API">
<code>RadioButton</code> accepts all standard{' '}
<Library.Link href="/using-components#presentational-components-api">
presentational component props
</Library.Link>
.
<Library.Example title="checked">
<Library.Info>
<Library.InfoItem label="description">
Set whether the <code>RadioButton</code> is checked. The presence
of this component indicates that the <code>RadioButton</code> is
being used as a controlled component.
</Library.InfoItem>
<Library.InfoItem label="type">
<code>{`boolean`}</code>
</Library.InfoItem>
<Library.InfoItem label="default">
<code>{`undefined`}</code>
</Library.InfoItem>
</Library.Info>
</Library.Example>
<Library.Example title="icon">
<Library.Info>
<Library.InfoItem label="description">
<code>IconComponent</code> to use as the (unchecked) radio icon
</Library.InfoItem>
<Library.InfoItem label="type">
<code>{`IconComponent`}</code>
</Library.InfoItem>
</Library.Info>
</Library.Example>
<Library.Example title="checkedIcon">
<Library.Info>
<Library.InfoItem label="description">
<code>IconComponent</code> to use as the (checked) radio icon
</Library.InfoItem>
<Library.InfoItem label="type">
<code>{`IconComponent`}</code>
</Library.InfoItem>
</Library.Info>
</Library.Example>
<Library.Example title="...htmlAttributes">
<Library.Info>
<Library.InfoItem label="description">
<code>RadioButton</code> accepts HTML attributes for input
elements
</Library.InfoItem>
<Library.InfoItem label="type">
<code>{`JSX.HTMLAttributes<HTMLInputElement>`}</code>
</Library.InfoItem>
</Library.Info>
</Library.Example>
</Library.Pattern>
</Library.Page>
);
}
41 changes: 41 additions & 0 deletions src/pattern-library/examples/radio-button-basic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useCallback, useState } from 'preact/hooks';

import { RadioButton } from '../..';

export default function App() {
const [value, setSelected] = useState<'one' | 'two' | 'three'>();
const onChange = useCallback((e: Event) => {
setSelected(
(e.target as HTMLInputElement).value as 'one' | 'two' | 'three',
);
}, []);

return (
<form className=" flex flex-col">
<RadioButton
name="option"
value="one"
checked={value === 'one'}
onChange={onChange}
>
Click me
</RadioButton>
<RadioButton
name="option"
value="two"
checked={value === 'two'}
onChange={onChange}
>
No, click me
</RadioButton>
<RadioButton
name="option"
value="three"
checked={value === 'three'}
disabled
>
Disabled
</RadioButton>
</form>
);
}
Loading

0 comments on commit 84ab1e6

Please sign in to comment.