refactor(Field): refactor with composition-api

This commit is contained in:
chenjiahan 2020-09-21 16:48:40 +08:00
parent 7e86fb9e43
commit 00dbf2cc50
4 changed files with 317 additions and 335 deletions

View File

@ -1,17 +1,15 @@
import { watch, inject, WatchSource, getCurrentInstance } from 'vue'; import { watch, inject } from 'vue';
import { FIELD_KEY } from '../field'; import { FIELD_KEY } from '../field';
export function useParentField(watchSource: WatchSource) { export function useParentField(getValue: () => unknown) {
const field = inject(FIELD_KEY, null) as any; const field = inject(FIELD_KEY, null) as any;
if (field && !field.children) { if (field && !field.childFieldValue.value) {
field.children = getCurrentInstance()!.proxy; field.childFieldValue.value = getValue;
watch(watchSource, () => { watch(getValue, () => {
if (field) { field.resetValidation();
field.resetValidation(); field.validateWithTrigger('onChange');
field.validateWithTrigger('onChange');
}
}); });
} }
} }

View File

@ -1,3 +1,13 @@
import {
ref,
watch,
provide,
computed,
nextTick,
reactive,
onMounted,
} from 'vue';
// Utils // Utils
import { resetScroll } from '../utils/dom/reset-scroll'; import { resetScroll } from '../utils/dom/reset-scroll';
import { formatNumber } from '../utils/format/number'; import { formatNumber } from '../utils/format/number';
@ -11,28 +21,20 @@ import {
createNamespace, createNamespace,
} from '../utils'; } from '../utils';
// Composition
import { useExpose } from '../composition/use-expose';
import { useParent } from '../composition/use-relation';
// Components // Components
import Icon from '../icon'; import Icon from '../icon';
import Cell, { cellProps } from '../cell'; import Cell, { cellProps } from '../cell';
import { FORM_KEY } from '../form';
const [createComponent, bem] = createNamespace('field'); const [createComponent, bem] = createNamespace('field');
export const FIELD_KEY = 'vanField'; export const FIELD_KEY = 'vanField';
export default createComponent({ export default createComponent({
provide() {
return {
vanField: this,
};
},
inject: {
vanForm: {
from: 'vanForm',
default: null,
},
},
props: { props: {
...cellProps, ...cellProps,
rows: [Number, String], rows: [Number, String],
@ -91,91 +93,37 @@ export default createComponent({
'update:modelValue', 'update:modelValue',
], ],
data() { setup(props, { emit, slots }) {
return { const state = reactive({
focused: false, focused: false,
validateFailed: false, validateFailed: false,
validateMessage: '', validateMessage: '',
}; });
},
watch: { const root = ref();
modelValue(val) { const inputRef = ref();
this.updateValue(val); const childFieldValue = ref();
this.resetValidation();
this.validateWithTrigger('onChange');
this.$nextTick(this.adjustSize);
},
},
mounted() { const showClear = computed(() => {
this.updateValue(this.modelValue, this.formatTrigger); if (props.clearable && !props.readonly) {
this.$nextTick(this.adjustSize); const hasValue = isDef(props.modelValue) && props.modelValue !== '';
if (this.vanForm) {
this.vanForm.addField(this);
}
},
beforeUnmount() {
if (this.vanForm) {
this.vanForm.removeField(this);
}
},
computed: {
showClear() {
if (this.clearable && !this.readonly) {
const hasValue = isDef(this.modelValue) && this.modelValue !== '';
const trigger = const trigger =
this.clearTrigger === 'always' || props.clearTrigger === 'always' ||
(this.clearTrigger === 'focus' && this.focused); (props.clearTrigger === 'focus' && state.focused);
return hasValue && trigger; return hasValue && trigger;
} }
}, });
showError() { const formValue = computed(() => {
if (this.error !== null) { if (childFieldValue.value && slots.input) {
return this.error; return childFieldValue.value();
} }
if (this.vanForm && this.vanForm.showError && this.validateFailed) { return props.modelValue;
return true; });
}
},
labelStyle() { const runValidator = (value, rule) =>
const labelWidth = this.getProp('labelWidth'); new Promise((resolve) => {
if (labelWidth) {
return { width: addUnit(labelWidth) };
}
},
formValue() {
if (this.children && this.$slots.input) {
return this.children.modelValue;
}
return this.modelValue;
},
},
methods: {
// @exposed-api
focus() {
if (this.$refs.input) {
this.$refs.input.focus();
}
},
// @exposed-api
blur() {
if (this.$refs.input) {
this.$refs.input.blur();
}
},
runValidator(value, rule) {
return new Promise((resolve) => {
const returnVal = rule.validator(value, rule); const returnVal = rule.validator(value, rule);
if (isPromise(returnVal)) { if (isPromise(returnVal)) {
@ -184,9 +132,8 @@ export default createComponent({
resolve(returnVal); resolve(returnVal);
}); });
},
isEmptyValue(value) { const isEmptyValue = (value) => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return !value.length; return !value.length;
} }
@ -194,85 +141,97 @@ export default createComponent({
return false; return false;
} }
return !value; return !value;
}, };
runSyncRule(value, rule) { const runSyncRule = (value, rule) => {
if (rule.required && this.isEmptyValue(value)) { if (rule.required && isEmptyValue(value)) {
return false; return false;
} }
if (rule.pattern && !rule.pattern.test(value)) { if (rule.pattern && !rule.pattern.test(value)) {
return false; return false;
} }
return true; return true;
}, };
getRuleMessage(value, rule) { const getRuleMessage = (value, rule) => {
const { message } = rule; const { message } = rule;
if (isFunction(message)) { if (isFunction(message)) {
return message(value, rule); return message(value, rule);
} }
return message; return message;
}, };
runRules(rules) { const runRules = (rules) =>
return rules.reduce( rules.reduce(
(promise, rule) => (promise, rule) =>
promise.then(() => { promise.then(() => {
if (this.validateFailed) { if (state.validateFailed) {
return; return;
} }
let value = this.formValue; let { value } = formValue;
if (rule.formatter) { if (rule.formatter) {
value = rule.formatter(value, rule); value = rule.formatter(value, rule);
} }
if (!this.runSyncRule(value, rule)) { if (!runSyncRule(value, rule)) {
this.validateFailed = true; state.validateFailed = true;
this.validateMessage = this.getRuleMessage(value, rule); state.validateMessage = getRuleMessage(value, rule);
return; return;
} }
if (rule.validator) { if (rule.validator) {
return this.runValidator(value, rule).then((result) => { return runValidator(value, rule).then((result) => {
if (result === false) { if (result === false) {
this.validateFailed = true; state.validateFailed = true;
this.validateMessage = this.getRuleMessage(value, rule); state.validateMessage = getRuleMessage(value, rule);
} }
}); });
} }
}), }),
Promise.resolve() Promise.resolve()
); );
},
validate(rules = this.rules) { const resetValidation = () => {
return new Promise((resolve) => { if (state.validateFailed) {
state.validateFailed = false;
state.validateMessage = '';
}
};
const validate = (rules = props.rules) =>
new Promise((resolve) => {
if (!rules) { if (!rules) {
resolve(); resolve();
} }
this.resetValidation(); resetValidation();
this.runRules(rules).then(() => { runRules(rules).then(() => {
if (this.validateFailed) { if (state.validateFailed) {
resolve({ resolve({
name: this.name, name: props.name,
message: this.validateMessage, message: state.validateMessage,
}); });
} else { } else {
resolve(); resolve();
} }
}); });
}); });
},
validateWithTrigger(trigger) { const { parent: form } = useParent(FORM_KEY, {
if (this.vanForm && this.rules) { root,
const defaultTrigger = this.vanForm.validateTrigger === trigger; props,
const rules = this.rules.filter((rule) => { validate,
formValue,
resetValidation,
});
const validateWithTrigger = (trigger) => {
if (form && props.rules) {
const defaultTrigger = form.validateTrigger === trigger;
const rules = props.rules.filter((rule) => {
if (rule.trigger) { if (rule.trigger) {
return rule.trigger === trigger; return rule.trigger === trigger;
} }
@ -280,134 +239,160 @@ export default createComponent({
return defaultTrigger; return defaultTrigger;
}); });
this.validate(rules); validate(rules);
} }
}, };
resetValidation() { const updateValue = (value, trigger = 'onChange') => {
if (this.validateFailed) {
this.validateFailed = false;
this.validateMessage = '';
}
},
updateValue(value, trigger = 'onChange') {
value = isDef(value) ? String(value) : ''; value = isDef(value) ? String(value) : '';
// native maxlength not work when type is number // native maxlength not work when type is number
const { maxlength } = this; const { maxlength } = props;
if (isDef(maxlength) && value.length > maxlength) { if (isDef(maxlength) && value.length > maxlength) {
value = value.slice(0, maxlength); value = value.slice(0, maxlength);
} }
if (this.type === 'number' || this.type === 'digit') { if (props.type === 'number' || props.type === 'digit') {
const isNumber = this.type === 'number'; const isNumber = props.type === 'number';
value = formatNumber(value, isNumber, isNumber); value = formatNumber(value, isNumber, isNumber);
} }
if (this.formatter && trigger === this.formatTrigger) { if (props.formatter && trigger === props.formatTrigger) {
value = this.formatter(value); value = props.formatter(value);
} }
const { input } = this.$refs; if (inputRef.value && value !== inputRef.value.value) {
if (input && value !== input.value) { inputRef.value.value = value;
input.value = value;
} }
if (value !== this.modelValue) { if (value !== props.modelValue) {
this.$emit('update:modelValue', value); emit('update:modelValue', value);
} }
};
this.currentValue = value; const onInput = (event) => {
}, // skip update value when composing
if (!event.target.composing) {
onInput(event) { updateValue(event.target.value);
// not update v-model when composing
if (event.target.composing) {
return;
} }
};
this.updateValue(event.target.value); const focus = () => {
}, if (inputRef.value) {
inputRef.value.focus();
}
};
onFocus(event) { const blur = () => {
this.focused = true; if (inputRef.value) {
this.$emit('focus', event); inputRef.value.blur();
}
};
const onFocus = (event) => {
state.focused = true;
emit('focus', event);
// readonly not work in lagacy mobile safari // readonly not work in lagacy mobile safari
/* istanbul ignore if */ if (props.readonly) {
if (this.readonly) { blur();
this.blur();
} }
}, };
onBlur(event) { const onBlur = (event) => {
this.focused = false; state.focused = false;
this.updateValue(this.modelValue, 'onBlur'); updateValue(props.modelValue, 'onBlur');
this.$emit('blur', event); emit('blur', event);
this.validateWithTrigger('onBlur'); validateWithTrigger('onBlur');
resetScroll(); resetScroll();
}, };
onClickInput(event) { const onClickInput = (event) => {
this.$emit('click-input', event); emit('click-input', event);
}, };
onClickLeftIcon(event) { const onClickLeftIcon = (event) => {
this.$emit('click-left-icon', event); emit('click-left-icon', event);
}, };
onClickRightIcon(event) { const onClickRightIcon = (event) => {
this.$emit('click-right-icon', event); emit('click-right-icon', event);
}, };
onClear(event) { const onClear = (event) => {
preventDefault(event); preventDefault(event);
this.$emit('update:modelValue', ''); emit('update:modelValue', '');
this.$emit('clear', event); emit('clear', event);
}, };
onKeypress(event) { const showError = computed(() => {
if (typeof props.error === 'boolean') {
return props.error;
}
if (form && form.showError && state.validateFailed) {
return true;
}
});
const getProp = (key) => {
if (isDef(props[key])) {
return props[key];
}
if (form && isDef(form[key])) {
return form[key];
}
};
const labelStyle = computed(() => {
const labelWidth = getProp('labelWidth');
if (labelWidth) {
return { width: addUnit(labelWidth) };
}
});
const onKeypress = (event) => {
const ENTER_CODE = 13; const ENTER_CODE = 13;
if (event.keyCode === ENTER_CODE) { if (event.keyCode === ENTER_CODE) {
const submitOnEnter = this.getProp('submitOnEnter'); const submitOnEnter = getProp('submitOnEnter');
if (!submitOnEnter && this.type !== 'textarea') { if (!submitOnEnter && props.type !== 'textarea') {
preventDefault(event); preventDefault(event);
} }
// trigger blur after click keyboard search button // trigger blur after click keyboard search button
if (this.type === 'search') { if (props.type === 'search') {
this.blur(); blur();
} }
} }
this.$emit('keypress', event); emit('keypress', event);
}, };
onCompositionStart(event) { const onCompositionStart = (event) => {
event.target.composing = true; event.target.composing = true;
}, };
onCompositionEnd(event) { const onCompositionEnd = (event) => {
const { target } = event; const { target } = event;
if (target.composing) { if (target.composing) {
target.composing = false; target.composing = false;
trigger(target, 'input'); trigger(target, 'input');
} }
}, };
adjustSize() { const adjustSize = () => {
const { input } = this.$refs; const input = inputRef.value;
if (!(this.type === 'textarea' && this.autosize) || !input) {
if (!(props.type === 'textarea' && props.autosize) || !input) {
return; return;
} }
input.style.height = 'auto'; input.style.height = 'auto';
let height = input.scrollHeight; let height = input.scrollHeight;
if (isObject(this.autosize)) { if (isObject(props.autosize)) {
const { maxHeight, minHeight } = this.autosize; const { maxHeight, minHeight } = props.autosize;
if (maxHeight) { if (maxHeight) {
height = Math.min(height, maxHeight); height = Math.min(height, maxHeight);
} }
@ -419,43 +404,44 @@ export default createComponent({
if (height) { if (height) {
input.style.height = height + 'px'; input.style.height = height + 'px';
} }
}, };
genInput() { const renderInput = () => {
const { type } = this; const inputAlign = getProp('inputAlign');
const inputAlign = this.getProp('inputAlign');
if (this.$slots.input) { if (slots.input) {
return ( return (
<div <div
class={bem('control', [inputAlign, 'custom'])} class={bem('control', [inputAlign, 'custom'])}
onClick={this.onClickInput} onClick={onClickInput}
> >
{this.$slots.input()} {slots.input()}
</div> </div>
); );
} }
const inputProps = { const inputProps = {
ref: 'input', ref: inputRef,
name: this.name, name: props.name,
rows: this.rows, rows: props.rows,
style: null, style: null,
class: bem('control', inputAlign), class: bem('control', inputAlign),
value: this.modelValue, value: props.modelValue,
disabled: this.disabled, disabled: props.disabled,
readonly: this.readonly, readonly: props.readonly,
placeholder: this.placeholder, placeholder: props.placeholder,
onBlur: this.onBlur, onBlur,
onFocus: this.onFocus, onFocus,
onInput: this.onInput, onInput,
onClick: this.onClickInput, onClick: onClickInput,
onChange: this.onCompositionEnd, onChange: onCompositionEnd,
onKeypress: this.onKeypress, onKeypress,
onCompositionend: this.onCompositionEnd, onCompositionend: onCompositionEnd,
onCompositionstart: this.onCompositionStart, onCompositionstart: onCompositionStart,
}; };
const { type } = props;
if (type === 'textarea') { if (type === 'textarea') {
return <textarea {...inputProps} />; return <textarea {...inputProps} />;
} }
@ -476,137 +462,144 @@ export default createComponent({
} }
return <input type={inputType} inputmode={inputMode} {...inputProps} />; return <input type={inputType} inputmode={inputMode} {...inputProps} />;
}, };
genLeftIcon() { const renderLeftIcon = () => {
const leftIconSlot = this.$slots['left-icon']; const leftIconSlot = slots['left-icon'];
if (this.leftIcon || leftIconSlot) { if (props.leftIcon || leftIconSlot) {
return ( return (
<div class={bem('left-icon')} onClick={this.onClickLeftIcon}> <div class={bem('left-icon')} onClick={onClickLeftIcon}>
{leftIconSlot ? ( {leftIconSlot ? (
leftIconSlot() leftIconSlot()
) : ( ) : (
<Icon name={this.leftIcon} classPrefix={this.iconPrefix} /> <Icon name={props.leftIcon} classPrefix={props.iconPrefix} />
)} )}
</div> </div>
); );
} }
}, };
genRightIcon() { const renderRightIcon = () => {
const rightIconSlot = this.$slots['right-icon']; const rightIconSlot = slots['right-icon'];
if (this.rightIcon || rightIconSlot) { if (props.rightIcon || rightIconSlot) {
return ( return (
<div class={bem('right-icon')} onClick={this.onClickRightIcon}> <div class={bem('right-icon')} onClick={onClickRightIcon}>
{rightIconSlot ? ( {rightIconSlot ? (
rightIconSlot() rightIconSlot()
) : ( ) : (
<Icon name={this.rightIcon} classPrefix={this.iconPrefix} /> <Icon name={props.rightIcon} classPrefix={props.iconPrefix} />
)} )}
</div> </div>
); );
} }
}, };
genWordLimit() {
if (this.showWordLimit && this.maxlength) {
const count = (this.modelValue || '').length;
const renderWordLimit = () => {
if (props.showWordLimit && props.maxlength) {
const count = (props.modelValue || '').length;
return ( return (
<div class={bem('word-limit')}> <div class={bem('word-limit')}>
<span class={bem('word-num')}>{count}</span>/{this.maxlength} <span class={bem('word-num')}>{count}</span>/{props.maxlength}
</div> </div>
); );
} }
}, };
genMessage() { const renderMessage = () => {
if (this.vanForm && this.vanForm.showErrorMessage === false) { if (form && form.showErrorMessage === false) {
return; return;
} }
const message = this.errorMessage || this.validateMessage; const message = props.errorMessage || state.validateMessage;
if (message) { if (message) {
const errorMessageAlign = this.getProp('errorMessageAlign'); const errorMessageAlign = getProp('errorMessageAlign');
return ( return (
<div class={bem('error-message', errorMessageAlign)}>{message}</div> <div class={bem('error-message', errorMessageAlign)}>{message}</div>
); );
} }
}, };
getProp(key) { const renderLabel = () => {
if (isDef(this[key])) { const colon = getProp('colon') ? ':' : '';
return this[key];
if (slots.label) {
return [slots.label(), colon];
} }
if (props.label) {
if (this.vanForm && isDef(this.vanForm[key])) { return <span>{props.label + colon}</span>;
return this.vanForm[key];
} }
}, };
genLabel() { useExpose({
const colon = this.getProp('colon') ? ':' : ''; focus,
blur,
});
if (this.$slots.label) { provide(FIELD_KEY, {
return [this.$slots.label(), colon]; childFieldValue,
resetValidation,
validateWithTrigger,
});
watch(
() => props.modelValue,
(value) => {
updateValue(value);
resetValidation();
validateWithTrigger('onChange');
nextTick(adjustSize);
} }
if (this.label) {
return <span>{this.label + colon}</span>;
}
},
},
render() {
const slots = this.$slots;
const labelAlign = this.getProp('labelAlign');
const Label = this.genLabel();
const LeftIcon = this.genLeftIcon();
return (
<Cell
v-slots={{
icon: LeftIcon ? () => LeftIcon : null,
title: Label ? () => Label : null,
extra: slots.extra,
}}
icon={this.leftIcon}
size={this.size}
class={bem({
error: this.showError,
disabled: this.disabled,
[`label-${labelAlign}`]: labelAlign,
'min-height': this.type === 'textarea' && !this.autosize,
})}
center={this.center}
border={this.border}
isLink={this.isLink}
required={this.required}
clickable={this.clickable}
titleStyle={this.labelStyle}
valueClass={bem('value')}
titleClass={[bem('label', labelAlign), this.labelClass]}
arrowDirection={this.arrowDirection}
>
<div class={bem('body')}>
{this.genInput()}
{this.showClear && (
<Icon
name="clear"
class={bem('clear')}
onTouchstart={this.onClear}
/>
)}
{this.genRightIcon()}
{slots.button && <div class={bem('button')}>{slots.button()}</div>}
</div>
{this.genWordLimit()}
{this.genMessage()}
</Cell>
); );
onMounted(() => {
updateValue(props.modelValue, props.formatTrigger);
nextTick(adjustSize);
});
return () => {
const labelAlign = getProp('labelAlign');
return (
<Cell
v-slots={{
icon: renderLeftIcon,
title: renderLabel,
extra: slots.extra,
}}
ref={root}
size={props.size}
icon={props.leftIcon}
class={bem({
error: showError.value,
disabled: props.disabled,
[`label-${labelAlign}`]: labelAlign,
'min-height': props.type === 'textarea' && !props.autosize,
})}
center={props.center}
border={props.border}
isLink={props.isLink}
required={props.required}
clickable={props.clickable}
titleStyle={labelStyle.value}
valueClass={bem('value')}
titleClass={[bem('label', labelAlign), props.labelClass]}
arrowDirection={props.arrowDirection}
>
<div class={bem('body')}>
{renderInput()}
{showClear.value && (
<Icon name="clear" class={bem('clear')} onTouchstart={onClear} />
)}
{renderRightIcon()}
{slots.button && <div class={bem('button')}>{slots.button()}</div>}
</div>
{renderWordLimit()}
{renderMessage()}
</Cell>
);
};
}, },
}); });

View File

@ -1,8 +1,9 @@
import { createNamespace } from '../utils'; import { createNamespace } from '../utils';
// import { sortChildren } from '../utils/vnodes';
const [createComponent, bem] = createNamespace('form'); const [createComponent, bem] = createNamespace('form');
export const FORM_KEY = 'vanForm';
export default createComponent({ export default createComponent({
props: { props: {
colon: Boolean, colon: Boolean,
@ -34,13 +35,13 @@ export default createComponent({
provide() { provide() {
return { return {
vanForm: this, [FORM_KEY]: this,
}; };
}, },
data() { data() {
return { return {
fields: [], children: [],
}; };
}, },
@ -49,7 +50,7 @@ export default createComponent({
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const errors = []; const errors = [];
this.fields this.children
.reduce( .reduce(
(promise, field) => (promise, field) =>
promise.then(() => { promise.then(() => {
@ -75,7 +76,7 @@ export default createComponent({
validateAll() { validateAll() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
Promise.all(this.fields.map((item) => item.validate())).then( Promise.all(this.children.map((item) => item.validate())).then(
(errors) => { (errors) => {
errors = errors.filter((item) => item); errors = errors.filter((item) => item);
@ -98,7 +99,7 @@ export default createComponent({
}, },
validateField(name) { validateField(name) {
const matched = this.fields.filter((item) => item.name === name); const matched = this.children.filter((item) => item.props.name === name);
if (matched.length) { if (matched.length) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -117,8 +118,8 @@ export default createComponent({
// @exposed-api // @exposed-api
resetValidation(name) { resetValidation(name) {
this.fields.forEach((item) => { this.children.forEach((item) => {
if (!name || item.name === name) { if (!name || item.props.name === name) {
item.resetValidation(); item.resetValidation();
} }
}); });
@ -126,28 +127,18 @@ export default createComponent({
// @exposed-api // @exposed-api
scrollToField(name, options) { scrollToField(name, options) {
this.fields.some((item) => { this.children.some((item) => {
if (item.name === name) { if (item.props.name === name) {
item.$el.scrollIntoView(options); item.root.value.scrollIntoView(options);
return true; return true;
} }
return false; return false;
}); });
}, },
addField(field) {
this.fields.push(field);
// TODO
// sortChildren(this.fields, this);
},
removeField(field) {
this.fields = this.fields.filter((item) => item !== field);
},
getValues() { getValues() {
return this.fields.reduce((form, field) => { return this.children.reduce((form, field) => {
form[field.name] = field.formValue; form[field.props.name] = field.formValue;
return form; return form;
}, {}); }, {});
}, },

View File

@ -9,7 +9,7 @@ export const FieldMixin = {
}, },
watch: { watch: {
value() { modelValue() {
const field = this.vanField; const field = this.vanField;
if (field) { if (field) {
@ -22,8 +22,8 @@ export const FieldMixin = {
created() { created() {
const field = this.vanField; const field = this.vanField;
if (field && !field.children) { if (field && !field.childFieldValue.value) {
field.children = this; field.childFieldValue.value = () => this.modelValue;
} }
}, },
}; };