Toggle component (#37582)

This commit is contained in:
Echo
2026-01-23 13:20:56 +01:00
committed by GitHub
parent 53c620ba69
commit 0a0e253614
4 changed files with 200 additions and 1 deletions

View File

@@ -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))));
}

View File

@@ -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 (
<div className='simple_form'>
<ToggleField {...args} />
</div>
);
},
} satisfies Meta<typeof ToggleField>;
export default meta;
type Story = StoryObj<typeof meta>;
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 <PlainToggleField {...props} />;
},
};
export const Small: Story = {
args: {
size: 12,
},
};
export const Large: Story = {
args: {
size: 36,
},
};

View File

@@ -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<ComponentPropsWithoutRef<'input'>, 'type'> & {
size?: number;
};
export const ToggleField = forwardRef<
HTMLInputElement,
Props & CommonFieldWrapperProps
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
>
{(inputProps) => (
<PlainToggleField {...otherProps} {...inputProps} ref={ref} />
)}
</FormFieldWrapper>
));
ToggleField.displayName = 'ToggleField';
export const PlainToggleField = forwardRef<HTMLInputElement, Props>(
({ className, size, ...otherProps }, ref) => (
<>
<input
{...otherProps}
type='checkbox'
className={classes.input}
ref={ref}
/>
<span
className={classNames(classes.toggle, className)}
style={
{ '--diameter': size ? `${size}px` : undefined } as CSSProperties
}
hidden
/>
</>
),
);
PlainToggleField.displayName = 'PlainToggleField';

View File

@@ -43,7 +43,7 @@ module.exports = {
},
},
{
files: ['app/javascript/**/*.module.scss'],
files: ['app/javascript/**/*.module.scss', 'app/javascript/**/*.module.css'],
rules: {
'selector-pseudo-class-no-unknown': [
true,