Skip to content

Commit

Permalink
feat(inputs): detect constraint validation attribute changes (#930)
Browse files Browse the repository at this point in the history
* fix(input): fix return type of checkHtml5Validity

We weren't using the return type even when we were actually returning a
value.

* fix(input): detect changes in native validation

* fix(input): detect changes in disabled status for validation
  • Loading branch information
blm768 authored Jun 5, 2024
1 parent 1af13cf commit 4de3bd7
Showing 1 changed file with 95 additions and 5 deletions.
100 changes: 95 additions & 5 deletions packages/oruga/src/composables/useInputHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import {
type ExtractPropTypes,
type MaybeRefOrGetter,
type Component,
watch,
} from "vue";
import { injectField } from "@/components/field/fieldInjection";
import { unrefElement } from "./unrefElement";
import { getOption } from "@/utils/config";
import { isSSR } from "@/utils/ssr";
import { isDefined } from "@/utils/helpers";

// This should cover all types of HTML elements that have properties related to
// HTML constraint validation, e.g. .form and .validity.
Expand All @@ -35,6 +37,17 @@ function asValidatableFormElement(el: unknown): ValidatableFormElement | null {
: null;
}

const constraintValidationAttributes = [
"disabled",
"required",
"pattern",
"maxlength",
"minlength",
"max",
"min",
"step",
];

/**
* Form input handler functionalities
*/
Expand All @@ -61,10 +74,12 @@ export function useInputHandler(
// inject parent field component if used inside one
const { parentField } = injectField();

const element = computed<ValidatableFormElement>(() => {
/// Allows access to the native element in cases where it might be missing,
/// e.g. because the component hasn't been mounted yet or has been suspended
/// by a <KeepAlive>
const maybeElement = computed<ValidatableFormElement | undefined>(() => {
const el = unrefElement<Component | HTMLElement>(inputRef);
if (!el) {
console.warn("useInputHandler: inputRef contains no element");
return undefined;
}
if (el.getAttribute("data-oruga-input"))
Expand All @@ -83,6 +98,16 @@ export function useInputHandler(
return inputs as ValidatableFormElement;
});

/// Should be used for most accesses to the native element; we generally
/// expect it to be present, especially in event handlers.
const element = computed(() => {
const el = maybeElement.value;
if (!el) {
console.warn("useInputHandler: inputRef contains no element");
}
return el;
});

// --- Input Focus Feature ---

const isFocused = ref(false);
Expand Down Expand Up @@ -138,7 +163,7 @@ export function useInputHandler(
* If validation fail, send 'danger' type,
* and error message to parent if it's a Field.
*/
function checkHtml5Validity(): boolean {
function checkHtml5Validity(): void {
if (!props.useHtml5Validation) return;

if (!element.value) return;
Expand All @@ -149,8 +174,6 @@ export function useInputHandler(
setInvalid();
isValid.value = false;
}

return isValid.value;
}

function setInvalid(): void {
Expand Down Expand Up @@ -203,6 +226,73 @@ export function useInputHandler(
emits("invalid", event);
}

if (!isSSR) {
// 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.

// 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.
// We're not checking for that here because it would require more expensive logic.
// Because of that, this will only work properly if the `required` attributes of all radio
// buttons in the group are synchronized with each other, which is likely anyway.
// (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 => {
if (!isValid.value) checkHtml5Validity();
};
let validationAttributeObserver: MutationObserver | null = null;
watch(
[maybeElement, isValid, () => props.useHtml5Validation],
(data) => {
// Not using destructuring assignment because browser support is just a little too weak at the moment
const el = data[0];
const valid = data[1];
const useValidation = data[2];

// Clean up previous state.
if (validationAttributeObserver != null) {
// Process any pending events.
if (validationAttributeObserver.takeRecords().length > 0) {
onAttributeChange();
}
validationAttributeObserver.disconnect();
}

if (!isDefined(el) || valid || !useValidation) {
return;
}

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 },
);
}

return {
isFocused,
isValid,
Expand Down

0 comments on commit 4de3bd7

Please sign in to comment.