Listado
BetaBuscar, filtrar y elegir entre muchos ítems: lista o grilla, orden, paginación, estado vacío y detalle en un Drawer.
registry/examples/templates/list.tsx"use client";
import { useState } from "react";
import { Avatar } from "@/registry/ds/avatar";
import { Chip } from "@/registry/ds/badge";
import { Breadcrumb, BreadcrumbItem } from "@/registry/ds/breadcrumb";
import { Button } from "@/registry/ds/button";
import { Card, CardBody, CardDescription, CardFooter, CardTitle } from "@/registry/ds/card";
import { Checkbox } from "@/registry/ds/checkbox";
import { Drawer, DrawerBody, DrawerClose, DrawerFooter, DrawerHeader } from "@/registry/ds/drawer";
import { EmptyState } from "@/registry/ds/empty-state";
import { BarChart, Bell, BookOpen, Calendar, FileText, Home, LayoutGrid, LogOut, Menu as MenuIcon, Plus, Search, Settings, User } from "@/registry/ds/icons";
import { Input } from "@/registry/ds/input";
import { Menu, MenuItem, MenuSeparator } from "@/registry/ds/menu";
import { Navbar, NavbarActions, NavbarBrand, NavbarMobile } from "@/registry/ds/navbar";
import { Pagination } from "@/registry/ds/pagination";
import { Popover } from "@/registry/ds/popover";
import { Select } from "@/registry/ds/select";
import { Sidebar, SidebarGroup, SidebarItem, SidebarNav } from "@/registry/ds/sidebar";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/registry/ds/table";
import { ToastProvider, useToast } from "@/registry/ds/toast";
import { ToggleGroup, ToggleItem } from "@/registry/ds/toggle-group";
import { Tooltip, TooltipProvider } from "@/registry/ds/tooltip";
const Logo = () => (
<svg viewBox="0 0 24 24" aria-hidden>
<rect width="24" height="24" rx="6" fill="var(--primary)" />
<path d="M7 12.5l3.5 3.5L17 9" stroke="var(--primary-fg)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
);
interface Materia {
codigo: string;
nombre: string;
carrera: string;
turno: "Mañana" | "Tarde" | "Noche";
docente: string;
cupo: number;
creditos: number;
modalidad: "Presencial" | "Virtual" | "Mixta";
descripcion: string;
}
const materias: Materia[] = [
{ codigo: "MAT201", nombre: "Cálculo II", carrera: "Ingeniería", turno: "Mañana", docente: "Julia Arce", cupo: 12, creditos: 8, modalidad: "Presencial", descripcion: "Integrales múltiples, series y ecuaciones diferenciales ordinarias." },
{ codigo: "FIS101", nombre: "Física I", carrera: "Ingeniería", turno: "Tarde", docente: "Martín Ferro", cupo: 3, creditos: 8, modalidad: "Presencial", descripcion: "Cinemática, dinámica y energía con laboratorio semanal." },
{ codigo: "PRG102", nombre: "Programación II", carrera: "Sistemas", turno: "Mañana", docente: "Lucía Paz", cupo: 0, creditos: 6, modalidad: "Mixta", descripcion: "Estructuras de datos, recursión y una introducción a la programación orientada a objetos." },
{ codigo: "ALG101", nombre: "Álgebra", carrera: "Ingeniería", turno: "Noche", docente: "Pedro Núñez", cupo: 24, creditos: 6, modalidad: "Presencial", descripcion: "Matrices, espacios vectoriales y transformaciones lineales." },
{ codigo: "ING201", nombre: "Inglés técnico", carrera: "Todas", turno: "Noche", docente: "Ana Rey", cupo: 31, creditos: 4, modalidad: "Virtual", descripcion: "Lectura y escritura de textos técnicos en inglés." },
{ codigo: "BDD201", nombre: "Bases de datos", carrera: "Sistemas", turno: "Tarde", docente: "Sofía Lema", cupo: 5, creditos: 6, modalidad: "Mixta", descripcion: "Modelo relacional, SQL y diseño de esquemas." },
{ codigo: "EST101", nombre: "Estadística", carrera: "Economía", turno: "Mañana", docente: "Diego Sosa", cupo: 18, creditos: 6, modalidad: "Presencial", descripcion: "Probabilidad, distribuciones e inferencia." },
{ codigo: "ECO101", nombre: "Microeconomía", carrera: "Economía", turno: "Noche", docente: "Carla Paz", cupo: 2, creditos: 6, modalidad: "Presencial", descripcion: "Oferta, demanda, elasticidad y estructuras de mercado." },
{ codigo: "RED301", nombre: "Redes", carrera: "Sistemas", turno: "Noche", docente: "Tomás Gil", cupo: 9, creditos: 6, modalidad: "Virtual", descripcion: "Modelo TCP/IP, enrutamiento y seguridad básica." },
{ codigo: "QUI101", nombre: "Química general", carrera: "Ingeniería", turno: "Tarde", docente: "Marta Ruiz", cupo: 0, creditos: 6, modalidad: "Presencial", descripcion: "Estructura atómica, enlaces y estequiometría." },
{ codigo: "SOC101", nombre: "Sociología", carrera: "Todas", turno: "Mañana", docente: "Nora Vidal", cupo: 40, creditos: 4, modalidad: "Virtual", descripcion: "Teorías clásicas y contemporáneas de la sociedad." },
{ codigo: "IA401", nombre: "Inteligencia artificial", carrera: "Sistemas", turno: "Tarde", docente: "Lucía Paz", cupo: 7, creditos: 8, modalidad: "Mixta", descripcion: "Búsqueda, aprendizaje automático y ética de los sistemas inteligentes." },
];
const carreras = ["Ingeniería", "Sistemas", "Economía"].map((c) => ({ label: c, value: c }));
const turnos = ["Mañana", "Tarde", "Noche"].map((t) => ({ label: t, value: t }));
const porPagina = 6;
const estadoDe = (cupo: number) => (cupo === 0 ? { status: "danger" as const, label: "Completa" } : cupo <= 5 ? { status: "warning" as const, label: "Últimos lugares" } : { status: "success" as const, label: "Con cupo" });
const nav = (
<>
<SidebarItem href="#" icon={<Home />}>
Inicio
</SidebarItem>
<SidebarGroup label="Académico">
<SidebarItem href="#" icon={<BookOpen />} active>
Materias
</SidebarItem>
<SidebarItem href="#" icon={<Calendar />}>
Calendario
</SidebarItem>
<SidebarItem href="#" icon={<BarChart />}>
Notas
</SidebarItem>
<SidebarItem href="#" icon={<FileText />}>
Certificados
</SidebarItem>
</SidebarGroup>
<SidebarGroup label="Cuenta">
<SidebarItem href="#" icon={<Settings />}>
Configuración
</SidebarItem>
</SidebarGroup>
</>
);
function ListPage() {
const toast = useToast();
const [query, setQuery] = useState("");
const [carrera, setCarrera] = useState<string | null>(null);
const [turno, setTurno] = useState<string | null>(null);
const [soloCupo, setSoloCupo] = useState(false);
const [soloVirtual, setSoloVirtual] = useState(false);
const [view, setView] = useState<"lista" | "grilla">("lista");
const [sort, setSort] = useState<"asc" | "desc" | "none">("none");
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<Materia | null>(null);
const q = query.trim().toLowerCase();
const filtered = materias
.filter((m) => !q || `${m.nombre} ${m.codigo} ${m.docente}`.toLowerCase().includes(q))
.filter((m) => !carrera || m.carrera === carrera || m.carrera === "Todas")
.filter((m) => !turno || m.turno === turno)
.filter((m) => !soloCupo || m.cupo > 0)
.filter((m) => !soloVirtual || m.modalidad !== "Presencial")
.sort((a, b) => (sort === "none" ? 0 : sort === "asc" ? a.cupo - b.cupo : b.cupo - a.cupo));
const pages = Math.max(1, Math.ceil(filtered.length / porPagina));
const current = Math.min(page, pages);
const visible = filtered.slice((current - 1) * porPagina, current * porPagina);
const filtersActive = Boolean(q || carrera || turno || soloCupo || soloVirtual);
const reset = () => {
setQuery("");
setCarrera(null);
setTurno(null);
setSoloCupo(false);
setSoloVirtual(false);
setPage(1);
};
const inscribir = (m: Materia) => {
setSelected(null);
toast({ color: "success", title: `Te inscribiste a ${m.nombre}`, description: "Te enviamos la confirmación por correo." });
};
return (
<div className="flex min-h-dvh flex-col bg-bg-subtle">
<Navbar collapse="md" width="full">
<NavbarBrand href="#">
<Logo />
Campus
</NavbarBrand>
<NavbarActions>
<Tooltip content="Notificaciones">
<Button variant="text" size="sm" iconOnly={<Bell />} aria-label="Notificaciones" />
</Tooltip>
<Menu
align="end"
trigger={
<button type="button" aria-label="Cuenta de Julia Arce" className="rounded-full focus-visible:shadow-[0_0_0_2px_var(--focus-ring)] focus-visible:outline-none">
<Avatar name="Julia Arce" size="sm" />
</button>
}
>
<MenuItem icon={<User />}>Mi perfil</MenuItem>
<MenuItem icon={<Settings />} href="#">
Configuración
</MenuItem>
<MenuSeparator />
<MenuItem icon={<LogOut />} destructive>
Cerrar sesión
</MenuItem>
</Menu>
<NavbarMobile>{nav}</NavbarMobile>
</NavbarActions>
</Navbar>
<div className="flex flex-1">
<Sidebar collapsible className="max-md:hidden sticky top-14 h-[calc(100dvh-3.5rem)] self-start">
<SidebarNav>{nav}</SidebarNav>
</Sidebar>
<main className="min-w-0 flex-1">
<div className="mx-auto w-full max-w-6xl space-y-6 p-4 sm:p-6 lg:p-8">
<Breadcrumb size="sm">
<BreadcrumbItem href="#" separator={false} icon={<Home />}>
Inicio
</BreadcrumbItem>
<BreadcrumbItem current>Materias</BreadcrumbItem>
</Breadcrumb>
<header className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-heading-sm font-semibold text-fg">Materias</h1>
<p className="mt-1 text-sm text-fg-muted">Oferta del segundo cuatrimestre 2026. Inscripciones abiertas hasta el 30 de octubre.</p>
</div>
<Button iconLeft={<Plus />}>Proponer materia</Button>
</header>
<section aria-label="Filtros" className="flex flex-col gap-3 md:flex-row md:flex-wrap md:items-end">
<div className="flex-1 md:min-w-64">
<Input
size="md"
aria-label="Buscar materias"
placeholder="Buscar por nombre, código o docente"
iconLeft={<Search />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
}}
/>
</div>
<Select aria-label="Carrera" placeholder="Carrera" options={carreras} value={carrera} onValueChange={(v) => { setCarrera(v); setPage(1); }} className="md:w-44" />
<Select aria-label="Turno" placeholder="Turno" options={turnos} value={turno} onValueChange={(v) => { setTurno(v); setPage(1); }} className="md:w-36" />
<Popover trigger={<Button variant="stroke">Más filtros</Button>} title="Más filtros" align="end" closable>
<div className="flex flex-col gap-3">
<Checkbox size="sm" label="Solo con cupo" checked={soloCupo} onCheckedChange={(c) => { setSoloCupo(Boolean(c)); setPage(1); }} />
<Checkbox size="sm" label="Virtual o mixta" checked={soloVirtual} onCheckedChange={(c) => { setSoloVirtual(Boolean(c)); setPage(1); }} />
</div>
</Popover>
<ToggleGroup aria-label="Vista" size="md" className="self-start md:self-auto" value={[view]} onValueChange={(v) => v[0] && setView(v[0] as "lista" | "grilla")}>
<ToggleItem value="lista" icon={<MenuIcon />} aria-label="Lista" />
<ToggleItem value="grilla" icon={<LayoutGrid />} aria-label="Grilla" />
</ToggleGroup>
</section>
<p className="text-sm text-fg-muted" aria-live="polite">
{filtered.length === 1 ? "1 materia" : `${filtered.length} materias`}
{filtersActive && (
<>
{" · "}
<button type="button" onClick={reset} className="font-medium text-primary-accent underline-offset-4 hover:underline">
Quitar filtros
</button>
</>
)}
</p>
{filtered.length === 0 ? (
<div className="rounded-2xl border border-border bg-bg">
<EmptyState
icon={<Search />}
title="Ninguna materia coincide"
description="Probá con otra palabra, o quitá los filtros de carrera y turno."
actions={<Button variant="stroke" onClick={reset}>Quitar filtros</Button>}
/>
</div>
) : view === "lista" ? (
<div>
<Table>
<TableHead>
<TableRow>
<TableHeader>Materia</TableHeader>
<TableHeader className="max-lg:hidden">Docente</TableHeader>
<TableHeader className="max-sm:hidden">Turno</TableHeader>
<TableHeader align="right" className="max-sm:hidden" sort={sort} onSort={() => setSort(sort === "asc" ? "desc" : "asc")}>
Cupo
</TableHeader>
<TableHeader>Estado</TableHeader>
<TableHeader>
<span className="sr-only">Acciones</span>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{visible.map((m) => {
const estado = estadoDe(m.cupo);
return (
<TableRow key={m.codigo}>
<TableCell>
<span className="block font-medium text-fg">{m.nombre}</span>
<span className="block text-xs text-fg-muted">
{m.codigo} · {m.carrera}
</span>
</TableCell>
<TableCell className="text-fg-muted max-lg:hidden">{m.docente}</TableCell>
<TableCell className="text-fg-muted max-sm:hidden">{m.turno}</TableCell>
<TableCell numeric className="max-sm:hidden">{m.cupo}</TableCell>
<TableCell>
<Chip size="sm" status={estado.status}>
{estado.label}
</Chip>
</TableCell>
<TableCell align="right">
<Button variant="text" size="sm" onClick={() => setSelected(m)} aria-label={`Ver ${m.nombre}`}>
Ver
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
) : (
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3" aria-label="Materias">
{visible.map((m) => {
const estado = estadoDe(m.cupo);
return (
<li key={m.codigo} className="flex">
<Card className="flex-1">
<CardBody>
<div>
<Chip size="sm" status={estado.status}>
{estado.label}
</Chip>
</div>
<CardTitle as="h2">{m.nombre}</CardTitle>
<CardDescription>
{m.docente} · {m.turno} · {m.modalidad}
</CardDescription>
</CardBody>
<CardFooter>
<span className="text-xs text-fg-muted">
{m.codigo} · {m.creditos} créditos
</span>
<Button variant="tonal" size="sm" onClick={() => setSelected(m)}>
Ver
</Button>
</CardFooter>
</Card>
</li>
);
})}
</ul>
)}
{filtered.length > porPagina && (
<div className="flex justify-center">
<Pagination page={current} count={pages} onPageChange={setPage} />
</div>
)}
</div>
</main>
</div>
<Drawer open={selected !== null} onOpenChange={(open) => !open && setSelected(null)} size="sm">
{selected && (
<>
<DrawerHeader title={selected.nombre} description={`${selected.codigo} · ${selected.docente}`} />
<DrawerBody className="space-y-5">
<p className="text-sm text-fg">{selected.descripcion}</p>
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
{[
["Carrera", selected.carrera],
["Turno", selected.turno],
["Modalidad", selected.modalidad],
["Créditos", String(selected.creditos)],
].map(([k, v]) => (
<div key={k}>
<dt className="text-xs text-fg-subtle">{k}</dt>
<dd className="font-medium text-fg">{v}</dd>
</div>
))}
<div>
<dt className="text-xs text-fg-subtle">Cupo</dt>
<dd className="mt-0.5">
<Chip size="sm" status={estadoDe(selected.cupo).status}>
{selected.cupo === 0 ? "Completa" : `${selected.cupo} lugares`}
</Chip>
</dd>
</div>
</dl>
</DrawerBody>
<DrawerFooter>
<DrawerClose>
<Button variant="stroke">Cerrar</Button>
</DrawerClose>
<Button disabled={selected.cupo === 0} onClick={() => inscribir(selected)}>
Inscribirme
</Button>
</DrawerFooter>
</>
)}
</Drawer>
</div>
);
}
/**
* Template List — Listado con búsqueda, filtros, vista lista/grilla, orden, paginación, estado vacío y detalle en un Drawer.
*/
export default function List() {
return (
<TooltipProvider>
<ToastProvider>
<ListPage />
</ToastProvider>
</TooltipProvider>
);
}Para probarlo: buscá "zzz" para ver el vacío, filtrá por turno Noche, cambiá a grilla, ordená por cupo y abrí una materia con "Ver".
Qué incluye
- Barra de filtros: Input de búsqueda con icono, dos Select, un Popover con Checkbox para los filtros secundarios y un Toggle group de iconos para la vista.
- Table con orden en la columna Cupo, o grilla de Card; en las dos, el estado es un Chip.
- Pagination numerada, Empty state con "Quitar filtros", Drawer controlado para el detalle y Toast al inscribirse.
Decisiones
- Cualquier filtro vuelve a la página 1. Si no, el usuario queda en una página que ya no existe.
- El conteo tiene
aria-live="polite": al filtrar, el lector de pantalla escucha "4 materias" sin que haya que mover el foco. - "Ver" es un botón, no la fila entera. Una fila clickeable no se anuncia,
no se tabula y choca con el botón de ordenar. El botón lleva
aria-labelcon el nombre de la materia. - El Drawer es controlado (
open+onOpenChange) y se cierra al inscribirse; la confirmación va en un Toast para no tapar el listado. - Inscribirme se deshabilita si no hay cupo, en vez de dejar que falle.
- Columnas secundarias se ocultan por ancho (
max-lg:hidden,max-sm:hidden); en móvil la grilla suele funcionar mejor que la tabla.
Cómo adaptarlo
- Mové el filtrado y el orden al servidor cuando haya más de unas cientas
filas: la forma del estado (
query,carrera,turno,sort,page) es la misma. - Si el detalle necesita más que un Drawer
sm, usásize="md"o llevalo a una página propia y dejá el Drawer para la vista rápida.
Instalación
npx shadcn@latest add https://polen.cdc.cool/r/template-list.json