Files
CRAPP/src/components/crapp/BarraSottosezioni.tsx
T
davideandClaude Opus 5.5 431530af2a Azzera errori di lint e di tipo e corregge il test sugli slot della rosa
- 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>
2026-09-24 12:14:37 +02:00

171 lines
6.4 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) => {
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>
);
}