封装省市区三级级联组件

106 阅读1分钟

前提

箭头图标使用的是阿里巴巴矢量图标库

1.属性

1.1 datas

  • 三级级联数据 - 树状数据
  • 类型为Array
  • 只要按照这个数据格式的数据,各种关系的三级级联组件都可以使用

数组中对象示例如下

  • label: 城市名字(显示的字段)
  • value: 城市对应的编码
  • children: 该城市下的子级 (例: 省份下的市区、市区下的区县)
{
        "label": "北京市",
        "value": "110000",
        "children": [
            {
                "label": "市辖区",
                "value": "110100",
                "children": [
                    {
                        "label": "东城区",
                        "value": "110101"
                    },
                    {
                        "label": "西城区",
                        "value": "110102"
                    },
                    {
                        "label": "朝阳区",
                        "value": "110105"
                    },
                    {
                        "label": "丰台区",
                        "value": "110106"
                    },
                    {
                        "label": "石景山区",
                        "value": "110107"
                    },
                    {
                        "label": "海淀区",
                        "value": "110108"
                    },
                    {
                        "label": "门头沟区",
                        "value": "110109"
                    },
                    {
                        "label": "房山区",
                        "value": "110111"
                    },
                    {
                        "label": "通州区",
                        "value": "110112"
                    },
                    {
                        "label": "顺义区",
                        "value": "110113"
                    },
                    {
                        "label": "昌平区",
                        "value": "110114"
                    },
                    {
                        "label": "大兴区",
                        "value": "110115"
                    },
                    {
                        "label": "怀柔区",
                        "value": "110116"
                    },
                    {
                        "label": "平谷区",
                        "value": "110117"
                    },
                    {
                        "label": "密云区",
                        "value": "110118"
                    },
                    {
                        "label": "延庆区",
                        "value": "110119"
                    }
                ]
            }
        ]
    },

2.实例

2.1 示例

<script setup>
    import datas from '@/assets/data.json' // 三级级联组件的树状数据
    
    import cascader from "./cascader.vue"; // 三级级联组件
</script>

<template>
    <div>
        <cascader :datas="datas"></cascader>
    </div>
</template>

2.2 实现 cascader.vue

<script setup>
    const props = defineProps({
        datas: {
            type: Array,
            default: () => ([])
        }
    })

    const datas = computed(() => props.datas)

    const selProvinceRef = ref(null)
    const selCityRef = ref(null)
    const selAreaRef = ref(null)

    /**
     * 
     * @param {HTMLElement} select 要填充的下拉列表
     * @param {Array} list 被填充的数据, 数组
     */
    function fillSelect (select, list) {
        // 如果下拉列表有值 得先清除
        const ul = select.querySelector('.options');
        ul.innerHTML.length && (ul.innerHTML = '');


        if(!list.length) {
            select.classList.add('disabled')
            setTitleText(select, '请选择')
            return
        } 
        
        select.classList.remove('disabled')

        const tip = select.dataset.tip
        setTitleText(select, `请选择${tip}`)

        select.datas = list // 将目前填充的数据; 添加到dom对象的属性datas中
        
        const Fragment = document.createDocumentFragment()
        for (const item of list) {
            const li = document.createElement('li');
            li.textContent = item.label;
            Fragment.appendChild(li);
        }
        ul.appendChild(Fragment)

        // 以下也可以实现li填充
        // ul.innerHTML = list.map(obj => `<li>${obj.label}</li>`).join('')
    }

    /**
     * 注册公共的事件处理
     * @param {HTMLElement} select 下拉框dom元素
     */
     function regCommonEvent (select) {
        // 1.title点击事件 - 打开下拉框
        const titleDom = select.querySelector('.title')
        titleDom.addEventListener('click', () => {
            // 禁用状态下无法操作
            if(select.classList.contains('disabled')) return

            // 清除所有下拉框的下拉状态
            const selectDomList = document.querySelectorAll('.select.expand')
            for (const sel of selectDomList) {
                if(sel !== select) {
                    sel.classList.remove('expand')
                }
            }
            // 切换当前的下拉状态即可
            select.classList.toggle('expand')
        })

        // 2.ul点击事件 - 下拉框内容选择
        const ulDom = select.querySelector('.options')
        ulDom.addEventListener('click', (e) => {
            if(e.target.tagName !== 'LI') return

            // 获取到之前选中的li元素 
            const beforeActiveLi = select.querySelector('li.active')
            // 当刚打开下拉框的时候 此前都未选中 所以得做一个判定
            beforeActiveLi && beforeActiveLi.classList.remove('active')
            e.target.classList.add('active')

            // 设置当前选中的值
            setTitleText(select, e.target.textContent)
            select.classList.remove('expand')
        })
    }

    /**
     * 注册省份的特殊点击事件
     */
     function regProvinceEvent () {
        const ul = selProvinceRef.value.querySelector('.options')
        ul.addEventListener('click', (e) => {
            if(e.target.tagName !== 'LI') return

            const li = e.target
            // 填充城市
            const pr = selProvinceRef.value.datas.find(item => item.label === li.textContent)
            fillSelect(selCityRef.value, pr.children)
            
            // 填充地区
            fillSelect(selAreaRef.value, [])
        })
    }

    /**
     * 注册城市的特殊点击事件
     */
     function regCityEvent () {
        const ul = selCityRef.value.querySelector('.options')
        ul.addEventListener('click', (e) => {
            if(e.target.tagName !== 'LI') return

            const li = e.target
            // 填充城市
            const city = selCityRef.value.datas.find(item => item.label === li.textContent)
            fillSelect(selAreaRef.value, city.children)
        })
    }

    function init () {
        fillSelect(selProvinceRef.value, datas.value)
        fillSelect(selCityRef.value, []) // 一开始,无法填充城市
        fillSelect(selAreaRef.value, []) // 一开始,无法填充地区

        regCommonEvent(selProvinceRef.value)
        regCommonEvent(selCityRef.value)
        regCommonEvent(selAreaRef.value)

        regProvinceEvent()
        regCityEvent()
    }

    /**
     * 
     * @param {HTMLElement} select 下拉框DOM元素
     * @param {string} textContent title下的span文字
     */
    function setTitleText (select, textContent) {
        const span = select.querySelector('.title span')
        span.textContent = textContent
    }

    onMounted(() => {
        init()
    })
