Skip to content

Commit

Permalink
Button: prevent double-click & add loader
Browse files Browse the repository at this point in the history
Why: Nudge developers to consider using a loading
component & preventing users' double/multi-clicks
  • Loading branch information
MikkelHansenAbtion committed Nov 11, 2024
1 parent c97ea23 commit b504176
Show file tree
Hide file tree
Showing 5 changed files with 71 additions and 12 deletions.
3 changes: 1 addition & 2 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ module.exports = {
"linebreak-style": ["error", "unix"],
camelcase: ["error"],

// Don't allow console.log
"no-console": ["error"],
// Allow console.log
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
Expand Down
6 changes: 5 additions & 1 deletion components/Button/index.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
.Button {
@apply border border-transparent font-medium leading-4 shadow-sm;
@apply relative border border-transparent font-medium leading-4 shadow-sm;

&--isLoading {
@apply cursor-progress;
}

// Round if no rounding class is specified
&:not([class*="rounded"]) {
Expand Down
31 changes: 28 additions & 3 deletions components/Button/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from "react"
import { render } from "@testing-library/react"
import { render, waitFor } from "@testing-library/react"

import Button, { ButtonProps } from "."
import userEvent from "@testing-library/user-event"

const defaultProps: ButtonProps = {
children: "Button text",
Expand All @@ -22,7 +23,7 @@ describe(Button, () => {

const button = getByText(defaultProps.children as string)

expect(button).toHaveClass("Button--md")
expect(button.parentElement).toHaveClass("Button--md")
})
})

Expand All @@ -34,7 +35,31 @@ describe(Button, () => {

const button = getByText(defaultProps.children as string)

expect(button).toHaveClass("Button--primary")
expect(button.parentElement).toHaveClass("Button--primary")
})
})

describe("when user clicks", () => {
it("shows a loader", async () => {
const { getByText } = render(
<Button
{...defaultProps}
onClick={() => new Promise((resolve) => setTimeout(resolve, 200))}
/>
)
await userEvent.click(getByText(defaultProps.children as string))
await waitFor(() => expect(getByText("Loading...")).toBeInTheDocument())
})

it("prevents calling onClick twice", async () => {
const onClickFunc = jest.fn(
() => new Promise((resolve) => setTimeout(resolve, 200))
)
const { getByText } = render(
<Button {...defaultProps} onClick={onClickFunc} />
)
await userEvent.dblClick(getByText(defaultProps.children as string))
expect(onClickFunc).toHaveBeenCalledTimes(1)
})
})
})
33 changes: 30 additions & 3 deletions components/Button/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from "react"
import React, { MouseEventHandler, useState } from "react"
import classNames from "classnames"
import "./index.scss"

Expand All @@ -17,16 +17,43 @@ export interface ButtonProps
}

export default function Button(props: ButtonProps): JSX.Element {
const { size, variant, className, ...rest } = props
const { size, variant, className, onClick, children, ...rest } = props
const [isLoading, setIsLoading] = useState(false)

const handleClick: MouseEventHandler<HTMLButtonElement> = async (e) => {
if (isLoading) return
setIsLoading(true)
try {
await Promise.resolve(onClick?.(e))
} finally {
setIsLoading(false)
}
}

const usedClassName = classNames(
"Button",
{
[`Button--${size}`]: size,
[`Button--${variant}`]: variant,
[`Button--isLoading`]: isLoading,
},
className
)

return <button className={usedClassName} {...rest} />
return (
<button
className={usedClassName}
{...(onClick ? { onClick: handleClick } : {})}
{...rest}
>
<div className={isLoading ? "invisible" : "visible"}>{children}</div>
<div
className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 ${
isLoading ? "visible" : "invisible"
}`}
>
<p>Loading...</p>
</div>
</button>
)
}
10 changes: 7 additions & 3 deletions stories/components/Button.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
import React from "react"
import { ComponentStory, ComponentMeta } from "@storybook/react"
import { Meta, StoryFn } from "@storybook/react"

import Button, { ButtonProps } from "~/components/Button"

// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
export default {
title: "Design System/Button",
component: Button,
} as ComponentMeta<typeof Button>
} as Meta<typeof Button>

// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />
const Template: StoryFn<typeof Button> = (args) => <Button {...args} />

const sharedProps: Partial<ButtonProps> = {
children: "Button text",
size: "md",
disabled: false,
onClick: async () => {
await new Promise((resolve) => setTimeout(resolve, 500))
console.log("onClick work finished")
},
}

// More on args: https://storybook.js.org/docs/react/writing-stories/args
Expand Down

0 comments on commit b504176

Please sign in to comment.