Skip to content

Custom Widgets ​

Your app can add its own form widgets beside the built-in ones. A form widget is a PHP class with its own partials, any number of AJAX handlers, and a script and stylesheet that load only on the pages that render it. This page builds one, a time checker that compares the browser clock with the server clock, and walks through each file.

The time checker widget ​

The widget shows the browser clock ticking, and a button that asks the server for its time. Checking runs the widget's own onCheckTime handler, which stamps the server time into the field and shows how far the two clocks drift apart. Saving the form stores the stamp like any other field.

A field uses the widget by naming its code as the type, and passes options beside it, here the format the stamp is shown in:

yaml
fields:
    checked_at:
        label: Server Time Check
        type: timechecker
        format: D j M Y, H:i:s T
        comment: Checking stamps the server time into this field, and Save stores it.

The widget class ​

A form widget extends Larajax\Ui\Classes\FormWidgetBase. The form creates it for the field, then calls these methods:

  • init() runs once the widget is created. fillFromConfig() copies options from the field's configuration onto the public properties of the same name, here format, keeping the property's value as the default.
  • render() returns the widget's markup, usually from a partial.
  • getSaveValue($value) receives the posted value when the form saves, and returns the value to store. Here an empty stamp is stored as null. When the form posts no value for the field at all, the widget is skipped and the field keeps its value.
  • Public methods whose names start with on are AJAX handlers. The widget's markup calls them by the widget's alias, as alias::onCheckTime, which getEventHandler('onCheckTime') returns.
php
<?php

namespace App\FormWidgets;

use Carbon\Carbon;
use Larajax\Ui\Classes\FormWidgetBase;

/**
 * TimeChecker is a custom form widget that compares the server clock with the browser clock and stamps the checked server time into its field.
 */
class TimeChecker extends FormWidgetBase
{
    /**
     * @var string format used to display the server times.
     */
    public $format = 'D j M Y, H:i:s T';

    /**
     * @var string defaultAlias to identify this widget.
     */
    protected $defaultAlias = 'timechecker';

    /**
     * init reads the display format from the field configuration.
     */
    public function init(): void
    {
        $this->fillFromConfig(['format']);
    }

    /**
     * loadAssets adds the widget stylesheet and script, served from the public folder.
     */
    protected function loadAssets(): void
    {
        $this->addCss('css/widgets/timechecker.css');
        $this->addJs('js/widgets/timechecker.js', ['defer' => true]);
    }

    /**
     * render the widget with its saved stamp.
     */
    public function render(): string
    {
        $this->prepareVars();

        return $this->makePartial('timechecker');
    }

    /**
     * prepareVars for the widget partials, where a checked server time replaces the saved stamp.
     */
    protected function prepareVars(?Carbon $serverTime = null): void
    {
        $stamp = $serverTime ?: $this->makeCarbon($this->getLoadValue());

        $this->vars['name'] = $this->getFieldName();
        $this->vars['value'] = $stamp ? $stamp->toDateTimeString() : '';
        $this->vars['stampLabel'] = $stamp ? $stamp->format($this->format) : null;
        $this->vars['serverTimeMs'] = $serverTime ? $serverTime->getTimestampMs() : null;
        $this->vars['isChecked'] = $serverTime !== null;
    }

    /**
     * onCheckTime reads the server clock and updates the widget, stamping the new time into the field.
     *
     * @return array<string, string>
     */
    public function onCheckTime(): array
    {
        $this->prepareVars(Carbon::now());

        return ['#'.$this->getId() => $this->makePartial('clock')];
    }

    /**
     * getSaveValue stores the stamped time, or null when the field holds none.
     */
    public function getSaveValue($value): ?string
    {
        return strlen((string) $value) ? (string) $value : null;
    }

    /**
     * makeCarbon converts a stored value into a date, ignoring values that do not parse.
     */
    protected function makeCarbon(mixed $value): ?Carbon
    {
        if (!$value) {
            return null;
        }

        try {
            return Carbon::parse($value);
        }
        catch (\Throwable $ex) {
            return null;
        }
    }
}

The base class gives the widget what it needs to read its field:

