# Introduction (/docs) This is a component library base on [shadcn-ui](https://ui.shadcn.com/). Make it as simple and convenient as a component library to use shadcn-ui and enhance commonly used components. Of course, you can also fork this repository to create your own component library, or directly copy certain component codes into your own project. ## Getting Started [#getting-started] See the [Installation](/docs/installation) guide to configure the `@easy-shadcn` registry namespace, then install any component with a single command. ## The difference with shadcn/ui [#the-difference-with-shadcnui] * Simplify the use of components and reduce the large amount of boilerplate code brought by integrated components. Use the props of each part to transmit. (Although this is not the best practice, it is simple) * Unified component usage, rather than being fixated on using either the Sonar or the Toast, the Drawer or the Sheet ## Roadmap [#roadmap] ๐Ÿšง easy-shadcn is in the early stages and under active development. # Installation (/docs/installation) easy-shadcn components are distributed through the shadcn CLI. You can install them in two ways: * **Recommended**: Configure the `@easy-shadcn` namespace once, then install any component with a short command. * **Quick try**: Install directly via the full JSON URL โ€” no configuration required. ## Recommended: Configure the `@easy-shadcn` Namespace [#recommended-configure-the-easy-shadcn-namespace] Add a `registries` entry to your project's `components.json`: ```json title="components.json" { "registries": { "@easy-shadcn": "https://easy-shadcn.vercel.app/r/{name}.json" } } ``` Then install any component using the namespace form: ```shell pnpm dlx shadcn@latest add @easy-shadcn/card pnpm dlx shadcn@latest add @easy-shadcn/modal ``` Cross-component dependencies (for example, `modal` depends on `async-button`) are resolved automatically through the same namespace โ€” you don't need to install them by hand. ### One-liner to patch `components.json` [#one-liner-to-patch-componentsjson] If you'd rather not edit the file manually, run this command once at your project root: ```shell node -e 'const f="components.json",fs=require("fs"),c=JSON.parse(fs.readFileSync(f,"utf8"));c.registries={...c.registries,"@easy-shadcn":"https://easy-shadcn.vercel.app/r/{name}.json"};fs.writeFileSync(f,JSON.stringify(c,null,2)+"\n")' ``` ## Alternative: Install via Full URL [#alternative-install-via-full-url] You can skip the namespace config entirely and pass the JSON URL to the shadcn CLI: ```shell pnpm dlx shadcn@latest add https://easy-shadcn.vercel.app/r/card.json pnpm dlx shadcn@latest add https://easy-shadcn.vercel.app/r/modal.json ``` Zero configuration, but you'll need to type the full URL every time. ## Why Namespaced? [#why-namespaced] * **Unambiguous**: `@easy-shadcn/card` clearly comes from this registry and won't be confused with shadcn's built-in `card`. * **Clean dependencies**: Registry items can declare internal dependencies like `@easy-shadcn/async-button`, and the CLI resolves them through the namespace mapping. * **Short commands**: Once configured, the install experience matches the official shadcn flow. > `@easy-shadcn` is **not** a globally registered namespace โ€” it's just a local alias in your `components.json` that points to this registry's URL template. You can rename the alias, but the registry's internal `registryDependencies` declare `@easy-shadcn/...`, so renaming it requires updating those references too. Keeping the default is recommended. ## AI Agents / MCP [#ai-agents--mcp] If you use an AI coding agent (Claude Code, Cursor, VS Code, Codex, opencode), the shadcn CLI ships an **MCP server** that lets the agent browse and install `@easy-shadcn/*` components on your behalf โ€” no need to paste URLs or component names by hand. First make sure the `@easy-shadcn` registry is configured in your `components.json` (see [Recommended](#recommended-configure-the-easy-shadcn-namespace) above). Then register the MCP server for your client: ```shell pnpm dlx shadcn@latest mcp init --client claude # other clients: --client cursor | vscode | codex | opencode ``` Once connected, ask the agent in natural language, for example: > "List the components available in the @easy-shadcn registry, then add the modal and card." The agent reads your `components.json`, resolves the `@easy-shadcn` namespace, and runs the install for you โ€” cross-component dependencies are resolved automatically. For agents that consume plain text, this site also publishes the whole documentation set as LLM-friendly Markdown: * [`/llms.txt`](https://easy-shadcn.vercel.app/llms.txt) โ€” an index of every page with a one-line description. * [`/llms-full.txt`](https://easy-shadcn.vercel.app/llms-full.txt) โ€” the full docs concatenated as a single Markdown file. * Append `.md` to any docs URL for that page's raw Markdown, e.g. `/docs/components/card.md`. ## Updating Components [#updating-components] easy-shadcn follows the shadcn **copy-in** model: `add` copies the component source into your project, so upgrading means re-running `add` with `--overwrite`: ```shell pnpm dlx shadcn@latest add @easy-shadcn/card --overwrite ``` > `--overwrite` replaces the local files, so any changes you made to the copied component will be lost. Commit or `git diff` first to review โ€” if you've customized a component, reapply your edits after updating (or extend it from a wrapper in your own directory instead of editing the copy in place). ## Localizing Built-in Text [#localizing-built-in-text] Components ship with English defaults โ€” `"Loadingโ€ฆ"`, `"No data"`, `"Pick an option"`, `"OK"` / `"Cancel"`. There is deliberately no locale mechanism: the code is copied into your repo, so localization is a consumer-side concern. Three ways, from ad-hoc to app-wide: 1. **Pass props.** Every built-in string has a prop: `loadingMessage`, `emptyMessage`, `placeholder`, `confirmText` / `cancelText`, and so on. 2. **Edit your copy.** Change the default right in the file `add` dropped into your project. Note that upgrading with `--overwrite` resets it. 3. **Wrap and re-export** (recommended for app-wide defaults). Bake your defaults into a wrapper you own โ€” upgrades never touch it: ```tsx // components/app-table.tsx import { Table, type TableProps } from "@/components/easy/table" export function AppTable(props: TableProps) { return } ``` # Accordion (/docs/components/accordion) Accordion flattens shadcn's `Accordion` compound components into a single items-driven component: each item describes its trigger and panel content, instead of hand-writing nested `AccordionItem` / `AccordionTrigger` / `AccordionContent` structures. ## Installation [#installation] With the [`@easy-shadcn` namespace](/docs/installation) configured: ```shell pnpm dlx shadcn@latest add @easy-shadcn/accordion ``` Or install via the full URL (zero configuration): ```shell pnpm dlx shadcn@latest add https://easy-shadcn.vercel.app/r/accordion.json ``` ## Props [#props] | Prop | Type | Default | Description | | ------------------ | ----------------------------------------- | ------- | ---------------------------------------------------------------------------------- | | `items` | `AccordionItem[]` | - | Item definitions โ€” see the table below. An empty array renders an empty accordion. | | `value` | `string[]` | - | The open item values. Use for the controlled mode. | | `defaultValue` | `string[]` | - | The initially open item values. Use for the uncontrolled mode. | | `onValueChange` | `(value: string[], eventDetails) => void` | - | Called when an item is expanded or collapsed. | | `multiple` | `boolean` | `false` | Allows multiple panels to stay open at the same time. | | `disabled` | `boolean` | `false` | Disables every item. | | `keepMounted` | `boolean` | `false` | Keeps closed panels mounted (hidden) instead of unmounting them. | | `itemClassName` | `ClassValue` | - | Class override applied to every `AccordionItem`. | | `triggerClassName` | `ClassValue` | - | Class override applied to every `AccordionTrigger`. | | `contentClassName` | `ClassValue` | - | Class override applied to every `AccordionContent`. | All other props (`className`, `orientation`, `loopFocus`, etc.) are forwarded to the underlying Accordion root. ### AccordionItem [#accordionitem] | Field | Type | Description | | ------------------ | ------------ | ----------------------------------------------------------------------- | | `value` | `string` | Unique identifier of the item. | | `trigger` | `ReactNode` | Header content โ€” plain text or e.g. icon + text. | | `content` | `ReactNode` | Panel content. | | `disabled` | `boolean` | Disables this item. | | `itemClassName` | `ClassValue` | Per-item class, merged after the root-level `itemClassName`. | | `triggerClassName` | `ClassValue` | Per-item trigger class, merged after the root-level `triggerClassName`. | | `contentClassName` | `ClassValue` | Per-item content class, merged after the root-level `contentClassName`. | ## Notes [#notes] * `value` / `defaultValue` are arrays because Base UI's accordion is multi-open at heart. In single mode (`multiple` omitted) the array holds at most one value. * `value` / `defaultValue` / `onValueChange` are narrowed to `string[]` โ€” the Base UI primitive accepts `any[]`, which silently swallows typos. * Use `keepMounted` when a panel holds expensive state (forms, iframes) that should survive collapsing. * `items` must be homogeneous. For heterogeneous triggers or panels (custom chevrons, non-uniform layouts), compose the `components/ui/accordion` primitives directly instead. # Alert Dialog (/docs/components/alert-dialog) AlertDialog flattens shadcn's `AlertDialog` compound components into a single component with flat `title`, `description`, and footer props. It is the zero-dependency, declarative sibling of `AlertModal` โ€” same visual and prop contract, but driven inline via a `trigger` or a controlled `open` prop instead of the imperative `@easy-shadcn/command-modal` helpers. ## Installation [#installation] With the [`@easy-shadcn` namespace](/docs/installation) configured: ```shell pnpm dlx shadcn@latest add @easy-shadcn/alert-dialog ``` Or install via the full URL (zero configuration): ```shell pnpm dlx shadcn@latest add https://easy-shadcn.vercel.app/r/alert-dialog.json ``` ## Usage [#usage] The flat `onConfirm` / `onCancel` footer needs no controlled state โ€” confirm closes the dialog after the (optionally async) handler resolves, and a rejection keeps it open and retryable: ```tsx import { AlertDialog } from "@/components/easy/alert-dialog" Delete} onConfirm={async () => { await deleteProject() }} /> ``` ## API [#api] | Prop | Type | Default | Description | | --------------------------------------- | ------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `title` / `description` | `ReactNode` | - | Header slots. The header renders only when one of them is present. | | `trigger` | `ReactElement` | - | Rendered as the `AlertDialogTrigger` for uncontrolled use. | | `open` / `defaultOpen` / `onOpenChange` | - | - | Controlled / uncontrolled open state. `onOpenChange` is `(open: boolean) => void`. | | `onConfirm` / `onCancel` | `() => void \| Promise` | - | Footer handlers. Async handlers show a pending spinner; a rejection keeps the dialog open. | | `confirmText` / `cancelText` | `ReactNode` | `"OK"` / `"Cancel"` | Footer button labels. | | `showCancel` | `boolean` | `true` | Hide the cancel button for single-action alerts. | | `confirmProps` / `cancelProps` | `Omit` | - | Extra props for the footer buttons. `onClick` and `children` are owned by the dialog (use `onConfirm` / `onCancel` and `confirmText` / `cancelText`) and cannot be overridden. | | `footer` | `ReactNode` | - | Replaces the entire button row. When set, all button-related props are ignored. | | `variant` | `"default" \| "destructive"` | `"default"` | Confirm-button color sugar. `confirmProps.variant` overrides it; the cancel button always stays `outline`. | | `size` | `"default" \| "sm"` | `"default"` | Forwarded to the `AlertDialogContent` primitive. | Class overrides: `className` (dialog content), `headerClassName`, `titleClassName`, `descriptionClassName`, `footerClassName`. ## Notes [#notes] * **Accessibility name.** A dialog without a title has no accessible name โ€” pass `title` (or render your own `AlertDialogTitle` via the primitives) in production. * Escape closes the dialog; outside/pointer clicks never dismiss it (both hardwired by base-ui's `AlertDialog`). * For a media/icon header, three or more actions, or heterogeneous footer layouts beyond the `footer` override, compose the `components/ui/alert-dialog` primitives directly. # Alert (/docs/components/alert) Alert flattens shadcn's compound Alert into `icon`, `title`, `description`, and `action` props. ## Installation [#installation] With the [`@easy-shadcn` namespace](/docs/installation) configured: ```shell pnpm dlx shadcn@latest add @easy-shadcn/alert ``` Or install via the full URL (zero configuration): ```shell pnpm dlx shadcn@latest add https://easy-shadcn.vercel.app/r/alert.json ``` ## Props [#props] | Prop | Type | Default | Description | | ---------------------- | ---------------------------- | ----------- | --------------------------------------------------------------------------- | | `title` | `ReactNode` | - | Alert title, rendered through the primitive `AlertTitle`. | | `description` | `ReactNode` | - | Supporting content, rendered through the primitive `AlertDescription`. | | `icon` | `ReactNode` | - | Leading icon rendered directly under the root. No icon is added by default. | | `action` | `ReactNode` | - | Caller-owned control or status content rendered in `AlertAction`. | | `variant` | `"default" \| "destructive"` | `"default"` | Visual variant forwarded to the primitive. | | `className` | `string` | - | Class override for the Alert root. | | `titleClassName` | `ClassValue` | - | Class override for the title part. | | `descriptionClassName` | `ClassValue` | - | Class override for the description part. | | `actionClassName` | `ClassValue` | - | Class override for the action part. | Safe root props (`id`, `style`, `aria-*`, `data-*`, and event handlers) are forwarded to the underlying Alert root. `children` and `dangerouslySetInnerHTML` are intentionally unavailable because the Compose layer owns the child structure; `title` is visible Alert content, not the native root `title` attribute. ## Slot structure [#slot-structure] Slots always render in primitive order: `icon`, `title`, `description`, then `action`. The icon has no wrapper or `iconClassName`; style the node you pass so it remains a direct child and keeps the primitive's icon grid layout. The action is content, not a behavior API. Pass a self-contained `Button`, `AsyncButton`, link, badge, or another node with its own event, disabled, navigation, and loading behavior. ## Notes [#notes] * Alert is server-compatible and adds no client boundary. Only a stateful action node or surrounding caller needs `"use client"`. * Slot values follow React's rendering rules: `null`, `undefined`, and booleans are absent, while valid falsy nodes such as `0` and `""` render normally. * The primitive supplies `role="alert"`. Use it for information that should be announced promptly, not as a generic styled container. * No default icon or variant-specific icon is invented; callers choose the meaning and accessible treatment of their icon. * For dismissible alerts, extra severity variants, multiple action regions, or reordered and heterogeneous content, compose the `components/ui/alert` primitives directly. # Async Button (/docs/components/async-button) AsyncButton extends shadcn's `Button`: when `onClick` returns a `Promise`, the component automatically manages the loading state โ€” the button is disabled and a spinner is shown until the promise settles. You can also drive the loading state manually via the `loading` prop, and decorate the button with `startIcon` / `endIcon` slots that the spinner slides into while loading. ## Installation [#installation] With the [`@easy-shadcn` namespace](/docs/installation) configured: ```shell pnpm dlx shadcn@latest add @easy-shadcn/async-button ``` Or install via the full URL (zero configuration): ```shell pnpm dlx shadcn@latest add https://easy-shadcn.vercel.app/r/async-button.json ``` ## Props [#props] | Prop | Type | Default | Description | | ----------- | ------------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `onClick` | `(e: MouseEvent) => void \| Promise` | - | Click handler. When it returns a Promise, the button enters the loading state until it settles. | | `loading` | `boolean` | - | Controlled loading state. When provided it takes precedence over the internal async loading state โ€” `loading={false}` fully suppresses the spinner even while an async `onClick` is in flight. | | `startIcon` | `ReactNode` | - | Icon rendered before `children`. Replaced by the spinner while loading. | | `endIcon` | `ReactNode` | - | Icon rendered after `children`. Replaced by the spinner while loading when `startIcon` is absent. | | `disabled` | `boolean` | `false` | Disables the button. The button is also disabled automatically while loading. | All other props (`variant`, `size`, `className`, etc.) are inherited from shadcn's `Button` and forwarded as-is. ## Loading indicator placement [#loading-indicator-placement] The spinner is rendered in the most context-appropriate place, in this priority order: 1. **Icon sizes (`size="icon"`, `"icon-xs"`, `"icon-sm"`, `"icon-lg"`)** โ€” `children` (the lone icon) is replaced by the spinner. 2. **`startIcon` present** โ€” `startIcon` is replaced by the spinner; `endIcon` (if any) stays put. 3. **Only `endIcon` present** โ€” `endIcon` is replaced by the spinner. 4. **No icons** โ€” a blurred overlay covers the button and the spinner is centered on top. This means the button never grows or reflows when loading kicks in. ## Notes [#notes] * Exceptions thrown from `onClick` are caught and logged via `console.error` so that unhandled promise rejections don't break the page, while still surfacing the error for observability. * The fallback overlay uses `backdrop-blur` so it works seamlessly with every button variant when no icon slot is available. * While loading, the button is disabled (no clicks, no hover) but keeps its full opacity โ€” the `disabled:opacity-50` style only applies when the button is purely disabled, not when it's loading. * The button sets `aria-busy={loading}` so assistive technologies can announce the loading state. * Repeat clicks are ignored while an async `onClick` is in flight โ€” even with `loading={false}` suppressing the spinner โ€” so a double click can't trigger a duplicate submit. # Avatar (/docs/components/avatar) Avatar flattens shadcn's root, image, fallback, and badge primitives into one component. Supply caller-owned `fallback` content, then opt into an image or badge without rebuilding the compound structure. ## Installation [#installation] With the [`@easy-shadcn` namespace](/docs/installation) configured: ```shell pnpm dlx shadcn@latest add @easy-shadcn/avatar ``` Or install via the full URL (zero configuration): ```shell pnpm dlx shadcn@latest add https://easy-shadcn.vercel.app/r/avatar.json ``` The underlying shadcn `avatar` primitive is installed automatically. ## Props [#props] | Prop | Type | Default | Description | | ------------------- | --------------------------- | ----------- | -------------------------------------------------------------------------------------- | | `fallback` | `ReactNode` | required | Caller-owned content rendered through `AvatarFallback`. | | `src` | `string` | - | Optional image source. `undefined` and `""` render no image element or request. | | `alt` | `string` | `""` | Alternative text for an informative image. The default treats the image as decorative. | | `badge` | `ReactNode` | - | Caller-owned content rendered through `AvatarBadge` after the active visual. | | `size` | `"sm" \| "default" \| "lg"` | `"default"` | Size forwarded to the Avatar root. | | `className` | `ClassValue` | - | Class override for the Avatar root. | | `imageClassName` | `ClassValue` | - | Class override for `AvatarImage`. | | `fallbackClassName` | `ClassValue` | - | Class override for `AvatarFallback`. | | `badgeClassName` | `ClassValue` | - | Class override for `AvatarBadge`. | Safe root props such as `id`, `style`, `aria-*`, `data-*`, event handlers, and `ref` are forwarded to the underlying Avatar root. The ref resolves to its `HTMLSpanElement`. `children`, `dangerouslySetInnerHTML`, and the primitive's custom `render` escape hatch are unavailable because the Compose layer owns the child structure. ## Image and fallback behavior [#image-and-fallback-behavior] `fallback` is the only Compose prop required for the first use case. With no `src`, or with an empty string, no image element is rendered and the fallback is available immediately. A non-empty `src` lets the underlying Base UI primitive manage loading: the fallback is active while the image is pending or failed, and the image replaces it after a successful load. `src` and `alt` are independently optional so values such as `avatarUrl?: string` can be passed directly. Use the default empty `alt` only when the image is decorative. For an informative image, pass concise meaningful alternative text. For fallback-only avatars that convey identity, label the root explicitly: ```tsx ``` ## Badge and slot ownership [#badge-and-slot-ownership] Badge content and semantics remain caller-owned. When the root represents one named image, include the status meaning in its accessible label; `role="img"` presents the descendants as a single image: ```tsx
``` ### Column types โ€” why `defineColumns` [#column-types--why-definecolumns] `TableColumn` is a discriminated union: when `dataIndex` is a literal `keyof T`, `render(value, record, index)` narrows `value` to `T[dataIndex]` (no casts). When `dataIndex` is omitted, `value` is `undefined` and you pull from `record`. The catch: TypeScript's contextual typing for inline array literals (`const cols: TableColumn[] = [...]`) can drop narrowing under certain strict-mode combinations โ€” `value` falls back to `any`, and invalid `dataIndex` literals stop erroring. The `defineColumns()` builder fixes this by binding the generic before inference and using `const` parameters to keep each element's `dataIndex` literal alive: ```tsx const cols = defineColumns()([ { dataIndex: "amount", key: "amount", title: "Amount", align: "right", render: (value) => formatter.format(value), // value: number }, { dataIndex: "status", key: "status", title: "Status", render: (value) => {value}, // value: Order["status"] }, { key: "actions", title: "Actions", // No dataIndex โ†’ value is undefined. render: (_value, record) => , }, ]) ``` You can still write `const cols: TableColumn[] = [...]` directly if you don't need narrowing โ€” `dataIndex` is still constrained to `keyof T`, `render` just has the union-of-shapes value type. Reach for `defineColumns` whenever you want IDE autocomplete to know what `value` is. `dataIndex` is intentionally limited to `keyof T` โ€” nested paths like `"user.name"` are not supported. Reach inside with `render: (_, record) => record.user.name` instead. If a field is not directly renderable as `ReactNode` (for example `Date` or an object), the column must provide `render`. String, number, boolean, nullish, and ReactNode fields may omit `render`. ### Formatting cells with `column.render` [#formatting-cells-with-columnrender] `column.render` is the one render-style prop the project allows, because it lives at the *data* layer โ€” formatting a value into a `ReactNode` โ€” not the component layer. It receives `(value, record, index)` and lives inside each column definition above. ### Loading, empty, and caption [#loading-empty-and-caption] `loading={true}` replaces the body with `loadingMessage`. When `dataSource` is empty and `loading` is false, `emptyMessage` is shown. `caption` renders inside `
`. ### Row highlighting via `rowClassName` [#row-highlighting-via-rowclassname] `rowClassName` accepts a `ClassValue` or a `(record, index) => ClassValue` function. Use the function form for conditional row styling without an extra wrapper. ```tsx record.cpu >= 80 ? "bg-destructive/10" : undefined} /> ``` ### Row selection [#row-selection] Set `selectable` to render a left-side checkbox column. The selection prop pair follows the same controlled / uncontrolled shape as other components in this library (`value` / `onValueChange` โ†’ `selectedRowKeys` / `onSelectedRowKeysChange`). The change callback receives both the new keys and the matching records in `dataSource` order, so you don't have to re-look-up rows. The header checkbox shows a distinct indeterminate (dash) icon while a non-empty proper subset is selected. Per-row checkbox configuration goes through `getCheckboxProps(record, index)` โ€” pass `disabled: true` to gate a row out (it will also be skipped by "select all"). `getCheckboxProps` is the one `xxxProps` escape hatch in this library, kept for parity with Antd's Table API. Checkbox state props such as `checked`, `defaultChecked`, `indeterminate`, `children`, and `onCheckedChange` are owned by the Table and are rejected by the public type contract; forced runtime values are stripped. ```tsx const [selected, setSelected] = useState([])
{ setSelected(keys) console.log("selected rows:", rows) }} getCheckboxProps={(record) => ({ disabled: record.role === "owner", })} /> ``` #### Selection across pages / filters [#selection-across-pages--filters] Keys in `selectedRowKeys` that aren't present in the current `dataSource` are **preserved** โ€” they survive pagination and filtering. `onSelectedRowKeysChange`'s second argument (`rows`) only contains the records that actually exist in `dataSource`, so consumers usually do: ```tsx // `selected` lives in your parent state and accumulates across pages.
``` For a header counter that reflects *only the visible* selection, derive it from your own visible set rather than `selected.length`: ```tsx const visibleSelected = selected.filter((k) => visibleIds.has(k)); ``` ### Row click [#row-click] Pass `onRowClick(record, index)` to make rows respond to interaction. The row keeps its native table-row semantics but becomes focusable (`tabIndex=0`) and reacts to mouse click, Enter, and Space. Clicks that originate inside the selection cell do **not** bubble to `onRowClick`, so checkboxes stay independent. Interactive elements inside normal cells (Button, Link, input, etc.) are ignored by the row activation handler, so clicking or pressing Enter / Space on a cell button does not also fire `onRowClick`. Try it: click a row body to set "viewing", then click a checkbox โ€” the row click does not fire. Tab to a row and press Enter or Space to activate it. ```tsx
router.push(`/members/${record.id}`)} /> ``` ## Accessibility [#accessibility] The Table ships with the WCAG defaults you'd expect, plus development-only warnings when they're missing: * **Accessible name (WCAG 1.3.1)** โ€” pass `caption`, `aria-label`, or `aria-labelledby`. Without one, dev builds log a warning. * **`aria-busy`** is set on the `
` whenever `loading` is `true`. * **Loading / empty cells** keep their native `` table semantics โ€” we do NOT override to `role="button"` (that would strip the table structure). Rows get `tabIndex={0}` and respond to Enter / Space. Focus outline is inset (`outline-offset: -2px`) so a surrounding `border` won't clip it. For dense or highly interactive tables, prefer a real Link/Button in a cell. * **Color is never the only signal** in row highlighting โ€” pair `rowClassName` with an icon or text cue (see the row-className example). ## API [#api] ### `TableProps` [#tablepropst] | Prop | Type | Default | Description | | -------------------------- | --------------------------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `columns` | `TableColumn[]` | โ€” | Column definitions. Use `defineColumns()` for narrowed `render(value, โ€ฆ)` types. | | `dataSource` | `T[] \| null \| undefined` | โ€” | Data rows. `null` / `undefined` are treated as empty, which matches SWR / React Query pre-response states. | | `rowKey` | string / number field of `T` \| `(record, index) => string \| number` | โ€” | Required. Derives a stable string key per row. No `index` fallback โ€” reordering / pagination would silently desync React keys. | | `loading` | `boolean` | `false` | Replaces the body with `loadingMessage`. | | `loadingMessage` | `ReactNode` | `"Loadingโ€ฆ"` | Shown while `loading` is true. | | `emptyMessage` | `ReactNode` | `"No data"` | Shown when `dataSource` is empty and not loading. | | `caption` | `ReactNode` | โ€” | Rendered inside ``. | | `bodyClassName` | `ClassValue` | โ€” | className on ``. | | `captionClassName` | `ClassValue` | โ€” | className on `
` semantics and contain an inner `role="status"` + `aria-live="polite"` node so SR users hear the state change instead of waiting in silence. * **Selection column** renders a visually-hidden `` carrying `selectionColumnLabel` (default `"Selection"`) so the column has a real name for AT, even though the `` shows only a checkbox. * **Selection checkboxes** default to `aria-label="Select row {key}"`. The row key is usually opaque โ€” pass a human label through `getCheckboxProps`: ```tsx getCheckboxProps={(record) => ({ "aria-label": `Select ${record.name}` })} ``` * **`onRowClick`** preserves native `
`. | | `rowClassName` | `ClassValue \| (record, index) => ClassValue` | โ€” | Per-row className. | | `onRowClick` | `(record, index) => void` | โ€” | Makes rows focusable (`tabIndex=0`) and responsive to click / Enter / Space. Native `role="row"` is preserved (no override). Selection-cell events do not bubble. | | `selectable` | `boolean` | `false` | Enable the selection column. | | `selectedRowKeys` | `string[]` | โ€” | Controlled selected keys. | | `defaultSelectedRowKeys` | `string[]` | `[]` | Uncontrolled initial keys. | | `onSelectedRowKeysChange` | `(keys, rows) => void` | โ€” | Called with the next keys and the matching records. | | `getCheckboxProps` | `(record, index) => Partial` | โ€” | Per-row Checkbox props (base-ui `Checkbox.Root` shape minus Table-owned state props โ€” typically `disabled`, `aria-label`, `data-*`; see [Base UI Checkbox](https://base-ui.com/react/components/checkbox)). `disabled: true` gates the row out of "select all". | | `selectionColumnClassName` | `ClassValue` | โ€” | className for the selection column's `th` and `td`. | | `selectionColumnLabel` | `string` | `"Selection"` | Visually-hidden column name for the selection `
` (announced before "Select all" by screen readers). | | `headerClassName` | `ClassValue` | โ€” | className on `
`. | | `emptyClassName` | `ClassValue` | โ€” | className on the empty-state cell. | | `loadingClassName` | `ClassValue` | โ€” | className on the loading-state cell. | | `className` | `ClassValue` | โ€” | className on the `` element. | ### `TableColumn` [#tablecolumnt] A discriminated union by `dataIndex`. When `dataIndex` is set to a `keyof T`, `render`'s `value` is narrowed to `T[dataIndex]` automatically โ€” no casts needed. | Field | Type | Description | | --------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `key` | `string` | Stable identifier and React key. | | `title` | `ReactNode` | Header content. | | `dataIndex` | `keyof T \| undefined` | **Top-level `keyof T` only.** Nested paths like `"user.name"` are intentionally unsupported โ€” use `render: (_, record) => record.user.name` for deep access. | | `render` | `(value, record, index) => ReactNode` | Cell formatter. `value` is `T[dataIndex]` when `dataIndex` is set, otherwise `undefined`. Required when `dataIndex` points to a non-renderable field such as `Date` or an object. | | `align` | `"left" \| "center" \| "right"` | Text alignment for `th` + `td`. | | `width` | `number \| string` | Emitted as inline `style.width`. | | `className` | `ClassValue` | Applied to both `th` and `td`. | | `headClassName` | `ClassValue` | Applied only to the header cell. | | `cellClassName` | `ClassValue` | Applied only to body cells. | ## Recipe: truncating long cells [#recipe-truncating-long-cells] Single-line truncation needs no extra props โ€” combine a fixed table layout, a column `width`, and `truncate` (the primitive `
` already applies `whitespace-nowrap`). With `table-fixed` + `width` the column width is fixed by the header; add `max-w-0` only if you stay on the default auto layout, where the cell would otherwise grow with its content: ```tsx ``` ## When to use the primitive instead [#when-to-use-the-primitive-instead] This component covers `columns` + `dataSource` + `rowKey` + selection + loading / empty / caption / row className. Anything else is intentionally out of scope: * **Sorting, filtering, pagination** โ€” derive these in your parent and feed `dataSource`. Combine with `@tanstack/react-table` if you want batteries included. * **Fixed columns / sticky headers, expandable rows, drag-to-reorder, column resize, virtualization** โ€” composition territory. * **Error state** โ€” the Table has `loading` and `emptyMessage` but no `errorMessage`. Rows aren't symmetric to a single async load, so the error UX is the parent's responsibility. Render your own error block above the Table (or swap the Table for an error block) when fetching fails. * **Per-row loading** (one row showing a saving spinner while others stay live) โ€” render the spinner inside a cell via `render`. Table-wide `loading` is all-or-nothing. `onRowClick` puts every row in the Tab sequence; that's fine up to 20โ€“30 interactive rows. For larger interactive tables you want a roving-tabindex grid pattern, which is also out of scope โ€” reach for `@tanstack/react-table` and compose the shadcn primitives directly. For any of the above, drop down to `components/ui/table` and compose `
`, ``, ``, ``, ``, ``, `` yourself. ### Server components [#server-components] This component is `"use client"` โ€” selection state, focus management and dev warnings all need the client runtime. A purely static read-only table (just `columns` + `dataSource` + `rowKey` + `caption` + a string `rowClassName`) doesn't strictly need that, but the Table forces CSR anyway. In an RSC page, either accept the client boundary, or drop down to `components/ui/table` primitives directly for the static case. # Tabs (/docs/components/tabs) Tabs flattens shadcn's `Tabs` compound components into a single items-driven component: each item describes its trigger label and panel content, instead of hand-writing nested `TabsList` / `TabsTrigger` / `TabsContent` structures. ## Installation [#installation] With the [`@easy-shadcn` namespace](/docs/installation) configured: ```shell pnpm dlx shadcn@latest add @easy-shadcn/tabs ``` Or install via the full URL (zero configuration): ```shell pnpm dlx shadcn@latest add https://easy-shadcn.vercel.app/r/tabs.json ``` ## Props [#props] | Prop | Type | Default | Description | | ------------------ | --------------------------------------- | ----------- | ---------------------------------------------------------------------------------------- | | `items` | `TabsItem[]` | - | Tab definitions โ€” see the table below. An empty array renders no tab list. | | `value` | `string` | - | The active tab. Use for the controlled mode. | | `defaultValue` | `string` | - | The initially active tab. Use for the uncontrolled mode. | | `onValueChange` | `(value: string, eventDetails) => void` | - | Called when the user activates a tab. | | `variant` | `"default" \| "line"` | `"default"` | List style: pill background or underline, forwarded to the `TabsList` primitive. | | `keepMounted` | `boolean` | `false` | Keeps inactive panels mounted (hidden) instead of unmounting them. Per-item overridable. | | `listClassName` | `ClassValue` | - | Class override for `TabsList`. | | `triggerClassName` | `ClassValue` | - | Class override applied to every `TabsTrigger`. | | `contentClassName` | `ClassValue` | - | Class override applied to every `TabsContent`. | All other props (`className`, `orientation`, etc.) are forwarded to the underlying Tabs root. ### TabsItem [#tabsitem] | Field | Type | Description | | ------------------ | ------------ | ----------------------------------------------------------------------- | | `value` | `string` | Unique identifier of the tab. | | `trigger` | `ReactNode` | Trigger content โ€” plain text or e.g. icon + text. | | `content` | `ReactNode` | Panel content. | | `disabled` | `boolean` | Disables this tab. | | `keepMounted` | `boolean` | Overrides the root-level `keepMounted` for this panel. | | `triggerClassName` | `ClassValue` | Per-item trigger class, merged after the root-level `triggerClassName`. | | `contentClassName` | `ClassValue` | Per-item content class, merged after the root-level `contentClassName`. | ## Notes [#notes] * `value` / `defaultValue` / `onValueChange` are narrowed to `string` โ€” the Base UI primitive accepts `any`, which silently swallows typos. If you need the `null` "no active tab" state, compose the primitives directly. * Use `keepMounted` when a panel holds expensive state (forms, iframes) that should survive tab switches. * `items` must be homogeneous. For heterogeneous triggers or panels (custom markup per trigger, non-uniform layouts), compose the `components/ui/tabs` primitives directly instead. # Tooltip (/docs/components/tooltip) Tooltip collapses shadcn's six nested Tooltip parts (`TooltipProvider` / `Tooltip` / `TooltipTrigger` / `TooltipContent` and the portal + positioner inside) into a single drop-anywhere component: wrap any element and pass the `content`. ## Installation [#installation] With the [`@easy-shadcn` namespace](/docs/installation) configured: ```shell pnpm dlx shadcn@latest add @easy-shadcn/tooltip ``` Or install via the full URL (zero configuration): ```shell pnpm dlx shadcn@latest add https://easy-shadcn.vercel.app/r/tooltip.json ``` ## Props [#props] | Prop | Type | Default | Description | | ------------------ | -------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------- | | `children` | `ReactElement` | - | The trigger. Must be a single element โ€” it becomes the trigger itself. | | `content` | `ReactNode` | - | The tooltip body. | | `side` | `"top" \| "bottom" \| "left" \| "right" \| "inline-start" \| "inline-end"` | `"top"` | Which side of the trigger the tooltip is placed on. | | `sideOffset` | `number` | `4` | Gap between the trigger and the tooltip. | | `align` | `"start" \| "center" \| "end"` | `"center"` | Alignment along the chosen side. | | `alignOffset` | `number` | `0` | Offset along the alignment axis. | | `open` | `boolean` | - | Controlled open state. | | `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled). | | `onOpenChange` | `(open: boolean, eventDetails) => void` | - | Called when the open state changes. | | `disabled` | `boolean` | `false` | Disables the tooltip โ€” it never opens. | | `delay` | `number` | `0` | Delay before opening, in ms. | | `closeDelay` | `number` | `0` | Delay before closing, in ms. | | `contentClassName` | `string` | - | Class override for the tooltip popup. | ## Notes [#notes] * `children` must be a single element that forwards props and a ref (a DOM tag like `; } ``` ## Core Concepts [#core-concepts] ### Modal Lifecycle [#modal-lifecycle] Modals have three main states: * **Created** - Modal is registered but not visible * **Visible** - Modal is shown on screen * **Hidden** - Modal is hidden but still mounted (unless `keepMounted: false`) ### Promise-based Workflows [#promise-based-workflows] CommandModal supports promise-based workflows for async operations: ```tsx const ConfirmModal = CommandModal.create<{ message: string }>(({ message }) => { const modal = CommandModal.useModal(); const handleConfirm = () => { modal.resolve(true); modal.hide(); }; const handleCancel = () => { modal.resolve(false); modal.hide(); }; return (

