Skip to content

Commit

Permalink
feat: added RadioButton and RadioGroup components. (#328)
Browse files Browse the repository at this point in the history
# Contents

RadioButton and FormFieldRadioGroup components

## Checklist

- [X] New features/components and bugfixes are covered by tests
- [X] Changesets are created
- [X] Definition of Done is checked

---------

Co-authored-by: Vlad Afanasev <[email protected]>
Co-authored-by: Remy Parzinski <[email protected]>
Co-authored-by: Jaap-Hein Wester <[email protected]>
Co-authored-by: Jaap-Hein Wester <[email protected]>
  • Loading branch information
5 people authored Nov 20, 2024
1 parent 3adfa8d commit a2ce8bc
Show file tree
Hide file tree
Showing 16 changed files with 1,040 additions and 0 deletions.
9 changes: 9 additions & 0 deletions .changeset/mean-books-sleep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@lux-design-system/components-react": major
---

In deze commit:

- Nieuw component: Radio Button
- Nieuw component: Form Field Radio Option
- Nieuw component: Form Field Radio Group
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.lux-radio-group {
display: flex;
row-gap: var(--lux-radio-group-row-gap);
flex-direction: column;
}

.lux-radio-group__options {
display: flex;
row-gap: var(--lux-radio-group-row-gap);
flex-direction: column;
}

.lux-radio-group__fieldset {
border: 0;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
'use client';
import React, { ForwardedRef, forwardRef } from 'react';
import './FormFieldRadioGroup.css';
import { LuxFormFieldRadioOption } from '../form-field-radio-option/FormFieldRadioOption';

interface RadioOption {
value: string;
label: string;
disabled?: boolean;
description?: React.ReactNode;
}

export interface LuxFormFieldRadioGroupProps {
name: string;
label: string;
description?: string;
errorMessage?: string;
options: RadioOption[];
value?: string;
invalid?: boolean;
required?: boolean;
className?: string;
// eslint-disable-next-line no-unused-vars
onChange?: (value: string) => void;
}

const CLASSNAME: { [key: string]: string } = {
fieldset: 'lux-radio-group lux-radio-group__fieldset',
legend: 'utrecht-form-label',
description: 'utrecht-form-field-description',
options: 'lux-radio-group__options',
error: 'utrecht-form-field-error-message',
};

export const LuxFormFieldRadioGroup = forwardRef(
(
{
name,
label,
description,
options,
value,
invalid,
required,
errorMessage = '',
className,
onChange,
}: LuxFormFieldRadioGroupProps,
ref: ForwardedRef<HTMLFieldSetElement>,
) => {
const uniqueId = React.useId();
const descriptionId = description ? `${uniqueId}-description` : undefined;
const errorId = invalid ? `${uniqueId}-error` : undefined;
const legendId = `${uniqueId}-legend`;

// Local state for uncontrolled mode (when no value prop is provided)
const [internalValue, setInternalValue] = React.useState<string>('');

// Check if component is controlled by parent (through value prop)
const isControlled = value !== undefined;

// Use parent value if controlled, otherwise use local state
const currentValue = isControlled ? value : internalValue;

// Handle radio button selection
const handleChange = (newValue: string) => {
// Only update local state in uncontrolled mode
if (!isControlled) {
setInternalValue(newValue);
}
// Always notify parent of changes through onChange
onChange?.(newValue);
};

return (
<fieldset
ref={ref}
className={CLASSNAME.fieldset}
aria-required={required}
aria-invalid={invalid || undefined}
aria-describedby={descriptionId}
role="radiogroup"
aria-labelledby={legendId}
>
<legend className={CLASSNAME.legend} id={legendId}>
{label}
</legend>

{description && (
<p className={CLASSNAME.description} id={descriptionId}>
{description}
</p>
)}
{invalid && (
<p className={CLASSNAME.error} id={errorId}>
{errorMessage}
</p>
)}

<div className={CLASSNAME.options}>
{options.map((option) => {
const {
value: optionValue,
label: optionLabel,
description: optionDescription,
disabled,
...optionRestProps
} = option;
const optionId = `${uniqueId}-${optionValue}`;

return (
<LuxFormFieldRadioOption
key={optionId}
id={optionId}
name={name}
value={optionValue}
label={optionLabel}
checked={currentValue === optionValue}
description={optionDescription}
disabled={disabled}
required={required}
invalid={invalid}
onChange={(e) => handleChange(e.target.value)}
className={className}
{...optionRestProps}
/>
);
})}
</div>
</fieldset>
);
},
);

LuxFormFieldRadioGroup.displayName = 'LuxFormFieldRadioGroup';
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, expect, it, jest } from '@jest/globals';
import { fireEvent, render, screen } from '@testing-library/react';
import { LuxFormFieldRadioGroup } from '../FormFieldRadioGroup';

describe('FormFieldRadioGroup', () => {
const defaultProps = {
name: 'test-group',
label: 'Test Group',
options: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' },
],
};

it('renders radio group with options', () => {
render(<LuxFormFieldRadioGroup {...defaultProps} />);

const radioGroup = screen.getByRole('radiogroup', { name: 'Test Group' });
expect(radioGroup).toBeInTheDocument();

const options = screen.getAllByRole('radio');
expect(options).toHaveLength(3);
});

it('renders radio group with custom className', () => {
render(<LuxFormFieldRadioGroup {...defaultProps} className="custom-class" />);

const radioGroup = screen.getByRole('radiogroup');

const elements = radioGroup.getElementsByClassName('custom-class');
expect(elements.length).toBeGreaterThan(0);
});

it('renders disabled options', () => {
const propsWithDisabled = {
...defaultProps,
options: [
{ value: 'option1', label: 'Option 1', disabled: true },
{ value: 'option2', label: 'Option 2' },
],
};

render(<LuxFormFieldRadioGroup {...propsWithDisabled} />);

const disabledOption = screen.getByRole('radio', { name: 'Option 1' });
const enabledOption = screen.getByRole('radio', { name: 'Option 2' });

expect(disabledOption).toBeDisabled();
expect(enabledOption).not.toBeDisabled();
});

it('renders invalid state for all radio buttons', () => {
render(<LuxFormFieldRadioGroup {...defaultProps} invalid />);

const options = screen.getAllByRole('radio');
options.forEach((option) => {
expect(option).toHaveAttribute('aria-invalid', 'true');
});
});

it('renders required state', () => {
render(<LuxFormFieldRadioGroup {...defaultProps} required />);

const radioGroup = screen.getByRole('radiogroup');
expect(radioGroup).toHaveAttribute('aria-required', 'true');

const options = screen.getAllByRole('radio');
options.forEach((option) => {
expect(option).toHaveAttribute('required');
});
});

it('renders with controlled value', () => {
render(<LuxFormFieldRadioGroup {...defaultProps} value="option2" />);

const selectedOption = screen.getByRole('radio', { name: 'Option 2' });
expect(selectedOption).toBeChecked();
});

it('handles uncontrolled state correctly', () => {
render(<LuxFormFieldRadioGroup {...defaultProps} />);

const option1 = screen.getByRole('radio', { name: 'Option 1' });
const option2 = screen.getByRole('radio', { name: 'Option 2' });

// Initially no option should be checked
expect(option1).not.toBeChecked();
expect(option2).not.toBeChecked();

// Click first option
fireEvent.click(option1);
expect(option1).toBeChecked();
expect(option2).not.toBeChecked();

// Click second option
fireEvent.click(option2);
expect(option1).not.toBeChecked();
expect(option2).toBeChecked();
});

it('calls onChange with selected value', () => {
const onChange = jest.fn();
render(<LuxFormFieldRadioGroup {...defaultProps} onChange={onChange} />);

const option = screen.getByRole('radio', { name: 'Option 1' });
fireEvent.click(option);

expect(onChange).toHaveBeenCalledWith('option1');
});

it('generates unique ids for options', () => {
render(<LuxFormFieldRadioGroup {...defaultProps} />);

const options = screen.getAllByRole('radio');

// Check that IDs are unique
const ids = options.map((option) => option.getAttribute('id'));
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(options.length);

// Check that each radio has a corresponding label
options.forEach((option, index) => {
const optionId = option.getAttribute('id');
const optionLabel = screen.getByText(`Option ${index + 1}`);
expect(optionLabel).toHaveAttribute('for', optionId);
});
});

it('associates legend with FormFieldRadioGroup through aria-labelledby', () => {
render(<LuxFormFieldRadioGroup {...defaultProps} />);

const radioGroup = screen.getByRole('radiogroup');
const legend = screen.getByText(defaultProps.label);

// Get the generated aria-labelledby value
const labelledById = radioGroup.getAttribute('aria-labelledby');

// Verify the relationship exists
expect(labelledById).toBeTruthy();
expect(legend).toHaveAttribute('id', labelledById);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.lux-radio-button__container {
display: inline flex;
align-items: start;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React, { ForwardedRef, forwardRef, PropsWithChildren } from 'react';
import { LuxFormFieldDescription } from '../form-field-description/FormFieldDescription';
import { LuxFormFieldLabel } from '../form-field-label/FormFieldLabel';
import { LuxRadioButton, type LuxRadioButtonProps } from '../radio-button/RadioButton';
import './FormFieldRadioOption.css';

export type LuxFormFieldRadioOptionProps = LuxRadioButtonProps & {
label: string | React.ReactNode;
description?: React.ReactNode;
checked?: boolean;
};

const CLASSNAME = {
container: 'lux-radio-button__container',
};

export const LuxFormFieldRadioOption = forwardRef(
(
{
disabled,
className,
invalid,
name,
label,
description,
id,
checked,
value,
...restProps
}: PropsWithChildren<LuxFormFieldRadioOptionProps>,
ref: ForwardedRef<HTMLInputElement>,
) => {
const radioId = id || React.useId();

return (
<div className={CLASSNAME.container}>
<LuxRadioButton
ref={ref}
aria-invalid={invalid || undefined}
disabled={disabled}
id={radioId}
name={name}
value={value}
className={className}
checked={checked}
{...restProps}
/>
<div>
<LuxFormFieldLabel htmlFor={radioId} type="radio" disabled={disabled}>
{label}
</LuxFormFieldLabel>
{description ? <LuxFormFieldDescription>{description}</LuxFormFieldDescription> : null}
</div>
</div>
);
},
);

LuxFormFieldRadioOption.displayName = 'LuxFormFieldRadioOption';
Loading

0 comments on commit a2ce8bc

Please sign in to comment.