@octanejs/base-ui 0.1.15 → 0.1.16

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/src/menubar.ts ADDED
@@ -0,0 +1,160 @@
1
+ // Ported from .base-ui/packages/react/src/menubar/ (v1.6.0) — Phase 3f STAGE 4.
2
+ //
3
+ // A `Menubar` is a `CompositeRoot` of `Menu.Trigger`s that share one `FloatingTree`, plus the
4
+ // `MenubarContext` that `Menu` has been branching on since stage 1 (`parent.type === 'menubar'`).
5
+ // Providing it here is what activates those already-transcribed branches: triggers render as
6
+ // `CompositeItem`s with `role="menuitem"`, sibling menus open on hover once one is open, the
7
+ // positioner flips its default side to match the bar's orientation, and the modal backdrop moves to
8
+ // the bar itself so the whole menubar stays clickable.
9
+ //
10
+ // `MenubarContext` itself lives in `./utils/MenubarContext` — it landed as a context-only module in
11
+ // stage 1 so `Menu`'s parent-union branches could be written verbatim ahead of this component.
12
+ //
13
+ // octane adaptations: `createElement`, not JSX; `forwardRef` → ref-as-prop; per-call-site `S('…')`
14
+ // slots with `subSlot` for every composed hook; `useFloatingNodeId()` takes an explicit
15
+ // (tree, slot) pair.
16
+ import { createElement, useState, useRef, useEffect, useMemo } from 'octane';
17
+
18
+ import { S, subSlot } from './internal';
19
+ import { useBaseUiId } from './utils/useBaseUiId';
20
+ import { CompositeRoot } from './utils/composite/CompositeRoot';
21
+ import {
22
+ FloatingNode,
23
+ FloatingTree,
24
+ useFloatingNodeId,
25
+ useFloatingTree,
26
+ } from './utils/floating/FloatingTree';
27
+ import { MenubarContext, useMenubarContext } from './utils/MenubarContext';
28
+ import type { MenubarContextValue } from './utils/MenubarContext';
29
+ import type { MenuOpenEventDetails, MenuRootOrientation } from './menu';
30
+ import type { StateAttributesMapping } from './utils/getStateAttributesProps';
31
+ import { REASONS } from './utils/createChangeEventDetails';
32
+
33
+ const menubarStateAttributesMapping: StateAttributesMapping<any> = {
34
+ hasSubmenuOpen(value: boolean) {
35
+ return value ? { 'data-has-submenu-open': '' } : null;
36
+ },
37
+ };
38
+
39
+ function MenubarContent(props: { children?: any }): any {
40
+ const slot = S('MenubarContent');
41
+ const nodeId = useFloatingNodeId(undefined, subSlot(slot, 'nodeId'));
42
+ const tree = useFloatingTree();
43
+ const menuEvents = (tree as any).events;
44
+ const rootContext = useMenubarContext();
45
+
46
+ useEffect(
47
+ () => {
48
+ function onSubmenuOpenChange(details: MenuOpenEventDetails) {
49
+ if (!details.nodeId || details.parentNodeId !== nodeId) {
50
+ return;
51
+ }
52
+
53
+ if (details.open) {
54
+ if (!rootContext.hasSubmenuOpen) {
55
+ rootContext.setHasSubmenuOpen(true);
56
+ }
57
+ } else if (
58
+ details.reason !== REASONS.siblingOpen &&
59
+ details.reason !== REASONS.listNavigation
60
+ ) {
61
+ rootContext.setHasSubmenuOpen(false);
62
+ }
63
+ }
64
+
65
+ menuEvents.on('menuopenchange', onSubmenuOpenChange);
66
+
67
+ return () => {
68
+ menuEvents.off('menuopenchange', onSubmenuOpenChange);
69
+ };
70
+ },
71
+ [menuEvents, nodeId, rootContext],
72
+ subSlot(slot, 'e'),
73
+ );
74
+
75
+ return createElement(FloatingNode, { id: nodeId, children: props.children });
76
+ }
77
+
78
+ /**
79
+ * The container for menus.
80
+ *
81
+ * Documentation: [Base UI Menubar](https://base-ui.com/react/components/menubar)
82
+ */
83
+ export function Menubar(props: any): any {
84
+ const slot = S('Menubar');
85
+ const {
86
+ orientation = 'horizontal',
87
+ loopFocus = true,
88
+ render,
89
+ className,
90
+ modal = true,
91
+ disabled = false,
92
+ id: idProp,
93
+ style,
94
+ ref: forwardedRef,
95
+ ...elementProps
96
+ } = props;
97
+
98
+ const [contentElement, setContentElement] = useState<HTMLElement | null>(
99
+ null,
100
+ subSlot(slot, 'content'),
101
+ );
102
+ const [hasSubmenuOpen, setHasSubmenuOpen] = useState(false, subSlot(slot, 'hasSub'));
103
+
104
+ const id = useBaseUiId(idProp, subSlot(slot, 'id'));
105
+
106
+ const state: MenubarState = {
107
+ orientation,
108
+ modal,
109
+ hasSubmenuOpen,
110
+ };
111
+
112
+ const contentRef = useRef<HTMLDivElement | null>(null, subSlot(slot, 'contentRef'));
113
+ const allowMouseUpTriggerRef = useRef(false, subSlot(slot, 'amut'));
114
+
115
+ const context: MenubarContextValue = useMemo(
116
+ () => ({
117
+ contentElement,
118
+ setContentElement,
119
+ setHasSubmenuOpen,
120
+ hasSubmenuOpen,
121
+ modal,
122
+ disabled,
123
+ orientation,
124
+ allowMouseUpTriggerRef,
125
+ rootId: id,
126
+ }),
127
+ [contentElement, hasSubmenuOpen, modal, disabled, orientation, id],
128
+ subSlot(slot, 'ctx'),
129
+ );
130
+
131
+ return createElement(MenubarContext.Provider, {
132
+ value: context,
133
+ children: createElement(FloatingTree, {
134
+ children: createElement(MenubarContent, {
135
+ children: createElement(CompositeRoot, {
136
+ render,
137
+ className,
138
+ style,
139
+ state,
140
+ stateAttributesMapping: menubarStateAttributesMapping,
141
+ refs: [forwardedRef, setContentElement, contentRef],
142
+ props: [{ role: 'menubar', id, 'aria-orientation': orientation }, elementProps],
143
+ orientation,
144
+ loopFocus,
145
+ enableHomeAndEndKeys: true,
146
+ highlightItemOnHover: hasSubmenuOpen,
147
+ }),
148
+ }),
149
+ }),
150
+ });
151
+ }
152
+
153
+ export interface MenubarState {
154
+ /** The orientation of the menubar. */
155
+ orientation: MenuRootOrientation;
156
+ /** Whether the menubar is modal. */
157
+ modal: boolean;
158
+ /** Whether any submenu within the menubar is open. */
159
+ hasSubmenuOpen: boolean;
160
+ }
package/src/popover.ts CHANGED
@@ -58,7 +58,7 @@ import { InternalBackdrop } from './utils/InternalBackdrop';
58
58
  import { useAnimationsFinished } from './utils/useAnimationsFinished';
