Skip to content
Open
Show file tree
Hide file tree
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
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
99 changes: 88 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,13 @@ export const VList = genericComponent<new <
const color = toRef(() => props.color)
const isSelectable = toRef(() => (props.selectable || props.activatable))

const navigationIndex = useProxiedModel(
props,
'navigationIndex',
-1,
v => v ?? -1
)

createList({
filterable: props.filterable,
})
Expand Down Expand Up @@ -219,6 +233,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 = navigationIndex.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 +276,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) {
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()
}
}
Comment on lines +279 to 320
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
}
}


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