about summary refs log tree commit diff
path: root/src/components/Link.tsx
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2024-01-18 20:28:04 -0600
committerGitHub <noreply@github.com>2024-01-18 20:28:04 -0600
commit66b8774ecb9c5d465987909577ddad3dd4a3ab8e (patch)
treeb1874c6cedd0111eca41db237e606f8e50739d55 /src/components/Link.tsx
parent9cbd3c0937d22e8dccbd9c086d3a3a24dbd27b3a (diff)
downloadvoidsky-66b8774ecb9c5d465987909577ddad3dd4a3ab8e.tar.zst
New component library based on ALF (#2459)
* Install on native as well

* Add button and link components

* Comments

* Use new prop

* Add some form elements

* Add labels to input

* Fix line height, add suffix

* Date inputs

* Autofill styles

* Clean up InputDate types

* Improve types for InputText, value handling

* Enforce a11y props on buttons

* Add Dialog, Portal

* Dialog contents

* Native dialog

* Clean up

* Fix animations

* Improvements to web modal, exiting still broken

* Clean up dialog types

* Add Prompt, Dialog refinement, mobile refinement

* Integrate new design tokens, reorg storybook

* Button colors

* Dim mode

* Reorg

* Some styles

* Toggles

* Improve a11y

* Autosize dialog, handle max height, Dialog.ScrolLView not working

* Try to use BottomSheet's own APIs

* Scrollable dialogs

* Add web shadow

* Handle overscroll

* Styles

* Dialog text input

* Shadows

* Button focus states

* Button pressed states

* Gradient poc

* Gradient colors and hovers

* Add hrefAttrs to Link

* Some more a11y

* Toggle invalid states

* Update dialog descriptions for demo

* Icons

* WIP Toggle cleanup

* Refactor toggle to not rely on immediate children

* Make Toggle controlled

* Clean up Toggles storybook

* ToggleButton styles

* Improve a11y labels

* ToggleButton hover darkmode

* Some i18n

* Refactor input

* Allow extension of input

* Remove old input

* Improve icons, add CalendarDays

* Refactor DateField, web done

* Add label example

* Clean up old InputDate, DateField android, text area example

* Consistent imports

* Button context, icons

* Add todo

* Add closeAllDialogs control

* Alignment

* Expand color palette

* Hitslops, add shortcut to Storybook in dev

* Fix multiline on ios

* Mark dialog close button as unused
Diffstat (limited to 'src/components/Link.tsx')
-rw-r--r--src/components/Link.tsx191
1 files changed, 191 insertions, 0 deletions
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
new file mode 100644
index 000000000..8f686f3c4
--- /dev/null
+++ b/src/components/Link.tsx
@@ -0,0 +1,191 @@
+import React from 'react'
+import {
+  Text,
+  TextStyle,
+  StyleProp,
+  GestureResponderEvent,
+  Linking,
+} from 'react-native'
+import {
+  useLinkProps,
+  useNavigation,
+  StackActions,
+} from '@react-navigation/native'
+import {sanitizeUrl} from '@braintree/sanitize-url'
+
+import {isWeb} from '#/platform/detection'
+import {useTheme, web, flatten} from '#/alf'
+import {Button, ButtonProps, useButtonContext} from '#/components/Button'
+import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types'
+import {
+  convertBskyAppUrlIfNeeded,
+  isExternalUrl,
+  linkRequiresWarning,
+} from '#/lib/strings/url-helpers'
+import {useModalControls} from '#/state/modals'
+import {router} from '#/routes'
+
+export type LinkProps = Omit<
+  ButtonProps,
+  'style' | 'onPress' | 'disabled' | 'label'
+> & {
+  /**
+   * `TextStyle` to apply to the anchor element itself. Does not apply to any children.
+   */
+  style?: StyleProp<TextStyle>
+  /**
+   * The React Navigation `StackAction` to perform when the link is pressed.
+   */
+  action?: 'push' | 'replace' | 'navigate'
+  /**
+   * If true, will warn the user if the link text does not match the href. Only
+   * works for Links with children that are strings i.e. text links.
+   */
+  warnOnMismatchingTextChild?: boolean
+  label?: ButtonProps['label']
+} & Pick<Parameters<typeof useLinkProps<AllNavigatorParams>>[0], 'to'>
+
+/**
+ * A interactive element that renders as a `<a>` tag on the web. On mobile it
+ * will translate the `href` to navigator screens and params and dispatch a
+ * navigation action.
+ *
+ * Intended to behave as a web anchor tag. For more complex routing, use a
+ * `Button`.
+ */
+export function Link({
+  children,
+  to,
+  action = 'push',
+  warnOnMismatchingTextChild,
+  style,
+  ...rest
+}: LinkProps) {
+  const navigation = useNavigation<NavigationProp>()
+  const {href} = useLinkProps<AllNavigatorParams>({
+    to:
+      typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to,
+  })
+  const isExternal = isExternalUrl(href)
+  const {openModal, closeModal} = useModalControls()
+  const onPress = React.useCallback(
+    (e: GestureResponderEvent) => {
+      const stringChildren = typeof children === 'string' ? children : ''
+      const requiresWarning = Boolean(
+        warnOnMismatchingTextChild &&
+          stringChildren &&
+          isExternal &&
+          linkRequiresWarning(href, stringChildren),
+      )
+
+      if (requiresWarning) {
+        e.preventDefault()
+
+        openModal({
+          name: 'link-warning',
+          text: stringChildren,
+          href: href,
+        })
+      } else {
+        e.preventDefault()
+
+        if (isExternal) {
+          Linking.openURL(href)
+        } else {
+          /**
+           * A `GestureResponderEvent`, but cast to `any` to avoid using a bunch
+           * of @ts-ignore below.
+           */
+          const event = e as any
+          const isMiddleClick = isWeb && event.button === 1
+          const isMetaKey =
+            isWeb &&
+            (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
+          const shouldOpenInNewTab = isMetaKey || isMiddleClick
+
+          if (
+            shouldOpenInNewTab ||
+            href.startsWith('http') ||
+            href.startsWith('mailto')
+          ) {
+            Linking.openURL(href)
+          } else {
+            closeModal() // close any active modals
+
+            if (action === 'push') {
+              navigation.dispatch(StackActions.push(...router.matchPath(href)))
+            } else if (action === 'replace') {
+              navigation.dispatch(
+                StackActions.replace(...router.matchPath(href)),
+              )
+            } else if (action === 'navigate') {
+              // @ts-ignore
+              navigation.navigate(...router.matchPath(href))
+            } else {
+              throw Error('Unsupported navigator action.')
+            }
+          }
+        }
+      }
+    },
+    [
+      href,
+      isExternal,
+      warnOnMismatchingTextChild,
+      navigation,
+      action,
+      children,
+      closeModal,
+      openModal,
+    ],
+  )
+
+  return (
+    <Button
+      label={href}
+      {...rest}
+      role="link"
+      accessibilityRole="link"
+      href={href}
+      onPress={onPress}
+      {...web({
+        hrefAttrs: {
+          target: isExternal ? 'blank' : undefined,
+          rel: isExternal ? 'noopener noreferrer' : undefined,
+        },
+        dataSet: {
+          // default to no underline, apply this ourselves
+          noUnderline: '1',
+        },
+      })}>
+      {typeof children === 'string' ? (
+        <LinkText style={style}>{children}</LinkText>
+      ) : (
+        children
+      )}
+    </Button>
+  )
+}
+
+function LinkText({
+  children,
+  style,
+}: React.PropsWithChildren<{
+  style?: StyleProp<TextStyle>
+}>) {
+  const t = useTheme()
+  const {hovered} = useButtonContext()
+  return (
+    <Text
+      style={[
+        {color: t.palette.primary_500},
+        hovered && {
+          textDecorationLine: 'underline',
+          textDecorationColor: t.palette.primary_500,
+        },
+        flatten(style),
+      ]}>
+      {children as string}
+    </Text>
+  )
+}