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 }} + +
diff --git a/app/components/Button/Base.vue b/app/components/Button/Base.vue index 1a142b193f..99bfa87628 100644 --- a/app/components/Button/Base.vue +++ b/app/components/Button/Base.vue @@ -16,7 +16,7 @@ const props = withDefaults( /** @default "secondary" */ variant?: 'primary' | 'secondary' /** @default "md" */ - size?: 'sm' | 'md' + size?: 'sm' | 'md' | 'icon' /** Keyboard shortcut hint */ ariaKeyshortcuts?: string /** Forces the button to occupy the entire width of its container. */ @@ -50,6 +50,7 @@ defineExpose({ 'flex': block, 'text-sm px-4 py-2': size === 'md', 'text-xs px-2 py-0.5': size === 'sm', + 'text-sm p-2': size === 'icon', 'bg-transparent text-fg hover:enabled:(bg-fg/10) focus-visible:enabled:(bg-fg/10) aria-pressed:(bg-fg/10 border-fg/20 hover:enabled:(bg-fg/20 text-fg/50))': variant === 'secondary', 'text-bg bg-fg hover:enabled:(bg-fg/50) focus-visible:enabled:(bg-fg/50) aria-pressed:(bg-fg text-bg border-fg hover:enabled:(text-bg/50))': diff --git a/app/components/Package/SkillsModal.vue b/app/components/Package/SkillsModal.vue index 34892bb8d9..a88d823bb2 100644 --- a/app/components/Package/SkillsModal.vue +++ b/app/components/Package/SkillsModal.vue @@ -35,7 +35,12 @@ const installCommand = computed(() => { }) const { copied, copy } = useClipboard({ copiedDuring: 2000 }) -const copyCommand = () => installCommand.value && copy(installCommand.value) +const { polite } = useAnnouncer() +const copyCommand = () => { + if (!installCommand.value) return + copy(installCommand.value) + polite($t('package.command.copied_skills')) +} function getWarningTooltip(skill: SkillListItem): string | undefined { if (!skill.warnings?.length) return undefined @@ -123,20 +128,20 @@ function getWarningTooltip(skill: SkillListItem): string | undefined {
-
- +
+ $ npx skills add {{ baseUrl }}/{{ packageName }} - + />
diff --git a/app/components/Terminal/Execute.vue b/app/components/Terminal/Execute.vue index 3984ea9cfa..d131f84875 100644 --- a/app/components/Terminal/Execute.vue +++ b/app/components/Terminal/Execute.vue @@ -14,6 +14,7 @@ const props = defineProps<{ }>() const selectedPM = useSelectedPackageManager() +const { polite } = useAnnouncer() // Generate execute command parts for a specific package manager function getExecutePartsForPM(pmId: PackageManagerId) { @@ -39,7 +40,10 @@ function getFullExecuteCommand() { // Copy handler const { copied: executeCopied, copy: copyExecute } = useClipboard({ copiedDuring: 2000 }) -const copyExecuteCommand = () => copyExecute(getFullExecuteCommand()) +const copyExecuteCommand = () => { + copyExecute(getFullExecuteCommand()) + polite($t('package.command.copied_execute')) +} @@ -289,7 +300,7 @@ useCommandPaletteContextCommands( :data-pm-cmd="pm.id" class="flex items-center gap-2 min-w-0" > - $ + $ - $ + $ {{ 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/) }) }) })