For AI agents: the complete documentation index is available at https://nhanbe.id.vn/llms.txt, the full documentation bundle is available at https://nhanbe.id.vn/llms-full.txt, and this page is available as Markdown at https://nhanbe.id.vn/aem/component/dialog-validation.md.
AEM's Touch UI dialog framework includes a client-side validation system that lets you enforce business rules before an author saves a dialog. This prevents invalid data from reaching the JCR, reduces content errors, and improves the authoring experience by providing immediate, in-dialog feedback.
This guide covers the built-in validation system, how to register custom validators, and a collection of ready-to-use validation examples.
The validation framework is identical on AEM 6.5 and AEM as a Cloud Service - the same foundation-registry API, the same field attributes, and the same cq.authoring.dialog clientlib category. Nothing in this guide is platform-specific.
Understanding the trigger points keeps the UX predictable:
Trigger
What happens
Author clicks Done / submits
Every registered validator runs against every matching field. First failure blocks the save.
Field adapts to foundation-field and calls .checkValidity()
Only that field's validators run. Used to trigger validation imperatively from custom JS.
Author leaves a field (blur on most inputs)
Coral triggers revalidation on fields that have already been validated once - so the error state clears as soon as the author corrects the value.
Author changes a field
Coral re-runs validate() if the field is currently marked invalid.
Trigger validation manually on a single field:
var $field = $(".my-field");var api = $field.adaptTo("foundation-field");if (api && api.checkValidity) { var isValid = api.checkValidity(); // also calls show()/clear() as needed}
Every custom validator follows the same structure:
(function ($) { "use strict"; $(window) .adaptTo("foundation-registry") .register("foundation.validation.validator", { // CSS selector that matches the dialog fields to validate. // Use ~= so fields with multiple validators still match. selector: "[data-validation~='my-custom-rule'], [data-foundation-validation~='my-custom-rule']", // Runs when the dialog is submitted or \`checkValidity()\` is called. // Return undefined/nothing for valid, or an error message string for invalid. validate: function (el) { // .val() can return string, number, array (multiselect), or null. // Normalise first so downstream logic can assume string. var raw = $(el).val(); var value = (raw == null ? "" : String(raw)).trim(); if (value.length > 0 && !isValid(value)) { return "Your custom error message here."; } // Return nothing = valid (empty fields are handled by \`required\` if needed) }, // Renders the error indicator on the field show: function (el, message) { var $el = $(el); var fieldAPI = $el.adaptTo("foundation-field"); if (fieldAPI && fieldAPI.setInvalid) { fieldAPI.setInvalid(true); } var error = $el.data("foundation-validation.internal.error"); if (error) { error.content.innerHTML = message; if (!error.parentNode) { $el.after(error); error.show(); } } else { error = new Coral.Tooltip(); error.variant = "error"; error.interaction = "off"; error.placement = "bottom"; error.target = el; error.content.innerHTML = message; error.open = true; error.id = Coral.commons.getUID(); $el.data("foundation-validation.internal.error", error); $el.after(error); } }, // Clears the error indicator when the field becomes valid clear: function (el) { var $el = $(el); var fieldAPI = $el.adaptTo("foundation-field"); if (fieldAPI && fieldAPI.setInvalid) { fieldAPI.setInvalid(false); } var error = $el.data("foundation-validation.internal.error"); if (error) { error.hide(); error.remove(); $el.removeData("foundation-validation.internal.error"); } } });})(Granite.$);
Two selectors, two declaration paths A common misconception is that
data-validation and data-foundation-validation are AEMaaCS-vs-6.5. They are not - both attributes work on both platforms, and they map to the two different ways you can declare a validator on a field:
Makes a field required only when another field has a specific value. For example, "Alt text" is required only when an image is selected.
<image jcr:primaryType="nt:unstructured" sling:resourceType="granite/ui/components/coral/foundation/form/pathfield" fieldLabel="Image" name="./imagePath" rootPath="/content/dam"/><altText jcr:primaryType="nt:unstructured" sling:resourceType="granite/ui/components/coral/foundation/form/textfield" fieldLabel="Alt Text" fieldDescription="Required when an image is selected" name="./altText"> <granite:data jcr:primaryType="nt:unstructured" validation="conditional-required" depends-on="./imagePath"/></altText>
(function ($) { "use strict"; $(window) .adaptTo("foundation-registry") .register("foundation.validation.validator", { selector: "[data-validation~='conditional-required']", validate: function (el) { var $el = $(el); var dependsOnName = el.getAttribute("data-depends-on"); if (!dependsOnName) return; // Find the field this depends on (by its name attribute) var $dialog = $el.closest("coral-dialog"); var $dependsOn = $dialog.find("[name='" + dependsOnName + "']"); var dependsOnValue = $dependsOn.val(); var thisValue = $el.val(); // If the dependency field has a value but this field is empty if (dependsOnValue && dependsOnValue.toString().trim().length > 0 && (!thisValue || thisValue.toString().trim().length === 0)) { return "This field is required when '" + dependsOnName.replace("./", "") + "' is filled."; } }, show: function (el, message) { showError($(el), el, message); }, clear: function (el) { clearError($(el)); } });})(Granite.$);
Validating individual items in a composite multifield (e.g. "each link must have both a label and a URL") needs a different selector than the multifield itself. Target the inner input fields and walk up to the enclosing coral-multifield-item to reach sibling fields.
(function ($) { "use strict"; $(window) .adaptTo("foundation-registry") .register("foundation.validation.validator", { selector: "[data-validation~='link-pair'], [data-foundation-validation~='link-pair']", validate: function (el) { var $el = $(el); // Scope lookups to THIS multifield row - not the whole dialog. var $row = $el.closest("coral-multifield-item"); if ($row.length === 0) return; var role = el.getAttribute("data-link-role"); var value = ($el.val() || "").toString().trim(); // The other field in the same row var otherRole = role === "label" ? "url" : "label"; var $other = $row.find("[data-link-role='" + otherRole + "']"); var otherValue = ($other.val() || "").toString().trim(); // Both empty = OK (author hasn't filled the row yet). // Both filled = OK. // One filled, one empty = invalid. if (value.length === 0 && otherValue.length > 0) { return "Both label and URL are required for each link."; } }, show: function (el, message) { showError($(el), el, message); }, clear: function (el) { clearError($(el)); } });})(Granite.$);
Partner-field revalidation Changing the "URL" input doesn't automatically re-run validation on the "Label" input. After the author edits one side, trigger a
checkValidity() on the partner so a previously-invalid state clears:
$(document).on("change blur", "[data-link-role]", function () { var $row = $(this).closest("coral-multifield-item"); $row.find("[data-link-role]").each(function () { var api = $(this).adaptTo("foundation-field"); if (api && api.checkValidity) api.checkValidity(); });});
Some rules can only be checked on the server - "is this slug already taken?", "does this product SKU exist?", "is this URL reachable?". The Foundation API is synchronous, so the pattern is: cache the last server result on the element, debounce fetches, and return synchronously from validate().
<slug jcr:primaryType="nt:unstructured" sling:resourceType="granite/ui/components/coral/foundation/form/textfield" fieldLabel="Page Slug" fieldDescription="Must be unique across the site" name="./slug" required="{Boolean}true"> <granite:data jcr:primaryType="nt:unstructured" foundation-validation="unique-slug" check-url="/bin/myapp/validate/slug"/></slug>
(function ($) { "use strict"; var DEBOUNCE_MS = 350; $(window) .adaptTo("foundation-registry") .register("foundation.validation.validator", { selector: "[data-validation~='unique-slug'], [data-foundation-validation~='unique-slug']", validate: function (el) { var $el = $(el); var raw = $el.val(); var value = (raw == null ? "" : String(raw)).trim(); if (value.length === 0) return; // Cache the last server answer on the DOM element so we can return // synchronously. The async fetch (below) re-triggers validation // once the response arrives. var cached = $el.data("slugValidation"); if (cached && cached.value === value) { return cached.error; // may be undefined = valid } scheduleCheck($el, el, value); return; // no error yet - optimistic pass; fetch re-validates }, show: function (el, message) { showError($(el), el, message); }, clear: function (el) { clearError($(el)); } }); function scheduleCheck($el, el, value) { var pending = $el.data("slugValidation.timer"); if (pending) clearTimeout(pending); var timer = setTimeout(function () { var url = el.getAttribute("data-check-url"); $.ajax({ url: url, data: { slug: value }, dataType: "json", cache: false }).done(function (resp) { $el.data("slugValidation", { value: value, error: resp.available ? undefined : "Slug \"" + value + "\" is already in use." }); }).fail(function () { // Network / server failure: do NOT block the author from saving. $el.data("slugValidation", { value: value, error: undefined }); }).always(function () { // Re-trigger validation so the cached result takes effect. var api = $el.adaptTo("foundation-field"); if (api && api.checkValidity) api.checkValidity(); }); }, DEBOUNCE_MS); $el.data("slugValidation.timer", timer); }})(Granite.$);
Server-side validation is still required Client-side async checks are a UX optimisation, not a security boundary. The servlet that handles the save (or a
@PostConstruct hook in the Sling Model) must re-validate uniqueness - a malicious or stale client can submit whatever it wants. See Server-Side Validation below.
foundation.* keys shipped by Granite UI are an implementation detail of the platform and occasionally get added to or renamed between versions. If a key above doesn't exist in your release, grep the granite/ui/components/coral/foundation/clientlibs/foundation/js/validations folder (or its successor) for the current list.
Client-side validation improves the authoring experience, but should never be the only layer of defence. Authors can bypass client-side checks (e.g. by editing the JCR directly, using CRXDE, or importing content packages).
For critical business rules, add server-side validation in your Sling Model:
@PostConstructprotected void init() { if (StringUtils.isNotBlank(imagePath) && StringUtils.isBlank(altText)) { LOG.warn("Teaser at {} has an image without alt text", resource.getPath()); // Optionally set a flag for the HTL template to show a warning in edit mode hasValidationWarning = true; }}
Include both selector variants so your validator fires regardless of whether the field uses validation="my-rule" or <granite:data foundation-validation="my-rule"/>:
Use the ~= space-separated-word selector - Granite renders multiple validators as a space-delimited list, and = would miss fields with more than one validator attached.
If a field is not required, your validator should allow empty values. Only validate when the field actually has content:
validate: function (el) { var value = $(el).val().toString().trim(); // Only validate non-empty values - empty is OK for optional fields if (value.length > 0 && !isValid(value)) { return "Error message"; }}
Validators on fields inside multifields run for every item. Make sure your validator logic handles the DOM structure correctly - the el parameter is the specific field instance, not the multifield container.
The Foundation Validation API is designed for single-field validation. For complex cross-field rules (e.g. "end date must be after start date"), use the coral-dialog submit event instead:
$(document).on("click", ".cq-dialog-submit", function (e) { var $dialog = $(this).closest("coral-dialog"); var startDate = $dialog.find("[name='./startDate']").val(); var endDate = $dialog.find("[name='./endDate']").val(); if (startDate && endDate && new Date(endDate) <= new Date(startDate)) { e.preventDefault(); e.stopPropagation(); // Show a Foundation UI notification var ui = $(window).adaptTo("foundation-ui"); ui.alert("Validation Error", "End date must be after start date.", "error"); }});
Calling fieldAPI.setInvalid(true) adds the is-invalid class to the field element and its surrounding coral-Form-fieldwrapper. Coral ships default red styling for these classes, but you can override them in your authoring clientlib if your brand or accessibility review needs stronger affordances.
Add a css.txt next to js.txt in the clientlib folder and list css/validation.css, then include css in the .content.xml categories / dependencies as needed. Remember: this CSS only runs inside dialogs, so it can't leak onto published pages.
Show which validator is failing When debugging dialogs with many overlapping validators, surface the rule name on the wrapper - it survives re-renders and is easy to spot in the inspector:
show: function (el, message) { $(el).closest("coral-Form-fieldwrapper").attr("data-failed", "my-rule"); /* ... */},clear: function (el) { $(el).closest("coral-Form-fieldwrapper").removeAttr("data-failed"); /* ... */}
A validation error that is only communicated with a red border is invisible to screen-reader users. Calling fieldAPI.setInvalid(true) already does most of the work - Coral sets aria-invalid="true" on the control - but make the rest of the experience accessible too:
Always show a text message, never colour alone. The Coral error Tooltip you create in show() carries the message text; keep it specific (the WCAG "error identification" success criterion).
Associate the message with the field. Give the tooltip an id (the examples already use Coral.commons.getUID()) and point the field at it with aria-describedby so assistive tech reads the error together with the label:
show: function (el, message) { // ... build the Coral.Tooltip with error.id = Coral.commons.getUID() ... el.setAttribute("aria-describedby", error.id);},clear: function (el) { // ... remove the tooltip ... el.removeAttribute("aria-describedby");}
Move focus to the first invalid field on submit so keyboard users land on the problem rather than hunting for it. checkValidity() plus el.focus() on the first failure is enough.
Don't disable the submit button to "prevent" invalid saves - a disabled control gives no feedback about why. Let validation run and report the error instead.
Zero matches = your field attribute isn't what you think it is. Inspect the rendered HTML - the validation property in the dialog XML becomes the data-validation attribute on the rendered input, but granite:data/foundation-validation becomes data-foundation-validation.
Instead of clicking "Done" and chasing through every validator, trigger only the one you care about:
var api = $("[name='./myField']").adaptTo("foundation-field");api.checkValidity(); // runs validators, triggers show()/clear()api.isValid(); // reads the current state
Multiple validator paths assume el.adaptTo("foundation-field") returns a truthy object with setInvalid and checkValidity. On non-form containers (fieldsets, multifields wrapped in custom components) this can be null. Always guard:
var api = $(el).adaptTo("foundation-field");if (api && api.setInvalid) api.setInvalid(true);