Skip to content
Brok UI

Loading…

No results

Duration Input

Open source

A text field that accepts free-form durations ("1:30", "90m", "1.5h", "1h30", "1,5u") and normalizes them to integer minutes on blur/Enter, formatting the committed value as a clock, hour/minute, or decimal string.

Version
v1.0.1
Stability
stable
License
MIT
Related
Time Picker
Number Field
Field

Preview

Type a duration like 1:30, 90m or 1.5h.

Disabled
previews.components.duration-input.default.blade.php Blade
<div class="w-full max-w-xs">
    <x-ui.field name="focus_time" label="{{ __('Focus time') }}" hint="{{ __('Type a duration like 1:30, 90m or 1.5h.') }}">
        <x-ui.duration-input name="focus_time" :value="90" />
    </x-ui.field>
</div>

Installation

terminal
php artisan ui:add duration-input

Note

This component ships an Alpine behavior module at resources/js/ui/duration-input.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './duration-input.js';

Registry contract

php artisan ui:add duration-input 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.

  • blade resources/views/components/ui/duration-input.blade.php
  • js resources/js/ui/duration-input.js
  • js resources/js/ui/duration.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

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.

duration-input.md
# Brok UI: Duration Input (`duration-input`)

A text field that accepts free-form durations ("1:30", "90m", "1.5h", "1h30", "1,5u") and normalizes them to integer minutes on blur/Enter, formatting the committed value as a clock, hour/minute, or decimal string.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add duration-input
```

## Usage

```blade
<div class="w-full max-w-xs">
    <x-ui.field name="focus_time" label="{{ __('Focus time') }}" hint="{{ __('Type a duration like 1:30, 90m or 1.5h.') }}">
        <x-ui.duration-input name="focus_time" :value="90" />
    </x-ui.field>
