1import { css } from '@emotion/react'; 2import { theme, iconSize, spacing, ChevronDownIcon, borderRadius, shadows } from '@expo/styleguide'; 3import { useRouter } from 'next/router'; 4import type { PropsWithChildren } from 'react'; 5import { useEffect, useRef, useState } from 'react'; 6 7import { ButtonBase } from '../Button'; 8import { CALLOUT } from '../Text'; 9 10import { stripVersionFromPath } from '~/common/utilities'; 11import { NavigationRoute } from '~/types/common'; 12 13if (typeof window !== 'undefined' && !window.hasOwnProperty('sidebarState')) { 14 window.sidebarState = {}; 15} 16 17type Props = PropsWithChildren<{ 18 info: NavigationRoute; 19}>; 20 21export function SidebarCollapsible(props: Props) { 22 const { info, children } = props; 23 const router = useRouter(); 24 const ref = useRef<HTMLButtonElement>(null); 25 26 const isChildRouteActive = () => { 27 let result = false; 28 29 const sections = info.children; 30 31 const isSectionActive = (section: NavigationRoute) => { 32 const linkUrl = stripVersionFromPath(section.as || section.href); 33 const pathname = stripVersionFromPath(router.pathname); 34 const asPath = stripVersionFromPath(router.asPath); 35 36 if (linkUrl === pathname || linkUrl === asPath) { 37 result = true; 38 } 39 }; 40 41 const posts: NavigationRoute[] = 42 sections 43 ?.map(section => (section.type === 'page' ? [section] : section?.children ?? [])) 44 .flat() ?? []; 45 46 posts.forEach(isSectionActive); 47 return result; 48 }; 49 50 const hasCachedState = 51 typeof window !== 'undefined' && window.sidebarState[info.name] !== undefined; 52 53 const containsActiveChild = isChildRouteActive(); 54 const initState = hasCachedState 55 ? window.sidebarState[info.name] 56 : containsActiveChild || info.expanded; 57 58 const [isOpen, setOpen] = useState(initState); 59 60 useEffect(() => { 61 if (containsActiveChild) { 62 window.sidebarState[info.name] = true; 63 } 64 }, []); 65 66 const toggleIsOpen = () => { 67 setOpen(prevState => !prevState); 68 window.sidebarState[info.name] = !isOpen; 69 }; 70 71 const customDataAttributes = containsActiveChild && { 72 'data-collapsible-active': true, 73 }; 74 75 return ( 76 <> 77 <ButtonBase 78 ref={ref} 79 css={titleStyle} 80 aria-expanded={isOpen ? 'true' : 'false'} 81 onClick={toggleIsOpen} 82 {...customDataAttributes}> 83 <div css={chevronContainerStyle}> 84 <ChevronDownIcon size={iconSize.xs} css={[chevronStyle, !isOpen && chevronClosedStyle]} /> 85 </div> 86 <CALLOUT weight="medium">{info.name}</CALLOUT> 87 </ButtonBase> 88 {isOpen && <div aria-hidden={!isOpen ? 'true' : 'false'}>{children}</div>} 89 </> 90 ); 91} 92 93const titleStyle = css({ 94 display: 'flex', 95 alignItems: 'center', 96 gap: spacing[1.5], 97 position: 'relative', 98 marginBottom: spacing[2], 99 userSelect: 'none', 100 transition: '100ms', 101 padding: `${spacing[1.5]}px ${spacing[3]}px`, 102 width: '100%', 103 104 ':hover': { 105 cursor: 'pointer', 106 backgroundColor: theme.background.element, 107 borderRadius: borderRadius.md, 108 transition: '100ms', 109 }, 110}); 111 112const chevronContainerStyle = css({ 113 backgroundColor: theme.background.default, 114 border: `1px solid ${theme.border.default}`, 115 borderRadius: borderRadius.sm, 116 display: 'flex', 117 alignItems: 'center', 118 justifyContent: 'center', 119 boxShadow: shadows.xs, 120 height: 20, 121 width: 20, 122 marginRight: spacing[1], 123}); 124 125const chevronStyle = css({ 126 transition: '100ms ease transform', 127 transform: 'translateX(-0.5px)', 128}); 129 130const chevronClosedStyle = css({ 131 transform: 'rotate(-90deg)', 132}); 133