Duration
A text input for a length of time. The value is a number of seconds, and people can type it as 1h 30m, 1:30:00 or 5400.
<Duration
label="Time spent"
v-model="value"
/>Examples
Logging time
Each entry is typed in whatever notation is quickest, and the saved seconds add up into a total. formatDuration renders a value outside the input.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Button, Duration, formatDuration } from 'frappe-ui'
// Logging effort against a CRM task: the user types a duration in any
// notation, "Log time" appends it, and the entries roll up into a total.
const entries = ref<{ label: string; seconds: number }[]>([
{ label: 'Discovery call', seconds: 2700 },
{ label: 'Proposal draft', seconds: 5445 },
])
const draft = ref<number | null>(null)
const total = computed(() =>
entries.value.reduce((sum, entry) => sum + entry.seconds, 0),
)
function logTime() {
if (!draft.value) return
entries.value.push({ label: 'Follow-up', seconds: draft.value })
draft.value = null
}
</script>
<template>
<div class="flex w-80 flex-col gap-4">
<div class="flex flex-col gap-2">
<div
v-for="(entry, index) in entries"
:key="index"
class="flex items-center justify-between text-base text-ink-gray-7"
>
<span>{{ entry.label }}</span>
<span class="text-ink-gray-9">{{ formatDuration(entry.seconds) }}</span>
</div>
</div>
<div class="flex items-end gap-2">
<Duration v-model="draft" label="Log time" placeholder="e.g. 45m" />
<Button label="Log time" @click="logTime" />
</div>
<div
class="flex items-center justify-between border-t border-outline-gray-1 pt-3 text-base font-medium text-ink-gray-9"
>
<span>Total logged</span>
<span>{{ formatDuration(total, 'long') }}</span>
</div>
</div>
</template>Service targets
format="long" writes the saved value out in words, which reads better in a settings form.
<script setup lang="ts">
import { ref } from 'vue'
import { Duration } from 'frappe-ui'
// Service targets for a support team. `format="long"` spells the saved value
// out in words, which reads better in a settings form than `4h`.
const firstResponse = ref<number | null>(4 * 3600)
const resolution = ref<number | null>(24 * 3600)
</script>
<template>
<div class="flex w-full max-w-md flex-col divide-y divide-outline-gray-1">
<div class="flex items-center justify-between gap-6 py-3">
<div class="flex flex-col gap-0.5">
<span class="text-base font-medium text-ink-gray-8"
>First response</span
>
<span class="text-p-sm text-ink-gray-5"
>Reply to a new ticket within</span
>
</div>
<Duration v-model="firstResponse" format="long" class="w-44" />
</div>
<div class="flex items-center justify-between gap-6 py-3">
<div class="flex flex-col gap-0.5">
<span class="text-base font-medium text-ink-gray-8">Resolution</span>
<span class="text-p-sm text-ink-gray-5">Close the ticket within</span>
</div>
<Duration v-model="resolution" format="long" class="w-44" />
</div>
</div>
</template>Behavior
What people can type
The input accepts several notations. Case does not matter, and units can come in any order.
| Notation | Example | Seconds |
|---|---|---|
| Short units | 1h 30m 45s, 1h30m45s, 45s 1h 30m | 5445 |
| Long units | 1 hour 30 minutes, 2hrs 15min | 5400, 8100 |
| Colon | 1:30:45, 1:30, :45 | 5445, 90, 45 |
| Bare integer | 90 | 90 |
The typed text is read when the input loses focus or on Enter. Text that cannot be read keeps the input open with an error, and the saved value stays as it was. Escape drops the edit. An empty input saves null.
Display format
When the input is not focused, it shows the saved value in the format you pass. format is a preset name or a template.
The presets leave out parts that are zero, and long adds plurals:
format | 5445 | 90 |
|---|---|---|
short (default) | 1h 30m 45s | 1m 30s |
long | 1 hour 30 minutes 45 seconds | 1 minute 30 seconds |
colon | 1:30:45 | 1:30 |
Any other value is a template, and every part in it is shown, zero or not. h, m and s stand for hours, minutes and seconds. Double a letter (hh) to pad it to two digits. Put text in single quotes so its letters are not read as units.
format | 7323 |
|---|---|
h'h' m'm' s's' | 2h 2m 3s |
hh:mm:ss | 02:02:03 |
h':'mm | 2:02 |
m is the minutes left over after the hours (2), not the total minutes (122). A template without h drops the hours from the output.
While the input has focus, it always shows the short notation (2h 2m 3s), so the text can be edited and read back the same way whatever the format.
Label, description and error
label renders above the field and description below it. error renders below the field and hides description. It takes a string, an array of strings (one line each), or an Error, the same values as ErrorMessage. An empty string or an empty array means no error. required adds a red asterisk to the label and sets required on the <input>.
The #label slot replaces the label text and the required marker, and receives { required }. A #description slot is not hidden by error. It renders above the error.
<Duration v-model="seconds">
<template #label="{ required }">
Time spent <Badge v-if="required" label="Required" />
</template>
<template #description>Type 1h 30m, or 01:30:00.</template>
</Duration>Accessibility
| Keys | Action |
|---|---|
Enter | Save the typed value |
Tab / blur | Save the typed value |
Escape | Drop the edit and show the saved value |
API Reference
Show types
import type {
InputExposed,
InputSize,
InputVariant,
} from '../../composables/inputTypes'
import type { InputLabelingProps } from '../../composables/useInputLabeling'
/**
* Named display presets (smart zero-omission; `long` also pluralizes):
* short — "1h 30m 45s"
* long — "1 hour 30 minutes 45 seconds"
* colon — "1:30:45"
*/
export type DurationFormatPreset = 'short' | 'long' | 'colon'
/**
* How a duration is rendered: a named preset, or a token template string
* rendered literally — `h`/`hh`, `m`/`mm`, `s`/`ss`, with single-quoted text
* taken as a literal (e.g. `h'h' m'm' s's'` → "2h 2m 3s", `hh:mm:ss` → "02:02:03").
*/
// `string & {}` keeps preset autocomplete while still accepting any template.
export type DurationFormat = DurationFormatPreset | (string & {})
export interface DurationProps extends InputLabelingProps {
/** The duration value in seconds (two-way via `v-model`). */
modelValue?: number | null
/** Placeholder shown when the input is empty. Defaults to `1h 30m 45s`. */
placeholder?: string
/**
* How the saved value is rendered when not focused: a named preset
* (`short` | `long` | `colon`) or a token template (e.g. `h'h' m'm' s's'`,
* `hh:mm:ss`). Defaults to `short`.
*/
format?: DurationFormat
/** Visual size of the input. Forwarded to the underlying `TextInput`. */
size?: InputSize
/** Style variant of the input. Forwarded to the underlying `TextInput`. */
variant?: InputVariant
/** Disables the input when true. */
disabled?: boolean
}
export interface DurationEmits {
/** Fired when the model value changes. */
'update:modelValue': [value: number | null]
}
export interface DurationExposed extends InputExposed {}The duration value in seconds (two-way via `v-model`).
Placeholder shown when the input is empty. Defaults to `1h 30m 45s`.
How the saved value is rendered when not focused: a named preset (`short` | `long` | `colon`) or a token template (e.g. `h'h' m'm' s's'`, `hh:mm:ss`). Defaults to `short`.
Visual size of the input. Forwarded to the underlying `TextInput`.
Style variant of the input. Forwarded to the underlying `TextInput`.
Disables the input when true.
Label rendered above (or beside, for binary controls) the input.
Helper text rendered below the input. Hidden when `error` is set. A `#description` slot is not: it renders beside the error, and is referenced alongside it.
Error message rendered below the input. When set, the control receives `aria-invalid="true"` and `data-state="invalid"`. Takes a string, an array of strings, or an `Error` whose `messages` are rendered as stacked lines (with `Error.message` as the fallback). This is the same value `ErrorMessage.message` takes. An empty array and an empty string both mean no error.
Marks the field as required. Renders an asterisk next to the label, with `sr-only` text that announces it, and forwards `required` / `aria-required` to the underlying control where the control's role allows it. `data-required` is set either way.
HTML id of the underlying control. Auto-generated via `useId()` if omitted.
| Slot | Payload |
|---|---|
label | { required: boolean; } Overrides the rendered label content. Receives `{ required }`. |
description | — Overrides the rendered description content. |
Overrides the rendered label content. Receives `{ required }`.
Overrides the rendered description content.
| Event | Payload |
|---|---|
update:modelValue | [value: number | null] Fired when the model value changes. |
Fired when the model value changes.
| Template ref | Type |
|---|---|
focus | (options?: FocusOptions) => void Moves focus to the input. |
Moves focus to the input.