diff --git a/web/app/components/base/copy-feedback/index.tsx b/web/app/components/base/copy-feedback/index.tsx index 8dc99a9435..3d2160d185 100644 --- a/web/app/components/base/copy-feedback/index.tsx +++ b/web/app/components/base/copy-feedback/index.tsx @@ -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(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 (
- {isCopied && } - {!isCopied && } + {copied && } + {!copied && }
@@ -57,21 +50,16 @@ export default CopyFeedback export const CopyFeedbackNew = ({ content, className }: Pick) => { const { t } = useTranslation() - const [isCopied, setIsCopied] = useState(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 (
diff --git a/web/app/components/base/copy-icon/index.tsx b/web/app/components/base/copy-icon/index.tsx index f9b357e741..a1d692b6df 100644 --- a/web/app/components/base/copy-icon/index.tsx +++ b/web/app/components/base/copy-icon/index.tsx @@ -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(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 ( -
- {!isCopied +
+ {!copied ? ( - + ) : ( diff --git a/web/app/components/base/input-with-copy/index.spec.tsx b/web/app/components/base/input-with-copy/index.spec.tsx index 438e72d142..1a4319603e 100644 --- a/web/app/components/base/input-with-copy/index.spec.tsx +++ b/web/app/components/base/input-with-copy/index.spec.tsx @@ -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() - 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 () => { diff --git a/web/app/components/base/input-with-copy/index.tsx b/web/app/components/base/input-with-copy/index.tsx index 745e89fb2f..7981ba6236 100644 --- a/web/app/components/base/input-with-copy/index.tsx +++ b/web/app/components/base/input-with-copy/index.tsx @@ -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(( ref, ) => { const { t } = useTranslation() - const [isCopied, setIsCopied] = useState(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 (
@@ -73,21 +56,21 @@ const InputWithCopy = React.forwardRef(( {showCopyButton && (
- {isCopied + {copied ? ( ) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index e430ea6739..d41ea7b299 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -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 diff --git a/web/package.json b/web/package.json index b36eb77603..9e0d2ff070 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index f1fdb091a8..9ad8cc0ed9 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -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