Saltar al contenido principal

Patrones de UI

Combinaciones recurrentes de componentes que resuelven escenarios concretos en el Admin Panel.


Layout de página

Toda pantalla de admin sigue esta estructura:

┌─────────────────────────────────────────────────────────────┐
│ Sidebar (232px expand / 64px colapsado) │ Main content │
│ - Tenant switcher + toggle collapse │ ┌────────────┐ │
│ - Nav groups con iconos │ │ Header │ │
│ - Upgrade card / zap icon │ ├────────────┤ │
│ │ │ Content │ │
│ │ └────────────┘ │
└─────────────────────────────────────────────────────────────┘

El sidebar alterna entre 232 px (expandido) y 64 px (colapsado) con una transición de 180 ms. El estado se persiste en localStorage con la clave omnibuy.sidebarCollapsed.

  • Expandido: muestra tenant button + toggle panel-left-close, labels de nav, grupo headers, upgrade card completo.
  • Colapsado: solo iconos centrados, tooltips flotantes al hover, dividers en lugar de grupo headers, botón zap para upgrade, botón expand adicional encima del upgrade card.
  • Tooltips: elemento global #sb-tip controlado por Alpine.js. Invocar con showSidebarTip(el, 'Label') / hideSidebarTip() en @mouseenter/@mouseleave de cada nav item. Llamar hideSidebarTip() también en @click de los botones que colapsan/expanden para evitar tooltips fantasma cuando el elemento desaparece vía x-if.
<!-- Estado en el wrapper principal -->
<div x-data="{
sidebarOpen: false,
sidebarCollapsed: localStorage.getItem('omnibuy.sidebarCollapsed') === '1'
}"
x-init="$watch('sidebarCollapsed', v =>
localStorage.setItem('omnibuy.sidebarCollapsed', v ? '1' : '0'))">

<aside :style="'width: ' + (sidebarCollapsed ? '64px' : '232px')"
class="... overflow-hidden [transition:width_180ms_ease]">

<!-- Toggle en header del tenant -->
<button @click="sidebarCollapsed = true">
<!-- panel-left-close SVG -->
</button>

<!-- Nav item con tooltip -->
<a @mouseenter="if(sidebarCollapsed) showSidebarTip($el, 'Dashboard')"
@mouseleave="hideSidebarTip()"
:class="sidebarCollapsed ? 'justify-center px-0 py-[9px]' : 'gap-2.5 px-2.5 py-[7px]'"
class="relative flex items-center rounded-md ...">
<svg .../>
<span x-show="!sidebarCollapsed">Dashboard</span>
</a>
</aside>
</div>

<!-- Tooltip global — al final del body -->
<div id="sb-tip" x-data="{ visible: false, text: '', tipTop: 0, tipLeft: 0 }"
x-show="visible" x-cloak
:style="`position:fixed;top:${tipTop}px;left:${tipLeft}px;transform:translateY(-50%)`"
class="z-[500] px-2.5 py-1.5 bg-[#0F172A] text-white text-[12px] ...">
</div>
{{template "layouts/base" dict
"title" "Productos"
"breadcrumb" (slice "Catálogo" "Productos")
"action" (dict
"text" "Nuevo producto"
"href" "/admin/productos/nuevo"
"icon" "plus"
)
"content" .
}}

Lista con filtros

Patrón estándar para index pages (productos, órdenes, clientes):

Producto
Precio
Estado
Zapatillas Running Pro
S/ 299.00
Activo
Polo Deportivo
S/ 89.90
Borrador
Mochila Trail 30L
S/ 199.00
Activo
<!-- Filtros via HTMX — se reenvían al cambiar -->
<form hx-get="/admin/productos"
hx-target="#products-table"
hx-trigger="change, submit"
hx-push-url="true">
<input name="q" placeholder="Buscar..." />
<select name="filter[status]">
<option value="">Todos</option>
<option value="active">Activo</option>
<option value="draft">Borrador</option>
</select>
</form>

Formulario de edición

Patrón para create/edit forms:

<form hx-post="/admin/productos"
hx-target="#form-container"
hx-swap="outerHTML"
class="space-y-6">

<!-- Sección: Info básica -->
<div class="bg-white rounded-xl border border-gray-200 p-6">
<h3 class="text-base font-semibold text-gray-900 mb-4">Información básica</h3>
<div class="grid grid-cols-1 gap-4">
{{template "components/form-field" dict "label" "Título" "name" "title" ...}}
{{template "components/form-field" dict "label" "Slug" "name" "slug" ...}}
</div>
</div>

<!-- Sección: Precio -->
<div class="bg-white rounded-xl border border-gray-200 p-6">
<h3 class="text-base font-semibold text-gray-900 mb-4">Precio</h3>
<div class="grid grid-cols-2 gap-4">
{{template "components/form-field" dict "label" "Precio" "name" "price" ...}}
{{template "components/form-field" dict "label" "Precio tachado" "name" "compare_at_price" ...}}
</div>
</div>

<!-- Footer pegado -->
<div class="sticky bottom-0 bg-white border-t border-gray-200 px-6 py-4
flex items-center justify-between">
<a href="/admin/productos" class="text-sm text-gray-500 hover:text-gray-700">
Cancelar
</a>
{{template "components/button" dict "text" "Guardar" "type" "submit" "variant" "primary"}}
</div>
</form>

Confirmación destructiva

Antes de eliminar o cancelar algo irreversible:

<!-- El botón dispara el modal, el modal hace el DELETE -->
<button
@click="$dispatch('open-modal', {id: 'delete-{{.Product.ID}}'})"
class="text-red-600 hover:text-red-700 text-sm font-medium">
Eliminar
</button>

{{template "components/modal" dict
"id" (printf "delete-%s" .Product.ID)
"title" "¿Eliminar producto?"
"description" "Esta acción es permanente y no se puede deshacer."
"confirmText" "Sí, eliminar"
"confirmVariant" "danger"
"hx-delete" (printf "/admin/productos/%s" .Product.ID)
"hx-target" (printf "#row-%s" .Product.ID)
"hx-swap" "outerHTML swap:300ms"
}}

Feedback de acciones (Toast)

Flujo estándar: acción → respuesta server → toast OOB:

// Handler Go
func (h *Handler) UpdateProduct(c *gin.Context) {
// ... update lógica ...

// Renderizar la fila actualizada + toast OOB
render.HTML(c, http.StatusOK, "products/row", gin.H{
"Product": updated,
"Toast": gin.H{
"message": "Producto actualizado",
"type": "success",
},
})
}
<!-- products/row.html -->
<tr id="row-{{.Product.ID}}" ...>
...fila actualizada...
</tr>

<!-- OOB toast — se inyecta en #toast-container -->
{{if .Toast}}
{{template "components/toast" dict
"message" .Toast.message
"type" .Toast.type
}}
{{end}}

Skeleton / Loading state

Para contenido que carga via HTMX:

<!-- Indicador de carga mientras htmx hace el request -->
<div class="htmx-indicator">
{{template "components/loading" dict "text" "Cargando productos..."}}
</div>

<!-- El contenido real, oculto hasta que carga -->
<div id="products-table"
hx-get="/admin/productos"
hx-trigger="load"
hx-indicator=".htmx-indicator">
</div>

Accesibilidad

  • Todos los <input> tienen <label> asociado con for/id
  • Botones destructivos tienen aria-label descriptivo
  • Modales atrapan el foco con Alpine.js x-trap
  • Colores de texto cumplen WCAG 2.1 AA (ratio mínimo 4.5:1)
  • Tablas tienen <thead> con scope="col" en cada <th>