selectCard 控件

15 阅读4分钟

image.png

image.png

lodash.js

import cloneDeep from 'lodash/cloneDeep'
import debounce from 'lodash/debounce'
import xor from 'lodash/xor'

export default {
  cloneDeep,
  debounce,
  xor,
}


props.js

export default {
  value: {
    type: [String, Number, Array],
  },
  disabled: {
    type: Boolean,
    default: false,
  },
  //选项集合
  options: {
    required: true,
    type: Array,
  },
  //label渲染字段,default默认为集合本身,当集合为对象时需要指定,可指定scopeSlots.default自定义渲染方式
  field: {
    type: Object,
    default: {
      label: "label",
      value: "value",
    },
  },
  //允许清除,不需要显示选项卡快速清除
  allowClear: {
    type: Boolean,
    default: false,
  },
  //
  showArrows: {
    type: Boolean,
    default: true,
  },
  //拉下选项卡展示的位置,默认为左下,可以指定,消除选项卡显示超出视窗的问题
  overlayPlacement: {
    type: String,
    default: "bottomLeft",
    validate: function (value) {
      return ["bottomLeft", "bottomRight", "topLeft", "topRight"].includes(
        value
      );
    },
  },
  //选择模式,单选和多选
  mode: {
    type: String,
    default: "default",
    validate: function (value) {
      return ["default", "multiple"].includes(value);
    },
  },
  //选项卡一行显示多少个选项,默认4个,不能超过5个
  rows: {
    type: Number,
    default: 4,
    validate: function (value) {
      return value >= 1 && value <= 5;
    },
  },
  placeholder: {
    type: String,
    default: "请选择",
  },
  dropdownWidth: {
    type: Number,
    default: 400,
  },
  dropdownMaxHeight: {
    type: Number,
    default: 200,
  },
  getPopupContainer: {
    type: Function,
    default: void 0,
  },
  size: {
    type: String,
    default: "default",
    validate: function (value) {
      return ["large", "default", "small"].includes(value);
    },
  },
  footerSize: {
    type: String,
    default: void 0,
    validate: function (value) {
      return value === void 0 || ["large", "default", "small"].includes(value);
    },
  },
  autoClose: {
    type: Boolean,
    default: true,
  },
  required: {
    type: [Boolean, Number],
    default: false,
    validate: function (value) {
      return typeof value === "boolean" || value >= 1;
    },
  },
  ignoreInitChange: {
    type: Boolean,
    default: false,
  }
};


utils.js

export function getTextWidth(text) {
  let span = document.createElement("span");
  let result = {};
  result.width = span.offsetWidth;
  result.height = span.offsetHeight;
  span.style.visibility = "hidden";
  span.style.fontSize = "12px";
  span.style.fontFamily = "思源黑体";
  span.style.display = "inline-block";
  span.style.whiteSpace = "nowrap";
  document.body.appendChild(span);
  let reg = new RegExp(" ", "g");
  text = text.replace(reg, "a");
  if (typeof span.textContent != "undefined") {
    span.textContent = text;
  } else {
    span.innerText = text;
  }
  let width = parseFloat(window.getComputedStyle(span).width) - result.width;
  span.remove();
  return width;
}

export function splitOptionsByRows(options, rows) {
  return Object.freeze(
    options.reduce(function(res, item, index) {
      let i = parseInt(index / rows);
      if (res[i] == null) {
        res[i] = [];
      }
      res[i].push(item);
      return res;
    }, [])
  );
}

export function isContain(root, n) {
  let node = n;
  while (node) {
    if (node === root) {
      return true;
    }
    node = node.parentNode;
  }
  return false;
}

export function formatEmpty(value, emptyText = "-") {
  if (value === null || value === "") {
    return emptyText;
  }
  return value;
}

index.js

import SelectCard from './SelectCard.vue'

export default SelectCard

SelectCard.vue

