@octanejs/base-ui 0.1.14 → 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.
Files changed (49) hide show
  1. package/package.json +5 -5
  2. package/src/alert-dialog.ts +2 -0
  3. package/src/button.ts +63 -0
  4. package/src/context-menu.ts +317 -0
  5. package/src/csp-provider.ts +37 -0
  6. package/src/dialog.ts +89 -22
  7. package/src/direction-provider.ts +32 -0
  8. package/src/index.ts +10 -0
  9. package/src/menu.ts +3387 -0
  10. package/src/menubar.ts +160 -0
  11. package/src/popover.ts +99 -25
  12. package/src/preview-card.ts +812 -0
  13. package/src/tooltip.ts +1053 -0
  14. package/src/unstable-use-media-query.ts +101 -0
  15. package/src/utils/CSPContext.ts +21 -0
  16. package/src/utils/ContextMenuRootContext.ts +44 -0
  17. package/src/utils/FloatingPortalLite.ts +29 -0
  18. package/src/utils/MenubarContext.ts +38 -0
  19. package/src/utils/RequestQueue.ts +132 -0
  20. package/src/utils/TimeoutManager.ts +32 -0
  21. package/src/utils/composite/grid-utils.ts +437 -0
  22. package/src/utils/composite/gridNavigation.ts +127 -0
  23. package/src/utils/composite/list-utils.ts +56 -28
  24. package/src/utils/composite/useCompositeRoot.ts +11 -13
  25. package/src/utils/constants.ts +22 -0
  26. package/src/utils/createChangeEventDetails.ts +4 -0
  27. package/src/utils/findRootOwnerId.ts +19 -0
  28. package/src/utils/floating/FloatingDelayGroup.ts +280 -0
  29. package/src/utils/floating/element.ts +36 -2
  30. package/src/utils/floating/gridNavigation.ts +54 -0
  31. package/src/utils/floating/safePolygon.ts +448 -0
  32. package/src/utils/floating/useClientPoint.ts +276 -0
  33. package/src/utils/floating/useFocus.ts +248 -0
  34. package/src/utils/floating/useHoverFloatingInteraction.ts +301 -11
  35. package/src/utils/floating/useHoverInteractionSharedState.ts +139 -0
  36. package/src/utils/floating/useHoverReferenceInteraction.ts +507 -14
  37. package/src/utils/floating/useHoverShared.ts +78 -0
  38. package/src/utils/floating/useListNavigation.ts +1018 -0
  39. package/src/utils/floating/useTypeahead.ts +287 -0
  40. package/src/utils/getCssDimensions.ts +28 -0
  41. package/src/utils/getPseudoElementBounds.ts +54 -0
  42. package/src/utils/platform.ts +10 -0
  43. package/src/utils/popups/index.ts +6 -0
  44. package/src/utils/popups/inlineRect.ts +289 -0
  45. package/src/utils/useMixedToggleClickHandler.ts +77 -0
  46. package/src/utils/useOpenInteractionType.ts +37 -1
  47. package/src/utils/usePopupAutoResize.ts +250 -0
  48. package/src/utils/usePopupViewport.ts +375 -0
  49. package/src/utils/usePreviousValue.ts +25 -0
