js做一个带模糊搜索、自动补全的select组件auto-input-select

效果图:

思路

原本是想弄一个输入框input,挡在原生select的前面,结果发现,原生select无论怎么弄,都无法js手动控制展开下拉选,必须点击select,这就很尴尬

然后就只能弄一个输入框input,然后用ul生成下拉选,输入框聚焦打开下拉选,输入框失焦关闭下拉选,最后封装到一个自定义标签中实现了如图效果。

遇到问题

这里有一个问题,类似码值下拉选一般都是配合表单提交的,这里使用原生表单form的提交功能,我在自定义元素中提供了name属性,提供了value属性,还是无法让form提交时带上输入框的值。

因为搜不到解决方法,查看源码也找不到源码实现,因此目前是手动在同级位置插入一个隐藏的input标签,把name属性设置到input上,然后当value更新时同步更新input的值,这样表单提交时就会自动提交了,真是个大聪明

结果

我给它命名为

<auto-input-select></auto-input-select>

一共实现了两种效果,码值搜索(默认)和普通文本搜索

使用方式

使用方式很简单

(1)引入js脚本

<script src="auto-input-select.js"></script>

(2)页面上使用

<auto-input-select type="text" placeholder="请输入查询语言" data='["java","c++","python"]'></auto-input-select>

(3)提供js函数入口实现复杂功能

属性介绍

value设置值,当value变化时,会实时处理
data设置数据源,当data变化时,会实时处理
typecode默认(码值搜索),text(文本搜索)
placeholder设置提示信息
style设置整体样式
input-style设置input样式
item-style设置下拉选样式
select-max-height设置下拉选框的最大高度,默认是输入框的8倍,展示8个选项,超出部分滚动展示
load-max-num选项加载最大数量,默认200

js函数介绍

函数返回类型描述

setData( String | Array

         , Function 

         , Function )

void

设置下拉选数据源,Array字符串或者Array对象,

当type=code时:必须包含name和value

函数1:(item)=>{   return  String ; }  返回value的值; 当数组项种没有value属性,可以传递该函数设置

函数2:(item)=>{   return  String ; }  返回name的值; 当数组项种没有name属性,可以传递该函数设置

当type=text时:数组项不是String类型,会自动将内容转成String

findData( any )数组项返回一个根据value查询到的数据项
setItemStyle( Function )void(data,item,keyWord)=>{   return  String ; }    设置样式回调函数,允许用户根据选项数据和搜索词自由设置选项展示效果,返回html代码字符串
setValue( any )void设置value值,也可以直接访问value属性设置
getValue()value获取value值,也可以直接访问value属性获取
getKeyWord()String获取输入框的搜索词
searchName( String )void允许调用该方式手动触发搜索功能,在下拉框展开的时候,可以看到下拉选项同步变化
open()void允许手动打开下拉选框
close()void允许手动关闭下拉选框
isOpen()bool判断下拉框的打开状态

事件触发

input输入框的输入事件,可以使用标签属性oninput
focus输入框的聚焦事件,可以使用标签属性onfocus
blur输入框的失焦事件,可以使用标签属性onblur
select下拉选选项变化事件,可以使用标签属性onselect
open打开事件,标签属性onopen不管用,只能用addEventListener( "open" , Function )
close关闭事件,可以使用标签属性onclose

源代码

