Skip to content

Behaviour ​

Fields can react to each other in two places. In the browser, triggers show, hide or fill a field as another one changes, and presets copy text from one field to another. On the server, the form refreshes fields from the posted values, calls change handlers and lets the model adjust its fields. The model also validates the saved values, and the form context and defaults decide what a new record starts with.

Validation ​

The model's $rules check the values on save, and a failed rule stops the save and shows its message. The form passes each field's label to the model as the attribute name, so a message reads "The Name field is required" rather than using the column name. A field the rules require gets the required marker beside its label. The required property sets the marker yourself, without checking anything, and required: false removes a marker the rules would add. autoFocus puts the cursor in the name field when the page loads.

yaml
fields:
    name:
        label: Name
        span: left
        autoFocus: true
        comment: Required by the model's rules. Clear it and save to see the error.

    email:
        label: Email
        type: email
        span: right
        comment: Checked by the model's email rule.

    code:
        label: Code
        required: true
        comment: The required property only shows the marker, so an empty code still saves.
php
/**
 * @var array<string, mixed> rules for validation, which also mark the matching form fields as required.
 */
public $rules = [
    'name' => 'required',
    'slug' => 'nullable|alpha_dash',
    'email' => 'nullable|email',
    'price' => 'nullable|numeric|min:0',
    'quantity' => 'nullable|integer|between:0,999',
    'discount_percent' => 'nullable|integer|between:1,90',
];

The required marker is read from models that use the Validation trait. Models covers validating other models.

Dependent fields ​

A field lists the fields it relies on in dependsOn. When one of them changes, the form posts its values to its onRefresh handler and redraws the dependent field, so its options can follow the new values. While the form refreshes, the model is filled with the posted values without being saved, so an options method such as getStateOptions reads the picked country from $this. A redrawn field fires its own change, so a chain of dependent fields updates in turn, as the city does here.

yaml
fields:
    country:
        label: Country
        type: dropdown
        span: left
        emptyOption: Pick a country
        comment: Starts the chain of dependent fields.

    state:
        label: State
        type: dropdown
        span: right
        emptyOption: Pick a state
        dependsOn: country
        comment: Reloads its options when the country changes.

    city:
        label: City
        type: dropdown
        span: left
        emptyOption: Pick a city
        dependsOn: [country, state]
        comment: Reloads when the country or the state changes.

    postcode:
        label: Postcode
        span: right
        dependsOn: country
        comment: Hidden by the model's filterFields until a country is picked, and called ZIP Code for the United States.
php
/**
 * getCountryOptions returns the countries that start the dependent dropdown chain.
 *
 * @return array<string, string>
 */
public function getCountryOptions(): array
{
    return [
        'au' => 'Australia',
        'nz' => 'New Zealand',
        'us' => 'United States',
    ];
}

/**
 * getStateOptions returns the states of the picked country, refreshed through dependsOn.
 *
 * @return array<string, string>
 */
public function getStateOptions(): array
{
    return array_map(
        fn (array $state) => $state['name'],
        static::listRegions()[$this->country] ?? []
    );
}

/**
 * getCityOptions returns the cities of the picked state, refreshed through dependsOn.
 *
 * @return array<string, string>
 */
public function getCityOptions(): array
{
    return static::listRegions()[$this->country][$this->state]['cities'] ?? [];
}

The model's filterFields method runs on every render and refresh, with the form's fields and its context (refresh during a refresh), and can change any property of any field. Here it hides the postcode until a country is picked, and relabels it for the United States. A model can do the same from a model.form.filterFields event listener.

php
/**
 * filterFields adapts the postcode field to the picked country whenever the form renders or refreshes.
 */
public function filterFields(ElementHolder $fields, ?string $context = null): void
{
    if ($fields->postcode) {
        $fields->postcode->hidden = !$this->country;
        $fields->postcode->label = $this->country === 'us' ? 'ZIP Code' : 'Postcode';
        $fields->postcode->placeholder = $this->country === 'us' ? '10001' : '2000';
    }
}

