unnathianalytics/laragrid
Composer 安装命令:
composer require unnathianalytics/laragrid
包简介
Excel-style, keyboard-first datagrid for Laravel + Livewire — display, server-side readonly, and editable grids, fully configured from the component class with zero blade wiring and zero consumer build tooling.
README 文档
README
Excel-style, keyboard-first datagrid for Laravel + Livewire. Extracted from a production accounting system built for spreadsheet-trained operators, then made app-neutral.
The engine is framework-free vanilla JavaScript that owns every cell it paints: the grid body
lives inside a wire:ignore region, Livewire never morphs a row, and all server traffic runs
over renderless RPCs. The result is spreadsheet-grade speed with Laravel-grade authority —
every edit is validated, authorized, and recomputed server-side.
Everything is configured in your component class with chained methods. No blade wiring,
no JavaScript to write, no npm step — composer require is the entire install.
The three modes
| Mode | Declare with | What you get |
|---|---|---|
| Display | rows passed to the tag | Paints in-memory rows. Works on plain Blade pages without any Livewire component. |
| Readonly server-side | ->query(fn () => Model::query()) |
Sort, global search, filters, pagination through a whitelisted fail-closed pipeline. Page 1 ships in the initial payload (zero-round-trip first paint); later pages stream over an RPC with an LRU cache and idle prefetch of the next page. |
| Editable | ->editable()->rowsFrom('lines') |
The full spreadsheet: optimistic client, authoritative server, typed op protocol, validation on both sides, formula columns, async pickers with row enrichment, auto-append, live footer totals. |
Both interactive modes share one keyboard model, one selection engine, one theming system.
Requirements
- PHP ^8.1
- Laravel 10 / 11 / 12 / 13
- Livewire ^4.1 — installed automatically as a dependency
Installation
composer require unnathianalytics/laragrid
That's all. The service provider auto-discovers, and the prebuilt script + stylesheet auto-inject into any page that renders a grid. No layout directives, no build step.
Optional publishes:
php artisan vendor:publish --tag=laragrid-config # config/laragrid.php (global defaults) php artisan vendor:publish --tag=laragrid-views # blade views (mount + badge/edit-link cells) php artisan vendor:publish --tag=laragrid-assets # copy dist/ to public/vendor/laragrid
Asset delivery is configurable: set laragrid.inject_assets => false to place
@laragridStyles / @laragridScripts yourself, or point laragrid.asset_url at a CDN or the
published copy. Asset URLs carry a content hash, so upgrades bust browser caches automatically.
Quick start — a readonly list
use App\Models\Resort; use LaraGrid\Actions\Action; use LaraGrid\Aggregate; use LaraGrid\Columns\{SerialColumn, TextColumn, IntegerColumn, DateColumn, ComputedColumn}; use LaraGrid\Filters\SelectFilter; use LaraGrid\Grid; use LaraGrid\Livewire\WithLaraGrid; use LaraGrid\Support\CellHtml; use Livewire\Component; class ResortsIndex extends Component { use WithLaraGrid; // ← required: provides gridDefinition() and the grid RPCs protected function grids(): array { return ['resorts' => Grid::make('resorts') ->query(fn () => Resort::query()) ->authorize('resort.viewAny') // mandatory — grids are fail-closed ->paginate(25, [10, 25, 50, 100]) ->defaultSort('name') ->searchable(['name', 'shortcode', 'resorts.slug']) // see note below ->filters([ SelectFilter::make('type')->label('Type') ->options(fn () => Resort::distinct()->orderBy('type')->pluck('type', 'type')), SelectFilter::make('visibility')->label('Visibility') ->options(['show' => 'Show', 'hide' => 'Hide']), ]) ->columns([ SerialColumn::make(), TextColumn::make('name')->label('Resort')->sortable()->searchable()->grow(), TextColumn::make('type')->sortable()->width(120), IntegerColumn::make('hits')->sortable()->width(90), ComputedColumn::make('status')->html()->width(90) ->state(fn (array $row) => $row['visibility'] === 'show' ? CellHtml::badge('green', 'Show') : CellHtml::badge('zinc', 'Hide')), DateColumn::make('created_at')->label('Added')->sortable()->width(110), ]) ->footer([Aggregate::sum('hits')->format('number')]) ->actions([ Action::make('edit')->icon('✎')->url(fn ($row) => route('resorts.edit', $row['id'])), Action::make('delete')->icon('✕')->confirm('Delete this resort?') ->call(fn (array $row) => Resort::whereKey($row['id'])->delete()), ]) ->stickyHeader()->striped()->maxHeight('70vh')]; } public function render() { return view('livewire.resorts-index'); } }
<x-laragrid :grid="$this->gridDefinition('resorts')" />
Search targets: bare names in ->searchable() must be declared columns — a typo fails
loudly at build time instead of silently searching nothing. To search a database column you
don't display, table-qualify it ('resorts.slug'); the dot marks it as an explicit DB column.
Quick start — an editable entry grid
use LaraGrid\Columns\{SerialColumn, SearchSelectColumn, IntegerColumn, DecimalColumn, FormulaColumn, TextColumn}; use LaraGrid\Editing\RowContext; class BookingEntry extends Component { use WithLaraGrid; /** @var list<array<string, mixed>> */ public array $lines = []; public function mount(): void { $this->lines = $this->gridMountRows('lines'); // seeds defaultRows via the factory } protected function grids(): array { return ['lines' => Grid::make('lines') ->editable() ->rowsFrom('lines') // binds public array $lines ->authorize(fn () => $this->authorize('booking.create')) ->defaultRows(3) ->newRowUsing(fn () => ['nights' => 1]) // template for seeded AND inserted rows ->minRows(1) ->autoAppend() // Enter past the last cell grows the grid ->focusOnMount() ->focusOutTo('[data-save]') // Tab past the last cell lands on Save ->columns([ SerialColumn::make(), SearchSelectColumn::make('resort_id')->label('Resort') ->optionsUsing(fn (string $term) => Resort::query() ->when($term !== '', fn ($q) => $q->where('name', 'like', "%{$term}%")) ->limit(50)->get(['id', 'name']) ->map(fn ($r) => ['value' => (string) $r->id, 'label' => $r->name]) ->all()) ->onSelect(function (RowContext $row, mixed $value): void { // Enrichment: the pick pre-fills the rate; write-backs reconcile // into the client row automatically. $row->set('rate', Resort::whereKey($value)->value('comparison_tariff')); }) ->required()->minChars(0)->debounce(250)->grow(), IntegerColumn::make('nights')->rules(['integer', 'min:1'])->required()->width(90), DecimalColumn::make('rate')->scale(2)->rules(['numeric', 'min:0'])->width(120), FormulaColumn::make('amount')->formula('round(nights * rate, 2)')->width(130), TextColumn::make('note')->maxLength(100)->grow(), ]) ->footer([Aggregate::sum('amount')->format('number', ['scale' => 2])])]; } public function save(): void { $rows = $this->gridRows('lines'); // cleaned: blank trailing rows + bookkeeping stripped Booking::createFromLines($rows); // your persistence — the grid never owns it $this->lines = $this->gridMountRows('lines'); $this->reseedGrid('lines'); // required after any out-of-band rows change } }
The editable contract in one paragraph: 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 formula columns are recomputed — the response reconciles authoritative values
back into the grid. Rows are addressed by stable keys, never positions. Blank trailing rows
(the auto-append artifact) are invisible to validation, totals, and gridRows().
Display-only mode
<x-laragrid :grid="$grid" :rows="[['name' => 'Alpha'], ['name' => 'Beta']]" />
No ->query(), no ->editable() — the rows are painted as-is with full keyboard navigation,
selection, copy, column chooser, and footer totals. No Livewire component is required.
To refresh a display grid's data later, call $this->reseedGrid('name', $freshRows).
Column types
| Column | Editor | Value | Notes |
|---|---|---|---|
SerialColumn |
— | row number | the # gutter |
TextColumn |
text | string | ->maxLength(), ->upper()/->lower() |
IntegerColumn |
number | int | grouping-tolerant input |
DecimalColumn |
number | fixed-scale string | ->scale(n); precision never rides a float |
DateColumn |
date | ISO Y-m-d |
fuzzy typed input (31/12, 311226); display pattern configurable; financial-year inference opt-in |
SelectColumn |
dropdown | option id | ->options([...]) embedded whitelist |
SearchSelectColumn |
async picker | option id | ->optionsUsing(fn ($term, $row)), ->onSelect() enrichment, ->minChars(), ->debounce(), ->limit() |
CheckboxColumn |
instant toggle | bool | |
FormulaColumn |
— | computed | ->formula('qty * rate') — evaluated live client-side, authoritatively server-side |
ComputedColumn |
— | server-baked | ->state(fn ($row)), pair with ->html() for badges/links via CellHtml |
ReadonlyColumn / HiddenColumn |
— | display / carried | writes rejected server-side |
Shared column chains: label, width / minWidth / maxWidth / grow, align, visible,
frozen, sortable(bool|'db.column'), searchable, filterable(Filter), required /
required(fn), readonly / readonly(fn), rules([...]), lockedWhen('col', value),
requiredWhen('col', value), whenFilled(sets: [...], clears: [...]), endOfListOption(),
opensPanel('name'), html().
Grid definition reference
Data — query(fn) · authorize(ability|fn) · paginate(per, [options]) ·
defaultSort(col, dir) · searchable([...]) · filters([...]) · rowKey('id') ·
rowActivate(fn ($row) => ?url) — Enter/double-click opens a row, permission-gated per row.
Editable — editable() · rowsFrom('prop') · defaultRows(n) · newRowUsing(fn) ·
minRows(n) · autoAppend() · padRows(n) · sync(SyncPolicy::PerCell|PerRow|Deferred) ·
refreshesHost([...]) (re-render host chrome when listed columns change) ·
completeWhenBalanced('dr', 'cr') · afterCellChange(fn) · afterRowRemove(fn).
Behavior — keymap('entry'|'excel') · toolbar(...) / toolbar(false) ·
focusOnMount() · focusOutTo(selector) · onCompleteFocus(selector) ·
emptyState(text) · statusBar(bool) · persistWidths() (column layout survives reloads).
Layout — stickyHeader() · freezeColumns(n) · striped() ·
density(GridDensity::Compact|Normal|Comfortable) · height('420px') · maxHeight('60vh') ·
fillParent() · themeClass('my-theme') · rowClass(fn) · cellClass(fn).
Actions
->actions([ // per-row buttons in a trailing column Action::make('edit')->icon('✎')->url(fn ($row) => route('items.edit', $row['id'])), Action::make('archive')->confirm('Archive?')->visible(fn ($row) => ! $row['archived']) ->call(fn (array $row) => Item::whereKey($row['id'])->archive()), ]) ->bulkActions([ // run over checked rows; a selector gutter + toolbar bulk bar appear Action::make('delete')->confirm('Delete selected?') ->call(fn (array $keys) => Item::whereKey($keys)->delete()), ]) ->toolbarActions([ // grid-scoped buttons in the toolbar Action::make('new')->label('New Item')->url(fn () => route('items.create')), ])
Actions are fail-closed end to end: the client only echoes an action name (plus row keys) —
the server re-authorizes the grid gate, the per-action ->authorize(), re-resolves the row
from its authoritative source, and re-checks ->visible() before the closure runs. A hidden
button is an unusable button. url() actions bake their per-row URL server-side; rows where
the resolver returns null get no button. After a call() action, readonly grids refetch
their current page and editable grids receive a reseed payload automatically. Throw a
ValidationException inside a callback to show the operator a refusal message.
Toolbar, search & filters
The toolbar renders itself from the definition: a debounced search box (when the grid declares
->searchable()), one control per grid-level filter, the bulk bar, toolbar action buttons, and
the column chooser. Suppress or tune it: ->toolbar(false), ->toolbar(search: false). Filter
options accept a {value => label} map (the pluck() idiom), a list of ['value' => , 'label' => ]
rows, or plain scalars. SelectFilter and TernaryFilter (All / Yes / No) ship in core; column
header funnels via ->filterable() run through the same whitelisted pipeline.
Keyboard
| Keys | Action |
|---|---|
| Arrows / Tab / Home / End / PageUp / PageDown / Ctrl+edges | navigate |
| Shift + movement, Ctrl+A | extend / select all |
| Ctrl+C | copy selection as TSV (pastes into Excel; paste back round-trips) |
| Type / F2 / double-click | overwrite / edit a cell |
| Enter | commit + advance — serpentine in entry, down in excel |
| Delete | clear selected cells |
| Shift+Delete or F7 | delete the row (minRows-guarded) |
| Insert / Ctrl+D | insert row / fill down |
| Ctrl+E | jump to the first error |
| ContextMenu / Shift+F10 | open the row's actions menu |
| Escape | clear selection / cancel edit |
Paste is bulk-aware: a multi-row TSV paste maps onto editable cells, auto-appends rows as needed, and flushes as one batch (with a confirm above 500 cells).
Theming
Every visual is a --lgrid-* CSS token (row height, paddings, all colors) with self-contained
defaults — the grid looks right on a page with no CSS framework at all. In a Tailwind v4 app it
adopts your --color-* @theme palette automatically. Override tokens globally, under your own
->themeClass(), or under .dark (dark mode is token-flipping, nothing more). All elements
carry stable lgrid-* semantic classes, so any part is restylable and nothing is ever purged
by a build tool. Print collapses to a clean black-on-white table.
Extending
// A custom column type is just a class: class RatingColumn extends \LaraGrid\Columns\Column { public function painterId(): string { return 'rating'; } public function editorId(): ?string { return 'rating'; } public function parseSpec(): array { return ['kind' => 'int']; } } // App-specific formatters and parse kinds register in a service provider: app(\LaraGrid\Formatting\FormatRegistry::class)->register('inr', new InrFormatter); app(\LaraGrid\Casting\CastRegistry::class)->register('paise', new PaiseCast);
// The window.LaraGrid seams (load your script after the grid bundle): LaraGrid.registerPainter('rating', (cellEl, ctx) => { /* draw the cell */ }); LaraGrid.registerEditor('rating', RatingEditor); // { mount, value, focus, destroy } LaraGrid.registerFormatter('inr', (value, args) => …); // twin of the PHP formatter LaraGrid.registerCast('paise', { parse, editText }); // twin of the PHP cast
Formatting and casting run in both runtimes — the client for instant paint, the server for
authority. Every PHP formatter/cast you register must have a behaviourally identical JS twin
under the same name, pinned by a shared vector in tests/fixtures/grid-vectors/ (the package's
own suite runs 122 vectors through both runtimes on every build). Complete worked example —
integer-paise money, lakh/crore grouping, financial-year dates:
docs/recipes/inr-paise.md.
Host events
Grid → host (bubbling DOM events): lgrid:complete (entry finished), lgrid:activate
(row opened), lgrid:panel (cell hands off to a host modal), lgrid:column-resized,
lgrid:column-visibility. Host → grid: lgrid:reseed (use $this->reseedGrid(...)),
lgrid:panel-done (use $this->gridPanelDone(...)), lgrid:toolbar (drive the grid from
fully custom host chrome).
Configuration
config/laragrid.php holds app-wide defaults only — density, keymap preset, date display
pattern, financial-year start month (off by default), toolbar defaults, asset injection and
asset URL. Any grid overrides any of them through its chained definition.
Troubleshooting
- "Method …::gridMountRows does not exist" — add
use WithLaraGrid;inside the component class (importing the trait at the top of the file is not enough). - "No hint path defined for [layouts]" on a bare Livewire 4 app — pin the layout on your
full-page component:
#[Layout('components.layouts.app')]. - "searchable target [x] is not a declared column" — declare the column, or table-qualify
the target (
'items.slug') to search an undisplayed DB column. - Stale assets after upgrading — none: tags carry a content hash. If a proxy interferes, hard-refresh once.
Testing
Package suite: composer test (Pest via Testbench) and npm test (Node vector runners) —
PHP and JS are asserted against the same committed vectors. Your app tests drive grids through
the public RPCs, exactly as the browser does:
Livewire::test(BookingEntry::class) ->call('gridOps', 'lines', ['ops' => [ ['t' => 'set', 'seq' => 1, 'row' => $key, 'col' => 'nights', 'v' => '3'], ]]) ->call('gridAction', 'resorts', 'delete', [$id]);
License
MIT © Unnathi Analytics
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 2
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-07-14