runeforge 0.0.55 → 0.0.56

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.
Files changed (54) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +1342 -1342
  3. package/dist/components/Avatar.svelte +31 -31
  4. package/dist/components/IconRenderer.svelte +22 -22
  5. package/dist/components/Modal.svelte +75 -75
  6. package/dist/components/common/Header.svelte +40 -40
  7. package/dist/components/crud/EmbeddedField.svelte +175 -175
  8. package/dist/components/crud/Field.svelte +469 -376
  9. package/dist/components/crud/GenericCRUD.svelte +426 -426
  10. package/dist/components/crud/SearchInput.svelte +53 -53
  11. package/dist/components/crud/columns/Avatar.svelte +15 -15
  12. package/dist/components/crud/columns/Icon.svelte +8 -8
  13. package/dist/components/crud/views/Create.svelte +232 -232
  14. package/dist/components/crud/views/Read.svelte +124 -124
  15. package/dist/components/crud/views/Update.svelte +208 -208
  16. package/dist/components/crud/views/list/List.svelte +291 -291
  17. package/dist/components/crud/views/list/Modals.svelte +56 -56
  18. package/dist/components/crud/views/list/Table.svelte +176 -176
  19. package/dist/components/crud/views/list/Toolbar.svelte +341 -341
  20. package/dist/components/form/Button.svelte +27 -27
  21. package/dist/components/form/Label.svelte +37 -37
  22. package/dist/components/form/MultiSelect.svelte +248 -248
  23. package/dist/components/form/PasswordInput.svelte +68 -68
  24. package/dist/components/form/Required.svelte +1 -1
  25. package/dist/components/form/Select.svelte +209 -209
  26. package/dist/components/form/Tree.svelte +62 -62
  27. package/dist/components/form/TreeNode.svelte +66 -66
  28. package/dist/components/navigation/Breadcrumbs.svelte +111 -111
  29. package/dist/components/table/ColumnFilter.svelte +168 -168
  30. package/dist/components/table/PaginatedTable.svelte +536 -536
  31. package/dist/components/table/Paginator.svelte +113 -113
  32. package/dist/components/table/SortHeader.svelte +43 -43
  33. package/dist/components/table/TableBody.svelte +150 -150
  34. package/dist/components/table/TableHeader.svelte +88 -88
  35. package/dist/i18n/en.js +1 -0
  36. package/dist/i18n/es.js +1 -0
  37. package/dist/i18n/types.d.ts +1 -0
  38. package/dist/icons/defaults/Clear.svelte +6 -6
  39. package/dist/icons/defaults/Create.svelte +6 -6
  40. package/dist/icons/defaults/Delete.svelte +6 -6
  41. package/dist/icons/defaults/Download.svelte +7 -7
  42. package/dist/icons/defaults/Edit.svelte +7 -7
  43. package/dist/icons/defaults/Filter.svelte +6 -6
  44. package/dist/icons/defaults/FilterActive.svelte +6 -6
  45. package/dist/icons/defaults/Folder.svelte +6 -6
  46. package/dist/icons/defaults/Grip.svelte +7 -7
  47. package/dist/icons/defaults/Home.svelte +6 -6
  48. package/dist/icons/defaults/PasswordHide.svelte +9 -9
  49. package/dist/icons/defaults/PasswordShow.svelte +7 -7
  50. package/dist/icons/defaults/SortAsc.svelte +6 -6
  51. package/dist/icons/defaults/SortDesc.svelte +6 -6
  52. package/dist/icons/defaults/SortNone.svelte +6 -6
  53. package/dist/icons/defaults/View.svelte +7 -7
  54. package/package.json +1 -1
package/README.md CHANGED
@@ -1,1342 +1,1342 @@
1
- <div align="center">
2
-
3
- <img src="docs/logo.png" alt="Runeforge logo" width="300" />
4
-
5
- # Runeforge
6
-
7
- A SvelteKit toolkit that forges forms, tables, actions, and CRUD workflows from reusable definitions.
8
-
9
- <img src="docs/crud-list.png" alt="CRUD list view" />
10
-
11
- </div>
12
-
13
- ---
14
-
15
- ## Table of Contents
16
-
17
- - [Runeforge](#runeforge)
18
- - [Table of Contents](#table-of-contents)
19
- - [Introduction](#introduction)
20
- - [Requirements](#requirements)
21
- - [Key Features](#key-features)
22
- - [Installation](#installation)
23
- - [Theming](#theming)
24
- - [Tailwind source scanning](#tailwind-source-scanning)
25
- - [CSS variables](#css-variables)
26
- - [Configuration](#configuration)
27
- - [Basic Usage](#basic-usage)
28
- - [1. Define your interface and metadata](#1-define-your-interface-and-metadata)
29
- - [2. Create the model](#2-create-the-model)
30
- - [3. Set up the server](#3-set-up-the-server)
31
- - [4. Add the page component](#4-add-the-page-component)
32
- - [Field System](#field-system)
33
- - [Attribute reference](#attribute-reference)
34
- - [Validation](#validation)
35
- - [Conditional fields](#conditional-fields)
36
- - [Field grouping](#field-grouping)
37
- - [Field rows](#field-rows)
38
- - [Default values](#default-values)
39
- - [Select options](#select-options)
40
- - [Multiselect fields](#multiselect-fields)
41
- - [Tree fields](#tree-fields)
42
- - [Embedded fields (sub-documents)](#embedded-fields-sub-documents)
43
- - [Components](#components)
44
- - [GenericCRUD](#genericcrud)
45
- - [Free-text search](#free-text-search)
46
- - [Custom row actions](#custom-row-actions)
47
- - [Custom bulk actions](#custom-bulk-actions)
48
- - [Exporting data (CSV/XLSX)](#exporting-data-csvxlsx)
49
- - [Reordering rows](#reordering-rows)
50
- - [Server-side pagination, sorting & filtering](#server-side-pagination-sorting--filtering)
51
- - [PaginatedTable](#paginatedtable)
52
- - [Form Components](#form-components)
53
- - [Shared Components](#shared-components)
54
- - [Formatters](#formatters)
55
- - [`formatBoolean`](#formatboolean)
56
- - [`formatDatetime`](#formatdatetime)
57
- - [`formatTruncateTextUpTo`](#formattruncatetextupto)
58
- - [`formatInstance`](#formatinstance)
59
- - [Custom Cell Components](#custom-cell-components)
60
- - [Example: avatar column](#example-avatar-column)
61
- - [Example: icon column](#example-icon-column)
62
- - [Internationalization](#internationalization)
63
- - [Switch to English](#switch-to-english)
64
- - [Override individual strings](#override-individual-strings)
65
- - [Full `RuneforgeStrings` reference](#full-runeforgestrings-reference)
66
- - [Bundled locales](#bundled-locales)
67
- - [Icon System](#icon-system)
68
- - [Running Tests](#running-tests)
69
- - [Unit Tests](#unit-tests)
70
- - [End-to-End Tests](#end-to-end-tests)
71
- - [Run All Tests](#run-all-tests)
72
- - [Development](#development)
73
- - [License](#license)
74
-
75
- ---
76
-
77
- ## Introduction
78
-
79
- Runeforge provides a set of composable, metadata-driven components for building data-heavy interfaces in SvelteKit. It handles the repetitive parts of CRUD UIs — listing records, creating and editing forms, sorting and filtering tables — through a declarative API built on top of [DaisyUI](https://daisyui.com/) and [Tailwind CSS](https://tailwindcss.com/).
80
-
81
- ---
82
-
83
- ## Requirements
84
-
85
- - SvelteKit 2+
86
- - Svelte 5 (runes mode)
87
- - Tailwind CSS 4
88
- - DaisyUI 5
89
- - Cally
90
- - `sortablejs` (optional, only if you enable [drag-to-reorder](#reordering-rows))
91
- - `xlsx` (optional, only if you enable Excel export)
92
-
93
- ---
94
-
95
- ## Key Features
96
-
97
- - **GenericCRUD** — a single orchestrator component that wires together list, create, read, and update views from field and column definitions.
98
- - **PaginatedTable** — a full-featured table with sorting, filtering, pagination, and row selection, usable either fully client-side or driven by a server-paginated backend.
99
- - **Field system** — declarative field definitions that drive both form rendering and display, supporting text, email, password, number, boolean, textarea, file, select, multiselect, tree, datetime, and embedded (sub-document list) types.
100
- - **Validation** — built-in `required`, `min`/`max`, `integer`, `minLength`/`maxLength`, and `pattern` rules, checked client-side before submit with consistent, translatable error messages.
101
- - **Conditional fields & field grouping** — disable, or entirely hide, a field based on the current values of others in the same form, and visually group related fields under a titled `fieldset`.
102
- - **Smart select fields** — options can be static, computed from page data, dependent on another field's value, or resolved live from the server as the user types. `multiselect` supports the same resolvers for a checkbox-style multiple-choice list.
103
- - **Tree fields** — a hierarchical, cascading-selection picker (e.g. categories with parent/child relationships) driven by a flat option list with a `parentValue` link.
104
- - **Embedded fields** — model one-to-many sub-documents (e.g. line items, adjustments) as an in-form add/edit list backed by a single JSON field.
105
- - **Custom row & bulk actions** — add entity-specific actions (in a panel or via redirect) alongside the built-in view/edit/delete, and bulk actions that operate on the current selection.
106
- - **CSV/XLSX export** — one-click export of the current table view, with optional Excel support via the `xlsx` package.
107
- - **Drag-to-reorder** — an opt-in row-reordering layer that persists a sequential order attribute after each drag.
108
- - **Server-side pagination, sorting & filtering** — point `GenericCRUD`/`PaginatedTable` at a paginated envelope and it drives page/sort/filter state through the URL for you.
109
- - **Pluggable icon system** — swap the default icon set or use the included Bootstrap Icons alternative via `setIconSet`.
110
- - **Standalone components** — table, form, and navigation components can be used independently without the full CRUD orchestrator.
111
-
112
- ---
113
-
114
- ## Installation
115
-
116
- ```bash
117
- pnpm add runeforge
118
- ```
119
-
120
- ---
121
-
122
- ## Theming
123
-
124
- ### Tailwind source scanning
125
-
126
- Runeforge components use Tailwind CSS utility classes internally. Tailwind v4 does not scan `node_modules` by default, so add an `@source` directive in your project's `app.css` to ensure every utility class is generated:
127
-
128
- ```css
129
- @import "tailwindcss";
130
- @source "../node_modules/runeforge/dist";
131
- @plugin "daisyui";
132
- ```
133
-
134
- ### CSS variables
135
-
136
- Key visual properties are exposed as CSS custom properties so you can tune them per project. Set them on `:root` (or any narrower selector) in your `app.css`:
137
-
138
- ```css
139
- :root {
140
- --runeforge-crud-title-size: 1.875rem;
141
- --runeforge-breadcrumb-font-size: 0.875rem;
142
- --runeforge-breadcrumb-icon-size: 1rem;
143
- }
144
- ```
145
-
146
- Responsive overrides work too:
147
-
148
- ```css
149
- :root {
150
- --runeforge-crud-title-size: 1.25rem;
151
- }
152
- @media (min-width: 768px) {
153
- :root {
154
- --runeforge-crud-title-size: 1.875rem;
155
- }
156
- }
157
- ```
158
-
159
- | Variable | Default | Affects |
160
- | --- | --- | --- |
161
- | `--runeforge-crud-max-width` | _(none)_ | Max width of the Header and List view; centers them when set |
162
- | `--runeforge-form-max-width` | `32rem` | Max width of the form/fields panel in Create, Update, and Read views |
163
- | `--runeforge-crud-title-size` | `1.875rem` | `<h1>` inside the `Header` component |
164
- | `--runeforge-breadcrumb-font-size` | `0.875rem` | Breadcrumb label text size |
165
- | `--runeforge-breadcrumb-icon-size` | `1rem` | Breadcrumb icon width and height |
166
- | `--runeforge-tree-max-height` | `24rem` | Max height of a `tree` field before it scrolls internally |
167
-
168
- Modal sizing (see [Shared Components](#shared-components)) is set per-instance via props rather than a CSS variable.
169
-
170
- ---
171
-
172
- ## Configuration
173
-
174
- Global settings are applied once in your root layout via `setConfig`. This avoids passing the same prop to every CRUD component.
175
-
176
- ```ts
177
- <!-- +layout.svelte -->
178
- <script>
179
- import { setConfig } from 'runeforge';
180
-
181
- setConfig({ homeHref: '/admin' });
182
- </script>
183
- ```
184
-
185
- | Option | Default | Description |
186
- | --- | --- | --- |
187
- | `homeHref` | `'/'` | URL for the home crumb in every breadcrumb trail |
188
-
189
- ---
190
-
191
- ## Basic Usage
192
-
193
- ### 1. Define your interface and metadata
194
-
195
- ```ts
196
- // interface.ts
197
- import { AttributeType, type InterfaceMetadata } from 'runeforge';
198
- import { formatBoolean, formatDatetime } from 'runeforge';
199
-
200
- export interface IArticle {
201
- _id: string;
202
- title: string;
203
- published: boolean;
204
- createdAt: Date;
205
- }
206
-
207
- export const articleMeta = {
208
- title: {
209
- label: 'Title',
210
- type: AttributeType.text,
211
- placeholder: 'My article',
212
- required: true,
213
- },
214
- published: {
215
- label: 'Published',
216
- type: AttributeType.boolean,
217
- formatter: formatBoolean,
218
- default: false,
219
- required: true,
220
- },
221
- createdAt: {
222
- label: 'Created',
223
- type: AttributeType.datetime,
224
- formatter: formatDatetime(),
225
- excludedFromCreate: true,
226
- excludedFromUpdate: true
227
- },
228
- updatedAt: {
229
- label: 'Updated',
230
- type: AttributeType.datetime,
231
- formatter: formatDatetime(),
232
- excludedFromCreate: true,
233
- excludedFromUpdate: true
234
- },
235
- } satisfies InterfaceMetadata<IArticle>;
236
- ```
237
-
238
- Each metadata entry drives both the table column and the form field for that attribute. You can use `excludedFromList`, `excludedFromCreate`, `excludedFromRead`, or `excludedFromUpdate` to hide a field from specific views. The [Field System](#field-system) section below covers the full set of options — validation, conditional/grouped fields, smart selects, and embedded sub-documents.
239
-
240
- ### 2. Create the model
241
-
242
- ```ts
243
- // model.ts
244
- import crypto from 'node:crypto';
245
- import mongoose from 'mongoose';
246
- import type { IArticle } from './interface';
247
-
248
- const schema = new mongoose.Schema<IArticle>(
249
- {
250
- _id: { type: String, default: () => crypto.randomUUID() },
251
- title: { type: String, required: true, trim: true },
252
- published: { type: Boolean, required: true, default: false },
253
- },
254
- { timestamps: true }
255
- );
256
-
257
- export const Article = mongoose.models.Article ?? mongoose.model<IArticle>('Article', schema);
258
- ```
259
-
260
- ### 3. Set up the server
261
-
262
- ```ts
263
- // +page.server.ts
264
- import { fail, error } from '@sveltejs/kit';
265
- import { Article } from '$lib/server/articles/model';
266
- import type { Actions, PageServerLoad } from './$types';
267
- import type { IArticle } from './interface';
268
-
269
- export const load: PageServerLoad = async ({ url }) => {
270
- const id = url.searchParams.get('id');
271
- if (id) {
272
- const article = await Article.findById(id).lean<IArticle>();
273
- if (!article) error(404, 'Not found');
274
- return { article };
275
- }
276
- const articles = await Article.find({}).sort({ createdAt: -1 }).lean<IArticle[]>();
277
- return { articles };
278
- };
279
-
280
- export const actions: Actions = {
281
- create: async ({ request }) => {
282
- const data = await request.formData();
283
- const title = String(data.get('title') ?? '').trim();
284
- if (!title) return fail(400, { error: 'Title is required' });
285
- await Article.create({ title, published: data.has('published') });
286
- return { success: true };
287
- },
288
-
289
- update: async ({ request }) => {
290
- const data = await request.formData();
291
- const id = String(data.get('id') ?? '').trim();
292
- if (!id) return fail(400, { error: 'ID is required' });
293
- await Article.findByIdAndUpdate(id, {
294
- title: String(data.get('title') ?? '').trim(),
295
- published: data.has('published'),
296
- });
297
- return { success: true };
298
- },
299
-
300
- delete: async ({ request }) => {
301
- const data = await request.formData();
302
- const id = String(data.get('id') ?? '').trim();
303
- if (!id) return fail(400, { error: 'ID is required' });
304
- await Article.findByIdAndDelete(id);
305
- return { success: true };
306
- },
307
- };
308
- ```
309
-
310
- The `load` function returns a single record when `?id=` is present (used by the read/edit views), or the full list otherwise.
311
-
312
- ### 4. Add the page component
313
-
314
- ```html
315
- <!-- +page.svelte -->
316
- <script lang="ts">
317
- import { GenericCRUD } from 'runeforge';
318
- import { articleMeta as meta } from './interface';
319
-
320
- let { data, form } = $props();
321
- </script>
322
-
323
- <GenericCRUD
324
- labelOne="Article"
325
- labelMany="Articles"
326
- {data}
327
- {form}
328
- {meta}
329
- dataKey="articles"
330
- creation={{ endpoint: '?/create' }}
331
- read={{ endpoint: '?/read' }}
332
- update={{ endpoint: '?/update' }}
333
- deletion={{ endpoint: '?/delete' }}
334
- />
335
- ```
336
-
337
- `dataKey` must match the key returned by the load function for the list. Each `endpoint` maps to a SvelteKit form action on the same page.
338
-
339
- If your records use a different identifier field than `_id` (e.g. a plain `id`), pass the `idKey` prop:
340
-
341
- ```ts
342
- <GenericCRUD idKey="id" ... />
343
- ```
344
-
345
- This propagates to navigation URLs, form submissions, deletion calls, and the auto-excluded column list, so no other changes are needed on your end.
346
-
347
- ---
348
-
349
- ## Field System
350
-
351
- Every entry in an `InterfaceMetadata<T>` object is an `AttributeMetadata` — a superset of what drives the table column, the form input, and its validation. This section documents every option beyond the basics shown above.
352
-
353
- ### Attribute reference
354
-
355
- | Option | Type | Applies to | Description |
356
- | --- | --- | --- | --- |
357
- | `label` | `string` | all | Column header, form label, and the field name used in validation messages |
358
- | `type` | `AttributeType` | all | `text` \| `email` \| `password` \| `number` \| `boolean` \| `textarea` \| `file` \| `select` \| `multiselect` \| `tree` \| `datetime` \| `embedded` |
359
- | `required` | `boolean \| (record) => boolean` | all | Marks the label and enforces a non-empty value on submit. The function form re-evaluates against the other fields' current values — see [Validation](#validation) |
360
- | `autocomplete` | `FullAutoFill` | text-like | Native `autocomplete` attribute |
361
- | `placeholder` | `string` | text-like, select, multiselect | Placeholder text |
362
- | `default` | `value \| (data) => value` | all | Initial value on the create form — see [Default values](#default-values) |
363
- | `min` / `max` | `number` | `number` | Numeric range validation |
364
- | `integer` | `boolean` | `number` | Rejects non-whole numbers |
365
- | `minLength` / `maxLength` | `number` | text-like | Character-count validation |
366
- | `pattern` | `string` | text-like | Regex the value must match (`new RegExp(pattern)`) |
367
- | `disabled` | `(record) => boolean` | all | Conditionally disables the input — see [Conditional fields](#conditional-fields) |
368
- | `hidden` | `boolean \| (record) => boolean` | all | Conditionally removes the field from the form entirely — not rendered, not validated, not submitted — see [Conditional fields](#conditional-fields) |
369
- | `groupedAs` | `string` | all | Visually groups fields under a titled section — see [Field grouping](#field-grouping) |
370
- | `row` | `string` | all | Renders fields sharing the same value side by side (desktop) / stacked (mobile) — see [Field rows](#field-rows) |
371
- | `defaultExpanded` | `boolean` | `tree` | Whether parent nodes start expanded. Defaults to `true` |
372
- | `options` | `SelectOption[] \| (data) => SelectOption[]` | `select`, `multiselect`, `tree` | Static or computed option list — see [Select options](#select-options). `tree` options additionally accept `parentValue` — see [Tree fields](#tree-fields) |
373
- | `dependentOptions` | `(data, record) => SelectOption[]` | `select`, `multiselect`, `tree` | Options derived from other fields' current values |
374
- | `search` | `(query) => Promise<SelectOption[]>` | `select`, `multiselect` | Server-side option search as the user types |
375
- | `seed` | `(instance) => unknown` | all | Overrides how the update form seeds this field from the loaded record |
376
- | `fields` | `InterfaceMetadata<any>` | `embedded` | Sub-field schema for each item — see [Embedded fields](#embedded-fields-sub-documents) |
377
- | `itemLabel` | `(item) => string` | `embedded` | Summary label for an item in the embedded list |
378
- | `component` | `CellComponent` | all | Custom cell renderer — see [Custom Cell Components](#custom-cell-components) |
379
- | `formatter` | `(data) => (value, row) => string` | all | Custom cell text — see [Formatters](#formatters) |
380
- | `excludedFromList/Create/Read/Update` | `boolean` | all | Hides the field from that specific view |
381
- | `sortable` / `filterable` | `boolean` | all | Table column controls |
382
- | `filterOptions` | `SelectOption[]` | all | Static, exhaustive column filter choices, replacing the sampled-from-loaded-rows checkbox list — see [Server-side pagination, sorting & filtering](#server-side-pagination-sorting--filtering) |
383
-
384
- ### Validation
385
-
386
- `required`, `min`/`max`, `integer`, `minLength`/`maxLength`, and `pattern` are checked client-side on submit, before the request hits your form action. Every failure is surfaced through the same field-level error UI (and the same translatable strings) regardless of which rule failed, so your server-side checks and Runeforge's checks look identical to the user.
387
-
388
- ```ts
389
- code: {
390
- label: 'Code',
391
- type: AttributeType.text,
392
- required: true,
393
- pattern: '^[A-Z0-9]{3,8}$',
394
- },
395
- quantity: {
396
- label: 'Quantity',
397
- type: AttributeType.number,
398
- min: 1,
399
- max: 100,
400
- integer: true,
401
- },
402
- notes: {
403
- label: 'Notes',
404
- type: AttributeType.textarea,
405
- minLength: 3,
406
- maxLength: 200,
407
- },
408
- ```
409
-
410
- `required` also accepts a function of the other fields' current values, for when whether a field is mandatory depends on the rest of the form rather than being fixed:
411
-
412
- ```ts
413
- formula: {
414
- label: 'Formula',
415
- type: AttributeType.select,
416
- options: [
417
- { value: 'benchmark', label: 'Benchmark' },
418
- { value: 'max', label: 'Max' },
419
- ],
420
- },
421
- quantity: {
422
- label: 'Quantity',
423
- type: AttributeType.number,
424
- // Not required for the "benchmark" formula, mandatory for every other one.
425
- required: (record) => record.formula !== 'benchmark',
426
- },
427
- ```
428
-
429
- The label's required marker and the submit-time check both re-evaluate the same way `disabled` does — see [Conditional fields](#conditional-fields).
430
-
431
- > [!TIP]
432
- > Client-side validation is a UX nicety, not a security boundary — always re-validate in your form actions.
433
-
434
- ### Conditional fields
435
-
436
- `disabled` receives the form's current draft record (including in-progress edits to sibling fields) and returns whether the input should be disabled. It re-evaluates as the user types. `required` (see [Validation](#validation)) follows the same pattern for making a field mandatory only in certain conditions.
437
-
438
- ```ts
439
- unlimited: {
440
- label: 'Unlimited quantity',
441
- type: AttributeType.boolean,
442
- default: false,
443
- },
444
- quantity: {
445
- label: 'Quantity',
446
- type: AttributeType.number,
447
- min: 1,
448
- disabled: (record) => !!record.unlimited,
449
- },
450
- ```
451
-
452
- `hidden` follows the exact same `boolean | (record) => boolean` shape, but goes a step further than `disabled`: a hidden field isn't just greyed out, it's removed from the form entirely — not rendered, not required-checked, not submitted. Use it when a field only makes sense for certain values of another field, rather than merely being non-editable:
453
-
454
- ```ts
455
- paymentMethod: {
456
- label: 'Payment method',
457
- type: AttributeType.select,
458
- options: [
459
- { value: 'card', label: 'Credit card' },
460
- { value: 'cash', label: 'Cash on delivery' },
461
- ],
462
- },
463
- cardNumber: {
464
- label: 'Card number',
465
- type: AttributeType.text,
466
- hidden: (record) => record.paymentMethod !== 'card',
467
- required: (record) => record.paymentMethod === 'card',
468
- },
469
- cardExpiry: {
470
- label: 'Expiry date',
471
- type: AttributeType.text,
472
- hidden: (record) => record.paymentMethod !== 'card',
473
- required: (record) => record.paymentMethod === 'card',
474
- },
475
- ```
476
-
477
- Switching `paymentMethod` between `card` and `cash` swaps which fields are present, live, in the same create/edit view — no separate step or modal needed to collect the payment-specific details.
478
-
479
- ### Field grouping
480
-
481
- Fields sharing the same `groupedAs` string render together inside a titled `fieldset`, at the position of the group's first field. Fields without `groupedAs` keep the original flat layout.
482
-
483
- ```ts
484
- code: {
485
- label: 'Code',
486
- type: AttributeType.text,
487
- groupedAs: 'Identification',
488
- },
489
- sku: {
490
- label: 'SKU',
491
- type: AttributeType.text,
492
- groupedAs: 'Identification',
493
- },
494
- ```
495
-
496
- ### Field rows
497
-
498
- Fields sharing the same `row` string render side by side on desktop and stacked on mobile, instead of each taking a full line. It's meant for small, related fields — a date range, a min/max pair — where a flat vertical stack wastes space.
499
-
500
- ```ts
501
- createdFrom: {
502
- label: 'Created from',
503
- type: AttributeType.datetime,
504
- row: 'createdRange',
505
- },
506
- createdTo: {
507
- label: 'Created to',
508
- type: AttributeType.datetime,
509
- row: 'createdRange',
510
- },
511
- ```
512
-
513
- A `row` only merges fields that are also in the same `groupedAs` fieldset (or both ungrouped) — it never pulls fields together across two different fieldsets. If one of the fields in a row is conditionally [hidden](#conditional-fields), the remaining field(s) simply expand to fill the row instead of leaving a gap.
514
-
515
- `groupedAs` and `row` both work the same way inside an [embedded field](#embedded-fields-sub-documents)'s `fields` sub-schema — the "+ Add"/edit modal groups and lays out its own fields identically to a top-level form.
516
-
517
- ### Default values
518
-
519
- `default` can be a plain value or a function of the page `data` object, evaluated once when the create form's fields are resolved — handy for defaulting a select to something derived from prefetched data.
520
-
521
- ```ts
522
- published: {
523
- label: 'Published',
524
- type: AttributeType.boolean,
525
- default: false,
526
- },
527
- assignedTo: {
528
- label: 'Assigned to',
529
- type: AttributeType.select,
530
- options: (data: { users?: IUser[] }) => (data.users ?? []).map((u) => ({ value: u._id, label: u.name })),
531
- default: (data: { currentUserId?: string }) => data.currentUserId ?? '',
532
- },
533
- ```
534
-
535
- ### Select options
536
-
537
- `select` fields support four ways of resolving their options, which can be combined as needed:
538
-
539
- - **Static** — a plain `SelectOption[]` array.
540
- - **Computed from page data** — a function of the page `data` object, useful for prefetched, related records (see `formatInstance` in [Formatters](#formatters) for rendering the resolved link back).
541
- - **Dependent** — `dependentOptions(data, record)` recomputes the option list from the *current draft record*, so one field's choices can depend on another's value. If the currently selected value is no longer in the recomputed list, it's cleared automatically.
542
- - **Server search** — `search(query)` is called (debounced) as the user types, instead of filtering the (possibly partial) `options` list in memory. Combine it with `options` to keep a usable list before the user starts typing.
543
-
544
- ```ts
545
- // Dependent options: narrow "city" choices by the selected "country"
546
- country: {
547
- label: 'Country',
548
- type: AttributeType.select,
549
- options: [{ value: 'ar', label: 'Argentina' }, { value: 'uy', label: 'Uruguay' }],
550
- },
551
- city: {
552
- label: 'City',
553
- type: AttributeType.select,
554
- dependentOptions: (data, record) => CITIES_BY_COUNTRY[record.country as string] ?? [],
555
- },
556
-
557
- // Server-aware search: fall back to a prefetched slice, but query the
558
- // server for anything outside it.
559
- owner: {
560
- label: 'Owner',
561
- type: AttributeType.select,
562
- placeholder: 'Choose an owner',
563
- options: (data: { owners?: IOwner[] }) => (data.owners ?? []).map((o) => ({ value: o.id, label: o.name })),
564
- search: async (query) => {
565
- const fd = new FormData();
566
- fd.set('query', query);
567
- const res = await fetch('?/searchOwners', { method: 'POST', body: fd });
568
- const result = deserialize(await res.text());
569
- if (result.type !== 'success') return [];
570
- return (result.data.owners ?? []).map((o: IOwner) => ({ value: o.id, label: o.name }));
571
- },
572
- },
573
- ```
574
-
575
- ```ts
576
- // +page.server.ts
577
- export const actions: Actions = {
578
- // ...create/update/delete
579
- searchOwners: async ({ request }) => {
580
- const data = await request.formData();
581
- const query = String(data.get('query') ?? '');
582
- return { owners: await Owner.find({ name: { $regex: query, $options: 'i' } }).limit(20).lean() };
583
- },
584
- };
585
- ```
586
-
587
- ### Multiselect fields
588
-
589
- `AttributeType.multiselect` is a checkbox-style multiple-choice dropdown — the same `options`/`dependentOptions`/`default`/`search` resolvers as `select` (see [Select options](#select-options)), but the stored value is a `string[]` instead of a single `string`. Picking an option toggles it without closing the dropdown, and the closed-state button summarizes the count (`"2 selected"`).
590
-
591
- ```ts
592
- tags: {
593
- label: 'Tags',
594
- type: AttributeType.multiselect,
595
- options: [
596
- { value: 'fragile', label: 'Fragile' },
597
- { value: 'perishable', label: 'Perishable' },
598
- { value: 'oversized', label: 'Oversized' },
599
- ],
600
- default: [],
601
- },
602
- ```
603
-
604
- Like `embedded`, the value is submitted as a single hidden field holding a JSON array — parse it back out the same way:
605
-
606
- ```ts
607
- const tags = JSON.parse(String(data.get('tags') ?? '[]'));
608
- ```
609
-
610
- If the field also sets `dependentOptions`, selections that fall outside the recomputed list are pruned automatically (rather than clearing the whole field, as a single `select` does) — e.g. narrowing a `provinces` multiselect to only the options valid for the currently selected `country`.
611
-
612
- ### Tree fields
613
-
614
- `AttributeType.tree` is a hierarchical picker — checkboxes in a collapsible tree, where checking a parent node cascades the selection to all of its descendants. It's driven by the same flat `SelectOption[]` as `select`/`multiselect`, plus an optional `parentValue` linking each option to its parent's `value` (omit or set `null` for a root node):
615
-
616
- ```ts
617
- categories: {
618
- label: 'Categories',
619
- type: AttributeType.tree,
620
- options: (data: { categories?: ICategory[] }) =>
621
- (data.categories ?? []).map((c) => ({
622
- value: String(c.id),
623
- label: c.name,
624
- parentValue: c.parentCategory != null ? String(c.parentCategory) : null,
625
- })),
626
- },
627
- ```
628
-
629
- The stored value is a `string[]` of selected node values, submitted the same way as `multiselect` — a single hidden field holding a JSON array, parsed back out server-side with `JSON.parse`. `dependentOptions` and `hidden` work the same as any other field type.
630
-
631
- By default every parent node renders expanded; set `defaultExpanded: false` to start with the whole tree collapsed instead (the user can still expand any branch — this only sets the initial state). The field itself is capped at `--runeforge-tree-max-height` (default `24rem`, see [CSS variables](#css-variables)) and scrolls internally once its content grows past that.
632
-
633
- ### Embedded fields (sub-documents)
634
-
635
- `AttributeType.embedded` models a one-to-many list of sub-records — line items, adjustments, contacts, anything you'd otherwise store as an array of objects — entirely within one form field. It renders as a list with an "+ Add" button; each item is added/edited through a modal built from the `fields` sub-schema, and removed with a single click. The whole list is serialized to JSON and submitted as a single hidden form field.
636
-
637
- ```ts
638
- export interface IAdjustment {
639
- kind: string;
640
- amount: number;
641
- }
642
-
643
- export interface IWidget {
644
- _id: string;
645
- name: string;
646
- adjustments: IAdjustment[];
647
- }
648
-
649
- export const widgetMeta = {
650
- name: { label: 'Name', type: AttributeType.text, required: true },
651
- adjustments: {
652
- label: 'Adjustments',
653
- type: AttributeType.embedded,
654
- // Arrays of objects have no sensible plain-text table cell.
655
- excludedFromList: true,
656
- fields: {
657
- kind: {
658
- label: 'Kind',
659
- type: AttributeType.select,
660
- required: true,
661
- options: [
662
- { value: 'bonus', label: 'Bonus' },
663
- { value: 'penalty', label: 'Penalty' },
664
- ],
665
- },
666
- amount: { label: 'Amount', type: AttributeType.number, required: true, min: 0 },
667
- },
668
- itemLabel: (item) => `${item.kind === 'bonus' ? 'Bonus' : 'Penalty'}: ${item.amount}`,
669
- },
670
- } satisfies InterfaceMetadata<IWidget>;
671
- ```
672
-
673
- On the server, parse the field back out of `FormData` as JSON:
674
-
675
- ```ts
676
- function widgetFromFormData(data: FormData) {
677
- let adjustments: IAdjustment[];
678
- try {
679
- adjustments = JSON.parse(String(data.get('adjustments') ?? '[]'));
680
- } catch {
681
- adjustments = [];
682
- }
683
- return { name: String(data.get('name') ?? '').trim(), adjustments };
684
- }
685
- ```
686
-
687
- Sub-fields support the same validation rules as top-level fields (`required`, `min`/`max`, `pattern`, etc.), checked when an item is added or edited in the modal. `itemLabel` controls how each item summarizes itself in the list; without it, Runeforge joins the resolved display value of every sub-field with `·`.
688
-
689
- ---
690
-
691
- ## Components
692
-
693
- ### GenericCRUD
694
-
695
- The main CRUD orchestrator. It manages navigation between List, Create, Read, and Update views using URL search params (`?view=create`, `?id=xxx`, `?view=edit`).
696
-
697
- Key props:
698
-
699
- - `data` / `dataKey` — the record array (or [server-paginated envelope](#server-side-pagination-sorting--filtering)) and its primary key field
700
- - `labelOne` / `labelMany` — singular and plural names for the entity
701
- - `columns` — `ColumnDefinition[]` for the table view
702
- - `fields` — `FieldDefinition[]` for form views
703
- - `creation`, `update`, `read`, `deletion` — `ActionConfiguration` objects that define handlers and permissions for each operation. Set `confirm: true` on `deletion` to show a confirmation dialog before any delete (single row or batch)
704
- - `actions` — a `ListActions` object grouping the list view's extra actions:
705
- - `actions.custom` — `CustomAction[]`, extra per-row actions — see [Custom row actions](#custom-row-actions)
706
- - `actions.bulk` — `CustomBulkAction[]`, extra actions on the current selection — see [Custom bulk actions](#custom-bulk-actions)
707
- - `config` — a `ListConfig` object grouping the list view's opt-in behaviors:
708
- - `config.search` — `SearchConfiguration`, shows a free-text search box — see [Free-text search](#free-text-search)
709
- - `config.export` — `ExportConfiguration`, enables CSV/Excel export — see [Exporting data](#exporting-data-csvxlsx)
710
- - `config.reorder` — `ReorderConfiguration`, enables drag-to-reorder rows — see [Reordering rows](#reordering-rows)
711
-
712
- #### Free-text search
713
-
714
- Passing `config.search` renders a debounced search box in the header. Typing updates a URL search param (`?search=...` by default), resets pagination and any open create/read/edit view, and leaves interpreting the term entirely to your `load` function — it's the same mechanism server-side pagination uses, so it composes naturally with it.
715
-
716
- ```ts
717
- <GenericCRUD
718
- ...
719
- config={{ search: { param: 'q', placeholder: 'Search tasks...', debounceMs: 300 } }}
720
- />
721
- ```
722
-
723
- | Option | Default | Description |
724
- | --- | --- | --- |
725
- | `param` | `'search'` | Query-string parameter name |
726
- | `placeholder` | `strings.searchPlaceholder` | Input placeholder |
727
- | `debounceMs` | `300` | Delay before the URL updates |
728
-
729
- #### Custom row actions
730
-
731
- `actions.custom` adds entries to the per-row action menu, alongside the built-in view/edit/delete. Each `CustomAction` resolves in one of two ways — provide exactly one of `view` or `href`:
732
-
733
- - `href(item)` — plain navigation, e.g. deep-linking into another CRUD's filtered list.
734
- - `view` — a Svelte component of your own that `GenericCRUD` mounts directly (no wrapper) when the action runs. Since you own the whole component, you decide how it presents itself — typically as a modal built on the exported `Modal` component, sized however that action needs via `Modal`'s `class`/`width`/`maxWidth`/`height`/`maxHeight` props (see [Shared Components](#shared-components)).
735
-
736
- ```ts
737
- import ArchiveIcon from './icons/Archive.svelte';
738
- import ArchiveForm from './ArchiveForm.svelte';
739
-
740
- const actions: CustomAction<IWidget>[] = [
741
- {
742
- label: 'Archive',
743
- icon: ArchiveIcon,
744
- endpoint: '?/archive',
745
- view: ArchiveForm,
746
- condition: (item) => !item.archived,
747
- },
748
- {
749
- label: 'Open in new tab',
750
- icon: ExternalLinkIcon,
751
- href: (item) => `/widgets/${item._id}`,
752
- },
753
- ];
754
- ```
755
-
756
- ```ts
757
- <GenericCRUD ... actions={{ custom: actions }} />
758
- ```
759
-
760
- A `view` component receives `instance`, `label`, `endpoint`, `serverError`, `onCancel`, and `onSuccess` — the same shape Create/Update use internally — so it can reuse `enhance`-based form submission while rendering as a parametrized modal:
761
-
762
- ```ts
763
- <!-- ArchiveForm.svelte -->
764
- <script lang="ts">
765
- import { enhance } from '$app/forms';
766
- import { Modal } from 'runeforge';
767
-
768
- let { instance, label, endpoint, serverError, onCancel, onSuccess } = $props();
769
- </script>
770
-
771
- <Modal title={label} onClose={onCancel} maxWidth="28rem">
772
- <form
773
- method="POST"
774
- action={endpoint}
775
- use:enhance={() => async ({ result, update }) => {
776
- await update({ reset: false });
777
- if (result.type === 'success') onSuccess();
778
- }}
779
- >
780
- <input type="hidden" name="id" value={instance._id} />
781
- {#if serverError}<p class="text-error">{serverError}</p>{/if}
782
- <div class="flex justify-end gap-2 mt-4">
783
- <button type="button" onclick={onCancel}>Cancel</button>
784
- <button type="submit">{label}</button>
785
- </div>
786
- </form>
787
- </Modal>
788
- ```
789
-
790
- #### Custom bulk actions
791
-
792
- `actions.bulk` adds buttons next to the built-in Delete button in the header, operating on the current row selection. Each one is disabled until at least one row is selected, and (like deletion) can require confirmation.
793
-
794
- ```ts
795
- <GenericCRUD
796
- ...
797
- actions={{
798
- bulk: [
799
- { kind: 'endpoint', label: 'Complete', icon: CheckIcon, endpoint: '?/complete' },
800
- { kind: 'endpoint', label: 'Mark pending', icon: UndoIcon, endpoint: '?/incomplete', variant: 'error', confirm: true },
801
- ],
802
- }}
803
- />
804
- ```
805
-
806
- `endpoint` is called once per selected row (`POST` with an `id` field), then the list is refreshed. `variant` matches DaisyUI's `btn-*` modifiers (`'primary'`, `'error'`, `'ghost'`, ...). `condition(selectedItems)` can hide the action entirely based on the current selection.
807
-
808
- #### Exporting data (CSV/XLSX)
809
-
810
- `config.export` adds an export button to the header offering CSV (always) and Excel (when a `xlsx` module is supplied). Its mere presence enables the button — pass `{}` for CSV-only export. Runeforge never bundles `xlsx` itself — install it separately and pass the resolved module in, so the dependency stays fully optional:
811
-
812
- ```bash
813
- pnpm add xlsx
814
- ```
815
-
816
- ```ts
817
- <script>
818
- import { GenericCRUD } from 'runeforge';
819
- import * as xlsx from 'xlsx';
820
- </script>
821
-
822
- <GenericCRUD ... config={{ export: { xlsx } }} />
823
- ```
824
-
825
- In client-pagination mode, export includes every row currently matching the table's filters (not just the visible page). In [server-pagination mode](#server-side-pagination-sorting--filtering), pass `config.export.callback` to fetch the full, unpaginated result set for the current query — without it, export falls back to just the currently loaded page:
826
-
827
- ```ts
828
- <GenericCRUD
829
- ...
830
- config={{
831
- export: {
832
- callback: async (query) => {
833
- const params = new URLSearchParams();
834
- if (query.ordering) params.set('ordering', query.ordering);
835
- // ...translate query.filters into your API's params
836
- const res = await fetch(`/api/widgets/export?${params}`);
837
- return res.json();
838
- },
839
- },
840
- }}
841
- />
842
- ```
843
-
844
- #### Reordering rows
845
-
846
- `config.reorder` turns on drag-to-reorder: each row gets a drag handle (⋮⋮ by default — pass `icon` for something else, e.g. a hamburger or a grab-hand icon) with a thicker left border, and dragging a row persists a new sequential value for whichever attribute you point it at. Off by default; only appears once `config.reorder` is set.
847
-
848
- Runeforge never imports `sortablejs` itself — install it separately and pass the resolved default export in via `sortable`, the same way `xlsx` works for export, so the dependency stays fully optional:
849
-
850
- ```bash
851
- pnpm add sortablejs
852
- ```
853
-
854
- ```ts
855
- <script>
856
- import { GenericCRUD } from 'runeforge';
857
- import Sortable from 'sortablejs';
858
- </script>
859
-
860
- <GenericCRUD
861
- ...
862
- config={{
863
- reorder: { attribute: 'order', sortable: Sortable, endpoint: '?/reorder' },
864
- }}
865
- />
866
- ```
867
-
868
- ```ts
869
- // +page.server.ts
870
- export const actions: Actions = {
871
- reorder: async ({ request }) => {
872
- const data = await request.formData();
873
- const id = String(data.get('id') ?? '');
874
- const order = Number(data.get('order') ?? 0);
875
- await setWidgetOrder(id, order);
876
- return { success: true };
877
- },
878
- };
879
- ```
880
-
881
- `endpoint` is `POST`ed once per row whose `attribute` value actually changed (FormData: `id` plus the attribute, e.g. `order`) after a drag, then the list refreshes — the same convention `deletion`/bulk actions use. Pass `callback` instead to handle the changed rows yourself (each one already carries its new attribute value):
882
-
883
- ```ts
884
- config={{
885
- reorder: {
886
- attribute: 'order',
887
- sortable: Sortable,
888
- callback: async (items) => {
889
- await Promise.all(items.map((item) => api.updateWidget(item._id, { order: item.order })));
890
- },
891
- },
892
- }}
893
- ```
894
-
895
- A few things worth knowing about how reorder mode behaves:
896
-
897
- - **The list is ordered by `attribute` ascending (or `compare`, see below) the whole time reorder is active** — not by `_id`, not by whatever order the backend/array happens to return, and not by clicking a column header either: column-header sorting is unavailable while `config.reorder` is set, since row order needs a single, unambiguous source of truth for dragging to mean anything. This holds even if `attribute` is `excludedFromList` and has no column of its own — ordering reads the raw row value regardless of what's rendered. Per-column filters are unavailable for the same reason (a filtered-out row's position would become undefined).
898
- - **Pagination stays on.** To move a row across a page boundary, drag it to the narrow zone at either edge of the table and hold — after `pageFlipThresholdMs` (default `2000`) it flips to the previous/next page; keep hovering (without letting go) to flip again. Drop once you're on the right page.
899
- - It's **not supported in [server-pagination mode](#server-side-pagination-sorting--filtering)** (a `PaginatedEnvelope` `data`) — cross-page drag positions aren't meaningful without a lot more server-side machinery, so `config.reorder` is ignored whenever server pagination is active.
900
- - Set `enabled: false` to keep the configuration in place (attribute, endpoint, icon) but temporarily turn dragging off, without having to remove the whole object.
901
-
902
- **Composite orders.** Sometimes the attribute that stores a row's position isn't unique on its own — e.g. an indicator's `order` only makes sense *within* its parent chapter, so two indicators in different chapters can share the same `order` value, and the true display order is really (chapter's order, indicator's own order). Pass `compare` to take full control of that ordering instead of the plain `attribute`-ascending default:
903
-
904
- ```ts
905
- config={{
906
- reorder: {
907
- attribute: 'order',
908
- sortable: Sortable,
909
- compare: (a, b) => a.chapter.order - b.chapter.order || a.order - b.order,
910
- endpoint: '?/reorder',
911
- },
912
- }}
913
- ```
914
-
915
- `attribute` is still what gets written back after a drag (as a plain sequential 0-based index across the whole reordered list) — `compare` only decides how rows are displayed and dragged. If a naive global renumbering doesn't fit your data model (as in the chapter example — you don't want every indicator in every chapter renumbered whenever one indicator moves within its own chapter), use `callback` instead of `endpoint` and remap the incoming rows' indices to whatever scoped scheme your backend actually expects before saving.
916
-
917
- **Multi-select drag.** Set `multiDrag: true` to let dragging one row of the current checkbox selection carry the whole selection along with it, via SortableJS's `MultiDrag` plugin — mount it on the module you pass in:
918
-
919
- ```ts
920
- import Sortable, { MultiDrag } from 'sortablejs';
921
- Sortable.mount(new MultiDrag());
922
- ```
923
-
924
- ```ts
925
- config={{
926
- reorder: { attribute: 'order', sortable: Sortable, multiDrag: true, endpoint: '?/reorder' },
927
- }}
928
- ```
929
-
930
- The existing row-selection checkboxes *are* the multi-drag selection — there's no separate ctrl/cmd-click UI to learn. Note that `multiDrag` uses native HTML5 drag-and-drop rather than the mouse-simulated dragging the rest of reorder mode uses (SortableJS's `MultiDrag` plugin needs it to track a multi-row drag correctly) — runeforge switches automatically, but it's worth knowing if you're scripting drags for tests.
931
-
932
- #### Server-side pagination, sorting & filtering
933
-
934
- By default, `GenericCRUD` and `PaginatedTable` paginate, sort, and filter the full `data` array in the browser. For large datasets, return a `PaginatedEnvelope<T>` from your `load` function instead — `{ results, count, page, pageSize }` — and Runeforge switches to server mode automatically: it drives `page`, `ordering`, and per-column filter values through the URL, and expects your `load` function to read them back.
935
-
936
- ```ts
937
- // +page.server.ts
938
- export const load: PageServerLoad = ({ url }) => {
939
- const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
940
- const ordering = url.searchParams.get('ordering');
941
- const name = url.searchParams.get('name'); // per-column text filter
942
-
943
- let rows = [...allWidgets];
944
- if (name) rows = rows.filter((w) => w.name.toLowerCase().includes(name.toLowerCase()));
945
- if (ordering) {
946
- const desc = ordering.startsWith('-');
947
- const field = desc ? ordering.slice(1) : ordering;
948
- rows = [...rows].sort((a, b) => (desc ? -1 : 1) * compare(a[field], b[field]));
949
- }
950
-
951
- const pageSize = 20;
952
- const start = (page - 1) * pageSize;
953
- return { widgets: { results: rows.slice(start, start + pageSize), count: rows.length, page, pageSize } };
954
- };
955
- ```
956
-
957
- ```ts
958
- <GenericCRUD
959
- ...
960
- data={{ widgets: data.widgets }}
961
- dataKey="widgets"
962
- />
963
- ```
964
-
965
- No other prop changes are needed — column sorting/filtering UI, the paginator, and (with `config.export.callback`) export all keep working the same way, just backed by the server instead of the in-memory array. Boolean-column filters send comma-separated values (`?active=true,false`); date-range filters send `<attribute>_from`/`<attribute>_to`.
966
-
967
- A text column's filter checkbox list is populated from values seen on the currently loaded page — a cosmetic hint in server mode, not an exhaustive list, since the full set of values lives server-side. For a column whose possible values are a known, bounded set (an enum-like field, a small lookup table), give it `filterOptions` instead so every choice always shows up, regardless of what the current page contains:
968
-
969
- ```ts
970
- status: {
971
- label: 'Status',
972
- type: AttributeType.text,
973
- filterable: true,
974
- filterOptions: [
975
- { value: 'DRAFT', label: 'Draft' },
976
- { value: 'PUBLISHED', label: 'Published' },
977
- { value: 'ARCHIVED', label: 'Archived' },
978
- ],
979
- },
980
- ```
981
-
982
- `value` is matched against the column's rendered cell text and sent server-side as-is (same as any other checkbox filter value); `label` is only what's displayed, falling back to `value`.
983
-
984
- ### PaginatedTable
985
-
986
- A standalone table component with built-in sort, filter, and pagination — the same engine `GenericCRUD` uses internally.
987
-
988
- ```ts
989
- <script>
990
- import { PaginatedTable } from 'runeforge';
991
- </script>
992
-
993
- <PaginatedTable {data} {columns} />
994
- ```
995
-
996
- Sort and filter state can be managed externally via the exported `SortState` and `FilterState` classes. Pass a `pagination` prop (`ServerPagination`) plus `onPaginationChange` to opt into the same [server-driven mode](#server-side-pagination-sorting--filtering) `GenericCRUD` uses. `bind:visibleRows` and `bind:query` expose the currently filtered/sorted rows and query snapshot, useful for building your own export UI on top of the raw table.
997
-
998
- Pass a `reorder` prop (`{ attribute, sortable, compare?, icon?, multiDrag?, pageFlipThresholdMs? }` — same shape as `GenericCRUD`'s [`config.reorder`](#reordering-rows), minus `endpoint`/`callback`) plus `onReorder` to get the same drag-to-reorder behavior without the CRUD-level persistence wiring — `onReorder` fires with the complete reordered row list after each drag, and it's on you to decide what to do with it. Ignored whenever `pagination` is also set.
999
-
1000
- ### Form Components
1001
-
1002
- Individual form primitives styled with DaisyUI:
1003
-
1004
- - `Button` — styled action button
1005
- - `Label` — form label with optional required marker
1006
- - `Select` — dropdown with option group support, optional in-memory filtering, and an optional `search` prop for server-resolved options (see [Select options](#select-options))
1007
- - `PasswordInput` — password field with show/hide toggle; `labelClass`, `inputClass`, and `buttonClass` props let you restyle the wrapper, input, and toggle button independently
1008
-
1009
- ### Shared Components
1010
-
1011
- - `Avatar` — user avatar display
1012
- - `Modal` — DaisyUI modal wrapper. Size it with Tailwind utility classes via `class` (e.g. `class="max-w-4xl"`), or with explicit `width`/`maxWidth`/`height`/`maxHeight` CSS lengths, which are applied as inline styles and take priority over `class`
1013
- - `Breadcrumbs` — navigation breadcrumb trail
1014
- - `IconRenderer` — renders icons from the active icon set
1015
-
1016
- ---
1017
-
1018
- ## Formatters
1019
-
1020
- Formatters are functions you attach to a metadata field to control how its value is displayed in the table and read view. They follow a curried signature: `(data) => (value) => string`, where `data` is the full page data object (useful for resolving related records).
1021
-
1022
- ### `formatBoolean`
1023
-
1024
- Converts a boolean to a readable label.
1025
-
1026
- > [!NOTE]
1027
- > Defaults to `Sí` / `No` because this was created at Argentina papá! 🇦🇷.
1028
-
1029
- ```ts
1030
- import { formatBoolean } from 'runeforge';
1031
-
1032
- isActive: {
1033
- label: 'Active',
1034
- type: AttributeType.boolean,
1035
- formatter: formatBoolean(),
1036
- // or with custom labels:
1037
- formatter: formatBoolean('Enabled', 'Disabled'),
1038
- },
1039
- ```
1040
-
1041
- ### `formatDatetime`
1042
-
1043
- Formats a `Date` value using the tokens `dd`, `mm`, `YYYY`, `HH`, `MM`, `ss`.
1044
-
1045
- > [!NOTE]
1046
- > Defaults to `'dd/mm/YYYY HH:MM'`.
1047
-
1048
- ```ts
1049
- import { formatDatetime } from 'runeforge';
1050
-
1051
- createdAt: {
1052
- label: 'Created',
1053
- type: AttributeType.datetime,
1054
- formatter: formatDatetime(), // → "13/06/2026 09:45"
1055
- },
1056
-
1057
- publishedAt: {
1058
- label: 'Published',
1059
- type: AttributeType.datetime,
1060
- formatter: formatDatetime('dd/mm/YYYY'), // → "13/06/2026"
1061
- },
1062
- ```
1063
-
1064
- ### `formatTruncateTextUpTo`
1065
-
1066
- Truncates long text to a maximum character count, appending `…`.
1067
-
1068
- ```ts
1069
- import { formatTruncateTextUpTo } from 'runeforge';
1070
-
1071
- description: {
1072
- label: 'Description',
1073
- type: AttributeType.textarea,
1074
- formatter: formatTruncateTextUpTo(80),
1075
- },
1076
- ```
1077
-
1078
- ### `formatInstance`
1079
-
1080
- Resolves a foreign-key ID to a linked label. Receives the related records and the URL path for the detail view, and renders an anchor tag pointing to that record.
1081
-
1082
- ```ts
1083
- import { formatInstance } from 'runeforge';
1084
- import type { ICategory } from '$lib/server/categories/interface';
1085
-
1086
- categoryId: {
1087
- label: 'Category',
1088
- type: AttributeType.select,
1089
- options: (data: { categories?: ICategory[] }) =>
1090
- (data.categories ?? []).map((c) => ({ value: c._id, label: c.name })),
1091
- formatter: (data: { categories?: ICategory[] }) =>
1092
- formatInstance<ICategory>('name', data.categories ?? [], '/admin/categories'),
1093
- },
1094
- ```
1095
-
1096
- ---
1097
-
1098
- ## Custom Cell Components
1099
-
1100
- Instead of a `formatter`, a metadata field can declare a `component` — a Svelte component that renders the cell in both the table list and the read view. This is useful when you need to render something visual, like an avatar image or an icon, rather than plain text.
1101
-
1102
- A cell component receives two props defined by `CellProps<T, V>`:
1103
-
1104
- - `value` — the raw field value for that cell
1105
- - `row` — the full record object, useful when the rendering depends on other fields
1106
-
1107
- ```ts
1108
- // CellProps interface (from runeforge)
1109
- interface CellProps<T extends object, V> {
1110
- value: V;
1111
- row: T;
1112
- }
1113
- ```
1114
-
1115
- ### Example: avatar column
1116
-
1117
- The following renders a user photo with a fallback to initials, using data from sibling fields on the row:
1118
-
1119
- ```ts
1120
- <!-- components/UserAvatar.svelte -->
1121
- <script lang="ts">
1122
- import { Avatar } from 'runeforge';
1123
- import type { CellProps } from 'runeforge';
1124
-
1125
- type UserRow = { firstName?: string; lastName?: string; email?: string };
1126
-
1127
- let { value, row }: CellProps<UserRow, string | null> = $props();
1128
-
1129
- const initials = [row.firstName?.[0], row.lastName?.[0]].filter(Boolean).join('').toUpperCase();
1130
- </script>
1131
-
1132
- <Avatar src={value} text={initials} alt={row.email ?? ''} />
1133
- ```
1134
-
1135
- Register it in the metadata with `component`:
1136
-
1137
- ```ts
1138
- // interface.ts
1139
- import UserAvatar from './components/UserAvatar.svelte';
1140
-
1141
- export const userMeta = {
1142
- photo: {
1143
- label: 'Photo',
1144
- type: AttributeType.file,
1145
- component: UserAvatar,
1146
- sortable: false,
1147
- filterable: false,
1148
- },
1149
- // ...
1150
- } satisfies InterfaceMetadata<IUser>;
1151
- ```
1152
-
1153
- ### Example: icon column
1154
-
1155
- A simpler case — render a Bootstrap icon by name stored as a plain string:
1156
-
1157
- ```ts
1158
- <!-- components/IconCell.svelte -->
1159
- <script lang="ts">
1160
- import { IconRenderer } from 'runeforge';
1161
- import type { CellProps } from 'runeforge';
1162
-
1163
- let { value }: CellProps<Record<string, unknown>, string> = $props();
1164
- </script>
1165
-
1166
- <IconRenderer name={value} />
1167
- ```
1168
-
1169
- ```ts
1170
- icon: {
1171
- label: 'Icon',
1172
- type: AttributeType.text,
1173
- component: IconCell,
1174
- },
1175
- ```
1176
-
1177
- > [!TIP]
1178
- > Both `AvatarCell` and `IconCell` are included in the package and ready to use — you don't need to build them from scratch:
1179
- >
1180
- > ```ts
1181
- > import { AvatarCell, IconCell } from 'runeforge';
1182
- >
1183
- > photo: { label: 'Photo', type: AttributeType.file, component: AvatarCell },
1184
- > icon: { label: 'Icon', type: AttributeType.text, component: IconCell },
1185
- > ```
1186
-
1187
- ---
1188
-
1189
- ## Internationalization
1190
-
1191
- All UI strings default to **Spanish** (Argentina). To switch to another language, call `setStrings` in your root layout with a full or partial `RuneforgeStrings` object. Values you omit fall back to the Spanish defaults.
1192
-
1193
- ### Switch to English
1194
-
1195
- ```ts
1196
- <!-- +layout.svelte -->
1197
- <script>
1198
- import { setStrings, en } from 'runeforge';
1199
-
1200
- setStrings(en);
1201
- </script>
1202
- ```
1203
-
1204
- ### Override individual strings
1205
-
1206
- ```ts
1207
- <script>
1208
- import { setStrings } from 'runeforge';
1209
-
1210
- setStrings({
1211
- create: 'New',
1212
- save: 'Confirm',
1213
- required: (field) => `${field} cannot be blank`,
1214
- });
1215
- </script>
1216
- ```
1217
-
1218
- ### Full `RuneforgeStrings` reference
1219
-
1220
- | Key | Type | Spanish default |
1221
- | --- | --- | --- |
1222
- | `showing` | `(start, end, total) => string` | `Mostrando 1–10 de 25` |
1223
- | `actions` | `string` | `Acciones` |
1224
- | `filter` | `string` | `Filtrar` |
1225
- | `filterColumn` | `(column) => string` | `Filtrar Nombre` |
1226
- | `filterPlaceholder` | `string` | `Filtrar…` |
1227
- | `clearFilter` | `string` | `Limpiar filtro` |
1228
- | `emptyValue` | `string` | `(vacío)` |
1229
- | `previous` | `string` | `Anterior` |
1230
- | `next` | `string` | `Siguiente` |
1231
- | `selectPlaceholder` | `string` | `Seleccioná una opción` |
1232
- | `selectSearch` | `string` | `Buscar...` |
1233
- | `selectSearching` | `string` | `Buscando...` |
1234
- | `selectNoResults` | `string` | `Sin resultados` |
1235
- | `view` | `string` | `Ver` |
1236
- | `edit` | `string` | `Editar` |
1237
- | `delete` | `string` | `Eliminar` |
1238
- | `create` | `string` | `Crear` |
1239
- | `searchPlaceholder` | `string` | `Buscar...` |
1240
- | `reorder` | `string` | `Arrastrar para reordenar` |
1241
- | `export` | `string` | `Exportar` |
1242
- | `exportCsv` | `string` | `Exportar a CSV` |
1243
- | `exportExcel` | `string` | `Exportar a Excel` |
1244
- | `save` | `string` | `Guardar` |
1245
- | `saveAndContinue` | `string` | `Guardar y continuar` |
1246
- | `cancel` | `string` | `Cancelar` |
1247
- | `back` | `string` | `Volver` |
1248
- | `add` | `string` | `Agregar` |
1249
- | `remove` | `string` | `Quitar` |
1250
- | `noItems` | `string` | `Sin elementos agregados` |
1251
- | `confirm` | `string` | `Confirmar` |
1252
- | `deleteConfirm` | `(count, actionLabel) => string` | `¿Seguro que querés eliminar 3 elementos?` |
1253
- | `required` | `(field) => string` | `Título es requerido` |
1254
- | `invalidNumber` | `(field) => string` | `Cantidad debe ser un número` |
1255
- | `integer` | `(field) => string` | `Cantidad debe ser un número entero` |
1256
- | `min` | `(field, min) => string` | `Cantidad debe ser mayor o igual a 1` |
1257
- | `max` | `(field, max) => string` | `Cantidad debe ser menor o igual a 100` |
1258
- | `minLength` | `(field, min) => string` | `Notas debe tener al menos 3 caracteres` |
1259
- | `maxLength` | `(field, max) => string` | `Notas debe tener como máximo 200 caracteres` |
1260
- | `pattern` | `(field) => string` | `Código tiene un formato inválido` |
1261
- | `serverError` | `string` | `Error inesperado del servidor.` |
1262
-
1263
- > [!NOTE]
1264
- > Defaults to Spanish because this was built in Argentina! 🇦🇷
1265
-
1266
- ### Bundled locales
1267
-
1268
- | Import | Language |
1269
- | --- | --- |
1270
- | `es` | Spanish 🇦🇷 (default) |
1271
- | `en` | English 🇺🇸 |
1272
-
1273
- ---
1274
-
1275
- ## Icon System
1276
-
1277
- Runeforge ships with a default icon set. To use Bootstrap Icons instead:
1278
-
1279
- ```ts
1280
- <script>
1281
- import { setIconSet, bootstrapIcons } from 'runeforge';
1282
-
1283
- setIconSet(bootstrapIcons);
1284
- </script>
1285
- ```
1286
-
1287
- You can also provide a fully custom icon set by passing an object that satisfies the icon set interface.
1288
-
1289
- ---
1290
-
1291
- ## Running Tests
1292
-
1293
- ### Unit Tests
1294
-
1295
- Unit tests cover utility functions (formatters, resolution helpers, misc utilities) and run with [Vitest](https://vitest.dev/).
1296
-
1297
- ```bash
1298
- # Single run
1299
- pnpm test:unit
1300
-
1301
- # Watch mode
1302
- pnpm test:unit:watch
1303
- ```
1304
-
1305
- ### End-to-End Tests
1306
-
1307
- E2E tests cover table interactions (pagination, sorting, filtering) and run with [Playwright](https://playwright.dev/). The dev server starts automatically when running locally.
1308
-
1309
- ```bash
1310
- pnpm test:e2e
1311
- ```
1312
-
1313
- ### Run All Tests
1314
-
1315
- ```bash
1316
- pnpm test
1317
- ```
1318
-
1319
- ---
1320
-
1321
- ## Development
1322
-
1323
- ```bash
1324
- # Start the dev server
1325
- pnpm dev
1326
-
1327
- # Type-check
1328
- pnpm check
1329
-
1330
- # Lint and format
1331
- pnpm lint
1332
- pnpm format
1333
-
1334
- # Build the library
1335
- pnpm build
1336
- ```
1337
-
1338
- ---
1339
-
1340
- ## License
1341
-
1342
- MIT
1
+ <div align="center">
2
+
3
+ <img src="docs/logo.png" alt="Runeforge logo" width="300" />
4
+
5
+ # Runeforge
6
+
7
+ A SvelteKit toolkit that forges forms, tables, actions, and CRUD workflows from reusable definitions.
8
+
9
+ <img src="docs/crud-list.png" alt="CRUD list view" />
10
+
11
+ </div>
12
+
13
+ ---
14
+
15
+ ## Table of Contents
16
+
17
+ - [Runeforge](#runeforge)
18
+ - [Table of Contents](#table-of-contents)
19
+ - [Introduction](#introduction)
20
+ - [Requirements](#requirements)
21
+ - [Key Features](#key-features)
22
+ - [Installation](#installation)
23
+ - [Theming](#theming)
24
+ - [Tailwind source scanning](#tailwind-source-scanning)
25
+ - [CSS variables](#css-variables)
26
+ - [Configuration](#configuration)
27
+ - [Basic Usage](#basic-usage)
28
+ - [1. Define your interface and metadata](#1-define-your-interface-and-metadata)
29
+ - [2. Create the model](#2-create-the-model)
30
+ - [3. Set up the server](#3-set-up-the-server)
31
+ - [4. Add the page component](#4-add-the-page-component)
32
+ - [Field System](#field-system)
33
+ - [Attribute reference](#attribute-reference)
34
+ - [Validation](#validation)
35
+ - [Conditional fields](#conditional-fields)
36
+ - [Field grouping](#field-grouping)
37
+ - [Field rows](#field-rows)
38
+ - [Default values](#default-values)
39
+ - [Select options](#select-options)
40
+ - [Multiselect fields](#multiselect-fields)
41
+ - [Tree fields](#tree-fields)
42
+ - [Embedded fields (sub-documents)](#embedded-fields-sub-documents)
43
+ - [Components](#components)
44
+ - [GenericCRUD](#genericcrud)
45
+ - [Free-text search](#free-text-search)
46
+ - [Custom row actions](#custom-row-actions)
47
+ - [Custom bulk actions](#custom-bulk-actions)
48
+ - [Exporting data (CSV/XLSX)](#exporting-data-csvxlsx)
49
+ - [Reordering rows](#reordering-rows)
50
+ - [Server-side pagination, sorting & filtering](#server-side-pagination-sorting--filtering)
51
+ - [PaginatedTable](#paginatedtable)
52
+ - [Form Components](#form-components)
53
+ - [Shared Components](#shared-components)
54
+ - [Formatters](#formatters)
55
+ - [`formatBoolean`](#formatboolean)
56
+ - [`formatDatetime`](#formatdatetime)
57
+ - [`formatTruncateTextUpTo`](#formattruncatetextupto)
58
+ - [`formatInstance`](#formatinstance)
59
+ - [Custom Cell Components](#custom-cell-components)
60
+ - [Example: avatar column](#example-avatar-column)
61
+ - [Example: icon column](#example-icon-column)
62
+ - [Internationalization](#internationalization)
63
+ - [Switch to English](#switch-to-english)
64
+ - [Override individual strings](#override-individual-strings)
65
+ - [Full `RuneforgeStrings` reference](#full-runeforgestrings-reference)
66
+ - [Bundled locales](#bundled-locales)
67
+ - [Icon System](#icon-system)
68
+ - [Running Tests](#running-tests)
69
+ - [Unit Tests](#unit-tests)
70
+ - [End-to-End Tests](#end-to-end-tests)
71
+ - [Run All Tests](#run-all-tests)
72
+ - [Development](#development)
73
+ - [License](#license)
74
+
75
+ ---
76
+
77
+ ## Introduction
78
+
79
+ Runeforge provides a set of composable, metadata-driven components for building data-heavy interfaces in SvelteKit. It handles the repetitive parts of CRUD UIs — listing records, creating and editing forms, sorting and filtering tables — through a declarative API built on top of [DaisyUI](https://daisyui.com/) and [Tailwind CSS](https://tailwindcss.com/).
80
+
81
+ ---
82
+
83
+ ## Requirements
84
+
85
+ - SvelteKit 2+
86
+ - Svelte 5 (runes mode)
87
+ - Tailwind CSS 4
88
+ - DaisyUI 5
89
+ - Cally
90
+ - `sortablejs` (optional, only if you enable [drag-to-reorder](#reordering-rows))
91
+ - `xlsx` (optional, only if you enable Excel export)
92
+
93
+ ---
94
+
95
+ ## Key Features
96
+
97
+ - **GenericCRUD** — a single orchestrator component that wires together list, create, read, and update views from field and column definitions.
98
+ - **PaginatedTable** — a full-featured table with sorting, filtering, pagination, and row selection, usable either fully client-side or driven by a server-paginated backend.
99
+ - **Field system** — declarative field definitions that drive both form rendering and display, supporting text, email, password, number, boolean, textarea, file, select, multiselect, tree, datetime, and embedded (sub-document list) types.
100
+ - **Validation** — built-in `required`, `min`/`max`, `integer`, `minLength`/`maxLength`, and `pattern` rules, checked client-side before submit with consistent, translatable error messages.
101
+ - **Conditional fields & field grouping** — disable, or entirely hide, a field based on the current values of others in the same form, and visually group related fields under a titled `fieldset`.
102
+ - **Smart select fields** — options can be static, computed from page data, dependent on another field's value, or resolved live from the server as the user types. `multiselect` supports the same resolvers for a checkbox-style multiple-choice list.
103
+ - **Tree fields** — a hierarchical, cascading-selection picker (e.g. categories with parent/child relationships) driven by a flat option list with a `parentValue` link.
104
+ - **Embedded fields** — model one-to-many sub-documents (e.g. line items, adjustments) as an in-form add/edit list backed by a single JSON field.
105
+ - **Custom row & bulk actions** — add entity-specific actions (in a panel or via redirect) alongside the built-in view/edit/delete, and bulk actions that operate on the current selection.
106
+ - **CSV/XLSX export** — one-click export of the current table view, with optional Excel support via the `xlsx` package.
107
+ - **Drag-to-reorder** — an opt-in row-reordering layer that persists a sequential order attribute after each drag.
108
+ - **Server-side pagination, sorting & filtering** — point `GenericCRUD`/`PaginatedTable` at a paginated envelope and it drives page/sort/filter state through the URL for you.
109
+ - **Pluggable icon system** — swap the default icon set or use the included Bootstrap Icons alternative via `setIconSet`.
110
+ - **Standalone components** — table, form, and navigation components can be used independently without the full CRUD orchestrator.
111
+
112
+ ---
113
+
114
+ ## Installation
115
+
116
+ ```bash
117
+ pnpm add runeforge
118
+ ```
119
+
120
+ ---
121
+
122
+ ## Theming
123
+
124
+ ### Tailwind source scanning
125
+
126
+ Runeforge components use Tailwind CSS utility classes internally. Tailwind v4 does not scan `node_modules` by default, so add an `@source` directive in your project's `app.css` to ensure every utility class is generated:
127
+
128
+ ```css
129
+ @import "tailwindcss";
130
+ @source "../node_modules/runeforge/dist";
131
+ @plugin "daisyui";
132
+ ```
133
+
134
+ ### CSS variables
135
+
136
+ Key visual properties are exposed as CSS custom properties so you can tune them per project. Set them on `:root` (or any narrower selector) in your `app.css`:
137
+
138
+ ```css
139
+ :root {
140
+ --runeforge-crud-title-size: 1.875rem;
141
+ --runeforge-breadcrumb-font-size: 0.875rem;
142
+ --runeforge-breadcrumb-icon-size: 1rem;
143
+ }
144
+ ```
145
+
146
+ Responsive overrides work too:
147
+
148
+ ```css
149
+ :root {
150
+ --runeforge-crud-title-size: 1.25rem;
151
+ }
152
+ @media (min-width: 768px) {
153
+ :root {
154
+ --runeforge-crud-title-size: 1.875rem;
155
+ }
156
+ }
157
+ ```
158
+
159
+ | Variable | Default | Affects |
160
+ | --- | --- | --- |
161
+ | `--runeforge-crud-max-width` | _(none)_ | Max width of the Header and List view; centers them when set |
162
+ | `--runeforge-form-max-width` | `32rem` | Max width of the form/fields panel in Create, Update, and Read views |
163
+ | `--runeforge-crud-title-size` | `1.875rem` | `<h1>` inside the `Header` component |
164
+ | `--runeforge-breadcrumb-font-size` | `0.875rem` | Breadcrumb label text size |
165
+ | `--runeforge-breadcrumb-icon-size` | `1rem` | Breadcrumb icon width and height |
166
+ | `--runeforge-tree-max-height` | `24rem` | Max height of a `tree` field before it scrolls internally |
167
+
168
+ Modal sizing (see [Shared Components](#shared-components)) is set per-instance via props rather than a CSS variable.
169
+
170
+ ---
171
+
172
+ ## Configuration
173
+
174
+ Global settings are applied once in your root layout via `setConfig`. This avoids passing the same prop to every CRUD component.
175
+
176
+ ```ts
177
+ <!-- +layout.svelte -->
178
+ <script>
179
+ import { setConfig } from 'runeforge';
180
+
181
+ setConfig({ homeHref: '/admin' });
182
+ </script>
183
+ ```
184
+
185
+ | Option | Default | Description |
186
+ | --- | --- | --- |
187
+ | `homeHref` | `'/'` | URL for the home crumb in every breadcrumb trail |
188
+
189
+ ---
190
+
191
+ ## Basic Usage
192
+
193
+ ### 1. Define your interface and metadata
194
+
195
+ ```ts
196
+ // interface.ts
197
+ import { AttributeType, type InterfaceMetadata } from 'runeforge';
198
+ import { formatBoolean, formatDatetime } from 'runeforge';
199
+
200
+ export interface IArticle {
201
+ _id: string;
202
+ title: string;
203
+ published: boolean;
204
+ createdAt: Date;
205
+ }
206
+
207
+ export const articleMeta = {
208
+ title: {
209
+ label: 'Title',
210
+ type: AttributeType.text,
211
+ placeholder: 'My article',
212
+ required: true,
213
+ },
214
+ published: {
215
+ label: 'Published',
216
+ type: AttributeType.boolean,
217
+ formatter: formatBoolean,
218
+ default: false,
219
+ required: true,
220
+ },
221
+ createdAt: {
222
+ label: 'Created',
223
+ type: AttributeType.datetime,
224
+ formatter: formatDatetime(),
225
+ excludedFromCreate: true,
226
+ excludedFromUpdate: true
227
+ },
228
+ updatedAt: {
229
+ label: 'Updated',
230
+ type: AttributeType.datetime,
231
+ formatter: formatDatetime(),
232
+ excludedFromCreate: true,
233
+ excludedFromUpdate: true
234
+ },
235
+ } satisfies InterfaceMetadata<IArticle>;
236
+ ```
237
+
238
+ Each metadata entry drives both the table column and the form field for that attribute. You can use `excludedFromList`, `excludedFromCreate`, `excludedFromRead`, or `excludedFromUpdate` to hide a field from specific views. The [Field System](#field-system) section below covers the full set of options — validation, conditional/grouped fields, smart selects, and embedded sub-documents.
239
+
240
+ ### 2. Create the model
241
+
242
+ ```ts
243
+ // model.ts
244
+ import crypto from 'node:crypto';
245
+ import mongoose from 'mongoose';
246
+ import type { IArticle } from './interface';
247
+
248
+ const schema = new mongoose.Schema<IArticle>(
249
+ {
250
+ _id: { type: String, default: () => crypto.randomUUID() },
251
+ title: { type: String, required: true, trim: true },
252
+ published: { type: Boolean, required: true, default: false },
253
+ },
254
+ { timestamps: true }
255
+ );
256
+
257
+ export const Article = mongoose.models.Article ?? mongoose.model<IArticle>('Article', schema);
258
+ ```
259
+
260
+ ### 3. Set up the server
261
+
262
+ ```ts
263
+ // +page.server.ts
264
+ import { fail, error } from '@sveltejs/kit';
265
+ import { Article } from '$lib/server/articles/model';
266
+ import type { Actions, PageServerLoad } from './$types';
267
+ import type { IArticle } from './interface';
268
+
269
+ export const load: PageServerLoad = async ({ url }) => {
270
+ const id = url.searchParams.get('id');
271
+ if (id) {
272
+ const article = await Article.findById(id).lean<IArticle>();
273
+ if (!article) error(404, 'Not found');
274
+ return { article };
275
+ }
276
+ const articles = await Article.find({}).sort({ createdAt: -1 }).lean<IArticle[]>();
277
+ return { articles };
278
+ };
279
+
280
+ export const actions: Actions = {
281
+ create: async ({ request }) => {
282
+ const data = await request.formData();
283
+ const title = String(data.get('title') ?? '').trim();
284
+ if (!title) return fail(400, { error: 'Title is required' });
285
+ await Article.create({ title, published: data.has('published') });
286
+ return { success: true };
287
+ },
288
+
289
+ update: async ({ request }) => {
290
+ const data = await request.formData();
291
+ const id = String(data.get('id') ?? '').trim();
292
+ if (!id) return fail(400, { error: 'ID is required' });
293
+ await Article.findByIdAndUpdate(id, {
294
+ title: String(data.get('title') ?? '').trim(),
295
+ published: data.has('published'),
296
+ });
297
+ return { success: true };
298
+ },
299
+
300
+ delete: async ({ request }) => {
301
+ const data = await request.formData();
302
+ const id = String(data.get('id') ?? '').trim();
303
+ if (!id) return fail(400, { error: 'ID is required' });
304
+ await Article.findByIdAndDelete(id);
305
+ return { success: true };
306
+ },
307
+ };
308
+ ```
309
+
310
+ The `load` function returns a single record when `?id=` is present (used by the read/edit views), or the full list otherwise.
311
+
312
+ ### 4. Add the page component
313
+
314
+ ```html
315
+ <!-- +page.svelte -->
316
+ <script lang="ts">
317
+ import { GenericCRUD } from 'runeforge';
318
+ import { articleMeta as meta } from './interface';
319
+
320
+ let { data, form } = $props();
321
+ </script>
322
+
323
+ <GenericCRUD
324
+ labelOne="Article"
325
+ labelMany="Articles"
326
+ {data}
327
+ {form}
328
+ {meta}
329
+ dataKey="articles"
330
+ creation={{ endpoint: '?/create' }}
331
+ read={{ endpoint: '?/read' }}
332
+ update={{ endpoint: '?/update' }}
333
+ deletion={{ endpoint: '?/delete' }}
334
+ />
335
+ ```
336
+
337
+ `dataKey` must match the key returned by the load function for the list. Each `endpoint` maps to a SvelteKit form action on the same page.
338
+
339
+ If your records use a different identifier field than `_id` (e.g. a plain `id`), pass the `idKey` prop:
340
+
341
+ ```ts
342
+ <GenericCRUD idKey="id" ... />
343
+ ```
344
+
345
+ This propagates to navigation URLs, form submissions, deletion calls, and the auto-excluded column list, so no other changes are needed on your end.
346
+
347
+ ---
348
+
349
+ ## Field System
350
+
351
+ Every entry in an `InterfaceMetadata<T>` object is an `AttributeMetadata` — a superset of what drives the table column, the form input, and its validation. This section documents every option beyond the basics shown above.
352
+
353
+ ### Attribute reference
354
+
355
+ | Option | Type | Applies to | Description |
356
+ | --- | --- | --- | --- |
357
+ | `label` | `string` | all | Column header, form label, and the field name used in validation messages |
358
+ | `type` | `AttributeType` | all | `text` \| `email` \| `password` \| `number` \| `boolean` \| `textarea` \| `file` \| `select` \| `multiselect` \| `tree` \| `datetime` \| `embedded` |
359
+ | `required` | `boolean \| (record) => boolean` | all | Marks the label and enforces a non-empty value on submit. The function form re-evaluates against the other fields' current values — see [Validation](#validation) |
360
+ | `autocomplete` | `FullAutoFill` | text-like | Native `autocomplete` attribute |
361
+ | `placeholder` | `string` | text-like, select, multiselect | Placeholder text |
362
+ | `default` | `value \| (data) => value` | all | Initial value on the create form — see [Default values](#default-values) |
363
+ | `min` / `max` | `number` | `number` | Numeric range validation |
364
+ | `integer` | `boolean` | `number` | Rejects non-whole numbers |
365
+ | `minLength` / `maxLength` | `number` | text-like | Character-count validation |
366
+ | `pattern` | `string` | text-like | Regex the value must match (`new RegExp(pattern)`) |
367
+ | `disabled` | `(record) => boolean` | all | Conditionally disables the input — see [Conditional fields](#conditional-fields) |
368
+ | `hidden` | `boolean \| (record) => boolean` | all | Conditionally removes the field from the form entirely — not rendered, not validated, not submitted — see [Conditional fields](#conditional-fields) |
369
+ | `groupedAs` | `string` | all | Visually groups fields under a titled section — see [Field grouping](#field-grouping) |
370
+ | `row` | `string` | all | Renders fields sharing the same value side by side (desktop) / stacked (mobile) — see [Field rows](#field-rows) |
371
+ | `defaultExpanded` | `boolean` | `tree` | Whether parent nodes start expanded. Defaults to `true` |
372
+ | `options` | `SelectOption[] \| (data) => SelectOption[]` | `select`, `multiselect`, `tree` | Static or computed option list — see [Select options](#select-options). `tree` options additionally accept `parentValue` — see [Tree fields](#tree-fields) |
373
+ | `dependentOptions` | `(data, record) => SelectOption[]` | `select`, `multiselect`, `tree` | Options derived from other fields' current values |
374
+ | `search` | `(query) => Promise<SelectOption[]>` | `select`, `multiselect` | Server-side option search as the user types |
375
+ | `seed` | `(instance) => unknown` | all | Overrides how the update form seeds this field from the loaded record |
376
+ | `fields` | `InterfaceMetadata<any>` | `embedded` | Sub-field schema for each item — see [Embedded fields](#embedded-fields-sub-documents) |
377
+ | `itemLabel` | `(item) => string` | `embedded` | Summary label for an item in the embedded list |
378
+ | `component` | `CellComponent` | all | Custom cell renderer — see [Custom Cell Components](#custom-cell-components) |
379
+ | `formatter` | `(data) => (value, row) => string` | all | Custom cell text — see [Formatters](#formatters) |
380
+ | `excludedFromList/Create/Read/Update` | `boolean` | all | Hides the field from that specific view |
381
+ | `sortable` / `filterable` | `boolean` | all | Table column controls |
382
+ | `filterOptions` | `SelectOption[]` | all | Static, exhaustive column filter choices, replacing the sampled-from-loaded-rows checkbox list — see [Server-side pagination, sorting & filtering](#server-side-pagination-sorting--filtering) |
383
+
384
+ ### Validation
385
+
386
+ `required`, `min`/`max`, `integer`, `minLength`/`maxLength`, and `pattern` are checked client-side on submit, before the request hits your form action. Every failure is surfaced through the same field-level error UI (and the same translatable strings) regardless of which rule failed, so your server-side checks and Runeforge's checks look identical to the user.
387
+
388
+ ```ts
389
+ code: {
390
+ label: 'Code',
391
+ type: AttributeType.text,
392
+ required: true,
393
+ pattern: '^[A-Z0-9]{3,8}$',
394
+ },
395
+ quantity: {
396
+ label: 'Quantity',
397
+ type: AttributeType.number,
398
+ min: 1,
399
+ max: 100,
400
+ integer: true,
401
+ },
402
+ notes: {
403
+ label: 'Notes',
404
+ type: AttributeType.textarea,
405
+ minLength: 3,
406
+ maxLength: 200,
407
+ },
408
+ ```
409
+
410
+ `required` also accepts a function of the other fields' current values, for when whether a field is mandatory depends on the rest of the form rather than being fixed:
411
+
412
+ ```ts
413
+ formula: {
414
+ label: 'Formula',
415
+ type: AttributeType.select,
416
+ options: [
417
+ { value: 'benchmark', label: 'Benchmark' },
418
+ { value: 'max', label: 'Max' },
419
+ ],
420
+ },
421
+ quantity: {
422
+ label: 'Quantity',
423
+ type: AttributeType.number,
424
+ // Not required for the "benchmark" formula, mandatory for every other one.
425
+ required: (record) => record.formula !== 'benchmark',
426
+ },
427
+ ```
428
+
429
+ The label's required marker and the submit-time check both re-evaluate the same way `disabled` does — see [Conditional fields](#conditional-fields).
430
+
431
+ > [!TIP]
432
+ > Client-side validation is a UX nicety, not a security boundary — always re-validate in your form actions.
433
+
434
+ ### Conditional fields
435
+
436
+ `disabled` receives the form's current draft record (including in-progress edits to sibling fields) and returns whether the input should be disabled. It re-evaluates as the user types. `required` (see [Validation](#validation)) follows the same pattern for making a field mandatory only in certain conditions.
437
+
438
+ ```ts
439
+ unlimited: {
440
+ label: 'Unlimited quantity',
441
+ type: AttributeType.boolean,
442
+ default: false,
443
+ },
444
+ quantity: {
445
+ label: 'Quantity',
446
+ type: AttributeType.number,
447
+ min: 1,
448
+ disabled: (record) => !!record.unlimited,
449
+ },
450
+ ```
451
+
452
+ `hidden` follows the exact same `boolean | (record) => boolean` shape, but goes a step further than `disabled`: a hidden field isn't just greyed out, it's removed from the form entirely — not rendered, not required-checked, not submitted. Use it when a field only makes sense for certain values of another field, rather than merely being non-editable:
453
+
454
+ ```ts
455
+ paymentMethod: {
456
+ label: 'Payment method',
457
+ type: AttributeType.select,
458
+ options: [
459
+ { value: 'card', label: 'Credit card' },
460
+ { value: 'cash', label: 'Cash on delivery' },
461
+ ],
462
+ },
463
+ cardNumber: {
464
+ label: 'Card number',
465
+ type: AttributeType.text,
466
+ hidden: (record) => record.paymentMethod !== 'card',
467
+ required: (record) => record.paymentMethod === 'card',
468
+ },
469
+ cardExpiry: {
470
+ label: 'Expiry date',
471
+ type: AttributeType.text,
472
+ hidden: (record) => record.paymentMethod !== 'card',
473
+ required: (record) => record.paymentMethod === 'card',
474
+ },
475
+ ```
476
+
477
+ Switching `paymentMethod` between `card` and `cash` swaps which fields are present, live, in the same create/edit view — no separate step or modal needed to collect the payment-specific details.
478
+
479
+ ### Field grouping
480
+
481
+ Fields sharing the same `groupedAs` string render together inside a titled `fieldset`, at the position of the group's first field. Fields without `groupedAs` keep the original flat layout.
482
+
483
+ ```ts
484
+ code: {
485
+ label: 'Code',
486
+ type: AttributeType.text,
487
+ groupedAs: 'Identification',
488
+ },
489
+ sku: {
490
+ label: 'SKU',
491
+ type: AttributeType.text,
492
+ groupedAs: 'Identification',
493
+ },
494
+ ```
495
+
496
+ ### Field rows
497
+
498
+ Fields sharing the same `row` string render side by side on desktop and stacked on mobile, instead of each taking a full line. It's meant for small, related fields — a date range, a min/max pair — where a flat vertical stack wastes space.
499
+
500
+ ```ts
501
+ createdFrom: {
502
+ label: 'Created from',
503
+ type: AttributeType.datetime,
504
+ row: 'createdRange',
505
+ },
506
+ createdTo: {
507
+ label: 'Created to',
508
+ type: AttributeType.datetime,
509
+ row: 'createdRange',
510
+ },
511
+ ```
512
+
513
+ A `row` only merges fields that are also in the same `groupedAs` fieldset (or both ungrouped) — it never pulls fields together across two different fieldsets. If one of the fields in a row is conditionally [hidden](#conditional-fields), the remaining field(s) simply expand to fill the row instead of leaving a gap.
514
+
515
+ `groupedAs` and `row` both work the same way inside an [embedded field](#embedded-fields-sub-documents)'s `fields` sub-schema — the "+ Add"/edit modal groups and lays out its own fields identically to a top-level form.
516
+
517
+ ### Default values
518
+
519
+ `default` can be a plain value or a function of the page `data` object, evaluated once when the create form's fields are resolved — handy for defaulting a select to something derived from prefetched data.
520
+
521
+ ```ts
522
+ published: {
523
+ label: 'Published',
524
+ type: AttributeType.boolean,
525
+ default: false,
526
+ },
527
+ assignedTo: {
528
+ label: 'Assigned to',
529
+ type: AttributeType.select,
530
+ options: (data: { users?: IUser[] }) => (data.users ?? []).map((u) => ({ value: u._id, label: u.name })),
531
+ default: (data: { currentUserId?: string }) => data.currentUserId ?? '',
532
+ },
533
+ ```
534
+
535
+ ### Select options
536
+
537
+ `select` fields support four ways of resolving their options, which can be combined as needed:
538
+
539
+ - **Static** — a plain `SelectOption[]` array.
540
+ - **Computed from page data** — a function of the page `data` object, useful for prefetched, related records (see `formatInstance` in [Formatters](#formatters) for rendering the resolved link back).
541
+ - **Dependent** — `dependentOptions(data, record)` recomputes the option list from the *current draft record*, so one field's choices can depend on another's value. If the currently selected value is no longer in the recomputed list, it's cleared automatically.
542
+ - **Server search** — `search(query)` is called (debounced) as the user types, instead of filtering the (possibly partial) `options` list in memory. Combine it with `options` to keep a usable list before the user starts typing.
543
+
544
+ ```ts
545
+ // Dependent options: narrow "city" choices by the selected "country"
546
+ country: {
547
+ label: 'Country',
548
+ type: AttributeType.select,
549
+ options: [{ value: 'ar', label: 'Argentina' }, { value: 'uy', label: 'Uruguay' }],
550
+ },
551
+ city: {
552
+ label: 'City',
553
+ type: AttributeType.select,
554
+ dependentOptions: (data, record) => CITIES_BY_COUNTRY[record.country as string] ?? [],
555
+ },
556
+
557
+ // Server-aware search: fall back to a prefetched slice, but query the
558
+ // server for anything outside it.
559
+ owner: {
560
+ label: 'Owner',
561
+ type: AttributeType.select,
562
+ placeholder: 'Choose an owner',
563
+ options: (data: { owners?: IOwner[] }) => (data.owners ?? []).map((o) => ({ value: o.id, label: o.name })),
564
+ search: async (query) => {
565
+ const fd = new FormData();
566
+ fd.set('query', query);
567
+ const res = await fetch('?/searchOwners', { method: 'POST', body: fd });
568
+ const result = deserialize(await res.text());
569
+ if (result.type !== 'success') return [];
570
+ return (result.data.owners ?? []).map((o: IOwner) => ({ value: o.id, label: o.name }));
571
+ },
572
+ },
573
+ ```
574
+
575
+ ```ts
576
+ // +page.server.ts
577
+ export const actions: Actions = {
578
+ // ...create/update/delete
579
+ searchOwners: async ({ request }) => {
580
+ const data = await request.formData();
581
+ const query = String(data.get('query') ?? '');
582
+ return { owners: await Owner.find({ name: { $regex: query, $options: 'i' } }).limit(20).lean() };
583
+ },
584
+ };
585
+ ```
586
+
587
+ ### Multiselect fields
588
+
589
+ `AttributeType.multiselect` is a checkbox-style multiple-choice dropdown — the same `options`/`dependentOptions`/`default`/`search` resolvers as `select` (see [Select options](#select-options)), but the stored value is a `string[]` instead of a single `string`. Picking an option toggles it without closing the dropdown, and the closed-state button summarizes the count (`"2 selected"`).
590
+
591
+ ```ts
592
+ tags: {
593
+ label: 'Tags',
594
+ type: AttributeType.multiselect,
595
+ options: [
596
+ { value: 'fragile', label: 'Fragile' },
597
+ { value: 'perishable', label: 'Perishable' },
598
+ { value: 'oversized', label: 'Oversized' },
599
+ ],
600
+ default: [],
601
+ },
602
+ ```
603
+
604
+ Like `embedded`, the value is submitted as a single hidden field holding a JSON array — parse it back out the same way:
605
+
606
+ ```ts
607
+ const tags = JSON.parse(String(data.get('tags') ?? '[]'));
608
+ ```
609
+
610
+ If the field also sets `dependentOptions`, selections that fall outside the recomputed list are pruned automatically (rather than clearing the whole field, as a single `select` does) — e.g. narrowing a `provinces` multiselect to only the options valid for the currently selected `country`.
611
+
612
+ ### Tree fields
613
+
614
+ `AttributeType.tree` is a hierarchical picker — checkboxes in a collapsible tree, where checking a parent node cascades the selection to all of its descendants. It's driven by the same flat `SelectOption[]` as `select`/`multiselect`, plus an optional `parentValue` linking each option to its parent's `value` (omit or set `null` for a root node):
615
+
616
+ ```ts
617
+ categories: {
618
+ label: 'Categories',
619
+ type: AttributeType.tree,
620
+ options: (data: { categories?: ICategory[] }) =>
621
+ (data.categories ?? []).map((c) => ({
622
+ value: String(c.id),
623
+ label: c.name,
624
+ parentValue: c.parentCategory != null ? String(c.parentCategory) : null,
625
+ })),
626
+ },
627
+ ```
628
+
629
+ The stored value is a `string[]` of selected node values, submitted the same way as `multiselect` — a single hidden field holding a JSON array, parsed back out server-side with `JSON.parse`. `dependentOptions` and `hidden` work the same as any other field type.
630
+
631
+ By default every parent node renders expanded; set `defaultExpanded: false` to start with the whole tree collapsed instead (the user can still expand any branch — this only sets the initial state). The field itself is capped at `--runeforge-tree-max-height` (default `24rem`, see [CSS variables](#css-variables)) and scrolls internally once its content grows past that.
632
+
633
+ ### Embedded fields (sub-documents)
634
+
635
+ `AttributeType.embedded` models a one-to-many list of sub-records — line items, adjustments, contacts, anything you'd otherwise store as an array of objects — entirely within one form field. It renders as a list with an "+ Add" button; each item is added/edited through a modal built from the `fields` sub-schema, and removed with a single click. The whole list is serialized to JSON and submitted as a single hidden form field.
636
+
637
+ ```ts
638
+ export interface IAdjustment {
639
+ kind: string;
640
+ amount: number;
641
+ }
642
+
643
+ export interface IWidget {
644
+ _id: string;
645
+ name: string;
646
+ adjustments: IAdjustment[];
647
+ }
648
+
649
+ export const widgetMeta = {
650
+ name: { label: 'Name', type: AttributeType.text, required: true },
651
+ adjustments: {
652
+ label: 'Adjustments',
653
+ type: AttributeType.embedded,
654
+ // Arrays of objects have no sensible plain-text table cell.
655
+ excludedFromList: true,
656
+ fields: {
657
+ kind: {
658
+ label: 'Kind',
659
+ type: AttributeType.select,
660
+ required: true,
661
+ options: [
662
+ { value: 'bonus', label: 'Bonus' },
663
+ { value: 'penalty', label: 'Penalty' },
664
+ ],
665
+ },
666
+ amount: { label: 'Amount', type: AttributeType.number, required: true, min: 0 },
667
+ },
668
+ itemLabel: (item) => `${item.kind === 'bonus' ? 'Bonus' : 'Penalty'}: ${item.amount}`,
669
+ },
670
+ } satisfies InterfaceMetadata<IWidget>;
671
+ ```
672
+
673
+ On the server, parse the field back out of `FormData` as JSON:
674
+
675
+ ```ts
676
+ function widgetFromFormData(data: FormData) {
677
+ let adjustments: IAdjustment[];
678
+ try {
679
+ adjustments = JSON.parse(String(data.get('adjustments') ?? '[]'));
680
+ } catch {
681
+ adjustments = [];
682
+ }
683
+ return { name: String(data.get('name') ?? '').trim(), adjustments };
684
+ }
685
+ ```
686
+
687
+ Sub-fields support the same validation rules as top-level fields (`required`, `min`/`max`, `pattern`, etc.), checked when an item is added or edited in the modal. `itemLabel` controls how each item summarizes itself in the list; without it, Runeforge joins the resolved display value of every sub-field with `·`.
688
+
689
+ ---
690
+
691
+ ## Components
692
+
693
+ ### GenericCRUD
694
+
695
+ The main CRUD orchestrator. It manages navigation between List, Create, Read, and Update views using URL search params (`?view=create`, `?id=xxx`, `?view=edit`).
696
+
697
+ Key props:
698
+
699
+ - `data` / `dataKey` — the record array (or [server-paginated envelope](#server-side-pagination-sorting--filtering)) and its primary key field
700
+ - `labelOne` / `labelMany` — singular and plural names for the entity
701
+ - `columns` — `ColumnDefinition[]` for the table view
702
+ - `fields` — `FieldDefinition[]` for form views
703
+ - `creation`, `update`, `read`, `deletion` — `ActionConfiguration` objects that define handlers and permissions for each operation. Set `confirm: true` on `deletion` to show a confirmation dialog before any delete (single row or batch)
704
+ - `actions` — a `ListActions` object grouping the list view's extra actions:
705
+ - `actions.custom` — `CustomAction[]`, extra per-row actions — see [Custom row actions](#custom-row-actions)
706
+ - `actions.bulk` — `CustomBulkAction[]`, extra actions on the current selection — see [Custom bulk actions](#custom-bulk-actions)
707
+ - `config` — a `ListConfig` object grouping the list view's opt-in behaviors:
708
+ - `config.search` — `SearchConfiguration`, shows a free-text search box — see [Free-text search](#free-text-search)
709
+ - `config.export` — `ExportConfiguration`, enables CSV/Excel export — see [Exporting data](#exporting-data-csvxlsx)
710
+ - `config.reorder` — `ReorderConfiguration`, enables drag-to-reorder rows — see [Reordering rows](#reordering-rows)
711
+
712
+ #### Free-text search
713
+
714
+ Passing `config.search` renders a debounced search box in the header. Typing updates a URL search param (`?search=...` by default), resets pagination and any open create/read/edit view, and leaves interpreting the term entirely to your `load` function — it's the same mechanism server-side pagination uses, so it composes naturally with it.
715
+
716
+ ```ts
717
+ <GenericCRUD
718
+ ...
719
+ config={{ search: { param: 'q', placeholder: 'Search tasks...', debounceMs: 300 } }}
720
+ />
721
+ ```
722
+
723
+ | Option | Default | Description |
724
+ | --- | --- | --- |
725
+ | `param` | `'search'` | Query-string parameter name |
726
+ | `placeholder` | `strings.searchPlaceholder` | Input placeholder |
727
+ | `debounceMs` | `300` | Delay before the URL updates |
728
+
729
+ #### Custom row actions
730
+
731
+ `actions.custom` adds entries to the per-row action menu, alongside the built-in view/edit/delete. Each `CustomAction` resolves in one of two ways — provide exactly one of `view` or `href`:
732
+
733
+ - `href(item)` — plain navigation, e.g. deep-linking into another CRUD's filtered list.
734
+ - `view` — a Svelte component of your own that `GenericCRUD` mounts directly (no wrapper) when the action runs. Since you own the whole component, you decide how it presents itself — typically as a modal built on the exported `Modal` component, sized however that action needs via `Modal`'s `class`/`width`/`maxWidth`/`height`/`maxHeight` props (see [Shared Components](#shared-components)).
735
+
736
+ ```ts
737
+ import ArchiveIcon from './icons/Archive.svelte';
738
+ import ArchiveForm from './ArchiveForm.svelte';
739
+
740
+ const actions: CustomAction<IWidget>[] = [
741
+ {
742
+ label: 'Archive',
743
+ icon: ArchiveIcon,
744
+ endpoint: '?/archive',
745
+ view: ArchiveForm,
746
+ condition: (item) => !item.archived,
747
+ },
748
+ {
749
+ label: 'Open in new tab',
750
+ icon: ExternalLinkIcon,
751
+ href: (item) => `/widgets/${item._id}`,
752
+ },
753
+ ];
754
+ ```
755
+
756
+ ```ts
757
+ <GenericCRUD ... actions={{ custom: actions }} />
758
+ ```
759
+
760
+ A `view` component receives `instance`, `label`, `endpoint`, `serverError`, `onCancel`, and `onSuccess` — the same shape Create/Update use internally — so it can reuse `enhance`-based form submission while rendering as a parametrized modal:
761
+
762
+ ```ts
763
+ <!-- ArchiveForm.svelte -->
764
+ <script lang="ts">
765
+ import { enhance } from '$app/forms';
766
+ import { Modal } from 'runeforge';
767
+
768
+ let { instance, label, endpoint, serverError, onCancel, onSuccess } = $props();
769
+ </script>
770
+
771
+ <Modal title={label} onClose={onCancel} maxWidth="28rem">
772
+ <form
773
+ method="POST"
774
+ action={endpoint}
775
+ use:enhance={() => async ({ result, update }) => {
776
+ await update({ reset: false });
777
+ if (result.type === 'success') onSuccess();
778
+ }}
779
+ >
780
+ <input type="hidden" name="id" value={instance._id} />
781
+ {#if serverError}<p class="text-error">{serverError}</p>{/if}
782
+ <div class="flex justify-end gap-2 mt-4">
783
+ <button type="button" onclick={onCancel}>Cancel</button>
784
+ <button type="submit">{label}</button>
785
+ </div>
786
+ </form>
787
+ </Modal>
788
+ ```
789
+
790
+ #### Custom bulk actions
791
+
792
+ `actions.bulk` adds buttons next to the built-in Delete button in the header, operating on the current row selection. Each one is disabled until at least one row is selected, and (like deletion) can require confirmation.
793
+
794
+ ```ts
795
+ <GenericCRUD
796
+ ...
797
+ actions={{
798
+ bulk: [
799
+ { kind: 'endpoint', label: 'Complete', icon: CheckIcon, endpoint: '?/complete' },
800
+ { kind: 'endpoint', label: 'Mark pending', icon: UndoIcon, endpoint: '?/incomplete', variant: 'error', confirm: true },
801
+ ],
802
+ }}
803
+ />
804
+ ```
805
+
806
+ `endpoint` is called once per selected row (`POST` with an `id` field), then the list is refreshed. `variant` matches DaisyUI's `btn-*` modifiers (`'primary'`, `'error'`, `'ghost'`, ...). `condition(selectedItems)` can hide the action entirely based on the current selection.
807
+
808
+ #### Exporting data (CSV/XLSX)
809
+
810
+ `config.export` adds an export button to the header offering CSV (always) and Excel (when a `xlsx` module is supplied). Its mere presence enables the button — pass `{}` for CSV-only export. Runeforge never bundles `xlsx` itself — install it separately and pass the resolved module in, so the dependency stays fully optional:
811
+
812
+ ```bash
813
+ pnpm add xlsx
814
+ ```
815
+
816
+ ```ts
817
+ <script>
818
+ import { GenericCRUD } from 'runeforge';
819
+ import * as xlsx from 'xlsx';
820
+ </script>
821
+
822
+ <GenericCRUD ... config={{ export: { xlsx } }} />
823
+ ```
824
+
825
+ In client-pagination mode, export includes every row currently matching the table's filters (not just the visible page). In [server-pagination mode](#server-side-pagination-sorting--filtering), pass `config.export.callback` to fetch the full, unpaginated result set for the current query — without it, export falls back to just the currently loaded page:
826
+
827
+ ```ts
828
+ <GenericCRUD
829
+ ...
830
+ config={{
831
+ export: {
832
+ callback: async (query) => {
833
+ const params = new URLSearchParams();
834
+ if (query.ordering) params.set('ordering', query.ordering);
835
+ // ...translate query.filters into your API's params
836
+ const res = await fetch(`/api/widgets/export?${params}`);
837
+ return res.json();
838
+ },
839
+ },
840
+ }}
841
+ />
842
+ ```
843
+
844
+ #### Reordering rows
845
+
846
+ `config.reorder` turns on drag-to-reorder: each row gets a drag handle (⋮⋮ by default — pass `icon` for something else, e.g. a hamburger or a grab-hand icon) with a thicker left border, and dragging a row persists a new sequential value for whichever attribute you point it at. Off by default; only appears once `config.reorder` is set.
847
+
848
+ Runeforge never imports `sortablejs` itself — install it separately and pass the resolved default export in via `sortable`, the same way `xlsx` works for export, so the dependency stays fully optional:
849
+
850
+ ```bash
851
+ pnpm add sortablejs
852
+ ```
853
+
854
+ ```ts
855
+ <script>
856
+ import { GenericCRUD } from 'runeforge';
857
+ import Sortable from 'sortablejs';
858
+ </script>
859
+
860
+ <GenericCRUD
861
+ ...
862
+ config={{
863
+ reorder: { attribute: 'order', sortable: Sortable, endpoint: '?/reorder' },
864
+ }}
865
+ />
866
+ ```
867
+
868
+ ```ts
869
+ // +page.server.ts
870
+ export const actions: Actions = {
871
+ reorder: async ({ request }) => {
872
+ const data = await request.formData();
873
+ const id = String(data.get('id') ?? '');
874
+ const order = Number(data.get('order') ?? 0);
875
+ await setWidgetOrder(id, order);
876
+ return { success: true };
877
+ },
878
+ };
879
+ ```
880
+
881
+ `endpoint` is `POST`ed once per row whose `attribute` value actually changed (FormData: `id` plus the attribute, e.g. `order`) after a drag, then the list refreshes — the same convention `deletion`/bulk actions use. Pass `callback` instead to handle the changed rows yourself (each one already carries its new attribute value):
882
+
883
+ ```ts
884
+ config={{
885
+ reorder: {
886
+ attribute: 'order',
887
+ sortable: Sortable,
888
+ callback: async (items) => {
889
+ await Promise.all(items.map((item) => api.updateWidget(item._id, { order: item.order })));
890
+ },
891
+ },
892
+ }}
893
+ ```
894
+
895
+ A few things worth knowing about how reorder mode behaves:
896
+
897
+ - **The list is ordered by `attribute` ascending (or `compare`, see below) the whole time reorder is active** — not by `_id`, not by whatever order the backend/array happens to return, and not by clicking a column header either: column-header sorting is unavailable while `config.reorder` is set, since row order needs a single, unambiguous source of truth for dragging to mean anything. This holds even if `attribute` is `excludedFromList` and has no column of its own — ordering reads the raw row value regardless of what's rendered. Per-column filters are unavailable for the same reason (a filtered-out row's position would become undefined).
898
+ - **Pagination stays on.** To move a row across a page boundary, drag it to the narrow zone at either edge of the table and hold — after `pageFlipThresholdMs` (default `2000`) it flips to the previous/next page; keep hovering (without letting go) to flip again. Drop once you're on the right page.
899
+ - It's **not supported in [server-pagination mode](#server-side-pagination-sorting--filtering)** (a `PaginatedEnvelope` `data`) — cross-page drag positions aren't meaningful without a lot more server-side machinery, so `config.reorder` is ignored whenever server pagination is active.
900
+ - Set `enabled: false` to keep the configuration in place (attribute, endpoint, icon) but temporarily turn dragging off, without having to remove the whole object.
901
+
902
+ **Composite orders.** Sometimes the attribute that stores a row's position isn't unique on its own — e.g. an indicator's `order` only makes sense *within* its parent chapter, so two indicators in different chapters can share the same `order` value, and the true display order is really (chapter's order, indicator's own order). Pass `compare` to take full control of that ordering instead of the plain `attribute`-ascending default:
903
+
904
+ ```ts
905
+ config={{
906
+ reorder: {
907
+ attribute: 'order',
908
+ sortable: Sortable,
909
+ compare: (a, b) => a.chapter.order - b.chapter.order || a.order - b.order,
910
+ endpoint: '?/reorder',
911
+ },
912
+ }}
913
+ ```
914
+
915
+ `attribute` is still what gets written back after a drag (as a plain sequential 0-based index across the whole reordered list) — `compare` only decides how rows are displayed and dragged. If a naive global renumbering doesn't fit your data model (as in the chapter example — you don't want every indicator in every chapter renumbered whenever one indicator moves within its own chapter), use `callback` instead of `endpoint` and remap the incoming rows' indices to whatever scoped scheme your backend actually expects before saving.
916
+
917
+ **Multi-select drag.** Set `multiDrag: true` to let dragging one row of the current checkbox selection carry the whole selection along with it, via SortableJS's `MultiDrag` plugin — mount it on the module you pass in:
918
+
919
+ ```ts
920
+ import Sortable, { MultiDrag } from 'sortablejs';
921
+ Sortable.mount(new MultiDrag());
922
+ ```
923
+
924
+ ```ts
925
+ config={{
926
+ reorder: { attribute: 'order', sortable: Sortable, multiDrag: true, endpoint: '?/reorder' },
927
+ }}
928
+ ```
929
+
930
+ The existing row-selection checkboxes *are* the multi-drag selection — there's no separate ctrl/cmd-click UI to learn. Note that `multiDrag` uses native HTML5 drag-and-drop rather than the mouse-simulated dragging the rest of reorder mode uses (SortableJS's `MultiDrag` plugin needs it to track a multi-row drag correctly) — runeforge switches automatically, but it's worth knowing if you're scripting drags for tests.
931
+
932
+ #### Server-side pagination, sorting & filtering
933
+
934
+ By default, `GenericCRUD` and `PaginatedTable` paginate, sort, and filter the full `data` array in the browser. For large datasets, return a `PaginatedEnvelope<T>` from your `load` function instead — `{ results, count, page, pageSize }` — and Runeforge switches to server mode automatically: it drives `page`, `ordering`, and per-column filter values through the URL, and expects your `load` function to read them back.
935
+
936
+ ```ts
937
+ // +page.server.ts
938
+ export const load: PageServerLoad = ({ url }) => {
939
+ const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
940
+ const ordering = url.searchParams.get('ordering');
941
+ const name = url.searchParams.get('name'); // per-column text filter
942
+
943
+ let rows = [...allWidgets];
944
+ if (name) rows = rows.filter((w) => w.name.toLowerCase().includes(name.toLowerCase()));
945
+ if (ordering) {
946
+ const desc = ordering.startsWith('-');
947
+ const field = desc ? ordering.slice(1) : ordering;
948
+ rows = [...rows].sort((a, b) => (desc ? -1 : 1) * compare(a[field], b[field]));
949
+ }
950
+
951
+ const pageSize = 20;
952
+ const start = (page - 1) * pageSize;
953
+ return { widgets: { results: rows.slice(start, start + pageSize), count: rows.length, page, pageSize } };
954
+ };
955
+ ```
956
+
957
+ ```ts
958
+ <GenericCRUD
959
+ ...
960
+ data={{ widgets: data.widgets }}
961
+ dataKey="widgets"
962
+ />
963
+ ```
964
+
965
+ No other prop changes are needed — column sorting/filtering UI, the paginator, and (with `config.export.callback`) export all keep working the same way, just backed by the server instead of the in-memory array. Boolean-column filters send comma-separated values (`?active=true,false`); date-range filters send `<attribute>_from`/`<attribute>_to`.
966
+
967
+ A text column's filter checkbox list is populated from values seen on the currently loaded page — a cosmetic hint in server mode, not an exhaustive list, since the full set of values lives server-side. For a column whose possible values are a known, bounded set (an enum-like field, a small lookup table), give it `filterOptions` instead so every choice always shows up, regardless of what the current page contains:
968
+
969
+ ```ts
970
+ status: {
971
+ label: 'Status',
972
+ type: AttributeType.text,
973
+ filterable: true,
974
+ filterOptions: [
975
+ { value: 'DRAFT', label: 'Draft' },
976
+ { value: 'PUBLISHED', label: 'Published' },
977
+ { value: 'ARCHIVED', label: 'Archived' },
978
+ ],
979
+ },
980
+ ```
981
+
982
+ `value` is matched against the column's rendered cell text and sent server-side as-is (same as any other checkbox filter value); `label` is only what's displayed, falling back to `value`.
983
+
984
+ ### PaginatedTable
985
+
986
+ A standalone table component with built-in sort, filter, and pagination — the same engine `GenericCRUD` uses internally.
987
+
988
+ ```ts
989
+ <script>
990
+ import { PaginatedTable } from 'runeforge';
991
+ </script>
992
+
993
+ <PaginatedTable {data} {columns} />
994
+ ```
995
+
996
+ Sort and filter state can be managed externally via the exported `SortState` and `FilterState` classes. Pass a `pagination` prop (`ServerPagination`) plus `onPaginationChange` to opt into the same [server-driven mode](#server-side-pagination-sorting--filtering) `GenericCRUD` uses. `bind:visibleRows` and `bind:query` expose the currently filtered/sorted rows and query snapshot, useful for building your own export UI on top of the raw table.
997
+
998
+ Pass a `reorder` prop (`{ attribute, sortable, compare?, icon?, multiDrag?, pageFlipThresholdMs? }` — same shape as `GenericCRUD`'s [`config.reorder`](#reordering-rows), minus `endpoint`/`callback`) plus `onReorder` to get the same drag-to-reorder behavior without the CRUD-level persistence wiring — `onReorder` fires with the complete reordered row list after each drag, and it's on you to decide what to do with it. Ignored whenever `pagination` is also set.
999
+
1000
+ ### Form Components
1001
+
1002
+ Individual form primitives styled with DaisyUI:
1003
+
1004
+ - `Button` — styled action button
1005
+ - `Label` — form label with optional required marker
1006
+ - `Select` — dropdown with option group support, optional in-memory filtering, and an optional `search` prop for server-resolved options (see [Select options](#select-options))
1007
+ - `PasswordInput` — password field with show/hide toggle; `labelClass`, `inputClass`, and `buttonClass` props let you restyle the wrapper, input, and toggle button independently
1008
+
1009
+ ### Shared Components
1010
+
1011
+ - `Avatar` — user avatar display
1012
+ - `Modal` — DaisyUI modal wrapper. Size it with Tailwind utility classes via `class` (e.g. `class="max-w-4xl"`), or with explicit `width`/`maxWidth`/`height`/`maxHeight` CSS lengths, which are applied as inline styles and take priority over `class`
1013
+ - `Breadcrumbs` — navigation breadcrumb trail
1014
+ - `IconRenderer` — renders icons from the active icon set
1015
+
1016
+ ---
1017
+
1018
+ ## Formatters
1019
+
1020
+ Formatters are functions you attach to a metadata field to control how its value is displayed in the table and read view. They follow a curried signature: `(data) => (value) => string`, where `data` is the full page data object (useful for resolving related records).
1021
+
1022
+ ### `formatBoolean`
1023
+
1024
+ Converts a boolean to a readable label.
1025
+
1026
+ > [!NOTE]
1027
+ > Defaults to `Sí` / `No` because this was created at Argentina papá! 🇦🇷.
1028
+
1029
+ ```ts
1030
+ import { formatBoolean } from 'runeforge';
1031
+
1032
+ isActive: {
1033
+ label: 'Active',
1034
+ type: AttributeType.boolean,
1035
+ formatter: formatBoolean(),
1036
+ // or with custom labels:
1037
+ formatter: formatBoolean('Enabled', 'Disabled'),
1038
+ },
1039
+ ```
1040
+
1041
+ ### `formatDatetime`
1042
+
1043
+ Formats a `Date` value using the tokens `dd`, `mm`, `YYYY`, `HH`, `MM`, `ss`.
1044
+
1045
+ > [!NOTE]
1046
+ > Defaults to `'dd/mm/YYYY HH:MM'`.
1047
+
1048
+ ```ts
1049
+ import { formatDatetime } from 'runeforge';
1050
+
1051
+ createdAt: {
1052
+ label: 'Created',
1053
+ type: AttributeType.datetime,
1054
+ formatter: formatDatetime(), // → "13/06/2026 09:45"
1055
+ },
1056
+
1057
+ publishedAt: {
1058
+ label: 'Published',
1059
+ type: AttributeType.datetime,
1060
+ formatter: formatDatetime('dd/mm/YYYY'), // → "13/06/2026"
1061
+ },
1062
+ ```
1063
+
1064
+ ### `formatTruncateTextUpTo`
1065
+
1066
+ Truncates long text to a maximum character count, appending `…`.
1067
+
1068
+ ```ts
1069
+ import { formatTruncateTextUpTo } from 'runeforge';
1070
+
1071
+ description: {
1072
+ label: 'Description',
1073
+ type: AttributeType.textarea,
1074
+ formatter: formatTruncateTextUpTo(80),
1075
+ },
1076
+ ```
1077
+
1078
+ ### `formatInstance`
1079
+
1080
+ Resolves a foreign-key ID to a linked label. Receives the related records and the URL path for the detail view, and renders an anchor tag pointing to that record.
1081
+
1082
+ ```ts
1083
+ import { formatInstance } from 'runeforge';
1084
+ import type { ICategory } from '$lib/server/categories/interface';
1085
+
1086
+ categoryId: {
1087
+ label: 'Category',
1088
+ type: AttributeType.select,
1089
+ options: (data: { categories?: ICategory[] }) =>
1090
+ (data.categories ?? []).map((c) => ({ value: c._id, label: c.name })),
1091
+ formatter: (data: { categories?: ICategory[] }) =>
1092
+ formatInstance<ICategory>('name', data.categories ?? [], '/admin/categories'),
1093
+ },
1094
+ ```
1095
+
1096
+ ---
1097
+
1098
+ ## Custom Cell Components
1099
+
1100
+ Instead of a `formatter`, a metadata field can declare a `component` — a Svelte component that renders the cell in both the table list and the read view. This is useful when you need to render something visual, like an avatar image or an icon, rather than plain text.
1101
+
1102
+ A cell component receives two props defined by `CellProps<T, V>`:
1103
+
1104
+ - `value` — the raw field value for that cell
1105
+ - `row` — the full record object, useful when the rendering depends on other fields
1106
+
1107
+ ```ts
1108
+ // CellProps interface (from runeforge)
1109
+ interface CellProps<T extends object, V> {
1110
+ value: V;
1111
+ row: T;
1112
+ }
1113
+ ```
1114
+
1115
+ ### Example: avatar column
1116
+
1117
+ The following renders a user photo with a fallback to initials, using data from sibling fields on the row:
1118
+
1119
+ ```ts
1120
+ <!-- components/UserAvatar.svelte -->
1121
+ <script lang="ts">
1122
+ import { Avatar } from 'runeforge';
1123
+ import type { CellProps } from 'runeforge';
1124
+
1125
+ type UserRow = { firstName?: string; lastName?: string; email?: string };
1126
+
1127
+ let { value, row }: CellProps<UserRow, string | null> = $props();
1128
+
1129
+ const initials = [row.firstName?.[0], row.lastName?.[0]].filter(Boolean).join('').toUpperCase();
1130
+ </script>
1131
+
1132
+ <Avatar src={value} text={initials} alt={row.email ?? ''} />
1133
+ ```
1134
+
1135
+ Register it in the metadata with `component`:
1136
+
1137
+ ```ts
1138
+ // interface.ts
1139
+ import UserAvatar from './components/UserAvatar.svelte';
1140
+
1141
+ export const userMeta = {
1142
+ photo: {
1143
+ label: 'Photo',
1144
+ type: AttributeType.file,
1145
+ component: UserAvatar,
1146
+ sortable: false,
1147
+ filterable: false,
1148
+ },
1149
+ // ...
1150
+ } satisfies InterfaceMetadata<IUser>;
1151
+ ```
1152
+
1153
+ ### Example: icon column
1154
+
1155
+ A simpler case — render a Bootstrap icon by name stored as a plain string:
1156
+
1157
+ ```ts
1158
+ <!-- components/IconCell.svelte -->
1159
+ <script lang="ts">
1160
+ import { IconRenderer } from 'runeforge';
1161
+ import type { CellProps } from 'runeforge';
1162
+
1163
+ let { value }: CellProps<Record<string, unknown>, string> = $props();
1164
+ </script>
1165
+
1166
+ <IconRenderer name={value} />
1167
+ ```
1168
+
1169
+ ```ts
1170
+ icon: {
1171
+ label: 'Icon',
1172
+ type: AttributeType.text,
1173
+ component: IconCell,
1174
+ },
1175
+ ```
1176
+
1177
+ > [!TIP]
1178
+ > Both `AvatarCell` and `IconCell` are included in the package and ready to use — you don't need to build them from scratch:
1179
+ >
1180
+ > ```ts
1181
+ > import { AvatarCell, IconCell } from 'runeforge';
1182
+ >
1183
+ > photo: { label: 'Photo', type: AttributeType.file, component: AvatarCell },
1184
+ > icon: { label: 'Icon', type: AttributeType.text, component: IconCell },
1185
+ > ```
1186
+
1187
+ ---
1188
+
1189
+ ## Internationalization
1190
+
1191
+ All UI strings default to **Spanish** (Argentina). To switch to another language, call `setStrings` in your root layout with a full or partial `RuneforgeStrings` object. Values you omit fall back to the Spanish defaults.
1192
+
1193
+ ### Switch to English
1194
+
1195
+ ```ts
1196
+ <!-- +layout.svelte -->
1197
+ <script>
1198
+ import { setStrings, en } from 'runeforge';
1199
+
1200
+ setStrings(en);
1201
+ </script>
1202
+ ```
1203
+
1204
+ ### Override individual strings
1205
+
1206
+ ```ts
1207
+ <script>
1208
+ import { setStrings } from 'runeforge';
1209
+
1210
+ setStrings({
1211
+ create: 'New',
1212
+ save: 'Confirm',
1213
+ required: (field) => `${field} cannot be blank`,
1214
+ });
1215
+ </script>
1216
+ ```
1217
+
1218
+ ### Full `RuneforgeStrings` reference
1219
+
1220
+ | Key | Type | Spanish default |
1221
+ | --- | --- | --- |
1222
+ | `showing` | `(start, end, total) => string` | `Mostrando 1–10 de 25` |
1223
+ | `actions` | `string` | `Acciones` |
1224
+ | `filter` | `string` | `Filtrar` |
1225
+ | `filterColumn` | `(column) => string` | `Filtrar Nombre` |
1226
+ | `filterPlaceholder` | `string` | `Filtrar…` |
1227
+ | `clearFilter` | `string` | `Limpiar filtro` |
1228
+ | `emptyValue` | `string` | `(vacío)` |
1229
+ | `previous` | `string` | `Anterior` |
1230
+ | `next` | `string` | `Siguiente` |
1231
+ | `selectPlaceholder` | `string` | `Seleccioná una opción` |
1232
+ | `selectSearch` | `string` | `Buscar...` |
1233
+ | `selectSearching` | `string` | `Buscando...` |
1234
+ | `selectNoResults` | `string` | `Sin resultados` |
1235
+ | `view` | `string` | `Ver` |
1236
+ | `edit` | `string` | `Editar` |
1237
+ | `delete` | `string` | `Eliminar` |
1238
+ | `create` | `string` | `Crear` |
1239
+ | `searchPlaceholder` | `string` | `Buscar...` |
1240
+ | `reorder` | `string` | `Arrastrar para reordenar` |
1241
+ | `export` | `string` | `Exportar` |
1242
+ | `exportCsv` | `string` | `Exportar a CSV` |
1243
+ | `exportExcel` | `string` | `Exportar a Excel` |
1244
+ | `save` | `string` | `Guardar` |
1245
+ | `saveAndContinue` | `string` | `Guardar y continuar` |
1246
+ | `cancel` | `string` | `Cancelar` |
1247
+ | `back` | `string` | `Volver` |
1248
+ | `add` | `string` | `Agregar` |
1249
+ | `remove` | `string` | `Quitar` |
1250
+ | `noItems` | `string` | `Sin elementos agregados` |
1251
+ | `confirm` | `string` | `Confirmar` |
1252
+ | `deleteConfirm` | `(count, actionLabel) => string` | `¿Seguro que querés eliminar 3 elementos?` |
1253
+ | `required` | `(field) => string` | `Título es requerido` |
1254
+ | `invalidNumber` | `(field) => string` | `Cantidad debe ser un número` |
1255
+ | `integer` | `(field) => string` | `Cantidad debe ser un número entero` |
1256
+ | `min` | `(field, min) => string` | `Cantidad debe ser mayor o igual a 1` |
1257
+ | `max` | `(field, max) => string` | `Cantidad debe ser menor o igual a 100` |
1258
+ | `minLength` | `(field, min) => string` | `Notas debe tener al menos 3 caracteres` |
1259
+ | `maxLength` | `(field, max) => string` | `Notas debe tener como máximo 200 caracteres` |
1260
+ | `pattern` | `(field) => string` | `Código tiene un formato inválido` |
1261
+ | `serverError` | `string` | `Error inesperado del servidor.` |
1262
+
1263
+ > [!NOTE]
1264
+ > Defaults to Spanish because this was built in Argentina! 🇦🇷
1265
+
1266
+ ### Bundled locales
1267
+
1268
+ | Import | Language |
1269
+ | --- | --- |
1270
+ | `es` | Spanish 🇦🇷 (default) |
1271
+ | `en` | English 🇺🇸 |
1272
+
1273
+ ---
1274
+
1275
+ ## Icon System
1276
+
1277
+ Runeforge ships with a default icon set. To use Bootstrap Icons instead:
1278
+
1279
+ ```ts
1280
+ <script>
1281
+ import { setIconSet, bootstrapIcons } from 'runeforge';
1282
+
1283
+ setIconSet(bootstrapIcons);
1284
+ </script>
1285
+ ```
1286
+
1287
+ You can also provide a fully custom icon set by passing an object that satisfies the icon set interface.
1288
+
1289
+ ---
1290
+
1291
+ ## Running Tests
1292
+
1293
+ ### Unit Tests
1294
+
1295
+ Unit tests cover utility functions (formatters, resolution helpers, misc utilities) and run with [Vitest](https://vitest.dev/).
1296
+
1297
+ ```bash
1298
+ # Single run
1299
+ pnpm test:unit
1300
+
1301
+ # Watch mode
1302
+ pnpm test:unit:watch
1303
+ ```
1304
+
1305
+ ### End-to-End Tests
1306
+
1307
+ E2E tests cover table interactions (pagination, sorting, filtering) and run with [Playwright](https://playwright.dev/). The dev server starts automatically when running locally.
1308
+
1309
+ ```bash
1310
+ pnpm test:e2e
1311
+ ```
1312
+
1313
+ ### Run All Tests
1314
+
1315
+ ```bash
1316
+ pnpm test
1317
+ ```
1318
+
1319
+ ---
1320
+
1321
+ ## Development
1322
+
1323
+ ```bash
1324
+ # Start the dev server
1325
+ pnpm dev
1326
+
1327
+ # Type-check
1328
+ pnpm check
1329
+
1330
+ # Lint and format
1331
+ pnpm lint
1332
+ pnpm format
1333
+
1334
+ # Build the library
1335
+ pnpm build
1336
+ ```
1337
+
1338
+ ---
1339
+
1340
+ ## License
1341
+
1342
+ MIT