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

@simplix-react/ui

v0.3.8

Published

CRUD scaffolding UI components with explicit field components and compound patterns

Readme

@simplix-react/ui

CRUD scaffolding UI components with explicit field components and compound patterns.

Overview

@simplix-react/ui provides a headless-friendly, Tailwind CSS-based component library for building CRUD interfaces. Every component follows the No Magic principle: explicit value/onChange props, no hidden state, and full control at every level.

Quick Start

import {
  UIProvider,
  CrudForm,
  FormFields,
  CrudDetail,
  DetailFields,
  List,
  useCrudList,
} from "@simplix-react/ui";

function App() {
  return (
    <UIProvider>
      {/* Your CRUD pages */}
    </UIProvider>
  );
}

Styles & Theme

Component CSS ships values only as var(--*) references. Import the stylesheet plus the official token theme from your app's CSS entry:

@import "tailwindcss";
@import "@simplix-react/ui/styles.css";   /* component styles */
@import "@simplix-react/ui/theme.css";    /* official design tokens (opt-in) */

@source "../node_modules/@simplix-react/ui/dist/**/*.js";

theme.css defines the design tokens (light/dark, color variants, radius, typography, status indicator animations) consumed by the components. Apps may override any token by redefining it after the import. Projects scaffolded via simplix init wire this up automatically.

App Architecture (FSD)

simplix-react apps follow Feature-Sliced Design (FSD) — UI code lives in modules, not in domain packages:

my-app/
├── packages/                     Domain packages (generated) — NO UI code
│   ├── user/
│   │   ├── schemas.ts                Zod schemas
│   │   ├── contract.ts               API contracts
│   │   ├── hooks.ts                  React Query hooks
│   │   └── form-hooks.ts             TanStack Form hooks
│   └── product/
│       └── ...
│
├── modules/                      FSD modules — compose domain packages + @simplix-react/ui
│   └── myapp-admin/
│       └── src/
│           ├── manifest.ts           Module manifest (navigation, capabilities)
│           ├── index.ts              Root exports
│           ├── features/             Feature layer — CRUD screens
│           │   ├── user/                 uses @myapp/user hooks
│           │   │   ├── user-list.tsx
│           │   │   ├── user-form.tsx
│           │   │   ├── user-detail.tsx
│           │   │   └── index.ts
│           │   └── product/              uses @myapp/product hooks
│           │       └── ...
│           ├── widgets/              Widget layer — compose multiple features
│           │   └── dashboard/
│           ├── shared/               Shared layer
│           │   ├── ui/
│           │   ├── lib/
│           │   └── config/
│           └── locales/              i18n (optional)
│
└── app/                          Final app — composes modules only
    └── src/
        ├── features/
        ├── widgets/
        └── shared/

FSD Layer Rules

| Layer | Can import from | Cannot import from | | --- | --- | --- | | shared/ | external packages only | features/, widgets/ | | features/ | shared/, domain packages | widgets/ | | widgets/ | shared/, features/, domain packages | — |

Module can compose multiple domain packages

A single module can contain CRUD features from different domain packages:

// modules/myapp-admin/src/features/user/user-list.tsx
import { useUserHooks } from "@myapp/user";   // domain package A

// modules/myapp-admin/src/features/product/product-list.tsx
import { useProductHooks } from "@myapp/product"; // domain package B

CLI Scaffold

# Generate CRUD feature into a module's features/ layer
simplix scaffold user --module myapp-admin

# Output: modules/myapp-admin/src/features/user/
#   user-list.tsx, user-form.tsx, user-detail.tsx, index.ts

Design Principles

No Magic

Every field component requires explicit value and onChange props. There is no hidden form state, no automatic binding, and no implicit context dependencies.

// Explicit value/onChange — always
<FormFields.TextField
  label="Name"
  value={name}
  onChange={setName}
  required
/>

Compound Components

CRUD layouts (List, CrudForm, CrudDetail) use the compound component pattern. Assemble only what you need:

