Skip to content

Commit a4eac5b

Browse files
authored
Collapsible Primitive (#947)
1 parent b80534c commit a4eac5b

File tree

7 files changed

+678
-0
lines changed

7 files changed

+678
-0
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import React, { createContext, useContext } from 'react';
2+
3+
/**
4+
* Context value for CollapsiblePrimitive
5+
*
6+
* This context provides a single source of truth for the open state
7+
* throughout the component tree. Only the Root component should control
8+
* the open state - child components like Trigger and Content should only
9+
* read from and update the context, not override it with local props.
10+
*/
11+
export type CollapsiblePrimitiveContextValue = {
12+
/**
13+
* Whether the collapsible is open
14+
*/
15+
open: boolean;
16+
/**
17+
* Callback fired when the open state changes
18+
*/
19+
onOpenChange: (open: boolean) => void;
20+
/**
21+
* Whether the collapsible is disabled
22+
*/
23+
disabled?: boolean;
24+
/**
25+
* Unique ID for ARIA relationships
26+
*/
27+
contentId: string;
28+
/**
29+
* Duration of the height transition animation in milliseconds
30+
*/
31+
transitionDuration: number;
32+
/**
33+
* CSS timing function for the transition
34+
*/
35+
transitionTimingFunction: string;
36+
};
37+
38+
export const CollapsiblePrimitiveContext = createContext<CollapsiblePrimitiveContextValue | undefined>(undefined);
39+
40+
export const useCollapsiblePrimitiveContext = (): CollapsiblePrimitiveContextValue => {
41+
const context = useContext(CollapsiblePrimitiveContext);
42+
43+
if (!context) {
44+
throw new Error(
45+
'Collapsible compound components must be used within a CollapsiblePrimitive.Root component'
46+
);
47+
}
48+
49+
return context;
50+
};
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import React, { useState, useRef, useEffect } from 'react';
2+
import Primitive from '~/core/primitives/Primitive';
3+
import { useCollapsiblePrimitiveContext } from '../contexts/CollapsiblePrimitiveContext';
4+
5+
export type CollapsiblePrimitiveContentProps = {
6+
/**
7+
* Content to be rendered inside the collapsible content
8+
*/
9+
children?: React.ReactNode;
10+
/**
11+
* CSS class name for custom styling
12+
*/
13+
className?: string;
14+
/**
15+
* For Polymorphic component support
16+
*/
17+
asChild?: boolean;
18+
/**
19+
* Additional props to be spread on the content element
20+
*/
21+
[key: string]: any;
22+
};
23+
24+
const CollapsiblePrimitiveContent = React.forwardRef<HTMLDivElement, CollapsiblePrimitiveContentProps>(
25+
({
26+
children,
27+
className,
28+
asChild = false,
29+
...props
30+
}, forwardedRef) => {
31+
const {
32+
open,
33+
contentId,
34+
transitionDuration,
35+
transitionTimingFunction
36+
} = useCollapsiblePrimitiveContext();
37+
38+
const ref = useRef<HTMLDivElement>(null);
39+
const combinedRef = (forwardedRef || ref) as React.RefObject<HTMLDivElement>;
40+
const [height, setHeight] = useState<number | undefined>(open ? undefined : 0);
41+
const animationTimeoutRef = useRef<NodeJS.Timeout>();
42+
43+
useEffect(() => {
44+
// Clear any existing timeout to avoid conflicts
45+
if (animationTimeoutRef.current) {
46+
clearTimeout(animationTimeoutRef.current);
47+
}
48+
49+
if (!ref.current) return;
50+
51+
if (open) {
52+
// Opening - Set to specific height first
53+
const contentHeight = ref.current.scrollHeight;
54+
setHeight(contentHeight);
55+
56+
// After animation completes, set height to undefined for responsive flexibility
57+
animationTimeoutRef.current = setTimeout(() => {
58+
setHeight(undefined);
59+
}, transitionDuration); // Use the transition duration from context
60+
} else {
61+
// Closing - First set to current height
62+
setHeight(ref.current.scrollHeight);
63+
64+
// Use RAF to ensure browser processes the height setting
65+
requestAnimationFrame(() => {
66+
// Force a reflow
67+
const _ = ref.current?.offsetHeight;
68+
69+
// Then animate to 0 in the next frame
70+
requestAnimationFrame(() => {
71+
setHeight(0);
72+
});
73+
});
74+
}
75+
76+
return () => {
77+
if (animationTimeoutRef.current) {
78+
clearTimeout(animationTimeoutRef.current);
79+
}
80+
};
81+
}, [open, transitionDuration]);
82+
83+
return (
84+
<Primitive.div
85+
id={contentId}
86+
ref={combinedRef}
87+
aria-hidden={!open}
88+
data-state={open ? 'open' : 'closed'}
89+
className={className}
90+
style={{
91+
height: height !== undefined ? `${height}px` : undefined,
92+
overflow: 'hidden',
93+
transition: `height ${transitionDuration}ms ${transitionTimingFunction}`
94+
}}
95+
{...props}
96+
>
97+
{children}
98+
</Primitive.div>
99+
);
100+
}
101+
);
102+
103+
CollapsiblePrimitiveContent.displayName = 'CollapsiblePrimitiveContent';
104+
105+
export default CollapsiblePrimitiveContent;
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import React, { useId } from 'react';
2+
import Primitive from '~/core/primitives/Primitive';
3+
import { CollapsiblePrimitiveContext } from '../contexts/CollapsiblePrimitiveContext';
4+
import useControllableState from '~/core/hooks/useControllableState';
5+
6+
export type CollapsiblePrimitiveRootProps = {
7+
/**
8+
* Whether the collapsible is open by default (uncontrolled)
9+
*/
10+
defaultOpen?: boolean;
11+
/**
12+
* Controls the open state (controlled)
13+
*/
14+
open?: boolean;
15+
/**
16+
* Callback fired when the open state changes
17+
*/
18+
onOpenChange?: (open: boolean) => void;
19+
/**
20+
* Content to be rendered inside the collapsible
21+
* Should include CollapsiblePrimitive.Trigger and CollapsiblePrimitive.Content components,
22+
* which will automatically connect to this root component via context
23+
*/
24+
children?: React.ReactNode;
25+
/**
26+
* Disables the collapsible
27+
*/
28+
disabled?: boolean;
29+
/**
30+
* CSS class name for custom styling
31+
*/
32+
className?: string;
33+
/**
34+
* Duration of the height transition animation in milliseconds
35+
*/
36+
transitionDuration?: number;
37+
/**
38+
* CSS timing function for the transition
39+
*/
40+
transitionTimingFunction?: string;
41+
/**
42+
* Additional props to be spread on the root element
43+
*/
44+
[key: string]: any;
45+
};
46+
47+
const CollapsiblePrimitiveRoot = ({
48+
children,
49+
defaultOpen = false,
50+
open,
51+
onOpenChange,
52+
disabled = false,
53+
transitionDuration = 300,
54+
transitionTimingFunction = 'ease-out',
55+
...props
56+
}: CollapsiblePrimitiveRootProps) => {
57+
const contentId = useId();
58+
59+
// Using the useControllableState hook to manage state
60+
const [isOpen, setIsOpen] = useControllableState<boolean>(
61+
open,
62+
defaultOpen,
63+
onOpenChange
64+
);
65+
66+
const handleOpenChange = (newOpen: boolean) => {
67+
if (disabled) return;
68+
setIsOpen(newOpen);
69+
};
70+
71+
return (
72+
<CollapsiblePrimitiveContext.Provider
73+
value={{
74+
open: isOpen,
75+
onOpenChange: handleOpenChange,
76+
disabled,
77+
contentId,
78+
transitionDuration,
79+
transitionTimingFunction
80+
}}
81+
>
82+
<Primitive.div
83+
{...props}
84+
data-state={isOpen ? 'open' : 'closed'}
85+
data-disabled={disabled ? '' : undefined}
86+
>
87+
{children}
88+
</Primitive.div>
89+
</CollapsiblePrimitiveContext.Provider>
90+
);
91+
};
92+
93+
export default CollapsiblePrimitiveRoot;
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import React from 'react';
2+
import Primitive from '~/core/primitives/Primitive';
3+
import { useCollapsiblePrimitiveContext } from '../contexts/CollapsiblePrimitiveContext';
4+
5+
export type CollapsiblePrimitiveTriggerProps = {
6+
/**
7+
* Content to be rendered inside the trigger
8+
*/
9+
children?: React.ReactNode;
10+
/**
11+
* CSS class name for custom styling
12+
*/
13+
className?: string;
14+
/**
15+
* For Polymorphic component support
16+
*/
17+
asChild?: boolean;
18+
/**
19+
* Additional props to be spread on the trigger element
20+
*
21+
* Note: open state, disabled state, and toggle functionality are
22+
* now automatically handled through context from CollapsiblePrimitive.Root
23+
*/
24+
[key: string]: any;
25+
};
26+
27+
const CollapsiblePrimitiveTrigger = React.forwardRef<HTMLButtonElement, CollapsiblePrimitiveTriggerProps>(
28+
({ children, className, asChild = false, ...props }, forwardedRef) => {
29+
const { open, onOpenChange, disabled, contentId } = useCollapsiblePrimitiveContext();
30+
31+
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
32+
// Allow event to propagate while still calling onOpenChange
33+
props.onClick?.(event);
34+
if (!disabled) {
35+
onOpenChange(!open);
36+
}
37+
};
38+
39+
return (
40+
<Primitive.button
41+
aria-controls={contentId}
42+
aria-expanded={open}
43+
data-state={open ? 'open' : 'closed'}
44+
data-disabled={disabled ? 'true' : undefined}
45+
ref={forwardedRef}
46+
className={className}
47+
onClick={handleClick}
48+
{...props}
49+
>
50+
{children}
51+
</Primitive.button>
52+
);
53+
}
54+
);
55+
56+
CollapsiblePrimitiveTrigger.displayName = 'CollapsiblePrimitiveTrigger';
57+
58+
export default CollapsiblePrimitiveTrigger;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import React from 'react';
2+
import CollapsiblePrimitiveRoot from './fragments/CollapsiblePrimitiveRoot';
3+
import CollapsiblePrimitiveContent from './fragments/CollapsiblePrimitiveContent';
4+
import CollapsiblePrimitiveTrigger from './fragments/CollapsiblePrimitiveTrigger';
5+
6+
// Re-export component types
7+
export type { CollapsiblePrimitiveRootProps } from './fragments/CollapsiblePrimitiveRoot';
8+
export type { CollapsiblePrimitiveContentProps } from './fragments/CollapsiblePrimitiveContent';
9+
export type { CollapsiblePrimitiveTriggerProps } from './fragments/CollapsiblePrimitiveTrigger';
10+
11+
// Empty props type - only supporting fragment exports
12+
export type CollapsiblePrimitiveProps = React.HTMLAttributes<HTMLDivElement> & {
13+
children?: React.ReactNode;
14+
};
15+
16+
// Empty implementation - we don't support direct usage
17+
const CollapsiblePrimitive = () => {
18+
console.warn('Direct usage of CollapsiblePrimitive is not supported. Please use CollapsiblePrimitive.Root, CollapsiblePrimitive.Content, etc. instead.');
19+
return null;
20+
};
21+
22+
// Export fragments via direct assignment pattern
23+
CollapsiblePrimitive.Root = CollapsiblePrimitiveRoot;
24+
CollapsiblePrimitive.Content = CollapsiblePrimitiveContent;
25+
CollapsiblePrimitive.Trigger = CollapsiblePrimitiveTrigger;
26+
27+
export default CollapsiblePrimitive;

0 commit comments

Comments
 (0)