UNPKG

mobile-select

Version:

A multi-function mobile phone scrolling selector, support single to multi-select, support multi-level cascade, provide custom callback function, provide update function redraw, relocation function, compatible pc drag and so on.

842 lines (782 loc) 27.1 kB
import { MobileSelectConfig, CustomConfig, CascadeData, OptionData, } from "./types"; import { checkIsPC } from "./utils/tools"; import "./style/mobile-select.less"; export default class MobileSelect { mobileSelect!: HTMLDivElement; trigger!: HTMLElement; wheelList!: HTMLCollectionOf<HTMLElement>; sliderList!: HTMLCollectionOf<HTMLElement>; wheelsContain!: HTMLDivElement; panel!: HTMLDivElement; ensureBtn!: HTMLDivElement; cancelBtn!: HTMLDivElement; grayLayer!: HTMLDivElement; popUp!: HTMLDivElement; /** 初始化滚动位置 由position 或 initValue计算决定 */ initPosition!: number[]; /** 轮子宽度比例 */ initColWidth!: number[]; /** 数据源 */ wheelsData!: CascadeData[]; /** 显示json */ displayJson!: CascadeData[]; /** 当前数值 */ curValue!: string[] | number[] | CascadeData[]; /** 当前索引位置 */ curIndexArr!: number[]; /** 是否级联 */ isCascade!: boolean; /** 是否JSON格式 */ isJsonType!: boolean; /** 开始 Y轴位置 */ startY!: number; /** 结束 Y轴位置 */ moveEndY!: number; /** 当前 Y轴位置 */ moveY!: number; /** 上一次 Y轴位置 */ preMoveY!: number; /** Y轴新旧位移差值 */ offsetY!: number; /** 差值总和? */ offsetSum!: number; /** 最大Border? */ oversizeBorder!: number; /** 是否启用点击状态 */ enableClickStatus!: boolean; /** 选项高度(li元素的高度) */ optionHeight!: number; /** 存放滚动距离的数组 */ curDistance!: number[]; /** 级联数据 相当于wheels[0].data的别名 */ cascadeJsonData!: CascadeData[]; /** 事件监听 */ eventHandleMap!: { [x: string]: { event: string | string[]; fn: Function }; }; /** 级联数据 级联深度 */ initDeepCount!: number; /** 用户配置项 */ config!: MobileSelectConfig; /** 默认配置 */ static defaultConfig = { keyMap: { id: "id", value: "value", childs: "childs" }, position: [], colWidth: [], title: "", connector: " ", ensureBtnText: "确认", cancelBtnText: "取消", triggerDisplayValue: true, scrollSpeed: 1, }; constructor(config: CustomConfig) { if (!MobileSelect.checkRequiredConfig(config)) return; this.config = Object.assign( {}, MobileSelect.defaultConfig, config ) as MobileSelectConfig; this.wheelsData = config.wheels; this.isJsonType = false; this.cascadeJsonData = []; this.displayJson = []; this.curValue = []; this.curIndexArr = []; this.isCascade = false; this.startY; this.moveEndY; this.moveY; this.preMoveY; this.offsetY = 0; this.offsetSum = 0; this.oversizeBorder; this.curDistance = []; this.enableClickStatus = false; this.optionHeight = 0; this.initPosition = config.position || []; this.initColWidth = config.colWidth || []; this.init(); } init(): void { if (!this.checkTriggerAvailable()) return; const { config } = this; this.isJsonType = MobileSelect.checkDataType(this.wheelsData); this.renderComponent(this.wheelsData); // 这里使用getElementsByClassName(不使用querySelectorAll)的原因:返回一个实时的 HTMLCollection, DOM的更改将在更改发生时反映在数组中 this.wheelList = this.mobileSelect.getElementsByClassName( "ms-wheel" ) as HTMLCollectionOf<HTMLElement>; this.sliderList = this.mobileSelect.getElementsByClassName( "ms-select-container" ) as HTMLCollectionOf<HTMLElement>; this.panel = this.mobileSelect.querySelector(".ms-panel")!; this.wheelsContain = this.mobileSelect.querySelector(".ms-wheels")!; this.ensureBtn = this.mobileSelect.querySelector(".ms-ensure")!; this.cancelBtn = this.mobileSelect.querySelector(".ms-cancel")!; this.grayLayer = this.mobileSelect.querySelector(".ms-gray-layer")!; this.popUp = this.mobileSelect.querySelector(".ms-content")!; this.optionHeight = this.mobileSelect.querySelector("li")!.offsetHeight; // 复显初始值 config.initValue && this.setTriggerInnerText(config.initValue); this.setStyle(config); this.isCascade = this.checkCascade(); this.isCascade && this.initCascade(); // 在设置之前就被已生成了displayjson if (config.initValue) { this.initPosition = this.getPositionByInitValue(); } // 补全initPosition if (this.initPosition.length < this.sliderList.length) { const diff = this.sliderList.length - this.initPosition.length; for (let i = 0; i < diff; i++) { this.initPosition.push(0); } } if (this.isCascade) { this.initPosition.forEach((_, index) => { this.checkRange(index, this.initPosition); }); } else { this.setCurDistance(this.initPosition); } // dom事件 this.eventHandleMap = { cancelBtn: { event: "click", fn: () => { this.hide(); this.config.cancel?.(this.curIndexArr, this.curValue, this); this.config.onCancel?.(this.curValue, this.curIndexArr, this); }, }, ensureBtn: { event: "click", fn: () => { this.hide(); if (!this.optionHeight) { this.optionHeight = this.mobileSelect.querySelector("li")!.offsetHeight; } this.setTriggerInnerText(this.getConnectedString()); this.curIndexArr = this.getIndexArr(); this.curValue = this.getCurValue(); this.config.callback?.(this.curIndexArr, this.curValue, this); this.config.onChange?.(this.curValue, this.curIndexArr, this); }, }, trigger: { event: "click", fn: () => { this.show(); }, }, grayLayer: { event: "click", fn: () => this.hide(), }, popUp: { event: "click", fn: (event: Event) => event.stopPropagation(), }, panel: { event: ["touchstart", "touchend", "touchmove"], fn: (event: TouchEvent | MouseEvent) => this.touch(event), }, }; checkIsPC() && (this.eventHandleMap.panel.event = ["mousedown", "mousemove", "mouseup"]); this.registerEvents("add"); this.fixRowStyle(); // 修正列数 config.autoFocus && this.show(); } static checkDataType(wheelsData: CascadeData): boolean { return typeof wheelsData[0]?.data?.[0] === "object"; } static REQUIRED_PARAMS = ["trigger", "wheels"] as (keyof CustomConfig)[]; static checkRequiredConfig(config: CustomConfig): boolean { const requiredParams = MobileSelect.REQUIRED_PARAMS; if (!config) { const singleQuotesParams = requiredParams.map((item) => `'${item}'`); MobileSelect.log( "error", `missing required param ${singleQuotesParams.join(" and ")}.` ); return false; } for (let i = 0; i < requiredParams.length; i++) { const key = requiredParams[i]; if (!config[key]) { MobileSelect.log("error", `missing required param '${key}'.`); return false; } } return true; } static log(type: "error" | "info", tips: string): void { console[type]?.(`[mobile-select]: ${tips}`); } checkTriggerAvailable() { const { config } = this; // @ts-ignore this.trigger = config.trigger instanceof HTMLElement ? config.trigger : document.querySelector(config.trigger); if (!this.trigger) { MobileSelect.log( "error", "trigger HTMLElement does not found on your document." ); return false; } return true; } /** 根据initValue 获取initPostion 需要区分级联和非级联情况 注意此时displayJson还没生成 */ getPositionByInitValue(): number[] { const { keyMap, connector, initValue } = this.config; const valueArr = initValue?.split(connector) || []; if (this.isJsonType) { let childList = this.wheelsData[0]?.data; return valueArr.reduce((result, cur) => { const posIndex = childList?.findIndex( (item: CascadeData) => item[keyMap.value] == cur // 此处使用弱等 因为value有可能是数字类型 ); result.push(posIndex < 0 ? 0 : posIndex); childList = childList[posIndex]?.[keyMap.childs]; return result; }, [] as unknown as number[]); } return valueArr.reduce((result, cur, index) => { const posIndex = this.wheelsData[index]?.data?.findIndex( (item: string | number) => item == cur // 此处使用弱等 因为value有可能是数字类型 ); result.push(posIndex < 0 ? 0 : posIndex); return result; }, [] as unknown as number[]); } getConnectedString() { let connectedStr = ""; for (let i = 0; i < this.wheelList.length; i++) { i == this.wheelList.length - 1 ? (connectedStr += this.getInnerText(i)) : (connectedStr += this.getInnerText(i) + this.config.connector); } return connectedStr; } setTriggerInnerText(value: string) { if (this.config.triggerDisplayValue) { this.trigger.textContent = value; } } setValue(valList: string[] | number[] | CascadeData[]) { if (!valList || !valList.length) return; if ( (this.isJsonType && typeof valList[0] !== "object") || (!this.isJsonType && typeof valList[0] === "object") ) { MobileSelect.log( "error", `The setValue() input format should be same with getValue(), like: ${JSON.stringify( this.getValue() )}` ); return; } const { keyMap } = this.config; valList.forEach((targetVal, sliderIndex) => { const sliderData = this.isCascade ? this.displayJson[sliderIndex] : this.wheelsData[sliderIndex]?.data; const targetIndex = sliderData?.findIndex( (item: string | number | CascadeData) => { return this.isJsonType ? (targetVal as CascadeData)[keyMap.id] == (item as CascadeData)[keyMap.id] || (targetVal as CascadeData)[keyMap.value] == (item as CascadeData)[keyMap.value] : targetVal == item; } ); this.locatePosition(sliderIndex, targetIndex); }); this.setTriggerInnerText(this.getConnectedString()); } setTitle(title: string): void { this.mobileSelect.querySelector(".ms-title")!.innerHTML = title; } setStyle(config: MobileSelectConfig): void { if (config.ensureBtnColor) { this.ensureBtn.style.color = config.ensureBtnColor; } if (config.cancelBtnColor) { this.cancelBtn.style.color = config.cancelBtnColor; } if (config.titleColor) { const titleDom = this.mobileSelect.querySelector<HTMLDivElement>(".ms-title")!; titleDom.style.color = config.titleColor; } if (config.textColor) { this.panel = this.mobileSelect.querySelector(".ms-panel")!; this.panel.style.color = config.textColor; } if (config.titleBgColor) { const btnBar = this.mobileSelect.querySelector<HTMLDivElement>(".ms-btn-bar")!; btnBar.style.backgroundColor = config.titleBgColor; } if (config.bgColor) { this.panel = this.mobileSelect.querySelector(".ms-panel")!; const shadowMask = this.mobileSelect.querySelector<HTMLDivElement>(".ms-shadow-mask")!; this.panel.style.backgroundColor = config.bgColor; shadowMask.style.background = "linear-gradient(to bottom, " + config.bgColor + ", rgba(255, 255, 255, 0), " + config.bgColor + ")"; } if (typeof config.maskOpacity === "number") { const grayMask = this.mobileSelect.querySelector<HTMLDivElement>(".ms-gray-layer")!; grayMask.style.background = "rgba(0, 0, 0, " + config.maskOpacity + ")"; } } show(): void { this.mobileSelect.classList.add("ms-show"); document.querySelector("body")?.classList.add("ms-show"); if (typeof this.config.onShow === "function") { this.config.onShow?.(this.curValue, this.curIndexArr, this); } } hide(): void { this.mobileSelect.classList.remove("ms-show"); document.querySelector("body")?.classList.remove("ms-show"); if (typeof this.config.onHide === "function") { this.config.onHide?.(this.curValue, this.curIndexArr, this); } } registerEvents(type: "add" | "remove"): void { for (const [domName, item] of Object.entries(this.eventHandleMap)) { if (typeof item.event === "string") { (this[domName as keyof MobileSelect] as HTMLElement)[ `${type}EventListener` ](item.event, item.fn as EventListener, { passive: false }); } else { // 数组 item.event.forEach((eventName) => { (this[domName as keyof MobileSelect] as HTMLElement)[ `${type}EventListener` ](eventName, item.fn as EventListener, { passive: false }); }); } } } destroy(): void { this.registerEvents("remove"); this.mobileSelect?.parentNode?.removeChild(this.mobileSelect); } getOptionsHtmlStr(childs: CascadeData): string { const { keyMap } = this.config; let tempHTML = ""; if (this.isJsonType) { for (let j = 0; j < childs.length; j++) { // 行 const id = childs[j][keyMap.id]; const val = childs[j][keyMap.value]; tempHTML += `<li data-id="${id}">${val}</li>`; } } else { for (let j = 0; j < childs.length; j++) { // 行 tempHTML += "<li>" + childs[j] + "</li>"; } } return tempHTML; } renderComponent(wheelsData: CascadeData[]): void { this.mobileSelect = document.createElement("div"); this.mobileSelect.className = "ms-mobile-select"; this.mobileSelect.innerHTML = `<div class="ms-gray-layer"></div> <div class="ms-content"> <div class="ms-btn-bar"> <div class="ms-fix-width"> <div class="ms-cancel">${this.config.cancelBtnText}</div> <div class="ms-title">${this.config.title || ""}</div> <div class="ms-ensure">${this.config.ensureBtnText}</div> </div> </div> <div class="ms-panel"> <div class="ms-fix-width"> <div class="ms-wheels"></div> <div class="ms-select-line"></div> <div class="ms-shadow-mask"></div> </div> </div>`; document.body.appendChild(this.mobileSelect); // 根据数据来渲染wheels let tempHTML = ""; for (let i = 0; i < wheelsData.length; i++) { // 列 tempHTML += `<div class="ms-wheel" data-index="${i}"><ul class="ms-select-container">`; tempHTML += this.getOptionsHtmlStr(wheelsData[i].data); tempHTML += "</ul></div>"; } this.mobileSelect.querySelector(".ms-wheels")!.innerHTML = tempHTML; } // 级联数据滚动时 右侧列数据的变化 reRenderWheels(): void { const diff = this.wheelList.length - this.displayJson.length; if (diff > 0) { for (let i = 0; i < diff; i++) { this.wheelsContain.removeChild( this.wheelList[this.wheelList.length - 1] ); } } for (let i = 0; i < this.displayJson.length; i++) { if (this.wheelList[i]) { this.sliderList[i].innerHTML = this.getOptionsHtmlStr( this.displayJson[i] ); } else { const tempWheel = document.createElement("div"); tempWheel.className = "ms-wheel"; tempWheel.innerHTML = `<ul class="ms-select-container">${this.getOptionsHtmlStr( this.displayJson[i] )}</ul>`; tempWheel.setAttribute("data-index", i.toString()); this.wheelsContain.appendChild(tempWheel); } } } checkCascade(): boolean { const { keyMap } = this.config; if (this.isJsonType) { const node = this.wheelsData[0].data; for (let i = 0; i < node.length; i++) { if (keyMap.childs in node[i] && node[i][keyMap.childs]?.length > 0) { this.cascadeJsonData = this.wheelsData[0].data; return true; } } } return false; } initCascade(): void { this.displayJson.push(this.cascadeJsonData); if (this.initPosition.length > 0) { this.initDeepCount = 0; this.initCheckArrDeep(this.cascadeJsonData[this.initPosition[0]]); } else { this.checkArrDeep(this.cascadeJsonData[0]); } this.reRenderWheels(); } initCheckArrDeep(parent: CascadeData): void { if (parent) { const { keyMap } = this.config; if (keyMap.childs in parent && parent[keyMap.childs].length > 0) { this.displayJson.push(parent[keyMap.childs]); this.initDeepCount++; const nextNode = parent[keyMap.childs][this.initPosition[this.initDeepCount]]; if (nextNode) { this.initCheckArrDeep(nextNode); } else { this.checkArrDeep(parent[keyMap.childs][0]); } } } } checkArrDeep(parent: CascadeData): void { // 检测子节点深度 修改displayJson if (!parent) return; const { keyMap } = this.config; if (keyMap.childs in parent && parent[keyMap.childs].length > 0) { this.displayJson.push(parent[keyMap.childs]); // 生成子节点数组 this.checkArrDeep(parent[keyMap.childs][0]); // 检测下一个子节点 } } checkRange(index: number, posIndexArr: number[]): void { const deleteNum = this.displayJson.length - 1 - index; const { keyMap } = this.config; for (let i = 0; i < deleteNum; i++) { this.displayJson.pop(); // 修改 displayJson } let resultNode; for (let i = 0; i <= index; i++) { resultNode = i == 0 ? this.cascadeJsonData[posIndexArr[0]] : (resultNode as unknown as CascadeData)?.[keyMap.childs]?.[ posIndexArr[i] ]; } this.checkArrDeep(resultNode); this.reRenderWheels(); this.fixRowStyle(); this.setCurDistance(this.resetPosition(index, posIndexArr)); } resetPosition(index: number, posIndexArr: number[]): number[] { const tempPosArr = [...posIndexArr]; let tempCount; if (this.sliderList.length > posIndexArr.length) { tempCount = this.sliderList.length - posIndexArr.length; for (let i = 0; i < tempCount; i++) { tempPosArr.push(0); } } else if (this.sliderList.length < posIndexArr.length) { tempCount = posIndexArr.length - this.sliderList.length; for (let i = 0; i < tempCount; i++) { tempPosArr.pop(); } } for (let i = index + 1; i < tempPosArr.length; i++) { tempPosArr[i] = 0; } return tempPosArr; } updateWheels(data: CascadeData[]): void { if (this.isCascade) { this.cascadeJsonData = data; this.displayJson = []; this.initCascade(); if (this.initPosition.length < this.sliderList.length) { const diff = this.sliderList.length - this.initPosition.length; for (let i = 0; i < diff; i++) { this.initPosition.push(0); } } this.setCurDistance(this.initPosition); this.fixRowStyle(); } } updateWheel( sliderIndex: number, data: Omit<OptionData, "CascadeData">[] ): void { if (this.isCascade) { MobileSelect.log( "error", "'updateWheel()' not support cascade json data, please use 'updateWheels()' instead to update the whole data source" ); return; } let tempHTML = ""; tempHTML += this.getOptionsHtmlStr(data); this.wheelsData[sliderIndex] = this.isJsonType ? { data } : data; this.sliderList[sliderIndex].innerHTML = tempHTML; } fixRowStyle(): void { // 自定义列宽度比例 用width不用flex的原因是可以做transition过渡 if ( this.initColWidth.length && this.initColWidth.length === this.wheelList.length ) { const widthSum = this.initColWidth.reduce((cur, pre) => cur + pre, 0); this.initColWidth.forEach((item, index) => { this.wheelList[index].style.width = ((item / widthSum) * 100).toFixed(2) + "%"; }); return; } const width = (100 / this.wheelList.length).toFixed(2); for (let i = 0; i < this.wheelList.length; i++) { this.wheelList[i].style.width = width + "%"; } } getIndex(distance: number): number { return Math.round((2 * this.optionHeight - distance) / this.optionHeight); } getIndexArr(): number[] { const temp = []; for (let i = 0; i < this.curDistance.length; i++) { temp.push(this.getIndex(this.curDistance[i])); } return temp; } getCurValue(): string[] | number[] | CascadeData[] { const temp = []; const positionArr = this.getIndexArr(); const { keyMap } = this.config; if (this.isCascade) { for (let i = 0; i < this.wheelList.length; i++) { const tempObj = this.displayJson[i][positionArr[i]]; if (tempObj) { temp.push({ [keyMap.id]: tempObj[keyMap.id], [keyMap.value]: tempObj[keyMap.value], }); } } } else if (this.isJsonType) { for (let i = 0; i < this.curDistance.length; i++) { temp.push(this.wheelsData[i].data[this.getIndex(this.curDistance[i])]); } } else { for (let i = 0; i < this.curDistance.length; i++) { temp.push(this.getInnerText(i)); } } return temp; } getValue(): string[] | number[] | CascadeData[] { return this.getCurValue(); } calcDistance(index: number): number { return 2 * this.optionHeight - index * this.optionHeight; } setCurDistance(indexArr: number[]): void { const temp = []; for (let i = 0; i < this.sliderList.length; i++) { temp.push(this.calcDistance(indexArr[i])); this.movePosition(this.sliderList[i], temp[i]); } this.curDistance = temp; } fixPosition(distance: number): number { return -(this.getIndex(distance) - 2) * this.optionHeight; } movePosition(theSlider: HTMLElement, distance: number): void { theSlider.style.transform = "translate3d(0," + distance + "px, 0)"; } locatePosition(sliderIndex: number, posIndex: number): void { if (sliderIndex === undefined || posIndex === undefined || posIndex < 0) return; this.curDistance[sliderIndex] = this.calcDistance(posIndex); this.movePosition( this.sliderList[sliderIndex], this.curDistance[sliderIndex] ); if (this.isCascade) { this.checkRange(sliderIndex, this.getIndexArr()); } } updateCurDistance(theSlider: HTMLElement, index: number): void { this.curDistance[index] = parseInt(theSlider.style.transform.split(",")[1]); } getInnerText(sliderIndex: number): string { const lengthOfList = this.sliderList[sliderIndex].getElementsByTagName("li").length; let index = this.getIndex(this.curDistance[sliderIndex]); if (index >= lengthOfList) { index = lengthOfList - 1; } else if (index < 0) { index = 0; } return ( this.sliderList[sliderIndex].getElementsByTagName("li")[index] ?.textContent || "" ); } touch(event: TouchEvent | MouseEvent): void { const path = event.composedPath && event.composedPath(); const currentCol = path.find((domItem) => { return (domItem as HTMLElement).classList?.contains("ms-wheel"); }) as HTMLElement; if (!currentCol) return; const theSlider = currentCol.firstChild as HTMLElement; // ul.select-container const index = parseInt(currentCol.getAttribute("data-index") || "0"); switch (event.type) { case "touchstart": case "mousedown": theSlider.style.transition = "none 0s ease-out"; this.startY = Math.floor( event instanceof TouchEvent ? event.touches[0].clientY : event.clientY ); this.preMoveY = this.startY; if (event.type === "mousedown") { this.enableClickStatus = true; } break; case "touchmove": case "mousemove": event.preventDefault(); if (event.type === "mousemove" && !this.enableClickStatus) break; this.moveY = Math.floor( event instanceof TouchEvent ? event.touches[0].clientY : event.clientY ); this.offsetY = (this.moveY - this.preMoveY) * this.config.scrollSpeed; this.updateCurDistance(theSlider, index); this.curDistance[index] = this.curDistance[index] + this.offsetY; this.movePosition(theSlider, this.curDistance[index]); this.preMoveY = this.moveY; break; case "touchend": case "mouseup": theSlider.style.transition = "transform 0.18s ease-out"; this.moveEndY = Math.floor( event instanceof TouchEvent ? event.changedTouches[0].clientY : event.clientY ); this.offsetSum = this.moveEndY - this.startY; this.oversizeBorder = -(theSlider.getElementsByTagName("li").length - 3) * this.optionHeight; if (this.offsetSum == 0) { // offsetSum为0, 相当于点击事件 点击了中间的选项 const clickOffetNum = Math.floor( (window.innerHeight - this.moveEndY) / 40 ); if (clickOffetNum != 2) { const tempOffset = clickOffetNum - 2; const newDistance = this.curDistance[index] + tempOffset * this.optionHeight; if ( newDistance <= 2 * this.optionHeight && newDistance >= this.oversizeBorder ) { this.curDistance[index] = newDistance; this.movePosition(theSlider, this.curDistance[index]); this.config.transitionEnd?.( this.getIndexArr(), this.getCurValue(), this ); this.config.onTransitionEnd?.( this.getCurValue(), this.getIndexArr(), this ); } } } else { // 修正位置 this.updateCurDistance(theSlider, index); this.curDistance[index] = this.fixPosition(this.curDistance[index]); if (this.curDistance[index] > 2 * this.optionHeight) { this.curDistance[index] = 2 * this.optionHeight; } else if (this.curDistance[index] < this.oversizeBorder) { this.curDistance[index] = this.oversizeBorder; } this.movePosition(theSlider, this.curDistance[index]); this.config.transitionEnd?.( this.getIndexArr(), this.getCurValue(), this ); this.config.onTransitionEnd?.( this.getCurValue(), this.getIndexArr(), this ); } if (event.type === "mouseup") { this.enableClickStatus = false; } if (this.isCascade) { this.checkRange(index, this.getIndexArr()); } break; } } }