Skip to content

Instantly share code, notes, and snippets.

@iMoses
Last active September 9, 2022 19:20
Show Gist options
  • Save iMoses/24b9dd6e13a26145a919127d38f8c088 to your computer and use it in GitHub Desktop.
Save iMoses/24b9dd6e13a26145a919127d38f8c088 to your computer and use it in GitHub Desktop.
react-headless-collapsible: a React hook to create collapsible components (inspired by `react-collapsible`)
import React, { useCallback, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { useCollapsible } from './use-collapsible';
function Collapsible({
open,
easing,
onOpen,
onClose,
onOpening,
onClosing,
transitionTime,
transitionCloseTime,
containerElementProps,
triggerElementProps,
contentElementId,
className,
classParentString,
openedClassName,
triggerStyle,
triggerClassName,
triggerOpenedClassName,
contentOuterClassName,
contentInnerClassName,
accordionPosition,
handleTriggerClick,
onTriggerOpening,
onTriggerClosing,
trigger,
triggerWhenOpen,
triggerDisabled,
lazyRender,
overflowWhenOpen,
contentHiddenWhenClosed,
triggerSibling,
tabIndex,
triggerTagName,
contentContainerTagName,
children,
}) {
const Trigger = triggerTagName;
const Container = contentContainerTagName;
const [inTransition, setInTransition] = useState(false);
const { contentId, triggerId } = useMemo(
() => ({
contentId: contentElementId || `collapsible-content-${Date.now()}`,
triggerId: triggerElementProps.id || `collapsible-trigger-${Date.now()}`,
}),
[]
);
const { getContentProps, getTriggerProps, shouldRender, isOpen } =
useCollapsible({
open,
easing,
overflow: overflowWhenOpen,
duration: [transitionTime, transitionCloseTime ?? transitionTime],
onOpen: useCallback(
(event) => {
setInTransition(false);
onOpen?.(event);
},
[onOpen]
),
onClose: useCallback(
(event) => {
setInTransition(false);
onClose?.(event);
},
[onClose]
),
onOpening: useCallback(() => {
setInTransition(true);
onOpening?.();
onTriggerOpening?.();
}, [onOpening, onTriggerOpening]),
onClosing: useCallback(() => {
setInTransition(true);
onClosing?.();
onTriggerClosing?.();
}, [onClosing, onTriggerClosing]),
});
const contentProps = getContentProps();
const triggerProps = getTriggerProps();
if (handleTriggerClick) {
triggerProps.onClick = (event) => {
event.preventDefault();
if (!triggerDisabled && !inTransition) {
handleTriggerClick(accordionPosition);
}
};
}
return (
<Container
className={classNames(
classParentString,
isOpen ? openedClassName : className
)}
{...containerElementProps}
>
<Trigger
id={triggerId}
className={classNames(
`${classParentString}__trigger`,
isOpen ? 'is-open' : 'is-closed',
triggerDisabled && 'is-disabled',
isOpen ? triggerOpenedClassName : triggerClassName
)}
{...triggerProps}
style={triggerStyle}
onKeyPress={(event) => {
const { key } = event;
if (
(key === ' ' && triggerTagName.toLowerCase() !== 'button') ||
key === 'Enter'
) {
triggerProps.onClick(event);
}
}}
tabIndex={tabIndex}
aria-expanded={isOpen}
aria-disabled={triggerDisabled}
aria-controls={contentId}
role="button" // Since our default TriggerElement is not a button
{...triggerElementProps}
>
{isOpen && typeof triggerWhenOpen !== 'undefined'
? triggerWhenOpen
: trigger}
</Trigger>
{renderTriggerSibling(triggerSibling, classParentString)}
<div
id={contentId}
className={classNames(
`${classParentString}__contentOuter`,
contentOuterClassName
)}
{...contentProps}
hidden={contentHiddenWhenClosed && !isOpen && !inTransition}
role="region"
aria-labelledby={triggerId}
>
<div
className={classNames(
`${classParentString}__contentInner`,
contentInnerClassName
)}
>
{(!lazyRender || shouldRender) && children}
</div>
</div>
</Container>
);
}
Collapsible.propTypes = {
transitionTime: PropTypes.number,
transitionCloseTime: PropTypes.number,
triggerTagName: PropTypes.string,
easing: PropTypes.string,
open: PropTypes.bool,
containerElementProps: PropTypes.object,
triggerElementProps: PropTypes.object,
contentElementId: PropTypes.string,
classParentString: PropTypes.string,
className: PropTypes.string,
openedClassName: PropTypes.string,
triggerStyle: PropTypes.object,
triggerClassName: PropTypes.string,
triggerOpenedClassName: PropTypes.string,
contentOuterClassName: PropTypes.string,
contentInnerClassName: PropTypes.string,
accordionPosition: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
handleTriggerClick: PropTypes.func,
onOpen: PropTypes.func,
onClose: PropTypes.func,
onOpening: PropTypes.func,
onClosing: PropTypes.func,
onTriggerOpening: PropTypes.func,
onTriggerClosing: PropTypes.func,
trigger: PropTypes.oneOfType([PropTypes.string, PropTypes.element]),
triggerWhenOpen: PropTypes.oneOfType([PropTypes.string, PropTypes.element]),
triggerDisabled: PropTypes.bool,
lazyRender: PropTypes.bool,
overflowWhenOpen: PropTypes.oneOf([
'hidden',
'visible',
'auto',
'scroll',
'inherit',
'initial',
'unset',
]),
contentHiddenWhenClosed: PropTypes.bool,
triggerSibling: PropTypes.oneOfType([
PropTypes.string,
PropTypes.element,
PropTypes.func,
]),
tabIndex: PropTypes.number,
contentContainerTagName: PropTypes.string,
children: PropTypes.oneOfType([PropTypes.string, PropTypes.element]),
};
Collapsible.defaultProps = {
transitionTime: 400,
transitionCloseTime: null,
triggerTagName: 'span',
easing: 'linear',
open: false,
classParentString: 'Collapsible',
triggerDisabled: false,
lazyRender: false,
overflowWhenOpen: 'hidden',
contentHiddenWhenClosed: false,
openedClassName: '',
triggerStyle: null,
triggerClassName: '',
triggerOpenedClassName: '',
contentOuterClassName: '',
contentInnerClassName: '',
className: '',
triggerSibling: null,
onOpen: () => {},
onClose: () => {},
onOpening: () => {},
onClosing: () => {},
onTriggerOpening: () => {},
onTriggerClosing: () => {},
tabIndex: null,
contentContainerTagName: 'div',
triggerElementProps: {},
};
export default Collapsible;
function classNames(...classNames) {
return classNames.filter(Boolean).join(' ');
}
function renderTriggerSibling(triggerSibling, classParentString) {
switch (typeof triggerSibling) {
case 'string':
return (
<span className={`${classParentString}__trigger-sibling`}>
{triggerSibling}
</span>
);
case 'function':
return triggerSibling();
case 'object':
return triggerSibling;
default:
return null;
}
}
import { useCollapsible } from './use-collapsible';
export function Collapsible({ open, title, className, lazyLoad, onToggle, children, ...props }) {
const { isOpen, shouldRender, getTriggerProps, getContentProps } = useCollapsible({ open, onToggle });
const classNames = [className, 'collapsible'];
if (isOpen) classNames.push('is-open');
return (
<div {...props} className={classNames.join(' ')}>
<header className="collapsible-trigger" {...getTriggerProps()}>
<div className="collapsible-title">{title}</div>
<span className="collapsible-chevron" />
</header>
<div className="collapsible-content" {...getContentProps()}>
{(!lazyLoad || shouldRender) && children}
</div>
</div>
);
}
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
export function useCollapsible({
open,
disabled,
duration = 400,
easing = 'cubic-bezier(0.45, 0, 0.55, 1)', // ease-in-out-quad
overflow = 'hidden',
immutable,
onOpen,
onOpening,
onClose,
onClosing,
onToggle,
} = {}) {
const [isOpen, setOpen] = useState(Boolean(open));
const wasOpen = useRef(Boolean(open));
const contentRef = useRef();
const triggerRef = useRef();
if (typeof duration === 'number') {
duration = [duration, duration];
}
useLayoutEffect(() => {
const { dataset, style } = contentRef.current;
if (dataset.ready) {
return toggleCollapsible(Boolean(open));
}
dataset.ready = true;
if (open) {
style.overflow = overflow;
style.height = 'auto';
} else {
style.overflow = 'hidden';
style.height = 0;
}
}, [immutable || open]);
const handleTriggerClick = useCallback(
event => {
event.preventDefault();
toggleCollapsible(!isOpen);
onToggle?.(!isOpen, event);
},
[disabled, duration, easing, isOpen, onOpening, onClosing, onToggle]
);
const handleTransitionEnd = useCallback(
event => {
if (event.target !== contentRef.current) {
return;
}
const { dataset, style } = contentRef.current;
delete dataset.inTransition;
if (isOpen) {
style.overflow = overflow;
style.height = 'auto';
onOpen?.(event);
} else {
onClose?.(event);
}
},
[isOpen, onOpen, onClose]
);
return {
isOpen,
shouldRender: isOpen || wasOpen.current,
getTriggerProps(props) {
return {
...props,
ref: triggerRef,
onClick: handleTriggerClick,
};
},
getContentProps(props) {
return {
...props,
ref: contentRef,
onTransitionEnd: handleTransitionEnd,
};
},
};
function toggleCollapsible(shouldOpen) {
const { dataset } = contentRef.current;
if (shouldOpen === isOpen || disabled || dataset.inTransition) {
return;
}
if (shouldOpen) {
openCollapsible(contentRef, duration, easing);
wasOpen.current = true;
setOpen(true);
onOpening?.();
} else {
closeCollapsible(contentRef, duration, easing);
setOpen(false);
onClosing?.();
}
}
}
function openCollapsible(contentRef, duration, easing) {
window.requestAnimationFrame(() => {
if (contentRef.current?.scrollHeight) {
setTransition(contentRef, duration[0], easing);
}
});
}
function closeCollapsible(contentRef, duration, easing) {
if (contentRef.current?.scrollHeight) {
const { style } = contentRef.current;
setTransition(contentRef, duration[1], easing);
window.requestAnimationFrame(() => {
style.overflow = 'hidden';
style.height = 0;
});
}
}
function setTransition(contentRef, duration, easing) {
const { dataset, scrollHeight, style } = contentRef.current;
style.transition = `height ${duration}ms ${easing}`;
style.height = `${scrollHeight}px`;
dataset.inTransition = '';
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment