253 lines
16 KiB
JavaScript
253 lines
16 KiB
JavaScript
/* ============================================================
|
||
ui.js — Reusable UI primitives & helpers
|
||
Exposes global `UI`
|
||
============================================================ */
|
||
(function () {
|
||
'use strict';
|
||
|
||
const ICONS = {
|
||
'user-plus': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/>',
|
||
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
|
||
'check': '<polyline points="20 6 9 17 4 12"/>',
|
||
'check-circle': '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>',
|
||
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||
'x-circle': '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>',
|
||
'file': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
|
||
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
||
'message': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||
'info': '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
|
||
'alert': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
|
||
'edit': '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z"/>',
|
||
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
|
||
'more': '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
|
||
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
||
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
||
'filter': '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>',
|
||
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||
'mail': '<path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><polyline points="22,6 12,13 2,6"/>',
|
||
'phone': '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.98.36 1.94.7 2.85a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.87.57 2.85.7A2 2 0 0 1 22 16.92z"/>',
|
||
'map': '<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>',
|
||
'briefcase': '<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>',
|
||
'trending-up': '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
|
||
'trending-down': '<polyline points="23 18 13.5 8.5 8.5 13.5 1 6"/><polyline points="17 18 23 18 23 12"/>',
|
||
'users': '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||
'award': '<circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/>',
|
||
'dollar': '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>',
|
||
'target': '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
|
||
'send': '<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>',
|
||
'video': '<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/>',
|
||
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
||
'chevron-left': '<polyline points="15 18 9 12 15 6"/>',
|
||
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
|
||
'refresh': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
|
||
'copy': '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
|
||
'linkedin': '<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/><rect x="2" y="9" width="4" height="12"/><circle cx="4" cy="4" r="2"/>',
|
||
'inbox': '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
|
||
'sparkles': '<path d="M12 3l1.9 5.8L20 10l-6.1 1.2L12 17l-1.9-5.8L4 10l6.1-1.2z"/><path d="M19 3v4M21 5h-4M5 17v4M7 19H3"/>',
|
||
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||
'grid': '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
|
||
'bookmark': '<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>',
|
||
'paperclip': '<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
|
||
'external': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
|
||
'shield': '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
|
||
'lock': '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||
'flame': '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
|
||
'bell': '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
|
||
'layers': '<polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/>',
|
||
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
|
||
'check-square': '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
|
||
'arrow-right': '<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>'
|
||
};
|
||
|
||
function icon(name, cls) { return `<svg class="${cls || ''}" viewBox="0 0 24 24">${ICONS[name] || ICONS['info']}</svg>`; }
|
||
|
||
function avatar(name, initials, color, cls) {
|
||
const bg = color || DB.avatarColor(name || '');
|
||
const init = initials || DB.initials(name || '?');
|
||
return `<span class="avatar ${cls || ''}" style="background:${bg}">${init}</span>`;
|
||
}
|
||
|
||
// ---------- badge helpers ----------
|
||
const statusMap = {
|
||
'Open': 'b-green', 'Closed': 'b-gray', 'On Hold': 'b-amber', 'Draft': 'b-blue',
|
||
'Applied': 'b-blue', 'Screening': 'b-purple', 'Assessment': 'b-amber', 'Interview': 'b-indigo',
|
||
'Offer': 'b-teal', 'Hired': 'b-green', 'Rejected': 'b-red',
|
||
'Scheduled': 'b-blue', 'Completed': 'b-green', 'Cancelled': 'b-red', 'No Show': 'b-amber',
|
||
'Sent': 'b-blue', 'Accepted': 'b-green', 'Negotiating': 'b-amber', 'Declined': 'b-red', 'Expired': 'b-gray',
|
||
'In Progress': 'b-amber', 'Pending': 'b-gray', 'Active': 'b-green', 'Invited': 'b-amber',
|
||
'Strong Hire': 'b-green', 'Hire': 'b-teal', 'Lean Hire': 'b-amber', 'No Hire': 'b-red'
|
||
};
|
||
function badge(text, cls) { return `<span class="badge ${cls || statusMap[text] || 'b-gray'}">${text}</span>`; }
|
||
|
||
function scoreChip(score) {
|
||
// Theme tokens, not fixed hex — the old greens/blues dropped to ~2.6:1 on dark cards.
|
||
const color = score >= 85 ? 'var(--success)' : score >= 70 ? 'var(--warning)' : score >= 55 ? 'var(--info)' : 'var(--danger)';
|
||
return `<span class="score"><span class="score-ring" style="--pct:${score};--sc-color:${color}"><span style="color:${color}">${score}</span></span></span>`;
|
||
}
|
||
|
||
function pbar(pct, cls) {
|
||
const c = pct >= 80 ? 'green' : pct >= 50 ? '' : pct >= 30 ? 'amber' : 'red';
|
||
return `<div class="pbar"><div class="pbar-fill ${cls || c}" style="width:${pct}%"></div></div>`;
|
||
}
|
||
|
||
function avatarStack(names, max) {
|
||
max = max || 3;
|
||
const shown = names.slice(0, max);
|
||
const extra = names.length - max;
|
||
let html = '<div class="avatar-stack">';
|
||
shown.forEach(n => html += avatar(n, DB.initials(n)));
|
||
if (extra > 0) html += `<span class="more-count">+${extra}</span>`;
|
||
return html + '</div>';
|
||
}
|
||
|
||
// ---------- Modal ----------
|
||
function modal({ title, subtitle, body, footer, size }) {
|
||
const root = document.getElementById('modalRoot');
|
||
root.innerHTML = `
|
||
<div class="modal-backdrop" data-close></div>
|
||
<div class="modal ${size || ''}" role="dialog" aria-modal="true">
|
||
<div class="modal-head">
|
||
<div><h2>${title}</h2>${subtitle ? `<p>${subtitle}</p>` : ''}</div>
|
||
<button class="modal-close" data-close aria-label="Close">${icon('x')}</button>
|
||
</div>
|
||
<div class="modal-body">${body}</div>
|
||
${footer ? `<div class="modal-foot">${footer}</div>` : ''}
|
||
</div>`;
|
||
root.classList.add('open');
|
||
document.body.style.overflow = 'hidden';
|
||
root.querySelectorAll('[data-close]').forEach(el => el.addEventListener('click', closeModal));
|
||
return root;
|
||
}
|
||
function closeModal() {
|
||
const root = document.getElementById('modalRoot');
|
||
root.classList.remove('open');
|
||
root.innerHTML = '';
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
// ---------- Toast ----------
|
||
function toast(msg, type = 'info', title) {
|
||
const root = document.getElementById('toastRoot');
|
||
const cfg = {
|
||
success: { i: 'check-circle', c: 'i-green', t: 'Success' },
|
||
error: { i: 'x-circle', c: 'i-red', t: 'Error' },
|
||
info: { i: 'info', c: 'i-blue', t: 'Notice' },
|
||
warning: { i: 'alert', c: 'i-amber', t: 'Warning' }
|
||
}[type] || { i: 'info', c: 'i-blue', t: 'Notice' };
|
||
const el = document.createElement('div');
|
||
el.className = 'toast';
|
||
el.innerHTML = `
|
||
<span class="toast-icn ${cfg.c}">${icon(cfg.i)}</span>
|
||
<div class="toast-body"><div class="toast-title">${title || cfg.t}</div><div class="toast-msg">${msg}</div></div>
|
||
<button class="toast-close">${icon('x')}</button>`;
|
||
root.appendChild(el);
|
||
const remove = () => { el.classList.add('out'); setTimeout(() => el.remove(), 300); };
|
||
el.querySelector('.toast-close').onclick = remove;
|
||
setTimeout(remove, 4200);
|
||
}
|
||
|
||
// ---------- Sortable / paginated table ----------
|
||
function dataTable(config) {
|
||
// config: { columns:[{key,label,sortable,render,align}], rows, pageSize, empty }
|
||
const state = { sortKey: null, sortDir: 1, page: 1, rows: config.rows };
|
||
const pageSize = config.pageSize || 10;
|
||
const id = 'tbl_' + Math.random().toString(36).slice(2, 8);
|
||
|
||
function sorted() {
|
||
let r = state.rows;
|
||
if (state.sortKey) {
|
||
const col = config.columns.find(c => c.key === state.sortKey);
|
||
r = [...r].sort((a, b) => {
|
||
let va = col.sortValue ? col.sortValue(a) : a[state.sortKey];
|
||
let vb = col.sortValue ? col.sortValue(b) : b[state.sortKey];
|
||
if (typeof va === 'string') { va = va.toLowerCase(); vb = (vb || '').toLowerCase(); }
|
||
if (va < vb) return -1 * state.sortDir;
|
||
if (va > vb) return 1 * state.sortDir;
|
||
return 0;
|
||
});
|
||
}
|
||
return r;
|
||
}
|
||
function render() {
|
||
const rows = sorted();
|
||
const total = rows.length;
|
||
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||
if (state.page > pages) state.page = pages;
|
||
const start = (state.page - 1) * pageSize;
|
||
const pageRows = rows.slice(start, start + pageSize);
|
||
|
||
const thead = config.columns.map(c => {
|
||
const sortedCls = state.sortKey === c.key ? (state.sortDir === 1 ? 'sorted-asc' : 'sorted-desc') : '';
|
||
const ind = c.sortable ? `<span class="sort-ind">${state.sortKey === c.key ? (state.sortDir === 1 ? '▲' : '▼') : '⇅'}</span>` : '';
|
||
return `<th class="${c.sortable ? 'sortable ' : ''}${sortedCls}" data-sort="${c.key}" style="text-align:${c.align || 'left'}">${c.label}${ind}</th>`;
|
||
}).join('');
|
||
|
||
let tbody;
|
||
if (!pageRows.length) {
|
||
tbody = `<tr><td colspan="${config.columns.length}">
|
||
<div class="empty-state">${icon('search')}<h3>No results found</h3><p>${config.empty || 'Try adjusting your filters or search.'}</p></div></td></tr>`;
|
||
} else {
|
||
tbody = pageRows.map(row => `<tr>${config.columns.map(c =>
|
||
`<td style="text-align:${c.align || 'left'}">${c.render ? c.render(row) : (row[c.key] ?? '')}</td>`).join('')}</tr>`).join('');
|
||
}
|
||
|
||
const from = total ? start + 1 : 0, to = Math.min(start + pageSize, total);
|
||
const pager = pageButtons(state.page, pages);
|
||
|
||
const el = document.getElementById(id);
|
||
el.innerHTML = `
|
||
<div class="table-wrap"><table class="data">
|
||
<thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table></div>
|
||
<div class="pagination">
|
||
<span class="page-info">Showing <b>${from}–${to}</b> of <b>${total}</b></span>
|
||
<div class="page-controls">${pager}</div>
|
||
</div>`;
|
||
|
||
el.querySelectorAll('th.sortable').forEach(th => th.onclick = () => {
|
||
const k = th.dataset.sort;
|
||
if (state.sortKey === k) state.sortDir *= -1; else { state.sortKey = k; state.sortDir = 1; }
|
||
render();
|
||
});
|
||
el.querySelectorAll('[data-page]').forEach(b => b.onclick = () => {
|
||
const p = b.dataset.page;
|
||
if (p === 'prev') state.page = Math.max(1, state.page - 1);
|
||
else if (p === 'next') state.page = Math.min(pages, state.page + 1);
|
||
else state.page = +p;
|
||
render();
|
||
});
|
||
if (config.onRender) config.onRender(el);
|
||
}
|
||
function pageButtons(cur, pages) {
|
||
let btns = `<button class="page-btn" data-page="prev" ${cur === 1 ? 'disabled' : ''}>${icon('chevron-left')}</button>`;
|
||
const list = [];
|
||
for (let i = 1; i <= pages; i++) {
|
||
if (i === 1 || i === pages || Math.abs(i - cur) <= 1) list.push(i);
|
||
else if (list[list.length - 1] !== '…') list.push('…');
|
||
}
|
||
list.forEach(i => btns += i === '…' ? `<span class="page-btn" style="cursor:default">…</span>` : `<button class="page-btn ${i === cur ? 'active' : ''}" data-page="${i}">${i}</button>`);
|
||
btns += `<button class="page-btn" data-page="next" ${cur === pages ? 'disabled' : ''}>${icon('chevron-right')}</button>`;
|
||
return btns;
|
||
}
|
||
// public API
|
||
return {
|
||
html: `<div id="${id}" class="dt"></div>`,
|
||
mount: render,
|
||
update(rows) { state.rows = rows; state.page = 1; render(); }
|
||
};
|
||
}
|
||
|
||
function fieldError(inputEl, msg) {
|
||
inputEl.classList.add('err');
|
||
let err = inputEl.parentElement.querySelector('.field-error');
|
||
if (err) { err.textContent = msg; err.classList.add('show'); }
|
||
}
|
||
function clearErrors(form) {
|
||
form.querySelectorAll('.err').forEach(e => e.classList.remove('err'));
|
||
form.querySelectorAll('.field-error').forEach(e => e.classList.remove('show'));
|
||
}
|
||
|
||
window.UI = { icon, avatar, badge, scoreChip, pbar, avatarStack, modal, closeModal, toast, dataTable, fieldError, clearErrors, ICONS };
|
||
})();
|