Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6b7db0f
feat: add list-header slot
Haviles04 Dec 6, 2025
55d9bae
Merge branch 'master' into haviles04/v-select-header-slot
Haviles04 Dec 6, 2025
477bbb7
docs: update slots
Haviles04 Dec 6, 2025
98213e5
fix: update slot location
Haviles04 Dec 6, 2025
bde3302
chore: remove unused class
Haviles04 Dec 6, 2025
5b61bf2
Merge branch 'master' into haviles04/v-select-header-slot
Haviles04 Dec 6, 2025
c03f30d
feat: wrap in v-sheet and fix blur
Haviles04 Dec 8, 2025
ec61d80
Merge branch 'master' into haviles04/v-select-header-slot
Haviles04 Dec 8, 2025
7194ba1
Merge branch 'master' into haviles04/v-select-header-slot
Haviles04 Dec 8, 2025
38b6074
chore: trailling line
Haviles04 Dec 8, 2025
45a2621
chore: space
Haviles04 Dec 8, 2025
cbfb06f
formatting cleanup + footer
J-Sek Dec 8, 2025
5a033db
correct place for styles
J-Sek Dec 8, 2025
062e45e
restore indentation
J-Sek Dec 8, 2025
ee89a00
update slots descriptions
J-Sek Dec 8, 2025
f38d5da
avoid closing menu on blur
J-Sek Dec 8, 2025
38c8d07
improve tabbing within menu
J-Sek Dec 8, 2025
cd9ed86
Merge branch 'master' into haviles04/v-select-header-slot
Haviles04 Dec 11, 2025
b9ef284
fix: aria label
Haviles04 Dec 11, 2025
9db48f8
feat: keydown handlers and better focus
Haviles04 Dec 11, 2025
eafd8f7
Update packages/vuetify/src/components/VSelect/VSelect.tsx
Haviles04 Dec 11, 2025
7ff72fe
skip focus for static content in slot + correct target
J-Sek Dec 11, 2025
6abd9be
missing immport
J-Sek Dec 11, 2025
1a8b5df
Merge branch 'master' into haviles04/v-select-header-slot
Haviles04 Dec 11, 2025
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: 3 additions & 1 deletion packages/api-generator/src/locale/en/VSelect.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"tags": "Tagging functionality, allows the user to create new values not available from the **items** prop."
},
"slots": {
"item": "Define a custom item appearance. The root element of this slot must be a **v-list-item** with `v-bind=\"props\"` applied. `props` includes everything required for the default select list behaviour - including title, value, click handlers, virtual scrolling, and anything else that has been added with [`item-props`](api/v-select/#props-item-props)."
"item": "Define a custom item appearance. The root element of this slot must be a **v-list-item** with `v-bind=\"props\"` applied. `props` includes everything required for the default select list behaviour - including title, value, click handlers, virtual scrolling, and anything else that has been added with [`item-props`](api/v-select/#props-item-props).",
"list-header": "Define a custom sticky header placed inside the menu.",
"list-footer": "Define a custom sticky footer placed inside the menu."
}
}
4 changes: 4 additions & 0 deletions packages/vuetify/src/components/VSelect/VSelect.sass
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
@at-root #{selector.append('.v-menu > .v-overlay__content', &)}
@include tools.rounded($select-content-border-radius)

> .v-sheet
display: flex
flex-direction: column

&__selection
display: inline-flex
align-items: center
Expand Down
225 changes: 140 additions & 85 deletions packages/vuetify/src/components/VSelect/VSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { VDivider } from '@/components/VDivider'
import { VIcon } from '@/components/VIcon'
import { VList, VListItem, VListSubheader } from '@/components/VList'
import { VMenu } from '@/components/VMenu'
import { VSheet } from '@/components/VSheet'
import { makeVTextFieldProps, VTextField } from '@/components/VTextField/VTextField'
import { VVirtualScroll } from '@/components/VVirtualScroll'

