Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

loading-button #3491

Closed
wants to merge 38 commits into from
Closed
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
677a2c2
loading-button-initial-commit
Vineet119 May 31, 2024
46efbd7
Merge branch 'main' of https://github.com/jpmorganchase/salt-ds into …
Vineet119 Jun 3, 2024
66ad014
added informatory msg
Vineet119 Jun 3, 2024
01fb9b3
review comments addressed
Vineet119 Jun 4, 2024
c9c5b30
design review comments addressed
Vineet119 Jun 5, 2024
726e415
added links for documentation
Vineet119 Jun 5, 2024
0b47609
adds loading props description
Vineet119 Jun 5, 2024
7783896
loading button docs enhancement
Vineet119 Jun 5, 2024
b6ba10a
loading button docs enhancement
Vineet119 Jun 5, 2024
a8b4ef3
adds test isLoading prop
Vineet119 Jun 6, 2024
a46537b
added tests back
Vineet119 Jun 10, 2024
619b675
review comments addressed for css variables
Vineet119 Jun 12, 2024
60967b4
Merge branch 'main' of https://github.com/jpmorganchase/salt-ds into …
Vineet119 Jun 12, 2024
c2daaa8
corrected secondary button tokens
Vineet119 Jun 13, 2024
7762e30
Merge branch 'main' of https://github.com/jpmorganchase/salt-ds into …
Vineet119 Jun 17, 2024
baf34ab
design review comments addressed
Vineet119 Jun 17, 2024
a48aa14
design review comments addressed
Vineet119 Jun 17, 2024
48026c2
design and tech review comments addressed
Vineet119 Jun 17, 2024
4821439
removed unnecessary css
Vineet119 Jun 17, 2024
de47c5a
design and tech review comments addressed
Vineet119 Jun 17, 2024
b2f31ef
added cursor unset in case of loading button
Vineet119 Jun 18, 2024
f6a04f2
added cursor unset in case of loading button
Vineet119 Jun 18, 2024
7ca748c
removed unnecessary import
Vineet119 Jun 18, 2024
cafce71
added contextual examples for loading button
Vineet119 Jun 19, 2024
a3ad508
Merge branch 'main' of https://github.com/jpmorganchase/salt-ds into …
Vineet119 Jun 19, 2024
1ca0108
corrected loadingText for without label variant loading buttons
Vineet119 Jun 26, 2024
77e4f98
corrected loadingText for without label variant loading buttons
Vineet119 Jun 26, 2024
ca10551
Merge branch 'main' of https://github.com/jpmorganchase/salt-ds into …
Vineet119 Jul 1, 2024
b89b9f1
corrected aria-label
Vineet119 Jul 1, 2024
b9ae7da
Merge branch 'main' of https://github.com/jpmorganchase/salt-ds into …
Vineet119 Jul 4, 2024
88b5f1f
review comments addressed
Vineet119 Jul 4, 2024
fde21c8
Merge branch 'main' of https://github.com/jpmorganchase/salt-ds into …
Vineet119 Jul 8, 2024
200c7b6
removed aria-live attribute
Vineet119 Jul 8, 2024
a0e8f87
merge latest
Vineet119 Aug 1, 2024
2459eb6
width changes for loading button
Vineet119 Aug 1, 2024
6e8366a
adjusted cypress test cases
Vineet119 Aug 1, 2024
972310d
removed unnecessary line
Vineet119 Aug 1, 2024
f139af1
added changeset
Vineet119 Aug 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/core/src/__tests__/__e2e__/button/Button.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,16 @@ describe("Given a Button", () => {
cy.mount(<FeatureButton />);
cy.findByRole("button").should("have.attr", "type", "button");
});
it("should have an aria-live attribute as assertive when isLoading is true", () => {
cy.mount(<FeatureButton isLoading />);
cy.findByRole("button").should("have.attr", "aria-live", "assertive");
});
it("should have overlay when isLoading is true", () => {
cy.mount(<FeatureButton isLoading />);
cy.get(".saltButton-loading-overlay").should("exist");
});
it("should have spinner when isLoading is true", () => {
cy.mount(<FeatureButton isLoading />);
cy.get(".saltButton-loading-spinner").should("exist");
});
});
69 changes: 63 additions & 6 deletions packages/core/src/button/Button.css
Original file line number Diff line number Diff line change
Expand Up @@ -91,23 +91,23 @@
}