<template>
  <div
    class="select-card"
    ref="container"
  >
    <p-dropdown
      ref="dropdown"
      :disabled="disabled"
      :visible="open"
      :get-popup-container="setPopupContainer"
      :placement="overlayPlacement"
      overlayClassName="select-card-dropdown"
    >
      <combobox
        ref="combobox"
        :disabled="disabled"
        :value="cache"
        :open="open"
        :is-multiple="isMultiple"
        :show-clear="allowClearState && cache[0] !== void 0"
        :show-arrows="showArrows"
        :placeholder="placeholder"
        :size="size"
        @clear="clear"
        @click="comboboxClick"
      />
      <div
        :class="['select-card-overlay', { empty: isEmpty }]"
        slot="overlay"
        ref="overlay"
        :tabindex="-1"
        @focusout="focusout"
      >
        <p-spin :spinning="loading">
          <recycle-scroller
            class="select-card-overlay-content"
            :key="contentKey"
            ref="scroller"
            :style="scrollStyle"
            :items="curOptions"
            :item-size="lineSize"
            key-field="0"
            v-slot="{ item }"
          >
            <div class="select-card-overlay-content-line">
              <select-item
                v-for="j in Array.from(Array(rows), (v, k) => k)"
                :key="j"
                :mode="mode"
                :empty="isEmptyItem(item[j])"
                :title="getAttr(item[j], 'label')"
                :value="getAttr(item[j])"
                :data="item[j]"
                :dataIndex="j"
                :checked="cache.includes(getAttr(item[j]))"
                :container="$refs.scroller.$el"
                @change="(checked) => setChecked(getAttr(item[j]), checked)"
              />
            </div>
          </recycle-scroller>
          <p-divider style="margin: 4px 0 0" />
          <div
            class="select-card-overlay-footer"
            ref="footer"
          >
            <p-input
              ref="search"
              class="footer-input"
              :size="footerSize || size"
              v-model.lazy="search"
              @input="searchChange"
            />
            <p-button-link
              class="footer-btn"
              :size="footerSize || size"
              :disabled="!isMultiple"
              @click="chooseAll"
            >
              全选
            </p-button-link>
            <p-button-link
              class="footer-btn"
              :size="footerSize || size"
              :disabled="!isMultiple"
              @click="chooseReverse"
            >
              反选
            </p-button-link>
            <p-button-link
              class="footer-btn"
              :size="footerSize || size"
              @click="closeDropdown"
            >
              确定
            </p-button-link>
          </div>
          <div
            class="empty"
            v-if="isEmpty"
          >
            <p-empty :image="simpleImage" />
          </div>
        </p-spin>
      </div>
    </p-dropdown>
  </div>
</template>

