Skip to content
Merged
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
126 changes: 108 additions & 18 deletions app/components/Compare/PackageSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ const maxPackages = computed(() => props.max ?? 4)
const inputValue = shallowRef('')
const isInputFocused = shallowRef(false)

// Keyboard navigation state
const highlightedIndex = shallowRef(-1)
const listRef = useTemplateRef('listRef')
const PAGE_JUMP = 5

// Use the shared search composable (supports both npm and Algolia providers)
const { searchProvider } = useSearchProvider()
const { data: searchData, status } = useSearch(inputValue, searchProvider, { size: 15 })
Expand Down Expand Up @@ -54,6 +59,20 @@ const filteredResults = computed(() => {
.filter(r => !packages.value.includes(r.name))
})

// Unified list of navigable items for keyboard navigation
const navigableItems = computed(() => {
const items: { type: 'no-dependency' | 'package'; name: string }[] = []
if (showNoDependencyOption.value) {
items.push({ type: 'no-dependency', name: NO_DEPENDENCY_ID })
}
for (const r of filteredResults.value) {
items.push({ type: 'package', name: r.name })
}
return items
})

const resultIndexOffset = computed(() => (showNoDependencyOption.value ? 1 : 0))

const numberFormatter = useNumberFormatter()

function addPackage(name: string) {
Expand All @@ -71,30 +90,93 @@ function addPackage(name: string) {
packages.value = [...packages.value, name]
}
inputValue.value = ''
highlightedIndex.value = -1
}

function removePackage(name: string) {
packages.value = packages.value.filter(p => p !== name)
}

function handleKeydown(e: KeyboardEvent) {
const inputValueTrim = inputValue.value.trim()
const hasMatchInPackages = filteredResults.value.find(result => {
return result.name === inputValueTrim
})

if (e.key === 'Enter' && inputValueTrim) {
e.preventDefault()
if (showNoDependencyOption.value) {
addPackage(NO_DEPENDENCY_ID)
} else if (hasMatchInPackages) {
addPackage(inputValueTrim)
const items = navigableItems.value
const count = items.length

switch (e.key) {
case 'ArrowDown':
e.preventDefault()
if (count === 0) return
highlightedIndex.value = Math.min(highlightedIndex.value + 1, count - 1)
break

case 'ArrowUp':
e.preventDefault()
if (count === 0) return
if (highlightedIndex.value > 0) {
highlightedIndex.value--
}
break

case 'PageDown':
e.preventDefault()
if (count === 0) return
if (highlightedIndex.value === -1) {
highlightedIndex.value = Math.min(PAGE_JUMP - 1, count - 1)
} else {
highlightedIndex.value = Math.min(highlightedIndex.value + PAGE_JUMP, count - 1)
}
break

case 'PageUp':
e.preventDefault()
if (count === 0) return
highlightedIndex.value = Math.max(highlightedIndex.value - PAGE_JUMP, 0)
break

case 'Enter': {
const inputValueTrim = inputValue.value.trim()
if (!inputValueTrim) return

e.preventDefault()

// If an item is highlighted, select it
if (highlightedIndex.value >= 0 && highlightedIndex.value < count) {
addPackage(items[highlightedIndex.value]!.name)
return
}

// Fallback: exact match or easter egg (preserves existing behavior)
if (showNoDependencyOption.value) {
addPackage(NO_DEPENDENCY_ID)
} else {
const hasMatch = filteredResults.value.find(r => r.name === inputValueTrim)
if (hasMatch) {
addPackage(inputValueTrim)
}
}
break
}
} else if (e.key === 'Escape') {
inputValue.value = ''

case 'Escape':
inputValue.value = ''
highlightedIndex.value = -1
break
}
}

// Reset highlight when user types
watch(inputValue, () => {
highlightedIndex.value = -1
})

// Scroll highlighted item into view
watch(highlightedIndex, index => {
if (index >= 0 && listRef.value) {
const items = listRef.value.querySelectorAll('[data-navigable]')
const item = items[index] as HTMLElement | undefined
item?.scrollIntoView({ block: 'nearest' })
}
})

const { start, stop } = useTimeoutFn(() => {
isInputFocused.value = false
}, 200)
Expand Down Expand Up @@ -176,16 +258,18 @@ function handleFocus() {
leave-to-class="opacity-0"
>
<div
v-if="
isInputFocused && (filteredResults.length > 0 || isSearching || showNoDependencyOption)
"
v-if="isInputFocused && (navigableItems.length > 0 || isSearching)"
ref="listRef"
class="absolute top-full inset-x-0 mt-1 bg-bg-elevated border border-border rounded-lg shadow-lg z-50 max-h-64 overflow-y-auto"
>
<!-- No dependency option (easter egg with James) -->
<ButtonBase
v-if="showNoDependencyOption"
data-navigable
class="block w-full text-start"
:class="highlightedIndex === 0 ? '!bg-accent/15' : ''"
:aria-label="$t('compare.no_dependency.add_column')"
@mouseenter="highlightedIndex = 0"
@click="addPackage(NO_DEPENDENCY_ID)"
>
<span class="text-sm text-accent italic flex items-center gap-2">
Expand All @@ -197,13 +281,19 @@ function handleFocus() {
</span>
</ButtonBase>

<div v-if="isSearching" class="px-4 py-3 text-sm text-fg-muted">
<div
v-if="isSearching && navigableItems.length === 0"
class="px-4 py-3 text-sm text-fg-muted"
>
{{ $t('compare.selector.searching') }}
</div>
<ButtonBase
v-for="result in filteredResults"
v-for="(result, index) in filteredResults"
:key="result.name"
data-navigable
class="block w-full text-start"
:class="highlightedIndex === index + resultIndexOffset ? '!bg-accent/15' : ''"
@mouseenter="highlightedIndex = index + resultIndexOffset"
@click="addPackage(result.name)"
>
<span class="font-mono text-sm text-fg block">{{ result.name }}</span>
Expand Down
Loading