Skip to content

Commit f2a534f

Browse files
committed
feat: adds useClipboard hook
Adds a new hook to copy text to the clipboard with state reporting for user feedback fixes #48
1 parent dec6744 commit f2a534f

File tree

7 files changed

+238
-2
lines changed

7 files changed

+238
-2
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=commitd_hooks&metric=coverage)](https://sonarcloud.io/dashboard?id=commitd_hooks)
1414
![GitHub repo size](https://img.shields.io/github/repo-size/commitd/hooks)
1515

16-
For documentation see <https://https://hooks.committed.software>
16+
For documentation see <https://hooks.committed.software>
1717

1818
## Install
1919

src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './useBoolean'
2+
export * from './useClipboard'
23
export * from './useControllableState'
34
export * from './useDebounce'
45
export * from './useDebug'

src/useClipboard/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useClipboard'
+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Button, Row, Text, Tooltip } from '@committed/components'
2+
import { Meta, Story } from '@storybook/react'
3+
import React from 'react'
4+
import { useClipboard } from '.'
5+
6+
export interface UseClipboardDocsProps {
7+
/** set to change the default timeout for notification of copy */
8+
timeout?: number
9+
}
10+
11+
/**
12+
* useClipboard hook can be used to copy text to the clipboard and report.
13+
*
14+
* Returns a function to set the copied text
15+
*/
16+
export const UseClipboardDocs = ({ timeout = 2000 }: UseClipboardDocsProps) =>
17+
null
18+
19+
export default {
20+
title: 'Hooks/useClipboard',
21+
component: UseClipboardDocs,
22+
excludeStories: ['UseClipboardDocs'],
23+
} as Meta
24+
25+
const Template: Story<UseClipboardDocsProps> = ({ timeout }) => {
26+
const { copy, copied } = useClipboard(timeout)
27+
const text = 'To be copied'
28+
return (
29+
<Row gap css={{ alignItems: 'baseline' }}>
30+
<Text>{text}</Text>
31+
<Tooltip open={copied} content={`Copied`}>
32+
<Button onClick={() => copy(text)}>Copy</Button>
33+
</Tooltip>
34+
</Row>
35+
)
36+
}
37+
38+
export const Default = Template.bind({})
39+
Default.args = {}

src/useClipboard/useClipboard.test.ts

+134
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { act, renderHook } from '@testing-library/react-hooks'
2+
import { useClipboard } from '.'
3+
4+
test('Should set error if clipboard not supported', () => {
5+
const { result } = renderHook(() => useClipboard())
6+
7+
act(() => {
8+
result.current.copy('test')
9+
})
10+
11+
expect(result.current.copied).toBeFalsy()
12+
expect(result.current.error).toBeDefined()
13+
})
14+
15+
describe('useClipboard with mock', () => {
16+
const originalClipboard = { ...global.navigator.clipboard }
17+
18+
beforeEach(() => {
19+
jest.useFakeTimers()
20+
const mockClipboard = {
21+
writeText: jest.fn().mockImplementation(() => Promise.resolve()),
22+
}
23+
//@ts-ignore
24+
global.navigator.clipboard = mockClipboard
25+
})
26+
27+
afterEach(() => {
28+
jest.resetAllMocks()
29+
//@ts-ignore
30+
global.navigator.clipboard = originalClipboard
31+
act(() => {
32+
jest.runOnlyPendingTimers()
33+
})
34+
jest.useRealTimers()
35+
})
36+
37+
test('Should provide initial output as not copied', () => {
38+
const { result } = renderHook(() => useClipboard())
39+
expect(result.current.copied).toBeFalsy()
40+
expect(result.current.error).toBeUndefined()
41+
})
42+
43+
test('Should set copied value on copy', async () => {
44+
const { result } = renderHook(() => useClipboard())
45+
46+
await act(async () => {
47+
await result.current.copy('test')
48+
})
49+
50+
expect(result.current.copied).toBeTruthy()
51+
expect(result.current.error).toBeUndefined()
52+
expect(global.navigator.clipboard.writeText).toHaveBeenCalledTimes(1)
53+
expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith('test')
54+
})
55+
56+
test('Should set copied value on copy and then timeout', async () => {
57+
const { result } = renderHook(() => useClipboard(10))
58+
59+
await act(async () => {
60+
await result.current.copy('test')
61+
})
62+
63+
expect(result.current.copied).toBeTruthy()
64+
65+
act(() => {
66+
jest.advanceTimersByTime(100)
67+
})
68+
expect(result.current.copied).toBeFalsy()
69+
})
70+
71+
test('Should set error if copy error', async () => {
72+
const mockClipboard = {
73+
writeText: jest
74+
.fn()
75+
.mockImplementation(() => Promise.reject(new Error('test'))),
76+
}
77+
//@ts-ignore
78+
global.navigator.clipboard = mockClipboard
79+
80+
const { result } = renderHook(() => useClipboard())
81+
82+
await act(async () => {
83+
await result.current.copy('test')
84+
})
85+
86+
expect(result.current.copied).toBeFalsy()
87+
expect(result.current.error).toBeDefined()
88+
89+
act(() => {
90+
result.current.reset()
91+
})
92+
expect(result.current.copied).toBeFalsy()
93+
expect(result.current.error).toBeUndefined()
94+
})
95+
96+
test('Should reset copied value on multiple copy', async () => {
97+
const { result } = renderHook(() => useClipboard())
98+
99+
await act(async () => {
100+
await result.current.copy('test1')
101+
})
102+
103+
act(() => {
104+
jest.advanceTimersByTime(1000)
105+
})
106+
107+
await act(async () => {
108+
await result.current.copy('test2')
109+
})
110+
111+
act(() => {
112+
jest.advanceTimersByTime(1000)
113+
})
114+
115+
expect(result.current.copied).toBeTruthy()
116+
expect(result.current.error).toBeUndefined()
117+
118+
act(() => {
119+
jest.advanceTimersByTime(1000)
120+
})
121+
122+
expect(result.current.copied).toBeFalsy()
123+
124+
act(() => {
125+
result.current.reset()
126+
})
127+
128+
expect(result.current.copied).toBeFalsy()
129+
130+
expect(global.navigator.clipboard.writeText).toHaveBeenCalledTimes(2)
131+
expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith('test1')
132+
expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith('test2')
133+
})
134+
})

src/useClipboard/useClipboard.ts

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { useState } from 'react'
2+
3+
/**
4+
* useClipboard hook can be used to copy text to the clipboard and report.
5+
*
6+
* Returns a function to set the copied text, a reset function, a boolean to report successful copy and an error state.
7+
*
8+
* @param {number | undefined} timeout set to change the default timeout for notification of copy
9+
*/
10+
export function useClipboard(
11+
timeout = 2000
12+
): {
13+
copy: (valueToCopy: string) => void
14+
reset: () => void
15+
error: Error | undefined
16+
copied: boolean
17+
} {
18+
const [error, setError] = useState<Error | undefined>()
19+
const [copied, setCopied] = useState(false)
20+
const [copyTimeout, setCopyTimeout] = useState<
21+
ReturnType<typeof setTimeout> | undefined
22+
>()
23+
24+
const handleCopyResult = (value: boolean) => {
25+
copyTimeout && clearTimeout(copyTimeout)
26+
const newTimeout: ReturnType<typeof setTimeout> = setTimeout(
27+
() => setCopied(false),
28+
timeout
29+
)
30+
setCopyTimeout(newTimeout)
31+
setCopied(value)
32+
}
33+
34+
const copy = async (valueToCopy: string) => {
35+
if ('clipboard' in navigator) {
36+
return navigator.clipboard
37+
.writeText(valueToCopy)
38+
.then(() => handleCopyResult(true))
39+
.catch((err) => setError(err))
40+
} else {
41+
setError(new Error('useClipboard: clipboard is not supported'))
42+
}
43+
}
44+
45+
const reset = () => {
46+
setCopied(false)
47+
setError(undefined)
48+
copyTimeout && clearTimeout(copyTimeout)
49+
}
50+
51+
return { copy, reset, error, copied }
52+
}

src/useDebounce/useDebounce.test.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { act, renderHook } from '@testing-library/react-hooks'
22
import { useDebounce } from '.'
33

4-
jest.useFakeTimers()
4+
beforeEach(() => {
5+
jest.useFakeTimers()
6+
})
7+
8+
afterEach(() => {
9+
act(() => {
10+
jest.runOnlyPendingTimers()
11+
})
12+
jest.useRealTimers()
13+
})
514

615
test('Should set value to the initial immediately`', () => {
716
const { result } = renderHook(({ value }) => useDebounce(value, 1000), {

0 commit comments

Comments
 (0)