feat(SW-1255): Add loading state to button component

This commit is contained in:
Tobias Johansson
2025-04-23 10:03:33 +02:00
committed by Simon Emanuelsson
parent 80ccdc0e44
commit 89468bc37f
8 changed files with 295 additions and 3 deletions

View File

@@ -68,6 +68,13 @@ export const PrimaryDisabled: Story = {
},
}
export const PrimaryLoading: Story = {
args: {
...PrimaryDefault.args,
isPending: true,
},
}
export const PrimaryLarge: Story = {
args: {
...PrimaryDefault.args,
@@ -106,6 +113,13 @@ export const PrimaryInvertedDisabled: Story = {
},
}
export const PrimaryInvertedLoading: Story = {
args: {
...PrimaryInvertedDefault.args,
isPending: true,
},
}
export const PrimaryInvertedLarge: Story = {
args: {
...PrimaryInvertedDefault.args,
@@ -181,6 +195,13 @@ export const SecondaryInvertedDisabled: Story = {
},
}
export const SecondaryInvertedLoading: Story = {
args: {
...SecondaryInvertedDefault.args,
isPending: true,
},
}
export const SecondaryInvertedLarge: Story = {
args: {
...SecondaryInvertedDefault.args,
@@ -218,6 +239,12 @@ export const TertiaryDisabled: Story = {
},
}
export const TertiaryLoading: Story = {
args: {
...TertiaryDefault.args,
isPending: true,
},
}
export const TertiaryLarge: Story = {
args: {
...TertiaryDefault.args,

View File

@@ -3,6 +3,8 @@ import { Button as ButtonRAC } from 'react-aria-components'
import { variants } from './variants'
import type { ButtonProps } from './types'
import { Spinner } from '../Spinner'
import styles from './button.module.css'
export function Button({
variant,
@@ -12,7 +14,8 @@ export function Button({
typography,
className,
children,
isPending,
...props
}: ButtonProps) {
const classNames = variants({
@@ -20,10 +23,24 @@ export function Button({
color,
size,
wrapping,
typography,
className,
})
return <ButtonRAC {...props} className={classNames} />
return (
<ButtonRAC {...props} className={classNames} isPending={isPending}>
{({ isPending }) => {
return (
<>
{children}
{isPending && (
<div className={styles.spinnerWrapper}>
<Spinner size="Small" color="CurrentColor" />
</div>
)}
</>
)
}}
</ButtonRAC>
)
}

View File

@@ -167,3 +167,9 @@
.variant-text.color-inverted:disabled {
color: var(--Component-Button-Brand-Secondary-On-fill-Disabled);
}
.spinnerWrapper {
display: flex;
align-items: center;
margin-left: var(--Space-x1);
}

View File

@@ -0,0 +1,104 @@
import 'react-material-symbols/rounded'
import type { Meta, StoryObj } from '@storybook/react'
import { Spinner } from './Spinner'
const meta: Meta<typeof Spinner> = {
title: 'Components/Spinner',
component: Spinner,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof Spinner>
function Wrapper({
children,
backgroundColor,
}: {
children: React.ReactNode
backgroundColor?: string
}) {
return (
<div
style={{
height: '200px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
backgroundColor,
}}
>
{children}
</div>
)
}
export const Default: Story = {
args: {
color: 'Accent',
size: 'Medium',
},
decorators: [
(Story) => (
<Wrapper>
<Story />
</Wrapper>
),
],
}
export const Inverted: Story = {
args: {
color: 'Inverted',
size: 'Medium',
},
decorators: [
(Story) => (
<Wrapper backgroundColor="var(--Icon-Interactive-Default)">
<Story />
</Wrapper>
),
],
}
export const Small: Story = {
args: {
size: 'Small',
},
decorators: [
(Story) => (
<Wrapper>
<Story />
</Wrapper>
),
],
}
export const Medium: Story = {
args: {
size: 'Medium',
},
decorators: [
(Story) => (
<Wrapper>
<Story />
</Wrapper>
),
],
}
export const Large: Story = {
args: {
size: 'Large',
},
decorators: [
(Story) => (
<Wrapper>
<Story />
</Wrapper>
),
],
}

View File

@@ -0,0 +1,21 @@
import { VariantProps } from 'class-variance-authority'
import styles from './spinner.module.css'
import { variants } from './variants'
type SpinnerProps = VariantProps<typeof variants>
export function Spinner({ color, size }: SpinnerProps) {
const classNames = variants({
color,
size,
})
return (
<div className={classNames}>
{[...Array(8)].map((_, i) => (
<div key={i} className={styles.dot} />
))}
</div>
)
}

View File

@@ -0,0 +1 @@
export { Spinner } from './Spinner'

View File

@@ -0,0 +1,92 @@
.spinner {
display: inline-block;
position: relative;
--size: 20px;
--dot-size: 3px;
width: var(--size);
height: var(--size);
}
.size-small {
--size: 20px;
}
.size-medium {
--size: 30px;
--dot-size: 5px;
}
.size-large {
--size: 40px;
--dot-size: 6px;
}
.spinner .dot {
transform-origin: calc(var(--size) / 2) calc(var(--size) / 2);
animation: spinnerAnimation 0.8s linear infinite;
}
.spinner .dot::after {
content: ' ';
display: block;
position: absolute;
top: calc(var(--dot-size) / 2);
left: var(--dot-size);
width: var(--dot-size);
height: var(--dot-size);
border-radius: 50%;
background-color: currentColor;
}
.accent .dot::after {
background-color: var(--Icon-Interactive-Default);
}
.inverted .dot::after {
background-color: var(--Icon-Inverted);
}
.dot:nth-child(1) {
transform: rotate(0deg);
animation-delay: -0.7s;
}
.dot:nth-child(2) {
transform: rotate(45deg);
animation-delay: -0.6s;
}
.dot:nth-child(3) {
transform: rotate(90deg);
animation-delay: -0.5s;
}
.dot:nth-child(4) {
transform: rotate(135deg);
animation-delay: -0.4s;
}
.dot:nth-child(5) {
transform: rotate(180deg);
animation-delay: -0.3s;
}
.dot:nth-child(6) {
transform: rotate(225deg);
animation-delay: -0.2s;
}
.dot:nth-child(7) {
transform: rotate(270deg);
animation-delay: -0.1s;
}
.dot:nth-child(8) {
transform: rotate(315deg);
animation-delay: 0s;
}
@keyframes spinnerAnimation {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}

View File

@@ -0,0 +1,24 @@
import { cva } from 'class-variance-authority'
import styles from './spinner.module.css'
export const config = {
variants: {
color: {
Accent: styles.accent,
Inverted: styles.inverted,
CurrentColor: 'currentColor',
},
size: {
Small: styles['size-small'],
Medium: styles['size-medium'],
Large: styles['size-large'],
},
},
defaultVariants: {
color: 'Accent',
size: 'Medium',
},
} as const
export const variants = cva(styles.spinner, config)