<List>
  <List.Toolbar>
    <List.Search value={search} onChange={setSearch} />
  </List.Toolbar>
  <List.Table data={items} sort={sort} onSortChange={setSort}>
    <List.Column field="name" header="Name" sortable />
    <List.Column field="status" header="Status" display="badge" />
  </List.Table>
  <List.Pagination page={1} pageSize={10} total={100} totalPages={10} onPageChange={setPage} />
</List>

Component Override Strategy

5 levels of customization, from least to most effort:

| Level | Method | Scope | | --- | --- | --- | | 1 | className prop | Single instance | | 2 | CVA variants (variant, size) | Variant-level | | 3 | FieldVariantContext | Section/page-wide field styling | | 4 | UIProvider overrides | App-wide component replacement | | 5 | Custom component | Full replacement |

// Level 4: Replace Input globally via UIProvider
import { MyCustomInput } from "./my-input";

<UIProvider overrides={{ Input: MyCustomInput }}>
  {/* All TextField components now use MyCustomInput */}
</UIProvider>

API Reference

Layout Primitives

Semantic layout components built with CVA variants.

| Component | Description | Key Props | | --- | --- | --- | | Stack | Vertical/horizontal flex layout | direction, gap, align, justify, wrap | | Flex | Horizontal flex (alias for Stack direction="row") | Same as Stack | | Grid | CSS Grid layout | columns (1-6), gap, gapX/gapY (per-axis overrides), divider (vertical line between columns), template (arbitrary grid-template-columns) | | Container | Centered max-width wrapper | size (sm/md/lg/xl/full) | | Section | Content section with title/description | title, description | | Card | Card container with border and shadow | padding (none/sm/md/lg), interactive | | Heading | Semantic heading (h1-h6) | level (1-6), tone, font (sans/display/mono) | | Text | Body text with typography scale | size (lg/base/sm/caption), tone, font (sans/display/mono) |

<Stack gap="lg">
  <Section title="User Info" description="Basic information">
    <Grid columns={2} gap="md">
      <FormFields.TextField label="First Name" value={first} onChange={setFirst} />
      <FormFields.TextField label="Last Name" value={last} onChange={setLast} />
    </Grid>
  </Section>
</Stack>

Base Components

Unstyled Radix UI primitives with Tailwind CSS styling. Used internally by field components and available for direct use.

  • Button (with CVA variants: variant, size)
  • Input, Textarea, Label, Badge, Switch, Checkbox
  • Select (Root, Trigger, Value, Content, Item, Group, Label, Separator)
  • RadioGroup (Root, Item)
  • Calendar (date picker grid)
  • TimePicker (time-of-day input with AM/PM toggle and drop-open hour/minute lists)
  • Popover (Root, Trigger, Content, Anchor)
  • Dialog (Root, Trigger, Content, Header, Footer, Title, Description, Close)
  • DropdownMenu (Root, Trigger, Content, Item, CheckboxItem, RadioItem, RadioGroup, Label, Separator, Sub, SubTrigger, SubContent, Group)
  • Sheet (Root, Trigger, Content, Header, Footer, Title, Description, Close)
  • Table (Root, Header, Body, Footer, Head, Row, Cell, Caption)
  • Tabs (Root, List, Trigger, Content)
  • Tooltip (Provider, Root, Trigger, Content)
  • NavigationMenu (Root, List, Item, Trigger, Content, Link, Viewport, Indicator)
  • Separator
  • Skeleton

Status & Display

Tone-driven components sharing a single semantic color vocabulary. Each StatusTone (success, warning, danger, info, neutral, pending, processing) bundles every Tailwind class — badge, dot, ring, icon, surface — with its dark: variant, so adopting them keeps colors consistent across light/dark.

  • StatusBadge — compact status pill (tone + label, optional leading dot/icon, filled/outline appearance)
  • StatusDot — small indicator dot with an optional ping/flash ring animation
  • AlertBanner — tinted status banner with icon, title/subtitle (or free-form children), trailing slot, and default/sm/hint density
  • DetailList / DetailListRow — bordered key-value rows, optionally interactive (onClick upgrades a row to button semantics)
  • LabeledField — label + description row paired with an arbitrary trailing control
  • SettingSwitch — labeled toggle row built on LabeledField + Switch

