diff --git a/app/app.vue b/app/app.vue
index 34fc1d5c01..fab31b20ea 100644
--- a/app/app.vue
+++ b/app/app.vue
@@ -146,6 +146,8 @@ defineOgImage('Page.takumi', {}, { alt: 'npmx — a fast, modern browser for the
{{ route.name === 'search' ? `${$t('search.title_packages')} - npmx` : message }}
+
+
+
$
npx
skills add {{ baseUrl }}/{{ packageName }}
-
+ />
copyExecute(getFullExecuteCommand())
>{{ i > 0 ? ' ' : '' }}{{ part }}
-
+ />
diff --git a/app/components/Terminal/Install.vue b/app/components/Terminal/Install.vue
index 0a91f8f7d3..9a4a56087c 100644
--- a/app/components/Terminal/Install.vue
+++ b/app/components/Terminal/Install.vue
@@ -23,6 +23,14 @@ const { selectedPM, showTypesInInstall, copied, copyInstallCommand } = useInstal
() => props.installVersionOverride ?? null,
)
+const { announce } = useCommandPalette()
+const { polite } = useAnnouncer()
+
+async function copyInstallCommandWithAnnounce() {
+ const success = await copyInstallCommand()
+ if (success) polite($t('package.command.copied_install'))
+}
+
// Generate install command parts for a specific package manager
function getInstallPartsForPM(pmId: PackageManagerId) {
return getInstallCommandParts({
@@ -105,13 +113,19 @@ function getFullCreateCommand() {
// Copy handlers
const { copied: runCopied, copy: copyRun } = useClipboard({ copiedDuring: 2000 })
-const copyRunCommand = (command?: string) => copyRun(getFullRunCommand(command))
+const copyRunCommand = (command?: string) => {
+ copyRun(getFullRunCommand(command))
+ polite($t('package.command.copied_run'))
+}
const { copied: createCopied, copy: copyCreate } = useClipboard({ copiedDuring: 2000 })
-const copyCreateCommand = () => copyCreate(getFullCreateCommand())
+const copyCreateCommand = () => {
+ copyCreate(getFullCreateCommand())
+ polite($t('package.command.copied_create'))
+}
const { copied: devInstallCopied, copy: copyDevInstall } = useClipboard({ copiedDuring: 2000 })
-const copyDevInstallCommand = () =>
+const copyDevInstallCommand = () => {
copyDevInstall(
getInstallCommand({
packageName: props.packageName,
@@ -121,8 +135,8 @@ const copyDevInstallCommand = () =>
dev: true,
}),
)
-
-const { announce } = useCommandPalette()
+ polite($t('package.command.copied_dev_install'))
+}
useCommandPaletteContextCommands(
computed((): CommandPaletteContextCommandInput[] => {
@@ -226,7 +240,7 @@ useCommandPaletteContextCommands(
:data-pm-cmd="pm.id"
class="flex items-center gap-2 group/installcmd min-w-0"
>
- $
+ $
{{ i > 0 ? ' ' : '' }}{{ part }}
-
+ :classicon="copied ? 'i-lucide:check' : 'i-lucide:copy'"
+ @click.stop="copyInstallCommandWithAnnounce"
+ />
@@ -269,15 +283,12 @@ useCommandPaletteContextCommands(
>
- $
+ $
{{ i > 0 ? ' ' : '' }}{{ part }}
-
+ />
@@ -369,7 +381,7 @@ useCommandPaletteContextCommands(
:data-pm-cmd="pm.id"
class="flex items-center gap-2 group/createcmd"
>
- $
+ $
{{ i > 0 ? ' ' : '' }}{{ part }}
-
+ />
diff --git a/app/composables/useInstallCommand.ts b/app/composables/useInstallCommand.ts
index 827ac89f46..0d9fd065e6 100644
--- a/app/composables/useInstallCommand.ts
+++ b/app/composables/useInstallCommand.ts
@@ -82,8 +82,9 @@ export function useInstallCommand(
const { copied, copy } = useClipboard({ copiedDuring: 2000 })
async function copyInstallCommand() {
- if (!fullInstallCommand.value) return
+ if (!fullInstallCommand.value) return false
await copy(fullInstallCommand.value)
+ return true
}
return {
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index c2031b8ca9..977ef7452e 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -468,7 +468,16 @@
},
"run": {
"title": "Run",
- "locally": "Run locally"
+ "locally": "Run locally",
+ "copy_command": "Copy command to run locally"
+ },
+ "command": {
+ "copied_install": "Install command copied",
+ "copied_dev_install": "Dev install command copied",
+ "copied_run": "Run command copied",
+ "copied_create": "Create command copied",
+ "copied_execute": "Execute command copied",
+ "copied_skills": "Skills command copied"
},
"readme": {
"title": "Readme",
diff --git a/i18n/schema.json b/i18n/schema.json
index c4478e1eb8..1b3097098b 100644
--- a/i18n/schema.json
+++ b/i18n/schema.json
@@ -1410,6 +1410,33 @@
},
"locally": {
"type": "string"
+ },
+ "copy_command": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "command": {
+ "type": "object",
+ "properties": {
+ "copied_install": {
+ "type": "string"
+ },
+ "copied_dev_install": {
+ "type": "string"
+ },
+ "copied_run": {
+ "type": "string"
+ },
+ "copied_create": {
+ "type": "string"
+ },
+ "copied_execute": {
+ "type": "string"
+ },
+ "copied_skills": {
+ "type": "string"
}
},
"additionalProperties": false
diff --git a/test/e2e/create-command.spec.ts b/test/e2e/create-command.spec.ts
index 8615df22d5..5893f16571 100644
--- a/test/e2e/create-command.spec.ts
+++ b/test/e2e/create-command.spec.ts
@@ -61,23 +61,76 @@ test.describe('Create Command', () => {
})
})
+ test.describe('Copy Functionality', () => {
+ test('copy button is accessible and keyboard discoverable', async ({ page, goto }) => {
+ await goto('/package/vite', { waitUntil: 'hydration' })
+
+ await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 })
+
+ const createCommandContainer = page.locator('.group\\/createcmd').first()
+ await expect(createCommandContainer).toBeVisible({ timeout: 20000 })
+
+ // Copy button should be in the DOM and accessible to screen readers
+ const copyButton = createCommandContainer.locator('button')
+ await expect(copyButton).toBeAttached()
+
+ // Focus the button to verify it's keyboard accessible
+ await copyButton.focus()
+ await expect(copyButton).toBeFocused()
+ })
+
+ test('clicking copy button copies create command and shows confirmation', async ({
+ page,
+ goto,
+ context,
+ }) => {
+ // Grant clipboard permissions
+ await context.grantPermissions(['clipboard-read', 'clipboard-write'])
+
+ await goto('/package/vite', { waitUntil: 'hydration' })
+ await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 })
+
+ const createCommandContainer = page.locator('.group\\/createcmd').first()
+ await expect(createCommandContainer).toBeVisible({ timeout: 20000 })
+
+ const copyButton = createCommandContainer.locator('button')
+
+ await copyButton.focus()
+ await expect(copyButton).toBeFocused()
+
+ await copyButton.click()
+
+ await expect(copyButton.locator('span[aria-hidden="true"]')).toHaveClass(/i-lucide:check/)
+
+ const clipboardContent = await page.evaluate(() => navigator.clipboard.readText())
+ expect(clipboardContent).toMatch(/create vite/i)
+
+ await expect(page.locator('.nuxt-announcer [aria-live="polite"]')).toContainText(
+ 'Create command copied',
+ )
+
+ await expect(copyButton.locator('span[aria-hidden="true"]')).toHaveClass(/i-lucide:copy/, {
+ timeout: 5000,
+ })
+ await expect(copyButton.locator('span[aria-hidden="true"]')).not.toHaveClass(/i-lucide:check/)
+ })
+ })
+
test.describe('Install Command Copy', () => {
- test('hovering install command shows copy button', async ({ page, goto }) => {
+ test('copy button is accessible and keyboard discoverable', async ({ page, goto }) => {
await goto('/package/is-odd', { waitUntil: 'hydration' })
// Find the install command container
const installCommandContainer = page.locator('.group\\/installcmd').first()
await expect(installCommandContainer).toBeVisible()
- // Copy button should initially be hidden
+ // Copy button should be in the DOM and accessible to screen readers
const copyButton = installCommandContainer.locator('button')
- await expect(copyButton).toHaveCSS('opacity', '0')
+ await expect(copyButton).toBeAttached()
- // Hover over the container
- await installCommandContainer.hover()
-
- // Copy button should become visible
- await expect(copyButton).toHaveCSS('opacity', '1')
+ // Focus the button to verify it's keyboard accessible
+ await copyButton.focus()
+ await expect(copyButton).toBeFocused()
})
test('clicking copy button copies install command and shows confirmation', async ({
@@ -90,23 +143,84 @@ test.describe('Create Command', () => {
await goto('/package/is-odd', { waitUntil: 'hydration' })
- // Find and hover over the install command container
const installCommandContainer = page.locator('.group\\/installcmd').first()
- await installCommandContainer.hover()
-
- // Click the copy button
const copyButton = installCommandContainer.locator('button')
+
+ await copyButton.focus()
+ await expect(copyButton).toBeFocused()
+
await copyButton.click()
- // Button text should change to "copied!"
- await expect(copyButton).toContainText(/copied/i)
+ await expect(copyButton.locator('span[aria-hidden="true"]')).toHaveClass(/i-lucide:check/)
// Verify clipboard content contains the install command
const clipboardContent = await page.evaluate(() => navigator.clipboard.readText())
expect(clipboardContent).toMatch(/install is-odd|add is-odd/i)
- await expect(copyButton).toContainText(/copy/i, { timeout: 5000 })
- await expect(copyButton).not.toContainText(/copied/i)
+ await expect(page.locator('.nuxt-announcer [aria-live="polite"]')).toContainText(
+ 'Install command copied',
+ )
+
+ await expect(copyButton.locator('span[aria-hidden="true"]')).toHaveClass(/i-lucide:copy/, {
+ timeout: 5000,
+ })
+ await expect(copyButton.locator('span[aria-hidden="true"]')).not.toHaveClass(/i-lucide:check/)
+ })
+ })
+
+ test.describe('Run Command Copy', () => {
+ test('copy button is accessible and keyboard discoverable', async ({ page, goto }) => {
+ await goto('/package/vite', { waitUntil: 'hydration' })
+
+ await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 })
+
+ const runCommandContainer = page.locator('.group\\/runcmd').first()
+ await expect(runCommandContainer).toBeVisible({ timeout: 20000 })
+
+ // Copy button should be in the DOM and accessible to screen readers
+ const copyButton = runCommandContainer.locator('button')
+ await expect(copyButton).toBeAttached()
+
+ // Focus the button to verify it's keyboard accessible
+ await copyButton.focus()
+ await expect(copyButton).toBeFocused()
+ })
+
+ test('clicking copy button copies run command and shows confirmation', async ({
+ page,
+ goto,
+ context,
+ }) => {
+ // Grant clipboard permissions
+ await context.grantPermissions(['clipboard-read', 'clipboard-write'])
+
+ await goto('/package/vite', { waitUntil: 'hydration' })
+ await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 })
+
+ const runCommandContainer = page.locator('.group\\/runcmd').first()
+ await expect(runCommandContainer).toBeVisible({ timeout: 20000 })
+
+ const copyButton = runCommandContainer.locator('button')
+
+ await copyButton.focus()
+ await expect(copyButton).toBeFocused()
+
+ await copyButton.click()
+
+ await expect(copyButton.locator('span[aria-hidden="true"]')).toHaveClass(/i-lucide:check/)
+
+ // Verify clipboard content contains the run command
+ const clipboardContent = await page.evaluate(() => navigator.clipboard.readText())
+ expect(clipboardContent).toMatch(/npx vite/i)
+
+ await expect(page.locator('.nuxt-announcer [aria-live="polite"]')).toContainText(
+ 'Run command copied',
+ )
+
+ await expect(copyButton.locator('span[aria-hidden="true"]')).toHaveClass(/i-lucide:copy/, {
+ timeout: 5000,
+ })
+ await expect(copyButton.locator('span[aria-hidden="true"]')).not.toHaveClass(/i-lucide:check/)
})
})
})