js 实现及联选择器

145 阅读4分钟

初次实现,可能有考虑不周的地方,欢迎各位大佬提点~
基本功能介绍:
1、允许一个页面内创建多个实例,互相之间不会有影响
2、允许 JSON 数据格式写入
3、允许鼠标悬浮触发或点击触发下级及联数据展示
4、允许设置默认值
5、支持输入框提示
6、提供选中后的回调函数

Cascader.js

class Cascader {
  constructor(selector, options = {}) {
    this.container = document.querySelector(selector);
    if (!this.container) throw new Error(`Container not found: ${selector}`);

    this.options = options;
    this.value = [];
    this.labelValue = []; // 用于存储选中的标签值
    this.menus = null;

    // 标志是否处于初始化阶段
    this.initializing = true;

    Cascader.injectStyles();
    this.init();

    // 设置默认值
    const valueElement = this.container.querySelector(".cascader-value");
    if (options.defaultValue && options.defaultValue.length) {
      this.setDefaultValue(options.defaultValue);
      valueElement.classList.remove("placeholderStyles");
    } else if (options.placeholder) {
      // 设置提示字体颜色
      valueElement.classList.add("placeholderStyles");
    }
    // 初始化完成
    this.initializing = false;
  }

  static injectStyles() {
    if (Cascader._stylesInjected) return;
    Cascader._stylesInjected = true;

    const styles = `
      .cascader-container { position: relative; }
      .cascader-value { border: 1px solid #ccc; cursor: pointer; }
      .placeholderStyles {color: #999999;}
      .cascader-menus { position: absolute; top: 100%; left: 0; display: flex; background: #fff; box-shadow: 0px 6px 16px 0px rgba(0,0,0,0.08), 0px 3px 6px -4px rgba(0,0,0,0.12), 0px 9px 28px 8px rgba(0,0,0,0.05);border-radius: 4px;border: 0px solid rgba(0,0,0,0.88); z-index: 1000; }
      .cascader-menu { margin: 0; padding: 0; list-style: none; min-width: 150px; border-right: 1px solid rgba(229, 229, 229, 1); }
      .cascader-menu:last-child { border-right: none; }
      .cascader-menu-item { padding: 10px; cursor: pointer; }
      .cascader-menu-item:hover { background: #f0f0f0; }
      .cascader-menu-item.selected {background: #fff7f5;color: #fa6634; }
      .cascader-menu-item.selected span {color: #fa6634!important; }
      .cascader-menu-item.disabled { color: #ccc; cursor: not-allowed; }
    `;
    const styleElement = document.createElement("style");
    styleElement.textContent = styles;
    document.head.appendChild(styleElement);
  }

  init() {
    this.container.innerHTML = `
      <div class="cascader-container">
        <div class="cascader-value">${
          this.options.placeholder || "Select"
        }</div>
        <div class="cascader-menus"></div>
      </div>
    `;
    this.menus = this.container.querySelector(".cascader-menus");
    this.attachEvents();
  }

  attachEvents() {
    const valueElement = this.container.querySelector(".cascader-value");
    valueElement.addEventListener("click", () => {
      // valueElement.classList.add("cascader-selected-value");
      this.toggleMenu();
    });

    const trigger = this.options.trigger || "click"; // 支持 hover 和 click
    this.menus.addEventListener("click", (event) =>
      this.handleMenuEvent(event)
    );
    if (trigger === "hover") {
      this.menus.addEventListener("mouseover", (event) =>
        this.handleMenuEvent(event, true)
      );
    }

    document.addEventListener("click", (event) => {
      // 优化悬浮记值
      if (!this.container.contains(event.target)) {
        // console.log(111);
        // setTimeout(() => {
        //   valueElement.classList.remove("cascader-selected-value");
        // },100)
        this.closeMenu();
      }
    });
  }

  handleMenuEvent(event, isHover = false) {
    const item = event.target.closest(".cascader-menu-item");
    if (item && !item.classList.contains("disabled")) {
      const level = parseInt(item.dataset.level, 10);
      const value = item.dataset.value;

      // 判断是否为叶子节点(即是否存在 children)
      const isLeaf = item.querySelector("span") === null;
      if (isLeaf && isHover && this.options.trigger === "hover") {
        return;
      }

      if ((isHover && this.options.trigger === "hover") || !isHover) {
        const siblingItems = item.parentNode.querySelectorAll(
          ".cascader-menu-item"
        );
        siblingItems.forEach((sibling) => sibling.classList.remove("selected"));

        // 为当前选中项添加样式
        item.classList.add("selected");

        // 更新选择项
        let textContent = item.textContent.trim();
        if (item.querySelector("span")) {
          textContent = textContent.slice(0, -1);
        }
        // 如果是末级菜单,且是点击触发,则更新选中项
        this.updateSelection(level, value, textContent);
      }
    }
  }

