Adds new Checkbox and CheckboxField components (#37665)

This commit is contained in:
diondiondion 2026-01-30 15:44:28 +01:00 committed by GitHub
parent a75790143c
commit a7a33ef6bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 344 additions and 50 deletions

View File

@ -0,0 +1,70 @@
.checkbox {
--size: 16px;
appearance: none;
box-sizing: border-box;
position: relative;
display: inline-flex;
margin: 0;
width: var(--size);
height: var(--size);
vertical-align: top;
border-radius: calc(var(--size) / 4);
border: 1px 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);
}
&:disabled {
background: var(--color-bg-secondary);
cursor: not-allowed;
}
/* Tick icon */
&::before {
content: '';
opacity: 0;
background-color: var(--color-text-on-brand-base);
display: block;
margin: auto;
width: calc(var(--size) * 0.625);
height: calc(var(--size) * 0.5);
mask-image: url("data:image/svg+xml;utf8,<svg width='10' height='8' viewBox='0 0 10 8' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M8.29289 0.292893C8.68342 -0.0976311 9.31643 -0.0976311 9.70696 0.292893C10.0975 0.683418 10.0975 1.31643 9.70696 1.70696L4.20696 7.20696C3.81643 7.59748 3.18342 7.59748 2.79289 7.20696L0.292893 4.70696C-0.0976311 4.31643 -0.0976311 3.68342 0.292893 3.29289C0.683418 2.90237 1.31643 2.90237 1.70696 3.29289L3.49992 5.08586L8.29289 0.292893Z' fill='black'/></svg>");
mask-position: center;
mask-size: 100%;
mask-repeat: no-repeat;
}
/* 'Minus' icon */
&:indeterminate::before {
width: calc(var(--size) * 0.5);
height: calc(var(--size) * 0.125);
mask-image: url("data:image/svg+xml;utf8,<svg width='8' height='2' viewBox='0 0 8 2' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M7.11111 0C7.60203 0 8 0.447715 8 1C8 1.55228 7.60203 2 7.11111 2H0.888889C0.397969 2 0 1.55228 0 1C0 0.447715 0.397969 0 0.888889 0H7.11111Z' fill='black'/></svg>");
}
&:checked,
&:indeterminate {
background-color: var(--color-bg-brand-base);
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);
}
&::before {
opacity: 1;
}
}
&:focus-visible {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
}

View File

@ -0,0 +1,87 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Checkbox, CheckboxField } from './checkbox_field';
const meta = {
title: 'Components/Form Fields/CheckboxField',
component: CheckboxField,
args: {
label: 'Label',
hint: 'This is a description of this form field',
disabled: false,
},
argTypes: {
size: {
control: { type: 'range', min: 10, max: 64, step: 1 },
},
},
} satisfies Meta<typeof CheckboxField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {};
export const WithoutHint: Story = {
args: {
hint: undefined,
},
};
export const Required: Story = {
args: {
required: true,
},
};
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 Indeterminate: Story = {
args: {
indeterminate: true,
},
};
export const Plain: Story = {
render(props) {
return <Checkbox {...props} />;
},
};
export const Small: Story = {
args: {
size: 14,
},
};
export const Large: Story = {
args: {
size: 64,
},
};

View File

@ -0,0 +1,65 @@
import type { ComponentPropsWithoutRef, CSSProperties } from 'react';
import { forwardRef, useCallback, useEffect, useRef } from 'react';
import classes from './checkbox.module.scss';
import type { CommonFieldWrapperProps } from './form_field_wrapper';
import { FormFieldWrapper } from './form_field_wrapper';
type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
size?: number;
indeterminate?: boolean;
};
export const CheckboxField = forwardRef<
HTMLInputElement,
Props & CommonFieldWrapperProps
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
inputPlacement='inline-start'
>
{(inputProps) => <Checkbox {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper>
));
CheckboxField.displayName = 'CheckboxField';
export const Checkbox = forwardRef<HTMLInputElement, Props>(
({ className, size, indeterminate, ...otherProps }, ref) => {
const inputRef = useRef<HTMLInputElement | null>(null);
const handleRef = useCallback(
(element: HTMLInputElement | null) => {
inputRef.current = element;
if (typeof ref === 'function') {
ref(element);
} else if (ref) {
ref.current = element;
}
},
[ref],
);
useEffect(() => {
if (inputRef.current) {
inputRef.current.indeterminate = indeterminate || false;
}
}, [indeterminate]);
return (
<input
{...otherProps}
type='checkbox'
className={classes.checkbox}
style={size ? ({ '--size': `${size}px` } as CSSProperties) : undefined}
ref={handleRef}
/>
);
},
);
Checkbox.displayName = 'Checkbox';

