Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React, { createContext, useContext } from 'react';

Check warning on line 1 in src/core/primitives/Collapsible/contexts/CollapsiblePrimitiveContext.tsx

View workflow job for this annotation

GitHub Actions / lint

'React' is defined but never used

/**
* Context value for CollapsiblePrimitive
*
* This context provides a single source of truth for the open state
* throughout the component tree. Only the Root component should control
* the open state - child components like Trigger and Content should only
* read from and update the context, not override it with local props.
*/
export type CollapsiblePrimitiveContextValue = {
/**
* Whether the collapsible is open
*/
open: boolean;
/**
* Callback fired when the open state changes
*/
onOpenChange: (open: boolean) => void;
/**
* Whether the collapsible is disabled
*/
disabled?: boolean;
/**
* Unique ID for ARIA relationships
*/
contentId: string;
/**
* Duration of the height transition animation in milliseconds
*/
transitionDuration: number;
/**
* CSS timing function for the transition
*/
transitionTimingFunction: string;
};

export const CollapsiblePrimitiveContext = createContext<CollapsiblePrimitiveContextValue | undefined>(undefined);

export const useCollapsiblePrimitiveContext = (): CollapsiblePrimitiveContextValue => {
const context = useContext(CollapsiblePrimitiveContext);

if (!context) {
throw new Error(
'Collapsible compound components must be used within a CollapsiblePrimitive.Root component'
);
}

return context;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React, { useState, useRef, useEffect } from 'react';
import Primitive from '~/core/primitives/Primitive';
import { useCollapsiblePrimitiveContext } from '../contexts/CollapsiblePrimitiveContext';

export type CollapsiblePrimitiveContentProps = {
/**
* Content to be rendered inside the collapsible content
*/
children?: React.ReactNode;
/**
* CSS class name for custom styling
*/
className?: string;
/**
* For Polymorphic component support
*/
asChild?: boolean;
/**
* Additional props to be spread on the content element
*/
[key: string]: any;
};

const CollapsiblePrimitiveContent = React.forwardRef<HTMLDivElement, CollapsiblePrimitiveContentProps>(
({
children,
className,
asChild = false,
...props
}, forwardedRef) => {
const {
open,
contentId,
transitionDuration,
transitionTimingFunction
} = useCollapsiblePrimitiveContext();

const ref = useRef<HTMLDivElement>(null);
const combinedRef = (forwardedRef || ref) as React.RefObject<HTMLDivElement>;
const [height, setHeight] = useState<number | undefined>(open ? undefined : 0);
const animationTimeoutRef = useRef<NodeJS.Timeout>();

useEffect(() => {
// Clear any existing timeout to avoid conflicts
if (animationTimeoutRef.current) {
clearTimeout(animationTimeoutRef.current);
}

if (!ref.current) return;

if (open) {
// Opening - Set to specific height first
const contentHeight = ref.current.scrollHeight;
setHeight(contentHeight);

// After animation completes, set height to undefined for responsive flexibility
animationTimeoutRef.current = setTimeout(() => {
setHeight(undefined);
}, transitionDuration); // Use the transition duration from context
} else {
// Closing - First set to current height
setHeight(ref.current.scrollHeight);

// Use RAF to ensure browser processes the height setting
requestAnimationFrame(() => {
// Force a reflow
const _ = ref.current?.offsetHeight;

Check warning on line 67 in src/core/primitives/Collapsible/fragments/CollapsiblePrimitiveContent.tsx

View workflow job for this annotation

GitHub Actions / lint

'_' is assigned a value but never used

// Then animate to 0 in the next frame
requestAnimationFrame(() => {
setHeight(0);
});
});
}

return () => {
if (animationTimeoutRef.current) {
clearTimeout(animationTimeoutRef.current);
}
};
}, [open, transitionDuration]);

return (
<Primitive.div
id={contentId}
ref={combinedRef}
aria-hidden={!open}
data-state={open ? 'open' : 'closed'}
className={className}
style={{
height: height !== undefined ? `${height}px` : undefined,
overflow: 'hidden',
transition: `height ${transitionDuration}ms ${transitionTimingFunction}`
}}
{...props}
>
{children}
</Primitive.div>
);
}
);

CollapsiblePrimitiveContent.displayName = 'CollapsiblePrimitiveContent';

export default CollapsiblePrimitiveContent;
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React, { useId } from 'react';
import Primitive from '~/core/primitives/Primitive';
import { CollapsiblePrimitiveContext } from '../contexts/CollapsiblePrimitiveContext';
import useControllableState from '~/core/hooks/useControllableState';

export type CollapsiblePrimitiveRootProps = {
/**
* Whether the collapsible is open by default (uncontrolled)
*/
defaultOpen?: boolean;
/**
* Controls the open state (controlled)
*/
open?: boolean;
/**
* Callback fired when the open state changes
*/
onOpenChange?: (open: boolean) => void;
/**
* Content to be rendered inside the collapsible
* Should include CollapsiblePrimitive.Trigger and CollapsiblePrimitive.Content components,
* which will automatically connect to this root component via context
*/
children?: React.ReactNode;
/**
* Disables the collapsible
*/
disabled?: boolean;
/**
* CSS class name for custom styling
*/
className?: string;
/**
* Duration of the height transition animation in milliseconds
*/
transitionDuration?: number;
/**
* CSS timing function for the transition
*/
transitionTimingFunction?: string;
/**
* Additional props to be spread on the root element
*/
[key: string]: any;
};

const CollapsiblePrimitiveRoot = ({
children,
defaultOpen = false,
open,
onOpenChange,
disabled = false,
transitionDuration = 300,
transitionTimingFunction = 'ease-out',
...props
}: CollapsiblePrimitiveRootProps) => {
const contentId = useId();

// Using the useControllableState hook to manage state
const [isOpen, setIsOpen] = useControllableState<boolean>(
open,
defaultOpen,
onOpenChange
);

const handleOpenChange = (newOpen: boolean) => {
if (disabled) return;
setIsOpen(newOpen);
};

return (
<CollapsiblePrimitiveContext.Provider
value={{
open: isOpen,
onOpenChange: handleOpenChange,
disabled,
contentId,
transitionDuration,
transitionTimingFunction
}}
>
<Primitive.div
{...props}
data-state={isOpen ? 'open' : 'closed'}
data-disabled={disabled ? '' : undefined}
>
{children}
</Primitive.div>
</CollapsiblePrimitiveContext.Provider>
);
};

export default CollapsiblePrimitiveRoot;
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React from 'react';
import Primitive from '~/core/primitives/Primitive';
import { useCollapsiblePrimitiveContext } from '../contexts/CollapsiblePrimitiveContext';

export type CollapsiblePrimitiveTriggerProps = {
/**
* Content to be rendered inside the trigger
*/
children?: React.ReactNode;
/**
* CSS class name for custom styling
*/
className?: string;
/**
* For Polymorphic component support
*/
asChild?: boolean;
/**
* Additional props to be spread on the trigger element
*
* Note: open state, disabled state, and toggle functionality are
* now automatically handled through context from CollapsiblePrimitive.Root
*/
[key: string]: any;
};

const CollapsiblePrimitiveTrigger = React.forwardRef<HTMLButtonElement, CollapsiblePrimitiveTriggerProps>(
({ children, className, asChild = false, ...props }, forwardedRef) => {
const { open, onOpenChange, disabled, contentId } = useCollapsiblePrimitiveContext();

const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
// Allow event to propagate while still calling onOpenChange
props.onClick?.(event);
if (!disabled) {
onOpenChange(!open);
}
};

return (
<Primitive.button
aria-controls={contentId}
aria-expanded={open}
data-state={open ? 'open' : 'closed'}
data-disabled={disabled ? 'true' : undefined}
ref={forwardedRef}
className={className}
onClick={handleClick}
{...props}
>
{children}
</Primitive.button>
);
}
);

