@llmnative/react 1.20.0 → 1.22.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/llms-full.txt CHANGED
@@ -1,1148 +1,1182 @@
1
- # @llmnative/react — Riferimento completo
2
-
3
- Framework React AI-first per generazione deterministica di UI. Data-driven di default, schema-driven opzionale.
4
- Versione 1.0.0 — Apache-2.0
5
-
6
- ```bash
7
- npm install @llmnative/react
8
- import '@llmnative/react/dist/index.css'
9
- ```
10
-
11
- ---
12
-
13
- ## Regole fondamentali
14
-
15
- - Tutti gli import da `'@llmnative/react'` — mai da sottodirectory
16
- - I provider sono iniettati via `<App providers={...}>` — mai importati direttamente nei componenti
17
- - Usare `useDataProvider()`, `useAuthProvider()`, ecc. nei componenti, non istanze dirette
18
- - TypeScript strict: no `any` — usare `unknown`, `Record<string, unknown>`, tipi specifici
19
- - Naming props: `view` (presentazione contenuto), `appearance` (guscio visuale), `layout` (disposizione spaziale), `mode` (comportamento operativo), `variant` (tono semantico), `position` (ancoraggio), `size` (scala)
20
-
21
- ---
22
-
23
- ## App — Entry point
24
-
25
- ```tsx
26
- <App
27
- providers={{
28
- firebase: { // FirebaseConfig — tutte le chiavi flat
29
- apiKey, authDomain, databaseURL, projectId,
30
- storageBucket, messagingSenderId, appId,
31
- },
32
- supabase: { url, anonKey },
33
- google: { clientId, scope }, // flat — NON { oAuth2: { clientId } }
34
- dropbox: { clientId }, // DropboxConfig
35
- mock: { data: mockData },
36
- ai: { // AIConfig — NON una prop separata di <App>
37
- openaiApiKey, geminiApiKey, anthropicApiKey,
38
- deepSeekApiKey, mistralApiKey, openRouterApiKey,
39
- groqApiKey,
40
- cloudflare: { apiToken, accountId }, // Cloudflare Workers AI
41
- },
42
- proxy: { enable: true, route: '/api/proxy' },
43
- custom: { data: { myAdapter: new MyDataProvider() } },
44
- services: {
45
- data: 'dbRealtime', // DataDriverName
46
- storage: 'firestorage', // StorageDriverName | null
47
- auth: 'googleAuth', // AuthDriverName | null
48
- email: 'gmail', // EmailDriverName | null
49
- ai: 'openai', // AIDriverName | null
50
- credentials: 'googleServiceAccount',
51
- },
52
- }}
53
- iconProvider="lucide" // 'lucide' | 'phosphor' | AppIconProviderConfig
54
- themeProvider="default" // 'default' | 'flat' | 'cyber' | AppThemeProviderConfig
55
- menuConfig={menuConfig}
56
- importPage={(path) => import(path)}
57
- LayoutDefault={MyLayout} // layout wrapper custom (opzionale)
58
- appName="MyApp" // nome app (opzionale)
59
- i18n={{ locale: 'it', translations: { ... } }}
60
- contextProviders={MyContextProvider}
61
- errorReportUrl="/api/errors"
62
- debug={import.meta.env.DEV}
63
- />
64
- ```
65
-
66
- ### AppThemeProviderConfig
67
- ```ts
68
- {
69
- mode?: 'light' | 'dark' | 'system';
70
- theme: string;
71
- themes?: Record<string, {
72
- preset?: { colors?: ColorScale; radius?: number; font?: string };
73
- motion?: MotionRegistry;
74
- components?: Record<string, Partial<ThemeComponent>>;
75
- }>;
76
- themeOverride?: Record<string, Partial<ThemeComponent>>;
77
- }
78
- ```
79
-
80
- Built-in themes: `default`, `flat`, `cyber`.
81
-
82
- ---
83
-
84
- ## Provider System
85
-
86
- ### Matrix completa
87
-
88
- | Servizio | Interfaccia | Driver | Config key | Obbligatorio |
89
- |---|---|---|---|---|
90
- | data | `DataProviderAdapter` | `mock`, `dbRealtime`, `firestoreDb`, `supabaseDb` | `providers.mock/firebase/supabase` | sì (fallback: mock) |
91
- | storage | `StorageProviderAdapter` | `firestorage`, `supabaseStorage` | `providers.firebase/supabase` | no |
92
- | auth | `AuthProviderAdapter` | `googleAuth`, `firebaseAuth`, `supabaseAuth`, `dropboxAuth` | `providers.google/firebase/supabase/dropbox` | sì (fallback: googleAuth) |
93
- | email | `EmailProviderAdapter` | `gmail` | `providers.google` | no |
94
- | ai | `AIProviderAdapter` | `openai`, `gemini`, `anthropic`, `deepseek`, `mistral`, `openrouter`, `opencode`, `cloudflare`, `groq` | `Config.tsx AIConfig` | no |
95
- | credentials | `CredentialsAdapter` | `googleServiceAccount` | `providers.google.serviceAccount` | no |
96
-
97
- ### Hooks
98
-
99
- ```ts
100
- const data = useDataProvider() // DataProviderAdapter | null
101
- const storage = useStorageProvider() // StorageProviderAdapter | null
102
- const auth = useAuthProvider() // AuthProviderAdapter | null
103
- const email = useEmailProvider() // EmailProviderAdapter | null
104
- const ai = useAIProvider() // AIProviderAdapter | null
105
- const creds = useCredentialsProvider() // CredentialsAdapter | null
106
-
107
- // Named provider (multi-provider setup)
108
- const supabase = useDataProvider('supabase')
109
- ```
110
-
111
- ### Interfacce principali
112
-
113
- ```ts
114
- // DataProviderAdapter
115
- read(path, options?) / set(path, data) / update(path, data) / remove(path)
116
- subscribe(path, callback: (records: RecordArray) => void): () => void
117
- count?(path, where?): Promise<number>
118
-
119
- // StorageProviderAdapter
120
- upload(file, path): Promise<string|undefined>
121
- getURL(path) / download(path): Promise<Blob|undefined> / delete(path): Promise<boolean>
122
-
123
- // AuthProviderAdapter
124
- signIn(intent?, options?) / signOut() / getUser(): UserProfile|null
125
- onAuthChange(callback) / getAccessToken(): Promise<string|null>
126
- ```
127
-
128
- ### Auth sign-in methods
129
-
130
- | method | Firebase | Supabase | Google |
131
- |---|---|---|---|
132
- | `password` | ✅ | ✅ | — |
133
- | `anonymous` | ✅ | ✅ | — |
134
- | `magic_link` | — | ✅ | — |
135
- | `oauth` (github, apple…) | ✅ | ✅ | — |
136
- | GIS + scoped token | — | — | ✅ |
137
-
138
- ```ts
139
- await auth.signIn({ method: 'password', email: '...', password: '...' })
140
- await auth.signIn({ method: 'oauth', provider: 'github' })
141
- // Google: token per API scope
142
- const token = await auth.getAccessToken('https://www.googleapis.com/auth/gmail.send')
143
- ```
144
-
145
- ### CredentialsAdapter (Google Service Account)
146
- ```tsx
147
- // Chiama Google APIs lato browser senza Node.js
148
- <App providers={{ google: { serviceAccount: { clientEmail, privateKey } }, services: { credentials: 'googleServiceAccount' } }} />
149
-
150
- const creds = useCredentialsProvider()
151
- const token = await creds.getToken('https://www.googleapis.com/auth/solar')
152
- ```
153
-
154
- ### Provider custom
155
- ```ts
156
- import { DataProviderAdapter, RecordArray } from '@llmnative/react'
157
-
158
- export class RestDataProvider implements DataProviderAdapter {
159
- async read(path: string): Promise<unknown> { ... }
160
- async set(path: string, data: object): Promise<void> { ... }
161
- async update(path: string, data: object): Promise<void> { ... }
162
- async remove(path: string): Promise<void> { ... }
163
- subscribe(path: string | undefined, callback: (records: RecordArray) => void): () => void {
164
- return () => undefined
165
- }
166
- }
167
- ```
168
-
169
- ---
170
-
171
- ## Grid — CRUD table/gallery
172
-
173
- ```tsx
174
- <Grid
175
- path="/users" // DataProvider-backed — oppure records={array} per in-memory
176
- columns={[
177
- { key: 'name', label: 'Nome', sortable: true },
178
- { key: 'email', label: 'Email' },
179
- { key: 'role', label: 'Ruolo', render: ({ value }) => <Badge>{value}</Badge> },
180
- { key: 'date', label: 'Data', render: 'date' },
181
- ]}
182
- actions={['add', 'edit', 'delete']} // oppure Record<string, GridAction>
183
- view="table" // 'table' | 'gallery'
184
- pagination={{ limit: 20 }}
185
- groupBy="status"
186
- sortable={{ field: 'name', dir: 'asc' }}
187
- form={<FormFields />}
188
- onSave={async ({ record, action }) => record._key}
189
- onComplete={async ({ record, action }) => true}
190
- selection={{ mode: 'multiple', onChange: (sel) => {} }}
191
- reorderable
192
- onReorder={(records, meta) => {}}
193
- header={(ctx) => <div>{ctx.title}</div>}
194
- footer={(ctx) => <div>{ctx.records.length} records</div>}
195
- />
196
- ```
197
-
198
- | Prop | Tipo | Note |
199
- |---|---|---|---|
200
- | `path` | `string \| "fromUrl"` | Path provider-backed |
201
- | `records` | `RecordArray` | Dati in memoria (alternativa a path) |
202
- | `columns` | `GridColumn[]` | `{ key, label, sortable?, render?, className? }` |
203
- | `actions` | `Array<'add'\|'edit'\|'delete'> \| Record<string, GridAction>` | |
204
- | `view` | `'table' \| 'gallery'` | |
205
- | `groupBy` | `string \| string[]` | |
206
- | `pagination` | `PaginationParams` | `{ limit, page?, maxPageButtons?, align?, sticky? }` |
207
- | `sortable` | `boolean \| OrderConfig` | |
208
- | `selection` | `false \| 'single' \| 'multiple' \| GridSelectionConfig` | |
209
- | `form` | `ReactElement \| ((ctx) => ReactNode)` | Form condiviso per CRUD |
210
- | `onSave` | `GridMutationSaveHandler` | `async ({ record, action }) => string` |
211
- | `onDelete` | `GridMutationDeleteHandler` | `async ({ record }) => string` |
212
- | `onComplete` | `GridAfterActionHandler` | `async ({ record, action }) => boolean` |
213
- | `onRowClick` | `(record) => void` | |
214
- | `reorderable` | `boolean` | Abilita drag & drop righe |
215
- | `onReorder` | `GridReorderHandler` | `(records, meta) => void` |
216
- | `onLoad` | `(records) => TRecord[]` | Trasforma records dopo il fetch |
217
- | `editDeepLink` | `boolean` | Sincronizza modal edit con URL |
218
- | `audit` | `boolean` | Logga mutazioni |
219
- | `sticky` | `'top' \| 'bottom'` | Header/footer sticky |
220
- | `wrapperClassName` | `string` | CSS wrapper esterno |
221
- | `loading` | `boolean` | Stato loading override |
222
- | `title` | `ReactNode` | Titolo header grid |
223
- | `before` / `after` | `ReactNode` | Slot prima/dopo il contenuto |
224
- | `header` / `footer` | `ReactNode \| ((ctx) => ReactNode)` | Header/footer custom |
225
- | `searchable` | `boolean \| GridSearchConfig` | Search box nell'header default — `{ fields?, placeholder? }` |
226
- | `filters` | `GridFilterConfig[]` | Checkbox toggle nell'header default, applicati PRIMA di `searchable` — `{ key, label, defaultValue?, predicate(record, value) }` |
227
-
228
- **GridAction kind:** `'modal'` (default), `'route'`, `'external'`, `'inline'`, `'delete'`
229
-
230
- ---
231
-
232
- ## Form — form CRUD con lifecycle
233
-
234
- ```tsx
235
- <Form
236
- path="/products"
237
- appearance="card" // 'card' | 'empty'
238
- showBack
239
- defaultValues={{ status: 'draft' }}
240
- keyGenerator={() => `prod_${Date.now()}`}
241
- onLoad={(data) => { /* side-effect hook, non trasforma */ }}
242
- onSave={async ({ record, action }) => { /* return storagePath or undefined */ }}
243
- onDelete={async ({ record }) => { /* confirm */ }}
244
- onComplete={async ({ action }) => { if (action === 'save') navigate('/products') }}
245
- >
246
- <Input name="title" label="Titolo" required />
247
- <Input name="price" label="Prezzo" type="number" />
248
- <Input name="address.city" label="Città" /> {/* oggetto annidato */}
249
- <Repeat name="tags" defaultLength={1}>
250
- {(i) => <Input name={`tags.${i}`} label={`Tag ${i+1}`} />}
251
- </Repeat>
252
- </Form>
253
- ```
254
-
255
- | Prop | Tipo | Note |
256
- |---|---|---|---|
257
- | `path` | `string` | Path provider-backed |
258
- | `appearance` | `'card' \| 'empty'` | Guscio visuale |
259
- | `controller` | `FormController` | Controller condiviso per bottoni custom esterni |
260
- | `showBack` | `boolean` | Pulsante Back nel footer |
261
- | `defaultValues` | `RecordProps` | Valori iniziali per nuovo record |
262
- | `keyGenerator` | `(record) => string` | Generatore chiave primaria custom |
263
- | `draftBucket` | `string` | Bucket stabile che isola la bozza locale; omettilo per disabilitare la persistenza |
264
- | `noticeAnchorRef` | `React.RefObject<HTMLElement>` | Ancoraggio sticky per notifiche |
265
- | `showNotice` | `boolean` | Mostra notifiche save/delete (default `true`) |
266
- | `children` | `ReactNode \| ((fields) => ReactNode) \| (({ record }) => ReactNode)` | Contenuto form |
267
- | `header` | `ReactNode` | Contenuto header custom |
268
- | `footer` | `ReactNode` | Contenuto footer custom |
269
- | `wrapperClassName` | `string` | CSS wrapper esterno |
270
- | `headerClassName` | `string` | CSS header |
271
- | `className` | `string` | CSS body form |
272
- | `footerClassName` | `string` | CSS footer |
273
- | `log` | `boolean` | Logga ogni modifica su console |
274
- | `onLoad` | `(record) => void` | Side-effect dopo il caricamento (non trasforma) |
275
- | `onRecordChange` | `(record) => void` | Cambio campo in tempo reale |
276
- | `onSave` | `FormSaveHandler` | `async ({ record, prevRecord?, storagePath?, action }) => string \| undefined` |
277
- | `onDelete` | `FormDeleteHandler` | `async ({ record }) => string \| undefined` |
278
- | `onComplete` | `FormFinallyHandler` | `async ({ record, action }) => boolean` |
279
-
280
- **Form controller** — stato e azioni native del form accessibili da qualsiasi punto della pagina.
281
-
282
- Due hook:
283
-
284
- | Hook | Dove usarlo | Cosa restituisce |
285
- |---|---|---|
286
- | `useFormController(controller?)` | Ovunque (anche fuori dal form) | Crea o si aggancia a un `FormController`. Senza argomenti ne crea uno locale stabile. Con un `controller` esistente ci si aggancia. |
287
- | `useFormActions()` | Solo dentro `<Form>` (legge dal context) | Restituisce lo stesso `FormController` del form genitore. Lancia errore se usato fuori. |
288
-
289
- Esempio — bottone custom fuori dal form:
290
- ```tsx
291
- const form = useFormController();
292
-
293
- <ActionButton disabled={form.saveDisabled} onClick={() => { void form.save(); }} />
294
- <Form controller={form} draftBucket="workspace/acme" onSave={...} />
295
- ```
296
-
297
- Esempio — uso dentro il form (senza passare controller):
298
- ```tsx
299
- function MySaveButton() {
300
- const form = useFormActions();
301
- return <LoadingButton disabled={form.saveDisabled} loading={form.isSaving} onClick={form.save} />;
302
- }
303
- ```
304
-
305
- Stato esposto dal controller: `record`, `isNewRecord`, `isDirty`, `canSave`, `canDelete`, `isSaving`, `isDeleting`, `saveDisabled`, `deleteDisabled`, `hasDraft`, `draftStatus`, `errors`, `hasErrors`.
306
-
307
- Azioni: `save()`, `delete()`, `reset()`, `restoreDraft()`, `discardDraft()`.
308
-
309
- ---
310
-
311
- ## Campi form
312
-
313
- ### Input
314
- ```tsx
315
- <Input name="x" label="..." required /> // text (default)
316
- <Input name="price" type="number" min={0} max={100} step={0.01} />
317
- <Input name="email" type="email" />
318
- <Input name="pwd" type="password" />
319
- <Input name="date" type="date" />
320
- <Input name="color" type="color" />
321
- <TextArea name="bio" minHeight={96} />
322
- <Checkbox name="active" valueChecked="yes" />
323
- ```
324
- `Input` props: `name`(req) `label` `type` `required` `disabled` `placeholder` `min` `max` `step` `validator:(v)=>string|undefined`
325
- `type`: `'text' | 'number' | 'email' | 'password' | 'color' | 'date' | 'time' | 'datetime-local' | 'week' | 'month' | 'range' | 'checkbox' | 'radio' | 'url'`
326
-
327
- **NOTA**: il tipo HTML si passa con `type`. `inputType` e i sotto-componenti `Input.Number`/`Input.Email`/`Input.Date` non esistono.
328
-
329
- ### Select / Autocomplete / Checklist
330
- ```tsx
331
- <Select name="role" options={[{ label: 'Admin', value: 'admin' }]} />
332
- <Select name="categoryId" optionsSource={{ path: '/categories', labelField: 'name', valueField: '_key' }} />
333
- <Select.Autocomplete name="userId" optionsSource={{ path: '/users', labelField: 'email' }} />
334
- <Select.Checklist name="tags" options={['a', 'b', 'c']} />
335
- ```
336
-
337
- | Prop | Tipo | Note |
338
- |---|---|---|
339
- | `options` | `Option[] \| string[] \| number[]` | |
340
- | `optionsSource` | `{ path, labelField, valueField?, where?, order?, fieldMap?, onLoad? }` | Carica da provider |
341
- | `order` | `{ field: 'label' \| 'value', dir: 'asc' \| 'desc' }` | |
342
- | `placeholderOption` | `Option` | Solo Select |
343
- | `placeholder` | `string` | Solo Autocomplete |
344
- | `creatable` | `boolean` | Solo Autocomplete — crea nuovi valori |
345
- | `onCreate` | `(value) => Promise<void>` | Solo Autocomplete |
346
- | `minItems` / `maxItems` | `number` | Solo Autocomplete |
347
- | `itemClassName` | `string` | Solo Checklist |
348
- | `validator` | `(value) => string \| undefined` | Tutte le varianti |
349
- | `readOnlyAfterSet` | `boolean` | readonly dopo primo valore |
350
- | `disabled` | `boolean` | |
351
- | `title` | `string` | |
352
- | `feedback` | `string` | Messaggio di errore/suggerimento |
353
-
354
- ### UploadImage / UploadDocument / UploadCSV
355
- ```tsx
356
- <UploadImage name="cover" label="Copertina" /> {/* con crop editor */}
357
- <UploadDocument name="pdf" label="PDF" />
358
- <UploadCSV name="data" label="Importa CSV" /> {/* con preview tabellare */}
359
- ```
360
-
361
- **UploadImageProps** (eredita FormFieldProps):
362
- | Prop | Tipo | Note |
363
- |---|---|---|
364
- | `name` | `string` (required) | |
365
- | `uploadPath` | `string` | Path storage per upload automatico |
366
- | `accept` | `string` | MIME types accettati |
367
- | `editable` | `boolean` | Abilita crop editor |
368
- | `multiple` | `boolean` | Multipla selezione |
369
- | `max` | `number` | Numero massimo file |
370
- | `feedback` | `string` | Testo placeholder drop zone |
371
- | `srcsetWidths` | `number[]` | Larghezze varianti responsive |
372
- | `previewHeight` / `previewWidth` | `number` | Dimensioni preview |
373
-
374
- **UploadDocumentProps** (eredita FormFieldProps): `name`, `uploadPath`, `accept`, `editable`, `multiple`, `max`
375
-
376
- ### UploadCSV
377
- ```tsx
378
- <UploadCSV name="data" label="Importa CSV" />
379
- ```
380
- | Prop | Tipo |
381
- |---|---|
382
- | `name` | `string` (required) |
383
- | `onDataLoaded` | `(data: UploadCSVData) => void` |
384
- | `onParseField` | `([key, value]) => [key, value] \| undefined` |
385
- | `normalizeKeys` | `boolean` |
386
- | `removeEmptyFields` | `boolean` |
387
- | `delimiter` | `string` |
388
- | `label` / `icon` | Opzionali |
389
-
390
- ### ImageField
391
- ```tsx
392
- <ImageField
393
- name="hero"
394
- label="Hero image"
395
- defaultValue={{ src: 'https://example.com/hero.jpg', alt: 'Hero shot' }}
396
- srcsetWidths={[400, 800]}
397
- uploadPath="/content/images"
398
- />
399
- ```
400
-
401
- Gestisce un oggetto `{ src, alt, srcset?, sizes? }`, preview inline, crop editor e generazione opzionale di varianti responsive.
402
-
403
- ### CropImage
404
- ```tsx
405
- <CropImage img={imageObj} title="Ritaglia" scales={{ '1:1': 1, '4:3': 1.33 }} />
406
- ```
407
- Componente di ritaglio indipendente. `img` ha tipo `{ fileName, variants }` dove ogni variante è `{ fileName, type, scale, top, left, width, height, base64? }`. Usato internamente da `UploadImage` e `ImageField`, ma esportato pubblicamente per uso standalone.
408
-
409
- ### CodeEditor
410
- ```tsx
411
- <CodeEditor
412
- name="code"
413
- label="Template"
414
- language="html"
415
- placeholder="Scrivi HTML..."
416
- minHeight={200}
417
- maxHeight={600}
418
- disabled={false}
419
- required
420
- validateSyntax
421
- commands={[{ name: 'wrap-section', handler: ({ value }) => `<section>\n${value}\n</section>` }]}
422
- commandsTrigger="/"
423
- />
424
- ```
425
- Editor basato su CodeMirror con syntax highlighting e validazione. Linguaggi: `liquid`, `html`, `json`, `js`, `ts`, `css`.
426
- Quando `commands` e presente, `CodeEditor` apre internamente `ContextMenu` sul trigger configurato.
427
-
428
- | Prop extra | Tipo |
429
- |---|---|
430
- | `language` | `CodeEditorLanguage` |
431
- | `placeholder` | `string` |
432
- | `minHeight` / `maxHeight` | `number` |
433
- | `validateSyntax` | `boolean` |
434
- | `validator` | `(value) => string \| undefined \| Promise<string \| undefined>` |
435
- | `extensions` | `unknown[]` |
436
-
437
- ### RichText
438
- ```tsx
439
- <RichText
440
- name="body"
441
- label="Article body"
442
- placeholder="Scrivi..."
443
- toolbar="fixed" // 'fixed' | 'floating' | false
444
- toolbarCommands={['bold', 'italic', 'heading1', '|', 'bulletList', 'link']}
445
- outputFormat="html" // 'html' | 'json' | 'text'
446
- statusBar={{ tagBreadcrumb: true, wordCount: true }}
447
- minHeight={200}
448
- maxHeight={800}
449
- imageUpload={{ path: '/content/images', srcsetWidths: [400, 800] }}
450
- commands={[{ name: 'summary', icon: 'sparkles' }]}
451
- commandsTrigger="/"
452
- />
453
- ```
454
-
455
- | Prop extra | Tipo |
456
- |---|---|
457
- | `placeholder` | `string` |
458
- | `disabled` | `boolean` |
459
- | `feedback` | `string` |
460
- | `toolbar` | `'fixed' \| 'floating' \| false` |
461
- | `toolbarCommands` | `ToolbarCommand[]` |
462
- | `outputFormat` | `'html' \| 'json' \| 'text'` |
463
- | `statusBar` | `boolean \| StatusBarConfig` |
464
- | `minHeight` / `maxHeight` | `number` |
465
- | `imageUpload` | `RichTextImageUploadConfig` |
466
- | `documentUpload` | `RichTextDocumentUploadConfig` |
467
- | `validator` | `(value) => string \| undefined` |
468
-
469
- ### Repeat — array dinamici
470
- ```tsx
471
- <Repeat name="items" defaultLength={3} minItems={1} maxItems={10} label="Elementi" layout="vertical" readOnly>
472
- {({ index, record, records, remove }) => (
473
- <>
474
- <Input name={`items.${index}.name`} label="Nome" />
475
- <UploadImage name={`items.${index}.photo`} label="Foto" />
476
- </>
477
- )}
478
- </Repeat>
479
- ```
480
-
481
- | Prop | Tipo | Note |
482
- |---|---|---|
483
- | `name` | `string` (required) | |
484
- | `children` | `ReactNode \| ((args) => ReactNode)` | Render prop con `{ index, record, records, remove }` |
485
- | `defaultLength` | `number` | Righe iniziali |
486
- | `minItems` / `maxItems` | `number` | Min/max righe |
487
- | `layout` | `'vertical' \| 'horizontal' \| 'inline'` | |
488
- | `label` | `string` | Etichetta gruppo |
489
- | `labelPosition` | `'top' \| 'bottom'` | |
490
- | `hideRemoveButton` | `boolean` | Nascondi rimozione |
491
- | `readOnly` | `boolean` | Sola lettura |
492
- | `onChange` | `FieldOnChange` | |
493
- | `onAdd` / `onRemove` | `(records) => void` | Callback aggiunta/rimozione |
494
-
495
- ### Prompt — campo AI-potenziato
496
- ```tsx
497
- <Prompt
498
- name="summary"
499
- mode="run" // 'edit' | 'run'
500
- onRunPrompt={async (prompt, options) => AI.fetch(prompt)}
501
- commands={[{ name: 'translate', icon: 'languages', handler: (v) => `Translate:\n${v}` }]}
502
- commandsTrigger="/" // default automatico quando commands e presente
503
- attachments
504
- statusItems={['tokensIn', 'tokensOut', 'contextPercent', 'duration']}
505
- />
506
- ```
507
-
508
- Quando `commands` e presente in `mode="run"`, `Prompt` monta internamente `ContextMenu` sulla textarea del risultato. Il trigger di default e `"/"`; `commandsTrigger` permette di cambiarlo. Il menu diventa searchable automaticamente sopra una soglia interna di comandi.
509
-
510
- `renderFallback`: quando l'AI non e configurata, `Prompt` monta un fallback testuale; passa `renderFallback` per personalizzarlo (riceve `{ name, label, required, onChange }`).
511
-
512
- ---
513
-
514
- ## UI Primitives
515
-
516
- ### Alert
517
- ```tsx
518
- <Alert variant="success">Salvato!</Alert>
519
- <Alert variant="warning" icon placement="fixed" timeout={3000}>Attenzione</Alert>
520
- ```
521
- `variant`: `'info' | 'success' | 'warning' | 'danger' | 'primary' | 'secondary' | 'light' | 'dark'`
522
- `appearance`: `'default' | 'text'`
523
- `placement`: `'inline' | 'fixed'`
524
-
525
- ### Badge
526
- ```tsx
527
- <Badge variant="success">Attivo</Badge>
528
- ```
529
- `variant`: stessi valori di Alert
530
-
531
- ### Button variants
532
- ```tsx
533
- <ActionButton variant="primary" label="Salva" icon="save" onClick={handleSave} />
534
- <LoadingButton variant="primary" label="Invia" onClick={async (e, setMsg) => { await save(); setMsg('Salvato!') }} />
535
- <BackLink label="Indietro" />
536
- <GoSite href="https://..." label="Visita" />
537
- ```
538
-
539
- **IButton props:** `variant`, `label`, `icon`, `badge`, `disabled`, `ariaLabel`, `title`, `iconClassName`, `onClick`
540
- **LoadingButton aggiunge:** `loading`, `loadingLabel` — `onClick` riceve `(e, setMessage?) => Promise<unknown>`
541
-
542
- ### Card
543
- ```tsx
544
- <Card title="Titolo" header={<Actions />} footer={<Pagination />} loading={isLoading}>
545
- {children}
546
- </Card>
547
- ```
548
-
549
- ### Modal
550
- ```tsx
551
- <Modal title="Conferma" size="md" position="center" onClose={() => {}} onSave={handleSave} onDelete={handleDelete} motion="fade" allowFullscreen stackedBehind>
552
- {content}
553
- </Modal>
554
- <ModalYesNo title="Sicuro?" onClose={() => {}} onYes={async () => true}>Eliminare il record?</ModalYesNo>
555
- <ModalOk title="Completato!" onClose={() => {}}>Salvato.</ModalOk>
556
- ```
557
- `size`: `'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'fullscreen'`
558
- `position`: `'center' | 'top' | 'left' | 'right' | 'bottom'`
559
- `ModalYesNo` e `ModalOk` sono export separati: `Modal.YesNo` e `Modal.Ok` non esistono.
560
- Tastiera: Esc chiude il modal in cima alla pila (`closeOnEscape`, default `true`).
561
- Accessibilità: il dialog ha `role="dialog"`, `aria-modal="true"` (non con `stackedBehind`) e `aria-labelledby` verso `title` (o `header` stringa) — nei test si trova con `getByRole('dialog', { name })`.
562
-
563
- | Prop | Tipo | Note |
564
- |---|---|---|
565
- | `children` | `ReactNode` (required) | |
566
- | `title` | `ReactNode` | |
567
- | `header` / `footer` | `ReactNode \| false` | Custom header/footer |
568
- | `size` / `position` | Come sopra | |
569
- | `onClose` | `() => void` | |
570
- | `onSave` / `onDelete` | `(e) => Promise<boolean>` | |
571
- | `allowFullscreen` | `boolean` | Toggle fullscreen |
572
- | `showCancel` | `boolean` | Default `true` |
573
- | `stackedBehind` | `boolean` | Modal seconda in pila |
574
- | `closeOnBackdrop` | `boolean` | Default `true` |
575
- | `closeOnEscape` | `boolean` | Default `true` — solo il modal in cima reagisce |
576
- | `zIndex` | `number` | |
577
- | `motion` | `MotionReference` | Preset animazione |
578
- | Classi CSS header/title/subtitle/body/footer | `string` | |
579
-
580
- ### Table
581
- ```tsx
582
- <Table
583
- columns={[{ key: 'name', label: 'Nome', sort: true }]}
584
- records={records}
585
- onRowClick={(record) => {}}
586
- selection="multiple"
587
- sortable={{ field: 'name', dir: 'asc' }}
588
- pagination={{ limit: 20 }}
589
- onReorder={(records, meta) => {}}
590
- onSelectionChange={(sel) => {}}
591
- groupBy="status"
592
- renderCell={(record, key, index) => <CustomCell />}
593
- />
594
- ```
595
-
596
- | Prop | Tipo |
597
- |---|---|
598
- | `columns` | `TableHeaderProp[]` |
599
- | `records` | `RecordArray` |
600
- | `sortable` | `boolean \| OrderConfig` |
601
- | `pagination` | `PaginationParams` |
602
- | `selection` | `'single' \| 'multiple'` |
603
- | `onRowClick` | `(record) => void` |
604
- | `onReorder` | `(records, meta) => void` |
605
- | `onSelectionChange` | `(selection) => void` |
606
- | `renderCell` | `(record, key, index) => ReactNode` |
607
- | `groupBy` | `string \| string[]` |
608
- | `activeKey` | `string \| null` |
609
- | `selectedKeys` | `string[]` |
610
- | `header` / `footer` | `ReactNode` |
611
- | `headerClassName` / `bodyClassName` / `footerClassName` / `scrollClassName` / `selectedClassName` / `heightClassName` | `string` |
612
-
613
- ### Gallery
614
- ```tsx
615
- <Gallery
616
- records={records}
617
- overlays={[{ position: 'topRight', badge: { variant: 'success', children: 'New' } }]}
618
- columns={3}
619
- gap={2}
620
- sortable={{ field: 'name', dir: 'asc' }}
621
- pagination={{ limit: 20 }}
622
- onRowClick={(record) => {}}
623
- onSelectionChange={(sel) => {}}
624
- header="Titolo"
625
- groupBy="status"
626
- />
627
- ```
628
-
629
- `position`: `'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight' | 'middleLeft' | 'middleRight'`
630
-
631
- | Prop | Tipo |
632
- |---|---|
633
- | `records` | `GalleryRecord[]` (`{ img?, thumbnail?, name?, width?, height? }`) |
634
- | `columns` | `1 \| 2 \| 3 \| 4 \| 6` |
635
- | `gap` | `0 \| 1 \| 2 \| 3 \| 4 \| 5` |
636
- | `overlays` | `GalleryOverlay[]` |
637
- | `sortable` | `boolean \| OrderConfig` |
638
- | `pagination` | `PaginationParams` |
639
- | `onRowClick` | `(record) => void` |
640
- | `onSelectionChange` | `(selection) => void` |
641
- | `groupBy` | `string \| string[]` |
642
- | `selectedKeys` | `string[]` |
643
- | `header` / `footer` | `ReactNode` |
644
- | Classi CSS: `headerClassName`, `bodyClassName`, `footerClassName`, `scrollClassName`, `selectedClassName` | `string` |
645
-
646
- ### Tab
647
- ```tsx
648
- import { Tab, TabItem } from '@llmnative/react'
649
-
650
- <Tab defaultIndex={0} layout="top">
651
- <TabItem label="Generale">{content1}</TabItem>
652
- <TabItem label="SEO">{content2}</TabItem>
653
- </Tab>
654
- ```
655
- `layout`: `'default' | 'top' | 'left' | 'right' | 'bottom'`
656
- Le schede sono `<TabItem>`, export separato: `Tab.Item` non esiste.
657
-
658
- ### Image
659
- ```tsx
660
- <Image src="..." fit="cover" position="center" width={400} height={300} />
661
- ```
662
- `fit`: `'cover' | 'contain' | 'fill' | 'scale-down' | 'none'`
663
-
664
- ### Pagination (standalone)
665
- ```tsx
666
- <Pagination records={items} limit={20}>
667
- {(pageRecords, offset) => pageRecords.map(r => <Row key={r._key} record={r} />)}
668
- </Pagination>
669
- ```
670
-
671
- ### Percentage
672
- ```tsx
673
- <Percentage value={75} appearance="bar" variant="success" showText />
674
- <Percentage value={0.75} appearance="circle" size={60} />
675
- ```
676
-
677
- | Prop | Tipo | Default |
678
- |---|---|---|
679
- | `value` | `number` | — |
680
- | `max` | `number` | `100` |
681
- | `min` | `number` | — |
682
- | `appearance` | `'bar' \| 'circle'` | `'bar'` |
683
- | `variant` | `ColorType` | — |
684
- | `trackVariant` | `ColorType` | — |
685
- | `thickness` | `number` | — (px) |
686
- | `showText` | `boolean` | — |
687
- | `size` | `number` | Diametro in modalità `'circle'` (px) |
688
- | `fontSize` | `number` | — |
689
- | `label` | `string` | — |
690
-
691
- ### DescriptionList
692
- Coppie etichetta → valore in sola lettura (profilo, dati di un record, riepiloghi), rese come `<dl>`/`<dt>`/`<dd>`. Da usare al posto di markup `<dl>` scritto a mano; per dati modificabili usare i campi di `<Form>`, per molti record `<Table>`/`<Grid>`.
693
- ```tsx
694
- <DescriptionList items={[{ label: 'Email', value: 'a@b.it' }, { label: 'Telefono', value: null }]} />
695
- <DescriptionList layout="stacked" columns={4} truncate items={[{ label: 'Stato', value: <Badge variant="success">Spedito</Badge> }]} />
696
- ```
697
-
698
- | Prop | Tipo | Default |
699
- |---|---|---|
700
- | `items` | `DescriptionListItem[]` — `{ key?, label, value?, title? }` | — |
701
- | `layout` | `'horizontal' \| 'stacked'` | `'horizontal'` (etichette a sinistra, impilate su mobile) |
702
- | `columns` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | `1` (solo `stacked`, dal breakpoint `lg`) |
703
- | `labelWidth` | `string` (lunghezza CSS) | `'12rem'` (solo `horizontal`) |
704
- | `emptyValue` | `ReactNode` | `'—'` per `null` / `undefined` / `''` (lo `0` resta) |
705
- | `truncate` | `boolean` | `false`; un valore testuale diventa il suo `title` |
706
- | Classi | `wrapperClassName`, `className`, `itemClassName`, `labelClassName`, `valueClassName` | tema `DescriptionList` |
707
-
708
- ### Dropdown
709
- ```tsx
710
- <Dropdown trigger={<Icon name="menu" />} position="end">
711
- <DropdownItem label="Modifica" icon="edit" onClick={() => {}} />
712
- <DropdownItem label="Elimina" icon="trash" variant="danger" />
713
- <DropdownDivider />
714
- <DropdownHeader label="Azioni rapide" />
715
- </Dropdown>
716
- ```
717
-
718
- Per elenchi caricati dinamicamente usare `AsyncDropdown`: gestisce ricerca con debounce,
719
- cancellazione delle richieste precedenti tramite `AbortSignal` e stati loading/empty/error.
720
-
721
- ```tsx
722
- <AsyncDropdown
723
- trigger={<ActionButton label="Select user" />}
724
- loadItems={(query, signal) => users.search(query, { signal })}
725
- getItemId={user => user.id}
726
- renderItem={user => user.name}
727
- onSelect={user => setUser(user)}
728
- selectedId={user?.id}
729
- />
730
- ```
731
-
732
- ### ContextMenu
733
- ```tsx
734
- <ContextMenu trigger="/" searchable>
735
- <ContextMenu.Heading>Comandi</ContextMenu.Heading>
736
- <ContextMenu.Item label="Titolo" value="# " icon="heading" />
737
- <ContextMenu.Separator />
738
- <ContextMenu.Item label="Bold" value="**bold**" icon="bold" />
739
- </ContextMenu>
740
- ```
741
- Funziona su qualsiasi `<textarea>` / `<input>` figlio. Trigger custom (`/`, `@`, `{`), filtro searchable, navigazione tastiera, floating positioning.
742
-
743
- | Prop | Tipo | Note |
744
- |---|---|---|
745
- | `trigger` | `string` (required) | Es. `"/"`, `"@"`, `"{"` |
746
- | `searchable` | `boolean` | Filtro testuale |
747
- | `controlled` | `ContextMenuControlledState` | Stato controllato esterno |
748
- | `onSelect` | `(item, context) => void` | |
749
-
750
- **EditorCommand** (usato da `commands` prop su `RichText`, `CodeEditor`, `Prompt`):
751
- ```ts
752
- {
753
- name: string;
754
- description?: string;
755
- icon?: string;
756
- group?: string;
757
- consumeSuffix?: string;
758
- handler?: (context: TextCommandContext) => string | Promise<string>;
759
- }
760
- ```
761
-
762
- `consumeSuffix` elimina una chiusura automatica adiacente durante la sostituzione del trigger,
763
- utile quando un comando cambia famiglia sintattica (per esempio da `{{ ... }}` a `{% ... %}`).
764
-
765
- ### Altre primitive
766
- ```tsx
767
- <Icon name="user" size={20} /> // usa provider icone configurato
768
- <ImageAvatar src="..." label="Mario" size={40} />
769
- <Loader show={isLoading}>{content}</Loader> // indicatore al posto dei figli finché show è true
770
- <Loader show>{null}</Loader> // solo indicatore (es. pagina in caricamento)
771
- <Code language="tsx">{snippet}</Code> // syntax highlight
772
- <GridSystem.Row><GridSystem.Col xs={12} md={6}>...</GridSystem.Col></GridSystem.Row>
773
- <LocaleSwitcher locales={[{ code: 'it', label: 'Italiano' }]} />
774
- <ThemeSwitcher />
775
- <ProviderSwitcher />
776
- <LayoutBuilder components={...} />
777
- ```
778
-
779
- ---
780
-
781
- ## Blocks
782
-
783
- ```tsx
784
- <Breadcrumbs trail={[{ label: 'Home', href: '/' }, { label: 'Utenti' }]} separator="/" jsonLd />
785
- <Brand url="/" label="MyApp" logo="/logo.svg" width={120} />
786
- <Search onQueryChange={(e) => setQ(e.target.value)} />
787
- <Notifications items={[...]} seeAllUrl="/notifications" onMarkAllRead={() => {}} />
788
- <Carousel images={[...]} />
789
- <Toolbar items={[...]}>
790
- {children}
791
- </Toolbar>
792
- <SideNav />
793
- <ListCard records={records} onRowClick={(r) => {}} />
794
- ```
795
-
796
- **Brand:**
797
- | Prop | Tipo |
798
- |---|---|
799
- | `url` | `string` |
800
- | `label` | `string` |
801
- | `logo` | `string` |
802
- | `width` / `height` | `number` |
803
- | `wrapperClassName` / `className` / `logoClassName` / `labelClassName` | `string` |
804
-
805
- **Search:**
806
- | Prop | Tipo |
807
- |---|---|
808
- | `onQueryChange` | `(e: React.ChangeEvent<HTMLInputElement>) => void` |
809
-
810
- **Notifications:**
811
- | Prop | Tipo |
812
- |---|---|
813
- | `items` | `NotificationItem[]` |
814
- | `badge` | `BadgeProps` |
815
- | `seeAllUrl` | `string` |
816
- | `onMarkAllRead` | `() => void` |
817
- | `motion` | `MotionReference` |
818
-
819
- **ListCard:**
820
- | Prop | Tipo |
821
- |---|---|
822
- | `title` / `description` / `meta` | `ReactNode` |
823
- | `badge` | `ReactNode` |
824
- | `icon` | `string \| ReactNode` |
825
- | `trailing` / `footer` / `children` | `ReactNode` |
826
- | `href` | `string` |
827
- | `external` / `active` / `disabled` / `dashed` / `compact` | `boolean` |
828
- | `align` | `'start' \| 'center'` |
829
- | `onClick` | `(e) => void` |
830
- | Classi CSS: `titleClassName`, `descriptionClassName`, `metaClassName`, `badgeClassName`, `leadingClassName`, `bodyClassName`, `headerClassName`, `trailingClassName`, `footerClassName` | `string` |
831
-
832
- **Dropdown:**
833
- | Prop | Tipo |
834
- |---|---|
835
- | `trigger` | `string \| ReactNode \| DropdownTogglerProps` |
836
- | `badge` | `BadgeProps` |
837
- | `header` / `footer` | `ReactNode` |
838
- | `defaultOpen` / `open` | `boolean` |
839
- | `onOpenChange` | `(open) => void` |
840
- | `position` | `'start' \| 'end'` |
841
- | `placement` | `'auto' \| 'top' \| 'bottom'` |
842
- | `strategy` | `'fixed' \| 'absolute'` |
843
-
844
- // Menu — renderizza una sezione del menuConfig passato ad <App>
845
- <Menu menuKey="main" /> // 'main' o qualsiasi chiave definita in MenuConfig
846
- <Menu menuKey="topbar" className="nav flex-row gap-2" />
847
- // NON usare context= — la prop si chiama menuKey
848
-
849
- // AuthButton — login/logout con il provider auth configurato
850
- <AuthButton />
851
- ```
852
-
853
- ---
854
-
855
- ## Widgets
856
-
857
- ### MarkdownReader
858
- ```tsx
859
- <MarkdownReader
860
- content={markdownString}
861
- onInternalLinkClick={(href, e) => { e.preventDefault(); navigate(href) }}
862
- wrapperClassName="prose"
863
- />
864
- ```
865
-
866
- ### ImageEditor
867
- ```tsx
868
- <ImageEditor src="..." onSave={(dataUrl) => save(dataUrl)} />
869
- ```
870
-
871
- `ImageEditor` e un singolo componente pubblico. La libreria pesante `tui-image-editor` viene importata lazy a runtime dentro il componente, quindi non viene scaricata finche l'editor non viene effettivamente montato.
872
-
873
- ### TabDynamic
874
- Tab con lazy loading dei contenuti — gestione dinamica aggiunta/rimozione tab.
875
-
876
- ---
877
-
878
- ## AI Integration
879
-
880
- ```ts
881
- import { useAIProvider, getAIModelCatalog, createAIProviderRegistry } from '@llmnative/react'
882
-
883
- // Hooks
884
- const provider = useAIProvider() // provider di default
885
- const registry = useAIProviderRegistry() // tutti i provider AI registrati
886
- const catalog = getAIModelCatalog() // modelli disponibili
887
- // ogni modello: { id, provider, model, label, pricing? } — pricing in USD per 1M token
888
- // { input, output, currency: 'USD', source: 'provider' | 'models.dev' }; assente = prezzo
889
- // non trovato (il modello resta in lista). isFreeAIModel(m) → input e output a 0.
890
- // Il model picker di Prompt/Chatbot mostra "$in / $out", evidenzia i free, segnala i mancanti,
891
- // e raggruppa per provider (ChatbotModelOption.group = providerLabel) con header sticky.
892
-
893
- // Provider adapter restituito da useAIProvider()
894
- const text = await provider.fetch("Scrivi un titolo per: {keyword}", { keyword: "React" })
895
- const json = await provider.json("Lista di 5 categorie per blog tech")
896
- const arr = await provider.array("5 tag per: machine learning")
897
-
898
- // Provider custom
899
- class MyAIProvider implements AIProviderAdapter {
900
- async fetch(prompt: string, options?: AIRequestOptions): Promise<string> { ... }
901
- async json<T>(prompt: string, options?: AIRequestOptions): Promise<T> { ... }
902
- async array(prompt: string, options?: AIRequestOptions): Promise<string[]> { ... }
903
- }
904
-
905
- // Factory per registri esterni
906
- const registry = createAIProviderRegistry(definitions)
907
- ```
908
-
909
- Configurazione API key in `Config.tsx`: `geminiApiKey`, `openaiApiKey`, `anthropicApiKey`, `deepSeekApiKey`, `mistralApiKey`, `openRouterApiKey`, `groqApiKey`, `cloudflare: { apiToken, accountId }` (Cloudflare Workers AI: l'account id fa parte dell'URL; discovery via `ai/models/search`, modelli Workers Paid esclusi salvo `includePaidModels`).
910
-
911
- ---
912
-
913
- ## Theme e icone
914
-
915
- ```tsx
916
- const theme = useThemeController()
917
- theme.applyTheme('flat') // 'default' | 'flat' | 'cyber' | custom
918
- theme.setMode('dark') // 'light' | 'dark' | 'system'
919
-
920
- const icons = useIconController()
921
- icons.setProvider('phosphor') // 'lucide' | 'phosphor' | custom
922
- ```
923
-
924
- ---
925
-
926
- ## Head (SEO)
927
-
928
- ```ts
929
- useHead({
930
- title: 'Pagina',
931
- description: '...',
932
- robots: 'noindex',
933
- canonical: 'https://...',
934
- og: { title: '...', image: '...' }
935
- })
936
- ```
937
-
938
- ---
939
-
940
- ## Global state (localStorage)
941
-
942
- ```ts
943
- const vars = useGlobalVars()
944
- getGlobalVars('key')
945
- setGlobalVars({ key: 'value' })
946
- removeGlobalVars('key')
947
- ```
948
-
949
- ---
950
-
951
- ## Hooks completi
952
-
953
- | Hook | Ritorna |
954
- |---|---|
955
- | `useDataProvider(name?)` | `DataProviderAdapter \| null` |
956
- | `useStorageProvider(name?)` | `StorageProviderAdapter \| null` |
957
- | `useAuthProvider(name?)` | `AuthProviderAdapter \| null` |
958
- | `useEmailProvider(name?)` | `EmailProviderAdapter \| null` |
959
- | `useAIProvider(name?)` | `AIProviderAdapter \| null` |
960
- | `useAIProviderRegistry()` | `Record<string, AIProviderAdapter>` |
961
- | `useCredentialsProvider()` | `CredentialsAdapter \| null` |
962
- | `useTheme()` | `Theme \| undefined` |
963
- | `useThemeController()` | `ThemeController` |
964
- | `useIconController()` | `IconController` |
965
- | `useAccessToken(clientID, renew?)` | `boolean` | Ottiene token per servizio esterno (true = ok) |
966
- | `useGlobalVars()` | `Record<string, unknown>` |
967
- | `useHead()` | `HeadController` |
968
- | `useDocumentHead()` | `HeadController` |
969
- | `useAssetsHead(title?)` | `void` |
970
- | `useLanguageHead(lang?)` | `void` |
971
- | `useSocialHead(config)` | `void` |
972
- | `usePaginationHead(config)` | `void` |
973
- | `usePwaHead(config)` | `void` |
974
- | `useSchemaOrgHead(config)` | `void` |
975
- | `useHeadLinks(links)` | `void` |
976
- | `useProxy()` | `(url, init?) => Promise<Response>` |
977
- | `useImage()` | `UseImageResult` |
978
- | `useI18n()` | `I18nController` |
979
- | `useMenu()` | Menu hook |
980
- | `useFormController(controller?)` | `FormController` |
981
- | `useFormActions()` | `FormController` |
982
- | `useApiProvider(pathname?)` | `ApiProviderAdapter \| null` |
983
- | `useSetProvider(service, driver?)` | `void` |
984
- | `useProvider(name)` | `ProviderConfigurationState` |
985
- | `useProviderSession()` | `ProviderSession` |
986
- | `useDropBoxConnect(config?)` | `DropBoxConnectResult` |
987
- | `useEnterMotion(baseStyle?, ref?, fallback?)` | `CSSProperties` |
988
- | `useMotionState(active, ref?, fallback?, baseStyle?)` | `CSSProperties` |
989
- | `useMotionEffect(ref?, fallback?)` | `ResolvedMotionEffect` |
990
- | `usePressMotion(hovered, baseStyle?, ref?, fallback?)` | `{ style, pressHandlers }` |
991
- | `useIconProvider()` | `IconProvider` |
992
-
993
- ---
994
-
995
- ## Tipi chiave
996
-
997
- ```ts
998
- type RecordProps = Record<string, FieldValue>
999
- type FieldValue = string | number | boolean | null | undefined | Record<string, unknown> | unknown[]
1000
- type RecordArray = RecordProps[]
1001
-
1002
- type OrderConfig = { field: string; dir: 'asc' | 'desc' }
1003
- type WhereClause = { field: string; op: '==' | '!=' | '<' | '<=' | '>' | '>=' | 'in' | 'not-in' | 'array-contains' | 'array-contains-any'; value: unknown }
1004
-
1005
- interface UIProps {
1006
- before?: React.ReactNode
1007
- after?: React.ReactNode
1008
- wrapperClassName?: string
1009
- className?: string
1010
- }
1011
-
1012
- type UserProfile = { uid?: string; email?: string; displayName?: string; photoURL?: string; [key: string]: unknown }
1013
- ```
1014
-
1015
- ---
1016
-
1017
- ## Utilities
1018
-
1019
- ```ts
1020
- import { cn, converter, Sanitizer, Order, path, fetchJson, fetchRest, fetchWithRetry } from '@llmnative/react'
1021
- import { getKeywordIdeas, getGoogleTrendsData, getGoogleTrendsRelated } from '@llmnative/react'
1022
-
1023
- cn('base', condition && 'extra', className) // tailwind-merge classname
1024
- new Sanitizer().sanitize(html) // XSS sanitizer (non usare dangerouslySetInnerHTML)
1025
- new Order().order(records, { field: 'name', dir: 'asc' }) // ordina record array
1026
- fetchJson(url, init?) // fetch + JSON parse + error handling
1027
- fetchRest(path, init?) // fetch REST con auth
1028
- getKeywordIdeas(keyword) // Google Ads Keyword Ideas
1029
- getGoogleTrendsData(keyword) // Google Trends interest over time
1030
- getGoogleTrendsRelated(keyword) // Google Trends related queries
1031
- ```
1032
-
1033
- ---
1034
-
1035
- ## Scaffold (npx create)
1036
-
1037
- ```bash
1038
- npx @llmnative/react create
1039
- npx @llmnative/react create --yes --provider=mock --template=admin
1040
- ```
1041
-
1042
- Flag disponibili: `--name` `--provider=firebase|supabase|mock|custom` `--ai-provider` `--icon-provider` `--theme` `--template=blank|admin|crm|inventory|project`
1043
-
1044
- **5 template disponibili:**
1045
- | Template | Pagine incluse | Uso tipico |
1046
- |---|---|---|
1047
- | `blank` | home | App vuota — massima libertà |
1048
- | `admin` | home, users, roles, settings | Dashboard admin/backoffice |
1049
- | `crm` | home, companies, contacts, deals | CRM |
1050
- | `inventory` | home + gestione inventario | Magazzino |
1051
- | `project` | home + gestione progetti | PM tool |
1052
-
1053
- **Struttura generata in-place nella current directory:**
1054
- ```
1055
- src/
1056
- index.tsx ← App entry
1057
- vite-env.d.ts
1058
- conf/
1059
- app.ts ← providers config letta da .env (VITE_PROVIDER, VITE_AI_PROVIDER...)
1060
- menu.ts ← MenuConfig con path, title, icon, page (layout opzionale: c'è LayoutDefault)
1061
- data/
1062
- mockData.ts ← dati fake per MockDataProvider
1063
- layouts/
1064
- Default.tsx ← layout wrapper (header + sidebar + main + footer)
1065
- sections/
1066
- Header.tsx / Sidebar.tsx / PageHeader.tsx / Footer.tsx ← solo componenti pubblici (SideNav, AuthButton, ActionButton, Brand, Breadcrumbs)
1067
- pages/
1068
- home/HomePage.tsx
1069
- [altre pagine dal template scelto]
1070
- styles/
1071
- globals.css ← @import '@llmnative/react/dist/index.css' + Tailwind override
1072
- .env ← VITE_PROVIDER, VITE_THEME, chiavi API (non committare)
1073
- .env.example ← stessa struttura, valori vuoti (committare)
1074
- vite.config.ts / tsconfig.json / postcss.config.js / index.html
1075
- ```
1076
-
1077
- **Pattern conf/app.ts** (auto-generato, leggere per capire come funziona):
1078
- ```ts
1079
- import type { AppProvidersConfig } from '@llmnative/react'
1080
- import { mockData } from '../data/mockData'
1081
- const env = import.meta.env
1082
-
1083
- export const providers: AppProvidersConfig = {
1084
- mock: { data: mockData },
1085
- ai: { openaiApiKey: env.VITE_OPENAI_API_KEY ?? '' }, // AI dentro providers, non prop <App>
1086
- services: { data: 'mock', auth: 'googleAuth' },
1087
- }
1088
- ```
1089
-
1090
- Driver per `VITE_PROVIDER`: `firebase` → `firestoreDb` + `firestorage` + `firebaseAuth`; `supabase` → `supabaseDb` + `supabaseStorage` + `supabaseAuth`; `mock` → `mock` + `googleAuth`. `index.tsx` passa `LayoutDefault={Default}` ad `<App>`.
1091
-
1092
- ---
1093
-
1094
- ## Gotchas — pitfall frequenti
1095
-
1096
- | Errore | Causa | Fix |
1097
- |---|---|---|
1098
- | `aiConfig` prop non esiste su `<App>` | La config AI va dentro `providers.ai`, NON come prop separata | `providers={{ ai: { openaiApiKey } }}` |
1099
- | `inputType` o `Input.Number`/`Input.Email` non esistono | Il tipo HTML è la prop `type` | `<Input type="number">`, `<Input type="email">`, `<Input type="date">` |
1100
- | `Menu context=` non esiste | La prop si chiama `menuKey` | `<Menu menuKey="main" />` |
1101
- | `SignInButton` non esiste | Rinominato | `<AuthButton />` |
1102
- | `firebase: { config: ... }` errato | FirebaseConfig è flat | `firebase: { apiKey, authDomain, databaseURL, ... }` |
1103
- | `google: { oAuth2: { clientId } }` errato | GoogleProviderConfig è flat | `google: { clientId, scope }` |
1104
- | `children` su `Grid` | Grid non accetta children | Usa `form={<Fields />}` per il form inline |
1105
- | `recordId` su `Form` | Prop non esiste | La chiave del record viene da URL automaticamente |
1106
- | `MenuConfig` non importabile | Era type interno | Ora esportato: `import type { MenuConfig } from '@llmnative/react'` |
1107
- | `GridCellContext` non importabile | Mancava dall'index | Ora esportato: `import type { GridCellContext } from '@llmnative/react'` |
1108
-
1109
- ---
1110
-
1111
- ## Dove cercare nel codice sorgente
1112
-
1113
- | Cosa | File |
1114
- |---|---|
1115
- | Form | `src/components/widgets/Form.tsx` |
1116
- | Grid | `src/components/widgets/Grid.tsx` |
1117
- | Prompt | `src/components/widgets/Prompt.tsx` |
1118
- | Campi form | `src/components/ui/fields/` |
1119
- | Blocks (Menu, Toolbar, Notifications, Dropdown…) | `src/components/blocks/` |
1120
- | Provider data Firebase (RTDB) | `src/providers/data/firebase.ts` |
1121
- | Provider data Firestore | `src/providers/data/firestore.ts` |
1122
- | Provider data Supabase | `src/providers/data/supabase.ts` |
1123
- | Provider storage Firebase | `src/providers/storage/firebase.ts` |
1124
- | Provider storage Supabase | `src/providers/storage/supabase.ts` |
1125
- | Provider auth Firebase | `src/providers/auth/firebase/FirebaseAuthProvider.ts` |
1126
- | Provider auth Supabase | `src/providers/auth/supabase/` |
1127
- | Provider auth Google | `src/providers/auth/google/` |
1128
- | Provider auth Dropbox | `src/providers/auth/dropbox/` |
1129
- | Provider email Gmail | `src/providers/email/google/GmailEmailProvider.ts` |
1130
- | Provider AI definitions | `src/providers/ai/` |
1131
- | Provider credenziali | `src/providers/credentials/` |
1132
- | Provider session | `src/providers/ProviderSession.ts` |
1133
- | Proxy CORS | `src/providers/proxy.ts` |
1134
- | Motion system | `src/motion.ts` |
1135
- | I18n | `src/I18n.tsx` |
1136
- | Dropbox integration | `src/providers/dropbox/` |
1137
- | Interfaccia DataProvider | `src/providers/data/DataProvider.ts` |
1138
- | Interfaccia StorageProvider | `src/providers/storage/StorageProvider.ts` |
1139
- | Interfaccia AuthProvider | `src/providers/auth/AuthProvider.ts` |
1140
- | Interfaccia EmailProvider | `src/providers/email/EmailProvider.ts` |
1141
- | Interfaccia AIProvider | `src/providers/ai/AIProvider.ts` |
1142
- | Registry provider | `src/providers/manifest.ts` |
1143
- | Config provider | `src/providers/ProviderConfiguration.ts` |
1144
- | SEO / Google Trends | `src/providers/seo/google/` |
1145
- | Sistema tema | `src/Theme.tsx` |
1146
- | Config runtime | `src/Config.tsx` |
1147
- | Tipi TypeScript | `src/types/` |
1148
- | Utilities pure | `src/libs/` |
1
+ # @llmnative/react — Riferimento completo
2
+
3
+ Framework React AI-first per generazione deterministica di UI. Data-driven di default, schema-driven opzionale.
4
+ Versione 1.0.0 — Apache-2.0
5
+
6
+ ```bash
7
+ npm install @llmnative/react
8
+ import '@llmnative/react/dist/index.css'
9
+ ```
10
+
11
+ ---
12
+
13
+ ## Regole fondamentali
14
+
15
+ - Tutti gli import da `'@llmnative/react'` — mai da sottodirectory
16
+ - I provider sono iniettati via `<App providers={...}>` — mai importati direttamente nei componenti
17
+ - Usare `useDataProvider()`, `useAuthProvider()`, ecc. nei componenti, non istanze dirette
18
+ - TypeScript strict: no `any` — usare `unknown`, `Record<string, unknown>`, tipi specifici
19
+ - Naming props: `view` (presentazione contenuto), `appearance` (guscio visuale), `layout` (disposizione spaziale), `mode` (comportamento operativo), `variant` (tono semantico), `position` (ancoraggio), `size` (scala)
20
+
21
+ ---
22
+
23
+ ## App — Entry point
24
+
25
+ ```tsx
26
+ <App
27
+ providers={{
28
+ firebase: { // FirebaseConfig — tutte le chiavi flat
29
+ apiKey, authDomain, databaseURL, projectId,
30
+ storageBucket, messagingSenderId, appId,
31
+ },
32
+ supabase: { url, anonKey },
33
+ google: { clientId, scope }, // flat — NON { oAuth2: { clientId } }
34
+ dropbox: { clientId }, // DropboxConfig
35
+ mock: { data: mockData },
36
+ ai: { // AIConfig — NON una prop separata di <App>
37
+ openaiApiKey, geminiApiKey, anthropicApiKey,
38
+ deepSeekApiKey, mistralApiKey, openRouterApiKey,
39
+ groqApiKey,
40
+ cloudflare: { apiToken, accountId }, // Cloudflare Workers AI
41
+ },
42
+ proxy: { enable: true, route: '/api/proxy' },
43
+ custom: { data: { myAdapter: new MyDataProvider() } },
44
+ services: {
45
+ data: 'dbRealtime', // DataDriverName
46
+ storage: 'firestorage', // StorageDriverName | null
47
+ auth: 'googleAuth', // AuthDriverName | null
48
+ email: 'gmail', // EmailDriverName | null
49
+ ai: 'openai', // AIDriverName | null
50
+ credentials: 'googleServiceAccount',
51
+ },
52
+ }}
53
+ iconProvider="lucide" // 'lucide' | 'phosphor' | AppIconProviderConfig
54
+ themeProvider="default" // 'default' | 'flat' | 'cyber' | AppThemeProviderConfig
55
+ menuConfig={menuConfig}
56
+ importPage={(path) => import(path)}
57
+ LayoutDefault={MyLayout} // layout wrapper custom (opzionale)
58
+ appName="MyApp" // nome app (opzionale)
59
+ i18n={{ locale: 'it', translations: { ... } }}
60
+ contextProviders={MyContextProvider}
61
+ errorReportUrl="/api/errors"
62
+ debug={import.meta.env.DEV}
63
+ />
64
+ ```
65
+
66
+ ### AppThemeProviderConfig
67
+ ```ts
68
+ {
69
+ mode?: 'light' | 'dark' | 'system';
70
+ theme: string;
71
+ themes?: Record<string, {
72
+ preset?: { colors?: ColorScale; radius?: number; font?: string };
73
+ motion?: MotionRegistry;
74
+ components?: Record<string, Partial<ThemeComponent>>;
75
+ }>;
76
+ themeOverride?: Record<string, Partial<ThemeComponent>>;
77
+ }
78
+ ```
79
+
80
+ Built-in themes: `default`, `flat`, `cyber`.
81
+
82
+ ---
83
+
84
+ ## Provider System
85
+
86
+ ### Matrix completa
87
+
88
+ | Servizio | Interfaccia | Driver | Config key | Obbligatorio |
89
+ |---|---|---|---|---|
90
+ | data | `DataProviderAdapter` | `mock`, `dbRealtime`, `firestoreDb`, `supabaseDb` | `providers.mock/firebase/supabase` | sì (fallback: mock) |
91
+ | storage | `StorageProviderAdapter` | `firestorage`, `supabaseStorage` | `providers.firebase/supabase` | no |
92
+ | auth | `AuthProviderAdapter` | `googleAuth`, `firebaseAuth`, `supabaseAuth`, `dropboxAuth` | `providers.google/firebase/supabase/dropbox` | sì (fallback: googleAuth) |
93
+ | email | `EmailProviderAdapter` | `gmail` | `providers.google` | no |
94
+ | ai | `AIProviderAdapter` | `openai`, `gemini`, `anthropic`, `deepseek`, `mistral`, `openrouter`, `opencode`, `cloudflare`, `groq` | `Config.tsx AIConfig` | no |
95
+ | credentials | `CredentialsAdapter` | `googleServiceAccount` | `providers.google.serviceAccount` | no |
96
+
97
+ ### Hooks
98
+
99
+ ```ts
100
+ const data = useDataProvider() // DataProviderAdapter | null
101
+ const storage = useStorageProvider() // StorageProviderAdapter | null
102
+ const auth = useAuthProvider() // AuthProviderAdapter | null
103
+ const email = useEmailProvider() // EmailProviderAdapter | null
104
+ const ai = useAIProvider() // AIProviderAdapter | null
105
+ const creds = useCredentialsProvider() // CredentialsAdapter | null
106
+
107
+ // Named provider (multi-provider setup)
108
+ const supabase = useDataProvider('supabase')
109
+ ```
110
+
111
+ ### Interfacce principali
112
+
113
+ ```ts
114
+ // DataProviderAdapter
115
+ read(path, options?) / set(path, data) / update(path, data) / remove(path)
116
+ subscribe(path, callback: (records: RecordArray) => void): () => void
117
+ count?(path, where?): Promise<number>
118
+
119
+ // StorageProviderAdapter
120
+ upload(file, path): Promise<string|undefined>
121
+ getURL(path) / download(path): Promise<Blob|undefined> / delete(path): Promise<boolean>
122
+
123
+ // AuthProviderAdapter
124
+ signIn(intent?, options?) / signOut() / getUser(): UserProfile|null
125
+ onAuthChange(callback) / getAccessToken(): Promise<string|null>
126
+ ```
127
+
128
+ ### Auth sign-in methods
129
+
130
+ | method | Firebase | Supabase | Google |
131
+ |---|---|---|---|
132
+ | `password` | ✅ | ✅ | — |
133
+ | `anonymous` | ✅ | ✅ | — |
134
+ | `magic_link` | — | ✅ | — |
135
+ | `oauth` (github, apple…) | ✅ | ✅ | — |
136
+ | GIS + scoped token | — | — | ✅ |
137
+
138
+ ```ts
139
+ await auth.signIn({ method: 'password', email: '...', password: '...' })
140
+ await auth.signIn({ method: 'oauth', provider: 'github' })
141
+ // Google: token per API scope
142
+ const token = await auth.getAccessToken('https://www.googleapis.com/auth/gmail.send')
143
+ ```
144
+
145
+ ### CredentialsAdapter (Google Service Account)
146
+ ```tsx
147
+ // Chiama Google APIs lato browser senza Node.js
148
+ <App providers={{ google: { serviceAccount: { clientEmail, privateKey } }, services: { credentials: 'googleServiceAccount' } }} />
149
+
150
+ const creds = useCredentialsProvider()
151
+ const token = await creds.getToken('https://www.googleapis.com/auth/solar')
152
+ ```
153
+
154
+ ### Provider custom
155
+ ```ts
156
+ import { DataProviderAdapter, RecordArray } from '@llmnative/react'
157
+
158
+ export class RestDataProvider implements DataProviderAdapter {
159
+ async read(path: string): Promise<unknown> { ... }
160
+ async set(path: string, data: object): Promise<void> { ... }
161
+ async update(path: string, data: object): Promise<void> { ... }
162
+ async remove(path: string): Promise<void> { ... }
163
+ subscribe(path: string | undefined, callback: (records: RecordArray) => void): () => void {
164
+ return () => undefined
165
+ }
166
+ }
167
+ ```
168
+
169
+ ---
170
+
171
+ ## Grid — CRUD table/gallery
172
+
173
+ ```tsx
174
+ <Grid
175
+ path="/users" // DataProvider-backed — oppure records={array} per in-memory
176
+ columns={[
177
+ { key: 'name', label: 'Nome', sortable: true },
178
+ { key: 'email', label: 'Email' },
179
+ { key: 'role', label: 'Ruolo', render: ({ value }) => <Badge>{value}</Badge> },
180
+ { key: 'date', label: 'Data', render: 'date' },
181
+ ]}
182
+ actions={['add', 'edit', 'delete']} // oppure Record<string, GridAction>
183
+ view="table" // 'table' | 'gallery'
184
+ pagination={{ limit: 20 }}
185
+ groupBy="status"
186
+ sortable={{ field: 'name', dir: 'asc' }}
187
+ form={<FormFields />}
188
+ onSave={async ({ record, action }) => record._key}
189
+ onComplete={async ({ record, action }) => true}
190
+ selection={{ mode: 'multiple', onChange: (sel) => {} }}
191
+ reorderable
192
+ onReorder={(records, meta) => {}}
193
+ header={(ctx) => <div>{ctx.title}</div>}
194
+ footer={(ctx) => <div>{ctx.records.length} records</div>}
195
+ />
196
+ ```
197
+
198
+ | Prop | Tipo | Note |
199
+ |---|---|---|---|
200
+ | `path` | `string \| "fromUrl"` | Path provider-backed |
201
+ | `records` | `RecordArray` | Dati in memoria (alternativa a path) |
202
+ | `columns` | `GridColumn[]` | `{ key, label, sortable?, render?, className? }` |
203
+ | `actions` | `Array<'add'\|'edit'\|'delete'> \| Record<string, GridAction>` | |
204
+ | `view` | `'table' \| 'gallery'` | |
205
+ | `groupBy` | `string \| string[]` | |
206
+ | `pagination` | `PaginationParams` | `{ limit, page?, maxPageButtons?, align?, sticky? }` |
207
+ | `sortable` | `boolean \| OrderConfig` | |
208
+ | `selection` | `false \| 'single' \| 'multiple' \| GridSelectionConfig` | |
209
+ | `form` | `ReactElement \| ((ctx) => ReactNode)` | Form condiviso per CRUD |
210
+ | `onSave` | `GridMutationSaveHandler` | `async ({ record, action }) => string` |
211
+ | `onDelete` | `GridMutationDeleteHandler` | `async ({ record }) => string` |
212
+ | `onComplete` | `GridAfterActionHandler` | `async ({ record, action }) => boolean` |
213
+ | `onRowClick` | `(record) => void` | |
214
+ | `reorderable` | `boolean` | Abilita drag & drop righe |
215
+ | `onReorder` | `GridReorderHandler` | `(records, meta) => void` |
216
+ | `onLoad` | `(records) => TRecord[]` | Trasforma records dopo il fetch |
217
+ | `editDeepLink` | `boolean` | Sincronizza modal edit con URL |
218
+ | `audit` | `boolean` | Logga mutazioni |
219
+ | `sticky` | `'top' \| 'bottom'` | Header/footer sticky |
220
+ | `wrapperClassName` | `string` | CSS wrapper esterno |
221
+ | `loading` | `boolean` | Stato loading override |
222
+ | `title` | `ReactNode` | Titolo header grid |
223
+ | `before` / `after` | `ReactNode` | Slot prima/dopo il contenuto |
224
+ | `header` / `footer` | `ReactNode \| ((ctx) => ReactNode)` | Header/footer custom |
225
+ | `searchable` | `boolean \| GridSearchConfig` | Search box nell'header default — `{ fields?, placeholder? }` |
226
+ | `filters` | `GridFilterConfig[]` | Checkbox toggle nell'header default, applicati PRIMA di `searchable` — `{ key, label, defaultValue?, predicate(record, value) }` |
227
+
228
+ **GridAction kind:** `'modal'` (default), `'route'`, `'external'`, `'inline'`, `'delete'`
229
+
230
+ ---
231
+
232
+ ## Form — form CRUD con lifecycle
233
+
234
+ ```tsx
235
+ <Form
236
+ path="/products"
237
+ appearance="card" // 'card' | 'empty'
238
+ showBack
239
+ defaultValues={{ status: 'draft' }}
240
+ keyGenerator={() => `prod_${Date.now()}`}
241
+ onLoad={(data) => { /* side-effect hook, non trasforma */ }}
242
+ onSave={async ({ record, action }) => { /* return storagePath or undefined */ }}
243
+ onDelete={async ({ record }) => { /* confirm */ }}
244
+ onComplete={async ({ action }) => { if (action === 'save') navigate('/products') }}
245
+ >
246
+ <Input name="title" label="Titolo" required />
247
+ <Input name="price" label="Prezzo" type="number" />
248
+ <Input name="address.city" label="Città" /> {/* oggetto annidato */}
249
+ <Repeat name="tags" defaultLength={1}>
250
+ {(i) => <Input name={`tags.${i}`} label={`Tag ${i+1}`} />}
251
+ </Repeat>
252
+ </Form>
253
+ ```
254
+
255
+ | Prop | Tipo | Note |
256
+ |---|---|---|---|
257
+ | `path` | `string` | Path provider-backed |
258
+ | `appearance` | `'card' \| 'empty'` | Guscio visuale |
259
+ | `controller` | `FormController` | Controller condiviso per bottoni custom esterni |
260
+ | `showBack` | `boolean` | Pulsante Back nel footer |
261
+ | `defaultValues` | `RecordProps` | Valori iniziali per nuovo record |
262
+ | `keyGenerator` | `(record) => string` | Generatore chiave primaria custom |
263
+ | `draftBucket` | `string` | Bucket stabile che isola la bozza locale; omettilo per disabilitare la persistenza |
264
+ | `noticeAnchorRef` | `React.RefObject<HTMLElement>` | Ancoraggio sticky per notifiche |
265
+ | `showNotice` | `boolean` | Mostra notifiche save/delete (default `true`) |
266
+ | `children` | `ReactNode \| ((fields) => ReactNode) \| (({ record }) => ReactNode)` | Contenuto form |
267
+ | `header` | `ReactNode` | Contenuto header custom |
268
+ | `footer` | `ReactNode` | Contenuto footer custom |
269
+ | `wrapperClassName` | `string` | CSS wrapper esterno |
270
+ | `headerClassName` | `string` | CSS header |
271
+ | `className` | `string` | CSS body form |
272
+ | `footerClassName` | `string` | CSS footer |
273
+ | `log` | `boolean` | Logga ogni modifica su console |
274
+ | `onLoad` | `(record) => void` | Side-effect dopo il caricamento (non trasforma) |
275
+ | `onRecordChange` | `(record) => void` | Cambio campo in tempo reale |
276
+ | `onSave` | `FormSaveHandler` | `async ({ record, prevRecord?, storagePath?, action }) => string \| undefined` |
277
+ | `onDelete` | `FormDeleteHandler` | `async ({ record }) => string \| undefined` |
278
+ | `onComplete` | `FormFinallyHandler` | `async ({ record, action }) => boolean` |
279
+
280
+ **Form controller** — stato e azioni native del form accessibili da qualsiasi punto della pagina.
281
+
282
+ Due hook:
283
+
284
+ | Hook | Dove usarlo | Cosa restituisce |
285
+ |---|---|---|
286
+ | `useFormController(controller?)` | Ovunque (anche fuori dal form) | Crea o si aggancia a un `FormController`. Senza argomenti ne crea uno locale stabile. Con un `controller` esistente ci si aggancia. |
287
+ | `useFormActions()` | Solo dentro `<Form>` (legge dal context) | Restituisce lo stesso `FormController` del form genitore. Lancia errore se usato fuori. |
288
+
289
+ Esempio — bottone custom fuori dal form:
290
+ ```tsx
291
+ const form = useFormController();
292
+
293
+ <ActionButton disabled={form.saveDisabled} onClick={() => { void form.save(); }} />
294
+ <Form controller={form} draftBucket="workspace/acme" onSave={...} />
295
+ ```
296
+
297
+ Esempio — uso dentro il form (senza passare controller):
298
+ ```tsx
299
+ function MySaveButton() {
300
+ const form = useFormActions();
301
+ return <LoadingButton disabled={form.saveDisabled} loading={form.isSaving} onClick={form.save} />;
302
+ }
303
+ ```
304
+
305
+ Stato esposto dal controller: `record`, `isNewRecord`, `isDirty`, `canSave`, `canDelete`, `isSaving`, `isDeleting`, `saveDisabled`, `deleteDisabled`, `hasDraft`, `draftStatus`, `errors`, `hasErrors`.
306
+
307
+ Azioni: `save()`, `delete()`, `reset()`, `restoreDraft()`, `discardDraft()`.
308
+
309
+ ---
310
+
311
+ ## Campi form
312
+
313
+ ### Input
314
+ ```tsx
315
+ <Input name="x" label="..." required /> // text (default)
316
+ <Input name="price" type="number" min={0} max={100} step={0.01} />
317
+ <Input name="email" type="email" />
318
+ <Input name="pwd" type="password" />
319
+ <Input name="date" type="date" />
320
+ <Input name="color" type="color" />
321
+ <TextArea name="bio" minHeight={96} />
322
+ <Checkbox name="active" valueChecked="yes" />
323
+ ```
324
+ `Input` props: `name`(req) `label` `type` `required` `disabled` `placeholder` `min` `max` `step` `validator:(v)=>string|undefined`
325
+ `type`: `'text' | 'number' | 'email' | 'password' | 'color' | 'date' | 'time' | 'datetime-local' | 'week' | 'month' | 'range' | 'checkbox' | 'radio' | 'url'`
326
+
327
+ **NOTA**: il tipo HTML si passa con `type`. `inputType` e i sotto-componenti `Input.Number`/`Input.Email`/`Input.Date` non esistono.
328
+
329
+ ### Select / Autocomplete / Checklist
330
+ ```tsx
331
+ <Select name="role" options={[{ label: 'Admin', value: 'admin' }]} />
332
+ <Select name="categoryId" optionsSource={{ path: '/categories', labelField: 'name', valueField: '_key' }} />
333
+ <Autocomplete name="userId" optionsSource={{ path: '/users', labelField: 'email' }} maxItems={1} />
334
+ <Checklist name="tags" options={['a', 'b', 'c']} />
335
+ ```
336
+
337
+ | Prop | Tipo | Note |
338
+ |---|---|---|
339
+ | `options` | `Option[] \| string[] \| number[]` | |
340
+ | `optionsSource` | `{ path, labelField, valueField?, where?, order?, fieldMap?, onLoad? }` | Carica da provider |
341
+ | `order` | `{ field: 'label' \| 'value', dir: 'asc' \| 'desc' }` | |
342
+ | `placeholderOption` | `Option` | Solo Select |
343
+ | `placeholder` | `string` | Solo Autocomplete |
344
+ | `creatable` | `boolean` | Solo Autocomplete — crea nuovi valori |
345
+ | `onCreate` | `(value) => void \| boolean \| Promise<void \| boolean>` | Solo Autocomplete — restituire `false` per non selezionare il valore creato (es. aprire un modulo di creazione) |
346
+ | `listClassName` / `optionClassName` | `string` | Solo Autocomplete — classi della lista e delle voci (anche da tema `Autocomplete.listClassName` / `optionClassName`) |
347
+ | `minItems` / `maxItems` | `number` | Solo Autocomplete |
348
+ | `itemClassName` | `string` | Solo Checklist |
349
+ | `validator` | `(value) => string \| undefined` | Tutte le varianti |
350
+ | `readOnlyAfterSet` | `boolean` | readonly dopo primo valore |
351
+ | `disabled` | `boolean` | |
352
+ | `title` | `string` | |
353
+ | `feedback` | `string` | Messaggio di errore/suggerimento |
354
+
355
+ `Autocomplete` è una combobox accessibile: la lista (in un portal, larga quanto il campo, stile popover del tema) mostra solo le label e filtra per label/value senza distinzione di maiuscole e accenti; i chip mostrano la label; tastiera: frecce, Invio, Esc (chiude solo la lista, non il `Modal`), Tab, Backspace su campo vuoto rimuove l'ultimo valore. Con `creatable` compare la voce "Crea «testo»". Il valore salvato è sempre l'array dei `value`. `Autocomplete` e `Checklist` sono export nominati: `Select.Autocomplete`/`Select.Checklist` non esistono.
356
+
357
+ ### UploadImage / UploadDocument / UploadCSV
358
+ ```tsx
359
+ <UploadImage name="cover" label="Copertina" /> {/* con crop editor */}
360
+ <UploadDocument name="pdf" label="PDF" />
361
+ <UploadCSV name="data" label="Importa CSV" /> {/* con preview tabellare */}
362
+ ```
363
+
364
+ **UploadImageProps** (eredita FormFieldProps):
365
+ | Prop | Tipo | Note |
366
+ |---|---|---|
367
+ | `name` | `string` (required) | |
368
+ | `uploadPath` | `string` | Path storage per upload automatico |
369
+ | `accept` | `string` | MIME types accettati |
370
+ | `editable` | `boolean` | Abilita crop editor |
371
+ | `multiple` | `boolean` | Multipla selezione |
372
+ | `max` | `number` | Numero massimo file |
373
+ | `feedback` | `string` | Testo placeholder drop zone |
374
+ | `srcsetWidths` | `number[]` | Larghezze varianti responsive |
375
+ | `previewHeight` / `previewWidth` | `number` | Dimensioni preview |
376
+
377
+ **UploadDocumentProps** (eredita FormFieldProps): `name`, `uploadPath`, `accept`, `editable`, `multiple`, `max`
378
+
379
+ ### UploadCSV
380
+ ```tsx
381
+ <UploadCSV name="data" label="Importa CSV" />
382
+ ```
383
+ | Prop | Tipo |
384
+ |---|---|
385
+ | `name` | `string` (required) |
386
+ | `onDataLoaded` | `(data: UploadCSVData) => void` |
387
+ | `onParseField` | `([key, value]) => [key, value] \| undefined` |
388
+ | `normalizeKeys` | `boolean` |
389
+ | `removeEmptyFields` | `boolean` |
390
+ | `delimiter` | `string` |
391
+ | `label` / `icon` | Opzionali |
392
+
393
+ ### ImageField
394
+ ```tsx
395
+ <ImageField
396
+ name="hero"
397
+ label="Hero image"
398
+ defaultValue={{ src: 'https://example.com/hero.jpg', alt: 'Hero shot' }}
399
+ srcsetWidths={[400, 800]}
400
+ uploadPath="/content/images"
401
+ />
402
+ ```
403
+
404
+ Gestisce un oggetto `{ src, alt, srcset?, sizes? }`, preview inline, crop editor e generazione opzionale di varianti responsive.
405
+
406
+ ### CropImage
407
+ ```tsx
408
+ <CropImage img={imageObj} title="Ritaglia" scales={{ '1:1': 1, '4:3': 1.33 }} />
409
+ ```
410
+ Componente di ritaglio indipendente. `img` ha tipo `{ fileName, variants }` dove ogni variante è `{ fileName, type, scale, top, left, width, height, base64? }`. Usato internamente da `UploadImage` e `ImageField`, ma esportato pubblicamente per uso standalone.
411
+
412
+ ### CodeEditor
413
+ ```tsx
414
+ <CodeEditor
415
+ name="code"
416
+ label="Template"
417
+ language="html"
418
+ placeholder="Scrivi HTML..."
419
+ minHeight={200}
420
+ maxHeight={600}
421
+ disabled={false}
422
+ required
423
+ validateSyntax
424
+ commands={[{ name: 'wrap-section', handler: ({ value }) => `<section>\n${value}\n</section>` }]}
425
+ commandsTrigger="/"
426
+ />
427
+ ```
428
+ Editor basato su CodeMirror con syntax highlighting e validazione. Linguaggi: `liquid`, `html`, `json`, `js`, `ts`, `css`.
429
+ Quando `commands` e presente, `CodeEditor` apre internamente `ContextMenu` sul trigger configurato.
430
+
431
+ | Prop extra | Tipo |
432
+ |---|---|
433
+ | `language` | `CodeEditorLanguage` |
434
+ | `placeholder` | `string` |
435
+ | `minHeight` / `maxHeight` | `number` |
436
+ | `validateSyntax` | `boolean` |
437
+ | `validator` | `(value) => string \| undefined \| Promise<string \| undefined>` |
438
+ | `extensions` | `unknown[]` |
439
+
440
+ ### RichText
441
+ ```tsx
442
+ <RichText
443
+ name="body"
444
+ label="Article body"
445
+ placeholder="Scrivi..."
446
+ toolbar="fixed" // 'fixed' | 'floating' | false
447
+ toolbarCommands={['bold', 'italic', 'heading1', '|', 'bulletList', 'link']}
448
+ outputFormat="html" // 'html' | 'json' | 'text'
449
+ statusBar={{ tagBreadcrumb: true, wordCount: true }}
450
+ minHeight={200}
451
+ maxHeight={800}
452
+ imageUpload={{ path: '/content/images', srcsetWidths: [400, 800] }}
453
+ commands={[{ name: 'summary', icon: 'sparkles' }]}
454
+ commandsTrigger="/"
455
+ />
456
+ ```
457
+
458
+ | Prop extra | Tipo |
459
+ |---|---|
460
+ | `placeholder` | `string` |
461
+ | `disabled` | `boolean` |
462
+ | `feedback` | `string` |
463
+ | `toolbar` | `'fixed' \| 'floating' \| false` |
464
+ | `toolbarCommands` | `ToolbarCommand[]` |
465
+ | `outputFormat` | `'html' \| 'json' \| 'text'` |
466
+ | `statusBar` | `boolean \| StatusBarConfig` |
467
+ | `minHeight` / `maxHeight` | `number` |
468
+ | `imageUpload` | `RichTextImageUploadConfig` |
469
+ | `documentUpload` | `RichTextDocumentUploadConfig` |
470
+ | `validator` | `(value) => string \| undefined` |
471
+
472
+ ### Repeat — array dinamici
473
+ ```tsx
474
+ <Repeat name="items" defaultLength={3} minItems={1} maxItems={10} label="Elementi" layout="vertical" readOnly>
475
+ {({ index, record, records, remove }) => (
476
+ <>
477
+ <Input name={`items.${index}.name`} label="Nome" />
478
+ <UploadImage name={`items.${index}.photo`} label="Foto" />
479
+ </>
480
+ )}
481
+ </Repeat>
482
+ ```
483
+
484
+ | Prop | Tipo | Note |
485
+ |---|---|---|
486
+ | `name` | `string` (required) | |
487
+ | `children` | `ReactNode \| ((args) => ReactNode)` | Render prop con `{ index, record, records, remove }` |
488
+ | `defaultLength` | `number` | Righe iniziali |
489
+ | `minItems` / `maxItems` | `number` | Min/max righe |
490
+ | `layout` | `'vertical' \| 'horizontal' \| 'inline'` | |
491
+ | `label` | `string` | Etichetta gruppo |
492
+ | `labelPosition` | `'top' \| 'bottom'` | |
493
+ | `hideRemoveButton` | `boolean` | Nascondi rimozione |
494
+ | `readOnly` | `boolean` | Sola lettura |
495
+ | `onChange` | `FieldOnChange` | |
496
+ | `onAdd` / `onRemove` | `(records) => void` | Callback aggiunta/rimozione |
497
+
498
+ ### Prompt — campo AI-potenziato
499
+ ```tsx
500
+ <Prompt
501
+ name="summary"
502
+ mode="run" // 'edit' | 'run'
503
+ onRunPrompt={async (prompt, options) => AI.fetch(prompt)}
504
+ commands={[{ name: 'translate', icon: 'languages', handler: (v) => `Translate:\n${v}` }]}
505
+ commandsTrigger="/" // default automatico quando commands e presente
506
+ attachments
507
+ statusItems={['tokensIn', 'tokensOut', 'contextPercent', 'duration']}
508
+ />
509
+ ```
510
+
511
+ Quando `commands` e presente in `mode="run"`, `Prompt` monta internamente `ContextMenu` sulla textarea del risultato. Il trigger di default e `"/"`; `commandsTrigger` permette di cambiarlo. Il menu diventa searchable automaticamente sopra una soglia interna di comandi.
512
+
513
+ `renderFallback`: quando l'AI non e configurata, `Prompt` monta un fallback testuale; passa `renderFallback` per personalizzarlo (riceve `{ name, label, required, onChange }`).
514
+
515
+ ---
516
+
517
+ ## UI Primitives
518
+
519
+ ### Alert
520
+ ```tsx
521
+ <Alert variant="success">Salvato!</Alert>
522
+ <Alert variant="warning" icon placement="fixed" timeout={3000}>Attenzione</Alert>
523
+ ```
524
+ `variant`: `'info' | 'success' | 'warning' | 'danger' | 'primary' | 'secondary' | 'light' | 'dark'`
525
+ `appearance`: `'default' | 'text'`
526
+ `placement`: `'inline' | 'fixed'`
527
+
528
+ ### Badge
529
+ ```tsx
530
+ <Badge variant="success">Attivo</Badge>
531
+ ```
532
+ `variant`: stessi valori di Alert
533
+
534
+ ### Button variants
535
+ ```tsx
536
+ <ActionButton variant="primary" label="Salva" icon="save" onClick={handleSave} />
537
+ <LoadingButton variant="primary" label="Invia" onClick={async (e, setMsg) => { await save(); setMsg('Salvato!') }} />
538
+ <BackLink label="Indietro" />
539
+ <GoSite href="https://..." label="Visita" />
540
+ ```
541
+
542
+ **IButton props:** `variant`, `label`, `icon`, `badge`, `disabled`, `ariaLabel`, `title`, `iconClassName`, `onClick`
543
+ **LoadingButton aggiunge:** `loading`, `loadingLabel` — `onClick` riceve `(e, setMessage?) => Promise<unknown>`
544
+
545
+ ### Card
546
+ ```tsx
547
+ <Card title="Titolo" header={<Actions />} footer={<Pagination />} loading={isLoading}>
548
+ {children}
549
+ </Card>
550
+ ```
551
+
552
+ ### Modal
553
+ ```tsx
554
+ <Modal title="Conferma" size="md" position="center" onClose={() => {}} onSave={handleSave} onDelete={handleDelete} motion="fade" allowFullscreen stackedBehind>
555
+ {content}
556
+ </Modal>
557
+ <ModalYesNo title="Sicuro?" onClose={() => {}} onYes={async () => true}>Eliminare il record?</ModalYesNo>
558
+ <ModalOk title="Completato!" onClose={() => {}}>Salvato.</ModalOk>
559
+ ```
560
+ `size`: `'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'fullscreen'`
561
+ `position`: `'center' | 'top' | 'left' | 'right' | 'bottom'`
562
+ `ModalYesNo` e `ModalOk` sono export separati: `Modal.YesNo` e `Modal.Ok` non esistono.
563
+ Tastiera: Esc chiude il modal in cima alla pila (`closeOnEscape`, default `true`).
564
+ Accessibilità: il dialog ha `role="dialog"`, `aria-modal="true"` (non con `stackedBehind`) e `aria-labelledby` verso `title` (o `header` stringa) — nei test si trova con `getByRole('dialog', { name })`.
565
+
566
+ | Prop | Tipo | Note |
567
+ |---|---|---|
568
+ | `children` | `ReactNode` (required) | |
569
+ | `title` | `ReactNode` | |
570
+ | `header` / `footer` | `ReactNode \| false` | Custom header/footer |
571
+ | `size` / `position` | Come sopra | |
572
+ | `onClose` | `() => void` | |
573
+ | `onSave` / `onDelete` | `(e) => Promise<boolean>` | |
574
+ | `allowFullscreen` | `boolean` | Toggle fullscreen |
575
+ | `showCancel` | `boolean` | Default `true` |
576
+ | `stackedBehind` | `boolean` | Modal seconda in pila |
577
+ | `closeOnBackdrop` | `boolean` | Default `true` |
578
+ | `closeOnEscape` | `boolean` | Default `true` — solo il modal in cima reagisce |
579
+ | `zIndex` | `number` | |
580
+ | `motion` | `MotionReference` | Preset animazione |
581
+ | Classi CSS header/title/subtitle/body/footer | `string` | |
582
+
583
+ ### Table
584
+ ```tsx
585
+ <Table
586
+ columns={[{ key: 'name', label: 'Nome', sort: true }]}
587
+ records={records}
588
+ onRowClick={(record) => {}}
589
+ selection="multiple"
590
+ sortable={{ field: 'name', dir: 'asc' }}
591
+ pagination={{ limit: 20 }}
592
+ onReorder={(records, meta) => {}}
593
+ onSelectionChange={(sel) => {}}
594
+ groupBy="status"
595
+ renderCell={(record, key, index) => <CustomCell />}
596
+ />
597
+ ```
598
+
599
+ | Prop | Tipo |
600
+ |---|---|
601
+ | `columns` | `TableHeaderProp[]` |
602
+ | `records` | `RecordArray` |
603
+ | `sortable` | `boolean \| OrderConfig` |
604
+ | `pagination` | `PaginationParams` |
605
+ | `selection` | `'single' \| 'multiple'` |
606
+ | `onRowClick` | `(record) => void` |
607
+ | `onReorder` | `(records, meta) => void` |
608
+ | `onSelectionChange` | `(selection) => void` |
609
+ | `renderCell` | `(record, key, index) => ReactNode` |
610
+ | `groupBy` | `string \| string[]` |
611
+ | `activeKey` | `string \| null` |
612
+ | `selectedKeys` | `string[]` |
613
+ | `header` / `footer` | `ReactNode` |
614
+ | `headerClassName` / `bodyClassName` / `footerClassName` / `scrollClassName` / `selectedClassName` / `heightClassName` | `string` |
615
+
616
+ ### Gallery
617
+ ```tsx
618
+ <Gallery
619
+ records={records}
620
+ overlays={[{ position: 'topRight', badge: { variant: 'success', children: 'New' } }]}
621
+ columns={3}
622
+ gap={2}
623
+ sortable={{ field: 'name', dir: 'asc' }}
624
+ pagination={{ limit: 20 }}
625
+ onRowClick={(record) => {}}
626
+ onSelectionChange={(sel) => {}}
627
+ header="Titolo"
628
+ groupBy="status"
629
+ />
630
+ ```
631
+
632
+ `position`: `'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight' | 'middleLeft' | 'middleRight'`
633
+
634
+ | Prop | Tipo |
635
+ |---|---|
636
+ | `records` | `GalleryRecord[]` (`{ img?, thumbnail?, name?, width?, height? }`) |
637
+ | `columns` | `1 \| 2 \| 3 \| 4 \| 6` |
638
+ | `gap` | `0 \| 1 \| 2 \| 3 \| 4 \| 5` |
639
+ | `overlays` | `GalleryOverlay[]` |
640
+ | `sortable` | `boolean \| OrderConfig` |
641
+ | `pagination` | `PaginationParams` |
642
+ | `onRowClick` | `(record) => void` |
643
+ | `onSelectionChange` | `(selection) => void` |
644
+ | `groupBy` | `string \| string[]` |
645
+ | `selectedKeys` | `string[]` |
646
+ | `header` / `footer` | `ReactNode` |
647
+ | Classi CSS: `headerClassName`, `bodyClassName`, `footerClassName`, `scrollClassName`, `selectedClassName` | `string` |
648
+
649
+ ### Tab
650
+ ```tsx
651
+ import { Tab, TabItem } from '@llmnative/react'
652
+
653
+ <Tab defaultIndex={0} layout="top">
654
+ <TabItem label="Generale">{content1}</TabItem>
655
+ <TabItem label="SEO">{content2}</TabItem>
656
+ </Tab>
657
+ ```
658
+ `layout`: `'default' | 'top' | 'left' | 'right' | 'bottom'`
659
+ Le schede sono `<TabItem>`, export separato: `Tab.Item` non esiste.
660
+
661
+ ### Image
662
+ ```tsx
663
+ <Image src="..." fit="cover" position="center" width={400} height={300} />
664
+ ```
665
+ `fit`: `'cover' | 'contain' | 'fill' | 'scale-down' | 'none'`
666
+
667
+ ### Pagination (standalone)
668
+ ```tsx
669
+ <Pagination records={items} limit={20}>
670
+ {(pageRecords, offset) => pageRecords.map(r => <Row key={r._key} record={r} />)}
671
+ </Pagination>
672
+ ```
673
+
674
+ ### Percentage
675
+ ```tsx
676
+ <Percentage value={75} appearance="bar" variant="success" showText />
677
+ <Percentage value={0.75} appearance="circle" size={60} />
678
+ ```
679
+
680
+ | Prop | Tipo | Default |
681
+ |---|---|---|
682
+ | `value` | `number` | — |
683
+ | `max` | `number` | `100` |
684
+ | `min` | `number` | — |
685
+ | `appearance` | `'bar' \| 'circle'` | `'bar'` |
686
+ | `variant` | `ColorType` | — |
687
+ | `trackVariant` | `ColorType` | — |
688
+ | `thickness` | `number` | — (px) |
689
+ | `showText` | `boolean` | — |
690
+ | `size` | `number` | Diametro in modalità `'circle'` (px) |
691
+ | `fontSize` | `number` | — |
692
+ | `label` | `string` | — |
693
+
694
+ ### DescriptionList
695
+ Coppie etichetta → valore in sola lettura (profilo, dati di un record, riepiloghi), rese come `<dl>`/`<dt>`/`<dd>`. Da usare al posto di markup `<dl>` scritto a mano; per dati modificabili usare i campi di `<Form>`, per molti record `<Table>`/`<Grid>`.
696
+ ```tsx
697
+ <DescriptionList items={[{ label: 'Email', value: 'a@b.it' }, { label: 'Telefono', value: null }]} />
698
+ <DescriptionList layout="stacked" columns={4} truncate items={[{ label: 'Stato', value: <Badge variant="success">Spedito</Badge> }]} />
699
+ ```
700
+
701
+ | Prop | Tipo | Default |
702
+ |---|---|---|
703
+ | `items` | `DescriptionListItem[]` — `{ key?, label, value?, title? }` | — |
704
+ | `layout` | `'horizontal' \| 'stacked'` | `'horizontal'` (etichette a sinistra, impilate su mobile) |
705
+ | `columns` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | `1` (solo `stacked`, dal breakpoint `lg`) |
706
+ | `labelWidth` | `string` (lunghezza CSS) | `'12rem'` (solo `horizontal`) |
707
+ | `emptyValue` | `ReactNode` | `'—'` per `null` / `undefined` / `''` (lo `0` resta) |
708
+ | `truncate` | `boolean` | `false`; un valore testuale diventa il suo `title` |
709
+ | Classi | `wrapperClassName`, `className`, `itemClassName`, `labelClassName`, `valueClassName` | tema `DescriptionList` |
710
+
711
+ ### Dropdown
712
+ ```tsx
713
+ <Dropdown trigger={<Icon name="menu" />} position="end">
714
+ <DropdownItem label="Modifica" icon="edit" onClick={() => {}} />
715
+ <DropdownItem label="Elimina" icon="trash" variant="danger" />
716
+ <DropdownDivider />
717
+ <DropdownHeader label="Azioni rapide" />
718
+ </Dropdown>
719
+ ```
720
+
721
+ Per elenchi caricati dinamicamente usare `AsyncDropdown`: gestisce ricerca con debounce,
722
+ cancellazione delle richieste precedenti tramite `AbortSignal` e stati loading/empty/error.
723
+
724
+ ```tsx
725
+ <AsyncDropdown
726
+ trigger={<ActionButton label="Select user" />}
727
+ loadItems={(query, signal) => users.search(query, { signal })}
728
+ getItemId={user => user.id}
729
+ renderItem={user => user.name}
730
+ onSelect={user => setUser(user)}
731
+ selectedId={user?.id}
732
+ />
733
+ ```
734
+
735
+ ### ContextMenu
736
+ ```tsx
737
+ <ContextMenu trigger="/" searchable>
738
+ <ContextMenu.Heading>Comandi</ContextMenu.Heading>
739
+ <ContextMenu.Item label="Titolo" value="# " icon="heading" />
740
+ <ContextMenu.Separator />
741
+ <ContextMenu.Item label="Bold" value="**bold**" icon="bold" />
742
+ </ContextMenu>
743
+ ```
744
+ Funziona su qualsiasi `<textarea>` / `<input>` figlio. Trigger custom (`/`, `@`, `{`), filtro searchable, navigazione tastiera, floating positioning.
745
+
746
+ | Prop | Tipo | Note |
747
+ |---|---|---|
748
+ | `trigger` | `string` (required) | Es. `"/"`, `"@"`, `"{"` |
749
+ | `searchable` | `boolean` | Filtro testuale |
750
+ | `controlled` | `ContextMenuControlledState` | Stato controllato esterno |
751
+ | `onSelect` | `(item, context) => void` | |
752
+
753
+ **EditorCommand** (usato da `commands` prop su `RichText`, `CodeEditor`, `Prompt`):
754
+ ```ts
755
+ {
756
+ name: string;
757
+ description?: string;
758
+ icon?: string;
759
+ group?: string;
760
+ consumeSuffix?: string;
761
+ handler?: (context: TextCommandContext) => string | Promise<string>;
762
+ }
763
+ ```
764
+
765
+ `consumeSuffix` elimina una chiusura automatica adiacente durante la sostituzione del trigger,
766
+ utile quando un comando cambia famiglia sintattica (per esempio da `{{ ... }}` a `{% ... %}`).
767
+
768
+ ### Altre primitive
769
+ ```tsx
770
+ <Icon name="user" size={20} /> // usa provider icone configurato
771
+ <ImageAvatar src="..." title="Mario" alt="Mario" width={40} height={40} />
772
+ <Loader show={isLoading}>{content}</Loader> // indicatore al posto dei figli finché show è true
773
+ <Loader show>{null}</Loader> // solo indicatore (es. pagina in caricamento)
774
+ <Code language="tsx">{snippet}</Code> // syntax highlight
775
+ <GridSystem.Row><GridSystem.Col xs={12} md={6}>...</GridSystem.Col></GridSystem.Row>
776
+ <LocaleSwitcher locales={[{ code: 'it', label: 'Italiano' }]} />
777
+ <ThemeSwitcher />
778
+ <ProviderSwitcher />
779
+ <LayoutBuilder components={...} />
780
+ ```
781
+
782
+ ---
783
+
784
+ ## Blocks
785
+
786
+ ```tsx
787
+ <Breadcrumbs trail={[{ label: 'Home', href: '/' }, { label: 'Utenti' }]} separator="/" jsonLd />
788
+ <Brand url="/" label="MyApp" logo="/logo.svg" width={120} />
789
+ <Search onQueryChange={(e) => setQ(e.target.value)} />
790
+ <Notifications items={[...]} seeAllUrl="/notifications" onMarkAllRead={() => {}} />
791
+ <Carousel images={[...]} />
792
+ <Toolbar items={[...]}>
793
+ {children}
794
+ </Toolbar>
795
+ <SideNav />
796
+ <ListCard records={records} onRowClick={(r) => {}} />
797
+ ```
798
+
799
+ **Brand:**
800
+ | Prop | Tipo |
801
+ |---|---|
802
+ | `url` | `string` |
803
+ | `label` | `string` |
804
+ | `logo` | `string` |
805
+ | `width` / `height` | `number` |
806
+ | `wrapperClassName` / `className` / `logoClassName` / `labelClassName` | `string` |
807
+
808
+ **SideNav:**
809
+ | Prop | Tipo |
810
+ |---|---|
811
+ | `items` | `SideNavItemDef[]` (in alternativa a `menuKey`) |
812
+ | `menuKey` | `string` — voci dal menu registrato in `<App>` |
813
+ | `bottomItems` | `SideNavItemDef[]` — voci fissate in fondo, sopra il footer e fuori dalla lista scorrevole (es. Impostazioni) |
814
+ | `header` / `footer` | `ReactNode` |
815
+ | `collapsed` + `onCollapsedChange` / `defaultCollapsed` | `boolean` — compressione controllata o no |
816
+ | `showIcons` / `showCollapseButton` | `boolean` (default `true`) |
817
+ | `embedded` | `boolean` — solo le voci, senza guscio (drawer, pannelli); `bottomItems` seguono le voci principali |
818
+
819
+ `SideNavItemDef`: `{ path, title?, icon?, group?, end?, badge?, children? }`. La voce attiva ha
820
+ `aria-current="page"`.
821
+
822
+ ```tsx
823
+ <SideNav
824
+ items={[{ path: '/dashboard', title: 'Dashboard', icon: 'home' }]}
825
+ bottomItems={[{ path: '/settings', title: 'Impostazioni', icon: 'settings' }]}
826
+ />
827
+ ```
828
+
829
+ **ImageAvatar:**
830
+ | Prop | Tipo |
831
+ |---|---|
832
+ | `src` | `string` (obbligatoria) |
833
+ | `width` / `height` | `number` |
834
+ | `title` / `alt` | `string` |
835
+ | `fit` | `AvatarFit` |
836
+ | `badge` | `BadgeDescriptor` |
837
+ | `feedback` | `ReactNode` |
838
+
839
+ **Search:**
840
+ | Prop | Tipo |
841
+ |---|---|
842
+ | `onQueryChange` | `(e: React.ChangeEvent<HTMLInputElement>) => void` |
843
+
844
+ **Notifications:**
845
+ | Prop | Tipo |
846
+ |---|---|
847
+ | `items` | `NotificationItem[]` |
848
+ | `badge` | `BadgeProps` |
849
+ | `seeAllUrl` | `string` |
850
+ | `onMarkAllRead` | `() => void` |
851
+ | `motion` | `MotionReference` |
852
+
853
+ **ListCard:**
854
+ | Prop | Tipo |
855
+ |---|---|
856
+ | `title` / `description` / `meta` | `ReactNode` |
857
+ | `badge` | `ReactNode` |
858
+ | `icon` | `string \| ReactNode` |
859
+ | `trailing` / `footer` / `children` | `ReactNode` |
860
+ | `href` | `string` |
861
+ | `external` / `active` / `disabled` / `dashed` / `compact` | `boolean` |
862
+ | `align` | `'start' \| 'center'` |
863
+ | `onClick` | `(e) => void` |
864
+ | Classi CSS: `titleClassName`, `descriptionClassName`, `metaClassName`, `badgeClassName`, `leadingClassName`, `bodyClassName`, `headerClassName`, `trailingClassName`, `footerClassName` | `string` |
865
+
866
+ **Dropdown:**
867
+ | Prop | Tipo |
868
+ |---|---|
869
+ | `trigger` | `string \| ReactNode \| DropdownTogglerProps` |
870
+ | `badge` | `BadgeProps` |
871
+ | `header` / `footer` | `ReactNode` |
872
+ | `defaultOpen` / `open` | `boolean` |
873
+ | `onOpenChange` | `(open) => void` |
874
+ | `position` | `'start' \| 'end'` |
875
+ | `placement` | `'auto' \| 'top' \| 'bottom'` |
876
+ | `strategy` | `'fixed' \| 'absolute'` |
877
+
878
+ // Menu — renderizza una sezione del menuConfig passato ad <App>
879
+ <Menu menuKey="main" /> // 'main' o qualsiasi chiave definita in MenuConfig
880
+ <Menu menuKey="topbar" className="nav flex-row gap-2" />
881
+ // NON usare context= — la prop si chiama menuKey
882
+
883
+ // AuthButton — login/logout con il provider auth configurato
884
+ <AuthButton />
885
+ ```
886
+
887
+ ---
888
+
889
+ ## Widgets
890
+
891
+ ### MarkdownReader
892
+ ```tsx
893
+ <MarkdownReader
894
+ content={markdownString}
895
+ onInternalLinkClick={(href, e) => { e.preventDefault(); navigate(href) }}
896
+ wrapperClassName="prose"
897
+ />
898
+ ```
899
+
900
+ ### ImageEditor
901
+ ```tsx
902
+ <ImageEditor src="..." onSave={(dataUrl) => save(dataUrl)} />
903
+ ```
904
+
905
+ `ImageEditor` e un singolo componente pubblico. La libreria pesante `tui-image-editor` viene importata lazy a runtime dentro il componente, quindi non viene scaricata finche l'editor non viene effettivamente montato.
906
+
907
+ ### TabDynamic
908
+ Tab con lazy loading dei contenuti — gestione dinamica aggiunta/rimozione tab.
909
+
910
+ ---
911
+
912
+ ## AI Integration
913
+
914
+ ```ts
915
+ import { useAIProvider, getAIModelCatalog, createAIProviderRegistry } from '@llmnative/react'
916
+
917
+ // Hooks
918
+ const provider = useAIProvider() // provider di default
919
+ const registry = useAIProviderRegistry() // tutti i provider AI registrati
920
+ const catalog = getAIModelCatalog() // modelli disponibili
921
+ // ogni modello: { id, provider, model, label, pricing? } — pricing in USD per 1M token
922
+ // { input, output, currency: 'USD', source: 'provider' | 'models.dev' }; assente = prezzo
923
+ // non trovato (il modello resta in lista). isFreeAIModel(m) → input e output a 0.
924
+ // Il model picker di Prompt/Chatbot mostra "$in / $out", evidenzia i free, segnala i mancanti,
925
+ // e raggruppa per provider (ChatbotModelOption.group = providerLabel) con header sticky.
926
+
927
+ // Provider adapter restituito da useAIProvider()
928
+ const text = await provider.fetch("Scrivi un titolo per: {keyword}", { keyword: "React" })
929
+ const json = await provider.json("Lista di 5 categorie per blog tech")
930
+ const arr = await provider.array("5 tag per: machine learning")
931
+
932
+ // Provider custom
933
+ class MyAIProvider implements AIProviderAdapter {
934
+ async fetch(prompt: string, options?: AIRequestOptions): Promise<string> { ... }
935
+ async json<T>(prompt: string, options?: AIRequestOptions): Promise<T> { ... }
936
+ async array(prompt: string, options?: AIRequestOptions): Promise<string[]> { ... }
937
+ }
938
+
939
+ // Factory per registri esterni
940
+ const registry = createAIProviderRegistry(definitions)
941
+ ```
942
+
943
+ Configurazione API key in `Config.tsx`: `geminiApiKey`, `openaiApiKey`, `anthropicApiKey`, `deepSeekApiKey`, `mistralApiKey`, `openRouterApiKey`, `groqApiKey`, `cloudflare: { apiToken, accountId }` (Cloudflare Workers AI: l'account id fa parte dell'URL; discovery via `ai/models/search`, modelli Workers Paid esclusi salvo `includePaidModels`).
944
+
945
+ ---
946
+
947
+ ## Theme e icone
948
+
949
+ ```tsx
950
+ const theme = useThemeController()
951
+ theme.applyTheme('flat') // 'default' | 'flat' | 'cyber' | custom
952
+ theme.setMode('dark') // 'light' | 'dark' | 'system'
953
+
954
+ const icons = useIconController()
955
+ icons.setProvider('phosphor') // 'lucide' | 'phosphor' | custom
956
+ ```
957
+
958
+ ---
959
+
960
+ ## Head (SEO)
961
+
962
+ ```ts
963
+ useHead({
964
+ title: 'Pagina',
965
+ description: '...',
966
+ robots: 'noindex',
967
+ canonical: 'https://...',
968
+ og: { title: '...', image: '...' }
969
+ })
970
+ ```
971
+
972
+ ---
973
+
974
+ ## Global state (localStorage)
975
+
976
+ ```ts
977
+ const vars = useGlobalVars()
978
+ getGlobalVars('key')
979
+ setGlobalVars({ key: 'value' })
980
+ removeGlobalVars('key')
981
+ ```
982
+
983
+ ---
984
+
985
+ ## Hooks completi
986
+
987
+ | Hook | Ritorna |
988
+ |---|---|
989
+ | `useDataProvider(name?)` | `DataProviderAdapter \| null` |
990
+ | `useStorageProvider(name?)` | `StorageProviderAdapter \| null` |
991
+ | `useAuthProvider(name?)` | `AuthProviderAdapter \| null` |
992
+ | `useEmailProvider(name?)` | `EmailProviderAdapter \| null` |
993
+ | `useAIProvider(name?)` | `AIProviderAdapter \| null` |
994
+ | `useAIProviderRegistry()` | `Record<string, AIProviderAdapter>` |
995
+ | `useCredentialsProvider()` | `CredentialsAdapter \| null` |
996
+ | `useTheme()` | `Theme \| undefined` |
997
+ | `useThemeController()` | `ThemeController` |
998
+ | `useIconController()` | `IconController` |
999
+ | `useAccessToken(clientID, renew?)` | `boolean` | Ottiene token per servizio esterno (true = ok) |
1000
+ | `useGlobalVars()` | `Record<string, unknown>` |
1001
+ | `useHead()` | `HeadController` |
1002
+ | `useDocumentHead()` | `HeadController` |
1003
+ | `useAssetsHead(title?)` | `void` |
1004
+ | `useLanguageHead(lang?)` | `void` |
1005
+ | `useSocialHead(config)` | `void` |
1006
+ | `usePaginationHead(config)` | `void` |
1007
+ | `usePwaHead(config)` | `void` |
1008
+ | `useSchemaOrgHead(config)` | `void` |
1009
+ | `useHeadLinks(links)` | `void` |
1010
+ | `useProxy()` | `(url, init?) => Promise<Response>` |
1011
+ | `useImage()` | `UseImageResult` |
1012
+ | `useI18n()` | `I18nController` |
1013
+ | `useMenu()` | Menu hook |
1014
+ | `useFormController(controller?)` | `FormController` |
1015
+ | `useFormActions()` | `FormController` |
1016
+ | `useApiProvider(pathname?)` | `ApiProviderAdapter \| null` |
1017
+ | `useSetProvider(service, driver?)` | `void` |
1018
+ | `useProvider(name)` | `ProviderConfigurationState` |
1019
+ | `useProviderSession()` | `ProviderSession` |
1020
+ | `useDropBoxConnect(config?)` | `DropBoxConnectResult` |
1021
+ | `useEnterMotion(baseStyle?, ref?, fallback?)` | `CSSProperties` |
1022
+ | `useMotionState(active, ref?, fallback?, baseStyle?)` | `CSSProperties` |
1023
+ | `useMotionEffect(ref?, fallback?)` | `ResolvedMotionEffect` |
1024
+ | `usePressMotion(hovered, baseStyle?, ref?, fallback?)` | `{ style, pressHandlers }` |
1025
+ | `useIconProvider()` | `IconProvider` |
1026
+
1027
+ ---
1028
+
1029
+ ## Tipi chiave
1030
+
1031
+ ```ts
1032
+ type RecordProps = Record<string, FieldValue>
1033
+ type FieldValue = string | number | boolean | null | undefined | Record<string, unknown> | unknown[]
1034
+ type RecordArray = RecordProps[]
1035
+
1036
+ type OrderConfig = { field: string; dir: 'asc' | 'desc' }
1037
+ type WhereClause = { field: string; op: '==' | '!=' | '<' | '<=' | '>' | '>=' | 'in' | 'not-in' | 'array-contains' | 'array-contains-any'; value: unknown }
1038
+
1039
+ interface UIProps {
1040
+ before?: React.ReactNode
1041
+ after?: React.ReactNode
1042
+ wrapperClassName?: string
1043
+ className?: string
1044
+ }
1045
+
1046
+ type UserProfile = { uid?: string; email?: string; displayName?: string; photoURL?: string; [key: string]: unknown }
1047
+ ```
1048
+
1049
+ ---
1050
+
1051
+ ## Utilities
1052
+
1053
+ ```ts
1054
+ import { cn, converter, Sanitizer, Order, path, fetchJson, fetchRest, fetchWithRetry } from '@llmnative/react'
1055
+ import { getKeywordIdeas, getGoogleTrendsData, getGoogleTrendsRelated } from '@llmnative/react'
1056
+
1057
+ cn('base', condition && 'extra', className) // tailwind-merge classname
1058
+ new Sanitizer().sanitize(html) // XSS sanitizer (non usare dangerouslySetInnerHTML)
1059
+ new Order().order(records, { field: 'name', dir: 'asc' }) // ordina record array
1060
+ fetchJson(url, init?) // fetch + JSON parse + error handling
1061
+ fetchRest(path, init?) // fetch REST con auth
1062
+ getKeywordIdeas(keyword) // Google Ads Keyword Ideas
1063
+ getGoogleTrendsData(keyword) // Google Trends interest over time
1064
+ getGoogleTrendsRelated(keyword) // Google Trends related queries
1065
+ ```
1066
+
1067
+ ---
1068
+
1069
+ ## Scaffold (npx create)
1070
+
1071
+ ```bash
1072
+ npx @llmnative/react create
1073
+ npx @llmnative/react create --yes --provider=mock --template=admin
1074
+ ```
1075
+
1076
+ Flag disponibili: `--name` `--provider=firebase|supabase|mock|custom` `--ai-provider` `--icon-provider` `--theme` `--template=blank|admin|crm|inventory|project`
1077
+
1078
+ **5 template disponibili:**
1079
+ | Template | Pagine incluse | Uso tipico |
1080
+ |---|---|---|
1081
+ | `blank` | home | App vuota — massima libertà |
1082
+ | `admin` | home, users, roles, settings | Dashboard admin/backoffice |
1083
+ | `crm` | home, companies, contacts, deals | CRM |
1084
+ | `inventory` | home + gestione inventario | Magazzino |
1085
+ | `project` | home + gestione progetti | PM tool |
1086
+
1087
+ **Struttura generata in-place nella current directory:**
1088
+ ```
1089
+ src/
1090
+ index.tsx ← App entry
1091
+ vite-env.d.ts
1092
+ conf/
1093
+ app.ts ← providers config letta da .env (VITE_PROVIDER, VITE_AI_PROVIDER...)
1094
+ menu.ts ← MenuConfig con path, title, icon, page (layout opzionale: c'è LayoutDefault)
1095
+ data/
1096
+ mockData.ts ← dati fake per MockDataProvider
1097
+ layouts/
1098
+ Default.tsx ← layout wrapper (header + sidebar + main + footer)
1099
+ sections/
1100
+ Header.tsx / Sidebar.tsx / PageHeader.tsx / Footer.tsx ← solo componenti pubblici (SideNav, AuthButton, ActionButton, Brand, Breadcrumbs)
1101
+ pages/
1102
+ home/HomePage.tsx
1103
+ [altre pagine dal template scelto]
1104
+ styles/
1105
+ globals.css ← @import '@llmnative/react/dist/index.css' + Tailwind override
1106
+ .env ← VITE_PROVIDER, VITE_THEME, chiavi API (non committare)
1107
+ .env.example ← stessa struttura, valori vuoti (committare)
1108
+ vite.config.ts / tsconfig.json / postcss.config.js / index.html
1109
+ ```
1110
+
1111
+ **Pattern conf/app.ts** (auto-generato, leggere per capire come funziona):
1112
+ ```ts
1113
+ import type { AppProvidersConfig } from '@llmnative/react'
1114
+ import { mockData } from '../data/mockData'
1115
+ const env = import.meta.env
1116
+
1117
+ export const providers: AppProvidersConfig = {
1118
+ mock: { data: mockData },
1119
+ ai: { openaiApiKey: env.VITE_OPENAI_API_KEY ?? '' }, // AI dentro providers, non prop <App>
1120
+ services: { data: 'mock', auth: 'googleAuth' },
1121
+ }
1122
+ ```
1123
+
1124
+ Driver per `VITE_PROVIDER`: `firebase` → `firestoreDb` + `firestorage` + `firebaseAuth`; `supabase` → `supabaseDb` + `supabaseStorage` + `supabaseAuth`; `mock` → `mock` + `googleAuth`. `index.tsx` passa `LayoutDefault={Default}` ad `<App>`.
1125
+
1126
+ ---
1127
+
1128
+ ## Gotchas — pitfall frequenti
1129
+
1130
+ | Errore | Causa | Fix |
1131
+ |---|---|---|
1132
+ | `aiConfig` prop non esiste su `<App>` | La config AI va dentro `providers.ai`, NON come prop separata | `providers={{ ai: { openaiApiKey } }}` |
1133
+ | `inputType` o `Input.Number`/`Input.Email` non esistono | Il tipo HTML è la prop `type` | `<Input type="number">`, `<Input type="email">`, `<Input type="date">` |
1134
+ | `Menu context=` non esiste | La prop si chiama `menuKey` | `<Menu menuKey="main" />` |
1135
+ | `SignInButton` non esiste | Rinominato | `<AuthButton />` |
1136
+ | `firebase: { config: ... }` errato | FirebaseConfig è flat | `firebase: { apiKey, authDomain, databaseURL, ... }` |
1137
+ | `google: { oAuth2: { clientId } }` errato | GoogleProviderConfig è flat | `google: { clientId, scope }` |
1138
+ | `children` su `Grid` | Grid non accetta children | Usa `form={<Fields />}` per il form inline |
1139
+ | `recordId` su `Form` | Prop non esiste | La chiave del record viene da URL automaticamente |
1140
+ | `MenuConfig` non importabile | Era type interno | Ora esportato: `import type { MenuConfig } from '@llmnative/react'` |
1141
+ | `GridCellContext` non importabile | Mancava dall'index | Ora esportato: `import type { GridCellContext } from '@llmnative/react'` |
1142
+
1143
+ ---
1144
+
1145
+ ## Dove cercare nel codice sorgente
1146
+
1147
+ | Cosa | File |
1148
+ |---|---|
1149
+ | Form | `src/components/widgets/Form.tsx` |
1150
+ | Grid | `src/components/widgets/Grid.tsx` |
1151
+ | Prompt | `src/components/widgets/Prompt.tsx` |
1152
+ | Campi form | `src/components/ui/fields/` |
1153
+ | Blocks (Menu, Toolbar, Notifications, Dropdown…) | `src/components/blocks/` |
1154
+ | Provider data Firebase (RTDB) | `src/providers/data/firebase.ts` |
1155
+ | Provider data Firestore | `src/providers/data/firestore.ts` |
1156
+ | Provider data Supabase | `src/providers/data/supabase.ts` |
1157
+ | Provider storage Firebase | `src/providers/storage/firebase.ts` |
1158
+ | Provider storage Supabase | `src/providers/storage/supabase.ts` |
1159
+ | Provider auth Firebase | `src/providers/auth/firebase/FirebaseAuthProvider.ts` |
1160
+ | Provider auth Supabase | `src/providers/auth/supabase/` |
1161
+ | Provider auth Google | `src/providers/auth/google/` |
1162
+ | Provider auth Dropbox | `src/providers/auth/dropbox/` |
1163
+ | Provider email Gmail | `src/providers/email/google/GmailEmailProvider.ts` |
1164
+ | Provider AI definitions | `src/providers/ai/` |
1165
+ | Provider credenziali | `src/providers/credentials/` |
1166
+ | Provider session | `src/providers/ProviderSession.ts` |
1167
+ | Proxy CORS | `src/providers/proxy.ts` |
1168
+ | Motion system | `src/motion.ts` |
1169
+ | I18n | `src/I18n.tsx` |
1170
+ | Dropbox integration | `src/providers/dropbox/` |
1171
+ | Interfaccia DataProvider | `src/providers/data/DataProvider.ts` |
1172
+ | Interfaccia StorageProvider | `src/providers/storage/StorageProvider.ts` |
1173
+ | Interfaccia AuthProvider | `src/providers/auth/AuthProvider.ts` |
1174
+ | Interfaccia EmailProvider | `src/providers/email/EmailProvider.ts` |
1175
+ | Interfaccia AIProvider | `src/providers/ai/AIProvider.ts` |
1176
+ | Registry provider | `src/providers/manifest.ts` |
1177
+ | Config provider | `src/providers/ProviderConfiguration.ts` |
1178
+ | SEO / Google Trends | `src/providers/seo/google/` |
1179
+ | Sistema tema | `src/Theme.tsx` |
1180
+ | Config runtime | `src/Config.tsx` |
1181
+ | Tipi TypeScript | `src/types/` |
1182
+ | Utilities pure | `src/libs/` |