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
7 changed files with 86 additions and 95 deletions

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" />
)