From daa39dac438ad2fe8fd9d358f731a2e20b68b603 Mon Sep 17 00:00:00 2001 From: saller Date: Mon, 17 Apr 2023 10:34:38 +0800 Subject: [PATCH] feat(pro:search): add quick select panel support (#1529) --- .../_private/overflow/src/Overflow.tsx | 5 +- .../_private/overlay/src/Overlay.tsx | 1 + .../components/_private/overlay/src/types.ts | 1 + packages/components/select/src/types.ts | 2 +- packages/pro/search/demo/ConvertToKeyword.vue | 14 +- packages/pro/search/demo/Custom.vue | 1 - packages/pro/search/demo/MergeItems.vue | 26 +- packages/pro/search/demo/QuickSelect.md | 14 + packages/pro/search/demo/QuickSelect.vue | 432 ++++++++++++++++++ packages/pro/search/demo/RemoteSearch.vue | 3 + packages/pro/search/docs/Api.zh.md | 3 + packages/pro/search/index.ts | 1 + packages/pro/search/src/ProSearch.tsx | 156 ++++--- .../search/src/components/MeasureElement.tsx | 35 ++ .../src/components/NameSelectOverlay.tsx | 180 ++++++++ .../pro/search/src/components/SearchItem.tsx | 262 +++++++++++ .../quickSelect/QuickSelectItem.tsx | 142 ++++++ .../quickSelect/QuickSelectPanel.tsx | 74 +++ .../quickSelect/QuickSelectShortcut.tsx | 39 ++ .../segment}/Segment.tsx | 240 +++++----- .../src/components/segment/SegmentInput.tsx | 116 +++++ .../src/components/segment/TempSegment.tsx | 157 +++++++ .../src/composables/useActiveSegment.ts | 86 +++- .../src/composables/useCommonOverlayProps.ts | 2 +- .../pro/search/src/composables/useControl.ts | 38 +- .../src/composables/useElementWidthMeasure.ts | 21 + .../search/src/composables/useFocusedState.ts | 198 ++++---- .../composables/useResolvedSearchFields.ts | 66 +++ .../search/src/composables/useSearchItem.ts | 78 +--- .../search/src/composables/useSearchStates.ts | 165 +++---- .../src/composables/useSegmentStates.ts | 100 +++- .../pro/search/src/panel/CascaderPanel.tsx | 2 +- .../pro/search/src/panel/DatePickerPanel.tsx | 35 +- packages/pro/search/src/panel/PanelFooter.tsx | 16 +- packages/pro/search/src/panel/SelectPanel.tsx | 20 +- .../pro/search/src/panel/TreeSelectPanel.tsx | 13 +- .../pro/search/src/searchItem/SearchItem.tsx | 78 ---- .../search/src/searchItem/SearchItemTag.tsx | 88 ---- .../src/segments/CreateCascaderSegment.tsx | 10 +- .../src/segments/CreateDatePickerSegment.tsx | 16 +- .../segments/CreateDateRangePickerSegment.tsx | 5 +- .../src/segments/CreateOperatorSegment.tsx | 4 +- .../src/segments/CreateSelectSegment.tsx | 13 +- .../src/segments/CreateTreeSelectSegment.tsx | 11 +- .../src/segments/createCustomSegment.ts | 4 +- .../search/src/segments/createInputSegment.ts | 2 - packages/pro/search/src/token.ts | 12 +- packages/pro/search/src/types/index.ts | 3 + .../pro/search/src/types/measureElement.ts | 13 + packages/pro/search/src/types/overlay.ts | 23 + packages/pro/search/src/types/panels.ts | 6 + packages/pro/search/src/types/proSearch.ts | 3 +- .../pro/search/src/types/quickSelectPanel.ts | 17 + packages/pro/search/src/types/searchFields.ts | 48 +- packages/pro/search/src/types/searchItem.ts | 12 +- packages/pro/search/src/types/segment.ts | 28 +- .../pro/search/src/utils/getBoxsizingData.ts | 44 ++ .../src/utils/getSelectableCommonParams.ts | 8 +- packages/pro/search/style/index.less | 257 ++++------- packages/pro/search/style/mixin.less | 7 + packages/pro/search/style/panel.less | 85 ++++ packages/pro/search/style/quick-select.less | 139 ++++++ .../search/style/themes/default.variable.less | 8 +- 63 files changed, 2732 insertions(+), 956 deletions(-) create mode 100644 packages/pro/search/demo/QuickSelect.md create mode 100644 packages/pro/search/demo/QuickSelect.vue create mode 100644 packages/pro/search/src/components/MeasureElement.tsx create mode 100644 packages/pro/search/src/components/NameSelectOverlay.tsx create mode 100644 packages/pro/search/src/components/SearchItem.tsx create mode 100644 packages/pro/search/src/components/quickSelect/QuickSelectItem.tsx create mode 100644 packages/pro/search/src/components/quickSelect/QuickSelectPanel.tsx create mode 100644 packages/pro/search/src/components/quickSelect/QuickSelectShortcut.tsx rename packages/pro/search/src/{searchItem => components/segment}/Segment.tsx (52%) create mode 100644 packages/pro/search/src/components/segment/SegmentInput.tsx create mode 100644 packages/pro/search/src/components/segment/TempSegment.tsx create mode 100644 packages/pro/search/src/composables/useElementWidthMeasure.ts create mode 100644 packages/pro/search/src/composables/useResolvedSearchFields.ts delete mode 100644 packages/pro/search/src/searchItem/SearchItem.tsx delete mode 100644 packages/pro/search/src/searchItem/SearchItemTag.tsx create mode 100644 packages/pro/search/src/types/measureElement.ts create mode 100644 packages/pro/search/src/types/overlay.ts create mode 100644 packages/pro/search/src/types/quickSelectPanel.ts create mode 100644 packages/pro/search/src/utils/getBoxsizingData.ts create mode 100644 packages/pro/search/style/mixin.less create mode 100644 packages/pro/search/style/panel.less create mode 100644 packages/pro/search/style/quick-select.less diff --git a/packages/components/_private/overflow/src/Overflow.tsx b/packages/components/_private/overflow/src/Overflow.tsx index fb3e1e166..ca66fc15e 100644 --- a/packages/components/_private/overflow/src/Overflow.tsx +++ b/packages/components/_private/overflow/src/Overflow.tsx @@ -118,10 +118,13 @@ export default defineComponent({ throwError('components/_private/overflow', 'item slot must be provided') } const nodeContent = slots.item?.(item) ?? '' + + const key = props.getKey(item) return ( setItemWidth(key!, itemEl)} > diff --git a/packages/components/_private/overlay/src/Overlay.tsx b/packages/components/_private/overlay/src/Overlay.tsx index a15b2ed33..cdb67ab33 100644 --- a/packages/components/_private/overlay/src/Overlay.tsx +++ b/packages/components/_private/overlay/src/Overlay.tsx @@ -102,6 +102,7 @@ export default defineComponent({ expose({ updatePopper: update, + getPopperElement: () => convertElement(popperRef), }) const handleClickOutside = (evt: Event) => { diff --git a/packages/components/_private/overlay/src/types.ts b/packages/components/_private/overlay/src/types.ts index 8d7c3fe8a..70fc573c4 100644 --- a/packages/components/_private/overlay/src/types.ts +++ b/packages/components/_private/overlay/src/types.ts @@ -68,6 +68,7 @@ export const overlayProps = { export interface OverlayBindings { updatePopper: (options?: Partial) => void + getPopperElement: () => HTMLElement | undefined | null } export type OverlayProps = ExtractInnerPropTypes diff --git a/packages/components/select/src/types.ts b/packages/components/select/src/types.ts index 7674e5b4a..92a8df67a 100644 --- a/packages/components/select/src/types.ts +++ b/packages/components/select/src/types.ts @@ -40,7 +40,7 @@ export const selectPanelProps = { onScrolledBottom: [Function, Array] as PropType void>>, // private - _virtualScrollHeight: { type: Number, default: 256 }, + _virtualScrollHeight: { type: [Number, String] as PropType, default: 256 }, _virtualScrollItemHeight: { type: Number, default: 32 }, } as const diff --git a/packages/pro/search/demo/ConvertToKeyword.vue b/packages/pro/search/demo/ConvertToKeyword.vue index 8a36c3160..8b1b63f02 100644 --- a/packages/pro/search/demo/ConvertToKeyword.vue +++ b/packages/pro/search/demo/ConvertToKeyword.vue @@ -5,12 +5,13 @@ :searchFields="searchFields" :onChange="onChange" :onSearch="onSearch" + :onItemCreate="onItemCreate" :onItemConfirm="onItemConfirm" > + + diff --git a/packages/pro/search/demo/RemoteSearch.vue b/packages/pro/search/demo/RemoteSearch.vue index e05090e81..9629f82fa 100644 --- a/packages/pro/search/demo/RemoteSearch.vue +++ b/packages/pro/search/demo/RemoteSearch.vue @@ -111,6 +111,7 @@ const searchFields = computed(() => [ multiple: true, searchable: true, dataSource: selectData.value, + virtual: true, searchFn: () => true, onSearch: selectOnSearch, }, @@ -125,6 +126,7 @@ const searchFields = computed(() => [ checkable: true, cascaderStrategy: 'all', dataSource: treeSelectData.value, + virtual: true, searchFn: () => true, onSearch: treeSelectOnSearch, }, @@ -138,6 +140,7 @@ const searchFields = computed(() => [ searchable: true, cascaderStrategy: 'all', dataSource: cascaderData.value, + virtual: true, searchFn: () => true, onSearch: cascaderOnSearch, }, diff --git a/packages/pro/search/docs/Api.zh.md b/packages/pro/search/docs/Api.zh.md index 285d63983..8535b5bf0 100644 --- a/packages/pro/search/docs/Api.zh.md +++ b/packages/pro/search/docs/Api.zh.md @@ -63,11 +63,14 @@ interface SearchItemConfirmContext extends Partial> | `key` | 唯一的key | `VKey` | - | - | 必填 | | `label` | 搜索条件的词条名称 | `string` | - | - | 必填 | | `multiple` | 是否允许重复 | `boolean` | - | - | 为 `true` 时,该搜索条件可以被输入多次 | +| `quickSelect` | 是否在快捷面板中展示 | `boolean` | - | - | 为 `true` 时,默认启用快捷面板, `multiple` 的搜索项该配置不生效 | +| `quickSelectSearchable` | 是否在快捷面板中启用搜索 | `boolean` | - | - | 为 `true` 时,为该搜索项在快捷面板中增加搜索输入框 | | `operators` | 搜索条件的中间操作符 | `string[]` | - | - | 提供时,会在搜索词条名称中间增加一个操作符,如 `'='`, `'!='` | | `defaultOperator` | 默认的操作符 | `string` | - | - | 提供时,会自动填入默认的操作符 | | `defaultValue` | 默认值 | - | - | - | 提供时,会自动填入默认值 | | `customOperatorLabel` | 自定义操作符下拉选择label | `string \| ((operator: string) => VNodeChild)` | - | - | - | | `inputClassName` | 输入框class | `string` | - | - | 用于自定义输入框样式 | +| `containerClassName` | 面板所在容器class | `string` | - | - | 用于自定义浮层样式或快捷面板容器样式 | | `placeholder` | 输入框placeholder | `string` | - | - | 搜索值输入框的占位符 | | `validator` | 搜索项校验函数 | `(value: SearchValue) => { message?: string } | undefined` | - | - | 返回错误信息 | diff --git a/packages/pro/search/index.ts b/packages/pro/search/index.ts index 4fb97ef4c..a92736d0e 100644 --- a/packages/pro/search/index.ts +++ b/packages/pro/search/index.ts @@ -20,5 +20,6 @@ export type { SearchField, SearchValue, SearchItemError, + SearchItemCreateContext, SearchItemConfirmContext, } from './src/types' diff --git a/packages/pro/search/src/ProSearch.tsx b/packages/pro/search/src/ProSearch.tsx index 3022a1c8b..9ae4b5980 100644 --- a/packages/pro/search/src/ProSearch.tsx +++ b/packages/pro/search/src/ProSearch.tsx @@ -5,32 +5,48 @@ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE */ -import { computed, defineComponent, nextTick, normalizeClass, normalizeStyle, provide, ref, toRef, watch } from 'vue' - -import { callEmit } from '@idux/cdk/utils' +import { + computed, + defineComponent, + nextTick, + normalizeClass, + normalizeStyle, + onMounted, + provide, + ref, + toRef, + watch, +} from 'vue' + +import { callEmit, convertCssPixel } from '@idux/cdk/utils' import { ɵOverflow } from '@idux/components/_private/overflow' +import { ɵOverlay, type ɵOverlayInstance } from '@idux/components/_private/overlay' import { useGlobalConfig as useComponentGlobalConfig, useDateConfig } from '@idux/components/config' import { useZIndex } from '@idux/components/utils' import { useGlobalConfig } from '@idux/pro/config' +import SearchItemComp from './components/SearchItem' +import QuickSelectPanel from './components/quickSelect/QuickSelectPanel' +import NameSelectSegment from './components/segment/TempSegment' import { useActiveSegment } from './composables/useActiveSegment' import { useCommonOverlayProps } from './composables/useCommonOverlayProps' import { useControl } from './composables/useControl' +import { useElementWidthMeasure } from './composables/useElementWidthMeasure' import { useFocusedState } from './composables/useFocusedState' +import { useResolvedSearchFields } from './composables/useResolvedSearchFields' import { useSearchItems } from './composables/useSearchItem' import { useSearchItemErrors } from './composables/useSearchItemErrors' import { useSearchStateWatcher } from './composables/useSearchStateWatcher' -import { tempSearchStateKey, useSearchStates } from './composables/useSearchStates' +import { useSearchStates } from './composables/useSearchStates' import { useSearchTrigger } from './composables/useSearchTrigger' import { useSearchValues } from './composables/useSearchValues' -import SearchItemComp from './searchItem/SearchItem' -import SearchItemTagComp from './searchItem/SearchItemTag' import { proSearchContext } from './token' import { type SearchItem, proSearchProps } from './types' import { renderIcon } from './utils/RenderIcon' export default defineComponent({ name: 'IxProSearch', + inheritAttrs: false, props: proSearchProps, setup(props, { attrs, expose, slots }) { const common = useGlobalConfig('common') @@ -39,39 +55,49 @@ export default defineComponent({ const config = useGlobalConfig('search') const dateConfig = useDateConfig() const mergedPrefixCls = computed(() => `${common.prefixCls}-search`) + const enableQuickSelect = computed( + () => !!props.searchFields?.some(field => !!field.quickSelect && !field.multiple), + ) + + const quickSelectOverlayOpened = computed(() => quickSelectActive.value && overlayOpened.value) + + const elementRef = ref() + const quickSelectOverlayRef = ref<ɵOverlayInstance>() + const tempSegmentInputRef = ref() const searchValueContext = useSearchValues(props) const { searchValues, searchValueEmpty } = searchValueContext const searchStateWatcherContext = useSearchStateWatcher() const searchStateContext = useSearchStates(props, dateConfig, searchValueContext, searchStateWatcherContext) + + const resolvedSearchFields = useResolvedSearchFields(props, slots, mergedPrefixCls, dateConfig) const errors = useSearchItemErrors(props, searchValues) - const searchItems = useSearchItems( - props, - slots, - mergedPrefixCls, - searchStateContext.searchStates, - errors, - dateConfig, - ) + const searchItems = useSearchItems(resolvedSearchFields, searchStateContext.searchStates, errors) const searchTriggerContext = useSearchTrigger() - const elementRef = ref() + const elementWidth = useElementWidthMeasure(elementRef) - const activeSegmentContext = useActiveSegment( - props, - elementRef, - searchItems, - searchStateContext.tempSearchStateAvailable, - ) + const activeSegmentContext = useActiveSegment(props, tempSegmentInputRef, searchItems, enableQuickSelect) const commonOverlayProps = useCommonOverlayProps(props, config, componentCommon, mergedPrefixCls) - const focusStateContext = useFocusedState(props, elementRef, commonOverlayProps) - const { focused, focus, blur } = focusStateContext + const focusStateContext = useFocusedState(props) + const { focused, bindMonitor, bindOverlayMonitor, focusVia, blurVia } = focusStateContext + const focus = () => { + focusVia(elementRef, 'program') + } + const blur = () => { + blurVia(elementRef) + } + + onMounted(() => { + bindMonitor(elementRef) + bindOverlayMonitor(quickSelectOverlayRef, quickSelectOverlayOpened) + }) useControl(elementRef, activeSegmentContext, searchStateContext, focusStateContext) const currentZIndex = useZIndex(toRef(props, 'zIndex'), toRef(componentCommon, 'overlayZIndex'), focused) - const { initSearchStates, clearSearchState, getSearchStateByKey } = searchStateContext - const { activeSegment } = activeSegmentContext + const { initSearchStates, clearSearchState } = searchStateContext + const { isActive, overlayOpened, quickSelectActive } = activeSegmentContext watch( () => props.value, @@ -86,6 +112,8 @@ export default defineComponent({ const clearIcon = computed(() => props.clearIcon ?? config.clearIcon) const searchIcon = computed(() => props.searchIcon ?? config.searchIcon) + const allItems = computed(() => [...searchItems.value, 'name-select' as const]) + const classes = computed(() => { const prefixCls = mergedPrefixCls.value return normalizeClass({ @@ -99,18 +127,6 @@ export default defineComponent({ zIndex: currentZIndex.value, }), ) - const searchItemContainerStyle = computed(() => { - if (!focused.value) { - return normalizeStyle({ - height: 0, - width: 0, - opacity: 0, - overflow: 'hidden', - }) - } - - return undefined - }) expose({ focus, blur }) @@ -135,10 +151,14 @@ export default defineComponent({ provide(proSearchContext, { props, locale: locale.search, + elementRef, + tempSegmentInputRef, mergedPrefixCls, + enableQuickSelect, commonOverlayProps, - focused, + resolvedSearchFields, + ...focusStateContext, ...searchStateContext, ...searchStateWatcherContext, ...activeSegmentContext, @@ -149,18 +169,12 @@ export default defineComponent({ const prefixCls = mergedPrefixCls.value const overflowSlots = { - item: (item: SearchItem) => { - const searchState = getSearchStateByKey(item.key)! - - const tagSegments = item.segments.map(segment => { - const segmentValue = searchState.segmentValues.find(sv => sv.name === segment.name)! - return { - name: segment.name, - input: segment.format(segmentValue?.value), - } - }) - - return + item: (item: SearchItem | 'name-select') => { + if (item === 'name-select') { + return + } + + return }, rest: (rest: SearchItem[]) => ( @@ -169,24 +183,19 @@ export default defineComponent({ ), } - return ( -
+ const quickSelectOverlaySlots = { + default: () => (
<ɵOverflow - v-show={!focused.value} + v-show={isActive.value || searchItems.value.length} v-slots={overflowSlots} prefixCls={prefixCls} - dataSource={searchItems.value.filter(item => item.key !== tempSearchStateKey)} - getKey={item => item.key} - maxLabel={props.maxLabel} + dataSource={allItems.value} + getKey={item => item.key ?? 'name-select'} + maxLabel={focused.value ? Number.MAX_SAFE_INTEGER : props.maxLabel} /> -
- {searchItems.value?.map(item => ( - - ))} -
- {searchValueEmpty.value && !activeSegment.value && ( + {searchValueEmpty.value && !isActive.value && ( {placeholder.value} )}
@@ -200,6 +209,29 @@ export default defineComponent({
)}
+ ), + content: () => , + } + + const quickSelectOverlayProps = { + ...commonOverlayProps.value, + class: `${mergedPrefixCls.value}-quick-select-overlay`, + style: { + width: convertCssPixel(elementWidth.value), + }, + offset: [0, 4] as [number, number], + trigger: 'manual' as const, + visible: quickSelectOverlayOpened.value, + } + + return ( +
+ <ɵOverlay + ref={quickSelectOverlayRef} + v-slots={quickSelectOverlaySlots} + {...quickSelectOverlayProps} + tabindex={-1} + >
() + const measureElWidth = useElementWidthMeasure(measureElRef) + + onMounted(() => { + watch(measureElWidth, width => { + callEmit(props.onWidthChange, width) + }) + }) + + return () => ( + + {slots.default?.()} + + ) + }, +}) diff --git a/packages/pro/search/src/components/NameSelectOverlay.tsx b/packages/pro/search/src/components/NameSelectOverlay.tsx new file mode 100644 index 000000000..a67f760c2 --- /dev/null +++ b/packages/pro/search/src/components/NameSelectOverlay.tsx @@ -0,0 +1,180 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import { type ComputedRef, type VNodeChild, computed, defineComponent, inject, onMounted, ref, watch } from 'vue' + +import { isString } from 'lodash-es' + +import { type VKey, callEmit, convertArray, useState } from '@idux/cdk/utils' +import { ɵOverlay, type ɵOverlayInstance, type ɵOverlayProps } from '@idux/components/_private/overlay' + +import SelectPanel from '../panel/SelectPanel' +import { proSearchContext } from '../token' +import { type SearchField, type SelectPanelData, nameSelectOverlayProps } from '../types' +import { filterDataSource, matchRule } from '../utils/selectData' + +export default defineComponent({ + props: nameSelectOverlayProps, + setup(props, { slots, expose }) { + const context = inject(proSearchContext)! + const { + props: proSearchProps, + mergedPrefixCls, + bindOverlayMonitor, + commonOverlayProps, + nameSelectActive, + overlayOpened, + searchStates, + setActiveSegment, + createSearchState, + convertStateToValue, + updateSearchState, + } = context + + const overlayRef = ref<ɵOverlayInstance>() + + const [selectedFieldKey, setSelectedFieldKey] = useState(undefined) + + const searchStatesKeys = computed(() => new Set(searchStates.value?.map(state => state.fieldKey))) + const dataSource = computed(() => { + const searchFields = proSearchProps.searchFields?.filter( + field => field.key === props.selectedFieldKey || field.multiple || !searchStatesKeys.value.has(field.key), + ) + return searchFields?.map(field => ({ key: field.key, label: field.label })) ?? [] + }) + const filteredDataSource = computed(() => + filterDataSource(dataSource.value, nameOption => matchRule(nameOption.label, props.searchValue)), + ) + const isActive = computed(() => nameSelectActive.value) + const nameSelectOverlayOpened = computed(() => overlayOpened.value && nameSelectActive.value) + + const updateOverlay = () => { + setTimeout(() => { + if (isActive.value) { + overlayRef.value?.updatePopper() + } + }) + } + + expose({ + updateOverlay, + }) + + const handlePanelChange = (value: VKey[]) => { + setSelectedFieldKey(value[0]) + props.onChange?.(value[0]) + + handleConfirm() + } + const handleConfirm = () => { + if (!selectedFieldKey.value) { + return + } + + const searchState = createSearchState(selectedFieldKey.value) + + if (!searchState) { + return + } + + updateSearchState(searchState.key) + setActiveSegment({ + itemKey: searchState.key, + name: searchState.segmentValues[0].name, + }) + + callEmit(proSearchProps.onItemCreate, { + ...(convertStateToValue(searchState.key) ?? {}), + nameInput: props.searchValue, + }) + + setSelectedFieldKey(undefined) + } + let panelKeyDown: ((evt: KeyboardEvent) => boolean | undefined) | undefined + const setOnKeyDown = (keydown: ((evt: KeyboardEvent) => boolean) | undefined) => { + panelKeyDown = keydown + } + const handleKeyDown = (evt: KeyboardEvent) => { + if (!selectedFieldKey.value && evt.key === 'Enter') { + callEmit(proSearchProps.onItemCreate, { + name: undefined, + nameInput: props.searchValue, + }) + props.onChange?.(undefined) + } + + if (!overlayOpened.value || !panelKeyDown) { + return true + } + + return !!panelKeyDown(evt) + } + + onMounted(() => { + bindOverlayMonitor(overlayRef, nameSelectOverlayOpened) + props.setOnKeyDown(handleKeyDown) + watch(isActive, active => { + if (!active) { + props.onChange?.(undefined) + } + + updateOverlay() + }) + }) + + const renderNameLabel = (key: VKey, renderer: (searchField: SearchField) => VNodeChild) => { + const searchField = proSearchProps.searchFields!.find(field => field.key === key)! + return renderer(searchField) + } + + const _customNameLabel = proSearchProps.customNameLabel ?? 'nameLabel' + const customNameLabelRender = isString(_customNameLabel) ? slots[_customNameLabel] : _customNameLabel + const panelSlots = { + optionLabel: customNameLabelRender + ? (option: SelectPanelData) => renderNameLabel(option.key, customNameLabelRender) + : undefined, + } + const overlayProps = useOverlayAttrs(mergedPrefixCls, commonOverlayProps, nameSelectOverlayOpened) + + const renderContent = () => { + return filteredDataSource.value.length ? ( + true} + multiple={false} + onChange={handlePanelChange} + setOnKeyDown={setOnKeyDown} + /> + ) : null + } + + return () => ( + <ɵOverlay + ref={overlayRef} + v-slots={{ default: slots.default, content: renderContent }} + {...overlayProps.value} + > + ) + }, +}) + +function useOverlayAttrs( + mergedPrefixCls: ComputedRef, + commonOverlayProps: ComputedRef<ɵOverlayProps>, + overlayOpened: ComputedRef, +): ComputedRef<ɵOverlayProps> { + return computed(() => ({ + ...commonOverlayProps.value, + class: `${mergedPrefixCls.value}-name-segment-overlay`, + trigger: 'manual', + visible: overlayOpened.value, + })) +} diff --git a/packages/pro/search/src/components/SearchItem.tsx b/packages/pro/search/src/components/SearchItem.tsx new file mode 100644 index 000000000..0a5c7783b --- /dev/null +++ b/packages/pro/search/src/components/SearchItem.tsx @@ -0,0 +1,262 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import { + type Ref, + computed, + defineComponent, + inject, + normalizeClass, + onMounted, + onUnmounted, + provide, + ref, + watch, +} from 'vue' + +import { useResizeObserver } from '@idux/cdk/resize' +import { getScroll } from '@idux/cdk/scroll' +import { useEventListener } from '@idux/cdk/utils' +import { IxTooltip } from '@idux/components/tooltip' + +import Segment from './segment/Segment' +import { useSegmentOverlayUpdate } from '../composables/useSegmentOverlayUpdate' +import { useSegmentStates } from '../composables/useSegmentStates' +import { proSearchContext, searchItemContext } from '../token' +import { searchItemProps } from '../types' +import { renderIcon } from '../utils/RenderIcon' +import { getBoxSizingData } from '../utils/getBoxsizingData' + +export default defineComponent({ + props: searchItemProps, + setup(props, { slots }) { + const context = inject(proSearchContext)! + const { props: proSearchProps, mergedPrefixCls, activeSegment, setActiveSegment, removeSearchState } = context + + const itemPrefixCls = computed(() => `${mergedPrefixCls.value}-search-item`) + const isActive = computed(() => activeSegment.value?.itemKey === props.searchItem!.key) + + const wrapperRef = ref() + const segmentsRef = ref() + + watch(isActive, () => { + segmentsRef.value?.scrollTo(0, 0) + }) + + const segmentStateContext = useSegmentStates(props, proSearchProps, context, isActive) + const segmentOverlayUpdateContext = useSegmentOverlayUpdate() + const { segmentStates } = segmentStateContext + + const classes = computed(() => { + const prefixCls = itemPrefixCls.value + return normalizeClass({ + [prefixCls]: true, + [`${prefixCls}-invalid`]: !!props?.searchItem?.error, + }) + }) + + provide(searchItemContext, { + ...segmentStateContext, + ...segmentOverlayUpdateContext, + }) + + const segmentRenderDatas = computed(() => { + const searchItem = props.searchItem! + + return searchItem.resolvedSearchField.segments.map(segment => { + const segmentState = segmentStates.value[segment.name] + return { + ...segment, + input: segmentState?.input, + value: segmentState?.value, + selectionStart: segmentState?.selectionStart, + } + }) + }) + const searchItemName = computed( + () => `${props.searchItem!.name}${props.searchItem!.resolvedSearchField.segments.length > 1 ? '' : ':'}`, + ) + const searchItemTitle = computed( + () => searchItemName.value + ' ' + segmentRenderDatas.value.map(data => data.input).join(' '), + ) + + const { wrapperScroll, segmentScrolls } = useSegmentsScroll(segmentsRef, segmentRenderDatas) + + // when we move cursor within input elements, + // current cursor position will be scrolled into view, + // so if ther start segment input and the segments wrapper is scrolled to start, + // there is no ellipsis + // + // however the wrapper may not be scrolled to start because inputs may have padding and border, + // and cursor wont be moved outside content + // so padding and border may always be ouside the wrapper view area + const leftSideEllipsis = computed(() => { + const startEl = segmentScrolls.value[0]?.el + const startElBoxSizingData = startEl && getBoxSizingData(startEl) + const offset = startElBoxSizingData ? startElBoxSizingData.paddingLeft + startElBoxSizingData.borderLeft : 0 + + return wrapperScroll.value.left > offset + 1 || (segmentScrolls.value[0]?.left ?? 0) > 1 + }) + const rightSideEllipsis = computed(() => { + const endEl = segmentScrolls.value[1]?.el + const endElBoxSizingData = endEl && getBoxSizingData(endEl) + const offset = endElBoxSizingData ? endElBoxSizingData.paddingRight + endElBoxSizingData.borderRight : 0 + + return wrapperScroll.value.right > offset + 1 || (segmentScrolls.value[1]?.right ?? 0) > 1 + }) + + const handleNameMouseDown = (evt: MouseEvent) => { + evt.stopPropagation() + evt.preventDefault() + + setActiveSegment({ + itemKey: props.searchItem!.key, + name: segmentRenderDatas.value[0].name, + }) + } + const handleCloseIconMouseDown = (evt: MouseEvent) => { + evt.stopPropagation() + evt.preventDefault() + } + const handleCloseIconClick = (evt: Event) => { + evt.stopPropagation() + removeSearchState(props.searchItem!.key) + } + + return () => { + const prefixCls = itemPrefixCls.value + return ( + + + + {searchItemName.value} + + + ... + + + {segmentRenderDatas.value.map(segment => ( + + ))} + + + ... + + {!proSearchProps.disabled && ( + + {renderIcon('close')} + + )} + + + ) + } + }, +}) + +interface SegmentScroll { + el: HTMLElement | undefined + left: number + right: number +} +function useSegmentsScroll( + wrapperRef: Ref, + resetTrigger: Ref, +): { + wrapperScroll: Ref + segmentScrolls: Ref +} { + const wrapperScroll = ref({ left: 0, right: 0, el: wrapperRef.value }) + const segmentScrolls = ref([]) + + const getScrolls = (el: HTMLElement) => { + const { scrollLeft } = getScroll(el) + return { + el, + left: scrollLeft, + right: Math.max(el.scrollWidth - scrollLeft - el.offsetWidth, 0), + } + } + + let clearListeners: (() => void) | null = null + let calcSize: (() => void) | null = null + const initScrollListeners = () => { + if (!wrapperRef.value) { + return + } + + clearListeners?.() + segmentScrolls.value = [] + + const calcWrapperScroll = () => { + wrapperScroll.value = getScrolls(wrapperRef.value!) + } + const sizeCalculations = [calcWrapperScroll] + + const listenerStopHandlers = [useEventListener(wrapperRef.value, 'scroll', calcWrapperScroll)] + + const inputs = wrapperRef.value.querySelectorAll('input') + if (!inputs.length) { + return + } + + // listen to the start and end input elements' scroll event + // and add its scroll calculation to the overall scroll calculation + ;[inputs.item(0), inputs.item(inputs.length - 1)].forEach((inputEl, index) => { + const scrollCalculation = () => { + segmentScrolls.value[index] = getScrolls(inputEl) + } + sizeCalculations.push(scrollCalculation) + listenerStopHandlers.push(useEventListener(inputEl, 'scroll', scrollCalculation)) + }) + + clearListeners = () => listenerStopHandlers.forEach(stop => stop()) + calcSize = () => sizeCalculations.forEach(calc => calc()) + + calcSize() + } + + let resizeStop: (() => void) | null = null + onMounted(() => { + // when reset is triggered by comsumer, we re-init the scroll calculations + watch(resetTrigger, initScrollListeners, { immediate: true }) + + // when wrapper is resized, calculate scrolls + resizeStop = useResizeObserver(wrapperRef, () => { + calcSize?.() + }) + }) + onUnmounted(() => { + clearListeners?.() + resizeStop?.() + clearListeners = null + calcSize = null + }) + + return { + wrapperScroll, + segmentScrolls, + } +} diff --git a/packages/pro/search/src/components/quickSelect/QuickSelectItem.tsx b/packages/pro/search/src/components/quickSelect/QuickSelectItem.tsx new file mode 100644 index 000000000..572133fa4 --- /dev/null +++ b/packages/pro/search/src/components/quickSelect/QuickSelectItem.tsx @@ -0,0 +1,142 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { SearchState } from '../../composables/useSearchStates' + +import { computed, defineComponent, inject, normalizeClass, ref, watch } from 'vue' + +import { useState } from '@idux/cdk/utils' +import { type IconInstance, IxIcon } from '@idux/components/icon' +import { IxInput } from '@idux/components/input' + +import { proSearchContext } from '../../token' +import { type SearchDataTypes, quickSelectPanelItemProps, searchDataTypes } from '../../types' + +export default defineComponent({ + props: quickSelectPanelItemProps, + setup(props, { slots }) { + const { + mergedPrefixCls, + createSearchState, + getSearchStatesByFieldKey, + updateSegmentValue, + tempSegmentInputRef, + updateSearchState, + } = inject(proSearchContext)! + + const searchInputRef = ref() + const searchIconRef = ref() + + const [searchInput, setSearchInput] = useState('') + const [searchInputOpend, _setSearchInputOpened] = useState(false) + const setSearchInputOpened = (opened: boolean) => { + _setSearchInputOpened(opened) + if (opened) { + searchInputRef.value?.focus() + } else { + tempSegmentInputRef.value?.focus() + } + } + + const searchBarCls = computed(() => { + const prefixCls = `${mergedPrefixCls.value}-quick-select-item-search-bar` + return normalizeClass({ + [prefixCls]: true, + [`${prefixCls}-opened`]: searchInputOpend.value, + }) + }) + + const searchState = computed(() => getSearchStatesByFieldKey(props.searchField.key)[0]) + const searchDataSegment = computed(() => + props.searchField.segments.find(seg => searchDataTypes.includes(seg.name as SearchDataTypes)), + ) + const searchDataSegmentValue = computed(() => + searchState.value?.segmentValues.find(seg => searchDataTypes.includes(seg.name as SearchDataTypes)), + ) + + const [itemValue, setItemValue] = useState(searchDataSegmentValue.value?.value) + watch(() => searchDataSegmentValue.value?.value, setItemValue) + + const confirmValue = (value: unknown) => { + let searchStateKey = searchState.value?.key + if (!searchState.value) { + searchStateKey = createSearchState(props.searchField.key, { + value, + })!.key + } else if (searchDataSegment.value?.name) { + updateSegmentValue(value, searchDataSegment.value.name, searchState.value.key) + } + + if (!props.searchField.operators) { + updateSearchState(searchStateKey!) + } + } + const setValue = (value: unknown) => { + setItemValue(value) + } + const ok = () => confirmValue(itemValue.value) + const cancel = () => { + setItemValue(searchDataSegmentValue.value?.value) + } + const setOnKeyDown = () => {} + + const handleBlur = () => { + setSearchInput('') + setSearchInputOpened(false) + } + const handleSearchIconClick = () => { + setSearchInputOpened(!searchInputOpend.value) + } + const handleSearchIconMouseDown = (evt: MouseEvent) => { + evt.preventDefault() + evt.stopImmediatePropagation() + } + + return () => { + const prefixCls = `${mergedPrefixCls.value}-quick-select-item` + const classes = normalizeClass([prefixCls, ...(searchDataSegment.value?.containerClassName ?? [])]) + return ( +
+
+ + {props.searchField.quickSelectSearchable && ( +
+ + +
+ )} +
+
+ {searchDataSegment.value?.panelRenderer?.({ + slots, + input: searchInput.value, + value: itemValue.value, + renderLocation: 'quick-select-panel', + ok, + cancel, + setValue, + setOnKeyDown, + })} +
+
+ ) + } + }, +}) diff --git a/packages/pro/search/src/components/quickSelect/QuickSelectPanel.tsx b/packages/pro/search/src/components/quickSelect/QuickSelectPanel.tsx new file mode 100644 index 000000000..ace340d1e --- /dev/null +++ b/packages/pro/search/src/components/quickSelect/QuickSelectPanel.tsx @@ -0,0 +1,74 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { ResolvedSearchField } from '../../types' + +import { computed, defineComponent, inject } from 'vue' + +import QuickSelectPanelItem from './QuickSelectItem' +import QuickSelectPanelShortcut from './QuickSelectShortcut' +import { proSearchContext } from '../../token' + +export default defineComponent({ + setup(_, { slots }) { + const { mergedPrefixCls, resolvedSearchFields, tempSegmentInputRef } = inject(proSearchContext)! + + const separatedFields = computed(() => { + const quickSelectFields: ResolvedSearchField[] = [] + const shortcutFields: ResolvedSearchField[] = [] + + resolvedSearchFields.value.forEach(field => { + if (!!field.quickSelect && !field.multiple) { + quickSelectFields.push(field) + } else { + shortcutFields.push(field) + } + }) + + return { + quickSelectFields, + shortcutFields, + } + }) + + const handleMouseDown = (evt: MouseEvent) => { + if (!(evt.target instanceof HTMLInputElement)) { + evt.preventDefault() + tempSegmentInputRef.value?.focus() + } + } + + return () => { + const prefixCls = `${mergedPrefixCls.value}-quick-select-panel` + + return ( +
+ {separatedFields.value.shortcutFields.length && ( +
+ {separatedFields.value.shortcutFields.map(field => ( + + ))} +
+ )} + {separatedFields.value.quickSelectFields.length && ( +
+ {separatedFields.value.quickSelectFields.map((field, idx) => { + const nodes = [] + + if (idx < separatedFields.value.quickSelectFields.length - 1) { + nodes.push(
) + } + + return nodes + })} +
+ )} +
+ ) + } + }, +}) diff --git a/packages/pro/search/src/components/quickSelect/QuickSelectShortcut.tsx b/packages/pro/search/src/components/quickSelect/QuickSelectShortcut.tsx new file mode 100644 index 000000000..1771002a3 --- /dev/null +++ b/packages/pro/search/src/components/quickSelect/QuickSelectShortcut.tsx @@ -0,0 +1,39 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import { defineComponent, inject } from 'vue' + +import { IxIcon } from '@idux/components/icon' + +import { proSearchContext } from '../../token' +import { quickSelectPanelShortcutProps } from '../../types' + +export default defineComponent({ + props: quickSelectPanelShortcutProps, + setup(props) { + const { mergedPrefixCls, createSearchState, updateSearchState, getSearchStatesByFieldKey } = + inject(proSearchContext)! + + const handleClick = () => { + const fieldKey = props.searchField.key + if (!!props.searchField.multiple || !getSearchStatesByFieldKey(fieldKey).length) { + const state = createSearchState(props.searchField.key) + state && updateSearchState(state.key) + } + } + + return () => { + const prefixCls = `${mergedPrefixCls.value}-quick-select-shortcut` + return ( + + {props.searchField.icon && } + {props.searchField.label} + + ) + } + }, +}) diff --git a/packages/pro/search/src/searchItem/Segment.tsx b/packages/pro/search/src/components/segment/Segment.tsx similarity index 52% rename from packages/pro/search/src/searchItem/Segment.tsx rename to packages/pro/search/src/components/segment/Segment.tsx index fe398bda2..32cd476cf 100644 --- a/packages/pro/search/src/searchItem/Segment.tsx +++ b/packages/pro/search/src/components/segment/Segment.tsx @@ -19,39 +19,40 @@ import { watch, } from 'vue' -import { useResizeObserver } from '@idux/cdk/resize' -import { convertArray, convertCssPixel, useState } from '@idux/cdk/utils' +import { convertArray, useState } from '@idux/cdk/utils' import { ɵOverlay, type ɵOverlayInstance, type ɵOverlayProps } from '@idux/components/_private/overlay' -import { tempSearchStateKey } from '../composables/useSearchStates' -import { type ProSearchContext, proSearchContext, searchItemContext } from '../token' -import { type SegmentProps, segmentProps } from '../types' +import { type ProSearchContext, proSearchContext, searchItemContext } from '../../token' +import { type SegmentInputInstance, type SegmentProps, segmentProps } from '../../types' +import SegmentInput from '../segment/SegmentInput' export default defineComponent({ props: segmentProps, setup(props: SegmentProps, { slots }) { const context = inject(proSearchContext)! - const { mergedPrefixCls, commonOverlayProps, focused, activeSegment, searchStates, setActiveSegment } = context + const { + mergedPrefixCls, + bindOverlayMonitor, + commonOverlayProps, + focused, + activeSegment, + searchStates, + overlayOpened: _overlayOpened, + setOverlayOpened, + } = context const overlayRef = ref<ɵOverlayInstance>() - const segmentInputRef = ref() - const measureSpanRef = ref() + const segmentInputRef = ref() const contentNodeEmpty = ref(false) - function setCurrentAsActive(overlayOpened: boolean) { - setActiveSegment({ - itemKey: props.itemKey, - name: props.segment.name, - overlayOpened, - }) - } - const { triggerOverlayUpdate, registerOverlayUpdate, unregisterOverlayUpdate, + changeActiveAndSelect, handleSegmentInput, handleSegmentChange, + handleSegmentSelect, handleSegmentConfirm, handleSegmentCancel, } = inject(searchItemContext)! @@ -59,26 +60,11 @@ export default defineComponent({ const isActive = computed( () => activeSegment.value?.itemKey === props.itemKey && activeSegment.value.name === props.segment.name, ) - const _overlayOpened = computed( - () => focused.value && isActive.value && !!activeSegment.value?.overlayOpened && !contentNodeEmpty.value, + const overlayOpened = computed( + () => focused.value && isActive.value && _overlayOpened.value && !contentNodeEmpty.value, ) - const [overlayOpened, setOverlayOpened] = useState(_overlayOpened.value) - - // use a proxied state to prevent unnecessary effect triggering - // which causes component render to trigger multiple times - watch(_overlayOpened, (opened, oldOpened) => { - opened !== oldOpened && setOverlayOpened(opened) - }) - const inputWidth = useInputWidth(measureSpanRef) - const inputStyle = computed(() => ({ - minWidth: props.disabled ? '0' : undefined, - width: convertCssPixel(inputWidth.value), - })) - const inputClasses = computed(() => [ - `${mergedPrefixCls.value}-segment-input`, - ...convertArray(props.segment.inputClassName), - ]) + const inputClasses = computed(() => convertArray(props.segment.inputClassName)) const updateOverlay = () => { nextTick(() => { @@ -87,7 +73,14 @@ export default defineComponent({ } }) } - watch([inputStyle, () => searchStates.value.length], triggerOverlayUpdate) + const updateSelectionStart = (selectionStart: number) => { + const inputEl = segmentInputRef.value?.getInputElement() + if (inputEl && selectionStart !== inputEl.selectionStart) { + inputEl.setSelectionRange(selectionStart, selectionStart) + } + } + + watch(() => searchStates.value.length, triggerOverlayUpdate) const classes = computed(() => { const prefixCls = `${mergedPrefixCls.value}-segment` @@ -99,40 +92,29 @@ export default defineComponent({ }) onMounted(() => { - watch( - activeSegment, - () => { - nextTick(() => { - if (isActive.value) { - segmentInputRef.value?.focus() - } - }) - }, - { immediate: true }, - ) + bindOverlayMonitor(overlayRef, overlayOpened) watch( isActive, active => { nextTick(() => { if (active) { - if (!props.value && props.segment.defaultValue) { - handleSegmentChange(props.segment.name, props.segment.defaultValue) - handleSegmentConfirm(props.segment.name, false) - } + segmentInputRef.value?.getInputElement().focus() + } else { + updateSelectionStart(0) + handleSegmentSelect(props.segment.name, 0) } }) }, { immediate: true }, ) watch( - inputStyle, - style => { - segmentInputRef.value && - Object.entries(style).forEach(([key, value]) => { - segmentInputRef.value!.style[key as keyof typeof style] = value ?? '' - }) + () => props.selectionStart, + selectionStart => { + updateSelectionStart(selectionStart ?? 0) + }, + { + immediate: true, }, - { immediate: true }, ) watch( overlayOpened, @@ -156,35 +138,36 @@ export default defineComponent({ handleSegmentConfirm(props.segment.name, true) } const handleCancel = () => { - setCurrentAsActive(false) + setOverlayOpened(false) handleSegmentCancel(props.segment.name) } - const { - handleInput, - handleCompositionStart, - handleCompositionEnd, - handleMouseDown, - handleKeyDown, - setPanelOnKeyDown, - } = useInputEvents(props, context, overlayOpened, handleSegmentInput, setCurrentAsActive, handleConfirm) + const { handleInput, handleFocus, handleKeyDown, setPanelOnKeyDown } = useInputEvents( + props, + context, + segmentInputRef, + overlayOpened, + handleSegmentInput, + handleSegmentSelect, + setOverlayOpened, + changeActiveAndSelect, + handleConfirm, + ) const overlayProps = useOverlayAttrs(props, mergedPrefixCls, commonOverlayProps, overlayOpened) const renderTrigger = () => ( - + onWidthChange={triggerOverlayUpdate} + > ) const renderContent = () => { @@ -192,6 +175,7 @@ export default defineComponent({ slots, input: props.input ?? '', value: props.value, + renderLocation: 'individual', cancel: handleCancel, ok: handleConfirm, setValue: handleChange, @@ -204,7 +188,6 @@ export default defineComponent({ return () => { const { panelRenderer } = props.segment - const prefixCls = `${mergedPrefixCls.value}-segment` return ( @@ -218,25 +201,12 @@ export default defineComponent({ ) : ( renderTrigger() )} - - {props.input || props.segment.placeholder || ''} - ) } }, }) -function useInputWidth(measureSpanRef: Ref): ComputedRef { - const [spanWidth, setSpanWidth] = useState(0) - - useResizeObserver(measureSpanRef, ({ contentRect }) => { - setSpanWidth(contentRect.width) - }) - - return spanWidth -} - function useOverlayAttrs( props: SegmentProps, mergedPrefixCls: ComputedRef, @@ -245,7 +215,7 @@ function useOverlayAttrs( ): ComputedRef<ɵOverlayProps> { return computed(() => ({ ...commonOverlayProps.value, - class: `${mergedPrefixCls.value}-segment-overlay`, + class: normalizeClass([`${mergedPrefixCls.value}-segment-overlay`, ...(props.segment.containerClassName ?? [])]), trigger: 'manual', visible: overlayOpened.value, disabled: props.disabled, @@ -253,10 +223,8 @@ function useOverlayAttrs( } interface InputEventHandlers { - handleInput: (evt: Event) => void - handleCompositionStart: () => void - handleCompositionEnd: (evt: CompositionEvent) => void - handleMouseDown: (evt: MouseEvent) => void + handleInput: (input: string) => void + handleFocus: (evt: FocusEvent) => void handleKeyDown: (evt: KeyboardEvent) => void setPanelOnKeyDown: (onKeyDown: ((evt: KeyboardEvent) => boolean) | undefined) => void } @@ -264,44 +232,43 @@ interface InputEventHandlers { function useInputEvents( props: SegmentProps, context: ProSearchContext, + segmentInputRef: Ref, overlayOpened: ComputedRef, handleSegmentInput: (name: string, input: string) => void, - setCurrentAsActive: (overlayOpened: boolean) => void, + handleSegmentSelect: (name: string, selectionStart: number | undefined | null) => void, + setOverlayOpened: (overlayOpened: boolean) => void, + changeActiveAndSelect: (offset: number, selectionStart: number | 'start' | 'end') => void, confirm: () => void, ): InputEventHandlers { - const { searchStates, removeSearchState, changeActive } = context + const { setActiveSegment } = context const [panelOnKeyDown, setPanelOnKeyDown] = useState<((evt: KeyboardEvent) => boolean) | undefined>(undefined) - const isComposing = ref(false) - - function removePreviousState() { - const currentStateIndex = searchStates.value.findIndex(state => state.key === props.itemKey) - const previousState = searchStates.value[currentStateIndex - 1] - - if (previousState?.key) { - removeSearchState(previousState.key) - } + function setCurrentActive() { + setActiveSegment({ + itemKey: props.itemKey, + name: props.segment.name, + }) } - - const handleInput = (evt: Event) => { - if (!isComposing.value) { - handleSegmentInput(props.segment.name, (evt.target as HTMLInputElement).value) - } - - setCurrentAsActive(true) + function getInputElement() { + return segmentInputRef.value?.getInputElement() } - const handleCompositionStart = () => { - isComposing.value = true + function setSelectionStart() { + setTimeout(() => { + handleSegmentSelect(props.segment.name, getInputElement()?.selectionStart) + }) } - const handleCompositionEnd = (evt: CompositionEvent) => { - if (isComposing.value) { - isComposing.value = false - handleInput(evt) - } + + const handleInput = (input: string) => { + handleSegmentInput(props.segment.name, input) + setCurrentActive() + setOverlayOpened(true) + setSelectionStart() } - const handleMouseDown = (evt: Event) => { + const handleFocus = (evt: FocusEvent) => { evt.stopPropagation() - setCurrentAsActive(true) + setCurrentActive() + setOverlayOpened(true) + setSelectionStart() } const handleKeyDown = (evt: KeyboardEvent) => { evt.stopPropagation() @@ -315,35 +282,48 @@ function useInputEvents( if (props.input || overlayOpened.value || !props.segment.panelRenderer) { confirm() } else { - setCurrentAsActive(true) + setOverlayOpened(true) } break case 'Backspace': - if (!props.input) { + if (props.selectionStart === 0) { evt.preventDefault() - if (props.itemKey === tempSearchStateKey && props.segment.name === 'name') { - removePreviousState() - break - } - changeActive(-1, true) + changeActiveAndSelect(-1, 'end') } break case 'Escape': - setCurrentAsActive(false) + setOverlayOpened(false) + break + case 'ArrowLeft': + if ((getInputElement()?.selectionStart ?? 0) <= 0) { + evt.preventDefault() + changeActiveAndSelect(-1, 'end') + } else { + setSelectionStart() + setOverlayOpened(true) + } + break + case 'ArrowRight': + if ((getInputElement()?.selectionStart ?? 0) >= (props.input?.length ?? 0)) { + evt.preventDefault() + changeActiveAndSelect(1, 'start') + } else { + setSelectionStart() + setOverlayOpened(true) + } break default: - setCurrentAsActive(true) + setSelectionStart() + setOverlayOpened(true) break } } return { handleInput, - handleCompositionStart, - handleCompositionEnd, - handleMouseDown, + handleFocus, handleKeyDown, setPanelOnKeyDown, } diff --git a/packages/pro/search/src/components/segment/SegmentInput.tsx b/packages/pro/search/src/components/segment/SegmentInput.tsx new file mode 100644 index 000000000..8b9d0295f --- /dev/null +++ b/packages/pro/search/src/components/segment/SegmentInput.tsx @@ -0,0 +1,116 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import { computed, defineComponent, inject, normalizeClass, onMounted, ref, watch } from 'vue' + +import { callEmit, convertCssPixel, useState } from '@idux/cdk/utils' + +import { proSearchContext } from '../../token' +import { type SegmentInputProps, segmentIputProps } from '../../types' +import MeasureElement from '../MeasureElement' + +export default defineComponent({ + inheritAttrs: false, + props: segmentIputProps, + setup(props: SegmentInputProps, { attrs, expose }) { + const { mergedPrefixCls } = inject(proSearchContext)! + + const segmentInputRef = ref() + + const [inputWidth, setInputWidth] = useState(0) + + const inputStyle = computed(() => ({ + minWidth: props.disabled ? '0' : undefined, + width: convertCssPixel(inputWidth.value), + })) + + watch(inputWidth, width => { + callEmit(props.onWidthChange, width) + }) + + const classes = computed(() => { + const prefixCls = `${mergedPrefixCls.value}-segment-input` + + return normalizeClass({ + [prefixCls]: true, + [`${prefixCls}-disabled`]: !!props.disabled, + }) + }) + + onMounted(() => { + watch( + inputStyle, + style => { + segmentInputRef.value && + Object.entries(style).forEach(([key, value]) => { + segmentInputRef.value!.style[key as keyof typeof style] = value ?? '' + }) + }, + { immediate: true }, + ) + }) + + const getInputElement = () => segmentInputRef.value + expose({ + getInputElement, + }) + + const { handleInput, handleCompositionStart, handleCompositionEnd } = useInputEvents(props) + + return () => { + const prefixCls = `${mergedPrefixCls.value}-segment-input` + + return ( + + + {props.value || props.placeholder || ''} + + ) + } + }, +}) + +interface InputEventHandlers { + handleInput: (evt: Event) => void + handleCompositionStart: () => void + handleCompositionEnd: (evt: CompositionEvent) => void +} + +function useInputEvents(props: SegmentInputProps): InputEventHandlers { + const isComposing = ref(false) + + const handleInput = (evt: Event) => { + if (!isComposing.value) { + callEmit(props.onInput, (evt.target as HTMLInputElement).value) + } + } + const handleCompositionStart = () => { + isComposing.value = true + } + const handleCompositionEnd = (evt: CompositionEvent) => { + if (isComposing.value) { + isComposing.value = false + handleInput(evt) + } + } + + return { + handleInput, + handleCompositionStart, + handleCompositionEnd, + } +} diff --git a/packages/pro/search/src/components/segment/TempSegment.tsx b/packages/pro/search/src/components/segment/TempSegment.tsx new file mode 100644 index 000000000..dac6db205 --- /dev/null +++ b/packages/pro/search/src/components/segment/TempSegment.tsx @@ -0,0 +1,157 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { NameSelectOverlayInstance, ResolvedSearchField, SegmentInputInstance } from '../../types' + +import { computed, defineComponent, inject, onMounted, ref, watch } from 'vue' + +import { type VKey, useState } from '@idux/cdk/utils' + +import SegmentInput from './SegmentInput' +import { proSearchContext } from '../../token' +import NameSelectOverlay from '../NameSelectOverlay' + +export default defineComponent({ + setup(_, { slots }) { + const context = inject(proSearchContext)! + const { + resolvedSearchFields, + mergedPrefixCls, + enableQuickSelect, + nameSelectActive, + quickSelectActive, + searchStates, + tempSegmentInputRef, + removeSearchState, + setNameSelectActive, + setQuickSelectActive, + setTempActive, + setOverlayOpened, + } = context + + const overlayRef = ref() + const segmentInputRef = ref() + + const [input, _setInput] = useState('') + + const setInput = (input: string) => { + _setInput(input) + if (!enableQuickSelect.value || (!nameSelectActive.value && !quickSelectActive.value)) { + return + } + + if (input) { + setNameSelectActive() + } else { + setQuickSelectActive() + } + } + + const isActive = computed(() => nameSelectActive.value || quickSelectActive.value) + + const nameInputStyle = computed(() => { + if (isActive.value) { + return undefined + } + + return { + opacity: 0, + width: 0, + height: 0, + overflow: 'hidden', + visibility: 'hidden', + } + }) + + const updateOverlay = () => { + overlayRef.value?.updateOverlay() + } + + onMounted(() => { + tempSegmentInputRef.value = segmentInputRef.value?.getInputElement() + watch(() => searchStates.value.length, updateOverlay) + }) + + let panelKeyDown: (evt: KeyboardEvent) => boolean | undefined = () => true + const setOnKeyDown = (keydown: ((evt: KeyboardEvent) => boolean) | undefined) => { + if (!keydown) { + panelKeyDown = () => true + } else { + panelKeyDown = keydown + } + } + const handleKeyDown = (evt: KeyboardEvent) => { + if (!panelKeyDown(evt)) { + return + } + + switch (evt.key) { + case 'Enter': + evt.preventDefault() + setOverlayOpened(true) + + break + case 'Backspace': + if (!input.value) { + evt.preventDefault() + const lastSearchState = searchStates.value[searchStates.value.length - 1] + + if (lastSearchState) { + removeSearchState(lastSearchState.key) + } + } + break + case 'Escape': + setOverlayOpened(false) + break + default: + setOverlayOpened(true) + } + } + + const handleMouseDown = () => { + setTempActive() + } + const handleInput = (input: string) => { + setInput(input) + } + const handleChange = (value: VKey | undefined) => { + setInput(formatValue(value, resolvedSearchFields.value)) + } + + const renderTrigger = () => ( + + ) + + return () => ( + + ) + }, +}) + +function formatValue(value: VKey | undefined, searchFields: ResolvedSearchField[]): string { + if (!value) { + return '' + } + + return searchFields.find(field => field.key === value)?.label ?? '' +} diff --git a/packages/pro/search/src/composables/useActiveSegment.ts b/packages/pro/search/src/composables/useActiveSegment.ts index deb412f3f..c326e528c 100644 --- a/packages/pro/search/src/composables/useActiveSegment.ts +++ b/packages/pro/search/src/composables/useActiveSegment.ts @@ -7,33 +7,46 @@ import type { ProSearchProps, SearchItem, Segment } from '../types' -import { type ComputedRef, type Ref, computed } from 'vue' +import { type ComputedRef, type Ref, computed, nextTick } from 'vue' import { type VKey, useState } from '@idux/cdk/utils' -import { tempSearchStateKey } from './useSearchStates' - export interface ActiveSegmentContext { + isActive: ComputedRef activeSegment: ComputedRef setActiveSegment: (segment: ActiveSegment | undefined) => void changeActive: (offset: number, crossItem?: boolean) => void setInactive: (blur?: boolean) => void - setTempActive: (overlayOpened?: boolean) => void + + nameSelectActive: ComputedRef + setNameSelectActive: () => void + quickSelectActive: ComputedRef + setQuickSelectActive: () => void + + setTempActive: () => void + + overlayOpened: ComputedRef + setOverlayOpened: (overlayOpened: boolean) => void } export interface ActiveSegment { itemKey: VKey name: string - overlayOpened: boolean } type FlattenedSegment = Segment & { itemKey: VKey } export function useActiveSegment( props: ProSearchProps, - elementRef: Ref, + tempSegmentInputRef: Ref, searchItems: ComputedRef, - tempSearchStateAvailable: ComputedRef, + enableQuickSelect: ComputedRef, ): ActiveSegmentContext { const [activeSegment, setActiveSegment] = useState(undefined) + const [nameSelectActive, _setNameSelectActive] = useState(false) + const [quickSelectActive, _setQuickSelectActive] = useState(false) + const [overlayOpened, setOverlayOpened] = useState(false) + + const isActive = computed(() => !!activeSegment.value || nameSelectActive.value || quickSelectActive.value) + const mergedActiveSegment = computed(() => (props.disabled ? undefined : activeSegment.value)) const flattenedSegments = computed(() => flattenSegments(searchItems.value ?? [])) const activeItem = computed(() => searchItems.value?.find(item => item.key === activeSegment.value?.itemKey)) @@ -45,14 +58,20 @@ export function useActiveSegment( const updateActiveSegment = (segment: ActiveSegment | undefined) => { if ( - activeSegment.value?.itemKey === segment?.itemKey && - activeSegment.value?.name === segment?.name && - activeSegment.value?.overlayOpened === segment?.overlayOpened + segment && + activeSegment.value && + activeSegment.value.itemKey === segment.itemKey && + activeSegment.value.name === segment.name ) { return } setActiveSegment(segment) + + if (segment) { + _setNameSelectActive(false) + _setQuickSelectActive(false) + } } const changeActive = (offset: number, crossItem = false) => { @@ -67,9 +86,11 @@ export function useActiveSegment( if (activeItem.value && targetSegment?.itemKey !== activeSegment.value.itemKey && !crossItem) { updateActiveSegment({ itemKey: activeItem.value!.key, - name: activeItem.value!.segments[offset < 0 ? 0 : activeItem.value!.segments.length - 1].name, - overlayOpened: !crossItem, + name: activeItem.value!.resolvedSearchField.segments[ + offset < 0 ? 0 : activeItem.value!.resolvedSearchField.segments.length - 1 + ].name, }) + setOverlayOpened(!crossItem) return } @@ -79,42 +100,63 @@ export function useActiveSegment( ? { itemKey: targetSegment.itemKey, name: targetSegment.name, - overlayOpened: !crossItem, } : undefined, ) + setOverlayOpened(!crossItem) /* eslint-enable indent */ } const setInactive = () => { setActiveSegment(undefined) + _setNameSelectActive(false) + _setQuickSelectActive(false) + } + const setNameSelectActive = () => { + setActiveSegment(undefined) + _setNameSelectActive(true) + _setQuickSelectActive(false) + } + const setQuickSelectActive = () => { + setActiveSegment(undefined) + _setQuickSelectActive(true) + _setNameSelectActive(false) } - const setTempActive = (overlayOpened = false) => { - if (!tempSearchStateAvailable.value) { - setInactive() + const setTempActive = () => { + if (enableQuickSelect.value) { + setQuickSelectActive() } else { - setActiveSegment({ - itemKey: tempSearchStateKey, - name: 'name', - overlayOpened, - }) + setNameSelectActive() } + + nextTick(() => { + tempSegmentInputRef.value?.focus() + }) + + setOverlayOpened(true) } return { + isActive, activeSegment: mergedActiveSegment, setActiveSegment: updateActiveSegment, changeActive, setInactive, + nameSelectActive, + setNameSelectActive, + quickSelectActive, + setQuickSelectActive, setTempActive, + overlayOpened, + setOverlayOpened, } } function flattenSegments(searchItems: SearchItem[]): FlattenedSegment[] { const segments: FlattenedSegment[] = [] searchItems.forEach(item => { - segments.push(...item.segments.map(segment => ({ ...segment, itemKey: item.key }))) + segments.push(...item.resolvedSearchField.segments.map(segment => ({ ...segment, itemKey: item.key }))) }) return segments diff --git a/packages/pro/search/src/composables/useCommonOverlayProps.ts b/packages/pro/search/src/composables/useCommonOverlayProps.ts index 57c126d3c..2d1f93611 100644 --- a/packages/pro/search/src/composables/useCommonOverlayProps.ts +++ b/packages/pro/search/src/composables/useCommonOverlayProps.ts @@ -24,6 +24,6 @@ export function useCommonOverlayProps( containerFallback: `.${mergedPrefixCls.value}-overlay-container`, placement: 'bottomStart', transitionName: `${componentCommonConfig.prefixCls}-slide-auto`, - offset: [0, 4], + offset: [0, 12], })) } diff --git a/packages/pro/search/src/composables/useControl.ts b/packages/pro/search/src/composables/useControl.ts index 628044084..08ebcb62a 100644 --- a/packages/pro/search/src/composables/useControl.ts +++ b/packages/pro/search/src/composables/useControl.ts @@ -19,28 +19,22 @@ export function useControl( searchStateContext: SearchStateContext, focusEventContext: FocusStateContext, ): void { - const { activeSegment, setInactive, setTempActive } = activeSegmentContext - const { searchStates, initTempSearchState } = searchStateContext - const { focused, focus, onFocus, onBlur } = focusEventContext + const { isActive, nameSelectActive, quickSelectActive, setInactive, setTempActive } = activeSegmentContext + const { searchStates } = searchStateContext + const { focused, focusVia, onFocus, onBlur } = focusEventContext onFocus(evt => { if (evt.target === elementRef.value) { - setTempActive(true) + setTempActive() } }) - onFocus((evt, origin) => { - if (focused.value && evt.target === elementRef.value && origin !== 'program') { - setTempActive(true) - } - }, true) onBlur(() => { setInactive() - initTempSearchState() }) - watch([activeSegment, searchStates], ([segment]) => { - if (!segment && focused.value) { - focus() + watch([isActive, searchStates], ([active]) => { + if (!active && focused.value) { + focusVia(elementRef) } }) @@ -49,14 +43,22 @@ export function useControl( clearHandlers.forEach(handler => handler()) } })([ - useEventListener(elementRef, 'mousedown', () => { - if (focused.value && !activeSegment.value) { - setTempActive(true) + useEventListener(elementRef, 'mousedown', evt => { + if (!focused.value) { + return + } + + if (!nameSelectActive.value || !quickSelectActive.value) { + setTempActive() + } + + if (!(evt.target instanceof HTMLInputElement)) { + evt.preventDefault() } }), useEventListener(elementRef, 'keydown', evt => { - if (focused.value && !activeSegment.value && !['Backspace', 'Escape'].includes(evt.code)) { - setTempActive(true) + if (focused.value && !isActive.value && !['Backspace', 'Escape'].includes(evt.code)) { + setTempActive() } }), ]) diff --git a/packages/pro/search/src/composables/useElementWidthMeasure.ts b/packages/pro/search/src/composables/useElementWidthMeasure.ts new file mode 100644 index 000000000..18e0d75cb --- /dev/null +++ b/packages/pro/search/src/composables/useElementWidthMeasure.ts @@ -0,0 +1,21 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { ComputedRef, Ref } from 'vue' + +import { useResizeObserver } from '@idux/cdk/resize' +import { useState } from '@idux/cdk/utils' + +export function useElementWidthMeasure(measureSpanRef: Ref): ComputedRef { + const [spanWidth, setSpanWidth] = useState(0) + + useResizeObserver(measureSpanRef, ({ contentRect }) => { + setSpanWidth(contentRect.width) + }) + + return spanWidth +} diff --git a/packages/pro/search/src/composables/useFocusedState.ts b/packages/pro/search/src/composables/useFocusedState.ts index 2f154dce4..e3b2e369b 100644 --- a/packages/pro/search/src/composables/useFocusedState.ts +++ b/packages/pro/search/src/composables/useFocusedState.ts @@ -6,31 +6,27 @@ */ import type { ProSearchProps } from '../types' -import type { ɵOverlayProps } from '@idux/components/_private/overlay' +import type { ɵOverlayInstance } from '@idux/components/_private/overlay' -import { type ComputedRef, type Ref, nextTick, onBeforeUnmount, onMounted, watch } from 'vue' - -import { isFunction, isString } from 'lodash-es' +import { type ComputedRef, type Ref, onBeforeUnmount, watch } from 'vue' import { type FocusOrigin, useSharedFocusMonitor } from '@idux/cdk/a11y' -import { MaybeElementRef, callEmit, useState } from '@idux/cdk/utils' +import { type MaybeElement, type MaybeElementRef, callEmit, useState } from '@idux/cdk/utils' type FocusHandler = (evt: FocusEvent, origin?: FocusOrigin) => void type BlurHandler = (evt: FocusEvent) => void export interface FocusStateContext { focused: ComputedRef - focus: (options?: FocusOptions) => void - blur: () => void + bindMonitor: (elRef: Ref) => void + bindOverlayMonitor: (overlayRef: Ref<ɵOverlayInstance | undefined>, overlayOpened: Ref) => void + focusVia: (elRef: MaybeElementRef, origin?: FocusOrigin, options?: FocusOptions) => void + blurVia: (elRef: MaybeElementRef) => void onFocus: (handler: FocusHandler, deep?: boolean) => void onBlur: (handler: BlurHandler, deep?: boolean) => void } -export function useFocusedState( - props: ProSearchProps, - elementRef: Ref, - commonOverlayProps: ComputedRef<ɵOverlayProps>, -): FocusStateContext { +export function useFocusedState(props: ProSearchProps): FocusStateContext { const [focused, setFocused] = useState(false) const { handleFocus, handleBlur } = useFocusHandlers(props, focused, setFocused) @@ -75,33 +71,9 @@ export function useFocusedState( }) } - const { focus, blur } = registerFocusBlurHandlers( - elementRef, - () => getContainerEl(commonOverlayProps.value), - _handleFocus, - _handleBlur, - ) - - const _focus = (options?: FocusOptions) => { - focus('program', options) - } - const _blur = () => { - blur() - setFocused(false) - } - - return { focused, focus: _focus, blur: _blur, onFocus, onBlur } -} - -function getContainerEl(commonOverlayProps: ɵOverlayProps): HTMLElement | null { - const container = isFunction(commonOverlayProps.container) - ? commonOverlayProps.container() - : commonOverlayProps.container - const resolvedContainer = container ?? commonOverlayProps.containerFallback + const { bindMonitor, bindOverlayMonitor, focusVia, blurVia } = useMonitor(_handleFocus, _handleBlur) - return isString(resolvedContainer) - ? document.querySelector(/^[.#]/.test(resolvedContainer) ? resolvedContainer : `.${resolvedContainer}`) - : resolvedContainer + return { focused, bindMonitor, bindOverlayMonitor, focusVia, blurVia, onFocus, onBlur } } function useFocusHandlers( @@ -112,27 +84,22 @@ function useFocusHandlers( handleFocus: (evt: FocusEvent, cb?: () => void) => void handleBlur: (evt: FocusEvent, cb?: () => void) => void } { - let shouldCheck = false - let subsequentFocus = false + let lastFocusEvtTime = 0 // check if the next focus event within the monitored elements // is triggered right away - const checkSubsequentFocus = async () => { - subsequentFocus = false - shouldCheck = true + const checkSubsequentFocus = async (evt: FocusEvent) => { await new Promise(resolve => setTimeout(resolve)) - const _subsequentFocus = subsequentFocus - subsequentFocus = false - shouldCheck = false - - return _subsequentFocus + // if last focus evt triggered time is after current blur event + // we treat it as a subsquent focus event + return lastFocusEvtTime > evt.timeStamp } const handleBlur = async (evt: FocusEvent, cb?: () => void) => { // if a subsequent focus event is triggered within the monitored elements // we considered the pro search component is still focused // then we skip the blur handler for this time - if (await checkSubsequentFocus()) { + if (await checkSubsequentFocus(evt)) { return } @@ -144,10 +111,8 @@ function useFocusHandlers( } const handleFocus = (evt: FocusEvent, cb?: () => void) => { - // set subsequentFocus to true for check - if (shouldCheck) { - subsequentFocus = true - } + // record focus evt time stamp + lastFocusEvtTime = evt.timeStamp if (focused.value) { return @@ -165,72 +130,107 @@ function useFocusHandlers( } } -function registerFocusBlurHandlers( - elementRef: Ref, - getOverlayContainer: () => HTMLElement | null, +function useMonitor( handleFocus: (evt: FocusEvent, origin: FocusOrigin) => void, handleBlur: (evt: FocusEvent) => void, ): { - focus: (origin: FocusOrigin | undefined, options?: FocusOptions) => void - blur: () => void + bindMonitor: (elRef: Ref) => void + bindOverlayMonitor: (overlayRef: Ref<ɵOverlayInstance | undefined>, overlayOpened: Ref) => void + focusVia: (elRef: MaybeElementRef, origin?: FocusOrigin, options?: FocusOptions) => void + blurVia: (elRef: MaybeElementRef) => void } { - const { monitor, stopMonitoring, focusVia, blurVia } = useSharedFocusMonitor() - - const monitoredElements = new Set() - const bindMonitor = ( - elRef: MaybeElementRef, - onFocus: (evt: FocusEvent, origin: FocusOrigin) => void, - onBlur: (evt: FocusEvent) => void, - ) => { - watch(monitor(elRef, true), evt => { + const { monitor, stopMonitoring, focusVia: _focusVia, blurVia } = useSharedFocusMonitor() + + const monitorStops = new Set<() => void>() + const _bindMonitor = (el: MaybeElement) => { + const stop = watch(monitor(el, true), evt => { const { origin, event } = evt if (event) { if (origin) { - onFocus(event, origin) + handleFocus(event, origin) } else { - onBlur(event) + handleBlur(event) } } }) - // store monitored elements for later destruction - monitoredElements.add(elRef) + return () => { + stop() + stopMonitoring(el) + } } - const unbindMonitor = () => { - monitoredElements.forEach(el => stopMonitoring(el)) + + const bindMonitor = (elRef: Ref) => { + let stop: () => void | undefined + const stopWatch = watch( + elRef, + el => { + stop?.() + + if (!el) { + return + } + + stop = _bindMonitor(el) + }, + { + immediate: true, + }, + ) + + const stopMonitor = () => { + stop?.() + stopWatch() + } + monitorStops.add(stopMonitor) + + return stopMonitor } + const bindOverlayMonitor = (overlayRef: Ref<ɵOverlayInstance | undefined>, overlayOpened: Ref) => { + let stop: () => void | undefined + + const stopWatch = watch( + [() => overlayRef.value?.getPopperElement(), overlayOpened], + ([el, opened], [formerEl]) => { + stop?.() + formerEl && stopMonitoring(formerEl) + if (!opened || !el) { + return + } - let overlayContainerMonitored = false - - onMounted(() => { - bindMonitor( - elementRef, - (evt, origin) => { - handleFocus(evt, origin) - - // overlayContainer isn't rendered until at least one of the inner overlays is rendered - // so we monitor the overlay container after focus (current logic ensures that overlay renders after focus) - nextTick(() => { - if (overlayContainerMonitored) { - return - } - - const container = getOverlayContainer() - if (container) { - bindMonitor(container, handleFocus, handleBlur) - overlayContainerMonitored = true - } - }) + _bindMonitor(el) + }, + { + immediate: true, }, - handleBlur, ) - }) + + const stopMonitor = () => { + stop?.() + stopWatch() + } + + monitorStops.add(stopMonitor) + + return stopMonitor + } + + const unbindAllMonitor = () => { + monitorStops.forEach(stop => stop()) + } + onBeforeUnmount(() => { - unbindMonitor() + unbindAllMonitor() }) + const focusVia = (elRef: MaybeElementRef, origin?: FocusOrigin, options?: FocusOptions) => { + _focusVia(elRef, origin ?? 'program', options) + } + return { - focus: (origin, options) => focusVia(elementRef.value, origin ?? 'program', options), - blur: () => blurVia(elementRef.value), + bindMonitor, + bindOverlayMonitor, + focusVia, + blurVia, } } diff --git a/packages/pro/search/src/composables/useResolvedSearchFields.ts b/packages/pro/search/src/composables/useResolvedSearchFields.ts new file mode 100644 index 000000000..571660ec2 --- /dev/null +++ b/packages/pro/search/src/composables/useResolvedSearchFields.ts @@ -0,0 +1,66 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { ProSearchProps, ResolvedSearchField, SearchField, Segment } from '../types' +import type { DateConfig } from '@idux/components/config' + +import { type ComputedRef, type Slots, computed } from 'vue' + +import { createCascaderSegment } from '../segments/CreateCascaderSegment' +import { createDatePickerSegment } from '../segments/CreateDatePickerSegment' +import { createDateRangePickerSegment } from '../segments/CreateDateRangePickerSegment' +import { createOperatorSegment } from '../segments/CreateOperatorSegment' +import { createSelectSegment } from '../segments/CreateSelectSegment' +import { createTreeSelectSegment } from '../segments/CreateTreeSelectSegment' +import { createCustomSegment } from '../segments/createCustomSegment' +import { createInputSegment } from '../segments/createInputSegment' + +export function useResolvedSearchFields( + props: ProSearchProps, + slots: Slots, + mergedPrefixCls: ComputedRef, + dateConfig: DateConfig, +): ComputedRef { + return computed( + () => + props.searchFields?.map(searchField => { + return { + ...searchField, + segments: [ + createOperatorSegment(mergedPrefixCls.value, searchField), + createSearchItemContentSegment(mergedPrefixCls.value, searchField, slots, dateConfig), + ].filter(Boolean) as Segment[], + } + }) ?? [], + ) +} + +function createSearchItemContentSegment( + prefixCls: string, + searchField: SearchField, + slots: Slots, + dateConfig: DateConfig, +) { + switch (searchField.type) { + case 'select': + return createSelectSegment(prefixCls, searchField) + case 'treeSelect': + return createTreeSelectSegment(prefixCls, searchField) + case 'cascader': + return createCascaderSegment(prefixCls, searchField) + case 'input': + return createInputSegment(prefixCls, searchField) + case 'datePicker': + return createDatePickerSegment(prefixCls, searchField, dateConfig) + case 'dateRangePicker': + return createDateRangePickerSegment(prefixCls, searchField, dateConfig) + case 'custom': + return createCustomSegment(prefixCls, searchField, slots) + default: + return + } +} diff --git a/packages/pro/search/src/composables/useSearchItem.ts b/packages/pro/search/src/composables/useSearchItem.ts index 820b79568..0f2167841 100644 --- a/packages/pro/search/src/composables/useSearchItem.ts +++ b/packages/pro/search/src/composables/useSearchItem.ts @@ -6,90 +6,26 @@ */ import type { SearchState } from './useSearchStates' -import type { ProSearchProps, SearchField, SearchItem, SearchItemError } from '../types' -import type { DateConfig } from '@idux/components/config' +import type { ResolvedSearchField, SearchItem, SearchItemError } from '../types' -import { type ComputedRef, type Slots, computed } from 'vue' - -import { createCascaderSegment } from '../segments/CreateCascaderSegment' -import { createDatePickerSegment } from '../segments/CreateDatePickerSegment' -import { createDateRangePickerSegment } from '../segments/CreateDateRangePickerSegment' -import { createNameSegment } from '../segments/CreateNameSegment' -import { createOperatorSegment } from '../segments/CreateOperatorSegment' -import { createSelectSegment } from '../segments/CreateSelectSegment' -import { createTreeSelectSegment } from '../segments/CreateTreeSelectSegment' -import { createCustomSegment } from '../segments/createCustomSegment' -import { createInputSegment } from '../segments/createInputSegment' +import { type ComputedRef, type Ref, computed } from 'vue' export function useSearchItems( - props: ProSearchProps, - slots: Slots, - mergedPrefixCls: ComputedRef, - searchStates: ComputedRef, + resolvedSearchFields: ComputedRef, + searchStates: Ref, searchItemErrors: ComputedRef, - dateConfig: DateConfig, ): ComputedRef { - const searchStatesKeys = computed(() => new Set(searchStates.value?.map(state => state.fieldKey))) - return computed(() => searchStates.value?.map(searchState => { - const searchFields = props.searchFields?.filter( - field => field.key === searchState.fieldKey || field.multiple || !searchStatesKeys.value.has(field.key), - ) - const searchField = searchFields?.find(field => field.key === searchState.fieldKey) - const operatorSegment = searchField && createOperatorSegment(mergedPrefixCls.value, searchField) - const nameSegment = createNameSegment( - mergedPrefixCls.value, - searchFields, - props.customNameLabel, - !operatorSegment, - ) + const searchField = resolvedSearchFields.value?.find(field => field.key === searchState.fieldKey) return { key: searchState.key, + name: searchField?.label, optionKey: searchState.fieldKey, error: searchItemErrors.value?.find(error => error.index === searchState.index), - searchField, - segments: searchState.segmentValues - .map(segmentValue => { - if (segmentValue.name === 'name') { - return nameSegment - } - - if (segmentValue.name === 'operator') { - return operatorSegment - } - - return searchField && createSearchItemContentSegment(mergedPrefixCls.value, searchField, slots, dateConfig) - }) - .filter(Boolean), + resolvedSearchField: searchField, } as SearchItem }), ) } - -function createSearchItemContentSegment( - prefixCls: string, - searchField: SearchField, - slots: Slots, - dateConfig: DateConfig, -) { - switch (searchField.type) { - case 'select': - return createSelectSegment(prefixCls, searchField) - case 'treeSelect': - return createTreeSelectSegment(prefixCls, searchField) - case 'cascader': - return createCascaderSegment(prefixCls, searchField) - case 'input': - return createInputSegment(prefixCls, searchField) - case 'datePicker': - return createDatePickerSegment(prefixCls, searchField, dateConfig) - case 'dateRangePicker': - return createDateRangePickerSegment(prefixCls, searchField, dateConfig) - case 'custom': - return createCustomSegment(prefixCls, searchField, slots) - default: - return - } -} diff --git a/packages/pro/search/src/composables/useSearchStates.ts b/packages/pro/search/src/composables/useSearchStates.ts index f67a159ef..b6d2e5373 100644 --- a/packages/pro/search/src/composables/useSearchStates.ts +++ b/packages/pro/search/src/composables/useSearchStates.ts @@ -8,7 +8,7 @@ import type { SearchValueContext } from './useSearchValues' import type { DateConfig } from '@idux/components/config' -import { type ComputedRef, computed, reactive, ref, toRaw } from 'vue' +import { type Ref, computed, ref, toRaw } from 'vue' import { isNil } from 'lodash-es' @@ -29,18 +29,18 @@ export interface SegmentValue { } export interface SearchState { key: VKey + name: string index?: number - fieldKey?: VKey + fieldKey: VKey segmentValues: SegmentValue[] } export interface SearchStateContext { - searchStates: ComputedRef - tempSearchState: SearchState - tempSearchStateAvailable: ComputedRef + searchStates: Ref initSearchStates: () => void - initTempSearchState: () => void + createSearchState: (fieldKey: VKey, searchValue?: Omit) => SearchState | undefined getSearchStateByKey: (key: VKey) => SearchState | undefined + getSearchStatesByFieldKey: (fieldKey: VKey) => SearchState[] validateSearchState: (key: VKey) => boolean | undefined convertStateToValue: (key: VKey) => SearchValue | undefined updateSegmentValue: (value: unknown, name: string, key: VKey) => void @@ -49,8 +49,6 @@ export interface SearchStateContext { clearSearchState: () => void } -export const tempSearchStateKey = Symbol('temp') - export function useSearchStates( props: ProSearchProps, dateConfig: DateConfig, @@ -58,7 +56,7 @@ export function useSearchStates( searchStateWatcherContext: SearchStateWatcherContext, ): SearchStateContext { const { searchValues, setSearchValues } = searchValueContext - const { compareSearchStates, compareSegmentValues, notifySearchStateChange } = searchStateWatcherContext + const { compareSearchStates, notifySearchStateChange } = searchStateWatcherContext const getKey = createStateKeyGetter() const searchStates = ref([]) @@ -66,43 +64,26 @@ export function useSearchStates( const countMap = new Map() searchStates.value.forEach(state => { - state.fieldKey && countMap.set(state.fieldKey, (countMap.get(state.fieldKey) ?? 0) + 1) + countMap.set(state.fieldKey, (countMap.get(state.fieldKey) ?? 0) + 1) }) return countMap }) - const tempSearchState: SearchState = reactive({ - key: tempSearchStateKey, - segmentValues: generateSegmentValues(), - }) - const tempSearchStateAvailable = computed(() => { - const searchFields = props.searchFields ?? [] - if (searchFields.findIndex(field => field.multiple) > -1) { - return true - } - - const selectedKeys = new Set(fieldKeyCountMap.value.keys()) - - return searchFields.some(field => !selectedKeys.has(field.key)) - }) - - const mergedSearchStates = computed(() => { - const states = [...searchStates.value] - if (tempSearchStateAvailable.value) { - states.push(tempSearchState) + const findSearchField = (fieldKey?: VKey) => { + if (isNil(fieldKey)) { + return } - return states - }) + return props.searchFields?.find(field => field.key === fieldKey) + } function getSearchStateByKey(key: VKey) { - if (key === tempSearchStateKey) { - return tempSearchState - } - return searchStates.value.find(value => value.key === key) } + function getSearchStatesByFieldKey(fieldKey: VKey) { + return searchStates.value.filter(value => value.fieldKey === fieldKey) + } function _convertStateToValue(state: SearchState) { const operatorSegment = state.segmentValues.find(value => value.name === 'operator') @@ -110,7 +91,7 @@ export function useSearchStates( return { key: state.fieldKey, - name: props.searchFields?.find(field => field.key === state.fieldKey)?.label, + name: findSearchField(state.fieldKey)?.label, operator: operatorSegment?.value as string, value: toRaw(contentSegment?.value), } as SearchValue @@ -118,19 +99,8 @@ export function useSearchStates( function setSegmentValue(searchState: SearchState, name: string, value: unknown) { let segmentValue = searchState.segmentValues.find(state => state.name === name) - - if (segmentValue?.name === 'name') { - searchState.fieldKey = value as VKey - const searchValue = searchValues.value?.find(value => value.key === searchState.key) - const searchFields = props.searchFields?.find(field => field.key === searchState.fieldKey) - const newSegmentsValues = generateSegmentValues(searchFields, searchValue, dateConfig) - - const updatedSegments = compareSegmentValues(searchState.segmentValues, newSegmentsValues) - searchState.segmentValues = newSegmentsValues - return updatedSegments - } - let oldValue: unknown + if (segmentValue) { oldValue = segmentValue.value segmentValue.value = value @@ -149,7 +119,7 @@ export function useSearchStates( ] } - function checkSearchStateValid(searchState: SearchState, dataKeyCountMap: Map, existed?: boolean) { + function checkSearchStateValid(searchState: SearchState, dataKeyCountMap: Map) { if (!searchState.fieldKey) { return false } @@ -164,7 +134,7 @@ export function useSearchStates( // if there are more than one searchState of the same field key // check whether mutiple searchState is allowed from the field config - if (count && count > (existed ? 1 : 0)) { + if (count && count > 1) { return !!props.searchFields?.find(field => field.key === searchState.fieldKey)?.multiple } @@ -174,7 +144,7 @@ export function useSearchStates( const validateSearchState = (key: VKey) => { const searchState = getSearchStateByKey(key) - return searchState && checkSearchStateValid(searchState, fieldKeyCountMap.value, key !== tempSearchStateKey) + return searchState && checkSearchStateValid(searchState, fieldKeyCountMap.value) } const convertStateToValue = (key: VKey) => { @@ -182,19 +152,6 @@ export function useSearchStates( return searchState ? _convertStateToValue(searchState) : undefined } - const initTempSearchState = () => { - tempSearchState.fieldKey = undefined - const newSegmentValues = generateSegmentValues() - const updatedSegments = compareSegmentValues(tempSearchState.segmentValues, newSegmentValues) - tempSearchState.segmentValues = newSegmentValues - - // notify temp searchState change - notifySearchStateChange(tempSearchStateKey, SEARCH_STATE_ACTION.UPDATED, { - searchState: tempSearchState, - updatedSegments, - }) - } - const initSearchStates = () => { const dataKeyCountMap = new Map() @@ -202,7 +159,7 @@ export function useSearchStates( searchStates.value = ( searchValues.value?.map((searchValue, index) => { const fieldKey = searchValue.key - const searchField = props.searchFields?.find(field => field.key === fieldKey) + const searchField = findSearchField(fieldKey) if (!searchField) { return } @@ -212,9 +169,9 @@ export function useSearchStates( const key = getKey(fieldKey, count) const searchState = { key, index, fieldKey, segmentValues } as SearchState - if (!checkSearchStateValid(searchState, dataKeyCountMap)) { - return - } + // if (!checkSearchStateValid(searchState, dataKeyCountMap)) { + // return + // } dataKeyCountMap.set(fieldKey, count + 1) return searchState @@ -224,6 +181,30 @@ export function useSearchStates( compareSearchStates(searchStates.value, oldSearchStates) } + const createSearchState = (fieldKey: VKey, searchValue?: Omit) => { + const searchField = findSearchField(fieldKey) + if (!searchField) { + return + } + + if (!searchField.multiple && (fieldKeyCountMap.value.get(searchField.key) ?? 0) > 1) { + return + } + + const newKey = getKey(fieldKey, fieldKeyCountMap.value.get(fieldKey) ?? 0) + const newSearchState: SearchState = { + key: newKey, + name: searchField.label, + fieldKey: fieldKey, + segmentValues: generateSegmentValues(searchField, searchValue, dateConfig), + } + searchStates.value.push(newSearchState) + + notifySearchStateChange(newKey, SEARCH_STATE_ACTION.CREATED, { searchState: newSearchState }) + + return newSearchState + } + function updateSearchValue() { const newSearchValues = searchStates.value .map(state => { @@ -265,20 +246,6 @@ export function useSearchStates( return } - if (searchState.key === tempSearchStateKey) { - // create new search value - const newKey = getKey(searchState.fieldKey!, fieldKeyCountMap.value.get(searchState.fieldKey!) ?? 0) - const newSearchState = { - ...searchState, - key: newKey, - } - searchStates.value.push(newSearchState) - - notifySearchStateChange(newKey, SEARCH_STATE_ACTION.CREATED, { searchState: newSearchState }) - - initTempSearchState() - } - updateSearchValue() } const removeSearchState = (key: VKey) => { @@ -313,12 +280,11 @@ export function useSearchStates( } return { - searchStates: mergedSearchStates, - tempSearchState, - tempSearchStateAvailable, + searchStates: searchStates, initSearchStates, - initTempSearchState, + createSearchState, getSearchStateByKey, + getSearchStatesByFieldKey, validateSearchState, convertStateToValue, updateSegmentValue, @@ -344,29 +310,22 @@ function createStateKeyGetter() { } function generateSegmentValues( - searchField?: SearchField, - searchValue?: SearchValue, + searchField: SearchField, + searchValue?: Omit, dateConfig?: DateConfig, ): SegmentValue[] { - const nameKey = searchField?.key - const hasOperators = searchField?.operators && searchField.operators.length > 0 + const hasOperators = searchField.operators && searchField.operators.length > 0 /* eslint-disable indent */ return [ - { - name: 'name', - value: nameKey, + hasOperators && { + name: 'operator', + value: searchValue?.operator ?? searchField.operators?.find(op => op === searchField.defaultOperator), + }, + dateConfig && { + name: searchField.type, + value: searchValue?.value ?? searchField.defaultValue, }, - searchField && - hasOperators && { - name: 'operator', - value: searchValue?.operator, - }, - searchField && - dateConfig && { - name: searchField.type, - value: searchValue?.value, - }, ].filter(Boolean) as SegmentValue[] /* eslint-enable indent */ } diff --git a/packages/pro/search/src/composables/useSegmentStates.ts b/packages/pro/search/src/composables/useSegmentStates.ts index d0ec83099..aff6442ac 100644 --- a/packages/pro/search/src/composables/useSegmentStates.ts +++ b/packages/pro/search/src/composables/useSegmentStates.ts @@ -9,19 +9,29 @@ import type { ProSearchContext } from '../token' import { type ComputedRef, type Ref, nextTick, onUnmounted, ref, watch } from 'vue' +import { isNil, isNumber } from 'lodash-es' + import { callEmit } from '@idux/cdk/utils' -import { type SegmentValue, tempSearchStateKey } from '../composables/useSearchStates' +import { type SegmentValue } from '../composables/useSearchStates' import { type ProSearchProps, type SearchItemProps, Segment, searchDataTypes } from '../types' -type SegmentStates = Record +interface SegmentState { + input: string + value: unknown + index: number + selectionStart: number +} +type SegmentStates = Record export interface SegmentStatesContext { segmentStates: Ref initSegmentStates: (force?: boolean) => void + changeActiveAndSelect: (index: number, selectionStart: number | 'start' | 'end') => void handleSegmentInput: (name: string, input: string) => void handleSegmentChange: (name: string, value: unknown) => void handleSegmentConfirm: (name: string, confirmItem?: boolean) => void handleSegmentCancel: (name: string) => void + handleSegmentSelect: (name: string, selectionStart: number | undefined | null) => void } export function useSegmentStates( @@ -37,21 +47,26 @@ export function useSegmentStates( updateSegmentValue, removeSearchState, convertStateToValue, - initTempSearchState, activeSegment, changeActive, setInactive, setTempActive, + setOverlayOpened, onSearchTrigger, watchSearchState, } = proSearchContext const segmentStates = ref({}) - const _genInitSegmentState = (segment: Segment, segmentValue: SegmentValue | undefined, index: number) => { + const _genInitSegmentState = ( + segment: Segment, + segmentValue: SegmentValue | undefined, + index: number, + ): SegmentState => { return { input: segment.format(segmentValue?.value) ?? '', value: segmentValue?.value, index: index, + selectionStart: 0, } } @@ -59,7 +74,7 @@ export function useSegmentStates( const initSegmentStates = () => { const searchState = getSearchStateByKey(props.searchItem!.key)! - segmentStates.value = props.searchItem!.segments.reduce((states, segment, index) => { + segmentStates.value = props.searchItem!.resolvedSearchField.segments.reduce((states, segment, index) => { const segmentValue = searchState?.segmentValues.find(value => value.name === segment.name) states[segment.name] = _genInitSegmentState(segment, segmentValue, index) @@ -75,11 +90,48 @@ export function useSegmentStates( } const searchState = getSearchStateByKey(props.searchItem!.key)! - const segment = props.searchItem!.segments[segmentState.index] + const segment = props.searchItem!.resolvedSearchField.segments[segmentState.index] const segmentValue = searchState?.segmentValues.find(value => value.name === segment.name) segmentStates.value[name] = _genInitSegmentState(segment, segmentValue, segmentState.index) } + const changeActiveAndSelect = (offset: number, selectionStart: number | 'start' | 'end') => { + const currentIndex = activeSegment.value?.name ? segmentStates.value[activeSegment.value.name].index : -1 + if (currentIndex < 0) { + return + } + + const length = props.searchItem!.resolvedSearchField.segments.length + + const _offset = + offset > 0 ? Math.min(offset, length - 1 - currentIndex) : Math.min(offset, -length + 1 + currentIndex) + if (_offset === 0) { + return + } + + changeActive(_offset, false) + + if (isNil(selectionStart)) { + return + } + + const targetSegmentName = props.searchItem!.resolvedSearchField.segments[currentIndex + _offset]?.name + const targetSegmentState = segmentStates.value[targetSegmentName] + if (!targetSegmentState) { + return + } + + /* eslint-disable indent */ + const _selectionStart = isNumber(selectionStart) + ? selectionStart + : selectionStart === 'start' + ? 0 + : targetSegmentState.input.length + /* eslint-enable indent */ + + targetSegmentState.selectionStart = _selectionStart + } + let searchStateWatchStop: () => void watch( () => props.searchItem, @@ -98,7 +150,7 @@ export function useSegmentStates( immediate: true, }, ) - watch([isActive, () => props.searchItem?.searchField], ([active, searchField]) => { + watch([isActive, () => props.searchItem?.resolvedSearchField], ([active, searchField]) => { if (!active || !searchField) { initSegmentStates() } @@ -113,7 +165,7 @@ export function useSegmentStates( return } - const segment = props.searchItem!.segments.find(seg => seg.name === name)! + const segment = props.searchItem!.resolvedSearchField.segments.find(seg => seg.name === name)! segmentStates.value[name].value = value segmentStates.value[name].input = segment.format(value) } @@ -122,17 +174,24 @@ export function useSegmentStates( return } - const segment = props.searchItem!.segments.find(seg => seg.name === name)! + const segment = props.searchItem!.resolvedSearchField.segments.find(seg => seg.name === name)! segmentStates.value[name].input = input segmentStates.value[name].value = segment.parse(input) } + const setSegmentSelectionStart = (name: string, selectionStart: number | undefined | null) => { + if (!segmentStates.value[name]) { + return + } + + segmentStates.value[name].selectionStart = selectionStart ?? 0 + } const confirmSearchItem = () => { const key = props.searchItem!.key const validateRes = validateSearchState(key) + const searchValue = convertStateToValue(key) if (!validateRes) { - initTempSearchState() removeSearchState(key) } else { updateSearchState(key) @@ -141,18 +200,15 @@ export function useSegmentStates( const valueName = searchDataTypes.find(name => !!segmentStates.value[name]) callEmit(proSearchProps.onItemConfirm, { - ...convertStateToValue(key), - nameInput: segmentStates.value.name?.input, + ...(searchValue ?? {}), + nameInput: searchValue?.name ?? '', operatorInput: segmentStates.value.operator?.input, valueInput: valueName && segmentStates.value[valueName]?.input, removed: !validateRes, }) - if (key !== tempSearchStateKey) { - setInactive() - } else { - setTempActive() - } + setTempActive() + setOverlayOpened(false) } const handleSegmentConfirm = (name: string, confirmItem?: boolean) => { @@ -165,11 +221,7 @@ export function useSegmentStates( // only confirm searchItem when the last segment is confirmed // if the last segment is searchItem name, confirm the searchItem only when no searchField is selected - if ( - index === props.searchItem!.segments.length - 1 && - confirmItem && - (name !== 'name' || !segmentStates.value[name].value) - ) { + if (index === props.searchItem!.resolvedSearchField.segments.length - 1 && confirmItem) { confirmSearchItem() } else { changeActive(1) @@ -181,7 +233,7 @@ export function useSegmentStates( if (!segmentStates.value[name].value) { changeActive(-1, true) - } else if (props.searchItem?.key !== tempSearchStateKey) { + } else { setInactive() } } @@ -201,8 +253,10 @@ export function useSegmentStates( return { segmentStates, initSegmentStates, + changeActiveAndSelect, handleSegmentInput: setSegmentInput, handleSegmentChange: setSegmentValue, + handleSegmentSelect: setSegmentSelectionStart, handleSegmentConfirm, handleSegmentCancel, } diff --git a/packages/pro/search/src/panel/CascaderPanel.tsx b/packages/pro/search/src/panel/CascaderPanel.tsx index 0bb88b0e0..ee684e45f 100644 --- a/packages/pro/search/src/panel/CascaderPanel.tsx +++ b/packages/pro/search/src/panel/CascaderPanel.tsx @@ -43,7 +43,7 @@ export default defineComponent({ } const renderFooter = () => { - if (!props.multiple) { + if (!props.multiple || !props.showFooter) { return } diff --git a/packages/pro/search/src/panel/DatePickerPanel.tsx b/packages/pro/search/src/panel/DatePickerPanel.tsx index 0cacbcbd1..6fe5c0ff6 100644 --- a/packages/pro/search/src/panel/DatePickerPanel.tsx +++ b/packages/pro/search/src/panel/DatePickerPanel.tsx @@ -54,6 +54,16 @@ export default defineComponent({ onChange: handleChange, } + const renderSwitchPanelBtn = () => ( + + {visiblePanel.value === 'datePanel' ? locale.switchToTimePanel : locale.switchToDatePanel} + + ) + const panelFooterSlots = { + prepend: () => props.type === 'datetime' && renderSwitchPanelBtn(), + default: props.type === 'datetime' && !props.showFooter ? renderSwitchPanelBtn : null, + } + return (
evt.preventDefault()}>
@@ -63,20 +73,17 @@ export default defineComponent({ )}
-
- - {props.type === 'datetime' && ( - - {visiblePanel.value === 'datePanel' ? locale.switchToTimePanel : locale.switchToDatePanel} - - )} - -
+ {(props.type === 'datetime' || props.showFooter) && ( +
+ +
+ )}
) } diff --git a/packages/pro/search/src/panel/PanelFooter.tsx b/packages/pro/search/src/panel/PanelFooter.tsx index 9b321287d..b4a57d55c 100644 --- a/packages/pro/search/src/panel/PanelFooter.tsx +++ b/packages/pro/search/src/panel/PanelFooter.tsx @@ -16,13 +16,15 @@ const PanelFooter: FunctionalComponent = (props, { sl return (
- {slots.default?.()} - - {locale!.ok} - - - {locale!.cancel} - + {slots.default?.() ?? [ + slots.prepend?.(), + + {locale!.ok} + , + + {locale!.cancel} + , + ]}
) } diff --git a/packages/pro/search/src/panel/SelectPanel.tsx b/packages/pro/search/src/panel/SelectPanel.tsx index b3af19c8b..8138d1994 100644 --- a/packages/pro/search/src/panel/SelectPanel.tsx +++ b/packages/pro/search/src/panel/SelectPanel.tsx @@ -11,6 +11,7 @@ import { computed, defineComponent, inject, + normalizeClass, onBeforeUnmount, onMounted, onUnmounted, @@ -59,6 +60,12 @@ export default defineComponent({ () => props.searchValue, searchValue => { callEmit(props.onSearch, searchValue ?? '') + if (!activeValue.value) { + setActiveValue(filteredDataSource.value?.[0]?.key) + } + }, + { + flush: 'post', }, ) onUnmounted(() => { @@ -100,6 +107,9 @@ export default defineComponent({ const handleSelectAllClick = () => { callEmit(props.onSelectAllClick) } + const handleMouseLeave = () => { + setActiveValue(undefined) + } const handleKeyDown = useOnKeyDown(props, panelRef, activeValue, changeSelected, handleConfirm) @@ -130,7 +140,7 @@ export default defineComponent({ ) } const renderFooter = () => { - if (!props.multiple) { + if (!props.multiple || !props.showFooter) { return } @@ -146,6 +156,10 @@ export default defineComponent({ return () => { const prefixCls = `${mergedPrefixCls.value}-select-panel` + const classes = normalizeClass({ + [prefixCls]: true, + [`${prefixCls}-auto-height`]: !!props.autoHeight, + }) const panelProps = { activeValue: activeValue.value, dataSource: filteredDataSource.value, @@ -154,11 +168,13 @@ export default defineComponent({ labelKey: 'label', selectedKeys: props.value, virtual: props.virtual, + _virtualScrollHeight: props.autoHeight ? '100%' : 256, onOptionClick: handleOptionClick, 'onUpdate:activeValue': setActiveValue, } + return ( -
evt.preventDefault()}> +
evt.preventDefault()} onMouseleave={handleMouseLeave}> {renderSelectAll()} {renderFooter()} diff --git a/packages/pro/search/src/panel/TreeSelectPanel.tsx b/packages/pro/search/src/panel/TreeSelectPanel.tsx index b37cb4136..d03d6d861 100644 --- a/packages/pro/search/src/panel/TreeSelectPanel.tsx +++ b/packages/pro/search/src/panel/TreeSelectPanel.tsx @@ -5,7 +5,7 @@ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE */ -import { type ComputedRef, computed, defineComponent, inject, onUnmounted, watch } from 'vue' +import { type ComputedRef, computed, defineComponent, inject, normalizeClass, onUnmounted, watch } from 'vue' import { isFunction } from 'lodash-es' @@ -77,7 +77,7 @@ export default defineComponent({ } const renderFooter = () => { - if (!props.multiple) { + if (!props.multiple || !props.showFooter) { return } @@ -114,6 +114,7 @@ export default defineComponent({ } = props const treeProps = { + autoHeight: props.autoHeight, blocked: true, checkOnClick: true, checkedKeys: props.value, @@ -128,7 +129,7 @@ export default defineComponent({ expandedKeys: expandedKeys.value, expandIcon: expandIcon, getKey: 'key', - height: 256, + height: props.autoHeight ? undefined : 256, loadChildren, leafLineIcon, virtual, @@ -153,9 +154,13 @@ export default defineComponent({ } as TreeProps const prefixCls = `${mergedPrefixCls.value}-tree-select-panel` + const classes = normalizeClass({ + [prefixCls]: true, + [`${prefixCls}-auto-height`]: !!props.autoHeight, + }) return ( -
evt.preventDefault()}> +
evt.preventDefault()}> {renderFooter()}
diff --git a/packages/pro/search/src/searchItem/SearchItem.tsx b/packages/pro/search/src/searchItem/SearchItem.tsx deleted file mode 100644 index df8ad91d6..000000000 --- a/packages/pro/search/src/searchItem/SearchItem.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/** - * @license - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE - */ - -import { computed, defineComponent, inject, provide } from 'vue' - -import SearchItemTag from './SearchItemTag' -import Segment from './Segment' -import { tempSearchStateKey } from '../composables/useSearchStates' -import { useSegmentOverlayUpdate } from '../composables/useSegmentOverlayUpdate' -import { useSegmentStates } from '../composables/useSegmentStates' -import { proSearchContext, searchItemContext } from '../token' -import { searchItemProps } from '../types' - -export default defineComponent({ - props: searchItemProps, - setup(props, { slots }) { - const context = inject(proSearchContext)! - const { props: proSearchProps, mergedPrefixCls, activeSegment } = context - - const isActive = computed(() => activeSegment.value?.itemKey === props.searchItem!.key) - const itemVisible = computed(() => isActive.value && !proSearchProps.disabled) - - const segmentStateContext = useSegmentStates(props, proSearchProps, context, isActive) - const segmentOverlayUpdateContext = useSegmentOverlayUpdate() - const { segmentStates } = segmentStateContext - - provide(searchItemContext, { - ...segmentStateContext, - ...segmentOverlayUpdateContext, - }) - - const segmentRenderDatas = computed(() => { - const searchItem = props.searchItem! - - return searchItem.segments.map(segment => { - const segmentState = segmentStates.value[segment.name] - return { - ...segment, - input: segmentState?.input, - value: segmentState?.value, - } - }) - }) - - return () => ( - <> - {!itemVisible.value && props.searchItem?.key !== tempSearchStateKey && ( - - )} - evt.preventDefault()} - > - {segmentRenderDatas.value.map(segment => ( - - ))} - - - ) - }, -}) diff --git a/packages/pro/search/src/searchItem/SearchItemTag.tsx b/packages/pro/search/src/searchItem/SearchItemTag.tsx deleted file mode 100644 index b68ef7a4c..000000000 --- a/packages/pro/search/src/searchItem/SearchItemTag.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/** - * @license - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE - */ - -import { computed, defineComponent, inject, normalizeClass } from 'vue' - -import { IxTooltip } from '@idux/components/tooltip' - -import { proSearchContext } from '../token' -import { searchItemTagProps } from '../types' -import { renderIcon } from '../utils/RenderIcon' -export default defineComponent({ - props: searchItemTagProps, - setup(props) { - const context = inject(proSearchContext)! - const { props: proSearchProps, mergedPrefixCls, changeActive, setActiveSegment, removeSearchState } = context - - const prefixCls = computed(() => `${mergedPrefixCls.value}-search-item-tag`) - const classes = computed(() => { - return normalizeClass({ - [prefixCls.value]: true, - [`${prefixCls.value}-invalid`]: !!props?.error, - }) - }) - - const setSegmentActive = (name: string) => { - setActiveSegment({ - itemKey: props.itemKey!, - name, - overlayOpened: true, - }) - } - const handleCloseIconClick = (evt: Event) => { - evt.stopPropagation() - removeSearchState(props.itemKey!) - } - - const handleTagSegmentMouseDown = (name: string) => { - if (proSearchProps.disabled) { - return - } - - setSegmentActive(name) - - if (name === 'name') { - changeActive(1) - } - } - - const renderTag = () => { - const content = props.segments!.map(data => data.input).join(' ') - - return [ - - {props.segments!.map(segmeng => ( - handleTagSegmentMouseDown(segmeng.name)}> - {segmeng.input} - - ))} - , - - {content} - , - ] - } - - return () => ( - - evt.preventDefault()}> - {renderTag()} - {!proSearchProps.disabled && ( - - {renderIcon('close')} - - )} - - - ) - }, -}) diff --git a/packages/pro/search/src/segments/CreateCascaderSegment.tsx b/packages/pro/search/src/segments/CreateCascaderSegment.tsx index 7ea4a9ce8..07504eed4 100644 --- a/packages/pro/search/src/segments/CreateCascaderSegment.tsx +++ b/packages/pro/search/src/segments/CreateCascaderSegment.tsx @@ -43,8 +43,8 @@ export function createCascaderSegment( onSearch, onLoaded, }, - defaultValue, inputClassName, + containerClassName, onPanelVisibleChange, } = searchField @@ -73,11 +73,12 @@ export function createCascaderSegment( ) const panelRenderer = (context: PanelRenderContext) => { - const { ok, cancel } = context + const { ok, cancel, renderLocation } = context const { panelValue, searchInput, handleChange } = getSelectableCommonParams( context, !!multiple, - separator ?? defaultSeparator, + renderLocation === 'individual' ? separator ?? defaultSeparator : undefined, + !multiple || renderLocation === 'quick-select-panel', ) return ( @@ -90,6 +91,7 @@ export function createCascaderSegment( fullPath={fullPath ?? defaultFullPath} strategy={mergedCascaderStrategy} separator={pathSeparator} + showFooter={renderLocation === 'individual'} multiple={multiple} virtual={virtual} searchFn={searchFn} @@ -106,8 +108,8 @@ export function createCascaderSegment( return { name: searchField.type, inputClassName: [inputClassName, `${prefixCls}-cascader-segment-input`], + containerClassName: [containerClassName, `${prefixCls}-cascader-segment-container`], placeholder: searchField.placeholder, - defaultValue, parse: input => parseInput(input, searchField, nodeLabelMap, checkedKeysResolver, parentKeyMap), format: value => formatValue(value, searchField, nodeKeyMap), panelRenderer, diff --git a/packages/pro/search/src/segments/CreateDatePickerSegment.tsx b/packages/pro/search/src/segments/CreateDatePickerSegment.tsx index 065a717fb..0c07ab92a 100644 --- a/packages/pro/search/src/segments/CreateDatePickerSegment.tsx +++ b/packages/pro/search/src/segments/CreateDatePickerSegment.tsx @@ -27,13 +27,20 @@ export function createDatePickerSegment( ): Segment { const { fieldConfig: { type, cellTooltip, disabledDate, timePanelOptions }, - defaultValue, inputClassName, + containerClassName, onPanelVisibleChange, } = searchField const panelRenderer = (context: PanelRenderContext) => { - const { value, setValue, cancel, ok } = context + const { value, renderLocation, setValue, cancel, ok } = context + + const onChange = (value: Date | undefined) => { + setValue(value) + if (renderLocation === 'quick-select-panel') { + ok() + } + } return ( void) | undefined} + onChange={onChange as ((value: Date | Date[] | undefined) => void) | undefined} onConfirm={ok} onCancel={cancel} /> @@ -53,8 +61,8 @@ export function createDatePickerSegment( return { name: searchField.type, inputClassName: [inputClassName, `${prefixCls}-date-picker-segment-input`], + containerClassName: [containerClassName, `${prefixCls}-date-picker-segment-container`], placeholder: searchField.placeholder, - defaultValue, parse: input => parseInput(input, dateConfig, searchField), format: value => formatValue(value, dateConfig, searchField), panelRenderer, diff --git a/packages/pro/search/src/segments/CreateDateRangePickerSegment.tsx b/packages/pro/search/src/segments/CreateDateRangePickerSegment.tsx index 32fe79917..e519dc33b 100644 --- a/packages/pro/search/src/segments/CreateDateRangePickerSegment.tsx +++ b/packages/pro/search/src/segments/CreateDateRangePickerSegment.tsx @@ -28,8 +28,8 @@ export function createDateRangePickerSegment( ): Segment<(Date | undefined)[] | undefined> { const { fieldConfig: { type, cellTooltip, disabledDate, timePanelOptions }, - defaultValue, inputClassName, + containerClassName, onPanelVisibleChange, } = searchField @@ -43,6 +43,7 @@ export function createDateRangePickerSegment( cellTooltip={cellTooltip} disabledDate={disabledDate} type={type ?? defaultType} + showFooter={true} timePanelOptions={timePanelOptions} onChange={setValue as ((value: Date | Date[] | undefined) => void) | undefined} onConfirm={ok} @@ -54,8 +55,8 @@ export function createDateRangePickerSegment( return { name: searchField.type, inputClassName: [inputClassName, `${prefixCls}-date-range-picker-segment-input`], + containerClassName: [containerClassName, `${prefixCls}-date-range-picker-segment-container`], placeholder: searchField.placeholder, - defaultValue, parse: input => parseInput(input, dateConfig, searchField), format: value => formatValue(value, dateConfig, searchField), panelRenderer, diff --git a/packages/pro/search/src/segments/CreateOperatorSegment.tsx b/packages/pro/search/src/segments/CreateOperatorSegment.tsx index 3fc624a0e..cc83a176c 100644 --- a/packages/pro/search/src/segments/CreateOperatorSegment.tsx +++ b/packages/pro/search/src/segments/CreateOperatorSegment.tsx @@ -17,7 +17,7 @@ export function createOperatorSegment( prefixCls: string, searchField: SearchField, ): Segment | undefined { - const { operators, defaultOperator, customOperatorLabel } = searchField + const { operators, operatorPlaceholder, customOperatorLabel } = searchField if (!operators || operators.length <= 0) { return } @@ -54,7 +54,7 @@ export function createOperatorSegment( return { name: 'operator', inputClassName: `${prefixCls}-operator-segment-input`, - defaultValue: operators.find(op => op === defaultOperator), + placeholder: operatorPlaceholder, parse: input => parseInput(input, searchField), format: value => value ?? '', panelRenderer, diff --git a/packages/pro/search/src/segments/CreateSelectSegment.tsx b/packages/pro/search/src/segments/CreateSelectSegment.tsx index d5fc4c280..9de9e051f 100644 --- a/packages/pro/search/src/segments/CreateSelectSegment.tsx +++ b/packages/pro/search/src/segments/CreateSelectSegment.tsx @@ -22,18 +22,19 @@ export function createSelectSegment( ): Segment { const { fieldConfig: { dataSource, separator, searchable, showSelectAll, searchFn, multiple, virtual, onSearch }, - defaultValue, inputClassName, + containerClassName, onPanelVisibleChange, } = searchField const panelRenderer = (context: PanelRenderContext) => { - const { setValue, ok, cancel, setOnKeyDown } = context + const { setValue, ok, cancel, setOnKeyDown, renderLocation } = context const keys = getSelectDataSourceKeys(dataSource) const { panelValue, searchInput, handleChange } = getSelectableCommonParams( context, !!multiple, - separator ?? defaultSeparator, + renderLocation === 'individual' ? separator ?? defaultSeparator : undefined, + !multiple || renderLocation === 'quick-select-panel', ) const handleSelectAll = () => { @@ -48,8 +49,10 @@ export function createSelectSegment( dataSource={dataSource} multiple={multiple} virtual={virtual} + autoHeight={renderLocation === 'quick-select-panel'} setOnKeyDown={setOnKeyDown} - showSelectAll={showSelectAll} + showSelectAll={renderLocation === 'individual' && showSelectAll} + showFooter={renderLocation === 'individual'} searchValue={searchable ? searchInput : ''} searchFn={searchFn} onChange={handleChange} @@ -64,8 +67,8 @@ export function createSelectSegment( return { name: searchField.type, inputClassName: [inputClassName, `${prefixCls}-select-segment-input`], + containerClassName: [containerClassName, `${prefixCls}-select-segment-container`], placeholder: searchField.placeholder, - defaultValue, parse: input => parseInput(input, searchField), format: value => formatValue(value, searchField), panelRenderer, diff --git a/packages/pro/search/src/segments/CreateTreeSelectSegment.tsx b/packages/pro/search/src/segments/CreateTreeSelectSegment.tsx index a7c6ccf7a..7f6c072d3 100644 --- a/packages/pro/search/src/segments/CreateTreeSelectSegment.tsx +++ b/packages/pro/search/src/segments/CreateTreeSelectSegment.tsx @@ -45,8 +45,8 @@ export function createTreeSelectSegment( onSearch, onLoaded, }, - defaultValue, inputClassName, + containerClassName, onPanelVisibleChange, } = searchField @@ -58,17 +58,19 @@ export function createTreeSelectSegment( }) const panelRenderer = (context: PanelRenderContext) => { - const { ok, cancel } = context + const { ok, cancel, renderLocation } = context const { panelValue, searchInput, handleChange } = getSelectableCommonParams( context, !!multiple, - separator ?? defaultSeparator, + renderLocation === 'individual' ? separator ?? defaultSeparator : undefined, + !multiple || renderLocation === 'quick-select-panel', ) return ( parseInput(input, searchField, nodeLabelMap), format: value => formatValue(value, searchField, nodeKeyMap), panelRenderer, diff --git a/packages/pro/search/src/segments/createCustomSegment.ts b/packages/pro/search/src/segments/createCustomSegment.ts index fa9d525e4..f652c39ba 100644 --- a/packages/pro/search/src/segments/createCustomSegment.ts +++ b/packages/pro/search/src/segments/createCustomSegment.ts @@ -13,8 +13,8 @@ import { isFunction, isString } from 'lodash-es' export function createCustomSegment(prefixCls: string, searchField: CustomSearchField, slots: Slots): Segment { const { fieldConfig: { parse, format, customPanel }, - defaultValue, inputClassName, + containerClassName, onPanelVisibleChange, } = searchField const panelRenderer = getPanelRenderer(customPanel, slots) @@ -23,8 +23,8 @@ export function createCustomSegment(prefixCls: string, searchField: CustomSearch return { name: 'custom', inputClassName: [inputClassName, `${prefixCls}-custom-segment-input`], + containerClassName: [containerClassName, `${prefixCls}-custom-segment-container`], placeholder: searchField.placeholder, - defaultValue, parse, format, panelRenderer, diff --git a/packages/pro/search/src/segments/createInputSegment.ts b/packages/pro/search/src/segments/createInputSegment.ts index 8a3a1a9c9..4572a5425 100644 --- a/packages/pro/search/src/segments/createInputSegment.ts +++ b/packages/pro/search/src/segments/createInputSegment.ts @@ -10,7 +10,6 @@ import type { InputSearchField, Segment } from '../types' export function createInputSegment(prefixCls: string, searchField: InputSearchField): Segment { const { fieldConfig: { trim }, - defaultValue, inputClassName, } = searchField @@ -18,7 +17,6 @@ export function createInputSegment(prefixCls: string, searchField: InputSearchFi name: 'input', inputClassName: [inputClassName, `${prefixCls}-input-segment-input`], placeholder: searchField.placeholder, - defaultValue, parse: input => (input ? input : undefined), format: value => (trim ? value?.trim() : value) ?? '', } diff --git a/packages/pro/search/src/token.ts b/packages/pro/search/src/token.ts index 0deb6b7ad..ee8e109ee 100644 --- a/packages/pro/search/src/token.ts +++ b/packages/pro/search/src/token.ts @@ -6,25 +6,31 @@ */ import type { ActiveSegmentContext } from './composables/useActiveSegment' +import type { FocusStateContext } from './composables/useFocusedState' import type { SearchStateWatcherContext } from './composables/useSearchStateWatcher' import type { SearchStateContext } from './composables/useSearchStates' import type { SearchTriggerContext } from './composables/useSearchTrigger' import type { SegmentOverlayUpdateContext } from './composables/useSegmentOverlayUpdate' import type { SegmentStatesContext } from './composables/useSegmentStates' -import type { ProSearchProps } from './types' +import type { ProSearchProps, ResolvedSearchField } from './types' import type { ɵOverlayProps } from '@idux/components/_private/overlay' import type { ProSearchLocale } from '@idux/pro/locales' -import type { ComputedRef, InjectionKey } from 'vue' +import type { ComputedRef, InjectionKey, Ref } from 'vue' export interface ProSearchContext - extends SearchStateContext, + extends FocusStateContext, + SearchStateContext, SearchStateWatcherContext, ActiveSegmentContext, SearchTriggerContext { + elementRef: Ref + tempSegmentInputRef: Ref props: ProSearchProps locale: ProSearchLocale mergedPrefixCls: ComputedRef + enableQuickSelect: ComputedRef commonOverlayProps: ComputedRef<ɵOverlayProps> + resolvedSearchFields: ComputedRef focused: ComputedRef } diff --git a/packages/pro/search/src/types/index.ts b/packages/pro/search/src/types/index.ts index 3550b988d..f5543bf23 100644 --- a/packages/pro/search/src/types/index.ts +++ b/packages/pro/search/src/types/index.ts @@ -11,3 +11,6 @@ export * from './searchValue' export * from './searchFields' export * from './panels' export * from './proSearch' +export * from './measureElement' +export * from './quickSelectPanel' +export * from './overlay' diff --git a/packages/pro/search/src/types/measureElement.ts b/packages/pro/search/src/types/measureElement.ts new file mode 100644 index 000000000..3cf50ff12 --- /dev/null +++ b/packages/pro/search/src/types/measureElement.ts @@ -0,0 +1,13 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { MaybeArray } from '@idux/cdk/utils' +import type { PropType } from 'vue' + +export const measureElementProps = { + onWidthChange: [Function, Array] as PropType void>>, +} as const diff --git a/packages/pro/search/src/types/overlay.ts b/packages/pro/search/src/types/overlay.ts new file mode 100644 index 000000000..21fe6bdda --- /dev/null +++ b/packages/pro/search/src/types/overlay.ts @@ -0,0 +1,23 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { ExtractInnerPropTypes, VKey } from '@idux/cdk/utils' +import type { DefineComponent, PropType } from 'vue' + +export const nameSelectOverlayProps = { + selectedFieldKey: [String, Number, Symbol] as PropType, + searchValue: { type: String, required: true }, + setOnKeyDown: { + type: Function as PropType<(keydown: ((evt: KeyboardEvent) => boolean) | undefined) => void>, + required: true, + }, + onChange: Function as PropType<(value: VKey | undefined) => void>, +} as const + +export type NameSelectOverlayProps = ExtractInnerPropTypes +export type NameSelectOverlayComponent = DefineComponent void }> +export type NameSelectOverlayInstance = InstanceType diff --git a/packages/pro/search/src/types/panels.ts b/packages/pro/search/src/types/panels.ts index 9e5155899..3ce4364b4 100644 --- a/packages/pro/search/src/types/panels.ts +++ b/packages/pro/search/src/types/panels.ts @@ -31,6 +31,8 @@ export const proSearchSelectPanelProps = { dataSource: { type: Array as PropType, default: undefined }, multiple: { type: Boolean, default: false }, showSelectAll: { type: Boolean, default: true }, + showFooter: { type: Boolean, default: true }, + autoHeight: { type: Boolean, default: false }, allSelected: Boolean, searchValue: { type: String, default: undefined }, searchFn: Function as PropType<(data: SelectPanelData, searchValue?: string) => boolean>, @@ -50,6 +52,7 @@ export const proSearchTreeSelectPanelProps = { dataSource: { type: Array as PropType, default: undefined }, multiple: { type: Boolean, default: false }, checkable: { type: Boolean, default: false }, + autoHeight: { type: Boolean, default: false }, expandedKeys: { type: Array as PropType, default: undefined }, cascaderStrategy: { type: String as PropType, default: 'off' }, draggable: { type: Boolean, default: false }, @@ -64,6 +67,7 @@ export const proSearchTreeSelectPanelProps = { leafLineIcon: { type: String, default: undefined }, showLine: { type: Boolean, default: undefined }, searchValue: { type: String, default: undefined }, + showFooter: { type: Boolean, default: true }, searchFn: Function as PropType<(node: TreeSelectPanelData, searchValue?: string) => boolean>, virtual: { type: Boolean, default: false }, @@ -105,6 +109,7 @@ export const proSearchCascaderPanelProps = { }, searchValue: String, separator: { type: String, default: '/' }, + showFooter: { type: Boolean, default: true }, strategy: { type: String as PropType, default: 'all' }, virtual: { type: Boolean, default: false }, @@ -126,6 +131,7 @@ export const proSearchDatePanelProps = { cellTooltip: Function as PropType<(cell: { value: Date; disabled: boolean }) => string | void>, disabledDate: Function as PropType<(date: Date) => boolean>, defaultOpenValue: [Date, Array] as PropType, + showFooter: { type: Boolean, default: true }, type: { type: String as PropType, default: 'date', diff --git a/packages/pro/search/src/types/proSearch.ts b/packages/pro/search/src/types/proSearch.ts index 04c5b9646..54904c78f 100644 --- a/packages/pro/search/src/types/proSearch.ts +++ b/packages/pro/search/src/types/proSearch.ts @@ -6,7 +6,7 @@ */ import type { SearchField } from './searchFields' -import type { SearchItemConfirmContext, SearchItemError } from './searchItem' +import type { SearchItemConfirmContext, SearchItemCreateContext, SearchItemError } from './searchItem' import type { SearchValue } from './searchValue' import type { ExtractInnerPropTypes, ExtractPublicPropTypes, MaybeArray } from '@idux/cdk/utils' import type { OverlayContainerType } from '@idux/components/utils' @@ -47,6 +47,7 @@ export const proSearchProps = { onItemRemove: [Array, Function] as PropType void>>, onSearch: [Array, Function] as PropType void>>, onItemConfirm: [Array, Function] as PropType void>>, + onItemCreate: [Array, Function] as PropType void>>, } as const export interface ProSearchBindings { diff --git a/packages/pro/search/src/types/quickSelectPanel.ts b/packages/pro/search/src/types/quickSelectPanel.ts new file mode 100644 index 000000000..60f1ef94c --- /dev/null +++ b/packages/pro/search/src/types/quickSelectPanel.ts @@ -0,0 +1,17 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { ResolvedSearchField } from './searchFields' +import type { PropType } from 'vue' + +export const quickSelectPanelItemProps = { + searchField: { type: Object as PropType, required: true }, +} as const + +export const quickSelectPanelShortcutProps = { + searchField: { type: Object as PropType, required: true }, +} as const diff --git a/packages/pro/search/src/types/searchFields.ts b/packages/pro/search/src/types/searchFields.ts index 8a823c3ac..3ee12e805 100644 --- a/packages/pro/search/src/types/searchFields.ts +++ b/packages/pro/search/src/types/searchFields.ts @@ -10,7 +10,7 @@ import type { CascaderPanelData, SelectPanelData, TreeSelectPanelData } from './panels' import type { SearchItemError } from './searchItem' import type { SearchValue } from './searchValue' -import type { InputFormater, InputParser, PanelRenderContext } from './segment' +import type { InputFormater, InputParser, PanelRenderContext, Segment } from './segment' import type { MaybeArray, VKey } from '@idux/cdk/utils' import type { CascaderExpandTrigger, CascaderStrategy } from '@idux/components/cascader' import type { DatePanelProps, DateRangePanelProps } from '@idux/components/date-picker' @@ -20,18 +20,27 @@ import type { VNodeChild } from 'vue' interface SearchFieldBase { key: VKey label: string + icon?: string multiple?: boolean operators?: string[] + quickSelect?: boolean + quickSelectSearchable?: boolean defaultOperator?: string defaultValue?: V inputClassName?: string + containerClassName?: string placeholder?: string + operatorPlaceholder?: string customOperatorLabel?: string | ((operator: string) => VNodeChild) validator?: (value: SearchValue) => Omit | undefined onPanelVisibleChange?: (visible: boolean) => void } -export interface SelectSearchField extends SearchFieldBase { +interface ResolvedSearchFieldBase extends SearchFieldBase { + segments: Segment[] +} + +interface SelectSearchFieldBase { type: 'select' fieldConfig: { dataSource: SelectPanelData[] @@ -44,8 +53,10 @@ export interface SelectSearchField extends SearchFieldBase { onSearch?: MaybeArray<(searchValue: string) => void> } } +export type SelectSearchField = SearchFieldBase & SelectSearchFieldBase +export type ResolvedSelectSearchField = ResolvedSearchFieldBase & SelectSearchFieldBase -export interface TreeSelectSearchField extends SearchFieldBase { +interface TreeSelectSearchFieldBase { type: 'treeSelect' fieldConfig: { dataSource: TreeSelectPanelData[] @@ -73,8 +84,10 @@ export interface TreeSelectSearchField extends SearchFieldBase { onLoaded?: MaybeArray<(loadedKeys: any[], node: TreeSelectPanelData) => void> } } +export type TreeSelectSearchField = SearchFieldBase & TreeSelectSearchFieldBase +export type ResolvedTreeSelectSearchField = ResolvedSearchFieldBase & TreeSelectSearchFieldBase -export interface CascaderSearchField extends SearchFieldBase { +interface CascaderSearchFieldBase { type: 'cascader' fieldConfig: { dataSource: CascaderPanelData[] @@ -94,15 +107,19 @@ export interface CascaderSearchField extends SearchFieldBase void> } } +export type CascaderSearchField = SearchFieldBase & CascaderSearchFieldBase +export type ResolvedCascaderSearchField = ResolvedSearchFieldBase & CascaderSearchFieldBase -export interface InputSearchField extends SearchFieldBase { +interface InputSearchFieldBase { type: 'input' fieldConfig: { trim?: boolean } } +export type InputSearchField = SearchFieldBase & InputSearchFieldBase +export type ResolvedInputSearchField = ResolvedSearchFieldBase & InputSearchFieldBase -export interface DatePickerSearchField extends SearchFieldBase { +interface DatePickerSearchFieldBase { type: 'datePicker' fieldConfig: { format?: string @@ -112,8 +129,10 @@ export interface DatePickerSearchField extends SearchFieldBase { timePanelOptions?: DatePanelProps['timePanelOptions'] } } +export type DatePickerSearchField = SearchFieldBase & DatePickerSearchFieldBase +export type ResolvedDatePickerSearchField = ResolvedSearchFieldBase & DatePickerSearchFieldBase -export interface DateRangePickerSearchField extends SearchFieldBase { +interface DateRangePickerSearchFieldBase { type: 'dateRangePicker' fieldConfig: { format?: string @@ -124,8 +143,10 @@ export interface DateRangePickerSearchField extends SearchFieldBase { timePanelOptions?: DateRangePanelProps['timePanelOptions'] } } +export type DateRangePickerSearchField = SearchFieldBase & DateRangePickerSearchFieldBase +export type ResolvedDateRangePickerSearchField = ResolvedSearchFieldBase & DateRangePickerSearchFieldBase -export interface CustomSearchField extends SearchFieldBase { +interface CustomSearchFieldBase { type: 'custom' fieldConfig: { customPanel?: string | ((context: PanelRenderContext) => VNodeChild) @@ -133,6 +154,8 @@ export interface CustomSearchField extends SearchFieldBase { parse: InputParser } } +export type CustomSearchField = SearchFieldBase & CustomSearchFieldBase +export type ResolvedCustomSearchField = ResolvedSearchFieldBase & CustomSearchFieldBase export type SearchField = | SelectSearchField @@ -143,6 +166,15 @@ export type SearchField = | DateRangePickerSearchField | CustomSearchField +export type ResolvedSearchField = + | ResolvedSelectSearchField + | ResolvedTreeSelectSearchField + | ResolvedCascaderSearchField + | ResolvedInputSearchField + | ResolvedDatePickerSearchField + | ResolvedDateRangePickerSearchField + | ResolvedCustomSearchField + export const searchDataTypes = [ 'select', 'treeSelect', diff --git a/packages/pro/search/src/types/searchItem.ts b/packages/pro/search/src/types/searchItem.ts index f91d51086..77bded9f2 100644 --- a/packages/pro/search/src/types/searchItem.ts +++ b/packages/pro/search/src/types/searchItem.ts @@ -5,9 +5,8 @@ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE */ -import type { SearchField } from './searchFields' +import type { ResolvedSearchField } from './searchFields' import type { SearchValue } from './searchValue' -import type { Segment } from './segment' import type { ExtractInnerPropTypes, VKey } from '@idux/cdk/utils' import type { PropType } from 'vue' @@ -16,6 +15,10 @@ export interface SearchItemError { message?: string } +export interface SearchItemCreateContext extends Partial> { + nameInput?: string +} + export interface SearchItemConfirmContext extends Partial> { nameInput?: string operatorInput?: string @@ -25,10 +28,10 @@ export interface SearchItemConfirmContext extends Partial, required: true, }, - error: Object as PropType, } export type SearchItemProps = ExtractInnerPropTypes diff --git a/packages/pro/search/src/types/segment.ts b/packages/pro/search/src/types/segment.ts index 8161a42e6..28b39ed51 100644 --- a/packages/pro/search/src/types/segment.ts +++ b/packages/pro/search/src/types/segment.ts @@ -5,16 +5,19 @@ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE */ -import type { ExtractInnerPropTypes, VKey } from '@idux/cdk/utils' -import type { PropType, Slots, VNodeChild } from 'vue' +import type { ExtractInnerPropTypes, MaybeArray, VKey } from '@idux/cdk/utils' +import type { DefineComponent, PropType, Slots, VNodeChild } from 'vue' export type InputFormater = (value: V) => string export type InputParser = (input: string) => V | null +export type RenderLocation = 'individual' | 'quick-select-panel' + export interface PanelRenderContext { slots: Slots input: string value: V + renderLocation: RenderLocation ok: () => void cancel: () => void setValue: (value: V) => void @@ -23,9 +26,9 @@ export interface PanelRenderContext { export interface Segment { name: string - inputClassName: string | (string | undefined)[] + inputClassName?: string | (string | undefined)[] + containerClassName?: string | (string | undefined)[] placeholder?: string - defaultValue?: V format: InputFormater parse: InputParser panelRenderer?: (context: PanelRenderContext) => VNodeChild @@ -39,6 +42,7 @@ export const segmentProps = { }, input: String, value: null, + selectionStart: Number, disabled: Boolean, segment: { type: Object as PropType, @@ -46,3 +50,19 @@ export const segmentProps = { }, } as const export type SegmentProps = ExtractInnerPropTypes + +export const segmentIputProps = { + value: String, + disabled: Boolean, + placeholder: String, + onInput: [Function, Array] as PropType void>>, + onWidthChange: [Function, Array] as PropType void>>, +} +export type SegmentInputProps = ExtractInnerPropTypes +export type SegmentInputComponent = DefineComponent< + SegmentInputProps, + { + getInputElement: () => HTMLInputElement + } +> +export type SegmentInputInstance = InstanceType diff --git a/packages/pro/search/src/utils/getBoxsizingData.ts b/packages/pro/search/src/utils/getBoxsizingData.ts new file mode 100644 index 000000000..0a3102aa3 --- /dev/null +++ b/packages/pro/search/src/utils/getBoxsizingData.ts @@ -0,0 +1,44 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +export interface BoxSizingData { + boxSizing: string + paddingTop: number + paddingBottom: number + paddingLeft: number + paddingRight: number + borderTop: number + borderBottom: number + borderLeft: number + borderRight: number +} + +export function getBoxSizingData(node: HTMLElement): BoxSizingData { + const { + boxSizing, + paddingBottom, + paddingTop, + paddingLeft, + paddingRight, + borderBottom, + borderTop, + borderLeft, + borderRight, + } = window.getComputedStyle(node) + + return { + boxSizing, + paddingTop: parseFloat(paddingTop), + paddingBottom: parseFloat(paddingBottom), + paddingLeft: parseFloat(paddingLeft), + paddingRight: parseFloat(paddingRight), + borderTop: parseFloat(borderTop), + borderBottom: parseFloat(borderBottom), + borderLeft: parseFloat(borderLeft), + borderRight: parseFloat(borderRight), + } +} diff --git a/packages/pro/search/src/utils/getSelectableCommonParams.ts b/packages/pro/search/src/utils/getSelectableCommonParams.ts index 5d3e1b705..ed5b09d63 100644 --- a/packages/pro/search/src/utils/getSelectableCommonParams.ts +++ b/packages/pro/search/src/utils/getSelectableCommonParams.ts @@ -19,16 +19,19 @@ export function getSelectableCommonParams( context: PanelRenderContext>, multiple: boolean, separator?: string, + confirmRightAway?: boolean, ): SearchablePanelParams export function getSelectableCommonParams( context: PanelRenderContext, multiple: boolean, separator?: string, + confirmRightAway?: boolean, ): SearchablePanelParams export function getSelectableCommonParams( context: PanelRenderContext, multiple: boolean, separator?: string, + confirmRightAway?: boolean, ): SearchablePanelParams { const { value, input, setValue, ok } = context const panelValue = convertArray(value) @@ -45,10 +48,13 @@ export function getSelectableCommonParams( const handleChange = (v: T[] | undefined) => { if (!multiple) { setValue(v?.[0]) - ok() } else { setValue(v && v.length > 0 ? v : undefined) } + + if (confirmRightAway) { + ok() + } } return { diff --git a/packages/pro/search/style/index.less b/packages/pro/search/style/index.less index f40f3fb8f..eadc6fc94 100644 --- a/packages/pro/search/style/index.less +++ b/packages/pro/search/style/index.less @@ -3,6 +3,10 @@ @import '../../../components/style/mixins/ellipsis.less'; @import '../../../components//style//mixins//placeholder.less'; +@import './mixin.less'; +@import './quick-select.less'; +@import './panel.less'; + .@{pro-search-prefix} { .reset-component(); @@ -12,6 +16,10 @@ overflow: visible; outline: none; + .@{overflow-prefix}-item { + height: auto; + } + &-input-container { position: relative; z-index: 1000; @@ -27,6 +35,16 @@ background-color: @pro-search-background-color; cursor: text; color: @pro-search-color; + + &::after { + content: ''; + background-color: @pro-search-search-button-background-color; + width: @pro-search-border-width; + height: @pro-search-min-height; + position: absolute; + right: -1px; + top: -1px; + } } &-input-content { @@ -38,10 +56,6 @@ margin-left: -@pro-search-item-tag-margin-left; margin-bottom: -@pro-search-item-tag-margin-bottom; } - &-search-item-container { - width: 100%; - height: auto; - } &-placeholder { color: @pro-search-placeholder-color; padding: 0 @pro-search-placeholder-padding-horizontal; @@ -95,76 +109,82 @@ cursor: not-allowed; } - .@{pro-search-prefix}-search-item-tag { + .@{pro-search-prefix}-search-item { color: @pro-search-disabled-color; border: 1px solid @pro-search-item-tag-disabled-border-color; background-color: @pro-search-item-tag-disabled-background-color; cursor: not-allowed; + &-name { + color: @pro-search-disabled-color; + } } } + &-measure-element { + position: fixed; + visibility: hidden; + white-space: pre-wrap; + top: -100px; + left: -100px; + } + &-search-item { - display: inline-block; - max-width: 100%; - color: @pro-search-color; - margin-left: @pro-search-item-margin-left; - padding-bottom: @pro-search-item-tag-padding-vertical; - &:first-child { - margin-left: @pro-search-item-margin-left + @pro-search-item-tag-margin-left; + display: inline-flex; + max-width: 250px; + align-items: center; + height: @pro-search-item-height; + padding: @pro-search-item-tag-padding; + margin-bottom: @pro-search-item-tag-margin-bottom; + margin-left: @pro-search-item-tag-margin-left; + color: @pro-search-item-tag-color; + background-color: @pro-search-item-tag-background-color; + border-radius: @pro-search-item-tag-border-radius; + + &-name { + padding-right: @pro-search-segment-margin; + flex-shrink: 0; + color: @color-graphite-d10; + cursor: pointer; + box-sizing: content-box; } - - &-tag { + &-segments { position: relative; + overflow: hidden; display: inline-flex; - max-width: 100%; align-items: center; - height: @pro-search-item-height; - padding: @pro-search-item-tag-padding-vertical @pro-search-item-tag-padding-horizontal; - margin-bottom: @pro-search-item-tag-margin-bottom; - margin-left: @pro-search-item-tag-margin-left; - color: @pro-search-item-tag-color; - background-color: @pro-search-item-tag-background-color; - border-radius: @pro-search-item-tag-border-radius; - overflow: hidden; + color: @color-graphite-d40; + } - &-segments { - position: absolute; - left: @pro-search-item-tag-padding-horizontal; - top: @pro-search-item-tag-padding-vertical; - display: flex; - opacity: 0; - } - &-segment { - flex-shrink: 0; - & + & { - padding-left: @spacing-xs; - } - } - &-content { - flex: auto; - display: inline-block; - .ellipsis(); + &-close-icon { + display: flex; + align-items: center; + margin-left: @pro-search-close-icon-margin-left; + cursor: pointer; + z-index: 1; + font-size: @font-size-lg; + &:hover { + color: @color-primary; } + } - &-close-icon { - display: flex; - align-items: center; - margin-left: @pro-search-close-icon-margin-left; - cursor: pointer; - z-index: 1; - } + &&-invalid { + border: 1px solid @pro-search-item-tag-invalid-border-color; + } - &&-invalid { - border: 1px solid @pro-search-item-tag-invalid-border-color; - } + &-invalid-tooltip { + background-color: @pro-search-item-tag-invalid-tooltip-background-color; + color: @pro-search-item-tag-invalid-tooltip-color; - &-invalid-tooltip { - background-color: @pro-search-item-tag-invalid-tooltip-background-color; - color: @pro-search-item-tag-invalid-tooltip-color; + .@{overlay-prefix}-arrow { + color: @pro-search-item-tag-invalid-tooltip-background-color; + } + } - .@{overlay-prefix}-arrow { - color: @pro-search-item-tag-invalid-tooltip-background-color; - } + .@{pro-search-prefix}-segment-input-inner { + padding-right: @pro-search-segment-margin; + padding-left: @pro-search-segment-margin; + .@{pro-search-prefix}:not(.@{pro-search-prefix}-disabled) &:hover { + color: @color-primary; } } } @@ -174,124 +194,37 @@ } } - &-segment { - height: @pro-search-item-height; + &-segment-input { + height: 100%; max-width: 100%; - display: inline-block; - padding: 0 @pro-search-segment-padding-horizontal; - margin-right: @pro-search-segment-margin; - - &:not(&-disabled) { - border-bottom: @pro-search-segment-border-bottom; - } - &-input { + &-inner { height: 100%; max-width: 100%; + min-width: 1px; outline: none; - + box-sizing: content-box; .placeholder(@pro-search-placeholder-color); } - - &-overlay { - z-index: 1000; - padding: @pro-search-overlay-padding; - background-color: @pro-search-overlay-background-color; - border-radius: @pro-search-overlay-border-radius; - box-shadow: @pro-search-overlay-box-shadow; - } - - &-measure-span { - position: fixed; - visibility: hidden; - white-space: pre-wrap; - top: -100px; - left: -100px; + &-disabled &-inner { + color: @pro-search-disabled-color; + cursor: not-allowed; } } + &-segment { + height: @pro-search-item-height; + max-width: 100%; + display: inline-block; - &-panel-footer { - .panel-footer(); - } - - &-select-panel-select-all-option { - .select-option(@select-option-font-size, @select-option-color); - - border-bottom: @pro-search-border-width @pro-search-border-style @pro-search-border-color; - - &-label { - margin-left: @select-option-label-margin-left; - } - } - &-tree-select-panel-body { - padding: @spacing-sm 0; - .@{tree-node-prefix} { - padding: 0 @spacing-sm; + &-overlay { + .overlay() } } - &-date-picker-panel-body { - padding: @pro-search-date-picker-panel-body-padding; + &-temp-segment-input { + min-width: 100px; + margin-left: 8px; } - - &-name-segment-panel { - min-width: @pro-search-name-segment-panel-min-width; - } - &-operator-segment-panel.@{pro-search-prefix}-select-panel { - min-width: @pro-search-operator-segment-panel-min-width; - } - &-select-panel { - min-width: @pro-search-select-panel-min-width; - } - &-tree-select-panel { - min-width: @pro-search-tree-select-panel-min-width; - max-width: @pro-search-tree-select-panel-max-width; - } - - &-name-segment-input { - min-width: @pro-search-name-segment-input-min-width; - text-align: @pro-search-name-segment-input-text-align; - } - &-operator-segment-input { - min-width: @pro-search-operator-segment-input-min-width; - text-align: @pro-search-operator-segment-input-text-align; - } - &-input-segment-input { - min-width: @pro-search-input-segment-input-min-width; - text-align: @pro-search-input-segment-input-text-align; - } - &-select-segment-input { - min-width: @pro-search-select-segment-input-min-width; - text-align: @pro-search-select-segment-input-text-align; - } - &-tree-select-segment-input { - min-width: @pro-search-tree-select-segment-input-min-width; - text-align: @pro-search-tree-select-segment-input-text-align; - } - &-cascader-segment-input { - min-width: @pro-search-cascader-segment-input-min-width; - text-align: @pro-search-cascader-segment-input-text-align; - } - &-date-picker-segment-input { - min-width: @pro-search-date-picker-segment-input-min-width; - text-align: @pro-search-date-picker-segment-input-text-align; - } - &-date-range-picker-segment-input { - min-width: @pro-search-date-range-picker-segment-input-min-width; - text-align: @pro-search-date-range-picker-segment-input-text-align; - } - &-custom-segment-input { - min-width: @pro-search-custom-segment-input-min-width; - text-align: @pro-search-custom-segment-input-text-align; - } -} - -.panel-footer() { - border-top: @pro-search-panel-footer-border-width @pro-search-panel-footer-border-style - @pro-search-panel-footer-border-color; - padding: @pro-search-panel-footer-padding-vertical @pro-search-panel-footer-padding-horizontal; - text-align: right; - - .@{button-prefix} + .@{button-prefix} { - margin-left: @pro-search-panel-footer-button-margin; + &-name-segment-overlay { + .overlay() } } diff --git a/packages/pro/search/style/mixin.less b/packages/pro/search/style/mixin.less new file mode 100644 index 000000000..a1d59459e --- /dev/null +++ b/packages/pro/search/style/mixin.less @@ -0,0 +1,7 @@ +.overlay() { + z-index: 1000; + padding: @pro-search-overlay-padding; + background-color: @pro-search-overlay-background-color; + border-radius: @pro-search-overlay-border-radius; + box-shadow: @pro-search-overlay-box-shadow; +} \ No newline at end of file diff --git a/packages/pro/search/style/panel.less b/packages/pro/search/style/panel.less new file mode 100644 index 000000000..58b3d6e81 --- /dev/null +++ b/packages/pro/search/style/panel.less @@ -0,0 +1,85 @@ +@import '../../style/mixins/reset.less'; +@import '../../../components/select/style/index.less'; + +@import './mixin.less'; +@import './quick-select.less'; + +.@{pro-search-prefix} { + &-panel-footer { + .panel-footer(); + } + + &-date-picker-panel-body { + padding: @pro-search-date-picker-panel-body-padding; + display: flex; + } + + &-name-segment-panel.@{pro-search-prefix}-select-panel { + min-width: @pro-search-name-segment-panel-min-width; + } + &-operator-segment-panel.@{pro-search-prefix}-select-panel { + min-width: @pro-search-operator-segment-panel-min-width; + } + + &-select-panel { + min-width: @pro-search-select-panel-min-width; + display: flex; + flex-direction: column; + + &-inner { + padding: 4px 0; + } + + &-auto-height { + height: 100%; + .@{pro-search-prefix}-select-panel-inner { + height: 0; + flex: auto; + } + .cdk-virtual-scroll { + height: 100%; + } + } + &-select-all-option { + .select-option(@select-option-font-size, @select-option-color); + + border-bottom: @pro-search-border-width @pro-search-border-style @pro-search-border-color; + + &-label { + margin-left: @select-option-label-margin-left; + } + } + } + &-tree-select-panel { + min-width: @pro-search-tree-select-panel-min-width; + max-width: @pro-search-tree-select-panel-max-width; + display: flex; + flex-direction: column; + + &-auto-height { + height: 100%; + .@{pro-search-prefix}-tree-select-panel-body { + height: 0; + flex: auto; + } + } + + &-body { + padding: @spacing-xs 0; + .@{tree-node-prefix} { + padding: 0 @spacing-xs; + } + } + } +} + +.panel-footer() { + border-top: @pro-search-panel-footer-border-width @pro-search-panel-footer-border-style + @pro-search-panel-footer-border-color; + padding: @pro-search-panel-footer-padding-vertical @pro-search-panel-footer-padding-horizontal; + text-align: right; + + .@{button-prefix} + .@{button-prefix} { + margin-left: @pro-search-panel-footer-button-margin; + } +} diff --git a/packages/pro/search/style/quick-select.less b/packages/pro/search/style/quick-select.less new file mode 100644 index 000000000..fda6ef66b --- /dev/null +++ b/packages/pro/search/style/quick-select.less @@ -0,0 +1,139 @@ +@import '../../../components/style/mixins/ellipsis.less'; +@import './mixin.less'; + +.@{pro-search-prefix}-quick-select { + &-overlay { + .overlay() + } + + &-panel { + padding: 12px 8px 0; + + &-shortcuts { + width: 100%; + display: flex; + flex-wrap: wrap; + padding: 0 8px; + margin-bottom: 8px; + } + &-items { + width: 100%; + overflow: auto hidden; + display: flex; + align-items: stretch; + justify-content: flex-start; + padding: 0 0 12px; + } + &-item-separator { + width: 1px; + background-color: @color-graphite-l30; + margin: 0 4px; + flex-shrink: 0; + } + } + &-shortcut { + margin-bottom: 8px; + height: 24px; + padding: 2px 8px; + display: flex; + align-items: center; + background-color: @color-graphite-l40; + border-radius: 2px; + &:not(&:first-child) { + margin-left: 8px; + } + + cursor: pointer; + } + &-item { + min-width: 220px; + max-height: 345px; + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden auto; + + &-header { + position: relative; + width: 100%; + height: 24px; + padding: 0 8px; + display: flex; + align-items: center; + justify-content: space-between; + } + &-content { + flex: auto; + } + &-label { + height: 100%; + max-width: calc(100% - 24px); + color: @color-graphite; + .ellipsis() + } + &-search-bar { + position: absolute; + right: 0; + height: 24px; + width: 24px; + display: flex; + align-items: center; + justify-content: flex-end; + background-color: @pro-search-background-color; + border: 1px solid transparent; + transition: all @transition-duration-fast @ease-in-out; + + &-opened { + width: 100%; + border: 1px solid @pro-search-border-color; + border-radius: @pro-search-border-radius; + } + } + &-search-bar:not(&-search-bar-opened) &-search-input { + padding: 0; + } + &-search-input { + width: 0; + border: 0; + height: 100%; + flex: auto; + transition: none; + } + &-search-icon { + width: 24px; + font-size: 16px; + flex-shrink: 0; + color: @color-graphite-d20; + cursor: pointer; + } + + &.@{pro-search-prefix}-date-range-picker-segment-container { + min-width: 505px; + } + &.@{pro-search-prefix}-date-picker-segment-container { + min-width: 252px; + } + .@{pro-search-prefix}-select-panel { + .@{select-option-prefix} { + padding: 8px; + &-selected:not(&-disabled) { + border-radius: 2px; + } + } + } + + .@{pro-search-prefix}-tree-select-panel { + min-height: 230px; + } + .@{pro-search-prefix}-date-picker-panel { + width: 493px; + padding: 0 8px; + &-body { + padding: 0 0 8px; + } + .@{pro-search-prefix}-panel-footer { + padding: 8px 0 0; + } + } + } +} \ No newline at end of file diff --git a/packages/pro/search/style/themes/default.variable.less b/packages/pro/search/style/themes/default.variable.less index 7c9b12b1b..a9962549e 100644 --- a/packages/pro/search/style/themes/default.variable.less +++ b/packages/pro/search/style/themes/default.variable.less @@ -27,7 +27,7 @@ @pro-search-close-icon-font-size: @font-size-lg; @pro-search-close-icon-color: @color-graphite-d20; -@pro-search-close-icon-margin-left: @spacing-xs; +@pro-search-close-icon-margin-left: 0; @pro-search-search-button-width: @pro-search-min-height; @pro-search-search-button-background-color: @color-primary; @@ -38,14 +38,12 @@ @pro-search-item-height: 22px; @pro-search-item-color: @pro-search-color; -@pro-search-item-margin-left: @spacing-xs; @pro-search-item-tag-max-width: 160px; @pro-search-item-tag-color: @pro-search-color; @pro-search-item-tag-background-color: @color-graphite-l40; @pro-search-item-tag-border-radius: 2px; -@pro-search-item-tag-padding-horizontal: @spacing-sm; -@pro-search-item-tag-padding-vertical: 2px; +@pro-search-item-tag-padding: 2px 4px 2px 8px; @pro-search-item-tag-margin-left: @spacing-xs; @pro-search-item-tag-margin-bottom: @spacing-xs; @pro-search-item-tag-disabled-border-color: @pro-search-border-color; @@ -72,7 +70,7 @@ @pro-search-date-picker-panel-body-padding: @spacing-lg; -@pro-search-name-segment-panel-min-width: 100px; +@pro-search-name-segment-panel-min-width: 160px; @pro-search-operator-segment-panel-min-width: 20px; @pro-search-select-panel-min-width: 100px; @pro-search-tree-select-panel-min-width: 200px;