  toggleMenu() {
    // 根据当前选中的值进行回显
    if (this.value.length) {
      this.setDefaultValue(this.value);
    }
    const isVisible = this.menus.style.display === "flex";
    this.menus.style.display = isVisible ? "none" : "flex";

    if (!isVisible) {
      // 如果有默认值,展开所有菜单
      if (this.value && this.value.length > 0) {
        this.expandDefaultMenus();
      } else {
        this.renderMenu();
      }
    }
  }
  expandDefaultMenus() {
    let options = this.options.options;
    let currentLevel = 0;

    this.value.forEach((value) => {
      const option = options.find((opt) => opt.value == value);
      if (option) {
        // 渲染当前级菜单
        this.renderMenu(currentLevel, options);
        // 更新到下一层级
        options = option.children || [];
        currentLevel++;
      }
    });
  }

  closeMenu() {
    this.menus.style.display = "none";
  }

  // 渲染菜单时支持多级联动
  renderMenu(level = 0, options = this.options.options) {
    // 清除当前级别之后的菜单
    const menus = this.menus.querySelectorAll(".cascader-menu");
    menus.forEach((menu, index) => {
      if (index >= level) menu.remove();
    });

    const menu = document.createElement("ul");
    menu.classList.add("cascader-menu");

    options.forEach((option) => {
      const item = document.createElement("li");
      item.classList.add("cascader-menu-item");
      if (option.disabled) item.classList.add("disabled");
      item.dataset.value = option.value;
      item.dataset.level = level;
      item.textContent = option.label;

      // 判断当前项是否已选中
      if (this.value[level] == option.value) {
        item.classList.add("selected");
      }

      // 如果该项有子菜单,添加箭头
      if (option.children) {
        const arrow = document.createElement("span");
        arrow.textContent = ">";
        arrow.style.float = "right";
        arrow.style.color = "rgba(0, 0, 0, 0.45)";
        item.appendChild(arrow);
      }

      menu.appendChild(item);
    });

    this.menus.appendChild(menu);
    // 为超出宽度的项添加 title 提示
    this.menus.querySelectorAll(".cascader-menu-item").forEach((item) => {
      if (item.scrollWidth > item.offsetWidth) {
        // 判断内容是否超出
        item.setAttribute("title", item.textContent.trim());
      }
    });
  }

  setDefaultValue(defaultValue) {
    let options = this.options.options;
    let currentLevel = 0;

    defaultValue.forEach((value) => {
      const option = options.find((opt) => opt.value == value);
      if (option) {
        // 保存选中的值和标签
        this.value[currentLevel] = value;
        this.labelValue[currentLevel] = option.label;
        // 渲染当前级菜单
        this.renderMenu(currentLevel, options);

        // 更新到下一层级
        options = option.children || [];
        currentLevel++; // 递增当前级别
      }
    });
    // 更新显示;
    this.updateValueDisplay();
  }

  updateSelection(level, value, label) {
    this.value = this.value.slice(0, level);
    this.labelValue = this.labelValue.slice(0, level);
    this.value.push(value);
    this.labelValue.push(label);

    const option = this.findOption(value, this.options.options);

    // 更新样式:清除同一级的选中状态,设置当前选中项的样式
    const menuItems = this.menus.querySelectorAll(
      `.cascader-menu-item[data-level="${level}"]`
    );
    menuItems.forEach((item) => {
      item.classList.remove("selected");
      if (item.dataset.value === value) {
        item.classList.add("selected");
      }
    });

    if (option && option.children) {
      this.renderMenu(level + 1, option.children);
    } else {
      this.closeMenu();
      this.updateValueDisplay();

      if (!this.initializing && this.options.onChange) {
        this.options.onChange(this.value, this.labelValue);
      }
    }
  }

  findOption(value, options) {
    for (const option of options) {
      if (option.value === value) return option;
      if (option.children) {
        const found = this.findOption(value, option.children);
        if (found) return found;
      }
    }
    return null;
  }

  updateValueDisplay() {
    const separator = this.options.separator || " / ";
    const valueElement = this.container.querySelector(".cascader-value");
    valueElement.classList.remove("placeholderStyles"); // 已选择新值
    valueElement.textContent = this.labelValue.join(separator);
  }
}

// 暴露为全局对象
if (typeof window !== "undefined") {
  window.Cascader = Cascader;
} else if (typeof global !== "undefined") {
  global.Cascader = Cascader;
}

使用如下

<div class="main-search-select label-select-search "id="tree-select-list" ></div>

