From a5672aa69b5615a868a78b3f4a3b5ff1d3536bb6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=99=A8=E8=8B=92?=
<16112591+chen-ran@users.noreply.github.com>
Date: Thu, 30 Jul 2026 18:34:02 +0800
Subject: [PATCH 1/3] fix: make clipboard fallback dialog-safe
---
package.json | 1 +
src/lib/clipboard.test.ts | 190 ++++++++++++++++++++++++++++++++++++++
src/lib/clipboard.ts | 74 +++++++++------
3 files changed, 238 insertions(+), 27 deletions(-)
create mode 100644 src/lib/clipboard.test.ts
diff --git a/package.json b/package.json
index a307742..b20a6fa 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"prebuild": "tsx ./scripts/gen-entry.ts",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
+ "test": "tsx --test src/lib/clipboard.test.ts",
"build-only": "vite build",
"type-check": "vue-tsc --build"
},
diff --git a/src/lib/clipboard.test.ts b/src/lib/clipboard.test.ts
new file mode 100644
index 0000000..6e39b45
--- /dev/null
+++ b/src/lib/clipboard.test.ts
@@ -0,0 +1,190 @@
+///
+
+import assert from 'node:assert/strict'
+import { afterEach, test } from 'node:test'
+import { useClipboard } from './clipboard'
+
+const originalGlobals = new Map(
+ ['document', 'navigator', 'HTMLElement'].map((name) => [
+ name,
+ Object.getOwnPropertyDescriptor(globalThis, name),
+ ]),
+)
+
+afterEach(() => {
+ for (const [name, descriptor] of originalGlobals) {
+ if (descriptor) {
+ Object.defineProperty(globalThis, name, descriptor)
+ }
+ else {
+ Reflect.deleteProperty(globalThis, name)
+ }
+ }
+})
+
+class FakeHTMLElement {
+ readonly children: FakeHTMLElement[] = []
+ parentElement: FakeHTMLElement | null = null
+ role: string | null = null
+ value = ''
+ readOnly = false
+ tabIndex = 0
+ readonly style: Record = {}
+ selectCalls = 0
+
+ constructor(readonly documentRef: FakeDocument) {}
+
+ appendChild(child: T): T {
+ child.parentElement = this
+ this.children.push(child)
+ this.documentRef.appendTargets.push(this)
+ return child
+ }
+
+ closest(): T | null {
+ if (this.role === 'dialog' || this.role === 'alertdialog') {
+ return this as unknown as T
+ }
+ return this.parentElement?.closest() ?? null
+ }
+
+ focus() {
+ this.documentRef.activeElement = this
+ }
+
+ select() {
+ this.selectCalls += 1
+ }
+
+ remove() {
+ if (!this.parentElement) return
+ const index = this.parentElement.children.indexOf(this)
+ if (index >= 0) this.parentElement.children.splice(index, 1)
+ this.parentElement = null
+ }
+}
+
+class FakeDocument {
+ readonly body = new FakeHTMLElement(this)
+ readonly appendTargets: FakeHTMLElement[] = []
+ activeElement: FakeHTMLElement | null = this.body
+ execCommandCalls = 0
+ execCommandResult = true
+ execCommandError: Error | null = null
+ lastExecCommandActiveElement: FakeHTMLElement | null = null
+
+ createElement() {
+ return new FakeHTMLElement(this)
+ }
+
+ execCommand(command: string) {
+ assert.equal(command, 'copy')
+ this.execCommandCalls += 1
+ this.lastExecCommandActiveElement = this.activeElement
+ if (this.execCommandError) throw this.execCommandError
+ return this.execCommandResult
+ }
+}
+
+function installBrowserGlobals(
+ documentRef: FakeDocument,
+ navigatorRef: { clipboard?: { writeText: (text: string) => Promise } } = {},
+) {
+ Object.defineProperty(globalThis, 'document', {
+ configurable: true,
+ value: documentRef,
+ })
+ Object.defineProperty(globalThis, 'navigator', {
+ configurable: true,
+ value: navigatorRef,
+ })
+ Object.defineProperty(globalThis, 'HTMLElement', {
+ configurable: true,
+ value: FakeHTMLElement,
+ })
+}
+
+test('uses the Clipboard API when it succeeds', async () => {
+ const documentRef = new FakeDocument()
+ const writes: string[] = []
+ installBrowserGlobals(documentRef, {
+ clipboard: {
+ async writeText(text) {
+ writes.push(text)
+ },
+ },
+ })
+
+ const { copyText } = useClipboard()
+
+ assert.equal(await copyText('hello'), true)
+ assert.deepEqual(writes, ['hello'])
+ assert.equal(documentRef.execCommandCalls, 0)
+})
+
+test('falls back inside the active dialog and restores focus', async () => {
+ const documentRef = new FakeDocument()
+ const dialog = new FakeHTMLElement(documentRef)
+ dialog.role = 'dialog'
+ const button = new FakeHTMLElement(documentRef)
+ dialog.appendChild(button)
+ button.focus()
+ documentRef.appendTargets.length = 0
+ installBrowserGlobals(documentRef)
+
+ const { copyText } = useClipboard()
+
+ assert.equal(await copyText('hello'), true)
+ assert.equal(documentRef.appendTargets[documentRef.appendTargets.length - 1], dialog)
+ assert.equal(documentRef.lastExecCommandActiveElement?.value, 'hello')
+ assert.equal(documentRef.lastExecCommandActiveElement?.selectCalls, 1)
+ assert.deepEqual(dialog.children, [button])
+ assert.equal(documentRef.activeElement, button)
+})
+
+test('falls back when the Clipboard API rejects the write', async () => {
+ const documentRef = new FakeDocument()
+ installBrowserGlobals(documentRef, {
+ clipboard: {
+ async writeText() {
+ throw new Error('denied')
+ },
+ },
+ })
+
+ const { copyText } = useClipboard()
+
+ assert.equal(await copyText('hello'), true)
+ assert.equal(documentRef.execCommandCalls, 1)
+})
+
+test('uses the document body when no dialog is active', async () => {
+ const documentRef = new FakeDocument()
+ const button = new FakeHTMLElement(documentRef)
+ documentRef.body.appendChild(button)
+ button.focus()
+ documentRef.appendTargets.length = 0
+ installBrowserGlobals(documentRef)
+
+ const { copyText } = useClipboard()
+
+ assert.equal(await copyText('hello'), true)
+ assert.equal(documentRef.appendTargets[documentRef.appendTargets.length - 1], documentRef.body)
+ assert.deepEqual(documentRef.body.children, [button])
+ assert.equal(documentRef.activeElement, button)
+})
+
+test('cleans up and reports failure when execCommand throws', async () => {
+ const documentRef = new FakeDocument()
+ const button = new FakeHTMLElement(documentRef)
+ documentRef.body.appendChild(button)
+ button.focus()
+ documentRef.execCommandError = new Error('copy failed')
+ installBrowserGlobals(documentRef)
+
+ const { copyText } = useClipboard()
+
+ assert.equal(await copyText('hello'), false)
+ assert.deepEqual(documentRef.body.children, [button])
+ assert.equal(documentRef.activeElement, button)
+})
diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts
index 91a4340..ea848b6 100644
--- a/src/lib/clipboard.ts
+++ b/src/lib/clipboard.ts
@@ -1,41 +1,61 @@
-// useClipboard — navigator.clipboard.writeText with an execCommand fallback.
-// Lifted from the host (apps/web/composables/useClipboard.ts, now a re-export
-// shim). SSR/touch-safe: reports unsupported instead of throwing; copyText
-// resolves false on any failure so callers can fall back to manual selection.
+const focusTrapContainerSelector = '[role="dialog"], [role="alertdialog"]'
+
+function resolveFallbackContainer(documentRef: Document): HTMLElement {
+ const activeElement = documentRef.activeElement
+ if (activeElement instanceof HTMLElement) {
+ return activeElement.closest(focusTrapContainerSelector) ?? documentRef.body
+ }
+ return documentRef.body
+}
+
+function copyWithExecCommand(documentRef: Document, text: string): boolean {
+ if (typeof documentRef.execCommand !== 'function') return false
+
+ const previousFocus = documentRef.activeElement instanceof HTMLElement
+ ? documentRef.activeElement
+ : null
+ const textArea = documentRef.createElement('textarea')
+ textArea.value = text
+ textArea.readOnly = true
+ textArea.tabIndex = -1
+ textArea.style.position = 'fixed'
+ textArea.style.left = '-9999px'
+ textArea.style.top = '0'
+ resolveFallbackContainer(documentRef).appendChild(textArea)
+
+ try {
+ textArea.focus()
+ textArea.select()
+ return documentRef.execCommand('copy')
+ } catch {
+ return false
+ } finally {
+ textArea.remove()
+ previousFocus?.focus()
+ }
+}
+
+// SSR/touch-safe clipboard access. The legacy fallback stays inside an active
+// dialog so focus traps cannot steal its temporary textarea selection.
export function useClipboard() {
const hasNavigatorClipboard = typeof navigator !== 'undefined' && !!navigator.clipboard?.writeText
const hasExecCommandFallback = typeof document !== 'undefined' && typeof document.execCommand === 'function'
const isSupported = hasNavigatorClipboard || hasExecCommandFallback
async function copyText(text: string): Promise {
- if (!isSupported) {
- return false
- }
-
- try {
- if (hasNavigatorClipboard) {
+ if (hasNavigatorClipboard && typeof navigator !== 'undefined') {
+ try {
await navigator.clipboard.writeText(text)
return true
}
-
- if (!hasExecCommandFallback || typeof document === 'undefined') {
- return false
+ catch {
+ // Permission behavior varies by browser. Try the legacy fallback as a
+ // second chance before reporting failure.
}
-
- const textArea = document.createElement('textarea')
- textArea.value = text
- textArea.style.position = 'fixed'
- textArea.style.left = '-9999px'
- textArea.style.top = '0'
- document.body.appendChild(textArea)
- textArea.focus()
- textArea.select()
- const success = document.execCommand('copy')
- document.body.removeChild(textArea)
- return success
- } catch {
- return false
}
+
+ if (!hasExecCommandFallback || typeof document === 'undefined') return false
+ return copyWithExecCommand(document, text)
}
return {
From 7795c118495e5ae26c58617b07b4279784f8f66d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=99=A8=E8=8B=92?=
<16112591+chen-ran@users.noreply.github.com>
Date: Thu, 30 Jul 2026 18:38:42 +0800
Subject: [PATCH 2/3] test: streamline clipboard regression coverage
---
src/lib/clipboard.test.ts | 167 +++++++++-----------------------------
src/lib/clipboard.ts | 5 +-
2 files changed, 40 insertions(+), 132 deletions(-)
diff --git a/src/lib/clipboard.test.ts b/src/lib/clipboard.test.ts
index 6e39b45..370c403 100644
--- a/src/lib/clipboard.test.ts
+++ b/src/lib/clipboard.test.ts
@@ -4,7 +4,7 @@ import assert from 'node:assert/strict'
import { afterEach, test } from 'node:test'
import { useClipboard } from './clipboard'
-const originalGlobals = new Map(
+const savedGlobals = new Map(
['document', 'navigator', 'HTMLElement'].map((name) => [
name,
Object.getOwnPropertyDescriptor(globalThis, name),
@@ -12,40 +12,31 @@ const originalGlobals = new Map(
)
afterEach(() => {
- for (const [name, descriptor] of originalGlobals) {
- if (descriptor) {
- Object.defineProperty(globalThis, name, descriptor)
- }
- else {
- Reflect.deleteProperty(globalThis, name)
- }
+ for (const [name, descriptor] of savedGlobals) {
+ if (descriptor) Object.defineProperty(globalThis, name, descriptor)
+ else Reflect.deleteProperty(globalThis, name)
}
})
class FakeHTMLElement {
- readonly children: FakeHTMLElement[] = []
- parentElement: FakeHTMLElement | null = null
- role: string | null = null
+ closestResult: FakeHTMLElement | null = null
+ parent: FakeHTMLElement | null = null
+ appended: FakeHTMLElement | null = null
value = ''
- readOnly = false
- tabIndex = 0
- readonly style: Record = {}
selectCalls = 0
+ readonly style: Record = {}
- constructor(readonly documentRef: FakeDocument) {}
+ constructor(private readonly documentRef: FakeDocument) {}
appendChild(child: T): T {
- child.parentElement = this
- this.children.push(child)
- this.documentRef.appendTargets.push(this)
+ this.documentRef.appendTarget = this
+ this.appended = child
+ child.parent = this
return child
}
closest(): T | null {
- if (this.role === 'dialog' || this.role === 'alertdialog') {
- return this as unknown as T
- }
- return this.parentElement?.closest() ?? null
+ return this.closestResult as T | null
}
focus() {
@@ -57,134 +48,50 @@ class FakeHTMLElement {
}
remove() {
- if (!this.parentElement) return
- const index = this.parentElement.children.indexOf(this)
- if (index >= 0) this.parentElement.children.splice(index, 1)
- this.parentElement = null
+ if (this.parent?.appended === this) this.parent.appended = null
+ this.parent = null
}
}
class FakeDocument {
readonly body = new FakeHTMLElement(this)
- readonly appendTargets: FakeHTMLElement[] = []
- activeElement: FakeHTMLElement | null = this.body
+ activeElement: FakeHTMLElement = this.body
+ appendTarget: FakeHTMLElement | null = null
+ createdElement: FakeHTMLElement | null = null
execCommandCalls = 0
- execCommandResult = true
- execCommandError: Error | null = null
- lastExecCommandActiveElement: FakeHTMLElement | null = null
createElement() {
- return new FakeHTMLElement(this)
+ this.createdElement = new FakeHTMLElement(this)
+ return this.createdElement
}
execCommand(command: string) {
assert.equal(command, 'copy')
this.execCommandCalls += 1
- this.lastExecCommandActiveElement = this.activeElement
- if (this.execCommandError) throw this.execCommandError
- return this.execCommandResult
+ return true
}
}
-function installBrowserGlobals(
- documentRef: FakeDocument,
- navigatorRef: { clipboard?: { writeText: (text: string) => Promise } } = {},
-) {
- Object.defineProperty(globalThis, 'document', {
- configurable: true,
- value: documentRef,
- })
- Object.defineProperty(globalThis, 'navigator', {
- configurable: true,
- value: navigatorRef,
- })
- Object.defineProperty(globalThis, 'HTMLElement', {
- configurable: true,
- value: FakeHTMLElement,
- })
-}
-
-test('uses the Clipboard API when it succeeds', async () => {
- const documentRef = new FakeDocument()
- const writes: string[] = []
- installBrowserGlobals(documentRef, {
- clipboard: {
- async writeText(text) {
- writes.push(text)
- },
- },
- })
-
- const { copyText } = useClipboard()
-
- assert.equal(await copyText('hello'), true)
- assert.deepEqual(writes, ['hello'])
- assert.equal(documentRef.execCommandCalls, 0)
-})
-
-test('falls back inside the active dialog and restores focus', async () => {
+test('keeps the HTTP fallback inside the active dialog', async () => {
const documentRef = new FakeDocument()
const dialog = new FakeHTMLElement(documentRef)
- dialog.role = 'dialog'
const button = new FakeHTMLElement(documentRef)
- dialog.appendChild(button)
- button.focus()
- documentRef.appendTargets.length = 0
- installBrowserGlobals(documentRef)
-
- const { copyText } = useClipboard()
-
- assert.equal(await copyText('hello'), true)
- assert.equal(documentRef.appendTargets[documentRef.appendTargets.length - 1], dialog)
- assert.equal(documentRef.lastExecCommandActiveElement?.value, 'hello')
- assert.equal(documentRef.lastExecCommandActiveElement?.selectCalls, 1)
- assert.deepEqual(dialog.children, [button])
- assert.equal(documentRef.activeElement, button)
-})
-
-test('falls back when the Clipboard API rejects the write', async () => {
- const documentRef = new FakeDocument()
- installBrowserGlobals(documentRef, {
- clipboard: {
- async writeText() {
- throw new Error('denied')
- },
- },
- })
-
- const { copyText } = useClipboard()
+ button.closestResult = dialog
+ documentRef.activeElement = button
+
+ for (const [name, value] of [
+ ['document', documentRef],
+ ['navigator', {}],
+ ['HTMLElement', FakeHTMLElement],
+ ] as const) {
+ Object.defineProperty(globalThis, name, { configurable: true, value })
+ }
- assert.equal(await copyText('hello'), true)
+ assert.equal(await useClipboard().copyText('hello'), true)
assert.equal(documentRef.execCommandCalls, 1)
-})
-
-test('uses the document body when no dialog is active', async () => {
- const documentRef = new FakeDocument()
- const button = new FakeHTMLElement(documentRef)
- documentRef.body.appendChild(button)
- button.focus()
- documentRef.appendTargets.length = 0
- installBrowserGlobals(documentRef)
-
- const { copyText } = useClipboard()
-
- assert.equal(await copyText('hello'), true)
- assert.equal(documentRef.appendTargets[documentRef.appendTargets.length - 1], documentRef.body)
- assert.deepEqual(documentRef.body.children, [button])
- assert.equal(documentRef.activeElement, button)
-})
-
-test('cleans up and reports failure when execCommand throws', async () => {
- const documentRef = new FakeDocument()
- const button = new FakeHTMLElement(documentRef)
- documentRef.body.appendChild(button)
- button.focus()
- documentRef.execCommandError = new Error('copy failed')
- installBrowserGlobals(documentRef)
-
- const { copyText } = useClipboard()
-
- assert.equal(await copyText('hello'), false)
- assert.deepEqual(documentRef.body.children, [button])
+ assert.equal(documentRef.appendTarget, dialog)
+ assert.equal(documentRef.createdElement?.value, 'hello')
+ assert.equal(documentRef.createdElement?.selectCalls, 1)
+ assert.equal(dialog.appended, null)
assert.equal(documentRef.activeElement, button)
})
diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts
index ea848b6..0006cc0 100644
--- a/src/lib/clipboard.ts
+++ b/src/lib/clipboard.ts
@@ -43,14 +43,15 @@ export function useClipboard() {
const isSupported = hasNavigatorClipboard || hasExecCommandFallback
async function copyText(text: string): Promise {
+ if (!isSupported) return false
+
if (hasNavigatorClipboard && typeof navigator !== 'undefined') {
try {
await navigator.clipboard.writeText(text)
return true
}
catch {
- // Permission behavior varies by browser. Try the legacy fallback as a
- // second chance before reporting failure.
+ return false
}
}
From 7816d6ed6fb008ac91e231e0357abdf7322b0331 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=99=A8=E8=8B=92?=
<16112591+chen-ran@users.noreply.github.com>
Date: Thu, 30 Jul 2026 18:40:39 +0800
Subject: [PATCH 3/3] test: remove clipboard test harness
---
package.json | 1 -
src/lib/clipboard.test.ts | 97 ---------------------------------------
2 files changed, 98 deletions(-)
delete mode 100644 src/lib/clipboard.test.ts
diff --git a/package.json b/package.json
index b20a6fa..a307742 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,6 @@
"prebuild": "tsx ./scripts/gen-entry.ts",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
- "test": "tsx --test src/lib/clipboard.test.ts",
"build-only": "vite build",
"type-check": "vue-tsc --build"
},
diff --git a/src/lib/clipboard.test.ts b/src/lib/clipboard.test.ts
deleted file mode 100644
index 370c403..0000000
--- a/src/lib/clipboard.test.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-///
-
-import assert from 'node:assert/strict'
-import { afterEach, test } from 'node:test'
-import { useClipboard } from './clipboard'
-
-const savedGlobals = new Map(
- ['document', 'navigator', 'HTMLElement'].map((name) => [
- name,
- Object.getOwnPropertyDescriptor(globalThis, name),
- ]),
-)
-
-afterEach(() => {
- for (const [name, descriptor] of savedGlobals) {
- if (descriptor) Object.defineProperty(globalThis, name, descriptor)
- else Reflect.deleteProperty(globalThis, name)
- }
-})
-
-class FakeHTMLElement {
- closestResult: FakeHTMLElement | null = null
- parent: FakeHTMLElement | null = null
- appended: FakeHTMLElement | null = null
- value = ''
- selectCalls = 0
- readonly style: Record = {}
-
- constructor(private readonly documentRef: FakeDocument) {}
-
- appendChild(child: T): T {
- this.documentRef.appendTarget = this
- this.appended = child
- child.parent = this
- return child
- }
-
- closest(): T | null {
- return this.closestResult as T | null
- }
-
- focus() {
- this.documentRef.activeElement = this
- }
-
- select() {
- this.selectCalls += 1
- }
-
- remove() {
- if (this.parent?.appended === this) this.parent.appended = null
- this.parent = null
- }
-}
-
-class FakeDocument {
- readonly body = new FakeHTMLElement(this)
- activeElement: FakeHTMLElement = this.body
- appendTarget: FakeHTMLElement | null = null
- createdElement: FakeHTMLElement | null = null
- execCommandCalls = 0
-
- createElement() {
- this.createdElement = new FakeHTMLElement(this)
- return this.createdElement
- }
-
- execCommand(command: string) {
- assert.equal(command, 'copy')
- this.execCommandCalls += 1
- return true
- }
-}
-
-test('keeps the HTTP fallback inside the active dialog', async () => {
- const documentRef = new FakeDocument()
- const dialog = new FakeHTMLElement(documentRef)
- const button = new FakeHTMLElement(documentRef)
- button.closestResult = dialog
- documentRef.activeElement = button
-
- for (const [name, value] of [
- ['document', documentRef],
- ['navigator', {}],
- ['HTMLElement', FakeHTMLElement],
- ] as const) {
- Object.defineProperty(globalThis, name, { configurable: true, value })
- }
-
- assert.equal(await useClipboard().copyText('hello'), true)
- assert.equal(documentRef.execCommandCalls, 1)
- assert.equal(documentRef.appendTarget, dialog)
- assert.equal(documentRef.createdElement?.value, 'hello')
- assert.equal(documentRef.createdElement?.selectCalls, 1)
- assert.equal(dialog.appended, null)
- assert.equal(documentRef.activeElement, button)
-})