fix(ui): keep tooltips on floating triggers
This commit is contained in:
parent
ee2a7472ee
commit
c4e21af7ee
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
### Fixes
|
||||
|
||||
- Fix tooltips around inspector dropdowns/popovers without breaking floating menu anchoring.
|
||||
- Harden MCP calls with bounded page-tree responses, oversized-result errors, JSON HTTP responses, and stale WebSocket cleanup.
|
||||
- Improve Figma boolean imports by preserving XOR operations as editable exclude nodes and falling back to imported fill geometry when boolean path reconstruction cannot produce a path.
|
||||
- Preserve rotated Figma transform origins for imported vector nodes.
|
||||
|
|
|
|||
|
|
@ -33,7 +33,12 @@ export default defineConfig({
|
|||
{
|
||||
name: 'openpencil-webkit',
|
||||
testDir: './tests/e2e',
|
||||
testMatch: '**/*.webkit.spec.ts',
|
||||
testMatch: [
|
||||
'**/*.webkit.spec.ts',
|
||||
'**/design/panel.spec.ts',
|
||||
'**/export/basic.spec.ts',
|
||||
'**/fonts/settings.spec.ts'
|
||||
],
|
||||
use: {
|
||||
browserName: 'webkit'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui'
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { isTauri } from '@/app/tauri/env'
|
||||
import { useFontSettings } from '@/components/FontSettings/use'
|
||||
|
|
@ -29,6 +29,7 @@ const primaryButton = useButtonUI({
|
|||
ui: { base: 'w-full px-2 py-1.5 text-[10px] font-medium disabled:opacity-50' }
|
||||
})
|
||||
const showDownloadedFonts = isTauri()
|
||||
const popoverOpen = ref(false)
|
||||
|
||||
const {
|
||||
accessState,
|
||||
|
|
@ -47,14 +48,19 @@ const {
|
|||
setGoogleFontsEnabled
|
||||
} = useFontSettings()
|
||||
|
||||
function setPopoverOpen(value: boolean) {
|
||||
popoverOpen.value = value
|
||||
if (value) void refreshSummary()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refreshSummary()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot @update:open="$event && refreshSummary()">
|
||||
<Tip :label="dialogs.fontSettings">
|
||||
<PopoverRoot v-model:open="popoverOpen" @update:open="setPopoverOpen">
|
||||
<Tip :label="dialogs.fontSettings" :disabled="popoverOpen">
|
||||
<PopoverTrigger data-test-id="font-settings-trigger" :class="trigger.base">
|
||||
<icon-lucide-settings class="size-3.5" />
|
||||
</PopoverTrigger>
|
||||
|
|
@ -62,6 +68,7 @@ onMounted(() => {
|
|||
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
data-test-id="font-settings-panel"
|
||||
side="left"
|
||||
:side-offset="8"
|
||||
align="start"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
PopoverClose,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverRoot,
|
||||
PopoverTrigger,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipRoot,
|
||||
TooltipTrigger
|
||||
} from 'reka-ui'
|
||||
import { PopoverClose, PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
|
||||
|
|
@ -21,11 +11,10 @@ import ProviderSelectField from '@/components/chat/ProviderSelect/ProviderSelect
|
|||
import StockPhotoKeysSection from '@/components/chat/ProviderSettings/StockPhotoKeysSection.vue'
|
||||
import { provideProviderSettings } from '@/components/chat/ProviderSettings/context'
|
||||
import { usePopoverUI } from '@/components/ui/popover'
|
||||
import { useTooltipUI } from '@/components/ui/tooltip'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
|
||||
const { dialogs } = useI18n()
|
||||
const cls = usePopoverUI({ content: 'isolate z-[51] w-64 p-3' })
|
||||
const tooltipCls = useTooltipUI({ content: 'animate-in zoom-in-95 fade-in' })
|
||||
const popoverOpen = ref(false)
|
||||
const providerSettings = provideProviderSettings()
|
||||
|
||||
|
|
@ -40,53 +29,46 @@ function onInteractOutside(e: Event) {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipRoot :open="popoverOpen ? false : undefined">
|
||||
<PopoverRoot v-model:open="popoverOpen">
|
||||
<TooltipTrigger as-child>
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
data-test-id="provider-settings-trigger"
|
||||
class="rounded p-0.5 text-muted hover:bg-hover hover:text-surface"
|
||||
>
|
||||
<icon-lucide-settings class="size-3" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="top" :side-offset="4" :class="tooltipCls.content">
|
||||
{{ dialogs.providerSettings }}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
:side-offset="8"
|
||||
align="end"
|
||||
:collision-padding="16"
|
||||
:avoid-collisions="true"
|
||||
:class="cls.content"
|
||||
@interact-outside="onInteractOutside"
|
||||
<PopoverRoot v-model:open="popoverOpen">
|
||||
<Tip :label="dialogs.providerSettings" :disabled="popoverOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
data-test-id="provider-settings-trigger"
|
||||
class="rounded p-0.5 text-muted hover:bg-hover hover:text-surface"
|
||||
>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<h3 class="text-[11px] font-semibold text-surface">{{ dialogs.aiProvider }}</h3>
|
||||
<ProviderSelectField test-id="provider-settings-provider" />
|
||||
<MaxTokensSection />
|
||||
<StockPhotoKeysSection />
|
||||
<CustomEndpointSection />
|
||||
<ApiTypeSection />
|
||||
<ApiKeySection />
|
||||
<icon-lucide-settings class="size-3" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</Tip>
|
||||
|
||||
<PopoverClose
|
||||
class="mt-1 w-full rounded bg-accent px-2 py-1 text-center text-[11px] font-medium text-white hover:bg-accent/90"
|
||||
data-test-id="provider-settings-done"
|
||||
@click="providerSettings.save"
|
||||
>
|
||||
{{ dialogs.done }}
|
||||
</PopoverClose>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
</TooltipRoot>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
:side-offset="8"
|
||||
align="end"
|
||||
:collision-padding="16"
|
||||
:avoid-collisions="true"
|
||||
:class="cls.content"
|
||||
@interact-outside="onInteractOutside"
|
||||
>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<h3 class="text-[11px] font-semibold text-surface">{{ dialogs.aiProvider }}</h3>
|
||||
<ProviderSelectField test-id="provider-settings-provider" />
|
||||
<MaxTokensSection />
|
||||
<StockPhotoKeysSection />
|
||||
<CustomEndpointSection />
|
||||
<ApiTypeSection />
|
||||
<ApiKeySection />
|
||||
|
||||
<PopoverClose
|
||||
class="mt-1 w-full rounded bg-accent px-2 py-1 text-center text-[11px] font-medium text-white hover:bg-accent/90"
|
||||
data-test-id="provider-settings-done"
|
||||
@click="providerSettings.save"
|
||||
>
|
||||
{{ dialogs.done }}
|
||||
</PopoverClose>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,172 @@
|
|||
<script setup lang="ts">
|
||||
import { TooltipContent, TooltipPortal, TooltipRoot, TooltipTrigger } from 'reka-ui'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import { useTooltipUI } from '@/components/ui/tooltip'
|
||||
|
||||
const TOOLTIP_OPEN_DELAY_MS = 400
|
||||
const TOOLTIP_SIDE_OFFSET = 4
|
||||
const TOOLTIP_VIEWPORT_PADDING = 8
|
||||
|
||||
type TooltipSide = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
const cls = useTooltipUI({ content: 'animate-in zoom-in-95 fade-in' })
|
||||
|
||||
const { side = 'top', disabled = false } = defineProps<{
|
||||
const {
|
||||
side = 'top',
|
||||
disabled = false,
|
||||
label
|
||||
} = defineProps<{
|
||||
label?: string
|
||||
side?: 'top' | 'bottom' | 'left' | 'right'
|
||||
side?: TooltipSide
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const triggerRef = ref<HTMLElement>()
|
||||
const contentRef = ref<HTMLElement>()
|
||||
const open = ref(false)
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
let openTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const canOpen = computed(() => Boolean(label) && !disabled)
|
||||
const contentStyle = computed(() => ({
|
||||
left: `${position.value.x}px`,
|
||||
top: `${position.value.y}px`
|
||||
}))
|
||||
|
||||
function clearOpenTimer() {
|
||||
if (!openTimer) return
|
||||
clearTimeout(openTimer)
|
||||
openTimer = undefined
|
||||
}
|
||||
|
||||
function anchorElement() {
|
||||
const root = triggerRef.value
|
||||
const child = root?.firstElementChild
|
||||
return child instanceof HTMLElement ? child : root
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function refreshPosition() {
|
||||
if (!open.value) return
|
||||
|
||||
const anchor = anchorElement()
|
||||
const content = contentRef.value
|
||||
if (!anchor || !content) return
|
||||
|
||||
const anchorRect = anchor.getBoundingClientRect()
|
||||
const contentRect = content.getBoundingClientRect()
|
||||
const centerX = anchorRect.left + anchorRect.width / 2
|
||||
const centerY = anchorRect.top + anchorRect.height / 2
|
||||
|
||||
let x = centerX - contentRect.width / 2
|
||||
let y = anchorRect.top - contentRect.height - TOOLTIP_SIDE_OFFSET
|
||||
|
||||
if (side === 'bottom') y = anchorRect.bottom + TOOLTIP_SIDE_OFFSET
|
||||
if (side === 'left') {
|
||||
x = anchorRect.left - contentRect.width - TOOLTIP_SIDE_OFFSET
|
||||
y = centerY - contentRect.height / 2
|
||||
}
|
||||
if (side === 'right') {
|
||||
x = anchorRect.right + TOOLTIP_SIDE_OFFSET
|
||||
y = centerY - contentRect.height / 2
|
||||
}
|
||||
|
||||
position.value = {
|
||||
x: clamp(
|
||||
x,
|
||||
TOOLTIP_VIEWPORT_PADDING,
|
||||
window.innerWidth - contentRect.width - TOOLTIP_VIEWPORT_PADDING
|
||||
),
|
||||
y: clamp(
|
||||
y,
|
||||
TOOLTIP_VIEWPORT_PADDING,
|
||||
window.innerHeight - contentRect.height - TOOLTIP_VIEWPORT_PADDING
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function show() {
|
||||
if (!canOpen.value) return
|
||||
clearOpenTimer()
|
||||
openTimer = setTimeout(() => {
|
||||
open.value = true
|
||||
void nextTick(refreshPosition)
|
||||
}, TOOLTIP_OPEN_DELAY_MS)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
clearOpenTimer()
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function containsRelatedTarget(event: PointerEvent | FocusEvent) {
|
||||
const relatedTarget = event.relatedTarget
|
||||
return relatedTarget instanceof Node && triggerRef.value?.contains(relatedTarget)
|
||||
}
|
||||
|
||||
function onPointerOver(event: PointerEvent) {
|
||||
if (containsRelatedTarget(event)) return
|
||||
show()
|
||||
}
|
||||
|
||||
function onPointerOut(event: PointerEvent) {
|
||||
if (containsRelatedTarget(event)) return
|
||||
hide()
|
||||
}
|
||||
|
||||
function onFocusIn(event: FocusEvent) {
|
||||
if (containsRelatedTarget(event)) return
|
||||
show()
|
||||
}
|
||||
|
||||
function onFocusOut(event: FocusEvent) {
|
||||
if (containsRelatedTarget(event)) return
|
||||
hide()
|
||||
}
|
||||
|
||||
function onPointerDown() {
|
||||
hide()
|
||||
}
|
||||
|
||||
useEventListener(window, 'resize', refreshPosition)
|
||||
useEventListener(window, 'scroll', refreshPosition, { capture: true, passive: true })
|
||||
useEventListener(document, 'pointerdown', hide, { capture: true })
|
||||
useEventListener(document, 'click', hide, { capture: true })
|
||||
|
||||
watch(canOpen, (value) => {
|
||||
if (!value) hide()
|
||||
})
|
||||
|
||||
onBeforeUnmount(hide)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipRoot :open="disabled ? false : undefined">
|
||||
<TooltipTrigger as-child>
|
||||
<slot />
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent v-if="label" :side="side" :side-offset="4" :class="cls.content">
|
||||
{{ label }}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
<span
|
||||
ref="triggerRef"
|
||||
class="contents"
|
||||
@focusin="onFocusIn"
|
||||
@focusout="onFocusOut"
|
||||
@pointerover="onPointerOver"
|
||||
@pointerout="onPointerOut"
|
||||
@pointerdown="onPointerDown"
|
||||
@click="hide"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open && label"
|
||||
ref="contentRef"
|
||||
role="tooltip"
|
||||
:class="cls.content"
|
||||
class="pointer-events-none fixed"
|
||||
:style="contentStyle"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
|
||||
import { expect, expectInViewport, test, useEditorSetup } from '#tests/e2e/fixtures'
|
||||
import { expectDefined } from '#tests/helpers/assert'
|
||||
|
||||
const editor = useEditorSetup()
|
||||
|
|
@ -305,8 +305,19 @@ test('multi-select shows mixed header and boolean operations', async () => {
|
|||
await expect(multiHeader).toContainText('Mixed')
|
||||
await expect(multiHeader).toContainText('layers')
|
||||
|
||||
await editor.page.getByTestId('boolean-operations-trigger').click()
|
||||
await expect(editor.page.getByTestId('boolean-operation-booleanUnion')).toBeVisible()
|
||||
const booleanOperations = editor.page.getByTestId('boolean-operations-trigger')
|
||||
await booleanOperations.hover()
|
||||
await expect(
|
||||
editor.page.locator('[role=tooltip]').filter({ hasText: 'Boolean operations' })
|
||||
).toBeVisible()
|
||||
|
||||
await booleanOperations.click()
|
||||
await expect(
|
||||
editor.page.locator('[role=tooltip]').filter({ hasText: 'Boolean operations' })
|
||||
).toHaveCount(0)
|
||||
const booleanUnion = editor.page.getByTestId('boolean-operation-booleanUnion')
|
||||
await expect(booleanUnion).toBeVisible()
|
||||
await expectInViewport(editor.page, booleanUnion)
|
||||
await expect(editor.page.getByTestId('boolean-operation-booleanSubtract')).toBeVisible()
|
||||
await expect(editor.page.getByTestId('boolean-operation-booleanIntersect')).toBeVisible()
|
||||
await expect(editor.page.getByTestId('boolean-operation-booleanExclude')).toBeVisible()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
import { expectInViewport } from '#tests/e2e/fixtures'
|
||||
import { CanvasHelper } from '#tests/helpers/canvas'
|
||||
|
||||
let page: Page
|
||||
|
|
@ -50,9 +51,15 @@ test('remove export row decreases row count', async () => {
|
|||
|
||||
test('format selector changes to JPG', async () => {
|
||||
const formatTrigger = exportItems().first().getByTestId('app-select-trigger').last()
|
||||
await formatTrigger.hover()
|
||||
await expect(page.locator('[role=tooltip]').filter({ hasText: 'Export format' })).toBeVisible()
|
||||
await formatTrigger.click()
|
||||
await expect(page.locator('[role=tooltip]').filter({ hasText: 'Export format' })).toHaveCount(0)
|
||||
|
||||
await page.locator('[role="option"]').filter({ hasText: 'JPG' }).click()
|
||||
const jpgOption = page.locator('[role="option"]').filter({ hasText: 'JPG' })
|
||||
await expect(jpgOption).toBeVisible()
|
||||
await expectInViewport(page, jpgOption)
|
||||
await jpgOption.click()
|
||||
await canvas.waitForRender()
|
||||
|
||||
await expect(formatTrigger).toHaveText('JPG')
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { test, expect, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
import { CanvasHelper } from '#tests/helpers/canvas'
|
||||
|
||||
|
|
@ -39,4 +39,17 @@ export function useEditorSetupWithClear(url = '/') {
|
|||
return ctx
|
||||
}
|
||||
|
||||
export async function expectInViewport(page: Page, locator: Locator) {
|
||||
const box = await locator.boundingBox()
|
||||
if (!box) throw new Error('Expected visible element to have a bounding box')
|
||||
|
||||
const viewport = page.viewportSize()
|
||||
if (!viewport) throw new Error('Expected page to have a viewport')
|
||||
|
||||
expect(box.x).toBeGreaterThanOrEqual(0)
|
||||
expect(box.y).toBeGreaterThanOrEqual(0)
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width)
|
||||
expect(box.y + box.height).toBeLessThanOrEqual(viewport.height)
|
||||
}
|
||||
|
||||
export { test, expect }
|
||||
|
|
|
|||
|
|
@ -18,7 +18,11 @@ test('font settings popover exposes web font access without desktop-only cache a
|
|||
})
|
||||
|
||||
await expect(page.getByTestId('typography-section')).toBeVisible()
|
||||
await page.getByTestId('font-settings-trigger').click()
|
||||
const fontSettings = page.getByTestId('font-settings-trigger')
|
||||
await fontSettings.hover()
|
||||
await expect(page.locator('[role=tooltip]').filter({ hasText: 'Font settings' })).toBeVisible()
|
||||
await fontSettings.click()
|
||||
await expect(page.locator('[role=tooltip]').filter({ hasText: 'Font settings' })).toHaveCount(0)
|
||||
|
||||
await expect(page.getByText('Allow browser access to local fonts')).toBeVisible()
|
||||
await expect(page.getByTestId('font-settings-request-access')).toBeVisible()
|
||||
|
|
|
|||
Loading…
Reference in a new issue