xref: /expo/docs/ui/components/Collapsible/index.tsx (revision e9783df2)
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