(function () {
    let tagName = 'auto-input-select';
    if (customElements.get(tagName)) {
        return; //避免多次引入报错
    }

    class AutoInputSelect extends HTMLElement {

        /**
         * 内部元素的dom,相当于document
         */
        shadowRoot = null;

        /**
         * 构造参数
         */
        constructor() {
            super();
            this.shadowRoot = this.attachShadow({mode: 'closed'});//元素内部的html不可见,为open时可见
            this.shadowRoot.innerHTML = `<style> :host { --item-height: 25px; } .container { display: inline-block; position: relative; width: 200px; height: var(--item-height); font-size: 13px; background-color: #a6e22e; } .container input { width: 100%; height: 100%; box-sizing: border-box; font-size: 13px; padding: 0 5px; outline: none; border-radius: 2px; border: 1px solid #DADADA; } .container input:focus { border: 1px solid #149bdf } .container>span{ display: none; position: absolute; cursor: pointer; width: 15px; height: 15px; top: calc(50% - 8px); color: #b6b6b6; right: 5px; border-radius: 7px; } .container:hover >span{ display: inline-block;background-color: white; } .container ul { display: none; position: absolute; top: 100%; left: 0; width: 100%; box-sizing: border-box; max-height: 800%; overflow-y: auto; border: 1px solid #ccc; border-radius: 4px; background-color: #fff; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); z-index: 1000; padding: 0; margin: 0; } .container ul li { list-style: none; line-height: var(--item-height); height: var(--item-height); cursor: pointer; padding: 0 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .container ul li:hover { background-color: #f0f0f0; } ::-webkit-scrollbar { height: 10px; width: 6px; } ::-webkit-scrollbar-thumb { background: #7f7f7f80; background-clip: padding-box; border: 1px solid transparent; border-radius: 10px; } </style> <div class="container"> <input type="text" autocomplete='off'> <span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path fill="currentColor" d="m466.752 512-90.496-90.496a32 32 0 0 1 45.248-45.248L512 466.752l90.496-90.496a32 32 0 1 1 45.248 45.248L557.248 512l90.496 90.496a32 32 0 1 1-45.248 45.248L512 557.248l-90.496 90.496a32 32 0 0 1-45.248-45.248z"></path><path fill="currentColor" d="M512 896a384 384 0 1 0 0-768 384 384 0 0 0 0 768m0 64a448 448 0 1 1 0-896 448 448 0 0 1 0 896"></path></svg></span> <ul></ul> </div>`;
        }

        /**
         * 对外提供的标签属性
         */
        type = 'code';//功能类型,目前有码值下拉(code默认)和文本匹配(text)两种
        data = null;//下拉选数据源,数组字符串,或者数组对象 ,必须包含name和value
        value = null;//双向绑定的值
        style = null;//外壳样式
        placeholder = '--请选择--';//提示文本
        inputStyle = null;//设置输入框的样式
        itemStyle = null;//这个是直接追加在元素上的属性样式,将会直接追加在选项元素上
        selectMaxHeight = null;//下拉选的最大高度 可以是百分比,会参照输入框的高度展示
        loadMaxNum = 200;//选项数量加载限制,数量多了导致页面卡顿

        /**
         * 定制化
         * 可以定制一些参数,用于集成到系统中,同时修改下方初始化逻辑
         */

        /**
         * 注册并监控标签属性
         * 在这里定义,可以被监控数据变化,实时更新元素内容
         * 不在这里定义 也可以主动通过 this.getAttribute('style')获取指定属性值
         * 区别就是一个被动接收可以实时更新,一个主动获取
         */
        static get observedAttributes() {
            return ['type', 'value', 'data', 'style', 'placeholder', 'input-style', 'item-style', 'select-max-height', 'load-max-num'];
        }

        /**
         * 这里用于处理监控到的标签属性变化
         * @param name 属性名称
         * @param oldValue 属性旧值
         * @param newValue 属性新值
         */
        attributeChangedCallback(name, oldValue, newValue) {
            switch (name) {
                case 'type':
                    this.type = newValue;
                    break;
                case 'value':
                    this.setValue(newValue);
                    break;
                case 'style':
                    this.style = newValue;
                    if (this.isFinish) this.div.style.cssText = this.style;
                    break;
                case 'data':
                    this.setData(newValue);
                    break;
                case 'placeholder':
                    this.placeholder = newValue;
                    if (this.isFinish) this.input.placeholder = newValue;
                    break;
                case 'input-style':
                    this.inputStyle = newValue;
                    if (this.isFinish) this.input.style.cssText = this.inputStyle;
                    break;
                case 'item-style':
                    this.itemStyle = newValue;
                    this.createItem(this.input.value);
                    break;
                case 'select-max-height':
                    this.selectMaxHeight = newValue;
                    if (this.isFinish) this.ui.style.maxHeight = this.selectMaxHeight;
                    break;
                case 'load-max-num':
                    this.loadMaxNum = newValue;
                    break;
            }
        }


        /**
         * 元素对象
         */
        div = null;//外壳
        input = null;//输入框
        ui = null;//下拉选框

        /**
         * 着重解释:这是一个隐藏的input元素,用于代替本元素表单提交,当存在name属性时触发创建
         * 因为没有找到解决本元素参数绑定到表单的方法,只能创建一个隐藏的input插在页面上使用
         * 实时同步value到这个input中,代替本元素表单提交
         */
        inputElement = null;

        /**
         * 选项内容样式回调,提供用户自定义
         * 回调入参(全量数据data,单个数据item,查询字符串keyWord)
         */
        itemStyleCallback = null;//这个是设置选项展示内容的样式,不能控制选项元素本身

        /**
         * 事件标记
         */
        isCreateItem = false;//是否正在创建下拉选项,避免多次调用冲突
        isFinish = false;//本元素的html是否渲染完成
        isFocus = false;//输入框是否聚焦

        /**
         * 当前选中数据项(手动指定值的时候,选项不存在时,会创建一个临时选项,解决码值越界也能正常读取写入value的问题)
         */
        selectedItemData = null;


        /**
         * html渲染完成回调,做一些事件初始化,数据初始化操作
         */
        connectedCallback() {
            // this.shadowRoot 这个是用来获取,本元素内部的html元素,与外部document隔离的
            this.div = this.shadowRoot.querySelector('div');
            this.input = this.div.children[0];
            this.ui = this.div.children[2];
            this.div.children[1].addEventListener('click', (event)=>{
                event.stopPropagation();
                this.setValue(null)
                this.createItem();
            });
            //根据name判断是否表单绑定
            if (this.getAttribute("name")) {
                this.inputElement = document.createElement('input');
                this.inputElement.name = this.getAttribute("name");
                this.inputElement.type = 'hidden';
                this.parentElement.appendChild(this.inputElement);
            }
            this.isFinish = true;//这个主要是为了标签属性值监控部分加的标识
            //追加样式
            if (this.style && this.style !== '') this.div.style.cssText = this.style;
            this.input.style.cssText = this.inputStyle;
            this.input.placeholder = this.placeholder;
            if (this.selectMaxHeight) this.ui.style.maxHeight = this.selectMaxHeight;

            //定制化逻辑,根据定制化属性初始化数据项,可在这写


            //初始化数据完成后,将初始化值绑定到本元素上,如输入框默认展示对应的选项
            this.setValue(this.value);

            //选项点击事件
            this.ui.addEventListener('click', (event) => {
                //有时候可能点中li内部的元素,这里循环查找
                let li = event.target;
                while (li.parentElement && li.tagName !== 'LI') {
                    li = li.parentElement;
                }
                if (li.tagName === 'LI') {
                    this.setValue(li.data, true);
                }
                event.stopPropagation();
                this.close();//点击选项后手动关闭下拉选
            });
            //将输入框的这些事件绑定到本元素上,用于给开发者使用
            this.input.addEventListener('input', this._handleInput);
            this.input.addEventListener('focus', this._handleFocus);
            this.input.addEventListener('blur', this._handleBlur);
        }

        //input与自定义元素事件绑定,转发
        _handleInput = (event) => {
            this.createItem(this.input.value);
            try {
                this.dispatchEvent(event);
            } catch (e) {
            }
        }
        _handleFocus = (event) => {
            this.isFocus = true;
            this.open();
            this.createItem(this.input.value);
            try {
                this.dispatchEvent(event);
            } catch (e) {
            }
        }
        _handleBlur = (event) => {
            this.isFocus = false;
            try {
                this.dispatchEvent(event);
            } catch (e) {
            }
        }
        /**
         * 自定义下拉选触发事件
         */
        _handleChange = (data) => {
            this.dispatchEvent(new CustomEvent('select', {detail: data}));
        }

        _handleOpen() {
            this.dispatchEvent(new CustomEvent('open'));
        }

        _handleClose() {
            this.dispatchEvent(new CloseEvent('close'));
        }

        /**
         *  根据搜索词创建匹配的下拉选项
         */
        createItem(keyWord) {
            if (!this.isFinish || this.isCreateItem) {
                // console.log("取消操作:初始化未完成,或者多次同时创建");
                return;
            }
            this.isCreateItem = true;
            if (this.data == null) {
                this.data = [];
            }
            this.ui.innerHTML = "";
            this.ui.style.opacity=1;
            let count = 0;
            for (let item of this.data) {
                if (this.loadMaxNum <= count) break;
                if (!keyWord || this.getItemName(item).indexOf(keyWord) > -1) {
                    let showHtml = this.getItemHtml(this.data, item, keyWord);
                    const newElement = document.createElement('li');
                    newElement.data = item;
                    newElement.innerHTML = showHtml;
                    newElement.style.cssText = this.itemStyle;
                    this.ui.appendChild(newElement);
                    count++;
                }
            }
            if(this.ui.innerHTML===''){
                this.ui.style.opacity=0;
            }
            this.isCreateItem = false;
        }

        //创建选项展示内容的html代码
        getItemHtml(data, item, keyWord) {
            if (this.itemStyleCallback) {
                return this.itemStyleCallback(data, item, keyWord)
            } else if (this.type === 'code') {
                return this.getString(item.name);
            } else if (this.type === 'text') {
                return this.getString(item);
            }
        }

        //转字符串
        getString(value) {
            if (!value) return '';
            // 判断值的类型
            if (typeof value === 'object') {
                // 如果是对象类型,转换为 JSON 字符串
                return JSON.stringify(value);
            } else {
                // 否则,直接调用 toString 方法转换为字符串
                return String(value);
            }
        }

        /**
         * 监听下拉选打开后的点击操作
         * (1)点击了下拉选选项,这里就不处理了,这里因为ul比document先一步拿到点击事件,且选中选项后会关闭下拉选,因此通过判断下拉选已经关闭,来判断点击了选项
         * (2)点击输入框里面,不做处理,因为输入框失焦比ul拿到点击还要早,因此这里通过判断输入框聚焦状态,来判断点击了输入框
         * (3)点击其他地方,关闭下拉选
         */
        _handleClick = (event) => {
            if (!this.isFocus && this.isOpen()) {
                if (this.input.value === '') {
                    this.setValue(null)
                } else {
                    this.input.value = this.getItemName();
                }
                this.close();
            }
        }

        //手动打开下拉选
        open() {
            this.ui.style.display = 'block';
            document.addEventListener('click', this._handleClick);
            this._handleOpen();
        }

        //手动关闭下拉选
        close() {
            this.ui.style.display = 'none';
            document.removeEventListener('click', this._handleClick);
            this._handleClose();
        }

        isOpen() {
            return this.ui.style.display === 'block';
        }

        /**
         * 设置下拉选数据源
         * 格式[{name:'xxx',value:'xxx'}]
         * @param data  数据源
         * @param valueCallback  数据中没有value,需要自定义映射
         * @param nameCallback  数据中没有name,需要自定义映射
         */
        setData(data, valueCallback, nameCallback) {
            try {
                if (typeof data === 'string') {
                    data = JSON.parse(data);
                }
                if (!(data instanceof Array)) {
                    console.warn("数据不合法,请提供数组数据,格式:[{name:'xxx',value:'xxx'},...,{name:'xxx',value:'xxx'}]");
                }
                if (nameCallback || valueCallback) {//有自定义映射
                    for (const item of data) {
                        if(valueCallback) item.value=valueCallback(item)
                        if(nameCallback) item.name=nameCallback(item)
                        if (this.value && this.value === item.value) this.setValue(item);
                        this.data.push(item);
                    }
                } else {//没有自定义映射
                    this.data = data;
                    if (this.value) this.setValue(this.value);
                }
                this.createItem();
            } catch (e) {
                console.warn("数据解析报错", data);
                throw new Error(e);
            }
        }

        //根据值进行数据搜索
        findData(value) {
            if (this.data == null) {
                return null;
            }
            for (let item of this.data) {
                if (value === item.value) return item;
            }
            return null;
        }

        //手动搜索
        searchName(keyWord) {
            this.createItem(keyWord);
            if (this.isFinish) this.input.value = keyWord;
        }

        getKeyWord(){
            return this.input.value;
        }

        //设置自定义选项样式回调
        setItemStyle(callback) {
            this.itemStyleCallback = callback;
        }

        //获取当前选中值的文本,换句话说是输入框的文本
        getItemName(item) {
            let itemTmp = this.selectedItemData;
            if (item) itemTmp = item;
            if (!itemTmp) return null;
            if (this.type === 'code') {
                return itemTmp.name || '';
            } else if (this.type === 'text') {
                return this.getString(itemTmp);
            }
        }

        //获取当前选中值
        getValue() {
            return this.value;
        }

        /**
         * 允许手动设置选中值
         * @param value  要设置的选中值
         * @param isItemData  表示传递的value是含value的数据项,无需去data中查询,数据量大的时候相当与小优化
         */
        setValue(value, isItemData) {
            if (this.type === 'code') {
                if (isItemData) {
                    this.selectedItemData = value;
                } else {
                    let item = this.findData(value);
                    if (item) {
                        this.selectedItemData = item;
                    } else {//空值,未匹配的值
                        this.selectedItemData = {name: '', value: value}
                    }
                }
                this.value = this.selectedItemData.value;
            } else if (this.type === 'text') {
                this.value = value;
                this.selectedItemData = value;
            }
            this._handleChange(this.selectedItemData);
            if (this.input) this.input.value = this.getItemName();
            if (this.inputElement) this.inputElement.value = this.getString(this.value);
        }

        // 获取自定义元素的值
        get value() {
            return this.getValue();
        }

        // 设置自定义元素的值
        /**
         * @param {any} val
         */
        set value(val) {
            this.setValue(val);
        }

    }

    // 注册自定义元素到html中
    customElements.define(tagName, AutoInputSelect);
})();

高级使用

我在代码中留下了定制化关键字,当你会写自定义标签的代码时,可以根据位置,追加自定义代码,以便于集成到系统中去,如传递码值类型,在初识化的代码中读取码值类型,异步请求获取系统码值数据进行初始化,这样在系统中使用时,就无需写很多的js代码控制,很方便的实现自动补全下拉选。

文本模糊搜索功能也是一样道理。

注意是刚写的,未经充分验证,有问题欢迎指正

  • 13
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值