Add reusable table and pagination helpers

This commit is contained in:
Jordan Wages 2025-09-16 22:17:29 -05:00
commit ad543d67b7
2 changed files with 421 additions and 16 deletions

View file

@ -207,8 +207,8 @@ Overview
- Use Bulma form/table/pagination patterns. Keep DOM small; paginate and debounce queries.
Shared Tasks
- [ ] Add small table/list renderer util in `script.js` to build rows safely (uses `escapeHtml`).
- [ ] Add shared pagination component (Prev/Next, page size select). Propagate `LIMIT/OFFSET`.
- [x] Add small table/list renderer util in `script.js` to build rows safely (uses `escapeHtml`). Implemented via `createTableRenderer`.
- [x] Add shared pagination component (Prev/Next, page size select). Propagate `LIMIT/OFFSET`. Implemented via `createPagination`.
- [ ] Add common keyboard handlers: Enter to open selection; Esc to close overlays (already wired globally).
- [ ] Add loading/empty-state helpers for lists.
@ -219,7 +219,7 @@ Primary Navigation (Hub)
Search (Existing)
- [x] `tpl-search` (base): Input is focusable; shows results area.
- [ ] Implement query execution with FTS join; debounce 250 ms; paginate results.
- [x] Implement query execution with FTS join; debounce 250 ms; paginate results. Wired into the new table + pagination helpers.
- SQL: `SELECT t.id, a.name AS artist, t.title, IFNULL(al.title,'') AS album, t.year, t.genre FROM fts_tracks f JOIN tracks t ON t.id=f.rowid JOIN artists a ON a.id=t.artist_id LEFT JOIN albums al ON al.id=t.album_id WHERE f MATCH ? ORDER BY rank LIMIT ? OFFSET ?`.
- [ ] Column sorts (toggle rank vs artist,title,year).
- [ ] Row activation opens Track overlay.

431
script.js
View file

