Add reusable table and pagination helpers
This commit is contained in:
parent
2ee403ddfb
commit
ad543d67b7
2 changed files with 421 additions and 16 deletions
|
@ -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
431
script.js
|
@ -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, ''');
|
||||
}
|
||||
})();
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue