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