</div>
```

## Props

- `name` (string|null, default `null`) — Hidden input name carrying the normalized minutes; inherited from a wrapping <x-ui.field name="…"> via @aware if omitted.
- `value` (int|null, default `null`) — Initial value in minutes. Repopulated from old() after a failed redirect-back when the field has a name.
- `min` (int|null, default `null`) — Lower bound in minutes. A parsed value below it is refused as invalid.
- `max` (int|null, default `null`) — Upper bound in minutes. A parsed value above it is refused as invalid.
- `step` (int|null, default `null`) — Rounds a parsed value to the nearest multiple of this many minutes (e.g. 15). Omit for no rounding.
- `format` (clock|hm|decimal, default `clock`) — How a committed value re-renders as text: clock ("1:30", "0:05"), hm ("1h 30m", "45m", "2h"), or decimal ("1.5", using decimalSeparator).
- `decimalSeparator` (string|null, default `null`) — Decimal separator used by format="decimal" and accepted when parsing. Defaults from the app locale (e.g. "," for nl).
- `bareUnit` (minutes|hours, default `minutes`) — How a bare number with no unit is interpreted: "90" means 90 minutes by default; set to "hours" so "2" means 120 minutes.
- `placeholder` (string|null, default `null`) — Placeholder shown while empty. Defaults to a translated "0:00".
- `invalidMessage` (string|null, default `null`) — Overrides the translated message shown while the typed text cannot be parsed or falls outside min/max.
- `size` (sm|md|lg, default `md`) — Control tier — 32 / 40 / 48px, matching input and its neighbours.
- `disabled` (bool, default `false`) — Renders the control inert and dimmed.
- `readonly` (bool, default `false`) — Keeps the committed value visible and postable but blocks typing.

## Use when

- Use when users must enter freeform information that cannot be reliably selected from a list.
- Collecting a length of time (a task estimate, a timesheet entry, a meeting length) as a single typed field instead of separate hour/minute controls.
- Needing the value normalized to integer minutes for storage/posting regardless of how the visitor typed it ("1:30", "90m", "1.5h", "1h30", "1,5u").

## Avoid when

- Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
- Collecting a time of day rather than an elapsed length — use <x-ui.time-picker> instead.
- Collecting a plain bounded number with no unit parsing — use <x-ui.number-field>.

## Anti-patterns

- Using freeform entry for a bounded answer set

## Rules

- Use the `<brok:duration-input>` tag (or `<x-ui.duration-input>`) 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/duration-input
- Registry JSON (files, props, contract): https://brokui.dev/r/open/duration-input.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

binding.blade.php Blade
{{-- x-model binds the outer Alpine state to the committed minutes: type a
     duration, blur, and the readout below updates. Inside a Livewire
     component, wire:model (or .live / .blur) on the same tag binds an int|null
     property through the same x-modelable contract. --}}
<div class="w-full max-w-xs space-y-4" x-data="{ minutes: 90 }">
    <x-ui.field name="length" label="{{ __('Length') }}">
        <x-ui.duration-input name="length" x-model="minutes" />
    </x-ui.field>

    <p class="text-sm text-muted-foreground">
        {{ __('Bound value:') }} <span x-text="minutes ?? 'null'"></span> {{ __('minutes') }}
    </p>
</div>
disabled.blade.php Blade
<div class="w-full max-w-xs">
    <x-ui.field name="locked_estimate" label="{{ __('Estimate') }}">
        <x-ui.duration-input name="locked_estimate" :value="90" disabled />
    </x-ui.field>
</div>
formats.blade.php Blade
{{-- The same 90-minute value rendered by each display format. --}}
<div class="flex w-full max-w-xs flex-col gap-6">
    <x-ui.field name="clock_format" label="{{ __('Clock (default)') }}">
        <x-ui.duration-input name="clock_format" :value="90" format="clock" />
    </x-ui.field>

    <x-ui.field name="hm_format" label="{{ __('Hours and minutes') }}">
        <x-ui.duration-input name="hm_format" :value="90" format="hm" />
    </x-ui.field>

    <x-ui.field name="decimal_format" label="{{ __('Decimal') }}">
        <x-ui.duration-input name="decimal_format" :value="90" format="decimal" />
    </x-ui.field>
</div>
invalid.blade.php Blade
{{-- Scripts a visitor typing garbage and blurring, so the invalid state
     (kept text, aria-invalid, the linked role="alert" message) renders
     settled for review without needing an interaction step. --}}
<div
    class="w-full max-w-xs"
    x-data
    x-init="$nextTick(() => {
        const field = $el.querySelector('[data-slot=duration-input-input]');
        field.value = '1h 90x';
        field.dispatchEvent(new Event('input'));
        field.dispatchEvent(new Event('blur'));
    })"
>
    <x-ui.field name="break_time" label="{{ __('Break time') }}">
        <x-ui.duration-input name="break_time" />
    </x-ui.field>
</div>
long-content.blade.php Blade
{{-- A narrow container with a long label and hint verifies both wrap and
     truncate without pushing the field itself wider than its column or
     clipping the help text a person needs to read. --}}
<div class="w-40">
    <x-ui.field
        name="task_estimate"
        label="{{ __('Estimated time for this recurring quarterly compliance review task') }}"
        hint="{{ __('Type a duration such as 1:30, 90 minutes, or 1.5 hours — it normalizes to whole minutes automatically.') }}"
    >
        <x-ui.duration-input name="task_estimate" :value="135" />
    </x-ui.field>
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
name string | null null Hidden input name carrying the normalized minutes; inherited from a wrapping <x-ui.field name="…"> via @aware if omitted.
value int | null null Initial value in minutes. Repopulated from old() after a failed redirect-back when the field has a name.
min int | null null Lower bound in minutes. A parsed value below it is refused as invalid.
max int | null null Upper bound in minutes. A parsed value above it is refused as invalid.
step int | null null Rounds a parsed value to the nearest multiple of this many minutes (e.g. 15). Omit for no rounding.
format clock | hm | decimal clock How a committed value re-renders as text: clock ("1:30", "0:05"), hm ("1h 30m", "45m", "2h"), or decimal ("1.5", using decimalSeparator).
decimalSeparator string | null null Decimal separator used by format="decimal" and accepted when parsing. Defaults from the app locale (e.g. "," for nl).
bareUnit minutes | hours minutes How a bare number with no unit is interpreted: "90" means 90 minutes by default; set to "hours" so "2" means 120 minutes.
placeholder string | null null Placeholder shown while empty. Defaults to a translated "0:00".
invalidMessage string | null null Overrides the translated message shown while the typed text cannot be parsed or falls outside min/max.
size sm | md | lg md Control tier — 32 / 40 / 48px, matching input and its neighbours.
disabled bool false Renders the control inert and dimmed.
readonly bool false Keeps the committed value visible and postable but blocks typing.

Slots

  • default — Not used by this component; present for attribute-bag compatibility.

Data slots

Stable hooks for CSS overrides and browser tests.

duration-input duration-input-input duration-input-message

Behavior

  • Typing a duration ("1:30", "90", "90m", "1.5h", "1h30", "1h 30m", "1,5u") and pressing Enter or blurring parses it to integer minutes, rounds to `step` when set, and re-renders the field in the configured `format`.
  • While the typed text cannot be parsed, or the parsed value falls outside min/max, the last committed minutes are kept (never overwritten with garbage): the field shows aria-invalid="true", the user's own text stays on screen, and a role="alert" message linked via aria-describedby explains what is expected.
  • The committed minutes are exposed on the root through x-modelable (`x-model`/`wire:model`/`wire:model.live`/`wire:model.blur` all work) and mirrored into a hidden input when named; setting the value from outside (a Livewire property update) reformats the visible text. Committing dispatches a `duration-change` CustomEvent (`detail: { minutes, text }`) plus native `input`/`change` on the hidden input.
  • 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

Freeform text input

Collect unpredictable freeform information.

Use when

  • Use when users must enter freeform information that cannot be reliably selected from a list.
  • Collecting a length of time (a task estimate, a timesheet entry, a meeting length) as a single typed field instead of separate hour/minute controls.
  • Needing the value normalized to integer minutes for storage/posting regardless of how the visitor typed it ("1:30", "90m", "1.5h", "1h30", "1,5u").

Avoid when

  • Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
  • Collecting a time of day rather than an elapsed length — use <x-ui.time-picker> instead.
  • Collecting a plain bounded number with no unit parsing — use <x-ui.number-field>.

Use instead

  • Radio or checkbox for bounded choices
  • Select or combobox for known options

Anti-patterns

  • Using freeform entry for a bounded answer set
Anatomy
duration-input duration-input-input duration-input-message
Theming hooks
input

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Enter
Focus
native
  • aria-invalid and a role="alert" message (linked via aria-describedby) announce both a client-side parse failure and a server-side validation error from the shared $errors bag the same way other named inputs do.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="duration-input-{{ $record->id }}">
    <div class="w-full max-w-xs">
        <x-ui.field name="focus_time" label="{{ __('Focus time') }}" hint="{{ __('Type a duration like 1:30, 90m or 1.5h.') }}">
            <x-ui.duration-input name="focus_time" :value="90" />
        </x-ui.field>
    </div>
</div>

Validation

Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:duration-input
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/duration-input.blade.php Blade
{{-- A wrapping <x-ui.field name="…"> shares its name down via @aware so this
     input self-wires old()/aria-invalid/aria-describedby off the shared
     $errors bag. Inert with no field ancestor and no own `name` —
     fully backward-compatible. --}}
@aware([
    'name' => null,
    'hint' => null,
    'required' => false,
])

{{--
    Props:
    - value: initial minutes (int|null). Free-form text the visitor types is
      normalized to integer minutes on blur/Enter.
    - min / max: minute bounds; a parsed value outside them is refused.
    - step: rounds a parsed value to the nearest multiple of `step` minutes
      (e.g. 15). Omit for no rounding.
    - format: how a committed value re-renders as text — `clock` ("1:30"),
      `hm` ("1h 30m"), or `decimal` ("1.5", using `decimalSeparator`).
    - bareUnit: a bare number with no unit means minutes by default
      ("90" -> 90); set "hours" so a bare number means hours ("2" -> 120).
--}}
@props([
    'name' => null,
    'value' => null,
    'min' => null,
    'max' => null,
    'step' => null,
    'format' => 'clock',
    'decimalSeparator' => null,
    'bareUnit' => 'minutes',
    'placeholder' => null,
    'invalidMessage' => null,
    'size' => 'md',
    'disabled' => false,
    'readonly' => false,
])

@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $input = $styles['input'];

    $size = isset($input['sizes'][$size]) ? $size : 'md';
    $format = in_array($format, ['clock', 'hm', 'decimal'], true) ? $format : 'clock';
    $bareUnit = $bareUnit === 'hours' ? 'hours' : 'minutes';

    // @aware leaves $name undefined without a wrapping field; normalise it.
    $awareName = $name ?? null;
    $fieldName = $awareName ?? $attributes->get('name');

    // The shared error bag drives invalid state on first paint, exactly like
    // <x-ui.input> and <x-ui.time-picker>; a wrapping <x-ui.field> renders the
    // visible message, this component only needs to point at it.
    $bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
    $hasBagError = $bag && filled($fieldName) && $bag->has($fieldName);

    // old() repopulates the minutes value after a failed redirect-back.
    $resolvedValue = filled($fieldName) ? old($fieldName, $value) : $value;
    $resolvedValue = is_numeric($resolvedValue) ? (int) round((float) $resolvedValue) : null;

    // Defaults from the app locale (",". for nl, "." for en, …) unless overridden.
    $numberFormatter = class_exists(\NumberFormatter::class) ? new \NumberFormatter(app()->getLocale(), \NumberFormatter::DECIMAL) : null;
    $resolvedDecimalSeparator = $decimalSeparator ?? ($numberFormatter?->getSymbol(\NumberFormatter::DECIMAL_SEPARATOR_SYMBOL) ?: '.');

    $resolvedPlaceholder = $placeholder ?? __('0:00');
    $resolvedInvalidMessage = $invalidMessage ?? __('Enter a duration such as 1:30, 90m or 1.5h.');

    $describedBy = collect(preg_split('/\s+/', trim((string) $attributes->get('aria-describedby', ''))) ?: [])
        ->when(filled($awareName) && filled($hint ?? null), fn ($ids) => $ids->push($fieldName.'-description'))
        ->when(filled($awareName) && $hasBagError, fn ($ids) => $ids->push($fieldName.'-error'))
        ->filter()
        ->unique()
        ->implode(' ');

    $isRequired = (bool) ($required ?? false) || $attributes->has('required');

    // A stable id for the client-side invalid message; derived from the
    // consumer/field id when there is one, otherwise a per-render fallback.
    $baseId = $attributes->get('id', $fieldName);
    $invalidId = filled($baseId) ? $baseId.'-invalid' : 'duration-input-'.\Illuminate\Support\Str::random(8).'-invalid';
@endphp

{{--
    Duration Input. A text field you type a duration into directly ("1:30",
    "90m", "1.5h", "1h30", "1,5u" all parse on Enter or blur) and that always
    normalizes to integer minutes for the hidden form value and the
    x-modelable binding, re-rendering the display text in the chosen format.
--}}
<div
    x-data="uiDurationInput({
        value: @js($resolvedValue),
        min: @js($min !== null ? (int) $min : null),
        max: @js($max !== null ? (int) $max : null),
        step: @js($step !== null ? (int) $step : null),
        format: @js($format),
        decimalSeparator: @js($resolvedDecimalSeparator),
        bareUnit: @js($bareUnit),
        unitHour: @js(__('h')),
        unitMinute: @js(__('m')),
        describedBy: @js($describedBy),
        invalidId: @js($invalidId),
    })"
    {{-- Modelable: `x-model` (Alpine) and `wire:model`/`wire:model.live`/
         `wire:model.blur` (Livewire binds through the same x-model contract)
         attach to the root. Inert without either. --}}
    x-modelable="model"
    data-slot="duration-input"
    data-size="{{ $size }}"
    x-bind:data-invalid="(parseInvalid || {{ $hasBagError ? 'true' : 'false' }}) ? 'true' : null"
    {{ $attributes->except(['name', 'required', 'aria-describedby', 'id'])->merge(['class' => 'w-full']) }}
>
    @if (filled($fieldName))
        <input type="hidden" name="{{ $fieldName }}" x-ref="hidden" :value="committedMinutes ?? ''" />
    @endif

    <input
        type="text"
        inputmode="text"
        autocomplete="off"
        data-slot="duration-input-input"
        @if (filled($baseId)) id="{{ $baseId }}" @endif
        x-model="draftText"
        x-bind:aria-invalid="(parseInvalid || {{ $hasBagError ? 'true' : 'false' }}) ? 'true' : null"
        x-bind:aria-describedby="ariaDescribedBy"
        placeholder="{{ $resolvedPlaceholder }}"
        @disabled($disabled)
        @if ($readonly) readonly @endif
        @if ($isRequired) aria-required="true" @endif
        @keydown.enter.prevent="commit()"
        @blur="commit()"
        {{-- Static state matches server truth (the shared $errors bag) so
             there is no flash before Alpine hydrates; the object binding
             then swaps the default border for the invalid one on a
             client-side parse failure (Alpine removes a false key's classes,
             static ones included) and swaps it back on the next valid commit. --}}
        class="{{ trim($input['base'].' '.$input['sizes'][$size].' '.($hasBagError ? $input['states']['invalid'] : $input['states']['default'])) }}"
        x-bind:class="{ @js($input['states']['default']): !(parseInvalid || {{ $hasBagError ? 'true' : 'false' }}), @js($input['states']['invalid']): parseInvalid || {{ $hasBagError ? 'true' : 'false' }} }"
    />

    <p
        data-slot="duration-input-message"
        id="{{ $invalidId }}"
        role="alert"
        x-show="parseInvalid"
        x-cloak
        class="mt-1 text-xs text-destructive"
    >{{ $resolvedInvalidMessage }}</p>
</div>
resources/js/ui/duration-input.js JS
import { parseDuration, formatDuration } from './duration.js';

/**
 * Duration Input behavior.
 *
 * Parses free-form duration text ("1:30", "90m", "1.5h", "1,5u", ...) into
 * integer minutes on blur/Enter and re-renders it in the configured display
 * format. `parseDuration`/`formatDuration` are pure and live in `./duration.js`
 * so another item (a timesheet grid, for instance) can reuse the same
 * conversion rules by importing that module directly.
 *
 * Modelable: `x-modelable="model"` on the root exposes the committed minutes
 * (`int|null`) for `x-model`/`wire:model`/`wire:model.live`/`wire:model.blur`.
 * Setting it from outside (a Livewire property update) reformats the visible
 * text. A hidden input mirrors the same value for plain form posts and fires
 * native `input`/`change` so external listeners see the commit too.
 *
 * While the typed text cannot be parsed, or falls outside min/max, the last
 * committed minutes are kept — never overwritten with garbage. Only the
 * visible text and the `invalid` flag change until the next valid commit.
 *
 * Internal state uses prefixed names (committedMinutes, draftText,
 * parseInvalid): an `x-model` on the root resolves in this component's own
 * scope first, so a plain name such as `minutes` would shadow the consumer's
 * outer property.
 *
 * Fully self-contained in x-data — it adds no window/document listeners, so
 * there is nothing to tear down. Self-registers on `alpine:init` so import
 * order does not matter.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiDurationInput', (config = {}) => ({
        committedMinutes: config.value ?? null,
        min: config.min ?? null,
        max: config.max ?? null,
        step: config.step ?? null,
        bareUnit: config.bareUnit === 'hours' ? 'hours' : 'minutes',
        format: config.format || 'clock',
        decimalSeparator: config.decimalSeparator || '.',
        unitHour: config.unitHour || 'h',
        unitMinute: config.unitMinute || 'm',
        describedBy: config.describedBy || '',
        invalidId: config.invalidId || '',
        draftText: '',
        // Client-side parse-failure flag only. A server-side validation error
        // (the shared $errors bag) is a separate, always-true static condition
        // baked into the Blade's aria-invalid/aria-describedby/data-invalid —
        // it drives the wrapping <x-ui.field>'s own message, not this one.
        parseInvalid: false,

        init() {
            this.draftText = this.committedMinutes == null ? '' : this.render(this.committedMinutes);
        },

        render(minutes) {
            return formatDuration(minutes, {
                format: this.format,
                decimalSeparator: this.decimalSeparator,
                unitHour: this.unitHour,
                unitMinute: this.unitMinute,
            });
        },

        /** Modelable value (`x-modelable="model"`): committed minutes, int|null. */
        get model() {
            return this.committedMinutes;
        },
        set model(v) {
            const next = v === '' || v === undefined ? null : v;
            const normalized = next === null ? null : Number(next);
            const resolved = normalized !== null && Number.isFinite(normalized) ? Math.round(normalized) : null;
            if (resolved === this.committedMinutes) return;
            this.committedMinutes = resolved;
            this.draftText = resolved === null ? '' : this.render(resolved);
            this.parseInvalid = false;
        },

        get ariaDescribedBy() {
            const ids = this.describedBy ? [this.describedBy] : [];
            if (this.parseInvalid && this.invalidId) ids.push(this.invalidId);
            return ids.length ? ids.join(' ') : null;
        },

        // Applies `step` rounding, then rejects (returns null) outside [min, max].
        bounded(parsedMinutes) {
            let v = parsedMinutes;
            if (this.step) v = Math.round(v / this.step) * this.step;
            if (this.min !== null && v < this.min) return null;
            if (this.max !== null && v > this.max) return null;
            return v;
        },

        /** Enter or blur: parse the typed text and commit, or flag it invalid. */
        commit() {
            const raw = this.draftText;
            if (raw.trim() === '') {
                this.parseInvalid = false;
                this.setMinutes(null);
                return;
            }

            const parsed = parseDuration(raw, { bareUnit: this.bareUnit });
            if (parsed === null || parsed < 0) {
                this.parseInvalid = true;
                return;
            }

            const bounded = this.bounded(parsed);
            if (bounded === null) {
                this.parseInvalid = true;
                return;
            }

            this.parseInvalid = false;
            this.setMinutes(bounded);
        },

        setMinutes(minutes) {
            this.committedMinutes = minutes;
            this.draftText = minutes === null ? '' : this.render(minutes);
            this.$dispatch('duration-change', { minutes, text: this.draftText });
            this.$nextTick(() => {
                const hidden = this.$refs.hidden;
                if (!hidden) return;
                hidden.dispatchEvent(new Event('input', { bubbles: true }));
                hidden.dispatchEvent(new Event('change', { bubbles: true }));
            });
        },
    }));
});
resources/js/ui/duration.js JS
/**
 * Duration parsing/formatting — pure, framework-free helpers shared by
 * `duration-input` and any other item that needs the same free-form
 * duration <-> integer-minutes conversion (e.g. a timesheet grid). Import
 * them from `./duration.js`; the registry flattens every item's JS into one
 * `resources/js/ui/` folder on install, so this relative import resolves
 * whether the file was copied alongside its own item or pulled in as a
 * `registryDependencies` target of `duration-input`.
 *
 * No DOM access, no Alpine, no globals — safe to unit test directly.
 */

// A number with an optional `.`/`,` decimal separator, or a leading separator
// with no integer part (".25", ",25").
const NUMBER = '(?:\\d+(?:[.,]\\d+)?|[.,]\\d+)';
const HOUR_UNIT = 'h|hr|hrs|hour|hours|u|uur';
const MINUTE_UNIT = 'm|min|mins|minute|minutes';

// "H:MM" / "HH:MM" — minutes must be 0-59.
const CLOCK_PATTERN = /^(\d{1,3}):([0-5]\d)$/;
// "1h30", "1h 30m", "1h30m", "1,5u" — an hour unit, an optional 1-2 digit
// minute part, an optional minute unit.
const COMPOUND_PATTERN = new RegExp(`^(${NUMBER})\\s*(${HOUR_UNIT})\\s*(\\d{1,2})?\\s*(${MINUTE_UNIT})?$`, 'i');
// A bare number with at most one trailing unit — "90", "90m", "90 min",
// "1.5h", ".25h", ",25u".
const UNIT_PATTERN = new RegExp(`^(${NUMBER})\\s*(${HOUR_UNIT}|${MINUTE_UNIT})?$`, 'i');

const HOUR_UNIT_TEST = new RegExp(`^(?:${HOUR_UNIT})$`, 'i');

function toNumber(text) {
    return Number(text.replace(',', '.'));
}

/**
 * Parse free-form duration text into integer minutes, or `null` when it
 * cannot be parsed (garbage, a negative, an out-of-range clock minute such
 * as "1:75", or a mismatched trailing token such as "1h 90x"). Empty/
 * whitespace-only text also returns `null` — callers treat that as "no
 * value" rather than "invalid", since there is nothing to reject.
 *
 * @param {string} text
 * @param {{ bareUnit?: 'minutes'|'hours' }} [options]
 * @returns {number|null}
 */
export function parseDuration(text, options = {}) {
    if (text == null) return null;
    const bareUnit = options.bareUnit === 'hours' ? 'hours' : 'minutes';
    const raw = String(text).trim();
    if (raw === '') return null;
    const normalized = raw.toLowerCase();

    let match = normalized.match(CLOCK_PATTERN);
    if (match) {
        const hours = Number(match[1]);
        const minutes = Number(match[2]);
        return hours * 60 + minutes;
    }

    match = normalized.match(COMPOUND_PATTERN);
    if (match) {
        const hours = toNumber(match[1]);
        const minutes = match[3] ? Number(match[3]) : 0;
        if (minutes > 59) return null;
        return Math.round(hours * 60 + minutes);
    }

    match = normalized.match(UNIT_PATTERN);
    if (match) {
        const value = toNumber(match[1]);
        const unit = match[2] || null;
        if (unit === null) {
            return Math.round(bareUnit === 'hours' ? value * 60 : value);
        }
        return Math.round(HOUR_UNIT_TEST.test(unit) ? value * 60 : value);
    }

    return null;
}

/**
 * Render integer minutes as text in the given format. Returns '' for
 * null/undefined/non-finite input (the "no value" case).
 *
 * @param {number|null|undefined} minutes
 * @param {{ format?: 'clock'|'hm'|'decimal', decimalSeparator?: string, unitHour?: string, unitMinute?: string }} [options]
 * @returns {string}
 */
export function formatDuration(minutes, options = {}) {
    if (minutes === null || minutes === undefined || !Number.isFinite(minutes)) return '';

    const format = options.format || 'clock';
    const decimalSeparator = options.decimalSeparator || '.';
    const unitHour = options.unitHour || 'h';
    const unitMinute = options.unitMinute || 'm';

    const total = Math.round(minutes);
    const sign = total < 0 ? '-' : '';
    const abs = Math.abs(total);
    const hours = Math.floor(abs / 60);
    const mins = abs % 60;

    if (format === 'decimal') {
        const decimalHours = abs / 60;
        const text = decimalHours.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
        return sign + text.replace('.', decimalSeparator);
    }

    if (format === 'hm') {
        if (hours === 0) return `${sign}${mins}${unitMinute}`;
        if (mins === 0) return `${sign}${hours}${unitHour}`;
        return `${sign}${hours}${unitHour} ${mins}${unitMinute}`;
    }

    // clock (default)
    return `${sign}${hours}:${String(mins).padStart(2, '0')}`;
}

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.0.1
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