Polish the Hub card carousel: edge shadows, fades, and drag-to-scroll (#6461)
* Fix clipped card shadows in Hub trending carousel The carousel scroller only had vertical padding, so with overflow-x set the first and last cards had their drop shadow clipped on the horizontal edges. Add px-2 with a matching -mx-2 so the shadow has room while the cards stay aligned with the section heading, and scroll-px-2 so snap-start does not scroll the padding away on load. * Align carousel edge fades with the scroll clip edge The shadow fix gave the scroller an -mx-2 bleed, but the left/right fade overlays stayed pinned to the wrapper edges, 8px inside the clip edge. That left a thin strip where a card showed beside the fade, so the fade read as a separate block instead of blending into the background. Offset both fades by the same 8px so their opaque edge sits on the clip edge. * Add click-and-drag panning to the Hub card carousel The rows only scrolled by wheel or trackpad, and grabbing a card started a native drag of its avatar image, so the cards could not be dragged to move the row. Add mouse drag-to-scroll (touch and pen keep native scrolling), swallow the click a drag would otherwise fire on a card, keep plain clicks working, and block the avatar's native drag. * Trim carousel edge fade width from 56px to 44px * Smooth out carousel drag panning Scroll snap was correcting the position on every drag frame, which made the pan feel sticky. Disable snap while a drag is active and restore it on release so the row follows the pointer and then settles on a card. * Drop stale carousel drag when the button is released off-element If a press ended outside the scroller before the drag threshold was crossed, no pointerup reached us and the drag stayed armed, so a later buttonless mousemove would scroll the row. Bail out and clear the drag whenever the primary button is no longer held.
This commit is contained in:
parent
45f060899e
commit
aec27263e7
2 changed files with 75 additions and 4 deletions
|
|
@ -5,6 +5,8 @@ import { cn } from "@/lib/utils";
|
|||
import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
|
|
@ -102,13 +104,79 @@ export function CardCarousel<T>({
|
|||
[stepPx],
|
||||
);
|
||||
|
||||
// Click-and-drag panning (mouse only; touch/pen keep native scrolling).
|
||||
const drag = useRef<{ id: number; x: number; left: number; moved: boolean } | null>(
|
||||
null,
|
||||
);
|
||||
const suppressClick = useRef(false);
|
||||
|
||||
const onPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
suppressClick.current = false;
|
||||
const el = scrollerRef.current;
|
||||
if (!el || e.pointerType !== "mouse" || e.button !== 0) return;
|
||||
drag.current = { id: e.pointerId, x: e.clientX, left: el.scrollLeft, moved: false };
|
||||
}, []);
|
||||
|
||||
const onPointerMove = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const d = drag.current;
|
||||
const el = scrollerRef.current;
|
||||
if (!d || !el || e.pointerId !== d.id) return;
|
||||
// Primary button no longer held: the press ended off the scroller, so no
|
||||
// pointerup reached us. Drop the stale drag instead of scrolling on hover.
|
||||
if ((e.buttons & 1) === 0) {
|
||||
if (d.moved) el.style.scrollSnapType = "";
|
||||
drag.current = null;
|
||||
return;
|
||||
}
|
||||
const dx = e.clientX - d.x;
|
||||
// Ignore tiny moves so plain clicks still register.
|
||||
if (!d.moved && Math.abs(dx) < 5) return;
|
||||
if (!d.moved) {
|
||||
d.moved = true;
|
||||
// Snap fights the per-frame scrollLeft writes; disable it while dragging.
|
||||
el.style.scrollSnapType = "none";
|
||||
el.setPointerCapture(d.id);
|
||||
}
|
||||
el.scrollLeft = d.left - dx;
|
||||
}, []);
|
||||
|
||||
const endDrag = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const d = drag.current;
|
||||
if (!d || e.pointerId !== d.id) return;
|
||||
if (d.moved) {
|
||||
// A drag just happened: swallow the click it would fire on a card.
|
||||
suppressClick.current = true;
|
||||
const el = scrollerRef.current;
|
||||
// Restore snap so the row settles on a card after the drag.
|
||||
if (el) el.style.scrollSnapType = "";
|
||||
el?.releasePointerCapture?.(d.id);
|
||||
}
|
||||
drag.current = null;
|
||||
}, []);
|
||||
|
||||
const onClickCapture = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!suppressClick.current) return;
|
||||
suppressClick.current = false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
onScroll={updateArrows}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onClickCapture={onClickCapture}
|
||||
// Stop the avatar image from starting a native drag during a pan.
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
aria-label={ariaLabel}
|
||||
className="hub-carousel flex snap-x gap-4 overflow-x-auto pb-4 pt-2"
|
||||
// px-2 + -mx-2 give card shadows room so the edge cards aren't clipped;
|
||||
// scroll-px-2 keeps snap-start aligned with the heading.
|
||||
className="hub-carousel -mx-2 flex cursor-grab snap-x scroll-px-2 gap-4 overflow-x-auto px-2 pb-4 pt-2 select-none active:cursor-grabbing"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@
|
|||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
width: 56px;
|
||||
width: 44px;
|
||||
opacity: 0;
|
||||
transition: opacity 240ms ease;
|
||||
}
|
||||
|
|
@ -157,7 +157,9 @@
|
|||
}
|
||||
|
||||
.hub-page .hub-carousel-fade-left {
|
||||
left: 0;
|
||||
/* -8px matches the scroller's -mx-2 bleed so the opaque edge sits on the
|
||||
clip edge and no card peeks out beside the fade. */
|
||||
left: -8px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--background),
|
||||
|
|
@ -167,7 +169,8 @@
|
|||
}
|
||||
|
||||
.hub-page .hub-carousel-fade-right {
|
||||
right: 0;
|
||||
/* Mirror of fade-left: offset by the -mx-2 bleed to reach the clip edge. */
|
||||
right: -8px;
|
||||
background: linear-gradient(
|
||||
to left,
|
||||
var(--background),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue