diff options
author | Eric Bailey <git@esb.lol> | 2025-01-21 15:56:01 -0600 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-01-21 21:56:01 +0000 |
commit | 9df5caf3c545a7a1c559c6561625d99154aa0603 (patch) | |
tree | 3f7a1b2fdb6fb7628e22b79a978b762cccdd200e /src/components/Link.tsx | |
parent | c8d062f1aef130e13a99892e7bb695b1e123c3db (diff) | |
download | voidsky-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.tsx | 126 |
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 +} |