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 85 size={iconSize.tiny} 86 css={[chevronStyle, !isOpen && chevronClosedStyle]} 87 /> 88 </div> 89 <CALLOUT weight="medium">{info.name}</CALLOUT> 90 </ButtonBase> 91 {isOpen && <div aria-hidden={!isOpen ? 'true' : 'false'}>{children}</div>} 92 </> 93 ); 94} 95 96const titleStyle = css({ 97 display: 'flex', 98 alignItems: 'center', 99 gap: spacing[1.5], 100 position: 'relative', 101 marginBottom: spacing[2], 102 userSelect: 'none', 103 transition: '100ms', 104 padding: `${spacing[1.5]}px ${spacing[3]}px`, 105 width: '100%', 106 107 ':hover': { 108 cursor: 'pointer', 109 backgroundColor: theme.background.tertiary, 110 borderRadius: borderRadius.medium, 111 transition: '100ms', 112 }, 113}); 114 115const chevronContainerStyle = css({ 116 backgroundColor: theme.background.default, 117 border: `1px solid ${theme.border.default}`, 118 borderRadius: borderRadius.small, 119 display: 'flex', 120 alignItems: 'center', 121 justifyContent: 'center', 122 boxShadow: shadows.micro, 123 height: 20, 124 width: 20, 125 marginRight: spacing[1], 126}); 127 128const chevronStyle = css({ 129 transition: '100ms ease transform', 130 transform: 'translateX(-0.5px)', 131}); 132 133const chevronClosedStyle = css({ 134 transform: 'rotate(-90deg)', 135}); 136