DevExtreme v25.1 is now available.

Explore our newest features/capabilities and share your thoughts with us.

Your search did not match any results.

Vue Stepper - Form Integration

This demo uses the DevExtreme Stepper component to guide users through a multi-page hotel registration form. The Stepper lacks a built-in form but can connect to it or other controls through APIs. Input controls at each step are organized using DevExtreme MultiView and Form components.

There are three key parts to this demo:

  • Stepper
  • Step content where the MultiView component displays content for each step. Each view with input fields contains a Form.
  • A navigation panel that displays the current step ("Step 1 of 5") and buttons for moving between steps (Next/Back).
Backend API
<template> <DxStepper :focus-state-enabled="!isStepperReadonly" :class="{ readonly: isStepperReadonly }" v-model:selected-index="selectedIndex" @selection-changing="onSelectionChanging" > <DxStepperItem v-for="item of steps" :label="item.label" :icon="item.icon" :is-valid="item.isValid" :hint="item.hint" :optional="item.optional" /> </DxStepper> <div class="content"> <DxMultiView v-model:selected-index="selectedIndex" :focus-state-enabled="false" :animation-enabled="false" :swipe-enabled="false" :height="400" > <DxMultiViewItem> <template #default> <DatesTemplate :form-data="formData" :validation-group="validationGroups[0]" /> </template> </DxMultiViewItem> <DxMultiViewItem> <template #default> <GuestsTemplate :form-data="formData" :validation-group="validationGroups[1]" /> </template> </DxMultiViewItem> <DxMultiViewItem> <template #default> <RoomMealPlanTemplate :form-data="formData" :validation-group="validationGroups[2]" /> </template> </DxMultiViewItem> <DxMultiViewItem> <template #default> <AdditionalTemplate :form-data="formData"/> </template> </DxMultiViewItem> <DxMultiViewItem> <template #default> <ConfirmationTemplate :form-data="formData" v-model:is-confirmed="isConfirmed" /> </template> </DxMultiViewItem> </DxMultiView> <div class="nav-panel"> <div class="current-step"> <span v-if="!isConfirmed"> Step <span class="selected-index">{{ selectedIndex + 1 }}</span> of {{ steps.length }} </span> </div> <div class="nav-buttons"> <DxButton id="prevButton" :visible="selectedIndex !== 0 && !isConfirmed" text="Back" type="normal" @click="onPrevButtonClick" :width="100" /> <DxButton id="nextButton" :text="nextButtonText" type="default" @click="onNextButtonClick" :width="100" /> </div> </div> </div> </template> <script setup lang="ts"> import { ref, computed } from 'vue'; import { DxButton } from 'devextreme-vue/button'; import { DxMultiView, DxItem as DxMultiViewItem } from 'devextreme-vue/multi-view'; import { DxStepper, DxItem as DxStepperItem, type DxStepperTypes } from 'devextreme-vue/stepper'; import validationEngine from 'devextreme/ui/validation_engine';; import DatesTemplate from './DatesTemplate.vue'; import GuestsTemplate from './GuestsTemplate.vue'; import RoomMealPlanTemplate from './RoomMealPlanTemplate.vue'; import AdditionalTemplate from './AdditionalTemplate.vue'; import ConfirmationTemplate from './ConfirmationTemplate.vue'; import { getInitialSteps, getInitialFormData } from './data.ts'; import type { BookingFormData } from './types'; const selectedIndex = ref(0); const isConfirmed = ref(false); const isStepperReadonly = ref(false); const steps = ref<DxStepperTypes.Item[]>(getInitialSteps()); const formData = ref<BookingFormData>(getInitialFormData()); const validationGroups = ['dates', 'guests', 'roomAndMealPlan']; const nextButtonText = computed(() => { if (selectedIndex.value < steps.value.length - 1) { return 'Next'; } return isConfirmed.value ? 'Reset' : 'Confirm'; }); const getValidationResult = (index: number) => { if (index >= validationGroups.length) { return true; } return validationEngine.validateGroup(validationGroups[index]).isValid; }; const setStepValidationResult = (index: number, isValid: boolean | undefined) => { steps.value[index].isValid = isValid; }; function onSelectionChanging(e: DxStepperTypes.SelectionChangingEvent) { const { component, addedItems, removedItems } = e; const { items = [] } = component.option(); const addedIndex = items.findIndex((item: DxStepperTypes.Item) => item === addedItems[0]); const removedIndex = items.findIndex((item: DxStepperTypes.Item) => item === removedItems[0]); const isMoveForward = addedIndex > removedIndex; if (isMoveForward) { const isValid = getValidationResult(removedIndex); setStepValidationResult(removedIndex, isValid); if (isValid === false) { e.cancel = true; } } } function onPrevButtonClick() { selectedIndex.value -= 1; } const moveNext = () => { const isValid = getValidationResult(selectedIndex.value); setStepValidationResult(selectedIndex.value, isValid); if (isValid) { selectedIndex.value += 1; } }; const reset = () => { isConfirmed.value = false; selectedIndex.value = 0; steps.value = getInitialSteps(); formData.value = getInitialFormData(); isStepperReadonly.value = false; }; const confirm = () => { isConfirmed.value = true; setStepValidationResult(selectedIndex.value, true); isStepperReadonly.value = true; }; function onNextButtonClick() { if (selectedIndex.value < steps.value.length - 1) { moveNext(); } else if (isConfirmed.value) { reset(); } else { confirm(); } } </script> <style scoped> .demo-container { min-height: 580px; } #app { display: flex; flex-direction: column; justify-content: center; row-gap: 20px; height: 580px; min-width: 620px; } .content { padding-inline: 40px; flex: 1; display: flex; flex-direction: column; row-gap: 20px; } .dx-multiview-item-content:has(> .summary-container) { overflow: auto; } .summary-container { display: flex; flex-direction: column; row-gap: 20px; } .summary-item { display: flex; flex-direction: column; row-gap: 8px; } .summary-item-header { font-weight: 600; font-size: var(--dx-font-size-sm); } .center { text-align: center; } .summary-item-label { color: var(--dx-color-icon); } .separator { width: 100%; height: 1px; border-bottom: solid 1px var(--dx-color-border); } .nav-panel { display: flex; align-items: center; justify-content: space-between; } .current-step { color: var(--dx-color-icon); } .nav-buttons { display: flex; gap: 8px; } .readonly { pointer-events: none; } </style>
<template> <div> Please let us know if you have any other requests. </div> <DxForm :form-data="formData"> <DxSimpleItem data-field="additionalRequest" editor-type="dxTextArea" :editor-options="textAreaOptions" :label="labelOptions" /> </DxForm> </template> <script setup lang="ts"> import { DxForm, DxSimpleItem } from 'devextreme-vue/form'; import 'devextreme-vue/text-area'; import type { BookingFormData } from './types.ts'; import { initialFormData } from './data.ts'; const props = withDefaults(defineProps<{ formData: BookingFormData; }>(), { formData: () => initialFormData, }); const labelOptions = { visible: false, }; const textAreaOptions = { height: 160, elementAttr: { id: 'additionalRequest' }, }; </script>
<template> <div class="summary-item-header center" v-if="isConfirmed" > Your booking request was submitted. </div> <div class="summary-container" v-if="!isConfirmed" > <div class="summary-item"> <div class="summary-item-header">Dates</div> <div class="separator"/> <div> <span class="summary-item-label">Check-in Date: </span> {{ new Date(formData.dates[0]).toLocaleDateString() }} </div> <div> <span class="summary-item-label">Check-out Date: </span> {{ new Date(formData.dates[1]).toLocaleDateString() }} </div> </div> <div class="summary-item"> <div class="summary-item-header">Guests</div> <div class="separator"/> <div><span class="summary-item-label">Adults: </span>{{ formData.adultsCount }}</div> <div><span class="summary-item-label">Children: </span>{{ formData.childrenCount }}</div> <div><span class="summary-item-label">Pets: </span>{{ formData.petsCount }}</div> </div> <div class="summary-item"> <div class="summary-item-header">Room and Meals</div> <div class="separator"/> <div><span class="summary-item-label">Room Type: </span>{{ formData.roomType }}</div> <div><span class="summary-item-label">Check-out Date: </span>{{ formData.mealPlan }}</div> </div> <div class="summary-item" v-if="!!formData.additionalRequest" > <div class="summary-item-header">Additional Requests</div> <div class="separator"/> <div>{{ formData.additionalRequest }}</div> </div> </div> </template> <script setup lang="ts"> import type { BookingFormData } from './types.ts'; import { initialFormData } from './data.ts'; const props = withDefaults(defineProps<{ formData: BookingFormData; isConfirmed: boolean; }>(), { formData: () => initialFormData, isConfirmed: () => false, }); </script>
<template> <p> Select your check-in and check-out dates. If your dates are flexible, include that information in Additional Requests. We will do our best to suggest best pricing options, depending on room availability. </p> <DxForm :form-data="formData" :validation-group="validationGroup" ref="formRef" > <DxSimpleItem :is-required="true" data-field="dates" editor-type="dxDateRangeBox" :editor-options="dateRangeBoxOptions" :label="labelOptions" /> </DxForm> </template> <script setup lang="ts"> import DxForm, { DxSimpleItem } from 'devextreme-vue/form'; import 'devextreme-vue/date-range-box'; import { ref, watch } from 'vue'; import type { BookingFormData } from './types.ts'; import { getInitialFormData } from './data.ts'; const formRef = ref(null); const props = withDefaults(defineProps<{ formData: BookingFormData; validationGroup?: string; }>(), { formData: getInitialFormData, validationGroup: () => undefined, }); watch(() => props.formData, (value) => { formRef.value.instance.reset(value); }); const labelOptions = { visible: false, }; const dateRangeBoxOptions = { startDatePlaceholder: 'Check-in', endDatePlaceholder: 'Check-out', elementAttr: { id: 'datesPicker' }, }; </script>
<template> <p> Enter the number of adults, children, and pets staying in the room. This information help us suggest suitable room types, number of beds, and included amenities. </p> <DxForm :form-data="formData" :validation-group="validationGroup" :col-count="3" ref="formRef" > <DxSimpleItem :is-required="true" data-field="adultsCount" editor-type="dxNumberBox" :editor-options="adultsNumberBoxOptions" :label="adultsLabelOptions" > <DxRangeRule :min="1"/> </DxSimpleItem> <DxSimpleItem data-field="childrenCount" editor-type="dxNumberBox" :editor-options="numberBoxOptions" :label="childrenLabelOptions" /> <DxSimpleItem data-field="petsCount" editor-type="dxNumberBox" :editor-options="numberBoxOptions" :label="petsLabelOptions" /> </DxForm> </template> <script setup lang="ts"> import { DxForm, DxRangeRule, DxSimpleItem } from 'devextreme-vue/form'; import 'devextreme-vue/number-box'; import { watch, ref } from 'vue'; import type { BookingFormData } from './types.ts'; import { getInitialFormData } from './data.ts'; const formRef = ref(null); const props = withDefaults(defineProps<{ formData: BookingFormData; validationGroup?: string; }>(), { formData: getInitialFormData, validationGroup: () => undefined, }); watch(() => props.formData, (value) => { formRef.value.instance.reset(value); }); const adultsLabelOptions = { text: 'Adults', location: 'top', }; const childrenLabelOptions = { text: 'Children', location: 'top', }; const petsLabelOptions = { text: 'Pets', location: 'top', }; const numberBoxOptions = { min: 0, max: 5, showSpinButtons: true, }; const adultsNumberBoxOptions = { ...numberBoxOptions, elementAttr: { id: 'adultsCount' }, }; </script>
<template> <p> Review room types that can accommodate your group size and make your selection. You can also choose a meal plan, whether it's breakfast only or full board. </p> <DxForm :form-data="formData" :validation-group="validationGroup" :col-count="2" ref="formRef" > <DxSimpleItem :is-required="true" data-field="roomType" editor-type="dxSelectBox" :editor-options="roomSelectBoxOptions" :label="roomLabelOptions" /> <DxSimpleItem :is-required="true" data-field="mealPlan" editor-type="dxSelectBox" :editor-options="mealSelectBoxOptions" :label="mealLabelOptions" /> </DxForm> </template> <script setup lang="ts"> import DxForm, { DxSimpleItem } from 'devextreme-vue/form'; import 'devextreme-vue/select-box'; import { watch, ref } from 'vue'; import type { BookingFormData } from './types.ts'; import { roomTypes, mealPlans, getInitialFormData } from './data.ts'; const formRef = ref(null); const props = withDefaults(defineProps<{ formData: BookingFormData; validationGroup?: string; }>(), { formData: getInitialFormData, validationGroup: () => undefined, }); watch(() => props.formData, (value) => { formRef.value.instance.reset(value); }); const roomLabelOptions = { text: 'Room Type', location: 'top', }; const roomSelectBoxOptions = { items: roomTypes, elementAttr: { id: 'roomType' }, }; const mealLabelOptions = { text: 'Meal Plan', location: 'top', }; const mealSelectBoxOptions = { items: mealPlans, elementAttr: { id: 'mealPlan' }, }; </script>
window.exports = window.exports || {}; window.config = { transpiler: 'plugin-babel', meta: { '*.vue': { loader: 'vue-loader', }, '*.ts': { loader: 'demo-ts-loader', }, '*.svg': { loader: 'svg-loader', }, 'devextreme/time_zone_utils.js': { 'esModule': true, }, 'devextreme/localization.js': { 'esModule': true, }, 'devextreme/viz/palette.js': { 'esModule': true, }, 'openai': { 'esModule': true, }, }, paths: { 'project:': '../../../../', 'npm:': 'https://cdn.jsdelivr.net/npm/', 'bundles:': '../../../../bundles/', 'externals:': '../../../../bundles/externals/', }, map: { 'vue': 'npm:vue@3.4.27/dist/vue.esm-browser.js', '@vue/shared': 'npm:@vue/shared@3.4.27/dist/shared.cjs.prod.js', 'vue-loader': 'npm:dx-systemjs-vue-browser@1.1.2/index.js', 'demo-ts-loader': 'project:utils/demo-ts-loader.js', 'jszip': 'npm:jszip@3.10.1/dist/jszip.min.js', 'svg-loader': 'project:utils/svg-loader.js', 'mitt': 'npm:mitt/dist/mitt.umd.js', 'rrule': 'npm:rrule@2.6.4/dist/es5/rrule.js', 'luxon': 'npm:luxon@3.4.4/build/global/luxon.min.js', 'es6-object-assign': 'npm:es6-object-assign', 'devextreme': 'npm:devextreme@25.1.3/cjs', 'devextreme-vue': 'npm:devextreme-vue@25.1.3/cjs', 'devextreme-quill': 'npm:devextreme-quill@1.7.3/dist/dx-quill.min.js', 'devexpress-diagram': 'npm:devexpress-diagram@2.2.19/dist/dx-diagram.js', 'devexpress-gantt': 'npm:devexpress-gantt@4.1.62/dist/dx-gantt.js', 'inferno': 'npm:inferno@8.2.3/dist/inferno.min.js', 'inferno-compat': 'npm:inferno-compat/dist/inferno-compat.min.js', 'inferno-create-element': 'npm:inferno-create-element@8.2.3/dist/inferno-create-element.min.js', 'inferno-dom': 'npm:inferno-dom/dist/inferno-dom.min.js', 'inferno-hydrate': 'npm:inferno-hydrate/dist/inferno-hydrate.min.js', 'inferno-clone-vnode': 'npm:inferno-clone-vnode/dist/inferno-clone-vnode.min.js', 'inferno-create-class': 'npm:inferno-create-class/dist/inferno-create-class.min.js', 'inferno-extras': 'npm:inferno-extras/dist/inferno-extras.min.js', '@preact/signals-core': 'npm:@preact/signals-core@1.8.0/dist/signals-core.min.js', 'plugin-babel': 'npm:systemjs-plugin-babel@0.0.25/plugin-babel.js', 'systemjs-babel-build': 'npm:systemjs-plugin-babel@0.0.25/systemjs-babel-browser.js', // Prettier 'prettier/standalone': 'npm:prettier@2.8.8/standalone.js', 'prettier/parser-html': 'npm:prettier@2.8.8/parser-html.js', }, packages: { 'devextreme-vue': { main: 'index.js', }, 'devextreme-vue/common': { main: 'index.js', }, 'devextreme': { defaultExtension: 'js', }, 'devextreme/events/utils': { main: 'index', }, 'devextreme/common/core/events/utils': { main: 'index', }, 'devextreme/events': { main: 'index', }, 'es6-object-assign': { main: './index.js', defaultExtension: 'js', }, }, packageConfigPaths: [ 'npm:@devextreme/*/package.json', ], babelOptions: { sourceMaps: false, stage0: true, }, }; System.config(window.config);
import { type DxStepperTypes } from 'devextreme-vue/stepper' import type { BookingFormData } from './types'; export const initialSteps: DxStepperTypes.Item[] = [ { label: 'Dates', hint: 'Dates', icon: 'daterangepicker', }, { label: 'Guests', hint: 'Guests', icon: 'group', }, { label: 'Room and Meal Plan', hint: 'Room and Meal Plan', icon: 'servicebell', }, { label: 'Additional Requests', hint: 'Additional Requests', icon: 'clipboardtasklist', optional: true, }, { label: 'Confirmation', hint: 'Confirmation', icon: 'checkmarkcircle', }, ]; export const roomTypes = ['Single', 'Double', 'Suite']; export const mealPlans = ['Bed & Breakfast', 'Half Board', 'Full Board', 'All-Inclusive']; export const initialFormData: BookingFormData = { dates: [null, null], adultsCount: 0, childrenCount: 0, petsCount: 0, roomType: undefined, mealPlan: undefined, additionalRequest: '', }; export const getInitialSteps = () => initialSteps.map((item) => ({ ...item })); export const getInitialFormData = () => ({ ...initialFormData, dates: [...initialFormData.dates], });
import { createApp } from 'vue'; import themes from 'devextreme/ui/themes'; import App from './App.vue'; themes.initialized(() => createApp(App).mount('#app'));
export interface BookingFormData { dates: Array<Date | null>; adultsCount: number; childrenCount: number; petsCount: number; roomType: string | undefined; mealPlan: string | undefined; additionalRequest: string; }
<!DOCTYPE html> <html lang="en"> <head> <title>DevExtreme Demo</title> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" /> <link rel="stylesheet" type="text/css" href="https://cdn3.devexpress.com/jslib/25.1.3/css/dx.light.css" /> <script type="module"> import * as vueCompilerSFC from "https://cdn.jsdelivr.net/npm/@vue/compiler-sfc@3.4.27/dist/compiler-sfc.esm-browser.js"; window.vueCompilerSFC = vueCompilerSFC; </script> <script src="https://cdn.jsdelivr.net/npm/typescript@5.4.5/lib/typescript.js"></script> <script src="https://cdn.jsdelivr.net/npm/core-js@2.6.12/client/shim.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/systemjs@0.21.3/dist/system.js"></script> <script type="text/javascript" src="config.js"></script> <script type="text/javascript"> System.import("./index.ts"); </script> </head> <body class="dx-viewport"> <div class="demo-container"> <div id="app"></div> </div> </body> </html>

Validation

Form validation impacts step progression and view switch operations. Attempting to proceed by clicking a step (onSelectionChanging) or pressing "Next" (onClick) triggers the validation process (validateStep) for current step input.

Valid data changes the current step to display a checkmark, selects the next step, and updates the view to the following form. Invalid data prompts error messages, marks the step as invalid, and prevents step progress. Only validated steps allow progression.

To view validation in action, move to step two without "Check-in" and "Check-out" dates. Required fields will fail validation, marking step one as invalid (isValid = false). The icon turns red and displays an exclamation mark. The DateRangeBox component also displays an error icon. DateRangeBox and Stepper both display validation errors since they belong to the same validation group.

The final step is unique. Once the "Additional Requests" step is completed, the request is submitted, and a return to previous steps is not permitted. Click "Reset" to restart booking.