Tone tokens are exported directly for custom rendering: STATUS_TONES, STATUS_TONE_NAMES, statusToneClass(tone, slot), plus the StatusTone / StatusToneToken / IconComponent types.

<StatusBadge tone="success" label="Active" showDot />
<AlertBanner tone="danger" icon={AlertTriangleIcon} title="Connection lost" subtitle="Reconnecting…" />

Form Field Components

All form fields follow the same pattern: value + onChange + common field props (label, error, description, required, disabled, className).

Every field also accepts prefixControl / suffixControl — a control rendered on the same row as the input (e.g. an add button, IconPicker, ColorPicker). Use these instead of composing a button next to the field: the control stays aligned with the input while description and error render below at full width.

<FormFields.TextField
  label="Training key"
  value={draft}
  onChange={setDraft}
  description="Added on submit."
  suffixControl={<Button onClick={add}>Add</Button>}
/>
import { FormFields } from "@simplix-react/ui";

| Component | Value Type | Key Props | | --- | --- | --- | | FormFields.TextField | string | type (text/email/url/password/tel), placeholder, maxLength | | FormFields.TextareaField | string | rows, maxLength, resize (none/vertical/both) | | FormFields.NumberField | number \| null | min, max, step, placeholder | | FormFields.SelectField | string | options (label/value pairs), placeholder | | FormFields.SwitchField | boolean | switchProps | | FormFields.CheckboxField | boolean | checkboxProps | | FormFields.RadioGroupField | string | options (label/value/description), direction | | FormFields.DateField | Date \| null | minDate, maxDate, format, placeholder | | FormFields.DateTimeField | Date \| string \| number \| null | minDate, maxDate, hideTime, hour12, minuteStep | | FormFields.TimeField | TimeValue \| null ({ hours, minutes }) | minTime, maxTime, hour12, minuteStep | | FormFields.ComboboxField | string \| null | options, onSearch, loading, emptyMessage | | FormFields.PasswordField | string | placeholder, maxLength (with visibility toggle) | | FormFields.ColorField | string (hex) | Native color picker + hex text input | | FormFields.SliderField | number | min, max, step, showValue | | FormFields.MultiSelectField | string[] | options, placeholder, maxCount | | FormFields.Field | ReactNode (children) | Generic wrapper for custom content |

<FormFields.SelectField
  label="Status"
  value={status}
  onChange={setStatus}
  options={[
    { label: "Active", value: "active" },
    { label: "Inactive", value: "inactive" },
  ]}
  required
  error={errors.status}
/>

Detail Field Components

Read-only display fields with formatting capabilities.

import { DetailFields } from "@simplix-react/ui";

| Component | Value Type | Key Props | | --- | --- | --- | | DetailFields.DetailTextField | string \| null | fallback, copyable | | DetailFields.DetailNumberField | number \| null | format (decimal/currency/percent), locale, currency | | DetailFields.DetailDateField | Date \| string \| null | format (date/datetime/relative), fallback | | DetailFields.DetailBadgeField | string | variants (value-to-variant mapping) | | DetailFields.DetailLinkField | string | href, external | | DetailFields.DetailBooleanField | boolean \| null | mode (text/icon), labels | | DetailFields.DetailImageField | string \| null (URL) | alt, width, height, imageClassName | | DetailFields.DetailListField | string[] \| null | mode (badges/comma/bullet) | | DetailFields.DetailField | ReactNode (children) | Generic wrapper for custom content |

<DetailFields.DetailDateField
  label="Created At"
  value={user.createdAt}
  format="relative"
/>

<DetailFields.DetailBadgeField
  label="Status"
  value={user.status}
  variants={{ active: "success", inactive: "secondary", banned: "destructive" }}
/>

Field Wrappers

Low-level wrappers used internally by FormFields and DetailFields. Export them to build custom field components.

| Component | Purpose | Key Props | | --- | --- | --- | | FieldWrapper | Wraps editable inputs with label, error, description | label, error, description, required, disabled, layout, size | | DetailFieldWrapper | Wraps read-only display values with label | label, layout, size |

import { FieldWrapper } from "@simplix-react/ui";

