lu2
Version:
Simple and flexible UI component library based on native HTML and JavaScript
756 lines (664 loc) • 26 kB
JavaScript
/*
* @Select.js
* @author liuwentao
* @version
* @Created: 2020-06-09
* @edit: 2020-06-09
**/
// 是否支持popover
const isSupportPopover = HTMLElement.prototype.hasOwnProperty("popover");
class Select extends HTMLSelectElement {
static get observedAttributes () {
return ['multiple', 'disabled', 'width'];
}
constructor () {
super();
// 关联的元素们
if (!this.element) {
this.element = {
button: null,
combobox: null,
datalist: null
};
}
// 尺寸和属性变化的观察器
this.observer = null;
this.resizeObserver = null;
// 重置原生的属性
this.setProperty();
}
static addClass () {
return ['ui', 'select'].concat([].slice.call(arguments)).join('-');
}
set multiple (value) {
return this.toggleAttribute('multiple', Boolean(value));
}
get multiple () {
return this.hasAttribute('multiple');
}
render () {
if (CSS.supports('appearance', 'base-select')) {
return;
}
this.create();
this.refresh();
this.events();
}
remove () {
if (this.parentElement) {
this.parentElement.removeChild(this);
}
if (this.element.combobox) {
this.element.combobox.remove();
}
}
getData () {
if (!this.options.length) {
return [{
html: ''
}];
}
// 所有分组元素
let eleOptgroups = this.querySelectorAll('optgroup');
// 如果有任意一个分组元素设置了label,那么就是标题分组
// 如果只是optgroup标签包括,那么使用分隔线分隔
let isIntent = !!this.querySelector('optgroup[label]');
// 如果有分组
if (eleOptgroups.length) {
let arrData = [];
eleOptgroups.forEach(optgroup => {
arrData.push({
html: optgroup.label,
disabled: optgroup.disabled,
className: optgroup.className,
divide: !isIntent
});
optgroup.querySelectorAll('option').forEach(option => {
arrData.push({
html: option.innerHTML,
value: option.value,
selected: option.selected,
disabled: optgroup.disabled || option.disabled,
className: option.className,
intent: isIntent
});
});
});
return arrData;
}
// hr元素支持
const eleOptions = this.querySelectorAll('option, hr');
return [].slice.call(eleOptions).map(option => {
if (option.matches('hr')) {
return {
divide: true
};
}
return {
html: option.innerHTML,
value: option.value,
selected: option.selected,
disabled: option.disabled,
className: option.className
};
});
}
// 获取<select>元素原始状态下的尺寸
get width () {
let strAttrWidth = this.getAttribute('width');
// 如果是纯数字,则px为单位
if (strAttrWidth && Number(strAttrWidth) === parseFloat(strAttrWidth)) {
strAttrWidth = strAttrWidth + 'px';
}
return strAttrWidth;
}
set width (value) {
if (/\d/.test(value) == false) {
return;
}
this.setAttribute('width', value);
}
getWidth () {
return this.style.width || this.width || this.offsetWidth + 'px';
}
setWidth () {
if (this.element.combobox) {
const width = this.getWidth();
// 创建的下拉的尺寸设置
this.element.combobox.style.width = width;
this.style.transform = '';
if (width.lastIndexOf('%') !== -1 && this.originPosition != 'absolute' && this.originPosition != 'fixed') {
// 如果是百分比宽度
// 同时原下拉框不是绝对定位
// 则有可能尺寸不不对的,通过水平缩放调整下
// 避免水平滚动的出现
this.style.transform = `scaleX(${ this.parentElement.clientWidth * parseFloat(width) * 0.01 / this.offsetWidth })`;
}
}
}
create () {
// 防止多次重复创建
if (this.element && this.element.combobox) {
return;
}
const strId = ('lulu_' + (this.id || Math.random())).replace('0.', '');
// 创建的列表元素需要的类名
const BUTTON_CLASS = Select.addClass('button');
const DATALIST_CLASS = Select.addClass('datalist');
// 原始下拉的定位属性
const strOriginPosition = window.getComputedStyle(this).position;
this.originPosition = strOriginPosition;
// 是否使用CSS定位
const isCSSPosition = this.dataset.cssPosition || this.hasAttribute('is-css-position');
// 滚动宽度
const isCustomScroll = /windows/i.test(navigator.userAgent);
// 是否使用 popover
const isPopover = !isCSSPosition && isSupportPopover;
// 直接插入对应的片段内容
this.insertAdjacentHTML('afterend', `<div style="width: ${this.getWidth()};${this.style.cssText}">
${!this.multiple ? `<button
type="button"
class="${BUTTON_CLASS}"
${isPopover ? 'popovertarget' : 'data-target'}="${strId}"
aria-owns="${strId}"
aria-expanded="false"
style="display: ${this.multiple ? 'none' : 'block'};anchor-name: --${strId}"
${this.disabled ? 'disabled ' : ''}
/></button>` : '' }
<ui-select-list
id="${strId}" ${isPopover ? 'popover' : ''}
role="listbox"
aria-expanded="false"
class="${DATALIST_CLASS}"
${!this.multiple ? 'aria-hidden="true"' : ''}
data-custom-scroll="${isCustomScroll}"
style="position-anchor:--${strId};"
></ui-select-list>
</div>`);
const eleCombobox = this.nextElementSibling;
// 元素暴露出去
Object.assign(this.element, {
combobox: eleCombobox,
button: eleCombobox.querySelector(`.${BUTTON_CLASS}`),
datalist: eleCombobox.querySelector(`.${DATALIST_CLASS}`)
});
// 原始下拉框的层级和位置
// 变成绝对定位,不占据任何空间
if (strOriginPosition != 'fixed') {
this.style.position = 'absolute';
}
this.dispatchEvent(new CustomEvent('DOMContentLoaded'));
}
/**
* 下拉内容的刷新
* @param {Array} data 刷新列表数组项,可选
*/
refresh (data) {
// 是否多选
const isMultiple = this.multiple;
// 主要的几个元素
const eleSelect = this;
// 几个创建元素
const eleCombobox = this.element.combobox;
const eleButton = this.element.button;
const eleDatalist = this.element.datalist;
if (!eleDatalist) {
return;
}
// id
const strId = eleDatalist.id;
// 获取当下下拉框的数据和状态
data = data || this.getData();
// 下拉组合框元素的样式
// 把原<select>的样式复制过来,这样,类似 margin 等样式可以继承过来
// 布局会更稳定
eleCombobox.className = (`${eleSelect.className} ${Select.addClass()}`).trim();
// 多选,高度需要同步,因为选项高度不确定
// eleSelect.style.height 性能很高,offsetHeight会触发重绘,因此优先 style 对象 获取
if (isMultiple) {
eleCombobox.style.height = eleSelect.style.height || (eleSelect.offsetHeight + 'px');
} else if (eleSelect[eleSelect.selectedIndex]) {
// 按钮元素中的文案
const strHtmlSelected = eleSelect[eleSelect.selectedIndex].innerHTML;
// 按钮赋值
eleButton.innerHTML = `<span class="${Select.addClass('text')}">${strHtmlSelected}</span><i class="${Select.addClass('icon')}" aria-hidden="true"></i>`;
eleButton.dataset.value = eleSelect[eleSelect.selectedIndex].value;
}
// 列表内容的刷新
let index = -1;
eleDatalist.innerHTML = data.map((obj) => {
let arrCl = [Select.addClass('datalist', 'li'), obj.className];
if (obj.selected) arrCl.push('selected');
if (obj.disabled) arrCl.push('disabled');
// 如果有分隔线
if (typeof obj.divide != 'undefined') {
if (obj.divide) {
arrCl = [Select.addClass('datalist', 'hr'), obj.className];
return `<div class="${arrCl.join(' ')}"></div>`;
}
return `<div class="${arrCl.join(' ')}" role="heading">${obj.html}</div>`;
}
// 这才是有效的索引
index++;
// 如果有缩进
if (obj.intent) {
arrCl.push(Select.addClass('intent'));
}
// 如果没有项目内容
if (!obj.html) {
return `<span class="${arrCl.join(' ')} disabled"></span>`;
}
// 复选模式列表不参与无障碍访问识别,因此HTML相对简单
if (isMultiple) {
return `<a class="${arrCl.join(' ')}" data-index=${index}>${obj.html}</a>`;
}
// 单选模式返回内容
return `<a
${obj.disabled ? '' : ' href="javascript:" '}
class="${arrCl.join(' ')}"
data-index=${index}
data-target="${strId}"
data-value="${obj.value}"
role="option"
aria-selected="${obj.selected}"
>${obj.html}</a>`;
}).join('');
}
/**
* 下拉的事件处理
*/
events () {
if (this.multiple) {
this.createMultipleEvent();
} else {
this.createNormalEvent();
}
}
/**
* 下拉的层级处理
*/
zIndex () {
let eleTarget = this.element.datalist;
// 返回eleTarget才是的样式计算对象
let objStyleTarget = window.getComputedStyle(eleTarget);
// 此时元素的层级
let numZIndexTarget = Number(objStyleTarget.zIndex);
// 用来对比的层级,也是最小层级
let numZIndexNew = 19;
// 只对同级子元素进行层级最大化计算处理
document.body.childNodes.forEach((eleChild) => {
if (eleChild.nodeType !== 1) return;
let objStyleChild = window.getComputedStyle(eleChild);
let numZIndexChild = objStyleChild.zIndex * 1;
if (numZIndexChild && eleTarget !== eleChild && objStyleChild.display !== 'none') {
numZIndexNew = Math.max(numZIndexChild + 1, numZIndexNew);
}
});
if (numZIndexNew !== numZIndexTarget) {
eleTarget.style.zIndex = numZIndexNew;
}
}
/**
* 定位
*/
position () {
const objElement = this.element;
const eleCombobox = objElement.combobox;
const eleButton = objElement.button;
const eleDatalist = objElement.datalist;
if (!eleCombobox || !eleButton || !eleDatalist) {
return;
}
if (!eleCombobox.classList.contains('active')) {
return;
}
if (CSS.supports('position-try-fallbacks', 'flip-block')) {
return;
}
// 按钮的尺寸和位置
const objBoundButton = eleButton.getBoundingClientRect();
// 滚动高度
const numScrollTop = document.scrollingElement.scrollTop;
// body元素下的绝对定位场景才处理
if (!eleCombobox.contains(eleDatalist) || eleButton.popoverTargetElement) {
// 下拉列表的尺寸和位置设置
eleDatalist.style.left = (objBoundButton.left + document.scrollingElement.scrollLeft) + 'px';
eleDatalist.style.top = (objBoundButton.bottom + numScrollTop - 1) + 'px';
eleDatalist.style.width = eleCombobox.getBoundingClientRect().width + 'px';
// 层级
this.zIndex();
}
// 边界判断
const objBoundDatalist = eleDatalist.getBoundingClientRect();
const numBounceBottom = objBoundDatalist.bottom + (eleDatalist.popover ? 0 : numScrollTop);
var isOverflow = numBounceBottom > Math.max(document.body.clientHeight, window.innerHeight);
eleCombobox.classList[isOverflow ? 'add' : 'remove']('reverse');
// 这里之所以加定时器和显隐二次重绘,都是因为Safari的bug
if (isOverflow && !this.dataset.cssPosition && !this.hasAttribute('is-css-position')) {
eleDatalist.style.display = 'none';
setTimeout(() => {
eleDatalist.style.top = (objBoundButton.top + numScrollTop - objBoundDatalist.height + 1) + 'px';
eleDatalist.style.display = 'block';
}, 10);
} else if (!eleCombobox.contains(eleDatalist) || eleButton.popoverTargetElement) {
eleDatalist.style.display = 'none';
setTimeout(() => {
eleDatalist.style.top = (objBoundButton.bottom + numScrollTop - 1) + 'px';
eleDatalist.style.display = 'block';
}, 10);
}
}
/**
* 单选下拉框的事件
*/
createNormalEvent () {
const objElement = this.element;
let eleCombobox = objElement.combobox;
let eleButton = objElement.button;
let eleDatalist = objElement.datalist;
// 点击按钮
eleButton.addEventListener('click', () => {
// 如果下拉被禁用则不处理
if (this.disabled) {
return false;
}
// 显示与隐藏
eleCombobox.classList.toggle('active');
// 显示
if (eleCombobox.classList.contains('active')) {
// 避免overflow剪裁,所以设置在body元素下
if (this.dataset.cssPosition || this.hasAttribute('is-css-position') || isSupportPopover) {
eleCombobox.appendChild(eleDatalist);
} else {
document.body.appendChild(eleDatalist);
}
// 定位
this.position();
// aria状态
eleButton.setAttribute('aria-expanded', 'true');
// datalist aria hidden去除
eleDatalist.removeAttribute('aria-hidden');
// 滚动与定位
var arrDataScrollTop = eleCombobox.dataScrollTop;
var eleDatalistSelected = eleDatalist.querySelector('.selected');
// 严格验证
if (arrDataScrollTop && eleDatalistSelected && arrDataScrollTop[1] === eleDatalistSelected.getAttribute('data-index') && arrDataScrollTop[2] === eleDatalistSelected.innerText) {
eleDatalist.scrollTop = arrDataScrollTop[0];
// 重置
delete eleCombobox.dataScrollTop;
}
} else {
eleCombobox.classList.remove('reverse');
// aria状态
eleButton.setAttribute('aria-expanded', 'false');
// 隐藏列表
eleDatalist.remove();
}
});
eleDatalist.addEventListener('click', (event) => {
var target = event.target;
if (!target || !target.closest) {
return;
}
// 点击的列表元素
var eleList = target;
// 对应的下拉<option>元素
var eleOption = null;
// 是否当前点击列表禁用
var isDisabled = eleList.classList.contains('disabled');
// 获取索引
var indexOption = eleList.getAttribute('data-index');
// 存储可能的滚动定位需要的数据
var scrollTop = eleDatalist.scrollTop;
eleCombobox.dataScrollTop = [scrollTop, indexOption, eleList.innerText];
// 修改下拉选中项
if (!isDisabled) {
eleOption = this[indexOption];
if (eleOption) {
eleOption.selected = true;
}
}
// 下拉收起
eleCombobox.classList.remove('active');
eleButton.setAttribute('aria-expanded', 'false');
eleDatalist.remove();
// focus
eleButton.focus();
eleButton.blur();
if (!isDisabled) {
// 更新下拉框
this.refresh();
// 回调处理
// 触发change事件
this.dispatchEvent(new CustomEvent('change', {
'bubbles': true
}));
}
});
// 非页面主题滚动的重定位实现
// 遍历所有的overflow:auto元素
const eleScrollable = [];
const funWalk = (ele) => {
// 不包括body元素
if (ele == document.body) {
return;
}
if (window.getComputedStyle(ele).overflow == 'auto') {
eleScrollable.push(ele);
}
// 递归
funWalk(ele.parentElement);
}
// 向上找到所有的可滚动元素
if (!this.dataset.cssPosition && !this.hasAttribute('is-css-position') && !isSupportPopover) {
funWalk(eleButton.parentElement);
// 滚动发生的时候,重定位
eleScrollable.forEach((ele) => {
ele.addEventListener('scroll', () => {
this.position();
});
});
}
// 点击页面空白要隐藏
// 测试表明,这里优化下可以提高40~50%性能
// 优化方式为改为一次性委托委托
if (!document.isSelectMouseEvent) {
// 点击空白隐藏处理
document.addEventListener('mouseup', (event) => {
var target = event.target;
if (!target) {
return;
}
// 识别此时的combobox
const eleCombobox = document.querySelector('select+.ui-select.active');
if (!eleCombobox) {
return;
}
// 对应的下拉元素
const eleSelect = eleCombobox.previousElementSibling;
const eleDatalist = eleSelect.element && eleSelect.element.datalist;
if (!eleDatalist.contains(target) && !eleCombobox.contains(target)) {
eleCombobox.classList.remove('active');
eleCombobox.classList.remove('reverse');
eleDatalist.remove();
}
});
// 防止事件2次绑定
document.isSelectMouseEvent = true;
}
}
/**
* 多选下拉的事件处理
*/
createMultipleEvent () {
const eleDatalist = this.element.datalist;
// 下拉多选
// 键盘交互UI同步
this.addEventListener('change', () => {
this.refresh();
});
// 滚动同步
this.addEventListener('scroll', () => {
eleDatalist.scrollTop = this.scrollTop;
});
// hover穿透
this.addEventListener('mousedown', () => {
this.setAttribute('data-active', 'true');
});
this.addEventListener('mousemove', (event) => {
if (this.getAttribute('data-active')) {
this.refresh();
return;
}
// 当前坐标元素
var clientY = event.clientY;
var clientX = event.clientX;
// 匹配当前坐标的页面元素
var elesFromPoint = document.elementsFromPoint(clientX, clientY);
// 识别哪几个列表元素匹配坐标元素
var eleListAll = eleDatalist.querySelectorAll('a');
for (var indexList = 0; indexList < eleListAll.length; indexList++) {
var eleList = eleListAll[indexList];
// hover状态先还原
eleList.removeAttribute('href');
// 然后开始寻找匹配的列表元素
if ([...elesFromPoint].includes(eleList)) {
if (!eleList.classList.contains('selected') && !eleList.classList.contains('disabled')) {
eleList.href = 'javascript:';
}
// 退出循环
// forEach无法中断,因此这里使用了for循环
break;
}
}
});
this.addEventListener('mouseout', () => {
var eleListAllWithHref = eleDatalist.querySelectorAll('a[href]');
eleListAllWithHref.forEach(function (eleList) {
eleList.removeAttribute('href');
});
});
document.addEventListener('mouseup', () => {
this.removeAttribute('data-active');
});
}
/**
* 重置原生的属性
*/
setProperty () {
Object.defineProperty(this, 'value', {
configurable: true,
enumerable: true,
writeable: true,
get: () => {
return [...this.selectedOptions].map(option => option.value).join();
},
set: (value) => {
[...this.options].some((option) => {
// 单选框模式下,如果多个值匹配,让第一个选中
// 如果没有下面这句,会最后一个匹配的选中
if (value.split(',').includes(option.value)) {
option.selected = true;
if (!this.multiple) {
return true;
}
} else if (this.multiple) {
option.selected = false;
}
});
}
});
const props = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'selectedIndex');
Object.defineProperty(HTMLSelectElement.prototype, 'selectedIndex', {
...props,
set (v) {
if (this.options[v]) {
this.options[v].selected = true;
}
}
});
}
/**
* <select>属性变化时候的处理
* @param {String} name 变化的属性名称
*/
attributeChangedCallback (name) {
const eleButton = this.element.button;
if (name === 'disabled') {
if (!eleButton) return;
eleButton.disabled = this.disabled;
} else if (name == 'width') {
this.setWidth();
}
}
/**
* is="ui-select" 元素载入到页面后
*/
connectedCallback () {
// 观察
this.observer = new MutationObserver((mutationsList) => {
let isRefresh = true;
mutationsList.forEach(mutation => {
if (mutation.type == 'attributes' && mutation.target.hasAttribute('selected')) {
// setAttribute('selected') 并不一定能真正改变selected状态
// 因此这里重新设置一次
mutation.target.selected = true;
// 上面代码就会自动触发刷新,无需再执行一次
isRefresh = false;
}
});
if (isRefresh) {
this.refresh();
}
});
this.resizeObserver = new ResizeObserver(() => {
this.setWidth();
this.position();
});
this.observer.observe(this, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['selected']
});
this.resizeObserver.observe(this);
// 全局事件
this.dispatchEvent(new CustomEvent('connected', {
detail: {
type: 'ui-select'
}
}));
this.isConnectedCallback = true;
// 渲染
this.render();
}
/**
* is="ui-select" 元素从页面移除后
*/
disconnectedCallback () {
if (!this.observer || !this.resizeObserver) {
return;
}
this.remove();
this.observer.disconnect();
this.resizeObserver.disconnect();
}
}
// option.selected 观察
const propOptionSelected = Object.getOwnPropertyDescriptor(HTMLOptionElement.prototype, 'selected');
Object.defineProperty(HTMLOptionElement.prototype, 'selected', {
...propOptionSelected,
set (value) {
propOptionSelected.set.call(this, value);
// 重新渲染
if (this.parentElement && this.parentElement.refresh) {
this.parentElement.refresh();
}
}
});
// 自定义元素注册
if (!customElements.get('ui-select')) {
customElements.define('ui-select', Select, {
extends: 'select'
});
}