@keboola/design 3.2.0 → 4.0.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/AGENTS.md ADDED
@@ -0,0 +1,447 @@
1
+ # @keboola/design
2
+
3
+ Consumer usage contract for the Keboola design system — shadcn-aligned React primitives (Radix underneath), Keboola-branded blocks, Lucide icons, and TanStack-Form bindings.
4
+
5
+ This file ships **inside the npm tarball** and is version-pinned to the package you installed — it is the source of truth for **which** component to use and how to wire theming. For the exact **prop signature** of the most-used components, see the generated [`COMPONENT-API.md`](./COMPONENT-API.md) (also shipped in the tarball).
6
+
7
+ Working **on** the package (commands, layer boundary, token surface, the FA→Lucide table, enforcement)? Those live in `MAINTAINING.md` in the repo — internal, not shipped with this package.
8
+
9
+ ## Import surface
10
+
11
+ - **Always** import from the package root: `import { Button } from '@keboola/design'`.
12
+ - App entry points **must** import styles. Two paths, pick by build setup:
13
+ - `import '@keboola/design/styles'` — Tailwind-extending consumers. Your PostCSS+Tailwind processes `@tailwind` directives plus design's `dist/` in your content scan. Avoids duplicate preflights/utilities. **Default for any Keboola app.**
14
+ - `import '@keboola/design/styles/compiled'` — drop-in pre-compiled CSS (~176 KB minified). No Tailwind setup required. Use only when you genuinely cannot run Tailwind.
15
+ - Icons: `import { ChevronDown } from '@keboola/design/icons'`.
16
+ - Code editors: `import { CodeEditor, SqlEditor, MergeEditor } from '@keboola/design/editors'`. These pull the full CodeMirror bundle (~1.3 MB), so they ship from a dedicated subpath — keeping the root barrel CodeMirror-free for apps that don't edit code.
17
+
18
+ ## Decision tree
19
+
20
+ This file answers **which** component to use; [`COMPONENT-API.md`](./COMPONENT-API.md) carries the exact prop signatures.
21
+
22
+ ### Buttons
23
+
24
+ | Use | When |
25
+ | -------------------- | ------------------------------------------------------------------------------------ |
26
+ | `Button` | Primary, secondary, danger, outline, or invisible actions with optional label + icon |
27
+ | `Button size="icon"` | Icon-only button — no separate `IconButton` |
28
+ | `ButtonInline` | Inline text-link-style action inside body copy |
29
+ | `SplitButton` | Primary action + secondary dropdown of related actions |
30
+ | `Link` | Navigational anchor that opens a URL (defaults `target="_blank"`) |
31
+ | `ButtonGroup` | Two or more related buttons rendered as a segmented row |
32
+
33
+ **Never** use raw `<button>` or `<a>` — always through `Button`, `ButtonInline`, or `Link`.
34
+
35
+ ### Overlays
36
+
37
+ | Use | When |
38
+ | -------------- | ------------------------------------------------------------------------ |
39
+ | `Modal` | Blocking dialog (confirmation, form, detail view) |
40
+ | `Sheet` | Slide-in panel for contextual editing without leaving the page |
41
+ | `Popconfirm` | Lightweight inline confirmation anchored to the trigger (delete actions) |
42
+ | `Popover` | Non-modal floating panel for arbitrary rich content |
43
+ | `HoverCard` | Read-only preview panel on hover; no interactive controls |
44
+ | `DropdownMenu` | Contextual list of actions or navigation items on click |
45
+
46
+ **Never** use `Popover` for destructive confirmations (use `Popconfirm`) or hover-only previews (use `HoverCard`).
47
+
48
+ ### Inputs
49
+
50
+ | Use | When |
51
+ | ----------------------- | ------------------------------------------------------------------ |
52
+ | `TextInput` | Bare text input with no surrounding label / help — rare on its own |
53
+ | `FormGroup.TextInput` | Text field with help text + error state from `FormGroup state` |
54
+ | `useAppForm` | Full form layouts with validation (see Forms below) |
55
+ | `useAppForm` connectors | Picking between `TextField` / `NumberField` / `SelectField` / … |
56
+ | `InputOtp` | Fixed-length verification code (MFA, email confirm, sudo step-up) |
57
+ | `FileUpload` | Argo-only drag-and-drop file picker; caller owns the file array |
58
+
59
+ **Never** build custom label + input combinations — compose `Label` + `FormGroup.TextInput` + `FormGroup.Help` inside `FormGroup`.
60
+
61
+ ### Status surfaces
62
+
63
+ | Use | When |
64
+ | ----------------------------- | ----------------------------------------------------- |
65
+ | `toast()` / `useNotification` | Transient auto-dismissing feedback for background ops |
66
+ | `Toaster` | Mount once at app root |
67
+ | `Alert` | Persistent contextual message in page layout |
68
+ | `FormGroup state="error"` | Field-level validation errors inline below input |
69
+
70
+ **Never** use `Alert` for ephemeral success (use `toast()`) or `toast()` for blocking errors (use `Alert`).
71
+
72
+ ### Loading
73
+
74
+ | Use | When |
75
+ | ------------------ | ----------------------------------------------------------------------------------------------- |
76
+ | `Skeleton` | Layout-aware placeholder shown in the slot the real content will occupy (initial load, refetch) |
77
+ | `SkeletonList` | Pre-composed list shell (icon + row, repeated) for Table / Card-list pages |
78
+ | `Button isLoading` | In-flight action on a known button — swaps the icon for a spinner and disables the button |
79
+ | `DotsLoader` | Indeterminate background work without a layout slot (small inline progress signal) |
80
+
81
+ **Never** build a new "Loader" / "BoxLoader" / "ActionLoader" wrapper component. Compose `Skeleton` directly into the slot the real content will occupy.
82
+
83
+ ### Tooltips
84
+
85
+ | Use | When |
86
+ | ----------- | ----------------------------------------------------- |
87
+ | `Tooltip` | Short plain-text label (icon-button aria description) |
88
+ | `HoverCard` | Rich preview content (multi-line, structured) |
89
+
90
+ **Never** put interactive elements inside a `Tooltip` — use `HoverCard` or `Popover`.
91
+
92
+ ### Data display
93
+
94
+ | Use | When |
95
+ | ----------- | ------------------------------------------------------------------ |
96
+ | `Table` | Tabular data with columns, sortable headers, row actions |
97
+ | `Card` list | Small heterogeneous collections needing their own visual container |
98
+
99
+ **Never** reach for raw `<ul>/<li>` for entities — use `Table` for columnar data.
100
+
101
+ ### Layout
102
+
103
+ **No** `Stack` / `Flex` / `Box` component exists, by design — shadcn doesn't ship them, and adding wrappers creates a third way to do layout. Use Tailwind utility classes directly.
104
+
105
+ | Use | When |
106
+ | ---------------- | ------------------------------------------------------------------------ |
107
+ | Tailwind classes | All ad-hoc layout: `tw-flex tw-flex-col tw-gap-4`, `tw-grid …`, `tw-p-6` |
108
+ | `AppShell` | App-root shell that follows the active brand (sidebar vs top-nav) |
109
+ | `PageLayout` | Top-level page shape: header, tabs, toolbar, content, footer |
110
+ | `PageToolbar` | Index-page toolbar: search + filters / sort / actions / view + chips row |
111
+ | `Card` | Semantic content surface with header / content / footer slots |
112
+ | `Sidebar` | App-shell left navigation rail (host inside `AppShell.Sidebar`) |
113
+
114
+ Vertical rhythm: prefer `tw-gap-*` on a flex column parent over `tw-mt-*` on children — avoids margin-collapse. Page-level max width: use `PageLayout`'s `maxWidth` prop (`narrow` / `default` / `wide` / `full`), not `tw-max-w-[…]` literals.
115
+
116
+ **Page gutter is consumer-owned.** `AppShell.Main` and `PageLayout` impose **no** page gutter by design — content renders flush against the sidebar/header unless you add one. Put the padding on the content region yourself (the `AppShell` story wraps Main content in `tw-p-6`):
117
+
118
+ ```tsx
119
+ <AppShell.Main>
120
+ <div className="tw-flex tw-flex-col tw-gap-4 tw-px-6 tw-py-6">{/* page content */}</div>
121
+ </AppShell.Main>
122
+ ```
123
+
124
+ ### Navigation / flow
125
+
126
+ | Use | When |
127
+ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
128
+ | `Stepper` | Linear progress through an ordered, finite sequence (multi-step form / wizard / pipeline). Argo-only. Set `onStepClick` for navigable flows |
129
+ | `Tabs` | Switching between sibling views of the same context |
130
+ | `Timeline` | Unordered chronological log of events |
131
+ | `Progress` | Single percentage value of an in-flight task |
132
+
133
+ **Never** use `Stepper` for branching navigation (use `Tabs`) or an unordered status list (use `Timeline`).
134
+
135
+ ### Separators
136
+
137
+ | Use | When |
138
+ | -------------------------- | --------------------------------------------------------------------- |
139
+ | `Separator` | Plain horizontal or vertical line between sections |
140
+ | `<Separator label="OR" />` | Centered text divider between two options (sign-in flows, form forks) |
141
+
142
+ The `label` prop implies `orientation="horizontal"` — labels are not rendered vertically.
143
+
144
+ ### Date/time, color, multi-select, code
145
+
146
+ | Use | When |
147
+ | ----------------- | ------------------------------------------------------------- |
148
+ | `DatePicker` | Single calendar date |
149
+ | `DateRangePicker` | Selecting a date range |
150
+ | `TimePicker` | Time-of-day |
151
+ | `Calendar` | Raw calendar grid (embed in custom range pickers) |
152
+ | `ColorInput` | Design-token-driven brand color picker |
153
+ | `Combobox` | Single selection from a searchable list |
154
+ | `MultiCombobox` | Multiple selections from a searchable list |
155
+ | `Select` | Single selection from a short enum (≤ ~10 options, no search) |
156
+ | `Tagger` | Free-form chip/tag entry |
157
+ | `CodeEditor` | Generic code editing (JSON, YAML, shell) |
158
+ | `SqlEditor` | SQL editing (syntax + autocomplete) |
159
+
160
+ `Select` is wrong above ~10 items — use `Combobox`. `Combobox` is wrong for free-form values — use `Tagger`. `CodeEditor`/`SqlEditor` load the full CodeMirror bundle and ship from the `@keboola/design/editors` subpath (not the root); reserve for real code editing.
161
+
162
+ ## Composition recipes
163
+
164
+ The platform ships **no** `EntityList` / `ModalForm` / `ConfirmDialog` wrapper — these are compositions, not components. Copy the recipe; see [`COMPONENT-API.md`](./COMPONENT-API.md) for the exact props.
165
+
166
+ **Modal form** — `Modal` + `useAppForm`. The submit button lives in the footer and targets the form by `id`:
167
+
168
+ ```tsx
169
+ import { Button, Modal, useAppForm } from '@keboola/design';
170
+
171
+ const FORM_ID = 'new-bucket-form';
172
+
173
+ const form = useAppForm({
174
+ defaultValues: { name: '', description: '' },
175
+ onSubmit: async ({ value }) => {
176
+ /* … */
177
+ },
178
+ });
179
+
180
+ <Modal open={open} onOpenChange={setOpen}>
181
+ <Modal.Content size="medium">
182
+ <Modal.Header title="New bucket" />
183
+ <Modal.Body>
184
+ <form.AppForm>
185
+ <form.FormBody id={FORM_ID}>
186
+ <form.AppField name="name">
187
+ {(field) => <field.TextField label="Name" required />}
188
+ </form.AppField>
189
+ </form.FormBody>
190
+ </form.AppForm>
191
+ </Modal.Body>
192
+ <Modal.Footer>
193
+ <Modal.Close asChild>
194
+ <Button type="button" variant="secondary">
195
+ Cancel
196
+ </Button>
197
+ </Modal.Close>
198
+ <form.AppForm>
199
+ <form.SubmitButton form={FORM_ID}>Create</form.SubmitButton>
200
+ </form.AppForm>
201
+ </Modal.Footer>
202
+ </Modal.Content>
203
+ </Modal>;
204
+ ```
205
+
206
+ **Confirm dialog** — `Modal size="small"` + `Modal.Icon` + a danger action with `isLoading` while the async confirm runs:
207
+
208
+ ```tsx
209
+ <Modal open={open} onOpenChange={setOpen}>
210
+ <Modal.Content size="small">
211
+ <Modal.Icon color="red" icon={<AlertTriangle />} />
212
+ <Modal.Header title="Delete bucket?" description="This action cannot be undone." />
213
+ <Modal.Footer>
214
+ <ButtonGroup variant="block">
215
+ <Modal.Close asChild>
216
+ <Button type="button" variant="secondary" disabled={isDeleting}>
217
+ Cancel
218
+ </Button>
219
+ </Modal.Close>
220
+ <Button type="button" variant="danger" isLoading={isDeleting} onClick={handleConfirm}>
221
+ Delete bucket
222
+ </Button>
223
+ </ButtonGroup>
224
+ </Modal.Footer>
225
+ </Modal.Content>
226
+ </Modal>
227
+ ```
228
+
229
+ **Card with empty state** — branch on the collection length inside `Card.Content`:
230
+
231
+ ```tsx
232
+ <Card bordered padding="none">
233
+ <Card.Header className="tw-flex tw-items-center tw-justify-between tw-p-4">
234
+ <Card.Title>Tokens</Card.Title>
235
+ <Button size="small">
236
+ <Plus className="tw-size-4" /> New token
237
+ </Button>
238
+ </Card.Header>
239
+ <Card.Content className="tw-p-0">
240
+ {tokens.length === 0 ? (
241
+ <EmptyState variant="compact">
242
+ <EmptyState.Title>No tokens yet</EmptyState.Title>
243
+ <EmptyState.Actions>
244
+ <Button size="small">Create token</Button>
245
+ </EmptyState.Actions>
246
+ </EmptyState>
247
+ ) : (
248
+ <Table>{/* rows */}</Table>
249
+ )}
250
+ </Card.Content>
251
+ </Card>
252
+ ```
253
+
254
+ **Entity list** — `SearchInput` + `Table` + `Pagination` + `EmptyState`. Keep query/page in local state, filter then slice:
255
+
256
+ ```tsx
257
+ <div className="tw-flex tw-flex-col tw-gap-4">
258
+ <SearchInput
259
+ allowClear
260
+ placeholder="Search…"
261
+ value={query}
262
+ onValueChange={setQuery}
263
+ className="tw-w-72"
264
+ />
265
+ {filtered.length === 0 ? (
266
+ <EmptyState>
267
+ <EmptyState.Icon>
268
+ <Search />
269
+ </EmptyState.Icon>
270
+ <EmptyState.Title>No results match your search</EmptyState.Title>
271
+ </EmptyState>
272
+ ) : (
273
+ <>
274
+ <Table bordered>{/* pageRows */}</Table>
275
+ <Pagination>{/* Pagination.Previous / .Button active={…} / .Next */}</Pagination>
276
+ </>
277
+ )}
278
+ </div>
279
+ ```
280
+
281
+ **Data card with skeleton** — render `Skeleton` / `SkeletonList` in the exact slot the real content will occupy:
282
+
283
+ ```tsx
284
+ <Card bordered>
285
+ <Card.Header>
286
+ <Card.Title>{isLoading ? <Skeleton height="md" className="tw-w-32" /> : 'Storage'}</Card.Title>
287
+ </Card.Header>
288
+ <Card.Content>{isLoading ? <SkeletonList groups={2} rowHeight="sm" /> : <Stats />}</Card.Content>
289
+ </Card>
290
+ ```
291
+
292
+ **Alert with action** — pass the action node to `Alert`'s `actions` prop; the variant stays categorical (see brand-neutrality contract):
293
+
294
+ ```tsx
295
+ <Alert
296
+ variant="warning"
297
+ title="Trial ends in 3 days"
298
+ actions={
299
+ <Button size="small" variant="outline" onClick={onExtend}>
300
+ Extend
301
+ </Button>
302
+ }
303
+ >
304
+ Add a payment method to keep your project active.
305
+ </Alert>
306
+ ```
307
+
308
+ **Disabled-button tooltip** — a disabled `Button` fires no events, so wrap it in a `span` that carries the trigger and `pointer-events-none` the button:
309
+
310
+ ```tsx
311
+ <Tooltip tooltip={canSave ? null : reason} placement="top" triggerAsChild>
312
+ <span className={canSave ? undefined : 'tw-inline-block tw-cursor-not-allowed'}>
313
+ <Button disabled={!canSave} className={canSave ? undefined : 'tw-pointer-events-none'}>
314
+ Save
315
+ </Button>
316
+ </span>
317
+ </Tooltip>
318
+ ```
319
+
320
+ **Info tooltip** — an icon-only `Button variant="inline"` next to a `Label`, with the hint on a `Tooltip`:
321
+
322
+ ```tsx
323
+ <div className="tw-flex tw-items-center tw-gap-1">
324
+ <Label htmlFor={id}>{label}</Label>
325
+ <Tooltip tooltip={hint} placement="right" triggerAsChild>
326
+ <Button
327
+ variant="inline"
328
+ size="icon-3xs"
329
+ aria-label={`More info about ${label}`}
330
+ className="tw-text-muted-foreground"
331
+ >
332
+ <Info className="tw-size-3" />
333
+ </Button>
334
+ </Tooltip>
335
+ </div>
336
+ ```
337
+
338
+ **Page header with tabs** — `PageLayout.Header` hosting `PageHeader.Index`, with `Tabs` passed to `headerNav`:
339
+
340
+ ```tsx
341
+ <PageLayout>
342
+ <PageLayout.Header>
343
+ <PageHeader.Index
344
+ title="Storage"
345
+ metadata="3 buckets and 39 tables"
346
+ headerNav={
347
+ <Tabs triggers={TABS} defaultValue={activeTab} onValueChange={setActiveTab}>
348
+ {/* TabsContent */}
349
+ </Tabs>
350
+ }
351
+ />
352
+ </PageLayout.Header>
353
+ </PageLayout>
354
+ ```
355
+
356
+ **Creatable combobox** — `Combobox` with `allowCreate` accepts values not in `items`:
357
+
358
+ ```tsx
359
+ <Combobox
360
+ items={TAGS}
361
+ value={tag}
362
+ onValueChange={setTag}
363
+ allowCreate
364
+ allowClear
365
+ placeholder="Pick or create a tag"
366
+ />
367
+ ```
368
+
369
+ **Sheet** — use `Sheet.Header title="…" description="…"` for a styled title + close icon; do **not** hand-roll with the raw `Sheet.Title`, which renders an unstyled `<h2>`. `Sheet.Body` provides horizontal padding only (`tw-px-4`) — add your own vertical rhythm/gap:
370
+
371
+ ```tsx
372
+ <Sheet open={open} onOpenChange={setOpen}>
373
+ <Sheet.Content>
374
+ <Sheet.Header title="Edit table" description="Adjust the table metadata." />
375
+ <Sheet.Body className="tw-flex tw-flex-col tw-gap-4 tw-py-4">{/* fields */}</Sheet.Body>
376
+ </Sheet.Content>
377
+ </Sheet>
378
+ ```
379
+
380
+ ## Icons
381
+
382
+ Canonical icon surface is `@keboola/design/icons` — a wildcard re-export of [lucide-react](https://lucide.dev) plus Keboola-custom inline-SVG icons. Follow **shadcn convention**: import named icons directly, no wrapper component. Every Lucide icon is available without any package edit.
383
+
384
+ ```tsx
385
+ import { ChevronDown, Keboola, Loader2 } from '@keboola/design/icons';
386
+
387
+ <button>Toggle <ChevronDown className="tw-size-4" /></button>
388
+ <Loader2 className="tw-size-4 tw-animate-spin" />
389
+ <Keboola className="tw-size-6" />
390
+ ```
391
+
392
+ - **One import surface**: Lucide icons and Keboola-custom icons (e.g. `Keboola`) both export from `@keboola/design/icons` and share the `LucideIcon` type, so they're interchangeable in any API typed `icon: LucideIcon` (Button, etc.).
393
+ - **Sizing**: prefixed Tailwind `tw-size-*` utility — `tw-size-4` = 16px (default for inline icons), `tw-size-5` = 20px, `tw-size-6` = 24px.
394
+ - **Spinner animation**: explicit via `tw-animate-spin`.
395
+
396
+ **FontAwesome is fully removed.** Every `icon` prop takes a Lucide `ReactNode`. (Maintainers: the canonical FA→Lucide mapping table lives in `MAINTAINING.md`.)
397
+
398
+ ## Brand-neutrality contract (Layer 0)
399
+
400
+ Layer-0 components split into two categories. The contract applies to chrome only.
401
+
402
+ **Chrome — follows the brand.** Surface, structure, navigation: Card body, Modal frame, Input border, Button outline, focus rings, Sidebar, generic text. Re-skins when a customer brand is applied. **Rule:** use semantic tokens (`tw-bg-background`, `tw-text-foreground`, `tw-border-border`, `tw-bg-muted`, `tw-text-muted-foreground`, `tw-bg-accent`, `tw-bg-destructive`, …). Customers override these via `@keboola/brand-registry`.
403
+
404
+ **Categorical — stays fixed across brands.** Badge `color`, Alert `variant`, ModalIcon status, decorative tag palette. Colors encode meaning; the prop API (`color="green"`, `variant="error"`) is a semantic contract with the consumer. **Rule:** keep using primitive scales (`primary-500`, `error-500`, etc.). A customer's "error" alert stays red regardless of brand — that is correct.
405
+
406
+ **Heuristic.** If a component exposes a `variant` / `color` / `status` prop encoding meaning, it is categorical. Otherwise it is chrome.
407
+
408
+ Layer-1 blocks (`src/blocks/`) are allowed to be Keboola-opinionated — the contract only applies to Layer-0 primitives.
409
+
410
+ ## Forms
411
+
412
+ ```ts
413
+ import { useAppForm, withForm, setServerFieldErrors } from '@keboola/design';
414
+ ```
415
+
416
+ `useAppForm` is a TanStack Form `createFormHook` binding with typed field connectors (`field.TextField`, `field.NumberField`, …). The schema library (`zod`, `valibot`, …) lives in your app — the block accepts any [Standard Schema](https://standardschema.dev/)-compatible validator. See the Modal-form recipe above for the composite case, and the per-connector signatures in [`COMPONENT-API.md`](./COMPONENT-API.md).
417
+
418
+ ## Theming (brand + colorScheme)
419
+
420
+ Wrap your app once in `<ThemeProvider>` from `@keboola/design`. It reads a `Brand` from `@keboola/brand-registry`, sets `data-brand` on `<html>`, and injects the matching `--color-*` / `--radius-*` CSS variables.
421
+
422
+ ```tsx
423
+ import { ThemeProvider } from '@keboola/design';
424
+
425
+ <ThemeProvider brand={brand} colorScheme="system">
426
+ <App />
427
+ </ThemeProvider>;
428
+ ```
429
+
430
+ - **`brand`** — the active brand decides the chrome. **argo styling is brand-driven**: any non-`keboola-legacy` brand renders argo chrome automatically (`renderArgo = !isLegacy`), so a default `keboola` / `example-customer` brand needs no per-component flags. The `keboola-legacy` brand renders the pre-modernization chrome. With no `brand` prop, `ThemeProvider` defaults to the canonical `keboola` brand.
431
+ - **`colorScheme`** — `"light"` (default) / `"dark"` / `"system"`. Dark mode is set **here, on the root provider** — not per component. `"system"` follows the OS preference.
432
+
433
+ The per-call-site `argo` / `variant="argo"` prop is a **transitional escape hatch** (force argo on one component regardless of brand), **not** the primary switch — prefer driving the whole tree from the brand. Two escape-hatch forms exist:
434
+
435
+ - **Boolean prop:** `<Button argo>`, `<Alert argo>` — swaps the entire variant family.
436
+ - **String variant:** `<Select variant="argo">`, `<MultiCombobox variant="argo">`, `<DropdownMenu variant="argo">` — for components whose `variant` union already encodes `'argo'`.
437
+
438
+ Define a custom brand with `@keboola/brand-registry`'s `brandSchema` (Zod) and pass it to `ThemeProvider`; or override the `--color-*` / `--radius-*` CSS variables directly on `:root`.
439
+
440
+ ## Common Tailwind patterns
441
+
442
+ ```
443
+ not-disabled:hover:tw-bg-accent
444
+ disabled:tw-opacity-40 tw-cursor-not-allowed
445
+ focus-visible:tw-ring-2 tw-ring-primary
446
+ [&_svg]:tw-text-muted-foreground
447
+ ```