59
59
  import { useOpenChangeComplete } from './utils/useOpenChangeComplete';
60
60
  import { getDisabledMountTransitionStyles } from './utils/getDisabledMountTransitionStyles';
61
- import { POPUP_COLLISION_AVOIDANCE } from './utils/constants';
61
+ import { POPUP_COLLISION_AVOIDANCE, PATIENT_CLICK_THRESHOLD } from './utils/constants';
62
62
  import { ClosePartProvider, useClosePartCount, useClosePartRegistration } from './utils/closePart';
63
63
  import { popupStateMapping } from './utils/popupStateMapping';
64
64
  import { transitionStatusMapping } from './utils/useTransitionStatus';
@@ -98,7 +98,6 @@ import { PopupTriggerMap } from './utils/popups/popupTriggerMap';
98
98
  import type { InteractionType } from './utils/useEnhancedClickHandler';
99
99
 
100
100
  const OPEN_DELAY = 300;
101
- const PATIENT_CLICK_THRESHOLD = 500;
102
101
  const CLICK_TRIGGER_IDENTIFIER = 'data-base-ui-click-trigger';
103
102
 
104
103
  // --- Store -------------------------------------------------------------------
@@ -533,7 +532,12 @@ function PopoverRootComponent<Payload>(props: any): any {
533
532
  });
