refactor(web): useClipboard hook to reduce duplication (#31308)

Signed-off-by: SherlockShemol <shemol@163.com>
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
This commit is contained in:
Shemol 2026-01-21 17:33:39 +08:00 committed by GitHub
parent aa68966b55
commit 1d778d532a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 86 additions and 95 deletions

View File

@ -3,10 +3,8 @@ import {
RiClipboardFill,
RiClipboardLine,
} from '@remixicon/react'
import copy from 'copy-to-clipboard'
import { debounce } from 'es-toolkit/compat'
import * as React from 'react'
import { useState } from 'react'
import { useClipboard } from 'foxact/use-clipboard'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Tooltip from '@/app/components/base/tooltip'
@ -21,32 +19,27 @@ const prefixEmbedded = 'overview.appInfo.embedded'
const CopyFeedback = ({ content }: Props) => {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState<boolean>(false)
const { copied, copy, reset } = useClipboard()
const onClickCopy = debounce(() => {
const handleCopy = useCallback(() => {
copy(content)
setIsCopied(true)
}, 100)
const onMouseLeave = debounce(() => {
setIsCopied(false)
}, 100)
}, [copy, content])
return (
<Tooltip
popupContent={
(isCopied
(copied
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''
}
>
<ActionButton>
<div
onClick={onClickCopy}
onMouseLeave={onMouseLeave}
onClick={handleCopy}
onMouseLeave={reset}
>
{isCopied && <RiClipboardFill className="h-4 w-4" />}
{!isCopied && <RiClipboardLine className="h-4 w-4" />}
{copied && <RiClipboardFill className="h-4 w-4" />}
{!copied && <RiClipboardLine className="h-4 w-4" />}
</div>
</ActionButton>
</Tooltip>
@ -57,21 +50,16 @@ export default CopyFeedback
export const CopyFeedbackNew = ({ content, className }: Pick<Props, 'className' | 'content'>) => {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState<boolean>(false)
const { copied, copy, reset } = useClipboard()
const onClickCopy = debounce(() => {
const handleCopy = useCallback(() => {
copy(content)
setIsCopied(true)
}, 100)
const onMouseLeave = debounce(() => {
setIsCopied(false)
}, 100)
}, [copy, content])
return (
<Tooltip
popupContent={
(isCopied
(copied
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''
}
@ -81,9 +69,9 @@ export const CopyFeedbackNew = ({ content, className }: Pick<Props, 'className'
}`}
>
<div
onClick={onClickCopy}
onMouseLeave={onMouseLeave}
className={`h-full w-full ${copyStyle.copyIcon} ${isCopied ? copyStyle.copied : ''
onClick={handleCopy}
onMouseLeave={reset}
className={`h-full w-full ${copyStyle.copyIcon} ${copied ? copyStyle.copied : ''
}`}
>
</div>

View File

@ -1,8 +1,6 @@
'use client'
import copy from 'copy-to-clipboard'
import { debounce } from 'es-toolkit/compat'
import * as React from 'react'
import { useState } from 'react'
import { useClipboard } from 'foxact/use-clipboard'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
Copy,
@ -18,29 +16,24 @@ const prefixEmbedded = 'overview.appInfo.embedded'
const CopyIcon = ({ content }: Props) => {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState<boolean>(false)
const { copied, copy, reset } = useClipboard()
const onClickCopy = debounce(() => {
const handleCopy = useCallback(() => {
copy(content)
setIsCopied(true)
}, 100)
const onMouseLeave = debounce(() => {
setIsCopied(false)
}, 100)
}, [copy, content])
return (
<Tooltip
popupContent={
(isCopied
(copied
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''
}
>
<div onMouseLeave={onMouseLeave}>
{!isCopied
<div onMouseLeave={reset}>
{!copied
? (
<Copy className="mx-1 h-3.5 w-3.5 cursor-pointer text-text-tertiary" onClick={onClickCopy} />
<Copy className="mx-1 h-3.5 w-3.5 cursor-pointer text-text-tertiary" onClick={handleCopy} />
)
: (
<CopyCheck className="mx-1 h-3.5 w-3.5 text-text-tertiary" />

View File

@ -3,13 +3,8 @@ import * as React from 'react'
import { createReactI18nextMock } from '@/test/i18n-mock'
import InputWithCopy from './index'
// Create a mock function that we can track using vi.hoisted
const mockCopyToClipboard = vi.hoisted(() => vi.fn(() => true))
// Mock the copy-to-clipboard library
vi.mock('copy-to-clipboard', () => ({
default: mockCopyToClipboard,
}))
// Mock navigator.clipboard for foxact/use-clipboard
const mockWriteText = vi.fn(() => Promise.resolve())
// Mock the i18n hook with custom translations for test assertions
vi.mock('react-i18next', () => createReactI18nextMock({
@ -19,15 +14,16 @@ vi.mock('react-i18next', () => createReactI18nextMock({
'overview.appInfo.embedded.copied': 'Copied',
}))
// Mock es-toolkit/compat debounce
vi.mock('es-toolkit/compat', () => ({
debounce: (fn: any) => fn,
}))
describe('InputWithCopy component', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCopyToClipboard.mockClear()
mockWriteText.mockClear()
// Setup navigator.clipboard mock
Object.assign(navigator, {
clipboard: {
writeText: mockWriteText,
},
})
})
it('renders correctly with default props', () => {
@ -55,7 +51,9 @@ describe('InputWithCopy component', () => {
const copyButton = screen.getByRole('button')
fireEvent.click(copyButton)
expect(mockCopyToClipboard).toHaveBeenCalledWith('test value')
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith('test value')
})
})
it('copies custom value when copyValue prop is provided', async () => {
@ -65,7 +63,9 @@ describe('InputWithCopy component', () => {
const copyButton = screen.getByRole('button')
fireEvent.click(copyButton)
expect(mockCopyToClipboard).toHaveBeenCalledWith('custom copy value')
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith('custom copy value')
})
})
it('calls onCopy callback when copy button is clicked', async () => {
@ -76,7 +76,9 @@ describe('InputWithCopy component', () => {
const copyButton = screen.getByRole('button')
fireEvent.click(copyButton)
expect(onCopyMock).toHaveBeenCalledWith('test value')
await waitFor(() => {
expect(onCopyMock).toHaveBeenCalledWith('test value')
})
})
it('shows copied state after successful copy', async () => {
@ -115,17 +117,19 @@ describe('InputWithCopy component', () => {
expect(input).toHaveClass('custom-class')
})
it('handles empty value correctly', () => {
it('handles empty value correctly', async () => {
const mockOnChange = vi.fn()
render(<InputWithCopy value="" onChange={mockOnChange} />)
const input = screen.getByRole('textbox')
const input = screen.getByDisplayValue('')
const copyButton = screen.getByRole('button')
expect(input).toBeInTheDocument()
expect(copyButton).toBeInTheDocument()
fireEvent.click(copyButton)
expect(mockCopyToClipboard).toHaveBeenCalledWith('')
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith('')
})
})
it('maintains focus on input after copy', async () => {

View File

@ -1,10 +1,8 @@
'use client'
import type { InputProps } from '../input'
import { RiClipboardFill, RiClipboardLine } from '@remixicon/react'
import copy from 'copy-to-clipboard'
import { debounce } from 'es-toolkit/compat'
import { useClipboard } from 'foxact/use-clipboard'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import ActionButton from '../action-button'
@ -30,31 +28,16 @@ const InputWithCopy = React.forwardRef<HTMLInputElement, InputWithCopyProps>((
ref,
) => {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState<boolean>(false)
// Determine what value to copy
const valueToString = typeof value === 'string' ? value : String(value || '')
const finalCopyValue = copyValue || valueToString
const onClickCopy = debounce(() => {
const { copied, copy, reset } = useClipboard()
const handleCopy = () => {
copy(finalCopyValue)
setIsCopied(true)
onCopy?.(finalCopyValue)
}, 100)
const onMouseLeave = debounce(() => {
setIsCopied(false)
}, 100)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => {
setIsCopied(false)
}, 2000)
return () => {
clearTimeout(timeout)
}
}
}, [isCopied])
}
return (
<div className={cn('relative w-full', wrapperClassName)}>
@ -73,21 +56,21 @@ const InputWithCopy = React.forwardRef<HTMLInputElement, InputWithCopyProps>((
{showCopyButton && (
<div
className="absolute right-2 top-1/2 -translate-y-1/2"
onMouseLeave={onMouseLeave}
onMouseLeave={reset}
>
<Tooltip
popupContent={
(isCopied
(copied
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''
}
>
<ActionButton
size="xs"
onClick={onClickCopy}
onClick={handleCopy}
className="hover:bg-components-button-ghost-bg-hover"
>
{isCopied
{copied
? (
<RiClipboardFill className="h-3.5 w-3.5 text-text-tertiary" />
)

View File

@ -1131,11 +1131,6 @@
"count": 1
}
},
"app/components/base/input-with-copy/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/base/input/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 1

View File

@ -97,6 +97,7 @@
"emoji-mart": "5.6.0",
"es-toolkit": "1.43.0",
"fast-deep-equal": "3.1.3",
"foxact": "0.2.52",
"html-entities": "2.6.0",
"html-to-image": "1.11.13",
"i18next": "25.7.3",

View File

@ -183,6 +183,9 @@ importers:
fast-deep-equal:
specifier: 3.1.3
version: 3.1.3
foxact:
specifier: 0.2.52
version: 0.2.52(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
html-entities:
specifier: 2.6.0
version: 2.6.0
@ -5560,6 +5563,17 @@ packages:
engines: {node: '>=18.3.0'}
hasBin: true
foxact@0.2.52:
resolution: {integrity: sha512-cc3ydJkM/mYkof1/ofI4VlVAiRyfsSDsHRC4UIAXQcnUXCuo0rXM66Zy1ggdxAXL03ikHnh3bPnQ7AYuI/Yzow==}
peerDependencies:
react: '*'
react-dom: '*'
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
@ -7637,6 +7651,9 @@ packages:
resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==}
engines: {node: '>=10'}
server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
serwist@9.5.0:
resolution: {integrity: sha512-wjrsPWHI5ZM20jIsVKZGN/uAdS2aKOgmIOE4dqUaFhK6SVIzgoJZjTnZ3v29T+NmneuD753jlhGui9eYypsj0A==}
peerDependencies:
@ -14358,6 +14375,14 @@ snapshots:
dependencies:
fd-package-json: 2.0.0
foxact@0.2.52(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
client-only: 0.0.1
server-only: 0.0.1
optionalDependencies:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
fraction.js@4.3.7: {}
fs-constants@1.0.0:
@ -16936,6 +16961,8 @@ snapshots:
seroval@1.3.2: {}
server-only@0.0.1: {}
serwist@9.5.0(typescript@5.9.3):
dependencies:
'@serwist/utils': 9.5.0