Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(VMenu): add submenu prop #20092

Merged
merged 7 commits into from
Jul 30, 2024
Merged
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
3 changes: 2 additions & 1 deletion packages/api-generator/src/locale/en/VMenu.json
Original file line number Diff line number Diff line change
@@ -13,6 +13,7 @@
"openDelay": "Milliseconds to wait before opening component. Only works with the **open-on-hover** prop.",
"openOnClick": "Designates whether menu should open on activator click.",
"openOnHover": "Designates whether menu should open on activator hover.",
"returnValue": "The value that is updated when the menu is closed - must be primitive. Dot notation is supported."
"returnValue": "The value that is updated when the menu is closed - must be primitive. Dot notation is supported.",
"submenu": "Opens with right arrow and closes on left instead of up/down. Implies `location=\"end\"`. Directions are reversed for RTL."
}
}
4 changes: 2 additions & 2 deletions packages/docs/src/examples/v-menu/misc-use-in-components.vue
Original file line number Diff line number Diff line change
@@ -6,14 +6,14 @@
sm="6"
>
<v-card height="200px">
<v-card-title class="bg-blue">
<v-card-title class="bg-blue d-flex align-center">
<span class="text-h5">Menu</span>

<v-spacer></v-spacer>

<v-menu>
<template v-slot:activator="{ props }">
<v-btn icon="mdi-dots-vertical" v-bind="props"></v-btn>
<v-btn icon="mdi-dots-vertical" variant="text" v-bind="props"></v-btn>
</template>

<v-list>
37 changes: 37 additions & 0 deletions packages/docs/src/examples/v-menu/prop-submenu.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<template>
<div class="text-center">
<v-btn color="primary">
Open menu

<v-menu activator="parent">
<v-list>
<v-list-item v-for="i in 5" :key="i" link>
<v-list-item-title>Item {{ i }}</v-list-item-title>
<template v-slot:append>
<v-icon icon="mdi-menu-right" size="x-small"></v-icon>
</template>

<v-menu :open-on-focus="false" activator="parent" open-on-hover submenu>
<v-list>
<v-list-item v-for="j in 5" :key="j" link>
<v-list-item-title>Item {{ i }} - {{ j }}</v-list-item-title>
<template v-slot:append>
<v-icon icon="mdi-menu-right" size="x-small"></v-icon>
</template>

