Manipulating element class names and attrs

The attrs-from-config hook

TODO

Note that:

  • the inputElement namespace is applied to both input element,
  • the inputField namespace is only applied to the input element in the built in Input field,
  • the powerDatePickerTimeSelectorInput namespace is only applied to the input elements in the built in Power Datepicker field.

This means that configuration using the inputElement namespace will affect both of the above inputs. Conversely, configuration using the inputField namespace will only apply to the input element in the Input field.

Using these namespaces, we can configure the class names that should be added to the rendered element, or use functions to manipulate the rendered element directly. This is done via the attrsFromConfig object, which can have two properties, classNames and attrFunctions.

Both classNames and attrFunctions are objects whose keys refer to the namespaces passed to the attrs-from-config modifier.

attrsFromConfig: {classNames: {inputElement: ['input', 'padding-s'], // Will replace the inherited value of classNames.inputElementinputField: ['$inherited', '$validationClassNames', '$validationPseudoClasses', 'input-field'] // Will merge with the inherited value of classNames.inputElement, because `$inherited` is included.}attrFunctions: {inputElement(element, changesetWebform, formField, _classNameSettings) { // Will always replace the inherted value of attrFunctions.inputElementif (formField.fieldType === 'email') { element.classList.add('email-input'); } } } }

Debug mode

Switichig on debuig mode in general config, or for a specific instance of a ChangesetWebform component, will show the namespaces applied to the various elements in the DOM as class names. This allows you to easily determine which namespace should be used to manipulate the class names or other attrinutes of an element.

See the debug mode docs for more.

Default config

The addon implements defaults which can be found in the attrsFromConfig object at ./utils/addon-defaults.js, shown in the snippet below.

  attrsFromConfig: {
    classNames: {
      // Generic element classes
      inputElement: ['input'],
      textareaElement: ['form-control', 'validation-area', '$validationClassNames', '$validationPseudoClasses'],
      labelElement: null,
      checkboxElement: ['form-check-input', '$validationClassNames', '$validationPseudoClasses'],
      radioButtonElement: ['form-check-input', '$validationClassNames', '$validationPseudoClasses'],
      buttonElement: ['btn', 'd-inline-flex'],
      buttonIcon: ['me-2', 'button-icon'],
      // Generic field classes- apply to all fields
      disabledField: ['disabled'],
      focussedField: ['focussed'],
      fieldWrapper: ['cwf-field', 'mb-3'],
      fieldControls: ['field-controls', '$validationClassNames'],
      fieldLabel: ['form-label'],
      fieldDescription: ['cwf-field-description'],
      requiredField: ['required'],
      optionsWrapper: ['cwf-field-options'],
      // Generic validation related classes - apply to all fields
      validClassNames: ['is-valid'],
      invalidClassNames: ['is-invalid'],
      validationErrors: ['cwf-field-errors', 'invalid-feedback', 'form-text'],
      validationError: ['cwf-field-error'],
      fieldValidates: ['validates'],
      validatedField: ['was-validated'],
      // Form wrapper
      formWrapper: ['cwf-form-wrapper'],
      formElement: ['cwf-form', '$validationClassNames'],
      // Form action element element classes
      formFields: ['form-fields'],
      formActions: ['form-actions', 'mt-4'],
      submitButton: ['btn-primary', 'form-submit-button', 'btn-lg'],
      submitButtonIcon: [],
      // Request in flight
      requestInFlightIcon: ['request-in-flight', 'spinner-border', 'spinner-border-sm', 'ms-2'],
      resetFormButtonIcon: [],
      clearFormButtonIcon: [],
      resetFormButton: ['btn-warning', 'btn-lg'],
      clearFormButton: ['btn-dark', 'btn-lg'],
      // fieldType === 'input
      fieldWrapperInput: ['cwf-field-input'],
      inputField: ['form-control', 'validation-area', '$validationClassNames', '$validationPseudoClasses'],
      // fieldType === 'clonable'
      cloneGroupWrapper: ['cwf-clone-group'],
      cloneWrapper: ['cwf-clone', 'mb-3'],
      cloneGroupItems: ['cwf-clone-group-items', '$validationClassNames'],
      cloneFieldControls: ['cwf-clone-field-controls', '$validationClassNames'],
      cloneGroupActions: ['cwf-clone-group-actions', 'margin-y-lg'],
      maxClonesReached: ['cwf-max-clones-reached'],
      addCloneButton: ['btn-outline-secondary'],
      removeCloneButton: ['hover-pointer', 'remove-clone', 'clone-actions', 'width-xl', 'p-2', 'pb-0'],
      addCloneButtonIcon: [],
      removeCloneButtonIcon: ['fill-gray-medium', 'remove-clone-icon'],
      // fieldType === 'powerSelect'
      powerSelectTrigger: ['form-control', '$validationClassNames', 'validation-area'],
      powerSelectDropdown: [],
      // fieldType === 'powerSelectCheckboxes'
      powerSelectCheckboxesTrigger: ['form-control', '$validationClassNames', 'validation-area'],
      // fieldType === powerDatePicker
      powerDatePickerTriggerWrapper: ['form-control', 'input', '$validationClassNames'],
      powerDatePickerTriggerInput: null,
      powerDatePickerDropdown: ['bg-transparent'],
      powerDatePickerDropdownInner: ['bg-white', 'p-2', 'border', 'rounded', 'd-flex', 'flex-column', 'align-items-center'],
      powerDatePickerCalendar: null,
      powerDatePickerTimeSelectorContainer: ['cwf-time-selector', 'mt-2'],
      powerDatePickerTimeSelectorInput: ['inline'],
      powerDatePickerClearButton: ['clear-date-time-button', 'icon'],
      powerDatePickerCalendarIcon: ['calendar-icon', 'icon'],
      powerDatePickerCalendarNav: ['d-flex', 'align-items-center'],
      powerDatePickerCalendarDays: null,
      // fieldType === 'clicker';
      clickerElement: ['cwf-clicker'],
      // fieldType === ('singleCheckBox' || 'checkBoxGroup)
      checkboxLabel: ['form-check-label'],
      labelledCheckbox: ['form-check', 'labelled-checkbox'],
      // fieldType === 'radioButtonGroup
      labelledRadioButton: ['form-check', 'labelled-radio-button'],
      radioButtonLabel: ['form-check-label'],
    },
    attrFunctions: {
      focussedField(element, changesetWebform, formField) {
        const classNameSettings = changesetWebform.formSchemaWithDefaults.classNameSettings;
        if (formField.focussed) {
          element.classList.add(...classNameSettings.focussedField);
        } else {
          element.classList.remove(...classNameSettings.focussedField);
        }
      },
      validatedField(element, changesetWebform, formField) {
        const classNameSettings = changesetWebform.formSchemaWithDefaults.classNameSettings;
        if (formField.showValidation) {
          element.classList.add(...classNameSettings.validatedField);
        } else {
          element.classList.remove(...classNameSettings.validatedField);
        }
      },
      disabledField(element, changesetWebform, formField) {
        const classNameSettings = changesetWebform.formSchemaWithDefaults.classNameSettings;
        if (formField.disabled) {
          element.classList.add(...classNameSettings.disabledField);
        } else {
          element.classList.remove(...classNameSettings.disabledField);
        }
      },
    },
  },

Overriding default config

In order to override these defaults, an attrsFromConfig object can included in the following places, listed in order of specificity.

  1. App level configuration - changesetWebformsDefaults.attrsFromConfig in services/ember-changeset-webforms.js (Applied throuought the app wide config)
  2. App level configuration - field type specific - Any of the field types changesetWebformsDefaults.fieldTypes in services/ember-changeset-webforms.js (Applied throuought the app wide config)
  3. Form level configuration - @formSchema.attrsFromConfig (Applied to a specific instance of a ChangesetWebform component)
  4. Form level configuration - field type specific - Any of the field types defined in @formSchema.fieldSettings.fieldTypes (Applied to all fields with the specified fieldType, within specific instance of a ChangesetWebform component)
  5. Field level configuration - field.attrsFromConfig where field is the definition of a specidfic field in @formSchema.fields.

At each level, the merged value of attrsFromConfig will be inherited, so you only need to include config for the namespaces that you would like to override.

Examples

1. App level configuration

The example below shows how to the ember-changeset-webforms service can be used to add the class name app-wide-label-element-class to all label elements throughout the app.

import ChangesetWebform from 'react-changeset-webforms/src/components/ChangesetWebform.jsx';

const formSchema = {
  formSettings: {
    formName: 'attrFunctions',
  },
  attrFunctions: {
    submitButton(element, changesetWebform) {
      if (changesetWebform.formSettings.requestInFlight) {
        element.classList.replace('btn-primary', 'btn-success');
      } else {
        element.classList.replace('btn-success', 'btn-primary');
      }
    },
  },
  fields: [
    {
      fieldId: 'name',
      fieldType: 'input',
      fieldLabel: 'Name',
      attrsFromConfig: {
        attrFunctions: {
          fieldLabel(element, _changesetWebform, _formField) {
            if (element.textContent.includes('Label loaded at')) {
              return;
            }
            element.textContent = `${element.textContent} (Label loaded at ${new Date().toLocaleTimeString()})`;
            ('This value is set by an attr function from the form field config.');
          },
        },
      },
    },
  ],
};

function submit() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, 1500);
  });
}

