@octanejs/base-ui 0.1.14 → 0.1.15

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,375 @@
1
+ // Ported from .base-ui/packages/react/src/utils/usePopupViewport.tsx (v1.6.0). Builds the morphing
2
+ // viewport containers for a popup that one of several triggers can open: while the content swaps,
3
+ // a DOM clone of the outgoing content is kept mounted alongside the incoming content so the change
4
+ // can be animated, and the popup auto-resizes between the two sizes.
5
+ //
6
+ // octane adaptations: `createElement` instead of JSX; `useIsoLayoutEffect` → `useLayoutEffect`;
7
+ // `ReactDOM.flushSync` → octane `flushSync`; every composed hook threads an explicit slot.
8
+ //
9
+ // SLOT: plain-`.ts` hook; the trailing arg is the caller's slot.
10
+ import { createElement, Fragment, flushSync, useLayoutEffect, useRef, useState } from 'octane';
11
+
12
+ import { S, splitSlot, subSlot } from '../internal';
13
+ import { inertValue } from './inertValue';
14
+ import { ownerDocument } from './owner';
15
+ import { useAnimationFrame } from './useAnimationFrame';
16
+ import { useAnimationsFinished } from './useAnimationsFinished';
17
+ import { usePreviousValue } from './usePreviousValue';
18
+ import { useStableCallback } from './useStableCallback';
19
+ import { usePopupAutoResize } from './usePopupAutoResize';
20
+ import { useDirection } from './DirectionContext';
21
+ import type { Dimensions } from './getCssDimensions';
22
+ import type { Side } from './useAnchorPositioning';
23
+
24
+ export interface PopupViewportCssVars {
25
+ /** CSS variable name storing the popup width for the previous content snapshot. */
26
+ popupWidth: string;
27
+ /** CSS variable name storing the popup height for the previous content snapshot. */
28
+ popupHeight: string;
29
+ }
30
+
31
+ export interface PopupViewportState {
32
+ /** Direction from which the popup was activated, used for directional animations. */
33
+ activationDirection: string | undefined;
34
+ /** Whether the viewport is currently transitioning between contents. */
35
+ transitioning: boolean;
36
+ }
37
+
38
+ export interface UsePopupViewportParameters {
39
+ store: any;
40
+ side: Side;
41
+ cssVars: PopupViewportCssVars;
42
+ children?: any;
43
+ }
44
+
45
+ export interface UsePopupViewportResult {
46
+ children: any;
47
+ state: PopupViewportState;
48
+ }
49
+
50
+ interface Offset {
51
+ horizontal: number;
52
+ vertical: number;
53
+ }
54
+
55
+ /**
56
+ * Describes an offset as a "<horizontal> <vertical>" pair, treating small offsets as no movement.
57
+ */
58
+ function getActivationDirection(offset: Offset | null): string | undefined {
59
+ if (!offset) {
60
+ return undefined;
61
+ }
62
+ return `${getValueWithTolerance(offset.horizontal, 5, 'right', 'left')} ${getValueWithTolerance(
63
+ offset.vertical,
64
+ 5,
65
+ 'down',
66
+ 'up',
67
+ )}`;
68
+ }
69
+
70
+ function getValueWithTolerance(
71
+ value: number,
72
+ tolerance: number,
73
+ positiveLabel: string,
74
+ negativeLabel: string,
75
+ ): string {
76
+ if (value > tolerance) {
77
+ return positiveLabel;
78
+ }
79
+ if (value < -tolerance) {
80
+ return negativeLabel;
81
+ }
82
+ return '';
83
+ }
84
+
85
+ /** Calculates the relative position between the centers of two elements. */
86
+ function calculateRelativePosition(from: Element, to: Element): Offset {
87
+ const fromRect = from.getBoundingClientRect();
88
+ const toRect = to.getBoundingClientRect();
89
+
90
+ const fromCenter = {
91
+ x: fromRect.left + fromRect.width / 2,
92
+ y: fromRect.top + fromRect.height / 2,
93
+ };
94
+ const toCenter = { x: toRect.left + toRect.width / 2, y: toRect.top + toRect.height / 2 };
95
+
96
+ return { horizontal: toCenter.x - fromCenter.x, vertical: toCenter.y - fromCenter.y };
97
+ }
98
+
99
+ /**
100
+ * Returns a key that forces remounting content when triggers change or a payload is updated.
101
+ */
102
+ function usePopupContentKey(
103
+ activeTriggerId: string | null,
104
+ payload: unknown,
105
+ slot: symbol | undefined,
106
+ ): string {
107
+ const [contentKey, setContentKey] = useState(0, subSlot(slot, 'ck'));
108
+ const previousActiveTriggerIdRef = useRef(activeTriggerId, subSlot(slot, 'prevId'));
109
+ const previousPayloadRef = useRef(payload, subSlot(slot, 'prevPayload'));
110
+ const pendingPayloadUpdateRef = useRef(false, subSlot(slot, 'pending'));
111
+
112
+ useLayoutEffect(
113
+ () => {
114
+ // Compare against the last committed values to decide whether a new DOM subtree is needed.
115
+ const previousActiveTriggerId = previousActiveTriggerIdRef.current;
116
+ const previousPayload = previousPayloadRef.current;
117
+ const triggerIdChanged = activeTriggerId !== previousActiveTriggerId;
118
+ const payloadChanged = payload !== previousPayload;
119
+
120
+ if (triggerIdChanged) {
121
+ // Remount immediately on trigger change; remember if the payload hasn't caught up yet.
122
+ setContentKey((k) => k + 1);
123
+ pendingPayloadUpdateRef.current = !payloadChanged;
124
+ } else if (pendingPayloadUpdateRef.current && payloadChanged) {
125
+ // The payload arrived a render later, so remount once more to avoid reusing old nodes.
126
+ setContentKey((k) => k + 1);
127
+ pendingPayloadUpdateRef.current = false;
128
+ }
129
+
130
+ previousActiveTriggerIdRef.current = activeTriggerId;
131
+ previousPayloadRef.current = payload;
132
+ },
133
+ [activeTriggerId, payload],
134
+ subSlot(slot, 'l:key'),
135
+ );
136
+
137
+ return `${activeTriggerId ?? 'current'}-${contentKey}`;
138
+ }
139
+
140
+ export function usePopupViewport(...args: any[]): UsePopupViewportResult {
141
+ const [user, slotArg] = splitSlot(args);
142
+ const slot = slotArg ?? S('usePopupViewport');
143
+ const parameters = user[0] as UsePopupViewportParameters;
144
+
145
+ const { store, side, cssVars, children } = parameters;
146
+
147
+ const direction = useDirection();
148
+
149
+ const activeTrigger = store.useState('activeTriggerElement', subSlot(slot, 's:trigger'));
150
+ const activeTriggerId = store.useState('activeTriggerId', subSlot(slot, 's:triggerId'));
151
+ const open = store.useState('open', subSlot(slot, 's:open'));
152
+ const payload = store.useState('payload', subSlot(slot, 's:payload'));
153
+ const mounted = store.useState('mounted', subSlot(slot, 's:mounted'));
154
+ const popupElement = store.useState('popupElement', subSlot(slot, 's:popup'));
155
+ const positionerElement = store.useState('positionerElement', subSlot(slot, 's:positioner'));
156
+
157
+ const previousActiveTrigger = usePreviousValue<Element | null>(
158
+ open ? activeTrigger : null,
159
+ subSlot(slot, 'prevTrigger'),
160
+ );
161
+ // Remount current content on trigger changes (and once more when the payload lags) to avoid DOM
162
+ // reuse flashes.
163
+ const currentContentKey = usePopupContentKey(
164
+ activeTriggerId,
165
+ payload,
166
+ subSlot(slot, 'contentKey'),
167
+ );
168
+
169
+ const capturedNodeRef = useRef<HTMLElement | null>(null, subSlot(slot, 'captured'));
170
+ const [previousContentNode, setPreviousContentNode] = useState<HTMLElement | null>(
171
+ null,
172
+ subSlot(slot, 'prevNode'),
173
+ );
174
+
175
+ const [newTriggerOffset, setNewTriggerOffset] = useState<Offset | null>(
176
+ null,
177
+ subSlot(slot, 'offset'),
178
+ );
179
+
180
+ const currentContainerRef = useRef<HTMLDivElement | null>(null, subSlot(slot, 'curRef'));
181
+ const previousContainerRef = useRef<HTMLDivElement | null>(null, subSlot(slot, 'prevRef'));
182
+
183
+ const onAnimationsFinished = useAnimationsFinished(
184
+ currentContainerRef,
185
+ true,
186
+ false,
187
+ subSlot(slot, 'anim'),
188
+ );
189
+ const cleanupFrame = useAnimationFrame(subSlot(slot, 'frame'));
190
+
191
+ const [previousContentDimensions, setPreviousContentDimensions] = useState<Dimensions | null>(
192
+ null,
193
+ subSlot(slot, 'prevDims'),
194
+ );
195
+
196
+ const [showStartingStyleAttribute, setShowStartingStyleAttribute] = useState(
197
+ false,
198
+ subSlot(slot, 'starting'),
199
+ );
200
+
201
+ useLayoutEffect(
202
+ () => {
203
+ store.set('hasViewport', true);
204
+ return () => {
205
+ store.set('hasViewport', false);
206
+ };
207
+ },
208
+ [store],
209
+ subSlot(slot, 'l:hasViewport'),
210
+ );
211
+
212
+ const handleMeasureLayout = useStableCallback(
213
+ () => {
214
+ currentContainerRef.current?.style.setProperty('animation', 'none');
215
+ currentContainerRef.current?.style.setProperty('transition', 'none');
216
+ previousContainerRef.current?.style.setProperty('display', 'none');
217
+ },
218
+ subSlot(slot, 'hml'),
219
+ );
220
+
221
+ const handleMeasureLayoutComplete = useStableCallback(
222
+ (previousDimensions: Dimensions | null) => {
223
+ currentContainerRef.current?.style.removeProperty('animation');
224
+ currentContainerRef.current?.style.removeProperty('transition');
225
+ previousContainerRef.current?.style.removeProperty('display');
226
+
227
+ if (previousDimensions) {
228
+ setPreviousContentDimensions(previousDimensions);
229
+ }
230
+ },
231
+ subSlot(slot, 'hmlc'),
232
+ );
233
+
234
+ const lastHandledTriggerRef = useRef<Element | null>(null, subSlot(slot, 'lastTrigger'));
235
+
236
+ useLayoutEffect(
237
+ () => {
238
+ if (!open || !mounted) {
239
+ lastHandledTriggerRef.current = null;
240
+ }
241
+ },
242
+ [open, mounted],
243
+ subSlot(slot, 'l:resetTrigger'),
244
+ );
245
+
246
+ useLayoutEffect(
247
+ () => {
248
+ // When the trigger changes, promote the captured clone to state so both the new and old
249
+ // content render together.
250
+ if (
251
+ activeTrigger &&
252
+ previousActiveTrigger &&
253
+ activeTrigger !== previousActiveTrigger &&
254
+ lastHandledTriggerRef.current !== activeTrigger &&
255
+ capturedNodeRef.current
256
+ ) {
257
+ setPreviousContentNode(capturedNodeRef.current);
258
+ setShowStartingStyleAttribute(true);
259
+
260
+ // The relative position between the previous and new trigger drives the directional
261
+ // animation.
262
+ setNewTriggerOffset(calculateRelativePosition(previousActiveTrigger, activeTrigger));
263
+
264
+ cleanupFrame.request(() => {
265
+ flushSync(() => {
266
+ setShowStartingStyleAttribute(false);
267
+ });
268
+ onAnimationsFinished(() => {
269
+ setPreviousContentNode(null);
270
+ setPreviousContentDimensions(null);
271
+ capturedNodeRef.current = null;
272
+ });
273
+ });
274
+
275
+ lastHandledTriggerRef.current = activeTrigger;
276
+ }
277
+ },
278
+ [activeTrigger, previousActiveTrigger, previousContentNode, onAnimationsFinished, cleanupFrame],
279
+ subSlot(slot, 'l:transition'),
280
+ );
281
+
282
+ // Capture a clone of the current content subtree on every render. Previous *nodes* can't be kept
283
+ // (they may be stateful), so a DOM clone provides the visual continuity. Capturing every render
284
+ // means rapid trigger switches always snapshot the latest committed content.
285
+ useLayoutEffect(
286
+ () => {
287
+ const source = currentContainerRef.current;
288
+ if (!source) {
289
+ return;
290
+ }
291
+
292
+ const wrapper = ownerDocument(source).createElement('div');
293
+ for (const child of Array.from(source.childNodes)) {
294
+ wrapper.appendChild(child.cloneNode(true));
295
+ }
296
+
297
+ capturedNodeRef.current = wrapper;
298
+ },
299
+ undefined,
300
+ subSlot(slot, 'l:capture'),
301
+ );
302
+
303
+ const isTransitioning = previousContentNode != null;
304
+ let childrenToRender: any;
305
+ if (!isTransitioning) {
306
+ childrenToRender = createElement('div', {
307
+ 'data-current': true,
308
+ ref: currentContainerRef,
309
+ key: currentContentKey,
310
+ children,
311
+ });
312
+ } else {
313
+ childrenToRender = createElement(Fragment, {
314
+ children: [
315
+ createElement('div', {
316
+ 'data-previous': true,
317
+ inert: inertValue(true),
318
+ ref: previousContainerRef,
319
+ style: {
320
+ ...(previousContentDimensions
321
+ ? {
322
+ [cssVars.popupWidth]: `${previousContentDimensions.width}px`,
323
+ [cssVars.popupHeight]: `${previousContentDimensions.height}px`,
324
+ }
325
+ : null),
326
+ position: 'absolute',
327
+ },
328
+ key: 'previous',
329
+ 'data-ending-style': showStartingStyleAttribute ? undefined : '',
330
+ }),
331
+ createElement('div', {
332
+ 'data-current': true,
333
+ ref: currentContainerRef,
334
+ key: currentContentKey,
335
+ 'data-starting-style': showStartingStyleAttribute ? '' : undefined,
336
+ children,
337
+ }),
338
+ ],
339
+ });
340
+ }
341
+
342
+ // Populate the previous container imperatively with the cloned children.
343
+ useLayoutEffect(
344
+ () => {
345
+ const container = previousContainerRef.current;
346
+ if (!container || !previousContentNode) {
347
+ return;
348
+ }
349
+ container.replaceChildren(...Array.from(previousContentNode.childNodes));
350
+ },
351
+ [previousContentNode],
352
+ subSlot(slot, 'l:populate'),
353
+ );
354
+
355
+ usePopupAutoResize(
356
+ {
357
+ popupElement,
358
+ positionerElement,
359
+ mounted,
360
+ content: payload,
361
+ onMeasureLayout: handleMeasureLayout,
362
+ onMeasureLayoutComplete: handleMeasureLayoutComplete,
363
+ side,
364
+ direction,
365
+ },
366
+ subSlot(slot, 'resize'),
367
+ );
368
+
369
+ const state: PopupViewportState = {
370
+ activationDirection: getActivationDirection(newTriggerOffset),
371
+ transitioning: isTransitioning,
372
+ };
373
+
374
+ return { children: childrenToRender, state };
375
+ }
@@ -0,0 +1,25 @@
1
+ // Ported from .base-ui/packages/utils/src/usePreviousValue.ts. Returns the argument's previous
2
+ // value (null before there is one), updating during render rather than in an effect so the
3
+ // previous value is available on the same render the current one changes.
4
+ //
5
+ // SLOT: plain-`.ts` hook; the trailing arg is the caller's slot.
6
+ import { useState } from 'octane';
7
+
8
+ import { S, splitSlot, subSlot } from '../internal';
9
+
10
+ export function usePreviousValue<T>(...args: any[]): T | null {
11
+ const [user, slotArg] = splitSlot(args);
12
+ const slot = slotArg ?? S('usePreviousValue');
13
+ const value = user[0] as T;
14
+
15
+ const [state, setState] = useState<{ current: T; previous: T | null }>(
16
+ { current: value, previous: null },
17
+ subSlot(slot, 'st'),
18
+ );
19
+
20
+ if (value !== state.current) {
21
+ setState({ current: value, previous: state.current });
22
+ }
23
+
24
+ return state.previous;
25
+ }