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