@ -226,6 +226,281 @@
return root;
}
function createTableRenderer({ columns, emptyMessage = 'No results', getRowId }) {
if (!Array.isArray(columns) || columns.length === 0) throw new Error('columns required');
let currentEmpty = emptyMessage;
const table = document.createElement('table');
table.className = 'table is-fullwidth is-striped is-hoverable';
const thead = document.createElement('thead');
const headRow = document.createElement('tr');
columns.forEach((column) => {
const th = document.createElement('th');
th.scope = 'col';
th.textContent = column.header || '';
if (column.headerTitle) th.title = column.headerTitle;
headRow.appendChild(th);
});
thead.appendChild(headRow);
const tbody = document.createElement('tbody');
table.appendChild(thead);
table.appendChild(tbody);
function renderEmpty(message) {
tbody.innerHTML = '';
const tr = document.createElement('tr');
const td = document.createElement('td');
td.colSpan = columns.length;
td.className = 'has-text-centered has-text-grey';
td.textContent = message;
tr.appendChild(td);
tbody.appendChild(tr);
tbody.dataset.state = 'empty';
}
function renderRows(rows) {
tbody.innerHTML = '';
if (!rows || rows.length === 0) {
renderEmpty(currentEmpty);
return;
}
delete tbody.dataset.state;
rows.forEach((row) => {
const tr = document.createElement('tr');
if (typeof getRowId === 'function') {
const rowId = getRowId(row);
if (rowId !== undefined && rowId !== null) tr.dataset.rowId = String(rowId);
}
columns.forEach((column) => {
const td = document.createElement('td');
if (column.className) td.className = column.className;
let value;
if (typeof column.render === 'function') value = column.render(row);
else if ('key' in column) value = row[column.key];
else value = '';
if (value instanceof Node) td.appendChild(value);
else {
const text = value === undefined || value === null ? '' : String(value);
td.innerHTML = escapeHtml(text);
}
tr.appendChild(td);
});
tbody.appendChild(tr);
});
}
renderEmpty(currentEmpty);
return {
el: table,
setRows(rows) { renderRows(rows); },
setEmptyMessage(message) {
currentEmpty = message;
if (tbody.dataset.state === 'empty') renderEmpty(currentEmpty);
},
clear() {
renderEmpty(currentEmpty);
},
};
}
let paginationIdCounter = 0;
function createPagination({
pageSizes = [10, 25, 50, 100],
initialPageSize,
onChange,
} = {}) {
if (!pageSizes.length) pageSizes = [25];
const defaultSize = initialPageSize && pageSizes.includes(initialPageSize) ? initialPageSize : pageSizes[0];
const selectId = `pagination-select-${++paginationIdCounter}`;
const state = {
page: 1,
pageSize: defaultSize,
total: 0,
resultsCount: 0,
disabled: true,
};
const wrapper = document.createElement('div');
wrapper.className = 'mt-4';
wrapper.setAttribute('hidden', '');
const level = document.createElement('div');
level.className = 'level is-mobile';
wrapper.appendChild(level);
const levelLeft = document.createElement('div');
levelLeft.className = 'level-left';
level.appendChild(levelLeft);
const levelLeftItem = document.createElement('div');
levelLeftItem.className = 'level-item';
levelLeft.appendChild(levelLeftItem);
const nav = document.createElement('nav');
nav.className = 'pagination is-small';
nav.setAttribute('role', 'navigation');
nav.setAttribute('aria-label', 'Pagination');
const prevBtn = document.createElement('button');
prevBtn.type = 'button';
prevBtn.className = 'pagination-previous';
prevBtn.textContent = 'Previous';
nav.appendChild(prevBtn);
const nextBtn = document.createElement('button');
nextBtn.type = 'button';
nextBtn.className = 'pagination-next';
nextBtn.textContent = 'Next';
nav.appendChild(nextBtn);
levelLeftItem.appendChild(nav);
const levelRight = document.createElement('div');
levelRight.className = 'level-right';
level.appendChild(levelRight);
const levelRightItem = document.createElement('div');
levelRightItem.className = 'level-item';
levelRight.appendChild(levelRightItem);
const sizeField = document.createElement('div');
sizeField.className = 'field is-grouped is-align-items-center mb-0';
const sizeLabel = document.createElement('label');
sizeLabel.className = 'label is-size-7 mb-0 mr-2';
sizeLabel.setAttribute('for', selectId);
sizeLabel.textContent = 'Rows per page';
sizeField.appendChild(sizeLabel);
const sizeControl = document.createElement('div');
sizeControl.className = 'control';
const selectWrapper = document.createElement('div');
selectWrapper.className = 'select is-small';
const sizeSelect = document.createElement('select');
sizeSelect.id = selectId;
pageSizes.forEach((size) => {
const opt = document.createElement('option');
opt.value = String(size);
opt.textContent = String(size);
sizeSelect.appendChild(opt);
});
sizeSelect.value = String(state.pageSize);
selectWrapper.appendChild(sizeSelect);
sizeControl.appendChild(selectWrapper);
sizeField.appendChild(sizeControl);
levelRightItem.appendChild(sizeField);
const meta = document.createElement('p');
meta.className = 'is-size-7 has-text-grey mt-2';
meta.textContent = '';
wrapper.appendChild(meta);
function emitChange() {
if (typeof onChange === 'function') {
onChange({ page: state.page, pageSize: state.pageSize });
}
}
function updateControls() {
if (state.disabled) {
wrapper.setAttribute('hidden', '');
} else {
wrapper.removeAttribute('hidden');
}
const atFirst = state.page <= 1;
const hasTotal = Number.isFinite(state.total) && state.total >= 0;
const hasResults = state.resultsCount > 0;
const maxKnown = hasTotal ? state.page * state.pageSize >= state.total : state.resultsCount < state.pageSize;
prevBtn.disabled = state.disabled || atFirst;
nextBtn.disabled = state.disabled || maxKnown;
sizeSelect.disabled = state.disabled;
if (state.disabled) {
meta.textContent = '';
return;
}
if (hasTotal) {
if (state.total === 0) {
meta.textContent = 'No results';
return;
}
const start = (state.page - 1) * state.pageSize + 1;
const end = Math.min(state.total, start + state.resultsCount - 1);
meta.textContent = `${start.toLocaleString()}${end.toLocaleString()} of ${state.total.toLocaleString()} results`;
} else if (hasResults) {
const start = (state.page - 1) * state.pageSize + 1;
const end = start + state.resultsCount - 1;
meta.textContent = `${start.toLocaleString()}${end.toLocaleString()} results`;
} else {
meta.textContent = 'No results';
}
}
prevBtn.addEventListener('click', () => {
if (state.disabled || state.page <= 1) return;
state.page -= 1;
updateControls();
emitChange();
});
nextBtn.addEventListener('click', () => {
if (state.disabled) return;
const hasTotal = Number.isFinite(state.total) && state.total >= 0;
if (hasTotal && state.page * state.pageSize >= state.total) return;
if (!hasTotal && state.resultsCount < state.pageSize) return;
state.page += 1;
updateControls();
emitChange();
});
sizeSelect.addEventListener('change', () => {
const nextSize = parseInt(sizeSelect.value, 10);
if (!Number.isFinite(nextSize) || nextSize <= 0) return;
if (nextSize === state.pageSize) return;
state.pageSize = nextSize;
state.page = 1;
updateControls();
emitChange();
});
updateControls();
return {
el: wrapper,
get page() { return state.page; },
get pageSize() { return state.pageSize; },
setDisabled(disabled) {
state.disabled = Boolean(disabled);
updateControls();
},
setState({ page, pageSize, total, resultsCount }) {
if (Number.isFinite(page) && page >= 1) state.page = page;
if (Number.isFinite(pageSize) && pageSize > 0) {
state.pageSize = pageSize;
sizeSelect.value = String(state.pageSize);
}
if (Number.isFinite(total) && total >= 0) state.total = total;
else state.total = NaN;
if (Number.isFinite(resultsCount) && resultsCount >= 0) state.resultsCount = resultsCount;
else state.resultsCount = 0;
state.disabled = false;
updateControls();
},
};
}
function debounce(fn, wait) {
let timer = null;
return function debounced(...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn.apply(this, args);
}, wait);
};
}
function createLoaderView() {
const el = instantiateTemplate('tpl-loader');
const $step = el.querySelector('[data-ref="step"]');
@ -258,33 +533,164 @@
const $q = el.querySelector('[data-ref="q"]');
const $results = el.querySelector('[data-ref="results"]');
function renderStub(qVal) {
if (!qVal) {
$results.innerHTML = '<p class="has-text-grey">Type to search…</p>';
} else {
$results.innerHTML = `<p class="has-text-grey">Search stub — ready for query: <code>${escapeHtml(qVal)}</code></p>`;
}
const $status = document.createElement('p');
$status.className = 'has-text-grey';
$results.innerHTML = '';
$results.appendChild($status);
const columns = [
{ header: 'Artist', key: 'artist' },
{ header: 'Title', key: 'title' },
{ header: 'Album', key: 'album' },
{ header: 'Year', key: 'year', className: 'has-text-right' },
{ header: 'Genre', key: 'genre' },
];
const table = createTableRenderer({
columns,
emptyMessage: 'No matches found',
getRowId: (row) => row.id,
});
table.el.hidden = true;
$results.appendChild(table.el);
const pagination = createPagination({
onChange: ({ page, pageSize }) => {
state.page = page;
state.pageSize = pageSize;
runSearch();
},
});
$results.appendChild(pagination.el);
const state = {
query: '',
page: 1,
pageSize: pagination.pageSize,
sort: 'rank',
};
const searchSql = {
rank: `
SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre
FROM fts_tracks f
JOIN tracks t ON t.id = f.rowid
JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE f MATCH ?
ORDER BY rank
LIMIT ? OFFSET ?
`,
alpha: `
SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre
FROM fts_tracks f
JOIN tracks t ON t.id = f.rowid
JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE f MATCH ?
ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE, COALESCE(t.year, 0)
LIMIT ? OFFSET ?
`,
};
const countSql = `
SELECT COUNT(*) AS count
FROM fts_tracks f
JOIN tracks t ON t.id = f.rowid
JOIN artists a ON a.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE f MATCH ?
`;
function setStatus(text) {
$status.textContent = text;
$status.hidden = false;
table.el.hidden = true;
pagination.setDisabled(true);
}
function enable() {
function enableSearch() {
$q.disabled = false;
$q.removeAttribute('aria-disabled');
$q.focus();
}
function runSearchImmediate() {
const term = state.query.trim();
if (!term) {
table.clear();
setStatus('Type to search…');
return;
}
$status.textContent = 'Searching…';
$status.hidden = false;
table.el.hidden = true;
pagination.setDisabled(true);
const offset = (state.page - 1) * state.pageSize;
let total = 0;
let rows = [];
try {
const countStmt = db.prepare(countSql);
countStmt.bind([term]);
if (countStmt.step()) {
const row = countStmt.getAsObject();
total = Number(row.count) || 0;
}
countStmt.free();
if (total === 0) {
table.clear();
$status.textContent = 'No matches found';
return;
}
if (offset >= total) {
state.page = Math.max(1, Math.ceil(total / state.pageSize));
}
const searchStmt = db.prepare(searchSql[state.sort] || searchSql.rank);
searchStmt.bind([term, state.pageSize, (state.page - 1) * state.pageSize]);
const nextRows = [];
while (searchStmt.step()) {
nextRows.push(searchStmt.getAsObject());
}
rows = nextRows;
searchStmt.free();
} catch (err) {
console.error(err);
$status.textContent = 'Search failed. Check console for details.';
return;
}
table.setRows(rows);
$status.hidden = true;
table.el.hidden = false;
pagination.setState({
page: state.page,
pageSize: state.pageSize,
total,
resultsCount: rows.length,
});
}
const runSearch = debounce(runSearchImmediate, 250);
$form.addEventListener('submit', (e) => e.preventDefault());
$q.addEventListener('input', () => {
const q = $q.value.trim();
renderStub(q);
// Future: db.prepare / db.exec queries
state.query = $q.value;
state.page = 1;
runSearch();
});
renderStub('');
setStatus('Type to search…');
return {
kind: 'base',
el,
onShow() { enable(); },
onShow() {
enableSearch();
if (state.query.trim()) runSearchImmediate();
},
destroy() {},
};
}
@ -335,4 +741,3 @@
.replace(/'/g, '&#039;');
}
})();