1import { css } from '@emotion/react'; 2import { LinkBase, shadows, theme } from '@expo/styleguide'; 3import { borderRadius, spacing } from '@expo/styleguide-base'; 4import { TriangleDownIcon } from '@expo/styleguide-icons'; 5import { useRouter } from 'next/compat/router'; 6import type { PropsWithChildren, ReactNode } from 'react'; 7import React from 'react'; 8 9import PermalinkIcon from '~/components/icons/Permalink'; 10import withHeadingManager, { 11 HeadingManagerProps, 12} from '~/components/page-higher-order/withHeadingManager'; 13import { DEMI } from '~/ui/components/Text'; 14 15type CollapsibleProps = PropsWithChildren<{ 16 /** 17 * The content of the collapsible summary. 18 */ 19 summary: ReactNode; 20 /** 21 * If the collapsible should be rendered "open" by default. 22 */ 23 open?: boolean; 24 testID?: string; 25}>; 26 27const Collapsible: React.FC<CollapsibleProps> = withHeadingManager( 28 (props: CollapsibleProps & HeadingManagerProps) => { 29 const { summary, testID, children } = props; 30 31 const router = useRouter(); 32 const { asPath } = router || {}; 33 34 // expand collapsible if the current hash matches the heading 35 React.useEffect(() => { 36 if (asPath) { 37 const splitUrl = asPath.split('#'); 38 const hash = splitUrl.length ? splitUrl[1] : undefined; 39 if (hash && hash === heading.current.slug) { 40 setOpen(true); 41 } 42 } 43 }, [asPath]); 44 45 // track open state so we can collapse header if it is set to open by the URL hash 46 const [open, setOpen] = React.useState<boolean>(props.open ?? false); 47 48 const onToggle = (event: { preventDefault: () => void }) => { 49 event.preventDefault(); 50 setOpen(!open); 51 }; 52 53 const onClickIcon = (event: { stopPropagation?: () => void }) => { 54 event.stopPropagation && event.stopPropagation(); 55 if (!open) { 56 setOpen(true); 57 } 58 }; 59 60 // HeadingManager is used to generate a slug that corresponds to the collapsible summary. 61 // These are normally generated for MD (#) headings, but Collapsible doesn't have those. 62 // This is a ref because identical tags will keep incrementing the number if it is not. 63 const heading = React.useRef(props.headingManager.addHeading(summary, 1, undefined)); 64 65 return ( 66 <details id={heading.current.slug} css={detailsStyle} open={open} data-testid={testID}> 67 <summary css={summaryStyle} onClick={onToggle}> 68 <div css={markerWrapperStyle}> 69 <TriangleDownIcon className="icon-sm text-icon-default" css={markerStyle} /> 70 </div> 71 <LinkBase href={'#' + heading.current.slug} ref={heading.current.ref}> 72 <DEMI>{summary}</DEMI> 73 <span css={STYLES_PERMALINK_ICON}> 74 <PermalinkIcon onClick={onClickIcon} /> 75 </span> 76 </LinkBase> 77 </summary> 78 <div css={contentStyle}>{children}</div> 79 </details> 80 ); 81 } 82); 83 84export { Collapsible }; 85 86const detailsStyle = css({ 87 overflow: 'hidden', 88 background: theme.background.default, 89 border: `1px solid ${theme.border.default}`, 90 borderRadius: borderRadius.md, 91 padding: 0, 92 marginBottom: spacing[3], 93 94 '&[open]': { 95 boxShadow: shadows.xs, 96 }, 97 98 'h4:first-child': { 99 marginTop: 0, 100 }, 101 102 'h4 + &, p + &, li > &': { 103 marginTop: spacing[3], 104 }, 105}); 106 107const summaryStyle = css({ 108 display: 'flex', 109 flexDirection: 'row', 110 alignItems: 'center', 111 userSelect: 'none', 112 listStyle: 'none', 113 backgroundColor: theme.background.subtle, 114 padding: spacing[1.5], 115 paddingRight: spacing[3], 116 margin: 0, 117 cursor: 'pointer', 118 119 '&:hover span': { 120 color: theme.text.secondary, 121 }, 122 123 '::-webkit-details-marker': { 124 display: 'none', 125 }, 126 127 h4: { 128 marginTop: 0, 129 marginBottom: 0, 130 }, 131 132 code: { 133 backgroundColor: theme.background.element, 134 display: 'inline', 135 fontSize: '90%', 136 }, 137}); 138 139const markerWrapperStyle = css({ 140 alignSelf: 'baseline', 141 marginTop: 5, 142 marginLeft: spacing[1.5], 143 marginRight: spacing[2], 144}); 145 146const markerStyle = css({ 147 transform: 'rotate(-90deg)', 148 transition: `transform 200ms`, 149 150 'details[open] &': { transform: 'rotate(0)' }, 151}); 152 153const contentStyle = css({ 154 padding: `${spacing[4]}px ${spacing[5]}px 0`, 155 156 p: { 157 marginLeft: 0, 158 }, 159 160 'pre > pre': { 161 marginTop: 0, 162 }, 163}); 164 165const STYLES_PERMALINK_ICON = css` 166 cursor: pointer; 167 vertical-align: middle; 168 display: inline-block; 169 width: 1.2em; 170 height: 1em; 171 padding: 0 0.2em; 172 visibility: hidden; 173 174 a:hover &, 175 a:focus-visible & { 176 visibility: visible; 177 } 178 179 svg { 180 width: 100%; 181 height: auto; 182 } 183`; 184