@dreamtree-org/twreact-ui 2.0.2 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -708,6 +708,12 @@
708
708
  "default": "false",
709
709
  "description": "Keep the desktop header visible while the body scrolls. `true` sticks within the table's own scroll container; a CSS length (`\"24rem\"`) or a number (px) also caps that container's height so the rows scroll under the header."
710
710
  },
711
+ {
712
+ "prop": "paginationPosition",
713
+ "type": "`'top'` | `'bottom'` | `'both'`",
714
+ "default": "'both'",
715
+ "description": "Which pager renders when `pagination` is on. `'both'` keeps the compact pager in the toolbar and the \"Page N of M\" footer. `'bottom'` drops the toolbar pager but keeps the rest of the toolbar (reload, page size, result count). `'top'` keeps only the toolbar pager (so with `showToolbar={false}` there is no pager). Unknown values fall back to `'both'`."
716
+ },
711
717
  {
712
718
  "prop": "showToolbar",
713
719
  "type": "`boolean`",
@@ -733,7 +739,7 @@
733
739
  "description": "Cap the fields shown on each mobile card. Applied in column order, after `hideOnMobile` columns are removed."
734
740
  }
735
741
  ],
736
- "examples": "### Basic table\n\n```jsx\nconst columns = [\n { key: 'name', label: 'Name' },\n { key: 'email', label: 'Email' },\n];\nconst data = [\n { id: 1, name: 'Alice', email: 'alice@example.com' },\n { id: 2, name: 'Bob', email: 'bob@example.com' },\n];\n<Table data={data} columns={columns} />\n```\n\n### With pagination and selection\n\n```jsx\n<Table\n data={data}\n columns={columns}\n pagination\n pageSize={10}\n selectable\n onSelectionChange={(selected) => console.log(selected)}\n/>\n```\n\n### Server-side data\n\n```jsx\n<Table\n data={data}\n columns={columns}\n serverSide\n pagination\n totalRecords={total}\n onFetch={async ({ setData, setLoading, page, limit, sort }) => {\n setLoading(true);\n const res = await fetch(`/api/users?page=${page}&limit=${limit}`);\n const json = await res.json();\n setData(json.rows);\n setLoading(false);\n }}\n/>\n```\n\n### Filtering and page clamping (client-side)\n\nWithout `serverSide`, the table filters the rows itself:\n\n- **Global search** (`globalSearch`) applies on Enter or the search button,\n with or without `filterable`. Columns with `searchable: false` are skipped.\n- **Column filters** (`filterable`) apply as you type. Each one matches only its\n own column's raw value (not a `render` output).\n- All matches are case-insensitive \"contains\" tests, and every active filter\n must match (AND).\n- `onFilter` / `onFilterChange` still receive the whole `filters` object\n (`{ global?, [columnKey]: text }`) on every change.\n\nWhen the rows shrink (filtering, deleted rows, a new `data` prop) and the\ncurrent page no longer exists, the table shows the last page that does and\ncalls `onPageChange` with it. A parent that drives `pageNumber` can simply\nstore that value, and no `clampPage` helper is needed. The clamp waits while\nthere are no rows, so a page restored from the URL survives until the data\narrives.\n\nWith `serverSide`, none of this runs locally: filters go to `onFetch` /\n`onFilter` / `onFilterChange`, the rows shown are exactly the rows you pass,\nand keeping `pageNumber` within `totalRecords` is up to you.\n\n### Compact, read-only table\n\nHide the chrome for a dashboard card, keep id columns out of the search, let\nlong text wrap, and keep phone cards short.\n\n```jsx\nconst columns = [\n { key: 'id', label: 'ID', searchable: false, hideOnMobile: true },\n { key: 'client', label: 'Client' },\n { key: 'status', label: 'Status' },\n { key: 'notes', label: 'Notes', wrap: true },\n { key: 'owner', label: 'Owner' },\n];\n\n<Table\n data={rows}\n columns={columns}\n showToolbar={false}\n showSerial={false}\n withAction={false}\n mobileMaxFields={3}\n/>\n```\n\nOn a phone each card shows Client, Status and Notes. With no serial, selection,\nexpand toggle or actions, the card has no header strip either.\n\n`showToolbar={false}` does not change pagination: with `pagination` on, the pager\nfooter still renders. Hide only the picker with `showColumnPicker={false}`.\n\n### Conditional row details\n\n`hasDetails` accepts a predicate so only rows that actually have something to\nshow get an expand toggle. Rows that don't qualify still render the (empty)\ntoggle cell, so the columns stay aligned.\n\n```jsx\nconst ErrorDetails = ({ row }) => <pre>{row.stackTrace}</pre>;\n\n<Table\n data={jobs}\n columns={columns}\n hasDetails={(row) => row.status === 'failed'}\n DetailsComponent={ErrorDetails}\n/>\n```\n\n### Mobile cards, filters and swipe actions\n\nBelow `responsiveBreakpoint` rows render as cards. Card controls (expand\ntoggle, selection checkbox, inline actions, kebab) have 44×44 px hit areas.\nWith `filterable` on a phone, the filters open in a bottom sheet with an\nApply / Clear footer; pass `mobileFiltersPresentation=\"inline\"` to keep the\ninline panel.\n\n```jsx\n<Table\n data={messages}\n columns={columns}\n filterable\n defaultMobileView // server-rendered HTML starts as cards\n swipeActions={(row) => ({\n leading: [{ key: 'read', label: 'Read', icon: MailOpen, tone: 'primary', onSelect: () => markRead(row) }],\n trailing: [\n { key: 'delete', label: 'Delete', icon: Trash2, tone: 'destructive', onSelect: () => remove(row) },\n { key: 'flag', label: 'Flag', icon: Flag, tone: 'warning', onSelect: () => flag(row) },\n ],\n })}\n/>\n```\n\n### Sticky header\n\n```jsx\n<Table data={rows} columns={columns} stickyHeader=\"28rem\" />\n```\n\n### Row action menu\n\nThe kebab button (`aria-haspopup=\"menu\"`) opens the row's menu actions. On\ndesktop it is a `role=\"menu\"` popover: focus moves to the first action,\nArrowUp/ArrowDown (wrapping), Home/End and first-letter typeahead move between\nactions, Escape or Tab closes it and returns focus to the kebab, and a press\noutside or a page scroll closes it (pressing the kebab again closes it too). On phones (`actionMenuPresentation=\"auto\"`,\nthe default) the same actions open as an action sheet: 48px rows in a rounded\ncard, destructive actions in red, and a Cancel button below. Pass\n`actionMenuPresentation=\"popover\"` to keep the desktop menu everywhere.\n\n```jsx\n<Table\n data={users}\n columns={columns}\n actions={[{ name: 'delete', tone: 'destructive' }]}\n actionMenuPresentation=\"auto\"\n/>\n```\n\n### Conditional and inline actions\n\n`hidden` and `disabled` accept a `(row, index)` predicate, `label` and `icon`\naccept a `(row, index)` producer, and `inline: true` promotes an action out of\nthe kebab menu into the row itself. When no menu actions remain for a row, the\nkebab button is not rendered at all.\n\n```jsx\n<Table\n data={jobs}\n columns={columns}\n replaceDefaultActions\n actions={[\n { name: 'view', label: 'View', icon: <Eye size={16} />, inline: true },\n { name: 'edit', label: 'Edit', icon: <Edit size={16} />,\n disabled: (row) => row.locked },\n { name: 'retry', label: (row) => `Retry (${row.tries})`,\n hidden: (row) => row.status !== 'failed' },\n ]}\n onAction={({ action, row }) => handle(action.name, row)}\n/>\n```\n\nFor a large table, wrap a function-valued `actions` in `useCallback` — a fresh\narrow on every render defeats the per-row memoization.\n\n```jsx\nconst actions = useCallback(\n (row) => (row.role === 'admin' ? adminActions : userActions),\n []\n);\n```\n\nNote that because `icon` may be a `(row, index) => node` producer, passing a\nbare component reference (`icon: Edit`) calls it rather than rendering it. Pass\nan element instead: `icon: <Edit size={16} />`.\n\n### Patching the default actions\n\nEntries you supply are merged onto the built-in Edit/Delete/View set by `name`,\nso you can swap one handler without restating the rest. Unmatched names are\nappended after the defaults, and `hidden: true` removes a default outright.\n\n```jsx\n<Table\n data={users}\n columns={columns}\n actions={[\n { name: 'edit', onClick: ({ row }) => edit(row) }, // keeps default label + icon\n { name: 'delete', hidden: (row) => !row.canDelete },\n { name: 'invite', label: 'Invite', icon: <Mail size={16} /> }, // appended\n ]}\n/>\n```\n\nPass `replaceDefaultActions` to skip merging and use only your own set:\n\n```jsx\n<Table\n data={users}\n columns={columns}\n replaceDefaultActions\n actions={[{ name: 'approve', label: 'Approve' }]}\n/>\n```\n\n### Interactive Table Mode\n\nPass `interactive={true}` to enable inline cell editing (double-click a cell or press standard `'e'` key on a focused active cell to enter edit mode), instant record addition (at top or bottom), keyboard navigation (`Tab`/`Shift+Tab` to move between cells, `Escape` to exit edit mode, `Enter` to save modified changes), and constraint validation before saving.\n\n#### Interactive Props & Options\n\n- `interactive`: `boolean` (default: `false`) — Enables interactive mode.\n- `editKeyBinding`: `string | ((e) => boolean)` (default: `\"e\"`) — Standard key binding to enter cell edit mode on active focused cell.\n- `newRowPosition`: `\"bottom\" | \"top\"` (default: `\"bottom\"`) — Position where empty new rows are inserted (at the bottom or top of table dataset).\n- `addRowRef`: `React.MutableRefObject` — External ref populated with `(position?: \"top\" | \"bottom\") => void` to trigger adding a new row from external buttons.\n- `onInitAddRow`: `(addRowFn: (position?: \"top\" | \"bottom\") => void) => void` — Callback receiving the add-row function on component initialization.\n- `renderAddRowButton`: `({ addRow, newRowPosition }) => ReactNode` — Render prop for rendering a custom external add-row button in the table toolbar.\n\n#### Interactive Usage Example\n\n```jsx\nconst columns = [\n { key: 'name', label: 'Name', type: 'text', required: true },\n { key: 'role', label: 'Role', type: 'select', options: ['Admin', 'User'], required: true },\n { key: 'notes', label: 'Notes', type: 'textarea' },\n];\n\nconst addRowRef = useRef();\n\nreturn (\n <div>\n <button onClick={() => addRowRef.current?.(\"top\")}>\n Add Row at Top\n </button>\n\n <Table\n interactive\n editKeyBinding=\"e\"\n newRowPosition=\"top\"\n addRowRef={addRowRef}\n columns={columns}\n data={users}\n onSave={async ({ updatedRows, newRows, allData }) => {\n await api.saveUsers({ updatedRows, newRows });\n }}\n />\n </div>\n);\n```\n\n[← Component overview](README.md)"
742
+ "examples": "### Basic table\n\n```jsx\nconst columns = [\n { key: 'name', label: 'Name' },\n { key: 'email', label: 'Email' },\n];\nconst data = [\n { id: 1, name: 'Alice', email: 'alice@example.com' },\n { id: 2, name: 'Bob', email: 'bob@example.com' },\n];\n<Table data={data} columns={columns} />\n```\n\n### With pagination and selection\n\n```jsx\n<Table\n data={data}\n columns={columns}\n pagination\n pageSize={10}\n selectable\n onSelectionChange={(selected) => console.log(selected)}\n/>\n```\n\n### Server-side data\n\n```jsx\n<Table\n data={data}\n columns={columns}\n serverSide\n pagination\n totalRecords={total}\n onFetch={async ({ setData, setLoading, page, limit, sort }) => {\n setLoading(true);\n const res = await fetch(`/api/users?page=${page}&limit=${limit}`);\n const json = await res.json();\n setData(json.rows);\n setLoading(false);\n }}\n/>\n```\n\n### Filtering and page clamping (client-side)\n\nWithout `serverSide`, the table filters the rows itself:\n\n- **Global search** (`globalSearch`) applies on Enter or the search button,\n with or without `filterable`. Columns with `searchable: false` are skipped.\n- **Column filters** (`filterable`) apply as you type. Each one matches only its\n own column's raw value (not a `render` output).\n- All matches are case-insensitive \"contains\" tests, and every active filter\n must match (AND).\n- `onFilter` / `onFilterChange` still receive the whole `filters` object\n (`{ global?, [columnKey]: text }`) on every change.\n\nWhen the rows shrink (filtering, deleted rows, a new `data` prop) and the\ncurrent page no longer exists, the table shows the last page that does and\ncalls `onPageChange` with it. A parent that drives `pageNumber` can simply\nstore that value, and no `clampPage` helper is needed. The clamp waits while\nthere are no rows, so a page restored from the URL survives until the data\narrives.\n\nWith `serverSide`, none of this runs locally: filters go to `onFetch` /\n`onFilter` / `onFilterChange`, the rows shown are exactly the rows you pass,\nand keeping `pageNumber` within `totalRecords` is up to you.\n\n### Compact, read-only table\n\nHide the chrome for a dashboard card, keep id columns out of the search, let\nlong text wrap, and keep phone cards short.\n\n```jsx\nconst columns = [\n { key: 'id', label: 'ID', searchable: false, hideOnMobile: true },\n { key: 'client', label: 'Client' },\n { key: 'status', label: 'Status' },\n { key: 'notes', label: 'Notes', wrap: true },\n { key: 'owner', label: 'Owner' },\n];\n\n<Table\n data={rows}\n columns={columns}\n showToolbar={false}\n showSerial={false}\n withAction={false}\n mobileMaxFields={3}\n/>\n```\n\nOn a phone each card shows Client, Status and Notes. With no serial, selection,\nexpand toggle or actions, the card has no header strip either.\n\n`showToolbar={false}` does not change pagination: with `pagination` on, the pager\nfooter still renders. Hide only the picker with `showColumnPicker={false}`.\n\n### One pager instead of two\n\nWith `pagination` and the toolbar both on, the table shows a compact pager in the\ntoolbar and another under the rows. Keep the toolbar (reload, page size, result\ncount) and drop the top pager with `paginationPosition=\"bottom\"`:\n\n```jsx\n<Table data={rows} columns={columns} pagination paginationPosition=\"bottom\" />\n```\n\n`paginationPosition=\"top\"` does the opposite and keeps only the toolbar pager.\n\n### Conditional row details\n\n`hasDetails` accepts a predicate so only rows that actually have something to\nshow get an expand toggle. Rows that don't qualify still render the (empty)\ntoggle cell, so the columns stay aligned.\n\n```jsx\nconst ErrorDetails = ({ row }) => <pre>{row.stackTrace}</pre>;\n\n<Table\n data={jobs}\n columns={columns}\n hasDetails={(row) => row.status === 'failed'}\n DetailsComponent={ErrorDetails}\n/>\n```\n\n### Mobile cards, filters and swipe actions\n\nBelow `responsiveBreakpoint` rows render as cards. Card controls (expand\ntoggle, selection checkbox, inline actions, kebab) have 44×44 px hit areas.\nWith `filterable` on a phone, the filters open in a bottom sheet with an\nApply / Clear footer; pass `mobileFiltersPresentation=\"inline\"` to keep the\ninline panel.\n\n```jsx\n<Table\n data={messages}\n columns={columns}\n filterable\n defaultMobileView // server-rendered HTML starts as cards\n swipeActions={(row) => ({\n leading: [{ key: 'read', label: 'Read', icon: MailOpen, tone: 'primary', onSelect: () => markRead(row) }],\n trailing: [\n { key: 'delete', label: 'Delete', icon: Trash2, tone: 'destructive', onSelect: () => remove(row) },\n { key: 'flag', label: 'Flag', icon: Flag, tone: 'warning', onSelect: () => flag(row) },\n ],\n })}\n/>\n```\n\n### Sticky header\n\n```jsx\n<Table data={rows} columns={columns} stickyHeader=\"28rem\" />\n```\n\n### Row action menu\n\nThe kebab button (`aria-haspopup=\"menu\"`) opens the row's menu actions. On\ndesktop it is a `role=\"menu\"` popover: focus moves to the first action,\nArrowUp/ArrowDown (wrapping), Home/End and first-letter typeahead move between\nactions, Escape or Tab closes it and returns focus to the kebab, and a press\noutside or a page scroll closes it (pressing the kebab again closes it too). On phones (`actionMenuPresentation=\"auto\"`,\nthe default) the same actions open as an action sheet: 48px rows in a rounded\ncard, destructive actions in red, and a Cancel button below. Pass\n`actionMenuPresentation=\"popover\"` to keep the desktop menu everywhere.\n\n```jsx\n<Table\n data={users}\n columns={columns}\n actions={[{ name: 'delete', tone: 'destructive' }]}\n actionMenuPresentation=\"auto\"\n/>\n```\n\n### Conditional and inline actions\n\n`hidden` and `disabled` accept a `(row, index)` predicate, `label` and `icon`\naccept a `(row, index)` producer, and `inline: true` promotes an action out of\nthe kebab menu into the row itself. When no menu actions remain for a row, the\nkebab button is not rendered at all.\n\n```jsx\n<Table\n data={jobs}\n columns={columns}\n replaceDefaultActions\n actions={[\n { name: 'view', label: 'View', icon: <Eye size={16} />, inline: true },\n { name: 'edit', label: 'Edit', icon: <Edit size={16} />,\n disabled: (row) => row.locked },\n { name: 'retry', label: (row) => `Retry (${row.tries})`,\n hidden: (row) => row.status !== 'failed' },\n ]}\n onAction={({ action, row }) => handle(action.name, row)}\n/>\n```\n\nFor a large table, wrap a function-valued `actions` in `useCallback` — a fresh\narrow on every render defeats the per-row memoization.\n\n```jsx\nconst actions = useCallback(\n (row) => (row.role === 'admin' ? adminActions : userActions),\n []\n);\n```\n\nNote that because `icon` may be a `(row, index) => node` producer, passing a\nbare component reference (`icon: Edit`) calls it rather than rendering it. Pass\nan element instead: `icon: <Edit size={16} />`.\n\n### Patching the default actions\n\nEntries you supply are merged onto the built-in Edit/Delete/View set by `name`,\nso you can swap one handler without restating the rest. Unmatched names are\nappended after the defaults, and `hidden: true` removes a default outright.\n\n```jsx\n<Table\n data={users}\n columns={columns}\n actions={[\n { name: 'edit', onClick: ({ row }) => edit(row) }, // keeps default label + icon\n { name: 'delete', hidden: (row) => !row.canDelete },\n { name: 'invite', label: 'Invite', icon: <Mail size={16} /> }, // appended\n ]}\n/>\n```\n\nPass `replaceDefaultActions` to skip merging and use only your own set:\n\n```jsx\n<Table\n data={users}\n columns={columns}\n replaceDefaultActions\n actions={[{ name: 'approve', label: 'Approve' }]}\n/>\n```\n\n### Interactive Table Mode\n\nPass `interactive={true}` to enable inline cell editing (double-click a cell or press standard `'e'` key on a focused active cell to enter edit mode), instant record addition (at top or bottom), keyboard navigation (`Tab`/`Shift+Tab` to move between cells, `Escape` to exit edit mode, `Enter` to save modified changes), and constraint validation before saving.\n\n#### Interactive Props & Options\n\n- `interactive`: `boolean` (default: `false`) — Enables interactive mode.\n- `editKeyBinding`: `string | ((e) => boolean)` (default: `\"e\"`) — Standard key binding to enter cell edit mode on active focused cell.\n- `newRowPosition`: `\"bottom\" | \"top\"` (default: `\"bottom\"`) — Position where empty new rows are inserted (at the bottom or top of table dataset).\n- `addRowRef`: `React.MutableRefObject` — External ref populated with `(position?: \"top\" | \"bottom\") => void` to trigger adding a new row from external buttons.\n- `onInitAddRow`: `(addRowFn: (position?: \"top\" | \"bottom\") => void) => void` — Callback receiving the add-row function on component initialization.\n- `renderAddRowButton`: `({ addRow, newRowPosition }) => ReactNode` — Render prop for rendering a custom external add-row button in the table toolbar.\n\n#### Interactive Usage Example\n\n```jsx\nconst columns = [\n { key: 'name', label: 'Name', type: 'text', required: true },\n { key: 'role', label: 'Role', type: 'select', options: ['Admin', 'User'], required: true },\n { key: 'notes', label: 'Notes', type: 'textarea' },\n];\n\nconst addRowRef = useRef();\n\nreturn (\n <div>\n <button onClick={() => addRowRef.current?.(\"top\")}>\n Add Row at Top\n </button>\n\n <Table\n interactive\n editKeyBinding=\"e\"\n newRowPosition=\"top\"\n addRowRef={addRowRef}\n columns={columns}\n data={users}\n onSave={async ({ updatedRows, newRows, allData }) => {\n await api.saveUsers({ updatedRows, newRows });\n }}\n />\n </div>\n);\n```\n\n[← Component overview](README.md)"
737
743
  },
738
744
  {
739
745
  "name": "Form",
@@ -6373,9 +6379,21 @@
6373
6379
  "type": "`function`",
6374
6380
  "default": "",
6375
6381
  "description": "`(instance, method, url, data, headers) => Promise<AxiosResponse>`; GET/HEAD/DELETE send no body"
6382
+ },
6383
+ {
6384
+ "prop": "onUnauthorized",
6385
+ "type": "`(error, request: { apiKey?, url, method }) => void`",
6386
+ "default": "",
6387
+ "description": "Called once per failing request that `isUnauthorized` matches, before the promise rejects. The promise still rejects with the original error. `apiKey` is the `apiMap` key for `fetchApi` / `fetchApiWith` and `undefined` for `fetchData`; `method` is upper-cased. If the handler throws, the throw is logged with `console.error` and the request still rejects with its own error"
6388
+ },
6389
+ {
6390
+ "prop": "isUnauthorized",
6391
+ "type": "`(error) => boolean`",
6392
+ "default": "error?.response?.status === 401",
6393
+ "description": "Which errors trigger `onUnauthorized`"
6376
6394
  }
6377
6395
  ],
