Skip to content

Commit 5a488b4

Browse files
authored
Merge branch 'dev' into haviles04/date-picker-readonly
2 parents 452d31a + aca7d30 commit 5a488b4

File tree

17 files changed

+312
-33
lines changed

17 files changed

+312
-33
lines changed

packages/api-generator/src/locale/en/VList.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
"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.",
1111
"link": "Applies `v-list-item` hover styles. Useful when using the item is an _activator_.",
1212
"nav": "An alternative styling that reduces `v-list-item` width and rounds the corners. Typically used with **[v-navigation-drawer](/components/navigation-drawers)**.",
13+
"navigationIndex": "Specifies the currently selected navigation index when using `navigationStrategy=\"track\"`. Can be used with `v-model:navigationIndex` for two-way binding. Items at this index receive visual keyboard focus styling and automatic scrolling. Note: Only works with the `items` prop, not with slotted items.",
14+
"navigationStrategy": "Determines keyboard navigation behavior. **focus** (default) moves DOM focus to items, suitable for traditional lists. **track** provides visual keyboard focus without moving DOM focus, ideal for command palettes and autocomplete where an external element retains focus. When track mode is active, items automatically receive `tabindex=\"-1\"`, proper `aria-activedescendant` is set on the list container, and keyboard-focused items display focus-visible styling with auto-scrolling.",
1315
"subheader": "Removes the top padding from `v-list-subheader` components. When used as a **String**, renders a subheader for you.",
1416
"slim": "Reduces horizontal spacing for badges, icons, tooltips, and avatars within slim list items to create a more compact visual representation.",
1517
"prependGap": "Sets the horizontal spacing between prepend slot and the main content within list item. Also affects indent to ensure expected alignment of group children.",
@@ -23,6 +25,7 @@
2325
"click:open": "Emitted when the list item is opened.",
2426
"click:select": "Emitted when the list item is selected.",
2527
"update:activated": "Emitted when the list item is activated.",
28+
"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.",
2629
"update:opened": "Emitted when the list item is opened.",
2730
"update:selected": "Emitted when the list item is selected."
2831
},
@@ -39,6 +42,7 @@
3942
"children": "The nested list items within the component.",
4043
"focus": "Focus the list item.",
4144
"getPath": "Get the position of an item within the nested structure.",
45+
"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\"`.",
4246
"open": "Open the list item.",
4347
"parents": "The parent list items within the component."
4448
}

packages/api-generator/src/locale/en/VListItem.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"value": "The value used for selection. Obtained from [`v-list`](/api/v-list)'s `v-model:selected` when the item is selected.",
99
"lines": "The line declaration specifies the minimum height of the item and can also be controlled from v-list with the same prop.",
1010
"nav": "Reduces the width v-list-item takes up as well as adding a border radius.",
11-
"slim": "Reduces horizontal spacing for badges, icons, tooltips, and avatars to create a more compact visual representation."
11+
"slim": "Reduces horizontal spacing for badges, icons, tooltips, and avatars to create a more compact visual representation.",
12+
"tabindex": "Controls the tabindex of the list item. When set, overrides the default tabindex behavior. Automatically set to -1 by VList when using `navigationStrategy=\"track\"` to prevent Tab key navigation into items."
1213
},
1314
"exposed": {
1415
"activate": "Activate the list item.",

packages/api-generator/src/locale/en/nested.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@
55
"opened": "An array containing the values of currently opened groups. Can be two-way bound with `v-model:opened`.",
66
"openStrategy": "Affects how items with children behave when expanded.\n- **multiple:** Any number of groups can be open at once.\n- **single:** Only one group at each level can be open, opening a group will cause others to close.\n- **list:** Multiple, but all other groups will close when an item is selected.",
77
"selected": "An array containing the values of currently selected items. Can be two-way bound with `v-model:selected`.",
8-
"selectStrategy": "Affects how items with children behave when selected.\n- **leaf:** Only leaf nodes (items without children) can be selected.\n- **independent:** All nodes can be selected whether they have children or not.\n- **classic:** Selecting a parent node will cause all children to be selected, parent nodes will be displayed as selected if all their descendants are selected. Only leaf nodes will be added to the model.\n- **trunk**: Same as classic but if all of a node's children are selected then only that node will be added to the model."
8+
"selectStrategy": "Affects how items with children behave when selected.\n- **leaf:** Only leaf nodes (items without children) can be selected.\n- **independent:** All nodes can be selected whether they have children or not.\n- **classic:** Selecting a parent node will cause all children to be selected, parent nodes will be displayed as selected if all their descendants are selected. Only leaf nodes will be added to the model.\n- **trunk**: Same as classic but if all of a node's children are selected then only that node will be added to the model.\n- **branch**: Same as classic but if any of a node's children are selected then that node will also be added to the model."
99
}
1010
}