// Pass a render function to receive { id, labelId }. Put `id` on a labelable
// control so the label names it; use `labelId` with `aria-labelledby` for a
// composite control (radio group, date picker) with no single labelable element.
// A plain-element child is instead named by the wrapping fieldset.
<FieldWrapper label="Custom Field" error={errors.custom} required>
  {({ id }) => <MyCustomInput id={id} value={value} onChange={onChange} />}
</FieldWrapper>

Field Variant System

Control field label position and size across a section or page using context:

import { FieldVariantContext } from "@simplix-react/ui";

// All fields inside will use left-aligned labels at small size
<FieldVariantContext.Provider value={{ layout: "left", size: "sm" }}>
  <FormFields.TextField label="Name" value={name} onChange={setName} />
  <FormFields.TextField label="Email" value={email} onChange={setEmail} />
</FieldVariantContext.Provider>

Options:

  • layout: "top" (default) | "left" | "inline" | "trailing" (control right-aligned with a dashed leader line from the label — the SwitchField default) | "hidden" (sr-only for accessibility)
  • size: "sm" | "md" (default) | "lg"

CRUD Layout Components

List (Compound)

Full-featured data table with sorting, filtering, pagination, selection, and bulk actions.

const { data, filters, sort, pagination, selection, emptyReason } = useCrudList(useUserList);

<List>
  <List.Toolbar>
    <List.Search value={filters.search} onChange={filters.setSearch} />
    <List.Filter
      label="Role"
      value={roleFilter}
      onChange={setRoleFilter}
      options={roleOptions}
    />
  </List.Toolbar>

  <List.BulkActions selectedCount={selection.selected.size} onClear={selection.clear}>
    <List.BulkAction label="Delete Selected" onClick={handleBulkDelete} variant="destructive" />
  </List.BulkActions>

  {emptyReason ? (
    <List.Empty reason={emptyReason} />
  ) : (
    <List.Table
      data={data}
      sort={{ field: sort.field!, direction: sort.direction }}
      onSortChange={(s) => sort.setSort(s.field, s.direction)}
      selectable
      selectedIndices={selection.selected}
      onSelectionChange={selection.toggle}
      onSelectAll={() => selection.toggleAll(data)}
      onRowClick={handleRowClick}
    >
      <List.Column field="name" header="Name" sortable />
      <List.Column field="email" header="Email" sortable />
      <List.Column field="status" header="Status" display="badge"
        variants={{ active: "success", inactive: "secondary" }} />
      <List.Column field="createdAt" header="Created" format="date" sortable />
    </List.Table>
  )}

  <List.Pagination
    page={pagination.page}
    pageSize={pagination.pageSize}
    total={pagination.total}
    totalPages={pagination.totalPages}
    onPageChange={pagination.setPage}
    onPageSizeChange={pagination.setPageSize}
  />
</List>

Sub-components: List.Toolbar, List.Search, List.Filter, List.Table, List.Column, List.RowActions, List.Action, List.Pagination, List.BulkActions, List.BulkAction, List.Empty

List.Table and Tree.Table render a sticky header by default — once scrolling an outer container (a page, dialog, or detail-pane body) would hide the header row, it sticks to the top of the nearest scrollable ancestor. Wide tables keep their own horizontal scrollbar, and the floating header scrolls with its columns. Pass stickyHeader={false} to disable. Inside a List.TableCard the table owns its scroll region and the header is always sticky.

CardList

Mobile-friendly card-based layout alternative to table.

<CardList
  data={items}
  columns={2}
  renderCard={(item, index) => (
    <div key={index} className="rounded-lg border p-4">
      <h3>{item.name}</h3>
    </div>
  )}
/>

CrudForm (Compound)

Form layout with sections and actions.

<CrudForm
  onSubmit={handleSubmit}
  fieldVariant={{ labelPosition: "top", size: "md" }}
  warnOnUnsavedChanges
