Skip to content

Commit

Permalink
fixup! feat(input): make overriding browsers' validation messages easier
Browse files Browse the repository at this point in the history
  • Loading branch information
blm768 committed Jul 25, 2024
1 parent 43efa27 commit 6b4b70f
Showing 1 changed file with 125 additions and 69 deletions.
194 changes: 125 additions & 69 deletions packages/oruga/src/composables/useInputHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
nextTick,
ref,
computed,
triggerRef,
watch,
watchEffect,
type ExtractPropTypes,
Expand Down Expand Up @@ -165,8 +166,11 @@ export function useInputHandler<T extends ValidatableFormElement>(
*/
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;
Expand Down Expand Up @@ -225,23 +229,57 @@ export function useInputHandler<T extends ValidatableFormElement>(
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);
Expand All @@ -258,19 +296,14 @@ export function useInputHandler<T extends ValidatableFormElement>(
// 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.
Expand All @@ -280,60 +313,83 @@ export function useInputHandler<T extends ValidatableFormElement>(
// (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(),
);
}

Expand Down

0 comments on commit 6b4b70f

Please sign in to comment.