Redesign buttons (#39898)

This commit is contained in:
Echo
2026-07-22 15:56:27 +02:00
committed by GitHub
parent 1043503c39
commit 3d51d8bc28
4 changed files with 390 additions and 2 deletions

View 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;
}
}

View 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',
},
};

View 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'
/>
);

View File

@@ -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'