>
  <CrudForm.Section title="Basic Info" layout="two-column">
    <FormFields.TextField label="Name" value={name} onChange={setName} required />
    <FormFields.TextField label="Email" value={email} onChange={setEmail} type="email" />
  </CrudForm.Section>

  <CrudForm.Section title="Details" layout="single-column">
    <FormFields.TextareaField label="Bio" value={bio} onChange={setBio} />
  </CrudForm.Section>

  <CrudForm.Actions>
    <button type="button" onClick={onCancel}>Cancel</button>
    <button type="submit">Save</button>
  </CrudForm.Actions>
</CrudForm>

Sub-components: CrudForm.Section (with layout: single-column/two-column/three-column), CrudForm.Actions

CrudDetail (Compound)

Read-only detail view layout.

<CrudDetail fieldVariant={{ labelPosition: "left" }}>
  <CrudDetail.Section title="User Info">
    <DetailFields.DetailTextField label="Name" value={user.name} copyable />
    <DetailFields.DetailDateField label="Joined" value={user.createdAt} format="relative" />
    <DetailFields.DetailBadgeField
      label="Status"
      value={user.status}
      variants={{ active: "success", inactive: "secondary" }}
    />
  </CrudDetail.Section>
  <CrudDetail.Actions>
    <button onClick={onEdit}>Edit</button>
    <button onClick={onDelete}>Delete</button>
  </CrudDetail.Actions>
</CrudDetail>

Sub-components: CrudDetail.Section, CrudDetail.Actions

CrudDelete

Confirmation dialog for delete operations using Radix AlertDialog.

import { CrudDelete } from "@simplix-react/ui";

<CrudDelete
  open={showDelete}
  onOpenChange={setShowDelete}
  onConfirm={handleDelete}
  entityName="user"
  loading={isDeleting}
/>

Props: open, onOpenChange, onConfirm, title, description, loading, entityName

CrudErrorBoundary

Error boundary for catching render errors in CRUD components.

import { CrudErrorBoundary } from "@simplix-react/ui";

<CrudErrorBoundary
  fallback={(error, reset) => (
    <div>
      <p>Error: {error.message}</p>
      <button onClick={reset}>Retry</button>
    </div>
  )}
  onError={(error) => logError(error)}
>
  <UserList />
</CrudErrorBoundary>

Props: children, fallback (ReactNode or render function), onError

Wizard (Compound)

Multi-step form wizard with step indicator and validation.

import { Wizard } from "@simplix-react/ui";

<Wizard onComplete={handleSubmit}>
  <Wizard.Step title="Basic Info" validate={validateStep1}>
    <FormFields.TextField label="Name" value={name} onChange={setName} />
  </Wizard.Step>
  <Wizard.Step title="Details">
    <FormFields.TextareaField label="Bio" value={bio} onChange={setBio} />
  </Wizard.Step>
  <Wizard.Step title="Review">
    <p>Confirm your details before submitting.</p>
  </Wizard.Step>
</Wizard>

Sub-components: Wizard.Step (with title, description, and optional async validate function)

QueryFallback

Loading and not-found fallback guard for single-entity views.

import { QueryFallback } from "@simplix-react/ui";

const { data, isLoading } = useGet(id);
if (isLoading || !data) return <QueryFallback isLoading={isLoading} notFoundMessage="Pet not found." />;

Props: isLoading, notFoundMessage, loadingMessage

CRUD Hooks

useCrudFormSubmit

Handles create/update mutation dispatch for CRUD forms. Determines whether to create or update based on entityId presence.

const { isEdit, handleSubmit, isPending } = useCrudFormSubmit<FormValues>({
  entityId,
  create: entityHooks.useCreate(),
  update: entityHooks.useUpdate(),
  onSuccess: () => navigate(-1),
});

Returns: { isEdit, handleSubmit, isPending, fieldErrors }

Client-side validation (optional)

Pass a validator to run a sync check on the raw form values before the server mutation. Returning a non-empty Record<field, message> stops the submit and exposes the errors via fieldErrors; the create/update mutation is not called. Returning null (or {}) passes through to the server.

Client and server errors are temporally mutually exclusive on the same submit, so fieldErrors always reflects exactly one source per attempt (client → server → success replaces).

// Zod (use `zodToFieldErrors` from @simplix-react/form):
import { zodToFieldErrors } from "@simplix-react/form";
import { createUserSchema, updateUserSchema } from "@my-app/domain-user";