534
533
  }
535
534
 
536
- function PopoverRoot<Payload = unknown>(props: any): any {
535
+ interface PopoverRootProps<Payload> {
536
+ handle?: PopoverHandle<Payload>;
537
+ [key: string]: unknown;
538
+ }
539
+
540
+ function PopoverRoot<Payload = unknown>(props: PopoverRootProps<Payload>): any {
537
541
  const slot = S('PopoverRootWrapper');
538
542
  // Top-level popovers establish a FloatingTree; nested ones reuse the parent's.
539
543
  if (usePopoverRootContext(true)) {
@@ -0,0 +1,44 @@
1
+ // Ported from .base-ui/packages/react/src/context-menu/root/ContextMenuRootContext.ts (v1.6.0),
2
+ // octane-adapted (`React.createContext` → octane `createContext`; `React.RefObject` → plain ref
3
+ // shapes; `React.Dispatch<SetStateAction<T>>` → octane's updater signature).
4
+ //
5
+ // STAGING: only the CONTEXT lands here, ahead of the `ContextMenu` components themselves
6
+ // (Phase 3f stage 4). `Menu.Root`/`Menu.Trigger`/`Menu.Positioner`/`Menu.Popup` branch on
7
+ // `parent.type === 'context-menu'` in several places; having the context now lets those branches
8
+ // be transcribed verbatim instead of being stubbed out and re-added later. Until `ContextMenu.Root`
9
+ // lands nothing provides this context, so `useContextMenuRootContext()` always returns `undefined`.
10
+ import { createContext, useContext } from 'octane';
11
+
12
+ export interface ContextMenuRootContextValue {
13
+ anchor: { getBoundingClientRect: () => DOMRect };
14
+ setAnchor: (value: ContextMenuRootContextValue['anchor']) => void;
15
+ backdropRef: { current: HTMLDivElement | null };
16
+ internalBackdropRef: { current: HTMLDivElement | null };
17
+ actionsRef: {
18
+ current: {
19
+ setOpen: (nextOpen: boolean, eventDetails: any) => void;
20
+ } | null;
21
+ };
22
+ positionerRef: { current: HTMLElement | null };
23
+ allowMouseUpTriggerRef: { current: boolean };
24
+ initialCursorPointRef: { current: { x: number; y: number } | null };
25
+ rootId: string | undefined;
26
+ }
27
+
28
+ export const ContextMenuRootContext = createContext<ContextMenuRootContextValue | undefined>(
29
+ undefined,
30
+ );
31
+
32
+ export function useContextMenuRootContext(optional: false): ContextMenuRootContextValue;
33
+ export function useContextMenuRootContext(optional?: true): ContextMenuRootContextValue | undefined;
34
+ export function useContextMenuRootContext(
35
+ optional = true,
36
+ ): ContextMenuRootContextValue | undefined {
37
+ const context = useContext(ContextMenuRootContext);
38
+ if (context === undefined && !optional) {
39
+ throw new Error(
40
+ 'Base UI: ContextMenuRootContext is missing. ContextMenu parts must be placed within <ContextMenu.Root>.',
41
+ );
42
+ }
43
+ return context;
44
+ }
@@ -0,0 +1,38 @@
1
+ // Ported from .base-ui/packages/react/src/menubar/MenubarContext.ts (v1.6.0), octane-adapted
2
+ // (`React.createContext` → octane `createContext`; `React.RefObject` → a plain ref shape).
3
+ //
4
+ // STAGING: only the CONTEXT lands here, ahead of the `Menubar` component itself (Phase 3f stage 4).
5
+ // `Menu.Root`/`Menu.Trigger`/`Menu.Positioner` branch on `parent.type === 'menubar'` in several
6
+ // places; having the context now lets those branches be transcribed verbatim instead of being
7
+ // stubbed out and re-added later. Until `Menubar` lands nothing provides this context, so
8
+ // `useMenubarContext(true)` always returns `null`.
9
+ import { createContext, useContext } from 'octane';
10
+
11
+ import type { MenuRootOrientation } from '../menu';
12
+
13
+ export interface MenubarContextValue {
14
+ modal: boolean;
15
+ disabled: boolean;
16
+ contentElement: HTMLElement | null;
17
+ setContentElement: (element: HTMLElement | null) => void;
18
+ hasSubmenuOpen: boolean;
19
+ setHasSubmenuOpen: (open: boolean) => void;
20
+ orientation: MenuRootOrientation;
21
+ allowMouseUpTriggerRef: { current: boolean };
22
+ rootId: string | undefined;
23
+ }
24
+
25
+ export const MenubarContext = createContext<MenubarContextValue | null>(null);
26
+
27
+ export function useMenubarContext(optional?: false): MenubarContextValue;
28
+ export function useMenubarContext(optional: true): MenubarContextValue | null;
29
+ export function useMenubarContext(optional?: boolean): MenubarContextValue | null {
30
+ const context = useContext(MenubarContext);
31
+ if (context === null && !optional) {
32
+ throw new Error(
33
+ 'Base UI: MenubarContext is missing. Menubar parts must be placed within <Menubar>.',
34
+ );
35
+ }
36
+
37
+ return context;
38
+ }
@@ -0,0 +1,132 @@
1
+ // Ported from .base-ui/packages/react/src/internals/RequestQueue.ts (v1.6.0). Manages concurrent
2
+ // fetching with a queue: tracks each key's lifecycle through queued → pending and caps the number
3
+ // of in-flight requests so the server is not overwhelmed. Subclasses override `pickEntries` to
4
+ // change the ordering (the default is FIFO).
5
+ //
6
+ // Pure — no renderer API is involved, so this is a straight transcription.
7
+ export type RequestStatus = 'queued' | 'pending' | 'unknown';
8
+
9
+ export interface RequestQueueOptions<TKey> {
10
+ /**
11
+ * The function to call when a queued key is ready to be fetched.
12
+ */
13
+ fetchFn: (key: TKey) => Promise<void>;
14
+ /**
15
+ * The maximum number of concurrent requests.
16
+ * @default Infinity
17
+ */
18
+ maxConcurrentRequests?: number | undefined;
19
+ /**
20
+ * Serialize a key to a string for use as a map key.
21
+ * Required for complex key types (e.g., objects).
22
+ * @default String(key)
23
+ */
24
+ getKeyId?: ((key: TKey) => string) | undefined;
25
+ }
26
+
27
+ /**
28
+ * Manages concurrent fetching with a queue system.
29
+ * Tracks request lifecycle through queued → pending states
30
+ * and limits concurrency to prevent overwhelming the server.
31
+ */
32
+ export class RequestQueue<TKey> {
33
+ protected pendingRequests = new Map<string, TKey>();
34
+
35
+ protected queuedRequests = new Map<string, TKey>();
36
+
37
+ protected fetchFn: (key: TKey) => Promise<void>;
38
+
39
+ protected maxConcurrentRequests: number;
40
+
41
+ protected getKeyId: (key: TKey) => string;
42
+
43
+ constructor(options: RequestQueueOptions<TKey>) {
44
+ this.fetchFn = options.fetchFn;
45
+ this.maxConcurrentRequests = options.maxConcurrentRequests ?? Infinity;
46
+ this.getKeyId = options.getKeyId ?? String;
47
+ }
48
+
49
+ /**
50
+ * Returns the next `count` entries to process from the queue.
51
+ * Default is FIFO (oldest first). Subclasses can override for different ordering.
52
+ */
53
+ protected pickEntries(count: number): [string, TKey][] {
54
+ const result: [string, TKey][] = [];
55
+ const iterator = this.queuedRequests.entries();
56
+ for (let i = 0; i < count; i += 1) {
57
+ const { value } = iterator.next() as IteratorYieldResult<[string, TKey]>;
58
+ result.push(value);
59
+ }
60
+ return result;
61
+ }
62
+
63
+ protected processQueue = async () => {
64
+ if (this.queuedRequests.size === 0 || this.pendingRequests.size >= this.maxConcurrentRequests) {
65
+ return;
66
+ }
67
+
68
+ const loopLength = Math.min(
69
+ this.maxConcurrentRequests - this.pendingRequests.size,
70
+ this.queuedRequests.size,
71
+ );
72
+ if (loopLength === 0) {
73
+ return;
74
+ }
75
+
76
+ const fetchPromises: Promise<void>[] = [];
77
+
78
+ for (const [keyId, key] of this.pickEntries(loopLength)) {
79
+ this.queuedRequests.delete(keyId);
80
+ this.pendingRequests.set(keyId, key);
81
+
82
+ fetchPromises.push(
83
+ this.fetchFn(key).catch(() => {
84
+ this.pendingRequests.delete(keyId);
85
+ }),
86
+ );
87
+ }
88
+
89
+ await Promise.all(fetchPromises);
90
+ if (this.queuedRequests.size > 0) {
91
+ await this.processQueue();
92
+ }
93
+ };
94
+
95
+ public queue = async (keys: TKey[]) => {
96
+ for (const key of keys) {
97
+ const keyId = this.getKeyId(key);
98
+ if (!this.pendingRequests.has(keyId)) {
99
+ this.queuedRequests.set(keyId, key);
100
+ }
101
+ }
102
+ await this.processQueue();
103
+ };
104
+
105
+ public setRequestSettled = async (key: TKey) => {
106
+ const keyId = this.getKeyId(key);
107
+ this.pendingRequests.delete(keyId);
108
+ await this.processQueue();
109
+ };
110
+
111
+ public clear = () => {
112
+ this.queuedRequests.clear();
113
+ this.pendingRequests.clear();
114
+ };
115
+
116
+ public clearPendingRequest = async (key: TKey) => {
117
+ const keyId = this.getKeyId(key);
118
+ this.pendingRequests.delete(keyId);
119
+ await this.processQueue();
120
+ };
121
+
122
+ public getRequestStatus = (key: TKey): RequestStatus => {
123
+ const keyId = this.getKeyId(key);
124
+ if (this.pendingRequests.has(keyId)) {
125
+ return 'pending';
126
+ }
127
+ if (this.queuedRequests.has(keyId)) {
128
+ return 'queued';
129
+ }
130
+ return 'unknown';
131
+ };
132
+ }
@@ -0,0 +1,32 @@
1
+ // Ported from .base-ui/packages/react/src/internals/TimeoutManager.ts (v1.6.0). Several keyed
2
+ // timeouts behind one handle, so a caller can start/replace/cancel each independently and drop them
3
+ // all on teardown. Distinct from `utils/useTimeout`'s `Timeout`, which owns exactly one.
4
+ //
5
+ // Pure — no renderer API is involved, so this is a straight transcription.
6
+ export class TimeoutManager {
7
+ private ids: Map<string, number> = new Map();
8
+
9
+ /** Starts `fn` after `delay`, replacing any timeout already running under `key`. */
10
+ start = (key: string, delay: number, fn: () => void): void => {
11
+ this.clear(key);
12
+ const id = setTimeout(() => {
13
+ this.ids.delete(key);
14
+ fn();
15
+ }, delay) as unknown as number;
16
+
17
+ this.ids.set(key, id);
18
+ };
19
+
20
+ clear = (key: string): void => {
21
+ const id = this.ids.get(key);
22
+ if (id != null) {
23
+ clearTimeout(id);
24
+ this.ids.delete(key);
25
+ }
26
+ };
27
+
28
+ clearAll = (): void => {
29
+ this.ids.forEach(clearTimeout);
30
+ this.ids.clear();
31
+ };
32
+ }