-
Notifications
You must be signed in to change notification settings - Fork 37
/
AccordionItem.tsx
117 lines (109 loc) · 4.34 KB
/
AccordionItem.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
import { useMergeRefs } from '@floating-ui/react';
import cl from 'clsx/lite';
import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, useEffect, useRef, useState } from 'react';
import '@u-elements/u-details';
export type AccordionItemProps = {
/**
* Controls open-state.
*
* Using this removes automatic control of open-state
*
* @default undefined
*/
open?: boolean;
/**
* Defaults the accordion to open if not controlled
* @default false
*/
defaultOpen?: boolean;
/** Callback function when AccordionItem wants to open due to a find in page-search */
onFound?: () => void;
/** Content should be one `<Accordion.Header>` and `<Accordion.Content>` */
children?: ReactNode;
} & HTMLAttributes<HTMLDetailsElement> &
(
| { open: boolean; onFound: () => void }
| { open?: never; onFound?: () => void }
);
/**
* Accordion item component, contains `Accordion.Header` and `Accordion.Content` components.
* @example
* <AccordionItem>
* <AccordionHeader>Header</AccordionHeader>
* <AccordionContent>Content</AccordionContent>
* </AccordionItem>
*/
export const AccordionItem = forwardRef<HTMLDetailsElement, AccordionItemProps>(
function AccordionItem(
{ className, open, defaultOpen = false, onFound, ...rest },
ref,
) {
const [internalOpen, setInternalOpen] = useState(open ?? defaultOpen);
const initialOpen = useRef(internalOpen); // Allow rendering initial open state on server, but animate in browser
const controlledOpen = useRef(internalOpen); // Using ref so we can access it inside useEffect without unbinding/binding event listeners
const detailsRef = useRef<HTMLDetailsElement>(null);
const mergedRefs = useMergeRefs([detailsRef, ref]);
controlledOpen.current = open ?? internalOpen; // Update controlledOpen on prop change
// Control state with a useEffect to animate on prop change and prevent native <details> toggle
useEffect(() => {
const details = detailsRef.current;
const summary = details?.querySelector(':scope > :is(summary,u-summary)');
const handleSummaryClick = (event: Event) => {
event?.preventDefault(); // Prevent native <details> toggle so we can animate
setInternalOpen((open) => !open);
};
const handleToggle = () => {
if (!details || details.open === controlledOpen.current) return;
onFound?.();
setInternalOpen(details?.open || false);
window.requestAnimationFrame(() => {
details.open = controlledOpen.current;
}); // Let onFound run before correcting state
};
details?.addEventListener('toggle', handleToggle, true);
summary?.addEventListener('click', handleSummaryClick);
return () => {
details?.removeEventListener('toggle', handleToggle, true);
summary?.removeEventListener('click', handleSummaryClick);
};
}, []);
useEffect(() => {
animateHeight(detailsRef.current, controlledOpen.current);
}, [controlledOpen.current]);
return (
<u-details
class={cl('ds-accordion__item', className)} // Using class since React does not translate className on custom elements
open={initialOpen.current || undefined} // Fallback to undefined to prevent rendering open="false"
ref={mergedRefs}
{...rest}
/>
);
},
);
const animateHeight = (details: HTMLDetailsElement | null, open: boolean) => {
const content = details?.querySelector(':scope > :not(summary, u-summary)');
const hasContent = content instanceof HTMLElement;
const hasAnimate = details && 'animate' in details;
const hasReducedMotion = window.matchMedia?.(
'(prefers-reduced-motion: reduce)',
).matches;
details?.setAttribute('data-chevron-open', `${open}`); // Make flip on click
if (hasReducedMotion || !hasAnimate || !hasContent) {
if (details) details.open = open;
} else if (details.open !== open) {
details.open = true;
const opened = `${content.scrollHeight}px`;
content.style.overflow = 'clip'; // Clip content while animating
content.animate(
{
height: [open ? '0px' : opened, open ? opened : '0px'],
paddingBlock: [open ? '0px' : '', open ? '' : '0px'],
},
{ duration: 400, easing: 'ease-in-out' },
).onfinish = () => {
content.style.removeProperty('overflow'); // Restore overlow
details.open = open;
};
}
};