commit 57cc705d4ee6788dd7d025e39c8be0f3da28a464 Author: 袁涛 Date: Tue Aug 12 00:07:53 2025 +0800 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..086f611 --- /dev/null +++ b/README.md @@ -0,0 +1,243 @@ +# UI组件库与API请求工具 + +一个功能强大的UI组件库和一个轻量级的API请求工具,可以帮助开发者快速构建现代化的Web应用界面。 + +## 功能概述 + +### API请求器 (`API`类) +- 支持GET和POST请求 +- 可设置基础URL和请求头 +- 提供请求中断功能 +- 自动格式化响应数据 +- 支持异步请求和回调处理 +- 包含进度回调支持(POST) + +### UI组件库 (`UI`类) +提供以下自定义Web组件: + +1. **搜索框组件 (Search)** + - 支持前缀/后缀插槽 + - 可清除内容 + - 支持最大长度限制 + - 支持换行显示 + +2. **下拉选择框组件 (Select)** + - 可自定义选项列表 + - 支持选项值绑定 + - 弹出式菜单 + - 选中状态高亮 + +3. **加载指示器组件 (Loading)** + - 自定义加载文本 + - 内联/全屏模式 + - 显示/隐藏控制 + - 内置加载动画 + +4. **气泡列表组件 (BubbleList)** + - 可选气泡项 + - 多行滚动支持 + - 自定义大小 + - 选中状态标记 + +5. **对话框组件 (Dialog)** + - 自定义标题和内容 + - 确定/取消按钮 + - 显示/隐藏控制 + - 支持插槽自定义内容 + +6. **模态框组件 (Modal)** + - 带模糊背景效果 + - 居中显示 + - 开/关动画 + - 自定义内容 + +7. **选项卡组件 (Tab)** + - 水平布局 + - 带描述的选项卡 + - 选中高亮效果 + - 切换事件 + +8. **提示条组件 (Tip)** + - 多种类型(info/success/warn/error) + - 自动隐藏计时器 + - 关闭按钮 + - 位置动画 + +## 安装与使用 + +### API请求器 + +- 用于发送HTTP请求,支持GET和POST方法。 +- 可以设置基础URL(baseUrl)。 +- 可以设置请求头(headers)。 +- 支持中断请求(abort)。 +- 支持异步请求。 +- 提供成功和失败的回调函数。 + +### 方法: + +1. `constructor(options)`: 初始化,可以传入配置对象(如baseUrl)。 +2. `setHeaders(headers)`: 设置请求头。 +3. `formatData(data)`: 格式化响应数据,尝试解析JSON。 +4. `abort(callback)`: 中断请求,并执行回调。 +5. `get(options)`: 发送GET请求,配置项包括url、headers、callback/success、fail/error、async等。 +6. `post(options)`: 发送POST请求,配置项包括url、data、headers、progress、callback/success、fail/error、async等。 + +```javascript +// 创建API实例 +const api = new API({ baseUrl: 'https://api.example.com' }); + +// GET请求 +api.get({ + url: '/data', + success: (data) => { + console.log('获取数据:', data); + } +}); + +// POST请求 +api.post({ + url: '/submit', + data: JSON.stringify({ name: 'John' }), + progress: (response) => { + console.log('上传进度:', response); + }, + success: (data) => { + console.log('提交成功:', data); + } +}); + +// 中断请求 +api.abort(); +``` + +### UI组件 + +- 用于创建自定义元素(Web Components)。 +- 包含多个内置组件:Search(搜索框)、Select(下拉框)、Loading(加载指示器)、BubbleList(气泡列表)、Dialog(对话框)、Modal(模态框)、Tab(选项卡)、Tip(提示条)。 +- 每个组件都是通过继承`HTMLElement`定义的,并且可以注册为自定义元素。 +- 提供了注册组件的方法`registerComponent`。 + +### 组件列表: + +1. **Search (搜索框)** + - 属性:value, placeholder, disabled, max, wrap + - 事件:change, enter, blur, focus + - 方法:clear() +2. **Select (下拉框)** + - 属性:placeholder, list(选项列表,逗号分隔), value(选项值,逗号分隔), current(当前选中索引), disabled, focus + - 事件:change + - 方法:reset(), addOption() +3. **Loading (加载指示器)** + - 属性:content(加载文本), inline(是否内联), hidden(是否隐藏) + - 方法:show(), hide() +4. **BubbleList (气泡列表)** + - 属性:list(列表项,逗号分隔), value(值,逗号分隔), current(当前选中索引), disabled, rows(显示行数), size(气泡大小), selected(当前选中值) + - 事件:change + - 方法:add(), clear(), select() +5. **Dialog (对话框)** + - 属性:subtitle(标题), content(内容), hidden + - 事件:show, hide + - 方法:show(), hide(), set() +6. **Modal (模态框)** + - 属性:subtitle, content, hidden + - 事件:open, close + - 方法:open(), close(), animate() +7. **Tab (选项卡)** + - 属性:list(选项卡列表,格式为"标题>描述|标题>描述",用|分隔), current(当前索引) + - 事件:change +8. **Tip (提示条)** + - 属性:content, type(info/success/warn/error), hidden, timeout(自动隐藏时间) + - 方法:show(), hide(), animate() + +### 图标(Icon) + +- 提供两个SVG图标:clear(清除)和task(任务)。 + +### 注册组件 + +在`UI`的构造函数中,传入一个组件名称数组(如`['ui-search', 'ui-select']`),则会自动注册这些组件。也可以使用`registerComponent`方法手动注册。 + +## 使用示例 + +### 注册组件 + +```javascript +const ui = new UI(['ui-search', 'ui-select', 'ui-loading', 'ui-bubble-list', 'ui-dialog', 'ui-modal', 'ui-tab', 'ui-tip']); +``` + +### 在HTML中使用 + +```html + + + + + + + + +``` + +## 自定义样式 + +所有组件都支持通过CSS变量进行样式自定义: + +```css +/* 全局样式自定义 */ +:root { + /* 搜索框 */ + --background-color: #f5f5f5; + --border-color: #ddd; + + /* 下拉框 */ + --hover-background-color: #eef2ff; + + /* 加载指示器 */ + --mask-color: rgba(255, 255, 255, 0.8); + + /* 气泡列表 */ + --background-color: #4f46e5; + + /* 对话框 */ + --border-radius: 12px; + + /* 提示条 */ + --color: #3b82f6; +} +``` + +## 内置图标 + +组件库提供两个内置SVG图标: +1. 清除图标 (Icon.clear) +2. 任务图标 (Icon.task) + +## 浏览器兼容性 + +- 现代浏览器(Chrome, Firefox, Edge最新版本) +- 需要支持Web Components和Shadow DOM + +## 注意事项 + +1. 所有UI组件都是自定义HTML元素,使用Shadow DOM封装 +2. 组件属性可以通过JavaScript或HTML属性设置 +3. 组件会触发自定义事件,可通过addEventListener监听 +4. API请求器使用XMLHttpRequest实现 + +## 扩展开发 + +可以通过继承UI类并调用registerComponent方法添加新的自定义组件: + +```javascript +class MyComponent extends HTMLElement { + // 组件实现 +} + +// 注册新组件 +ui.registerComponent('my-component', MyComponent); +``` + +## 贡献指南 + +欢迎提交Pull Request或Issue报告问题。请确保代码风格一致并通过基本测试。 \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..5ac4c74 --- /dev/null +++ b/index.js @@ -0,0 +1,1544 @@ +// 请求器组件 +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 = ` + + + + + + + + + + + + + + + + + + + + + + + + + ` + loading.appendChild(content) + shadow.appendChild(style) + mask.appendChild(loading) + shadow.appendChild(mask) + this.loading = mask + } + // 显示loading + show(fn = null) { + this.hidden = false + this.loading.style.display = 'block' + this.loading.querySelector('.loading-content').innerText = this.content + fn && fn() + } + // 隐藏loading + hide(fn = null) { + this.hidden = true + this.loading.style.display = 'none' + fn && fn() + } + } + // TODO:泡泡列表 + this.BubbleList = class BubbleList extends HTMLElement { + // 可用属性 + static observedAttributes = ['list', 'value', 'current', 'disabled', 'rows', 'size', 'selected'] + static get observedAttributes() { + return ['selected', 'current', 'disabled'] + } + constructor() { + super() + // 泡泡列表 + this.bubbleList = null + this.bubbles = [] + // 泡泡列表属性 + this.list = [] + // 泡泡列表值 + this.val = null + // 泡泡列表当前下标 + this.current = undefined + // 泡泡列表当前值 + this.selected = '' + // 泡泡列表禁用 + this.disabled = false + // 泡泡列表行数 + this.rows = null + // 泡泡列表大小 + this.size = 'auto' + // 初始化 + this.init() + this.select() + } + // 属性变化 + attributeChangedCallback(name, oldValue, newValue) { + this.clear() + switch (name) { + // 列表 + case 'list': + if (newValue) { + this.list = newValue.split(',') + } + break + // 值 + case 'value': + if (newValue) { + this.val = newValue.split(',') + } + break + // 下标 + case 'current': + this.current = newValue + this.bubbles.forEach((item, index) => { + if (index === newValue) { + item.classList.add('selected') + item.part = 'selected' + } else { + item.classList.remove('selected') + item.part = 'bubble' + } + }) + break + // 行数 + case 'rows': + this.rows = newValue + if (this.rows) { + const { rows } = this + this.bubbleList.style.height = `calc(${Number(rows) * 2.5}em - var(--gap))` + this.bubbleList.style.overflow = 'auto' + } + break + default: + this[name] = newValue + break + } + + if (this.val) { + this.list.forEach((item, index) => { + this.add(item, this.val[index]) + }) + } else { + this.list.forEach(item => { + this.add(item) + }) + } + } + // 初始化 + init() { + const shadow = this.attachShadow({ mode: 'open' }) + this.bubbleList = document.createElement('div') + this.bubbleList.className = this.bubbleList.part = 'bubble-list' + const style = document.createElement('style') + + style.innerHTML = ` + .bubble-list { + --background-color: #3297f3; + --text-color: #fff; + --shadow-color: rgba(0, 0, 0, 0.1); + --hover-background-color: #7983ff; + --hover-text-color: #fff; + --border: 1px solid transparent; + --hover-border: 1px solid transparent; + --scrollbar-width: 1px; + --scrollbar-color: transparent; + --gap:.5em; + display: flex; + flex-wrap: wrap; + font-size: 1.2em; + gap: var(--gap); + user-select: none; + } + .bubble-list::-webkit-scrollbar { + width: var(--scrollbar-width); + height: var(--scrollbar-width); + } + .bubble-list::-webkit-scrollbar-thumb { + background: var(--scrollbar-color); + border-radius: 5px; + } + .bubble { + padding: .2em 1.5em; + border-radius: calc(infinity * 1px); + background: var(--background-color); + color: var(--text-color); + font-size: 1em; + box-shadow: 0 0 5px var(--shadow-color); + cursor: pointer; + transition: all .2s; + border: var(--border); + } + .bubble.selected,.bubble:hover:not([disabled]) { + background: var(--hover-background-color); + box-shadow: 0 0 10px var(--shadow-color); + color: var(--hover-text-color); + border: var(--hover-border); + } + ` + shadow.appendChild(style) + shadow.appendChild(this.bubbleList) + } + // 添加泡泡 + add(text = '', value = '') { + const { current, disabled, size } = this + const bubble = document.createElement('button') + bubble.className = bubble.part = 'bubble' + disabled && bubble.setAttribute('disabled', 'disabled') + bubble.innerText = text + bubble.value = value || text + bubble.style.width = size + + if (size && size !== 'auto') { + bubble.style.whiteSpace = 'nowrap' + bubble.style.overflow = 'hidden' + bubble.style.textOverflow = 'ellipsis' + } + + this.bubbles.push(bubble) + current && this.bubbles[current].classList.add('selected') + this.bubbleList.appendChild(bubble) + } + // 清空泡泡 + clear() { + this.bubbleList.innerHTML = '' + this.bubbles = [] + } + // 选中泡泡 + select(fn = null) { + const { bubbleList } = this + bubbleList.addEventListener('click', e => { + bubbleList.querySelectorAll('.bubble').forEach(item => { + item.classList.remove('selected') + item.part = 'bubble' + }) + if (e.target.className === 'bubble') { + e.target.classList.add('selected') + e.target.part = 'selected' + const { innerText, value } = e.target + this.selected = innerText + this.value = value || innerText + fn && fn(innerText, value) + } + this.dispatchEvent(new CustomEvent('change', { detail: this })) + }) + } + } + // TODO:对话框 + this.Dialog = class Dialog extends HTMLElement { + // 可用属性 + static observedAttributes = ['subtitle', 'content', 'hidden'] + constructor() { + super() + this.dialog = null + this.mask = null + this.subtitle = '提示' + this.content = '' + this.confirmText = '确定' + this.cancelText = '取消' + this.confirm = null + this.cancel = null + this.hidden = true + } + // 生命周期 + connectedCallback() { + this.init() + } + // 属性变化 + attributeChangedCallback(name, oldValue, newValue) { + if (name == 'hidden') { + this[name] = eval(newValue) + } else { + this[name] = newValue + } + + if (this.dialog) { + switch (name) { + case 'subtitle': + this.dialog.querySelector('.dialog-title').innerText = newValue + break + case 'content': + this.dialog.querySelector('.dialog-text').innerText = newValue + break + } + } + } + // 初始化 + init() { + const { subtitle, content, confirmText, cancelText, confirm, cancel, hidden } = this + const shadow = this.attachShadow({ mode: 'open' }) + const template = document.createElement('template') + const style = document.createElement('style') + const mask = document.createElement('div') + const ctx = document.createElement('div') + mask.className = mask.part = 'dialog-mask' + ctx.className = ctx.part = 'dialog-content' + this.dialog = document.createElement('div') + this.dialog.className = this.dialog.part = 'dialog' + template.content.appendChild(mask) + + ctx.innerHTML += ` + ${subtitle} +
${that.Icon.task}
+
${content}
+
+ + + + +
+ + ` + mask.appendChild(ctx) + + style.innerHTML = ` + .dialog { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: calc(Infinity + 1); + display: block; + font-size: 1em; + --background-color: #fff; + --border-radius: 10px; + --shadow-color: rgba(0, 0, 0, 0.1); + --mask-color: rgba(0, 0, 0, 0.5); + --show-icon: block; + --min-width: 580px; + --min-height: 320px; + --btn-color: #fff; + --btn-background-color: linear-gradient(to bottom, #7983ff, #3297f3); + } + .dialog-mask { + position: absolute; + width: 100%; + height: 100%; + background-color: var(--mask-color); + display: flex; + justify-content: center; + align-items: center; + } + .dialog-content { + padding: 20px; + background:url(/static/v3/tipbg01.png) no-repeat,var(--background-color); + background-size: cover; + border-radius: var(--border-radius); + overflow: hidden; + box-shadow: 0 0 10px var(--shadow-color); + text-align: center; + min-width: var(--min-width); + min-height: var(--min-height); + color:black + } + .dialog-icon{ + margin: 20px auto; + display: var(--show-icon); + } + .dialog-title { + font-size: 1.5em; + margin-bottom: 10px; + text-align: left; + display: block; + } + .dialog-text { + font-size: 1.2em; + margin-bottom: 20px; + } + .dialog-btns { + display: flex; + justify-content: space-around; + width: 80%; + margin: 40px auto 0 auto; + } + .dialog-btn { + padding: 5px 20px; + margin: 0 10px; + border: none; + border-radius: 10px; + background:var(--btn-background-color); + cursor: pointer; + color: var(--btn-color); + font-size: 1.2em; + } + ` + + template.content.appendChild(style) + this.dialog.style.display = hidden ? 'none' : 'block' + this.dialog.appendChild(template.content.cloneNode(true)) + shadow.appendChild(this.dialog) + this.mask = this.dialog.querySelector('.dialog-mask') + const confirmBtn = this.dialog.querySelector('.dialog-confirm') + const cancelBtn = this.dialog.querySelector('.dialog-cancel') + + // 绑定事件 + confirmBtn.addEventListener('click', () => { + this.hide() + confirm && confirm(this) + }) + cancelBtn.addEventListener('click', () => { + this.hide() + cancel && cancel(this) + }) + } + // 显示对话框 + show(detail = null) { + this.dialog.style.display = 'block' + this.hidden = false + this.dispatchEvent(new CustomEvent('show', { detail })) + } + // 隐藏对话框 + hide(detail = null) { + this.dialog.style.display = 'none' + this.hidden = true + this.dispatchEvent(new CustomEvent('hide', { detail })) + } + // 设置对话框 + set(options = {}) { + Object.assign(this, options) + } + } + // TODO:模态框 + this.Modal = class Modal extends HTMLElement { + // 可用属性 + static observedAttributes = ['subtitle', 'content', 'hidden'] + constructor() { + super() + // 模态框 + this.modal = null + // 遮罩层 + this.mask = null + // 标题 + this.subtitle = '标题' + // 内容 + this.content = '第一行

第二行

第三行

第四行' + // 动画定时器 + this.timer = null + // 是否隐藏 + this.hidden = true + } + // 生命周期 + connectedCallback() { + this.init() + } + // 属性变化 + attributeChangedCallback(name, oldValue, newValue) { + if (name == 'hidden') { + this[name] = eval(newValue) + } else { + this[name] = newValue + } + } + // 初始化 + init() { + const { subtitle, content, hidden } = this + const shadow = this.attachShadow({ mode: 'open' }) + const template = document.createElement('template') + const style = document.createElement('style') + this.modal = document.createElement('div') + this.modal.className = this.modal.part = 'modal' + this.mask = document.createElement('div') + this.mask.className = this.mask.part = 'modal-mask' + this.mask.popoverTargetElement = this.modal + + template.innerHTML = ` + + ` + style.innerHTML = ` + .modal { + display: block; + font-size: 1rem; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 2; + --background-color: #fff; + --border-radius: 10px; + --shadow-color: rgba(0, 0, 0, 0.1); + --min-width: 200px; + --min-height: 120px; + --mask-color: rgba(0, 0, 0, 0.3); + --mask-blur: 8px; + transition: all .3s; + } + .modal-mask { + position: absolute; + width: 100%; + height: 100%; + background-color: var(--mask-color); + backdrop-filter: blur(var(--mask-blur)); + -moz-backdrop-filter: blur(var(--mask-blur)); + -webkit-backdrop-filter: blur(var(--mask-blur)); + } + .modal-content { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 1em; + background: var(--background-color); + border-radius: var(--border-radius); + box-shadow: 0 0 10px var(--shadow-color); + text-align: center; + min-width: var(--min-width); + width: max-content; + min-height: var(--min-height); + box-sizing: border-box; + } + .modal-title { + font-size: 1.5em; + margin-block: 1vh; + display: block; + color: black; + } + .modal-text { + font-size: 1.2em; + } + ` + + template.content.appendChild(style) + this.modal.appendChild(this.mask) + this.modal.style.display = hidden ? 'none' : 'block' + this.modal.appendChild(template.content.cloneNode(true)) + shadow.appendChild(this.modal) + } + // 动画 + animate(action = 'open', params) { + const { modal } = this + + this.timer && clearTimeout(this.timer) + + switch (action) { + case 'open': + modal.style.display = 'block' + this.hidden = false + modal.style.opacity = 0 + this.timer = setTimeout(() => { + modal.style.opacity = 1 + modal.style.display = 'block' + this.dispatchEvent(new CustomEvent('open', { detail: modal, params })) + }, 100) + break + case 'close': + modal.style.opacity = 0 + this.timer = setTimeout(() => { + modal.style.display = 'none' + this.hidden = true + this.dispatchEvent(new CustomEvent('close', { detail: modal, params })) + }, 300) + break + } + } + // 显示模态框 + open(params = null) { + this.animate('open', params) + } + // 隐藏模态框 + close(params = null) { + this.animate('close', params) + } + } + // TODO:选项卡 + this.Tab = class Tab extends HTMLElement { + // 可用属性 + static observedAttributes = ['list', 'current'] + constructor() { + super() + // 选项卡 + this.tabs = null + // 选项卡列表 + this.list = null + // 选项卡当前下标 + this.current = 0 + } + // 生命周期 + connectedCallback() { + this.init() + this.setTab() + } + // 属性变化 + attributeChangedCallback(name, oldValue, newValue) { + this['list'] = newValue.split('|') + this['list'].map((item, index) => { + this['list'][index] = item.split('>') + }) + } + // 初始化 + init() { + const { list } = this + const shadow = this.attachShadow({ mode: 'open' }) + const template = document.createElement('template') + const style = document.createElement('style') + this.tabs = document.createElement('div') + const tabList = document.createElement('dl') + tabList.className = tabList.part = 'tab-list' + this.tabs.className = this.tabs.part = 'tab-box' + + template.innerHTML = ` + + ` + style.innerHTML = ` + .tab-box { + font-size: 1em; + width: 100%; + user-select: none; + --highlight-color: #001EFF; + --text-color: black; + --background-color: transparent; + --description-color: #424242; + --gap: 5em; + } + .tab-list { + width: 100%; + display: flex; + justify-content: center; + gap: var(--gap); + margin: 0; + } + .tab-item { + padding: .6em 2em; + background: var(--background-color); + cursor: pointer; + transition: all .3s; + border-bottom: 3px solid transparent; + text-align: center; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + color: var(--text-color); + box-sizing: border-box; + margin: 0; + } + .tab-item:hover,.tab-item.active { + border-bottom: 3px solid var(--highlight-color); + } + .tab-item cite { + font-size: .7em; + color: var(--description-color); + font-weight: 400; + font-style: normal; + margin-top: .4em; + pointer-events: none; + } + ` + list.forEach((item, index) => { + const dd = document.createElement('dd') + dd.className = dd.part = 'tab-item' + dd.index = index + + if (index === this.current) { + dd.classList.add('active') + } + + // 判断是否有描述 + if (item.length > 1) { + const cite = document.createElement('cite') + cite.className = cite.part = 'tab-description' + cite.innerText = item[1] + dd.innerText = item[0] + dd.appendChild(cite) + } else { + dd.innerText = item + } + + tabList.appendChild(dd) + }) + + this.tabs.appendChild(tabList) + shadow.appendChild(style) + shadow.appendChild(this.tabs) + shadow.appendChild(template.content.cloneNode(true)) + } + // 选项卡切换 + setTab() { + this.tabs.addEventListener('click', e => { + if (e.target.tagName === 'DD') { + this.tabs.querySelectorAll('.tab-item').forEach(item => { + item.classList.remove('active') + }) + e.target.classList.add('active') + this.dispatchEvent(new CustomEvent('change', { detail: e.target })) + } + }) + } + } + // TODO:提示条 + this.Tip = class Tip extends HTMLElement { + // 可用属性 + static observedAttributes = ['content', 'type', 'hidden', 'timeout'] + constructor() { + super() + // 提示条 + this.Tip = null + // 提示内容 + this.content = '提示内容' + // 提示类型 + this.type = 'info' + // 提示条隐藏 + this.hidden = false + // 提示条定时器 + this.timer = null + // 提示条显示时间 + this.timeout = 3000 + } + // 生命周期 + connectedCallback() { + this.init() + !this.hidden && this.show() + } + // 属性变化 + attributeChangedCallback(name, oldValue, newValue) { + if (name == 'hidden') { + this[name] = newValue + if (this[name]) { + this.hide() + } + } else if (name == 'timeout') { + this[name] = Number(newValue) + } else { + this[name] = newValue + } + } + // 初始化 + init() { + const { content, type, hidden } = this + const shadow = this.attachShadow({ mode: 'open' }) + const template = document.createElement('template') + const style = document.createElement('style') + this.Tip = document.createElement('div') + this.Tip.className = this.Tip.part = 'tip' + const contentSlot = document.createElement('slot') + const prefixSlot = document.createElement('slot') + contentSlot.name = 'content' + prefixSlot.name = 'prefix' + const closeBtn = document.createElement('button') + closeBtn.className = closeBtn.part = 'tip-close' + closeBtn.innerHTML = that.Icon.clear + + contentSlot.innerHTML = `${content}` + prefixSlot.innerHTML = `${that.Icon.task}` + style.innerHTML = ` + .tip { + --icon-color: #333; + --close-color: #333; + --color: #78A2F5; + --background-color: #E7F3FF; + position: absolute; + top: 1vh; + left: 50%; + transform: translate(-50%,-150%); + padding: .5em .5em .5em 1em; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.15); + border: 1px solid; + border-radius: 5px; + font-size: 16px; + z-index: calc(Infinity + 1); + transition: all .3s ease-out; + display: none; + align-items: center; + width: auto; + opacity: 0; + box-sizing: border-box; + } + .tip-icon { + width: 1.3em; + height: 1.3em; + flex-shrink: 0; + margin-right: .5em; + --icon-color:var(--color); + } + .tip-icon svg{ + width: 100%; + height: 100%; + fill: var(--icon-color); + } + .tip-content { + font-size: 1em; + font-weight: bold; + text-align: left; + min-width: 100px; + max-width: 400px; + } + .tip-close{ + border: none; + background: transparent; + cursor: pointer; + width: 2.2em; + height: 2.2em; + margin-left: .5em; + padding: 0; + flex-shrink: 0; + } + .tip-close svg { + width: 100%; + height: 100%; + fill: var(--close-color); + } + .tip.info { + background: var(--background-color); + color: var(--color); + } + .tip.success { + --color: #4CAF50; + --background-color: #E8F5E9; + } + .tip.warn { + --color: #ff9800; + --background-color: #FFF3E0; + } + .tip.error { + --color: #f44336; + --background-color: #FFEBEE; + } + ` + + // 关闭提示条 + closeBtn.onclick = () => { + this.hide() + } + + this.Tip.classList.add(type) + this.Tip.style.display = hidden ? 'none' : 'flex' + template.content.appendChild(prefixSlot) + template.content.appendChild(contentSlot) + this.Tip.appendChild(template.content.cloneNode(true)) + this.Tip.appendChild(closeBtn) + shadow.appendChild(style) + shadow.appendChild(this.Tip) + } + // 动画 + animate(action = 'show') { + const { Tip } = this + switch (action) { + case 'show': + Tip.style.display = 'flex' + setTimeout(() => { + Tip.style.transform = 'translate(-50%, 0)' + Tip.style.opacity = 1 + }, 50) + break + case 'hide': + Tip.style.transform = 'translate(-50%, -150%)' + Tip.style.opacity = 0 + Tip.addEventListener( + 'transitionend', + () => { + Tip.style.display = 'none' + }, + { once: true } + ) + break + } + } + // 显示提示条 + show(msg = null) { + const { timeout, Tip, content } = this + Tip.querySelector('.tip-content').innerText = msg || content + + if (Tip.querySelector('.tip-content').innerText !== '') { + this.animate('show') + + if (timeout && timeout > 0) { + clearTimeout(this.timer) + this.timer = setTimeout(() => { + this.animate('hide') + }, timeout) + } + } else { + return false + } + } + // 隐藏提示条 + hide() { + this.animate('hide') + } + } + + // 注册组件 + if (registry) { + registry.map(name => { + const componentName = name.toLowerCase() + switch (name.toLowerCase()) { + case 'ui-search': + this.registerComponent(componentName, this.Search) + break + case 'ui-select': + this.registerComponent(componentName, this.Select) + break + case 'ui-loading': + this.registerComponent(componentName, this.Loading) + break + case 'ui-bubble-list': + this.registerComponent(componentName, this.BubbleList) + break + case 'ui-dialog': + this.registerComponent(componentName, this.Dialog) + break + case 'ui-modal': + this.registerComponent(componentName, this.Modal) + break + case 'ui-tab': + this.registerComponent(componentName, this.Tab) + break + case 'ui-tip': + this.registerComponent(componentName, this.Tip) + break + } + }) + } else { + throw new Error('请传入注册组件名称') + } + } + // 注册组件 + registerComponent(name, component, extend = false) { + // 如果是扩展元素 + if (extend) { + customElements.define(name, component, { extends: extend }) + } else { + customElements.define(name, component) + } + } + // 组件图标 + Icon = { + // 清除&关闭 + clear: ``, + // 任务 + task: ``, + } +}