Repository: kevinchappell/formBuilder

Issue:
consolidated custom control options
Custom Plugin API is very powerful but requires a build step even for simple controls such a new button type.

70% of a controls definition can be made via an entry in the `fields` option. If a few more definitions could be passed through this option entire new controls could be added to the editor and renderer.

Discussion:
@github-actions: :tada: This issue has been resolved in version 3.21.0 :tada:

The release is available on:
- [npm package (@latest dist-tag)](https://www.npmjs.com/package/formBuilder/v/3.21.0)
- [GitHub release](https://github.com/kevinchappell/formBuilder/releases/tag/v3.21.0)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:

Solution patch:
diff --git a/package-lock.json b/package-lock.json
index 14fa664..89aa443 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5954,9 +5954,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001639",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001639.tgz",
-      "integrity": "sha512-eFHflNTBIlFwP2AIKaYuBQN/apnUoKNhBdza8ZnW/h2di4LCZ4xFqYlxUxo+LQ76KFI1PGcC1QDxMbxTZpSCAg==",
+      "version": "1.0.30001735",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001735.tgz",
+      "integrity": "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==",
       "dev": true,
       "funding": [
         {
diff --git a/package.json b/package.json
index 8685acd..baa572e 100644
--- a/package.json
+++ b/package.json
@@ -113,12 +113,14 @@
     "@babel/eslint-parser": "^7.24.7",
     "@babel/node": "^7.24.7",
     "@babel/plugin-transform-class-properties": "^7.24.7",
+    "@babel/plugin-transform-destructuring": "^7.24.7",
     "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7",
     "@babel/plugin-transform-object-rest-spread": "^7.24.7",
-    "@babel/plugin-transform-destructuring": "^7.24.7",
     "@babel/preset-env": "^7.24.7",
     "@commitlint/cli": "^18.4.3",
     "@commitlint/config-conventional": "^18.4.3",
+    "@eslint/eslintrc": "^3.1.0",
+    "@eslint/js": "^9.6.0",
     "@semantic-release/changelog": "^6.0.3",
     "@semantic-release/git": "^10.0.1",
     "@semantic-release/npm": "^10.0.5",
@@ -136,8 +138,6 @@
     "eslint-config-prettier": "^9.1.0",
     "eslint-plugin-prettier": "^5.1.3",
     "eslint-webpack-plugin": "^4.2.0",
-    "@eslint/js": "^9.6.0",
-    "@eslint/eslintrc": "^3.1.0",
     "formbuilder-languages": "latest",
     "fs-extra": "^9.0.1",
     "globals": "^15.7.0",
diff --git a/src/demo/js/demo.js b/src/demo/js/demo.js
index 8fdfb96..984ae61 100644
--- a/src/demo/js/demo.js
+++ b/src/demo/js/demo.js
@@ -45,7 +45,7 @@ jQuery(function ($) {
     },
     {
       type: 'checkbox-group',
-      subtype: 'custom-group',
+      subtype: 'custom',
       label: 'Custom Checkbox Group w/Sub Type',
       required: true,
       values: [{ label: 'Option 1' }, { label: 'Option 2' }],
@@ -180,13 +180,11 @@ jQuery(function ($) {
         max: 11,
       },
     },
-    'checkbox-group': {
-      'custom-group': {
-        customInput: {
-          label: 'Custom Text Field',
-          value: 'This field is added only to checkbox with specific subtype',
-          type: 'text',
-        },
+    'checkbox-group-custom': {
+      customInput: {
+        label: 'Custom Text Field',
+        value: 'This field is added only to checkbox with specific subtype',
+        type: 'text',
       },
     },
   }
@@ -214,7 +212,6 @@ jQuery(function ($) {
     dataType,
     subtypes: {
       text: ['datetime-local'],
-      'checkbox-group': ['custom-group'],
     },
     onSave: toggleEdit,
     onAddField: fieldId => {
diff --git a/src/js/controls.js b/src/js/controls.js
index 352b9b7..bbe4331 100644
--- a/src/js/controls.js
+++ b/src/js/controls.js
@@ -42,7 +42,7 @@ export default class Controls {
     control.loadCustom(opts.controls)
     // register any passed custom templates & fields
     this.custom =  new customControls(opts.templates, opts.fields)
-
+    
     // retrieve a full list of loaded controls
     const registeredControls = control.getRegistered()
     const customFields = this.custom.getRegistered()
diff --git a/src/js/customControls.js b/src/js/customControls.js
index 208368b..9da5fa9 100644
--- a/src/js/customControls.js
+++ b/src/js/customControls.js
@@ -28,6 +28,12 @@ export default class customControls {
    * @param {Array} fields
    */
   register(templates = {}, fields = []) {
+    fields.forEach(field => {
+      if (field.template) {
+        const fieldType = field.type || field.attrs?.type
+        templates[fieldType] = field.template
+      }
+    })
     // prepare i18n locale definition
     const locale = mi18n.locale
     if (!this.def.i18n[locale]) {
@@ -36,18 +42,18 @@ export default class customControls {
 
     const _this = this
     Object.keys(templates).forEach(templateName => {
-      const templateControl = function(config, preview) {
+      const templateControl = function (config, preview) {
         this.customControl = new controlCustom(config, preview, templates[templateName])
 
         /**
          * build a custom control defined in the templates option
          * @return {{field: any, layout: any}} DOM Element to be injected into the form.
          */
-        this.build = function() {
+        this.build = function () {
           return this.customControl.build()
         }
 
-        this.on = function(eventType) {
+        this.on = function (eventType) {
           return this.customControl.on(eventType)
         }
       }
@@ -85,7 +91,7 @@ export default class customControls {
             type: type,
             class: controlClass,
           })
-        } catch(e) {
+        } catch (e) {
           control.error(
             'Error while registering custom field: ' +
             type +
@@ -114,7 +120,7 @@ export default class customControls {
    * @param {String} type
    * @return {String} translated control
    */
-   label(type) {
+  label(type) {
     /**
      * Retrieve a translated string
      * By default looks for translations defined against the class (for plugin controls)
@@ -123,27 +129,27 @@ export default class customControls {
      * @param {Object|Number|String} [args] - string or key/val pairs for string lookups with variables
      * @return {String} the translated label
      */
-      const def = this.def
-      let i18n = def.i18n || {}
-      const locale = mi18n.locale
-      i18n = i18n[locale] || i18n.default || i18n
-      const lookupCamel = control.camelCase(type)
-
-      // if translation is defined in the control, return it
-      const value = typeof i18n == 'object' ? i18n[lookupCamel] || i18n[type] : i18n
-      if (value) {
-        return value
-      } else {
-        // otherwise check the mi18n object - allow for mapping a lookup to a custom mi18n lookup
-        let mapped = def.mi18n
-        if (typeof mapped === 'object') {
-          mapped = mapped[lookupCamel] || mapped[type]
-        }
-        if (!mapped) {
-          mapped = lookupCamel
-        }
-        return mi18n.get(mapped)
+    const def = this.def
+    let i18n = def.i18n || {}
+    const locale = mi18n.locale
+    i18n = i18n[locale] || i18n.default || i18n
+    const lookupCamel = control.camelCase(type)
+
+    // if translation is defined in the control, return it
+    const value = typeof i18n == 'object' ? i18n[lookupCamel] || i18n[type] : i18n
+    if (value) {
+      return value
+    } else {
+      // otherwise check the mi18n object - allow for mapping a lookup to a custom mi18n lookup
+      let mapped = def.mi18n
+      if (typeof mapped === 'object') {
+        mapped = mapped[lookupCamel] || mapped[type]
       }
+      if (!mapped) {
+        mapped = lookupCamel
+      }
+      return mi18n.get(mapped)
+    }
   }
 
   get definition() {
@@ -184,7 +190,7 @@ export default class customControls {
    * a class mapped to this subtype. If none found, fall back to the type.
    * @return {Class} control subclass as defined in the call to register
    */
-   getClass(type) {
+  getClass(type) {
     return this.templateControlRegister[type] ?? undefined
   }
 
@@ -196,4 +202,4 @@ export default class customControls {
   lookup(lookup) {
     return this.customRegister[lookup]
   }
-}
\ No newline at end of file
+}
diff --git a/src/js/form-builder.js b/src/js/form-builder.js
index b5d70a7..c6d8d50 100644
--- a/src/js/form-builder.js
+++ b/src/js/form-builder.js
@@ -24,7 +24,7 @@ import {
   forEach,
   markup,
   removeFromArray,
-  attrString,
+  titleCase,
   capitalize,
   parsedHtml,
   closest,
@@ -33,6 +33,7 @@ import {
   getContentType,
   generateSelectorClassNames,
   firstNumberOrUndefined,
+  safeClassName,
 } from './utils'
 import { attributeWillClobber, setElementContent, setSanitizerConfig } from './sanitizer'
 import fontConfig from '../fonts/config.json'
@@ -281,11 +282,11 @@ function FormBuilder(opts, element, $) {
   }
 
   // builds the standard formbuilder datastructure for a field definition
-  const prepFieldVars = ($field, isNew = false) => {
+  const prepFieldVars = (fieldArg, isNew = false) => {
     let field = {}
-    if ($field instanceof jQuery) {
+    if (fieldArg instanceof jQuery) {
       // get the default type etc & label for this field
-      field.type = $field[0].dataset.type
+      field.type = fieldArg[0].dataset.type
       if (field.type) {
         // check for a custom type
         const custom = controls.custom.lookup(field.type)
@@ -301,9 +302,9 @@ function FormBuilder(opts, element, $) {
         // @todo: any other attrs ever set in aFields? value or selected?
       } else {
         // is dataType XML
-        const attrs = $field[0].attributes
+        const attrs = fieldArg[0].attributes
         if (!isNew) {
-          field.values = $field.children().map((index, elem) => {
+          field.values = fieldArg.children().map((index, elem) => {
             return {
               label: $(elem).text(),
               value: $(elem).attr('value'),
@@ -317,7 +318,7 @@ function FormBuilder(opts, element, $) {
         }
       }
     } else {
-      field = Object.assign({}, $field)
+      field = Object.assign({}, fieldArg)
     }
 
     if (!field.name) {
@@ -335,7 +336,7 @@ function FormBuilder(opts, element, $) {
 
     if (isNew) {
       const eventTimeout = setTimeout(() => {
-        $stage[0].dispatchEvent(new Event('fieldAdded', { bubbles: true, cancelable: false} ))
+        $stage[0].dispatchEvent(new Event('fieldAdded', { bubbles: true, cancelable: false }))
         clearTimeout(eventTimeout)
       }, 10)
     }
@@ -426,7 +427,7 @@ function FormBuilder(opts, element, $) {
     }
     const optionActionsWrap = m('div', optionActions, { className: 'option-actions' })
 
-    const optionGroupName = nameAttr({type: 'grp-options'}) + '-options'
+    const optionGroupName = nameAttr({ type: 'grp-options' }) + '-options'
     const options = m(
       'ol',
       fieldValues.map((option, index) => {
@@ -444,7 +445,7 @@ function FormBuilder(opts, element, $) {
 
     fieldOptions.push(optionsWrap)
 
-    return m('div', fieldOptions, { name: fieldName, className: 'form-group field-options' }).outerHTML
+    return m('div', fieldOptions, { name: fieldName, className: 'form-group field-options' })
   }
 
   const defaultFieldAttrs = type => {
@@ -509,8 +510,9 @@ function FormBuilder(opts, element, $) {
    * @param  {Object} values configuration object for advanced fields
    * @return {string}        markup for advanced fields
    */
-  const advFields = values => {
-    const { type } = values
+  const generateAdvFields = values => {
+    const { type, subtype } = values
+    const roles = values.role !== undefined ? values.role.split(',') : []
     const advFields = []
     const typeClass = controls.getClass(type)
     const fieldAttrs = defaultFieldAttrs(type)
@@ -527,7 +529,17 @@ function FormBuilder(opts, element, $) {
       },
       label: () => textAttribute('label', values),
       description: () => textAttribute('description', values),
-      subtype: isHidden => selectAttribute('subtype', values, subtypes[type], isHidden),
+      subtype: isHidden => {
+        const events = {
+          change: evt => {
+            const { value } = evt.target
+            const curAdvFields = evt.target.closest('.form-elements-inner')
+            const newAdvFields = generateAdvFields({ ...values, subtype: value })
+            curAdvFields.replaceWith(newAdvFields)
+          }
+        }
+        return selectAttribute('subtype', { events, ...values }, subtypes[type], isHidden)
+      },
       style: () => btnStyles(values.style),
       placeholder: () => textAttribute('placeholder', values),
       rows: () => numberAttribute('rows', values),
@@ -537,7 +549,7 @@ function FormBuilder(opts, element, $) {
       maxlength: () => numberAttribute('maxlength', values),
       access: () => {
         const rolesDisplay = values.role ? 'style="display:block"' : ''
-        const availableRoles = [`<div class="available-roles" ${rolesDisplay}>`]
+        const availableRoles = []
         for (key in opts.roles) {
           if (opts.roles.hasOwnProperty(key)) {
             const roleId = `fld-${data.lastID}-roles-${key}`
@@ -552,16 +564,21 @@ function FormBuilder(opts, element, $) {
               cbAttrs.checked = 'checked'
             }
 
-            availableRoles.push(`<label for="${roleId}">`)
-            availableRoles.push(h.input(cbAttrs).outerHTML)
-            availableRoles.push(` ${opts.roles[key]}</label>`)
+            const checkbox = h.input(cbAttrs)
+            const label = m('label', [checkbox, ` ${opts.roles[key]}`], { for: roleId })
+            availableRoles.push(label)
           }
         }
-        availableRoles.push('</div>')
+
+        const availableRolesDiv = m('div', availableRoles, {
+          className: 'available-roles',
+          style: rolesDisplay
+        })
+
         const accessLabels = {
           first: mi18n.get('roles'),
           second: mi18n.get('limitRole'),
-          content: availableRoles.join(''),
+          content: availableRolesDiv,
         }
 
         return boolAttribute('access', values, accessLabels)
@@ -596,8 +613,6 @@ function FormBuilder(opts, element, $) {
       },
     }
     let key
-    const roles = values.role !== undefined ? values.role.split(',') : []
-
     const numAttrs = ['min', 'max', 'step']
 
     numAttrs.forEach(numAttr => {
@@ -607,7 +622,12 @@ function FormBuilder(opts, element, $) {
 
     const noDisable = ['name', 'className', 'subtype']
 
-    const typeUserAttrs = Object.assign({}, opts.typeUserAttrs['*'], opts.typeUserAttrs[type])
+    const tuaLookup = [type, subtype].filter(Boolean).join('-')
+    const typeUserAttrs = {
+      ...(opts.typeUserAttrs['*'] ?? {}),
+      ...(opts.typeUserAttrs[tuaLookup] ?? {}),
+      ...(typeClass?.definition?.userAttrs ?? {}),
+    }
 
     Object.keys(fieldAttrs).forEach(index => {
       const attr = fieldAttrs[index]
@@ -655,7 +675,7 @@ function FormBuilder(opts, element, $) {
       advFields.push(customAttr)
     }
 
-    return advFields.join('')
+    return m('div', advFields, { className: 'form-elements-inner' })
   }
 
   /**
@@ -664,12 +684,13 @@ function FormBuilder(opts, element, $) {
    * @return {string} type of user attr
    */
   function userAttrType(attrData) {
+    const value = attrData.value ?? attrData
     return (
       [
         ['array', ({ options }) => !!options],
         ['boolean', ({ type }) => type === 'checkbox'], // automatic bool if checkbox
         ['options', ({ type }) => type === 'options'],
-        [typeof attrData.value, () => true], // string, number,
+        [typeof value, () => true], // string, number, 
       ].find(typeCondition => typeCondition[1](attrData))[0]
     )
   }
@@ -696,26 +717,45 @@ function FormBuilder(opts, element, $) {
       array: selectUserAttrs,
       string: inputUserAttrs,
       number: numberAttribute,
-      boolean: (attr, attrData) => {
+      boolean: (attrName, attrData) => {
         let isChecked = false
-        if (attr.type === 'checkbox') {
+        if (attrName.type === 'checkbox') {
           isChecked = Boolean(attrData.hasOwnProperty('value') ? attrData.value : false)
-        } else if (values.hasOwnProperty(attr)) {
-          isChecked = values[attr]
+        } else if (values.hasOwnProperty(attrName)) {
+          isChecked = values[attrName]
         } else if (attrData.hasOwnProperty('value') || attrData.hasOwnProperty('checked')) {
           isChecked = attrData.value || attrData.checked || false
         }
-        return boolAttribute(attr, { ...attrData, [attr]: isChecked }, { first: i18n[attr] })
+        return boolAttribute(attrName, { ...attrData, [attrName]: isChecked }, { first: i18n[attrName] })
       },
       options: fieldOptions,
+      object: (attrName, { [attrName]: value, label, className }) => {
+        const id = `${attrName}-${data.lastID}`
+
+        const userAttrs = Object.entries(value).reduce((acc, [name, val]) => {
+          const subAttrName = `${attrName}[${name}]`
+          acc[subAttrName] = {
+            label: i18n[name] || titleCase(name),
+            value: val,
+            name: subAttrName
+          }
+          return acc
+        }, {})
+
+        const labelDom = m('label', label || i18n[attrName])
+        const inputWrap = m('div', processTypeUserAttrs(userAttrs, values), { id, className: 'input-group-wrap' })
+
+        return m('div', [labelDom, inputWrap], { className: ['form-group', `${safeClassName(attrName)}-wrap`, className] })
+      }
     }
 
+
     for (const attribute in typeUserAttr) {
       if (typeUserAttr.hasOwnProperty(attribute)) {
         const attrValType = userAttrType(typeUserAttr[attribute])
         if (attrValType !== 'undefined') {
           const orig = mi18n.get(attribute)
-          const tUA = Object.assign({}, typeUserAttr[attribute]) //Ensure we work on a copy of the attributes
+          const { [attribute]: value, ...tUA } = typeUserAttr[attribute]
           let origValue = tUA.value
           if (attrValType === 'boolean') {
             tUA[attribute] ??= tUA.value
@@ -725,7 +765,7 @@ function FormBuilder(opts, element, $) {
             origValue ??= ''
             tUA[attribute] ??= values[attribute] || origValue
           }
-          tUA.value = tUA[attribute]
+          tUA.value = value
 
           if (tUA.label) {
             i18n[attribute] = Array.isArray(tUA.label) ? mi18n.get(...tUA.label) || tUA.label[0] : tUA.label
@@ -746,7 +786,7 @@ function FormBuilder(opts, element, $) {
       }
     }
 
-    return advField.join('')
+    return advField
   }
 
   /**
@@ -762,10 +802,10 @@ function FormBuilder(opts, element, $) {
       title: attrs.description || attrs.label || name.toUpperCase(),
       name: name,
       type: attrs.type || 'text',
-      className: [`fld-${name}`, (classname || className || '').trim()],
+      className: [safeClassName(`fld-${name}`), (classname || className || '').trim()],
       value: attrs.hasOwnProperty(name) ? attrs[name] : attrs.value || '',
     }
-    const label = `<label for="${textAttrs.id}">${i18n[name] || ''}</label>`
+    const label = m('label', i18n[name] || '', { for: textAttrs.id })
 
     const optionInputs = ['checkbox', 'checkbox-group', 'radio-group']
     if (!optionInputs.includes(textAttrs.type)) {
@@ -778,14 +818,14 @@ function FormBuilder(opts, element, $) {
       if (textAttrs.type === 'textarea') {
         const textValue = textAttrs.value
         delete textAttrs.value
-        return `<textarea ${attrString(textAttrs)}>${textValue}</textarea>`
+        return m('textarea', textValue, textAttrs)
       } else {
-        return `<input ${attrString(textAttrs)}>`
+        return m('input', null, textAttrs)
       }
     })()
 
-    const inputWrap = `<div class="input-wrap">${textInput}</div>`
-    return `<div class="form-group ${name}-wrap">${label}${inputWrap}</div>`
+    const inputWrap = m('div', textInput, { className: 'input-wrap' })
+    return m('div', [label, inputWrap], { className: `form-group ${safeClassName(name)}-wrap` })
   }
 
   /**
@@ -813,28 +853,25 @@ function FormBuilder(opts, element, $) {
       title: restData.description || labelText || name.toUpperCase(),
       name,
       className: `fld-${name} form-control ${classname || className || ''}`.trim(),
+      ...restData,
     }
 
     if (multiple) {
       selectAttrs.multiple = true
     }
 
-    const label = `<label for="${selectAttrs.id}">${i18n[name]}</label>`
+    const label = m('label', i18n[name] || '', { for: selectAttrs.id })
 
-    Object.keys(restData).forEach(function (attr) {
-      selectAttrs[attr] = restData[attr]
-    })
-
-    const select = m('select', optis, selectAttrs).outerHTML
-    const inputWrap = `<div class="input-wrap">${select}</div>`
-    return `<div class="form-group ${name}-wrap">${label}${inputWrap}</div>`
+    const select = m('select', optis, selectAttrs)
+    const inputWrap = m('div', select, { className: 'input-wrap' })
+    return m('div', [label, inputWrap], { className: `form-group ${safeClassName(name)}-wrap` })
   }
 
   const boolAttribute = (name, values, labels = {}) => {
     const label = txt =>
       m('label', txt, {
         for: `${name}-${data.lastID}`,
-      }).outerHTML
+      })
     const cbAttrs = {
       type: 'checkbox',
       className: `fld-${name}`,
@@ -845,7 +882,7 @@ function FormBuilder(opts, element, $) {
       cbAttrs.checked = true
     }
     const left = []
-    let right = [m('input', null, cbAttrs).outerHTML]
+    let right = [m('input', null, cbAttrs)]
 
     if (labels.first) {
       left.push(label(labels.first))
@@ -858,50 +895,43 @@ function FormBuilder(opts, element, $) {
       right.push(labels.content)
     }
 
-    right = m('div', right, { className: 'input-wrap' }).outerHTML
+    right = m('div', right, { className: 'input-wrap' })
 
     return m('div', left.concat(right), {
-      className: `form-group ${name}-wrap`,
-    }).outerHTML
+      className: `form-group ${safeClassName(name)}-wrap`,
+    })
   }
 
   const btnStyles = style => {
-    let styleField = ''
-
-    // corrects issue where 'undefined' was saved to formData
-    if (style === 'undefined') {
-      style = 'default'
-    }
-
-    const styleLabel = `<label>${i18n.style}</label>`
-    styleField += h.input({
+    const styleLabel = m('label', i18n.style)
+    const styleInput = h.input({
       value: style || 'default',
       type: 'hidden',
       className: 'btn-style',
-    }).outerHTML
-    styleField += '<div class="btn-group" role="group">'
+    })
 
-    styles.btn.forEach(btnStyle => {
+    const styleButtons = styles.btn.map(btnStyle => {
       const classList = ['btn-xs', 'btn', `btn-${btnStyle}`]
       if (style === btnStyle) {
         classList.push('selected')
       }
-      const btn = m('button', mi18n.get(`styles.btn.${btnStyle}`), {
+      return m('button', mi18n.get(`styles.btn.${btnStyle}`), {
         value: btnStyle,
         type: 'button',
         className: classList.join(' '),
-      }).outerHTML
-
-      styleField += btn
+      })
     })
+    const styleButtonGroup = m('div', styleButtons, { className: 'btn-group', role: 'group' })
 
-    styleField += '</div>'
+    // corrects issue where 'undefined' was saved to formData
+    if (style === 'undefined') {
+      style = 'default'
+    }
 
-    styleField = m('div', [styleLabel, styleField], {
+    return m('div', [styleLabel, styleInput, styleButtonGroup], {
       className: 'form-group style-wrap',
     })
 
-    return styleField.outerHTML
   }
 
   /**
@@ -921,16 +951,16 @@ function FormBuilder(opts, element, $) {
       value: attrVal,
       name: attribute,
       placeholder,
-      className: `fld-${attribute} form-control ${classname || className || ''}`.trim(),
+      className: [safeClassName(`fld-${attribute}`), 'form-control', (classname || className || '').trim()],
       id: `${attribute}-${data.lastID}`,
     }
-    const numberAttribute = h.input(trimObj(inputConfig)).outerHTML
-    const inputWrap = `<div class="input-wrap">${numberAttribute}</div>`
-    const inputLabel = `<label for="${inputConfig.id}">${attrLabel}</label>`
+    const numberAttribute = h.input(trimObj(inputConfig))
+    const inputWrap = m('div', numberAttribute, { className: 'input-wrap' })
+    const inputLabel = m('label', attrLabel, { for: inputConfig.id })
 
     return m('div', [inputLabel, inputWrap], {
-      className: `form-group ${attribute}-wrap`,
-    }).outerHTML
+      className: `form-group ${safeClassName(attribute)}-wrap`,
+    })
   }
 
   /**
@@ -941,7 +971,7 @@ function FormBuilder(opts, element, $) {
    * @param  {boolean} [isHidden=false] field should be hidden on the stage
    * @return {string}            select input makrup
    */
-  const selectAttribute = (attribute, values, optionData, isHidden = false) => {
+  const selectAttribute = (attribute, { events, ...values }, optionData, isHidden = false) => {
     const selectOptions = optionData.map((option, i) => {
       let optionAttrs = Object.assign(
         {
@@ -960,18 +990,18 @@ function FormBuilder(opts, element, $) {
       id: attribute + '-' + data.lastID,
       name: attribute,
       className: `fld-${attribute} form-control`,
+      events,
     }
     const labelText = mi18n.get(attribute) || capitalize(attribute) || ''
     const label = m('label', labelText, { for: selectAttrs.id })
     const select = m('select', selectOptions, selectAttrs)
     const inputWrap = m('div', select, { className: 'input-wrap' })
-    const visibility = isHidden ? 'none' : 'block'
     const attrWrap = m('div', [label, inputWrap], {
       className: `form-group ${selectAttrs.name}-wrap`,
-      style: `display: ${visibility}`,
+      style: isHidden && 'display: none',
     })
 
-    return attrWrap.outerHTML
+    return attrWrap
   }
 
   /**
@@ -996,7 +1026,7 @@ function FormBuilder(opts, element, $) {
     }
 
     const placeholder = mi18n.get(`placeholders.${attribute}`) || ''
-    let attributefield = ''
+    let attributefield
     const noMakeAttr = []
 
     if (!noMakeAttr.some(elem => elem === true)) {
@@ -1006,35 +1036,34 @@ function FormBuilder(opts, element, $) {
         className: `fld-${attribute} form-control`,
         id: `${attribute}-${data.lastID}`,
       }
-      const attributeLabel = m('label', attrLabel, {
-        for: inputConfig.id,
-      }).outerHTML
+      const attributeLabel = m('label', attrLabel)
 
       if (attribute === 'label' && !opts.disableHTMLLabels) {
         inputConfig.contenteditable = true
-        attributefield += m('div', attrVal, inputConfig).outerHTML
+        attributefield = m('div', attrVal, inputConfig)
       } else if (values.type === 'textarea' && attribute === 'value') {
-        attributefield += m('textarea', attrVal, inputConfig).outerHTML
+        attributefield = m('textarea', attrVal, inputConfig)
       } else {
         inputConfig.value = attrVal
         inputConfig.type = 'text'
-        attributefield += `<input ${attrString(inputConfig)}>`
+        attributefield = m('input', null, inputConfig)
       }
 
-      const inputWrap = `<div class="input-wrap">${attributefield}</div>`
+      const inputWrap = m('div', attributefield, { className: 'input-wrap' })
 
-      let visibility = isHidden ? 'none' : 'block'
-      if (attribute === 'value') {
-        visibility = values.subtype && values.subtype === 'quill' && 'none'
+      const fieldAttrs = {
+        className: `form-group ${attribute}-wrap`,
       }
 
-      attributefield = m('div', [attributeLabel, inputWrap], {
-        className: `form-group ${attribute}-wrap`,
-        style: `display: ${visibility}`,
-      })
+      if (attribute === 'value' && values.subtype === 'quill' || isHidden) {
+        fieldAttrs.style = 'display: none'
+      }
+
+      attributefield = m('div', [attributeLabel, inputWrap], fieldAttrs)
     }
 
-    return attributefield.outerHTML
+
+    return attributefield
   }
 
   const requiredField = fieldData => {
@@ -1059,8 +1088,7 @@ function FormBuilder(opts, element, $) {
   const appendNewField = function (values, isNew = true) {
     const columnData = prepareFieldRow(values)
     data.lastID = h.incrementId(data.lastID)
-
-    const type = values.type || 'text'
+    const { type = 'text', subtype } = values
     let label = values.label || (isNew ? i18n[type] || mi18n.get('label') : '')
     if (type === 'hidden' || label === '') {
       label = `${mi18n.get(type) ?? type}: ${values.name}`
@@ -1144,7 +1172,7 @@ function FormBuilder(opts, element, $) {
     const prevHolder = m('div', '', { className: 'prev-holder', dataFieldId: data.lastID })
     liContents.push(prevHolder)
 
-    const formElements = m('div', [advFields(values), m('a', mi18n.get('close'), { className: 'close-field' })], {
+    const formElements = m('div', [generateAdvFields(values), m('a', mi18n.get('close'), { className: 'close-field' })], {
       className: 'form-elements',
     })
 
@@ -1158,11 +1186,17 @@ function FormBuilder(opts, element, $) {
 
     liContents.push(editPanel)
 
-    const field = m('li', liContents, {
+    const liAttrs = {
       class: `${type}-field form-field`,
       type: type,
       id: data.lastID,
-    })
+    }
+
+    if (type !== subtype) {
+      liAttrs.subtype = subtype
+    }
+
+    const field = m('li', liContents, liAttrs)
     const $li = $(field)
 
     AttachColWrapperHandler($li)
@@ -1612,7 +1646,7 @@ function FormBuilder(opts, element, $) {
     }
     optionInputs.push(m('a', null, removeAttrs))
 
-    return m('li', optionInputs).outerHTML
+    return m('li', optionInputs)
   }
 
   const cloneItem = function cloneItem(currentItem) {
@@ -1622,7 +1656,7 @@ function FormBuilder(opts, element, $) {
 
     const currentId = currentItem.attr('id')
     const type = currentItem.attr('type')
-    const cloneName = nameAttr({type: type})
+    const cloneName = nameAttr({ type: type })
     const $clone = currentItem.clone()
 
     $('.fld-name', $clone).val(cloneName)
@@ -1646,7 +1680,7 @@ function FormBuilder(opts, element, $) {
     $clone.attr('name', cloneName)
     $clone.addClass('cloned')
     const sortableOptions = $('.sortable-options', $clone)
-    sortableOptions.find('.option-selected').attr('name', nameAttr({type: 'grp-options'}) + '-options')
+    sortableOptions.find('.option-selected').attr('name', nameAttr({ type: 'grp-options' }) + '-options')
     sortableOptions.sortable()
 
     if (opts.typeUserEvents[type] && opts.typeUserEvents[type].onclone) {
@@ -1790,7 +1824,7 @@ function FormBuilder(opts, element, $) {
   })
 
   // update preview to label
-  $stage.on('input','.fld-label',({ target }) => {
+  $stage.on('input', '.fld-label', ({ target }) => {
     const value = target.value || target.innerHTML
     const label = closest(target, '.form-field').querySelector('.field-label')
     setElementContent(label, parsedHtml(value), config.opts.disableHTMLLabels)
@@ -1806,7 +1840,10 @@ function FormBuilder(opts, element, $) {
     const ttVal = $(e.target).val()
     if (ttVal !== '') {
       if (!closestToolTip.length) {
-        const tt = `<span class="tooltip-element" tooltip="${ttVal}">?</span>`
+        const tt = m('span', '?', {
+          className: 'tooltip-element',
+          tooltip: ttVal
+        })
         $('.field-label', $field).after(tt)
       } else {
         closestToolTip.attr('tooltip', ttVal).css('display', 'inline-block')
@@ -1850,7 +1887,7 @@ function FormBuilder(opts, element, $) {
     cloneControls = $cbUL.clone()
 
     cloneControls.hover(
-      function () {},
+      function () { },
       function () {
         cloneControls.remove()
       },
@@ -2434,7 +2471,7 @@ function FormBuilder(opts, element, $) {
     d.editorWrap.classList.remove('formbuilder-embedded-bootstrap')
   }
 
-  $stage[0].dispatchEvent(new Event('loaded', { bubbles: true, cancelable: false} ))
+  $stage[0].dispatchEvent(new Event('loaded', { bubbles: true, cancelable: false }))
 
   // Make actions accessible
   formBuilder.actions = {
diff --git a/src/js/helpers.js b/src/js/helpers.js
index 2bede83..5bc6dbe 100644
--- a/src/js/helpers.js
+++ b/src/js/helpers.js
@@ -95,7 +95,7 @@ export default class Helpers {
     }
 
     //Cancel the drop if an element that is not a li.input-control is dropped onto the stage (eg. an input-group container)
-    cancelArray.push(ui.item.is(':not(li.input-control,li.input-set-control)') && !ui.item.parent().hasClass('frmb-control') )
+    cancelArray.push(ui.item.is(':not(li.input-control,li.input-set-control)') && !ui.item.parent().hasClass('frmb-control'))
 
     if (opts.prepend) {
       cancelArray.push(_this.stopIndex === 0)
@@ -123,7 +123,7 @@ export default class Helpers {
     if (subtype !== types.type) {
       types.subtype = subtype
     }
-
+    
     return types
   }
 
@@ -282,8 +282,8 @@ export default class Helpers {
             fieldData = trimObj(fieldData)
 
             $field.find('.form-group.field-options').each((_, attribute) => {
-                const attributeName = attribute.getAttribute('name')
-                fieldData[attributeName] = _this.fieldOptionData(attribute)
+              const attributeName = attribute.getAttribute('name')
+              fieldData[attributeName] = _this.fieldOptionData(attribute)
             })
 
             formData.push(fieldData)
@@ -345,7 +345,7 @@ export default class Helpers {
     data.formData = doSave[this.config.opts.dataType](minify)
 
     // trigger formSaved event
-    stage.dispatchEvent(new Event('formSaved', { bubbles: true, cancelable: false} ))
+    stage.dispatchEvent(new Event('formSaved', { bubbles: true, cancelable: false }))
     return data.formData
   }
 
@@ -374,7 +374,8 @@ export default class Helpers {
     forEach(attrs, index => {
       const attr = attrs[index]
       const name = camelCase(attr.getAttribute('name'))
-      fieldData[name] = [
+
+      const attrVal = [
         [
           attr.attributes.contenteditable,
           () => (config.opts.dataType === 'xml' ? escapeHtml(attr.innerHTML) : attr.innerHTML),
@@ -384,6 +385,18 @@ export default class Helpers {
         [attr.attributes.multiple, () => $(attr).val()],
         [true, () => attr.value],
       ].find(([condition]) => !!condition)[1]()
+
+      // Check if name contains square brackets notation
+      const regex = /^([^[]+)\[([^[\]]+)\]$/
+      const matches = regex.exec(name)
+      if (matches) {
+        const [, objName, propName] = matches
+        // Create nested object if needed and set the property
+        fieldData[objName] ??= {}
+        fieldData[objName][propName] = attrVal
+      } else {
+        fieldData[name] = attrVal
+      }
     })
     return fieldData
   }
@@ -396,7 +409,7 @@ export default class Helpers {
     const _this = this
     const fieldClass = $field.attr('class')
     const field = $field[0]
-    if (fieldClass.includes('input-control')) {
+    if (!field || fieldClass.includes('input-control')) {
       return
     }
 
@@ -423,7 +436,7 @@ export default class Helpers {
 
     empty($prevHolder[0])
     $prevHolder[0].appendChild(preview)
-    preview.dispatchEvent(new Event('fieldRendered', { bubbles: true, cancelable: false} ))
+    preview.dispatchEvent(new Event('fieldRendered', { bubbles: true, cancelable: false }))
   }
 
   /**
@@ -513,7 +526,7 @@ export default class Helpers {
     }
     dialog && remove(dialog)
     document.removeEventListener('keydown', this.handleKeyDown, false)
-    document.dispatchEvent(new Event('modalClosed', { bubbles: true, cancelable: false} ))
+    document.dispatchEvent(new Event('modalClosed', { bubbles: true, cancelable: false }))
   }
 
   /**
@@ -630,10 +643,10 @@ export default class Helpers {
 
     document.body.appendChild(miniModal)
 
-    document.dispatchEvent(new Event('modalOpened', { bubbles: true, cancelable: false} ))
+    document.dispatchEvent(new Event('modalOpened', { bubbles: true, cancelable: false }))
 
     if (className.indexOf('data-dialog') !== -1) {
-      document.dispatchEvent(new Event('viewData', { bubbles: true, cancelable: false} ))
+      document.dispatchEvent(new Event('viewData', { bubbles: true, cancelable: false }))
     }
 
     return miniModal
@@ -718,7 +731,7 @@ export default class Helpers {
    */
   emptyStage(stage) {
     empty(stage).classList.remove('removing')
-    stage.dispatchEvent(new Event('stageEmptied', { bubbles: true, cancelable: false} ))
+    stage.dispatchEvent(new Event('stageEmptied', { bubbles: true, cancelable: false }))
     this.save()
   }
 
@@ -835,7 +848,7 @@ export default class Helpers {
     this.removeContainerProtection(rowContainer.attr('id'))
 
     this.config.opts.onCloseFieldEdit($editPanel[0])
-    this.d.stage.dispatchEvent(new Event('fieldEditClosed', { bubbles: true, cancelable: false} ))
+    this.d.stage.dispatchEvent(new Event('fieldEditClosed', { bubbles: true, cancelable: false }))
 
     const prevHolder = liContainer.find('.prev-holder')
     const resultsTimeout = setTimeout(() => {
@@ -905,7 +918,7 @@ export default class Helpers {
 
     this.formBuilder.currentEditPanel = $editPanel[0]
     this.config.opts.onOpenFieldEdit($editPanel[0])
-    this.d.stage.dispatchEvent(new Event('fieldEditOpened', { bubbles: true, cancelable: false} ))
+    this.d.stage.dispatchEvent(new Event('fieldEditOpened', { bubbles: true, cancelable: false }))
 
     $(document).trigger('fieldOpened', [{ rowWrapperID: rowWrapper.attr('id') }])
 
@@ -967,7 +980,7 @@ export default class Helpers {
       this.config.opts.notify.warning('fieldID required to remove specific fields.')
       this.config.opts.notify.warning('Removing last field since no ID was supplied.')
       this.config.opts.notify.warning('Available IDs: ' + availableIds.join(', '))
-      fieldID = availableIds[availableIds.length-1]
+      fieldID = availableIds[availableIds.length - 1]
     }
 
     const field = document.getElementById(fieldID)
@@ -996,7 +1009,7 @@ export default class Helpers {
       userEvents.onremove(field)
     }
 
-    this.d.stage.dispatchEvent(new Event('fieldRemoved', { bubbles: true, cancelable: false} ))
+    this.d.stage.dispatchEvent(new Event('fieldRemoved', { bubbles: true, cancelable: false }))
 
     if (fieldRowWrapper.length) {
       this.removeContainerProtection(`${fieldID}-cont`)
@@ -1191,7 +1204,11 @@ export default class Helpers {
       // html labels are not available using xml dataType
       opts.disableHTMLLabels = true
     }
-    _this.config.opts = Object.assign({}, { actionButtons: mergedActionButtons }, { fieldEditContainer }, opts)
+    _this.config.opts = {
+      actionButtons: mergedActionButtons,
+      fieldEditContainer,
+      ...opts
+    }
     return _this.config.opts
   }
 
@@ -1243,8 +1260,8 @@ export default class Helpers {
         const parseResult = _this.tryParseColumnInfo($field[0])
 
         //tmpCleanColumnInfo may be called multiple times, remove previous work to ensure we don't keep appending tmp- to class names
-        $field.attr('class', $field.attr('class').replace('__fb-tmp-col-', 'col-' ))
-        $field.attr('class', $field.attr('class').replace('__fb-tmp-row-', 'row-' ))
+        $field.attr('class', $field.attr('class').replace('__fb-tmp-col-', 'col-'))
+        $field.attr('class', $field.attr('class').replace('__fb-tmp-row-', 'row-'))
         $field.attr('class', $field.attr('class').replace('col-', '__fb-tmp-col-'))
         $field.attr('class', $field.attr('class').replace('row-', '__fb-tmp-row-'))
 
@@ -1459,7 +1476,7 @@ export default class Helpers {
    * @param {HTMLElement} field
    * @param {HTMLElement} wrapperRow
    */
-   syncFieldWithNewRow(field, wrapperRow) {
+  syncFieldWithNewRow(field, wrapperRow) {
     if (field) {
       const inputClassElement = $(field).find('.fld-className')
       const currentClassName = inputClassElement.val()?.trim()
@@ -1469,7 +1486,7 @@ export default class Helpers {
         const newRow = this.getRowClass(wrapperRow?.className ?? '')
         if (oldRow !== newRow) {
           if (oldRow) {
-            currentClasses = currentClasses.filter(function(obj) {
+            currentClasses = currentClasses.filter(function (obj) {
               return obj !== oldRow
             })
           }
diff --git a/src/js/utils.js b/src/js/utils.js
index f5c60ca..e4978dd 100644
--- a/src/js/utils.js
+++ b/src/js/utils.js
@@ -123,6 +123,19 @@ export const safeAttrName = name => {
   return safeAttr[name] || hyphenCase(name)
 }
 
+/**
+ * Converts a className string to a safe CSS class name by replacing bracket notation with hyphens.
+ * Transforms square bracket notation (e.g., "class[name]") to hyphen-separated format (e.g., "class-name").
+ * 
+ * @param {string} className - The className string to be sanitized
+ * @returns {string} The sanitized className with brackets replaced by hyphens
+ * 
+ * @example
+ * safeClassName('form[field]') // returns 'form-field'
+ * safeClassName('input[name][value]') // returns 'input-name-value'
+ */
+export const safeClassName = className => className.replace(/\[([^\]]+)\]/g, '-$1')
+
 /**
  * Convert strings into lowercase-hyphen
  *
@@ -131,7 +144,8 @@ export const safeAttrName = name => {
  */
 export const hyphenCase = str => {
   // eslint-disable-next-line no-useless-escape
-  str = str.replace(/[^\w\s\-]/gi, '')
+  str = str.replace(/[^\w\s\-\[\]]/gi, '')
+  str = safeClassName(str)
   str = str.replace(/([A-Z])/g, function ($1) {
     return '-' + $1.toLowerCase()
   })
@@ -196,7 +210,7 @@ export const getContentType = content => {
   return [
     ['array', content => Array.isArray(content)],
     ['node', content => content instanceof window.Node || content instanceof window.HTMLElement],
-    ['component', () => content && content.dom],
+    ['component', () => content?.dom],
     [typeof content, () => true],
   ].find(typeCondition => typeCondition[1](content))[0]
 }
@@ -216,7 +230,7 @@ export const markup = function (tag, content = '', attributes = {}) {
 
   const appendContent = {
     string: content => {
-      setElementContent(field,field.innerHTML + content)
+      setElementContent(field, field.innerHTML + content)
     },
     object: config => {
       const { tag, content, ...data } = config
@@ -236,7 +250,7 @@ export const markup = function (tag, content = '', attributes = {}) {
       contentType = getContentType(content)
       appendContent[contentType](content)
     },
-    undefined: () => {},
+    undefined: () => { },
   }
 
   for (const attr in attrs) {
@@ -451,7 +465,7 @@ export const removeFromArray = (val, arr) => {
  * @param  {String} [path='']   optional to load form
  * @return {Promise}       a promise
  */
-export const getScripts = (scriptScr, path= '') => {
+export const getScripts = (scriptScr, path = '') => {
   const $ = jQuery
   let _arr = []
 
@@ -492,7 +506,7 @@ export const isCached = (src, type = 'js') => {
  * @param  {String} [path='']
  * @return {void}
  */
-export const getStyles = (scriptScr, path= '') => {
+export const getStyles = (scriptScr, path = '') => {
   if (!Array.isArray(scriptScr)) {
     scriptScr = [scriptScr]
   }
@@ -586,15 +600,15 @@ export const closest = (el, cls) => {
  */
 export const mobileClass = () => {
   let mobileClass = ''
-  ;(a => {
-    if (
-      /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(
-        a,
-      )
-    ) {
-      mobileClass = 'formbuilder-mobile'
-    }
-  })(navigator.userAgent || navigator.vendor || window.opera)
+    ; (a => {
+      if (
+        /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(
+          a,
+        )
+      ) {
+        mobileClass = 'formbuilder-mobile'
+      }
+    })(navigator.userAgent || navigator.vendor || window.opera)
   return mobileClass
 }
 
diff --git a/src/sass/_stage.scss b/src/sass/_stage.scss
index 88bcbbc..db6060c 100644
--- a/src/sass/_stage.scss
+++ b/src/sass/_stage.scss
@@ -47,7 +47,9 @@
 
       border-color: #66afe9;
       outline: 0;
-      box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.1), 0 0 8px rgba(102, 175, 233, 0.6);
+      box-shadow:
+        inset 0 1px 1px rgba(0, 0, 0, 0.1),
+        0 0 8px rgba(102, 175, 233, 0.6);
 
       li {
         :hover {
@@ -64,7 +66,9 @@
     margin-left: 0;
     margin-bottom: 3px;
     background-color: $white;
-    transition: background-color 250ms ease-in-out, margin-top 400ms;
+    transition:
+      background-color 250ms ease-in-out,
+      margin-top 400ms;
 
     &.hidden-field {
       background-color: $hidden-field-bgcolor;
@@ -451,7 +455,8 @@
     }
   }
 
-  .radio-group-field, .select-field {
+  .radio-group-field,
+  .select-field {
     .sortable-options li:nth-child(2) .remove {
       opacity: 0;
       pointer-events: none;
@@ -469,7 +474,7 @@
     padding: 0;
 
     > li {
-      display: flex!important;
+      display: flex !important;
       flex-wrap: wrap;
 
       cursor: move;
@@ -559,10 +564,10 @@
     margin: 0;
     border: 1px solid $input-border-color;
 
-    .input-wrap {
+    .input-wrap,
+    .input-group-wrap {
       width: 81.33333333%;
       margin-left: 2%;
-      float: left;
 
       > input[type='checkbox'] {
         margin-top: 8px;
@@ -603,7 +608,6 @@
       .input-wrap {
         width: 100%;
         margin-left: 0;
-        float: none;
       }
     }
 
@@ -611,10 +615,6 @@
       width: auto;
     }
 
-    .btn-group {
-      margin-left: 2%;
-    }
-
     .false-label,
     label {
       .col-md-6 &,
@@ -628,7 +628,6 @@
         margin-bottom: 0;
         text-align: right;
         font-weight: 700;
-        float: left;
         white-space: nowrap;
         overflow: hidden;
         text-overflow: ellipsis;
@@ -637,7 +636,6 @@
         @include breakpoint('phone-lrg') {
           display: block;
           width: auto;
-          float: none;
           text-align: left;
 
           &.empty-label {
@@ -652,19 +650,39 @@
       &.roles-label,
       &.other-label {
         text-align: left;
-        float: none;
         margin-bottom: -3px;
         font-weight: 400;
         width: calc(81.3333% - 23px);
       }
     }
 
+    .input-group-wrap {
+      display: flex;
+      gap: 2em;
+
+      label {
+        width: auto;
+        display: block;
+        text-align: left;
+      }
+
+      .form-group {
+        flex-direction: column;
+        width: auto;
+      }
+
+      .input-wrap {
+        margin: 0;
+      }
+    }
+
     input {
       &.error {
         border: 1px solid $error;
       }
 
-      &.fld-maxlength, &.fld-rows {
+      &.fld-maxlength,
+      &.fld-rows {
         width: 75px;
       }
 
@@ -744,8 +762,12 @@
 
 .snackbar.show {
   visibility: visible;
-  -webkit-animation: fadein 0.5s, fadeout 0.5s 2.5s;
-  animation: fadein 0.5s, fadeout 0.5s 2.5s;
+  -webkit-animation:
+    fadein 0.5s,
+    fadeout 0.5s 2.5s;
+  animation:
+    fadein 0.5s,
+    fadeout 0.5s 2.5s;
 }
 
 @-webkit-keyframes fadein {
@@ -888,6 +910,13 @@
   left: -100px !important;
 }
 
-ol.options-no-select input[type=radio]{
+ol.options-no-select input[type='radio'] {
   display: none;
-}
\ No newline at end of file
+}
+
+.hidden {
+  display: none !important;
+}
+.flex {
+  display: flex !important;
+}
diff --git a/src/sass/form-builder.scss b/src/sass/form-builder.scss
index 189172f..b07ffbe 100644
--- a/src/sass/form-builder.scss
+++ b/src/sass/form-builder.scss
@@ -37,12 +37,16 @@
   }
 
   .form-elements,
-  .form-group,
   .multi-row span,
   textarea {
     display: block;
   }
 
+  .form-group {
+    display: flex;
+    flex-direction: row;
+  }
+
   .form-elements::after,
   .form-group::after {
     content: '.';
@@ -101,4 +105,4 @@
   }
 }
 
-@import 'dialog';
\ No newline at end of file
+@import 'dialog';



Test patch:
diff --git a/tests/form-builder.test.js b/tests/form-builder.test.js
index 2eed21e..941bdb3 100644
--- a/tests/form-builder.test.js
+++ b/tests/form-builder.test.js
@@ -593,6 +593,43 @@ describe('FormBuilder typeUserAttrs detection', () => {
     expect(input.length).toBe(1)
     expect(input.val()).toEqual(['green form-control','blue form-control'])
   })
+
+
+  test('can add typeUserAttr to custom subtype', async() => {
+    const config = {
+      fields: [
+        {
+          type: 'text',
+          subtype: 'custom',
+        }
+      ],
+      typeUserAttrs: {
+        'text-custom': {
+          testInput: {
+            label: 'Test Input',
+            value: 'hello',
+          }
+        },
+      },
+    }
+    const fbWrap = $('<div>')
+    const fb = await fbWrap.formBuilder(config).promise
+    fb.actions.setData([
+      {
+        type: 'text',
+      },
+      {
+        type: 'text',
+        subtype: 'custom',
+      }
+    ])
+
+    
+    const formFields = fbWrap.find('.form-field')
+    const input = formFields.find('.testInput-wrap')
+    expect(formFields.length).toBe(2)
+    expect(input.length).toBe(1)
+  })
 })
 
 describe('FormBuilder can return formData', () => {
@@ -905,4 +942,4 @@ describe('FormBuilder callbacks', () => {
     expect(Object.keys(cb.mock.calls[0][0])).toHaveLength(3)
     expect(Object.keys(cb.mock.calls[0][1])).toHaveLength(4)
   })
-})
\ No newline at end of file
+})
diff --git a/tests/utils.test.js b/tests/utils.test.js
index b1fcdf6..0cebe34 100644
--- a/tests/utils.test.js
+++ b/tests/utils.test.js
@@ -1,7 +1,7 @@
 const utils = require('./../src/js/utils.js')
 const { safeAttr, flattenArray, safeAttrName, hyphenCase, camelCase, bindEvents, attrString, nameAttr, markup,
   parsedHtml, escapeAttrs, getScripts, capitalize, addEventListeners, unique, escapeAttr, escapeHtml,
-  getAllGridRelatedClasses, subtract, safename, xmlParseAttrs, parseXML, firstNumberOrUndefined
+  getAllGridRelatedClasses, subtract, safename, xmlParseAttrs, parseXML, firstNumberOrUndefined, safeClassName
 } = require('../src/js/utils')
 
 describe('Test Util functions', () => {
@@ -58,14 +58,14 @@ describe('Test Util functions', () => {
 
   test('attrString', () => {
     //@TODO Test some invalid values. Passing a number for an attribute value will trigger an error with trim()
-    expect(attrString({type: 'number', min: '0', max: '10', class: 'input-control input-lg', 'data-target': 'someValue'}))
+    expect(attrString({ type: 'number', min: '0', max: '10', class: 'input-control input-lg', 'data-target': 'someValue' }))
       .toBe('type="number" min="0" max="10" class="input-control input-lg" data-target="someValue"')
   })
 
   test.todo('safeAttr')
 
   test('flattenArray', () => {
-    expect(flattenArray([[1,2],[3,[4,[5]]]])).toStrictEqual([ 1, 2, 3, 4, 5 ])
+    expect(flattenArray([[1, 2], [3, [4, [5]]]])).toStrictEqual([1, 2, 3, 4, 5])
   })
 
   test('safeAttrName', () => {
@@ -76,6 +76,12 @@ describe('Test Util functions', () => {
   test('hyphenCase', () => {
     expect(hyphenCase('dataTarget')).toBe('data-target')
     expect(hyphenCase('dataElementName')).toBe('data-element-name')
+    expect(hyphenCase('dataElement[size]')).toBe('data-element-size')
+  })
+
+  test('safeClassName', () => {
+    expect(safeClassName('form[field]')).toBe('form-field')
+    expect(safeClassName('input[name][value]')).toBe('input-name-value')
   })
 
   test('camelCase', () => {
@@ -90,7 +96,7 @@ describe('Test Util functions', () => {
   })
 
   test('unique', () => {
-    expect(unique(['orange','blue','orange','pink','red','red'])).toStrictEqual(['orange','blue','pink','red'])
+    expect(unique(['orange', 'blue', 'orange', 'pink', 'red', 'red'])).toStrictEqual(['orange', 'blue', 'pink', 'red'])
   })
 
   test.todo('closest')
@@ -98,7 +104,7 @@ describe('Test Util functions', () => {
   test('bindEvents', () => {
     const cb = jest.fn()
     const el = document.createElement('input')
-    bindEvents(el, {'change': cb, 'input': cb})
+    bindEvents(el, { 'change': cb, 'input': cb })
     el.dispatchEvent(new Event('input'))
     expect(cb.mock.calls).toHaveLength(1)
     el.dispatchEvent(new Event('change'))
@@ -139,15 +145,15 @@ describe('Test Util functions', () => {
   test.todo('merge')
 
   test('subtract', () => {
-    expect(subtract([1,2,3],[2])).toEqual([])
-    expect(subtract([2],[1,2,3])).toEqual([1,3])
-    expect(subtract(['remove-me'],['classA','classB','remove-me','classC'])).toEqual(['classA','classB','classC'])
+    expect(subtract([1, 2, 3], [2])).toEqual([])
+    expect(subtract([2], [1, 2, 3])).toEqual([1, 3])
+    expect(subtract(['remove-me'], ['classA', 'classB', 'remove-me', 'classC'])).toEqual(['classA', 'classB', 'classC'])
   })
 
   test('firstNumberOrUndefined', () => {
     expect(firstNumberOrUndefined(1)).toEqual(1)
-    expect(firstNumberOrUndefined(1,2,3)).toEqual(1)
-    expect(firstNumberOrUndefined(undefined,'',2,1)).toEqual(2)
+    expect(firstNumberOrUndefined(1, 2, 3)).toEqual(1)
+    expect(firstNumberOrUndefined(undefined, '', 2, 1)).toEqual(2)
   })
 })
 
@@ -155,7 +161,7 @@ describe('Test XML functions', () => {
   test('parseXml', () => {
     const xml = '<form-template xmlns="http://www.w3.org/1999/xhtml"><fields><field type="header" subtype="h1" label="Header" class-name="my-class"></field><field type="number" required="true" label="Number" class-name="form-control myClass" name="number-1696560201856-0" min="1" max="100" step="1" value="2"></field><field type="select" required="false" label="Select" class-name="form-control" name="select-1696560229465-0" multiple="false"><option label="Option 1" value="option-1" selected="selected">Option 1</option><option label="Option 2" value="option-2">Option 2</option><option label="Option 3" value="option-3">Option 3</option></field></fields></form-template>'
     const parsed = parseXML(xml)
-    expect(parsed).toEqual([{'className': 'my-class', 'label': 'Header', 'subtype': 'h1', 'type': 'header'}, {'className': 'form-control myClass', 'label': 'Number', 'max': '100', 'min': '1', 'name': 'number-1696560201856-0', 'required': true, 'step': '1', 'type': 'number', value: '2'}, {'className': 'form-control', 'label': 'Select', 'name': 'select-1696560229465-0', 'type': 'select', 'values': [{'label': 'Option 1', 'selected': 'selected', 'value': 'option-1'}, {'label': 'Option 2', 'value': 'option-2'}, {'label': 'Option 3', 'value': 'option-3'}]}])
+    expect(parsed).toEqual([{ 'className': 'my-class', 'label': 'Header', 'subtype': 'h1', 'type': 'header' }, { 'className': 'form-control myClass', 'label': 'Number', 'max': '100', 'min': '1', 'name': 'number-1696560201856-0', 'required': true, 'step': '1', 'type': 'number', value: '2' }, { 'className': 'form-control', 'label': 'Select', 'name': 'select-1696560229465-0', 'type': 'select', 'values': [{ 'label': 'Option 1', 'selected': 'selected', 'value': 'option-1' }, { 'label': 'Option 2', 'value': 'option-2' }, { 'label': 'Option 3', 'value': 'option-3' }] }])
   })
 
   test.todo('xmlParseAttrs')
@@ -165,13 +171,13 @@ describe('Test XML functions', () => {
 
 describe('Test markup function', () => {
   test('markup input with attributes', () => {
-    const input = markup('input', null, {type: 'text', 'value': 'simple input'})
+    const input = markup('input', null, { type: 'text', 'value': 'simple input' })
     expect(input).toBeInstanceOf(HTMLElement)
     expect(input.value).toBe('simple input')
   })
 
   test('markup span with text', () => {
-    const span = markup('span', 'Example span text', {class: 'small'})
+    const span = markup('span', 'Example span text', { class: 'small' })
     expect(span).toBeInstanceOf(HTMLElement)
     expect(span.tagName).toBe('SPAN')
     expect(span.textContent).toBe('Example span text')
@@ -179,7 +185,7 @@ describe('Test markup function', () => {
   })
 
   test('markup div with child span', () => {
-    const div = markup('div', {tag: 'span', content:'Example span text', class: 'small' }, {class: 'content'})
+    const div = markup('div', { tag: 'span', content: 'Example span text', class: 'small' }, { class: 'content' })
     expect(div).toBeInstanceOf(HTMLElement)
     expect(div.tagName).toBe('DIV')
     expect(div.classList).toContain('content')
@@ -190,17 +196,17 @@ describe('Test markup function', () => {
   })
 
   test('markup input with boolean attribute', () => {
-    const input = markup('input', null, {required: true,})
+    const input = markup('input', null, { required: true, })
     expect(input.getAttribute('required')).toBe('required')
-    const input2 = markup('input', null, {required: false,})
+    const input2 = markup('input', null, { required: false, })
     expect(input2.getAttribute('required')).toBeNull()
-    const textarea = markup('textarea', null, {contenteditable: true,})
+    const textarea = markup('textarea', null, { contenteditable: true, })
     expect(textarea.getAttribute('contenteditable')).toBe('true')
   })
 
   test('markup input with bound events', () => {
     const cb = jest.fn()
-    const input = markup('input', null, {type: 'text', 'events': {'change': cb, 'input': cb}})
+    const input = markup('input', null, { type: 'text', 'events': { 'change': cb, 'input': cb } })
     input.dispatchEvent(new Event('input'))
     expect(cb.mock.calls).toHaveLength(1)
     input.dispatchEvent(new Event('change'))
@@ -284,4 +290,4 @@ describe('enhancedBootstrap feature utils', () => {
   test('getAllGridRelatedClasses', () => {
     expect(getAllGridRelatedClasses('test row-1 col-md-6')).toEqual(['row-1', 'col-md-6'])
   })
-})
\ No newline at end of file
+})