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.
- package/dist/react/components/DropdownMenu.d.ts +1 -1
- package/dist/react/components/DropdownMenu.d.ts.map +1 -1
- package/dist/react/components/DropdownMenu.js +29 -2
- package/dist/react/components/DropdownMenu.js.map +1 -1
- package/dist/react/components/HoverCard.d.ts.map +1 -1
- package/dist/react/components/HoverCard.js +3 -1
- package/dist/react/components/HoverCard.js.map +1 -1
- package/dist/react/components/Popover.d.ts +70 -8
- package/dist/react/components/Popover.d.ts.map +1 -1
- package/dist/react/components/Popover.js +78 -6
- package/dist/react/components/Popover.js.map +1 -1
- package/dist/react/components/SelectField.d.ts.map +1 -1
- package/dist/react/components/SelectField.js +3 -1
- package/dist/react/components/SelectField.js.map +1 -1
- package/dist/react/components/TooltipImpl.d.ts.map +1 -1
- package/dist/react/components/TooltipImpl.js +3 -1
- package/dist/react/components/TooltipImpl.js.map +1 -1
- package/dist/react/hooks/useOutsidePress.d.ts +60 -0
- package/dist/react/hooks/useOutsidePress.d.ts.map +1 -0
- package/dist/react/hooks/useOutsidePress.js +120 -0
- package/dist/react/hooks/useOutsidePress.js.map +1 -0
- package/dist/react/icons.d.ts +13 -3
- package/dist/react/icons.d.ts.map +1 -1
- package/dist/react/icons.js +9 -3
- package/dist/react/icons.js.map +1 -1
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +2 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/floatingContainer.d.ts +19 -0
- package/dist/react/lib/floatingContainer.d.ts.map +1 -0
- package/dist/react/lib/floatingContainer.js +36 -0
- package/dist/react/lib/floatingContainer.js.map +1 -0
- package/dist/react/media/CanvasVideoPlayer.d.ts.map +1 -1
- package/dist/react/media/CanvasVideoPlayer.js +2 -1
- package/dist/react/media/CanvasVideoPlayer.js.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/media.css +4 -4
- package/dist/styles-utilities.css +0 -3
- package/package.json +3 -2
- package/scripts/guardrails-baseline.json +2 -1
- package/scripts/guardrailsEnforce.spec.ts +53 -0
- package/scripts/guardrailsEnforce.ts +60 -0
- package/src/react/components/DropdownMenu.spec.tsx +39 -0
- package/src/react/components/DropdownMenu.tsx +37 -5
- package/src/react/components/HoverCard.tsx +3 -1
- package/src/react/components/Popover.spec.tsx +241 -0
- package/src/react/components/Popover.tsx +172 -23
- package/src/react/components/SelectField.tsx +3 -1
- package/src/react/components/TooltipImpl.tsx +3 -1
- package/src/react/hooks/useOutsidePress.spec.tsx +112 -0
- package/src/react/hooks/useOutsidePress.ts +159 -0
- package/src/react/icons.spec.ts +19 -0
- package/src/react/icons.tsx +14 -4
- package/src/react/index.ts +2 -1
- package/src/react/lib/floatingContainer.ts +42 -0
- package/src/react/media/CanvasVideoPlayer.tsx +36 -28
- package/src/styles-areas/media.css +4 -4
- package/src/styles-utilities.css +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt",
|
|
3
|
-
"version": "5.
|
|
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",
|
|
@@ -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
|
|
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={
|
|
90
|
-
<RDrop.Trigger asChild
|
|
91
|
-
|
|
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
|
+
});
|