const { handleSubmit, fieldErrors } = useCrudFormSubmit<FormValues>({
  entityId,
  create: adaptOrvalCreate(_create),
  update: adaptOrvalUpdate(_update, "userId"),
  validator: (v) => zodToFieldErrors(isEdit ? updateUserSchema : createUserSchema, v),
  onSuccess,
});

// Custom imperative rules:
validator: (v) => v.email?.includes("@") ? null : { email: "Invalid email" }

Wrap inline validator functions with useCallback / useMemo to keep handleSubmit identity stable across renders.

useCrudDeleteList

Manages delete-confirmation state for list views.

const del = useCrudDeleteList();

// Trigger: del.requestDelete({ id: row.id, name: row.name })
<CrudDelete
  open={del.open}
  onOpenChange={(o) => { if (!o) del.cancel(); }}
  onConfirm={() => deleteMutation.mutate(del.target!.id)}
  entityName={del.target?.name}
/>

Returns: { open, target, requestDelete, cancel }

useCrudDeleteDetail

Manages delete-confirmation state for detail views (single item).

const del = useCrudDeleteDetail();

<button onClick={del.requestDelete}>Delete</button>
<CrudDelete open={del.open} onOpenChange={del.onOpenChange} onConfirm={handleDelete} />

Returns: { open, requestDelete, cancel, onOpenChange }

usePreviousData

Retains previous query data during refetch to prevent layout flicker.

const { data, isLoading } = useGet(id);
const stableData = usePreviousData(data);

useFadeTransition

CSS fade transition hook for view transitions (e.g., list-to-detail).

const { isVisible, shouldRender } = useFadeTransition({
  show: !!selectedId,
  duration: 200,
});

Returns: { isVisible, shouldRender }

useListDetailState

State management for the ListDetail pattern — tracks selected item and panel toggle.

const { selectedId, select, deselect, isDetailOpen } = useListDetailState({
  onSelect: (id) => navigate(`/users/${id}`),
});

Returns: { selectedId, select, deselect, isDetailOpen }

useCrudList

State management hook for list views with filtering, sorting, pagination, and selection.

const result = useCrudList(useUserList, {
  stateMode: "server",      // "server" (API-driven) or "client" (local)
  defaultSort: { field: "name", direction: "asc" },
  defaultPageSize: 20,
});

Returns: { data, isLoading, error, isPaused, failureCount, filters, sort, pagination, selection, emptyReason }

emptyReason is "no-data", "no-filter", "no-search", "error", "unavailable", or null. "unavailable" covers a query that is neither settled successfully nor settled with an error — paused (React Query fetchStatus === "paused", which happens while the document is hidden or the browser reports offline) or waiting to retry after a failed attempt. Such a query reports isLoading: false and error: null, so it is reported as "the list could not be loaded" rather than as an empty result.

useUrlSync

Syncs list state (filters, sort, pagination) with URL query parameters.

useUrlSync({
  filters: { search, values: filterValues },
  sort: sortState,
  pagination: { page, pageSize, total },
  setFilters,
  setSort,
  setPage,
});

useVirtualList

Virtual scrolling for large lists via @tanstack/react-virtual.

const { virtualizer, virtualRows, totalHeight } = useVirtualList({
  count: data.length,
  estimateSize: () => 48,
  parentRef: scrollRef,
  overscan: 5,
});

useKeyboardNav

Keyboard navigation for list components (ArrowUp/Down, Enter, Space, Ctrl+K, Escape).

const { containerRef } = useKeyboardNav({
  onNavigate: (dir) => { /* move focus up/down */ },
  onSelect: () => { /* select current item */ },
  onToggle: () => { /* toggle checkbox */ },
  onSearch: () => { /* focus search input */ },
  onEscape: () => { /* dismiss */ },
});

useMediaQuery

Responsive breakpoint detection via matchMedia API.

const isMobile = useMediaQuery("(max-width: 768px)");

useAutosave

Debounced autosave hook that watches form values for changes.

