"`.
- The root gets `data-invalid`.
- A message appears below the field, linked through `aria-describedby`.
- The `error` event fires with the code, and again with `null` once the error clears.
`validateOn` controls when that happens:
| `validateOn` | Behaviour |
| --- | --- |
| `'eager'` (default) | Not while you're still typing. The error appears on blur or Enter. While it's shown, it updates live, so fixing the input clears it right away. |
| `'blur'` | The shown error only changes on blur or Enter. Stepping, reverting and outside changes to `v-model` also clear it. |
| `'input'` | The error updates on every keystroke. |
Whatever the mode, calling the exposed `validate()` shows the current error, e.g. on submit.
## Preview
With `preview`, the normalized value appears inside the field as you type (`= 1h 30min`), but only when it differs from what you typed. It's also announced to screen readers through a polite live region, after a 600 ms pause in typing so not every keystroke is read out.
`preview` only works with the built-in field. With [`as`](https://nicoalbrecht.github.io/semantic-duration-input/guide/integrations.md), use the `preview` slot prop of the [renderless slot](https://nicoalbrecht.github.io/semantic-duration-input/guide/integrations.md#any-other-component-renderless-slot) instead.
### Custom preview
`formatPreview` renders the preview yourself. It gets the duration in seconds and a context with the default preview (`normalized`), the typed `text`, the model `value` and the display `locale`:
```vue
```
Its output is shown and announced as is, without the leading `= `. The preview still only appears while the text differs from its normalized form, so it goes away on blur. Return `null` (or an empty string) to hide it for a value.
`formatPreview` changes the `preview` slot prop and the `preview` of `useDurationInput` too. It only affects the preview: the text is still normalized with `displayStyle` and `displayUnits` on blur.
## Keyboard
| Key | Action |
| --- | --- |
| ↑ / ↓ | Step by `step` (default 15min), snapping to multiples: `1h07` → `1h15` / `1h` |
| Shift + ↑ / ↓ | Step by 1 hour, snapping to whole hours |
| PgUp / PgDn | Step by 1 day (as configured in [`customUnits`](https://nicoalbrecht.github.io/semantic-duration-input/guide/units.md#working-time)), snapping to whole days |
| Enter | Normalize and show errors. The form still submits, unless a preset is highlighted in the open menu, in which case that preset is picked |
| Esc | Close the presets menu, or revert to the last committed value. When reverting, the event only stops propagating if something was reverted, so closing a dialog still works |
| Alt + ↓ | Open the presets menu |
Details:
- Steps stay within `min`/`max` and never go below zero.
- If the text is invalid, stepping starts from the last valid value. From an empty field, ↑ gives one step and ↓ gives zero.
- `:step="false"` turns all stepping off, including Shift and PgUp/PgDn.
- Keys pressed with Ctrl, Cmd or Alt are left alone (apart from Alt+↓), so browser shortcuts keep working.
### Snapping and clamping
- `snapToStep` rounds to the nearest `step` on blur and Enter, then keeps the result within `min`/`max`.
- `clamp` keeps values within `min`/`max` instead of reporting `out_of_range`. It applies while typing and on commit.
## Presets
`presets` shows suggestions in a menu below the field. The input is an ARIA combobox.
- **Opens** on focus or click while the field is empty, on Alt+↓, and while typing, filtered by label.
- **While it's open:** the arrow keys move through the options, Enter picks the highlighted one, and Esc closes it. Tab and blur close it too.
```vue
```
Each entry is one of:
- duration text,
- a number in the model's unit,
- `{ label, value }`.
Entries that can't be parsed are dropped. Style the menu through `ui.menu` and `ui.option`; the highlighted option has `data-active`.
The built-in menu is not rendered with `as`. To show presets with your own component, use the [renderless slot](https://nicoalbrecht.github.io/semantic-duration-input/guide/integrations.md#any-other-component-renderless-slot), which gets `presets` and `selectPreset`.
---
# Value formats
The parser works in seconds. `valueFormat` decides what ends up in `v-model`:
| `valueFormat` | `1h 30m` becomes |
| --- | --- |
| `'minutes'` (default) | `90` |
| `'seconds'` | `5400` |
| `'ms'` | `5400000` |
| `'iso'` | `'PT1H30M'` |
| `{ toModel(seconds), fromModel(value) }` | whatever you return |
Empty input is always `null`.
For numeric formats, the model must be a number: a numeric string such as `"90"` counts as empty.
ISO output is written in the `PnDTnHnMnS` form without weeks, which is the most widely supported: two weeks become `P14D`.
## Bounds, steps and presets
Numeric `min`, `max`, `step` and `presets` are in the model's unit:
- minutes for `'minutes'` and `'iso'`,
- seconds for `'seconds'`,
- milliseconds for `'ms'`,
- whatever `fromModel` accepts for a custom format.
Duration text like `'8h'` works with any format. Text that can't be parsed is ignored.
## Seconds precision
With `precision="second"`, seconds are accepted (`30s`, `1:02:03`) and kept. With `valueFormat: 'minutes'`, the model can then be fractional: `1m30s` becomes `1.5`.
## Custom formats
```ts
import type { CustomValueFormat } from 'semantic-duration-input'
// Store hours as a decimal number.
const hours: CustomValueFormat = {
toModel: (seconds) => seconds / 3600,
fromModel: (value) => (typeof value === 'number' ? value * 3600 : null),
}
```
```vue
```
`fromModel` should return `null` for values that aren't a duration.
---
# Locales
Eight languages are built in. English and German are accepted by default; the others are accepted once you pass them in `locales`. Two props control languages:
- `locales`: which unit names and separator words the parser accepts. Default `[en, de]`.
- `locale`: the language of the normalized text and the error messages. Defaults to the first of `locales`.
```vue
```
## Built-in locales
| Export | Language | Input | Normalized (short / long) |
| --- | --- | --- | --- |
| `en` | English | `2 hours and 15 min` | `2h 15min` / `2 hours 15 minutes` |
| `de` | German | `2 Std. und 15 Min.` | `2h 15min` / `2 Stunden 15 Minuten` |
| `es` | Spanish | `2 horas y 15 minutos` | `2h 15min` / `2 horas 15 minutos` |
| `fr` | French | `1 j et 2 heures` | `1j 2h` / `1 jour 2 heures` |
| `pt` | Portuguese | `2 horas e 15 minutos` | `2h 15min` / `2 horas 15 minutos` |
| `hi` | Hindi | `2 घंटे और 15 मिनट` | `2घं 15मि` / `2 घंटे 15 मिनट` |
| `ar` | Arabic | `2 ساعة و 15 دقيقة`, `٢ ساعة و ١٥ دقيقة` | `2س 15د` / `2 ساعتان 15 دقيقة` |
| `zh` | Chinese (Mandarin) | `2小时15分钟`, `2個小時15分鐘` | `2小时 15分钟` / `2 小时 15 分钟` |
The unit names of each locale are listed in [Syntax](https://nicoalbrecht.github.io/semantic-duration-input/guide/syntax.md#units). Each built-in locale also accepts `h`, `m`, `min`, `s`, `d` and `w`, and every locale accepts native digits (`٣٠`, `३०`, `30`).
- Portuguese messages use Brazilian wording. Chinese labels and messages are Simplified, and Traditional unit names are accepted too.
- Bundlers leave out the locales you don't import.
- Accepting a locale doesn't change the language of the text: set `locale`, or put that locale first in `locales`.
```vue
```
### Right-to-left
For Arabic, set `dir="rtl"` on the page or on an element around the field. `dir` on `` itself only reaches the ``, not the leading and trailing content or the preview.
```vue
```
## Display
| Prop | Effect |
| --- | --- |
| `displayStyle` | `'short'`: `1d 2h 30min`, `'long'`: `1 day 2 hours 30 minutes` |
| `displayUnits` | Units the normalized text is built from. Default: days, hours and minutes (plus seconds with `precision="second"`). Add `'week'` to get `1w 2d`, or pass only `['hour']` to get `1.5h`. |
## Custom locales
A locale is a plain object with unit aliases, separator words, labels for the normalized text and error messages:
```ts
import { defineLocale, en } from 'semantic-duration-input'
const hu = defineLocale({
...en,
code: 'hu',
aliases: { minute: ['p', 'perc'], hour: ['ó', 'óra'], day: ['nap'], week: ['hét'] },
separators: ['és'],
labels: undefined, // use Intl.DurationFormat for the normalized text where available
messages: { ...en.messages, empty: 'Adj meg egy időtartamot.' },
})
```
```vue
```
- **Aliases** are matched case-insensitively and in Unicode normal form (NFC), so composed and decomposed letters like `फ़` match. Combining marks (Devanagari vowel signs, Arabic diacritics) are part of a name. If two active locales use the same alias for different units, the later locale in `locales` wins.
- **Separators** are extra words that may join parts, like `and`. The symbols `,` `+` `&` always work.
- **Labels:** without `labels`, the text is formatted with `Intl.DurationFormat` using `code`, falling back to English labels where that API isn't available. Make sure the parser can read what the formatter writes: add the words it produces to `aliases` and `separators`.
- **Plural forms:** a long label is `[one, other]`, picked by the plural rules of `code` (`Intl.PluralRules`): French writes `0 minute` and `2 minutes`. For languages with more forms, pass a `PluralLabels` object instead. Missing categories use `other`:
```ts
long: {
...en.labels!.long,
day: { one: 'diwrnod', two: 'ddiwrnod', other: 'diwrnod' }, // Welsh
}
```
- **Messages** may use `{min}`, `{max}`, `{token}` and `{suggestion}`. See [Error messages](https://nicoalbrecht.github.io/semantic-duration-input/guide/forms.md#error-messages) for the keys.
Locales can also name and label [custom units](https://nicoalbrecht.github.io/semantic-duration-input/guide/units.md#translations).
To add aliases to a built-in language, spread it:
```ts
const myEn = defineLocale({ ...en, aliases: { ...en.aliases, hour: [...en.aliases.hour!, 'hs'] } })
```
---
# Custom units
`customUnits` adds units of your own and changes the length of `day` and `week`. Each entry is a length in seconds, or a definition with aliases and labels:
```vue
```
| You type | `v-model` | After blur |
| --- | --- | --- |
| `2 sprints 3d` | `44640` | `2sp 3d` |
| `3 pomodoro` | `75` | `1h 15min` |
| `1 sprint 2` | `40320` | `2sp` (`2` takes weeks, see [below](#implicit-units)) |
## Working time
Override `day` and `week` to count working time, as in Jira, where a day is 8 hours and a week is 5 days:
```vue
```
`1w 2d` is now 56 hours, and 10 hours are shown as `1d 2h`. PgUp/PgDn step by the configured day. Overriding `day` doesn't change `week`, so set both.
Only `day` and `week` can be overridden. `second`, `minute` and `hour` keep their lengths, so precision, rounding and clock input stay predictable. The day must stay longer than an hour and shorter than the week.
Some input always uses the standard lengths, because it has a fixed meaning outside your app:
- ISO 8601: `P1D` is 24 hours even when `1d` is 8. `valueFormat: 'iso'` writes standard lengths too.
- Clock format: `26:00` is 26 hours.
## Definitions
| Field | |
| --- | --- |
| `seconds` | Length in seconds: a whole number greater than 0 |
| `aliases` | Extra names the parser accepts in every locale, matched case-insensitively |
| `labels` | `{ short?, long?: [one, other] }` for the normalized text. Not allowed on `day` and `week`, whose labels come from the locale |
- A custom unit's name is accepted too, and is its label when it has none: `{ pomodoro: 1500 }` accepts `2 pomodoro` and shows `2 pomodoro`. Without a short label, the short style writes the long form.
- Names, aliases and labels must be letters only, since unit words in the input are. A label may end with a period (`Std.`).
- Labels are accepted by the parser as well, so the normalized text can always be read back.
- Names from `customUnits` win over the unit names of the locales: an alias `s` takes over seconds. Two custom units can't share a name.
- Units shorter than the precision are ignored: a 30-second unit is an `unknown_unit` unless `precision="second"`.
- Invalid definitions throw an `Error`, when the component mounts or, with the [Nuxt module](https://nicoalbrecht.github.io/semantic-duration-input/guide/nuxt.md), at build time.
A name in `displayUnits` or `defaultUnit` that is neither built in nor in `customUnits` throws as well, so typos don't go unnoticed.
## Showing custom units
Custom units appear in the normalized text, the preview, the presets and the error messages when they're in `displayUnits`. Units are always shown longest first, whatever their order in `displayUnits`.
The smallest display unit takes the rest as a decimal with up to two places, like `1.5h` with `displayUnits: ['hour']`. With long units such as sprints, that can round off a few minutes; add a smaller unit to keep them.
## Implicit units
A trailing number takes the longest *built-in* unit that is shorter than the last unit, so custom units never change what `1h30` means:
| Input | Means |
| --- | --- |
| `1 workday 3` (workday = 8h) | 1 workday 3 hours |
| `1 pomodoro 5` | 1 pomodoro 5 minutes |
| `1 sprint 2` (sprint = 14 days) | 1 sprint 2 weeks |
| `1w 2` (week = 5 × 8h, day = 8h) | 1 week 2 days of 8 hours |
## Translations
Unit names in `customUnits` work in every locale. To translate a unit, give it aliases and labels in a locale, under the same name:
```ts
import { de, defineLocale } from 'semantic-duration-input'
const deAgile = defineLocale({
...de,
aliases: { ...de.aliases, sprint: ['Sprints'] },
labels: {
short: { ...de.labels!.short, sprint: 'Sp' },
long: { ...de.labels!.long, sprint: ['Sprint', 'Sprints'] },
},
})
```
The display locale's labels win over the labels in `customUnits`. The parser accepts the labels that the active locales give custom units, so the translated text parses back too.
Locales without `labels` format built-in units with `Intl` and custom units with their labels from `customUnits`.
## App-wide units
Pass `customUnits` to the plugin (or the Nuxt module) to use them everywhere. A `customUnits` prop replaces the app-wide units rather than merging with them.
```ts
app.use(plugin, { customUnits: { day: 8 * 3600, week: 5 * 8 * 3600 } })
```
The core functions take the same option: `parseDuration`, `formatDuration`, `formatErrorMessage`, `resolveAmount` and `durationSchema`. See the [Core API](https://nicoalbrecht.github.io/semantic-duration-input/reference/core.md).
---
# Forms and validation
## Native forms
With a `name`, a hidden input carries the model value (`90`, or `PT1H30M` with `'iso'`), so the form submits what `v-model` holds, not the typed text. Plain objects and arrays from a custom `valueFormat` are submitted as JSON; other objects (like `Temporal.Duration`) use their `toString()`. This also works with `as`. The renderless slot gets `hiddenInputProps` to bind it yourself.
While the text is invalid, the input reports it to the browser through `setCustomValidity()`, with the error message as text. So the browser blocks submitting the form instead of sending the last valid value, just like it does for an empty `required` field. This happens with the built-in field and with `as`. With the renderless slot, call `setCustomValidity()` yourself, e.g. from `rawErrorDetail`.
```vue
```
## Required, min and max
- `required` makes empty input an `empty` error. `v-model` still becomes `null` when the field is cleared, so the model always matches the field.
- `min` and `max` are inclusive. They take a number in the model's unit or text like `'8h'`. Values outside them are `out_of_range` errors, or with `clamp`, get clamped. In development, a `min` greater than `max` logs a warning; `durationSchema` throws a `RangeError` for it.
## Show errors on submit
Call `validate()` through a template ref. It shows the current error, whatever `validateOn` is set to, and returns whether the input is valid:
```vue
```
## Error messages
Override texts per key with `messages`, or pass `false` to hide the built-in message (the `error` event and `aria-invalid` still work):
```vue
```
| Key | Used for |
| --- | --- |
| `empty` | `empty` |
| `invalid_format` | `invalid_format` |
| `unknown_unit` | `unknown_unit` without a suggestion |
| `unknown_unit_suggestion` | `unknown_unit` with a suggestion |
| `missing_unit` | `missing_unit` |
| `out_of_range` | `out_of_range` with both bounds |
| `out_of_range_min` | `out_of_range` with only `min` |
| `out_of_range_max` | `out_of_range` with only `max` |
- Overriding a base key also covers its variants: `unknown_unit` covers `unknown_unit_suggestion`, and `out_of_range` covers `out_of_range_min` and `out_of_range_max`.
- Placeholders: `{min}`, `{max}`, `{token}` and `{suggestion}`. Each is filled in once per message.
## Schema validation
`durationSchema()` is a [Standard Schema](https://standardschema.dev), so it works directly with Valibot, ArkType, TanStack Form, VeeValidate and others.
- **Text** is parsed into the model value.
- **Values that already have the output type** (e.g. numbers) are only range-checked. Values it can't convert fail with `invalid_format`.
```ts
import { durationSchema } from 'semantic-duration-input/core'
const estimate = durationSchema({ required: true, max: '8h', valueFormat: 'minutes' })
estimate['~standard'].validate('1h30') // { value: 90 }
estimate['~standard'].validate('9h') // { issues: [{ message: 'Must be at most 8h.' }] }
estimate['~standard'].validate(90) // { value: 90 }
```
Options:
- Everything the component uses for parsing: `valueFormat`, `min`, `max`, `required`, `locales`, `precision`, `implicitUnits` and `defaultUnit`.
- `locale` and `messages` for the issue texts.
Without `required`, empty input, `null` and `undefined` validate to `{ value: null }`.
### Zod
With Zod, use `parseDuration` in a transform:
```ts
import { z } from 'zod'
import { formatErrorMessage, parseDuration } from 'semantic-duration-input/core'
const minutes = z.string().transform((text, ctx) => {
const result = parseDuration(text)
if (result.ok) return result.minutes // null for empty input
ctx.addIssue({ code: 'custom', message: formatErrorMessage(result) })
return z.NEVER
})
```
---
# Styling
The built-in field is styled with Tailwind CSS v4 utilities. It follows your theme through a few CSS variables, and each variable falls back to the shadcn-vue and Nuxt UI tokens when those are present. See [Getting started](https://nicoalbrecht.github.io/semantic-duration-input/guide/getting-started.md#add-the-styles) for which stylesheet to import.
## Sizes and variants
`size` is `'sm'`, `'md'` (default) or `'lg'`. `variant` is `'outline'` (default), `'soft'` or `'ghost'`.
## Tokens
| Variable | Falls back to (shadcn-vue → Nuxt UI → neutral) |
| --- | --- |
| `--sdi-fg` | `--foreground` → `--ui-text` → `currentColor` |
| `--sdi-bg` | `transparent` |
| `--sdi-border` | `--input` → `--ui-border-accented` → 25% `currentColor` |
| `--sdi-ring` | `--ring` → `--ui-primary` → `Highlight` |
| `--sdi-invalid` | `--destructive` → `--ui-error` → `#dc2626` |
| `--sdi-muted` | `--muted-foreground` → `--ui-text-muted` → 55% `currentColor` |
| `--sdi-soft` | `--muted` → `--ui-bg-elevated` → 8% `currentColor` |
| `--sdi-popover` | `--popover` → `--ui-bg` → `Canvas` |
The neutral fallbacks are derived from `currentColor`, so dark mode works without extra setup. Set the tokens on any ancestor, such as `:root` or a section of your page, or on the component itself:
```css
:root {
--sdi-ring: var(--color-emerald-500);
}
```
```vue
```
This site sets them once on `:root` to follow the VitePress theme.
## Classes per part
Every part takes extra classes through `ui`. They are merged with [tailwind-merge](https://github.com/dcastil/tailwind-merge), so conflicting defaults get replaced: `rounded-full` removes `rounded-md`. A `class` attribute is merged into `root` the same way.
```vue
```
### Structure
```
root
├─ field
│ ├─ leading
│ ├─ input
│ ├─ preview
│ ├─ trailing
│ └─ menu
│ └─ option
└─ message
```
- Each element carries `data-slot=""`.
- The root also carries `data-invalid`, `data-disabled`, `data-size` and `data-variant`. It has the `group` class, so children can use Tailwind variants such as `group-data-invalid:`.
- The input carries `data-error=""` while an error is shown.
## Unstyled
`unstyled` drops all default classes. The root keeps only the `sdi` class plus whatever you pass through `ui`, `class` and the app-wide `ui` defaults. These are joined as-is, without tailwind-merge.
```vue
```
## Changing the defaults everywhere
For small changes across an app, pass `ui` as an [app-wide default](https://nicoalbrecht.github.io/semantic-duration-input/guide/integrations.md#app-wide-defaults). It is merged with the defaults and with each component's own `ui`:
```ts
app.use(DurationInputPlugin, { ui: { field: 'rounded-none', menu: 'rounded-none' } })
```
The default theme is exported as `durationInputTheme`, a [tailwind-variants](https://www.tailwind-variants.org) component. Use it to read the defaults, or as the base for your own theme combined with `unstyled`:
```ts
import { tv } from 'tailwind-variants'
import { durationInputTheme } from 'semantic-duration-input'
const theme = tv({ extend: durationInputTheme, slots: { field: 'rounded-none' } })
const { root, field, input, message } = theme({ size: 'md', variant: 'outline' })
```
```vue
```
Because the root keeps the `group` class, the invalid state can be styled with `group-data-invalid:` classes in your theme.
---
# Integrations
## Rendering another input with `as`
Use `as` to render your UI library's input. The component binds these to it:
- `modelValue`, `onUpdate:modelValue`, `onBlur` and `onKeydown`
- `disabled`, `readonly` and `required`
- `aria-invalid` (while an error is shown) and `data-error`
- `size` and `variant`
Attributes such as `id`, `placeholder` and `class`, and all slots except `default`, are passed through. Your own listeners (`@blur`, `@keydown`, ...) run alongside the component's. The library's own props and slots keep working.
If `as` is a string such as `'input'`, it gets native props (`value`/`onInput`) instead of `modelValue`.
Things to know in `as` mode:
- **Error message:** the component renders no message of its own. Show it through the library, as the Vuetify preset does, or with the renderless slot.
- **Preview and presets:** `preview` and the presets menu belong to the built-in field and are not rendered. Use the [renderless slot](#any-other-component-renderless-slot) for those.
- **Styles:** the built-in stylesheet isn't needed.
### `invalidProps`
`invalidProps` maps the error state onto library-specific props. It receives `{ invalid, error, message, onBlur }` and returns props. Presets are included:
| Preset | Sets |
| --- | --- |
| `vuetify` | `error`, `error-messages`, and commits on `update:focused` |
| `primevue` | `invalid` |
| `nuxtUi` | `color: 'error'` and `highlight` while invalid |
Your own function works the same way:
```ts
import type { InvalidPropsFn } from 'semantic-duration-input'
const myLib: InvalidPropsFn = ({ invalid, message }) => ({ state: invalid ? 'error' : undefined, hint: message })
```
### shadcn-vue
Its `Input` already styles `aria-invalid`, so no preset is needed. The built-in field also picks up shadcn's tokens automatically.
```vue
```
### Nuxt UI
```vue
```
In Nuxt, `UInput` is auto-imported for templates, but `as` needs the component itself, hence the import from `#components`. The [Nuxt module](https://nicoalbrecht.github.io/semantic-duration-input/guide/nuxt.md) can set this up app-wide.
### Vuetify
```vue
```
The preset sets `error` and `error-messages`, and normalizes the text on `update:focused`.
If you also use Tailwind next to Vuetify, some class names collide (`.border`, `.rounded-*`, ...). Order the cascade layers so Tailwind's utilities win:
```css
@layer theme, base, vuetify-core, components, vuetify-components, vuetify-overrides, vuetify-utilities, utilities, vuetify-final;
```
### PrimeVue
```vue
```
The adapter works with PrimeVue 4 and 5.
## Any other component (renderless slot)
For full control, pass a default slot. The component then renders nothing but your slot, and handles only the parsing:
```vue
```
The slot props:
| Prop | |
| --- | --- |
| `inputProps` | For components with a `modelValue` contract. Includes attributes, `class` and `style` |
| `nativeInputProps` | For a plain ``: `value`, `onInput`, `onBlur`, `onKeydown`. Includes attributes |
| `hiddenInputProps` | For an `` carrying the model value, or `null` without a `name` |
| `text` | The text in the field |
| `value` | The model value |
| `error`, `errorDetail` | The shown error code, and the shown failure with `token`, `index` and `suggestion` |
| `rawError`, `rawErrorDetail` | The current error, shown or not, and its failure details |
| `invalid`, `message`, `messageId` | Whether an error is shown, its text, and the id to give the message element for `aria-describedby` |
| `preview` | The normalized text (or the `formatPreview` output) while it differs from what was typed, else `null` |
| `presets`, `selectPreset(preset)` | The resolved presets (`{ id, label, seconds }`), to render your own suggestions |
| `onBlur`, `onKeydown`, `commit`, `validate`, `revert`, `stepBy` | The handlers and methods |
A plain input with its own presets list:
```vue
{{ s.message }}
```
## App-wide defaults
Pass defaults to the plugin, and they apply to every instance. Props set on a component still win, and `ui` defaults are merged with the component's `ui`.
```ts
import { de, plugin as DurationInputPlugin } from 'semantic-duration-input'
import { Input } from '@/components/ui/input'
app.use(DurationInputPlugin, { as: Input, locale: de, validateOn: 'blur' })
```
Every prop can be a default except `min`, `max`, `required`, `disabled` and `readonly`, which belong to the individual field.
---
# Nuxt
Add the module. It:
- auto-imports ``, `useDurationInput`, `parseDuration` and `formatDuration`,
- applies the defaults from `nuxt.config`,
- adds the prebuilt stylesheet.
```ts
export default defineNuxtConfig({
modules: ['@nuxt/ui', 'semantic-duration-input/nuxt'],
durationInput: {
as: 'UInput', // any component name from #components
invalidProps: 'nuxtUi', // 'nuxtUi' | 'vuetify' | 'primevue'
validateOn: 'blur',
},
})
```
## Options
The options are the [app-wide defaults](https://nicoalbrecht.github.io/semantic-duration-input/guide/integrations.md#app-wide-defaults), in a form that can be serialized into the generated plugin:
| Option | |
| --- | --- |
| `as` | A component name resolved from `#components`, e.g. `'UInput'`. It must be a valid identifier, or the build fails |
| `invalidProps` | A preset name: `'nuxtUi'`, `'vuetify'` or `'primevue'` |
| `valueFormat` | `'minutes'`, `'seconds'`, `'ms'` or `'iso'`. Custom conversions can't be serialized, so pass them as a prop instead |
| `formatPreview` | Not available: functions can't be serialized, so pass it as a prop instead |
| `css` | Adds `semantic-duration-input/style.css`. Defaults to `true`, or `false` when `as` is set. `true` forces it |
| Everything else | As on the component: `locales`, `validateOn`, `presets`, `ui`, ... |
Locales are plain data, so they serialize fine. Import them from the core entry in `nuxt.config`:
```ts
import { de, en } from 'semantic-duration-input/core'
export default defineNuxtConfig({
modules: ['semantic-duration-input/nuxt'],
durationInput: { locales: [de, en] },
})
```
`preview` and the presets menu only work with the built-in field (see [`as` mode](https://nicoalbrecht.github.io/semantic-duration-input/guide/integrations.md#rendering-another-input-with-as)), so they have no effect together with `as`.
If you use Tailwind v4 in the Nuxt app, set `css: false` and import `semantic-duration-input/tailwind.css` in your main CSS instead, as described in [Getting started](https://nicoalbrecht.github.io/semantic-duration-input/guide/getting-started.md#add-the-styles).
---
# Headless usage
## Core
The parser, formatter and validation have their own entry point, without Vue or Tailwind. It works in Node, e.g. to validate on the server:
```ts
import { parseDuration, formatDuration, formatErrorMessage, toIso, de } from 'semantic-duration-input/core'
parseDuration('1h 30m') // { ok: true, seconds: 5400, minutes: 90 }
parseDuration('45') // { ok: false, error: 'missing_unit', token: '45', index: 0 }
parseDuration('2 huors') // { ok: false, error: 'unknown_unit', token: 'huors', index: 2, suggestion: 'hours' }
formatDuration(680400, { style: 'long', locale: de }) // '7 Tage 21 Stunden' (takes seconds)
formatErrorMessage('out_of_range', { min: 1800, max: 28800 }) // 'Must be between 30min and 8h.'
toIso(5400) // 'PT1H30M'
```
All of it is described in the [Core API reference](https://nicoalbrecht.github.io/semantic-duration-input/reference/core.md).
## `useDurationInput`
The component's logic is available as a composable, to build your own input in Vue:
```vue
= {{ preview }}
```
The first argument is the model ref. The second takes the component's parsing, display and validation options, as an object, ref or getter:
`valueFormat`, `locales`, `locale`, `displayStyle`, `displayUnits`, `precision`, `implicitUnits`, `defaultUnit`, `required`, `min`, `max`, `step`, `snapToStep`, `clamp`, `validateOn`, `readonly`, `formatPreview`
It returns:
| | |
| --- | --- |
| `text` | Ref of the text in the field |
| `seconds` | The model value in seconds |
| `error`, `errorDetail` | The shown error code, and the shown failure (see `validateOn`) |
| `rawError`, `rawErrorDetail`, `isValid` | The current error and failure, shown or not, and whether there is none |
| `preview` | The normalized text (or the `formatPreview` output) while it differs from what was typed |
| `settings` | Resolved options: bounds and step in seconds, locale, display units |
| `format(seconds)` | Formats with the current locale and display options |
| `onInput(event \| text \| null)` | Input handler; also takes the new text, or `null` to clear |
| `onBlur`, `onKeydown` | Blur and keyboard handlers |
| `commit()`, `validate()`, `revert()` | Return booleans: whether the text was valid / is valid / whether anything was reverted |
| `stepBy(direction, size?)` | Step by `direction` steps of `size` seconds (default: `step`) |
The component adds error messages, the presets menu, the preview announcement and the hidden form input on top. Use the [renderless slot](https://nicoalbrecht.github.io/semantic-duration-input/guide/integrations.md#any-other-component-renderless-slot) if you want those but not the markup.
---
# Component
```ts
import { DurationInput } from 'semantic-duration-input'
```
## Props
| Prop | Type | Default | |
| --- | --- | --- | --- |
| `modelValue` | `number \| string \| null` | `null` | The duration, see `valueFormat` |
| `valueFormat` | `'minutes' \| 'seconds' \| 'ms' \| 'iso' \| { toModel, fromModel }` | `'minutes'` | How `v-model` stores the duration, see [Value formats](https://nicoalbrecht.github.io/semantic-duration-input/guide/value-formats.md) |
| `precision` | `'minute' \| 'second'` | `'minute'` | `'second'` accepts `30s` and `h:mm:ss` and keeps seconds |
| `locales` | `DurationLocale[]` | `[en, de]` | Which unit names and separator words are accepted, see [Locales](https://nicoalbrecht.github.io/semantic-duration-input/guide/locales.md) |
| `locale` | `DurationLocale` | first of `locales` | Language of the normalized text and the messages |
| `displayStyle` | `'short' \| 'long'` | `'short'` | `1d 2h 30min` vs `1 day 2 hours 30 minutes` |
| `displayUnits` | `UnitName[]` | `['day', 'hour', 'minute']` (+ `'second'`) | Units used in the normalized text, including custom ones |
| `implicitUnits` | `boolean` | `true` | `1h30` means 1h 30min |
| `defaultUnit` | `UnitName` | – | Unit for a bare number, e.g. `'minute'` |
| `customUnits` | `CustomUnits` | – | Units of your own and other lengths for `day` and `week`, see [Custom units](https://nicoalbrecht.github.io/semantic-duration-input/guide/units.md) |
| `min` / `max` | `number \| string` | – | Inclusive bounds: a number in the model's unit, or text like `'8h'` (`out_of_range`) |
| `step` | `number \| string \| false` | `'15m'` | Arrow-key step |
| `snapToStep` | `boolean` | `false` | Round to the nearest `step` on blur/Enter |
| `clamp` | `boolean` | `false` | Clamp to `min`/`max` instead of reporting `out_of_range` |
| `validateOn` | `'eager' \| 'blur' \| 'input'` | `'eager'` | When errors are shown, see [Behaviour](https://nicoalbrecht.github.io/semantic-duration-input/guide/behaviour.md#when-errors-appear) |
| `required` | `boolean` | `false` | Empty input is an `empty` error |
| `disabled` | `boolean` | `false` | |
| `readonly` | `boolean` | `false` | Read-only: no stepping, reverting or presets menu |
| `presets` | `(number \| string \| { label?, value })[]` | – | Suggestions menu, see [Presets](https://nicoalbrecht.github.io/semantic-duration-input/guide/behaviour.md#presets). Built-in field only |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Field size. With `as`, any value is passed on |
| `variant` | `'outline' \| 'soft' \| 'ghost'` | `'outline'` | Field look. With `as`, any value is passed on |
| `preview` | `boolean` | `false` | Shows the normalized value inside the field while typing, and announces it to screen readers. Built-in field only |
| `formatPreview` | `(seconds, { normalized, text, value, locale }) => string \| null` | – | Renders the preview instead of the normalized text, see [Custom preview](https://nicoalbrecht.github.io/semantic-duration-input/guide/behaviour.md#custom-preview) |
| `messages` | `{ [key]: string } \| false` | – | Overrides the error texts, or `false` to hide them, see [Error messages](https://nicoalbrecht.github.io/semantic-duration-input/guide/forms.md#error-messages) |
| `ui` | `{ root?, field?, input?, leading?, trailing?, preview?, message?, menu?, option? }` | – | Extra classes per part, see [Styling](https://nicoalbrecht.github.io/semantic-duration-input/guide/styling.md#classes-per-part) |
| `unstyled` | `boolean` | `false` | Drops all default classes |
| `as` | `Component \| string` | – | Renders another input component, see [Integrations](https://nicoalbrecht.github.io/semantic-duration-input/guide/integrations.md) |
| `invalidProps` | `(state) => props` | – | Maps the invalid state onto the `as` component's props |
## Attributes
- **Built-in field:** `class` and `style` go to the root element. `name` goes to a hidden input carrying the model value (see [Forms](https://nicoalbrecht.github.io/semantic-duration-input/guide/forms.md#native-forms)). All other attributes (`id`, `placeholder`, `data-*`, ...) go to the ``.
- **With `as` or the renderless slot:** `class` and `style` go to the `as` component or into `inputProps`, together with the other attributes.
## Events
| Event | Payload | |
| --- | --- | --- |
| `update:modelValue` | `value \| null` | The model changed |
| `error` | `code \| null` | The shown error changed (see `validateOn`). Codes: `empty`, `invalid_format`, `unknown_unit`, `missing_unit`, `out_of_range` |
## Slots
| Slot | |
| --- | --- |
| `leading` | Before the input: an icon, a unit hint. With `as`, all named slots are passed on to that component |
| `trailing` | After the input: a unit hint, a clear button |
| `default` | Renderless mode, see [the slot props](https://nicoalbrecht.github.io/semantic-duration-input/guide/integrations.md#any-other-component-renderless-slot) |
## Exposed methods
Through a template ref:
| Method | Returns | |
| --- | --- | --- |
| `focus()` | | Focuses the input |
| `blur()` | | Blurs the input, which normalizes the text |
| `commit()` | `boolean` | Normalizes the text, shows any error and writes the value, like blur. Returns whether the text was valid |
| `validate()` | `boolean` | Shows the current error, whatever `validateOn` is, and returns whether the text is valid |
| `revert()` | `boolean` | Goes back to the last committed value. Returns whether anything changed |
| `stepBy(direction, seconds?)` | | Steps like the arrow keys, e.g. `stepBy(-2)` or `stepBy(1, 3600)` |
## Plugin
```ts
import { plugin } from 'semantic-duration-input'
app.use(plugin, defaults?)
```
Registers `` globally. The optional `defaults` apply to every instance, see [App-wide defaults](https://nicoalbrecht.github.io/semantic-duration-input/guide/integrations.md#app-wide-defaults). They are provided under the `DURATION_INPUT_DEFAULTS` injection key, which you can also `provide` yourself for a subtree.
## Types
Exported from `semantic-duration-input`, besides everything from [`/core`](https://nicoalbrecht.github.io/semantic-duration-input/reference/core.md#types):
| Type | |
| --- | --- |
| `DurationInputProps` | The props above |
| `DurationInputDefaults` | App-wide defaults: the props except `min`, `max`, `required`, `disabled` and `readonly` |
| `DurationInputUi`, `DurationInputPart` | The `ui` prop and its part names |
| `DurationInputSize`, `DurationInputVariant`, `DurationInputVariants` | Built-in sizes and looks |
| `DurationPreset`, `DurationPresetItem` | A `presets` entry, and a resolved one as given to the slot |
| `DurationInputSlotProps` | Props of the renderless slot |
| `InvalidPropsFn`, `InvalidState` | An `invalidProps` function and what it receives |
| `DurationInputOptions`, `ValidateOn` | Options of `useDurationInput` |
| `PreviewFormatter`, `PreviewContext` | A `formatPreview` function and what it receives |
---
# Core API
```ts
import { parseDuration } from 'semantic-duration-input/core'
```
Everything here is also exported from `semantic-duration-input`. The core entry has no Vue or Tailwind dependency. All durations are in **seconds** unless noted.
## `parseDuration(input, options?)`
Parses duration text. Returns `{ ok: true, seconds, minutes }`, with both `null` for empty input, or a failure. Input longer than 256 characters (after trimming) is rejected as `invalid_format` without being parsed, so it's safe to call on untrusted input, e.g. on the server.
```ts
parseDuration('1h 30m') // { ok: true, seconds: 5400, minutes: 90 }
parseDuration('') // { ok: true, seconds: null, minutes: null }
parseDuration('45') // { ok: false, error: 'missing_unit', token: '45', index: 0 }
parseDuration('2 huors') // { ok: false, error: 'unknown_unit', token: 'huors', index: 2, suggestion: 'hours' }
parseDuration('9h', { max: 8 * 3600 }) // { ok: false, error: 'out_of_range' }
```
| Option | Default | |
| --- | --- | --- |
| `locales` | `[en, de]` | Locales whose unit names and separators are accepted |
| `precision` | `'minute'` | `'second'` accepts seconds; results are rounded to the precision |
| `implicitUnits` | `true` | `1h30` means 1h 30min |
| `defaultUnit` | – | Unit for a bare number |
| `customUnits` | – | Units of your own and other lengths for `day` and `week`, see [Custom units](https://nicoalbrecht.github.io/semantic-duration-input/guide/units.md) |
| `min`, `max` | – | Inclusive bounds in seconds |
| `required` | `false` | Empty input is an `empty` failure instead of `null` |
A failure (`ParseFailure`) has:
- `error`: see [Errors](https://nicoalbrecht.github.io/semantic-duration-input/guide/syntax.md#errors).
- `token` and `index`: the offending part of the input and its position. They are missing for `empty` and `out_of_range`.
- `suggestion`: for `unknown_unit`, when a unit name is close.
## `formatDuration(seconds, options?)`
```ts
formatDuration(5400) // '1h 30min'
formatDuration(680400, { style: 'long', locale: de }) // '7 Tage 21 Stunden'
formatDuration(5400, { units: ['hour'] }) // '1.5h'
```
| Option | Default | |
| --- | --- | --- |
| `locale` | `en` | Labels to use. Locales without `labels` use `Intl.DurationFormat` |
| `style` | `'short'` | `'short'` or `'long'` |
| `units` | `['day', 'hour', 'minute']` | Units to decompose into, custom ones included. The smallest one takes the rest, as a decimal if needed |
| `customUnits` | – | See [Custom units](https://nicoalbrecht.github.io/semantic-duration-input/guide/units.md) |
Throws a `RangeError` for negative, `NaN` or infinite input, and an `Error` for an unknown unit in `units`.
## `formatErrorMessage(error, options?)`
Readable text for an error code or a whole `ParseFailure`. Pass the failure to get `{token}` and `{suggestion}` filled in.
```ts
formatErrorMessage(parseDuration('2 huors')) // 'Unknown unit "huors". Did you mean "hours"?'
formatErrorMessage('out_of_range', { min: 1800, max: 28800 }) // 'Must be between 30min and 8h.'
```
| Option | |
| --- | --- |
| `locale` | Language, default `en` |
| `min`, `max` | Bounds in seconds, for `{min}` and `{max}`. Which is set also picks `out_of_range_min` / `out_of_range_max` |
| `units` | Units to format the bounds with |
| `customUnits` | See [Custom units](https://nicoalbrecht.github.io/semantic-duration-input/guide/units.md) |
| `overrides` | Replacement texts per key, see [Error messages](https://nicoalbrecht.github.io/semantic-duration-input/guide/forms.md#error-messages) |
## `durationSchema(options?)`
A [Standard Schema](https://standardschema.dev) for form libraries, see [Schema validation](https://nicoalbrecht.github.io/semantic-duration-input/guide/forms.md#schema-validation).
## ISO 8601
```ts
toIso(5400) // 'PT1H30M'
toIso(1209600) // 'P14D' (no weeks, for the widest support)
fromIso('P1DT2H') // 93600
fromIso('1h') // null
```
`toIso` throws a `RangeError` for negative, `NaN` or infinite input.
## Model values
These convert between seconds and a `valueFormat`. They're used by the component and exported for custom inputs:
| Function | |
| --- | --- |
| `toModelValue(seconds, format)` | Seconds → model value. `null` stays `null` |
| `fromModelValue(value, format)` | Model value → seconds, or `null` when empty or not a duration (including negative numbers) |
| `resolveAmount(amount, format, { locales, customUnits })` | A bound or step (number in the model's unit, or text like `'8h'`) → seconds, or `undefined` if invalid |
## Locales
| Export | |
| --- | --- |
| `en`, `de` | English and German, accepted by default |
| `es`, `fr`, `pt`, `hi`, `ar`, `zh` | Spanish, French, Portuguese, Hindi, Arabic and Chinese. Pass them in `locales` to accept them, see [Built-in locales](https://nicoalbrecht.github.io/semantic-duration-input/guide/locales.md#built-in-locales) |
| `DEFAULT_LOCALES` | `[en, de]` |
| `defineLocale(locale)` | Typed helper for custom locales, see [Custom locales](https://nicoalbrecht.github.io/semantic-duration-input/guide/locales.md#custom-locales) |
## Constants
| Export | |
| --- | --- |
| `UNIT_SECONDS` | Standard length of each built-in unit in seconds: `{ second: 1, minute: 60, hour: 3600, day: 86400, week: 604800 }` |
| `UNITS_DESC` | `['week', 'day', 'hour', 'minute', 'second']` |
| `DEFAULT_DISPLAY_UNITS` | `['day', 'hour', 'minute']` |
## Types
| Type | |
| --- | --- |
| `ParseResult`, `ParseFailure`, `ParseOptions`, `ParseErrorCode` | `parseDuration` |
| `FormatOptions`, `FormatStyle` | `formatDuration` |
| `ErrorMessageOptions`, `ErrorMessages`, `MessageKey` | `formatErrorMessage` and the `messages` prop |
| `DurationLocale` | A locale |
| `LongLabel`, `PluralLabels` | A long label: `[one, other]`, or `{ one?, two?, few?, many?, zero?, other }` per plural category |
| `UnitKey`, `Precision` | `'second' \| 'minute' \| 'hour' \| 'day' \| 'week'`, `'minute' \| 'second'` |
| `UnitName` | A `UnitKey` or the name of a custom unit |
| `CustomUnits`, `CustomUnit` | `customUnits`, see [Custom units](https://nicoalbrecht.github.io/semantic-duration-input/guide/units.md) |
| `ValueFormat`, `CustomValueFormat`, `DurationAmount` | `valueFormat`, and a bound or step |
| `DurationSchemaOptions`, `StandardSchemaV1`, `StandardSchemaResult` | `durationSchema` |
---
# Migrating to 0.2
## Core works in seconds
`parseDuration` returns `seconds` next to `minutes`. Several inputs changed from minutes to seconds:
- `min` and `max` in `ParseOptions`
- the input of `formatDuration`
- the bounds of `formatErrorMessage`
```ts
// 0.1
formatDuration(90)
// 0.2
formatDuration(90 * 60)
```
The component's `v-model` still holds minutes by default, see [Value formats](https://nicoalbrecht.github.io/semantic-duration-input/guide/value-formats.md).
## Locales are objects
- `locales` takes locale objects: `:locales="[en, de]"` instead of `['en', 'de']`.
- `locale` replaces `displayLocale`.
- `customAliases` is gone. Extend a locale with [`defineLocale`](https://nicoalbrecht.github.io/semantic-duration-input/guide/locales.md#custom-locales) instead.
- `LOCALE_ALIASES`, `ERROR_MESSAGES` and `UNIT_MINUTES` are replaced by the locale objects and `UNIT_SECONDS`.
## Errors wait for blur
Errors are no longer shown while typing by default (`validateOn: 'eager'`). Use `validate-on="input"` for the old behaviour.
## Implicit units
`1h 30` is now 90 minutes instead of a `missing_unit` error. Set `:implicit-units="false"` to keep the old behaviour.
## Forms
`name` now goes to a hidden input carrying the model value, instead of the text input.
## Renderless slot
The slot prop `minutes` is now `value`.