6378
- "examples": "```jsx\nconst apiMap = {\n getUser: (id) => ({ url: `/users/${id}`, method: 'GET' }),\n updateUser: (id, body) => ({ url: `/users/${id}`, method: 'PUT', data: body }),\n};\n\nfunction Profile({ id }) {\n const { data, loading, error, fetchApi, fetchApiWith } = useApi({\n BASE_URL: 'https://api.example.com',\n DEFAULT_HEADERS: { Authorization: `Bearer ${token}` },\n apiMap,\n });\n\n useEffect(() => { fetchApi('getUser', id); }, [id]);\n\n const save = (body) => fetchApiWith('updateUser', [id, body]);\n // …\n}\n```\n\n[← Component overview](README.md)"
6396
+ "examples": "```jsx\nconst apiMap = {\n getUser: (id) => ({ url: `/users/${id}`, method: 'GET' }),\n updateUser: (id, body) => ({ url: `/users/${id}`, method: 'PUT', data: body }),\n};\n\nfunction Profile({ id }) {\n const { data, loading, error, fetchApi, fetchApiWith } = useApi({\n BASE_URL: 'https://api.example.com',\n DEFAULT_HEADERS: { Authorization: `Bearer ${token}` },\n apiMap,\n });\n\n useEffect(() => { fetchApi('getUser', id); }, [id]);\n\n const save = (body) => fetchApiWith('updateUser', [id, body]);\n // …\n}\n```\n\n### Session expiry (401): redirect only once\n\n`onUnauthorized` runs for **every** request that fails with a 401, so a page that\nfires several requests at once calls it several times when the session ends.\nGuard the redirect with a module-level flag, and exempt the login call (its 401\nmeans \"wrong password\", not \"session ended\"):\n\n```jsx\n// api.js — module scope, shared by every useApi instance in the app\nlet redirecting = false;\n\nexport const apiOptions = {\n BASE_URL: '/api',\n apiMap,\n onUnauthorized: (error, { apiKey }) => {\n if (apiKey === 'login' || redirecting) return;\n redirecting = true;\n clearCachedProfile();\n window.location.assign(`/sign-in?next=${encodeURIComponent(location.pathname)}`);\n },\n};\n\n// Component\nconst { fetchApi } = useApi(apiOptions);\nfetchApi('getProjects').catch(() => setLoading(false)); // still rejects\n```\n\nWith a client-side router, reset the flag once the sign-in page mounts\n(`redirecting = false`) instead of relying on the full page load. To exempt by\nerror instead of by key, pass `isUnauthorized`:\n`isUnauthorized: (e) => e?.response?.status === 401 && !e.config?.url?.endsWith('/login')`.\n\n[← Component overview](README.md)"
6379
6397
  },
