UNPKG

lu2

Version:

Simple and flexible UI component library based on native HTML and JavaScript

1,090 lines (949 loc) 40.7 kB
/** * @Datalist.js * @author zhangxinxu * @version * Created: 16-03-28 * edited: 20-07-30 edit by yanmeng * @description 多功能下拉数据列表 **/ import './Follow.js'; const Datalist = (() => { /** * 数据下拉列表 * 类似于传统Autocomplete功能 * 仿HTML5原生datalist功能实现 * 支持静态呈现(基于现有HTML构建)和动态获取(本地存储或ajax请求) */ // 常量变量 const DATALIST = 'datalist'; const SELECTED = 'selected'; const DISABLED = 'disabled'; const ACTIVE = 'active'; const REVERSE = 'reverse'; // 样式类名统一处理 const CL = { add: (props) => ['ui', DATALIST].concat([props]).join('-'), toString: () => `ui-${DATALIST}` }; let objEventType = { end: 'mouseup' }; if ('ontouchstart' in document) { objEventType = { end: 'touchend' }; } /** * 对输入框元素进行Datalist类扩展 */ class Datalist extends HTMLInputElement { constructor () { super(); if (!this.params) { this.params = {}; } if (!this.element) { this.element = {}; } } /** * 过滤HTML标签的方法 * @param {String} str 需要过滤的HTML字符串 * @return {String} 返回过滤后的HTML字符串 */ static stripHTML (str) { if (typeof str == 'string') { return str.replace(/<\/?[^<>]*>/g, '').replace(/<\/?[^<>]*>/g, ''); } return ''; } /** * 转义HTML标签的方法 * @param {String} str 需要转义的HTML字符串 * @return {String} 转义后的字符串 */ static encodeHTML (str) { if (typeof str == 'string') { return str.replace(/<|&|>/g, function (matches) { return { '<': '&lt;', '>': '&gt;', '&': '&amp;' }[matches]; }); } return ''; } /** * 反转义HTML标签的方法 * @param {String} str 需要反转义的字符串 * @return {String} 反转义后的字符串 */ static decodeHTML (str) { if (typeof str == 'string') { return str.replace(/&lt;|&gt;|&amp;/gi, (matches) => { return { '&lt;': '<', '&gt;': '>', '&amp;': '&' }[matches.toLowerCase()]; }); } return ''; } // 多个参数快捷设置方法 setParams (options) { Object.assign(this.params, options || {}); } /** * @param {Object} value 不同的数据类型统一为function类型 */ convertData (value) { this.params.data = value || this.params.data; // 从本地自动获得数据 if (this.params.data == 'auto') { // 数据实时从<datalist>元素获取 let strAttrList = this.getAttribute('list'); if (strAttrList) { // 走<datalist>元素获取数据 let eleDatalist = document.getElementById(strAttrList); if (!eleDatalist) { // 有可能是内容在后面渲染,没来得及识别 setTimeout(() => { if (document.getElementById(strAttrList)) { this.convertData(); } }, 1); return; } // 去掉浏览器原生的行为 this.removeAttribute('list'); // 数据实时从<datalist>元素获取 this.params.data = function () { return [].slice.call(eleDatalist.querySelectorAll('option')).map(function (eleOption) { let objAttr = {}; [].slice.call(eleOption.attributes).forEach(function (objNameValue) { objAttr[objNameValue.name] = objNameValue.value; }); // value和label是必须的 // 降低filter中出错概率 objAttr.value = objAttr.value || ''; // 如果没有设置value值,尝试使用内容值作为value值 if (!eleOption.hasAttribute('value')) { objAttr.value = eleOption.textContent || ''; } objAttr.label = objAttr.label || ''; // 将dataset值也合并到对象中 let objDataset = eleOption.dataset; for (let strKey in objDataset) { if (strKey != 'value' && strKey != 'label') { objAttr[strKey] = objDataset[strKey]; } else { objAttr[strKey + '2'] = objDataset[strKey]; } } return objAttr; }); }; this.element.datalist = eleDatalist; } else if (this.name && (this.autocomplete === '' || this.autocomplete == 'on')) { this.params.twice = true; // autocomplete交互占位符不参与 this.params.placeholder = false; // 走本地存储获取数据,模拟浏览器autocomplete效果 // 跟随浏览器autocomplete行为规范实现 // 数据从本地localStorage实时获取 this.params.data = () => { let data = []; // 本地获取 let strList = localStorage[DATALIST + '-' + this.name]; if (strList) { strList.split(',').forEach(function (value) { // value必须 if (value && value.trim()) { data.push({ label: '', value: value }); } }); } return data; }; } else { this.params.data = function () { return []; }; } } else if (this.params.data instanceof Array) { // 2. 如果直接data参数 let array = this.params.data; this.params.data = function () { return array; }; } else if (typeof this.params.data == 'function') { // 3. 如果是函数 } else if (typeof this.params.data == 'object' && this.params.data.url) { // 4. 如果是ajax参数对象 let timerAjaxDatalist = null; let objParams = { ...this.params.data }; this.params.data = () => { // 清除延时,避免每次输入都请求 clearTimeout(timerAjaxDatalist); // 搜索关键字的查询字段 let strName = objParams.name || this.name || 'k'; // 没有值的时候清空数据 // 不请求 let strValue = this.value.trim(); if (strValue == '') { this.datalist = []; return []; } let objAjaxParams = new URLSearchParams(objParams.data); // 加入输入数据 objAjaxParams.append(strName, strValue); // URL处理 let strUrlAjax = objParams.url.split('#')[0]; // URL拼接 if (strUrlAjax.split('?').length > 1) { strUrlAjax = strUrlAjax + '&' + objAjaxParams.toString(); } else { strUrlAjax = strUrlAjax + '?' + objAjaxParams.toString(); } // 有2个参数有内置,需要合并 // 1是搜索查询参数 // 2是成功后的回调 let funAjax = async () => { const response = await fetch(strUrlAjax); this.setAttribute('aria-busy', 'true'); const json = await response.json(); this.removeAttribute('aria-busy'); if (json && json.data) { let jsonData = json.data; // encode转义处理 if (this.params.encode && jsonData.map) { jsonData = jsonData.map(obj => { if (obj.value) { obj.value = Datalist.encodeHTML(obj.value); } return obj; }); } this.refresh(this.params.filter.call(this, jsonData, strValue)); // 成功回调 if (objParams.success) { objParams.success(json); } } else if (objParams.error) { objParams.error(json); } }; // 请求保护,200毫秒延迟判断 timerAjaxDatalist = setTimeout(funAjax, 200); }; // autocomplete交互占位符不参与 this.params.placeholder = false; // 边缘超出不重定位 if (!this.dataset.edgeAdjust) { this.dataset.edgeAdjust = 'false'; } } } /** * 本地存储输入框的值 * @return {Object} 返回当前输入框元素 */ store () { // 元素属性值 const strValue = this.val(); const { name: strName } = this; // 只有有值的时候才本地记忆 if (strValue && strName) { // 本地获取 let arrList = (localStorage[`${DATALIST}-${strName}`] || '').split(','); // 如果当前数据并未保存过 const numIndexMatch = arrList.indexOf(strValue); if (numIndexMatch == -1) { // 新值,前面插入 arrList.unshift(strValue); } else if (numIndexMatch != 0) { // 如果当前匹配内容不是首位,顺序提前 // 把需要提前的数组弄出来 let arrSplice = arrList.splice(numIndexMatch, 1); // 重新连接 arrList = [arrSplice, ...arrList]; } // 更改对应的本地存储值 localStorage[`${DATALIST}-${strName}`] = arrList.join(); } return this; } /** * 清除本地存储的值 * @param {String} value value参数存在3种逻辑,具体参见方法内注释 * @return {Object} 返回当前输入框元素 */ removeStore (value) { // value参数存在下面3种逻辑 // 1. 字符串内容,表示移除本地该值数据(如果有) // 2. true,表示清空本地对应该存储 // 3. undefined, 表示使用输入框的value值作为移除对象 // 元素属性值 const strName = this.name; // 值 const strValue = value || this.val(); // 只有data为auto时候才本地记忆 if (strValue && strName) { if (strValue === true) { localStorage.removeItem(`${DATALIST}-${strName}`); } else if (typeof strValue == 'string') { // 本地获取 let arrList = ( localStorage[`${DATALIST}-${strName}`] || '' ).split(','); // 当前数据位置 const numIndexMatch = arrList.indexOf(strValue); if (numIndexMatch != -1) { // 删除 arrList.splice(numIndexMatch, 1); // 更改对应的本地存储值 localStorage[`${DATALIST}-${strName}`] = arrList.join(); } } } return this; } /** * 刷新列表 * @param {Array} data 刷新列表的数据,可缺省,缺省则调用API中的data()方法获取 * @return {Object} 返回当前输入框元素 */ refresh (data) { // 元素们 let eleTarget = this.element.target; if (!eleTarget) { this.create(); eleTarget = this.element.target; } // 此时输入框的值 const strValue = this.val(); // 显示的列表数据 let arrData = data; // 列表的刷新 // 根据data和filter得到最终呈现的数据 if (typeof arrData == 'undefined') { if (typeof this.params.data != 'function') { this.convertData(); } if (typeof this.params.data != 'function') { return this; } arrData = this.params.data(); if (!arrData) { return this; } if (this.params.encode && arrData.map) { arrData = arrData.map(obj => { if (obj.value) { obj.value = Datalist.encodeHTML(obj.value); } return obj; }); } arrData = this.params.filter.call(this, arrData, strValue); if (arrData instanceof Array == false) { return this; } } // max是否通过原生的results属性设置 const strResults = this.getAttribute('results'); const numResults = Number(strResults); if (strResults) { arrData = arrData.slice(0, numResults); } // 暴露最终使用的列表数据 this.datalist = arrData; // 列表HTML组装 let strHtmlList = ''; if (arrData && arrData.length) { // 先不匹配任何列表项 this.params.index = -1; // 占位符 const strAttrPlaceholder = this.getAttribute('placeholder'); const strParamPlaceholder = this.params.placeholder; // 拼接列表字符内容 arrData.forEach((objData, numIndex) => { let strValueEncode = objData.value || ''; // 过滤HTML标签和换行 let strValueStrip = Datalist.stripHTML(strValueEncode).trim().replace(/\n/g, ''); let strLabelStrip = Datalist.stripHTML(objData.label || '').trim().replace(/\n/g, ''); let strClassList = ''; if ( (strValue && strValueStrip == strValue) || (!strValue && strValueStrip == strAttrPlaceholder && strValueStrip != strParamPlaceholder) ) { strClassList = ` ${SELECTED}`; // 这个键盘操作时候需要 this.params.index = numIndex; } // 禁用态,禁用态和选中态不能同时存在 if (objData[DISABLED] || typeof objData[DISABLED] == 'string') { strClassList = ' ' + DISABLED; } if (objData.label) { // 虽然使用其他标签模拟<datalist> // 但是,一些属性值,还是遵循HTML规范 // label应该前置,可以通过相邻选择器控制后面内容的UI strHtmlList = `${strHtmlList}<li class="${CL.add( 'option' )}${strClassList}" data-value="${strValueStrip}" label="${strLabelStrip}" data-index="${numIndex}"><label class="${CL.add( 'label' )}">${objData.label}</label><span class="${CL.add( 'value' )}">${strValueEncode}</span></li>`; } else { // 但是,一些属性值,还是遵循HTML规范 strHtmlList = `${strHtmlList}<li class="${CL.add( 'option' )}${strClassList}" data-value="${strValueStrip}" data-index="${numIndex}"><span class="${CL.add( 'value' )}">${strValueEncode}</span></li>`; } }); } if (strHtmlList != '') { strHtmlList = `<ul class="${CL.add( DATALIST )}">${strHtmlList}</ul>`; } eleTarget.innerHTML = strHtmlList; const eleSelected = eleTarget.querySelector('.' + SELECTED); if (this.display == true && eleSelected) { // 选中元素距离容器上边缘的位置 const numOffsetTop = eleSelected.offsetTop - (eleTarget.lastScrollTop || 0); // 如果不可见 if ( numOffsetTop < 0 || numOffsetTop >= eleSelected.parentElement.clientHeight ) { eleSelected.parentElement.scrollTop = eleSelected.offsetTop; eleTarget.lastScrollTop = eleSelected.offsetTop; } else { eleSelected.parentElement.scrollTop = eleTarget.lastScrollTop || 0; } } if (strHtmlList) { if (this.display == false) { this.show(); } // 重定位 this.position(); } else if (this.display == true) { this.hide(); } } /** * 创建下拉面板 * 方法私有 * @return {Object} 返回当前输入框元素 */ create () { // list属性值需要和创建的列表元素id对应获取 // 原生HTML5应该是对应datalist元素 // 但1. datalist无法自定义UI; 2. IE9-不支持;3. 一些bug存在 // 所以,我们使用普通的ul列表元素模拟 if (!this.element.target) { // 看看是否有list属性值 let strId = this.element.datalist && this.element.datalist.id; if (!strId) { // 如果没有关联id,创建之 strId = `lulu_${Math.random()}`.replace('0.', ''); // 设置关联id this.setAttribute('data-target', strId); } const eleTarget = document.createElement('div'); eleTarget.classList.add(CL); eleTarget.addEventListener('click', (event) => { if (event.touches && event.touches.length) { event = event.touches[0]; } if (!event.target) { return; } const eleClicked = event.target.closest('li'); if (eleClicked && eleClicked.classList.contains(DISABLED) == false) { const strIndex = eleClicked.getAttribute('data-index'); this.params.index = Number(strIndex); // 赋值并关闭列表 this.val(this.datalist[this.params.index]); this.hide(); this.dispatchEvent(new CustomEvent('select', { detail: this.datalist[this.params.index] })); } }); // 方便区分不同的数据列表 if (this.id) { eleTarget.classList.add( CL.add( this.id .replace( /[A-Z]/g, (matches) => `-${matches.toLowerCase()}` ) .replace(/^-+|-+$/g, '') ) ); } // 载入页面 document.body.appendChild(eleTarget); // 元素暴露 this.element.target = eleTarget; // 默认display态 this.display = false; this.dispatchEvent(new CustomEvent('DOMContentLoaded')); } return this; } /** * 输入框赋值或者取值 * @param {String} value 赋予输入框的值,如果缺省,则表示取值 * @param {String} label 赋予输入框的值,如果缺省,则表示取值 * @return {Object} 返回当前输入框元素 */ val (props = {}) { const {value} = props; // 元素们 if (typeof value == 'undefined') { // 取值 return Datalist.encodeHTML(this.value.trim()); } const strValue = value.toString(); // 赋值 this.value = Datalist.decodeHTML(Datalist.stripHTML(strValue.trim())); // 事件 if (strValue != this.oldValue) { // 赋值时候触发的回调事件们 this.dispatchEvent( new CustomEvent('change', { bubbles: true, detail: {...props} }) ); // 由于输入框可输入,因此input事件是一定要触发的 this.dispatchEvent( new CustomEvent('input', { bubbles: true, detail: {...props} }) ); } this.oldValue = strValue; return this; } /** * 一些事件 * @return {Object} 返回当前输入框元素 */ events () { // 事件 // 元素们 if (document.activeElement != this) { this.isFocus = false; } this.addEventListener('blur', function () { this.isFocus = false; }); this.addEventListener('focus', function () { if (window.isKeyEvent) { this.click(); } }); this.addEventListener('click', () => { if (this.display == false) { // autocomplete模式不执行占位符交互 if (this.params.placeholder === true) { this.focusValue = this.value.trim(); if (this.focusValue) { this.setAttribute('placeholder', this.focusValue); } this.value = ''; } if (this.params.twice == true && this.isFocus == true) { this.refresh(); } else if (!this.params.twice) { this.refresh(); } } this.isFocus = true; }); this.addEventListener('input', (event) => { if (event.isTrusted === false) { return; } // 输入行为的时候,如果内容为空,内隐藏列表 if (this.params.placeholder == true || this.value.trim()) { this.refresh(); } else { this.hide(); } }); this.addEventListener('keydown', (event) => { // data为当前列表使用的数据 // index表示当前选中列表的索引值 let arrData = this.datalist; let numIndex = this.params.index; // 面板元素 const eleTarget = this.element.target; if (!eleTarget) return; const eleSelected = eleTarget.querySelector(`.${SELECTED}`); switch (event.code) { case 'Escape': case 'Enter': { // ESC-27 ENTER-13 if (this.display == true) { // 列表隐藏 this.hide(); event.preventDefault(); if (eleSelected) { eleSelected.click(); // 当键盘选值的时候,阻止默认行为 // 例如ENTER表单提交,ESC输入框内容清空 event.preventDefault(); // 文本内容从选中态改为focus态 setTimeout(function () { const eleInput = this; if (this.setSelectionRange) { try { // 部分输入框类型,例如email, number不支持selection eleInput.setSelectionRange( eleInput.value.length, eleInput.value.length ); } catch (e) { eleInput.value = eleInput.value; } } else { eleInput.value = eleInput.value; } // 触发Validate.js中的验证 eleInput.dispatchEvent( new CustomEvent('input', { bubbles: true, detail: arrData[numIndex] }) ); }, 17); } } break; } case 'ArrowUp': case 'ArrowDown': { // UP-38 if (this.display == true && arrData && arrData.length) { event.preventDefault(); // 过滤出可以用来选中的索引 const arrIndexMatchAble = []; arrData.forEach((objData, numIndexMatch) => { if ( !objData[DISABLED] && objData[DISABLED] !== '' ) { arrIndexMatchAble.push(numIndexMatch); } }); // 全部列表都禁用,忽略 if (arrIndexMatchAble.length == 0) return; // 然后索引往后挪一位 let numIndexFilterMatch = arrIndexMatchAble.indexOf( numIndex ); if (event.code == 'ArrowUp') { numIndexFilterMatch--; } else { numIndexFilterMatch++; } if (numIndexFilterMatch < 0) { numIndex = arrIndexMatchAble[ arrIndexMatchAble.length - 1 ]; } else if ( numIndexFilterMatch > arrIndexMatchAble.length - 1 ) { numIndex = arrIndexMatchAble[0]; } else { numIndex = arrIndexMatchAble[numIndexFilterMatch]; } } // 上下键的时候,列表数据不动态获取和过滤 if (arrData[numIndex]) { const curValue = arrData[numIndex]; this.val(curValue); } this.select(); this.refresh(arrData); break; } case 'Delete': { // DELETE-46 if ( this.display == true && this.params.twice == true && eleSelected ) { let strValueSelected = eleSelected.getAttribute( 'data-value' ); // 清除本地存储内容 this.removeStore(strValueSelected); // data中对应对象删除 arrData = arrData.filter( (objData) => objData.value != strValueSelected ); // 阻止默认删除行为 event.preventDefault(); // 获取现在应该显示的值 const objDataLeave = arrData[numIndex] || arrData[numIndex - 1]; if (objDataLeave) { this.val(objDataLeave.value); // 列表刷新 this.refresh(arrData); } else { // 全部删除 this.value = ''; // 列表隐藏 this.hide(); } } break; } } }); // 表单记忆,模拟原生autocomplete行为 if (this.form && this.params.data == 'auto' && this.name && (this.autocomplete == '' || this.autocomplete == 'on')) { this.form.addEventListener('submit', () => { this.store(); }); } // 点击空白处隐藏 document.addEventListener(objEventType.end, (event) => { if (event.touches && event.touches.length) { event = event.touches[0]; } const eleClicked = event.target; const eleTarget = this.element.target; if ( eleTarget && eleClicked.nodeType == 1 && eleTarget.contains(eleClicked) == false ) { this.hide(); } if (eleClicked != this && this.value.trim() == '') { if (this.focusValue) { this.value = this.focusValue; } else if (typeof this.params.placeholder == 'string' && this.params.placeholder !== 'auto') { this.setAttribute('placeholder', this.params.placeholder); } } }); // 浏览器窗口改变重定位 window.addEventListener('resize', () => { if (this.display == true) { this.position(); } }); return this; } /** * 下拉面板的定位 * @return {Object} 返回当前实例 */ position () { // 元素们 const eleTarget = this.element.target; if (this && eleTarget) { this.follow(eleTarget); if (this.display == true) { this.classList.add(ACTIVE); } } // 列表的定位 return this; } /** * 下拉面板显示 * @return {Object} 返回当前实例 */ show () { // 元素们 let eleTarget = this.element.target; if (!eleTarget) { this.create(); eleTarget = this.element.target; } // 当前的显示状态 const isDisplay = this.display; // 列表的宽度 let numWidthTarget = this.params.width; const numWidthTrigger = this.getBoundingClientRect().width || this.clientWidth; if (numWidthTarget == 'auto') { numWidthTarget = numWidthTrigger; } else if (typeof numWidthTarget == 'function') { numWidthTarget = numWidthTarget.call( this, eleTarget ); } if (numWidthTarget != 'auto' && typeof numWidthTarget != 'number') { numWidthTarget = numWidthTrigger; } eleTarget.style.display = 'block'; eleTarget.style.width = numWidthTarget + 'px'; if ( typeof eleTarget.lastScrollTop == 'number' && eleTarget.querySelector('ul') ) { eleTarget.querySelector('ul').scrollTop = eleTarget.lastScrollTop; } // 显示状态标记 this.display = true; // 定位 this.position(); // 显示回调,当之前隐藏时候才触发 if (isDisplay == false) { this.dispatchEvent(new CustomEvent('show', { detail: { type: 'ui-datalist' } })); } } /** * 下拉面板隐藏 * @return {Object} 返回当前实例 */ hide () { // 元素们 const eleTarget = this.element.target; if (eleTarget && this.display == true) { // 记住滚动高度 if (eleTarget.querySelector('ul')) { eleTarget.lastScrollTop = eleTarget.querySelector( 'ul' ).scrollTop; } eleTarget.style.display = 'none'; eleTarget.classList.remove(REVERSE); // 隐藏回调 this.dispatchEvent(new CustomEvent('hide', { detail: { type: 'ui-datalist' } })); } this.classList.remove(ACTIVE); this.classList.remove(REVERSE); // 隐藏状态标记 this.display = false; } // 元素进入页面时候的生命周期函数执行 connectedCallback () { // 避免重复初始化 if (this.isConnectedCallback) { return; } this.params = Object.assign(this.params, { filter (data, value) { // this是当前输入框元素 const arr = []; if (!data || !data.forEach) { return arr; } // 如果设置了filter="none" // 直接返回原始data if (this.getAttribute('filter') == 'none') { return data; } // 默认是从头到尾完全字符匹配 data.forEach(function (obj) { if (!value || obj.value.indexOf(value) == 0) { arr.push(obj); } }); return arr; }, encode: true, index: -1, data: 'auto', // 默认值是'auto',还支持true/和false placeholder: 'auto', width: 'auto' }); // 记住输入框默认的placeholder值 const strAttrPlaceholder = this.getAttribute('placeholder'); if (strAttrPlaceholder) { this.params.placeholder = strAttrPlaceholder; } this.display = false; // 占位符交互标示量 if (this.params.placeholder == 'auto') { this.params.placeholder = true; } // 事件绑定 this.events(); // 列表数据的格式转换 this.convertData(); // 去掉浏览器原生的行为 // 务必灭了浏览器内置的autocomplete 谷歌off 会带出密码 if (this.form) { this.setAttribute('autocomplete', 'off'); } else { this.setAttribute('autocomplete', 'new-password'); } // 全局事件 this.dispatchEvent(new CustomEvent('connected', { detail: { type: 'ui-datalist' } })); this.isConnectedCallback = true; // 观察<datalist>元素的变化,实时更新 setTimeout(() => { if (this.element.datalist) { new MutationObserver(() => { if (this.display == true) { this.refresh(); } }).observe(this.element.datalist, { childList: true, subtree: true }); } }, 20); } } return Datalist; })(); if (!customElements.get('ui-datalist')) { customElements.define('ui-datalist', Datalist, { extends: 'input' }); } export default Datalist;