Moodinary/UI

Navegación (context bar + tabs + sidebar)

La estructura de toda la app. Tabs para secciones, sidebar solo dentro de Ajustes.

Así está armado el panel: barra de contexto (wordmark, selector de organización, búsqueda, QR, notificaciones, reportar un problema y cuenta), tabs de sección con ícono y navegación lateral agrupada dentro de Ajustes. Todo en el azul de Moodinary; el color de la organización solo aparece en el formulario público.

Instalación

shadcn
npx shadcn@latest add https://design.moodinary.com/r/app-shell.json
import
import { AppShell, AppHeader, AppSheet, ContextBar, ContextSep, contextButtonClass, contextIconClass, NavTabs, navTabClass, SideLayout, SideNav, SideNavGroup, sideNavLinkClass } from "@moodinary/ui/components/app-shell";

Ejemplos

Las secciones son links con navTabClass dentro de NavTabs (no el componente Tabs). Ajustes abre SideNav con tres grupos: Organización, Desarrolladores y Registro. Marcá la página actual con aria-current="page".

Organización y datos ilustrativos.

Moodinary

Equipos

Ajustes de la organización de ejemplo.

Código
structure.tsx
import {
	AppHeader,
	AppSheet,
	AppShell,
	ContextBar,
	ContextSep,
	contextButtonClass,
	contextIconClass,
	NavTabs,
	navTabClass,
	SideLayout,
	SideNav,
	SideNavGroup,
	sideNavLinkClass,
} from "@moodinary/ui/components/app-shell";
import { Button } from "@moodinary/ui/components/button";
import { CommandTrigger } from "@moodinary/ui/components/command";
import {
	BalanceScaleIcon,
	BellIcon,
	Blocks,
	BugIcon,
	CalendarDays,
	CheckCircle,
	CodeIcon,
	CreditCard,
	HeartPulseIcon,
	House,
	type IconComponent,
	PaintBoardIcon,
	QrCodeIcon,
	ScrollText,
	Settings,
	TrendingUp,
	TriangleAlertIcon,
	Users,
	Wrench,
} from "@moodinary/ui/components/icons";
import { BRAND_ASSETS } from "@moodinary/ui/lib/brand-assets";
import { cn } from "@moodinary/ui/lib/utils";
import { useState } from "react";

const tabs: [string, string, IconComponent][] = [
	["inicio", "Inicio", House],
	["pulsos", "Pulsos", HeartPulseIcon],
	["acciones", "Acciones", CheckCircle],
	["analiticas", "Analíticas", TrendingUp],
	["calendario", "Calendario", CalendarDays],
	["alertas", "Alertas", TriangleAlertIcon],
	["ajustes", "Ajustes", Settings],
];

const settings: [string, [string, string, IconComponent][]][] = [
	[
		"Organización",
		[
			["equipos", "Equipos", Users],
			["personalizacion", "Personalización", PaintBoardIcon],
			["facturacion", "Facturación", CreditCard],
			["comparativa", "Comparativa", BalanceScaleIcon],
			["configuracion", "Configuración", Wrench],
		],
	],
	[
		"Desarrolladores",
		[
			["api", "API", CodeIcon],
			["conectores", "Conectores", Blocks],
		],
	],
	["Registro", [["auditoria", "Auditoría", ScrollText]]],
];