const { status, lastSavedAt, isSaving } = useAutosave({
  values: formValues,
  onSave: async (values) => await api.saveDraft(values),
  debounceMs: 2000,
  hasErrors: form.hasErrors,
});

Returns: { status, lastSavedAt, isSaving }

Status values: "idle" | "saving" | "saved" | "error"

CRUD Patterns

ListDetail

Two rendering variants for list + detail layouts.

Panel variant (default): Side-by-side layout with draggable divider.

<ListDetail variant="panel" listWidth="1/3">
  <ListDetail.List>
    {/* List content */}
  </ListDetail.List>
  <ListDetail.Detail>
    {/* Detail content */}
  </ListDetail.Detail>
</ListDetail>

Dialog variant: Full-width list with detail in a modal dialog.

<ListDetail variant="dialog" onClose={() => setSelected(null)}>
  <ListDetail.List>
    {/* List takes full width */}
  </ListDetail.List>
  <ListDetail.Detail>
    {/* Opens in a modal dialog */}
  </ListDetail.Detail>
</ListDetail>

Props:

  • variant: "panel" (default) | "dialog"
  • listWidth: "1/4" | "1/3" | "2/5" | "1/2" | "3/5" | "2/3" | "3/4" | "4/5" (panel variant only)
  • activePanel: "list" | "detail" (controlled mode)
  • onClose: Callback when dialog is dismissed (dialog variant only)

Panel variant features:

  • Responsive: collapses to single-panel on mobile
  • Draggable divider with keyboard support (ArrowLeft/Right)

ListDetailRoot is the same component as ListDetail — exported as an alias for compound pattern composition.

Router Adapters

The package is router-agnostic. Use CrudProvider to inject a router adapter.

import { CrudProvider, createReactRouterAdapter } from "@simplix-react/ui";
import { useNavigate, useSearchParams, useLocation } from "react-router-dom";

function AppShell() {
  const adapter = createReactRouterAdapter({ useNavigate, useSearchParams, useLocation });

  return (
    <CrudProvider router={adapter}>
      {/* CRUD pages can now use useRouter(), useUrlSync(), etc. */}
    </CrudProvider>
  );
}

UIProvider

Override base components globally. Supports nesting for scoped overrides.

import { UIProvider } from "@simplix-react/ui";
import { MyInput, MySelect } from "./custom-components";

<UIProvider overrides={{
  Input: MyInput,
  Select: {
    Root: MySelect.Root,
    Trigger: MySelect.Trigger,
    Value: MySelect.Value,
    Content: MySelect.Content,
    Item: MySelect.Item,
  },
}}>
  {/* All fields now use custom input/select */}
</UIProvider>

Overridable components: Input, Textarea, Label, Switch, Checkbox, Badge, Calendar, Select (compound), RadioGroup (compound).

Utilities

| Function | Description | | --- | --- | | cn(...inputs) | Merges class names with clsx + tailwind-merge | | toTestId(label) | Converts a label string to a kebab-case data-testid value | | sanitizeHtml(dirty) | Sanitizes HTML via DOMPurify |

Accessibility

  • FieldWrapper generates the field's ids and hands { id, labelId } to a render-function child, so every control gets an accessible name: id plus the label's htmlFor for a labelable control, or aria-labelledby={labelId} for a composite one. A plain-element child is named by the wrapping fieldset instead.
  • Icon-only controls (row actions, toolbar buttons) carry their own aria-label; a tooltip describes a control but does not name one.
  • Hidden labels use sr-only class for screen reader accessibility
  • Error messages use role="alert" for live announcements
  • aria-invalid set on inputs when errors are present
  • aria-label provided when layout="hidden"
  • Keyboard navigation support via useKeyboardNav hook
  • Sort buttons and pagination controls have descriptive aria-label
  • Selection checkboxes have row-specific aria-label

Peer Dependencies

{
  "@simplix-react/form": "workspace:*",
  "@simplix-react/i18n": "workspace:*",
  "@simplix-react/react": "workspace:*",
  "react": ">=18.0.0",
  "react-router-dom": ">=6.0.0 (optional)"
}

react-router-dom is required only when using createReactRouterAdapter. Other router adapters can be provided via CrudProvider.

License

See root LICENSE file.