- schema-profili: il test pretendeva che lo slot g1 fosse libero, ma in produzione è collegato a un giocatore vero; ora verifica che il tentativo anonimo non cambi il valore. - rosa: il default dei voti MVP era fuori da useMemo e faceva ricalcolare la rosa a ogni render finché i voti non erano caricati. - test dei badge, profili-core, push-sw, scout-stato: tipi allineati al codice (MappaPresenze, nascita da M18, renotify, narrowing di deepEqual). - vite.config: tipizzato come UserConfig e rimosso esbuild.keepNames, ignorato da Vite 8 che non usa più esbuild. - formattazione Prettier in BarraSottosezioni e classifica. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
171 lines
6.4 KiB
TypeScript
171 lines
6.4 KiB
TypeScript
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) => {
|
||
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>
|
||
);
|
||
}
|