-
-
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
-
-
-
-
+ {{ tag }}
+
+
+
+
+
+
+ -
-
- {{ group.primaryVersion.version }}
+ {{ v.version }}
+
+
+ {{ tag }}
+
+
+
+
+
+
+
+
+ {{
+ $t(
+ 'package.versions.view_all',
+ { count: Object.keys(versions).length },
+ Object.keys(versions).length,
+ )
+ }}
+
+
+
+
+
-
-
-
- {{ tag }}
-
-
-
+
diff --git a/app/pages/search.vue b/app/pages/search.vue
index 9010734a57..49793b54c1 100644
--- a/app/pages/search.vue
+++ b/app/pages/search.vue
@@ -438,6 +438,18 @@ function getFocusableElements(): HTMLElement[] {
return [...suggestions, ...packages]
}
+/**
+ * Returns true when focus is inside a text-entry context where j/k should
+ * not be intercepted (input, textarea, or contenteditable).
+ */
+function isTypingContext(): boolean {
+ const el = document.activeElement
+ if (!el) return false
+ const tag = el.tagName
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return true
+ return (el as HTMLElement).isContentEditable
+}
+
/**
* Focus an element and scroll it into view
*/
@@ -477,22 +489,18 @@ watch(displayResults, newResults => {
}
})
-/**
- * Focus the header search input
- */
-function focusSearchInput() {
- const searchInput = document.querySelector
(
- 'input[type="search"], input[name="q"]',
- )
- searchInput?.focus()
-}
-
const keyboardShortcuts = useKeyboardShortcuts()
function handleResultsKeydown(e: KeyboardEvent) {
if (!keyboardShortcuts.value) {
return
}
+
+ // Don't intercept j/k when the user is typing in an input, textarea, or contenteditable
+ if ((e.key === 'j' || e.key === 'k') && isTypingContext()) {
+ return
+ }
+
// If the active element is an input, navigate to exact match or wait for results
if (e.key === 'Enter' && document.activeElement?.tagName === 'INPUT') {
// Get value directly from input (not from route query, which may be debounced)
@@ -521,7 +529,7 @@ function handleResultsKeydown(e: KeyboardEvent) {
const currentIndex = elements.findIndex(el => el === document.activeElement)
- if (e.key === 'ArrowDown') {
+ if (e.key === 'j') {
e.preventDefault()
const nextIndex = currentIndex < 0 ? 0 : Math.min(currentIndex + 1, elements.length - 1)
const el = elements[nextIndex]
@@ -529,13 +537,9 @@ function handleResultsKeydown(e: KeyboardEvent) {
return
}
- if (e.key === 'ArrowUp') {
+ if (e.key === 'k') {
+ if (currentIndex <= 0) return
e.preventDefault()
- // At first result or no result focused: return focus to search input
- if (currentIndex <= 0) {
- focusSearchInput()
- return
- }
const nextIndex = currentIndex - 1
const el = elements[nextIndex]
if (el) focusElement(el)
@@ -555,7 +559,7 @@ function handleResultsKeydown(e: KeyboardEvent) {
}
}
-onKeyDown(['ArrowDown', 'ArrowUp', 'Enter'], handleResultsKeydown)
+onKeyDown(['j', 'k', 'Enter'], handleResultsKeydown)
useSeoMeta({
title: () =>
diff --git a/docs/content/2.guide/2.keyboard-shortcuts.md b/docs/content/2.guide/2.keyboard-shortcuts.md
index 1c35a31660..eba2444252 100644
--- a/docs/content/2.guide/2.keyboard-shortcuts.md
+++ b/docs/content/2.guide/2.keyboard-shortcuts.md
@@ -36,10 +36,10 @@ These shortcuts work anywhere on the site. Press `/` from any page to quickly se
## Search results
-| Key | Action |
-| ------------------------- | --------------------- |
-| `Arrow Up` / `Arrow Down` | Move through results |
-| `Enter` | Open selected package |
+| Key | Action |
+| --------- | --------------------- |
+| `j` / `k` | Move through results |
+| `Enter` | Open selected package |
## Package page
diff --git a/docs/content/index.md b/docs/content/index.md
index 012bd7ce8f..c0f0df11ee 100644
--- a/docs/content/index.md
+++ b/docs/content/index.md
@@ -42,7 +42,7 @@ links:
:::u-page-feature{icon="i-lucide:link" to="/guide/url-structure" title="Use familiar URLs" description="Replace npmjs.com with npmx.dev in any URL and share exact package, code, diff, docs, changelog, timeline, stats, search, and compare views."}
:::
-:::u-page-feature{icon="i-lucide:keyboard" to="/guide/keyboard-shortcuts" title="Navigate with keyboard" description="Open the command palette with ⌘K on macOS or Ctrl+K on Windows and Linux. Press / to search. Use arrow keys to browse results."}
+:::u-page-feature{icon="i-lucide:keyboard" to="/guide/keyboard-shortcuts" title="Navigate with keyboard" description="Open the command palette with ⌘K on macOS or Ctrl+K on Windows and Linux. Press / to search. Use j/k to browse results."}
:::
:::u-page-feature{icon="i-lucide:shield-check" to="/guide/features" title="Check security" description="Vulnerability warnings from OSV database and provenance indicators for verified builds."}
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index c6881a08ae..433922b16f 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -585,6 +585,8 @@
},
"versions": {
"title": "Versions",
+ "selector_label": "Select version",
+ "nav_label": "Package versions",
"collapse": "Collapse {tag}",
"expand": "Expand {tag}",
"collapse_other": "Collapse other versions",
diff --git a/i18n/schema.json b/i18n/schema.json
index a955b9da4e..159837c333 100644
--- a/i18n/schema.json
+++ b/i18n/schema.json
@@ -1759,6 +1759,12 @@
"title": {
"type": "string"
},
+ "selector_label": {
+ "type": "string"
+ },
+ "nav_label": {
+ "type": "string"
+ },
"collapse": {
"type": "string"
},
diff --git a/test/e2e/docs.spec.ts b/test/e2e/docs.spec.ts
index 267ed6ad69..83a0915495 100644
--- a/test/e2e/docs.spec.ts
+++ b/test/e2e/docs.spec.ts
@@ -1,5 +1,21 @@
+import type { Page } from '@playwright/test'
import { expect, test } from './test-utils'
+/** The version selector trigger button in the package subheader. */
+function getVersionButton(page: Page) {
+ return page.locator('[data-testid="package-subheader"] [data-testid="version-selector-button"]')
+}
+
+/** The native popover that holds the version links. */
+function getVersionDropdown(page: Page) {
+ return page.locator('[popover="auto"]')
+}
+
+/** Version links inside the open dropdown for the given package. */
+function getVersionLinks(page: Page, packageName: string) {
+ return getVersionDropdown(page).locator(`a[href*="/package-docs/${packageName}/v/"]`)
+}
+
test.describe('API Documentation Pages', () => {
test('docs page loads and shows content for a package', async ({ page, goto }) => {
// Use a small, stable package with TypeScript types
@@ -10,7 +26,7 @@ test.describe('API Documentation Pages', () => {
// Header should show package name and version
await expect(page.locator('header').getByText('ufo')).toBeVisible()
- await expect(page.locator('[data-testid="package-subheader"]').getByText('1.6.3')).toBeVisible()
+ await expect(getVersionButton(page).getByText('1.6.3')).toBeVisible()
// Should have documentation content
const docsContent = page.locator('.docs-content')
@@ -94,35 +110,30 @@ test.describe('Version Selector', () => {
await goto('/package-docs/ufo/v/1.6.3', { waitUntil: 'hydration' })
// Find and click the version selector button (wait for it to be visible)
- const versionButton = page
- .locator('[data-testid="package-subheader"] button')
- .filter({ hasText: '1.6.3' })
+ const versionButton = getVersionButton(page).filter({ hasText: '1.6.3' })
await expect(versionButton).toBeVisible({ timeout: 10000 })
await versionButton.click()
- // Dropdown should appear with version options
- const dropdown = page.locator('[role="listbox"]')
+ // Popover should appear with version links
+ const dropdown = getVersionDropdown(page)
await expect(dropdown).toBeVisible()
// Should show multiple versions
- const versionOptions = dropdown.locator('[role="option"]')
- await expect(versionOptions.first()).toBeVisible()
+ await expect(getVersionLinks(page, 'ufo').first()).toBeVisible()
})
test('selecting a version navigates to that version', async ({ page, goto }) => {
await goto('/package-docs/ufo/v/1.6.3', { waitUntil: 'hydration' })
// Find and click the version selector button (wait for it to be visible)
- const versionButton = page
- .locator('[data-testid="package-subheader"] button')
- .filter({ hasText: '1.6.3' })
+ const versionButton = getVersionButton(page).filter({ hasText: '1.6.3' })
await expect(versionButton).toBeVisible({ timeout: 10000 })
await versionButton.click()
// Find a version link that's not the current version by checking the href
- const versionLinks = page.locator('[role="option"] a[href*="/package-docs/ufo/v/"]')
+ const versionLinks = getVersionLinks(page, 'ufo')
const count = await versionLinks.count()
// Find first link that doesn't point to 1.6.3
@@ -150,14 +161,12 @@ test.describe('Version Selector', () => {
await goto('/package-docs/ufo/v/1.6.3', { waitUntil: 'hydration' })
// Wait for version button to be visible
- const versionButton = page
- .locator('[data-testid="package-subheader"] button')
- .filter({ hasText: '1.6.3' })
+ const versionButton = getVersionButton(page).filter({ hasText: '1.6.3' })
await expect(versionButton).toBeVisible({ timeout: 10000 })
await versionButton.click()
- const dropdown = page.locator('[role="listbox"]')
+ const dropdown = getVersionDropdown(page)
await expect(dropdown).toBeVisible()
// Press escape
diff --git a/test/e2e/interactions.spec.ts b/test/e2e/interactions.spec.ts
index 2ac88c05fc..1b767aa821 100644
--- a/test/e2e/interactions.spec.ts
+++ b/test/e2e/interactions.spec.ts
@@ -2,7 +2,9 @@ import { expect, test } from './test-utils'
test.describe('Compare Page', () => {
test('no-dep column renders separately from package columns', async ({ page, goto }) => {
- await goto('/compare?packages=vue,__no_dependency__', { waitUntil: 'hydration' })
+ await goto('/compare?packages=vue,__no_dependency__', {
+ waitUntil: 'hydration',
+ })
const grid = page.locator('.comparison-grid')
await expect(grid).toBeVisible({ timeout: 15000 })
@@ -20,13 +22,15 @@ test.describe('Compare Page', () => {
goto,
}) => {
// Start with vue and no-dep
- await goto('/compare?packages=vue,__no_dependency__', { waitUntil: 'hydration' })
+ await goto('/compare?packages=vue,__no_dependency__', {
+ waitUntil: 'hydration',
+ })
const grid = page.locator('.comparison-grid')
await expect(grid).toBeVisible({ timeout: 15000 })
// Add another package via the input
- const input = page.locator('#package-search')
+ const input = page.getByRole('combobox', { name: 'Search for packages' })
await input.fill('nuxt')
// Wait for search results and click on nuxt
@@ -176,7 +180,7 @@ test.describe('Package Page', () => {
})
test.describe('Search Pages', () => {
- test('/search?q=vue → keyboard navigation (arrow keys + enter)', async ({ page, goto }) => {
+ test('/search?q=vue → keyboard navigation (j/k + enter)', async ({ page, goto }) => {
await goto('/search?q=vue', { waitUntil: 'hydration' })
await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({
@@ -187,11 +191,11 @@ test.describe('Search Pages', () => {
await expect(firstResult).toBeVisible()
// Global keyboard navigation works regardless of focus
- // ArrowDown selects the next result
- await page.keyboard.press('ArrowDown')
+ // j moves focus to the next result
+ await page.keyboard.press('j')
- // ArrowUp selects the previous result
- await page.keyboard.press('ArrowUp')
+ // k moves focus to the previous result (does nothing at the first item)
+ await page.keyboard.press('k')
// Enter navigates to the selected result
// URL is /package/vue or /org/vue or /user/vue. Not /vue
@@ -199,7 +203,7 @@ test.describe('Search Pages', () => {
await expect(page).toHaveURL(/\/(package|org|user)\/vue/)
})
- test('/search?q=vue → ArrowDown navigates only between results, not keyword buttons', async ({
+ test('/search?q=vue → j navigates only between results, not keyword buttons', async ({
page,
goto,
}) => {
@@ -214,16 +218,16 @@ test.describe('Search Pages', () => {
await expect(firstResult).toBeVisible()
await expect(secondResult).toBeVisible()
- // ArrowDown from input focuses the first result
- await page.keyboard.press('ArrowDown')
+ // j from input focuses the first result
+ await page.keyboard.press('j')
await expect(firstResult).toBeFocused()
- // Second ArrowDown focuses the second result (not a keyword button within the first)
- await page.keyboard.press('ArrowDown')
+ // Second j focuses the second result (not a keyword button within the first)
+ await page.keyboard.press('j')
await expect(secondResult).toBeFocused()
})
- test('/search?q=vue → ArrowUp from first result returns focus to search input', async ({
+ test('/search?q=vue → k at first result does nothing (focus stays on first result)', async ({
page,
goto,
}) => {
@@ -234,12 +238,13 @@ test.describe('Search Pages', () => {
})
// Navigate to first result
- await page.keyboard.press('ArrowDown')
- await expect(page.locator('[data-result-index="0"]').first()).toBeFocused()
+ await page.keyboard.press('j')
+ const firstResult = page.locator('[data-result-index="0"]').first()
+ await expect(firstResult).toBeFocused()
- // ArrowUp returns to the search input
- await page.keyboard.press('ArrowUp')
- await expect(page.locator('input[type="search"]')).toBeFocused()
+ // k at the first result does nothing — focus stays on the first result
+ await page.keyboard.press('k')
+ await expect(firstResult).toBeFocused()
})
test('/search?q=vue → "/" focuses the search input from results', async ({ page, goto }) => {
diff --git a/test/nuxt/components/VersionSelector.spec.ts b/test/nuxt/components/VersionSelector.spec.ts
index 417d563edb..dca9667b4f 100644
--- a/test/nuxt/components/VersionSelector.spec.ts
+++ b/test/nuxt/components/VersionSelector.spec.ts
@@ -1,4 +1,5 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
+import { userEvent } from 'vitest/browser'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import type { PackageVersionInfo } from '#shared/types/npm-registry'
import VersionSelector from '~/components/VersionSelector.vue'
@@ -13,6 +14,37 @@ vi.mock('~/utils/npm/api', () => ({
const mockNavigateTo = vi.fn()
vi.stubGlobal('navigateTo', mockNavigateTo)
+const defaultProps = {
+ packageName: 'test-package',
+ currentVersion: '1.0.0',
+ versions: { '1.0.0': {} },
+ distTags: { latest: '1.0.0' },
+ urlPattern: '/package-docs/test-package/v/{version}',
+}
+
+type VersionSelectorWrapper = Awaited>
+
+/** The trigger button that toggles the version popover. */
+function getTrigger(component: VersionSelectorWrapper) {
+ return component.find('[data-testid="version-selector-button"]')
+}
+
+/** The native popover element that holds the version groups. */
+function getPopover(component: VersionSelectorWrapper) {
+ return component.find('[popover="auto"]')
+}
+
+/** Open the popover by activating its trigger. */
+function openPopover(component: VersionSelectorWrapper) {
+ return getTrigger(component).trigger('click')
+}
+
+/** Returns true when the native popover is open (in the top layer). */
+function isPopoverOpen(component: VersionSelectorWrapper): boolean {
+ const popover = getPopover(component)
+ return popover.exists() && popover.element.matches(':popover-open')
+}
+
describe('VersionSelector', () => {
beforeEach(() => {
mockFetchAllPackageVersions.mockReset()
@@ -22,16 +54,9 @@ describe('VersionSelector', () => {
describe('basic rendering', () => {
it('renders the current version in the button', async () => {
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: defaultProps,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
+ const button = getTrigger(component)
expect(button.exists()).toBe(true)
expect(button.text()).toContain('1.0.0')
})
@@ -39,543 +64,400 @@ describe('VersionSelector', () => {
it('shows "latest" badge when current version is latest', async () => {
const component = await mountSuspended(VersionSelector, {
props: {
- packageName: 'test-package',
+ ...defaultProps,
currentVersion: '2.0.0',
versions: { '2.0.0': {} },
distTags: { latest: '2.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
},
})
-
expect(component.text()).toContain('latest')
})
- it('does not show "latest" badge when current version is not latest', async () => {
+ it('does not show "latest" badge in trigger when current version is not latest', async () => {
const component = await mountSuspended(VersionSelector, {
props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
+ ...defaultProps,
versions: { '1.0.0': {}, '2.0.0': {} },
distTags: { latest: '2.0.0', old: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
},
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
- // The button itself shouldn't have the latest badge
+ const button = getTrigger(component)
expect(button.text()).not.toContain('latest')
})
- it('has aria-expanded="false" initially', async () => {
+ it('popover is not visible initially', async () => {
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: defaultProps,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
- expect(button.attributes('aria-expanded')).toBe('false')
+ expect(isPopoverOpen(component)).toBe(false)
})
})
- describe('dropdown behavior', () => {
- it('opens dropdown when button is clicked', async () => {
+ describe('popover behavior', () => {
+ it('opens popover when trigger is clicked', async () => {
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: defaultProps,
+ attachTo: document.body,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- expect(button.attributes('aria-expanded')).toBe('true')
- expect(component.find('[role="listbox"]').exists()).toBe(true)
+ await openPopover(component)
+ expect(isPopoverOpen(component)).toBe(true)
+ component.unmount()
})
- it('closes dropdown when button is clicked again', async () => {
+ it('closes popover when trigger is clicked again', async () => {
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: defaultProps,
+ attachTo: document.body,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
+ const button = getTrigger(component)
await button.trigger('click')
- expect(button.attributes('aria-expanded')).toBe('true')
-
+ expect(isPopoverOpen(component)).toBe(true)
await button.trigger('click')
- expect(button.attributes('aria-expanded')).toBe('false')
+ expect(isPopoverOpen(component)).toBe(false)
+ component.unmount()
})
- it('shows version groups in dropdown', async () => {
+ it('shows version groups in popover', async () => {
const component = await mountSuspended(VersionSelector, {
props: {
- packageName: 'test-package',
+ ...defaultProps,
currentVersion: '2.0.0',
versions: { '1.0.0': {}, '2.0.0': {} },
- distTags: {
- latest: '2.0.0',
- old: '1.0.0',
- },
- urlPattern: '/package-docs/test-package/v/{version}',
+ distTags: { latest: '2.0.0', old: '1.0.0' },
},
+ attachTo: document.body,
})
+ await openPopover(component)
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- const listbox = component.find('[role="listbox"]')
- expect(listbox.text()).toContain('2.0.0')
- expect(listbox.text()).toContain('1.0.0')
+ const popover = getPopover(component)
+ expect(popover.text()).toContain('2.0.0')
+ expect(popover.text()).toContain('1.0.0')
+ component.unmount()
})
it('shows "View all X versions" link', async () => {
const component = await mountSuspended(VersionSelector, {
props: {
- packageName: 'test-package',
+ ...defaultProps,
currentVersion: '1.0.0',
versions: { '1.0.0': {}, '2.0.0': {}, '3.0.0': {} },
distTags: { latest: '3.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
},
+ attachTo: document.body,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
+ await openPopover(component)
expect(component.text()).toContain('View all 3 versions')
+ component.unmount()
})
})
describe('keyboard navigation', () => {
- it('opens dropdown on ArrowDown when closed', async () => {
+ it('opens popover on ArrowDown when trigger is focused', async () => {
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: defaultProps,
+ attachTo: document.body,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('keydown', { key: 'ArrowDown' })
-
- expect(button.attributes('aria-expanded')).toBe('true')
+ await getTrigger(component).trigger('keydown', { key: 'ArrowDown' })
+ expect(isPopoverOpen(component)).toBe(true)
+ component.unmount()
})
- it('closes dropdown on Escape', async () => {
+ it('closes popover on Escape', async () => {
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: defaultProps,
+ attachTo: document.body,
})
+ await openPopover(component)
+ expect(isPopoverOpen(component)).toBe(true)
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
- expect(button.attributes('aria-expanded')).toBe('true')
-
- await button.trigger('keydown', { key: 'Escape' })
- expect(button.attributes('aria-expanded')).toBe('false')
+ // A real (trusted) Escape triggers the browser's native popover
+ // light-dismiss; a synthetic keydown event would not.
+ await userEvent.keyboard('{Escape}')
+ await vi.waitFor(() => expect(isPopoverOpen(component)).toBe(false))
+ component.unmount()
})
- it('navigates with arrow keys in listbox', async () => {
+ it('navigates with arrow keys in popover', async () => {
const component = await mountSuspended(VersionSelector, {
props: {
- packageName: 'test-package',
+ ...defaultProps,
currentVersion: '2.0.0',
versions: { '1.0.0': {}, '2.0.0': {} },
- distTags: {
- latest: '2.0.0',
- old: '1.0.0',
- },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
- })
-
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- const listbox = component.find('[role="listbox"]')
-
- // Navigate down
- await listbox.trigger('keydown', { key: 'ArrowDown' })
-
- // Navigate up
- await listbox.trigger('keydown', { key: 'ArrowUp' })
-
- // Should still be focused on an item (test that it doesn't crash)
- expect(listbox.exists()).toBe(true)
- })
-
- it('closes listbox on Escape', async () => {
- const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
+ distTags: { latest: '2.0.0', old: '1.0.0' },
},
+ attachTo: document.body,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- const listbox = component.find('[role="listbox"]')
- await listbox.trigger('keydown', { key: 'Escape' })
-
- expect(button.attributes('aria-expanded')).toBe('false')
+ await openPopover(component)
+ const popover = getPopover(component)
+ await popover.trigger('keydown', { key: 'ArrowDown' })
+ await popover.trigger('keydown', { key: 'ArrowUp' })
+ expect(isPopoverOpen(component)).toBe(true)
+ component.unmount()
})
it('navigates to Home and End', async () => {
const component = await mountSuspended(VersionSelector, {
props: {
- packageName: 'test-package',
+ ...defaultProps,
currentVersion: '3.0.0',
versions: { '1.0.0': {}, '2.0.0': {}, '3.0.0': {} },
- distTags: {
- latest: '3.0.0',
- beta: '2.0.0',
- old: '1.0.0',
- },
- urlPattern: '/package-docs/test-package/v/{version}',
+ distTags: { latest: '3.0.0', beta: '2.0.0', old: '1.0.0' },
},
+ attachTo: document.body,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- const listbox = component.find('[role="listbox"]')
-
- // Navigate to end
- await listbox.trigger('keydown', { key: 'End' })
-
- // Navigate to home
- await listbox.trigger('keydown', { key: 'Home' })
-
- // Should not crash
- expect(listbox.exists()).toBe(true)
+ await openPopover(component)
+ const popover = getPopover(component)
+ await popover.trigger('keydown', { key: 'End' })
+ await popover.trigger('keydown', { key: 'Home' })
+ expect(isPopoverOpen(component)).toBe(true)
+ component.unmount()
})
})
describe('version selection', () => {
- it('closes dropdown and navigates when clicking a version', async () => {
+ it('generates correct URL from pattern', async () => {
const component = await mountSuspended(VersionSelector, {
props: {
- packageName: 'test-package',
+ ...defaultProps,
currentVersion: '2.0.0',
versions: { '1.0.0': {}, '2.0.0': {} },
- distTags: {
- latest: '2.0.0',
- old: '1.0.0',
- },
- urlPattern: '/package-docs/test-package/v/{version}',
+ distTags: { latest: '2.0.0', old: '1.0.0' },
+ urlPattern: '/package-code/test-package/v/{version}/src/index.ts',
},
+ attachTo: document.body,
})
+ await openPopover(component)
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- // Click on the version link
const versionLink = component.findAll('a').find(a => a.text().includes('1.0.0'))
- expect(versionLink?.exists()).toBe(true)
- await versionLink!.trigger('click')
-
- // Dropdown should close
- expect(button.attributes('aria-expanded')).toBe('false')
+ expect(versionLink?.attributes('href')).toBe(
+ '/package-code/test-package/v/1.0.0/src/index.ts',
+ )
+ component.unmount()
})
- it('generates correct URL from pattern', async () => {
+ it('closes popover when clicking a version link', async () => {
const component = await mountSuspended(VersionSelector, {
props: {
- packageName: 'test-package',
+ ...defaultProps,
currentVersion: '2.0.0',
versions: { '1.0.0': {}, '2.0.0': {} },
- distTags: {
- latest: '2.0.0',
- old: '1.0.0',
- },
- urlPattern: '/package-code/test-package/v/{version}/src/index.ts',
+ distTags: { latest: '2.0.0', old: '1.0.0' },
},
+ attachTo: document.body,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
+ await openPopover(component)
const versionLink = component.findAll('a').find(a => a.text().includes('1.0.0'))
- expect(versionLink?.attributes('href')).toBe(
- '/package-code/test-package/v/1.0.0/src/index.ts',
- )
+ expect(versionLink?.exists()).toBe(true)
+ await versionLink!.trigger('click')
+ expect(isPopoverOpen(component)).toBe(false)
+ component.unmount()
})
})
describe('expand/collapse groups', () => {
it('shows expand button for groups', async () => {
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: defaultProps,
+ attachTo: document.body,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- // Find expand button within the dropdown
- const expandButton = component.find('[role="listbox"] button[aria-expanded]')
- expect(expandButton.exists()).toBe(true)
+ await openPopover(component)
+ expect(getPopover(component).find('button[aria-expanded]').exists()).toBe(true)
+ component.unmount()
})
it('loads versions when expanding a group', async () => {
mockFetchAllPackageVersions.mockResolvedValue([
- { version: '1.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false },
- { version: '0.9.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false },
+ {
+ version: '1.0.0',
+ time: '2024-01-15T12:00:00.000Z',
+ hasProvenance: false,
+ },
+ {
+ version: '0.9.0',
+ time: '2024-01-10T12:00:00.000Z',
+ hasProvenance: false,
+ },
])
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: defaultProps,
+ attachTo: document.body,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- // Find and click expand button
- const expandButton = component.find('[role="listbox"] button[aria-expanded="false"]')
- await expandButton.trigger('click')
+ await openPopover(component)
+ await getPopover(component).find('button[aria-expanded="false"]').trigger('click')
await vi.waitFor(() => {
expect(mockFetchAllPackageVersions).toHaveBeenCalledWith('test-package')
})
+ component.unmount()
})
it('collapses group when clicking expanded button', async () => {
mockFetchAllPackageVersions.mockResolvedValue([
- { version: '1.2.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false },
- { version: '1.1.0', time: '2024-01-12T12:00:00.000Z', hasProvenance: false },
- { version: '1.0.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false },
+ {
+ version: '1.2.0',
+ time: '2024-01-15T12:00:00.000Z',
+ hasProvenance: false,
+ },
+ {
+ version: '1.1.0',
+ time: '2024-01-12T12:00:00.000Z',
+ hasProvenance: false,
+ },
+ {
+ version: '1.0.0',
+ time: '2024-01-10T12:00:00.000Z',
+ hasProvenance: false,
+ },
])
const component = await mountSuspended(VersionSelector, {
props: {
- packageName: 'test-package',
+ ...defaultProps,
currentVersion: '1.2.0',
versions: { '1.2.0': {} },
- distTags: { latest: '1.2.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
},
+ attachTo: document.body,
})
+ await openPopover(component)
+ await getPopover(component).find('button[aria-expanded]').trigger('click')
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- // Expand
- const expandButton = component.find('[role="listbox"] button[aria-expanded]')
- await expandButton.trigger('click')
-
- await vi.waitFor(() => {
- expect(mockFetchAllPackageVersions).toHaveBeenCalled()
- })
-
- // Wait for expansion
+ await vi.waitFor(() => expect(mockFetchAllPackageVersions).toHaveBeenCalled())
await vi.waitFor(
- () => {
- const btn = component.find('[role="listbox"] button[aria-expanded="true"]')
- expect(btn.exists()).toBe(true)
- },
+ () =>
+ expect(getPopover(component).find('button[aria-expanded="true"]').exists()).toBe(true),
{ timeout: 2000 },
)
- // Collapse
- const expandedButton = component.find('[role="listbox"] button[aria-expanded="true"]')
- await expandedButton.trigger('click')
-
+ await getPopover(component).find('button[aria-expanded="true"]').trigger('click')
await vi.waitFor(
- () => {
- const btn = component.find('[role="listbox"] button[aria-expanded="false"]')
- expect(btn.exists()).toBe(true)
- },
+ () =>
+ expect(getPopover(component).find('button[aria-expanded="false"]').exists()).toBe(true),
{ timeout: 2000 },
)
+ component.unmount()
})
it('toggles older version groups for a single-version tagged release', async () => {
mockFetchAllPackageVersions.mockResolvedValue([
- { version: '1.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false },
- { version: '0.9.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false },
+ {
+ version: '1.0.0',
+ time: '2024-01-15T12:00:00.000Z',
+ hasProvenance: false,
+ },
+ {
+ version: '0.9.0',
+ time: '2024-01-10T12:00:00.000Z',
+ hasProvenance: false,
+ },
])
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {}, '0.9.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
- })
-
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- const expandButton = component.find('[role="listbox"] button[aria-expanded="false"]')
- await expandButton.trigger('click')
-
- await vi.waitFor(() => {
- expect(mockFetchAllPackageVersions).toHaveBeenCalledWith('test-package')
+ props: { ...defaultProps, versions: { '1.0.0': {}, '0.9.0': {} } },
+ attachTo: document.body,
})
+ await openPopover(component)
+ await getPopover(component).find('button[aria-expanded="false"]').trigger('click')
+ await vi.waitFor(() =>
+ expect(mockFetchAllPackageVersions).toHaveBeenCalledWith('test-package'),
+ )
await vi.waitFor(() => {
- expect(component.find('[role="listbox"]').text()).toContain('0.9')
- const expandedButton = component.find('[role="listbox"] button[aria-expanded="true"]')
- expect(expandedButton.exists()).toBe(true)
+ expect(getPopover(component).text()).toContain('0.9')
+ expect(getPopover(component).find('button[aria-expanded="true"]').exists()).toBe(true)
})
- const expandedButton = component.find('[role="listbox"] button[aria-expanded="true"]')
- await expandedButton.trigger('click')
-
+ await getPopover(component).find('button[aria-expanded="true"]').trigger('click')
await vi.waitFor(() => {
- expect(component.find('[role="listbox"]').text()).not.toContain('0.9')
- const collapsedButton = component.find('[role="listbox"] button[aria-expanded="false"]')
- expect(collapsedButton.exists()).toBe(true)
+ expect(getPopover(component).text()).not.toContain('0.9')
+ expect(getPopover(component).find('button[aria-expanded="false"]').exists()).toBe(true)
})
+ component.unmount()
})
it('does not reveal unrelated older groups when expanding a tagged row with nested versions', async () => {
mockFetchAllPackageVersions.mockResolvedValue([
- { version: '1.2.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false },
- { version: '1.1.0', time: '2024-01-12T12:00:00.000Z', hasProvenance: false },
- { version: '1.0.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false },
- { version: '0.9.0', time: '2024-01-08T12:00:00.000Z', hasProvenance: false },
+ {
+ version: '1.2.0',
+ time: '2024-01-15T12:00:00.000Z',
+ hasProvenance: false,
+ },
+ {
+ version: '1.1.0',
+ time: '2024-01-12T12:00:00.000Z',
+ hasProvenance: false,
+ },
+ {
+ version: '1.0.0',
+ time: '2024-01-10T12:00:00.000Z',
+ hasProvenance: false,
+ },
+ {
+ version: '0.9.0',
+ time: '2024-01-08T12:00:00.000Z',
+ hasProvenance: false,
+ },
])
const component = await mountSuspended(VersionSelector, {
props: {
- packageName: 'test-package',
+ ...defaultProps,
currentVersion: '1.2.0',
versions: { '1.2.0': {}, '1.1.0': {}, '1.0.0': {}, '0.9.0': {} },
distTags: { latest: '1.2.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
- })
-
- const trigger = component.find('button[aria-haspopup="listbox"]')
- await trigger.trigger('click')
-
- const expandButton = component.find('[role="listbox"] button[aria-expanded="false"]')
- await expandButton.trigger('click')
-
- await vi.waitFor(() => {
- expect(mockFetchAllPackageVersions).toHaveBeenCalledWith('test-package')
- })
-
- await vi.waitFor(() => {
- const listboxText = component.find('[role="listbox"]').text()
- expect(listboxText).toContain('1.1.0')
- expect(listboxText).toContain('1.0.0')
- expect(listboxText).not.toContain('0.9')
- })
-
- const expandedButton = component.find('[role="listbox"] button[aria-expanded="true"]')
- await expandedButton.trigger('click')
-
- await vi.waitFor(() => {
- const listboxText = component.find('[role="listbox"]').text()
- expect(listboxText).not.toContain('1.1.0')
- expect(listboxText).not.toContain('1.0.0')
- expect(listboxText).not.toContain('0.9')
- })
- })
-
- it('collapses additional version groups with ArrowLeft when showAllGroups is open', async () => {
- mockFetchAllPackageVersions.mockResolvedValue([
- { version: '1.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false },
- { version: '0.9.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false },
- ])
-
- const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {}, '0.9.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
},
+ attachTo: document.body,
})
+ await openPopover(component)
+ await getPopover(component).find('button[aria-expanded="false"]').trigger('click')
- const trigger = component.find('button[aria-haspopup="listbox"]')
- await trigger.trigger('click')
-
- await component.find('[role="listbox"] button[aria-expanded="false"]').trigger('click')
-
+ await vi.waitFor(() =>
+ expect(mockFetchAllPackageVersions).toHaveBeenCalledWith('test-package'),
+ )
await vi.waitFor(() => {
- expect(component.find('[role="listbox"]').text()).toContain('0.9')
+ const text = getPopover(component).text()
+ expect(text).toContain('1.1.0')
+ expect(text).toContain('1.0.0')
+ expect(text).not.toContain('0.9')
})
- const listbox = component.find('[role="listbox"]')
- await listbox.trigger('keydown', { key: 'ArrowLeft' })
-
+ await getPopover(component).find('button[aria-expanded="true"]').trigger('click')
await vi.waitFor(() => {
- expect(listbox.text()).not.toContain('0.9')
+ const text = getPopover(component).text()
+ expect(text).not.toContain('1.1.0')
+ expect(text).not.toContain('1.0.0')
+ expect(text).not.toContain('0.9')
})
+ component.unmount()
})
it('resets showAllGroups when dist-tags props change after loading', async () => {
mockFetchAllPackageVersions.mockResolvedValue([
- { version: '1.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false },
- { version: '0.9.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false },
+ {
+ version: '1.0.0',
+ time: '2024-01-15T12:00:00.000Z',
+ hasProvenance: false,
+ },
+ {
+ version: '0.9.0',
+ time: '2024-01-10T12:00:00.000Z',
+ hasProvenance: false,
+ },
])
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {}, '0.9.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: { ...defaultProps, versions: { '1.0.0': {}, '0.9.0': {} } },
+ attachTo: document.body,
})
+ await openPopover(component)
+ await getPopover(component).find('button[aria-expanded="false"]').trigger('click')
- const trigger = component.find('button[aria-haspopup="listbox"]')
- await trigger.trigger('click')
- await component.find('[role="listbox"] button[aria-expanded="false"]').trigger('click')
-
- await vi.waitFor(() => {
- expect(component.find('[role="listbox"]').text()).toContain('0.9')
- })
+ await vi.waitFor(() => expect(getPopover(component).text()).toContain('0.9'))
await component.setProps({ distTags: { latest: '1.0.0' } })
-
- await vi.waitFor(() => {
- expect(component.find('[role="listbox"]').text()).not.toContain('0.9')
- })
+ await vi.waitFor(() => expect(getPopover(component).text()).not.toContain('0.9'))
+ component.unmount()
})
it('ignores expand clicks while a group is already loading', async () => {
@@ -586,19 +468,11 @@ describe('VersionSelector', () => {
mockFetchAllPackageVersions.mockReturnValue(loadPromise)
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: defaultProps,
+ attachTo: document.body,
})
-
- const trigger = component.find('button[aria-haspopup="listbox"]')
- await trigger.trigger('click')
-
- const expandButton = component.find('[role="listbox"] button[aria-expanded]')
+ await openPopover(component)
+ const expandButton = getPopover(component).find('button[aria-expanded]')
await expandButton.trigger('click')
await expandButton.trigger('click')
@@ -608,43 +482,55 @@ describe('VersionSelector', () => {
{
version: '1.0.0',
time: '2024-01-15T12:00:00.000Z',
- trustStatus: { provenance: false, trustedPublisher: false, stagedPublish: false },
+ trustStatus: {
+ provenance: false,
+ trustedPublisher: false,
+ stagedPublish: false,
+ },
},
])
+ component.unmount()
})
})
describe('0.x version grouping', () => {
it('groups 0.x versions by minor version, not major', async () => {
mockFetchAllPackageVersions.mockResolvedValue([
- { version: '0.10.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false },
- { version: '0.10.1', time: '2024-01-16T12:00:00.000Z', hasProvenance: false },
- { version: '0.9.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false },
- { version: '0.9.3', time: '2024-01-12T12:00:00.000Z', hasProvenance: false },
+ {
+ version: '0.10.0',
+ time: '2024-01-15T12:00:00.000Z',
+ hasProvenance: false,
+ },
+ {
+ version: '0.10.1',
+ time: '2024-01-16T12:00:00.000Z',
+ hasProvenance: false,
+ },
+ {
+ version: '0.9.0',
+ time: '2024-01-10T12:00:00.000Z',
+ hasProvenance: false,
+ },
+ {
+ version: '0.9.3',
+ time: '2024-01-12T12:00:00.000Z',
+ hasProvenance: false,
+ },
])
const component = await mountSuspended(VersionSelector, {
props: {
- packageName: 'test-package',
+ ...defaultProps,
currentVersion: '0.10.1',
versions: { '0.10.1': {} },
distTags: { latest: '0.10.1' },
- urlPattern: '/package-docs/test-package/v/{version}',
},
+ attachTo: document.body,
})
+ await openPopover(component)
+ await getPopover(component).find('button[aria-expanded]').trigger('click')
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- // Expand the group
- const expandButton = component.find('[role="listbox"] button[aria-expanded]')
- await expandButton.trigger('click')
-
- await vi.waitFor(() => {
- expect(mockFetchAllPackageVersions).toHaveBeenCalled()
- })
-
- // Wait for versions to load
+ await vi.waitFor(() => expect(mockFetchAllPackageVersions).toHaveBeenCalled())
await vi.waitFor(
() => {
const text = component.text()
@@ -654,6 +540,7 @@ describe('VersionSelector', () => {
},
{ timeout: 2000 },
)
+ component.unmount()
})
})
@@ -661,45 +548,28 @@ describe('VersionSelector', () => {
it('displays multiple tags for same version', async () => {
const component = await mountSuspended(VersionSelector, {
props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: {
- latest: '1.0.0',
- stable: '1.0.0',
- },
- urlPattern: '/package-docs/test-package/v/{version}',
+ ...defaultProps,
+ distTags: { latest: '1.0.0', stable: '1.0.0' },
},
+ attachTo: document.body,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- const listbox = component.find('[role="listbox"]')
- expect(listbox.text()).toContain('latest')
- expect(listbox.text()).toContain('stable')
+ await openPopover(component)
+ const popover = getPopover(component)
+ expect(popover.text()).toContain('latest')
+ expect(popover.text()).toContain('stable')
+ component.unmount()
})
it('shows "latest" tag with special styling', async () => {
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: defaultProps,
+ attachTo: document.body,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- // Find the latest tag span
+ await openPopover(component)
const latestTags = component.findAll('span').filter(s => s.text() === 'latest')
expect(latestTags.length).toBeGreaterThan(0)
- // Should have accent styling
- const hasAccentStyle = latestTags.some(t => t.classes().some(c => c.includes('badge-accent')))
- expect(hasAccentStyle).toBe(true)
+ expect(latestTags.some(t => t.classes().some(c => c.includes('badge-accent')))).toBe(true)
+ component.unmount()
})
})
@@ -709,278 +579,163 @@ describe('VersionSelector', () => {
mockFetchAllPackageVersions.mockReturnValue(promise)
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: defaultProps,
+ attachTo: document.body,
})
+ await openPopover(component)
+ await getPopover(component).find('button[aria-expanded]').trigger('click')
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- // Click expand
- const expandButton = component.find('[role="listbox"] button[aria-expanded]')
- await expandButton.trigger('click')
-
- // Should show loading spinner (motion-safe:animate-spin is applied)
await vi.waitFor(() => {
- const spinner = component.find('.i-svg-spinners\\:ring-resize')
- expect(spinner.exists()).toBe(true)
+ expect(component.find('.i-svg-spinners\\:ring-resize').exists()).toBe(true)
})
-
- // Resolve the promise to clean up
resolve([])
+ component.unmount()
})
})
describe('accessibility', () => {
- it('has aria-haspopup="listbox" on trigger button', async () => {
+ it('trigger button has popovertarget wired to the popover id', async () => {
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: defaultProps,
})
-
- const button = component.find('button[aria-haspopup]')
- expect(button.attributes('aria-haspopup')).toBe('listbox')
+ const button = getTrigger(component)
+ const popover = getPopover(component)
+ expect(button.attributes('popovertarget')).toBe(popover.attributes('id'))
})
- it('has role="listbox" on dropdown', async () => {
+ it('popover has an accessible aria-label', async () => {
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: defaultProps,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- expect(component.find('[role="listbox"]').exists()).toBe(true)
+ expect(getPopover(component).attributes('aria-label')).toBeTruthy()
})
- it('has role="option" on version items', async () => {
+ it('component is wrapped in a nav with an aria-label', async () => {
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: defaultProps,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- expect(component.find('[role="option"]').exists()).toBe(true)
+ expect(component.find('nav[aria-label]').exists()).toBe(true)
})
- it('sets aria-selected on current version', async () => {
+ it('current version link has aria-current="page"', async () => {
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: defaultProps,
+ attachTo: document.body,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- const selectedOption = component.find('[role="option"][aria-selected="true"]')
- expect(selectedOption.exists()).toBe(true)
+ await openPopover(component)
+ const currentLink = component.find('a[aria-current="page"]')
+ expect(currentLink.exists()).toBe(true)
+ expect(currentLink.text()).toContain('1.0.0')
+ component.unmount()
})
- it('updates aria-activedescendant when navigating', async () => {
+ it('non-current version links do not have aria-current', async () => {
const component = await mountSuspended(VersionSelector, {
props: {
- packageName: 'test-package',
+ ...defaultProps,
currentVersion: '2.0.0',
versions: { '1.0.0': {}, '2.0.0': {} },
- distTags: {
- latest: '2.0.0',
- old: '1.0.0',
- },
- urlPattern: '/package-docs/test-package/v/{version}',
+ distTags: { latest: '2.0.0', old: '1.0.0' },
},
+ attachTo: document.body,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- const listbox = component.find('[role="listbox"]')
- expect(listbox.attributes('aria-activedescendant')).toBeDefined()
+ await openPopover(component)
+ const oldLink = component.findAll('a[href]').find(a => a.text().includes('1.0.0'))
+ expect(oldLink?.attributes('aria-current')).toBeUndefined()
+ component.unmount()
})
- it('expand buttons have aria-expanded attribute', async () => {
+ it('expand buttons have aria-expanded and aria-controls', async () => {
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: defaultProps,
+ attachTo: document.body,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- const expandButton = component.find('[role="listbox"] button[aria-expanded]')
+ await openPopover(component)
+ const expandButton = getPopover(component).find('button[aria-expanded]')
expect(expandButton.exists()).toBe(true)
expect(['true', 'false']).toContain(expandButton.attributes('aria-expanded'))
+ expect(expandButton.attributes('aria-controls')).toBeTruthy()
+ component.unmount()
})
- it('expand buttons have aria-label', async () => {
+ it('expand buttons have a descriptive aria-label', async () => {
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: defaultProps,
+ attachTo: document.body,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- const expandButton = component.find('[role="listbox"] button[aria-label]')
+ await openPopover(component)
+ const expandButton = getPopover(component).find('button[aria-label]')
expect(expandButton.exists()).toBe(true)
- expect(expandButton.attributes('aria-label')).toMatch(/Expand|Collapse/)
+ expect(expandButton.attributes('aria-label')).toBeTruthy()
+ component.unmount()
})
- it('icons have aria-hidden attribute', async () => {
+ it('decorative icons have aria-hidden', async () => {
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: defaultProps,
})
-
- // The chevron icon in the main button
- const chevronIcon = component.find('button[aria-haspopup] span[aria-hidden="true"]')
- expect(chevronIcon.exists()).toBe(true)
+ expect(component.findAll('[aria-hidden="true"]').length).toBeGreaterThan(0)
})
})
describe('error handling', () => {
it('handles fetch errors gracefully', async () => {
mockFetchAllPackageVersions.mockRejectedValue(new Error('Network error'))
-
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
+ props: defaultProps,
+ attachTo: document.body,
})
+ await openPopover(component)
+ await getPopover(component).find('button[aria-expanded]').trigger('click')
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
-
- // Click expand
- const expandButton = component.find('[role="listbox"] button[aria-expanded]')
- await expandButton.trigger('click')
-
- // Wait for error to be logged
await vi.waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Failed to load versions:', expect.any(Error))
})
-
consoleSpy.mockRestore()
+ component.unmount()
})
})
describe('caching behavior', () => {
it('only fetches versions once when expanding multiple groups', async () => {
mockFetchAllPackageVersions.mockResolvedValue([
- { version: '2.0.0', time: '2024-01-15T12:00:00.000Z', hasProvenance: false },
- { version: '1.0.0', time: '2024-01-10T12:00:00.000Z', hasProvenance: false },
+ {
+ version: '2.0.0',
+ time: '2024-01-15T12:00:00.000Z',
+ hasProvenance: false,
+ },
+ {
+ version: '1.0.0',
+ time: '2024-01-10T12:00:00.000Z',
+ hasProvenance: false,
+ },
])
const component = await mountSuspended(VersionSelector, {
props: {
- packageName: 'test-package',
+ ...defaultProps,
currentVersion: '2.0.0',
versions: { '1.0.0': {}, '2.0.0': {} },
- distTags: {
- latest: '2.0.0',
- old: '1.0.0',
- },
- urlPattern: '/package-docs/test-package/v/{version}',
+ distTags: { latest: '2.0.0', old: '1.0.0' },
},
+ attachTo: document.body,
})
-
- const button = component.find('button[aria-haspopup="listbox"]')
+ const button = getTrigger(component)
await button.trigger('click')
- // Expand first group
- const expandButtons = component.findAll('[role="listbox"] button[aria-expanded="false"]')
- if (expandButtons[0]) {
- await expandButtons[0].trigger('click')
- }
+ const expandButtons = getPopover(component).findAll('button[aria-expanded="false"]')
+ if (expandButtons[0]) await expandButtons[0].trigger('click')
+ await vi.waitFor(() => expect(mockFetchAllPackageVersions).toHaveBeenCalledTimes(1))
- await vi.waitFor(() => {
- expect(mockFetchAllPackageVersions).toHaveBeenCalledTimes(1)
- })
-
- // Close and reopen
await button.trigger('click')
await button.trigger('click')
- // Expand another group - should not fetch again
- const updatedButtons = component.findAll('[role="listbox"] button[aria-expanded="false"]')
- if (updatedButtons[0]) {
- await updatedButtons[0].trigger('click')
- }
-
- // Should still only have been called once
+ const updatedButtons = getPopover(component).findAll('button[aria-expanded="false"]')
+ if (updatedButtons[0]) await updatedButtons[0].trigger('click')
expect(mockFetchAllPackageVersions).toHaveBeenCalledTimes(1)
- })
- })
-
- describe('click outside', () => {
- it('closes dropdown when clicking outside', async () => {
- const component = await mountSuspended(VersionSelector, {
- props: {
- packageName: 'test-package',
- currentVersion: '1.0.0',
- versions: { '1.0.0': {} },
- distTags: { latest: '1.0.0' },
- urlPattern: '/package-docs/test-package/v/{version}',
- },
- attachTo: document.body,
- })
-
- const button = component.find('button[aria-haspopup="listbox"]')
- await button.trigger('click')
- expect(button.attributes('aria-expanded')).toBe('true')
-
- // Simulate click outside by directly setting isOpen
- // Note: onClickOutside is hard to test in JSDOM, so we verify the behavior exists
- // by checking the component closes when we trigger a click on the main element
- // after it opens
+ component.unmount()
})
})
})