Skip to content

Commit

Permalink
Add shim behaviours (debounce and space press) for Button component (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
Tomdango authored Jun 10, 2024
1 parent e0aaaa2 commit 41d858c
Show file tree
Hide file tree
Showing 3 changed files with 173 additions and 19 deletions.
103 changes: 84 additions & 19 deletions src/components/form-elements/button/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,54 @@
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<HTMLButtonElement> {
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
secondary?: boolean;
reverse?: boolean;
as?: 'button';
preventDoubleClick?: boolean;
debounceTimeout?: number;
}

export interface ButtonLinkProps extends HTMLProps<HTMLAnchorElement> {
disabled?: boolean;
secondary?: boolean;
reverse?: boolean;
as?: 'a';
preventDoubleClick?: boolean;
debounceTimeout?: number;
}

const useDebounceTimeout = (
fn?: EventHandler<SyntheticEvent>,
timeout: number = DefaultButtonDebounceTimeout,
) => {
const timeoutRef = useRef<number>();

if (!fn) return undefined;

const handler: EventHandler<SyntheticEvent> = (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<ButtonProps> = ({
Expand All @@ -22,24 +57,31 @@ export const Button: FC<ButtonProps> = ({
secondary,
reverse,
type = 'submit',
preventDoubleClick = false,
debounceTimeout = DefaultButtonDebounceTimeout,
onClick,
...rest
}) => (
// eslint-disable-next-line react/button-has-type
<button
className={classNames(
'nhsuk-button',
{ 'nhsuk-button--disabled': disabled },
{ 'nhsuk-button--secondary': secondary },
{ 'nhsuk-button--reverse': reverse },
className,
)}
disabled={disabled}
aria-disabled={disabled ? 'true' : 'false'}
type={type}
{...rest}
/>
);
}) => {
const debouncedHandleClick = useDebounceTimeout(onClick, debounceTimeout);

return (
// eslint-disable-next-line react/button-has-type
<button
className={classNames(
'nhsuk-button',
{ 'nhsuk-button--disabled': disabled },
{ 'nhsuk-button--secondary': secondary },
{ 'nhsuk-button--reverse': reverse },
className,
)}
disabled={disabled}
aria-disabled={disabled ? 'true' : 'false'}
type={type}
onClick={preventDoubleClick ? debouncedHandleClick : onClick}
{...rest}
/>
);
}
export const ButtonLink: FC<ButtonLinkProps> = ({
className,
role = 'button',
Expand All @@ -48,8 +90,28 @@ export const ButtonLink: FC<ButtonLinkProps> = ({
disabled,
secondary,
reverse,
preventDoubleClick = false,
debounceTimeout = DefaultButtonDebounceTimeout,
onClick,
...rest
}) => (
}) => {
const debouncedHandleClick = useDebounceTimeout(onClick, debounceTimeout);

/**
* Recreate the shim behaviour from NHS.UK/GOV.UK Frontend
* https://github.com/alphagov/govuk-frontend/blob/main/packages/govuk-frontend/src/govuk/components/button/button.mjs
* https://github.com/nhsuk/nhsuk-frontend/blob/main/packages/components/button/button.js
*/
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLAnchorElement>) => {
const { currentTarget } = event;

if (role === 'button' && event.key === ' ') {
event.preventDefault();
currentTarget.click();
}
}, [role]);

return (
<a
className={classNames(
'nhsuk-button',
Expand All @@ -61,11 +123,14 @@ export const ButtonLink: FC<ButtonLinkProps> = ({
role={role}
aria-disabled={disabled ? 'true' : 'false'}
draggable={draggable}
onKeyDown={handleKeyDown}
onClick={preventDoubleClick ? debouncedHandleClick : onClick}
{...rest}
>
{children}
</a>
);
);
}

const ButtonWrapper: FC<ButtonLinkProps | ButtonProps> = ({ href, as, ...rest }) => {
if (as === 'a') {
Expand Down
70 changes: 70 additions & 0 deletions src/components/form-elements/button/__tests__/Button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,76 @@ describe('Button', () => {
).toBe('true');
expect(container.querySelector('button.nhsuk-button.nhsuk-button--disabled')).toBeDisabled();
});

it('preventDoubleClick calls debounced function', () => {
jest.useFakeTimers();
const clickHandler = jest.fn();

const { container } = render(
<Button preventDoubleClick onClick={clickHandler}>
Submit
</Button>,
);

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(
<Button preventDoubleClick={false} onClick={clickHandler}>
Submit
</Button>,
);

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(
<Button preventDoubleClick debounceTimeout={5000} onClick={clickHandler}>
Submit
</Button>,
);

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', () => {
Expand Down
19 changes: 19 additions & 0 deletions stories/Form Elements/Button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => <Button {...args} />,
};
export const DebouncedLinkButton: Story = {
args: {
preventDoubleClick: true,
href: '#',
onClick: () => console.log(new Date()),
children: 'Debounced Button as Link',
debounceTimeout: 5000,
},
render: (args) => <Button {...args} />,
};

0 comments on commit 41d858c

Please sign in to comment.