@overdoser/react-toolkit 0.0.2 → 0.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.
- package/AGENTS.md +109 -0
- package/llms.txt +480 -0
- package/manifest.json +362 -0
- package/package.json +1 -1
- package/recipes/confirm-modal.tsx +63 -0
- package/recipes/dropdown-menu.tsx +36 -0
- package/recipes/login-form.tsx +51 -0
- package/recipes/paginated-table.tsx +48 -0
- package/recipes/searchable-multi-select.tsx +42 -0
- package/recipes/server-side-table.tsx +92 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# AGENTS.md — @overdoser/react-toolkit
|
|
2
|
+
|
|
3
|
+
Guidance for AI coding assistants integrating this library into a consumer project. Drop the relevant rules into your own project's AGENTS.md / CLAUDE.md / Cursor rules / Copilot instructions.
|
|
4
|
+
|
|
5
|
+
For exhaustive component reference (every prop, every variant, every signature), see `llms.txt` in this package. For machine-readable structured data, see `manifest.json`.
|
|
6
|
+
|
|
7
|
+
## Setup checklist
|
|
8
|
+
|
|
9
|
+
1. Install:
|
|
10
|
+
```bash
|
|
11
|
+
npm install @overdoser/react-toolkit
|
|
12
|
+
```
|
|
13
|
+
2. Install the optional peer **only if** you'll use `Form`/`FormField`:
|
|
14
|
+
```bash
|
|
15
|
+
npm install react-hook-form
|
|
16
|
+
```
|
|
17
|
+
3. Import the theme stylesheet **once** at the app entry point:
|
|
18
|
+
```ts
|
|
19
|
+
import '@overdoser/react-toolkit/theme.css';
|
|
20
|
+
```
|
|
21
|
+
Without this, components render unstyled.
|
|
22
|
+
|
|
23
|
+
## Import rules
|
|
24
|
+
|
|
25
|
+
- Always import from the package root: `import { Button } from '@overdoser/react-toolkit'`. Do **not** reach into subpaths (`@overdoser/react-toolkit/dist/...`) — they're not part of the public API.
|
|
26
|
+
- The only valid stylesheet subpath is `@overdoser/react-toolkit/theme.css`.
|
|
27
|
+
|
|
28
|
+
## Hard rules (don't violate)
|
|
29
|
+
|
|
30
|
+
1. **Don't ship raw `<button>`, `<input>`, `<select>`, `<textarea>`, `<a>` for UI affordances.** Use the toolkit's `Button`, `Input`, `Select`, `Textarea`, `Link` so styling and a11y come for free.
|
|
31
|
+
2. **Inside `<Form>`, every focusable input must be wrapped in `<FormField>`.** Don't manually wire `value`/`onChange`/`name` on the inner input — `FormField` clones the child to inject them.
|
|
32
|
+
3. **`<Modal>` content must be wrapped in `Modal.Header`, `Modal.Body`, `Modal.Footer`.** Raw children may render but they bypass focus targeting and styling.
|
|
33
|
+
4. **Use `inputSize`, not `size`,** on `Input`/`Select`/`Textarea`. The native `size` attribute is preserved separately.
|
|
34
|
+
5. **`Form`'s prop is `form`, not `formMethods`.** Pass the result of `useForm()`.
|
|
35
|
+
6. **`Button` uses `loadingStyle`** (`'dots' | 'shimmer' | 'border'`), not `loadingType`.
|
|
36
|
+
7. **`Typography` `color`** is an enum (`default | muted | primary | danger | success`), not a free-form string. The native HTML `color` attribute is omitted.
|
|
37
|
+
8. **`Popover` content goes in the `content` prop**, not as children. `children` is typed `never`.
|
|
38
|
+
9. **Server-side `Table`:** if you provide `onSort`, the table will not sort or paginate `data` itself. Your backend must return the page already sorted and sliced. Pass `pagination.totalRows` for the paginator to compute pages.
|
|
39
|
+
10. **Theming is via CSS custom properties** (`--crk-*`) on `:root`. Don't override styles by hand-writing CSS that targets internal class names — those are hashed and unstable. Use the `classes` prop where finer control is needed.
|
|
40
|
+
|
|
41
|
+
## Decision flow
|
|
42
|
+
|
|
43
|
+
- **Need a select?**
|
|
44
|
+
- Few options, native UX OK → `<Select options={...} />`.
|
|
45
|
+
- Many options, want a search filter → `<Select searchable options={...} />`.
|
|
46
|
+
- Multi-pick with chips → `<Select multiple options={...} onValuesChange={...} />`.
|
|
47
|
+
- You want it to look like a "menu trigger" instead of a form input → `<Dropdown options={...} value={...} onChange={...} />` (select-mode dropdown).
|
|
48
|
+
|
|
49
|
+
- **Need a menu (Edit / Delete / Archive)?** → `<Dropdown trigger={...}>` with `<DropdownItem>` children.
|
|
50
|
+
|
|
51
|
+
- **Need a tooltip / hover card / contextual panel?** → `<Popover trigger={...} content={...} />`.
|
|
52
|
+
|
|
53
|
+
- **Need a confirm prompt or a full dialog?** → `<Modal>` with `Modal.Header`/`Body`/`Footer`. Always provide either `aria-label` or use a `Modal.Header` (it's auto-wired as `aria-labelledby`).
|
|
54
|
+
|
|
55
|
+
- **Need a data table?** → `<Table>`.
|
|
56
|
+
- All data in memory → don't pass `onSort`; pass `pagination` (without `totalRows`) for client-side paging.
|
|
57
|
+
- Data from a server → pass `sortConfig` + `onSort`, and `pagination` with `totalRows`.
|
|
58
|
+
|
|
59
|
+
- **Multi-step / wizard form?** Use one `useForm()` per step OR a single `<Form>` wrapping all steps with conditional rendering. The toolkit doesn't ship a stepper.
|
|
60
|
+
|
|
61
|
+
## Common shapes (copy-paste)
|
|
62
|
+
|
|
63
|
+
### Wire a Select inside a Form
|
|
64
|
+
```tsx
|
|
65
|
+
<FormField name="role" label="Role">
|
|
66
|
+
<Select options={[
|
|
67
|
+
{ value: 'admin', label: 'Admin' },
|
|
68
|
+
{ value: 'member', label: 'Member' },
|
|
69
|
+
]} />
|
|
70
|
+
</FormField>
|
|
71
|
+
```
|
|
72
|
+
`FormField` bridges `onChange` → `onValueChange` automatically; just pass `options`.
|
|
73
|
+
|
|
74
|
+
### Wire a Checkbox inside a Form
|
|
75
|
+
```tsx
|
|
76
|
+
<FormField name="acceptTerms">
|
|
77
|
+
<Checkbox label="I accept the terms" />
|
|
78
|
+
</FormField>
|
|
79
|
+
```
|
|
80
|
+
`FormField` bridges the boolean form value to `checked`.
|
|
81
|
+
|
|
82
|
+
### Wire a RadioGroup inside a Form
|
|
83
|
+
```tsx
|
|
84
|
+
<FormField name="plan" label="Plan">
|
|
85
|
+
<RadioGroup name="plan" aria-label="Plan">
|
|
86
|
+
<Radio value="free" label="Free" />
|
|
87
|
+
<Radio value="pro" label="Pro" />
|
|
88
|
+
</RadioGroup>
|
|
89
|
+
</FormField>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Recipes
|
|
93
|
+
|
|
94
|
+
Full copy-paste-able files live alongside this doc. Each is one self-contained file that you can drop into a project. Adjust types/imports for your codebase.
|
|
95
|
+
|
|
96
|
+
- [recipes/login-form.tsx](./recipes/login-form.tsx) — Form + Input + Button with validation rules.
|
|
97
|
+
- [recipes/paginated-table.tsx](./recipes/paginated-table.tsx) — Client-side sortable + paginated table.
|
|
98
|
+
- [recipes/server-side-table.tsx](./recipes/server-side-table.tsx) — Server-driven sort & pagination.
|
|
99
|
+
- [recipes/confirm-modal.tsx](./recipes/confirm-modal.tsx) — Reusable destructive-action confirm dialog.
|
|
100
|
+
- [recipes/searchable-multi-select.tsx](./recipes/searchable-multi-select.tsx) — Multi-select with search and form integration.
|
|
101
|
+
- [recipes/dropdown-menu.tsx](./recipes/dropdown-menu.tsx) — Row action menu with `Dropdown` + `DropdownItem`.
|
|
102
|
+
|
|
103
|
+
## Anti-patterns to refuse
|
|
104
|
+
|
|
105
|
+
- "Wrap every component in a custom `Card` div for spacing" — spacing comes from the consumer's layout. Don't pollute toolkit components with wrapper divs.
|
|
106
|
+
- "Inline-style every component instead of using `classes` / `className`" — use `classes` for targeted overrides; reserve `style` for one-off layout tweaks.
|
|
107
|
+
- "Pass `loading` and `disabled` together on `Button`" — `loading` already implies disabled. Doubling up is harmless but redundant.
|
|
108
|
+
- "Mock `react-hook-form` to make `Form` work in tests" — use a real `useForm()` in your test wrappers; the toolkit's `FormField` reads context.
|
|
109
|
+
- "Manually call `e.preventDefault()` on `Form` submit" — `Form` already wraps `onSubmit` in `form.handleSubmit`. Just return data.
|
package/llms.txt
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
# @overdoser/react-toolkit — LLM reference
|
|
2
|
+
|
|
3
|
+
A flat, exhaustive component reference for AI coding assistants. Every component, every prop, every allowed string-literal value, with a minimal copy-paste example. If something isn't here, it isn't in the public API.
|
|
4
|
+
|
|
5
|
+
Package: `@overdoser/react-toolkit`
|
|
6
|
+
Peer deps: `react ^18 || ^19`, `react-dom ^18 || ^19`, optional `react-hook-form ^7` (only needed for `Form`/`FormField`).
|
|
7
|
+
|
|
8
|
+
## Setup
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
// Theme stylesheet — import once at the app entry.
|
|
12
|
+
import '@overdoser/react-toolkit/theme.css';
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Override CSS custom properties on `:root` to theme:
|
|
16
|
+
```css
|
|
17
|
+
:root {
|
|
18
|
+
--crk-color-primary: #3b82f6;
|
|
19
|
+
--crk-color-danger: #ef4444;
|
|
20
|
+
--crk-font-family: 'Inter', sans-serif;
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
All components forward `className` and `style`. Most also accept a `classes` object to override internal element classNames (see `*Classes` types per component).
|
|
25
|
+
|
|
26
|
+
## Components
|
|
27
|
+
|
|
28
|
+
### Button
|
|
29
|
+
Import: `import { Button } from '@overdoser/react-toolkit'`
|
|
30
|
+
Element: `<button>` (forwards ref, accepts all native button props).
|
|
31
|
+
|
|
32
|
+
Props:
|
|
33
|
+
- `variant?: 'primary' | 'secondary' | 'danger' | 'ghost'` — default `'primary'`
|
|
34
|
+
- `size?: 'sm' | 'md' | 'lg'` — default `'md'`
|
|
35
|
+
- `loading?: boolean` — default `false`. When `true`, button is disabled and `aria-busy`.
|
|
36
|
+
- `loadingStyle?: 'dots' | 'shimmer' | 'border'` — default `'dots'`. Only used when `loading` is true.
|
|
37
|
+
- `fullWidth?: boolean` — default `false`
|
|
38
|
+
- `classes?: Partial<ButtonClasses>` where `ButtonClasses = { root, content, shimmer, dots, dot }`
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
```tsx
|
|
42
|
+
<Button variant="danger" size="lg" loading loadingStyle="shimmer" onClick={onDelete}>
|
|
43
|
+
Delete
|
|
44
|
+
</Button>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Link
|
|
48
|
+
Import: `import { Link } from '@overdoser/react-toolkit'`
|
|
49
|
+
Element: `<a>` (forwards ref, accepts all native anchor props).
|
|
50
|
+
|
|
51
|
+
Props:
|
|
52
|
+
- `variant?: 'default' | 'muted' | 'danger'` — default `'default'`
|
|
53
|
+
- `external?: boolean` — default `false`. When `true`, sets `target="_blank" rel="noopener noreferrer"` and adds a visually-hidden " (opens in a new tab)" suffix.
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
```tsx
|
|
57
|
+
<Link href="https://example.com" external>Docs</Link>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Typography
|
|
61
|
+
Import: `import { Typography } from '@overdoser/react-toolkit'`
|
|
62
|
+
Element: dynamic — renders the tag named by `variant`.
|
|
63
|
+
|
|
64
|
+
Props:
|
|
65
|
+
- `variant?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span' | 'label'` — default `'p'`
|
|
66
|
+
- `weight?: 'normal' | 'medium' | 'semibold' | 'bold'`
|
|
67
|
+
- `color?: 'default' | 'muted' | 'primary' | 'danger' | 'success'` — note: native HTML `color` attr is intentionally omitted in favor of these presets.
|
|
68
|
+
- `align?: 'left' | 'center' | 'right'`
|
|
69
|
+
- `truncate?: boolean` — single-line ellipsis truncation.
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
```tsx
|
|
73
|
+
<Typography variant="h2" weight="bold" color="primary">Heading</Typography>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### List / ListItem
|
|
77
|
+
Import: `import { List, ListItem } from '@overdoser/react-toolkit'`
|
|
78
|
+
Element: `<ul>` or `<ol>` depending on `variant`; `ListItem` renders `<li>`.
|
|
79
|
+
|
|
80
|
+
`ListProps`:
|
|
81
|
+
- `variant?: 'unordered' | 'ordered' | 'none'` — default `'unordered'`. `'none'` removes bullets.
|
|
82
|
+
- `spacing?: 'sm' | 'md' | 'lg'` — default `'md'`
|
|
83
|
+
|
|
84
|
+
`ListItemProps`: native `<li>` props only.
|
|
85
|
+
|
|
86
|
+
Example:
|
|
87
|
+
```tsx
|
|
88
|
+
<List variant="ordered" spacing="lg">
|
|
89
|
+
<ListItem>First</ListItem>
|
|
90
|
+
<ListItem>Second</ListItem>
|
|
91
|
+
</List>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Table
|
|
95
|
+
Import: `import { Table, useTableSort, type ColumnDef, type SortConfig } from '@overdoser/react-toolkit'`
|
|
96
|
+
Generic: `Table<T extends Record<string, unknown>>`.
|
|
97
|
+
|
|
98
|
+
Props:
|
|
99
|
+
- `data: T[]`
|
|
100
|
+
- `columns: ColumnDef<T>[]` — see `ColumnDef` below.
|
|
101
|
+
- `sortConfig?: SortConfig[]` — controlled sort. Provide together with `onSort` for server-side sorting.
|
|
102
|
+
- `onSort?: (config: SortConfig[]) => void` — when provided, table is in **controlled** mode: it does NOT sort `data` internally and does NOT slice for pagination (server is assumed to handle both).
|
|
103
|
+
- `multiSort?: boolean` — default `true`. Ctrl/Cmd+click a sortable header to add/cycle a secondary sort.
|
|
104
|
+
- `striped?: boolean` — default `false`
|
|
105
|
+
- `hoverable?: boolean` — default `false`
|
|
106
|
+
- `compact?: boolean` — default `false`
|
|
107
|
+
- `rowKey?: keyof T & string` — column key to use as React key; falls back to row index.
|
|
108
|
+
- `emptyMessage?: ReactNode` — default `'No data'`
|
|
109
|
+
- `pagination?: PaginationConfig` — see below. When omitted, all rows render.
|
|
110
|
+
- `classes?: Partial<TableClasses>` where `TableClasses = { wrapper, root, headerCell, row, cell, emptyCell, paginator, pageButton }`
|
|
111
|
+
|
|
112
|
+
`ColumnDef<T>`:
|
|
113
|
+
- `key: keyof T & string` — required.
|
|
114
|
+
- `header: ReactNode` — required.
|
|
115
|
+
- `sortable?: boolean`
|
|
116
|
+
- `render?: (value: T[keyof T], row: T, index: number) => ReactNode`
|
|
117
|
+
- `width?: string | number`
|
|
118
|
+
- `align?: 'left' | 'center' | 'right'`
|
|
119
|
+
|
|
120
|
+
`SortConfig`:
|
|
121
|
+
- `key: string`
|
|
122
|
+
- `direction: 'asc' | 'desc'`
|
|
123
|
+
|
|
124
|
+
`PaginationConfig`:
|
|
125
|
+
- `page: number` — 1-based.
|
|
126
|
+
- `pageSize: number`
|
|
127
|
+
- `totalRows?: number` — required for server-side mode; for client-side, defaults to `data.length`.
|
|
128
|
+
- `pageSizeOptions?: number[]` — default `[10, 25, 50, 100]`. Pass `[n]` to hide the page-size select.
|
|
129
|
+
- `onPageChange: (page: number, pageSize: number) => void`
|
|
130
|
+
|
|
131
|
+
Sort cycle on a sortable header click: `none → asc → desc → none`.
|
|
132
|
+
|
|
133
|
+
Client-side example:
|
|
134
|
+
```tsx
|
|
135
|
+
const columns: ColumnDef<User>[] = [
|
|
136
|
+
{ key: 'name', header: 'Name', sortable: true },
|
|
137
|
+
{ key: 'email', header: 'Email' },
|
|
138
|
+
];
|
|
139
|
+
<Table data={users} columns={columns} striped hoverable rowKey="id" />
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
With pagination (client-side):
|
|
143
|
+
```tsx
|
|
144
|
+
const [page, setPage] = useState(1);
|
|
145
|
+
const [pageSize, setPageSize] = useState(10);
|
|
146
|
+
<Table
|
|
147
|
+
data={users}
|
|
148
|
+
columns={columns}
|
|
149
|
+
pagination={{ page, pageSize, onPageChange: (p, s) => { setPage(p); setPageSize(s); } }}
|
|
150
|
+
/>
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Server-side (controlled) example:
|
|
154
|
+
```tsx
|
|
155
|
+
<Table
|
|
156
|
+
data={pageRows}
|
|
157
|
+
columns={columns}
|
|
158
|
+
sortConfig={sort}
|
|
159
|
+
onSort={setSort}
|
|
160
|
+
pagination={{ page, pageSize, totalRows, onPageChange }}
|
|
161
|
+
/>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### `useTableSort` (hook)
|
|
165
|
+
Import: `import { useTableSort } from '@overdoser/react-toolkit'`
|
|
166
|
+
Signature: `useTableSort<T>(data: T[], initialSort?: SortConfig[])`
|
|
167
|
+
Returns:
|
|
168
|
+
- `sortedData: T[]`
|
|
169
|
+
- `sortConfig: SortConfig[]`
|
|
170
|
+
- `requestSort(key): void` — single-column; cycles asc → desc → cleared.
|
|
171
|
+
- `requestMultiSort(key): void` — adds/toggles in multi-sort.
|
|
172
|
+
- `resetSort(): void`
|
|
173
|
+
|
|
174
|
+
Use this only if you want to manage sort state outside `<Table>`; `<Table>` already does this internally when `onSort` is not supplied.
|
|
175
|
+
|
|
176
|
+
### Dropdown / DropdownItem
|
|
177
|
+
Import: `import { Dropdown, DropdownItem } from '@overdoser/react-toolkit'`
|
|
178
|
+
|
|
179
|
+
Two distinct usage modes — pick one:
|
|
180
|
+
|
|
181
|
+
**Mode A — menu of arbitrary items:** pass `trigger` + `<DropdownItem>` children. The trigger button toggles a menu; clicking outside or pressing Escape closes it.
|
|
182
|
+
|
|
183
|
+
**Mode B — selectable form input:** pass `options` + `value` + `onChange`. The `trigger` prop is ignored; the selected option's label appears in the trigger.
|
|
184
|
+
|
|
185
|
+
`DropdownProps`:
|
|
186
|
+
- `trigger?: ReactNode` — required for Mode A; ignored in Mode B.
|
|
187
|
+
- `children?: ReactNode` — `<DropdownItem>` elements (Mode A).
|
|
188
|
+
- `options?: { value: string; label: ReactNode; disabled?: boolean }[]` — switches to Mode B.
|
|
189
|
+
- `value?: string` — Mode B selected value.
|
|
190
|
+
- `onChange?: (value: string) => void` — Mode B select callback.
|
|
191
|
+
- `placeholder?: ReactNode` — default `'Select...'` (Mode B).
|
|
192
|
+
- `align?: 'left' | 'right'` — default `'left'`. Menu alignment relative to trigger.
|
|
193
|
+
- `error?: boolean` — default `false`
|
|
194
|
+
- `fullWidth?: boolean` — default `true`
|
|
195
|
+
- `id?: string`
|
|
196
|
+
- `onOpen?: () => void`
|
|
197
|
+
- `onClose?: () => void`
|
|
198
|
+
- `classes?: Partial<DropdownClasses>` where `DropdownClasses = { root, trigger, triggerLabel, chevron, menu, item }`
|
|
199
|
+
|
|
200
|
+
`DropdownItemProps`: native `<button>` props plus `disabled?: boolean`.
|
|
201
|
+
|
|
202
|
+
Mode A example:
|
|
203
|
+
```tsx
|
|
204
|
+
<Dropdown trigger={<>Menu ▾</>}>
|
|
205
|
+
<DropdownItem onClick={onEdit}>Edit</DropdownItem>
|
|
206
|
+
<DropdownItem onClick={onDelete}>Delete</DropdownItem>
|
|
207
|
+
</Dropdown>
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Mode B example:
|
|
211
|
+
```tsx
|
|
212
|
+
<Dropdown
|
|
213
|
+
options={[{ value: 'a', label: 'Option A' }, { value: 'b', label: 'Option B' }]}
|
|
214
|
+
value={value}
|
|
215
|
+
onChange={setValue}
|
|
216
|
+
placeholder="Pick one"
|
|
217
|
+
/>
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Popover
|
|
221
|
+
Import: `import { Popover } from '@overdoser/react-toolkit'`
|
|
222
|
+
|
|
223
|
+
Props:
|
|
224
|
+
- `trigger: ReactNode` — required. Wrapped in an internal `<button>`.
|
|
225
|
+
- `content: ReactNode` — required. Rendered inside an `[role="dialog"]` panel when open.
|
|
226
|
+
- `position?: 'top' | 'bottom' | 'left' | 'right'` — default `'bottom'`
|
|
227
|
+
- `open?: boolean` — controlled.
|
|
228
|
+
- `onOpenChange?: (open: boolean) => void`
|
|
229
|
+
- `classes?: Partial<PopoverClasses>` where `PopoverClasses = { root, trigger, popover }`
|
|
230
|
+
|
|
231
|
+
Closes on outside click and Escape. Auto-focuses the first focusable child of `content` (or the dialog itself).
|
|
232
|
+
|
|
233
|
+
`children` is intentionally typed as `never` — pass via `content`.
|
|
234
|
+
|
|
235
|
+
Example:
|
|
236
|
+
```tsx
|
|
237
|
+
<Popover trigger="Help" content={<p>Some help text</p>} position="right" />
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Modal
|
|
241
|
+
Import: `import { Modal } from '@overdoser/react-toolkit'`
|
|
242
|
+
|
|
243
|
+
Compound component: `Modal.Header`, `Modal.Body`, `Modal.Footer`. Renders into a portal at `document.body`.
|
|
244
|
+
|
|
245
|
+
`ModalProps`:
|
|
246
|
+
- `open: boolean` — required.
|
|
247
|
+
- `onClose: () => void` — required.
|
|
248
|
+
- `closeOnBackdrop?: boolean` — default `true`
|
|
249
|
+
- `closeOnEscape?: boolean` — default `true`
|
|
250
|
+
- `size?: 'sm' | 'md' | 'lg' | 'fullscreen'` — default `'md'`
|
|
251
|
+
- `aria-label?: string` — use this OR `aria-labelledby`. If neither is provided, the `Modal.Header` content is auto-wired as the `aria-labelledby` target.
|
|
252
|
+
- `aria-labelledby?: string`
|
|
253
|
+
- `classes?: Partial<ModalClasses>` where `ModalClasses = { backdrop, modal, header, closeButton, body, footer }`
|
|
254
|
+
|
|
255
|
+
Locks body scroll while open; traps focus; restores focus to previously-focused element on close.
|
|
256
|
+
|
|
257
|
+
`Modal.Header` props:
|
|
258
|
+
- `children: ReactNode` — required.
|
|
259
|
+
- `onClose?: () => void` — when provided, renders an "×" close button.
|
|
260
|
+
|
|
261
|
+
`Modal.Body`, `Modal.Footer`: just `children`, `className`, `style`.
|
|
262
|
+
|
|
263
|
+
Example:
|
|
264
|
+
```tsx
|
|
265
|
+
<Modal open={open} onClose={() => setOpen(false)} size="md">
|
|
266
|
+
<Modal.Header onClose={() => setOpen(false)}>Confirm</Modal.Header>
|
|
267
|
+
<Modal.Body>Are you sure?</Modal.Body>
|
|
268
|
+
<Modal.Footer>
|
|
269
|
+
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
|
|
270
|
+
<Button variant="danger" onClick={confirm}>Delete</Button>
|
|
271
|
+
</Modal.Footer>
|
|
272
|
+
</Modal>
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Form / FormField / FormRow
|
|
276
|
+
Requires `react-hook-form` peer dependency.
|
|
277
|
+
|
|
278
|
+
Imports: `import { Form, FormField, FormRow } from '@overdoser/react-toolkit'`
|
|
279
|
+
|
|
280
|
+
`Form<T>` props:
|
|
281
|
+
- `form: UseFormReturn<T>` — **the prop name is `form`, not `formMethods`**. Pass the result of `useForm()`.
|
|
282
|
+
- `onSubmit: SubmitHandler<T>` — passed through `form.handleSubmit`.
|
|
283
|
+
- `errors?: ReactNode[]` — top-of-form error list (rendered above children with `role="alert"`).
|
|
284
|
+
|
|
285
|
+
`FormField` props:
|
|
286
|
+
- `name: string` — required. Becomes the field id.
|
|
287
|
+
- `label?: ReactNode`
|
|
288
|
+
- `helperText?: ReactNode`
|
|
289
|
+
- `required?: boolean` — adds a "*" indicator next to the label.
|
|
290
|
+
- `rules?: Record<string, unknown>` — react-hook-form rules.
|
|
291
|
+
- `children: ReactElement` — exactly one child input. The child does NOT need `value`/`onChange`/`name` — `FormField` injects them via `cloneElement`.
|
|
292
|
+
- `classes?: Partial<FormFieldClasses>` where `FormFieldClasses = { field, label, error, helperText }`
|
|
293
|
+
|
|
294
|
+
`FormField` injects bridges so these inputs work without manual wiring:
|
|
295
|
+
- `Input`, `Textarea`, `Select`, `Dropdown` (Mode B), `Checkbox`, `Radio`/`RadioGroup`
|
|
296
|
+
- For `Select` (multi/searchable) / `Dropdown` (Mode B): bridges `onChange` → `onValueChange`/`onValuesChange`.
|
|
297
|
+
- For `Checkbox`: bridges `value` → `checked` when value is boolean.
|
|
298
|
+
|
|
299
|
+
`FormRow` props: `children`, `className`, `style`. Wraps fields in a horizontal flex row.
|
|
300
|
+
|
|
301
|
+
Example:
|
|
302
|
+
```tsx
|
|
303
|
+
import { useForm } from 'react-hook-form';
|
|
304
|
+
import { Form, FormField, FormRow, Input, Button } from '@overdoser/react-toolkit';
|
|
305
|
+
|
|
306
|
+
function LoginForm() {
|
|
307
|
+
const form = useForm<{ email: string; password: string }>();
|
|
308
|
+
return (
|
|
309
|
+
<Form form={form} onSubmit={(values) => console.log(values)}>
|
|
310
|
+
<FormField name="email" label="Email" required rules={{ required: 'Email is required' }}>
|
|
311
|
+
<Input type="email" />
|
|
312
|
+
</FormField>
|
|
313
|
+
<FormField name="password" label="Password" required rules={{ required: true, minLength: { value: 8, message: 'Min 8 chars' } }}>
|
|
314
|
+
<Input type="password" />
|
|
315
|
+
</FormField>
|
|
316
|
+
<Button type="submit">Sign in</Button>
|
|
317
|
+
</Form>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Input
|
|
323
|
+
Import: `import { Input } from '@overdoser/react-toolkit'`
|
|
324
|
+
Element: `<input>` (forwards ref). Native `prefix` attribute is omitted in favor of the prop below.
|
|
325
|
+
|
|
326
|
+
Props:
|
|
327
|
+
- `inputSize?: 'sm' | 'md' | 'lg'` — default `'md'`. **Note**: it is `inputSize`, NOT `size` — the native HTML `size` attribute is preserved separately.
|
|
328
|
+
- `error?: boolean` — default `false`
|
|
329
|
+
- `prefix?: ReactNode` — content rendered inside the input wrapper, before the field.
|
|
330
|
+
- `suffix?: ReactNode` — same, after the field.
|
|
331
|
+
- `classes?: Partial<InputClasses>` where `InputClasses = { root, wrapper, prefix, suffix }`
|
|
332
|
+
|
|
333
|
+
Example:
|
|
334
|
+
```tsx
|
|
335
|
+
<Input type="email" inputSize="lg" prefix="@" placeholder="username" />
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### Select
|
|
339
|
+
Import: `import { Select } from '@overdoser/react-toolkit'`
|
|
340
|
+
|
|
341
|
+
Three internal modes, picked automatically:
|
|
342
|
+
1. **Native** (default) — wraps `<select>`.
|
|
343
|
+
2. **Searchable** — set `searchable`. Custom dropdown with text filter.
|
|
344
|
+
3. **Multi** — set `multiple`. Chip-based multi-select with text filter.
|
|
345
|
+
|
|
346
|
+
Props:
|
|
347
|
+
- `inputSize?: 'sm' | 'md' | 'lg'` — default `'md'`. (Same naming convention as `Input`.)
|
|
348
|
+
- `error?: boolean`
|
|
349
|
+
- `options?: { value: string; label: string; content?: ReactNode; disabled?: boolean }[]`
|
|
350
|
+
- `placeholder?: string` — default `'Select...'` for searchable/multi.
|
|
351
|
+
- `searchable?: boolean`
|
|
352
|
+
- `searchPlaceholder?: string` — default `'Search...'`
|
|
353
|
+
- `multiple?: boolean`
|
|
354
|
+
- `value?: string | string[]` — string for single, string[] for multi.
|
|
355
|
+
- `onChange?: (e: ChangeEvent<HTMLSelectElement>) => void` — fires for native + searchable (synthetic for searchable).
|
|
356
|
+
- `onValueChange?: (value: string) => void` — single-mode value-only callback.
|
|
357
|
+
- `onValuesChange?: (values: string[]) => void` — multi-mode callback.
|
|
358
|
+
- `clearSearchOnSelect?: boolean` — default `true`. Multi-mode only.
|
|
359
|
+
- `classes?: Partial<SelectClasses>` where `SelectClasses = { wrapper, root, arrow, search, menu, item, chip, chipRemove }`
|
|
360
|
+
|
|
361
|
+
Searchable example:
|
|
362
|
+
```tsx
|
|
363
|
+
<Select
|
|
364
|
+
searchable
|
|
365
|
+
options={users.map(u => ({ value: u.id, label: u.name }))}
|
|
366
|
+
value={userId}
|
|
367
|
+
onValueChange={setUserId}
|
|
368
|
+
/>
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Multi example:
|
|
372
|
+
```tsx
|
|
373
|
+
<Select
|
|
374
|
+
multiple
|
|
375
|
+
options={tags}
|
|
376
|
+
value={selected}
|
|
377
|
+
onValuesChange={setSelected}
|
|
378
|
+
placeholder="Pick tags"
|
|
379
|
+
/>
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### Checkbox
|
|
383
|
+
Import: `import { Checkbox } from '@overdoser/react-toolkit'`
|
|
384
|
+
Element: `<input type="checkbox">` wrapped in a `<label>` (forwards ref to the input).
|
|
385
|
+
|
|
386
|
+
Props (extends native input props):
|
|
387
|
+
- `label?: ReactNode`
|
|
388
|
+
- `indeterminate?: boolean` — default `false`. Sets the DOM `indeterminate` property; visually distinct.
|
|
389
|
+
- `classes?: Partial<CheckboxClasses>` where `CheckboxClasses = { root, input, label }`
|
|
390
|
+
|
|
391
|
+
Example:
|
|
392
|
+
```tsx
|
|
393
|
+
<Checkbox label="Remember me" checked={remember} onChange={(e) => setRemember(e.target.checked)} />
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
### Radio / RadioGroup
|
|
397
|
+
Import: `import { Radio, RadioGroup } from '@overdoser/react-toolkit'`
|
|
398
|
+
|
|
399
|
+
`RadioGroup` props:
|
|
400
|
+
- `name: string` — required. Becomes the `name` attribute on every `Radio` inside.
|
|
401
|
+
- `value?: string` — controlled selected value.
|
|
402
|
+
- `onChange?: (value: string) => void`
|
|
403
|
+
- `id?: string`
|
|
404
|
+
- `aria-label?` / `aria-labelledby?`
|
|
405
|
+
- `required?: boolean`
|
|
406
|
+
|
|
407
|
+
`Radio` props (extends native input props minus `type`):
|
|
408
|
+
- `value: string` — required.
|
|
409
|
+
- `label?: ReactNode`
|
|
410
|
+
- `classes?: Partial<RadioClasses>` where `RadioClasses = { root, input, label }`
|
|
411
|
+
|
|
412
|
+
Inside a `RadioGroup`, individual `Radio` components do NOT need their own `name`/`checked`/`onChange` — the group provides them via context.
|
|
413
|
+
|
|
414
|
+
Example:
|
|
415
|
+
```tsx
|
|
416
|
+
<RadioGroup name="plan" value={plan} onChange={setPlan} aria-label="Plan">
|
|
417
|
+
<Radio value="free" label="Free" />
|
|
418
|
+
<Radio value="pro" label="Pro" />
|
|
419
|
+
</RadioGroup>
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Textarea
|
|
423
|
+
Import: `import { Textarea } from '@overdoser/react-toolkit'`
|
|
424
|
+
Element: `<textarea>` (forwards ref).
|
|
425
|
+
|
|
426
|
+
Props:
|
|
427
|
+
- `inputSize?: 'sm' | 'md' | 'lg'` — default `'md'`
|
|
428
|
+
- `error?: boolean`
|
|
429
|
+
- `resize?: 'none' | 'vertical' | 'horizontal' | 'both'` — default `'vertical'`. Ignored when `autoExpand` is true.
|
|
430
|
+
- `autoExpand?: boolean` — default `true`. Auto-grows height to fit content; `resize` is suppressed.
|
|
431
|
+
|
|
432
|
+
Example:
|
|
433
|
+
```tsx
|
|
434
|
+
<Textarea placeholder="Bio" rows={3} autoExpand />
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
## Hooks
|
|
438
|
+
|
|
439
|
+
### useClickOutside
|
|
440
|
+
Import: `import { useClickOutside } from '@overdoser/react-toolkit'`
|
|
441
|
+
Signature: `useClickOutside(ref: RefObject<HTMLElement | null>, handler: () => void, enabled?: boolean): void`
|
|
442
|
+
|
|
443
|
+
Calls `handler` on mousedown outside `ref.current`. Pass `enabled = false` to pause the listener (e.g., when a menu is closed) without unmounting.
|
|
444
|
+
|
|
445
|
+
```tsx
|
|
446
|
+
useClickOutside(ref, close, isOpen);
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### useFocusTrap
|
|
450
|
+
Import: `import { useFocusTrap } from '@overdoser/react-toolkit'`
|
|
451
|
+
Signature: `useFocusTrap(ref: RefObject<HTMLElement | null>, active: boolean): void`
|
|
452
|
+
|
|
453
|
+
Traps Tab / Shift+Tab inside `ref.current` while `active`. Focuses the first focusable child on activation; restores prior focus on deactivation.
|
|
454
|
+
|
|
455
|
+
```tsx
|
|
456
|
+
useFocusTrap(dialogRef, isOpen);
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
### useKeyboard
|
|
460
|
+
Import: `import { useKeyboard } from '@overdoser/react-toolkit'`
|
|
461
|
+
Signature: `useKeyboard(handlers: Record<string, (e: KeyboardEvent) => void>, active?: boolean): void`
|
|
462
|
+
|
|
463
|
+
Binds global keydown listeners keyed by `KeyboardEvent.key`. `active` defaults to `true`.
|
|
464
|
+
|
|
465
|
+
```tsx
|
|
466
|
+
useKeyboard({ Escape: () => close(), Enter: () => confirm() }, isOpen);
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
## Common gotchas (LLM-relevant)
|
|
470
|
+
|
|
471
|
+
1. **`Form` prop is `form`, not `formMethods`.** Older docs may show `formMethods`.
|
|
472
|
+
2. **`Button` prop is `loadingStyle`, not `loadingType`.**
|
|
473
|
+
3. **`Input` / `Select` / `Textarea` size prop is `inputSize`** — the native `size` attribute still works as itself.
|
|
474
|
+
4. **`Typography` does not accept a free-form `color` string** — only the listed presets. The native `color` attr is omitted.
|
|
475
|
+
5. **`Dropdown` is dual-mode.** If you pass `options`, the `trigger` prop is ignored and it behaves like a select. If you don't, you must pass `trigger` and `<DropdownItem>` children.
|
|
476
|
+
6. **`Popover.children` is typed `never`** — content goes through the `content` prop.
|
|
477
|
+
7. **`Modal` requires `Modal.Header`/`Body`/`Footer`** — don't put raw markup inside `<Modal>`.
|
|
478
|
+
8. **`FormField` clones its single child** to inject `value`/`onChange`/`id`/`name`/`error`/aria. Never put more than one element inside, never wire those props on the child manually inside a form.
|
|
479
|
+
9. **Server-side `Table`:** when `onSort` is provided, the table won't sort or paginate `data` itself — your server must.
|
|
480
|
+
10. **Theme stylesheet is a separate import**: `'@overdoser/react-toolkit/theme.css'`. Without it, components render unstyled.
|
package/manifest.json
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"name": "@overdoser/react-toolkit",
|
|
4
|
+
"version": "0.0.2",
|
|
5
|
+
"description": "Machine-readable manifest of components, props, allowed values, and hooks. Generated for tooling and AI assistants.",
|
|
6
|
+
"themeCssImport": "@overdoser/react-toolkit/theme.css",
|
|
7
|
+
"peerDependencies": {
|
|
8
|
+
"required": ["react", "react-dom"],
|
|
9
|
+
"optional": ["react-hook-form"]
|
|
10
|
+
},
|
|
11
|
+
"components": {
|
|
12
|
+
"Button": {
|
|
13
|
+
"import": "import { Button } from '@overdoser/react-toolkit'",
|
|
14
|
+
"element": "button",
|
|
15
|
+
"extendsNativeProps": "button",
|
|
16
|
+
"forwardsRef": true,
|
|
17
|
+
"props": {
|
|
18
|
+
"variant": { "type": "enum", "values": ["primary", "secondary", "danger", "ghost"], "default": "primary" },
|
|
19
|
+
"size": { "type": "enum", "values": ["sm", "md", "lg"], "default": "md" },
|
|
20
|
+
"loading": { "type": "boolean", "default": false, "notes": "Disables the button and sets aria-busy." },
|
|
21
|
+
"loadingStyle": { "type": "enum", "values": ["dots", "shimmer", "border"], "default": "dots", "notes": "Only used when loading=true." },
|
|
22
|
+
"fullWidth": { "type": "boolean", "default": false },
|
|
23
|
+
"classes": { "type": "Partial<ButtonClasses>", "shape": ["root", "content", "shimmer", "dots", "dot"] }
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"Link": {
|
|
27
|
+
"import": "import { Link } from '@overdoser/react-toolkit'",
|
|
28
|
+
"element": "a",
|
|
29
|
+
"extendsNativeProps": "a",
|
|
30
|
+
"forwardsRef": true,
|
|
31
|
+
"props": {
|
|
32
|
+
"variant": { "type": "enum", "values": ["default", "muted", "danger"], "default": "default" },
|
|
33
|
+
"external": { "type": "boolean", "default": false, "notes": "Adds target=_blank, rel=noopener noreferrer, and an sr-only 'opens in a new tab' suffix." }
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"Typography": {
|
|
37
|
+
"import": "import { Typography } from '@overdoser/react-toolkit'",
|
|
38
|
+
"element": "dynamic (variant)",
|
|
39
|
+
"extendsNativeProps": "p (without color)",
|
|
40
|
+
"forwardsRef": true,
|
|
41
|
+
"props": {
|
|
42
|
+
"variant": { "type": "enum", "values": ["h1", "h2", "h3", "h4", "h5", "h6", "p", "span", "label"], "default": "p" },
|
|
43
|
+
"weight": { "type": "enum", "values": ["normal", "medium", "semibold", "bold"] },
|
|
44
|
+
"color": { "type": "enum", "values": ["default", "muted", "primary", "danger", "success"], "notes": "Native color attribute is omitted in favor of these presets." },
|
|
45
|
+
"align": { "type": "enum", "values": ["left", "center", "right"] },
|
|
46
|
+
"truncate": { "type": "boolean", "default": false }
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"List": {
|
|
50
|
+
"import": "import { List } from '@overdoser/react-toolkit'",
|
|
51
|
+
"element": "ul or ol",
|
|
52
|
+
"extendsNativeProps": "ul",
|
|
53
|
+
"forwardsRef": true,
|
|
54
|
+
"props": {
|
|
55
|
+
"variant": { "type": "enum", "values": ["unordered", "ordered", "none"], "default": "unordered" },
|
|
56
|
+
"spacing": { "type": "enum", "values": ["sm", "md", "lg"], "default": "md" }
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"ListItem": {
|
|
60
|
+
"import": "import { ListItem } from '@overdoser/react-toolkit'",
|
|
61
|
+
"element": "li",
|
|
62
|
+
"extendsNativeProps": "li",
|
|
63
|
+
"forwardsRef": true,
|
|
64
|
+
"props": {}
|
|
65
|
+
},
|
|
66
|
+
"Table": {
|
|
67
|
+
"import": "import { Table, type ColumnDef, type SortConfig } from '@overdoser/react-toolkit'",
|
|
68
|
+
"element": "table (inside wrapper div)",
|
|
69
|
+
"generic": "T extends Record<string, unknown>",
|
|
70
|
+
"props": {
|
|
71
|
+
"data": { "type": "T[]", "required": true },
|
|
72
|
+
"columns": { "type": "ColumnDef<T>[]", "required": true },
|
|
73
|
+
"sortConfig": { "type": "SortConfig[]", "notes": "Controlled sort. Provide with onSort for server-side mode." },
|
|
74
|
+
"onSort": { "type": "(config: SortConfig[]) => void", "notes": "When set, Table is fully controlled — does not sort or paginate data internally." },
|
|
75
|
+
"multiSort": { "type": "boolean", "default": true, "notes": "Ctrl/Cmd+click a sortable header to add a secondary sort." },
|
|
76
|
+
"striped": { "type": "boolean", "default": false },
|
|
77
|
+
"hoverable": { "type": "boolean", "default": false },
|
|
78
|
+
"compact": { "type": "boolean", "default": false },
|
|
79
|
+
"rowKey": { "type": "keyof T & string" },
|
|
80
|
+
"emptyMessage": { "type": "ReactNode", "default": "No data" },
|
|
81
|
+
"pagination": { "type": "PaginationConfig" },
|
|
82
|
+
"classes": { "type": "Partial<TableClasses>", "shape": ["wrapper", "root", "headerCell", "row", "cell", "emptyCell", "paginator", "pageButton"] }
|
|
83
|
+
},
|
|
84
|
+
"subTypes": {
|
|
85
|
+
"ColumnDef<T>": {
|
|
86
|
+
"key": { "type": "keyof T & string", "required": true },
|
|
87
|
+
"header": { "type": "ReactNode", "required": true },
|
|
88
|
+
"sortable": { "type": "boolean" },
|
|
89
|
+
"render": { "type": "(value, row, index) => ReactNode" },
|
|
90
|
+
"width": { "type": "string | number" },
|
|
91
|
+
"align": { "type": "enum", "values": ["left", "center", "right"] }
|
|
92
|
+
},
|
|
93
|
+
"SortConfig": {
|
|
94
|
+
"key": { "type": "string", "required": true },
|
|
95
|
+
"direction": { "type": "enum", "values": ["asc", "desc"], "required": true }
|
|
96
|
+
},
|
|
97
|
+
"PaginationConfig": {
|
|
98
|
+
"page": { "type": "number", "required": true, "notes": "1-based" },
|
|
99
|
+
"pageSize": { "type": "number", "required": true },
|
|
100
|
+
"totalRows": { "type": "number", "notes": "Required for server-side mode; defaults to data.length client-side." },
|
|
101
|
+
"pageSizeOptions": { "type": "number[]", "default": [10, 25, 50, 100] },
|
|
102
|
+
"onPageChange": { "type": "(page: number, pageSize: number) => void", "required": true }
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
"behavior": {
|
|
106
|
+
"sortCycle": "none → asc → desc → none",
|
|
107
|
+
"controlledMode": "Activated when onSort is provided. Table renders data as-is and does not slice for pagination."
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
"Dropdown": {
|
|
111
|
+
"import": "import { Dropdown, DropdownItem } from '@overdoser/react-toolkit'",
|
|
112
|
+
"element": "div",
|
|
113
|
+
"modes": {
|
|
114
|
+
"menu": "Pass `trigger` plus <DropdownItem> children.",
|
|
115
|
+
"select": "Pass `options` + `value` + `onChange`. Trigger label shows the selected option."
|
|
116
|
+
},
|
|
117
|
+
"props": {
|
|
118
|
+
"trigger": { "type": "ReactNode", "notes": "Required for menu mode; ignored when `options` is provided." },
|
|
119
|
+
"options": { "type": "{ value: string; label: ReactNode; disabled?: boolean }[]" },
|
|
120
|
+
"value": { "type": "string", "notes": "Select-mode controlled value." },
|
|
121
|
+
"onChange": { "type": "(value: string) => void", "notes": "Select-mode callback." },
|
|
122
|
+
"placeholder": { "type": "ReactNode", "default": "Select..." },
|
|
123
|
+
"align": { "type": "enum", "values": ["left", "right"], "default": "left" },
|
|
124
|
+
"error": { "type": "boolean", "default": false },
|
|
125
|
+
"fullWidth": { "type": "boolean", "default": true },
|
|
126
|
+
"id": { "type": "string" },
|
|
127
|
+
"onOpen": { "type": "() => void" },
|
|
128
|
+
"onClose": { "type": "() => void" },
|
|
129
|
+
"classes": { "type": "Partial<DropdownClasses>", "shape": ["root", "trigger", "triggerLabel", "chevron", "menu", "item"] }
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
"DropdownItem": {
|
|
133
|
+
"import": "import { DropdownItem } from '@overdoser/react-toolkit'",
|
|
134
|
+
"element": "button",
|
|
135
|
+
"extendsNativeProps": "button",
|
|
136
|
+
"forwardsRef": true,
|
|
137
|
+
"props": {
|
|
138
|
+
"disabled": { "type": "boolean", "default": false }
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
"Popover": {
|
|
142
|
+
"import": "import { Popover } from '@overdoser/react-toolkit'",
|
|
143
|
+
"element": "div",
|
|
144
|
+
"props": {
|
|
145
|
+
"trigger": { "type": "ReactNode", "required": true },
|
|
146
|
+
"content": { "type": "ReactNode", "required": true },
|
|
147
|
+
"position": { "type": "enum", "values": ["top", "bottom", "left", "right"], "default": "bottom" },
|
|
148
|
+
"open": { "type": "boolean", "notes": "Controlled mode." },
|
|
149
|
+
"onOpenChange": { "type": "(open: boolean) => void" },
|
|
150
|
+
"classes": { "type": "Partial<PopoverClasses>", "shape": ["root", "trigger", "popover"] }
|
|
151
|
+
},
|
|
152
|
+
"behavior": {
|
|
153
|
+
"closesOn": ["outside click", "Escape"],
|
|
154
|
+
"childrenTyped": "never — pass via `content` prop"
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
"Modal": {
|
|
158
|
+
"import": "import { Modal } from '@overdoser/react-toolkit'",
|
|
159
|
+
"element": "div (portal at document.body)",
|
|
160
|
+
"compoundComponents": ["Modal.Header", "Modal.Body", "Modal.Footer"],
|
|
161
|
+
"props": {
|
|
162
|
+
"open": { "type": "boolean", "required": true },
|
|
163
|
+
"onClose": { "type": "() => void", "required": true },
|
|
164
|
+
"closeOnBackdrop": { "type": "boolean", "default": true },
|
|
165
|
+
"closeOnEscape": { "type": "boolean", "default": true },
|
|
166
|
+
"size": { "type": "enum", "values": ["sm", "md", "lg", "fullscreen"], "default": "md" },
|
|
167
|
+
"aria-label": { "type": "string" },
|
|
168
|
+
"aria-labelledby": { "type": "string" },
|
|
169
|
+
"classes": { "type": "Partial<ModalClasses>", "shape": ["backdrop", "modal", "header", "closeButton", "body", "footer"] }
|
|
170
|
+
},
|
|
171
|
+
"subComponents": {
|
|
172
|
+
"Modal.Header": {
|
|
173
|
+
"props": {
|
|
174
|
+
"children": { "type": "ReactNode", "required": true },
|
|
175
|
+
"onClose": { "type": "() => void", "notes": "When set, renders an × close button." }
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
"Modal.Body": { "props": { "children": { "type": "ReactNode", "required": true } } },
|
|
179
|
+
"Modal.Footer": { "props": { "children": { "type": "ReactNode", "required": true } } }
|
|
180
|
+
},
|
|
181
|
+
"behavior": ["body scroll lock", "focus trap", "restores prior focus on close"]
|
|
182
|
+
},
|
|
183
|
+
"Form": {
|
|
184
|
+
"import": "import { Form } from '@overdoser/react-toolkit'",
|
|
185
|
+
"requires": "react-hook-form",
|
|
186
|
+
"generic": "T extends FieldValues",
|
|
187
|
+
"props": {
|
|
188
|
+
"form": { "type": "UseFormReturn<T>", "required": true, "notes": "The result of useForm(). NOTE: prop is `form`, not `formMethods`." },
|
|
189
|
+
"onSubmit": { "type": "SubmitHandler<T>", "required": true },
|
|
190
|
+
"errors": { "type": "ReactNode[]", "notes": "Top-of-form errors rendered above children with role=alert." }
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
"FormField": {
|
|
194
|
+
"import": "import { FormField } from '@overdoser/react-toolkit'",
|
|
195
|
+
"requires": "react-hook-form (must be inside <Form>)",
|
|
196
|
+
"props": {
|
|
197
|
+
"name": { "type": "string", "required": true },
|
|
198
|
+
"label": { "type": "ReactNode" },
|
|
199
|
+
"helperText": { "type": "ReactNode" },
|
|
200
|
+
"required": { "type": "boolean", "notes": "Adds a * indicator next to the label." },
|
|
201
|
+
"rules": { "type": "Record<string, unknown>", "notes": "Passed to react-hook-form's useController." },
|
|
202
|
+
"children": { "type": "ReactElement", "required": true, "notes": "Exactly one input element. FormField clones it to inject value/onChange/id/name/error/aria-*." },
|
|
203
|
+
"classes": { "type": "Partial<FormFieldClasses>", "shape": ["field", "label", "error", "helperText"] }
|
|
204
|
+
},
|
|
205
|
+
"behavior": {
|
|
206
|
+
"supportedChildren": ["Input", "Textarea", "Select", "Dropdown (select-mode)", "Checkbox", "Radio", "RadioGroup"],
|
|
207
|
+
"bridges": {
|
|
208
|
+
"onValueChange": "Bridged to react-hook-form onChange (Select single, Dropdown).",
|
|
209
|
+
"onValuesChange": "Bridged to react-hook-form onChange (Select multi).",
|
|
210
|
+
"checked": "Auto-set from value when value is boolean (Checkbox)."
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
"FormRow": {
|
|
215
|
+
"import": "import { FormRow } from '@overdoser/react-toolkit'",
|
|
216
|
+
"props": { "children": { "type": "ReactNode", "required": true } }
|
|
217
|
+
},
|
|
218
|
+
"Input": {
|
|
219
|
+
"import": "import { Input } from '@overdoser/react-toolkit'",
|
|
220
|
+
"element": "input",
|
|
221
|
+
"extendsNativeProps": "input (without prefix)",
|
|
222
|
+
"forwardsRef": true,
|
|
223
|
+
"props": {
|
|
224
|
+
"inputSize": { "type": "enum", "values": ["sm", "md", "lg"], "default": "md", "notes": "Named `inputSize` so the native `size` attribute is preserved." },
|
|
225
|
+
"error": { "type": "boolean", "default": false },
|
|
226
|
+
"prefix": { "type": "ReactNode" },
|
|
227
|
+
"suffix": { "type": "ReactNode" },
|
|
228
|
+
"classes": { "type": "Partial<InputClasses>", "shape": ["root", "wrapper", "prefix", "suffix"] }
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
"Select": {
|
|
232
|
+
"import": "import { Select } from '@overdoser/react-toolkit'",
|
|
233
|
+
"element": "select (or custom for searchable/multi)",
|
|
234
|
+
"extendsNativeProps": "select (without value/onChange)",
|
|
235
|
+
"forwardsRef": true,
|
|
236
|
+
"modes": {
|
|
237
|
+
"native": "Default — wraps a native <select>.",
|
|
238
|
+
"searchable": "When `searchable=true`. Custom dropdown with text filter.",
|
|
239
|
+
"multi": "When `multiple=true`. Chip-based multi with text filter."
|
|
240
|
+
},
|
|
241
|
+
"props": {
|
|
242
|
+
"inputSize": { "type": "enum", "values": ["sm", "md", "lg"], "default": "md" },
|
|
243
|
+
"error": { "type": "boolean", "default": false },
|
|
244
|
+
"options": { "type": "{ value: string; label: string; content?: ReactNode; disabled?: boolean }[]" },
|
|
245
|
+
"placeholder": { "type": "string", "default": "Select..." },
|
|
246
|
+
"searchable": { "type": "boolean", "default": false },
|
|
247
|
+
"searchPlaceholder": { "type": "string", "default": "Search..." },
|
|
248
|
+
"multiple": { "type": "boolean", "default": false },
|
|
249
|
+
"value": { "type": "string | string[]", "notes": "string for single-mode, string[] for multi." },
|
|
250
|
+
"onChange": { "type": "(e: ChangeEvent<HTMLSelectElement>) => void", "notes": "Native + searchable (synthetic for searchable)." },
|
|
251
|
+
"onValueChange": { "type": "(value: string) => void", "notes": "Single-mode value-only callback." },
|
|
252
|
+
"onValuesChange": { "type": "(values: string[]) => void", "notes": "Multi-mode callback." },
|
|
253
|
+
"clearSearchOnSelect": { "type": "boolean", "default": true, "notes": "Multi-mode only." },
|
|
254
|
+
"classes": { "type": "Partial<SelectClasses>", "shape": ["wrapper", "root", "arrow", "search", "menu", "item", "chip", "chipRemove"] }
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
"Checkbox": {
|
|
258
|
+
"import": "import { Checkbox } from '@overdoser/react-toolkit'",
|
|
259
|
+
"element": "input[type=checkbox] inside label",
|
|
260
|
+
"extendsNativeProps": "input",
|
|
261
|
+
"forwardsRef": true,
|
|
262
|
+
"props": {
|
|
263
|
+
"label": { "type": "ReactNode" },
|
|
264
|
+
"indeterminate": { "type": "boolean", "default": false },
|
|
265
|
+
"classes": { "type": "Partial<CheckboxClasses>", "shape": ["root", "input", "label"] }
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
"Radio": {
|
|
269
|
+
"import": "import { Radio } from '@overdoser/react-toolkit'",
|
|
270
|
+
"element": "input[type=radio] inside label",
|
|
271
|
+
"extendsNativeProps": "input (without type)",
|
|
272
|
+
"forwardsRef": true,
|
|
273
|
+
"props": {
|
|
274
|
+
"value": { "type": "string", "required": true },
|
|
275
|
+
"label": { "type": "ReactNode" },
|
|
276
|
+
"classes": { "type": "Partial<RadioClasses>", "shape": ["root", "input", "label"] }
|
|
277
|
+
},
|
|
278
|
+
"behavior": "Inside a <RadioGroup>, name/checked/onChange come from context — do not pass them on Radio."
|
|
279
|
+
},
|
|
280
|
+
"RadioGroup": {
|
|
281
|
+
"import": "import { RadioGroup } from '@overdoser/react-toolkit'",
|
|
282
|
+
"element": "div[role=radiogroup]",
|
|
283
|
+
"props": {
|
|
284
|
+
"name": { "type": "string", "required": true },
|
|
285
|
+
"value": { "type": "string", "notes": "Controlled selected value." },
|
|
286
|
+
"onChange": { "type": "(value: string) => void" },
|
|
287
|
+
"id": { "type": "string" },
|
|
288
|
+
"aria-label": { "type": "string" },
|
|
289
|
+
"aria-labelledby": { "type": "string" },
|
|
290
|
+
"required": { "type": "boolean" }
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
"Textarea": {
|
|
294
|
+
"import": "import { Textarea } from '@overdoser/react-toolkit'",
|
|
295
|
+
"element": "textarea",
|
|
296
|
+
"extendsNativeProps": "textarea",
|
|
297
|
+
"forwardsRef": true,
|
|
298
|
+
"props": {
|
|
299
|
+
"inputSize": { "type": "enum", "values": ["sm", "md", "lg"], "default": "md" },
|
|
300
|
+
"error": { "type": "boolean", "default": false },
|
|
301
|
+
"resize": { "type": "enum", "values": ["none", "vertical", "horizontal", "both"], "default": "vertical", "notes": "Ignored when autoExpand=true." },
|
|
302
|
+
"autoExpand": { "type": "boolean", "default": true, "notes": "When true, auto-grows height to fit content." }
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
"hooks": {
|
|
307
|
+
"useClickOutside": {
|
|
308
|
+
"import": "import { useClickOutside } from '@overdoser/react-toolkit'",
|
|
309
|
+
"signature": "useClickOutside(ref: RefObject<HTMLElement | null>, handler: () => void, enabled?: boolean): void",
|
|
310
|
+
"params": {
|
|
311
|
+
"ref": "Element to monitor.",
|
|
312
|
+
"handler": "Called on mousedown outside the ref element.",
|
|
313
|
+
"enabled": "Pause the listener without unmounting (default true)."
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
"useFocusTrap": {
|
|
317
|
+
"import": "import { useFocusTrap } from '@overdoser/react-toolkit'",
|
|
318
|
+
"signature": "useFocusTrap(ref: RefObject<HTMLElement | null>, active: boolean): void",
|
|
319
|
+
"behavior": "Cycles Tab / Shift+Tab inside ref. Restores prior focus on deactivation."
|
|
320
|
+
},
|
|
321
|
+
"useKeyboard": {
|
|
322
|
+
"import": "import { useKeyboard } from '@overdoser/react-toolkit'",
|
|
323
|
+
"signature": "useKeyboard(handlers: Record<string, (e: KeyboardEvent) => void>, active?: boolean): void",
|
|
324
|
+
"params": {
|
|
325
|
+
"handlers": "Map keyed by KeyboardEvent.key (e.g., 'Escape', 'Enter').",
|
|
326
|
+
"active": "Default true. Pass false to disable without unmounting."
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
"useTableSort": {
|
|
330
|
+
"import": "import { useTableSort } from '@overdoser/react-toolkit'",
|
|
331
|
+
"signature": "useTableSort<T>(data: T[], initialSort?: SortConfig[])",
|
|
332
|
+
"returns": {
|
|
333
|
+
"sortedData": "T[]",
|
|
334
|
+
"sortConfig": "SortConfig[]",
|
|
335
|
+
"requestSort": "(key) => void — single-column; cycles asc → desc → cleared.",
|
|
336
|
+
"requestMultiSort": "(key) => void — adds/toggles a column in multi-sort.",
|
|
337
|
+
"resetSort": "() => void"
|
|
338
|
+
},
|
|
339
|
+
"notes": "Only needed if you want to manage sort state outside <Table>."
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
"gotchas": [
|
|
343
|
+
"Form prop is `form`, not `formMethods`.",
|
|
344
|
+
"Button prop is `loadingStyle`, not `loadingType`.",
|
|
345
|
+
"Input/Select/Textarea size prop is `inputSize`, not `size` — native size attribute is preserved.",
|
|
346
|
+
"Typography color is restricted to enum presets; native `color` HTML attribute is omitted.",
|
|
347
|
+
"Dropdown is dual-mode: `options` triggers select-mode and `trigger` is ignored.",
|
|
348
|
+
"Popover.children is typed `never` — content goes through the `content` prop.",
|
|
349
|
+
"Modal contents must be wrapped in Modal.Header/Body/Footer.",
|
|
350
|
+
"FormField clones a single child element to inject form props — don't wire value/onChange manually.",
|
|
351
|
+
"Server-side Table mode (onSort provided) means Table renders data verbatim — no internal sort or pagination slicing.",
|
|
352
|
+
"Theme stylesheet must be imported separately: '@overdoser/react-toolkit/theme.css'."
|
|
353
|
+
],
|
|
354
|
+
"recipes": [
|
|
355
|
+
"recipes/login-form.tsx",
|
|
356
|
+
"recipes/paginated-table.tsx",
|
|
357
|
+
"recipes/server-side-table.tsx",
|
|
358
|
+
"recipes/confirm-modal.tsx",
|
|
359
|
+
"recipes/searchable-multi-select.tsx",
|
|
360
|
+
"recipes/dropdown-menu.tsx"
|
|
361
|
+
]
|
|
362
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Modal, Button, Typography } from '@overdoser/react-toolkit';
|
|
3
|
+
|
|
4
|
+
interface ConfirmModalProps {
|
|
5
|
+
open: boolean;
|
|
6
|
+
title: string;
|
|
7
|
+
message: React.ReactNode;
|
|
8
|
+
confirmLabel?: string;
|
|
9
|
+
cancelLabel?: string;
|
|
10
|
+
destructive?: boolean;
|
|
11
|
+
onConfirm: () => void | Promise<void>;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ConfirmModal({
|
|
16
|
+
open,
|
|
17
|
+
title,
|
|
18
|
+
message,
|
|
19
|
+
confirmLabel = 'Confirm',
|
|
20
|
+
cancelLabel = 'Cancel',
|
|
21
|
+
destructive = false,
|
|
22
|
+
onConfirm,
|
|
23
|
+
onClose,
|
|
24
|
+
}: ConfirmModalProps) {
|
|
25
|
+
const [busy, setBusy] = useState(false);
|
|
26
|
+
|
|
27
|
+
const handleConfirm = async () => {
|
|
28
|
+
setBusy(true);
|
|
29
|
+
try {
|
|
30
|
+
await onConfirm();
|
|
31
|
+
onClose();
|
|
32
|
+
} finally {
|
|
33
|
+
setBusy(false);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<Modal open={open} onClose={onClose} size="sm" closeOnBackdrop={!busy} closeOnEscape={!busy}>
|
|
39
|
+
<Modal.Header onClose={busy ? undefined : onClose}>
|
|
40
|
+
<Typography variant="h3" weight="semibold">
|
|
41
|
+
{title}
|
|
42
|
+
</Typography>
|
|
43
|
+
</Modal.Header>
|
|
44
|
+
<Modal.Body>
|
|
45
|
+
<Typography variant="p" color="muted">
|
|
46
|
+
{message}
|
|
47
|
+
</Typography>
|
|
48
|
+
</Modal.Body>
|
|
49
|
+
<Modal.Footer>
|
|
50
|
+
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
|
51
|
+
{cancelLabel}
|
|
52
|
+
</Button>
|
|
53
|
+
<Button
|
|
54
|
+
variant={destructive ? 'danger' : 'primary'}
|
|
55
|
+
onClick={handleConfirm}
|
|
56
|
+
loading={busy}
|
|
57
|
+
>
|
|
58
|
+
{confirmLabel}
|
|
59
|
+
</Button>
|
|
60
|
+
</Modal.Footer>
|
|
61
|
+
</Modal>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Dropdown, DropdownItem } from '@overdoser/react-toolkit';
|
|
2
|
+
|
|
3
|
+
interface RowActionsProps {
|
|
4
|
+
rowId: string;
|
|
5
|
+
onEdit: (id: string) => void;
|
|
6
|
+
onArchive: (id: string) => void;
|
|
7
|
+
onDelete: (id: string) => void;
|
|
8
|
+
canDelete?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function RowActions({
|
|
12
|
+
rowId,
|
|
13
|
+
onEdit,
|
|
14
|
+
onArchive,
|
|
15
|
+
onDelete,
|
|
16
|
+
canDelete = true,
|
|
17
|
+
}: RowActionsProps) {
|
|
18
|
+
return (
|
|
19
|
+
<Dropdown
|
|
20
|
+
align="right"
|
|
21
|
+
fullWidth={false}
|
|
22
|
+
trigger={
|
|
23
|
+
<span aria-label="Row actions">
|
|
24
|
+
{/* Replace with an icon component from your codebase */}
|
|
25
|
+
⋯
|
|
26
|
+
</span>
|
|
27
|
+
}
|
|
28
|
+
>
|
|
29
|
+
<DropdownItem onClick={() => onEdit(rowId)}>Edit</DropdownItem>
|
|
30
|
+
<DropdownItem onClick={() => onArchive(rowId)}>Archive</DropdownItem>
|
|
31
|
+
<DropdownItem disabled={!canDelete} onClick={() => onDelete(rowId)}>
|
|
32
|
+
Delete
|
|
33
|
+
</DropdownItem>
|
|
34
|
+
</Dropdown>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useForm } from 'react-hook-form';
|
|
2
|
+
import { Form, FormField, Input, Button } from '@overdoser/react-toolkit';
|
|
3
|
+
|
|
4
|
+
interface LoginFields {
|
|
5
|
+
email: string;
|
|
6
|
+
password: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function LoginForm({ onLogin }: { onLogin: (values: LoginFields) => Promise<void> }) {
|
|
10
|
+
const form = useForm<LoginFields>({
|
|
11
|
+
defaultValues: { email: '', password: '' },
|
|
12
|
+
mode: 'onTouched',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<Form
|
|
17
|
+
form={form}
|
|
18
|
+
onSubmit={async (values) => {
|
|
19
|
+
await onLogin(values);
|
|
20
|
+
}}
|
|
21
|
+
>
|
|
22
|
+
<FormField
|
|
23
|
+
name="email"
|
|
24
|
+
label="Email"
|
|
25
|
+
required
|
|
26
|
+
rules={{
|
|
27
|
+
required: 'Email is required',
|
|
28
|
+
pattern: { value: /^\S+@\S+\.\S+$/, message: 'Enter a valid email' },
|
|
29
|
+
}}
|
|
30
|
+
>
|
|
31
|
+
<Input type="email" autoComplete="email" />
|
|
32
|
+
</FormField>
|
|
33
|
+
|
|
34
|
+
<FormField
|
|
35
|
+
name="password"
|
|
36
|
+
label="Password"
|
|
37
|
+
required
|
|
38
|
+
rules={{
|
|
39
|
+
required: 'Password is required',
|
|
40
|
+
minLength: { value: 8, message: 'Min 8 characters' },
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
<Input type="password" autoComplete="current-password" />
|
|
44
|
+
</FormField>
|
|
45
|
+
|
|
46
|
+
<Button type="submit" variant="primary" loading={form.formState.isSubmitting} fullWidth>
|
|
47
|
+
Sign in
|
|
48
|
+
</Button>
|
|
49
|
+
</Form>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Table, type ColumnDef } from '@overdoser/react-toolkit';
|
|
3
|
+
|
|
4
|
+
interface User extends Record<string, unknown> {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
email: string;
|
|
8
|
+
role: 'admin' | 'member';
|
|
9
|
+
lastSeen: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const columns: ColumnDef<User>[] = [
|
|
13
|
+
{ key: 'name', header: 'Name', sortable: true },
|
|
14
|
+
{ key: 'email', header: 'Email', sortable: true },
|
|
15
|
+
{ key: 'role', header: 'Role', sortable: true, width: 120 },
|
|
16
|
+
{
|
|
17
|
+
key: 'lastSeen',
|
|
18
|
+
header: 'Last seen',
|
|
19
|
+
sortable: true,
|
|
20
|
+
align: 'right',
|
|
21
|
+
width: 160,
|
|
22
|
+
render: (value) => new Date(value as string).toLocaleString(),
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function PaginatedTable({ users }: { users: User[] }) {
|
|
27
|
+
const [page, setPage] = useState(1);
|
|
28
|
+
const [pageSize, setPageSize] = useState(10);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<Table
|
|
32
|
+
data={users}
|
|
33
|
+
columns={columns}
|
|
34
|
+
rowKey="id"
|
|
35
|
+
striped
|
|
36
|
+
hoverable
|
|
37
|
+
pagination={{
|
|
38
|
+
page,
|
|
39
|
+
pageSize,
|
|
40
|
+
onPageChange: (nextPage, nextPageSize) => {
|
|
41
|
+
setPage(nextPage);
|
|
42
|
+
setPageSize(nextPageSize);
|
|
43
|
+
},
|
|
44
|
+
}}
|
|
45
|
+
emptyMessage="No users yet."
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useForm } from 'react-hook-form';
|
|
2
|
+
import { Form, FormField, Select, Button } from '@overdoser/react-toolkit';
|
|
3
|
+
|
|
4
|
+
interface TagFormValues {
|
|
5
|
+
tags: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const TAG_OPTIONS = [
|
|
9
|
+
{ value: 'react', label: 'React' },
|
|
10
|
+
{ value: 'typescript', label: 'TypeScript' },
|
|
11
|
+
{ value: 'rust', label: 'Rust' },
|
|
12
|
+
{ value: 'go', label: 'Go' },
|
|
13
|
+
{ value: 'python', label: 'Python' },
|
|
14
|
+
{ value: 'css', label: 'CSS' },
|
|
15
|
+
{ value: 'graphql', label: 'GraphQL' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export function TagPicker({ onSave }: { onSave: (tags: string[]) => Promise<void> }) {
|
|
19
|
+
const form = useForm<TagFormValues>({ defaultValues: { tags: [] } });
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Form
|
|
23
|
+
form={form}
|
|
24
|
+
onSubmit={async ({ tags }) => {
|
|
25
|
+
await onSave(tags);
|
|
26
|
+
}}
|
|
27
|
+
>
|
|
28
|
+
<FormField
|
|
29
|
+
name="tags"
|
|
30
|
+
label="Tags"
|
|
31
|
+
helperText="Pick all that apply"
|
|
32
|
+
rules={{ validate: (v: string[]) => v.length > 0 || 'Pick at least one tag' }}
|
|
33
|
+
>
|
|
34
|
+
<Select multiple options={TAG_OPTIONS} placeholder="Choose tags…" />
|
|
35
|
+
</FormField>
|
|
36
|
+
|
|
37
|
+
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
38
|
+
Save tags
|
|
39
|
+
</Button>
|
|
40
|
+
</Form>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Table, type ColumnDef, type SortConfig } from '@overdoser/react-toolkit';
|
|
3
|
+
|
|
4
|
+
interface Order extends Record<string, unknown> {
|
|
5
|
+
id: string;
|
|
6
|
+
customer: string;
|
|
7
|
+
total: number;
|
|
8
|
+
status: 'pending' | 'paid' | 'refunded';
|
|
9
|
+
createdAt: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface FetchArgs {
|
|
13
|
+
page: number;
|
|
14
|
+
pageSize: number;
|
|
15
|
+
sort: SortConfig[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface FetchResult {
|
|
19
|
+
rows: Order[];
|
|
20
|
+
totalRows: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const columns: ColumnDef<Order>[] = [
|
|
24
|
+
{ key: 'id', header: 'Order #', sortable: true, width: 120 },
|
|
25
|
+
{ key: 'customer', header: 'Customer', sortable: true },
|
|
26
|
+
{
|
|
27
|
+
key: 'total',
|
|
28
|
+
header: 'Total',
|
|
29
|
+
sortable: true,
|
|
30
|
+
align: 'right',
|
|
31
|
+
width: 120,
|
|
32
|
+
render: (value) =>
|
|
33
|
+
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value as number),
|
|
34
|
+
},
|
|
35
|
+
{ key: 'status', header: 'Status', sortable: true, width: 120 },
|
|
36
|
+
{
|
|
37
|
+
key: 'createdAt',
|
|
38
|
+
header: 'Created',
|
|
39
|
+
sortable: true,
|
|
40
|
+
align: 'right',
|
|
41
|
+
width: 180,
|
|
42
|
+
render: (value) => new Date(value as string).toLocaleString(),
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export function ServerSideOrdersTable({
|
|
47
|
+
fetchPage,
|
|
48
|
+
}: {
|
|
49
|
+
fetchPage: (args: FetchArgs) => Promise<FetchResult>;
|
|
50
|
+
}) {
|
|
51
|
+
const [page, setPage] = useState(1);
|
|
52
|
+
const [pageSize, setPageSize] = useState(25);
|
|
53
|
+
const [sort, setSort] = useState<SortConfig[]>([{ key: 'createdAt', direction: 'desc' }]);
|
|
54
|
+
const [rows, setRows] = useState<Order[]>([]);
|
|
55
|
+
const [totalRows, setTotalRows] = useState(0);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
let cancelled = false;
|
|
59
|
+
fetchPage({ page, pageSize, sort }).then((result) => {
|
|
60
|
+
if (!cancelled) {
|
|
61
|
+
setRows(result.rows);
|
|
62
|
+
setTotalRows(result.totalRows);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
return () => {
|
|
66
|
+
cancelled = true;
|
|
67
|
+
};
|
|
68
|
+
}, [page, pageSize, sort, fetchPage]);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<Table
|
|
72
|
+
data={rows}
|
|
73
|
+
columns={columns}
|
|
74
|
+
rowKey="id"
|
|
75
|
+
sortConfig={sort}
|
|
76
|
+
onSort={(next) => {
|
|
77
|
+
setSort(next);
|
|
78
|
+
setPage(1);
|
|
79
|
+
}}
|
|
80
|
+
hoverable
|
|
81
|
+
pagination={{
|
|
82
|
+
page,
|
|
83
|
+
pageSize,
|
|
84
|
+
totalRows,
|
|
85
|
+
onPageChange: (nextPage, nextPageSize) => {
|
|
86
|
+
setPage(nextPage);
|
|
87
|
+
setPageSize(nextPageSize);
|
|
88
|
+
},
|
|
89
|
+
}}
|
|
90
|
+
/>
|
|
91
|
+
);
|
|
92
|
+
}
|