diff --git a/.changeset/true-taxes-cover.md b/.changeset/true-taxes-cover.md new file mode 100644 index 0000000000..ec204084d0 --- /dev/null +++ b/.changeset/true-taxes-cover.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Added `loading` prop to Button and ButtonIcon components for displaying spinner during async operations. diff --git a/docs-ui/src/content/button-icon.mdx b/docs-ui/src/content/button-icon.mdx index e1f27e0601..fbc0c8c54c 100644 --- a/docs-ui/src/content/button-icon.mdx +++ b/docs-ui/src/content/button-icon.mdx @@ -9,6 +9,7 @@ import { buttonIconVariantsSnippet, buttonIconSizesSnippet, buttonIconDisabledSnippet, + buttonIconLoadingSnippet, buttonIconResponsiveSnippet, buttonIconAsLinkSnippet, } from './button-icon.props'; @@ -74,6 +75,18 @@ Here's a view when buttons are disabled. code={buttonIconDisabledSnippet} /> +### Loading + +Here's a view when buttons are in a loading state. + +} + code={buttonIconLoadingSnippet} +/> + ### Responsive Here's a view when buttons are responsive. diff --git a/docs-ui/src/content/button-icon.props.ts b/docs-ui/src/content/button-icon.props.ts index 2ba7372a9c..b063484d27 100644 --- a/docs-ui/src/content/button-icon.props.ts +++ b/docs-ui/src/content/button-icon.props.ts @@ -19,6 +19,7 @@ export const buttonIconPropDefs: Record = { }, icon: { type: 'enum', values: ['ReactNode'], responsive: false }, isDisabled: { type: 'boolean', default: 'false', responsive: false }, + loading: { type: 'boolean', default: 'false', responsive: false }, type: { type: 'enum', values: ['button', 'submit', 'reset'], @@ -50,6 +51,8 @@ export const buttonIconSizesSnippet = ` export const buttonIconDisabledSnippet = `} isDisabled />`; +export const buttonIconLoadingSnippet = `} variant="primary" loading={isLoading} onPress={handleClick} />`; + export const buttonIconResponsiveSnippet = `} variant={{ initial: 'primary', lg: 'secondary' }} />`; export const buttonIconAsLinkSnippet = `import { ButtonLink } from '@backstage/ui'; diff --git a/docs-ui/src/content/button.mdx b/docs-ui/src/content/button.mdx index a25115012b..b60f32ff07 100644 --- a/docs-ui/src/content/button.mdx +++ b/docs-ui/src/content/button.mdx @@ -9,6 +9,7 @@ import { buttonSizesSnippet, buttonIconsSnippet, buttonDisabledSnippet, + buttonLoadingSnippet, buttonResponsiveSnippet, buttonAsLinkSnippet, } from './button.props'; @@ -86,6 +87,18 @@ Here's a view when buttons are disabled. code={buttonDisabledSnippet} /> +### Loading + +Here's a view when buttons are in a loading state. + +} + code={buttonLoadingSnippet} +/> + ### Responsive Here's a view when buttons are responsive. diff --git a/docs-ui/src/content/button.props.ts b/docs-ui/src/content/button.props.ts index a3bb2659bf..e650a9f2e9 100644 --- a/docs-ui/src/content/button.props.ts +++ b/docs-ui/src/content/button.props.ts @@ -17,6 +17,7 @@ export const buttonPropDefs: Record = { iconStart: { type: 'enum', values: ['ReactNode'], responsive: false }, iconEnd: { type: 'enum', values: ['ReactNode'], responsive: false }, isDisabled: { type: 'boolean', default: 'false', responsive: false }, + loading: { type: 'boolean', default: 'false', responsive: false }, children: { type: 'enum', values: ['ReactNode'], responsive: false }, type: { type: 'enum', @@ -65,6 +66,10 @@ export const buttonResponsiveSnippet = ``; + export const buttonAsLinkSnippet = `import { ButtonLink } from '@backstage/ui'; diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 018b2ccddf..4f6087b0f0 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -187,6 +187,8 @@ export interface ButtonIconProps extends ButtonProps_2 { // (undocumented) icon?: ReactElement; // (undocumented) + loading?: boolean; + // (undocumented) size?: 'small' | 'medium' | Partial>; // (undocumented) variant?: @@ -228,6 +230,8 @@ export interface ButtonProps extends ButtonProps_2 { // (undocumented) iconStart?: ReactElement; // (undocumented) + loading?: boolean; + // (undocumented) size?: 'small' | 'medium' | Partial>; // (undocumented) variant?: @@ -418,15 +422,20 @@ export const componentDefinitions: { readonly Button: { readonly classNames: { readonly root: 'bui-Button'; + readonly content: 'bui-ButtonContent'; + readonly spinner: 'bui-ButtonSpinner'; }; readonly dataAttributes: { readonly size: readonly ['small', 'medium', 'large']; readonly variant: readonly ['primary', 'secondary', 'tertiary']; + readonly loading: readonly [true, false]; }; }; readonly ButtonIcon: { readonly classNames: { readonly root: 'bui-ButtonIcon'; + readonly content: 'bui-ButtonIconContent'; + readonly spinner: 'bui-ButtonIconSpinner'; }; }; readonly ButtonLink: { diff --git a/packages/ui/src/components/Button/Button.module.css b/packages/ui/src/components/Button/Button.module.css index 2216d0882b..a87f5d67cf 100644 --- a/packages/ui/src/components/Button/Button.module.css +++ b/packages/ui/src/components/Button/Button.module.css @@ -18,22 +18,27 @@ @layer components { .bui-Button { - border: none; + --loading-duration: 200ms; + position: relative; display: inline-flex; - align-items: center; - justify-content: center; + border: none; user-select: none; font-family: var(--bui-font-regular); font-weight: var(--bui-font-weight-bold); padding: 0; cursor: pointer; border-radius: var(--bui-radius-2); - gap: var(--bui-space-1_5); flex-shrink: 0; + transition: background-color var(--loading-duration) ease-out, + box-shadow var(--loading-duration) ease-out; &[data-disabled='true'] { cursor: not-allowed; } + + &[data-loading='true'] { + cursor: wait; + } } .bui-Button[data-variant='primary'] { @@ -54,7 +59,8 @@ outline-offset: 2px; } - &[data-disabled='true'] { + &[data-disabled='true'], + &[data-loading='true'] { background-color: var(--bui-bg-solid-disabled); color: var(--bui-fg-solid-disabled); } @@ -80,7 +86,8 @@ box-shadow: inset 0 0 0 2px var(--bui-ring); } - &[data-disabled='true'] { + &[data-disabled='true'], + &[data-loading='true'] { box-shadow: inset 0 0 0 1px var(--bui-border-disabled); color: var(--bui-fg-disabled); } @@ -105,31 +112,92 @@ box-shadow: inset 0 0 0 2px var(--bui-ring); } - &[data-disabled='true'] { + &[data-disabled='true'], + &[data-loading='true'] { background-color: transparent; color: var(--bui-fg-disabled); } } + .bui-Button[data-size='small'] { + font-size: var(--bui-font-size-3); + padding: 0 var(--bui-space-2); + height: 2rem; + + svg { + width: 1rem; + height: 1rem; + } + } + .bui-Button[data-size='medium'] { font-size: var(--bui-font-size-4); padding: 0 var(--bui-space-3); height: 2.5rem; + + svg { + width: 1.25rem; + height: 1.25rem; + } } - .bui-Button[data-size='small'] { - font-size: var(--bui-font-size-3); - padding: 0 var(--bui-space-2); - height: 2rem; + .bui-ButtonContent { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--bui-space-1_5); + height: 100%; + width: 100%; + transition: opacity var(--loading-duration) ease-out; + + .bui-Button[data-loading='true'] & { + opacity: 0; + } } - .bui-Button[data-size='small'] svg { - width: 1rem; - height: 1rem; + .bui-ButtonSpinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + opacity: 0; + transition: opacity var(--loading-duration) ease-in; + + .bui-Button[data-loading='true'] & { + opacity: 1; + } + + & svg { + animation: bui-spin 1s linear infinite; + } } - .bui-Button[data-size='medium'] svg { - width: 1.25rem; - height: 1.25rem; + @media (prefers-reduced-motion: reduce) { + .bui-Button { + transition-duration: 50ms; + } + + .bui-ButtonContent { + transition-duration: 50ms; + } + + .bui-ButtonSpinner { + transition-duration: 50ms; + } + + .bui-ButtonSpinner svg { + animation: none; + } + } + + @keyframes bui-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } } } diff --git a/packages/ui/src/components/Button/Button.stories.tsx b/packages/ui/src/components/Button/Button.stories.tsx index 6b74615eeb..4734770aa4 100644 --- a/packages/ui/src/components/Button/Button.stories.tsx +++ b/packages/ui/src/components/Button/Button.stories.tsx @@ -19,6 +19,7 @@ import { Button } from './Button'; import { Flex } from '../Flex'; import { Text } from '../Text'; import { RiArrowRightSLine, RiCloudLine } from '@remixicon/react'; +import { useState } from 'react'; const meta = { title: 'Backstage UI/Button', @@ -212,3 +213,80 @@ export const Playground: Story = { ), }; + +export const Loading: Story = { + render: () => { + const [isLoading, setIsLoading] = useState(false); + + const handleClick = () => { + setIsLoading(true); + setTimeout(() => { + setIsLoading(false); + }, 3000); + }; + + return ( + + ); + }, +}; + +export const LoadingVariants: Story = { + render: () => ( + + Primary + + + + + + + Secondary + + + + + + + Tertiary + + + + + + + Loading vs Disabled + + + + + + + ), +}; diff --git a/packages/ui/src/components/Button/Button.tsx b/packages/ui/src/components/Button/Button.tsx index d22a3036e6..45228ff6ef 100644 --- a/packages/ui/src/components/Button/Button.tsx +++ b/packages/ui/src/components/Button/Button.tsx @@ -16,7 +16,8 @@ import clsx from 'clsx'; import { forwardRef, Ref } from 'react'; -import { Button as RAButton } from 'react-aria-components'; +import { Button as RAButton, ProgressBar } from 'react-aria-components'; +import { RiLoader4Line } from '@remixicon/react'; import type { ButtonProps } from './types'; import { useStyles } from '../../hooks/useStyles'; import styles from './Button.module.css'; @@ -30,18 +31,38 @@ export const Button = forwardRef( ...props, }); - const { children, className, iconStart, iconEnd, ...rest } = cleanedProps; + const { children, className, iconStart, iconEnd, loading, ...rest } = + cleanedProps; return ( - {iconStart} - {children} - {iconEnd} + {({ isPending }) => ( + <> + + {iconStart} + {children} + {iconEnd} + + + {isPending && ( + + + )} + + )} ); }, diff --git a/packages/ui/src/components/Button/types.ts b/packages/ui/src/components/Button/types.ts index cbe5bd3608..daeba7653f 100644 --- a/packages/ui/src/components/Button/types.ts +++ b/packages/ui/src/components/Button/types.ts @@ -33,4 +33,5 @@ export interface ButtonProps extends RAButtonProps { iconStart?: ReactElement; iconEnd?: ReactElement; children?: ReactNode; + loading?: boolean; } diff --git a/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx b/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx index c1a908119f..97072c3664 100644 --- a/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx +++ b/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx @@ -17,7 +17,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { ButtonIcon } from './ButtonIcon'; import { Flex } from '../Flex'; +import { Text } from '../Text'; import { RiCloudLine } from '@remixicon/react'; +import { useState } from 'react'; const meta = { title: 'Backstage UI/ButtonIcon', @@ -83,3 +85,91 @@ export const Responsive: Story = { }, render: args => } />, }; + +export const Loading: Story = { + render: () => { + const [isLoading, setIsLoading] = useState(false); + + const handleClick = () => { + setIsLoading(true); + setTimeout(() => { + setIsLoading(false); + }, 3000); + }; + + return ( + } + loading={isLoading} + onPress={handleClick} + /> + ); + }, +}; + +export const LoadingVariants: Story = { + render: () => ( + + Primary + + } + loading + /> + } + loading + /> + + + Secondary + + } + loading + /> + } + loading + /> + + + Tertiary + + } + loading + /> + } + loading + /> + + + Loading vs Disabled + + } loading /> + } isDisabled /> + } + loading + isDisabled + /> + + + ), +}; diff --git a/packages/ui/src/components/ButtonIcon/ButtonIcon.tsx b/packages/ui/src/components/ButtonIcon/ButtonIcon.tsx index 2e6a96c4c1..fecccd6a37 100644 --- a/packages/ui/src/components/ButtonIcon/ButtonIcon.tsx +++ b/packages/ui/src/components/ButtonIcon/ButtonIcon.tsx @@ -16,7 +16,8 @@ import clsx from 'clsx'; import { forwardRef, Ref } from 'react'; -import { Button as RAButton } from 'react-aria-components'; +import { Button as RAButton, ProgressBar } from 'react-aria-components'; +import { RiLoader4Line } from '@remixicon/react'; import type { ButtonIconProps } from './types'; import { useStyles } from '../../hooks/useStyles'; import stylesButtonIcon from './ButtonIcon.module.css'; @@ -33,7 +34,7 @@ export const ButtonIcon = forwardRef( const { classNames: classNamesButtonIcon } = useStyles('ButtonIcon'); - const { className, icon, ...rest } = cleanedProps; + const { className, icon, loading, ...rest } = cleanedProps; return ( - {icon} + {({ isPending }) => ( + <> + + {icon} + + + {isPending && ( + + + )} + + )} ); }, diff --git a/packages/ui/src/components/ButtonIcon/types.ts b/packages/ui/src/components/ButtonIcon/types.ts index bc358b6d7d..6af3d12361 100644 --- a/packages/ui/src/components/ButtonIcon/types.ts +++ b/packages/ui/src/components/ButtonIcon/types.ts @@ -31,4 +31,5 @@ export interface ButtonIconProps extends RAButtonProps { | 'tertiary' | Partial>; icon?: ReactElement; + loading?: boolean; } diff --git a/packages/ui/src/components/ButtonLink/ButtonLink.tsx b/packages/ui/src/components/ButtonLink/ButtonLink.tsx index 70d216b3d8..28a67105bb 100644 --- a/packages/ui/src/components/ButtonLink/ButtonLink.tsx +++ b/packages/ui/src/components/ButtonLink/ButtonLink.tsx @@ -41,47 +41,38 @@ export const ButtonLink = forwardRef( const isExternal = isExternalLink(href); - // If it's an external link, render RALink without RouterProvider - if (isExternal) { - return ( - + {iconStart} {children} {iconEnd} - - ); + + + ); + + // If it's an external link, render RALink without RouterProvider + if (isExternal) { + return linkButton; } // For internal links, use RouterProvider return ( - - {iconStart} - {children} - {iconEnd} - + {linkButton} ); }, diff --git a/packages/ui/src/utils/componentDefinitions.ts b/packages/ui/src/utils/componentDefinitions.ts index 526340e4be..cc45dbefe1 100644 --- a/packages/ui/src/utils/componentDefinitions.ts +++ b/packages/ui/src/utils/componentDefinitions.ts @@ -63,15 +63,20 @@ export const componentDefinitions = { Button: { classNames: { root: 'bui-Button', + content: 'bui-ButtonContent', + spinner: 'bui-ButtonSpinner', }, dataAttributes: { size: ['small', 'medium', 'large'] as const, variant: ['primary', 'secondary', 'tertiary'] as const, + loading: [true, false] as const, }, }, ButtonIcon: { classNames: { root: 'bui-ButtonIcon', + content: 'bui-ButtonIconContent', + spinner: 'bui-ButtonIconSpinner', }, }, ButtonLink: {