packages/docs/src/data/new-in.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,11 @@
239239
"autocomplete": "3.10.0"
240240
}
241241
},
242+
"VOtpInput": {
243+
"props": {
244+
"density": "3.12.0"
245+
}
246+
},
242247
"VOverlay": {
243248
"props": {
244249
"captureFocus": "3.11.0",

packages/docs/src/examples/v-treeview/prop-selection-type.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<v-container fluid>
44
<v-select
55
v-model="strategy"
6-
:items="['leaf', 'single-leaf', 'independent', 'single-independent', 'classic']"
6+
:items="['leaf', 'single-leaf', 'independent', 'single-independent', 'classic', 'trunk', 'branch']"
77
label="Selection type"
88
></v-select>
99

packages/docs/src/pages/en/components/treeview.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,16 @@ You can control the color of the selected node checkbox.
112112

113113
#### Selection type
114114

115-
Treeview now supports two different selection types. The default type is **'leaf'**, which will only include leaf nodes in the v-model array, but will render parent nodes as either partially or fully selected. The alternative mode is **'independent'**, which allows one to select parent nodes, but each node is independent of its parent and children.
115+
Treeview supports several selection modes:
116+
117+
- **leaf** (default): Limits selection to items without children.
118+
- **independent**: Lets you select any node, with no parent-child linkage at all.
119+
- **classic**: Selecting a parent selects all descendants, and parent nodes show as selected only when all their descendants are selected. Only leaf nodes are added to the model.
120+
121+
Classic has two variants that are displayed the same way but with slightly different v-model behavior:
122+
123+
- **branch**: Any parent node with at least one selected descendant is also added to the model.
124+
- **trunk**: If all children are selected only the parent node is added to the model.
116125

117126
<ExamplesExample file="v-treeview/prop-selection-type" />
118127

packages/vuetify/src/components/VList/VList.tsx

Lines changed: 109 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@ import { makeElevationProps, useElevation } from '@/composables/elevation'
1616
import { IconValue } from '@/composables/icons'
1717
import { makeItemsProps } from '@/composables/list-items'
1818
import { makeNestedProps, useNested } from '@/composables/nested/nested'
19+
import { useProxiedModel } from '@/composables/proxiedModel'
1920
import { makeRoundedProps, useRounded } from '@/composables/rounded'
2021
import { makeTagProps } from '@/composables/tag'
2122
import { makeThemeProps, provideTheme } from '@/composables/theme'
2223
import { makeVariantProps } from '@/composables/variant'
2324

2425
// Utilities
25-
import { computed, ref, shallowRef, toRef } from 'vue'
26+
import { computed, ref, shallowRef, toRef, useId, watch } from 'vue'
2627
import {
2728
convertToUnit,
2829
EventProp,
@@ -108,6 +109,11 @@ export const makeVListProps = propsFactory({
108109
prependGap: [Number, String],
109110
indent: [Number, String],
110111
nav: Boolean,
112+
navigationStrategy: {
113+
type: String as PropType<'focus' | 'track'>,
114+
default: 'focus',
115+
},
116+
navigationIndex: Number,
111117

112118
'onClick:open': EventProp<[{ id: unknown, value: boolean, path: unknown[] }]>(),
113119
'onClick:select': EventProp<[{ id: unknown, value: boolean, path: unknown[] }]>(),
@@ -158,12 +164,13 @@ export const VList = genericComponent<new <
158164
'update:selected': (value: unknown) => true,
159165
'update:activated': (value: unknown) => true,
160166
'update:opened': (value: unknown) => true,
167+
'update:navigationIndex': (value: number) => true,
161168
'click:open': (value: { id: unknown, value: boolean, path: unknown[] }) => true,
162169
'click:activate': (value: { id: unknown, value: boolean, path: unknown[] }) => true,
163170
'click:select': (value: { id: unknown, value: boolean, path: unknown[] }) => true,
164171
},
165172

166-
setup (props, { slots }) {
173+
setup (props, { slots, emit }) {
167174
const { items } = useListItems(props)
168175
const { themeClasses } = provideTheme(props)
169176
const { backgroundColorClasses, backgroundColorStyles } = useBackgroundColor(() => props.bgColor)
@@ -172,16 +179,39 @@ export const VList = genericComponent<new <
172179
const { dimensionStyles } = useDimension(props)
173180
const { elevationClasses } = useElevation(props)
174181
const { roundedClasses } = useRounded(props)
175-
const { children, open, parents, select, getPath } = useNested(props, items, () => props.returnObject)
182+
183+
const { children, open, parents, select, getPath } = useNested(props, {
184+
items,
185+
returnObject: toRef(() => props.returnObject),
186+
scrollToActive: toRef(() => props.navigationStrategy === 'track'),
187+
})
176188

177189
const lineClasses = toRef(() => props.lines ? `v-list--${props.lines}-line` : undefined)
178190
const activeColor = toRef(() => props.activeColor)
179191
const baseColor = toRef(() => props.baseColor)
180192
const color = toRef(() => props.color)
181193
const isSelectable = toRef(() => (props.selectable || props.activatable))
182194

195+
const navigationIndex = useProxiedModel(
196+
props,
197+
'navigationIndex',
198+
-1,
199+
v => v ?? -1
200+
)
201+
202+
const uid = useId()
203+
183204
createList({
184205
filterable: props.filterable,
206+
trackingIndex: navigationIndex,
207+
navigationStrategy: toRef(() => props.navigationStrategy),
208+
uid,
209+
})
210+
211+
watch(items, () => {
212+
if (props.navigationStrategy === 'track') {
213+
navigationIndex.value = -1
214+
}
185215
})
186216

187217
provideDefaults({
@@ -203,11 +233,13 @@ export const VList = genericComponent<new <
203233
nav: toRef(() => props.nav),
204234
slim: toRef(() => props.slim),
205235
variant: toRef(() => props.variant),
236+
tabindex: toRef(() => props.navigationStrategy === 'track' ? -1 : undefined),
206237
},
207238
})
208239

209240
const isFocused = shallowRef(false)
210241
const contentRef = ref<HTMLElement>()
242+
211243
function onFocusin (e: FocusEvent) {
212244
isFocused.value = true
213245
}
@@ -217,12 +249,66 @@ export const VList = genericComponent<new <
217249
}
218250

219251
function onFocus (e: FocusEvent) {
220-
if (
252+
if (props.navigationStrategy === 'track') {
253+
if (!~navigationIndex.value) {
254+
navigationIndex.value = getNextIndex('first')
255+
}
256+
} else if (
221257
!isFocused.value &&
222258
!(e.relatedTarget && contentRef.value?.contains(e.relatedTarget as Node))
223259
) focus()
224260
}
225261

262+
function onBlur () {
263+
if (props.navigationStrategy === 'track') {
264+
navigationIndex.value = -1
265+
}
266+
}
267+
268+
function getNavigationDirection (key: string): 'next' | 'prev' | 'first' | 'last' | null {
269+
switch (key) {
270+
case 'ArrowDown': return 'next'
271+
case 'ArrowUp': return 'prev'
272+
case 'Home': return 'first'
273+
case 'End': return 'last'
274+
default: return null
275+
}
276+
}
277+
278+
function getNextIndex (direction: 'next' | 'prev' | 'first' | 'last'): number {
279+
const itemCount = items.value.length
280+
if (itemCount === 0) return -1
281+
282+
let nextIndex: number
283+
284+
if (direction === 'first') {
285+
nextIndex = 0
286+
} else if (direction === 'last') {
287+
nextIndex = itemCount - 1
288+
} else {
289+
nextIndex = navigationIndex.value + (direction === 'next' ? 1 : -1)
290+
291+
if (nextIndex < 0) nextIndex = itemCount - 1
292+
if (nextIndex >= itemCount) nextIndex = 0
293+
}
294+
295+
const startIndex = nextIndex
296+
let attempts = 0
297+
while (attempts < itemCount) {
298+
const item = items.value[nextIndex]
299+
if (item && item.type !== 'divider' && item.type !== 'subheader') {
300+
return nextIndex
301+
}
302+
nextIndex += direction === 'next' || direction === 'first' ? 1 : -1
303+
if (nextIndex < 0) nextIndex = itemCount - 1
304+
if (nextIndex >= itemCount) nextIndex = 0
305+
if (nextIndex === startIndex) return -1
306+
attempts++
307+
}
308+
309+
return -1
310+
}
311+
226312
function onKeydown (e: KeyboardEvent) {
227313
const target = e.target as HTMLElement
228314

@@ -232,19 +318,19 @@ export const VList = genericComponent<new <
232318
return
233319
}
234320

235-
if (e.key === 'ArrowDown') {
236-
focus('next')
237-
} else if (e.key === 'ArrowUp') {
238-
focus('prev')
239-
} else if (e.key === 'Home') {
240-
focus('first')
241-
} else if (e.key === 'End') {
242-
focus('last')
243-
} else {
244-
return
321+
const direction = getNavigationDirection(e.key)
322+
323+
if (direction !== null) {
324+
e.preventDefault()
325+
if (props.navigationStrategy === 'track') {
326+
const nextIndex = getNextIndex(direction)
327+
if (nextIndex !== -1) {
328+
navigationIndex.value = nextIndex
329+
}
330+
} else {
331+
focus(direction)
332+
}
245333
}
246-
247-
e.preventDefault()
248334
}
249335

250336
function onMousedown (e: MouseEvent) {
@@ -294,10 +380,15 @@ export const VList = genericComponent<new <
294380
]}
295381
tabindex={ props.disabled ? -1 : 0 }
296382
role={ isSelectable.value ? 'listbox' : 'list' }
297-
aria-activedescendant={ undefined }
383+
aria-activedescendant={
384+
props.navigationStrategy === 'track' && navigationIndex.value >= 0
385+
? `v-list-item-${uid}-${navigationIndex.value}`
386+
: undefined
387+
}
298388
onFocusin={ onFocusin }
299389
onFocusout={ onFocusout }
300390
onFocus={ onFocus }
391+
onBlur={ onBlur }
301392
onKeydown={ onKeydown }
302393
onMousedown={ onMousedown }
303394
>
@@ -317,6 +408,7 @@ export const VList = genericComponent<new <
317408
children,
318409
parents,
319410
getPath,
411+
navigationIndex,
320412
}
321413
},
322414
})