Triggers on a checkbox ​

trigger changes a field in the browser when another field meets a condition, with no request to the server. It takes the field to watch, the condition and the action. The checked and unchecked conditions watch a checkbox or switch. show and enable apply while the condition holds, and reverse when it stops, so the discount code hides again when the box is unticked.

yaml
fields:
    has_discount:
        label: Offer a discount
        type: checkbox
        comment: Tick to show the code and enable the percentage.

    discount_code:
        label: Discount Code
        span: left
        trigger:
            action: show
            field: has_discount
            condition: checked

    discount_percent:
        label: Discount Percent
        type: number
        span: right
        min: 1
        max: 90
        trigger:
            action: enable
            field: has_discount
            condition: checked

show and hide act on the element that wraps the field, while enable, disable and empty act on the input. A field hidden by a trigger is still posted and saved. A field disabled by a trigger posts nothing, so its saved value stays as it was.

Triggers on a value ​

value[a][b] holds when the field's value is any of the listed values. A * matches any run of text, so value[courier*] matches both courier options and value[*] holds for any value at all, while value[] holds while the field is empty.

yaml
fields:
    delivery:
        label: Delivery
        type: dropdown
        span: left
        emptyOption: Pick a method
        options:
            pickup: Pick up in store
            courier: Courier
            courier_express: Express courier
            post: Post

    delivery_address:
        label: Delivery Address
        span: right
        trigger:
            action: show
            field: delivery
            condition: value[courier*][post]
        comment: The wildcard in courier* matches both couriers.

    _pickup_hint:
        label: Ready for pick up
        type: hint
        mode: success
        trigger:
            action: show
            field: delivery
            condition: value[pickup]

    _no_delivery_hint:
        label: Pick a delivery method
        type: hint
        mode: warning
        trigger:
            action: show
            field: delivery
            condition: value[]

Trigger actions ​

Besides show and enable, the actions are hide, disable, empty, which clears the field, and fill[value], which sets it (fill alone sets 1, or ticks a checkbox). Join actions with a pipe, as in hide|empty. empty and fill act once, when the condition starts to hold, and do not restore the old value when it stops. Both fire a change on the field, so its own triggers and dependent fields follow.

yaml
fields:
    is_featured:
        label: Featured
        type: switch
        comment: Turn it on and off to drive the fields below.

    priority:
        label: Priority
        type: dropdown
        span: left
        options:
            low: Low
            normal: Normal
            high: High
        trigger:
            action: fill[high]
            field: is_featured
            condition: checked
        comment: Set to High when Featured turns on.

    excerpt:
        label: Teaser
        span: right
        trigger:
            action: disable
            field: is_featured
            condition: unchecked
        comment: Disabled while Featured is off.

    notes:
        label: Why is it not featured?
        type: textarea
        size: tiny
        trigger:
            action: hide|empty
            field: is_featured
            condition: checked
        comment: Hidden and cleared while Featured is on.

Triggers on a list ​

A field that posts several values, such as a checkbox list, is named with [] in the trigger, as in features[], so the condition reads every ticked option and holds when any of them matches.

yaml
fields:
    features:
        label: Features
        type: checkboxlist
        inlineOptions: true
        options:
            wifi: Wi-Fi
            parking: Parking
            pool: Pool
            gym: Gym

    summary:
        label: Parking Details
        type: textarea
        size: tiny
        trigger:
            action: show
            field: features[]
            condition: value[parking]
        comment: Shown while Parking is one of the ticked features.

Triggers on a parent field ​

A trigger names a field of its own form, so inside a nested form or a repeater item it names fields of the same level. A leading ^ climbs one level up to the parent form, and ^^ climbs two.