{message}

); }); // Usage const result = await CommandModal.show(ConfirmModal, { message: 'Are you sure?', }); if (result) { console.log('User confirmed'); } else { console.log('User cancelled'); } ``` #### Promise Settlement Semantics [#promise-settlement-semantics] To avoid leaked pending promises (e.g. `await modal.show()` hanging forever after the modal was dismissed without an explicit `resolve`), the library settles outstanding promises on teardown paths: * `hide(modal)` settles any pending `show()` promise with `undefined` before changing visibility. Your `resolve()` / `reject()` calls still win if they happened first (promises can only settle once). * `remove(modal)` settles any pending `hide()` promise with `undefined`. * When a `ModalDef` unmounts or `unregister()` fires with pending promises, both are settled with `undefined`. In practice this means: if you `await modal.show()` and the user dismisses the modal by clicking the backdrop (which calls `hide()` via the adapter), your `await` resolves with `undefined` instead of hanging. ## Scoped vs. Top-level API [#scoped-vs-top-level-api] The library exposes two routing modes for `show` / `hide` / `remove`: ### Hook-based (recommended) [#hook-based-recommended] `useModal()` reads a scoped dispatch from the closest enclosing `Provider` via React context. This is **deterministic under all conditions** โ€” multiple Providers, StrictMode double-invoke, and concurrent rendering โ€” because the hook routes through context, not a module-level stack. ```tsx function MyButton() { const modal = useModal(MyModal); return ; } ``` ### Top-level imports (legacy / imperative) [#top-level-imports-legacy--imperative] The module-level `show` / `hide` / `remove` functions dispatch to whichever Provider is on top of an internal stack. When exactly one Provider is mounted, this is unambiguous. **With multiple Providers mounted the routing target is unspecified** โ€” you will see a dev-only warning: ``` [CommandModal] Multiple Providers are currently mounted (N). Top-level show/hide/remove routes to an arbitrary Provider and should be considered undefined in multi-Provider setups. Use useModal() inside your component tree for scoped, deterministic dispatching. ``` If you need imperative access from outside a component (e.g. inside a Redux thunk or a library integration), prefer `useCommandModalDispatch()` and pass the dispatch where you need it. ## API Reference [#api-reference] ### CommandModal.Provider [#commandmodalprovider] The root provider component that manages modal state. ```tsx interface CommandModalProviderProps { children: React.ReactNode; config?: CommandModalConfig; } ``` **Props:** * `children` - Your app content * `config` - Optional configuration object ### CommandModal.create() [#commandmodalcreate] Creates a modal component with proper typing. ```tsx function create( component: React.ComponentType ): CreateModalComponent ``` **Type Parameters:** * `TProps` - Props type for your modal component (validated at `show()` time) * `TResult` - The type `show()` resolves with (what you pass to `modal.resolve()`); defaults to `unknown` **Returns:** A wrapped component carrying both `TProps` and `TResult`, for use with `show()` ### CommandModal.show() [#commandmodalshow] Shows a modal and returns a promise. Both the args and the resolved type are inferred from the component created by `create` โ€” no explicit type argument: ```tsx // Component form โ€” args (Props) and result (Result) inferred from the component: function show(modal: C, args?: Partial): Promise // String-id form โ€” the id carries no type, so pass the result explicitly: function show(modal: string, args?: Record): Promise ``` **Parameters:** * `modal` - Modal component created with `CommandModal.create()` (or a string id) * `args` - Args passed to the modal component (validated against its `Props`) **Returns:** Promise that resolves with the value passed to `modal.resolve()` (typed `Result`), or `undefined` if the modal is dismissed without resolving. ### CommandModal.hide() [#commandmodalhide] Hides a specific modal. ```tsx function hide(modal: React.ComponentType): Promise ``` **Parameters:** * `modal` - Modal component to hide **Returns:** Promise that resolves when modal is hidden ### CommandModal.remove() [#commandmodalremove] Removes a modal from the DOM. ```tsx function remove(modal: React.ComponentType): void ``` **Parameters:** * `modal` - Modal component to remove ### CommandModal.useModal() [#commandmodalusemodal] Hook to access modal controls within a modal component. Inside the body, call it with no argument (the modal id comes from context); the resolve type is the `Result` you declared on `create`. ```tsx const modal = useModal(); // inside a create()'d modal ``` **Returns:** Modal handler object with the following properties: * `id` - Unique modal identifier * `visible` - Current visibility state * `keepMounted` - Whether modal stays mounted when hidden * `show()` - Show the modal * `hide()` - Hide the modal * `remove()` - Remove the modal * `resolve(value)` - Resolve the modal promise with a value * `reject(reason)` - Reject the modal promise * `resolveHide()` - Called when modal animation completes * `modalProps` - Props object for your UI library's modal ### CommandModal.useModalHolder() [#commandmodalusemodalholder] Hook to control a modal from outside the modal component. ```tsx function useModalHolder( modal: React.ComponentType ): [React.ComponentType, CommandModalHandler] ``` **Parameters:** * `modal` - Modal component created with `CommandModal.create()` **Returns:** Tuple of `[ModalComponent, handler]` **Example:** ```tsx function MyComponent() { const [UserModal, userModal] = CommandModal.useModalHolder(UserFormModal); return ( <> ); } ``` ### CommandModal.useCommandModalDispatch() [#commandmodalusecommandmodaldispatch] Hook that returns the raw reducer dispatch of the closest enclosing `Provider`, or `null` when called outside any Provider subtree. ```tsx function useCommandModalDispatch(): Dispatch | null ``` Use this as an **escape hatch** when you need scoped, deterministic dispatch from non-component code (library integrations, middlewares, imperative helpers you pass into event handlers). For normal modal control, prefer `useModal()`. ### CommandModal.CommandModalDispatchContext [#commandmodalcommandmodaldispatchcontext] The React context that carries the Provider-scoped dispatch. Exposed for advanced use cases such as writing custom hooks that must interoperate with the command-modal reducer. Most callers should use `useCommandModalDispatch()` or `useModal()` instead. ## Advanced Usage [#advanced-usage] ### Adapters [#adapters] An **adapter** maps the modal handler to the prop shape one UI library expects. It is the single seam by which command-modal stays UI-library-agnostic. Each adapter emits its own library's real prop names. #### Default shadcn adapter [#default-shadcn-adapter] shadcn/ui (Base UI Dialog) is the zero-config default โ€” you don't configure anything. The default `createModalProps` emits Base UI's real props `{ open, onOpenChange, onOpenChangeComplete }`, wiring teardown to `onOpenChangeComplete` (guarded on close) so `` removes the modal once its close animation finishes: ```tsx import { createModalProps } from '@easy-shadcn/command-modal'; // Used automatically; you only import it to build on top of it. ``` #### Typed adapters with `createCommandModal` (antd, โ€ฆ) [#typed-adapters-with-createcommandmodal-antd-] For a non-shadcn library, pair its adapter with `createCommandModal` once. The factory returns a `Provider` pre-bound to the adapter and a `useModal` whose `modalProps` is **statically typed** as that library's props โ€” no casts. **antd v6** is first-class: an official `antdModalProps` adapter ships at the `/antd` subpath (no `antd` dependency is added to the core). Set it up once in an app-local barrel, and re-export the imperative verbs from the same module so app code has a single import source: ```tsx // lib/modal.ts โ€” the only command-modal entry point in your app import { createCommandModal, show, hide, remove, create, } from '@easy-shadcn/command-modal'; import { antdModalProps } from '@easy-shadcn/command-modal/antd'; // Call the factory once at module scope (never inside a render). export const { Provider, useModal } = createCommandModal(antdModalProps); export { show, hide, remove, create }; ``` ```tsx // any feature file import { useModal, show } from '@/lib/modal'; const EditUser = create(() => { const modal = useModal(); // modal.modalProps is typed as antd's { open, onCancel, afterClose } return โ€ฆ; }); ``` > **Guard the footgun.** The package root also exports a `useModal` typed for > shadcn. If you accidentally import it instead of your factory's, `modalProps` > silently reverts to the shadcn shape. Forbid the root import in your project > so it fails in CI instead of at runtime: > > ```jsonc > // eslint config โ€” no-restricted-imports > { > "paths": [{ > "name": "@easy-shadcn/command-modal", > "importNames": ["useModal"], > "message": "Import useModal from your createCommandModal factory (e.g. @/lib/modal)." > }] > } > ``` #### Writing your own adapter (BYO) [#writing-your-own-adapter-byo] Any UI library works through a typed adapter โ€” emit its real prop names and wire teardown to its post-close hook: ```tsx import type { ModalPropsAdapter } from '@easy-shadcn/command-modal'; // Example: Material UI type MuiDialogProps = { open: boolean; onClose: () => void; TransitionProps?: { onExited?: () => void }; }; const muiModalProps: ModalPropsAdapter = (handler) => ({ open: handler.visible, onClose: () => handler.hide(), TransitionProps: { onExited: () => { handler.resolveHide(); if (!handler.keepMounted) { handler.remove(); } }, }, }); // Then: export const { Provider, useModal } = createCommandModal(muiModalProps); ``` ### Keep Modal Mounted [#keep-modal-mounted] By default, modals are removed from the DOM after they are hidden. To keep a modal mounted across hide/show cycles (e.g. to preserve scroll position or form state), pass `keepMounted` on the JSX-declared modal instance: ```tsx const MyModal = CommandModal.create(() => { const modal = CommandModal.useModal(); return ...; }); function App() { return ( {/* Declare keepMounted on the HOC; it configures the modal itself. */} {/* ...rest of the app */} ); } ``` > Do not assign `modal.keepMounted = true` inside the render body โ€” the > handler returned by `useModal()` is read-only. Use the `keepMounted` prop > on the JSX-declared modal as shown above. ### Nested Modals [#nested-modals] CommandModal supports nested modals out of the box: ```tsx const ConfirmModal = CommandModal.create(() => { const modal = CommandModal.useModal(); return Confirm?; }); const ParentModal = CommandModal.create(() => { const modal = CommandModal.useModal(); const handleDelete = async () => { const confirmed = await CommandModal.show(ConfirmModal); if (confirmed) { // Delete action modal.hide(); } }; return ( ); }); ``` ### Error Handling [#error-handling] Handle errors in modal workflows: ```tsx const FormModal = CommandModal.create(() => { const modal = CommandModal.useModal(); const handleSubmit = async () => { try { const result = await submitForm(); modal.resolve(result); modal.hide(); } catch (error) { modal.reject(error); modal.hide(); } }; return ...; }); // Usage try { const result = await CommandModal.show(FormModal); console.log('Success:', result); } catch (error) { console.error('Error:', error); } ``` ## TypeScript [#typescript] ### Type-safe Props [#type-safe-props] ```tsx interface UserFormProps { userId: string; mode: 'create' | 'edit'; } const UserForm = CommandModal.create(({ userId, mode }) => { // TypeScript knows userId and mode types const modal = CommandModal.useModal(); return ...; }); // Type-safe usage CommandModal.show(UserForm, { userId: '123', mode: 'edit', }); // TypeScript error: missing required props // CommandModal.show(UserForm, {}); // โŒ ``` ### Type-safe Results [#type-safe-results] Declare a modal's resolve (result) type as the **second type argument to `create`**. It flows to `show()`, which infers both the args and the result with no explicit type argument and no cast: ```tsx interface FormResult { name: string; email: string; } const FormModal = CommandModal.create<{ defaultName?: string }, FormResult>( ({ defaultName }) => { const modal = CommandModal.useModal(); const handleSubmit = (data: FormResult) => { modal.resolve(data); modal.hide(); }; return ...; } ); // show() infers Promise AND validates the args. const result = await CommandModal.show(FormModal, { defaultName: 'Ada' }); // result: FormResult ``` The result type is declared at the definition site (not the `show()` call site) so that args-checking and a typed result can coexist โ€” TypeScript has no partial type-argument inference, so pinning the result at the call site would disable args inference. The result type defaults to `unknown` when omitted, so modals whose result you don't consume need no extra annotation. ## Best Practices [#best-practices] ### 1. Use TypeScript for Type Safety [#1-use-typescript-for-type-safety] Always define prop types and result types for better IDE support and type checking. ### 2. Clean Up Side Effects [#2-clean-up-side-effects] Clean up subscriptions and side effects when modal is hidden: ```tsx const MyModal = CommandModal.create(() => { const modal = CommandModal.useModal(); useEffect(() => { if (!modal.visible) { // Clean up when modal is hidden return; } const subscription = subscribe(); return () => subscription.unsubscribe(); }, [modal.visible]); return ...; }); ``` ### 3. Separate Modal Logic [#3-separate-modal-logic] Keep complex logic in custom hooks: ```tsx function useUserForm(userId: string) { const [data, setData] = useState(); const [loading, setLoading] = useState(false); const submit = async () => { setLoading(true); // Submit logic setLoading(false); }; return { data, loading, submit }; } const UserFormModal = CommandModal.create<{ userId: string }>(({ userId }) => { const modal = CommandModal.useModal(); const form = useUserForm(userId); return ...; }); ``` ### 4. Avoid Blocking Operations [#4-avoid-blocking-operations] Don't perform blocking operations in modal render: ```tsx // โŒ Bad: Fetching in render const MyModal = CommandModal.create(() => { const data = fetchData(); // Don't do this return ...; }); // โœ… Good: Fetch in effect const MyModal = CommandModal.create(() => { const [data, setData] = useState(); useEffect(() => { fetchData().then(setData); }, []); return ...; }); ``` ## Migration Guide [#migration-guide] ### From @ebay/nice-modal-react [#from-ebaynice-modal-react] CommandModal is largely compatible with nice-modal-react: ```tsx // Before (nice-modal-react) import NiceModal, { useModal } from '@ebay/nice-modal-react'; const MyModal = NiceModal.create(() => { const modal = useModal(); return ...; }); NiceModal.show(MyModal); // After (command-modal) import CommandModal from '@easy-shadcn/command-modal'; const MyModal = CommandModal.create(() => { const modal = CommandModal.useModal(); return ...; }); CommandModal.show(MyModal); ``` **Key Differences:** * Must configure modal adapter through Provider config * Default adapter is for shadcn/ui * Enhanced TypeScript support * Configurable modal props adapters ## Troubleshooting [#troubleshooting] ### Modal Doesn't Close (or leaks after closing) [#modal-doesnt-close-or-leaks-after-closing] Spread `modal.modalProps` directly onto your dialog so the adapter's post-close hook reaches the underlying component. For the shadcn/Base UI default that hook is `onOpenChangeComplete`; if you forward props by hand, don't drop it, or the modal is never removed after it closes: ```tsx // โœ… spread everything โ€” open, onOpenChange, onOpenChangeComplete ... ``` ### TypeScript Errors [#typescript-errors] Make sure you're using the correct generic types: ```tsx // Define props type interface MyModalProps { title: string; } // Pass type to create const MyModal = CommandModal.create(({ title }) => { // ... }); ``` ### Modal Not Showing [#modal-not-showing] 1. Check that Provider is wrapping your app 2. Verify the modal component is created with `CommandModal.create()` 3. Check browser console for errors ### `Multiple Providers are currently mounted` Warning [#multiple-providers-are-currently-mounted-warning] This dev-only warning fires when **two or more `` are mounted in the React tree simultaneously** AND top-level `show` / `hide` / `remove` is called. Module-level helpers route to an internal stack of Providers, and with more than one mounted the routing target is not guaranteed. **Fixes:** * If the nested Provider is unintentional (e.g. a page layout wraps one and a demo component wraps another), remove the inner Provider and share the outer one. * If you deliberately run multiple Providers (isolated sub-apps, design system docs rendering demos), switch to `useModal()` inside each sub-tree โ€” hooks route via context and are deterministic. * For imperative dispatch outside components, use `useCommandModalDispatch()` at the relevant subtree to grab a scoped dispatch and pass it where you need it. ## Examples [#examples] ### Confirmation Dialog [#confirmation-dialog] ```tsx const ConfirmDialog = CommandModal.create< { title: string; message: string }, boolean >(({ title, message }) => { const modal = CommandModal.useModal(); return ( {title} {message} { modal.resolve(false); modal.hide(); }}> Cancel { modal.resolve(true); modal.hide(); }}> Confirm ); }); // Usage const confirmed = await CommandModal.show(ConfirmDialog, { title: 'Delete item?', message: 'This action cannot be undone.', }); if (confirmed) { deleteItem(); } ``` ### Form Modal with Validation [#form-modal-with-validation] ```tsx const FormModal = CommandModal.create<{ defaultValues?: FormData }, FormData>( ({ defaultValues }) => { const modal = CommandModal.useModal(); const [values, setValues] = useState(defaultValues || {}); const [errors, setErrors] = useState({}); const handleSubmit = () => { const validationErrors = validate(values); if (Object.keys(validationErrors).length > 0) { setErrors(validationErrors); return; } modal.resolve(values); modal.hide(); }; return ( Form
{/* Form fields */}
); } ); ``` ### Multi-step Wizard [#multi-step-wizard] ```tsx const WizardModal = CommandModal.create(() => { const modal = CommandModal.useModal(); const [step, setStep] = useState(1); return ( {step === 1 && setStep(2)} />} {step === 2 && setStep(3)} onBack={() => setStep(1)} />} {step === 3 && modal.hide()} onBack={() => setStep(2)} />} ); }); ``` ## Credits [#credits] This project is inspired by and built upon the patterns established by [@ebay/nice-modal-react](https://github.com/eBay/nice-modal-react). Special thanks to the original authors for their pioneering work in imperative modal management. ## License [#license] MIT