The Context Insights surface moves from a sidebar-foot button opening a frame-wide overlay to a first-level panel, a sibling of the shipped Plugins and Automation tasks pages: the page registers on the layout's keyed `main` slot and the entry on `sidebar.panellist` under one id (`dsh-context`), so the shell owns the entry row and panel switching — mounting the page IS opening it (no plugin-side open state, the footer entry, the overlay seat, and the overviewStore all go away). The `insightsEntry` preference now mounts/unwinds the registration pair. Both seats ship on every supported line (0.1.5-rc.1+), pinned per baseline by the new insightPage seam probe; runtime-verified on a disposable 0.1.5-rc.1 profile. The chrome mirrors the shipped first-level pages: one scroll region with the 960px centered column, the 20px page heading beside the range group and the balance capsule, and the page element as the `lc-ov` query container. Inline-size containment makes the page the containing block for fixed descendants, so the heatmap/usage/balance tooltips can no longer ride the harness Tooltip (its portal prop is 0.1.7+): they now carry `data-lc-tip` served by the page's own hover tip — one fixed bubble portaled to <body>, delegated off the page root (the image lightbox's precedent), correct on every supported line. Session card jumps need no close (openSession's own navigation returns to the conversation); the settings row jump unmounts the page by selecting the Plugins panel. Verified across 1440-390px in both locales, and the /context modal is untouched.
186 lines
7.9 KiB
TypeScript
186 lines
7.9 KiB
TypeScript
// The dashboard's hover tip (src/client/components/hoverTip.tsx): one fixed
|
||
// bubble portaled to <body>, driven by delegated zone events — show on
|
||
// anchor hover/focus, re-anchor on switch, hide on leave/blur/scroll/unmount,
|
||
// with viewport clamping and side flipping in the measure pass.
|
||
|
||
import { act, createElement as h, type ReactElement } from 'react'
|
||
import assert from 'node:assert/strict'
|
||
import { afterEach, describe, test } from 'vitest'
|
||
import { useHoverTip } from '../../../src/client/components/hoverTip'
|
||
import { flush, mount, query, type Mounted } from '../helpers/kit'
|
||
|
||
/** A tip zone with a pair of anchors (A carries a child span) and a plain non-anchor. */
|
||
function Zone(): ReactElement {
|
||
const { zoneProps, bubble } = useHoverTip()
|
||
return h(
|
||
'div',
|
||
zoneProps,
|
||
h('button', { id: 'a', 'data-lc-tip': 'tip A' }, h('span', { id: 'a-child' }, 'A')),
|
||
h('button', { id: 'b', 'data-lc-tip': 'tip B', 'data-lc-tip-side': 'bottom' }, 'B'),
|
||
h('span', { id: 'plain' }, 'plain'),
|
||
h('span', { id: 'empty', 'data-lc-tip': '' }, 'empty'),
|
||
bubble,
|
||
)
|
||
}
|
||
|
||
const bubble = (): HTMLElement | null => document.body.querySelector('#lc-hovertip-bubble')
|
||
|
||
async function mouseover(target: Element): Promise<void> {
|
||
await act(async () => { target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) })
|
||
}
|
||
|
||
async function mouseout(target: Element, relatedTarget: EventTarget | null): Promise<void> {
|
||
await act(async () => { target.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget })) })
|
||
}
|
||
|
||
// React implements onFocus/onBlur (and their captures) over the bubbling
|
||
// focusin/focusout pair, so the specs drive those natively.
|
||
async function focusIn(target: Element): Promise<void> {
|
||
await act(async () => { target.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) })
|
||
}
|
||
|
||
async function blurOut(target: Element, relatedTarget: EventTarget | null): Promise<void> {
|
||
await act(async () => { target.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget })) })
|
||
}
|
||
|
||
/** Pin an anchor's rect (jsdom's are all zero) so the measure pass exercises real geometry. */
|
||
function pinRect(anchor: Element, rect: { left: number; top: number; width: number; height: number }): void {
|
||
const { left, top, width, height } = rect
|
||
Object.defineProperty(anchor, 'getBoundingClientRect', {
|
||
configurable: true,
|
||
value: () => ({ left, top, width, height, bottom: top + height, right: left + width }),
|
||
})
|
||
}
|
||
|
||
afterEach(() => {
|
||
// A leaked portal from a failed test never reaches the next one.
|
||
bubble()?.remove()
|
||
})
|
||
|
||
describe('useHoverTip', () => {
|
||
test('hovering an anchor shows its portaled tip and described-by; leaving hides both', async () => {
|
||
const m = await mount(h(Zone))
|
||
const a = query(m.container, '#a')
|
||
await mouseover(a)
|
||
const el = bubble()
|
||
assert.ok(el !== null, 'the bubble portaled to <body>')
|
||
assert.equal(el.getAttribute('role'), 'tooltip')
|
||
assert.equal(el.textContent, 'tip A')
|
||
assert.ok(el.className.includes('lc-tip') && el.className.includes('lc-hovertip'))
|
||
assert.equal(el.style.opacity, '1', 'the measured pass revealed the bubble')
|
||
// Zero-rect jsdom geometry: side 'top' overflows (top < margin), so the
|
||
// bubble flips below and clamps to the viewport margin.
|
||
assert.equal(el.style.left, '8px')
|
||
assert.equal(el.style.top, '8px')
|
||
assert.equal(a.getAttribute('aria-describedby'), 'lc-hovertip-bubble')
|
||
|
||
await mouseout(a, m.container)
|
||
assert.equal(bubble(), null, 'leaving to a non-anchor hides the bubble')
|
||
assert.equal(a.getAttribute('aria-describedby'), null, 'the described-by goes with it')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('moving across the anchor subtree keeps the tip; switching anchors re-anchors without hiding', async () => {
|
||
const m = await mount(h(Zone))
|
||
const a = query(m.container, '#a')
|
||
const b = query(m.container, '#b')
|
||
await mouseover(a)
|
||
const shown = bubble()?.style.cssText
|
||
|
||
// Moving from the anchor onto its own child, and child events bubbling
|
||
// up: the same anchor keeps tip and measure (no hidden re-flash).
|
||
await mouseover(query(m.container, '#a-child'))
|
||
assert.equal(bubble()?.style.cssText, shown, 'same anchor keeps the measured spot')
|
||
assert.equal(a.getAttribute('aria-describedby'), 'lc-hovertip-bubble')
|
||
|
||
// A → B: leaving A toward B does not hide; B's mouseover re-anchors.
|
||
await mouseout(a, b)
|
||
assert.ok(bubble() !== null, 'anchor-to-anchor movement never hides')
|
||
await mouseover(b)
|
||
assert.equal(bubble()?.textContent, 'tip B')
|
||
assert.equal(a.getAttribute('aria-describedby'), null, 'the old anchor released')
|
||
assert.equal(b.getAttribute('aria-describedby'), 'lc-hovertip-bubble')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('focus and blur mirror hover; blur into another anchor keeps the tip', async () => {
|
||
const m = await mount(h(Zone))
|
||
const a = query(m.container, '#a')
|
||
const b = query(m.container, '#b')
|
||
await focusIn(a)
|
||
assert.equal(bubble()?.textContent, 'tip A')
|
||
await blurOut(a, b)
|
||
assert.ok(bubble() !== null, 'focus moving anchor-to-anchor never hides')
|
||
await focusIn(b)
|
||
assert.equal(bubble()?.textContent, 'tip B')
|
||
await blurOut(b, null)
|
||
assert.equal(bubble(), null, 'focus leaving the zone hides')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a zone scroll hides the tip; non-anchors and empty labels never show one', async () => {
|
||
const m = await mount(h(Zone))
|
||
const a = query(m.container, '#a')
|
||
await mouseover(query(m.container, '#plain'))
|
||
assert.equal(bubble(), null, 'a non-anchor target shows nothing')
|
||
await mouseover(query(m.container, '#empty'))
|
||
assert.equal(bubble(), null, 'an empty data-lc-tip shows nothing')
|
||
|
||
await mouseover(a)
|
||
assert.ok(bubble() !== null)
|
||
await act(async () => {
|
||
query(m.container, '#plain').dispatchEvent(new Event('scroll', { bubbles: false }))
|
||
})
|
||
assert.equal(bubble(), null, 'any zone scroll retracts the bubble')
|
||
assert.equal(a.getAttribute('aria-describedby'), null)
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a viewport resize retracts the tip (every anchor just moved)', async () => {
|
||
const m = await mount(h(Zone))
|
||
const a = query(m.container, '#a')
|
||
await mouseover(a)
|
||
assert.ok(bubble() !== null)
|
||
await act(async () => {
|
||
window.dispatchEvent(new Event('resize'))
|
||
})
|
||
assert.equal(bubble(), null, 'the resize retracted the bubble')
|
||
assert.equal(a.getAttribute('aria-describedby'), null)
|
||
// The listener went with it: a second resize reaches nothing to hide.
|
||
await act(async () => {
|
||
window.dispatchEvent(new Event('resize'))
|
||
})
|
||
assert.equal(bubble(), null)
|
||
await m.unmount()
|
||
})
|
||
|
||
test('the measure pass centers on the anchor, clamps horizontally, and flips a bottom tip that overflows', async () => {
|
||
const m = await mount(h(Zone))
|
||
const b = query(m.container, '#b')
|
||
// Side 'bottom', no overflow: below the anchor, centered (jsdom bubble
|
||
// measures 0×0, so the center lands on the anchor's own center).
|
||
pinRect(b, { left: 100, top: 100, width: 20, height: 20 })
|
||
await mouseover(b)
|
||
assert.equal(bubble()?.style.left, '110px')
|
||
assert.equal(bubble()?.style.top, '126px', 'anchor bottom + gap')
|
||
|
||
// Re-pin beyond the right edge and below the viewport: the left clamp and
|
||
// the bottom flip both engage (jsdom viewport is 1024×768).
|
||
pinRect(b, { left: 2000, top: 740, width: 20, height: 20 })
|
||
await mouseout(b, m.container)
|
||
await mouseover(b)
|
||
assert.equal(bubble()?.style.left, '1016px', 'clamped to innerWidth - margin (width 0)')
|
||
assert.equal(bubble()?.style.top, '734px', 'flipped above the anchor')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('unmounting the zone removes the portaled bubble', async () => {
|
||
const m: Mounted = await mount(h(Zone))
|
||
await mouseover(query(m.container, '#a'))
|
||
assert.ok(bubble() !== null)
|
||
await m.unmount()
|
||
await flush()
|
||
assert.equal(bubble(), null, 'the portal goes down with the zone')
|
||
})
|
||
})
|