yaml
fields:
    size:
        label: Size
        type: radio
        inlineOptions: true
        options:
            s: Small
            m: Medium
            l: Large

    packaging:
        label: Packaging
        type: nestedform
        form:
            fields:
                boxes:
                    label: Boxes
                    type: number
                    span: left
                    min: 1

                crate_notes:
                    label: Crate Notes
                    span: right
                    trigger:
                        action: show
                        field: ^size
                        condition: value[l]
                    comment: Shown for the Large size, a field of the parent form.

Change handler ​

changeHandler names an AJAX handler on the controller, called with the form's values whenever the field changes. The handler decides what to send back. This one fills the form from the posted values with setFormValues(), without saving them, and redraws the stats partial by replacing its field with renderField().

yaml
fields:
    headline:
        label: Headline
        placeholder: Write a headline, then leave the field
        changeHandler: onChangeHeadline
        comment: Changing it calls onChangeHeadline, which redraws the stats below.

    _headline_stats:
        label: Headline Stats
        type: partial
        path: ~/resources/views/reference/forms/_field_headline_stats.php
php
<?php

namespace App\Controllers\Reference\FormsController;

use Larajax\Classes\AjaxResponse;

/**
 * BehaviourHandlers holds the AJAX handlers of the Behaviour page.
 */
trait BehaviourHandlers
{
    /**
     * onChangeHeadline is the headline field's change handler, refreshing the headline stats partial from the posted values without saving them.
     */
    public function onChangeHeadline(): AjaxResponse
    {
        $form = $this->exampleForms['change-handler'];

        $form->setFormValues();

        $statsField = $form->getField('_headline_stats');

        return ajax()->update([
            '#'.$statsField->getId('group') => $form->renderField($statsField, ['useContainer' => false]),
        ]);
    }
}
php
<?php
    /* A partial field that reads the headline from the form model, redrawn by the headline's change handler */
    $headline = trim((string) $formModel->headline);
    $characters = mb_strlen($headline);
    $words = $headline === '' ? 0 : count(preg_split('/\s+/u', $headline));

    if ($characters === 0) {
        $verdict = ['fg-3', 'Type a headline and leave the field to measure it.'];
    }
    elseif ($characters > 60) {
        $verdict = ['fg-danger', 'Too long, search results cut titles after about 60 characters.'];
    }
    elseif ($characters < 20) {
        $verdict = ['fg-warning', 'A little short, aim for 20 to 60 characters.'];
    }
    else {
        $verdict = ['fg-success', 'A good length for a title.'];
    }
?>
<div class="reference-forms-inline">
    <span class="badge theme-secondary">
        <?= $characters ?> characters
    </span>
    <span class="badge theme-secondary">
        <?= $words ?> words
    </span>
    <span class="<?= $verdict[0] ?>">
        <?= e($verdict[1]) ?>
    </span>
</div>

Refreshing the form ​

Every form has an onRefresh handler, called through the form's alias as {alias}::onRefresh, that redraws the form from the posted values without saving them. Posting fields, a list of field names, redraws just those fields, as the Recalculate button in this partial does for its own field. With no fields, it redraws the whole form. Dependent fields use the same handler.

yaml
fields:
    price:
        label: Price
        type: number
        span: left
        min: 0
        step: 0.01

    quantity:
        label: Quantity
        type: number
        span: right
        min: 0
        step: 1

    _stock_value:
        label: Stock Value
        type: partial
        path: ~/resources/views/reference/forms/_field_stock_value.php
php
<?php
    /* A partial field showing the price times the quantity, with a button that asks the form's onRefresh handler to redraw this field */
    $stockValue = (float) $formModel->price * (int) $formModel->quantity;
?>
<div class="reference-forms-inline">
    <strong>
        $<?= number_format($stockValue, 2) ?>
    </strong>
    <span class="fg-3">
        <?= (int) $formModel->quantity ?> at $<?= number_format((float) $formModel->price, 2) ?>
    </span>
    <?= Ui::ajaxButton(
        label: 'Recalculate',
        handler: 'exampleRefresh::onRefresh',
        icon: 'ph ph-arrows-clockwise',
        requestData: ['fields' => ['_stock_value']],
    ) ?>