<script>
import { RecycleScroller } from "vue-virtual-scroller";
import _ from "./js/lodash";
import Props from "./js/props";
import { splitOptionsByRows, isContain, formatEmpty } from "./js/utils";
import Combobox from "./SelectCardCombobox.vue";
import SelectItem from "./SelectCardItem.vue";
import Empty from "poros/ui/lib/empty";
export default {
  inheritAttrs: false,
  name: "SelectCard",
  props: Props,
  watch: {
    value: {
      deep: true,
      immediate: true,
      handler: function () {
        this.checkType().then(() => {
          let cache = this.isMultiple
            ? this.value
            : this.value === void 0
              ? []
              : [this.value];
          if (JSON.stringify(cache) !== JSON.stringify(this.cache)) {
            if (this.ignoreInitChange && this.allowEmit) {
              this.allowEmit = false;
            }
            this.cache = cache;
          }
        });
      },
    },
    options: {
      deep: true,
      immediate: true,
      handler: function () {
        if (this.options) {
          this.loadOptions(this.options);
        }
      },
    },
    rows () {
      this.getLinesData(this.originOptions);
    },
    open (value) {
      if (value) {
        this.contentKey++;
        this.$refs.dropdown.$nextTick(() => {
          this.scrollToView();
        });
      } else {
        if (this.search !== "") {
          setTimeout(() => {
            this.curOptions = _.cloneDeep(this.curOptionsSnap);
            this.contentKey++;
          }, 300);
        }
        this.search = "";
        this.$emit("close", this.isMultiple ? [...this.cache] : this.cache[0]);
      }
    },
    cache: {
      deep: true,
      handler: function () {
        this.emitChange();
      },
    },
  },
  computed: {
    isEmpty () {
      return this.curOptions.length === 0;
    },
    isMultiple () {
      return this.mode === "multiple";
    },
    isFilter () {
      return typeof this.$listeners.search === "function";
    },
    lineSize () {
      return this.size === "large" ? 40 : this.size === "small" ? 24 : 32;
    },
    footerHeight () {
      return this.size === "large" ? 48 : this.size === "small" ? 32 : 40;
    },
    scrollStyle () {
      return `width: 100%;min-width: ${this.isEmpty ? 0 : this.dropdownWidth
        }px;height:${this.curOptions.length * this.lineSize >
          this.dropdownMaxHeight - this.footerHeight - 4
          ? this.dropdownMaxHeight - this.footerHeight - 4
          : this.curOptions.length * this.lineSize + 4
        }px;`;
    },
    requiredNumber () {
      return Number(this.required);
    },
    requiredMin () {
      if (this.mode === "default" && this.requiredNumber > 1) {
        return 1;
      }
      return this.requiredNumber;
    },
    allowClearState () {
      if (this.requiredNumber > 0) {
        return false;
      }
      return this.allowClear;
    },
  },
  components: {
    RecycleScroller,
    Combobox,
    SelectItem,
  },
  provide () {
    return {
      getOptionLabel: this.getOptionLabel,
      getAttr: this.getAttr,
    };
  },
  data () {
    return {
      allowEmit: true,
      cache: [],
      curOptions: [],
      open: false,
      loading: false,
      contentKey: 0,
    };
  },
  beforeCreate () {
    this.simpleImage = Empty.PRESENTED_IMAGE_SIMPLE;
  },
  created () {
    this.options && this.loadOptions(this.options);
  },
  mounted () { },
  methods: {
    isEmptyItem (value) {
      return value === void 0;
    },
    setPopupContainer (trigger) {
      let container;
      if (typeof this.getPopupContainer === "function") {
        container = this.getPopupContainer(trigger);
      }
      return container || this.$refs.container;
    },
    closeDropdown () {
      this.open = false;
    },
    delayCloseDropdown () {
      this.delaySetOpenState(false);
    },
    focusout (e) {
      if (!this.open) return;
      const { $refs: { overlay } = {} } = this;
      const { currentTarget, relatedTarget } = e;
      if (
        currentTarget === overlay &&
        (!relatedTarget || !isContain(overlay, relatedTarget))
      ) {
        this.delaySetOpenState(false);
      }
    },
    comboboxClick (e) {
      e.preventDefault();
      if (this.disabled) {
        return;
      }
      if (this.open) {
        this.delaySetOpenState(false);
      } else {
        this.open = true;
        this.$nextTick(() => {
          this.$refs.search &&
            this.$refs.search.$nextTick(this.$refs.search.focus);
        });
      }
    },
    delaySetOpenState (state) {
      if (this.delayTimer) {
        clearTimeout(this.delayTimer);
      }
      this.delayTimer = setTimeout(() => {
        this.open = state;
      }, 200);
    },
    getOptionLabel (target, byValue) {
      if (target === void 0) {
        return void 0;
      }
      const { getAttr, originOptions } = this;
      if (byValue) {
        if (this.originOptions === void 0) {
          return target;
        }
        target = originOptions.find((o) => getAttr(o) == target);
      }
      return getAttr(target, "label");
    },
    getAttr (item, attr = "value") {
      let rightAttr = ["value", "label"].includes(attr);
      return item !== void 0
        ? rightAttr
          ? typeof item === "object" && item !== null
            ? attr === "label"
              ? formatEmpty(item[this.field[attr]])
              : item[this.field[attr]]
            : attr === "label"
              ? formatEmpty(item)
              : item
          : void 0
        : void 0;
    },
    clear () {
      this.cache = [];
    },
    checkType () {
      if (this.value !== void 0) {
        let type = typeof this.value;
        if (
          (!this.isMultiple &&
            !(["string", "number"].includes(type) || this.value === null)) ||
          (this.isMultiple && !Array.isArray(this.value))
        ) {
          throw new Error(
            `type of value cannot be ${type}, it should be ${!this.isMultiple ? "string or number" : "array"
            }`
          );
          // return Promise.reject()
        }
      }
      return Promise.resolve();
    },
    loadOptions (options) {
      this.originOptions = Object.freeze(options);
      this.search = "";
      this.curOptions = splitOptionsByRows(this.originOptions, this.rows);
      this.curOptionsSnap = _.cloneDeep(this.curOptions);
      this.contentKey++;
      this.$refs.combobox && this.$refs.combobox.update();
    },
    setChecked (value, checked) {
      const { isMultiple } = this;
      if (isMultiple) {
        if (checked) {
          this.cache.push(value);
        } else {
          if (this.cache.length <= this.requiredMin) {
            return;
          }
          let index = this.cache.indexOf(value);
          this.cache.splice(index, 1);
        }
      } else {
        if (checked) {
          this.cache.splice(0, 1, value);
        } else {
          if (this.cache.length <= this.requiredMin) {
            return;
          }
          this.cache = [];
        }
      }
    },
    emitChange () {
      const { isMultiple, cache, autoClose } = this;
      let requireClose = false,
        value = isMultiple ? [...cache] : cache[0];
      if (!isMultiple && cache.length >= 1 && autoClose) {
        requireClose = true;
      }
      if (this.allowEmit) {
        this.allowEmit && this.$emit("input", value);
        this.allowEmit && this.$emit("change", value);
      } else {
        this.allowEmit = true;
      }
      if (requireClose) {
        this.$nextTick(this.delayCloseDropdown);
      }
    },
    searchChange: _.debounce(function () {
      this.loading = true;
      setTimeout(() => {
        let options = this.originOptions.filter(
          (o) =>
            this.getAttr(o, "label")
              .toString()
              .toLowerCase()
              .indexOf(this.search.toLowerCase()) > -1
        );
        this.curOptions = splitOptionsByRows(options, this.rows);
        this.scrollToItem(0);
        this.contentKey++;
        this.loading = false;
      }, 20);
    }, 300),
    getCurValues () {
      return this.curOptions.flat().map((option) => this.getAttr(option));
    },
    chooseAll () {
      const { cache, getCurValues } = this;
      let values = getCurValues();
      this.cache = Array.from(new Set([...cache, ...values]));
    },
    chooseReverse () {
      const { cache, getCurValues } = this;
      let values = getCurValues();
      let reverseValues = _.xor(cache, values);
      let reversLength = reverseValues.length;
      if (this.required && reversLength <= this.requiredMin) {
        reverseValues.push(
          ...this.originOptions
            .filter((item) => !reverseValues.includes(this.getAttr(item)))
            .slice(0, this.requiredMin - reversLength)
            .map((item) => this.getAttr(item))
        );
        this.$message.info(
          `数据要求必填,要求必选${this.requiredMin}个,将默认选择前${this
            .requiredMin - reversLength}个`
        );
      }
      this.cache = reverseValues;
    },
    scrollToView () {
      let index = this.curOptions.findIndex(
        (arr) =>
          arr.findIndex((item) => this.getAttr(item) === this.cache[0]) > -1
      );
      setTimeout(() => {
        this.scrollToItem(index);
      }, 20);
    },
    scrollToItem (index) {
      this.$refs.scroller && this.$refs.scroller.scrollToItem(index);
    },
  },
};
</script>

