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/compat/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 crawlable={false}>{info.name}</CALLOUT> 91 </ButtonBase> 92 {isOpen && ( 93 <div aria-hidden={!isOpen ? 'true' : 'false'} css={childrenContainerStyle}> 94 {children} 95 </div> 96 )} 97 </> 98 ); 99} 100 101const titleStyle = css({ 102 display: 'flex', 103 alignItems: 'center', 104 gap: spacing[2], 105 position: 'relative', 106 userSelect: 'none', 107 transition: '100ms', 108 padding: `${spacing[1.5]}px ${spacing[3]}px`, 109 width: '100%', 110 111 ':hover': { 112 cursor: 'pointer', 113 backgroundColor: theme.background.element, 114 borderRadius: borderRadius.md, 115 transition: '100ms', 116 }, 117}); 118 119const chevronContainerStyle = css({ 120 backgroundColor: theme.background.default, 121 border: `1px solid ${theme.border.default}`, 122 borderRadius: borderRadius.sm, 123 display: 'flex', 124 alignItems: 'center', 125 justifyContent: 'center', 126 boxShadow: shadows.xs, 127 height: 16, 128 width: 16, 129}); 130 131const chevronClosedStyle = css({ 132 transform: 'rotate(-90deg) translateY(0.5px)', 133}); 134 135const childrenContainerStyle = css({ 136 paddingLeft: spacing[2.5], 137}); 138