@octanejs/base-ui 0.1.20 → 0.1.22

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,916 @@
1
+ // Ported from .base-ui/packages/react/src/collapsible/ (v1.6.0): root/CollapsibleRootContext,
2
+ // root/useCollapsibleRoot, root/CollapsibleRoot, root/stateAttributesMapping,
3
+ // trigger/CollapsibleTrigger, panel/useCollapsiblePanel, panel/CollapsiblePanel,
4
+ // panel/CollapsiblePanelCssVars — plus its `index.parts` (the `Collapsible` namespace).
5
+ //
6
+ // octane adaptations:
7
+ // 1. `forwardRef` → ref-as-prop, per the repo-wide divergence. `useMergedRefs` becomes an
8
+ // array ref, which octane accepts natively.
9
+ // 2. Events are NATIVE. Base UI reads `event.nativeEvent` to build its change-event details;
10
+ // octane hands the native event straight to the handler, so it is passed as-is.
11
+ // 3. `useIsoLayoutEffect` → `useLayoutEffect`, per the other ports in this package.
12
+ // 4. Every hook threads an explicit compiler slot (`S(...)` / `subSlot(...)`), because these
13
+ // are plain `.ts` hooks rather than compiled `.tsrx` call sites.
14
+ //
15
+ // Upstream's `<Activity>` animation-resume suppression IS ported. It exists because Activity
16
+ // tears down effects while preserving state and DOM, so revealing an already-open keyframe panel
17
+ // would replay its open animation. Octane has the same contract, not an absence of one: it
18
+ // exports `Activity`, a hidden subtree still renders, `deactivateScope` fires each effect's
19
+ // cleanup and clears its deps, and the reveal re-render re-fires them — so the teardown-marks,
20
+ // reveal-suppresses mechanism works here unchanged.
21
+ import {
22
+ createContext,
23
+ createElement,
24
+ useContext,
25
+ useEffect,
26
+ useLayoutEffect,
27
+ useMemo,
28
+ useRef,
29
+ useState,
30
+ } from 'octane';
31
+
32
+ import { S, splitSlot, subSlot } from './internal';
33
+ import { useRenderElement } from './utils/useRenderElement';
34
+ import type { StateAttributesMapping } from './utils/getStateAttributesProps';
35
+ import { useBaseUiId } from './utils/useBaseUiId';
36
+ import { useButton } from './utils/useButton';
37
+ import { useControlled } from './utils/useControlled';
38
+ import { useStableCallback } from './utils/useStableCallback';
39
+ import { createChangeEventDetails, REASONS } from './utils/createChangeEventDetails';
40
+ import { useTransitionStatus, transitionStatusMapping } from './utils/useTransitionStatus';
41
+ import type { TransitionStatus } from './utils/useTransitionStatus';
42
+ import { addEventListener } from './utils/addEventListener';
43
+ import { AnimationFrame } from './utils/useAnimationFrame';
44
+ import { ownerWindow } from './utils/owner';
45
+ import { resolveStyle } from './utils/resolveStyle';
46
+ import { useAnimationsFinished } from './utils/useAnimationsFinished';
47
+ import { useOpenChangeComplete } from './utils/useOpenChangeComplete';
48
+ import { useValueAsRef } from './utils/useValueAsRef';
49
+ import {
50
+ collapsibleOpenStateMapping,
51
+ collapsibleTriggerOpenStateMapping,
52
+ } from './utils/collapsibleOpenStateMapping';
53
+
54
+ export interface CollapsibleRootState {
55
+ open: boolean;
56
+ disabled: boolean;
57
+ transitionStatus: TransitionStatus;
58
+ }
59
+
60
+ const rootStateAttributesMapping: StateAttributesMapping<CollapsibleRootState> = {
61
+ ...(collapsibleOpenStateMapping as StateAttributesMapping<any>),
62
+ ...(transitionStatusMapping as StateAttributesMapping<any>),
63
+ };
64
+
65
+ const triggerStateAttributesMapping: StateAttributesMapping<CollapsibleRootState> = {
66
+ ...(collapsibleTriggerOpenStateMapping as StateAttributesMapping<any>),
67
+ ...(transitionStatusMapping as StateAttributesMapping<any>),
68
+ };
69
+
70
+ // --- Context -----------------------------------------------------------------
71
+
72
+ export interface CollapsibleRootContextValue {
73
+ disabled: boolean;
74
+ handleTrigger: (event: Event) => void;
75
+ mounted: boolean;
76
+ open: boolean;
77
+ panelId: string | undefined;
78
+ setMounted: (nextMounted: boolean) => void;
79
+ setOpen: (open: boolean) => void;
80
+ setPanelIdState: (id: string | undefined) => void;
81
+ transitionStatus: TransitionStatus;
82
+ onOpenChange: (open: boolean, eventDetails: any) => void;
83
+ state: CollapsibleRootState;
84
+ }
85
+
86
+ // Exported because Accordion.Item provides its OWN value for this context rather than rendering a
87
+ // Collapsible.Root — that is how upstream shares the whole collapsible open/transition machinery
88
+ // with each accordion item. Consumers should reach for `useCollapsibleRootContext` instead.
89
+ export const CollapsibleRootContext = createContext<CollapsibleRootContextValue | undefined>(
90
+ undefined,
91
+ );
92
+
93
+ export function useCollapsibleRootContext(): CollapsibleRootContextValue {
94
+ const context = useContext(CollapsibleRootContext);
95
+ if (context === undefined) {
96
+ throw new Error(
97
+ 'Base UI: CollapsibleRootContext is missing. Collapsible parts must be placed within <Collapsible.Root>.',
98
+ );
99
+ }
100
+ return context;
101
+ }
102
+
103
+ // --- useCollapsibleRoot ------------------------------------------------------
104
+
105
+ export function useCollapsibleRoot(
106
+ ...args: any[]
107
+ ): Omit<CollapsibleRootContextValue, 'onOpenChange' | 'state'> {
108
+ const [user, slotArg] = splitSlot(args);
109
+ const slot = slotArg ?? S('useCollapsibleRoot');
110
+ const {
111
+ open: openParam,
112
+ defaultOpen,
113
+ onOpenChange,
114
+ disabled,
115
+ } = user[0] as {
116
+ open?: boolean;
117
+ defaultOpen?: boolean;
118
+ onOpenChange: (open: boolean, eventDetails: any) => void;
119
+ disabled: boolean;
120
+ };
121
+
122
+ const [open, setOpen] = useControlled<boolean>(
123
+ { controlled: openParam, default: defaultOpen, name: 'Collapsible', state: 'open' },
124
+ subSlot(slot, 'ctrl'),
125
+ );
126
+
127
+ const { mounted, setMounted, transitionStatus } = useTransitionStatus(
128
+ open,
129
+ true,
130
+ true,
131
+ subSlot(slot, 'transition'),
132
+ );
133
+
134
+ const defaultPanelId = useBaseUiId(undefined, subSlot(slot, 'id'));
135
+ const [panelIdState, setPanelIdState] = useState<string | undefined>(
136
+ undefined,
137
+ subSlot(slot, 'panelId'),
138
+ );
139
+ const panelId = panelIdState ?? defaultPanelId;
140
+
141
+ const handleTrigger = useStableCallback(
142
+ (event: Event) => {
143
+ const nextOpen = !open;
144
+ // octane: events are native, so the handler already HAS what Base UI reaches for
145
+ // through `event.nativeEvent`.
146
+ const eventDetails = createChangeEventDetails(REASONS.triggerPress, event);
147
+
148
+ onOpenChange(nextOpen, eventDetails);
149
+
150
+ if (eventDetails.isCanceled) {
151
+ return;
152
+ }
153
+
154
+ setOpen(nextOpen);
155
+ },
156
+ subSlot(slot, 'trigger'),
157
+ );
158
+
159
+ return useMemo(
160
+ () => ({
161
+ disabled,
162
+ handleTrigger,
163
+ mounted,
164
+ open,
165
+ panelId,
166
+ setMounted,
167
+ setOpen,
168
+ setPanelIdState,
169
+ transitionStatus,
170
+ }),
171
+ [
172
+ disabled,
173
+ handleTrigger,
174
+ mounted,
175
+ open,
176
+ panelId,
177
+ setMounted,
178
+ setOpen,
179
+ setPanelIdState,
180
+ transitionStatus,
181
+ ],
182
+ subSlot(slot, 'memo'),
183
+ );
184
+ }
185
+
186
+ // --- Root --------------------------------------------------------------------
187
+
188
+ function CollapsibleRoot(props: any): any {
189
+ const slot = S('CollapsibleRoot');
190
+ const {
191
+ render,
192
+ className,
193
+ defaultOpen = false,
194
+ disabled = false,
195
+ onOpenChange: onOpenChangeProp,
196
+ open,
197
+ style,
198
+ ref,
199
+ ...elementProps
200
+ } = props;
201
+
202
+ const onOpenChange = useStableCallback(onOpenChangeProp ?? (() => {}), subSlot(slot, 'onOpen'));
203
+
204
+ const collapsible = useCollapsibleRoot(
205
+ { open, defaultOpen, onOpenChange, disabled },
206
+ subSlot(slot, 'root'),
207
+ );
208
+
209
+ const state: CollapsibleRootState = useMemo(
210
+ () => ({
211
+ open: collapsible.open,
212
+ disabled: collapsible.disabled,
213
+ transitionStatus: collapsible.transitionStatus,
214
+ }),
215
+ [collapsible.open, collapsible.disabled, collapsible.transitionStatus],
216
+ subSlot(slot, 'state'),
217
+ );
218
+
219
+ const contextValue: CollapsibleRootContextValue = useMemo(
220
+ () => ({ ...collapsible, onOpenChange, state }),
221
+ [collapsible, onOpenChange, state],
222
+ subSlot(slot, 'ctx'),
223
+ );
224
+
225
+ const element = useRenderElement(
226
+ 'div',
227
+ { render, className, style },
228
+ {
229
+ state,
230
+ ref,
231
+ props: elementProps,
232
+ stateAttributesMapping: rootStateAttributesMapping,
233
+ },
234
+ subSlot(slot, 're'),
235
+ );
236
+
237
+ return createElement(CollapsibleRootContext.Provider, {
238
+ value: contextValue,
239
+ children: element,
240
+ });
241
+ }
242
+
243
+ // --- Trigger -----------------------------------------------------------------
244
+
245
+ function CollapsibleTrigger(props: any): any {
246
+ const slot = S('CollapsibleTrigger');
247
+ const {
248
+ panelId,
249
+ open,
250
+ handleTrigger,
251
+ state,
252
+ disabled: contextDisabled,
253
+ } = useCollapsibleRootContext();
254
+
255
+ const {
256
+ className,
257
+ disabled = contextDisabled,
258
+ render,
259
+ nativeButton = true,
260
+ style,
261
+ ref,
262
+ ...elementProps
263
+ } = props;
264
+
265
+ const { getButtonProps, buttonRef } = useButton(
266
+ { disabled, focusableWhenDisabled: true, native: nativeButton },
267
+ subSlot(slot, 'button'),
268
+ );
269
+
270
+ return useRenderElement(
271
+ 'button',
272
+ { render, className, style },
273
+ {
274
+ state,
275
+ ref: [ref, buttonRef],
276
+ props: [
277
+ {
278
+ 'aria-controls': open ? panelId : undefined,
279
+ 'aria-expanded': open,
280
+ onClick: handleTrigger,
281
+ },
282
+ elementProps,
283
+ getButtonProps,
284
+ ],
285
+ stateAttributesMapping: triggerStateAttributesMapping,
286
+ },
287
+ subSlot(slot, 're'),
288
+ );
289
+ }
290
+
291
+ // --- Panel -------------------------------------------------------------------
292
+
293
+ export const CollapsiblePanelCssVars = {
294
+ collapsiblePanelHeight: '--collapsible-panel-height',
295
+ collapsiblePanelWidth: '--collapsible-panel-width',
296
+ } as const;
297
+
298
+ type AnimationType = 'css-transition' | 'css-animation' | 'none';
299
+
300
+ interface Dimensions {
301
+ height: number | undefined;
302
+ width: number | undefined;
303
+ }
304
+
305
+ const EMPTY_DIMENSIONS: Dimensions = { height: undefined, width: undefined };
306
+
307
+ function getDimensions(element: HTMLElement): Dimensions {
308
+ return { height: element.scrollHeight, width: element.scrollWidth };
309
+ }
310
+
311
+ function hasNonZeroDuration(value: string): boolean {
312
+ return value
313
+ .split(',')
314
+ .map((part) => part.trim())
315
+ .some((part) => part !== '' && Number.parseFloat(part) > 0);
316
+ }
317
+
318
+ function getAnimationType(
319
+ element: HTMLElement,
320
+ hasSuppressedMountAnimation = false,
321
+ ): AnimationType {
322
+ const panelStyles = ownerWindow(element).getComputedStyle(element);
323
+ const hasAnimation =
324
+ (panelStyles.animationName
325
+ .split(',')
326
+ .map((name) => name.trim())
327
+ .some((name) => name !== '' && name !== 'none') ||
328
+ hasSuppressedMountAnimation) &&
329
+ hasNonZeroDuration(panelStyles.animationDuration);
330
+ const hasTransition = hasNonZeroDuration(panelStyles.transitionDuration);
331
+
332
+ if (hasAnimation && hasTransition) {
333
+ if (process.env.NODE_ENV !== 'production') {
334
+ console.warn(
335
+ 'Base UI: CSS transitions and CSS animations both detected on Collapsible or Accordion panel. ' +
336
+ 'Only one of either animation type should be used.',
337
+ );
338
+ }
339
+ return 'css-transition';
340
+ }
341
+ if (hasTransition) {
342
+ return 'css-transition';
343
+ }
344
+ if (hasAnimation) {
345
+ return 'css-animation';
346
+ }
347
+ return 'none';
348
+ }
349
+
350
+ /** Temporarily override an inline style property; the returned cleanup restores it. */
351
+ function setTemporaryStyle(element: HTMLElement, property: string, value: string): () => void {
352
+ const previousValue = element.style.getPropertyValue(property);
353
+ const previousPriority = element.style.getPropertyPriority(property);
354
+
355
+ element.style.setProperty(property, value);
356
+
357
+ return () => {
358
+ if (previousValue === '') {
359
+ element.style.removeProperty(property);
360
+ return;
361
+ }
362
+ element.style.setProperty(property, previousValue, previousPriority);
363
+ };
364
+ }
365
+
366
+ /** Reset inline alignment styles that distort scroll-based measurement, restoring next frame. */
367
+ function resetLayoutStyles(element: HTMLElement): () => void {
368
+ const originalLayoutStyles: Record<string, string> = {
369
+ 'justify-content': element.style.justifyContent,
370
+ 'align-items': element.style.alignItems,
371
+ 'align-content': element.style.alignContent,
372
+ 'justify-items': element.style.justifyItems,
373
+ };
374
+
375
+ Object.keys(originalLayoutStyles).forEach((key) => {
376
+ element.style.setProperty(key, 'initial', 'important');
377
+ });
378
+
379
+ function restoreLayoutStyles() {
380
+ Object.entries(originalLayoutStyles).forEach(([key, value]) => {
381
+ if (value === '') {
382
+ element.style.removeProperty(key);
383
+ return;
384
+ }
385
+ element.style.setProperty(key, value);
386
+ });
387
+ }
388
+
389
+ const frame = AnimationFrame.request(restoreLayoutStyles);
390
+
391
+ return () => {
392
+ AnimationFrame.cancel(frame);
393
+ restoreLayoutStyles();
394
+ };
395
+ }
396
+
397
+ export function useCollapsiblePanel(...args: any[]): any {
398
+ const [user, slotArg] = splitSlot(args);
399
+ const slot = slotArg ?? S('useCollapsiblePanel');
400
+ const {
401
+ hiddenUntilFound,
402
+ id: idParam,
403
+ keepMounted,
404
+ mounted,
405
+ onOpenChange,
406
+ open,
407
+ setMounted,
408
+ setOpen,
409
+ transitionStatus,
410
+ } = user[0] as any;
411
+
412
+ const panelRef = useRef<HTMLDivElement | null>(null, subSlot(slot, 'panelRef'));
413
+ const animationTypeRef = useRef<AnimationType | null>(null, subSlot(slot, 'animType'));
414
+ const [dimensions, setDimensionsUnwrapped] = useState<Dimensions>(
415
+ EMPTY_DIMENSIONS,
416
+ subSlot(slot, 'dims'),
417
+ );
418
+ const lastMeasuredDimensionsRef = useRef<Dimensions>(EMPTY_DIMENSIONS, subSlot(slot, 'lastDims'));
419
+ // `beforematch` reveals matched content immediately, so the next open cycle skips
420
+ // author-defined motion once and then returns to normal.
421
+ const shouldSkipNextOpenRef = useRef(false, subSlot(slot, 'skipOpen'));
422
+ // Keyframe mount animations on initially open panels cause a visible layout shift during
423
+ // the server-rendered first paint, so suppress that first open until it has closed once.
424
+ const shouldPreventMountAnimationRef = useRef(open, subSlot(slot, 'preventMount'));
425
+ // `<Activity>` tears down effects while PRESERVING state and DOM, so revealing an
426
+ // already-open keyframe panel would otherwise replay its open animation. Octane's Activity
427
+ // has exactly React's contract here: a hidden subtree still renders, `deactivateScope` fires
428
+ // each effect's cleanup and clears its deps, and the reveal re-render re-fires them.
429
+ const shouldPreventActivityResumeAnimationRef = useRef(
430
+ false,
431
+ subSlot(slot, 'preventActivityResume'),
432
+ );
433
+ // Some open paths intentionally bypass motion while the shared root transition status still
434
+ // advances asynchronously; force the panel to idle so its attributes reflect the real state.
435
+ const [forcePanelIdle, setForcePanelIdle] = useState(false, subSlot(slot, 'forceIdle'));
436
+ const pendingTemporaryStyleRestoreRef = useRef<(() => void) | null>(
437
+ null,
438
+ subSlot(slot, 'pendingRestore'),
439
+ );
440
+
441
+ const latestStateRef = useValueAsRef<{ mounted: boolean; open: boolean }>(
442
+ { mounted, open },
443
+ subSlot(slot, 'latest'),
444
+ );
445
+ // Only used to handle panel close.
446
+ const runOnceCloseAnimationsFinish = useAnimationsFinished(
447
+ panelRef,
448
+ false,
449
+ false,
450
+ subSlot(slot, 'closeAnims'),
451
+ );
452
+
453
+ const hidden = !open && !mounted;
454
+ const panelTransitionStatus: TransitionStatus = forcePanelIdle ? 'idle' : transitionStatus;
455
+ // These refs are safe to read in render: they are only written from committed layout/effect
456
+ // paths and gate one-shot motion suppression for the next open lifecycle.
457
+ const shouldPreventOpenAnimation =
458
+ open &&
459
+ (shouldPreventMountAnimationRef.current || shouldPreventActivityResumeAnimationRef.current);
460
+ const renderedDimensions =
461
+ !open &&
462
+ mounted &&
463
+ animationTypeRef.current === 'css-animation' &&
464
+ dimensions.height === undefined &&
465
+ dimensions.width === undefined
466
+ ? lastMeasuredDimensionsRef.current
467
+ : dimensions;
468
+ const shouldPersistHiddenTransitionStyles =
469
+ hiddenUntilFound && hidden && animationTypeRef.current !== 'css-animation';
470
+
471
+ // Measured dimensions are reused when a CSS keyframe close needs a pixel size after the
472
+ // rendered dimensions have been reset back to `auto`. `false` only clears current state.
473
+ const setDimensions = useStableCallback(
474
+ (next: Dimensions, shouldCacheMeasurement = true) => {
475
+ if (shouldCacheMeasurement) {
476
+ lastMeasuredDimensionsRef.current = next;
477
+ }
478
+ setDimensionsUnwrapped(next);
479
+ },
480
+ subSlot(slot, 'setDims'),
481
+ );
482
+
483
+ const restorePendingTemporaryStyle = useStableCallback(
484
+ () => {
485
+ pendingTemporaryStyleRestoreRef.current?.();
486
+ pendingTemporaryStyleRestoreRef.current = null;
487
+ },
488
+ subSlot(slot, 'restorePending'),
489
+ );
490
+
491
+ // Runs from the teardown path. If an OPEN keyframe panel is being torn down while its state
492
+ // survives — i.e. `<Activity>` hiding it rather than a real unmount — remember to suppress the
493
+ // replayed open animation on the next committed reveal.
494
+ const markActivityResumeAnimationSuppressed = useStableCallback(
495
+ () => {
496
+ if (open && mounted && animationTypeRef.current === 'css-animation') {
497
+ shouldPreventActivityResumeAnimationRef.current = true;
498
+ }
499
+ },
500
+ subSlot(slot, 'markActivityResume'),
501
+ );
502
+
503
+ const setPendingTemporaryStyleRestore = useStableCallback(
504
+ (restore: () => void) => {
505
+ restorePendingTemporaryStyle();
506
+ pendingTemporaryStyleRestoreRef.current = () => {
507
+ pendingTemporaryStyleRestoreRef.current = null;
508
+ restore();
509
+ };
510
+ },
511
+ subSlot(slot, 'setPending'),
512
+ );
513
+
514
+ useLayoutEffect(
515
+ () => {
516
+ // `forcePanelIdle` is a temporary override for open paths that skip motion. Keep it
517
+ // active while the root still reports `starting`, then drop it once the root catches up.
518
+ if (!forcePanelIdle || transitionStatus === 'starting') {
519
+ return;
520
+ }
521
+ setForcePanelIdle(false);
522
+ },
523
+ [forcePanelIdle, transitionStatus],
524
+ subSlot(slot, 'idleEffect'),
525
+ );
526
+
527
+ useEffect(
528
+ () => () => {
529
+ markActivityResumeAnimationSuppressed();
530
+ restorePendingTemporaryStyle();
531
+ },
532
+ [markActivityResumeAnimationSuppressed, restorePendingTemporaryStyle],
533
+ subSlot(slot, 'unmount'),
534
+ );
535
+
536
+ useLayoutEffect(
537
+ () => {
538
+ const panel = panelRef.current;
539
+ if (!panel) {
540
+ return undefined;
541
+ }
542
+
543
+ // `beforematch` can force a `0s` duration so matched content reveals immediately.
544
+ // Restore the authored duration before detecting the next close animation type,
545
+ // otherwise that first close is misread as "no motion" and gets skipped.
546
+ if (!open && pendingTemporaryStyleRestoreRef.current) {
547
+ restorePendingTemporaryStyle();
548
+ }
549
+
550
+ const animationType = getAnimationType(panel, shouldPreventOpenAnimation);
551
+ animationTypeRef.current = animationType;
552
+
553
+ // Initially open keyframe panels skip their first paint animation to avoid layout
554
+ // shift, but the expanded size is still cached so the first close starts from pixels.
555
+ if (
556
+ open &&
557
+ transitionStatus === 'idle' &&
558
+ shouldPreventMountAnimationRef.current &&
559
+ animationType === 'css-animation'
560
+ ) {
561
+ lastMeasuredDimensionsRef.current = getDimensions(panel);
562
+ return undefined;
563
+ }
564
+
565
+ // The opening pass: measure the expanded size and, when necessary, neutralize
566
+ // author-defined motion so the panel can open immediately.
567
+ if (open && transitionStatus === 'starting') {
568
+ const skipNextOpen = shouldSkipNextOpenRef.current;
569
+ shouldSkipNextOpenRef.current = false;
570
+
571
+ if (animationType === 'none') {
572
+ setDimensions(getDimensions(panel));
573
+ setForcePanelIdle(true);
574
+ return undefined;
575
+ }
576
+
577
+ if (animationType === 'css-transition') {
578
+ const restoreLayout = resetLayoutStyles(panel);
579
+ setDimensions(getDimensions(panel));
580
+
581
+ if (!skipNextOpen) {
582
+ return restoreLayout;
583
+ }
584
+
585
+ setPendingTemporaryStyleRestore(setTemporaryStyle(panel, 'transition-duration', '0s'));
586
+ setForcePanelIdle(true);
587
+ return restoreLayout;
588
+ }
589
+
590
+ if (animationType === 'css-animation') {
591
+ setDimensions(getDimensions(panel));
592
+
593
+ if (!skipNextOpen) {
594
+ setTemporaryStyle(panel, 'animation-name', 'none')();
595
+ return undefined;
596
+ }
597
+
598
+ const restoreAnimationName = setTemporaryStyle(panel, 'animation-name', 'none');
599
+ const restoreAnimationDuration = setTemporaryStyle(panel, 'animation-duration', '0s');
600
+
601
+ restoreAnimationName();
602
+ setPendingTemporaryStyleRestore(restoreAnimationDuration);
603
+ setForcePanelIdle(true);
604
+ return undefined;
605
+ }
606
+ }
607
+
608
+ // Capture the current size as soon as close is requested, before the deferred ending
609
+ // phase applies closed styles, so close transitions start from a measured pixel value.
610
+ if (!open && mounted && (transitionStatus === 'idle' || transitionStatus === 'starting')) {
611
+ shouldPreventMountAnimationRef.current = false;
612
+ shouldPreventActivityResumeAnimationRef.current = false;
613
+
614
+ if (animationType === 'none') {
615
+ setDimensions(EMPTY_DIMENSIONS, false);
616
+ setMounted(false);
617
+ return undefined;
618
+ }
619
+
620
+ setDimensions(getDimensions(panel));
621
+ return undefined;
622
+ }
623
+
624
+ if (transitionStatus !== 'ending') {
625
+ return undefined;
626
+ }
627
+
628
+ if (animationType === 'none') {
629
+ setMounted(false);
630
+ return undefined;
631
+ }
632
+
633
+ const nextDimensions = getDimensions(panel);
634
+ const hasMeasuredSize = (nextDimensions.height ?? 0) > 0 || (nextDimensions.width ?? 0) > 0;
635
+
636
+ if (!hasMeasuredSize) {
637
+ setMounted(false);
638
+ return undefined;
639
+ }
640
+
641
+ setDimensions(nextDimensions);
642
+
643
+ if (animationType === 'css-animation') {
644
+ setTemporaryStyle(panel, 'animation-name', 'none')();
645
+ }
646
+
647
+ return undefined;
648
+ },
649
+ [
650
+ mounted,
651
+ open,
652
+ restorePendingTemporaryStyle,
653
+ setDimensions,
654
+ setMounted,
655
+ setPendingTemporaryStyleRestore,
656
+ shouldPreventOpenAnimation,
657
+ transitionStatus,
658
+ ],
659
+ subSlot(slot, 'mainEffect'),
660
+ );
661
+
662
+ useOpenChangeComplete(
663
+ {
664
+ enabled: open && mounted && panelTransitionStatus === 'idle',
665
+ open: true,
666
+ ref: panelRef,
667
+ onComplete() {
668
+ if (!open) {
669
+ return;
670
+ }
671
+ setDimensions(EMPTY_DIMENSIONS, false);
672
+ },
673
+ },
674
+ subSlot(slot, 'openComplete'),
675
+ );
676
+
677
+ // Closing panels need sequencing beyond `useOpenChangeComplete`. This passive effect runs
678
+ // after the `ending` render has committed, so `[data-ending-style]` is already present.
679
+ // Chrome can register the exit transition a frame later when an Accordion closes one item
680
+ // while opening another, so wait one frame before watching animations.
681
+ useEffect(
682
+ () => {
683
+ if (open || !mounted || panelTransitionStatus !== 'ending') {
684
+ return undefined;
685
+ }
686
+
687
+ const panel = panelRef.current;
688
+ if (!panel) {
689
+ return undefined;
690
+ }
691
+
692
+ const abortController = new AbortController();
693
+
694
+ function handleComplete() {
695
+ if (latestStateRef.current.open) {
696
+ return;
697
+ }
698
+ setMounted(false);
699
+ setDimensions(EMPTY_DIMENSIONS, false);
700
+ }
701
+
702
+ const endingStyleFrame = AnimationFrame.request(() => {
703
+ if (!abortController.signal.aborted) {
704
+ runOnceCloseAnimationsFinish(handleComplete, abortController.signal);
705
+ }
706
+ });
707
+
708
+ return () => {
709
+ AnimationFrame.cancel(endingStyleFrame);
710
+ abortController.abort();
711
+ };
712
+ },
713
+ [
714
+ latestStateRef,
715
+ mounted,
716
+ open,
717
+ panelTransitionStatus,
718
+ runOnceCloseAnimationsFinish,
719
+ setDimensions,
720
+ setMounted,
721
+ ],
722
+ subSlot(slot, 'closeEffect'),
723
+ );
724
+
725
+ useLayoutEffect(
726
+ () => {
727
+ const panel = panelRef.current;
728
+ if (!panel || !hiddenUntilFound || !hidden) {
729
+ return;
730
+ }
731
+ // React coerces `hidden` to a boolean, so upstream forces the string back in the DOM.
732
+ // octane serializes the string faithfully, but this is kept: the attribute must also
733
+ // survive a re-render that recomputes `hidden` as a boolean prop below.
734
+ panel.setAttribute('hidden', 'until-found');
735
+ },
736
+ [hidden, hiddenUntilFound],
737
+ subSlot(slot, 'hiddenEffect'),
738
+ );
739
+
740
+ useEffect(
741
+ () => {
742
+ const panel = panelRef.current;
743
+ if (!panel) {
744
+ return undefined;
745
+ }
746
+
747
+ function handleBeforeMatch(event: Event) {
748
+ const eventDetails = createChangeEventDetails(REASONS.none, event);
749
+
750
+ onOpenChange(true, eventDetails);
751
+
752
+ if (eventDetails.isCanceled) {
753
+ return;
754
+ }
755
+
756
+ shouldSkipNextOpenRef.current = true;
757
+ setOpen(true);
758
+ }
759
+
760
+ return addEventListener(panel, 'beforematch', handleBeforeMatch);
761
+ },
762
+ [onOpenChange, setOpen],
763
+ subSlot(slot, 'beforematch'),
764
+ );
765
+
766
+ const shouldRender = keepMounted || hiddenUntilFound || mounted || open;
767
+
768
+ return {
769
+ height: renderedDimensions.height,
770
+ props: {
771
+ ...(shouldPersistHiddenTransitionStyles ? { 'data-starting-style': '' } : undefined),
772
+ hidden,
773
+ id: idParam,
774
+ },
775
+ panelRef,
776
+ shouldPreventOpenAnimation,
777
+ shouldRender,
778
+ transitionStatus: panelTransitionStatus,
779
+ width: renderedDimensions.width,
780
+ };
781
+ }
782
+
783
+ export interface CollapsiblePanelState extends CollapsibleRootState {
784
+ transitionStatus: TransitionStatus;
785
+ }
786
+
787
+ function CollapsiblePanel(props: any): any {
788
+ const slot = S('CollapsiblePanel');
789
+ const {
790
+ className,
791
+ hiddenUntilFound: hiddenUntilFoundProp,
792
+ keepMounted: keepMountedProp,
793
+ render,
794
+ id: idProp,
795
+ style,
796
+ ref,
797
+ ...elementProps
798
+ } = props;
799
+
800
+ const {
801
+ mounted,
802
+ onOpenChange,
803
+ open,
804
+ panelId,
805
+ setMounted,
806
+ setPanelIdState,
807
+ setOpen,
808
+ state,
809
+ transitionStatus,
810
+ } = useCollapsibleRootContext();
811
+
812
+ const hiddenUntilFound = hiddenUntilFoundProp ?? false;
813
+ const keepMounted = keepMountedProp ?? false;
814
+
815
+ if (process.env.NODE_ENV !== 'production') {
816
+ useLayoutEffect(
817
+ () => {
818
+ if (hiddenUntilFound && keepMountedProp === false) {
819
+ console.warn(
820
+ 'Base UI: The `keepMounted={false}` prop on `Collapsible.Panel` is ignored when ' +
821
+ '`hiddenUntilFound` is enabled, since the panel must remain mounted while closed.',
822
+ );
823
+ }
824
+ },
825
+ [hiddenUntilFound, keepMountedProp],
826
+ subSlot(slot, 'warn'),
827
+ );
828
+ }
829
+
830
+ useLayoutEffect(
831
+ () => {
832
+ if (idProp) {
833
+ setPanelIdState(idProp);
834
+ return () => {
835
+ setPanelIdState(undefined);
836
+ };
837
+ }
838
+ return undefined;
839
+ },
840
+ [idProp, setPanelIdState],
841
+ subSlot(slot, 'registerId'),
842
+ );
843
+
844
+ const {
845
+ height,
846
+ props: panelProps,
847
+ panelRef,
848
+ shouldPreventOpenAnimation,
849
+ shouldRender,
850
+ transitionStatus: panelTransitionStatus,
851
+ width,
852
+ } = useCollapsiblePanel(
853
+ {
854
+ hiddenUntilFound,
855
+ id: panelId,
856
+ keepMounted,
857
+ mounted,
858
+ onOpenChange,
859
+ open,
860
+ setMounted,
861
+ setOpen,
862
+ transitionStatus,
863
+ },
864
+ subSlot(slot, 'panel'),
865
+ );
866
+
867
+ const panelState: CollapsiblePanelState = {
868
+ ...state,
869
+ transitionStatus: panelTransitionStatus,
870
+ };
871
+
872
+ const resolvedStyle = resolveStyle(style, panelState);
873
+
874
+ const element = useRenderElement(
875
+ 'div',
876
+ { render, className, style: undefined },
877
+ {
878
+ state: panelState,
879
+ ref: [ref, panelRef],
880
+ props: [
881
+ panelProps,
882
+ {
883
+ style: {
884
+ [CollapsiblePanelCssVars.collapsiblePanelHeight]:
885
+ height === undefined ? 'auto' : `${height}px`,
886
+ [CollapsiblePanelCssVars.collapsiblePanelWidth]:
887
+ width === undefined ? 'auto' : `${width}px`,
888
+ },
889
+ },
890
+ elementProps,
891
+ resolvedStyle ? { style: resolvedStyle } : undefined,
892
+ // The public `style` prop is resolved above so a temporary `animationName: 'none'`
893
+ // can still win after the user's inline styles have been merged.
894
+ shouldPreventOpenAnimation ? { style: { animationName: 'none' } } : undefined,
895
+ ],
896
+ stateAttributesMapping: rootStateAttributesMapping,
897
+ },
898
+ subSlot(slot, 're'),
899
+ );
900
+
901
+ if (!shouldRender) {
902
+ return null;
903
+ }
904
+
905
+ return element;
906
+ }
907
+
908
+ // --- Namespace (mirrors `export * as Collapsible`) ---------------------------
909
+
910
+ export const Collapsible = {
911
+ Root: CollapsibleRoot,
912
+ Trigger: CollapsibleTrigger,
913
+ Panel: CollapsiblePanel,
914
+ };
915
+
916
+ export { CollapsibleRoot, CollapsibleTrigger, CollapsiblePanel };