@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.
- package/package.json +4 -4
- package/src/accordion.ts +615 -0
- package/src/checkbox.ts +8 -1
- package/src/collapsible.ts +916 -0
- package/src/dialog.ts +5 -1
- package/src/index.ts +2 -0
- package/src/meter.ts +10 -3
- package/src/number-field.ts +7 -1
- package/src/popover.ts +4 -1
- package/src/progress.ts +10 -3
- package/src/radio.ts +15 -2
- package/src/slider.ts +8 -2
- package/src/switch.ts +16 -2
- package/src/utils/FloatingPortalLite.ts +6 -1
- package/src/utils/collapsibleOpenStateMapping.ts +30 -0
- package/src/utils/floating/FloatingFocusManager.ts +6 -2
- package/src/utils/floating/FloatingPortal.ts +15 -3
|
@@ -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 };
|