Merge pull request 'fix/sidebar-cotraction-bugs' (#112) from fix/sidebar-cotraction-bugs into feature/nav-tab-client-provider

Reviewed-on: ADUANASOFT/anexo76#112
This commit is contained in:
2026-02-09 16:43:16 +00:00
14 changed files with 181 additions and 50 deletions

View File

@@ -1,7 +1,9 @@
<script lang="ts">
import * as Collapsible from "$lib/components/ui/collapsible/index.js";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { useSidebar } from "$lib/components/ui/sidebar/context.svelte.js";
import ChevronRight from "@lucide/svelte/icons/chevron-right";
let {
items,
@@ -9,8 +11,6 @@
items: {
title: string;
url: string;
// this should be `Component` after @lucide/svelte updates types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon?: any;
isActive?: boolean;
items?: {
@@ -19,6 +19,61 @@
}[];
}[];
} = $props();
const sidebar = useSidebar();
// Estado del Sistema Híbrido controlado por hover estricto (sin timers)
let activeTitle = $state<string | null>(null);
function shouldKeepOpen(event: PointerEvent, title: string) {
const next = event.relatedTarget as Node | null;
if (!next) return false;
const trigger = document.getElementById(`trigger-${title}`);
const content = document.getElementById(`content-${title}`);
// Mantenemos abierto si el puntero se mueve hacia el trigger o el contenido del menú
return (trigger && trigger.contains(next)) || (content && content.contains(next));
}
function handleTriggerEnter(title: string) {
if (sidebar.state !== "collapsed") return;
activeTitle = title;
}
function handleTriggerLeave(event: PointerEvent, title: string) {
if (sidebar.state !== "collapsed") return;
// Si nos movemos al contenido (o nos quedamos en el trigger), no cerramos
if (shouldKeepOpen(event, title)) return;
activeTitle = null;
}
function handleContentEnter(title: string) {
if (sidebar.state !== "collapsed") return;
activeTitle = title;
}
function handleContentLeave(event: PointerEvent, title: string) {
if (sidebar.state !== "collapsed") return;
// Si nos movemos de vuelta al trigger (o dentro del contenido), no cerramos
if (shouldKeepOpen(event, title)) return;
activeTitle = null;
}
function onOpenChange(open: boolean, title: string) {
// Sincronización base
if (open) {
activeTitle = title;
} else {
if (activeTitle === title) {
activeTitle = null;
}
}
}
</script>
<Sidebar.Group>
@@ -26,43 +81,118 @@
<Sidebar.Menu>
{#each items as item (item.title)}
{#if item.items && item.items.length > 0}
<!-- Dropdown para items con subitems -->
<Collapsible.Root open={item.isActive} class="group/collapsible">
{#snippet child({ props })}
<Sidebar.MenuItem {...props}>
<Collapsible.Trigger>
{#if sidebar.state === "collapsed"}
<!-- Sidebar Colapsado: Dropdown controlado por eventos estrictos -->
<Sidebar.MenuItem>
<DropdownMenu.Root
open={activeTitle === item.title}
onOpenChange={(v) => onOpenChange(v, item.title)}
modal={false}
>
<DropdownMenu.Trigger asChild>
{#snippet child({ props })}
<Sidebar.MenuButton {...props} tooltipContent={item.title}>
{#if item.icon}
<item.icon />
{/if}
<span>{item.title}</span>
<ChevronRightIcon
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</Sidebar.MenuButton>
<div
id={`trigger-${item.title}`}
class="relative z-30 w-full flex justify-center"
onpointerenter={() => handleTriggerEnter(item.title)}
onpointerleave={(e) => handleTriggerLeave(e, item.title)}
>
<Sidebar.MenuButton {...props} tooltipContent={null} class="justify-center">
{#if item.icon}
<item.icon />
{:else}
<div class="size-4" />
{/if}
<!-- Ocultamos el texto en modo colapsado para asegurar que solo sea el icono -->
<span class="sr-only">{item.title}</span>
</Sidebar.MenuButton>
</div>
{/snippet}
</Collapsible.Trigger>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each item.items as subItem (subItem.title)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton>
{#snippet child({ props })}
<a href={subItem.url} {...props}>
<span>{subItem.title}</span>
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Sidebar.MenuItem>
{/snippet}
</Collapsible.Root>
</DropdownMenu.Trigger>
<DropdownMenu.Content
side="right"
align="start"
sideOffset={0}
class="z-50 w-64 rounded-lg p-0 shadow-lg overflow-visible"
id={`content-${item.title}`}
onpointerenter={() => handleContentEnter(item.title)}
onpointerleave={(e) => handleContentLeave(e, item.title)}
>
<!--
Header "Tab" (Icono Flotante)
Posicionado con right-full para estar exactamente donde el trigger termina (offset 0).
Usamos w-8 h-8 para coincidir con un botón de tamaño estándar de sidebar.
-->
<div class="absolute right-full top-0 flex items-center justify-center bg-sidebar-accent text-sidebar-accent-foreground rounded-l-lg w-8 h-8 shadow-none border border-sidebar-border border-r-0 z-50">
{#if item.icon}
<item.icon class="size-4 shrink-0" />
{:else}
<div class="size-4 shrink-0" />
{/if}
</div>
<!--
Panel Principal
-->
<div class="w-full h-full p-1 pointer-events-auto bg-popover rounded-lg rounded-tl-none border border-sidebar-border ml-[0px]">
<!-- Título en el panel principal -->
<div class="px-2 py-2 text-sm font-medium border-b text-sidebar-foreground truncate">
{item.title}
</div>
<DropdownMenu.DropdownMenuGroup class="mt-1 max-h-80 overflow-y-auto">
{#each item.items as subItem (subItem.title)}
<DropdownMenu.DropdownMenuItem asChild>
<a href={subItem.url} class="flex w-full items-center gap-2 overflow-hidden">
<span class="truncate">{subItem.title}</span>
</a>
</DropdownMenu.DropdownMenuItem>
{/each}
</DropdownMenu.DropdownMenuGroup>
</div>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
{:else}
<!-- Sidebar Expandido: Collapsible original -->
<Collapsible.Root open={item.isActive} class="group/collapsible">
{#snippet child({ props })}
<Sidebar.MenuItem {...props}>
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton {...props} tooltipContent={item.title}>
{#if item.icon}
<item.icon />
{/if}
<span>{item.title}</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</Sidebar.MenuButton>
{/snippet}
</Collapsible.Trigger>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each item.items as subItem (subItem.title)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton>
{#snippet child({ props })}
<a href={subItem.url} {...props}>
<span>{subItem.title}</span>
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Sidebar.MenuItem>
{/snippet}
</Collapsible.Root>
{/if}
{:else}
<!-- Botón simple para items sin subitems -->
<!-- Items sin submenú -->
<Sidebar.MenuItem>
<Sidebar.MenuButton tooltipContent={item.title}>
{#snippet child({ props })}
@@ -79,3 +209,4 @@
{/each}
</Sidebar.Menu>
</Sidebar.Group>

View File

@@ -80,7 +80,7 @@
<div
data-slot="sidebar-container"
class={cn(
"w-(--sidebar-width) fixed inset-y-0 z-10 hidden h-svh transition-[left,right,width] duration-200 ease-linear md:flex",
"w-(--sidebar-width) fixed inset-y-0 z-30 hidden h-svh transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",

View File

@@ -23,7 +23,7 @@
'Lista Códigos',
obtenerAtajosLista({
manejarNuevo: () => (showCreateDialog = true),
manejarActualizar: handleRefresh
manejarActualizar: reloadData
})
);

View File

@@ -23,7 +23,7 @@
'Lista Contenedores',
obtenerAtajosLista({
manejarNuevo: () => (showCreateDialog = true),
manejarActualizar: handleRefresh
manejarActualizar: reloadData
})
);

View File

@@ -22,7 +22,7 @@
'Lista Países',
obtenerAtajosLista({
manejarNuevo: () => (showCreateDialog = true),
manejarActualizar: handleRefresh
manejarActualizar: reloadData
})
);

View File

@@ -22,7 +22,7 @@
'Lista Tipos de Moneda',
obtenerAtajosLista({
manejarNuevo: () => (showCreateDialog = true),
manejarActualizar: handleRefresh
manejarActualizar: reloadData
})
);

View File

@@ -22,7 +22,7 @@
'Lista Almacenes Aduanales',
obtenerAtajosLista({
manejarNuevo: () => (showCreateDialog = true),
manejarActualizar: handleRefresh
manejarActualizar: reloadData
})
);

View File

@@ -22,7 +22,7 @@
'Lista Tipos de Factura',
obtenerAtajosLista({
manejarNuevo: () => (showCreateDialog = true),
manejarActualizar: handleRefresh
manejarActualizar: reloadData
})
);

View File

@@ -23,7 +23,7 @@
'Lista Tipos de Material',
obtenerAtajosLista({
manejarNuevo: () => (showCreateDialog = true),
manejarActualizar: handleRefresh
manejarActualizar: reloadData
})
);

View File

@@ -23,7 +23,7 @@
'Lista Sectores',
obtenerAtajosLista({
manejarNuevo: () => (showCreateDialog = true),
manejarActualizar: handleRefresh
manejarActualizar: reloadData
})
);

View File

@@ -23,7 +23,7 @@
'Lista Estados',
obtenerAtajosLista({
manejarNuevo: () => (showCreateDialog = true),
manejarActualizar: handleRefresh
manejarActualizar: reloadData
})
);

View File

@@ -23,7 +23,7 @@
'Lista Modos de Transporte',
obtenerAtajosLista({
manejarNuevo: () => (showCreateDialog = true),
manejarActualizar: handleRefresh
manejarActualizar: reloadData
})
);

View File

@@ -23,7 +23,7 @@
'Lista Tipos de Transporte',
obtenerAtajosLista({
manejarNuevo: () => (showCreateDialog = true),
manejarActualizar: handleRefresh
manejarActualizar: reloadData
})
);

View File

@@ -22,7 +22,7 @@
'Lista Métodos de Valoración',
obtenerAtajosLista({
manejarNuevo: () => (showCreateDialog = true),
manejarActualizar: handleRefresh
manejarActualizar: reloadData
})
);