runeforge
v0.0.56
Published
SvelteKit toolkit for building metadata-driven CRUD interfaces with tables, forms, and actions
Downloads
4,470
Maintainers
Readme
Runeforge
A SvelteKit toolkit that forges forms, tables, actions, and CRUD workflows from reusable definitions.
Table of Contents
- Runeforge
Introduction
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 and Tailwind CSS.
Requirements
- SvelteKit 2+
- Svelte 5 (runes mode)
- Tailwind CSS 4
- DaisyUI 5
- Cally
sortablejs(optional, only if you enable drag-to-reorder)xlsx(optional, only if you enable Excel export)
Key Features
- GenericCRUD — a single orchestrator component that wires together list, create, read, and update views from field and column definitions.
- PaginatedTable — a full-featured table with sorting, filtering, pagination, and row selection, usable either fully client-side or driven by a server-paginated backend.
- 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.
- Validation — built-in
required,min/max,integer,minLength/maxLength, andpatternrules, checked client-side before submit with consistent, translatable error messages. - 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. - 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.
multiselectsupports the same resolvers for a checkbox-style multiple-choice list. - Tree fields — a hierarchical, cascading-selection picker (e.g. categories with parent/child relationships) driven by a flat option list with a
parentValuelink. - 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.
- 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.
- CSV/XLSX export — one-click export of the current table view, with optional Excel support via the
xlsxpackage. - Drag-to-reorder — an opt-in row-reordering layer that persists a sequential order attribute after each drag.
- Server-side pagination, sorting & filtering — point
GenericCRUD/PaginatedTableat a paginated envelope and it drives page/sort/filter state through the URL for you. - Pluggable icon system — swap the default icon set or use the included Bootstrap Icons alternative via
setIconSet. - Standalone components — table, form, and navigation components can be used independently without the full CRUD orchestrator.
Installation
pnpm add runeforgeTheming
Tailwind source scanning
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:
@import "tailwindcss";
@source "../node_modules/runeforge/dist";
@plugin "daisyui";CSS variables
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:
:root {
--runeforge-crud-title-size: 1.875rem;
--runeforge-breadcrumb-font-size: 0.875rem;
--runeforge-breadcrumb-icon-size: 1rem;
}Responsive overrides work too:
:root {
--runeforge-crud-title-size: 1.25rem;
}
@media (min-width: 768px) {
:root {
--runeforge-crud-title-size: 1.875rem;
}
}| Variable | Default | Affects |
| --- | --- | --- |
| --runeforge-crud-max-width | (none) | Max width of the Header and List view; centers them when set |
| --runeforge-form-max-width | 32rem | Max width of the form/fields panel in Create, Update, and Read views |
| --runeforge-crud-title-size | 1.875rem | <h1> inside the Header component |
| --runeforge-breadcrumb-font-size | 0.875rem | Breadcrumb label text size |
| --runeforge-breadcrumb-icon-size | 1rem | Breadcrumb icon width and height |
| --runeforge-tree-max-height | 24rem | Max height of a tree field before it scrolls internally |
Modal sizing (see Shared Components) is set per-instance via props rather than a CSS variable.
Configuration
Global settings are applied once in your root layout via setConfig. This avoids passing the same prop to every CRUD component.
<!-- +layout.svelte -->
<script>
import { setConfig } from 'runeforge';
setConfig({ homeHref: '/admin' });
</script>| Option | Default | Description |
| --- | --- | --- |
| homeHref | '/' | URL for the home crumb in every breadcrumb trail |
Basic Usage
1. Define your interface and metadata
// interface.ts
import { AttributeType, type InterfaceMetadata } from 'runeforge';
import { formatBoolean, formatDatetime } from 'runeforge';
export interface IArticle {
_id: string;
title: string;
published: boolean;
createdAt: Date;
}
export const articleMeta = {
title: {
label: 'Title',
type: AttributeType.text,
placeholder: 'My article',
required: true,
},
published: {
label: 'Published',
type: AttributeType.boolean,
formatter: formatBoolean,
default: false,
required: true,
},
createdAt: {
label: 'Created',
type: AttributeType.datetime,
formatter: formatDatetime(),
excludedFromCreate: true,
excludedFromUpdate: true
},
updatedAt: {
label: 'Updated',
type: AttributeType.datetime,
formatter: formatDatetime(),
excludedFromCreate: true,
excludedFromUpdate: true
},
} satisfies InterfaceMetadata<IArticle>;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 section below covers the full set of options — validation, conditional/grouped fields, smart selects, and embedded sub-documents.
2. Create the model
// model.ts
import crypto from 'node:crypto';
import mongoose from 'mongoose';
import type { IArticle } from './interface';
const schema = new mongoose.Schema<IArticle>(
{
_id: { type: String, default: () => crypto.randomUUID() },
title: { type: String, required: true, trim: true },
published: { type: Boolean, required: true, default: false },
},
{ timestamps: true }
);
export const Article = mongoose.models.Article ?? mongoose.model<IArticle>('Article', schema);3. Set up the server
// +page.server.ts
import { fail, error } from '@sveltejs/kit';
import { Article } from '$lib/server/articles/model';
import type { Actions, PageServerLoad } from './$types';
import type { IArticle } from './interface';
export const load: PageServerLoad = async ({ url }) => {
const id = url.searchParams.get('id');
if (id) {
const article = await Article.findById(id).lean<IArticle>();
if (!article) error(404, 'Not found');
return { article };
}
const articles = await Article.find({}).sort({ createdAt: -1 }).lean<IArticle[]>();
return { articles };
};
export const actions: Actions = {
create: async ({ request }) => {
const data = await request.formData();
const title = String(data.get('title') ?? '').trim();
if (!title) return fail(400, { error: 'Title is required' });
await Article.create({ title, published: data.has('published') });
return { success: true };
},
update: async ({ request }) => {
const data = await request.formData();
const id = String(data.get('id') ?? '').trim();
if (!id) return fail(400, { error: 'ID is required' });
await Article.findByIdAndUpdate(id, {
title: String(data.get('title') ?? '').trim(),
published: data.has('published'),
});
return { success: true };
},
delete: async ({ request }) => {
const data = await request.formData();
const id = String(data.get('id') ?? '').trim();
if (!id) return fail(400, { error: 'ID is required' });
await Article.findByIdAndDelete(id);
return { success: true };
},
};The load function returns a single record when ?id= is present (used by the read/edit views), or the full list otherwise.
4. Add the page component
<!-- +page.svelte -->
<script lang="ts">
import { GenericCRUD } from 'runeforge';
import { articleMeta as meta } from './interface';
let { data, form } = $props();
</script>
<GenericCRUD
labelOne="Article"
labelMany="Articles"
{data}
{form}
{meta}
dataKey="articles"
creation={{ endpoint: '?/create' }}
read={{ endpoint: '?/read' }}
update={{ endpoint: '?/update' }}
deletion={{ endpoint: '?/delete' }}
/>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.
If your records use a different identifier field than _id (e.g. a plain id), pass the idKey prop:
<GenericCRUD idKey="id" ... />This propagates to navigation URLs, form submissions, deletion calls, and the auto-excluded column list, so no other changes are needed on your end.
Field System
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.
Attribute reference
| Option | Type | Applies to | Description |
| --- | --- | --- | --- |
| label | string | all | Column header, form label, and the field name used in validation messages |
| type | AttributeType | all | text | email | password | number | boolean | textarea | file | select | multiselect | tree | datetime | embedded |
| 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 |
| autocomplete | FullAutoFill | text-like | Native autocomplete attribute |
| placeholder | string | text-like, select, multiselect | Placeholder text |
| default | value \| (data) => value | all | Initial value on the create form — see Default values |
| min / max | number | number | Numeric range validation |
| integer | boolean | number | Rejects non-whole numbers |
| minLength / maxLength | number | text-like | Character-count validation |
| pattern | string | text-like | Regex the value must match (new RegExp(pattern)) |
| disabled | (record) => boolean | all | Conditionally disables the input — see Conditional fields |
| hidden | boolean \| (record) => boolean | all | Conditionally removes the field from the form entirely — not rendered, not validated, not submitted — see Conditional fields |
| groupedAs | string | all | Visually groups fields under a titled section — see Field grouping |
| row | string | all | Renders fields sharing the same value side by side (desktop) / stacked (mobile) — see Field rows |
| defaultExpanded | boolean | tree | Whether parent nodes start expanded. Defaults to true |
| options | SelectOption[] \| (data) => SelectOption[] | select, multiselect, tree | Static or computed option list — see Select options. tree options additionally accept parentValue — see Tree fields |
| dependentOptions | (data, record) => SelectOption[] | select, multiselect, tree | Options derived from other fields' current values |
| search | (query) => Promise<SelectOption[]> | select, multiselect | Server-side option search as the user types |
| seed | (instance) => unknown | all | Overrides how the update form seeds this field from the loaded record |
| fields | InterfaceMetadata<any> | embedded | Sub-field schema for each item — see Embedded fields |
| itemLabel | (item) => string | embedded | Summary label for an item in the embedded list |
| component | CellComponent | all | Custom cell renderer — see Custom Cell Components |
| formatter | (data) => (value, row) => string | all | Custom cell text — see Formatters |
| excludedFromList/Create/Read/Update | boolean | all | Hides the field from that specific view |
| sortable / filterable | boolean | all | Table column controls |
| filterOptions | SelectOption[] | all | Static, exhaustive column filter choices, replacing the sampled-from-loaded-rows checkbox list — see Server-side pagination, sorting & filtering |
Validation
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.
code: {
label: 'Code',
type: AttributeType.text,
required: true,
pattern: '^[A-Z0-9]{3,8}$',
},
quantity: {
label: 'Quantity',
type: AttributeType.number,
min: 1,
max: 100,
integer: true,
},
notes: {
label: 'Notes',
type: AttributeType.textarea,
minLength: 3,
maxLength: 200,
},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:
formula: {
label: 'Formula',
type: AttributeType.select,
options: [
{ value: 'benchmark', label: 'Benchmark' },
{ value: 'max', label: 'Max' },
],
},
quantity: {
label: 'Quantity',
type: AttributeType.number,
// Not required for the "benchmark" formula, mandatory for every other one.
required: (record) => record.formula !== 'benchmark',
},The label's required marker and the submit-time check both re-evaluate the same way disabled does — see Conditional fields.
[!TIP] Client-side validation is a UX nicety, not a security boundary — always re-validate in your form actions.
Conditional fields
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) follows the same pattern for making a field mandatory only in certain conditions.
unlimited: {
label: 'Unlimited quantity',
type: AttributeType.boolean,
default: false,
},
quantity: {
label: 'Quantity',
type: AttributeType.number,
min: 1,
disabled: (record) => !!record.unlimited,
},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:
paymentMethod: {
label: 'Payment method',
type: AttributeType.select,
options: [
{ value: 'card', label: 'Credit card' },
{ value: 'cash', label: 'Cash on delivery' },
],
},
cardNumber: {
label: 'Card number',
type: AttributeType.text,
hidden: (record) => record.paymentMethod !== 'card',
required: (record) => record.paymentMethod === 'card',
},
cardExpiry: {
label: 'Expiry date',
type: AttributeType.text,
hidden: (record) => record.paymentMethod !== 'card',
required: (record) => record.paymentMethod === 'card',
},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.
Field grouping
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.
code: {
label: 'Code',
type: AttributeType.text,
groupedAs: 'Identification',
},
sku: {
label: 'SKU',
type: AttributeType.text,
groupedAs: 'Identification',
},Field rows
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.
createdFrom: {
label: 'Created from',
type: AttributeType.datetime,
row: 'createdRange',
},
createdTo: {
label: 'Created to',
type: AttributeType.datetime,
row: 'createdRange',
},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, the remaining field(s) simply expand to fill the row instead of leaving a gap.
groupedAs and row both work the same way inside an embedded field's fields sub-schema — the "+ Add"/edit modal groups and lays out its own fields identically to a top-level form.
Default values
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.
published: {
label: 'Published',
type: AttributeType.boolean,
default: false,
},
assignedTo: {
label: 'Assigned to',
type: AttributeType.select,
options: (data: { users?: IUser[] }) => (data.users ?? []).map((u) => ({ value: u._id, label: u.name })),
default: (data: { currentUserId?: string }) => data.currentUserId ?? '',
},Select options
select fields support four ways of resolving their options, which can be combined as needed:
- Static — a plain
SelectOption[]array. - Computed from page data — a function of the page
dataobject, useful for prefetched, related records (seeformatInstancein Formatters for rendering the resolved link back). - 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. - Server search —
search(query)is called (debounced) as the user types, instead of filtering the (possibly partial)optionslist in memory. Combine it withoptionsto keep a usable list before the user starts typing.
// Dependent options: narrow "city" choices by the selected "country"
country: {
label: 'Country',
type: AttributeType.select,
options: [{ value: 'ar', label: 'Argentina' }, { value: 'uy', label: 'Uruguay' }],
},
city: {
label: 'City',
type: AttributeType.select,
dependentOptions: (data, record) => CITIES_BY_COUNTRY[record.country as string] ?? [],
},
// Server-aware search: fall back to a prefetched slice, but query the
// server for anything outside it.
owner: {
label: 'Owner',
type: AttributeType.select,
placeholder: 'Choose an owner',
options: (data: { owners?: IOwner[] }) => (data.owners ?? []).map((o) => ({ value: o.id, label: o.name })),
search: async (query) => {
const fd = new FormData();
fd.set('query', query);
const res = await fetch('?/searchOwners', { method: 'POST', body: fd });
const result = deserialize(await res.text());
if (result.type !== 'success') return [];
return (result.data.owners ?? []).map((o: IOwner) => ({ value: o.id, label: o.name }));
},
},// +page.server.ts
export const actions: Actions = {
// ...create/update/delete
searchOwners: async ({ request }) => {
const data = await request.formData();
const query = String(data.get('query') ?? '');
return { owners: await Owner.find({ name: { $regex: query, $options: 'i' } }).limit(20).lean() };
},
};Multiselect fields
AttributeType.multiselect is a checkbox-style multiple-choice dropdown — the same options/dependentOptions/default/search resolvers as select (see 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").
tags: {
label: 'Tags',
type: AttributeType.multiselect,
options: [
{ value: 'fragile', label: 'Fragile' },
{ value: 'perishable', label: 'Perishable' },
{ value: 'oversized', label: 'Oversized' },
],
default: [],
},Like embedded, the value is submitted as a single hidden field holding a JSON array — parse it back out the same way:
const tags = JSON.parse(String(data.get('tags') ?? '[]'));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.
Tree fields
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):
categories: {
label: 'Categories',
type: AttributeType.tree,
options: (data: { categories?: ICategory[] }) =>
(data.categories ?? []).map((c) => ({
value: String(c.id),
label: c.name,
parentValue: c.parentCategory != null ? String(c.parentCategory) : null,
})),
},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.
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) and scrolls internally once its content grows past that.
Embedded fields (sub-documents)
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.
export interface IAdjustment {
kind: string;
amount: number;
}
export interface IWidget {
_id: string;
name: string;
adjustments: IAdjustment[];
}
export const widgetMeta = {
name: { label: 'Name', type: AttributeType.text, required: true },
adjustments: {
label: 'Adjustments',
type: AttributeType.embedded,
// Arrays of objects have no sensible plain-text table cell.
excludedFromList: true,
fields: {
kind: {
label: 'Kind',
type: AttributeType.select,
required: true,
options: [
{ value: 'bonus', label: 'Bonus' },
{ value: 'penalty', label: 'Penalty' },
],
},
amount: { label: 'Amount', type: AttributeType.number, required: true, min: 0 },
},
itemLabel: (item) => `${item.kind === 'bonus' ? 'Bonus' : 'Penalty'}: ${item.amount}`,
},
} satisfies InterfaceMetadata<IWidget>;On the server, parse the field back out of FormData as JSON:
function widgetFromFormData(data: FormData) {
let adjustments: IAdjustment[];
try {
adjustments = JSON.parse(String(data.get('adjustments') ?? '[]'));
} catch {
adjustments = [];
}
return { name: String(data.get('name') ?? '').trim(), adjustments };
}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 ·.
Components
GenericCRUD
The main CRUD orchestrator. It manages navigation between List, Create, Read, and Update views using URL search params (?view=create, ?id=xxx, ?view=edit).
Key props:
data/dataKey— the record array (or server-paginated envelope) and its primary key fieldlabelOne/labelMany— singular and plural names for the entitycolumns—ColumnDefinition[]for the table viewfields—FieldDefinition[]for form viewscreation,update,read,deletion—ActionConfigurationobjects that define handlers and permissions for each operation. Setconfirm: trueondeletionto show a confirmation dialog before any delete (single row or batch)actions— aListActionsobject grouping the list view's extra actions:actions.custom—CustomAction[], extra per-row actions — see Custom row actionsactions.bulk—CustomBulkAction[], extra actions on the current selection — see Custom bulk actions
config— aListConfigobject grouping the list view's opt-in behaviors:config.search—SearchConfiguration, shows a free-text search box — see Free-text searchconfig.export—ExportConfiguration, enables CSV/Excel export — see Exporting dataconfig.reorder—ReorderConfiguration, enables drag-to-reorder rows — see Reordering rows
Free-text search
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.
<GenericCRUD
...
config={{ search: { param: 'q', placeholder: 'Search tasks...', debounceMs: 300 } }}
/>| Option | Default | Description |
| --- | --- | --- |
| param | 'search' | Query-string parameter name |
| placeholder | strings.searchPlaceholder | Input placeholder |
| debounceMs | 300 | Delay before the URL updates |
Custom row actions
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:
href(item)— plain navigation, e.g. deep-linking into another CRUD's filtered list.view— a Svelte component of your own thatGenericCRUDmounts 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 exportedModalcomponent, sized however that action needs viaModal'sclass/width/maxWidth/height/maxHeightprops (see Shared Components).
import ArchiveIcon from './icons/Archive.svelte';
import ArchiveForm from './ArchiveForm.svelte';
const actions: CustomAction<IWidget>[] = [
{
label: 'Archive',
icon: ArchiveIcon,
endpoint: '?/archive',
view: ArchiveForm,
condition: (item) => !item.archived,
},
{
label: 'Open in new tab',
icon: ExternalLinkIcon,
href: (item) => `/widgets/${item._id}`,
},
];<GenericCRUD ... actions={{ custom: actions }} />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:
<!-- ArchiveForm.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
import { Modal } from 'runeforge';
let { instance, label, endpoint, serverError, onCancel, onSuccess } = $props();
</script>
<Modal title={label} onClose={onCancel} maxWidth="28rem">
<form
method="POST"
action={endpoint}
use:enhance={() => async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') onSuccess();
}}
>
<input type="hidden" name="id" value={instance._id} />
{#if serverError}<p class="text-error">{serverError}</p>{/if}
<div class="flex justify-end gap-2 mt-4">
<button type="button" onclick={onCancel}>Cancel</button>
<button type="submit">{label}</button>
</div>
</form>
</Modal>Custom bulk actions
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.
<GenericCRUD
...
actions={{
bulk: [
{ kind: 'endpoint', label: 'Complete', icon: CheckIcon, endpoint: '?/complete' },
{ kind: 'endpoint', label: 'Mark pending', icon: UndoIcon, endpoint: '?/incomplete', variant: 'error', confirm: true },
],
}}
/>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.
Exporting data (CSV/XLSX)
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:
pnpm add xlsx<script>
import { GenericCRUD } from 'runeforge';
import * as xlsx from 'xlsx';
</script>
<GenericCRUD ... config={{ export: { xlsx } }} />In client-pagination mode, export includes every row currently matching the table's filters (not just the visible page). In server-pagination mode, 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:
<GenericCRUD
...
config={{
export: {
callback: async (query) => {
const params = new URLSearchParams();
if (query.ordering) params.set('ordering', query.ordering);
// ...translate query.filters into your API's params
const res = await fetch(`/api/widgets/export?${params}`);
return res.json();
},
},
}}
/>Reordering rows
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.
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:
pnpm add sortablejs<script>
import { GenericCRUD } from 'runeforge';
import Sortable from 'sortablejs';
</script>
<GenericCRUD
...
config={{
reorder: { attribute: 'order', sortable: Sortable, endpoint: '?/reorder' },
}}
/>// +page.server.ts
export const actions: Actions = {
reorder: async ({ request }) => {
const data = await request.formData();
const id = String(data.get('id') ?? '');
const order = Number(data.get('order') ?? 0);
await setWidgetOrder(id, order);
return { success: true };
},
};endpoint is POSTed 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):
config={{
reorder: {
attribute: 'order',
sortable: Sortable,
callback: async (items) => {
await Promise.all(items.map((item) => api.updateWidget(item._id, { order: item.order })));
},
},
}}A few things worth knowing about how reorder mode behaves:
- The list is ordered by
attributeascending (orcompare, 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 whileconfig.reorderis set, since row order needs a single, unambiguous source of truth for dragging to mean anything. This holds even ifattributeisexcludedFromListand 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). - 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(default2000) it flips to the previous/next page; keep hovering (without letting go) to flip again. Drop once you're on the right page. - It's not supported in server-pagination mode (a
PaginatedEnvelopedata) — cross-page drag positions aren't meaningful without a lot more server-side machinery, soconfig.reorderis ignored whenever server pagination is active. - Set
enabled: falseto keep the configuration in place (attribute, endpoint, icon) but temporarily turn dragging off, without having to remove the whole object.
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:
config={{
reorder: {
attribute: 'order',
sortable: Sortable,
compare: (a, b) => a.chapter.order - b.chapter.order || a.order - b.order,
endpoint: '?/reorder',
},
}}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.
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:
import Sortable, { MultiDrag } from 'sortablejs';
Sortable.mount(new MultiDrag());config={{
reorder: { attribute: 'order', sortable: Sortable, multiDrag: true, endpoint: '?/reorder' },
}}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.
Server-side pagination, sorting & filtering
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.
// +page.server.ts
export const load: PageServerLoad = ({ url }) => {
const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
const ordering = url.searchParams.get('ordering');
const name = url.searchParams.get('name'); // per-column text filter
let rows = [...allWidgets];
if (name) rows = rows.filter((w) => w.name.toLowerCase().includes(name.toLowerCase()));
if (ordering) {
const desc = ordering.startsWith('-');
const field = desc ? ordering.slice(1) : ordering;
rows = [...rows].sort((a, b) => (desc ? -1 : 1) * compare(a[field], b[field]));
}
const pageSize = 20;
const start = (page - 1) * pageSize;
return { widgets: { results: rows.slice(start, start + pageSize), count: rows.length, page, pageSize } };
};<GenericCRUD
...
data={{ widgets: data.widgets }}
dataKey="widgets"
/>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.
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:
status: {
label: 'Status',
type: AttributeType.text,
filterable: true,
filterOptions: [
{ value: 'DRAFT', label: 'Draft' },
{ value: 'PUBLISHED', label: 'Published' },
{ value: 'ARCHIVED', label: 'Archived' },
],
},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.
PaginatedTable
A standalone table component with built-in sort, filter, and pagination — the same engine GenericCRUD uses internally.
<script>
import { PaginatedTable } from 'runeforge';
</script>
<PaginatedTable {data} {columns} />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 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.
Pass a reorder prop ({ attribute, sortable, compare?, icon?, multiDrag?, pageFlipThresholdMs? } — same shape as GenericCRUD's config.reorder, 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.
Form Components
Individual form primitives styled with DaisyUI:
Button— styled action buttonLabel— form label with optional required markerSelect— dropdown with option group support, optional in-memory filtering, and an optionalsearchprop for server-resolved options (see Select options)PasswordInput— password field with show/hide toggle;labelClass,inputClass, andbuttonClassprops let you restyle the wrapper, input, and toggle button independently
Shared Components
Avatar— user avatar displayModal— DaisyUI modal wrapper. Size it with Tailwind utility classes viaclass(e.g.class="max-w-4xl"), or with explicitwidth/maxWidth/height/maxHeightCSS lengths, which are applied as inline styles and take priority overclassBreadcrumbs— navigation breadcrumb trailIconRenderer— renders icons from the active icon set
Formatters
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).
formatBoolean
Converts a boolean to a readable label.
[!NOTE] Defaults to
Sí/Nobecause this was created at Argentina papá! 🇦🇷.
import { formatBoolean } from 'runeforge';
isActive: {
label: 'Active',
type: AttributeType.boolean,
formatter: formatBoolean(),
// or with custom labels:
formatter: formatBoolean('Enabled', 'Disabled'),
},formatDatetime
Formats a Date value using the tokens dd, mm, YYYY, HH, MM, ss.
[!NOTE] Defaults to
'dd/mm/YYYY HH:MM'.
import { formatDatetime } from 'runeforge';
createdAt: {
label: 'Created',
type: AttributeType.datetime,
formatter: formatDatetime(), // → "13/06/2026 09:45"
},
publishedAt: {
label: 'Published',
type: AttributeType.datetime,
formatter: formatDatetime('dd/mm/YYYY'), // → "13/06/2026"
},formatTruncateTextUpTo
Truncates long text to a maximum character count, appending ….
import { formatTruncateTextUpTo } from 'runeforge';
description: {
label: 'Description',
type: AttributeType.textarea,
formatter: formatTruncateTextUpTo(80),
},formatInstance
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.
import { formatInstance } from 'runeforge';
import type { ICategory } from '$lib/server/categories/interface';
categoryId: {
label: 'Category',
type: AttributeType.select,
options: (data: { categories?: ICategory[] }) =>
(data.categories ?? []).map((c) => ({ value: c._id, label: c.name })),
formatter: (data: { categories?: ICategory[] }) =>
formatInstance<ICategory>('name', data.categories ?? [], '/admin/categories'),
},Custom Cell Components
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.
A cell component receives two props defined by CellProps<T, V>:
value— the raw field value for that cellrow— the full record object, useful when the rendering depends on other fields
// CellProps interface (from runeforge)
interface CellProps<T extends object, V> {
value: V;
row: T;
}Example: avatar column
The following renders a user photo with a fallback to initials, using data from sibling fields on the row:
<!-- components/UserAvatar.svelte -->
<script lang="ts">
import { Avatar } from 'runeforge';
import type { CellProps } from 'runeforge';
type UserRow = { firstName?: string; lastName?: string; email?: string };
let { value, row }: CellProps<UserRow, string | null> = $props();
const initials = [row.firstName?.[0], row.lastName?.[0]].filter(Boolean).join('').toUpperCase();
</script>
<Avatar src={value} text={initials} alt={row.email ?? ''} />Register it in the metadata with component:
// interface.ts
import UserAvatar from './components/UserAvatar.svelte';
export const userMeta = {
photo: {
label: 'Photo',
type: AttributeType.file,
component: UserAvatar,
sortable: false,
filterable: false,
},
// ...
} satisfies InterfaceMetadata<IUser>;Example: icon column
A simpler case — render a Bootstrap icon by name stored as a plain string:
<!-- components/IconCell.svelte -->
<script lang="ts">
import { IconRenderer } from 'runeforge';
import type { CellProps } from 'runeforge';
let { value }: CellProps<Record<string, unknown>, string> = $props();
</script>
<IconRenderer name={value} />icon: {
label: 'Icon',
type: AttributeType.text,
component: IconCell,
},[!TIP] Both
AvatarCellandIconCellare included in the package and ready to use — you don't need to build them from scratch:import { AvatarCell, IconCell } from 'runeforge'; photo: { label: 'Photo', type: AttributeType.file, component: AvatarCell }, icon: { label: 'Icon', type: AttributeType.text, component: IconCell },
Internationalization
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.
Switch to English
<!-- +layout.svelte -->
<script>
import { setStrings, en } from 'runeforge';
setStrings(en);
</script>Override individual strings
<script>
import { setStrings } from 'runeforge';
setStrings({
create: 'New',
save: 'Confirm',
required: (field) => `${field} cannot be blank`,
});
</script>Full RuneforgeStrings reference
| Key | Type | Spanish default |
| --- | --- | --- |
| showing | (start, end, total) => string | Mostrando 1–10 de 25 |
| actions | string | Acciones |
| filter | string | Filtrar |
| filterColumn | (column) => string | Filtrar Nombre |
| filterPlaceholder | string | Filtrar… |
| clearFilter | string | Limpiar filtro |
| emptyValue | string | (vacío) |
| previous | string | Anterior |
| next | string | Siguiente |
| selectPlaceholder | string | Seleccioná una opción |
| selectSearch | string | Buscar... |
| selectSearching | string | Buscando... |
| selectNoResults | string | Sin resultados |
| view | string | Ver |
| edit | string | Editar |
| delete | string | Eliminar |
| create | string | Crear |
| searchPlaceholder | string | Buscar... |
| reorder | string | Arrastrar para reordenar |
| export | string | Exportar |
| exportCsv | string | Exportar a CSV |
| exportExcel | string | Exportar a Excel |
| save | string | Guardar |
| saveAndContinue | string | Guardar y continuar |
| cancel | string | Cancelar |
| back | string | Volver |
| add | string | Agregar |
| remove | string | Quitar |
| noItems | string | Sin elementos agregados |
| confirm | string | Confirmar |
| deleteConfirm | (count, actionLabel) => string | ¿Seguro que querés eliminar 3 elementos? |
| required | (field) => string | Título es requerido |
| invalidNumber | (field) => string | Cantidad debe ser un número |
| integer | (field) => string | Cantidad debe ser un número entero |
| min | (field, min) => string | Cantidad debe ser mayor o igual a 1 |
| max | (field, max) => string | Cantidad debe ser menor o igual a 100 |
| minLength | (field, min) => string | Notas debe tener al menos 3 caracteres |
| maxLength | (field, max) => string | Notas debe tener como máximo 200 caracteres |
| pattern | (field) => string | Código tiene un formato inválido |
| serverError | string | Error inesperado del servidor. |
[!NOTE] Defaults to Spanish because this was built in Argentina! 🇦🇷
Bundled locales
| Import | Language |
| --- | --- |
| es | Spanish 🇦🇷 (default) |
| en | English 🇺🇸 |
Icon System
Runeforge ships with a default icon set. To use Bootstrap Icons instead:
<script>
import { setIconSet, bootstrapIcons } from 'runeforge';
setIconSet(bootstrapIcons);
</script>You can also provide a fully custom icon set by passing an object that satisfies the icon set interface.
Running Tests
Unit Tests
Unit tests cover utility functions (formatters, resolution helpers, misc utilities) and run with Vitest.
# Single run
pnpm test:unit
# Watch mode
pnpm test:unit:watchEnd-to-End Tests
E2E tests cover table interactions (pagination, sorting, filtering) and run with Playwright. The dev server starts automatically when running locally.
pnpm test:e2eRun All Tests
pnpm testDevelopment
# Start the dev server
pnpm dev
# Type-check
pnpm check
# Lint and format
pnpm lint
pnpm format
# Build the library
pnpm buildLicense
MIT