export default function AttrFunctions() {
  return (
    <ChangesetWebform
      formSchema={formSchema}
      submitData={submit}
    />
  );
}

2. App level configuration - field type specific

The example below shows how the ember-changeset-webforms service can be used to add the class name app-wide-radio-button-group-label-element-class to all label elements within fields with type radioButtonGroup throughout the app.

Thus, the class name is added to all radio option labels throughout the app.


The built in fields have the following fieldTypes:

3. Form level configuration

The example below show how the attrsFromConfig property of a formSchema can be used to add the class name form-wide-label-class to all labels throughout one instance of a ChangesetWebform component.

import ChangesetWebform from 'react-changeset-webforms/src/components/ChangesetWebform.jsx';

const formSchema = {
  formSettings: {
    formName: 'formClassNames',
    hideSubmitButton: true,
  },
  attrsFromConfig: {
    classNames: {
      labelElement: ['$inherited', 'form-wide-label-class'],
    },
  },
  fields: [
    {
      fieldId: 'name',
      fieldType: 'input',
      fieldLabel: 'Name',
    },
    {
      fieldId: 'radioButtons1',
      fieldType: 'radioButtonGroup',
      fieldLabel: 'Basic usage',
      options: [
        {
          label: 'Option 1',
          value: '1',
        },
        {
          label: 'Option 2',
          value: '2',
        },
      ],
    },
  ],
};

