@stackcraftmx/calcita
v0.32.2
Published
Componentes y utilidades de React con MUI para arrancar proyectos rápido: tabla de servidor, diálogos, avisos, descargas, formularios, formatos y reportes en Excel.
Maintainers
Readme
Calcita
@stackcraftmx/calcita · un framework de React sobre MUI 7 con el vidrio líquido de Apple. La calcita es el cristal
transparente famoso por doblar la luz, y eso es lo que define al vidrio líquido: su lente.
Arranca un proyecto sin copiar y volver a probar lo de siempre: el marco de la app con menú y permisos, tabla de servidor, diálogos de confirmación, avisos, descargas, formularios, formatos de número y fecha, errores RFC 9457, reportes en Excel, la cuenta completa, mapa, calendario, tablero kanban, bandeja tipo correo y el tema con vidrio líquido, interpretado componente por componente según la guía de Apple.
JavaScript con sus tipos generados: se usa igual desde un proyecto en TypeScript. Sirve para un proyecto nuevo y también para meterlo por pedazos en un sistema que ya existe (ver «En un sistema que ya existe»).
Versiones: Calcita está hecha y probada con MUI 7 (Material 7.3, MUI X 8), React 19 y react-router 7. MUI 9 ya existe, pero cambia piezas que Calcita usa: todavía no está soportado. Con MUI 9 en el proyecto, quédese en MUI 7 por ahora.
Arrancar un proyecto con el CLI
pnpm dlx @stackcraftmx/calcita new mi-tienda # o: npx @stackcraftmx/calcita new mi-tienda
cd mi-tienda
pnpm install
pnpm devPregunta cómo lo quiere (Enter acepta lo recomendado): el menú a un lado o arriba; vidrio, sin vidrio o minimalista; claro
y oscuro o solo claro; los colores de Stackcraft, los de MUI o uno propio; formik o react-hook-form; los textos de Calcita o
i18next; las pantallas de entrar, «¿Olvidó su contraseña?» y Mi perfil; y si se puede instalar como app (PWA). Con --yes
no pregunta, y cada respuesta también va como opción (--forms=react-hook-form, --menu-position=top…;
calcita --help las dice todas).
Sale un proyecto de Vite con la forma de los ejemplos: src/app (el único lugar donde se conecta el framework),
src/core (lo que comparten los módulos), src/fakeServer (el servidor de mentira, con la forma del API de verdad) y
src/modules/<módulo>, cada uno con su menú, sus rutas, sus pantallas, su API y sus textos en español e inglés, que se
descubren solos. Trae un módulo de ejemplo (Contactos: lista, formulario, errores del servidor en su campo) y
src/core/apiClient.js listo para cambiar el servidor de mentira por el de verdad.
Para crecerlo, dentro del proyecto:
pnpm exec calcita make:module purchase-orders --label-es "Órdenes de compra" --label-en "Purchase orders"
pnpm exec calcita make:page purchase-orders OrderDetail # la pantalla y su ruta en routes.js del módulo
pnpm exec calcita make:form purchase-orders SupplierForm # con la librería del proyecto (calcita.json)
pnpm exec calcita make:component StatusBadge # en src/core, y en su puerta
pnpm exec calcita make:component OrderTotals --module purchase-ordersEl CLI nunca pisa un archivo que ya existe. Sus plantillas son los archivos tal cual (cli/templates), con
//#if pwa … //#endif en lo que depende de una respuesta.
Arrancar en un proyecto que ya existe
pnpm add @stackcraftmx/calcita @mui/material @mui/icons-material @emotion/react @emotion/styled xstate @xstate/react sonner
pnpm add formik yup # solo si usa /forms o /accountCon npm es igual, con npm install en lugar de pnpm add. Desde la 0.25, formik y yup los pone el proyecto: Calcita usa
la versión que ya tenga, no se casa con una.
import {ThemeProvider, createTheme} from '@mui/material/styles';
import {ToolkitProvider, Toaster} from '@stackcraftmx/calcita';
const theme = createTheme(); // el tema lo arma cada proyecto; sin él, los colores por omisión de MUI
export const App = () => (
<ThemeProvider theme={theme}>
<ToolkitProvider appName="Mi aplicación">
<Toaster />
{/* las pantallas */}
</ToolkitProvider>
</ThemeProvider>
);Sin nada más, todo sale en español, con la zona horaria del navegador, montos en pesos y la navegación del navegador.
Lo que se conecta en ToolkitProvider
| Prop | Para qué | Por omisión |
|---|---|---|
| language, onLanguageChange | idioma activo y a quién avisar cuando se cambia | es |
| languages | los idiomas que ofrece la app, con su nombre y su locale ({code: 'pt', label: 'Português', locale: 'pt-BR'}); sus traducciones van en texts | español e inglés |
| texts | cambiar textos del paquete, por idioma: {es: {common: {actions: {back: 'Volver'}}}} | los del paquete |
| translate | un traductor propio, por ejemplo i18next | ninguno |
| timeZone | zona en que se pintan las horas | la del navegador |
| currency | moneda de los montos (ISO 4217) | MXN |
| hourCycle | la hora de 24 horas («15:10») o de 12 con a.m. y p.m. («3:10 p.m.»): HOUR_CYCLE.twentyFourHours o HOUR_CYCLE.twelveHours | 24 horas |
| appName | nombre en el título de la pestaña | ninguno |
| navigation | el router: {useNavigate, useLocation, Link} | la del navegador |
| canAccess | quién ve qué (menú, rutas, botones): la regla del proyecto, normalmente createAccessChecker(...) | solo lo que no tiene regla |
| buildId | la compilación del front que corre en la pestaña, para el aviso de versión nueva | ninguna (sin aviso) |
| fileTypes | tipos de archivo propios para las descargas (su ícono y su color), además de Excel, CSV y PDF | los de la casa |
Un ToolkitProvider dentro de otro (una parte de la app en otro idioma, una vista previa) toma del de afuera lo que no le
digan, y lo de adentro usa el suyo: el más cercano. No le cambia nada al resto de la app. Dentro de un componente,
useFormatters() da los formatos (formatDate, formatCurrency…) con el idioma, la zona y la moneda del proveedor más
cercano; los sueltos usan los del proveedor de la raíz.
Con react-router y i18next:
import {reactRouterNavigation} from '@stackcraftmx/calcita/react-router';
import {translateWithI18next} from '@stackcraftmx/calcita/i18next';
<ToolkitProvider
navigation={reactRouterNavigation}
language={i18n.resolvedLanguage}
onLanguageChange={(language) => i18n.changeLanguage(language)}
translate={translateWithI18next(i18n)}
>Aviso de versión nueva
Una pestaña abierta desde hace horas sigue corriendo el front con que se abrió. Si el servidor anuncia en sus respuestas qué compilación está publicada, el paquete enseña una franja «Hay una versión nueva» con su botón «Recargar». No recarga solo: la persona decide cuándo.
import {NewBuildNotice, reportPublishedBuild} from '@stackcraftmx/calcita';
// 1. la compilación de esta pestaña (por ejemplo, de una variable de entorno al construir)
<ToolkitProvider buildId={import.meta.env.VITE_BUILD_ID}>
// 2. el cliente HTTP entrega la que anuncia el servidor en cada respuesta (aquí, en un header)
axiosClient.interceptors.response.use((response) => {
reportPublishedBuild(response.headers['x-frontend-build']);
return response;
});
// 3. la franja, donde se vea siempre (normalmente dentro del AppBar); sin versión nueva no ocupa lugar
<NewBuildNotice />Con AppLayout no hace falta el paso 3: el marco ya lo pone. Con vidrio no es franja sino una cápsula que flota aparte
de la barra (variant="floating"): en escritorio, centrada debajo de la barra de arriba; en el teléfono, encima de la
barra de abajo, como el reproductor chico que Apple pone encima de las pestañas.
Sin API que anuncie la versión, o con la app anclada al inicio del iPhone (al abrirla, el iPhone no vuelve a pedir la
página: retoma la que tenía en memoria, aunque haya pasado un día), usePublishedBuildWatch se entera sola. El build deja
un archivo con su versión y la app lo vuelve a leer cada vez que regresa del segundo plano y cada tanto. Si estuvo fuera
15 minutos o más, se recarga sola con lo nuevo; si solo salió un momento (a copiar un código del SMS), sale el aviso con su
botón, para no borrar lo que llevaba escrito.
// vite.config.js: la versión va dentro de la app y en dist/version.json
const BUILD_ID = process.env.VITE_BUILD_ID || new Date().toISOString();
export default defineConfig({
plugins: [react(), {
name: 'build-version', apply: 'build',
generateBundle() { this.emitFile({type: 'asset', fileName: 'version.json', source: JSON.stringify({buildId: BUILD_ID})}); },
}],
define: {'import.meta.env.VITE_BUILD_ID': JSON.stringify(BUILD_ID)},
});
// en la raíz de la app, una vez (en desarrollo no hay archivo: sin url no hace nada)
usePublishedBuildWatch({url: import.meta.env.PROD ? `${import.meta.env.BASE_URL}version.json` : null, buildId: import.meta.env.VITE_BUILD_ID});
// opciones: checkEveryMinutes (5), reloadAfterHiddenMinutes (15; null = nunca recarga sola), onReloadEl servidor no debe guardar version.json (con Cache-Control: no-cache, como el index.html).
Qué trae cada puerta
Cada puerta pide solo sus librerías; un proyecto instala lo que usa.
| Importar de | Trae | Pide además |
|---|---|---|
| @stackcraftmx/calcita | proveedor, marco de la app, diálogos, avisos, descargas, encabezados, filtros, chips, métricas, formatos (useFormatters), fechas, errores RFC 9457, la petición de la casa (createApiClient), la recuperación de versión vieja, la navegación sin router (createBrowserNavigation, createHashNavigation), la búsqueda de la barra (BarSearch), el menú «⋯» (ActionsMenu), las acciones de lo elegido (SelectionActions), la seguridad de la cuenta (códigos de recuperación, sesiones, bitácora) y la bandeja tipo correo (InboxLayout, MessageList con selección, MessageView con la conversación) | — |
| @stackcraftmx/calcita/table | ServerTable: paginada, filtrada y ordenada en el servidor, con su estado en la URL | @mui/x-data-grid, nuqs |
| @stackcraftmx/calcita/forms | campos de formik (texto, lista de una o varias opciones, número con miles, catálogo, color), formulario por pasos, aviso de arriba, errores del servidor pegados a cada campo | formik, yup |
| @stackcraftmx/calcita/react-hook-form | los mismos campos con los mismos nombres, conectados a react-hook-form, su formulario por pasos y yupResolver | react-hook-form, @hookform/resolvers (y yup si valida con yup) |
| @stackcraftmx/calcita/date-pickers | la fecha con el DatePicker de MUI X (DateInput, FormDateField); viaja AAAA-MM-DD | @mui/x-date-pickers 8, dayjs |
| @stackcraftmx/calcita/charts | barras, columnas y líneas (una serie o varias), cada una con su vista de tabla, y ChartTooltip para las propias | recharts |
| @stackcraftmx/calcita/timeline | línea de tiempo por grupos | @mui/lab |
| @stackcraftmx/calcita/excel | reporte en Excel (Resumen y Datos) y CSV | exceljs, que se le pasa al llamarlo |
| @stackcraftmx/calcita/react-router | el adaptador de navegación | react-router |
| @stackcraftmx/calcita/i18next | el adaptador de textos | i18next (la instancia del proyecto) |
| @stackcraftmx/calcita/laravel | el reintento del 419 de Sanctum | — |
| @stackcraftmx/calcita/filepond | FileDropzone con FilePond: el mismo uso que la de la puerta principal, más varios archivos, avance por archivo y createPondServer | filepond, react-filepond, filepond-plugin-file-validate-type, filepond-plugin-file-validate-size |
| @stackcraftmx/calcita/next | la navegación de Next.js (App Router) para ToolkitProvider; los componentes ya vienen marcados con 'use client' y los puros sirven en el servidor | next |
| @stackcraftmx/calcita/money | Money: dinero exacto en centavos enteros, con suma, resta, multiplicación, división, potencia, raíz y su formato con la moneda | — |
| @stackcraftmx/calcita/passkeys | llaves de acceso (passkeys): crearlas y firmar con ellas, con los errores del navegador en palabras de la persona | @simplewebauthn/browser 14 |
| @stackcraftmx/calcita/account | la cuenta completa: SignInScreen (con el segundo paso), ForgotPasswordScreen, ResetPasswordScreen, ProfileScreen (con campos y pestañas propias), AccountSecuritySection, AccountProvider y createRestAccountConnector | @simplewebauthn/browser 14, formik, yup |
| @stackcraftmx/calcita/account-demo | createDemoAccountConnector: la cuenta en el navegador, sin backend (y los códigos TOTP) | — |
| @stackcraftmx/calcita/maps | MapView: mapa con marcadores del color del tema, el elegido con su globo, se acomoda solo; en oscuro, mosaicos oscuros | leaflet, react-leaflet 5 |
| @stackcraftmx/calcita/calendar | EventCalendar: mes, semana, día y lista, con la barra del framework; arrastrar cambia la cita y regresa si el servidor dice que no. EventDetails: el detalle de una cita (lugar, liga de la reunión, invitados con su respuesta, recordatorio, notas) | @fullcalendar/react, core, daygrid, timegrid, list, interaction 6.1 |
| @stackcraftmx/calcita/kanban | KanbanBoard: columnas con tope (y que se encogen, como un congelador) y tarjetas de producto (etiquetas, responsables, fecha, pendientes, terminar) que se arrastran con el ratón, el dedo o el teclado; moveCard para ver la movida al instante | @hello-pangea/dnd 18 |
El tema
createToolkitTheme() es opcional (ToolkitProvider funciona con cualquier tema de MUI) y trae el estilo de Stackcraft:
claro y oscuro con variables CSS, letra base de 16 px (con menos, el iPhone hace zoom al entrar a un campo), tamaños que
se ajustan al ancho, transiciones de 220 ms, esquinas de 8 px, botones sin mayúsculas y contorno de foco visible.
const theme = createToolkitTheme(); // los colores de Stackcraft
const theme = createToolkitTheme({glass: true}); // vidrio líquido en lo que flota y en los controles
const theme = createToolkitTheme({colorSchemes: {light: {palette: {primary: {main: '#0B5FFF'}}}, dark: {palette: {…}}}});
const theme = createToolkitTheme({components: {MuiCard: {…}}}); // se SUMA a los ajustes del frameworkEl vidrio es la versión en CSS del Liquid Glass de Apple (60 % de opacidad por omisión). Va en lo que flota sobre el contenido y en los controles, como pide su guía:
- la barra y el menú lateral, que en escritorio flotan a 8 px de la orilla;
- los menús, las listas de opciones, el calendario, los menús de la tabla, los diálogos, los tooltips, los avisos, el panel de descargas, el botón flotante y la barra de pestañas de abajo;
- la «gota» que marca lo elegido en las pestañas y en los botones de opción, que se desliza a la opción nueva;
- botones en píldora que se hunden al apretarlos;
- un lienzo con manchas tenues de color, para que el vidrio tenga algo que desenfocar.
Las tarjetas del contenido se quedan sólidas, para que el texto largo se lea, salvo que el proyecto las pida de vidrio.
Todo se pinta sólido si la persona pidió en su equipo «Reducir transparencia» o «Aumentar contraste». La refracción (el
lente que dobla lo de atrás en la orilla del vidrio) sale en Chrome y sus parientes (Edge, Brave, Arc) con
GlassLensFilter, que AppLayout y AuthLayout ya ponen; Safari y Firefox no pintan ese filtro y ahí el vidrio se queda
esmerilado.
const theme = createToolkitTheme({glass: {opacity: 0.7}}); // más transparente (0 a 1)
const theme = createToolkitTheme({glass: {surfaces: true}}); // también las tarjetas del contenidosurfaces se puede usar, pero avisa en la consola: cada tarjeta de vidrio desenfoca lo que tiene detrás (pesa más en
equipos modestos) y la guía de Apple pide el contenido sin vidrio. Va bien en una portada o una demostración, no en
pantallas de trabajo.
La barra de arriba va sin franja, como las ventanas de macOS Tahoe: la ☰ con el nombre, el menú y la cuenta en cápsulas,
y el título suelto. Con vidrio las cápsulas son de vidrio y el contenido pasa por debajo, desenfocado en la orilla para
que todo se lea (scrollEdgeStylesOf); con el minimalista son bloques del tono de las tarjetas, también en el teléfono.
Con cápsulas, el menú encogido deja la cuenta arriba en su cápsula, y en el teléfono el minimalista también lleva la
barra de pestañas de abajo (sólida, del gris 5 de Apple). Quien prefiera la franja de lado a lado la pide;
está en el mismo alto y el mismo lugar, así que nada más se mueve. El estilo de Stackcraft sin vidrio sigue con su barra.
const theme = createToolkitTheme({glass: true, bar: TOP_BAR.band}); // la franja; por omisión TOP_BAR.capsulesCada pantalla puede poner sus propias cápsulas en la barra (buscar, agregar, exportar) con BarActions: las escribe en
su código donde quiera y, en escritorio con cápsulas, el marco las sube a la barra; con la franja entran a la franja, y en
el teléfono, con cápsulas, flotan arriba a la derecha y se quedan al bajar: las que caben, solo con ícono, y las que no,
en un menú «⋯». Las actions de PageHeading ya suben solas, y ServerTable sube su búsqueda con
searchInBar. «Hecho en Calcita» → «Marco y menú» lo enseña vivo.
<BarActions label="Buscar en pedidos" order={1}>
<IconButton aria-label="Buscar" onClick={openSearch}><SearchRoundedIcon /></IconButton>
</BarActions>
<BarActions label="Acciones de pedidos" order={2}> {/* otro grupo, otra cápsula */}
{/* en la cápsula, solo el ícono con su nombre en el tooltip */}
<BarActionButton label="Agregar pedido" icon={<AddRoundedIcon />} onClick={openNew} />
</BarActions>
<BarCapsule aria-label="Zoom">…</BarCapsule> {/* la misma cápsula, fuera de la barra */}En Android conviene arrancar con el minimalista: el vidrio es el estilo de Apple (allá se ve ajeno) y el desenfoque pesa
en los teléfonos baratos. isAndroidDevice() lo dice; con Next, desde el servidor, se le pasa la identificación del
navegador: isAndroidDevice({userAgent: headers.get('user-agent')}). Sirve para elegir un estilo, no para seguridad.
const theme = isAndroidDevice() ? createToolkitTheme({look: THEME_LOOK.minimal}) : createToolkitTheme({glass: true});Los campos de captura (con vidrio, con el minimalista y dentro de FormSection) traen el gris de relleno de Apple. Para que
se vean como los de su sistema: fields, por modo, con el relleno (fill), el relleno con el cursor encima (hoverFill),
la orilla (border) y la orilla con el cursor encima (hoverBorder); y sus esquinas (radius). Lo que no se pase se queda
como lo trae el estilo (FIELD_TONES). Van en la paleta de cada modo (palette.field): cambian solos con el modo y también
se pueden escribir en colorSchemes.
createToolkitTheme({glass: true, fields: {radius: 8, light: {fill: '#FFFFFF', hoverFill: '#FFFFFF', border: '#C4CDD5'}}});El minimalista separa las superficies por su tono con los grises de Apple. Para pintarlo con los colores de la marca:
minimalTones, por modo, con el fondo de la página (default), las tarjetas (paper), el relleno de campos, grupos y
botones sin color (fill) y lo que flota, como la barra del teléfono (floating). Lo que no se pase se queda con el gris
de Apple (MINIMAL_TONES). Ojo: con el minimalista el fondo sale de aquí y no de la paleta.
createToolkitTheme({
colorSchemes: {light: {palette: brandLight}, dark: {palette: brandDark}},
look: THEME_LOOK.minimal,
minimalTones: {light: {default: '#EEF4FF', fill: '#DCE6F5'}, dark: {default: '#101827', paper: '#1A2333'}},
});Para lo que el proyecto quiera hacer flotar (los botones encima de un mapa, una barra de acciones): GlassPanel, que es
de vidrio si el tema lo trae y una tarjeta con sombra si no. Para algo propio: sx={(theme) => glassStylesOf(theme)} e
isGlassTheme(theme). Cada ajuste vive en su archivo (src/theme/components/MuiButton.js…).
Todas las piezas de Calcita se cambian desde el tema del proyecto igual que las de MUI: con su nombre en components,
styleOverrides para cada parte y defaultProps. El orden, de abajo hacia arriba: el estilo de Calcita, encima el del
tema del proyecto y al final el sx que pase la pantalla. Los números se leen como en MUI (borderRadius: 4 son 4 px).
Sirve con createToolkitTheme y con cualquier tema de MUI (el que ya tenía un sistema, tal cual): así un sistema puede
dejar Calcita con su propia cara, parte por parte.
| Nombre en el tema | Partes |
| --- | --- |
| CalcitaAccountMenu | button, avatar, header, name, settings |
| CalcitaAccountSecurity | title, form |
| CalcitaActionsMenu | item, itemIcon |
| CalcitaActivityLog | itemIcon |
| CalcitaAppearanceSettings | selectSection, section, sectionLabel, option, divider |
| CalcitaAppLayout | skipLink, brand, brandLogo, brandText, brandName, brandCaption, titleArea, phoneTitle, desktopTitle, railBrand, railLogo, collapseButton, root, scrollEdge, bar, toolbar, menuButton, spacer, tools, progress, body, drawer, main, phoneSlot, content, phoneDrawer |
| CalcitaAuditTrailDialog | description |
| CalcitaAuthLayout | root, tools, body, brand, logo, brandName, card, description, content, footer |
| CalcitaBarActions | group |
| CalcitaBarCapsule | root |
| CalcitaBarList | row, chart |
| CalcitaBarSearch | field |
| CalcitaCalendarHeatmap | header, toggle, tableArea, dateCell, valueCell, descriptionCell, gridArea, grid, monthLabel, week, dayLabel, cell, legend, legendLess, legendCell, legendMore |
| CalcitaChart | tableArea, legend, legendItem, legendLine, legendSwatch, reference, referenceLine |
| CalcitaChartTooltip | root |
| CalcitaCollapsibleSection | root, accordion, summary, titleArea, collapsedTitle, expandedTitle, details |
| CalcitaColorInput | swatch, nativeInput |
| CalcitaColorSchemeToggle | button |
| CalcitaColumnChart | chart |
| CalcitaConfirmDialog | problem, actions |
| CalcitaContentDialog | header, title, closeButton, body |
| CalcitaDateRangeFilter | chip, panel, actions |
| CalcitaDefinitionList | root, entry, label, value |
| CalcitaDemoTotpCode | root, code |
| CalcitaDetailHeader | root, titleArea, title, badges, actions |
| CalcitaDownloadManager | doneIcon, failedIcon, canceledIcon, cancelButton, progress, cancelIcon, item, itemText, itemStatus, itemActions, panel, header, title, list |
| CalcitaDownloadMenu | button |
| CalcitaEmptyState | root, icon |
| CalcitaErrorPage | root, actions |
| CalcitaEventCalendar | toolbar, navigation, title, calendar |
| CalcitaEventDetails | row, rowContent, titleRow, toneDot, linkRow, link, guestsTitle, guest, guestAvatar, guestName, notes, actions |
| CalcitaFileDropzone | root, fileArea, fileChip, hint, actions, limits, extra |
| CalcitaFilterChips | root, header, clearButton |
| CalcitaFontSizeToggle | button |
| CalcitaFormProblemAlert | root |
| CalcitaFormRow | root, label, control |
| CalcitaFormSection | root, title, card, footer |
| CalcitaFullPageLoader | root |
| CalcitaGlassPanel | root |
| CalcitaGoToPage | root, field |
| CalcitaGroupedTimeline | group, groupHeader, groupButton, groupTitle, expandIcon, timeline, opposite, connector, content, entryTitle |
| CalcitaHeaderFilter | button, badge, panel |
| CalcitaHeaderTools | divider, root |
| CalcitaInboxLayout | labelDot, folder, folderIcon, folderCount, root, folders, foldersHeader, labelsTitle, listPane, phoneTabs, phoneTabBadge, listHeader, list, detail, backButton |
| CalcitaInstallAppSetting | root |
| CalcitaInstallInstructions | steps |
| CalcitaInstallPromptBanner | root, alert |
| CalcitaKanbanBoard | completeButton, tags, titleRow, doneIcon, cardTitle, meta, due, dueIcon, dueText, checklist, checklistIcon, comments, commentsIcon, attachments, attachmentsIcon, assignees, root, collapsedColumn, collapsedTitle, hidden, column, columnHeader, columnTitle, quickAdd, dropZone, card, cardActions, cardContent, empty |
| CalcitaLanguageToggle | button |
| CalcitaLineChart | chart |
| CalcitaMapView | popupText, root |
| CalcitaMessageList | empty, selectedAvatar, item, actions, starButton, itemButton, avatarSlot, avatarButton, avatar, body, text, topRow, unreadDot, sender, threadCount, date, subject, labels |
| CalcitaMessageView | header, headerText, recipients, body, empty, labels, attachments, divider, collapsed, collapsedAvatar, collapsedText, collapsedSender, collapsedDate |
| CalcitaMetricCard | root, action, header, icon, label, value, caption, extra, trend |
| CalcitaMobileTabBar | tab, tabIconPlaceholder, tabLabel, root, bar, minimized, account, sheetHeader |
| CalcitaModalWaiting | root, title, body, text |
| CalcitaMultiSelectFilter | list, option |
| CalcitaNewBuildNotice | floating, icon, message, reloadButton, banner |
| CalcitaPageBreadcrumbs | root, link, current |
| CalcitaPageHeader | root, subtitle, action |
| CalcitaPageHeading | root, breadcrumbs, titleRow, title, actions, description |
| CalcitaPasswordChecklist | title, list, item, hidden |
| CalcitaPeriodRangeSelector | field, select, root |
| CalcitaPermissionMatrix | permission, checkbox, group, groupHeader, groupTitle, grid |
| CalcitaPondDropzone | root, summary, limits, actions, extra |
| CalcitaProfileHeader | root, cover, body, avatar, text, name, chips, actions |
| CalcitaProfileScreen | sectionTitle, form, preferences |
| CalcitaQuickFilters | root, caption, chip, chipLabel, chipCount |
| CalcitaRecoveryCodesDialog | codes, actions |
| CalcitaScrollableRow | root |
| CalcitaSecondStepForm | actions |
| CalcitaSectionCard | root, header, title |
| CalcitaSegmentedControl | root |
| CalcitaSelectInput | option, optionText |
| CalcitaSelectionActions | bar, count, clear, phoneCount, phoneActions |
| CalcitaServerTable | toolbar, summary, customHeader, count, countRow, selectionNotice, error, card, grid, pagination |
| CalcitaSessionsCard | item, itemIcon, itemHeader, actions |
| CalcitaSettingsMenu | divider |
| CalcitaSideMenu | scrollArrow, root, brand, brandLogo, brandText, brandName, brandCaption, railTop, listArea, list, section, sectionTitle, sectionTitleText, footer, railBottom |
| CalcitaSideMenuEntry | button, icon, iconPlaceholder, text, badge, expand |
| CalcitaSignInForm | footer |
| CalcitaSignInScreen | alert |
| CalcitaSortableFilterHeader | title, root, sortButton, sortIndicator, clearButton |
| CalcitaStatusChip | root, dot, label, chip |
| CalcitaSubmenuActionsMenu | badge |
| CalcitaTabBarEditor | root, header, title, list, itemIcon, footer |
| CalcitaTableToggle | root, button |
| CalcitaTag | root |
| CalcitaTextInput | field |
| CalcitaTopMenu | link, root |
| CalcitaTwoStepVerification | qr, secret, secretCode, header, headerTitle, headerText, menuIcon |
| CalcitaWizardLayout | stepper, phoneSteps, actions |
| CalcitaWordmark | root |
Para cambiar de un jalón los valores globales, sin tocar pieza por pieza, Calcita los lee de variables CSS. Cada una trae su valor de siempre de respaldo: sin ella se ve igual.
| Variable | Qué cambia | De fábrica |
| --- | --- | --- |
| --calcita-panel-radius | las esquinas de lo que flota grande y las tarjetas grandes (menú lateral, diálogos, la sección de un formulario) | 20px (16px en el minimalista) |
| --calcita-menu-radius | las esquinas de menús, listas desplegables, globos y avisos | 12px |
| --calcita-field-radius | las esquinas de los campos de captura | 12px (10px en el minimalista; cápsula dentro de FormSection) |
| --calcita-glass-blur | el desenfoque del vidrio | 20px |
| --calcita-glass-saturate | cuánto aviva el vidrio los colores de atrás | 180% |
:root { --calcita-panel-radius: 6px; --calcita-glass-blur: 12px; } /* también en la página de un sistema que ya existe */createToolkitTheme({cssTokens: {panelRadius: 6, glassBlur: 12}}); // las mismas, declaradas por el tema (un número va en px)Los colores de los campos son variables de MUI (--mui-palette-field-fill, --mui-palette-field-border…; ver fields).
Lo que se pinta flotando al final de la página (menús, ventanas) toma las de :root, no las de un contenedor.
createTheme({
components: {
CalcitaStatusChip: {defaultProps: {variant: 'chip'}},
CalcitaFormSection: {styleOverrides: {card: {borderRadius: 4}}},
CalcitaSegmentedControl: {styleOverrides: {root: {'& .MuiToggleButton-root': {textTransform: 'uppercase'}}}},
},
});Para un ajuste solo en escritorio o solo en el teléfono, las mismas reglas que usa el marco: DESKTOP_MEDIA_QUERY y
PHONE_MEDIA_QUERY (teléfono es angosto, menos de 900 px, o bajito, menos de 500 px de alto: un teléfono acostado).
import {DESKTOP_MEDIA_QUERY} from '@stackcraftmx/calcita';
// la barra sin el espacio de la izquierda, solo en escritorio
createToolkitTheme({components: {CalcitaAppLayout: {styleOverrides: {toolbar: {[DESKTOP_MEDIA_QUERY]: {paddingLeft: 0}}}}}});El marco de la app, el menú y los permisos
AppLayout es el marco de las plantillas de MUI: la barra a todo lo ancho, con el logo y el nombre de la app; debajo,
el menú lateral por secciones, que la ☰ encoge a solo íconos (y lo recuerda). La barra trae el título de la página
cuando el grande sale de la vista, el tamaño de letra, el idioma, claro u oscuro y la cuenta (Mi perfil, lo que agregue
el proyecto y Salir). Con menuPosition="top" el menú va dentro de la barra. En el teléfono, la hamburguesa abre el menú
en un cajón y los ajustes se juntan detrás de un engrane. Con vidrio, en el teléfono las secciones van en una barra de
pestañas abajo que se encoge al leer hacia abajo y regresa al subir, como en iOS 26 (minimizeTabBarOnScroll={false} la
deja fija). Van las que el menu.js marca con mobileTab: true, y en «Más» → «Editar la barra» cada quien elige las suyas
(se recuerda en su navegador; «Como venía» regresa a las del proyecto). Van tantas como quepan a lo ancho sin cortar su
nombre; las demás, en «Más».
Claro u oscuro tiene tres opciones en el menú de la cuenta: Claro, Oscuro y Automático, que sigue lo que diga el equipo y es lo de omisión. El botón de la barra fija claro u oscuro; Automático es la forma de volver a seguir al equipo.
Una marca que es una palabra larga va en wordmark (a 24 px de alto, en lugar del logo cuadrado y el nombre). Una entrada
del menú puede llevar a otro sistema (href) y traer su ícono ya pintado (icon) en lugar del componente (Icon).
El menú no se escribe en una lista central: cada módulo trae su menu.js y composeMenu los junta, los ordena y deja
solo los que la sesión puede ver (también dentro de los submenús).
// modules/orders/menu.js
export const order = 10;
export const access = {ability: 'orders.view'}; // o {role}, {anyOf}, {policy: 'sameOrganization'}, {check}
export function buildMenuItem() {
return {
to: '/orders', labelKey: 'orders:menu.label', sectionKey: 'app:sections.operation', Icon: ReceiptLongIcon,
badge: 3, // opcional
children: [{to: '/orders/returns', labelKey: 'orders:menu.returns', access: {ability: 'returns.view'}}], // opcional
};
}const canAccess = createAccessChecker({abilities: session.abilities, roles: session.roles, policies, context: {session}});
const menuItems = composeMenu(import.meta.glob('@/modules/*/menu.js', {eager: true}), {canAccess});
<ToolkitProvider canAccess={canAccess}>
<AppLayout logo={<Logo />} menuItems={menuItems} user={session.user} profilePath="/me" onSignOut={signOut}
headerActions={<MiBoton />} sidebarFooter={<MiTarjeta />} accountMenuItems={[{to: '/billing', label: 'Facturación'}]}>
…
</AppLayout>
</ToolkitProvider>En cada pantalla, PageHeading pone el título, la descripción y los botones, y arma las migas solo desde el menú
(Inicio › Sección › Pantalla). PageHeader es el encabezado centrado de una portada y DetailHeader el de un registro.
La misma regla protege las rutas (withAccess(access, routes) de /react-router: quien no puede ve «sin permiso», o la
página propia que se pase en {fallback}) y
los botones (<Can access={…}>, useCan). El framework no sabe de roles: RBAC por permisos o roles y ABAC por políticas
con nombre o reglas escritas ahí mismo, como cada proyecto las necesite. Lo que se pinta no protege nada: el backend
vuelve a revisar cada petición. ErrorPage trae las pantallas de 404, 403 y 500, y se extiende sin tocarla: botones
propios junto a los de la casa (extraActions) o en su lugar (actions), algo debajo del texto (children), otro ícono y
onGoHome.
El marco avisa cuando la persona encoge el menú (onMenuCollapse), abre el menú del teléfono (onMobileMenuChange) o elige
sus pestañas (onMobileTabsChange), para guardar esas preferencias en el servidor del proyecto si se quiere; sus textos se
cambian con texts.
Eventos y textos de cada componente
Cada componente de la casa avisa hacia afuera todo lo que pasa adentro (onSelect, onChange, onViewChange,
onMenuCollapse, onCardComplete, onDatesChange…) y nunca llama un servidor propio: el proyecto decide qué hacer. Así se
mide, se guarda una preferencia en el servidor del proyecto o se conecta un sistema viejo sin tocar Calcita.
Cada texto también se cambia desde afuera: el componente recibe texts con la forma de su namespace en los textos del
paquete (src/locales/es.js), y ComponentTexts los cambia para todo lo que va adentro (un módulo con su propio
vocabulario):
<ServerTable texts={{count: {records_one: '{{total}} pedido', records_other: '{{total}} pedidos'}}} … />
<ComponentTexts namespace="table" texts={{emptyState: {title: 'Sin pedidos'}}}>
<OrdersModule />
</ComponentTexts>Una opción que la pieza no conoce no se pierde en silencio: mientras se programa, la consola avisa una vez por pieza y opción
(Calcita · ServerTable no conoce la opción «countNoun» y no la usa…). Sirve sobre todo al pasar pantallas de un sistema que
ya existe, donde las opciones traen otros nombres (countNoun es countLabel; el label de la columna de detalle es
ariaLabelOf). En producción no avisa: Vite y Next cambian process.env.NODE_ENV al construir la app, y una página sin
empaquetador se toma como producción.
En un sistema que ya existe
Calcita entra por pedazos en un sistema hecho en PHP, JSP o .NET, sin reescribirlo: cada pedazo es una «isla» de React en
un <div> de la página vieja, con su propio ToolkitProvider.
- Sin router:
createHashNavigation()pone las pantallas después del#(/app.php#/ventas): cambiar de pantalla no recarga, atrás y adelante funcionan y recargar no le pide nada nuevo al servidor viejo. Con la app bajo un prefijo y sin router,createBrowserNavigation({basePath: '/portal'}). - Su tema de MUI tal cual: Calcita lee los colores del tema, con o sin variables CSS y con el prefijo que traiga
(
cssVarPrefix). - Varias islas, cada una con lo suyo: dos islas pueden ir en idiomas y monedas distintas;
useFormatters()escribe con los de su isla. - Un backend que contesta a su manera:
createApiClient({toProblem, mapResponse})convierte sus errores a RFC 9457 (Laravel manda{message, errors}) y desenvuelve sus respuestas ({data: …});onErrorrecibe cada error en un lugar. Una subida con avance (uploadWithProgress) regresa la respuesta tal cual:apiErrorOf(response)da el mismoApiError. - Su marca y su menú: un logo ancho (
wordmark) en lugar del cuadrado y el nombre; entradas del menú con liga a otro sistema (href) o con un ícono ya pintado (icon, por ejemplo de iconify); y las ligas de la cuenta (accountMenuItems) salen también sin sesión, detrás del engrane («Regresar al sistema»). - Su
sxgana: lo que el proyecto pinte consxles gana a los ajustes de Calcita en claro y en oscuro. - Su registro:
addLogSinkmanda cada anotación también a su reporte (Sentry, Datadog, su endpoint). - Mejor no parta el código a mano (
manualChunks): con las pantallas cargadas al abrirse (rutas perezosas), Vite arma solo los archivos que se comparten y lo pesado de una pantalla (la tabla de MUI X, FilePond, formik y yup) baja solo ahí. Medido en un sistema real: con el reparto a mano bajaban 1,171 kB en cada pantalla; sin él, 971 kB, ya con su tabla y su formulario nuevos. Asignar un paquete a un archivo fijo le gana a la carga perezosa y lo baja en todas las pantallas. Si aun así reparte a mano, Calcita va en su propio archivo (junto con lo demás denode_modulesarma un círculo con MUI y la app truena al abrir: «reading 'isValidElementType'») y lo que solo usan algunas pantallas se queda sin asignar:
const LAZY_ONLY = ['filepond', '/@mui/x-', '/nuqs/', '/formik/', '/yup/', '/@stackcraftmx/calcita/dist/DataTable/',
'/@stackcraftmx/calcita/dist/table.js', '/@stackcraftmx/calcita/dist/forms'];
if (LAZY_ONLY.some((part) => id.includes(part))) return undefined; // que siga a la pantalla que lo usa
if (id.includes('@stackcraftmx/calcita')) return 'calcita';const hashNavigation = createHashNavigation(); // una vez, fuera de los componentes
document.querySelectorAll('[data-calcita="sales"]').forEach((element) => {
createRoot(element).render(
<ThemeProvider theme={legacyTheme}>
<ToolkitProvider language={element.lang || 'es'} navigation={hashNavigation}>
<SalesOfTheDay onOpen={() => element.dispatchEvent(new CustomEvent('calcita:open-sales', {bubbles: true}))} />
</ToolkitProvider>
</ThemeProvider>,
);
});Detrás de un proxy con prefijo
Para publicar la app en una ruta y no en un subdominio (miempresa.com/ventas/), el prefijo se da una vez al construir,
ASSET_URL=/ventas, la misma línea del .env del servidor. El proyecto que arma calcita new ya lo trae:
vite.config.js:basecon el prefijo; Vite se lo pone a sus archivos y a las rutas deindex.html;- el router con el mismo prefijo (
createBrowserRouter(routes, {basename})): las pantallas siguen escribiendo sus rutas desde la raíz; - lo de
public/yversion.jsonconimport.meta.env.BASE_URL(una dirección escrita desde la raíz se rompe detrás del proxy) y el manifiesto con rutas relativas; - el servidor: cada dirección bajo el prefijo que no sea un archivo regresa su
index.html.
location /ventas/ {
try_files $uri $uri/ /ventas/index.html;
}La petición de la casa
Todo lo que habla con el servidor pasa por un cliente, armado una vez por proyecto con fetch del navegador (sin axios):
import {createApiClient} from '@stackcraftmx/calcita';
export const api = createApiClient({
baseUrl: '/api/v1',
csrf: {refreshPath: '/auth/csrf-token'}, // Laravel Sanctum: la cookie XSRF-TOKEN y el reintento del 419
onUnauthenticated: () => navigate('/sign-in'), // un 401 a media consulta
});
const orders = await api.get('/orders', {params: {page: 2}, signal});
await api.post('/orders', {customerId: 7});
const {blob, fileName} = await api.download('/orders/export', {onDownloadProgress});- Cada petición lleva el idioma vigente (
Accept-Language): el servidor responde sus mensajes ya traducidos. - Un error es siempre un
ApiError: el problema RFC 9457 del servidor con su mensaje ya traducido, su status, surequestIdpara soporte y los errores por campo de un 422 (los formularios los ponen en su campo). Sin red es la misma forma (status 0); cancelar no es un error. - Cada respuesta le avisa al aviso de versión nueva qué compilación está publicada (header
X-Frontend-Build). - Un Excel que llegó cortado no se entrega.
Si se publica otra versión con la pestaña abierta, la pantalla que no se había abierto pide un archivo que ya no existe.
withChunkRecovery (de /react-router) recarga una vez y RouteErrorScreen dice «Hay una versión nueva» con su botón;
sin router de datos, recoverFromStaleChunk en el .catch() del import y StaleBuildBoundary alrededor de las pantallas.
La cuenta completa: conectar su backend
Entrar (con el segundo paso), «¿Olvidó su contraseña?», elegir una contraseña nueva, Mi perfil (sus datos, seguridad y
preferencias) y la seguridad de la cuenta (llaves de acceso, app autenticadora, códigos de recuperación, sesiones abiertas y
bitácora) vienen hechos en @stackcraftmx/calcita/account. El proyecto solo escribe el conector: las llamadas a su
backend. Pide @simplewebauthn/browser (las llaves de acceso).
import {AccountProvider, ProfileScreen, SignInScreen, createRestAccountConnector} from '@stackcraftmx/calcita/account';
// 1. el conector: si el backend sigue el contrato de abajo, es una línea
const accountConnector = createRestAccountConnector(apiClient);
// 2. el proveedor, por fuera de ToolkitProvider (le pasa la regla de permisos de quien está dentro)
<AccountProvider connector={accountConnector}>
{(canAccess) => <ToolkitProvider canAccess={canAccess} …>…</ToolkitProvider>}
</AccountProvider>
// 3. las pantallas, en sus rutas
{path: '/sign-in', element: <SignInScreen forgotPasswordPath="/forgot-password" logo={<AppLogo />} />}
{path: '/forgot-password', element: <ForgotPasswordScreen signInPath="/sign-in" />}
{path: '/reset-password', element: <ResetPasswordScreen signInPath="/sign-in" />} // el enlace del correo: ?token=…
{path: '/profile', element: <ProfileScreen />}useAccount() da {session, refreshSession, signOut} para la barra y las pantallas propias.
Sin backend todavía: createDemoAccountConnector() de @stackcraftmx/calcita/account-demo (todo en el navegador,
con la verificación en dos pasos funcionando con su teléfono de verdad). Con toQrImageUrl sale el QR de la app; el
proyecto que arma calcita new usa la demostración mientras no tenga VITE_API_URL y el REST en cuanto la tiene (la
demostración ni siquiera viaja en lo construido).
Si el backend es distinto: se cambia la ruta en paths o solo la función que no coincida; TypeScript (o el editor,
con el JSDoc) avisa si una función no regresa lo que piden las pantallas (AccountConnector).
const accountConnector = {
...createRestAccountConnector(apiClient, {paths: {session: '/me'}}),
signIn: async ({email, password, remember}) => {
const response = await apiClient.post('/login', {username: email, password, remember});
return {secondStep: response.mfa_required ? {methods: ['totp']} : null};
},
};El contrato REST (ACCOUNT_REST_PATHS; llaves en snake_case, errores RFC 9457 con errors por campo):
| Petición | Manda | Responde |
|---|---|---|
| GET /auth/session | — | {user: {id, name, email, subtitle, phone}, roles, abilities, labels}; 401 si nadie está dentro |
| POST /auth/sign-in | {email, password, remember} | {second_step: {methods: ['passkey', 'totp', 'recovery_code']} \| null} |
| POST /auth/second-step | {method: 'totp' \| 'recovery_code', code} | 204; 401 con remaining_attempts, 423 account_locked |
| GET /auth/second-step/passkey-options | — | las opciones de WebAuthn para firmar |
| POST /auth/second-step/passkey | {credential} | 204 |
| POST /auth/sign-out | — | 204 |
| POST /auth/forgot-password | {email} | 204, exista o no el correo |
| POST /auth/reset-password | {token, new_password} | 204 |
| PUT /account | {name, phone} | {message?} |
| PUT /account/password | {current_password, new_password} | 204 |
| GET /account/security | — | {is_enabled, enabled_at, passkeys: [{id, name, created_at, last_used_at}], totp: {is_enabled, enabled_at}, recovery_codes_remaining} |
| POST /account/security/totp | — | {secret, qr_image_url} |
| POST /account/security/totp/confirm | {code} | {security, recovery_codes?} |
| DELETE /account/security/totp | {password} | {security} |
| GET /account/security/passkeys/registration-options | — | las opciones de WebAuthn para crear |
| POST /account/security/passkeys | {name, credential} | {security, recovery_codes?} |
| DELETE /account/security/passkeys/{id} | {password} | {security} |
| POST /account/security/recovery-codes | {password} | {security, recovery_codes} |
| GET /account/sessions | — | [{id, browser, platform, is_mobile, started_at, network, is_current}] |
| POST /account/sessions/close-others | — | {closed} |
| GET /account/activity | — | [{id, kind, occurred_at, device, network}] |
Las opciones y la firma de WebAuthn viajan tal cual: las arma y las revisa la librería del servidor (py_webauthn,
@simplewebauthn/server, web-auth/webauthn-lib). Lo que quita protección pide la contraseña; recovery_codes se enseña una
sola vez. kind de la bitácora es uno de ACCOUNT_EVENT_KIND.
Seguridad de la cuenta (las piezas sueltas)
Las mismas piezas de la cuenta completa, para armar pantallas propias:
- En
/forms,TwoStepVerificationCard: llaves de acceso, app autenticadora y códigos de recuperación. Lo que quita protección pide la contraseña. El proyecto conecta las acciones con su API; la tarjeta no sabe del servidor. - En
/forms,SecondStepForm: el segundo paso al entrar (llave, código de la app o de recuperación), con los intentos que quedan. RecoveryCodesDialog: los códigos se enseñan una vez y no se cierra sin «Ya los guardé»; se copian o se bajan en .txt.SessionsCard: dónde está abierta la cuenta y «cerrar la sesión en los demás equipos».ActivityLog: la bitácora.- En
/passkeys,createPasskeyysignWithPasskey: el navegador pide la huella, el rostro o el PIN.
Contra XSS: los textos del servidor se pintan como texto (nunca dangerouslySetInnerHTML), safeInternalPath solo deja
seguir direcciones de la app en ?next=, la sesión va en una cookie HttpOnly (sin tokens en localStorage) y el CSRF con
la cookie doble de Sanctum. Los ejemplos lo enseñan en Componentes → Seguridad, y la verificación funciona con su
teléfono de verdad en Mi perfil → Seguridad.
Entrar, Mi perfil y los permisos (el template)
Lo que piden todos los sistemas, listo para conectar a su API:
AuthLayout: la pantalla de entrar, con el logo, una tarjeta al centro y el idioma y claro u oscuro arriba (con vidrio, la tarjeta es de vidrio sobre el lienzo de color).- En
/forms,SignInForm,ForgotPasswordFormyChangePasswordForm. Los errores del servidor salen en su lugar. «¿Olvidó su contraseña?» no dice si el correo existe, a propósito. La contraseña nueva trae su lista de reglas, que se va palomeando (DEFAULT_PASSWORD_POLICY: 12 caracteres, minúscula, mayúscula y número; cada proyecto pone la suya). ProfileHeader: portada, avatar con iniciales, nombre y perfil.AppearanceSettings: tamaño de letra, idioma y claro u oscuro, los mismos del engrane del teléfono.PermissionMatrix: los permisos de un perfil por grupo, con «todos» y cuántos tiene. Cada módulo declara los suyos en sumenu.jsycomposePermissionGroupslos junta: no hay lista central.MetricCardcontrend: cuánto cambió contra el periodo anterior, en verde si es bueno y en rojo si es malo.
<AuthLayout logo={<Logo />} title={t('auth:signIn.title')} description={t('auth:signIn.subtitle')}>
<SignInForm onSubmit={(values) => api.signIn(values)} forgotPasswordPath="/forgot-password" />
</AuthLayout>
// modules/orders/menu.js
export const permissions = [
{key: 'orders.view', labelKey: 'orders:permissions.view', descriptionKey: 'orders:permissions.viewDescription'},
{key: 'orders.cancel', labelKey: 'orders:permissions.cancel'},
];
// la pantalla de perfiles
const groups = composePermissionGroups(import.meta.glob('@/modules/*/menu.js', {eager: true}), t);
<PermissionMatrix groups={groups} value={profile.abilities} onChange={setAbilities} />Los ejemplos lo traen armado: entrar (con «Entrar como…» para los tres perfiles de prueba), «¿olvidó su contraseña?», elegir una contraseña nueva, Mi perfil (sus datos, seguridad y preferencias), Perfiles y permisos (al guardar los de su propio perfil, el menú cambia en ese momento) y un tablero con tarjetas, gráficas y los últimos pedidos.
Next.js (App Router)
El paquete ya viene marcado para Next: los componentes llevan 'use client' y lo puro (formatos, Money, permisos,
menú, diccionarios) no lleva marca, así que también corre en los componentes de servidor. Lo que se conecta:
// app/layout.jsx (servidor)
<html lang="es" suppressHydrationWarning>
<body>
<InitColorSchemeScript attribute="class" /> {/* claro u oscuro antes de pintar, sin brinco */}
<AppRouterCacheProvider> {/* @mui/material-nextjs/v16-appRouter */}
<AppProviders>{children}</AppProviders> {/* 'use client': tema, ToolkitProvider y AppLayout */}
</AppRouterCacheProvider>
</body>
</html>
// AppProviders.jsx ('use client')
<ToolkitProvider navigation={nextNavigation} …> {/* de @stackcraftmx/calcita/next */}- El servidor calcula con las piezas puras y le pasa TEXTO al cliente (
money.toJSON()): lo que cruza del servidor al navegador tiene que ser JSON. - Zona y moneda del servidor con
configureToolkit({timeZone, currency})una vez (son de toda la app; sin eso, en un contenedor la zona es UTC). El idioma NO: cambia por persona y vive en el cliente. - Los
menu.jsse descubren conrequire.context(Next no tieneimport.meta.glob). - El «Regresar» de
BackButtonno sabe a dónde regresa (Next no guarda el índice del historial que pone react-router): regresa a la última lista anotada o afallbackTo.
Formularios
Cada campo tiene su vista suelta en la puerta principal (TextInput, SelectInput, NumberInput, CatalogInput,
ColorInput; sirven sin formulario; SelectInput y CatalogInput aceptan varias opciones con multiple) y su conexión: a formik en /forms y a react-hook-form en /react-hook-form, con
los mismos nombres. Cambiar de librería es cambiar el import.
import {Form, Formik, FormNumberField, FormProblemAlert, FormTextField, SubmitButton, Yup, applyServerProblem} from '@stackcraftmx/calcita/forms';
<Formik initialValues={{name: '', credit_limit: ''}} validationSchema={schema}
onSubmit={async (values, helpers) => {
try { await api.save(values); } catch (error) { applyServerProblem(error, {...helpers, values}); }
}}>
<Form noValidate>
<FormProblemAlert />
<FormTextField name="name" label="Nombre" />
<FormNumberField name="credit_limit" label="Límite de crédito" prefix="$" /> {/* viaja '50000.00' */}
<SubmitButton>Guardar</SubmitButton>
</Form>
</Formik>formik y yup los instala el proyecto (pnpm add formik yup); Formik, Form y Yup también se pueden importar de
@stackcraftmx/calcita/forms, que es la misma copia. FormWizard parte un formulario en pasos, con pasos opcionales (hay
uno para formik y otro para react-hook-form); los ejemplos traen los dos.
Para que el formulario se vea como los de Apple (Ajustes, la hoja «Nuevo evento» de Calendario), los campos van dentro de
una FormSection: cada uno se vuelve un renglón, con la etiqueta a la izquierda y el valor a la derecha, sin la caja de
MUI. Son los mismos campos, sin cambiarles nada; las listas llevan su ⌃⌄ y la fecha va en una cápsula. FormRow es el
renglón suelto para lo demás (un interruptor, un texto).
import {FormRow, FormSection} from '@stackcraftmx/calcita';
<FormSection title="Contacto" footer="Con el correo le avisamos de cada pedido.">
<FormTextField name="name" label="Nombre" placeholder="Nombre y apellidos" />
<FormTextField name="email" label="Correo" type="email" />
<FormRow label="Avisos por correo"><Switch /></FormRow>
</FormSection>Los campos van directo dentro de la FormSection (sin Grid ni Stack en medio): la línea entre renglones se dibuja entre
hermanos.
Subir archivos
Dos zonas para soltar archivos con el mismo uso: la de la puerta principal, con puro MUI (un archivo), y la de
/filepond (varios archivos, animaciones, avance por archivo). Cambiar de una a otra es cambiar el import. Las dos solo
eligen; la pantalla sube con uploadWithProgress (el avance real de la subida) y sigue la subida con uploadMachine.
import {FileDropzone, buildUploadActors, uploadMachine, uploadWithProgress} from '@stackcraftmx/calcita';
<FileDropzone value={file} onChange={setFile} accept="excel" maxSize="10 MB" busy={isUploading}
actions={<Button onClick={() => send({type: 'UPLOAD', input: {file}})}>Importar</Button>} />accept acepta nombres ('excel', ['pdf', 'image']) o lo de siempre del input ('.xlsx,.xls'); maxSize va en bytes
o con unidades ('10 MB', en base 1000 como el navegador).
Dinero sin problemas de decimales
En JavaScript 0.1 + 0.2 da 0.30000000000000004. Money guarda los montos en centavos enteros (como el Money de PHP
con bcmath y el de Go con int64), así que las sumas siempre cuadran al centavo:
import {MONEY_ROUNDING, Money} from '@stackcraftmx/calcita/money';
const total = Money.fromString(order.total).plus(Money.fromNumber(order.shipping)); // del API, en texto o número
const withTax = total.times('1.16'); // redondea una vez, al centavo
const perPayment = total.dividedBy(3, {rounding: MONEY_ROUNDING.halfEven}); // redondeo bancario
total.format(); // "$1,234.50", en el idioma y la moneda de ToolkitProvider
total.amount(); // "1234.50", lo que se le manda al APILos decimales salen de la moneda (MXN 2, JPY 0, KWD 3) y se pueden cambiar con {currency, decimals}. Truena en
vez de perder centavos: un texto que no es número, decimales de más, mezclar monedas o usar + y > en lugar de
plus e isGreaterThan.
Desarrollo del paquete
Se trabaja con Node 22 y pnpm, las dos versiones fijadas en el paquete: nvm use toma la de .nvmrc, Volta la toma
sola de package.json, y Corepack (viene con Node 22) baja la versión de pnpm que dice packageManager.
nvm use
pnpm install
pnpm test # pruebas sin navegador (node --test)
pnpm run build # dist/ con un archivo por módulo y sus tipos en dist/types
pnpm run check # pruebas, build, cada componente pintado con el tema por omisión de MUI, el CLI, un proyecto en TypeScript y los ejemplos
pnpm run examples # construye el paquete y abre los ejemplos sin backend en http://localhost:5173Los ejemplos (examples/) usan el paquete ya construido, como un proyecto nuevo: tabla de servidor, formulario, avisos, descargas y Excel, y métricas, con datos inventados en memoria. No viajan a npm.
Probar sin instalar nada
Con Docker, los ejemplos se construyen y se sirven en una imagen; no hace falta Node ni pnpm:
docker compose up -d examples # construye y arranca: http://localhost:8095
docker compose down # lo apagaEs la versión de producción (archivos construidos, servidos por nginx sin permisos de administrador), no el servidor de
desarrollo. EXAMPLES_PORT=8100 docker compose up -d examples cambia el puerto.
Enseñarlo por internet (túnel de Cloudflare)
scripts/tunnel.sh publica los ejemplos con un túnel de Cloudflare. El token va en tunnel/.cloudflared.token (una
línea, fuera de git) y la dirección pública y su destino se configuran en el panel de Cloudflare; el destino es el puerto
de Docker (http://localhost:8095), no el servidor de desarrollo, que se rompe cada vez que cambian sus dependencias.
pnpm run tunnel:start # arranca y dice la dirección pública y a dónde apunta
pnpm run tunnel:status # ¿corre? y las dos direcciones responden
pnpm run tunnel:stop # lo apaga (solo el suyo, nunca el cloudflared de otro proyecto)Licencia
FSL-1.1-MIT (Functional Source License): se puede usar, copiar, modificar y poner en producción gratis, en cualquier proyecto y empresa, también comercial. Lo único que no se permite es ofrecer Calcita (o algo que la sustituya) como producto o servicio comercial que compita con ella. Dos años después de publicada, cada versión pasa sola a MIT.
