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.
Preview
Enter a duration such as 1:30, 90m or 1.5h.
Type a duration like 1:30, 90m or 1.5h.
<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
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:
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.
-
resources/views/components/ui/duration-input.blade.php -
resources/js/ui/duration-input.js -
resources/js/ui/duration.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: 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.
# 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
{{-- 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>
<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>
{{-- 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>
{{-- 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
{{-- 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-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="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.
<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.
{{-- 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>
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 }));
});
},
}));
});
/**
* 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