@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 +25 -0
- package/package.json +39 -0
- package/src/components/button.tsx +53 -0
- package/src/components/checkbox.tsx +64 -0
- package/src/components/context-menu.tsx +397 -0
- package/src/components/dialog.tsx +203 -0
- package/src/components/disclosure.tsx +36 -0
- package/src/components/dropdown-menu.tsx +143 -0
- package/src/components/input.tsx +70 -0
- package/src/components/radio.tsx +72 -0
- package/src/components/section-tabs.tsx +103 -0
- package/src/components/select.tsx +115 -0
- package/src/components/separator.tsx +36 -0
- package/src/components/switch.tsx +40 -0
- package/src/components/tabs.tsx +40 -0
- package/src/components/text.tsx +68 -0
- package/src/components/textarea.tsx +59 -0
- package/src/components/toast.tsx +177 -0
- package/src/components/tooltip.tsx +66 -0
- package/src/components/view-toggle.tsx +101 -0
- package/src/index.ts +37 -0
- package/src/layout/app-shell.tsx +184 -0
- package/src/layout/card.tsx +41 -0
- package/src/layout/page-container.tsx +36 -0
- package/src/layout/persistent-page-shell.tsx +134 -0
- package/src/layout/section.tsx +35 -0
- package/src/layout/topbar.tsx +220 -0
- package/src/utils/uic.ts +232 -0
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
|
+
}
|