@octanejs/floating-ui 0.1.2

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.
@@ -0,0 +1,142 @@
1
+ // Ported from @floating-ui/react FloatingDelayGroup + useDelayGroup — share a single
2
+ // open/close delay across a group of floating elements. `.ts` component via
3
+ // createElement; useDelayGroup is a hook (resolves its own slot).
4
+ import {
5
+ createContext,
6
+ createElement,
7
+ useCallback,
8
+ useContext,
9
+ useMemo,
10
+ useReducer,
11
+ useRef,
12
+ } from 'octane';
13
+
14
+ import { S, splitSlot, subSlot } from './internal';
15
+ import { getDelay, useModernLayoutEffect } from './utils';
16
+
17
+ const NOOP = () => {};
18
+ export const FloatingDelayGroupContext = createContext<any>({
19
+ delay: 0,
20
+ initialDelay: 0,
21
+ timeoutMs: 0,
22
+ currentId: null,
23
+ setCurrentId: NOOP,
24
+ setState: NOOP,
25
+ isInstantPhase: false,
26
+ });
27
+
28
+ export const useDelayGroupContext = () => useContext(FloatingDelayGroupContext);
29
+
30
+ export function FloatingDelayGroup(props: any): any {
31
+ const children = props.children;
32
+ const delay = props.delay;
33
+ const timeoutMs = props.timeoutMs ?? 0;
34
+
35
+ const [state, setState] = useReducer(
36
+ (prev: any, next: any) => ({ ...prev, ...next }),
37
+ {
38
+ delay,
39
+ timeoutMs,
40
+ initialDelay: delay,
41
+ currentId: null,
42
+ isInstantPhase: false,
43
+ },
44
+ S('FloatingDelayGroup:state'),
45
+ );
46
+ const initialCurrentIdRef = useRef<any>(null, S('FloatingDelayGroup:initialId'));
47
+ const setCurrentId = useCallback(
48
+ (currentId: any) => {
49
+ setState({ currentId });
50
+ },
51
+ [],
52
+ S('FloatingDelayGroup:setId'),
53
+ );
54
+ useModernLayoutEffect(
55
+ () => {
56
+ if (state.currentId) {
57
+ if (initialCurrentIdRef.current === null) {
58
+ initialCurrentIdRef.current = state.currentId;
59
+ } else if (!state.isInstantPhase) {
60
+ setState({ isInstantPhase: true });
61
+ }
62
+ } else {
63
+ if (state.isInstantPhase) {
64
+ setState({ isInstantPhase: false });
65
+ }
66
+ initialCurrentIdRef.current = null;
67
+ }
68
+ },
69
+ [state.currentId, state.isInstantPhase],
70
+ S('FloatingDelayGroup:eff'),
71
+ );
72
+ const value = useMemo(
73
+ () => ({ ...state, setState, setCurrentId }),
74
+ [state, setCurrentId],
75
+ S('FloatingDelayGroup:value'),
76
+ );
77
+ return createElement(FloatingDelayGroupContext.Provider, { value, children });
78
+ }
79
+
80
+ export function useDelayGroup(...args: any[]): any {
81
+ const [user, slot] = splitSlot(args);
82
+ const context = user[0];
83
+ const options = (user[1] as any) ?? {};
84
+
85
+ const open = context.open;
86
+ const onOpenChange = context.onOpenChange;
87
+ const floatingId = context.floatingId;
88
+ const optionId = options.id;
89
+ const enabled = options.enabled ?? true;
90
+ const id = optionId != null ? optionId : floatingId;
91
+ const groupContext = useDelayGroupContext();
92
+ const { currentId, setCurrentId, initialDelay, setState, timeoutMs } = groupContext;
93
+
94
+ useModernLayoutEffect(
95
+ () => {
96
+ if (!enabled) return;
97
+ if (!currentId) return;
98
+ setState({
99
+ delay: { open: 1, close: getDelay(initialDelay, 'close') },
100
+ });
101
+ if (currentId !== id) {
102
+ onOpenChange(false);
103
+ }
104
+ },
105
+ [enabled, id, onOpenChange, setState, currentId, initialDelay],
106
+ subSlot(slot, 'e:sync'),
107
+ );
108
+
109
+ useModernLayoutEffect(
110
+ () => {
111
+ function unset() {
112
+ onOpenChange(false);
113
+ setState({ delay: initialDelay, currentId: null });
114
+ }
115
+ if (!enabled) return;
116
+ if (!currentId) return;
117
+ if (!open && currentId === id) {
118
+ if (timeoutMs) {
119
+ const timeout = window.setTimeout(unset, timeoutMs);
120
+ return () => {
121
+ clearTimeout(timeout);
122
+ };
123
+ }
124
+ unset();
125
+ }
126
+ },
127
+ [enabled, open, setState, currentId, id, onOpenChange, initialDelay, timeoutMs],
128
+ subSlot(slot, 'e:unset'),
129
+ );
130
+
131
+ useModernLayoutEffect(
132
+ () => {
133
+ if (!enabled) return;
134
+ if (setCurrentId === NOOP || !open) return;
135
+ setCurrentId(id);
136
+ },
137
+ [enabled, open, setCurrentId, id],
138
+ subSlot(slot, 'e:set'),
139
+ );
140
+
141
+ return groupContext;
142
+ }
package/src/index.ts ADDED
@@ -0,0 +1,79 @@
1
+ // @octanejs/floating-ui — a port of @floating-ui/react on top of the agnostic
2
+ // @floating-ui/dom. PHASE 1 (positioning) is in place: useFloating, useMergeRefs,
3
+ // the ref-aware `arrow`, and the re-exported @floating-ui/dom middleware. The
4
+ // interaction hooks (useInteractions/useHover/useClick/useDismiss/useFocus/useRole/
5
+ // useListNavigation/useTypeahead/useClientPoint), the components (FloatingPortal/
6
+ // Overlay/FocusManager/Arrow/List/Tree, Composite), and transitions land in
7
+ // subsequent phases.
8
+
9
+ // Agnostic positioning core, re-exported from @floating-ui/dom.
10
+ export {
11
+ autoPlacement,
12
+ autoUpdate,
13
+ computePosition,
14
+ detectOverflow,
15
+ flip,
16
+ getOverflowAncestors,
17
+ hide,
18
+ inline,
19
+ limitShift,
20
+ offset,
21
+ platform,
22
+ shift,
23
+ size,
24
+ } from '@floating-ui/dom';
25
+
26
+ // Positioning + context. The public `useFloating` (from ./context) wraps the
27
+ // positioning core and returns the interaction `context`.
28
+ export { useFloating, useFloatingRootContext, createPubSub } from './context';
29
+ export { arrow } from './useFloating';
30
+ // The bare positioning core (the @floating-ui/react-dom `useFloating` shape, no
31
+ // interaction context) — consumed by bindings that only position (e.g. @octanejs/radix's
32
+ // Popper). Call as `usePositionFloating([options, slot])`.
33
+ export { usePositionFloating } from './useFloating';
34
+ export { useMergeRefs } from './useMergeRefs';
35
+ export { useId } from './useId';
36
+ export {
37
+ useFloatingTree,
38
+ useFloatingParentNodeId,
39
+ useFloatingNodeId,
40
+ FloatingTree,
41
+ FloatingNode,
42
+ FloatingNodeContext,
43
+ FloatingTreeContext,
44
+ } from './tree';
45
+
46
+ // Interaction hooks (phase 2 — in progress).
47
+ export { useInteractions } from './useInteractions';
48
+ export { useRole } from './useRole';
49
+ export { useClick } from './useClick';
50
+ export { useFocus } from './useFocus';
51
+ export { useDismiss } from './useDismiss';
52
+ export { useClientPoint } from './useClientPoint';
53
+ export { useListNavigation } from './useListNavigation';
54
+ export { useTypeahead } from './useTypeahead';
55
+ export { useHover } from './useHover';
56
+ export { safePolygon } from './safePolygon';
57
+
58
+ // Components (phase 3 — in progress).
59
+ export { FloatingOverlay } from './FloatingOverlay';
60
+ export {
61
+ FloatingPortal,
62
+ FocusGuard,
63
+ useFloatingPortalNode,
64
+ usePortalContext,
65
+ PortalContext,
66
+ } from './FloatingPortal';
67
+ export { FloatingList, useListItem, FloatingListContext } from './FloatingList';
68
+ export { FloatingArrow } from './FloatingArrow';
69
+ export { Composite, CompositeItem, CompositeContext } from './Composite';
70
+ export { FloatingFocusManager, VisuallyHiddenDismiss } from './FloatingFocusManager';
71
+
72
+ // Transitions + delay group (phase 4).
73
+ export { useTransitionStatus, useTransitionStyles } from './transitions';
74
+ export {
75
+ FloatingDelayGroup,
76
+ useDelayGroup,
77
+ useDelayGroupContext,
78
+ FloatingDelayGroupContext,
79
+ } from './delayGroup';
@@ -0,0 +1,46 @@
1
+ // Slot mechanics for @octanejs/floating-ui's plain-`.ts` hooks. octane's compiler
2
+ // injects a per-call-site Symbol slot as the trailing arg of every hook call in a
3
+ // compiled `.tsx`/`.tsrx`. These hook files are published source (consumed from
4
+ // node_modules, where the auto-slotting pass is skipped), so each hook receives the
5
+ // caller's slot as its trailing argument and derives a distinct sub-slot for every
6
+ // base hook it composes. The floating `context` threads the root slot to the
7
+ // interaction hooks so they need no slot of their own. (Same pattern as the other
8
+ // @octanejs bindings.)
9
+
10
+ // Derive a stable, distinct sub-slot from a wrapper's slot, namespaced per hook.
11
+ // Memoized: subSlot runs on EVERY hook call every render, and the naive form
12
+ // pays a string concat + global symbol-registry lookup each time. The cache is
13
+ // keyed by the slot symbol itself; the minted value is byte-identical to the
14
+ // uncached Symbol.for result, so identity is preserved across HMR re-evals and
15
+ // the per-package copies of this helper. Key universe is bounded: slots are
16
+ // per-call-site module constants (never minted per render).
17
+ const subSlotCache = new Map<symbol, Map<string, symbol>>();
18
+ export function subSlot(slot: symbol | undefined, tag: string): symbol | undefined {
19
+ if (slot === undefined) return undefined;
20
+ let byTag = subSlotCache.get(slot);
21
+ if (byTag === undefined) subSlotCache.set(slot, (byTag = new Map()));
22
+ let sym = byTag.get(tag);
23
+ if (sym === undefined) byTag.set(tag, (sym = Symbol.for((slot.description ?? '') + ':' + tag)));
24
+ return sym;
25
+ }
26
+
27
+ // Split the compiler-injected trailing slot off a hook's args. Needed because the
28
+ // public hooks take optional args, so the slot can't be located positionally.
29
+ export function splitSlot(args: any[]): [any[], symbol | undefined] {
30
+ const tail = args[args.length - 1];
31
+ const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
32
+ return [slot !== undefined ? args.slice(0, -1) : args, slot];
33
+ }
34
+
35
+ // A stable per-call-site slot for the binding's plain-`.ts` COMPONENTS (written
36
+ // with createElement, not compiled, so they get no auto-injected slots). A
37
+ // component runs in its OWN per-instance scope (componentSlot), so a globally
38
+ // stable Symbol.for(tag) resolves to a distinct slot per instance. Pass S('tag')
39
+ // as the slot to base hooks; sub-hooks derive their own sub-slots from its
40
+ // description.
41
+ const sCache = new Map<string, symbol>();
42
+ export function S(tag: string): symbol {
43
+ let sym = sCache.get(tag);
44
+ if (sym === undefined) sCache.set(tag, (sym = Symbol.for('@octanejs/floating-ui:' + tag)));
45
+ return sym;
46
+ }
package/src/pubsub.ts ADDED
@@ -0,0 +1,19 @@
1
+ // The tiny event emitter shared by useFloatingRootContext and FloatingTree. Kept
2
+ // in its own module so context.ts and tree.ts don't form an import cycle.
3
+ export function createPubSub(): any {
4
+ const map = new Map<string, Set<(data: any) => void>>();
5
+ return {
6
+ emit(event: string, data: any) {
7
+ map.get(event)?.forEach((listener) => listener(data));
8
+ },
9
+ on(event: string, listener: (data: any) => void) {
10
+ if (!map.has(event)) {
11
+ map.set(event, new Set());
12
+ }
13
+ map.get(event)!.add(listener);
14
+ },
15
+ off(event: string, listener: (data: any) => void) {
16
+ map.get(event)?.delete(listener);
17
+ },
18
+ };
19
+ }
@@ -0,0 +1,339 @@
1
+ // Ported from @floating-ui/react safePolygon — the "safe triangle" close handler
2
+ // that keeps a hover floating element open while the cursor traverses toward it.
3
+ // Pure geometry; the only framework touchpoints are the shared dom helpers.
4
+ import { isElement } from '@floating-ui/utils/dom';
5
+
6
+ import { clearTimeoutIfSet, contains, getNodeChildren, getTarget } from './utils';
7
+
8
+ function isPointInPolygon(point: number[], polygon: number[][]): boolean {
9
+ const [x, y] = point;
10
+ let isInsideResult = false;
11
+ const length = polygon.length;
12
+ for (let i = 0, j = length - 1; i < length; j = i++) {
13
+ const [xi, yi] = polygon[i] || [0, 0];
14
+ const [xj, yj] = polygon[j] || [0, 0];
15
+ const intersect = yi >= y !== yj >= y && x <= ((xj - xi) * (y - yi)) / (yj - yi) + xi;
16
+ if (intersect) {
17
+ isInsideResult = !isInsideResult;
18
+ }
19
+ }
20
+ return isInsideResult;
21
+ }
22
+ function isInside(point: number[], rect: any): boolean {
23
+ return (
24
+ point[0] >= rect.x &&
25
+ point[0] <= rect.x + rect.width &&
26
+ point[1] >= rect.y &&
27
+ point[1] <= rect.y + rect.height
28
+ );
29
+ }
30
+
31
+ export function safePolygon(options: any = {}): any {
32
+ const { buffer = 0.5, blockPointerEvents = false, requireIntent = true } = options;
33
+
34
+ const timeoutRef = { current: -1 };
35
+ let hasLanded = false;
36
+ let lastX: number | null = null;
37
+ let lastY: number | null = null;
38
+ let lastCursorTime = typeof performance !== 'undefined' ? performance.now() : 0;
39
+
40
+ function getCursorSpeed(x: number, y: number): number | null {
41
+ const currentTime = performance.now();
42
+ const elapsedTime = currentTime - lastCursorTime;
43
+ if (lastX === null || lastY === null || elapsedTime === 0) {
44
+ lastX = x;
45
+ lastY = y;
46
+ lastCursorTime = currentTime;
47
+ return null;
48
+ }
49
+ const deltaX = x - lastX;
50
+ const deltaY = y - lastY;
51
+ const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
52
+ const speed = distance / elapsedTime;
53
+ lastX = x;
54
+ lastY = y;
55
+ lastCursorTime = currentTime;
56
+ return speed;
57
+ }
58
+
59
+ const fn = (_ref: any) => {
60
+ const { x, y, placement, elements, onClose, nodeId, tree } = _ref;
61
+ return function onMouseMove(event: any) {
62
+ function close() {
63
+ clearTimeoutIfSet(timeoutRef);
64
+ onClose();
65
+ }
66
+ clearTimeoutIfSet(timeoutRef);
67
+ if (
68
+ !elements.domReference ||
69
+ !elements.floating ||
70
+ placement == null ||
71
+ x == null ||
72
+ y == null
73
+ ) {
74
+ return;
75
+ }
76
+ const { clientX, clientY } = event;
77
+ const clientPoint = [clientX, clientY];
78
+ const target = getTarget(event);
79
+ const isLeave = event.type === 'mouseleave';
80
+ const isOverFloatingEl = contains(elements.floating, target as any);
81
+ const isOverReferenceEl = contains(elements.domReference, target as any);
82
+ const refRect = elements.domReference.getBoundingClientRect();
83
+ const rect = elements.floating.getBoundingClientRect();
84
+ const side = placement.split('-')[0];
85
+ const cursorLeaveFromRight = x > rect.right - rect.width / 2;
86
+ const cursorLeaveFromBottom = y > rect.bottom - rect.height / 2;
87
+ const isOverReferenceRect = isInside(clientPoint, refRect);
88
+ const isFloatingWider = rect.width > refRect.width;
89
+ const isFloatingTaller = rect.height > refRect.height;
90
+ const left = (isFloatingWider ? refRect : rect).left;
91
+ const right = (isFloatingWider ? refRect : rect).right;
92
+ const top = (isFloatingTaller ? refRect : rect).top;
93
+ const bottom = (isFloatingTaller ? refRect : rect).bottom;
94
+
95
+ if (isOverFloatingEl) {
96
+ hasLanded = true;
97
+ if (!isLeave) {
98
+ return;
99
+ }
100
+ }
101
+ if (isOverReferenceEl) {
102
+ hasLanded = false;
103
+ }
104
+ if (isOverReferenceEl && !isLeave) {
105
+ hasLanded = true;
106
+ return;
107
+ }
108
+ if (
109
+ isLeave &&
110
+ isElement(event.relatedTarget) &&
111
+ contains(elements.floating, event.relatedTarget)
112
+ ) {
113
+ return;
114
+ }
115
+ if (tree && getNodeChildren(tree.nodesRef.current, nodeId).length) {
116
+ return;
117
+ }
118
+ if (
119
+ (side === 'top' && y >= refRect.bottom - 1) ||
120
+ (side === 'bottom' && y <= refRect.top + 1) ||
121
+ (side === 'left' && x >= refRect.right - 1) ||
122
+ (side === 'right' && x <= refRect.left + 1)
123
+ ) {
124
+ return close();
125
+ }
126
+
127
+ let rectPoly: number[][] = [];
128
+ switch (side) {
129
+ case 'top':
130
+ rectPoly = [
131
+ [left, refRect.top + 1],
132
+ [left, rect.bottom - 1],
133
+ [right, rect.bottom - 1],
134
+ [right, refRect.top + 1],
135
+ ];
136
+ break;
137
+ case 'bottom':
138
+ rectPoly = [
139
+ [left, rect.top + 1],
140
+ [left, refRect.bottom - 1],
141
+ [right, refRect.bottom - 1],
142
+ [right, rect.top + 1],
143
+ ];
144
+ break;
145
+ case 'left':
146
+ rectPoly = [
147
+ [rect.right - 1, bottom],
148
+ [rect.right - 1, top],
149
+ [refRect.left + 1, top],
150
+ [refRect.left + 1, bottom],
151
+ ];
152
+ break;
153
+ case 'right':
154
+ rectPoly = [
155
+ [refRect.right - 1, bottom],
156
+ [refRect.right - 1, top],
157
+ [rect.left + 1, top],
158
+ [rect.left + 1, bottom],
159
+ ];
160
+ break;
161
+ }
162
+
163
+ function getPolygon([px, py]: number[]): number[][] {
164
+ switch (side) {
165
+ case 'top': {
166
+ const cursorPointOne = [
167
+ isFloatingWider
168
+ ? px + buffer / 2
169
+ : cursorLeaveFromRight
170
+ ? px + buffer * 4
171
+ : px - buffer * 4,
172
+ py + buffer + 1,
173
+ ];
174
+ const cursorPointTwo = [
175
+ isFloatingWider
176
+ ? px - buffer / 2
177
+ : cursorLeaveFromRight
178
+ ? px + buffer * 4
179
+ : px - buffer * 4,
180
+ py + buffer + 1,
181
+ ];
182
+ const commonPoints = [
183
+ [
184
+ rect.left,
185
+ cursorLeaveFromRight
186
+ ? rect.bottom - buffer
187
+ : isFloatingWider
188
+ ? rect.bottom - buffer
189
+ : rect.top,
190
+ ],
191
+ [
192
+ rect.right,
193
+ cursorLeaveFromRight
194
+ ? isFloatingWider
195
+ ? rect.bottom - buffer
196
+ : rect.top
197
+ : rect.bottom - buffer,
198
+ ],
199
+ ];
200
+ return [cursorPointOne, cursorPointTwo, ...commonPoints];
201
+ }
202
+ case 'bottom': {
203
+ const cursorPointOne = [
204
+ isFloatingWider
205
+ ? px + buffer / 2
206
+ : cursorLeaveFromRight
207
+ ? px + buffer * 4
208
+ : px - buffer * 4,
209
+ py - buffer,
210
+ ];
211
+ const cursorPointTwo = [
212
+ isFloatingWider
213
+ ? px - buffer / 2
214
+ : cursorLeaveFromRight
215
+ ? px + buffer * 4
216
+ : px - buffer * 4,
217
+ py - buffer,
218
+ ];
219
+ const commonPoints = [
220
+ [
221
+ rect.left,
222
+ cursorLeaveFromRight
223
+ ? rect.top + buffer
224
+ : isFloatingWider
225
+ ? rect.top + buffer
226
+ : rect.bottom,
227
+ ],
228
+ [
229
+ rect.right,
230
+ cursorLeaveFromRight
231
+ ? isFloatingWider
232
+ ? rect.top + buffer
233
+ : rect.bottom
234
+ : rect.top + buffer,
235
+ ],
236
+ ];
237
+ return [cursorPointOne, cursorPointTwo, ...commonPoints];
238
+ }
239
+ case 'left': {
240
+ const cursorPointOne = [
241
+ px + buffer + 1,
242
+ isFloatingTaller
243
+ ? py + buffer / 2
244
+ : cursorLeaveFromBottom
245
+ ? py + buffer * 4
246
+ : py - buffer * 4,
247
+ ];
248
+ const cursorPointTwo = [
249
+ px + buffer + 1,
250
+ isFloatingTaller
251
+ ? py - buffer / 2
252
+ : cursorLeaveFromBottom
253
+ ? py + buffer * 4
254
+ : py - buffer * 4,
255
+ ];
256
+ const commonPoints = [
257
+ [
258
+ cursorLeaveFromBottom
259
+ ? rect.right - buffer
260
+ : isFloatingTaller
261
+ ? rect.right - buffer
262
+ : rect.left,
263
+ rect.top,
264
+ ],
265
+ [
266
+ cursorLeaveFromBottom
267
+ ? isFloatingTaller
268
+ ? rect.right - buffer
269
+ : rect.left
270
+ : rect.right - buffer,
271
+ rect.bottom,
272
+ ],
273
+ ];
274
+ return [...commonPoints, cursorPointOne, cursorPointTwo];
275
+ }
276
+ case 'right': {
277
+ const cursorPointOne = [
278
+ px - buffer,
279
+ isFloatingTaller
280
+ ? py + buffer / 2
281
+ : cursorLeaveFromBottom
282
+ ? py + buffer * 4
283
+ : py - buffer * 4,
284
+ ];
285
+ const cursorPointTwo = [
286
+ px - buffer,
287
+ isFloatingTaller
288
+ ? py - buffer / 2
289
+ : cursorLeaveFromBottom
290
+ ? py + buffer * 4
291
+ : py - buffer * 4,
292
+ ];
293
+ const commonPoints = [
294
+ [
295
+ cursorLeaveFromBottom
296
+ ? rect.left + buffer
297
+ : isFloatingTaller
298
+ ? rect.left + buffer
299
+ : rect.right,
300
+ rect.top,
301
+ ],
302
+ [
303
+ cursorLeaveFromBottom
304
+ ? isFloatingTaller
305
+ ? rect.left + buffer
306
+ : rect.right
307
+ : rect.left + buffer,
308
+ rect.bottom,
309
+ ],
310
+ ];
311
+ return [cursorPointOne, cursorPointTwo, ...commonPoints];
312
+ }
313
+ }
314
+ return [];
315
+ }
316
+
317
+ if (isPointInPolygon([clientX, clientY], rectPoly)) {
318
+ return;
319
+ }
320
+ if (hasLanded && !isOverReferenceRect) {
321
+ return close();
322
+ }
323
+ if (!isLeave && requireIntent) {
324
+ const cursorSpeed = getCursorSpeed(event.clientX, event.clientY);
325
+ const cursorSpeedThreshold = 0.1;
326
+ if (cursorSpeed !== null && cursorSpeed < cursorSpeedThreshold) {
327
+ return close();
328
+ }
329
+ }
330
+ if (!isPointInPolygon([clientX, clientY], getPolygon([x, y]))) {
331
+ close();
332
+ } else if (!hasLanded && requireIntent) {
333
+ timeoutRef.current = window.setTimeout(close, 40);
334
+ }
335
+ };
336
+ };
337
+ fn.__options = { blockPointerEvents };
338
+ return fn;
339
+ }