Skip to content

g1t/apps/web/app/components/ui/tab-strip.tsx

79 lines3,080 bytesCodeBlame
1import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react";
2import { useLocation } from "react-router";
3
4import { cn } from "../../lib/cn";
5
6/** How far the fade reaches in from an edge with more tabs past it. */
7const FADE = "1.5rem";
8
9/** The fade at either end that has more tabs beyond it. */
10function fade(start: boolean, end: boolean): CSSProperties | undefined {
11 if (!start && !end) return undefined;
12 const mask = `linear-gradient(to right, ${start ? "transparent" : "#000"}, #000 ${start ? FADE : "0px"}, #000 calc(100% - ${end ? FADE : "0px"}), ${end ? "transparent" : "#000"})`;
13 return { maskImage: mask, WebkitMaskImage: mask };
14}
15
16/** The current tab (`aria-current="page"` or `data-active`) scrolled into view, without scrolling the page. */
17function reveal(strip: HTMLElement) {
18 // A tab that says it is current wins over a link that only contains the address.
19 const current = strip.querySelector<HTMLElement>('[data-active="true"]') ?? strip.querySelector<HTMLElement>('[aria-current="page"]');
20 if (!current) return;
21 const left = current.offsetLeft;
22 const right = left + current.offsetWidth;
23 if (left < strip.scrollLeft) strip.scrollLeft = Math.max(0, left - 24);
24 else if (right > strip.scrollLeft + strip.clientWidth) strip.scrollLeft = right - strip.clientWidth + 24;
25}
26
27/**
28 * A row of tabs that never wraps: on a narrow screen it scrolls sideways,
29 * with no scrollbar, faded at an edge that has more beyond it, and the
30 * current tab (`aria-current="page"` or `data-active`) kept in view.
31 */
32export function TabStrip({ label, className, children }: { label?: string; className?: string; children: ReactNode }) {
33 const ref = useRef<HTMLElement>(null);
34 const [edges, setEdges] = useState({ start: false, end: false });
35 const { pathname, search } = useLocation();
36
37 useEffect(() => {
38 const strip = ref.current;
39 if (!strip) return;
40 const measure = () => {
41 const start = strip.scrollLeft > 1;
42 const end = strip.scrollLeft + strip.clientWidth < strip.scrollWidth - 1;
43 setEdges((now) => (now.start === start && now.end === end ? now : { start, end }));
44 };
45 measure();
46 strip.addEventListener("scroll", measure, { passive: true });
47 // A new width (the first layout, a rotated phone) can hide the current tab.
48 const observer =
49 typeof ResizeObserver === "undefined"
50 ? null
51 : new ResizeObserver(() => {
52 reveal(strip);
53 measure();
54 });
55 observer?.observe(strip);
56 return () => {
57 strip.removeEventListener("scroll", measure);
58 observer?.disconnect();
59 };
60 }, []);
61
62 useEffect(() => {
63 if (ref.current) reveal(ref.current);
64 }, [pathname, search]);
65
66 return (
67 <nav
68 ref={ref}
69 aria-label={label}
70 style={fade(edges.start, edges.end)}
71 className={cn(
72 "relative flex overflow-x-auto overflow-y-hidden whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&>*]:shrink-0",
73 className,
74 )}
75 >
76 {children}
77 </nav>
78 );
79}