[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:
parent
7dad362e29
commit
be2df968d3
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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';
|
||||||
Loading…
x
Reference in New Issue
Block a user