This guide will help you migrate from the legacy @frontile/forms-legacy package to the modern frontile forms. The new forms package provides improved developer experience, better accessibility, enhanced customization options, and reduced external dependencies.
# Remove the legacy package
npm uninstall @frontile/forms-legacy
# Install the new package
npm install frontile @frontile/theme
Update your imports:
// Before (forms-legacy)
import FormInput from '@frontile/forms-legacy/components/form-input';
import FormCheckbox from '@frontile/forms-legacy/components/form-checkbox';
// After (forms)
import { Input, Checkbox } from 'frontile';
The modern frontile package introduces a powerful Form + Field pattern that simplifies data binding and validation. This is the recommended approach for new forms and migrations.
import Component from '@glimmer/component';
import { Form } from 'frontile';
import type { FormResultData } from 'frontile';
import * as v from 'valibot';
const loginSchema = v.object({
email: v.pipe(
v.string(),
v.nonEmpty('Email is required'),
v.email('Please enter a valid email')
),
password: v.pipe(
v.string(),
v.minLength(6, 'Password must be at least 6 characters')
)
});
type LoginSchema = v.InferOutput<typeof loginSchema>;
export default class LoginForm extends Component {
schema = loginSchema;
handleSubmit = (result: FormResultData<LoginSchema>) => {
if (result.isValid) {
// result.data is typed as LoginSchema
this.login(result.data);
}
};
<template>
<Form
@schema={{this.schema}}
@onSubmit={{this.handleSubmit}}
as |form|
>
<form.Field @name='email' as |field|>
<field.Input @label='Email' @type='email' />
</form.Field>
<form.Field @name='password' as |field|>
<field.Input @label='Password' @type='password' />
</form.Field>
<button type='submit'>Login</button>
</Form>
</template>
}
Key Benefits:
form.Field automatically binds the value and errors to the input@tracked properties for form dataresult.data contains all form values on submitFor complete documentation on the Form component, validation patterns, nested data, and advanced features, see the Form Component Documentation.
If you're using manual validation with forms-legacy, you can migrate to schema-based validation with Valibot for a better developer experience.
import { tracked } from '@glimmer/tracking';
import { Input, Textarea } from '@frontile/forms-legacy';
export default class UserProfileForm extends Component {
@tracked email = '';
@tracked bio = '';
@tracked errors = {};
validateForm = () => {
const errors = {};
if (!this.email) {
errors.email = 'Email is required';
} else if (!this.email.includes('@')) {
errors.email = 'Invalid email format';
}
if (this.bio && this.bio.length < 10) {
errors.bio = 'Bio must be at least 10 characters';
}
return errors;
};
handleSubmit = (event) => {
event.preventDefault();
this.errors = this.validateForm();
if (Object.keys(this.errors).length === 0) {
this.saveProfile({ email: this.email, bio: this.bio });
}
};
<template>
<form {{on 'submit' this.handleSubmit}}>
<FormInput
@label='Email'
@value={{this.email}}
@onInput={{fn (mut this.email)}}
@errors={{this.errors.email}}
/>
<FormTextarea
@label='Bio'
@value={{this.bio}}
@onInput={{fn (mut this.bio)}}
@errors={{this.errors.bio}}
/>
<button type='submit'>Save Profile</button>
</form>
</template>
}
import Component from '@glimmer/component';
import { Form } from 'frontile';
import type { FormResultData } from 'frontile';
import * as v from 'valibot';
const profileSchema = v.object({
email: v.pipe(
v.string(),
v.nonEmpty('Email is required'),
v.email('Invalid email format')
),
bio: v.optional(
v.pipe(
v.string(),
v.minLength(10, 'Bio must be at least 10 characters')
)
)
});
type ProfileSchema = v.InferOutput<typeof profileSchema>;
export default class UserProfileForm extends Component {
schema = profileSchema;
handleSubmit = (result: FormResultData<ProfileSchema>) => {
if (result.isValid) {
// result.data is typed as ProfileSchema
this.saveProfile(result.data);
}
};
<template>
<Form
@schema={{this.schema}}
@onSubmit={{this.handleSubmit}}
as |form|
>
<form.Field @name='email' as |field|>
<field.Input @label='Email' @type='email' />
</form.Field>
<form.Field @name='bio' as |field|>
<field.Textarea @label='Bio' />
</form.Field>
<button type='submit'>Save Profile</button>
</Form>
</template>
}
Key Improvements:
@tracked properties or manual state management@validateOn)For complex validation scenarios or custom validation functions, see the Form Component Documentation.
The Form + Field pattern supports nested data structures using dot notation in field names. This makes it easy to work with complex data models without flattening your data structure.
import Component from '@glimmer/component';
import { Form } from 'frontile';
import type { FormResultData } from 'frontile';
import * as v from 'valibot';
const userSchema = v.object({
user: v.object({
profile: v.object({
email: v.pipe(v.string(), v.email()),
firstName: v.string(),
lastName: v.string()
}),
settings: v.object({
notifications: v.boolean()
})
})
});
type UserSchema = v.InferOutput<typeof userSchema>;
export default class UserSettingsForm extends Component {
schema = userSchema;
handleSubmit = (result: FormResultData<UserSchema>) => {
if (result.isValid) {
// result.data.user.profile.email is fully typed
this.saveUserSettings(result.data);
}
};
<template>
<Form @schema={{this.schema}} @onSubmit={{this.handleSubmit}} as |form|>
<form.Field @name='user.profile.email' as |field|>
<field.Input @label='Email' />
</form.Field>
<form.Field @name='user.profile.firstName' as |field|>
<field.Input @label='First Name' />
</form.Field>
<form.Field @name='user.settings.notifications' as |field|>
<field.Checkbox @label='Enable notifications' />
</form.Field>
<button type='submit'>Save</button>
</Form>
</template>
}
The Form component automatically handles data flattening and unflattening. On submit, result.data will contain the properly nested structure. See the Form Component Documentation for more details.
All component names have dropped the Form prefix:
FormInput → InputFormTextarea → TextareaFormCheckbox → CheckboxChanged from default imports to named imports from the package index.
The error handling approach has been simplified:
hasSubmitted, hasError, showError propserrors and isInvalid for error stateserrors presenceBetter integration with form validation libraries through the new Form component.
Theme classes have been updated - check @frontile/theme for new class names.
The Input component now supports start/end content slots and clearable functionality.
<FormInput
@label='First Name'
@value={{this.firstName}}
@onInput={{this.setFirstName}}
@errors={{this.validationErrors.firstName}}
@hasSubmitted={{this.hasSubmitted}}
@size='md'
@hint='Enter your first name'
@containerClass='custom-container'
@inputClass='custom-input'
/>
<Input
@label='First Name'
@value={{this.firstName}}
@onInput={{this.setFirstName}}
@errors={{this.validationErrors.firstName}}
@size='md'
@description='Enter your first name'
@classes={{hash base='custom-container' input='custom-input'}}
@isClearable={{true}}
>
<:startContent>
<SearchIcon />
</:startContent>
<:endContent>
<Button @size='sm'>Go</Button>
</:endContent>
</Input>
@hint → @description@containerClass → @classes={{hash base="..."}}@inputClass → @classes={{hash input="..."}}@hasSubmitted, @hasError, @showError@isClearable option<:startContent> and <:endContent> slots@startContentPointerEvents and @endContentPointerEvents for click handlingMinimal changes required for textarea migration.
<FormTextarea
@label='Description'
@value={{this.description}}
@onInput={{this.setDescription}}
@errors={{this.validationErrors.description}}
@hasSubmitted={{this.hasSubmitted}}
@rows='4'
/>
<Textarea
@label='Description'
@value={{this.description}}
@onInput={{this.setDescription}}
@errors={{this.validationErrors.description}}
rows='4'
/>
@rows to attributes (rows="4")@hasSubmitted, etc.)The Checkbox component now has better standalone usage and improved accessibility.
<FormCheckbox
@label='I agree to the terms'
@checked={{this.agreedToTerms}}
@onChange={{this.setAgreedToTerms}}
@errors={{this.validationErrors.terms}}
@hasSubmitted={{this.hasSubmitted}}
/>
<Checkbox
@label='I agree to the terms'
@checked={{this.agreedToTerms}}
@onChange={{this.setAgreedToTerms}}
@errors={{this.validationErrors.terms}}
/>
@checked@hasSubmitted, etc.)CheckboxGroup now uses a component-as-block pattern instead of an items-based API.
<FormCheckboxGroup
@label='Select your interests'
@onChange={{this.setInterests}}
@errors={{this.validationErrors.interests}}
as |Checkbox|
>
{{#each this.interestOptions as |option|}}
<Checkbox
@value={{option.value}}
@checked={{this.isInterestSelected option.value}}
>
{{option.label}}
</Checkbox>
{{/each}}
</FormCheckboxGroup>
<CheckboxGroup
@label='Select your interests'
@onChange={{this.setInterests}}
@errors={{this.validationErrors.interests}}
@name='interests'
as |Checkbox|
>
{{#each this.interestOptions as |option|}}
<Checkbox
@value={{option.value}}
@checked={{this.isInterestSelected option.value}}
>
{{option.label}}
</Checkbox>
{{/each}}
</CheckboxGroup>
@onChange@name prop for shared name attribute// Tracking selected values (same pattern as before)
@tracked selectedInterests = [];
isInterestSelected(value) {
return this.selectedInterests.includes(value);
}
setInterests = (value, isChecked) => {
if (isChecked) {
this.selectedInterests = [...this.selectedInterests, value];
} else {
this.selectedInterests = this.selectedInterests.filter(v => v !== value);
}
};
Minimal changes required for radio migration.
<FormRadio
@name='plan'
@value='premium'
@checked={{this.selectedPlan}}
@onChange={{this.setPlan}}
>
Premium Plan
</FormRadio>
<Radio
@name='plan'
@value='premium'
@checkedValue={{this.selectedPlan}}
@onChange={{this.setPlan}}
>
Premium Plan
</Radio>
@checked → @checkedValue (same concept, just renamed)@hasSubmitted, etc.)RadioGroup now uses a component-as-block pattern instead of an items-based API.
<FormRadioGroup
@label='Select a plan'
@onChange={{this.setPlan}}
@errors={{this.validationErrors.plan}}
as |Radio|
>
{{#each this.planOptions as |option|}}
<Radio
@value={{option.value}}
@checked={{eq this.selectedPlan option.value}}
>
{{option.label}}
</Radio>
{{/each}}
</FormRadioGroup>
<RadioGroup
@label='Select a plan'
@value={{this.selectedPlan}}
@onChange={{this.setPlan}}
@errors={{this.validationErrors.plan}}
@name='plan'
as |Radio|
>
{{#each this.planOptions as |option|}}
<Radio @value={{option.value}}>
{{option.label}}
</Radio>
{{/each}}
</RadioGroup>
@onChange@value for current selected value@name prop for shared name attribute@checkedValue to child radiosThis is the most significant change. The new Select component is completely rebuilt and no longer depends on ember-power-select.
<FormSelect
@label='Select Country'
@options={{this.countries}}
@selected={{this.selectedCountry}}
@onChange={{this.setCountry}}
@searchEnabled={{true}}
@searchField='name'
@errors={{this.validationErrors.country}}
@placeholder='Choose a country'
as |country|
>
{{country.name}}
</FormSelect>
{{! Single selection mode (default) }}
<Select
@label='Select Country'
@items={{this.countries}}
@selectedKey={{this.selectedCountryKey}}
@onSelectionChange={{this.setCountry}}
@isFilterable={{true}}
@errors={{this.validationErrors.country}}
@placeholder='Choose a country'
>
<:item as |item|>
<item.Item @key={{item.key}}>{{item.label}}</item.Item>
</:item>
</Select>
@options → @items@selected → @selectedKey (string | null for single selection)@onChange → @onSelectionChange (callback receives string | null for single selection)@searchEnabled → @isFilterable@searchField (filtering works on label automatically)<:item> slot instead of block param// Before: Object-based selection
@tracked selectedCountry = null;
@tracked countries = [
{ id: 1, name: 'United States', code: 'US' },
{ id: 2, name: 'Canada', code: 'CA' }
];
setCountry = (country) => {
this.selectedCountry = country;
};
// After: Key-based selection (single mode)
@tracked selectedCountryKey = null;
@tracked countries = [
{ key: 'us', label: 'United States', code: 'US' },
{ key: 'ca', label: 'Canada', code: 'CA' }
];
setCountry = (key) => {
this.selectedCountryKey = key;
};
{{! Multiple selection }}
<Select
@selectionMode='multiple'
@selectedKeys={{this.selectedCountryKeys}}
@onSelectionChange={{this.setCountries}}
@items={{this.countries}}
/>
// Multiple selection data handling
@tracked selectedCountryKeys = [];
setCountries = (keys) => {
this.selectedCountryKeys = keys; // receives array of strings
};
{{! Single selection with advanced features }}
<Select
@items={{this.countries}}
@selectedKey={{this.selectedKey}}
@onSelectionChange={{this.onChange}}
@isFilterable={{true}}
@isClearable={{true}}
@isLoading={{this.isLoading}}
@filter={{this.customFilter}}
@popoverSize='lg'
>
<:startContent>
<SearchIcon />
</:startContent>
<:item as |item|>
<item.Item @key={{item.key}}>
<div class='flex items-center gap-2'>
<img src={{item.flag}} alt='' class='w-5 h-5' />
{{item.label}}
</div>
</item.Item>
</:item>
<:emptyContent>
<div class='text-center p-4'>
No countries found matching your search.
</div>
</:emptyContent>
</Select>
// Handler for single selection
onChange = (key) => {
this.selectedKey = key; // receives string | null
};
The new Form component provides automatic form data extraction, validation, and handling. See the Recommended Approach section above for a complete example with validation.
A new toggle/switch component not available in forms-legacy.
{{! Controlled mode }}
<Switch
@label='Enable notifications'
@isSelected={{this.notificationsEnabled}}
@onChange={{this.setNotificationsEnabled}}
>
<:startContent>
<NotificationIcon />
</:startContent>
</Switch>
{{! Uncontrolled mode }}
<Switch
@label='Enable notifications'
@defaultSelected={{false}}
@onChange={{this.setNotificationsEnabled}}
>
<:startContent>
<NotificationIcon />
</:startContent>
</Switch>
For simple dropdown needs without the complexity of the full Select component.
<NativeSelect
@label='Priority'
@selectedKeys={{this.selectedPriority}}
@onSelectionChange={{this.setPriority}}
@items={{this.priorityOptions}}
>
<:item as |item|>
<item.Option @key={{item.key}}>{{item.label}}</item.Option>
</:item>
</NativeSelect>
{{! Before: Multiple error state props }}
<FormInput
@errors={{this.errors}}
@hasSubmitted={{this.hasSubmitted}}
@showError={{this.forceShowErrors}}
/>
{{! After: Simplified approach }}
<Input @errors={{this.errors}} @isInvalid={{this.hasErrors}} />
{{! Before: Individual class props }}
<FormInput
@containerClass='my-container'
@inputClass='my-input'
@labelClass='my-label'
/>
{{! After: Classes object }}
<Input
@classes={{hash base='my-container' input='my-input' label='my-label'}}
/>
For form validation patterns, see the Migrating Validation section above.
@frontile/forms-legacyfrontile and @frontile/theme@frontile/theme to compatible versionOption A: Full Migration to Form + Field (Recommended)
valibot or zod)<Form> component<form.Field> component@tracked properties)result.dataOption B: Component-Level Migration
Form* components (drop Form prefix)@hasSubmitted, @hasError)@hint to @description@classes object@checked to @checkedValue@isClearable to appropriate inputsSwitch component for togglesThis migration guide covers the essential changes needed to move from @frontile/forms-legacy to frontile. The new package provides improved developer experience with better accessibility, flexibility, and maintainability. For detailed Form component documentation, see frontile.dev/docs/forms/form.