From 306a262bb67ca52ade4861a20b06788038facf15 Mon Sep 17 00:00:00 2001 From: Kael Date: Tue, 30 Jul 2024 14:50:51 +1000 Subject: [PATCH] feat(VMenu): add submenu prop (#20092) closes #19093 closes #20130 --- .../api-generator/src/locale/en/VMenu.json | 3 +- .../v-menu/misc-use-in-components.vue | 4 +- .../docs/src/examples/v-menu/prop-submenu.vue | 37 +++++++++++++++++++ .../docs/src/pages/en/components/menus.md | 6 +++ .../src/components/VList/VListItem.tsx | 2 +- .../vuetify/src/components/VMenu/VMenu.tsx | 24 ++++++++++-- .../src/components/VOverlay/VOverlay.tsx | 8 ++-- .../src/components/VOverlay/useActivator.tsx | 8 +++- 8 files changed, 79 insertions(+), 13 deletions(-) create mode 100644 packages/docs/src/examples/v-menu/prop-submenu.vue diff --git a/packages/api-generator/src/locale/en/VMenu.json b/packages/api-generator/src/locale/en/VMenu.json index 1c722b31e76..58081027c73 100644 --- a/packages/api-generator/src/locale/en/VMenu.json +++ b/packages/api-generator/src/locale/en/VMenu.json @@ -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." } } diff --git a/packages/docs/src/examples/v-menu/misc-use-in-components.vue b/packages/docs/src/examples/v-menu/misc-use-in-components.vue index 749b22906c0..d23056fafd5 100644 --- a/packages/docs/src/examples/v-menu/misc-use-in-components.vue +++ b/packages/docs/src/examples/v-menu/misc-use-in-components.vue @@ -6,14 +6,14 @@ sm="6" > - + Menu diff --git a/packages/docs/src/examples/v-menu/prop-submenu.vue b/packages/docs/src/examples/v-menu/prop-submenu.vue new file mode 100644 index 00000000000..fe24d717024 --- /dev/null +++ b/packages/docs/src/examples/v-menu/prop-submenu.vue @@ -0,0 +1,37 @@ + diff --git a/packages/docs/src/pages/en/components/menus.md b/packages/docs/src/pages/en/components/menus.md index 0450f24063f..e1249927039 100644 --- a/packages/docs/src/pages/en/components/menus.md +++ b/packages/docs/src/pages/en/components/menus.md @@ -91,6 +91,12 @@ Menus can be accessed using hover instead of clicking with the **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. + + + ### Slots #### Activator and tooltip diff --git a/packages/vuetify/src/components/VList/VListItem.tsx b/packages/vuetify/src/components/VList/VListItem.tsx index 9a924136c21..e575aa8ce10 100644 --- a/packages/vuetify/src/components/VList/VListItem.tsx +++ b/packages/vuetify/src/components/VList/VListItem.tsx @@ -195,7 +195,7 @@ export const VListItem = genericComponent()({ 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)) } } diff --git a/packages/vuetify/src/components/VMenu/VMenu.tsx b/packages/vuetify/src/components/VMenu/VMenu.tsx index acf1e75e4b5..7e31dcdd632 100644 --- a/packages/vuetify/src/components/VMenu/VMenu.tsx +++ b/packages/vuetify/src/components/VMenu/VMenu.tsx @@ -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()({ 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()({ 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()({ 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()({ v-model={ isActive.value } absolute activatorProps={ activatorProps.value } + location={ props.location ?? (props.submenu ? 'end' : 'bottom') } onClick:outside={ onClickOutside } onKeydown={ onKeydown } { ...scopeId } diff --git a/packages/vuetify/src/components/VOverlay/VOverlay.tsx b/packages/vuetify/src/components/VOverlay/VOverlay.tsx index 14ec2c1fcae..5680ab5e685 100644 --- a/packages/vuetify/src/components/VOverlay/VOverlay.tsx +++ b/packages/vuetify/src/components/VOverlay/VOverlay.tsx @@ -133,6 +133,9 @@ export const VOverlay = genericComponent()({ }, setup (props, { slots, attrs, emit }) { + const root = ref() + const scrimEl = ref() + const contentEl = ref() const model = useProxiedModel(props, 'modelValue') const isActive = computed({ get: () => model.value, @@ -153,7 +156,7 @@ export const VOverlay = genericComponent()({ 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()({ if (v) isActive.value = false }) - const root = ref() - const scrimEl = ref() - const contentEl = ref() const { contentStyles, updateLocation } = useLocationStrategies(props, { isRtl, contentEl, diff --git a/packages/vuetify/src/components/VOverlay/useActivator.tsx b/packages/vuetify/src/components/VOverlay/useActivator.tsx index 0bf11e5ac86..bb216f85848 100644 --- a/packages/vuetify/src/components/VOverlay/useActivator.tsx +++ b/packages/vuetify/src/components/VOverlay/useActivator.tsx @@ -73,7 +73,11 @@ export const makeActivatorProps = propsFactory({ export function useActivator ( props: ActivatorProps, - { isActive, isTop }: { isActive: Ref, isTop: Ref } + { isActive, isTop, contentEl }: { + isActive: Ref + isTop: Ref + contentEl: Ref + } ) { const vm = getCurrentInstance('useActivator') const activatorEl = ref() @@ -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 } })