runeforge 0.0.23 → 0.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,8 +29,21 @@ A SvelteKit toolkit that forges forms, tables, actions, and CRUD workflows from
29
29
  - [2. Create the model](#2-create-the-model)
30
30
  - [3. Set up the server](#3-set-up-the-server)
31
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
+ - [Default values](#default-values)
38
+ - [Select options](#select-options)
39
+ - [Embedded fields (sub-documents)](#embedded-fields-sub-documents)
32
40
  - [Components](#components)
33
41
  - [GenericCRUD](#genericcrud)
42
+ - [Free-text search](#free-text-search)
43
+ - [Custom row actions](#custom-row-actions)
44
+ - [Custom bulk actions](#custom-bulk-actions)
45
+ - [Exporting data (CSV/XLSX)](#exporting-data-csvxlsx)
46
+ - [Server-side pagination, sorting & filtering](#server-side-pagination-sorting--filtering)
34
47
  - [PaginatedTable](#paginatedtable)
35
48
  - [Form Components](#form-components)
36
49
  - [Shared Components](#shared-components)
@@ -70,14 +83,22 @@ Runeforge provides a set of composable, metadata-driven components for building
70
83
  - Tailwind CSS 4
71
84
  - DaisyUI 5
72
85
  - Cally
86
+ - `xlsx` (optional, only if you enable Excel export)
73
87
 
74
88
  ---
75
89
 
76
90
  ## Key Features
77
91
 
78
92
  - **GenericCRUD** — a single orchestrator component that wires together list, create, read, and update views from field and column definitions.
79
- - **PaginatedTable** — a full-featured table with sorting, filtering, pagination, and row selection.
80
- - **Field system** — declarative field definitions that drive both form rendering and display, supporting text, email, password, number, boolean, textarea, file, select, and datetime types.
93
+ - **PaginatedTable** — a full-featured table with sorting, filtering, pagination, and row selection, usable either fully client-side or driven by a server-paginated backend.
94
+ - **Field system** — declarative field definitions that drive both form rendering and display, supporting text, email, password, number, boolean, textarea, file, select, datetime, and embedded (sub-document list) types.
95
+ - **Validation** — built-in `required`, `min`/`max`, `integer`, `minLength`/`maxLength`, and `pattern` rules, checked client-side before submit with consistent, translatable error messages.
96
+ - **Conditional fields & field grouping** — disable a field based on the current values of others in the same form, and visually group related fields under a titled `fieldset`.
97
+ - **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.
98
+ - **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.
99
+ - **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.
100
+ - **CSV/XLSX export** — one-click export of the current table view, with optional Excel support via the `xlsx` package.
101
+ - **Server-side pagination, sorting & filtering** — point `GenericCRUD`/`PaginatedTable` at a paginated envelope and it drives page/sort/filter state through the URL for you.
81
102
  - **Pluggable icon system** — swap the default icon set or use the included Bootstrap Icons alternative via `setIconSet`.
82
103
  - **Standalone components** — table, form, and navigation components can be used independently without the full CRUD orchestrator.
83
104
 
@@ -136,13 +157,15 @@ Responsive overrides work too:
136
157
  | `--runeforge-breadcrumb-font-size` | `0.875rem` | Breadcrumb label text size |
137
158
  | `--runeforge-breadcrumb-icon-size` | `1rem` | Breadcrumb icon width and height |
138
159
 
160
+ Modal sizing (see [Shared Components](#shared-components)) is set per-instance via props rather than a CSS variable.
161
+
139
162
  ---
140
163
 
141
164
  ## Configuration
142
165
 
143
166
  Global settings are applied once in your root layout via `setConfig`. This avoids passing the same prop to every CRUD component.
144
167
 
145
- ```svelte
168
+ ```ts
146
169
  <!-- +layout.svelte -->
147
170
  <script>
148
171
  import { setConfig } from 'runeforge';
@@ -204,7 +227,7 @@ export const articleMeta = {
204
227
  } satisfies InterfaceMetadata<IArticle>;
205
228
  ```
206
229
 
207
- 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.
230
+ 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.
208
231
 
209
232
  ### 2. Create the model
210
233
 
@@ -307,7 +330,7 @@ The `load` function returns a single record when `?id=` is present (used by the
307
330
 
308
331
  If your records use a different identifier field than `_id` (e.g. a plain `id`), pass the `idKey` prop:
309
332
 
310
- ```svelte
333
+ ```ts
311
334
  <GenericCRUD idKey="id" ... />
312
335
  ```
313
336
 
@@ -315,6 +338,250 @@ This propagates to navigation URLs, form submissions, deletion calls, and the au
315
338
 
316
339
  ---
317
340
 
341
+ ## Field System
342
+
343
+ 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.
344
+
345
+ ### Attribute reference
346
+
347
+ | Option | Type | Applies to | Description |
348
+ | --- | --- | --- | --- |
349
+ | `label` | `string` | all | Column header, form label, and the field name used in validation messages |
350
+ | `type` | `AttributeType` | all | `text` \| `email` \| `password` \| `number` \| `boolean` \| `textarea` \| `file` \| `select` \| `datetime` \| `embedded` |
351
+ | `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) |
352
+ | `autocomplete` | `FullAutoFill` | text-like | Native `autocomplete` attribute |
353
+ | `placeholder` | `string` | text-like, select | Placeholder text |
354
+ | `default` | `value \| (data) => value` | all | Initial value on the create form — see [Default values](#default-values) |
355
+ | `min` / `max` | `number` | `number` | Numeric range validation |
356
+ | `integer` | `boolean` | `number` | Rejects non-whole numbers |
357
+ | `minLength` / `maxLength` | `number` | text-like | Character-count validation |
358
+ | `pattern` | `string` | text-like | Regex the value must match (`new RegExp(pattern)`) |
359
+ | `disabled` | `(record) => boolean` | all | Conditionally disables the input — see [Conditional fields](#conditional-fields) |
360
+ | `groupedAs` | `string` | all | Visually groups fields under a titled section — see [Field grouping](#field-grouping) |
361
+ | `options` | `SelectOption[] \| (data) => SelectOption[]` | `select` | Static or computed option list — see [Select options](#select-options) |
362
+ | `dependentOptions` | `(data, record) => SelectOption[]` | `select` | Options derived from other fields' current values |
363
+ | `search` | `(query) => Promise<SelectOption[]>` | `select` | Server-side option search as the user types |
364
+ | `seed` | `(instance) => unknown` | all | Overrides how the update form seeds this field from the loaded record |
365
+ | `fields` | `InterfaceMetadata<any>` | `embedded` | Sub-field schema for each item — see [Embedded fields](#embedded-fields-sub-documents) |
366
+ | `itemLabel` | `(item) => string` | `embedded` | Summary label for an item in the embedded list |
367
+ | `component` | `CellComponent` | all | Custom cell renderer — see [Custom Cell Components](#custom-cell-components) |
368
+ | `formatter` | `(data) => (value, row) => string` | all | Custom cell text — see [Formatters](#formatters) |
369
+ | `excludedFromList/Create/Read/Update` | `boolean` | all | Hides the field from that specific view |
370
+ | `sortable` / `filterable` | `boolean` | all | Table column controls |
371
+
372
+ ### Validation
373
+
374
+ `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.
375
+
376
+ ```ts
377
+ code: {
378
+ label: 'Code',
379
+ type: AttributeType.text,
380
+ required: true,
381
+ pattern: '^[A-Z0-9]{3,8}$',
382
+ },
383
+ quantity: {
384
+ label: 'Quantity',
385
+ type: AttributeType.number,
386
+ min: 1,
387
+ max: 100,
388
+ integer: true,
389
+ },
390
+ notes: {
391
+ label: 'Notes',
392
+ type: AttributeType.textarea,
393
+ minLength: 3,
394
+ maxLength: 200,
395
+ },
396
+ ```
397
+
398
+ `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:
399
+
400
+ ```ts
401
+ formula: {
402
+ label: 'Formula',
403
+ type: AttributeType.select,
404
+ options: [
405
+ { value: 'benchmark', label: 'Benchmark' },
406
+ { value: 'max', label: 'Max' },
407
+ ],
408
+ },
409
+ quantity: {
410
+ label: 'Quantity',
411
+ type: AttributeType.number,
412
+ // Not required for the "benchmark" formula, mandatory for every other one.
413
+ required: (record) => record.formula !== 'benchmark',
414
+ },
415
+ ```
416
+
417
+ The label's required marker and the submit-time check both re-evaluate the same way `disabled` does — see [Conditional fields](#conditional-fields).
418
+
419
+ > [!TIP]
420
+ > Client-side validation is a UX nicety, not a security boundary — always re-validate in your form actions.
421
+
422
+ ### Conditional fields
423
+
424
+ `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.
425
+
426
+ ```ts
427
+ unlimited: {
428
+ label: 'Unlimited quantity',
429
+ type: AttributeType.boolean,
430
+ default: false,
431
+ },
432
+ quantity: {
433
+ label: 'Quantity',
434
+ type: AttributeType.number,
435
+ min: 1,
436
+ disabled: (record) => !!record.unlimited,
437
+ },
438
+ ```
439
+
440
+ ### Field grouping
441
+
442
+ 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.
443
+
444
+ ```ts
445
+ code: {
446
+ label: 'Code',
447
+ type: AttributeType.text,
448
+ groupedAs: 'Identification',
449
+ },
450
+ sku: {
451
+ label: 'SKU',
452
+ type: AttributeType.text,
453
+ groupedAs: 'Identification',
454
+ },
455
+ ```
456
+
457
+ ### Default values
458
+
459
+ `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.
460
+
461
+ ```ts
462
+ published: {
463
+ label: 'Published',
464
+ type: AttributeType.boolean,
465
+ default: false,
466
+ },
467
+ assignedTo: {
468
+ label: 'Assigned to',
469
+ type: AttributeType.select,
470
+ options: (data: { users?: IUser[] }) => (data.users ?? []).map((u) => ({ value: u._id, label: u.name })),
471
+ default: (data: { currentUserId?: string }) => data.currentUserId ?? '',
472
+ },
473
+ ```
474
+
475
+ ### Select options
476
+
477
+ `select` fields support four ways of resolving their options, which can be combined as needed:
478
+
479
+ - **Static** — a plain `SelectOption[]` array.
480
+ - **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).
481
+ - **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.
482
+ - **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.
483
+
484
+ ```ts
485
+ // Dependent options: narrow "city" choices by the selected "country"
486
+ country: {
487
+ label: 'Country',
488
+ type: AttributeType.select,
489
+ options: [{ value: 'ar', label: 'Argentina' }, { value: 'uy', label: 'Uruguay' }],
490
+ },
491
+ city: {
492
+ label: 'City',
493
+ type: AttributeType.select,
494
+ dependentOptions: (data, record) => CITIES_BY_COUNTRY[record.country as string] ?? [],
495
+ },
496
+
497
+ // Server-aware search: fall back to a prefetched slice, but query the
498
+ // server for anything outside it.
499
+ owner: {
500
+ label: 'Owner',
501
+ type: AttributeType.select,
502
+ placeholder: 'Choose an owner',
503
+ options: (data: { owners?: IOwner[] }) => (data.owners ?? []).map((o) => ({ value: o.id, label: o.name })),
504
+ search: async (query) => {
505
+ const fd = new FormData();
506
+ fd.set('query', query);
507
+ const res = await fetch('?/searchOwners', { method: 'POST', body: fd });
508
+ const result = deserialize(await res.text());
509
+ if (result.type !== 'success') return [];
510
+ return (result.data.owners ?? []).map((o: IOwner) => ({ value: o.id, label: o.name }));
511
+ },
512
+ },
513
+ ```
514
+
515
+ ```ts
516
+ // +page.server.ts
517
+ export const actions: Actions = {
518
+ // ...create/update/delete
519
+ searchOwners: async ({ request }) => {
520
+ const data = await request.formData();
521
+ const query = String(data.get('query') ?? '');
522
+ return { owners: await Owner.find({ name: { $regex: query, $options: 'i' } }).limit(20).lean() };
523
+ },
524
+ };
525
+ ```
526
+
527
+ ### Embedded fields (sub-documents)
528
+
529
+ `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.
530
+
531
+ ```ts
532
+ export interface IAdjustment {
533
+ kind: string;
534
+ amount: number;
535
+ }
536
+
537
+ export interface IWidget {
538
+ _id: string;
539
+ name: string;
540
+ adjustments: IAdjustment[];
541
+ }
542
+
543
+ export const widgetMeta = {
544
+ name: { label: 'Name', type: AttributeType.text, required: true },
545
+ adjustments: {
546
+ label: 'Adjustments',
547
+ type: AttributeType.embedded,
548
+ // Arrays of objects have no sensible plain-text table cell.
549
+ excludedFromList: true,
550
+ fields: {
551
+ kind: {
552
+ label: 'Kind',
553
+ type: AttributeType.select,
554
+ required: true,
555
+ options: [
556
+ { value: 'bonus', label: 'Bonus' },
557
+ { value: 'penalty', label: 'Penalty' },
558
+ ],
559
+ },
560
+ amount: { label: 'Amount', type: AttributeType.number, required: true, min: 0 },
561
+ },
562
+ itemLabel: (item) => `${item.kind === 'bonus' ? 'Bonus' : 'Penalty'}: ${item.amount}`,
563
+ },
564
+ } satisfies InterfaceMetadata<IWidget>;
565
+ ```
566
+
567
+ On the server, parse the field back out of `FormData` as JSON:
568
+
569
+ ```ts
570
+ function widgetFromFormData(data: FormData) {
571
+ let adjustments: IAdjustment[];
572
+ try {
573
+ adjustments = JSON.parse(String(data.get('adjustments') ?? '[]'));
574
+ } catch {
575
+ adjustments = [];
576
+ }
577
+ return { name: String(data.get('name') ?? '').trim(), adjustments };
578
+ }
579
+ ```
580
+
581
+ 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 `·`.
582
+
583
+ ---
584
+
318
585
  ## Components
319
586
 
320
587
  ### GenericCRUD
@@ -323,17 +590,183 @@ The main CRUD orchestrator. It manages navigation between List, Create, Read, an
323
590
 
324
591
  Key props:
325
592
 
326
- - `data` / `dataKey` — the record array and its primary key field
593
+ - `data` / `dataKey` — the record array (or [server-paginated envelope](#server-side-pagination-sorting--filtering)) and its primary key field
327
594
  - `labelOne` / `labelMany` — singular and plural names for the entity
328
595
  - `columns` — `ColumnDefinition[]` for the table view
329
596
  - `fields` — `FieldDefinition[]` for form views
330
597
  - `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)
598
+ - `actions` — `CustomAction[]`, extra per-row actions — see [Custom row actions](#custom-row-actions)
599
+ - `customBulkActions` — `CustomBulkAction[]`, extra actions on the current selection — see [Custom bulk actions](#custom-bulk-actions)
600
+ - `search` — `SearchConfiguration`, shows a free-text search box — see [Free-text search](#free-text-search)
601
+ - `enableExport`, `onExport`, `xlsx` — CSV/Excel export — see [Exporting data](#exporting-data-csvxlsx)
331
602
 
332
- ### PaginatedTable
603
+ #### Free-text search
604
+
605
+ Passing `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.
333
606
 
334
- A standalone table component with built-in sort, filter, and pagination.
607
+ ```ts
608
+ <GenericCRUD
609
+ ...
610
+ search={{ param: 'q', placeholder: 'Search tasks...', debounceMs: 300 }}
611
+ />
612
+ ```
613
+
614
+ | Option | Default | Description |
615
+ | --- | --- | --- |
616
+ | `param` | `'search'` | Query-string parameter name |
617
+ | `placeholder` | `strings.searchPlaceholder` | Input placeholder |
618
+ | `debounceMs` | `300` | Delay before the URL updates |
619
+
620
+ #### Custom row actions
621
+
622
+ `actions` 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`:
623
+
624
+ - `href(item)` — plain navigation, e.g. deep-linking into another CRUD's filtered list.
625
+ - `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)).
626
+
627
+ ```ts
628
+ import ArchiveIcon from './icons/Archive.svelte';
629
+ import ArchiveForm from './ArchiveForm.svelte';
630
+
631
+ const actions: CustomAction<IWidget>[] = [
632
+ {
633
+ label: 'Archive',
634
+ icon: ArchiveIcon,
635
+ endpoint: '?/archive',
636
+ view: ArchiveForm,
637
+ condition: (item) => !item.archived,
638
+ },
639
+ {
640
+ label: 'Open in new tab',
641
+ icon: ExternalLinkIcon,
642
+ href: (item) => `/widgets/${item._id}`,
643
+ },
644
+ ];
645
+ ```
646
+
647
+ ```svelte
648
+ <GenericCRUD ... {actions} />
649
+ ```
650
+
651
+ 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:
335
652
 
336
653
  ```svelte
654
+ <!-- ArchiveForm.svelte -->
655
+ <script lang="ts">
656
+ import { enhance } from '$app/forms';
657
+ import { Modal } from 'runeforge';
658
+
659
+ let { instance, label, endpoint, serverError, onCancel, onSuccess } = $props();
660
+ </script>
661
+
662
+ <Modal title={label} onClose={onCancel} maxWidth="28rem">
663
+ <form
664
+ method="POST"
665
+ action={endpoint}
666
+ use:enhance={() => async ({ result, update }) => {
667
+ await update({ reset: false });
668
+ if (result.type === 'success') onSuccess();
669
+ }}
670
+ >
671
+ <input type="hidden" name="id" value={instance._id} />
672
+ {#if serverError}<p class="text-error">{serverError}</p>{/if}
673
+ <div class="flex justify-end gap-2 mt-4">
674
+ <button type="button" onclick={onCancel}>Cancel</button>
675
+ <button type="submit">{label}</button>
676
+ </div>
677
+ </form>
678
+ </Modal>
679
+ ```
680
+
681
+ #### Custom bulk actions
682
+
683
+ `customBulkActions` 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.
684
+
685
+ ```ts
686
+ <GenericCRUD
687
+ ...
688
+ customBulkActions={[
689
+ { label: 'Complete', icon: CheckIcon, endpoint: '?/complete' },
690
+ { label: 'Mark pending', icon: UndoIcon, endpoint: '?/incomplete', variant: 'error', confirm: true },
691
+ ]}
692
+ />
693
+ ```
694
+
695
+ `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.
696
+
697
+ #### Exporting data (CSV/XLSX)
698
+
699
+ `enableExport` adds an export button to the header offering CSV (always) and Excel (when an `xlsx` module is supplied). Runeforge never bundles `xlsx` itself — install it separately and pass the resolved module in, so the dependency stays fully optional:
700
+
701
+ ```bash
702
+ pnpm add xlsx
703
+ ```
704
+
705
+ ```ts
706
+ <script>
707
+ import { GenericCRUD } from 'runeforge';
708
+ import * as xlsx from 'xlsx';
709
+ </script>
710
+
711
+ <GenericCRUD ... enableExport {xlsx} />
712
+ ```
713
+
714
+ 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 `onExport` to fetch the full, unpaginated result set for the current query — without it, export falls back to just the currently loaded page:
715
+
716
+ ```ts
717
+ <GenericCRUD
718
+ ...
719
+ enableExport
720
+ onExport={async (query) => {
721
+ const params = new URLSearchParams();
722
+ if (query.ordering) params.set('ordering', query.ordering);
723
+ // ...translate query.filters into your API's params
724
+ const res = await fetch(`/api/widgets/export?${params}`);
725
+ return res.json();
726
+ }}
727
+ />
728
+ ```
729
+
730
+ #### Server-side pagination, sorting & filtering
731
+
732
+ 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.
733
+
734
+ ```ts
735
+ // +page.server.ts
736
+ export const load: PageServerLoad = ({ url }) => {
737
+ const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
738
+ const ordering = url.searchParams.get('ordering');
739
+ const name = url.searchParams.get('name'); // per-column text filter
740
+
741
+ let rows = [...allWidgets];
742
+ if (name) rows = rows.filter((w) => w.name.toLowerCase().includes(name.toLowerCase()));
743
+ if (ordering) {
744
+ const desc = ordering.startsWith('-');
745
+ const field = desc ? ordering.slice(1) : ordering;
746
+ rows = [...rows].sort((a, b) => (desc ? -1 : 1) * compare(a[field], b[field]));
747
+ }
748
+
749
+ const pageSize = 20;
750
+ const start = (page - 1) * pageSize;
751
+ return { widgets: { results: rows.slice(start, start + pageSize), count: rows.length, page, pageSize } };
752
+ };
753
+ ```
754
+
755
+ ```ts
756
+ <GenericCRUD
757
+ ...
758
+ data={{ widgets: data.widgets }}
759
+ dataKey="widgets"
760
+ />
761
+ ```
762
+
763
+ No other prop changes are needed — column sorting/filtering UI, the paginator, and (with `onExport`) 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`.
764
+
765
+ ### PaginatedTable
766
+
767
+ A standalone table component with built-in sort, filter, and pagination — the same engine `GenericCRUD` uses internally.
768
+
769
+ ```ts
337
770
  <script>
338
771
  import { PaginatedTable } from 'runeforge';
339
772
  </script>
@@ -341,7 +774,7 @@ A standalone table component with built-in sort, filter, and pagination.
341
774
  <PaginatedTable {data} {columns} />
342
775
  ```
343
776
 
344
- Sort and filter state can be managed externally via the exported `SortState` and `FilterState` classes.
777
+ 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.
345
778
 
346
779
  ### Form Components
347
780
 
@@ -349,13 +782,13 @@ Individual form primitives styled with DaisyUI:
349
782
 
350
783
  - `Button` — styled action button
351
784
  - `Label` — form label with optional required marker
352
- - `Select` — dropdown with option group support
353
- - `PasswordInput` — password field with show/hide toggle
785
+ - `Select` — dropdown with option group support, optional in-memory filtering, and an optional `search` prop for server-resolved options (see [Select options](#select-options))
786
+ - `PasswordInput` — password field with show/hide toggle; `labelClass`, `inputClass`, and `buttonClass` props let you restyle the wrapper, input, and toggle button independently
354
787
 
355
788
  ### Shared Components
356
789
 
357
790
  - `Avatar` — user avatar display
358
- - `Modal` — DaisyUI modal wrapper
791
+ - `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`
359
792
  - `Breadcrumbs` — navigation breadcrumb trail
360
793
  - `IconRenderer` — renders icons from the active icon set
361
794
 
@@ -369,7 +802,7 @@ Formatters are functions you attach to a metadata field to control how its value
369
802
 
370
803
  Converts a boolean to a readable label.
371
804
 
372
- > [!INFO]
805
+ > [!NOTE]
373
806
  > Defaults to `Sí` / `No` because this was created at Argentina papá! 🇦🇷.
374
807
 
375
808
  ```ts
@@ -388,7 +821,7 @@ isActive: {
388
821
 
389
822
  Formats a `Date` value using the tokens `dd`, `mm`, `YYYY`, `HH`, `MM`, `ss`.
390
823
 
391
- > [!INFO]
824
+ > [!NOTE]
392
825
  > Defaults to `'dd/mm/YYYY HH:MM'`.
393
826
 
394
827
  ```ts
@@ -462,7 +895,7 @@ interface CellProps<T extends object, V> {
462
895
 
463
896
  The following renders a user photo with a fallback to initials, using data from sibling fields on the row:
464
897
 
465
- ```svelte
898
+ ```ts
466
899
  <!-- components/UserAvatar.svelte -->
467
900
  <script lang="ts">
468
901
  import { Avatar } from 'runeforge';
@@ -500,7 +933,7 @@ export const userMeta = {
500
933
 
501
934
  A simpler case — render a Bootstrap icon by name stored as a plain string:
502
935
 
503
- ```svelte
936
+ ```ts
504
937
  <!-- components/IconCell.svelte -->
505
938
  <script lang="ts">
506
939
  import { IconRenderer } from 'runeforge';
@@ -538,7 +971,7 @@ All UI strings default to **Spanish** (Argentina). To switch to another language
538
971
 
539
972
  ### Switch to English
540
973
 
541
- ```svelte
974
+ ```ts
542
975
  <!-- +layout.svelte -->
543
976
  <script>
544
977
  import { setStrings, en } from 'runeforge';
@@ -549,7 +982,7 @@ All UI strings default to **Spanish** (Argentina). To switch to another language
549
982
 
550
983
  ### Override individual strings
551
984
 
552
- ```svelte
985
+ ```ts
553
986
  <script>
554
987
  import { setStrings } from 'runeforge';
555
988
 
@@ -576,21 +1009,36 @@ All UI strings default to **Spanish** (Argentina). To switch to another language
576
1009
  | `next` | `string` | `Siguiente` |
577
1010
  | `selectPlaceholder` | `string` | `Seleccioná una opción` |
578
1011
  | `selectSearch` | `string` | `Buscar...` |
1012
+ | `selectSearching` | `string` | `Buscando...` |
579
1013
  | `selectNoResults` | `string` | `Sin resultados` |
580
1014
  | `view` | `string` | `Ver` |
581
1015
  | `edit` | `string` | `Editar` |
582
1016
  | `delete` | `string` | `Eliminar` |
583
1017
  | `create` | `string` | `Crear` |
1018
+ | `searchPlaceholder` | `string` | `Buscar...` |
1019
+ | `export` | `string` | `Exportar` |
1020
+ | `exportCsv` | `string` | `Exportar a CSV` |
1021
+ | `exportExcel` | `string` | `Exportar a Excel` |
584
1022
  | `save` | `string` | `Guardar` |
585
1023
  | `saveAndContinue` | `string` | `Guardar y continuar` |
586
1024
  | `cancel` | `string` | `Cancelar` |
587
1025
  | `back` | `string` | `Volver` |
1026
+ | `add` | `string` | `Agregar` |
1027
+ | `remove` | `string` | `Quitar` |
1028
+ | `noItems` | `string` | `Sin elementos agregados` |
588
1029
  | `confirm` | `string` | `Confirmar` |
589
- | `deleteConfirm` | `(count) => string` | `¿Seguro que querés eliminar 3 elementos?` |
1030
+ | `deleteConfirm` | `(count, actionLabel) => string` | `¿Seguro que querés eliminar 3 elementos?` |
590
1031
  | `required` | `(field) => string` | `Título es requerido` |
1032
+ | `invalidNumber` | `(field) => string` | `Cantidad debe ser un número` |
1033
+ | `integer` | `(field) => string` | `Cantidad debe ser un número entero` |
1034
+ | `min` | `(field, min) => string` | `Cantidad debe ser mayor o igual a 1` |
1035
+ | `max` | `(field, max) => string` | `Cantidad debe ser menor o igual a 100` |
1036
+ | `minLength` | `(field, min) => string` | `Notas debe tener al menos 3 caracteres` |
1037
+ | `maxLength` | `(field, max) => string` | `Notas debe tener como máximo 200 caracteres` |
1038
+ | `pattern` | `(field) => string` | `Código tiene un formato inválido` |
591
1039
  | `serverError` | `string` | `Error inesperado del servidor.` |
592
1040
 
593
- > [!INFO]
1041
+ > [!NOTE]
594
1042
  > Defaults to Spanish because this was built in Argentina! 🇦🇷
595
1043
 
596
1044
  ### Bundled locales
@@ -606,7 +1054,7 @@ All UI strings default to **Spanish** (Argentina). To switch to another language
606
1054
 
607
1055
  Runeforge ships with a default icon set. To use Bootstrap Icons instead:
608
1056
 
609
- ```svelte
1057
+ ```ts
610
1058
  <script>
611
1059
  import { setIconSet, bootstrapIcons } from 'runeforge';
612
1060
 
@@ -6,15 +6,40 @@
6
6
  title = '',
7
7
  onClose,
8
8
  children,
9
+ class: additionalClass,
10
+ width,
11
+ maxWidth,
12
+ height,
13
+ maxHeight,
9
14
  }: {
10
15
  title?: string;
11
16
  onClose?: () => void;
12
17
  children: Snippet;
18
+ /** Extra classes merged onto the modal box, e.g. Tailwind size utilities
19
+ * like `max-w-4xl` or `w-11/12`. */
20
+ class?: string;
21
+ /** Explicit size overrides (any valid CSS length, e.g. '600px', '90vw').
22
+ * Applied as inline styles, so they take priority over `class` utilities. */
23
+ width?: string;
24
+ maxWidth?: string;
25
+ height?: string;
26
+ maxHeight?: string;
13
27
  } = $props();
28
+
29
+ const boxStyle = $derived(
30
+ [
31
+ width ? `width:${width}` : '',
32
+ maxWidth ? `max-width:${maxWidth}` : '',
33
+ height ? `height:${height}` : '',
34
+ maxHeight ? `max-height:${maxHeight}` : '',
35
+ ]
36
+ .filter(Boolean)
37
+ .join(';')
38
+ );
14
39
  </script>
15
40
 
16
41
  <dialog class="modal" open>
17
- <div class="modal-box">
42
+ <div class={['modal-box', additionalClass]} style={boxStyle}>
18
43
  <div class="flex items-center justify-between gap-4">
19
44
  <h3 class="text-lg font-bold">{title}</h3>
20
45
  {#if onClose}