diff --git a/packages/components/selector/demo/Basic.md b/packages/components/selector/demo/Basic.md new file mode 100644 index 000000000..817a7ee7c --- /dev/null +++ b/packages/components/selector/demo/Basic.md @@ -0,0 +1,14 @@ +--- +title: + zh: 基本使用 + en: Basic usage +order: 0 +--- + +## zh + +最简单的用法。 + +## en + +The simplest usage. diff --git a/packages/components/selector/demo/Basic.vue b/packages/components/selector/demo/Basic.vue new file mode 100644 index 000000000..534c16edf --- /dev/null +++ b/packages/components/selector/demo/Basic.vue @@ -0,0 +1,145 @@ + + + diff --git a/packages/components/selector/docs/Api.en.md b/packages/components/selector/docs/Api.en.md new file mode 100644 index 000000000..7bfa74377 --- /dev/null +++ b/packages/components/selector/docs/Api.en.md @@ -0,0 +1,20 @@ + +### IxSelector + +#### SelectorProps + +| Name | Description | Type | Default | Global Config | Remark | +| --- | --- | --- | --- | --- | --- | +| - | - | - | - | ✅ | - | + +#### SelectorSlots + +| Name | Description | Parameter Type | Remark | +| --- | --- | --- | --- | +| - | - | - | - | + +#### SelectorMethods + +| Name | Description | Parameter Type | Remark | +| --- | --- | --- | --- | +| - | - | - | - | diff --git a/packages/components/selector/docs/Api.zh.md b/packages/components/selector/docs/Api.zh.md new file mode 100644 index 000000000..5ad07682a --- /dev/null +++ b/packages/components/selector/docs/Api.zh.md @@ -0,0 +1,80 @@ + +### IxSelector + +#### SelectorProps + +| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `allowInput` | 是否允许输入 | `boolean \| 'searchable'` | - | - | 配置为 `'searchable'` 表示可搜索 | +| `autocomplete` | 是否自动补全 | `boolean` | - | - | - | +| `autofocus` | 是否自动聚焦 | `boolean` | - | - | - | +| `borderless` | 是否无边框 | `boolean` | - | - | - | +| `clearable` | 是否可清除 | `boolean` | - | - | - | +| `clearIcon` | 清除图标 | `string \| #clearIcon` | `'close-circle'` | - | - | +| `dataSource` | 选择框数据 | `SelectorData[]` | - | - | - | +| `disabled` | 是否禁用 | `boolean` | - | - | - | +| `focused` | 是否聚焦 | `boolean` | - | - | - | +| `getKey` | 获取数据的唯一标识 | `string \| (data: any) => VKey` | `key` | - | - | +| `labelKey` | 选项 label 的 key | `string` | `label` | - | - | +| `maxLabel` | 最多显示多少个标签 | `number \| 'responsive'` | - | - | 响应式模式会对性能产生损耗 | +| `multiple` | 是否多选 | `boolean` | - | - | - | +| `monitorFocus` | 是否监听内部的focus | `boolean` | `true` | - | 如果不监听,则 `onFocus` 和 `onBlur` 事件不会真正生效 | +| `opened` | 是否处于打开的状态 | `boolean` | `false` | - | 由于选择框主要用于带有下拉面板的场景,该状态是为了配合表现面板打开的状态效果 | +| `placeholder` | 占位符 | `string` | - | - | - | +| `readonly` | 是否是只读 | `boolean` | - | - | - | +| `size` | 设置选择框大小 | `'sm' \| 'md' \| 'lg'` | `md` | - | - | +| `status` | 手动指定校验状态 | `valid \| invalid \| validating` | - | - | - | +| `suffix` | 设置后缀图标 | `string \| #suffix` | `down` | - | - | +| `suffixRotate` | 后缀图标的旋转角度 | `string \| number \| boolean` | - | - | 配置为 `false` 则不会旋转 | +| `onClear` | 清除图标被点击后的回调 | `(evt: MouseEvent) => void` | - | - | - | +| `onFocus` | 获取焦点后的回调 | `(evt: FocusEvent) => void` | - | - | - | +| `onBlur` | 失去焦点后的回调 | `(evt: FocusEvent) => void` | - | - | - | +| `onCompositionStart` | 输入框的 `compositionstart` 事件 | `(evt: CompositionEvent) => void` | - | - | - | +| `onCompositionEnd` | 输入框的 `compositionend` 事件 | `(evt: CompositionEvent) => void` | - | - | - | +| `onInput` | 输入框的 `input` 事件 | `(evt: Event) => void` | - | - | - | +| `onInputValueChange` | 输入的内容变化后的回调事件 | `(value: string) => void` | - | - | 该事件区别于 `onInput` 在于,只会在输入的值变更之后触发,`composition` 阶段不触发 | +| `onItemRemove` | 选项被移除的回调事件 | `(value: any) => void` | - | - | - | + +```ts +interface SelectorData { + disabled?: boolean + key?: K + label?: string | number + value?: any + rawData?: any // 原始数据,不提供则认为数据本身是原始数据,提供给插槽作为参数 + customLabel?: string | ((data: SelectorData) => VNodeChild) + + [key: string]: any +} +``` + +#### SelectorSlots + +| 名称 | 说明 | 参数类型 | 备注 | +| -- | -- | -- | -- | +| `suffix` | 自定义后缀 | - | - | +| `clearIcon` | 自定义清除图标 | - | - | +| `placeholder` | 自定义占位符 | - | - | +| `selectedItem` | 自定义选中项 | `data: SelectedItemProps` | 使用该插槽后`selectedLabel`将无效 | +| `selectedLabel` | 自定义选中的标签 | `data: SelectorData` | | +| `overflowedLabel` | 自定义超出最多显示多少个标签的内容 | `data: SelectOption[]` | 参数为超出的数组 | + +```ts +interface SelectedItemProps { + disabled: boolean + key: VKey + prefixCls: string + removable: boolean + label: string + value: unknown + onRemove: (key: VKey) => void +} +``` + +#### SelectorMethods + +| 名称 | 说明 | 参数类型 | 备注 | +| --- | --- | --- | --- | +| `blur` | 失去焦点 | - | - | +| `focus` | 获取焦点 | - | - | +| `clearInput` | 清除输入 | `() => void` | - | diff --git a/packages/components/selector/docs/Design.zh.md b/packages/components/selector/docs/Design.zh.md new file mode 100644 index 000000000..bbee136b0 --- /dev/null +++ b/packages/components/selector/docs/Design.zh.md @@ -0,0 +1,3 @@ +## 组件定义 + +选择框 diff --git a/packages/components/selector/docs/Index.en.md b/packages/components/selector/docs/Index.en.md new file mode 100644 index 000000000..bae51773e --- /dev/null +++ b/packages/components/selector/docs/Index.en.md @@ -0,0 +1,9 @@ +--- +category: components +type: Data Entry +title: Selector +subtitle: +order: 0 +--- + + diff --git a/packages/components/selector/docs/Index.zh.md b/packages/components/selector/docs/Index.zh.md new file mode 100644 index 000000000..400bb995c --- /dev/null +++ b/packages/components/selector/docs/Index.zh.md @@ -0,0 +1,8 @@ +--- +category: components +type: 数据录入 +title: Selector +subtitle: 选择框 +order: 0 +--- + diff --git a/packages/components/selector/docs/Theme.en.md b/packages/components/selector/docs/Theme.en.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/components/selector/docs/Theme.zh.md b/packages/components/selector/docs/Theme.zh.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/components/selector/index.ts b/packages/components/selector/index.ts new file mode 100644 index 000000000..18086ea99 --- /dev/null +++ b/packages/components/selector/index.ts @@ -0,0 +1,21 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +import type { SelectorComponent } from './src/types' + +import Selector from './src/Selector' + +const IxSelector = Selector as unknown as SelectorComponent + +export { IxSelector } + +export type { + SelectorInstance, + SelectorComponent, + SelectorPublicProps as SelectorProps, + SelectorData, +} from './src/types' diff --git a/packages/components/selector/src/Selector.tsx b/packages/components/selector/src/Selector.tsx new file mode 100644 index 000000000..826a519e5 --- /dev/null +++ b/packages/components/selector/src/Selector.tsx @@ -0,0 +1,252 @@ +/** + * @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 VNodeChild, computed, defineComponent, normalizeClass, provide, ref } from 'vue' + +import { isString } from 'lodash-es' + +import { callEmit } from '@idux/cdk/utils' +import { ɵOverflow } from '@idux/components/_private/overflow' +import { ɵTrigger, type ɵTriggerInstance } from '@idux/components/_private/trigger' +import { useGlobalConfig } from '@idux/components/config' +import { useThemeToken } from '@idux/components/theme' +import { useGetKey } from '@idux/components/utils' + +import { useInputState } from './composables/useInputState' +import Input from './contents/Input' +import Item from './contents/Item' +import { selectorToken } from './token' +import { type SelectorData, selectorProps } from './types' + +export default defineComponent({ + name: 'IxSelector', + props: selectorProps, + setup(props, { expose, slots }) { + const common = useGlobalConfig('common') + const { globalHashId } = useThemeToken() + const getKey = useGetKey(props, { getKey: 'key' }, 'selector') + const value = computed(() => { + return props.dataSource?.map(data => getKey.value(data)) ?? [] + }) + + const triggerRef = ref<ɵTriggerInstance>() + + const mergedPrefixCls = computed(() => `${common.prefixCls}-selector`) + const mergedClearable = computed(() => { + return !props.disabled && !props.readonly && props.clearable && value.value.length > 0 + }) + const mergedSearchable = computed(() => { + return !props.disabled && !props.readonly && props.allowInput === 'searchable' + }) + const mergedSuffix = computed(() => { + return props.suffix ?? (mergedSearchable.value && props.focused ? 'search' : 'down') + }) + const mergedSuffixRotate = computed(() => { + return !mergedSearchable.value ? props.suffixRotate : undefined + }) + const showPlaceholder = computed(() => { + return !props.dataSource?.length && !isComposing.value && !inputValue.value + }) + const mergedLabelKey = computed(() => { + return props.labelKey ?? 'label' + }) + + const { + mirrorRef, + inputRef, + inputValue, + isComposing, + mergedFocused, + handleCompositionStart, + handleCompositionEnd, + handleInput, + clearInput, + handleEnterDown, + handleBlur, + handleFocus, + } = useInputState(props, mergedSearchable) + + const focus = (options?: FocusOptions) => { + if (inputRef.value) { + inputRef.value.focus(options) + } else { + triggerRef.value?.focus(options) + } + } + const blur = () => { + if (inputRef.value) { + inputRef.value.blur() + } else { + triggerRef.value?.blur() + } + } + + expose({ focus, blur, clearInput }) + + const classes = computed(() => { + const { allowInput, borderless, multiple, opened, size, status } = props + const prefixCls = mergedPrefixCls.value + return normalizeClass({ + [globalHashId.value]: !!globalHashId.value, + [prefixCls]: true, + [`${prefixCls}-${size}`]: !!size, + [`${prefixCls}-${status}`]: !!status, + [`${prefixCls}-allow-input`]: allowInput, + [`${prefixCls}-borderless`]: borderless, + [`${prefixCls}-clearable`]: mergedClearable.value, + [`${prefixCls}-disabled`]: props.disabled, + [`${prefixCls}-focused`]: mergedFocused.value, + [`${prefixCls}-multiple`]: multiple, + [`${prefixCls}-opened`]: opened, + [`${prefixCls}-readonly`]: props.readonly, + [`${prefixCls}-searchable`]: mergedSearchable.value, + [`${prefixCls}-single`]: !multiple, + }) + }) + + const handleClear = (evt: MouseEvent) => { + const { disabled, readonly } = props + if (disabled || readonly) { + return + } + evt.stopPropagation() + callEmit(props.onClear, evt) + } + + provide(selectorToken, { + props, + mergedPrefixCls, + mergedSearchable, + mirrorRef, + inputRef, + inputValue, + isComposing, + mergedFocused, + handleCompositionStart, + handleCompositionEnd, + handleInput, + handleEnterDown, + }) + + return () => { + const { + borderless, + clearable, + clearIcon, + multiple, + disabled, + focused, + readonly, + dataSource, + maxLabel, + size, + status, + } = props + const prefixCls = mergedPrefixCls.value + const itemPrefixCls = `${prefixCls}-item` + + const renderItem = (item: SelectorData) => { + const { value, rawData } = item + const key = getKey.value(item) + const label = item[mergedLabelKey.value] + const _disabled = disabled || item.disabled + const removable = multiple && !_disabled && !readonly + const itemProps = { + key, + disabled: _disabled, + prefixCls: itemPrefixCls, + removable, + value: value ?? key, + label, + onRemove: props.onItemRemove, + } + + const selectedItemSlot = slots.selectedItem + if (selectedItemSlot) { + return selectedItemSlot(itemProps) + } + const slotOrName = slots.selectedLabel || slots.label || item?.customLabel || rawData?.customLabel + const selectedLabelRender = isString(slotOrName) ? slots[slotOrName] : slotOrName + const labelNode = selectedLabelRender ? selectedLabelRender(rawData ?? item) : label + return {labelNode} + } + + const children: VNodeChild[] = [] + + if (showPlaceholder.value) { + const placeholderNode = slots.placeholder ? slots.placeholder() : props.placeholder + children.push( +
+ {placeholderNode} +
, + ) + } + + if (multiple) { + const renderRest = (rest: unknown[]) => { + const key = '__IDUX_SELECT_MAX_ITEM' + const itemProps = { + key, + prefixCls: itemPrefixCls, + removable: false, + } + const overflowedLabelSlot = slots.overflowedLabel || slots.maxLabel + const labelNode = overflowedLabelSlot ? overflowedLabelSlot(rest) : `+ ${rest.length} ...` + return {labelNode} + } + const overflowSlot = { + item: renderItem, + rest: renderRest, + suffix: () => , + } + children.push( + <ɵOverflow + v-slots={overflowSlot} + prefixCls={prefixCls} + dataSource={dataSource} + getKey={getKey.value} + maxLabel={maxLabel} + />, + ) + } else { + if (dataSource?.length && !isComposing.value && !inputValue.value) { + dataSource.forEach(item => children.push(renderItem(item))) + } + children.push() + } + + return ( + <ɵTrigger + ref={triggerRef} + class={classes.value} + borderless={borderless} + clearable={clearable} + clearIcon={clearIcon} + disabled={disabled} + focused={focused} + monitorFocus={props.monitorFocus} + paddingless={multiple} + readonly={readonly} + size={size} + status={status} + suffix={mergedSuffix.value} + suffixRotate={mergedSuffixRotate.value} + value={value.value} + onClear={handleClear} + onFocus={handleFocus} + onBlur={handleBlur} + v-slots={{ + suffix: slots.suffix, + clearIcon: slots.clearIcon, + }} + > +
{children}
+ + ) + } + }, +}) diff --git a/packages/components/selector/src/composables/useInputState.ts b/packages/components/selector/src/composables/useInputState.ts new file mode 100644 index 000000000..2c3e8c7b8 --- /dev/null +++ b/packages/components/selector/src/composables/useInputState.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 + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { type ComputedRef, type Ref, computed, nextTick, onMounted, ref, watch } from 'vue' + +import { callEmit, useControlledProp } from '@idux/cdk/utils' + +import { type SelectorProps } from '../types' + +export interface InputStateContext { + mirrorRef: Ref + inputRef: Ref + inputValue: Ref + isComposing: Ref + mergedFocused: ComputedRef + handleCompositionStart: (evt: CompositionEvent) => void + handleCompositionEnd: (evt: CompositionEvent) => void + handleInput: (evt: Event) => void + clearInput: () => void + handleEnterDown: (evt: KeyboardEvent) => void + handleFocus: (evt: FocusEvent) => void + handleBlur: (evt: FocusEvent) => void +} + +export function useInputState(props: SelectorProps, mergedSearchable: ComputedRef): InputStateContext { + const mirrorRef = ref() + const inputRef = ref() + const [inputValue, setInputValue] = useControlledProp(props, 'input', '') + const isComposing = ref(false) + const focused = ref(false) + const mergedFocused = computed(() => props.focused ?? focused.value) + + const handleFocus = (evt: FocusEvent) => { + focused.value = true + callEmit(props.onFocus, evt) + + nextTick(() => { + inputRef.value?.focus() + }) + } + + const handleBlur = (evt: FocusEvent) => { + focused.value = false + callEmit(props.onBlur, evt) + } + + const syncMirrorWidth = (evt?: Event) => { + if (props.multiple) { + const mirrorElement = mirrorRef.value + if (!mirrorElement) { + return + } + const inputText = evt ? (evt.target as HTMLInputElement).value : inputRef.value!.value + mirrorElement.textContent = inputText + inputRef.value!.style.width = `${mirrorElement.offsetWidth}px` + } + } + + const handleCompositionStart = (evt: CompositionEvent) => { + isComposing.value = true + callEmit(props.onCompositionStart, evt) + } + + const handleCompositionEnd = (evt: CompositionEvent) => { + callEmit(props.onCompositionEnd, evt) + if (isComposing.value) { + isComposing.value = false + handleInput(evt, false) + } + } + + // 处理中文输入法下的回车无法触发 compositionEnd 事件的问题 + const handleEnterDown = (evt: KeyboardEvent) => { + if (evt.code === 'Enter' && isComposing.value) { + evt.stopImmediatePropagation() + handleCompositionEnd(evt as any) + } + } + + const handleInput = (evt: Event, emitInput = true) => { + emitInput && callEmit(props.onInput, evt) + + const inputEnabled = props.allowInput || mergedSearchable.value + + if (isComposing.value) { + inputEnabled && syncMirrorWidth(evt) + return + } + + if (inputEnabled) { + const { value } = evt.target as HTMLInputElement + if (value !== inputValue.value) { + setInputValue(value) + callEmit(props.onInputValueChange, value) + } + syncMirrorWidth() + } + } + + const clearInput = () => { + const inputElement = inputRef.value + if (inputElement) { + inputElement.value = '' + } + setInputValue('') + callEmit(props.onInputValueChange, '') + syncMirrorWidth() + } + + onMounted(() => syncMirrorWidth()) + watch(mirrorRef, val => { + if (val) { + syncMirrorWidth() + } + }) + + return { + inputRef, + mirrorRef, + inputValue, + isComposing, + mergedFocused, + handleCompositionStart, + handleCompositionEnd, + handleInput, + clearInput, + handleEnterDown, + handleFocus, + handleBlur, + } +} diff --git a/packages/components/selector/src/contents/Input.tsx b/packages/components/selector/src/contents/Input.tsx new file mode 100644 index 000000000..d4ea6c624 --- /dev/null +++ b/packages/components/selector/src/contents/Input.tsx @@ -0,0 +1,59 @@ +/** + * @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 } from 'vue' + +import { selectorToken } from '../token' + +export default defineComponent({ + setup() { + const { + props, + mergedPrefixCls, + mergedSearchable, + mergedFocused, + mirrorRef, + inputRef, + inputValue, + handleCompositionStart, + handleCompositionEnd, + handleInput, + handleEnterDown, + } = inject(selectorToken)! + + const inputReadonly = computed( + () => props.readonly || !mergedFocused.value || !(props.allowInput || mergedSearchable.value), + ) + const innerStyle = computed(() => { + return { opacity: inputReadonly.value ? 0 : undefined } + }) + + return () => { + const { autocomplete, autofocus, disabled, multiple } = props + const prefixCls = `${mergedPrefixCls.value}-input` + return ( +
+ + {multiple && } +
+ ) + } + }, +}) diff --git a/packages/components/selector/src/contents/Item.tsx b/packages/components/selector/src/contents/Item.tsx new file mode 100644 index 000000000..bfd90cd74 --- /dev/null +++ b/packages/components/selector/src/contents/Item.tsx @@ -0,0 +1,45 @@ +/** + * @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 + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { FunctionalComponent } from 'vue' + +import { type MaybeArray, callEmit } from '@idux/cdk/utils' +import { IxIcon } from '@idux/components/icon' + +interface ItemProps { + disabled?: boolean + prefixCls: string + removable?: boolean + value?: unknown + onRemove?: MaybeArray<(value: unknown) => void> +} + +const Item: FunctionalComponent = (props, { slots }) => { + const { disabled, prefixCls, removable, value, onRemove } = props + + const classes = prefixCls + (disabled ? ` ${prefixCls}-disabled` : '') + + const handleClick = (evt: Event) => { + evt.stopPropagation() + callEmit(onRemove, value) + } + + return ( +
+ {slots.default!()} + {removable && ( + + + + )} +
+ ) +} + +export default Item diff --git a/packages/components/selector/src/token.ts b/packages/components/selector/src/token.ts new file mode 100644 index 000000000..a5f80edba --- /dev/null +++ b/packages/components/selector/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 + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { SelectorProps } from './types' +import type { ComputedRef, InjectionKey, Ref } from 'vue' + +export interface SelectorContext { + props: SelectorProps + mergedPrefixCls: ComputedRef + mergedSearchable: ComputedRef + mirrorRef: Ref + inputRef: Ref + inputValue: Ref + isComposing: Ref + mergedFocused: ComputedRef + handleCompositionStart: (evt: CompositionEvent) => void + handleCompositionEnd: (evt: CompositionEvent) => void + handleInput: (evt: Event) => void + handleEnterDown: (evt: KeyboardEvent) => void +} + +export const selectorToken: InjectionKey = Symbol('selectorToken') diff --git a/packages/components/selector/src/types.ts b/packages/components/selector/src/types.ts new file mode 100644 index 000000000..8de726c7d --- /dev/null +++ b/packages/components/selector/src/types.ts @@ -0,0 +1,74 @@ +/** + * @license + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { ValidateStatus } from '@idux/cdk/forms' +import type { ExtractInnerPropTypes, ExtractPublicPropTypes, MaybeArray, VKey } from '@idux/cdk/utils' +import type { FormSize } from '@idux/components/form' +import type { GetKeyFn } from '@idux/components/utils' +import type { DefineComponent, HTMLAttributes, PropType, VNodeChild } from 'vue' + +export interface SelectorData { + disabled?: boolean + key?: K + label?: string | number + value?: any + rawData?: any + customLabel?: string | ((data: SelectorData) => VNodeChild) + + [key: string]: any +} + +export const selectorProps = { + input: String, + allowInput: { type: [Boolean, String] as PropType, default: undefined }, + autocomplete: { type: String, default: undefined }, + autofocus: { type: Boolean, default: undefined }, + borderless: { type: Boolean, default: undefined }, + clearable: { type: Boolean, default: undefined }, + clearIcon: { type: String, default: 'close-circle' }, + dataSource: Array as PropType, + disabled: { type: Boolean, default: undefined }, + focused: { type: Boolean, default: undefined }, + getKey: { type: [String, Function] as PropType, default: 'key' }, + labelKey: { type: String, default: undefined }, + maxLabel: { type: [Number, String] as PropType, default: undefined }, + multiple: { type: Boolean, default: undefined }, + monitorFocus: { type: Boolean, default: true }, + opened: { type: Boolean, default: false }, + placeholder: { type: String, default: undefined }, + readonly: { type: Boolean, default: undefined }, + size: { type: String as PropType, default: 'md' }, + status: String as PropType, + suffix: { type: String, default: undefined }, + suffixRotate: { type: [Boolean, Number, String] as PropType, default: undefined }, + + 'onUpdate:input': [Function, Array] as PropType void>>, + onBlur: [Function, Array] as PropType void>>, + onFocus: [Function, Array] as PropType void>>, + onClear: [Function, Array] as PropType void>>, + onCompositionStart: [Function, Array] as PropType void>>, + onCompositionEnd: [Function, Array] as PropType void>>, + onInput: [Function, Array] as PropType void>>, + onInputValueChange: [Function, Array] as PropType void>>, + onItemRemove: [Function, Array] as PropType void>>, +} as const + +export type SelectorProps = ExtractInnerPropTypes +export type SelectorPublicProps = ExtractPublicPropTypes +export interface SelectorBindings { + blur: () => void + focus: (options?: FocusOptions) => void + clearInput: () => void + getBoundingClientRect: () => DOMRect | undefined +} +export type SelectorComponent = DefineComponent< + Omit & SelectorPublicProps, + SelectorBindings +> +export type SelectorInstance = InstanceType> diff --git a/packages/components/selector/style/index.less b/packages/components/selector/style/index.less new file mode 100644 index 000000000..ff38eca8b --- /dev/null +++ b/packages/components/selector/style/index.less @@ -0,0 +1,63 @@ +@import '../../style/variable/index.less'; +@import '../../style/mixins/borderless.less'; +@import '../../style/mixins/ellipsis.less'; +@import '../../style/mixins/reset.less'; +@import './single.less'; +@import './multiple.less'; + +.@{selector-prefix} { + .reset-component(); + + position: relative; + display: inline-block; + width: 100%; + + &-content { + position: relative; + display: flex; + align-items: center; + transition: all var(--ix-motion-duration-medium) var(--ix-motion-ease-in-out); + cursor: pointer; + } + + &-item { + .ellipsis(); + + user-select: none; + + &-label { + .ellipsis(); + } + } + + &-input { + &-inner { + width: 100%; + min-width: 1px; + margin: 0; + padding: 0; + background: transparent; + border: none; + outline: none; + appearance: none; + cursor: pointer; + } + } + + &-placeholder { + flex: 1; + overflow: hidden; + color: var(--ix-color-text-placeholder); + position: absolute; + .ellipsis(); + } + + &-searchable &-content, + &-allow-input &-content { + cursor: text; + + .@{selector-prefix}-input-inner { + cursor: auto; + } + } +} diff --git a/packages/components/selector/style/index.ts b/packages/components/selector/style/index.ts new file mode 100644 index 000000000..a66eb7949 --- /dev/null +++ b/packages/components/selector/style/index.ts @@ -0,0 +1,6 @@ +// style dependencies + +import '@idux/components/icon/style' +import '@idux/components/input/style' + +import './index.less' diff --git a/packages/components/selector/style/multiple.less b/packages/components/selector/style/multiple.less new file mode 100644 index 000000000..07e16f9ce --- /dev/null +++ b/packages/components/selector/style/multiple.less @@ -0,0 +1,122 @@ +.select-size(@select-height; @select-font-size; @horizontal-padding) { + @select-margin-half: calc((var(--ix-margin-size-xs) / 2)); + @select-padding-vertical: ~'max(calc(var(--ix-margin-size-xs) - var(--ix-control-line-width) - @{select-margin-half}), 0px)'; + @select-item-height: calc(@select-height - var(--ix-margin-size-xs) * 2); + @select-item-line-height: calc(@select-item-height - var(--ix-control-line-width) * 2); + + &.@{selector-prefix} { + font-size: @select-font-size; + } + .@{selector-prefix} { + &-content { + padding: @select-padding-vertical var(--ix-margin-size-xs); + + .@{overflow-prefix}-item { + max-width: calc(100% - var(--ix-font-size-icon)); + } + } + + &-item { + height: @select-item-height; + line-height: @select-item-line-height; + margin: @select-margin-half 0; + margin-inline-end: var(--ix-margin-size-xs); + } + + &-input { + margin: @select-margin-half 0; + margin-inline-start: var(--ix-margin-size-xs); + + &-inner, + &-mirror { + height: @select-item-height; + line-height: @select-item-line-height; + } + } + + &-overflow { + line-height: @select-item-line-height; + } + + &-placeholder { + right: @horizontal-padding; + left: @horizontal-padding; + } + } + + &.@{selector-prefix}-searchable .@{selector-prefix}-overflow, + &.@{selector-prefix}-allow-input .@{selector-prefix}-overflow { + padding-right: calc(var(--ix-font-size-icon) + var(--ix-margin-size-xs)); + } +} + +.@{selector-prefix}-multiple { + .@{selector-prefix} { + &-content { + flex-wrap: wrap; + align-items: center; + + .@{overflow-prefix}-item { + overflow: hidden; + } + } + + &-item { + display: flex; + flex: none; + max-width: 100%; + padding: 0 var(--ix-padding-size-sm) 0 var(--ix-padding-size-sm); + background-color: var(--ix-color-emphasized-container-bg); + border: var(--ix-control-line-width) var(--ix-control-line-type) var(--ix-color-border-inverse); + border-radius: var(--ix-border-radius-sm); + cursor: default; + + &-label { + display: inline-block; + .ellipsis(); + } + + &-remove { + margin: 0 calc(var(--ix-margin-size-xs) * -1) 0 var(--ix-margin-size-xs); + color: var(--ix-color-icon); + font-size: var(--ix-font-size-icon); + line-height: inherit; + cursor: pointer; + + &:hover { + color: var(--ix-color-icon-hover); + } + } + } + + &-input { + position: relative; + max-width: 100%; + + &-mirror { + position: absolute; + top: 0; + left: 0; + white-space: pre; + visibility: hidden; + } + } + } + &.@{selector-prefix}-disabled .@{selector-prefix}-item-disabled { + color: var(--ix-color-text-disabled); + border-color: var(--ix-color-border); + cursor: not-allowed; + } + + &.@{selector-prefix}-sm { + .select-size(var(--ix-control-height-sm), var(--ix-control-font-size-sm), var(--ix-control-padding-size-horizontal-sm)); + } + + &.@{selector-prefix}-md { + .select-size(var(--ix-control-height-md), var(--ix-control-font-size-md), var(--ix-control-padding-size-horizontal-md)); + } + + &.@{selector-prefix}-lg { + .select-size(var(--ix-control-height-lg), var(--ix-control-font-size-lg), var(--ix-control-padding-size-horizontal-lg)); + } +} diff --git a/packages/components/selector/style/single.less b/packages/components/selector/style/single.less new file mode 100644 index 000000000..58a7c86a0 --- /dev/null +++ b/packages/components/selector/style/single.less @@ -0,0 +1,22 @@ +.@{selector-prefix}-single { + .@{selector-prefix} { + &-content { + width: 100%; + display: flex; + } + + &-input { + flex: auto; + width: 0; + margin-right: calc((var(--ix-font-size-icon) / 2) + var(--ix-margin-size-sm)); + } + + &-item { + position: absolute; + } + } + + &.@{selector-prefix}-opened .@{selector-prefix}-item { + color: var(--ix-color-text-placeholder); + } +}