2026-09-05 21:18:19 +02:00
|
|
|
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
2026-09-08 09:39:40 +02:00
|
|
|
|
import { motion } from "motion/react";
|
2026-09-05 21:18:19 +02:00
|
|
|
|
import { cn } from "@/lib/utils";
|
2026-09-05 22:24:57 +02:00
|
|
|
|
import { proietta } from "@/lib/molla";
|
2026-09-08 09:39:40 +02:00
|
|
|
|
import { useMotoRidotto } from "@/lib/motion";
|
2026-09-05 21:18:19 +02:00
|
|
|
|
|
|
|
|
|
|
export type VoceSottosezione = {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
contenuto: ReactNode;
|
2026-09-07 08:48:21 +02:00
|
|
|
|
/** Se true, il pannello è a tutta larghezza (niente padding), es. barra filtri Classifica. */
|
|
|
|
|
|
aTuttoLarghezza?: boolean;
|
2026-09-05 21:18:19 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-05 22:24:57 +02:00
|
|
|
|
/** Tween breve: evita molle + exit che tengono due pannelli in DOM insieme. */
|
|
|
|
|
|
const transizioneTab = { type: "tween" as const, duration: 0.16, ease: [0.25, 0.1, 0.25, 1] };
|
|
|
|
|
|
|
2026-09-05 21:18:19 +02:00
|
|
|
|
/**
|
2026-09-07 17:49:40 +02:00
|
|
|
|
* Barra di sottosezioni in un'unica fila e pannello che mostra una sola sezione
|
|
|
|
|
|
* alla volta, cambiabile anche con swipe sul contenuto.
|
2026-09-05 22:24:57 +02:00
|
|
|
|
*
|
2026-09-07 17:49:40 +02:00
|
|
|
|
* `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 naturale (Profilo). Il titolo ripetuto sotto la
|
|
|
|
|
|
* barra non viene mai mostrato: l’etichetta è già nella tab.
|
2026-09-05 21:18:19 +02:00
|
|
|
|
*/
|
|
|
|
|
|
export function BarraSottosezioni({
|
|
|
|
|
|
voci,
|
|
|
|
|
|
defaultId,
|
2026-09-06 18:50:11 +02:00
|
|
|
|
variante = "pillole",
|
2026-09-07 18:35:35 +02:00
|
|
|
|
riempiLarghezza = false,
|
2026-09-05 21:18:19 +02:00
|
|
|
|
}: {
|
|
|
|
|
|
voci: VoceSottosezione[];
|
|
|
|
|
|
defaultId?: string;
|
2026-09-06 18:50:11 +02:00
|
|
|
|
variante?: "pillole" | "sottolineatura";
|
2026-09-07 18:35:35 +02:00
|
|
|
|
/** Tab a larghezza uguale che riempiono la barra (es. Campionato). */
|
|
|
|
|
|
riempiLarghezza?: boolean;
|
2026-09-05 21:18:19 +02:00
|
|
|
|
}) {
|
|
|
|
|
|
const [attiva, setAttiva] = useState(defaultId ?? voci[0]?.id ?? "");
|
|
|
|
|
|
const direzione = useRef(0);
|
|
|
|
|
|
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
2026-09-08 09:39:40 +02:00
|
|
|
|
// `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();
|
2026-09-05 21:18:19 +02:00
|
|
|
|
const indice = Math.max(
|
|
|
|
|
|
0,
|
|
|
|
|
|
voci.findIndex((v) => v.id === attiva),
|
|
|
|
|
|
);
|
|
|
|
|
|
const voce = voci[indice] ?? voci[0];
|
2026-09-06 18:50:11 +02:00
|
|
|
|
const sottolineatura = variante === "sottolineatura";
|
2026-09-05 21:18:19 +02:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
tabRefs.current[attiva]?.scrollIntoView({
|
2026-09-07 17:49:40 +02:00
|
|
|
|
behavior: "smooth",
|
2026-09-05 21:18:19 +02:00
|
|
|
|
inline: "center",
|
|
|
|
|
|
block: "nearest",
|
|
|
|
|
|
});
|
2026-09-05 22:24:57 +02:00
|
|
|
|
}, [attiva]);
|
2026-09-05 21:18:19 +02:00
|
|
|
|
|
|
|
|
|
|
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 (
|
2026-09-07 17:49:40 +02:00
|
|
|
|
<div className="min-w-0 max-w-full">
|
2026-09-06 18:50:11 +02:00
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
2026-09-07 17:49:40 +02:00
|
|
|
|
"min-w-0 max-w-full border-b border-border bg-background",
|
2026-09-08 09:39:40 +02:00
|
|
|
|
!sottolineatura &&
|
|
|
|
|
|
(ridotto ? "bg-background pt-3" : "bg-background/80 pt-3 backdrop-blur-md"),
|
2026-09-06 18:50:11 +02:00
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-09-07 17:49:40 +02:00
|
|
|
|
{/* Solo la barra tab può scrollare in orizzontale: non allarga il layout pagina. */}
|
2026-09-05 21:18:19 +02:00
|
|
|
|
<div
|
2026-09-06 18:50:11 +02:00
|
|
|
|
className={cn(
|
2026-09-07 17:49:40 +02:00
|
|
|
|
"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",
|
2026-09-06 18:50:11 +02:00
|
|
|
|
)}
|
2026-09-05 21:18:19 +02:00
|
|
|
|
>
|
2026-09-07 17:49:40 +02:00
|
|
|
|
<div
|
|
|
|
|
|
role="tablist"
|
|
|
|
|
|
aria-label="Sottosezioni"
|
|
|
|
|
|
className={cn(
|
2026-09-07 18:35:35 +02:00
|
|
|
|
"flex flex-nowrap",
|
2026-09-08 14:12:49 +02:00
|
|
|
|
riempiLarghezza ? "w-full" : "w-max min-w-full",
|
2026-09-07 17:49:40 +02:00
|
|
|
|
sottolineatura ? "gap-1 px-2 py-1.5" : "snap-x snap-mandatory 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(
|
2026-09-07 18:35:35 +02:00
|
|
|
|
"min-h-11 touch-manipulation whitespace-nowrap text-sm font-bold uppercase tracking-wide transition-colors",
|
|
|
|
|
|
riempiLarghezza ? "min-w-0 flex-1" : "shrink-0",
|
2026-09-07 17:49:40 +02:00
|
|
|
|
sottolineatura
|
|
|
|
|
|
? cn(
|
|
|
|
|
|
"rounded-xl px-1 py-2.5 text-center",
|
|
|
|
|
|
selezionata
|
|
|
|
|
|
? "bg-accent text-accent-foreground shadow-pop"
|
|
|
|
|
|
: "text-muted-foreground",
|
|
|
|
|
|
)
|
|
|
|
|
|
: cn(
|
|
|
|
|
|
"snap-center rounded-full px-3.5 py-2",
|
|
|
|
|
|
selezionata
|
|
|
|
|
|
? "bg-accent text-accent-foreground shadow-pop"
|
|
|
|
|
|
: "bg-secondary text-muted-foreground",
|
|
|
|
|
|
),
|
2026-09-07 18:35:35 +02:00
|
|
|
|
)}
|
2026-09-07 17:49:40 +02:00
|
|
|
|
>
|
|
|
|
|
|
{v.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
2026-09-05 21:18:19 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-07 17:49:40 +02:00
|
|
|
|
<div className="relative min-w-0 overflow-hidden">
|
2026-09-05 22:24:57 +02:00
|
|
|
|
<motion.div
|
|
|
|
|
|
key={voce.id}
|
|
|
|
|
|
role="tabpanel"
|
|
|
|
|
|
aria-label={voce.label}
|
2026-09-08 09:39:40 +02:00
|
|
|
|
// 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"}
|
2026-09-05 22:24:57 +02:00
|
|
|
|
dragConstraints={{ left: 0, right: 0 }}
|
|
|
|
|
|
dragElastic={0.12}
|
|
|
|
|
|
dragMomentum={false}
|
|
|
|
|
|
onDragEnd={(_, info) => {
|
|
|
|
|
|
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}
|
2026-09-07 08:48:21 +02:00
|
|
|
|
className={cn("touch-pan-y", voce.aTuttoLarghezza ? "px-0 pt-0 pb-4" : "px-5 py-4")}
|
2026-09-05 22:24:57 +02:00
|
|
|
|
>
|
|
|
|
|
|
{voce.contenuto}
|
|
|
|
|
|
</motion.div>
|
2026-09-05 21:18:19 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|