bonsaif-ui
v0.1.61
Published
Reusable Bonsaif UI components for Bonsaif applications.
Maintainers
Readme
bonsaif-ui
Reusable React components for Bonsaif applications. The package focuses on internal product screens: dense forms, admin tables, status feedback, drawers, page headers, and service composition patterns.
Start Here
Install the package, import the stylesheet once in the app shell, and then import only the components needed by each screen.
npm install bonsaif-uiimport { Button, FileUpload, PageHeader } from "bonsaif-ui";
import "bonsaif-ui/styles.css";
export function Example() {
return (
<>
<PageHeader title="Documentos" currentPath="/admin/documents" />
<FileUpload
label="Adjuntos"
multiple
onFilesChange={(files) => console.log(files)}
/>
<Button>Guardar</Button>
</>
);
}For Next.js consumers using a local package or monorepo setup, add transpilePackages: ["bonsaif-ui"] in next.config.ts.
Choose A Component
| Need | Start with | Why |
| --- | --- | --- |
| Build an admin page header | PageHeader | Breadcrumbs, metadata, page actions, and compact action buttons. |
| Capture form data | InputField, EmailField, PhoneField, NumberField, FileUpload | Shared labels, helpers, errors, focus states, and validation surfaces. |
| Search or filter a list | SearchableSelect, CompactFilterSelect, CompactSearchInput | Compact controls that work inside toolbars and table headers. |
| Show tabular records | DataTable, Pagination | Sorting, selection, loading, empty state, column width behavior, and paging. |
| Confirm or edit in context | Drawer, Modal, ConfirmDialog | Overlays with consistent chrome and guardrails for unsaved changes. |
| Explain status | Alert, StatusBadge, StatusSelect, EmptyState, LoadingState | Clear operational feedback for data states and workflows. |
| Compose related modules | DynamicDetailTabs | Parent-detail layout with lazy loaded child modules. |
Documentation App
Use the local Next.js documentation app for component documentation and full-screen integration examples. The app consumes bonsaif-ui through the local package entrypoint, so the examples use the same exports that consuming products use.
npm run docs
npm run docs:build
npm run docs:previewThe documentation includes isolated states such as disabled, error, loading, single-file upload, multi-file upload, overlays, validation, shells, tables, composition patterns, and dark mode in one app-like screen.
Component Map
The package includes reusable components migrated from prime-auth and new shared primitives for Bonsaif apps.
Layout
AuthShell,PrimeAuthRouteShell: authenticated application shells.PageHeader,PageSearchInput: page title, breadcrumbs, metadata, actions, and page-level search.GlobalScrollbars: global OverlayScrollbars setup.SectionPanel: reusable section container with header, actions, body, and footer.
PageHeader Breadcrumbs
PageHeader can build local breadcrumbs from currentPath or render custom breadcrumbs. Those breadcrumbs are intended for standalone screens and development views.
When a page is opened through Prime Auth SSO, MicroserviceFrame passes primeAuthOrigin and primeAuthProjectKey to the embedded app. PageHeader detects those parameters, stores the SSO state for the browser session, and hides its local breadcrumbs so the Prime Auth shell remains the single source for the global breadcrumb trail.
Use showBreadcrumbs={false} when a standalone page should also hide the local breadcrumb.
<PageHeader
title="Usuarios"
currentPath="/admin/users"
showBreadcrumbs={false}
/>Forms
InputField,EmailField,PhoneField,NumberField,TextInput,Textarea: inputs with Bonsaif styling and inline validation for email and phone values.FileUpload: drag-and-drop or click-to-browse file selection with single-file and multi-file modes.SearchableSelect,CompactFilterSelect,CompactSearchInput: select and filter controls.CalendarPicker,DateRangePicker: single-date and range selection.Checkbox,RadioGroup,Toggle: boolean and option controls.FormField: label, helper, error, and required wrapper for custom controls.
File Uploads
Use FileUpload when a form needs attachments, CSV imports, evidence files, or any file selector that should support both drag-and-drop and regular browsing.
import { useState } from "react";
import { FileUpload } from "bonsaif-ui";
export function AttachmentField() {
const [files, setFiles] = useState<File[]>([]);
return (
<FileUpload
label="Adjuntos"
multiple
appendFiles
maxFiles={4}
accept=".pdf,.png,.jpg,.jpeg,.csv"
value={files}
onFilesChange={setFiles}
helperText="PDF, imagenes o CSV. Maximo 4 archivos."
onRejectedFiles={(rejectedFiles) => {
console.warn("Archivos rechazados", rejectedFiles);
}}
/>
);
}Useful props:
| Prop | Description |
| --- | --- |
| multiple | Allows selecting or dropping more than one file. |
| accept | Native accept filter plus drag-and-drop filtering for MIME types and extensions. |
| maxFiles | Limits the accepted file list. |
| appendFiles | Adds new files to the current selection instead of replacing it. |
| value / defaultValue | Controlled or uncontrolled selected files. |
| onFilesChange | Receives the accepted File[]. |
| onRejectedFiles | Receives files blocked by accept. |
| showFileList | Shows or hides the selected file list. |
Form Validation
Use validateForm with reusable validators to validate complete forms before saving. Validators run in order and return the first error per field. Add noValidate to the form to avoid native browser validation, then pass each error to the field with errorDisplay="tooltip" when the message should appear in the compact validation tooltip.
Use useValidationVisibility when a form should validate immediately for save-button state but should not show red errors until the user touches a field or attempts to submit. Call submit() before blocking an invalid save, touchField(field) from onChange/onBlur, and reset() when opening a new draft.
import { useMemo, useState, type FormEvent } from "react";
import {
EmailField,
PhoneField,
emailFormat,
maxValue,
minLength,
minValue,
phoneFormat,
required,
validateForm,
useValidationVisibility,
type ValidationSchema,
} from "bonsaif-ui";
type AccountForm = {
contactName: string;
contactEmail: string;
contactCountryCode: string;
contactPhone: string;
seats: string;
discountCode: string;
};
const schema = {
contactName: [required("Escribe el nombre."), minLength(3)],
contactEmail: [required(), emailFormat()],
contactCountryCode: [required()],
contactPhone: (value, values) =>
phoneFormat()(`${values.contactCountryCode} ${value}`),
seats: [required(), minValue(1), maxValue(500)],
discountCode: (value, values) =>
Number(values.seats) > 100 && String(value).trim() === ""
? "Solicita un codigo para cuentas de mas de 100 usuarios."
: undefined,
} satisfies ValidationSchema<AccountForm>;
function AccountFormExample() {
const [form, setForm] = useState<AccountForm>({
contactName: "",
contactEmail: "",
contactCountryCode: "+1",
contactPhone: "",
seats: "",
discountCode: "",
});
const validationResult = useMemo(
() => validateForm(form, schema),
[form],
);
const errors = validationResult.errors;
const validationVisibility = useValidationVisibility(errors);
function submitForm(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
validationVisibility.submit();
if (!validationResult.valid) return;
saveAccount(form);
validationVisibility.reset();
}
return (
<form noValidate onSubmit={submitForm}>
<EmailField
label="Correo"
value={form.contactEmail}
error={validationVisibility.getFieldError("contactEmail")}
validate={false}
errorDisplay="tooltip"
onChange={(event) => {
validationVisibility.touchField("contactEmail");
setForm((current) => ({
...current,
contactEmail: event.currentTarget.value,
}));
}}
onBlur={() => validationVisibility.touchField("contactEmail")}
/>
<PhoneField
label="Telefono"
countryCode={form.contactCountryCode}
onCountryCodeChange={(countryCode) =>
setForm((current) => ({ ...current, contactCountryCode: countryCode }))
}
allowedCountryCodes={["+1", "+34", "+52", "+57", "+58"]}
value={form.contactPhone}
onChange={(event) => {
validationVisibility.touchField("contactPhone");
setForm((current) => ({
...current,
contactPhone: event.currentTarget.value,
}));
}}
onBlur={() => validationVisibility.touchField("contactPhone")}
error={validationVisibility.getFieldError("contactPhone")}
validate={false}
errorDisplay="tooltip"
/>
<button type="submit" disabled={!validationResult.valid}>
Guardar
</button>
</form>
);
}Actions And Overlays
Button,BonsaifButton: action buttons.DropdownMenu: contextual action menu.CopyButton,CopyLinkButton: clipboard utilities.Modal,Drawer,ConfirmDialog: overlays and confirmations.MouseTooltip: lightweight hover tooltip.
Drawer Unsaved Changes Guard
Use hasUnsavedChanges when a drawer edits data and must not close silently. When the prop is true, the drawer intercepts its own close controls: backdrop click, Escape, the X button, and the default Cancel button. It shows a confirmation modal and only calls onClose after the user confirms that unsaved data can be discarded.
The guard is meant for local draft forms. Keep the saved value separate from the draft value, calculate whether the draft is dirty, and pass that boolean to hasUnsavedChanges.
import { useState } from "react";
import { Drawer, Textarea } from "bonsaif-ui";
export function TeamNotesDrawer() {
const [open, setOpen] = useState(false);
const [savedNote, setSavedNote] = useState("Nota guardada del equipo.");
const [draftNote, setDraftNote] = useState(savedNote);
const hasUnsavedChanges = draftNote !== savedNote;
function openDrawer() {
setDraftNote(savedNote);
setOpen(true);
}
function saveDrawer() {
setSavedNote(draftNote);
setOpen(false);
}
return (
<>
<button type="button" onClick={openDrawer}>
Editar nota
</button>
<Drawer
open={open}
onClose={() => setOpen(false)}
title="Editar equipo"
onConfirm={saveDrawer}
hasUnsavedChanges={hasUnsavedChanges}
unsavedChangesTitle="Descartar cambios"
unsavedChangesDescription="Hay cambios sin guardar. Si cierras el drawer se perderan."
unsavedChangesConfirmLabel="Descartar"
unsavedChangesCancelLabel="Seguir editando"
onDiscardChanges={() => setDraftNote(savedNote)}
>
<Textarea
value={draftNote}
onChange={(event) => setDraftNote(event.currentTarget.value)}
className="min-h-28 w-full"
/>
</Drawer>
</>
);
}onDiscardChanges can be used to clear local draft state right before onClose runs.
Useful props:
hasUnsavedChanges: enables the discard confirmation whentrue.unsavedChangesTitle: confirmation title.unsavedChangesDescription: confirmation body copy.unsavedChangesConfirmLabel: label for the destructive discard action.unsavedChangesCancelLabel: label for the action that keeps editing.onDiscardChanges: optional cleanup beforeonCloseruns after discard confirmation.
The documentation app includes a live drawer example: edit the note, then try closing with the X, backdrop, Escape, or Cancel button to see the discard modal.
How it works:
- The consuming screen owns the saved value and draft value.
- The screen passes
hasUnsavedChanges={true}when the draft differs from the saved value. - Drawer close requests from backdrop click, Escape, the X button, and the default Cancel button are intercepted.
- The discard modal opens with a fade plus scale/slide animation.
- Choosing
Seguir editandocloses only the modal and keeps the drawer open. - Choosing the discard action calls
onDiscardChangesfirst, then callsonClose. - If
hasUnsavedChangesisfalse, close actions callonCloseimmediately.
Modal Backdrop Close
Use closeOnBackdrop={false} when a modal must stay open after clicking outside the panel. The close button and Escape behavior remain controlled separately through showCloseButton and closeOnEscape.
<Modal
open={open}
onClose={() => setOpen(false)}
title="Editar registro"
closeOnBackdrop={false}
>
<AccountForm />
</Modal>ConfirmDialog accepts the same prop when a confirmation should only close through the explicit cancel or confirm actions.
<ConfirmDialog
open={confirmOpen}
title="Confirmar accion"
description="Esta accion requiere una decision explicita."
closeOnBackdrop={false}
onConfirm={confirmAction}
onCancel={() => setConfirmOpen(false)}
/>Feedback And Data
Alert,StatusBadge,EmptyState,LoadingState: visible states and feedback.DataTable,Pagination: tabular data and standalone pagination.Tabs,StepWizard: local navigation and guided flows.
Composition And Services
DynamicDetailTabs: parent-detail layout with lazy loaded child modules.MicroserviceFrame,PrimeSuiteChatFrame: embedded service frames.BonsaifUIToaster: Sonner toaster configured for Bonsaif themes.UserAvatar: user and bot avatars with tooltip support.
App-coupled components were adapted to receive routing, session, and navigation data through props so the library does not import private prime-auth modules.
UI Primitives
Small primitives cover common product UI patterns while keeping the same Bonsaif visual tokens.
import {
Alert,
EmptyState,
SectionPanel,
StatusBadge,
Tabs,
} from "bonsaif-ui";
<StatusBadge tone="success" dot>
Activo
</StatusBadge>
<Alert variant="warning" title="Revision pendiente">
Hay cambios sin publicar.
</Alert>
<SectionPanel title="Actividad" actions={<Button>Revisar</Button>}>
<Tabs activeTabId={tab} onTabChange={setTab} tabs={tabs} />
</SectionPanel>
<EmptyState title="Sin registros" action={<Button>Crear</Button>} />Copy Buttons
CopyButton copies any text value. CopyLinkButton copies URLs and accepts resolveUrl when an app needs to convert relative paths into public absolute URLs.
import { CopyButton, CopyLinkButton } from "bonsaif-ui";
<CopyButton value="Texto para copiar" title="Copiar texto" />
<CopyLinkButton
url="/tickets/123"
resolveUrl={(url) => new URL(url, window.location.origin).toString()}
/>DataTable
DataTable owns its horizontal and vertical scrolling. When a table is rendered inside a constrained panel, pass minTableWidth so the table keeps usable column widths and exposes its own internal horizontal scrollbar.
When pagination is enabled with pageSize, the pagination footer is hidden automatically if the row count fits in a single page. For example, a table with pageSize={25} and 25 or fewer rows shows no pagination controls; once it has 26 rows, the footer appears.
Header controls for column reordering, resizing, and visibility are floating controls. The drag handle and column visibility menu animate in on hover/focus and do not consume label width, so narrow headers keep as much readable text as possible.
DataTable renders its rows with CSS Grid instead of the browser's native table layout. Column resizing uses the saved column widths as the base grid tracks, fills any spare container width across visible columns, and switches to horizontal scrolling when the resized columns exceed the available space. This keeps resize behavior predictable without leaving a blank gutter on the right side.
DataTable can also expose a table query state for server-backed screens. Enable enableBql to show a compact BQL input in the toolbar, listen to onQueryChange to receive the current BQL text, column filters, sort, page, page size, and hidden columns, and use persistState with storageKey when a screen should reopen with saved filters or BQL. The component only emits and stores query state; each app should validate and translate BQL on the server for its own allowed fields.
By default, the page-size selector includes 25, 50, 100, 200, and 500 rows.
You can override those defaults globally with configureBonsaifUI (used when a table does not pass pageSizeOptions directly):
import { configureBonsaifUI } from "bonsaif-ui";
configureBonsaifUI({
dataTable: {
pageSizeOptions: [20, 40, 80, 160, 320],
},
});<DataTable
columns={columns}
data={rows}
getRowId={(row) => row.id}
minTableWidth={810}
/>
<DataTable
columns={columns}
data={rows}
getRowId={(row) => row.id}
storageKey="prime-auth-users-v5"
enableBql
persistState={{
layout: true,
filters: true,
sort: true,
bql: true,
pagination: true,
}}
onQueryChange={(query) => {
// Send query.bql, query.filters, query.conditionFilters, and query.sort to the API.
}}
/>
<DataTable
columns={columns}
data={rows.slice(0, 25)}
getRowId={(row) => row.id}
pageSize={25}
/>// No pagination footer: all rows fit in one page.
<DataTable
columns={columns}
data={rows.slice(0, 25)}
getRowId={(row) => row.id}
pageSize={25}
/>
// Pagination footer appears: row 26 needs another page.
<DataTable
columns={columns}
data={rows.slice(0, 26)}
getRowId={(row) => row.id}
pageSize={25}
/>
// Server-side mode uses totalRows for the same decision.
<DataTable
columns={columns}
data={rows}
getRowId={(row) => row.id}
page={1}
pageSize={25}
totalRows={26}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>StepWizard
StepWizard renders animated, gated steps. A future step stays locked until every previous non-optional step is marked as completed.
import { Button, StepWizard } from "bonsaif-ui";
<StepWizard
steps={[
{
id: "method",
title: "Metodo de carga",
description: "Cliente, plantilla y archivo",
content: (wizard) => (
<Button onClick={() => wizard.completeStep()}>
Marcar completado
</Button>
),
},
{
id: "sms",
title: "Configuracion de SMS",
content: (wizard) => (
<Button onClick={() => wizard.completeStep()}>
Marcar completado
</Button>
),
},
{
id: "summary",
title: "Resumen",
content: <Summary />,
},
]}
onBlockedStep={(step, requiredStep) => {
toast.error(`Completa ${requiredStep.title} antes de abrir ${step.title}.`);
}}
/>The component can be controlled with activeStepId, completedStepIds, onActiveStepChange, and onCompletedStepIdsChange.
DynamicDetailTabs
DynamicDetailTabs renders a parent-detail panel next to a tabbed workspace. Each tab is declared with a service id and loaded lazily through a registry owned by the consuming app.
Use this component when a main entity detail needs to host multiple independent modules, such as related lists, API keys, settings, contacts, billing records, or any child feature that owns its own list/detail flow.
Basic Setup
import {
DynamicDetailTabs,
type DynamicDetailModuleProps,
type DynamicDetailModuleRegistry,
type DynamicDetailParentPanelProps,
} from "bonsaif-ui";
type ClientContext = {
clientId: string;
permissions: string[];
};
const moduleRegistry: DynamicDetailModuleRegistry<ClientContext> = {
"views/settings/lv_server_by_client": () =>
import("@/modules/settings/ServerByClientModule"),
"views/settings/lv_account_executive": () =>
import("@/modules/settings/AccountExecutiveModule"),
"views/api_keys/lv_api_keys": () =>
import("@/modules/api-keys/ApiKeysModule"),
};
function ClientSummaryPanel({
parent,
context,
selectedLink,
}: DynamicDetailParentPanelProps<ClientContext, Client>) {
return (
<aside>
<h2>{parent.name}</h2>
<p>Cliente: {context.clientId}</p>
<p>Vista activa: {selectedLink?.label ?? "Sin vista"}</p>
</aside>
);
}
export function ClientDetailPage({ client }: { client: Client }) {
return (
<DynamicDetailTabs
title="Modulos relacionados"
subtitle="Listados y detalles cargados de forma independiente"
parent={client}
context={{ clientId: client._id, permissions: client.permissions }}
parentPanelComponent={ClientSummaryPanel}
links={[
{
service: "views/settings/lv_server_by_client",
label: "Servidores",
},
{
service: "views/settings/lv_account_executive",
label: "Ejecutivos",
},
{
service: "views/api_keys/lv_api_keys",
label: "API Keys",
requiredPermission: "api-keys:view",
},
]}
moduleRegistry={moduleRegistry}
searchableTabs="auto"
tabsSearchThreshold={6}
tabsSearchPlaceholder="Buscar modulo..."
canAccess={(link, context) => {
if (!link.requiredPermission) return true;
const permissions = Array.isArray(link.requiredPermission)
? link.requiredPermission
: [link.requiredPermission];
return permissions.every((permission) =>
context.permissions.includes(permission),
);
}}
/>
);
}Parent Panel Contract
The left panel can be static JSX with parentPanel, a reusable component with parentPanelComponent, or a render callback with renderParentPanel.
import { useState } from "react";
import { Button, Drawer, type DynamicDetailParentPanelProps } from "bonsaif-ui";
function ClientSummaryPanel({
parent,
context,
selectedLink,
visibleLinks,
onSelectService,
}: DynamicDetailParentPanelProps<ClientContext, Client>) {
const [editing, setEditing] = useState(false);
return (
<>
<section>
<h2>{parent.name}</h2>
<p>{context.clientId}</p>
<p>{selectedLink?.label}</p>
<Button onClick={() => setEditing(true)}>Editar</Button>
<button onClick={() => onSelectService(visibleLinks[0])}>
Ir a primera vista
</button>
</section>
<Drawer open={editing} onClose={() => setEditing(false)} title="Editar cliente">
<ClientEditForm client={parent} onSaved={() => setEditing(false)} />
</Drawer>
</>
);
}
<DynamicDetailTabs
parent={client}
context={context}
links={links}
moduleRegistry={moduleRegistry}
parentPanelComponent={ClientSummaryPanel}
/>Child Module Contract
Each module should export a default React component. The module receives the shared parent context and navigation helpers.
import { Button, DataTable, type DynamicDetailModuleProps } from "bonsaif-ui";
type ClientContext = {
clientId: string;
permissions: string[];
};
export default function ApiKeysModule({
context,
activeItemId,
onOpenDetail,
onBackToList,
}: DynamicDetailModuleProps<ClientContext>) {
if (activeItemId) {
return (
<section>
<Button variant="secondary" onClick={onBackToList}>
Volver al listado
</Button>
<ApiKeyDetail clientId={context.clientId} apiKeyId={activeItemId} />
</section>
);
}
return <ApiKeysList clientId={context.clientId} onOpen={onOpenDetail} />;
}Props
| Prop | Description |
| --- | --- |
| parentPanel | Static React content rendered in the left parent-detail panel. |
| parentPanelComponent | Reusable component rendered in the left panel with workspace props. |
| renderParentPanel | Render callback alternative for fully custom left-panel composition. |
| context | Shared context passed to every loaded module. |
| parent | Optional parent object passed to every module. |
| links | Declarative tab config. Each item needs at least service. |
| moduleRegistry | Map of service ids to lazy import functions. |
| loadModule | Optional fallback loader when a service is not in moduleRegistry. |
| canAccess | Optional permission/filter callback for hiding tabs. |
| activeService | Controlled active tab service id. |
| initialService | Initial uncontrolled active tab service id. |
| onActiveServiceChange | Callback fired when the user selects a tab. |
| keepAlive | Keeps loaded module components cached. Defaults to true. |
| loadingState | Optional custom loading UI. |
| errorState | Optional render function for module loading errors. |
| searchableTabs | Enables the tab search box. Use true, false, or "auto". Defaults to "auto". |
| tabsSearchThreshold | Minimum visible tab count before automatic search appears. Defaults to 6. |
| tabsSearchPlaceholder | Placeholder text for the tab search input. |
| tabsSearchValue | Optional controlled search value. |
| onTabsSearchChange | Callback fired when the tab search changes. |
| emptySearchState | Optional custom UI when the search returns no tabs. |
| showTabCount | Shows filtered / total tab count. Defaults to true. |
Link Shape
type DynamicDetailTabLink = {
service: string;
label?: string;
description?: string;
icon?: React.ReactNode;
badge?: React.ReactNode;
disabled?: boolean;
hidden?: boolean;
requiredPermission?: string | string[];
};Notes
- The consuming app owns business logic, API calls, permissions, and actual module imports.
bonsaif-uiowns only the layout, tabs, lazy loading state, error state, and list/detail navigation helpers.- When there are many tabs, the component keeps horizontal scrolling and can show an animated header search. With
searchableTabs="auto", the search icon appears when the visible tab count reachestabsSearchThreshold, and the input expands inline before the close icon. - Tab search matches
label, generated service label,description, andservice. Arrow keys, Home, and End navigate between currently filtered tabs. - Prefer static registry keys over arbitrary backend-provided import paths. If links come from a backend, map those service ids to known registry entries in the frontend.
- For Next.js consumers using the local package or monorepo setup, add
transpilePackages: ["bonsaif-ui"]innext.config.ts.
Scripts
npm run dev
npm run build
npm run lintStructure
src/
components/
layout/
messaging/
microservices/
notifications/
BonsaifButton/
BonsaifButton.tsx
index.ts
ui/
users/
lib/
cn.ts
index.ts
styles.css