const transformTreeData = (data) => {
  return data.map((item) => ({
    value: item.id,
    label: item.name,
    children: item.children ? transformTreeData(item.children) : undefined,
  }));
};
// 树状结构数据
const treeData = [
  {
    id: "Chinese Mainland",
    name: "Chinese Mainland",
    children: [
      { id: 1, name: "Dairy Product" },
      { id: 26, name: "Beverage" },
      { id: 29, name: "Alcoholic Beverage" },
      { id: 31, name: "Health Supplement" },
      { id: 35, name: "Infant Formula" },
      { id: 38, name: "Food for Special Medical Purpose" },
    ],
  },
  {
    id: "Hong Kong S.A.R.",
    name: "Hong Kong S.A.R.",
    children: [
      { id: 28, name: "Beverage" },
      { id: 34, name: "Milk and Milk Product " },
      { id: 36, name: "Infant and Follow-up Formula" },
      { id: 53, name: "Alcohol Drink" },
      { id: 55, name: "Health Food" },
      { id: 62, name: "Formula for Special Medical Purposes" },
    ],
  },
  {
    id: "South Korea",
    name: "South Korea",
    children: [
      { id: 19, name: "Dairy Product" },
      { id: 21, name: "Beverage" },
      { id: 44, name: "Liquor " },
      { id: 46, name: "Health Functional Food" },
      { id: 49, name: "Infant formula" },
      { id: 59, name: "Food for Special Medical Purpose" },
    ],
  },
  {
    id: "Japan",
    name: "Japan",
    children: [
      { id: 90, name: "Dairy Product" },
      { id: 95, name: "Beverage" },
      { id: 98, name: "Alcoholic Beverage" },
      { id: 106, name: "Health Supplement" },
      { id: 108, name: "Infant Formula" },
      { id: 110, name: "Food for Special Dietary Uses" },
    ],
  },
  {
    id: "Singapore",
    name: "Singapore",
    children: [
      { id: 113, name: "Dairy Product" },
      { id: 114, name: "Beverage" },
      { id: 115, name: "Alcoholic Drink" },
      { id: 116, name: "Health Supplement" },
      { id: 117, name: "Infant Formula" },
      { id: 118, name: "Special Purpose Food" },
    ],
  },
  {
    id: "Canada",
    name: "Canada",
    children: [
      { id: 45, name: "Dairy Product" },
      { id: 47, name: "Beverage" },
      { id: 50, name: "Alcoholic Beverage" },
      { id: 54, name: "Natural Health Product" },
      { id: 56, name: "Infant Formula" },
      { id: 57, name: "Foods for Special Dietary Use (FSDU)" },
    ],
  },
  {
    id: "USA",
    name: "USA",
    children: [
      { id: 63, name: "Dairy Product" },
      { id: 77, name: "Beverage" },
      { id: 78, name: "Alcoholic Beverage " },
      { id: 79, name: "Dietary Supplement" },
      { id: 80, name: "Infant Formula " },
      { id: 82, name: "Medical Food" },
    ],
  }
  
];
const newTreeData = transformTreeData(treeData);

var cascader1 = new Cascader("#tree-select-list", {
  width: 1200,
  placeholder:
    "请输入",
  options: newTreeData,
  trigger: "hover", // 子级及联展开的触发方式
  separator: " > ", // 自定义连接符
  // defaultValue: [ '1','19'],   // 默认数据
  onChange: function (value, labelValue) {
    // value 输入为 url
    // window.location.href = value[value.length - 1];
    console.log(value, labelValue);
  },
});

CSS 样式

.cascader-container .cascader-menus {
  display: none;
}
.cascader-container .cascader-menus.active {
  z-index: 999;
  height: 196px;
}
.cascader-container {
  border: none !important;
}
.cascader-container .cascader-menus .cascader-menu .cascader-menu-item.active {
  background: #fff7f5 !important;
}
.cascader-menu-item.active .cascader-menu-item-label {
  color: #fa6634;
}
.cascader-container .cascader-menus .cascader-menu {
  width: 393px;
  max-height: 300px;
  overflow: auto;
}
.cascader-menu-item .cascader-menu-item-label {
  width: 335px;
  color: #293a4c;
  white-space: normal !important;
  word-wrap: break-word;
}
.cascader-container .cascader-menus .cascader-menu .cascader-menu-item {
  position: relative;
  height: auto !important;
  padding: 10px 40px 10px 16px !important;
  line-height: 20px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.cascader-container .cascader-menus .cascader-menu .cascader-menu-item span {
  position: absolute;
  right: 16px;
}
.cascader-menus.active {
  top: 59px;
}
.cascader-container .cascader-menus .cascader-menu {
  padding: 8px 0px !important;
  width: 292px;
}
.cascader-container {
  width: 100%;
  height: 100%;
  line-height: 38px;
}
.cascader-value {
  border: none !important;
  padding: 0 40px 0 26px !important;
}
.cascader-menus {
  top: 55px !important;
}

.cascader-menus::-webkit-scrollbar {
  width: 6px; /* 滚动条宽度 */
}

.cascader-menus::-webkit-scrollbar-thumb {
  background: #cccccc;
  border-radius: 4px; /* 圆角 */
}
.cascader-value::after {
  position: absolute;
  top: 50%;
  right: 16px;
  display: block;
  color: #656565;
  font-size: 12px;
  font-family: "iconfont";
  border-radius: 2px;
  transform: rotate(0deg) translateY(-50%);
  content: "\e68e";
}
.cascader-selected-value::after {
  transform: rotate(180deg) translateY(50%);
}