Files
gui/apps/kitchen-sink/tests/PopoverAndMenuMultiTrigger.test.tsx
zeekayandHanzo Dev 69c6e860d7 transplant onto tamagui v2.6.0, brand as a re-runnable transform
This repo could never sync. There was NO merge-base with tamagui — 372 commits
here against 15,791 there, unrelated histories — because it began as a
copy-paste rebrand with a flattened layout. Every "sync" since was a hand
re-application that drifted, which is why the tree sat on 7.3.1 while upstream
shipped fixes we then rediscovered by hand.

This commit's PARENT IS TAMAGUI. A future sync is `git merge tama/main` then
`node scripts/rebrand.mjs` — a real merge, because the history is now shared.

174 packages: 166 upstream, our 8 restored (chrome, shell, telemetry, recipes,
get, font-geist-{sans,mono}, @hanzo/booker), bento-get gained. 170/170 build.

Our patches to files UPSTREAM ALSO OWNS are carried, which the package-level
delta missed and which was where the value actually was:
  - useMedia's server snapshot (`initState ?? getMedia()`). Upstream still
    returns undefined there, so every Tamagui Dialog kills a prerender — the
    exact crash hanzo.app spent a day on.
  - data-slot on web host nodes, absent upstream entirely.
Both applied ONTO upstream's newer files (it rewrote useMedia +150/-63 and
createComponent +160/-55), marked `PATCH (ours, carried across the transplant)`.

Seven traps are encoded in scripts/rebrand.mjs, each found by a gate:
skip itself and lockfiles; special-case the double-branded names in BOTH scoped
and bare form; map path strings, not just paths; drop a level for the flattened
group; check disk rather than a stale index.

Rust build artifacts are NOT committed. An earlier draft of this commit swept
apps/desktop/src-tauri/target/debug in via `git add -A` — 3,859 files including
a 272MB .a, over GitHub's 100MB cap, so the push could never have landed. They
are gitignored now.

Co-Authored-By: Hanzo Dev <[email protected]>
2026-07-30 18:25:06 -07:00