6380
6398
  {
6381
6399
  "name": "useHaptics",
@@ -6522,7 +6540,7 @@
6522
6540
  "Stepper": "# Stepper\n\nHorizontal or vertical step indicator. Each step has circle (with check for completed), optional line, and label. Optional `onStepClick` for clickable steps.\n\n## Import\n\n```jsx\nimport { Stepper } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `steps` | `Array` | `[]` | Each step: `{ id?, title?, label?, description? }`. `title` is the step's text; `label` is accepted as an alias (when both are set, `title` wins). |\n| `currentStep` | `number` | `0` | Zero-based index of current step. |\n| `orientation` | `\"horizontal\"` \\| `\"vertical\"` \\| `\"auto\"` | `\"horizontal\"` | Layout. `\"auto\"` stacks vertically below the `sm` breakpoint (640 px) and lays out horizontally from `sm` up, using CSS only. |\n| `variant` | `\"default\"` \\| `\"dots\"` | `\"default\"` | `\"dots\"` renders compact progress dots: one labelled dot per step, the current step as a wider pill, and no titles or descriptions. Clicks work as with `onStepClick`. Unknown values render the default stepper. |\n| `onStepClick` | `(step, stepIndex) => void` | — | Called when a step is clicked (e.g. for completed/current). |\n| `className` | `string` | — | Nav wrapper CSS classes. |\n| ...rest | — | — | Passed to `<nav>`. The nav defaults to `aria-label=\"Progress\"`; a caller's `aria-label` replaces it. |\n\n## Step status\n\n- **completed**: `stepIndex < currentStep` (check icon, primary color).\n- **current**: `stepIndex === currentStep`.\n- **upcoming**: `stepIndex > currentStep` (gray circle; title and description use the `text-muted` role, 7.5:1 on white).\n\nConnectors flex to fill the space between circles (horizontal) or between steps (vertical), so any number of steps fits its container without overflowing. The connector after a completed step is primary. Step buttons show a `focus-visible` ring, have dark-mode colours, and grow to a 44 px touch target on coarse pointers when they are clickable.\n\nEach step button (and each dot) is named `Step <n>: <title or label>`, with ` (completed)` for completed steps (their circle shows only a check icon).\n\n## Example\n\n```jsx\n<Stepper\n steps={[\n { id: '1', label: 'Details' },\n { id: '2', label: 'Payment' },\n { id: '3', label: 'Confirm' },\n ]}\n currentStep={1}\n orientation=\"horizontal\"\n onStepClick={(step, idx) => setStep(idx)}\n/>\n```\n\n[← Component overview](README.md)\n",
6523
6541
  "SwipeableListItem": "# SwipeableListItem\n\nA list row with swipe actions, the iOS Mail / Gmail pattern. Swipe right to reveal the **leading** actions, left to reveal the **trailing** ones; release to snap open or closed, or swipe all the way to commit the first action on that side. Past the actions the row rubber-bands. Every action is also reachable without a gesture through a **More actions** button that opens them as a menu (an action sheet on phones).\n\n## Import\n\n```jsx\nimport { SwipeableListItem } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `leadingActions` | `SwipeAction[]` | `[]` | Revealed by swiping right. The first one sits at the left edge and is the full-swipe action |\n| `trailingActions` | `SwipeAction[]` | `[]` | Revealed by swiping left. The first one sits at the right edge and is the full-swipe action |\n| `fullSwipe` | `boolean` \\| `{ leading?, trailing? }` | `true` | A swipe past `fullSwipeThreshold` commits the first action of that side. Pass an object to allow it on one side only |\n| `fullSwipeThreshold` | `number` | `0.6` | Fraction of the row width a full swipe must travel (and at least 24px past the revealed actions) |\n| `actionWidth` | `number` | `80` | Width in px of each revealed action |\n| `open` | `'leading'` \\| `'trailing'` \\| `null` | — | Controlled open side |\n| `defaultOpen` | `'leading'` \\| `'trailing'` \\| `null` | `null` | Uncontrolled initial open side |\n| `onOpenChange` | `(side) => void` | — | Called with `'leading'`, `'trailing'` or `null` when the row opens or closes |\n| `disabled` | `boolean` | `false` | Turns off the gesture and the More actions button |\n| `moreActions` | `'focus'` \\| `'always'` \\| `false` | `'focus'` | The gesture-free button. `focus`: shown on keyboard focus and mouse hover. `always`: always visible. `false`: removed (only when you provide another way to reach the actions) |\n| `moreActionsLabel` | `string` | `\"More actions\"` | Accessible name of the button and its menu |\n| `menuPresentation` | `'auto'` \\| `'popover'` \\| `'sheet'` | `'auto'` | How the More actions menu opens. `auto` uses an action sheet on small touch screens |\n| `as` | `ElementType` | `\"div\"` | Root element, e.g. `\"li\"` inside a `<ul>` |\n| `contentClassName` | `string` | — | Classes for the sliding content layer (default background `bg-surface`) |\n| `className` | `string` | — | Root classes (merged with `cn`) |\n| `children` | `ReactNode` | — | The row content |\n| `...rest` | `HTMLAttributes` | — | Forwarded to the root; `ref` points at the root |\n\n### `SwipeAction`\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `key` | `string` | Unique key |\n| `label` | `ReactNode` | Visible text (also the menu item text) |\n| `icon` | `ReactNode` \\| `Component` | Shown above the label (`<Trash2 />` or `Trash2`) |\n| `tone` | `string` | `default`, `primary`, `secondary`, `success`, `warning`, `info`, `error` or `destructive`. Sets the action's background; `error` / `destructive` also mark it destructive in the menu |\n| `disabled` | `boolean` | Not selectable |\n| `onSelect` | `({ action, source }) => void` | Runs the action. `source` is `'button'` (a revealed action was tapped), `'full-swipe'` or `'menu'`. The row closes afterwards |\n\n## Behaviour\n\n- **Pointer events**, with `touch-action: pan-y` on the content: a swipe only starts once horizontal movement dominates, so vertical scrolling is never blocked.\n- **Snap**: on release the row opens when dragged past half of its actions (or flicked toward open) and closes otherwise.\n- **Full swipe**: while past the threshold the first action grows to fill the revealed area; releasing there commits it.\n- **Closing**: a tap outside the row, a tap on the open row (which does not activate the row), Escape, or selecting an action.\n- **No ghost clicks**: the click that ends a swipe is swallowed.\n- **Reduced motion**: snapping animates with the motion tokens and is instant under `prefers-reduced-motion`.\n\n## Accessibility\n\n- Actions that are not revealed are `aria-hidden` and out of the tab order; revealed actions are regular buttons.\n- The **More actions** button (`aria-haspopup=\"menu\"`) is in the tab order even when visually hidden (`moreActions=\"focus\"`) and appears on focus. It opens every action as a `role=\"menu\"` (arrow keys, Home/End, typeahead, Escape returns focus) or, on phones, as an action sheet.\n- Escape closes an open row.\n\n## Examples\n\n### Inbox row\n\n```jsx\nimport { Archive, Trash2, Flag, MailOpen } from 'lucide-react';\n\n<ul className=\"divide-y divide-line\">\n {messages.map((m) => (\n <SwipeableListItem\n key={m.id}\n as=\"li\"\n leadingActions={[{ key: 'read', label: 'Read', icon: MailOpen, tone: 'primary', onSelect: () => markRead(m) }]}\n trailingActions={[\n { key: 'delete', label: 'Delete', icon: Trash2, tone: 'destructive', onSelect: () => remove(m) },\n { key: 'flag', label: 'Flag', icon: Flag, tone: 'warning', onSelect: () => flag(m) },\n { key: 'archive', label: 'Archive', icon: Archive, tone: 'success', onSelect: () => archive(m) },\n ]}\n >\n <div className=\"px-4 py-3\">{m.subject}</div>\n </SwipeableListItem>\n ))}\n</ul>\n```\n\n### One row open at a time\n\n```jsx\nconst [open, setOpen] = useState(null); // { id, side }\n\n<SwipeableListItem\n open={open?.id === m.id ? open.side : null}\n onOpenChange={(side) => setOpen(side ? { id: m.id, side } : null)}\n trailingActions={actions}\n>\n …\n</SwipeableListItem>\n```\n\n### In a Table\n\n`Table` builds its mobile cards on this component through the `swipeActions` prop; see [Table](Table.md#mobile-cards-filters-and-swipe-actions).\n\n[← Component overview](README.md)\n",
6524
6542
  "Switch": "# Switch\n\nToggle switch (on/off). Controlled or uncontrolled. Without an `id` prop, the id comes from `React.useId()`, so it is stable across server rendering and hydration.\n\n## Import\n\n```jsx\nimport { Switch } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `checked` | `boolean` | — | Controlled checked |\n| `defaultChecked` | `boolean` | `false` | Uncontrolled default |\n| `onChange` | `(checked: boolean) => void` | — | Receives the new boolean state (not a DOM event) |\n| `disabled` | `boolean` | `false` | Disable |\n| `size` | `string` | `\"md\"` | `sm`, `md`, `lg`. `md` takes the iOS 51×31 size on coarse pointers (touch screens). |\n| `color` | `string` | `\"primary\"` | Token preset (`primary`, `secondary`, `success`, `error`, `warning`; aliases `green`, `danger`, `red`) or a custom CSS colour string. The raw-palette presets `blue`, `indigo`, `purple` were **removed in 2.0.0** ([migration](MIGRATION-2.0.md#checkbox-and-switch-raw-colour-presets)). |\n| `name` | `string` | — | Form name |\n| `topLabel` | `string` | — | Label above |\n| `bottomLabel` | `string` | — | Label below |\n| `leftLabel` | `string` | — | Label left |\n| `rightLabel` | `string` | — | Label right |\n| `haptics` | `boolean` | `false` | Opt-in: a `selection` vibration (10ms) on every toggle (tap, drag or keyboard). No-op without `navigator.vibrate` (iOS Safari) and under reduced motion. See [useHaptics](useHaptics.md) |\n\n## Behavior\n\n- **Tap or drag:** a tap/click toggles; dragging the knob left or right (pointer events) sets off / on, iOS style. A drag toward the current state is a no-op, and a cancelled drag reverts. `Space` / `Enter` toggle from the keyboard.\n- The off track is a visible grey in light and dark mode; colour presets (`primary`, `success`, …) paint the on track.\n- Transitions are `transition-colors` / `transition-transform` and turn off under `prefers-reduced-motion`. Focus shows a `focus-visible` ring.\n\n## Example\n\n```jsx\n<Switch checked={enabled} onChange={checked => setEnabled(checked)} />\n<Switch defaultChecked rightLabel=\"Enable notifications\" />\n```\n\n[← Component overview](README.md)\n",
6525
- "Table": "# Table\n\nData table with sorting, filtering, pagination, row selection, expandable details, and responsive mobile card view.\n\n## Import\n\n```jsx\nimport { Table } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `data` | `Array` | `[]` | Row data (array of objects) |\n| `columns` | `Array` | `[]` | Column definitions `[{ key, label, sortable?, isVisible?, editable?, type?, options?, required?, validate?, onRender?, render?, searchable?, wrap?, hideOnMobile? }]`. `searchable: false` excludes the column from the global search, `wrap: true` lets its cells wrap, `hideOnMobile: true` drops it from the mobile card view. |\n| `interactive` | `boolean` | `false` | Enable interactive mode with inline cell editing (double-click cell), keyboard shortcuts (`Tab`/`Shift+Tab` cell navigation, `Enter` to save modified changes, `Arrows`, `Escape`), primary Save button, outline Clear Changes button, constraint validation before save, and instant new row addition. |\n| `onSave` | `function` | — | Save callback in interactive mode `({ updatedRows, newRows, allData, tableData }) => Promise<allData | { data: allData } | void> | void`. Returning a dataset array or `{ data }`/`{ allData }` updates internal state with returned records. |\n| `onEdit` | `function` | — | Live edit callback fired when a cell value changes or row edit action is clicked `({ rowKey, colKey, newValue, row, action, isNew }) => void` |\n| `sortable` | `boolean` | `true` | Enable column sort. Each sortable header is a real `<button>` inside its `<th>` (Tab to it, Enter / Space to sort) and the `<th>` carries `aria-sort` (`none`, `ascending` or `descending`). |\n| `filterable` | `boolean` | `false` | Show a filter input per column. Client-side, each non-empty filter keeps rows whose value in that column contains the text (case-insensitive); filters combine with each other and with the global search. In `serverSide` mode nothing is filtered locally. |\n| `selectable` | `boolean` | `false` | Row selection checkboxes |\n| `pagination` | `boolean` | `false` | Enable pagination |\n| `pageSize` | `number` | `25` | Rows per page |\n| `onSort` | `function` | — | Sort callback |\n| `onFilter` | `function` | — | Filter callback |\n| `onFetch` | `function` | — | Server-side fetch (setData, setLoading, filters, page, limit, sort) |\n| `onFilterChange` | `function` | — | Filter change callback |\n| `onSelectionChange` | `function` | — | Selected rows callback |\n| `onRowClick` | `function` | — | Row click callback |\n| `hasDetails` | `boolean` or `(row, index) => boolean` | `false` | Expandable row details. Pass a predicate to give only matching rows an expand toggle; non-matching rows render an empty placeholder cell so columns stay aligned. |\n| `DetailsComponent` | `Component` | — | Component for expanded content |\n| `withAction` | `boolean` | `true` | Show actions column |\n| `onAction` | `function` | — | Action menu callback |\n| `actions` | `Array` or `(row, index) => Array` | — | Row actions. Entries are `{ name, label, icon, render?, onClick?, hidden?, disabled?, inline?, tone? }`; `tone: 'destructive'` (or `destructive: true`) styles a menu action in the error tone; `label`, `icon`, `hidden` and `disabled` each accept a value or `(row, index) => value`. `inline: true` renders the action as an icon button in the actions cell instead of inside the kebab menu; the kebab is hidden when no menu actions remain. Entries are merged onto the built-in Edit/Delete/View set by `name` (so `{ name: 'edit', onClick }` keeps the default label and icon, and `{ name: 'delete', hidden: true }` removes a default); unmatched entries are appended. Set `replaceDefaultActions` to opt out. |\n| `actionMenuPresentation` | `string` | `\"auto\"` | One of `auto`, `popover`, `sheet`. How the row kebab menu opens. `popover`: a floating menu portaled to `document.body` that flips/shifts to stay on screen (never clipped by the table's overflow). `sheet`: an iOS-style action sheet from the bottom with a separate Cancel button. `auto` uses the sheet on small touch screens (`(pointer: coarse) and (max-width: 639px)`) and the popover elsewhere. |\n| `replaceDefaultActions` | `boolean` | `false` | Replace the built-in Edit/Delete/View actions entirely instead of patching them by `name`. |\n| `showSerial` | `boolean` | `true` | Show row number column |\n| `cellClass` | `string` \\| `function` | — | Cell className |\n| `rowClass` | `string` \\| `function` | — | Row className |\n| `globalSearch` | `boolean` | `false` | Global search box. Works with or without `filterable`. Client-side, keeps rows where any value outside a `searchable: false` column contains the term (case-insensitive). In `serverSide` mode it only reports `filters.global`. |\n| `limitOptions` | `number[]` | `[10,25,50,100]` | Page size options |\n| `showLimitSelector` | `boolean` | `true` | Show page size selector |\n| `showReloadButton` | `boolean` | `true` | Show reload button. It is named \"Reload\" (`aria-label`) because its text label is hidden below the `sm` breakpoint |\n| `onReload` | `function` | — | Reload click handler |\n| `stripedRows` | `boolean` | `true` | Striped row background |\n| `theme` | `object` | see below | `stripedColors`, `rowHover`, `accentColor`, `menuBg`, `menuItemHover`. Defaults are dark-mode aware: `{ stripedColors: ['bg-surface', 'bg-background'], rowHover: 'bg-background', accentColor: 'bg-background', menuBg: 'bg-surface', menuItemHover: 'hover:bg-background' }`. A `stripedColors` entry may be a Tailwind `bg-*` class (applied as a class) or a CSS colour such as `'#f7fafc'` (applied as an inline background). `rowHover` is a `bg-*` token or a full hover class string. In the pagination controls, the shipped tokens (`bg-background`, `bg-surface`, `bg-gray-50`, `bg-gray-100`, `bg-primary-50`, `bg-primary-100`) map to static `hover:` literals; for any other colour pass the full class (e.g. `'hover:bg-gray-200'`) so your Tailwind build generates it — a bare unknown token is still prefixed with `hover:` at runtime for backward compatibility, but Tailwind can purge such a class. Pagination buttons grow to a 44×44 px touch target on coarse pointers. |\n| `serverSide` | `boolean` | `false` | Server-side pagination/sort/filter |\n| `totalRecords` | `number` | `0` | Total count (serverSide). Drives the page count and the \"start - end of total\" counter; when omitted the counter falls back to the rows received. |\n| `pageNumber` | `number` | — | Controlled page. Client-side, a page past the last one is clamped to the last page once rows exist. |\n| `onPageChange` | `(page: number) => void` | — | Called with the new page number when the user changes page, and when the table clamps a page that no longer exists (client-side). Not called for the reset to page 1 on a filter or page-size change. Consumed by the table — it is **not** forwarded onto the `<table>`. |\n| `responsiveBreakpoint` | `number` | `768` | px breakpoint for mobile cards: card mode while the viewport is narrower than this. Read with an SSR-safe media query (`not all and (min-width: <n>px)`), so client renders pick the right layout on the first render and follow resizes live. |\n| `defaultMobileView` | `boolean` | `false` | Layout used on the server and while hydrating, before the media query can be read. Set `true` on phone-first pages so server-rendered HTML starts as cards. |\n| `mobileFiltersPresentation` | `string` | `\"auto\"` | Card-mode filters (with `filterable` / `globalSearch`). `sheet`: a \"Filters\" button (with an active-filter count) opening a bottom `Sheet`; edits are a draft until **Apply**, **Clear** empties the draft. `inline`: the collapsible inline panel (the previous behaviour, live filtering). `auto` uses the sheet on small touch screens (`(pointer: coarse) and (max-width: 639px)`) and the inline panel elsewhere. |\n| `swipeActions` | `(row, index) => { leading?, trailing? } \\| null` | — | Swipe actions on the mobile cards, built on [`SwipeableListItem`](SwipeableListItem.md). Each side is an array of `{ key, label, icon?, tone?, disabled?, onSelect }`; swipe right for `leading`, left for `trailing`, a full swipe commits the first action of that side. Only one card is open at a time. Every action is also reachable without a gesture through the card's \"Swipe actions\" button. Return `null` (or empty arrays) for rows without swipe actions. |\n| `stickyHeader` | `boolean` \\| `string` \\| `number` | `false` | Keep the desktop header visible while the body scrolls. `true` sticks within the table's own scroll container; a CSS length (`\"24rem\"`) or a number (px) also caps that container's height so the rows scroll under the header. |\n| `showToolbar` | `boolean` | `true` | Show the toolbar above the table (reload, page size, result count, pager header) and the column picker. `false` hides all of it; the pager footer stays, and in `interactive` mode the add-row / Save / Clear controls still render. `globalSearch` is its own opt-in and is unaffected. |\n| `showColumnPicker` | `boolean` | `true` | Show the column-picker button in the header (accessible name \"Choose columns\", with `aria-expanded`). `false` also removes its (otherwise empty) column from every row. |\n| `wrapCells` | `boolean` | `false` | Let cell text wrap instead of staying on one line. A column's own `wrap` overrides it. |\n| `mobileMaxFields` | `number` | — | Cap the fields shown on each mobile card. Applied in column order, after `hideOnMobile` columns are removed. |\n\nOnly DOM-valid attributes in `...rest` (e.g. `id`, `aria-*`, `data-*`) are\nforwarded to the root `<table>`; the table's own control props (`onPageChange`,\n`serverSide`, `pagination`, the `on*` callbacks, …) are consumed and never leak\nto the DOM as unknown attributes.\n\n## Examples\n\n### Basic table\n\n```jsx\nconst columns = [\n { key: 'name', label: 'Name' },\n { key: 'email', label: 'Email' },\n];\nconst data = [\n { id: 1, name: 'Alice', email: 'alice@example.com' },\n { id: 2, name: 'Bob', email: 'bob@example.com' },\n];\n<Table data={data} columns={columns} />\n```\n\n### With pagination and selection\n\n```jsx\n<Table\n data={data}\n columns={columns}\n pagination\n pageSize={10}\n selectable\n onSelectionChange={(selected) => console.log(selected)}\n/>\n```\n\n### Server-side data\n\n```jsx\n<Table\n data={data}\n columns={columns}\n serverSide\n pagination\n totalRecords={total}\n onFetch={async ({ setData, setLoading, page, limit, sort }) => {\n setLoading(true);\n const res = await fetch(`/api/users?page=${page}&limit=${limit}`);\n const json = await res.json();\n setData(json.rows);\n setLoading(false);\n }}\n/>\n```\n\n### Filtering and page clamping (client-side)\n\nWithout `serverSide`, the table filters the rows itself:\n\n- **Global search** (`globalSearch`) applies on Enter or the search button,\n with or without `filterable`. Columns with `searchable: false` are skipped.\n- **Column filters** (`filterable`) apply as you type. Each one matches only its\n own column's raw value (not a `render` output).\n- All matches are case-insensitive \"contains\" tests, and every active filter\n must match (AND).\n- `onFilter` / `onFilterChange` still receive the whole `filters` object\n (`{ global?, [columnKey]: text }`) on every change.\n\nWhen the rows shrink (filtering, deleted rows, a new `data` prop) and the\ncurrent page no longer exists, the table shows the last page that does and\ncalls `onPageChange` with it. A parent that drives `pageNumber` can simply\nstore that value, and no `clampPage` helper is needed. The clamp waits while\nthere are no rows, so a page restored from the URL survives until the data\narrives.\n\nWith `serverSide`, none of this runs locally: filters go to `onFetch` /\n`onFilter` / `onFilterChange`, the rows shown are exactly the rows you pass,\nand keeping `pageNumber` within `totalRecords` is up to you.\n\n### Compact, read-only table\n\nHide the chrome for a dashboard card, keep id columns out of the search, let\nlong text wrap, and keep phone cards short.\n\n```jsx\nconst columns = [\n { key: 'id', label: 'ID', searchable: false, hideOnMobile: true },\n { key: 'client', label: 'Client' },\n { key: 'status', label: 'Status' },\n { key: 'notes', label: 'Notes', wrap: true },\n { key: 'owner', label: 'Owner' },\n];\n\n<Table\n data={rows}\n columns={columns}\n showToolbar={false}\n showSerial={false}\n withAction={false}\n mobileMaxFields={3}\n/>\n```\n\nOn a phone each card shows Client, Status and Notes. With no serial, selection,\nexpand toggle or actions, the card has no header strip either.\n\n`showToolbar={false}` does not change pagination: with `pagination` on, the pager\nfooter still renders. Hide only the picker with `showColumnPicker={false}`.\n\n### Conditional row details\n\n`hasDetails` accepts a predicate so only rows that actually have something to\nshow get an expand toggle. Rows that don't qualify still render the (empty)\ntoggle cell, so the columns stay aligned.\n\n```jsx\nconst ErrorDetails = ({ row }) => <pre>{row.stackTrace}</pre>;\n\n<Table\n data={jobs}\n columns={columns}\n hasDetails={(row) => row.status === 'failed'}\n DetailsComponent={ErrorDetails}\n/>\n```\n\n### Mobile cards, filters and swipe actions\n\nBelow `responsiveBreakpoint` rows render as cards. Card controls (expand\ntoggle, selection checkbox, inline actions, kebab) have 44×44 px hit areas.\nWith `filterable` on a phone, the filters open in a bottom sheet with an\nApply / Clear footer; pass `mobileFiltersPresentation=\"inline\"` to keep the\ninline panel.\n\n```jsx\n<Table\n data={messages}\n columns={columns}\n filterable\n defaultMobileView // server-rendered HTML starts as cards\n swipeActions={(row) => ({\n leading: [{ key: 'read', label: 'Read', icon: MailOpen, tone: 'primary', onSelect: () => markRead(row) }],\n trailing: [\n { key: 'delete', label: 'Delete', icon: Trash2, tone: 'destructive', onSelect: () => remove(row) },\n { key: 'flag', label: 'Flag', icon: Flag, tone: 'warning', onSelect: () => flag(row) },\n ],\n })}\n/>\n```\n\n### Sticky header\n\n```jsx\n<Table data={rows} columns={columns} stickyHeader=\"28rem\" />\n```\n\n### Row action menu\n\nThe kebab button (`aria-haspopup=\"menu\"`) opens the row's menu actions. On\ndesktop it is a `role=\"menu\"` popover: focus moves to the first action,\nArrowUp/ArrowDown (wrapping), Home/End and first-letter typeahead move between\nactions, Escape or Tab closes it and returns focus to the kebab, and a press\noutside or a page scroll closes it (pressing the kebab again closes it too). On phones (`actionMenuPresentation=\"auto\"`,\nthe default) the same actions open as an action sheet: 48px rows in a rounded\ncard, destructive actions in red, and a Cancel button below. Pass\n`actionMenuPresentation=\"popover\"` to keep the desktop menu everywhere.\n\n```jsx\n<Table\n data={users}\n columns={columns}\n actions={[{ name: 'delete', tone: 'destructive' }]}\n actionMenuPresentation=\"auto\"\n/>\n```\n\n### Conditional and inline actions\n\n`hidden` and `disabled` accept a `(row, index)` predicate, `label` and `icon`\naccept a `(row, index)` producer, and `inline: true` promotes an action out of\nthe kebab menu into the row itself. When no menu actions remain for a row, the\nkebab button is not rendered at all.\n\n```jsx\n<Table\n data={jobs}\n columns={columns}\n replaceDefaultActions\n actions={[\n { name: 'view', label: 'View', icon: <Eye size={16} />, inline: true },\n { name: 'edit', label: 'Edit', icon: <Edit size={16} />,\n disabled: (row) => row.locked },\n { name: 'retry', label: (row) => `Retry (${row.tries})`,\n hidden: (row) => row.status !== 'failed' },\n ]}\n onAction={({ action, row }) => handle(action.name, row)}\n/>\n```\n\nFor a large table, wrap a function-valued `actions` in `useCallback` — a fresh\narrow on every render defeats the per-row memoization.\n\n```jsx\nconst actions = useCallback(\n (row) => (row.role === 'admin' ? adminActions : userActions),\n []\n);\n```\n\nNote that because `icon` may be a `(row, index) => node` producer, passing a\nbare component reference (`icon: Edit`) calls it rather than rendering it. Pass\nan element instead: `icon: <Edit size={16} />`.\n\n### Patching the default actions\n\nEntries you supply are merged onto the built-in Edit/Delete/View set by `name`,\nso you can swap one handler without restating the rest. Unmatched names are\nappended after the defaults, and `hidden: true` removes a default outright.\n\n```jsx\n<Table\n data={users}\n columns={columns}\n actions={[\n { name: 'edit', onClick: ({ row }) => edit(row) }, // keeps default label + icon\n { name: 'delete', hidden: (row) => !row.canDelete },\n { name: 'invite', label: 'Invite', icon: <Mail size={16} /> }, // appended\n ]}\n/>\n```\n\nPass `replaceDefaultActions` to skip merging and use only your own set:\n\n```jsx\n<Table\n data={users}\n columns={columns}\n replaceDefaultActions\n actions={[{ name: 'approve', label: 'Approve' }]}\n/>\n```\n\n### Interactive Table Mode\n\nPass `interactive={true}` to enable inline cell editing (double-click a cell or press standard `'e'` key on a focused active cell to enter edit mode), instant record addition (at top or bottom), keyboard navigation (`Tab`/`Shift+Tab` to move between cells, `Escape` to exit edit mode, `Enter` to save modified changes), and constraint validation before saving.\n\n#### Interactive Props & Options\n\n- `interactive`: `boolean` (default: `false`) — Enables interactive mode.\n- `editKeyBinding`: `string | ((e) => boolean)` (default: `\"e\"`) — Standard key binding to enter cell edit mode on active focused cell.\n- `newRowPosition`: `\"bottom\" | \"top\"` (default: `\"bottom\"`) — Position where empty new rows are inserted (at the bottom or top of table dataset).\n- `addRowRef`: `React.MutableRefObject` — External ref populated with `(position?: \"top\" | \"bottom\") => void` to trigger adding a new row from external buttons.\n- `onInitAddRow`: `(addRowFn: (position?: \"top\" | \"bottom\") => void) => void` — Callback receiving the add-row function on component initialization.\n- `renderAddRowButton`: `({ addRow, newRowPosition }) => ReactNode` — Render prop for rendering a custom external add-row button in the table toolbar.\n\n#### Interactive Usage Example\n\n```jsx\nconst columns = [\n { key: 'name', label: 'Name', type: 'text', required: true },\n { key: 'role', label: 'Role', type: 'select', options: ['Admin', 'User'], required: true },\n { key: 'notes', label: 'Notes', type: 'textarea' },\n];\n\nconst addRowRef = useRef();\n\nreturn (\n <div>\n <button onClick={() => addRowRef.current?.(\"top\")}>\n Add Row at Top\n </button>\n\n <Table\n interactive\n editKeyBinding=\"e\"\n newRowPosition=\"top\"\n addRowRef={addRowRef}\n columns={columns}\n data={users}\n onSave={async ({ updatedRows, newRows, allData }) => {\n await api.saveUsers({ updatedRows, newRows });\n }}\n />\n </div>\n);\n```\n\n[← Component overview](README.md)\n",
6543
+ "Table": "# Table\n\nData table with sorting, filtering, pagination, row selection, expandable details, and responsive mobile card view.\n\n## Import\n\n```jsx\nimport { Table } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `data` | `Array` | `[]` | Row data (array of objects) |\n| `columns` | `Array` | `[]` | Column definitions `[{ key, label, sortable?, isVisible?, editable?, type?, options?, required?, validate?, onRender?, render?, searchable?, wrap?, hideOnMobile? }]`. `searchable: false` excludes the column from the global search, `wrap: true` lets its cells wrap, `hideOnMobile: true` drops it from the mobile card view. |\n| `interactive` | `boolean` | `false` | Enable interactive mode with inline cell editing (double-click cell), keyboard shortcuts (`Tab`/`Shift+Tab` cell navigation, `Enter` to save modified changes, `Arrows`, `Escape`), primary Save button, outline Clear Changes button, constraint validation before save, and instant new row addition. |\n| `onSave` | `function` | — | Save callback in interactive mode `({ updatedRows, newRows, allData, tableData }) => Promise<allData | { data: allData } | void> | void`. Returning a dataset array or `{ data }`/`{ allData }` updates internal state with returned records. |\n| `onEdit` | `function` | — | Live edit callback fired when a cell value changes or row edit action is clicked `({ rowKey, colKey, newValue, row, action, isNew }) => void` |\n| `sortable` | `boolean` | `true` | Enable column sort. Each sortable header is a real `<button>` inside its `<th>` (Tab to it, Enter / Space to sort) and the `<th>` carries `aria-sort` (`none`, `ascending` or `descending`). |\n| `filterable` | `boolean` | `false` | Show a filter input per column. Client-side, each non-empty filter keeps rows whose value in that column contains the text (case-insensitive); filters combine with each other and with the global search. In `serverSide` mode nothing is filtered locally. |\n| `selectable` | `boolean` | `false` | Row selection checkboxes |\n| `pagination` | `boolean` | `false` | Enable pagination |\n| `pageSize` | `number` | `25` | Rows per page |\n| `onSort` | `function` | — | Sort callback |\n| `onFilter` | `function` | — | Filter callback |\n| `onFetch` | `function` | — | Server-side fetch (setData, setLoading, filters, page, limit, sort) |\n| `onFilterChange` | `function` | — | Filter change callback |\n| `onSelectionChange` | `function` | — | Selected rows callback |\n| `onRowClick` | `function` | — | Row click callback |\n| `hasDetails` | `boolean` or `(row, index) => boolean` | `false` | Expandable row details. Pass a predicate to give only matching rows an expand toggle; non-matching rows render an empty placeholder cell so columns stay aligned. |\n| `DetailsComponent` | `Component` | — | Component for expanded content |\n| `withAction` | `boolean` | `true` | Show actions column |\n| `onAction` | `function` | — | Action menu callback |\n| `actions` | `Array` or `(row, index) => Array` | — | Row actions. Entries are `{ name, label, icon, render?, onClick?, hidden?, disabled?, inline?, tone? }`; `tone: 'destructive'` (or `destructive: true`) styles a menu action in the error tone; `label`, `icon`, `hidden` and `disabled` each accept a value or `(row, index) => value`. `inline: true` renders the action as an icon button in the actions cell instead of inside the kebab menu; the kebab is hidden when no menu actions remain. Entries are merged onto the built-in Edit/Delete/View set by `name` (so `{ name: 'edit', onClick }` keeps the default label and icon, and `{ name: 'delete', hidden: true }` removes a default); unmatched entries are appended. Set `replaceDefaultActions` to opt out. |\n| `actionMenuPresentation` | `string` | `\"auto\"` | One of `auto`, `popover`, `sheet`. How the row kebab menu opens. `popover`: a floating menu portaled to `document.body` that flips/shifts to stay on screen (never clipped by the table's overflow). `sheet`: an iOS-style action sheet from the bottom with a separate Cancel button. `auto` uses the sheet on small touch screens (`(pointer: coarse) and (max-width: 639px)`) and the popover elsewhere. |\n| `replaceDefaultActions` | `boolean` | `false` | Replace the built-in Edit/Delete/View actions entirely instead of patching them by `name`. |\n| `showSerial` | `boolean` | `true` | Show row number column |\n| `cellClass` | `string` \\| `function` | — | Cell className |\n| `rowClass` | `string` \\| `function` | — | Row className |\n| `globalSearch` | `boolean` | `false` | Global search box. Works with or without `filterable`. Client-side, keeps rows where any value outside a `searchable: false` column contains the term (case-insensitive). In `serverSide` mode it only reports `filters.global`. |\n| `limitOptions` | `number[]` | `[10,25,50,100]` | Page size options |\n| `showLimitSelector` | `boolean` | `true` | Show page size selector |\n| `showReloadButton` | `boolean` | `true` | Show reload button. It is named \"Reload\" (`aria-label`) because its text label is hidden below the `sm` breakpoint |\n| `onReload` | `function` | — | Reload click handler |\n| `stripedRows` | `boolean` | `true` | Striped row background |\n| `theme` | `object` | see below | `stripedColors`, `rowHover`, `accentColor`, `menuBg`, `menuItemHover`. Defaults are dark-mode aware: `{ stripedColors: ['bg-surface', 'bg-background'], rowHover: 'bg-background', accentColor: 'bg-background', menuBg: 'bg-surface', menuItemHover: 'hover:bg-background' }`. A `stripedColors` entry may be a Tailwind `bg-*` class (applied as a class) or a CSS colour such as `'#f7fafc'` (applied as an inline background). `rowHover` is a `bg-*` token or a full hover class string. In the pagination controls, the shipped tokens (`bg-background`, `bg-surface`, `bg-gray-50`, `bg-gray-100`, `bg-primary-50`, `bg-primary-100`) map to static `hover:` literals; for any other colour pass the full class (e.g. `'hover:bg-gray-200'`) so your Tailwind build generates it — a bare unknown token is still prefixed with `hover:` at runtime for backward compatibility, but Tailwind can purge such a class. Pagination buttons grow to a 44×44 px touch target on coarse pointers. |\n| `serverSide` | `boolean` | `false` | Server-side pagination/sort/filter |\n| `totalRecords` | `number` | `0` | Total count (serverSide). Drives the page count and the \"start - end of total\" counter; when omitted the counter falls back to the rows received. |\n| `pageNumber` | `number` | — | Controlled page. Client-side, a page past the last one is clamped to the last page once rows exist. |\n| `onPageChange` | `(page: number) => void` | — | Called with the new page number when the user changes page, and when the table clamps a page that no longer exists (client-side). Not called for the reset to page 1 on a filter or page-size change. Consumed by the table — it is **not** forwarded onto the `<table>`. |\n| `responsiveBreakpoint` | `number` | `768` | px breakpoint for mobile cards: card mode while the viewport is narrower than this. Read with an SSR-safe media query (`not all and (min-width: <n>px)`), so client renders pick the right layout on the first render and follow resizes live. |\n| `defaultMobileView` | `boolean` | `false` | Layout used on the server and while hydrating, before the media query can be read. Set `true` on phone-first pages so server-rendered HTML starts as cards. |\n| `mobileFiltersPresentation` | `string` | `\"auto\"` | Card-mode filters (with `filterable` / `globalSearch`). `sheet`: a \"Filters\" button (with an active-filter count) opening a bottom `Sheet`; edits are a draft until **Apply**, **Clear** empties the draft. `inline`: the collapsible inline panel (the previous behaviour, live filtering). `auto` uses the sheet on small touch screens (`(pointer: coarse) and (max-width: 639px)`) and the inline panel elsewhere. |\n| `swipeActions` | `(row, index) => { leading?, trailing? } \\| null` | — | Swipe actions on the mobile cards, built on [`SwipeableListItem`](SwipeableListItem.md). Each side is an array of `{ key, label, icon?, tone?, disabled?, onSelect }`; swipe right for `leading`, left for `trailing`, a full swipe commits the first action of that side. Only one card is open at a time. Every action is also reachable without a gesture through the card's \"Swipe actions\" button. Return `null` (or empty arrays) for rows without swipe actions. |\n| `stickyHeader` | `boolean` \\| `string` \\| `number` | `false` | Keep the desktop header visible while the body scrolls. `true` sticks within the table's own scroll container; a CSS length (`\"24rem\"`) or a number (px) also caps that container's height so the rows scroll under the header. |\n| `paginationPosition` | `'top'` \\| `'bottom'` \\| `'both'` | `'both'` | Which pager renders when `pagination` is on. `'both'` keeps the compact pager in the toolbar and the \"Page N of M\" footer. `'bottom'` drops the toolbar pager but keeps the rest of the toolbar (reload, page size, result count). `'top'` keeps only the toolbar pager (so with `showToolbar={false}` there is no pager). Unknown values fall back to `'both'`. |\n| `showToolbar` | `boolean` | `true` | Show the toolbar above the table (reload, page size, result count, pager header) and the column picker. `false` hides all of it; the pager footer stays, and in `interactive` mode the add-row / Save / Clear controls still render. `globalSearch` is its own opt-in and is unaffected. |\n| `showColumnPicker` | `boolean` | `true` | Show the column-picker button in the header (accessible name \"Choose columns\", with `aria-expanded`). `false` also removes its (otherwise empty) column from every row. |\n| `wrapCells` | `boolean` | `false` | Let cell text wrap instead of staying on one line. A column's own `wrap` overrides it. |\n| `mobileMaxFields` | `number` | — | Cap the fields shown on each mobile card. Applied in column order, after `hideOnMobile` columns are removed. |\n\nOnly DOM-valid attributes in `...rest` (e.g. `id`, `aria-*`, `data-*`) are\nforwarded to the root `<table>`; the table's own control props (`onPageChange`,\n`serverSide`, `pagination`, the `on*` callbacks, …) are consumed and never leak\nto the DOM as unknown attributes.\n\n## Examples\n\n### Basic table\n\n```jsx\nconst columns = [\n { key: 'name', label: 'Name' },\n { key: 'email', label: 'Email' },\n];\nconst data = [\n { id: 1, name: 'Alice', email: 'alice@example.com' },\n { id: 2, name: 'Bob', email: 'bob@example.com' },\n];\n<Table data={data} columns={columns} />\n```\n\n### With pagination and selection\n\n```jsx\n<Table\n data={data}\n columns={columns}\n pagination\n pageSize={10}\n selectable\n onSelectionChange={(selected) => console.log(selected)}\n/>\n```\n\n### Server-side data\n\n```jsx\n<Table\n data={data}\n columns={columns}\n serverSide\n pagination\n totalRecords={total}\n onFetch={async ({ setData, setLoading, page, limit, sort }) => {\n setLoading(true);\n const res = await fetch(`/api/users?page=${page}&limit=${limit}`);\n const json = await res.json();\n setData(json.rows);\n setLoading(false);\n }}\n/>\n```\n\n### Filtering and page clamping (client-side)\n\nWithout `serverSide`, the table filters the rows itself:\n\n- **Global search** (`globalSearch`) applies on Enter or the search button,\n with or without `filterable`. Columns with `searchable: false` are skipped.\n- **Column filters** (`filterable`) apply as you type. Each one matches only its\n own column's raw value (not a `render` output).\n- All matches are case-insensitive \"contains\" tests, and every active filter\n must match (AND).\n- `onFilter` / `onFilterChange` still receive the whole `filters` object\n (`{ global?, [columnKey]: text }`) on every change.\n\nWhen the rows shrink (filtering, deleted rows, a new `data` prop) and the\ncurrent page no longer exists, the table shows the last page that does and\ncalls `onPageChange` with it. A parent that drives `pageNumber` can simply\nstore that value, and no `clampPage` helper is needed. The clamp waits while\nthere are no rows, so a page restored from the URL survives until the data\narrives.\n\nWith `serverSide`, none of this runs locally: filters go to `onFetch` /\n`onFilter` / `onFilterChange`, the rows shown are exactly the rows you pass,\nand keeping `pageNumber` within `totalRecords` is up to you.\n\n### Compact, read-only table\n\nHide the chrome for a dashboard card, keep id columns out of the search, let\nlong text wrap, and keep phone cards short.\n\n```jsx\nconst columns = [\n { key: 'id', label: 'ID', searchable: false, hideOnMobile: true },\n { key: 'client', label: 'Client' },\n { key: 'status', label: 'Status' },\n { key: 'notes', label: 'Notes', wrap: true },\n { key: 'owner', label: 'Owner' },\n];\n\n<Table\n data={rows}\n columns={columns}\n showToolbar={false}\n showSerial={false}\n withAction={false}\n mobileMaxFields={3}\n/>\n```\n\nOn a phone each card shows Client, Status and Notes. With no serial, selection,\nexpand toggle or actions, the card has no header strip either.\n\n`showToolbar={false}` does not change pagination: with `pagination` on, the pager\nfooter still renders. Hide only the picker with `showColumnPicker={false}`.\n\n### One pager instead of two\n\nWith `pagination` and the toolbar both on, the table shows a compact pager in the\ntoolbar and another under the rows. Keep the toolbar (reload, page size, result\ncount) and drop the top pager with `paginationPosition=\"bottom\"`:\n\n```jsx\n<Table data={rows} columns={columns} pagination paginationPosition=\"bottom\" />\n```\n\n`paginationPosition=\"top\"` does the opposite and keeps only the toolbar pager.\n\n### Conditional row details\n\n`hasDetails` accepts a predicate so only rows that actually have something to\nshow get an expand toggle. Rows that don't qualify still render the (empty)\ntoggle cell, so the columns stay aligned.\n\n```jsx\nconst ErrorDetails = ({ row }) => <pre>{row.stackTrace}</pre>;\n\n<Table\n data={jobs}\n columns={columns}\n hasDetails={(row) => row.status === 'failed'}\n DetailsComponent={ErrorDetails}\n/>\n```\n\n### Mobile cards, filters and swipe actions\n\nBelow `responsiveBreakpoint` rows render as cards. Card controls (expand\ntoggle, selection checkbox, inline actions, kebab) have 44×44 px hit areas.\nWith `filterable` on a phone, the filters open in a bottom sheet with an\nApply / Clear footer; pass `mobileFiltersPresentation=\"inline\"` to keep the\ninline panel.\n\n```jsx\n<Table\n data={messages}\n columns={columns}\n filterable\n defaultMobileView // server-rendered HTML starts as cards\n swipeActions={(row) => ({\n leading: [{ key: 'read', label: 'Read', icon: MailOpen, tone: 'primary', onSelect: () => markRead(row) }],\n trailing: [\n { key: 'delete', label: 'Delete', icon: Trash2, tone: 'destructive', onSelect: () => remove(row) },\n { key: 'flag', label: 'Flag', icon: Flag, tone: 'warning', onSelect: () => flag(row) },\n ],\n })}\n/>\n```\n\n### Sticky header\n\n```jsx\n<Table data={rows} columns={columns} stickyHeader=\"28rem\" />\n```\n\n### Row action menu\n\nThe kebab button (`aria-haspopup=\"menu\"`) opens the row's menu actions. On\ndesktop it is a `role=\"menu\"` popover: focus moves to the first action,\nArrowUp/ArrowDown (wrapping), Home/End and first-letter typeahead move between\nactions, Escape or Tab closes it and returns focus to the kebab, and a press\noutside or a page scroll closes it (pressing the kebab again closes it too). On phones (`actionMenuPresentation=\"auto\"`,\nthe default) the same actions open as an action sheet: 48px rows in a rounded\ncard, destructive actions in red, and a Cancel button below. Pass\n`actionMenuPresentation=\"popover\"` to keep the desktop menu everywhere.\n\n```jsx\n<Table\n data={users}\n columns={columns}\n actions={[{ name: 'delete', tone: 'destructive' }]}\n actionMenuPresentation=\"auto\"\n/>\n```\n\n### Conditional and inline actions\n\n`hidden` and `disabled` accept a `(row, index)` predicate, `label` and `icon`\naccept a `(row, index)` producer, and `inline: true` promotes an action out of\nthe kebab menu into the row itself. When no menu actions remain for a row, the\nkebab button is not rendered at all.\n\n```jsx\n<Table\n data={jobs}\n columns={columns}\n replaceDefaultActions\n actions={[\n { name: 'view', label: 'View', icon: <Eye size={16} />, inline: true },\n { name: 'edit', label: 'Edit', icon: <Edit size={16} />,\n disabled: (row) => row.locked },\n { name: 'retry', label: (row) => `Retry (${row.tries})`,\n hidden: (row) => row.status !== 'failed' },\n ]}\n onAction={({ action, row }) => handle(action.name, row)}\n/>\n```\n\nFor a large table, wrap a function-valued `actions` in `useCallback` — a fresh\narrow on every render defeats the per-row memoization.\n\n```jsx\nconst actions = useCallback(\n (row) => (row.role === 'admin' ? adminActions : userActions),\n []\n);\n```\n\nNote that because `icon` may be a `(row, index) => node` producer, passing a\nbare component reference (`icon: Edit`) calls it rather than rendering it. Pass\nan element instead: `icon: <Edit size={16} />`.\n\n### Patching the default actions\n\nEntries you supply are merged onto the built-in Edit/Delete/View set by `name`,\nso you can swap one handler without restating the rest. Unmatched names are\nappended after the defaults, and `hidden: true` removes a default outright.\n\n```jsx\n<Table\n data={users}\n columns={columns}\n actions={[\n { name: 'edit', onClick: ({ row }) => edit(row) }, // keeps default label + icon\n { name: 'delete', hidden: (row) => !row.canDelete },\n { name: 'invite', label: 'Invite', icon: <Mail size={16} /> }, // appended\n ]}\n/>\n```\n\nPass `replaceDefaultActions` to skip merging and use only your own set:\n\n```jsx\n<Table\n data={users}\n columns={columns}\n replaceDefaultActions\n actions={[{ name: 'approve', label: 'Approve' }]}\n/>\n```\n\n### Interactive Table Mode\n\nPass `interactive={true}` to enable inline cell editing (double-click a cell or press standard `'e'` key on a focused active cell to enter edit mode), instant record addition (at top or bottom), keyboard navigation (`Tab`/`Shift+Tab` to move between cells, `Escape` to exit edit mode, `Enter` to save modified changes), and constraint validation before saving.\n\n#### Interactive Props & Options\n\n- `interactive`: `boolean` (default: `false`) — Enables interactive mode.\n- `editKeyBinding`: `string | ((e) => boolean)` (default: `\"e\"`) — Standard key binding to enter cell edit mode on active focused cell.\n- `newRowPosition`: `\"bottom\" | \"top\"` (default: `\"bottom\"`) — Position where empty new rows are inserted (at the bottom or top of table dataset).\n- `addRowRef`: `React.MutableRefObject` — External ref populated with `(position?: \"top\" | \"bottom\") => void` to trigger adding a new row from external buttons.\n- `onInitAddRow`: `(addRowFn: (position?: \"top\" | \"bottom\") => void) => void` — Callback receiving the add-row function on component initialization.\n- `renderAddRowButton`: `({ addRow, newRowPosition }) => ReactNode` — Render prop for rendering a custom external add-row button in the table toolbar.\n\n#### Interactive Usage Example\n\n```jsx\nconst columns = [\n { key: 'name', label: 'Name', type: 'text', required: true },\n { key: 'role', label: 'Role', type: 'select', options: ['Admin', 'User'], required: true },\n { key: 'notes', label: 'Notes', type: 'textarea' },\n];\n\nconst addRowRef = useRef();\n\nreturn (\n <div>\n <button onClick={() => addRowRef.current?.(\"top\")}>\n Add Row at Top\n </button>\n\n <Table\n interactive\n editKeyBinding=\"e\"\n newRowPosition=\"top\"\n addRowRef={addRowRef}\n columns={columns}\n data={users}\n onSave={async ({ updatedRows, newRows, allData }) => {\n await api.saveUsers({ updatedRows, newRows });\n }}\n />\n </div>\n);\n```\n\n[← Component overview](README.md)\n",
6526
6544
  "Tabs": "# Tabs\n\nTabbed content with list and panels. Use subcomponents: `Tabs.List`, `Tabs.Tab`, `Tabs.Panels`, `Tabs.Panel`. Supports controlled (`index` + `onChange`) or uncontrolled (`defaultIndex`), keyboard navigation, and variants.\n\n**Accessibility and SSR:** tab and panel ids come from `React.useId()`, so server-rendered markup hydrates without mismatches and two Tabs on one page never collide. Each tab's `aria-controls` and each panel's `aria-labelledby` point at elements that exist. Panels mount lazily, so only the selected tab carries `aria-controls`.\n\n## Import\n\n```jsx\nimport { Tabs } from '@dreamtree-org/twreact-ui';\n```\n\n## Props (Tabs root)\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `children` | `ReactNode` | — | Must include `Tabs.List` and `Tabs.Panels`. |\n| `defaultIndex` | `number` | `0` | Uncontrolled initial active tab index. |\n| `index` | `number` | — | Controlled active index. |\n| `onChange` | `(index: number) => void` | — | Called when tab changes. |\n| `orientation` | `\"horizontal\"` \\| `\"vertical\"` | `\"horizontal\"` | Layout direction. |\n| `size` | `\"sm\"` \\| `\"md\"` \\| `\"lg\"` | `\"md\"` | Tab size. Other `Size` values (`xs`, `xl`, `2xl`) fall back to `md`. |\n| `variant` | `\"line\"` \\| `\"pills\"` \\| `\"segmented\"` \\| `\"unstyled\"` | `\"line\"` | Tab style. `segmented` is an iOS-style segmented control: equal-width segments on a tinted `bg-line` track (same as `SegmentedControl`, follows dark mode) with a sliding surface thumb. |\n| `animated` | `boolean` | `true` | Fade-in (`animate-fade-in`) on the newly active panel; skipped under `prefers-reduced-motion`. |\n| `swipeable` | `boolean` | `false` | Swipe the panels left/right (touch or pen, at least 50 px and mostly horizontal) to move to the next/previous enabled tab. Vertical scrolling is never blocked (`touch-action: pan-y`); mouse drags are ignored. |\n| `className` | `string` | `\"\"` | Wrapper CSS classes. |\n\n**Scrolling and indicator:** a horizontal `Tabs.List` scrolls sideways with scroll-snap and a hidden scrollbar, and keeps the active tab scrolled into view. For `line` and `segmented`, the active underline / thumb is a single indicator that slides between tabs (`duration-base`, no transition under `prefers-reduced-motion`). It is measured after mount, so server-rendered markup shows the static active style until then. Horizontal tabs draw their focus ring inset so the scroll container doesn't clip it.\n\n`Tabs.List` forwards a `ref` and spreads extra props (`aria-label`, `data-*`, …) onto the `role=\"tablist\"` element.\n\n## Tabs.Tab props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `children` | `ReactNode` | — | Tab label |\n| `leftIcon` | `ReactNode` | — | Icon before the label. Renamed from `iconLeft` in 2.0.0 ([migration](MIGRATION-2.0.md#tabstab-iconleft--iconright)) |\n| `rightIcon` | `ReactNode` | — | Icon after the label. Renamed from `iconRight` in 2.0.0 |\n| `disabled` | `boolean` | `false` | Skip the tab in keyboard navigation and ignore clicks |\n| `tabClassName` | `string` | `\"\"` | Extra classes for the tab button |\n\n## Usage\n\n```jsx\n<Tabs defaultIndex={0} variant=\"pills\" size=\"md\">\n <Tabs.List>\n <Tabs.Tab>Home</Tabs.Tab>\n <Tabs.Tab>Profile</Tabs.Tab>\n <Tabs.Tab leftIcon={<Settings className=\"h-4 w-4\" />}>Settings</Tabs.Tab>\n </Tabs.List>\n <Tabs.Panels>\n <Tabs.Panel>Home content</Tabs.Panel>\n <Tabs.Panel>Profile content</Tabs.Panel>\n <Tabs.Panel>Settings content</Tabs.Panel>\n </Tabs.Panels>\n</Tabs>\n```\n\nControlled:\n\n```jsx\nconst [index, setIndex] = useState(0);\n<Tabs index={index} onChange={setIndex}>\n ...\n</Tabs>\n```\n\n[← Component overview](README.md)\n",
6527
6545
  "TextToSpeech": "# TextToSpeech\n\nText-to-speech using the browser’s Speech Synthesis API. Renders a styled \"Speak\" / \"Stop\" button (matching `SpeechToText`) or a custom button via `renderButton`. Toggling speak while playing stops playback.\n\n## Import\n\n```jsx\nimport { TextToSpeech } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `text` | `string` | `\"\"` | Text to speak. The **current** value is spoken on each click, so later prop changes are honoured. |\n| `rate` | `number` | `1` | Speech rate (0.1–10). |\n| `pitch` | `number` | `0.5` | Pitch (0–2). |\n| `onSpeak` | `(text: string) => void` | `() => {}` | Called when speech starts (with the text spoken). |\n| `onError` | `(error: Error) => void` | `() => {}` | Called when synthesis fails. Intentional stops (`interrupted` / `canceled`) are not reported. |\n| `disabled` | `boolean` | `false` | Disables the button. |\n| `renderButton` | `(props: { onClick, isSpeaking, isSupported, disabled, speak, stop }) => ReactNode` | — | Custom button. `onClick` toggles; `isSupported` is `false` when the browser has no `speechSynthesis`. |\n| `className` | `string` | — | Merged (via `cn`) onto the default button. |\n| `...rest` | `ButtonHTMLAttributes` | — | Forwarded to the default `<button>` (`aria-*`, `data-*`, `id`, …). |\n\n`ref` is forwarded to the default `<button>` element.\n\n## Behavior\n\n- Clicking the button toggles: if not speaking, speaks the current `text` with a new `SpeechSynthesisUtterance`; if speaking, calls `speechSynthesis.cancel()`.\n- `onSpeak` is called when speech starts; the button returns to \"Speak\" when the utterance ends **or errors**.\n- **Unsupported browsers:** when `window.speechSynthesis` is missing the default button renders disabled with a `title` (and screen-reader text) explaining that text-to-speech is not supported. `renderButton` receives `isSupported: false` / `disabled: true`.\n- **Unmount:** if this instance is speaking when it unmounts, playback is cancelled.\n- The default button is rendered directly (no wrapper `<div>`), with `aria-pressed` reflecting the speaking state and a `focus-visible` ring.\n\n## Example\n\n```jsx\n<TextToSpeech text=\"Hello, world!\" rate={1} onSpeak={(t) => console.log('Speaking:', t)} />\n\n<TextToSpeech\n text={content}\n renderButton={({ onClick, isSpeaking }) => (\n <Button onClick={onClick}>{isSpeaking ? 'Stop' : 'Play'} audio</Button>\n )}\n/>\n```\n\n[← Component overview](README.md)\n",
6528
6546
  "Textarea": "# Textarea\n\nMulti-line text input that mirrors `Input`'s API: label, error, helper text, required marker, character count, plus optional auto-resize.\n\n## Import\n\n```jsx\nimport { Textarea } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `label` | `ReactNode` | — | Label rendered above the field (linked with `htmlFor`) |\n| `error` | `ReactNode` | — | Error message; sets `aria-invalid` and is shown with an icon |\n| `helperText` | `ReactNode` | — | Hint below the field; wired to `aria-describedby` |\n| `required` | `boolean` | `false` | Marks the field required (native `required` + visual `*`) |\n| `disabled` | `boolean` | `false` | Disables the field |\n| `showCount` | `boolean` | `false` | Show `current/maxLength` (needs `maxLength`) |\n| `maxLength` | `number` | — | Native max length |\n| `autoResize` | `boolean` | `false` | Grow with the content; disables the manual resize handle |\n| `rows` | `number` | `3` | Initial (minimum) rows |\n| `maxRows` | `number` | — | With `autoResize`, cap the height and scroll beyond it |\n| `size` | `\"sm\"` \\| `\"md\"` \\| `\"lg\"` | `\"md\"` | Padding and text size (`md` is the original look); `rows` still sets the height |\n| `value` | `string` | — | Controlled value |\n| `defaultValue` | `string` | — | Uncontrolled initial value |\n| `onChange` | `function` | — | Native change handler |\n| `id` | `string` | — | Field id (auto-generated otherwise) |\n| `className` | `string` | — | Extra classes for the `<textarea>` (merged with `cn`) |\n| `...rest` | `TextareaHTMLAttributes` | — | Forwarded to the `<textarea>`; `ref` points at it |\n\nWorks controlled (`value` + `onChange`) or uncontrolled (`defaultValue`); the character count tracks both.\n\nThe focus ring is `focus-visible` only (no ring left after a mouse click), and colours use role tokens (`bg-surface`, `text-foreground`) with `dark:` counterparts.\n\n## Examples\n\n### Basic\n\n```jsx\n<Textarea label=\"Description\" placeholder=\"What needs to happen?\" helperText=\"Plain text only.\" />\n```\n\n### Count + validation\n\n```jsx\n<Textarea label=\"Reason\" required showCount maxLength={280} error={errors.reason} value={reason} onChange={(e) => setReason(e.target.value)} />\n```\n\n### Auto-resize\n\n```jsx\n<Textarea label=\"Notes\" autoResize maxRows={8} value={notes} onChange={(e) => setNotes(e.target.value)} />\n```\n\n## Accessibility\n\n| Behavior | Detail |\n|----------|--------|\n| Labelling | `label` is a real `<label htmlFor>` |\n| Description | `error` and `helperText` ids (plus any `aria-describedby` you pass) are joined into `aria-describedby` |\n| Error | `aria-invalid=\"true\"`; the message is `aria-live=\"polite\"` and paired with an icon (not color alone) |\n| Focus ring | `focus:ring-2 focus:ring-primary-500` (error ring when invalid) |\n| Dark mode | Field, border, label and helper text use `dark:` variants |\n\n[← Component overview](README.md)\n",
@@ -6530,8 +6548,8 @@
6530
6548
  "Toast": "# Toast\n\nToast notification component, container and app-level provider. Wrap your app in `ToastProvider` and call `useToast()` anywhere below it — every caller shares one queue and the provider renders it. Without a provider, `useToast` keeps a component-local queue that you render with `ToastContainer` (the original behavior). Toasts have a status icon, message body, action buttons and a bottom progress bar; they swipe away horizontally, pause while hovered, focused or touched, sit bottom-center above the FootNav on phones, and collapse into a Sonner-style stack past three.\n\n## Import\n\n```jsx\nimport { Toast, ToastContainer, ToastProvider, useToast } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\nProps of the `Toast` card. `ToastContainer` and `ToastProvider` props are listed below.\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `id` | `string` | — | Unique id (for key/onClose) |\n| `title` | `string` | — | Toast title |\n| `message` | `string` | — | Toast message body (alias for `description`) |\n| `description` | `string` | — | Toast body text |\n| `type` | `string` | `\"info\"` | Toast status type: `success`, `error`, `warning`, `info`, `default` |\n| `duration` | `number` | `5000` | Auto-close timer in ms (0 = no auto-close) |\n| `statusIcon` | `ReactNode` | — | Custom status icon (overrides the icon for `type`) |\n| `actions` | `Array` | `[]` | Action button definitions `[{ label, onClick, variant: 'primary' or 'secondary', dismiss, className }]` |\n| `onClose` | `function` | — | Called with `id` once the toast has closed (after its exit animation) |\n| `position` | `string` | `\"top-right\"` | Position key: `top-right`, `top-left`, `bottom-right`, `bottom-left`, `top-center`, `bottom-center` |\n| `portal` | `boolean` | `true` | Render through a portal at a fixed position (`ToastContainer` sets `false`) |\n| `paused` | `boolean` | `false` | Pause the auto-dismiss timer from outside (the stack does this while the user has it expanded) |\n| `swipeToDismiss` | `boolean` | `true` | A horizontal swipe past 64px, or a quick flick, dismisses the toast. Vertical page scroll is untouched (`touch-action: pan-y`) |\n| `mobilePosition` | `string` or `false` | `\"bottom-center\"` | `bottom-center` or `top-center` on phones (coarse pointer, under 640px): full width inside a 1rem gutter. `false` keeps `position`. Applies to a portaled toast |\n| `mobileOffset` | `number` or `string` | `\"5rem\"` | Gap above the bottom edge on phones (px or any CSS length), plus the safe area. Sets `--dt-toast-mobile-offset`; the default clears the FootNav |\n| `closeLabel` | `string` | `\"Close notification\"` | Accessible name of the close button |\n| `color` | `string` | — | Custom progress-bar colour (any CSS colour) |\n| `footerNote` | `ReactNode` | — | Footer text under the body (shown while `duration > 0`) |\n| `showFooterNote` | `boolean` | `false` | Show the default \"This message will close in N seconds. Click to stop.\" footer |\n| `className` | `string` | — | Classes merged onto the toast root |\n\nOther props (`aria-*`, `data-*`, event handlers) are forwarded to the root; your `onMouseEnter`, `onFocus`, `onPointerDown`… run alongside the toast's own handlers.\n\n### Behavior\n\n- **Timer pause**: the auto-dismiss timer pauses while the pointer is over the toast, while focus is inside it, while a finger is pressed on it, during a swipe, and after \"Click to stop.\".\n- **Swipe to dismiss**: drag left or right. Past 64px (or on a flick) the toast slides out that way and closes; otherwise it springs back. A press only becomes a swipe after a few pixels, so taps on the action and close buttons still click.\n- **Phones**: on a coarse-pointer screen under 640px the toast (and the stack) moves to `mobilePosition`, full width with a 1rem gutter, above `env(safe-area-inset-bottom)` and `mobileOffset`. Set `mobileOffset={16}` when the app has no FootNav, or set `--dt-toast-mobile-offset` in CSS.\n- **Theming**: role tokens (`bg-surface`, `text-foreground`, `text-muted`, `border-line`) and the status palettes, so dark mode and re-theming follow `--dt-*` variables.\n\n### Accessibility\n\n- Each toast is a live region: `type=\"error\"` uses `role=\"alert\"` + `aria-live=\"assertive\"`; every other type uses `role=\"status\"` + `aria-live=\"polite\"` (both `aria-atomic`). Your own `role` / `aria-live` props win.\n- The stack is a `role=\"region\"` landmark named \"Notifications\".\n- The close button has an `aria-label` (`closeLabel`), a focus-visible ring and a 44×44px hit area.\n- The enter, exit, swipe and stack animations are off under `prefers-reduced-motion`.\n\n## ToastContainer\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `toasts` | `Array` | `[]` | Array of toast objects `{ id, title, message, description, type, actions, ... }` |\n| `position` | `string` | `\"top-right\"` | Stacking position on screen |\n| `onRemove` | `function` | — | Callback invoked when a toast is dismissed (passes toast `id`) |\n| `className` | `string` | — | Classes for the stacking container |\n| `expand` | `boolean` | — | Unset: collapse into a peek stack once there are more than `visibleToasts`, expanding on hover, focus or tap. `true`: always list every toast. `false`: collapse whenever there is more than one |\n| `visibleToasts` | `number` | `3` | Toasts listed before the stack collapses, and the number that peek while collapsed |\n| `mobilePosition` | `string` or `false` | `\"bottom-center\"` | Stack placement on phones: `bottom-center` or `top-center`; `false` keeps `position` |\n| `mobileOffset` | `number` or `string` | `\"5rem\"` | Gap above the bottom edge on phones (sets `--dt-toast-mobile-offset`) |\n\nThe stack's height is capped at `100dvh - 2rem` and scrolls without a visible scrollbar. While collapsed, the newest toast sits in front and older ones peek behind it, slightly smaller; hovering, focusing (keyboard) or tapping the stack expands it and pauses every toast's timer until the pointer leaves, focus leaves, or you tap elsewhere.\n\nUnder a `ToastProvider`, a `ToastContainer` handed the provider's own `toasts` renders nothing (the provider already renders them), so existing screens don't double-render while you migrate.\n\n## ToastProvider\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `children` | `ReactNode` | — | Your app |\n| `position` | `string` | `\"top-right\"` | Stacking position of the provider's container |\n| `className` | `string` | — | Classes for the provider's stacking container |\n| `expand` | `boolean` | — | Same as `ToastContainer` |\n| `visibleToasts` | `number` | `3` | Same as `ToastContainer` |\n| `mobilePosition` | `string` or `false` | `\"bottom-center\"` | Same as `ToastContainer` |\n| `mobileOffset` | `number` or `string` | `\"5rem\"` | Same as `ToastContainer` |\n\n## useToast\n\nReturns `{ toast, toasts, addToast, removeToast, clearToasts }`. `toast(options)` or `toast.success|error|warning|info|default(title, message?, options?)` enqueue a toast and return its id.\n\n- **Inside `<ToastProvider>`**: every `useToast()` call shares the provider's queue.\n- **Outside a provider**: each hook call owns its own queue — render it with `<ToastContainer toasts={toasts} onRemove={removeToast} />`. (Unlike `useTheme`, `useToast` does not throw outside its provider; this keeps existing code working.)\n\n## Examples\n\n### App-level provider (recommended)\n\n```jsx\n// main.jsx\n<ToastProvider position=\"top-right\">\n <App />\n</ToastProvider>\n\n// anywhere below it\nfunction SaveButton() {\n const { toast } = useToast();\n return <Button onClick={() => toast.success('Saved', 'Your changes are saved.')}>Save</Button>;\n}\n```\n\n### Stack and phone options\n\n```jsx\n// Always list every toast; on phones sit 1rem above the bottom (no FootNav).\n<ToastProvider position=\"bottom-right\" expand mobileOffset={16}>\n <App />\n</ToastProvider>\n\n// Collapse as soon as two toasts are showing, and keep the desktop corner on phones.\n<ToastProvider expand={false} mobilePosition={false}>\n <App />\n</ToastProvider>\n```\n\n### Standalone (no provider)\n\n```jsx\nfunction App() {\n const { toast, toasts, removeToast } = useToast();\n\n const handleShowToast = () => {\n toast.info('System Update', 'A new update is available for download.', {\n duration: 6000,\n actions: [\n { label: 'Update Now', variant: 'primary', onClick: () => alert('Updating...') },\n { label: 'Later', variant: 'secondary', onClick: () => alert('Postponed') },\n ],\n });\n };\n\n return (\n <>\n <Button onClick={handleShowToast}>\n Show Notification\n </Button>\n <ToastContainer toasts={toasts} onRemove={removeToast} position=\"top-right\" />\n </>\n );\n}\n```\n\n[← Component overview](README.md)\n",
6531
6549
  "Tooltip": "# Tooltip\n\nTooltip shown on hover, focus, or click — and on long-press on touch screens. Supports placement (auto/top/bottom/left/right), delay, offset, and optional portal. Positioned by the shared floating engine (`position: fixed`, flips and shifts to stay in view) in the `z-tooltip` layer. Content can be string or render prop `({ close }) => ReactNode`. Tooltip has no sheet presentation: for interactive content on phones use [`Popover`](Popover.md).\n\n## Import\n\n```jsx\nimport { Tooltip } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `position` | `\"auto\"` \\| `\"top\"` \\| `\"bottom\"` \\| `\"left\"` \\| `\"right\"` | `\"auto\"` | Preferred side. `auto` means top; every side flips to the opposite side when there is no room and shifts along its axis to stay 8px inside the viewport. The resolved placement is on the tooltip as `data-placement`. |\n| `trigger` | `\"hover\"` \\| `\"click\"` \\| `\"focus\"` \\| `Array` | `\"hover\"` | How tooltip opens. `hover` also opens on keyboard focus (unless `click` is in the list), and on touch screens `hover` / `focus` open on a long-press (see **Touch**). |\n| `content` | `ReactNode` \\| `({ close }) => ReactNode` | — | Tooltip content. |\n| `customTrigger` | `ReactElement` | — | Element used directly as the trigger (gets the ref + `aria-describedby`; no wrapper span). Use it to avoid an extra tab stop around focusable components. |\n| `children` | `ReactNode` | — | Trigger content when `customTrigger` is not used. Wrapped in a `<span>`; see Tab stops below. |\n| `open` | `boolean` | — | Controlled open state. |\n| `defaultOpen` | `boolean` | `false` | Uncontrolled initial open. |\n| `onChange` | `(open: boolean) => void` | — | Called when open state changes. |\n| `delay` | `number` | `80` | Delay in ms before opening (hover). |\n| `offset` | `number` | `8` | Spacing in px between trigger and tooltip. |\n| `textClass` | `string` | `\"text-foreground\"` | Tailwind class for the tooltip text color (role token, flips in dark mode). |\n| `bgClass` | `string` | `\"bg-surface\"` | Tailwind class for the tooltip background (role token, flips in dark mode). |\n| `className` | `string` | `\"\"` | Tooltip box CSS classes. |\n| `portal` | `boolean` | `true` | Render tooltip in document.body. With `false` it renders inline; it is still `position: fixed`, so avoid ancestors with `transform`/`filter` (they become its containing block). |\n| `style` | `CSSProperties` | — | Merged over the computed position styles on the tooltip box. |\n| `id` | `string` | — | Tooltip id. If omitted, a `React.useId()`-based id is generated, stable across server rendering and hydration. |\n\n## Touch\n\nOn a touch pointer (`pointerType: \"touch\"`), `hover` and `focus` tooltips:\n\n- **open on a long-press** (500 ms, finger held still within 10px). The OS context menu / callout is suppressed for that press, and the click a long-press may produce is swallowed;\n- **ignore a plain tap**: the emulated `mouseenter` / focus after a tap do not open the tip, so the tap only activates the trigger's own `onClick`. A tap on the trigger while the tip is open closes it;\n- **never stick open**: the tip hides 1.5 s after the finger lifts, or at once on a tap anywhere outside the trigger and the tooltip.\n\n`trigger=\"click\"` tooltips keep tap-to-toggle.\n\n## Hover and dismissal\n\n- Leaving the trigger closes a hover tooltip after a 100 ms grace period, so the pointer can move onto the tooltip; hovering the tooltip keeps it open (WCAG 1.4.13).\n- `Escape` closes any open tooltip; a pointer-down outside closes it too.\n\n## Tab stops: `customTrigger` vs `children`\n\nWhen you pass `children`, Tooltip wraps them in a `<span>`. If that span were\nalways focusable, a tooltip around a button would give keyboard users **two**\ntab stops (span, then button) — painful in table rows, toolbars and grids.\n\n- **A single native focusable child** — an enabled `<button>`, `<a href>`,\n `<input>`, `<select>`, `<textarea>`, or any element with `tabIndex >= 0` — is\n detected and the span gets **no** `tabIndex`; the child itself receives\n `aria-describedby` (merged with any it already has). One tab stop.\n- **Anything else** (text, an icon, a disabled button, several children, or a\n *component* such as `<Button>` that Tooltip cannot inspect) keeps the\n `tabIndex=\"0\"` wrapper so the tooltip stays keyboard-reachable. This is what\n makes tooltips on disabled buttons work.\n- **For components (e.g. `<Button>`, `<Badge>`), prefer `customTrigger`.** It\n attaches the ref and `aria-describedby` to your element with no wrapper and\n no extra tab stop:\n\n```jsx\n// Grid / table row: one tab stop per action\n<Tooltip content=\"Edit row\" customTrigger={<Button size=\"sm\">Edit</Button>} />\n\n// Native button child: also a single tab stop\n<Tooltip content=\"Delete\">\n <button type=\"button\">Delete</button>\n</Tooltip>\n\n// Disabled control: wrapper stays focusable so the reason is reachable\n<Tooltip content=\"Uploads are not available yet\">\n <button type=\"button\" disabled>Add</button>\n</Tooltip>\n```\n\n## Example\n\n```jsx\n<Tooltip content=\"Save your changes\" customTrigger={<Button>Save</Button>} />\n\n<Tooltip trigger=\"click\" content={({ close }) => <span>Click to close <button onClick={close}>OK</button></span>}>\n <span>Click me</span>\n</Tooltip>\n\n<Tooltip position=\"bottom\" delay={200} content=\"Delayed tip\">\n <Input placeholder=\"Focus me\" />\n</Tooltip>\n```\n\n[← Component overview](README.md)\n",
6532
6550
  "Tour": "# Tour\n\nStep-based onboarding tour (coach marks): dims the page, spotlights each step's target element and shows a step card beside it with Back / Next / Skip / Finish. Generalises the `SpotlightTour` that TaskPro and ProcurementPro each hand-rolled. A step without a target — or whose target never renders — shows as a centred card, so the tour never stalls. On phones the card docks at the bottom of the screen and steps can be swiped.\n\n## Import\n\n```jsx\nimport { Tour } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `open` | `boolean` | `false` | Show the tour |\n| `steps` | `array` | `[]` | `TourStep[]`: `{ target, title, content, placement }` (see below) |\n| `onClose` | `function` | — | `(reason) => void`; reason is `skip`, `escape`, `close` or `finish`. Set `open` to false here |\n| `onFinish` | `function` | — | Called by the Finish button on the last step (before `onClose('finish')`) |\n| `step` | `number` | — | Controlled step index |\n| `defaultStep` | `number` | `0` | Uncontrolled starting step; the tour restarts here every time it opens |\n| `onStepChange` | `function` | — | `(index, step) => void`; use it to navigate routes before a step shows |\n| `labels` | `object` | — | Override `next`, `back`, `skip`, `finish`, `close` and `progress(current, total)` |\n| `targetTimeout` | `number` | `3000` | How long (ms) to wait for a step's target before falling back to a centred card |\n| `spotlightPadding` | `number` | `6` | Space in px between the target and the spotlight ring |\n| `presentation` | `\"auto\"`, `\"popover\"` or `\"sheet\"` | `\"auto\"` | `sheet` docks the step card at the bottom of the screen; `popover` anchors it beside the target; `auto` docks on small touch screens (`(pointer: coarse) and (max-width: 639px)`) and anchors elsewhere (anchored on the server and while hydrating) |\n| `className` | `string` | — | Extra classes for the step card (merged with `cn`) |\n| `...rest` | `HTMLAttributes` | — | Forwarded to the step card; `ref` points at it. Your `onPointerDown` / `onPointerUp` / `onPointerCancel` / `onClickCapture` run before the swipe handling |\n\n## Step shape\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `target` | `string, ref, element or function` | CSS selector (e.g. `[data-tour=\"new-task\"]`), a React ref, a DOM element, or `() => element`. Omit for a centred intro/outro card |\n| `title` | `ReactNode` | Card heading; labels the dialog |\n| `content` | `ReactNode` | Card body; describes the dialog |\n| `placement` | `string` | `top`, `bottom` (default), `left`, `right` (each optionally `-start` / `-end`) or `center`; flips when there is no room and shifts to stay in view. Ignored while docked |\n\n## Examples\n\n### Basic\n\n```jsx\nconst steps = [\n { title: 'Welcome', content: 'A 30-second tour of your workspace.' },\n { target: '[data-tour=\"new-task\"]', title: 'Capture work', content: 'New task opens a quick form.', placement: 'bottom' },\n { target: searchRef, title: 'Jump anywhere', content: 'Search tasks and projects.', placement: 'right' },\n];\n\nconst [open, setOpen] = useState(false);\n\n<Button onClick={() => setOpen(true)}>Take the tour</Button>\n<Tour open={open} steps={steps} onClose={() => setOpen(false)} onFinish={() => markTourSeen()} />\n```\n\n### Route-aware (react-router)\n\n```jsx\n<Tour\n open={open}\n steps={steps}\n onStepChange={(i, step) => step.route && navigate(step.route)}\n onClose={() => setOpen(false)}\n/>\n```\n\nExtra fields on a step (like `route`) are passed back to you untouched. The tour waits up to `targetTimeout` for the next screen's target to render.\n\n### Phones (docked card)\n\n```jsx\n// Default: docked on phones, anchored on desktop\n<Tour open={open} steps={steps} onClose={() => setOpen(false)} />\n\n// Always docked (e.g. a mobile-only shell)\n<Tour presentation=\"sheet\" open={open} steps={steps} onClose={() => setOpen(false)} />\n```\n\nWhile docked:\n\n- the card spans the screen width minus a 1rem gutter, sits 1rem above the bottom edge with `pb-safe` for the home indicator, and scrolls inside `max-h-[60dvh]`;\n- the spotlight stays on the target; the card carries `data-placement=\"docked\"` and `data-presentation=\"sheet\"`;\n- swiping the card **left goes to the next step, right goes back** (at least 50px, clearly more horizontal than vertical; vertical drags still scroll the card). A swipe that ends on a button does not also press it;\n- the close button is a 44 × 44px touch target on coarse pointers.\n\nThe anchored card is capped at `calc(100dvh - 2rem)` (dynamic viewport units, so the mobile browser toolbar never clips it).\n\n## Accessibility & keyboard\n\n| Behavior | Detail |\n|----------|--------|\n| Role | The **step card** is `role=\"dialog\"` + `aria-modal=\"true\"`, labelled by the step title and described by its content (the full-screen layer has no role, so global `[role=dialog]` styles never paint over the page) |\n| Focus | Moves to the card on open and on every step change; `Tab` is trapped in the card; focus returns to the opener on close |\n| `Escape` | Closes with reason `escape` |\n| `ArrowRight` / `ArrowLeft` | Next / previous step (ignored while typing in a field inside the card) |\n| Scroll into view | The target is scrolled into view through its nearest scrolling ancestor only (never `overflow: hidden` shells); smooth unless `prefers-reduced-motion` |\n| Reduced motion | Spotlight and card transitions are disabled under `prefers-reduced-motion` |\n| Scroll lock | Body scroll is locked while open |\n| Progress | \"Step 2 of 5\" text is visible and read with the card |\n| Dark mode | Card uses `dark:` variants; the spotlight ring switches to `primary-400` |\n\n[← Component overview](README.md)\n",
6533
- "useApi": "# useApi\n\nThin axios wrapper hook. Sends requests by URL (`fetchData`) or by key from an `apiMap` (`fetchApi` / `fetchApiWith`) and tracks `data`, `error`, `loading` and `lastRequest`. It is not a cache/dedup layer — bring React Query or SWR for that.\n\n## Import\n\n```jsx\nimport { useApi } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\nOptions object passed to `useApi(options)`.\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `BASE_URL` | `string` | `null` | axios `baseURL` |\n| `DEFAULT_HEADERS` | `object` | `{}` | Headers sent with every request |\n| `apiMap` | `Record<string, (...args) => { url, method, headers?, data? }>` | `{}` | Named request builders. Entries may take any number of positional arguments |\n| `createRequestInstance` | `function` | `axios.create` | `(baseURL, defaultHeaders) => AxiosInstance` |\n| `sendRequest` | `function` | — | `(instance, method, url, data, headers) => Promise<AxiosResponse>`; GET/HEAD/DELETE send no body |\n\n## Returns\n\n| Field | Type | Description |\n|------|------|-------------|\n| `data` | `any` | Last successful `response.data` |\n| `error` | `Error \\| null` | Last error |\n| `loading` | `boolean` | True while a request with `needLoading` is in flight |\n| `lastRequest` | `string \\| null` | Key of the last `fetchApi` / `fetchApiWith` call |\n| `fetchData(url, method, data?, headers?, { needLoading? })` | `Promise` | Send a request directly |\n| `fetchApi(apiKey, data?, needLoading = true)` | `Promise` | Call `apiMap[apiKey](data)` — **exactly one argument** — and send the result |\n| `fetchApiWith(apiKey, args = [], { needLoading = true })` | `Promise` | Call `apiMap[apiKey](...args)` — every element of `args` is a positional argument — and send the result |\n\n### fetchApi vs fetchApiWith\n\n`fetchApi` always calls the map entry with ONE argument. An entry written as `(id, body) => ({...})` called through `fetchApi('updateUser', id)` gets `body === undefined`, so nothing is sent. Use `fetchApiWith` for multi-argument entries:\n\n```jsx\nfetchApiWith('updateUser', [id, body]); // apiMap.updateUser(id, body)\nfetchApiWith('updateUser', [id, body], { needLoading: false });\n```\n\n`fetchApi` is unchanged; existing single-argument callers keep working.\n\n## Examples\n\n```jsx\nconst apiMap = {\n getUser: (id) => ({ url: `/users/${id}`, method: 'GET' }),\n updateUser: (id, body) => ({ url: `/users/${id}`, method: 'PUT', data: body }),\n};\n\nfunction Profile({ id }) {\n const { data, loading, error, fetchApi, fetchApiWith } = useApi({\n BASE_URL: 'https://api.example.com',\n DEFAULT_HEADERS: { Authorization: `Bearer ${token}` },\n apiMap,\n });\n\n useEffect(() => { fetchApi('getUser', id); }, [id]);\n\n const save = (body) => fetchApiWith('updateUser', [id, body]);\n // …\n}\n```\n\n[← Component overview](README.md)\n",
6551
+ "useApi": "# useApi\n\nThin axios wrapper hook. Sends requests by URL (`fetchData`) or by key from an `apiMap` (`fetchApi` / `fetchApiWith`) and tracks `data`, `error`, `loading` and `lastRequest`. It is not a cache/dedup layer — bring React Query or SWR for that.\n\n## Import\n\n```jsx\nimport { useApi } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\nOptions object passed to `useApi(options)`.\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `BASE_URL` | `string` | `null` | axios `baseURL` |\n| `DEFAULT_HEADERS` | `object` | `{}` | Headers sent with every request |\n| `apiMap` | `Record<string, (...args) => { url, method, headers?, data? }>` | `{}` | Named request builders. Entries may take any number of positional arguments |\n| `createRequestInstance` | `function` | `axios.create` | `(baseURL, defaultHeaders) => AxiosInstance` |\n| `sendRequest` | `function` | — | `(instance, method, url, data, headers) => Promise<AxiosResponse>`; GET/HEAD/DELETE send no body |\n| `onUnauthorized` | `(error, request: { apiKey?, url, method }) => void` | — | Called once per failing request that `isUnauthorized` matches, before the promise rejects. The promise still rejects with the original error. `apiKey` is the `apiMap` key for `fetchApi` / `fetchApiWith` and `undefined` for `fetchData`; `method` is upper-cased. If the handler throws, the throw is logged with `console.error` and the request still rejects with its own error |\n| `isUnauthorized` | `(error) => boolean` | `error?.response?.status === 401` | Which errors trigger `onUnauthorized` |\n\n## Returns\n\n| Field | Type | Description |\n|------|------|-------------|\n| `data` | `any` | Last successful `response.data` |\n| `error` | `Error \\| null` | Last error |\n| `loading` | `boolean` | True while a request with `needLoading` is in flight |\n| `lastRequest` | `string \\| null` | Key of the last `fetchApi` / `fetchApiWith` call |\n| `fetchData(url, method, data?, headers?, { needLoading? })` | `Promise` | Send a request directly |\n| `fetchApi(apiKey, data?, needLoading = true)` | `Promise` | Call `apiMap[apiKey](data)` — **exactly one argument** — and send the result |\n| `fetchApiWith(apiKey, args = [], { needLoading = true })` | `Promise` | Call `apiMap[apiKey](...args)` — every element of `args` is a positional argument — and send the result |\n\n### fetchApi vs fetchApiWith\n\n`fetchApi` always calls the map entry with ONE argument. An entry written as `(id, body) => ({...})` called through `fetchApi('updateUser', id)` gets `body === undefined`, so nothing is sent. Use `fetchApiWith` for multi-argument entries:\n\n```jsx\nfetchApiWith('updateUser', [id, body]); // apiMap.updateUser(id, body)\nfetchApiWith('updateUser', [id, body], { needLoading: false });\n```\n\n`fetchApi` is unchanged; existing single-argument callers keep working.\n\n## Examples\n\n```jsx\nconst apiMap = {\n getUser: (id) => ({ url: `/users/${id}`, method: 'GET' }),\n updateUser: (id, body) => ({ url: `/users/${id}`, method: 'PUT', data: body }),\n};\n\nfunction Profile({ id }) {\n const { data, loading, error, fetchApi, fetchApiWith } = useApi({\n BASE_URL: 'https://api.example.com',\n DEFAULT_HEADERS: { Authorization: `Bearer ${token}` },\n apiMap,\n });\n\n useEffect(() => { fetchApi('getUser', id); }, [id]);\n\n const save = (body) => fetchApiWith('updateUser', [id, body]);\n // …\n}\n```\n\n### Session expiry (401): redirect only once\n\n`onUnauthorized` runs for **every** request that fails with a 401, so a page that\nfires several requests at once calls it several times when the session ends.\nGuard the redirect with a module-level flag, and exempt the login call (its 401\nmeans \"wrong password\", not \"session ended\"):\n\n```jsx\n// api.js — module scope, shared by every useApi instance in the app\nlet redirecting = false;\n\nexport const apiOptions = {\n BASE_URL: '/api',\n apiMap,\n onUnauthorized: (error, { apiKey }) => {\n if (apiKey === 'login' || redirecting) return;\n redirecting = true;\n clearCachedProfile();\n window.location.assign(`/sign-in?next=${encodeURIComponent(location.pathname)}`);\n },\n};\n\n// Component\nconst { fetchApi } = useApi(apiOptions);\nfetchApi('getProjects').catch(() => setLoading(false)); // still rejects\n```\n\nWith a client-side router, reset the flag once the sign-in page mounts\n(`redirecting = false`) instead of relying on the full page load. To exempt by\nerror instead of by key, pass `isUnauthorized`:\n`isUnauthorized: (e) => e?.response?.status === 401 && !e.config?.url?.endsWith('/login')`.\n\n[← Component overview](README.md)\n",
6534
6552
  "useHaptics": "# useHaptics\n\nThin, opt-in wrapper around `navigator.vibrate` with named patterns. It does nothing where vibration is unsupported (iOS Safari, desktop Safari), on the server, while hydrating, and while the user asks for reduced motion, so you can call it unconditionally.\n\n## Import\n\n```jsx\nimport { useHaptics } from '@dreamtree-org/twreact-ui';\n```\n\n## Props\n\nOptions object passed to `useHaptics(options)`.\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `enabled` | `boolean` | `true` | Gates every `trigger` call; pass your own opt-in flag here |\n\n## Returns\n\n| Field | Type | Description |\n|------|------|-------------|\n| `trigger(pattern = 'selection')` | `(pattern?: HapticPattern) => boolean` | Requests a vibration. `pattern` is a name (below), a duration in ms, or a vibrate/pause array. Returns `true` when a vibration was requested, `false` when it was a no-op (unsupported, reduced motion, `enabled: false`, unknown name, or the browser refused) |\n| `supported` | `boolean` | `true` when the device exposes `navigator.vibrate`. Always `false` on the server and during hydration |\n| `reducedMotion` | `boolean` | Mirrors `prefers-reduced-motion` live; `trigger` is a no-op while it is `true` |\n\n### Patterns\n\n| Name | Pattern (ms) | Use for |\n|------|--------------|---------|\n| `selection` | `10` | A value changed: toggles, checkboxes, snap points |\n| `success` | `[10, 40, 10]` | An action completed |\n| `warning` | `[20, 60, 20]` | About to do something destructive |\n| `error` | `[30, 40, 30, 40, 30]` | An action failed |\n\n## Behavior\n\n- **Never on by default.** Components that use it (`Switch`, `Checkbox`, `ConfirmDialog`, `Sheet`) take a `haptics` prop that defaults to `false`.\n- **Never the only signal.** Haptics confirm what the UI already shows; most devices (all of iOS) never vibrate.\n- Browsers ignore `vibrate()` before the first user interaction on the page; `trigger` then returns `false`.\n\n## Examples\n\n```jsx\nfunction SaveButton({ onSave }) {\n const { trigger } = useHaptics();\n return (\n <Button\n onClick={async () => {\n try {\n await onSave();\n trigger('success');\n } catch {\n trigger('error');\n }\n }}\n >\n Save\n </Button>\n );\n}\n```\n\n```jsx\n// Built-in opt-in on components\n<Switch haptics rightLabel=\"Wi-Fi\" />\n<ConfirmDialog haptics destructive isOpen={open} title=\"Delete invoice?\" confirmLabel=\"Delete\" onConfirm={remove} onCancel={close} />\n```\n\n[← Component overview](README.md)\n"
6535
6553
  },
