Skip to content

Commit

Permalink
refactor(odyssey-react): cleanup RadioButton and RadioGroup
Browse files Browse the repository at this point in the history
  • Loading branch information
jeffbelser-okta committed Jul 19, 2021
1 parent ee2857a commit 255dc07
Show file tree
Hide file tree
Showing 11 changed files with 469 additions and 548 deletions.
Expand Up @@ -12,13 +12,12 @@

import type { Story } from "@storybook/react";
import React from "react";
import RadioButtonGroup from ".";
import RadioButton from "../RadioButton";
import type { Props } from ".";
import Radio from ".";
import type { Props } from "./RadioGroup";

export default {
title: `Components/RadioButtonGroup`,
component: RadioButtonGroup,
title: `Components/Radio`,
component: Radio.Group,
args: {
legend: 'Select speed',
name: 'speed',
Expand All @@ -28,20 +27,21 @@ export default {
legend: { control: 'text' },
required: { control: 'boolean' },
disabled: { control: 'boolean' },
value: { control: 'text' },
value: {
control: 'radio',
options: ['light', 'warp', 'ludicrous']
},
name: { control: 'text' },
onChange: { control: false },
onBlur: { control: false },
onFocus: { control: false }
},
};

const Template: Story<Props> = (args) => (
<RadioButtonGroup { ...args } >
<RadioButton label="Lightspeed" value="light" />
<RadioButton label="Warp speed" value="warp" />
<RadioButton label="Ludicrous speed" value="ludicrous" />
</RadioButtonGroup>
<Radio.Group {...args} >
<Radio.Button label="Lightspeed" value="light" />
<Radio.Button label="Warp speed" value="warp" />
<Radio.Button label="Ludicrous speed" value="ludicrous" />
</Radio.Group>
);

export const Default = Template.bind({});
Expand Down
126 changes: 126 additions & 0 deletions packages/odyssey-react/src/components/Radio/Radio.test.tsx
@@ -0,0 +1,126 @@
/*!
* Copyright (c) 2021-present, Okta, Inc. and/or its affiliates. All rights reserved.
* The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
*
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and limitations under the License.
*/

import React from "react";
import { render, fireEvent, screen, within } from "@testing-library/react";
import Radio from ".";

const radioRole = 'radio';
const groupRole = 'group';
const legend = 'Select speed';
const name = 'speed';

const warpLabel = 'Warp speed';
const warpValue = 'warp';

const tree = (props: Record<string, unknown> = {}) => (
<Radio.Group {...props} legend={legend} name={name}>
<Radio.Button label="Lightspeed" value="light" />
<Radio.Button label="Warp speed" value="warp" />
<Radio.Button label="Ludicrous speed" value="ludicrous" />
</Radio.Group>
);

describe("Radio", () => {
it('renders visibly into the document', () => {
expect.assertions(4);
render(tree());

const group = screen.getByRole(groupRole, { name: legend });
expect(group).toBeVisible();

const radios = within(group).getAllByRole(radioRole);
radios.forEach((radio) => {
expect(radio).toBeVisible();
});
});

it('renders through children that are not expected Radio.Button', () => {
render(
// @ts-expect-error Radio.Group does not accept text as children
<Radio.Group legend={legend} name={name}>
oops
</Radio.Group>
);

expect(screen.getByRole(groupRole)).toContainHTML('oops');
});

it('renders a controlled checked input when provided with a valid value', () => {
render(tree({ value: warpValue }));

const radio = screen.getByLabelText(warpLabel);
expect(radio).toBeChecked();
});

it('renders a uncontrolled checked input when a Radio.Button is checked', () => {
render(tree());

const radio = screen.getByLabelText(warpLabel);

fireEvent.click(radio);
expect(radio).toBeChecked();
});

it('renders hint text when provided', () => {
const hint = 'Time is relative';
render(tree({ hint }));

const hintElement = screen.getByText(hint);
expect(hintElement).toBeVisible();
});

it('invokes ref with expected args after render', () => {
const ref = jest.fn();

render(tree({ ref }));

expect(ref).toHaveBeenCalledTimes(1);
expect(ref).toHaveBeenLastCalledWith(screen.getByRole(groupRole));
});

it('invokes onChange with expected args when change input event fires', () => {
const onChange = jest.fn();
render(tree({ onChange }));

const radio = screen.getByLabelText(warpLabel);
fireEvent.click(radio);

expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({ type: 'change' }),
warpValue
);
});

describe('propagation to Radio.Button children', () => {
it.each([
['name', name],
['disabled', undefined],
['required', undefined],

])('renders %s attribute as expected', (
attr: string, attrValue: string | undefined
) => {
expect.assertions(3);
render(tree({ [attr]: attrValue ?? true }));

const radios = screen.getAllByRole(radioRole);

radios.forEach((radio) => {
expect(radio).toHaveAttribute(attr, attrValue);
});
});
});

a11yCheck(() => render(tree()));
});
88 changes: 88 additions & 0 deletions packages/odyssey-react/src/components/Radio/RadioButton/index.tsx
@@ -0,0 +1,88 @@
/*!
* Copyright (c) 2021-present, Okta, Inc. and/or its affiliates. All rights reserved.
* The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
*
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and limitations under the License.
*/

