Running Timer
A compact time-tracking control: elapsed H:MM:SS since a server-given ISO 8601 start, recomputed from the clock on every tick, with a Start/Stop toggle, a quiet Discard icon button, an optional key and label, and a bindable draft description. A compact variant fits a top bar on one line. Emits cancelable timer-start, timer-stop and timer-discard events and can call Livewire methods.
Preview
{{-- A running entry: the server passes when it began; the control shows the
elapsed time and ticks from the clock. --}}
<div class="w-full max-w-2xl">
<x-ui.running-timer
:started-at="now()->subMinutes(83)->subSeconds(12)->toIso8601String()"
:description="__('Review the release notes')"
/>
</div>
Size options
Variant options
Installation
php artisan ui:add running-timer
Note
This component ships an Alpine behavior module at
resources/js/ui/running-timer.js. Import it once from your bundle so it registers on alpine:init:
import './running-timer.js';
Registry contract
php artisan ui:add running-timer
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/running-timer.blade.php -
resources/js/ui/running-timer.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: Running Timer (`running-timer`)
A compact time-tracking control: elapsed H:MM:SS since a server-given ISO 8601 start, recomputed from the clock on every tick, with a Start/Stop toggle, a quiet Discard icon button, an optional key and label, and a bindable draft description. A compact variant fits a top bar on one line. Emits cancelable timer-start, timer-stop and timer-discard events and can call Livewire methods.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add running-timer
```
## Usage
```blade
{{-- A running entry: the server passes when it began; the control shows the
elapsed time and ticks from the clock. --}}
<div class="w-full max-w-2xl">
<x-ui.running-timer
:started-at="now()->subMinutes(83)->subSeconds(12)->toIso8601String()"
:description="__('Review the release notes')"
/>
</div>
```
## Props
- `startedAt` (string|DateTimeInterface|null, default `null`) — When the running entry began, as an ISO 8601 string (e.g. 2026-09-25T08:15:00Z) or a Carbon/DateTime. Null or an unreadable value is the idle state. Printed as data-started-at; a re-render that changes it switches the control in place.
- `description` (string|null, default `null`) — Initial draft description. The live value is x-modelable on the root, so x-model and wire:model bind a string.
- `label` (string|null, default `null`) — Optional title of what is being timed (for example a task title), truncated to one line. Also names the control group for screen readers.
- `key` (string|null, default `null`) — Optional short key shown as a monospaced badge before the label (for example OPS-12).
- `name` (string|null, default `description`) — Name of the description input for a plain form post; a hidden input carries it when withDescription is false. Null for no name.
- `placeholder` (string|null, default `null`) — Placeholder of the description input. Defaults to a translated "What are you working on?".
- `withDescription` (bool, default `true`) — Shows the inline description input. Set false for a label-only timer row.
- `startMethod` (string|null, default `null`) — Livewire method called as $wire.call(method, detail) on Start, where detail is the timer-start detail. Inside a Livewire component the control then waits for the server to render a new startedAt.
- `stopMethod` (string|null, default `null`) — Livewire method called with the timer-stop detail on Stop. The server re-render with startedAt null ends the running state.
- `discardMethod` (string|null, default `null`) — Livewire method called with the timer-discard detail on Discard.
- `confirmDiscard` (string|null, default `null`) — When set, Discard opens an alert dialog with this text and a destructive Discard action instead of discarding at once.
- `size` (sm|md|lg, default `md`) — Control tier of the input and buttons (control-h-sm, -md, -lg); md fits a 44px row.
- `variant` (default|compact, default `default`) — default is a full row with the description field. compact is one line for a top bar: idle is a quiet ghost button (clock icon, "Start timer"); running shows the label (truncated), the elapsed time, Stop and Discard; there is no description field (a named hidden input still posts it). Below the sm breakpoint compact hides the label and shows Start and Stop as square icon buttons whose text stays the accessible name. Accepts a backed enum or Stringable.
## Use when
- Use when date, time, or bounded-value context helps users enter valid values faster and with fewer errors.
- Tracking time on a task or project where the server stores when the running entry began and the page must show how long it has run.
- Needing Start, Stop and Discard actions that a Livewire component or a plain page can persist, with a draft description of the work.
- Showing a running timer in an app top bar or toolbar: variant="compact" keeps it on one line with a quiet Start timer button.
## Avoid when
- Do not force a picker when expert users can type the value faster and validation is straightforward.
- Counting down to a target date or moment: use <x-ui.countdown> (or <x-ui.snail-timer> for a playful countdown).
- Showing a wall-clock time: use <x-ui.flip-clock>. Showing how long ago something happened as text: use <x-ui.relative-time>.
- Typing a duration by hand: use <x-ui.duration-input>.
## Anti-patterns
- Forcing a picker when typing is faster
## Rules
- Use the `<brok:running-timer>` tag (or `<x-ui.running-timer>`) 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/running-timer
- Registry JSON (files, props, contract): https://brokui.dev/r/open/running-timer.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 on the root binds the draft description (a string); wire:model
binds the same way inside a Livewire component. --}}
<div x-data="{ description: 'Write the changelog' }" class="flex w-full max-w-2xl flex-col gap-2">
<x-ui.running-timer x-model="description" />
<p class="text-sm text-muted-foreground">{{ __('Bound value:') }} <span x-text="description"></span></p>
</div>
{{-- Compact: one line for a top bar. Idle is a quiet Start timer button;
running shows the task, the elapsed time, Stop and Discard. Below the sm
breakpoint the task drops out and the buttons become icons. Listen for
timer-start or timer-stop and call preventDefault() to open your own
dialog instead of the built-in flow. --}}
<div class="flex w-full max-w-2xl flex-col gap-4">
<header class="flex min-w-0 items-center justify-between gap-4 border-b border-border pb-2">
<span class="truncate text-sm font-medium">{{ __('Inbox') }}</span>
<x-ui.running-timer variant="compact" size="sm" />
</header>
<header class="flex min-w-0 items-center justify-between gap-4 border-b border-border pb-2">
<span class="truncate text-sm font-medium">{{ __('Inbox') }}</span>
<x-ui.running-timer
variant="compact"
size="sm"
key="OPS-142"
:label="__('Rotate the staging database credentials')"
:started-at="now()->subMinutes(24)->toIso8601String()"
:confirm-discard="__('The time you tracked for this entry will be lost.')"
/>
</header>
</div>
Confirm Discard
{{-- Discard asks first: the trash button opens an alert dialog, and only its
destructive action discards the running entry. --}}
<div class="w-full max-w-2xl">
<x-ui.running-timer
:started-at="now()->subMinutes(47)->toIso8601String()"
:description="__('Pair on the billing export')"
:confirm-discard="__('The time you tracked for this entry will be lost.')"
/>
</div>
{{-- No start time: the control shows 0:00:00 and a Start button. Start sets
the start time to now and dispatches timer-start. --}}
<div class="w-full max-w-2xl">
<x-ui.running-timer />
</div>
Long Content
{{-- A narrow column with a long key, label and description: the label
truncates, the row wraps and the time and actions stay visible. --}}
<div class="w-80 max-w-full">
<x-ui.running-timer
key="COMPLIANCE-2048"
:label="__('Recurring quarterly compliance review for every regional subsidiary')"
:description="__('Collect the signed statements from the finance and legal teams before the deadline')"
:started-at="now()->subHours(27)->subMinutes(3)->toIso8601String()"
/>
</div>
With Label
{{-- A key badge and a truncated title name what is being timed; the second
row replaces them with the default slot and hides the description input. --}}
<div class="flex w-full max-w-2xl flex-col gap-4">
<x-ui.running-timer
key="OPS-142"
:label="__('Rotate the staging database credentials')"
:started-at="now()->subMinutes(24)->toIso8601String()"
/>
<x-ui.running-timer
:started-at="now()->subHours(2)->subMinutes(5)->toIso8601String()"
:with-description="false"
size="sm"
>
<a href="#" class="min-w-0 truncate text-sm font-medium text-foreground underline-offset-4 hover:underline">{{ __('Quarterly planning') }}</a>
</x-ui.running-timer>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| startedAt | string | DateTimeInterface | null | null | When the running entry began, as an ISO 8601 string (e.g. 2026-09-25T08:15:00Z) or a Carbon/DateTime. Null or an unreadable value is the idle state. Printed as data-started-at; a re-render that changes it switches the control in place. |
| description | string | null | null | Initial draft description. The live value is x-modelable on the root, so x-model and wire:model bind a string. |
| label | string | null | null | Optional title of what is being timed (for example a task title), truncated to one line. Also names the control group for screen readers. |
| key | string | null | null | Optional short key shown as a monospaced badge before the label (for example OPS-12). |
| name | string | null | description | Name of the description input for a plain form post; a hidden input carries it when withDescription is false. Null for no name. |
| placeholder | string | null | null | Placeholder of the description input. Defaults to a translated "What are you working on?". |
| withDescription | bool | true | Shows the inline description input. Set false for a label-only timer row. |
| startMethod | string | null | null | Livewire method called as $wire.call(method, detail) on Start, where detail is the timer-start detail. Inside a Livewire component the control then waits for the server to render a new startedAt. |
| stopMethod | string | null | null | Livewire method called with the timer-stop detail on Stop. The server re-render with startedAt null ends the running state. |
| discardMethod | string | null | null | Livewire method called with the timer-discard detail on Discard. |
| confirmDiscard | string | null | null | When set, Discard opens an alert dialog with this text and a destructive Discard action instead of discarding at once. |
| size | sm | md | lg | md | Control tier of the input and buttons (control-h-sm, -md, -lg); md fits a 44px row. |
| variant | default | compact | default | default is a full row with the description field. compact is one line for a top bar: idle is a quiet ghost button (clock icon, "Start timer"); running shows the label (truncated), the elapsed time, Stop and Discard; there is no description field (a named hidden input still posts it). Below the sm breakpoint compact hides the label and shows Start and Stop as square icon buttons whose text stays the accessible name. Accepts a backed enum or Stringable. |
Slots
default— Replaces the key badge and label with your own content (for example a link to the task). Keep it to one line.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Elapsed time is Date.now() minus Date.parse(startedAt), recomputed on every tick (no counter, so no drift and no error after tab throttling). Negative elapsed from clock skew shows 0:00:00. Format H:MM:SS with unbounded hours. The first paint is computed on the server.
- The tick is a timeout aligned to the next whole elapsed second. It stops when idle, pauses while document.hidden and recomputes on return, and is cleared in destroy() together with the visibilitychange listener and the MutationObserver.
- Start, Stop and Discard dispatch bubbling CustomEvents on the root: timer-start { startedAt, description }, timer-stop { startedAt, stoppedAt, seconds, description } and timer-discard { startedAt, description }; dates are ISO 8601 UTC strings and seconds is an integer.
- Every event is cancelable. A listener that calls event.preventDefault() (Alpine: x-on:timer-start.prevent) stops the built-in flow: no state change, no Livewire method call and no announcement. Use it to open your own start or stop dialog; the server then renders the new startedAt (or none) and the control follows it.
- Without a method (or outside Livewire) the control changes its own state: Start sets startedAt to now, Stop and Discard return to idle and clear the description. With startMethod/stopMethod/discardMethod inside a Livewire component, the event still fires, the control calls $wire.call(method, detail), stays busy (disabled, aria-busy) until the call returns and changes nothing optimistically: the server re-render with a new startedAt (or none) is the source of truth.
- Livewire: <x-ui.running-timer :started-at="$entry?->started_at" wire:model="description" start-method="startTimer" stop-method="stopTimer" discard-method="discardTimer" />. wire:model binds a string (the description). Each method receives one array argument, the event detail.
- Discard is a quiet, neutral icon button (muted text, muted hover) in both variants, so it does not compete with Start/Stop; with confirmDiscard the alert dialog keeps the destructive Discard action.
- 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
Choose a date, time, or bounded contextual value.
Use when
- Use when date, time, or bounded-value context helps users enter valid values faster and with fewer errors.
- Tracking time on a task or project where the server stores when the running entry began and the page must show how long it has run.
- Needing Start, Stop and Discard actions that a Livewire component or a plain page can persist, with a draft description of the work.
- Showing a running timer in an app top bar or toolbar: variant="compact" keeps it on one line with a quiet Start timer button.
Avoid when
- Do not force a picker when expert users can type the value faster and validation is straightforward.
- Counting down to a target date or moment: use <x-ui.countdown> (or <x-ui.snail-timer> for a playful countdown).
- Showing a wall-clock time: use <x-ui.flip-clock>. Showing how long ago something happened as text: use <x-ui.relative-time>.
- Typing a duration by hand: use <x-ui.duration-input>.
Use instead
- Direct typed entry with validation
Anti-patterns
- Forcing a picker when typing is faster
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
managed
- The ticking time is a plain <time> element (not a live region) preceded by a visually hidden "Elapsed time" label; digits stay left to right under dir="rtl" and use tabular numerals.
- A visually hidden role="status" region announces only state changes: "Timer started", "Timer stopped at 1:23:45" and "Timer discarded", never every second.
- Start/Stop is one button whose visible text (its accessible name) changes, so focus stays on it; Discard is a separate icon button named "Discard timer". After a discard, focus moves to the Start button. The running dot pulses only with motion allowed.
- 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="running-timer-{{ $record->id }}">
{{-- A running entry: the server passes when it began; the control shows the
elapsed time and ticks from the clock. --}}
<div class="w-full max-w-2xl">
<x-ui.running-timer
:started-at="now()->subMinutes(83)->subSeconds(12)->toIso8601String()"
:description="__('Review the release notes')"
/>
</div>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{--
Props:
- startedAt: when the running entry began, as an ISO 8601 string or a
DateTimeInterface from the server. Null (or unreadable) is the idle state.
- description: the draft description of the running entry.
- label / key: an optional generic label (a task title) and key badge (a
task key such as "OPS-12"). The default slot replaces both.
- name: the name of the description input for a plain form post.
- withDescription: set false to hide the description input.
- startMethod / stopMethod / discardMethod: Livewire method names. When
set inside a Livewire component, the action calls the method with the
event detail and waits for the server to re-render a new startedAt.
- confirmDiscard: question shown in an alert dialog before a discard.
- size: control tier of the input and buttons (sm, md or lg).
- variant: default (a full row with the description field) or compact
(one line for a top bar: a quiet Start timer button when idle; the
label, elapsed time, Stop and Discard while running; no description
field). Below the sm breakpoint compact drops the label and shows the
buttons as icons that keep their accessible names.
Start, Stop and Discard dispatch cancelable events (timer-start,
timer-stop, timer-discard); preventDefault() skips the built-in flow so
the consumer can run its own (for example open a dialog).
--}}
@props([
'startedAt' => null,
'description' => null,
'label' => null,
'key' => null,
'name' => 'description',
'placeholder' => null,
'withDescription' => true,
'startMethod' => null,
'stopMethod' => null,
'discardMethod' => null,
'confirmDiscard' => null,
'size' => 'md',
'variant' => 'default',
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$button = $styles['button'];
$input = $styles['input'];
$variant = $styles['normalizeVariant']($variant);
$variant = in_array($variant, ['default', 'compact'], true) ? $variant : 'default';
$compact = $variant === 'compact';
if ($compact) {
// A top bar has no room for a draft field; a named hidden input still posts it.
$withDescription = false;
}
$size = in_array($size, ['sm', 'md', 'lg'], true) ? $size : 'md';
$iconSize = ['sm' => 'icon-sm', 'md' => 'icon', 'lg' => 'icon-lg'][$size];
// One canonical UTC string for the browser, whatever the server passed.
$startedTimestamp = null;
if ($startedAt instanceof \DateTimeInterface) {
$startedTimestamp = $startedAt->getTimestamp();
} elseif (is_string($startedAt) && trim($startedAt) !== '') {
try {
$startedTimestamp = \Illuminate\Support\Carbon::parse($startedAt)->getTimestamp();
} catch (\Throwable) {
$startedTimestamp = null;
}
}
$startedIso = $startedTimestamp !== null ? gmdate('Y-m-d\TH:i:s\Z', $startedTimestamp) : null;
$running = $startedIso !== null;
// First paint shows the real elapsed time; clock skew never shows a negative.
$elapsed = $running ? max(0, now()->getTimestamp() - $startedTimestamp) : 0;
$elapsedText = sprintf('%d:%02d:%02d', intdiv($elapsed, 3600), intdiv($elapsed % 3600, 60), $elapsed % 60);
$elapsedIso = sprintf('PT%dH%dM%dS', intdiv($elapsed, 3600), intdiv($elapsed % 3600, 60), $elapsed % 60);
$hasSlot = trim((string) $slot) !== '';
$hasLabel = $hasSlot || filled($label) || filled($key);
$groupLabel = trim(($key ?? '').' '.($label ?? '')) ?: __('Timer');
$config = [
'methods' => (object) array_filter([
'start' => filled($startMethod) ? (string) $startMethod : null,
'stop' => filled($stopMethod) ? (string) $stopMethod : null,
'discard' => filled($discardMethod) ? (string) $discardMethod : null,
]),
'text' => [
'start' => $compact ? __('Start timer') : __('Start'),
'stop' => __('Stop'),
'started' => __('Timer started'),
'stopped' => __('Timer stopped at :time'),
'discarded' => __('Timer discarded'),
],
];
$discardClass = trim($button['base'].' '.$button['sizes'][$iconSize].' text-muted-foreground hover:bg-muted hover:text-foreground');
$timeSize = $size === 'lg' ? 'text-base' : 'text-sm';
// Compact, below sm: the toggle and discard become square icon buttons;
// their text stays as the accessible name (sr-only), so nothing is lost.
$compactSquare = [
'sm' => 'max-sm:w-control-h-sm max-sm:px-0',
'md' => 'max-sm:w-control-h-md max-sm:px-0',
'lg' => 'max-sm:w-control-h-lg max-sm:px-0',
][$size];
$rootClass = $compact
? 'inline-flex max-w-full min-w-0 flex-nowrap items-center gap-2 whitespace-nowrap text-foreground'
: 'flex w-full min-w-0 flex-wrap items-center gap-2 text-foreground';
@endphp
{{--
Running Timer. Elapsed time since a server-given start, recomputed from
the clock on every tick, with Start/Stop, Discard and a draft
description. The start time lives in data-started-at, outside x-data, so
a Livewire re-render that changes it updates the running state in place.
x-modelable exposes the description for x-model and wire:model.
--}}
<div
role="group"
aria-label="{{ $groupLabel }}"
data-slot="running-timer"
data-size="{{ $size }}"
data-variant="{{ $variant }}"
data-state="{{ $running ? 'running' : 'idle' }}"
data-started-at="{{ $startedIso }}"
data-description="{{ $description }}"
x-data="uiRunningTimer({{ \Illuminate\Support\Js::from($config) }})"
x-modelable="timerDraft"
x-bind:data-state="timerRunning ? 'running' : 'idle'"
{{ $attributes->merge(['class' => $rootClass]) }}
>
@if ($hasLabel)
@if ($compact)
{{-- Compact: only while running, and only from sm up; the group
label still names the control for screen readers on a phone. --}}
<div
data-slot="running-timer-label"
class="hidden min-w-0 max-w-60 items-center gap-2 sm:flex"
@unless ($running) style="display: none" @endunless
x-show="timerRunning"
>
@else
<div data-slot="running-timer-label" class="flex min-w-40 flex-1 items-center gap-2">
@endif
@if ($hasSlot)
{{ $slot }}
@else
@if (filled($key))
<x-ui.badge variant="secondary" class="shrink-0 font-mono">{{ $key }}</x-ui.badge>
@endif
@if (filled($label))
<span data-slot="running-timer-title" class="min-w-0 truncate text-sm font-medium">{{ $label }}</span>
@endif
@endif
</div>
@endif
{{-- Without a label the description leads the row; with one it drops to
its own row under the label and actions. The DOM follows the visual
order, so Tab order matches what people see. --}}
@foreach ([true, false] as $leading)
@if ($leading === ! $hasLabel)
@if ($withDescription)
<input
type="text"
data-slot="running-timer-description"
@if (filled($name)) name="{{ $name }}" @endif
value="{{ $description }}"
x-model="timerDraft"
autocomplete="off"
aria-label="{{ __('Description') }}"
placeholder="{{ $placeholder ?? __('What are you working on?') }}"
class="{{ trim($input['base'].' '.$input['sizes'][$size].' '.$input['states']['default'].' '.($hasLabel ? 'basis-full' : 'basis-40 flex-1')) }}"
/>
@elseif (filled($name))
<input type="hidden" name="{{ $name }}" value="{{ $description }}" x-bind:value="timerDraft" />
@endif
@endif
@if ($leading)
<div class="{{ $compact ? 'flex shrink-0 items-center gap-1' : 'ms-auto flex shrink-0 items-center gap-2' }}">
<span
data-slot="running-timer-display"
class="inline-flex items-center gap-2 px-1"
@if ($compact)
@unless ($running) style="display: none" @endunless
x-show="timerRunning"
@endif
>
<span
data-slot="running-timer-indicator"
aria-hidden="true"
class="{{ $running ? 'bg-success motion-safe:animate-pulse' : 'bg-border' }} size-2 shrink-0 rounded-full"
x-bind:class="{ 'bg-success motion-safe:animate-pulse': timerRunning, 'bg-border': !timerRunning }"
></span>
<span class="sr-only">{{ __('Elapsed time') }}</span>
{{-- Not a live region: the status line below speaks the state
changes, never every second. dir="ltr" keeps H:MM:SS in
order under RTL. --}}
<time
data-slot="running-timer-time"
dir="ltr"
datetime="{{ $elapsedIso }}"
class="{{ $timeSize }} {{ $running ? '' : 'text-muted-foreground' }} font-medium tabular-nums"
x-bind:class="{ 'text-muted-foreground': !timerRunning }"
x-bind:datetime="timerIsoDuration"
x-text="timerDisplay"
>{{ $elapsedText }}</time>
</span>
<x-ui.button
:size="$size"
:variant="$compact ? 'ghost' : 'default'"
:class="$compact ? $compactSquare : null"
data-timer-action="toggle"
x-ref="timerToggle"
x-on:click="timerToggle()"
x-bind:disabled="timerBusy"
x-bind:aria-busy="timerBusy ? 'true' : null"
>
<x-slot:icon-leading>
@if ($compact)
<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" data-timer-icon="start" @if ($running) style="display: none" @endif x-show="!timerRunning"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>
@else
<svg class="size-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" data-timer-icon="start" @if ($running) style="display: none" @endif x-show="!timerRunning"><path d="M7 4.5v15a1 1 0 0 0 1.5.86l12-7.5a1 1 0 0 0 0-1.72l-12-7.5A1 1 0 0 0 7 4.5Z" /></svg>
@endif
<svg class="size-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" data-timer-icon="stop" @unless ($running) style="display: none" @endunless x-show="timerRunning"><rect x="5" y="5" width="14" height="14" rx="2" /></svg>
</x-slot:icon-leading>
<span @if ($compact) class="max-sm:sr-only" @endif x-text="timerRunning ? timerText.stop : timerText.start">{{ $running ? __('Stop') : $config['text']['start'] }}</span>
</x-ui.button>
@php
$trashIcon = '<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="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3" /></svg>';
@endphp
@if (filled($confirmDiscard))
<div class="contents" @unless ($running) style="display: none" @endunless x-show="timerRunning">
<x-ui.alert-dialog>
<x-ui.alert-dialog.trigger
data-timer-action="discard"
aria-label="{{ __('Discard timer') }}"
title="{{ __('Discard timer') }}"
class="{{ $discardClass }}"
>{!! $trashIcon !!}</x-ui.alert-dialog.trigger>
<x-ui.alert-dialog.content>
<x-ui.alert-dialog.header>
<x-ui.alert-dialog.title>{{ __('Discard timer?') }}</x-ui.alert-dialog.title>
<x-ui.alert-dialog.description>{{ $confirmDiscard }}</x-ui.alert-dialog.description>
</x-ui.alert-dialog.header>
<x-ui.alert-dialog.footer>
<x-ui.alert-dialog.cancel>{{ __('Cancel') }}</x-ui.alert-dialog.cancel>
<x-ui.alert-dialog.action variant="destructive" data-timer-action="confirm-discard" x-on:click="timerDiscard()">{{ __('Discard') }}</x-ui.alert-dialog.action>
</x-ui.alert-dialog.footer>
</x-ui.alert-dialog.content>
</x-ui.alert-dialog>
</div>
@else
<button
type="button"
data-slot="running-timer-discard"
data-timer-action="discard"
aria-label="{{ __('Discard timer') }}"
title="{{ __('Discard timer') }}"
class="{{ $discardClass }}"
@unless ($running) style="display: none" @endunless
x-show="timerRunning"
x-on:click="timerDiscard()"
x-bind:disabled="timerBusy"
>{!! $trashIcon !!}</button>
@endif
</div>
@endif
@endforeach
<span data-slot="running-timer-status" role="status" class="sr-only" x-text="timerAnnouncement"></span>
</div>
/**
* Running Timer behaviour.
*
* Elapsed time is `Date.now() - Date.parse(startedAt)` on every tick, never a
* counter, so it cannot drift and a throttled background tab shows the right
* time again on the next tick. Negative elapsed (clock skew) shows 0:00:00.
* The tick is a setTimeout aligned to the next whole elapsed second; it stops
* when idle, while `document.hidden`, and in destroy().
*
* The start time is read from the root's `data-started-at` attribute and
* watched with a MutationObserver, so a Livewire re-render (or any server
* swap) that changes it moves the control between idle and running in place.
*
* Actions dispatch bubbling CustomEvents on the root:
* timer-start { startedAt, description }
* timer-stop { startedAt, stoppedAt, seconds, description }
* timer-discard { startedAt, description }
* Every event is cancelable: a listener that calls preventDefault() stops the
* built-in flow (no state change, no Livewire call), so the consumer can run
* its own, such as a start dialog, and render the new startedAt itself.
* With a Livewire method configured for the action and a Livewire component
* around the control, the event still fires and `$wire.call(method, detail)`
* runs as well; the control then waits for the server's new start time and
* changes nothing optimistically. Otherwise the control switches its own
* state (and clears the description after a stop or discard).
*
* Internal state is prefixed (`timer*`): an x-model on the root resolves in
* this scope first, so a plain name would shadow the consumer's property.
*/
const pad = (value) => String(value).padStart(2, '0');
export function formatElapsed(totalSeconds) {
const seconds = Math.max(0, Math.floor(totalSeconds));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}:${pad(minutes)}:${pad(seconds % 60)}`;
}
function isoDuration(totalSeconds) {
const seconds = Math.max(0, Math.floor(totalSeconds));
return `PT${Math.floor(seconds / 3600)}H${Math.floor((seconds % 3600) / 60)}M${seconds % 60}S`;
}
function parseStart(value) {
if (typeof value !== 'string' || value.trim() === '') return null;
const ms = Date.parse(value);
return Number.isFinite(ms) ? ms : null;
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiRunningTimer', (config = {}) => {
// Kept outside the reactive object: DOM nodes, observers and
// handles must not be wrapped in Alpine proxies.
let root = null;
let handle = null;
let observer = null;
let onVisibility = null;
return {
timerStartedAt: null,
timerStartMs: null,
timerSeconds: 0,
timerDraft: '',
timerBusy: false,
timerAnnouncement: '',
timerPending: null,
timerStopText: '',
timerRefocus: false,
timerMethods: config.methods || {},
timerText: config.text || {},
get timerRunning() {
return this.timerStartedAt !== null;
},
get timerDisplay() {
return formatElapsed(this.timerSeconds);
},
get timerIsoDuration() {
return isoDuration(this.timerSeconds);
},
init() {
root = this.$el;
this.timerDraft = root.dataset.description || '';
this.timerApply(root.getAttribute('data-started-at'), { silent: true });
observer = new MutationObserver(() => {
this.timerApply(root.getAttribute('data-started-at'));
});
observer.observe(root, { attributes: true, attributeFilter: ['data-started-at'] });
onVisibility = () => {
if (document.hidden) {
this.timerClear();
} else {
this.timerTick();
}
};
document.addEventListener('visibilitychange', onVisibility);
},
destroy() {
this.timerClear();
observer?.disconnect();
observer = null;
document.removeEventListener('visibilitychange', onVisibility);
},
timerClear() {
if (handle !== null) clearTimeout(handle);
handle = null;
},
/** Recompute from the clock, then wait for the next whole elapsed second. */
timerTick() {
this.timerClear();
if (this.timerStartMs === null) {
this.timerSeconds = 0;
return;
}
const elapsed = Math.max(0, Date.now() - this.timerStartMs);
this.timerSeconds = Math.floor(elapsed / 1000);
if (document.hidden) return;
handle = setTimeout(() => this.timerTick(), 1000 - (elapsed % 1000));
},
/** Move to a new start time (null = idle) and announce the change once. */
timerApply(value, { silent = false } = {}) {
const ms = parseStart(value);
const next = ms === null ? null : new Date(ms).toISOString();
if (next === this.timerStartedAt) return;
const wasRunning = this.timerRunning;
const lastDisplay = this.timerDisplay;
this.timerStartedAt = next;
this.timerStartMs = ms;
this.timerTick();
if (!silent && !wasRunning && this.timerRunning) {
this.timerAnnounce(this.timerText.started);
} else if (!silent && wasRunning && !this.timerRunning) {
this.timerAnnounce(
this.timerPending === 'discard'
? this.timerText.discarded
: String(this.timerText.stopped || '').replace(
':time',
this.timerStopText || lastDisplay,
),
);
}
this.timerPending = null;
this.timerStopText = '';
if (!this.timerRunning && this.timerRefocus) {
this.timerRefocus = false;
// Start/Stop is always visible; the discard button just hid.
this.$refs.timerToggle?.focus();
}
},
// Messages always alternate (started, then stopped or discarded), so the
// region never has to be cleared to repeat one.
timerAnnounce(message) {
if (message) this.timerAnnouncement = message;
},
/** Dispatch a cancelable event; false when a listener prevented it. */
timerEmit(name, detail) {
return (root || this.$root).dispatchEvent(
new CustomEvent(name, { detail, bubbles: true, composed: true, cancelable: true }),
);
},
/** The nearest Livewire component, or null outside Livewire. */
timerWire() {
try {
return window.Livewire && this.$wire ? this.$wire : null;
} catch {
return null;
}
},
/** Call the configured Livewire method, or run the local state change. */
async timerPerform(action, detail, local) {
this.timerPending = action;
const method = this.timerMethods[action];
const wire = method ? this.timerWire() : null;
if (!wire) {
local();
return;
}
this.timerBusy = true;
try {
await wire.call(method, detail);
} finally {
this.timerBusy = false;
}
},
timerToggle() {
if (this.timerRunning) {
this.timerStop();
} else {
this.timerStart();
}
},
timerStart() {
if (this.timerBusy || this.timerRunning) return;
const startedAt = new Date().toISOString();
const detail = { startedAt, description: this.timerDraft };
if (!this.timerEmit('timer-start', detail)) return;
return this.timerPerform('start', detail, () => this.timerApply(startedAt));
},
timerStop() {
if (this.timerBusy || !this.timerRunning) return;
const stoppedAt = Date.now();
const seconds = Math.max(0, Math.floor((stoppedAt - this.timerStartMs) / 1000));
const detail = {
startedAt: this.timerStartedAt,
stoppedAt: new Date(stoppedAt).toISOString(),
seconds,
description: this.timerDraft,
};
if (!this.timerEmit('timer-stop', detail)) return;
this.timerStopText = formatElapsed(seconds);
return this.timerPerform('stop', detail, () => {
this.timerApply(null);
this.timerDraft = '';
});
},
timerDiscard() {
if (this.timerBusy || !this.timerRunning) return;
const detail = { startedAt: this.timerStartedAt, description: this.timerDraft };
if (!this.timerEmit('timer-discard', detail)) return;
this.timerRefocus = true;
return this.timerPerform('discard', detail, () => {
this.timerApply(null);
this.timerDraft = '';
});
},
};
});
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.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