Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 19 additions & 15 deletions app/components/ConnectorModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -125,21 +125,25 @@ watch(open, isOpen => {
>
<span class="text-fg-subtle">$</span>
<span class="text-fg-subtle ms-2">{{ executeNpmxConnectorCommand }}</span>
<button
type="button"
:aria-label="
copied ? $t('connector.modal.copied') : $t('connector.modal.copy_command')
"
class="ms-auto text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded"
@click="copyCommand"
>
<span v-if="!copied" class="i-carbon:copy block w-5 h-5" aria-hidden="true" />
<span
v-else
class="i-carbon:checkmark block w-5 h-5 text-green-500"
aria-hidden="true"
/>
</button>
<div class="ms-auto flex items-center gap-2">
<PackageManagerSelect />

<button
type="button"
:aria-label="
copied ? $t('connector.modal.copied') : $t('connector.modal.copy_command')
"
class="ms-auto text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded"
@click="copyCommand"
>
<span v-if="!copied" class="i-carbon:copy block w-5 h-5" aria-hidden="true" />
<span
v-else
class="i-carbon:checkmark block w-5 h-5 text-green-500"
aria-hidden="true"
/>
</button>
</div>
</div>

<p class="text-sm text-fg-muted">{{ $t('connector.modal.paste_token') }}</p>
Expand Down
150 changes: 150 additions & 0 deletions app/components/PackageManagerSelect.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'

const selectedPM = useSelectedPackageManager()

const listRef = useTemplateRef('listRef')
const triggerRef = useTemplateRef('triggerRef')
const isOpen = shallowRef(false)
const highlightedIndex = shallowRef(-1)

// Generate unique ID for accessibility
const inputId = useId()
const listboxId = `${inputId}-listbox`

const pm = computed(() => {
return packageManagers.find(p => p.id === selectedPM.value) ?? packageManagers[0]
})

function toggle() {
if (isOpen.value) {
close()
} else {
isOpen.value = true
highlightedIndex.value = packageManagers.findIndex(pm => pm.id === selectedPM.value)
}
}

function close() {
isOpen.value = false
highlightedIndex.value = -1
}

function select(id: PackageManagerId) {
selectedPM.value = id
close()
triggerRef.value?.focus()
}

// Check for reduced motion preference
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')

onClickOutside(listRef, close, { ignore: [triggerRef] })
function handleKeydown(event: KeyboardEvent) {
if (!isOpen.value) return

switch (event.key) {
case 'ArrowDown':
event.preventDefault()
highlightedIndex.value = (highlightedIndex.value + 1) % packageManagers.length
break
case 'ArrowUp':
event.preventDefault()
highlightedIndex.value =
highlightedIndex.value <= 0 ? packageManagers.length - 1 : highlightedIndex.value - 1
break
case 'Enter': {
event.preventDefault()
const pm = packageManagers[highlightedIndex.value]
if (pm) {
select(pm.id)
}
break
}
case 'Escape':
close()
triggerRef.value?.focus()
break
}
}
</script>

<template>
<div class="relative">
<button
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chose to build a custom dropdown because I think icons don't show up well in native select. Someone please correct me if I'm wrong!

ref="triggerRef"
type="button"
class="inline-flex items-center gap-1 px-2 py-1 font-mono text-xs text-fg-muted bg-bg-subtle/80 border border-border rounded transition-colors duration-200 hover:(text-fg border-border-hover) active:scale-95 focus:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
:aria-expanded="isOpen"
aria-haspopup="listbox"
:aria-label="$t('settings.package_manager')"
:aria-controls="listboxId"
@click="toggle"
@keydown="handleKeydown"
>
<ClientOnly>
<span class="inline-block h-3 w-3" :class="pm.icon" aria-hidden="true" />
<span>{{ pm.label }}</span>
<template #fallback>
<span class="inline-block h-3 w-3 i-simple-icons:npm" aria-hidden="true" />
<span>npm</span>
</template>
</ClientOnly>
<span
class="i-carbon:chevron-down w-3 h-3"
:class="[
{ 'rotate-180': isOpen },
prefersReducedMotion ? '' : 'transition-transform duration-200',
]"
aria-hidden="true"
/>
</button>

<!-- Dropdown menu -->
<Transition
:enter-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-150'"
:enter-from-class="prefersReducedMotion ? '' : 'opacity-0'"
enter-to-class="opacity-100"
:leave-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-100'"
leave-from-class="opacity-100"
:leave-to-class="prefersReducedMotion ? '' : 'opacity-0'"
>
<ul
v-if="isOpen"
:id="listboxId"
ref="listRef"
role="listbox"
:aria-activedescendant="
highlightedIndex >= 0
? `${listboxId}-${packageManagers[highlightedIndex]?.id}`
: undefined
"
:aria-label="$t('settings.package_manager')"
class="absolute inset-ie-0 top-full mt-1 min-w-28 bg-bg-elevated border border-border rounded-lg shadow-lg z-50 py-1"
>
<li
v-for="(pm, index) in packageManagers"
:id="`${listboxId}-${pm.id}`"
:key="pm.id"
role="option"
:aria-selected="selectedPM === pm.id"
class="flex items-center gap-2 px-3 py-1.5 font-mono text-xs cursor-pointer transition-colors duration-150"
:class="[
selectedPM === pm.id ? 'text-fg' : 'text-fg-subtle',
highlightedIndex === index ? 'bg-bg-subtle' : 'hover:bg-bg-subtle',
]"
@click="select(pm.id)"
@mouseenter="highlightedIndex = index"
>
<span class="inline-block h-3 w-3 shrink-0" :class="pm.icon" aria-hidden="true" />
<span>{{ pm.label }}</span>
<span
v-if="selectedPM === pm.id"
class="i-carbon:checkmark w-3 h-3 text-accent ms-auto shrink-0"
aria-hidden="true"
/>
</li>
</ul>
</Transition>
</div>
</template>
6 changes: 6 additions & 0 deletions app/pages/[...package].vue
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,7 @@ function handleClick(event: MouseEvent) {
</h2>
<!-- Package manager tabs -->
<PackageManagerTabs />
<PackageManagerSelect />
Comment on lines 848 to +849
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: Remove PackageManagerTabs

</div>
<div
role="tabpanel"
Expand Down Expand Up @@ -880,6 +881,7 @@ function handleClick(event: MouseEvent) {
</h2>
<!-- Package manager tabs -->
<PackageManagerTabs />
<PackageManagerSelect />
</div>
<div
role="tabpanel"
Expand Down Expand Up @@ -1117,18 +1119,22 @@ function handleClick(event: MouseEvent) {
grid-area: header;
overflow-x: hidden;
}

.area-install {
grid-area: install;
overflow-x: hidden;
}

.area-vulns {
grid-area: vulns;
overflow-x: hidden;
}

.area-readme {
grid-area: readme;
overflow-x: hidden;
}

.area-sidebar {
grid-area: sidebar;
}
Expand Down
9 changes: 9 additions & 0 deletions test/nuxt/components.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ import ViewModeToggle from '~/components/ViewModeToggle.vue'
import PackageVulnerabilityTree from '~/components/PackageVulnerabilityTree.vue'
import PackageDeprecatedTree from '~/components/PackageDeprecatedTree.vue'
import DependencyPathPopup from '~/components/DependencyPathPopup.vue'
import PackageManagerSelect from '~/components/PackageManagerSelect.vue'

describe('component accessibility audits', () => {
describe('DateTime', () => {
Expand Down Expand Up @@ -1293,4 +1294,12 @@ describe('component accessibility audits', () => {
expect(results.violations).toEqual([])
})
})

describe('PackageManagerSelect', () => {
it('should have no accessibility violations', async () => {
const component = await mountSuspended(PackageManagerSelect)
const results = await runAxe(component)
expect(results.violations).toEqual([])
})
})
})
Loading