# Patterns Code snippets for common UI patterns in humand-create-app projects. These are ready-to-adapt references — read the relevant pattern and pass it to the implementation subagent. All imports use `@material-hu/components/...`. Never import from `@mui/material` directly. --- ## Page layout with Title header ```tsx import Stack from '@material-hu/mui/Stack'; import Button from '@material-hu/components/design-system/Buttons/Button'; import Title from '@material-hu/components/design-system/Title'; import { IconPlus } from '@material-hu/icons/tabler'; import { DashboardLayout } from '../../layouts/DashboardLayout'; export const MyPage = () => { return ( <Button variant="primary" size="large" startIcon={<IconPlus size={20} />} > Action label </Button> </Stack> {/* page content */} </Stack> </DashboardLayout> ); }; ``` --- ## Empty state Uses the **composed** StateCard (not the design-system one). ```tsx import StateCard from '@material-hu/components/composed-components/StateCard'; import { IconInfoCircle } from '@material-hu/icons/tabler'; <StateCard slotProps={{ title: { title: 'No hay elementos aún', description: 'Al crearlos podrás verlos listados aquí', variant: 'M', }, avatar: { Icon: IconInfoCircle, color: 'default', }, }} /> ``` --- ## Table with pagination ```tsx import Table from '@material-hu/components/design-system/Table'; import TableContainer from '@material-hu/components/design-system/Table/components/TableContainer'; import TableHead from '@material-hu/components/design-system/Table/components/TableHead'; import TableBody from '@material-hu/components/design-system/Table/components/TableBody'; import TableRow from '@material-hu/components/design-system/Table/components/TableRow'; import TableCell from '@material-hu/components/design-system/Table/components/TableCell'; import Pagination from '@material-hu/components/design-system/Inputs/Pagination'; const [page, setPage] = useState(1); const [limit, setLimit] = useState(10); const totalPages = Math.ceil(items.length / limit); const paginatedItems = items.slice((page - 1) * limit, page * limit); <TableContainer> <Table> <TableHead> <TableRow headerRow> <TableCell headerCell>Nombre</TableCell> <TableCell headerCell>Estado</TableCell> {/* more columns */} </TableRow> </TableHead> <TableBody> {paginatedItems.map(item => ( <TableRow key={item.id}> <TableCell>{item.name}</TableCell> <TableCell>{/* content */}</TableCell> </TableRow> ))} </TableBody> </Table> </TableContainer> <Pagination type="changer" page={page} totalPages={totalPages} limit={limit} limitOptions={[10, 25, 50]} onChangePage={setPage} onChangeLimit={newLimit => { setLimit(newLimit); setPage(1); }} /> ``` --- ## Status badges with Pills ```tsx import Pills from '@material-hu/components/design-system/Pills'; type StatusType = 'active' | 'paused' | 'draft'; const STATUS_CONFIG: Record<StatusType, { label: string; type: PillsProps['type'] }> = { active: { label: 'ACTIVO', type: 'success' }, paused: { label: 'PAUSADO', type: 'warning' }, draft: { label: 'BORRADOR', type: 'neutral' }, }; const { label, type } = STATUS_CONFIG[item.status]; <Pills label={label} type={type} size="small" /> ``` --- ## Menu via useMenuLayer Always use the layer hook. Never use MenuList or Menu directly. ```tsx import { useMenuLayer } from '@material-hu/components/layers/Menus'; import IconButton from '@material-hu/components/design-system/Buttons/IconButton'; import { IconDotsVertical, IconEdit, IconCopy, IconTrash } from '@material-hu/icons/tabler'; const { openMenu } = useMenuLayer(); const handleOpenMenu = (e: React.MouseEvent<HTMLElement>, item: MyItem) => { openMenu({ anchorEl: e.currentTarget, items: [ { id: 'edit', title: 'Editar', icon: IconEdit, onSelect: () => handleEdit(item), }, { id: 'duplicate', title: 'Duplicar', icon: IconCopy, onSelect: () => handleDuplicate(item), }, { id: 'delete', title: 'Eliminar', icon: IconTrash, onSelect: () => handleDelete(item), }, ], }); }; <IconButton variant="tertiary" onClick={e => handleOpenMenu(e, item)} > <IconDotsVertical size={20} /> </IconButton> ``` --- ## Dialog confirmation via useDialogLayer Always use the layer hook. Never use Dialog directly. ```tsx import { useDialogLayer } from '@material-hu/components/layers/Dialogs'; const { openDialog, closeDialog } = useDialogLayer(); const handleDelete = (item: MyItem) => { openDialog({ title: '¿Eliminar proceso?', textBody: `Se eliminará "${item.name}" de forma permanente.`, primaryButtonProps: { children: 'Eliminar', color: 'error', onClick: () => { // perform delete closeDialog(); }, }, secondaryButtonProps: { children: 'Cancelar', onClick: () => closeDialog(), }, }); }; ``` --- ## Drawer form via useDrawerLayer Always use the layer hook. Never use Drawer directly. ```tsx import { useDrawerLayer } from '@material-hu/components/layers/Drawers'; const { openDrawer, closeDrawer } = useDrawerLayer(); const handleOpenDrawer = () => { openDrawer({ title: 'Editar paso', size: 'medium', children: <MyDrawerForm onClose={closeDrawer} />, footerProps: { primaryButtonProps: { children: 'Guardar', form: 'my-form-id', type: 'submit', }, secondaryButtonProps: { children: 'Cancelar', onClick: () => closeDrawer(), }, }, }); }; ``` --- ## Search input ```tsx import Search from '@material-hu/components/design-system/Inputs/Search'; const [search, setSearch] = useState(''); <Search value={search} onChange={e => setSearch(e.target.value)} placeholder="Buscar" variant="classic" /> ``` --- ## Stepper ```tsx import Stepper from '@material-hu/components/design-system/Stepper'; import Step from '@material-hu/components/design-system/Stepper/components/Step'; const [activeStep, setActiveStep] = useState(0); const steps = ['Información básica', 'Contenido', 'Asignación', 'Revisión']; <Stepper> {steps.map((label, index) => ( <Step key={label} label={label} current={index === activeStep} completed={index < activeStep} last={index === steps.length - 1} /> ))} </Stepper> ``` --- ## Form with React Hook Form + Zod ```tsx import { useForm, FormProvider } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import FormInputClassic from '@material-hu/components/design-system/Inputs/Classic/form'; import FormTextArea from '@material-hu/components/design-system/Inputs/TextArea/form'; const schema = z.object({ name: z.string().min(1, 'Requerido').max(100), description: z.string().max(256).optional(), }); type FormValues = z.infer<typeof schema>; const methods = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { name: '', description: '' }, }); <FormProvider {...methods}> <form id="my-form" onSubmit={methods.handleSubmit(onSubmit)}> <FormInputClassic name="name" inputProps={{ label: 'Nombre', placeholder: 'Ingresá un nombre', hasCounter: true, maxLength: 100, }} /> <FormTextArea name="description" inputProps={{ label: 'Descripción (opcional)', placeholder: 'Ingresá una descripción', }} /> </form> </FormProvider> ``` --- ## Vercel API proxy route (external service) Server-side proxy that keeps API keys out of the browser. Each operation is an explicit route — no open passthrough. ```typescript // api/<service-name>/[...path].ts import type { VercelRequest, VercelResponse } from '@vercel/node'; const ROUTES: Record<string, (req: VercelRequest) => Promise<unknown>> = { 'GET /contacts': handleGetContacts, 'POST /contacts': handleCreateContact, }; export default async function handler(req: VercelRequest, res: VercelResponse) { const path = Array.isArray(req.query.path) ? req.query.path.join('/') : req.query.path ?? ''; const routeKey = `${req.method} /${path}`; const routeHandler = ROUTES[routeKey]; if (!routeHandler) { return res.status(404).json({ error: 'Not found' }); } try { const data = await routeHandler(req); return res.status(200).json(data); } catch (error) { console.error(`[api/<service-name>] ${routeKey} failed:`, error); return res.status(500).json({ error: 'Internal server error' }); } } async function handleGetContacts(_req: VercelRequest) { const API_KEY = process.env.SERVICE_API_KEY; if (!API_KEY) throw new Error('Missing API key'); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); try { const response = await fetch('https://api.example.com/contacts', { headers: { Authorization: `Bearer ${API_KEY}` }, signal: controller.signal, }); if (!response.ok) throw new Error(`Upstream ${response.status}`); const raw = await response.json(); return raw.results.map((c: any) => ({ id: c.id, name: c.name, email: c.email })); } finally { clearTimeout(timeout); } } ``` --- ## react-query: query hook Wraps a service call with `useQuery`. Always use single-object syntax. ```tsx import { useQuery } from '@tanstack/react-query'; import { contactsService } from '../services/contacts'; // Query keys factory export const contactsKeys = { all: () => ['contacts'] as const, lists: () => [...contactsKeys.all(), 'list'] as const, list: (params?: unknown) => [...contactsKeys.lists(), params] as const, }; export const useGetContacts = (params?: ContactFilters) => { const { data, ...query } = useQuery({ queryKey: contactsKeys.list(params), queryFn: () => contactsService.getContacts(params), }); return { contacts: data ?? [], ...query }; }; ``` --- ## react-query: mutation hook Wraps a service call with `useMutation`. Invalidates related queries on success. ```tsx import { useMutation, useQueryClient } from '@tanstack/react-query'; import { contactsService } from '../services/contacts'; export const useCreateContactMutation = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: contactsService.createContact, onSuccess: () => { queryClient.invalidateQueries({ queryKey: contactsKeys.lists() }); }, }); }; ``` --- ## react-query: using hooks in a component ```tsx import { useGetContacts, useCreateContactMutation } from '../../services/contacts.hooks'; export const ContactList = () => { const { contacts, isLoading } = useGetContacts(); const createContact = useCreateContactMutation(); const handleCreate = (data: CreateContactInput) => { createContact.mutate(data, { onSuccess: () => { // UI feedback: toast, close drawer, etc. }, }); }; if (isLoading) return <CircularProgress />; return ( // render contacts... ); }; ```