react-formal
Version:
Classy HTML form management for React
121 lines (118 loc) • 4.8 kB
JavaScript
/* eslint-disable @typescript-eslint/no-empty-function */
import expr from 'property-expr';
import useUpdatedRef from '@restart/hooks/useUpdatedRef';
import React, { useCallback, useMemo, useContext, useRef, useState, useLayoutEffect } from 'react';
import updateIn from './utils/updateIn';
export const formGetter = (path, model) => path ? expr.getter(path, true)(model || {}) : model;
export function formSetter(path, value, fieldValue) {
return updateIn(value, path, fieldValue);
}
export const BindingContext = /*#__PURE__*/React.createContext({
getValue() {},
updateBindingValue() {}
});
BindingContext.displayName = 'ReactFormalValueContext';
export const useBindingContext = () => {
return useContext(BindingContext);
};
const isEvent = e => typeof e == 'object' && e != null && 'target' in e;
function parseValueFromEvent(target, fieldValue, fieldSchema) {
const {
type,
value,
checked,
options,
multiple,
files
} = target;
if (type === 'file') return multiple ? files : files && files[0];
if (multiple) {
var _fieldSchema$_subType;
// @ts-ignore
const innerType = fieldSchema == null ? void 0 : (_fieldSchema$_subType = fieldSchema._subType) == null ? void 0 : _fieldSchema$_subType._type;
return Array.from(options).filter(opt => opt.selected).map(({
value: option
}) => innerType == 'number' ? parseFloat(option) : option);
}
if (/number|range/.test(type)) {
let parsed = parseFloat(value);
return isNaN(parsed) ? null : parsed;
}
if (type === 'checkbox') {
const isArray = Array.isArray(fieldValue);
const isBool = !isArray && (fieldSchema == null ? void 0 : fieldSchema._type) === 'boolean';
if (isBool) return checked;
const nextValue = isArray ? [...fieldValue] : [];
const idx = nextValue.indexOf(value);
if (checked) {
if (idx === -1) nextValue.push(value);
} else nextValue.splice(idx, 1);
return nextValue;
}
return value;
}
function useFormBindingContext({
formValue,
onChange,
setter = formSetter,
getter = formGetter,
getSchemaForPath
}) {
// Why is this so complicated?
// Well, calling onChange, from a binding multiple times would trigger
// a change multiple times. Duh. This change, when controlled, might not flush
// back through by the time the next change is called, leaving the updateBindingValue()
// with a stale copy of `model`. React's setState avoids this with it's function
// signature of useState, so we "queue" model changes locally in state, and
// then "flush" them in an effect when the update is finished.
let formValueRef = useUpdatedRef(formValue);
let pendingChangeRef = useRef(false);
let [pendingChange, setPendingChange] = useState([formValue, []]);
// This assumes that we won't get an update until all the queued setState's fire,
// then if there is a pending change we fire onChange with it and the consolidated
// paths
useLayoutEffect(() => {
const [nextFormValue, paths] = pendingChange;
if (pendingChangeRef.current) {
pendingChangeRef.current = false;
onChange(nextFormValue, paths);
}
});
const updateBindingValue = useCallback((mapValue, args) => {
setPendingChange(pendingState => {
let [nextModel, paths] = pendingState;
// If there are no unflushed changes then use the current props model, assuming it
// would be up to date.
if (!pendingChangeRef.current) {
pendingChangeRef.current = true;
nextModel = formValueRef.current;
paths = [];
}
Object.keys(mapValue).forEach(key => {
let field = mapValue[key];
let value;
if (typeof field === 'function') value = field(...args);else if (field === '.' || field == null || args[0] == null) value = args[0];else {
value = expr.getter(field, true)(args[0]);
}
if (paths.indexOf(key) === -1) paths.push(key);
if (isEvent(value)) value = parseValueFromEvent(value.target, formGetter(key, nextModel), getSchemaForPath(key, nextModel));
nextModel = setter(key, nextModel, value, formSetter);
});
return [nextModel, paths];
});
}, [formValueRef, getSchemaForPath, setter]);
const getValue = useCallback(pathOrAccessor => typeof pathOrAccessor === 'function' ? pathOrAccessor(formValue, getter) : getter(pathOrAccessor, formValue), [getter, formValue]);
return useMemo(() => ({
getValue,
updateBindingValue,
getSchemaForPath: getSchemaForPath,
updateFormValue: nextFormValue => {
setPendingChange(() => {
pendingChangeRef.current = true;
return [nextFormValue, []];
});
},
formValue
}), [getValue, updateBindingValue, setPendingChange, getSchemaForPath, formValue]);
}
export default useFormBindingContext;