@karni/ui 0.3.1 → 0.4.0
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/README.md +712 -72
- package/dist/index.cjs +5 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +8660 -4725
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +18 -1
- package/dist/script-BVJ9BbPm.cjs +2 -0
- package/dist/script-BVJ9BbPm.cjs.map +1 -0
- package/dist/script-D1fn4Dm4.js +6 -0
- package/dist/script-D1fn4Dm4.js.map +1 -0
- package/dist/src/auth/AuthGuard.d.ts +69 -4
- package/dist/src/auth/LoginForm.d.ts +41 -7
- package/dist/src/auth/engine.d.ts +15 -13
- package/dist/src/auth/provider.d.ts +10 -10
- package/dist/src/auth/storage.d.ts +1 -1
- package/dist/src/auth/types.d.ts +3 -3
- package/dist/src/components/brand/karni-mark.d.ts +4 -4
- package/dist/src/components/chart/data-chart.d.ts +66 -11
- package/dist/src/components/empty-state/empty-state.d.ts +22 -2
- package/dist/src/components/file-upload/FileUpload.d.ts +67 -3
- package/dist/src/components/layout/layout.d.ts +104 -9
- package/dist/src/components/layout/route-focus.d.ts +43 -0
- package/dist/src/components/modal/modal.d.ts +49 -8
- package/dist/src/components/page/page.d.ts +36 -5
- package/dist/src/components/primitives/button.d.ts +1 -0
- package/dist/src/components/primitives/checkbox.d.ts +4 -1
- package/dist/src/components/primitives/dropdown-menu.d.ts +1 -1
- package/dist/src/components/primitives/icons.d.ts +9 -1
- package/dist/src/components/primitives/input.d.ts +4 -1
- package/dist/src/components/primitives/label.d.ts +4 -1
- package/dist/src/components/primitives/select.d.ts +37 -6
- package/dist/src/components/primitives/skeleton.d.ts +4 -1
- package/dist/src/components/primitives/switch.d.ts +4 -1
- package/dist/src/components/primitives/tabs.d.ts +2 -2
- package/dist/src/components/primitives/textarea.d.ts +4 -1
- package/dist/src/components/resource/ResourceDetail.d.ts +3 -3
- package/dist/src/components/resource/ResourceForm.d.ts +4 -3
- package/dist/src/components/resource/ResourceSelect.d.ts +3 -1
- package/dist/src/components/resource/ResourceTable.d.ts +9 -3
- package/dist/src/components/resource/audit-fields.d.ts +1 -1
- package/dist/src/components/resource/detail/ResourceDetail.d.ts +109 -11
- package/dist/src/components/resource/empty-value.d.ts +17 -0
- package/dist/src/components/resource/form/ResourceForm.d.ts +65 -6
- package/dist/src/components/resource/form/field-inputs.d.ts +15 -1
- package/dist/src/components/resource/form/form-states.d.ts +87 -0
- package/dist/src/components/resource/form/prepare.d.ts +3 -3
- package/dist/src/components/resource/form/server-errors.d.ts +14 -0
- package/dist/src/components/resource/history/ChangeHistory.d.ts +46 -4
- package/dist/src/components/resource/import/ResourceImport.d.ts +73 -7
- package/dist/src/components/resource/nouns.d.ts +12 -0
- package/dist/src/components/resource/query-state.d.ts +20 -0
- package/dist/src/components/resource/resource-names.d.ts +23 -0
- package/dist/src/components/resource/select/ResourceSelect.d.ts +39 -8
- package/dist/src/components/resource/select/select-states.d.ts +68 -0
- package/dist/src/components/resource/select/use-option-pages.d.ts +37 -0
- package/dist/src/components/resource/select/venue-backend.d.ts +144 -0
- package/dist/src/components/resource/table/BulkActionsBar.d.ts +62 -5
- package/dist/src/components/resource/table/CardList.d.ts +31 -0
- package/dist/src/components/resource/table/ConfirmDialog.d.ts +28 -0
- package/dist/src/components/resource/table/ExportButtons.d.ts +2 -2
- package/dist/src/components/resource/table/FilterBar.d.ts +3 -3
- package/dist/src/components/resource/table/PaginationControls.d.ts +15 -3
- package/dist/src/components/resource/table/ResourceTable.d.ts +79 -14
- package/dist/src/components/resource/table/RowCard.d.ts +31 -0
- package/dist/src/components/resource/table/TableView.d.ts +29 -0
- package/dist/src/components/resource/table/ViewOptions.d.ts +19 -0
- package/dist/src/components/resource/table/access.d.ts +12 -0
- package/dist/src/components/resource/table/derive-columns.d.ts +2 -2
- package/dist/src/components/resource/table/grid-navigation.d.ts +23 -0
- package/dist/src/components/resource/table/layout.d.ts +37 -0
- package/dist/src/components/resource/table/preferences.d.ts +12 -0
- package/dist/src/components/resource/table/slots.d.ts +41 -0
- package/dist/src/components/resource/table/states.d.ts +91 -0
- package/dist/src/components/resource/table/story-fixtures.d.ts +25 -0
- package/dist/src/components/resource/table/structural-columns.d.ts +25 -0
- package/dist/src/components/resource/table/table-icons.d.ts +16 -0
- package/dist/src/components/resource/table/table-utils.d.ts +27 -3
- package/dist/src/components/resource/table/url-state.d.ts +67 -0
- package/dist/src/components/resource/table/use-container-width.d.ts +10 -0
- package/dist/src/components/resource/testapp-fixtures.d.ts +78 -0
- package/dist/src/components/stat/stat.d.ts +36 -6
- package/dist/src/components/toast/toast-store.d.ts +1 -1
- package/dist/src/components/toast/toaster.d.ts +39 -6
- package/dist/src/components/toast/use-toast.d.ts +1 -1
- package/dist/src/hooks/keys.d.ts +4 -4
- package/dist/src/hooks/multipart.d.ts +9 -9
- package/dist/src/hooks/params.d.ts +1 -1
- package/dist/src/hooks/types.d.ts +3 -3
- package/dist/src/hooks/useChangeHistory.d.ts +3 -2
- package/dist/src/hooks/useResourceImport.d.ts +2 -2
- package/dist/src/hooks/useResourceMutations.d.ts +2 -2
- package/dist/src/hooks/useUnsavedChangesGuard.d.ts +31 -0
- package/dist/src/index.d.ts +49 -31
- package/dist/src/lib/roles.d.ts +4 -4
- package/dist/src/lib/variants.d.ts +1 -1
- package/dist/src/primitives/registry.d.ts +81 -0
- package/dist/src/schema/client.d.ts +10 -10
- package/dist/src/schema/parse.d.ts +2 -2
- package/dist/src/schema/provider.d.ts +10 -3
- package/dist/src/schema/types.d.ts +18 -9
- package/dist/src/schema/useSchema.d.ts +5 -1
- package/dist/src/states/ErrorState.d.ts +43 -0
- package/dist/src/states/PendingButton.d.ts +16 -0
- package/dist/src/states/describe-error.d.ts +25 -0
- package/dist/src/states/hooks.d.ts +10 -0
- package/dist/src/theme/script.d.ts +12 -0
- package/dist/src/theme/theme.d.ts +45 -0
- package/dist/styles.css +174 -13
- package/dist/tailwind-preset.cjs +2 -2
- package/dist/tailwind-preset.cjs.map +1 -1
- package/dist/tailwind-preset.d.ts +83 -1
- package/dist/tailwind-preset.js +181 -62
- package/dist/tailwind-preset.js.map +1 -1
- package/dist/tailwind-v4.css +31 -0
- package/dist/vite-plugin.cjs +2 -0
- package/dist/vite-plugin.cjs.map +1 -0
- package/dist/vite-plugin.d.ts +19 -0
- package/dist/vite-plugin.js +16 -0
- package/dist/vite-plugin.js.map +1 -0
- package/eslint-plugin/index.js +4 -0
- package/eslint-plugin/rules/max-page-lines.js +1 -1
- package/eslint-plugin/rules/no-deep-imports.js +1 -1
- package/eslint-plugin/rules/no-direct-http.js +1 -1
- package/eslint-plugin/rules/no-raw-elements.js +1 -1
- package/eslint-plugin/rules/no-style-literals.js +222 -0
- package/eslint-plugin/rules.json +10 -4
- package/package.json +22 -19
package/README.md
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
# @karni/ui
|
|
2
2
|
|
|
3
3
|
Schema-driven React components for [Karni](https://github.com/cbsshekhawat18-lab/karni#readme) backends. Point it at a
|
|
4
|
-
Karni API's OpenAPI schema and get working tables, forms, detail views and
|
|
5
|
-
plus the themable primitives they are built from
|
|
4
|
+
Karni API's OpenAPI schema and you get working tables, forms, detail views and
|
|
5
|
+
auth, plus the themable primitives they are built from, without regenerating boilerplate.
|
|
6
6
|
|
|
7
|
-
- **Resource components
|
|
7
|
+
- **Resource components**: `ResourceTable`, `ResourceForm`, `ResourceDetail`,
|
|
8
8
|
`ResourceSelect` render themselves from the backend schema (`/api/schema/`).
|
|
9
|
-
- **Hooks
|
|
9
|
+
- **Hooks**: `useResourceList`, `useResourceItem`, `useResourceMutations`,
|
|
10
10
|
`useSchema` on TanStack Query, with a shared `karniKeys` key factory.
|
|
11
|
-
- **Auth
|
|
11
|
+
- **Auth**: `AuthProvider`, `useAuth`, `AuthGuard`, `usePermission` for Karni's
|
|
12
12
|
JWT or session auth modes.
|
|
13
|
-
- **Primitives
|
|
14
|
-
Tabs, DropdownMenu, Skeleton
|
|
15
|
-
`className`-mergeable.
|
|
13
|
+
- **Primitives**: Button, Input, Textarea, Select, Checkbox, Switch, Label,
|
|
14
|
+
Tabs, DropdownMenu, Skeleton. They are Radix-based, token-themed, `forwardRef`
|
|
15
|
+
and `className`-mergeable.
|
|
16
16
|
|
|
17
17
|
The package ships **dual ESM/CJS** output plus `.d.ts` files: `import` resolves
|
|
18
18
|
`dist/index.js`, `require()` resolves `dist/index.cjs` (same for
|
|
@@ -28,8 +28,8 @@ npm install @karni/ui @tanstack/react-query @tanstack/react-table react-hook-for
|
|
|
28
28
|
|
|
29
29
|
Peer dependencies: `react` / `react-dom` (18 or 19), `@tanstack/react-query` v5,
|
|
30
30
|
`@tanstack/react-table` v8, `react-hook-form` v7, `@hookform/resolvers` and `zod` v3.
|
|
31
|
-
The project also needs `tailwindcss` (v3.4+ or v4) as a build tool
|
|
32
|
-
devDependency,
|
|
31
|
+
The project also needs `tailwindcss` (v3.4+ or v4) as a build tool. It is a
|
|
32
|
+
devDependency, which is how `karni new` sets it up. It compiles the classes the
|
|
33
33
|
components use and is never imported at runtime, so it is not a peer.
|
|
34
34
|
|
|
35
35
|
## Quick start
|
|
@@ -53,14 +53,27 @@ export function App() {
|
|
|
53
53
|
|
|
54
54
|
`KarniProvider` creates a TanStack `QueryClient` when you do not pass one
|
|
55
55
|
(`queryClient` prop is the escape hatch). `AuthProvider` reads the auth mode from
|
|
56
|
-
the schema root (`x-karni-auth-mode`)
|
|
56
|
+
the schema root (`x-karni-auth-mode`). Override it with `mode="jwt" | "session"`.
|
|
57
57
|
|
|
58
58
|
## Styling
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
`@karni/ui/styles.css
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
Every visual value in every component resolves from a `--karni-*` CSS custom
|
|
61
|
+
property defined in `@karni/ui/styles.css`:
|
|
62
|
+
|
|
63
|
+
| Family | What it holds |
|
|
64
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
65
|
+
| `--karni-color-*` | colours: background, foreground, card, popover, primary, secondary, muted, accent, destructive, border, input, ring, overlay, chart-1 … chart-5, and the derived `-foreground`, `-hover`, `-subtle` and `-border` shades |
|
|
66
|
+
| `--karni-space-*` | the spacing scale: `--karni-space-unit` (0.25rem), or any step `--karni-space-<n>` (`--karni-space-4` is 1rem) |
|
|
67
|
+
| `--karni-radius-*` | `sm`, `md`, `lg`, `xl`, `full` (derived from `--karni-radius`) |
|
|
68
|
+
| `--karni-font-*` | `sans`, `mono`, `size-xs` … `size-2xl` with `leading-*`, `weight-*`, `tracking-*` |
|
|
69
|
+
| `--karni-shadow-*` | `sm`, `md`, `lg` (and bare `--karni-shadow`) |
|
|
70
|
+
| other | `--karni-z-*`, `--karni-opacity-*`, `--karni-duration` and `--karni-ease` (0ms under `prefers-reduced-motion`), `--karni-border-width*`, `--karni-ring-*`, `--karni-table-max-height` (ResourceTable's scroll box) |
|
|
71
|
+
|
|
72
|
+
The Tailwind preset maps its scales onto these properties, so `bg-primary`,
|
|
73
|
+
`p-4` or `rounded-md`, in a component or in your own code, reads them. Import
|
|
74
|
+
the stylesheet once, then wire Tailwind (3.4 or later) so the classes exist in
|
|
75
|
+
your build. The rule `karni/no-style-literals` (in the ESLint plugin) and a
|
|
76
|
+
test that compiles every component class keep literals out of the library.
|
|
64
77
|
|
|
65
78
|
### Tailwind v3 (preset)
|
|
66
79
|
|
|
@@ -84,14 +97,36 @@ import "@karni/ui/styles.css";
|
|
|
84
97
|
import "./index.css"; // your @tailwind base/components/utilities file
|
|
85
98
|
```
|
|
86
99
|
|
|
87
|
-
### Tailwind v4 (
|
|
100
|
+
### Tailwind v4 (what `karni new` sets up)
|
|
101
|
+
|
|
102
|
+
Keep the same `tailwind.config.ts` (the preset and the content paths) and load
|
|
103
|
+
it with `@config`. Add the `@tailwindcss/vite` plugin to `vite.config.ts`:
|
|
104
|
+
|
|
105
|
+
```css
|
|
106
|
+
/* src/index.css (main.tsx imports @karni/ui/styles.css first) */
|
|
107
|
+
@import "tailwindcss";
|
|
108
|
+
@import "@karni/ui/tailwind-v4.css";
|
|
109
|
+
@config "../tailwind.config.ts";
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`@karni/ui/tailwind-v4.css` keeps a bare `border` on `--karni-color-border`,
|
|
113
|
+
as Tailwind 3 does (Tailwind 4 defaults borders to `currentColor`), and points
|
|
114
|
+
Tailwind 4's default transition timing at `--karni-duration` and
|
|
115
|
+
`--karni-ease`. A Tailwind 3 project must not import it. With it, every
|
|
116
|
+
Storybook story and the pages `karni new` generates render pixel-identical
|
|
117
|
+
under Tailwind 3 and 4, light and dark.
|
|
88
118
|
|
|
89
|
-
|
|
119
|
+
#### Alternative: no JavaScript config
|
|
120
|
+
|
|
121
|
+
Map the tokens in CSS instead of loading the preset. This covers colours,
|
|
122
|
+
spacing, type, radius, shadow and motion. The named stacking classes
|
|
123
|
+
(`z-popover`, …) and the token-backed opacity steps come only with the preset.
|
|
90
124
|
|
|
91
125
|
```css
|
|
92
126
|
/* app.css */
|
|
93
127
|
@import "tailwindcss";
|
|
94
128
|
@import "@karni/ui/styles.css";
|
|
129
|
+
@import "@karni/ui/tailwind-v4.css";
|
|
95
130
|
|
|
96
131
|
/* Scan the library's shipped output for class names. */
|
|
97
132
|
@source "../node_modules/@karni/ui/dist";
|
|
@@ -99,58 +134,211 @@ Tailwind v4 has no `presets` array; map the tokens in CSS instead:
|
|
|
99
134
|
/* Dark mode is class-based: add `dark` to <html> (or any ancestor). */
|
|
100
135
|
@custom-variant dark (&:where(.dark, .dark *));
|
|
101
136
|
|
|
102
|
-
/* Map Tailwind color/radius theme slots onto the --karni-* tokens. */
|
|
103
137
|
@theme inline {
|
|
104
|
-
--color-background:
|
|
105
|
-
--color-foreground:
|
|
106
|
-
--color-card:
|
|
107
|
-
--color-card-foreground:
|
|
108
|
-
--color-popover:
|
|
109
|
-
--color-popover-foreground:
|
|
110
|
-
--color-primary:
|
|
111
|
-
--color-primary-foreground:
|
|
112
|
-
--color-
|
|
113
|
-
--color-secondary
|
|
114
|
-
--color-
|
|
115
|
-
--color-
|
|
116
|
-
--color-
|
|
117
|
-
--color-
|
|
118
|
-
--color-
|
|
119
|
-
--color-
|
|
120
|
-
--color-
|
|
121
|
-
--color-
|
|
122
|
-
--color-
|
|
123
|
-
--color-
|
|
124
|
-
--color-
|
|
125
|
-
--color-
|
|
126
|
-
--color-
|
|
127
|
-
--color-
|
|
128
|
-
--
|
|
129
|
-
--
|
|
130
|
-
--
|
|
138
|
+
--color-background: var(--karni-color-background);
|
|
139
|
+
--color-foreground: var(--karni-color-foreground);
|
|
140
|
+
--color-card: var(--karni-color-card);
|
|
141
|
+
--color-card-foreground: var(--karni-color-card-foreground);
|
|
142
|
+
--color-popover: var(--karni-color-popover);
|
|
143
|
+
--color-popover-foreground: var(--karni-color-popover-foreground);
|
|
144
|
+
--color-primary: var(--karni-color-primary);
|
|
145
|
+
--color-primary-foreground: var(--karni-color-primary-foreground);
|
|
146
|
+
--color-primary-hover: var(--karni-color-primary-hover);
|
|
147
|
+
--color-secondary: var(--karni-color-secondary);
|
|
148
|
+
--color-secondary-foreground: var(--karni-color-secondary-foreground);
|
|
149
|
+
--color-secondary-hover: var(--karni-color-secondary-hover);
|
|
150
|
+
--color-muted: var(--karni-color-muted);
|
|
151
|
+
--color-muted-foreground: var(--karni-color-muted-foreground);
|
|
152
|
+
--color-muted-subtle: var(--karni-color-muted-subtle);
|
|
153
|
+
--color-accent: var(--karni-color-accent);
|
|
154
|
+
--color-accent-foreground: var(--karni-color-accent-foreground);
|
|
155
|
+
--color-destructive: var(--karni-color-destructive);
|
|
156
|
+
--color-destructive-foreground: var(--karni-color-destructive-foreground);
|
|
157
|
+
--color-destructive-hover: var(--karni-color-destructive-hover);
|
|
158
|
+
--color-destructive-subtle: var(--karni-color-destructive-subtle);
|
|
159
|
+
--color-destructive-border: var(--karni-color-destructive-border);
|
|
160
|
+
--color-border: var(--karni-color-border);
|
|
161
|
+
--color-input: var(--karni-color-input);
|
|
162
|
+
--color-ring: var(--karni-color-ring);
|
|
163
|
+
--color-overlay: var(--karni-color-overlay);
|
|
164
|
+
--color-chart-1: var(--karni-color-chart-1);
|
|
165
|
+
--color-chart-2: var(--karni-color-chart-2);
|
|
166
|
+
--color-chart-3: var(--karni-color-chart-3);
|
|
167
|
+
--color-chart-4: var(--karni-color-chart-4);
|
|
168
|
+
--color-chart-5: var(--karni-color-chart-5);
|
|
169
|
+
--spacing: var(--karni-space-unit);
|
|
170
|
+
--radius-sm: var(--karni-radius-sm);
|
|
171
|
+
--radius-md: var(--karni-radius-md);
|
|
172
|
+
--radius-lg: var(--karni-radius-lg);
|
|
173
|
+
--radius-xl: var(--karni-radius-xl);
|
|
174
|
+
--font-sans: var(--karni-font-sans);
|
|
175
|
+
--font-mono: var(--karni-font-mono);
|
|
176
|
+
--text-xs: var(--karni-font-size-xs);
|
|
177
|
+
--text-xs--line-height: var(--karni-font-leading-xs);
|
|
178
|
+
--text-sm: var(--karni-font-size-sm);
|
|
179
|
+
--text-sm--line-height: var(--karni-font-leading-sm);
|
|
180
|
+
--text-base: var(--karni-font-size-base);
|
|
181
|
+
--text-base--line-height: var(--karni-font-leading-base);
|
|
182
|
+
--text-lg: var(--karni-font-size-lg);
|
|
183
|
+
--text-lg--line-height: var(--karni-font-leading-lg);
|
|
184
|
+
--text-xl: var(--karni-font-size-xl);
|
|
185
|
+
--text-xl--line-height: var(--karni-font-leading-xl);
|
|
186
|
+
--text-2xl: var(--karni-font-size-2xl);
|
|
187
|
+
--text-2xl--line-height: var(--karni-font-leading-2xl);
|
|
188
|
+
--font-weight-normal: var(--karni-font-weight-normal);
|
|
189
|
+
--font-weight-medium: var(--karni-font-weight-medium);
|
|
190
|
+
--font-weight-semibold: var(--karni-font-weight-semibold);
|
|
191
|
+
--font-weight-bold: var(--karni-font-weight-bold);
|
|
192
|
+
--tracking-tight: var(--karni-font-tracking-tight);
|
|
193
|
+
--tracking-wide: var(--karni-font-tracking-wide);
|
|
194
|
+
--shadow-sm: var(--karni-shadow-sm);
|
|
195
|
+
--shadow-md: var(--karni-shadow-md);
|
|
196
|
+
--shadow-lg: var(--karni-shadow-lg);
|
|
131
197
|
}
|
|
132
198
|
```
|
|
133
199
|
|
|
134
200
|
### Theming and dark mode
|
|
135
201
|
|
|
136
|
-
Override any token after importing the stylesheet
|
|
137
|
-
|
|
202
|
+
Override any token after importing the stylesheet. Colours take any CSS colour
|
|
203
|
+
(`hsl()`, `oklch()`, hex, or `var()` of your own design system's variable):
|
|
138
204
|
|
|
139
205
|
```css
|
|
140
206
|
:root {
|
|
141
|
-
--karni-primary:
|
|
207
|
+
--karni-color-primary: oklch(0.55 0.22 285);
|
|
208
|
+
--karni-color-primary-foreground: white;
|
|
142
209
|
--karni-radius: 0.75rem;
|
|
210
|
+
--karni-space-unit: 0.225rem; /* a denser spacing scale everywhere */
|
|
211
|
+
--karni-font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
|
212
|
+
}
|
|
213
|
+
.dark {
|
|
214
|
+
--karni-color-primary: oklch(0.7 0.18 285);
|
|
143
215
|
}
|
|
144
216
|
```
|
|
145
217
|
|
|
146
|
-
|
|
147
|
-
|
|
218
|
+
The derived shades (`-hover`, `-subtle`, `-border`) follow the colour they
|
|
219
|
+
derive from unless you set them too.
|
|
220
|
+
|
|
221
|
+
Dark mode is the `dark` class on `<html>`. `ThemeProvider` manages it: it
|
|
222
|
+
follows the system until the user chooses, keeps the choice per browser, and
|
|
223
|
+
`ThemeToggle` is a button that cycles Light, Dark and System. The first paint
|
|
224
|
+
needs the theme before React starts: in a Vite app, `karniTheme()` puts
|
|
225
|
+
`KARNI_THEME_SCRIPT` first in `index.html`'s `<head>` (a project from
|
|
226
|
+
`karni new` has all three wired already):
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
// vite.config.ts
|
|
230
|
+
import { karniTheme } from "@karni/ui/vite";
|
|
231
|
+
export default defineConfig({ plugins: [react(), karniTheme()] });
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
```tsx
|
|
235
|
+
<ThemeProvider>
|
|
236
|
+
<App /> {/* <ThemeToggle /> in the header */}
|
|
237
|
+
</ThemeProvider>
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Without Vite, put `KARNI_THEME_SCRIPT` in an inline `<script>` in `<head>`
|
|
241
|
+
yourself.
|
|
242
|
+
|
|
243
|
+
The Django side of a Karni project reads the same tokens: the admin and the
|
|
244
|
+
welcome page load a copy of this stylesheet, so the same overrides, added to
|
|
245
|
+
the admin's `UNFOLD["STYLES"]`, retheme the admin too
|
|
246
|
+
([admin theming](https://cbsshekhawat18-lab.github.io/karni/admin/#theming)).
|
|
247
|
+
|
|
248
|
+
The 0.3 names (`--karni-primary: 262 83% 58%`, an HSL triplet) still work:
|
|
249
|
+
each `--karni-color-*` defaults to `hsl()` of its 0.3 name. They are
|
|
250
|
+
deprecated in 0.4 and removed in 1.0 ([migration note](https://cbsshekhawat18-lab.github.io/karni/migrations/0.4/)).
|
|
251
|
+
|
|
252
|
+
### Adopting your design system: `karni ui detect`
|
|
253
|
+
|
|
254
|
+
A project that already has a design system does not retype it. Run
|
|
255
|
+
`karni ui detect` in the frontend (or the Karni project root):
|
|
256
|
+
|
|
257
|
+
```sh
|
|
258
|
+
karni ui detect --dry-run # the report and the file, nothing written
|
|
259
|
+
karni ui detect # write src/karni-theme.css (and wire it in)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
It maps the first theme it finds onto the tokens above and writes one file
|
|
263
|
+
the project owns, `karni-theme.css`, imported after `@karni/ui/styles.css`:
|
|
264
|
+
|
|
265
|
+
1. **shadcn/ui** (`components.json`): every token references the project's
|
|
266
|
+
own variable, `--karni-color-primary: var(--primary)` (or
|
|
267
|
+
`hsl(var(--primary))` for shadcn's Tailwind 3 HSL channels), declared on
|
|
268
|
+
`:root, .dark` so the project's theme and dark mode flow through live. It
|
|
269
|
+
also writes `karni-primitives.tsx`, registering the project's shadcn
|
|
270
|
+
components as Karni's primitives (below).
|
|
271
|
+
2. **A Tailwind theme**: `@theme` blocks (Tailwind 4), or the
|
|
272
|
+
`tailwind.config` Node evaluates in the project (Tailwind 3).
|
|
273
|
+
3. **A Material UI, Chakra UI or Mantine theme**: the literal values of its
|
|
274
|
+
`createTheme` / `extendTheme` / `createSystem` call.
|
|
275
|
+
4. **Nothing**: Karni's defaults, listed commented, for you to edit.
|
|
276
|
+
|
|
277
|
+
The report lists every token mapped (from, to, value) and every value not
|
|
278
|
+
mapped with the reason (a palette is never turned into `primary`
|
|
279
|
+
silently: the line to add is printed). Re-running never overwrites a file
|
|
280
|
+
you edited unless you pass `--force`. `--json` prints the report for agents.
|
|
281
|
+
See [Theming](https://cbsshekhawat18-lab.github.io/karni/theming/) for each
|
|
282
|
+
source in detail.
|
|
283
|
+
|
|
284
|
+
## Unhappy states
|
|
285
|
+
|
|
286
|
+
Every component words failures the same way, through `describeError(error,
|
|
287
|
+
{ action, resource })`: a 403 reads "You can't delete events", a dropped
|
|
288
|
+
connection "You're offline", a 5xx "Something went wrong on our side". A status
|
|
289
|
+
code is never shown, but the server's own explanation is, when it gave one. The
|
|
290
|
+
building blocks are exported for your own screens:
|
|
291
|
+
|
|
292
|
+
- `ErrorState`: what failed and what to do, with "Try again" when retrying can
|
|
293
|
+
help (`onRetry`, `retrying`), announced to screen readers.
|
|
294
|
+
- `PermissionDenied`: which action the role does not allow, in plain words.
|
|
295
|
+
- `PendingButton`: disabled while `pending`, shows progress after 500ms
|
|
296
|
+
(`SLOW_AFTER_MS`) and keeps its width.
|
|
297
|
+
- `useDelayedFlag(active)` (true once work has run for 500ms) and
|
|
298
|
+
`useOnlineStatus()`.
|
|
299
|
+
|
|
300
|
+
A visible field with no value (`null`, `""`, an empty list) renders the same
|
|
301
|
+
everywhere, as `EmptyValue`: a muted en dash that screen readers read as
|
|
302
|
+
"empty" (`label` changes the word). ResourceTable's cells, ResourceDetail's
|
|
303
|
+
rows and ChangeHistory's before/after values all use it. A field the role may
|
|
304
|
+
not read (`Karni.field_roles`) is different: it is absent from the per-role
|
|
305
|
+
schema and from the payload, so it gets no row and no cell at all. It never
|
|
306
|
+
gets a dash that would hint it exists.
|
|
307
|
+
|
|
308
|
+
### Replacing a state: `slots`
|
|
309
|
+
|
|
310
|
+
The resource and auth components take `slots`, an object of components that
|
|
311
|
+
replace one part each. `defaultXSlots` (`defaultResourceDetailSlots`, ...)
|
|
312
|
+
holds Karni's own, so a project can wrap one instead of rewriting it. Every
|
|
313
|
+
`Error` slot receives `ErrorSlotProps` (`error`, `context`, `onRetry`,
|
|
314
|
+
`retrying`), so one project error panel fits all of them.
|
|
315
|
+
|
|
316
|
+
```tsx
|
|
317
|
+
<ResourceDetail
|
|
318
|
+
resource="events"
|
|
319
|
+
id={id}
|
|
320
|
+
slots={{ Error: MyErrorPanel, EmptyValue: ({ field }) => <i>not set</i> }}
|
|
321
|
+
/>
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
| Component | Slots |
|
|
325
|
+
| ---------------- | ---------------------------------------------------------------------------------------- |
|
|
326
|
+
| `ResourceDetail` | `Loading`, `Error` (+ `back`), `NotFound`, `EmptyValue`, `DeleteDialog`, `Deleted` |
|
|
327
|
+
| `ResourceImport` | `Loading`, `Error`, `Progress`, `Summary`, `Problems`, `ConfirmCommit` |
|
|
328
|
+
| `ChangeHistory` | `Loading`, `Error`, `Empty`, `Entry` |
|
|
329
|
+
| `LoginForm` | `Error` (+ `id` for `aria-describedby`, and `described`: the failure worded for sign-in) |
|
|
330
|
+
| `AuthGuard` | `Anonymous` (not signed in: a sign-in prompt), `Denied` (signed in, role lacks access) |
|
|
331
|
+
|
|
332
|
+
Loading placeholders take the real layout from the start (ResourceDetail
|
|
333
|
+
draws one row per field the schema declares, plus the title and action
|
|
334
|
+
buttons) but are drawn only once loading is slow (500ms), so a fast load
|
|
335
|
+
never flashes them and nothing moves when the data arrives.
|
|
148
336
|
|
|
149
337
|
## Resource components
|
|
150
338
|
|
|
151
339
|
Every resource component takes the backend's resource name (the router prefix)
|
|
152
|
-
and renders from the schema.
|
|
153
|
-
|
|
340
|
+
and renders from the schema. These are defaults. Each one exposes override
|
|
341
|
+
props, and you can always drop down to the hooks + primitives.
|
|
154
342
|
|
|
155
343
|
```tsx
|
|
156
344
|
// Table: server-driven pagination, sorting, search and filters
|
|
@@ -173,10 +361,47 @@ override props, and you can always drop down to the hooks + primitives.
|
|
|
173
361
|
<ResourceSelect resource="venues" value={venueId} onChange={setVenueId} />
|
|
174
362
|
```
|
|
175
363
|
|
|
176
|
-
|
|
177
|
-
|
|
364
|
+
`ResourceDetail` also renders its own header and actions:
|
|
365
|
+
|
|
366
|
+
```tsx
|
|
367
|
+
<ResourceDetail
|
|
368
|
+
resource="events"
|
|
369
|
+
id={eventId}
|
|
370
|
+
title={(event) => event.title} // a function waits for the record (a placeholder stands in)
|
|
371
|
+
back={
|
|
372
|
+
<Button variant="outline" onClick={() => navigate("/events")}>
|
|
373
|
+
Back to events
|
|
374
|
+
</Button>
|
|
375
|
+
}
|
|
376
|
+
onEdit={(event) => navigate(`/events/${event.id}/edit`)}
|
|
377
|
+
canEdit={hasRole("organizer")} // or (event) => boolean
|
|
378
|
+
enableDelete // asks for confirmation; a refusal is shown in the dialog
|
|
379
|
+
canDelete={(event) => event.status === "draft"}
|
|
380
|
+
onDeleted={() => navigate("/events")}
|
|
381
|
+
/>
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
A 404 reads "Couldn't find this event" with the `back` action and no retry.
|
|
385
|
+
Karni answers 404 for a record outside the user's scope too, so the words never
|
|
386
|
+
say the record exists. A 403 reads "You can't view this event". Other failures
|
|
387
|
+
offer "Try again" (it refetches the schema too when that is what failed).
|
|
388
|
+
Offline, it says so and reloads when the connection returns. `resourceLabel`
|
|
389
|
+
("event") names the resource in these messages when the router prefix does not
|
|
390
|
+
singularize well.
|
|
391
|
+
|
|
392
|
+
`ResourceImport` shows no upload form when the role may not import ("You
|
|
393
|
+
can't import events"). A failed upload keeps the chosen file, moves focus to
|
|
394
|
+
the error and offers "Try again" when it can help. A preview's rows with errors
|
|
395
|
+
are a captioned table. Commit asks for confirmation with the counts
|
|
396
|
+
(created, updated, unchanged, failed). Background progress is a
|
|
397
|
+
`progressbar` announced by the quarter, not every poll. `ChangeHistory` words
|
|
398
|
+
its failures the same way ("You can't view the history of this event").
|
|
399
|
+
|
|
400
|
+
Audit fields never lead the screen. These are the `AUDIT_FIELDS` (`id`,
|
|
401
|
+
`created_at`, `updated_at`, `created_by`, `updated_by`), which every Karni
|
|
402
|
+
resource carries.
|
|
178
403
|
`ResourceTable` hides their columns by default (domain fields come first, in
|
|
179
|
-
schema order
|
|
404
|
+
schema order, and rows are still keyed by `id`). Show one by naming it in `columns`
|
|
180
405
|
(`{ field: "created_at" }`), or show all of them after the domain columns with
|
|
181
406
|
`showAuditFields`. `ResourceDetail` lists them last, in a muted "Record" group,
|
|
182
407
|
and still honours `fields` overrides for them. `ResourceForm` never renders them
|
|
@@ -186,9 +411,239 @@ A create form starts from the schema's defaults (a model's `default=`), and a
|
|
|
186
411
|
cleared optional field is left out of the request rather than sent as `null`
|
|
187
412
|
(`null` is sent only for nullable fields).
|
|
188
413
|
|
|
189
|
-
Validation schemas are generated at runtime with `zodFor(schema, "create" | "update")
|
|
414
|
+
Validation schemas are generated at runtime with `zodFor(schema, "create" | "update")`.
|
|
190
415
|
`displayLabel(obj)` picks a human label for relation objects.
|
|
191
416
|
|
|
417
|
+
### ResourceTable in every state
|
|
418
|
+
|
|
419
|
+
Each state is its own screen, and each is a slot you can replace:
|
|
420
|
+
|
|
421
|
+
| State | What the user sees | Slot |
|
|
422
|
+
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
|
|
423
|
+
| first load | the real layout in outline: header with the real column count, a full page of rows, the pagination row. Nothing moves when data arrives | `Loading` |
|
|
424
|
+
| no records yet | "No events yet", and the create action when `onCreate` is set and the role may create | `NoRecords` |
|
|
425
|
+
| nothing matches | "No events match", what is narrowing the list (search, each filter) and "Clear filters" (pinned `params` stay) | `NoResults` |
|
|
426
|
+
| the list failed | `ErrorState`: plain words, "Try again" when it can help | `Error` |
|
|
427
|
+
| a refresh failed | the rows stay, and one line above them says so, with "Try again" | `RefetchError` |
|
|
428
|
+
| 403 on the list | "You can't view events", no retry (4xx are not retried, except 408 and 429) | `Forbidden` |
|
|
429
|
+
| offline | a line over the rows it has, or the offline panel. The list reloads by itself when the connection is back | `Offline` |
|
|
430
|
+
| slow (500ms, `SLOW_AFTER_MS`) | a progress bar, dimmed rows, "Loading events…" announced. The clicked control shows progress and ignores repeat clicks | none |
|
|
431
|
+
|
|
432
|
+
The other slots are `Pagination`, `BulkActions` and `RowCard`. Every slot
|
|
433
|
+
receives the props type exported next to it (`ResourceTableLoadingProps`,
|
|
434
|
+
`ResourceTableNoResultsProps`, …, while `Error` and `RefetchError` take
|
|
435
|
+
`ErrorSlotProps`), and the defaults are exported as
|
|
436
|
+
`defaultResourceTableSlots`, so a replacement can wrap one:
|
|
437
|
+
|
|
438
|
+
```tsx
|
|
439
|
+
<ResourceTable
|
|
440
|
+
resource="events"
|
|
441
|
+
onCreate={() => navigate("/events/new")} // "New event" in the toolbar and the empty screen
|
|
442
|
+
slots={{
|
|
443
|
+
NoRecords: (props) => <OurEmptyState {...props} />,
|
|
444
|
+
Error: (props) => <defaultResourceTableSlots.Error {...props} />,
|
|
445
|
+
}}
|
|
446
|
+
/>
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
The `empty` prop still replaces both empty screens, but the `NoRecords` and
|
|
450
|
+
`NoResults` slots are preferred, because they tell "nothing yet" from
|
|
451
|
+
"nothing matches". `resourceLabel` ("events") and `itemLabel` ("event") word
|
|
452
|
+
every message. By default they come from the resource name.
|
|
453
|
+
|
|
454
|
+
**Permissions.** Columns follow the role's schema (a field the role cannot
|
|
455
|
+
read is not in its schema, so its column and filter are absent). Row actions
|
|
456
|
+
follow the schema's `x-karni-access` rules for the signed-in user (from
|
|
457
|
+
`AuthProvider`): Edit shows on the rows the update rule allows (for example
|
|
458
|
+
`Owned("organizer") | HasRole("staff")` shows it on the user's own rows),
|
|
459
|
+
Delete on the rows the delete rule allows, and the create action only when
|
|
460
|
+
the create rule allows it. Rules only the server can answer (grants, custom
|
|
461
|
+
`Where` functions) keep the action, and a refusal is reported in plain words.
|
|
462
|
+
`canCreate`, `canEdit` and `canDelete` (a boolean, or a function of the row)
|
|
463
|
+
replace the schema's answer.
|
|
464
|
+
|
|
465
|
+
**Deletes and bulk actions.** `enableDelete` asks in a dialog ("Delete
|
|
466
|
+
“Spring Gala”?"). A failure is reported in the dialog, with "Try again" when
|
|
467
|
+
it can help. A destructive bulk action (or one with `confirm: true`) asks
|
|
468
|
+
first with the count ("Delete 3 events?"). An action may return a promise:
|
|
469
|
+
the button shows progress after 500ms, the rows that succeeded are
|
|
470
|
+
deselected, and a resolved `{ failed: [{ id, reason }] }` marks each failed
|
|
471
|
+
row with its reason. `onClick(ids)` returning nothing keeps working as
|
|
472
|
+
before. "Select all" selects the rows on this page, and the bar says so when
|
|
473
|
+
more rows match.
|
|
474
|
+
|
|
475
|
+
**At real size.** Only the current page is fetched: a page size choice
|
|
476
|
+
(`pageSizeOptions`, default 10/25/50/100), first, previous, next, last and a
|
|
477
|
+
page number to jump to. Wide tables scroll inside their own box (the page
|
|
478
|
+
never scrolls sideways): the header sticks, the first column stays put and
|
|
479
|
+
shows an edge once the table scrolls. The box is at most
|
|
480
|
+
`--karni-table-max-height` (70vh) tall. Set it to `none` to let the page
|
|
481
|
+
scroll. The "View" menu toggles compact rows and hides columns, and both are
|
|
482
|
+
remembered per user (in `localStorage`, under the signed-in user's id).
|
|
483
|
+
`density` / `onDensityChange` control density from outside. Below
|
|
484
|
+
`cardBreakpoint` (640px of the table's own width, not the viewport's, and `0`
|
|
485
|
+
turns cards off) each row is a card: its title, then label and value pairs,
|
|
486
|
+
with its checkbox and actions, and a sort choice above the cards. The sort
|
|
487
|
+
choice keeps keyboard focus while the sorted page loads (the list says it is
|
|
488
|
+
busy instead), and when the control that opened a dialog is gone afterwards
|
|
489
|
+
(a deleted row, the bulk toolbar once its action ran) focus lands on the list,
|
|
490
|
+
as it lands on the scroll region in the table layout.
|
|
491
|
+
|
|
492
|
+
**View state in the URL.** Page, page size, ordering, search and filters are
|
|
493
|
+
query parameters under the table's namespace (`?events.page=2&events.ordering=-starts_at`),
|
|
494
|
+
so a view can be shared and Back returns to the previous one. A value equal
|
|
495
|
+
to the default is left out, and pinned `params` never appear. `urlNamespace`
|
|
496
|
+
keeps two tables on one page apart, `syncUrl={false}` keeps the view in
|
|
497
|
+
component state, and `stateStore` plugs in a router. A store reads the
|
|
498
|
+
current query values, writes changes (`null` removes a key, `push` adds a
|
|
499
|
+
history entry) and reports outside changes:
|
|
500
|
+
|
|
501
|
+
```tsx
|
|
502
|
+
// TanStack Router: the table's keys live in the route's search params.
|
|
503
|
+
function useRouterTableStore(): ResourceTableStateStore {
|
|
504
|
+
const search = useSearch({ strict: false }) as Record<string, unknown>;
|
|
505
|
+
const navigate = useNavigate();
|
|
506
|
+
return React.useMemo(
|
|
507
|
+
() => ({
|
|
508
|
+
read: () =>
|
|
509
|
+
Object.fromEntries(Object.entries(search).map(([key, value]) => [key, String(value)])),
|
|
510
|
+
write: (changes, { push }) =>
|
|
511
|
+
void navigate({
|
|
512
|
+
to: ".",
|
|
513
|
+
replace: !push,
|
|
514
|
+
search: (previous: Record<string, unknown>) => {
|
|
515
|
+
const next = { ...previous };
|
|
516
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
517
|
+
if (value === null) delete next[key];
|
|
518
|
+
else next[key] = value;
|
|
519
|
+
}
|
|
520
|
+
return next;
|
|
521
|
+
},
|
|
522
|
+
}),
|
|
523
|
+
// The store object changes with `search`, so the table re-reads it.
|
|
524
|
+
subscribe: () => () => undefined,
|
|
525
|
+
}),
|
|
526
|
+
[search, navigate],
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
**Keyboard and screen readers.** The table is a WAI-ARIA grid, named by a
|
|
532
|
+
caption (`caption`, default the capitalised `resourceLabel`). The whole table
|
|
533
|
+
is one Tab stop, so a 50-row page costs one Tab press instead of one per
|
|
534
|
+
control. Tab enters at the first row (or at the cell you left) and the next
|
|
535
|
+
Tab leaves the table. Inside, the arrow keys move between rows and cells,
|
|
536
|
+
Home and End go to the ends of a row, and Ctrl+Home and Ctrl+End go to the
|
|
537
|
+
first and last cell of the grid. Each control in a cell is a stop of its own:
|
|
538
|
+
the checkbox, the row's button, links, Edit, Delete and your `rowActions`. A
|
|
539
|
+
control keeps its own keys: a button activates, a menu trigger opens its menu
|
|
540
|
+
with ArrowDown, and Ctrl or Cmd+Enter opens a link in a new tab. On a cell,
|
|
541
|
+
Enter opens the row (`onRowClick`) and Space selects it (`bulkActions`).
|
|
542
|
+
Enter on a row's checkbox opens the row too. Sortable headers are buttons
|
|
543
|
+
with `aria-sort` in the grid's first row. Each checkbox is named after its row
|
|
544
|
+
("Select Spring Gala"), and a selected row has `aria-selected`. The mouse
|
|
545
|
+
works as before. `keyboardNavigation="native"` turns the grid off: a plain
|
|
546
|
+
table where every control is its own Tab stop and the scroll box is
|
|
547
|
+
focusable. Below `cardBreakpoint` the cards are a list, and their controls
|
|
548
|
+
are Tab stops as before. Page changes, sorting, the selection count and slow
|
|
549
|
+
loading are announced in a polite live region. Errors are announced as
|
|
550
|
+
alerts.
|
|
551
|
+
|
|
552
|
+
### ResourceForm: states, errors and the keyboard
|
|
553
|
+
|
|
554
|
+
- **Loading** draws the form's own layout: once the schema is known (edit
|
|
555
|
+
mode while the row loads) it has the real rows, labels and submit row, so
|
|
556
|
+
nothing moves when the values arrive.
|
|
557
|
+
- **A failed load** (schema or row) is an `ErrorState` with "Try again". A 403
|
|
558
|
+
on the row reads "You can't edit this event" (`PermissionDenied`). Fields a
|
|
559
|
+
role may not see or write are absent from its schema, so the form has no
|
|
560
|
+
row for them: no blank row, no dash.
|
|
561
|
+
- **Validation runs when a field loses focus**, not on every keystroke. A
|
|
562
|
+
field that shows an error re-checks on every change, so the error clears as
|
|
563
|
+
soon as it is fixed. `validationMode` and `reValidateMode` (react-hook-form's
|
|
564
|
+
`mode` / `reValidateMode`) change it.
|
|
565
|
+
- **Server errors** land on their field for every DRF shape (field lists,
|
|
566
|
+
strings, nested serializers, list serializers, `non_field_errors`, `detail`,
|
|
567
|
+
keys the form does not show). When more than one thing fails, a summary at
|
|
568
|
+
the top lists them, each a link to its field, and takes focus. With one
|
|
569
|
+
failing field, focus moves to it. Errors are announced, and every control
|
|
570
|
+
carries `aria-invalid`, `aria-describedby` (help text and error) and, where
|
|
571
|
+
its role allows, `aria-required`.
|
|
572
|
+
- **Any other failure** (offline, 401, 403 "You can't save this event", 409
|
|
573
|
+
"This changed while you were working" with "Load the latest version", 429,
|
|
574
|
+
5xx) is a banner in plain words, with "Try again" when it can help. **The
|
|
575
|
+
typed values are never lost**, whatever the failure.
|
|
576
|
+
- **Submitting** disables the fields and the button (`PendingButton`: progress
|
|
577
|
+
after 500ms, same width). Enter submits from a one-line input, and Ctrl/Cmd+Enter
|
|
578
|
+
from anywhere (a textarea included). Enter inside an open relation picker
|
|
579
|
+
chooses an option. The first field takes focus on create, and `autoFocus`
|
|
580
|
+
changes that. Edit mode does not focus a field unless asked.
|
|
581
|
+
|
|
582
|
+
Replace any state through `slots` (`Loading`, `Error`, `Forbidden`,
|
|
583
|
+
`ErrorSummary`, `SubmitError`). The defaults are exported as
|
|
584
|
+
`defaultResourceFormSlots` to wrap. `submitLabel` renames the button and
|
|
585
|
+
`resourceLabel={{ singular: "person", plural: "people" }}` fixes the
|
|
586
|
+
resource's name in messages.
|
|
587
|
+
|
|
588
|
+
**Unsaved changes.** A dirty form warns before the page unloads
|
|
589
|
+
(`guardUnsavedChanges={false}` opts out). In-app navigation belongs to your
|
|
590
|
+
router: `useUnsavedChangesGuard()` holds the state and gives the router's
|
|
591
|
+
blocker its answer. With TanStack Router (what `karni new` sets up):
|
|
592
|
+
|
|
593
|
+
```tsx
|
|
594
|
+
import { useBlocker, useNavigate } from "@tanstack/react-router";
|
|
595
|
+
import { ResourceForm, useUnsavedChangesGuard } from "@karni/ui";
|
|
596
|
+
|
|
597
|
+
function EditEvent({ id }: { id: string }) {
|
|
598
|
+
const navigate = useNavigate();
|
|
599
|
+
const guard = useUnsavedChangesGuard();
|
|
600
|
+
// Asks "You have unsaved changes. Leave without saving them?" while dirty.
|
|
601
|
+
useBlocker({ shouldBlockFn: guard.shouldBlock, enableBeforeUnload: false });
|
|
602
|
+
return (
|
|
603
|
+
<ResourceForm
|
|
604
|
+
resource="events"
|
|
605
|
+
id={id}
|
|
606
|
+
onDirtyChange={guard.setDirty}
|
|
607
|
+
onSuccess={() => navigate({ to: "/events/$id", params: { id } })}
|
|
608
|
+
/>
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
ResourceForm reports itself clean just before `onSuccess`, so navigating there
|
|
614
|
+
is never blocked. `useUnsavedChangesGuard({ confirm })` swaps
|
|
615
|
+
`window.confirm` for your own dialog, and `useUnsavedChangesGuard({ dirty })`
|
|
616
|
+
guards a form of your own.
|
|
617
|
+
|
|
618
|
+
### ResourceSelect: thousands of options
|
|
619
|
+
|
|
620
|
+
- Options load a page at a time from the related list (its own `next` link,
|
|
621
|
+
page-number or cursor), more as the list scrolls, through "Load more", or
|
|
622
|
+
when the keyboard reaches the end. Nothing fetches every row, and long lists
|
|
623
|
+
render only the rows in view. The list says how many there are: "Showing 50
|
|
624
|
+
of 10,000. Type to narrow."
|
|
625
|
+
- Typing searches (`?search=`, debounced by `searchDebounceMs`), keeping the
|
|
626
|
+
previous results on screen and saying "Searching…". A related viewset with
|
|
627
|
+
no `search_fields` documents no `search` parameter. Then typing narrows the
|
|
628
|
+
loaded rows, and the list says search is not available. `searchable`
|
|
629
|
+
overrides what the schema says.
|
|
630
|
+
- Empty reads "No venues yet" or "No venues match “ber”". A failure reads "You
|
|
631
|
+
can't view venues", "Couldn't reach the server" or "Something went wrong on
|
|
632
|
+
our side", with "Try again" when it can help.
|
|
633
|
+
- Preselected ids are labelled in one request when the list documents
|
|
634
|
+
`id__in` (Karni's derived filters do not include it, so add it on the viewset
|
|
635
|
+
with `filterset_fields = {"id": ["exact", "in"]}`), otherwise one detail
|
|
636
|
+
request per id, in parallel. `idsFilter` names another filter or (`false`)
|
|
637
|
+
forces per-id requests. An id that cannot be loaded says "Couldn't load venue
|
|
638
|
+
12" instead of showing the bare id.
|
|
639
|
+
- It is an ARIA 1.2 combobox (`aria-expanded`, `aria-controls`,
|
|
640
|
+
`aria-activedescendant`). Arrows, Home/End and PageUp/PageDown move through
|
|
641
|
+
every loaded page. Chips remove with their button, Backspace/Delete, or
|
|
642
|
+
Backspace in the empty input. The ref is the input, and `onBlur`,
|
|
643
|
+
`aria-describedby`, `aria-invalid` and `aria-required` make it a form field.
|
|
644
|
+
`slots` replaces `Option`, `Chip`, `Loading`, `Empty` and `Error`
|
|
645
|
+
(`defaultResourceSelectSlots`).
|
|
646
|
+
|
|
192
647
|
## Hooks
|
|
193
648
|
|
|
194
649
|
```tsx
|
|
@@ -206,7 +661,7 @@ const { create, update, remove } = useResourceMutations<Event>("events");
|
|
|
206
661
|
|
|
207
662
|
List responses are DRF pages: `{ count, next, previous, results }`
|
|
208
663
|
(the `Paginated<T>` type). Mutations invalidate `karniKeys.resource(resource)`
|
|
209
|
-
on success
|
|
664
|
+
on success. Pass `invalidateKeys` to invalidate more. Use `karniKeys` for any
|
|
210
665
|
manual cache work so your keys and the library's keys never drift.
|
|
211
666
|
|
|
212
667
|
## Auth
|
|
@@ -215,15 +670,31 @@ manual cache work so your keys and the library's keys never drift.
|
|
|
215
670
|
const { user, status, login, logout } = useAuth();
|
|
216
671
|
const { hasRole, isAuthenticated } = usePermission();
|
|
217
672
|
|
|
218
|
-
<AuthGuard roles={["admin"]}
|
|
673
|
+
<AuthGuard roles={["admin"]} explain>
|
|
219
674
|
<AdminPanel />
|
|
220
675
|
</AuthGuard>;
|
|
221
676
|
```
|
|
222
677
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
678
|
+
With `explain`, `AuthGuard` says why it refuses. A visitor who is not signed in
|
|
679
|
+
sees a sign-in prompt with a `LoginForm` (signing in reveals the content in
|
|
680
|
+
place). A signed-in user whose role lacks access sees `PermissionDenied` naming
|
|
681
|
+
who is signed in. Replace one case with `anonymous={...}` or `denied={...}` (or
|
|
682
|
+
the `Anonymous` / `Denied` slots). `fallback` still covers both, and
|
|
683
|
+
`fallback={null}` renders nothing. Without any of these a guard renders
|
|
684
|
+
nothing, as in 0.3, and warns once in development: 1.0 makes `explain` the
|
|
685
|
+
default. To hide a piece of UI from a role, prefer `usePermission().hasRole(...)`.
|
|
686
|
+
|
|
687
|
+
`LoginForm` tells wrong credentials ("That email and password do not match")
|
|
688
|
+
from throttling ("Too many sign-in attempts. Wait 42 seconds"), a server error
|
|
689
|
+
and a dropped connection. The message is announced. Both fields point at it
|
|
690
|
+
(`aria-describedby`) and are marked invalid when they are the problem. The
|
|
691
|
+
email stays after a failure, and wrong credentials clear and focus the password.
|
|
692
|
+
The email field takes focus on mount (`autoFocus={false}` turns that off).
|
|
693
|
+
|
|
694
|
+
In JWT mode the refresh token is stored through a pluggable `TokenStorage`.
|
|
695
|
+
The default is `localStorage`, which is simple but readable by any XSS on your
|
|
696
|
+
page. For stricter setups use `createMemoryTokenStorage()` or your own adapter,
|
|
697
|
+
at the cost of logout-on-reload. Session mode uses cookies + CSRF headers and needs no
|
|
227
698
|
token storage.
|
|
228
699
|
|
|
229
700
|
## Primitives and escape hatches
|
|
@@ -234,32 +705,201 @@ three rules:
|
|
|
234
705
|
1. **`className` always merges** via `cn()` (clsx + tailwind-merge), so your
|
|
235
706
|
classes win over the component's own.
|
|
236
707
|
2. **Refs are forwarded** to the underlying element.
|
|
237
|
-
3. **Composition over configuration
|
|
708
|
+
3. **Composition over configuration**: for example, `buttonVariants()` is exported to
|
|
238
709
|
style any element as a button, and the styled Radix parts
|
|
239
710
|
(`SelectRoot`/`SelectTrigger`/`SelectContent`/`SelectItem`, the
|
|
240
711
|
`DropdownMenu*` family, `Tabs*`) are exported for fully custom composition.
|
|
241
712
|
|
|
242
713
|
`Select` note: Radix reserves the empty string, so the composed `Select` maps
|
|
243
714
|
"no selection" through an internal `"__karni_empty__"` sentinel. Consumers only
|
|
244
|
-
ever see `null
|
|
715
|
+
ever see `null`. Pass `clearable` to render a clear item that emits `null`.
|
|
245
716
|
Option values `""` and `"__karni_empty__"` are reserved.
|
|
246
717
|
|
|
247
718
|
The `variants()` helper (a dependency-free cva-style utility) and `cn()` are
|
|
248
719
|
exported for building your own components in the same style.
|
|
249
720
|
|
|
721
|
+
Leaf primitives have no `slots`: there is nothing inside them to replace, and
|
|
722
|
+
each one is replaced whole through the registry below (or restyled with
|
|
723
|
+
`className`). `Input`, `Textarea`, `SelectTrigger` (and the composed `Select`)
|
|
724
|
+
and `Checkbox` show a destructive border while `aria-invalid` is true, so a
|
|
725
|
+
form marks a field invalid with the same attribute screen readers announce.
|
|
726
|
+
The composed `Select` passes `aria-invalid`, `aria-describedby` and
|
|
727
|
+
`aria-labelledby` to its trigger.
|
|
728
|
+
|
|
729
|
+
### Your design system's primitives
|
|
730
|
+
|
|
731
|
+
A project with its own design system registers its primitives once, and every
|
|
732
|
+
Karni component renders them in place of Karni's, keeping its schema-driven
|
|
733
|
+
behaviour:
|
|
734
|
+
|
|
735
|
+
```tsx
|
|
736
|
+
import { Button } from "@/components/ui/button";
|
|
737
|
+
import { Input } from "@/components/ui/input";
|
|
738
|
+
import * as select from "@/components/ui/select";
|
|
739
|
+
|
|
740
|
+
<KarniProvider
|
|
741
|
+
client={client}
|
|
742
|
+
primitives={{
|
|
743
|
+
button: Button,
|
|
744
|
+
input: Input,
|
|
745
|
+
select: {
|
|
746
|
+
Root: select.Select,
|
|
747
|
+
Trigger: select.SelectTrigger,
|
|
748
|
+
Value: select.SelectValue,
|
|
749
|
+
Content: select.SelectContent,
|
|
750
|
+
Item: select.SelectItem,
|
|
751
|
+
Group: select.SelectGroup,
|
|
752
|
+
Label: select.SelectLabel,
|
|
753
|
+
Separator: select.SelectSeparator,
|
|
754
|
+
},
|
|
755
|
+
}}
|
|
756
|
+
>
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
The roles are `button`, `input`, `textarea`, `checkbox`, `switch`, `label`,
|
|
760
|
+
`skeleton` and the `select` family. A registered component receives the props
|
|
761
|
+
of Karni's primitive (`ButtonProps`, `InputProps`, …) and its ref. Components
|
|
762
|
+
from shadcn/ui take them as they are, and another design system registers a small
|
|
763
|
+
adapter (its own button with Karni's `variant` and `size` names mapped onto
|
|
764
|
+
its own). `select` is replaced as a whole family because Radix parts share a
|
|
765
|
+
context. A registered component may wrap Karni's own primitive: inside it, the
|
|
766
|
+
role resolves to Karni's again. `KarniPrimitivesProvider` changes the registry
|
|
767
|
+
for a subtree.
|
|
768
|
+
|
|
769
|
+
In a shadcn/ui project, `karni ui detect` writes this registry for you as
|
|
770
|
+
`karni-primitives.tsx` (a file the project owns), with only the roles whose
|
|
771
|
+
shadcn component exists, and the `select` family only when `select.tsx`
|
|
772
|
+
exports all eight parts on Radix. It adds `primitives={primitives}` to the
|
|
773
|
+
scaffold's `<KarniProvider client={client}>`, or prints the line to add.
|
|
774
|
+
|
|
775
|
+
## App shell and support components: slots
|
|
776
|
+
|
|
777
|
+
`Layout`, `Page`, `Modal`, `Toaster`, `EmptyState`, `Stat`, `DataChart` and
|
|
778
|
+
`FileUpload` take `className` (merged onto their root with `cn()`) and
|
|
779
|
+
`slots`: replace one internal piece and keep the rest of the component's
|
|
780
|
+
behaviour. Each component exports its slot contract (`LayoutSlots`,
|
|
781
|
+
`PageSlots`, ...), the props each slot receives, and its defaults
|
|
782
|
+
(`defaultLayoutSlots`, ...) so a replacement can wrap the original:
|
|
783
|
+
|
|
784
|
+
```tsx
|
|
785
|
+
import { Layout, type LayoutNavLinkProps } from "@karni/ui";
|
|
786
|
+
import { Link } from "@tanstack/react-router";
|
|
787
|
+
|
|
788
|
+
function NavLink({ item, href, onClick, ...props }: LayoutNavLinkProps) {
|
|
789
|
+
// onClick closes the mobile drawer; className carries the active style and focus ring.
|
|
790
|
+
return <Link to={href} onClick={onClick} {...props} />;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
<Layout navItems={nav} slots={{ NavLink }}>
|
|
794
|
+
…
|
|
795
|
+
</Layout>;
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
| Component | Slots |
|
|
799
|
+
| ------------ | ---------------------------------------------------------- |
|
|
800
|
+
| `Layout` | `Header`, `Sidebar`, `NavLink`, `MobileDrawer`, `SkipLink` |
|
|
801
|
+
| `Page` | `Header`, `Title`, `Description`, `Actions` |
|
|
802
|
+
| `Modal` | `Header`, `Footer`, `CloseButton` |
|
|
803
|
+
| `Toaster` | `Title`, `Description`, `CloseButton` |
|
|
804
|
+
| `EmptyState` | `Icon`, `Title`, `Description`, `Actions` |
|
|
805
|
+
| `Stat` | `Label`, `Value`, `Delta`, `Icon` |
|
|
806
|
+
| `DataChart` | `Tooltip`, `Legend`, `Empty`, `Loading` |
|
|
807
|
+
| `FileUpload` | `Dropzone`, `FileRow` |
|
|
808
|
+
|
|
809
|
+
Where a slot does what an older prop did, the prop keeps working and the slot
|
|
810
|
+
is preferred: `Layout`'s `renderNavItem` (use `NavLink`), `Page`'s `header`
|
|
811
|
+
(use `Header`, which still receives the title, description and actions),
|
|
812
|
+
`DataChart`'s `renderTooltip` (use `Tooltip`), `Toaster`'s `viewportClassName`
|
|
813
|
+
(use `className`). A custom `Modal` `Header` renders `ModalTitle` (and
|
|
814
|
+
`ModalDescription`) so the dialog stays labelled. A custom `CloseButton` wraps
|
|
815
|
+
its button in `ModalClose`.
|
|
816
|
+
|
|
817
|
+
Other additions: `Layout` takes `mobileNavOpen`, `defaultMobileNavOpen` and
|
|
818
|
+
`onMobileNavOpenChange` to control the drawer. `Stat` and `DataChart` take
|
|
819
|
+
`loading`, and `DataChart` also takes `summary`. `FileUpload` takes `error`,
|
|
820
|
+
`uploading`, `rejectionMessage`, `aria-invalid`, `aria-describedby` and
|
|
821
|
+
`aria-labelledby`.
|
|
822
|
+
|
|
823
|
+
## Accessibility
|
|
824
|
+
|
|
825
|
+
The app shell, the support components and the primitives target WCAG 2.2 AA.
|
|
826
|
+
What each one does for keyboard and screen-reader users:
|
|
827
|
+
|
|
828
|
+
- **Layout**: a "Skip to content" link is the first Tab stop and moves focus
|
|
829
|
+
to `<main>`. Below `md` the sidebar is a modal drawer (Radix Dialog): named
|
|
830
|
+
"Navigation", focus moves into it and stays there, Escape, the backdrop, the
|
|
831
|
+
close button or a link close it, and focus returns to the menu button. The
|
|
832
|
+
menu button's `aria-controls` names the drawer only while it is open. The
|
|
833
|
+
drawer closes itself if the window grows past `md`. Nav links show a focus
|
|
834
|
+
ring and mark the current page with `aria-current="page"`. After a
|
|
835
|
+
client-side navigation (from the drawer too), focus moves to the new
|
|
836
|
+
page's `<h1>` (else `<main>`) and a polite live region says the page
|
|
837
|
+
title. Nothing moves on the first load (a redirect before the user acts
|
|
838
|
+
included), when only search params change (filters, pagination, sorting),
|
|
839
|
+
or when the page placed focus itself (an autofocused field, a control
|
|
840
|
+
inside `<main>`, a dialog). No router is needed: the route is the
|
|
841
|
+
URL's path, and a hash route like `#/events` counts. Render `Layout` once,
|
|
842
|
+
around your router's outlet, as `karni new` does. `routeFocus={false}`
|
|
843
|
+
turns this off, and `routeFocus={{ routeKey, getTarget, getAnnouncement }}`
|
|
844
|
+
sets the route (for a memory router), the element to focus and the words.
|
|
845
|
+
- **ResourceTable**: the table is a grid with one Tab stop. The arrow keys
|
|
846
|
+
move inside it, Enter opens a row and Space selects it, so a long page no
|
|
847
|
+
longer costs a Tab press per control. `keyboardNavigation="native"` gives
|
|
848
|
+
every control its own Tab stop again. The details are under "Keyboard and
|
|
849
|
+
screen readers" in the ResourceTable section.
|
|
850
|
+
- **Modal**: named by its title and described by its description. Focus moves
|
|
851
|
+
in, Tab stays inside, Escape closes, and focus returns to what opened it.
|
|
852
|
+
- **Toaster**: an error toast is announced at once (assertive), the others
|
|
853
|
+
politely. F8 moves focus to the toasts, Tab walks them and their buttons,
|
|
854
|
+
Escape dismisses the focused one. A toast's timer pauses while the pointer is
|
|
855
|
+
over the toasts or focus is inside them.
|
|
856
|
+
- **FileUpload**: the dropzone is a button (Enter or Space opens the file
|
|
857
|
+
picker), named by its text, `aria-label`, `aria-labelledby` or a
|
|
858
|
+
`<label htmlFor={id}>`. A refused file ("notes.txt isn't an accepted file
|
|
859
|
+
type.") and the `error` prop are announced and linked with
|
|
860
|
+
`aria-describedby`, and `aria-invalid` gives the invalid style. While
|
|
861
|
+
`uploading` it is busy and says so. Removing a file returns focus to the
|
|
862
|
+
dropzone.
|
|
863
|
+
- **DataChart**: an image named by its title and described by its description
|
|
864
|
+
plus a generated summary (each series' lowest and highest value). Every
|
|
865
|
+
value is in a visually hidden table. Tab focuses the chart. The arrow keys,
|
|
866
|
+
Home and End move the tooltip from point to point and announce each one, and
|
|
867
|
+
Escape hides it. On a narrow screen the tick labels stay at 11px.
|
|
868
|
+
- **Stat**: a change is spoken ("Increased by", "Decreased by", "Unchanged")
|
|
869
|
+
and drawn with an arrow or a dash, not by colour alone. `loading` sets
|
|
870
|
+
`aria-busy`.
|
|
871
|
+
- **Primitives**: every control shows a focus ring on keyboard focus
|
|
872
|
+
(`focus-visible`, the `--karni-color-ring` token). Tabs, DropdownMenu,
|
|
873
|
+
Select, Checkbox and Switch follow the WAI-ARIA keyboard patterns through
|
|
874
|
+
Radix.
|
|
875
|
+
|
|
876
|
+
Contrast: every text and background pair the components use reaches 4.5:1 in
|
|
877
|
+
both themes, and field boundaries, the switch track, focus rings, icon-only
|
|
878
|
+
buttons and chart series reach 3:1. `tests/contrast.test.ts` computes the
|
|
879
|
+
ratios from the tokens in `styles.css`, so a token change cannot lower one
|
|
880
|
+
unnoticed. If you override tokens, check the same pairs. Motion reads
|
|
881
|
+
`--karni-duration`, which is 0ms under `prefers-reduced-motion`, and nothing
|
|
882
|
+
animates from JavaScript.
|
|
883
|
+
|
|
884
|
+
`tests/a11y.test.tsx` runs axe-core (the WCAG A and AA rules) over every
|
|
885
|
+
story of these components.
|
|
886
|
+
|
|
250
887
|
## Manifest for coding agents
|
|
251
888
|
|
|
252
|
-
The published package contains `dist/manifest.json
|
|
253
|
-
`{ "components": [...], "hooks": [...], "roles": {...} }
|
|
254
|
-
public barrel (and a static scan of the `registerComponentRole` call sites)
|
|
255
|
-
build time. AI coding agents can read it (export path
|
|
889
|
+
The published package contains `dist/manifest.json`, shaped
|
|
890
|
+
`{ "components": [...], "hooks": [...], "roles": {...} }`. It is generated from
|
|
891
|
+
the public barrel (and a static scan of the `registerComponentRole` call sites)
|
|
892
|
+
at build time. AI coding agents can read it (export path
|
|
256
893
|
`@karni/ui/manifest.json`) to discover what exists instead of crawling source.
|
|
257
894
|
|
|
258
895
|
## ESLint plugin
|
|
259
896
|
|
|
260
897
|
`@karni/ui/eslint-plugin` ships the Karni frontend enforcement rules
|
|
261
898
|
(`karni/no-raw-elements`, `karni/max-page-lines`, `karni/no-deep-imports`,
|
|
262
|
-
`karni/no-direct-http`) as a flat-config preset for ESLint >= 9
|
|
899
|
+
`karni/no-direct-http`) as a flat-config preset for ESLint >= 9. A fifth rule,
|
|
900
|
+
`karni/no-style-literals`, is not in the preset: it fails on any visual value
|
|
901
|
+
that does not resolve from a `--karni-*` token, and a project whose own code
|
|
902
|
+
should follow the theme turns it on with `"karni/no-style-literals": "error"`.
|
|
263
903
|
|
|
264
904
|
```js
|
|
265
905
|
// eslint.config.js
|
|
@@ -267,7 +907,7 @@ import karni from "@karni/ui/eslint-plugin";
|
|
|
267
907
|
export default [...karni.configs.recommended];
|
|
268
908
|
```
|
|
269
909
|
|
|
270
|
-
Every rule is an error
|
|
910
|
+
Every rule is an error. Opt-outs are disable comments with a REQUIRED reason
|
|
271
911
|
(`// eslint-disable-next-line karni/no-direct-http -- <reason>`). See
|
|
272
912
|
`docs/enforcement.md` at the repo root.
|
|
273
913
|
|
|
@@ -284,6 +924,6 @@ npm run storybook # Storybook 8 (react-vite) with a light/dark toolbar
|
|
|
284
924
|
Before the first `npm run storybook`, generate the MSW worker once:
|
|
285
925
|
`npx msw init public/`.
|
|
286
926
|
|
|
287
|
-
|
|
288
|
-
palette classes or raw color literals
|
|
927
|
+
Tests enforce the color discipline: component source uses tokens only, with no
|
|
928
|
+
Tailwind palette classes or raw color literals
|
|
289
929
|
(see `src/components/primitives/no-color-literals.test.ts`).
|