| null>(null)
+ return { menu }
+ },
+ template: `
+
+ Правый клик в любом месте области
+
+ Открыть
+ Переименовать
+
+ Удалить
+
+
+ `,
+ }),
+}
+
diff --git a/src/components/Menu/UidMenu.vue b/src/components/Menu/UidMenu.vue
index 6a6933e..a4fdfd6 100644
--- a/src/components/Menu/UidMenu.vue
+++ b/src/components/Menu/UidMenu.vue
@@ -4,16 +4,27 @@ import { ref, computed, provide, watch, nextTick, onMounted, onUnmounted, useId
import { usePopover } from '../../composables/usePopover.js'
import { MENU_CLOSE_KEY, MENU_LEVEL_KEY, getLevelItems, moveFocus } from './context.js'
-defineSlots<{
- trigger(): unknown
+const slots = defineSlots<{
+ trigger?(): unknown
default(): unknown
}>()
+// v-model:open makes the menu controlled (context menus, external triggers).
+const open = defineModel('open', { default: false })
+
const triggerRef = ref(null)
const menuRef = ref(null)
-const open = ref(false)
const menuId = useId()
+// openAt() point as a zero-size virtual element for usePopover.
+const point = ref<{ x: number; y: number } | null>(null)
+const pointAnchor = computed(() => {
+ const p = point.value
+ if (!p) return null
+ return { getBoundingClientRect: () => new DOMRect(p.x, p.y, 0, 0) } as unknown as HTMLElement
+})
+let returnFocus: HTMLElement | null = null
+
// `.uid-menu-trigger` has `display: contents`, which means the root div itself
// takes no place in the layout and its getBoundingClientRect() returns
// (0,0,0,0). usePopover must see the real dimensions — we take them from the
@@ -23,7 +34,9 @@ const triggerAnchorRef = computed(() => {
return (triggerRef.value?.firstElementChild as HTMLElement | null) ?? triggerRef.value
})
-const { floatingStyle, update } = usePopover(triggerAnchorRef, menuRef, {
+const referenceRef = computed(() => pointAnchor.value ?? triggerAnchorRef.value)
+
+const { floatingStyle, update } = usePopover(referenceRef, menuRef, {
placement: 'bottom-start',
})
@@ -36,7 +49,7 @@ const delegatesToChild = ref(false)
function syncTriggerRole(): void {
const child = triggerRef.value?.firstElementChild as HTMLElement | null
- delegatesToChild.value = child !== null && child.matches(INTERACTIVE)
+ delegatesToChild.value = child != null && child.matches(INTERACTIVE)
if (!delegatesToChild.value) return
@@ -54,27 +67,52 @@ function getItems(): HTMLElement[] {
function close(): void {
open.value = false
- triggerRef.value?.querySelector('button, [tabindex]')?.focus()
+ if (point.value) {
+ returnFocus?.focus?.()
+ } else {
+ triggerRef.value?.querySelector('button, [tabindex]')?.focus()
+ }
}
provide(MENU_CLOSE_KEY, close)
const activeSubmenu = ref(null)
provide(MENU_LEVEL_KEY, { activeSubmenu })
-watch(open, (val) => { if (!val) activeSubmenu.value = null })
+watch(open, (val) => {
+ if (!val) {
+ activeSubmenu.value = null
+ point.value = null
+ }
+})
+
+async function settle(): Promise {
+ await nextTick()
+ update()
+ requestAnimationFrame(() => {
+ update()
+ getItems()[0]?.focus()
+ })
+}
async function toggle(): Promise {
+ point.value = null
open.value = !open.value
+}
+
+function openAt(x: number, y: number): void {
+ returnFocus = document.activeElement as HTMLElement | null
+ point.value = { x, y }
if (open.value) {
- await nextTick()
- update()
- requestAnimationFrame(() => {
- update()
- getItems()[0]?.focus()
- })
+ void settle()
+ } else {
+ open.value = true
}
}
+watch(open, (val) => { if (val) void settle() })
+
+defineExpose({ openAt, close, toggle })
+
function onTriggerKeydown(event: KeyboardEvent): void {
if ((event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') && !open.value) {
event.preventDefault()
@@ -96,7 +134,8 @@ function onMenuKeydown(event: KeyboardEvent): void {
function onOutsideClick(event: MouseEvent): void {
const target = event.target as Node
- if (triggerRef.value?.contains(target) || menuRef.value?.contains(target)) return
+ if (menuRef.value?.contains(target)) return
+ if (!point.value && triggerRef.value?.contains(target)) return
open.value = false
}
@@ -115,6 +154,7 @@ onUnmounted(() => {