yyf_aka-web-components
Version:
A collection of modern Web Components including counter, data table and custom button components
1,010 lines (890 loc) • 34.1 kB
JavaScript
/**
* 数据表格组件 - 复杂数据处理示例
*
* 功能特性:
* - 自定义元素:使用 <data-table> 标签
* - Shadow DOM:封装样式和结构
* - 数据绑定:支持动态数据加载和更新
* - 排序功能:支持多列排序,升序/降序切换
* - 搜索功能:支持全局搜索,实时过滤
* - 分页功能:支持自定义页大小,分页导航
* - 自定义列:支持状态徽章、操作按钮等自定义渲染
* - 事件系统:触发多种自定义事件
* - 公共 API:提供多种数据操作方法
*
* 使用示例:
* <data-table page-size="5" searchable="true" sortable="true" pagination="true"></data-table>
*
* 属性说明:
* - page-size: 每页显示的行数(默认:10)
* - searchable: 是否启用搜索功能(默认:true)
* - sortable: 是否启用排序功能(默认:true)
* - pagination: 是否启用分页功能(默认:true)
*
* 事件说明:
* - row-added: 添加行时触发
* detail: { row: object }
* - row-updated: 更新行时触发
* detail: { index: number, row: object }
* - row-deleted: 删除行时触发
* detail: { index: number, row: object }
* - row-view: 查看行时触发
* detail: { index: number, row: object }
* - row-edit: 编辑行时触发
* detail: { index: number, row: object }
*
* 公共方法:
* - setData(data): 设置表格数据
* - setColumns(columns): 设置列定义
* - addRow(row): 添加新行
* - updateRow(index, row): 更新指定行
* - deleteRow(index): 删除指定行
* - viewRow(rowIndex): 查看指定行
* - editRow(rowIndex): 编辑指定行
* - getData(): 获取所有数据
* - getFilteredData(): 获取过滤后的数据
*/
class DataTable extends HTMLElement {
/**
* 构造函数
* 初始化组件的基本属性和状态
*/
constructor() {
super();
// 创建 Shadow DOM,mode: 'open' 允许外部访问
this.attachShadow({ mode: 'open' });
// 数据相关属性
this._data = []; // 原始数据数组
this._columns = []; // 列定义数组
this._filteredData = []; // 过滤后的数据数组
// 分页相关属性
this._currentPage = 1; // 当前页码
this._pageSize = 10; // 每页显示的行数
// 排序相关属性
this._sortColumn = null; // 当前排序列
this._sortDirection = 'asc'; // 排序方向(asc/desc)
// 搜索相关属性
this._searchTerm = ''; // 搜索关键词
// 功能开关
this._searchable = true; // 是否启用搜索
this._sortable = true; // 是否启用排序
this._pagination = true; // 是否启用分页
// 事件监听器引用,用于清理
this._eventListeners = new Map();
}
/**
* 定义需要观察的属性
* 当这些属性发生变化时,会触发 attributeChangedCallback
* @returns {string[]} 需要观察的属性名数组
*/
static get observedAttributes() {
return ['page-size', 'searchable', 'sortable', 'pagination'];
}
/**
* 组件连接到 DOM 时调用
* 初始化组件状态、加载数据、渲染界面、绑定事件
*/
connectedCallback() {
// 从属性中读取配置参数
this._pageSize = parseInt(this.getAttribute('page-size')) || 10;
this._searchable = this.getAttribute('searchable') !== 'false';
this._sortable = this.getAttribute('sortable') !== 'false';
this._pagination = this.getAttribute('pagination') !== 'false';
// 加载数据(从属性或插槽)
this.loadData();
// 渲染组件界面
this.render();
// 绑定事件监听器
this.addEventListeners();
}
/**
* 组件从 DOM 中移除时调用
* 清理事件监听器,防止内存泄漏
*/
disconnectedCallback() {
this.removeEventListeners();
}
/**
* 观察的属性发生变化时调用
* @param {string} name - 发生变化的属性名
* @param {string} oldValue - 变化前的值
* @param {string} newValue - 变化后的值
*/
attributeChangedCallback(name, oldValue, newValue) {
// 只有当值真正发生变化时才处理
if (oldValue !== newValue) {
switch (name) {
case 'page-size':
// 更新页大小,并重置到第一页
this._pageSize = parseInt(newValue) || 10;
this._currentPage = 1;
break;
case 'searchable':
// 更新搜索功能开关
this._searchable = newValue !== 'false';
break;
case 'sortable':
// 更新排序功能开关
this._sortable = newValue !== 'false';
break;
case 'pagination':
// 更新分页功能开关
this._pagination = newValue !== 'false';
break;
}
// 重新渲染界面
this.render();
}
}
/**
* 绑定事件监听器
* 为搜索、排序、分页等功能添加事件处理
*/
addEventListeners() {
const shadow = this.shadowRoot;
// 搜索功能事件监听
if (this._searchable) {
const searchInput = shadow.querySelector('.search-input');
if (searchInput) {
const searchHandler = (e) => {
// 更新搜索关键词
this._searchTerm = e.target.value;
// 重置到第一页
this._currentPage = 1;
// 执行数据过滤
this.filterData();
// 重新渲染界面
this.render();
};
// 存储监听器引用
this._eventListeners.set('search', { element: searchInput, handler: searchHandler, event: 'input' });
searchInput.addEventListener('input', searchHandler);
}
}
// 排序功能事件监听
if (this._sortable) {
const sortButtons = shadow.querySelectorAll('.sort-btn');
sortButtons.forEach((btn, index) => {
const sortHandler = () => {
// 从按钮的 data-column 属性获取列名
const column = btn.dataset.column;
// 执行排序
this.sortData(column);
};
// 存储监听器引用
this._eventListeners.set(`sort-${index}`, { element: btn, handler: sortHandler, event: 'click' });
btn.addEventListener('click', sortHandler);
});
}
// 分页功能事件监听
if (this._pagination) {
const paginationContainer = shadow.querySelector('.pagination');
if (paginationContainer) {
const paginationHandler = (e) => {
// 检查点击的是否为分页按钮
if (e.target.classList.contains('page-btn')) {
const page = parseInt(e.target.dataset.page);
// 确保页码有效且不是当前页
if (page && page !== this._currentPage && !e.target.disabled) {
this._currentPage = page;
// 重新渲染界面
this.render();
}
}
};
// 存储监听器引用
this._eventListeners.set('pagination', { element: paginationContainer, handler: paginationHandler, event: 'click' });
paginationContainer.addEventListener('click', paginationHandler);
}
}
// 操作按钮事件监听(使用事件委托)
const tableBody = shadow.querySelector('.data-table tbody');
if (tableBody) {
const actionHandler = (e) => {
// 检查点击的是否为操作按钮
if (e.target.classList.contains('action-btn')) {
const action = e.target.dataset.action;
const rowIndex = parseInt(e.target.dataset.rowIndex);
// 确保操作类型和行索引有效
if (action && rowIndex !== undefined) {
switch (action) {
case 'view':
this.viewRow(rowIndex);
break;
case 'edit':
this.editRow(rowIndex);
break;
case 'delete':
this.deleteRow(rowIndex);
break;
}
}
}
};
// 存储监听器引用
this._eventListeners.set('actions', { element: tableBody, handler: actionHandler, event: 'click' });
tableBody.addEventListener('click', actionHandler);
}
}
/**
* 移除事件监听器
* 清理事件监听器,防止内存泄漏
*/
removeEventListeners() {
// 清理所有存储的事件监听器
this._eventListeners.forEach(({ element, handler, event }) => {
if (element && element.removeEventListener) {
element.removeEventListener(event, handler);
}
});
this._eventListeners.clear();
}
/**
* 加载数据
* 从属性或插槽中获取数据和列定义,并更新过滤后的数据
*/
loadData() {
// 从属性中获取数据
const dataAttr = this.getAttribute('data');
if (dataAttr) {
try {
this._data = JSON.parse(dataAttr);
} catch (e) {
console.error('Invalid data attribute:', e);
this._data = [];
}
}
// 从属性中获取列定义
const columnsAttr = this.getAttribute('columns');
if (columnsAttr) {
try {
this._columns = JSON.parse(columnsAttr);
} catch (e) {
console.error('Invalid columns attribute:', e);
this._columns = [];
}
}
// 如果没有列定义,提供默认列
if (this._columns.length === 0 && this._data.length > 0) {
this._columns = Object.keys(this._data[0]).map(key => ({
key: key,
label: key.charAt(0).toUpperCase() + key.slice(1),
type: 'text'
}));
}
// 更新过滤后的数据
this._filteredData = [...this._data];
}
/**
* 执行数据过滤
* 根据当前的搜索关键词过滤数据
*/
filterData() {
if (!this._searchTerm || this._searchTerm.trim() === '') {
this._filteredData = [...this._data];
return;
}
const searchTerm = this._searchTerm.toLowerCase().trim();
this._filteredData = this._data.filter(item => {
return Object.values(item).some(value => {
if (value === null || value === undefined) return false;
return String(value).toLowerCase().includes(searchTerm);
});
});
}
/**
* 执行数据排序
* @param {string} column - 要排序的列名
*/
sortData(column) {
if (this._sortColumn === column) {
this._sortDirection = this._sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this._sortColumn = column;
this._sortDirection = 'asc';
}
this._filteredData.sort((a, b) => {
let aVal = a[column];
let bVal = b[column];
// 处理null和undefined值
if (aVal === null || aVal === undefined) aVal = '';
if (bVal === null || bVal === undefined) bVal = '';
// 处理数字类型
if (!isNaN(aVal) && !isNaN(bVal) && aVal !== '' && bVal !== '') {
aVal = parseFloat(aVal);
bVal = parseFloat(bVal);
} else {
// 字符串比较
aVal = String(aVal).toLowerCase();
bVal = String(bVal).toLowerCase();
}
if (aVal < bVal) return this._sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return this._sortDirection === 'asc' ? 1 : -1;
return 0;
});
// 重置到第一页
this._currentPage = 1;
this.render();
}
/**
* 获取当前页的数据
* @returns {Array} 当前页的数据数组
*/
getPaginatedData() {
if (!this._pagination) {
return this._filteredData;
}
const startIndex = (this._currentPage - 1) * this._pageSize;
const endIndex = startIndex + this._pageSize;
return this._filteredData.slice(startIndex, endIndex);
}
/**
* 获取总页数
* @returns {number} 总页数
*/
getTotalPages() {
if (this._filteredData.length === 0) return 0;
return Math.ceil(this._filteredData.length / this._pageSize);
}
/**
* 渲染组件界面
* 根据当前状态(数据、分页、排序、搜索)重新渲染表格内容
*/
render() {
// 先清理旧的事件监听器
this.removeEventListeners();
const paginatedData = this.getPaginatedData();
const totalPages = this.getTotalPages();
const totalItems = this._filteredData.length;
// 确保当前页码有效
if (this._currentPage > totalPages && totalPages > 0) {
this._currentPage = totalPages;
}
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.data-table-container {
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
overflow: hidden;
}
.table-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.table-title {
margin: 0;
font-size: 20px;
font-weight: 300;
}
.table-controls {
display: flex;
gap: 15px;
align-items: center;
}
.search-container {
position: relative;
}
.search-input {
padding: 8px 12px 8px 35px;
border: none;
border-radius: 6px;
background: rgba(255,255,255,0.9);
color: #333;
font-size: 14px;
width: 200px;
}
.search-input:focus {
outline: none;
background: white;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: #666;
font-size: 14px;
}
.table-wrapper {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
background: white;
}
.data-table th {
background: #f8f9fa;
padding: 15px 12px;
text-align: left;
font-weight: 600;
color: #333;
border-bottom: 2px solid #e9ecef;
position: relative;
}
.data-table th.sortable {
cursor: pointer;
user-select: none;
}
.data-table th.sortable:hover {
background: #e9ecef;
}
.sort-btn {
background: none;
border: none;
cursor: pointer;
padding: 0;
width: 100%;
text-align: left;
font-weight: inherit;
color: inherit;
display: flex;
align-items: center;
justify-content: space-between;
}
.sort-icon {
font-size: 12px;
margin-left: 5px;
opacity: 0.5;
}
.sort-icon.active {
opacity: 1;
}
.data-table td {
padding: 12px;
border-bottom: 1px solid #f0f0f0;
color: #333;
}
.data-table tbody tr:hover {
background: #f8f9fa;
}
.data-table tbody tr:nth-child(even) {
background: #fafbfc;
}
.data-table tbody tr:nth-child(even):hover {
background: #f0f1f2;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #999;
}
.empty-state h3 {
margin: 0 0 10px 0;
font-weight: 300;
}
.table-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: #f8f9fa;
border-top: 1px solid #e9ecef;
}
.table-info {
font-size: 14px;
color: #666;
}
.pagination {
display: flex;
gap: 5px;
align-items: center;
}
.page-btn {
padding: 6px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s ease;
}
.page-btn:hover {
background: #f0f1f2;
}
.page-btn.active {
background: #667eea;
color: white;
border-color: #667eea;
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.status-badge {
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.status-active {
background: #d4edda;
color: #155724;
}
.status-inactive {
background: #f8d7da;
color: #721c24;
}
.status-pending {
background: #fff3cd;
color: #856404;
}
.action-btn {
padding: 4px 8px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
margin-right: 5px;
transition: all 0.3s ease;
}
.edit-btn {
background: #ffc107;
color: #333;
}
.delete-btn {
background: #dc3545;
color: white;
}
.view-btn {
background: #17a2b8;
color: white;
}
(max-width: 768px) {
.table-header {
flex-direction: column;
gap: 15px;
align-items: stretch;
}
.table-controls {
justify-content: center;
}
.search-input {
width: 100%;
}
.table-footer {
flex-direction: column;
gap: 15px;
}
.pagination {
justify-content: center;
}
}
</style>
<div class="data-table-container">
<div class="table-header">
<h2 class="table-title">数据表格</h2>
${this._searchable ? `
<div class="table-controls">
<div class="search-container">
<span class="search-icon">🔍</span>
<input type="text" class="search-input" placeholder="搜索..." value="${this._searchTerm}">
</div>
</div>
` : ''}
</div>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
${this._columns.length > 0 ? this._columns.map(column => `
<th class="${this._sortable ? 'sortable' : ''}">
${this._sortable ? `
<button class="sort-btn" data-column="${column.key}">
${column.label}
<span class="sort-icon ${this._sortColumn === column.key ? 'active' : ''}">
${this._sortColumn === column.key ?
(this._sortDirection === 'asc' ? '↑' : '↓') : '↕'}
</span>
</button>
` : column.label}
</th>
`).join('') : '<th>暂无列定义</th>'}
</tr>
</thead>
<tbody>
${paginatedData.length > 0 ? paginatedData.map((row, index) => `
<tr>
${this._columns.length > 0 ? this._columns.map(column => `
<td>
${this.renderCell(row, column, index)}
</td>
`).join('') : '<td>暂无数据</td>'}
</tr>
`).join('') : `
<tr>
<td colspan="${Math.max(this._columns.length, 1)}" class="empty-state">
<h3>暂无数据</h3>
<p>${this._searchTerm ? '没有找到匹配的数据' : '请添加数据'}</p>
</td>
</tr>
`}
</tbody>
</table>
</div>
${this._pagination && totalPages > 1 ? `
<div class="table-footer">
<div class="table-info">
显示 ${totalItems > 0 ? (this._currentPage - 1) * this._pageSize + 1 : 0} -
${Math.min(this._currentPage * this._pageSize, totalItems)}
条,共 ${totalItems} 条数据
</div>
<div class="pagination">
<button class="page-btn" data-page="${this._currentPage - 1}"
${this._currentPage <= 1 ? 'disabled' : ''}>
上一页
</button>
${this.getPageNumbers().map(page => `
<button class="page-btn ${page === this._currentPage ? 'active' : ''}"
data-page="${page}">
${page}
</button>
`).join('')}
<button class="page-btn" data-page="${this._currentPage + 1}"
${this._currentPage >= totalPages ? 'disabled' : ''}>
下一页
</button>
</div>
</div>
` : ''}
</div>
<slot name="footer"></slot>
`;
// 重新绑定事件监听器
this.addEventListeners();
}
/**
* 渲染单个单元格
* @param {object} row - 当前行数据
* @param {object} column - 列定义
* @param {number} rowIndex - 当前行在分页数据中的索引
* @returns {string} 渲染后的单元格内容
*/
renderCell(row, column, rowIndex) {
const value = row[column.key];
// 自定义渲染器
if (column.render) {
return column.render(value, row, rowIndex);
}
// 状态徽章
if (column.type === 'status') {
const statusMap = {
'active': 'status-active',
'inactive': 'status-inactive',
'pending': 'status-pending'
};
const statusClass = statusMap[value] || 'status-inactive';
return `<span class="status-badge ${statusClass}">${value || 'unknown'}</span>`;
}
// 操作按钮
if (column.type === 'actions') {
return `
<button class="action-btn view-btn" data-action="view" data-row-index="${rowIndex}">查看</button>
<button class="action-btn edit-btn" data-action="edit" data-row-index="${rowIndex}">编辑</button>
<button class="action-btn delete-btn" data-action="delete" data-row-index="${rowIndex}">删除</button>
`;
}
// 默认渲染
return value !== null && value !== undefined ? String(value) : '';
}
/**
* 获取分页按钮的页码数组
* @returns {Array} 页码数组
*/
getPageNumbers() {
const totalPages = this.getTotalPages();
const current = this._currentPage;
const pages = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (current <= 4) {
for (let i = 1; i <= 5; i++) {
pages.push(i);
}
if (totalPages > 5) {
pages.push('...');
pages.push(totalPages);
}
} else if (current >= totalPages - 3) {
pages.push(1);
pages.push('...');
for (let i = totalPages - 4; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
pages.push('...');
for (let i = current - 1; i <= current + 1; i++) {
pages.push(i);
}
pages.push('...');
pages.push(totalPages);
}
}
return pages;
}
// 公共方法
/**
* 设置表格数据
* @param {Array} data - 新的数据数组
*/
setData(data) {
this._data = Array.isArray(data) ? data : [];
this._filteredData = [...this._data];
this._currentPage = 1;
this.render();
}
/**
* 设置列定义
* @param {Array} columns - 新的列定义数组
*/
setColumns(columns) {
this._columns = Array.isArray(columns) ? columns : [];
this.render();
}
/**
* 添加新行
* @param {object} row - 要添加的行数据
*/
addRow(row) {
if (row && typeof row === 'object') {
this._data.push(row);
this._filteredData = [...this._data];
this.render();
this.dispatchEvent(new CustomEvent('row-added', {
detail: { row },
bubbles: true,
composed: true
}));
}
}
/**
* 更新指定行
* @param {number} index - 要更新的行在原始数据中的索引
* @param {object} row - 新的行数据
*/
updateRow(index, row) {
if (index >= 0 && index < this._data.length && row && typeof row === 'object') {
this._data[index] = { ...this._data[index], ...row };
this._filteredData = [...this._data];
this.render();
this.dispatchEvent(new CustomEvent('row-updated', {
detail: { index, row: this._data[index] },
bubbles: true,
composed: true
}));
}
}
/**
* 删除指定行
* @param {number} index - 要删除的行在原始数据中的索引
*/
deleteRow(index) {
if (index >= 0 && index < this._data.length) {
const deletedRow = this._data.splice(index, 1)[0];
this._filteredData = [...this._data];
// 如果当前页没有数据了,回到上一页
if (this.getPaginatedData().length === 0 && this._currentPage > 1) {
this._currentPage--;
}
this.render();
this.dispatchEvent(new CustomEvent('row-deleted', {
detail: { index, row: deletedRow },
bubbles: true,
composed: true
}));
}
}
/**
* 查看指定行
* @param {number} rowIndex - 要查看的行在分页数据中的索引
*/
viewRow(rowIndex) {
const paginatedData = this.getPaginatedData();
const row = paginatedData[rowIndex];
if (row) {
// 找到在原始数据中的索引
const originalIndex = this._data.findIndex(item => {
// 尝试使用id字段,如果没有则使用对象引用比较
if (item.id !== undefined && row.id !== undefined) {
return item.id === row.id;
}
return item === row;
});
this.dispatchEvent(new CustomEvent('row-view', {
detail: { index: originalIndex, row },
bubbles: true,
composed: true
}));
}
}
/**
* 编辑指定行
* @param {number} rowIndex - 要编辑的行在分页数据中的索引
*/
editRow(rowIndex) {
const paginatedData = this.getPaginatedData();
const row = paginatedData[rowIndex];
if (row) {
// 找到在原始数据中的索引
const originalIndex = this._data.findIndex(item => {
// 尝试使用id字段,如果没有则使用对象引用比较
if (item.id !== undefined && row.id !== undefined) {
return item.id === row.id;
}
return item === row;
});
this.dispatchEvent(new CustomEvent('row-edit', {
detail: { index: originalIndex, row },
bubbles: true,
composed: true
}));
}
}
/**
* 获取所有原始数据
* @returns {Array} 原始数据数组
*/
getData() {
return [...this._data];
}
/**
* 获取过滤后的数据
* @returns {Array} 过滤后的数据数组
*/
getFilteredData() {
return [...this._filteredData];
}
/**
* 获取当前页码
* @returns {number} 当前页码
*/
getCurrentPage() {
return this._currentPage;
}
/**
* 设置当前页码
* @param {number} page - 要设置的页码
*/
setCurrentPage(page) {
const totalPages = this.getTotalPages();
if (page >= 1 && page <= totalPages) {
this._currentPage = page;
this.render();
}
}
}
// 导出组件类
export default DataTable;