185 lines
5.9 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test'
import { getBoundingRect, setupPage } from './test-utils'
// shared multi-trigger tests for both Popover and Menu
// validates render isolation, position re-anchoring, state tracking,
// focus return, and rapid cycling — the same infra backs both components
function parseRenderCount(text: string | null): number {
if (!text) return 0
const match = text.match(/renders:\s*(\d+)/)
return match ? Number(match[1]) : 0
}
// abstracts the common interaction + assertion pattern for any floating element
// with multiple triggers, regardless of whether it's Popover or Menu
type MultiTriggerHarness = {
prefix: string // "pop" | "menu"
contentTestId: string
triggerCount: number
open: (page: Page, idx: number) => Promise<void>
close: (page: Page) => Promise<void>
}
const popoverHarness: MultiTriggerHarness = {
prefix: 'pop',
contentTestId: 'pop-content',
triggerCount: 3,
open: async (page, idx) => {
await page.getByTestId(`pop-trigger-${idx}`).click()
await page.waitForTimeout(300)
},
close: async (page) => {
// use Escape (same as Menu) so focus returns to trigger
await page.keyboard.press('Escape')
await page.waitForTimeout(300)
},
}
const menuHarness: MultiTriggerHarness = {
prefix: 'menu',
contentTestId: 'menu-content',
triggerCount: 3,
open: async (page, idx) => {
await page.getByTestId(`menu-trigger-${idx}`).click()
await page.waitForTimeout(300)
},
close: async (page) => {
await page.keyboard.press('Escape')
await page.waitForTimeout(300)
},
}
function multiTriggerSuite(h: MultiTriggerHarness) {
const triggerTestId = (idx: number) => `${h.prefix}-trigger-${idx}`
test(`[${h.prefix}] only the active trigger re-renders`, async ({ page }) => {
const getRenderCounts = async () => {
const counts: number[] = []
for (let i = 1; i <= h.triggerCount; i++) {
counts.push(
parseRenderCount(
await page.getByTestId(`${triggerTestId(i)}-render-count`).textContent()
)
)
}
return counts
}
const initial = await getRenderCounts()
// open via trigger 1
await h.open(page, 1)
await expect(page.getByTestId(h.contentTestId)).toBeVisible()
const afterOpen = await getRenderCounts()
// trigger 2 and 3 should NOT re-render
expect(afterOpen[1]).toBe(initial[1])
expect(afterOpen[2]).toBe(initial[2])
// close
await h.close(page)
await expect(page.getByTestId(h.contentTestId)).not.toBeVisible()
const afterClose = await getRenderCounts()
expect(afterClose[1]).toBe(initial[1])
expect(afterClose[2]).toBe(initial[2])
})
test(`[${h.prefix}] content re-anchors when switching triggers`, async ({ page }) => {
// open via trigger 1
await h.open(page, 1)
await expect(page.getByTestId(h.contentTestId)).toBeVisible()
const trigger1Box = await getBoundingRect(page, `[data-testid="${triggerTestId(1)}"]`)
const contentBox1 = await getBoundingRect(page, `[data-testid="${h.contentTestId}"]`)
expect(trigger1Box).not.toBeNull()
expect(contentBox1).not.toBeNull()
expect(Math.abs(contentBox1!.x - trigger1Box!.x)).toBeLessThan(50)
// close then open via trigger 3
await h.close(page)
await h.open(page, 3)
await expect(page.getByTestId(h.contentTestId)).toBeVisible()
const trigger3Box = await getBoundingRect(page, `[data-testid="${triggerTestId(3)}"]`)
const contentBox3 = await getBoundingRect(page, `[data-testid="${h.contentTestId}"]`)
expect(trigger3Box).not.toBeNull()
expect(contentBox3).not.toBeNull()
expect(Math.abs(contentBox3!.x - trigger3Box!.x)).toBeLessThan(50)
// content should have moved — trigger 3 is rightward of trigger 1
expect(contentBox3!.x).toBeGreaterThan(contentBox1!.x + 20)
})
test(`[${h.prefix}] only the active trigger has data-state=open`, async ({ page }) => {
await h.open(page, 2)
await expect(page.getByTestId(h.contentTestId)).toBeVisible()
await expect(page.getByTestId(triggerTestId(1))).toHaveAttribute(
'data-state',
'closed'
)
await expect(page.getByTestId(triggerTestId(2))).toHaveAttribute('data-state', 'open')
await expect(page.getByTestId(triggerTestId(3))).toHaveAttribute(
'data-state',
'closed'
)
})
test(`[${h.prefix}] close returns focus to active trigger`, async ({ page }) => {
const trigger = page.getByTestId(triggerTestId(2))
await trigger.click()
await page.waitForTimeout(300)
await expect(page.getByTestId(h.contentTestId)).toBeVisible()
await h.close(page)
await page.waitForTimeout(300)
await expect(trigger).toBeFocused()
})
test(`[${h.prefix}] rapid open/close cycles work reliably`, async ({ page }) => {
for (let cycle = 0; cycle < 4; cycle++) {
const idx = (cycle % h.triggerCount) + 1
await h.open(page, idx)
await expect(page.getByTestId(h.contentTestId)).toBeVisible()
await h.close(page)
await expect(page.getByTestId(h.contentTestId)).not.toBeVisible()
}
})
test(`[${h.prefix}] rapid trigger switching re-anchors each time`, async ({ page }) => {
for (let idx = 1; idx <= h.triggerCount; idx++) {
await h.open(page, idx)
await expect(page.getByTestId(h.contentTestId)).toBeVisible()
const triggerBox = await getBoundingRect(
page,
`[data-testid="${triggerTestId(idx)}"]`
)
const contentBox = await getBoundingRect(page, `[data-testid="${h.contentTestId}"]`)
expect(Math.abs(contentBox!.x - triggerBox!.x)).toBeLessThan(50)
await h.close(page)
}
})
}
test.describe('Popover & Menu Multi-Trigger', () => {
test.beforeEach(async ({ page }) => {
await setupPage(page, {
name: 'PopoverAndMenuMultiTriggerCase',
type: 'useCase',
waitExtra: true,
})
})
test.describe('Popover', () => {
multiTriggerSuite(popoverHarness)
})
test.describe('Menu', () => {
multiTriggerSuite(menuHarness)
})
})