mirror of
https://gitee.com/vant-contrib/vant.git
synced 2025-04-06 03:57:59 +08:00
594 lines
15 KiB
TypeScript
594 lines
15 KiB
TypeScript
import {
|
|
ref,
|
|
watch,
|
|
provide,
|
|
computed,
|
|
nextTick,
|
|
reactive,
|
|
onMounted,
|
|
defineComponent,
|
|
type PropType,
|
|
type ExtractPropTypes,
|
|
} from 'vue';
|
|
|
|
// Utils
|
|
import {
|
|
isDef,
|
|
extend,
|
|
addUnit,
|
|
toArray,
|
|
FORM_KEY,
|
|
numericProp,
|
|
unknownProp,
|
|
resetScroll,
|
|
formatNumber,
|
|
preventDefault,
|
|
makeStringProp,
|
|
makeNumericProp,
|
|
createNamespace,
|
|
} from '../utils';
|
|
import {
|
|
cutString,
|
|
runSyncRule,
|
|
endComposing,
|
|
mapInputType,
|
|
startComposing,
|
|
getRuleMessage,
|
|
resizeTextarea,
|
|
getStringLength,
|
|
runRuleValidator,
|
|
} from './utils';
|
|
import { cellSharedProps } from '../cell/Cell';
|
|
|
|
// Composables
|
|
import { CUSTOM_FIELD_INJECTION_KEY, useParent } from '@vant/use';
|
|
import { useId } from '../composables/use-id';
|
|
import { useExpose } from '../composables/use-expose';
|
|
|
|
// Components
|
|
import { Icon } from '../icon';
|
|
import { Cell } from '../cell';
|
|
|
|
// Types
|
|
import type {
|
|
FieldRule,
|
|
FieldType,
|
|
FieldExpose,
|
|
FieldTextAlign,
|
|
FieldClearTrigger,
|
|
FieldFormatTrigger,
|
|
FieldValidateError,
|
|
FieldAutosizeConfig,
|
|
FieldValidateTrigger,
|
|
FieldFormSharedProps,
|
|
} from './types';
|
|
|
|
const [name, bem] = createNamespace('field');
|
|
|
|
// provide to Search component to inherit
|
|
export const fieldSharedProps = {
|
|
id: String,
|
|
name: String,
|
|
leftIcon: String,
|
|
rightIcon: String,
|
|
autofocus: Boolean,
|
|
clearable: Boolean,
|
|
maxlength: numericProp,
|
|
formatter: Function as PropType<(value: string) => string>,
|
|
clearIcon: makeStringProp('clear'),
|
|
modelValue: makeNumericProp(''),
|
|
inputAlign: String as PropType<FieldTextAlign>,
|
|
placeholder: String,
|
|
autocomplete: String,
|
|
errorMessage: String,
|
|
enterkeyhint: String,
|
|
clearTrigger: makeStringProp<FieldClearTrigger>('focus'),
|
|
formatTrigger: makeStringProp<FieldFormatTrigger>('onChange'),
|
|
error: {
|
|
type: Boolean,
|
|
default: null,
|
|
},
|
|
disabled: {
|
|
type: Boolean,
|
|
default: null,
|
|
},
|
|
readonly: {
|
|
type: Boolean,
|
|
default: null,
|
|
},
|
|
};
|
|
|
|
const fieldProps = extend({}, cellSharedProps, fieldSharedProps, {
|
|
rows: numericProp,
|
|
type: makeStringProp<FieldType>('text'),
|
|
rules: Array as PropType<FieldRule[]>,
|
|
autosize: [Boolean, Object] as PropType<boolean | FieldAutosizeConfig>,
|
|
labelWidth: numericProp,
|
|
labelClass: unknownProp,
|
|
labelAlign: String as PropType<FieldTextAlign>,
|
|
showWordLimit: Boolean,
|
|
errorMessageAlign: String as PropType<FieldTextAlign>,
|
|
colon: {
|
|
type: Boolean,
|
|
default: null,
|
|
},
|
|
});
|
|
|
|
export type FieldProps = ExtractPropTypes<typeof fieldProps>;
|
|
|
|
export default defineComponent({
|
|
name,
|
|
|
|
props: fieldProps,
|
|
|
|
emits: [
|
|
'blur',
|
|
'focus',
|
|
'clear',
|
|
'keypress',
|
|
'click-input',
|
|
'click-left-icon',
|
|
'click-right-icon',
|
|
'update:modelValue',
|
|
],
|
|
|
|
setup(props, { emit, slots }) {
|
|
const id = useId();
|
|
const state = reactive({
|
|
focused: false,
|
|
validateFailed: false,
|
|
validateMessage: '',
|
|
});
|
|
|
|
const inputRef = ref<HTMLInputElement>();
|
|
const customValue = ref<() => unknown>();
|
|
|
|
const { parent: form } = useParent(FORM_KEY);
|
|
|
|
const getModelValue = () => String(props.modelValue ?? '');
|
|
|
|
const getProp = <T extends FieldFormSharedProps>(key: T) => {
|
|
if (isDef(props[key])) {
|
|
return props[key];
|
|
}
|
|
if (form && isDef(form.props[key])) {
|
|
return form.props[key];
|
|
}
|
|
};
|
|
|
|
const showClear = computed(() => {
|
|
const readonly = getProp('readonly');
|
|
|
|
if (props.clearable && !readonly) {
|
|
const hasValue = getModelValue() !== '';
|
|
const trigger =
|
|
props.clearTrigger === 'always' ||
|
|
(props.clearTrigger === 'focus' && state.focused);
|
|
|
|
return hasValue && trigger;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
const formValue = computed(() => {
|
|
if (customValue.value && slots.input) {
|
|
return customValue.value();
|
|
}
|
|
return props.modelValue;
|
|
});
|
|
|
|
const runRules = (rules: FieldRule[]) =>
|
|
rules.reduce(
|
|
(promise, rule) =>
|
|
promise.then(() => {
|
|
if (state.validateFailed) {
|
|
return;
|
|
}
|
|
|
|
let { value } = formValue;
|
|
|
|
if (rule.formatter) {
|
|
value = rule.formatter(value, rule);
|
|
}
|
|
|
|
if (!runSyncRule(value, rule)) {
|
|
state.validateFailed = true;
|
|
state.validateMessage = getRuleMessage(value, rule);
|
|
return;
|
|
}
|
|
|
|
if (rule.validator) {
|
|
return runRuleValidator(value, rule).then((result) => {
|
|
if (result && typeof result === 'string') {
|
|
state.validateFailed = true;
|
|
state.validateMessage = result;
|
|
} else if (result === false) {
|
|
state.validateFailed = true;
|
|
state.validateMessage = getRuleMessage(value, rule);
|
|
}
|
|
});
|
|
}
|
|
}),
|
|
Promise.resolve()
|
|
);
|
|
|
|
const resetValidation = () => {
|
|
if (state.validateFailed) {
|
|
state.validateFailed = false;
|
|
state.validateMessage = '';
|
|
}
|
|
};
|
|
|
|
const validate = (rules = props.rules) =>
|
|
new Promise<FieldValidateError | void>((resolve) => {
|
|
resetValidation();
|
|
if (rules) {
|
|
runRules(rules).then(() => {
|
|
if (state.validateFailed) {
|
|
resolve({
|
|
name: props.name,
|
|
message: state.validateMessage,
|
|
});
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
|
|
const validateWithTrigger = (trigger: FieldValidateTrigger) => {
|
|
if (form && props.rules) {
|
|
const { validateTrigger } = form.props;
|
|
const defaultTrigger = toArray(validateTrigger).includes(trigger);
|
|
const rules = props.rules.filter((rule) => {
|
|
if (rule.trigger) {
|
|
return toArray(rule.trigger).includes(trigger);
|
|
}
|
|
return defaultTrigger;
|
|
});
|
|
|
|
if (rules.length) {
|
|
validate(rules);
|
|
}
|
|
}
|
|
};
|
|
|
|
// native maxlength have incorrect line-break counting
|
|
// see: https://github.com/youzan/vant/issues/5033
|
|
const limitValueLength = (value: string) => {
|
|
const { maxlength } = props;
|
|
if (isDef(maxlength) && getStringLength(value) > maxlength) {
|
|
const modelValue = getModelValue();
|
|
if (modelValue && getStringLength(modelValue) === +maxlength) {
|
|
return modelValue;
|
|
}
|
|
return cutString(value, +maxlength);
|
|
}
|
|
return value;
|
|
};
|
|
|
|
const updateValue = (
|
|
value: string,
|
|
trigger: FieldFormatTrigger = 'onChange'
|
|
) => {
|
|
value = limitValueLength(value);
|
|
|
|
if (props.type === 'number' || props.type === 'digit') {
|
|
const isNumber = props.type === 'number';
|
|
value = formatNumber(value, isNumber, isNumber);
|
|
}
|
|
|
|
if (props.formatter && trigger === props.formatTrigger) {
|
|
value = props.formatter(value);
|
|
}
|
|
|
|
if (inputRef.value && inputRef.value.value !== value) {
|
|
inputRef.value.value = value;
|
|
}
|
|
|
|
if (value !== props.modelValue) {
|
|
emit('update:modelValue', value);
|
|
}
|
|
};
|
|
|
|
const onInput = (event: Event) => {
|
|
// skip update value when composing
|
|
if (!event.target!.composing) {
|
|
updateValue((event.target as HTMLInputElement).value);
|
|
}
|
|
};
|
|
|
|
const blur = () => inputRef.value?.blur();
|
|
const focus = () => inputRef.value?.focus();
|
|
|
|
const adjustTextareaSize = () => {
|
|
const input = inputRef.value;
|
|
if (props.type === 'textarea' && props.autosize && input) {
|
|
resizeTextarea(input, props.autosize);
|
|
}
|
|
};
|
|
|
|
const onFocus = (event: Event) => {
|
|
state.focused = true;
|
|
emit('focus', event);
|
|
nextTick(adjustTextareaSize);
|
|
|
|
// readonly not work in legacy mobile safari
|
|
if (getProp('readonly')) {
|
|
blur();
|
|
}
|
|
};
|
|
|
|
const onBlur = (event: Event) => {
|
|
if (getProp('readonly')) {
|
|
return;
|
|
}
|
|
|
|
state.focused = false;
|
|
updateValue(getModelValue(), 'onBlur');
|
|
emit('blur', event);
|
|
validateWithTrigger('onBlur');
|
|
nextTick(adjustTextareaSize);
|
|
resetScroll();
|
|
};
|
|
|
|
const onClickInput = (event: MouseEvent) => emit('click-input', event);
|
|
|
|
const onClickLeftIcon = (event: MouseEvent) =>
|
|
emit('click-left-icon', event);
|
|
|
|
const onClickRightIcon = (event: MouseEvent) =>
|
|
emit('click-right-icon', event);
|
|
|
|
const onClear = (event: MouseEvent) => {
|
|
preventDefault(event);
|
|
emit('update:modelValue', '');
|
|
emit('clear', event);
|
|
};
|
|
|
|
const showError = computed(() => {
|
|
if (typeof props.error === 'boolean') {
|
|
return props.error;
|
|
}
|
|
if (form && form.props.showError && state.validateFailed) {
|
|
return true;
|
|
}
|
|
});
|
|
|
|
const labelStyle = computed(() => {
|
|
const labelWidth = getProp('labelWidth');
|
|
if (labelWidth) {
|
|
return { width: addUnit(labelWidth) };
|
|
}
|
|
});
|
|
|
|
const onKeypress = (event: KeyboardEvent) => {
|
|
const ENTER_CODE = 13;
|
|
|
|
if (event.keyCode === ENTER_CODE) {
|
|
const submitOnEnter = form && form.props.submitOnEnter;
|
|
if (!submitOnEnter && props.type !== 'textarea') {
|
|
preventDefault(event);
|
|
}
|
|
|
|
// trigger blur after click keyboard search button
|
|
if (props.type === 'search') {
|
|
blur();
|
|
}
|
|
}
|
|
|
|
emit('keypress', event);
|
|
};
|
|
|
|
const getInputId = () => props.id || `${id}-input`;
|
|
|
|
const renderInput = () => {
|
|
const controlClass = bem('control', [
|
|
getProp('inputAlign'),
|
|
{
|
|
error: showError.value,
|
|
custom: !!slots.input,
|
|
'min-height': props.type === 'textarea' && !props.autosize,
|
|
},
|
|
]);
|
|
|
|
if (slots.input) {
|
|
return (
|
|
<div class={controlClass} onClick={onClickInput}>
|
|
{slots.input()}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const inputAttrs = {
|
|
id: getInputId(),
|
|
ref: inputRef,
|
|
name: props.name,
|
|
rows: props.rows !== undefined ? +props.rows : undefined,
|
|
class: controlClass,
|
|
disabled: getProp('disabled'),
|
|
readonly: getProp('readonly'),
|
|
autofocus: props.autofocus,
|
|
placeholder: props.placeholder,
|
|
autocomplete: props.autocomplete,
|
|
enterkeyhint: props.enterkeyhint,
|
|
'aria-labelledby': props.label ? `${id}-label` : undefined,
|
|
onBlur,
|
|
onFocus,
|
|
onInput,
|
|
onClick: onClickInput,
|
|
onChange: endComposing,
|
|
onKeypress,
|
|
onCompositionend: endComposing,
|
|
onCompositionstart: startComposing,
|
|
};
|
|
|
|
if (props.type === 'textarea') {
|
|
return <textarea {...inputAttrs} />;
|
|
}
|
|
|
|
return <input {...mapInputType(props.type)} {...inputAttrs} />;
|
|
};
|
|
|
|
const renderLeftIcon = () => {
|
|
const leftIconSlot = slots['left-icon'];
|
|
|
|
if (props.leftIcon || leftIconSlot) {
|
|
return (
|
|
<div class={bem('left-icon')} onClick={onClickLeftIcon}>
|
|
{leftIconSlot ? (
|
|
leftIconSlot()
|
|
) : (
|
|
<Icon name={props.leftIcon} classPrefix={props.iconPrefix} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
|
|
const renderRightIcon = () => {
|
|
const rightIconSlot = slots['right-icon'];
|
|
|
|
if (props.rightIcon || rightIconSlot) {
|
|
return (
|
|
<div class={bem('right-icon')} onClick={onClickRightIcon}>
|
|
{rightIconSlot ? (
|
|
rightIconSlot()
|
|
) : (
|
|
<Icon name={props.rightIcon} classPrefix={props.iconPrefix} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
|
|
const renderWordLimit = () => {
|
|
if (props.showWordLimit && props.maxlength) {
|
|
const count = getStringLength(getModelValue());
|
|
return (
|
|
<div class={bem('word-limit')}>
|
|
<span class={bem('word-num')}>{count}</span>/{props.maxlength}
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
|
|
const renderMessage = () => {
|
|
if (form && form.props.showErrorMessage === false) {
|
|
return;
|
|
}
|
|
|
|
const message = props.errorMessage || state.validateMessage;
|
|
|
|
if (message) {
|
|
const slot = slots['error-message'];
|
|
const errorMessageAlign = getProp('errorMessageAlign');
|
|
return (
|
|
<div class={bem('error-message', errorMessageAlign)}>
|
|
{slot ? slot({ message }) : message}
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
|
|
const renderLabel = () => {
|
|
const colon = getProp('colon') ? ':' : '';
|
|
|
|
if (slots.label) {
|
|
return [slots.label(), colon];
|
|
}
|
|
if (props.label) {
|
|
return (
|
|
<label id={`${id}-label`} for={getInputId()}>
|
|
{props.label + colon}
|
|
</label>
|
|
);
|
|
}
|
|
};
|
|
|
|
const renderFieldBody = () => [
|
|
<div class={bem('body')}>
|
|
{renderInput()}
|
|
{showClear.value && (
|
|
<Icon
|
|
name={props.clearIcon}
|
|
class={bem('clear')}
|
|
onTouchstart={onClear}
|
|
/>
|
|
)}
|
|
{renderRightIcon()}
|
|
{slots.button && <div class={bem('button')}>{slots.button()}</div>}
|
|
</div>,
|
|
renderWordLimit(),
|
|
renderMessage(),
|
|
];
|
|
|
|
useExpose<FieldExpose>({
|
|
blur,
|
|
focus,
|
|
validate,
|
|
formValue,
|
|
resetValidation,
|
|
});
|
|
|
|
provide(CUSTOM_FIELD_INJECTION_KEY, {
|
|
customValue,
|
|
resetValidation,
|
|
validateWithTrigger,
|
|
});
|
|
|
|
watch(
|
|
() => props.modelValue,
|
|
() => {
|
|
updateValue(getModelValue());
|
|
resetValidation();
|
|
validateWithTrigger('onChange');
|
|
nextTick(adjustTextareaSize);
|
|
}
|
|
);
|
|
|
|
onMounted(() => {
|
|
updateValue(getModelValue(), props.formatTrigger);
|
|
nextTick(adjustTextareaSize);
|
|
});
|
|
|
|
return () => {
|
|
const disabled = getProp('disabled');
|
|
const labelAlign = getProp('labelAlign');
|
|
const Label = renderLabel();
|
|
const LeftIcon = renderLeftIcon();
|
|
|
|
return (
|
|
<Cell
|
|
v-slots={{
|
|
icon: LeftIcon ? () => LeftIcon : null,
|
|
title: Label ? () => Label : null,
|
|
value: renderFieldBody,
|
|
extra: slots.extra,
|
|
}}
|
|
size={props.size}
|
|
icon={props.leftIcon}
|
|
class={bem({
|
|
error: showError.value,
|
|
disabled,
|
|
[`label-${labelAlign}`]: labelAlign,
|
|
})}
|
|
center={props.center}
|
|
border={props.border}
|
|
isLink={props.isLink}
|
|
clickable={props.clickable}
|
|
titleStyle={labelStyle.value}
|
|
valueClass={bem('value')}
|
|
titleClass={[
|
|
bem('label', [labelAlign, { required: props.required }]),
|
|
props.labelClass,
|
|
]}
|
|
arrowDirection={props.arrowDirection}
|
|
/>
|
|
);
|
|
};
|
|
},
|
|
});
|