From 53e5dc36fa7b6427e1d5ada70247d20137f0efb5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sat, 14 Sep 2024 19:36:38 +0300 Subject: [PATCH] fix: implement multi input validity support --- .../useEventListener/useEventListener.ts | 15 +++-- packages/core/src/useRadio/useRadio.ts | 59 ++++++++----------- packages/core/src/useRadio/useRadioGroup.ts | 50 +++++++--------- .../core/src/validation/useInputValidity.ts | 46 +++++++-------- 4 files changed, 76 insertions(+), 94 deletions(-) diff --git a/packages/core/src/helpers/useEventListener/useEventListener.ts b/packages/core/src/helpers/useEventListener/useEventListener.ts index da33020b..bee9e5e3 100644 --- a/packages/core/src/helpers/useEventListener/useEventListener.ts +++ b/packages/core/src/helpers/useEventListener/useEventListener.ts @@ -7,7 +7,7 @@ interface ListenerOptions { } export function useEventListener( - targetRef: MaybeRefOrGetter>, + targetRef: MaybeRefOrGetter>>, event: Arrayable, listener: (e: TEvent) => unknown, opts?: ListenerOptions, @@ -17,7 +17,7 @@ export function useEventListener( controller?.abort(); } - function setup(el: EventTarget) { + function setup(target: Arrayable) { if (toValue(opts?.disabled)) { return; } @@ -26,7 +26,9 @@ export function useEventListener( const events = normalizeArrayable(event); const listenerOpts = { signal: controller.signal }; events.forEach(evt => { - el.addEventListener(evt, listener as EventListener, listenerOpts); + normalizeArrayable(target).forEach(el => { + el.addEventListener(evt, listener as EventListener, listenerOpts); + }); }); } @@ -34,9 +36,12 @@ export function useEventListener( () => [toValue(targetRef), toValue(opts?.disabled)] as const, ([el, disabled]) => { cleanup(); - if (el && !disabled) { - setup(el); + if (disabled) { + return; } + + const targets = normalizeArrayable(el).filter(elm => !!elm); + setup(targets); }, { immediate: true }, ); diff --git a/packages/core/src/useRadio/useRadio.ts b/packages/core/src/useRadio/useRadio.ts index fb16366d..6eb1a5b0 100644 --- a/packages/core/src/useRadio/useRadio.ts +++ b/packages/core/src/useRadio/useRadio.ts @@ -57,51 +57,40 @@ export function useRadio( } const registration = group?.useRadioRegistration({ + id: inputId, + getElem: () => inputEl.value, isChecked: () => checked.value, isDisabled, setChecked, }); - function createHandlers(isInput: boolean) { - const baseHandlers = { - onClick() { - if (toValue(props.disabled)) { - return; - } - + const handlers = { + onClick() { + if (toValue(props.disabled)) { + return; + } + + setChecked(); + }, + onKeydown(e: KeyboardEvent) { + if (toValue(props.disabled)) { + return; + } + + if (e.code === 'Space') { + e.preventDefault(); setChecked(); - }, - onKeydown(e: KeyboardEvent) { - if (toValue(props.disabled)) { - return; - } - - if (e.code === 'Space') { - e.preventDefault(); - setChecked(); - } - }, - onBlur() { - group?.setTouched(true); - }, - }; - - if (isInput) { - return { - ...baseHandlers, - onInvalid() { - group?.updateValidityWithElem(inputEl.value); - }, - }; - } - - return baseHandlers; - } + } + }, + onBlur() { + group?.setTouched(true); + }, + }; function createBindings(isInput: boolean): RadioDomInputProps | RadioDomProps { const base = { ...labelledByProps.value, - ...createHandlers(isInput), + ...handlers, id: inputId, [isInput ? 'checked' : 'aria-checked']: checked.value, [isInput ? 'readonly' : 'aria-readonly']: group?.readonly || undefined, diff --git a/packages/core/src/useRadio/useRadioGroup.ts b/packages/core/src/useRadio/useRadioGroup.ts index bf377fe9..346bfa9c 100644 --- a/packages/core/src/useRadio/useRadioGroup.ts +++ b/packages/core/src/useRadio/useRadioGroup.ts @@ -1,4 +1,4 @@ -import { InjectionKey, toValue, computed, onBeforeUnmount, reactive, provide } from 'vue'; +import { InjectionKey, toValue, computed, onBeforeUnmount, reactive, provide, ref } from 'vue'; import { useInputValidity } from '../validation/useInputValidity'; import { useLabel } from '../a11y/useLabel'; import { @@ -17,6 +17,7 @@ import { normalizeProps, isEmpty, createAccessibleErrorMessageProps, + removeFirst, } from '../utils/common'; import { useLocale } from '../i18n/useLocale'; import { useFormField } from '../useFormField'; @@ -32,15 +33,16 @@ export interface RadioGroupContext { readonly modelValue: TValue | undefined; setGroupValue(value: TValue, element?: HTMLElement): void; - updateValidityWithElem(el?: HTMLElement): void; setTouched(touched: boolean): void; - useRadioRegistration(radio: RadioItemContext): { canReceiveFocus(): boolean }; + useRadioRegistration(radio: RadioRegistration): { canReceiveFocus(): boolean }; } -export interface RadioItemContext { +export interface RadioRegistration { + id: string; isChecked(): boolean; isDisabled(): boolean; setChecked(): boolean; + getElem(): HTMLElement | undefined; } export const RadioGroupKey: InjectionKey> = Symbol('RadioGroupKey'); @@ -91,7 +93,7 @@ export function useRadioGroup(_props: Reactivify([]); const { labelProps, labelledByProps } = useLabel({ for: groupId, label: props.label, @@ -104,7 +106,7 @@ export function useRadioGroup(_props: Reactivify radios.value.map(r => r.getElem())) }); const { fieldValue, setValue, setTouched, errorMessage } = field; const { descriptionProps, describedByProps } = createDescribedByProps({ @@ -118,25 +120,25 @@ export function useRadioGroup(_props: Reactivify radio.isChecked()); + const currentIdx = radios.value.findIndex(radio => radio.isChecked()); if (currentIdx < 0) { - radios[0]?.setChecked(); + radios.value[0]?.setChecked(); return; } - const availableCandidates = radios.filter(radio => !radio.isDisabled()); + const availableCandidates = radios.value.filter(radio => !radio.isDisabled()); const nextCandidate = availableCandidates[getNextCycleArrIdx(currentIdx + 1, availableCandidates)]; nextCandidate?.setChecked(); } function handleArrowPrevious() { - const currentIdx = radios.findIndex(radio => radio.isChecked()); + const currentIdx = radios.value.findIndex(radio => radio.isChecked()); if (currentIdx === -1) { - radios[0]?.setChecked(); + radios.value[0]?.setChecked(); return; } - const availableCandidates = radios.filter(radio => !radio.isDisabled()); + const availableCandidates = radios.value.filter(radio => !radio.isDisabled()); const prevCandidate = availableCandidates[getNextCycleArrIdx(currentIdx - 1, availableCandidates)]; prevCandidate?.setChecked(); } @@ -174,34 +176,23 @@ export function useRadioGroup(_props: Reactivify= 0) { - radios.splice(idx, 1); - } - } - - function useRadioRegistration(radio: RadioItemContext) { - registerRadio(radio); + function useRadioRegistration(radio: RadioRegistration) { + const id = radio.id; + radios.value.push(radio); onBeforeUnmount(() => { - unregisterRadio(radio); + removeFirst(radios.value, reg => reg.id === id); }); return { canReceiveFocus() { - return radios[0] === radio && isEmpty(fieldValue.value); + return radios.value[0].id === radio.id && isEmpty(fieldValue.value); }, }; } - function setGroupValue(value: TValue, el?: HTMLElement) { + function setGroupValue(value: TValue) { setValue(value); - updateValidityWithElem(el); } const context: RadioGroupContext = reactive({ @@ -212,7 +203,6 @@ export function useRadioGroup(_props: Reactivify>>; + interface InputValidityOptions { - inputEl?: Ref>; + inputEl?: ElementReference; disableHtmlValidation?: MaybeRefOrGetter; field: FormField; events?: string[]; @@ -25,14 +27,14 @@ export function useInputValidity(opts: InputValidityOptions) { (formGroup || form)?.isHtmlValidationDisabled() ?? getConfig().validation.disableHtmlValidation; - function validateNative(mutate?: boolean, el?: HTMLElement): ValidationResult { + function validateNative(mutate?: boolean): ValidationResult { const baseReturns: Omit = { type: 'FIELD', path: getPath() || '', }; - const inputEl = el ?? opts.inputEl?.value; - if (!isInputElement(inputEl) || isHtmlValidationDisabled()) { + const inputs = normalizeArrayable(opts.inputEl?.value).filter(el => isInputElement(el)); + if (!inputs.length || isHtmlValidationDisabled()) { return { ...baseReturns, isValid: true, @@ -40,9 +42,9 @@ export function useInputValidity(opts: InputValidityOptions) { }; } - inputEl.setCustomValidity(''); - validityDetails.value = inputEl.validity; - const messages = normalizeArrayable(inputEl.validationMessage || ([] as string[])).filter(Boolean); + inputs.forEach(el => el.setCustomValidity('')); + validityDetails.value = inputs[0].validity; + const messages = normalizeArrayable(inputs.map(i => i.validationMessage) || ([] as string[])).filter(m => !!m); if (mutate) { setErrors(messages); @@ -55,8 +57,8 @@ export function useInputValidity(opts: InputValidityOptions) { }; } - async function _updateValidity(el?: HTMLElement) { - let result = validateNative(true, el); + async function _updateValidity() { + let result = validateNative(true); if (schema && result.isValid) { result = await validateField(true); } @@ -92,11 +94,6 @@ export function useInputValidity(opts: InputValidityOptions) { useEventListener(opts.inputEl, opts?.events || ['invalid'], () => validateNative(true)); } - async function updateValidityWithElem(element?: HTMLElement) { - await nextTick(); - _updateValidity(element); - } - /** * Validity is always updated on mount. */ @@ -107,26 +104,27 @@ export function useInputValidity(opts: InputValidityOptions) { return { validityDetails, updateValidity, - updateValidityWithElem, }; } /** * Syncs the message with the input's native validation message. */ -function useMessageCustomValiditySync(message: Ref, input?: Ref>) { +function useMessageCustomValiditySync(message: Ref, input?: ElementReference) { if (!input) { return; } - watch(message, msg => { - if (!isInputElement(input.value)) { - return; - } - - const inputMsg = input?.value?.validationMessage; + function applySync(el: HTMLInputElement, msg: string) { + const inputMsg = el.validationMessage; if (inputMsg !== msg) { - input?.value?.setCustomValidity(msg || ''); + el.setCustomValidity(msg || ''); } + } + + watch(message, msg => { + normalizeArrayable(toValue(input)) + .filter(isInputElement) + .forEach(el => applySync(el, msg)); }); }