由於主流瀏覽器對select元素渲染不同,所以在每種瀏覽器下顯示也不一樣,最主要的是默認情況下UI太粗糙,即使通過css加以美化也不能達到很美觀的效果。這對於我們這些專注於UX的前端開發人員是無法容忍的。於是在項目不太忙的時候,就計划寫一個模擬的select控件出來。接下來就把實現的細節、遇到的問題以及如何使用和大家分享一下。
1. 實現細節
init: function(context) { //獲取指定上下文所有select元素 var elems = squid.getElementsByTagName('select', context) this.globalEvent() this.initView(elems) }
在一個用戶注冊的應用場景,有多個select元素。模擬的select控件(以下簡稱jselect)初始化方法會獲取頁面上所有select元素,然后綁定全局事件globalEvent,初始化頁面顯示initView。globalEvent方法如下:
globalEvent: function() { //document 添加click事件,用戶處理每個jselect元素展開關閉 var target, className, elem, wrapper, status, that = this; squid.on(document, 'click', function(event) { target = event.target, className = target.className; switch(className) { case 'select-icon': case 'select-default unselectable': elem = target.tagName.toLowerCase() === 'div' ? target : target.previousSibling wrapper = elem.nextSibling.nextSibling //firefox 鼠標右鍵會觸發click事件 //鼠標左鍵點擊執行 if(event.button === 0) { //初始化選中元素 that.initSelected(elem) if(squid.isHidden(wrapper)) { status = 'block' //關閉所有展開jselect that.closeSelect() }else{ status = 'none' } wrapper.style.display = status elem.focus() }else if(event.button === 2){ wrapper.style.display = 'none' } that.zIndex(wrapper) break case 'select-option': case 'select-option selected': if(event.button === 0) { that.fireSelected(target, target.parentNode.parentNode.previousSibling.previousSibling) wrapper.style.display = 'none' } break default: while(target && target.nodeType !== 9) { if(target.nodeType === 1) { if(target.className === 'select-wrapper') { return } } target = target.parentNode } that.closeSelect() break } }) }
globalEvent實現了在document綁定click事件,然后在頁面上觸發點擊事件的時候通過事件代理來判斷當前點擊元素是否是需要進行處理的目標元素,判斷條件是通過元素的class,代碼中語句的分支分別是:展開當前點擊的jselect元素下拉、選中點擊列表項、判斷是否需要關閉jselect。
initView方法如下:
initView: function(elems) { var i = 0, elem, length = elems.length, enabled; for(; i < length; i++) { elem = elems[i] enabled = elem.getAttribute('data-enabled') //使用系統select if(!enabled || enabled === 'true') continue if(squid.isVisible(elem)) elem.style.display = 'none' this.create(elem) } }
initView實現了將需要使用jselect替換的select元素先隱藏然后調用create方法,生成單個jselect的整體結構並插入到頁面並替代默認select位置。
create方法如下:
create: function(elem) { var data = [], i = 0, length, option, options, value, text, obj, lis, ul, _default, icon, selectedText, selectedValue, div, wrapper, position, left, top, cssText; options = elem.getElementsByTagName('option') length = options.length for(; i < length; i++) { option = options[i] value = option.value text = option.innerText || option.textContent obj = { value: value, text: text } if(option.selected) { selectedValue = value selectedText = text obj['selected'] = true } data.push(obj) } lis = this.render(this.tmpl, data) ul = '<ul class="select-item">' + lis + '</ul>' // div = document.createElement('div') div.style.display = 'none' div.className = 'select-wrapper' //已選元素 _default = document.createElement('div') _default.className = 'select-default unselectable' _default.unselectable = 'on' //讓div元素能夠獲取焦點 _default.setAttribute('tabindex', '1') _default.setAttribute('data-value', selectedValue) _default.setAttribute('hidefocus', true) _default.innerHTML = selectedText div.appendChild(_default) //選擇icon icon = document.createElement('span') icon.className = 'select-icon' div.appendChild(icon) //下拉列表 wrapper = document.createElement('div') wrapper.className = 'select-list hide' wrapper.innerHTML = ul //生成新的元素 div.appendChild(wrapper) //插入到select元素后面 elem.parentNode.insertBefore(div, null) //獲取select元素left top值 //先設置select顯示,取完left, top值后重新隱藏 elem.style.display = 'block' //事件綁定 this.sysEvent(div) position = squid.position(elem) elem.style.display = 'none' left = position.left top = position.top cssText = 'left: ' + left + 'px; top: ' + top + 'px; display: block;' div.style.cssText = cssText }
create方法實現了將系統select數據拷貝到jselect下拉列表,jselect的層級關系是最外層有一個class為select-wrapper的元素包裹,里面有class為select-default的元素用於存放已選的元素,class為select-icon的元素用戶告訴用戶這是一個下拉列表,class為select-list的div元素里面包含了一個ul元素里面是從系統select拷貝的option的文本和值分別存放在li元素的文本和data-value屬性。sysEvent方法是為jselect添加點擊展開關閉下拉列表事件以及鍵盤上下選擇下拉元素回車選中下拉元素事件。squid.position方法用於獲取系統select元素相對於其offsetParent的位置,這里與獲取系統select元素的offset是有區別。其實就是獲取自己的offset得到top,left值然后分別減去offsetParent獲取的offset的top,left值。最后是把jselect插入到系統select元素后面,顯示到頁面。
jselect創建的基本流程就是上面描述的這樣,剩下就是細節地方的實現,比如說:點擊展開下拉顯示上次已選擇的元素,具體實現該功能的是initSelected方法如下
initSelected: function(elem) { var curText = elem.innerText || elem.textContent, curValue = elem.getAttribute('data-value'), wrapper = elem.nextSibling.nextSibling, n = wrapper.firstChild.firstChild, text, value, dir, min = 0, max, hidden = false; for(; n; n = n.nextSibling) { text = n.innerText || n.textContent value = n.getAttribute('data-value') if(curText === text && curValue === value) { //顯示已選中元素 if(squid.isHidden(wrapper)) { wrapper.style.display = 'block' hidden = true } max = wrapper.scrollHeight if(n.offsetTop > (max / 2)) { if(wrapper.clientHeight + wrapper.scrollTop === max) dir = 'up' else dir = 'down' }else{ if(wrapper.scrollTop === min) dir = 'down' else dir = 'up' } this.inView(n, wrapper, dir) if(hidden) wrapper.style.display = 'none' this.activate(n) break } } }
該方法接收class為select-default的div元素即用於存放用戶已選擇內容的元素,具體實現方式是先遍歷所有選項獲取class有selected的li元素,通過activate方法標示為當前已選中的元素。這里有一個需要計算的地方,就是每次展開下拉列表都要將已選中的元素滾動到頁面可視區。因為有可能下來列表內容很多,但是下拉列表的外層select-list會有一個最大的高度,超過最大高度會出現滾動條,默認不做計算的話有可能已選中的元素會在滾動條下面或者是滾動條上面,所以需要通過計算來重置容器滾動條的位置。具體是已選中內容顯示到滾動條的上面還是下面需要根據已選中元素的offsetTop值是否大於外層容器select-list的實際高度一半,把已選中元素顯示到可視區的方式是inView方法。inView方法如下
inView: function(elem, wrapper, dir) { var scrollTop = wrapper.scrollTop, //已選中元素offsetTop offsetTop = elem.offsetTop, top; if(dir === 'up') { if(offsetTop === 0) { //滾動條置頂 wrapper.scrollTop = offsetTop; }else if(offsetTop < scrollTop) { top = offsetTop - scrollTop //滾動條滾動到top值 this.scrollInView(wrapper, top) } }else{ var clientHeight = wrapper.clientHeight; if(offsetTop + elem.offsetHeight === wrapper.scrollHeight) { wrapper.scrollTop = wrapper.scrollHeight - wrapper.clientHeight }else if(offsetTop + elem.offsetHeight > clientHeight + scrollTop) { top = (offsetTop + elem.offsetHeight) - (scrollTop + clientHeight) this.scrollInView(wrapper, top) } } }
inView方法需要判斷是向上滾動還是向下滾動,scrollInView方法代碼很簡單就是把下拉列表外層容器的scrollTop設置為指定的值。方法實現如下
scrollInView: function(elem, top) { setTimeout(function() { elem.scrollTop += top }, 10) }
這個方法實現放到了setTimeout里面做了一個延遲添加到javascript執行隊列里面,主要解決的是IE8下展開下拉列表滾動條會最終滾動到頂部,忽略代碼設置的scrollTop(從表現上來看好像對scrollTop的設置也能生效,但是最后會重置滾動條到頂部,不知道IE8為什么會有這個問題。),不能把已選中的元素顯示到可視區范圍,其他瀏覽器下不會有這個問題。
整個的實現細節大致就這么多,鍵盤上下鍵回車鍵,關閉下拉列表邏輯都很簡單。
2. 遇到的問題
如何讓div獲取焦點來響應鍵盤keydown, keyup, keypress事件,到谷歌(非常時期谷歌都不好用了,沒辦法誰讓這是咱的特色呢)查找一些資料最后發現需要為div元素設置tabindex屬性,這樣就可以讓div元素獲取焦點,來響應用戶的操作。因為瀏覽器在默認情況下雙擊或者是點擊太頻繁的話會選中當前區域,為了取消這個默認操作給用戶一個好的體驗需要為div元素添加一個屬性unselectable,不過這個屬性只能適用於IE瀏覽器,其他瀏覽器下可以通過添加一個class名字是unselectable來避免這個問題。其他的問題都是邏輯上的控制,還有一些位置的計算了,這里就不再說了。
3. 使用方法
首先是在頁面模板把希望通過jselect替換的元素隱藏或者不做任何處理,默認情況下jselect會獲取頁面所有select依次替換,如果不希望jselect替換的select元素
需要添加自定義屬性data-enabled="true"。當然添加data-enabled="false"和沒有這個自定義屬性一樣都會被jselect替換。在使用的過程中可能對於布局結構比較復雜的頁面還會有其他的問題,因為我測試的頁面結構很簡單,所以可能沒有測試出來。
使用jselect需要先引入squid.js,然后引入jselect-1.0.js, jselect-1.0.css文件,在需要調用jselect的地方通過如下的調用方式來初始化jselect:squid.swing.jselect();
jselect的使用demo可以參見如下幾張截圖: