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
<template>
  <v-app>
    <v-container class="pa-4" fluid>
      <h1 class="text-h4 mb-4">VList Navigation Strategy Test Suite</h1>

      <!-- Test 1: Mode Comparison -->
      <v-card class="mb-6">
        <v-card-title>Test 1: Focus vs Track Mode</v-card-title>
        <v-card-subtitle>
          Arrow keys in FOCUS mode should move DOM focus.
          Arrow keys in TRACK mode should show visual indicator without moving focus.
        </v-card-subtitle>
        <v-card-text>
          <v-radio-group v-model="strategy" inline>
            <v-radio label="Focus Mode (default)" value="focus" />
            <v-radio label="Track Mode" value="track" />
          </v-radio-group>

          <div class="mb-2">
            <strong>Navigation Index:</strong> {{ navIndex1 }}
            <v-btn class="ml-2" size="small" @click="navIndex1 = 0">Set to 0</v-btn>
            <v-btn class="ml-2" size="small" @click="navIndex1 = -1">Reset to -1</v-btn>
          </div>

          <v-list
            v-model:navigation-index="navIndex1"
            :items="basicItems"
            :navigation-strategy="strategy"
            activatable
            border
          />

          <v-alert v-if="strategy === 'focus'" class="mt-2" density="compact" type="info">
            ✓ Tab key should enter list items<br>
            ✓ Arrow keys should move browser focus<br>
            ✓ Focus ring should appear on focused item
          </v-alert>
          <v-alert v-else class="mt-2" density="compact" type="info">
            ✓ Tab key should skip list items (tabindex=-1)<br>
            ✓ Arrow keys should show border indicator<br>
            ✓ Focus stays on container, not items<br>
            ✓ aria-activedescendant should update
          </v-alert>
        </v-card-text>
      </v-card>

      <!-- Test 2: Command Palette Scenario -->
      <v-card class="mb-6">
        <v-card-title>Test 2: Command Palette Pattern (Track Mode)</v-card-title>
        <v-card-subtitle>
          Type to filter, arrow keys navigate, Enter to select.
          Search input should maintain focus throughout.
        </v-card-subtitle>
        <v-card-text>
          <v-text-field
            ref="searchInput"
            v-model="search"
            placeholder="Type to search..."
            prepend-inner-icon="mdi-magnify"
            variant="outlined"
            autofocus
            @keydown.down.prevent="moveDown"
            @keydown.enter.prevent="selectItem"
            @keydown.up.prevent="moveUp"
          />

          <div class="mb-2">
            <strong>Selected Item:</strong> {{ selectedItem || 'None' }}
            <strong class="ml-4">Navigation Index:</strong> {{ commandPaletteIndex }}
          </div>

          <v-list
            v-model:navigation-index="commandPaletteIndex"
            :items="filteredItems"
            max-height="300"
            navigation-strategy="track"
            activatable
            border
            @click:activate="handleActivate"
          />

          <v-alert class="mt-2" density="compact" type="success">
            ✓ Search input keeps focus while navigating<br>
            ✓ Arrow keys in input navigate list<br>
            ✓ Visual indicator shows current item<br>
            ✓ Auto-scrolling works with long lists
          </v-alert>
        </v-card-text>
      </v-card>

      <!-- Test 3: Long List with Scrolling -->
      <v-card class="mb-6">
        <v-card-title>Test 3: Auto-Scrolling (Track Mode)</v-card-title>
        <v-card-subtitle>
          Navigate through many items to verify auto-scroll works.
        </v-card-subtitle>
        <v-card-text>
          <div class="mb-2">
            <strong>Navigation Index:</strong> {{ navIndex3 }} / {{ longList.length }}
            <v-btn class="ml-2" size="small" @click="navIndex3 = 0">Jump to First</v-btn>
            <v-btn class="ml-2" size="small" @click="navIndex3 = 49">Jump to Last</v-btn>
          </div>

          <v-list
            v-model:navigation-index="navIndex3"
            :items="longList"
            max-height="200"
            navigation-strategy="track"
            style="overflow-y: auto;"
            activatable
            border
          />

          <v-alert class="mt-2" density="compact" type="info">
            ✓ Items scroll into view automatically<br>
            ✓ Home/End keys work<br>
            ✓ Visual indicator always visible
          </v-alert>
        </v-card-text>
      </v-card>

      <!-- Test 4: Mixed Item Types -->
      <v-card class="mb-6">
        <v-card-title>Test 4: Dividers & Subheaders (Track Mode)</v-card-title>
        <v-card-subtitle>
          Navigation should skip non-selectable items.
        </v-card-subtitle>
        <v-card-text>
          <div class="mb-2">
            <strong>Navigation Index:</strong> {{ navIndex4 }}
            (Dividers and subheaders should be skipped)
          </div>

          <v-list
            v-model:navigation-index="navIndex4"
            :items="mixedItems"
            navigation-strategy="track"
            activatable
            border
          />

          <v-alert class="mt-2" density="compact" type="info">
            ✓ Arrow keys skip dividers<br>
            ✓ Arrow keys skip subheaders<br>
            ✓ Only regular items get focus indicator
          </v-alert>
        </v-card-text>
      </v-card>

      <!-- Test 5: Accessibility -->
      <v-card class="mb-6">
        <v-card-title>Test 5: Accessibility (Track Mode)</v-card-title>
        <v-card-subtitle>
          Open browser DevTools and inspect the VList element.
        </v-card-subtitle>
        <v-card-text>
          <div class="mb-2">
            <strong>Navigation Index:</strong> {{ navIndex5 }}<br>
            <strong>Expected aria-activedescendant:</strong>
            {{ navIndex5 >= 0 ? `v-list-item-{uid}-${navIndex5}` : 'undefined' }}
          </div>

          <v-list
            v-model:navigation-index="navIndex5"
            :items="basicItems"
            navigation-strategy="track"
            activatable
            border
          />

          <v-alert class="mt-2" density="compact" type="warning">
            Inspect the v-list element:<br>
            ✓ Should have role="listbox"<br>
            ✓ Should have aria-activedescendant="v-list-item-{uid}-{index}" when index >= 0<br>
            ✓ List items should have id="v-list-item-{uid}-{index}" (unique per list)<br>
            ✓ List items should have tabindex="-1" in track mode
          </v-alert>
        </v-card-text>
      </v-card>

      <!-- Test 6: v-model Binding -->
      <v-card class="mb-6">
        <v-card-title>Test 6: Two-Way Binding</v-card-title>
        <v-card-subtitle>
          Changes should sync between lists.
        </v-card-subtitle>
        <v-card-text>
          <v-row>
            <v-col cols="6">
              <h3>List A</h3>
              <div class="mb-2"><strong>Index:</strong> {{ sharedIndex }}</div>
              <v-list
                v-model:navigation-index="sharedIndex"
                :items="basicItems"
                navigation-strategy="track"
                activatable
                border
              />
            </v-col>
            <v-col cols="6">
              <h3>List B (Same Index)</h3>
              <div class="mb-2"><strong>Index:</strong> {{ sharedIndex }}</div>
              <v-list
                v-model:navigation-index="sharedIndex"
                :items="basicItems"
                navigation-strategy="track"
                activatable
                border
              />
            </v-col>
          </v-row>

          <v-alert class="mt-2" density="compact" type="info">
            ✓ Navigate in one list, both should update<br>
            ✓ v-model:navigationIndex works correctly
          </v-alert>
        </v-card-text>
      </v-card>
    </v-container>
  </v-app>
