Skip to content

Commit 09b5724

Browse files
committed
Add forwardRef back
1 parent 7817444 commit 09b5724

File tree

2 files changed

+175
-82
lines changed

2 files changed

+175
-82
lines changed

src/index.spec.tsx

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react';
1+
import React, { createRef } from 'react';
22
import { act, render, screen } from '@testing-library/react';
33
import userEvent from '@testing-library/user-event';
44

@@ -39,7 +39,15 @@ describe('<AsyncButton /> component', () => {
3939
expect(screen.queryByRole('button')).toBeInTheDocument();
4040
});
4141

42-
it.todo('passes ref correctly');
42+
it('passes ref correctly', () => {
43+
const ref = createRef<HTMLButtonElement>();
44+
45+
render(<AsyncButton {...defaultProps} ref={ref} />);
46+
47+
const button = screen.getByRole('button');
48+
49+
expect(ref.current).toBe(button);
50+
});
4351

4452
it('calls onClick properly', async () => {
4553
const onClick = jest.fn();
@@ -180,4 +188,71 @@ describe('<AsyncButton /> component', () => {
180188
const button5 = screen.getByRole('button');
181189
expect(button5).toHaveTextContent('Click me');
182190
});
191+
192+
it('should allow button props to be passed by default', () => {
193+
// @ts-expect-no-error
194+
<AsyncButton {...defaultProps} type="submit" />;
195+
});
196+
197+
it('should allow button props to be passed given as="button"', () => {
198+
// @ts-expect-no-error
199+
<AsyncButton {...defaultProps} as="button" disabled />;
200+
});
201+
202+
it('should not allow link props to be passed given as="button"', () => {
203+
// @ts-expect-error-next-line
204+
<AsyncButton {...defaultProps} as="button" href="https://example.com" />;
205+
206+
// Sanity check
207+
// @ts-expect-error-next-line
208+
<button href="https://example.com"></button>;
209+
});
210+
211+
it('should allow link props to be passed given as="a"', () => {
212+
// @ts-expect-no-error
213+
<AsyncButton {...defaultProps} as="a" href="https://example.com" />;
214+
});
215+
216+
it('should not allow button props to be passed given as="a"', () => {
217+
// @ts-expect-error-next-line
218+
<AsyncButton {...defaultProps} as="a" disabled href="https://example.com" />;
219+
220+
// Sanity check
221+
// @ts-expect-error-next-line
222+
<a disabled href="https://example.com">
223+
Click me
224+
</a>;
225+
});
226+
227+
it('should not allow button props to be passed given as={MyButton}', () => {
228+
function MyButton() {
229+
return <button type="submit"></button>;
230+
}
231+
232+
// @ts-expect-error-next-line
233+
<AsyncButton {...defaultProps} as={MyButton} type="submit" />;
234+
235+
// Sanity check
236+
function MyCustomComponent({ as, ...otherProps }: { as: React.ElementType }) {
237+
const Component = as || 'div';
238+
return <Component {...otherProps} />;
239+
}
240+
241+
// @ts-expect-error-next-line
242+
<MyCustomComponent as={MyButton} type="submit" />;
243+
});
244+
245+
it('should not allow invalid values for as', () => {
246+
// @ts-expect-error-next-line
247+
<AsyncButton {...defaultProps} as={5} type="submit" />;
248+
249+
// Sanity check
250+
function MyCustomComponent({ as }: { as: React.ElementType }) {
251+
const Component = as || 'div';
252+
return <Component />;
253+
}
254+
255+
// @ts-expect-error-next-line
256+
<MyCustomComponent as={5} />;
257+
});
183258
});

src/index.tsx

Lines changed: 98 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -21,90 +21,104 @@ const STATES = {
2121
SUCCESS: 'success',
2222
};
2323

