Live Demo
Every form below is a real PHP form class from this page, serialized with json_encode() and rendered by the Vue <Form> component. Pick a form, change the accent color, fill it in, and press submit.
Form class
What happens on submit
The demo passes an onBeforeSubmit callback to <Form>. It checks required fields in the browser and returns false, so nothing is sent to a server. In your app, the same form posts to Laravel and the #[Validate] attribute validates it on the server.
The form classes
Fifteen form classes: one that shows every field type, one built from custom fields, three that show the newer features, and ten real-world forms with conditional fields.
- Onboarding wizard is a three-step wizard: each fieldset is a step with an icon, and Continue checks the step on the server. It also uses a Slug, an OTP Input and a Callout.
- Landing page combines a Slug, a structured Link, Blocks, a Repeater, display helpers, and two Submit buttons with different intents.
- Support chat is a single Composer with attachments and quick replies. It uses
resetOnSuccess, so the box clears after each send.
<?php
namespace App\Forms;
use Erag\InertiaForms\Fields\Callout;
use Erag\InertiaForms\Fields\Checkbox;
use Erag\InertiaForms\Fields\CheckboxGroup;
use Erag\InertiaForms\Fields\ColorPicker;
use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\DatePicker;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\FileUpload;
use Erag\InertiaForms\Fields\Hidden;
use Erag\InertiaForms\Fields\KeyValue;
use Erag\InertiaForms\Fields\Link;
use Erag\InertiaForms\Fields\OtpInput;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Repeater;
use Erag\InertiaForms\Fields\Slider;
use Erag\InertiaForms\Fields\Slug;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TagsInput;
use Erag\InertiaForms\Fields\Textarea;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Fields\TimePicker;
use Erag\InertiaForms\Fields\Toggle;
use Erag\InertiaForms\Form;
/**
* Every field type and its main options on one page.
*/
class AllFieldsForm extends Form
{
protected ?string $actionUrl = '/all-fields';
/**
* @return array<int, Field|Fieldset>
*/
public function fields(): array
{
return [
Fieldset::make('Text inputs')->description('TextInput types, prefix / suffix and clear button.')->columns(2)->fields([
TextInput::make('full_name')->required()->placeholder('Jane Doe')->clearable(),
TextInput::make('email')->email()->placeholder('jane@example.com'),
TextInput::make('password')->password()->minLength(8),
TextInput::make('phone')->tel()->placeholder('+91 98765 43210'),
TextInput::make('website')->url()->prefix('https://'),
TextInput::make('price')->number()->min(0)->step('0.01')->prefix('$')->suffix('USD'),
Textarea::make('about')->rows(3)->autoResize()->maxLength(200)->showCharacterCount()->columnSpan(2),
]),
Fieldset::make('Selects and lists')->description('Custom dropdowns with descriptions, search and chips, key-value rows and tags.')->columns(2)->fields([
Combobox::make('plan')->clearable()->options([
['value' => 'free', 'label' => 'Free', 'description' => 'For side projects'],
['value' => 'pro', 'label' => 'Pro', 'description' => 'For growing teams'],
['value' => 'enterprise', 'label' => 'Enterprise', 'description' => 'SSO, audit log and support'],
]),
Combobox::make('country')->searchable()->clearable()->options([
'IN' => 'India',
'US' => 'United States',
'GB' => 'United Kingdom',
'DE' => 'Germany',
'JP' => 'Japan',
]),
Combobox::make('frameworks')->multiple()->searchable()->clearable()
->options(['Laravel', 'Inertia', 'Vue', 'React', 'Svelte', 'Tailwind CSS'])->columnSpan(2),
KeyValue::make('headers')->label('Request headers')->keyLabel('Header')->keyPlaceholder('Accept')
->valuePlaceholder('application/json')->addActionLabel('Add header')->columnSpan(2),
TagsInput::make('keywords')->suggestions(['laravel', 'inertia', 'forms', 'tailwind'])
->maxTags(6)->reorderable()->columnSpan(2),
]),
Fieldset::make('Choices')->description('Radio cards, segmented buttons, checkbox pills and toggles.')->columns(2)->fields([
Radio::make('shipping')->options([
['value' => 'standard', 'label' => 'Standard', 'description' => '3 to 5 business days'],
['value' => 'express', 'label' => 'Express', 'description' => 'Next business day'],
])->default('standard')->columns(2)->columnSpan(2),
Radio::make('size')->options(['S', 'M', 'L', 'XL'])->buttons()->default('M'),
Radio::make('contact_by')->options(['email' => 'Email', 'phone' => 'Phone', 'none' => 'None'])->inline(),
CheckboxGroup::make('channels')->options(['email' => 'Email', 'sms' => 'SMS', 'push' => 'Push'])->buttons(),
CheckboxGroup::make('days')->options(['mon' => 'Mon', 'tue' => 'Tue', 'wed' => 'Wed', 'thu' => 'Thu', 'fri' => 'Fri'])->inline(),
Toggle::make('notifications')->onLabel('On')->offLabel('Off')->default(true),
Checkbox::make('newsletter')->label('Subscribe to the newsletter'),
]),
Fieldset::make('Dates and times')->description('Calendar, range, date with time, and time columns.')->columns(2)->fields([
DatePicker::make('start_date')->minDate(now()->toDateString())->clearable(),
DatePicker::make('meeting_at')->label('Meeting')->withTime()->clearable(),
DatePicker::make('stay')->range()->firstDayOfWeek(1)->columnSpan(2),
TimePicker::make('opens_at')->minuteStep(15)->clearable(),
TimePicker::make('duration')->withSeconds()->clearable(),
]),
Fieldset::make('Colors, sliders and files')->columns(2)->fields([
ColorPicker::make('brand_color')->swatches(['#4f46e5', '#0ea5e9', '#10b981', '#f59e0b', '#e11d48', '#9333ea'])
->default('#7c3aed')->clearable(),
Slider::make('volume')->min(0)->max(100)->step(5)->suffix('%')->default(40),
FileUpload::make('photo')->image()->maxSize(2048),
FileUpload::make('attachments')->multiple()->maxFiles(3)->accept(['pdf', 'docx'])->maxSize(4096),
]),
Fieldset::make('Links, slugs and display')->description('Slugs, codes, links and repeating rows.')->columns(2)->fields([
TextInput::make('page_title')->placeholder('Hello world'),
Slug::make('page_slug')->from('page_title')->prefix('example.test/'),
Link::make('homepage')->placeholder('example.test'),
OtpInput::make('pin')->label('PIN')->length(4)->password(),
Repeater::make('contacts')->itemLabel('Contact')->titleFrom('name')->columns(2)->columnSpan(2)->fields([
TextInput::make('name')->required(),
TextInput::make('email')->email(),
]),
Callout::make('Heads up', 'Display helpers like this one have no value and are not submitted.')->info(),
]),
Checkbox::make('terms')->label('I agree to the terms')->rule('accepted'),
Hidden::make('source')->default('all-fields-demo'),
Submit::make('Submit all fields')->processingLabel('Submitting…'),
];
}
}<?php
namespace App\Forms;
use App\Forms\Fields\CodeInput;
use App\Forms\Fields\QuantityStepper;
use App\Forms\Fields\Rating;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\Textarea;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Form;
/**
* Three custom fields (Rating, CodeInput, QuantityStepper) mixed with built-in ones.
*/
class CustomFieldsForm extends Form
{
protected ?string $actionUrl = '/custom-fields';
/**
* @return array<int, Field|Fieldset>
*/
public function fields(): array
{
return [
Fieldset::make('Order')->description('QuantityStepper is a custom field with min, max and a unit.')->columns(2)->fields([
Radio::make('ticket')->options([
['value' => 'standard', 'label' => 'Standard', 'description' => '$49 per ticket'],
['value' => 'vip', 'label' => 'VIP', 'description' => '$129, front rows and lounge'],
])->default('standard')->columns(2)->columnSpan(2),
QuantityStepper::make('tickets')->min(1)->max(10)->unit('tickets')->default(2)->required(),
QuantityStepper::make('parking')->label('Parking spots')->min(0)->max(4)->unit('spots'),
]),
Fieldset::make('Verify')->description('CodeInput is a custom field: one box per digit, with paste support.')->fields([
TextInput::make('email')->email()->required()->placeholder('jane@example.com'),
CodeInput::make('code')->label('Verification code')->length(6)->required()
->help('Use 123456 in this demo.'),
]),
Fieldset::make('Feedback')->description('Rating is a custom field with keyboard support.')->fields([
Rating::make('rating')->label('How was the booking experience?')->stars(5)->required(),
Textarea::make('comment')->rows(3)->maxLength(200)->showCharacterCount()
->visibleWhen('rating', '<=', 3)->help('Shown when the rating is 3 stars or less.'),
]),
Submit::make('Book tickets')->processingLabel('Booking…'),
];
}
}<?php
namespace App\Forms;
use Erag\InertiaForms\Fields\Callout;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\OtpInput;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Slug;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Form;
/**
* A three-step wizard: each fieldset is a step, checked on the server before moving on.
*/
class OnboardingWizardForm extends Form
{
protected ?string $actionUrl = '/onboarding-wizard';
protected bool $wizard = true;
/**
* @return array<int, Field|Fieldset>
*/
public function fields(): array
{
return [
Fieldset::make('Account')->description('Create your login')->icon('user')->fields([
TextInput::make('name')->required()->placeholder('Jane Example'),
TextInput::make('email')->email()->required()->placeholder('jane@example.test'),
TextInput::make('password')->password()->required()->minLength(8),
]),
Fieldset::make('Workspace')->description('Tell us about your team')->icon('briefcase')->columns(2)->fields([
TextInput::make('workspace')->label('Workspace name')->required()->placeholder('Acme Studio'),
Slug::make('workspace_url')->label('Workspace URL')->from('workspace')->prefix('app.example.test/')->required(),
Radio::make('team_size')->options(['1' => 'Just me', '2-10' => '2–10', '11-50' => '11–50', '50+' => '50+'])
->buttons()->default('2-10')->columnSpan(2),
]),
Fieldset::make('Verify')->description('Confirm your email')->icon('shield')->fields([
Callout::make('Check your inbox', 'We sent a 6-digit code to your email. Use 123456 in this demo.')->info(),
OtpInput::make('code')->label('Verification code')->length(6)->groupSize(3)->required()
->rule('in:123456'),
]),
Submit::make('Create account')->icon('check')->processingLabel('Creating…'),
];
}
public function messages(): array
{
return ['code.in' => 'That code is not right. Use 123456 in this demo.'];
}
}<?php
namespace App\Forms;
use Erag\InertiaForms\Fields\Block;
use Erag\InertiaForms\Fields\Blocks;
use Erag\InertiaForms\Fields\Callout;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\Heading;
use Erag\InertiaForms\Fields\Link;
use Erag\InertiaForms\Fields\Repeater;
use Erag\InertiaForms\Fields\Separator;
use Erag\InertiaForms\Fields\Slug;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\Text;
use Erag\InertiaForms\Fields\Textarea;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Form;
/**
* A landing page editor: blocks, a repeater, links, display helpers
* and two submit actions (save draft / publish).
*/
class LandingPageForm extends Form
{
protected ?string $actionUrl = '/landing-page';
/**
* @return array<int, Field|Fieldset>
*/
public function fields(): array
{
return [
Fieldset::make('Page')->columns(2)->fields([
Heading::make('Page details')->level(3),
Text::make('The URL follows the title until you change it.'),
TextInput::make('title')->required()->placeholder('Launch faster with Inertia Forms'),
Slug::make('slug')->from('title')->prefix('example.test/')->required(),
Link::make('cta')->label('Call to action')->withLabel(placeholder: 'Get started')->withTarget()
->requireScheme()->allowedSchemes('https')->columnSpan(2),
Textarea::make('intro')->rows(4)->autoResize()->maxLength(600)->showCharacterCount()->columnSpan(2),
]),
Fieldset::make('Content')->fields([
Blocks::make('sections')->label('Sections')->addActionLabel('Add section')->blocks([
Block::make('hero')->description('Big headline with a summary')->titleFrom('headline')->fields([
TextInput::make('headline')->required(),
Textarea::make('summary')->rows(3),
]),
Block::make('quote')->description('Customer quote')->icon('Q')->columns(2)->fields([
Textarea::make('quote')->required()->columnSpan(2),
TextInput::make('author')->required(),
TextInput::make('company'),
]),
])->default([['type' => 'hero', 'data' => ['headline' => 'Launch faster', 'summary' => 'Build forms in PHP and render them in your frontend.']]]),
Separator::make()->spacing('sm'),
Repeater::make('faq')->label('FAQ')->itemLabel('Question')->titleFrom('question')->maxItems(8)->fields([
TextInput::make('question')->required(),
Textarea::make('answer')->rows(2)->required(),
])->default([['question' => 'Do I need a frontend library?', 'answer' => 'No. Every field is built in with Tailwind CSS.']]),
Callout::make('Publishing is final', 'Published pages are visible to everyone right away.')->warning(),
]),
Submit::make('Save draft')->secondary()->intent('draft'),
Submit::make('Publish')->icon('send', 'right')->intent('publish')->processingLabel('Publishing…'),
];
}
}<?php
namespace App\Forms;
use Erag\InertiaForms\Fields\Composer;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\Text;
use Erag\InertiaForms\Form;
/**
* A chat-style reply box: Enter sends, Shift+Enter adds a line, files can be attached.
*/
class SupportChatForm extends Form
{
protected ?string $actionUrl = '/support-chat';
protected bool $resetOnSuccess = true;
/**
* @return array<int, Field|Fieldset>
*/
public function fields(): array
{
return [
Text::make('Reply to the customer. Attach screenshots or invoices if they help.'),
Composer::make('reply')->label('First reply')->required()->placeholder('Write a reply…')
->accept(['png', 'jpg', 'pdf'])->maxFiles(3)->maxSize(4096)->maxLength(2000)
->quickReplies(['Thanks for reaching out!', 'Could you share a screenshot?', 'This is fixed now.']),
];
}
}<?php
namespace App\Forms;
use Erag\InertiaForms\Fields\CheckboxGroup;
use Erag\InertiaForms\Fields\ColorPicker;
use Erag\InertiaForms\Fields\DatePicker;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\FileUpload;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\Textarea;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Fields\Toggle;
use Erag\InertiaForms\Form;
/**
* Launch a product: pricing tier, channels and a press kit when press is picked.
*/
class ProductLaunchForm extends Form
{
protected ?string $actionUrl = '/product-launch';
/**
* @return array<int, Field|Fieldset>
*/
public function fields(): array
{
return [
Fieldset::make('Product')->columns(2)->fields([
TextInput::make('product_name')->required()->placeholder('Aurora headphones')->clearable(),
TextInput::make('price')->number()->min(0)->step('0.01')->prefix('$')->required(),
Radio::make('tier')->options(['free' => 'Free', 'pro' => 'Pro', 'enterprise' => 'Enterprise'])->buttons()->default('pro'),
DatePicker::make('launch_date')->required()->minDate(now()->toDateString())->clearable(),
Textarea::make('tagline')->rows(2)->maxLength(120)->showCharacterCount()->columnSpan(2),
]),
Fieldset::make('Go to market')->columns(2)->fields([
CheckboxGroup::make('channels')->options([
'email' => 'Email',
'blog' => 'Blog',
'social' => 'Social',
'press' => 'Press',
])->buttons()->required()->columnSpan(2),
FileUpload::make('press_kit')->accept(['pdf', 'zip'])->maxSize(5120)
->visibleWhen('channels', 'contains', 'press')->columnSpan(2),
ColorPicker::make('brand_color')->swatches(['#4f46e5', '#0ea5e9', '#10b981', '#f59e0b', '#e11d48'])->default('#4f46e5'),
Toggle::make('announce')->label('Announce on launch day')->onLabel('Yes')->offLabel('No')->default(true),
]),
Submit::make('Schedule launch')->processingLabel('Scheduling…'),
];
}
}<?php
namespace App\Forms;
use Erag\InertiaForms\Fields\CheckboxGroup;
use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\DatePicker;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\FileUpload;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Form;
/**
* Kick off a client project: business details only show up for business clients.
*/
class ProjectKickoffForm extends Form
{
protected ?string $actionUrl = '/project-kickoff';
/**
* @return array<int, Field|Fieldset>
*/
public function fields(): array
{
return [
Fieldset::make('Project')->columns(2)->fields([
TextInput::make('project_name')->required()->maxLength(80)->placeholder('Acme redesign'),
TextInput::make('contact_email')->email()->required(),
Combobox::make('client_type')->options(['personal' => 'Personal', 'business' => 'Business'])->default('personal'),
TextInput::make('company')->required()->visibleWhen('client_type', 'business')->clearWhenHidden(),
TextInput::make('budget')->number()->min(100)->prefix('$'),
DatePicker::make('deadline')->minDate(now()->toDateString())->clearable(),
]),
Fieldset::make('Scope')->fields([
Radio::make('priority')->options([
['value' => 'low', 'label' => 'Low', 'description' => 'Whenever there is time.'],
['value' => 'high', 'label' => 'High', 'description' => 'Needs attention this week.'],
])->default('low')->columns(2),
Combobox::make('stack')->label('Tech stack')->multiple()->searchable()->clearable()
->options(['Laravel', 'Inertia', 'React', 'Vue', 'Svelte', 'Tailwind CSS']),
CheckboxGroup::make('services')->options(['design' => 'Design', 'build' => 'Build', 'hosting' => 'Hosting'])->buttons(),
FileUpload::make('brief')->accept(['pdf', 'docx'])->maxSize(2048)->help('Optional project brief.'),
]),
Submit::make('Start project')->processingLabel('Starting…'),
];
}
}<?php
namespace App\Forms;
use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\FileUpload;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Slider;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TagsInput;
use Erag\InertiaForms\Fields\Textarea;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Fields\Toggle;
use Erag\InertiaForms\Form;
/**
* Triage a support ticket: critical issues ask for an on-call phone number.
*/
class SupportTriageForm extends Form
{
protected ?string $actionUrl = '/support-triage';
/**
* @return array<int, Field|Fieldset>
*/
public function fields(): array
{
return [
Fieldset::make('Ticket')->columns(2)->fields([
TextInput::make('requester_email')->email()->required()->placeholder('customer@example.com'),
Combobox::make('area')->label('Product area')->required()->clearable()->options([
['value' => 'billing', 'label' => 'Billing', 'description' => 'Invoices, cards and refunds'],
['value' => 'auth', 'label' => 'Login & access', 'description' => 'Passwords, SSO and 2FA'],
['value' => 'api', 'label' => 'API', 'description' => 'Tokens, limits and webhooks'],
['value' => 'other', 'label' => 'Something else', 'description' => 'Anything not listed'],
]),
Radio::make('severity')->options(['low' => 'Low', 'medium' => 'Medium', 'high' => 'High', 'critical' => 'Critical'])
->buttons()->default('medium')->columnSpan(2),
TextInput::make('on_call_phone')->tel()->required()->placeholder('+91 98765 43210')
->visibleWhen('severity', 'critical')->clearWhenHidden()->columnSpan(2),
Slider::make('affected_users')->min(1)->max(500)->step(1)->default(10)->columnSpan(2),
]),
Fieldset::make('Details')->fields([
Textarea::make('steps')->label('Steps to reproduce')->required()->rows(4)->autoResize(),
TagsInput::make('labels')->suggestions(['bug', 'regression', 'billing', 'login', 'performance'])->maxTags(5),
FileUpload::make('screenshots')->image()->multiple()->maxFiles(3)->maxSize(2048),
Toggle::make('escalate')->label('Escalate to engineering')->onLabel('Yes')->offLabel('No'),
]),
Submit::make('Create ticket')->processingLabel('Creating…'),
];
}
}<?php
namespace App\Forms;
use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\DatePicker;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Slider;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TagsInput;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Fields\TimePicker;
use Erag\InertiaForms\Fields\Toggle;
use Erag\InertiaForms\Form;
/**
* Plan an event session: room and meeting link depend on the format.
*/
class EventSessionForm extends Form
{
protected ?string $actionUrl = '/event-session';
/**
* @return array<int, Field|Fieldset>
*/
public function fields(): array
{
return [
Fieldset::make('Session')->columns(2)->fields([
TextInput::make('title')->required()->placeholder('Building forms with Inertia')->columnSpan(2),
DatePicker::make('dates')->label('Session dates')->range()->required()->columnSpan(2),
TimePicker::make('starts_at')->label('Start time')->minuteStep(15)->required()->clearable(),
TimePicker::make('ends_at')->label('End time')->minuteStep(15)->clearable(),
]),
Fieldset::make('Format')->columns(2)->fields([
Radio::make('format')->options([
['value' => 'in_person', 'label' => 'In person', 'description' => 'At the venue'],
['value' => 'online', 'label' => 'Online', 'description' => 'Live stream only'],
['value' => 'hybrid', 'label' => 'Hybrid', 'description' => 'Venue and stream'],
])->default('in_person')->columns(3)->columnSpan(2),
Combobox::make('room')->options(['Main hall', 'Workshop A', 'Workshop B', 'Studio'])->required()
->visibleWhen('format', 'in', ['in_person', 'hybrid'])->clearWhenHidden(),
TextInput::make('meeting_link')->url()->required()->placeholder('https://meet.example.com/…')
->visibleWhen('format', 'in', ['online', 'hybrid'])->clearWhenHidden(),
Slider::make('capacity')->min(10)->max(300)->step(10)->default(50)->columnSpan(2),
TagsInput::make('speakers')->placeholder('Add a speaker and press Enter')->reorderable()->columnSpan(2),
Toggle::make('recorded')->label('Record this session')->onLabel('Yes')->offLabel('No'),
]),
Submit::make('Save session')->processingLabel('Saving…'),
];
}
}<?php
namespace App\Forms;
use Erag\InertiaForms\Fields\CheckboxGroup;
use Erag\InertiaForms\Fields\ColorPicker;
use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\DatePicker;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Slider;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TagsInput;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Form;
/**
* Plan a marketing campaign: audiences, flight dates, channels and UTM tags.
*/
class CampaignPlanForm extends Form
{
protected ?string $actionUrl = '/campaign-plan';
/**
* @return array<int, Field|Fieldset>
*/
public function fields(): array
{
return [
Fieldset::make('Campaign')->columns(2)->fields([
TextInput::make('campaign_name')->required()->placeholder('Autumn sale')->clearable(),
Radio::make('goal')->options(['awareness' => 'Awareness', 'leads' => 'Leads', 'sales' => 'Sales'])->buttons()->default('leads'),
Combobox::make('audiences')->multiple()->searchable()->clearable()->required()
->options(['New visitors', 'Returning customers', 'Newsletter subscribers', 'Trial users', 'Churned users'])->columnSpan(2),
DatePicker::make('flight')->label('Flight dates')->range()->required()->columnSpan(2),
]),
Fieldset::make('Budget')->columns(2)->fields([
TextInput::make('budget')->number()->min(0)->prefix('$')->suffix('USD')->required(),
Slider::make('daily_cap')->label('Daily cap')->min(0)->max(100)->step(5)->suffix('%')->default(25),
CheckboxGroup::make('channels')->options([
'search' => 'Search',
'social' => 'Social',
'display' => 'Display',
'email' => 'Email',
'video' => 'Video',
])->buttons()->columnSpan(2),
TagsInput::make('utm_tags')->label('UTM tags')->suggestions(['autumn', 'sale', 'retargeting', 'brand'])
->maxTags(6)->columnSpan(2),
ColorPicker::make('creative_color')->swatches(['#e11d48', '#f59e0b', '#10b981', '#0ea5e9', '#7c3aed'])->default('#e11d48'),
]),
Submit::make('Save plan')->processingLabel('Saving…'),
];
}
}<?php
namespace App\Forms;
use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\DatePicker;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TagsInput;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Form;
/**
* Open a role: office location only matters when the role is not remote.
*/
class HiringPipelineForm extends Form
{
protected ?string $actionUrl = '/hiring-pipeline';
/**
* @return array<int, Field|Fieldset>
*/
public function fields(): array
{
return [
Fieldset::make('Role')->columns(2)->fields([
TextInput::make('role_title')->required()->placeholder('Senior Laravel developer'),
Combobox::make('department')->required()->options(['Engineering', 'Design', 'Product', 'Marketing', 'Support']),
Radio::make('employment')->options(['full_time' => 'Full-time', 'part_time' => 'Part-time', 'contract' => 'Contract'])
->buttons()->default('full_time')->columnSpan(2),
TextInput::make('salary_min')->number()->min(0)->prefix('$')->suffix('/yr'),
TextInput::make('salary_max')->number()->min(0)->prefix('$')->suffix('/yr'),
]),
Fieldset::make('Location')->columns(2)->fields([
Radio::make('workplace')->options([
['value' => 'remote', 'label' => 'Remote', 'description' => 'Work from anywhere'],
['value' => 'hybrid', 'label' => 'Hybrid', 'description' => 'Two days in the office'],
['value' => 'onsite', 'label' => 'On-site', 'description' => 'Every day in the office'],
])->default('remote')->columns(3)->columnSpan(2),
Combobox::make('office')->required()->searchable()->options(['Bengaluru', 'Berlin', 'London', 'New York', 'Singapore'])
->visibleWhen('workplace', '!=', 'remote')->clearWhenHidden(),
DatePicker::make('start_date')->minDate(now()->toDateString())->clearable(),
]),
Fieldset::make('Pipeline')->fields([
Combobox::make('skills')->multiple()->searchable()->clearable()
->options(['PHP', 'Laravel', 'Inertia', 'React', 'Vue', 'SQL', 'Testing', 'AWS']),
TagsInput::make('stages')->reorderable()->default(['Screening', 'Technical interview', 'Team interview', 'Offer'])
->help('Drag the stages into order.'),
]),
Submit::make('Open role')->processingLabel('Opening…'),
];
}
}<?php
namespace App\Forms;
use Erag\InertiaForms\Fields\Checkbox;
use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\KeyValue;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Slider;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Fields\Toggle;
use Erag\InertiaForms\Form;
/**
* Choose a plan: EU countries ask for a VAT number.
*/
class SubscriptionBillingForm extends Form
{
protected ?string $actionUrl = '/subscription-billing';
/**
* @return array<int, Field|Fieldset>
*/
public function fields(): array
{
return [
Fieldset::make('Plan')->columns(2)->fields([
Radio::make('plan')->options([
['value' => 'starter', 'label' => 'Starter', 'description' => '$9 per seat, up to 5 seats'],
['value' => 'growth', 'label' => 'Growth', 'description' => '$19 per seat, priority support'],
['value' => 'scale', 'label' => 'Scale', 'description' => '$39 per seat, SSO and audit log'],
])->default('growth')->columns(3)->columnSpan(2),
Radio::make('cycle')->label('Billing cycle')->options(['monthly' => 'Monthly', 'yearly' => 'Yearly'])
->buttons()->default('yearly')->help('Yearly billing gets two months free.'),
Slider::make('seats')->min(1)->max(100)->default(5),
TextInput::make('coupon')->placeholder('LAUNCH20')->clearable(),
]),
Fieldset::make('Billing details')->columns(2)->fields([
TextInput::make('billing_email')->email()->required(),
Combobox::make('country')->searchable()->required()->options([
'IN' => 'India',
'US' => 'United States',
'GB' => 'United Kingdom',
'DE' => 'Germany',
'FR' => 'France',
'NL' => 'Netherlands',
]),
TextInput::make('vat_number')->label('VAT number')->required()->placeholder('DE123456789')
->visibleWhen('country', 'in', ['DE', 'FR', 'NL'])->clearWhenHidden()->columnSpan(2),
KeyValue::make('invoice_metadata')->keyPlaceholder('region')->valuePlaceholder('EMEA')
->addActionLabel('Add metadata')->maxItems(8)->default(['region' => 'EMEA', 'cost_center' => 'OPS-204'])
->help('Shown on every invoice.')->columnSpan(2),
Toggle::make('auto_renew')->onLabel('On')->offLabel('Off')->default(true),
Checkbox::make('terms')->label('I agree to the subscription terms')->rule('accepted'),
]),
Submit::make('Subscribe')->processingLabel('Subscribing…'),
];
}
}<?php
namespace App\Forms;
use Erag\InertiaForms\Fields\Checkbox;
use Erag\InertiaForms\Fields\CheckboxGroup;
use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\DatePicker;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\Slider;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TagsInput;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Fields\Toggle;
use Erag\InertiaForms\Form;
/**
* Patient intake: insurance details appear only when the patient is insured.
*/
class ClinicIntakeForm extends Form
{
protected ?string $actionUrl = '/clinic-intake';
/**
* @return array<int, Field|Fieldset>
*/
public function fields(): array
{
return [
Fieldset::make('Patient')->columns(2)->fields([
TextInput::make('patient_name')->required()->placeholder('Jane Doe'),
DatePicker::make('birthday')->label('Date of birth')->required()->maxDate(today()->toDateString()),
TextInput::make('phone')->tel()->required(),
Combobox::make('reason')->label('Reason for visit')->required()->options([
['value' => 'checkup', 'label' => 'General check-up', 'description' => 'Routine visit'],
['value' => 'follow_up', 'label' => 'Follow-up', 'description' => 'After a previous visit'],
['value' => 'new_issue', 'label' => 'New problem', 'description' => 'Something new to look at'],
]),
]),
Fieldset::make('Symptoms')->columns(2)->fields([
CheckboxGroup::make('symptoms')->options([
'fever' => 'Fever',
'cough' => 'Cough',
'headache' => 'Headache',
'fatigue' => 'Fatigue',
'nausea' => 'Nausea',
])->inline()->columnSpan(2),
Slider::make('pain_level')->min(0)->max(10)->default(0)->columnSpan(2),
TagsInput::make('allergies')->suggestions(['penicillin', 'peanuts', 'latex', 'pollen'])->columnSpan(2),
DatePicker::make('appointment_at')->label('Preferred appointment')->withTime()->minDate(now()->toDateString())->clearable(),
Toggle::make('insured')->label('I have insurance')->onLabel('Yes')->offLabel('No'),
TextInput::make('insurance_number')->required()->visibleWhen('insured', 'truthy')->clearWhenHidden()->columnSpan(2),
]),
Checkbox::make('consent')->label('I consent to the clinic storing this information')->rule('accepted'),
Submit::make('Send intake form')->processingLabel('Sending…'),
];
}
}<?php
namespace App\Forms;
use Erag\InertiaForms\Fields\CheckboxGroup;
use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\DatePicker;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Slider;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\Textarea;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Fields\TimePicker;
use Erag\InertiaForms\Fields\Toggle;
use Erag\InertiaForms\Form;
/**
* Book a stay: pet details show up when pets are coming along.
*/
class PropertyBookingForm extends Form
{
protected ?string $actionUrl = '/property-booking';
/**
* @return array<int, Field|Fieldset>
*/
public function fields(): array
{
return [
Fieldset::make('Stay')->columns(2)->fields([
Combobox::make('property')->required()->options([
['value' => 'lake_house', 'label' => 'Lake house', 'description' => '3 bedrooms, private dock'],
['value' => 'city_loft', 'label' => 'City loft', 'description' => '1 bedroom, walk to everything'],
['value' => 'forest_cabin', 'label' => 'Forest cabin', 'description' => '2 bedrooms, wood stove'],
])->columnSpan(2),
DatePicker::make('stay')->label('Check-in and check-out')->range()->required()->minDate(now()->toDateString())->columnSpan(2),
TimePicker::make('arrival')->label('Arrival time')->minuteStep(30)->minTime('14:00')->maxTime('22:00')->clearable(),
Slider::make('guests')->min(1)->max(8)->default(2),
]),
Fieldset::make('Extras')->columns(2)->fields([
Radio::make('bed_setup')->options(['double' => 'Double bed', 'twin' => 'Twin beds'])->buttons()->default('double'),
CheckboxGroup::make('extras')->options([
'breakfast' => 'Breakfast',
'pickup' => 'Airport pickup',
'late_checkout' => 'Late checkout',
])->buttons(),
Toggle::make('pets')->label('Travelling with pets')->onLabel('Yes')->offLabel('No'),
TextInput::make('pet_details')->placeholder('One small dog')->required()
->visibleWhen('pets', 'truthy')->clearWhenHidden(),
Textarea::make('requests')->label('Special requests')->rows(3)->maxLength(300)->showCharacterCount()->columnSpan(2),
]),
Submit::make('Request booking')->processingLabel('Requesting…'),
];
}
}<?php
namespace App\Forms;
use Erag\InertiaForms\Fields\Block;
use Erag\InertiaForms\Fields\Blocks;
use Erag\InertiaForms\Fields\ColorPicker;
use Erag\InertiaForms\Fields\Combobox;
use Erag\InertiaForms\Fields\DatePicker;
use Erag\InertiaForms\Fields\Field;
use Erag\InertiaForms\Fields\Fieldset;
use Erag\InertiaForms\Fields\FileUpload;
use Erag\InertiaForms\Fields\Radio;
use Erag\InertiaForms\Fields\Slider;
use Erag\InertiaForms\Fields\Submit;
use Erag\InertiaForms\Fields\TagsInput;
use Erag\InertiaForms\Fields\Textarea;
use Erag\InertiaForms\Fields\TextInput;
use Erag\InertiaForms\Fields\Toggle;
use Erag\InertiaForms\Form;
/**
* Schedule an article: the publish time is only needed once it is scheduled.
*/
class EditorialCalendarForm extends Form
{
protected ?string $actionUrl = '/editorial-calendar';
/**
* @return array<int, Field|Fieldset>
*/
public function fields(): array
{
return [
Fieldset::make('Article')->columns(2)->fields([
TextInput::make('headline')->required()->maxLength(90)->placeholder('Ten tips for faster forms')->columnSpan(2),
Combobox::make('author')->searchable()->required()->clearable()->placeholder('Search authors…')->options([
['value' => 1, 'label' => 'Avery Stone', 'description' => 'avery@example.test'],
['value' => 2, 'label' => 'Rowan Example', 'description' => 'rowan@example.test'],
['value' => 3, 'label' => 'Morgan Vale', 'description' => 'morgan@example.test'],
['value' => 4, 'label' => 'Casey Example', 'description' => 'casey@example.test'],
['value' => 5, 'label' => 'Skyler Example', 'description' => 'skyler@example.test'],
['value' => 6, 'label' => 'Aisha Khan', 'description' => 'aisha@example.test'],
['value' => 7, 'label' => 'Ben Carter', 'description' => 'ben@example.test'],
['value' => 8, 'label' => 'Chen Wei', 'description' => 'chen@example.test'],
['value' => 9, 'label' => 'Diego Ruiz', 'description' => 'diego@example.test'],
['value' => 10, 'label' => 'Emma Novak', 'description' => 'emma@example.test'],
]),
Radio::make('section')->options(['news' => 'News', 'guides' => 'Guides', 'opinion' => 'Opinion'])->buttons()->default('guides'),
Textarea::make('summary')->rows(3)->autoResize()->maxLength(200)->showCharacterCount()->columnSpan(2),
]),
Fieldset::make('Body')->fields([
Blocks::make('body')->label('Body outline')->addActionLabel('Add content block')->maxItems(12)
->blocks([
Block::make('section')->description('Heading with notes')->titleFrom('heading')->fields([
TextInput::make('heading')->required()->maxLength(90),
Textarea::make('summary')->rows(2)->autoResize(),
]),
Block::make('quote')->description('Pull quote')->icon('Q')->columns(2)->fields([
Textarea::make('text')->label('Quote')->required()->rows(2)->columnSpan(2),
TextInput::make('author')->required(),
TextInput::make('source')->placeholder('Book, talk or link'),
]),
Block::make('image')->description('Picture with a caption')->icon('I')->fields([
FileUpload::make('file')->image()->maxSize(3072),
TextInput::make('caption'),
]),
])
->default([
['type' => 'section', 'data' => ['heading' => 'Why forms belong in PHP']],
['type' => 'quote', 'data' => ['text' => 'One class drives the page and the validation.', 'author' => 'Aisha Khan']],
]),
]),
Fieldset::make('Publishing')->columns(2)->fields([
Combobox::make('status')->options([
['value' => 'draft', 'label' => 'Draft', 'description' => 'Still being written'],
['value' => 'review', 'label' => 'In review', 'description' => 'Waiting for an editor'],
['value' => 'scheduled', 'label' => 'Scheduled', 'description' => 'Goes live at a set time'],
])->default('draft'),
DatePicker::make('publish_at')->withTime()->required()->minDate(now()->toDateString())
->visibleWhen('status', 'scheduled')->clearWhenHidden(),
TagsInput::make('tags')->suggestions(['laravel', 'inertia', 'tutorial', 'release'])->reorderable()->maxTags(5)->columnSpan(2),
FileUpload::make('cover_image')->image()->maxSize(3072),
ColorPicker::make('accent')->label('Accent color')->swatches(['#4f46e5', '#0f766e', '#b45309', '#be123c'])->default('#0f766e'),
Slider::make('word_target')->label('Word count target')->min(300)->max(3000)->step(100)->default(1200)->columnSpan(2),
Toggle::make('featured')->label('Feature on the home page')->onLabel('Yes')->offLabel('No'),
]),
Submit::make('Save article')->processingLabel('Saving…'),
];
}
}Custom fields in this demo
The Custom fields form uses three field types that are not part of the package: Rating, CodeInput and QuantityStepper. Each one is a small PHP class plus a frontend component registered through the components prop. See Custom Fields for the full guide.
<?php
namespace App\Forms\Fields;
use Erag\InertiaForms\Fields\Field;
/**
* Star rating rendered by the `Rating` frontend component.
*/
class Rating extends Field
{
protected int $stars = 5;
public function component(): string
{
return 'Rating';
}
/**
* Number of stars to show.
*/
public function stars(int $stars): static
{
$this->stars = max(1, $stars);
return $this;
}
public function emptyValue(): mixed
{
return null;
}
/**
* @return array<int, string>
*/
protected function typeRules(): array
{
return ['integer', 'between:1,'.$this->stars];
}
/**
* @return array{stars: int}
*/
protected function props(): array
{
return ['stars' => $this->stars];
}
}<?php
namespace App\Forms\Fields;
use Erag\InertiaForms\Fields\Field;
/**
* One box per digit, like a verification code, rendered by the `CodeInput` component.
*/
class CodeInput extends Field
{
protected int $length = 6;
public function component(): string
{
return 'CodeInput';
}
/**
* Number of digits in the code.
*/
public function length(int $length): static
{
$this->length = min(max($length, 2), 10);
return $this;
}
/**
* @return array<int, string>
*/
protected function typeRules(): array
{
return ['digits:'.$this->length];
}
/**
* @return array{length: int}
*/
protected function props(): array
{
return ['length' => $this->length];
}
}<?php
namespace App\Forms\Fields;
use Erag\InertiaForms\Fields\Field;
/**
* A number with minus and plus buttons, rendered by the `QuantityStepper` component.
*/
class QuantityStepper extends Field
{
protected int $min = 0;
protected int $max = 99;
protected int $step = 1;
protected ?string $unit = null;
public function component(): string
{
return 'QuantityStepper';
}
public function min(int $min): static
{
$this->min = $min;
return $this;
}
public function max(int $max): static
{
$this->max = $max;
return $this;
}
public function step(int $step): static
{
$this->step = max(1, $step);
return $this;
}
/**
* Text shown after the number, like "tickets".
*/
public function unit(?string $unit): static
{
$this->unit = $unit;
return $this;
}
public function emptyValue(): mixed
{
return $this->min;
}
/**
* @return array<int, string>
*/
protected function typeRules(): array
{
return ['integer', 'between:'.$this->min.','.$this->max];
}
/**
* @return array{min: int, max: int, step: int, unit: string|null}
*/
protected function props(): array
{
return ['min' => $this->min, 'max' => $this->max, 'step' => $this->step, 'unit' => $this->unit];
}
}<script setup lang="ts">
import { computed, ref } from 'vue';
/**
* Star rating for the custom `Rating` field (demo/Fields/Rating.php).
*/
const props = defineProps<{
field: Record<string, any>;
id: string;
modelValue: unknown;
error?: string;
disabled: boolean;
describedBy?: string;
}>();
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>();
const starPath = 'M12 2.5l2.9 6.1 6.6.8-4.9 4.6 1.3 6.6L12 17.3l-5.9 3.3 1.3-6.6-4.9-4.6 6.6-.8L12 2.5z';
const stars = computed(() => Number(props.field.stars ?? 5));
const current = computed(() => Number(props.modelValue) || 0);
const hover = ref(0);
const buttons = ref<HTMLButtonElement[]>([]);
const locked = computed(() => props.disabled || Boolean(props.field.readonly));
const shown = computed(() => hover.value || current.value);
function pick(star: number): void {
emit('update:modelValue', star);
buttons.value[star - 1]?.focus();
}
function keydown(event: KeyboardEvent): void {
const moves: Record<string, number> = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 };
if (event.key in moves) {
event.preventDefault();
pick(Math.min(stars.value, Math.max(1, current.value + moves[event.key]!)));
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault();
pick(event.key === 'Home' ? 1 : stars.value);
}
}
</script>
<template>
<div class="flex items-center gap-3">
<div
:id="id"
role="radiogroup"
:aria-describedby="describedBy"
:aria-invalid="error ? true : undefined"
:aria-required="field.required || undefined"
class="flex gap-0.5 rounded-lg aria-invalid:ring-2 aria-invalid:ring-red-500/30"
@pointerleave="hover = 0"
>
<button
v-for="star in stars"
:key="star"
ref="buttons"
type="button"
role="radio"
:aria-checked="current === star"
:aria-label="`${star} of ${stars} stars`"
:tabindex="star === (current || 1) ? 0 : -1"
:disabled="locked"
class="rounded-md p-0.5 transition hover:scale-110 focus-visible:ring-2 focus-visible:ring-[var(--erag-form-accent,#4f46e5)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
@pointerenter="!locked && (hover = star)"
@click="pick(star)"
@keydown="keydown"
>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
stroke-width="1.5"
stroke-linejoin="round"
:class="
shown >= star
? 'size-7 fill-amber-400 stroke-amber-500'
: 'size-7 fill-transparent stroke-zinc-300 dark:stroke-zinc-600'
"
>
<path :d="starPath" />
</svg>
</button>
</div>
<span class="text-sm text-zinc-500 tabular-nums dark:text-zinc-400">
{{ shown ? `${shown} / ${stars}` : 'Not rated yet' }}
</span>
</div>
</template><script setup lang="ts">
import { computed, ref, watch } from 'vue';
/**
* One box per digit for the custom `CodeInput` field (demo/Fields/CodeInput.php).
*/
const props = defineProps<{
field: Record<string, any>;
id: string;
modelValue: unknown;
error?: string;
disabled: boolean;
describedBy?: string;
}>();
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>();
const length = computed(() => Number(props.field.length ?? 6));
const code = computed(() => (typeof props.modelValue === 'string' ? props.modelValue.replace(/\D/g, '') : ''));
const boxes = ref<HTMLInputElement[]>([]);
// The code just typed, before the new value comes back as a prop.
let latest = code.value;
watch(code, (next) => {
latest = next;
});
function emitCode(next: string): void {
latest = next;
emit('update:modelValue', next);
}
const locked = computed(() => props.disabled || Boolean(props.field.readonly));
function onFocus(index: number): void {
if (index > latest.length) focusBox(latest.length);
}
function focusBox(index: number): void {
const box = boxes.value[Math.min(Math.max(index, 0), length.value - 1)];
box?.focus();
box?.select();
}
function type(index: number, event: Event): void {
const input = event.target as HTMLInputElement;
const digit = input.value.replace(/\D/g, '').slice(-1);
input.value = code.value[index] ?? '';
if (!digit) {
return;
}
const position = Math.min(index, code.value.length);
emitCode((code.value.slice(0, position) + digit + code.value.slice(position + 1)).slice(0, length.value));
focusBox(position + 1);
}
function keydown(index: number, event: KeyboardEvent): void {
if (event.key === 'Backspace') {
event.preventDefault();
const position = code.value[index] ? index : index - 1;
if (position >= 0) {
emitCode(code.value.slice(0, position) + code.value.slice(position + 1));
focusBox(position);
}
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
focusBox(index - 1);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
focusBox(Math.min(index + 1, code.value.length));
}
}
function paste(event: ClipboardEvent): void {
event.preventDefault();
const pasted = (event.clipboardData?.getData('text') ?? '').replace(/\D/g, '').slice(0, length.value);
if (pasted) {
emitCode(pasted);
focusBox(pasted.length);
}
}
</script>
<template>
<div role="group" :aria-describedby="describedBy" class="flex flex-wrap gap-2">
<input
v-for="(_, index) in length"
:id="index === 0 ? id : `${id}-${index}`"
:key="index"
ref="boxes"
type="text"
inputmode="numeric"
:autocomplete="index === 0 ? 'one-time-code' : 'off'"
:aria-label="`Digit ${index + 1} of ${length}`"
:aria-invalid="error ? true : undefined"
:value="code[index] ?? ''"
:disabled="locked"
class="size-11 rounded-lg border border-zinc-300 bg-white text-center text-lg font-semibold text-zinc-900 shadow-xs tabular-nums transition focus:border-[var(--erag-form-accent,#4f46e5)] focus:ring-2 focus:ring-[color-mix(in_oklab,var(--erag-form-accent,#4f46e5)_22%,transparent)] focus:outline-none disabled:cursor-not-allowed disabled:bg-zinc-50 aria-invalid:border-red-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
@focus="onFocus(index)"
@input="type(index, $event)"
@keydown="keydown(index, $event)"
@paste="paste"
/>
</div>
</template><script setup lang="ts">
import { computed } from 'vue';
/**
* Minus / plus counter for the custom `QuantityStepper` field (demo/Fields/QuantityStepper.php).
*/
const props = defineProps<{
field: Record<string, any>;
id: string;
modelValue: unknown;
error?: string;
disabled: boolean;
describedBy?: string;
}>();
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>();
const min = computed(() => Number(props.field.min ?? 0));
const max = computed(() => Number(props.field.max ?? 99));
const step = computed(() => Number(props.field.step ?? 1));
const unit = computed(() => (typeof props.field.unit === 'string' ? props.field.unit : null));
const locked = computed(() => props.disabled || Boolean(props.field.readonly));
const number = computed(() =>
props.modelValue === '' || props.modelValue === null || props.modelValue === undefined ? null : Number(props.modelValue),
);
const buttonClass =
'flex w-10 items-center justify-center text-lg font-medium text-zinc-600 transition hover:bg-zinc-50 hover:text-zinc-900 focus-visible:bg-zinc-100 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40 dark:text-zinc-300 dark:hover:bg-zinc-800';
function clamp(next: number): number {
return Math.min(max.value, Math.max(min.value, next));
}
function input(event: Event): void {
const value = (event.target as HTMLInputElement).value;
emit('update:modelValue', value === '' ? null : Number(value));
}
</script>
<template>
<div class="flex items-center gap-3">
<div
:aria-invalid="error ? true : undefined"
class="inline-flex h-10 items-stretch overflow-hidden rounded-lg border border-zinc-300 bg-white shadow-xs focus-within:border-[var(--erag-form-accent,#4f46e5)] focus-within:ring-2 focus-within:ring-[color-mix(in_oklab,var(--erag-form-accent,#4f46e5)_22%,transparent)] aria-invalid:border-red-500 dark:border-zinc-700 dark:bg-zinc-900"
>
<button
type="button"
:aria-label="`Decrease ${field.label}`"
:disabled="locked || (number ?? min) <= min"
:class="buttonClass"
@click="emit('update:modelValue', clamp((number ?? min) - step))"
>
−
</button>
<input
:id="id"
type="number"
inputmode="numeric"
:min="min"
:max="max"
:step="step"
:value="number ?? ''"
:disabled="locked"
:aria-describedby="describedBy"
:aria-invalid="error ? true : undefined"
class="w-14 [appearance:textfield] border-x border-zinc-200 bg-transparent text-center text-sm font-semibold text-zinc-900 tabular-nums focus:outline-none dark:border-zinc-700 dark:text-zinc-100 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
@input="input"
@blur="number !== null && emit('update:modelValue', clamp(number))"
/>
<button
type="button"
:aria-label="`Increase ${field.label}`"
:disabled="locked || (number ?? min) >= max"
:class="buttonClass"
@click="emit('update:modelValue', clamp((number ?? min) + step))"
>
+
</button>
</div>
<span v-if="unit" class="text-sm text-zinc-500 dark:text-zinc-400">{{ unit }}</span>
</div>
</template><Form :form="form" :components="{ Rating, CodeInput, QuantityStepper }" />Render it in your app
Pass the form from a controller, then render it with one component. The accent prop sets the theme color, and you can also set it in PHP with ->accent('#059669').
use App\Forms\EventSessionForm;
use Inertia\Inertia;
public function create()
{
return Inertia::render('EventSession', [
'form' => EventSessionForm::make(),
]);
}<script setup lang="ts">
import { Form, type FormSchema } from '@erag/inertia-forms-vue';
defineProps<{ form: FormSchema }>();
</script>
<template>
<Form :form="form" accent="#059669" />
</template>import { Form, type FormSchema } from '@erag/inertia-forms-react';
export default function EventSession({ form }: { form: FormSchema }) {
return <Form form={form} accent="#059669" />;
}<script lang="ts">
import { Form, type FormSchema } from '@erag/inertia-forms-svelte';
let { form }: { form: FormSchema } = $props();
</script>
<Form {form} accent="#059669" />Next, follow the Quick Start to build your first form, or read about styling.