<v-menu :open-on-focus="false" activator="parent" open-on-hover submenu>
<v-list>
<v-list-item v-for="k in 5" :key="k" link>
<v-list-item-title>Item {{ i }} - {{ j }} - {{ k }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-list-item>
</v-list>
</v-menu>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
</div>
</template>
6 changes: 6 additions & 0 deletions packages/docs/src/pages/en/components/menus.md
Original file line number Diff line number Diff line change
@@ -91,6 +91,12 @@ Menus can be accessed using hover instead of clicking with the **open-on-hover**

<ExamplesExample file="v-menu/prop-open-on-hover" />

#### Nested menus

Menus with other menus inside them will not close until their children are closed. The **submenu** prop changes keyboard behaviour to open and close with left/right arrow keys instead of up/down.

<ExamplesExample file="v-menu/prop-submenu" />

### Slots

#### Activator and tooltip
2 changes: 1 addition & 1 deletion packages/vuetify/src/components/VList/VListItem.tsx
Original file line number Diff line number Diff line change
@@ -195,7 +195,7 @@ export const VListItem = genericComponent<VListItemSlots>()({
function onKeyDown (e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick(e as any as MouseEvent)
e.target!.dispatchEvent(new MouseEvent('click', e))
}
}

24 changes: 21 additions & 3 deletions packages/vuetify/src/components/VMenu/VMenu.tsx
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@ import { makeVOverlayProps } from '@/components/VOverlay/VOverlay'

// Composables
import { forwardRefs } from '@/composables/forwardRefs'
import { useRtl } from '@/composables/locale'
import { useProxiedModel } from '@/composables/proxiedModel'
import { useScopeId } from '@/composables/scopeId'

@@ -46,11 +47,13 @@ export const makeVMenuProps = propsFactory({
// TODO
// disableKeys: Boolean,
id: String,
submenu: Boolean,

...omit(makeVOverlayProps({
closeDelay: 250,
closeOnContentClick: true,
locationStrategy: 'connected' as const,
location: undefined,
openDelay: 300,
scrim: false,
scrollStrategy: 'reposition' as const,
@@ -70,6 +73,7 @@ export const VMenu = genericComponent<OverlaySlots>()({
setup (props, { slots }) {
const isActive = useProxiedModel(props, 'modelValue')
const { scopeId } = useScopeId()
const { isRtl } = useRtl()

const uid = getUid()
const id = computed(() => props.id || `v-menu-${uid}`)
@@ -157,9 +161,9 @@ export const VMenu = genericComponent<OverlaySlots>()({
isActive.value = false
overlay.value?.activatorEl?.focus()
}
} else if (['Enter', ' '].includes(e.key) && props.closeOnContentClick) {
} else if (props.submenu && e.key === (isRtl.value ? 'ArrowRight' : 'ArrowLeft')) {
isActive.value = false
parent?.closeParents()
overlay.value?.activatorEl?.focus()
}
}

@@ -170,12 +174,25 @@ export const VMenu = genericComponent<OverlaySlots>()({
if (el && isActive.value) {
if (e.key === 'ArrowDown') {
e.preventDefault()
e.stopImmediatePropagation()
focusChild(el, 'next')
} else if (e.key === 'ArrowUp') {
e.preventDefault()
e.stopImmediatePropagation()
focusChild(el, 'prev')
} else if (props.submenu) {
if (e.key === (isRtl.value ? 'ArrowRight' : 'ArrowLeft')) {
isActive.value = false
} else if (e.key === (isRtl.value ? 'ArrowLeft' : 'ArrowRight')) {
e.preventDefault()
focusChild(el, 'first')
}
}
} else if (['ArrowDown', 'ArrowUp'].includes(e.key)) {
} else if (
props.submenu
? e.key === (isRtl.value ? 'ArrowLeft' : 'ArrowRight')
: ['ArrowDown', 'ArrowUp'].includes(e.key)
) {
isActive.value = true
e.preventDefault()
setTimeout(() => setTimeout(() => onActivatorKeydown(e)))
@@ -207,6 +224,7 @@ export const VMenu = genericComponent<OverlaySlots>()({
v-model={ isActive.value }
absolute
activatorProps={ activatorProps.value }
location={ props.location ?? (props.submenu ? 'end' : 'bottom') }
onClick:outside={ onClickOutside }
onKeydown={ onKeydown }
{ ...scopeId }
8 changes: 4 additions & 4 deletions packages/vuetify/src/components/VOverlay/VOverlay.tsx
Original file line number Diff line number Diff line change
@@ -133,6 +133,9 @@ export const VOverlay = genericComponent<OverlaySlots>()({
},

setup (props, { slots, attrs, emit }) {
const root = ref<HTMLElement>()
const scrimEl = ref<HTMLElement>()
const contentEl = ref<HTMLElement>()
const model = useProxiedModel(props, 'modelValue')
const isActive = computed({
get: () => model.value,
@@ -153,7 +156,7 @@ export const VOverlay = genericComponent<OverlaySlots>()({
activatorEvents,
contentEvents,
scrimEvents,
} = useActivator(props, { isActive, isTop: localTop })
} = useActivator(props, { isActive, isTop: localTop, contentEl })
const { teleportTarget } = useTeleport(() => {
const target = props.attach || props.contained
if (target) return target
@@ -169,9 +172,6 @@ export const VOverlay = genericComponent<OverlaySlots>()({
if (v) isActive.value = false
})

const root = ref<HTMLElement>()
const scrimEl = ref<HTMLElement>()
const contentEl = ref<HTMLElement>()
const { contentStyles, updateLocation } = useLocationStrategies(props, {
isRtl,
contentEl,
8 changes: 6 additions & 2 deletions packages/vuetify/src/components/VOverlay/useActivator.tsx
Original file line number Diff line number Diff line change
@@ -73,7 +73,11 @@ export const makeActivatorProps = propsFactory({

export function useActivator (
props: ActivatorProps,
{ isActive, isTop }: { isActive: Ref<boolean>, isTop: Ref<boolean> }
{ isActive, isTop, contentEl }: {
isActive: Ref<boolean>
isTop: Ref<boolean>
contentEl: Ref<HTMLElement | undefined>
}
) {
const vm = getCurrentInstance('useActivator')
const activatorEl = ref<HTMLElement>()
@@ -215,7 +219,7 @@ export function useActivator (
if (val && (
(props.openOnHover && !isHovered && (!openOnFocus.value || !isFocused)) ||
(openOnFocus.value && !isFocused && (!props.openOnHover || !isHovered))
)) {
) && !contentEl.value?.contains(document.activeElement)) {
isActive.value = false
}
})