diff --git a/.changeset/hungry-islands-divide.md b/.changeset/hungry-islands-divide.md new file mode 100644 index 0000000000..d0ec3fa26e --- /dev/null +++ b/.changeset/hungry-islands-divide.md @@ -0,0 +1,9 @@ +--- +"@salt-ds/core": minor +--- + +Added `loading` prop for Button. + +```tsx + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); diff --git a/packages/core/stories/button/button.stories.tsx b/packages/core/stories/button/button.stories.tsx index ff2eb31d1a..067d68b63f 100644 --- a/packages/core/stories/button/button.stories.tsx +++ b/packages/core/stories/button/button.stories.tsx @@ -1,11 +1,20 @@ -import { Button, type ButtonProps, StackLayout } from "@salt-ds/core"; +import { + Button, + type ButtonProps, + GridLayout, + Spinner, + StackLayout, +} from "@salt-ds/core"; import { DownloadIcon, + RefreshIcon, SearchIcon, SendIcon, SettingsSolidIcon, + SyncIcon, } from "@salt-ds/icons"; import type { Meta, StoryFn } from "@storybook/react"; +import { useState } from "react"; export default { title: "Core/Button", @@ -216,3 +225,348 @@ export const FullWidth: StoryFn = () => { ); }; + +function useLoadOnClick() { + const [loading, setLoading] = useState(false); + + const handleClick = () => { + if (!loading) { + setLoading(true); + setTimeout(() => { + setLoading(false); + }, 3000); + } + }; + + return [loading, handleClick] as const; +} + +export const LoadingButtonsReplaceIcon: StoryFn = () => { + const [accentSolidLoading, setAccentSolidLoading] = useLoadOnClick(); + const [accentOutlineLoading, setAccentOutlineLoading] = useLoadOnClick(); + const [accentTransparentLoading, setAccentTransparentLoading] = + useLoadOnClick(); + const [neutralSolidLoading, setNeutralSolidLoading] = useLoadOnClick(); + const [neutralOutlineLoading, setNeutralOutlineLoading] = useLoadOnClick(); + const [neutralTransparentLoading, setNeutralTransparentLoading] = + useLoadOnClick(); + + return ( + + + + + + + + + ); +}; + +export const LoadingButtons: StoryFn = () => { + const [accentSolidLoading, setAccentSolidLoading] = useLoadOnClick(); + const [accentOutlineLoading, setAccentOutlineLoading] = useLoadOnClick(); + const [accentTransparentLoading, setAccentTransparentLoading] = + useLoadOnClick(); + const [neutralSolidLoading, setNeutralSolidLoading] = useLoadOnClick(); + const [neutralOutlineLoading, setNeutralOutlineLoading] = useLoadOnClick(); + const [neutralTransparentLoading, setNeutralTransparentLoading] = + useLoadOnClick(); + + return ( + + + + + + + + + ); +}; + +export const LoadingButtonsWithLabel: StoryFn = () => { + const [accentSolidLoading, setAccentSolidLoading] = useLoadOnClick(); + const [accentOutlineLoading, setAccentOutlineLoading] = useLoadOnClick(); + const [accentTransparentLoading, setAccentTransparentLoading] = + useLoadOnClick(); + const [neutralSolidLoading, setNeutralSolidLoading] = useLoadOnClick(); + const [neutralOutlineLoading, setNeutralOutlineLoading] = useLoadOnClick(); + const [neutralTransparentLoading, setNeutralTransparentLoading] = + useLoadOnClick(); + + return ( + + + + + + + + + ); +}; diff --git a/site/docs/components/button/examples.mdx b/site/docs/components/button/examples.mdx index b5e132e715..e7882e6324 100644 --- a/site/docs/components/button/examples.mdx +++ b/site/docs/components/button/examples.mdx @@ -87,7 +87,38 @@ This is useful when you want to show a tooltip or a popover when the user hovers Use full width buttons on mobile devices or smaller viewports, the button will take up the full width of its container. - + + + +## Loading + +Buttons can show that a short action is being performed by passing `loading={true}`. This allows a [Spinner](/salt/components/spinner) to be displayed inside the button. + +### Best practices + +- Use loading buttons for actions that take a short amount of time to complete, such as submitting a form, fetching data from a server or a quick processing action (generating a report or performing a calculation). +- The [Content Status](salt/patterns/content-status) pattern can be used with a loading button to provide additional feedback to the user. +- Don't use loading buttons if an action is instantaneous or very quick. For further guidance, view the [Salt duration foundation](/salt/foundations/duration). +- Don't use loading buttons if the action is complex and will take a significant amount of time (more than a few seconds) e.g. uploading or downloading large files, installing software, or completing a multi-step process. Use a [Progress Bar](/salt/components/progress) or a [Toast](/salt/components/toast) instead. + + + + + +## Loading buttons with labels + +A loading button can also have a label to provide more context about the action being performed. + + + + + +## Loading buttons - Replace icon + +An icon can be swapped out with a spinner when a button is in a loading state. + + + ## CTA - Deprecated diff --git a/site/src/examples/button/Loading.tsx b/site/src/examples/button/Loading.tsx new file mode 100644 index 0000000000..d47f298730 --- /dev/null +++ b/site/src/examples/button/Loading.tsx @@ -0,0 +1,130 @@ +import { Button, GridLayout, Spinner } from "@salt-ds/core"; +import { RefreshIcon, SendIcon, SyncIcon } from "@salt-ds/icons"; +import { type ReactElement, useState } from "react"; + +function useLoadOnClick() { + const [loading, setLoading] = useState(false); + + const handleClick = () => { + if (!loading) { + setLoading(true); + setTimeout(() => { + setLoading(false); + }, 3000); + } + }; + + return [loading, handleClick] as const; +} + +export const Loading = (): ReactElement => { + const [accentSolidLoading, setAccentSolidLoading] = useLoadOnClick(); + const [accentOutlineLoading, setAccentOutlineLoading] = useLoadOnClick(); + const [accentTransparentLoading, setAccentTransparentLoading] = + useLoadOnClick(); + const [neutralSolidLoading, setNeutralSolidLoading] = useLoadOnClick(); + const [neutralOutlineLoading, setNeutralOutlineLoading] = useLoadOnClick(); + const [neutralTransparentLoading, setNeutralTransparentLoading] = + useLoadOnClick(); + + return ( + + + + + + + + + ); +}; diff --git a/site/src/examples/button/LoadingReplaceIcon.tsx b/site/src/examples/button/LoadingReplaceIcon.tsx new file mode 100644 index 0000000000..fcf07187aa --- /dev/null +++ b/site/src/examples/button/LoadingReplaceIcon.tsx @@ -0,0 +1,112 @@ +import { Button, GridLayout, Spinner } from "@salt-ds/core"; +import { RefreshIcon, SendIcon, SyncIcon } from "@salt-ds/icons"; +import { type ReactElement, useState } from "react"; + +function useLoadOnClick() { + const [loading, setLoading] = useState(false); + + const handleClick = () => { + if (!loading) { + setLoading(true); + setTimeout(() => { + setLoading(false); + }, 3000); + } + }; + + return [loading, handleClick] as const; +} + +export const LoadingReplaceIcon = (): ReactElement => { + const [accentSolidLoading, setAccentSolidLoading] = useLoadOnClick(); + const [accentOutlineLoading, setAccentOutlineLoading] = useLoadOnClick(); + const [accentTransparentLoading, setAccentTransparentLoading] = + useLoadOnClick(); + const [neutralSolidLoading, setNeutralSolidLoading] = useLoadOnClick(); + const [neutralOutlineLoading, setNeutralOutlineLoading] = useLoadOnClick(); + const [neutralTransparentLoading, setNeutralTransparentLoading] = + useLoadOnClick(); + + return ( + + + + + + + + + ); +}; diff --git a/site/src/examples/button/LoadingWithLabel.tsx b/site/src/examples/button/LoadingWithLabel.tsx new file mode 100644 index 0000000000..8a526c1fba --- /dev/null +++ b/site/src/examples/button/LoadingWithLabel.tsx @@ -0,0 +1,142 @@ +import { Button, GridLayout, Spinner } from "@salt-ds/core"; +import { RefreshIcon, SendIcon, SyncIcon } from "@salt-ds/icons"; +import { type ReactElement, useState } from "react"; + +function useLoadOnClick() { + const [loading, setLoading] = useState(false); + + const handleClick = () => { + if (!loading) { + setLoading(true); + setTimeout(() => { + setLoading(false); + }, 3000); + } + }; + + return [loading, handleClick] as const; +} + +export const LoadingWithLabel = (): ReactElement => { + const [accentSolidLoading, setAccentSolidLoading] = useLoadOnClick(); + const [accentOutlineLoading, setAccentOutlineLoading] = useLoadOnClick(); + const [accentTransparentLoading, setAccentTransparentLoading] = + useLoadOnClick(); + const [neutralSolidLoading, setNeutralSolidLoading] = useLoadOnClick(); + const [neutralOutlineLoading, setNeutralOutlineLoading] = useLoadOnClick(); + const [neutralTransparentLoading, setNeutralTransparentLoading] = + useLoadOnClick(); + + return ( + + + + + + + + + ); +}; diff --git a/site/src/examples/button/index.ts b/site/src/examples/button/index.ts index 4695439f9d..edd758b9a9 100644 --- a/site/src/examples/button/index.ts +++ b/site/src/examples/button/index.ts @@ -8,3 +8,6 @@ export * from "./Primary"; export * from "./Secondary"; export * from "./FullWidth"; export * from "./FocusableWhenDisabled"; +export * from "./Loading"; +export * from "./LoadingWithLabel"; +export * from "./LoadingReplaceIcon";