Redesign components: Radio buttons (#39943)

This commit is contained in:
Echo
2026-07-28 15:55:14 +02:00
committed by GitHub
parent 7d36211f88
commit 165613d2a6
11 changed files with 197 additions and 73 deletions

View File

@@ -139,9 +139,10 @@ a.base {
// Helper classes
.icon {
width: 1em;
height: 1em;
.icon,
.loading {
width: var(--fs-xl);
height: var(--fs-xl);
}
// Adds padding to the text label. This is supposed to be there even if there is no icon.
@@ -156,15 +157,9 @@ a.base {
.iconOnly {
aspect-ratio: 1;
padding: 0;
}
.loading {
color: inherit;
height: 0.8em;
width: 0.8em;
.iconOnly & {
height: 1em;
width: 1em;
}
}

View File

@@ -13,18 +13,24 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
export const CheckboxField = forwardRef<
HTMLInputElement,
Props & CommonFieldWrapperProps
>(({ id, label, hint, status, required, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
status={status}
inputId={id}
inputPlacement='inline-start'
>
{(inputProps) => <Checkbox {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper>
));
>(
(
{ id, label, hint, status, required, wrapperClassName, ...otherProps },
ref,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
status={status}
inputId={id}
inputPlacement='inline-start'
className={wrapperClassName}
>
{(inputProps) => <Checkbox {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper>
),
);
CheckboxField.displayName = 'CheckboxField';

View File

@@ -180,7 +180,15 @@ export const ComboboxFieldWithRef = <
Item extends ComboboxItem,
GroupKey extends string,
>(
{ id, label, hint, status, required, ...otherProps }: Props<Item, GroupKey>,
{
id,
label,
hint,
status,
required,
wrapperClassName,
...otherProps
}: Props<Item, GroupKey>,
ref: React.ForwardedRef<HTMLInputElement>,
) => (
<FormFieldWrapper
@@ -189,6 +197,7 @@ export const ComboboxFieldWithRef = <
required={required}
status={status}
inputId={id}
className={wrapperClassName}
>
{(inputProps) => <Combobox {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper>

View File

@@ -22,7 +22,17 @@ interface CopyLinkFieldProps extends CommonFieldWrapperProps, TextInputProps {
export const CopyLinkField = forwardRef<HTMLInputElement, CopyLinkFieldProps>(
(
{ id, label, hint, status, value, required, className, ...otherProps },
{
id,
label,
hint,
status,
value,
required,
className,
wrapperClassName,
...otherProps
},
ref,
) => {
const intl = useIntl();
@@ -50,6 +60,7 @@ export const CopyLinkField = forwardRef<HTMLInputElement, CopyLinkFieldProps>(
required={required}
status={status}
inputId={id}
className={wrapperClassName}
>
{(inputProps) => (
<div className={classes.wrapper}>

View File

@@ -3,6 +3,8 @@
import type { ReactNode, FC } from 'react';
import { createContext, useId } from 'react';
import classNames from 'classnames';
import { A11yLiveRegion } from 'mastodon/components/a11y_live_region';
import { CalloutInline } from 'mastodon/components/callout_inline';
@@ -17,6 +19,7 @@ interface FieldsetProps {
name?: string;
status?: FieldStatus | FieldStatus['variant'];
layout?: 'vertical' | 'horizontal';
className?: string;
children: ReactNode;
}
@@ -33,6 +36,7 @@ export const Fieldset: FC<FieldsetProps> = ({
name,
status,
layout,
className,
children,
}) => {
const uniqueId = useId();
@@ -54,23 +58,23 @@ export const Fieldset: FC<FieldsetProps> = ({
return (
<fieldset
className={classes.fieldset}
className={classNames(classes.fieldset, className)}
data-has-error={status === 'error'}
aria-labelledby={labelId}
aria-describedby={descriptionIds}
>
<div className={formFieldWrapperClasses.labelWrapper}>
<div id={labelId} className={formFieldWrapperClasses.label}>
<div id={labelId} className={formFieldWrapperClasses.label} data-label>
{legend}
</div>
{hasHint && (
<p id={hintId} className={formFieldWrapperClasses.hint}>
<p id={hintId} className={formFieldWrapperClasses.hint} data-hint>
{hint}
</p>
)}
</div>
<div className={classes.fieldsWrapper} data-layout={layout}>
<div className={classes.fieldsWrapper} data-layout={layout} data-fields>
<FieldsetNameContext.Provider value={fieldsetName}>
{children}
</FieldsetNameContext.Provider>

View File

@@ -83,7 +83,9 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
};
const input = (
<div className={classes.inputWrapper}>{children(inputProps)}</div>
<div className={classes.inputWrapper} data-input>
{children(inputProps)}
</div>
);
return (
@@ -105,7 +107,7 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
</label>
{hasHint && (
<span className={classes.hint} id={hintId}>
<span className={classes.hint} id={hintId} data-hint>
{hint}
</span>
)}

View File

@@ -3,6 +3,8 @@
import type { ComponentPropsWithoutRef, CSSProperties } from 'react';
import { forwardRef, useContext } from 'react';
import classNames from 'classnames';
import { FieldsetNameContext } from './fieldset';
import type { CommonFieldWrapperProps } from './form_field_wrapper';
import { FormFieldWrapper } from './form_field_wrapper';
@@ -15,41 +17,50 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
export const RadioButtonField = forwardRef<
HTMLInputElement,
Props & CommonFieldWrapperProps
>(({ id, label, hint, status, required, ...otherProps }, ref) => {
const fieldsetName = useContext(FieldsetNameContext);
>(
(
{ id, label, hint, status, required, wrapperClassName, ...otherProps },
ref,
) => {
const fieldsetName = useContext(FieldsetNameContext);
return (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
status={status}
inputId={id}
inputPlacement='inline-start'
>
{(inputProps) => (
<RadioButton
{...otherProps}
{...inputProps}
name={otherProps.name || fieldsetName}
ref={ref}
/>
)}
</FormFieldWrapper>
);
});
return (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
status={status}
inputId={id}
className={wrapperClassName}
inputPlacement='inline-start'
>
{(inputProps) => (
<RadioButton
{...otherProps}
{...inputProps}
name={otherProps.name || fieldsetName}
ref={ref}
/>
)}
</FormFieldWrapper>
);
},
);
RadioButtonField.displayName = 'RadioButtonField';
export const RadioButton = forwardRef<HTMLInputElement, Props>(
({ className, size, ...otherProps }, ref) => (
<input
{...otherProps}
type='radio'
className={classes.radioButton}
style={size ? ({ '--size': `${size}px` } as CSSProperties) : undefined}
ref={ref}
/>
({ className, size, children, ...otherProps }, ref) => (
<>
{children}
<input
{...otherProps}
type='radio'
className={classNames(classes.radioButton, className)}
style={size ? ({ '--size': `${size}px` } as CSSProperties) : undefined}
ref={ref}
/>
</>
),
);

View File

@@ -1,3 +1,39 @@
// Radio
.radioWrapper {
[data-input] {
position: relative;
}
.icon {
position: absolute;
pointer-events: none;
opacity: 0;
transition:
color 200ms,
opacity 200ms;
}
.icon:has(+ :checked) {
opacity: 1;
}
input {
opacity: 0;
}
label {
font-size: var(--fs-sm);
}
}
.icon {
width: var(--fs-lg);
height: var(--fs-lg);
}
// Text Input
.inputWrapper {
label {
font-size: var(--fs-sm);

View File

@@ -1,6 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { TextInputField, ToggleField } from './redesign';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { Fieldset } from './fieldset';
import { RadioButtonField, TextInputField, ToggleField } from './redesign';
interface FieldProps {
label: string;
@@ -28,6 +31,17 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const RadioButton: Story = {
render(args) {
return (
<Fieldset name='test' legend='Test radio'>
<RadioButtonField {...args} icon={CheckIcon} defaultChecked />
<RadioButtonField {...args} icon={CheckIcon} />
</Fieldset>
);
},
};
export const TextInput: Story = {
render(args) {
return <TextInputField {...args} />;

View File

@@ -2,6 +2,10 @@ import classNames from 'classnames';
import type { Merge } from 'type-fest';
import { Icon } from '../icon';
import type { IconProp } from '../icon';
import { RadioButtonField as OldRadioButtonField } from './radio_button_field';
import classes from './redesign.module.scss';
import {
TextInput as OldTextInput,
@@ -18,6 +22,31 @@ type RedesignComponent<
P = object,
> = React.FC<Merge<React.ComponentProps<T>, P>>;
// Radio button field
interface RadioButtonProps {
icon?: IconProp | null;
iconClassName?: string;
}
export const RadioButtonField: RedesignComponent<
typeof OldRadioButtonField,
RadioButtonProps
> = ({ icon, iconClassName, wrapperClassName, ...props }) => (
<OldRadioButtonField
{...props}
wrapperClassName={classNames(wrapperClassName, classes.radioWrapper)}
>
{icon && (
<Icon
id='checked'
icon={icon}
className={classNames(classes.icon, iconClassName)}
/>
)}
</OldRadioButtonField>
);
// Text field
export const TextInputField: RedesignComponent<typeof OldTextInputField> = ({
@@ -71,5 +100,6 @@ export const ToggleField: RedesignComponent<
classes.toggle,
size === 'sm' && classes.toggleSmall,
)}
size={size === 'sm' ? 14 : 20}
/>
);

View File

@@ -14,18 +14,24 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
export const ToggleField = forwardRef<
HTMLInputElement,
Props & CommonFieldWrapperProps
>(({ id, label, hint, status, required, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
status={status}
inputId={id}
inputPlacement='inline-end'
>
{(inputProps) => <Toggle {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper>
));
>(
(
{ id, label, hint, status, required, wrapperClassName, ...otherProps },
ref,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
status={status}
inputId={id}
inputPlacement='inline-end'
className={wrapperClassName}
>
{(inputProps) => <Toggle {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper>
),
);
ToggleField.displayName = 'ToggleField';