From 9806528676d918842a38f2139ca0db220868cd74 Mon Sep 17 00:00:00 2001 From: Denis Skripchenko Date: Sun, 25 Oct 2026 23:43:50 +0300 Subject: [PATCH] =?UTF-8?q?feat(menu):=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D1=8F=D0=B5=D0=BC=D1=8B=D0=B9=20=D1=80=D0=B5=D0=B6=D0=B8?= =?UTF-8?q?=D0=BC=20=D0=B8=20openAt=20=D0=B4=D0=BB=D1=8F=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=BD=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=8E;=20fix(tooltip):=20=D0=BF=D0=BE=D0=B7?= =?UTF-8?q?=D0=B8=D1=86=D0=B8=D1=8F=20=D1=83=20=D0=BE=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D0=B5=D0=BC=D0=BE=D0=B3=D0=BE=20=D1=8D=D0=BB?= =?UTF-8?q?=D0=B5=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .changeset/menu-context-tooltip-anchor.md | 5 ++ src/components/Menu/UidMenu.spec.ts | 54 ++++++++++++++++++ src/components/Menu/UidMenu.stories.ts | 27 +++++++++ src/components/Menu/UidMenu.vue | 68 ++++++++++++++++++----- src/components/Tooltip/UidTooltip.spec.ts | 17 ++++++ src/components/Tooltip/UidTooltip.vue | 7 ++- 6 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 .changeset/menu-context-tooltip-anchor.md diff --git a/.changeset/menu-context-tooltip-anchor.md b/.changeset/menu-context-tooltip-anchor.md new file mode 100644 index 0000000..fcde635 --- /dev/null +++ b/.changeset/menu-context-tooltip-anchor.md @@ -0,0 +1,5 @@ +--- +"@dskripchenko/ui": minor +--- + +UidMenu: управляемый режим через `v-model:open` и `openAt(x, y)` для контекстного меню; меню можно использовать без слота `trigger`. UidTooltip: подсказка встаёт у описываемого элемента — раньше обёртка с `display: contents` давала пустой прямоугольник, и подсказка уезжала в левый верхний угол. diff --git a/src/components/Menu/UidMenu.spec.ts b/src/components/Menu/UidMenu.spec.ts index e00766e..d678d32 100644 --- a/src/components/Menu/UidMenu.spec.ts +++ b/src/components/Menu/UidMenu.spec.ts @@ -402,3 +402,57 @@ describe('UidSubMenu', () => { wrapper.unmount() }) }) + +describe('UidMenu: управляемый режим и меню в точке', () => { + const items = 'ОткрытьУдалить' + + it('openAt открывает меню без триггера и ставит его в точку', async () => { + const wrapper = mount(UidMenu, { + slots: { default: items }, + global: { components: { UidMenuItem } }, + attachTo: document.body, + }) + expect(wrapper.find('.uid-menu-trigger').exists()).toBe(false) + ;(wrapper.vm as unknown as { openAt(x: number, y: number): void }).openAt(120, 80) + await flushPromises() + await new Promise((r) => requestAnimationFrame(() => r(null))) + const menu = document.querySelector('.uid-menu') + expect(menu).not.toBeNull() + // Below and to the right of the point (bottom-start), clamped to the viewport margin. + expect(parseFloat(menu!.style.left)).toBe(120) + expect(parseFloat(menu!.style.top)).toBe(88) + wrapper.unmount() + }) + + it('v-model:open управляет видимостью и сообщает о закрытии', async () => { + const wrapper = mount(UidMenu, { + props: { open: false, 'onUpdate:open': (v: boolean) => wrapper.setProps({ open: v }) }, + slots: { trigger: '', default: items }, + global: { components: { UidMenuItem } }, + attachTo: document.body, + }) + await wrapper.setProps({ open: true }) + await flushPromises() + expect(document.querySelector('.uid-menu')).not.toBeNull() + + document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + await flushPromises() + expect(wrapper.emitted('update:open')?.at(-1)).toEqual([false]) + expect(document.querySelector('.uid-menu')).toBeNull() + wrapper.unmount() + }) + + it('клик вне меню в точке закрывает его, даже если это триггер', async () => { + const wrapper = mount(UidMenu, { + slots: { trigger: '', default: items }, + global: { components: { UidMenuItem } }, + attachTo: document.body, + }) + ;(wrapper.vm as unknown as { openAt(x: number, y: number): void }).openAt(10, 10) + await flushPromises() + wrapper.find('.uid-menu-trigger button').element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + await flushPromises() + expect(document.querySelector('.uid-menu')).toBeNull() + wrapper.unmount() + }) +}) diff --git a/src/components/Menu/UidMenu.stories.ts b/src/components/Menu/UidMenu.stories.ts index 1faae85..44edaeb 100644 --- a/src/components/Menu/UidMenu.stories.ts +++ b/src/components/Menu/UidMenu.stories.ts @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/vue3' +import { ref } from 'vue' import UidMenu from './UidMenu.vue' import UidMenuItem from './UidMenuItem.vue' import UidMenuSeparator from './UidMenuSeparator.vue' @@ -146,3 +147,29 @@ export const NestedSubmenus: Story = { `, }), } + +export const ContextMenu: Story = { + name: 'Контекстное меню (openAt)', + render: () => ({ + components: { UidMenu, UidMenuItem, UidMenuSeparator }, + setup() { + const menu = ref | 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(() => {