cursedbelt 5.14.0 β†’ 5.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/react/components/DropdownMenu.d.ts +1 -1
  2. package/dist/react/components/DropdownMenu.d.ts.map +1 -1
  3. package/dist/react/components/DropdownMenu.js +29 -2
  4. package/dist/react/components/DropdownMenu.js.map +1 -1
  5. package/dist/react/components/HoverCard.d.ts.map +1 -1
  6. package/dist/react/components/HoverCard.js +3 -1
  7. package/dist/react/components/HoverCard.js.map +1 -1
  8. package/dist/react/components/Popover.d.ts +70 -8
  9. package/dist/react/components/Popover.d.ts.map +1 -1
  10. package/dist/react/components/Popover.js +78 -6
  11. package/dist/react/components/Popover.js.map +1 -1
  12. package/dist/react/components/SelectField.d.ts.map +1 -1
  13. package/dist/react/components/SelectField.js +3 -1
  14. package/dist/react/components/SelectField.js.map +1 -1
  15. package/dist/react/components/TooltipImpl.d.ts.map +1 -1
  16. package/dist/react/components/TooltipImpl.js +3 -1
  17. package/dist/react/components/TooltipImpl.js.map +1 -1
  18. package/dist/react/hooks/useOutsidePress.d.ts +60 -0
  19. package/dist/react/hooks/useOutsidePress.d.ts.map +1 -0
  20. package/dist/react/hooks/useOutsidePress.js +120 -0
  21. package/dist/react/hooks/useOutsidePress.js.map +1 -0
  22. package/dist/react/icons.d.ts +13 -3
  23. package/dist/react/icons.d.ts.map +1 -1
  24. package/dist/react/icons.js +9 -3
  25. package/dist/react/icons.js.map +1 -1
  26. package/dist/react/index.d.ts +2 -1
  27. package/dist/react/index.d.ts.map +1 -1
  28. package/dist/react/index.js +2 -1
  29. package/dist/react/index.js.map +1 -1
  30. package/dist/react/lib/floatingContainer.d.ts +19 -0
  31. package/dist/react/lib/floatingContainer.d.ts.map +1 -0
  32. package/dist/react/lib/floatingContainer.js +36 -0
  33. package/dist/react/lib/floatingContainer.js.map +1 -0
  34. package/dist/react/media/CanvasVideoPlayer.d.ts.map +1 -1
  35. package/dist/react/media/CanvasVideoPlayer.js +2 -1
  36. package/dist/react/media/CanvasVideoPlayer.js.map +1 -1
  37. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  38. package/dist/styles-areas/media.css +4 -4
  39. package/dist/styles-utilities.css +0 -3
  40. package/package.json +3 -2
  41. package/scripts/guardrails-baseline.json +2 -1
  42. package/scripts/guardrailsEnforce.spec.ts +53 -0
  43. package/scripts/guardrailsEnforce.ts +60 -0
  44. package/src/react/components/DropdownMenu.spec.tsx +39 -0
  45. package/src/react/components/DropdownMenu.tsx +37 -5
  46. package/src/react/components/HoverCard.tsx +3 -1
  47. package/src/react/components/Popover.spec.tsx +241 -0
  48. package/src/react/components/Popover.tsx +172 -23
  49. package/src/react/components/SelectField.tsx +3 -1
  50. package/src/react/components/TooltipImpl.tsx +3 -1
  51. package/src/react/hooks/useOutsidePress.spec.tsx +112 -0
  52. package/src/react/hooks/useOutsidePress.ts +159 -0
  53. package/src/react/icons.spec.ts +19 -0
  54. package/src/react/icons.tsx +14 -4
  55. package/src/react/index.ts +2 -1
  56. package/src/react/lib/floatingContainer.ts +42 -0
  57. package/src/react/media/CanvasVideoPlayer.tsx +36 -28
  58. package/src/styles-areas/media.css +4 -4
  59. package/src/styles-utilities.css +0 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.14.0",
3
+ "version": "5.15.2",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The React design system of the cursedbelt split β€” components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
@@ -67,7 +67,7 @@
67
67
  "typecheck:scripts": "tsc --noEmit --project tsconfig.scripts.json",
