mirror of
https://github.com/mastodon/mastodon.git
synced 2026-08-23 23:25:06 -05:00
Redesign buttons (#39898)
This commit is contained in:
150
app/javascript/mastodon/components/button/redesign.module.scss
Normal file
150
app/javascript/mastodon/components/button/redesign.module.scss
Normal file
@@ -0,0 +1,150 @@
|
||||
.base {
|
||||
--bg: transparent;
|
||||
--fg: var(--color-text-primary);
|
||||
|
||||
background-color: var(--bg);
|
||||
border-radius: var(--radius-round);
|
||||
color: var(--fg);
|
||||
transition:
|
||||
background-color 200ms,
|
||||
color 200ms;
|
||||
border: 0;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
gap: var(--space-2xs);
|
||||
|
||||
&:focus-visible,
|
||||
&:focus-within {
|
||||
outline: 2px solid var(--fg);
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&:hover:disabled {
|
||||
cursor: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Override default anchor styles
|
||||
a.base {
|
||||
&:focus {
|
||||
border-radius: var(--radius-round);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.xs {
|
||||
padding: var(--space-3xs) var(--space-xs);
|
||||
font-size: var(--fs-2xs);
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.sm {
|
||||
padding: var(--space-xs);
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.md {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.lg {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.solid {
|
||||
--bg: var(--color-bg-inverted);
|
||||
--bg-hover: color-mix(in srgb, var(--bg) 90%, var(--fg));
|
||||
--fg: var(--color-text-inverted);
|
||||
|
||||
background-color: var(--bg);
|
||||
border-radius: var(--radius-round);
|
||||
color: var(--fg);
|
||||
transition:
|
||||
background-color 200ms,
|
||||
color 200ms;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: color-mix(in srgb, var(--bg-hover) 80%, var(--fg));
|
||||
}
|
||||
|
||||
&.accent {
|
||||
--bg: var(--color-bg-brand-base);
|
||||
--bg-hover: var(--color-bg-brand-base-hover);
|
||||
}
|
||||
|
||||
&.tonal {
|
||||
--bg: var(--color-bg-highlight);
|
||||
--fg: var(--color-text-primary);
|
||||
}
|
||||
|
||||
&.destructive {
|
||||
--bg: var(--color-bg-error-base);
|
||||
--bg-hover: var(--color-bg-error-base-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
--bg: var(--color-bg-disabled);
|
||||
--bg-hover: var(--bg);
|
||||
--fg: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
&.accent {
|
||||
--fg: var(--color-text-brand);
|
||||
|
||||
&:hover {
|
||||
--fg: var(--color-text-brand-soft);
|
||||
}
|
||||
}
|
||||
|
||||
&.neutral,
|
||||
&.tonal {
|
||||
&:hover {
|
||||
--fg: rgb(from var(--color-text-primary) r g b / 80%);
|
||||
}
|
||||
}
|
||||
|
||||
&.destructive {
|
||||
--fg: var(--color-text-error);
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&:hover:disabled {
|
||||
--fg: var(--color-text-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: var(--fs-lg);
|
||||
}
|
||||
|
||||
.iconOnly {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: inherit;
|
||||
height: 0.8em;
|
||||
width: 0.8em;
|
||||
|
||||
.iconOnly & {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
}
|
||||
100
app/javascript/mastodon/components/button/redesign.stories.tsx
Normal file
100
app/javascript/mastodon/components/button/redesign.stories.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { fn } from 'storybook/test';
|
||||
|
||||
import ChatIcon from '@/material-icons/400-24px/chat.svg?react';
|
||||
import DownloadIcon from '@/material-icons/400-24px/download.svg?react';
|
||||
import HeadphonesIcon from '@/material-icons/400-24px/headphones.svg?react';
|
||||
|
||||
import { Button, IconButton } from './redesign';
|
||||
|
||||
const iconArgType = {
|
||||
control: 'select',
|
||||
options: ['chat', 'download', 'audio'],
|
||||
mapping: {
|
||||
chat: ChatIcon,
|
||||
download: DownloadIcon,
|
||||
audio: HeadphonesIcon,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const meta = {
|
||||
title: 'Redesign/Button',
|
||||
component: Button,
|
||||
args: {
|
||||
children: 'Action',
|
||||
size: 'md',
|
||||
variant: 'solid',
|
||||
color: 'neutral',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
as: 'button',
|
||||
onClick: fn(),
|
||||
},
|
||||
argTypes: {
|
||||
children: {
|
||||
control: 'text',
|
||||
},
|
||||
as: {
|
||||
control: 'inline-radio',
|
||||
options: ['button', 'a', 'link'],
|
||||
},
|
||||
size: {
|
||||
control: 'inline-radio',
|
||||
options: ['xs', 'sm', 'md', 'lg'],
|
||||
},
|
||||
variant: {
|
||||
control: 'inline-radio',
|
||||
options: ['solid', 'text'],
|
||||
},
|
||||
color: {
|
||||
control: 'inline-radio',
|
||||
options: ['neutral', 'accent', 'tonal', 'destructive'],
|
||||
},
|
||||
leadingIcon: iconArgType,
|
||||
trailingIcon: iconArgType,
|
||||
onClick: {
|
||||
table: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Button>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const IconLeading: Story = {
|
||||
args: {
|
||||
leadingIcon: ChatIcon,
|
||||
},
|
||||
};
|
||||
|
||||
export const IconTrailing: Story = {
|
||||
args: {
|
||||
trailingIcon: HeadphonesIcon,
|
||||
},
|
||||
};
|
||||
|
||||
export const IconOnly: Story = {
|
||||
render(args) {
|
||||
return <IconButton {...args} icon={args.leadingIcon ?? ChatIcon} />;
|
||||
},
|
||||
args: {
|
||||
leadingIcon: ChatIcon,
|
||||
},
|
||||
};
|
||||
|
||||
export const Link: Story = {
|
||||
render(args) {
|
||||
if (args.as === 'link') {
|
||||
return <Button {...args} to='/example' />;
|
||||
}
|
||||
return <Button {...args} />;
|
||||
},
|
||||
args: {
|
||||
as: 'link',
|
||||
},
|
||||
};
|
||||
133
app/javascript/mastodon/components/button/redesign.tsx
Normal file
133
app/javascript/mastodon/components/button/redesign.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import type React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { LinkProps } from 'react-router-dom';
|
||||
|
||||
import { CircularProgress } from '../circular_progress';
|
||||
import type { IconProp } from '../icon';
|
||||
import { Icon } from '../icon';
|
||||
|
||||
import classes from './redesign.module.scss';
|
||||
|
||||
interface ButtonPropsBase<As extends 'a' | 'button'> {
|
||||
size?: 'lg' | 'md' | 'sm' | 'xs';
|
||||
variant?: 'solid' | 'text';
|
||||
color?: 'accent' | 'neutral' | 'tonal' | 'destructive';
|
||||
onClick?: React.MouseEventHandler<
|
||||
As extends 'button' ? HTMLButtonElement : HTMLAnchorElement
|
||||
>;
|
||||
loading?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
type ButtonButtonProps = { as?: 'button' } & ButtonPropsBase<'button'> &
|
||||
Omit<React.ComponentPropsWithRef<'button'>, 'children'>;
|
||||
type ButtonAnchorProps = { as: 'a' } & ButtonPropsBase<'a'> &
|
||||
Omit<React.ComponentPropsWithRef<'a'>, 'children'>;
|
||||
type ButtonLinkProps = { as: 'link' } & ButtonPropsBase<'a'> &
|
||||
Omit<LinkProps, 'children'>;
|
||||
|
||||
type ButtonProps = ButtonButtonProps | ButtonAnchorProps | ButtonLinkProps;
|
||||
|
||||
const BaseButton: React.FC<ButtonProps> = ({
|
||||
size = 'md',
|
||||
variant = 'solid',
|
||||
color = 'neutral',
|
||||
as: asComp = 'button',
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
loading,
|
||||
'aria-disabled': ariaDisabled,
|
||||
'aria-live': ariaLive,
|
||||
...props
|
||||
}) => {
|
||||
const disabled = 'disabled' in props ? props.disabled : false;
|
||||
const handleClick: React.MouseEventHandler<
|
||||
HTMLButtonElement & HTMLAnchorElement
|
||||
> = useCallback(
|
||||
(event) => {
|
||||
if (disabled || loading) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
} else if (onClick) {
|
||||
onClick(event);
|
||||
}
|
||||
},
|
||||
[loading, onClick, disabled],
|
||||
);
|
||||
|
||||
let Comp: React.ElementType = asComp;
|
||||
if (asComp === 'link') {
|
||||
Comp = Link;
|
||||
}
|
||||
|
||||
return (
|
||||
<Comp
|
||||
type='button'
|
||||
{...props}
|
||||
className={classNames(
|
||||
className,
|
||||
classes.base,
|
||||
classes[size],
|
||||
classes[color],
|
||||
classes[variant],
|
||||
)}
|
||||
onClick={handleClick}
|
||||
// Disabled buttons can't have focus, so we don't really
|
||||
// disable the button during loading
|
||||
disabled={disabled && !loading}
|
||||
aria-disabled={loading || ariaDisabled}
|
||||
// If the loading prop is used, announce label changes
|
||||
aria-live={ariaLive ?? (loading !== undefined ? 'polite' : undefined)}
|
||||
>
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
};
|
||||
|
||||
export const Button: React.FC<
|
||||
ButtonProps & {
|
||||
leadingIcon?: IconProp;
|
||||
trailingIcon?: IconProp;
|
||||
}
|
||||
> = ({ children, leadingIcon, trailingIcon, ...props }) => (
|
||||
<BaseButton {...props}>
|
||||
{leadingIcon && !props.loading && (
|
||||
<Icon id='leading' icon={leadingIcon} className={classes.icon} />
|
||||
)}
|
||||
{props.loading && <LoadingIcon />}
|
||||
{children}
|
||||
{trailingIcon && (
|
||||
<Icon id='trailing' icon={trailingIcon} className={classes.icon} />
|
||||
)}
|
||||
</BaseButton>
|
||||
);
|
||||
|
||||
export const IconButton: React.FC<ButtonProps & { icon: IconProp }> = ({
|
||||
icon,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => (
|
||||
<BaseButton {...props} className={classNames(classNames, classes.iconOnly)}>
|
||||
{props.loading ? (
|
||||
<LoadingIcon />
|
||||
) : (
|
||||
<Icon id='icon' icon={icon} className={classes.icon} />
|
||||
)}
|
||||
<span className='sr-only'>{children}</span>
|
||||
</BaseButton>
|
||||
);
|
||||
|
||||
const LoadingIcon: React.FC = () => (
|
||||
<CircularProgress
|
||||
size={10}
|
||||
strokeWidth={1}
|
||||
className={classes.loading}
|
||||
role='none'
|
||||
/>
|
||||
);
|
||||
@@ -1,9 +1,13 @@
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
size: number;
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
export const CircularProgress: React.FC<Props> = ({ size, strokeWidth }) => {
|
||||
export const CircularProgress: React.FC<
|
||||
Props & React.SVGAttributes<SVGElement>
|
||||
> = ({ size, strokeWidth, className, ...props }) => {
|
||||
const viewBox = `0 0 ${size} ${size}`;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
|
||||
@@ -12,8 +16,9 @@ export const CircularProgress: React.FC<Props> = ({ size, strokeWidth }) => {
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={viewBox}
|
||||
className='circular-progress'
|
||||
className={classNames('circular-progress', className)}
|
||||
role='progressbar'
|
||||
{...props}
|
||||
>
|
||||
<circle
|
||||
fill='none'
|
||||
|
||||
Reference in New Issue
Block a user