MemberDescription
getLoadValue()the field's value, from the form's model or data, or the field's default on a new record.
getFieldName()the name of the field's input, which the posted value must use.
getId($suffix)a unique id for the widget's markup, with an optional suffix.
getEventHandler($name)the full name of one of the widget's handlers, for a data-request attribute.
makePartial($name, $vars)renders a partial from the widget's partials folder.
fillFromConfig($names)copies field options onto the public properties of the same name.
getConfig($name, $default)reads one field option.
$this->varsthe variables passed to every partial.
$this->previewModetrue when the form shows its values read-only.
$this->modelthe model the form edits.
$this->formFieldthe field's definition, with its label, comment and other properties.

Partials ​

makePartial('timechecker') renders _timechecker.php from the partials folder beside the class, with $this as the widget and the entries of $this->vars as variables. A partial can render another one, as the outer partial does here.

The split matters for the handler. The outer partial keeps the element with the widget's id, which stays on the page, and the inner _clock partial is what goes inside it. The handler returns the inner partial keyed by # and that id, so Larajax replaces only the clock. The inner partial hides the check button in preview mode.

php
<div id="<?= $this->getId() ?>" class="time-checker-container">
    <?= $this->makePartial('clock') ?>
</div>
php
<div
    class="time-checker"
    data-control="timechecker"
    <?php if ($serverTimeMs): ?>data-server-time="<?= e($serverTimeMs) ?>"<?php endif ?>>
    <input
        type="hidden"
        name="<?= e($name) ?>"
        value="<?= e($value) ?>" />

    <dl class="time-checker-readings">
        <div class="time-checker-reading">
            <dt>
                Browser clock
            </dt>
            <dd data-timechecker-browser>
                --:--:--
            </dd>
        </div>
        <div class="time-checker-reading">
            <dt>
                <?= $isChecked ? 'Server clock' : 'Saved stamp' ?>
            </dt>
            <dd>
                <?= $stampLabel ? e($stampLabel) : 'Not checked yet' ?>
            </dd>
        </div>
        <div class="time-checker-reading">
            <dt>
                Drift
            </dt>
            <dd data-timechecker-drift>
                <?= $isChecked ? 'Measuring...' : 'Check the server first' ?>
            </dd>
        </div>
    </dl>

    <?php if (!$this->previewMode): ?>
        <?= Ui::ajaxButton(
            label: 'Check server time',
            handler: $this->getEventHandler('onCheckTime'),
            icon: 'ph ph-clock-clockwise',
            secondary: true
        ) ?>
    <?php endif ?>
</div>

The hidden input carries the stamp, named with getFieldName(), so the form posts it with the other fields and getSaveValue() receives it.

Registering the widget ​

registerFormWidget() gives the class a code, which fields name as their type. Register your widgets in a service provider's boot() method, through the widget manager's registerFormWidgets() callback:

php
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        // The app's own form widget, used in field definitions as type: timechecker
        app('system.widgets')->registerFormWidgets(function ($manager) {
            $manager->registerFormWidget(\App\FormWidgets\TimeChecker::class, 'timechecker');
        });
    }
}

Its own assets ​

loadAssets() runs when the widget is created, before init(), and adds the widget's files with addCss() and addJs(). Each takes a path and the attributes for its tag, such as ['defer' => true] or ['type' => 'module']. The path is resolved as follows:

  • a relative path, such as js/widgets/timechecker.js, is under public, so it is served from asset();
  • when the widget sets an $assetPath, relative paths are under that path instead, while a path starting with / stays under public;
  • a full URL, starting with https://, http:// or //, is used as it is.

The script registers a control, timechecker, that the markup starts with data-control="timechecker". The control reads the server time from data-server-time, which the partial sets after a check, ticks the browser clock, and works out the drift. Because the handler replaces the element, a new control starts for each check, and disconnect() stops the old one's timer.

js
/*
 * Time checker form widget
 *
 * Data attributes:
 * - data-control="timechecker" - ticks the browser clock and compares it with the checked server time
 * - data-server-time - the server time in milliseconds, present after a check
 *
 * The widget's AJAX handler replaces this element after each check, so a new control measures the new drift.
 */
'use strict';