</div>

Nested names ​

A name like seo[title] reads and saves a key inside an array attribute, here the seo column, which the model lists in $jsonable so it is stored as JSON. When the first part names a singular relation, such as user[name], the value is saved on the related record instead.

yaml
fields:
    seo[title]:
        label: SEO Title
        span: left

    seo[robots]:
        label: Robots
        type: dropdown
        span: right
        options:
            index: Index and follow
            noindex: Do not index

    seo[description]:
        label: SEO Description
        type: textarea
        size: tiny

Contexts ​

A form has a context, usually create for a new record and update for a saved one, passed as context to Form::make(). A field with a context, or with a name ending in @context, only appears in that context, and context can also list several. Fields with neither appear in every context. Two definitions such as reference@create and reference@update are the same field set up two ways, so one YAML file serves both forms.

yaml
fields:
    _create_hint:
        label: Creating
        type: hint
        mode: tip
        context: create
        comment: This hint has context create.

    _update_hint:
        label: Editing
        type: hint
        mode: success
        context: update
        comment: This hint has context update.

    reference@create:
        label: Reference
        placeholder: REF-0001
        comment: Editable while creating.

    reference@update:
        label: Reference
        readOnly: true
        comment: Read-only once the record exists.

The first example on the demo page edits the saved record, so its context is update. The second renders the same YAML on a new record in the create context. That form, and the default and preset forms below it, have an arrayName of NewSample, so their fields post under NewSample[...] and the page's Save leaves them out:

php
/**
 * behaviour displays the Behaviour page, where the context, default and preset examples also edit a new record.
 */
public function behaviour(): View
{
    $this->makeExampleForms('forms/behaviour', [
        'validation', 'dependent-fields',
        'trigger-checked', 'trigger-value', 'trigger-actions', 'trigger-multi-value', 'trigger-parent',
        'change-handler', 'refresh', 'nested-names', 'contexts',
    ]);

    return view('reference.forms.behaviour', $this->exampleVars() + [
        'standaloneForms' => $this->makeStandaloneForms('forms/behaviour', ['contexts', 'defaults', 'presets'], new Sample(['name' => 'Untitled sample']), [
            'arrayName' => 'NewSample',
            'context' => 'create',
        ]),
    ]);
}

Default values ​

default fills a field on a new record, and for option fields it names the option to select. defaultFrom copies another attribute of the record instead. Defaults apply when the form is in the create context, the record is not saved yet, or the form is nested in another, and only to attributes that have no value, so they never replace saved values.

yaml
fields:
    status:
        label: Status
        type: balloon-selector
        span: left
        default: draft
        options:
            draft: Draft
            active: Active
            archived: Archived

    priority:
        label: Priority
        type: dropdown
        span: right
        default: normal
        options:
            low: Low
            normal: Normal
            high: High

    quantity:
        label: Quantity
        type: number
        span: left
        default: 1

    is_featured:
        label: Featured
        type: switch
        span: right
        default: true

    headline:
        label: Headline
        defaultFrom: name
        comment: Starts as the record's name, through defaultFrom.

Input presets ​

preset fills a field from another one as you type, converted by its type. slug, the type used when preset is just a field name, and file turn the text into lowercase words joined by dashes, url does the same with a leading slash, camel writes camelCase, and exact copies the text as typed. Accented letters are transliterated, and short English words such as "a" and "the" are dropped.

A preset only fills an empty field, and stops once you edit the field yourself, so it suits create forms. On a saved record the field already has a value, and the preset leaves it alone.

