Skip to content

Commit 41d858c

Browse files
authored
Add shim behaviours (debounce and space press) for Button component (#231)
1 parent e0aaaa2 commit 41d858c

File tree

3 files changed

+173
-19
lines changed

3 files changed

+173
-19
lines changed

src/components/form-elements/button/Button.tsx

Lines changed: 84 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,54 @@
1-
import React, { FC, HTMLProps } from 'react';
1+
import React, { EventHandler, FC, HTMLProps, KeyboardEvent, SyntheticEvent, useCallback, useRef } from 'react';
22
import classNames from 'classnames';
33

4+
// Debounce timeout - default 1 second
5+
export const DefaultButtonDebounceTimeout = 1000;
6+
47
export interface ButtonProps extends HTMLProps<HTMLButtonElement> {
58
type?: 'button' | 'submit' | 'reset';
69
disabled?: boolean;
710
secondary?: boolean;
811
reverse?: boolean;
912
as?: 'button';
13+
preventDoubleClick?: boolean;
14+
debounceTimeout?: number;
1015
}
1116

1217
export interface ButtonLinkProps extends HTMLProps<HTMLAnchorElement> {
1318
disabled?: boolean;
1419
secondary?: boolean;
1520
reverse?: boolean;
1621
as?: 'a';
22+
preventDoubleClick?: boolean;
23+
debounceTimeout?: number;
24+
}
25+
26+
const useDebounceTimeout = (
27+
fn?: EventHandler<SyntheticEvent>,
28+
timeout: number = DefaultButtonDebounceTimeout,
29+
) => {
30+
const timeoutRef = useRef<number>();
31+
32+
if (!fn) return undefined;
33+
34+
const handler: EventHandler<SyntheticEvent> = (event) => {
35+
event.persist();
36+
37+
if (timeoutRef.current) {
38+
event.preventDefault();
39+
event.stopPropagation();
40+
return
41+
}
42+
43+
fn(event);
44+
45+
timeoutRef.current = window.setTimeout(() => {
46+
timeoutRef.current = undefined;
47+
}, timeout);
48+
49+
}
50+
51+
return handler;
1752
}
1853

1954
export const Button: FC<ButtonProps> = ({
@@ -22,24 +57,31 @@ export const Button: FC<ButtonProps> = ({
2257
secondary,
2358
reverse,
2459
type = 'submit',
60+
preventDoubleClick = false,
61+
debounceTimeout = DefaultButtonDebounceTimeout,
62+
onClick,
2563
...rest
26-
}) => (
27-
// eslint-disable-next-line react/button-has-type
28-
<button
29-
className={classNames(
30-
'nhsuk-button',
31-
{ 'nhsuk-button--disabled': disabled },
32-
{ 'nhsuk-button--secondary': secondary },
33-
{ 'nhsuk-button--reverse': reverse },
34-
className,
35-
)}
36-
disabled={disabled}
37-
aria-disabled={disabled ? 'true' : 'false'}
38-
type={type}
39-
{...rest}
40-
/>
41-
);
64+
}) => {
65+
const debouncedHandleClick = useDebounceTimeout(onClick, debounceTimeout);
4266

67+
return (
68+
// eslint-disable-next-line react/button-has-type
69+
<button
70+
className={classNames(
71+
'nhsuk-button',
72+
{ 'nhsuk-button--disabled': disabled },
73+
{ 'nhsuk-button--secondary': secondary },
74+
{ 'nhsuk-button--reverse': reverse },
75+
className,
76+
)}
77+
disabled={disabled}
78+
aria-disabled={disabled ? 'true' : 'false'}
79+
type={type}
80+
onClick={preventDoubleClick ? debouncedHandleClick : onClick}
81+
{...rest}
82+
/>
83+
);
84+
}
4385
export const ButtonLink: FC<ButtonLinkProps> = ({
4486
className,
4587
role = 'button',
@@ -48,8 +90,28 @@ export const ButtonLink: FC<ButtonLinkProps> = ({
4890
disabled,
4991
secondary,
5092
reverse,
93+
preventDoubleClick = false,
94+
debounceTimeout = DefaultButtonDebounceTimeout,
95+
onClick,
5196
...rest
52-
}) => (
97+
}) => {
98+
const debouncedHandleClick = useDebounceTimeout(onClick, debounceTimeout);
99+
100+
/**
101+
* Recreate the shim behaviour from NHS.UK/GOV.UK Frontend
102+
* https://github.com/alphagov/govuk-frontend/blob/main/packages/govuk-frontend/src/govuk/components/button/button.mjs
103+
* https://github.com/nhsuk/nhsuk-frontend/blob/main/packages/components/button/button.js
104+
*/
105+
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLAnchorElement>) => {
106+
const { currentTarget } = event;
107+
108+
if (role === 'button' && event.key === ' ') {
109+
event.preventDefault();
110+
currentTarget.click();
111+
}
112+
}, [role]);
113+
114+
return (
53115
<a
54116
className={classNames(
55117
'nhsuk-button',
@@ -61,11 +123,14 @@ export const ButtonLink: FC<ButtonLinkProps> = ({
61123
role={role}
62124
aria-disabled={disabled ? 'true' : 'false'}
63125
draggable={draggable}
126+
onKeyDown={handleKeyDown}
127+
onClick={preventDoubleClick ? debouncedHandleClick : onClick}
64128
{...rest}
65129
>
66130
{children}
67131
</a>
68-
);
132+
);
133+
}
69134

70135
const ButtonWrapper: FC<ButtonLinkProps | ButtonProps> = ({ href, as, ...rest }) => {
71136
if (as === 'a') {

src/components/form-elements/button/__tests__/Button.test.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,76 @@ describe('Button', () => {
7070
).toBe('true');
7171
expect(container.querySelector('button.nhsuk-button.nhsuk-button--disabled')).toBeDisabled();
7272
});
73+
74+
it('preventDoubleClick calls debounced function', () => {
75+
jest.useFakeTimers();
76+
const clickHandler = jest.fn();
77+
78+
const { container } = render(
79+
<Button preventDoubleClick onClick={clickHandler}>
80+
Submit
81+
</Button>,
82+
);
83+
84+
const button = container.querySelector('button');
85+
86+
button?.click();
87+
expect(clickHandler).toHaveBeenCalledTimes(1);
88+
89+
button?.click();
90+
expect(clickHandler).toHaveBeenCalledTimes(1);
91+
92+
jest.runAllTimers();
93+
button?.click();
94+
expect(clickHandler).toHaveBeenCalledTimes(2);
95+
});
96+
97+
it('preventDoubleClick=false calls original function', () => {
98+
const clickHandler = jest.fn();
99+
100+
const { container } = render(
101+
<Button preventDoubleClick={false} onClick={clickHandler}>
102+
Submit
103+
</Button>,
104+
);
105+
106+
const button = container.querySelector('button');
107+
button?.click();
108+
expect(clickHandler).toHaveBeenCalledTimes(1);
109+
110+
button?.click();
111+
expect(clickHandler).toHaveBeenCalledTimes(2);
112+
113+
button?.click();
114+
expect(clickHandler).toHaveBeenCalledTimes(3);
115+
});
116+
117+
it('uses custom debounce timeout', () => {
118+
jest.useFakeTimers();
119+
120+
const clickHandler = jest.fn();
121+
122+
const { container } = render(
123+
<Button preventDoubleClick debounceTimeout={5000} onClick={clickHandler}>
124+
Submit
125+
</Button>,
126+
);
127+
128+
const button = container.querySelector('button');
129+
button?.click();
130+
expect(clickHandler).toHaveBeenCalledTimes(1);
131+
132+
button?.click();
133+
expect(clickHandler).toHaveBeenCalledTimes(1);
134+
135+
jest.advanceTimersByTime(4999);
136+
button?.click();
137+
expect(clickHandler).toHaveBeenCalledTimes(1);
138+
139+
jest.advanceTimersByTime(1);
140+
button?.click();
141+
expect(clickHandler).toHaveBeenCalledTimes(2);
142+
});
73143
});
74144

75145
describe('ButtonLink', () => {

stories/Form Elements/Button.stories.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,22 @@ export const Disabled: Story = { args: { disabled: true, children: 'Disabled' }
6868
export const LinkButton: Story = { args: { href: '/', children: 'As a Link' } };
6969
export const ForceButton: Story = { args: { as: 'button', children: 'As a Button' } };
7070
export const ForceAnchor: Story = { args: { as: 'a', children: 'As an Anchor' } };
71+
export const DebouncedButton: Story = {
72+
args: {
73+
preventDoubleClick: true,
74+
onClick: () => console.log(new Date()),
75+
children: 'Debounced Button',
76+
debounceTimeout: 5000,
77+
},
78+
render: (args) => <Button {...args} />,
79+
};
80+
export const DebouncedLinkButton: Story = {
81+
args: {
82+
preventDoubleClick: true,
83+
href: '#',
84+
onClick: () => console.log(new Date()),
85+
children: 'Debounced Button as Link',
86+
debounceTimeout: 5000,
87+
},
88+
render: (args) => <Button {...args} />,
89+
};

0 commit comments

Comments
 (0)