</template>

<script setup>
  import { computed, ref } from 'vue'

  // Test 1: Basic navigation
  const strategy = ref('track')
  const navIndex1 = ref(-1)
  const basicItems = [
    { title: 'Item 1', value: 1 },
    { title: 'Item 2', value: 2 },
    { title: 'Item 3', value: 3 },
    { title: 'Item 4', value: 4 },
    { title: 'Item 5', value: 5 },
  ]

  // Test 2: Command Palette
  const search = ref('')
  const commandPaletteIndex = ref(-1)
  const selectedItem = ref(null)
  const searchInput = ref(null)

  const commandItems = [
    { title: 'Create New File', value: 'new-file', subtitle: 'Ctrl+N' },
    { title: 'Open File', value: 'open', subtitle: 'Ctrl+O' },
    { title: 'Save File', value: 'save', subtitle: 'Ctrl+S' },
    { title: 'Save As...', value: 'save-as', subtitle: 'Ctrl+Shift+S' },
    { title: 'Close File', value: 'close', subtitle: 'Ctrl+W' },
    { title: 'Find in Files', value: 'find', subtitle: 'Ctrl+Shift+F' },
    { title: 'Replace in Files', value: 'replace', subtitle: 'Ctrl+Shift+H' },
    { title: 'Go to Line', value: 'goto', subtitle: 'Ctrl+G' },
    { title: 'Command Palette', value: 'palette', subtitle: 'Ctrl+Shift+P' },
    { title: 'Settings', value: 'settings', subtitle: 'Ctrl+,' },
  ]

  const filteredItems = computed(() => {
    if (!search.value) return commandItems
    const query = search.value.toLowerCase()
    return commandItems.filter(item =>
      item.title.toLowerCase().includes(query) ||
      item.subtitle.toLowerCase().includes(query)
    )
  })

  function moveDown () {
    if (commandPaletteIndex.value < filteredItems.value.length - 1) {
      commandPaletteIndex.value++
    } else {
      commandPaletteIndex.value = 0
    }
  }

  function moveUp () {
    if (commandPaletteIndex.value > 0) {
      commandPaletteIndex.value--
    } else {
      commandPaletteIndex.value = filteredItems.value.length - 1
    }
  }

  function selectItem () {
    if (commandPaletteIndex.value >= 0 && commandPaletteIndex.value < filteredItems.value.length) {
      selectedItem.value = filteredItems.value[commandPaletteIndex.value].title
    }
  }

  function handleActivate (event) {
    selectedItem.value = event.value.title
  }

  // Test 3: Long list
  const navIndex3 = ref(-1)
  const longList = Array.from({ length: 50 }, (_, i) => ({
    title: `Item ${i + 1}`,
    value: i,
    subtitle: `This is item number ${i + 1}`,
  }))

  // Test 4: Mixed items
  const navIndex4 = ref(-1)
  const mixedItems = [
    { type: 'subheader', title: 'Section 1' },
    { title: 'Item 1', value: 1 },
    { title: 'Item 2', value: 2 },
    { type: 'divider' },
    { type: 'subheader', title: 'Section 2' },
    { title: 'Item 3', value: 3 },
    { title: 'Item 4', value: 4 },
    { type: 'divider' },
    { type: 'subheader', title: 'Section 3' },
    { title: 'Item 5', value: 5 },
  ]

  // Test 5: Accessibility
  const navIndex5 = ref(-1)

  // Test 6: Shared binding
  const sharedIndex = ref(-1)
</script>

@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
@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="select()"
            @keydown.up="moveUp"
          />
          <v-list
            v-model:navigation-index="listNavIndex"
            :items="visibleItems"
            class="py-0"
            density="compact"
            elevation="0"
            max-height="300"
            navigation-strategy="track"
            activatable
            item-props
            mandatory
          >
            <template #item="{ props }">
              <v-list-item
                v-bind="props"
                :append-icon="selection.includes(props.value) ? '$complete' : ''"
                @click="select(props.value)"
              >
                <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 select (v?: any) {
    if (v !== undefined) {
      selection.value = [v]
      open.value = false
      return
    }
    if (!visibleItems.value.length) return
    const index = Math.max(0, listNavIndex.value ?? 0)
    if (index < visibleItems.value.length) {
      selection.value = [visibleItems.value[index].value]
      open.value = false
    }
  }

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

@MatthewAry MatthewAry requested a review from J-Sek November 18, 2025 18:44
@J-Sek J-Sek force-pushed the matthewary/feat-VList-navigation-modes branch 2 times, most recently from e9e80ae to 97e8682 Compare November 18, 2025 19:51
@J-Sek
Copy link
Contributor

J-Sek commented Nov 18, 2025

I can [tab] and have 2 items selected. How do we avoid it? With inert (feels heavy handed) or just by instructing VListItems to set tabindex="-1"?

J-Sek
J-Sek previously approved these changes Nov 19, 2025
@MatthewAry MatthewAry changed the base branch from master to dev November 19, 2025 21:01
@MatthewAry MatthewAry removed the S: has merge conflicts The pending Pull Request has merge conflicts label Nov 20, 2025
@J-Sek
Copy link
Contributor

J-Sek commented Nov 20, 2025

Something broke the demo with #item slot - maybe an accident during the last merge index is not passed to itemProps in VListChildren.tsx

- index not passed to item slot in VListChildren.tsx
- Non-unique IDs across multiple lists
@MatthewAry MatthewAry requested a review from KaelWD November 25, 2025 14:23
@J-Sek
Copy link
Contributor

J-Sek commented Nov 25, 2025

Minor defect, would be great if we could iron out:

Steps:

  • go to test 2, or any demo with a search field
  • navigate to the last item
  • type something that excludes that item
  • clear input with backspace
  • manually scroll to the last item (with mouse wheel)

Problem: it is still selected, but was not visible. If a user would click "enter" they would apply a selection that they don't see.

Suggestion:

  • reset navigation index to -1 when items change
  • or scroll back down to the active element

The first one is probably something that would work in general, but some may require some extra thought - can we support navigation in the list that loads items progressively (i.e. only appends new items)?

@MatthewAry
Copy link
Contributor Author

@J-Sek LMK if you think the most recent change addresses your feedback!

@J-Sek
Copy link
Contributor

J-Sek commented Nov 25, 2025

@MatthewAry it solves the problem 👍🏼

The only nitpick would be that it might feel a bit inconsistent to keep index while typing. VCombobox, just drops the selection and scroll to the top of the list. Is it intentional or some kind of a side-effect?

@MatthewAry
Copy link
Contributor Author

MatthewAry commented Nov 25, 2025

@J-Sek Not intentional, rather, it's an oversight. I think VCombobox's behavior makes sense.

Copy link
Contributor

@J-Sek J-Sek left a comment

Choose a reason for hiding this comment

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

One last suggestion for naming and it is 🟢 from me.

@MatthewAry MatthewAry requested a review from J-Sek December 1, 2025 14:30
@J-Sek J-Sek changed the title feat: Add VList "Strategy" Mode with Index Tracking feat(VList): add navigation-strategy to control focused item Dec 1, 2025
@MatthewAry MatthewAry dismissed stale reviews from KaelWD and johnleider December 2, 2025 14:19

Cause it's gone through enough

@MatthewAry MatthewAry merged commit 3815eee into dev Dec 2, 2025
18 checks passed
@MatthewAry MatthewAry deleted the matthewary/feat-VList-navigation-modes branch December 2, 2025 14:20
@KaelWD KaelWD added this to the v3.12.0 milestone Dec 5, 2025
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.

5 participants