Skip to content

Commit

Permalink
feat(radio): ✨ add radio component
Browse files Browse the repository at this point in the history
  • Loading branch information
navin-moorthy committed Sep 14, 2021
1 parent 55c687f commit d45813e
Show file tree
Hide file tree
Showing 14 changed files with 614 additions and 7 deletions.
2 changes: 1 addition & 1 deletion docs-templates/checkbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ js: src/checkbox/stories/templates/CheckboxBasicJsx.ts
- When not checked, Checkbox has `aria-checked` set to `false`.
- When partially checked, Checkbox has `aria-checked` set to `mixed`.
- When Checkbox is not rendered as a native input checkbox, Checkbox will add
`role="checkbox`
`role="checkbox"`

<!-- INJECT_COMPOSITION src/checkbox -->

Expand Down
34 changes: 34 additions & 0 deletions docs-templates/radio.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Radio

`Radio` component follows the
[WAI-ARIA Radio Pattern](https://w3c.github.io/aria-practices/#radiobutton) for
it's
[accessibility properties](https://w3c.github.io/aria-practices/#wai-aria-roles-states-and-properties-16).
By default, it renders the native `<input type="radio">`.

<!-- INJECT_TOC -->

## Usage

<!-- IMPORT_EXAMPLE src/radio/stories/templates/RadioBasicJsx.ts -->

<!-- CODESANDBOX
link_title: Radio Basic
js: src/radio/stories/templates/RadioBasicJsx.ts
-->

## Accessibility Requirement

- Radio has role `radio`.
- Radio has aria-checked set to true when it's checked. Otherwise, aria-checked
is set to false.
- Radio extends the accessibility features of CompositeItem, which means it uses
the roving tabindex method to manage focus.
- When Radio is not rendered as a native input checkbox, Radio will add
`role="radio"`
- RadioGroup has role `radiogroup`.
- RadioGroup must has `aria-label` or `aria-labelledby` to describe the group.

<!-- INJECT_COMPOSITION src/radio -->

<!-- INJECT_PROPS src/radio -->
2 changes: 1 addition & 1 deletion docs/checkbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export default Checkbox;
- When not checked, Checkbox has `aria-checked` set to `false`.
- When partially checked, Checkbox has `aria-checked` set to `mixed`.
- When Checkbox is not rendered as a native input checkbox, Checkbox will add
`role="checkbox`
`role="checkbox"`

## Composition

Expand Down
152 changes: 152 additions & 0 deletions docs/radio.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from "./number-input";
export * from "./pagination";
export * from "./picker-base";
export * from "./progress";
export * from "./radio";
export * from "./segment";
export * from "./select";
export * from "./slider";
Expand Down
149 changes: 149 additions & 0 deletions src/radio/Radio.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import {
CompositeItemOptions,
CompositeItemHTMLProps,
useCompositeItem,
} from "reakit";
import * as React from "react";
import { warning } from "reakit-warning/warning";
import { useLiveRef, useForkRef } from "reakit-utils";
import { createComponent, createHook } from "reakit-system";

import { RADIO_KEYS } from "./__keys";
import { RadioStateReturn } from "./RadioState";
import { useInitialChecked, getChecked, fireChange } from "./helpers";

export type RadioOptions = CompositeItemOptions &
Pick<Partial<RadioStateReturn>, "state" | "setState"> & {
/**
* Same as the `value` attribute.
*/
value: string | number;
/**
* Same as the `checked` attribute.
*/
checked?: boolean;
/**
* @private
*/
unstable_checkOnFocus?: boolean;
};

export type RadioHTMLProps = CompositeItemHTMLProps &
React.InputHTMLAttributes<any>;

export type RadioProps = RadioOptions & RadioHTMLProps;

export const useRadio = createHook<RadioOptions, RadioHTMLProps>({
name: "Radio",
compose: useCompositeItem,
keys: RADIO_KEYS,

useOptions(
{ unstable_clickOnEnter = false, unstable_checkOnFocus = true, ...options },
{ value, checked },
) {
return {
checked,
unstable_clickOnEnter,
unstable_checkOnFocus,
...options,
value: options.value ?? value,
};
},

useProps(
options,
{
ref: htmlRef,
onChange: htmlOnChange,
onClick: htmlOnClick,
...htmlProps
},
) {
const {
currentId,
id,
disabled,
setState,
value,
unstable_moves,
unstable_checkOnFocus,
baseId,
} = options;
const ref = React.useRef<HTMLInputElement>(null);
const [isNativeRadio, setIsNativeRadio] = React.useState(true);
const checked = getChecked(options);
const isCurrentItemRef = useLiveRef(currentId === id);
const onChangeRef = useLiveRef(htmlOnChange);
const onClickRef = useLiveRef(htmlOnClick);

useInitialChecked(options);

React.useEffect(() => {
const element = ref.current;
if (!element) {
warning(
true,
"Can't determine whether the element is a native radio because `ref` wasn't passed to the component",
"See https://reakit.io/docs/radio",
);
return;
}
if (element.tagName !== "INPUT" || element.type !== "radio") {
setIsNativeRadio(false);
}
}, []);

const onChange = React.useCallback(
(event: React.ChangeEvent) => {
onChangeRef.current?.(event);

if (event.defaultPrevented) return;
if (disabled) return;

setState?.(value);
},
[disabled, onChangeRef, setState, value],
);

const onClick = React.useCallback(
(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
onClickRef.current?.(event);

if (event.defaultPrevented) return;
if (isNativeRadio) return;

fireChange(event.currentTarget, onChange);
},
[onClickRef, isNativeRadio, onChange],
);

React.useEffect(() => {
const element = ref.current;
if (!element) return;

if (unstable_moves && isCurrentItemRef.current && unstable_checkOnFocus) {
fireChange(element, onChange);
}
}, [unstable_moves, unstable_checkOnFocus, onChange, isCurrentItemRef]);

return {
ref: useForkRef(ref, htmlRef),
role: !isNativeRadio ? "radio" : undefined,
type: isNativeRadio ? "radio" : undefined,
value: isNativeRadio ? value : undefined,
name: isNativeRadio ? baseId : undefined,
"aria-checked": checked,
checked,
onChange,
onClick,
...htmlProps,
};
},
});

export const Radio = createComponent({
as: "input",
memo: true,
useHook: useRadio,
});
36 changes: 36 additions & 0 deletions src/radio/RadioGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as React from "react";
import { useWarning } from "reakit-warning";
import { CompositeOptions, CompositeHTMLProps, useComposite } from "reakit";
import { createComponent, createHook, useCreateElement } from "reakit-system";

import { RADIO_GROUP_KEYS } from "./__keys";

export type RadioGroupOptions = CompositeOptions;

export type RadioGroupHTMLProps = CompositeHTMLProps &
React.FieldsetHTMLAttributes<any>;

export type RadioGroupProps = RadioGroupOptions & RadioGroupHTMLProps;

const useRadioGroup = createHook<RadioGroupOptions, RadioGroupHTMLProps>({
name: "RadioGroup",
compose: useComposite,
keys: RADIO_GROUP_KEYS,

useProps(options, htmlProps) {
return { role: "radiogroup", ...htmlProps };
},
});

export const RadioGroup = createComponent({
as: "div",
useHook: useRadioGroup,
useCreateElement: (type, props, children) => {
useWarning(
!props["aria-label"] && !props["aria-labelledby"],
"You should provide either `aria-label` or `aria-labelledby` props.",
"See https://reakit.io/docs/radio",
);
return useCreateElement(type, props, children);
},
});
65 changes: 65 additions & 0 deletions src/radio/RadioState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import * as React from "react";
import { useControllableState } from "../utils";
import {
CompositeState,
CompositeActions,
CompositeInitialState,
useCompositeState,
} from "reakit";

export type RadioState = CompositeState & {
/**
* The `value` attribute of the current checked radio.
*/
state: string | number | null;
};

export type RadioActions = CompositeActions & {
/**
* Sets `state`.
*/
setState: React.Dispatch<React.SetStateAction<string | number | null>>;
};

export type RadioInitialState = CompositeInitialState & {
/**
* Default State of the Checkbox for uncontrolled Checkbox.
*
* @default false
*/
defaultState?: RadioState["state"];

/**
* State of the Checkbox for controlled Checkbox..
*/
state?: RadioState["state"];

/**
* OnChange callback for controlled Checkbox.
*/
onStateChange?: RadioActions["setState"];
};

export type RadioStateReturn = RadioState & RadioActions;

export function useRadioState(props: RadioInitialState): RadioStateReturn {
const {
defaultState,
state: stateProps,
onStateChange,
loop = true,
...sealed
} = props;
const [state, setState] = useControllableState({
defaultValue: defaultState,
value: stateProps,
onChange: onStateChange,
});
const composite = useCompositeState({ ...sealed, loop });

return {
...composite,
state,
setState,
};
}
63 changes: 63 additions & 0 deletions src/radio/__keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Automatically generated
export const USE_RADIO_STATE_KEYS = [
"baseId",
"unstable_virtual",
"rtl",
"orientation",
"currentId",
"loop",
"wrap",
"shift",
"unstable_includesBaseElement",
"defaultState",
"state",
"onStateChange",
] as const;
export const RADIO_STATE_KEYS = [
"baseId",
"unstable_idCountRef",
"unstable_virtual",
"rtl",
"orientation",
"items",
"groups",
"currentId",
"loop",
"wrap",
"shift",
"unstable_moves",
"unstable_hasActiveWidget",
"unstable_includesBaseElement",
"state",
"setBaseId",
"registerItem",
"unregisterItem",
"registerGroup",
"unregisterGroup",
"move",
"next",
"previous",
"up",
"down",
"first",
"last",
"sort",
"unstable_setVirtual",
"setRTL",
"setOrientation",
"setCurrentId",
"setLoop",
"setWrap",
"setShift",
"reset",
"unstable_setIncludesBaseElement",
"unstable_setHasActiveWidget",
"setState",
] as const;
export const RADIO_KEYS = [
...RADIO_STATE_KEYS,
"value",
"checked",
"unstable_checkOnFocus",
] as const;
export const RADIO_GROUP_KEYS = RADIO_STATE_KEYS;
Loading

0 comments on commit d45813e

Please sign in to comment.