diff --git a/app/javascript/mastodon/components/form_fields/toggle.module.css b/app/javascript/mastodon/components/form_fields/toggle.module.css new file mode 100644 index 0000000000..c2d3f57bcc --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/toggle.module.css @@ -0,0 +1,70 @@ +.input { + position: absolute; + opacity: 0; + width: 100%; + height: 100%; + margin: 0; + cursor: pointer; + z-index: 1; +} + +.toggle { + --diameter: 20px; + --padding: 2px; + --transition: 0.2s ease-in-out; + + display: inline-flex; + align-items: center; + border-radius: 9999px; + width: calc(var(--diameter) * 2); + background: var(--color-bg-tertiary); + padding: var(--padding); + transition: background var(--transition); + box-sizing: border-box; +} + +.toggle::before { + content: ''; + height: var(--diameter); + width: var(--diameter); + border-radius: 9999px; + background: var(--color-text-on-brand-base); + box-shadow: 0 2px 4px 0 color-mix(var(--color-black), transparent 75%); + transition: transform var(--transition); +} + +@media (prefers-reduced-motion: reduce) { + .toggle, + .toggle::before { + transition: none; + } +} + +.input:checked + .toggle { + background: var(--color-bg-brand-base); +} + +.input:checked:is(:hover, :focus) + .toggle { + background: var(--color-bg-brand-base-hover); +} + +.input:focus-visible + .toggle { + outline: var(--outline-focus-default); + outline-offset: 2px; +} + +.input:checked + .toggle::before { + transform: translateX(calc(var(--diameter) - (var(--padding) * 2))); +} + +.input:disabled { + cursor: not-allowed; +} + +.input:disabled + .toggle { + opacity: 0.6; +} + +:global([dir='rtl']) .input:checked + .toggle::before { + transform: translateX(calc(-1 * (var(--diameter) - (var(--padding) * 2)))); +} diff --git a/app/javascript/mastodon/components/form_fields/toggle_field.stories.tsx b/app/javascript/mastodon/components/form_fields/toggle_field.stories.tsx new file mode 100644 index 0000000000..260ba4131f --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/toggle_field.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { PlainToggleField, ToggleField } from './toggle_field'; + +const meta = { + title: 'Components/Form Fields/ToggleField', + component: ToggleField, + args: { + label: 'Label', + hint: 'This is a description of this form field', + disabled: false, + size: 20, + }, + argTypes: { + size: { + 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; + +type Story = StoryObj; + +export const Simple: Story = {}; + +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 Disabled: Story = { + args: { + disabled: true, + checked: true, + }, +}; + +export const Plain: Story = { + render(props) { + return ; + }, +}; + +export const Small: Story = { + args: { + size: 12, + }, +}; + +export const Large: Story = { + args: { + size: 36, + }, +}; diff --git a/app/javascript/mastodon/components/form_fields/toggle_field.tsx b/app/javascript/mastodon/components/form_fields/toggle_field.tsx new file mode 100644 index 0000000000..a116c001bc --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/toggle_field.tsx @@ -0,0 +1,52 @@ +import type { ComponentPropsWithoutRef, CSSProperties } from 'react'; +import { forwardRef } from 'react'; + +import classNames from 'classnames'; + +import classes from './toggle.module.css'; +import type { CommonFieldWrapperProps } from './wrapper'; +import { FormFieldWrapper } from './wrapper'; + +type Props = Omit, 'type'> & { + size?: number; +}; + +export const ToggleField = forwardRef< + HTMLInputElement, + Props & CommonFieldWrapperProps +>(({ id, label, hint, hasError, required, ...otherProps }, ref) => ( + + {(inputProps) => ( + + )} + +)); + +ToggleField.displayName = 'ToggleField'; + +export const PlainToggleField = forwardRef( + ({ className, size, ...otherProps }, ref) => ( + <> + +