[Glitch] Add components RadioButton and Fieldset

Port 29e5532870 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 {
--size: 16px;
--border-width: 1px;
appearance: none;
box-sizing: border-box;
@@ -10,18 +11,26 @@
height: var(--size);
vertical-align: top;
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);
transition: 0.15s ease-out;
transition-property: background-color, border-color;
cursor: pointer;
@supports not (appearance: none) {
accent-color: var(--color-bg-brand-base);
/* 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-secondary);
background: var(--color-bg-tertiary);
border: none;
cursor: not-allowed;
}
@@ -53,9 +62,12 @@
border-color: var(--color-bg-brand-base);
&:disabled {
border-color: var(--color-bg-disabled);
background: var(--color-bg-disabled);
color: var(--color-text-on-disabled);
border: none;
background-color: var(--color-text-disabled);
&::before {
background-color: var(--color-bg-tertiary);
}
}
&::before {

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Checkbox, CheckboxField } from './checkbox_field';
import { Fieldset } from './fieldset';
const meta = {
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 = {
args: {
required: true,
@@ -82,6 +114,6 @@ export const Small: Story = {
export const Large: Story = {
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 {
--form-field-label-gap: 6px;
display: flex;
flex-direction: column;
gap: 6px;
gap: var(--form-field-label-gap);
color: var(--color-text-primary);
font-size: 15px;
&[data-input-placement^='inline'] {
flex-direction: row;
gap: 8px;
--form-field-label-gap: 8px;
}
&[data-input-placement='inline-start'] {
@@ -29,6 +32,10 @@
.label {
font-weight: 500;
&[data-has-parent-fieldset='true'] {
font-weight: normal;
}
[data-has-error='true'] & {
color: var(--color-text-error);
}

View File

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