24-
export default function AsyncButton<T extends React.ElementType>({
25-
as,
26-
errorConfig,
27-
onClick,
28-
pendingConfig,
29-
resetTimeout = 2000,
30-
successConfig,
31-
...otherProps
32-
}: AsyncButtonProps<T>) {
33-
const [buttonState, setButtonState] = useState(STATES.INIT);
34-
const cancellablePromise = useRef<ReturnType<typeof makeCancellable>>();
35-
const timeout = useRef<ReturnType<typeof setTimeout>>();
36-
37-
useEffect(
38-
() => () => {
39-
if (cancellablePromise.current) {
40-
cancellablePromise.current.cancel();
41-
}
42-
clearTimeout(timeout.current);
43-
},
44-
[],
45-
);
46-
47-
const onClickInternal = useCallback(
48-
(event: React.MouseEvent) => {
49-
if (!onClick) {
50-
return;
51-
}
24+
const AsyncButton = React.forwardRef(
25+
<T extends React.ElementType>(
26+
{
27+
as,
28+
errorConfig,
29+
onClick,
30+
pendingConfig,
31+
resetTimeout = 2000,
32+
successConfig,
33+
...otherProps
34+
}: AsyncButtonProps<T>,
35+
ref: React.ForwardedRef<any>,
36+
) => {
37+
const [buttonState, setButtonState] = useState(STATES.INIT);
38+
const cancellablePromise = useRef<ReturnType<typeof makeCancellable>>();
39+
const timeout = useRef<ReturnType<typeof setTimeout>>();
40+
41+
useEffect(
42+
() => () => {
43+
if (cancellablePromise.current) {
44+
cancellablePromise.current.cancel();
45+
}
46+
clearTimeout(timeout.current);
47+
},
48+
[],
49+
);
50+
51+
const onClickInternal = useCallback(
52+
(event: React.MouseEvent) => {
53+
if (!onClick) {
54+
return;
55+
}
5256

53-
clearTimeout(timeout.current);
54-
55-
const onSuccess = () => {
56-
setButtonState(STATES.SUCCESS);
57-
};
58-
59-
const onError = () => {
60-
setButtonState(STATES.ERROR);
61-
};
62-
63-
const finallyCallback = () => {
64-
timeout.current = setTimeout(() => {
65-
setButtonState(STATES.INIT);
66-
}, resetTimeout);
67-
};
68-
69-
try {
70-
const result = onClick(event);
71-
setButtonState(STATES.PENDING);
72-
73-
if (result instanceof Promise) {
74-
cancellablePromise.current = makeCancellable(result);
75-
cancellablePromise.current.promise
76-
.then(onSuccess)
77-
.catch(onError)
78-
.finally(finallyCallback);
79-
} else {
80-
onSuccess();
57+
clearTimeout(timeout.current);
58+
59+
const onSuccess = () => {
60+
setButtonState(STATES.SUCCESS);
61+
};
62+
63+
const onError = () => {
64+
setButtonState(STATES.ERROR);
65+
};
66+
67+
const finallyCallback = () => {
68+
timeout.current = setTimeout(() => {
69+
setButtonState(STATES.INIT);
70+
}, resetTimeout);
71+
};
72+
73+
try {
74+
const result = onClick(event);
75+
setButtonState(STATES.PENDING);
76+
77+
if (result instanceof Promise) {
78+
cancellablePromise.current = makeCancellable(result);
79+
cancellablePromise.current.promise
80+
.then(onSuccess)
81+
.catch(onError)
82+
.finally(finallyCallback);
83+
} else {
84+
onSuccess();
85+
finallyCallback();
86+
}
87+
} catch (error) {
88+
onError();
8189
finallyCallback();
8290
}
83-
} catch (error) {
84-
onError();
85-
finallyCallback();
91+
},
92+
[onClick, resetTimeout],
93+
);
94+
95+
const Component = as || 'button';
96+
97+
const buttonConfig: Config<typeof Component> | null | undefined = (() => {
98+
switch (buttonState) {
99+
case STATES.ERROR:
100+
return errorConfig;
101+
case STATES.PENDING:
102+
return pendingConfig;
103+
case STATES.SUCCESS:
104+
return successConfig;
105+
default:
106+
return null;
86107
}
87-
},
88-
[onClick, resetTimeout],
89-
);
90-
91-
const Component = as || 'button';
92-
93-
const buttonConfig: Config<typeof Component> | null | undefined = (() => {
94-
switch (buttonState) {
95-
case STATES.ERROR:
96-
return errorConfig;
97-
case STATES.PENDING:
98-
return pendingConfig;
99-
case STATES.SUCCESS:
100-
return successConfig;
101-
default:
102-
return null;
103-
}
104-
})();
105-
106-
return <Component onClick={onClick ? onClickInternal : null} {...otherProps} {...buttonConfig} />;
107-
}
108+
})();
109+
110+
return (
111+
<Component
112+
ref={ref}
113+
onClick={onClick ? onClickInternal : null}
114+
{...otherProps}
115+
{...buttonConfig}
116+
/>
117+
);
118+
},
119+
);
120+
121+
AsyncButton.displayName = 'AsyncButton';
108122

109123
const configProps = {
110124
children: PropTypes.node,
@@ -122,3 +136,7 @@ AsyncButton.propTypes = {
122136
resetTimeout: PropTypes.number,
123137
successConfig: isConfigObject,
124138
};
139+
140+
export default AsyncButton as <T extends React.ElementType>(
141+
props: AsyncButtonProps<T> & { ref?: React.Ref<any> },
142+
) => React.ReactElement;

0 commit comments

Comments
 (0)