From a7a33ef6bc61cf10ef654313cfb7a45fe2b58ae3 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Fri, 30 Jan 2026 15:44:28 +0100 Subject: [PATCH] Adds new Checkbox and CheckboxField components (#37665) --- .../form_fields/checkbox.module.scss | 70 +++++++++++++++ .../form_fields/checkbox_field.stories.tsx | 87 +++++++++++++++++++ .../components/form_fields/checkbox_field.tsx | 65 ++++++++++++++ .../form_field_wrapper.module.scss | 44 ++++++++++ .../{wrapper.tsx => form_field_wrapper.tsx} | 26 ++++-- .../mastodon/components/form_fields/index.ts | 3 +- .../form_fields/select_field.stories.tsx | 6 ++ .../components/form_fields/select_field.tsx | 4 +- .../form_fields/text_area_field.stories.tsx | 6 ++ .../form_fields/text_area_field.tsx | 4 +- .../form_fields/text_input_field.stories.tsx | 6 ++ .../form_fields/text_input_field.tsx | 4 +- .../form_fields/toggle_field.stories.tsx | 18 ++-- .../components/form_fields/toggle_field.tsx | 13 ++- .../features/account_timeline/v2/filters.tsx | 6 +- .../mastodon/features/collections/editor.tsx | 4 +- app/javascript/styles/mastodon/forms.scss | 28 +++--- 17 files changed, 344 insertions(+), 50 deletions(-) create mode 100644 app/javascript/mastodon/components/form_fields/checkbox.module.scss create mode 100644 app/javascript/mastodon/components/form_fields/checkbox_field.stories.tsx create mode 100644 app/javascript/mastodon/components/form_fields/checkbox_field.tsx create mode 100644 app/javascript/mastodon/components/form_fields/form_field_wrapper.module.scss rename app/javascript/mastodon/components/form_fields/{wrapper.tsx => form_field_wrapper.tsx} (76%) diff --git a/app/javascript/mastodon/components/form_fields/checkbox.module.scss b/app/javascript/mastodon/components/form_fields/checkbox.module.scss new file mode 100644 index 00000000000..36f029a17b7 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/checkbox.module.scss @@ -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,"); + 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,"); + } + + &: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; + } +} diff --git a/app/javascript/mastodon/components/form_fields/checkbox_field.stories.tsx b/app/javascript/mastodon/components/form_fields/checkbox_field.stories.tsx new file mode 100644 index 00000000000..3f73143ba65 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/checkbox_field.stories.tsx @@ -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; + +export default meta; + +type Story = StoryObj; + +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 ; + }, +}; + +export const Small: Story = { + args: { + size: 14, + }, +}; + +export const Large: Story = { + args: { + size: 64, + }, +}; diff --git a/app/javascript/mastodon/components/form_fields/checkbox_field.tsx b/app/javascript/mastodon/components/form_fields/checkbox_field.tsx new file mode 100644 index 00000000000..2b6933c8473 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/checkbox_field.tsx @@ -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, 'type'> & { + size?: number; + indeterminate?: boolean; +}; + +export const CheckboxField = forwardRef< + HTMLInputElement, + Props & CommonFieldWrapperProps +>(({ id, label, hint, hasError, required, ...otherProps }, ref) => ( + + {(inputProps) => } + +)); + +CheckboxField.displayName = 'CheckboxField'; + +export const Checkbox = forwardRef( + ({ className, size, indeterminate, ...otherProps }, ref) => { + const inputRef = useRef(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 ( + + ); + }, +); + +Checkbox.displayName = 'Checkbox'; diff --git a/app/javascript/mastodon/components/form_fields/form_field_wrapper.module.scss b/app/javascript/mastodon/components/form_fields/form_field_wrapper.module.scss new file mode 100644 index 00000000000..3625b107c7b --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/form_field_wrapper.module.scss @@ -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; +} diff --git a/app/javascript/mastodon/components/form_fields/wrapper.tsx b/app/javascript/mastodon/components/form_fields/form_field_wrapper.tsx similarity index 76% rename from app/javascript/mastodon/components/form_fields/wrapper.tsx rename to app/javascript/mastodon/components/form_fields/form_field_wrapper.tsx index afec04b9f5d..18a877e4bd0 100644 --- a/app/javascript/mastodon/components/form_fields/wrapper.tsx +++ b/app/javascript/mastodon/components/form_fields/form_field_wrapper.tsx @@ -5,7 +5,7 @@ import { useId } from 'react'; import { FormattedMessage } from 'react-intl'; -import classNames from 'classnames'; +import classes from './form_field_wrapper.module.scss'; interface InputProps { id: string; @@ -19,6 +19,7 @@ interface FieldWrapperProps { required?: boolean; hasError?: boolean; inputId?: string; + inputPlacement?: 'inline-start' | 'inline-end'; children: (inputProps: InputProps) => ReactNode; } @@ -42,6 +43,7 @@ export const FormFieldWrapper: FC = ({ hint, required, hasError, + inputPlacement, children, }) => { const uniqueId = useId(); @@ -57,26 +59,32 @@ export const FormFieldWrapper: FC = ({ inputProps['aria-describedby'] = hintId; } + const input = ( +
{children(inputProps)}
+ ); + return (
-
-
); }; diff --git a/app/javascript/mastodon/components/form_fields/index.ts b/app/javascript/mastodon/components/form_fields/index.ts index 8100d560495..8dd693d51e1 100644 --- a/app/javascript/mastodon/components/form_fields/index.ts +++ b/app/javascript/mastodon/components/form_fields/index.ts @@ -1,4 +1,5 @@ export { TextInputField } from './text_input_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'; diff --git a/app/javascript/mastodon/components/form_fields/select_field.stories.tsx b/app/javascript/mastodon/components/form_fields/select_field.stories.tsx index 30897adda1f..762436fe287 100644 --- a/app/javascript/mastodon/components/form_fields/select_field.stories.tsx +++ b/app/javascript/mastodon/components/form_fields/select_field.stories.tsx @@ -35,6 +35,12 @@ type Story = StoryObj; export const Simple: Story = {}; +export const WithoutHint: Story = { + args: { + hint: undefined, + }, +}; + export const Required: Story = { args: { required: true, diff --git a/app/javascript/mastodon/components/form_fields/select_field.tsx b/app/javascript/mastodon/components/form_fields/select_field.tsx index aa058fc782e..e612a215b5d 100644 --- a/app/javascript/mastodon/components/form_fields/select_field.tsx +++ b/app/javascript/mastodon/components/form_fields/select_field.tsx @@ -1,8 +1,8 @@ import type { ComponentPropsWithoutRef } from 'react'; import { forwardRef } from 'react'; -import { FormFieldWrapper } from './wrapper'; -import type { CommonFieldWrapperProps } from './wrapper'; +import { FormFieldWrapper } from './form_field_wrapper'; +import type { CommonFieldWrapperProps } from './form_field_wrapper'; interface Props extends ComponentPropsWithoutRef<'select'>, CommonFieldWrapperProps {} diff --git a/app/javascript/mastodon/components/form_fields/text_area_field.stories.tsx b/app/javascript/mastodon/components/form_fields/text_area_field.stories.tsx index f06a8a223ad..f4b84409169 100644 --- a/app/javascript/mastodon/components/form_fields/text_area_field.stories.tsx +++ b/app/javascript/mastodon/components/form_fields/text_area_field.stories.tsx @@ -25,6 +25,12 @@ type Story = StoryObj; export const Simple: Story = {}; +export const WithoutHint: Story = { + args: { + hint: undefined, + }, +}; + export const Required: Story = { args: { required: true, diff --git a/app/javascript/mastodon/components/form_fields/text_area_field.tsx b/app/javascript/mastodon/components/form_fields/text_area_field.tsx index bfd5ce8a41e..fd514a88e2c 100644 --- a/app/javascript/mastodon/components/form_fields/text_area_field.tsx +++ b/app/javascript/mastodon/components/form_fields/text_area_field.tsx @@ -1,8 +1,8 @@ import type { ComponentPropsWithoutRef } from 'react'; import { forwardRef } from 'react'; -import { FormFieldWrapper } from './wrapper'; -import type { CommonFieldWrapperProps } from './wrapper'; +import { FormFieldWrapper } from './form_field_wrapper'; +import type { CommonFieldWrapperProps } from './form_field_wrapper'; interface Props extends ComponentPropsWithoutRef<'textarea'>, CommonFieldWrapperProps {} diff --git a/app/javascript/mastodon/components/form_fields/text_input_field.stories.tsx b/app/javascript/mastodon/components/form_fields/text_input_field.stories.tsx index e1ee57fdf53..ec00ef5fd39 100644 --- a/app/javascript/mastodon/components/form_fields/text_input_field.stories.tsx +++ b/app/javascript/mastodon/components/form_fields/text_input_field.stories.tsx @@ -25,6 +25,12 @@ type Story = StoryObj; export const Simple: Story = {}; +export const WithoutHint: Story = { + args: { + hint: undefined, + }, +}; + export const Required: Story = { args: { required: true, diff --git a/app/javascript/mastodon/components/form_fields/text_input_field.tsx b/app/javascript/mastodon/components/form_fields/text_input_field.tsx index 3baafbc57c0..3b2d941173f 100644 --- a/app/javascript/mastodon/components/form_fields/text_input_field.tsx +++ b/app/javascript/mastodon/components/form_fields/text_input_field.tsx @@ -1,8 +1,8 @@ import type { ComponentPropsWithoutRef } from 'react'; import { forwardRef } from 'react'; -import { FormFieldWrapper } from './wrapper'; -import type { CommonFieldWrapperProps } from './wrapper'; +import { FormFieldWrapper } from './form_field_wrapper'; +import type { CommonFieldWrapperProps } from './form_field_wrapper'; interface Props extends ComponentPropsWithoutRef<'input'>, CommonFieldWrapperProps {} diff --git a/app/javascript/mastodon/components/form_fields/toggle_field.stories.tsx b/app/javascript/mastodon/components/form_fields/toggle_field.stories.tsx index 260ba4131fc..924c18aa74c 100644 --- a/app/javascript/mastodon/components/form_fields/toggle_field.stories.tsx +++ b/app/javascript/mastodon/components/form_fields/toggle_field.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { PlainToggleField, ToggleField } from './toggle_field'; +import { Toggle, ToggleField } from './toggle_field'; const meta = { title: 'Components/Form Fields/ToggleField', @@ -16,14 +16,6 @@ const meta = { control: { type: 'range', min: 10, max: 40, step: 1 }, }, }, - render(args) { - // Component styles require a wrapper class at the moment - return ( -
- -
- ); - }, } satisfies Meta; export default meta; @@ -32,6 +24,12 @@ type Story = StoryObj; export const Simple: Story = {}; +export const WithoutHint: Story = { + args: { + hint: undefined, + }, +}; + export const Required: Story = { args: { required: true, @@ -60,7 +58,7 @@ export const Disabled: Story = { export const Plain: Story = { render(props) { - return ; + return ; }, }; diff --git a/app/javascript/mastodon/components/form_fields/toggle_field.tsx b/app/javascript/mastodon/components/form_fields/toggle_field.tsx index e14bb54ad12..6cafbcdc360 100644 --- a/app/javascript/mastodon/components/form_fields/toggle_field.tsx +++ b/app/javascript/mastodon/components/form_fields/toggle_field.tsx @@ -3,9 +3,9 @@ import { forwardRef } from 'react'; import classNames from 'classnames'; +import type { CommonFieldWrapperProps } from './form_field_wrapper'; +import { FormFieldWrapper } from './form_field_wrapper'; import classes from './toggle.module.css'; -import type { CommonFieldWrapperProps } from './wrapper'; -import { FormFieldWrapper } from './wrapper'; type Props = Omit, 'type'> & { size?: number; @@ -21,16 +21,15 @@ export const ToggleField = forwardRef< required={required} hasError={hasError} inputId={id} + inputPlacement='inline-end' > - {(inputProps) => ( - - )} + {(inputProps) => } )); ToggleField.displayName = 'ToggleField'; -export const PlainToggleField = forwardRef( +export const Toggle = forwardRef( ({ className, size, ...otherProps }, ref) => ( ( ), ); -PlainToggleField.displayName = 'PlainToggleField'; +Toggle.displayName = 'Toggle'; diff --git a/app/javascript/mastodon/features/account_timeline/v2/filters.tsx b/app/javascript/mastodon/features/account_timeline/v2/filters.tsx index 874d653c207..d9adec13fac 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/filters.tsx +++ b/app/javascript/mastodon/features/account_timeline/v2/filters.tsx @@ -7,7 +7,7 @@ import { useParams } from 'react-router'; 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 KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react'; @@ -119,7 +119,7 @@ const FilterDropdown: FC = () => { defaultMessage='Show replies' /> - { defaultMessage='Show boosts' /> -
-