/* Pseudo-class applied to the root element on focus when Button is active */
.saltButton.saltButton-active:focus-visible,
.saltButton:focus-visible(:active) {
.saltButton.saltButton-active:focus-visible:not(.saltButton-loading-primary, .saltButton-loading-secondary, .saltButton-loading-cta),
.saltButton:focus-visible(:active):not(.saltButton-loading-primary, .saltButton-loading-secondary, .saltButton-loading-cta) {
background: var(--saltButton-background-active-hover, var(--button-background));
color: var(--saltButton-text-color-active-hover, var(--button-text-color));
border-color: var(--saltButton-borderColor-hover, var(--button-borderColor-hover));
}

/* Pseudo-class applied to the root element on hover when Button is not active or disabled */
.saltButton:hover {
.saltButton:hover:not(.saltButton-loading-primary, .saltButton-loading-secondary, .saltButton-loading-cta) {
origami-z marked this conversation as resolved.
Show resolved Hide resolved
background: var(--saltButton-background-hover, var(--button-background-hover));
color: var(--saltButton-text-color-hover, var(--button-text-color-hover));
border-color: var(--saltButton-borderColor-hover, var(--button-borderColor-hover));
}

/* Pseudo-class applied to the root element when Button is active and not disabled */
.saltButton:active,
.saltButton.saltButton-active {
.saltButton:active:not(.saltButton-loading-primary, .saltButton-loading-secondary, .saltButton-loading-cta),
.saltButton.saltButton-active:not(.saltButton-loading-primary, .saltButton-loading-secondary, .saltButton-loading-cta) {
background: var(--saltButton-background-active, var(--button-background-active));
color: var(--saltButton-text-color-active, var(--button-text-color-active));
border-color: var(--saltButton-borderColor-active, var(--button-borderColor-active));
Expand All @@ -124,7 +124,7 @@
/* Pseudo-class applied to the root element if disabled={true} */
.saltButton:disabled,
.saltButton-disabled,
/* Overrides to apply the disabled style when the button is focusable while also disabled */
/* Overrides to apply the disabled style when the button is focusable while also disabled */
.saltButton-disabled:active,
.saltButton-disabled:focus-visible,
.saltButton-disabled:hover {
Expand All @@ -133,3 +133,60 @@
cursor: var(--saltButton-cursor-disabled, var(--salt-actionable-cursor-disabled));
border-color: var(--saltButton-borderColor-disabled, var(--button-borderColor-disabled));
}

.saltButton-loading-cta,
.saltButton-loading-cta:active,
.saltButton-loading-cta:focus-visible,
.saltButton-loading-cta:hover {
--button-borderColor: var(--salt-actionable-cta-borderColor);
}

.saltButton-loading-primary,
.saltButton-loading-primary:active,
.saltButton-loading-primary:focus-visible,
.saltButton-loading-primary:hover {
--button-borderColor: var(--salt-actionable-primary-borderColor);
}

.saltButton-loading-secondary,
.saltButton-loading-secondary:active,
.saltButton-loading-secondary:focus-visible,
.saltButton-loading-secondary:hover {
--button-borderColor: var(--salt-container-primary-background);
}

.saltButton-loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--salt-container-primary-background);
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
gap: var(--salt-spacing-50);
border-radius: var(--saltButton-borderRadius, var(--salt-palette-corner-weak, 0));
}

.saltButton-hidden-accessible-element {
border: 0px solid;
clip: rect(0px, 0px, 0px, 0px);
height: 1px;
overflow: hidden;
padding: 0px;
position: absolute;
white-space: nowrap;
width: 1px;
}

.saltButton-hidden-element {
display: flex;
gap: var(--salt-spacing-50);
align-items: center;
}

.saltButton-loading-text {
color: var(--salt-content-primary-foreground);
}
51 changes: 50 additions & 1 deletion packages/core/src/button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { clsx } from "clsx";
import { useComponentCssInjection } from "@salt-ds/styles";
import { useWindow } from "@salt-ds/window";
import { makePrefixer } from "../utils";
import { Spinner } from "../spinner";

import buttonCss from "./Button.css";
import { useButton } from "./useButton";
Expand All @@ -26,6 +27,19 @@ export interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
* 'primary' is the default value.
*/
variant?: ButtonVariant;
/**
* To show a loading spinner.
*/
isLoading?: boolean;
joshwooding marked this conversation as resolved.
Show resolved Hide resolved
/**
* For the screen reader to announce while button is in loading state
* 'Loading' is the default value.
*/
loadingText?: string;
joshwooding marked this conversation as resolved.
Show resolved Hide resolved
/**
* If `true`, a loading text with spinner will be shown while button is in loading state.
*/
showLoadingText?: boolean;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
Expand All @@ -41,6 +55,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
onClick,
type = "button",
variant = "primary",
isLoading,
loadingText = "Loading",
showLoadingText,
...restProps
},
ref?
Expand Down Expand Up @@ -72,15 +89,47 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
withBaseName(variant),
{
[withBaseName("disabled")]: disabled,
[withBaseName(`loading-${variant}`)]: isLoading,
[withBaseName("active")]: active,
},
className
)}
aria-disabled={isLoading || disabled}
aria-live={isLoading !== undefined ? "assertive" : undefined}
{...restProps}
ref={ref}
type={type}
>
{children}
{isLoading ? (
<>
<span className={clsx(withBaseName("loading-overlay"), className)}>
<Spinner
size="small"
className={clsx(withBaseName("loading-spinner"), className)}
/>
<span
joshwooding marked this conversation as resolved.
Show resolved Hide resolved
className={clsx(
{
[withBaseName("hidden-accessible-element")]:
!showLoadingText,
[withBaseName("loading-text")]: showLoadingText,
},
className
)}
>
{loadingText}
</span>
</span>
<span
aria-hidden="true"
className={clsx(withBaseName("hidden-element"))}
>
{children}
</span>
</>
) : (
children
)}
</button>
);
}
Expand Down
103 changes: 103 additions & 0 deletions packages/core/stories/button/button.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useState } from "react";
import { Button, ButtonProps, StackLayout } from "@salt-ds/core";
import {
DownloadIcon,
SearchIcon,
SendIcon,
SettingsSolidIcon,
ChevronRightIcon,
} from "@salt-ds/icons";
import { Meta, StoryFn } from "@storybook/react";

