UNPKG

kui-vue

Version:

A lightweight desktop UI component library suitable for Vue.js 2.

666 lines (633 loc) 18.5 kB
import Option from "./option"; import Icon from "../icon"; import Empty from "../empty"; import transfer from "../directives/transfer"; import resize from "../directives/resize"; import zhCN from "../locale/zh-CN"; import { isEmpty } from "../utils/number"; import { getChildren } from "../utils/vnode"; import { setPlacement } from "../utils/placement"; import { Loading, Close, CloseCircle, ChevronDown } from "kui-icons"; import { withInstall } from "../utils/vue"; import { ref, defineComponent, watch, nextTick, inject, // Transition, onBeforeMount, onMounted, computed, onBeforeUpdate, // cloneVNode, } from "vue"; const Select = defineComponent({ name: "Select", directives: { transfer, resize, }, props: { placeholder: String, size: { default: "default", validator(value) { return ["small", "large", "default"].indexOf(value) >= 0; }, }, placement: { validator(value) { return [ "top", "top-left", "top-right", "bottom", "bottom-left", "bottom-right", ].includes(value); }, default: "bottom-left", }, width: Number, maxTagCount: Number, value: [String, Number, Array], clearable: { type: Boolean, default: true }, filterable: Boolean, block: Boolean, disabled: Boolean, multiple: Boolean, loading: Boolean, bordered: { type: Boolean, default: true }, showArrow: { type: Boolean, default: true }, options: Array, theme: String, emptyText: String, loadingText: String, icon: [String, Array], shape: String, arrowIcon: [String, Array], }, setup(ps, { slots, emit, attrs, listeners }) { const locale = computed(() => { const injectedLocale = inject("locale", zhCN); return injectedLocale instanceof Object && "value" in injectedLocale ? injectedLocale.value : injectedLocale; }); // const labelText = ref([]); const visible = ref(false); const rendered = ref(false); const currentValue = ref( ps.multiple ? ps.value || [] : isEmpty(ps.value) ? [] : [ps.value] ); const queryInputVisible = ref(false); const queryKey = ref(""); const queryInputMirrorRef = ref(); const minWidth = ref(""); const queryInputFocused = ref(false); const queryInputRef = ref(); const hasSearchEvent = "search" in listeners; const refPopper = ref(); const transOrigin = ref("bottom"); const refSelection = ref(); const left = ref(0); const top = ref(0); const currentPlacement = ref(ps.placement); const queryInputEventTimer = ref(); const activeIndex = ref(-1); const reallySize = ref(0); const ctxFocused = ref(false); const updateTrigger = ref(0); onBeforeUpdate(() => { updateTrigger.value++; }); watch( () => ps.placement, (v) => { currentPlacement.value = v; if (visible.value) { updatePosition(); } } ); watch( () => ps.options, (v) => { if (visible.value) { updatePosition(); } }, { deep: true } ); watch( () => ps.value, (v) => { currentValue.value = ps.multiple ? v || [] : isEmpty(v) ? [] : [v]; if (visible.value) { updatePosition(); } } ); const scrollOptionIntoView = () => { const containerEl = refPopper.value; const optionEl = refPopper.value.children[0].children[activeIndex.value]; const optionTop = optionEl.offsetTop; const optionHeight = optionEl.offsetHeight; const containerHeight = containerEl.clientHeight; const targetScroll = optionTop - containerHeight / 2 + optionHeight / 2; containerEl.scrollTop = targetScroll; }; const onKeydown = (e) => { const key = e.key; if ((!visible.value || optionsData.value.size == 0) && ctxFocused.value) { if (key === "ArrowDown" || key === "ArrowUp") { toggle(); } return; } if (visible.value) { if (key === "ArrowDown") { let index = activeIndex.value; if (index < reallySize.value - 1) { index += 1; } else { index = 0; } activeIndex.value = index; scrollOptionIntoView(); e.preventDefault(); return; } else if (key === "ArrowUp") { let index = activeIndex.value; if (index >= 1) { index -= 1; } else { index = reallySize.value - 1; } activeIndex.value = index; scrollOptionIntoView(); e.preventDefault(); return; } else if ( key === "Enter" && activeIndex.value >= 0 && (ctxFocused.value || queryInputFocused.value) ) { let { label, value } = optionsData.value[activeIndex.value]; onSelect({ label, value }); e.preventDefault(); return; } else if ( key == "Escape" && (ctxFocused.value || queryInputFocused.value) ) { visible.value = false; clearQuery(); e.preventDefault(); } } }; onBeforeMount(() => { document.removeEventListener("keydown", onKeydown); document.removeEventListener("click", outsideClick); }); const labelText = computed(() => { return optionsData.value .filter((item) => currentValue.value.includes(item.value)) .map((item) => item.label); }); const updatePosition = () => { nextTick(() => { minWidth.value = refSelection.value?.offsetWidth; setPlacement({ refSelection, refPopper, currentPlacement, transOrigin, top, left, }); }); }; onMounted(() => { nextTick(() => { minWidth.value = refSelection.value?.offsetWidth; }); document.addEventListener("keydown", onKeydown); }); const outsideClick = (e) => { const ctx = refSelection.value?.$el || refSelection.value; if ( refPopper.value && !refPopper.value.contains(e.target) && ctx && !ctx.contains(e.target) ) { visible.value = false; clearQuery(); } }; const isChecked = (value) => { if (ps.multiple) { return currentValue.value?.indexOf(value) >= 0; } else { return !isEmpty(currentValue.value) && currentValue.value[0] === value; } }; const clearQuery = () => { activeIndex.value = -1; if (ps.filterable || hasSearchEvent) { setTimeout(() => { queryKey.value = ""; if (queryInputRef.value) { queryInputRef.value.value = ""; queryInputRef.value.style.width = ""; } queryInputVisible.value = false; }, 300); } }; const onMouseenter = (index) => { activeIndex.value = index; }; const onSelect = (item) => { const { value, label } = { ...item }; let selected = true; if (ps.multiple) { if (currentValue.value?.indexOf(value) >= 0) { selected = false; currentValue.value = currentValue.value.filter((v) => v !== value); } else { currentValue.value.push(value); } updatePosition(); if (hasSearchEvent || ps.filterable) { queryInputRef.value.value = ""; queryKey.value = ""; showQuery(); } } else { currentValue.value = [value]; visible.value = false; emit("openChange", false); clearQuery(); activeIndex.value = -1; } const result = ps.multiple ? currentValue.value : currentValue.value[0]; // emit("update:value", result); emit("input", result); emit("change", result); emit("select", { value, label, selected }); }; const searchInput = (e) => { queryKey.value = e.target.value; activeIndex.value = -1; nextTick(() => { e.target.style.width = queryInputMirrorRef.value.offsetWidth + "px"; updatePosition(); }); if (hasSearchEvent) { clearTimeout(queryInputEventTimer.value); queryInputEventTimer.value = setTimeout(() => { if (!rendered.value) { rendered.value = true; document.addEventListener("click", outsideClick); nextTick(() => { visible.value = true; emit("openChange", true); updatePosition(); }); } else { visible.value = true; emit("openChange", true); updatePosition(); } emit("search", e); }, 500); } }; const emptyClick = (e) => { if (queryInputVisible.value) { nextTick((e) => { queryInputRef.value.focus(); queryInputFocused.value = true; }); } }; const removeTag = (e, index) => { if (ps.disabled) return; currentValue.value.splice(index, 1); e.stopPropagation(); updatePosition(); }; const onClear = (e) => { currentValue.value = []; emit("input", ps.multiple ? [] : ""); emit("change", ps.multiple ? [] : ""); clearQuery(); e.stopPropagation(); }; const showQuery = () => { if (ps.filterable || hasSearchEvent) { queryInputVisible.value = true; nextTick(() => { queryInputRef.value?.focus(); queryInputFocused.value = true; }); } }; const toggle = (show = false) => { if (ps.disabled) { return; } if (hasSearchEvent) { showQuery(); return; } if (!rendered.value) { rendered.value = true; document.addEventListener("click", outsideClick); nextTick(() => { visible.value = true; emit("openChange", true); updatePosition(); showQuery(); }); } else { visible.value = show || !visible.value; emit("openChange", visible.value); if (visible.value) { updatePosition(); showQuery(); } else { clearQuery(); } } }; const optionsData = computed(() => { updateTrigger.value; let { options, loading } = ps; if (loading) return []; if (!options) { options = []; const children = getChildren(slots.default?.()); children.forEach((child, index) => { let { label, value, disabled } = child?.componentOptions?.propsData || {}; let { children = [] } = child?.componentOptions; options.push({ value, disabled, label: label || children[0]?.text || value, }); }); } return options; }); const filterOptions = () => { const key = queryKey.value; const filter = ps.filterable && key.trim() !== ""; return filter ? optionsData.value.filter((item) => item.label.toLowerCase().includes(key.toLowerCase()) ) : optionsData.value; }; const renderOptions = () => { const optionNodes = []; const nodes = filterOptions(); reallySize.value = nodes.length; nodes.forEach((item, index) => { let { label, value, disabled } = { ...item }; const checked = isChecked(value); optionNodes.push( <Option onSelect={onSelect} onMouseenter={() => onMouseenter(index)} key={`${value}-${label}`} active={activeIndex.value == index} value={value} label={label} disabled={disabled} checked={checked} multiple={ps.multiple} /> ); }); return optionNodes; }; const queryKeydown = ({ key }) => { if (key === "Backspace") { if ( queryKey.value == "" && ps.multiple && currentValue.value.length > 0 ) { currentValue.value = currentValue.value.slice(0, -1); emit("input", currentValue.value); // emit("update:value", currentValue.value); emit( "change", ps.multiple ? currentValue.value : currentValue.value[0] || "" ); updatePosition(); } } }; const showClear = computed(() => { return ( ps.clearable && !ps.disabled && !isEmpty(currentValue.value) && !isEmpty(labelText.value) ); }); const renderOverlay = () => { let overlay = null; if (rendered.value) { const optionNodes = renderOptions(); const preCls = "k-select"; const props = { ref: refPopper, style: { minWidth: `${minWidth.value}px`, left: `${left.value}px`, top: `${top.value}px`, transformOrigin: transOrigin.value, }, class: [ "k-select-dropdown", "k-scroll", { "k-select-dropdown-multiple": ps.multiple, "k-select-dropdown-sm": ps.size == "small", }, ], }; const loadingNode = ( <div class="k-select-loading"> <Icon type={Loading} spin /> <span>{locale.value.k.select.loading}</span> </div> ); overlay = ( <transition name={`${preCls}`}> <div v-transfer={true} v-show={visible.value} {...props}> {ps.loading ? ( loadingNode ) : optionNodes.length ? ( <ul>{optionNodes}</ul> ) : ( <Empty onClick={emptyClick} description={locale.value.k.select.emptyText} /> )} </div> </transition> ); } return overlay; }; return () => { let { disabled, size, multiple, placeholder, showArrow, bordered, theme, arrowIcon, icon, shape, filterable, } = ps; let childNode = []; if (arrowIcon === undefined) { arrowIcon = ChevronDown; } const queryProps = { ref: queryInputRef, class: "k-select-search", autoComplete: "off", on: { change: (e) => e.stopPropagation(), keydown: queryKeydown, input: searchInput, blur: () => { if (!visible.value) { queryInputVisible.value = false; } }, }, }; const queryNode = ( <div v-show={queryInputVisible.value} key="search" class="k-select-search-wrap" > <input {...queryProps} /> <span class="k-select-search-mirror" ref={queryInputMirrorRef}> {queryKey.value} </span> </div> ); const placeholderText = placeholder || locale?.value.k.select.placeholder; const placeNode = placeholderText && isEmpty(labelText.value) && !queryKey.value ? ( <div class="k-select-placeholder">{placeholderText}</div> ) : null; const labelStyle = { display: queryKey.value.length ? "none" : "", }; const renderTags = () => { let tags = labelText.value.map((label, i) => { return ( <span class="k-select-tag" key={label}> {label} <Icon type={Close} onClick={(e) => removeTag(e, i)} /> </span> ); }); if ( ps.maxTagCount && ps.maxTagCount > 0 && tags.length > ps.maxTagCount ) { tags = tags.slice(0, ps.maxTagCount); tags.push( <span class="k-select-tag"> +{labelText.value.length - ps.maxTagCount}... </span> ); } return tags; }; const labelsNode = multiple ? ( <div class="k-select-labels" name="k-select-tag"> {renderTags()} {queryNode} </div> ) : !isEmpty(labelText.value) ? ( <div class="k-select-label" style={labelStyle}> {labelText.value[0]} </div> ) : null; childNode.push(labelsNode); placeNode && childNode.push(placeNode); if ((filterable || hasSearchEvent) && !multiple) { childNode.push(queryNode); } const styles = { width: `${ps.width}px` }; const arrowNode = !hasSearchEvent && showArrow ? ( <Icon class="k-select-arrow" type={arrowIcon} /> ) : null; const classes = [ "k-select", { "k-select-disabled": disabled, "k-select-block": ps.block, "k-select-opened": visible.value, "k-select-borderless": bordered === false, "k-select-lg": size == "large", "k-select-sm": size == "small", "k-select-light": theme == "light", "k-select-has-icon": !!icon, "k-select-circle": shape == "circle" && !multiple, "k-select-multiple": multiple, "k-select-show-search": queryInputFocused.value, "k-select-show-tags": multiple && !isEmpty(labelText.value), "k-select-has-clear": showClear.value, }, ]; const clearNode = showClear.value ? ( <Icon class="k-select-clearable" type={CloseCircle} onClick={onClear} /> ) : null; return ( <div tabIndex="0" class={classes} style={styles} v-resize={updatePosition} onClick={toggle} onFocus={() => (ctxFocused.value = true)} onBlur={() => (ctxFocused.value = false)} ref={refSelection} > {icon ? <Icon type={icon} class="k-select-icon" /> : null} <div class="k-select-selection">{childNode}</div> <span class="k-select-suffix"> {arrowNode} {clearNode} </span> {renderOverlay()} </div> ); }; }, }); export default withInstall(Select);