6536
- "skill": "# Dreamtree UI — AI assistant reference\n\n> Skill installed by `npx @dreamtree-org/twreact-ui init --ai <provider>`.\n> Source of truth: [`@dreamtree-org/twreact-ui`](https://www.npmjs.com/package/@dreamtree-org/twreact-ui).\n> Re-run the installer to refresh this block when the library updates.\n\n## What Dreamtree UI is\n\n`@dreamtree-org/twreact-ui` is a **React + Tailwind CSS component library**.\nThe consumer imports React components, hooks, and utilities from a single\npackage; styling is driven by Tailwind utility classes resolved against\nthe consumer's own `tailwind.config.js`. Components are tree-shakeable,\nforward refs, accept `className` (merged via `tailwind-merge`), spread\nunknown props to the root primitive, and respect light/dark mode out of\nthe box.\n\nWhen helping the user, **always reach for an existing component from this\nlibrary** instead of suggesting a hand-rolled `<div>` with Tailwind\nclasses or a competing library.\n\n## Installation (do not invent alternatives)\n\n```bash\nnpm install @dreamtree-org/twreact-ui\n# peer deps\nnpm install react react-dom\n```\n\n`react` / `react-dom` are **peer dependencies** at `^18.3.1`. React 19\nis not yet supported.\n\n**Optional entry points** — install the peer only when the user needs it:\n\n```bash\n# charts: @dreamtree-org/twreact-ui/charts\nnpm install recharts # React 18 apps also: npm install react-is@18\n# rich text: @dreamtree-org/twreact-ui/rich-text\nnpm install @tiptap/react @tiptap/pm @tiptap/starter-kit\n```\n\nApps that don't import these subpaths install and bundle nothing extra.\n\n**Upgrading from 1.x to 2.0:** run the bundled codemod, then fix what it reports\n(see `doc/MIGRATION-2.0.md`, also served as `dreamtree://docs/MIGRATION-2.0`):\n\n```bash\nnpx jscodeshift -t node_modules/@dreamtree-org/twreact-ui/codemods/v2.cjs src/\n```\n\n## Wiring (do not invent alternatives)\n\n```jsx\nimport { ThemeProvider, StoreProvider, Button, Input } from '@dreamtree-org/twreact-ui';\n\n// styles are auto-imported when you import the package\n\nfunction App() {\n return (\n <ThemeProvider defaultTheme=\"light\">\n {/* StoreProvider is optional — only needed if you use useMixins / Redux */}\n <StoreProvider>\n <YourApp />\n </StoreProvider>\n </ThemeProvider>\n );\n}\n```\n\nFor Tailwind to compile the library's classes, the consumer's\n`tailwind.config.js` `content[]` MUST include the package:\n\n```js\n// consumer's tailwind.config.js\nmodule.exports = {\n content: [\n './src/**/*.{js,jsx,ts,tsx}',\n './node_modules/@dreamtree-org/twreact-ui/dist/**/*.{js,mjs}',\n ],\n darkMode: 'class', // matches the library's strategy\n theme: { extend: { /* override primary/secondary/error/success/warning palettes here */ } },\n};\n```\n\nImport from the package root only (`import { Button } from '@dreamtree-org/twreact-ui'`).\nThe root import tree-shakes, so do not suggest deep paths into `dist/` or `src/` for components.\n\n**Next.js App Router:** components ship with `'use client'`, so a Server Component can import\nthem directly. Do not generate a `\"use client\"` wrapper file that re-exports the library.\nCompound members (`Card.Header`, `Tabs.List`, `Dialog.Footer`) and function props (`onClick`)\nstill require a `'use client'` file. `cn`, `Helpers` and `Emitter` also work on the server.\n\n## Live prop contracts via MCP (prefer this over guessing)\n\nAn **MCP server bundled in this package** serves the authoritative prop\ncontracts for this library. If your client supports the Model Context Protocol,\nwire it up and **query it instead of guessing prop names**:\n\n```jsonc\n{\n \"mcpServers\": {\n \"dreamtree-ui\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@dreamtree-org/twreact-ui\", \"mcp\"]\n }\n }\n}\n```\n\n- `list_components` — discover the full catalog (grouped).\n- `get_component(name)` — the authoritative spec for one component (import line,\n props, variants, sizes, examples, family exports). **Call this before using a\n component.** Accepts a family-export name (`useToast` → `Toast`).\n- `search_components(query)` — keyword search by capability.\n\nResources: `dreamtree://skill` (this guide) and `dreamtree://docs/<Component>`.\nGuide: `dreamtree://docs/MobileNativeFeel` (`doc/MobileNativeFeel.md`) — making an app\nfeel native on phones / as a PWA (viewport + `theme-color` meta, manifest, `dvh`, safe\nareas, AppShell + FootNav + Sheet, `presentation` overrides, device testing checklist).\nPrompt: `compose_ui`. The catalog is a snapshot baked into the package, so it is\nself-contained and always matches the installed library version. The lists below\nare the fallback when the MCP server isn't connected.\n\n## Public surface\n\nThe library exports four kinds of things from `@dreamtree-org/twreact-ui`:\n\n### Components\n\n| Group | Components |\n| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **Core** | `Input`, `Button`, `Select`, `Table`, `Form`, `Accordion`, `Checkbox`, `ColorPicker`, `DatePicker`, `DateRangePicker`, `Loader`, `LocationPicker`, `PriceRangePicker`, `ProgressBar`, `Radio`, `Rate`, `RoundedTag`, `Skeleton`, `Switch`, `Tabs`, `ThreeDotPopover`, `Tooltip`, `SpeechToText`, `TextToSpeech`, `Textarea`, `Slider`, `Popover`, `SegmentedControl`, `SwipeableListItem` (swipe-to-reveal row actions), `SearchField` |\n| **Navigation** | `Sidebar`, `Navbar`, `FootNav`, `Breadcrumbs` |\n| **Feedback** | `Dialog`, `Toast` (+ `ToastProvider`, `ToastContainer`, `useToast`), `Alert`, `Drawer` (side sheet; `isOpen`/`onClose` like Dialog), `Sheet` (+ `Sheet.Header/Body/Footer`; phone sheet with snap points), `ActionSheet` (iOS action sheet: `actions` + separate Cancel), `Tour` (onboarding spotlight) |\n| **Utility** | `Badge`, `Avatar`, `AvatarGroup` (overlapping stack + `max` \"+N\"), `Card`, `Pagination`, `Stepper`, `FileUpload`, `Condition`, `Carousel`, `Sortable` (drag + keyboard reorder), `PullToRefresh` (touch pull-to-refresh around a scroller) |\n| **Blocks** | `AppShell`, `PageHeader`, `StatCard`, `StatusPill`, `EmptyState`, `ErrorState`, `LoadingState`, `ConfirmDialog`, `BottomActionBar` (+ `BottomActionBar.Spacer`) — page-level patterns; build pages from these before composing layouts by hand |\n| **Charts** (`@dreamtree-org/twreact-ui/charts`, peer `recharts`) | `LineChart`, `BarChart`, `AreaChart`, `DonutChart`, `Sparkline` — NOT exported from the root |\n| **Rich text** (`@dreamtree-org/twreact-ui/rich-text`, peers `@tiptap/react @tiptap/pm @tiptap/starter-kit`) | `RichTextEditor` — NOT exported from the root |\n\n### Hooks\n\n| Hook | Purpose |\n| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |\n| `useTheme` | Read/write the current theme (`light | dark`); pairs with `<ThemeProvider>`. Throws if used outside the provider. |\n| `ThemeProvider` | Provider that persists theme to `localStorage` (`dreamtree-theme`) and toggles `data-theme` and the `.dark` class on `<html>`. |\n| `useApi` | Thin axios wrapper. Returns `{data, error, loading, lastRequest, fetchData, fetchApi, fetchApiWith}`. Accepts `BASE_URL`, `DEFAULT_HEADERS`, `apiMap`. |\n| `useHaptics` | Opt-in `navigator.vibrate` wrapper. Returns `{trigger(pattern), supported, reducedMotion}`; patterns `selection` / `success` / `warning` / `error`. No-op on iOS Safari, the server and under reduced motion. |\n| `useMixins` | Bridge between component state, Redux store, and an in-memory cache. Power-user hook — prefer local React state for new code. |\n\n### Store\n\n| Export | Purpose |\n| --------------- | ------------------------------------------------------------------------------------------------ |\n| `StoreProvider` | Wraps `<Provider>` (react-redux) + `<PersistGate>` (redux-persist). Boots a slice + listener mw. |\n\n### Utils\n\n| Export | Signature | Use for |\n| --------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ |\n| `cn` | `cn(...inputs: ClassValue[]): string` | Merge Tailwind class strings (`twMerge(clsx(...))` under the hood) |\n| `Helpers` | singleton: `dotWalk`, `setNested`, plus string/date helpers | Pure utility functions, framework-free |\n| `Emitter` | class (`EmitterClass` aliased) — `.on`, `.off`, `.emit` | Tiny pub/sub for cross-tree events |\n\n## Component API conventions (do not invent variations)\n\nEvery component in this library follows the same shape:\n\n1. **`forwardRef`** when wrapping a single DOM element. Consumers may attach refs.\n2. **`className` is merged** via `cn(...)` — caller classes win on conflict.\n3. **`...rest` props pass through** to the root primitive (button, input, …).\n4. **Defaults via destructuring**, e.g. `variant = 'primary', size = 'md'`.\n5. **Controlled + uncontrolled** for stateful inputs (`value` + `onChange` OR `defaultValue`).\n6. **Theming is class-based**, never via a `color` prop. Re-skin via Tailwind config.\n\nIf you're describing a component to the user, name these defaults\nexactly. Do not propose a different shape (e.g. inventing a `color` prop\non `Button` or suggesting `tw` prop merging).\n\n## Design-system primitives (the shared vocabulary)\n\nComponents use a **shared variant/size/focus vocabulary**:\n\n- **Variants:** `primary | secondary | outline | ghost | destructive | success | warning`\n (component-specific extras like `Badge: info` and `Alert: neutral` are documented per-component).\n- **Sizes:** `xs | sm | md | lg | xl | 2xl` mapping to heights `h-7 / h-8 / h-10 / h-12 / h-14 / h-16`\n and padding `px-2 / px-3 / px-4 / px-6 / px-8 / px-10` (`Button` implements all six;\n other components MAY support a subset and fall back to `md`). `Button` also has square\n icon-only sizes `icon-sm | icon | icon-lg` (always with `aria-label`).\n- **Focus ring:** `focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500`\n (the color token swaps for destructive / etc.).\n- **Dark mode:** Tailwind `darkMode: 'class'`. Every color choice has a `dark:` counterpart.\n\n- **Icon slots:** `leftIcon` / `rightIcon` (Button, Input, `Tabs.Tab`).\n- **Colour is a token, never a HEX prop** (2.0.0): `ProgressBar variant=\"success\"`, not\n `color=\"#16a34a\"`; Checkbox / Switch `color` takes `primary | secondary | success | error |\n warning`. Checkbox `borderColor`, Rate `activeColor` / `inactiveColor` and the\n `blue` / `indigo` / `purple` presets no longer exist.\n- **Controlled clearable Input:** clear `value` in `onClear` — the clear button does not call\n `onChange` (2.0.0).\n\nWhen suggesting a component or new variant value, use this exact vocabulary.\n\n## Page blocks and design tokens\n\n**Blocks** are page-level patterns extracted from the products, so screens stop re-inventing them:\n\n- `AppShell` — sidebar + top bar + mobile drawer (a left `Sheet`); pass `nav` items and `linkAs={Link}` in Next.js. `bottomNav={<FootNav … />}` adds a phone tab bar (below `md`, spacer included). Fills `min-h-dvh` and pads safe areas.\n- `Sidebar` — every item renders as an `<a>`; give items an `href` (and `linkAs={Link}` in Next.js) so Ctrl/Cmd-click opens a new tab. Parents with `href` navigate + expand; the chevron toggles. Mark the current page `active: true` and its group opens on load (control groups with `expandedIds`/`defaultExpandedIds`/`onExpandedChange`). Top-level `group: 'Name'` adds section headings; `badge` (+ `badgeLabel: '3 unread'`) shows counts; `footer` pins links above the user block; `stickyOffset={64}` when a top bar sits above it. The mobile drawer (`isMobileOpen`/`setIsMobileOpen`) is inert when shut, closes on Escape, and opens/closes with an edge swipe (`swipeable`, default on). The content renders once (column on `md`+, drawer below), so don't render a second Sidebar for phones.\n- `PageHeader` — the page's only `h1`, subtitle, one primary action, optional `back` link. `actionsPlacement=\"bottom\"` pins the actions in a `BottomActionBar` on phones (inline on desktop).\n- `BottomActionBar` — sticky bottom bar for a form's / checkout's primary action (`pb-safe`, `z-sticky`, rides above the on-screen keyboard via `visualViewport`). Children are the actions; `fullWidth` / `stackOnMobile` for phone layouts, `align`, `position=\"fixed\" | \"sticky\"`. Put `<BottomActionBar.Spacer />` at the end of the page. Never hand-roll a `fixed bottom-0` action bar.\n- `StatCard` — KPI tile (`label`, `value`, `icon`, `tone`, `change`).\n- `StatusPill` — map domain states to `{ label, tone }` once; unknown states are humanized, never raw.\n- `EmptyState`, `ErrorState`, `LoadingState` — every data card needs all three.\n- `ConfirmDialog` — use instead of `window.confirm()`; title is the question, button is the verb. Like `Dialog`, it takes `presentation` (`auto` default: bottom sheet on phones, centred modal elsewhere; `popover` = always centred; `sheet` = always a sheet). Write footer actions secondary-then-primary; sheet mode stacks them full width with the primary on top.\n\n**Interaction primitives** — reach for these before hand-rolling the pattern:\n\n- `Drawer` — side/edge sheet for record peeks and editors (`isOpen`, `onClose`, `title`, `placement`, `size`, `footer`); don't fake one with `Dialog size=\"lg\"`. On phones use `placement=\"bottom\"` (+ `swipeToClose`) as the bottom sheet: it already has a grab `handle`, rounded top and safe-area padding — don't add them with `className`.\n- `FootNav` — phone tab bar. A centre \"+\" is an item with `variant: 'action'` (raised primary circle); give it `'aria-haspopup': 'dialog'` and `'aria-expanded'` when it opens a sheet. Navigation tabs take `href`/`to` (+ `linkAs={Link}`) so they render as links with `aria-current=\"page\"`; `hideOnScroll` slides it away on scroll down; put `<FootNav.Spacer />` after the page content instead of a hand-tuned `pb-20`. It already pads the safe area and caps badges at `99+` (`badgeMax`).\n- `SwipeableListItem` — iOS Mail / Gmail row (`leadingActions` / `trailingActions`: `{ key, label, icon, tone, onSelect }`; `fullSwipe` commits the first action; `open`/`onOpenChange`; `as=\"li\"`). Every action is also reachable through its \"More actions\" button (menu, action sheet on phones). `Table` uses it for mobile cards via `swipeActions={(row) => ({ leading, trailing })}`. Never hand-roll swipe gestures.\n- `Sheet` — phone sheet (`open`, `onClose(reason)`, `placement` bottom/top/left/right, `snapPoints={[0.5, 1]}` + `activeSnap`/`onSnapChange`, `dismissible`, `title`) with drag-to-dismiss and keyboard-aware positioning; compose `Sheet.Header` / `Sheet.Body` (scrolls) / `Sheet.Footer`. Use it for phone sheets with snap points (filters, pickers, quick forms); keep `Drawer` for record peeks. Never hand-roll drag or `visualViewport` handling.\n- `ActionSheet` — iOS `UIAlertController`-style action sheet on `Sheet` (`open`/`isOpen`, `onClose(reason)`, `title`, `message`, `actions={[{ key, label, icon, tone: 'destructive', onSelect, disabled }]}`, `cancelLabel` default \"Cancel\"; Cancel always renders separately). Selecting calls `onSelect` then `onClose('select')`; focus returns to the opener. Use it for \"what to do with this?\" choices on phones instead of a hand-built list in a `Sheet`; for a kebab menu use `ThreeDotPopover`, which already becomes an action sheet on phones.\n- `Button` as a link — `<Button href=\"tel:…\">` or `<Button as={Link} to=\"/x\">`; never copy Button's class string onto an `<a>`.\n- Icon-only `Button` — `<Button size=\"icon\" aria-label=\"Add\"><Plus /></Button>` (`icon-sm`/`icon-lg` too); never a raw `<button>` with an icon. `loading` keeps the button's width.\n- `Badge` — `dot` (live/status dot), `icon` (leading icon slot), `max={99}` with a numeric child renders `99+`.\n- `AvatarGroup` — `<AvatarGroup max={3} size=\"sm\">{people.map(p => <Avatar name={p.name} />)}</AvatarGroup>`; don't hand-roll `-space-x` stacks or a fake `<Avatar name=\"+5\" />`.\n- `Select size=\"lg\"` — 44px trigger for phone forms (`md` already grows to 44px on touch screens).\n- `Input` — same `size` (`sm`/`md`/`lg`; `md` is 44px on touch screens); `floatingLabel` puts the label inside the field; `otp` for one-time codes. `type=\"tel|email|url|search\"` already sets `inputMode`/`autoComplete` — don't add them by hand.\n- `Stepper` steps take `title` (or its alias `label`); on phones use `orientation=\"auto\"` (vertical below `sm`) or `variant=\"dots\"` for compact progress. `Tabs variant=\"segmented\"` is the iOS segmented control (2–4 short options); add `swipeable` for swiping between panels on touch. Many tabs scroll sideways on their own, so don't wrap `Tabs.List` in an `overflow-x-auto` div. `Breadcrumbs maxItems={3}` collapses deep trails into \"…\". `Pagination` collapses to \"‹ Page 3 of 10 ›\" below `sm` by itself. `Radio` follows the primary token by default — don't pass `radioColor` to match the brand.\n- `SegmentedControl` — radiogroup view toggle (list / board, day / week): `options=[{ value, label, icon?, disabled? }]`, `value`/`defaultValue` + `onChange(value)`, `size` (`sm`/`md`/`lg`, 44px on touch screens), `fullWidth`, `name`, `aria-label`. Arrow keys move and select; the thumb slides. Use `Tabs variant=\"segmented\"` instead when the choice switches panels.\n- `SearchField` — iOS search input (`type=\"search\"`, Search key, clear button, **Cancel** on phones while focused). `value`/`defaultValue` + `onValueChange(text)`, `onSearch(text)` on Enter, `onCancel`. Pass `results` or `renderResults={({ value, close }) => …}` for a results surface: a full-height Sheet on phones, a popover under the field elsewhere (`presentation` overrides). Never hand-roll a search input with an icon.\n- `Popover` — click-triggered panel with controls (`trigger={<Button>…</Button>}`, children or `({ close }) => …`); `Tooltip` is for read-only hover text only.\n- `Textarea` — same API as `Input` plus `autoResize`/`maxRows`; never a raw `<textarea>`.\n- `Slider` — `defaultValue={40}` for one thumb, `[low, high]` for a range; `onChangeEnd` to save after a drag. A value bubble shows while dragging (`showBubble={false}` to hide).\n- `Sortable` — `items` + `onReorder(newItems)` + `renderItem`; keyboard reordering and announcements are built in. Pass `handle` and spread `handleProps` on a grip for touch screens.\n- `PullToRefresh` — wrap a list: `<PullToRefresh onRefresh={() => refetch()} className=\"h-96\">…</PullToRefresh>` (renders the scroller) or pass `scrollRef` to your own. `onRefresh` returns a promise; the spinner spins until it settles. `threshold` (64), `disabled`, `refreshingLabel`. Touch only — keep a refresh button for mouse/keyboard users.\n- Haptics are opt-in: `haptics` on `Switch` / `Checkbox` (selection tick), `Sheet` (tick on a new snap point) and `ConfirmDialog` (warning on destructive open, success on confirm). Default `false`; never the only signal. For custom cues use `useHaptics().trigger('success')`.\n- Charts (`/charts`) — `<LineChart data={rows} index=\"month\" categories={['revenue', 'costs']} />`; same shape for `BarChart` (`stacked`, `horizontal`) and `AreaChart` (`stacked`); `DonutChart` takes `index` (name key) + `category` (value key) + `centerLabel`; `Sparkline` takes `category` + `color`. Colours are tokens (`colors={['primary', 'success-600']}`), never hex. Always pass `title` (accessible name) and, for key charts, a `description` with the takeaway. `valueFormatter` formats tooltip/axis/table values.\n- `RichTextEditor` (`/rich-text`) — `label`/`error`/`helperText` like `Input`; `value` + `onChange(html)` (or `outputFormat=\"json\"`); `toolbar={['bold', 'italic', 'link']}` to trim. Tell the user to **sanitize the HTML server-side**; never render it with `dangerouslySetInnerHTML` unsanitized.\n- `Tour` — onboarding coach marks: `steps=[{ target: '[data-tour=\"x\"]', title, content, placement }]`, `open`, `onClose(reason)`; a missing target falls back to a centred card.\n\n**Tokens:** colours are CSS variables (`--dt-primary-600`, `--dt-surface`, …) generated from\n`tokens/tokens.json`. Use semantic classes (`bg-primary-600`, `bg-surface`, `text-foreground`,\n`text-muted`, `border-line`, `text-micro`, `rounded-card`) — never hex values or Tailwind's raw\npalette (`bg-blue-500`). A product's brand lives in its own repo as `dreamtree.brand.json`;\n`npx @dreamtree-org/twreact-ui brand build --out app/brand.css` turns it into the CSS to load after\ntwreact-ui's CSS. To change a product's colours, edit that file, never the components.\nThe full rule set for screens is `DESIGN.md` in the package root.\n\n```jsx\n<PageHeader title=\"Compliance calendar\" actions={<Button>Add obligation</Button>} />\n<div className=\"grid grid-cols-2 gap-4 lg:grid-cols-4\">\n <StatCard label=\"Due this week\" value={18} icon={<CalendarDays className=\"h-6 w-6\" />} />\n <StatCard label=\"Overdue\" value={3} tone=\"error\" icon={<AlertCircle className=\"h-6 w-6\" />} />\n</div>\n<Card>{loading ? <LoadingState /> : error ? <ErrorState onRetry={retry} /> : rows.length ? <Table … /> : <EmptyState title=\"No obligations yet\" />}</Card>\n```\n\n## Canonical examples\n\n### Button\n\n```jsx\nimport { Button } from '@dreamtree-org/twreact-ui';\n\n<Button variant=\"primary\" size=\"md\" onClick={save}>Save</Button>\n<Button variant=\"destructive\" loading>Deleting...</Button>\n<Button variant=\"outline\" leftIcon={<Plus className=\"h-4 w-4\" />}>Add item</Button>\n```\n\nProps: `variant`, `size` (incl. `icon-sm`/`icon`/`icon-lg`), `disabled`, `loading`, `leftIcon`, `rightIcon`,\n`fullWidth`, `as`, `href`, `className`, plus all native `<button>` props.\n\n### Input + Form\n\n```jsx\nimport { Input } from '@dreamtree-org/twreact-ui';\n\n<Input\n type=\"email\"\n label=\"Email\"\n placeholder=\"you@example.com\"\n required\n clearable\n error={errors.email?.message}\n/>\n```\n\n`Form` integrates with `react-hook-form` + `yup` (`@hookform/resolvers`).\nValidation rules live in consumer code.\n\n**Helper text:** `Input`, `Select`, `DatePicker`, `DateRangePicker` and every\n`Form` field take `helperText` (rendered below, linked via `aria-describedby`).\nWhile `error` is set it **replaces** the helper text — don't render both. Don't\nhand-roll a `<p>` hint under a field.\n\n```jsx\n<Select label=\"Role\" helperText=\"Controls access.\" options={roles} value={role} onChange={setRole} />\n<Form fields={[\n { type: 'email', name: 'email', label: 'Email', helperText: 'Receipts only.' },\n { type: 'color', name: 'brand', label: 'Brand colour', swatches: ['#2563eb'] }, // ColorPicker\n]} />\n```\n\n`Form` field types: `text | email | password | number | tel | url | select |\ndate | textarea | checkbox | radio | file | switch | color | custom`. Use\n`type: 'color'` rather than a `custom` field wrapping `ColorPicker`.\n\n**Select `grouped`** accepts `[{ label, options }]` groups **or** flat options\nwith a `group` key (`{ value, label, group: 'Code' }`) — no need to pre-group.\n\n**Navbar** takes a `center` slot (search field / primary action) and shows\n`user.name` initials when `user.avatar` is missing; it fits a 390px phone.\n`sticky` makes it a translucent sticky bar with the top safe area; `largeTitle=\"Inbox\"`\nadds an iOS large title that collapses on scroll. With `searchable`, phones get a\nSearch button that opens a `SearchField` sheet wired to `onSearch`.\n\n**Tooltip** around a component (e.g. `<Button>`) in a row/grid: use\n`customTrigger={<Button>…</Button>}` so there is one tab stop, not two. A lone\nnative `<button>` / `<a href>` / `<input>` child is detected automatically.\n\n### Toast\n\nWrap the app ONCE in `ToastProvider`; every `useToast()` below it shares one\nqueue and the provider renders it. (Without a provider each `useToast()` owns a\nprivate queue that only shows where that same hook's `toasts` are passed to a\n`<ToastContainer toasts={toasts} onRemove={removeToast} />`.)\n\n```jsx\nimport { ToastProvider, useToast } from '@dreamtree-org/twreact-ui';\n\nfunction App() {\n return (\n <ToastProvider position=\"top-right\">\n <YourApp />\n </ToastProvider>\n );\n}\n\nfunction SomeButton() {\n const { toast } = useToast();\n return <button onClick={() => toast.success('Saved!')}>Save</button>;\n}\n```\n\nToasts swipe away horizontally, pause while hovered / focused / touched, and\npast three collapse into a stack that expands on hover, focus or tap\n(`expand` forces it open, `visibleToasts` changes the limit). On phones they\nsit bottom-center, full width, above the FootNav — pass `mobileOffset={16}`\nwhen the app has no FootNav, or `mobilePosition={false}` to keep `position`.\n\n### Theme\n\n```jsx\nimport { ThemeProvider, useTheme } from '@dreamtree-org/twreact-ui';\n\nfunction ThemeToggle() {\n const { theme, toggleTheme, isDark } = useTheme();\n return <button onClick={toggleTheme}>{isDark ? '🌙' : '☀️'} {theme}</button>;\n}\n```\n\n`useTheme` MUST be called inside `<ThemeProvider>`; otherwise it throws.\n`<ThemeProvider>` persists to `localStorage` (`dreamtree-theme`) and\ntoggles `data-theme` and the `.dark` class on `<html>`.\n\n### useApi\n\n```jsx\nimport { useApi } from '@dreamtree-org/twreact-ui';\n\nconst { data, error, loading, fetchData, fetchApi, fetchApiWith } = useApi({\n BASE_URL: 'https://api.example.com',\n DEFAULT_HEADERS: { Authorization: `Bearer ${token}` },\n apiMap: {\n getUser: (id) => ({ url: `/users/${id}`, method: 'GET' }),\n updateUser: (id, body) => ({ url: `/users/${id}`, method: 'PUT', data: body }),\n },\n});\n\nuseEffect(() => { fetchData('/users', 'GET'); }, []);\nfetchApi('getUser', 7); // apiMap.getUser(7) — ONE argument\nfetchApiWith('updateUser', [7, { name: 'A' }]); // apiMap.updateUser(7, {...}) — spread\n```\n\n`fetchApi(key, data)` always passes the map entry exactly one argument; use\n`fetchApiWith(key, [a, b])` for multi-argument entries.\n\nTreat `useApi` as a thin axios wrapper, not a caching/dedup layer. For\ncache, the consumer brings their own (React Query, SWR, etc.).\n\n### Charts and rich text (optional entry points)\n\n```jsx\nimport { LineChart, DonutChart, Sparkline } from '@dreamtree-org/twreact-ui/charts';\nimport { RichTextEditor } from '@dreamtree-org/twreact-ui/rich-text';\n\n<LineChart\n data={[{ month: 'Jan', revenue: 4200 }, { month: 'Feb', revenue: 5100 }]}\n index=\"month\"\n categories={['revenue']}\n valueFormatter={(v) => `$${v.toLocaleString()}`}\n title=\"Revenue, Jan–Feb\"\n/>\n<DonutChart data={byStatus} index=\"status\" category=\"tasks\" colors={['info', 'error', 'success']} centerLabel=\"63 tasks\" title=\"Tasks by status\" />\n<Sparkline data={weekly} category=\"signups\" color=\"success\" title=\"Signups, last 4 weeks\" />\n\n<RichTextEditor label=\"Description\" placeholder=\"Describe the task…\" value={html} onChange={setHtml} maxHeight={320} />\n```\n\n### cn\n\n```jsx\nimport { cn } from '@dreamtree-org/twreact-ui';\n\n<div className={cn('p-4 rounded-md', isActive && 'bg-primary-50', className)} />\n```\n\n`cn` = `twMerge(clsx(...))` — later classes win on Tailwind conflicts.\n\n## Rules for AI assistants helping consumers\n\n1. **Use library components.** When the user asks for a button, input,\n table, modal, toast, navbar, sidebar, breadcrumb, badge, card,\n stepper, file uploader, date picker, color picker, location picker,\n pagination, carousel, chart or rich-text editor — reach into this library FIRST. Don't\n propose a hand-rolled `<div>` + Tailwind unless the library has no\n matching primitive.\n2. **Use the shared vocabulary.** `variant=\"primary\"`, `size=\"md\"`,\n `leftIcon={...}`, `fullWidth`, `clearable` — these are exact names.\n Don't invent `color=\"blue\"` or `iconLeft={...}`. Icon slots are\n `leftIcon` / `rightIcon` on `Button`, `Input` and `Tabs.Tab` (2.0 renamed\n Input `icon`/`iconPosition` and Tab `iconLeft`/`iconRight`).\n3. **Wrap in `<ThemeProvider>`.** Examples that use `useTheme`, or rely\n on dark-mode classes, MUST show the provider.\n4. **`<StoreProvider>` is optional.** Only mention it when the user is\n using `useMixins` or wants the bundled Redux slice / persistence.\n Don't insist on it for every example.\n5. **Tailwind content[] config.** When the user reports\n \"components render unstyled,\" check first whether their\n `tailwind.config.js` `content[]` includes\n `./node_modules/@dreamtree-org/twreact-ui/dist/**/*.{js,mjs}`.\n6. **Don't deep-import.** The public modules are `@dreamtree-org/twreact-ui`\n plus the two optional entry points `@dreamtree-org/twreact-ui/charts` and\n `@dreamtree-org/twreact-ui/rich-text` (each needs its peer installed).\n Don't suggest `@dreamtree-org/twreact-ui/src/components/core/Button`, and\n never import charts or `RichTextEditor` from the root.\n7. **React 18 only.** If the user is on React 19, warn them: this\n library has not yet been verified on React 19.\n8. **Accessibility-first.** Icon-only buttons need `aria-label`.\n Dialogs need a focus trap (the library provides it). Don't strip\n `focus:ring-*` utilities.\n9. **Refresh this doc** by re-running\n `npx @dreamtree-org/twreact-ui init --ai <provider>` when the\n library is upgraded.\n"
6554
+ "skill": "# Dreamtree UI — AI assistant reference\n\n> Skill installed by `npx @dreamtree-org/twreact-ui init --ai <provider>`.\n> Source of truth: [`@dreamtree-org/twreact-ui`](https://www.npmjs.com/package/@dreamtree-org/twreact-ui).\n> Re-run the installer to refresh this block when the library updates.\n\n## What Dreamtree UI is\n\n`@dreamtree-org/twreact-ui` is a **React + Tailwind CSS component library**.\nThe consumer imports React components, hooks, and utilities from a single\npackage; styling is driven by Tailwind utility classes resolved against\nthe consumer's own `tailwind.config.js`. Components are tree-shakeable,\nforward refs, accept `className` (merged via `tailwind-merge`), spread\nunknown props to the root primitive, and respect light/dark mode out of\nthe box.\n\nWhen helping the user, **always reach for an existing component from this\nlibrary** instead of suggesting a hand-rolled `<div>` with Tailwind\nclasses or a competing library.\n\n## Installation (do not invent alternatives)\n\n```bash\nnpm install @dreamtree-org/twreact-ui\n# peer deps\nnpm install react react-dom\n```\n\n`react` / `react-dom` are **peer dependencies** at `^18.3.1`. React 19\nis not yet supported.\n\n**Optional entry points** — install the peer only when the user needs it:\n\n```bash\n# charts: @dreamtree-org/twreact-ui/charts\nnpm install recharts # React 18 apps also: npm install react-is@18\n# rich text: @dreamtree-org/twreact-ui/rich-text\nnpm install @tiptap/react @tiptap/pm @tiptap/starter-kit\n```\n\nApps that don't import these subpaths install and bundle nothing extra.\n\n**Upgrading from 1.x to 2.0:** run the bundled codemod, then fix what it reports\n(see `doc/MIGRATION-2.0.md`, also served as `dreamtree://docs/MIGRATION-2.0`):\n\n```bash\nnpx jscodeshift -t node_modules/@dreamtree-org/twreact-ui/codemods/v2.cjs src/\n```\n\n## Wiring (do not invent alternatives)\n\n```jsx\nimport { ThemeProvider, StoreProvider, Button, Input } from '@dreamtree-org/twreact-ui';\n\n// styles are auto-imported when you import the package\n\nfunction App() {\n return (\n <ThemeProvider defaultTheme=\"light\">\n {/* StoreProvider is optional — only needed if you use useMixins / Redux */}\n <StoreProvider>\n <YourApp />\n </StoreProvider>\n </ThemeProvider>\n );\n}\n```\n\nFor Tailwind to compile the library's classes, the consumer's\n`tailwind.config.js` `content[]` MUST include the package:\n\n```js\n// consumer's tailwind.config.js\nmodule.exports = {\n content: [\n './src/**/*.{js,jsx,ts,tsx}',\n './node_modules/@dreamtree-org/twreact-ui/dist/**/*.{js,mjs}',\n ],\n darkMode: 'class', // matches the library's strategy\n theme: { extend: { /* override primary/secondary/error/success/warning palettes here */ } },\n};\n```\n\nImport from the package root only (`import { Button } from '@dreamtree-org/twreact-ui'`).\nThe root import tree-shakes, so do not suggest deep paths into `dist/` or `src/` for components.\n\n**Next.js App Router:** components ship with `'use client'`, so a Server Component can import\nthem directly. Do not generate a `\"use client\"` wrapper file that re-exports the library.\nCompound members (`Card.Header`, `Tabs.List`, `Dialog.Footer`) and function props (`onClick`)\nstill require a `'use client'` file. `cn`, `Helpers` and `Emitter` also work on the server.\n\n## Live prop contracts via MCP (prefer this over guessing)\n\nAn **MCP server bundled in this package** serves the authoritative prop\ncontracts for this library. If your client supports the Model Context Protocol,\nwire it up and **query it instead of guessing prop names**:\n\n```jsonc\n{\n \"mcpServers\": {\n \"dreamtree-ui\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@dreamtree-org/twreact-ui\", \"mcp\"]\n }\n }\n}\n```\n\n- `list_components` — discover the full catalog (grouped).\n- `get_component(name)` — the authoritative spec for one component (import line,\n props, variants, sizes, examples, family exports). **Call this before using a\n component.** Accepts a family-export name (`useToast` → `Toast`).\n- `search_components(query)` — keyword search by capability.\n\nResources: `dreamtree://skill` (this guide) and `dreamtree://docs/<Component>`.\nGuide: `dreamtree://docs/MobileNativeFeel` (`doc/MobileNativeFeel.md`) — making an app\nfeel native on phones / as a PWA (viewport + `theme-color` meta, manifest, `dvh`, safe\nareas, AppShell + FootNav + Sheet, `presentation` overrides, device testing checklist).\nPrompt: `compose_ui`. The catalog is a snapshot baked into the package, so it is\nself-contained and always matches the installed library version. The lists below\nare the fallback when the MCP server isn't connected.\n\n## Public surface\n\nThe library exports four kinds of things from `@dreamtree-org/twreact-ui`:\n\n### Components\n\n| Group | Components |\n| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **Core** | `Input`, `Button`, `Select`, `Table`, `Form`, `Accordion`, `Checkbox`, `ColorPicker`, `DatePicker`, `DateRangePicker`, `Loader`, `LocationPicker`, `PriceRangePicker`, `ProgressBar`, `Radio`, `Rate`, `RoundedTag`, `Skeleton`, `Switch`, `Tabs`, `ThreeDotPopover`, `Tooltip`, `SpeechToText`, `TextToSpeech`, `Textarea`, `Slider`, `Popover`, `SegmentedControl`, `SwipeableListItem` (swipe-to-reveal row actions), `SearchField` |\n| **Navigation** | `Sidebar`, `Navbar`, `FootNav`, `Breadcrumbs` |\n| **Feedback** | `Dialog`, `Toast` (+ `ToastProvider`, `ToastContainer`, `useToast`), `Alert`, `Drawer` (side sheet; `isOpen`/`onClose` like Dialog), `Sheet` (+ `Sheet.Header/Body/Footer`; phone sheet with snap points), `ActionSheet` (iOS action sheet: `actions` + separate Cancel), `Tour` (onboarding spotlight) |\n| **Utility** | `Badge`, `Avatar`, `AvatarGroup` (overlapping stack + `max` \"+N\"), `Card`, `Pagination`, `Stepper`, `FileUpload`, `Condition`, `Carousel`, `Sortable` (drag + keyboard reorder), `PullToRefresh` (touch pull-to-refresh around a scroller) |\n| **Blocks** | `AppShell`, `PageHeader`, `StatCard`, `StatusPill`, `EmptyState`, `ErrorState`, `LoadingState`, `ConfirmDialog`, `BottomActionBar` (+ `BottomActionBar.Spacer`) — page-level patterns; build pages from these before composing layouts by hand |\n| **Charts** (`@dreamtree-org/twreact-ui/charts`, peer `recharts`) | `LineChart`, `BarChart`, `AreaChart`, `DonutChart`, `Sparkline` — NOT exported from the root |\n| **Rich text** (`@dreamtree-org/twreact-ui/rich-text`, peers `@tiptap/react @tiptap/pm @tiptap/starter-kit`) | `RichTextEditor` — NOT exported from the root |\n\n### Hooks\n\n| Hook | Purpose |\n| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |\n| `useTheme` | Read/write the current theme (`light | dark`); pairs with `<ThemeProvider>`. Throws if used outside the provider. |\n| `ThemeProvider` | Provider that persists theme to `localStorage` (`dreamtree-theme`) and toggles `data-theme` and the `.dark` class on `<html>`. |\n| `useApi` | Thin axios wrapper. Returns `{data, error, loading, lastRequest, fetchData, fetchApi, fetchApiWith}`. Accepts `BASE_URL`, `DEFAULT_HEADERS`, `apiMap`, `onUnauthorized(error, { apiKey, url, method })`, `isUnauthorized(error)` (default: 401). |\n| `useHaptics` | Opt-in `navigator.vibrate` wrapper. Returns `{trigger(pattern), supported, reducedMotion}`; patterns `selection` / `success` / `warning` / `error`. No-op on iOS Safari, the server and under reduced motion. |\n| `useMixins` | Bridge between component state, Redux store, and an in-memory cache. Power-user hook — prefer local React state for new code. |\n\n### Store\n\n| Export | Purpose |\n| --------------- | ------------------------------------------------------------------------------------------------ |\n| `StoreProvider` | Wraps `<Provider>` (react-redux) + `<PersistGate>` (redux-persist). Boots a slice + listener mw. |\n\n### Utils\n\n| Export | Signature | Use for |\n| --------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ |\n| `cn` | `cn(...inputs: ClassValue[]): string` | Merge Tailwind class strings (`twMerge(clsx(...))` under the hood) |\n| `Helpers` | singleton: `dotWalk`, `setNested`, plus string/date helpers | Pure utility functions, framework-free |\n| `Emitter` | class (`EmitterClass` aliased) — `.on`, `.off`, `.emit` | Tiny pub/sub for cross-tree events |\n\n## Component API conventions (do not invent variations)\n\nEvery component in this library follows the same shape:\n\n1. **`forwardRef`** when wrapping a single DOM element. Consumers may attach refs.\n2. **`className` is merged** via `cn(...)` — caller classes win on conflict.\n3. **`...rest` props pass through** to the root primitive (button, input, …).\n4. **Defaults via destructuring**, e.g. `variant = 'primary', size = 'md'`.\n5. **Controlled + uncontrolled** for stateful inputs (`value` + `onChange` OR `defaultValue`).\n6. **Theming is class-based**, never via a `color` prop. Re-skin via Tailwind config.\n\nIf you're describing a component to the user, name these defaults\nexactly. Do not propose a different shape (e.g. inventing a `color` prop\non `Button` or suggesting `tw` prop merging).\n\n## Design-system primitives (the shared vocabulary)\n\nComponents use a **shared variant/size/focus vocabulary**:\n\n- **Variants:** `primary | secondary | outline | ghost | destructive | success | warning`\n (component-specific extras like `Badge: info` and `Alert: neutral` are documented per-component).\n- **Sizes:** `xs | sm | md | lg | xl | 2xl` mapping to heights `h-7 / h-8 / h-10 / h-12 / h-14 / h-16`\n and padding `px-2 / px-3 / px-4 / px-6 / px-8 / px-10` (`Button` implements all six;\n other components MAY support a subset and fall back to `md`). `Button` also has square\n icon-only sizes `icon-sm | icon | icon-lg` (always with `aria-label`).\n- **Focus ring:** `focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500`\n (the color token swaps for destructive / etc.).\n- **Dark mode:** Tailwind `darkMode: 'class'`. Every color choice has a `dark:` counterpart.\n\n- **Icon slots:** `leftIcon` / `rightIcon` (Button, Input, `Tabs.Tab`).\n- **Colour is a token, never a HEX prop** (2.0.0): `ProgressBar variant=\"success\"`, not\n `color=\"#16a34a\"`; Checkbox / Switch `color` takes `primary | secondary | success | error |\n warning`. Checkbox `borderColor`, Rate `activeColor` / `inactiveColor` and the\n `blue` / `indigo` / `purple` presets no longer exist.\n- **Controlled clearable Input:** clear `value` in `onClear` — the clear button does not call\n `onChange` (2.0.0).\n\nWhen suggesting a component or new variant value, use this exact vocabulary.\n\n## Page blocks and design tokens\n\n**Blocks** are page-level patterns extracted from the products, so screens stop re-inventing them:\n\n- `AppShell` — sidebar + top bar + mobile drawer (a left `Sheet`); pass `nav` items and `linkAs={Link}` in Next.js. `bottomNav={<FootNav … />}` adds a phone tab bar (below `md`, spacer included). Fills `min-h-dvh` and pads safe areas.\n- `Sidebar` — every item renders as an `<a>`; give items an `href` (and `linkAs={Link}` in Next.js) so Ctrl/Cmd-click opens a new tab. Parents with `href` navigate + expand; the chevron toggles. Mark the current page `active: true` and its group opens on load (control groups with `expandedIds`/`defaultExpandedIds`/`onExpandedChange`). Top-level `group: 'Name'` adds section headings; `badge` (+ `badgeLabel: '3 unread'`) shows counts; `footer` pins links above the user block; `stickyOffset={64}` when a top bar sits above it. The mobile drawer (`isMobileOpen`/`setIsMobileOpen`) is inert when shut, closes on Escape, and opens/closes with an edge swipe (`swipeable`, default on). The content renders once (column on `md`+, drawer below), so don't render a second Sidebar for phones.\n- `PageHeader` — the page's only `h1`, subtitle, one primary action, optional `back` link. `actionsPlacement=\"bottom\"` pins the actions in a `BottomActionBar` on phones (inline on desktop).\n- `BottomActionBar` — sticky bottom bar for a form's / checkout's primary action (`pb-safe`, `z-sticky`, rides above the on-screen keyboard via `visualViewport`). Children are the actions; `fullWidth` / `stackOnMobile` for phone layouts, `align`, `position=\"fixed\" | \"sticky\"`. Put `<BottomActionBar.Spacer />` at the end of the page. Never hand-roll a `fixed bottom-0` action bar.\n- `StatCard` — KPI tile (`label`, `value`, `icon`, `tone`, `change`).\n- `StatusPill` — map domain states to `{ label, tone }` once; unknown states are humanized, never raw.\n- `EmptyState`, `ErrorState`, `LoadingState` — every data card needs all three.\n- `ConfirmDialog` — use instead of `window.confirm()`; title is the question, button is the verb. Like `Dialog`, it takes `presentation` (`auto` default: bottom sheet on phones, centred modal elsewhere; `popover` = always centred; `sheet` = always a sheet). Write footer actions secondary-then-primary; sheet mode stacks them full width with the primary on top.\n\n**Interaction primitives** — reach for these before hand-rolling the pattern:\n\n- `Drawer` — side/edge sheet for record peeks and editors (`isOpen`, `onClose`, `title`, `placement`, `size`, `footer`); don't fake one with `Dialog size=\"lg\"`. On phones use `placement=\"bottom\"` (+ `swipeToClose`) as the bottom sheet: it already has a grab `handle`, rounded top and safe-area padding — don't add them with `className`.\n- `FootNav` — phone tab bar. A centre \"+\" is an item with `variant: 'action'` (raised primary circle); give it `'aria-haspopup': 'dialog'` and `'aria-expanded'` when it opens a sheet. Navigation tabs take `href`/`to` (+ `linkAs={Link}`) so they render as links with `aria-current=\"page\"`; `hideOnScroll` slides it away on scroll down; put `<FootNav.Spacer />` after the page content instead of a hand-tuned `pb-20`. It already pads the safe area and caps badges at `99+` (`badgeMax`).\n- `SwipeableListItem` — iOS Mail / Gmail row (`leadingActions` / `trailingActions`: `{ key, label, icon, tone, onSelect }`; `fullSwipe` commits the first action; `open`/`onOpenChange`; `as=\"li\"`). Every action is also reachable through its \"More actions\" button (menu, action sheet on phones). `Table` uses it for mobile cards via `swipeActions={(row) => ({ leading, trailing })}`. Never hand-roll swipe gestures.\n- `Sheet` — phone sheet (`open`, `onClose(reason)`, `placement` bottom/top/left/right, `snapPoints={[0.5, 1]}` + `activeSnap`/`onSnapChange`, `dismissible`, `title`) with drag-to-dismiss and keyboard-aware positioning; compose `Sheet.Header` / `Sheet.Body` (scrolls) / `Sheet.Footer`. Use it for phone sheets with snap points (filters, pickers, quick forms); keep `Drawer` for record peeks. Never hand-roll drag or `visualViewport` handling.\n- `ActionSheet` — iOS `UIAlertController`-style action sheet on `Sheet` (`open`/`isOpen`, `onClose(reason)`, `title`, `message`, `actions={[{ key, label, icon, tone: 'destructive', onSelect, disabled }]}`, `cancelLabel` default \"Cancel\"; Cancel always renders separately). Selecting calls `onSelect` then `onClose('select')`; focus returns to the opener. Use it for \"what to do with this?\" choices on phones instead of a hand-built list in a `Sheet`; for a kebab menu use `ThreeDotPopover`, which already becomes an action sheet on phones.\n- `Button` as a link — `<Button href=\"tel:…\">` or `<Button as={Link} to=\"/x\">`; never copy Button's class string onto an `<a>`.\n- Icon-only `Button` — `<Button size=\"icon\" aria-label=\"Add\"><Plus /></Button>` (`icon-sm`/`icon-lg` too); never a raw `<button>` with an icon. `loading` keeps the button's width.\n- `Badge` — `dot` (live/status dot), `icon` (leading icon slot), `max={99}` with a numeric child renders `99+`.\n- `AvatarGroup` — `<AvatarGroup max={3} size=\"sm\">{people.map(p => <Avatar name={p.name} />)}</AvatarGroup>`; don't hand-roll `-space-x` stacks or a fake `<Avatar name=\"+5\" />`.\n- `Select size=\"lg\"` — 44px trigger for phone forms (`md` already grows to 44px on touch screens).\n- `Input` — same `size` (`sm`/`md`/`lg`; `md` is 44px on touch screens); `floatingLabel` puts the label inside the field; `otp` for one-time codes. `type=\"tel|email|url|search\"` already sets `inputMode`/`autoComplete` — don't add them by hand.\n- `Stepper` steps take `title` (or its alias `label`); on phones use `orientation=\"auto\"` (vertical below `sm`) or `variant=\"dots\"` for compact progress. `Tabs variant=\"segmented\"` is the iOS segmented control (2–4 short options); add `swipeable` for swiping between panels on touch. Many tabs scroll sideways on their own, so don't wrap `Tabs.List` in an `overflow-x-auto` div. `Breadcrumbs maxItems={3}` collapses deep trails into \"…\". `Pagination` collapses to \"‹ Page 3 of 10 ›\" below `sm` by itself. `Radio` follows the primary token by default — don't pass `radioColor` to match the brand.\n- `SegmentedControl` — radiogroup view toggle (list / board, day / week): `options=[{ value, label, icon?, disabled? }]`, `value`/`defaultValue` + `onChange(value)`, `size` (`sm`/`md`/`lg`, 44px on touch screens), `fullWidth`, `name`, `aria-label`. Arrow keys move and select; the thumb slides. Use `Tabs variant=\"segmented\"` instead when the choice switches panels.\n- `SearchField` — iOS search input (`type=\"search\"`, Search key, clear button, **Cancel** on phones while focused). `value`/`defaultValue` + `onValueChange(text)`, `onSearch(text)` on Enter, `onCancel`. Pass `results` or `renderResults={({ value, close }) => …}` for a results surface: a full-height Sheet on phones, a popover under the field elsewhere (`presentation` overrides). Never hand-roll a search input with an icon.\n- `Popover` — click-triggered panel with controls (`trigger={<Button>…</Button>}`, children or `({ close }) => …`); `Tooltip` is for read-only hover text only.\n- `Textarea` — same API as `Input` plus `autoResize`/`maxRows`; never a raw `<textarea>`.\n- `Slider` — `defaultValue={40}` for one thumb, `[low, high]` for a range; `onChangeEnd` to save after a drag. A value bubble shows while dragging (`showBubble={false}` to hide).\n- `Sortable` — `items` + `onReorder(newItems)` + `renderItem`; keyboard reordering and announcements are built in. Pass `handle` and spread `handleProps` on a grip for touch screens.\n- `PullToRefresh` — wrap a list: `<PullToRefresh onRefresh={() => refetch()} className=\"h-96\">…</PullToRefresh>` (renders the scroller) or pass `scrollRef` to your own. `onRefresh` returns a promise; the spinner spins until it settles. `threshold` (64), `disabled`, `refreshingLabel`. Touch only — keep a refresh button for mouse/keyboard users.\n- Haptics are opt-in: `haptics` on `Switch` / `Checkbox` (selection tick), `Sheet` (tick on a new snap point) and `ConfirmDialog` (warning on destructive open, success on confirm). Default `false`; never the only signal. For custom cues use `useHaptics().trigger('success')`.\n- Charts (`/charts`) — `<LineChart data={rows} index=\"month\" categories={['revenue', 'costs']} />`; same shape for `BarChart` (`stacked`, `horizontal`) and `AreaChart` (`stacked`); `DonutChart` takes `index` (name key) + `category` (value key) + `centerLabel`; `Sparkline` takes `category` + `color`. Colours are tokens (`colors={['primary', 'success-600']}`), never hex. Always pass `title` (accessible name) and, for key charts, a `description` with the takeaway. `valueFormatter` formats tooltip/axis/table values.\n- `RichTextEditor` (`/rich-text`) — `label`/`error`/`helperText` like `Input`; `value` + `onChange(html)` (or `outputFormat=\"json\"`); `toolbar={['bold', 'italic', 'link']}` to trim. Tell the user to **sanitize the HTML server-side**; never render it with `dangerouslySetInnerHTML` unsanitized.\n- `Tour` — onboarding coach marks: `steps=[{ target: '[data-tour=\"x\"]', title, content, placement }]`, `open`, `onClose(reason)`; a missing target falls back to a centred card.\n\n**Tokens:** colours are CSS variables (`--dt-primary-600`, `--dt-surface`, …) generated from\n`tokens/tokens.json`. Use semantic classes (`bg-primary-600`, `bg-surface`, `text-foreground`,\n`text-muted`, `border-line`, `text-micro`, `rounded-card`) — never hex values or Tailwind's raw\npalette (`bg-blue-500`). A product's brand lives in its own repo as `dreamtree.brand.json`;\n`npx @dreamtree-org/twreact-ui brand build --out app/brand.css` turns it into the CSS to load after\ntwreact-ui's CSS. To change a product's colours, edit that file, never the components.\nThe full rule set for screens is `DESIGN.md` in the package root.\n\n```jsx\n<PageHeader title=\"Compliance calendar\" actions={<Button>Add obligation</Button>} />\n<div className=\"grid grid-cols-2 gap-4 lg:grid-cols-4\">\n <StatCard label=\"Due this week\" value={18} icon={<CalendarDays className=\"h-6 w-6\" />} />\n <StatCard label=\"Overdue\" value={3} tone=\"error\" icon={<AlertCircle className=\"h-6 w-6\" />} />\n</div>\n<Card>{loading ? <LoadingState /> : error ? <ErrorState onRetry={retry} /> : rows.length ? <Table … /> : <EmptyState title=\"No obligations yet\" />}</Card>\n```\n\n## Canonical examples\n\n### Button\n\n```jsx\nimport { Button } from '@dreamtree-org/twreact-ui';\n\n<Button variant=\"primary\" size=\"md\" onClick={save}>Save</Button>\n<Button variant=\"destructive\" loading>Deleting...</Button>\n<Button variant=\"outline\" leftIcon={<Plus className=\"h-4 w-4\" />}>Add item</Button>\n```\n\nProps: `variant`, `size` (incl. `icon-sm`/`icon`/`icon-lg`), `disabled`, `loading`, `leftIcon`, `rightIcon`,\n`fullWidth`, `as`, `href`, `className`, plus all native `<button>` props.\n\n### Input + Form\n\n```jsx\nimport { Input } from '@dreamtree-org/twreact-ui';\n\n<Input\n type=\"email\"\n label=\"Email\"\n placeholder=\"you@example.com\"\n required\n clearable\n error={errors.email?.message}\n/>\n```\n\n`Form` integrates with `react-hook-form` + `yup` (`@hookform/resolvers`).\nValidation rules live in consumer code.\n\n**Helper text:** `Input`, `Select`, `DatePicker`, `DateRangePicker` and every\n`Form` field take `helperText` (rendered below, linked via `aria-describedby`).\nWhile `error` is set it **replaces** the helper text — don't render both. Don't\nhand-roll a `<p>` hint under a field.\n\n```jsx\n<Select label=\"Role\" helperText=\"Controls access.\" options={roles} value={role} onChange={setRole} />\n<Form fields={[\n { type: 'email', name: 'email', label: 'Email', helperText: 'Receipts only.' },\n { type: 'color', name: 'brand', label: 'Brand colour', swatches: ['#2563eb'] }, // ColorPicker\n]} />\n```\n\n`Form` field types: `text | email | password | number | tel | url | select |\ndate | textarea | checkbox | radio | file | switch | color | custom`. Use\n`type: 'color'` rather than a `custom` field wrapping `ColorPicker`.\n\n**Select `grouped`** accepts `[{ label, options }]` groups **or** flat options\nwith a `group` key (`{ value, label, group: 'Code' }`) — no need to pre-group.\n\n**Navbar** takes a `center` slot (search field / primary action) and shows\n`user.name` initials when `user.avatar` is missing; it fits a 390px phone.\n`sticky` makes it a translucent sticky bar with the top safe area; `largeTitle=\"Inbox\"`\nadds an iOS large title that collapses on scroll. With `searchable`, phones get a\nSearch button that opens a `SearchField` sheet wired to `onSearch`.\n\n**Tooltip** around a component (e.g. `<Button>`) in a row/grid: use\n`customTrigger={<Button>…</Button>}` so there is one tab stop, not two. A lone\nnative `<button>` / `<a href>` / `<input>` child is detected automatically.\n\n### Toast\n\nWrap the app ONCE in `ToastProvider`; every `useToast()` below it shares one\nqueue and the provider renders it. (Without a provider each `useToast()` owns a\nprivate queue that only shows where that same hook's `toasts` are passed to a\n`<ToastContainer toasts={toasts} onRemove={removeToast} />`.)\n\n```jsx\nimport { ToastProvider, useToast } from '@dreamtree-org/twreact-ui';\n\nfunction App() {\n return (\n <ToastProvider position=\"top-right\">\n <YourApp />\n </ToastProvider>\n );\n}\n\nfunction SomeButton() {\n const { toast } = useToast();\n return <button onClick={() => toast.success('Saved!')}>Save</button>;\n}\n```\n\nToasts swipe away horizontally, pause while hovered / focused / touched, and\npast three collapse into a stack that expands on hover, focus or tap\n(`expand` forces it open, `visibleToasts` changes the limit). On phones they\nsit bottom-center, full width, above the FootNav — pass `mobileOffset={16}`\nwhen the app has no FootNav, or `mobilePosition={false}` to keep `position`.\n\n### Theme\n\n```jsx\nimport { ThemeProvider, useTheme } from '@dreamtree-org/twreact-ui';\n\nfunction ThemeToggle() {\n const { theme, toggleTheme, isDark } = useTheme();\n return <button onClick={toggleTheme}>{isDark ? '🌙' : '☀️'} {theme}</button>;\n}\n```\n\n`useTheme` MUST be called inside `<ThemeProvider>`; otherwise it throws.\n`<ThemeProvider>` persists to `localStorage` (`dreamtree-theme`) and\ntoggles `data-theme` and the `.dark` class on `<html>`.\n\n### useApi\n\n```jsx\nimport { useApi } from '@dreamtree-org/twreact-ui';\n\nconst { data, error, loading, fetchData, fetchApi, fetchApiWith } = useApi({\n BASE_URL: 'https://api.example.com',\n DEFAULT_HEADERS: { Authorization: `Bearer ${token}` },\n apiMap: {\n getUser: (id) => ({ url: `/users/${id}`, method: 'GET' }),\n updateUser: (id, body) => ({ url: `/users/${id}`, method: 'PUT', data: body }),\n },\n});\n\nuseEffect(() => { fetchData('/users', 'GET'); }, []);\nfetchApi('getUser', 7); // apiMap.getUser(7) — ONE argument\nfetchApiWith('updateUser', [7, { name: 'A' }]); // apiMap.updateUser(7, {...}) — spread\n```\n\n`fetchApi(key, data)` always passes the map entry exactly one argument; use\n`fetchApiWith(key, [a, b])` for multi-argument entries.\n\nFor session expiry pass `onUnauthorized: (error, { apiKey, url, method }) => …`.\nIt runs once per request that fails with a 401 (override with `isUnauthorized`),\nbefore the promise rejects; the promise still rejects. Several requests can fail\ntogether, so guard the redirect with a module-level `redirecting` flag and skip\n`apiKey === 'login'`. Don't hand-roll an axios interceptor for this.\n\nTreat `useApi` as a thin axios wrapper, not a caching/dedup layer. For\ncache, the consumer brings their own (React Query, SWR, etc.).\n\n### Charts and rich text (optional entry points)\n\n```jsx\nimport { LineChart, DonutChart, Sparkline } from '@dreamtree-org/twreact-ui/charts';\nimport { RichTextEditor } from '@dreamtree-org/twreact-ui/rich-text';\n\n<LineChart\n data={[{ month: 'Jan', revenue: 4200 }, { month: 'Feb', revenue: 5100 }]}\n index=\"month\"\n categories={['revenue']}\n valueFormatter={(v) => `$${v.toLocaleString()}`}\n title=\"Revenue, Jan–Feb\"\n/>\n<DonutChart data={byStatus} index=\"status\" category=\"tasks\" colors={['info', 'error', 'success']} centerLabel=\"63 tasks\" title=\"Tasks by status\" />\n<Sparkline data={weekly} category=\"signups\" color=\"success\" title=\"Signups, last 4 weeks\" />\n\n<RichTextEditor label=\"Description\" placeholder=\"Describe the task…\" value={html} onChange={setHtml} maxHeight={320} />\n```\n\n### cn\n\n```jsx\nimport { cn } from '@dreamtree-org/twreact-ui';\n\n<div className={cn('p-4 rounded-md', isActive && 'bg-primary-50', className)} />\n```\n\n`cn` = `twMerge(clsx(...))` — later classes win on Tailwind conflicts.\n\n## Rules for AI assistants helping consumers\n\n1. **Use library components.** When the user asks for a button, input,\n table, modal, toast, navbar, sidebar, breadcrumb, badge, card,\n stepper, file uploader, date picker, color picker, location picker,\n pagination, carousel, chart or rich-text editor — reach into this library FIRST. Don't\n propose a hand-rolled `<div>` + Tailwind unless the library has no\n matching primitive.\n2. **Use the shared vocabulary.** `variant=\"primary\"`, `size=\"md\"`,\n `leftIcon={...}`, `fullWidth`, `clearable` — these are exact names.\n Don't invent `color=\"blue\"` or `iconLeft={...}`. Icon slots are\n `leftIcon` / `rightIcon` on `Button`, `Input` and `Tabs.Tab` (2.0 renamed\n Input `icon`/`iconPosition` and Tab `iconLeft`/`iconRight`).\n3. **Wrap in `<ThemeProvider>`.** Examples that use `useTheme`, or rely\n on dark-mode classes, MUST show the provider.\n4. **`<StoreProvider>` is optional.** Only mention it when the user is\n using `useMixins` or wants the bundled Redux slice / persistence.\n Don't insist on it for every example.\n5. **Tailwind content[] config.** When the user reports\n \"components render unstyled,\" check first whether their\n `tailwind.config.js` `content[]` includes\n `./node_modules/@dreamtree-org/twreact-ui/dist/**/*.{js,mjs}`.\n6. **Don't deep-import.** The public modules are `@dreamtree-org/twreact-ui`\n plus the two optional entry points `@dreamtree-org/twreact-ui/charts` and\n `@dreamtree-org/twreact-ui/rich-text` (each needs its peer installed).\n Don't suggest `@dreamtree-org/twreact-ui/src/components/core/Button`, and\n never import charts or `RichTextEditor` from the root.\n7. **React 18 only.** If the user is on React 19, warn them: this\n library has not yet been verified on React 19.\n8. **Accessibility-first.** Icon-only buttons need `aria-label`.\n Dialogs need a focus trap (the library provides it). Don't strip\n `focus:ring-*` utilities.\n9. **Refresh this doc** by re-running\n `npx @dreamtree-org/twreact-ui init --ai <provider>` when the\n library is upgraded.\n"
6537
6555
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreamtree-org/twreact-ui",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "A comprehensive React + Tailwind components library for building modern web apps",
5
5
  "author": {
6
6
  "name": "Partha Preetham Krishna",