Skip to content

ModuleMenuMobile - Menu Mobile per Moduli

Panoramica

ModuleMenuMobile è il componente dedicato alla visualizzazione del menu dei moduli su dispositivi mobili e tablet. Sostituisce il menu desktop ModuleMenu quando la larghezza della finestra è inferiore a 1024px, fornendo un'esperienza ottimizzata per schermi touch.

Caratteristiche

  • 🎯 Menu Sheet Laterale: Usa SheetRoot da @elerama/ui per un drawer scorrevole da sinistra
  • 📱 Touch-Optimized: Implementa fix specifici per lo scroll su dispositivi touch
  • 🔄 Collapsible Submenu: Supporta menu a più livelli con animazioni e icone dinamiche
  • Auto-Close: Si chiude automaticamente al click su un link per migliore UX mobile

Quando Viene Usato

Il componente viene renderizzato condizionalmente in ModuleMenu basandosi sul breakpoint:

File: app/components/layout/topbar/module-menu.tsx

typescript
const isMobile = useIsMobile(1024);

return (
    <>
        {isMobile ? (
            <ModuleMenuMobile
                menuData={menuData}
                activeSubmenuId={activeSubmenuId}
            />
        ) : (
            // Desktop menu...
        )}
    </>
);

Breakpoint:

  • < 1024px: ModuleMenuMobile (Sheet laterale)
  • >= 1024px: ModuleMenu desktop (dropdown)

Struttura del Componente

Props

typescript
interface ModuleMenuMobileProps {
    menuData: MenuItem[];        // Dati menu dal backend
    activeSubmenuId: string | null;  // ID del submenu attivo
}

Componenti Usati

  • SheetRoot: Container principale del drawer (da @elerama/ui)
  • SheetContent: Contenuto scrollabile con fix touch
  • Collapsible: Gestisce l'espansione/collasso dei submenu
  • CollapsibleTrigger: Pulsante per toggle del submenu
  • CollapsibleContent: Contenuto del submenu

Implementazione

File: app/components/layout/topbar/module-menu-mobile.tsx

Apertura Sheet

typescript
<Button
    variant="ghost"
    size="icon"
    onClick={() => setOpenMobile(true)}
    aria-label="Apri menu modulo"
>
    <Menu />
</Button>

Sheet con Fix Touch Scroll

typescript
<SheetRoot open={openMobile} onOpenChange={setOpenMobile}>
    <SheetContent
        side="left"
        className="w-[280px] p-0 flex flex-col overflow-hidden"
        hideCloseButton={true}
    >
        {/* Container scrollabile con fix touch */}
        <div
            ref={scrollContainerRef}
            className="flex-1 overflow-y-scroll overscroll-contain p-4"
            style={{ touchAction: 'pan-y' }}
            onTouchMove={(e) => e.stopPropagation()}
        >
            {renderMobileMenuItems(menuData)}
        </div>
    </SheetContent>
</SheetRoot>

Nota: Il fix touch scroll è documentato in dettaglio in Touch Scroll Fix.

Rendering Menu Items

Il metodo renderMobileMenuItems gestisce tre tipi di voci:

  1. Voci semplici (primo livello)

    typescript
    <Link
        to={`/r${item.link}`}
        onClick={() => setOpenMobile(false)}
        className={cn("rounded-md px-3 py-2...", isActive && "bg-accent")}
    >
        {item.text}
    </Link>
  2. Voci con submenu (Collapsible)

    typescript
    <CollapsibleMenuItem
        title={item.text}
        defaultOpen={isActive}
    >
        {/* Sottovoci qui */}
    </CollapsibleMenuItem>
  3. Sottomenu annidati

    • Supporta fino a 3 livelli di profondità
    • Renderizza header e sottovoci raggruppate

Auto-Close al Click

Ogni link chiude automaticamente lo Sheet:

typescript
onClick={() => setOpenMobile(false)}

Questo migliora l'esperienza utente mobile evitando che l'utente debba chiudere manualmente il menu dopo aver selezionato una voce.

CollapsibleMenuItem Component

Componente interno per gestire submenu collassabili con stato controllato:

typescript
function CollapsibleMenuItem({
    title,
    defaultOpen = false,
    children
}: CollapsibleMenuItemProps) {
    const [isOpen, setIsOpen] = React.useState(defaultOpen);

    return (
        <Collapsible open={isOpen} onOpenChange={setIsOpen}>
            <CollapsibleTrigger className="...">
                <span>{title}</span>
                {isOpen ? <ChevronDown /> : <ChevronRight />}
            </CollapsibleTrigger>
            <CollapsibleContent className="pl-4 pt-1 data-[state=closed]:animate-none">
                {children}
            </CollapsibleContent>
        </Collapsible>
    );
}

