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