From 7b739aaca85bfb0cfc675b88de06426579dc3b76 Mon Sep 17 00:00:00 2001 From: saller Date: Mon, 30 Oct 2023 15:15:36 +0800 Subject: [PATCH] feat(comp:*): all input components with overlay support focus and blur event (#1714) --- .../_private/selector/src/Selector.tsx | 10 +- .../selector/src/composables/useInputState.ts | 5 - .../_private/selector/src/contents/Input.tsx | 3 +- .../components/_private/selector/src/token.ts | 1 - .../components/_private/selector/src/types.ts | 1 + .../__snapshots__/trigger.spec.ts.snap | 2 +- .../_private/trigger/src/Trigger.tsx | 19 ++- packages/components/cascader/demo/Basic.vue | 14 +- packages/components/cascader/src/Cascader.tsx | 32 +++- packages/components/cascader/src/types.ts | 2 + .../__snapshots__/datePicker.spec.ts.snap | 2 +- .../dateRangePicker.spec.ts.snap | 2 +- .../date-picker/demo/AllowInput.vue | 15 +- .../components/date-picker/src/DatePicker.tsx | 45 ++++-- .../date-picker/src/DateRangePicker.tsx | 48 +++--- .../src/composables/useKeyboardEvents.ts | 46 ++++-- .../src/composables/useOverlayProps.ts | 2 +- .../src/composables/useOverlayState.ts | 18 +-- .../src/composables/usePickerState.ts | 10 +- .../src/composables/useTriggerProps.ts | 9 +- packages/components/date-picker/src/token.ts | 2 + .../date-picker/src/trigger/RangeTrigger.tsx | 20 ++- .../date-picker/src/trigger/Trigger.tsx | 21 ++- packages/components/select/demo/Basic.vue | 14 +- packages/components/select/src/Select.tsx | 27 +++- .../src/composables/useKeyboardEvents.ts | 35 ++++- packages/components/select/src/types.ts | 2 + .../__snapshots__/timePicker.spec.ts.snap | 2 +- .../timeRangePicker.spec.ts.snap | 2 +- .../time-picker/__tests__/timePicker.spec.ts | 4 +- .../__tests__/timeRangePicker.spec.ts | 9 +- .../components/time-picker/demo/Basic.vue | 15 +- .../components/time-picker/src/TimePicker.tsx | 45 ++++-- .../time-picker/src/TimeRangePicker.tsx | 45 ++++-- .../src/composables/useKeyboardEvents.ts | 35 ++++- .../src/composables/useOverlayProps.ts | 2 +- .../src/composables/useOverlayState.ts | 18 +-- .../src/composables/usePickerState.ts | 10 +- .../src/composables/useTriggerProps.ts | 12 +- packages/components/time-picker/src/tokens.ts | 1 + .../time-picker/src/trigger/RangeTrigger.tsx | 19 ++- .../time-picker/src/trigger/Trigger.tsx | 20 ++- .../components/tree-select/demo/Basic.vue | 8 + .../components/tree-select/src/TreeSelect.tsx | 28 +++- .../tree-select/src/content/Content.tsx | 2 +- packages/components/tree-select/src/types.ts | 2 + packages/components/utils/index.ts | 1 + .../utils/src/useOverlayFocusMonitor.ts | 142 ++++++++++++++++++ 48 files changed, 605 insertions(+), 224 deletions(-) create mode 100644 packages/components/utils/src/useOverlayFocusMonitor.ts diff --git a/packages/components/_private/selector/src/Selector.tsx b/packages/components/_private/selector/src/Selector.tsx index efde3108d..48c4b69fe 100644 --- a/packages/components/_private/selector/src/Selector.tsx +++ b/packages/components/_private/selector/src/Selector.tsx @@ -39,7 +39,7 @@ export default defineComponent({ }) const mergedSize = useFormSize(props, props.config) const mergedSuffix = computed(() => { - return props.suffix ?? (mergedSearchable.value && isFocused.value ? 'search' : props.config.suffix) + return props.suffix ?? (mergedSearchable.value && props.focused ? 'search' : props.config.suffix) }) const showPlaceholder = computed(() => { return props.value.length === 0 && !isComposing.value && !inputValue.value @@ -50,7 +50,6 @@ export default defineComponent({ inputRef, inputValue, isComposing, - isFocused, blur, focus, handleCompositionStart, @@ -77,7 +76,7 @@ export default defineComponent({ [`${prefixCls}-borderless`]: borderless, [`${prefixCls}-clearable`]: mergedClearable.value, [`${prefixCls}-disabled`]: props.disabled, - [`${prefixCls}-focused`]: isFocused.value, + [`${prefixCls}-focused`]: props.focused, [`${prefixCls}-multiple`]: multiple, [`${prefixCls}-opened`]: props.opened, [`${prefixCls}-readonly`]: props.readonly, @@ -100,7 +99,7 @@ export default defineComponent({ } const { disabled, readonly } = props - if (disabled || readonly || isFocused.value) { + if (disabled || readonly || props.focused) { evt.preventDefault() } } @@ -135,7 +134,6 @@ export default defineComponent({ inputRef, inputValue, isComposing, - isFocused, handleCompositionStart, handleCompositionEnd, handleInput, @@ -246,7 +244,7 @@ export default defineComponent({ return (
- {isFocused.value && !opened && ( + {props.focused && !opened && ( {value.join(', ')} diff --git a/packages/components/_private/selector/src/composables/useInputState.ts b/packages/components/_private/selector/src/composables/useInputState.ts index f7215b0c7..807158d8a 100644 --- a/packages/components/_private/selector/src/composables/useInputState.ts +++ b/packages/components/_private/selector/src/composables/useInputState.ts @@ -19,7 +19,6 @@ export interface InputStateContext { inputRef: Ref inputValue: Ref isComposing: Ref - isFocused: Ref focus: (options?: FocusOptions) => void blur: () => void handleCompositionStart: (evt: CompositionEvent) => void @@ -33,15 +32,12 @@ export function useInputState(props: SelectorProps, mergedSearchable: ComputedRe const mirrorRef = ref() const inputValue = ref('') const isComposing = ref(false) - const isFocused = ref(false) const handleFocus = (evt: FocusEvent) => { - isFocused.value = true callEmit(props.onFocus, evt) } const handleBlur = (evt: FocusEvent) => { - isFocused.value = false callEmit(props.onBlur, evt) } @@ -120,7 +116,6 @@ export function useInputState(props: SelectorProps, mergedSearchable: ComputedRe mirrorRef, inputValue, isComposing, - isFocused, handleCompositionStart, handleCompositionEnd, handleInput, diff --git a/packages/components/_private/selector/src/contents/Input.tsx b/packages/components/_private/selector/src/contents/Input.tsx index d67870efa..41c0dbdc8 100644 --- a/packages/components/_private/selector/src/contents/Input.tsx +++ b/packages/components/_private/selector/src/contents/Input.tsx @@ -18,7 +18,6 @@ export default defineComponent({ mirrorRef, inputRef, inputValue, - isFocused, handleCompositionStart, handleCompositionEnd, handleInput, @@ -26,7 +25,7 @@ export default defineComponent({ } = inject(selectorToken)! const inputReadonly = computed( - () => props.readonly || !isFocused.value || !(props.allowInput || mergedSearchable.value), + () => props.readonly || !props.focused || !(props.allowInput || mergedSearchable.value), ) const innerStyle = computed(() => { return { opacity: inputReadonly.value ? 0 : undefined } diff --git a/packages/components/_private/selector/src/token.ts b/packages/components/_private/selector/src/token.ts index b756d8c7c..5dc3962f8 100644 --- a/packages/components/_private/selector/src/token.ts +++ b/packages/components/_private/selector/src/token.ts @@ -18,7 +18,6 @@ export interface SelectorContext { inputRef: Ref inputValue: Ref isComposing: Ref - isFocused: Ref handleCompositionStart: (evt: CompositionEvent) => void handleCompositionEnd: (evt: CompositionEvent) => void handleInput: (evt: Event) => void diff --git a/packages/components/_private/selector/src/types.ts b/packages/components/_private/selector/src/types.ts index b8e5d2405..116a53e74 100644 --- a/packages/components/_private/selector/src/types.ts +++ b/packages/components/_private/selector/src/types.ts @@ -25,6 +25,7 @@ export const selectorProps = { dataSource: { type: Array, required: true }, defaultLabelSlotName: { type: String, default: undefined }, disabled: { type: Boolean, required: true }, + focused: { type: Boolean, required: true }, maxLabel: { type: [Number, String] as PropType, required: true }, multiple: { type: Boolean, required: true }, opened: { type: Boolean, required: true }, diff --git a/packages/components/_private/trigger/__tests__/__snapshots__/trigger.spec.ts.snap b/packages/components/_private/trigger/__tests__/__snapshots__/trigger.spec.ts.snap index 2ecbcd836..59ccf9e9e 100644 --- a/packages/components/_private/trigger/__tests__/__snapshots__/trigger.spec.ts.snap +++ b/packages/components/_private/trigger/__tests__/__snapshots__/trigger.spec.ts.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1 exports[`Trigger > render work 1`] = ` -"
+"
diff --git a/packages/components/_private/trigger/src/Trigger.tsx b/packages/components/_private/trigger/src/Trigger.tsx index ed4bc17ec..72332ed48 100644 --- a/packages/components/_private/trigger/src/Trigger.tsx +++ b/packages/components/_private/trigger/src/Trigger.tsx @@ -59,6 +59,16 @@ export default defineComponent({ callEmit(props.onClick, evt) } + const handleMouseDown = (evt: MouseEvent) => { + if (evt.target instanceof HTMLInputElement) { + return + } + + const { disabled, readonly } = props + if (disabled || readonly || props.focused) { + evt.preventDefault() + } + } const handleKeyDown = (evt: KeyboardEvent) => { if (props.disabled) { @@ -100,7 +110,14 @@ export default defineComponent({ } return () => ( -
+
{slots.default?.()} {renderSuffix()} {renderClearIcon()} diff --git a/packages/components/cascader/demo/Basic.vue b/packages/components/cascader/demo/Basic.vue index d428c19ec..092a0d43d 100644 --- a/packages/components/cascader/demo/Basic.vue +++ b/packages/components/cascader/demo/Basic.vue @@ -1,6 +1,12 @@ @@ -14,6 +20,12 @@ const fullPathValue = ref(['components', 'general', 'button']) const singlePathValue = ref('button') const onChange = console.log +const onFocus = (evt: FocusEvent) => { + console.log('focus', evt) +} +const onBlur = (evt: FocusEvent) => { + console.log('blur', evt) +} const dataSource: CascaderData[] = [ { diff --git a/packages/components/cascader/src/Cascader.tsx b/packages/components/cascader/src/Cascader.tsx index b68d800da..624be3dc8 100644 --- a/packages/components/cascader/src/Cascader.tsx +++ b/packages/components/cascader/src/Cascader.tsx @@ -5,7 +5,7 @@ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE */ -import { computed, defineComponent, normalizeClass, provide, ref, toRaw, toRef, watch } from 'vue' +import { computed, defineComponent, normalizeClass, onMounted, provide, ref, toRaw, toRef, watch } from 'vue' import { useAccessorAndControl } from '@idux/cdk/forms' import { type VKey, callEmit, useState } from '@idux/cdk/utils' @@ -15,7 +15,7 @@ import { ɵSelector, type ɵSelectorInstance } from '@idux/components/_private/s import { useGlobalConfig } from '@idux/components/config' import { useFormItemRegister, useFormSize, useFormStatus } from '@idux/components/form' import { ɵUseOverlayState } from '@idux/components/select' -import { useGetDisabled, useGetKey } from '@idux/components/utils' +import { useGetDisabled, useGetKey, useOverlayFocusMonitor } from '@idux/components/utils' import { useDataSource } from './composables/useDataSource' import { usePanelProps } from './composables/usePanelProps' @@ -84,13 +84,25 @@ export default defineComponent({ clearInput() }) - const handleOverlayClick = () => { + const handleOverlayMousedown = () => { if (props.searchable !== 'overlay') { - focus() + setTimeout(focus) } } - const handleBlur = () => accessor.markAsBlurred() + const onFocus = (evt: FocusEvent) => { + callEmit(props.onFocus, evt) + } + const onBlur = (evt: FocusEvent) => { + accessor.markAsBlurred() + setOverlayOpened(false) + callEmit(props.onBlur, evt) + } + const { focused, handleFocus, handleBlur, bindOverlayMonitor } = useOverlayFocusMonitor(onFocus, onBlur) + onMounted(() => { + bindOverlayMonitor(overlayRef, overlayOpened) + }) + const handleItemRemove = (key: VKey) => { focus() selectedStateContext.handleSelect(key) @@ -129,6 +141,7 @@ export default defineComponent({ config={config} dataSource={selectedData.value} disabled={accessor.disabled} + focused={focused.value} maxLabel={props.maxLabel} multiple={props.multiple} opened={overlayOpened.value} @@ -139,6 +152,7 @@ export default defineComponent({ status={mergedStatus.value} suffix={props.suffix} value={resolvedSelectedKeys.value} + onFocus={handleFocus} onBlur={handleBlur} onClear={handleClear} onInputValueChange={setInputValue} @@ -190,13 +204,17 @@ export default defineComponent({ ) } - return
{overlayRender ? overlayRender(children) : children}
+ return ( +
+ {overlayRender ? overlayRender(children) : children} +
+ ) } return () => { const overlayProps = { class: overlayClasses.value, - clickOutside: true, + clickOutside: false, container: props.overlayContainer ?? config.overlayContainer, containerFallback: `.${mergedPrefixCls.value}-overlay-container`, disabled: accessor.disabled || props.readonly, diff --git a/packages/components/cascader/src/types.ts b/packages/components/cascader/src/types.ts index c4382687f..a8d22eb41 100644 --- a/packages/components/cascader/src/types.ts +++ b/packages/components/cascader/src/types.ts @@ -120,6 +120,8 @@ export const cascaderProps = { 'onUpdate:open': [Function, Array] as PropType void>>, onChange: [Function, Array] as PropType void>>, onClear: [Function, Array] as PropType void>>, + onFocus: [Function, Array] as PropType void>>, + onBlur: [Function, Array] as PropType void>>, onExpand: [Function, Array] as PropType void>>, onExpandedChange: [Function, Array] as PropType void>>, onLoaded: [Function, Array] as PropType void>>, diff --git a/packages/components/date-picker/__tests__/__snapshots__/datePicker.spec.ts.snap b/packages/components/date-picker/__tests__/__snapshots__/datePicker.spec.ts.snap index 34bbb530b..66073b9ba 100644 --- a/packages/components/date-picker/__tests__/__snapshots__/datePicker.spec.ts.snap +++ b/packages/components/date-picker/__tests__/__snapshots__/datePicker.spec.ts.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1 exports[`DatePicker > render work 1`] = ` -"
+"
diff --git a/packages/components/date-picker/__tests__/__snapshots__/dateRangePicker.spec.ts.snap b/packages/components/date-picker/__tests__/__snapshots__/dateRangePicker.spec.ts.snap index 0b94cd72a..fc676e9a8 100644 --- a/packages/components/date-picker/__tests__/__snapshots__/dateRangePicker.spec.ts.snap +++ b/packages/components/date-picker/__tests__/__snapshots__/dateRangePicker.spec.ts.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1 exports[`DateRangePicker > render work 1`] = ` -"
+"
diff --git a/packages/components/date-picker/demo/AllowInput.vue b/packages/components/date-picker/demo/AllowInput.vue index 82cc66e8f..cf2118fe1 100644 --- a/packages/components/date-picker/demo/AllowInput.vue +++ b/packages/components/date-picker/demo/AllowInput.vue @@ -8,7 +8,13 @@ - + @@ -27,4 +33,11 @@ const quarterValue = ref('2022-Q1') const yearValue = ref('2022') const allowInput = ref(true) + +const onFocus = (evt: FocusEvent) => { + console.log('focus', evt) +} +const onBlur = (evt: FocusEvent) => { + console.log('blur', evt) +} diff --git a/packages/components/date-picker/src/DatePicker.tsx b/packages/components/date-picker/src/DatePicker.tsx index c900820f2..1ab7faa43 100644 --- a/packages/components/date-picker/src/DatePicker.tsx +++ b/packages/components/date-picker/src/DatePicker.tsx @@ -5,11 +5,12 @@ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE */ -import { computed, defineComponent, normalizeClass, provide, toRef, watch } from 'vue' +import { computed, defineComponent, normalizeClass, onMounted, provide, ref, toRef, watch } from 'vue' -import { ɵOverlay } from '@idux/components/_private/overlay' +import { ɵOverlay, ɵOverlayInstance } from '@idux/components/_private/overlay' import { useDateConfig, useGlobalConfig } from '@idux/components/config' import { useFormElement } from '@idux/components/form' +import { useOverlayFocusMonitor } from '@idux/components/utils' import { useControl } from './composables/useControl' import { useFormat } from './composables/useFormat' @@ -34,15 +35,24 @@ export default defineComponent({ const config = useGlobalConfig('datePicker') const dateConfig = useDateConfig() + const overlayRef = ref<ɵOverlayInstance>() + const triggerRef = ref<{ focus: () => void }>() + const { elementRef: inputRef, focus, blur } = useFormElement() expose({ focus, blur }) + const { overlayOpened, overlayVisible, onAfterLeave, setOverlayOpened } = useOverlayState(props) const inputEnableStatus = useInputEnableStatus(props, config) const formatContext = useFormat(props, config) - const pickerStateContext = usePickerState(props, config, dateConfig, formatContext.formatRef) + const pickerStateContext = usePickerState(props, config, dateConfig, formatContext.formatRef, setOverlayOpened) + + const { accessor, handleFocus: _handleFocus, handleBlur: _handleBlur, handleChange } = pickerStateContext - const { accessor, handleChange } = pickerStateContext + const { focused, handleFocus, handleBlur, bindOverlayMonitor } = useOverlayFocusMonitor(_handleFocus, _handleBlur) + onMounted(() => { + bindOverlayMonitor(overlayRef, overlayOpened) + }) const controlContext = useControl( dateConfig, @@ -51,8 +61,7 @@ export default defineComponent({ toRef(accessor, 'value'), handleChange, ) - const { overlayOpened, overlayVisible, onAfterLeave, setOverlayOpened } = useOverlayState(props, controlContext) - const handleKeyDown = useKeyboardEvents(setOverlayOpened) + const handleKeyDown = useKeyboardEvents(overlayOpened, setOverlayOpened) const context = { props, @@ -60,6 +69,7 @@ export default defineComponent({ common, locale, config, + focused, mergedPrefixCls, dateConfig, inputRef, @@ -72,29 +82,34 @@ export default defineComponent({ controlContext, ...formatContext, ...pickerStateContext, + handleFocus, + handleBlur, } provide(datePickerToken, context) watch(overlayOpened, opened => { - setTimeout(() => { - if (opened) { - focus() - inputRef.value?.dispatchEvent(new FocusEvent('focus')) - } else { - blur() - inputRef.value?.dispatchEvent(new FocusEvent('blur')) + if (opened) { + setTimeout(() => { + inputRef.value?.focus() + }) + } else { + controlContext.init(true) + + if (focused.value) { + triggerRef.value?.focus() } - }) + } }) - const renderTrigger = () => + const renderTrigger = () => const renderContent = () => const overlayProps = useOverlayProps(context) const overlayClass = computed(() => normalizeClass([`${mergedPrefixCls.value}-overlay`, props.overlayClassName])) return () => ( <ɵOverlay + ref={overlayRef} {...overlayProps.value} class={overlayClass.value} v-slots={{ default: renderTrigger, content: renderContent }} diff --git a/packages/components/date-picker/src/DateRangePicker.tsx b/packages/components/date-picker/src/DateRangePicker.tsx index c41b722a7..52ae340f8 100644 --- a/packages/components/date-picker/src/DateRangePicker.tsx +++ b/packages/components/date-picker/src/DateRangePicker.tsx @@ -5,11 +5,12 @@ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE */ -import { computed, defineComponent, normalizeClass, provide, toRef, watch } from 'vue' +import { computed, defineComponent, normalizeClass, onMounted, provide, ref, toRef, watch } from 'vue' -import { ɵOverlay } from '@idux/components/_private/overlay' +import { ɵOverlay, ɵOverlayInstance } from '@idux/components/_private/overlay' import { useDateConfig, useGlobalConfig } from '@idux/components/config' import { useFormElement } from '@idux/components/form' +import { useOverlayFocusMonitor } from '@idux/components/utils' import { useFormat } from './composables/useFormat' import { useInputEnableStatus } from './composables/useInputEnableStatus' @@ -34,22 +35,27 @@ export default defineComponent({ const config = useGlobalConfig('datePicker') const dateConfig = useDateConfig() + const overlayRef = ref<ɵOverlayInstance>() + const triggerRef = ref<{ focus: () => void }>() + const { elementRef: inputRef, focus, blur } = useFormElement() expose({ focus, blur }) + const { overlayOpened, overlayVisible, onAfterLeave, setOverlayOpened } = useOverlayState(props) const inputEnableStatus = useInputEnableStatus(props, config) const formatContext = useFormat(props, config) - const pickerStateContext = usePickerState(props, config, dateConfig, formatContext.formatRef) + const pickerStateContext = usePickerState(props, config, dateConfig, formatContext.formatRef, setOverlayOpened) - const { accessor, handleChange } = pickerStateContext + const { accessor, handleFocus: _handleFocus, handleBlur: _handleBlur, handleChange } = pickerStateContext const rangeControlContext = useRangeControl(dateConfig, formatContext, inputEnableStatus, toRef(accessor, 'value')) - const { overlayOpened, overlayVisible, onAfterLeave, setOverlayOpened } = useOverlayState( - props, - rangeControlContext, - ) - const handleKeyDown = useRangeKeyboardEvents(rangeControlContext, setOverlayOpened, handleChange) + const handleKeyDown = useRangeKeyboardEvents(rangeControlContext, overlayOpened, setOverlayOpened, handleChange) + + const { focused, handleFocus, handleBlur, bindOverlayMonitor } = useOverlayFocusMonitor(_handleFocus, _handleBlur) + onMounted(() => { + bindOverlayMonitor(overlayRef, overlayOpened) + }) const renderSeparator = () => slots.separator?.() ?? props.separator ?? locale.dateRangePicker.separator @@ -59,6 +65,7 @@ export default defineComponent({ common, locale, config, + focused, mergedPrefixCls, dateConfig, inputRef, @@ -72,23 +79,27 @@ export default defineComponent({ handleKeyDown, ...formatContext, ...pickerStateContext, + handleFocus, + handleBlur, } provide(dateRangePickerToken, context) watch(overlayOpened, opened => { - setTimeout(() => { - if (opened) { - focus() - inputRef.value?.dispatchEvent(new FocusEvent('focus')) - } else { - blur() - inputRef.value?.dispatchEvent(new FocusEvent('blur')) + if (opened) { + setTimeout(() => { + inputRef.value?.focus() + }) + } else { + rangeControlContext.init(true) + + if (focused.value) { + triggerRef.value?.focus() } - }) + } }) - const renderTrigger = () => + const renderTrigger = () => const renderContent = () => const overlayProps = useOverlayProps(context) const overlayClass = computed(() => normalizeClass([`${mergedPrefixCls.value}-overlay`, props.overlayClassName])) @@ -96,6 +107,7 @@ export default defineComponent({ return () => { return ( <ɵOverlay + ref={overlayRef} {...overlayProps.value} class={overlayClass.value} v-slots={{ default: renderTrigger, content: renderContent }} diff --git a/packages/components/date-picker/src/composables/useKeyboardEvents.ts b/packages/components/date-picker/src/composables/useKeyboardEvents.ts index 07d16ac31..480a6c70d 100644 --- a/packages/components/date-picker/src/composables/useKeyboardEvents.ts +++ b/packages/components/date-picker/src/composables/useKeyboardEvents.ts @@ -6,10 +6,25 @@ */ import type { PickerRangeControlContext } from './useRangeControl' +import type { ComputedRef } from 'vue' -export function useKeyboardEvents(setOverlayOpened: (opened: boolean) => void): (evt: KeyboardEvent) => void { +export function useKeyboardEvents( + overlayOpened: ComputedRef, + setOverlayOpened: (opened: boolean) => void, +): (evt: KeyboardEvent) => void { return (evt: KeyboardEvent) => { - if (evt.code === 'Escape' || evt.code === 'Enter') { + if (evt.code === 'Escape') { + setOverlayOpened(false) + return + } + + if (!overlayOpened.value && !['Backspace', 'Tab'].includes(evt.code)) { + evt.preventDefault() + setOverlayOpened(true) + return + } + + if (evt.code === 'Enter') { setOverlayOpened(false) } } @@ -17,25 +32,28 @@ export function useKeyboardEvents(setOverlayOpened: (opened: boolean) => void): export function useRangeKeyboardEvents( rangeControl: PickerRangeControlContext, + overlayOpened: ComputedRef, setOverlayOpened: (opened: boolean) => void, handleChange: (value: (Date | undefined)[] | undefined) => void, ): (evt: KeyboardEvent) => void { const { bufferUpdated, buffer } = rangeControl return (evt: KeyboardEvent) => { - switch (evt.code) { - case 'Escape': - setOverlayOpened(false) - break + if (evt.code === 'Escape') { + setOverlayOpened(false) + return + } - case 'Enter': - if (bufferUpdated.value) { - handleChange(buffer.value) - } - setOverlayOpened(false) - break + if (!overlayOpened.value && !['Backspace', 'Tab'].includes(evt.code)) { + evt.preventDefault() + setOverlayOpened(true) + return + } - default: - break + if (evt.code === 'Enter') { + if (bufferUpdated.value) { + handleChange(buffer.value) + } + setOverlayOpened(false) } } } diff --git a/packages/components/date-picker/src/composables/useOverlayProps.ts b/packages/components/date-picker/src/composables/useOverlayProps.ts index eef1be0e9..7a598fb8a 100644 --- a/packages/components/date-picker/src/composables/useOverlayProps.ts +++ b/packages/components/date-picker/src/composables/useOverlayProps.ts @@ -16,7 +16,7 @@ export function useOverlayProps(context: DatePickerContext | DateRangePickerCont const { props, common, config, accessor, mergedPrefixCls, overlayOpened, setOverlayOpened, onAfterLeave } = context return computed(() => { return { - clickOutside: true, + clickOutside: false, container: props.overlayContainer ?? config.overlayContainer, containerFallback: `.${mergedPrefixCls.value}-overlay-container`, disabled: accessor.disabled || props.readonly, diff --git a/packages/components/date-picker/src/composables/useOverlayState.ts b/packages/components/date-picker/src/composables/useOverlayState.ts index ce138e4d1..a75fa6d3f 100644 --- a/packages/components/date-picker/src/composables/useOverlayState.ts +++ b/packages/components/date-picker/src/composables/useOverlayState.ts @@ -5,8 +5,6 @@ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE */ -import type { PickerControlContext } from './useControl' -import type { PickerRangeControlContext } from './useRangeControl' import type { DatePickerProps, DateRangePickerProps } from '../types' import type { ComputedRef } from 'vue' @@ -21,10 +19,7 @@ export interface OverlayStateContext { onAfterLeave: () => void } -export function useOverlayState( - props: DatePickerProps | DateRangePickerProps, - control: PickerControlContext | PickerRangeControlContext, -): OverlayStateContext { +export function useOverlayState(props: DatePickerProps | DateRangePickerProps): OverlayStateContext { const [overlayOpened, setOverlayOpened] = useControlledProp(props, 'open', false) const [overlayVisible, setOverlayVisible] = useState(false) watch( @@ -37,13 +32,6 @@ export function useOverlayState( { immediate: true }, ) - const changeOpenedState = (open: boolean) => { - setOverlayOpened(open) - if (!open) { - control.init(true) - } - } - const onAfterLeave = () => { if (!overlayOpened.value) { setOverlayVisible(false) @@ -52,9 +40,9 @@ export function useOverlayState( onMounted(() => { if (props.autofocus) { - changeOpenedState(true) + setOverlayOpened(true) } }) - return { overlayOpened, overlayVisible, setOverlayOpened: changeOpenedState, onAfterLeave } + return { overlayOpened, overlayVisible, setOverlayOpened, onAfterLeave } } diff --git a/packages/components/date-picker/src/composables/usePickerState.ts b/packages/components/date-picker/src/composables/usePickerState.ts index 0aec8c185..0bda07eed 100644 --- a/packages/components/date-picker/src/composables/usePickerState.ts +++ b/packages/components/date-picker/src/composables/usePickerState.ts @@ -10,7 +10,7 @@ import { type ComputedRef, toRaw } from 'vue' import { isArray } from 'lodash-es' import { type FormAccessor, ValidateStatus, useAccessorAndControl } from '@idux/cdk/forms' -import { callEmit, convertArray, useState } from '@idux/cdk/utils' +import { callEmit, convertArray } from '@idux/cdk/utils' import { type DateConfig } from '@idux/components/config' import { FormSize, useFormItemRegister, useFormSize, useFormStatus } from '@idux/components/form' @@ -25,7 +25,6 @@ export interface PickerStateContext mergedSize: ComputedRef mergedStatus: ComputedRef - isFocused: ComputedRef handleChange: (value: StateValueType) => void handleClear: (evt: MouseEvent) => void handleFocus: (evt: FocusEvent) => void @@ -37,14 +36,13 @@ export function usePickerState config: { size: FormSize }, dateConfig: DateConfig, formatRef: ComputedRef, + setOverlayOpened: (overlayOpened: boolean) => void, ): PickerStateContext { const { accessor, control } = useAccessorAndControl() useFormItemRegister(control) const mergedSize = useFormSize(props, config) const mergedStatus = useFormStatus(props, control) - const [isFocused, setFocused] = useState(false) - function handleChange(value: StateValueType) { const newValue = (isArray(value) ? sortRangeValue(dateConfig, value) : value) as StateValueType @@ -68,13 +66,12 @@ export function usePickerState } function handleFocus(evt: FocusEvent) { - setFocused(true) callEmit(props.onFocus, evt) } function handleBlur(evt: FocusEvent) { - setFocused(false) accessor.markAsBlurred() + setOverlayOpened(false) callEmit(props.onBlur, evt) } @@ -82,7 +79,6 @@ export function usePickerState accessor, mergedSize, mergedStatus, - isFocused, handleChange, handleClear, handleFocus, diff --git a/packages/components/date-picker/src/composables/useTriggerProps.ts b/packages/components/date-picker/src/composables/useTriggerProps.ts index 1f3e9b980..55a10db6e 100644 --- a/packages/components/date-picker/src/composables/useTriggerProps.ts +++ b/packages/components/date-picker/src/composables/useTriggerProps.ts @@ -19,19 +19,18 @@ export function useTriggerProps(context: DatePickerContext | DateRangePickerCont accessor, mergedSize, mergedStatus, - isFocused, + focused, handleFocus, handleBlur, handleClear, handleKeyDown, overlayOpened, setOverlayOpened, - inputEnableStatus, } = context const handleClick = () => { const currOpened = overlayOpened.value - if (currOpened || accessor.disabled) { + if (accessor.disabled) { return } @@ -48,8 +47,8 @@ export function useTriggerProps(context: DatePickerContext | DateRangePickerCont (isArray(accessor.value) ? !!accessor.value.length : !!accessor.value), clearIcon: props.clearIcon ?? config.clearIcon, disabled: accessor.disabled, - focused: isFocused.value, - readonly: props.readonly || inputEnableStatus.value.enableInput === false, + focused: focused.value, + readonly: props.readonly, size: mergedSize.value, status: mergedStatus.value, suffix: props.suffix ?? config.suffix, diff --git a/packages/components/date-picker/src/token.ts b/packages/components/date-picker/src/token.ts index 8664b5c12..efe3aa543 100644 --- a/packages/components/date-picker/src/token.ts +++ b/packages/components/date-picker/src/token.ts @@ -24,6 +24,7 @@ export interface DatePickerContext extends OverlayStateContext, FormatContext, P common: CommonConfig locale: Locale config: DatePickerConfig + focused: ComputedRef mergedPrefixCls: ComputedRef dateConfig: DateConfig inputRef: Ref @@ -39,6 +40,7 @@ export interface DateRangePickerContext props: DateRangePickerProps slots: Slots common: CommonConfig + focused: ComputedRef locale: Locale config: DatePickerConfig mergedPrefixCls: ComputedRef diff --git a/packages/components/date-picker/src/trigger/RangeTrigger.tsx b/packages/components/date-picker/src/trigger/RangeTrigger.tsx index 19f73b5f1..440648b07 100644 --- a/packages/components/date-picker/src/trigger/RangeTrigger.tsx +++ b/packages/components/date-picker/src/trigger/RangeTrigger.tsx @@ -5,7 +5,7 @@ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE */ -import { computed, defineComponent, inject } from 'vue' +import { computed, defineComponent, inject, ref } from 'vue' import { callEmit } from '@idux/cdk/utils' import { ɵTrigger } from '@idux/components/_private/trigger' @@ -15,9 +15,10 @@ import { dateRangePickerToken } from '../token' export default defineComponent({ inheritAttrs: false, - setup(_, { attrs }) { + setup(_, { attrs, expose }) { const context = inject(dateRangePickerToken)! const { + accessor, props, slots, locale, @@ -29,6 +30,8 @@ export default defineComponent({ renderSeparator, } = context + const triggerInputRef = ref() + const placeholders = computed(() => [ props.placeholder?.[0] ?? locale.dateRangePicker[`${props.type}Placeholder`][0], props.placeholder?.[1] ?? locale.dateRangePicker[`${props.type}Placeholder`][1], @@ -45,22 +48,25 @@ export default defineComponent({ callEmit(props.onInput, false, evt) } + const focus = () => { + ;(inputEnableStatus.value.allowInput === 'overlay' ? triggerInputRef : inputRef).value?.focus() + } + expose({ focus }) + const renderSide = (isFrom: boolean) => { const prefixCls = mergedPrefixCls.value const { inputValue } = isFrom ? fromControl : toControl const placeholder = placeholders.value[isFrom ? 0 : 1] const handleInput = isFrom ? handleFromInput : handleToInput - const { disabled, readonly } = triggerProps.value - return ( () + const placeholder = computed(() => props.placeholder ?? locale.datePicker[`${props.type}Placeholder`]) const inputSize = computed(() => Math.max(10, formatRef.value.length) + 2) const triggerProps = useTriggerProps(context) + const focus = () => { + ;(inputEnableStatus.value.allowInput === 'overlay' ? triggerInputRef : inputRef).value?.focus() + } + + expose({ focus }) + const handleInput = (evt: Event) => { _handleInput(evt) callEmit(props.onInput, evt) } const renderContent = (prefixCls: string) => { - const { readonly, disabled } = triggerProps.value - return (
- + @@ -27,4 +33,10 @@ const value = ref('tom') const onChange = (value: string, oldValue: string) => { console.log('selected change: ', value, oldValue) } +const onFocus = () => { + console.log('focus') +} +const onBlur = () => { + console.log('blur') +} diff --git a/packages/components/select/src/Select.tsx b/packages/components/select/src/Select.tsx index 7c00e75df..60ba2a32b 100644 --- a/packages/components/select/src/Select.tsx +++ b/packages/components/select/src/Select.tsx @@ -7,7 +7,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { type ComputedRef, Slots, computed, defineComponent, normalizeClass, provide, ref, watch } from 'vue' +import { type ComputedRef, Slots, computed, defineComponent, normalizeClass, onMounted, provide, ref, watch } from 'vue' import { isBoolean } from 'lodash-es' @@ -20,6 +20,7 @@ import { ɵSelector, type ɵSelectorInstance } from '@idux/components/_private/s import { type SelectConfig, useGlobalConfig } from '@idux/components/config' import { useFormItemRegister, useFormSize, useFormStatus } from '@idux/components/form' import { IxSpin } from '@idux/components/spin' +import { useOverlayFocusMonitor } from '@idux/components/utils' import { useActiveState } from './composables/useActiveState' import { GetKeyFn, useGetOptionKey } from './composables/useGetOptionKey' @@ -85,20 +86,18 @@ export default defineComponent({ inputValue, selectedValue, activeValue, + overlayOpened, changeActiveIndex, changeSelected, handleRemove, clearInput, setOverlayOpened, - blur, ) watch(overlayOpened, opened => { - if (!opened && props.allowInput && inputValue.value) { - changeSelected(inputValue.value) + if (opened) { + focus() } - opened && focus() - clearInput() }) const handleOptionClick = (option: SelectData) => { @@ -109,13 +108,23 @@ export default defineComponent({ } } - const handleBlur = () => { + const onFocus = (evt: FocusEvent) => { + callEmit(props.onFocus, evt) + } + const onBlur = (evt: FocusEvent) => { if (props.allowInput && inputValue.value) { changeSelected(inputValue.value) clearInput() } accessor.markAsBlurred() + setOverlayOpened(false) + callEmit(props.onBlur, evt) } + const { focused, bindOverlayMonitor, handleFocus, handleBlur } = useOverlayFocusMonitor(onFocus, onBlur) + onMounted(() => { + bindOverlayMonitor(overlayRef, overlayOpened) + }) + const handleItemRemove = (value: VKey) => { focus() handleRemove(value) @@ -145,6 +154,7 @@ export default defineComponent({ config={config} dataSource={selectedOptions.value} disabled={accessor.disabled} + focused={focused.value} maxLabel={props.maxLabel} multiple={props.multiple} opened={overlayOpened.value} @@ -155,6 +165,7 @@ export default defineComponent({ status={mergedStatus.value} suffix={props.suffix} value={selectedValue.value} + onFocus={handleFocus} onBlur={handleBlur} onClear={handleClear} onInputValueChange={setInputValue} @@ -214,7 +225,7 @@ export default defineComponent({ const overlayProps = { class: overlayClasses.value, style: overlayStyle.value, - clickOutside: true, + clickOutside: false, container: props.overlayContainer ?? config.overlayContainer, containerFallback: `.${mergedPrefixCls.value}-overlay-container`, disabled: accessor.disabled || props.readonly, diff --git a/packages/components/select/src/composables/useKeyboardEvents.ts b/packages/components/select/src/composables/useKeyboardEvents.ts index 342a20074..10e6a0fde 100644 --- a/packages/components/select/src/composables/useKeyboardEvents.ts +++ b/packages/components/select/src/composables/useKeyboardEvents.ts @@ -16,32 +16,50 @@ export function useKeyboardEvents( inputValue: ComputedRef, selectedValue: ComputedRef, activeValue: ComputedRef, + overlayOpened: ComputedRef, changeActiveIndex: (offset: number) => void, changeSelected: (key: VKey) => void, handleRemove: (key: VKey) => void, clearInput: () => void, setOverlayOpened: (opened: boolean) => void, - blur: () => void | undefined, ): (evt: KeyboardEvent) => void { return (evt: KeyboardEvent) => { + const ensureOverlayOpened = () => { + if (['Backspace', 'Tab'].includes(evt.code)) { + return + } + + if (!overlayOpened.value) { + setOverlayOpened(true) + } + } + switch (evt.code) { case 'ArrowUp': evt.preventDefault() changeActiveIndex(-1) + ensureOverlayOpened() break case 'ArrowDown': evt.preventDefault() changeActiveIndex(1) + ensureOverlayOpened() break case 'Enter': { evt.preventDefault() const key = activeValue.value - !isNil(key) && changeSelected(key) - props.allowInput && clearInput() - if (!props.multiple) { + + if (!isNil(key) && ((props.allowInput && inputValue.value) || overlayOpened.value)) { + changeSelected(key) + } + + if (!overlayOpened.value && (!props.allowInput || !inputValue.value)) { + ensureOverlayOpened() + } else if (!props.multiple) { setOverlayOpened(false) - blur() } + + props.allowInput && clearInput() break } case 'Backspace': { @@ -51,10 +69,15 @@ export function useKeyboardEvents( } break } - case 'Escape': + case 'Escape': { evt.preventDefault() setOverlayOpened(false) break + } + + default: { + ensureOverlayOpened() + } } } } diff --git a/packages/components/select/src/types.ts b/packages/components/select/src/types.ts index bba99d442..6f0a04d0b 100644 --- a/packages/components/select/src/types.ts +++ b/packages/components/select/src/types.ts @@ -93,6 +93,8 @@ export const selectProps = { 'onUpdate:open': [Function, Array] as PropType void>>, onChange: [Function, Array] as PropType void>>, onClear: [Function, Array] as PropType void>>, + onFocus: [Function, Array] as PropType void>>, + onBlur: [Function, Array] as PropType void>>, onSearch: [Function, Array] as PropType void>>, onScroll: [Function, Array] as PropType void>>, onScrolledChange: [Function, Array] as PropType< diff --git a/packages/components/time-picker/__tests__/__snapshots__/timePicker.spec.ts.snap b/packages/components/time-picker/__tests__/__snapshots__/timePicker.spec.ts.snap index a3e29b75b..83e97626b 100644 --- a/packages/components/time-picker/__tests__/__snapshots__/timePicker.spec.ts.snap +++ b/packages/components/time-picker/__tests__/__snapshots__/timePicker.spec.ts.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1 exports[`TimePicker > render work 1`] = ` -"
+"
diff --git a/packages/components/time-picker/__tests__/__snapshots__/timeRangePicker.spec.ts.snap b/packages/components/time-picker/__tests__/__snapshots__/timeRangePicker.spec.ts.snap index 56b7fe757..5b7d9d916 100644 --- a/packages/components/time-picker/__tests__/__snapshots__/timeRangePicker.spec.ts.snap +++ b/packages/components/time-picker/__tests__/__snapshots__/timeRangePicker.spec.ts.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1 exports[`TimePicker > render work 1`] = ` -"
+"
diff --git a/packages/components/time-picker/__tests__/timePicker.spec.ts b/packages/components/time-picker/__tests__/timePicker.spec.ts index 497d90c9a..8dbd123e4 100644 --- a/packages/components/time-picker/__tests__/timePicker.spec.ts +++ b/packages/components/time-picker/__tests__/timePicker.spec.ts @@ -1,6 +1,6 @@ import { MountingOptions, mount } from '@vue/test-utils' -import { renderWork } from '@tests' +import { renderWork, wait } from '@tests' import { parse } from 'date-fns' import { ɵTimePanel } from '@idux/components/_private/time-panel' @@ -190,9 +190,11 @@ describe('TimePicker', () => { }) await wrapper.find('.ix-time-picker').find('input').trigger('focus') + await wait(100) expect(onFocus).toBeCalled() await wrapper.find('.ix-time-picker').find('input').trigger('blur') + await wait(100) expect(onBlur).toBeCalled() }) diff --git a/packages/components/time-picker/__tests__/timeRangePicker.spec.ts b/packages/components/time-picker/__tests__/timeRangePicker.spec.ts index 13ceb8088..307e3995a 100644 --- a/packages/components/time-picker/__tests__/timeRangePicker.spec.ts +++ b/packages/components/time-picker/__tests__/timeRangePicker.spec.ts @@ -1,6 +1,6 @@ import { MountingOptions, VueWrapper, mount } from '@vue/test-utils' -import { renderWork } from '@tests' +import { renderWork, wait } from '@tests' import { parse } from 'date-fns' import { ɵTimePanel, ɵTimePanelInstance } from '@idux/components/_private/time-panel' @@ -28,13 +28,16 @@ describe('TimePicker', () => { const findCell = (wrapper: VueWrapper<ɵTimePanelInstance>, idx: number, value: string | number) => findCellWithValue(findColumn(wrapper, idx), value) - const triggerConfirm = (wrapper: ReturnType) => + const triggerConfirm = async (wrapper: ReturnType) => { wrapper .findComponent(RangeContent) .findAll('.ix-time-range-picker-overlay-footer .ix-button') .find(btn => btn.text() === '确定') ?.trigger('click') + await wrapper.setProps({ open: false }) + await wrapper.setProps({ open: true }) + } renderWork(IxTimeRangePicker, { props: { open: true } }) test('v-model:value work', async () => { @@ -236,9 +239,11 @@ describe('TimePicker', () => { }) await wrapper.find('.ix-time-range-picker').find('input').trigger('focus') + await wait(100) expect(onFocus).toBeCalled() await wrapper.find('.ix-time-range-picker').find('input').trigger('blur') + await wait(100) expect(onBlur).toBeCalled() }) diff --git a/packages/components/time-picker/demo/Basic.vue b/packages/components/time-picker/demo/Basic.vue index 50611b4ec..becd96fc2 100644 --- a/packages/components/time-picker/demo/Basic.vue +++ b/packages/components/time-picker/demo/Basic.vue @@ -1,7 +1,13 @@