ce2d9d9f77
The state pills and the agent select sat 10px apart, and the pill row was sliced mid-chip at the viewport edge with nothing to say it scrolled. - New `ScrollRow`: fades whichever edge still has content behind it, so a clipped chip reads as "scroll for more" instead of as a broken layout. Reused for the admin and connect-modal tab strips. - Drop scroll-snap from the pill row. It clamped the resting scrollLeft to the container's 12px padding, so the first chip sat flush against the screen edge with no gutter; flick-snapping through short chips felt wrong regardless. - Breathing room: pills to select 10px -> 14px, filters to list 16px -> 20px. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
64 lines
1.9 KiB
TypeScript
64 lines
1.9 KiB
TypeScript
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
const FADE = 28; // px of taper at each overflowing edge
|
|
|
|
/**
|
|
* Horizontally scrollable row of chips/tabs. Fades whichever edge still has
|
|
* content behind it, so a clipped item reads as "scroll for more" rather than
|
|
* as a layout bug — and contains its overscroll so a sideways swipe never
|
|
* triggers the browser's back gesture.
|
|
*/
|
|
export function ScrollRow({
|
|
children,
|
|
className = "",
|
|
wrapperClassName = "",
|
|
bleed = true,
|
|
}: {
|
|
children: ReactNode;
|
|
/** Applied to the scrolling element (layout of the items). */
|
|
className?: string;
|
|
/** Applied to the positioned wrapper (borders, margins). */
|
|
wrapperClassName?: string;
|
|
/** Extend to the screen edges on phones so the row reads as scrollable. */
|
|
bleed?: boolean;
|
|
}) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const [edges, setEdges] = useState({ start: false, end: false });
|
|
|
|
const update = useCallback(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const max = el.scrollWidth - el.clientWidth;
|
|
setEdges({ start: el.scrollLeft > 4, end: max > 4 && el.scrollLeft < max - 4 });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
update();
|
|
const el = ref.current;
|
|
if (!el || typeof ResizeObserver === "undefined") return;
|
|
const ro = new ResizeObserver(update);
|
|
ro.observe(el);
|
|
for (const child of Array.from(el.children)) ro.observe(child);
|
|
return () => ro.disconnect();
|
|
}, [update]);
|
|
|
|
const mask = `linear-gradient(to right, ${
|
|
edges.start ? `transparent 0, black ${FADE}px` : "black 0"
|
|
}, ${edges.end ? `black calc(100% - ${FADE}px), transparent 100%` : "black 100%"})`;
|
|
|
|
return (
|
|
<div className={`relative ${bleed ? "-mx-3 sm:mx-0" : ""} ${wrapperClassName}`}>
|
|
<div
|
|
ref={ref}
|
|
onScroll={update}
|
|
style={{ maskImage: mask, WebkitMaskImage: mask }}
|
|
className={`no-scrollbar flex overflow-x-auto overscroll-x-contain ${
|
|
bleed ? "px-3 sm:px-0" : ""
|
|
} ${className}`}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|