</script>

<template>
    <div class="cascader">
        <div class="select" ref="selProvinceRef" data-tip="省份">
            <div class="title">
                <span>请选择</span>
                <i class="iconfont icon-xiangxiajiantou"></i>
            </div>
            <ul class="options"></ul>
        </div>

        <div class="select" ref="selCityRef" data-tip="市区">
            <div class="title">
                <span>请选择</span>
                <i class="iconfont icon-xiangxiajiantou"></i>
            </div>
            <ul class="options"></ul>
        </div>

        <div class="select" ref="selAreaRef" data-tip="区县">
            <div class="title">
                <span>请选择</span>
                <i class="iconfont icon-xiangxiajiantou"></i>
            </div>
            <ul class="options"></ul>
        </div>
    </div>
</template>

<style scoped>
    .cascader {
        box-sizing: border-box;
    }

    ul {
    padding: 0;
    margin: 0;
    }

    li {
        list-style: none;
    }

    .select {
        display: inline-block;
        margin: 0 5px;
        position: relative;
        white-space: nowrap;

        color: #666;
    }

    .title {
        min-width: 150px;
        height: 40px;
        line-height: 40px;
        padding: 0 10px;
        display: flex;
        justify-content: space-between;
        font-size: 14px;
        border: 1px solid #ccc;
        border-radius: 5px;
        cursor: pointer;
    }

    .title .iconfont {
        /* font-size: 10px; */
        transition: all .25s;
    }

    .options {
        font-size: 12px;
        border: 1px solid #ccc;
        border-radius: 5px;
        max-height: 300px;
        min-width: 100%;
        position: absolute;
        padding: 10px;
        top: 50px;
        background-color: #fff;

        display: grid;
        grid-auto-flow: column;
        grid-template-rows: repeat(auto-fit, 20px);
        /* 行间距 */
        row-gap: 6px; 
        column-gap: 26px;
        /* 每一项左对齐 默认拉伸 */
        justify-items: left;
        box-shadow: 0 0 3px rgba(0, 0, 0, .5);

        transform: scaleY(0);
        opacity: 0;
        transition: all .23s;
        transform-origin: 10px -10px;
    }

    .options li {
        cursor: pointer;
        padding: 3px 6px;
        border-radius: 5px;
    }

    .options li.active {
        background-color: #eec05a;
    }

    .options::before {
        content: "";
        width: 10px;
        height: 10px;
        position: absolute;
        left: 70px;
        top: -6px;
        border: 1px solid #ccc;
        transform: rotate(45deg);
        border-bottom: none;
        border-right: none;
        background-color: #fff;
    }

    .select.expand .options {
        transform: scaleY(1);
        opacity: 1;
    }

    .select.expand .iconfont {
        transform: rotate(180deg);
    }

    .select.disabled {
        color: #ccc;
    } 

    .select.disabled .title {
        cursor: not-allowed;
    }
</style>

最后

请各位大佬请教指正, 很想学封装组件这一类的知识。