diff --git a/index.html b/index.html
new file mode 100644
index 0000000..0690f85
--- /dev/null
+++ b/index.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+ TRMNL YML Form Builder - Web Component Test
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/trmnl-form-builder.js b/trmnl-form-builder.js
new file mode 100644
index 0000000..86dcd89
--- /dev/null
+++ b/trmnl-form-builder.js
@@ -0,0 +1,2148 @@
+/**
+ * TRMNL YML Form Builder Web Component
+ *
+ * A standalone, dependency-free Web Component for building TRMNL custom plugin forms.
+ * Requires only Tailwind CSS for styling.
+ *
+ * Usage:
+ *
+ *
+ * Attributes:
+ * - accent-color: Hex color for accent elements (default: #F8654B)
+ * - theme: 'light' or 'dark' (default: 'light')
+ */
+
+class TRMLYamlForm extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: 'open' });
+
+ // State management
+ this.fields = [];
+ this.selectedFieldId = null;
+ this.draggedFieldId = null;
+ this.draggedFromIndex = null;
+ this.nextFieldId = 1;
+
+ // Configuration
+ this.accentColor = this.getAttribute('accent-color') || '#F8654B';
+ this.theme = this.getAttribute('theme') || 'light';
+
+ // Categories cache
+ this.categories = [];
+ this.loadCategories();
+
+ // supported locales (based on trmnl-i18n)
+ this.locales = [
+ { code: 'de', label: 'Deutsch' },
+ { code: 'es-ES', label: 'Español' },
+ { code: 'fr', label: 'Français' },
+ { code: 'it', label: 'Italiano' },
+ { code: 'nl', label: 'Nederlands' },
+ { code: 'pt-BR', label: 'Português' },
+ { code: 'uk', label: 'український' }
+ ];
+
+ // --- Property Definitions (How to render each setting) ---
+ this.propertyDefinitions = {
+ // Common
+ keyname: { label: 'Keyname', type: 'text', help: 'Lowercase alphanumeric + underscores only' },
+ name: { label: 'Name', type: 'text', placeholder: 'About This Plugin' },
+ description: { label: 'Description', type: 'textarea', placeholder: 'Main description (HTML-friendly)' },
+ help_text: { label: 'Help Text', type: 'textarea', placeholder: 'Sub-label / hint (HTML-friendly)' },
+ optional: { label: 'Optional field', type: 'checkbox' },
+
+ // Inputs
+ default: { label: 'Default Value', type: 'text', placeholder: 'Pre-selected value' },
+ placeholder: { label: 'Placeholder', type: 'text', placeholder: 'Example value' },
+ min: { label: 'Minimum Value', type: 'number', placeholder: '0' },
+ max: { label: 'Maximum Value', type: 'number', placeholder: '100' },
+ maxlength: { label: 'Maximum Length', type: 'number', placeholder: '12' },
+ rows: { label: 'Rows', type: 'number', placeholder: '10' },
+
+ // Selection
+ options: { label: 'Options (one per line)', type: 'textarea', placeholder: 'Option 1\nOption 2', help: 'Enter one option per line' },
+ multiple: { label: 'Allow multiple selection', type: 'checkbox' },
+ endpoint: { label: 'Endpoint *', type: 'text', placeholder: 'https://api.example.com/options' },
+ http_verb: {
+ label: 'HTTP Method',
+ type: 'select',
+ default: 'GET',
+ options: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT'],
+ placeholder: 'GET'
+ },
+
+ // Special
+ value: { label: 'Value', type: 'text', placeholder: 'sk_live_abc123xyz' },
+
+ // Author Bio Specifics
+ categories: { label: 'Categories (Max 2)', type: 'custom_categories' },
+ github_url: { label: 'GitHub URL', type: 'url', placeholder: 'https://github.com/username' },
+ email_address: { label: 'Email Address', type: 'text', placeholder: 'user@example.com' },
+ learn_more_url: { label: 'Learn More URL', type: 'url', placeholder: 'https://thesatanictemple.com/' },
+ youtube_url: { label: 'YouTube URL', type: 'url', placeholder: 'https://www.youtube.com/@useTRMNL' },
+
+ // The Translation Manager
+ i18n_descriptions: { label: 'Description Translations', type: 'i18n_map', help: 'Add translated descriptions for specific locales.' },
+
+ // Conditional Logic
+ conditional_validation: { label: 'Conditional Logic', type: 'conditional_builder', help: 'Hide other fields based on this field\'s value' }
+ };
+
+ // --- Field Types (Added 'properties' array to each) ---
+ this.fieldTypes = {
+ // Text Input
+ author_bio: {
+ category: 'TEXT INPUT', name: 'Author Bio', description: 'Plugin information',
+ properties: [{
+ key: 'name',
+ default: 'About This Plugin'
+ }, 'categories', 'github_url', 'email_address', 'learn_more_url', 'youtube_url'] // Note: Common props are handled automatically
+ },
+ url: {
+ category: 'TEXT INPUT', name: 'Url', description: 'URL input',
+ properties: ['default', 'placeholder', 'optional']
+ },
+ string: {
+ category: 'TEXT INPUT', name: 'String', description: 'Single-line text',
+ properties: ['default', 'placeholder', 'maxlength', 'optional', 'conditional_validation']
+ },
+ multi_string: {
+ category: 'TEXT INPUT', name: 'Multi String', description: 'Comma-separated',
+ properties: ['default', 'placeholder', 'optional']
+ },
+ text: {
+ category: 'TEXT INPUT', name: 'Text', description: 'Multi-line textarea',
+ properties: ['default', 'placeholder', 'rows', 'optional', 'conditional_validation']
+ },
+ code: {
+ category: 'TEXT INPUT', name: 'Code', description: 'Monospace code',
+ properties: ['default', 'placeholder', 'rows', 'optional']
+ },
+ password: {
+ category: 'TEXT INPUT', name: 'Password', description: 'Masked input',
+ properties: ['default', 'placeholder', 'optional']
+ },
+
+ // Numeric
+ number: {
+ category: 'NUMERIC', name: 'Number', description: 'Numeric input',
+ properties: ['default', 'placeholder', 'min', 'max', 'optional', 'conditional_validation']
+ },
+
+ // Date & Time
+ date: {
+ category: 'DATE & TIME', name: 'Date', description: 'Date picker',
+ // We use an object to override the generic 'default' definition
+ properties: [
+ {
+ key: 'default', // The property key we are targeting
+ placeholder: 'YYYY-MM-DD',
+ help: 'Can also be set to "today" to use the current date automatically.'
+ },
+ 'optional'
+ ]
+ },
+ time: { category: 'DATE & TIME', name: 'Time', description: 'Time picker', properties: ['default', 'optional'] },
+ timezone: { category: 'DATE & TIME', name: 'Time Zone', description: 'Timezone selector', properties: ['default', 'optional'] },
+
+ // Selection
+ select: {
+ category: 'SELECTION', name: 'Select', description: 'Dropdown',
+ properties: ['options', 'default', 'multiple', 'optional', 'conditional_validation']
+ },
+ xhrSelect: {
+ category: 'SELECTION', name: 'XhrSelect', description: 'Dynamic dropdown',
+ properties: ['endpoint', 'http_verb', 'multiple', 'optional']
+ },
+ xhrSelectSearch: {
+ category: 'SELECTION', name: 'XhrSelectSearch', description: 'Searchable dynamic',
+ properties: ['endpoint', 'http_verb', 'multiple', 'optional']
+ },
+
+ // Special
+ copyable: { category: 'SPECIAL', name: 'Copyable', description: 'Click-to-copy', properties: ['value'] },
+ copyable_webhook_url: { category: 'SPECIAL', name: 'Copyable Webhook', description: 'Click-to-copy webhook', properties: ['value'] },
+ };
+ }
+
+ connectedCallback() {
+ this.render();
+ this.setupEventListeners();
+ }
+
+ async loadCategories() {
+ try {
+ const response = await fetch('https://usetrmnl.com/api/categories');
+ if (response.ok) {
+ const data = await response.json();
+ // Handle both array and object with data property
+ this.categories = Array.isArray(data) ? data : (data.data || this.getFallbackCategories());
+ // Extract just the names if they're objects
+ if (this.categories.length > 0 && typeof this.categories[0] === 'object') {
+ this.categories = this.categories.map(cat => cat.name || cat.title || cat);
+ }
+ } else {
+ this.categories = this.getFallbackCategories();
+ }
+ } catch (error) {
+ console.warn('Failed to load categories from API, using fallback:', error);
+ this.categories = this.getFallbackCategories();
+ }
+ }
+
+ getFallbackCategories() {
+ return [
+ "analytics",
+ "art",
+ "calendar",
+ "comics",
+ "crm",
+ "custom",
+ "discovery",
+ "ecommerce",
+ "education",
+ "email",
+ "entertainment",
+ "environment",
+ "finance",
+ "games",
+ "humor",
+ "images",
+ "kpi",
+ "life",
+ "marketing",
+ "nature",
+ "news",
+ "personal",
+ "productivity",
+ "programming",
+ "sales",
+ "sports",
+ "travel"
+ ];
+ }
+
+ render() {
+ const html = `
+
+
+
+
+
+
+
+
+
+
+
+
No fields yet
+
Select a field type from the left panel to start building your form.
+
You can drag fields to reorder them, or click to edit properties.
+
+
+
+
+
+ `;
+
+ this.shadowRoot.innerHTML = html;
+ this.renderFieldTypes();
+ }
+
+ renderFieldTypes() {
+ const container = this.shadowRoot.getElementById('fieldTypesList');
+ const grouped = {};
+
+ // Group field types by category
+ Object.entries(this.fieldTypes).forEach(([key, config]) => {
+ if (!grouped[config.category]) {
+ grouped[config.category] = [];
+ }
+ grouped[config.category].push({ key, ...config });
+ });
+
+ let html = '';
+ Object.entries(grouped).forEach(([category, fields]) => {
+ html += `
+
${category}
`;
+
+ fields.forEach(field => {
+ html += `
+
+ `;
+ });
+
+ html += '
';
+ });
+
+ container.innerHTML = html;
+ this.updateAuthorBioState(); // Check immediately on render
+ }
+
+ renderCategoryPills(selectedCategories = []) {
+ if (!this.categories || this.categories.length === 0) {
+ return 'Loading categories...
';
+ }
+
+ let html = '';
+
+ this.categories.forEach(category => {
+ const isSelected = selectedCategories.includes(category);
+ html += `
+
+ `;
+ });
+
+ html += '
';
+ return html;
+ }
+
+ setupEventListeners() {
+ // Field type buttons
+ this.shadowRoot.querySelectorAll('.field-type-button').forEach(btn => {
+ btn.addEventListener('dragstart', (e) => this.handleFieldTypeDragStart(e));
+ btn.addEventListener('click', (e) => this.addField(e.currentTarget.dataset.fieldType));
+ });
+
+ // Canvas drop zone
+ const canvas = this.shadowRoot.getElementById('canvas');
+ canvas.addEventListener('dragover', (e) => this.handleCanvasDragOver(e));
+ canvas.addEventListener('drop', (e) => this.handleCanvasDrop(e));
+ canvas.addEventListener('dragleave', (e) => this.handleCanvasDragLeave(e));
+
+ // Tabs
+ this.shadowRoot.querySelectorAll('.tab').forEach(tab => {
+ tab.addEventListener('click', (e) => this.switchTab(e.currentTarget.dataset.tab));
+ });
+
+ // YAML buttons
+ this.shadowRoot.getElementById('copyBtn').addEventListener('click', () => this.copyYaml());
+ this.shadowRoot.getElementById('importBtn').addEventListener('click', () => this.importYaml());
+
+ // Reset button listener
+ const resetBtn = this.shadowRoot.getElementById('resetBtn');
+ if (resetBtn) {
+ resetBtn.addEventListener('click', () => {
+ if (confirm('Are you sure you want to reset the form? All fields will be lost.')) {
+ this.clear();
+ }
+ });
+ }
+ }
+
+ handleFieldTypeDragStart(e) {
+ e.dataTransfer.effectAllowed = 'copy';
+ e.dataTransfer.setData('fieldType', e.currentTarget.dataset.fieldType);
+ }
+
+ handleCanvasDragOver(e) {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'copy';
+ this.shadowRoot.getElementById('canvas').classList.add('drag-over');
+ }
+
+ handleCanvasDragLeave(e) {
+ if (e.target === this.shadowRoot.getElementById('canvas')) {
+ this.shadowRoot.getElementById('canvas').classList.remove('drag-over');
+ }
+ }
+
+ handleCanvasDrop(e) {
+ e.preventDefault();
+ this.shadowRoot.getElementById('canvas').classList.remove('drag-over');
+ const fieldType = e.dataTransfer.getData('fieldType');
+ if (fieldType) {
+ this.addField(fieldType);
+ }
+ }
+
+ addField(fieldType) {
+ if (fieldType === 'author_bio' && this.fields.some(f => f.field_type === 'author_bio')) {
+ this.showToast('Only one Author Bio field is allowed');
+ return;
+ }
+
+ const fieldKeyname = fieldType === 'author_bio'
+ ? fieldType
+ : `${fieldType}_${Date.now().toString().slice(-4)}`;
+
+ const typeConfig = this.fieldTypes[fieldType];
+
+ // Create new field object with minimal defaults
+ const field = {
+ id: `field_${this.nextFieldId++}`,
+ keyname: fieldKeyname,
+ field_type: fieldType,
+ };
+
+ // Only set 'name' if there's a default specified in the type config
+ let hasNameDefault = false;
+ if (typeConfig && Array.isArray(typeConfig.properties)) {
+ typeConfig.properties.forEach(prop => {
+ if (typeof prop === 'object' && prop.key === 'name' && prop.default) {
+ field.name = prop.default;
+ hasNameDefault = true;
+ }
+ });
+ }
+
+ // Fallback to type name if no custom default was set
+ if (!hasNameDefault) {
+ field.name = this.fieldTypes[fieldType]?.name || fieldType;
+ }
+
+ // Update state
+ this.fields.push(field);
+ this.selectedFieldId = field.id;
+
+ this.switchTab('config');
+ this.updateConfigPanel();
+ this.updateCanvasView();
+ this.updateYamlOutput();
+ this.updateAuthorBioState();
+ }
+
+ updateCanvasView() {
+ const canvas = this.shadowRoot.getElementById('canvas');
+
+ if (this.fields.length === 0) {
+ canvas.innerHTML = `
+
+
+
+
No fields yet
+
Select a field type from the left panel to start building your form.
+
You can drag fields to reorder them, or click to edit properties.
+
+ `;
+ canvas.classList.remove('has-fields');
+ return;
+ }
+
+ canvas.classList.add('has-fields');
+ let html = ``;
+
+ this.fields.forEach((field, index) => {
+ const isSelected = field.id === this.selectedFieldId;
+ html += `
+
+
+
${field.keyname}${field.field_type}
+
${field.name}
+
+
+ `;
+ if (field.field_type != 'author_bio'){
+ html += ``;
+ }
+ html += `
+
+
+
+ `;
+ });
+
+ html += '
';
+ canvas.innerHTML = html;
+
+ // Setup field card event listeners
+ this.shadowRoot.querySelectorAll('.field-card').forEach(card => {
+ card.addEventListener('click', (e) => {
+ if (!e.target.closest('.icon-button')) {
+ this.selectedFieldId = card.dataset.fieldId;
+ this.switchTab('config');
+ this.updateConfigPanel();
+ this.updateCanvasView();
+ }
+ });
+
+ card.addEventListener('dragstart', (e) => {
+ // Explicitly set data to satisfy browser validation
+ e.dataTransfer.setData('text/plain', card.dataset.fieldId);
+ e.dataTransfer.effectAllowed = 'move';
+
+ this.draggedFieldId = card.dataset.fieldId;
+ this.draggedFromIndex = this.fields.findIndex(f => f.id === this.draggedFieldId);
+ card.style.opacity = '0.5';
+ });
+
+ card.addEventListener('dragend', () => {
+ card.style.opacity = '1';
+ this.draggedFieldId = null;
+ this.draggedFromIndex = null;
+ });
+
+ card.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ // Stop propagation to prevent Canvas "copy" effect from overriding "move"
+ e.stopPropagation();
+ e.dataTransfer.dropEffect = 'move';
+
+ // Add visual cue
+ card.style.borderTop = '2px solid var(--accent-color)';
+ });
+
+ card.addEventListener('dragleave', (e) => {
+ // Ensure we don't remove style when entering child elements
+ // Only remove if we are genuinely leaving the card rect
+ const rect = card.getBoundingClientRect();
+ const x = e.clientX;
+ const y = e.clientY;
+
+ // Check if cursor is actually outside the element bounds
+ if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
+ card.style.borderTop = '';
+ }
+ });
+
+ card.addEventListener('drop', (e) => {
+ e.preventDefault();
+ // Stop propagation so the canvas handler doesn't trigger
+ e.stopPropagation();
+
+ card.style.borderTop = '';
+
+ if (this.draggedFieldId && this.draggedFieldId !== card.dataset.fieldId) {
+ const toIndex = this.fields.findIndex(f => f.id === card.dataset.fieldId);
+
+ if (this.draggedFromIndex !== -1 && toIndex !== -1) {
+ // Perform the move
+ const [draggedField] = this.fields.splice(this.draggedFromIndex, 1);
+ this.fields.splice(toIndex, 0, draggedField);
+
+ // Re-render
+ this.updateCanvasView();
+ this.updateYamlOutput();
+ }
+ }
+ });
+ });
+
+ // Setup action buttons
+ this.shadowRoot.querySelectorAll('.icon-button.duplicate').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.duplicateField(btn.dataset.fieldId);
+ });
+ });
+
+ this.shadowRoot.querySelectorAll('.icon-button.delete').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.deleteField(btn.dataset.fieldId);
+ });
+ });
+
+ this.updateFieldCount();
+ }
+
+ updateAuthorBioState() {
+ const hasAuthorBio = this.fields.some(f => f.field_type === 'author_bio');
+ const btn = this.shadowRoot.querySelector('button[data-field-type="author_bio"]');
+
+ if (btn) {
+ if (hasAuthorBio) {
+ btn.setAttribute('disabled', 'true');
+ btn.removeAttribute('draggable');
+ btn.title = "Only one Author Bio allowed";
+ } else {
+ btn.removeAttribute('disabled');
+ btn.setAttribute('draggable', 'true');
+ btn.removeAttribute('title');
+ }
+ }
+ }
+
+ updateConfigPanel() {
+ const field = this.fields.find(f => f.id === this.selectedFieldId);
+ if (!field) {
+ this.shadowRoot.getElementById('configContent').innerHTML = 'Select a field to edit its properties
';
+ return;
+ }
+
+ const typeConfig = this.fieldTypes[field.field_type];
+
+ // 1. Define Standard Properties
+ let visibleProps = ['keyname', 'field_type_display', 'name', 'description', 'i18n_descriptions', 'help_text'];
+
+ // 2. Add Type-Specific Properties (These can now be strings OR objects)
+ if (typeConfig && Array.isArray(typeConfig.properties)) {
+ visibleProps = [...visibleProps, ...typeConfig.properties];
+ }
+
+ // 3. Generate HTML
+ let html = '';
+ visibleProps.forEach(propEntry => { // propEntry is now string or object
+
+ // Determine the base key (string or object.key)
+ const propKey = typeof propEntry === 'string' ? propEntry : propEntry.key;
+
+ // Special case for static display of field type
+ if (propKey === 'field_type_display') {
+ html += `
+ `;
+ return;
+ }
+
+ let propDef = this.propertyDefinitions[propKey];
+
+ // --- NEW LOGIC: Merge Overrides ---
+ if (typeof propEntry === 'object' && propDef) {
+ // Create a new definition by merging the base definition with the specific overrides
+ propDef = {...propDef, ...propEntry};
+ }
+
+ if (propDef) {
+ // Pass the key and the potentially merged definition to the renderer
+ html += this.renderPropertyInput(propKey, propDef, field);
+ }
+ });
+
+ this.shadowRoot.getElementById('configContent').innerHTML = html;
+
+ // 4. Re-attach Event Listeners
+ this.attachConfigListeners(field);
+ }
+
+ renderPropertyInput(key, def, field) {
+ const value = field[key] !== undefined ? field[key] : (def.default || '');
+
+ if (def.type === 'custom_categories') {
+ const selectedCategories = field.category ? field.category.split(',').map(c => c.trim()).filter(c => c) : [];
+ return `
+
+ `;
+ }
+
+ if (def.type === 'checkbox') {
+ return `
+
+
+
`;
+ }
+
+ if (def.type === 'textarea') {
+ // Convert array options to string for display
+ let displayValue = value;
+ let useLabelValue = false;
+
+ if (key === 'options' && Array.isArray(value)) {
+ // Check if any option is an object (label:value pair)
+ useLabelValue = value.some(opt => typeof opt === 'object');
+
+ if (useLabelValue) {
+ // Convert objects to "Label: value" format
+ displayValue = value.map(opt => {
+ if (typeof opt === 'object') {
+ const [label, val] = Object.entries(opt)[0];
+ return `${label}: ${val}`;
+ }
+ return opt;
+ }).join('\n');
+ } else {
+ // Simple string array
+ displayValue = value.join('\n');
+ }
+ }
+
+ // Add toggle for options field
+ const optionsToggle = key === 'options' ? `
+
+
+ ${useLabelValue ? 'Enter one per line in format "Label: value"' : 'Enter one option per line'}
+
+ ` : '';
+
+ return `
+ `;
+ }
+
+ // --- i18n_map Renderer ---
+ if (def.type === 'i18n_map') {
+ // 1. Find existing translations in the field object
+ const existingTranslations = Object.keys(field)
+ .filter(k => k.startsWith('description-'))
+ .map(k => {
+ const code = k.replace('description-', '');
+ return { code, value: field[k] };
+ });
+
+ // 2. Build the list of existing translation inputs
+ let rowsHtml = existingTranslations.map(item => `
+
+
+ ${item.code}
+
+
+
+
+ `).join('');
+
+ // 3. Build the "Add New" dropdown
+ // Filter out locales that are already added
+ const availableLocales = this.locales.filter(l => !field[`description-${l.code}`]);
+
+ let addHtml = '';
+ if (availableLocales.length > 0) {
+ addHtml = `
+
+
+
+
+ `;
+ }
+
+ return `
+ `;
+ }
+
+ // --- Handle generic Select inputs (including http_verb) ---
+ if (def.options && Array.isArray(def.options)) {
+ // Determine the current value, falling back to default if not set
+ const currentValue = field[key] !== undefined ? field[key] : (def.default || '');
+
+ const optionsHtml = def.options.map(option => {
+ // The option value is the method name itself (GET, POST, etc.)
+ const selected = currentValue === option ? 'selected' : '';
+ return ``;
+ }).join('');
+
+ return `
+ `;
+ }
+
+ // --- Conditional Builder Renderer ---
+ if (def.type === 'conditional_builder') {
+ const conditions = field.conditional_validation || [];
+
+ // Get list of other fields for checkboxes
+ const otherFields = this.fields.filter(f => f.id !== field.id);
+
+ let conditionsHtml = conditions.map((condition, idx) => {
+ const hiddenSet = new Set(condition.hidden || []);
+
+ const checkboxesHtml = otherFields.map(f => {
+ const checked = hiddenSet.has(f.keyname) ? 'checked' : '';
+ return `
+
+ `;
+ }).join('');
+
+ return `
+
+
+
+ When equals:
+
+
+
+
+
Hide these fields:
+
+ ${checkboxesHtml || '
No other fields available
'}
+
+
+ `;
+ }).join('');
+
+ return `
+
+ `;
+ }
+
+ // --- Special handling for 'default' field ---
+ if (key === 'default') {
+ const hasDefault = field.hasOwnProperty('default');
+ const defaultValue = hasDefault ? (field.default || '') : '';
+
+ return `
+ `;
+ }
+
+ // Default: Text, Number, URL inputs
+ return `
+ `;
+ }
+
+
+ attachConfigListeners(field) {
+ const container = this.shadowRoot.getElementById('configContent');
+
+ // Generic Inputs
+ container.querySelectorAll('[data-prop]').forEach(input => {
+ input.addEventListener('input', (e) => this.handleConfigInput(e, field));
+ input.addEventListener('change', (e) => this.handleConfigInput(e, field));
+ });
+
+ // Handle Label:Value toggle for options
+ const labelValueToggle = container.querySelector('#use-label-value');
+ if (labelValueToggle) {
+ labelValueToggle.addEventListener('change', (e) => {
+ const helpDiv = container.querySelector('#options-help');
+ if (helpDiv) {
+ helpDiv.textContent = e.target.checked
+ ? 'Enter one per line in format "Label: value"'
+ : 'Enter one option per line';
+ }
+ });
+ }
+
+ // Handle "Set default value" checkbox
+ const enableDefaultCheckbox = container.querySelector('#enable-default');
+ const defaultInput = container.querySelector('#default-input');
+ if (enableDefaultCheckbox && defaultInput) {
+ enableDefaultCheckbox.addEventListener('change', (e) => {
+ if (e.target.checked) {
+ // Enable the input and set default to empty string if not already set
+ defaultInput.disabled = false;
+ defaultInput.style.opacity = '1';
+ if (!field.hasOwnProperty('default')) {
+ field.default = '';
+ }
+ } else {
+ // Disable the input and remove the default property
+ defaultInput.disabled = true;
+ defaultInput.style.opacity = '0.5';
+ delete field.default;
+ }
+ this.updateYamlOutput();
+ });
+ }
+
+ // Special Case: Categories (Pills)
+ const pills = container.querySelectorAll('.category-pill');
+ if (pills.length > 0) {
+ pills.forEach(pill => {
+ pill.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const category = pill.dataset.category;
+ // Parse current selection
+ let selected = field.category ? field.category.split(',').map(c => c.trim()).filter(c => c) : [];
+
+ const index = selected.indexOf(category);
+
+ if (index > -1) {
+ // Deselect
+ selected.splice(index, 1);
+ } else if (selected.length < 2) {
+ // Select (if under limit)
+ selected.push(category);
+ } else {
+ // Limit Reached Feedback
+ this.showToast('You can only select up to 2 categories');
+ return; // Stop here so we don't re-render unnecessarily
+ }
+
+ field.category = selected.join(',');
+ this.updateConfigPanel(); // Re-render to update pill styling
+ this.updateYamlOutput();
+ });
+ });
+ }
+
+ // --- i18n Listeners ---
+ // Handle Input Changes (typing in the translation box)
+ container.querySelectorAll('.i18n-input').forEach(input => {
+ input.addEventListener('input', (e) => {
+ const code = e.target.dataset.code;
+ field[`description-${code}`] = e.target.value;
+ this.updateYamlOutput();
+ });
+ });
+
+ // Handle "Add" Button
+ const addBtn = container.querySelector('#add-locale-btn');
+ if (addBtn) {
+ addBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ const select = container.querySelector('#new-locale-select');
+ const code = select.value;
+ if (code) {
+ // Initialize the new key
+ field[`description-${code}`] = '';
+ this.updateConfigPanel(); // Re-render to show the new input
+ this.updateYamlOutput();
+ }
+ });
+ }
+
+ // Handle "Remove" Buttons
+ container.querySelectorAll('.remove-i18n-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.preventDefault();
+ const code = e.target.dataset.code;
+ delete field[`description-${code}`]; // Remove the key entirely
+ this.updateConfigPanel(); // Re-render to remove the input
+ this.updateYamlOutput();
+ });
+ });
+
+ // --- Conditional Logic Listeners ---
+ // Add Condition Button
+ const addConditionBtn = container.querySelector('#add-condition-btn');
+ if (addConditionBtn) {
+ addConditionBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ if (!field.conditional_validation) {
+ field.conditional_validation = [];
+ }
+ field.conditional_validation.push({
+ when: '',
+ hidden: []
+ });
+ this.updateConfigPanel();
+ this.updateYamlOutput();
+ });
+ }
+
+ // Remove Condition Buttons
+ container.querySelectorAll('.remove-condition-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.preventDefault();
+ const idx = parseInt(e.target.dataset.conditionIdx);
+ field.conditional_validation.splice(idx, 1);
+ if (field.conditional_validation.length === 0) {
+ delete field.conditional_validation;
+ }
+ this.updateConfigPanel();
+ this.updateYamlOutput();
+ });
+ });
+
+ // When Value Inputs
+ container.querySelectorAll('.condition-when-input').forEach(input => {
+ input.addEventListener('input', (e) => {
+ const idx = parseInt(e.target.dataset.conditionIdx);
+ field.conditional_validation[idx].when = e.target.value;
+ this.updateYamlOutput();
+ });
+ });
+
+ // Hidden Field Checkboxes
+ container.querySelectorAll('.condition-hidden-cb').forEach(cb => {
+ cb.addEventListener('change', (e) => {
+ const idx = parseInt(e.target.dataset.conditionIdx);
+ const fieldKeyname = e.target.dataset.fieldKeyname;
+
+ if (!field.conditional_validation[idx].hidden) {
+ field.conditional_validation[idx].hidden = [];
+ }
+
+ if (e.target.checked) {
+ if (!field.conditional_validation[idx].hidden.includes(fieldKeyname)) {
+ field.conditional_validation[idx].hidden.push(fieldKeyname);
+ }
+ } else {
+ field.conditional_validation[idx].hidden = field.conditional_validation[idx].hidden.filter(k => k !== fieldKeyname);
+ }
+
+ this.updateYamlOutput();
+ });
+ });
+ }
+
+ handleConfigInput(e, field) {
+ const input = e.target;
+ const prop = input.dataset.prop;
+ let value;
+ const container = this.shadowRoot.getElementById('configContent');
+
+ // 1. Determine Value
+ if (input.type === 'checkbox') {
+ value = input.checked;
+ } else if (input.type === 'number') {
+ value = input.value === '' ? undefined : Number(input.value);
+ } else if (prop === 'options') {
+ const lines = input.value.split('\n').filter(o => o.trim());
+
+ // Check if we're in label:value mode
+ const useLabelValue = container.querySelector('#use-label-value')?.checked;
+
+ if (useLabelValue) {
+ // Parse as label:value pairs
+ value = lines.map(line => {
+ const colonIndex = line.indexOf(':');
+ if (colonIndex > -1) {
+ const label = line.substring(0, colonIndex).trim();
+ const val = line.substring(colonIndex + 1).trim();
+ return { [label]: val };
+ }
+ // Fallback to simple string if no colon found
+ return line.trim();
+ });
+ } else {
+ // Simple string array
+ value = lines;
+ }
+ } else {
+ value = input.value;
+ // Special handling for 'default' - allow empty string but only if checkbox is checked
+ if (prop === 'default' && value === '') {
+ // If checkbox is checked, explicitly set to empty string
+ // If unchecked, this won't be called because input is disabled
+ field[prop] = '';
+ this.updateYamlOutput();
+ return;
+ }
+ }
+
+ // 2. Save Value
+ field[prop] = value;
+
+ // 3. --- NEW: Auto-populate help text for multiple select ---
+ if (prop === 'multiple' && value === true && field.field_type === 'select') {
+ // Only populate if currently empty to avoid overwriting user content
+ if (!field.help_text || !field.help_text.trim()) {
+ field.help_text = 'Use ⌘+click or ctrl+click to select and deselect multiple options.';
+
+ // Force a re-render so the user sees the text appear immediately in the specific field
+ this.updateConfigPanel();
+ }
+ }
+
+ // 4. Specific UI updates
+ if (prop === 'keyname' || prop === 'name') {
+ this.updateCanvasView(); // Refresh the sidebar list items
+ }
+
+ this.updateYamlOutput();
+ }
+
+
+ updateYamlOutput() {
+ const yaml = this.generateYaml();
+ this.shadowRoot.getElementById('yamlOutput').textContent = yaml;
+ }
+
+ generateYaml() {
+ if (this.fields.length === 0) {
+ return '# No fields defined yet';
+ }
+
+ const lines = [];
+
+ this.fields.forEach(field => {
+ lines.push(`- keyname: ${field.keyname}`);
+ lines.push(` field_type: ${field.field_type}`);
+ lines.push(` name: ${this.escapeYaml(field.name)}`);
+
+ if (field.description) {
+ lines.push(` description: ${this.escapeYaml(field.description)}`);
+ }
+
+ if (field.help_text) {
+ lines.push(` help_text: ${this.escapeYaml(field.help_text)}`);
+ }
+
+ if (field.placeholder) {
+ lines.push(` placeholder: ${this.escapeYaml(field.placeholder)}`);
+ }
+
+ if (field.hasOwnProperty('default')) {
+ lines.push(` default: ${this.escapeYaml(field.default)}`);
+ }
+
+ if (field.optional) {
+ lines.push(` optional: true`);
+ }
+
+ if (field.min !== undefined) {
+ lines.push(` min: ${field.min}`);
+ }
+
+ if (field.max !== undefined) {
+ lines.push(` max: ${field.max}`);
+ }
+
+ if (field.maxlength !== undefined) {
+ lines.push(` maxlength: ${field.maxlength}`);
+ }
+
+ if (field.rows !== undefined) {
+ lines.push(` rows: ${field.rows}`);
+ }
+
+ if (field.options && field.options.length > 0) {
+ // console.log('Field options:', field.keyname, field.options);
+ lines.push(` options:`);
+ field.options.forEach(opt => {
+ if (typeof opt === 'object') {
+ // Label:Value pair - format as YAML key:value
+ const [label, value] = Object.entries(opt)[0];
+ // Don't escape the label in the key position, only the value
+ lines.push(` - ${label}: ${this.escapeYaml(value)}`);
+ } else {
+ // Simple string
+ lines.push(` - ${this.escapeYaml(opt)}`);
+ }
+ });
+ }
+
+ if (field.multiple) {
+ lines.push(` multiple: true`);
+ }
+
+ if (field.endpoint) {
+ lines.push(` endpoint: ${this.escapeYaml(field.endpoint)}`);
+ }
+
+ const isXhrField = field.field_type === 'xhrSelect' || field.field_type === 'xhrSelectSearch';
+ if (isXhrField) {
+ // Determine the verb: use the field property value, or fall back to the default 'GET'
+ const verb = field.http_verb || this.propertyDefinitions.http_verb.default;
+
+ // Always print the http_verb for XHR fields, even if it's the default 'GET'.
+ lines.push(` http_verb: ${verb}`);
+ } else if (field.http_verb && field.http_verb !== 'GET') {
+ // Keep this secondary check for non-XHR fields that might use a non-default verb
+ // (though unlikely with current definitions)
+ lines.push(` http_verb: ${field.http_verb}`);
+ }
+
+ if (field.value) {
+ lines.push(` value: ${this.escapeYaml(field.value)}`);
+ }
+
+ if (field.category) {
+ lines.push(` category: ${this.escapeYaml(field.category)}`);
+ }
+
+ if (field.github_url) {
+ lines.push(` github_url: ${this.escapeYaml(field.github_url)}`);
+ }
+ if (field.email_address) {
+ lines.push(` email_address: ${this.escapeYaml(field.email_address)}`);
+ }
+ if (field.learn_more_url) {
+ lines.push(` learn_more_url: ${this.escapeYaml(field.learn_more_url)}`);
+ }
+ if (field.youtube_url) {
+ lines.push(` youtube_url: ${this.escapeYaml(field.youtube_url)}`);
+ }
+
+ // Conditional validation
+ if (field.conditional_validation && field.conditional_validation.length > 0) {
+ lines.push(` conditional_validation:`);
+ field.conditional_validation.forEach(condition => {
+ lines.push(` - when: ${this.escapeYaml(condition.when)}`);
+ if (condition.hidden && condition.hidden.length > 0) {
+ lines.push(` hidden:`);
+ condition.hidden.forEach(keyname => {
+ lines.push(` - ${keyname}`);
+ });
+ }
+ });
+ }
+
+ // Capture any i18n description keys
+ Object.keys(field).forEach(k => {
+ if (k.startsWith('description-')) {
+ lines.push( ` ${k}: "${field[k]}"`);
+ }
+ });
+
+ });
+
+ return lines.join('\n');
+ }
+
+ escapeYaml(value) {
+ if (!value && value !== 0) return '""';
+
+ // Convert to string if it's a number
+ if (typeof value === 'number') { return value.toString(); }
+
+ // If it's not a string at this point, return empty
+ if (typeof value !== 'string') return '""';
+
+ // Check if value needs quoting
+ if (/[:#\[\]\{\},&\*\|>!'"%@`\n\t]/.test(value) || /^\d+$/.test(value) || value.startsWith(' ') || value.endsWith(' ')) {
+ return `"${value.replace(/"/g, '\\"')}"`;
+ }
+
+ return value;
+ }
+
+ reorderFields(draggedId, targetId) {
+ const draggedIndex = this.fields.findIndex(f => f.id === draggedId);
+ const targetIndex = this.fields.findIndex(f => f.id === targetId);
+
+ if (draggedIndex !== -1 && targetIndex !== -1) {
+ const [draggedField] = this.fields.splice(draggedIndex, 1);
+ this.fields.splice(targetIndex, 0, draggedField);
+ this.updateCanvasView();
+ this.updateYamlOutput();
+ }
+ }
+
+ duplicateField(fieldId) {
+ const field = this.fields.find(f => f.id === fieldId);
+
+ // Prevent duplicating author_bio (existing logic)
+ if (field && field.field_type === 'author_bio') {
+ this.showToast('Author Bio field cannot be duplicated');
+ return;
+ }
+
+ if (field) {
+ // 1. Create a deep copy of the original field object
+ const newField = JSON.parse(JSON.stringify(field));
+
+ // 2. Assign a new unique ID
+ const newFieldId = `field_${this.nextFieldId++}`;
+ newField.id = newFieldId;
+
+ // 3. FIX: Generate a clean, unique keyname based on the field type
+ // Example: 'string' -> 'string_2'
+ newField.keyname = `${field.field_type}_${this.nextFieldId - 1}`;
+
+ // 4. Insert the new field and update state
+ this.fields.push(newField);
+ this.selectedFieldId = newField.id;
+
+ // Ensure the config panel opens for the newly duplicated field
+ this.updateConfigPanel();
+ this.updateCanvasView();
+ this.updateYamlOutput();
+ this.updateAuthorBioState();
+ }
+ }
+
+ deleteField(fieldId) {
+ this.fields = this.fields.filter(f => f.id !== fieldId);
+ if (this.selectedFieldId === fieldId) {
+ this.selectedFieldId = this.fields[0]?.id || null;
+ }
+ this.updateCanvasView();
+ this.updateConfigPanel();
+ this.updateYamlOutput();
+ this.updateAuthorBioState();
+ }
+
+ updateFieldCount() {
+ this.shadowRoot.querySelector('#fieldCount').textContent = this.fields.length;
+ }
+
+ switchTab(tab) {
+ this.shadowRoot.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
+ this.shadowRoot.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
+
+ this.shadowRoot.querySelector(`[data-tab="${tab}"]`).classList.add('active');
+ this.shadowRoot.getElementById(`${tab}Tab`).classList.add('active');
+ }
+
+ copyYaml() {
+ const yaml = this.generateYaml();
+ navigator.clipboard.writeText(yaml).then(() => {
+ this.showToast('YAML copied to clipboard');
+ });
+ }
+
+ importYaml() {
+ // Create modal overlay
+ const modal = document.createElement('div');
+ modal.style.cssText = `
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 9999;
+ `;
+
+ const modalContent = document.createElement('div');
+ modalContent.style.cssText = `
+ background: ${this.theme === 'dark' ? '#1a1a1a' : '#ffffff'};
+ border: 1px solid ${this.theme === 'dark' ? '#404040' : '#e0e0e0'};
+ border-radius: 8px;
+ padding: 24px;
+ width: 600px;
+ max-width: 90vw;
+ max-height: 80vh;
+ display: flex;
+ flex-direction: column;
+ `;
+
+ modalContent.innerHTML = `
+ Import YAML
+
+
+
+
+
+ `;
+
+ modal.appendChild(modalContent);
+ document.body.appendChild(modal);
+
+ const textarea = modalContent.querySelector('#yamlImportTextarea');
+ const cancelBtn = modalContent.querySelector('#cancelImportBtn');
+ const confirmBtn = modalContent.querySelector('#confirmImportBtn');
+
+ // Focus textarea
+ textarea.focus();
+
+ // Cancel handler
+ const closeModal = () => {
+ document.body.removeChild(modal);
+ };
+
+ cancelBtn.addEventListener('click', closeModal);
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) closeModal();
+ });
+
+ // Import handler
+ confirmBtn.addEventListener('click', () => {
+ const yamlText = textarea.value;
+
+ if (!yamlText || !yamlText.trim()) {
+ closeModal();
+ return;
+ }
+
+ try {
+ const fields = this.parseYaml(yamlText);
+ // console.log('Parsed fields:', fields);
+ if (fields.length === 0) {
+ this.showToast('No fields found in YAML');
+ closeModal();
+ return;
+ }
+
+ // Clear existing fields and import new ones
+ this.fields = fields.map((fieldData, index) => {
+ const field = {
+ id: `field_${this.nextFieldId++}`,
+ keyname: fieldData.keyname || `field_${index}`,
+ field_type: fieldData.field_type || 'string',
+ name: fieldData.name || '',
+ };
+
+ // Copy over all other properties
+ Object.keys(fieldData).forEach(key => {
+ if (key !== 'id') {
+ field[key] = fieldData[key];
+ }
+ });
+
+ return field;
+ });
+
+ this.selectedFieldId = this.fields[0]?.id || null;
+ this.updateCanvasView();
+ this.updateConfigPanel();
+ this.updateYamlOutput();
+ this.updateAuthorBioState();
+ this.switchTab('config');
+ this.showToast(`Imported ${fields.length} field(s)`);
+ closeModal();
+ } catch (error) {
+ alert(`Error parsing YAML: ${error.message}`);
+ }
+ });
+ }
+
+ parseYaml(yamlText) {
+ const fields = [];
+ const lines = yamlText.split('\n');
+ let currentField = null;
+ let currentArray = null;
+ let currentArrayKey = null;
+ let currentCondition = null;
+ let currentNestedKey = null; // Helper to track if we are in hidden/required
+
+ lines.forEach(line => {
+ // Skip comments and empty lines
+ if (line.trim().startsWith('#') || !line.trim()) {
+ return;
+ }
+
+ // 1. New field (starts with - keyname:)
+ if (line.match(/^-\s+keyname:/)) {
+ if (currentField) {
+ fields.push(currentField);
+ }
+ currentField = {};
+ currentArray = null;
+ currentArrayKey = null;
+ currentCondition = null;
+ currentNestedKey = null;
+ const value = line.split('keyname:')[1].trim();
+ currentField.keyname = this.unescapeYaml(value);
+ }
+
+ // 2. Field properties (2 spaces indentation)
+ else if (currentField && line.match(/^\s{2}[a-z_-]+:/)) {
+ const match = line.match(/^\s{2}([a-z_-]+):\s*(.*)$/);
+ if (match) {
+ const key = match[1];
+ const value = match[2].trim();
+
+ // Handle array start (value is empty)
+ if (!value || value === '') {
+ currentArrayKey = key;
+ currentArray = [];
+
+ if (key === 'conditional_validation') {
+ currentField[key] = [];
+ } else {
+ currentField[key] = currentArray;
+ }
+ } else {
+ // Scalar values
+ currentArrayKey = null;
+ currentArray = null;
+ currentField[key] = this.unescapeYaml(value);
+
+ // Convert string booleans
+ if (value === 'true') currentField[key] = true;
+ if (value === 'false') currentField[key] = false;
+
+ // Convert numbers (but not empty strings!)
+ if (!isNaN(value) && value !== '' && value !== '""') {
+ currentField[key] = Number(value);
+ }
+ }
+ }
+ }
+
+ // 3. Nested properties in conditions (e.g., " hidden:")
+ // We check this BEFORE array items to set context
+ else if (currentCondition && line.match(/^\s{4}[a-z_-]+:/)) {
+ const match = line.match(/^\s{4}([a-z_]+):\s*(.*)$/);
+ if (match) {
+ const key = match[1];
+ // Initialize the nested array
+ currentCondition[key] = [];
+ currentNestedKey = key; // Track that we are currently filling this key
+ }
+ }
+
+ // 4. Nested Array Items (Deep indentation: 4 or more spaces + dash)
+ // IMPORTANT: This must come BEFORE the generic array check
+ else if (currentCondition && line.match(/^\s{4,}-\s+/)) {
+ const value = line.replace(/^\s{4,}-\s+/, '').trim();
+
+ // Add to the currently active nested array (hidden or required)
+ if (currentNestedKey && Array.isArray(currentCondition[currentNestedKey])) {
+ currentCondition[currentNestedKey].push(value);
+ }
+ }
+
+ // 5. Top-Level Array items (Exactly 2 spaces + dash)
+ // Regex changed from {2,} to {2} to avoid eating nested items
+ else if (line.match(/^\s{2}-\s+/)) {
+ const value = line.replace(/^\s{2}-\s+/, '').trim();
+
+ // If we're in conditional_validation, this is a NEW condition object
+ if (currentArrayKey === 'conditional_validation') {
+ currentCondition = {};
+ currentField.conditional_validation.push(currentCondition);
+ currentNestedKey = null; // Reset nested context for new object
+
+ // Parse the "when" value
+ if (value.startsWith('when:')) {
+ currentCondition.when = this.unescapeYaml(value.split('when:')[1].trim());
+ }
+ }
+ // Otherwise it's a regular array item (like options)
+ else if (currentArray) {
+ const colonIndex = value.indexOf(':');
+ if (currentArrayKey === 'options' && colonIndex > -1) {
+ const label = value.substring(0, colonIndex).trim();
+ const val = value.substring(colonIndex + 1).trim();
+ currentArray.push({ [this.unescapeYaml(label)]: this.unescapeYaml(val) });
+ } else {
+ currentArray.push(this.unescapeYaml(value));
+ }
+ }
+ }
+ });
+
+ // Don't forget the last field
+ if (currentField) {
+ fields.push(currentField);
+ }
+
+ return fields;
+ }
+
+ unescapeYaml(value) {
+ if (!value && value !== 0) return '';
+
+ // If it's not a string, just return it as-is
+ if (typeof value !== 'string') return value;
+
+ // Remove surrounding quotes
+ value = value.replace(/^["'](.*)["']$/, '$1');
+
+ // Unescape escaped quotes
+ value = value.replace(/\\"/g, '"');
+
+ return value;
+ }
+
+ showToast(message) {
+ const toast = document.createElement('div');
+ toast.className = 'toast';
+ toast.textContent = message;
+ toast.style.cssText = `
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ padding: 12px 16px;
+ background: ${this.theme === 'dark' ? '#e0e0e0' : '#1a1a1a'};
+ color: ${this.theme === 'dark' ? '#1a1a1a' : '#e0e0e0'};
+ border-radius: 4px;
+ font-size: 150%;
+ font-weight: 500;
+ z-index: 1000;
+ animation: slideIn 0.3s ease-out;
+ `;
+ document.body.appendChild(toast);
+ setTimeout(() => toast.remove(), 2000);
+ }
+
+ // Public API methods
+ getYaml() {
+ return this.generateYaml();
+ }
+
+ getFields() {
+ return JSON.parse(JSON.stringify(this.fields));
+ }
+
+ setFields(fields) {
+ this.fields = JSON.parse(JSON.stringify(fields));
+ this.selectedFieldId = this.fields[0]?.id || null;
+ this.updateCanvasView();
+ this.updateConfigPanel();
+ this.updateYamlOutput();
+ this.updateAuthorBioState(); // Check immediately on render
+ }
+
+ clear() {
+ this.fields = [];
+ this.selectedFieldId = null;
+ this.draggedFieldId = null;
+ this.draggedFromIndex = null;
+ this.nextFieldId = 1;
+ this.render();
+ this.setupEventListeners();
+ this.updateCanvasView();
+ this.updateConfigPanel();
+ this.updateYamlOutput();
+ this.updateAuthorBioState(); // Check immediately on render
+ }
+}
+
+// Register the custom element
+customElements.define('trmnl-form-builder', TRMLYamlForm);