68
68
  "demo:check": "tsc --noEmit --project demo/tsconfig.json",
69
69
  "demo:paths": "bun run demo/generatePaths.ts --write",
70
- "guardrails:report": "bun run scripts/guardrailsEnforce.ts no-raw-action-button,no-raw-overflow,no-raw-inputs,no-hardcoded-colors,no-raw-modal-footer,no-unguarded-editing-modal,control-height-tokens,no-raw-zindex,no-raw-data-table,no-tailwind-palette,no-raw-title-attr,micro-typography-floor,choice-needs-empty-state,no-select-as-action-menu,no-native-dialog,no-fast-tooltip,no-raw-password,no-raw-lazy --scope cursedbelt src/react src/styles.css",
70
+ "guardrails:report": "bun run scripts/guardrailsEnforce.ts no-raw-action-button,no-raw-overflow,no-raw-inputs,no-hardcoded-colors,no-raw-modal-footer,no-unguarded-editing-modal,control-height-tokens,no-raw-zindex,no-raw-data-table,no-tailwind-palette,no-raw-title-attr,micro-typography-floor,choice-needs-empty-state,no-select-as-action-menu,no-native-dialog,no-fast-tooltip,no-raw-password,no-raw-lazy,no-raw-popover --scope cursedbelt src/react src/styles.css",
71
71
  "lint": "biome lint --error-on-warnings src e2e demo scripts",
72
72
  "styles": "bun run styles:static && bun run styles:utilities && bun run styles:areas",
73
73
  "styles:static": "bun run scripts/generateStaticStyles.ts",
@@ -86,6 +86,7 @@
86
86
  "e2e:playback-degraded": "playwright test --config e2e/playwright.config.ts playback-degraded.spec.ts",
87
87
  "e2e:resize-grip": "playwright test --config e2e/playwright.config.ts resize-grip-rail.spec.ts",
88
88
  "e2e:touch": "playwright test --config e2e/playwright.config.ts --project=touch",
89
+ "e2e:tap-away": "playwright test --config e2e/playwright.config.ts tap-away.spec.ts",
89
90
  "done:serial": "bun run typecheck && bun run lint && bun run test && bun run build",
90
91
  "paths": "bun run scripts/paths.ts",
91
92
  "surface": "public-surface",
@@ -14,7 +14,8 @@
14
14
  "no-local-reimplementation": 0,
15
15
  "micro-typography-floor": 0,
16
16
  "no-fast-tooltip": 0,
17
- "no-raw-lazy": 0
17
+ "no-raw-lazy": 0,
18
+ "no-raw-popover": 0
18
19
  },
