xref: /expo/docs/ui/components/Text/withAnchor.tsx (revision dfd15ebd)
1import { css } from '@emotion/react';
2import { spacing, theme } from '@expo/styleguide';
3import GithubSlugger from 'github-slugger';
4import {
5  Children,
6  FC,
7  createContext,
8  isValidElement,
9  ReactNode,
10  useContext,
11  PropsWithChildren,
12} from 'react';
13
14import { A } from '.';
15import { TextComponentProps } from './types';
16
17import { durations } from '~/ui/foundations/durations';
18
19export const AnchorContext = createContext<GithubSlugger | null>(null);
20
21/**
22 * Render the component with anchor elements and properties.
23 * This adds the following elements:
24 *   - hidden link position
25 *   - children of the component
26 *   - anchor hover icon
27 */
28export function withAnchor(Component: FC<PropsWithChildren<TextComponentProps>>) {
29  function AnchorComponent({ id, children, ...rest }: TextComponentProps) {
30    const slug = useSlug(id, children);
31    return (
32      <Component css={headingStyle} data-id={slug} {...rest}>
33        <span css={anchorStyle} id={slug} />
34        <A href={`#${slug}`} css={linkStyle}>
35          {children}
36        </A>
37      </Component>
38    );
39  }
40  AnchorComponent.displayName = `Anchor(${Component.displayName})`;
41  return AnchorComponent;
42}
43
44const headingStyle = css({
45  position: 'relative',
46});
47
48const anchorStyle = css({
49  position: 'relative',
50  top: -100,
51  visibility: 'hidden',
52});
53
54const linkStyle = css({
55  position: 'relative',
56  color: 'inherit',
57  textDecoration: 'inherit',
58
59  '::before': {
60    content: '"#"',
61    position: 'absolute',
62    transform: 'translatex(-100%)',
63    transition: `opacity ${durations.hover}`,
64    opacity: 0,
65    color: theme.icon.secondary,
66    padding: `0.25em ${spacing[2]}px`,
67    fontSize: '0.75em',
68  },
69
70  '&:hover': {
71    '::before': {
72      opacity: 1,
73    },
74  },
75});
76
77function useSlug(id: string | undefined, children: ReactNode) {
78  const slugger = useContext(AnchorContext)!;
79  let slugText = id;
80
81  if (!slugText) {
82    slugText = getTextFromChildren(children);
83    maybeWarnMissingID(slugText);
84  }
85
86  return slugger.slug(slugText);
87}
88
89export function getTextFromChildren(children: ReactNode): string {
90  return Children.toArray(children)
91    .map(child => {
92      if (typeof child === 'string') {
93        return child;
94      }
95      if (isValidElement(child)) {
96        return getTextFromChildren(child.props.children);
97      }
98      return '';
99    })
100    .join(' ')
101    .trim();
102}
103
104/** Eventually, we want to get rid of the auto-generating ID. For now, we need to do this */
105function maybeWarnMissingID(identifier: ReactNode) {
106  // commenting this out, it's not useful to log this warning, only when actually fixing this issue.
107  // console.warn(
108  //   `Anchor element "${identifier}" is missing ID, please add it manually. Auto-generating anchor IDs will be deprecated.`
109  // );
110}
111