export default function FormWideClassSettings() {
  return <ChangesetWebform formSchema={formSchema} />;
}

4. Form level configuration - field type specific

The example below show how the attrsFromConfig property of a specific field type in formSchema.formSettings.fieldTypes can be used to add the class name form-wide-radio-button-label-el-class to all label elements within fields with type radioButtonGroup throughout one instance of a ChangesetWebform component.

import ChangesetWebform from 'react-changeset-webforms/src/components/ChangesetWebform.jsx';

const formSchema = {
  formSettings: {
    formName: 'fieldTypeWithinFormClassnames',
    hideSubmitButton: true,
  },
  fieldSettings: {
    fieldTypes: [
      {
        fieldType: 'input',
        attrsFromConfig: {
          classNames: {
            labelElement: ['$inherited', 'form-wide-label-class'],
          },
        },
      },
      {
        fieldType: 'radioButtonGroup',
        attrsFromConfig: {
          classNames: {
            labelElement: ['form-wide-radio-button-label-el-class'],
            radioButtonLabel: [],
          },
        },
      },
    ],
  },
  fields: [
    {
      fieldId: 'name',
      fieldType: 'input',
      fieldLabel: 'Name',
    },
    {
      fieldId: 'radioButtons1',
      fieldType: 'radioButtonGroup',
      fieldLabel: 'Basic usage',
      options: [
        {
          label: 'Option 1',
          value: '1',
        },
        {
          label: 'Option 2',
          value: '2',
        },
      ],
    },
    {
      fieldId: 'radioButtons2',
      fieldType: 'radioButtonGroup',
      fieldLabel: 'A second field',
      options: [
        {
          label: 'Option 1',
          value: '1',
        },
        {
          label: 'Option 2',
          value: '2',
        },
      ],
    },
  ],
};