export default function NavigationStructure() {
	const [tab, setTab] = useState("ajustes");
	const [page, setPage] = useState("equipos");
	const [open, setOpen] = useState(false);
	const label = settings
		.flatMap(([, items]) => items)
		.find(([seg]) => seg === page)?.[1];
	return (
		<div className="w-full">
			<p className="mb-3 text-ink-3 text-xs">
				Organización y datos ilustrativos.
			</p>
			<AppShell className="min-h-0 overflow-hidden rounded-[10px] border border-line">
				<AppHeader className="static">
					<ContextBar>
						<img
							alt="Moodinary"
							className="block h-5 w-auto"
							height={20}
							src={BRAND_ASSETS.wordmark}
							width={71}
						/>
						<ContextSep />
						<button
							aria-label="Organización: Organización de ejemplo. Cambiar"
							className={contextButtonClass}
							type="button"
						>
							<span className="grid size-6 shrink-0 place-items-center rounded-md bg-primary font-semibold text-[11px] text-primary-foreground">
								OE
							</span>
							<span className="max-w-[45vw] truncate font-medium md:max-w-[40vw]">
								Organización de ejemplo
							</span>
							<svg
								aria-hidden
								className="shrink-0 text-ink-3"
								fill="none"
								height="14"
								stroke="currentColor"
								strokeWidth="1.5"
								viewBox="0 0 16 16"
								width="14"
							>
								<path d="M5 6l3-3 3 3M5 10l3 3 3-3" />
							</svg>
						</button>
						<span className="flex-1" />
						<CommandTrigger className="mr-1.5" />
						<Button
							className="mr-1.5 max-md:hidden"
							size="sm"
							variant="secondary"
						>
							<QrCodeIcon aria-hidden className="size-4" />
							Generar QR
						</Button>
						<button
							aria-label="Generar QR"
							className={cn(contextIconClass, "md:hidden")}
							type="button"
						>
							<QrCodeIcon aria-hidden className="size-4" />
						</button>
						<a
							aria-label="Notificaciones"
							className={contextIconClass}
							href="#notificaciones"
						>
							<BellIcon aria-hidden className="size-4" />
						</a>
						<button
							aria-label="Reportar un problema"
							className={contextIconClass}
							title="Reportar un problema"
							type="button"
						>
							<BugIcon aria-hidden className="size-4" />
						</button>
						<button
							aria-label="Cuenta"
							className="grid size-7 shrink-0 place-items-center rounded-full bg-primary font-semibold text-[11px] text-primary-foreground"
							type="button"
						>
							MN
						</button>
					</ContextBar>
					<NavTabs aria-label="Secciones">
						{tabs.map(([seg, name, Icon]) => (
							<a
								aria-current={tab === seg ? "page" : undefined}
								className={navTabClass(tab === seg)}
								href={`#${seg}`}
								key={seg}
								onClick={(event) => {
									event.preventDefault();
									setTab(seg);
								}}
							>
								<Icon aria-hidden />
								{name}
							</a>
						))}
					</NavTabs>
				</AppHeader>
				<AppSheet>
					{tab === "ajustes" ? (
						<SideLayout className="p-4">
							<SideNav aria-label="Ajustes" className="static">
								{settings.map(([group, items]) => (
									<SideNavGroup key={group} label={group}>
										{items.map(([seg, name, Icon]) => (
											<a
												aria-current={page === seg ? "page" : undefined}
												className={sideNavLinkClass(page === seg)}
												href={`#${seg}`}
												key={seg}
												onClick={(event) => {
													event.preventDefault();
													setPage(seg);
													setOpen(false);
												}}
											>
												<Icon aria-hidden />
												{name}
											</a>
										))}
									</SideNavGroup>
								))}
							</SideNav>
							<section aria-label={label} className="min-w-0">
								<button
									aria-expanded={open}
									className="mb-3 flex h-9 w-max items-center gap-2 rounded-lg border border-line bg-white px-2.5 font-medium shadow-card md:hidden"
									onClick={() => setOpen((o) => !o)}
									type="button"
								>
									<svg
										aria-hidden
										fill="none"
										height="14"
										stroke="currentColor"
										strokeWidth="1.6"
										viewBox="0 0 16 16"
										width="14"
									>
										<path d="M2.5 4h11M2.5 8h11M2.5 12h11" />
									</svg>
									Ajustes · {label}
									<svg
										aria-hidden
										className="text-ink-3"
										fill="none"
										height="12"
										stroke="currentColor"
										strokeWidth="1.6"
										viewBox="0 0 16 16"
										width="12"
									>
										<path d="M4 6l4 4 4-4" />
									</svg>
								</button>
								{open ? (
									<nav
										aria-label="Ajustes"
										className="mb-4 rounded-[10px] border border-line bg-white p-1.5 shadow-pop md:hidden [&>div+div]:pt-2"
									>
										{settings.map(([group, items]) => (
											<SideNavGroup key={group} label={group}>
												{items.map(([seg, name, Icon]) => (
													<a
														aria-current={page === seg ? "page" : undefined}
														className={sideNavLinkClass(page === seg)}
														href={`#${seg}`}
														key={seg}
														onClick={(event) => {
															event.preventDefault();
															setPage(seg);
															setOpen(false);
														}}
													>
														<Icon aria-hidden />
														{name}
													</a>
												))}
											</SideNavGroup>
										))}
									</nav>
								) : null}
								<h3 className="font-semibold text-lg">{label}</h3>
								<p className="mt-1 text-ink-2">
									Ajustes de la organización de ejemplo.
								</p>
							</section>
						</SideLayout>
					) : (
						<div className="p-5 text-ink-2">
							{tabs.find(([seg]) => seg === tab)?.[1]} de la organización de
							ejemplo.
						</div>
					)}
				</AppSheet>
			</AppShell>
		</div>
	);
}

Props

SideNavGroup

PropTipoDefaultDescripción
labelReactNode-Título del grupo.
childrenReactNode-Links del grupo.

sideNavLinkClass

PropTipoDefaultDescripción
activeboolean-Devuelve las clases del link activo o inactivo.

navTabClass

PropTipoDefaultDescripción
activeboolean-Clases del link de sección; activo suma subrayado y texto en tinta.

contextButtonClass, contextIconClass

PropTipoDefaultDescripción
(string)string-Clases para el selector de organización y para los íconos cuadrados de la barra (campana, reportar).

AppShell, AppHeader, AppSheet, ContextBar, ContextSep, NavTabs, SideLayout, SideNav

PropTipoDefaultDescripción
...propsComponentProps del elemento-Contenedores con estilos de layout.
classNamestring-Clases extra.

Cuándo sí y cuándo no

Hacé
  • Mostrá la organización actual en la barra de contexto.
  • Usá SideNav solo para las subpáginas de Ajustes.
  • Mantené el orden de las secciones en todo el producto.
Evitá
  • No uses el componente Tabs para las secciones: son links.
  • No pongas el plan en el encabezado de página.
  • No uses SideNav para acciones.

Accesibilidad

  • NavTabs y SideNav son nav con aria-label (Secciones, Ajustes).
  • Los botones de ícono llevan aria-label: Notificaciones, Reportar un problema, Cuenta.
  • El link actual lleva aria-current="page".