generated from nl-design-system/example
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: added RadioButton and RadioGroup components. (#328)
# 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
1 parent
3adfa8d
commit a2ce8bc
Showing
16 changed files
with
1,040 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
15 changes: 15 additions & 0 deletions
15
packages/components-react/src/form-field-radio-group/FormFieldRadioGroup.css
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
135 changes: 135 additions & 0 deletions
135
packages/components-react/src/form-field-radio-group/FormFieldRadioGroup.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
143 changes: 143 additions & 0 deletions
143
packages/components-react/src/form-field-radio-group/test/FormFieldRadioGroup.spec.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
4 changes: 4 additions & 0 deletions
4
packages/components-react/src/form-field-radio-option/FormFieldRadioOption.css
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
.lux-radio-button__container { | ||
display: inline flex; | ||
align-items: start; | ||
} |
59 changes: 59 additions & 0 deletions
59
packages/components-react/src/form-field-radio-option/FormFieldRadioOption.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
Oops, something went wrong.