Manipulating element class names and attrs
The attrs-from-config hook
TODO
Note that:
- the
inputElementnamespace is applied to both input element, - the
inputFieldnamespace is only applied to the input element in the built in Input field, - the
powerDatePickerTimeSelectorInputnamespace 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.
- App level configuration -
changesetWebformsDefaults.attrsFromConfiginservices/ember-changeset-webforms.js(Applied throuought the app wide config) - App level configuration - field type specific - Any of the field types
changesetWebformsDefaults.fieldTypesinservices/ember-changeset-webforms.js(Applied throuought the app wide config) - Form level configuration -
@formSchema.attrsFromConfig(Applied to a specific instance of aChangesetWebformcomponent) - Form level configuration - field type specific - Any of the field types defined in
@formSchema.fieldSettings.fieldTypes(Applied to all fields with the specifiedfieldType, within specific instance of aChangesetWebformcomponent) - Field level configuration -
field.attrsFromConfigwhere 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
attrsFromConfigproperty of thenamefield can be used to add the class nameclass-for-the-field-label-of-this-fieldto the field label for that field, - how the
attrsFromConfigproperty of theradioButtons1field can be used to add the class nameclass-for-all-label-els-in-this-fieldto 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.
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 elementchangesetWebform=: 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} /> ); }