Caratteristiche:

  • ✅ Icona dinamica: ChevronDown quando aperto, ChevronRight quando chiuso
  • ✅ Animazione rimossa alla chiusura (data-[state=closed]:animate-none)
  • ✅ Aperto di default se contiene la voce attiva (defaultOpen={isActive})

Active State Detection

Il componente usa le utility da module-menu.utils.ts per determinare quale voce è attiva:

typescript
import { isSubmenuActive, normalizeId } from "./module-menu.utils";

const isActive = isDirectMatch || isSubmenuActive(item, activeSubmenuId);

Normalizzazione ID:

  • Rimuove prefissi /erp/ e /modules/
  • Converte in lowercase
  • Permette match flessibili tra diversi formati di URL

Styling

Larghezza Sheet

typescript
className="w-[280px]"  // Più stretto del mobile standard (288px)

Stati Visivi

Link Attivo:

typescript
className={cn(
    "rounded-md px-3 py-2 text-sm transition-colors",
    isActive
        ? "bg-accent text-accent-foreground font-medium"
        : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
)}

Collapsible Trigger:

typescript
className="flex w-full items-center justify-between rounded-md px-3 py-2
           text-sm font-medium transition-colors hover:bg-accent/50
           data-[state=open]:bg-accent/30"

Differenze con ModuleMenu Desktop

CaratteristicaMobile (< 1024px)Desktop (>= 1024px)
ContainerSheet laterale (drawer)Dropdown menu
TriggerButton con icona MenuButton con testo modulo
SubmenuCollapsible inlineNested dropdown
ScrollVerticale con fix touchVerticale standard
Auto-closeSì, sempreSolo su selezione
Larghezza280px fissoAuto (min-content)

Touch Scroll Fix

ModuleMenuMobile implementa un fix critico per lo scroll su dispositivi touch:

typescript
style={{ touchAction: 'pan-y' }}
onTouchMove={(e) => e.stopPropagation()}

Problema risolto:

  • Radix UI Sheet applica gestori touch sull'overlay per implementare la chiusura al click fuori
  • Questi gestori interferiscono con lo scroll interno su dispositivi touch
  • Senza il fix, gli utenti non possono scrollare il contenuto del menu su mobile

Documentazione completa: Touch Scroll Fix

Test

Pagina di test: /test-iframe nella tab del menu modulo

Test Manuale

  1. Riduci la finestra del browser < 1024px
  2. Verifica che appaia l'icona Menu invece del dropdown desktop
  3. Clicca sull'icona Menu
  4. Verifica che si apra lo Sheet da sinistra
  5. Testa lo scroll verticale su dispositivo touch o con DevTools touch emulation
  6. Clicca su una voce di menu
  7. Verifica che lo Sheet si chiuda automaticamente

Test Collapsible

  1. Apri il menu mobile
  2. Clicca su una voce con submenu
  3. Verifica che si espanda con animazione
  4. Verifica che l'icona cambi da ChevronRight a ChevronDown
  5. Clicca nuovamente per collassare
  6. Verifica che l'icona torni ChevronRight

Compatibilità

  • ✅ iOS Safari (fix touch scroll necessario)
  • ✅ Android Chrome (fix touch scroll necessario)
  • ✅ Desktop in modalità mobile (DevTools)
  • ✅ Tablet in orientamento portrait e landscape

Note Tecniche

Perché SheetRoot invece di Sheet?

Dal commit 857829e, il progetto usa SheetRoot invece di Sheet:

Motivazione:

  • Migliore gestione del mobile handling
  • Più controllo sul comportamento del drawer
  • Compatibilità migliorata con fix touch scroll

Vedi anche: Sidebar Migration Note

Key Forcing per Re-render

Il menu mobile viene forzato a re-renderizzare usando activeSubmenuId nelle key:

typescript
key={`mobile-${activeSubmenuId}-${item.link}-${index}`}

Questo previene problemi di stato stale quando l'utente naviga tra moduli.

Best Practices

DO ✅

  • Usa sempre setOpenMobile(false) al click sui link
  • Implementa il fix touch scroll per scroll container
  • Usa Collapsible per submenu invece di nested Sheet
  • Normalizza gli ID prima di confrontare active state

DON'T ❌

  • Non omettere stopPropagation() sugli eventi touch
  • Non usare nested Sheet per submenu (usa Collapsible)
  • Non dimenticare hideCloseButton={true} su SheetContent
  • Non fare scroll orizzontale (usa sempre touchAction: 'pan-y')

Risorse Correlate

Documentazione Elerama Frontend