<style lang="less" scoped>
.select-card {
  position: relative;
  &-overlay {
    border: 1px solid #e8e8e8;
    background: #ffffff;
    position: relative;
    &.empty {
      height: 136px;
    }
    &-content {
      padding: 4px 10px 0;
      overflow: hidden auto;
      &-line {
        display: flex;
      }
    }
    &-footer {
      padding: 4px 10px;
      display: flex;
      .footer-input {
        flex: 1;
        max-width: 180px;
      }
      .footer-btn {
        margin-left: 10px;
      }
    }
    .empty {
      position: absolute;
      top: 0;
      z-index: 1;
      background-color: #ffffff;
      height: 134px;
      width: 100%;
    }
  }
}
</style>
<style lang="less">
.select-card-dropdown {
  // box-shadow: 0px 2px 6px 0px #ccc;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
</style>
SelectCardCombobox.vue

<template>
  <div
    :class="[
      'select-card-combobox',
      { 'combobox--open': open, 'combobox--disabled': disabled },
      'combobox--' + size,
    ]"
    :style="comboboxStyle"
    @click="comboboxClick"
  >
    <template v-if="!showPlaceholder">
      <div class="select-selection" :style="selectionStyle">
        {{ text }}
      </div>
      <div
        :class="['select-selection-count', 'select--' + size]"
        v-if="showCountNumber"
      >
        {{ "+ " + (value.length - 1) + " ..." }}
      </div>
    </template>
    <div class="clear-icon icon" v-if="showClear" @click="clearAll">
      <p-icon type="close-circle" theme="filled" />
    </div>
    <div class="arrow-icon icon" v-if="showArrows">
      <p-icon type="down" />
    </div>
    <div
      v-show="showPlaceholder"
      class="select-placeholder"
      style="user-select: none"
    >
      {{ placeholder }}
    </div>
  </div>