View File

@ -0,0 +1,44 @@
.wrapper {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--color-text-primary);
font-size: 15px;
&[data-input-placement^='inline'] {
flex-direction: row;
gap: 8px;
}
&[data-input-placement='inline-start'] {
align-items: start;
}
&[data-input-placement='inline-end'] {
align-items: center;
}
}
.labelWrapper {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: 4px;
}
.label {
font-weight: 500;
[data-has-error='true'] & {
color: var(--color-text-error);
}
}
.hint {
color: var(--color-text-secondary);
font-size: 13px;
}
.inputWrapper {
display: block;
}

View File

@ -5,7 +5,7 @@ import { useId } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classes from './form_field_wrapper.module.scss';
interface InputProps { interface InputProps {
id: string; id: string;
@ -19,6 +19,7 @@ interface FieldWrapperProps {
required?: boolean; required?: boolean;
hasError?: boolean; hasError?: boolean;
inputId?: string; inputId?: string;
inputPlacement?: 'inline-start' | 'inline-end';
children: (inputProps: InputProps) => ReactNode; children: (inputProps: InputProps) => ReactNode;
} }
@ -42,6 +43,7 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
hint, hint,
required, required,
hasError, hasError,
inputPlacement,
children, children,
}) => { }) => {
const uniqueId = useId(); const uniqueId = useId();
@ -57,26 +59,32 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
inputProps['aria-describedby'] = hintId; inputProps['aria-describedby'] = hintId;
} }
const input = (
<div className={classes.inputWrapper}>{children(inputProps)}</div>
);
return ( return (
<div <div
className={classNames('input with_block_label', { className={classes.wrapper}
field_with_errors: hasError, data-has-error={hasError}
})} data-input-placement={inputPlacement}
> >
<div className='label_input'> {inputPlacement === 'inline-start' && input}
<label htmlFor={inputId}>
<div className={classes.labelWrapper}>
<label htmlFor={inputId} className={classes.label}>
{label} {label}
{required !== undefined && <RequiredMark required={required} />} {required !== undefined && <RequiredMark required={required} />}
</label> </label>
{hasHint && ( {hasHint && (
<span className='hint' id={hintId}> <span className={classes.hint} id={hintId}>
{hint} {hint}
</span> </span>
)} )}
<div className='label_input__wrapper'>{children(inputProps)}</div>
</div> </div>
{inputPlacement !== 'inline-start' && input}
</div> </div>
); );
}; };

View File

@ -1,4 +1,5 @@
export { TextInputField } from './text_input_field'; export { TextInputField } from './text_input_field';
export { TextAreaField } from './text_area_field'; export { TextAreaField } from './text_area_field';
export { ToggleField, PlainToggleField } from './toggle_field'; export { CheckboxField, Checkbox } from './checkbox_field';
export { ToggleField, Toggle } from './toggle_field';
export { SelectField } from './select_field'; export { SelectField } from './select_field';

View File

