import {
cloneElement,
type HTMLAttributes,
isValidElement,
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
type ReactNode,
type Ref,
useRef,
} from 'react';
import { clsx } from 'clsx';
import { mergeRefs } from '#/lib/utils/merge-refs';
import { useNavigationDisabled } from '#/components/NavigationDisabled';
import { isModifiedClick, navigateTo } from '#/components/web/Link';
import { type RouteTarget, useRouter } from '#/router';
// elements that handle their own press, plus regions that opt out via {@link noRowLink}; a click landing
// on one must not also navigate the row
const INTERACTIVE_SELECTOR = 'a, button, [role="button"], [role="link"], [data-no-row-link]';
/**
* spread onto any element to exempt clicks within it from {@link BlockLink} row navigation. useful for regions
* that handle their own clicks but are not semantic links or buttons.
*/
export const noRowLink = { 'data-no-row-link': '' };
type BlockLinkChildProps = HTMLAttributes<HTMLElement> & {
ref?: Ref<HTMLElement>;
};
type BlockLinkProps = {
/** The single host element to make clickable; its own box becomes the row. */
children: ReactNode;
/** The in-app destination, e.g. `{ name: 'PostThread', actor, rkey }`. */
to: RouteTarget;
/** structured-cloneable state for the destination history entry. */
state?: unknown;
/**
* makes the row a focusable link with the specified accessible name, activated by Enter. omit if inner
* links already provide keyboard/AT access to avoid redundant tab stops.
*/
label?: string;
className?: string;
ref?: Ref<HTMLElement>;
onBeforePress?: () => void;
onPointerEnter?: () => void;
onPointerLeave?: () => void;
};
// middle-click opens a new tab. mirrors WebAuxClickWrapper: swallow the middle-click autoscroll, then
// synthesise a meta-click so the same `onClick` path takes over (links handle their own middle-click).
const onMouseDown = (e: MouseEvent<HTMLElement>) => {
if (e.button === 1) {
e.preventDefault();
}
};
const onAuxClick = (e: MouseEvent<HTMLElement>) => {
const target = e.target;
if (e.button !== 1 || !(target instanceof Element) || target.closest('a')) {
return;
}
target.dispatchEvent(new MouseEvent('click', { bubbles: true, metaKey: true }));
};
/**
* navigates to `to` when its child is clicked, while allowing nested interactive elements and portalled
* popups to behave normally.
*
* clones its single child to attach press behavior, keyboard/AT affordances, and forwarded refs/classnames
* without rendering a wrapper element.
*/
export function BlockLink({
children,
to,
state,
label,
className,
ref,
onBeforePress,
onPointerEnter,
onPointerLeave,
}: BlockLinkProps) {
const router = useRouter();
const disabled = useNavigationDisabled();
// where the pointer last went down within the row; read back on click to gate navigation on the press
// origin rather than the release target
const pressOriginRef = useRef<Element | null>(null);
const go = (e?: MouseEvent<HTMLElement>) => {
onBeforePress?.();
// the row renders no anchor, so the path is only ever needed once a press actually lands
const path = router.href(to);
// a modified/middle click opens the route in a new same-origin tab (no native `<a>` to fall through to);
// a plain click navigates in place
if (e && isModifiedClick(e)) {
window.open(path, '_blank');
return;
}
navigateTo(router, path, 'push', state);
};
// record where the pointer went down so onClick can consult the press origin. capture phase so a child
// can't stop it from reaching us first
const onPointerDownCapture = (e: PointerEvent<HTMLElement>) => {
pressOriginRef.current = e.target instanceof Element ? e.target : null;
};
const onClick = (e: MouseEvent<HTMLElement>) => {
const target = e.target;
// a portalled popup's click bubbles up the component tree but its DOM node lives elsewhere
if (!(target instanceof Element) || !e.currentTarget.contains(target)) {
return;
}
// the browser fires `click` on the nearest common ancestor of the press and release nodes, so a drag
// that begins on an interactive sub-region (an image-carousel tile, a button) and releases on the row
// body yields a row-targeted click that slips past the target check below. gate on the press origin too.
const origin = pressOriginRef.current;
const originInteractive = origin?.closest(INTERACTIVE_SELECTOR);
if (originInteractive && originInteractive !== e.currentTarget) {
return;
}
// a nested link/button handles its own press; only a click on the row body should navigate
const interactive = target.closest(INTERACTIVE_SELECTOR);
if ((interactive && interactive !== e.currentTarget) || e.defaultPrevented) {
return;
}
// the click ending a drag-to-select gesture lands here; don't navigate while the user is selecting text
const selection = window.getSelection();
if (selection && !selection.isCollapsed && e.currentTarget.contains(selection.anchorNode)) {
return;
}
go(e);
};
// only fires when `label` makes the row a focusable link; activate on Enter when the row itself is focused
const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (e.key === 'Enter' && e.target === e.currentTarget) {
go();
}
};
if (!isValidElement<BlockLinkChildProps>(children)) {
throw new Error('BlockLink children must be a single React element');
}
const node = children;
const pressProps = disabled
? undefined
: {
'aria-label': label,
onClick,
onKeyDown: label ? onKeyDown : undefined,
onMouseDown,
onAuxClick,
onPointerDownCapture,
role: label ? 'link' : undefined,
tabIndex: label ? 0 : undefined,
};
// oxlint-disable-next-line react/refs -- forwards the prop ref
return cloneElement(node, {
...pressProps,
className: clsx(node.props.className, className),
onPointerEnter,
onPointerLeave,
// oxlint-disable-next-line react/refs -- forwards the prop ref
ref: mergeRefs([ref, node.props.ref]),
});
}