diff --git a/app/javascript/mastodon/components/form_fields/range_input.module.scss b/app/javascript/mastodon/components/form_fields/range_input.module.scss new file mode 100644 index 0000000000..cbace07dcc --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/range_input.module.scss @@ -0,0 +1,128 @@ +/* + Inspired by: + https://danielstern.ca/range.css + https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/ +*/ + +.input { + --color-bg-thumb: var(--color-bg-brand-base); + --color-bg-thumb-hover: var(--color-bg-brand-base-hover); + --color-bg-track: var(--color-bg-secondary); + + width: 100%; + margin: 6px 0; + background-color: transparent; + appearance: none; + + &:focus { + outline: none; + } + + // Thumb + + &::-webkit-slider-thumb { + margin-top: -6px; + width: 16px; + height: 16px; + background: var(--color-bg-thumb); + border: 0; + border-radius: 50px; + cursor: pointer; + -webkit-appearance: none; + } + + &::-moz-range-thumb { + width: 16px; + height: 16px; + background: var(--color-bg-thumb); + border: 0; + border-radius: 50px; + cursor: pointer; + } + + &::-ms-thumb { + width: 16px; + height: 16px; + background: var(--color-bg-thumb); + border: 0; + border-radius: 50px; + cursor: pointer; + margin-top: 0; // Needed to keep the Edge thumb centred + } + + &:focus, + &:hover { + &::-webkit-slider-thumb { + background: var(--color-bg-thumb-hover); + } + + &::-moz-range-thumb { + background: var(--color-bg-thumb-hover); + } + + &::-ms-thumb { + background: var(--color-bg-thumb-hover); + } + } + + &:focus-visible { + &::-webkit-slider-thumb { + outline: var(--outline-focus-default); + outline-offset: 2px; + } + + &::-moz-range-thumb { + outline: var(--outline-focus-default); + outline-offset: 2px; + } + + &::-ms-thumb { + outline: var(--outline-focus-default); + outline-offset: 2px; + } + } + + // Track + + &::-webkit-slider-runnable-track { + background: var(--color-bg-track); + border: 0; + border-radius: 1.3px; + width: 100%; + height: 4px; + cursor: pointer; + } + + &::-moz-range-track { + background: var(--color-bg-track); + border: 0; + border-radius: 1.3px; + width: 100%; + height: 4px; + cursor: pointer; + } + + &::-ms-track { + background: var(--color-bg-track); + border: 0; + color: transparent; + width: 100%; + height: 4px; + cursor: pointer; + } +} + +.markers { + display: flex; + flex-direction: column; + justify-content: space-between; + writing-mode: vertical-lr; + width: 100%; + font-size: 11px; + color: var(--color-text-secondary); + user-select: none; + + option { + padding: 0; + } +} diff --git a/app/javascript/mastodon/components/form_fields/range_input_field.stories.tsx b/app/javascript/mastodon/components/form_fields/range_input_field.stories.tsx new file mode 100644 index 0000000000..672228ab8c --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/range_input_field.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { RangeInputField } from './range_input_field'; + +const meta = { + title: 'Components/Form Fields/RangeInputField', + component: RangeInputField, + args: { + label: 'Label', + hint: 'This is a description of this form field', + checked: false, + disabled: false, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Simple: Story = {}; + +export const Markers: Story = { + args: { + markers: [ + { value: 0, label: 'None' }, + { value: 25, label: 'Some' }, + { value: 50, label: 'Half' }, + { value: 75, label: 'Most' }, + { value: 100, label: 'All' }, + ], + }, +}; diff --git a/app/javascript/mastodon/components/form_fields/range_input_field.tsx b/app/javascript/mastodon/components/form_fields/range_input_field.tsx new file mode 100644 index 0000000000..8fb2620339 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/range_input_field.tsx @@ -0,0 +1,86 @@ +import type { ComponentPropsWithoutRef } from 'react'; +import { forwardRef, useId } from 'react'; + +import classNames from 'classnames'; + +import { FormFieldWrapper } from './form_field_wrapper'; +import type { CommonFieldWrapperProps } from './form_field_wrapper'; +import classes from './range_input.module.scss'; + +export type RangeInputProps = Omit< + ComponentPropsWithoutRef<'input'>, + 'type' | 'list' +> & { + markers?: { value: number; label: string }[] | number[]; +}; + +interface Props extends RangeInputProps, CommonFieldWrapperProps {} + +/** + * A simple form field for single-line text. + * + * Accepts an optional `hint` and can be marked as required + * or optional (by explicitly setting `required={false}`) + */ + +export const RangeInputField = forwardRef( + ( + { id, label, hint, status, required, wrapperClassName, ...otherProps }, + ref, + ) => ( + + {(inputProps) => } + + ), +); + +RangeInputField.displayName = 'RangeInputField'; + +export const RangeInput = forwardRef( + ({ className, markers, id, ...otherProps }, ref) => { + const markersId = useId(); + + if (!markers) { + return ( + + ); + } + return ( + <> + + + {markers.map((marker) => { + const value = typeof marker === 'number' ? marker : marker.value; + return ( + + + ); + }, +); + +RangeInput.displayName = 'RangeInput';