diff --git a/.gitignore b/.gitignore index 8481697f278837bbf5abd341f05e6f6372349a8e..5aa59fce646e10ca856168035ccc04f0cadf3f85 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist dist-ssr *.local package-lock.json +pnpm-lock.yaml .history .vscode devui/vue-devui.ts diff --git a/devui/select/__tests__/select.spec.ts b/devui/select/__tests__/select.spec.ts index d7fcaa9b39297c6d2396525af914a21455b5a150..d724b6fa8e9c406cc5ed0c5f2754069e79378dfe 100644 --- a/devui/select/__tests__/select.spec.ts +++ b/devui/select/__tests__/select.spec.ts @@ -111,4 +111,107 @@ describe('select', () => { expect(valueChange).toBeCalledTimes(1); expect(value.value).toBe('test'); }); + + it('select v-model work', async () => { + const value = ref() + const options = reactive([1,2,3]) + const wrapper = mount({ + components: { DSelect }, + template: ``, + setup() { + return { + value, + options, + }; + }, + }); + + const container = wrapper.find('.devui-select'); + const item = container.findAll('.devui-select-item'); + + await container.trigger('click') + await item[1].trigger('click') + expect(value.value).toBe(2) + value.value = 1 + await nextTick() + const input = container.find('.devui-select-input') + expect(input.element.value).toBe('1') + + }); + + it('select disabled work', async () => { + const wrapper = mount(DSelect, { + props: { + disabled: true, + }, + }); + + const container = wrapper.find('.devui-select'); + expect(container.classes()).toContain('devui-select-disabled'); + + const input = wrapper.find('.devui-select-input'); + expect(input.attributes()).toHaveProperty('disabled') + }); + + it('select item disabled work', async () => { + const value = ref([]) + const options = reactive([ + { + name: '多选', + value: 0 + }, { + name: '多选很重要呢', + value: 1, + disabled: true + }, { + name: '多选真的很重要呢', + value: 2, + disabled: false + } + ]) + const wrapper = mount({ + components: { DSelect }, + template: ``, + setup() { + return { + value, + options, + }; + }, + }); + + const container = wrapper.find('.devui-select'); + const item = container.findAll('.devui-select-item'); + + await container.trigger('click') + await nextTick() + expect(item[1].classes()).toContain('disabled') + await item[1].trigger('click') + expect(value.value).toEqual([]) + await item[0].trigger('click') + expect(value.value).toEqual([0]) + + }); + + it('select clear work', async () => { + const value = ref(1) + const options = reactive([1,2,3]) + const wrapper = mount({ + components: { DSelect }, + template: ``, + setup() { + return { + value, + options, + }; + }, + }); + + const container = wrapper.find('.devui-select'); + const clearIcon = container.find('.devui-select-clear'); + + expect(clearIcon.exists()).toBeTruthy() + await clearIcon.trigger('click') + expect(value.value).toBe('') + }); }); diff --git a/devui/select/hooks/use-cache-options.ts b/devui/select/hooks/use-cache-options.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f27abd3a1869a028478aa58fbff61e5c7e650e3 --- /dev/null +++ b/devui/select/hooks/use-cache-options.ts @@ -0,0 +1,18 @@ +import { ComputedRef, computed } from 'vue'; +import { OptionObjectItem } from '../src/use-select'; +import { KeyType } from '../src/utils'; + +export default function (mergeOptions: ComputedRef): any { + const cacheOptions = computed(() => { + const map = new Map, OptionObjectItem>(); + mergeOptions.value.forEach((item) => { + map.set(item.value, item); + }); + return map; + }); + + const getValuesOption = (values: KeyType[]) => + values.map((value) => cacheOptions.value.get(value)); + + return getValuesOption; +} diff --git a/devui/select/hooks/use-select-outside-click.ts b/devui/select/hooks/use-select-outside-click.ts new file mode 100644 index 0000000000000000000000000000000000000000..463c439e6b81d4d3961e02cb1b61adc7e8d4f68b --- /dev/null +++ b/devui/select/hooks/use-select-outside-click.ts @@ -0,0 +1,32 @@ +import { Ref, onMounted, onBeforeUnmount } from 'vue'; + +export default function ( + refs: Ref[], + isOpen: Ref, + toggleChange: (isOpen: boolean) => void +): void { + function onGlobalMouseDown(e: MouseEvent) { + let target = e.target as HTMLElement; + + // TODO: 需要去了解下shadow DOM + if (target.shadowRoot && e.composed) { + target = (e.composedPath()[0] || target) as HTMLElement; + } + + const element = [refs[0]?.value, refs[1]?.value]; + if ( + isOpen.value && + element.every((el) => el && !el.contains(target) && el !== target) + ) { + toggleChange(false); + } + } + + onMounted(() => { + document.body.addEventListener('mousedown', onGlobalMouseDown, false); + }); + + onBeforeUnmount(() => { + document.body.addEventListener('mousedown', onGlobalMouseDown, false); + }); +} diff --git a/devui/select/src/select.scss b/devui/select/src/select.scss index f520b07dc28a7aadcb23db0fb55822eaea3efde1..e0b0027021ef058308941f2e81bf63e8299a55e8 100644 --- a/devui/select/src/select.scss +++ b/devui/select/src/select.scss @@ -41,14 +41,43 @@ $select-item-min-height: 36px; } } +.devui-select-disabled { + cursor: not-allowed; + background-color: $devui-disabled-bg; + border-color: $devui-disabled-line; + color: $devui-disabled-text; + + .devui-select-input { + cursor: not-allowed; + background-color: $devui-disabled-bg; + border-color: $devui-disabled-line; + color: $devui-disabled-text; + } + + .devui-select-arrow { + cursor: not-allowed; + color: $devui-disabled-text; + } +} + .devui-select-open { .devui-select-arrow { transform: rotate3d(0, 0, 1, 180deg); } } +.devui-dropdown-menu-multiple { + .devui-select-item { + &.active { + color: $devui-list-item-active-text; + background-color: transparent; + } + } +} + .devui-select-selection { position: relative; + cursor: pointer; } .devui-select-input { @@ -93,6 +122,17 @@ $select-item-min-height: 36px; } } +.devui-select-clearable:hover { + .devui-select-clear { + display: inline-flex; + } + + .devui-select-arrow { + display: none; + } +} + +.devui-select-clear, .devui-select-arrow { position: absolute; right: 0; @@ -101,6 +141,17 @@ $select-item-min-height: 36px; display: inline-flex; justify-content: center; align-items: center; +} + +.devui-select-clear { + display: none; + + &:hover { + color: $devui-icon-fill-active; + } +} + +.devui-select-arrow { transform: rotate3d(0, 0, 1, 0deg); transition: transform $transition-base-time ease-out; } @@ -141,7 +192,7 @@ $select-item-min-height: 36px; color: $devui-text; cursor: pointer; - &:hover:not(.active) { + &:hover:not(.active):not(.disabled) { color: $devui-list-item-hover-text; background-color: $devui-list-item-hover-bg; } @@ -150,6 +201,12 @@ $select-item-min-height: 36px; color: $devui-list-item-active-text; background-color: $devui-list-item-active-bg; } + + &.disabled { + cursor: not-allowed; + background-color: $devui-disabled-bg; + color: $devui-disabled-text; + } } .devui-scrollbar { diff --git a/devui/select/src/select.tsx b/devui/select/src/select.tsx index b164187c6c3c1829ea41cdccccea570eca7e255b..008fe96ffc839c0c209e7d71e979b11e35cd3f2b 100644 --- a/devui/select/src/select.tsx +++ b/devui/select/src/select.tsx @@ -1,7 +1,10 @@ -import { defineComponent, ref, Transition, toRefs } from 'vue'; -import { selectProps, SelectProps, OptionItem } from './use-select'; -import DIcon from '../../icon/src/icon'; +import { defineComponent, ref, Transition, computed } from 'vue'; +import { selectProps, SelectProps, OptionObjectItem } from './use-select'; +import { Icon } from '../../icon'; +import { Checkbox } from '../../checkbox'; import { className } from './utils'; +import useCacheOptions from '../hooks/use-cache-options'; +import useSelectOutsideClick from '../hooks/use-select-outside-click'; import './select.scss'; export default defineComponent({ @@ -9,105 +12,193 @@ export default defineComponent({ props: selectProps, emits: ['toggleChange', 'valueChange', 'update:modelValue'], setup(props: SelectProps, ctx) { + const containerRef = ref(null); + const dropdownRef = ref(null); + // 控制弹窗开合 const isOpen = ref(false); function toggleChange(bool: boolean) { + if (props.disabled) return; isOpen.value = bool; ctx.emit('toggleChange', bool); } + useSelectOutsideClick([containerRef, dropdownRef], isOpen, toggleChange); - const inputValue = ref(props.modelValue + ''); - initInputValue(); - - function initInputValue() { - props.options.forEach((item) => { - if (typeof item === 'object' && item.value === props.modelValue) { - inputValue.value = item.name; + // 这里对options做统一处理 + const mergeOptions = computed(() => { + const { multiple, modelValue } = props; + return props.options.map((item) => { + let option: OptionObjectItem; + if (typeof item === 'object') { + option = { + name: item.name ? item.name : item.value + '', + value: item.value, + _checked: false, + ...item, + }; + } else { + option = { + name: item + '', + value: item, + _checked: false, + }; + } + if (multiple) { + /** + * TODO: 这里mergeOptions依赖了modelValue + * 但是下面点击item更新的时候modelValue又是根据mergeOptions来算出来的 + * 因此可能会多更新一次,后续优化 + */ + if (Array.isArray(modelValue)) { + option._checked = modelValue.includes(option.value); + } else { + option._checked = false; + } } + + return option; }); - } + }); + // 缓存options,用value来获取对应的optionItem + const getValuesOption = useCacheOptions(mergeOptions); + // 控制输入框的显示内容 + const inputValue = computed(() => { + if (props.multiple && Array.isArray(props.modelValue)) { + const selectedOptions = getValuesOption(props.modelValue); + return selectedOptions.map((item) => item.name).join(','); + } else if (!Array.isArray(props.modelValue)) { + return getValuesOption([props.modelValue])[0]?.name || ''; + } + return ''; + }); + // 是否可清空 + const mergeClearable = computed(() => { + return !props.disabled && props.allowClear && inputValue.value.length > 0; + }); - function valueChange(item: OptionItem, index: number) { - const value = typeof item === 'object' ? item.value : item; - inputValue.value = getInputValue(item); - ctx.emit('update:modelValue', value); + function valueChange(item: OptionObjectItem, index: number) { + const { multiple, optionDisabledKey: disabledKey } = props; + let { modelValue } = props; + if (disabledKey && !!item[disabledKey]) return; + if (multiple) { + item._checked = !item._checked; + modelValue = mergeOptions.value + .filter((item) => item._checked) + .map((item) => item.value); + ctx.emit('update:modelValue', modelValue); + } else { + ctx.emit('update:modelValue', item.value); + toggleChange(false); + } ctx.emit('valueChange', item, index); - toggleChange(false); } - function getItemClassName(item: OptionItem) { - const value = typeof item === 'object' ? item.value : item; + function getItemClassName(item: OptionObjectItem) { + const { optionDisabledKey: disabledKey } = props; return className('devui-select-item', { - active: value === props.modelValue, + active: item.value === props.modelValue, + disabled: disabledKey ? !!item[disabledKey] : false, }); } - function getInputValue(item: OptionItem) { - const value = typeof item === 'object' ? item.name : item; - return value + ''; + function handleClear(e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + if (props.multiple) { + ctx.emit('update:modelValue', []); + } else { + ctx.emit('update:modelValue', ''); + } } return { isOpen, + containerRef, + dropdownRef, inputValue, + mergeOptions, + mergeClearable, valueChange, toggleChange, getItemClassName, - ...toRefs(props), + handleClear, }; }, render() { const { - options, + mergeOptions, isOpen, inputValue, size, + multiple, + disabled, + optionDisabledKey: disabledKey, placeholder, overview, valueChange, toggleChange, getItemClassName, + mergeClearable, + handleClear, } = this; - const selectClassName = className('devui-select', { + const selectCls = className('devui-select', { 'devui-select-open': isOpen, + 'devui-dropdown-menu-multiple': multiple, 'devui-select-lg': size === 'lg', 'devui-select-sm': size === 'sm', 'devui-select-underlined': overview === 'underlined', + 'devui-select-disabled': disabled, }); - const inputClassName = className('devui-select-input', { + const inputCls = className('devui-select-input', { 'devui-select-input-lg': size === 'lg', 'devui-select-input-sm': size === 'sm', }); + const selectionCls = className('devui-select-selection', { + 'devui-select-clearable': mergeClearable, + }); + return ( -
-
+
+
toggleChange(!isOpen)}> toggleChange(!isOpen)} - onBlur={() => toggleChange(false)} + disabled={disabled} /> + + + - +
- +
    - {options.map((item, i) => ( + {mergeOptions.map((item, i) => (
  • { + onClick={(e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); valueChange(item, i); }} class={getItemClassName(item)} key={i} > - {typeof item === 'object' ? item.name : item} + {multiple ? ( + + ) : ( + item.name + )}
  • ))}
diff --git a/devui/select/src/use-select.ts b/devui/select/src/use-select.ts index 60b40ee1e6ce3d782246a1991d801ece7447056c..a796cab82a9838f58b9fd49dad74b8f6b516cad3 100644 --- a/devui/select/src/use-select.ts +++ b/devui/select/src/use-select.ts @@ -3,18 +3,25 @@ import { PropType, ExtractPropTypes } from 'vue'; export interface OptionObjectItem { name: string value: string | number + _checked: boolean [key: string]: any } -export type OptionItem = number | string | OptionObjectItem; + +export type OptionItem = + | number + | string + | ({ value: string | number; } & Partial); export type Options = Array; +export type ModelValue = number | string | Array; + export const selectProps = { modelValue: { - type: [String, Number] as PropType, + type: [String, Number, Array] as PropType, default: '', }, 'onUpdate:modelValue': { - type: Function as PropType<(val: string | number) => void>, + type: Function as PropType<(val: ModelValue) => void>, default: undefined, }, options: { @@ -33,6 +40,22 @@ export const selectProps = { type: String, default: '请选择', }, + multiple: { + type: Boolean, + default: false, + }, + disabled: { + type: Boolean, + default: false + }, + allowClear: { + type: Boolean, + default: false + }, + optionDisabledKey: { + type: String, + default: '' + }, onToggleChange: { type: Function as PropType<(bool: boolean) => void>, default: undefined, diff --git a/devui/select/src/utils.ts b/devui/select/src/utils.ts index 3092da7789a04ffc4321311f268cccd1b802bb9b..1bab126858801e0c3372f6cd5ac7eb437ebebb26 100644 --- a/devui/select/src/utils.ts +++ b/devui/select/src/utils.ts @@ -17,3 +17,5 @@ export function className( return classname; } + +export type KeyType = T[K] diff --git a/sites/components/select/index.md b/sites/components/select/index.md index 4af9aadcfef8cf06b7909c8073c71cb2810e9873..5440b1c701421c85c04c4995e22167598e44b1f2 100644 --- a/sites/components/select/index.md +++ b/sites/components/select/index.md @@ -2,87 +2,174 @@ 用于从列表中选择单个或者多个数据 -### 何时使用 +### 基本用法 -需要从列表中选择单个或者多个数据 +:::demo 通过`size`:`sm`,`md(默认)`,`lg`来设置`Select`大小,通过`overview`:`underlined`设置只有下边框样式 -### 基本用法 +```vue + -
- -
+ +``` +::: -#### Underlined +#### 多选 -
- -
+:::demo 通过`multiple`:`true`来开启多选 -```html - +```vue + - + ``` +::: + +#### 禁用 + +:::demo 通过`disabled`:`true`来禁用`Select`,通过`option-disabled-key`来设置单个选项禁用,比如设置`disabled`字段,则对象上disabled为`true`时不可选择 + +```vue + - + +``` +::: + +#### 可清空 + +:::demo 通过`allow-clear`:`true`来设置`Select`可清空 + +```vue + + + + +``` +:::