Skip to content

Commit fe40f4a

Browse files
authored
Add support for isLabelWrapped and component in Checkbox / Radio (#9830)
* feat(Checkbox): add support for isLabelWrapped and isLabelBeforeButton * feat(Radio): add support for component * refactor(Radio/Checkbox): check isLabelBeforeButton only once * feat(Radio/Checkbox): add support for component === "label" behaving the same as isLabelWrapped * docs(Radio/Checkbox): update props description * test(Checkbox): add unit tests * test(Checkbox): unit test * test(Radio): unit tests
1 parent f4175ff commit fe40f4a

4 files changed

Lines changed: 184 additions & 67 deletions

File tree

packages/react-core/src/components/Checkbox/Checkbox.tsx

Lines changed: 63 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,16 @@ import { ASTERISK } from '../../helpers/htmlConstants';
88
export interface CheckboxProps
99
extends Omit<React.HTMLProps<HTMLInputElement>, 'type' | 'onChange' | 'disabled' | 'label'>,
1010
OUIAProps {
11-
/** Additional classes added to the checkbox. */
11+
/** Additional classes added to the checkbox wrapper. This wrapper will be div element by default. It will be a label element if
12+
* isLabelWrapped is true, or it can be overridden by any element specified in the component prop.
13+
*/
1214
className?: string;
13-
/** Additional classed added to the radio input */
15+
/** Additional classes added to the checkbox input. */
1416
inputClassName?: string;
17+
/** Flag to indicate whether the checkbox wrapper element is a <label> element for the checkbox input. Will not apply if a component prop (with a value other than a "label") is specified. */
18+
isLabelWrapped?: boolean;
19+
/** Flag to show if the checkbox label is shown before the checkbox input. */
20+
isLabelBeforeButton?: boolean;
1521
/** Flag to show if the checkbox selection is valid or invalid. */
1622
isValid?: boolean;
1723
/** Flag to show if the checkbox is disabled. */
@@ -33,7 +39,7 @@ export interface CheckboxProps
3339
description?: React.ReactNode;
3440
/** Body text of the checkbox */
3541
body?: React.ReactNode;
36-
/** Sets the input wrapper component to render. Defaults to <div> */
42+
/** Sets the checkbox wrapper component to render. Defaults to "div". If set to "label", behaves the same as if isLabelWrapped prop was specified. */
3743
component?: React.ElementType;
3844
/** Value to overwrite the randomly generated data-ouia-component-id.*/
3945
ouiaId?: number | string;
@@ -52,13 +58,13 @@ class Checkbox extends React.Component<CheckboxProps, CheckboxState> {
5258
static displayName = 'Checkbox';
5359
static defaultProps: PickOptional<CheckboxProps> = {
5460
className: '',
61+
isLabelWrapped: false,
5562
isValid: true,
5663
isDisabled: false,
5764
isRequired: false,
5865
isChecked: false,
5966
onChange: defaultOnChange,
60-
ouiaSafe: true,
61-
component: 'div'
67+
ouiaSafe: true
6268
};
6369

6470
constructor(props: any) {
@@ -78,6 +84,8 @@ class Checkbox extends React.Component<CheckboxProps, CheckboxState> {
7884
className,
7985
inputClassName,
8086
onChange,
87+
isLabelWrapped,
88+
isLabelBeforeButton,
8189
isValid,
8290
isDisabled,
8391
isRequired,
@@ -89,7 +97,7 @@ class Checkbox extends React.Component<CheckboxProps, CheckboxState> {
8997
body,
9098
ouiaId,
9199
ouiaSafe,
92-
component: Component,
100+
component,
93101
...props
94102
} = this.props;
95103
if (!props.id) {
@@ -107,31 +115,57 @@ class Checkbox extends React.Component<CheckboxProps, CheckboxState> {
107115
checkedProps.defaultChecked = defaultChecked;
108116
}
109117

118+
const inputRendered = (
119+
<input
120+
{...props}
121+
className={css(styles.checkInput, inputClassName)}
122+
type="checkbox"
123+
onChange={this.handleChange}
124+
aria-invalid={!isValid}
125+
aria-label={ariaLabel}
126+
disabled={isDisabled}
127+
required={isRequired}
128+
ref={(elem) => elem && (elem.indeterminate = isChecked === null)}
129+
{...checkedProps}
130+
{...getOUIAProps(Checkbox.displayName, ouiaId !== undefined ? ouiaId : this.state.ouiaStateId, ouiaSafe)}
131+
/>
132+
);
133+
134+
const wrapWithLabel = (isLabelWrapped && !component) || component === 'label';
135+
136+
const Label = wrapWithLabel ? 'span' : 'label';
137+
const labelRendered = label ? (
138+
<Label
139+
className={css(styles.checkLabel, isDisabled && styles.modifiers.disabled)}
140+
htmlFor={!wrapWithLabel && props.id}
141+
>
142+
{label}
143+
{isRequired && (
144+
<span className={css(styles.checkLabelRequired)} aria-hidden="true">
145+
{ASTERISK}
146+
</span>
147+
)}
148+
</Label>
149+
) : null;
150+
151+
const Component = component ?? (wrapWithLabel ? 'label' : 'div');
152+
110153
checkedProps.checked = checkedProps.checked === null ? false : checkedProps.checked;
111154
return (
112-
<Component className={css(styles.check, !label && styles.modifiers.standalone, className)}>
113-
<input
114-
{...props}
115-
className={css(styles.checkInput, inputClassName)}
116-
type="checkbox"
117-
onChange={this.handleChange}
118-
aria-invalid={!isValid}
119-
aria-label={ariaLabel}
120-
disabled={isDisabled}
121-
required={isRequired}
122-
ref={(elem) => elem && (elem.indeterminate = isChecked === null)}
123-
{...checkedProps}
124-
{...getOUIAProps(Checkbox.displayName, ouiaId !== undefined ? ouiaId : this.state.ouiaStateId, ouiaSafe)}
125-
/>
126-
{label && (
127-
<label className={css(styles.checkLabel, isDisabled && styles.modifiers.disabled)} htmlFor={props.id}>
128-
{label}
129-
{isRequired && (
130-
<span className={css(styles.checkLabelRequired)} aria-hidden="true">
131-
{ASTERISK}
132-
</span>
133-
)}
134-
</label>
155+
<Component
156+
className={css(styles.check, !label && styles.modifiers.standalone, className)}
157+
htmlFor={wrapWithLabel && props.id}
158+
>
159+
{isLabelBeforeButton ? (
160+
<>
161+
{labelRendered}
162+
{inputRendered}
163+
</>
164+
) : (
165+
<>
166+
{inputRendered}
167+
{labelRendered}
168+
</>
135169
)}
136170
{description && <span className={css(styles.checkDescription)}>{description}</span>}
137171
{body && <span className={css(styles.checkBody)}>{body}</span>}

packages/react-core/src/components/Checkbox/__tests__/Checkbox.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,47 @@ test('Renders with the provided component', () => {
231231
expect(screen.getByRole('checkbox').parentElement?.tagName).toBe('SPAN');
232232
});
233233

234+
test('Renders with the label wrapper if isLabelWrapped is provided', () => {
235+
render(<Checkbox id="test-id" isLabelWrapped />);
236+
237+
expect(screen.getByRole('checkbox').parentElement?.tagName).toBe('LABEL');
238+
});
239+
240+
test('Renders with span element around the inner label text if isLabelWrapped is provided', () => {
241+
const labelText = "test checkbox label";
242+
render(<Checkbox id="test-id" isLabelWrapped label={labelText} />);
243+
244+
expect(screen.getByText(labelText).tagName).toBe('SPAN');
245+
});
246+
247+
test('Renders with the provided component although isLabelWrapped is provided', () => {
248+
render(<Checkbox id="test-id" isLabelWrapped component="h3" />);
249+
250+
expect(screen.getByRole('checkbox').parentElement?.tagName).toBe('H3');
251+
});
252+
253+
test('Renders with the label wrapper if component is set to label', () => {
254+
render(<Checkbox id="test-id" component="label" />);
255+
256+
expect(screen.getByRole('checkbox').parentElement?.tagName).toBe('LABEL');
257+
});
258+
259+
test('Renders with span element around the inner label text if component is set to label', () => {
260+
const labelText = "test checkbox label";
261+
render(<Checkbox id="test-id" component="label" label={labelText} />);
262+
263+
expect(screen.getByText(labelText).tagName).toBe('SPAN');
264+
});
265+
266+
test('Renders label before checkbox input if isLabelBeforeButton is provided', () => {
267+
render(<Checkbox id="test-id" isLabelBeforeButton label={"test checkbox label"} />);
268+
269+
const wrapper = screen.getByRole('checkbox').parentElement!;
270+
271+
expect(wrapper.children[0].tagName).toBe('LABEL');
272+
expect(wrapper.children[1].tagName).toBe('INPUT');
273+
});
274+
234275
test(`Spreads additional props`, () => {
235276
render(<Checkbox id="test-id" data-testid="test-id" />);
236277

packages/react-core/src/components/Radio/Radio.tsx

Lines changed: 39 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,17 @@ import { getOUIAProps, OUIAProps, getDefaultOUIAId } from '../../helpers';
77
export interface RadioProps
88
extends Omit<React.HTMLProps<HTMLInputElement>, 'disabled' | 'label' | 'onChange' | 'type'>,
99
OUIAProps {
10-
/** Additional classes added to the radio wrapper. This will be a div element if
11-
* isLabelWrapped is true, otherwise this will be a label element.
10+
/** Additional classes added to the radio wrapper. This wrapper will be div element by default. It will be a label element if
11+
* isLabelWrapped is true, or it can be overridden by any element specified in the component prop.
1212
*/
1313
className?: string;
14-
/** Additional classed added to the radio input */
14+
/** Additional classes added to the radio input. */
1515
inputClassName?: string;
1616
/** Id of the radio. */
1717
id: string;
18-
/** Flag to show if the radio label is wrapped on small screen. */
18+
/** Flag to indicate whether the radio wrapper element is a native label element for the radio input. Will not apply if a component prop (with a value other than a "label") is specified. */
1919
isLabelWrapped?: boolean;
20-
/** Flag to show if the radio label is shown before the radio button. */
20+
/** Flag to show if the radio label is shown before the radio input. */
2121
isLabelBeforeButton?: boolean;
2222
/** Flag to show if the radio is checked. */
2323
checked?: boolean;
@@ -39,6 +39,8 @@ export interface RadioProps
3939
description?: React.ReactNode;
4040
/** Body of the radio. */
4141
body?: React.ReactNode;
42+
/** Sets the radio wrapper component to render. Defaults to "div". If set to "label", behaves the same as if isLabelWrapped prop was specified. */
43+
component?: React.ElementType;
4244
/** Value to overwrite the randomly generated data-ouia-component-id.*/
4345
ouiaId?: number | string;
4446
/** Set the value of data-ouia-safe. Only set to true when the component is in a static state, i.e. no animations are occurring. At all other times, this value must be false. */
@@ -88,6 +90,7 @@ class Radio extends React.Component<RadioProps, { ouiaStateId: string }> {
8890
body,
8991
ouiaId,
9092
ouiaSafe = true,
93+
component,
9194
...props
9295
} = this.props;
9396
if (!props.id) {
@@ -110,41 +113,39 @@ class Radio extends React.Component<RadioProps, { ouiaStateId: string }> {
110113
/>
111114
);
112115

113-
let labelRendered: React.ReactNode = null;
114-
if (label && isLabelWrapped) {
115-
labelRendered = <span className={css(styles.radioLabel, isDisabled && styles.modifiers.disabled)}>{label}</span>;
116-
} else if (label) {
117-
labelRendered = (
118-
<label className={css(styles.radioLabel, isDisabled && styles.modifiers.disabled)} htmlFor={props.id}>
119-
{label}
120-
</label>
121-
);
122-
}
116+
const wrapWithLabel = (isLabelWrapped && !component) || component === 'label';
123117

124-
const descRender = description ? <span className={css(styles.radioDescription)}>{description}</span> : null;
125-
const bodyRender = body ? <span className={css(styles.radioBody)}>{body}</span> : null;
126-
const childrenRendered = isLabelBeforeButton ? (
127-
<>
128-
{labelRendered}
129-
{inputRendered}
130-
{descRender}
131-
{bodyRender}
132-
</>
133-
) : (
134-
<>
135-
{inputRendered}
136-
{labelRendered}
137-
{descRender}
138-
{bodyRender}
139-
</>
140-
);
118+
const Label = wrapWithLabel ? 'span' : 'label';
119+
const labelRendered = label ? (
120+
<Label
121+
className={css(styles.radioLabel, isDisabled && styles.modifiers.disabled)}
122+
htmlFor={!wrapWithLabel && props.id}
123+
>
124+
{label}
125+
</Label>
126+
) : null;
127+
128+
const Component = component ?? (wrapWithLabel ? 'label' : 'div');
141129

142-
return isLabelWrapped ? (
143-
<label className={css(styles.radio, className)} htmlFor={props.id}>
144-
{childrenRendered}
145-
</label>
146-
) : (
147-
<div className={css(styles.radio, !label && styles.modifiers.standalone, className)}>{childrenRendered}</div>
130+
return (
131+
<Component
132+
className={css(styles.radio, !label && styles.modifiers.standalone, className)}
133+
htmlFor={wrapWithLabel && props.id}
134+
>
135+
{isLabelBeforeButton ? (
136+
<>
137+
{labelRendered}
138+
{inputRendered}
139+
</>
140+
) : (
141+
<>
142+
{inputRendered}
143+
{labelRendered}
144+
</>
145+
)}
146+
{description && <span className={css(styles.radioDescription)}>{description}</span>}
147+
{body && <span className={css(styles.radioBody)}>{body}</span>}
148+
</Component>
148149
);
149150
}
150151
}

packages/react-core/src/components/Radio/__tests__/Radio.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,45 @@ describe('Radio', () => {
100100

101101
expect(myMock).toHaveBeenCalled();
102102
});
103+
104+
test('Renders with the label wrapper if isLabelWrapped is provided', () => {
105+
render(<Radio id="test-id" name="check" isLabelWrapped />);
106+
107+
expect(screen.getByRole('radio').parentElement?.tagName).toBe('LABEL');
108+
});
109+
110+
test('Renders with span element around the inner label text if isLabelWrapped is provided', () => {
111+
const labelText = 'test radio label';
112+
render(<Radio id="test-id" name="check" isLabelWrapped label={labelText} />);
113+
114+
expect(screen.getByText(labelText).tagName).toBe('SPAN');
115+
});
116+
117+
test('Renders with the provided component although isLabelWrapped is provided', () => {
118+
render(<Radio id="test-id" name="check" isLabelWrapped component="h3" />);
119+
120+
expect(screen.getByRole('radio').parentElement?.tagName).toBe('H3');
121+
});
122+
123+
test('Renders with the label wrapper if component is set to label', () => {
124+
render(<Radio id="test-id" name="check" component="label" />);
125+
126+
expect(screen.getByRole('radio').parentElement?.tagName).toBe('LABEL');
127+
});
128+
129+
test('Renders with span element around the inner label text if component is set to label', () => {
130+
const labelText = 'test radio label';
131+
render(<Radio id="test-id" name="check" component="label" label={labelText} />);
132+
133+
expect(screen.getByText(labelText).tagName).toBe('SPAN');
134+
});
135+
136+
test('Renders label before radio input if isLabelBeforeButton is provided', () => {
137+
render(<Radio id="test-id" name="check" isLabelBeforeButton label={"test radio label"} />);
138+
139+
const wrapper = screen.getByRole('radio').parentElement!;
140+
141+
expect(wrapper.children[0].tagName).toBe('LABEL');
142+
expect(wrapper.children[1].tagName).toBe('INPUT');
143+
});
103144
});

0 commit comments

Comments
 (0)