Timesheet
Time entry by the week — a grid of duration cells per row and day with live totals, locked days and a save action.
Timesheet Grid — A weekly timesheet: rows by seven day columns of compact duration cells (1:30, 90m, 1,5u) with live row, day and grand totals, locked rows and days, cells with several entries that open them, keyboard navigation, a dirty count and a Save action. Emits timesheet-change and timesheet-save, binds the cells map with wire:model, calls a Livewire save method or posts hidden inputs.
Preview
21 – 27 Sep 2026
| Entry | Monday 21 September | Tuesday 22 September | Wednesday 23 September | Thursday 24 September | Friday 25 September | Saturday 26 September | Sunday 27 September | Total |
|---|---|---|---|---|---|---|---|---|
| Research Discovery | 6:30 | |||||||
| Design Interface | 11:30 | |||||||
| Support | 1:45 | |||||||
| Meetings Internal | 0:00 | |||||||
| Administration | 1:00 | 0:30 | 1:30 | |||||
| Total | 7:00 Below the daily target | 7:45 Below the daily target | 4:00 Below the daily target | 2:30 Below the daily target | 0:00 Below the daily target | 0:00 | 0:00 | 21:15 |
{{-- An editable week: type 1:30, 90m or 1,5u in a cell and the totals follow.
The last row has two locked days; days under the 8-hour target are marked. --}}
<x-ui.timesheet-grid
week-start="2026-09-21"
today="2026-09-24"
:daily-target="480"
:rows="[
['id' => 101, 'label' => __('Research'), 'sublabel' => __('Discovery'), 'cells' => ['2026-09-21' => 120, '2026-09-22' => 90, '2026-09-23' => 180]],
['id' => 102, 'label' => __('Design'), 'sublabel' => __('Interface'), 'cells' => ['2026-09-21' => 240, '2026-09-22' => 300, '2026-09-24' => 150]],
['id' => 103, 'label' => __('Support'), 'cells' => ['2026-09-22' => 45, '2026-09-23' => 60]],
['id' => 104, 'label' => __('Meetings'), 'sublabel' => __('Internal'), 'cells' => []],
['id' => 105, 'label' => __('Administration'), 'lockedDays' => ['2026-09-21', '2026-09-22'], 'cells' => ['2026-09-21' => 60, '2026-09-22' => 30]],
]"
>
<x-slot:footer>
<x-ui.button variant="outline" size="sm">
<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 row') }}
</x-ui.button>
</x-slot:footer>
</x-ui.timesheet-grid>
Installation
php artisan ui:add timesheet-grid
Note
This component ships an Alpine behavior module at
resources/js/ui/timesheet-grid.js. Import it once from your bundle so it registers on alpine:init:
import './timesheet-grid.js';
Registry contract
php artisan ui:add timesheet-grid
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/timesheet-grid.blade.php -
resources/js/ui/timesheet-grid.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: Timesheet Grid (`timesheet-grid`)
A weekly timesheet: rows by seven day columns of compact duration cells (1:30, 90m, 1,5u) with live row, day and grand totals, locked rows and days, cells with several entries that open them, keyboard navigation, a dirty count and a Save action. Emits timesheet-change and timesheet-save, binds the cells map with wire:model, calls a Livewire save method or posts hidden inputs.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add timesheet-grid
```
## Usage
```blade
{{-- An editable week: type 1:30, 90m or 1,5u in a cell and the totals follow.
The last row has two locked days; days under the 8-hour target are marked. --}}
<x-ui.timesheet-grid
week-start="2026-09-21"
today="2026-09-24"
:daily-target="480"
:rows="[
['id' => 101, 'label' => __('Research'), 'sublabel' => __('Discovery'), 'cells' => ['2026-09-21' => 120, '2026-09-22' => 90, '2026-09-23' => 180]],
['id' => 102, 'label' => __('Design'), 'sublabel' => __('Interface'), 'cells' => ['2026-09-21' => 240, '2026-09-22' => 300, '2026-09-24' => 150]],
['id' => 103, 'label' => __('Support'), 'cells' => ['2026-09-22' => 45, '2026-09-23' => 60]],
['id' => 104, 'label' => __('Meetings'), 'sublabel' => __('Internal'), 'cells' => []],
['id' => 105, 'label' => __('Administration'), 'lockedDays' => ['2026-09-21', '2026-09-22'], 'cells' => ['2026-09-21' => 60, '2026-09-22' => 30]],
]"
>
<x-slot:footer>
<x-ui.button variant="outline" size="sm">
<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 row') }}
</x-ui.button>
</x-slot:footer>
</x-ui.timesheet-grid>
```
## Props
- `rows` (array, default `[]`) — The rows: a list of { id: int|string, label: string, sublabel?: string, href?: string, locked?: bool, lockedDays?: list<Y-m-d>, cells?: array<Y-m-d, int|null>, entries?: array<Y-m-d, int> }. cells holds whole minutes per date; dates outside the week are ignored. entries is the number of time entries behind a cell: from openFrom on, the cell is a button that opens them. Keep the list generic data from your app; the grid prints it with {{ }} only.
- `weekStart` (string|DateTimeInterface|null, default `null`) — First day of the week (Y-m-d or a Carbon/DateTime). Null uses the Monday of the current week. The seven column headers are the short weekday and day number from Carbon isoFormat in the app locale.
- `today` (string|DateTimeInterface|null, default `null`) — The date to mark as today (aria-current="date" and a filled day number). Null uses the current date.
- `todayTone` (neutral|primary, default `neutral`) — Fill of the today day number: neutral uses the foreground colour, so a product that keeps primary for its one main action and focus is not diluted; primary uses the brand primary.
- `format` (clock|hm|decimal, default `clock`) — How cells and totals render: clock ("1:30"), hm ("1h 30m") or decimal ("1.5"). Same rules as duration-input.
- `bareUnit` (minutes|hours, default `minutes`) — How a bare number is read: "90" is 90 minutes by default; set hours so "2" is 2 hours.
- `decimalSeparator` (string|null, default `null`) — Decimal separator for format="decimal". Defaults from the app locale ("," for nl).
- `name` (string|null, default `null`) — Plain form mode: renders one hidden input name[rowId][Y-m-d] per editable cell with whole minutes (empty for no value), and without saveMethod the Save button is a submit button. Locked cells are not posted.
- `saveMethod` (string|null, default `null`) — Livewire method called as $wire[saveMethod](changes) on Save or Ctrl/Cmd+S, where changes is the timesheet-save changes list. The dirty state clears when the call resolves; a rejection keeps it and announces an error.
- `saveLabel` (string|null, default `null`) — Label of the Save button. Defaults to a translated "Save".
- `caption` (string|null, default `null`) — Visually hidden table caption. Defaults to a translated "Timesheet for the week of :date".
- `rowHeading` (string|null, default `null`) — Header of the row label column. Defaults to a translated "Entry".
- `locked` (bool, default `false`) — Makes every cell read-only (a submitted or approved week). No Save area is rendered.
- `readonly` (bool, default `false`) — Alias of locked.
- `dailyTarget` (int|null, default `null`) — Minutes expected per working day. A weekday total below it gets a warning dot, data-under-target="true" and a visually hidden "Below the daily target". Weekends are exempt.
- `invalidMessage` (string|null, default `null`) — Overrides the translated message shown while a cell holds text that cannot be read as a duration of at most 24 hours.
- `withNavigation` (bool, default `true`) — Renders the default week header (week range, Previous week, This week, Next week) when no header slot is given. The buttons dispatch timesheet-navigate.
- `maxRows` (int, default `200`) — Largest supported row count (default 200). More rows throw an InvalidArgumentException, so a grid never renders thousands of inputs. Page or filter the rows on the server (by project, team or person) and keep a page to about 50 rows; raise maxRows only after you have measured the page.
- `openFrom` (int, default `2`) — A cell whose rows[].entries count for that date is at least this number renders as a button with the total instead of an input, and dispatches timesheet-cell-open. The grid never edits such a cell (it is left out of changes and hidden inputs); the consumer shows the entries (a dialog, a sheet) and edits them there. Set 1 to open every cell with an entry.
- `emptyTitle` (string|null, default `null`) — Title of the empty state. Defaults to a translated "No rows this week".
- `emptyDescription` (string|null, default `null`) — Text under the empty title. Defaults to a translated "Add a row to start entering time."; an empty string hides it.
## Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- Entering or reviewing time per row (a project, a task, an activity) for each day of one week, with totals per row, per day and for the week.
- Needing a week of durations saved in one action from a Livewire component (saveMethod, wire:model) or a plain form post (name).
## Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- Typing one duration: use <x-ui.duration-input>.
- Showing a read-only table of records with sorting and filtering: use <x-ui.data-table>.
- Planning availability or events on an hour-by-day calendar: use the scheduling-weekly block or <x-ui.calendar>.
## Anti-patterns
- Adding display ornament without informational value
## Rules
- Use the `<brok:timesheet-grid>` tag (or `<x-ui.timesheet-grid>`) 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/timesheet-grid
- Registry JSON (files, props, contract): https://brokui.dev/r/open/timesheet-grid.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 cells map { [rowId]: { [date]: minutes|null } }; wire:model
binds the same shape inside Livewire. name adds hidden inputs for a form post. --}}
<div
x-data="{ cells: { 'a': { '2026-09-21': 90, '2026-09-22': null }, 'b': { '2026-09-21': 30 } } }"
class="flex w-full flex-col gap-2"
>
<x-ui.timesheet-grid
x-model="cells"
name="entries"
week-start="2026-09-21"
today="2026-09-24"
:with-navigation="false"
:rows="[
['id' => 'a', 'label' => __('Research'), 'cells' => ['2026-09-21' => 90]],
['id' => 'b', 'label' => __('Design'), 'cells' => ['2026-09-21' => 30]],
]"
/>
<p class="text-sm text-muted-foreground">{{ __('Bound Monday values:') }} <span data-bound x-text="cells.a['2026-09-21'] + ' / ' + cells.b['2026-09-21']"></span></p>
<x-ui.button variant="outline" size="sm" class="self-start" x-on:click="cells = { ...cells, b: { ...cells.b, '2026-09-23': 150 } }">{{ __('Set Design on Wednesday to 2:30') }}</x-ui.button>
</div>
{{-- No rows yet: the table keeps its shape with an empty state, and the footer
slot holds the control that adds the first row. --}}
<x-ui.timesheet-grid week-start="2026-09-21" today="2026-09-24" :rows="[]">
<x-slot:footer>
<x-ui.button size="sm">
<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 row') }}
</x-ui.button>
</x-slot:footer>
</x-ui.timesheet-grid>
{{-- Cells with several time entries: rows[].entries holds the count per date.
From two entries the cell shows the total on a button and dispatches
timesheet-cell-open { rowId, rowKey, date, minutes, entries, locked };
the app shows the entries (here a list, in an app a dialog or sheet). --}}
<div
x-data="{
opened: null,
entries: {
'201|2026-09-22': [['09:00', '10:30', '{{ __('Wireframes') }}'], ['13:00', '14:00', '{{ __('Review with the team') }}']],
'202|2026-09-23': [['09:00', '09:45', '{{ __('Inbox') }}'], ['11:00', '11:30', '{{ __('Invoices') }}'], ['16:00', '16:45', '{{ __('Planning') }}']],
},
}"
x-on:timesheet-cell-open="opened = $event.detail"
class="flex w-full flex-col gap-4"
>
<x-ui.timesheet-grid
week-start="2026-09-21"
today="2026-09-24"
:with-navigation="false"
:rows="[
['id' => 201, 'label' => __('Design review for the customer portal redesign'), 'cells' => ['2026-09-21' => 60, '2026-09-22' => 150], 'entries' => ['2026-09-21' => 1, '2026-09-22' => 2]],
['id' => 202, 'label' => __('Administration'), 'cells' => ['2026-09-23' => 120], 'entries' => ['2026-09-23' => 3]],
]"
/>
<section data-entries-panel class="flex flex-col gap-2" style="display: none" x-show="opened">
<h3 class="text-sm font-semibold" x-text="opened ? '{{ __('Entries on') }} ' + opened.date : ''"></h3>
<ul class="flex flex-col gap-1 text-sm">
<template x-for="entry in (opened ? entries[opened.rowKey + '|' + opened.date] || [] : [])">
<li class="flex gap-2"><span class="tabular-nums text-muted-foreground" x-text="entry[0] + ' – ' + entry[1]"></span><span x-text="entry[2]"></span></li>
</template>
</ul>
</section>
</div>
{{-- A submitted week: every cell is read-only text with a lock, still reachable
with the arrow keys. The header slot replaces the default week navigation. --}}
<x-ui.timesheet-grid
week-start="2026-09-14"
today="2026-09-24"
locked
:rows="[
['id' => 'r1', 'label' => __('Research'), 'cells' => ['2026-09-14' => 240, '2026-09-15' => 180, '2026-09-16' => 300, '2026-09-17' => 120, '2026-09-18' => 60]],
['id' => 'r2', 'label' => __('Design'), 'cells' => ['2026-09-14' => 240, '2026-09-15' => 300, '2026-09-16' => 180, '2026-09-17' => 360, '2026-09-18' => 420]],
['id' => 'r3', 'label' => __('Support'), 'cells' => ['2026-09-17' => 30]],
]"
>
<x-slot:header>
<p class="text-sm font-semibold">{{ __('14 – 20 Sep 2026') }}</p>
<x-ui.badge variant="secondary">{{ __('Submitted') }}</x-ui.badge>
</x-slot:header>
</x-ui.timesheet-grid>
Long Content
{{-- A narrow column with long labels: the labels wrap, the table scrolls
sideways inside its own frame and the label column stays in place. --}}
<div class="w-full max-w-sm">
<x-ui.timesheet-grid
week-start="2026-09-21"
today="2026-09-24"
format="hm"
:rows="[
['id' => 1, 'label' => __('Quarterly compliance review of the vendor onboarding documentation'), 'sublabel' => __('A deliberately long secondary line that has to wrap without clipping'), 'href' => '#', 'cells' => ['2026-09-21' => 95, '2026-09-23' => 600]],
['id' => 2, 'label' => __('Supercalifragilisticexpialidociousworkstreamwithoutspaces'), 'locked' => true, 'cells' => ['2026-09-22' => 45]],
]"
/>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| rows | array | [] | The rows: a list of { id: int|string, label: string, sublabel?: string, href?: string, locked?: bool, lockedDays?: list<Y-m-d>, cells?: array<Y-m-d, int|null>, entries?: array<Y-m-d, int> }. cells holds whole minutes per date; dates outside the week are ignored. entries is the number of time entries behind a cell: from openFrom on, the cell is a button that opens them. Keep the list generic data from your app; the grid prints it with {{ }} only. |
| weekStart | string | DateTimeInterface | null | null | First day of the week (Y-m-d or a Carbon/DateTime). Null uses the Monday of the current week. The seven column headers are the short weekday and day number from Carbon isoFormat in the app locale. |
| today | string | DateTimeInterface | null | null | The date to mark as today (aria-current="date" and a filled day number). Null uses the current date. |
| todayTone | neutral | primary | neutral | Fill of the today day number: neutral uses the foreground colour, so a product that keeps primary for its one main action and focus is not diluted; primary uses the brand primary. |
| format | clock | hm | decimal | clock | How cells and totals render: clock ("1:30"), hm ("1h 30m") or decimal ("1.5"). Same rules as duration-input. |
| bareUnit | minutes | hours | minutes | How a bare number is read: "90" is 90 minutes by default; set hours so "2" is 2 hours. |
| decimalSeparator | string | null | null | Decimal separator for format="decimal". Defaults from the app locale ("," for nl). |
| name | string | null | null | Plain form mode: renders one hidden input name[rowId][Y-m-d] per editable cell with whole minutes (empty for no value), and without saveMethod the Save button is a submit button. Locked cells are not posted. |
| saveMethod | string | null | null | Livewire method called as $wire[saveMethod](changes) on Save or Ctrl/Cmd+S, where changes is the timesheet-save changes list. The dirty state clears when the call resolves; a rejection keeps it and announces an error. |
| saveLabel | string | null | null | Label of the Save button. Defaults to a translated "Save". |
| caption | string | null | null | Visually hidden table caption. Defaults to a translated "Timesheet for the week of :date". |
| rowHeading | string | null | null | Header of the row label column. Defaults to a translated "Entry". |
| locked | bool | false | Makes every cell read-only (a submitted or approved week). No Save area is rendered. |
| readonly | bool | false | Alias of locked. |
| dailyTarget | int | null | null | Minutes expected per working day. A weekday total below it gets a warning dot, data-under-target="true" and a visually hidden "Below the daily target". Weekends are exempt. |
| invalidMessage | string | null | null | Overrides the translated message shown while a cell holds text that cannot be read as a duration of at most 24 hours. |
| withNavigation | bool | true | Renders the default week header (week range, Previous week, This week, Next week) when no header slot is given. The buttons dispatch timesheet-navigate. |
| maxRows | int | 200 | Largest supported row count (default 200). More rows throw an InvalidArgumentException, so a grid never renders thousands of inputs. Page or filter the rows on the server (by project, team or person) and keep a page to about 50 rows; raise maxRows only after you have measured the page. |
| openFrom | int | 2 | A cell whose rows[].entries count for that date is at least this number renders as a button with the total instead of an input, and dispatches timesheet-cell-open. The grid never edits such a cell (it is left out of changes and hidden inputs); the consumer shows the entries (a dialog, a sheet) and edits them there. Set 1 to open every cell with an entry. |
| emptyTitle | string | null | null | Title of the empty state. Defaults to a translated "No rows this week". |
| emptyDescription | string | null | null | Text under the empty title. Defaults to a translated "Add a row to start entering time."; an empty string hides it. |
Slots
header— Replaces the default week header, for your own week navigation (for example links or buttons with the week label).footer— Start side of the footer bar, for an add-row control (a button, a combobox). The dirty count and Save button stay on the end side.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- A cell commits on blur, Enter or a move. Text is parsed by duration.js (1:30, 90, 90m, 1.5h, 1h30, 1,5u) into whole minutes and re-rendered in format; empty text is no value. Text that cannot be parsed or is over 24 hours stays on screen with aria-invalid, the invalid border and aria-describedby pointing at one shared message; the committed value does not change. Escape restores the committed text.
- Row totals, day totals and the grand total are computed from the committed minutes and update live. The first paint is computed on the server, so the table is complete without JavaScript (editing needs it; a noscript line says so).
- Events (bubbling CustomEvents on the root): timesheet-change { rowId, date, minutes, previous } on each committed value change (minutes and previous are int|null, date is Y-m-d, rowId keeps the type of rows[].id); timesheet-save { changes: [{ rowId, date, minutes }] } on Save or Ctrl/Cmd+S; timesheet-navigate { direction: 'prev'|'next'|'today', weekStart: Y-m-d, dirty: bool } from the default header; timesheet-cell-open { rowId, rowKey, date, minutes, entries, locked } when a cell with several entries is activated (click, Enter or Space).
- Dirty state: changes are the editable cells whose value differs from the first render (or the last save). The root carries data-dirty="true|false" and data-dirty-count; the footer shows a translated count and the Save button only while dirty. Save is refused while a cell is invalid: focus moves to that cell and the status region says why.
- Saving: with saveMethod inside Livewire the grid calls $wire[saveMethod](changes), keeps the button busy (disabled, aria-busy) and clears the dirty state when the promise resolves; a rejection keeps it and announces an error. In plain form mode (name, no saveMethod) Save submits the enclosing form and Ctrl/Cmd+S calls form.requestSubmit(). Otherwise handle timesheet-save yourself and dispatch timesheet-saved (clears the dirty state and announces it) or timesheet-save-failed on the grid element.
- Binding: x-modelable exposes the cells map { [rowId]: { [Y-m-d]: minutes|null } } (object keys are strings), so x-model and wire:model bind it. A value set from outside re-renders the cells; keys missing from it keep their value, so an empty array or object leaves the grid unchanged. Inside Livewire give the grid wire:key with the week (wire:key="timesheet-{{ $weekStart }}") so a new week mounts a fresh grid.
- Keyboard (a table with inputs, not an ARIA grid): Tab and Shift+Tab visit the editable cells and the cells with several entries in reading order; the arrow keys move between all cells including locked ones; Left/Right leave a cell only when the caret is at that edge, the text is fully selected or the cell is empty, and follow the visual direction (swapped under dir="rtl"); Up/Down move rows; Enter commits and moves down (on a cell with several entries Enter and Space open it), Shift+Enter up; Escape reverts the cell; Home/End go to the first/last day of the row, Ctrl/Cmd+Home/End to the first/last cell; Ctrl/Cmd+S saves. Focusing a cell selects its text.
- Cells with several entries: pass rows[].entries (a count per date). From openFrom (default 2) the cell shows its total on a quiet button and dispatches timesheet-cell-open; a total of several entries cannot be typed over without losing which entry changed. Open your own dialog or sheet with the entries of { rowKey, date }, and after an edit send the new total back through the cells model (wire:model) or re-render the grid.
- Livewire: <x-ui.timesheet-grid :rows="$rows" :week-start="$weekStart" wire:model="cells" save-method="saveTimesheet" wire:key="timesheet-{{ $weekStart }}" x-on:timesheet-navigate="$wire.set('weekStart', $event.detail.weekStart)" />. saveTimesheet(array $changes) receives a list of ['rowId' => …, 'date' => 'Y-m-d', 'minutes' => int|null]; authorize and validate it on the server (never trust locked cells from the client).
- Size: at most maxRows (default 200) rows, each with seven inputs. Page or filter on the server (a project, a team, a person) and keep a page to about 50 rows, so typing and totals stay instant; do not raise the limit to show everything at once.
- 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
Present data for rapid scanning.
Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- Entering or reviewing time per row (a project, a task, an activity) for each day of one week, with totals per row, per day and for the week.
- Needing a week of durations saved in one action from a Livewire component (saveMethod, wire:model) or a plain form post (name).
Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- Typing one duration: use <x-ui.duration-input>.
- Showing a read-only table of records with sorting and filtering: use <x-ui.data-table>.
- Planning availability or events on an hour-by-day calendar: use the scheduling-weekly block or <x-ui.calendar>.
Use instead
- Table for exact comparison
- Plain text for a single value
Anti-patterns
- Adding display ornament without informational value
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Enter Escape ArrowUp ArrowDown ArrowLeft ArrowRight Home End
- Focus
managed
- A real <table> with a visually hidden <caption>, <th scope="col"> day headers (short weekday and day number, with the full date in visually hidden text and aria-current="date" on today) and <th scope="row"> row labels. Totals sit in a Total column and a Total footer row, so their headers name them.
- Each editable cell is a text input named ":day, :row" (for example "Mon 22 Sep, Design review"). Locked cells are role="textbox" aria-readonly="true" text with the same name, a lock icon and tabindex="-1": reachable with the arrow keys, skipped by Tab.
- A cell with several entries is a native <button> named ":count entries, :duration, :day, :row" (for example "3 entries, 2:30, Tue 22 Sep, Design review"); its name follows the total when the model changes. A locked one keeps its lock icon and still opens, so the entries can be read.
- A visually hidden role="status" region announces save results only (saved, failed, fix the highlighted cells). Totals are not live regions.
- Durations read left to right (dir="ltr") with tabular numerals under dir="rtl"; the row label column is sticky at the logical start edge and at least 8rem wide, so long titles stay readable on a phone, while the table scrolls sideways on narrow screens. No motion is added.
- 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="timesheet-grid-{{ $record->id }}">
{{-- An editable week: type 1:30, 90m or 1,5u in a cell and the totals follow.
The last row has two locked days; days under the 8-hour target are marked. --}}
<x-ui.timesheet-grid
week-start="2026-09-21"
today="2026-09-24"
:daily-target="480"
:rows="[
['id' => 101, 'label' => __('Research'), 'sublabel' => __('Discovery'), 'cells' => ['2026-09-21' => 120, '2026-09-22' => 90, '2026-09-23' => 180]],
['id' => 102, 'label' => __('Design'), 'sublabel' => __('Interface'), 'cells' => ['2026-09-21' => 240, '2026-09-22' => 300, '2026-09-24' => 150]],
['id' => 103, 'label' => __('Support'), 'cells' => ['2026-09-22' => 45, '2026-09-23' => 60]],
['id' => 104, 'label' => __('Meetings'), 'sublabel' => __('Internal'), 'cells' => []],
['id' => 105, 'label' => __('Administration'), 'lockedDays' => ['2026-09-21', '2026-09-22'], 'cells' => ['2026-09-21' => 60, '2026-09-22' => 30]],
]"
>
<x-slot:footer>
<x-ui.button variant="outline" size="sm">
<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 row') }}
</x-ui.button>
</x-slot:footer>
</x-ui.timesheet-grid>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{--
Props:
- rows: list of rows. Each row: id (int|string), label, sublabel?, href?,
locked? (bool), lockedDays? (list of Y-m-d), cells? (map of Y-m-d to
minutes or null), entries? (map of Y-m-d to the number of time entries
behind that cell).
- openFrom: a cell with at least this many entries (default 2) is a
button that shows the total and dispatches timesheet-cell-open, so the
consumer can show the entries; it is not typed into.
- weekStart: first day of the week (Y-m-d or a DateTimeInterface). The
default is the Monday of the current week. Seven day columns follow it.
- today: the day to mark as today (default: the current date).
- todayTone: how the today date is marked. neutral (default) fills it
with the foreground colour; primary uses the brand primary, for a
product where primary is not reserved for the one main action.
- format / bareUnit / decimalSeparator: the duration-input text rules.
- name: renders hidden inputs name[rowId][Y-m-d] with whole minutes for a
plain form post. Without saveMethod the Save button submits the form.
- saveMethod: a Livewire method called with the list of changes.
- locked / readonly: every cell is read-only.
- dailyTarget: minutes per working day; a lower day total is marked.
- withNavigation: the default week header when no header slot is given.
- maxRows: the largest supported row count (larger input throws). Page
or filter the rows on the server; keep a page well under the limit.
- emptyTitle / emptyDescription: the text of the empty state.
Slots: header (week navigation), footer (an add-row control).
--}}
@props([
'rows' => [],
'weekStart' => null,
'today' => null,
'todayTone' => 'neutral',
'format' => 'clock',
'bareUnit' => 'minutes',
'decimalSeparator' => null,
'name' => null,
'saveMethod' => null,
'saveLabel' => null,
'caption' => null,
'rowHeading' => null,
'locked' => false,
'readonly' => false,
'dailyTarget' => null,
'invalidMessage' => null,
'withNavigation' => true,
'maxRows' => 200,
'openFrom' => 2,
'emptyTitle' => null,
'emptyDescription' => null,
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$input = $styles['input'];
$format = in_array($format, ['clock', 'hm', 'decimal'], true) ? $format : 'clock';
$bareUnit = $bareUnit === 'hours' ? 'hours' : 'minutes';
$gridLocked = (bool) $locked || (bool) $readonly;
$locale = app()->getLocale();
$rows = array_values(is_iterable($rows) ? collect($rows)->all() : []);
if (count($rows) > (int) $maxRows) {
throw new \InvalidArgumentException('timesheet-grid supports at most '.(int) $maxRows.' rows; '.count($rows).' given. Page or filter the rows on the server.');
}
$toDay = function ($value, $fallback) {
if ($value instanceof \DateTimeInterface) {
return \Illuminate\Support\Carbon::instance($value)->startOfDay();
}
if (is_string($value) && trim($value) !== '') {
try {
return \Illuminate\Support\Carbon::parse($value)->startOfDay();
} catch (\Throwable) {
}
}
return $fallback;
};
$start = $toDay($weekStart, now()->startOfWeek(\Illuminate\Support\Carbon::MONDAY)->startOfDay())->locale($locale);
$todayDate = $toDay($today, now()->startOfDay())->toDateString();
$days = [];
for ($i = 0; $i < 7; $i++) {
$day = $start->copy()->addDays($i);
$days[] = [
'date' => $day->toDateString(),
'weekday' => $day->isoFormat('ddd'),
'number' => $day->isoFormat('D'),
'long' => $day->isoFormat('dddd D MMMM'),
'short' => $day->isoFormat('ddd D MMM'),
'weekend' => $day->isWeekend(),
'today' => $day->toDateString() === $todayDate,
];
}
$dates = array_column($days, 'date');
// "This week" keeps the weekday the consumer's weeks start on.
$todayCarbon = \Illuminate\Support\Carbon::parse($todayDate);
$todayWeekStart = $todayCarbon->copy()->subDays(($todayCarbon->dayOfWeekIso - $start->dayOfWeekIso + 7) % 7)->toDateString();
$end = $start->copy()->addDays(6);
$numberFormatter = class_exists(\NumberFormatter::class) ? new \NumberFormatter($locale, \NumberFormatter::DECIMAL) : null;
$resolvedDecimalSeparator = $decimalSeparator ?? ($numberFormatter?->getSymbol(\NumberFormatter::DECIMAL_SEPARATOR_SYMBOL) ?: '.');
$unitHour = __('h');
$unitMinute = __('m');
// Mirrors formatDuration() in duration.js, so the first paint matches what
// the browser renders after a commit.
$formatMinutes = function ($minutes) use ($format, $resolvedDecimalSeparator, $unitHour, $unitMinute): string {
if ($minutes === null) {
return '';
}
$sign = $minutes < 0 ? '-' : '';
$abs = abs((int) $minutes);
$hours = intdiv($abs, 60);
$mins = $abs % 60;
if ($format === 'decimal') {
$text = rtrim(rtrim(number_format($abs / 60, 2, '.', ''), '0'), '.');
return $sign.str_replace('.', $resolvedDecimalSeparator, $text);
}
if ($format === 'hm') {
if ($hours === 0) {
return $sign.$mins.$unitMinute;
}
return $mins === 0 ? $sign.$hours.$unitHour : $sign.$hours.$unitHour.' '.$mins.$unitMinute;
}
return sprintf('%s%d:%02d', $sign, $hours, $mins);
};
$openThreshold = max(1, (int) $openFrom);
$gridRows = [];
$cells = [];
foreach ($rows as $index => $row) {
$row = (array) $row;
$id = $row['id'] ?? $index;
$key = (string) $id;
$lockedDays = array_map('strval', (array) ($row['lockedDays'] ?? []));
$rowLocked = $gridLocked || (bool) ($row['locked'] ?? false);
$values = [];
$entryCounts = [];
foreach ($dates as $date) {
$raw = ((array) ($row['cells'] ?? []))[$date] ?? null;
$values[$date] = is_numeric($raw) ? (int) round((float) $raw) : null;
$count = ((array) ($row['entries'] ?? []))[$date] ?? null;
if (is_numeric($count) && (int) $count >= $openThreshold) {
$entryCounts[$date] = (int) $count;
}
}
$cells[$key] = $values;
$gridRows[] = [
'id' => $id,
'key' => $key,
'label' => (string) ($row['label'] ?? $key),
'sublabel' => $row['sublabel'] ?? null,
'href' => $row['href'] ?? null,
'rowLocked' => (bool) ($row['locked'] ?? false),
'lockedDates' => $rowLocked ? $dates : array_values(array_intersect($dates, $lockedDays)),
'openDates' => array_keys($entryCounts),
'entries' => $entryCounts,
'total' => array_sum(array_filter($values, fn ($v) => $v !== null)),
];
}
$dayTotals = [];
foreach ($dates as $date) {
$dayTotals[$date] = array_sum(array_map(fn ($values) => $values[$date] ?? 0, $cells));
}
$grandTotal = array_sum($dayTotals);
$target = is_numeric($dailyTarget) && (int) $dailyTarget > 0 ? (int) $dailyTarget : null;
$baseId = $attributes->get('id') ?: 'timesheet-grid-'.\Illuminate\Support\Str::random(8);
$invalidId = $baseId.'-invalid';
$formMode = filled($name) && blank($saveMethod);
$config = [
'rows' => array_map(fn ($row) => ['id' => $row['id'], 'key' => $row['key'], 'lockedDates' => $row['lockedDates'], 'openDates' => $row['openDates']], $gridRows),
'days' => $dates,
'weekends' => array_values(array_map(fn ($day) => $day['date'], array_filter($days, fn ($day) => $day['weekend']))),
'cells' => (object) array_map(fn ($values) => (object) $values, $cells),
'format' => $format,
'bareUnit' => $bareUnit,
'decimalSeparator' => $resolvedDecimalSeparator,
'unitHour' => $unitHour,
'unitMinute' => $unitMinute,
'dailyTarget' => $target,
'saveMethod' => filled($saveMethod) ? (string) $saveMethod : null,
'formMode' => $formMode,
'invalidId' => $invalidId,
'classes' => ['valid' => $input['states']['default'], 'invalid' => $input['states']['invalid']],
'weeks' => [
'prev' => $start->copy()->subDays(7)->toDateString(),
'next' => $start->copy()->addDays(7)->toDateString(),
'today' => $todayWeekStart,
],
'text' => [
'changeOne' => __(':count unsaved change'),
'changeOther' => __(':count unsaved changes'),
'saved' => __('Timesheet saved.'),
'saveFailed' => __('The timesheet could not be saved. Try again.'),
'fixInvalid' => __('Fix the highlighted cells before you save.'),
],
];
$resolvedCaption = $caption ?? __('Timesheet for the week of :date', ['date' => $start->isoFormat('LL')]);
$weekLabel = $start->month === $end->month && $start->year === $end->year
? __(':start – :end', ['start' => $start->isoFormat('D'), 'end' => $end->isoFormat('D MMM YYYY')])
: __(':start – :end', ['start' => $start->isoFormat('D MMM'), 'end' => $end->isoFormat('D MMM YYYY')]);
// hm text ("10h 45m") needs a wider cell than clock or decimal text.
$cellWidth = $format === 'hm' ? 'min-w-20' : 'min-w-14';
$cellInputClass = trim($input['base'].' '.$input['sizes']['sm'].' '.$input['states']['default'].' '.$cellWidth.' text-end tabular-nums');
$lockedCellClass = 'flex h-control-h-sm w-full '.$cellWidth.' items-center justify-end gap-1 rounded-md border border-transparent px-control-x-sm text-sm tabular-nums text-muted-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring';
$openCellClass = 'flex h-control-h-sm w-full '.$cellWidth.' items-center justify-end gap-1 rounded-md border border-transparent bg-muted/60 px-control-x-sm text-sm font-medium tabular-nums text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring';
$stickyClass = 'sticky start-0 z-10 border-e border-border';
$lockIcon = '<svg class="size-3 shrink-0" 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>';
$hasHeader = isset($header) && trim((string) $header) !== '';
$hasFooter = isset($footer) && trim((string) $footer) !== '';
$stackIcon = '<svg class="size-3 shrink-0 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h10" /></svg>';
@endphp
{{--
Timesheet Grid. A week of duration cells: rows by seven day columns with
row, day and grand totals. One Alpine component owns the whole table and
works through event delegation, so the cells stay plain inputs. It is a
table with inputs, not an ARIA grid: Tab visits the editable cells in
reading order and the arrow keys move between all cells.
--}}
<div
id="{{ $baseId }}"
data-slot="timesheet-grid"
data-dirty="false"
data-dirty-count="0"
@if ($gridLocked) data-locked="true" @endif
x-data="uiTimesheetGrid({{ \Illuminate\Support\Js::from($config) }})"
x-modelable="tsModel"
x-bind:data-dirty="tsDirtyCount > 0 ? 'true' : 'false'"
x-bind:data-dirty-count="tsDirtyCount"
x-on:keydown="tsKeydown($event)"
x-on:focusin="tsFocusIn($event)"
x-on:focusout="tsFocusOut($event)"
x-on:mouseup="tsMouseUp($event)"
x-on:click="tsClick($event)"
x-on:timesheet-saved="tsMarkSaved()"
x-on:timesheet-save-failed="tsSaveFailed()"
{{ $attributes->except('id')->merge(['class' => '@container flex w-full min-w-0 flex-col gap-4 text-foreground']) }}
>
@if ($hasHeader)
<div data-slot="timesheet-grid-header" class="flex min-w-0 flex-wrap items-center justify-between gap-2">{{ $header }}</div>
@elseif ($withNavigation)
<div data-slot="timesheet-grid-header" class="flex min-w-0 flex-wrap items-center justify-between gap-2">
<p data-slot="timesheet-grid-week" dir="auto" class="flex min-h-control-h-sm min-w-0 items-center text-sm font-semibold">{{ $weekLabel }}</p>
<div class="flex items-center gap-1">
<x-ui.button variant="outline" size="sm" icon-label="{{ __('Previous week') }}" data-ts-nav="prev" x-on:click="tsNavigate('prev')">
<svg class="size-4 rtl:-scale-x-100" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg>
</x-ui.button>
<x-ui.button variant="outline" size="sm" data-ts-nav="today" x-on:click="tsNavigate('today')">{{ __('This week') }}</x-ui.button>
<x-ui.button variant="outline" size="sm" icon-label="{{ __('Next week') }}" data-ts-nav="next" x-on:click="tsNavigate('next')">
<svg class="size-4 rtl:-scale-x-100" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
</x-ui.button>
</div>
</div>
@endif
<x-ui.table bordered class="bg-background">
<caption class="sr-only">{{ $resolvedCaption }}</caption>
<x-ui.table.header>
<tr>
<th scope="col" class="{{ $stickyClass }} bg-background px-3 py-2 text-start text-xs font-medium text-muted-foreground">{{ $rowHeading ?? __('Entry') }}</th>
@foreach ($days as $day)
<th
scope="col"
data-slot="timesheet-grid-day"
data-date="{{ $day['date'] }}"
@if ($day['weekend']) data-weekend="true" @endif
@if ($day['today']) data-today="true" aria-current="date" @endif
@class(['px-1 py-2 text-end align-bottom text-xs font-medium', 'bg-muted/40' => $day['weekend'], 'text-foreground' => $day['today'], 'text-muted-foreground' => ! $day['today']])
>
<span aria-hidden="true" class="flex flex-col items-end gap-1 px-2">
<span>{{ $day['weekday'] }}</span>
<span @class(['inline-flex size-6 items-center justify-center rounded-full tabular-nums', 'bg-foreground text-background' => $day['today'] && $todayTone !== 'primary', 'bg-primary text-primary-foreground' => $day['today'] && $todayTone === 'primary'])>{{ $day['number'] }}</span>
</span>
<span class="sr-only">{{ $day['long'] }}</span>
</th>
@endforeach
<th scope="col" class="px-3 py-2 text-end text-xs font-medium text-muted-foreground">{{ __('Total') }}</th>
</tr>
</x-ui.table.header>
<x-ui.table.body>
@forelse ($gridRows as $r => $row)
<tr data-slot="timesheet-grid-row" data-row-id="{{ $row['key'] }}" @if ($row['rowLocked']) data-locked="true" @endif class="border-b border-border">
<th scope="row" class="{{ $stickyClass }} min-w-32 max-w-40 bg-background px-3 py-2 text-start align-middle font-normal @2xl:max-w-64">
<span class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-col">
@if (filled($row['href']))
<a href="{{ $row['href'] }}" class="break-words text-sm font-medium text-foreground underline-offset-4 hover:underline focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring">{{ $row['label'] }}</a>
@else
<span class="break-words text-sm font-medium">{{ $row['label'] }}</span>
@endif
@if (filled($row['sublabel']))
<span class="break-words text-xs text-muted-foreground">{{ $row['sublabel'] }}</span>
@endif
</span>
@if ($row['rowLocked'] && ! $gridLocked)
<span class="ms-auto shrink-0 text-muted-foreground" title="{{ __('Locked') }}">{!! $lockIcon !!}<span class="sr-only">{{ __('Locked') }}</span></span>
@endif
</span>
</th>
@foreach ($days as $c => $day)
@php
$minutes = $cells[$row['key']][$day['date']];
$cellLabel = __(':day, :row', ['day' => $day['short'], 'row' => $row['label']]);
@endphp
<td data-slot="timesheet-grid-cell" data-date="{{ $day['date'] }}" @class(['p-1 align-middle', 'bg-muted/40' => $day['weekend']])>
@if (isset($row['entries'][$day['date']]))
@php
$entryCount = $row['entries'][$day['date']];
$openLabel = trans_choice(':count entry, :duration, :day, :row|:count entries, :duration, :day, :row', $entryCount, ['count' => $entryCount, 'duration' => ':duration', 'day' => $day['short'], 'row' => $row['label']]);
$cellLocked = in_array($day['date'], $row['lockedDates'], true);
@endphp
<button
type="button"
aria-label="{{ str_replace(':duration', $formatMinutes($minutes ?? 0), $openLabel) }}"
dir="ltr"
data-slot="timesheet-grid-cell-open"
data-ts-cell
data-ts-open
data-ts-label="{{ $openLabel }}"
data-ts-entries="{{ $entryCount }}"
@if ($cellLocked) data-locked="true" @endif
data-ts-row="{{ $row['key'] }}"
data-ts-date="{{ $day['date'] }}"
data-ts-r="{{ $r }}"
data-ts-c="{{ $c }}"
class="{{ $openCellClass }}"
>@if ($cellLocked){!! $lockIcon !!}@else{!! $stackIcon !!}@endif<span data-ts-value>{{ $formatMinutes($minutes ?? 0) }}</span></button>
@elseif (in_array($day['date'], $row['lockedDates'], true))
<span
role="textbox"
aria-readonly="true"
aria-label="{{ $cellLabel }}"
tabindex="-1"
title="{{ __('Locked') }}"
dir="ltr"
data-ts-cell
data-locked="true"
data-ts-row="{{ $row['key'] }}"
data-ts-date="{{ $day['date'] }}"
data-ts-r="{{ $r }}"
data-ts-c="{{ $c }}"
class="{{ $lockedCellClass }}"
>{!! $lockIcon !!}<span data-ts-value>{{ $formatMinutes($minutes) }}</span></span>
@else
<input
type="text"
inputmode="text"
autocomplete="off"
spellcheck="false"
size="5"
dir="ltr"
aria-label="{{ $cellLabel }}"
value="{{ $formatMinutes($minutes) }}"
data-ts-cell
data-ts-row="{{ $row['key'] }}"
data-ts-date="{{ $day['date'] }}"
data-ts-r="{{ $r }}"
data-ts-c="{{ $c }}"
class="{{ $cellInputClass }}"
/>
@endif
</td>
@endforeach
<td data-slot="timesheet-grid-row-total" class="whitespace-nowrap px-3 py-2 text-end align-middle text-sm font-medium tabular-nums" dir="ltr" x-text="tsRowText({{ $r }})">{{ $formatMinutes($row['total']) }}</td>
</tr>
@empty
{{-- The message sticks to the visible start of the scrolling
frame and is as wide as the grid, so it stays readable
when the day columns overflow a narrow screen. --}}
<tr data-slot="timesheet-grid-empty">
<td colspan="9" class="p-0">
<div class="sticky start-0 flex w-full max-w-[100cqw] flex-col items-center gap-2 px-4 py-10 text-center">
<span class="flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground" aria-hidden="true">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>
</span>
<p class="text-sm font-medium">{{ $emptyTitle ?? __('No rows this week') }}</p>
@if ($emptyDescription !== '')
<p class="text-sm text-muted-foreground">{{ $emptyDescription ?? __('Add a row to start entering time.') }}</p>
@endif
</div>
</td>
</tr>
@endforelse
</x-ui.table.body>
<tfoot data-slot="timesheet-grid-totals" class="border-t border-border bg-muted">
<tr>
<th scope="row" class="{{ $stickyClass }} bg-muted px-3 py-2 text-start text-sm font-semibold">{{ __('Total') }}</th>
@foreach ($days as $c => $day)
@php $under = $target !== null && ! $day['weekend'] && $dayTotals[$day['date']] < $target; @endphp
<td
data-slot="timesheet-grid-day-total"
data-date="{{ $day['date'] }}"
@if ($under) data-under-target="true" @endif
x-bind:data-under-target="tsUnderTarget({{ $c }}) ? 'true' : null"
class="whitespace-nowrap px-3 py-2 text-end text-sm font-medium tabular-nums"
>
<span class="inline-flex items-center gap-1" dir="ltr">
@if ($target !== null)
<span aria-hidden="true" class="size-2 rounded-full bg-warning" @unless ($under) style="display: none" @endunless x-show="tsUnderTarget({{ $c }})"></span>
@endif
<span x-text="tsDayText({{ $c }})">{{ $formatMinutes($dayTotals[$day['date']]) }}</span>
</span>
@if ($target !== null)
<span class="sr-only" @unless ($under) style="display: none" @endunless x-show="tsUnderTarget({{ $c }})">{{ __('Below the daily target') }}</span>
@endif
</td>
@endforeach
<td data-slot="timesheet-grid-grand-total" class="whitespace-nowrap px-3 py-2 text-end text-sm font-semibold tabular-nums" dir="ltr" x-text="tsGrandText()">{{ $formatMinutes($grandTotal) }}</td>
</tr>
</tfoot>
</x-ui.table>
<p id="{{ $invalidId }}" data-slot="timesheet-grid-message" class="text-xs text-destructive" style="display: none" x-show="tsInvalidCount > 0">{{ $invalidMessage ?? __('Enter a duration up to 24 hours, such as 1:30, 90m or 1.5h.') }}</p>
@if ($hasFooter || ! $gridLocked)
<div data-slot="timesheet-grid-footer" class="flex min-w-0 flex-wrap items-center justify-between gap-2">
<div class="flex min-w-0 flex-wrap items-center gap-2">{{ $footer ?? '' }}</div>
@unless ($gridLocked)
<div class="ms-auto flex items-center gap-2" style="display: none" x-show="tsDirtyCount > 0">
<span data-slot="timesheet-grid-dirty" class="text-sm text-muted-foreground" x-text="tsDirtyText()"></span>
<x-ui.button
size="sm"
:type="$formMode ? 'submit' : 'button'"
data-ts-save
x-on:click="tsSaveClick($event)"
x-bind:disabled="tsSaving"
x-bind:aria-busy="tsSaving ? 'true' : null"
>{{ $saveLabel ?? __('Save') }}</x-ui.button>
</div>
@endunless
</div>
@endif
@if (filled($name) && ! $gridLocked)
@foreach ($gridRows as $row)
@foreach ($dates as $date)
@unless (in_array($date, $row['lockedDates'], true) || in_array($date, $row['openDates'], true))
<input type="hidden" name="{{ $name }}[{{ $row['key'] }}][{{ $date }}]" value="{{ $cells[$row['key']][$date] }}" data-ts-hidden="{{ $row['key'] }}|{{ $date }}" />
@endunless
@endforeach
@endforeach
@endif
<p data-slot="timesheet-grid-status" role="status" class="sr-only" x-text="tsAnnouncement"></p>
<noscript><p class="text-sm text-muted-foreground">{{ __('Editing this timesheet needs JavaScript.') }}</p></noscript>
</div>
import { parseDuration, formatDuration } from './duration.js';
/**
* Timesheet Grid behaviour.
*
* One Alpine component owns the whole week. The cells are plain text inputs
* (locked cells are read-only text) and every interaction arrives through
* delegated keydown/focusin/focusout/mouseup listeners on the root, so a grid
* of 20 rows by 7 days has no per-cell component. Only the totals, the dirty
* count and the Save area are reactive bindings.
*
* Pattern: a table with inputs, not an ARIA grid. Tab and Shift+Tab visit the
* editable cells in reading order (native order). The arrow keys move between
* all cells, locked ones included. Left/Right leave a cell only when the caret
* is at that edge of the text, the text is fully selected or the cell is
* empty; they follow the visual direction, so they swap under dir="rtl".
* Enter commits and moves down (Shift+Enter up), Escape restores the last
* committed text, Home/End go to the first/last day of the row
* (Ctrl/Cmd+Home/End to the first/last cell), and Ctrl/Cmd+S saves.
*
* A cell commits on blur, Enter or a move. Text that duration.js cannot parse,
* or more than 24 hours, stays on screen with aria-invalid and the invalid
* border; the committed minutes do not change.
*
* State (all prefixed `ts*`: an x-model on the root resolves in this scope
* first, so a plain name such as `cells` would shadow the consumer's own):
* tsCells { [rowId]: { [Y-m-d]: minutes|null } }, x-modelable as tsModel
* tsBaseline the last saved (or first rendered) cells, for the dirty count
*
* Events (bubbling CustomEvents on the root):
* timesheet-change { rowId, date, minutes, previous } on every value change
* timesheet-save { changes: [{ rowId, date, minutes }] }
* timesheet-navigate { direction: 'prev'|'next'|'today', weekStart, dirty }
* timesheet-cell-open { rowId, rowKey, date, minutes, entries, locked } when
* a cell with several entries (a button, not an input)
* is activated; the consumer shows the entries behind it.
* Listened for on the root, for saves the grid does not run itself:
* timesheet-saved (clears the dirty state), timesheet-save-failed.
*/
const MAX_CELL_MINUTES = 24 * 60;
const splitClasses = (value) =>
String(value || '')
.split(/\s+/)
.filter(Boolean);
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiTimesheetGrid', (config = {}) => {
// Plain (non-reactive) structure and DOM handles.
const rows = Array.isArray(config.rows) ? config.rows : [];
const days = Array.isArray(config.days) ? config.days : [];
const weekends = new Set(Array.isArray(config.weekends) ? config.weekends : []);
const rowIds = new Map(rows.map((row) => [String(row.key), row.id]));
const lockedCells = new Set();
// Cells with several entries: the grid shows their total but never edits it.
const openCells = new Set();
for (const row of rows) {
for (const date of row.lockedDates || []) lockedCells.add(`${row.key}|${date}`);
for (const date of row.openDates || []) openCells.add(`${row.key}|${date}`);
}
const validClasses = splitClasses(config.classes?.valid);
const invalidClasses = splitClasses(config.classes?.invalid);
const text = config.text || {};
let root = null;
let selectOnMouseUp = null;
const cellsFrom = (source, fallback) => {
const next = {};
for (const row of rows) {
const key = String(row.key);
const incoming = source && typeof source === 'object' ? source[key] : undefined;
next[key] = {};
for (const date of days) {
const has =
incoming && typeof incoming === 'object' && Object.hasOwn(incoming, date);
const raw = has ? incoming[date] : fallback?.[key]?.[date];
const number =
raw === null || raw === undefined || raw === '' ? null : Number(raw);
next[key][date] =
number !== null && Number.isFinite(number) ? Math.round(number) : null;
}
}
return next;
};
const initial = cellsFrom(config.cells, null);
return {
tsCells: initial,
tsBaseline: cellsFrom(initial, null),
tsInvalid: {},
tsSaving: false,
tsAnnouncement: '',
tsSaveMethod: config.saveMethod || null,
init() {
root = this.$el;
},
destroy() {
root = null;
selectOnMouseUp = null;
},
/* ---------------------------------------------------------- model */
/** x-modelable: `{ [rowId]: { [Y-m-d]: minutes|null } }`. Missing keys keep their value. */
get tsModel() {
return this.tsCells;
},
set tsModel(value) {
if (value === null || value === undefined || typeof value !== 'object') return;
const next = cellsFrom(value, this.tsCells);
if (JSON.stringify(next) === JSON.stringify(this.tsCells)) return;
this.tsCells = next;
this.tsRenderAll();
},
tsFormat(minutes) {
return formatDuration(minutes, {
format: config.format || 'clock',
decimalSeparator: config.decimalSeparator || '.',
unitHour: config.unitHour || 'h',
unitMinute: config.unitMinute || 'm',
});
},
/* --------------------------------------------------------- totals */
tsRowTotal(index) {
const values = this.tsCells[String(rows[index]?.key)] || {};
return days.reduce((sum, date) => sum + (values[date] ?? 0), 0);
},
tsDayTotal(index) {
const date = days[index];
return rows.reduce(
(sum, row) => sum + (this.tsCells[String(row.key)]?.[date] ?? 0),
0,
);
},
tsRowText(index) {
return this.tsFormat(this.tsRowTotal(index));
},
tsDayText(index) {
return this.tsFormat(this.tsDayTotal(index));
},
tsGrandText() {
return this.tsFormat(
days.reduce((sum, _date, index) => sum + this.tsDayTotal(index), 0),
);
},
tsUnderTarget(index) {
const target = config.dailyTarget;
if (!target || weekends.has(days[index])) return false;
return this.tsDayTotal(index) < target;
},
/* ---------------------------------------------------------- dirty */
/** Every editable cell whose committed value differs from the baseline. */
get tsChanges() {
const changes = [];
for (const row of rows) {
const key = String(row.key);
for (const date of days) {
if (lockedCells.has(`${key}|${date}`) || openCells.has(`${key}|${date}`))
continue;
const minutes = this.tsCells[key]?.[date] ?? null;
if (minutes !== (this.tsBaseline[key]?.[date] ?? null)) {
changes.push({ rowId: rowIds.get(key), date, minutes });
}
}
}
return changes;
},
get tsDirtyCount() {
return this.tsChanges.length;
},
get tsInvalidCount() {
return Object.keys(this.tsInvalid).length;
},
tsDirtyText() {
const count = this.tsDirtyCount;
return String(count === 1 ? text.changeOne : text.changeOther).replace(
':count',
String(count),
);
},
/* ---------------------------------------------------------- cells */
tsCell(r, c) {
return (
root?.querySelector(`[data-ts-cell][data-ts-r="${r}"][data-ts-c="${c}"]`) ||
null
);
},
tsCellFrom(target) {
const cell = target?.closest?.('[data-ts-cell]');
return cell && root?.contains(cell) ? cell : null;
},
tsSetInvalid(input, invalid) {
const id = `${input.dataset.tsRow}|${input.dataset.tsDate}`;
input.classList.remove(...(invalid ? validClasses : invalidClasses));
input.classList.add(...(invalid ? invalidClasses : validClasses));
if (invalid) {
input.setAttribute('aria-invalid', 'true');
input.setAttribute('aria-describedby', config.invalidId);
if (!this.tsInvalid[id]) this.tsInvalid[id] = true;
} else {
input.removeAttribute('aria-invalid');
input.removeAttribute('aria-describedby');
if (this.tsInvalid[id]) delete this.tsInvalid[id];
}
},
/** Parse and store one input's text. Returns false when the text is invalid. */
tsCommit(input) {
if (!input || input.tagName !== 'INPUT' || input.dataset.tsCell === undefined)
return true;
const key = input.dataset.tsRow;
const date = input.dataset.tsDate;
const raw = input.value;
let minutes = null;
if (raw.trim() !== '') {
minutes = parseDuration(raw, {
bareUnit: config.bareUnit === 'hours' ? 'hours' : 'minutes',
});
if (minutes === null || minutes < 0 || minutes > MAX_CELL_MINUTES) {
this.tsSetInvalid(input, true);
return false;
}
}
this.tsSetInvalid(input, false);
input.value = this.tsFormat(minutes);
const previous = this.tsCells[key]?.[date] ?? null;
if (minutes === previous) return true;
this.tsCells[key][date] = minutes;
this.tsSyncHidden(key, date, minutes);
this.tsEmit('timesheet-change', {
rowId: rowIds.get(key),
date,
minutes,
previous,
});
return true;
},
/** Escape: back to the committed text. */
tsRevert(input) {
const minutes = this.tsCells[input.dataset.tsRow]?.[input.dataset.tsDate] ?? null;
input.value = this.tsFormat(minutes);
this.tsSetInvalid(input, false);
input.select();
},
tsSyncHidden(key, date, minutes) {
const hidden = root?.querySelector(
`input[type="hidden"][data-ts-hidden="${CSS.escape(`${key}|${date}`)}"]`,
);
if (!hidden) return;
hidden.value = minutes === null ? '' : String(minutes);
hidden.dispatchEvent(new Event('input', { bubbles: true }));
hidden.dispatchEvent(new Event('change', { bubbles: true }));
},
/** Re-render every cell after the model changed from outside. */
tsRenderAll() {
if (!root) return;
for (const cell of root.querySelectorAll('[data-ts-cell]')) {
const key = cell.dataset.tsRow;
const date = cell.dataset.tsDate;
const minutes = this.tsCells[key]?.[date] ?? null;
if (cell.tagName === 'INPUT') {
cell.value = this.tsFormat(minutes);
this.tsSetInvalid(cell, false);
} else if (cell.dataset.tsOpen !== undefined) {
const total = this.tsFormat(minutes ?? 0);
const value = cell.querySelector('[data-ts-value]');
if (value) value.textContent = total;
cell.setAttribute(
'aria-label',
String(cell.dataset.tsLabel || '').replace(':duration', total),
);
} else {
const value = cell.querySelector('[data-ts-value]');
if (value) value.textContent = this.tsFormat(minutes);
}
const hidden = root.querySelector(
`input[type="hidden"][data-ts-hidden="${CSS.escape(`${key}|${date}`)}"]`,
);
if (hidden) hidden.value = minutes === null ? '' : String(minutes);
}
},
/* ------------------------------------------------------- keyboard */
tsIsRtl() {
return root ? getComputedStyle(root).direction === 'rtl' : false;
},
/** Can Left/Right leave this input without first moving the caret? */
tsAtEdge(input, key) {
const length = input.value.length;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
if (length === 0 || (start === 0 && end === length)) return true;
if (start !== end) return false;
// Cells are dir="ltr" (durations read left to right everywhere).
return key === 'ArrowLeft' ? start === 0 : end === length;
},
tsMove(from, r, c) {
const target = this.tsCell(r, c);
if (!target || target === from) return false;
target.focus();
return true;
},
tsKeydown(event) {
const key = event.key;
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
String(key).toLowerCase() === 's'
) {
event.preventDefault();
this.tsSaveShortcut();
return;
}
const cell = this.tsCellFrom(event.target);
if (!cell || event.isComposing) return;
const r = Number(cell.dataset.tsR);
const c = Number(cell.dataset.tsC);
const isInput = cell.tagName === 'INPUT';
const modified = event.altKey || event.metaKey || event.ctrlKey;
const lastRow = rows.length - 1;
const lastDay = days.length - 1;
switch (key) {
case 'ArrowUp':
case 'ArrowDown':
if (modified || event.shiftKey) return;
this.tsMove(cell, r + (key === 'ArrowDown' ? 1 : -1), c);
break;
case 'ArrowLeft':
case 'ArrowRight': {
if (modified || event.shiftKey) return;
if (isInput && !this.tsAtEdge(cell, key)) return;
const forward = (key === 'ArrowRight') !== this.tsIsRtl();
this.tsMove(cell, r, c + (forward ? 1 : -1));
break;
}
case 'Home':
case 'End':
if (event.shiftKey || event.altKey) return;
if (event.ctrlKey || event.metaKey) {
this.tsMove(
cell,
key === 'Home' ? 0 : lastRow,
key === 'Home' ? 0 : lastDay,
);
} else {
this.tsMove(cell, r, key === 'Home' ? 0 : lastDay);
}
break;
case 'Enter': {
// A cell with several entries is a button: Enter opens it.
if (modified || cell.tagName === 'BUTTON') return;
if (isInput && !this.tsCommit(cell)) break;
const next = r + (event.shiftKey ? -1 : 1);
if (!this.tsMove(cell, next, c) && isInput) cell.select();
break;
}
case 'Escape':
if (!isInput) return;
this.tsRevert(cell);
break;
default:
return;
}
event.preventDefault();
},
tsFocusIn(event) {
const cell = this.tsCellFrom(event.target);
if (!cell || cell.tagName !== 'INPUT') return;
cell.select();
// A click would put the caret back on mouseup; keep the selection.
selectOnMouseUp = cell;
},
tsMouseUp(event) {
if (selectOnMouseUp && event.target === selectOnMouseUp) {
event.preventDefault();
selectOnMouseUp.select();
}
selectOnMouseUp = null;
},
/** A cell with several entries: ask the consumer to show them. */
tsClick(event) {
const cell = this.tsCellFrom(event.target);
if (!cell || cell.dataset.tsOpen === undefined) return;
const key = cell.dataset.tsRow;
const date = cell.dataset.tsDate;
this.tsEmit('timesheet-cell-open', {
rowId: rowIds.get(key),
rowKey: key,
date,
minutes: this.tsCells[key]?.[date] ?? null,
entries: Number(cell.dataset.tsEntries) || 0,
locked: cell.dataset.locked === 'true',
});
},
tsFocusOut(event) {
const cell = this.tsCellFrom(event.target);
if (cell && cell.tagName === 'INPUT') this.tsCommit(cell);
},
/* ----------------------------------------------------------- save */
tsEmit(name, detail) {
(root || this.$root).dispatchEvent(
new CustomEvent(name, { detail, bubbles: true, composed: true }),
);
},
tsAnnounce(message) {
if (!message) return;
if (this.tsAnnouncement === message) {
this.tsAnnouncement = '';
this.$nextTick(() => {
this.tsAnnouncement = message;
});
return;
}
this.tsAnnouncement = message;
},
/** The nearest Livewire component, or null outside Livewire. */
tsWire() {
try {
return window.Livewire && this.$wire ? this.$wire : null;
} catch {
return null;
}
},
/** Commit the focused cell, then return the changes, or null when nothing can be saved. */
tsPrepareSave() {
if (this.tsSaving) return null;
const active = document.activeElement;
if (active && root?.contains(active)) this.tsCommit(this.tsCellFrom(active));
if (this.tsInvalidCount > 0) {
this.tsAnnounce(text.fixInvalid);
root?.querySelector('[data-ts-cell][aria-invalid="true"]')?.focus();
return null;
}
const changes = this.tsChanges;
return changes.length ? changes : null;
},
tsSaveClick(event) {
const changes = this.tsPrepareSave();
if (!changes) {
event.preventDefault();
return;
}
this.tsEmit('timesheet-save', { changes });
// Plain form mode: the Save button is a submit button; let it post.
if (
config.formMode &&
event.currentTarget?.type === 'submit' &&
event.currentTarget.form
)
return;
this.tsPersist(changes);
},
tsSaveShortcut() {
const changes = this.tsPrepareSave();
if (!changes) return;
this.tsEmit('timesheet-save', { changes });
const form = config.formMode ? root?.closest('form') : null;
if (form) {
form.requestSubmit();
return;
}
this.tsPersist(changes);
},
/** With saveMethod inside Livewire: call it and clear the dirty state once it resolves. */
async tsPersist(changes) {
const method = this.tsSaveMethod;
const wire = method ? this.tsWire() : null;
if (!wire) return;
const snapshot = JSON.parse(JSON.stringify(this.tsCells));
this.tsSaving = true;
try {
await (typeof wire[method] === 'function'
? wire[method](changes)
: wire.call(method, changes));
this.tsMarkSaved(snapshot);
} catch {
this.tsSaveFailed();
} finally {
this.tsSaving = false;
}
},
/** Clears the dirty state. Edits made while the save ran stay dirty. */
tsMarkSaved(snapshot = null) {
this.tsBaseline = cellsFrom(snapshot || this.tsCells, null);
this.tsAnnounce(text.saved);
},
tsSaveFailed() {
this.tsAnnounce(text.saveFailed);
},
/* ----------------------------------------------------- navigation */
tsNavigate(direction) {
const weekStart = config.weeks?.[direction];
if (!weekStart) return;
this.tsEmit('timesheet-navigate', {
direction,
weekStart,
dirty: this.tsDirtyCount > 0,
});
},
};
});
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.2.2 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0