// 请求器组件
class API {
constructor(options = {}) {
// 接口基础地址
this.baseUrl = ''
// 请求对象
this.xhr = new XMLHttpRequest()
// 合并配置
Object.assign(this, options)
}
// 设置请求头
setHeaders(headers = {}) {
const { xhr } = this
for (let key in headers) {
xhr.setRequestHeader(key, headers[key])
}
}
// 格式化数据
formatData(data) {
let parseData = null
try {
parseData = JSON.parse(data)
} catch {
parseData = data
}
return parseData
}
// 中断请求
abort(callback = null) {
this.xhr.abort()
callback && callback()
}
// get请求
get(options = {}) {
const { baseUrl, xhr, formatData } = this
// 默认配置
const defaultOption = {
// 请求地址
url: '',
// 请求头
headers: null,
// 成功回调
callback: null,
success: null,
// 失败回调
fail: null,
error: null,
// 是否异步
async: true,
}
Object.assign(defaultOption, options)
const { url, callback, success, fail, error, async, headers } = defaultOption
xhr.open('GET', `${baseUrl}${url}`, async)
headers && this.setHeaders(headers)
xhr.addEventListener('error', function (err) {
error && error(err)
fail && fail(err)
})
xhr.onreadystatechange = function () {
if (this.readyState === 4 && this.status === 200) {
callback && callback(formatData(this.responseText))
success && success(formatData(this.responseText))
}
}
xhr.send()
}
// post请求
post(options = {}) {
const { baseUrl, xhr, formatData } = this
// 默认配置
const defaultOption = {
// 请求地址
url: '',
// 请求数据
data: null,
// 请求头
headers: null,
// 进度回调
progress: null,
// 成功回调
callback: null,
success: null,
// 失败回调
fail: null,
error: null,
// 是否异步
async: true,
}
Object.assign(defaultOption, options)
const { url, data, progress, callback, success, error, fail, async, headers } = defaultOption
xhr.open('POST', `${baseUrl}${url}`, async)
headers && this.setHeaders(headers)
xhr.addEventListener('progress', function (evt) {
const { response } = evt.target
progress && progress(response)
})
xhr.addEventListener('error', function (err) {
error && error(err)
fail && fail(err)
})
xhr.onreadystatechange = function () {
if (this.readyState === 4 && this.status === 200) {
callback && callback(formatData(this.responseText))
success && success(formatData(this.responseText))
}
}
xhr.send(data)
}
}
// UI组件
class UI {
constructor(registry = null) {
const that = this
// TODO:搜索框
this.Search = class Input extends HTMLElement {
// 可用属性
static observedAttributes = ['value', 'placeholder', 'disabled', 'max', 'wrap']
static get observedAttributes() {
return ['value', 'disabled']
}
// 构造函数
constructor() {
super()
// 输入框
this.input = null
// 输入框值
this.value = ''
// 输入框占位符
this.placeholder = '搜索...'
// 输入框禁用
this.disabled = false
// 输入框最大长度
this.max = Infinity
// 是否换行
this.wrap = false
}
// 生命周期
connectedCallback() {
this.init()
this.bindEvent()
}
// 属性变化
attributeChangedCallback(name, oldValue, newValue) {
this[name] = newValue
}
// 初始化
init() {
const { value, placeholder, disabled, wrap } = this
const shadow = this.attachShadow({ mode: 'open' })
const template = document.createElement('template')
const style = document.createElement('style')
const inputBox = document.createElement('div')
inputBox.className = inputBox.part = 'search'
const input = document.createElement('div')
input.className = input.part = 'search-input'
input.setAttribute('placeholder', placeholder)
input.setAttribute('disabled', disabled)
input.contentEditable = true
const prefix = document.createElement('slot')
prefix.name = 'prefix'
const append = document.createElement('slot')
append.name = 'append'
const clear = document.createElement('button')
clear.className = clear.part = 'search-clear'
clear.innerHTML = that.Icon.clear
style.innerHTML = `
.search {
--background-color: #fff;
--border-color: #ccc;
--placeholder-color: var(--border-color);
--clear-color: var(--border-color);
--clear-hover-color: #333;
--border-radius: 5px;
--padding: .5em 1em;
--min-width: 150px;
--gap: .5em;
--scrollbar-color: var(--border-color);
display: flex;
align-items: center;
font-size: 1em;
position: relative;
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: var(--padding);
box-sizing: border-box;
width: 100%;
min-width: var(--min-width);
gap: var(--gap);
cursor: text;
}
.search-input {
width: 100%;
font-size: 1em;
outline: none;
transition: all .2s;
resize: none;
}
.search-input::-webkit-scrollbar {
width: 0;
height: .1em;
}
.search-input::-webkit-scrollbar-thumb {
background: var(--scrollbar-color);
}
.search-input:empty::before {
content: attr(placeholder);
color: var(--placeholder-color);
}
.search-input:focus::before {
display: none;
}
.search:focus-within {
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
}
.search-input:focus-within + .search-clear {
opacity: 1;
}
.search-clear {
cursor: pointer;
color: var(--clear-color);
width: 20px;
height: 20px;
background: none;
border: none;
opacity: 0;
transition: all .3s;
position: relative;
}
.search-clear svg {
fill: var(--clear-color);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
width: 2em;
height: 2em;
}
`
inputBox.appendChild(prefix)
inputBox.appendChild(input)
inputBox.appendChild(clear)
inputBox.appendChild(append)
if (wrap || wrap === '') {
input.style.whiteSpace = 'pre-wrap'
input.style.overflowY = 'auto'
} else {
input.style.whiteSpace = 'nowrap'
input.style.overflowX = 'auto'
}
input.innerText = value.trim()
template.content.appendChild(style)
template.content.appendChild(inputBox)
shadow.appendChild(template.content.cloneNode(true))
this.input = shadow
}
// 清空输入框
clear() {
this.input.innerText = this.value = ''
}
// 绑定事件
bindEvent() {
const { input, max } = this
// 清空输入框
input.querySelector('.search-clear').addEventListener('click', e => {
e.preventDefault()
input.querySelector('.search-input').innerText = this.value = ''
})
// 输入框输入事件
input.querySelector('.search-input').addEventListener('input', e => {
e.preventDefault()
// 过滤换行符
this.value = e.target.innerText.replace(/[\n]/g, '')
if (this.value.length < max) {
e.target.innerText = this.value
} else {
e.target.innerText = this.value = this.value.slice(0, max)
}
// 移动光标到最后
const range = document.createRange()
const selection = window.getSelection()
range.selectNodeContents(e.target)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
e.target.focus()
this.dispatchEvent(new CustomEvent('change', { detail: this.value }))
})
// 输入框回车事件
input.querySelector('.search-input').addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault()
this.dispatchEvent(new CustomEvent('enter', { detail: this.value }))
}
})
// 输入框失焦事件
input.querySelector('.search-input').addEventListener('blur', () => this.dispatchEvent(new CustomEvent('blur')))
// 输入框聚焦事件
input.querySelector('.search-input').addEventListener('focus', () => this.dispatchEvent(new CustomEvent('focus')))
}
}
// TODO:下拉框
this.Select = class Select extends HTMLElement {
// 可用属性
static observedAttributes = ['placeholder', 'list', 'value', 'current', 'disabled', 'focus']
static get observedAttributes() {
return ['value', 'current', 'disabled', 'focus']
}
// 构造函数
constructor() {
super()
// 下拉框
this.select = null
// 下拉框列表
this.list = '默认选项'
this.dl = null
this.options = []
// 下拉框当前下标
this.current = 0
// 下拉框值
this.value = ''
// 下拉框占位符
this.placeholder = null
// 下拉框禁用
this.disabled = false
// 下拉框按钮
this.button = null
// 下拉框焦点
this.focus = false
this.selected = false
}
// 生命周期
connectedCallback() {
this.init()
}
// 属性变化
attributeChangedCallback(name, oldValue, newValue) {
this[name] = newValue
}
// 重置
reset() {
const { current, focus, options, placeholder, list } = this
if (focus) {
this.selected = false
this.select.classList.remove('selected')
}
this.value = options[current].value
this.button.innerText = placeholder || list.split(',')[current]
}
// 添加选项
addOption() {
const { list, value } = this
const options = list.split(',')
const optionValues = value.split(',')
this.dl.innerHTML = ''
// 添加选项
options.forEach((item, index) => {
const dd = document.createElement('dd')
dd.innerText = item
dd.value = optionValues[index]
dd.className = dd.part = 'select-option'
if (index === Number(this.current)) {
dd.classList.add('selected')
}
this.options.push({ text: item, value: optionValues[index] })
this.dl.appendChild(dd)
})
}
// 初始化
init() {
const { focus, disabled, placeholder, options, current } = this
const shadow = this.attachShadow({ mode: 'open' })
this.select = document.createElement('div')
this.select.className = this.select.part = 'select'
this.dl = document.createElement('dl')
const dl = this.dl
dl.className = dl.part = 'select-list'
dl.setAttribute('popover', '')
this.button = document.createElement('button')
const button = this.button
button.disabled = disabled
button.className = button.part = 'select-button'
button.popoverTargetElement = dl
const style = document.createElement('style')
style.innerHTML = `
.select {
position: relative;
font-size: 1em;
--background-color: #fff;
--border-color: #ccc;
--list-background-color: #fff;
--hover-background-color: #dee6ff;
--hover-text-color: #001eff;
--selected-background-color: #dee6ff;
--selected-text-color: #001eff;
--border-radius: 5px;
--min-width: 150px;
min-width: var(--min-width);
}
.select.selected button{
background: var(--selected-background-color);
color: var(--selected-text-color);
border: 1px solid transparent;
}
.select-button {
min-width: var(--min-width);
width: 100%;
padding: .5em 1em;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background: var(--background-color);
cursor: pointer;
font-size: 1em;
}
.select-button:disabled {
background: #f5f5f5;
cursor: not-allowed;
opacity: .9;
}
.select-button::after {
content: '▼';
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
}
.select-list {
position: absolute;
min-width: var(--min-width);
width: 100%;
background: var(--list-background-color);
border: none;
border-radius: var(--border-radius);
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
list-style: none;
padding: 0;
top: 0;
left: 0;
margin: 0;
}
.select-list dt,.select-list dd {
padding: .5em 1em;
margin: .2em;
border-radius: calc(var(--border-radius) / 2);
}
.select-list dt {
background: #f5f5f5;
cursor: not-allowed;
}
.select-list dd:hover,.select-list dd.selected {
background: var(--hover-background-color);
cursor: pointer;
color: var(--hover-text-color);
}
`
this.addOption()
button.innerText = placeholder || options[current].text
this.value = options[current].value
this.select.appendChild(button)
this.select.appendChild(dl)
shadow.appendChild(style)
shadow.appendChild(this.select)
// 选项点击事件
dl.addEventListener('click', e => {
if (e.target.tagName === 'DD') {
const { innerText, value } = e.target
button.innerText = innerText
this.value = value
if (focus) {
this.selected = true
this.select.classList.add('selected')
}
// 移除选中状态
dl.querySelectorAll('.select-option').forEach(item => {
item.classList.remove('selected')
})
e.target.classList.add('selected')
dl.hidePopover()
this.dispatchEvent(new CustomEvent('change', { detail: e.target }))
}
})
// 按钮点击事件
button.addEventListener('click', () => {
const { left, top, height, width } = this.select.getBoundingClientRect()
dl.style.top = `${top + height}px`
dl.style.left = `${left}px`
dl.style.width = `${width}px`
})
}
}
// TODO:加载中
this.Loading = class Loading extends HTMLElement {
static observedAttributes = ['content', 'inline', 'hidden']
constructor() {
super()
this.loading = null
// 加载中内容
this.content = ''
// 是否内联
this.inline = false
// 是否默认隐藏
this.hidden = true
}
// 属性变化
attributeChangedCallback(name, oldValue, newValue) {
if (name == 'content') {
this[name] = newValue
} else {
this[name] = eval(newValue)
}
}
//生命周期
connectedCallback() {
this.init()
!this.hidden && this.show()
}
// 初始化
init() {
const shadow = this.attachShadow({ mode: 'open' })
const style = document.createElement('style')
const loading = document.createElement('div')
const mask = document.createElement('div')
const content = document.createElement('div')
mask.className = mask.part = 'loading'
loading.className = loading.part = 'loader'
content.className = content.part = 'loading-content'
content.innerText = this.content
style.innerHTML = `
.loading{
--background-color: rgba(0, 0, 0, 0.2);
--text-color: #333;
--shadow-color: rgba(0, 0, 0, 0.1);
--icon-color: white;
${this.inline ? 'position: absolute;' : 'position: fixed;'}
top: 0;
left: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(5px);
display: none;
z-index: calc(Infinity + 1);
font-size: 1rem;
background: var(--background-color);
}
.loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 6em;
height: 6em;
filter: drop-shadow(0 0 10px var(--shadow-color));
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.loading svg {
width: 100%;
height: 100%;
fill: var(--icon-color);
}
.loading-content {
font-size: .9em;
color: var(--text-color);
white-space: nowrap;
margin-top: 1em;
font-weight: bold;
}
`
loading.innerHTML = `