19
20
  "cursedshell": {
20
21
  "no-raw-action-button": 0,
@@ -1406,3 +1406,56 @@ const View = React.lazy(() => import('./View'));`,
1406
1406
  ).toHaveLength(0);
1407
1407
  });
1408
1408
  });
1409
+
1410
+ describe('no-raw-popover', () => {
1411
+ test("πŸ”΄ flags the hand-rolled menu flix's subtitle button opened (owner, 2026-09-27, iPhone)", () => {
1412
+ expect(
1413
+ check(
1414
+ 'no-raw-popover',
1415
+ `{open ? (
1416
+ <div className="absolute right-0 bottom-12 min-w-44 overflow-hidden rounded-xl bg-black/90">
1417
+ <MenuRow />
1418
+ </div>
1419
+ ) : null}`,
1420
+ ),
1421
+ ).toHaveLength(1);
1422
+ });
1423
+
1424
+ test('flags `&&`, a responsive position and an inline style, on any disclosure-state name', () => {
1425
+ expect(check('no-raw-popover', `{menuOpen && <div className="md:absolute z-30">x</div>}`)).toHaveLength(1);
1426
+ expect(check('no-raw-popover', `{showPicker && (<ul style={{ position: "fixed" }}>x</ul>)}`)).toHaveLength(1);
1427
+ expect(check('no-raw-popover', `{menu === "audio" && <div className={cn("absolute", x)}>x</div>}`)).toHaveLength(1);
1428
+ });
1429
+
1430
+ test('passes an in-flow disclosure, a badge, and an overlay nobody opens', () => {
1431
+ expect(check('no-raw-popover', `{open && <div className="flex flex-col gap-1">x</div>}`)).toHaveLength(0);
1432
+ expect(check('no-raw-popover', `{showBadge && <span className="absolute -right-1 top-0">3</span>}`)).toHaveLength(0);
1433
+ expect(check('no-raw-popover', `{loading && <div className="absolute inset-0">…</div>}`)).toHaveLength(0);
1434
+ });
1435
+
1436
+ test('passes a panel with its reason in the block above', () => {
1437
+ expect(
1438
+ check(
1439
+ 'no-raw-popover',
1440
+ `{/* guardrails-ignore no-raw-popover: a drop-target outline, not a popup */}
1441
+ {dragOpen && <div className="absolute inset-0 ring-2">x</div>}`,
1442
+ ),
1443
+ ).toHaveLength(0);
1444
+ });
1445
+
1446
+ test("the belt's own Popover may render what it replaces", () => {
1447
+ expect(
1448
+ RULES['no-raw-popover'].check(
1449
+ 'src/react/components/Popover.tsx',
1450
+ `{open && <div className="absolute">x</div>}`,
1451
+ ),
1452
+ ).toHaveLength(0);
1453
+ });
1454
+
1455
+ test('stays linear on a huge file β€” an unbounded condition backtracked for minutes on desk', () => {
1456
+ const big = `{a && b}\n`.repeat(20000) + `const x = 1;`.repeat(2000);
1457
+ const started = performance.now();
1458
+ check('no-raw-popover', big);
1459
+ expect(performance.now() - started).toBeLessThan(2000);
1460
+ });
1461
+ });
@@ -114,6 +114,10 @@
114
114
  // is the belt one and is not matched; `*.spec|test.tsx` fixtures and cursedbelt's
115
115
  // own `react/confirm.tsx` / `react/prompt.tsx` are exempt by construction.
116
116
  //
117
+ // no-raw-popover β€” owner, 2026-09-27: no hand-rolled `{open && <div className="absolute …">}`
118
+ // popup; they never close when you tap away. Use `Popover` (`unstyled` for your own look)
119
+ // or `DropdownMenu`. See the rule's own block below.
120
+ //
117
121
  // no-fast-tooltip β€” owner, 2026-09-08: a hover-only hint must REST before it opens.
118
122
  // Flags a hover delay written as a numeric literal below 500ms (`delayDuration={120}`,
119
123
  // `openDelay={300}`, `delay: { open: 0 }`) and a direct `@radix-ui/react-tooltip` /
@@ -1817,6 +1821,61 @@ const noRawLazy: Rule = {
1817
1821
  },
1818
1822
  };
1819
1823
 
1824
+ // ── no-raw-popover ──────────────────────────────────────────────────────────
1825
+ // Owner report, 2026-09-27, on an iPhone: "many popup menu places don't shut when you
1826
+ // click off of them". flix's subtitle menu, music's folder menu β€” each a hand-rolled
1827
+ // `{open && <div className="absolute …">}` with no outside-press handling at all, so the
1828
+ // bug was in every copy, on every screen. cursedbelt's `Popover` closes on a tap or click
1829
+ // anywhere else, on Escape and on focus leaving, follows a full-screen element, and takes
1830
+ // `unstyled` for a panel with its own look; `DropdownMenu` is the action-list form.
1831
+ //
1832
+ // The rule is on the SHAPE, for the reason `no-raw-data-table` gives β€” "does this panel
1833
+ // close when you tap away?" is not decidable by a scanner: a conditional render (`&&` or
1834
+ // `? (`) whose condition names disclosure state (open, menu, expanded, dropdown, popover,
1835
+ // picker, flyout, show…) and whose element is positioned `absolute`/`fixed`. A panel that
1836
+ // genuinely is not a popup (an in-place badge, a drop-target overlay) takes the opt-out
1837
+ // with its reason in the comment block above. The belt's own `Popover` is exempt.
1838
+ // Bounded (160 chars, no `{}<>;`): unbounded, a 9,000-line file backtracked for minutes.
1839
+ const RAW_POPOVER_RE =
1840
+ /\{([^{}<>;]{1,160}?)(?:&&|\?)\s*\(?\s*<(div|ul|ol|section|menu|aside|nav|span|form|dialog)\b/g;
1841
+ const POPOVER_STATE_RE = /(open|menu|expanded|dropdown|popover|picker|flyout|popup|popout|suggestions)/i;
1842
+ const FLOATING_POSITION_RE =
1843
+ /(?:^|[\s"'`{:])(?:[a-z0-9-]+:)*(?:absolute|fixed)(?=[\s"'`}]|$)|position\s*:\s*["'](?:absolute|fixed)["']/;
1844
+ const RAW_POPOVER_IGNORE_RE = /guardrails-ignore\s+no-raw-popover\s*:/;
1845
+
1846
+ const noRawPopover: Rule = {
1847
+ name: "no-raw-popover",
1848
+ description:
1849
+ "Owner, 2026-09-27: no hand-rolled popup (`{open && <div className=\"absolute …\">}`) β€” they never close when you tap away. Use cursedbelt's `Popover` (`unstyled` keeps your own look; it follows a full-screen element) or `DropdownMenu` for an action list. A panel that is not a popup takes the opt-out with its reason.",
1850
+ check(filePath, source) {
1851
+ const normalized = filePath.replace(/\\/g, "/");
1852
+ if (/(^|\/)src\/react\/components\/Popover\.tsx$/.test(normalized)) return [];
1853
+ if (isTestSource(normalized)) return [];
1854
+ const scannable = blankComments(source);
1855
+ const lines = source.split("\n");
1856
+ const out: Violation[] = [];
1857
+ RAW_POPOVER_RE.lastIndex = 0;
1858
+ for (const match of scannable.matchAll(RAW_POPOVER_RE)) {
1859
+ const condition = match[1] ?? "";
1860
+ if (!POPOVER_STATE_RE.test(condition)) continue;
1861
+ const tagStart = (match.index ?? 0) + match[0].lastIndexOf("<");
1862
+ const tag = readOpeningTag(source, tagStart);
1863
+ if (!FLOATING_POSITION_RE.test(tag.code)) continue;
1864
+ const line = lineOf(source, tagStart);
1865
+ const matchLine = lineOf(source, match.index ?? 0);
1866
+ if (
1867
+ RAW_POPOVER_IGNORE_RE.test(tag.text) ||
1868
+ RAW_POPOVER_IGNORE_RE.test(lines[matchLine - 1] ?? "") ||
1869
+ RAW_POPOVER_IGNORE_RE.test(blockAbove(lines, matchLine)) ||
1870
+ RAW_POPOVER_IGNORE_RE.test(blockAbove(lines, line))
1871
+ )
1872
+ continue;
1873
+ out.push({ file: filePath, line, snippet: lines[line - 1]?.trim().slice(0, 160) ?? "" });
1874
+ }
1875
+ return out;
1876
+ },
1877
+ };
1878
+
1820
1879
  export const RULES: Record<string, Rule> = {
1821
1880
  [noRawActionButton.name]: noRawActionButton,
1822
1881
  [noRawOverflow.name]: noRawOverflow,
@@ -1838,6 +1897,7 @@ export const RULES: Record<string, Rule> = {
1838
1897
  [noRawPassword.name]: noRawPassword,
1839
1898
  [noRawNav.name]: noRawNav,
1840
1899
  [noRawLazy.name]: noRawLazy,
1900
+ [noRawPopover.name]: noRawPopover,
1841
1901
  };
1842
1902
 
1843
1903
  /**
@@ -0,0 +1,39 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
3
+ import type { ButtonHTMLAttributes } from 'react';
4
+ import { DropdownMenu } from './DropdownMenu.js';
5
+
6
+ afterEach(cleanup);
7
+
8
+ function B(props: ButtonHTMLAttributes<HTMLButtonElement>) {
9
+ return (
10
+ <button
11
+ // guardrails-ignore no-raw-action-button: a spec fixture β€” any trigger element must work.
12
+ type='button'
13
+ {...props}
14
+ />
15
+ );
16
+ }
17
+
18
+ const menu = () => screen.queryByRole('menu', { hidden: true });
19
+
20
+ describe('DropdownMenu closes on a press anywhere else', () => {
21
+ test('even on a surface that stops its own pointerdown β€” a drag handle, a canvas', async () => {
22
+ render(
23
+ <div>
24
+ <B onPointerDown={(e) => e.stopPropagation()}>drag handle</B>
25
+ <DropdownMenu items={[{ key: 'a', label: 'Rename' }]} trigger={<B>actions</B>} />
26
+ </div>,
27
+ );
28
+ fireEvent.pointerDown(screen.getByText('actions'), { button: 0, ctrlKey: false });
29
+ expect(menu()).toBeTruthy();
30
+ await act(() => new Promise((r) => setTimeout(r, 0)));
31
+ act(() => {
32
+ screen.getByText('drag handle').dispatchEvent(
33
+ new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerType: 'touch' }),
34
+ );
35
+ });
36
+ await act(() => new Promise((r) => setTimeout(r, 5)));
37
+ expect(menu()).toBeNull();
38
+ });
39
+ });
@@ -1,7 +1,9 @@
1
1
  import * as RDrop from '@radix-ui/react-dropdown-menu';
2
2
  import { Check, ChevronRight } from 'lucide-react';
3
- import type { ReactNode } from 'react';
3
+ import { type ReactNode, useCallback, useRef, useState } from 'react';
4
+ import { useOutsidePress } from '../hooks/useOutsidePress.js';
4
5
  import { cn } from '../lib/cn.js';
6
+ import { useFloatingContainer } from '../lib/floatingContainer.js';
5
7
  import {
6
8
  FLOATING_ANIMATION,
7
9
  FLOATING_COLLISION_PADDING,
@@ -85,15 +87,44 @@ export function DropdownMenu({
85
87
  open,
86
88
  onOpenChange,
87
89
  }: DropdownMenuProps) {
90
+ const container = useFloatingContainer();
91
+ // Always controlled underneath, so the outside-press fallback can close it too.
92
+ const [ownOpen, setOwnOpen] = useState(false);
93
+ const isOpen = open ?? ownOpen;
94
+ const setOpen = useCallback(
95
+ (next: boolean) => {
96
+ if (open === undefined) setOwnOpen(next);
97
+ onOpenChange?.(next);
98
+ },
99
+ [open, onOpenChange],
100
+ );
101
+ /*
102
+ * Radix's menu listens for the outside `pointerdown` in the BUBBLE phase, so a surface that
103
+ * stops its own pointerdown (a drag handle, a canvas) kept the menu open. The belt's one
104
+ * tap-away listener runs in the capture phase; see `useOutsidePress`. The menu is modal, so
105
+ * it decides on the pointerdown (an inert page sends an iPhone no click).
106
+ */
107
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
108
+ const pressInside = useOutsidePress({
109
+ open: isOpen,
110
+ onDismiss: () => setOpen(false),
111
+ inside: [triggerRef],
112
+ escape: false,
113
+ decideOn: 'pointerdown',
114
+ });
88
115
  return (
89
- <RDrop.Root open={open} onOpenChange={onOpenChange}>
90
- <RDrop.Trigger asChild>{trigger}</RDrop.Trigger>
91
- <RDrop.Portal>
116
+ <RDrop.Root open={isOpen} onOpenChange={setOpen}>
117
+ <RDrop.Trigger asChild ref={triggerRef}>
118
+ {trigger}
119
+ </RDrop.Trigger>
120
+ <RDrop.Portal container={container}>
92
121
  <RDrop.Content
93
122
  align={align}
94
123
  side={side}
95
124
  sideOffset={4}
96
125
  collisionPadding={FLOATING_COLLISION_PADDING}
126
+ onClickCapture={pressInside.onClickCapture}
127
+ onPointerDownCapture={pressInside.onPointerDownCapture}
97
128
  className={cn(FLOATING_CONTENT_BASE, FLOATING_ANIMATION, 'min-w-[8rem] p-1', className)}
98
129
  >
99
130
  {items.map((item) => (
@@ -107,6 +138,7 @@ export function DropdownMenu({
107
138
  }
108
139
 
109
140
  function DropdownItem({ item }: { item: DropdownMenuItem }) {
141
+ const container = useFloatingContainer();
110
142
  if (item.divider) {
111
143
  return <RDrop.Separator className='my-1 h-px bg-border' />;
112
144
  }
@@ -125,7 +157,7 @@ function DropdownItem({ item }: { item: DropdownMenuItem }) {
125
157
  </span>
126
158
  <ChevronRight className='size-4 opacity-60' />
127
159
  </RDrop.SubTrigger>
128
- <RDrop.Portal>
160
+ <RDrop.Portal container={container}>
129
161
  <RDrop.SubContent
130
162
  className={cn(FLOATING_CONTENT_BASE, FLOATING_ANIMATION, 'min-w-[8rem] p-1')}
131
163
  sideOffset={2}
@@ -2,6 +2,7 @@ import * as RHover from '@radix-ui/react-hover-card';
2
2
  import type { ReactNode } from 'react';
3
3
  import { useHasHover } from '../hooks/useHasHover.js';
4
4
  import { cn } from '../lib/cn.js';
5
+ import { useFloatingContainer } from '../lib/floatingContainer.js';
5
6
  import { HOVER_HINT_DELAY_MS } from '../lib/hoverIntent.js';
6
7
  import {
7
8
  FLOATING_ANIMATION,
@@ -40,6 +41,7 @@ export function HoverCard({
40
41
  className,
41
42
  }: HoverCardProps) {
42
43
  const hasHover = useHasHover();
44
+ const container = useFloatingContainer();
43
45
 
44
46
  // On touch devices, render the trigger without the HoverCard wrapper
45
47
  // (no hover = no way to trigger it; don't block pointer events).
@@ -48,7 +50,7 @@ export function HoverCard({
48
50
  return (
49
51
  <RHover.Root openDelay={openDelay} closeDelay={closeDelay}>
50
52
  <RHover.Trigger asChild>{trigger}</RHover.Trigger>
51
- <RHover.Portal>
53
+ <RHover.Portal container={container}>
52
54
  <RHover.Content
53
55
  align={align}
54
56
  side={side}
@@ -0,0 +1,241 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
3
+ import { type ButtonHTMLAttributes, type Ref, useState } from 'react';
4
+ import { Popover, type PopoverPoint } from './Popover.js';
5
+
6
+ afterEach(cleanup);
7
+
8
+ /** The fixtures' plain control β€” Popover has to work with ANY trigger element. */
9
+ function B(props: ButtonHTMLAttributes<HTMLButtonElement> & { ref?: Ref<HTMLButtonElement> }) {
10
+ return (
11
+ <button
12
+ // guardrails-ignore no-raw-action-button: a spec fixture β€” the point is that the
13
+ // behaviour holds for whatever element an app hands it.
14
+ type='button'
15
+ {...props}
16
+ />
17
+ );
18
+ }
19
+
20
+ /**
21
+ * Owner report, 2026-09-27, on an iPhone: menus that stayed open after tapping away (flix's
22
+ * subtitle menu, music's folder menu). Each was a hand-rolled panel with no outside-press
23
+ * handling. These pin the behaviour every app now gets from this one component.
24
+ */
25
+ function Harness({ modal = false }: { modal?: boolean }) {
26
+ const [open, setOpen] = useState(false);
27
+ return (
28
+ <div>
29
+ <B>elsewhere</B>
30
+ <Popover
31
+ open={open}
32
+ onOpenChange={setOpen}
33
+ modal={modal}
34
+ unstyled
35
+ label='Subtitles'
36
+ trigger={<B>subtitles</B>}
37
+ >
38
+ <B>English</B>
39
+ </Popover>
40
+ </div>
41
+ );
42
+ }
43
+
44
+ const openIt = () => fireEvent.click(screen.getByRole('button', { name: 'subtitles' }));
45
+ const panel = () => screen.queryByRole('dialog', { name: 'Subtitles' });
46
+
47
+ /** A press as a browser delivers it: pointerdown, then click. Radix decides on the click. */
48
+ async function tapOutside(target: Element, pointerType: 'mouse' | 'touch') {
49
+ act(() => {
50
+ target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerType }));
51
+ });
52
+ act(() => {
53
+ target.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 }));
54
+ });
55
+ await act(() => new Promise((r) => setTimeout(r, 5)));
56
+ }
57
+
58
+ describe('Popover closes when you press anywhere else', () => {
59
+ for (const pointerType of ['mouse', 'touch'] as const) {
60
+ test(`a ${pointerType} press outside closes it`, async () => {
61
+ render(<Harness />);
62
+ openIt();
63
+ expect(panel()).toBeTruthy();
64
+ // Radix arms its outside listener a tick after opening, so an opening press never closes it.
65
+ await act(() => new Promise((r) => setTimeout(r, 0)));
66
+ await tapOutside(screen.getByRole('button', { name: 'elsewhere' }), pointerType);
67
+ expect(panel()).toBeNull();
68
+ });
69
+ }
70
+
71
+ test('a press on something that stops its own click still closes it', async () => {
72
+ // Radix alone skips dismissal when an outside handler stops propagation ("intercepted") β€”
73
+ // a video surface, a card, a row. The capture-phase fallback is what closes it then.
74
+ function Stopper() {
75
+ const [open, setOpen] = useState(false);
76
+ return (
77
+ <div>
78
+ <B onClick={(e) => e.stopPropagation()}>
79
+ video surface
80
+ </B>
81
+ <Popover open={open} onOpenChange={setOpen} label='Subtitles' trigger={<B>subtitles</B>}>
82
+ <B>English</B>
83
+ </Popover>
84
+ </div>
85
+ );
86
+ }
87
+ render(<Stopper />);
88
+ openIt();
89
+ await act(() => new Promise((r) => setTimeout(r, 0)));
90
+ await tapOutside(screen.getByRole('button', { name: 'video surface' }), 'touch');
91
+ expect(panel()).toBeNull();
92
+ });
93
+
94
+ test('an uncontrolled popover closes on an outside press too', async () => {
95
+ render(
96
+ <div>
97
+ <B>elsewhere</B>
98
+ <Popover label='Subtitles' trigger={<B>subtitles</B>}>
99
+ <B>English</B>
100
+ </Popover>
101
+ </div>,
102
+ );
103
+ openIt();
104
+ expect(panel()).toBeTruthy();
105
+ await act(() => new Promise((r) => setTimeout(r, 0)));
106
+ await tapOutside(screen.getByRole('button', { name: 'elsewhere' }), 'mouse');
107
+ expect(panel()).toBeNull();
108
+ });
109
+
110
+ test('a press inside does not close it', async () => {
111
+ render(<Harness />);
112
+ openIt();
113
+ await act(() => new Promise((r) => setTimeout(r, 0)));
114
+ await tapOutside(screen.getByRole('button', { name: 'English' }), 'mouse');
115
+ expect(panel()).toBeTruthy();
116
+ });
117
+
118
+ test('Escape closes it', () => {
119
+ render(<Harness />);
120
+ openIt();
121
+ fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
122
+ expect(panel()).toBeNull();
123
+ });
124
+
125
+ test('the trigger toggles it shut', () => {
126
+ render(<Harness />);
127
+ openIt();
128
+ expect(panel()).toBeTruthy();
129
+ openIt();
130
+ expect(panel()).toBeNull();
131
+ });
132
+
133
+ test('πŸ”΄ modal closes on a tap that sends NO click β€” an iPhone tapping the inert page', async () => {
134
+ // Measured in Playwright's iPhone 15 WebKit, 2026-09-27: under `modal` the tap lands on
135
+ // <html>, iOS sends no click for it, and a click-deciding popover never closed.
136
+ render(<Harness modal />);
137
+ openIt();
138
+ await act(() => new Promise((r) => setTimeout(r, 0)));
139
+ act(() => {
140
+ document.documentElement.dispatchEvent(
141
+ new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerType: 'touch' }),
142
+ );
143
+ });
144
+ await act(() => new Promise((r) => setTimeout(r, 5)));
145
+ expect(panel()).toBeNull();
146
+ });
147
+
148
+ test('modal: the click the closing tap produces does not reach what the menu uncovered', async () => {
149
+ let played = 0;
150
+ function Player() {
151
+ const [open, setOpen] = useState(false);
152
+ return (
153
+ <div>
154
+ <B onClick={() => played++}>picture</B>
155
+ <Popover open={open} onOpenChange={setOpen} modal label='Subtitles' trigger={<B>subtitles</B>}>
156
+ <B>English</B>
157
+ </Popover>
158
+ </div>
159
+ );
160
+ }
161
+ render(<Player />);
162
+ openIt();
163
+ await act(() => new Promise((r) => setTimeout(r, 0)));
164
+ await tapOutside(screen.getByText('picture'), 'touch');
165
+ expect(panel()).toBeNull();
166
+ expect(played).toBe(0);
167
+ await tapOutside(screen.getByText('picture'), 'touch');
168
+ expect(played).toBe(1); // closed, the picture works again
169
+ });
170
+
171
+ test('modal still closes on an outside press', async () => {
172
+ render(<Harness modal />);
173
+ openIt();
174
+ await act(() => new Promise((r) => setTimeout(r, 0)));
175
+ await tapOutside(document.body, 'mouse');
176
+ expect(panel()).toBeNull();
177
+ });
178
+ });
179
+
180
+ describe('anchor mode β€” a panel with no trigger of its own', () => {
181
+ function AnchorHarness() {
182
+ const [at, setAt] = useState<PopoverPoint | null>(null);
183
+ return (
184
+ <div>
185
+ <B>elsewhere</B>
186
+ <B
187
+ onClick={(e) => {
188
+ const trigger = e.currentTarget;
189
+ setAt((cur) => (cur ? null : { x: 10, y: 10, trigger }));
190
+ }}
191
+ >
192
+ more
193
+ </B>
194
+ <Popover anchor={at} onOpenChange={(o) => !o && setAt(null)} label='Row actions'>
195
+ <p>menu body</p>
196
+ </Popover>
197
+ </div>
198
+ );
199
+ }
200
+ const anchorPanel = () => screen.queryByRole('dialog', { name: 'Row actions' });
201
+
202
+ test('opens while the anchor is set and closes on an outside press', async () => {
203
+ render(<AnchorHarness />);
204
+ expect(anchorPanel()).toBeNull();
205
+ fireEvent.click(screen.getByRole('button', { name: 'more' }));
206
+ expect(anchorPanel()).toBeTruthy();
207
+ await act(() => new Promise((r) => setTimeout(r, 0)));
208
+ await tapOutside(screen.getByRole('button', { name: 'elsewhere' }), 'mouse');
209
+ expect(anchorPanel()).toBeNull();
210
+ });
211
+
212
+ test("a press on the anchor's own control is left to its click, so it toggles", async () => {
213
+ render(<AnchorHarness />);
214
+ const more = screen.getByRole('button', { name: 'more' });
215
+ fireEvent.click(more);
216
+ await act(() => new Promise((r) => setTimeout(r, 0)));
217
+ act(() => {
218
+ more.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerType: 'mouse' }));
219
+ });
220
+ expect(anchorPanel()).toBeTruthy(); // the pointerdown alone did not close it…
221
+ fireEvent.click(more);
222
+ await act(() => new Promise((r) => setTimeout(r, 5)));
223
+ expect(anchorPanel()).toBeNull(); // …the control's own click did β€” once, not shut-then-reopen.
224
+ });
225
+ });
226
+
227
+ describe('full screen', () => {
228
+ test('the panel mounts inside the full-screen element, the only subtree the browser paints', () => {
229
+ const stage = document.createElement('div');
230
+ document.body.appendChild(stage);
231
+ Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: () => stage });
232
+ try {
233
+ render(<Harness />);
234
+ openIt();
235
+ expect(stage.contains(panel())).toBe(true);
236
+ } finally {
237
+ Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: () => null });
238
+ stage.remove();
239
+ }
240
+ });
241
+ });