Chore: Bump version to v1.1.0 and implement automated release system

This commit is contained in:
dyzulk
2026-01-17 13:01:05 +07:00
parent 64609a5821
commit 5b0b6de2dc
69 changed files with 3157 additions and 2375 deletions

View File

@@ -0,0 +1,322 @@
/**
* Mivo Component: Datatable
* A simple, lightweight, client-side datatable.
*/
class DataTable {
constructor(tableSelector, options = {}) {
this.table = document.querySelector(tableSelector);
if (!this.table) return;
this.tbody = this.table.querySelector('tbody');
this.rows = Array.from(this.tbody.querySelectorAll('tr'));
this.originalRows = [...this.rows];
this.options = {
itemsPerPage: 10,
searchable: true,
pagination: true,
filters: [],
...options
};
this.currentPage = 1;
this.searchQuery = '';
this.activeFilters = {};
this.filteredRows = [...this.originalRows];
// Listen for language changes via Mivo
if (window.Mivo) {
window.Mivo.on('languageChanged', () => {
this.reTranslate();
this.render();
});
}
// Wait for I18n readiness if available
if (window.i18n && window.i18n.ready) {
window.i18n.ready.then(() => this.init());
} else {
this.init();
}
}
reTranslate() {
const i18n = window.Mivo?.modules?.I18n || window.i18n;
if (!i18n) return;
// Labels
const labels = this.wrapper.querySelectorAll('.datatable-label');
labels.forEach(l => l.textContent = i18n.t('common.table.entries_per_page'));
// Placeholder
const searchInput = this.wrapper.querySelector('input.form-input-search');
if (searchInput) searchInput.placeholder = i18n.t('common.table.search_placeholder');
// "All" option
const perPageSelect = this.wrapper.querySelector('select.form-filter');
if (perPageSelect) {
const allOption = Array.from(perPageSelect.options).find(opt => opt.value === "-1");
if (allOption) {
allOption.text = i18n.t('common.table.all');
// Refresh custom select UI if needed
if (window.Mivo?.components?.Select) {
const instance = window.Mivo.components.Select.get(perPageSelect.id || '');
if (instance) instance.refresh();
}
}
}
}
init() {
const i18n = window.Mivo?.modules?.I18n || window.i18n;
// Wrapper
this.wrapper = document.createElement('div');
this.wrapper.className = 'datatable-wrapper space-y-4';
this.table.parentNode.insertBefore(this.wrapper, this.table);
// Header Controls
const header = document.createElement('div');
header.className = 'flex flex-col sm:flex-row justify-between items-center gap-4 mb-4';
// Left Controls
const controlsLeft = document.createElement('div');
controlsLeft.className = 'flex items-center gap-3 w-full sm:w-auto flex-wrap';
// Per Page Select
const perPageSelect = document.createElement('select');
perPageSelect.className = 'form-filter w-20';
// Add ID for CustomSelect registry if needed
perPageSelect.id = 'dt-perpage-' + Math.random().toString(36).substr(2, 9);
[5, 10, 25, 50, 100].forEach(num => {
const opt = document.createElement('option');
opt.value = num;
opt.text = num;
if (num === this.options.itemsPerPage) opt.selected = true;
perPageSelect.appendChild(opt);
});
// All Option
const allOpt = document.createElement('option');
allOpt.value = -1;
allOpt.text = i18n ? i18n.t('common.table.all') : 'All';
perPageSelect.appendChild(allOpt);
perPageSelect.addEventListener('change', (e) => {
const val = parseInt(e.target.value);
this.options.itemsPerPage = val === -1 ? this.originalRows.length : val;
this.currentPage = 1;
this.render();
});
// Label
const label = document.createElement('span');
label.className = 'text-sm text-accents-5 whitespace-nowrap datatable-label';
label.textContent = i18n ? i18n.t('common.table.entries_per_page') : 'entries per page';
controlsLeft.appendChild(perPageSelect);
controlsLeft.appendChild(label);
// Init Custom Select using Mivo Component
if (window.Mivo?.components?.Select) {
new window.Mivo.components.Select(perPageSelect);
}
// Filters
if (this.options.filters && this.options.filters.length > 0) {
this.options.filters.forEach(config => this.initFilter(config, controlsLeft));
}
header.appendChild(controlsLeft);
// Search
if (this.options.searchable) {
const searchWrapper = document.createElement('div');
searchWrapper.className = 'input-group sm:w-64 z-10';
const placeholder = i18n ? i18n.t('common.table.search_placeholder') : 'Search...';
searchWrapper.innerHTML = `
<div class="input-icon">
<i data-lucide="search" class="w-4 h-4"></i>
</div>
<input type="text" class="form-input-search w-full" placeholder="${placeholder}">
`;
const input = searchWrapper.querySelector('input');
input.addEventListener('input', (e) => this.handleSearch(e.target.value));
header.appendChild(searchWrapper);
}
this.wrapper.appendChild(header);
// Table Container
this.tableWrapper = document.createElement('div');
this.tableWrapper.className = 'rounded-md border border-accents-2 overflow-x-auto bg-white/30 dark:bg-black/30 backdrop-blur-sm';
this.tableWrapper.appendChild(this.table);
this.wrapper.appendChild(this.tableWrapper);
if (typeof lucide !== 'undefined') lucide.createIcons({ root: header });
// Pagination
if (this.options.pagination) {
this.paginationContainer = document.createElement('div');
this.paginationContainer.className = 'flex items-center justify-between px-2';
this.wrapper.appendChild(this.paginationContainer);
}
this.render();
}
initFilter(config, container) {
const colIndex = config.index;
const values = new Set();
this.originalRows.forEach(row => {
const cell = row.cells[colIndex];
if (cell) {
const text = cell.innerText.trim();
if(text && text !== '-' && text !== '') values.add(text);
}
});
const select = document.createElement('select');
select.className = 'form-filter datatable-select';
const defOpt = document.createElement('option');
defOpt.value = '';
defOpt.text = config.label;
select.appendChild(defOpt);
Array.from(values).sort().forEach(val => {
const opt = document.createElement('option');
opt.value = val;
opt.text = val;
select.appendChild(opt);
});
select.addEventListener('change', (e) => {
const val = e.target.value;
if (val === '') delete this.activeFilters[colIndex];
else this.activeFilters[colIndex] = val;
this.currentPage = 1;
this.filterRows();
this.render();
});
container.appendChild(select);
if (window.Mivo?.components?.Select) {
new window.Mivo.components.Select(select);
}
}
handleSearch(query) {
this.searchQuery = query.toLowerCase();
this.currentPage = 1;
this.filterRows();
this.render();
}
filterRows() {
this.filteredRows = this.originalRows.filter(row => {
let matchesSearch = true;
if (this.searchQuery) {
matchesSearch = row.innerText.toLowerCase().includes(this.searchQuery);
}
let matchesFilters = true;
for (const [colIndex, filterValue] of Object.entries(this.activeFilters)) {
const cell = row.cells[colIndex];
if (!cell || cell.innerText.trim() !== filterValue) {
matchesFilters = false;
break;
}
}
return matchesSearch && matchesFilters;
});
}
render() {
const i18n = window.Mivo?.modules?.I18n || window.i18n;
const totalItems = this.filteredRows.length;
const totalPages = Math.ceil(totalItems / this.options.itemsPerPage);
if (this.currentPage > totalPages) this.currentPage = totalPages || 1;
if (this.currentPage < 1) this.currentPage = 1;
const start = (this.currentPage - 1) * this.options.itemsPerPage;
const end = start + this.options.itemsPerPage;
const currentItems = this.filteredRows.slice(start, end);
this.tbody.innerHTML = '';
if (currentItems.length > 0) {
currentItems.forEach(row => this.tbody.appendChild(row));
} else {
const emptyRow = document.createElement('tr');
const noMatchText = i18n ? i18n.t('common.table.no_match') : 'No match found.';
emptyRow.innerHTML = `
<td colspan="100%" class="px-6 py-12 text-center text-accents-5">
<span class="text-sm">${noMatchText}</span>
</td>
`;
this.tbody.appendChild(emptyRow);
}
if (this.options.pagination) {
this.renderPagination(totalItems, totalPages, start + 1, Math.min(end, totalItems), i18n);
}
if (typeof lucide !== 'undefined') lucide.createIcons();
}
renderPagination(totalItems, totalPages, start, end, i18n) {
if (totalItems === 0) {
this.paginationContainer.innerHTML = '';
return;
}
const showingText = i18n ? i18n.t('common.table.showing', {start, end, total: totalItems}) : `Showing ${start} to ${end} of ${totalItems}`;
const previousText = i18n ? i18n.t('common.previous') : 'Previous';
const nextText = i18n ? i18n.t('common.next') : 'Next';
const pageText = i18n ? i18n.t('common.page_of', {current: this.currentPage, total: totalPages}) : `Page ${this.currentPage} of ${totalPages}`;
this.paginationContainer.innerHTML = `
<div class="text-sm text-accents-5">
${showingText}
</div>
<div class="flex items-center gap-2">
<button class="btn-prev btn btn-secondary py-1 px-3 text-xs disabled:opacity-50 disabled:cursor-not-allowed" ${this.currentPage === 1 ? 'disabled' : ''}>
${previousText}
</button>
<div class="text-sm font-medium">${pageText}</div>
<button class="btn-next btn btn-secondary py-1 px-3 text-xs disabled:opacity-50 disabled:cursor-not-allowed" ${this.currentPage === totalPages ? 'disabled' : ''}>
${nextText}
</button>
</div>
`;
this.paginationContainer.querySelector('.btn-prev').addEventListener('click', () => {
if (this.currentPage > 1) {
this.currentPage--;
this.render();
}
});
this.paginationContainer.querySelector('.btn-next').addEventListener('click', () => {
if (this.currentPage < totalPages) {
this.currentPage++;
this.render();
}
});
}
}
// Register as Mivo Component
if (window.Mivo) {
window.Mivo.registerComponent('Datatable', DataTable);
// Expose as window global for simpler backward compatibility if typically invoked via new SimpleDataTable()
window.SimpleDataTable = DataTable;
} else {
window.SimpleDataTable = DataTable;
}

