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
|
@@ -1,14 +1,38 @@
|
|
|
1
1
|
import * as RPopover from '@radix-ui/react-popover';
|
|
2
|
-
import type
|
|
2
|
+
import { type ReactNode, useCallback, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { useOutsidePress } from '../hooks/useOutsidePress.js';
|
|
3
4
|
import { cn } from '../lib/cn.js';
|
|
5
|
+
import { useFloatingContainer } from '../lib/floatingContainer.js';
|
|
4
6
|
import {
|
|
5
7
|
FLOATING_ANIMATION,
|
|
6
8
|
FLOATING_COLLISION_PADDING,
|
|
7
9
|
FLOATING_CONTENT_BASE,
|
|
8
10
|
} from '../lib/floatingStyles.js';
|
|
9
11
|
|
|
12
|
+
/**
|
|
13
|
+
* A point to open a popover at, for a panel that has no trigger of its own — a context
|
|
14
|
+
* menu at the pointer, or a menu one control opens for another.
|
|
15
|
+
*/
|
|
16
|
+
export interface PopoverPoint {
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
/**
|
|
20
|
+
* The control this was opened from, when there is one. A press on it is never
|
|
21
|
+
* "outside": its own `onClick` decides whether the press opens or shuts, so a menu
|
|
22
|
+
* button toggles instead of closing on pointerdown and reopening on click.
|
|
23
|
+
*/
|
|
24
|
+
trigger?: Element | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
10
27
|
export interface PopoverProps {
|
|
11
|
-
|
|
28
|
+
/** The control that opens it. Omit it and pass {@link PopoverProps.anchor} instead. */
|
|
29
|
+
trigger?: ReactNode;
|
|
30
|
+
/**
|
|
31
|
+
* Position the panel against this instead of a trigger: an element, or a point.
|
|
32
|
+
* With no `open` prop, the panel is open exactly while this is non-null, and
|
|
33
|
+
* `onOpenChange(false)` is the caller's cue to clear it.
|
|
34
|
+
*/
|
|
35
|
+
anchor?: Element | PopoverPoint | null;
|
|
12
36
|
children: ReactNode;
|
|
13
37
|
open?: boolean;
|
|
14
38
|
onOpenChange?: (open: boolean) => void;
|
|
@@ -17,19 +41,58 @@ export interface PopoverProps {
|
|
|
17
41
|
sideOffset?: number;
|
|
18
42
|
/** Width of the popover panel. Default: auto (sized to content). */
|
|
19
43
|
width?: string | number;
|
|
20
|
-
/** Show the little arrow pointing at the trigger. Default true. */
|
|
44
|
+
/** Show the little arrow pointing at the trigger. Default true (false when `unstyled`). */
|
|
21
45
|
arrow?: boolean;
|
|
22
46
|
className?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Drop the belt's surface — border, background, shadow, padding, arrow — and keep only
|
|
49
|
+
* the behaviour: positioning, collision flipping, and closing on a tap or click outside,
|
|
50
|
+
* on Escape and on focus leaving. For a panel that belongs to its own visual language
|
|
51
|
+
* (a video player's controls over the picture); `className` then carries the look.
|
|
52
|
+
*/
|
|
53
|
+
unstyled?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* A press outside ONLY closes the panel, and never also lands on what is under it —
|
|
56
|
+
* the way a native menu behaves. Right for a menu over something a stray tap would
|
|
57
|
+
* change (a playing video). Default false: the press closes the panel AND reaches its
|
|
58
|
+
* target, which is right for a panel beside a form.
|
|
59
|
+
*/
|
|
60
|
+
modal?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Render in place rather than in a portal. The portal already follows the full-screen
|
|
63
|
+
* element (see `floatingContainer.ts`), so this is for a panel that must scroll or
|
|
64
|
+
* clip with its container. Default true.
|
|
65
|
+
*/
|
|
66
|
+
portal?: boolean;
|
|
67
|
+
/** Move focus into the panel when it opens. Default true; false keeps it on the trigger. */
|
|
68
|
+
autoFocus?: boolean;
|
|
69
|
+
/** Accessible name of the panel, when no heading inside it names it. */
|
|
70
|
+
label?: string;
|
|
71
|
+
/** ARIA role of the panel. Default `dialog` (Radix's). */
|
|
72
|
+
role?: 'dialog' | 'menu' | 'listbox';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isPoint(anchor: Element | PopoverPoint): anchor is PopoverPoint {
|
|
76
|
+
return typeof (anchor as PopoverPoint).x === 'number' && !('getBoundingClientRect' in anchor);
|
|
23
77
|
}
|
|
24
78
|
|
|
25
79
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
80
|
+
* THE anchored floating panel of the fleet, on Radix Popover. Never clipped by overflow
|
|
81
|
+
* ancestors, auto-flips and shifts to stay on screen, and — the reason it must be the one
|
|
82
|
+
* everybody uses — **it closes when you tap or click anywhere else**, on Escape, and when
|
|
83
|
+
* focus leaves it, on a phone exactly as on a desktop.
|
|
84
|
+
*
|
|
85
|
+
* 🔴 Owner report, 2026-09-27, on an iPhone: flix's subtitle menu, and music's folder
|
|
86
|
+
* menu, stayed open after tapping away. Each was a hand-rolled `{open && <div
|
|
87
|
+
* className="absolute …">}` with no outside-press handling at all, so the bug was in every
|
|
88
|
+
* copy on every screen. The guardrail `no-raw-popover` now flags that shape in every app.
|
|
89
|
+
*
|
|
90
|
+
* Use for menus, pickers, confirmations and inline editors. For an action list prefer
|
|
91
|
+
* {@link DropdownMenu} (keyboard roving built in); for field help use {@link InfoHint}.
|
|
30
92
|
*/
|
|
31
93
|
export function Popover({
|
|
32
94
|
trigger,
|
|
95
|
+
anchor,
|
|
33
96
|
children,
|
|
34
97
|
open,
|
|
35
98
|
onOpenChange,
|
|
@@ -37,25 +100,111 @@ export function Popover({
|
|
|
37
100
|
side = 'bottom',
|
|
38
101
|
sideOffset = 4,
|
|
39
102
|
width,
|
|
40
|
-
arrow
|
|
103
|
+
arrow,
|
|
41
104
|
className,
|
|
105
|
+
unstyled = false,
|
|
106
|
+
modal = false,
|
|
107
|
+
portal = true,
|
|
108
|
+
autoFocus = true,
|
|
109
|
+
label,
|
|
110
|
+
role,
|
|
42
111
|
}: PopoverProps) {
|
|
112
|
+
const container = useFloatingContainer();
|
|
113
|
+
const showArrow = arrow ?? !unstyled;
|
|
114
|
+
|
|
115
|
+
// A point becomes a zero-size virtual element — what Radix's popper measures.
|
|
116
|
+
const virtualRef = useMemo(() => {
|
|
117
|
+
if (!anchor) return null;
|
|
118
|
+
const measure = isPoint(anchor)
|
|
119
|
+
? () => new DOMRect(anchor.x, anchor.y, 0, 0)
|
|
120
|
+
: () => anchor.getBoundingClientRect();
|
|
121
|
+
return { current: { getBoundingClientRect: measure } };
|
|
122
|
+
}, [anchor]);
|
|
123
|
+
const anchorTrigger = anchor ? (isPoint(anchor) ? anchor.trigger : anchor) : null;
|
|
124
|
+
|
|
125
|
+
// Always controlled underneath, so the outside-press fallback below can close an
|
|
126
|
+
// uncontrolled popover too.
|
|
127
|
+
const [ownOpen, setOwnOpen] = useState(false);
|
|
128
|
+
const isOpen = open ?? (anchor !== undefined ? anchor !== null : ownOpen);
|
|
129
|
+
const setOpen = useCallback(
|
|
130
|
+
(next: boolean) => {
|
|
131
|
+
if (open === undefined && anchor === undefined) setOwnOpen(next);
|
|
132
|
+
onOpenChange?.(next);
|
|
133
|
+
},
|
|
134
|
+
[open, anchor, onOpenChange],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
/*
|
|
138
|
+
* 🔴 Radix's own outside-press dismissal is not enough on its own. `@radix-ui/react-popover`
|
|
139
|
+
* (1.1.23) defers it to the `click` after the pointerdown and then SKIPS it when any
|
|
140
|
+
* outside handler stopped that click's propagation ("the interaction was intercepted") —
|
|
141
|
+
* and a video surface, a card or a row routinely does, so a tap on exactly the things
|
|
142
|
+
* most likely to be under a menu left it open. `useOutsidePress` listens in the capture
|
|
143
|
+
* phase, before anything can stop it; when Radix has already closed the panel, its second
|
|
144
|
+
* close is a no-op. Escape and `modal` stay Radix's.
|
|
145
|
+
*/
|
|
146
|
+
// Typed as Radix types its Trigger; with `asChild` it is whatever element `trigger` is.
|
|
147
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
148
|
+
const pressInside = useOutsidePress({
|
|
149
|
+
open: isOpen,
|
|
150
|
+
onDismiss: () => setOpen(false),
|
|
151
|
+
inside: [triggerRef, anchorTrigger],
|
|
152
|
+
escape: false,
|
|
153
|
+
// Under `modal` the page is inert and an iPhone sends no click for a tap on it — see
|
|
154
|
+
// `decideOn` in useOutsidePress.
|
|
155
|
+
decideOn: modal ? 'pointerdown' : 'click',
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const content = (
|
|
159
|
+
<RPopover.Content
|
|
160
|
+
align={align}
|
|
161
|
+
side={side}
|
|
162
|
+
sideOffset={sideOffset}
|
|
163
|
+
collisionPadding={FLOATING_COLLISION_PADDING}
|
|
164
|
+
style={width != null ? { width } : undefined}
|
|
165
|
+
aria-label={label}
|
|
166
|
+
// Spread, never `role={role}`: Radix spreads our props over its own `role: "dialog"`,
|
|
167
|
+
// so an explicit `undefined` would erase the panel's role rather than keep the default.
|
|
168
|
+
{...(role ? { role } : {})}
|
|
169
|
+
onOpenAutoFocus={autoFocus ? undefined : (event) => event.preventDefault()}
|
|
170
|
+
onInteractOutside={(event) => {
|
|
171
|
+
// The anchor's own control decides open/shut with its click — see PopoverPoint.
|
|
172
|
+
if (anchorTrigger?.contains(event.target as Node)) event.preventDefault();
|
|
173
|
+
}}
|
|
174
|
+
// React-tree capture: a press on a nested layer this panel portalled (a Select's
|
|
175
|
+
// list, a sub-menu) is INSIDE even though the DOM puts it elsewhere.
|
|
176
|
+
onClickCapture={pressInside.onClickCapture}
|
|
177
|
+
onPointerDownCapture={pressInside.onPointerDownCapture}
|
|
178
|
+
className={cn(
|
|
179
|
+
unstyled
|
|
180
|
+
? 'z-[var(--z-popover)] outline-none'
|
|
181
|
+
: cn(FLOATING_CONTENT_BASE, 'p-3'),
|
|
182
|
+
FLOATING_ANIMATION,
|
|
183
|
+
className,
|
|
184
|
+
)}
|
|
185
|
+
>
|
|
186
|
+
{children}
|
|
187
|
+
{showArrow && <RPopover.Arrow className='fill-border' />}
|
|
188
|
+
</RPopover.Content>
|
|
189
|
+
);
|
|
190
|
+
|
|
43
191
|
return (
|
|
44
|
-
<RPopover.Root open={
|
|
45
|
-
<RPopover.
|
|
46
|
-
|
|
47
|
-
<RPopover.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
style={width != null ? { width } : undefined}
|
|
53
|
-
className={cn(FLOATING_CONTENT_BASE, FLOATING_ANIMATION, 'p-3', className)}
|
|
54
|
-
>
|
|
55
|
-
{children}
|
|
56
|
-
{arrow && <RPopover.Arrow className='fill-border' />}
|
|
57
|
-
</RPopover.Content>
|
|
58
|
-
</RPopover.Portal>
|
|
192
|
+
<RPopover.Root open={isOpen} onOpenChange={setOpen} modal={modal}>
|
|
193
|
+
{virtualRef ? <RPopover.Anchor virtualRef={virtualRef} /> : null}
|
|
194
|
+
{trigger !== undefined ? (
|
|
195
|
+
<RPopover.Trigger asChild ref={triggerRef}>
|
|
196
|
+
{trigger}
|
|
197
|
+
</RPopover.Trigger>
|
|
198
|
+
) : null}
|
|
199
|
+
{portal ? <RPopover.Portal container={container}>{content}</RPopover.Portal> : content}
|
|
59
200
|
</RPopover.Root>
|
|
60
201
|
);
|
|
61
202
|
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Wrap a control inside a {@link Popover}'s panel so pressing it also closes the panel —
|
|
206
|
+
* for an uncontrolled popover whose items should dismiss it.
|
|
207
|
+
*/
|
|
208
|
+
export function PopoverClose({ children }: { children: ReactNode }) {
|
|
209
|
+
return <RPopover.Close asChild>{children}</RPopover.Close>;
|
|
210
|
+
}
|
|
@@ -2,6 +2,7 @@ import * as RSelect from '@radix-ui/react-select';
|
|
|
2
2
|
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
|
3
3
|
import type { ReactNode } from 'react';
|
|
4
4
|
import { cn } from '../lib/cn.js';
|
|
5
|
+
import { useFloatingContainer } from '../lib/floatingContainer.js';
|
|
5
6
|
import { type ControlSize, fieldControlClass } from '../lib/field.js';
|
|
6
7
|
import {
|
|
7
8
|
FLOATING_ANIMATION,
|
|
@@ -94,6 +95,7 @@ export function SelectField({
|
|
|
94
95
|
const allGroups: SelectGroup[] =
|
|
95
96
|
groups.length > 0 ? groups : options.length > 0 ? [{ options }] : [];
|
|
96
97
|
const empty = allGroups.every((group) => group.options.length === 0);
|
|
98
|
+
const container = useFloatingContainer();
|
|
97
99
|
|
|
98
100
|
return (
|
|
99
101
|
<RSelect.Root
|
|
@@ -120,7 +122,7 @@ export function SelectField({
|
|
|
120
122
|
<ChevronDown className='size-4 shrink-0 opacity-50' />
|
|
121
123
|
</RSelect.Icon>
|
|
122
124
|
</RSelect.Trigger>
|
|
123
|
-
<RSelect.Portal>
|
|
125
|
+
<RSelect.Portal container={container}>
|
|
124
126
|
<RSelect.Content
|
|
125
127
|
position='popper'
|
|
126
128
|
sideOffset={4}
|
|
@@ -2,6 +2,7 @@ import * as RTooltip from '@radix-ui/react-tooltip';
|
|
|
2
2
|
import { cloneElement, isValidElement, useEffect, useRef, useState } 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 { FLOATING_COLLISION_PADDING } from '../lib/floatingStyles.js';
|
|
6
7
|
import { HOVER_HINT_DELAY_MS, HOVER_HINT_INSTANT_MS } from '../lib/hoverIntent.js';
|
|
7
8
|
import type { TooltipArm, TooltipProps } from './Tooltip.js';
|
|
@@ -40,6 +41,7 @@ export default function TooltipImpl({
|
|
|
40
41
|
arm,
|
|
41
42
|
}: TooltipImplProps) {
|
|
42
43
|
const hasHover = useHasHover();
|
|
44
|
+
const container = useFloatingContainer();
|
|
43
45
|
const [open, setOpen] = useState(false);
|
|
44
46
|
const resolvedSide = placement ?? side ?? 'top';
|
|
45
47
|
const triggerRef = useRef<HTMLElement | null>(null);
|
|
@@ -212,7 +214,7 @@ export default function TooltipImpl({
|
|
|
212
214
|
}, [disabled]);
|
|
213
215
|
|
|
214
216
|
const bubble = (
|
|
215
|
-
<RTooltip.Portal>
|
|
217
|
+
<RTooltip.Portal container={container}>
|
|
216
218
|
<RTooltip.Content
|
|
217
219
|
data-cb-tooltip=''
|
|
218
220
|
side={resolvedSide}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { type ButtonHTMLAttributes, type Ref, useRef, useState } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { useOutsidePress } from './useOutsidePress.js';
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
/** The fixtures' plain control — Popover has to work with ANY trigger element. */
|
|
10
|
+
function B(props: ButtonHTMLAttributes<HTMLButtonElement> & { ref?: Ref<HTMLButtonElement> }) {
|
|
11
|
+
return (
|
|
12
|
+
<button
|
|
13
|
+
// guardrails-ignore no-raw-action-button: a spec fixture — the point is that the
|
|
14
|
+
// behaviour holds for whatever element an app hands it.
|
|
15
|
+
type='button'
|
|
16
|
+
{...props}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The shape music's folder tree has (2026-09-27): a panel that is always rendered and only
|
|
23
|
+
* becomes an overlay when a toggle says so — so it cannot be a Popover, and needs the same
|
|
24
|
+
* tap-away behaviour anyway.
|
|
25
|
+
*/
|
|
26
|
+
function Panel({ modal = false, onRow = () => {} }: { modal?: boolean; onRow?: () => void }) {
|
|
27
|
+
const [open, setOpen] = useState(false);
|
|
28
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
const toggleRef = useRef<HTMLButtonElement>(null);
|
|
30
|
+
const [nested, setNested] = useState(false);
|
|
31
|
+
const inside = useOutsidePress({
|
|
32
|
+
open,
|
|
33
|
+
onDismiss: () => setOpen(false),
|
|
34
|
+
inside: [panelRef, toggleRef],
|
|
35
|
+
modal,
|
|
36
|
+
});
|
|
37
|
+
return (
|
|
38
|
+
<div role='dialog' aria-label='page modal'>
|
|
39
|
+
<B onClick={onRow}>
|
|
40
|
+
row
|
|
41
|
+
</B>
|
|
42
|
+
<B ref={toggleRef} onClick={() => setOpen((was) => !was)}>
|
|
43
|
+
folders
|
|
44
|
+
</B>
|
|
45
|
+
<div ref={panelRef} {...inside} data-open={open} data-testid='panel'>
|
|
46
|
+
<B onClick={() => setNested(true)}>
|
|
47
|
+
open nested
|
|
48
|
+
</B>
|
|
49
|
+
{nested
|
|
50
|
+
? createPortal(
|
|
51
|
+
<div role='menu'>
|
|
52
|
+
<B>nested item</B>
|
|
53
|
+
</div>,
|
|
54
|
+
document.body,
|
|
55
|
+
)
|
|
56
|
+
: null}
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const isOpen = () => screen.getByTestId('panel').dataset.open === 'true';
|
|
63
|
+
const press = async (el: Element) => {
|
|
64
|
+
fireEvent.click(el);
|
|
65
|
+
await act(() => new Promise((r) => setTimeout(r, 5)));
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
describe('useOutsidePress', () => {
|
|
69
|
+
test('a press anywhere else closes it — even inside the dialog the panel itself lives in', async () => {
|
|
70
|
+
render(<Panel />);
|
|
71
|
+
await press(screen.getByRole('button', { name: 'folders' }));
|
|
72
|
+
expect(isOpen()).toBe(true);
|
|
73
|
+
await press(screen.getByRole('button', { name: 'row' }));
|
|
74
|
+
expect(isOpen()).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('the toggle toggles, rather than shutting and reopening', async () => {
|
|
78
|
+
render(<Panel />);
|
|
79
|
+
const toggle = screen.getByRole('button', { name: 'folders' });
|
|
80
|
+
await press(toggle);
|
|
81
|
+
await press(toggle);
|
|
82
|
+
expect(isOpen()).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a press inside, or on a layer the panel portalled, keeps it open', async () => {
|
|
86
|
+
render(<Panel />);
|
|
87
|
+
await press(screen.getByRole('button', { name: 'folders' }));
|
|
88
|
+
await press(screen.getByRole('button', { name: 'open nested' }));
|
|
89
|
+
expect(isOpen()).toBe(true);
|
|
90
|
+
await press(screen.getByRole('button', { name: 'nested item' }));
|
|
91
|
+
expect(isOpen()).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('Escape closes it', async () => {
|
|
95
|
+
render(<Panel />);
|
|
96
|
+
await press(screen.getByRole('button', { name: 'folders' }));
|
|
97
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
98
|
+
expect(isOpen()).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('modal: the tap that closes it does not also reach the row under it', async () => {
|
|
102
|
+
const onRow = mock(() => {});
|
|
103
|
+
render(<Panel modal onRow={onRow} />);
|
|
104
|
+
await press(screen.getByRole('button', { name: 'folders' }));
|
|
105
|
+
await press(screen.getByRole('button', { name: 'row' }));
|
|
106
|
+
expect(isOpen()).toBe(false);
|
|
107
|
+
expect(onRow).not.toHaveBeenCalled();
|
|
108
|
+
// Closed, the row works again.
|
|
109
|
+
await press(screen.getByRole('button', { name: 'row' }));
|
|
110
|
+
expect(onRow).toHaveBeenCalledTimes(1);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
type InsideTarget = { current: Element | null } | Element | null | undefined;
|
|
4
|
+
|
|
5
|
+
export interface UseOutsidePressOptions {
|
|
6
|
+
/** Only listens while true. */
|
|
7
|
+
open: boolean;
|
|
8
|
+
/** Called once per press outside (and on Escape, unless `escape` is false). */
|
|
9
|
+
onDismiss: () => void;
|
|
10
|
+
/**
|
|
11
|
+
* What counts as inside: the panel, and the control that toggles it — a press on the
|
|
12
|
+
* toggle is left to its own `onClick`, so it toggles instead of shutting and reopening.
|
|
13
|
+
*/
|
|
14
|
+
inside?: readonly InsideTarget[];
|
|
15
|
+
/** Also dismiss on Escape. Default true. */
|
|
16
|
+
escape?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* The outside press ONLY dismisses and never also reaches what is under it — the way a
|
|
19
|
+
* native menu behaves. For a panel laid over something a stray tap would change (a list
|
|
20
|
+
* whose rows play on tap). Default false.
|
|
21
|
+
*/
|
|
22
|
+
modal?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Decide on the `pointerdown` rather than the `click`. Only for a panel that has made the
|
|
25
|
+
* page under it inert (`Popover`'s `modal`, which sets `pointer-events: none` on body).
|
|
26
|
+
*
|
|
27
|
+
* 🔴 Measured 2026-09-27 in Playwright's iPhone 15 WebKit: with the page inert, a tap
|
|
28
|
+
* outside hits `<html>`, which iOS does not consider clickable, so NO `click` is ever
|
|
29
|
+
* sent — the menu stayed open under the owner's finger, exactly as reported. Deciding on
|
|
30
|
+
* the pointerdown closes it; the `click` that same press may still produce is swallowed,
|
|
31
|
+
* or it would land on whatever the closed menu uncovered (the picture, which pauses).
|
|
32
|
+
*/
|
|
33
|
+
decideOn?: 'click' | 'pointerdown';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A floating layer another component portalled — a press there belongs to that layer. */
|
|
37
|
+
const FLOATING_LAYER =
|
|
38
|
+
'[data-radix-popper-content-wrapper],[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"]';
|
|
39
|
+
|
|
40
|
+
function resolve(target: InsideTarget): Element | null {
|
|
41
|
+
return (target && 'current' in target ? target.current : target) ?? null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Close a panel when the reader presses anywhere else — the ONE implementation of it in the
|
|
46
|
+
* fleet. `Popover` uses it; use it directly only for a panel that cannot be a `Popover`: a
|
|
47
|
+
* sheet, or a panel that is always rendered and only BECOMES an overlay at one width.
|
|
48
|
+
*
|
|
49
|
+
* 🔴 Owner report, 2026-09-27, on an iPhone: menus that stayed open after tapping away.
|
|
50
|
+
* Three things make an outside-press handler right, and every hand-rolled one got at least
|
|
51
|
+
* one wrong:
|
|
52
|
+
*
|
|
53
|
+
* · **`click`, in the CAPTURE phase on `document`.** Capture runs before any handler can
|
|
54
|
+
* `stopPropagation()`, and a tap on a video surface, a card or a row routinely does —
|
|
55
|
+
* Radix Popover's own dismissal is skipped in exactly that case, which is why `Popover`
|
|
56
|
+
* needs this too. `click` rather than `pointerdown`, so a finger that lands outside to
|
|
57
|
+
* SCROLL does not close the panel: a scroll never produces a click.
|
|
58
|
+
* · **decided after the event has finished dispatching**, so a press inside the panel's
|
|
59
|
+
* React tree — a nested layer it portalled elsewhere in the DOM — is seen as inside
|
|
60
|
+
* (spread the returned props on the panel), and a press on another floating layer (a
|
|
61
|
+
* menu or dialog opened from the panel) belongs to that layer.
|
|
62
|
+
* · **the toggle is inside**, or pressing an open menu's own button shuts it and reopens
|
|
63
|
+
* it a frame later — the "menu that ignores the second press" desk hit on 2026-09-07.
|
|
64
|
+
*/
|
|
65
|
+
export function useOutsidePress({
|
|
66
|
+
open,
|
|
67
|
+
onDismiss,
|
|
68
|
+
inside = [],
|
|
69
|
+
escape: closeOnEscape = true,
|
|
70
|
+
modal = false,
|
|
71
|
+
decideOn = 'click',
|
|
72
|
+
}: UseOutsidePressOptions): { onClickCapture: () => void; onPointerDownCapture: () => void } {
|
|
73
|
+
const pressInsideRef = useRef(false);
|
|
74
|
+
// Latest values without re-subscribing on every render — a listener torn down mid-gesture
|
|
75
|
+
// would miss the very click it exists for.
|
|
76
|
+
const latest = useRef({ onDismiss, inside, modal });
|
|
77
|
+
latest.current = { onDismiss, inside, modal };
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!open) return;
|
|
81
|
+
const timers = new Set<ReturnType<typeof setTimeout>>();
|
|
82
|
+
const isInside = (node: Node) =>
|
|
83
|
+
latest.current.inside.some((target) => Boolean(resolve(target)?.contains(node)));
|
|
84
|
+
/**
|
|
85
|
+
* A press on a floating layer that does NOT hold this panel's own controls — a menu or
|
|
86
|
+
* dialog opened FROM the panel — belongs to that layer. A layer that does hold them (the
|
|
87
|
+
* Modal this popover was opened inside) is just the page, and a press there is outside.
|
|
88
|
+
*/
|
|
89
|
+
const onAnotherLayer = (node: Node) => {
|
|
90
|
+
const layer = node instanceof Element ? node.closest(FLOATING_LAYER) : node.parentElement?.closest(FLOATING_LAYER);
|
|
91
|
+
if (!layer?.isConnected) return false;
|
|
92
|
+
return !latest.current.inside.some((target) => {
|
|
93
|
+
const element = resolve(target);
|
|
94
|
+
return element ? layer.contains(element) : false;
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Decide once the event has finished dispatching — see the header. */
|
|
99
|
+
const decideLater = (target: Node | null) => {
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
timers.delete(timer);
|
|
102
|
+
if (pressInsideRef.current || !target) return;
|
|
103
|
+
if (isInside(target)) return;
|
|
104
|
+
if (onAnotherLayer(target)) return;
|
|
105
|
+
latest.current.onDismiss();
|
|
106
|
+
}, 0);
|
|
107
|
+
timers.add(timer);
|
|
108
|
+
};
|
|
109
|
+
const outside = (target: Node | null) => Boolean(target) && !isInside(target as Node) && !onAnotherLayer(target as Node);
|
|
110
|
+
|
|
111
|
+
// `pointerdown` mode: the click the same press produces is not a second decision — it is
|
|
112
|
+
// swallowed, until the next press or a second later, whichever comes first.
|
|
113
|
+
let swallowUntil = 0;
|
|
114
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
115
|
+
pressInsideRef.current = false;
|
|
116
|
+
swallowUntil = 0;
|
|
117
|
+
const target = event.target instanceof Node ? event.target : null;
|
|
118
|
+
if (outside(target)) swallowUntil = Date.now() + 1000;
|
|
119
|
+
decideLater(target);
|
|
120
|
+
};
|
|
121
|
+
const onClick = (event: MouseEvent) => {
|
|
122
|
+
const target = event.target instanceof Node ? event.target : null;
|
|
123
|
+
if (decideOn === 'pointerdown') {
|
|
124
|
+
if (Date.now() < swallowUntil) {
|
|
125
|
+
swallowUntil = 0;
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
event.stopPropagation();
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
pressInsideRef.current = false;
|
|
132
|
+
if (latest.current.modal && outside(target)) {
|
|
133
|
+
// Decidable now for a DOM-inside press; a React-tree-inside press would have been
|
|
134
|
+
// swallowed too, so a modal panel's portalled layers must live inside `inside`.
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
event.stopPropagation();
|
|
137
|
+
}
|
|
138
|
+
decideLater(target);
|
|
139
|
+
};
|
|
140
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
141
|
+
if (event.key === 'Escape') latest.current.onDismiss();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
document.addEventListener('click', onClick, true);
|
|
145
|
+
if (decideOn === 'pointerdown') document.addEventListener('pointerdown', onPointerDown, true);
|
|
146
|
+
if (closeOnEscape) document.addEventListener('keydown', onKeyDown);
|
|
147
|
+
return () => {
|
|
148
|
+
document.removeEventListener('click', onClick, true);
|
|
149
|
+
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
150
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
151
|
+
for (const timer of timers) clearTimeout(timer);
|
|
152
|
+
};
|
|
153
|
+
}, [open, closeOnEscape, decideOn]);
|
|
154
|
+
|
|
155
|
+
const markInside = () => {
|
|
156
|
+
pressInsideRef.current = true;
|
|
157
|
+
};
|
|
158
|
+
return { onClickCapture: markInside, onPointerDownCapture: markInside };
|
|
159
|
+
}
|
package/src/react/icons.spec.ts
CHANGED
|
@@ -42,3 +42,22 @@ describe('DynamicIcon fallback', () => {
|
|
|
42
42
|
expect(container.innerHTML).toBe('');
|
|
43
43
|
});
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
describe('DynamicIcon with no iconMap', () => {
|
|
47
|
+
// 🔴 vault rendered `<DynamicIcon token="unlock" />` with no map, and every token fell to the
|
|
48
|
+
// letter fallback — "U W S" in its header where three icons belonged (2026-09-28).
|
|
49
|
+
test('draws the default icon, never the letter fallback', () => {
|
|
50
|
+
for (const token of ['unlock', 'wand', 'settings', 'search'] as const) {
|
|
51
|
+
const { container } = render(DynamicIcon({ token }));
|
|
52
|
+
expect(container.querySelector('[data-icon]'), `${token} fell back to a letter`).toBeNull();
|
|
53
|
+
expect(container.querySelector('svg'), `${token} drew no icon`).not.toBeNull();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('an explicit map that lacks the token still falls back to its letter', () => {
|
|
58
|
+
const icons = createIconMapRegistry();
|
|
59
|
+
icons.clear();
|
|
60
|
+
const { container } = render(DynamicIcon({ token: 'settings', iconMap: icons }));
|
|
61
|
+
expect(container.querySelector('[data-icon="settings"]')?.textContent).toBe('s');
|
|
62
|
+
});
|
|
63
|
+
});
|
package/src/react/icons.tsx
CHANGED
|
@@ -232,7 +232,11 @@ export function createIconMapRegistry(): IconMapRegistry {
|
|
|
232
232
|
|
|
233
233
|
export interface DynamicIconProps {
|
|
234
234
|
token?: IconToken;
|
|
235
|
-
/**
|
|
235
|
+
/**
|
|
236
|
+
* The icon map to resolve `token` against — typically `runtime.icons`. Omitted, the token
|
|
237
|
+
* resolves against `defaultIconMap`, so a plain `<DynamicIcon token="settings" />` draws the
|
|
238
|
+
* settings icon.
|
|
239
|
+
*/
|
|
236
240
|
iconMap?: IconMapRegistry | null;
|
|
237
241
|
className?: string;
|
|
238
242
|
}
|
|
@@ -249,12 +253,18 @@ export interface DynamicIconProps {
|
|
|
249
253
|
* element and visually overflows into neighboring content instead of
|
|
250
254
|
* respecting the icon's slot.
|
|
251
255
|
*
|
|
252
|
-
* The `iconMap` is typically the `runtime.icons` registry from the host shell
|
|
253
|
-
*
|
|
256
|
+
* The `iconMap` is typically the `runtime.icons` registry from the host shell.
|
|
257
|
+
*
|
|
258
|
+
* 🔴 Omitted, it is `defaultIconMap` — NOT "nothing registered". Until 5.15.1 a missing
|
|
259
|
+
* `iconMap` resolved every token to the letter fallback, so vault's header, which renders
|
|
260
|
+
* `<DynamicIcon token="unlock" />` and its neighbours with no map, painted the letters
|
|
261
|
+
* "U W S" where its lock, generator and settings icons belonged, on every screen, from the
|
|
262
|
+
* day it adopted the component. A registry that was never passed is not a registry that was
|
|
263
|
+
* cleared; only an explicit map may leave a token unresolved.
|
|
254
264
|
*/
|
|
255
265
|
export function DynamicIcon({ token, iconMap, className }: DynamicIconProps) {
|
|
256
266
|
if (!token) return null;
|
|
257
|
-
const Icon = iconMap
|
|
267
|
+
const Icon = iconMap ? iconMap.get(token) : defaultIconMap[token];
|
|
258
268
|
if (!Icon)
|
|
259
269
|
return (
|
|
260
270
|
<Tooltip content={token}>
|
package/src/react/index.ts
CHANGED
|
@@ -429,7 +429,7 @@ export {
|
|
|
429
429
|
export { PathText, type PathTextProps } from './components/PathText.js';
|
|
430
430
|
export { TruncatedText, type TruncatedTextProps } from './components/TruncatedText.js';
|
|
431
431
|
export { PluginCard, type PluginCardProps } from './components/PluginCard.js';
|
|
432
|
-
export { Popover, type PopoverProps } from './components/Popover.js';
|
|
432
|
+
export { Popover, PopoverClose, type PopoverPoint, type PopoverProps } from './components/Popover.js';
|
|
433
433
|
export {
|
|
434
434
|
Progress,
|
|
435
435
|
type ProgressProps,
|
|
@@ -659,6 +659,7 @@ export {
|
|
|
659
659
|
useDragReorder,
|
|
660
660
|
} from './hooks/useDragReorder.js';
|
|
661
661
|
export { useEscapeKey } from './hooks/useEscapeKey.js';
|
|
662
|
+
export { type UseOutsidePressOptions, useOutsidePress } from './hooks/useOutsidePress.js';
|
|
662
663
|
export { useHasHover } from './hooks/useHasHover.js';
|
|
663
664
|
export { useKeyboardInset } from './hooks/useKeyboardInset.js';
|
|
664
665
|
export { useKeyDown } from './hooks/useKeyDown.js';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a portalled floating panel (Popover, DropdownMenu) mounts: the element that is
|
|
5
|
+
* full screen right now, or `document.body` when nothing is.
|
|
6
|
+
*
|
|
7
|
+
* 🔴 While an element is full screen the browser paints ONLY that element's subtree. A
|
|
8
|
+
* panel portalled to `document.body` is still in the DOM, still focusable and still
|
|
9
|
+
* "open" — and invisible. That is why every video player in the fleet hand-rolled its
|
|
10
|
+
* own in-place menu instead of using the belt's (flix's subtitle, audio and speed menus;
|
|
11
|
+
* the belt's own CanvasVideoPlayer speed menu), and a hand-rolled menu is the one that
|
|
12
|
+
* forgets to close when you tap away from it (owner report, 2026-09-27, on an iPhone).
|
|
13
|
+
* Following the full-screen element is what lets those menus be the shared one.
|
|
14
|
+
*
|
|
15
|
+
* Returns `undefined` for "the default" (Radix portals to body) so a page that never goes
|
|
16
|
+
* full screen renders exactly what it did before this existed.
|
|
17
|
+
*/
|
|
18
|
+
export function fullscreenElement(): Element | null {
|
|
19
|
+
if (typeof document === 'undefined') return null;
|
|
20
|
+
const doc = document as Document & { webkitFullscreenElement?: Element | null };
|
|
21
|
+
return doc.fullscreenElement ?? doc.webkitFullscreenElement ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const EVENTS = ['fullscreenchange', 'webkitfullscreenchange'] as const;
|
|
25
|
+
|
|
26
|
+
function subscribe(onChange: () => void): () => void {
|
|
27
|
+
for (const name of EVENTS) document.addEventListener(name, onChange);
|
|
28
|
+
return () => {
|
|
29
|
+
for (const name of EVENTS) document.removeEventListener(name, onChange);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The portal container for a floating panel — see {@link fullscreenElement}. */
|
|
34
|
+
export function useFloatingContainer(): Element | undefined {
|
|
35
|
+
return (
|
|
36
|
+
useSyncExternalStore(
|
|
37
|
+
subscribe,
|
|
38
|
+
fullscreenElement,
|
|
39
|
+
() => null,
|
|
40
|
+
) ?? undefined
|
|
41
|
+
);
|
|
42
|
+
}
|