@@ -141,14 +141,9 @@ export function useCompositeRoot(...args: any[]): {
141
141
 
142
142
  if (activeIndex !== -1) {
143
143
  onHighlightedIndexChange(activeIndex);
144
- } else if (
145
- isListIndexDisabled({ current: sortedElements }, highlightedIndex, disabledIndices)
146
- ) {
147
- const firstEnabledIndex = findNonDisabledListIndex(
148
- { current: sortedElements },
149
- { disabledIndices },
150
- );
151
- if (!isIndexOutOfListBounds({ current: sortedElements }, firstEnabledIndex)) {
144
+ } else if (isListIndexDisabled(sortedElements, highlightedIndex, disabledIndices)) {
145
+ const firstEnabledIndex = findNonDisabledListIndex(sortedElements, { disabledIndices });
146
+ if (!isIndexOutOfListBounds(sortedElements, firstEnabledIndex)) {
152
147
  onHighlightedIndexChange(firstEnabledIndex);
153
148
  }
154
149
  }
@@ -168,9 +163,9 @@ export function useCompositeRoot(...args: any[]): {
168
163
  return;
169
164
  }
170
165
  const elements = elementsRef.current;
171
- if (isListIndexDisabled(elementsRef, highlightedIndex, disabledIndices)) {
172
- const firstEnabledIndex = findNonDisabledListIndex(elementsRef, { disabledIndices });
173
- if (!isIndexOutOfListBounds({ current: elements }, firstEnabledIndex)) {
166
+ if (isListIndexDisabled(elements, highlightedIndex, disabledIndices)) {
167
+ const firstEnabledIndex = findNonDisabledListIndex(elements, { disabledIndices });
168
+ if (!isIndexOutOfListBounds(elements, firstEnabledIndex)) {
174
169
  onHighlightedIndexChange(firstEnabledIndex);
175
170
  }
176
171
  }
@@ -294,7 +289,7 @@ export function useCompositeRoot(...args: any[]): {
294
289
  nextIndex = onLoop(event, highlightedIndex, nextIndex, elementsRef);
295
290
  }
296
291
  } else {
297
- nextIndex = findNonDisabledListIndex(elementsRef, {
292
+ nextIndex = findNonDisabledListIndex(elementsRef.current, {
298
293
  startingIndex: nextIndex,
299
294
  decrement: backwardKeys.includes(event.key),
300
295
  disabledIndices,
@@ -302,7 +297,10 @@ export function useCompositeRoot(...args: any[]): {
302
297
  }
303
298
  }
304
299
 
305
- if (nextIndex !== highlightedIndex && !isIndexOutOfListBounds(elementsRef, nextIndex)) {
300
+ if (
301
+ nextIndex !== highlightedIndex &&
302
+ !isIndexOutOfListBounds(elementsRef.current, nextIndex)
303
+ ) {
306
304
  if (stopEventPropagation) {
307
305
  event.stopPropagation();
308
306
  }
@@ -2,8 +2,30 @@
2
2
  // overlay/positioner layer needs. `POPUP_COLLISION_AVOIDANCE` is the default collision config
3
3
  // popups pass to `useAnchorPositioning`; `DISABLED_TRANSITIONS_STYLE` disables transitions while
4
4
  // a popup is in the `starting` transition phase (so the mount frame doesn't animate).
5
+
6
+ /**
7
+ * Used by regular popups that usually aren't scrollable and are allowed to
8
+ * freely flip to any axis of placement.
9
+ */
5
10
  export const POPUP_COLLISION_AVOIDANCE = {
6
11
  fallbackAxisSide: 'end',
7
12
  } as const;
8
13
 
14
+ /**
15
+ * Used for dropdowns that usually strictly prefer top/bottom placements and
16
+ * use `var(--available-height)` to limit their height.
17
+ */
18
+ export const DROPDOWN_COLLISION_AVOIDANCE = {
19
+ fallbackAxisSide: 'none',
20
+ } as const;
21
+
9
22
  export const DISABLED_TRANSITIONS_STYLE = { style: { transition: 'none' } } as const;
23
+
24
+ /** How long a typeahead buffer survives without a keystroke before it resets. */
25
+ export const TYPEAHEAD_RESET_MS = 500;
26
+
27
+ /**
28
+ * How long after a hover-open a click is still treated as "patient" (and therefore does not
29
+ * immediately close the popup it just opened).
30
+ */
31
+ export const PATIENT_CLICK_THRESHOLD = 500;
@@ -22,13 +22,17 @@ export const REASONS = {
22
22
  scrub: 'scrub',
23
23
  trackPress: 'track-press',
24
24
  drag: 'drag',
25
+ disabled: 'disabled',
25
26
  triggerHover: 'trigger-hover',
26
27
  triggerFocus: 'trigger-focus',
27
28
  outsidePress: 'outside-press',
28
29
  closePress: 'close-press',
29
30
  focusOut: 'focus-out',
31
+ listNavigation: 'list-navigation',
30
32
  escapeKey: 'escape-key',
31
33
  imperativeAction: 'imperative-action',
34
+ cancelOpen: 'cancel-open',
35
+ siblingOpen: 'sibling-open',
32
36
  } as const;
33
37
 
34
38
  export type BaseUIEventReason = (typeof REASONS)[keyof typeof REASONS];
@@ -0,0 +1,19 @@
1
+ // Ported verbatim from .base-ui/packages/react/src/menu/utils/findRootOwnerId.ts (v1.6.0). Pure —
2
+ // no octane adaptations needed.
3
+ //
4
+ // Walks up from a node to the nearest element carrying `data-rootownerid` (stamped on
5
+ // `Menu.Popup`), so a mouseup landing in ANOTHER popup of the same menu root can be told apart
6
+ // from a mouseup that landed outside the menu tree entirely.
7
+ import { getParentNode, isHTMLElement, isLastTraversableNode } from '@floating-ui/utils/dom';
8
+
9
+ export function findRootOwnerId(node: Node): string | undefined {
10
+ if (isHTMLElement(node) && node.hasAttribute('data-rootownerid')) {
11
+ return node.getAttribute('data-rootownerid') ?? undefined;
12
+ }
13
+
14
+ if (isLastTraversableNode(node)) {
15
+ return undefined;
16
+ }
17
+
18
+ return findRootOwnerId(getParentNode(node));
19
+ }
@@ -0,0 +1,280 @@
1
+ // Ported from .base-ui/packages/react/src/floating-ui-react/components/FloatingDelayGroup.tsx
2
+ // (v1.6.0). A group of tooltips that share one hover delay: once any member has opened, the others
3
+ // open instantly until the group's `timeoutMs` window lapses. The delay lives in a ref so changing
4
+ // it does not re-render unrelated consumers.
5
+ //
6
+ // octane adaptations: `createContext`/`createElement` instead of JSX; `useIsoLayoutEffect` →
7
+ // `useLayoutEffect`; every composed hook threads an explicit slot.
8
+ import {
9
+ createContext,
10
+ createElement,
11
+ useContext,
12
+ useLayoutEffect,
13
+ useMemo,
14
+ useRef,
15
+ useState,
16
+ } from 'octane';
17
+
18
+ import { S, splitSlot, subSlot } from '../../internal';
19
+ import { Timeout, useTimeout } from '../useTimeout';
20
+ import { createChangeEventDetails, REASONS } from '../createChangeEventDetails';
21
+ import { getDelay } from './useHoverShared';
22
+ import type { HoverDelay } from './useHoverShared';
23
+
24
+ interface DelayGroupContextValue {
25
+ hasProvider: boolean;
26
+ timeoutMs: number;
27
+ delayRef: { current: HoverDelay };
28
+ initialDelayRef: { current: HoverDelay };
29
+ timeout: Timeout;
30
+ currentIdRef: { current: any };
31
+ currentContextRef: {
32
+ current: {
33
+ onOpenChange: (open: boolean, eventDetails: any) => void;
34
+ setIsInstantPhase: (value: boolean) => void;
35
+ } | null;
36
+ };
37
+ }
38
+
39
+ const DEFAULT_CONTEXT: DelayGroupContextValue = {
40
+ hasProvider: false,
41
+ timeoutMs: 0,
42
+ delayRef: { current: 0 },
43
+ initialDelayRef: { current: 0 },
44
+ timeout: new Timeout(),
45
+ currentIdRef: { current: null },
46
+ currentContextRef: { current: null },
47
+ };
48
+
49
+ const FloatingDelayGroupContext = createContext<DelayGroupContextValue>(DEFAULT_CONTEXT);
50
+
51
+ function resetDelayRef(
52
+ delayRef: { current: HoverDelay },
53
+ initialDelayRef: { current: HoverDelay },
54
+ ): void {
55
+ delayRef.current = initialDelayRef.current;
56
+ }
57
+
58
+ export interface FloatingDelayGroupProps {
59
+ children?: any;
60
+ /** The delay to use for the group when it's not in the instant phase. */
61
+ delay: HoverDelay;
62
+ /**
63
+ * An explicit timeout for the group, representing when grouping logic stops being active
64
+ * after the close delay completes. Useful when there is no close delay at all.
65
+ */
66
+ timeoutMs?: number | undefined;
67
+ }
68
+
69
+ export function FloatingDelayGroup(props: FloatingDelayGroupProps): any {
70
+ const slot = S('FloatingDelayGroup');
71
+ const { children, delay, timeoutMs = 0 } = props;
72
+
73
+ const delayRef = useRef<HoverDelay>(delay, subSlot(slot, 'delay'));
74
+ const initialDelayRef = useRef<HoverDelay>(delay, subSlot(slot, 'initial'));
75
+ const currentIdRef = useRef<string | null>(null, subSlot(slot, 'id'));
76
+ const currentContextRef = useRef<any>(null, subSlot(slot, 'ctxRef'));
77
+ const timeout = useTimeout(subSlot(slot, 'to'));
78
+
79
+ useLayoutEffect(
80
+ () => {
81
+ initialDelayRef.current = delay;
82
+
83
+ if (!currentIdRef.current) {
84
+ delayRef.current = delay;
85
+ return;
86
+ }
87
+
88
+ // A member is already open: keep the (zeroed) open delay, adopt the new close delay.
89
+ delayRef.current = {
90
+ open: getDelay(delayRef.current, 'open'),
91
+ close: getDelay(delay, 'close'),
92
+ } as HoverDelay;
93
+ },
94
+ [delay, currentIdRef, delayRef, initialDelayRef],
95
+ subSlot(slot, 'l:delay'),
96
+ );
97
+
98
+ const contextValue = useMemo(
99
+ () => ({
100
+ hasProvider: true,
101
+ delayRef,
102
+ initialDelayRef,
103
+ currentIdRef,
104
+ timeoutMs,
105
+ currentContextRef,
106
+ timeout,
107
+ }),
108
+ [timeoutMs, timeout],
109
+ subSlot(slot, 'ctx'),
110
+ );
111
+
112
+ return createElement(FloatingDelayGroupContext.Provider, { value: contextValue, children });
113
+ }
114
+
115
+ export interface UseDelayGroupReturn {
116
+ delayRef: { current: HoverDelay };
117
+ isInstantPhase: boolean;
118
+ hasProvider: boolean;
119
+ }
120
+
121
+ export function useDelayGroup(...args: any[]): UseDelayGroupReturn {
122
+ const [user, slotArg] = splitSlot(args);
123
+ const slot = slotArg ?? S('useDelayGroup');
124
+ const context = user[0] as any;
125
+ const options = (user[1] as { open: boolean } | undefined) ?? { open: false };
126
+
127
+ const { open } = options;
128
+
129
+ const store = context != null && 'rootStore' in context ? context.rootStore : context;
130
+ const floatingId = store.useState('floatingId', subSlot(slot, 's:id'));
131
+
132
+ const groupContext = useContext(FloatingDelayGroupContext);
133
+ const {
134
+ currentIdRef,
135
+ delayRef,
136
+ timeoutMs,
137
+ initialDelayRef,
138
+ currentContextRef,
139
+ hasProvider,
140
+ timeout,
141
+ } = groupContext;
142
+
143
+ const [isInstantPhase, setIsInstantPhase] = useState(false, subSlot(slot, 'instant'));
144
+ const openRef = useRef(open, subSlot(slot, 'open'));
145
+ const isUnmountedRef = useRef(false, subSlot(slot, 'unmounted'));
146
+
147
+ useLayoutEffect(
148
+ () => {
149
+ openRef.current = open;
150
+ },
151
+ [open],
152
+ subSlot(slot, 'l:openRef'),
153
+ );
154
+
155
+ useLayoutEffect(
156
+ () => {
157
+ return () => {
158
+ isUnmountedRef.current = true;
159
+ };
160
+ },
161
+ [],
162
+ subSlot(slot, 'l:unmount'),
163
+ );
164
+
165
+ useLayoutEffect(
166
+ () => {
167
+ function unset() {
168
+ if (!isUnmountedRef.current) {
169
+ setIsInstantPhase(false);
170
+ }
171
+ currentContextRef.current?.setIsInstantPhase(false);
172
+ currentIdRef.current = null;
173
+ currentContextRef.current = null;
174
+ delayRef.current = initialDelayRef.current;
175
+ timeout.clear();
176
+ }
177
+
178
+ if (!currentIdRef.current) {
179
+ return undefined;
180
+ }
181
+
182
+ if (!open && currentIdRef.current === floatingId) {
183
+ setIsInstantPhase(false);
184
+
185
+ if (timeoutMs) {
186
+ const closingId = floatingId;
187
+ timeout.start(timeoutMs, () => {
188
+ // If another tooltip has taken over the group, skip resetting.
189
+ if (
190
+ store.select('open') ||
191
+ (currentIdRef.current && currentIdRef.current !== closingId)
192
+ ) {
193
+ return;
194
+ }
195
+ unset();
196
+ });
197
+ return () => {
198
+ if (openRef.current || currentIdRef.current !== closingId) {
199
+ timeout.clear();
200
+ }
201
+ };
202
+ }
203
+
204
+ unset();
205
+ }
206
+
207
+ return undefined;
208
+ },
209
+ [
210
+ open,
211
+ floatingId,
212
+ currentIdRef,
213
+ delayRef,
214
+ timeoutMs,
215
+ initialDelayRef,
216
+ currentContextRef,
217
+ timeout,
218
+ store,
219
+ ],
220
+ subSlot(slot, 'l:close'),
221
+ );
222
+
223
+ useLayoutEffect(
224
+ () => {
225
+ if (!open) {
226
+ return;
227
+ }
228
+
229
+ const prevContext = currentContextRef.current;
230
+ const prevId = currentIdRef.current;
231
+
232
+ // A new tooltip is opening, so cancel any pending timeout that would reset the group's
233
+ // delay back to the initial value.
234
+ timeout.clear();
235
+ currentContextRef.current = { onOpenChange: store.setOpen, setIsInstantPhase };
236
+ currentIdRef.current = floatingId;
237
+ delayRef.current = {
238
+ open: 0,
239
+ close: getDelay(initialDelayRef.current, 'close'),
240
+ } as HoverDelay;
241
+
242
+ if (prevId !== null && prevId !== floatingId) {
243
+ setIsInstantPhase(true);
244
+ prevContext?.setIsInstantPhase(true);
245
+ prevContext?.onOpenChange(false, createChangeEventDetails(REASONS.none));
246
+ } else {
247
+ setIsInstantPhase(false);
248
+ prevContext?.setIsInstantPhase(false);
249
+ }
250
+ },
251
+ [open, floatingId, store, currentIdRef, delayRef, initialDelayRef, currentContextRef, timeout],
252
+ subSlot(slot, 'l:open'),
253
+ );
254
+
255
+ useLayoutEffect(
256
+ () => {
257
+ return () => {
258
+ if (currentIdRef.current === floatingId) {
259
+ currentContextRef.current = null;
260
+
261
+ if (!openRef.current) {
262
+ return;
263
+ }
264
+
265
+ currentIdRef.current = null;
266
+ resetDelayRef(delayRef, initialDelayRef);
267
+ timeout.clear();
268
+ }
269
+ };
270
+ },
271
+ [currentContextRef, currentIdRef, delayRef, floatingId, initialDelayRef, timeout],
272
+ subSlot(slot, 'l:teardown'),
273
+ );
274
+
275
+ return useMemo(
276
+ () => ({ hasProvider, delayRef, isInstantPhase }),
277
+ [hasProvider, delayRef, isInstantPhase],
278
+ subSlot(slot, 'out'),
279
+ );
280
+ }
@@ -1,10 +1,44 @@
1
1
  // Ported from .base-ui/packages/react/src/floating-ui-react/utils/element.ts (v1.6.0) — the subset
2
2
  // used so far. `getTarget` is reused from the composite list utils.
3
- import { isHTMLElement } from '../dom';
3
+ import { isElement, isHTMLElement } from '../dom';
4
+ import type { PopupTriggerMap } from '../popups/popupTriggerMap';
4
5
  import { TYPEABLE_SELECTOR, FOCUSABLE_ATTRIBUTE } from './constants';
5
6
 
7
+ import { contains } from '../contains';
8
+
6
9
  export { getTarget } from '../composite/list-utils';
7
- export { contains } from '../contains';
10
+ export { contains };
11
+
12
+ // Whether the target sits on (or inside) one of this popup's triggers, and that trigger is not
13
+ // marked disabled. Hover uses it to keep a popup open while the cursor crosses between triggers.
14
+ export function isTargetInsideEnabledTrigger(
15
+ target: EventTarget | null,
16
+ triggerElements: PopupTriggerMap,
17
+ ): boolean {
18
+ if (!isElement(target)) {
19
+ return false;
20
+ }
21
+ const targetElement = target as Element;
22
+ if (triggerElements.hasElement(targetElement)) {
23
+ return !targetElement.hasAttribute('data-trigger-disabled');
24
+ }
25
+ for (const [, trigger] of triggerElements.entries()) {
26
+ if (contains(trigger, targetElement)) {
27
+ return !trigger.hasAttribute('data-trigger-disabled');
28
+ }
29
+ }
30
+ return false;
31
+ }
32
+
33
+ // Whether the element (or an ancestor) is something the user can interact with directly — used to
34
+ // decide whether a pointerdown inside a popup counts as a click-like open.
35
+ export function isInteractiveElement(element: Element | null): boolean {
36
+ return (
37
+ element?.closest(
38
+ `button,a[href],[role="button"],select,[tabindex]:not([tabindex="-1"]),${TYPEABLE_SELECTOR}`,
39
+ ) != null
40
+ );
41
+ }
8
42
 
9
43
  // Ported from .base-ui/…/internals/shadowDom.ts — the deepest active element across shadow roots.
10
44
  export function activeElement(doc: Document): Element | null {
@@ -0,0 +1,54 @@
1
+ // Ported from .base-ui/packages/react/src/floating-ui-react/hooks/gridNavigation.ts (v1.6.0). The
2
+ // grid navigator `useListNavigation` injects for its own `grid` option — distinct from
3
+ // `../composite/gridNavigation`, which builds the navigator `useCompositeRoot` takes.
4
+ //
5
+ // octane adaptations: the event is the NATIVE `KeyboardEvent`, and `React.RefObject<T>` is a plain
6
+ // `{ current: T }`. Not a hook, so no slot. Upstream's `utils/composite` module is split in this
7
+ // binding: the list algebra lives in `../composite/list-utils`, the grid algorithm in
8
+ // `../composite/grid-utils`.
9
+ import { getGridNavigatedIndex } from '../composite/grid-utils';
10
+ import { isIndexOutOfListBounds, type DisabledIndices } from '../composite/list-utils';
11
+
12
+ /**
13
+ * Positional arguments are deliberate: property names of an options object
14
+ * don't minify, and the signature is locked to the caller via `typeof` on the
15
+ * `grid` option of `useListNavigation`.
16
+ *
17
+ * The injected grid navigator only ever operates on a uniform 1x1 grid (sizes are
18
+ * always `1x1` and packing is never dense), so the cell-map machinery that supports
19
+ * multi-cell items collapses to an identity transform over the item list. Calling
20
+ * `getGridNavigatedIndex` directly keeps the cell-map helpers
21
+ * (`createGridCellMap`/`getGridCellIndexOfCorner`/`getGridCellIndices`) out of
22
+ * grid-combobox bundles.
23
+ */
24
+ export function gridNavigation(
25
+ event: KeyboardEvent,
26
+ prevIndex: number,
27
+ listRef: { current: Array<HTMLElement | null> },
28
+ orientation: 'horizontal' | 'vertical' | 'both',
29
+ loopFocus: boolean,
30
+ rtl: boolean,
31
+ disabledIndices: DisabledIndices | undefined,
32
+ minIndex: number,
33
+ maxIndex: number,
34
+ cols = 2,
35
+ ): number | undefined {
36
+ const nextIndex = getGridNavigatedIndex(listRef.current, {
37
+ event,
38
+ orientation,
39
+ loopFocus,
40
+ rtl,
41
+ cols,
42
+ disabledIndices,
43
+ minIndex,
44
+ maxIndex,
45
+ // An out-of-range previous index falls back to the first enabled item.
46
+ prevIndex: prevIndex > maxIndex ? minIndex : prevIndex,
47
+ stopEvent: true,
48
+ });
49
+
50
+ // `getGridNavigatedIndex` can return an out-of-bounds sentinel (e.g. `-1` when there is no
51
+ // previous item to move from); surface that as `undefined` so the caller treats it as
52
+ // "no navigation" rather than highlighting index `-1`.
53
+ return isIndexOutOfListBounds(listRef.current, nextIndex) ? undefined : nextIndex;
54
+ }