@podoba/react 0.0.1

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/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # @podoba/react
2
+
3
+ The universal component library — React Aria Components + Tailwind, composed with `uic`.
4
+
5
+ ```tsx
6
+ import { Button, Dialog, Select } from "@podoba/react";
7
+ ```
8
+
9
+ Requires the token CSS + the Tailwind preset in the consuming app:
10
+
11
+ ```ts
12
+ // app root
13
+ import "@podoba/tokens/variables.css";
14
+ // tailwind.config.ts
15
+ import podobaPreset from "@podoba/tailwind";
16
+ export default { presets: [podobaPreset], content: ["./src/**/*.{ts,tsx}", "./node_modules/@podoba/react/src/**/*.{ts,tsx}"] };
17
+ ```
18
+
19
+ > Note the second `content` glob: because `@podoba/react` ships class strings (not
20
+ > compiled CSS), the consuming app's Tailwind must scan podoba's source so its utilities
21
+ > are generated.
22
+
23
+ Scope is **atomic primitives + layout only**. GS product patterns (schema renderer,
24
+ delivery/approval modals, brand page header) live in GS and consume this. See
25
+ [../../EXTRACTION.md](../../EXTRACTION.md).
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@podoba/react",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "files": ["src"],
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "scripts": {
16
+ "typecheck": "tsc --build"
17
+ },
18
+ "dependencies": {
19
+ "@podoba/tokens": "workspace:*",
20
+ "@podoba/tailwind": "workspace:*",
21
+ "react-aria-components": "catalog:ui",
22
+ "class-variance-authority": "catalog:ui",
23
+ "clsx": "catalog:ui",
24
+ "tailwind-merge": "catalog:ui",
25
+ "@radix-ui/react-slot": "catalog:ui"
26
+ },
27
+ "peerDependencies": {
28
+ "react": ">=19",
29
+ "react-dom": ">=19"
30
+ },
31
+ "devDependencies": {
32
+ "react": "catalog:ui",
33
+ "react-dom": "catalog:ui",
34
+ "@types/react": "catalog:ui",
35
+ "@types/react-dom": "catalog:ui",
36
+ "typescript": "catalog:",
37
+ "@types/bun": "catalog:"
38
+ }
39
+ }
@@ -0,0 +1,53 @@
1
+ import { Button as RACButton, type ButtonProps as RACButtonProps } from 'react-aria-components'
2
+ import { uic } from '../utils/uic'
3
+
4
+ /**
5
+ * Button — management SPA action button.
6
+ *
7
+ * Built on React Aria Components `Button` (full keyboard + ARIA + press
8
+ * handling for free) wrapped with `uic` for type-safe Tailwind variants.
9
+ */
10
+ export const Button = uic(RACButton, {
11
+ displayName: 'Button',
12
+ // gs `Button.module.scss`: pill radius, FIXED 13px / weight-400 text (`text-compact`)
13
+ // on every size — size variants change PADDING ONLY, never the text scale. 200ms
14
+ // ease-in-out transition.
15
+ baseClass:
16
+ 'inline-flex items-center justify-center gap-2 rounded-full text-compact leading-4 transition-all duration-200 ease-in-out ' +
17
+ 'outline-none data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2 ' +
18
+ 'data-[disabled]:opacity-50 data-[disabled]:pointer-events-none ' +
19
+ 'data-[pending]:opacity-70 data-[pending]:cursor-progress',
20
+ variants: {
21
+ variant: {
22
+ // primary (gs `_primary`): base neutral-400 (#242423 = surface-inverted) →
23
+ // hover neutral-600 (#333333) + `0 0 5px rgba(0,0,0,.5)` shadow → active back
24
+ // to neutral-400 (surface-inverted, no shadow).
25
+ primary:
26
+ 'bg-surface-inverted text-fg-inverted hover:bg-neutral-600 hover:shadow-[0_0_5px_0_rgba(0,0,0,0.5)] data-[pressed]:bg-surface-inverted data-[pressed]:shadow-none',
27
+ // secondary: #f7f6f2 (surface-card) + 1px #eceae1 (border); hover bg #eceae1
28
+ // (surface-muted) + border #aba89c (fg-subtle); active bg #eceae1.
29
+ secondary:
30
+ 'bg-surface-card text-fg border border-border hover:bg-surface-muted hover:border-fg-subtle data-[pressed]:bg-surface-muted',
31
+ // ghost: transparent → hover/active #eceae1 (surface-muted).
32
+ ghost: 'bg-transparent text-fg hover:bg-surface-muted data-[pressed]:bg-surface-muted',
33
+ // destructive: #ef4444 → danger token; hover/active dim via opacity (gs parity).
34
+ destructive: 'bg-danger text-danger-fg hover:opacity-90 data-[pressed]:opacity-80',
35
+ },
36
+ size: {
37
+ // gs sizes: PADDING ONLY (spacing-2/4, 3/6, 4/8) — text is a fixed 13px/400
38
+ // from the base, never a size-scaled ramp.
39
+ sm: 'py-2 px-4',
40
+ md: 'py-3 px-6',
41
+ lg: 'py-4 px-8',
42
+ },
43
+ },
44
+ defaultVariants: {
45
+ variant: 'primary',
46
+ size: 'md',
47
+ },
48
+ })
49
+
50
+ export type ButtonProps = RACButtonProps & {
51
+ variant?: 'primary' | 'secondary' | 'ghost' | 'destructive'
52
+ size?: 'sm' | 'md' | 'lg'
53
+ }
@@ -0,0 +1,64 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Checkbox as RACCheckbox, type CheckboxProps as RACCheckboxProps } from 'react-aria-components'
3
+
4
+ /**
5
+ * Checkbox — labelled boolean control with indeterminate support.
6
+ *
7
+ * Ported 1:1 from gs-platform's `Checkbox` (20px box, 2px border, checked =
8
+ * filled `fg` with a white check, 200ms transition). Built on React Aria
9
+ * Components `Checkbox`, which renders the `<label>` wrapper + hidden native
10
+ * input and exposes `isSelected` / `isIndeterminate` via its render prop and
11
+ * `data-*` attributes (keyboard + ARIA for free). The visual box is a styled
12
+ * `<span>` driven by `group-data-[…]` hooks. Styling = Tailwind + `@app/tokens`.
13
+ */
14
+ const checkIcon = (
15
+ <svg viewBox="0 0 14 14" fill="none" className="h-3.5 w-3.5" aria-hidden="true">
16
+ <path
17
+ d="M11.6666 3.5L5.24998 9.91667L2.33331 7"
18
+ stroke="currentColor"
19
+ strokeWidth="2"
20
+ strokeLinecap="round"
21
+ strokeLinejoin="round"
22
+ />
23
+ </svg>
24
+ )
25
+
26
+ const dashIcon = (
27
+ <svg viewBox="0 0 14 14" fill="none" className="h-3.5 w-3.5" aria-hidden="true">
28
+ <path d="M3 7H11" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
29
+ </svg>
30
+ )
31
+
32
+ export type CheckboxProps = Omit<RACCheckboxProps, 'children'> & {
33
+ /** Visible label rendered next to the box. */
34
+ children?: ReactNode
35
+ }
36
+
37
+ export const Checkbox = ({ children, ...props }: CheckboxProps) => (
38
+ <RACCheckbox
39
+ {...props}
40
+ className="group flex cursor-pointer select-none items-center gap-3 text-sm text-fg data-[disabled]:cursor-not-allowed"
41
+ >
42
+ {({ isSelected, isIndeterminate }) => (
43
+ <>
44
+ {/* gs token map: border #eceae1 → border · hover #aba89c → fg-subtle ·
45
+ checked fill #0d0d0d → fg · check #ffffff → fg-inverted · error → danger. */}
46
+ <span
47
+ className={
48
+ 'flex h-5 w-5 shrink-0 items-center justify-center rounded-sm border-2 border-border bg-surface text-fg-inverted ' +
49
+ 'transition-all duration-200 ' +
50
+ 'group-data-[hovered]:border-fg-subtle ' +
51
+ 'group-data-[selected]:border-fg group-data-[selected]:bg-fg ' +
52
+ 'group-data-[indeterminate]:border-fg group-data-[indeterminate]:bg-fg ' +
53
+ 'group-data-[focus-visible]:ring-2 group-data-[focus-visible]:ring-ring group-data-[focus-visible]:ring-offset-2 ' +
54
+ 'group-data-[invalid]:border-danger ' +
55
+ 'group-data-[disabled]:opacity-50'
56
+ }
57
+ >
58
+ {isIndeterminate ? dashIcon : isSelected ? checkIcon : null}
59
+ </span>
60
+ {children}
61
+ </>
62
+ )}
63
+ </RACCheckbox>
64
+ )
@@ -0,0 +1,397 @@
1
+ import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
2
+ import {
3
+ Header,
4
+ Menu as RACMenu,
5
+ MenuItem as RACMenuItem,
6
+ MenuSection as RACMenuSection,
7
+ Popover as RACPopover,
8
+ } from 'react-aria-components'
9
+ import { uic } from '../utils/uic'
10
+
11
+ /**
12
+ * ContextMenu — right-click action menu (port of gs-platform `ContextActionPanel`).
13
+ *
14
+ * gs-platform renders a fixed dark panel of grouped action buttons at the cursor,
15
+ * hand-rolling outside-click / Escape / viewport-clamping (its `EDGE_GAP = 8`). We
16
+ * re-implement on a React Aria standalone `Popover` + `Menu`, which provides the
17
+ * WAI-ARIA menu pattern for free: roving focus, arrow / Home / End navigation,
18
+ * type-ahead, Escape-to-close, outside-press dismissal, focus restoration and —
19
+ * crucially — viewport-aware collision handling (the popover flips / shifts to stay
20
+ * on-screen, replacing the manual edge clamp). The popover is anchored to a
21
+ * zero-size element placed at the click coordinates.
22
+ *
23
+ * Two ways to drive it, sharing one renderer:
24
+ * - **Controlled** (the documented API): pass `isOpen` + `position` + `onClose`
25
+ * and own the open state yourself — pair with {@link useContextMenu} so a
26
+ * surface can do `<div onContextMenu={menu.open} />` + `<ContextMenu
27
+ * {...menu.props} groups={…} />`. Renders nothing while closed or when every
28
+ * group is empty / hidden.
29
+ * - **Wrapper**: wrap a target with `<ContextMenu groups={…}>{target}</ContextMenu>`;
30
+ * the right-click on the wrapper opens the menu and the component owns the state.
31
+ * `groups` may be a {@link ContextMenuGroupsResolver} for contextual menus.
32
+ *
33
+ * Presentational only (hard rule #1) — no app imports.
34
+ */
35
+
36
+ export interface ContextMenuItem {
37
+ /** Stable identity + React key. Either `id` (preferred) or `key` must be set. */
38
+ id?: string
39
+ /** @deprecated alias of `id`, kept for the wrapper-API callers. */
40
+ key?: string
41
+ label: ReactNode
42
+ icon?: ReactNode
43
+ /**
44
+ * Trailing pill (e.g. a count or status). When omitted, a disabled item falls
45
+ * back to the `soonLabel` pill so unbuilt actions read as upcoming.
46
+ */
47
+ badge?: ReactNode
48
+ disabled?: boolean
49
+ /** Styled with the danger token (red text). */
50
+ destructive?: boolean
51
+ /** Filtered out before render. */
52
+ hidden?: boolean
53
+ /** Invoked on activation; the menu then closes. */
54
+ onSelect?: () => void
55
+ /** @deprecated alias of `onSelect`, kept for the wrapper-API callers. */
56
+ onAction?: () => void
57
+ }
58
+
59
+ export interface ContextMenuGroup {
60
+ id?: string
61
+ /** @deprecated alias of `id`, kept for the wrapper-API callers. */
62
+ key?: string
63
+ label?: ReactNode
64
+ items: ContextMenuItem[]
65
+ }
66
+
67
+ /**
68
+ * Resolves the menu groups for a given right-click. Receives the element that was
69
+ * actually clicked, so wrapper-API callers can prepend item-specific actions (the
70
+ * "context" in context menu) ahead of the shared groups — e.g. read
71
+ * `event.target.closest('[data-context-item]')` to learn which card / row the
72
+ * cursor was over.
73
+ */
74
+ export type ContextMenuGroupsResolver = (ctx: { target: HTMLElement }) => ContextMenuGroup[]
75
+
76
+ /** Controlled API — the shape {@link useContextMenu} drives. */
77
+ export interface ContextMenuProps {
78
+ groups: ContextMenuGroup[]
79
+ isOpen: boolean
80
+ position: { x: number; y: number } | null
81
+ onClose: () => void
82
+ /** Names the menu for assistive tech. Defaults to "Actions". */
83
+ 'aria-label'?: string
84
+ /** Label shown on the fallback pill of disabled items. Defaults to "Soon". */
85
+ soonLabel?: ReactNode
86
+ className?: string
87
+ }
88
+
89
+ /** Wrapper API — wrap a target; the component owns the open state. */
90
+ interface ContextMenuWrapperProps {
91
+ groups: ContextMenuGroup[] | ContextMenuGroupsResolver
92
+ children: ReactNode
93
+ soonLabel?: ReactNode
94
+ 'aria-label'?: string
95
+ className?: string
96
+ }
97
+
98
+ const itemId = (item: ContextMenuItem, fallback: number): string => item.id ?? item.key ?? String(fallback)
99
+ const groupId = (group: ContextMenuGroup, fallback: number): string => group.id ?? group.key ?? String(fallback)
100
+
101
+ /** Drop hidden items, then drop groups that end up empty. */
102
+ const visibleGroupsOf = (groups: ContextMenuGroup[]): ContextMenuGroup[] =>
103
+ groups
104
+ .map((group) => ({ ...group, items: group.items.filter((item) => !item.hidden) }))
105
+ .filter((group) => group.items.length > 0)
106
+
107
+ const MenuItem = uic(RACMenuItem, {
108
+ displayName: 'ContextMenuItem',
109
+ // Dark panel (gs `ContextActionPanel`): light text on the inverted surface, a
110
+ // solid #2f2f2f wash + weight bump on hover / focus (gs `.itemButton:hover`).
111
+ baseClass:
112
+ 'flex min-h-[34px] cursor-pointer select-none items-center gap-2.5 rounded-[10px] px-3 py-1.5 ' +
113
+ 'text-compact text-white outline-none ' +
114
+ 'data-[focused]:bg-[#2f2f2f] data-[focused]:font-medium data-[hovered]:bg-[#2f2f2f] data-[hovered]:font-medium ' +
115
+ 'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-45',
116
+ variants: {
117
+ // gs uses `--color-error-light` (#ffb5b5) — a light red tuned for the dark
118
+ // panel; our `text-danger` (#dc2626) is a light-surface red that fails
119
+ // contrast here, and there is no danger-light token.
120
+ destructive: { true: 'text-[#ffb5b5] data-[focused]:text-[#ffb5b5]' },
121
+ },
122
+ }) as (
123
+ props: React.ComponentProps<typeof RACMenuItem> & { destructive?: boolean },
124
+ ) => ReturnType<typeof RACMenuItem>
125
+
126
+ // gs `.panel`: 258px, radius 12px, 8px/6px padding, 180deg #242424→#1f1f1f
127
+ // gradient, 0 12px 28px rgba(0,0,0,.22) shadow. No token covers the gradient/shadow.
128
+ const panelClass =
129
+ 'w-[258px] max-w-[calc(100vw-16px)] rounded-xl bg-gradient-to-b from-[#242424] to-[#1f1f1f] ' +
130
+ 'px-1.5 py-2 shadow-[0_12px_28px_rgba(0,0,0,0.22)] outline-none'
131
+ const menuClass = 'grid max-h-[calc(100vh-16px)] gap-0.5 overflow-y-auto outline-none'
132
+
133
+ /**
134
+ * The popover body shared by both modes — the RAC `Menu` of grouped items. `onItem`
135
+ * fires after an item's own handler so the controlled mode can close the menu.
136
+ */
137
+ function ContextMenuBody({
138
+ groups,
139
+ soonLabel,
140
+ ariaLabel,
141
+ onItem,
142
+ }: {
143
+ groups: ContextMenuGroup[]
144
+ soonLabel: ReactNode
145
+ ariaLabel: string
146
+ onItem?: () => void
147
+ }) {
148
+ return (
149
+ <RACMenu aria-label={ariaLabel} className={menuClass}>
150
+ {groups.map((group, gi) => (
151
+ // gs separates groups with an 18px top margin (`.group + .group`), not a
152
+ // rule line — so the first group sits flush, the rest gain the gap.
153
+ <RACMenuSection key={groupId(group, gi)} className={gi > 0 ? 'mt-[18px] grid gap-0.5' : 'grid gap-0.5'}>
154
+ {group.label ? (
155
+ <Header className="mx-2 mt-1.5 mb-1 text-compact font-medium text-white">{group.label}</Header>
156
+ ) : null}
157
+ {group.items.map((item, ii) => {
158
+ const id = itemId(item, ii)
159
+ const select = item.onSelect ?? item.onAction
160
+ const badge = item.badge ?? (item.disabled ? soonLabel : null)
161
+ return (
162
+ <MenuItem
163
+ key={id}
164
+ id={id}
165
+ textValue={typeof item.label === 'string' ? item.label : id}
166
+ isDisabled={item.disabled}
167
+ destructive={item.destructive}
168
+ onAction={() => {
169
+ select?.()
170
+ onItem?.()
171
+ }}
172
+ >
173
+ {item.icon ? (
174
+ <span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
175
+ {item.icon}
176
+ </span>
177
+ ) : null}
178
+ <span className="min-w-0 flex-1 truncate">{item.label}</span>
179
+ {badge ? (
180
+ // gs `.badge`: 24px pill, #333437 bg / #c8c8ca text, 10px (text-micro).
181
+ <span className="ml-auto inline-flex h-6 items-center justify-center rounded-full bg-[#333437] px-3 py-0.5 text-micro font-medium leading-5 tracking-[-0.2px] text-[#c8c8ca]">
182
+ {badge}
183
+ </span>
184
+ ) : null}
185
+ </MenuItem>
186
+ )
187
+ })}
188
+ </RACMenuSection>
189
+ ))}
190
+ </RACMenu>
191
+ )
192
+ }
193
+
194
+ /** Controlled context menu — anchored at `position`, open/close owned by the caller. */
195
+ function ControlledContextMenu({
196
+ groups,
197
+ isOpen,
198
+ position,
199
+ onClose,
200
+ soonLabel = 'Soon',
201
+ 'aria-label': ariaLabel = 'Actions',
202
+ className,
203
+ }: ContextMenuProps): React.JSX.Element | null {
204
+ const anchorRef = useRef<HTMLSpanElement>(null)
205
+ const visibleGroups = visibleGroupsOf(groups)
206
+ if (visibleGroups.length === 0) {
207
+ return null
208
+ }
209
+ const pos = position ?? { x: 0, y: 0 }
210
+ return (
211
+ <>
212
+ {/* Zero-size anchor at the cursor; the Popover attaches here. Keyed by
213
+ position so a re-open at a new spot re-measures (RAC only measures the
214
+ anchor when the popover opens). */}
215
+ <span
216
+ ref={anchorRef}
217
+ aria-hidden="true"
218
+ style={{ position: 'fixed', left: pos.x, top: pos.y, width: 0, height: 0 }}
219
+ />
220
+ <RACPopover
221
+ key={`${pos.x}:${pos.y}`}
222
+ isOpen={isOpen}
223
+ onOpenChange={(open) => {
224
+ if (!open) {
225
+ onClose()
226
+ }
227
+ }}
228
+ triggerRef={anchorRef}
229
+ placement="bottom start"
230
+ className={className ? `${panelClass} ${className}` : panelClass}
231
+ >
232
+ <ContextMenuBody groups={visibleGroups} soonLabel={soonLabel} ariaLabel={ariaLabel} onItem={onClose} />
233
+ </RACPopover>
234
+ </>
235
+ )
236
+ }
237
+
238
+ /** Wrapper context menu — wraps a target and owns its own open state. */
239
+ function WrapperContextMenu({
240
+ groups,
241
+ children,
242
+ soonLabel = 'Soon',
243
+ 'aria-label': ariaLabel = 'Actions',
244
+ className,
245
+ }: ContextMenuWrapperProps): React.JSX.Element {
246
+ const [isOpen, setOpen] = useState(false)
247
+ const anchorRef = useRef<HTMLSpanElement>(null)
248
+ const wrapperRef = useRef<HTMLDivElement>(null)
249
+ const popoverRef = useRef<HTMLElement>(null)
250
+ const [position, setPosition] = useState({ x: 0, y: 0 })
251
+ // When `groups` is a resolver, the groups depend on WHAT was clicked, so they
252
+ // are computed at right-click time and held until the next open. A static array
253
+ // is read straight from props (stays live while the menu is open).
254
+ const [resolved, setResolved] = useState<ContextMenuGroup[]>([])
255
+ // Latest `groups` reachable from the document listener without re-subscribing on
256
+ // every parent render (resolvers are usually inline functions).
257
+ const groupsRef = useRef(groups)
258
+ groupsRef.current = groups
259
+
260
+ const openAt = (x: number, y: number, target: HTMLElement) => {
261
+ if (typeof groupsRef.current === 'function') {
262
+ setResolved(groupsRef.current({ target }))
263
+ }
264
+ setPosition({ x, y })
265
+ setOpen(true)
266
+ }
267
+
268
+ const onContextMenu = (e: React.MouseEvent) => {
269
+ e.preventDefault()
270
+ openAt(e.clientX, e.clientY, e.target as HTMLElement)
271
+ }
272
+
273
+ // While the panel is open, a second right-click would otherwise be swallowed by
274
+ // the overlay's outside-press dismissal and surface the BROWSER's native menu.
275
+ // Intercept it ourselves: keep the OS menu suppressed and re-anchor our panel to
276
+ // the new cursor. Re-anchoring needs a close + reopen across a frame so the
277
+ // popover recomputes its position (it only measures the anchor on open). A
278
+ // right-click on our own panel just suppresses the OS menu; one outside our
279
+ // region closes the panel and behaves normally.
280
+ useEffect(() => {
281
+ if (!isOpen) {
282
+ return
283
+ }
284
+ const handle = (e: MouseEvent) => {
285
+ const target = e.target as HTMLElement
286
+ if (popoverRef.current?.contains(target)) {
287
+ // On our own panel: suppress the OS menu, leave the panel as-is. Stop
288
+ // propagation so the wrapper's onContextMenu doesn't also fire.
289
+ e.preventDefault()
290
+ e.stopImmediatePropagation()
291
+ return
292
+ }
293
+ if (!wrapperRef.current?.contains(target)) {
294
+ setOpen(false)
295
+ return
296
+ }
297
+ // In our region with the panel open: take over fully. stopImmediatePropagation
298
+ // keeps the wrapper's bubble onContextMenu from re-opening synchronously —
299
+ // otherwise isOpen never commits `false` and the popover never re-anchors.
300
+ e.preventDefault()
301
+ e.stopImmediatePropagation()
302
+ setOpen(false)
303
+ const { clientX, clientY } = e
304
+ requestAnimationFrame(() => {
305
+ if (typeof groupsRef.current === 'function') {
306
+ setResolved(groupsRef.current({ target }))
307
+ }
308
+ setPosition({ x: clientX, y: clientY })
309
+ setOpen(true)
310
+ })
311
+ }
312
+ document.addEventListener('contextmenu', handle, true)
313
+ return () => document.removeEventListener('contextmenu', handle, true)
314
+ }, [isOpen])
315
+
316
+ const sourceGroups = typeof groups === 'function' ? resolved : groups
317
+ const visibleGroups = visibleGroupsOf(sourceGroups)
318
+
319
+ return (
320
+ <div ref={wrapperRef} className={className} onContextMenu={onContextMenu}>
321
+ {children}
322
+ {/* Zero-size anchor positioned at the cursor; the Popover attaches here. */}
323
+ <span
324
+ ref={anchorRef}
325
+ aria-hidden="true"
326
+ style={{ position: 'fixed', left: position.x, top: position.y, width: 0, height: 0 }}
327
+ />
328
+ {visibleGroups.length > 0 ? (
329
+ <RACPopover
330
+ ref={popoverRef}
331
+ isOpen={isOpen}
332
+ onOpenChange={setOpen}
333
+ triggerRef={anchorRef}
334
+ placement="bottom start"
335
+ className={panelClass}
336
+ >
337
+ <ContextMenuBody groups={visibleGroups} soonLabel={soonLabel} ariaLabel={ariaLabel} />
338
+ </RACPopover>
339
+ ) : null}
340
+ </div>
341
+ )
342
+ }
343
+
344
+ /**
345
+ * Right-click action menu. Pass `isOpen` / `position` / `onClose` for the controlled
346
+ * API (drive it with {@link useContextMenu}), or `children` for the wrapper API.
347
+ */
348
+ export function ContextMenu(props: ContextMenuProps | ContextMenuWrapperProps): React.JSX.Element | null {
349
+ if ('isOpen' in props) {
350
+ return <ControlledContextMenu {...props} />
351
+ }
352
+ return <WrapperContextMenu {...props} />
353
+ }
354
+
355
+ /**
356
+ * Drives a controlled {@link ContextMenu} from a right-click. `open` calls
357
+ * `preventDefault()` (suppressing the native OS menu), records the cursor and opens.
358
+ *
359
+ * ```tsx
360
+ * const menu = useContextMenu()
361
+ * return (
362
+ * <>
363
+ * <div onContextMenu={menu.open}>…</div>
364
+ * <ContextMenu {...menu.props} groups={groups} aria-label="Actions" />
365
+ * </>
366
+ * )
367
+ * ```
368
+ */
369
+ export function useContextMenu(): {
370
+ isOpen: boolean
371
+ position: { x: number; y: number } | null
372
+ open: (e: { preventDefault: () => void; clientX: number; clientY: number }) => void
373
+ close: () => void
374
+ props: Pick<ContextMenuProps, 'isOpen' | 'position' | 'onClose'>
375
+ } {
376
+ const [state, setState] = useState<{ isOpen: boolean; position: { x: number; y: number } | null }>({
377
+ isOpen: false,
378
+ position: null,
379
+ })
380
+
381
+ const open = useCallback((e: { preventDefault: () => void; clientX: number; clientY: number }) => {
382
+ e.preventDefault()
383
+ setState({ isOpen: true, position: { x: e.clientX, y: e.clientY } })
384
+ }, [])
385
+
386
+ const close = useCallback(() => {
387
+ setState((prev) => ({ ...prev, isOpen: false }))
388
+ }, [])
389
+
390
+ return {
391
+ isOpen: state.isOpen,
392
+ position: state.position,
393
+ open,
394
+ close,
395
+ props: { isOpen: state.isOpen, position: state.position, onClose: close },
396
+ }
397
+ }