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
+
+```
\ No newline at end of file
diff --git a/packages/core/src/__tests__/__e2e__/button/Button.cy.tsx b/packages/core/src/__tests__/__e2e__/button/Button.cy.tsx
index 0958d8ba35..16267d76fe 100644
--- a/packages/core/src/__tests__/__e2e__/button/Button.cy.tsx
+++ b/packages/core/src/__tests__/__e2e__/button/Button.cy.tsx
@@ -60,4 +60,8 @@ describe("Given a Button", () => {
cy.mount();
cy.findByRole("button").should("have.attr", "type", "button");
});
+ it("should have overlay when loading is true", () => {
+ cy.mount();
+ cy.get(".saltButton-loading").should("exist");
+ });
});
diff --git a/packages/core/src/button/Button.css b/packages/core/src/button/Button.css
index 8a92c6276e..24f73f7b1a 100644
--- a/packages/core/src/button/Button.css
+++ b/packages/core/src/button/Button.css
@@ -7,7 +7,7 @@
border-width: var(--saltButton-borderWidth, var(--salt-size-border, 0));
border-radius: var(--saltButton-borderRadius, var(--salt-palette-corner-weak, 0));
color: var(--saltButton-text-color, var(--button-text-color));
- cursor: var(--saltButton-cursor, pointer);
+ cursor: var(--saltButton-cursor, var(--salt-actionable-cursor-hover));
display: inline-flex;
gap: var(--salt-spacing-50);
justify-content: var(--saltButton-justifyContent, center);
@@ -76,7 +76,8 @@
/* Pseudo-class applied to the root element if disabled={true} */
.saltButton:disabled,
.saltButton-disabled,
-.saltButton-disabled:active,
+/* Overrides to apply the disabled style when the button is focusable while also disabled */
+ .saltButton-disabled:active,
.saltButton-disabled:focus-visible,
.saltButton-disabled:hover {
background: var(--saltButton-background-disabled, var(--button-background-disabled));
@@ -174,3 +175,28 @@
--button-borderColor-active: var(--salt-actionable-subtle-borderColor-active);
--button-borderColor-disabled: var(--salt-actionable-subtle-borderColor-disabled);
}
+.saltButton-accent.saltButton-loading {
+ --button-text-color: var(--salt-actionable-accent-foreground);
+ --button-text-color-hover: var(--salt-actionable-accent-foreground);
+ --button-text-color-active: var(--salt-actionable-accent-foreground);
+ --button-background: var(--salt-actionable-accent-background);
+ --button-background-hover: var(--salt-actionable-accent-background);
+ --button-background-active: var(--salt-actionable-accent-background);
+ --button-borderColor: var(--salt-actionable-accent-borderColor);
+ --button-borderColor-hover: var(--salt-actionable-accent-borderColor);
+ --button-borderColor-active: var(--salt-actionable-accent-borderColor);
+ cursor: default;
+}
+
+.saltButton-neutral.saltButton-loading {
+ --button-text-color: var(--salt-actionable-foreground);
+ --button-text-color-hover: var(--salt-actionable-foreground);
+ --button-text-color-active: var(--salt-actionable-foreground);
+ --button-background: var(--salt-actionable-background);
+ --button-background-hover: var(--salt-actionable-background);
+ --button-background-active: var(--salt-actionable-background);
+ --button-borderColor: var(--salt-actionable-borderColor);
+ --button-borderColor-hover: var(--salt-actionable-borderColor);
+ --button-borderColor-active: var(--salt-actionable-borderColor);
+ cursor: default;
+}
diff --git a/packages/core/src/button/Button.tsx b/packages/core/src/button/Button.tsx
index 709e0c73b7..3bc8075e15 100644
--- a/packages/core/src/button/Button.tsx
+++ b/packages/core/src/button/Button.tsx
@@ -43,6 +43,10 @@ export interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
* The color of the button. Options are 'accent' and 'neutral'.
*/
color?: ButtonColor;
+ /**
+ * To show a loading spinner.
+ */
+ loading?: boolean;
}
function variantToAppearanceAndColor(variant: ButtonVariant) {
@@ -71,9 +75,10 @@ export const Button = forwardRef(
color: colorProp,
type = "button",
variant = "primary",
+ loading,
...restProps
},
- ref?,
+ ref,
): ReactElement {
const { active, buttonProps } = useButton({
disabled,
@@ -106,6 +111,7 @@ export const Button = forwardRef(
withBaseName(variant),
{
[withBaseName("disabled")]: disabled,
+ [withBaseName("loading")]: loading,
[withBaseName("active")]: active,
[withBaseName(appearance)]: appearance,
[withBaseName(color)]: color,
diff --git a/packages/core/src/button/useButton.ts b/packages/core/src/button/useButton.ts
index b1a8f58def..26ac322d58 100644
--- a/packages/core/src/button/useButton.ts
+++ b/packages/core/src/button/useButton.ts
@@ -13,6 +13,7 @@ export interface ButtonHookProps {
onKeyDown?: (event: KeyboardEvent) => void;
onClick?: (event: MouseEvent) => void;
onBlur?: (event: FocusEvent) => void;
+ loading?: boolean;
}
export interface ButtonHookResult {
@@ -35,6 +36,7 @@ export const useButton = ({
onKeyDown,
onClick,
onBlur,
+ loading,
}: ButtonHookProps): ButtonHookResult => {
const [keyIsDown, setkeyIsDown] = useState("");
const [active, setActive] = useState(false);
@@ -64,6 +66,9 @@ export const useButton = ({
const handleClick = (event: MouseEvent) => {
setActive(true);
+ if (loading) {
+ event.preventDefault();
+ }
onClick?.(event);
};
diff --git a/packages/core/stories/button/button.qa.stories.tsx b/packages/core/stories/button/button.qa.stories.tsx
index 68fdacc470..5d509fff23 100644
--- a/packages/core/stories/button/button.qa.stories.tsx
+++ b/packages/core/stories/button/button.qa.stories.tsx
@@ -1,4 +1,4 @@
-import { Button } from "@salt-ds/core";
+import { Button, StackLayout } from "@salt-ds/core";
import { SearchIcon } from "@salt-ds/icons";
import type { Meta, StoryFn } from "@storybook/react";
import {
@@ -18,82 +18,128 @@ export default {
} as Meta;
export const AllVariantsGrid: StoryFn = (props) => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
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";