CollapsiblePrimitiveTrigger.displayName = 'CollapsiblePrimitiveTrigger';

export default CollapsiblePrimitiveTrigger;
27 changes: 27 additions & 0 deletions src/core/primitives/Collapsible/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react';
import CollapsiblePrimitiveRoot from './fragments/CollapsiblePrimitiveRoot';
import CollapsiblePrimitiveContent from './fragments/CollapsiblePrimitiveContent';
import CollapsiblePrimitiveTrigger from './fragments/CollapsiblePrimitiveTrigger';

// Re-export component types
export type { CollapsiblePrimitiveRootProps } from './fragments/CollapsiblePrimitiveRoot';
export type { CollapsiblePrimitiveContentProps } from './fragments/CollapsiblePrimitiveContent';
export type { CollapsiblePrimitiveTriggerProps } from './fragments/CollapsiblePrimitiveTrigger';

// Empty props type - only supporting fragment exports
export type CollapsiblePrimitiveProps = React.HTMLAttributes<HTMLDivElement> & {
children?: React.ReactNode;
};

// Empty implementation - we don't support direct usage
const CollapsiblePrimitive = () => {
console.warn('Direct usage of CollapsiblePrimitive is not supported. Please use CollapsiblePrimitive.Root, CollapsiblePrimitive.Content, etc. instead.');
return null;
};

// Export fragments via direct assignment pattern
CollapsiblePrimitive.Root = CollapsiblePrimitiveRoot;
CollapsiblePrimitive.Content = CollapsiblePrimitiveContent;
CollapsiblePrimitive.Trigger = CollapsiblePrimitiveTrigger;

export default CollapsiblePrimitive;
Loading
Loading