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
@@ -1,14 +1,38 @@
1
1
  import * as RPopover from '@radix-ui/react-popover';
2
- import type { ReactNode } from 'react';
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
- trigger: ReactNode;
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
- * Portal-based popover on Radix Popover. General-purpose anchored panel — never
27
- * clipped by overflow ancestors, auto-flips + shifts to stay on-screen. Use for
28
- * confirmations, color/date pickers, inline editors, and grouped item pickers.
29
- * For action menus use {@link DropdownMenu}; for field help use {@link InfoHint}.
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 = true,
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={open} onOpenChange={onOpenChange}>
45
- <RPopover.Trigger asChild>{trigger}</RPopover.Trigger>
46
- <RPopover.Portal>
47
- <RPopover.Content
48
- align={align}
49
- side={side}
50
- sideOffset={sideOffset}
51
- collisionPadding={FLOATING_COLLISION_PADDING}
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
+ }
@@ -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
+ });
@@ -232,7 +232,11 @@ export function createIconMapRegistry(): IconMapRegistry {
232
232
 
233
233
  export interface DynamicIconProps {
234
234
  token?: IconToken;
235
- /** The icon map to resolve `token` against — typically `runtime.icons`. */
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
- * pass it explicitly so this component stays framework/runtime-agnostic.
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?.get(token);
267
+ const Icon = iconMap ? iconMap.get(token) : defaultIconMap[token];
258
268
  if (!Icon)
259
269
  return (
260
270
  <Tooltip content={token}>
@@ -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
+ }