packages/vuetify/src/components/VList/VListChildren.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export type VListChildrenSlots<T> = {
1919
[K in keyof Omit<VListItemSlots, 'default'>]: VListItemSlots[K] & { item: T }
2020
} & {
2121
default: never
22-
item: { props: InternalListItem['props'] }
22+
item: { props: InternalListItem['props'] & { index: number } }
2323
divider: { props: InternalListItem['props'] }
2424
subheader: { props: InternalListItem['props'] }
2525
header: { props: InternalListItem['props'] }
@@ -44,7 +44,7 @@ export const VListChildren = genericComponent<new <T extends InternalListItem>(
4444
setup (props, { slots }) {
4545
createList()
4646

47-
return () => slots.default?.() ?? props.items?.map(({ children, props: itemProps, type, raw: item }) => {
47+
return () => slots.default?.() ?? props.items?.map(({ children, props: itemProps, type, raw: item }, index) => {
4848
if (type === 'divider') {
4949
return slots.divider?.({ props: itemProps }) ?? (
5050
<VDivider { ...itemProps } />
@@ -83,7 +83,7 @@ export const VListChildren = genericComponent<new <T extends InternalListItem>(
8383
return slots.header
8484
? slots.header({ props: listItemProps })
8585
: (
86-
<VListItem { ...listItemProps } v-slots={ slotsWithItem } />
86+
<VListItem { ...listItemProps } index={ index } v-slots={ slotsWithItem } />
8787
)
8888
},
8989
default: () => (
@@ -96,9 +96,10 @@ export const VListChildren = genericComponent<new <T extends InternalListItem>(
9696
}}
9797
</VListGroup>
9898
) : (
99-
slots.item ? slots.item({ props: itemProps }) : (
99+
slots.item ? slots.item({ props: { ...itemProps, index } }) : (
100100
<VListItem
101101
{ ...itemProps }
102+
index={ index }
102103
value={ props.returnObject ? item : itemProps.value }
103104
v-slots={ slotsWithItem }
104105
/>

packages/vuetify/src/components/VList/VListItem.sass

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
@include tools.rounded($list-item-border-radius)
2424
@include tools.variant($list-item-variants...)
2525

26+
&--focus-visible
27+
.v-list-item__overlay
28+
opacity: calc(#{map.get(settings.$states, 'focus')} * var(--v-theme-overlay-multiplier))
29+
2630
@supports selector(:focus-visible)
2731
&::after
2832
pointer-events: none
@@ -32,7 +36,8 @@
3236
transition: opacity .2s ease-in-out
3337
@include tools.absolute(true)
3438

35-
&:focus-visible::after
39+
&:focus-visible::after,
40+
&--focus-visible::after
3641
opacity: calc(.15 * var(--v-theme-overlay-multiplier))
3742

3843
&__prepend,
@@ -359,6 +364,9 @@
359364
color: highlighttext !important
360365
forced-color-adjust: preserve-parent-color
361366

367+
&--focus-visible::after
368+
opacity: 1
369+
362370
@supports selector(:focus-visible)
363371
&::after
364372
color: buttontext

0 commit comments

Comments
 (0)