View File

@@ -0,0 +1,252 @@
/**
* Mivo Component: Select
* Standardized Custom Select for Forms, Filters, and Navigation.
*/
class CustomSelect {
static instances = [];
static get(elementOrId) {
if (typeof elementOrId === 'string') {
return CustomSelect.instances.find(i => i.originalSelect.id === elementOrId);
}
return CustomSelect.instances.find(i => i.originalSelect === elementOrId);
}
constructor(selectElement) {
if (selectElement.dataset.customSelectInitialized === 'true') return;
selectElement.dataset.customSelectInitialized = 'true';
this.originalSelect = selectElement;
this.originalSelect.style.display = 'none';
this.options = Array.from(this.originalSelect.options);
// Determine Variant
this.variant = this.originalSelect.dataset.variant || 'default';
if (this.originalSelect.classList.contains('form-filter')) this.variant = 'filter';
if (this.originalSelect.classList.contains('nav-select')) this.variant = 'nav';
this.wrapper = document.createElement('div');
this.buildWrapperClasses();
this.init();
CustomSelect.instances.push(this);
}
buildWrapperClasses() {
let base = 'custom-select-wrapper relative active-select';
// Copy width classes
const widthClass = Array.from(this.originalSelect.classList).find(c => c.startsWith('w-') && c !== 'w-full');
const isFullWidth = this.originalSelect.classList.contains('w-full') ||
this.originalSelect.classList.contains('form-control') ||
this.originalSelect.classList.contains('form-input');
if (widthClass) base += ' ' + widthClass;
else if (isFullWidth) base += ' w-full';
else base += ' w-fit';
this.wrapper.className = base;
}
init() {
this.trigger = document.createElement('div');
// Variant Styling
let triggerClass = 'flex items-center justify-between cursor-pointer pr-3 transition-all duration-200';
if (this.variant === 'filter') {
triggerClass += ' form-filter';
} else if (this.variant === 'nav') {
// New Nav variant for transparent/header usage
triggerClass += ' text-sm font-medium hover:bg-accents-2/50 rounded-lg px-2 py-1.5 border border-transparent hover:border-accents-2';
} else {
triggerClass += ' form-input';
}
// Inherit non-structural classes
const inherited = Array.from(this.originalSelect.classList)
.filter(c => !['custom-select', 'hidden', 'form-filter', 'form-input', 'w-full'].includes(c))
.join(' ');
if (inherited) triggerClass += ' ' + inherited;
this.trigger.className = triggerClass;
this.renderTrigger();
// Dropdown Menu
this.menu = document.createElement('div');
this.menu.className = 'custom-select-dropdown';
this.listContainer = document.createElement('div');
this.listContainer.className = 'overflow-y-auto flex-1 py-1 custom-scrollbar';
if (this.originalSelect.dataset.search === 'true') {
this.buildSearch();
}
this.buildOptions();
this.menu.appendChild(this.listContainer);
this.wrapper.appendChild(this.trigger);
this.wrapper.appendChild(this.menu);
this.originalSelect.parentNode.insertBefore(this.wrapper, this.originalSelect);
this.bindEvents();
if (typeof lucide !== 'undefined') lucide.createIcons({ root: this.wrapper });
}
renderTrigger() {
const option = this.originalSelect.options[this.originalSelect.selectedIndex];
const text = option ? option.text : '';
const icon = option?.dataset.icon;
const image = option?.dataset.image;
const flag = option?.dataset.flag;
let html = '';
if (image) html += `<img src="${image}" class="w-5 h-5 mr-2 rounded-full object-cover">`;
else if (flag) html += `<span class="fi fi-${flag} mr-2 rounded-sm shadow-sm"></span>`;
else if (icon) html += `<i data-lucide="${icon}" class="w-4 h-4 mr-2 opacity-70"></i>`;
html += `<span class="truncate flex-1 text-left select-none">${text}</span>`;
html += `<i data-lucide="chevron-down" class="custom-select-icon w-4 h-4 ml-2 opacity-70 transition-transform duration-200"></i>`;
this.trigger.innerHTML = html;
if (typeof lucide !== 'undefined') lucide.createIcons({ root: this.trigger });
}
buildSearch() {
const div = document.createElement('div');
div.className = 'p-2 bg-background z-10 border-b border-accents-2 rounded-t-xl sticky top-0';
const input = document.createElement('input');
input.type = 'text';
input.className = 'w-full px-2 py-1.5 text-xs bg-accents-1 border border-accents-2 rounded-md focus:outline-none focus:ring-1 focus:ring-foreground transition-all';
input.placeholder = 'Search...';
input.addEventListener('input', (e) => {
const term = e.target.value.toLowerCase();
Array.from(this.listContainer.children).forEach(el => {
el.style.display = el.textContent.toLowerCase().includes(term) ? 'flex' : 'none';
});
});
input.addEventListener('click', e => e.stopPropagation());
div.appendChild(input);
this.menu.appendChild(div);
this.searchInput = input;
}
buildOptions() {
this.listContainer.innerHTML = '';
this.options.forEach((opt, idx) => {
const el = document.createElement('div');
el.className = 'px-3 py-2 text-sm cursor-pointer hover:bg-accents-1 transition-colors flex items-center gap-2 relative';
if (opt.selected) el.classList.add('bg-accents-1', 'font-medium');
// Icon/Image Logic
if (opt.dataset.image) el.innerHTML += `<img src="${opt.dataset.image}" class="w-5 h-5 rounded-full object-cover">`;
else if (opt.dataset.flag) el.innerHTML += `<span class="fi fi-${opt.dataset.flag} rounded-sm shadow-sm"></span>`;
else if (opt.dataset.icon) el.innerHTML += `<i data-lucide="${opt.dataset.icon}" class="w-4 h-4 opacity-70"></i>`;
el.innerHTML += `<span class="truncate">${opt.text}</span>`;
// Selected Checkmark
if (opt.selected) {
el.innerHTML += `<i data-lucide="check" class="w-3 h-3 ml-auto text-foreground absolute right-3"></i>`;
}
el.addEventListener('click', () => this.select(idx));
this.listContainer.appendChild(el);
});
}
bindEvents() {
this.trigger.addEventListener('click', e => {
e.stopPropagation();
this.toggle();
});
document.addEventListener('click', e => {
if (!this.wrapper.contains(e.target)) this.close();
});
}
toggle() {
this.menu.classList.contains('open') ? this.close() : this.open();
}
open() {
// Close others
CustomSelect.instances.forEach(i => i !== this && i.close());
// Smart Position
const rect = this.wrapper.getBoundingClientRect();
const menuHeight = 260; // Max-h-60 (240px) + padding + search if exists
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
// Reset positioning classes
this.menu.classList.remove(
'right-0', 'left-0',
'origin-top-right', 'origin-top-left',
'origin-bottom-right', 'origin-bottom-left',
'dropdown-up'
);
// Vertical check
const goUp = spaceBelow < menuHeight && spaceAbove > spaceBelow;
if (goUp) {
this.menu.classList.add('dropdown-up');
}
// Horizontal check
const isRightAligned = window.innerWidth - rect.left < 250;
if (isRightAligned) {
this.menu.classList.add('right-0');
} else {
this.menu.classList.add('left-0');
}
// Apply correct Origin for animation
const originY = goUp ? 'bottom' : 'top';
const originX = isRightAligned ? 'right' : 'left';
this.menu.classList.add(`origin-${originY}-${originX}`);
this.menu.classList.add('open');
this.trigger.classList.add('ring-1', 'ring-foreground');
this.trigger.querySelector('.custom-select-icon')?.classList.add('rotate-180');
if (this.searchInput) setTimeout(() => this.searchInput.focus(), 50);
}
close() {
this.menu.classList.remove('open');
this.trigger.classList.remove('ring-1', 'ring-foreground');
this.trigger.querySelector('.custom-select-icon')?.classList.remove('rotate-180');
}
select(index) {
this.originalSelect.selectedIndex = index;
this.renderTrigger();
this.buildOptions(); // Rebuild to move checkmark
this.close();
this.originalSelect.dispatchEvent(new Event('change', { bubbles: true }));
if (typeof lucide !== 'undefined') lucide.createIcons({ root: this.wrapper });
}
refresh() {
this.options = Array.from(this.originalSelect.options);
this.buildOptions();
this.renderTrigger();
}
}
// Register to Mivo Framework
if (window.Mivo) {
window.Mivo.registerComponent('Select', CustomSelect);
// Auto-init on load
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('select.custom-select').forEach(el => new CustomSelect(el));
});
}