[Glitch] Add components RadioButton and Fieldset

Port 29e553287039c9e2107ea0bae62a382cc80b9c41 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion 2026-02-02 14:26:31 +01:00 committed by Claire
parent 7dad362e29
commit be2df968d3
9 changed files with 368 additions and 12 deletions

View File

@ -1,5 +1,6 @@
.checkbox { .checkbox {
--size: 16px; --size: 16px;
--border-width: 1px;
appearance: none; appearance: none;
box-sizing: border-box; box-sizing: border-box;
@ -10,18 +11,26 @@
height: var(--size); height: var(--size);
vertical-align: top; vertical-align: top;
border-radius: calc(var(--size) / 4); border-radius: calc(var(--size) / 4);
border: 1px solid var(--color-border-primary); border: var(--border-width) solid var(--color-border-primary);
background-color: var(--color-bg-primary); background-color: var(--color-bg-primary);
transition: 0.15s ease-out; transition: 0.15s ease-out;
transition-property: background-color, border-color; transition-property: background-color, border-color;
cursor: pointer; cursor: pointer;
@supports not (appearance: none) { /* Increase clickable area, prevents misclicks and covers gap between control and label */
accent-color: var(--color-bg-brand-base); &::after {
content: '';
position: absolute;
--spread: calc(var(--border-width) + var(--form-field-label-gap, 8px));
inset-inline: calc(-1 * var(--spread));
inset-block: calc(-0.75 * var(--spread));
} }
&:disabled { &:disabled {
background: var(--color-bg-secondary); background: var(--color-bg-tertiary);
border: none;
cursor: not-allowed; cursor: not-allowed;
} }
@ -53,9 +62,12 @@
border-color: var(--color-bg-brand-base); border-color: var(--color-bg-brand-base);
&:disabled { &:disabled {
border-color: var(--color-bg-disabled); border: none;
background: var(--color-bg-disabled); background-color: var(--color-text-disabled);
color: var(--color-text-on-disabled);
&::before {
background-color: var(--color-bg-tertiary);
}
} }
&::before { &::before {

View File

@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';
import { Checkbox, CheckboxField } from './checkbox_field'; import { Checkbox, CheckboxField } from './checkbox_field';
import { Fieldset } from './fieldset';
const meta = { const meta = {
title: 'Components/Form Fields/CheckboxField', title: 'Components/Form Fields/CheckboxField',
@ -29,6 +30,37 @@ export const WithoutHint: Story = {
}, },
}; };
export const InFieldset: Story = {
render() {
return (
<Fieldset
legend='Choose your options'
hint='This is a description of this set of options'
>
<CheckboxField label='Option 1' />
<CheckboxField label='Option 2' />
<CheckboxField label='Option 3' defaultChecked />
</Fieldset>
);
},
};
export const InFieldsetHorizontal: Story = {
render() {
return (
<Fieldset
legend='Choose your options'
hint='This is a description of this set of options'
layout='horizontal'
>
<CheckboxField label='Option 1' />
<CheckboxField label='Option 2' />
<CheckboxField label='Option 3' defaultChecked />
</Fieldset>
);
},
};
export const Required: Story = { export const Required: Story = {
args: { args: {
required: true, required: true,
@ -82,6 +114,6 @@ export const Small: Story = {
export const Large: Story = { export const Large: Story = {
args: { args: {
size: 64, size: 36,
}, },
}; };

View File

@ -0,0 +1,19 @@
.fieldset {
display: flex;
flex-direction: column;
gap: 12px;
color: var(--color-text-primary);
font-size: 15px;
}
.fieldsWrapper {
display: flex;
flex-direction: column;
row-gap: 8px;
&[data-layout='horizontal'] {
flex-direction: row;
flex-wrap: wrap;
column-gap: 24px;
}
}

View File

@ -0,0 +1,64 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
import type { ReactNode, FC } from 'react';
import { createContext, useId } from 'react';
import classes from './fieldset.module.scss';
import formFieldWrapperClasses from './form_field_wrapper.module.scss';
interface FieldsetProps {
legend: ReactNode;
hint?: ReactNode;
name?: string;
hasError?: boolean;
layout?: 'vertical' | 'horizontal';
children: ReactNode;
}
export const FieldsetNameContext = createContext<string | undefined>(undefined);
/**
* A fieldset suitable for wrapping a group of checkboxes,
* radio buttons, or other grouped form controls.
*/
export const Fieldset: FC<FieldsetProps> = ({
legend,
hint,
name,
hasError,
layout,
children,
}) => {
const uniqueId = useId();
const labelId = `${uniqueId}-label`;
const hintId = `${uniqueId}-hint`;
const fieldsetName = name || `${uniqueId}-fieldset-name`;
const hasHint = !!hint;
return (
<fieldset
className={classes.fieldset}
data-has-error={hasError}
aria-labelledby={labelId}
aria-describedby={hintId}
>
<div className={formFieldWrapperClasses.labelWrapper}>
<div id={labelId} className={formFieldWrapperClasses.label}>
{legend}
</div>
{hasHint && (
<p id={hintId} className={formFieldWrapperClasses.hint}>
{hint}
</p>
)}
</div>
<div className={classes.fieldsWrapper} data-layout={layout}>
<FieldsetNameContext.Provider value={fieldsetName}>
{children}
</FieldsetNameContext.Provider>
</div>
</fieldset>
);
};

View File

@ -1,13 +1,16 @@
.wrapper { .wrapper {
--form-field-label-gap: 6px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: var(--form-field-label-gap);
color: var(--color-text-primary); color: var(--color-text-primary);
font-size: 15px; font-size: 15px;
&[data-input-placement^='inline'] { &[data-input-placement^='inline'] {
flex-direction: row; flex-direction: row;
gap: 8px;
--form-field-label-gap: 8px;
} }
&[data-input-placement='inline-start'] { &[data-input-placement='inline-start'] {
@ -29,6 +32,10 @@
.label { .label {
font-weight: 500; font-weight: 500;
&[data-has-parent-fieldset='true'] {
font-weight: normal;
}
[data-has-error='true'] & { [data-has-error='true'] & {
color: var(--color-text-error); color: var(--color-text-error);
} }

View File

@ -1,10 +1,11 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
import type { ReactNode, FC } from 'react'; import type { ReactNode, FC } from 'react';
import { useId } from 'react'; import { useContext, useId } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { FieldsetNameContext } from './fieldset';
import classes from './form_field_wrapper.module.scss'; import classes from './form_field_wrapper.module.scss';
interface InputProps { interface InputProps {
@ -51,6 +52,8 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
const hintId = `${inputIdProp || uniqueId}-hint`; const hintId = `${inputIdProp || uniqueId}-hint`;
const hasHint = !!hint; const hasHint = !!hint;
const hasParentFieldset = !!useContext(FieldsetNameContext);
const inputProps: InputProps = { const inputProps: InputProps = {
required, required,
id: inputId, id: inputId,
@ -72,7 +75,11 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
{inputPlacement === 'inline-start' && input} {inputPlacement === 'inline-start' && input}
<div className={classes.labelWrapper}> <div className={classes.labelWrapper}>
<label htmlFor={inputId} className={classes.label}> <label
htmlFor={inputId}
className={classes.label}
data-has-parent-fieldset={hasParentFieldset}
>
{label} {label}
{required !== undefined && <RequiredMark required={required} />} {required !== undefined && <RequiredMark required={required} />}
</label> </label>

View File

@ -0,0 +1,51 @@
.radioButton {
--size: 16px;
--border-width: calc(var(--size) / 4);
appearance: none;
box-sizing: border-box;
position: relative;
display: inline-flex;
margin: 0;
width: var(--size);
height: var(--size);
vertical-align: top;
border-radius: 100%;
border: var(--border-width) solid transparent;
box-shadow: 0 0 0 1px var(--color-border-primary);
background-color: var(--color-bg-primary);
transition: 0.15s ease-out;
transition-property: border-color;
cursor: pointer;
/* Increase clickable area, prevents misclicks and covers gap between control and label */
&::after {
content: '';
position: absolute;
--spread: calc(var(--border-width) + var(--form-field-label-gap, 8px));
inset-inline: calc(-1 * var(--spread));
inset-block: calc(-0.75 * var(--spread));
}
&:disabled {
background: var(--color-bg-tertiary);
box-shadow: none;
cursor: not-allowed;
}
&:checked {
border-color: var(--color-bg-brand-base);
box-shadow: none;
&:disabled {
border-color: var(--color-text-disabled);
}
}
&:focus-visible {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
}

View File

@ -0,0 +1,108 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Fieldset } from './fieldset';
import { RadioButton, RadioButtonField } from './radio_button_field';
const meta = {
title: 'Components/Form Fields/RadioButtonField',
component: RadioButtonField,
args: {
label: 'Label',
hint: 'This is a description of this form field',
checked: false,
disabled: false,
},
argTypes: {
size: {
control: { type: 'range', min: 10, max: 64, step: 1 },
},
},
} satisfies Meta<typeof RadioButtonField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {};
export const WithoutHint: Story = {
args: {
hint: undefined,
},
};
export const InFieldset: Story = {
render() {
return (
<Fieldset
legend='Choose one option'
hint='This is a description of this set of options'
>
<RadioButtonField label='Option 1' defaultChecked />
<RadioButtonField label='Option 2' />
<RadioButtonField label='Option 3' />
</Fieldset>
);
},
};
export const InFieldsetHorizontal: Story = {
render() {
return (
<Fieldset
legend='Choose one option'
hint='This is a description of this set of options'
layout='horizontal'
>
<RadioButtonField label='Option 1' defaultChecked />
<RadioButtonField label='Option 2' />
<RadioButtonField label='Option 3' />
</Fieldset>
);
},
};
export const Optional: Story = {
args: {
required: false,
},
};
export const WithError: Story = {
args: {
required: false,
hasError: true,
},
};
export const DisabledChecked: Story = {
args: {
disabled: true,
checked: true,
},
};
export const DisabledUnchecked: Story = {
args: {
disabled: true,
checked: false,
},
};
export const Plain: Story = {
render(props) {
return <RadioButton {...props} />;
},
};
export const Small: Story = {
args: {
size: 14,
},
};
export const Large: Story = {
args: {
size: 36,
},
};

View File

@ -0,0 +1,56 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
import type { ComponentPropsWithoutRef, CSSProperties } from 'react';
import { forwardRef, useContext } from 'react';
import { FieldsetNameContext } from './fieldset';
import type { CommonFieldWrapperProps } from './form_field_wrapper';
import { FormFieldWrapper } from './form_field_wrapper';
import classes from './radio_button.module.scss';
type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
size?: number;
};
export const RadioButtonField = forwardRef<
HTMLInputElement,
Props & CommonFieldWrapperProps
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => {
const fieldsetName = useContext(FieldsetNameContext);
return (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
inputPlacement='inline-start'
>
{(inputProps) => (
<RadioButton
{...otherProps}
{...inputProps}
name={otherProps.name || fieldsetName}
ref={ref}
/>
)}
</FormFieldWrapper>
);
});
RadioButtonField.displayName = 'RadioButtonField';
export const RadioButton = forwardRef<HTMLInputElement, Props>(
({ className, size, ...otherProps }, ref) => (
<input
{...otherProps}
type='radio'
className={classes.radioButton}
style={size ? ({ '--size': `${size}px` } as CSSProperties) : undefined}
ref={ref}
/>
),
);
RadioButton.displayName = 'RadioButton';