Expand Down Expand Up @@ -168,3 +170,104 @@ export const FullWidth: StoryFn<typeof Button> = () => {
</StackLayout>
);
};

const LoadingButtonGrid = ({
primaryButtonLabel,
secondaryButtonLabel,
ctaButtonLabel,
loadingText,
showLoadingText,
}: {
primaryButtonLabel: string;
secondaryButtonLabel: string;
ctaButtonLabel: string;
loadingText: string;
showLoadingText?: boolean;
}) => {
const [primaryLoadingState, setPrimaryLoadingState] = useState(false);
const [secondaryLoadingState, setSecondaryLoadingState] = useState(false);
const [ctaLoadingState, setCtaLoadingState] = useState(false);

const handlePrimaryClick = () => {
setPrimaryLoadingState(true);
setTimeout(() => {
setPrimaryLoadingState(false);
}, 3000);
};
const handleSecondaryClick = () => {
setSecondaryLoadingState(true);
setTimeout(() => {
setSecondaryLoadingState(false);
}, 3000);
};
const handleCtaClick = () => {
setCtaLoadingState(true);
setTimeout(() => {
setCtaLoadingState(false);
}, 3000);
};

return (
<div
style={{
display: "grid",
gridTemplateColumns: "auto auto auto",
gridTemplateRows: "auto",
gridGap: 10,
}}
>
<Button
variant="primary"
showLoadingText={showLoadingText}
loadingText={loadingText}
isLoading={primaryLoadingState}
onClick={handlePrimaryClick}
>
{primaryButtonLabel}
<ChevronRightIcon aria-hidden />
</Button>
<Button
variant="secondary"
showLoadingText={showLoadingText}
loadingText={loadingText}
isLoading={secondaryLoadingState}
onClick={handleSecondaryClick}
>
{secondaryButtonLabel}
<ChevronRightIcon aria-hidden />
</Button>
<Button
variant="cta"
showLoadingText={showLoadingText}
loadingText={loadingText}
isLoading={ctaLoadingState}
onClick={handleCtaClick}
>
{ctaButtonLabel}
</Button>
</div>
);
};

