@octanejs/base-ui 0.1.15 → 0.1.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/base-ui",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -41,19 +41,19 @@
41
41
  "@floating-ui/utils": "^0.2.12",
42
42
  "aria-hidden": "^1.2.6",
43
43
  "tabbable": "^6.5.0",
44
- "@octanejs/floating-ui": "0.1.16"
44
+ "@octanejs/floating-ui": "0.1.18"
45
45
  },
46
46
  "peerDependencies": {
47
- "octane": "0.1.17"
47
+ "octane": "0.1.19"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@base-ui/react": "^1.6.0",
51
- "@tsrx/react": "^0.2.50",
51
+ "@tsrx/react": "^0.2.56",
52
52
  "esbuild": "^0.28.1",
53
53
  "react": "^19.2.7",
54
54
  "react-dom": "^19.2.7",
55
55
  "vitest": "^4.1.10",
56
- "octane": "0.1.17"
56
+ "octane": "0.1.19"
57
57
  },
58
58
  "scripts": {
59
59
  "test": "vitest run"
@@ -0,0 +1,317 @@
1
+ // Ported from .base-ui/packages/react/src/context-menu/ (v1.6.0) — Phase 3f STAGE 4.
2
+ //
3
+ // A `ContextMenu` is a `Menu.Root` opened by right-click or long-press at the pointer, anchored to a
4
+ // synthetic zero-size rect at the cursor instead of to a trigger element. `ContextMenu.Root`
5
+ // provides the `ContextMenuRootContext` that `Menu` has been branching on since stage 1
6
+ // (`parent.type === 'context-menu'`): a modal focus manager, `mousedown`-grace-period outside-press
7
+ // dismissal, `fixed` positioning, `start` alignment, and the item `mouseup` guards that stop the
8
+ // gesture which OPENED the menu from immediately activating an item under the cursor.
9
+ //
10
+ // `ContextMenuRootContext` itself lives in `./utils/ContextMenuRootContext` — it landed as a
11
+ // context-only module in stage 1 so `Menu`'s parent-union branches could be written verbatim ahead
12
+ // of this component.
13
+ //
14
+ // Every part other than `Root` and `Trigger` is `Menu`'s own, re-exported through this namespace
15
+ // exactly as upstream's `context-menu/index.parts.ts` does.
16
+ //
17
+ // octane adaptations: `createElement`, not JSX; `forwardRef` → ref-as-prop; per-call-site `S('…')`
18
+ // slots with `subSlot`; native events, so `event.nativeEvent` collapses to `event`; upstream's
19
+ // `React.TouchEvent`/`React.MouseEvent` are the native types here.
20
+ import { createElement, useState, useRef, useEffect, useMemo, useId } from 'octane';
21
+
22
+ import { S, subSlot } from './internal';
23
+ import { Menu, MenuRootContext, useMenuRootContext } from './menu';
24
+ import type { MenuRootContextValue } from './menu';
25
+ import { ContextMenuRootContext, useContextMenuRootContext } from './utils/ContextMenuRootContext';
26
+ import type { ContextMenuRootContextValue } from './utils/ContextMenuRootContext';
27
+ import { useRenderElement } from './utils/useRenderElement';
28
+ import { useTimeout } from './utils/useTimeout';
29
+ import { addEventListener } from './utils/addEventListener';
30
+ import { ownerDocument } from './utils/owner';
31
+ import { contains } from './utils/contains';
32
+ import { getTarget, stopEvent } from './utils/composite/list-utils';
33
+ import { createChangeEventDetails, REASONS } from './utils/createChangeEventDetails';
34
+ import { pressableTriggerOpenStateMapping } from './utils/popupStateMapping';
35
+ import { findRootOwnerId } from './utils/findRootOwnerId';
36
+
37
+ const LONG_PRESS_DELAY = 500;
38
+
39
+ // --- Root ---------------------------------------------------------------------
40
+
41
+ /**
42
+ * A component that creates a context menu activated by right clicking or long pressing.
43
+ * Doesn't render its own HTML element.
44
+ */
45
+ function ContextMenuRoot(props: any): any {
46
+ const slot = S('ContextMenuRoot');
47
+ const [anchor, setAnchor] = useState<ContextMenuRootContextValue['anchor']>(
48
+ {
49
+ getBoundingClientRect() {
50
+ return DOMRect.fromRect({ width: 0, height: 0, x: 0, y: 0 });
51
+ },
52
+ },
53
+ subSlot(slot, 'anchor'),
54
+ );
55
+
56
+ const backdropRef = useRef<HTMLDivElement | null>(null, subSlot(slot, 'backdrop'));
57
+ const internalBackdropRef = useRef<HTMLDivElement | null>(null, subSlot(slot, 'ibackdrop'));
58
+ const actionsRef = useRef<ContextMenuRootContextValue['actionsRef']['current']>(
59
+ null,
60
+ subSlot(slot, 'actions'),
61
+ );
62
+ const positionerRef = useRef<HTMLElement | null>(null, subSlot(slot, 'positioner'));
63
+ const allowMouseUpTriggerRef = useRef(true, subSlot(slot, 'amut'));
64
+ const initialCursorPointRef = useRef<{ x: number; y: number } | null>(
65
+ null,
66
+ subSlot(slot, 'cursor'),
67
+ );
68
+ // Raw `useId` (no `base-ui-` prefix), matching Base UI's `@base-ui/utils/useId`.
69
+ const id = useId(subSlot(slot, 'id'));
70
+
71
+ const contextValue: ContextMenuRootContextValue = useMemo(
72
+ () => ({
73
+ anchor,
74
+ setAnchor,
75
+ actionsRef,
76
+ backdropRef,
77
+ internalBackdropRef,
78
+ positionerRef,
79
+ allowMouseUpTriggerRef,
80
+ initialCursorPointRef,
81
+ rootId: id,
82
+ }),
83
+ [anchor, id],
84
+ subSlot(slot, 'ctx'),
85
+ );
86
+
87
+ // The inner `MenuRootContext.Provider value={undefined}` is load-bearing, not defensive: it is
88
+ // how `MenuRoot` tells "this Menu IS the context menu" apart from "this Menu is nested inside a
89
+ // ContextMenu.Trigger" (the `contextMenuContext && !parentMenuRootContext` guard).
90
+ return createElement(ContextMenuRootContext.Provider, {
91
+ value: contextValue,
92
+ children: createElement(MenuRootContext.Provider, {
93
+ value: undefined,
94
+ children: createElement(Menu.Root, props),
95
+ }),
96
+ });
97
+ }
98
+
99
+ // --- Trigger ------------------------------------------------------------------
100
+
101
+ /**
102
+ * An area that opens the menu on right click or long press.
103
+ * Renders a `<div>` element.
104
+ */
105
+ function ContextMenuTrigger(componentProps: any): any {
106
+ const slot = S('ContextMenuTrigger');
107
+ const { render, className, style, ref: forwardedRef, ...elementProps } = componentProps;
108
+
109
+ const {
110
+ setAnchor,
111
+ actionsRef,
112
+ internalBackdropRef,
113
+ backdropRef,
114
+ positionerRef,
115
+ allowMouseUpTriggerRef,
116
+ initialCursorPointRef,
117
+ rootId,
118
+ } = useContextMenuRootContext(false);
119
+
120
+ const { store } = useMenuRootContext() as MenuRootContextValue;
121
+ const open = store.useState('open', subSlot(slot, 'open'));
122
+ const disabled = store.useState('disabled', subSlot(slot, 'disabled'));
123
+
124
+ const triggerRef = useRef<HTMLDivElement | null>(null, subSlot(slot, 'trigger'));
125
+ const touchPositionRef = useRef<{ x: number; y: number } | null>(null, subSlot(slot, 'touch'));
126
+ const longPressTimeout = useTimeout(subSlot(slot, 'lp'));
127
+ const allowMouseUpTimeout = useTimeout(subSlot(slot, 'amu'));
128
+ const allowMouseUpRef = useRef(false, subSlot(slot, 'amur'));
129
+
130
+ function handleLongPress(x: number, y: number, event: MouseEvent | TouchEvent) {
131
+ const isTouchEvent = event.type.startsWith('touch');
132
+
133
+ initialCursorPointRef.current = { x, y };
134
+
135
+ setAnchor({
136
+ getBoundingClientRect() {
137
+ return DOMRect.fromRect({
138
+ width: isTouchEvent ? 10 : 0,
139
+ height: isTouchEvent ? 10 : 0,
140
+ x,
141
+ y,
142
+ });
143
+ },
144
+ });
145
+
146
+ allowMouseUpRef.current = false;
147
+ actionsRef.current?.setOpen(true, createChangeEventDetails(REASONS.triggerPress, event));
148
+
149
+ allowMouseUpTimeout.start(LONG_PRESS_DELAY, () => {
150
+ allowMouseUpRef.current = true;
151
+ });
152
+ }
153
+
154
+ function handleContextMenu(event: MouseEvent) {
155
+ if (disabled) {
156
+ return;
157
+ }
158
+ allowMouseUpTriggerRef.current = true;
159
+ stopEvent(event);
160
+ // octane dispatches NATIVE events, so upstream's `event.nativeEvent` collapses to `event`.
161
+ handleLongPress(event.clientX, event.clientY, event);
162
+ const doc = ownerDocument(triggerRef.current);
163
+
164
+ addEventListener(
165
+ doc,
166
+ 'mouseup',
167
+ (mouseEvent: Event) => {
168
+ allowMouseUpTriggerRef.current = false;
169
+
170
+ if (!allowMouseUpRef.current) {
171
+ return;
172
+ }
173
+
174
+ allowMouseUpTimeout.clear();
175
+ allowMouseUpRef.current = false;
176
+
177
+ const mouseUpTarget = getTarget(mouseEvent) as Element | null;
178
+
179
+ if (contains(positionerRef.current, mouseUpTarget)) {
180
+ return;
181
+ }
182
+
183
+ if (rootId && mouseUpTarget && findRootOwnerId(mouseUpTarget) === rootId) {
184
+ return;
185
+ }
186
+
187
+ actionsRef.current?.setOpen(
188
+ false,
189
+ createChangeEventDetails(REASONS.cancelOpen, mouseEvent),
190
+ );
191
+ },
192
+ { once: true },
193
+ );
194
+ }
195
+
196
+ function handleTouchStart(event: TouchEvent) {
197
+ if (disabled) {
198
+ return;
199
+ }
200
+ allowMouseUpTriggerRef.current = false;
201
+ if (event.touches.length === 1) {
202
+ event.stopPropagation();
203
+ const touch = event.touches[0];
204
+ touchPositionRef.current = { x: touch.clientX, y: touch.clientY };
205
+ longPressTimeout.start(LONG_PRESS_DELAY, () => {
206
+ if (touchPositionRef.current) {
207
+ handleLongPress(touchPositionRef.current.x, touchPositionRef.current.y, event);
208
+ }
209
+ });
210
+ }
211
+ }
212
+
213
+ function handleTouchMove(event: TouchEvent) {
214
+ if (longPressTimeout.isStarted() && touchPositionRef.current && event.touches.length === 1) {
215
+ const touch = event.touches[0];
216
+ const moveThreshold = 10;
217
+
218
+ const deltaX = Math.abs(touch.clientX - touchPositionRef.current.x);
219
+ const deltaY = Math.abs(touch.clientY - touchPositionRef.current.y);
220
+
221
+ if (deltaX > moveThreshold || deltaY > moveThreshold) {
222
+ longPressTimeout.clear();
223
+ }
224
+ }
225
+ }
226
+
227
+ function handleTouchEnd() {
228
+ longPressTimeout.clear();
229
+ touchPositionRef.current = null;
230
+ }
231
+
232
+ useEffect(
233
+ () => {
234
+ function handleDocumentContextMenu(event: Event) {
235
+ if (disabled) {
236
+ return;
237
+ }
238
+
239
+ const target = getTarget(event);
240
+ const targetElement = target as HTMLElement | null;
241
+ if (
242
+ contains(triggerRef.current, targetElement) ||
243
+ contains(internalBackdropRef.current, targetElement) ||
244
+ contains(backdropRef.current, targetElement)
245
+ ) {
246
+ event.preventDefault();
247
+ }
248
+ }
249
+
250
+ const doc = ownerDocument(triggerRef.current);
251
+ return addEventListener(doc, 'contextmenu', handleDocumentContextMenu);
252
+ },
253
+ [backdropRef, disabled, internalBackdropRef],
254
+ subSlot(slot, 'e:ctxmenu'),
255
+ );
256
+
257
+ const state: ContextMenuTriggerState = {
258
+ open,
259
+ };
260
+
261
+ return useRenderElement(
262
+ 'div',
263
+ componentProps,
264
+ {
265
+ state,
266
+ ref: [triggerRef, forwardedRef],
267
+ props: [
268
+ {
269
+ onContextMenu: handleContextMenu,
270
+ onTouchStart: handleTouchStart,
271
+ onTouchMove: handleTouchMove,
272
+ onTouchEnd: handleTouchEnd,
273
+ onTouchCancel: handleTouchEnd,
274
+ style: {
275
+ WebkitTouchCallout: 'none',
276
+ },
277
+ },
278
+ elementProps,
279
+ ],
280
+ stateAttributesMapping: pressableTriggerOpenStateMapping,
281
+ },
282
+ subSlot(slot, 're'),
283
+ );
284
+ }
285
+
286
+ export interface ContextMenuTriggerState {
287
+ /** Whether the context menu is currently open. */
288
+ open: boolean;
289
+ }
290
+
291
+ // --- Namespace ----------------------------------------------------------------
292
+
293
+ /**
294
+ * Mirrors upstream's `context-menu/index.parts.ts`: `Root` and `Trigger` are ContextMenu's own,
295
+ * everything else is `Menu`'s part re-exported here.
296
+ */
297
+ export const ContextMenu = {
298
+ Root: ContextMenuRoot,
299
+ Trigger: ContextMenuTrigger,
300
+ Backdrop: Menu.Backdrop,
301
+ Portal: Menu.Portal,
302
+ Positioner: Menu.Positioner,
303
+ Popup: Menu.Popup,
304
+ Arrow: Menu.Arrow,
305
+ Group: Menu.Group,
306
+ GroupLabel: Menu.GroupLabel,
307
+ Item: Menu.Item,
308
+ CheckboxItem: Menu.CheckboxItem,
309
+ CheckboxItemIndicator: Menu.CheckboxItemIndicator,
310
+ LinkItem: Menu.LinkItem,
311
+ RadioGroup: Menu.RadioGroup,
312
+ RadioItem: Menu.RadioItem,
313
+ RadioItemIndicator: Menu.RadioItemIndicator,
314
+ SubmenuRoot: Menu.SubmenuRoot,
315
+ SubmenuTrigger: Menu.SubmenuTrigger,
316
+ Separator: Menu.Separator,
317
+ };
package/src/index.ts CHANGED
@@ -11,12 +11,15 @@ export { DirectionProvider, useDirection } from './direction-provider';
11
11
  export type { TextDirection } from './direction-provider';
12
12
  export { useMediaQuery } from './unstable-use-media-query';
13
13
  export { Checkbox } from './checkbox';
14
+ export { ContextMenu } from './context-menu';
14
15
  export { CheckboxGroup } from './checkbox-group';
15
16
  export { Dialog } from './dialog';
16
17
  export { Field } from './field';
17
18
  export { Fieldset } from './fieldset';
18
19
  export { Form } from './form';
19
20
  export { Input } from './input';
21
+ export { Menu } from './menu';
22
+ export { Menubar } from './menubar';
20
23
  export { Meter } from './meter';
21
24
  export { NumberField } from './number-field';
22
25
  export { Progress } from './progress';