import React, { forwardRef } from 'react';
import type { ComponentPropsWithRef } from 'react';
import { useRadioGroup } from '../context';
import { useOid, useOmit } from '../../../utils';

export interface Props extends Omit<
ComponentPropsWithRef<'input'>,
'style' | 'className'
> {
/**
* The underlying input element id attribute. Automatically generated if not provided
*/
id?: string,

/**
* The form field label
*/
label: string,

/**
* The underlying input element value attribute
*/
value: string,
}


/**
* RadioButton appears as a ring-shaped UI accompanied by a label.
*/
const RadioButton = forwardRef<HTMLInputElement, Props>((props, ref) => {
const {
id,
label,
value,
...rest
} = props;

const {
value: controlledValue,
onChange,
disabled,
required,
name
} = useRadioGroup();

const oid = useOid(id);

const omitProps = useOmit(rest);

const checked = value === controlledValue;

return (
<>
<input
{...omitProps}
className="ods-radio"
checked={checked}
disabled={disabled}
id={oid}
onChange={onChange}
name={name}
ref={ref}
required={required}
type="radio"
value={value}
/>
<label
children={label}
className="ods-radio--label"
htmlFor={oid}
/>
</>
);
});

export default RadioButton;
129 changes: 129 additions & 0 deletions packages/odyssey-react/src/components/Radio/RadioGroup/index.tsx
@@ -0,0 +1,129 @@
/*!
* Copyright (c) 2021-present, Okta, Inc. and/or its affiliates. All rights reserved.
* The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
*
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and limitations under the License.
*/

import React, { forwardRef } from 'react';
import type {
ChangeEvent,
ReactElement,
ComponentPropsWithRef
} from 'react';
import { RadioGroupProvider } from '../context';
import { useOmit } from '../../../utils';

export interface Props extends Omit<
ComponentPropsWithRef<'fieldset'>,
'onChange' | 'style' | 'className'
> {
/**
* One or more Radio.Button to be used together as a group
*/
children: ReactElement | ReactElement[];

/**
* The form field hint
*/
hint?: string,

/**
* The form field legend
*/
legend: string,

/**
* The underlying input element name attribute for the group
*/
name: string,

/**
* The underlying input element required attribute for the group
* @default true
*/
required?: boolean,

/**
* The underlying input element disabled attribute for the group
* @default false
*/
disabled?: boolean,

/**
* The checked Radio.Button value attribute for a controlled group.
*/
value?: string,

/**
* Callback executed when the input group fires a change event
* @param {Object} event the event object
* @param {string} value the string value of the input
*/
onChange?: (event: ChangeEvent<HTMLInputElement>, value: string) => void,
}


/**
* Text inputs allow users to edit and input data.
*/
const RadioGroup = forwardRef<HTMLFieldSetElement, Props>((props, ref) => {
const {
hint,
children,
disabled = false,
legend,
name,
onChange,
required = true,
value,
...rest
} = props;


const legendElement = (
<legend
className="ods-input-legend"
children={legend}
/>
);

const omitProps = useOmit(rest);

const inputElements = (
<div className="ods-fieldset-flex">
<RadioGroupProvider
value={{
disabled,
required,
name,
onChange,
value
}}
children={children}
/>
</div>
);

const hintElement = (
<aside
className="ods-field--hint"
children={hint}
/>
);

return (
<fieldset className="ods-fieldset" ref={ref} {...omitProps}>
{ legendElement}
{ inputElements}
{ hint && hintElement}
</fieldset>
);
});

export default RadioGroup;

0 comments on commit 255dc07

Please sign in to comment.