diff --git a/packages/pro/config/src/defaultConfig.ts b/packages/pro/config/src/defaultConfig.ts index 38ae89163..dc2cbe874 100644 --- a/packages/pro/config/src/defaultConfig.ts +++ b/packages/pro/config/src/defaultConfig.ts @@ -23,4 +23,9 @@ export const defaultConfig: ProGlobalConfig = { clearIcon: 'close-circle', collapseIcon: ['collapse', 'uncollapse'], }, + search: { + clearable: true, + clearIcon: 'close-circle', + searchIcon: 'search', + }, } diff --git a/packages/pro/config/src/types.ts b/packages/pro/config/src/types.ts index b7dcb1133..c3f40690a 100644 --- a/packages/pro/config/src/types.ts +++ b/packages/pro/config/src/types.ts @@ -5,8 +5,10 @@ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE */ +import type { PortalTargetType } from '@idux/cdk/portal' import type { ProLocale } from '@idux/pro/locales' import type { ProTableColumnIndexable } from '@idux/pro/table' +import type { VNode } from 'vue' export interface ProGlobalConfig { common: ProCommonConfig @@ -14,6 +16,7 @@ export interface ProGlobalConfig { table: ProTableConfig tree: ProTreeConfig + search: ProSearchConfig } export type ProGlobalConfigKey = keyof ProGlobalConfig @@ -30,3 +33,10 @@ export interface ProTreeConfig { clearIcon: string collapseIcon: [string, string] } + +export interface ProSearchConfig { + clearable: boolean + clearIcon: string | VNode + searchIcon: string | VNode + overlayContainer?: PortalTargetType +} diff --git a/packages/pro/default.less b/packages/pro/default.less index 987650843..98c23f66a 100644 --- a/packages/pro/default.less +++ b/packages/pro/default.less @@ -2,3 +2,4 @@ @import './table/style/themes/default.less'; @import './transfer/style/themes/default.less'; @import './tree/style/themes/default.less'; +@import './search/style/themes/default.less'; \ No newline at end of file diff --git a/packages/pro/index.ts b/packages/pro/index.ts index 7dcc2e108..93279f558 100644 --- a/packages/pro/index.ts +++ b/packages/pro/index.ts @@ -8,6 +8,7 @@ import type { App, Directive } from 'vue' import { IxProLayout, IxProLayoutSiderTrigger } from '@idux/pro/layout' +import { IxProSearch } from '@idux/pro/search' import { IxProTable, IxProTableLayoutTool } from '@idux/pro/table' import { IxProTransfer } from '@idux/pro/transfer' import { IxProTree } from '@idux/pro/tree' @@ -15,7 +16,15 @@ import { version } from '@idux/pro/version' const directives: Record = {} -const components = [IxProLayout, IxProLayoutSiderTrigger, IxProTable, IxProTableLayoutTool, IxProTransfer, IxProTree] +const components = [ + IxProLayout, + IxProLayoutSiderTrigger, + IxProTable, + IxProTableLayoutTool, + IxProTransfer, + IxProTree, + IxProSearch, +] const install = (app: App): void => { components.forEach(component => { @@ -36,4 +45,5 @@ export * from '@idux/pro/layout' export * from '@idux/pro/table' export * from '@idux/pro/transfer' export * from '@idux/pro/tree' +export * from '@idux/pro/search' export * from '@idux/pro/version' diff --git a/packages/pro/locales/src/langs/en-US.ts b/packages/pro/locales/src/langs/en-US.ts index 21034e74b..2d2841ce0 100644 --- a/packages/pro/locales/src/langs/en-US.ts +++ b/packages/pro/locales/src/langs/en-US.ts @@ -34,6 +34,15 @@ const enUS: ProLocale = { expandAll: 'Expand all', collapseAll: 'Collapse all', }, + search: { + keyword: 'Keyword', + ok: 'Ok', + cancel: 'Cancel', + selectAll: 'Select All', + placeholder: 'Click To select search options, press Enter to confirm', + switchToDatePanel: 'Switch To Date', + switchToTimePanel: 'Switch To Time', + }, } export default enUS diff --git a/packages/pro/locales/src/langs/zh-CN.ts b/packages/pro/locales/src/langs/zh-CN.ts index 98effb611..74ffd5ca2 100644 --- a/packages/pro/locales/src/langs/zh-CN.ts +++ b/packages/pro/locales/src/langs/zh-CN.ts @@ -34,6 +34,15 @@ const zhCN: ProLocale = { expandAll: '展开全部', collapseAll: '收起全部', }, + search: { + keyword: '关键字', + ok: '确定', + cancel: '取消', + selectAll: '全选', + placeholder: '点击选择筛选条件, 按回车键确认', + switchToDatePanel: '切换日期选择', + switchToTimePanel: '切换时间选择', + }, } export default zhCN diff --git a/packages/pro/locales/src/types.ts b/packages/pro/locales/src/types.ts index 2edb89841..626260219 100644 --- a/packages/pro/locales/src/types.ts +++ b/packages/pro/locales/src/types.ts @@ -30,11 +30,22 @@ export interface ProTreeLocale { collapseAll: string } +export interface ProSearchLocale { + keyword: string + ok: string + cancel: string + selectAll: string + placeholder: string + switchToTimePanel: string + switchToDatePanel: string +} + export interface ProLocale { type: ProLocaleType table: ProTableLocale tree: ProTreeLocale + search: ProSearchLocale } export type ProLocaleType = 'zh-CN' | 'en-US' diff --git a/packages/pro/search/__tests__/proSearch.spec.ts b/packages/pro/search/__tests__/proSearch.spec.ts new file mode 100644 index 000000000..2299c4c62 --- /dev/null +++ b/packages/pro/search/__tests__/proSearch.spec.ts @@ -0,0 +1,25 @@ +import { MountingOptions, mount } from '@vue/test-utils' + +import { renderWork } from '@tests' + +import ProSearch from '../src/ProSearch' +import { ProSearchProps } from '../src/types' + +describe.skip('ProSearch', () => { + const ProSearchMount = (options?: MountingOptions>) => + mount(ProSearch, { ...(options as MountingOptions) }) + + renderWork(ProSearch, { + props: {}, + }) + + test('xxx work', async () => { + const wrapper = ProSearchMount({ props: { xxx: 'Xxx' } }) + + expect(wrapper.classes()).toContain('ix-Xxx') + + await wrapper.setProps({ xxx: 'Yyy' }) + + expect(wrapper.classes()).toContain('ix-Yyy') + }) +}) diff --git a/packages/pro/search/demo/Basic.md b/packages/pro/search/demo/Basic.md new file mode 100644 index 000000000..ce416793a --- /dev/null +++ b/packages/pro/search/demo/Basic.md @@ -0,0 +1,14 @@ +--- +order: 0 +title: + zh: 基本使用 + en: Basic usage +--- + +## zh + +最简单的用法。关键字为默认内置搜索项。 + +## en + +The simplest usage. diff --git a/packages/pro/search/demo/Basic.vue b/packages/pro/search/demo/Basic.vue new file mode 100644 index 000000000..e2580eb3a --- /dev/null +++ b/packages/pro/search/demo/Basic.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/packages/pro/search/demo/Custom.md b/packages/pro/search/demo/Custom.md new file mode 100644 index 000000000..460c9b8b5 --- /dev/null +++ b/packages/pro/search/demo/Custom.md @@ -0,0 +1,14 @@ +--- +order: 30 +title: + zh: 自定义搜索项 + en: Custom SearchOption +--- + +## zh + +通过 `'custom'` 类型的option自定义搜索项。 + +## en + +Customize SearchOption using option with type `'custom'`. diff --git a/packages/pro/search/demo/Custom.vue b/packages/pro/search/demo/Custom.vue new file mode 100644 index 000000000..3939d59ce --- /dev/null +++ b/packages/pro/search/demo/Custom.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/packages/pro/search/demo/Disabled.md b/packages/pro/search/demo/Disabled.md new file mode 100644 index 000000000..b10422255 --- /dev/null +++ b/packages/pro/search/demo/Disabled.md @@ -0,0 +1,14 @@ +--- +order: 10 +title: + zh: 禁用 + en: Disabled +--- + +## zh + +通过配置 `disabled` 来禁用搜索 + +## en + +disable search by `disabled` prop. diff --git a/packages/pro/search/demo/Disabled.vue b/packages/pro/search/demo/Disabled.vue new file mode 100644 index 000000000..5cae2ded0 --- /dev/null +++ b/packages/pro/search/demo/Disabled.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/packages/pro/search/demo/Invalid.md b/packages/pro/search/demo/Invalid.md new file mode 100644 index 000000000..a75b2d44e --- /dev/null +++ b/packages/pro/search/demo/Invalid.md @@ -0,0 +1,14 @@ +--- +order: 20 +title: + zh: 非法搜索项处理 + en: Invalid Search Value Handling +--- + +## zh + +使用 `onItemInvalid` 事件处理非法搜索项。 + +## en + +Handle invalid search value via `onItemInvalid` event. diff --git a/packages/pro/search/demo/Invalid.vue b/packages/pro/search/demo/Invalid.vue new file mode 100644 index 000000000..9024f99bf --- /dev/null +++ b/packages/pro/search/demo/Invalid.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/packages/pro/search/docs/Design.en.md b/packages/pro/search/docs/Design.en.md new file mode 100644 index 000000000..d1e713d5e --- /dev/null +++ b/packages/pro/search/docs/Design.en.md @@ -0,0 +1,3 @@ +## Description + +## Usage scenarios diff --git a/packages/pro/search/docs/Design.zh.md b/packages/pro/search/docs/Design.zh.md new file mode 100644 index 000000000..933801767 --- /dev/null +++ b/packages/pro/search/docs/Design.zh.md @@ -0,0 +1,3 @@ +## 组件定义 + +## 使用场景 diff --git a/packages/pro/search/docs/Index.en.md b/packages/pro/search/docs/Index.en.md new file mode 100644 index 000000000..e52e41972 --- /dev/null +++ b/packages/pro/search/docs/Index.en.md @@ -0,0 +1,29 @@ +--- +category: pro +type: Data Entry +order: 0 +title: ProSearch +subtitle: +--- + +## API + +### IxProSearch + +#### ProSearchProps + +| Name | Description | Type | Default | Global Config | Remark | +| --- | --- | --- | --- | --- | --- | +| - | - | - | - | ✅ | - | + +#### ProSearchSlots + +| Name | Description | Parameter Type | Remark | +| --- | --- | --- | --- | +| - | - | - | - | + +#### ProSearchMethods + +| Name | Description | Parameter Type | Remark | +| --- | --- | --- | --- | +| - | - | - | - | diff --git a/packages/pro/search/docs/Index.zh.md b/packages/pro/search/docs/Index.zh.md new file mode 100644 index 000000000..cfab7c5bc --- /dev/null +++ b/packages/pro/search/docs/Index.zh.md @@ -0,0 +1,168 @@ +--- +category: pro +type: 数据录入 +order: 0 +title: ProSearch +subtitle: 复合搜索 +--- + +## API + +### IxProSearch + +#### ProSearchProps + +| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `v-model:value` | 复合选中的搜索值 | - | - | ✅ | - | +| `clearable` | 是否可清除 | `boolean` | `true` | ✅ | - | +| `clearIcon` | 清除图标 | `string \| VNode \| #clearIcon` | `close-circle` | ✅ | - | +| `disabled` | 是否禁用 | `boolean` | `false` | - | - | +| `overlayContainer` | 自定义浮层容器节点 | `string \| HTMLElement \| () => string \| HTMLElement` | - | ✅ | - | +| `placeholder` | 默认文本 | `string` | - | - | - | +| `searchFields` | 搜索选项 | `SearchField[]` | - | - | 用于配置支持那些搜索条件 | +| `onChange` | 搜索条件改变之后的回调 | `(value: searchValue[] \| undefined, oldValue: searchValue[] \| undefined) => void` | - | - | - | +| `onClear` | 清除搜索条件的回调 | `() => void` | - | - | - | +| `onItemRemove` | 搜索条件删除时的回调 | `(item: SearchValue) => void` | - | - | - | +| `onItemInvalid` | 搜索条件不合法时触发的回调 | `(item: InvalidSearchValue) => void` | - | - | - | +| `onSearch` | 搜索按钮触发的回调 | `(value: searchValue[] \| undefined) => void` | - | - | - | + +#### ProSearchSlots + +| 名称 | 说明 | 参数类型 | 备注 | +| --- | --- | --- | --- | +| `clearIcon` | 清除图标 | - | - | + +```typescript +interface SearchValue { + key: VKey // 对应SearchData的key + name?: string // 对应SearchField的label + value: V // 搜索值 + operator?: string // 搜索操作符 +} +interface InvalidSearchValue extends Partial> { + nameInput?: string // 搜索字段名称输入 + operatorInput?: string // 操作符输入 + valueInput?: string // 值输入 +} +``` + +### SearchField + +`SearchField` 根据 `type` 字段不同区分不通的搜索条件类型,除去共同包含的配置之外,分别有不同的配置项 + +#### SearchFieldBase + +基础配置 + +| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `key` | 唯一的key | `VKey` | - | - | 必填 | +| `label` | 搜索条件的词条名称 | `string` | - | - | 必填 | +| `multiple` | 是否允许重复 | `boolean` | - | - | 为 `true` 时,该搜索条件可以被输入多次 | +| `operators` | 搜索条件的中间操作符 | `string[]` | - | - | 提供时,会在搜索词条名称中间增加一个操作符,如 `'='`, `'!='` | +| `defaultOperator` | 默认的操作符 | `string` | - | - | 提供时,会自动填入默认的操作符 | +| `defaultValue` | 默认值 | - | - | - | 提供时,会自动填入默认值 | +| `inputClassName` | 输入框class | `string` | - | - | 用于自定义输入框样式 | + +#### InputSearchField + +普通输入类型 + +| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `type` | 类型 | `'input'` | - | - | 固定为 `'input'` | +| `fieldConfig` | 配置 | `{ trim?: boolean }` | - | - | - | + +#### SelectSearchField + +下拉选择类型 + +| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `type` | 类型 | `'select'` | - | - | 固定为 `'select'` | +| `fieldConfig` | 配置 | `SelectSearchFieldConfig` | - | - | - | + +SelectSearchFieldConfig + +| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `dataSource` | 类型 | `SelectPanelData[]` | - | - | 继承自`SelectData`,但`key`和`label`为必填,不支持可配,详情参考[Select](/components/select/zh) | +| `multiple` | 是否为多选 | `boolean` | - | - | 默认为单选 | +| `searchable` | 是否支持筛选 | `boolean` | false | - | 默认不支持 | +| `searchFn` | 搜索函数 | `(data: SelectPanelData, searchText: string) => boolean` | - | - | 默认模糊匹配 | +| `separator` | 多选分隔符 | `string` | `'|'` | - | - | +| `virtual` | 是否支持虚拟滚动 | `boolean` | `false` | - | 默认不支持 | +| `overlayItemWidth` | 选项宽度 | `number` | - | - | - | + +> 注:使用 `Ctrl + Enter` 在多选下切换面板中选项选中状态 + +```typescript +type SelectPanelData = Required> & SelectData +``` + +#### DatePickerSearchField + +日期选择类型 + +| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `type` | 类型 | `'datePicker'` | - | - | 固定为 `'datePicker'` | +| `fieldConfig` | 配置 | `'DatePickerSearchFieldConfig'` | - | - | - | + +DatePickerSearchFieldConfig + +| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `format` | 日期输入格式 | `'格式'` | - | ✅ | 详见[DatePicker](/components/date-picker/zh) | +| `type` | 日期选择类型 | `DatePickerType` | `'date'` | - | 同`DatePicker`的`type`, 详见[DatePicker](/components/date-picker/zh) | +| `cellTooltip` | 日期禁用的悬浮提示 | `(cell: { value: Date, disabled: boolean}) => string | void` | - | - | 详见[DatePicker](/components/date-picker/zh) | +| `disabledDate` | 日期禁用判断 | `(date: Date) => boolean` | - | - | 详见[DatePicker](/components/date-picker/zh) | +| `timePanelOptions` | 时间面板配置 | `TimePanelOptions` | - | - | 详见[DatePicker](/components/date-picker/zh) | + +#### DateRangePickerSearchField + +日期范围选择类型 + +| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `type` | 类型 | `'dateRangePicker'` | - | - | 固定为 `'dateRangePicker'` | +| `fieldConfig` | 配置 | `'DateRangePickerSearchFieldConfig'` | - | - | - | + +DateRangePickerSearchFieldConfig + +| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `format` | 日期输入格式 | `'格式'` | - | ✅ | 详见[DatePicker](/components/date-picker/zh) | +| `separator` | 日期范围之间的分隔符 | `string` | `'~'` | - | - | +| `type` | 日期选择类型 | `DatePickerType` | `'date'` | - | 同`DatePicker`的`type`, 详见[DatePicker](/components/date-picker/zh) | +| `cellTooltip` | 日期禁用的悬浮提示 | `(cell: { value: Date, disabled: boolean}) => string | void` | - | - | 详见[DatePicker](/components/date-picker/zh) | +| `disabledDate` | 日期禁用判断 | `(date: Date) => boolean` | - | - | 详见[DatePicker](/components/date-picker/zh) | +| `timePanelOptions` | 时间面板配置 | `TimePanelOptions` | - | - | 详见[DatePicker](/components/date-picker/zh) | + +#### CustomSearchField + +自定义类型 + +| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `type` | 类型 | `'custom'` | - | - | 固定为 `'custom'` | +| `fieldConfig` | 配置 | `CustomSearchFieldConfig` | - | - | - | + +CustomSearchFieldConfig + +| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `customPanel` | 自定义面板渲染 | `string \| (context: PanelRenderContext) => VNodeChild` | - | - | 如果有面板则需要提供,类型为`string`时指代插槽名称 | +| `format` | 数据格式化函数 | `(value: unknown) => string` | - | - | 必填,用于将指定的类型转换成字符串输入 | +| `parse` | 输入解析函数 | `(input: string) => unknown | null` | - | - | 必填,用于将输入的字符串解析到指定的类型 | + +```typescript +interface PanelRenderContext { + input: string // 输入的字符串 + value: V // 值 + ok: () => void // 确认 + cancel: () => void // 取消 + setValue: (value: V) => void // 设置搜索值 +} +``` diff --git a/packages/pro/search/index.ts b/packages/pro/search/index.ts new file mode 100644 index 000000000..e8a88b2ca --- /dev/null +++ b/packages/pro/search/index.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 { ProSearchComponent } from './src/types' + +import ProSearch from './src/ProSearch' + +const IxProSearch = ProSearch as unknown as ProSearchComponent + +export { IxProSearch } + +export type { + ProSearchInstance, + ProSearchComponent, + ProSearchPublicProps as ProSearchProps, + SearchField, + SearchValue, + InvalidSearchValue, +} from './src/types' diff --git a/packages/pro/search/src/ProSearch.tsx b/packages/pro/search/src/ProSearch.tsx new file mode 100644 index 000000000..f73a5db8a --- /dev/null +++ b/packages/pro/search/src/ProSearch.tsx @@ -0,0 +1,185 @@ +/** + * @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, nextTick, normalizeClass, provide, watch, withDirectives } from 'vue' + +import { isFunction, isString } from 'lodash-es' + +import { clickOutside } from '@idux/cdk/click-outside' +import { callEmit } from '@idux/cdk/utils' +import { ɵOverflow } from '@idux/components/_private/overflow' +import { useDateConfig } from '@idux/components/config' +import { useGlobalConfig } from '@idux/pro/config' + +import { useActiveSegment } from './composables/useActiveSegment' +import { useCommonOverlayProps } from './composables/useCommonOverlayProps' +import { useSearchItems } from './composables/useSearchItem' +import { tempSearchStateKey, useSearchStates } from './composables/useSearchStates' +import { useSearchValues } from './composables/useSearchValues' +import SearchItemComp from './searchItem/SearchItem' +import { proSearchContext } from './token' +import { type SearchItem, proSearchProps } from './types' +import { renderIcon } from './utils/RenderIcon' + +export default defineComponent({ + name: 'IxProSearch', + props: proSearchProps, + setup(props, { slots }) { + const common = useGlobalConfig('common') + const locale = useGlobalConfig('locale') + const config = useGlobalConfig('search') + const dateConfig = useDateConfig() + const mergedPrefixCls = computed(() => `${common.prefixCls}-search`) + + const { searchValues, searchValueEmpty, setSearchValues } = useSearchValues(props) + const searchStateContext = useSearchStates(props, dateConfig, searchValues, setSearchValues) + const searchItems = useSearchItems(props, slots, mergedPrefixCls, searchStateContext.searchStates, dateConfig) + const activeSegmentContext = useActiveSegment(props, searchItems, searchStateContext.tempSearchStateAvailable) + const commonOverlayProps = useCommonOverlayProps(mergedPrefixCls, props, config) + + const overlayContainer = computed(() => { + const target = commonOverlayProps.value.target + return isFunction(target) ? target() : target + }) + + const { initSearchStates, updateSearchState, clearSearchState, tempSearchState } = searchStateContext + const { activeSegment, setInactive, setTempActive } = activeSegmentContext + + watch( + () => props.value, + () => { + nextTick(initSearchStates) + }, + { immediate: true, deep: true }, + ) + + const placeholder = computed(() => props.placeholder ?? locale.search.placeholder) + const clearable = computed(() => props.clearable ?? config.clearable) + const clearIcon = computed(() => props.clearIcon ?? config.clearIcon) + const searchIcon = computed(() => props.searchIcon ?? config.searchIcon) + + const classes = computed(() => { + const prefixCls = mergedPrefixCls.value + return normalizeClass({ + [prefixCls]: true, + [`${prefixCls}-focused`]: !!activeSegment.value, + [`${prefixCls}-disabled`]: !!props.disabled, + }) + }) + + let previousActiveSegmentName: string | undefined + const setTempSegmentActive = () => { + let name = previousActiveSegmentName + + if (!name) { + name = tempSearchState.segmentValues[0]?.name ?? 'name' + for (let idx = tempSearchState.segmentValues.length - 1; idx > -1; idx--) { + if (tempSearchState.segmentValues[idx].value) { + name = tempSearchState.segmentValues[idx].name + break + } + } + } + + setTempActive(name) + } + + const handleProSearchClick = (evt: Event) => { + evt.preventDefault() + setTempSegmentActive() + } + const handleClickOutside = (evt: MouseEvent) => { + if (!activeSegment.value) { + previousActiveSegmentName = undefined + return + } + + const paths = evt.composedPath() + const target = overlayContainer.value + + if ( + paths.some(path => (isString(target) ? (path as HTMLElement).classList?.contains(target) : path === target)) + ) { + return + } + + const { itemKey, name } = activeSegment.value + + if (itemKey === tempSearchStateKey) { + previousActiveSegmentName = name + } else { + previousActiveSegmentName = undefined + updateSearchState(itemKey) + } + + setInactive() + } + const handleSearchBtnClick = () => { + callEmit(props.onSearch, searchValues.value) + } + + provide(proSearchContext, { + props, + slots, + locale: locale.search, + mergedPrefixCls, + commonOverlayProps, + + ...searchStateContext, + ...activeSegmentContext, + }) + + return () => { + const prefixCls = mergedPrefixCls.value + + const overfloweSlots = { + item: (item: SearchItem) => , + rest: (rest: SearchItem[]) => ( + + {slots.overflowedLabel?.(rest) ?? `+ ${rest.length}`} + + ), + } + + return ( +
+
+ {withDirectives( +
+ <ɵOverflow + v-show={!activeSegment.value} + v-slots={overfloweSlots} + prefixCls={prefixCls} + dataSource={searchItems.value} + getKey={item => item.key} + maxLabel={props.maxLabel} + /> +
+ {searchItems.value?.map(item => ( + + ))} +
+ {searchValueEmpty.value && !activeSegment.value && ( + {placeholder.value} + )} +
, + [[clickOutside, handleClickOutside]], + )} + {!searchValueEmpty.value && clearable.value && !props.disabled && ( +
+ {renderIcon(clearIcon.value, slots.clearIcon)} +
+ )} +
+
+ {renderIcon(searchIcon.value, slots.searchIcon)} +
+
+ ) + } + }, +}) diff --git a/packages/pro/search/src/composables/useActiveSegment.ts b/packages/pro/search/src/composables/useActiveSegment.ts new file mode 100644 index 000000000..ab22bca22 --- /dev/null +++ b/packages/pro/search/src/composables/useActiveSegment.ts @@ -0,0 +1,123 @@ +/** + * @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, SearchItem, Segment } from '../types' + +import { type ComputedRef, computed } from 'vue' + +import { type VKey, useState } from '@idux/cdk/utils' + +import { tempSearchStateKey } from './useSearchStates' + +export interface ActiveSegmentContext { + activeSegment: ComputedRef + setActiveSegment: (segment: ActiveSegment | undefined) => void + changeActive: (offset: number, crossItem?: boolean) => void + setFakeActive: () => void + setInactive: () => void + setTempActive: (name?: string) => void +} +export interface ActiveSegment { + itemKey: VKey + name: string +} +type FlattenedSegment = Segment & { itemKey: VKey } + +const fakeItemKey = Symbol('fake') + +export function useActiveSegment( + props: ProSearchProps, + searchItems: ComputedRef, + tempSearchStateAvailable: ComputedRef, +): ActiveSegmentContext { + const [activeSegment, setActiveSegment] = useState(undefined) + 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)) + const activeSegmentIndex = computed(() => + flattenedSegments.value.findIndex( + segment => segment.itemKey === activeSegment.value?.itemKey && segment.name === activeSegment.value.name, + ), + ) + + const updateActiveSegment = (segment: ActiveSegment | undefined) => { + if (activeSegment.value?.itemKey === segment?.itemKey && activeSegment.value?.name === segment?.name) { + return + } + + setActiveSegment(segment) + } + + const changeActive = (offset: number, crossItem = false) => { + if (!activeSegment.value || activeSegment.value.itemKey === fakeItemKey) { + return + } + + let targetIndex = activeSegmentIndex.value + offset + targetIndex = offset > 0 ? Math.min(targetIndex, flattenedSegments.value.length - 1) : Math.max(targetIndex, 0) + const targetSegment = flattenedSegments.value[targetIndex] + + 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, + }) + return + } + + /* eslint-disable indent */ + updateActiveSegment( + targetSegment + ? { + itemKey: targetSegment.itemKey, + name: targetSegment.name, + } + : undefined, + ) + /* eslint-enable indent */ + } + + const setFakeActive = () => { + setActiveSegment({ + itemKey: fakeItemKey, + name: '', + }) + } + + const setInactive = () => { + setActiveSegment(undefined) + } + + const setTempActive = (name?: string) => { + if (!tempSearchStateAvailable.value) { + setFakeActive() + } else { + setActiveSegment({ + itemKey: tempSearchStateKey, + name: name ?? 'name', + }) + } + } + + return { + activeSegment: mergedActiveSegment, + setActiveSegment: updateActiveSegment, + changeActive, + setFakeActive, + setInactive, + setTempActive, + } +} + +function flattenSegments(searchItems: SearchItem[]): FlattenedSegment[] { + const segments: FlattenedSegment[] = [] + searchItems.forEach(item => { + segments.push(...item.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 new file mode 100644 index 000000000..bf2612ba3 --- /dev/null +++ b/packages/pro/search/src/composables/useCommonOverlayProps.ts @@ -0,0 +1,24 @@ +/** + * @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 } from '../types' +import type { ɵOverlayProps } from '@idux/components/_private/overlay' +import type { ProSearchConfig } from '@idux/pro/config' + +import { type ComputedRef, computed } from 'vue' + +export function useCommonOverlayProps( + mergedPrefixCls: ComputedRef, + props: ProSearchProps, + config: ProSearchConfig, +): ComputedRef<ɵOverlayProps> { + return computed(() => ({ + target: props.overlayContainer || config.overlayContainer || `${mergedPrefixCls.value}-overlay-container`, + placement: 'bottomStart', + offset: [0, 8], + })) +} diff --git a/packages/pro/search/src/composables/useSearchItem.ts b/packages/pro/search/src/composables/useSearchItem.ts new file mode 100644 index 000000000..0c20ee6e7 --- /dev/null +++ b/packages/pro/search/src/composables/useSearchItem.ts @@ -0,0 +1,81 @@ +/** + * @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, SearchField, SearchItem } from '../types' +import type { SearchState } from './useSearchStates' +import type { DateConfig } from '@idux/components/config' + +import { type ComputedRef, type Slots, computed } from 'vue' + +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 { createCustomSegment } from '../segments/createCustomSegment' +import { createInputSegment } from '../segments/createInputSegment' + +export function useSearchItems( + props: ProSearchProps, + slots: Slots, + mergedPrefixCls: ComputedRef, + searchStates: 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, !operatorSegment) + + return { + key: searchState.key, + optionKey: searchState.fieldKey, + 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), + } as SearchItem + }), + ) +} + +function createSearchItemContentSegment( + prefixCls: string, + searchField: SearchField, + slots: Slots, + dateConfig: DateConfig, +) { + switch (searchField.type) { + case 'select': + return createSelectSegment(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 new file mode 100644 index 000000000..e47fb9ee4 --- /dev/null +++ b/packages/pro/search/src/composables/useSearchStates.ts @@ -0,0 +1,344 @@ +/** + * @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 { DateConfig } from '@idux/components/config' + +import { type ComputedRef, computed, reactive, ref, toRaw } from 'vue' + +import { isNil } from 'lodash-es' + +import { type VKey, callEmit } from '@idux/cdk/utils' + +import { + type ProSearchProps, + type SearchDataTypes, + type SearchField, + type SearchValue, + searchDataTypes, +} from '../types' + +export interface SegmentValue { + name: string + value: unknown +} +export interface SearchState { + key: VKey + fieldKey?: VKey + segmentValues: SegmentValue[] +} + +export interface SearchStateContext { + searchStates: ComputedRef + tempSearchState: SearchState + tempSearchStateAvailable: ComputedRef + initSearchStates: () => void + initTempSearchState: () => void + getSearchStateByKey: (key: VKey) => { searchState: SearchState; index: number } + validateSearchState: (key: VKey) => boolean + convertStateToValue: (key: VKey) => SearchValue + updateSegmentValue: (value: unknown, name: string, key: VKey) => void + updateSearchState: (key: VKey) => void + removeSearchState: (key: VKey) => void + clearSearchState: () => void +} + +export const tempSearchStateKey = Symbol('temp') + +export function useSearchStates( + props: ProSearchProps, + dateConfig: DateConfig, + searchValues: ComputedRef, + setSearchValues: (value: SearchValue[]) => void, +): SearchStateContext { + const getKey = createStateKeyGetter() + + const searchStates = ref([]) + const fieldKeyCountMap = computed(() => { + const countMap = new Map() + + searchStates.value.forEach(state => { + state.fieldKey && 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) + } + + return states + }) + + function getSearchStateByKey(key: VKey) { + if (key === tempSearchStateKey) { + return { searchState: tempSearchState, index: -1 } + } + + const index = searchStates.value.findIndex(value => value.key === key) + return { searchState: searchStates.value[index], index } + } + + function _convertStateToValue(state: SearchState) { + const operatorSegment = state.segmentValues.find(value => value.name === 'operator') + const contentSegment = state.segmentValues.find(value => searchDataTypes.includes(value.name as SearchDataTypes)) + + return { + key: state.fieldKey, + name: props.searchFields?.find(field => field.key === state.fieldKey)?.label, + operator: operatorSegment?.value as string, + value: toRaw(contentSegment?.value), + } as SearchValue + } + + function setSegmentValue(searchState: SearchState, name: string, value: unknown) { + let segmentValue = searchState.segmentValues.find(state => state.name === name) + if (segmentValue) { + segmentValue.value = value + } else { + segmentValue = { name, value } + searchState.segmentValues.push(segmentValue) + } + + 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 segmentValues = generateSegmentValues(searchFields, searchValue, dateConfig) + segmentValues.shift() + + searchState.segmentValues = [searchState.segmentValues[0], ...segmentValues] + } + } + + const validateSearchState = (key: VKey) => { + const { searchState } = getSearchStateByKey(key) + + return checkSearchStateValid(searchState, props.searchFields, fieldKeyCountMap.value, key !== tempSearchStateKey) + } + + const convertStateToValue = (key: VKey) => { + const { searchState } = getSearchStateByKey(key) + return _convertStateToValue(searchState) + } + + const initTempSearchState = () => { + tempSearchState.fieldKey = undefined + tempSearchState.segmentValues = generateSegmentValues() + } + + const initSearchStates = () => { + const dataKeyCountMap = new Map() + + searchStates.value = ( + searchValues.value?.map(searchValue => { + const fieldKey = searchValue.key + const searchData = props.searchFields?.find(field => field.key === fieldKey) + if (!searchData) { + return + } + + const segmentValues = generateSegmentValues(searchData, searchValue, dateConfig) + const index = dataKeyCountMap.has(fieldKey) ? dataKeyCountMap.get(fieldKey)! : 0 + const key = getKey(fieldKey, index) + + const searchState = { key, fieldKey, segmentValues } + if (!checkSearchStateValid(searchState, props.searchFields, dataKeyCountMap)) { + return + } + + dataKeyCountMap.set(fieldKey, index + 1) + return searchState + }) ?? [] + ).filter(Boolean) as SearchState[] + } + + function updateSearchValue() { + const newSearchValues = searchStates.value + .map(state => { + // filters invalid searchValues + if (!state.key) { + return + } + + const operatorSegmentValue = state.segmentValues.find(value => value.name === 'operator') + const contentSegmentValue = state.segmentValues.find(value => + searchDataTypes.includes(value.name as SearchDataTypes), + ) + + return { + key: state.fieldKey, + name: props.searchFields?.find(fileld => fileld.key === state.fieldKey)?.label, + operator: operatorSegmentValue?.value, + value: toRaw(contentSegmentValue?.value), + } + }) + .filter(Boolean) as SearchValue[] + + callEmit(props.onChange, newSearchValues, toRaw(searchValues.value)) + setSearchValues(newSearchValues) + } + + const updateSegmentValue = (value: unknown, name: string, key: VKey) => { + if (props.disabled) { + return + } + + const { searchState } = getSearchStateByKey(key) + if (!searchState) { + return + } + + setSegmentValue(searchState, name, value) + } + const updateSearchState = (key: VKey) => { + if (props.disabled) { + return + } + + const { searchState } = getSearchStateByKey(key) + + if (!searchState) { + return + } + + if (searchState.key === tempSearchStateKey) { + // create new search value + searchStates.value.push({ + ...searchState, + key: getKey(searchState.fieldKey!, fieldKeyCountMap.value.get(searchState.fieldKey!) ?? 0), + }) + + initTempSearchState() + } + + updateSearchValue() + } + const removeSearchState = (key: VKey) => { + if (props.disabled) { + return + } + + const stateIndex = searchStates.value.findIndex(state => state.key === key) + const searchValue = searchValues.value?.find(value => value.key === key) + + if (stateIndex < 0) { + return + } + + searchStates.value.splice(stateIndex, 1) + callEmit(props.onItemRemove, searchValue!) + updateSearchValue() + } + const clearSearchState = () => { + if (props.disabled) { + return + } + + searchStates.value = [] + updateSearchValue() + + callEmit(props.onClear) + } + + return { + searchStates: mergedSearchStates, + tempSearchState, + tempSearchStateAvailable, + initSearchStates, + initTempSearchState, + getSearchStateByKey, + validateSearchState, + convertStateToValue, + updateSegmentValue, + updateSearchState, + removeSearchState, + clearSearchState, + } +} + +function createStateKeyGetter() { + let seed = 0 + const keyMap = new Map() + + return (key: VKey, index: number) => { + if (!keyMap.has(key)) { + const newKey = seed++ + keyMap.set(key, newKey) + return `${newKey}-${index}` + } + + return `${keyMap.get(key)}-${index}` + } +} + +function generateSegmentValues( + searchField?: SearchField, + searchValue?: SearchValue, + dateConfig?: DateConfig, +): SegmentValue[] { + const nameKey = searchField?.key + const hasOperators = searchField?.operators && searchField.operators.length > 0 + + /* eslint-disable indent */ + return [ + { + name: 'name', + value: nameKey, + }, + searchField && + hasOperators && { + name: 'operator', + value: searchValue?.operator, + }, + searchField && + dateConfig && { + name: searchField.type, + value: searchValue?.value, + }, + ].filter(Boolean) as SegmentValue[] + /* eslint-enable indent */ +} + +function checkSearchStateValid( + searchState: SearchState, + searchFields: SearchField[] | undefined, + dataKeyCountMap: Map, + existed?: boolean, +) { + if (!searchState.fieldKey) { + return false + } + + if (searchState.segmentValues.some(segmentValue => isNil(segmentValue.value))) { + return false + } + + const count = dataKeyCountMap.get(searchState.fieldKey) + if (count && count > (existed ? 1 : 0)) { + return !!searchFields?.find(field => field.key === searchState.fieldKey)?.multiple + } + + return true +} diff --git a/packages/pro/search/src/composables/useSearchValues.ts b/packages/pro/search/src/composables/useSearchValues.ts new file mode 100644 index 000000000..87e6aefa5 --- /dev/null +++ b/packages/pro/search/src/composables/useSearchValues.ts @@ -0,0 +1,31 @@ +/** + * @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, SearchValue } from '../types' + +import { type ComputedRef, computed } from 'vue' + +import { useControlledProp } from '@idux/cdk/utils' + +export interface SearchValueContext { + searchValues: ComputedRef + searchValueEmpty: ComputedRef + setSearchValues: (values: SearchValue[]) => void +} + +export const tempSearchStateKey = 'temp' + +export function useSearchValues(props: ProSearchProps): SearchValueContext { + const [searchValues, setSearchValues] = useControlledProp(props, 'value') + const searchValueEmpty = computed(() => !searchValues.value || searchValues.value.length <= 0) + + return { + searchValues, + searchValueEmpty, + setSearchValues, + } +} diff --git a/packages/pro/search/src/composables/useSegmentOverlayUpdate.ts b/packages/pro/search/src/composables/useSegmentOverlayUpdate.ts new file mode 100644 index 000000000..4319e7d8e --- /dev/null +++ b/packages/pro/search/src/composables/useSegmentOverlayUpdate.ts @@ -0,0 +1,33 @@ +/** + * @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 SegmentOverlayUpdateContext { + triggerOverlayUpdate: () => void + registerOverlayUpdate: (update: () => void) => void + unregisterOverlayUpdate: (update: () => void) => void +} + +export function useSegmentOverlayUpdate(): SegmentOverlayUpdateContext { + const updateHandlers = new Set<() => void>() + const triggerOverlayUpdate = () => { + for (const update of updateHandlers.values()) { + update() + } + } + const registerOverlayUpdate = (update: () => void) => { + updateHandlers.add(update) + } + const unregisterOverlayUpdate = (update: () => void) => { + updateHandlers.delete(update) + } + + return { + triggerOverlayUpdate, + registerOverlayUpdate, + unregisterOverlayUpdate, + } +} diff --git a/packages/pro/search/src/composables/useSegmentStates.ts b/packages/pro/search/src/composables/useSegmentStates.ts new file mode 100644 index 000000000..a6a088660 --- /dev/null +++ b/packages/pro/search/src/composables/useSegmentStates.ts @@ -0,0 +1,137 @@ +/** + * @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 { ProSearchContext } from '../token' + +import { type Ref, ref, watch } from 'vue' + +import { callEmit } from '@idux/cdk/utils' + +import { tempSearchStateKey } from '../composables/useSearchStates' +import { type ProSearchProps, type SearchItemProps, searchDataTypes } from '../types' + +type SegmentStates = Record +export interface SegmentStatesContext { + segmentStates: Ref + handleSegmentInput: (name: string, input: string) => void + handleSegmentChange: (name: string, value: unknown) => void + handleSegmentConfirm: (name: string, confirmItem?: boolean) => void + handleSegmentCancel: (name: string) => void +} + +export function useSegmentStates( + props: SearchItemProps, + proSearchProps: ProSearchProps, + proSearchContext: ProSearchContext, +): SegmentStatesContext { + const { + getSearchStateByKey, + validateSearchState, + updateSearchState, + updateSegmentValue, + removeSearchState, + convertStateToValue, + initTempSearchState, + changeActive, + setFakeActive, + setTempActive, + } = proSearchContext + const segmentStates = ref({}) + + const initSegmentStates = () => { + const states = {} as SegmentStates + props.searchItem!.segments.forEach((segment, index) => { + const { searchState } = getSearchStateByKey(props.searchItem!.key) + const segmentValue = searchState.segmentValues.find(value => value.name === segment.name) + + states[segment.name] = { + input: segment.format(segmentValue?.value) ?? '', + value: segmentValue?.value, + index, + } + }) + + segmentStates.value = states + } + watch(() => props.searchItem?.segments, initSegmentStates, { immediate: true }) + + const setSegmentValue = (name: string, value: unknown) => { + if (!segmentStates.value[name]) { + return + } + + const segment = props.searchItem!.segments.find(seg => seg.name === name)! + segmentStates.value[name].value = value + segmentStates.value[name].input = segment.format(value) + } + const setSegmentInput = (name: string, input: string) => { + if (!segmentStates.value[name]) { + return + } + + const segment = props.searchItem!.segments.find(seg => seg.name === name)! + + segmentStates.value[name].input = input + segmentStates.value[name].value = segment.parse(input) + } + const confirmSearchItem = () => { + const key = props.searchItem!.key + if (validateSearchState(key)) { + updateSearchState(key) + } else { + const valueName = searchDataTypes.find(name => !!segmentStates.value[name]) + removeSearchState(key) + callEmit(proSearchProps.onItemInvalid, { + ...convertStateToValue(key), + nameInput: segmentStates.value.name?.input, + operatorInput: segmentStates.value.operator?.input, + valueInput: valueName && segmentStates.value[valueName]?.input, + }) + initTempSearchState() + } + + if (key !== tempSearchStateKey) { + setFakeActive() + } else { + setTempActive() + } + } + + const handleSegmentConfirm = (name: string, confirmItem?: boolean) => { + const segmentState = segmentStates.value[name] + if (!segmentState) { + return + } + const { value, index } = segmentState + updateSegmentValue(value, name, props.searchItem!.key) + + if ( + index === props.searchItem!.segments.length - 1 && + confirmItem && + (name !== 'name' || !segmentStates.value[name].value) + ) { + confirmSearchItem() + } else { + changeActive(1) + } + } + const handleSegmentCancel = (name: string) => { + if (!segmentStates.value[name].value) { + changeActive(-1, true) + } else if (props.searchItem?.key !== tempSearchStateKey) { + setFakeActive() + } + } + + return { + segmentStates, + handleSegmentInput: setSegmentInput, + handleSegmentChange: setSegmentValue, + handleSegmentConfirm, + handleSegmentCancel, + } +} diff --git a/packages/pro/search/src/panel/DatePickerPanel.tsx b/packages/pro/search/src/panel/DatePickerPanel.tsx new file mode 100644 index 000000000..b065d9320 --- /dev/null +++ b/packages/pro/search/src/panel/DatePickerPanel.tsx @@ -0,0 +1,82 @@ +/** + * @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 { useState } from '@idux/cdk/utils' +import { IxButton } from '@idux/components/button' +import { + type DatePanelProps, + type DateRangePanelProps, + IxDatePanel, + IxDateRangePanel, +} from '@idux/components/date-picker' + +import { proSearchContext } from '../token' +import { searchDatePickerPanelProps } from '../types' + +export default defineComponent({ + props: searchDatePickerPanelProps, + setup(props) { + const { locale, mergedPrefixCls } = inject(proSearchContext)! + const [visiblePanel, setVisiblePanel] = useState('datePanel') + + const handleChange = (value: Date | undefined) => { + props.onChange?.(value) + } + + const handleSwitchPanelClick = () => { + setVisiblePanel(visiblePanel.value === 'datePanel' ? 'timePanel' : 'datePanel') + } + const handleConfirm = () => { + props.onConfirm?.() + } + const handleCancel = () => { + props.onCancel?.() + } + + return () => { + const prefixCls = `${mergedPrefixCls.value}-date-picker-panel` + + const panelProps = { + cellToolTip: props.cellTooltip, + disabledDate: props.disabledDate, + value: props.value, + defaultOpenValue: props.defaultOpenValue, + type: props.type, + timePanelOptions: props.timePanelOptions, + visible: visiblePanel.value, + onChange: handleChange, + } + + return ( +
evt.preventDefault()}> +
+ {props.panelType === 'datePicker' ? ( + + ) : ( + + )} +
+
+ {props.type === 'datetime' && ( + + {visiblePanel.value === 'datePanel' ? locale.switchToTimePanel : locale.switchToDatePanel} + + )} + + {locale.ok} + + + {locale.cancel} + +
+
+ ) + } + }, +}) diff --git a/packages/pro/search/src/panel/SelectPanel.tsx b/packages/pro/search/src/panel/SelectPanel.tsx new file mode 100644 index 000000000..18b914ca2 --- /dev/null +++ b/packages/pro/search/src/panel/SelectPanel.tsx @@ -0,0 +1,205 @@ +/** + * @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 Ref, + computed, + defineComponent, + inject, + onBeforeUnmount, + onMounted, + ref, + watch, +} from 'vue' + +import { type VKey, useState } from '@idux/cdk/utils' +import { IxButton } from '@idux/components/button' +import { IxCheckbox } from '@idux/components/checkbox' +import { IxSelectPanel, type SelectData, type SelectPanelInstance } from '@idux/components/select' + +import { proSearchContext } from '../token' +import { SearchSelectPanelProps, searchSelectPanelProps } from '../types' +import { findDataSourceItem } from '../utils/selectData' + +export default defineComponent({ + props: searchSelectPanelProps, + setup(props) { + const { locale, mergedPrefixCls } = inject(proSearchContext)! + const [activeValue, setActiveValue] = useState(undefined) + const partiallySelected = computed(() => props.value && props.value.length > 0 && !props.allSelected) + + watch( + () => props.value, + value => { + const key = value?.[value.length - 1] + key && setActiveValue(key) + }, + ) + watch( + () => props.dataSource, + dataSource => { + if (activeValue.value && findDataSourceItem(dataSource!, option => option.key === activeValue.value)) { + return + } + + const firstItem = dataSource?.[0] + if (!firstItem) { + setActiveValue(undefined) + return + } + + if (firstItem.children && firstItem.children.length > 0) { + setActiveValue(firstItem.children[0].key) + } else { + setActiveValue(firstItem.key) + } + }, + ) + + const panelRef = ref() + const changeSelected = (key: VKey) => { + const multiple = !!props.multiple + const currValue = props.value ?? [] + const targetIndex = currValue.findIndex(item => item === key) + const isSelected = targetIndex > -1 + + if (!multiple) { + props.onChange?.([key]) + return + } + if (isSelected) { + props.onChange?.(currValue.filter((_, index) => targetIndex !== index)) + return + } + + props.onChange?.([...currValue, key]) + } + const handleOptionClick = (option: SelectData) => { + changeSelected(option.key!) + } + const handleConfirm = () => { + props.onConfirm?.() + } + const handleCancel = () => { + props.onCancel?.() + } + const handleSelectAllChange = () => { + props.onSelectAll?.(!props.allSelected) + } + + const handleKeyDown = useOnKeyDown(props, panelRef, activeValue, changeSelected, handleConfirm) + + onMounted(() => { + props.setOnKeyDown?.(handleKeyDown) + }) + onBeforeUnmount(() => { + props.setOnKeyDown?.(undefined) + }) + + const renderSelectAll = () => { + if (!props.multiple) { + return + } + + const prefixCls = `${mergedPrefixCls.value}-select-panel-select-all-option` + return ( +
+ + {locale.selectAll} +
+ ) + } + const renderFooter = () => { + if (!props.multiple) { + return + } + + return ( +
+ + {locale.ok} + + + {locale.cancel} + +
+ ) + } + + return () => { + const prefixCls = `${mergedPrefixCls.value}-select-panel` + const panelProps = { + activeValue: activeValue.value, + dataSource: props.dataSource, + multiple: props.multiple, + getKey: 'key', + labelKey: 'label', + selectedKeys: props.value, + onOptionClick: handleOptionClick as (option: SelectData) => void, + 'onUpdate:activeValue': setActiveValue as (value: K) => void, + } + return ( +
evt.preventDefault()}> + {renderSelectAll()} + + {renderFooter()} +
+ ) + } + }, +}) + +function useOnKeyDown( + props: SearchSelectPanelProps, + panelRef: Ref, + activeValue: ComputedRef, + changeSelected: (key: VKey) => void, + handleConfirm: () => void, +) { + return function (evt: KeyboardEvent) { + if (!panelRef.value) { + return true + } + + switch (evt.key) { + case 'ArrowUp': + evt.preventDefault() + panelRef.value.changeActiveIndex(-1) + break + case 'ArrowDown': + evt.preventDefault() + panelRef.value.changeActiveIndex(1) + break + case 'Enter': { + if (!props.dataSource || props.dataSource.length <= 0) { + return true + } + + evt.preventDefault() + + if (!props.multiple || evt.ctrlKey) { + activeValue.value && changeSelected(activeValue.value) + } else if ((props.value && props.value.length > 0) || !activeValue.value) { + handleConfirm() + } + + return false + } + default: + break + } + + return true + } +} diff --git a/packages/pro/search/src/searchItem/SearchItem.tsx b/packages/pro/search/src/searchItem/SearchItem.tsx new file mode 100644 index 000000000..600c85118 --- /dev/null +++ b/packages/pro/search/src/searchItem/SearchItem.tsx @@ -0,0 +1,146 @@ +/** + * @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, provide } from 'vue' + +import { tempSearchStateKey } from '../composables/useSearchStates' +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 Segment from './Segment' + +export default defineComponent({ + props: searchItemProps, + setup(props) { + const context = inject(proSearchContext)! + const { + props: proSearchProps, + mergedPrefixCls, + activeSegment, + changeActive, + setActiveSegment, + removeSearchState, + } = context + + const prefixCls = computed(() => `${mergedPrefixCls.value}-search-item`) + + const segmentStateContext = useSegmentStates(props, proSearchProps, context) + 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, + } + }) + }) + + const isActive = computed(() => activeSegment.value?.itemKey === props.searchItem!.key) + + const classes = computed(() => { + return normalizeClass({ + [prefixCls.value]: true, + [`${prefixCls.value}-tag`]: !isActive.value, + }) + }) + + const setSegmentActive = (name: string) => { + setActiveSegment({ + itemKey: props.searchItem!.key, + name, + }) + } + const handleCloseIconClick = (evt: Event) => { + evt.stopPropagation() + removeSearchState(props.searchItem!.key) + } + + const handleTagSegmentClick = (evt: Event, name: string) => { + evt.stopPropagation() + if (proSearchProps.disabled) { + return + } + + setSegmentActive(name) + + if (name === 'name') { + changeActive(1) + } + } + + const renderTag = () => { + const content = segmentRenderDatas.value.map(data => data.input).join(' ') + + return [ + + {segmentRenderDatas.value.map(data => ( + handleTagSegmentClick(evt, data.name)}> + {data.input} + + ))} + , + + {content} + , + ] + } + + return () => { + const children = [] + + if (!props.tagOnly) { + children.push( + ...segmentRenderDatas.value.map(segment => ( + + )), + ) + } + + if (!isActive.value) { + children.push(...renderTag()) + + if (!proSearchProps.disabled) { + children.push( + + {renderIcon('close')} + , + ) + } + } + + return ( + + {children} + + ) + } + }, +}) diff --git a/packages/pro/search/src/searchItem/Segment.tsx b/packages/pro/search/src/searchItem/Segment.tsx new file mode 100644 index 000000000..da523ccd0 --- /dev/null +++ b/packages/pro/search/src/searchItem/Segment.tsx @@ -0,0 +1,308 @@ +/** + * @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 Ref, + type WatchStopHandle, + computed, + defineComponent, + inject, + nextTick, + normalizeClass, + normalizeStyle, + onBeforeUnmount, + onMounted, + ref, + watch, +} from 'vue' + +import { useResizeObserver } from '@idux/cdk/resize' +import { convertArray, convertCssPixel, 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' + +export default defineComponent({ + props: segmentProps, + setup(props: SegmentProps) { + const context = inject(proSearchContext)! + const { mergedPrefixCls, commonOverlayProps, activeSegment } = context + const overlayRef = ref<ɵOverlayInstance>() + const inputRef = ref() + const measureSpanRef = ref() + const [overlayOpened, setOverlayOpened] = useState(false) + + const { + triggerOverlayUpdate, + registerOverlayUpdate, + unregisterOverlayUpdate, + + handleSegmentInput, + handleSegmentChange, + handleSegmentConfirm, + handleSegmentCancel, + } = inject(searchItemContext)! + + const isActive = computed( + () => activeSegment.value?.itemKey === props.itemKey && activeSegment.value.name === props.segment.name, + ) + + const inputWidth = useInputWidth(measureSpanRef) + const inputStyle = computed(() => { + return normalizeStyle({ + minWidth: props.disabled ? '0' : undefined, + width: convertCssPixel(inputWidth.value), + }) + }) + const inputClasses = computed(() => [ + `${mergedPrefixCls.value}-segment-input`, + ...convertArray(props.segment.inputClassName), + ]) + + const updateOverlay = () => { + nextTick(() => isActive.value && overlayRef.value?.updatePopper()) + } + watch(inputStyle, triggerOverlayUpdate) + + const classes = computed(() => { + const prefixCls = `${mergedPrefixCls.value}-segment` + + return normalizeClass({ + [prefixCls]: true, + [`${prefixCls}-disabled`]: !!props.disabled, + }) + }) + + let stopActiveSegmentWatch: WatchStopHandle + onMounted(() => { + stopActiveSegmentWatch = watch( + isActive, + (active, preActive) => { + if (active) { + nextTick(() => inputRef.value?.focus()) + setOverlayOpened(true) + nextTick(() => { + if (!props.value && props.segment.defaultValue) { + handleSegmentChange(props.segment.name, props.segment.defaultValue) + handleSegmentConfirm(props.segment.name, false) + } + }) + } else if (preActive) { + nextTick(() => inputRef.value?.blur()) + setOverlayOpened(false) + } + }, + { immediate: true }, + ) + registerOverlayUpdate(updateOverlay) + }) + onBeforeUnmount(() => { + stopActiveSegmentWatch?.() + unregisterOverlayUpdate(updateOverlay) + }) + + const handleChange = (value: unknown) => { + handleSegmentChange(props.segment.name, value) + } + const handleConfirm = () => { + handleSegmentConfirm(props.segment.name, true) + } + const handleCancel = () => { + setOverlayOpened(false) + handleSegmentCancel(props.segment.name) + } + + const { + handleInput, + handleCompositionStart, + handleCompositionEnd, + handleClick, + handleFocus, + handleKeyDown, + setPanelOnKeyDown, + } = useInputEvents(props, context, handleSegmentInput, setOverlayOpened, handleConfirm) + + const overlayProps = useOverlayAttrs(props, mergedPrefixCls, commonOverlayProps, overlayOpened) + + const renderTrigger = () => ( + + ) + const renderContent = () => + props.segment.panelRenderer?.({ + input: props.input ?? '', + value: props.value, + cancel: handleCancel, + ok: handleConfirm, + setValue: handleChange, + setOnKeyDown: setPanelOnKeyDown, + }) + + return () => { + const { panelRenderer } = props.segment + const prefixCls = `${mergedPrefixCls.value}-segment` + + return ( + + {panelRenderer ? ( + <ɵOverlay + ref={overlayRef} + v-slots={{ default: renderTrigger, content: renderContent }} + tabindex={-1} + {...overlayProps.value} + /> + ) : ( + renderTrigger() + )} + + {props.input ?? ''} + + + ) + } + }, +}) + +function useInputWidth(measureSpanRef: Ref): ComputedRef { + const [spanWidth, setSpanWidth] = useState(0) + + useResizeObserver(measureSpanRef, ({ contentRect }) => { + setSpanWidth(contentRect.width) + }) + + return spanWidth +} + +function useOverlayAttrs( + props: SegmentProps, + mergedPrefixCls: ComputedRef, + commonOverlayProps: ComputedRef<ɵOverlayProps>, + overlayOpened: ComputedRef, +): ComputedRef<ɵOverlayProps> { + return computed(() => ({ + ...commonOverlayProps.value, + class: `${mergedPrefixCls.value}-segment-overlay`, + trigger: 'manual', + visible: overlayOpened.value, + disabled: props.disabled, + })) +} + +interface InputEventHandlers { + handleInput: (evt: Event) => void + handleCompositionStart: () => void + handleCompositionEnd: (evt: CompositionEvent) => void + handleClick: (evt: Event) => void + handleFocus: () => void + handleKeyDown: (evt: KeyboardEvent) => void + setPanelOnKeyDown: (onKeyDown: ((evt: KeyboardEvent) => boolean) | undefined) => void +} + +function useInputEvents( + props: SegmentProps, + context: ProSearchContext, + handleSegmentInput: (name: string, input: string) => void, + setOverlayOpened: (opened: boolean) => void, + confirm: () => void, +): InputEventHandlers { + const { searchStates, removeSearchState, setActiveSegment, changeActive } = context + const [panelOnKeyDown, setPanelOnKeyDown] = useState<((evt: KeyboardEvent) => boolean) | undefined>(undefined) + + const isComposing = ref(false) + + function setCurrentAsActive() { + setActiveSegment({ + itemKey: props.itemKey, + name: props.segment.name, + }) + } + function removePreviousState() { + const currentStateIndex = searchStates.value.findIndex(state => state.key === props.itemKey) + const previousState = searchStates.value[currentStateIndex - 1] + + if (previousState.key) { + removeSearchState(previousState.key) + } + } + + const handleInput = (evt: Event) => { + if (!isComposing.value) { + handleSegmentInput(props.segment.name, (evt.target as HTMLInputElement).value) + } + + setOverlayOpened(true) + } + const handleCompositionStart = () => { + isComposing.value = true + } + const handleCompositionEnd = (evt: CompositionEvent) => { + if (isComposing.value) { + isComposing.value = false + handleInput(evt) + } + } + const handleFocus = () => { + setOverlayOpened(true) + setCurrentAsActive() + } + const handleClick = (evt: Event) => { + evt.stopPropagation() + + setOverlayOpened(true) + setCurrentAsActive() + } + const handleKeyDown = (evt: KeyboardEvent) => { + const paneKeyDownRes = panelOnKeyDown.value?.(evt) ?? true + if (!paneKeyDownRes) { + return + } + + switch (evt.key) { + case 'Enter': + evt.preventDefault() + confirm() + break + case 'Backspace': + if (!props.input) { + evt.preventDefault() + if (props.itemKey === tempSearchStateKey && props.segment.name === 'name') { + removePreviousState() + break + } + + changeActive(-1, true) + } + break + default: + break + } + } + + return { + handleInput, + handleCompositionStart, + handleCompositionEnd, + handleFocus, + handleClick, + handleKeyDown, + setPanelOnKeyDown, + } +} diff --git a/packages/pro/search/src/segments/CreateDatePickerSegment.tsx b/packages/pro/search/src/segments/CreateDatePickerSegment.tsx new file mode 100644 index 000000000..b140eb88f --- /dev/null +++ b/packages/pro/search/src/segments/CreateDatePickerSegment.tsx @@ -0,0 +1,77 @@ +/** + * @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 { DatePickerSearchField, PanelRenderContext, Segment } from '../types' +import type { DateConfig } from '@idux/components/config' + +import DatePickerPanel from '../panel/DatePickerPanel' + +const defaultFormat = { + date: 'yyyy-MM-dd', + week: 'RRRR-II', + month: 'yyyy-MM', + quarter: "yyyy-'Q'Q", + year: 'yyyy', + datetime: 'yyyy-MM-dd HH:mm:ss', +} as const +const defaultType = 'date' + +export function createDatePickerSegment( + prefixCls: string, + searchField: DatePickerSearchField, + dateConfig: DateConfig, +): Segment { + const { + fieldConfig: { type, cellTooltip, disabledDate, timePanelOptions }, + defaultValue, + inputClassName, + } = searchField + + const panelRenderer = (context: PanelRenderContext) => { + const { value, setValue, cancel, ok } = context + + return ( + void) | undefined} + onConfirm={ok} + onCancel={cancel} + /> + ) + } + + return { + name: searchField.type, + inputClassName: [inputClassName, `${prefixCls}-date-picker-segment-input`], + defaultValue, + parse: input => parseInput(input, dateConfig, searchField), + format: value => formatValue(value, dateConfig, searchField), + panelRenderer, + } +} + +function parseInput(input: string, dateConfig: DateConfig, searchField: DatePickerSearchField): Date | undefined { + const { + fieldConfig: { format, type }, + } = searchField + const _format = format ?? defaultFormat[type ?? defaultType] + const date = dateConfig.parse(input, _format) + return dateConfig.isValid(date) ? date : undefined +} + +function formatValue(value: Date | undefined, dateConfig: DateConfig, searchField: DatePickerSearchField): string { + const { + fieldConfig: { format, type }, + } = searchField + const _format = format ?? defaultFormat[type ?? defaultType] + return value ? dateConfig.format(value, _format) : '' +} diff --git a/packages/pro/search/src/segments/CreateDateRangePickerSegment.tsx b/packages/pro/search/src/segments/CreateDateRangePickerSegment.tsx new file mode 100644 index 000000000..989ea29db --- /dev/null +++ b/packages/pro/search/src/segments/CreateDateRangePickerSegment.tsx @@ -0,0 +1,96 @@ +/** + * @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 { DateRangePickerSearchField, PanelRenderContext, Segment } from '../types' +import type { DateConfig } from '@idux/components/config' + +import DatePickerPanel from '../panel/DatePickerPanel' + +const defaultSeparator = '~' +const defaultFormat = { + date: 'yyyy-MM-dd', + week: 'RRRR-II', + month: 'yyyy-MM', + quarter: "yyyy-'Q'Q", + year: 'yyyy', + datetime: 'yyyy-MM-dd HH:mm:ss', +} as const +const defaultType = 'date' + +export function createDateRangePickerSegment( + prefixCls: string, + searchField: DateRangePickerSearchField, + dateConfig: DateConfig, +): Segment<(Date | undefined)[] | undefined> { + const { + fieldConfig: { type, cellTooltip, disabledDate, timePanelOptions }, + defaultValue, + inputClassName, + } = searchField + + const panelRenderer = (context: PanelRenderContext<(Date | undefined)[] | undefined>) => { + const { value, setValue, cancel, ok } = context + + return ( + void) | undefined} + onConfirm={ok} + onCancel={cancel} + /> + ) + } + + return { + name: searchField.type, + inputClassName: [inputClassName, `${prefixCls}-date-range-picker-segment-input`], + defaultValue, + parse: input => parseInput(input, dateConfig, searchField), + format: value => formatValue(value, dateConfig, searchField), + panelRenderer, + } +} + +function parseInput( + input: string, + dateConfig: DateConfig, + searchField: DateRangePickerSearchField, +): (Date | undefined)[] | undefined { + const { + fieldConfig: { type, format, separator }, + } = searchField + const _format = format ?? defaultFormat[type ?? defaultType] + const _separator = separator ?? defaultSeparator + + const [fromStr, toStr] = input.split(_separator) + + const dates = [fromStr, toStr].map(str => { + const date = str && dateConfig.parse(str.trim(), _format) + return date && dateConfig.isValid(date) ? date : undefined + }) + + return dates && dates.every(date => !!date) ? dates : undefined +} + +function formatValue( + value: (Date | undefined)[] | undefined, + dateConfig: DateConfig, + searchField: DateRangePickerSearchField, +): string { + const { + fieldConfig: { type, format, separator }, + } = searchField + const _format = format ?? defaultFormat[type ?? defaultType] + const _separator = separator ?? defaultSeparator + + return value ? value.map(v => v && dateConfig.format(v, _format)).join(` ${_separator} `) : '' +} diff --git a/packages/pro/search/src/segments/CreateNameSegment.tsx b/packages/pro/search/src/segments/CreateNameSegment.tsx new file mode 100644 index 000000000..f498b63ce --- /dev/null +++ b/packages/pro/search/src/segments/CreateNameSegment.tsx @@ -0,0 +1,73 @@ +/** + * @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 { PanelRenderContext, SearchField, Segment } from '../types' + +import { type VKey, convertArray } from '@idux/cdk/utils' + +import SelectPanel from '../panel/SelectPanel' +import { filterSelectDataSourceByInput } from '../utils/selectData' + +export const defaultNameSegmentEndSymbol = ':' + +export function createNameSegment( + prefixCls: string, + searchFields: SearchField[] | undefined, + showEndSymbol: boolean, +): Segment { + const names = getSearchOptionNameList(searchFields ?? []) + const panelRenderer = (context: PanelRenderContext) => { + const { input, value, setValue, ok, setOnKeyDown } = context + const handleChange = (value: VKey[]) => { + setValue(value[0]) + ok() + } + return ( + + ) + } + + return { + name: 'name', + inputClassName: `${prefixCls}-name-segment-input`, + parse: input => parseInput(input, names), + format: value => formatValue(value, names, showEndSymbol), + panelRenderer, + } +} + +function getRawInput(input: string): string { + return input.trim().replace(new RegExp(`${defaultNameSegmentEndSymbol}$`), '') +} + +function getSearchOptionNameList(dataSource: SearchField[]): { key: VKey; label: string }[] { + return dataSource.map(data => ({ key: data.key, label: data.label })) +} + +function parseInput(input: string, names: { key: VKey; label: string }[]): VKey | undefined { + const name = getRawInput(input) + + return names.find(item => item.label === name)?.key +} + +function formatValue(value: VKey | undefined, names: { key: VKey; label: string }[], showEndSymbol: boolean): string { + if (!value) { + return '' + } + + const name = names.find(item => item.key === value)?.label ?? '' + + return name && showEndSymbol ? name + defaultNameSegmentEndSymbol : name +} diff --git a/packages/pro/search/src/segments/CreateOperatorSegment.tsx b/packages/pro/search/src/segments/CreateOperatorSegment.tsx new file mode 100644 index 000000000..8fac5bcfc --- /dev/null +++ b/packages/pro/search/src/segments/CreateOperatorSegment.tsx @@ -0,0 +1,53 @@ +/** + * @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 { PanelRenderContext, SearchField, Segment } from '../types' + +import { type VKey, convertArray } from '@idux/cdk/utils' + +import SelectPanel from '../panel/SelectPanel' + +export function createOperatorSegment( + prefixCls: string, + searchField: SearchField, +): Segment | undefined { + if (!searchField.operators || searchField.operators.length <= 0) { + return + } + + const panelRenderer = (context: PanelRenderContext) => { + const { value, setValue, ok, setOnKeyDown } = context + const handleChange = (value: string[]) => { + setValue(value[0]) + ok() + } + return ( + ({ key: operator, label: operator }))} + multiple={false} + onChange={handleChange as (value: VKey[]) => void} + onConfirm={ok} + setOnKeyDown={setOnKeyDown} + /> + ) + } + + return { + name: 'operator', + inputClassName: `${prefixCls}-operator-segment-input`, + defaultValue: searchField.operators.find(op => op === searchField.defaultOperator), + parse: input => parseInput(input, searchField), + format: value => value ?? '', + panelRenderer, + } +} + +function parseInput(input: string, searchField: SearchField): string | undefined { + return input.match(new RegExp(`^(${searchField.operators?.join('|')})`))?.[1] +} diff --git a/packages/pro/search/src/segments/CreateSelectSegment.tsx b/packages/pro/search/src/segments/CreateSelectSegment.tsx new file mode 100644 index 000000000..6789dec16 --- /dev/null +++ b/packages/pro/search/src/segments/CreateSelectSegment.tsx @@ -0,0 +1,123 @@ +/** + * @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 { PanelRenderContext, Segment, SelectPanelData, SelectSearchField } from '../types' + +import { toString } from 'lodash-es' + +import { type VKey, convertArray } from '@idux/cdk/utils' + +import SelectPanel from '../panel/SelectPanel' +import { + filterDataSource, + filterSelectDataSourceByInput, + findDataSourceItem, + getSelectDataSourceKeys, +} from '../utils/selectData' + +const defaultSeparator = '|' + +export function createSelectSegment( + prefixCls: string, + searchField: SelectSearchField, +): Segment { + const { + fieldConfig: { dataSource, separator, searchable, searchFn, multiple, virtual }, + defaultValue, + inputClassName, + } = searchField + + const panelRenderer = (context: PanelRenderContext) => { + const { input, value, setValue, ok, cancel, setOnKeyDown } = context + + const panelValue = convertArray(value) + const keys = getSelectDataSourceKeys(dataSource) + const lastInputPart = input + .trim() + .split(separator ?? defaultSeparator) + .pop() + ?.trim() + + const panelDataSource = + searchable && !findDataSourceItem(dataSource, item => item.label === lastInputPart) + ? filterSelectDataSourceByInput(dataSource, lastInputPart, searchFn) + : dataSource + + const handleChange = (value: VKey[]) => { + if (!multiple) { + setValue(value[0]) + ok() + } else { + setValue(value.length > 0 ? value : undefined) + } + } + const handleSelectAll = (selectAll: boolean) => { + setValue(selectAll ? keys : []) + } + + return ( + + ) + } + + return { + name: searchField.type, + inputClassName: [inputClassName, `${prefixCls}-select-segment-input`], + defaultValue, + parse: input => parseInput(input, searchField), + format: value => formatValue(value, searchField), + panelRenderer, + } +} + +function parseInput(input: string, searchField: SelectSearchField): VKey | VKey[] | undefined { + const { separator, dataSource, multiple } = searchField.fieldConfig + const trimedInput = input.trim() + + const keys = getKeyByLabels(dataSource, trimedInput.split(separator ?? defaultSeparator)) + + return multiple ? (keys.length > 0 ? keys : undefined) : keys[0] +} + +function formatValue(value: VKey | VKey[] | undefined, searchField: SelectSearchField): string { + const { dataSource, separator } = searchField.fieldConfig + if (!value) { + return '' + } + + return getLabelByKeys(dataSource, convertArray(value)).join(` ${separator ?? defaultSeparator} `) +} + +function getLabelByKeys(dataSource: SelectPanelData[], keys: VKey[]): (string | number)[] { + if (keys.length <= 0) { + return [] + } + + return filterDataSource(dataSource, option => keys.includes(option.key)).map(data => data.label) +} + +function getKeyByLabels(dataSource: SelectPanelData[], labels: string[]): VKey[] { + if (labels.length <= 0) { + return [] + } + + return filterDataSource( + dataSource, + option => labels.findIndex(label => label.trim() === toString(option.label).trim()) > -1, + ).map(data => data.key) +} diff --git a/packages/pro/search/src/segments/createCustomSegment.ts b/packages/pro/search/src/segments/createCustomSegment.ts new file mode 100644 index 000000000..be84a7817 --- /dev/null +++ b/packages/pro/search/src/segments/createCustomSegment.ts @@ -0,0 +1,46 @@ +/** + * @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 { CustomSearchField, PanelRenderContext, Segment } from '../types' +import type { Slots, VNodeChild } from 'vue' + +import { isFunction, isString } from 'lodash-es' + +export function createCustomSegment(prefixCls: string, searchField: CustomSearchField, slots: Slots): Segment { + const { + fieldConfig: { parse, format, customPanel }, + defaultValue, + inputClassName, + } = searchField + const panelRenderer = getPanelRenderer(customPanel, slots) + + /* eslint-disable indent */ + return { + name: 'custom', + inputClassName: [inputClassName, `${prefixCls}-custom-segment-input`], + defaultValue, + parse, + format, + panelRenderer, + } + /* eslint-enable indent */ +} + +function getPanelRenderer( + customPanel: CustomSearchField['fieldConfig']['customPanel'], + slots: Slots, +): ((context: PanelRenderContext) => VNodeChild) | undefined { + if (isFunction(customPanel)) { + return (context: PanelRenderContext) => customPanel(context) + } + + if (isString(customPanel)) { + return (context: PanelRenderContext) => slots[customPanel]?.(context) + } + + return +} diff --git a/packages/pro/search/src/segments/createInputSegment.ts b/packages/pro/search/src/segments/createInputSegment.ts new file mode 100644 index 000000000..f1d710002 --- /dev/null +++ b/packages/pro/search/src/segments/createInputSegment.ts @@ -0,0 +1,25 @@ +/** + * @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 { InputSearchField, Segment } from '../types' + +export function createInputSegment(prefixCls: string, searchField: InputSearchField): Segment { + const { + fieldConfig: { trim }, + defaultValue, + inputClassName, + } = searchField + + return { + name: 'input', + inputClassName: [inputClassName, `${prefixCls}-input-segment-input`], + defaultValue, + parse: input => input, + format: value => (trim ? value?.trim() : value) ?? '', + panel: null, + } +} diff --git a/packages/pro/search/src/token.ts b/packages/pro/search/src/token.ts new file mode 100644 index 000000000..86f7f9a5a --- /dev/null +++ b/packages/pro/search/src/token.ts @@ -0,0 +1,28 @@ +/** + * @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 { ActiveSegmentContext } from './composables/useActiveSegment' +import type { SearchStateContext } from './composables/useSearchStates' +import type { SegmentOverlayUpdateContext } from './composables/useSegmentOverlayUpdate' +import type { SegmentStatesContext } from './composables/useSegmentStates' +import type { ProSearchProps } from './types' +import type { ɵOverlayProps } from '@idux/components/_private/overlay' +import type { ProSearchLocale } from '@idux/pro/locales' +import type { ComputedRef, InjectionKey, Slots } from 'vue' + +export interface ProSearchContext extends SearchStateContext, ActiveSegmentContext { + props: ProSearchProps + slots: Slots + locale: ProSearchLocale + mergedPrefixCls: ComputedRef + commonOverlayProps: ComputedRef<ɵOverlayProps> +} + +export interface SearchItemContext extends SegmentOverlayUpdateContext, SegmentStatesContext {} + +export const proSearchContext: InjectionKey = Symbol('proSearchContext') +export const searchItemContext: InjectionKey = Symbol('searchItemContext') diff --git a/packages/pro/search/src/types.ts b/packages/pro/search/src/types.ts new file mode 100644 index 000000000..431026eaa --- /dev/null +++ b/packages/pro/search/src/types.ts @@ -0,0 +1,229 @@ +/** + * @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, ExtractPublicPropTypes, MaybeArray, VKey } from '@idux/cdk/utils' +import type { DatePanelProps, DateRangePanelProps } from '@idux/components/date-picker' +import type { SelectData } from '@idux/components/select' +import type { DefineComponent, HTMLAttributes, PropType, VNode, VNodeChild } from 'vue' + +import { PortalTargetType } from '@idux/cdk/portal' + +export interface SearchValue { + key: VKey + name?: string + value: V + operator?: string +} + +export interface InvalidSearchValue extends Partial> { + nameInput?: string + operatorInput?: string + valueInput?: string +} + +export interface PanelRenderContext { + input: string + value: V + ok: () => void + cancel: () => void + setValue: (value: V) => void + setOnKeyDown: (onKeyDown: ((evt: KeyboardEvent) => boolean) | undefined) => void +} + +export interface Segment { + name: string + inputClassName: string | (string | undefined)[] + defaultValue?: V + format: InputFormater + parse: InputParser + panelRenderer?: (context: PanelRenderContext) => VNodeChild +} + +export type InputFormater = (value: V) => string +export type InputParser = (input: string) => V | null + +export interface SearchItem { + key: VKey + optionKey?: VKey + segments: Segment[] +} + +interface SearchFieldBase { + key: VKey + label: string + multiple?: boolean + operators?: string[] + defaultOperator?: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + defaultValue?: any + inputClassName?: string +} + +export type SelectPanelData = Required> & SelectData + +export const searchDataTypes = ['select', 'input', 'datePicker', 'dateRangePicker', 'custom'] as const +export type SearchDataTypes = typeof searchDataTypes[number] + +export interface SelectSearchField extends SearchFieldBase { + type: 'select' + fieldConfig: { + dataSource: SelectPanelData[] + multiple?: boolean + searchable?: boolean + separator?: string + virtual?: boolean + searchFn?: (data: SelectPanelData, searchText: string) => boolean + overlayItemWidth?: number + } +} + +export interface InputSearchField extends SearchFieldBase { + type: 'input' + fieldConfig: { + trim?: boolean + } +} + +export interface DatePickerSearchField extends SearchFieldBase { + type: 'datePicker' + fieldConfig: { + format?: string + type?: DatePanelProps['type'] + cellTooltip?: DatePanelProps['cellTooltip'] + disabledDate?: DatePanelProps['disabledDate'] + timePanelOptions?: DatePanelProps['timePanelOptions'] + } +} + +export interface DateRangePickerSearchField extends SearchFieldBase { + type: 'dateRangePicker' + fieldConfig: { + format?: string + separator?: string + type?: DateRangePanelProps['type'] + cellTooltip?: DateRangePanelProps['cellTooltip'] + disabledDate?: DateRangePanelProps['disabledDate'] + timePanelOptions?: DateRangePanelProps['timePanelOptions'] + } +} + +export interface CustomSearchField extends SearchFieldBase { + type: 'custom' + fieldConfig: { + customPanel?: string | ((context: PanelRenderContext) => VNodeChild) + format: InputFormater + parse: InputParser + } +} + +export type SearchField = + | SelectSearchField + | InputSearchField + | DatePickerSearchField + | DateRangePickerSearchField + | CustomSearchField + +export const proSearchProps = { + value: Array as PropType, + clearable: { + type: Boolean, + default: undefined, + }, + clearIcon: [String, Object] as PropType, + maxLabel: { type: [Number, String] as PropType, default: 'responsive' }, + searchIcon: [String, Object] as PropType, + disabled: { + type: Boolean, + default: false, + }, + overlayContainer: { + type: [String, HTMLElement, Function] as PropType, + default: undefined, + }, + placeholder: String, + searchFields: Array as PropType, + + //events + 'onUpdate:value': [Function, Array] as PropType void>>, + onChange: [Function, Array] as PropType< + MaybeArray<(value: SearchValue[] | undefined, oldValue: SearchValue[] | undefined) => void> + >, + onClear: [Function, Array] as PropType void>>, + onItemRemove: [Array, Function] as PropType void>>, + onSearch: [Array, Function] as PropType void>>, + onItemInvalid: [Array, Function] as PropType void>>, +} as const + +export type ProSearchProps = ExtractInnerPropTypes +export type ProSearchPublicProps = ExtractPublicPropTypes +export type ProSearchComponent = DefineComponent< + Omit & ProSearchPublicProps +> +export type ProSearchInstance = InstanceType> + +export const searchItemProps = { + searchItem: { + type: Object as PropType, + required: true, + }, + tagOnly: { + type: Boolean, + default: false, + }, +} +export type SearchItemProps = ExtractInnerPropTypes + +export const segmentProps = { + itemKey: { + type: [String, Number, Symbol] as PropType, + required: true, + }, + input: String, + value: null, + disabled: Boolean, + segment: { + type: Object as PropType, + required: true, + }, +} as const +export type SegmentProps = ExtractInnerPropTypes + +export const searchSelectPanelProps = { + value: { type: Array as PropType, default: undefined }, + dataSource: { type: Array as PropType, default: undefined }, + multiple: { type: Boolean, default: false }, + allSelected: Boolean, + virtual: { type: Boolean, default: false }, + onChange: Function as PropType<(value: VKey[]) => void>, + onSelectAll: Function as PropType<(selectAll: boolean) => void>, + setOnKeyDown: Function as PropType<(onKeyDown: ((evt: KeyboardEvent) => boolean) | undefined) => void>, + onConfirm: Function as PropType<() => void>, + onCancel: Function as PropType<() => void>, +} as const +export type SearchSelectPanelProps = ExtractInnerPropTypes + +export const searchDatePickerPanelProps = { + panelType: { + type: String as PropType<'datePicker' | 'dateRangePicker'>, + required: true, + }, + value: { type: [Date, Array] as PropType, default: undefined }, + cellTooltip: Function as PropType<(cell: { value: Date; disabled: boolean }) => string | void>, + disabledDate: Function as PropType<(date: Date) => boolean>, + defaultOpenValue: [Date, Array] as PropType, + type: { + type: String as PropType, + default: 'date', + }, + timePanelOptions: [Object, Array] as PropType< + DatePanelProps['timePanelOptions'] | DateRangePanelProps['timePanelOptions'] + >, + onChange: Function as PropType<(value: Date | Date[] | undefined) => void>, + onConfirm: Function as PropType<() => void>, + onCancel: Function as PropType<() => void>, +} as const +export type SearchDatePickerPanelProps = ExtractInnerPropTypes diff --git a/packages/pro/search/src/utils/RenderIcon.tsx b/packages/pro/search/src/utils/RenderIcon.tsx new file mode 100644 index 000000000..ed904f52f --- /dev/null +++ b/packages/pro/search/src/utils/RenderIcon.tsx @@ -0,0 +1,20 @@ +/** + * @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 { Slot, VNode, VNodeChild } from 'vue' + +import { isString } from 'lodash-es' + +import { IxIcon } from '@idux/components/icon' + +export function renderIcon(icon: string | VNode, slot?: Slot): VNodeChild { + if (slot) { + return slot() + } + + return isString(icon) ? : icon +} diff --git a/packages/pro/search/src/utils/selectData.ts b/packages/pro/search/src/utils/selectData.ts new file mode 100644 index 000000000..4beb68f98 --- /dev/null +++ b/packages/pro/search/src/utils/selectData.ts @@ -0,0 +1,87 @@ +/** + * @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 { SelectPanelData } from '../types' +import type { VKey } from '@idux/cdk/utils' + +import { isNil } from 'lodash-es' + +export function getSelectDataSourceKeys(dataSource: SelectPanelData[]): VKey[] { + const keys = [] + for (const option of dataSource) { + if (option.children && option.children.length > 0) { + keys.push(...option.children.map((child: SelectPanelData) => child.key)) + } else { + keys.push(option.key) + } + } + + return keys +} +export function findDataSourceItem( + dataSource: SelectPanelData[], + searchFn: (option: SelectPanelData) => boolean, +): SelectPanelData | undefined { + for (const option of dataSource) { + if (option.children) { + for (const child of option.children as SelectPanelData[]) { + if (searchFn(child)) { + return child + } + } + } + + if (searchFn(option)) { + return option + } + } + + return +} +export function filterDataSource( + dataSource: SelectPanelData[], + filterFn: (option: SelectPanelData) => boolean, +): SelectPanelData[] { + const filteredData: SelectPanelData[] = [] + for (const option of dataSource) { + const children = [] + if (option.children) { + for (const child of option.children as SelectPanelData[]) { + filterFn(child) && children.push(child) + } + } + + if (children.length > 0 || filterFn(option)) { + filteredData.push({ + ...option, + children, + }) + } + } + + return filteredData +} + +export function filterSelectDataSourceByInput( + dataSource: SelectPanelData[], + input: string | undefined, + searchFn?: (data: SelectPanelData, searchText: string) => boolean, +): SelectPanelData[] { + if (!input) { + return dataSource + } + + const filterFn = searchFn + ? (option: SelectPanelData) => searchFn(option, input.trim()) + : (option: SelectPanelData) => matchRule(option.label, input.trim()) + + return filterDataSource(dataSource, filterFn) +} + +function matchRule(srcString: string | number | undefined, targetString: string): boolean { + return !isNil(srcString) && String(srcString).toLowerCase().includes(targetString.toLowerCase()) +} diff --git a/packages/pro/search/style/index.less b/packages/pro/search/style/index.less new file mode 100644 index 000000000..68d74db7e --- /dev/null +++ b/packages/pro/search/style/index.less @@ -0,0 +1,265 @@ +@import '../../style/mixins/reset.less'; +@import '../../../components/select/style/index.less'; +@import '../../../components/style/mixins/ellipsis.less'; + +.@{pro-search-prefix} { + .reset-component(); + + display: flex; + font-size: @pro-search-font-size; + + &-input-container { + position: relative; + flex: auto; + width: 0; + padding-right: @pro-search-clear-icon-margin-right + @pro-search-clear-icon-width; + margin-right: -@pro-search-border-width; + border: @pro-search-border-width @pro-search-border-style @pro-search-border-color; + border-top-left-radius: @pro-search-border-radius; + border-bottom-left-radius: @pro-search-border-radius; + background-color: @pro-search-background-color; + cursor: text; + color: @pro-search-color; + } + + @content-height: @pro-search-min-height - @pro-search-border-width * 2; + &-input-content { + display: flex; + flex-wrap: wrap; + width: 100%; + height: auto; + padding: @pro-search-content-padding-vertical @pro-search-content-padding-horizontal; + min-height: @content-height; + 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; + } + &-clear-icon { + position: absolute; + right: 0; + top: 0; + display: flex; + align-items: center; + justify-content: center; + width: @pro-search-clear-icon-width; + height: @pro-search-min-height - @pro-search-border-width * 2; + margin-right: @pro-search-clear-icon-margin-right; + font-size: @pro-search-clear-icon-font-size; + color: @pro-search-clear-icon-color; + cursor: pointer; + } + &-search-button { + z-index: 1; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: @pro-search-search-button-width; + height: @pro-search-min-height; + background-color: @pro-search-search-button-background-color; + color: @pro-search-search-button-color; + font-size: @pro-search-search-button-font-size; + border-top-right-radius: @pro-search-border-radius; + border-bottom-right-radius: @pro-search-border-radius; + cursor: pointer; + } + + &:not(&-disabled):hover &-input-container { + border-color: @pro-search-hover-color; + } + &-focused:not(&-disabled) &-input-container { + border-color: @pro-search-active-color; + box-shadow: @pro-search-active-box-shadow; + } + &-disabled { + .@{pro-search-prefix}-input-container { + background-color: @pro-search-disabled-background-color; + cursor: not-allowed; + } + .@{pro-search-prefix}-search-button { + color: @pro-search-search-button-disabled-color; + background-color: @pro-search-search-button-disabled-background-color; + cursor: not-allowed; + } + + .@{pro-search-prefix}-search-item-tag { + 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; + } + } + + &-search-item { + display: inline-block; + max-width: 100%; + color: @pro-search-color; + + &:not(&-tag) { + 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; + } + } + + &-tag { + position: relative; + display: inline-flex; + 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; + + &-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; + } + } + &:not(&-focused) { + .@{pro-search-prefix}-search-item-tag { + max-width: @pro-search-item-tag-max-width; + } + } + + &-segment { + height: @pro-search-item-height; + 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 { + height: 100%; + max-width: 100%; + outline: none; + } + + &-overlay { + z-index: @pro-search-overlay-zindex; + 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; + } + } + + &-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; + } + } + &-footer { + .panel-footer(); + } + } + &-date-picker-panel { + &-body { + padding: @pro-search-date-picker-panel-body-padding; + } + &-footer { + .panel-footer(); + } + } + + &-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; + } + + &-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; + } + &-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; + } +} diff --git a/packages/pro/search/style/themes/default.less b/packages/pro/search/style/themes/default.less new file mode 100644 index 000000000..9c7c48453 --- /dev/null +++ b/packages/pro/search/style/themes/default.less @@ -0,0 +1,7 @@ +@import '../../../style/themes/default.less'; +@import '../../../../components/form/style/themes/default.variable.less'; +@import '../../../../components/select/style/themes/default.variable.less'; +@import './default.variable.less'; + +@import '../index.less'; + diff --git a/packages/pro/search/style/themes/default.ts b/packages/pro/search/style/themes/default.ts new file mode 100644 index 000000000..c24b01be9 --- /dev/null +++ b/packages/pro/search/style/themes/default.ts @@ -0,0 +1,6 @@ +// style dependencies +import '@idux/components/icon/style/themes/default' +import '@idux/components/select/style/themes/default' +import '@idux/components/date-picker/style/themes/default' + +import './default.less' diff --git a/packages/pro/search/style/themes/default.variable.less b/packages/pro/search/style/themes/default.variable.less new file mode 100644 index 000000000..2faf6ddc8 --- /dev/null +++ b/packages/pro/search/style/themes/default.variable.less @@ -0,0 +1,90 @@ +@pro-search-font-size: @form-font-size-md; +@pro-search-min-height: @height-md; +@pro-search-content-padding-horizontal: @spacing-xs; +@pro-search-content-padding-vertical: @spacing-xs; + +@pro-search-border-width: @form-border-width; +@pro-search-border-style: @form-border-style; +@pro-search-border-color: @form-border-color; +@pro-search-border-radius: @border-radius-sm; + +@pro-search-color: @form-color; +@pro-search-background-color: @form-background-color; +@pro-search-placeholder-color: @form-placeholder-color; +@pro-search-hover-color: @form-hover-color; +@pro-search-active-color: @form-active-color; +@pro-search-active-box-shadow: @form-active-box-shadow; +@pro-search-disabled-color: @form-disabled-color; +@pro-search-disabled-background-color: @form-disabled-background-color; + +@pro-search-placeholder-color: @color-graphite; +@pro-search-placeholder-padding-horizontal: 12px; + +@pro-search-clear-icon-font-size: @font-size-lg; +@pro-search-clear-icon-width: @font-size-lg; +@pro-search-clear-icon-margin-right: @spacing-sm; +@pro-search-clear-icon-color: @color-graphite-d20; + +@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-search-button-width: @pro-search-min-height; +@pro-search-search-button-background-color: @color-primary; +@pro-search-search-button-font-size: @font-size-lg; +@pro-search-search-button-color: @color-white; +@pro-search-search-button-disabled-background-color: @disabled-color; +@pro-search-search-button-disabled-color: @disabled-bg-color; + +@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-margin-left: @spacing-xs; +@pro-search-item-tag-margin-bottom: @spacing-xs; +@pro-search-item-tag-disabled-border-color: @pro-search-border-color; +@pro-search-item-tag-disabled-background-color: @color-graphite-l40; + +@pro-search-segment-padding-horizontal: @spacing-xs; +@pro-search-segment-margin: @spacing-xs; +@pro-search-segment-border-bottom: 1px solid @color-primary; + +@pro-search-overlay-zindex: @zindex-l4-3; +@pro-search-overlay-padding: 0; +@pro-search-overlay-background-color: @background-color-component; +@pro-search-overlay-border-radius: @border-radius-sm; +@pro-search-overlay-box-shadow: @shadow-bottom-md; + +@pro-search-panel-footer-border-width: 1px; +@pro-search-panel-footer-border-style: solid; +@pro-search-panel-footer-border-color: @color-graphite-l30; +@pro-search-panel-footer-padding-horizontal: 12px; +@pro-search-panel-footer-padding-vertical: 8px; +@pro-search-panel-footer-button-margin: @spacing-sm; + +@pro-search-date-picker-panel-body-padding: @spacing-lg; + +@pro-search-name-segment-panel-min-width: 100px; +@pro-search-operator-segment-panel-min-width: 20px; +@pro-search-select-panel-min-width: 100px; + +@pro-search-name-segment-input-min-width: 60px; +@pro-search-name-segment-input-text-align: start; +@pro-search-operator-segment-input-min-width: 20px; +@pro-search-operator-segment-input-text-align: center; +@pro-search-input-segment-input-min-width: 100px; +@pro-search-input-segment-input-text-align: start; +@pro-search-select-segment-input-min-width: 100px; +@pro-search-select-segment-input-text-align: start; +@pro-search-date-picker-segment-input-min-width: 100px; +@pro-search-date-picker-segment-input-text-align: start; +@pro-search-date-range-picker-segment-input-min-width: 100px; +@pro-search-date-range-picker-segment-input-text-align: start; +@pro-search-custom-segment-input-min-width: 100px; +@pro-search-custom-segment-input-text-align: start; \ No newline at end of file diff --git a/packages/pro/search/style/themes/seer.less b/packages/pro/search/style/themes/seer.less new file mode 100644 index 000000000..eddf3486c --- /dev/null +++ b/packages/pro/search/style/themes/seer.less @@ -0,0 +1,6 @@ +@import '../../../style/themes/seer.less'; +@import '../../../../components/form/style/themes/seer.variable.less'; +@import '../../../../components/select/style/themes/seer.variable.less'; +@import './seer.variable.less'; + +@import '../index.less'; diff --git a/packages/pro/search/style/themes/seer.ts b/packages/pro/search/style/themes/seer.ts new file mode 100644 index 000000000..c3a9f332f --- /dev/null +++ b/packages/pro/search/style/themes/seer.ts @@ -0,0 +1,7 @@ +// style dependencies + +import '@idux/components/icon/style/themes/seer' +import '@idux/components/select/style/themes/seer' +import '@idux/components/date-picker/style/themes/seer' + +import './seer.less' diff --git a/packages/pro/search/style/themes/seer.variable.less b/packages/pro/search/style/themes/seer.variable.less new file mode 100644 index 000000000..498793af1 --- /dev/null +++ b/packages/pro/search/style/themes/seer.variable.less @@ -0,0 +1 @@ +@import './default.variable.less'; diff --git a/packages/pro/seer.less b/packages/pro/seer.less index 5cb327ad5..032766f21 100644 --- a/packages/pro/seer.less +++ b/packages/pro/seer.less @@ -2,3 +2,4 @@ @import './table/style/themes/seer.less'; @import './transfer/style/themes/seer.less'; @import './tree/style/themes/seer.less'; +@import './search/style/themes/seer.less'; diff --git a/packages/pro/style/variable/prefix.less b/packages/pro/style/variable/prefix.less index de8d2c2ee..9499dc23d 100644 --- a/packages/pro/style/variable/prefix.less +++ b/packages/pro/style/variable/prefix.less @@ -6,3 +6,5 @@ @pro-table-prefix: ~'@{idux-pro-prefix}-table'; @pro-transfer-prefix: ~'@{idux-pro-prefix}-transfer'; @pro-tree-prefix: ~'@{idux-pro-prefix}-tree'; + +@pro-search-prefix: ~'@{idux-pro-prefix}-search'; \ No newline at end of file diff --git a/packages/pro/types.d.ts b/packages/pro/types.d.ts index 3a3832dd2..4dad610d9 100644 --- a/packages/pro/types.d.ts +++ b/packages/pro/types.d.ts @@ -6,6 +6,7 @@ */ import type { ProLayoutComponent, ProLayoutSiderTriggerComponent } from '@idux/pro/layout' +import type { ProSearchComponent } from '@idux/pro/search' import type { ProTableComponent, ProTableLayoutToolComponent } from '@idux/pro/table' import type { ProTransferComponent } from '@idux/pro/transfer' import type { ProTreeComponent } from '@idux/pro/tree' @@ -18,6 +19,7 @@ declare module 'vue' { IxProTableLayoutTool: ProTableLayoutToolComponent IxProTransfer: ProTransferComponent IxProTree: ProTreeComponent + IxProSearch: ProSearchComponent } }