export default function FieldTypeWithinFormSettings() {
  return <ChangesetWebform formSchema={formSchema} />;
}

5. Field level configuration

The example below shows:

  • how the attrsFromConfig property of the name field can be used to add the class name class-for-the-field-label-of-this-field to the field label for that field,
  • how the attrsFromConfig property of the radioButtons1 field can be used to add the class name class-for-all-label-els-in-this-field to the field label for that field.
import ChangesetWebform from 'react-changeset-webforms/src/components/ChangesetWebform.jsx';

const formSchema = {
  formSettings: {
    formName: 'fieldClassNames',
    hideSubmitButton: true,
  },
  fields: [
    {
      fieldId: 'name',
      fieldType: 'input',
      fieldLabel: 'Name',
      attrsFromConfig: {
        classNames: {
          fieldLabel: ['$inherited', 'class-for-the-field-label-of-this-field'],
        },
      },
    },
    {
      fieldId: 'radioButtons1',
      fieldType: 'radioButtonGroup',
      fieldLabel: 'Basic usage',
      attrsFromConfig: {
        classNames: {
          labelElement: ['class-for-all-label-els-in-this-field'],
          radioButtonLabel: [],
        },
      },
      options: [
        {
          label: 'Option 1',
          value: '1',
        },
        {
          label: 'Option 2',
          value: '2',
        },
      ],
    },
  ],
};

export default function FieldSpecificClassSettings() {
  return <ChangesetWebform formSchema={formSchema} />;
}

Special classNames values

The class names $inherited, $validationClassNames and $validationPseudoClasses have special meanings, outlined below. They will never be included in a final listed of class names on a browser element.

Inheriting vs overriding class names settings from higher levels

Include $inherited in the array of class names for an element as a placeholder for the class names inherited from the next level up.

import ChangesetWebform from 'react-changeset-webforms/src/components/ChangesetWebform.jsx';

const formSchema = {
  formSettings: {
    formName: 'inheritClassNames',
    hideSubmitButton: true,
  },
  attrsFromConfig: {
    classNames: {
      labelElement: ['$inherited', 'form-wide-label-class'],
    },
  },
  fields: [
    {
      fieldId: 'name',
      fieldType: 'input',
      fieldLabel: 'Name',
    },
  ],
};

export default function InheritClassSettings() {
  return <ChangesetWebform formSchema={formSchema} />;
}

Alternatively, exclude $inherited in order to completely override the value.

Note that the class form-label is still included. This is because it is included via the fieldLabel property, and it is the labelElement property which has been overridden.

import ChangesetWebform from 'react-changeset-webforms/src/components/ChangesetWebform.jsx';

const formSchema = {
  formSettings: {
    formName: 'overrideClassNames',
    hideSubmitButton: true,
  },
  attrsFromConfig: {
    classNames: {
      labelElement: ['form-wide-label-class'],
    },
  },
  fields: [
    {
      fieldId: 'name',
      fieldType: 'input',
      fieldLabel: 'Name',
    },
  ],
};

