Skip to content

The Spec Paradigm

The Spec Paradigm is the foundational concept behind ion-components’ dynamic forms. A “spec” is a JSON schema that defines the structure, types, validation, and behavior of form fields.


A spec is a hierarchical structure that describes:

  • Field types (text, select, collection, array, etc.)
  • Validation rules (required, min/max, patterns)
  • Labels and placeholders
  • Default values
  • Nested structures (collections within collections)
  • Dynamic behavior (actions, conditional fields)

Every spec has a type property that determines its behavior:

// Root spec - always a collection
const spec = {
type: 'collection',
name: 'form',
spec: [
// Field definitions here
],
};

Simple text input field.

{
type: 'text',
name: 'firstName',
label: 'First Name',
placeholder: 'Enter your first name',
required: true,
}

Text field with email validation.

{
type: 'email',
name: 'email',
label: 'Email Address',
required: true,
}

Masked text input for passwords.

{
type: 'password',
name: 'password',
label: 'Password',
required: true,
}

Numeric input with optional min/max.

{
type: 'number',
name: 'quantity',
label: 'Quantity',
min: 1,
max: 100,
default: 1,
}

Toggle switch or checkbox.

{
type: 'boolean',
name: 'subscribe',
label: 'Subscribe to newsletter',
default: false,
}

Single selection from options.

{
type: 'select',
name: 'country',
label: 'Country',
options: [
{ label: 'United States', value: 'us' },
{ label: 'Canada', value: 'ca' },
{ label: 'Mexico', value: 'mx' },
],
required: true,
}

Multiple selection from options.

{
type: 'select',
name: 'skills',
label: 'Skills',
multiple: true,
options: [
{ label: 'JavaScript', value: 'js' },
{ label: 'Python', value: 'py' },
{ label: 'Go', value: 'go' },
{ label: 'Rust', value: 'rust' },
],
}

Multi-line text input.

{
type: 'text',
name: 'description',
label: 'Description',
multiline: true,
}

Text field with URL validation.

{
type: 'url',
name: 'website',
label: 'Website URL',
placeholder: 'https://example.com',
}

Date and datetime pickers.

{
type: 'date',
name: 'birthDate',
label: 'Birth Date',
}
{
type: 'datetime-local',
name: 'eventTime',
label: 'Event Date & Time',
}

Groups multiple fields together. The root spec is always a collection.

{
type: 'collection',
name: 'address',
label: 'Address',
spec: [
{ type: 'text', name: 'street', label: 'Street' },
{ type: 'text', name: 'city', label: 'City' },
{ type: 'text', name: 'state', label: 'State' },
{ type: 'text', name: 'zip', label: 'ZIP Code' },
],
}

Resulting data structure:

{
"address": {
"street": "123 Main St",
"city": "New York",
"state": "NY",
"zip": "10001"
}
}

Repeatable group of fields (list).

{
type: 'array',
name: 'contacts',
label: 'Emergency Contacts',
spec: {
type: 'collection',
spec: [
{ type: 'text', name: 'name', label: 'Name', required: true },
{ type: 'tel', name: 'phone', label: 'Phone', required: true },
{ type: 'text', name: 'relationship', label: 'Relationship' },
],
},
}

Resulting data structure:

{
"contacts": [
{ "name": "John Doe", "phone": "555-1234", "relationship": "Father" },
{ "name": "Jane Doe", "phone": "555-5678", "relationship": "Spouse" }
]
}

All field types inherit these base properties:

interface FieldBase {
id?: string; // Unique identifier
icon?: string; // Icon to display
name?: string | null; // Field name (key in data)
label?: string; // Display label
required?: boolean; // Whether field is required
action?: Item; // Action button configuration
template?: string; // Template for display
readonly?: boolean; // Read-only mode
hidden?: boolean; // Hide the field
advanced?: boolean; // Show only in "advanced" mode
}

type Field =
| FieldCollection
| FieldArray
| FieldInput
| FieldText
| FieldNumber
| FieldSelect;
// Collection of fields
type FieldCollection = FieldBase & {
type: 'collection';
spec: Array<Field>;
};
// Repeatable array
type FieldArray = FieldBase & {
type: 'array';
spec: Field;
};
// Text-based inputs
type FieldText = FieldBase & {
type:
| 'text'
| 'password'
| 'email'
| 'url'
| 'tel'
| 'binary'
| 'date'
| 'datetime-local';
multiline?: boolean;
placeholder?: string;
};
// Numeric input
type FieldNumber = FieldBase & {
type: 'number';
min?: number;
max?: number;
};
// Select dropdown
type FieldSelect = FieldBase & {
type: 'select';
options?: Item[] | SelectPlaceholderOptions | SelectNestedOptions;
multiple?: boolean;
searchable?: boolean;
placeholder?: string;
};