export const LoadingButtons: StoryFn<typeof Button> = () => {
return (
<LoadingButtonGrid
joshwooding marked this conversation as resolved.
Show resolved Hide resolved
primaryButtonLabel="Submit"
secondaryButtonLabel="Search"
ctaButtonLabel="Continue"
loadingText="Loading"
/>
);
};

export const LoadingButtonsWithLabel: StoryFn<typeof Button> = () => {
return (
<LoadingButtonGrid
primaryButtonLabel="Next Page"
secondaryButtonLabel="Search"
ctaButtonLabel="Click to Continue"
loadingText="Loading"
showLoadingText
/>
);
};
29 changes: 29 additions & 0 deletions site/docs/components/button/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,34 @@ A button with the prop `disabled={true}` will suppress all functionality. If you

Use full width buttons on mobile devices or smaller viewport, the button will take up the full width of its container.

</LivePreview>

<LivePreview componentName="button" exampleName="LoadingButtons" >

## Loading Buttons

The buttons will show a loading spinner when setting the prop `isLoading` to `true`. You should pass a message using the `loadingText` prop for screenreader to announce while loading state is true. It defaults to "Loading".

A Loading Button provides immediate feedback to the user after they trigger an action. It's typically used in situations where a user's action requires some processing time (more than a few seconds) before the result can be displayed. Use cases include:

- Form Submission: When a user submits a [form](/salt/patterns/forms), the Loading Button indicates that the form data is being processed. This helps prevent the user from clicking the submit button multiple times, which could potentially cause duplicate submissions or errors.
- Data Fetching: If a button triggers a data fetch from a server, a Loading Button indicates that the data is being retrieved. This is especially useful in applications where data retrieval might take a significant amount of time.
- Processing Actions: If a button triggers a complex action that requires processing, such as generating a report or performing a calculation, a Loading Button indicates that the action is in progress.

The [Content Status](salt/patterns/content-status) pattern can be displayed with a Loading Button to provide additional feedback to the user.

Alternative solutions for other use cases:

- Instant Actions: If an action is instantaneous or very quick. A Loading Button is not necessary.
- Complex processing: Use a [Progress Bar](/salt/components/progress) when a task is going to take a significant amount of time (more than a few seconds) and when it's possible to estimate the progress of the task. This could be when uploading or downloading large files, installing software, or completing a multi-step process.

</LivePreview>

<LivePreview componentName="button" exampleName="LoadingButtonsWithLabel" >

## Loading Buttons with Label

The buttons will show a loading spinner when setting the prop `isLoading` to `true`. You should pass a message using the `loadingText` prop and `showLoadingText` as `true` for screenreader to announce while loading state is true. It defaults to "Loading".

</LivePreview>
</LivePreviewControls>
Loading
Loading