export default function OverrideClassSettings() {
  return <ChangesetWebform formSchema={formSchema} />;
}

Including dynamic validation class names

The class names applied to elements as a result of wither passing or failing validation are defined in the validClassNames and invalidClassNames properties respectively. The defaults are is-valid and is-invalid.

You may wish to customise which elements within a form field receive those classes once a field has been validated. This can be done by adding $validationClassNames as an class name for any element which should receive those class names.

import ChangesetWebform from 'react-changeset-webforms/src/components/ChangesetWebform.jsx';

const formSchema = {
  formSettings: {
    formName: 'validationClassNames',
    hideSubmitButton: true,
  },
  attrsFromConfig: {
    classNames: {
      fieldLabel: ['$inherited', '$validationClassNames'],
    },
  },
  fields: [
    {
      fieldId: 'name',
      fieldType: 'input',
      fieldLabel: 'Name',
      showValidationWhenFocussed: true,
      // validatesOn: ['keyUp'],
      validationRules: [
        {
          validationMethod: 'validatePresence',
          arguments: true,
        },
      ],
    },
  ],
};

export default function ValidationClassSettings() {
  return <ChangesetWebform formSchema={formSchema} />;
}

Enabling the :valid and :invalid pseudo classes with $validationPseudoClasses

If $validationPseudoClasses is one of the items in the array of class names, and the element in question is a form element, then the data-set-custom-validity attribute will set to true on the element.

As a result, whenever the field is validated, all elements within that field with data-set-custom-validity=true will have their setCustomValidity method called with any validation errors. This allows the browser to add the :valid or :invalid pseudo classes to the elements as appropriate.

TODO add code that shows class names in the DOM.

Manipulting DOM attrs with a function

If you would one or more class names for an element to be dynamic, you can add a method to the classNames object to manipulate the final array of class names for a particular class name property.

The name of the method should be the the property name with Fn appended. For example the methos at classNames.submitButtonFn will be applied to the classes for the submitButton property. It must return an array of strings.

  • element =: the DOM element
  • changesetWebform =: the changesetWebform instance.
  • formField =: the relevant form field object, where the relevant element is within a form field.
  • classNameSettings =: the merged class name settings as they apply to an instance of changesetWebform or a form field.

The example below removes the class btn-primary adds the class btn-success to the submit button if formSettings.requestInFlight is true. This results in a green background.

Note that what you return from the method will completely override the class name settings for the property.

If you would like to keep those classes, then always include the contents of the first argument (classNamesArray) in the response.

The method will be run each time the class-names-from-config helper is instered or updated in the relevant template. As it receives changesetWebform and formField as arguments, this will occur whenever a getter or tracked property is updated on opne of those to class instances.

import ChangesetWebform from 'react-changeset-webforms/src/components/ChangesetWebform.jsx';

const formSchema = {
  formSettings: {
    formName: 'attrFunctions',
  },
  attrFunctions: {
    submitButton(element, changesetWebform) {
      if (changesetWebform.formSettings.requestInFlight) {
        element.classList.replace('btn-primary', 'btn-success');
      } else {
        element.classList.replace('btn-success', 'btn-primary');
      }
    },
  },
  fields: [
    {
      fieldId: 'name',
      fieldType: 'input',
      fieldLabel: 'Name',
      attrsFromConfig: {
        attrFunctions: {
          fieldLabel(element, _changesetWebform, _formField) {
            if (element.textContent.includes('Label loaded at')) {
              return;
            }
            element.textContent = `${element.textContent} (Label loaded at ${new Date().toLocaleTimeString()})`;
            ('This value is set by an attr function from the form field config.');
          },
        },
      },
    },
  ],
};

function submit() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, 1500);
  });
}

export default function AttrFunctions() {
  return (
    <ChangesetWebform
      formSchema={formSchema}
      submitData={submit}
    />
  );
}