Loom (Page Builder)

Loom is a definition-driven page builder for Canvas that turns structured PHP definitions into interactive, data-bound admin pages such as forms, settings panels, and editors. Define your page structure using the fluent builder API, pass your data, and Loom handles rendering, field binding, and WakaPAC initialisation. WakaPAC is the client-side runtime used by Canvas for reactive components and message-based interaction between UI elements.

Loom architecture overview — shows the relationship between the Builder, Engine, Renderers, and WakaPAC initialisation

Core Concepts

Loom is built around a small set of primitives that work together to turn a PHP definition into a rendered page. Understanding these makes it easier to extend or customise any part of the pipeline.

  • Engine — The Loom class that recursively renders a node tree into HTML and collects WakaPAC initialisation scripts.
  • Node — A single element in the tree, defined by a type, properties, and optional children.
  • Renderer — A PHP class responsible for turning a node into HTML. One renderer per node type.
  • Builder — A fluent PHP API for constructing node trees without writing JSON by hand. Builders expose static make() or helper methods that create nodes and allow properties to be configured fluently. Calling build() on the root node returns the final definition passed to the Loom engine.
  • Data binding — Entity data passed to render() is automatically distributed to field values via DOM hydration.

Node Types

Node Purpose
ResourcePage root — renders a form with header, title, and save/cancel buttons
SectionVisual grouping — adds a subtle border and background around related fields
TabsLayout container with tab navigation
PanelLayout container for grouping fields
Columns / ColumnFlex layout with percentage-based widths
FieldInput element with label, validation, and data binding
TextRead-only label/value pair, supports WakaPAC interpolation
ButtonAction trigger bound to WakaPAC expressions

Installation

Install via Composer:

composer require quellabs/canvas-loom

Then copy the Loom stylesheet to your public folder:

php ./vendor/bin/sculpt loom:install-css

Quick Start

Define a page, pass entity data, and render:

use Quellabs\Canvas\Loom\Loom;
use Quellabs\Canvas\Loom\Builder\Resource;
use Quellabs\Canvas\Loom\Builder\Section;
use Quellabs\Canvas\Loom\Builder\Field;

$definition = Resource::make('post-form', '/admin/posts/save')
    ->title('Edit Post')
    ->add(Section::make()
        ->add(Field::text('title', 'Title')->required())
        ->add(Field::textarea('body', 'Content')->rows(10))
    )
    ->build();

$loom = new Loom();
echo $loom->render($definition, [
    'title' => 'My First Post',
    'body'  => 'Hello world.',
]);

Resource

A resource is the root node of every Loom page. It renders a <form> element with a header containing a title, cancel button, and save button. The form is automatically initialised as a WakaPAC component with DOM hydration enabled.

Resource::make('post-form', '/admin/posts/save')
    ->title('Edit Post')
    ->method('POST')
    ->saveLabel('Publish')
    ->add(...)
    ->build();

The header can be rendered separately from the form body — useful when your layout places the header outside the form:

// Header only
echo $loom->render($definition, $data, ['part' => 'header']);

// Body only
echo $loom->render($definition, $data, ['part' => 'body']);

Header Buttons

Extra buttons can be added to the header. They are hidden by default and shown or hidden by sending a WakaPAC message to the header component ({id}-header):

Resource::make('post-form', '/admin/posts/save')
    ->title('Edit Post')
    ->abstraction([
        '_MSG_SHOW_DELETE' => 1001,
        '_MSG_HIDE_DELETE' => 1002,
    ])
    ->addHeaderButton(
        Button::make('Delete')
            ->danger()
            ->name('delete')
            ->showMessage(1001)
            ->hideMessage(1002)
            ->action("Stdlib.sendMessage('post-form-header', _MSG_SHOW_DELETE, 0, 0)")
    );
// Show the delete button
wakaPAC.sendMessage('post-form-header', 1001, 0, 0);

// Hide the delete button
wakaPAC.sendMessage('post-form-header', 1002, 0, 0);

Abstraction

