npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

bonsaif-ui

v0.1.61

Published

Reusable Bonsaif UI components for Bonsaif applications.

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-ui
import { 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:preview

The 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 when true.
  • 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 before onClose runs 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:

  1. The consuming screen owns the saved value and draft value.
  2. The screen passes hasUnsavedChanges={true} when the draft differs from the saved value.
  3. Drawer close requests from backdrop click, Escape, the X button, and the default Cancel button are intercepted.
  4. The discard modal opens with a fade plus scale/slide animation.
  5. Choosing Seguir editando closes only the modal and keeps the drawer open.
  6. Choosing the discard action calls onDiscardChanges first, then calls onClose.
  7. If hasUnsavedChanges is false, close actions call onClose immediately.

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-ui owns 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 reaches tabsSearchThreshold, and the input expands inline before the close icon.
  • Tab search matches label, generated service label, description, and service. 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"] in next.config.ts.

Scripts

npm run dev
npm run build
npm run lint

Structure

src/
  components/
    layout/
    messaging/
    microservices/
    notifications/
    BonsaifButton/
      BonsaifButton.tsx
      index.ts
    ui/
    users/
  lib/
    cn.ts
  index.ts
  styles.css