</template>

<script>
import { getTextWidth } from "./js/utils";
export default {
  inheritAttrs: false,
  name: "SelectCardCombobox",
  props: {
    value: Array,
    open: Boolean,
    isMultiple: Boolean,
    showArrows: Boolean,
    showClear: Boolean,
    placeholder: String,
    size: String,
    disabled: Boolean,
  },
  inject: ["getOptionLabel"],
  watch: {
    value: {
      deep: true,
      immediate: true,
      handler: function () {
        this.update();
      },
    },
  },
  computed: {
    selection() {
      return this.value[0] || void 0;
    },
    showPlaceholder() {
      return this.value.length > 0 ? false : true;
    },
    showCountNumber() {
      return this.value.length > 1 ? true : false;
    },
    countNumberWidth() {
      return this.value.length > 1
        ? getTextWidth("+ " + (this.value.length - 1) + " ...")
        : 0;
    },
    selectionStyle() {
      return `max-width: calc(100% - ${
        this.countNumberWidth ? this.countNumberWidth + 22 : 0
      }px);`;
    },
    comboboxStyle() {
      return this.showArrows || this.showClear ? "padding-right: 24px;" : "";
    },
  },
  data() {
    return {
      text: "",
    };
  },
  created() {},
  mounted() {
    this.update();
  },
  methods: {
    clearAll(e) {
      e.stopPropagation();
      e.preventDefault();
      this.$emit("clear");
    },
    update() {
      this.text =
        this.selection != null ? this.getOptionLabel(this.selection, true) : "";
    },
    comboboxClick(e) {
      this.$emit("click", e);
    },
  },
};
</script>

