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.
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./**
* @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.
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./**
* 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.
/**
* 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.
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: checkedshow 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.
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.
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.
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.
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().
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
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
/* 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.
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
/* 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.
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: tinyContexts
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.
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:
/**
* 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.
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.
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: exactPreset 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.
<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>| Attribute | Description |
|---|---|
| data-input-preset | a selector for the input to copy from. |
| data-input-preset-type | the conversion: slug, url, file, camel, snake, namespace or exact. Default: slug. |
| data-input-preset-closest-parent | a selector for a parent of both inputs, which limits where the source is looked for. |
| data-input-preset-prefix-input | a selector for an input whose value starts the result. |
| data-input-preset-remove-words | false 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.
<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>| Attribute | Description |
|---|---|
| data-trigger | a selector for the inputs to watch. |
| data-trigger-action | the actions, such as show or hide|empty. |
| data-trigger-condition | checked, unchecked or value[...]. |
| data-trigger-closest-parent | selectors, 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.
| Option | Description |
|---|---|
| field | the field to watch, by its name in the same form. ^ climbs to a parent form, and [] reads every value of a multi-value field. |
| condition | checked, unchecked, or value[a][b], where * matches any text and value[] an empty field. |
| action | show, 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.
| Option | Description |
|---|---|
| field | the field to copy from, by its name in the same form. |
| type | the conversion: slug, url, file, camel, snake, namespace or exact. Default: slug. |
| prefixInput | a selector for an input whose value starts the result. |