import {
type ComponentPropsWithoutRef,
type ReactNode,
useEffect,
useEffectEvent,
useRef,
useState,
} from 'react';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { clsx } from 'clsx';
import * as css from '#/components/TabScroller.css';
import { Button, ButtonIcon } from '#/components/web/Button';
import ArrowLeft from '#/icons/central/ArrowLeft_round_outlined_radius1_stroke2.svg';
import ArrowRight from '#/icons/central/ArrowRight_round_outlined_radius1_stroke2.svg';
import { m } from '#/paraglide/messages';
import { space } from '#/styles/tokens.css';
/** Pixels scrolled per edge-button click. */
const SCROLL_STEP = 200;
/** Pixels scrolled per frame while an edge button is held. */
const CONTINUOUS_SCROLL_SPEED = 6;
/** Hold this long before a press turns into a continuous scroll. */
const CONTINUOUS_SCROLL_DELAY = 500;
/**
* Horizontally-scrolling row of pills. Owns the scroll mechanics — drag-to-scroll, hidden scrollbar, edge
* fade/scroll buttons, and centering the active pill into view — while the pills themselves are supplied as
* {@link Tab} children. Mark the selected child with `active`, and pass its key as `activeKey` so the row
* re-centers when the selection changes.
*/
export function Root({
activeKey,
children,
gutterWidth = space.lg,
}: {
/** Key of the active tab; changing it re-centers the `active` pill into view. */
activeKey: string;
children: ReactNode;
/** Horizontal inset of the scroller and its edge fades. */
gutterWidth?: number;
}) {
const scrollerRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const syncAffordances = useEffectEvent(() => {
const el = scrollerRef.current;
if (!el) {
return;
}
setCanScrollLeft(el.scrollLeft > 0);
setCanScrollRight(Math.ceil(el.scrollLeft) < el.scrollWidth - el.clientWidth - 1);
});
// track whether either edge can scroll, both as the row scrolls and as its size changes
useEffect(() => {
const el = scrollerRef.current;
if (!el) {
return;
}
el.addEventListener('scroll', syncAffordances, { passive: true });
const observer = new ResizeObserver(syncAffordances);
observer.observe(el);
return () => {
el.removeEventListener('scroll', syncAffordances);
observer.disconnect();
};
}, []);
// re-sync after every commit so a changing tab set (which grows content without resizing the row) is caught
useEffect(() => {
syncAffordances();
});
// center the active pill — scoped to the row's own scrollLeft so it doesn't nudge ancestors (e.g. the page)
useEffect(() => {
const el = scrollerRef.current;
const active = el?.querySelector('[data-active="true"]');
if (!el || !active) {
return;
}
const rowRect = el.getBoundingClientRect();
const tabRect = active.getBoundingClientRect();
const dx = tabRect.left + tabRect.width / 2 - (rowRect.left + rowRect.width / 2);
if (dx !== 0) {
el.scrollBy({ behavior: 'smooth', left: dx });
}
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- activeKey triggers recentering
}, [activeKey]);
// click-and-drag to scroll, since the scrollbar is hidden and a trackpad/shift-wheel isn't always available
useEffect(() => {
const row = scrollerRef.current;
if (!row) {
return;
}
let isPressed = false;
let isDragging = false;
let startX = 0;
let startScrollLeft = 0;
const onMouseDown = (e: MouseEvent) => {
isPressed = true;
startX = e.pageX;
startScrollLeft = row.scrollLeft;
};
const onMouseMove = (e: MouseEvent) => {
if (!isPressed) {
return;
}
const walk = e.pageX - startX;
if (!isDragging && Math.abs(walk) < 3) {
return;
}
isDragging = true;
e.preventDefault();
row.scrollLeft = startScrollLeft - walk;
};
const onMouseUp = () => {
if (isDragging) {
// swallow the trailing click so a drag that lands on a pill doesn't select it
row.addEventListener('click', (e) => e.stopPropagation(), { capture: true, once: true });
}
isPressed = false;
isDragging = false;
};
row.addEventListener('mousedown', onMouseDown);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
return () => {
row.removeEventListener('mousedown', onMouseDown);
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
}, []);
const holdTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const holdFrame = useRef<number | null>(null);
const stopContinuousScroll = () => {
if (holdTimeout.current != null) {
clearTimeout(holdTimeout.current);
holdTimeout.current = null;
}
if (holdFrame.current != null) {
cancelAnimationFrame(holdFrame.current);
holdFrame.current = null;
}
};
const scrollByStep = (direction: 'left' | 'right') => {
scrollerRef.current?.scrollBy({
behavior: 'smooth',
left: direction === 'left' ? -SCROLL_STEP : SCROLL_STEP,
});
};
const startContinuousScroll = (direction: 'left' | 'right') => {
stopContinuousScroll();
holdTimeout.current = setTimeout(() => {
const step = () => {
const el = scrollerRef.current;
if (!el) {
return;
}
el.scrollLeft += direction === 'left' ? -CONTINUOUS_SCROLL_SPEED : CONTINUOUS_SCROLL_SPEED;
holdFrame.current = requestAnimationFrame(step);
};
holdFrame.current = requestAnimationFrame(step);
}, CONTINUOUS_SCROLL_DELAY);
};
const stopOnUnmount = useEffectEvent(stopContinuousScroll);
useEffect(() => () => stopOnUnmount(), []);
return (
<div className={css.outer} style={assignInlineVars({ [css.gutterVar]: `${gutterWidth}px` })}>
<div className={css.scroller} ref={scrollerRef}>
{children}
</div>
{canScrollLeft && (
<div className={css.edgeLeft}>
<Button
className={css.edgeButton}
color="secondary"
label={m['common.a11y.scrollLeft']()}
onClick={() => scrollByStep('left')}
onPointerDown={() => startContinuousScroll('left')}
onPointerLeave={stopContinuousScroll}
onPointerUp={stopContinuousScroll}
shape="round"
variant="outline"
>
<ButtonIcon icon={ArrowLeft} />
</Button>
</div>
)}
{canScrollRight && (
<div className={css.edgeRight}>
<Button
className={css.edgeButton}
color="secondary"
label={m['common.a11y.scrollRight']()}
onClick={() => scrollByStep('right')}
onPointerDown={() => startContinuousScroll('right')}
onPointerLeave={stopContinuousScroll}
onPointerUp={stopContinuousScroll}
shape="round"
variant="outline"
>
<ButtonIcon icon={ArrowRight} />
</Button>
</div>
)}
</div>
);
}
/**
* A pill inside a {@link Root}. Renders a `<button>` carrying the shared pill styling; pass its content as
* children and its click/aria handlers as regular button props.
*/
export function Tab({
active = false,
children,
className,
...props
}: { active?: boolean; children: ReactNode } & ComponentPropsWithoutRef<'button'>) {
'use no memo'; // forwarded props invalidate the generated wrapper cache
return (
<button {...props} className={clsx(css.tab({ active }), className)} data-active={active} type="button">
{children}
</button>
);
}