ant-design-vue/components/vc-picker/Picker.tsx
2022-06-13 11:07:06 +08:00

626 lines
18 KiB
Vue

/**
* Removed:
* - getCalendarContainer: use `getPopupContainer` instead
* - onOk
*
* New Feature:
* - picker
* - allowEmpty
* - selectable
*
* Tips: Should add faq about `datetime` mode with `defaultValue`
*/
import type {
PickerPanelBaseProps,
PickerPanelDateProps,
PickerPanelTimeProps,
} from './PickerPanel';
import PickerPanel from './PickerPanel';
import PickerTrigger from './PickerTrigger';
import { formatValue, isEqual, parseValue } from './utils/dateUtil';
import getDataOrAriaProps, { toArray } from './utils/miscUtil';
import type { ContextOperationRefProps } from './PanelContext';
import { useProvidePanel } from './PanelContext';
import type { CustomFormat, PickerMode } from './interface';
import { getDefaultFormat, getInputSize, elementsContains } from './utils/uiUtil';
import usePickerInput from './hooks/usePickerInput';
import useTextValueMapping from './hooks/useTextValueMapping';
import useValueTexts from './hooks/useValueTexts';
import useHoverValue from './hooks/useHoverValue';
import type { CSSProperties, HTMLAttributes, Ref } from 'vue';
import { computed, defineComponent, ref, toRef, watch } from 'vue';
import type { ChangeEvent, FocusEventHandler, MouseEventHandler } from '../_util/EventInterface';
import type { VueNode } from '../_util/type';
import type { AlignType } from '../vc-align/interface';
import useMergedState from '../_util/hooks/useMergedState';
import { warning } from '../vc-util/warning';
import classNames from '../_util/classNames';
import type { SharedTimeProps } from './panels/TimePanel';
import { useProviderTrigger } from '../vc-trigger/context';
import { legacyPropsWarning } from './utils/warnUtil';
export type PickerRefConfig = {
focus: () => void;
blur: () => void;
};
export type PickerSharedProps<DateType> = {
dropdownClassName?: string;
dropdownAlign?: AlignType;
popupStyle?: CSSProperties;
transitionName?: string;
placeholder?: string;
allowClear?: boolean;
autofocus?: boolean;
disabled?: boolean;
tabindex?: number;
open?: boolean;
defaultOpen?: boolean;
/** Make input readOnly to avoid popup keyboard in mobile */
inputReadOnly?: boolean;
id?: string;
// Value
format?: string | CustomFormat<DateType> | (string | CustomFormat<DateType>)[];
// Render
suffixIcon?: VueNode;
clearIcon?: VueNode;
prevIcon?: VueNode;
nextIcon?: VueNode;
superPrevIcon?: VueNode;
superNextIcon?: VueNode;
getPopupContainer?: (node: HTMLElement) => HTMLElement;
panelRender?: (originPanel: VueNode) => VueNode;
inputRender?: (props: HTMLAttributes) => VueNode;
// Events
onChange?: (value: DateType | null, dateString: string) => void;
onOpenChange?: (open: boolean) => void;
onFocus?: FocusEventHandler;
onBlur?: FocusEventHandler;
onMousedown?: MouseEventHandler;
onMouseup?: MouseEventHandler;
onMouseenter?: MouseEventHandler;
onMouseleave?: MouseEventHandler;
onClick?: MouseEventHandler;
onContextmenu?: MouseEventHandler;
onKeydown?: (event: KeyboardEvent, preventDefault: () => void) => void;
// WAI-ARIA
role?: string;
name?: string;
autocomplete?: string;
direction?: 'ltr' | 'rtl';
showToday?: boolean;
showTime?: boolean | SharedTimeProps<DateType>;
};
type OmitPanelProps<Props> = Omit<
Props,
'onChange' | 'hideHeader' | 'pickerValue' | 'onPickerValueChange'
>;
export type PickerBaseProps<DateType> = {} & PickerSharedProps<DateType> &
OmitPanelProps<PickerPanelBaseProps<DateType>>;
export type PickerDateProps<DateType> = {} & PickerSharedProps<DateType> &
OmitPanelProps<PickerPanelDateProps<DateType>>;
export type PickerTimeProps<DateType> = {
picker: 'time';
/**
* @deprecated Please use `defaultValue` directly instead
* since `defaultOpenValue` will confuse user of current value status
*/
defaultOpenValue?: DateType;
} & PickerSharedProps<DateType> &
Omit<OmitPanelProps<PickerPanelTimeProps<DateType>>, 'format'>;
export type PickerProps<DateType> =
| PickerBaseProps<DateType>
| PickerDateProps<DateType>
| PickerTimeProps<DateType>;
// TMP type to fit for ts 3.9.2
type OmitType<DateType> = Omit<PickerBaseProps<DateType>, 'picker'> &
Omit<PickerDateProps<DateType>, 'picker'> &
Omit<PickerTimeProps<DateType>, 'picker'>;
type MergedPickerProps<DateType> = {
picker?: PickerMode;
} & OmitType<DateType>;
function Picker<DateType>() {
return defineComponent<MergedPickerProps<DateType>>({
name: 'Picker',
inheritAttrs: false,
props: [
'prefixCls',
'id',
'tabindex',
'dropdownClassName',
'dropdownAlign',
'popupStyle',
'transitionName',
'generateConfig',
'locale',
'inputReadOnly',
'allowClear',
'autofocus',
'showTime',
'showNow',
'showHour',
'showMinute',
'showSecond',
'picker',
'format',
'use12Hours',
'value',
'defaultValue',
'open',
'defaultOpen',
'defaultOpenValue',
'suffixIcon',
'clearIcon',
'disabled',
'disabledDate',
'placeholder',
'getPopupContainer',
'panelRender',
'inputRender',
'onChange',
'onOpenChange',
'onFocus',
'onBlur',
'onMousedown',
'onMouseup',
'onMouseenter',
'onMouseleave',
'onContextmenu',
'onClick',
'onKeydown',
'onSelect',
'direction',
'autocomplete',
'showToday',
'renderExtraFooter',
'dateRender',
'minuteStep',
'hourStep',
'secondStep',
'hideDisabledOptions',
] as any,
// slots: [
// 'suffixIcon',
// 'clearIcon',
// 'prevIcon',
// 'nextIcon',
// 'superPrevIcon',
// 'superNextIcon',
// 'panelRender',
// ],
setup(props, { attrs, expose }) {
const inputRef = ref(null);
const picker = computed(() => props.picker ?? 'date');
const needConfirmButton = computed(
() => (picker.value === 'date' && !!props.showTime) || picker.value === 'time',
);
// ============================ Warning ============================
if (process.env.NODE_ENV !== 'production') {
legacyPropsWarning(props);
}
// ============================= State =============================
const formatList = computed(() =>
toArray(getDefaultFormat(props.format, picker.value, props.showTime, props.use12Hours)),
);
// Panel ref
const panelDivRef = ref<HTMLDivElement>(null);
const inputDivRef = ref<HTMLDivElement>(null);
const containerRef = ref<HTMLDivElement>(null);
// Real value
const [mergedValue, setInnerValue] = useMergedState<DateType>(null, {
value: toRef(props, 'value'),
defaultValue: props.defaultValue,
});
const selectedValue = ref(mergedValue.value) as Ref<DateType>;
const setSelectedValue = (val: DateType) => {
selectedValue.value = val;
};
// Operation ref
const operationRef = ref<ContextOperationRefProps>(null);
// Open
const [mergedOpen, triggerInnerOpen] = useMergedState(false, {
value: toRef(props, 'open'),
defaultValue: props.defaultOpen,
postState: postOpen => (props.disabled ? false : postOpen),
onChange: newOpen => {
if (props.onOpenChange) {
props.onOpenChange(newOpen);
}
if (!newOpen && operationRef.value && operationRef.value.onClose) {
operationRef.value.onClose();
}
},
});
// ============================= Text ==============================
const [valueTexts, firstValueText] = useValueTexts(selectedValue, {
formatList,
generateConfig: toRef(props, 'generateConfig'),
locale: toRef(props, 'locale'),
});
const [text, triggerTextChange, resetText] = useTextValueMapping({
valueTexts,
onTextChange: newText => {
const inputDate = parseValue(newText, {
locale: props.locale,
formatList: formatList.value,
generateConfig: props.generateConfig,
});
if (inputDate && (!props.disabledDate || !props.disabledDate(inputDate))) {
setSelectedValue(inputDate);
}
},
});
// ============================ Trigger ============================
const triggerChange = (newValue: DateType | null) => {
const { onChange, generateConfig, locale } = props;
setSelectedValue(newValue);
setInnerValue(newValue);
if (onChange && !isEqual(generateConfig, mergedValue.value, newValue)) {
onChange(
newValue,
newValue
? formatValue(newValue, { generateConfig, locale, format: formatList.value[0] })
: '',
);
}
};
const triggerOpen = (newOpen: boolean) => {
if (props.disabled && newOpen) {
return;
}
triggerInnerOpen(newOpen);
};
const forwardKeydown = (e: KeyboardEvent) => {
if (mergedOpen.value && operationRef.value && operationRef.value.onKeydown) {
// Let popup panel handle keyboard
return operationRef.value.onKeydown(e);
}
/* istanbul ignore next */
/* eslint-disable no-lone-blocks */
{
warning(
false,
'Picker not correct forward Keydown operation. Please help to fire issue about this.',
);
return false;
}
};
const onInternalMouseup: MouseEventHandler = (...args) => {
if (props.onMouseup) {
props.onMouseup(...args);
}
if (inputRef.value) {
inputRef.value.focus();
triggerOpen(true);
}
};
// ============================= Input =============================
const [inputProps, { focused, typing }] = usePickerInput({
blurToCancel: needConfirmButton,
open: mergedOpen,
value: text,
triggerOpen,
forwardKeydown,
isClickOutside: target =>
!elementsContains(
[panelDivRef.value, inputDivRef.value, containerRef.value],
target as HTMLElement,
),
onSubmit: () => {
if (
// When user typing disabledDate with keyboard and enter, this value will be empty
!selectedValue.value ||
// Normal disabled check
(props.disabledDate && props.disabledDate(selectedValue.value))
) {
return false;
}
triggerChange(selectedValue.value);
triggerOpen(false);
resetText();
return true;
},
onCancel: () => {
triggerOpen(false);
setSelectedValue(mergedValue.value);
resetText();
},
onKeydown: (e, preventDefault) => {
props.onKeydown?.(e, preventDefault);
},
onFocus: (e: FocusEvent) => {
props.onFocus?.(e);
},
onBlur: (e: FocusEvent) => {
props.onBlur?.(e);
},
});
// ============================= Sync ==============================
// Close should sync back with text value
watch([mergedOpen, valueTexts], () => {
if (!mergedOpen.value) {
setSelectedValue(mergedValue.value);
if (!valueTexts.value.length || valueTexts.value[0] === '') {
triggerTextChange('');
} else if (firstValueText.value !== text.value) {
resetText();
}
}
});
// Change picker should sync back with text value
watch(picker, () => {
if (!mergedOpen.value) {
resetText();
}
});
// Sync innerValue with control mode
watch(mergedValue, () => {
// Sync select value
setSelectedValue(mergedValue.value);
});
const [hoverValue, onEnter, onLeave] = useHoverValue(text, {
formatList,
generateConfig: toRef(props, 'generateConfig'),
locale: toRef(props, 'locale'),
});
const onContextSelect = (date: DateType, type: 'key' | 'mouse' | 'submit') => {
if (type === 'submit' || (type !== 'key' && !needConfirmButton.value)) {
// triggerChange will also update selected values
triggerChange(date);
triggerOpen(false);
}
};
useProvidePanel({
operationRef,
hideHeader: computed(() => picker.value === 'time'),
panelRef: panelDivRef,
onSelect: onContextSelect,
open: mergedOpen,
defaultOpenValue: toRef(props, 'defaultOpenValue'),
onDateMouseenter: onEnter,
onDateMouseleave: onLeave,
});
expose({
focus: () => {
if (inputRef.value) {
inputRef.value.focus();
}
},
blur: () => {
if (inputRef.value) {
inputRef.value.blur();
}
},
});
const getPortal = useProviderTrigger();
return () => {
const {
prefixCls = 'rc-picker',
id,
tabindex,
dropdownClassName,
dropdownAlign,
popupStyle,
transitionName,
generateConfig,
locale,
inputReadOnly,
allowClear,
autofocus,
picker = 'date',
defaultOpenValue,
suffixIcon,
clearIcon,
disabled,
placeholder,
getPopupContainer,
panelRender,
onMousedown,
onMouseenter,
onMouseleave,
onContextmenu,
onClick,
onSelect,
direction,
autocomplete = 'off',
} = props;
// ============================= Panel =============================
const panelProps = {
// Remove `picker` & `format` here since TimePicker is little different with other panel
...(props as Omit<MergedPickerProps<DateType>, 'picker' | 'format'>),
...attrs,
class: classNames({
[`${prefixCls}-panel-focused`]: !typing.value,
}),
style: undefined,
pickerValue: undefined,
onPickerValueChange: undefined,
onChange: null,
};
let panelNode: VueNode = (
<PickerPanel
{...panelProps}
generateConfig={generateConfig}
value={selectedValue.value}
locale={locale}
tabindex={-1}
onSelect={date => {
onSelect?.(date);
setSelectedValue(date);
}}
direction={direction}
onPanelChange={(viewDate, mode) => {
const { onPanelChange } = props;
onLeave(true);
onPanelChange?.(viewDate, mode);
}}
/>
);
if (panelRender) {
panelNode = panelRender(panelNode);
}
const panel = (
<div
class={`${prefixCls}-panel-container`}
onMousedown={e => {
e.preventDefault();
}}
>
{panelNode}
</div>
);
let suffixNode: VueNode;
if (suffixIcon) {
suffixNode = <span class={`${prefixCls}-suffix`}>{suffixIcon}</span>;
}
let clearNode: VueNode;
if (allowClear && mergedValue.value && !disabled) {
clearNode = (
<span
onMousedown={e => {
e.preventDefault();
e.stopPropagation();
}}
onMouseup={e => {
e.preventDefault();
e.stopPropagation();
triggerChange(null);
triggerOpen(false);
}}
class={`${prefixCls}-clear`}
role="button"
>
{clearIcon || <span class={`${prefixCls}-clear-btn`} />}
</span>
);
}
const mergedInputProps: HTMLAttributes = {
id,
tabindex,
disabled,
readonly: inputReadOnly || typeof formatList.value[0] === 'function' || !typing.value,
value: hoverValue.value || text.value,
onInput: (e: ChangeEvent) => {
triggerTextChange(e.target.value);
},
autofocus,
placeholder,
ref: inputRef,
title: text.value,
...inputProps.value,
size: getInputSize(picker, formatList.value[0], generateConfig),
...getDataOrAriaProps(props),
autocomplete,
};
const inputNode = props.inputRender ? (
props.inputRender(mergedInputProps)
) : (
<input {...mergedInputProps} />
);
// ============================ Warning ============================
if (process.env.NODE_ENV !== 'production') {
warning(
!defaultOpenValue,
'`defaultOpenValue` may confuse user for the current value status. Please use `defaultValue` instead.',
);
}
// ============================ Return =============================
const popupPlacement = direction === 'rtl' ? 'bottomRight' : 'bottomLeft';
return (
<PickerTrigger
visible={mergedOpen.value}
popupStyle={popupStyle}
prefixCls={prefixCls}
dropdownClassName={dropdownClassName}
dropdownAlign={dropdownAlign}
getPopupContainer={getPopupContainer}
transitionName={transitionName}
popupPlacement={popupPlacement}
direction={direction}
v-slots={{
popupElement: () => panel,
}}
>
<div
ref={containerRef}
class={classNames(prefixCls, attrs.class, {
[`${prefixCls}-disabled`]: disabled,
[`${prefixCls}-focused`]: focused.value,
[`${prefixCls}-rtl`]: direction === 'rtl',
})}
style={attrs.style as CSSProperties}
onMousedown={onMousedown}
onMouseup={onInternalMouseup}
onMouseenter={onMouseenter}
onMouseleave={onMouseleave}
onContextmenu={onContextmenu}
onClick={onClick}
>
<div
class={classNames(`${prefixCls}-input`, {
[`${prefixCls}-input-placeholder`]: !!hoverValue.value,
})}
ref={inputDivRef}
>
{inputNode}
{suffixNode}
{clearNode}
</div>
{getPortal()}
</div>
</PickerTrigger>
);
};
},
});
}
export default Picker<any>();