Skip to content

Conversation

@MatthewAry
Copy link
Contributor

@MatthewAry MatthewAry commented Nov 13, 2025

https://bin.vuetifyjs.com/bins/l_ScLQ

Add VList "Strategy" Mode with Index Tracking

The Problem (Context)

VList's keyboard navigation assumes: focus movement to items
VCommandPalette needs: index tracking without focus movement

Currently VCommandPalette works around this by:

  • Setting tabindex=-1 on items (breaks accessibility)
  • Intercepting keyboard events with useHotkey before VList sees them
  • Reimplementing all keyboard logic separately (212 lines)
  • Managing selection state independently from VList

The Solution

Add two keyboard navigation modes to VList:

Mode 1: 'focus' (default, current behavior)

  • Arrow keys move DOM focus to items
  • Uses existing focusChild() utility
  • Backward compatible
  • Use case: Traditional lists, select components

Mode 2: 'track' (new, for VCommandPalette)

  • Arrow keys update a tracked index
  • No DOM focus movement
  • Emits index changes to consumer
  • Use case: Command palette, autocomplete with external focus, dropdowns

Why This Works

  • VList still owns keyboard logic - it's the right place for it
  • VCommandPalette gets clean bindings - no interception, no hacks
  • Search input keeps focus - navigationStrategy='track' doesn't move focus
  • Backward compatible - default mode is 'focus', existing behavior unchanged
  • Reusable - any component needing this pattern can use 'strategy' mode

@MatthewAry MatthewAry changed the title feat: Add VList "Track" Mode with Index Tracking feat: Add VList "Strategy" Mode with Index Tracking Nov 14, 2025
@MatthewAry MatthewAry requested review from johnleider and removed request for johnleider November 14, 2025 18:38
Comment on lines +279 to 320
let handled = false

if (props.navigationStrategy === 'track') {
let nextIdx: number | null = null

if (e.key === 'ArrowDown') {
nextIdx = getNextIndex('next')
handled = true
} else if (e.key === 'ArrowUp') {
nextIdx = getNextIndex('prev')
handled = true
} else if (e.key === 'Home') {
nextIdx = getNextIndex('first')
handled = true
} else if (e.key === 'End') {
nextIdx = getNextIndex('last')
handled = true
}

if (handled && nextIdx !== null && nextIdx !== -1) {
navigationIndex.value = nextIdx
}
} else {
return
if (e.key === 'ArrowDown') {
focus('next')
handled = true
} else if (e.key === 'ArrowUp') {
focus('prev')
handled = true
} else if (e.key === 'Home') {
focus('first')
handled = true
} else if (e.key === 'End') {
focus('last')
handled = true
}
}

e.preventDefault()
if (handled) {
e.preventDefault()
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
let handled = false
if (props.navigationStrategy === 'track') {
let nextIdx: number | null = null
if (e.key === 'ArrowDown') {
nextIdx = getNextIndex('next')
handled = true
} else if (e.key === 'ArrowUp') {
nextIdx = getNextIndex('prev')
handled = true
} else if (e.key === 'Home') {
nextIdx = getNextIndex('first')
handled = true
} else if (e.key === 'End') {
nextIdx = getNextIndex('last')
handled = true
}
if (handled && nextIdx !== null && nextIdx !== -1) {
navigationIndex.value = nextIdx
}
} else {
return
if (e.key === 'ArrowDown') {
focus('next')
handled = true
} else if (e.key === 'ArrowUp') {
focus('prev')
handled = true
} else if (e.key === 'Home') {
focus('first')
handled = true
} else if (e.key === 'End') {
focus('last')
handled = true
}
}
e.preventDefault()
if (handled) {
e.preventDefault()
}
}
const direction = getNavigatonDirection(e.key)
if (direction !== null) {
e.preventDefault()
if (props.navigationStrategy === 'track') {
navigationIndex.value = getNextIndex(direction)
} else {
focus(direction)
}
}
}
function getNavigatonDirection (key: string) {
switch (key) {
case 'ArrowUp': return 'prev'
case 'ArrowDown': return 'next'
case 'Home': return 'first'
case 'End': return 'last'
default: return null
}
}

@J-Sek
Copy link
Contributor

J-Sek commented Nov 15, 2025

At which point do we supplement visual feedback? I have a feeling it should be a part of this PR.

watch(navigationIndex, v => {
  activateSingle(v)
  // ...and scroll
})
// nested.ts
// naive implementation, without testing with expandable and nested lists... but maybe it is fine?
activateSingle: (index: number) => {
  const targetValue = [...nodeIds].at(index)
  activated.value = new Set(targetValue !== undefined ? [targetValue] : [])
},
Playground (from one of my oldest demos)
<template>
  <v-app theme="dark">
    <div class="d-flex justify-space-around">
      <v-menu v-model="open" :close-on-content-click="false">
        <template #activator="{ props }">
          <v-btn color="primary" v-bind="props">{{ selection[0] }}</v-btn>
        </template>
        <v-card>
          <v-text-field
            ref="searchField"
            v-model="search"
            autocomplete="off"
            class="ma-1"
            density="compact"
            min-width="200"
            variant="outlined"
            hide-details
            @keydown.down="moveDown"
            @keydown.enter="selectFirstVisibleItem"
            @keydown.up="moveUp"
          />
          <v-list
            v-model:navigation-index="listNavIndex"
            v-model:selected="selection"
            :items="visibleItems"
            class="py-0"
            density="compact"
            elevation="0"
            max-height="300"
            navigation-strategy="track"
            activatable
            item-props
            mandatory
            @update:selected="open = false"
          >
            <template #item="{ props }">
              <v-list-item v-bind="props">
                <template v-if="search" #title>
                  <span>{{ props.title.split(searchRegex, 1)[0] }}</span>
                  <span class="font-weight-bold">{{ props.title.match(searchRegex)[0] }}</span>
                  <span>{{
                    props.title.substring(props.title.split(searchRegex)[0].length
                      + search.length) }}</span>
                </template>
              </v-list-item>
            </template>
          </v-list>
        </v-card>
      </v-menu>
    </div>
  </v-app>
</template>

<script setup lang="ts">
  import { computed, ref, watch } from 'vue'

  const selection = ref(['Apple'])
  const search = ref('')
  const searchField = ref()

  const listNavIndex = ref<number>()
  function moveUp () {
    listNavIndex.value = Math.max(0, (listNavIndex.value ?? visibleItems.value.length) - 1)
  }
  function moveDown () {
    listNavIndex.value = Math.min(visibleItems.value.length, (listNavIndex.value ?? -1) + 1)
  }

  const open = ref(false)
  const items = [
    { title: 'Apple', value: 'Apple' },
    { title: 'Orange', value: 'Orange' },
    { title: 'Banana', value: 'Banana' },
    { title: 'Grapefruit', value: 'Grapefruit' },
  ].concat(
    [...new Array(50)].map((_, i) => ({
      title: `item #${i}`,
      value: `item #${i}`,
    }))
  )

  const searchRegex = computed(() => {
    const regEscape = v => v.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
    return new RegExp(regEscape(search.value), 'i')
  })

  const visibleItems = computed(() => {
    return search.value
      ? items.filter(x => searchRegex.value.test(x.title))
      : items
  })

  function selectFirstVisibleItem () {
    if (!visibleItems.value.length) return
    selection.value = [visibleItems.value[0].value]
    open.value = false
  }

  watch(open, v => {
    if (v) {
      setTimeout(() => searchField.value.focus(), 200)
    } else {
      setTimeout(() => search.value = '', 300)
    }
  })
</script>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants