Skip to content
Merged
Show file tree
Hide file tree
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
5 changes: 5 additions & 0 deletions .changeset/menu-context-tooltip-anchor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@dskripchenko/ui": minor
---

UidMenu: управляемый режим через `v-model:open` и `openAt(x, y)` для контекстного меню; меню можно использовать без слота `trigger`. UidTooltip: подсказка встаёт у описываемого элемента — раньше обёртка с `display: contents` давала пустой прямоугольник, и подсказка уезжала в левый верхний угол.
54 changes: 54 additions & 0 deletions src/components/Menu/UidMenu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,3 +402,57 @@ describe('UidSubMenu', () => {
wrapper.unmount()
})
})

describe('UidMenu: управляемый режим и меню в точке', () => {
const items = '<UidMenuItem>Открыть</UidMenuItem><UidMenuItem>Удалить</UidMenuItem>'

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<HTMLElement>('.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: '<button>Меню</button>', 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: '<button>Меню</button>', 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()
})
})
27 changes: 27 additions & 0 deletions src/components/Menu/UidMenu.stories.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -146,3 +147,29 @@ export const NestedSubmenus: Story = {
`,
}),
}

export const ContextMenu: Story = {
name: 'Контекстное меню (openAt)',
render: () => ({
components: { UidMenu, UidMenuItem, UidMenuSeparator },
setup() {
const menu = ref<InstanceType<typeof UidMenu> | null>(null)
return { menu }
},
template: `
<div
style="height:240px;display:grid;place-items:center;border:1px dashed var(--uid-color-border);border-radius:8px"
@contextmenu.prevent="menu.openAt($event.clientX, $event.clientY)"
>
Правый клик в любом месте области
<UidMenu ref="menu">
<UidMenuItem>Открыть</UidMenuItem>
<UidMenuItem>Переименовать</UidMenuItem>
<UidMenuSeparator />
<UidMenuItem variant="danger">Удалить</UidMenuItem>
</UidMenu>
</div>
`,
}),
}

68 changes: 54 additions & 14 deletions src/components/Menu/UidMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>('open', { default: false })

const triggerRef = ref<HTMLElement | null>(null)
const menuRef = ref<HTMLElement | null>(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<HTMLElement | null>(() => {
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
Expand All @@ -23,7 +34,9 @@ const triggerAnchorRef = computed<HTMLElement | null>(() => {
return (triggerRef.value?.firstElementChild as HTMLElement | null) ?? triggerRef.value
})

const { floatingStyle, update } = usePopover(triggerAnchorRef, menuRef, {
const referenceRef = computed<HTMLElement | null>(() => pointAnchor.value ?? triggerAnchorRef.value)

const { floatingStyle, update } = usePopover(referenceRef, menuRef, {
placement: 'bottom-start',
})

Expand All @@ -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

Expand All @@ -54,27 +67,52 @@ function getItems(): HTMLElement[] {

function close(): void {
open.value = false
triggerRef.value?.querySelector<HTMLElement>('button, [tabindex]')?.focus()
if (point.value) {
returnFocus?.focus?.()
} else {
triggerRef.value?.querySelector<HTMLElement>('button, [tabindex]')?.focus()
}
}

provide(MENU_CLOSE_KEY, close)

const activeSubmenu = ref<string | null>(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<void> {
await nextTick()
update()
requestAnimationFrame(() => {
update()
getItems()[0]?.focus()
})
}

async function toggle(): Promise<void> {
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()
Expand All @@ -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
}

Expand All @@ -115,6 +154,7 @@ onUnmounted(() => {

<template>
<div
v-if="slots.trigger"
ref="triggerRef"
class="uid-menu-trigger"
:role="delegatesToChild ? undefined : 'button'"
Expand Down
17 changes: 17 additions & 0 deletions src/components/Tooltip/UidTooltip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,21 @@ describe('UidTooltip', () => {
expect(document.querySelector('[role="tooltip"]')).not.toBeNull()
wrapper.unmount()
})

it('ставит tooltip у описываемого элемента, а не у пустой обёртки с display: contents', async () => {
const wrapper = mount(UidTooltip, {
props: { content: 'Подсказка', placement: 'bottom' },
slots: { default: '<button>Hover me</button>' },
attachTo: document.body,
})
const button = wrapper.find('button').element
button.getBoundingClientRect = () => new DOMRect(300, 200, 80, 32)
await wrapper.find('.uid-tooltip-anchor').trigger('mouseenter')
await flushPromises()
const tip = document.querySelector<HTMLElement>('.uid-tooltip')!
// Under the button (200 + 32 + 8): the empty anchor would have put it at the top margin.
expect(parseFloat(tip.style.top)).toBe(240)
wrapper.unmount()
})
})

7 changes: 6 additions & 1 deletion src/components/Tooltip/UidTooltip.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ const referenceRef = ref<HTMLElement | null>(null)
const floatingRef = ref<HTMLElement | null>(null)
const visible = ref(false)

const { floatingStyle, update } = usePopover(referenceRef, floatingRef, {
// The anchor is display: contents (an empty box); measure its first child.
const anchorRef = computed<HTMLElement | null>(
() => (referenceRef.value?.firstElementChild as HTMLElement | null) ?? referenceRef.value,
)

const { floatingStyle, update } = usePopover(anchorRef, floatingRef, {
placement: computed(() => props.placement),
})

Expand Down
Loading