diff --git a/src/components/form-elements/button/Button.tsx b/src/components/form-elements/button/Button.tsx index 333e5c4d..27377ade 100644 --- a/src/components/form-elements/button/Button.tsx +++ b/src/components/form-elements/button/Button.tsx @@ -1,12 +1,17 @@ -import React, { FC, HTMLProps } from 'react'; +import React, { EventHandler, FC, HTMLProps, KeyboardEvent, SyntheticEvent, useCallback, useRef } from 'react'; import classNames from 'classnames'; +// Debounce timeout - default 1 second +export const DefaultButtonDebounceTimeout = 1000; + export interface ButtonProps extends HTMLProps { type?: 'button' | 'submit' | 'reset'; disabled?: boolean; secondary?: boolean; reverse?: boolean; as?: 'button'; + preventDoubleClick?: boolean; + debounceTimeout?: number; } export interface ButtonLinkProps extends HTMLProps { @@ -14,6 +19,36 @@ export interface ButtonLinkProps extends HTMLProps { secondary?: boolean; reverse?: boolean; as?: 'a'; + preventDoubleClick?: boolean; + debounceTimeout?: number; +} + +const useDebounceTimeout = ( + fn?: EventHandler, + timeout: number = DefaultButtonDebounceTimeout, +) => { + const timeoutRef = useRef(); + + if (!fn) return undefined; + + const handler: EventHandler = (event) => { + event.persist(); + + if (timeoutRef.current) { + event.preventDefault(); + event.stopPropagation(); + return + } + + fn(event); + + timeoutRef.current = window.setTimeout(() => { + timeoutRef.current = undefined; + }, timeout); + + } + + return handler; } export const Button: FC = ({ @@ -22,24 +57,31 @@ export const Button: FC = ({ secondary, reverse, type = 'submit', + preventDoubleClick = false, + debounceTimeout = DefaultButtonDebounceTimeout, + onClick, ...rest -}) => ( - // eslint-disable-next-line react/button-has-type - , + ); + + const button = container.querySelector('button'); + + button?.click(); + expect(clickHandler).toHaveBeenCalledTimes(1); + + button?.click(); + expect(clickHandler).toHaveBeenCalledTimes(1); + + jest.runAllTimers(); + button?.click(); + expect(clickHandler).toHaveBeenCalledTimes(2); + }); + + it('preventDoubleClick=false calls original function', () => { + const clickHandler = jest.fn(); + + const { container } = render( + , + ); + + const button = container.querySelector('button'); + button?.click(); + expect(clickHandler).toHaveBeenCalledTimes(1); + + button?.click(); + expect(clickHandler).toHaveBeenCalledTimes(2); + + button?.click(); + expect(clickHandler).toHaveBeenCalledTimes(3); + }); + + it('uses custom debounce timeout', () => { + jest.useFakeTimers(); + + const clickHandler = jest.fn(); + + const { container } = render( + , + ); + + const button = container.querySelector('button'); + button?.click(); + expect(clickHandler).toHaveBeenCalledTimes(1); + + button?.click(); + expect(clickHandler).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(4999); + button?.click(); + expect(clickHandler).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(1); + button?.click(); + expect(clickHandler).toHaveBeenCalledTimes(2); + }); }); describe('ButtonLink', () => { diff --git a/stories/Form Elements/Button.stories.tsx b/stories/Form Elements/Button.stories.tsx index 8d82f81d..59d798c0 100644 --- a/stories/Form Elements/Button.stories.tsx +++ b/stories/Form Elements/Button.stories.tsx @@ -68,3 +68,22 @@ export const Disabled: Story = { args: { disabled: true, children: 'Disabled' } export const LinkButton: Story = { args: { href: '/', children: 'As a Link' } }; export const ForceButton: Story = { args: { as: 'button', children: 'As a Button' } }; export const ForceAnchor: Story = { args: { as: 'a', children: 'As an Anchor' } }; +export const DebouncedButton: Story = { + args: { + preventDoubleClick: true, + onClick: () => console.log(new Date()), + children: 'Debounced Button', + debounceTimeout: 5000, + }, + render: (args) =>