<style lang="less" scoped>
.select-card-combobox {
  cursor: pointer;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
  height: 30px;
  display: flex;
  position: relative;

  padding: 4px 11px;
  height: 30px;
  line-height: 1.8;
  transition: border 0.3s;
  &.combobox--large {
    height: 40px;
    padding: 8px 11px;
    font-size: 14px;
  }
  &.combobox--small {
    height: 24px;
    padding: 3px 7px;
    line-height: 1.5;
  }
  &.combobox--open {
    border-color: @primary-color;
    .arrow-icon {
      ::v-deep svg {
        transform: rotate(180deg);
      }
    }
  }
  .select-selection {
    text-overflow: ellipsis;
    white-space: nowrap;
    overflow: hidden;
  }
  .select-selection-count {
    margin-left: 6px;
    background-color: #fafafa;
    border-radius: 2px;
    // padding: 0 4px 5px;
    padding: 0 10px;
    line-height: 20px;
    user-select: none;
    border: 1px solid #e8e8e8;
    line-height: 1.5;
    padding: 0 8px;
    &.select--small {
      line-height: 1.2;
      padding: 0 4px;
    }
    &.select-large {
    }
  }
  .icon {
    position: absolute;
    top: 50%;
    right: 11px;
    z-index: 1;
    display: inline-block;
    width: 12px;
    height: 12px;
    margin-top: -9px;
    color: rgba(0, 0, 0, 0.25);
    background-color: #fff;
    &.clear-icon {
      z-index: 2;
      opacity: 0;
      transition: opacity 0.3s;
      &:hover {
        color: rgba(0, 0, 0, 0.45);
      }
    }
    &.arrow-icon {
      transform-origin: 50% 50%;
      ::v-deep svg {
        transition: transform 0.3s;
      }
    }
  }
  &:hover .clear-icon {
    opacity: 1;
  }
  // &.open{
  //   border-color: #3c64e8;
  //   box-shadow: 0 0 0 2px rgba(23, 64, 220, .2);
  // }
  .select-placeholder {
    flex: 1;
    overflow: hidden;
    color: #bfbfbf;
    white-space: nowrap;
    text-overflow: ellipsis;
    pointer-events: none;
    position: absolute;
    top: 50%;
    right: 11px;
    left: 11px;
    transform: translateY(-50%);
    transition: all 0.3s;
    text-align: left;
  }

  &.combobox--disabled {
    cursor: not-allowed;
    background-color: #f5f5f5;
    .select-selection {
      color: rgba(0, 0, 0, 0.25);
    }
    .icon {
      background-color: #f5f5f5;
    }
  }
}
</style>

SelectCardItem.vue

<template>
  <div
    :class="['select-card-overlay-content-item', { 'item--checked': checked }]"
    @click="itemClick"
  >
    <template v-if="!empty">
      <span class="item-checkbox">
        <p-icon class="item-checkbox-icon" type="check" v-if="checked" />
      </span>
      <div class="item-label">
        <m-overflow-tooltip placement="top" :title="title" />
      </div>
    </template>
  </div>
</template>

<script>
export default {
  inheritAttrs: false,
  name: "SelectCardItem",
  props: {
    mode: String,
    checked: Boolean,
    value: [String, Number],
    title: [String, Number],
    empty: Boolean,
    container: Object,
    data: [String, Number, Object, Boolean],
    dataIndex: Number,
  },
  computed: {
    isMultiple() {
      return this.mode === "multiple";
    },
  },
  created() {},
  mounted() {},
  methods: {
    itemClick(e) {
      e.preventDefault();
      e.stopPropagation();
      !this.empty && this.$emit("change", !this.checked);
    },
  },
};
</script>

<style lang="less" scoped>
.select-card-overlay-content-item {
  flex: 1;
  width: 0;
  display: flex;
  line-height: 32px;
  cursor: pointer;
  &.item--checked {
    color: @primary-color;
  }
  &:hover .item-label {
    color: #3c64e8;
  }
  .item-checkbox {
    width: 8px;
    margin-right: 8px;
    &-icon {
      //transition: all 0.3s;
      transition: all 0.2s cubic-bezier(0.12, 0.4, 0.29, 1.46) 0.1s;
    }
  }
  .item-label {
    height: 30px;
    max-width: calc(100% - 24px);
    position: relative;
  }
}
</style>