about summary refs log tree commit diff
path: root/src/components/Link.tsx
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2025-01-21 15:56:01 -0600
committerGitHub <noreply@github.com>2025-01-21 21:56:01 +0000
commit9df5caf3c545a7a1c559c6561625d99154aa0603 (patch)
tree3f7a1b2fdb6fb7628e22b79a978b762cccdd200e /src/components/Link.tsx
parentc8d062f1aef130e13a99892e7bb695b1e123c3db (diff)
downloadvoidsky-9df5caf3c545a7a1c559c6561625d99154aa0603.tar.zst
Update hashtag menu to use `Menu`, convert to native link for additional a11y and click handling (#7529)
* Make tag a normal link on web

* Replace old TagMenu with new RichTextTag component, expand and improve click utils

* Clarify intents

* Ensure we're passing down hint

* ope

* DRY
Diffstat (limited to 'src/components/Link.tsx')
-rw-r--r--src/components/Link.tsx126
1 files changed, 108 insertions, 18 deletions
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
index 3cd593a10..50e741ea7 100644
--- a/src/components/Link.tsx
+++ b/src/components/Link.tsx
@@ -15,7 +15,6 @@ import {
   linkRequiresWarning,
 } from '#/lib/strings/url-helpers'
 import {isNative, isWeb} from '#/platform/detection'
-import {shouldClickOpenNewTab} from '#/platform/urls'
 import {useModalControls} from '#/state/modals'
 import {atoms as a, flatten, TextStyleProp, useTheme, web} from '#/alf'
 import {Button, ButtonProps} from '#/components/Button'
@@ -56,6 +55,12 @@ type BaseLinkProps = Pick<
   onPress?: (e: GestureResponderEvent) => void | false
 
   /**
+   * Callback for when the link is long pressed (on native). Prevent default
+   * and return `false` to exit early and prevent default long press hander.
+   */
+  onLongPress?: (e: GestureResponderEvent) => void | false
+
+  /**
    * Web-only attribute. Sets `download` attr on web.
    */
   download?: string
@@ -72,6 +77,7 @@ export function useLink({
   action = 'push',
   disableMismatchWarning,
   onPress: outerOnPress,
+  onLongPress: outerOnLongPress,
   shareOnLongPress,
 }: BaseLinkProps & {
   displayText: string
@@ -175,8 +181,14 @@ export function useLink({
     }
   }, [disableMismatchWarning, displayText, href, isExternal, openModal])
 
-  const onLongPress =
-    isNative && isExternal && shareOnLongPress ? handleLongPress : undefined
+  const onLongPress = React.useCallback(
+    (e: GestureResponderEvent) => {
+      const exitEarlyIfFalse = outerOnLongPress?.(e)
+      if (exitEarlyIfFalse === false) return
+      return isNative && shareOnLongPress ? handleLongPress() : undefined
+    },
+    [outerOnLongPress, handleLongPress, shareOnLongPress],
+  )
 
   return {
     isExternal,
@@ -202,14 +214,16 @@ export function Link({
   to,
   action = 'push',
   onPress: outerOnPress,
+  onLongPress: outerOnLongPress,
   download,
   ...rest
 }: LinkProps) {
-  const {href, isExternal, onPress} = useLink({
+  const {href, isExternal, onPress, onLongPress} = useLink({
     to,
     displayText: typeof children === 'string' ? children : '',
     action,
     onPress: outerOnPress,
+    onLongPress: outerOnLongPress,
   })
 
   return (
@@ -220,6 +234,7 @@ export function Link({
       accessibilityRole="link"
       href={href}
       onPress={download ? undefined : onPress}
+      onLongPress={onLongPress}
       {...web({
         hrefAttrs: {
           target: download ? undefined : isExternal ? 'blank' : undefined,
@@ -241,7 +256,7 @@ export type InlineLinkProps = React.PropsWithChildren<
     TextStyleProp &
     Pick<TextProps, 'selectable' | 'numberOfLines'>
 > &
-  Pick<ButtonProps, 'label'> & {
+  Pick<ButtonProps, 'label' | 'accessibilityHint'> & {
     disableUnderline?: boolean
     title?: TextProps['title']
   }
@@ -253,6 +268,7 @@ export function InlineLinkText({
   disableMismatchWarning,
   style,
   onPress: outerOnPress,
+  onLongPress: outerOnLongPress,
   download,
   selectable,
   label,
@@ -268,6 +284,7 @@ export function InlineLinkText({
     action,
     disableMismatchWarning,
     onPress: outerOnPress,
+    onLongPress: outerOnLongPress,
     shareOnLongPress,
   })
   const {
@@ -319,6 +336,21 @@ export function InlineLinkText({
   )
 }
 
+export function WebOnlyInlineLinkText({
+  children,
+  to,
+  onPress,
+  ...props
+}: Omit<InlineLinkProps, 'onLongPress'>) {
+  return isWeb ? (
+    <InlineLinkText {...props} to={to} onPress={onPress}>
+      {children}
+    </InlineLinkText>
+  ) : (
+    <Text {...props}>{children}</Text>
+  )
+}
+
 /**
  * Utility to create a static `onPress` handler for a `Link` that would otherwise link to a URI
  *
@@ -327,7 +359,10 @@ export function InlineLinkText({
  */
 export function createStaticClick(
   onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>,
-): Pick<BaseLinkProps, 'to' | 'onPress'> {
+): {
+  to: BaseLinkProps['to']
+  onPress: Exclude<BaseLinkProps['onPress'], undefined>
+} {
   return {
     to: '#',
     onPress(e: GestureResponderEvent) {
@@ -338,17 +373,72 @@ export function createStaticClick(
   }
 }
 
-export function WebOnlyInlineLinkText({
-  children,
-  to,
-  onPress,
-  ...props
-}: InlineLinkProps) {
-  return isWeb ? (
-    <InlineLinkText {...props} to={to} onPress={onPress}>
-      {children}
-    </InlineLinkText>
-  ) : (
-    <Text {...props}>{children}</Text>
+/**
+ * Utility to create a static `onPress` handler for a `Link`, but only if the
+ * click was not modified in some way e.g. `Cmd` or a middle click.
+ *
+ * On native, this behaves the same as `createStaticClick` because there are no
+ * options to "modify" the click in this sense.
+ *
+ * Example:
+ *   `<Link {...createStaticClick(e => {...})} />`
+ */
+export function createStaticClickIfUnmodified(
+  onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>,
+): {onPress: Exclude<BaseLinkProps['onPress'], undefined>} {
+  return {
+    onPress(e: GestureResponderEvent) {
+      if (!isWeb || !isModifiedClickEvent(e)) {
+        e.preventDefault()
+        onPressHandler(e)
+        return false
+      }
+    },
+  }
+}
+
+/**
+ * Determines if the click event has a meta key pressed, indicating the user
+ * intends to deviate from default behavior.
+ */
+export function isClickEventWithMetaKey(e: GestureResponderEvent) {
+  if (!isWeb) return false
+  const event = e as unknown as MouseEvent
+  return event.metaKey || event.altKey || event.ctrlKey || event.shiftKey
+}
+
+/**
+ * Determines if the web click target is anything other than `_self`
+ */
+export function isClickTargetExternal(e: GestureResponderEvent) {
+  if (!isWeb) return false
+  const event = e as unknown as MouseEvent
+  const el = event.currentTarget as HTMLAnchorElement
+  return el && el.target && el.target !== '_self'
+}
+
+/**
+ * Determines if a click event has been modified in some way from its default
+ * behavior, e.g. `Cmd` or a middle click.
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button}
+ */
+export function isModifiedClickEvent(e: GestureResponderEvent): boolean {
+  if (!isWeb) return false
+  const event = e as unknown as MouseEvent
+  const isPrimaryButton = event.button === 0
+  return (
+    isClickEventWithMetaKey(e) || isClickTargetExternal(e) || !isPrimaryButton
   )
 }
+
+/**
+ * Determines if a click event has been modified in a way that should indiciate
+ * that the user intends to open a new tab.
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button}
+ */
+export function shouldClickOpenNewTab(e: GestureResponderEvent) {
+  if (!isWeb) return false
+  const event = e as unknown as MouseEvent
+  const isMiddleClick = isWeb && event.button === 1
+  return isClickEventWithMetaKey(e) || isClickTargetExternal(e) || isMiddleClick
+}