Add keyboard navigation to property selection
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
:placeholder="placeholder"
|
||||
:auto-focus="autoFocus"
|
||||
input-type="search"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -23,6 +24,10 @@ defineProps<{
|
||||
autoFocus?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
keydown: [event: KeyboardEvent]
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
|
||||
const iconClasses = computed(() => {
|
||||
@@ -36,4 +41,11 @@ const iconClasses = computed(() => {
|
||||
const inputClasses = computed(() => {
|
||||
return 'text-foreground-2 placeholder:!text-foreground-3 placeholder:group-hover:!text-foreground-2 focus:placeholder:text-foreground-2'
|
||||
})
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
// For arrow down or enter emit the event to parent
|
||||
if (['ArrowDown', 'Enter'].includes(event.key)) {
|
||||
emit('keydown', event)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
||||
<template>
|
||||
<aside
|
||||
class="absolute left-2 lg:left-0 z-50 flex rounded-lg border border-outline-2 bg-foundation px-1 overflow-visible lg:h-full"
|
||||
class="absolute left-2 lg:left-0 z-50 flex rounded-lg border border-outline-2 bg-foundation px-1 overflow-visible lg:h-full focus-visible:outline-none"
|
||||
:class="[
|
||||
isEmbedEnabled
|
||||
? 'top-[0.5rem]'
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
<div class="px-1">
|
||||
<button
|
||||
v-tippy="{ content: property.parentPath, delay: 500 }"
|
||||
class="w-full h-9 px-1.5 text-foreground rounded-md hover:bg-highlight-1 text-left flex items-center gap-3"
|
||||
:class="!property.parentPath ? 'py-1.5' : 'py-1'"
|
||||
class="w-full h-9 px-1.5 text-foreground rounded-md text-left flex items-center gap-3 transition-colors"
|
||||
:class="[
|
||||
!property.parentPath ? 'py-1.5' : 'py-1',
|
||||
isFocused ? 'bg-highlight-1' : 'hover:bg-highlight-1'
|
||||
]"
|
||||
@click="$emit('selectProperty', property.value)"
|
||||
>
|
||||
<Hash
|
||||
@@ -37,6 +40,7 @@ import { FilterType } from '~/lib/viewer/helpers/filters/types'
|
||||
|
||||
defineProps<{
|
||||
property: PropertyOption
|
||||
isFocused?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col select-none">
|
||||
<div
|
||||
ref="listContainer"
|
||||
class="h-full flex flex-col select-none focus-visible:outline-none"
|
||||
tabindex="0"
|
||||
role="listbox"
|
||||
@keydown="handleListKeydown"
|
||||
>
|
||||
<ViewerFiltersPropertySelectionSearch
|
||||
ref="searchComponent"
|
||||
v-model="searchQuery"
|
||||
placeholder="Search for a property..."
|
||||
input-id="property-search"
|
||||
@keydown="handleSearchKeydown"
|
||||
/>
|
||||
|
||||
<div v-if="isLoading" class="flex-1 flex items-center justify-center py-8">
|
||||
@@ -46,7 +54,10 @@
|
||||
<ViewerFiltersPropertySelectionItem
|
||||
v-else-if="item.type === 'property' && item.property"
|
||||
:property="item.property"
|
||||
:is-focused="isItemFocused(index)"
|
||||
@select-property="$emit('selectProperty', $event)"
|
||||
@mouseenter="handleItemHover(index)"
|
||||
@focus="handleItemHover(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,7 +66,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVirtualList, useDebounceFn } from '@vueuse/core'
|
||||
import { useVirtualList, useDebounceFn, onKeyStroke } from '@vueuse/core'
|
||||
import type {
|
||||
PropertyOption,
|
||||
PropertySelectionListItem
|
||||
@@ -72,24 +83,27 @@ const props = defineProps<{
|
||||
options: PropertyOption[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
selectProperty: [propertyKey: string]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const dataStore = useFilteringDataStore()
|
||||
|
||||
const isLoading = computed(() => {
|
||||
return dataStore.dataSources.value.length === 0
|
||||
})
|
||||
|
||||
const searchQuery = ref('')
|
||||
const listContainer = ref<HTMLElement>()
|
||||
const searchComponent = ref()
|
||||
const focusedIndex = ref(-1)
|
||||
const debouncedSearchQuery = ref('')
|
||||
|
||||
const updateDebouncedSearch = useDebounceFn((query: string) => {
|
||||
debouncedSearchQuery.value = query
|
||||
}, 200)
|
||||
|
||||
// Pre-compute lowercase versions for efficient searching
|
||||
const isLoading = computed(() => {
|
||||
return dataStore.dataSources.value.length === 0
|
||||
})
|
||||
|
||||
const optionsWithLowercase = computed(() => {
|
||||
return props.options.map((option) => ({
|
||||
...option,
|
||||
@@ -161,12 +175,6 @@ const listItems = computed((): PropertySelectionListItem[] => {
|
||||
return items
|
||||
})
|
||||
|
||||
const hasSearchQuery = computed(() => debouncedSearchQuery.value.trim().length > 0)
|
||||
|
||||
const clearSearch = () => {
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
const itemHeight = computed(() => PROPERTY_SELECTION_ITEM_HEIGHT)
|
||||
const maxHeight = computed(() => PROPERTY_SELECTION_MAX_HEIGHT - 28)
|
||||
|
||||
@@ -175,6 +183,156 @@ const { list, containerProps, wrapperProps } = useVirtualList(listItems, {
|
||||
overscan: PROPERTY_SELECTION_OVERSCAN
|
||||
})
|
||||
|
||||
const hasSearchQuery = computed(() => debouncedSearchQuery.value.trim().length > 0)
|
||||
|
||||
const propertyItems = computed(() => {
|
||||
return listItems.value.filter((item) => item.type === 'property' && item.property)
|
||||
})
|
||||
|
||||
const isItemFocused = (index: number) => {
|
||||
const propertyItemIndex = getPropertyItemIndex(index)
|
||||
return propertyItemIndex === focusedIndex.value
|
||||
}
|
||||
|
||||
const getPropertyItemIndex = (virtualIndex: number) => {
|
||||
let propertyIndex = -1
|
||||
for (let i = 0; i <= virtualIndex; i++) {
|
||||
const item = listItems.value[i]
|
||||
if (item && item.type === 'property' && item.property) {
|
||||
propertyIndex++
|
||||
}
|
||||
}
|
||||
return propertyIndex
|
||||
}
|
||||
|
||||
const getVirtualIndex = (propertyIndex: number) => {
|
||||
let currentPropertyIndex = -1
|
||||
for (let i = 0; i < listItems.value.length; i++) {
|
||||
const item = listItems.value[i]
|
||||
if (item && item.type === 'property' && item.property) {
|
||||
currentPropertyIndex++
|
||||
if (currentPropertyIndex === propertyIndex) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// Handle keyboard events from search input
|
||||
const handleSearchKeydown = (event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
if (propertyItems.value.length > 0) {
|
||||
focusedIndex.value = 0
|
||||
scrollToFocusedItem()
|
||||
nextTick(() => {
|
||||
listContainer.value?.focus()
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'Enter':
|
||||
event.preventDefault()
|
||||
if (focusedIndex.value >= 0 && focusedIndex.value < propertyItems.value.length) {
|
||||
const property = propertyItems.value[focusedIndex.value].property
|
||||
if (property) {
|
||||
emit('selectProperty', property.value)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
emit('close')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleListKeydown = (event: KeyboardEvent) => {
|
||||
if (propertyItems.value.length === 0) return
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
if (focusedIndex.value < propertyItems.value.length - 1) {
|
||||
focusedIndex.value++
|
||||
scrollToFocusedItem()
|
||||
}
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
if (focusedIndex.value > 0) {
|
||||
focusedIndex.value--
|
||||
scrollToFocusedItem()
|
||||
}
|
||||
break
|
||||
case 'Enter':
|
||||
event.preventDefault()
|
||||
if (focusedIndex.value >= 0 && focusedIndex.value < propertyItems.value.length) {
|
||||
const property = propertyItems.value[focusedIndex.value].property
|
||||
if (property) {
|
||||
emit('selectProperty', property.value)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'Escape': {
|
||||
event.preventDefault()
|
||||
emit('close')
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle item hover to update focused index
|
||||
const handleItemHover = (virtualIndex: number) => {
|
||||
const propertyItemIndex = getPropertyItemIndex(virtualIndex)
|
||||
if (propertyItemIndex >= 0) {
|
||||
focusedIndex.value = propertyItemIndex
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToFocusedItem = () => {
|
||||
if (focusedIndex.value >= 0) {
|
||||
const virtualIndex = getVirtualIndex(focusedIndex.value)
|
||||
if (virtualIndex >= 0) {
|
||||
nextTick(() => {
|
||||
const container = containerProps.ref.value
|
||||
if (container) {
|
||||
const containerHeight = container.clientHeight
|
||||
const itemHeight = PROPERTY_SELECTION_ITEM_HEIGHT
|
||||
const totalOffset = virtualIndex * itemHeight
|
||||
const centerOffset = containerHeight / 2 - itemHeight / 2
|
||||
const scrollPosition = Math.max(0, totalOffset - centerOffset)
|
||||
|
||||
container.scrollTo({
|
||||
top: scrollPosition,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset scroll position to top when search results change
|
||||
const resetScrollPosition = () => {
|
||||
nextTick(() => {
|
||||
const container = containerProps.ref.value
|
||||
if (container) {
|
||||
container.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
searchQuery.value = ''
|
||||
focusedIndex.value = -1
|
||||
resetScrollPosition()
|
||||
}
|
||||
|
||||
watch(
|
||||
searchQuery,
|
||||
(newQuery) => {
|
||||
@@ -182,4 +340,25 @@ watch(
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Reset focused index and scroll position when list changes
|
||||
watch(listItems, () => {
|
||||
focusedIndex.value = -1
|
||||
resetScrollPosition()
|
||||
})
|
||||
|
||||
// Reset focused index and scroll position when search changes
|
||||
watch(debouncedSearchQuery, () => {
|
||||
focusedIndex.value = -1
|
||||
resetScrollPosition()
|
||||
})
|
||||
|
||||
// Handle typing to focus search input
|
||||
onKeyStroke((event) => {
|
||||
event.preventDefault()
|
||||
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
const searchInput = document.getElementById('property-search') as HTMLInputElement
|
||||
searchInput?.focus()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<template>
|
||||
<div class="border-b border-outline-2 flex-shrink-0 relative">
|
||||
<div class="py-1">
|
||||
<ViewerSearchInput v-model="modelValue" auto-focus :placeholder="placeholder" />
|
||||
<ViewerSearchInput
|
||||
v-model="modelValue"
|
||||
auto-focus
|
||||
:placeholder="placeholder"
|
||||
@keydown="$emit('keydown', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasSearchValue"
|
||||
@@ -30,6 +35,10 @@ defineProps<{
|
||||
inputId?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
keydown: [event: KeyboardEvent]
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
const hasSearchValue = computed(() => modelValue.value.trim().length > 0)
|
||||
|
||||
Reference in New Issue
Block a user