Files
CRAPP/src/components/crapp/BarraSottosezioni.tsx
T
davideandClaude Sonnet 5 34edfc662e Il precedente fix dello swipe non bastava: guarda anche offset.y
dragDirectionLock non ha effetto quando drag è già vincolato a un solo
asse ("x"): il gesto veniva comunque interpretato come swipe orizzontale
sulla sola velocity.x, rumorosa nei flick verticali veloci (bug visto
sia verso Obiettivi che verso Rosa, segno che il rumore cambiava segno).
Ora onDragEnd ignora il gesto se lo spostamento verticale (offset.y) è
maggiore di quello orizzontale (offset.x): uno scroll resta sempre
dominante in verticale, uno swipe intenzionale in orizzontale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 09:23:06 +02:00

175 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState, type ReactNode } from "react";
import { motion, type Transition } from "motion/react";
import { cn } from "@/lib/utils";
import { proietta } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
export type VoceSottosezione = {
id: string;
label: string;
contenuto: ReactNode;
/** Se true, il pannello è a tutta larghezza (niente padding), es. barra filtri Classifica. */
aTuttoLarghezza?: boolean;
};
/** Tween breve: evita molle + exit che tengono due pannelli in DOM insieme. */
const transizioneTab: Transition = { type: "tween", duration: 0.16, ease: [0.25, 0.1, 0.25, 1] };
/**
* Barra di sottosezioni in un'unica fila e pannello che mostra una sola sezione
* alla volta, cambiabile anche con swipe sul contenuto.
*
* `variante="sottolineatura"`: pillola piena sulla tab attiva (Squadra e Classifica CSI);
* su mobile la sola barra tab scorre in orizzontale senza allargare la pagina.
* Default `pillole`: tab a larghezza uguale (basata sulla etichetta più lunga); se non
* entrano nella viewport la barra scorre in orizzontale (Profilo). Il titolo ripetuto sotto
* la barra non viene mai mostrato: l’etichetta è già nella tab.
*/
export function BarraSottosezioni({
voci,
defaultId,
variante = "pillole",
riempiLarghezza = false,
}: {
voci: VoceSottosezione[];
defaultId?: string;
variante?: "pillole" | "sottolineatura";
/** Tab a larghezza uguale che riempiono la barra (es. Campionato, Squadra). */
riempiLarghezza?: boolean;
}) {
const [attiva, setAttiva] = useState(defaultId ?? voci[0]?.id ?? "");
const direzione = useRef(0);
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
// `prefers-reduced-motion`: qui disattiva sia lo swipe orizzontale sia il
// blur della barra tab, i due costi maggiori all'ingresso in una pagina con
// sottosezioni (Squadra, Campionato, Profilo).
const ridotto = useMotoRidotto();
const indice = Math.max(
0,
voci.findIndex((v) => v.id === attiva),
);
const voce = voci[indice] ?? voci[0];
const sottolineatura = variante === "sottolineatura";
useEffect(() => {
tabRefs.current[attiva]?.scrollIntoView({
behavior: "smooth",
inline: "center",
block: "nearest",
});
}, [attiva]);
function vaiA(nuovo: number) {
if (nuovo < 0 || nuovo >= voci.length) return;
const target = voci[nuovo];
if (!target || target.id === attiva) return;
direzione.current = nuovo > indice ? 1 : -1;
setAttiva(target.id);
}
if (!voce) return null;
return (
<div className="min-w-0 max-w-full">
<div
className={cn(
"min-w-0 max-w-full border-b border-border bg-background",
!sottolineatura &&
(ridotto ? "bg-background pt-3" : "bg-background/80 pt-3 backdrop-blur-md"),
)}
>
{/* Solo la barra tab può scrollare in orizzontale: non allarga il layout pagina. */}
<div
className={cn(
"w-full max-w-full min-w-0 overflow-x-auto overflow-y-hidden [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
!sottolineatura && "pb-3",
)}
>
<div
role="tablist"
aria-label="Sottosezioni"
className={cn(
riempiLarghezza
? "flex w-full flex-nowrap"
: sottolineatura
? "flex w-max min-w-full flex-nowrap"
: // Pillole: colonne uguali alla voce più lunga; overflow → scroll sul wrapper.
"grid w-max min-w-full grid-flow-col auto-cols-[1fr]",
sottolineatura ? "gap-1 px-2 py-1.5" : "gap-1.5 px-5",
)}
>
{voci.map((v) => {
const selezionata = v.id === attiva;
return (
<button
key={v.id}
ref={(el) => {
tabRefs.current[v.id] = el;
}}
type="button"
role="tab"
aria-selected={selezionata}
onClick={() => {
const i = voci.findIndex((x) => x.id === v.id);
vaiA(i);
}}
className={cn(
"min-h-11 touch-manipulation whitespace-nowrap text-sm font-bold uppercase tracking-wide transition-colors",
riempiLarghezza ? "min-w-0 flex-1" : sottolineatura ? "shrink-0" : "w-full",
sottolineatura
? cn(
"rounded-xl px-1 py-2.5 text-center",
selezionata
? "bg-accent text-accent-foreground shadow-pop"
: "text-muted-foreground",
)
: cn(
"rounded-full py-2 text-center",
riempiLarghezza ? "px-2" : "px-3.5",
selezionata
? "bg-accent text-accent-foreground shadow-pop"
: "bg-secondary text-muted-foreground",
),
)}
>
{v.label}
</button>
);
})}
</div>
</div>
</div>
<div className="relative min-w-0 overflow-hidden">
<motion.div
key={voce.id}
role="tabpanel"
aria-label={voce.label}
// Sui device deboli lo swipe orizzontale (pointer listener + hit-testing
// ad ogni frame) resta disattivato: si cambia tab solo toccando la barra.
drag={ridotto ? false : "x"}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.12}
dragMomentum={false}
onDragEnd={(_, info) => {
// Uno scroll verticale (anche veloce) ha sempre offset.y ben maggiore di
// offset.x: la velocity.x, invece, può essere rumorosa su gesti rapidi e da
// sola non basta a distinguere uno swipe reale da un semplice scroll in giù.
if (Math.abs(info.offset.x) <= Math.abs(info.offset.y)) return;
const arrivo = info.offset.x + proietta(info.velocity.x);
if (arrivo < -60) vaiA(indice + 1);
else if (arrivo > 60) vaiA(indice - 1);
}}
initial={ridotto ? { opacity: 0 } : { opacity: 0, x: direzione.current * 20 }}
animate={{ opacity: 1, x: 0 }}
transition={ridotto ? { duration: 0.1 } : transizioneTab}
className={cn("touch-pan-y", voce.aTuttoLarghezza ? "px-0 pt-0 pb-4" : "px-5 py-4")}
>
{voce.contenuto}
</motion.div>
</div>
</div>
);
}