Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions packages/api-generator/src/locale/en/VList.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
"lines": "Designates a **minimum-height** for all children `v-list-item` components. This prop uses [line-clamp](https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-line-clamp) and is not supported in all browsers.",
"link": "Applies `v-list-item` hover styles. Useful when using the item is an _activator_.",
"nav": "An alternative styling that reduces `v-list-item` width and rounds the corners. Typically used with **[v-navigation-drawer](/components/navigation-drawers)**.",
"navigationIndex": "Specifies the currently selected navigation index when using `navigationStrategy=\"track\"`. Can be used with `v-model:navigationIndex` for two-way binding. When set, overrides the internal navigation state.",
"navigationStrategy": "Determines keyboard navigation behavior. **focus** (default) moves DOM focus to items, suitable for traditional lists. **track** updates an index without moving focus, ideal for command palettes and autocomplete components where an external element retains focus.",
"subheader": "Removes the top padding from `v-list-subheader` components. When used as a **String**, renders a subheader for you.",
"slim": "Reduces horizontal spacing for badges, icons, tooltips, and avatars within slim list items to create a more compact visual representation.",
"collapseIcon": "Icon to display when the list item is expanded.",
Expand All @@ -20,6 +22,7 @@
"click:open": "Emitted when the list item is opened.",
"click:select": "Emitted when the list item is selected.",
"update:activated": "Emitted when the list item is activated.",
"update:navigationIndex": "Emitted when keyboard navigation occurs in `navigationStrategy=\"track\"`. The event payload is the new index of the selected item. Automatically skips non-selectable items like dividers and subheaders.",
"update:opened": "Emitted when the list item is opened.",
"update:selected": "Emitted when the list item is selected."
},
Expand All @@ -36,6 +39,7 @@
"children": "The nested list items within the component.",
"focus": "Focus the list item.",
"getPath": "Get the position of an item within the nested structure.",
"navigationIndex": "A computed ref that returns the current navigation index when using `navigationStrategy=\"track\"`. Returns -1 when no item is selected or when using `navigationStrategy=\"focus\"`.",
"open": "Open the list item.",
"parents": "The parent list items within the component."
}
Expand Down
100 changes: 89 additions & 11 deletions packages/vuetify/src/components/VList/VList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { makeElevationProps, useElevation } from '@/composables/elevation'
import { IconValue } from '@/composables/icons'
import { makeItemsProps } from '@/composables/list-items'
import { makeNestedProps, useNested } from '@/composables/nested/nested'
import { useProxiedModel } from '@/composables/proxiedModel'
import { makeRoundedProps, useRounded } from '@/composables/rounded'
import { makeTagProps } from '@/composables/tag'
import { makeThemeProps, provideTheme } from '@/composables/theme'
Expand Down Expand Up @@ -105,6 +106,11 @@ export const makeVListProps = propsFactory({
},
slim: Boolean,
nav: Boolean,
navigationStrategy: {
type: String as PropType<'focus' | 'track'>,
default: 'focus',
},
navigationIndex: Number,

'onClick:open': EventProp<[{ id: unknown, value: boolean, path: unknown[] }]>(),
'onClick:select': EventProp<[{ id: unknown, value: boolean, path: unknown[] }]>(),
Expand Down Expand Up @@ -155,12 +161,13 @@ export const VList = genericComponent<new <
'update:selected': (value: unknown) => true,
'update:activated': (value: unknown) => true,
'update:opened': (value: unknown) => true,
'update:navigationIndex': (value: number) => true,
'click:open': (value: { id: unknown, value: boolean, path: unknown[] }) => true,
'click:activate': (value: { id: unknown, value: boolean, path: unknown[] }) => true,
'click:select': (value: { id: unknown, value: boolean, path: unknown[] }) => true,
},

setup (props, { slots }) {
setup (props, { slots, emit }) {
const { items } = useListItems(props)
const { themeClasses } = provideTheme(props)
const { backgroundColorClasses, backgroundColorStyles } = useBackgroundColor(() => props.bgColor)
Expand All @@ -176,6 +183,14 @@ export const VList = genericComponent<new <
const color = toRef(() => props.color)
const isSelectable = toRef(() => (props.selectable || props.activatable))

// Track strategy navigation index
const currentNavIndex = useProxiedModel(
props,
'navigationIndex',
-1,
v => v ?? -1
)

createList({
filterable: props.filterable,
})
Expand Down Expand Up @@ -219,6 +234,40 @@ export const VList = genericComponent<new <
) focus()
}

function getNextIndex (direction: 'next' | 'prev' | 'first' | 'last'): number {
const itemCount = items.value.length
if (itemCount === 0) return -1

let nextIndex: number

if (direction === 'first') {
nextIndex = 0
} else if (direction === 'last') {
nextIndex = itemCount - 1
} else {
nextIndex = currentNavIndex.value + (direction === 'next' ? 1 : -1)

if (nextIndex < 0) nextIndex = itemCount - 1
if (nextIndex >= itemCount) nextIndex = 0
}

const startIndex = nextIndex
let attempts = 0
while (attempts < itemCount) {
const item = items.value[nextIndex]
if (item && item.type !== 'divider' && item.type !== 'subheader') {
return nextIndex
}
nextIndex += direction === 'next' || direction === 'last' ? 1 : -1
if (nextIndex < 0) nextIndex = itemCount - 1
if (nextIndex >= itemCount) nextIndex = 0
if (nextIndex === startIndex) return -1
attempts++
}

return -1
}

function onKeydown (e: KeyboardEvent) {
const target = e.target as HTMLElement

Expand All @@ -228,19 +277,47 @@ export const VList = genericComponent<new <
return
}

if (e.key === 'ArrowDown') {
focus('next')
} else if (e.key === 'ArrowUp') {
focus('prev')
} else if (e.key === 'Home') {
focus('first')
} else if (e.key === 'End') {
focus('last')
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) {
currentNavIndex.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()
}
}

function onMousedown (e: MouseEvent) {
Expand Down Expand Up @@ -303,6 +380,7 @@ export const VList = genericComponent<new <
children,
parents,
getPath,
navigationIndex: computed(() => currentNavIndex.value),
}
},
})
Expand Down