From 6b4b70f489d271b48867b63ee9fc92abfa537f8c Mon Sep 17 00:00:00 2001 From: Ben Merritt Date: Wed, 24 Jul 2024 17:00:57 -0700 Subject: [PATCH] fixup! feat(input): make overriding browsers' validation messages easier --- .../oruga/src/composables/useInputHandler.ts | 194 +++++++++++------- 1 file changed, 125 insertions(+), 69 deletions(-) diff --git a/packages/oruga/src/composables/useInputHandler.ts b/packages/oruga/src/composables/useInputHandler.ts index 027e8e6d5..cd4f6e281 100644 --- a/packages/oruga/src/composables/useInputHandler.ts +++ b/packages/oruga/src/composables/useInputHandler.ts @@ -2,6 +2,7 @@ import { nextTick, ref, computed, + triggerRef, watch, watchEffect, type ExtractPropTypes, @@ -165,8 +166,11 @@ export function useInputHandler( */ function checkHtml5Validity(): void { if (!props.useHtml5Validation) return; - if (!element.value) return; + + triggerRef(forceValidationReactivityUpdate); + updateCustomValidationMessage(); + if (element.value.validity.valid) { setFieldValidity(null, null); isValid.value = true; @@ -225,23 +229,57 @@ export function useInputHandler( emits("invalid", event); } + /** + * Provides a way to force the watcher on `updateCustomValidationMessage` to re-run + * + * There are some cases (e.g. changes to the element's validation attributes) that can + * force changes to the element's `validityState`, which isn't a reactive property. + * We call `updateCustomValidationMessage` in cases where we expect relevant changes + * to `validityState`. That's enough to update the DOM correctly. However, changes in + * `validityState` are likely to cause the function version of `props.customValidity` + * to execute different branches that may have different dependencies on reactive + * properties. The `watchEffect` call on `updateCustomValidationMessage` needs to know + * about those new reactive dependencies, and the only way to inform it of them is to + * re-run `updateCustomValidationMessage` within the watcher's context. + * + * This does mean that if you need to update the DOM element's validity state _immediately_, + * you'll need to call updateCustomValidationMessage directly (to make the change happen now + * rather than waiting for the reactivity system) _and_ trigger this `ref` (to register + * reactive dependencies), so `updateCustomValidationMessage` will run twice for those updates. + */ + const forceValidationReactivityUpdate = ref(null); + + /** + * Propagates any custom constraint validation message to the underlying DOM element + */ + function updateCustomValidationMessage(): void { + forceValidationReactivityUpdate.value; + if (!(props.useHtml5Validation ?? true)) { + return; + } + const element = maybeElement.value; + if (!isDefined(element)) { + return; + } + const validity = props.customValidity ?? ""; + if (typeof validity === "string") { + element.setCustomValidity(validity); + } else { + // The custom validation message may depend on `element.validity`, + // which isn't a reactive property. `element.validity` depends on + // the element's current value and the native constraint validation + // attributes. We can use `props.modelValue` as a reasonable proxy + // for the DOM element's value, and `props.modelValue` _is_ reactive, + // so we can use it to help solve that reactivity problem. + // When `props.modelValue` is retrieved in this function, even if we + // throw the value away, the `watchEffect` call on this function will + // register it as a dependency. + props.modelValue; + element.setCustomValidity(validity(element.validity)); + } + } + if (!isSSR) { - /// Propagates any custom constraint validation message to the underlying DOM element - const updateCustomValidationMessage = (): void => { - if (!(props.useHtml5Validation ?? true)) { - return; - } - const element = maybeElement.value; - if (!isDefined(element)) { - return; - } - const validity = props.customValidity ?? ""; - if (typeof validity === "string") { - element.setCustomValidity(validity); - } else { - element.setCustomValidity(validity(element.validity)); - } - }; // Note that using watchEffect will implicitly pick up any reactive dependencies used // inside props.customValidity, which should help the computed message stay up to date. watchEffect(updateCustomValidationMessage); @@ -258,19 +296,14 @@ export function useInputHandler( // Since we're no longer managing the element, we might // as well clean up any custom validity we set up. oldElement?.setCustomValidity(""); - updateCustomValidationMessage(); } else if (oldUseValidation && !newUseValidation) { newElement?.setCustomValidity(""); } }, ); - // Respond to attribute changes that might clear constraint validation errors. - // For instance, removing the `required` attribute on an empty field means that it's no - // longer invalid, so we might as well clear the validation message. - // In order to follow our usual convention, we won't add new validation messages - // until the next time the user interacts with the control. - + // Respond to attribute changes that could affect validation messages. + // // Technically, having the `required` attribute on one element in a radio button // group affects the validity of the entire group. // See https://html.spec.whatwg.org/multipage/input.html#radio-button-group. @@ -280,60 +313,83 @@ export function useInputHandler( // (We're also expecting the use of radio buttons with our default validation message handling // to be fairly uncommon because the overall visual experience is clunky with such a configuration.) const onAttributeChange = (): void => { - updateCustomValidationMessage(); - if (!isValid.value) checkHtml5Validity(); + if (!isValid.value) { + // This should update the custom validation message. + checkHtml5Validity(); + } else { + triggerRef(forceValidationReactivityUpdate); + } }; let validationAttributeObserver: MutationObserver | null = null; watch( - [maybeElement, (): boolean => props.useHtml5Validation ?? true], - (data) => { + [ + maybeElement, + isValid, + (): boolean => props.useHtml5Validation ?? true, + (): string | ((v: ValidityState) => string) | undefined => + props.customValidity, + ], + (newData, oldData) => { // Not using destructuring assignment because browser support is just a little too weak at the moment - const el = data[0]; - const useValidation = data[1]; - - if (!isDefined(el) || !useValidation) { - // Clean up previous state. - if (validationAttributeObserver != null) { - // Process any pending events. - if ( - validationAttributeObserver.takeRecords().length > 0 - ) - onAttributeChange(); - validationAttributeObserver.disconnect(); - } - return; + const el = newData[0]; + const valid = newData[1]; + const useValidation = newData[2]; + const functionalValidation = newData[3] instanceof Function; + const oldEl = oldData[0]; + + const needWatcher = + isDefined(el) && + useValidation && + // For inputs known to be invalid, changes in constraint validation properties + // may make it so the field is now valid and the message needs to be hidden. + // For browser-implemented constraint validation (e.g. the `required` attribute), + // we just care about the message displayed to the user, which is hidden for valid inputs + // until the next interaction with the control. + (!valid || + // For inputs with complex custom validation, any changes to validation-related attributes + // may affect the results of `props.customValidity`. + functionalValidation); + + // Clean up previous state. + if ( + (!needWatcher || el !== oldEl) && + validationAttributeObserver != null + ) { + // Process any pending events. + if (validationAttributeObserver.takeRecords().length > 0) + onAttributeChange(); + validationAttributeObserver.disconnect(); } - if (validationAttributeObserver == null) { - validationAttributeObserver = new MutationObserver( - onAttributeChange, - ); - } - validationAttributeObserver.observe(el, { - attributeFilter: constraintValidationAttributes, - }); - - // Note that this doesn't react to changes in the list of ancestors. - // Based on testing, Vue seems to rarely, if ever, re-parent DOM nodes; - // it generally prefers to create new ones under the new parent. - // That means this simpler solution is likely good enough for now. - let ancestor: Node | null = el; - while ((ancestor = ancestor.parentNode)) { - // Form controls can be disabled by their ancestor fieldsets. - if (ancestor instanceof HTMLFieldSetElement) { - validationAttributeObserver.observe(ancestor, { - attributeFilter: ["disabled"], - }); + // Update the watcher. + // Note that this branch is also used for the initial setup of the watcher. + // We're assuming that `maybeElement` will start out null when the watcher is created, which will + // cause the watcher to be triggered (with `oldEl == undefined`) once the component is mounted. + if (needWatcher && isDefined(el) && el !== oldEl) { + if (validationAttributeObserver == null) { + validationAttributeObserver = new MutationObserver( + onAttributeChange, + ); + } + validationAttributeObserver.observe(el, { + attributeFilter: constraintValidationAttributes, + }); + + // Note that this doesn't react to changes in the list of ancestors. + // Based on testing, Vue seems to rarely, if ever, re-parent DOM nodes; + // it generally prefers to create new ones under the new parent. + // That means this simpler solution is likely good enough for now. + let ancestor: Node | null = el; + while ((ancestor = ancestor.parentNode)) { + // Form controls can be disabled by their ancestor fieldsets. + if (ancestor instanceof HTMLFieldSetElement) { + validationAttributeObserver.observe(ancestor, { + attributeFilter: ["disabled"], + }); + } } } }, - { immediate: true }, - ); - // The custom validation message may depend on the element's validity - // state, which may depend on the element's current value. - watch( - () => props.modelValue, - () => updateCustomValidationMessage(), ); }