---
url: "https://vueda.dev/v3/reference/components/widgets.md"
title: "Form Widgets"
---

<script setup>
import FieldMessage from "@vueda/shell/field/FieldMessage.vue";
import WidgetDuration from "@vueda/widgets/WidgetDuration.vue";
import WidgetTagsInput from "@vueda/widgets/WidgetTagsInput.vue";
import WidgetTimeRangeField from "@vueda/widgets/WidgetTimeRangeField.vue";
import { ref } from "vue";
import { SHOWCASE_FIELD_TYPES } from "../../.vitepress/theme/fixtures/showcaseFieldTypes.js";

const duration = ref("1 02:30:00");
const distributorIds = ref(["12", "40"]);
</script>

# Form Widgets

A [Widget](/v3/reference/glossary.md#widget) is the input that a generated form renders for one field. This page
shows each widget as [FormModel](/v3/reference/api/vue/FormModel.md) renders it. A widget can
carry chrome that its primitive lacks: a picker trigger, a remove control, a preview,
or a notice in place of an input. The [Inputs](/v3/reference/components/inputs.md) and
[Date + Time](/v3/reference/components/datetime.md) pages show the primitives that
the widgets use. [Components](/v3/reference/components/index.md) describes the skin rules
that every component page shares.

Every demo runs against one seeded model with one field per widget. The caption
under each demo names the field's
[`typeSerializer`](/v3/reference/api/js/stores/storeModelInfo/types/FieldInfo.md#typeSerializer)
and
[`typeModel`](/v3/reference/api/js/stores/storeModelInfo/types/FieldInfo.md#typeModel)
pair, which selects the widget through the
[type mapping tables](/v3/reference/api/js/utils/fieldMappings.md).
[Field and Widget Resolution](/v3/core-concepts/contract-first-dynamic-ui.md#field-and-widget-resolution)
describes the lookup.

## Date and time

Four widgets cover date and time fields. The date widgets show editable segments
and a trigger that opens a calendar. The time widgets show editable segments
only. Each range widget shows both bounds on one row.

<ClientOnly>
<VuedaDemo class="flex flex-col gap-3">
  <DemoFormModel
    :app="SHOWCASE_FIELD_TYPES.app"
    :model="SHOWCASE_FIELD_TYPES.model"
    :fields="['releaseDate', 'publishedAt', 'campaignWindow', 'opensAt', 'serviceWindow']"
  />
  <footer class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
    <span>Release date: <code>DateField</code> / <code>DateField</code> selects [WidgetDateField](/v3/reference/api/vue/WidgetDateField.md) at day granularity</span>
    <span>Published at: <code>DateTimeField</code> / <code>DateTimeField</code> selects the same widget with <code>granularity: "minute"</code>, so it adds time segments</span>
    <span>Campaign window: <code>RangeField</code> / <code>DateRangeField</code> selects [WidgetDateRangeField](/v3/reference/api/vue/WidgetDateRangeField.md)</span>
    <span>Opens at: <code>TimeField</code> / <code>TimeField</code> selects [WidgetTimeField](/v3/reference/api/vue/WidgetTimeField.md)</span>
    <span>Service window: <code>RangeField</code> / <code>TimeRangeField</code> selects [WidgetTimeRangeField](/v3/reference/api/vue/WidgetTimeRangeField.md)</span>
    <span>theme keys: [WidgetDateField](/v3/reference/theming/keys/WidgetDateField.md), [WidgetDateRangeField](/v3/reference/theming/keys/WidgetDateRangeField.md), [DateField](/v3/reference/theming/keys/DateField.md), [DateFieldInput](/v3/reference/theming/keys/DateFieldInput.md)</span>
  </footer>
</VuedaDemo>
</ClientOnly>

A time range with an error marks both bounds invalid.

<VuedaDemo>
  <DemoCard title="Time range: invalid">
    <WidgetTimeRangeField :model-value="{ lower: '17:00:00', upper: '09:00:00' }" invalid />
    <FieldMessage :messages="['End time must be later than start time.']" />
    <template #footer>
      <span>This specimen sets <code>invalid</code> directly; both bounds show the invalid state.</span>
    </template>
  </DemoCard>
</VuedaDemo>

### Read-only dates

A read view, a computed field, or a read-only model config renders a field
without its input. Date, time, and datetime fields then render with
[WidgetDateTimeReadOnly](/v3/reference/api/vue/WidgetDateTimeReadOnly.md), which formats the value. Its display
options come from the mapping entry's
[`readOnlyWidgetProps`](/v3/reference/api/js/utils/fieldMappings/types/FieldMappingEntry.md#readOnlyWidgetProps).
They match the
[`columnProps`](/v3/reference/api/js/utils/columnMappings/types/ColumnMappingEntry.md#columnProps)
of the list column for the same type, so a field reads the same in a list and on
a read view. An empty date renders the same dash in both.

<ClientOnly>
<VuedaDemo class="flex flex-col gap-3">
  <DemoFormModel
    :app="SHOWCASE_FIELD_TYPES.app"
    :model="SHOWCASE_FIELD_TYPES.model"
    view="read"
    view-theme="ViewRead"
    :fields="['releaseDate', 'publishedAt', 'opensAt']"
    :initial-values="{ releaseDate: '2026-06-01', publishedAt: '2026-08-25T17:21:56.906248Z', opensAt: '09:30:00' }"
  />
  <footer class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
    <span>Release date: <code>showTime: false</code>, so the time is left off</span>
    <span>Published at: <code>showTime: true</code>, with the relative time on hover</span>
    <span>Opens at: <code>format: "t"</code>, and no relative text, which would reference today and mislead</span>
  </footer>
</VuedaDemo>
</ClientOnly>

<ClientOnly>
<VuedaDemo class="flex flex-col gap-3">
  <DemoFormModel
    :app="SHOWCASE_FIELD_TYPES.app"
    :model="SHOWCASE_FIELD_TYPES.model"
    view="read"
    view-theme="ViewRead"
    :fields="['releaseDate', 'publishedAt']"
  />
  <footer class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
    <span>empty dates render the [DateTimeDisplay](/v3/reference/theming/keys/DateTimeDisplay.md) <code>dash</code> slot, matching a list cell</span>
  </footer>
</VuedaDemo>
</ClientOnly>

## Duration

[WidgetDuration](/v3/reference/api/vue/WidgetDuration.md) enters a duration through one spinner per
time unit. Only [`showMinutes`](/v3/reference/api/vue/WidgetDuration.md#prop-showMinutes)
defaults to true;
[`showDays`](/v3/reference/api/vue/WidgetDuration.md#prop-showDays),
[`showHours`](/v3/reference/api/vue/WidgetDuration.md#prop-showHours), and
[`showSeconds`](/v3/reference/api/vue/WidgetDuration.md#prop-showSeconds) add the other
units. Each spinner has a visible unit label, and clicking the label focuses its
input. The [WidgetDuration.unitLabel](/v3/reference/theming/keys/WidgetDuration.md#theme-key-WidgetDuration-unitLabel) slot styles the labels.

The value is the duration string that a `DurationField` sends and reads, such as
`1 02:30:00`. With [`seconds`](/v3/reference/api/vue/WidgetDuration.md#prop-seconds),
the value is a number of seconds, as a `DurationSecondsField` sends and reads
it. The shown units split the value between them, so two days read as 48 hours
when days are hidden. Clearing a unit while the rest of the value is zero sets
the value to `null`. Entering `0` keeps a zero duration.

A negative value, such as `-1 23:00:00` (Django's form for minus one hour),
shows a "Negative" segment with a minus sign before the spinners. The spinners
show the size of the duration, and editing them keeps the value negative. The
[WidgetDuration.sign](/v3/reference/theming/keys/WidgetDuration.md#theme-key-WidgetDuration-sign) and [WidgetDuration.signSymbol](/v3/reference/theming/keys/WidgetDuration.md#theme-key-WidgetDuration-signSymbol)
slots style that segment.

<ClientOnly>
<VuedaDemo class="flex flex-col gap-3">
  <DemoFormModel
    :app="SHOWCASE_FIELD_TYPES.app"
    :model="SHOWCASE_FIELD_TYPES.model"
    :fields="['leadTime']"
  />
  <footer class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
    <span>Lead time: <code>DurationField</code> / <code>DurationField</code> selects [WidgetDuration](/v3/reference/api/vue/WidgetDuration.md)</span>
    <span>a <code>DurationSecondsField</code> serializer selects the same widget with <code>seconds: true</code></span>
    <span>one spinner here, because only <code>showMinutes</code> defaults to true</span>
    <span>theme key: [WidgetDuration](/v3/reference/theming/keys/WidgetDuration.md)</span>
  </footer>
</VuedaDemo>
</ClientOnly>

<VuedaDemo>
  <DemoCard title="All duration units">
    <WidgetDuration v-model="duration" show-days show-hours show-seconds />
    <template #footer>
      <span>value: <code>{{ JSON.stringify(duration) }}</code></span>
      <span>Each enabled unit has its own label and input.</span>
    </template>
  </DemoCard>
</VuedaDemo>

### Read-only durations

Read-only duration fields render with
[WidgetDurationReadOnly](/v3/reference/api/vue/WidgetDurationReadOnly.md), which names the units that the
value holds. A `DurationField` sends `2 08:00:00`, and the read row shows "2 days, 8
hours". A `DurationSecondsField` sends a number of seconds, which the same widget
reads. Units that hold zero are left out, and a duration of zero names its
smallest unit ("0 seconds"). An empty value renders the same dash as an empty
date. A list cell uses [ColumnDuration](/v3/reference/api/vue/ColumnDuration.md), which wraps the same
display, so a field reads the same in both places.

<ClientOnly>
<VuedaDemo class="flex flex-col gap-3">
  <DemoFormModel
    :app="SHOWCASE_FIELD_TYPES.app"
    :model="SHOWCASE_FIELD_TYPES.model"
    view="read"
    view-theme="ViewRead"
    :fields="['leadTime']"
    :initial-values="{ leadTime: '2 08:00:00' }"
  />
  <footer class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
    <span>Lead time: the stored <code>2 08:00:00</code> reads as named units, and the zero minutes and seconds are left out</span>
    <span>a duration of zero still names its smallest unit, because a recorded zero differs from no value</span>
    <span>theme key: [DurationDisplay](/v3/reference/theming/keys/DurationDisplay.md), whose <code>dash</code> slot holds the empty value</span>
  </footer>
</VuedaDemo>
</ClientOnly>

## Choice

A single choice renders [WidgetSelectDropdown](/v3/reference/api/vue/WidgetSelectDropdown.md). The demo
below shows the other two choice widgets: a many-valued choice, and a boolean
with labels for its two states.

`WidgetSelectDropdown` treats empty-string, `null`, and `undefined` choices as no
selection and leaves them out of the menu. Without a
[`placeholder`](/v3/reference/api/vue/WidgetSelectDropdown.md#prop-placeholder), it uses
the first empty choice's label as the placeholder. The values `false` and `0` stay
in the menu as ordinary choices. The menu has no empty item, so a user cannot
clear a selection from it.

<ClientOnly>
<VuedaDemo class="flex flex-col gap-3">
  <DemoFormModel
    :app="SHOWCASE_FIELD_TYPES.app"
    :model="SHOWCASE_FIELD_TYPES.model"
    :fields="['regions', 'expedited']"
  />
  <footer class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
    <span>Regions: <code>ChoiceField</code> / <code>CharField</code> with <code>many: true</code> selects [WidgetCombobox](/v3/reference/api/vue/WidgetCombobox.md) with <code>multiple: true</code>. A relation field selects the same widget, so this demo also shows the picker a foreign key renders</span>
    <span>Handling: <code>BooleanField</code> / <code>BooleanField</code> with <code>choices</code> selects [WidgetRadioGroup](/v3/reference/api/vue/WidgetRadioGroup.md). The same field without choices selects [WidgetToggle](/v3/reference/api/vue/WidgetToggle.md)</span>
    <span>theme keys: [WidgetCombobox](/v3/reference/theming/keys/WidgetCombobox.md), [RadioGroup](/v3/reference/theming/keys/RadioGroup.md)</span>
  </footer>
</VuedaDemo>
</ClientOnly>

## Lists of values

[WidgetTagsInput](/v3/reference/api/vue/WidgetTagsInput.md) enters a list of values as removable tags.
Its value is an array of strings. The widget adds an entry on Enter, on a comma,
on paste, and when the input loses focus. It trims each entry first, so ` 2`
next to an existing `2` is a duplicate, and the widget does not add it. With
[`numeric`](/v3/reference/api/vue/WidgetTagsInput.md#prop-numeric), the widget adds
only decimal numbers, such as `12`, `-2.5`, or `1e3`.

The widget keeps a refused entry, such as `1..2`, in the input and marks it
invalid until the user edits it. A `DecimalInField` filter, such as a list of
IDs, renders this widget.

<VuedaDemo>
  <DemoCard title="Numeric entries">
    <WidgetTagsInput v-model="distributorIds" numeric placeholder="Add an ID" />
    <template #footer>
      <span>value: <code>{{ JSON.stringify(distributorIds) }}</code></span>
      <span>theme keys: [TagsInput](/v3/reference/theming/keys/TagsInput.md), [TagsInputItem](/v3/reference/theming/keys/TagsInputItem.md), [TagsInputInput](/v3/reference/theming/keys/TagsInputInput.md)</span>
    </template>
  </DemoCard>
</VuedaDemo>

## Structured data

[WidgetJson](/v3/reference/api/vue/WidgetJson.md) edits a `JSON` field as text and submits the
parsed value.

<ClientOnly>
<VuedaDemo class="flex flex-col gap-3">
  <DemoFormModel
    :app="SHOWCASE_FIELD_TYPES.app"
    :model="SHOWCASE_FIELD_TYPES.model"
    :fields="['metadata']"
  />
  <footer class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
    <span>Metadata: <code>JSONField</code> / <code>JSONField</code> selects [WidgetJson](/v3/reference/api/vue/WidgetJson.md)</span>
    <span>theme key: [WidgetJson](/v3/reference/theming/keys/WidgetJson.md)</span>
  </footer>
</VuedaDemo>
</ClientOnly>

### Read-only JSON

Read-only `JSON` fields render with [WidgetJsonReadOnly](/v3/reference/api/vue/WidgetJsonReadOnly.md),
which prints the payload over several lines, indented by
[`indent`](/v3/reference/api/vue/WidgetJsonReadOnly.md#prop-indent) spaces per level
(2 by default). A list cell uses [ColumnJson](/v3/reference/api/vue/ColumnJson.md), which prints the
same value compact and truncates it past
[`maxLength`](/v3/reference/api/vue/ColumnJson.md#prop-maxLength) characters (200 by
default). Both render a null value as the same dash as an empty date. An empty
object or array is a recorded value, so it reads as `{}` or `[]`.

<ClientOnly>
<VuedaDemo class="flex flex-col gap-3">
  <DemoFormModel
    :app="SHOWCASE_FIELD_TYPES.app"
    :model="SHOWCASE_FIELD_TYPES.model"
    view="read"
    view-theme="ViewRead"
    :fields="['metadata']"
    :initial-values="{ metadata: { sku: 'BRG-6204', revision: 3, channels: ['direct', 'partner'] } }"
  />
  <footer class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
    <span>Metadata: each nesting level is indented, and a long string value wraps inside the row</span>
    <span>theme key: [JsonDisplay](/v3/reference/theming/keys/JsonDisplay.md), whose <code>dash</code> slot holds the empty value</span>
  </footer>
</VuedaDemo>
</ClientOnly>

## Files and images

Each widget shows an upload control while the field is empty. Once the field has
a file, [WidgetFile](/v3/reference/api/vue/WidgetFile.md) shows it as a download link with remove
and download controls, and [WidgetImage](/v3/reference/api/vue/WidgetImage.md) shows a preview with a
remove control.

In a read view, both field types render with [WidgetFileReadOnly](/v3/reference/api/vue/WidgetFileReadOnly.md),
which shows the file as a link labelled with the file name, or a dash when the field
is empty.

<ClientOnly>
<VuedaDemo class="flex flex-col gap-3">
  <DemoFormModel
    :app="SHOWCASE_FIELD_TYPES.app"
    :model="SHOWCASE_FIELD_TYPES.model"
    :fields="['datasheet', 'heroImage']"
  />
  <footer class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
    <span>Datasheet: <code>FileField</code> / <code>FileField</code> selects [WidgetFile](/v3/reference/api/vue/WidgetFile.md)</span>
    <span>Hero image: <code>ImageField</code> / <code>ImageField</code> selects [WidgetImage](/v3/reference/api/vue/WidgetImage.md)</span>
    <span>theme keys: [WidgetFile](/v3/reference/theming/keys/WidgetFile.md), [WidgetImage](/v3/reference/theming/keys/WidgetImage.md), [FileUpload](/v3/reference/theming/keys/FileUpload.md)</span>
  </footer>
</VuedaDemo>
</ClientOnly>

## Unmapped widget

IP address fields map to [WidgetUnmapped](/v3/reference/api/vue/WidgetUnmapped.md) by default. It
renders an alert that names the field in place of an input, so the field stays
visible on the form. The alert sits in the field's content column and must not
look like a control.
[Map a Field Type to a Widget](/v3/guides/custom-field-widget-rendering.md#map-a-field-type-to-a-widget)
describes how to give the type a widget, and the error that a field with no
mapping renders.

<ClientOnly>
<VuedaDemo class="flex flex-col gap-3">
  <DemoFormModel
    :app="SHOWCASE_FIELD_TYPES.app"
    :model="SHOWCASE_FIELD_TYPES.model"
    :fields="['serverIp']"
  />
  <footer class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
    <span>Server IP: <code>IPAddressField</code> / <code>IPAddressField</code> selects [WidgetUnmapped](/v3/reference/api/vue/WidgetUnmapped.md)</span>
    <span>theme key: [WidgetUnmapped](/v3/reference/theming/keys/WidgetUnmapped.md)</span>
  </footer>
</VuedaDemo>
</ClientOnly>
