Editable Livewire Datagrid — Inline Editing, Formula Columns, Focus Modes & Async Pickers
A spreadsheet-grade entry screen driven entirely from one Livewire component class: the client applies every keystroke optimistically and streams typed ops to the server, where each write is authorized, cast, validated, run through your hooks and recomputed for formula columns — then the response reconciles the authoritative values back into the grid. Rows are addressed by stable keys, never positions. Nothing here is written to the database.
Try it:
type a resort name — the rate and city auto-fill from the pick, and the option list
shows the tariff as muted meta ·
pick two dates and Nights derives itself, then Base, GST and Amount
recompute in the same round trip ·
notice Enter never stops on Guests — a
FocusMode::Manual column that defaults to 2; click it when a party is
bigger — nor on Booked, a FocusMode::Never column stamped
from a closure default ·
tick Comp? and watch the rate zero itself and GST% grey out
(whenFilled + lockedWhen, mirrored client-side, authoritative
server-side) ·
switch Tax to Exempt and the required cell moves from GST% to the
exemption reason ·
Enter on Note opens a host panel and resumes exactly where
it left off ·
Y/N answers Confirmed and advances in one keystroke ·
Delete clears, F9 or Shift+Delete
deletes the row, Insert adds one, F7 repeats the row above,
Ctrl+D fills down,
Ctrl+Z/Ctrl+Y undo and redo,
Ctrl+C copies TSV and a multi-row paste from Excel maps straight
onto the cells ·
leave a required cell blank on a filled row and the footer's Review errors
control lists it (Ctrl+E jumps there) ·
reload the page mid-entry and a Restore / Discard bar brings the draft
back from IndexedDB (persistDraft()) ·
pick <-- End of List --> on a blank row to finish — focus lands on Save.
refreshesHost().
grid.commitAndSave('save') — commit the editor, flush, await a
settled queue, then call the host — and is disabled while the grid reports
canSave: false. The cleaned gridRows() land in the tab below.
What this page demonstrates
- Optimistic client, authoritative server — a typed op protocol, validation on both sides, and formula recomputation server-side after your hooks.
- Column focus modes — the ERP field behaviours as one chain:
FocusMode::Always(the default stop-here field),FocusMode::Manual(skipped by Enter/Tab, editable on click — Guests) andFocusMode::Never(skipped and locked — Booked), each with a static or closuredefault()that seeds every fresh row. - Async picker with row enrichment —
SearchSelectColumn::optionsUsing()streams options over an RPC andonSelect()pre-fills dependent cells in the same round trip. - Chained formula columns —
base → tax → amount, evaluated live in the browser and authoritatively in PHP by twin evaluators. - Declarative cell rules —
rules(),required()andreadonly()(static or per-row closures),requiredWhen(),lockedWhen()andwhenFilled()sibling mirrors. - Row lifecycle — per-column
default()s as the new-row template,autoAppend(),minRows(),padRows(), and blank trailing rows that are invisible to validation, totals andgridRows(). - The completion circuit — an end-of-list picker exit fires
lgrid:complete,onCompleteFocus()carries focus to Save, andfocusOutTo()separately repairs the plain Tab exit. - The Save contract —
grid.commitAndSave('save')instead of a racingwire:click, a Save button that mirrors the bubblinglgrid:statechangeevent'scanSave, and the grid's own sync bar (Saved · Syncing · Offline · Retry) and Review errors list. - Draft recovery —
persistDraft()keeps rows, queued ops, errors and focus in IndexedDB until the save is confirmed; a reload offers Restore / Discard, and every editable grid warns before a dirty navigation. - Host hand-offs —
opensPanel()withlgrid:panel/gridPanelDone(), andrefreshesHost()for live chrome outside the grid. - Undo that never bypasses the server — 100 steps, one gesture per step; each undone step replays through the same op protocol as typing.
The whole screen, in one class
<?php
declare(strict_types=1);
namespace App\Livewire;
use App\Models\Resort;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Carbon;
use LaraGrid\Aggregate;
use LaraGrid\ColumnGroup;
use LaraGrid\Columns\CheckboxColumn;
use LaraGrid\Columns\DateColumn;
use LaraGrid\Columns\DecimalColumn;
use LaraGrid\Columns\FocusMode;
use LaraGrid\Columns\FormulaColumn;
use LaraGrid\Columns\HiddenColumn;
use LaraGrid\Columns\IntegerColumn;
use LaraGrid\Columns\ReadonlyColumn;
use LaraGrid\Columns\SearchSelectColumn;
use LaraGrid\Columns\SelectColumn;
use LaraGrid\Columns\SerialColumn;
use LaraGrid\Columns\TextColumn;
use LaraGrid\Columns\YesNoColumn;
use LaraGrid\Editing\RowContext;
use LaraGrid\Grid;
use LaraGrid\GridDensity;
use LaraGrid\Livewire\WithLaraGrid;
use LaraGrid\SyncPolicy;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The EDITABLE showcase — a booking-lines entry screen over the real resorts table, exercising
* the whole editing machinery in one definition.
*
* Covered here:
* modes/rows — editable(), rowsFrom(), defaultRows(), per-column default() as the new-row
* template, minRows(), padRows(), autoAppend(), sync(SyncPolicy::PerCell),
* persistDraft() (IndexedDB crash/reload recovery),
* gridMountRows()/gridRows()/reseedGrid()
* columns — Serial, SearchSelect (async options + meta + onSelect enrichment +
* endOfListOption), Select, Text (maxLength/upper), Integer, Decimal, Date,
* Checkbox, YesNo, Formula (chained: base → tax → amount), Readonly and a
* writable Hidden carried id
* focus modes — the ERP field behaviours: FocusMode::Always (the default stop-here field),
* FocusMode::Manual (Enter/Tab skip it, a click edits it — Guests) and
* FocusMode::Never (skipped and locked, filled from a closure default — Booked)
* rules — rules(), required(), required(fn), readonly(fn), requiredWhen(), lockedWhen(),
* whenFilled() sibling mirrors
* hooks — onSelect(), afterCellChange() (derived nights), afterRowRemove()
* flow — focusOnMount(), focusOutTo(), onCompleteFocus() + lgrid:complete,
* opensPanel() + lgrid:panel / gridPanelDone(), refreshesHost(), and the
* host Save contract: grid.commitAndSave('save') + lgrid:statechange
* layout — columnGroups(), stickyHeader(), freezeColumns(), density(), theme(),
* statusBar(), persistWidths(), rowClass(), cellClass(), footer aggregates
*
* Nothing writes to the database — Save captures the cleaned rows and shows them.
*/
#[Layout('components.layouts.app', ['wide' => true])]
class BookingEntry extends Component
{
use WithLaraGrid;
private const PLANS = [
'EP' => 'Room Only (EP)',
'CP' => 'With Breakfast (CP)',
'MAP' => 'Half Board (MAP)',
'AP' => 'Full Board (AP)',
];
private const TAX_STATUS = ['taxable' => 'Taxable', 'exempt' => 'Exempt'];
/** @var list<array<string, mixed>> The grid-bound rows (each carries a stable _k). */
public array $lines = [];
/** @var list<array<string, mixed>> The last saved (cleaned) payload, for display. */
public array $saved = [];
/**
* Host-owned extras captured by the ->opensPanel() modal, keyed by the grid's row key.
* Deliberately NOT grid columns: a panel exists precisely for the data that belongs to a
* line but has no place in the row of cells (serial numbers, long descriptions, in this
* demo the guest's special requests).
*
* @var array<string, array{requests: string, arrival: string}>
*/
public array $extras = [];
/** The row key the open panel is editing, or null when no panel is open. */
public ?string $panelRow = null;
public string $panelRequests = '';
public string $panelArrival = '';
/** A plain host field, so ->focusOutTo() has somewhere meaningful to send Tab. */
public string $remarks = '';
public function mount(): void
{
$this->lines = $this->gridMountRows('lines');
}
/**
* @return array<string, Grid>
*/
protected function grids(): array
{
return [
'lines' => Grid::make('lines')
->editable()
->rowsFrom('lines')
// Demo app has no auth — permit openly. Gate with a policy in real apps.
->authorize(fn (): bool => true)
->defaultRows(3)
// The new-row TEMPLATE is built from the columns: every declared column null,
// then each column's ->default() (see guests / plan / taxStatus / gstPct /
// bookedOn below). The same template builds seeded rows, Insert rows,
// auto-appended rows and paste rows — so a row nobody touched still counts as
// BLANK (defaults are not operator data) and stays exempt from validation,
// totals and gridRows(). ->newRowUsing() is the grid-level alternative — the
// voucher grid uses that one.
->minRows(1)
->autoAppend() // Enter past the last cell grows the grid
->padRows(3) // paint empty filler rows so the box never looks cropped
// One op per committed cell — the default. PerRow batches a row's ops until the
// cursor leaves it; Deferred holds everything until the host flushes.
->sync(SyncPolicy::PerCell)
// Crash / reload recovery: rows, queued ops, errors and focus go to IndexedDB
// under this key; a reload offers Restore / Discard, and the draft is kept until
// the host save is confirmed (commitAndSave → markSaved, or the reseed in save()).
// Opt-in because it stores row data in the browser — scope the key by tenant,
// operator and document on shared machines.
->persistDraft('local', 'laragrid-demo:booking')
->keymap('entry') // serpentine Enter flow; 'excel' = Enter down, Tab right
->focusOnMount()
->focusOutTo('#remarks') // Tab off the last cell → the Remarks field
->onCompleteFocus('[data-save]')// the completion signal → Save
// Re-render this component's own chrome (the live badge above the grid) whenever
// one of these columns changes — the grid's rows are already written back to
// $this->lines server-side, so render() can total them.
->refreshesHost(['nights', 'rate', 'gstPct', 'taxStatus', 'complimentary'])
->columnGroups([
ColumnGroup::make('Stay', ['fromDate', 'toDate', 'nights', 'guests']),
ColumnGroup::make('Tariff', ['plan', 'rate', 'complimentary', 'base']),
ColumnGroup::make('Tax', ['taxStatus', 'gstPct', 'exemptReason', 'tax']),
])
->columns([
SerialColumn::make(),
SearchSelectColumn::make('resort_id')->label('Resort')
// A synthetic first dropdown entry that ENDS entry: it commits no value,
// it fires lgrid:complete. Only offered on a blank trailing row.
->endOfListOption(allowOnEmpty: true)
->optionsUsing(fn (string $term): array => Resort::query()
->where('visibility', 'show')
->when($term !== '', fn ($q) => $q->where('name', 'like', "%{$term}%"))
->orderBy('name')
->limit(50)
->get(['id', 'name', 'city', 'comparison_tariff'])
->map(fn (Resort $resort): array => [
'value' => (string) $resort->id,
'label' => $resort->name,
// 'meta' paints right-aligned and muted in the option list —
// the stock-on-hand slot. Here: the resort's city and tariff.
'meta' => trim(($resort->city ?? '').' ₹'.(int) $resort->comparison_tariff),
])
->all())
// Enrichment: picking a resort pre-fills the rate and city; clearing the
// pick clears them. Write-backs ride the op response, and the formula
// columns recompute AFTER this hook in the same round trip.
->onSelect(function (RowContext $row, mixed $value): void {
if ($value === null) {
$row->set('rate', null)->set('city', null);
return;
}
$resort = Resort::query()->whereKey($value)
->first(['name', 'city', 'comparison_tariff']);
$row->set('city', $resort?->city);
$row->set('rate', $resort?->comparison_tariff !== null
? number_format((float) $resort->comparison_tariff, 2, '.', '')
: null);
// Keeps the picker's painted label right even after a reseed.
$row->setLabel('resort_id', (string) ($resort?->name ?? ''));
})
->required()
->minChars(0)->debounce(250)->limit(50)
->minWidth(180)->grow(),
// Written by the hook above, never by the operator.
ReadonlyColumn::make('city')->label('City')->width(110),
// FocusMode::Never — the ERP "fixed" field: keyboard navigation skips it, it
// is locked, and a Closure default stamps it on every fresh row (seeded,
// auto-appended, inserted or pasted). The classic voucher-date cell.
DateColumn::make('bookedOn')->label('Booked')->width(105)
->displayFormat('d-M-Y')
->focusMode(FocusMode::Never, default: fn (): string => now()->toDateString()),
DateColumn::make('fromDate')->label('From')->width(115)
->displayFormat('d-M-Y')
->required(),
DateColumn::make('toDate')->label('To')->width(115)
->displayFormat('d-M-Y')
->required(),
// Derived by afterCellChange() below, but still editable so the operator can
// override the computed count.
IntegerColumn::make('nights')->label('Nights')->width(80)->align('right')
->rules(['integer', 'min:1', 'max:60'])
->required(),
// FocusMode::Manual — the ERP "semi-variable" field: Enter and Tab skip it
// (the default of 2 is right most of the time), a mouse click still edits
// it. The default seeds the new-row template, so an untouched row stays
// blank and required() is already satisfied on it.
IntegerColumn::make('guests')->label('Guests')->width(80)->align('right')
->rules(['integer', 'min:1', 'max:12'])
->focusMode(FocusMode::Manual, default: 2)
->required(),
SelectColumn::make('plan')->label('Meal Plan')->options(self::PLANS)
->default('CP')
->width(150)->required(),
DecimalColumn::make('rate')->label('Rate / night')->scale(2)->width(120)
->align('right')->format('number', ['scale' => 2])
->rules(['numeric', 'min:0'])
// A per-row readonly closure is a SERVER verdict: a complimentary line's
// rate is locked at zero and the server refuses a write to it.
->readonly(fn (array $row): bool => (bool) ($row['complimentary'] ?? false))
// …and required only when it is actually chargeable.
->required(fn (array $row): bool => ! ($row['complimentary'] ?? false)),
// whenFilled() is a pure DECLARATION the client mirrors instantly: ticking
// the box zeroes the rate and blanks the GST% on the same row, with no
// round trip. afterCellChange() below is the authoritative twin.
CheckboxColumn::make('complimentary')->label('Comp?')->width(90)->align('center')
->whenFilled(sets: ['rate' => '0.00', 'taxStatus' => 'exempt'], clears: ['gstPct']),
FormulaColumn::make('base')->label('Base')->width(110)->align('right')
->formula('round(nights * rate, 2)'),
SelectColumn::make('taxStatus')->label('Tax')->options(self::TAX_STATUS)
->default('taxable')
->width(110)->required(),
// lockedWhen(): the client can pre-evaluate a SIBLING-keyed lock, so the
// editor refuses these cells, serpentine navigation skips them, and they
// paint muted — instantly, with no server round trip.
DecimalColumn::make('gstPct')->label('GST %')->scale(2)->width(90)
->align('right')
->default('12.00')
->rules(['numeric', 'min:0', 'max:28'])
->lockedWhen('taxStatus', 'exempt')
->requiredWhen('taxStatus', 'taxable'),
TextColumn::make('exemptReason')->label('Exemption reason')->maxLength(60)
->upper() // committed values are upper-cased
->minWidth(160)
->lockedWhen('taxStatus', 'taxable')
->requiredWhen('taxStatus', 'exempt'),
// Formulas recompute in declaration order, so a later formula may read an
// earlier one: tax reads base, amount reads both.
FormulaColumn::make('tax')->label('GST')->width(100)->align('right')
->formula('round(base * gstPct / 100, 2)'),
FormulaColumn::make('amount')->label('Amount')->width(120)->align('right')
->formula('round(base + tax, 2)'),
YesNoColumn::make('confirmed')->label('Confirmed?')->width(105)->align('center'),
// opensPanel(): Enter here hands off to the HOST modal instead of advancing.
// The advance is stashed and resumes when the host fires lgrid:panel-done.
TextColumn::make('note')->label('Note (Enter opens the panel)')
->maxLength(100)->minWidth(180)->grow()
->opensPanel('line-notes'),
// Carried, unpainted — and ->writable() so ops may set it (a HiddenColumn is
// read-only by default).
HiddenColumn::make('line_id')->writable(),
])
->footer([
Aggregate::sum('nights')->format('number'),
Aggregate::sum('base')->format('number', ['scale' => 2]),
Aggregate::sum('tax')->format('number', ['scale' => 2]),
Aggregate::sum('amount')->format('number', ['scale' => 2]),
])
// Runs after EVERY applied cell change (typing, paste, fill-down). Two jobs here:
// derive nights from the date range, and be the AUTHORITATIVE twin of the
// whenFilled() mirror declared on the Comp? column.
->afterCellChange(function (RowContext $row, string $column): void {
if ($column === 'complimentary') {
if ($row->get('complimentary')) {
$row->set('rate', '0.00')->set('taxStatus', 'exempt')->set('gstPct', null);
}
return;
}
if ($column === 'taxStatus') {
// Switching sides clears the cell the other side owns, so a stale value
// can never survive behind a lockedWhen() mask.
$row->get('taxStatus') === 'exempt'
? $row->set('gstPct', null)
: $row->set('exemptReason', null);
return;
}
if (! in_array($column, ['fromDate', 'toDate'], true)) {
return;
}
$from = $row->get('fromDate');
$to = $row->get('toDate');
if (! $from || ! $to) {
return; // one side still blank — nothing to derive yet
}
$nights = (int) Carbon::parse($from)->startOfDay()
->diffInDays(Carbon::parse($to)->startOfDay(), false);
// A reversed range derives nothing — clear nights so the required/min
// validation flags the row instead of silently keeping a stale count.
$row->set('nights', $nights >= 1 ? $nights : null);
})
// Fires after a row is deleted (F9 / Shift+Delete / the row menu): drop any
// host-side extras the panel captured for a line that no longer exists.
->afterRowRemove(function (): void {
$live = array_column($this->lines, '_k');
$this->extras = array_intersect_key($this->extras, array_flip($live));
})
->stickyHeader()
->freezeColumns(2) // gutter + Resort stay put while you scroll right
->density(GridDensity::Normal)
->theme('emerald')
->statusBar()
->persistWidths()
->rowClass(fn (array $row): ?string => ($row['complimentary'] ?? false) ? 'row-comp' : null)
->cellClass(fn (mixed $value, array $row, string $column): ?string => $column === 'amount' && (float) $value > 100000
? 'cell-big'
: null)
->maxHeight('55vh')
->emptyState('No booking lines yet — start typing a resort name.'),
];
}
/**
* The ->opensPanel('line-notes') handler: the client dispatched lgrid:panel with the row
* key; open the modal over this component's own state.
*/
public function openPanel(string $rowKey): void
{
$this->panelRow = $rowKey;
$this->panelRequests = $this->extras[$rowKey]['requests'] ?? '';
$this->panelArrival = $this->extras[$rowKey]['arrival'] ?? '';
}
/**
* Every panel exit path — OK, Cancel, Esc — must resume the grid, or the operator is left
* with a cursor that never advanced.
*/
public function closePanel(bool $keep = true): void
{
if ($keep && $this->panelRow !== null) {
$this->extras[$this->panelRow] = [
'requests' => $this->panelRequests,
'arrival' => $this->panelArrival,
];
}
$this->panelRow = null;
$this->panelRequests = '';
$this->panelArrival = '';
$this->gridPanelDone('lines');
}
/**
* "Save": capture the cleaned rows (blank trailing rows stripped, client bookkeeping
* removed), reset the grid to fresh seeded lines, and push the reset to the client
* (reseedGrid — the mandatory step after any out-of-band rows mutation).
*
* Invoked by grid.commitAndSave('save') from the page — i.e. only after the open editor
* has committed and every queued op is acknowledged — never by a bare wire:click that
* could race the grid. The reseed is also what retires the persisted draft.
*/
public function save(): void
{
$rows = $this->gridRows('lines');
if ($rows === []) {
return;
}
// Fold the panel-captured extras into the payload the host would persist.
$this->saved = array_map(fn (array $row): array => $row + [
'_extras' => $this->extras[$row['_k'] ?? ''] ?? null,
], $rows);
$this->lines = $this->gridMountRows('lines');
$this->extras = [];
$this->reseedGrid('lines');
}
public function render(): View
{
// Server-side totals over the bound rows — kept live by ->refreshesHost().
$rows = array_filter($this->lines, fn (array $row): bool => ! empty($row['resort_id']));
return view('livewire.booking-entry', [
'lineCount' => count($rows),
'runningTotal' => array_sum(array_map(fn (array $row): float => (float) ($row['amount'] ?? 0), $rows)),
]);
}
}
<div>
<h1>Editable Livewire Datagrid — Inline Editing, Formula Columns, Focus Modes & Async Pickers</h1>
<p class="lede">
A spreadsheet-grade entry screen driven entirely from
<a href="#source">one Livewire component class</a>: the client applies every keystroke
optimistically and streams typed ops to the server, where each write is authorized,
cast, validated, run through your hooks and recomputed for formula columns — then the
response reconciles the authoritative values back into the grid. Rows are addressed by
stable keys, never positions. <em>Nothing here is written to the database.</em>
</p>
<p class="keys">
<strong>Try it:</strong>
type a resort name — the rate and city auto-fill from the pick, and the option list
shows the tariff as muted meta ·
pick two dates and <strong>Nights</strong> derives itself, then Base, GST and Amount
recompute in the same round trip ·
notice <kbd>Enter</kbd> never stops on <strong>Guests</strong> — a
<code>FocusMode::Manual</code> column that defaults to 2; click it when a party is
bigger — nor on <strong>Booked</strong>, a <code>FocusMode::Never</code> column stamped
from a closure default ·
tick <strong>Comp?</strong> and watch the rate zero itself and GST% grey out
(<code>whenFilled</code> + <code>lockedWhen</code>, mirrored client-side, authoritative
server-side) ·
switch <strong>Tax</strong> to Exempt and the required cell moves from GST% to the
exemption reason ·
<kbd>Enter</kbd> on <strong>Note</strong> opens a host panel and resumes exactly where
it left off ·
<kbd>Y</kbd>/<kbd>N</kbd> answers Confirmed and advances in one keystroke ·
<kbd>Delete</kbd> clears, <kbd>F9</kbd> or <kbd>Shift</kbd>+<kbd>Delete</kbd>
deletes the row, <kbd>Insert</kbd> adds one, <kbd>F7</kbd> repeats the row above,
<kbd>Ctrl</kbd>+<kbd>D</kbd> fills down,
<kbd>Ctrl</kbd>+<kbd>Z</kbd>/<kbd>Ctrl</kbd>+<kbd>Y</kbd> undo and redo,
<kbd>Ctrl</kbd>+<kbd>C</kbd> copies TSV and a multi-row paste from Excel maps straight
onto the cells ·
leave a required cell blank on a filled row and the footer's <strong>Review errors</strong>
control lists it (<kbd>Ctrl</kbd>+<kbd>E</kbd> jumps there) ·
reload the page mid-entry and a <strong>Restore / Discard</strong> bar brings the draft
back from IndexedDB (<code>persistDraft()</code>) ·
pick <em><-- End of List --></em> on a blank row to finish — focus lands on Save.
</p>
<div class="running">
<span><strong>{{ $lineCount }}</strong> line{{ $lineCount === 1 ? '' : 's' }} entered</span>
<span>Running total <strong>{{ number_format($runningTotal, 2) }}</strong></span>
<span class="muted">— this badge is host chrome, kept live by <code>refreshesHost()</code>.</span>
</div>
<x-laragrid :grid="$this->gridDefinition('lines')" :rows="$lines" />
<div class="entry-foot">
<label for="remarks" class="muted">Remarks (Tab off the last cell lands here)</label>
<input id="remarks" type="text" wire:model.blur="remarks" placeholder="Voucher remarks…">
{{-- No wire:click: the script below runs grid.commitAndSave('save') instead, so Save can
never race an open editor or a queued op, and mirrors the grid's canSave flag. --}}
<button type="button" data-save class="btn-save">Save</button>
<span class="muted">
Save runs <code>grid.commitAndSave('save')</code> — commit the editor, flush, await a
settled queue, then call the host — and is disabled while the grid reports
<code>canSave: false</code>. The cleaned <code>gridRows()</code> land in the tab below.
</span>
</div>
{{-- The ->opensPanel('line-notes') host modal. The grid keeps its active cell while this is
open and resumes the stashed advance the moment closePanel() fires gridPanelDone(). --}}
@if ($panelRow !== null)
<div class="panel-backdrop" wire:click.self="closePanel(false)">
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="panel-title"
wire:keydown.escape="closePanel(false)">
<h2 id="panel-title">Line notes</h2>
<p class="muted">
Data that belongs to the line but has no column of its own — the classic
reason a cell hands off to a host panel.
</p>
<label for="panel-requests">Special requests</label>
<textarea id="panel-requests" rows="3" wire:model="panelRequests" autofocus></textarea>
<label for="panel-arrival">Expected arrival time</label>
<input id="panel-arrival" type="time" wire:model="panelArrival">
<div class="panel-actions">
<button type="button" wire:click="closePanel(false)">Cancel</button>
<button type="button" class="btn-save" wire:click="closePanel(true)">Keep</button>
</div>
</div>
</div>
@endif
<h2>What this page demonstrates</h2>
<ul class="lede">
<li><strong>Optimistic client, authoritative server</strong> — a typed op protocol,
validation on both sides, and formula recomputation server-side after your hooks.</li>
<li><strong>Column focus modes</strong> — the ERP field behaviours as one chain:
<code>FocusMode::Always</code> (the default stop-here field),
<code>FocusMode::Manual</code> (skipped by <kbd>Enter</kbd>/<kbd>Tab</kbd>, editable on
click — Guests) and <code>FocusMode::Never</code> (skipped and locked — Booked), each
with a static or closure <code>default()</code> that seeds every fresh row.</li>
<li><strong>Async picker with row enrichment</strong> —
<code>SearchSelectColumn::optionsUsing()</code> streams options over an RPC and
<code>onSelect()</code> pre-fills dependent cells in the same round trip.</li>
<li><strong>Chained formula columns</strong> — <code>base → tax → amount</code>,
evaluated live in the browser and authoritatively in PHP by twin evaluators.</li>
<li><strong>Declarative cell rules</strong> — <code>rules()</code>,
<code>required()</code> and <code>readonly()</code> (static or per-row closures),
<code>requiredWhen()</code>, <code>lockedWhen()</code> and
<code>whenFilled()</code> sibling mirrors.</li>
<li><strong>Row lifecycle</strong> — per-column <code>default()</code>s as the new-row
template, <code>autoAppend()</code>, <code>minRows()</code>, <code>padRows()</code>, and
blank trailing rows that are invisible to validation, totals and <code>gridRows()</code>.</li>
<li><strong>The completion circuit</strong> — an end-of-list picker exit fires
<code>lgrid:complete</code>, <code>onCompleteFocus()</code> carries focus to Save,
and <code>focusOutTo()</code> separately repairs the plain Tab exit.</li>
<li><strong>The Save contract</strong> — <code>grid.commitAndSave('save')</code> instead
of a racing <code>wire:click</code>, a Save button that mirrors the bubbling
<code>lgrid:statechange</code> event's <code>canSave</code>, and the grid's own sync
bar (Saved · Syncing · Offline · Retry) and <strong>Review errors</strong> list.</li>
<li><strong>Draft recovery</strong> — <code>persistDraft()</code> keeps rows, queued ops,
errors and focus in IndexedDB until the save is confirmed; a reload offers
Restore / Discard, and every editable grid warns before a dirty navigation.</li>
<li><strong>Host hand-offs</strong> — <code>opensPanel()</code> with
<code>lgrid:panel</code> / <code>gridPanelDone()</code>, and
<code>refreshesHost()</code> for live chrome outside the grid.</li>
<li><strong>Undo that never bypasses the server</strong> — 100 steps, one gesture per
step; each undone step replays through the same op protocol as typing.</li>
</ul>
<h2 id="source">The whole screen, in one class</h2>
<x-source-code title="Booking Entry source" panel="Saved payload" :files="[
'app/Livewire/BookingEntry.php',
'resources/views/livewire/booking-entry.blade.php',
]">
@if ($saved !== [])
<pre>{{ json_encode($saved, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
@else
<p>Fill a line and hit Save — the cleaned gridRows() output lands here.</p>
@endif
</x-source-code>
@push('styles')
<style>
.running { display: flex; flex-wrap: wrap; gap: .25rem 1.25rem; align-items: baseline; margin: 0 0 .6rem; padding: .45rem .75rem; border: 1px solid #e4e4e7; border-radius: .375rem; background: #fff; font-size: .85rem; }
html.dark .running { background: #18181b; border-color: #27272a; }
.entry-foot { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; margin-top: 1rem; }
.entry-foot input#remarks { font: inherit; font-size: .85rem; padding: .4rem .6rem; border: 1px solid #d4d4d8; border-radius: .375rem; background: #fff; color: inherit; min-width: 16rem; }
html.dark .entry-foot input#remarks { background: #18181b; border-color: #3f3f46; }
.btn-save { font: inherit; padding: .45rem 1.4rem; border: 1px solid #18181b; border-radius: .375rem; background: #18181b; color: #fff; cursor: pointer; }
.btn-save:disabled { opacity: .45; cursor: default; }
html.dark .btn-save { background: #e4e4e7; border-color: #e4e4e7; color: #18181b; }
/* ->rowClass() / ->cellClass() targets. */
.row-comp .lgrid-cell { font-style: italic; }
.cell-big { color: #b45309; font-weight: 700; }
html.dark .cell-big { color: #fbbf24; }
.panel-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; background: rgba(9, 9, 11, .55); padding: 1rem; }
.panel { width: min(28rem, 100%); background: #fff; color: #18181b; border-radius: .5rem; padding: 1rem 1.25rem 1.25rem; box-shadow: 0 20px 50px -20px rgba(0,0,0,.6); }
html.dark .panel { background: #18181b; color: #e4e4e7; }
.panel h2 { margin: 0 0 .25rem; font-size: 1rem; }
.panel label { display: block; margin: .75rem 0 .25rem; font-size: .8rem; font-weight: 600; }
.panel textarea, .panel input { width: 100%; font: inherit; font-size: .85rem; padding: .4rem .55rem; border: 1px solid #d4d4d8; border-radius: .375rem; background: transparent; color: inherit; }
html.dark .panel textarea, html.dark .panel input { border-color: #3f3f46; }
.panel-actions { display: flex; justify-content: flex-end; gap: .5rem; margin-top: 1rem; }
.panel-actions button { font: inherit; font-size: .85rem; padding: .35rem .9rem; border: 1px solid #d4d4d8; border-radius: .375rem; background: transparent; color: inherit; cursor: pointer; }
html.dark .panel-actions button { border-color: #3f3f46; }
</style>
@endpush
@script
<script>
const root = $wire.$el.querySelector('[data-lgrid]');
const save = $wire.$el.querySelector('[data-save]');
// The grid hands its Enter off to us: open the host panel for the row it names.
// Every exit path calls closePanel(), which fires gridPanelDone() and lets the
// stashed advance run — so the cursor never gets stranded.
document.addEventListener('lgrid:panel', (event) => {
if (event.detail.grid !== 'lines' || event.detail.panel !== 'line-notes') return;
$wire.openPanel(event.detail.rowKey);
});
// The completion signal, for anything beyond the packaged focus move.
document.addEventListener('lgrid:complete', (event) => {
if (event.detail.grid === 'lines') console.debug('[demo] entry complete');
});
// The Save contract, half one: lgrid:statechange bubbles from the grid root once
// per settled state with {status, canSave, pending, modified, errors, …}. A
// declarative host button simply mirrors canSave — never clickable while an op is
// in flight, the grid is offline, or a validation error remains.
root.addEventListener('lgrid:statechange', (event) => {
if (event.detail.grid !== 'lines') return;
save.disabled = !event.detail.canSave;
});
// Half two: commitAndSave() commits the open editor, flushes, awaits a stable
// queue, then invokes $wire.save() and marks the draft saved. It refuses (throws)
// while errors, offline state or a terminal sync failure remain — the grid's own
// sync bar already says why, so the demo only logs the refusal.
save.addEventListener('click', async () => {
const grid = window.LaraGrid && window.LaraGrid.find(root);
if (!grid) return;
try {
await grid.commitAndSave('save');
} catch (error) {
console.warn('[demo] save refused:', error.message);
}
});
</script>
@endscript
</div>
Fill a line and hit Save — the cleaned gridRows() output lands here.