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 │ │
│ │ └────────────┘ │
└─────────────────────────────────────────────────────────────┘
Sidebar colapsable
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-tipcontrolado por Alpine.js. Invocar conshowSidebarTip(el, 'Label')/hideSidebarTip()en@mouseenter/@mouseleavede cada nav item. LlamarhideSidebarTip()también en@clickde los botones que colapsan/expanden para evitar tooltips fantasma cuando el elemento desaparece víax-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):
<!-- 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 confor/id - Botones destructivos tienen
aria-labeldescriptivo - 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>conscope="col"en cada<th>