yaml
fields:
    title:
        label: Title
        placeholder: Type a title, such as The Aurora Desk Lamp

    slug:
        label: Slug
        span: left
        preset: title
        comment: A field name alone uses the slug type.

    url:
        label: URL
        span: right
        preset:
            field: title
            type: url

    code:
        label: Code
        span: left
        preset:
            field: title
            type: camel

    file_name:
        label: File Name
        span: right
        preset:
            field: title
            type: file

    display_title:
        label: Display Title
        preset:
            field: title
            type: exact

Preset options lists what preset takes as an array.

The input preset control ​

Presets work on any input through data-input-preset, a selector for the source input. The control also has snake and namespace types, which form fields can use too, and data-input-preset-remove-words="false" keeps the short words that are dropped by default.

blade
<div class="ui-field-grid w-100">
    <div>
        <label class="form-label" for="presetClassTitle">
            Class title
        </label>
        <input type="text" id="presetClassTitle" class="form-control" placeholder="Type a title, such as Order Line Item" />
    </div>

    <div>
        <label class="form-label" for="presetClassName">
            Class name
        </label>
        <input
            type="text"
            id="presetClassName"
            class="form-control"
            data-input-preset="#presetClassTitle"
            data-input-preset-type="namespace" />
    </div>

    <div>
        <label class="form-label" for="presetTableName">
            Table name
        </label>
        <input
            type="text"
            id="presetTableName"
            class="form-control"
            data-input-preset="#presetClassTitle"
            data-input-preset-type="snake"
            data-input-preset-remove-words="false" />
    </div>
</div>
AttributeDescription
data-input-preseta selector for the input to copy from.
data-input-preset-typethe conversion: slug, url, file, camel, snake, namespace or exact. Default: slug.
data-input-preset-closest-parenta selector for a parent of both inputs, which limits where the source is looked for.
data-input-preset-prefix-inputa selector for an input whose value starts the result.
data-input-preset-remove-wordsfalse keeps short English words. Default: true.

The control dispatches input-preset:before-update and input-preset:after-update on the input it fills.

The input trigger control ​

Triggers work on any element through data-trigger, a selector for the inputs to watch, with data-trigger-action and data-trigger-condition. They take the same actions and conditions as the trigger property. Here the hint hides and the button enables once the box is ticked.

blade
<div class="w-100">
    <div class="form-field">
        <input class="check" type="checkbox" id="triggerAgreeTerms" />
        <div class="form-field-content">
            <label for="triggerAgreeTerms">
                I have read the terms
            </label>
        </div>
    </div>

    <p
        class="fg-3 mt-3"
        data-trigger="#triggerAgreeTerms"
        data-trigger-action="hide"
        data-trigger-condition="checked">
        Tick the box to continue.
    </p>

    <button
        type="button"
        class="btn-solid theme-primary mt-3"
        data-trigger="#triggerAgreeTerms"
        data-trigger-action="enable"
        data-trigger-condition="checked"
        disabled>
        Continue
    </button>
</div>
AttributeDescription
data-triggera selector for the inputs to watch.
data-trigger-actionthe actions, such as show or hide|empty.
data-trigger-conditionchecked, unchecked or value[...].
data-trigger-closest-parentselectors, separated by commas, for the parent to look for the inputs in. The first that matches an ancestor wins. Default: the whole page.

Dispatch trigger:update on the element to check the condition again, for example after changing the watched input from a script. The control dispatches trigger:after-update with the new state.

Trigger options ​

The trigger property takes these options.

OptionDescription
fieldthe field to watch, by its name in the same form. ^ climbs to a parent form, and [] reads every value of a multi-value field.
conditionchecked, unchecked, or value[a][b], where * matches any text and value[] an empty field.
actionshow, hide, enable, disable, empty or fill[value], joined with | to run several.

Preset options ​

preset takes a field name, which uses the slug type, or an array of these options.

OptionDescription
fieldthe field to copy from, by its name in the same form.
typethe conversion: slug, url, file, camel, snake, namespace or exact. Default: slug.
prefixInputa selector for an input whose value starts the result.