(function () {
    jax.registerControl('timechecker', class extends jax.ControlBase {
        connect() {
            this.browserClock = this.element.querySelector('[data-timechecker-browser]');
            this.driftOutput = this.element.querySelector('[data-timechecker-drift]');

            if (this.config.serverTime) {
                this.showDrift(Date.now() - parseInt(this.config.serverTime, 10));
            }

            this.tick();
            this.timer = window.setInterval(this.proxy(this.tick), 1000);
        }

        disconnect() {
            window.clearInterval(this.timer);

            this.browserClock = null;
            this.driftOutput = null;
        }

        tick() {
            this.browserClock.textContent = new Date().toLocaleTimeString();
        }

        showDrift(driftMs) {
            const seconds = (driftMs / 1000).toFixed(2),
                isClose = Math.abs(driftMs) < 2000;

            this.driftOutput.textContent = (driftMs >= 0 ? '+' : '') + seconds + ' s';
            this.driftOutput.classList.toggle('is-close', isClose);
            this.driftOutput.classList.toggle('is-far', !isClose);
        }
    });
})();
css
/*
 * Time checker form widget
 * --------------------------------------------------
 * Loaded by App\FormWidgets\TimeChecker through loadAssets(), so it only reaches pages that render the widget.
 */

.time-checker {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 1rem;
    padding: 1rem;
    border: 1px dashed var(--bs-border-color);
    border-radius: 8px;

    .time-checker-readings {
        display: flex;
        flex: 1 1 auto;
        flex-wrap: wrap;
        gap: 0.75rem 2rem;
        margin: 0;
    }

    .time-checker-reading {
        min-width: 9rem;

        dt {
            font-size: 0.75rem;
            font-weight: 600;
            letter-spacing: 0.04em;
            text-transform: uppercase;
            color: var(--bs-fg-3);
        }

        dd {
            margin: 0;
            font-variant-numeric: tabular-nums;
            font-size: 1.05rem;
        }
    }

    [data-timechecker-drift] {
        &.is-close {
            color: var(--bs-success-fg, var(--bs-success-base));
        }

        &.is-far {
            color: var(--bs-danger-fg, var(--bs-danger-base));
        }
    }
}

See the control API of Larajax for jax.registerControl() and jax.ControlBase.

Printing the assets ​

Ui::renderAssets() prints a <link> or <script> tag for every asset the widgets added during the request, stylesheets first and each file once. Call it in your layout's <head>, after the library's own assets. This works because Blade renders the page content before the layout around it, so by the time the head renders, the page's widgets have added their assets.

blade
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>
            @yield('title', 'Amber') - {{ config('app.name', 'Amber') }}
        </title>

        {{-- Sets data-bs-theme before the page paints, from the saved choice or else the OS preference --}}
        <script>
            (function () {
                var amberTheme = null;

                try {
                    amberTheme = localStorage.getItem('amber-theme');
                }
                catch (e) {
                    // Storage can be unavailable, so the OS preference applies
                }

                if (amberTheme !== 'light' && amberTheme !== 'dark') {
                    amberTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
                }

                document.documentElement.setAttribute('data-bs-theme', amberTheme);
            })();
        </script>

        {{-- No build step: assets load from a CDN and the published vendor/larajax files. --}}
        <script src="https://cdn.jsdelivr.net/npm/@phosphor-icons/web@2.1.2"></script>
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@6.0.0-alpha.1/dist/css/bootstrap.min.css" rel="stylesheet">
        <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
        <link href="{{ asset('vendor/larajax/ui/ui.css') }}" rel="stylesheet">
        <link href="{{ asset('css/amber.css') }}" rel="stylesheet">
        <script type="module" src="https://cdn.jsdelivr.net/npm/bootstrap@6.0.0-alpha.1/dist/js/bootstrap.bundle.min.js"></script>
        <script src="{{ asset('vendor/larajax/framework-bundle.js') }}"></script>
        <script type="module" src="{{ asset('vendor/larajax/ui/ui.js') }}"></script>
        <script src="{{ asset('js/amber.js') }}" defer></script>
        <script type="module" src="{{ asset('js/code-blocks.js') }}"></script>

        {{-- Assets added by custom widgets; the page content renders before this layout, so its widgets are included --}}
        {{ Ui::renderAssets() }}
    </head>
    <body class="amber-layout">
        <div class="app-shell">
            <button
                class="sidebar-scrim"
                type="button"
                aria-label="Close navigation"
                data-amber-menu-close></button>

            @include('partials.site.sidebar')

            @include('partials.site.topbar')

            <main class="app-main">
                @yield('content')
            </main>
        </div>
    </body>
</html>

Ui::renderAssets('css') or Ui::renderAssets('js') prints one kind only, for a layout that places scripts at the end of the body.

A widget created by an AJAX handler, such as a form loaded into a popup, sends its assets with the handler's response instead, and the browser loads any that the page does not have yet.