Expand All @@ -33,6 +34,7 @@ import {
checkPrintable,
deepEqual,
ensureValidVNode,
focusableChildren,
genericComponent,
IN_BROWSER,
matchesSelector,
Expand Down Expand Up @@ -127,6 +129,8 @@ export const VSelect = genericComponent<new <
'prepend-item': never
'append-item': never
'no-data': never
'list-header': never
'list-footer': never
}
) => GenericProps<typeof props, typeof slots>>()({
name: 'VSelect',
Expand All @@ -143,6 +147,8 @@ export const VSelect = genericComponent<new <
const { t } = useLocale()
const vTextFieldRef = ref<VTextField>()
const vMenuRef = ref<VMenu>()
const headerRef = ref<HTMLElement>()
const footerRef = ref<HTMLElement>()
const vVirtualScrollRef = ref<VVirtualScroll>()
const { items, transformIn, transformOut } = useItems(props)
const model = useProxiedModel(
Expand Down Expand Up @@ -217,10 +223,47 @@ export const VSelect = genericComponent<new <
menu.value = !menu.value
}
function onListKeydown (e: KeyboardEvent) {
if (e.key === 'Tab' && !e.shiftKey && footerRef.value) {
const firstFocusableInFooter = focusableChildren(footerRef.value).at(0)
if (firstFocusableInFooter) {
e.preventDefault()
e.stopImmediatePropagation()
return firstFocusableInFooter.focus()
}
}

if (e.key === 'Tab' && e.shiftKey && headerRef.value) {
const firstFocusableInHeader = focusableChildren(headerRef.value).at(0)
if (firstFocusableInHeader) {
e.preventDefault()
e.stopImmediatePropagation()
return firstFocusableInHeader.focus()
}
}

if (checkPrintable(e)) {
onKeydown(e)
}
}

function onHeaderKeydown (e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault()
return listRef.value?.focus('first')
}
}

function onFooterKeydown (e: KeyboardEvent) {
if (e.key === 'ArrowUp') {
e.preventDefault()
return listRef.value?.focus('last')
}
if (e.key === 'Tab' && !e.shiftKey) {
menu.value = false
vTextFieldRef.value?.focus()
}
}

function onKeydown (e: KeyboardEvent) {
if (!e.key || form.isReadonly.value) return

Expand Down Expand Up @@ -323,7 +366,8 @@ export const VSelect = genericComponent<new <
}
}
function onBlur (e: FocusEvent) {
if (!listRef.value?.$el.contains(e.relatedTarget as HTMLElement)) {
const target = e.target as Element
if (!vTextFieldRef.value?.$el.contains(target)) {
menu.value = false
}
}
Expand Down Expand Up @@ -454,91 +498,102 @@ export const VSelect = genericComponent<new <
onAfterLeave={ onAfterLeave }
{ ...computedMenuProps.value }
>
{ hasList && (
<VList
ref={ listRef }
selected={ selectedValues.value }
selectStrategy={ props.multiple ? 'independent' : 'single-independent' }
onMousedown={ (e: MouseEvent) => e.preventDefault() }
onKeydown={ onListKeydown }
onFocusin={ onFocusin }
tabindex="-1"
selectable
aria-live="polite"
aria-labelledby={ `${id.value}-label` }
aria-multiselectable={ props.multiple }
color={ props.itemColor ?? props.color }
{ ...listEvents }
{ ...props.listProps }
>
{ slots['prepend-item']?.() }

{ !displayItems.value.length && !props.hideNoData && (slots['no-data']?.() ?? (
<VListItem key="no-data" title={ t(props.noDataText) } />
))}

<VVirtualScroll ref={ vVirtualScrollRef } renderless items={ displayItems.value } itemKey="value">
{ ({ item, index, itemRef }) => {
const camelizedProps = camelizeProps(item.props)

const itemProps = mergeProps(item.props, {
ref: itemRef,
key: item.value,
onClick: () => select(item, null),
'aria-posinset': index + 1,
'aria-setsize': displayItems.value.length,
})

if (item.type === 'divider') {
return slots.divider?.({ props: item.raw, index }) ?? (
<VDivider { ...item.props } key={ `divider-${index}` } />
)
}

if (item.type === 'subheader') {
return slots.subheader?.({ props: item.raw, index }) ?? (
<VListSubheader { ...item.props } key={ `subheader-${index}` } />
<VSheet onFocusin={ onFocusin }>
{ slots['list-header'] && (
<header onKeydown={ onHeaderKeydown } ref={ headerRef } tabindex="-1">
{ slots['list-header']() }
</header>
)}

{ hasList && (
<VList
key="select-list"
ref={ listRef }
selected={ selectedValues.value }
selectStrategy={ props.multiple ? 'independent' : 'single-independent' }
onKeydown={ onListKeydown }
tabindex="-1"
selectable
aria-live="polite"
aria-labelledby={ `${id.value}-label` }
aria-multiselectable={ props.multiple }
color={ props.itemColor ?? props.color }
{ ...listEvents }
{ ...props.listProps }
>
{ slots['prepend-item']?.() }

{ !displayItems.value.length && !props.hideNoData && (slots['no-data']?.() ?? (
<VListItem key="no-data" title={ t(props.noDataText) } />
))}

<VVirtualScroll ref={ vVirtualScrollRef } renderless items={ displayItems.value } itemKey="value">
{ ({ item, index, itemRef }) => {
const camelizedProps = camelizeProps(item.props)

const itemProps = mergeProps(item.props, {
ref: itemRef,
key: item.value,
onClick: () => select(item, null),
})

if (item.type === 'divider') {
return slots.divider?.({ props: item.raw, index }) ?? (
<VDivider { ...item.props } key={ `divider-${index}` } />
)
}

if (item.type === 'subheader') {
return slots.subheader?.({ props: item.raw, index }) ?? (
<VListSubheader { ...item.props } key={ `subheader-${index}` } />
)
}

return slots.item?.({
item,
index,
props: itemProps,
}) ?? (
<VListItem { ...itemProps } role="option">
{{
prepend: ({ isSelected }) => (
<>
{ props.multiple && !props.hideSelected ? (
<VCheckboxBtn
key={ item.value }
modelValue={ isSelected }
ripple={ false }
tabindex="-1"
aria-hidden
onClick={ (event: MouseEvent) => event.preventDefault() }
/>
) : undefined }

{ camelizedProps.prependAvatar && (
<VAvatar image={ camelizedProps.prependAvatar } />
)}

{ camelizedProps.prependIcon && (
<VIcon icon={ camelizedProps.prependIcon } />
)}
</>
),
}}
</VListItem>
)
}

return slots.item?.({
item,
index,
props: itemProps,
}) ?? (
<VListItem { ...itemProps } role="option">
{{
prepend: ({ isSelected }) => (
<>
{ props.multiple && !props.hideSelected ? (
<VCheckboxBtn
key={ item.value }
modelValue={ isSelected }
ripple={ false }
tabindex="-1"
aria-hidden
onClick={ (event: MouseEvent) => event.preventDefault() }
/>
) : undefined }

{ camelizedProps.prependAvatar && (
<VAvatar image={ camelizedProps.prependAvatar } />
)}

{ camelizedProps.prependIcon && (
<VIcon icon={ camelizedProps.prependIcon } />
)}
</>
),
}}
</VListItem>
)
}}
</VVirtualScroll>

{ slots['append-item']?.() }
</VList>
)}
}}
</VVirtualScroll>

{ slots['append-item']?.() }
</VList>
)}

{ slots['list-footer'] && (
<footer ref={ footerRef } onKeydown={ onFooterKeydown } tabindex="-1">
{ slots['list-footer']() }
</footer>
)}
</VSheet>
</VMenu>

{ model.value.map((item, index) => {
Expand Down
Loading