Line items
Document lines with money totals: an editable or read-only table of lines with quantity, unit price and VAT, and a subtotal, VAT per option and total.
Line Item Editor — An editable table of document lines for quotes, invoices, credit notes and purchase orders: description, locale-aware quantity, unit, unit price in integer cents and a VAT option per line, with live line totals, subtotal, VAT per option and total. Add, remove and reorder by drag or keyboard; readonly mode prints. Binds the lines with wire:model, posts hidden inputs and emits line-items-change.
Preview
| Order | Description | Quantity | Unit | Unit price | VAT | Total | Actions |
|---|---|---|---|---|---|---|---|
| Discovery workshop | 1 | day | €950.00 | 21% | €950.00 | ||
| Interface design | 12.5 | hour | €85.00 | 21% | €1,062.50 | ||
| Printed style guide | 3 | piece | €24.95 | 9% | €74.85 | ||
| Hosting setup for a business customer abroad | 1 | piece | €450.00 | Reverse charge | €450.00 | ||
|
|
|
|
|
- Subtotal
- €2,537.35
- VAT 21% on €2,012.50
- €422.63
- VAT 9% on €74.85
- €6.74
- VAT reverse charged on €450.00 VAT is accounted for by the customer.
- €0.00
- Total
- €2,966.72
Press the up or down arrow key to move the line, or drag it.
{{-- An editable document: type a quantity (1,5 or 1.5) or a price and the line
total, subtotal, VAT per option and total follow. Money is integer cents;
the last line is reverse charged, so its VAT row shows 0. --}}
<x-ui.line-item-editor
name="lines"
currency="EUR"
:units="[
['key' => 'hour', 'label' => __('hour')],
['key' => 'day', 'label' => __('day')],
['key' => 'piece', 'label' => __('piece')],
]"
:vat-rates="[
['key' => 'standard', 'label' => '21%', 'rate' => 21],
['key' => 'reduced', 'label' => '9%', 'rate' => 9],
['key' => 'zero', 'label' => '0%', 'rate' => 0],
['key' => 'reverse-charge', 'label' => __('Reverse charge'), 'rate' => 0, 'summary' => __('VAT reverse charged'), 'note' => __('VAT is accounted for by the customer.')],
['key' => 'export', 'label' => __('Outside the EU'), 'rate' => 0, 'summary' => __('VAT outside the EU'), 'note' => __('Export of services, no VAT charged.')],
]"
:lines="[
['id' => 1, 'description' => __('Discovery workshop'), 'quantity' => 1, 'unit' => 'day', 'unitPrice' => 95000, 'vatRate' => 'standard'],
['id' => 2, 'description' => __('Interface design'), 'quantity' => 12.5, 'unit' => 'hour', 'unitPrice' => 8500, 'vatRate' => 'standard'],
['id' => 3, 'description' => __('Printed style guide'), 'quantity' => 3, 'unit' => 'piece', 'unitPrice' => 2495, 'vatRate' => 'reduced'],
['id' => 4, 'description' => __('Hosting setup for a business customer abroad'), 'quantity' => 1, 'unit' => 'piece', 'unitPrice' => 45000, 'vatRate' => 'reverse-charge'],
]"
/>
Installation
php artisan ui:add line-item-editor
Note
This component ships an Alpine behavior module at
resources/js/ui/line-item-editor.js. Import it once from your bundle so it registers on alpine:init:
import './line-item-editor.js';
Registry contract
php artisan ui:add line-item-editor
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/line-item-editor.blade.php -
resources/js/ui/line-item-editor.js
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Line Item Editor (`line-item-editor`)
An editable table of document lines for quotes, invoices, credit notes and purchase orders: description, locale-aware quantity, unit, unit price in integer cents and a VAT option per line, with live line totals, subtotal, VAT per option and total. Add, remove and reorder by drag or keyboard; readonly mode prints. Binds the lines with wire:model, posts hidden inputs and emits line-items-change.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add line-item-editor
```
## Usage
```blade
{{-- An editable document: type a quantity (1,5 or 1.5) or a price and the line
total, subtotal, VAT per option and total follow. Money is integer cents;
the last line is reverse charged, so its VAT row shows 0. --}}
<x-ui.line-item-editor
name="lines"
currency="EUR"
:units="[
['key' => 'hour', 'label' => __('hour')],
['key' => 'day', 'label' => __('day')],
['key' => 'piece', 'label' => __('piece')],
]"
:vat-rates="[
['key' => 'standard', 'label' => '21%', 'rate' => 21],
['key' => 'reduced', 'label' => '9%', 'rate' => 9],
['key' => 'zero', 'label' => '0%', 'rate' => 0],
['key' => 'reverse-charge', 'label' => __('Reverse charge'), 'rate' => 0, 'summary' => __('VAT reverse charged'), 'note' => __('VAT is accounted for by the customer.')],
['key' => 'export', 'label' => __('Outside the EU'), 'rate' => 0, 'summary' => __('VAT outside the EU'), 'note' => __('Export of services, no VAT charged.')],
]"
:lines="[
['id' => 1, 'description' => __('Discovery workshop'), 'quantity' => 1, 'unit' => 'day', 'unitPrice' => 95000, 'vatRate' => 'standard'],
['id' => 2, 'description' => __('Interface design'), 'quantity' => 12.5, 'unit' => 'hour', 'unitPrice' => 8500, 'vatRate' => 'standard'],
['id' => 3, 'description' => __('Printed style guide'), 'quantity' => 3, 'unit' => 'piece', 'unitPrice' => 2495, 'vatRate' => 'reduced'],
['id' => 4, 'description' => __('Hosting setup for a business customer abroad'), 'quantity' => 1, 'unit' => 'piece', 'unitPrice' => 45000, 'vatRate' => 'reverse-charge'],
]"
/>
```
## Props
- `lines` (array, default `[]`) — The lines: a list of { id: int|string, description: string, quantity: number (up to 3 decimals), unit: string, unitPrice: int (minor units, cents), vatRate: string (a vatRates key), locked?: bool, lockedFields?: list<string>, lockedReason?: string, movable?: bool, removable?: bool, total?: int }. locked makes every field read-only, lockedFields only those (description, quantity, unit, unitPrice, vatRate); either way the line shows a lock mark. movable and removable default to false for a locked line and to true for every other line, including one with only lockedFields. total is a server line total in minor units that shows instead of quantity × unitPrice (for example time rounded per entry). A missing or duplicate id becomes a generated one; a missing unit or vatRate takes the default. Printed with {{ }} only.
- `name` (string|null, default `null`) — Renders hidden inputs name[i][id|description|quantity|unit|unitPrice|vatRate] for a plain form post (quantity as a decimal string with a dot, unitPrice as integer cents). It is also the error-bag prefix: an error under name.i.field (for example lines.0.description) marks that cell. Without name, the wire:model target is the prefix.
- `currency` (string, default `EUR`) — ISO 4217 currency code. It sets the symbol, the minor-unit digits (2 for EUR, 0 for JPY) and the money format of every amount.
- `locale` (string|null, default `null`) — BCP 47 locale for Intl.NumberFormat and the server paint (for example nl-NL). Null uses the app locale. Typed numbers parse in this locale: 1,5 in nl is one and a half and 1.000 is a thousand.
- `units` (array, default `[]`) — Unit options as a list of { key, label } or a key => label map (for example hour, day, piece with translated labels). With options the unit cell is a select; empty keeps a free-text unit.
- `vatRates` (array, default `[]`) — VAT options: a list of { key, label, rate (percent, may be 0 or decimal), note?, summary? }. label shows in the select; summary names the totals row (default "VAT :label"); note prints under it (for a reverse-charge or export statement). Empty hides the VAT column and rows.
- `defaultVatRate` (string|null, default `null`) — The VAT option key of a new line. Null uses the first option.
- `defaultUnit` (string|null, default `null`) — The unit of a new line. Null uses the first unit option, or empty text.
- `readonly` (bool, default `false`) — Renders the same table as static text with no inputs, no drag handles and no add or remove controls. No JavaScript is needed; suitable for a sent document and print.
- `caption` (string|null, default `null`) — The visually hidden table caption. Null uses "Line items".
- `maxLines` (int, default `200`) — The largest supported line count. More lines from the server throw an InvalidArgumentException; Add line stops at the limit and announces it.
- `maxQuantity` (number, default `100000`) — The largest quantity a person can type, as an absolute value (up to 3 decimals). A larger quantity is flagged with aria-invalid and a message and is not committed.
- `maxUnitPrice` (int, default `100000000`) — The largest unit price a person can type, in minor units, as an absolute value (100000000 is 1,000,000.00 in a two-digit currency). A larger price is flagged with aria-invalid and a message and is not committed.
- `totals` (array|null, default `null`) — Server totals in minor units: { subtotal, vat: [{ key, base, amount, label?, summary?, note? }], total }. They replace the computed totals in the paint and in the script (a Livewire render updates them through data-totals), so the shown values are the authoritative ones; line-items-change still carries the computed totals.
- `vatRounding` (total|line, default `total`) — How VAT rounds: total rounds once per VAT option on the sum of its line totals; line rounds the VAT of every line, then sums per option. The Blade paint and the script use the same rule.
## Use when
- Use when users must enter freeform information that cannot be reliably selected from a list.
- Editing the lines of a quote, invoice, credit note or purchase order: description, quantity, unit, unit price and VAT option per line, with live line totals, subtotal, VAT per option and total.
- Showing the same lines of a sent document as static text for the detail view or print (readonly).
- Binding the lines to a Livewire array with wire:model or posting them as a plain form (name).
## Avoid when
- Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
- A consumer shopping cart with thumbnails and quantity steppers: use the shopping-cart blocks.
- An order confirmation or receipt summary: use the order-summary blocks.
- One money field: use <x-ui.currency-input>. One formatted amount: use <x-ui.money-value>.
- More than a few hundred lines or server-side paging: use <x-ui.data-table> with your own row editor.
## Anti-patterns
- Using freeform entry for a bounded answer set
## Rules
- Use the `<brok:line-item-editor>` tag (or `<x-ui.line-item-editor>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/line-item-editor
- Registry JSON (files, props, contract): https://brokui.dev/r/open/line-item-editor.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- x-model binds the list of lines; wire:model binds the same shape inside
Livewire. The Dutch locale reads 1,5 as one and a half. name adds hidden
inputs lines[i][field] for a plain form post. --}}
<div
x-data="{ lines: [
{ id: 'a', description: 'Consultancy', quantity: 2, unit: 'hour', unitPrice: 10000, vatRate: 'standard' },
{ id: 'b', description: 'Travel', quantity: 1, unit: 'piece', unitPrice: 4550, vatRate: 'reverse-charge' },
] }"
class="flex w-full flex-col gap-2"
>
<x-ui.line-item-editor
x-model="lines"
name="lines"
locale="nl-NL"
currency="EUR"
:units="[
['key' => 'hour', 'label' => __('hour')],
['key' => 'piece', 'label' => __('piece')],
]"
:vat-rates="[
['key' => 'standard', 'label' => '21%', 'rate' => 21],
['key' => 'reverse-charge', 'label' => __('Reverse charge'), 'rate' => 0, 'summary' => __('VAT reverse charged')],
]"
:lines="[
['id' => 'a', 'description' => 'Consultancy', 'quantity' => 2, 'unit' => 'hour', 'unitPrice' => 10000, 'vatRate' => 'standard'],
['id' => 'b', 'description' => 'Travel', 'quantity' => 1, 'unit' => 'piece', 'unitPrice' => 4550, 'vatRate' => 'reverse-charge'],
]"
/>
<p class="text-sm text-muted-foreground">{{ __('Bound quantities:') }} <span data-bound x-text="lines.map((line) => line.quantity).join(' / ')"></span></p>
<x-ui.button variant="outline" size="sm" class="self-start" x-on:click="lines = [...lines, { id: 'c', description: 'Support', quantity: 0.5, unit: 'hour', unitPrice: 9000, vatRate: 'standard' }]">{{ __('Add a support line from outside') }}</x-ui.button>
</div>
{{-- No lines yet: the table keeps its shape with an empty state, the totals
read zero and Add line starts the first line. --}}
<x-ui.line-item-editor
currency="EUR"
:units="[
['key' => 'hour', 'label' => __('hour')],
['key' => 'piece', 'label' => __('piece')],
]"
:vat-rates="[
['key' => 'standard', 'label' => '21%', 'rate' => 21],
['key' => 'reduced', 'label' => '9%', 'rate' => 9],
]"
:lines="[]"
/>
@php
// Simulate a failed $request->validate() round-trip: the redirect-back
// flashes lines.i.field errors into the shared $errors bag (the same bag
// Livewire fills). The editor marks those cells invalid and lists the
// messages below the table.
$previewErrors = new \Illuminate\Support\ViewErrorBag;
$previewErrors->put('default', new \Illuminate\Support\MessageBag([
'lines.0.description' => [__('Enter a description.')],
'lines.1.quantity' => [__('The quantity must be greater than 0.')],
]));
view()->share('errors', $previewErrors);
@endphp
<x-ui.line-item-editor
name="lines"
currency="EUR"
:units="[
['key' => 'hour', 'label' => __('hour')],
['key' => 'piece', 'label' => __('piece')],
]"
:vat-rates="[
['key' => 'standard', 'label' => '21%', 'rate' => 21],
['key' => 'reduced', 'label' => '9%', 'rate' => 9],
]"
:lines="[
['id' => 1, 'description' => '', 'quantity' => 2, 'unit' => 'hour', 'unitPrice' => 8500, 'vatRate' => 'standard'],
['id' => 2, 'description' => __('Printed style guide'), 'quantity' => 0, 'unit' => 'piece', 'unitPrice' => 2495, 'vatRate' => 'reduced'],
]"
/>
{{-- A line billed from time entries: its quantity, unit and price come from
the tracked hours, so only the description and VAT stay editable, and
its total is the server's (each entry rounded on its own). It shows a
lock mark but can still move, and removing it leaves the time to bill
later. The agreed fee is locked as a whole: no move, no remove. VAT
rounds per line (vat-rounding="line"), as some invoice rules require. --}}
<x-ui.line-item-editor
name="lines"
currency="EUR"
vat-rounding="line"
:units="[
['key' => 'hour', 'label' => __('hour')],
['key' => 'piece', 'label' => __('piece')],
]"
:vat-rates="[
['key' => 'standard', 'label' => '21%', 'rate' => 21],
['key' => 'reduced', 'label' => '9%', 'rate' => 9],
]"
:lines="[
['id' => 'time-9', 'description' => __('Design work, week 38'), 'quantity' => 12.25, 'unit' => 'hour', 'unitPrice' => 8500, 'vatRate' => 'standard', 'lockedFields' => ['quantity', 'unit', 'unitPrice'], 'lockedReason' => __('From time entries'), 'total' => 104130],
['id' => 'fee-1', 'description' => __('Agreed fee, website launch'), 'quantity' => 1, 'unit' => 'piece', 'unitPrice' => 50000, 'vatRate' => 'standard', 'locked' => true, 'lockedReason' => __('Agreed in the quote')],
['id' => 2, 'description' => __('Printed style guide'), 'quantity' => 3, 'unit' => 'piece', 'unitPrice' => 2495, 'vatRate' => 'reduced'],
]"
/>
Long Content
{{-- A narrow column with long descriptions and large amounts: the table scrolls
sideways inside its own frame, the descriptions wrap and the totals stay
readable below it. Free-text units, since no units list is given. --}}
<div class="w-full max-w-md">
<x-ui.line-item-editor
currency="EUR"
:vat-rates="[
['key' => 'standard', 'label' => '21%', 'rate' => 21],
['key' => 'export', 'label' => __('Outside the EU'), 'rate' => 0, 'summary' => __('VAT outside the EU'), 'note' => __('Export of goods to a destination outside the European Union, no VAT charged under the export exemption.')],
]"
:lines="[
['id' => 'a', 'description' => __('Quarterly maintenance of the customer portal, including security updates, dependency upgrades and a written report of every change'), 'quantity' => 1250.75, 'unit' => __('hours'), 'unitPrice' => 1234567, 'vatRate' => 'standard'],
['id' => 'b', 'description' => __('Supercalifragilisticexpialidociousproductnamewithoutspaces'), 'quantity' => 2, 'unit' => __('pallets'), 'unitPrice' => 99999999, 'vatRate' => 'export'],
]"
/>
</div>
{{-- A sent document: the same table as static text, with no inputs and no
add, remove or reorder controls. It needs no JavaScript and prints. --}}
<x-ui.line-item-editor
readonly
currency="EUR"
:units="[
['key' => 'hour', 'label' => __('hour')],
['key' => 'day', 'label' => __('day')],
['key' => 'piece', 'label' => __('piece')],
]"
:vat-rates="[
['key' => 'standard', 'label' => '21%', 'rate' => 21],
['key' => 'reduced', 'label' => '9%', 'rate' => 9],
['key' => 'reverse-charge', 'label' => __('Reverse charge'), 'rate' => 0, 'summary' => __('VAT reverse charged'), 'note' => __('VAT is accounted for by the customer.')],
]"
:lines="[
['id' => 1, 'description' => __('Discovery workshop'), 'quantity' => 1, 'unit' => 'day', 'unitPrice' => 95000, 'vatRate' => 'standard'],
['id' => 2, 'description' => __('Interface design'), 'quantity' => 12.5, 'unit' => 'hour', 'unitPrice' => 8500, 'vatRate' => 'standard'],
['id' => 3, 'description' => __('Printed style guide'), 'quantity' => 3, 'unit' => 'piece', 'unitPrice' => 2495, 'vatRate' => 'reduced'],
['id' => 4, 'description' => __('Hosting setup for a business customer abroad'), 'quantity' => 1, 'unit' => 'piece', 'unitPrice' => 45000, 'vatRate' => 'reverse-charge'],
]"
/>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| lines | array | [] | The lines: a list of { id: int|string, description: string, quantity: number (up to 3 decimals), unit: string, unitPrice: int (minor units, cents), vatRate: string (a vatRates key), locked?: bool, lockedFields?: list<string>, lockedReason?: string, movable?: bool, removable?: bool, total?: int }. locked makes every field read-only, lockedFields only those (description, quantity, unit, unitPrice, vatRate); either way the line shows a lock mark. movable and removable default to false for a locked line and to true for every other line, including one with only lockedFields. total is a server line total in minor units that shows instead of quantity × unitPrice (for example time rounded per entry). A missing or duplicate id becomes a generated one; a missing unit or vatRate takes the default. Printed with {{ }} only. |
| name | string | null | null | Renders hidden inputs name[i][id|description|quantity|unit|unitPrice|vatRate] for a plain form post (quantity as a decimal string with a dot, unitPrice as integer cents). It is also the error-bag prefix: an error under name.i.field (for example lines.0.description) marks that cell. Without name, the wire:model target is the prefix. |
| currency | string | EUR | ISO 4217 currency code. It sets the symbol, the minor-unit digits (2 for EUR, 0 for JPY) and the money format of every amount. |
| locale | string | null | null | BCP 47 locale for Intl.NumberFormat and the server paint (for example nl-NL). Null uses the app locale. Typed numbers parse in this locale: 1,5 in nl is one and a half and 1.000 is a thousand. |
| units | array | [] | Unit options as a list of { key, label } or a key => label map (for example hour, day, piece with translated labels). With options the unit cell is a select; empty keeps a free-text unit. |
| vatRates | array | [] | VAT options: a list of { key, label, rate (percent, may be 0 or decimal), note?, summary? }. label shows in the select; summary names the totals row (default "VAT :label"); note prints under it (for a reverse-charge or export statement). Empty hides the VAT column and rows. |
| defaultVatRate | string | null | null | The VAT option key of a new line. Null uses the first option. |
| defaultUnit | string | null | null | The unit of a new line. Null uses the first unit option, or empty text. |
| readonly | bool | false | Renders the same table as static text with no inputs, no drag handles and no add or remove controls. No JavaScript is needed; suitable for a sent document and print. |
| caption | string | null | null | The visually hidden table caption. Null uses "Line items". |
| maxLines | int | 200 | The largest supported line count. More lines from the server throw an InvalidArgumentException; Add line stops at the limit and announces it. |
| maxQuantity | number | 100000 | The largest quantity a person can type, as an absolute value (up to 3 decimals). A larger quantity is flagged with aria-invalid and a message and is not committed. |
| maxUnitPrice | int | 100000000 | The largest unit price a person can type, in minor units, as an absolute value (100000000 is 1,000,000.00 in a two-digit currency). A larger price is flagged with aria-invalid and a message and is not committed. |
| totals | array | null | null | Server totals in minor units: { subtotal, vat: [{ key, base, amount, label?, summary?, note? }], total }. They replace the computed totals in the paint and in the script (a Livewire render updates them through data-totals), so the shown values are the authoritative ones; line-items-change still carries the computed totals. |
| vatRounding | total | line | total | How VAT rounds: total rounds once per VAT option on the sum of its line totals; line rounds the VAT of every line, then sums per option. The Blade paint and the script use the same rule. |
Slots
footer— Beside the Add line button, for more actions (for example a product picker or an add-discount button).
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Money is integer minor units everywhere (value, hidden inputs, events). Line total = the line's server total when it has one, else round(quantity × unitPrice), half away from zero, per line. VAT = round(group base × rate / 100), half away from zero, once per VAT option on the sum of its line totals (vatRounding total), or per line and then summed (vatRounding line). Total = subtotal + every VAT amount. The math is integer only (quantity in thousandths, rate in thousandths of a percent), and the Blade first paint uses the same rule.
- Quantity and unit price parse in the locale on change (blur) or Enter: the locale decimal separator is the decimal point, groups of three after the other separator are thousands, currency symbols and spaces are ignored, empty text is 0. Text that is not a number, a quantity above maxQuantity or a unit price above maxUnitPrice stays with aria-invalid and a shared message and is not committed; Escape restores the committed value. While a line holds such a value, its total keeps the last valid amount, marked data-invalid and muted, and the totals keep using it. The description commits 400 ms after typing stops and on change.
- Add line appends a line (quantity 1, price 0, the default unit and VAT option) and focuses its description. Remove line focuses the next line's description, or Add line when none is left. The drag handle moves a line by pointer (live, rows glide; no glide under reduced motion) or by keyboard: ArrowUp/ArrowDown (with or without Alt) one place, Home/End to the ends; each move is announced as ":line moved to position :position of :count."
- Events: line-items-change (bubbling CustomEvent on the root) after every committed change, add, remove and reorder, with detail { lines: [{ id, description, quantity, unit, unitPrice, vatRate }], totals: { subtotal, vat: [{ key, label, rate, base, amount }], total } }.
- Binding: x-modelable exposes the lines array, so wire:model="lines" (a public array property) or x-model binds it; setting the outer value re-renders the rows and totals. New lines get a string id new-<random>; treat an id you do not know as a new line. Internal Alpine state is prefixed li* so an outer variable named lines is not shadowed.
- Server validation: errors under name.i.field (or the wire:model target) mark that cell with aria-invalid, the invalid border and aria-describedby to the message, listed under the table as "Line :number: message". The messages travel in the root's data-errors, which a Livewire render updates and the script re-reads, so errors from a later render reach their cells too. Editing the cell clears its mark. A plain error under the prefix itself prints in the same list.
- The server paints every row and the totals first, so the table reads without JavaScript and prints; editing needs JavaScript (a noscript line says so). Narrow screens scroll the table sideways inside its bordered frame, never the page; the totals list sits below the table and stays in view.
- Livewire: use it without an outer wire:ignore. The x-data expression never changes between renders (lines, messages and totals sit in data-lines, data-errors and data-totals, and the root id is stable), and every region the script draws carries its own wire:ignore, so a render keeps the component and its focus. Give two editors bound to the same property different ids.
- Locked lines: the locked fields show as text in the cell, and the row ends with a lock mark named "Line :number is locked" (with ": reason" from lockedReason). Moving and removing are separate per-line flags: movable and removable default to false for a locked line and to true otherwise, so a line with only lockedFields keeps its drag handle and remove button; set them to false to pin a line. Other lines still move around a pinned one. The flags go back in the model unchanged. Enforce them on the server as well: the client only stops editing.
- Server line totals: a line with total (integer minor units) shows that amount and counts it in the subtotal and VAT, with data-source="server" on its total cell. Editing that line's quantity or unit price drops total from the line, so the computed amount shows until the host sends a new one (with wire:model, after the next render). The other fields keep it. total is not posted in the hidden inputs.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, behaviorTest, authoredStateFixtures, responsive, rtl, darkMode, localized.
Guidance
Collect unpredictable freeform information.
Use when
- Use when users must enter freeform information that cannot be reliably selected from a list.
- Editing the lines of a quote, invoice, credit note or purchase order: description, quantity, unit, unit price and VAT option per line, with live line totals, subtotal, VAT per option and total.
- Showing the same lines of a sent document as static text for the detail view or print (readonly).
- Binding the lines to a Livewire array with wire:model or posting them as a plain form (name).
Avoid when
- Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
- A consumer shopping cart with thumbnails and quantity steppers: use the shopping-cart blocks.
- An order confirmation or receipt summary: use the order-summary blocks.
- One money field: use <x-ui.currency-input>. One formatted amount: use <x-ui.money-value>.
- More than a few hundred lines or server-side paging: use <x-ui.data-table> with your own row editor.
Use instead
- Radio or checkbox for bounded choices
- Select or combobox for known options
Anti-patterns
- Using freeform entry for a bounded answer set
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Enter Escape Home End
- Focus
managed
- A real <table> with a visually hidden <caption> and <th scope="col"> headers. Every input has an accessible name with the line number ("Quantity, line 2", "Unit price, line 2"); the icon buttons are "Move line 2" and "Remove line 2".
- The drag handle is a button described by a hint (arrow keys move the line, or drag it). Moves, removals, additions and the line limit are announced in a visually hidden polite role="status" region.
- Totals are a <dl> (Subtotal, one row per VAT option with its base, Total) below the table. Numbers use tabular numerals and dir="ltr" runs so money stays readable under dir="rtl"; number columns align to the end.
- Invalid cells use aria-invalid and aria-describedby, with the border colour as a second cue, never colour alone. A line total that waits for a valid quantity or price adds a visually hidden "(last valid total)".
- A locked line has a lock mark with role="img" named "Line 2 is locked: From time entries" at the end of the row, before its remove button, and its locked values are plain text in their cells, so a screen reader reads them without a disabled field. A line that cannot move has no move button.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="line-item-editor-{{ $record->id }}">
{{-- An editable document: type a quantity (1,5 or 1.5) or a price and the line
total, subtotal, VAT per option and total follow. Money is integer cents;
the last line is reverse charged, so its VAT row shows 0. --}}
<x-ui.line-item-editor
name="lines"
currency="EUR"
:units="[
['key' => 'hour', 'label' => __('hour')],
['key' => 'day', 'label' => __('day')],
['key' => 'piece', 'label' => __('piece')],
]"
:vat-rates="[
['key' => 'standard', 'label' => '21%', 'rate' => 21],
['key' => 'reduced', 'label' => '9%', 'rate' => 9],
['key' => 'zero', 'label' => '0%', 'rate' => 0],
['key' => 'reverse-charge', 'label' => __('Reverse charge'), 'rate' => 0, 'summary' => __('VAT reverse charged'), 'note' => __('VAT is accounted for by the customer.')],
['key' => 'export', 'label' => __('Outside the EU'), 'rate' => 0, 'summary' => __('VAT outside the EU'), 'note' => __('Export of services, no VAT charged.')],
]"
:lines="[
['id' => 1, 'description' => __('Discovery workshop'), 'quantity' => 1, 'unit' => 'day', 'unitPrice' => 95000, 'vatRate' => 'standard'],
['id' => 2, 'description' => __('Interface design'), 'quantity' => 12.5, 'unit' => 'hour', 'unitPrice' => 8500, 'vatRate' => 'standard'],
['id' => 3, 'description' => __('Printed style guide'), 'quantity' => 3, 'unit' => 'piece', 'unitPrice' => 2495, 'vatRate' => 'reduced'],
['id' => 4, 'description' => __('Hosting setup for a business customer abroad'), 'quantity' => 1, 'unit' => 'piece', 'unitPrice' => 45000, 'vatRate' => 'reverse-charge'],
]"
/>
</div>
Validation
Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:line-item-editor
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{--
Props:
- lines: list of { id, description, quantity, unit, unitPrice, vatRate,
locked?, lockedFields?, lockedReason?, movable?, removable?, total? }.
unitPrice is an integer in minor units (cents); quantity is a number
with up to 3 decimals; vatRate is the key of a vatRates option. locked
(every field) or lockedFields (those fields) makes a line read-only
there, with a lock mark; lockedReason names why. movable and removable
default to false for a locked line and to true otherwise (lockedFields
alone keeps them). total is a server line total in minor units that
shows instead of quantity × unitPrice.
- name: renders hidden inputs name[i][field] for a plain form post and is
the error-bag prefix (name.i.field). Without it the wire:model target is
the prefix.
- currency (ISO 4217) and locale (BCP 47, default: the app locale).
- units: list of { key, label } (or a key => label map). Empty: free text.
- vatRates: list of { key, label, rate (percent), note?, summary? }. Empty:
no VAT column and no VAT rows.
- defaultVatRate / defaultUnit: the values of a new line (default: first option).
- readonly: a static table without inputs, for a sent document and print.
- caption: the table caption (visually hidden).
- maxLines: the largest supported line count (more input throws).
- maxQuantity / maxUnitPrice: the largest typed quantity and unit price
(minor units), as absolute values. A larger value is flagged, not
committed.
- totals: { subtotal, vat: [{ key, base, amount, label?, summary?, note? }],
total } in minor units from the server. It replaces the computed totals
(a Livewire render updates it), so the shown values are authoritative.
- vatRounding: total (round VAT once per option, the default) or line
(round VAT per line, then sum per option).
Slot: footer (next to the Add line button).
--}}
@props([
'lines' => [],
'name' => null,
'currency' => 'EUR',
'locale' => null,
'units' => [],
'vatRates' => [],
'defaultVatRate' => null,
'defaultUnit' => null,
'readonly' => false,
'caption' => null,
'maxLines' => 200,
'maxQuantity' => 100000,
'maxUnitPrice' => 100000000,
'totals' => null,
'vatRounding' => 'total',
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$input = $styles['input'];
$textarea = $styles['textarea'];
$currency = strtoupper((string) $currency);
$currency = preg_match('/^[A-Z]{3}$/', $currency) === 1 ? $currency : 'EUR';
$locale = str_replace('_', '-', (string) ($locale ?: app()->getLocale()));
$icuLocale = str_replace('-', '_', $locale);
$readonly = (bool) $readonly;
$vatRounding = $styles['normalizeVariant']($vatRounding);
$vatRounding = in_array($vatRounding, ['line', 'total'], true) ? $vatRounding : 'total';
$maxLines = max(1, (int) $maxLines);
$maxQuantity = is_numeric($maxQuantity) && (float) $maxQuantity > 0 ? round((float) $maxQuantity, 3) : 100000;
$maxQuantity = $maxQuantity == (int) $maxQuantity ? (int) $maxQuantity : $maxQuantity;
$maxUnitPrice = is_numeric($maxUnitPrice) && (int) $maxUnitPrice > 0 ? (int) $maxUnitPrice : 100000000;
$hasIntl = class_exists(\NumberFormatter::class);
// Minor-unit digits of the currency (2 for EUR, 0 for JPY, 3 for KWD).
$digits = 2;
$moneyFormatter = null;
if ($hasIntl) {
$moneyFormatter = new \NumberFormatter($icuLocale.'@currency='.$currency, \NumberFormatter::CURRENCY);
$digits = max(0, (int) $moneyFormatter->getAttribute(\NumberFormatter::MAX_FRACTION_DIGITS));
}
$scale = 10 ** $digits;
$formatMoney = function (int $minor) use ($moneyFormatter, $currency, $scale, $digits): string {
$formatted = $moneyFormatter?->formatCurrency($minor / $scale, $currency);
return is_string($formatted) && $formatted !== '' ? $formatted : $currency.' '.number_format($minor / $scale, $digits, '.', ',');
};
$quantityFormatter = $hasIntl ? new \NumberFormatter($icuLocale, \NumberFormatter::DECIMAL) : null;
$quantityFormatter?->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, 3);
$quantityFormatter?->setAttribute(\NumberFormatter::GROUPING_USED, 0);
$formatQuantity = function (float $quantity) use ($quantityFormatter): string {
$formatted = $quantityFormatter?->format($quantity);
return is_string($formatted) && $formatted !== '' ? $formatted : rtrim(rtrim(number_format($quantity, 3, '.', ''), '0'), '.');
};
// One rounding rule, shared with line-item-editor.js: integer division of
// $n by $d, rounded half away from zero.
$roundDiv = function (int $n, int $d): int {
$q = intdiv($n, $d);
$r = $n - $q * $d;
return abs($r) * 2 >= $d ? $q + ($n <=> 0) : $q;
};
// quantity is held in thousandths; line total = round(quantity × unitPrice),
// or the line's server total when it has one.
$lineTotal = fn (array $line): int => $line['total'] ?? $roundDiv((int) round($line['quantity'] * 1000) * $line['unitPrice'], 1000);
$unitOptions = [];
foreach ((is_iterable($units) ? collect($units)->all() : []) as $key => $unit) {
$unitOptions[] = is_array($unit)
? ['key' => (string) ($unit['key'] ?? $key), 'label' => (string) ($unit['label'] ?? $unit['key'] ?? $key)]
: ['key' => (string) $key, 'label' => (string) $unit];
}
$unitLabels = array_column($unitOptions, 'label', 'key');
$vatOptions = [];
foreach ((is_iterable($vatRates) ? collect($vatRates)->all() : []) as $index => $option) {
$option = (array) $option;
$key = (string) ($option['key'] ?? $index);
$label = (string) ($option['label'] ?? $key);
$vatOptions[] = [
'key' => $key,
'label' => $label,
'rate' => is_numeric($option['rate'] ?? null) ? (float) $option['rate'] : 0.0,
'note' => filled($option['note'] ?? null) ? (string) $option['note'] : null,
'summary' => filled($option['summary'] ?? null) ? (string) $option['summary'] : __('VAT :label', ['label' => $label]),
];
}
$hasVat = $vatOptions !== [];
$vatByKey = array_column($vatOptions, null, 'key');
$defaultVatKey = $hasVat ? (string) ($defaultVatRate ?? $vatOptions[0]['key']) : '';
$defaultUnitKey = (string) ($defaultUnit ?? ($unitOptions[0]['key'] ?? ''));
$lines = array_values(is_iterable($lines) ? collect($lines)->all() : []);
if (count($lines) > $maxLines) {
throw new \InvalidArgumentException('line-item-editor supports at most '.$maxLines.' lines; '.count($lines).' given. Split the document or raise max-lines.');
}
$editableFields = ['description', 'quantity', 'unit', 'unitPrice', 'vatRate'];
$normalized = [];
$seenIds = [];
foreach ($lines as $index => $line) {
$line = (array) $line;
$id = $line['id'] ?? null;
if ($id === null || $id === '' || isset($seenIds[(string) $id])) {
$id = 'line-'.($index + 1);
}
$seenIds[(string) $id] = true;
$quantity = is_numeric($line['quantity'] ?? null) ? round((float) $line['quantity'], 3) : 0.0;
$entry = [
'id' => $id,
'description' => (string) ($line['description'] ?? ''),
'quantity' => $quantity == (int) $quantity ? (int) $quantity : $quantity,
'unit' => (string) ($line['unit'] ?? $defaultUnitKey),
'unitPrice' => is_numeric($line['unitPrice'] ?? null) ? (int) round((float) $line['unitPrice']) : 0,
'vatRate' => $hasVat ? (string) ($line['vatRate'] ?? $defaultVatKey) : '',
];
// Lock, move and remove flags and the server total: kept as given so
// the model round-trips them.
if (($line['locked'] ?? false) === true) {
$entry['locked'] = true;
}
if (is_array($line['lockedFields'] ?? null)) {
$entry['lockedFields'] = array_values(array_intersect(array_map('strval', $line['lockedFields']), $editableFields));
}
if (filled($line['lockedReason'] ?? null)) {
$entry['lockedReason'] = (string) $line['lockedReason'];
}
foreach (['movable', 'removable'] as $flag) {
if (is_bool($line[$flag] ?? null)) {
$entry[$flag] = $line[$flag];
}
}
if (is_numeric($line['total'] ?? null)) {
$entry['total'] = (int) round((float) $line['total']);
}
$normalized[] = $entry;
}
// Totals: subtotal of the rounded line totals, then VAT per option on the
// option's base, rounded once per group (vatRounding total) or per line
// and summed (vatRounding line).
$vatOn = fn (int $base, float $rate): int => $roundDiv($base * (int) round($rate * 1000), 100000);
$subtotal = 0;
$groups = [];
$lineVat = [];
foreach ($normalized as $line) {
$amount = $lineTotal($line);
$subtotal += $amount;
if ($hasVat) {
$groups[$line['vatRate']] = ($groups[$line['vatRate']] ?? 0) + $amount;
$lineVat[$line['vatRate']] = ($lineVat[$line['vatRate']] ?? 0) + $vatOn($amount, (float) ($vatByKey[$line['vatRate']]['rate'] ?? 0));
}
}
$vatRows = [];
$order = array_merge(array_column($vatOptions, 'key'), array_keys($groups));
foreach (array_unique($order) as $key) {
if (! array_key_exists($key, $groups)) {
continue;
}
$option = $vatByKey[$key] ?? ['key' => $key, 'label' => $key, 'rate' => 0.0, 'note' => null, 'summary' => $key];
$vatRows[] = $option + [
'base' => $groups[$key],
'amount' => $vatRounding === 'line' ? $lineVat[$key] : $vatOn($groups[$key], (float) $option['rate']),
];
}
$total = $subtotal + array_sum(array_column($vatRows, 'amount'));
// Server totals replace the computed ones in the paint and in the script.
$serverTotals = null;
if (is_array($totals) || $totals instanceof \Illuminate\Contracts\Support\Arrayable) {
$totals = $totals instanceof \Illuminate\Contracts\Support\Arrayable ? $totals->toArray() : $totals;
$serverVat = [];
foreach ((array) ($totals['vat'] ?? []) as $row) {
$row = (array) $row;
$key = (string) ($row['key'] ?? '');
$option = $vatByKey[$key] ?? ['label' => $key, 'rate' => 0.0, 'note' => null, 'summary' => $key];
$serverVat[] = [
'key' => $key,
'label' => (string) ($row['label'] ?? $option['label']),
'rate' => is_numeric($row['rate'] ?? null) ? (float) $row['rate'] : (float) $option['rate'],
'base' => (int) round((float) ($row['base'] ?? 0)),
'amount' => (int) round((float) ($row['amount'] ?? 0)),
'note' => filled($row['note'] ?? null) ? (string) $row['note'] : $option['note'],
'summary' => filled($row['summary'] ?? null) ? (string) $row['summary'] : $option['summary'],
];
}
$serverTotals = [
'subtotal' => (int) round((float) ($totals['subtotal'] ?? 0)),
'vat' => $serverVat,
'total' => (int) round((float) ($totals['total'] ?? 0)),
];
$subtotal = $serverTotals['subtotal'];
$vatRows = $serverVat;
$total = $serverTotals['total'];
}
// Error bag: name.i.field (or the wire:model target) marks that cell.
$wireModel = collect($attributes->getAttributes())->first(fn ($value, $key) => str_starts_with((string) $key, 'wire:model'));
$errorPrefix = filled($name)
? trim(str_replace(['[]', '[', ']'], ['', '.', ''], (string) $name), '.')
: (is_string($wireModel) && $wireModel !== '' ? $wireModel : null);
// A stable id: the x-data config holds it, and a config that changes
// between Livewire renders would start the component again.
$baseId = $attributes->get('id') ?: 'line-item-editor-'.(filled($name) || (is_string($wireModel) && $wireModel !== '')
? substr(md5((string) $name.'|'.(string) $wireModel), 0, 8)
: \Illuminate\Support\Str::random(8));
$bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
$generalErrors = $bag && $errorPrefix ? $bag->get($errorPrefix) : [];
$cellErrors = [];
// data-errors: { general: [...], cells: { index: { field: message } } },
// read by the script at start and after every Livewire render.
$errorCells = [];
if ($bag && $errorPrefix) {
foreach ($normalized as $index => $line) {
foreach ($editableFields as $field) {
$message = $bag->first($errorPrefix.'.'.$index.'.'.$field);
if (filled($message)) {
$cellErrors[] = ['id' => $baseId.'-error-'.$index.'-'.$field, 'line' => (string) $line['id'], 'field' => $field, 'number' => $index + 1, 'message' => $message];
$errorCells[(string) $index][$field] = $message;
}
}
}
}
$errorData = ['general' => array_values($generalErrors), 'cells' => (object) $errorCells];
$invalidId = $baseId.'-invalid';
$hintId = $baseId.'-reorder-hint';
$columnCount = 5 + ($hasVat ? 1 : 0) + ($readonly ? 0 : 2);
// Never the lines, messages or totals here: those change between renders
// and travel in data attributes, so this expression stays the same.
$config = [
'baseId' => $baseId,
'vatRounding' => $vatRounding,
'currency' => $currency,
'locale' => $locale,
'units' => $unitOptions,
'vatRates' => array_map(fn ($o) => ['key' => $o['key'], 'label' => $o['label'], 'rate' => $o['rate'], 'note' => $o['note'], 'summary' => $o['summary']], $vatOptions),
'defaultVatRate' => $defaultVatKey,
'defaultUnit' => $defaultUnitKey,
'name' => filled($name) ? (string) $name : null,
'maxLines' => $maxLines,
'maxQuantity' => $maxQuantity,
'maxUnitPrice' => $maxUnitPrice,
'invalidId' => $invalidId,
'text' => [
'description' => __('Description, line :number'),
'quantity' => __('Quantity, line :number'),
'unit' => __('Unit, line :number'),
'unitPrice' => __('Unit price, line :number'),
'vatRate' => __('VAT, line :number'),
'remove' => __('Remove line :number'),
'move' => __('Move line :number'),
'line' => __('Line :number'),
'on' => __('on :amount'),
'moved' => __(':line moved to position :position of :count.'),
'removed' => __(':line removed.'),
'added' => __('Line :number added.'),
'limit' => __('This document holds at most :count lines.'),
'locked' => __('Line :number is locked'),
'lockedBecause' => __('Line :number is locked: :reason'),
'errorLine' => __('Line :number: :message'),
'lastValid' => __('(last valid total)'),
],
];
$resolvedCaption = $caption ?? __('Line items');
$cellInput = trim($input['base'].' '.$input['sizes']['md']);
$numberInput = $cellInput.' text-end tabular-nums';
$headClass = 'whitespace-nowrap px-3 py-2 text-start align-bottom text-xs font-medium text-muted-foreground';
// Edit mode sizes the columns from the header, so an empty table keeps its shape.
$width = fn (string $class) => $readonly ? '' : $class;
$numberHeadClass = 'whitespace-nowrap px-3 py-2 text-end align-bottom text-xs font-medium text-muted-foreground';
$staticCell = 'px-3 py-2 align-top text-sm';
$numberCell = 'whitespace-nowrap px-3 py-2 text-end align-top text-sm tabular-nums';
$bad = fn (string $field) => "{ 'border-input': !liIsInvalid(line, '{$field}'), 'border-destructive focus-visible:ring-destructive': liIsInvalid(line, '{$field}') }";
$invalidAttrs = fn (string $field) => "x-bind:aria-invalid=\"liIsInvalid(line, '{$field}') ? 'true' : null\" x-bind:aria-describedby=\"liDescribedBy(line, '{$field}')\"";
$hasFooter = isset($footer) && trim((string) $footer) !== '';
// Edit mode: every region the script draws is left alone by a Livewire render.
$ignored = $readonly ? '' : 'wire:ignore';
$lockedText = 'flex min-h-control-h-md items-center px-3 text-sm text-muted-foreground';
$lockIcon = '<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></svg>';
@endphp
{{--
Line Item Editor. A table of document lines (quotes, invoices, credit notes,
purchase orders) with subtotal, VAT per option and total. One Alpine
component owns the lines; the rows render through x-for. The server paints
the same rows and totals first (and alone in readonly mode), so the table
reads without JavaScript and prints.
In a Livewire view it needs no outer wire:ignore: the regions the script
draws carry their own, and the root's data-errors and data-totals take the
server messages and totals of every render to the script.
--}}
<div
id="{{ $baseId }}"
data-slot="line-item-editor"
@if ($readonly) data-readonly="true" @else
x-data="uiLineItemEditor({{ \Illuminate\Support\Js::from($config) }})"
x-modelable="liModel"
data-lines="{{ json_encode($normalized) }}"
data-errors="{{ json_encode($errorData) }}"
@if ($serverTotals !== null) data-totals="{{ json_encode($serverTotals) }}" @endif
@endif
{{ $attributes->except('id')->merge(['class' => '@container flex w-full min-w-0 flex-col gap-4 text-foreground']) }}
>
<div class="min-w-0" {{ $ignored }}>
<x-ui.table bordered class="bg-background">
<caption class="sr-only">{{ $resolvedCaption }}</caption>
<x-ui.table.header>
<tr>
@unless ($readonly)
<th scope="col" class="w-px px-1 py-2"><span class="sr-only">{{ __('Order') }}</span></th>
@endunless
<th scope="col" class="{{ $headClass }} min-w-48 {{ $readonly ? 'w-full' : '' }}">{{ __('Description') }}</th>
<th scope="col" class="{{ $numberHeadClass }} {{ $width('w-24') }}">{{ __('Quantity') }}</th>
<th scope="col" class="{{ $headClass }} {{ $width('w-32') }}">{{ __('Unit') }}</th>
<th scope="col" class="{{ $numberHeadClass }} {{ $width('w-36') }}">{{ __('Unit price') }}</th>
@if ($hasVat)
<th scope="col" class="{{ $headClass }} {{ $width('w-44') }}">{{ __('VAT') }}</th>
@endif
<th scope="col" class="{{ $numberHeadClass }} {{ $width('w-32') }}">{{ __('Total') }}</th>
@unless ($readonly)
<th scope="col" class="w-px px-1 py-2"><span class="sr-only">{{ __('Actions') }}</span></th>
@endunless
</tr>
</x-ui.table.header>
<x-ui.table.body>
{{-- Server paint: the whole table in readonly mode; before Alpine
starts (and without JavaScript) in edit mode. --}}
@foreach ($normalized as $index => $line)
<tr data-slot="line-item-editor-line" data-line-id="{{ $line['id'] }}" @unless ($readonly) data-li-static @endunless class="border-b border-border">
@unless ($readonly)
<td class="w-px px-1 py-2"></td>
@endunless
<td class="{{ $staticCell }} min-w-48 whitespace-pre-line break-words">{{ $line['description'] }}</td>
<td class="{{ $numberCell }}"><span dir="ltr">{{ $formatQuantity((float) $line['quantity']) }}</span></td>
<td class="{{ $staticCell }} whitespace-nowrap">{{ $unitLabels[$line['unit']] ?? $line['unit'] }}</td>
<td class="{{ $numberCell }}"><span dir="ltr">{{ $formatMoney($line['unitPrice']) }}</span></td>
@if ($hasVat)
<td class="{{ $staticCell }} whitespace-nowrap">{{ $vatByKey[$line['vatRate']]['label'] ?? $line['vatRate'] }}</td>
@endif
<td data-slot="line-item-editor-line-total" @isset($line['total']) data-source="server" @endisset class="{{ $numberCell }} font-medium"><span dir="ltr">{{ $formatMoney($lineTotal($line)) }}</span></td>
@unless ($readonly)
<td class="w-px px-1 py-2"></td>
@endunless
</tr>
@endforeach
@unless ($readonly)
<template x-for="(line, index) in liLines" x-bind:key="line.id">
<tr
data-slot="line-item-editor-line"
data-li-row
x-bind:data-line-id="line.id"
x-bind:data-dragging="liDragId === line.id ? 'true' : null"
class="border-b border-border transition-colors data-[dragging=true]:bg-muted motion-reduce:transition-none"
>
<td class="w-px px-1 py-2 align-top">
<template x-if="liIsMovable(line)">
<x-ui.button
variant="ghost"
size="md"
icon-label="{{ __('Move line') }}"
data-li-handle
aria-describedby="{{ $hintId }}"
x-bind:aria-label="liLabel('move', index)"
x-on:pointerdown="liDragStart($event, index)"
x-on:keydown="liHandleKey($event, index)"
class="cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
>
<svg class="size-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="9" cy="6" r="1.5" /><circle cx="15" cy="6" r="1.5" /><circle cx="9" cy="12" r="1.5" /><circle cx="15" cy="12" r="1.5" /><circle cx="9" cy="18" r="1.5" /><circle cx="15" cy="18" r="1.5" /></svg>
</x-ui.button>
</template>
</td>
<td class="min-w-48 px-1 py-2 align-top">
<template x-if="liFieldLocked(line, 'description')">
<span data-li-locked="description" class="{{ $lockedText }} whitespace-pre-line break-words" x-text="line.description"></span>
</template>
<template x-if="! liFieldLocked(line, 'description')">
<textarea
rows="1"
data-li-field="description"
x-bind:aria-label="liLabel('description', index)"
{!! $invalidAttrs('description') !!}
x-bind:value="line.description"
x-on:input.debounce.400ms="liCommitText(line, 'description', $event.target)"
x-on:change="liCommitText(line, 'description', $event.target)"
x-bind:class="{{ $bad('description') }}"
class="{{ $textarea['base'] }} field-sizing-content resize-y"
></textarea>
</template>
</td>
<td class="px-1 py-2 align-top">
<template x-if="liFieldLocked(line, 'quantity')">
<span data-li-locked="quantity" class="{{ $lockedText }} justify-end tabular-nums" dir="ltr" x-text="liQuantityText(line)"></span>
</template>
<template x-if="! liFieldLocked(line, 'quantity')">
<input
type="text"
inputmode="decimal"
autocomplete="off"
spellcheck="false"
dir="ltr"
data-li-field="quantity"
x-bind:aria-label="liLabel('quantity', index)"
{!! $invalidAttrs('quantity') !!}
x-bind:value="liQuantityText(line)"
x-on:change="liCommitNumber(line, 'quantity', $event.target)"
x-on:keydown.enter="liCommitNumber(line, 'quantity', $event.target)"
x-on:keydown.escape="liRevert(line, 'quantity', $event.target)"
x-bind:class="{{ $bad('quantity') }}"
class="{{ $numberInput }} min-w-20"
/>
</template>
</td>
<td class="px-1 py-2 align-top">
<template x-if="liFieldLocked(line, 'unit')">
<span data-li-locked="unit" class="{{ $lockedText }} whitespace-nowrap" x-text="liUnitLabel(line)"></span>
</template>
<template x-if="! liFieldLocked(line, 'unit')">
<div>
@if ($unitOptions !== [])
<x-ui.select
data-li-field="unit"
x-bind:aria-label="liLabel('unit', index)"
x-bind:aria-invalid="liIsInvalid(line, 'unit') ? 'true' : null"
x-bind:aria-describedby="liDescribedBy(line, 'unit')"
x-on:change="liCommitText(line, 'unit', $event.target)"
x-bind:class="{{ $bad('unit') }}"
class="min-w-28"
>
<template x-for="option in liUnitOptions(line)" x-bind:key="option.key">
<option x-bind:value="option.key" x-bind:selected="option.key === line.unit" x-text="option.label"></option>
</template>
</x-ui.select>
@else
<input
type="text"
autocomplete="off"
data-li-field="unit"
x-bind:aria-label="liLabel('unit', index)"
{!! $invalidAttrs('unit') !!}
x-bind:value="line.unit"
x-on:change="liCommitText(line, 'unit', $event.target)"
x-bind:class="{{ $bad('unit') }}"
class="{{ $cellInput }} min-w-20"
/>
@endif
</div>
</template>
</td>
<td class="px-1 py-2 align-top">
<template x-if="liFieldLocked(line, 'unitPrice')">
<span data-li-locked="unitPrice" class="{{ $lockedText }} justify-end whitespace-nowrap tabular-nums" dir="ltr" x-text="liMoney(line.unitPrice)"></span>
</template>
<template x-if="! liFieldLocked(line, 'unitPrice')">
<div class="relative" dir="ltr">
<span class="pointer-events-none absolute inset-y-0 start-0 flex items-center ps-3 text-sm text-muted-foreground" aria-hidden="true" x-text="liSymbol">{{ $currency }}</span>
<input
type="text"
inputmode="decimal"
autocomplete="off"
spellcheck="false"
data-li-field="unitPrice"
x-bind:aria-label="liLabel('unitPrice', index)"
{!! $invalidAttrs('unitPrice') !!}
x-bind:value="liPriceText(line)"
x-on:change="liCommitNumber(line, 'unitPrice', $event.target)"
x-on:keydown.enter="liCommitNumber(line, 'unitPrice', $event.target)"
x-on:keydown.escape="liRevert(line, 'unitPrice', $event.target)"
x-bind:class="{{ $bad('unitPrice') }}"
class="{{ $numberInput }} min-w-28 ps-8"
/>
</div>
</template>
</td>
@if ($hasVat)
<td class="px-1 py-2 align-top">
<template x-if="liFieldLocked(line, 'vatRate')">
<span data-li-locked="vatRate" class="{{ $lockedText }} whitespace-nowrap" x-text="liVatLabel(line)"></span>
</template>
<template x-if="! liFieldLocked(line, 'vatRate')">
<div>
<x-ui.select
data-li-field="vatRate"
x-bind:aria-label="liLabel('vatRate', index)"
x-bind:aria-invalid="liIsInvalid(line, 'vatRate') ? 'true' : null"
x-bind:aria-describedby="liDescribedBy(line, 'vatRate')"
x-on:change="liCommitText(line, 'vatRate', $event.target)"
x-bind:class="{{ $bad('vatRate') }}"
class="min-w-32"
>
<template x-for="option in liVatOptions(line)" x-bind:key="option.key">
<option x-bind:value="option.key" x-bind:selected="option.key === line.vatRate" x-text="option.label"></option>
</template>
</x-ui.select>
</div>
</template>
</td>
@endif
<td
data-slot="line-item-editor-line-total"
x-bind:data-source="liHasServerTotal(line) ? 'server' : null"
x-bind:data-invalid="liLineInvalid(line) ? 'true' : null"
class="{{ $numberCell }} font-medium data-[invalid=true]:text-muted-foreground"
><span class="flex h-control-h-md items-center justify-end"><span dir="ltr" x-text="liMoney(liLineTotal(line))"></span><span class="sr-only" x-text="liLastValidText(line)"></span></span></td>
<td class="w-px px-1 py-2 align-top">
<div class="flex items-center justify-end">
<template x-if="liIsLocked(line)">
<span data-slot="line-item-editor-lock" role="img" x-bind:aria-label="liLockLabel(line, index)" x-bind:title="liLockLabel(line, index)" class="flex size-control-h-md items-center justify-center text-muted-foreground">{!! $lockIcon !!}</span>
</template>
<template x-if="liIsRemovable(line)">
<x-ui.button
variant="ghost"
size="md"
icon-label="{{ __('Remove line') }}"
data-li-remove
x-bind:aria-label="liLabel('remove', index)"
x-on:click="liRemove(index)"
class="text-muted-foreground hover:text-destructive"
>
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
</x-ui.button>
</template>
</div>
</td>
</tr>
</template>
@endunless
<tr
data-slot="line-item-editor-empty"
@if ($normalized !== []) style="display: none" @endif
@unless ($readonly) x-show="liLines.length === 0" @endunless
>
{{-- Sticks to the visible start of the scrolling frame and is as wide
as the component, so it stays centred when the columns
overflow a narrow screen. --}}
<td colspan="{{ $columnCount }}" class="p-0">
<div class="sticky start-0 flex w-full max-w-[100cqw] flex-col items-center gap-1 px-4 py-10 text-center">
<p class="text-sm font-medium">{{ __('No lines yet') }}</p>
@unless ($readonly)
<p class="text-sm text-muted-foreground">{{ __('Add a line to start.') }}</p>
@endunless
</div>
</td>
</tr>
</x-ui.table.body>
</x-ui.table>
</div>
@if ($readonly)
@if ($generalErrors !== [] || $cellErrors !== [])
<ul data-slot="line-item-editor-errors" class="flex flex-col gap-1 text-xs text-destructive">
@foreach ($generalErrors as $message)
<li>{{ $message }}</li>
@endforeach
@foreach ($cellErrors as $error)
<li id="{{ $error['id'] }}" data-line-id="{{ $error['line'] }}" data-field="{{ $error['field'] }}">{{ __('Line :number: :message', ['number' => $error['number'], 'message' => $error['message']]) }}</li>
@endforeach
</ul>
@endif
@else
{{-- The script draws this list from data-errors after every render;
the server items show until it starts. --}}
<ul data-slot="line-item-editor-errors" wire:ignore x-show="liErrorList.length > 0" @if ($generalErrors === [] && $cellErrors === []) style="display: none" @endif class="flex flex-col gap-1 text-xs text-destructive">
@foreach ($generalErrors as $message)
<li data-li-static>{{ $message }}</li>
@endforeach
@foreach ($cellErrors as $error)
<li data-li-static id="{{ $error['id'] }}" data-line-id="{{ $error['line'] }}" data-field="{{ $error['field'] }}">{{ __('Line :number: :message', ['number' => $error['number'], 'message' => $error['message']]) }}</li>
@endforeach
<template x-for="error in liErrorList" x-bind:key="error.key">
<li x-bind:id="error.id" x-bind:data-line-id="error.line" x-bind:data-field="error.field" x-text="error.text"></li>
</template>
</ul>
@endif
@unless ($readonly)
<p id="{{ $invalidId }}" data-slot="line-item-editor-message" data-reason="format" wire:ignore class="text-xs text-destructive" style="display: none" x-show="liHasInvalid('format')">{{ __('Enter a number, such as :example.', ['example' => $formatQuantity(1.5)]) }}</p>
<p id="{{ $baseId }}-range-quantity" data-slot="line-item-editor-message" data-reason="quantity" wire:ignore class="text-xs text-destructive" style="display: none" x-show="liHasInvalid('quantity')">{{ __('Enter a quantity up to :max.', ['max' => $formatQuantity((float) $maxQuantity)]) }}</p>
<p id="{{ $baseId }}-range-unitPrice" data-slot="line-item-editor-message" data-reason="unitPrice" wire:ignore class="text-xs text-destructive" style="display: none" x-show="liHasInvalid('unitPrice')">{{ __('Enter a unit price up to :max.', ['max' => $formatMoney($maxUnitPrice)]) }}</p>
@endunless
<div class="flex min-w-0 flex-col gap-4 @xl:flex-row @xl:items-start @xl:justify-between">
<div class="flex min-w-0 shrink-0 flex-wrap items-center gap-2">
@unless ($readonly)
<x-ui.button
variant="outline"
size="md"
wire:ignore
data-li-add
x-on:click="liAdd()"
x-bind:disabled="liLines.length >= liMaxLines"
>
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
{{ __('Add line') }}
</x-ui.button>
@endunless
@if ($hasFooter)
{{ $footer }}
@endif
</div>
<dl data-slot="line-item-editor-totals" {{ $ignored }} @if ($serverTotals !== null) data-source="server" @endif class="flex w-full min-w-0 flex-col gap-2 text-sm @xl:ms-auto @xl:max-w-sm">
<div class="flex items-baseline justify-between gap-4">
<dt class="text-muted-foreground">{{ __('Subtotal') }}</dt>
<dd data-slot="line-item-editor-subtotal" class="whitespace-nowrap tabular-nums" dir="ltr" @unless ($readonly) x-text="liMoney(liTotals.subtotal)" @endunless>{{ $formatMoney($subtotal) }}</dd>
</div>
@foreach ($vatRows as $row)
<div data-slot="line-item-editor-vat" data-vat-key="{{ $row['key'] }}" @unless ($readonly) data-li-static @endunless class="flex items-baseline justify-between gap-4">
<dt class="flex min-w-0 flex-col text-muted-foreground">
<span>{{ $row['summary'] }} <span class="whitespace-nowrap" dir="auto">{{ __('on :amount', ['amount' => $formatMoney($row['base'])]) }}</span></span>
@if (filled($row['note']))
<span class="text-xs">{{ $row['note'] }}</span>
@endif
</dt>
<dd class="whitespace-nowrap tabular-nums" dir="ltr">{{ $formatMoney($row['amount']) }}</dd>
</div>
@endforeach
@unless ($readonly)
<template x-for="row in liTotals.vat" x-bind:key="row.key">
<div data-slot="line-item-editor-vat" x-bind:data-vat-key="row.key" class="flex items-baseline justify-between gap-4">
<dt class="flex min-w-0 flex-col text-muted-foreground">
<span><span x-text="row.summary"></span> <span class="whitespace-nowrap" dir="auto" x-text="liOnText(row.base)"></span></span>
<span class="text-xs" x-show="row.note" x-text="row.note"></span>
</dt>
<dd class="whitespace-nowrap tabular-nums" dir="ltr" x-text="liMoney(row.amount)"></dd>
</div>
</template>
@endunless
<div class="flex items-baseline justify-between gap-4 border-t border-border pt-2 font-semibold">
<dt>{{ __('Total') }}</dt>
<dd data-slot="line-item-editor-total" class="whitespace-nowrap tabular-nums" dir="ltr" @unless ($readonly) x-text="liMoney(liTotals.total)" @endunless>{{ $formatMoney($total) }}</dd>
</div>
</dl>
</div>
@if (filled($name) && ! $readonly)
<div data-li-hidden wire:ignore class="hidden">
@foreach ($normalized as $index => $line)
<input type="hidden" name="{{ $name }}[{{ $index }}][id]" value="{{ $line['id'] }}" />
<input type="hidden" name="{{ $name }}[{{ $index }}][description]" value="{{ $line['description'] }}" />
<input type="hidden" name="{{ $name }}[{{ $index }}][quantity]" value="{{ $line['quantity'] }}" />
<input type="hidden" name="{{ $name }}[{{ $index }}][unit]" value="{{ $line['unit'] }}" />
<input type="hidden" name="{{ $name }}[{{ $index }}][unitPrice]" value="{{ $line['unitPrice'] }}" />
<input type="hidden" name="{{ $name }}[{{ $index }}][vatRate]" value="{{ $line['vatRate'] }}" />
@endforeach
</div>
@endif
@unless ($readonly)
<p id="{{ $hintId }}" class="sr-only">{{ __('Press the up or down arrow key to move the line, or drag it.') }}</p>
<p data-slot="line-item-editor-status" role="status" wire:ignore class="sr-only" x-text="liAnnouncement"></p>
<noscript><p class="text-sm text-muted-foreground">{{ __('Editing these lines needs JavaScript.') }}</p></noscript>
@endunless
</div>
/**
* Line Item Editor behaviour.
*
* One Alpine component owns the lines of a document (quote, invoice, credit
* note, purchase order). The rows render through x-for keyed by line id; the
* server paints the same rows and totals first, and init() removes that paint.
*
* Money is always an integer in minor units (cents). A quantity is a number
* held to 3 decimals. Rounding, the same in the Blade first paint:
* line total = round(quantity × unitPrice), half away from zero, per line
* VAT = round(group base × rate / 100), half away from zero, once per
* VAT option on the sum of its line totals (vatRounding
* 'total', the default), or round(line total × rate / 100)
* per line and summed per option (vatRounding 'line')
* total = subtotal + every VAT amount
* Server totals (the root's data-totals) replace the shown totals when the
* host sends them; the change event still carries the computed ones.
* The arithmetic is integer only (quantity in thousandths, rate in
* thousandths of a percent), so no float error reaches a cent.
*
* Typing: numbers parse in the locale (1,5 in nl, 1.5 in en; 1.000 in nl is a
* thousand) and commit on change (blur) or Enter; Escape restores the committed
* text. Text that is not a number stays on screen with aria-invalid and is not
* committed. The description commits 400 ms after typing stops and on change.
*
* Keyboard reorder: on a line's drag handle, ArrowUp/ArrowDown (with or
* without Alt) move the line one place, Home/End to the ends. Every move is
* announced in the polite status region. Pointer drag on the handle moves the
* line live; reduced motion drops the glide.
*
* Livewire: the x-data expression never changes between renders (the lines,
* the server messages and the server totals sit in data attributes), and
* every region the script draws carries wire:ignore. A render therefore
* keeps the component and only updates the root's attributes: an observer
* re-reads data-errors ({ general: [...], cells: { index: { field:
* message } } }, from name.N.field in the error bag) and data-totals, so a
* server message reaches its cell after every render.
*
* Locked lines: a line with `locked: true` (every field) or `lockedFields`
* (those fields) shows the locked values as text and a lock mark;
* `lockedReason` names why ("From time entries"). Moving and removing are
* separate: `movable` and `removable` default to false for a `locked` line
* and to true for every other line (a line with only `lockedFields` too).
* The flags travel back in the model unchanged; enforce them on the server
* as well.
*
* Server line totals: a line can carry `total` (integer minor units) that
* shows instead of quantity × unit price, such as time billed per entry.
* Editing that line's quantity or unit price drops it, so the computed
* value shows until the host sends a new one.
*
* Bounds: a typed quantity above maxQuantity or a unit price above
* maxUnitPrice (both as absolute values) is flagged like text that is not
* a number and is not committed. The line total then keeps the last valid
* value, marked data-invalid, and the totals keep using it.
*
* State names are prefixed `li*`: an x-model on the root resolves in this
* scope first, so a plain `lines` would shadow the consumer's own variable.
*
* Events (bubbling CustomEvent on the root):
* line-items-change { lines, totals: { subtotal, vat: [{ key, label, rate,
* base, amount }], total } } after every committed change.
*/
const FIELDS = ['id', 'description', 'quantity', 'unit', 'unitPrice', 'vatRate'];
const EDITABLE = ['description', 'quantity', 'unit', 'unitPrice', 'vatRate'];
// Keys that travel back in the model as they came (the host owns them).
const KEPT_KEYS = ['locked', 'lockedFields', 'lockedReason', 'movable', 'removable', 'total'];
/** True when `field` of `line` is read-only. */
export const isFieldLocked = (line, field) =>
line?.locked === true || (Array.isArray(line?.lockedFields) && line.lockedFields.includes(field));
/** True when the line shows a lock mark (any lock). */
export const isLineLocked = (line) =>
line?.locked === true || (Array.isArray(line?.lockedFields) && line.lockedFields.length > 0);
/** True when the line can be moved: `movable`, else not for a `locked` line. */
export const isLineMovable = (line) =>
typeof line?.movable === 'boolean' ? line.movable : line?.locked !== true;
/** True when the line can be removed: `removable`, else not for a `locked` line. */
export const isLineRemovable = (line) =>
typeof line?.removable === 'boolean' ? line.removable : line?.locked !== true;
/** True when the line carries a server total. */
export const hasServerTotal = (line) => Number.isSafeInteger(line?.total);
/**
* a × b / d rounded half away from zero (d > 0), for integer a and b. BigInt
* keeps a product above 2^53 exact; the result is a Number.
*/
const mulDivRound = (a, b, d) => {
const n = BigInt(a) * BigInt(b);
const div = BigInt(d);
let q = n / div;
const r = n - q * div;
const abs = r < 0n ? -r : r;
if (abs * 2n >= div) q += n < 0n ? -1n : 1n;
return Number(q) || 0;
};
/** An integer from a number, or 0 when it is not finite. */
const whole = (value) => {
const number = Math.round(Number(value || 0));
return Number.isFinite(number) ? number : 0;
};
/** Integer n / d rounded half away from zero (d > 0). */
export const roundDiv = (n, d) => mulDivRound(n, 1, d);
/**
* Line total in minor units: the line's server `total` when it has one,
* else quantity (to 3 decimals) × unit price.
*/
export const lineTotal = (line) =>
hasServerTotal(line)
? line.total
: mulDivRound(whole(Number(line.quantity || 0) * 1000), whole(line.unitPrice), 1000);
/**
* Parse typed text into an integer scaled by 10^scale (half away from zero on
* the next digit), or null when it is not a number. Empty text is 0.
* decimal/group are the locale separators.
*/
export const parseScaled = (text, scale, { decimal = '.', group = ',' } = {}) => {
const raw = String(text ?? '').trim();
if (raw === '') return 0;
const negative = /^[-−(]/.test(raw) || /[-−)]$/.test(raw);
// Currency symbols, letters and spaces (\s includes the no-break and narrow
// no-break spaces some locales group with) are ignored; any other character is not.
const cleaned = raw.replace(/[\s'’]|\p{Sc}|\p{L}/gu, '').replace(/^[(+−-]+|[)−-]+$/g, '');
if (cleaned === '' || !/^[0-9.,]+$/.test(cleaned) || !/[0-9]/.test(cleaned)) return null;
let whole = cleaned;
let fraction = '';
const other = decimal === ',' ? '.' : ',';
const count = (char) => cleaned.split(char).length - 1;
const splitAt = (index) => {
whole = cleaned.slice(0, index);
fraction = cleaned.slice(index + 1);
};
if (count(decimal) === 1) {
splitAt(cleaned.lastIndexOf(decimal));
} else if (count(decimal) === 0 && count(other) > 0) {
// Only the other separator: groups of exactly three digits after the
// first are thousands (1.000 in nl), anything else is a decimal point.
const parts = cleaned.split(other);
const grouped =
other === group &&
parts.length > 1 &&
parts[0].length > 0 &&
parts.slice(1).every((part) => part.length === 3);
if (!grouped) splitAt(cleaned.lastIndexOf(other));
}
whole = whole.replace(/[.,]/g, '');
if (/[.,]/.test(fraction)) return null;
if (whole.length > 13) return null;
const padded = (fraction + '0'.repeat(scale + 1)).slice(0, scale + 1);
let value = Number(whole || '0') * 10 ** scale + Number(padded.slice(0, scale) || '0');
if (Number(padded.slice(scale)) >= 5) value += 1;
return negative && value !== 0 ? -value : value;
};
/** VAT on `base` minor units at `rate` percent, half away from zero. */
const vatOn = (base, rate) => mulDivRound(base, whole((Number(rate) || 0) * 1000), 100000);
/**
* Subtotal, VAT per option (in option order, unknown keys after) and total.
* `rounding` 'total' rounds VAT once per option; 'line' rounds it per line.
*/
export const computeTotals = (lines, vatRates = [], rounding = 'total') => {
const options = new Map(vatRates.map((option) => [String(option.key), option]));
const bases = new Map();
const perLine = new Map();
let subtotal = 0;
for (const line of lines) {
const amount = lineTotal(line);
subtotal += amount;
if (options.size === 0) continue;
const key = String(line.vatRate ?? '');
bases.set(key, (bases.get(key) ?? 0) + amount);
perLine.set(key, (perLine.get(key) ?? 0) + vatOn(amount, options.get(key)?.rate));
}
const keys = [...new Set([...options.keys(), ...bases.keys()])].filter((key) => bases.has(key));
const vat = keys.map((key) => {
const option = options.get(key) || { key, label: key, rate: 0, note: null, summary: key };
const base = bases.get(key);
const rate = Number(option.rate) || 0;
return {
key,
label: option.label,
rate,
base,
amount: rounding === 'line' ? perLine.get(key) : vatOn(base, rate),
note: option.note || null,
summary: option.summary || option.label,
};
});
return { subtotal, vat, total: vat.reduce((sum, row) => sum + row.amount, subtotal) };
};
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiLineItemEditor', (config = {}) => {
const text = config.text || {};
const units = Array.isArray(config.units) ? config.units : [];
const vatRates = Array.isArray(config.vatRates) ? config.vatRates : [];
const locale = config.locale || undefined;
const currency = config.currency || 'EUR';
let money;
let amount;
let quantity;
try {
money = new Intl.NumberFormat(locale, { style: 'currency', currency });
} catch {
money = new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' });
}
const digits = money.resolvedOptions().maximumFractionDigits ?? 2;
const scale = 10 ** digits;
amount = new Intl.NumberFormat(locale, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
quantity = new Intl.NumberFormat(locale, { maximumFractionDigits: 3, useGrouping: false });
const parts = new Intl.NumberFormat(locale).formatToParts(12345.6);
const separators = {
decimal: parts.find((part) => part.type === 'decimal')?.value || '.',
group: parts.find((part) => part.type === 'group')?.value || ',',
};
const symbol =
money.formatToParts(0).find((part) => part.type === 'currency')?.value || currency;
let root = null;
let counter = 0;
let drag = null;
const onMove = (event) => drag?.self.liDragMove(event);
const onUp = () => drag?.self.liDragEnd();
const newId = () => `new-${Date.now().toString(36)}-${++counter}`;
const clean = (line, seen) => {
const source = line && typeof line === 'object' ? line : {};
let id = source.id ?? null;
if (id === null || id === '' || seen.has(String(id))) id = newId();
seen.add(String(id));
const qty = Number(source.quantity);
const price = Number(source.unitPrice);
const cleaned = {
id,
description: String(source.description ?? ''),
quantity: Number.isFinite(qty) ? Math.round(qty * 1000) / 1000 : 0,
unit: String(source.unit ?? config.defaultUnit ?? ''),
unitPrice: Number.isFinite(price) ? Math.round(price) : 0,
vatRate: vatRates.length
? String(source.vatRate ?? config.defaultVatRate ?? '')
: '',
};
// Lock flags, move and remove flags and the server total go back
// in the model as they came.
if (source.locked === true) cleaned.locked = true;
if (Array.isArray(source.lockedFields))
cleaned.lockedFields = source.lockedFields.map(String).filter((field) => EDITABLE.includes(field));
if (source.lockedReason != null && source.lockedReason !== '')
cleaned.lockedReason = String(source.lockedReason);
if (typeof source.movable === 'boolean') cleaned.movable = source.movable;
if (typeof source.removable === 'boolean') cleaned.removable = source.removable;
const total = Number(source.total);
if (source.total != null && source.total !== '' && Number.isFinite(total))
cleaned.total = Math.round(total);
return cleaned;
};
const linesFrom = (value) => {
const seen = new Set();
return (Array.isArray(value) ? value : [])
.slice(0, config.maxLines || 200)
.map((line) => clean(line, seen));
};
const plain = (lines) =>
lines.map((line) =>
Object.fromEntries(
[...FIELDS, ...KEPT_KEYS.filter((key) => line[key] !== undefined)].map((field) => [
field,
Array.isArray(line[field]) ? [...line[field]] : line[field],
]),
),
);
const readJson = (el, name) => {
try {
return JSON.parse(el?.getAttribute(name) || 'null');
} catch {
return null;
}
};
let observer = null;
return {
liLines: [],
liInvalid: {},
// `${lineId}|${field}` => { id, message }, from data-errors.
liServerErrors: {},
liGeneralErrors: [],
// The host's totals from data-totals, or null (computed totals show).
liServerTotals: null,
liAnnouncement: '',
liDragId: null,
liMaxLines: config.maxLines || 200,
// The typed bounds: absolute quantity and unit price (minor units).
liMaxQuantity: Number(config.maxQuantity) || 100000,
liMaxUnitPrice: Number(config.maxUnitPrice) || 100000000,
liSymbol: symbol,
init() {
root = this.$el;
// x-modelable may already have set the bound lines.
if (this.liLines.length === 0) this.liLines = linesFrom(readJson(root, 'data-lines'));
root.querySelectorAll('[data-li-static]').forEach((node) => node.remove());
this.liReadServer();
observer = new MutationObserver(() => this.liReadServer());
observer.observe(root, { attributes: true, attributeFilter: ['data-errors', 'data-totals'] });
this.liSyncHidden();
},
/**
* The server messages (by line index, mapped to line ids now) and
* the server totals from the root's attributes. A Livewire render
* updates them; this runs on every change.
*/
liReadServer() {
if (!root) return;
const errors = readJson(root, 'data-errors') || {};
const cells = {};
for (const [index, fields] of Object.entries(errors.cells || {})) {
const line = this.liLines[Number(index)];
if (!line || !fields || typeof fields !== 'object') continue;
for (const [field, message] of Object.entries(fields)) {
if (!EDITABLE.includes(field) || message == null || message === '') continue;
cells[`${line.id}|${field}`] = {
id: `${config.baseId}-error-${index}-${field}`,
message: String(message),
};
}
}
this.liServerErrors = cells;
this.liGeneralErrors = Array.isArray(errors.general) ? errors.general.map(String) : [];
const totals = readJson(root, 'data-totals');
this.liServerTotals = totals && typeof totals === 'object' ? totals : null;
},
destroy() {
observer?.disconnect();
observer = null;
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
drag = null;
root = null;
},
/* ---------------------------------------------------------- model */
/** x-modelable: the list of lines. */
get liModel() {
return this.liLines;
},
set liModel(value) {
if (!Array.isArray(value)) return;
const next = linesFrom(value);
if (JSON.stringify(next) === JSON.stringify(plain(this.liLines))) return;
this.liLines = next;
this.liInvalid = {};
this.liReadServer();
this.$nextTick(() => this.liRenderInputs());
this.liSyncHidden();
},
/** The totals from the lines, with the configured VAT rounding. */
get liComputedTotals() {
return computeTotals(this.liLines, vatRates, config.vatRounding);
},
/** The shown totals: the host's server totals when sent, else computed. */
get liTotals() {
const server = this.liServerTotals;
if (!server) return this.liComputedTotals;
const options = new Map(vatRates.map((option) => [String(option.key), option]));
const vat = (Array.isArray(server.vat) ? server.vat : []).map((row) => {
const option = options.get(String(row?.key ?? '')) || {};
const label = row?.label ?? option.label ?? String(row?.key ?? '');
return {
key: String(row?.key ?? ''),
label,
rate: Number(row?.rate ?? option.rate) || 0,
base: Math.round(Number(row?.base) || 0),
amount: Math.round(Number(row?.amount) || 0),
note: row?.note ?? option.note ?? null,
summary: row?.summary ?? option.summary ?? label,
};
});
return {
subtotal: Math.round(Number(server.subtotal) || 0),
vat,
total: Math.round(Number(server.total) || 0),
};
},
get liInvalidCount() {
return Object.keys(this.liInvalid).length;
},
/** True while a cell holds a value of `reason` ('format', 'quantity', 'unitPrice'). */
liHasInvalid(reason) {
return Object.values(this.liInvalid).includes(reason);
},
/* ----------------------------------------------------- formatting */
liMoney(minor) {
return money.format((Number(minor) || 0) / scale);
},
liOnText(minor) {
return String(text.on || 'on :amount').replace(':amount', this.liMoney(minor));
},
liQuantityText(line) {
return quantity.format(Number(line.quantity) || 0);
},
liPriceText(line) {
return amount.format((Number(line.unitPrice) || 0) / scale);
},
liLineTotal(line) {
return lineTotal(line);
},
liHasServerTotal(line) {
return hasServerTotal(line);
},
/** The hidden note after a line total that waits for a valid value. */
liLastValidText(line) {
return this.liLineInvalid(line) ? ` ${text.lastValid || ''}` : '';
},
/** True while the quantity or unit price of the line is not valid. */
liLineInvalid(line) {
return Boolean(this.liInvalid[`${line.id}|quantity`] || this.liInvalid[`${line.id}|unitPrice`]);
},
liLabel(kind, index) {
return String(text[kind] || ':number').replace(':number', String(index + 1));
},
liLineName(index) {
const first = String(this.liLines[index]?.description || '')
.split('\n')[0]
.trim();
if (!first) return this.liLabel('line', index);
return first.length > 40 ? `${first.slice(0, 39)}…` : first;
},
liUnitOptions(line) {
return units.some((option) => option.key === line.unit) || line.unit === ''
? units
: [...units, { key: line.unit, label: line.unit }];
},
liUnitLabel(line) {
return units.find((option) => option.key === line.unit)?.label ?? line.unit;
},
liVatLabel(line) {
return vatRates.find((option) => option.key === line.vatRate)?.label ?? line.vatRate;
},
liFieldLocked(line, field) {
return isFieldLocked(line, field);
},
liIsLocked(line) {
return isLineLocked(line);
},
liIsMovable(line) {
return isLineMovable(line);
},
liIsRemovable(line) {
return isLineRemovable(line);
},
/** The lock mark's name: "Line 2 is locked: From time entries". */
liLockLabel(line, index) {
const reason = String(line?.lockedReason ?? '');
const template = reason ? text.lockedBecause : text.locked;
return String(template || ':number')
.replace(':number', String(index + 1))
.replace(':reason', reason);
},
liVatOptions(line) {
return vatRates.some((option) => option.key === line.vatRate)
? vatRates
: [...vatRates, { key: line.vatRate, label: line.vatRate || '—' }];
},
/* --------------------------------------------------------- errors */
liIsInvalid(line, field) {
const key = `${line.id}|${field}`;
return Boolean(this.liInvalid[key] || this.liServerErrors[key]);
},
liHasServerError(id, field) {
return Boolean(this.liServerErrors[`${id}|${field}`]);
},
/** The messages under the table: general ones, then per cell in line order. */
get liErrorList() {
const list = this.liGeneralErrors.map((message, index) => ({
key: `general-${index}`,
id: null,
line: null,
field: null,
text: message,
}));
this.liLines.forEach((line, index) => {
for (const field of EDITABLE) {
const error = this.liServerErrors[`${line.id}|${field}`];
if (!error) continue;
list.push({
key: `${line.id}|${field}`,
id: error.id,
line: String(line.id),
field,
text: String(text.errorLine || ':message')
.replace(':number', String(index + 1))
.replace(':message', error.message),
});
}
});
return list;
},
liDescribedBy(line, field) {
const key = `${line.id}|${field}`;
const reason = this.liInvalid[key];
const ids = [
this.liServerErrors[key]?.id,
reason ? (reason === 'format' ? config.invalidId : `${config.baseId}-range-${reason}`) : null,
].filter(Boolean);
return ids.length ? ids.join(' ') : null;
},
/** reason: 'format', 'quantity' or 'unitPrice' (out of bounds), or false. */
liSetInvalid(line, field, reason) {
const key = `${line.id}|${field}`;
if (reason) this.liInvalid[key] = reason;
else if (this.liInvalid[key]) delete this.liInvalid[key];
},
/* -------------------------------------------------------- commits */
liCommitText(line, field, el) {
if (!this.liLines.includes(line) || !el || isFieldLocked(line, field)) return;
const value = String(el.value ?? '');
if (line[field] === value) return;
line[field] = value;
this.liChanged(line, field);
},
liCommitNumber(line, field, el) {
if (!this.liLines.includes(line) || !el || isFieldLocked(line, field)) return;
const isQuantity = field === 'quantity';
const parsed = parseScaled(el.value, isQuantity ? 3 : digits, separators);
if (parsed === null) {
this.liSetInvalid(line, field, 'format');
return;
}
const value = isQuantity ? parsed / 1000 : parsed;
if (Math.abs(value) > (isQuantity ? this.liMaxQuantity : this.liMaxUnitPrice)) {
this.liSetInvalid(line, field, field);
return;
}
this.liSetInvalid(line, field, false);
el.value = isQuantity ? quantity.format(value) : amount.format(value / scale);
if (line[field] === value) return;
line[field] = value;
// The server total was for the old quantity or price.
if ('total' in line) delete line.total;
this.liChanged(line, field);
},
/** Escape: back to the committed value. */
liRevert(line, field, el) {
this.liSetInvalid(line, field, false);
el.value =
field === 'quantity' ? this.liQuantityText(line) : this.liPriceText(line);
el.select?.();
},
liChanged(line, field) {
const key = `${line.id}|${field}`;
if (this.liServerErrors[key]) delete this.liServerErrors[key];
this.liCommit();
},
/** Every committed change: hidden inputs, then the change event. */
liCommit() {
this.liSyncHidden();
const totals = this.liComputedTotals;
this.liEmit('line-items-change', {
lines: plain(this.liLines),
totals: {
subtotal: totals.subtotal,
vat: totals.vat.map(({ key, label, rate, base, amount: vat }) => ({
key,
label,
rate,
base,
amount: vat,
})),
total: totals.total,
},
});
},
liEmit(name, detail) {
(root || this.$root).dispatchEvent(
new CustomEvent(name, { detail, bubbles: true, composed: true }),
);
},
liAnnounce(message) {
if (this.liAnnouncement === message) {
this.liAnnouncement = '';
this.$nextTick(() => {
this.liAnnouncement = message;
});
return;
}
this.liAnnouncement = message;
},
/** Rebuild the hidden name[i][field] inputs (textContent-safe DOM). */
liSyncHidden() {
const box = root?.querySelector('[data-li-hidden]');
if (!box || !config.name) return;
const inputs = [];
this.liLines.forEach((line, index) => {
for (const field of FIELDS) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = `${config.name}[${index}][${field}]`;
input.value =
line[field] === null || line[field] === undefined
? ''
: String(line[field]);
inputs.push(input);
}
});
box.replaceChildren(...inputs);
},
/** After an outside model change: re-render kept rows' text. */
liRenderInputs() {
if (!root) return;
for (const row of root.querySelectorAll('[data-li-row]')) {
const line = this.liLines.find(
(item) => String(item.id) === row.dataset.lineId,
);
if (!line) continue;
for (const el of row.querySelectorAll('[data-li-field]')) {
const field = el.dataset.liField;
if (field === 'quantity') el.value = this.liQuantityText(line);
else if (field === 'unitPrice') el.value = this.liPriceText(line);
else el.value = line[field] ?? '';
}
}
},
/* -------------------------------------------------- add / remove */
liFocus(id, field) {
this.$nextTick(() => {
const row = root?.querySelector(
`[data-li-row][data-line-id="${CSS.escape(String(id))}"]`,
);
row?.querySelector(
field === 'handle' ? '[data-li-handle]' : `[data-li-field="${field}"]`,
)?.focus();
});
},
liAdd() {
if (this.liLines.length >= this.liMaxLines) {
this.liAnnounce(
String(text.limit || '').replace(':count', String(this.liMaxLines)),
);
return;
}
const line = clean(
{ quantity: 1, unitPrice: 0 },
new Set(this.liLines.map((item) => String(item.id))),
);
this.liLines.push(line);
this.liCommit();
this.liAnnounce(this.liLabel('added', this.liLines.length - 1));
this.liFocus(line.id, 'description');
},
liRemove(index) {
const line = this.liLines[index];
if (!line || !isLineRemovable(line)) return;
const name = this.liLineName(index);
for (const key of Object.keys(this.liInvalid))
if (key.startsWith(`${line.id}|`)) delete this.liInvalid[key];
this.liLines.splice(index, 1);
this.liCommit();
this.liAnnounce(String(text.removed || '').replace(':line', name));
const next = this.liLines[index];
if (next) this.liFocus(next.id, 'description');
else this.$nextTick(() => root?.querySelector('[data-li-add]')?.focus());
},
/* -------------------------------------------------------- reorder */
liRows() {
return root ? [...root.querySelectorAll('[data-li-row]')] : [];
},
/** FLIP: rows glide from their old place; none under reduced motion. */
liGlide(before) {
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
this.$nextTick(() => {
for (const row of this.liRows()) {
const previous = before.get(row.dataset.lineId);
if (!previous) continue;
const dy = previous - row.getBoundingClientRect().top;
if (!dy) continue;
row.getAnimations?.().forEach((animation) => animation.cancel());
row.animate?.(
[{ transform: `translateY(${dy}px)` }, { transform: 'none' }],
{
duration: 200,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
},
);
}
});
},
/** Move a line; returns the new index or null. */
liReorder(from, to) {
const dest = Math.max(0, Math.min(to, this.liLines.length - 1));
if (from === dest || !this.liLines[from]) return null;
const before = new Map(
this.liRows().map((row) => [
row.dataset.lineId,
row.getBoundingClientRect().top,
]),
);
const [line] = this.liLines.splice(from, 1);
this.liLines.splice(dest, 0, line);
this.liGlide(before);
return dest;
},
liAnnounceMove(index) {
this.liAnnounce(
String(text.moved || '')
.replace(':line', this.liLineName(index))
.replace(':position', String(index + 1))
.replace(':count', String(this.liLines.length)),
);
},
liMove(from, to) {
if (!this.liLines[from] || !isLineMovable(this.liLines[from])) return;
const dest = this.liReorder(from, to);
if (dest === null) return;
const line = this.liLines[dest];
this.liCommit();
this.liAnnounceMove(dest);
this.liFocus(line.id, 'handle');
},
liHandleKey(event, index) {
if (event.ctrlKey || event.metaKey || event.shiftKey) return;
const targets = {
ArrowUp: index - 1,
ArrowDown: index + 1,
Home: 0,
End: this.liLines.length - 1,
};
if (!(event.key in targets)) return;
if (event.altKey && (event.key === 'Home' || event.key === 'End')) return;
event.preventDefault();
this.liMove(index, targets[event.key]);
},
liDragStart(event, index) {
if (event.button !== undefined && event.button !== 0) return;
const line = this.liLines[index];
if (!line || drag || !isLineMovable(line)) return;
drag = { self: this, id: line.id, origin: index };
this.liDragId = line.id;
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
event.preventDefault();
event.currentTarget?.focus?.();
},
liDragMove(event) {
if (!drag) return;
const from = this.liLines.findIndex((line) => line.id === drag.id);
let target = from;
// Layout positions (offsetTop), not the gliding transforms.
for (const [index, row] of this.liRows().entries()) {
const top =
(row.offsetParent?.getBoundingClientRect().top ?? 0) + row.offsetTop;
if (event.clientY < top + row.offsetHeight / 2) {
target = index > from ? index - 1 : index;
break;
}
target = index;
}
if (target !== from) this.liReorder(from, target);
},
liDragEnd() {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
if (!drag) return;
const { id, origin } = drag;
drag = null;
this.liDragId = null;
const index = this.liLines.findIndex((line) => line.id === id);
if (index === -1 || index === origin) return;
this.liCommit();
this.liAnnounceMove(index);
this.liFocus(id, 'handle');
},
};
});
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
2.0.1 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- 2026-10-01
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0
Migration history
-
1.1.0 → 2.0.0— A line with only lockedFields can now be moved and removed; a locked line still cannot. The lock mark moved from the drag-handle column to the end of the row.