{
type: 'select',
name: 'status',
options: [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
{ label: 'Pending', value: 'pending' },
],
}
{
type: 'select',
name: 'plan',
options: [
{
label: 'Basic',
value: 'basic',
description: '$9/month - 1 user, 1GB storage',
},
{
label: 'Pro',
value: 'pro',
description: '$29/month - 5 users, 10GB storage',
},
{
label: 'Enterprise',
value: 'enterprise',
description: 'Custom pricing - unlimited',
},
],
}
{
type: 'select',
name: 'integration',
options: [
{ label: 'Shopify', value: 'shopify', icon: 'shopify' },
{ label: 'WooCommerce', value: 'woo', icon: 'woo' },
{ label: 'Magento', value: 'magento', icon: 'magento' },
],
}

Select options can define nested fields that appear when selected:

{
type: 'select',
name: 'paymentMethod',
label: 'Payment Method',
options: [
{
label: 'Credit Card',
value: 'credit_card',
nested: [
{ type: 'text', name: 'cardNumber', label: 'Card Number', required: true },
{ type: 'text', name: 'expiry', label: 'Expiry (MM/YY)', required: true },
{ type: 'text', name: 'cvv', label: 'CVV', required: true },
],
},
{
label: 'PayPal',
value: 'paypal',
nested: [
{ type: 'email', name: 'paypalEmail', label: 'PayPal Email', required: true },
],
},
{
label: 'Bank Transfer',
value: 'bank',
// No nested fields - nothing extra appears
},
],
}

Fields can have action buttons for triggering external behavior:

{
type: 'select',
name: 'connection',
label: 'API Connection',
options: [], // Will be populated dynamically
action: {
label: 'Add Connection',
value: 'add.connection',
icon: 'plus',
},
}

The action triggers an event that the parent can handle:

<form-component @action="handleAction" />
<script setup>
function handleAction(action, value) {
if (action.value === 'add.connection') {
openConnectionDialog();
}
}
</script>

Fields can be marked as “advanced” to hide them by default:

const spec = {
type: 'collection',
spec: [
{ type: 'text', name: 'name', label: 'Name', required: true },
{ type: 'url', name: 'url', label: 'URL', required: true },
// Advanced options - hidden by default
{ type: 'number', name: 'timeout', label: 'Timeout (ms)', advanced: true },
{ type: 'number', name: 'retries', label: 'Max Retries', advanced: true },
{ type: 'boolean', name: 'debug', label: 'Debug Mode', advanced: true },
],
};

The FormBuilder automatically shows a toggle to reveal advanced options when any field has advanced: true.


const userProfileSpec = {
type: 'collection',
name: 'profile',
spec: [
// Basic Info
{ type: 'text', name: 'firstName', label: 'First Name', required: true },
{ type: 'text', name: 'lastName', label: 'Last Name', required: true },
{ type: 'email', name: 'email', label: 'Email', required: true },
{ type: 'tel', name: 'phone', label: 'Phone' },
// Address (nested collection)
{
type: 'collection',
name: 'address',
label: 'Address',
spec: [
{ type: 'text', name: 'street', label: 'Street Address' },
{ type: 'text', name: 'city', label: 'City' },
{ type: 'text', name: 'state', label: 'State' },
{ type: 'text', name: 'zip', label: 'ZIP Code' },
{
type: 'select',
name: 'country',
label: 'Country',
options: [
{ label: 'United States', value: 'US' },
{ label: 'Canada', value: 'CA' },
],
},
],
},
// Preferences
{
type: 'select',
name: 'language',
label: 'Preferred Language',
options: [
{ label: 'English', value: 'en' },
{ label: 'Spanish', value: 'es' },
{ label: 'French', value: 'fr' },
],
},
{ type: 'boolean', name: 'newsletter', label: 'Subscribe to Newsletter' },
// Advanced
{ type: 'text', name: 'timezone', label: 'Timezone', advanced: true },
{ type: 'boolean', name: 'betaFeatures', label: 'Enable Beta Features', advanced: true },
],
};
// Resulting data structure after form submission:
const userData = {
firstName: 'John',
lastName: 'Doe',
phone: '555-1234',
address: {
street: '123 Main St',
city: 'New York',
state: 'NY',
zip: '10001',
country: 'US',
},
language: 'en',
newsletter: true,
timezone: 'America/New_York',
betaFeatures: false,
};