Resource can expose custom properties on its abstraction object using ->abstraction(). Values must be scalars or arrays and are serialised to JSON. The primary use case is named message constants, which must be present on the abstraction to be accessible inside data-pac-bind expressions.

Properties with an underscore prefix are treated as non-reactive by WakaPAC — they are readable in bind expressions but do not trigger re-renders when accessed:

Resource::make('post-form', '/admin/posts/save')
    ->abstraction([
        '_MSG_SHOW_DELETE' => 1001,
        '_MSG_HIDE_DELETE' => 1002,
    ]);

Loom merges these into the generated wakaPAC() call alongside the built-in submit(), post(), and dismiss() methods:

wakaPAC('post-form', {
    _MSG_SHOW_DELETE: 1001,
    _MSG_HIDE_DELETE: 1002,
    submit() { ... },
    post(url) { ... },
    dismiss() { ... }
}, { hydrate: true });

Script

Resource can be extended with raw JavaScript using ->script(). The snippet is placed directly inside the abstraction object literal, so it must be valid in that context: method definitions, computed properties, arrow function properties, and so on.

The primary use case is setting bind values programmatically — something that can only be done by calling a WakaPAC method rather than passing a static value through the data array.

Resource::make('post-form', '/admin/posts/save')
    ->script("
        resetForm() {
            this.title = '';
            this.body  = '';
        },
        computed: {
            charCount() {
                return this.body ? this.body.length : 0;
            }
        }
    ")
    ->add(...)
    ->build();

The generated output lands alongside the built-in methods inside the wakaPAC() call:

wakaPAC('post-form', {
    resetForm() {
        this.title = '';
        this.body  = '';
    },
    computed: {
        charCount() {
            return this.body ? this.body.length : 0;
        }
    },
    submit() { ... },
    post(url) { ... },
    dismiss() { ... }
}, { hydrate: true });
Warning: Script content is emitted verbatim with no escaping. Never pass untrusted input to ->script().

->script() and ->abstraction() serve different purposes. Use abstraction() for scalar and array values — named constants, configuration, initial state. Use script() when you need methods, computed properties, or any logic that cannot be expressed as a serialised value.

Sections, Panels & Tabs

Loom provides four container types that group fields: Section for visual grouping, Panel for structural grouping, Tabs for tabbed navigation, and a sidebar layout variant built on Column.

Section

A section groups related fields together with a subtle border and background. It carries no WakaPAC initialisation — field reactivity is handled by the parent container.

Section::make('post-details')
    ->add(Field::text('title', 'Title'))
    ->add(Field::textarea('body', 'Content'));

Panel

A panel is a structural grouping container. It renders a <div> wrapper around its children.

Panel::make('post-panel')
    ->add(Section::make()
        ->add(Field::text('title', 'Title'))
    );

Sidebar

A column can be marked as a sidebar, rendering a title and hint text separated from the content by a vertical line. This is set on the column itself, not as a separate node type:

Columns::make([25, 75])
    ->add(Column::make()
        ->markAsSidebar('Post Details', 'Fill in the basic information about your post.')
    )
    ->add(Column::make()
        ->add(Field::text('title', 'Title'))
        ->add(Field::textarea('body', 'Content'))
    );

Tabs

Tabs renders a tabbed interface. Tab switching is handled client-side without a page reload. The initially active tab is set via the second argument of Tabs::make().

Tabs::make('post-tabs', 'general')
    ->add(Tab::make('general', 'General')
        ->add(Section::make()
            ->add(Field::text('title', 'Title'))
        )
    )
    ->add(Tab::make('seo', 'SEO')
        ->add(Section::make()
            ->add(Field::text('meta_title', 'Meta title'))
        )
    );
Note: Tab definitions must be declared explicitly via Tabs::make() — the tab bar is built from these definitions, not inferred from child nodes. This keeps tab order explicit and avoids scanning the node tree during rendering. Each Tab::make() id must match an entry in the tabs definition.

Layout

Loom provides a flex-based column layout for arranging fields side by side. Widths are declared on the parent and columns fill the remaining space proportionally.

Columns

Columns creates a flex layout. Widths are defined as percentages on the parent — columns without a corresponding width entry expand to fill the remaining space.

Columns::make([70, 30])
    ->add(Column::make()
        ->add(Field::text('title', 'Title'))
    )
    ->add(Column::make()
        ->add(Field::select('status', 'Status'))
    );

An optional gap between columns can be set:

Columns::make([70, 30], '2rem');

Fields

Fields are the basic input elements of a Loom page. Each field renders a label, an input element, and an optional hint. Field values are populated automatically from the data array passed to render().

Input Types

Field::text('title', 'Title')
Field::textarea('body', 'Content')
Field::select('status', 'Status')
Field::checkbox('featured', 'Featured post')
Field::radio('theme', 'Theme')
Field::number('priority', 'Priority')
Field::toggle('is_active', 'Active')
Field::hidden('post_id')
Field::email('email', 'Email address')
Field::tel('phone', 'Phone number')
Field::url('website', 'Website')
Field::range('volume', 'Volume')
Field::date('published_at', 'Publish date')
Field::datetimeLocal('scheduled_at', 'Scheduled at')
Field::time('start_time', 'Start time')
Field::week('week', 'Week')
Field::month('month', 'Month')

Toggle

A toggle renders as an on/off switch. The WakaPAC bind expression uses checked:, mapping the value as a boolean rather than a string. The initial state is read from the data array passed to render() — a truthy value renders the toggle on, a falsy value or absent key renders it off. The disabled() modifier is supported.

Field::toggle('is_active', 'Active')
Field::toggle('is_active', 'Active')->disabled()
echo $loom->render($definition, [
    'is_active' => true,
]);

Hidden

A hidden field renders a bare <input type="hidden"> with no label, no wrapper div, and no WakaPAC binding. Use it to pass IDs or flags alongside a form without exposing them in the UI. The value is populated from the data array.

Field::hidden('post_id')
echo $loom->render($definition, [
    'post_id' => 42,
]);

Validation Attributes

Field::text('title', 'Title')
    ->required()
    ->maxlength(200)
    ->placeholder('Enter a title')

Field::number('priority', 'Priority')
    ->min(1)
    ->max(10)
    ->step(1)

Field::text('slug', 'Slug')
    ->pattern('[a-z0-9-]+')
    ->readonly()

Hint Text

A hint is displayed below the field. It supports WakaPAC interpolation for reactive values:

Field::text('slug', 'Slug')
    ->hint('Used in the URL. Only lowercase letters, numbers and hyphens.')

Field::textarea('body', 'Content')
    ->hint('{{ body.length }} characters typed')

Select Options

Static options are defined as explicit value/label pairs:

Field::select('status', 'Status')
    ->options([
        ['value' => 'draft',     'label' => 'Draft'],
        ['value' => 'published', 'label' => 'Published'],
    ])

Dependent Dropdowns

A select can depend on the value of another select. Options are nested by parent value — Loom converts this structure into a WakaPAC foreach expression that filters the available options based on the selected value of the parent field:

Field::select('country', 'Country')
    ->options([
        ['value' => 'nl', 'label' => 'Netherlands'],
        ['value' => 'de', 'label' => 'Germany'],
    ])

Field::select('region', 'Region')
    ->dependsOn('country')
    ->options([
        'nl' => [
            ['value' => 'nh', 'label' => 'Noord-Holland'],
            ['value' => 'zh', 'label' => 'Zuid-Holland'],
        ],
        'de' => [
            ['value' => 'by', 'label' => 'Bayern'],
        ],
    ])

Field::select('city', 'City')
    ->dependsOn('region')
    ->options([
        'nl' => [
            'nh' => [
                ['value' => 'ams', 'label' => 'Amsterdam'],
            ],
        ],
    ])
Note: Dependent dropdowns must be direct siblings within the same Column or Section — dependency resolution only scans direct children of a field container.

Read-only Content

The Text node renders a label and a value without an input element. Use it to display computed values, identifiers, or any data that should not be editable. Supports WakaPAC interpolation for reactive values.

Text::make('Created at', '{{created_at}}')
Text::make('Post ID', '{{id}}')
Text::make('Author', 'Floris')

Buttons

Buttons trigger actions via WakaPAC binding expressions — either unit functions from Stdlib or methods on the container abstraction. Three variants are available: primary (default), secondary, and danger.

Button::make('Save draft')->secondary()->action('submit()')
Button::make('Publish')->action("post('/admin/posts/publish')")
Button::make('Delete')->danger()->action("Stdlib.sendMessage('post-form', MSG_DELETE, 0, 0)")

Container Methods

Every Resource exposes these built-in methods on its WakaPAC abstraction:

  • submit() — submits the form natively via the browser
  • post(url) — posts the form data to a custom endpoint via fetch
Button::make('Save')->action('submit()')
Button::make('Publish')->action("post('/admin/posts/publish')")

Stdlib Functions

The following Stdlib functions are commonly used in Loom button action expressions. Stdlib covers more than what is listed here — see the WakaPAC documentation for the full reference.

  • Stdlib.sendMessage(pacId, message, wParam, lParam, extended={}) — send a message to a specific component
  • Stdlib.sendMessageToParent(pacId, message, wParam, lParam, extended={}) — send a message to the parent component
  • Stdlib.broadcastMessage(message, wParam, lParam, extended={}) — broadcast a message to all components

Notifications

Notifications are displayed at the top of the form. They are added to the Loom instance before rendering — typically populated from session flash data after a redirect. Four types are available: success, error, warning, and info.

$loom = new Loom();
$loom->notification('error', 'Title is required.');
$loom->notification('error', 'Slug is required.');
$loom->notification('success', 'Post saved successfully.');

echo $loom->render($definition, $data);

Multiple notifications are displayed together. Clicking the dismiss button removes all of them at once.

Data Binding

Entity data is passed as the second argument to render(). Loom distributes the values to the corresponding fields via DOM hydration — the field name attribute is used as the key.

echo $loom->render($definition, [
    'title'  => 'My First Post',
    'slug'   => 'my-first-post',
    'status' => 'draft',
]);

Nested values are supported via dot and bracket notation:

echo $loom->render($definition, [
    'configuration' => [
        'theme' => 'dark',
    ],
]);
<input name="configuration[theme]" data-pac-field value="dark">

Values in the data array take precedence over any value set in the builder. If a field has no corresponding key in the data array, the builder value is used as the default.

Custom Renderers

Any node type can be handled by a custom renderer. By default, Loom resolves renderers by naming convention — a node with type field maps to Quellabs\Canvas\Loom\Renderer\FieldRenderer. Custom renderers override this convention for specific types.

Creating a Renderer

Extend AbstractRenderer and implement RendererInterface. The render() method returns a RenderResult, which carries the generated HTML and an optional script string. Each renderer produces at most one script — accumulation across the tree is handled by the engine.

class MapFieldRenderer extends AbstractRenderer {

    public function render(array $properties, string $children, ?array $parent = null, int $index = 0): RenderResult {
        $name  = $properties['name']  ?? '';
        $label = $properties['label'] ?? '';

        $html = <<<HTML
            <div class="map-field">
            <label>{$label}</label>
            <div data-pac-id="map-{$name}" class="map-container"></div>
        </div>
        HTML;

        $script = "wakaPAC('map-{$name}', MapComponent, { hydrate: false });";

        return new RenderResult($html, $script);
    }
}

Registering a Renderer

Register the renderer on the Loom instance before rendering:

$loom = new Loom();
$loom->register('field', MapFieldRenderer::class);

echo $loom->render($definition, $data);

The registered renderer replaces the default for that type across the entire render pass. To override only specific nodes, check the node properties inside the renderer and delegate to the parent for all other cases:

class MapFieldRenderer extends AbstractRenderer {

    public function render(array $properties, string $children, ?array $parent = null, int $index = 0): RenderResult {
        if (($properties['input'] ?? '') !== 'map') {
            return (new FieldRenderer($this->loom))->render($properties, $children, $parent, $index);
        }

        // custom map rendering
    }
}