()({
const rangeStart = shallowRef()
const rangeStop = shallowRef()
+ if (props.multiple === 'range' && model.value.length > 0) {
+ rangeStart.value = model.value[0]
+ if (model.value.length > 1) {
+ rangeStop.value = model.value[model.value.length - 1]
+ }
+ }
+
const atMax = computed(() => {
const max = ['number', 'string'].includes(typeof props.multiple) ? Number(props.multiple) : Infinity
@@ -68,15 +75,15 @@ export const VDatePickerMonth = genericComponent
()({
rangeStart.value = _value
model.value = [rangeStart.value]
} else if (!rangeStop.value) {
- if (adapter.isSameDay(value, rangeStart.value)) {
+ if (adapter.isSameDay(_value, rangeStart.value)) {
rangeStart.value = undefined
model.value = []
return
- } else if (adapter.isBefore(value, rangeStart.value)) {
- rangeStop.value = rangeStart.value
+ } else if (adapter.isBefore(_value, rangeStart.value)) {
+ rangeStop.value = adapter.endOfDay(rangeStart.value)
rangeStart.value = _value
} else {
- rangeStop.value = _value
+ rangeStop.value = adapter.endOfDay(_value)
}
const diff = adapter.getDiff(rangeStop.value, rangeStart.value, 'days')
diff --git a/packages/vuetify/src/components/VDatePicker/__tests__/VDatePicker.spec.cy.tsx b/packages/vuetify/src/components/VDatePicker/__tests__/VDatePicker.spec.cy.tsx
index 7b68ddc3846..9d328f6133d 100644
--- a/packages/vuetify/src/components/VDatePicker/__tests__/VDatePicker.spec.cy.tsx
+++ b/packages/vuetify/src/components/VDatePicker/__tests__/VDatePicker.spec.cy.tsx
@@ -21,4 +21,23 @@ describe('VDatePicker', () => {
expect(model.value).to.have.length(11)
})
})
+
+ it('selects a range of dates across month boundary', () => {
+ const model = ref([])
+ cy.mount(() => (
+
+
+
+ ))
+
+ cy.get('.v-date-picker-controls__month-btn').click()
+ cy.get('.v-date-picker-months__content').contains('Jan').click()
+ cy.get('.v-date-picker-month__day').contains(7).click()
+ cy.get('.v-date-picker-controls__month-btn').click()
+ cy.get('.v-date-picker-months__content').contains('Feb').click()
+ cy.get('.v-date-picker-month__day').contains(7).click()
+ .then(() => {
+ expect(model.value).to.have.length(32)
+ })
+ })
})
diff --git a/packages/vuetify/src/components/VList/VList.tsx b/packages/vuetify/src/components/VList/VList.tsx
index a47a8112a17..aa36192caa6 100644
--- a/packages/vuetify/src/components/VList/VList.tsx
+++ b/packages/vuetify/src/components/VList/VList.tsx
@@ -73,7 +73,7 @@ function transformItems (props: ItemProps & { itemType: string }, items: (string
return array
}
-function useListItems (props: ItemProps & { itemType: string }) {
+export function useListItems (props: ItemProps & { itemType: string }) {
const items = computed(() => transformItems(props, props.items))
return { items }
@@ -141,8 +141,10 @@ export const VList = genericComponent true,
+ 'update:activated': (value: unknown[]) => true,
'update:opened': (value: unknown[]) => 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,
},
@@ -155,7 +157,7 @@ export const VList = genericComponent props.lines ? `v-list--${props.lines}-line` : undefined)
const activeColor = toRef(props, 'activeColor')
const baseColor = toRef(props, 'baseColor')
@@ -277,6 +279,8 @@ export const VList = genericComponent()({
))
- return {}
+ return {
+ isOpen,
+ }
},
})
diff --git a/packages/vuetify/src/components/VList/VListItem.tsx b/packages/vuetify/src/components/VList/VListItem.tsx
index 5ceed20f447..54cf2ab1fc5 100644
--- a/packages/vuetify/src/components/VList/VListItem.tsx
+++ b/packages/vuetify/src/components/VList/VListItem.tsx
@@ -115,17 +115,27 @@ export const VListItem = genericComponent()({
setup (props, { attrs, slots, emit }) {
const link = useLink(props, attrs)
const id = computed(() => props.value === undefined ? link.href.value : props.value)
- const { select, isSelected, isIndeterminate, isGroupActivator, root, parent, openOnSelect } = useNestedItem(id, false)
+ const {
+ activate,
+ isActivated,
+ select,
+ isSelected,
+ isIndeterminate,
+ isGroupActivator,
+ root,
+ parent,
+ openOnSelect,
+ } = useNestedItem(id, false)
const list = useList()
const isActive = computed(() =>
props.active !== false &&
- (props.active || link.isActive?.value || isSelected.value)
+ (props.active || link.isActive?.value || (root.activatable.value ? isActivated.value : isSelected.value))
)
const isLink = computed(() => props.link !== false && link.isLink.value)
const isClickable = computed(() =>
!props.disabled &&
props.link !== false &&
- (props.link || link.isClickable.value || (props.value != null && !!list))
+ (props.link || link.isClickable.value || (!!list && (root.selectable.value || root.activatable.value || props.value != null)))
)
const roundedProps = computed(() => props.rounded || props.nav)
@@ -167,7 +177,14 @@ export const VListItem = genericComponent()({
if (isGroupActivator || !isClickable.value) return
link.navigate?.(e)
- props.value != null && select(!isSelected.value, e)
+
+ if (root.activatable) {
+ activate(!isActivated.value, e)
+ } else if (root.selectable) {
+ select(!isSelected.value, e)
+ } else if (props.value != null) {
+ select(!isSelected.value, e)
+ }
}
function onKeyDown (e: KeyboardEvent) {
@@ -339,7 +356,12 @@ export const VListItem = genericComponent()({
)
})
- return {}
+ return {
+ isGroupActivator,
+ isSelected,
+ list,
+ select,
+ }
},
})
diff --git a/packages/vuetify/src/components/VMenu/VMenu.tsx b/packages/vuetify/src/components/VMenu/VMenu.tsx
index 08bebfa5458..74f643e2015 100644
--- a/packages/vuetify/src/components/VMenu/VMenu.tsx
+++ b/packages/vuetify/src/components/VMenu/VMenu.tsx
@@ -135,6 +135,9 @@ export const VMenu = genericComponent()({
isActive.value = false
overlay.value?.activatorEl?.focus()
}
+ } else if (['Enter', ' '].includes(e.key) && props.closeOnContentClick) {
+ isActive.value = false
+ parent?.closeParents()
}
}
diff --git a/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.sass b/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.sass
index 839adea358e..7a9bacbcf5e 100644
--- a/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.sass
+++ b/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.sass
@@ -70,7 +70,8 @@
width: 100%
z-index: -1
- img
+ // TODO: remove in v4
+ img:not(.v-img__img)
height: $navigation-drawer-img-height
object-fit: $navigation-drawer-img-object-fit
width: $navigation-drawer-img-width
diff --git a/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.tsx b/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.tsx
index a6004f2f37e..57b37d248fb 100644
--- a/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.tsx
+++ b/packages/vuetify/src/components/VNavigationDrawer/VNavigationDrawer.tsx
@@ -1,6 +1,10 @@
// Styles
import './VNavigationDrawer.sass'
+// Components
+import { VDefaultsProvider } from '@/components/VDefaultsProvider'
+import { VImg } from '@/components/VImg'
+
// Composables
import { useSticky } from './sticky'
import { useTouch } from './touch'
@@ -246,10 +250,29 @@ export const VNavigationDrawer = genericComponent()({
>
{ hasImage && (
- { slots.image
- ? slots.image?.({ image: props.image })
- : (

)
- }
+ { !slots.image ? (
+
+ ) : (
+
+ )}
)}
diff --git a/packages/vuetify/src/components/VOtpInput/VOtpInput.tsx b/packages/vuetify/src/components/VOtpInput/VOtpInput.tsx
index 27ab3bec289..28f5ef00933 100644
--- a/packages/vuetify/src/components/VOtpInput/VOtpInput.tsx
+++ b/packages/vuetify/src/components/VOtpInput/VOtpInput.tsx
@@ -100,10 +100,11 @@ export const VOtpInput = genericComponent()({
function onInput () {
// The maxlength attribute doesn't work for the number type input, so the text type is used.
// The following logic simulates the behavior of a number input.
- if (props.type === 'number' && /[^0-9]/g.test(current.value.value)) {
+ if (isValidNumber(current.value.value)) {
current.value.value = ''
return
}
+
const array = model.value.slice()
const value = current.value.value
@@ -165,7 +166,11 @@ export const VOtpInput = genericComponent()({
e.preventDefault()
e.stopPropagation()
- model.value = (e?.clipboardData?.getData('Text') ?? '').split('')
+ const clipboardText = e?.clipboardData?.getData('Text') ?? ''
+
+ if (!isValidNumber(clipboardText)) return
+
+ model.value = clipboardText.split('')
inputRef.value?.[index].blur()
}
@@ -186,6 +191,10 @@ export const VOtpInput = genericComponent()({
focusIndex.value = -1
}
+ function isValidNumber (value: string) {
+ return props.type === 'number' && !isNaN(Number(value))
+ }
+
provideDefaults({
VField: {
color: computed(() => props.color),
diff --git a/packages/vuetify/src/components/VOverlay/scrollStrategies.ts b/packages/vuetify/src/components/VOverlay/scrollStrategies.ts
index 3d2495c4072..9369a54637d 100644
--- a/packages/vuetify/src/components/VOverlay/scrollStrategies.ts
+++ b/packages/vuetify/src/components/VOverlay/scrollStrategies.ts
@@ -1,5 +1,5 @@
// Utilities
-import { effectScope, nextTick, onScopeDispose, watchEffect } from 'vue'
+import { effectScope, onScopeDispose, watchEffect } from 'vue'
import { requestNewFrame } from './requestNewFrame'
import { convertToUnit, getScrollParents, hasScrollbar, IN_BROWSER, propsFactory } from '@/util'
@@ -49,7 +49,7 @@ export function useScrollStrategies (
if (!(data.isActive.value && props.scrollStrategy)) return
scope = effectScope()
- await nextTick()
+ await new Promise(resolve => setTimeout(resolve))
scope.active && scope.run(() => {
if (typeof props.scrollStrategy === 'function') {
props.scrollStrategy(data, props, scope!)
diff --git a/packages/vuetify/src/components/VSelect/VSelect.tsx b/packages/vuetify/src/components/VSelect/VSelect.tsx
index 7f6ff07e3b9..44814fdde29 100644
--- a/packages/vuetify/src/components/VSelect/VSelect.tsx
+++ b/packages/vuetify/src/components/VSelect/VSelect.tsx
@@ -313,10 +313,12 @@ export const VSelect = genericComponent props.items, val => {
- if (!isFocused.value || !val.length || menu.value) return
+ watch(() => props.items, (newVal, oldVal) => {
+ if (menu.value) return
- menu.value = true
+ if (isFocused.value && !oldVal.length && newVal.length) {
+ menu.value = true
+ }
})
useRender(() => {
diff --git a/packages/vuetify/src/components/VSelect/__tests__/VSelect.spec.cy.tsx b/packages/vuetify/src/components/VSelect/__tests__/VSelect.spec.cy.tsx
index 0ca61fa47cd..b438127a98a 100644
--- a/packages/vuetify/src/components/VSelect/__tests__/VSelect.spec.cy.tsx
+++ b/packages/vuetify/src/components/VSelect/__tests__/VSelect.spec.cy.tsx
@@ -584,6 +584,21 @@ describe('VSelect', () => {
.should('exist')
})
+ // https://github.com/vuetifyjs/vuetify/issues/19346
+ it('should not show menu when focused and existing non-empty items are changed', () => {
+ cy
+ .mount((props: any) => ())
+ .setProps({ items: ['Foo', 'Bar'] })
+ .get('.v-select')
+ .click()
+ .get('.v-overlay')
+ .should('exist')
+ .get('.v-list-item').eq(1).click({ waitForAnimations: false })
+ .setProps({ items: ['Foo', 'Bar', 'test', 'test 2'] })
+ .get('.v-overlay')
+ .should('not.exist')
+ })
+
describe('Showcase', () => {
generate({ stories })
})
diff --git a/packages/vuetify/src/composables/date/adapters/vuetify.ts b/packages/vuetify/src/composables/date/adapters/vuetify.ts
index 712c2e1be3f..2c43a8d7a4b 100644
--- a/packages/vuetify/src/composables/date/adapters/vuetify.ts
+++ b/packages/vuetify/src/composables/date/adapters/vuetify.ts
@@ -491,7 +491,7 @@ function setYear (date: Date, year: number) {
}
function startOfDay (date: Date) {
- return new Date(date.getFullYear(), date.getMonth(), date.getDate())
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0)
}
function endOfDay (date: Date) {
diff --git a/packages/vuetify/src/composables/nested/activeStrategies.ts b/packages/vuetify/src/composables/nested/activeStrategies.ts
new file mode 100644
index 00000000000..495cfcef2af
--- /dev/null
+++ b/packages/vuetify/src/composables/nested/activeStrategies.ts
@@ -0,0 +1,130 @@
+/* eslint-disable sonarjs/no-identical-functions */
+// Utilities
+import { toRaw } from 'vue'
+
+export type ActiveStrategyFn = (data: {
+ id: unknown
+ value: boolean
+ activated: Set
+ children: Map
+ parents: Map
+ event?: Event
+}) => Set
+
+export type ActiveStrategyTransformInFn = (
+ v: readonly unknown[] | undefined,
+ children: Map,
+ parents: Map,
+) => Set
+
+export type ActiveStrategyTransformOutFn = (
+ v: Set,
+ children: Map,
+ parents: Map,
+) => unknown[]
+
+export type ActiveStrategy = {
+ activate: ActiveStrategyFn
+ in: ActiveStrategyTransformInFn
+ out: ActiveStrategyTransformOutFn
+}
+
+export const independentActiveStrategy = (mandatory?: boolean): ActiveStrategy => {
+ const strategy: ActiveStrategy = {
+ activate: ({ id, value, activated }) => {
+ id = toRaw(id)
+
+ // When mandatory and we're trying to deselect when id
+ // is the only currently selected item then do nothing
+ if (mandatory && !value && activated.size === 1 && activated.has(id)) return activated
+
+ if (value) {
+ activated.add(id)
+ } else {
+ activated.delete(id)
+ }
+
+ return activated
+ },
+ in: (v, children, parents) => {
+ let set = new Set()
+
+ for (const id of (v || [])) {
+ set = strategy.activate({
+ id,
+ value: true,
+ activated: new Set(set),
+ children,
+ parents,
+ })
+ }
+
+ return set
+ },
+ out: v => {
+ return Array.from(v)
+ },
+ }
+
+ return strategy
+}
+
+export const independentSingleActiveStrategy = (mandatory?: boolean): ActiveStrategy => {
+ const parentStrategy = independentActiveStrategy(mandatory)
+
+ const strategy: ActiveStrategy = {
+ activate: ({ activated, id, ...rest }) => {
+ id = toRaw(id)
+ const singleSelected = activated.has(id) ? new Set([id]) : new Set()
+ return parentStrategy.activate({ ...rest, id, activated: singleSelected })
+ },
+ in: (v, children, parents) => {
+ let set = new Set()
+
+ if (v?.length) {
+ set = parentStrategy.in(v.slice(0, 1), children, parents)
+ }
+
+ return set
+ },
+ out: (v, children, parents) => {
+ return parentStrategy.out(v, children, parents)
+ },
+ }
+
+ return strategy
+}
+
+export const leafActiveStrategy = (mandatory?: boolean): ActiveStrategy => {
+ const parentStrategy = independentActiveStrategy(mandatory)
+
+ const strategy: ActiveStrategy = {
+ activate: ({ id, activated, children, ...rest }) => {
+ id = toRaw(id)
+ if (children.has(id)) return activated
+
+ return parentStrategy.activate({ id, activated, children, ...rest })
+ },
+ in: parentStrategy.in,
+ out: parentStrategy.out,
+ }
+
+ return strategy
+}
+
+export const leafSingleActiveStrategy = (mandatory?: boolean): ActiveStrategy => {
+ const parentStrategy = independentSingleActiveStrategy(mandatory)
+
+ const strategy: ActiveStrategy = {
+ activate: ({ id, activated, children, ...rest }) => {
+ id = toRaw(id)
+ if (children.has(id)) return activated
+
+ return parentStrategy.activate({ id, activated, children, ...rest })
+ },
+ in: parentStrategy.in,
+ out: parentStrategy.out,
+ }
+
+ return strategy
+}
diff --git a/packages/vuetify/src/composables/nested/nested.ts b/packages/vuetify/src/composables/nested/nested.ts
index d6f8073a694..aa006fa4f72 100644
--- a/packages/vuetify/src/composables/nested/nested.ts
+++ b/packages/vuetify/src/composables/nested/nested.ts
@@ -2,7 +2,12 @@
import { useProxiedModel } from '@/composables/proxiedModel'
// Utilities
-import { computed, inject, onBeforeUnmount, provide, ref, shallowRef, toRaw } from 'vue'
+import { computed, inject, onBeforeUnmount, provide, ref, shallowRef, toRaw, toRef } from 'vue'
+import {
+ independentActiveStrategy,
+ independentSingleActiveStrategy, leafActiveStrategy,
+ leafSingleActiveStrategy,
+} from './activeStrategies'
import { listOpenStrategy, multipleOpenStrategy, singleOpenStrategy } from './openStrategies'
import {
classicSelectStrategy,
@@ -23,11 +28,16 @@ export type SelectStrategy = 'single-leaf' | 'leaf' | 'independent' | 'single-in
export type OpenStrategyProp = 'single' | 'multiple' | 'list' | OpenStrategy
export interface NestedProps {
+ activatable: boolean
+ selectable: boolean
+ activeStrategy: SelectStrategy | undefined
selectStrategy: SelectStrategy | undefined
openStrategy: OpenStrategyProp | undefined
+ activated: readonly unknown[] | undefined
selected: readonly unknown[] | undefined
opened: readonly unknown[] | undefined
mandatory: boolean
+ 'onUpdate:activated': EventProp<[unknown[]]> | undefined
'onUpdate:selected': EventProp<[unknown[]]> | undefined
'onUpdate:opened': EventProp<[unknown[]]> | undefined
}
@@ -38,12 +48,16 @@ type NestedProvide = {
root: {
children: Ref