From 5e96615cb497667f7fe279fac05ff90e5550683e Mon Sep 17 00:00:00 2001 From: Ben Merritt Date: Mon, 22 Jul 2024 16:37:10 -0700 Subject: [PATCH] feat(input): make overriding browsers' validation messages easier With this change, rather than having to duplicate the browser's native validation logic when selecting a custom message, the parent component can just react to the validity state that the browser reports. --- packages/docs/components/Input.md | 60 +++---- packages/oruga/src/components/input/props.ts | 10 +- .../oruga/src/composables/useInputHandler.ts | 165 ++++++++++++------ 3 files changed, 152 insertions(+), 83 deletions(-) diff --git a/packages/docs/components/Input.md b/packages/docs/components/Input.md index 7c6f88a18..07c75d74d 100644 --- a/packages/docs/components/Input.md +++ b/packages/docs/components/Input.md @@ -33,36 +33,36 @@ ### Props -| Prop name | Description | Type | Values | Default | -| ------------------ | -------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| autocomplete | Native options to use in HTML5 validation | string | - |
From config:
input: {
  autocomplete: "off"
}
| -| autosize | Automatically adjust height in textarea | boolean | - | false | -| clearIcon | Icon name to be added on the clear button | string | - |
From config:
input: {
  clearIcon: "close-circle"
}
| -| clearable | Add a button/icon to clear the inputed text | boolean | - |
From config:
input: {
  clearable: false
}
| -| counter | Show character counter when maxlength prop is passed | boolean | - |
From config:
input: {
  counter: false
}
| -| debounce | Number of milliseconds to delay before to emit input event | number | - |
From config:
autocomplete: {
  debounce: 400
}
| -| disabled | Same as native disabled | boolean | - | false | -| expanded | Makes input full width when inside a grouped or addon field | boolean | - | false | -| icon | Icon to be shown | string | - |
From config:
input: {
  icon: undefined
}
| -| iconClickable | Makes the icon clickable | boolean | - | false | -| iconPack | Icon pack to use | string | `mdi`, `fa`, `fas and any other custom icon pack` |
From config:
input: {
  iconPack: undefined
}
| -| iconRight | Icon to be added on the right side | string | - |
From config:
input: {
  iconRight: undefined
}
| -| iconRightClickable | Make the icon right clickable | boolean | - | false | -| iconRightVariant | Variant of right icon | string | - | | -| id | Same as native id. Also set the for label for o-field wrapper. | string | - | uuid() | -| maxlength | Same as native maxlength, plus character counter | string \| number | - | | -| v-model | The input value state | string \| number | - | | -| number | Convert the ´modelValue`into type`number` | boolean | - | | -| override | Override existing theme classes completely | boolean | - | | -| passwordReveal | Adds the reveal password functionality | boolean | - | false | -| placeholder | Input placeholder | string | - | | -| rounded | Makes the element rounded | boolean | - | false | -| size | Size of the control | string | `small`, `medium`, `large` |
From config:
input: {
  size: undefined
}
| -| statusIcon | Show status icon using field and variant prop | boolean | - |
From config:
{
  statusIcon: true
}
| -| type | Input type, like native | string | `Any native input type`, `and textarea` | "text" | -| useHtml5Validation | Enable HTML 5 native validation | boolean | - |
From config:
{
  useHtml5Validation: true
}
| -| customValidity | Custom HTML 5 validation error to set on the form control | string | - | | -| variant | Color of the control | string | `primary`, `info`, `success`, `warning`, `danger`, `and any other custom color` |
From config:
input: {
  variant: undefined
}
| +| Prop name | Description | Type | Values | Default | +| ------------------ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| autocomplete | Native options to use in HTML5 validation | string | - |
From config:
input: {
  autocomplete: "off"
}
| +| autosize | Automatically adjust height in textarea | boolean | - | false | +| clearIcon | Icon name to be added on the clear button | string | - |
From config:
input: {
  clearIcon: "close-circle"
}
| +| clearable | Add a button/icon to clear the inputed text | boolean | - |
From config:
input: {
  clearable: false
}
| +| counter | Show character counter when maxlength prop is passed | boolean | - |
From config:
input: {
  counter: false
}
| +| debounce | Number of milliseconds to delay before to emit input event | number | - |
From config:
autocomplete: {
  debounce: 400
}
| +| disabled | Same as native disabled | boolean | - | false | +| expanded | Makes input full width when inside a grouped or addon field | boolean | - | false | +| icon | Icon to be shown | string | - |
From config:
input: {
  icon: undefined
}
| +| iconClickable | Makes the icon clickable | boolean | - | false | +| iconPack | Icon pack to use | string | `mdi`, `fa`, `fas and any other custom icon pack` |
From config:
input: {
  iconPack: undefined
}
| +| iconRight | Icon to be added on the right side | string | - |
From config:
input: {
  iconRight: undefined
}
| +| iconRightClickable | Make the icon right clickable | boolean | - | false | +| iconRightVariant | Variant of right icon | string | - | | +| id | Same as native id. Also set the for label for o-field wrapper. | string | - | uuid() | +| maxlength | Same as native maxlength, plus character counter | string \| number | - | | +| v-model | The input value state | string \| number | - | | +| number | Convert the ´modelValue`into type`number` | boolean | - | | +| override | Override existing theme classes completely | boolean | - | | +| passwordReveal | Adds the reveal password functionality | boolean | - | false | +| placeholder | Input placeholder | string | - | | +| rounded | Makes the element rounded | boolean | - | false | +| size | Size of the control | string | `small`, `medium`, `large` |
From config:
input: {
  size: undefined
}
| +| statusIcon | Show status icon using field and variant prop | boolean | - |
From config:
{
  statusIcon: true
}
| +| type | Input type, like native | string | `Any native input type`, `and textarea` | "text" | +| useHtml5Validation | Enable HTML 5 native validation | boolean | - |
From config:
{
  useHtml5Validation: true
}
| +| customValidity | Custom HTML 5 validation error to set on the form control | string \| ((currentValue: string, state: ValidityState) => string) (`currentValue` is `number` if the `number` prop is `true`.) | - | | +| variant | Color of the control | string | `primary`, `info`, `success`, `warning`, `danger`, `and any other custom color` |
From config:
input: {
  variant: undefined
}
| ### Events diff --git a/packages/oruga/src/components/input/props.ts b/packages/oruga/src/components/input/props.ts index c34d8061a..019237b42 100644 --- a/packages/oruga/src/components/input/props.ts +++ b/packages/oruga/src/components/input/props.ts @@ -1,5 +1,9 @@ import type { ComponentClass } from "@/types"; +export type InputType = IsNumber extends true + ? number + : string; + export type InputProps = { /** Override existing theme classes completely */ override?: boolean; @@ -7,7 +11,7 @@ export type InputProps = { * The input value state * @type string | number */ - modelValue?: IsNumber extends true ? number : string; + modelValue?: InputType; /** * Convert the ´modelValue` into type `number` * @type boolean @@ -74,7 +78,9 @@ export type InputProps = { /** Enable HTML 5 native validation */ useHtml5Validation?: boolean; /** Custom HTML 5 validation error to set on the form control */ - customValidity?: string; + customValidity?: + | string + | ((currentValue: InputType, state: ValidityState) => string); } & InputClasses; // class props (will not be displayed in the docs) diff --git a/packages/oruga/src/composables/useInputHandler.ts b/packages/oruga/src/composables/useInputHandler.ts index 213f3df19..4f0b74043 100644 --- a/packages/oruga/src/composables/useInputHandler.ts +++ b/packages/oruga/src/composables/useInputHandler.ts @@ -2,7 +2,9 @@ import { nextTick, ref, computed, + triggerRef, watch, + watchEffect, type ExtractPropTypes, type MaybeRefOrGetter, type Component, @@ -66,8 +68,11 @@ export function useInputHandler( /** validation configuration props */ props: Readonly< ExtractPropTypes<{ + modelValue?: unknown; useHtml5Validation?: boolean; - customValidity?: string; + customValidity?: + | string + | ((currentValue: any, v: ValidityState) => string); }> >, ) { @@ -163,8 +168,8 @@ export function useInputHandler( */ function checkHtml5Validity(): void { if (!props.useHtml5Validation) return; - if (!element.value) return; + if (element.value.validity.valid) { setFieldValidity(null, null); isValid.value = true; @@ -224,39 +229,68 @@ export function useInputHandler( } if (!isSSR) { + /** + * 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. + * Note that just calling the watcher's internal function directly (outside the watcher) + * wouldn't be a complete solution; the watcher would then miss any new reactive dependencies + * that show up, e.g. because `props.customValidity` starts taking a branch that the watcher + * hasn't seen before. + */ + const forceValidationUpdate = ref(null); + + // Propagate any custom constraint validation message to the underlying DOM element. + // 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((): void => { + forceValidationUpdate.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 read it to help solve that reactivity problem. + element.setCustomValidity( + validity(props.modelValue, element.validity), + ); + } + // Updates the user-visible validation message if necessary + if (!isValid.value) checkHtml5Validity(); + }); + + // Clean up validation state if we stop controlling it. watch( - [ - maybeElement, - (): string => props.customValidity ?? "", - (): boolean => props.useHtml5Validation ?? true, - ], + [maybeElement, (): boolean => props.useHtml5Validation ?? true], (newItems, oldItems) => { const newElement = newItems[0]; - const newMessage = newItems[1]; - const newUseValidation = newItems[2]; + const newUseValidation = newItems[1]; const oldElement = oldItems[0]; - const oldUseValidation = oldItems[2]; + const oldUseValidation = oldItems[1]; if (newElement !== oldElement) { // Since we're no longer managing the element, we might // as well clean up any custom validity we set up. oldElement?.setCustomValidity(""); - if (newUseValidation) { - newElement?.setCustomValidity(newMessage); - } } else if (oldUseValidation && !newUseValidation) { newElement?.setCustomValidity(""); - } else if (newUseValidation) { - newElement?.setCustomValidity(newMessage); } }, ); - // 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. @@ -266,51 +300,80 @@ 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 => { - if (!isValid.value) checkHtml5Validity(); + triggerRef(forceValidationUpdate); }; let validationAttributeObserver: MutationObserver | null = null; watch( - [maybeElement, isValid, () => props.useHtml5Validation], - (data) => { + [ + maybeElement, + isValid, + (): boolean => props.useHtml5Validation ?? true, + (): + | string + | ((s: ValidityState, v: any) => 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 valid = data[1]; - const useValidation = data[2]; + 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 (validationAttributeObserver != null) { + if ( + (!needWatcher || el !== oldEl) && + 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"], - }); + // 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 }, ); }