Skip to content

Commit

Permalink
fix: implement multi input validity support
Browse files Browse the repository at this point in the history
  • Loading branch information
logaretm committed Sep 14, 2024
1 parent 7dc0e69 commit 53e5dc3
Show file tree
Hide file tree
Showing 4 changed files with 76 additions and 94 deletions.
15 changes: 10 additions & 5 deletions packages/core/src/helpers/useEventListener/useEventListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ interface ListenerOptions {
}

export function useEventListener<TEvent extends Event>(
targetRef: MaybeRefOrGetter<Maybe<EventTarget>>,
targetRef: MaybeRefOrGetter<Arrayable<Maybe<EventTarget>>>,
event: Arrayable<string>,
listener: (e: TEvent) => unknown,
opts?: ListenerOptions,
Expand All @@ -17,7 +17,7 @@ export function useEventListener<TEvent extends Event>(
controller?.abort();
}

function setup(el: EventTarget) {
function setup(target: Arrayable<EventTarget>) {
if (toValue(opts?.disabled)) {
return;
}
Expand All @@ -26,17 +26,22 @@ export function useEventListener<TEvent extends Event>(
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);
});
});
}

const stopWatch = watch(
() => [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 },
);
Expand Down
59 changes: 24 additions & 35 deletions packages/core/src/useRadio/useRadio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,51 +57,40 @@ export function useRadio<TValue = string>(
}

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,
Expand Down
50 changes: 20 additions & 30 deletions packages/core/src/useRadio/useRadioGroup.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -17,6 +17,7 @@ import {
normalizeProps,
isEmpty,
createAccessibleErrorMessageProps,
removeFirst,
} from '../utils/common';
import { useLocale } from '../i18n/useLocale';
import { useFormField } from '../useFormField';
Expand All @@ -32,15 +33,16 @@ export interface RadioGroupContext<TValue> {
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<RadioGroupContext<any>> = Symbol('RadioGroupKey');
Expand Down Expand Up @@ -91,7 +93,7 @@ export function useRadioGroup<TValue = string>(_props: Reactivify<RadioGroupProp
const groupId = useUniqId(FieldTypePrefixes.RadioButtonGroup);
const { direction } = useLocale();

const radios: RadioItemContext[] = [];
const radios = ref<RadioRegistration[]>([]);
const { labelProps, labelledByProps } = useLabel({
for: groupId,
label: props.label,
Expand All @@ -104,7 +106,7 @@ export function useRadioGroup<TValue = string>(_props: Reactivify<RadioGroupProp
schema: props.schema,
});

const { validityDetails, updateValidityWithElem } = useInputValidity({ field });
const { validityDetails } = useInputValidity({ field, inputEl: computed(() => radios.value.map(r => r.getElem())) });
const { fieldValue, setValue, setTouched, errorMessage } = field;

const { descriptionProps, describedByProps } = createDescribedByProps({
Expand All @@ -118,25 +120,25 @@ export function useRadioGroup<TValue = string>(_props: Reactivify<RadioGroupProp
});

function handleArrowNext() {
const currentIdx = radios.findIndex(radio => 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();
}
Expand Down Expand Up @@ -174,34 +176,23 @@ export function useRadioGroup<TValue = string>(_props: Reactivify<RadioGroupProp
};
});

function registerRadio(radio: RadioItemContext) {
radios.push(radio);
}

function unregisterRadio(radio: RadioItemContext) {
const idx = radios.indexOf(radio);
if (idx >= 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<any> = reactive({
Expand All @@ -212,7 +203,6 @@ export function useRadioGroup<TValue = string>(_props: Reactivify<RadioGroupProp
modelValue: fieldValue,
setErrors: field.setErrors,
setGroupValue,
updateValidityWithElem,
setTouched,
useRadioRegistration,
});
Expand Down
46 changes: 22 additions & 24 deletions packages/core/src/validation/useInputValidity.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { Ref, inject, nextTick, onMounted, shallowRef, watch, MaybeRefOrGetter, toValue } from 'vue';
import { useEventListener } from '../helpers/useEventListener';
import { FormKey } from '../useForm';
import { Maybe, ValidationResult } from '../types';
import { Arrayable, Maybe, ValidationResult } from '../types';
import { FormField } from '../useFormField';
import { isInputElement, normalizeArrayable } from '../utils/common';
import { FormGroupKey } from '../useFormGroup';
import { getConfig } from '../config';

type ElementReference = Ref<Arrayable<Maybe<HTMLElement>>>;

interface InputValidityOptions {
inputEl?: Ref<Maybe<HTMLElement>>;
inputEl?: ElementReference;
disableHtmlValidation?: MaybeRefOrGetter<boolean | undefined>;
field: FormField<any>;
events?: string[];
Expand All @@ -25,24 +27,24 @@ 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<ValidationResult, 'errors' | 'isValid'> = {
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,
errors: [{ messages: [], path: getPath() || '' }],
};
}

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);
Expand All @@ -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);
}
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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<string>, input?: Ref<Maybe<HTMLElement>>) {
function useMessageCustomValiditySync(message: Ref<string>, 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));
});
}

0 comments on commit 53e5dc3

Please sign in to comment.