@ -35,6 +35,12 @@ type Story = StoryObj<typeof meta>;
export const Simple: Story = {}; export const Simple: Story = {};
export const WithoutHint: Story = {
args: {
hint: undefined,
},
};
export const Required: Story = { export const Required: Story = {
args: { args: {
required: true, required: true,

View File

@ -1,8 +1,8 @@
import type { ComponentPropsWithoutRef } from 'react'; import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import { FormFieldWrapper } from './wrapper'; import { FormFieldWrapper } from './form_field_wrapper';
import type { CommonFieldWrapperProps } from './wrapper'; import type { CommonFieldWrapperProps } from './form_field_wrapper';
interface Props interface Props
extends ComponentPropsWithoutRef<'select'>, CommonFieldWrapperProps {} extends ComponentPropsWithoutRef<'select'>, CommonFieldWrapperProps {}

View File

@ -25,6 +25,12 @@ type Story = StoryObj<typeof meta>;
export const Simple: Story = {}; export const Simple: Story = {};
export const WithoutHint: Story = {
args: {
hint: undefined,
},
};
export const Required: Story = { export const Required: Story = {
args: { args: {
required: true, required: true,

View File

@ -1,8 +1,8 @@
import type { ComponentPropsWithoutRef } from 'react'; import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import { FormFieldWrapper } from './wrapper'; import { FormFieldWrapper } from './form_field_wrapper';
import type { CommonFieldWrapperProps } from './wrapper'; import type { CommonFieldWrapperProps } from './form_field_wrapper';
interface Props interface Props
extends ComponentPropsWithoutRef<'textarea'>, CommonFieldWrapperProps {} extends ComponentPropsWithoutRef<'textarea'>, CommonFieldWrapperProps {}

View File

@ -25,6 +25,12 @@ type Story = StoryObj<typeof meta>;
export const Simple: Story = {}; export const Simple: Story = {};
export const WithoutHint: Story = {
args: {
hint: undefined,
},
};
export const Required: Story = { export const Required: Story = {
args: { args: {
required: true, required: true,

View File

@ -1,8 +1,8 @@
import type { ComponentPropsWithoutRef } from 'react'; import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import { FormFieldWrapper } from './wrapper'; import { FormFieldWrapper } from './form_field_wrapper';
import type { CommonFieldWrapperProps } from './wrapper'; import type { CommonFieldWrapperProps } from './form_field_wrapper';
interface Props interface Props
extends ComponentPropsWithoutRef<'input'>, CommonFieldWrapperProps {} extends ComponentPropsWithoutRef<'input'>, CommonFieldWrapperProps {}

View File

@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';
import { PlainToggleField, ToggleField } from './toggle_field'; import { Toggle, ToggleField } from './toggle_field';
const meta = { const meta = {
title: 'Components/Form Fields/ToggleField', title: 'Components/Form Fields/ToggleField',
@ -16,14 +16,6 @@ const meta = {
control: { type: 'range', min: 10, max: 40, step: 1 }, control: { type: 'range', min: 10, max: 40, step: 1 },
}, },
}, },
render(args) {
// Component styles require a wrapper class at the moment
return (
<div className='simple_form'>
<ToggleField {...args} />
</div>
);
},
} satisfies Meta<typeof ToggleField>; } satisfies Meta<typeof ToggleField>;
export default meta; export default meta;
@ -32,6 +24,12 @@ type Story = StoryObj<typeof meta>;
export const Simple: Story = {}; export const Simple: Story = {};
export const WithoutHint: Story = {
args: {
hint: undefined,
},
};
export const Required: Story = { export const Required: Story = {
args: { args: {
required: true, required: true,
@ -60,7 +58,7 @@ export const Disabled: Story = {
export const Plain: Story = { export const Plain: Story = {
render(props) { render(props) {
return <PlainToggleField {...props} />; return <Toggle {...props} />;
}, },
}; };

View File

@ -3,9 +3,9 @@ import { forwardRef } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import type { CommonFieldWrapperProps } from './form_field_wrapper';
import { FormFieldWrapper } from './form_field_wrapper';
import classes from './toggle.module.css'; import classes from './toggle.module.css';
import type { CommonFieldWrapperProps } from './wrapper';
import { FormFieldWrapper } from './wrapper';
type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & { type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
size?: number; size?: number;
@ -21,16 +21,15 @@ export const ToggleField = forwardRef<
required={required} required={required}
hasError={hasError} hasError={hasError}
inputId={id} inputId={id}
inputPlacement='inline-end'
> >
{(inputProps) => ( {(inputProps) => <Toggle {...otherProps} {...inputProps} ref={ref} />}
<PlainToggleField {...otherProps} {...inputProps} ref={ref} />
)}
</FormFieldWrapper> </FormFieldWrapper>
)); ));
ToggleField.displayName = 'ToggleField'; ToggleField.displayName = 'ToggleField';
export const PlainToggleField = forwardRef<HTMLInputElement, Props>( export const Toggle = forwardRef<HTMLInputElement, Props>(
({ className, size, ...otherProps }, ref) => ( ({ className, size, ...otherProps }, ref) => (
<span className={classes.wrapper}> <span className={classes.wrapper}>
<input <input
@ -49,4 +48,4 @@ export const PlainToggleField = forwardRef<HTMLInputElement, Props>(
</span> </span>
), ),
); );
PlainToggleField.displayName = 'PlainToggleField'; Toggle.displayName = 'Toggle';

View File

@ -7,7 +7,7 @@ import { useParams } from 'react-router';
import Overlay from 'react-overlays/esm/Overlay'; import Overlay from 'react-overlays/esm/Overlay';
import { PlainToggleField } from '@/mastodon/components/form_fields/toggle_field'; import { Toggle } from '@/mastodon/components/form_fields';
import { Icon } from '@/mastodon/components/icon'; import { Icon } from '@/mastodon/components/icon';
import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react'; import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
@ -119,7 +119,7 @@ const FilterDropdown: FC = () => {
defaultMessage='Show replies' defaultMessage='Show replies'
/> />
</label> </label>
<PlainToggleField <Toggle
name='replies' name='replies'
checked={replies} checked={replies}
onChange={handleChange} onChange={handleChange}
@ -132,7 +132,7 @@ const FilterDropdown: FC = () => {
defaultMessage='Show boosts' defaultMessage='Show boosts'
/> />
</label> </label>
<PlainToggleField <Toggle
name='boosts' name='boosts'
checked={boosts} checked={boosts}
onChange={handleChange} onChange={handleChange}

View File

@ -16,7 +16,7 @@ import type {
import { Button } from 'mastodon/components/button'; import { Button } from 'mastodon/components/button';
import { Column } from 'mastodon/components/column'; import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header'; import { ColumnHeader } from 'mastodon/components/column_header';
import { TextAreaField, ToggleField } from 'mastodon/components/form_fields'; import { CheckboxField, TextAreaField } from 'mastodon/components/form_fields';
import { TextInputField } from 'mastodon/components/form_fields/text_input_field'; import { TextInputField } from 'mastodon/components/form_fields/text_input_field';
import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { import {
@ -194,7 +194,7 @@ const CollectionSettings: React.FC<{
</div> </div>
<div className='fields-group'> <div className='fields-group'>
<ToggleField <CheckboxField
label={ label={
<FormattedMessage <FormattedMessage
id='collections.mark_as_sensitive' id='collections.mark_as_sensitive'

View File

@ -549,8 +549,8 @@ code {
input[type='email'], input[type='email'],
input[type='password'], input[type='password'],
input[type='datetime-local'] { input[type='datetime-local'] {
&:focus:invalid:not(:placeholder-shown), &:focus:user-invalid:not(:placeholder-shown),
&:required:invalid:not(:placeholder-shown) { &:required:user-invalid:not(:placeholder-shown) {
border-color: var(--color-text-error); border-color: var(--color-text-error);
} }
} }
@ -560,16 +560,6 @@ code {
color: var(--color-text-error); color: var(--color-text-error);
} }
input[type='text'],
input[type='number'],
input[type='email'],
input[type='password'],
input[type='datetime-local'],
textarea,
select {
border-color: var(--color-text-error);
}
.error { .error {
display: block; display: block;
font-weight: 500; font-weight: 500;
@ -732,9 +722,23 @@ code {
} }
} }
.simple_form .input.field_with_errors,
[data-has-error='true'] {
input[type='text'],
input[type='number'],
input[type='email'],
input[type='password'],
input[type='datetime-local'],
textarea,
select {
border-color: var(--color-text-error);
}
}
/* Double-chevron icon for custom select components */ /* Double-chevron icon for custom select components */
.select-wrapper, .select-wrapper,
.select .label_input__wrapper { .select .label_input__wrapper {
position: relative;
width: 100%; width: 100%;
&::after { &::after {