vant/packages/vant/src/address-edit/AddressEdit.tsx

384 lines
10 KiB
TypeScript

import {
ref,
watch,
computed,
nextTick,
reactive,
defineComponent,
type PropType,
type ExtractPropTypes,
} from 'vue';
// Utils
import {
extend,
isObject,
isMobile,
truthProp,
numericProp,
makeArrayProp,
makeNumericProp,
createNamespace,
} from '../utils';
// Composables
import { useExpose } from '../composables/use-expose';
// Components
import { Area, AreaList, AreaInstance } from '../area';
import { Cell } from '../cell';
import { Form } from '../form';
import { Field, FieldRule } from '../field';
import { Popup } from '../popup';
import { Toast } from '../toast';
import { Button } from '../button';
import { Switch } from '../switch';
import AddressEditDetail from './AddressEditDetail';
// Types
import type { AddressEditInfo, AddressEditSearchItem } from './types';
import { PickerConfirmEventParams, PickerOption } from '../picker';
import { AREA_EMPTY_CODE } from '../area/utils';
const [name, bem, t] = createNamespace('address-edit');
const DEFAULT_DATA: AddressEditInfo = {
name: '',
tel: '',
city: '',
county: '',
country: '',
province: '',
areaCode: '',
isDefault: false,
postalCode: '',
addressDetail: '',
};
const isPostal = (value: string) => /^\d{6}$/.test(value);
const addressEditProps = {
areaList: Object as PropType<AreaList>,
isSaving: Boolean,
isDeleting: Boolean,
validator: Function as PropType<
(key: string, value: string) => string | undefined
>,
showArea: truthProp,
showDetail: truthProp,
showDelete: Boolean,
showPostal: Boolean,
disableArea: Boolean,
searchResult: Array as PropType<AddressEditSearchItem[]>,
telMaxlength: numericProp,
showSetDefault: Boolean,
saveButtonText: String,
areaPlaceholder: String,
deleteButtonText: String,
showSearchResult: Boolean,
detailRows: makeNumericProp(1),
detailMaxlength: makeNumericProp(200),
areaColumnsPlaceholder: makeArrayProp<string>(),
addressInfo: {
type: Object as PropType<Partial<AddressEditInfo>>,
default: () => extend({}, DEFAULT_DATA),
},
telValidator: {
type: Function as PropType<(val: string) => boolean>,
default: isMobile,
},
postalValidator: {
type: Function as PropType<(val: string) => boolean>,
default: isPostal,
},
};
export type AddressEditProps = ExtractPropTypes<typeof addressEditProps>;
export default defineComponent({
name,
props: addressEditProps,
emits: [
'save',
'focus',
'delete',
'clickArea',
'changeArea',
'changeDetail',
'selectSearch',
'changeDefault',
],
setup(props, { emit, slots }) {
const areaRef = ref<AreaInstance>();
const data = reactive({} as AddressEditInfo);
const showAreaPopup = ref(false);
const detailFocused = ref(false);
const areaListLoaded = computed(
() => isObject(props.areaList) && Object.keys(props.areaList).length
);
const areaText = computed(() => {
const { province, city, county, areaCode } = data;
if (areaCode) {
const arr = [province, city, county];
if (province && province === city) {
arr.splice(1, 1);
}
return arr.filter(Boolean).join('/');
}
return '';
});
// hide bottom field when use search && detail get focused
const hideBottomFields = computed(
() => props.searchResult?.length && detailFocused.value
);
const onFocus = (key: string) => {
detailFocused.value = key === 'addressDetail';
emit('focus', key);
};
const rules = computed<Record<string, FieldRule[]>>(() => {
const { validator, telValidator, postalValidator } = props;
const makeRule = (name: string, emptyMessage: string): FieldRule => ({
validator: (value) => {
if (validator) {
const message = validator(name, value);
if (message) {
return message;
}
}
if (!value) {
return emptyMessage;
}
return true;
},
});
return {
name: [makeRule('name', t('nameEmpty'))],
tel: [
makeRule('tel', t('telInvalid')),
{ validator: telValidator, message: t('telInvalid') },
],
areaCode: [makeRule('areaCode', t('areaEmpty'))],
addressDetail: [makeRule('addressDetail', t('addressEmpty'))],
postalCode: [
makeRule('addressDetail', t('postalEmpty')),
{ validator: postalValidator, message: t('postalEmpty') },
],
};
});
const onSave = () => emit('save', data);
const onChangeDetail = (val: string) => {
data.addressDetail = val;
emit('changeDetail', val);
};
const assignAreaText = (options: PickerOption[]) => {
data.province = options[0].text as string;
data.city = options[1].text as string;
data.county = options[2].text as string;
};
const onAreaConfirm = ({
selectedValues,
selectedOptions,
}: PickerConfirmEventParams) => {
if (selectedValues.some((value) => value === AREA_EMPTY_CODE)) {
Toast(t('areaEmpty'));
} else {
showAreaPopup.value = false;
assignAreaText(selectedOptions);
emit('changeArea', selectedOptions);
}
};
const onDelete = () => emit('delete', data);
// set area code to area component
const setAreaCode = (code?: string) => {
data.areaCode = code || '';
};
const onDetailBlur = () => {
// await for click search event
setTimeout(() => {
detailFocused.value = false;
});
};
const setAddressDetail = (value: string) => {
data.addressDetail = value;
};
const renderSetDefaultCell = () => {
if (props.showSetDefault) {
const slots = {
'right-icon': () => (
<Switch
v-model={data.isDefault}
onChange={(event) => emit('changeDefault', event)}
/>
),
};
return (
<Cell
v-slots={slots}
v-show={!hideBottomFields.value}
center
title={t('defaultAddress')}
class={bem('default')}
/>
);
}
};
useExpose({
setAreaCode,
setAddressDetail,
});
watch(
() => props.addressInfo,
(value) => {
extend(data, DEFAULT_DATA, value);
nextTick(() => {
const options = areaRef.value?.getSelectedOptions();
if (
options &&
options.every((option) => option.value !== AREA_EMPTY_CODE)
) {
assignAreaText(options);
}
});
},
{
deep: true,
immediate: true,
}
);
return () => {
const { disableArea } = props;
return (
<Form class={bem()} onSubmit={onSave}>
<div class={bem('fields')}>
<Field
v-model={data.name}
clearable
label={t('name')}
rules={rules.value.name}
placeholder={t('name')}
onFocus={() => onFocus('name')}
/>
<Field
v-model={data.tel}
clearable
type="tel"
label={t('tel')}
rules={rules.value.tel}
maxlength={props.telMaxlength}
placeholder={t('tel')}
onFocus={() => onFocus('tel')}
/>
<Field
v-show={props.showArea}
readonly
label={t('area')}
is-link={!disableArea}
modelValue={areaText.value}
rules={rules.value.areaCode}
placeholder={props.areaPlaceholder || t('area')}
onFocus={() => onFocus('areaCode')}
onClick={() => {
emit('clickArea');
showAreaPopup.value = !disableArea;
}}
/>
<AddressEditDetail
show={props.showDetail}
rows={props.detailRows}
rules={rules.value.addressDetail}
value={data.addressDetail}
focused={detailFocused.value}
maxlength={props.detailMaxlength}
searchResult={props.searchResult}
showSearchResult={props.showSearchResult}
onBlur={onDetailBlur}
onFocus={() => onFocus('addressDetail')}
onInput={onChangeDetail}
onSelectSearch={(event: Event) => emit('selectSearch', event)}
/>
{props.showPostal && (
<Field
v-show={!hideBottomFields.value}
v-model={data.postalCode}
type="tel"
rules={rules.value.postalCode}
label={t('postal')}
maxlength="6"
placeholder={t('postal')}
onFocus={() => onFocus('postalCode')}
/>
)}
{slots.default?.()}
</div>
{renderSetDefaultCell()}
<div v-show={!hideBottomFields.value} class={bem('buttons')}>
<Button
block
round
type="primary"
text={props.saveButtonText || t('save')}
class={bem('button')}
loading={props.isSaving}
nativeType="submit"
/>
{props.showDelete && (
<Button
block
round
class={bem('button')}
loading={props.isDeleting}
text={props.deleteButtonText || t('delete')}
onClick={onDelete}
/>
)}
</div>
<Popup
v-model:show={showAreaPopup.value}
round
teleport="body"
position="bottom"
lazyRender={false}
>
<Area
v-model={data.areaCode}
ref={areaRef}
loading={!areaListLoaded.value}
areaList={props.areaList}
columnsPlaceholder={props.areaColumnsPlaceholder}
onConfirm={onAreaConfirm}
onCancel={() => {
showAreaPopup.value = false;
}}
/>
</Popup>
</Form>
);
};
},
});