Datatabel met bediening New
Invoice table with search, status filter, sortable columns (aria-sort), row selection with checkboxes (including indeterminate), a bulk action bar, pagination and an empty state with one way out. On narrow screens rows become cards through a container query; without container query support the table stays horizontally scrollable. In production you map the filters to your API parameters.
0 gekozen
| Status |
|---|
How to use it
- Paste the HTML where the numbers & charts should appear.
- Add the CSS to your stylesheet. All classes are prefixed, so nothing clashes with your own styles.
- Add the JavaScript and call init() with the element that contains the HTML.
<!-- Datatabel met bediening · NedDev · eigen werk. -->
<div class="jouw-container">
<div class="dtb">
<div class="dtb__tools">
<div class="dtb__std">
<label class="dtb__search"><span class="dtb__sr">Zoek op klant, plaats of nummer</span><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4.5 4.5"/></svg><input type="search" placeholder="Zoek klant of nummer"></label>
<label class="dtb__sel"><span class="dtb__sr">Status</span><select class="dtb__status"><option value="">Alle statussen</option><option>Open</option><option>Te laat</option><option>Betaald</option></select></label>
<label class="dtb__sel dtb__sortsel"><span class="dtb__sr">Sorteren</span><select><option value="due:asc">Vervaldatum, eerst</option><option value="due:desc">Vervaldatum, laatst</option><option value="amt:desc">Bedrag, hoog-laag</option><option value="amt:asc">Bedrag, laag-hoog</option><option value="name:asc">Klant, A-Z</option></select></label>
</div>
<div class="dtb__bulk" hidden>
<span class="dtb__count">0 gekozen</span>
<button type="button" class="dtb__btn" data-act="paid" aria-label="Markeer als betaald">Betaald</button>
<button type="button" class="dtb__btn" data-act="remind" aria-label="Herinnering sturen">Herinnering</button>
<button type="button" class="dtb__link" data-act="none" aria-label="Selectie wissen">Wissen</button>
</div>
</div>
<div class="dtb__wrap">
<table class="dtb__t">
<caption class="dtb__sr">Facturen, sorteerbaar per kolom</caption>
<thead><tr>
<th class="dtb__ck" scope="col"><label class="dtb__cb"><input type="checkbox" class="dtb__all" aria-label="Alle facturen op deze pagina selecteren"><span></span></label></th>
<th scope="col" aria-sort="none"><button type="button" data-k="name">Klant<i aria-hidden="true"></i></button></th>
<th scope="col" class="dtb__num" aria-sort="none"><button type="button" data-k="amt">Bedrag<i aria-hidden="true"></i></button></th>
<th scope="col">Status</th>
<th scope="col" aria-sort="ascending"><button type="button" data-k="due">Vervalt<i aria-hidden="true"></i></button></th>
</tr></thead>
<tbody></tbody>
</table>
</div>
<div class="dtb__pagin">
<span class="dtb__range" aria-live="polite"></span>
<button type="button" class="dtb__pg" data-d="-1" aria-label="Vorige pagina"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m14 6-6 6 6 6"/></svg></button>
<button type="button" class="dtb__pg" data-d="1" aria-label="Volgende pagina"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m10 6 6 6-6 6"/></svg></button>
</div>
<p class="dtb__sr dtb__live" aria-live="polite"></p>
</div>
</div>/* Datatabel met bediening · NedDev · eigen werk. */
.dtb{--ink:#0a0a0a;--pop:#F5480F;--pd:#b83a0b;--mut:#5b5b5b;--line:rgba(10,10,10,.12);--soft:#f4f3f1;position:absolute;inset:0;display:flex;flex-direction:column;gap:8px;padding:10px 12px;font-family:'Switzer',system-ui,sans-serif;font-size:12.5px;line-height:1.35;color:var(--ink);background:#fff;container:dtb/inline-size;text-align:left}
.dtb *,.dtb *::before,.dtb *::after{box-sizing:border-box}
.dtb svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}
.dtb__sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
.dtb__tools{min-height:30px}
.dtb__std,.dtb__bulk{display:flex;align-items:center;gap:6px;height:30px}
.dtb__std[hidden],.dtb__bulk[hidden]{display:none}
.dtb__search{flex:1;min-width:0;display:flex;align-items:center;gap:6px;height:30px;padding:0 8px;border:1px solid var(--line);border-radius:4px;color:var(--mut);transition:border-color .15s}
.dtb__search:hover{border-color:rgba(10,10,10,.35)}
.dtb__search:focus-within{border-color:var(--ink);outline:2px solid var(--pop);outline-offset:1px}
.dtb__search input{flex:1;min-width:0;height:100%;border:0;background:none;font:500 12.5px/1 'Switzer',system-ui,sans-serif;color:var(--ink)}
.dtb__search input:focus{outline:none}
.dtb__sel select{height:30px;max-width:130px;padding:0 6px;border:1px solid var(--line);border-radius:4px;background:#fff;font:500 12px/1 'Switzer',system-ui,sans-serif;color:var(--ink);cursor:pointer}
.dtb__sel select:focus-visible{outline:2px solid var(--pop);outline-offset:1px}
.dtb__sortsel{display:none}
.dtb__bulk{padding:0 4px 0 10px;background:var(--ink);color:#fff;border-radius:4px}
.dtb__count{font-weight:600;margin-right:auto;white-space:nowrap}
.dtb__btn{height:24px;padding:0 8px;border:1px solid rgba(255,255,255,.5);border-radius:4px;background:none;color:#fff;font:500 11.5px/1 'Switzer',system-ui,sans-serif;cursor:pointer;white-space:nowrap;transition:background-color .15s,color .15s}
.dtb__btn:hover{background:#fff;color:var(--ink)}
.dtb__btn[aria-disabled="true"]{opacity:.45;cursor:not-allowed}
.dtb__btn[aria-disabled="true"]:hover{background:none;color:#fff}
.dtb__link{min-height:24px;border:0;background:none;color:#fff;font:500 11.5px/1 'Switzer',system-ui,sans-serif;text-decoration:underline;text-underline-offset:2px;cursor:pointer;padding:4px}
.dtb__bulk button:focus-visible{outline:2px solid var(--pop);outline-offset:1px}
.dtb__wrap{flex:1;min-height:0;overflow:auto;border-top:1px solid var(--ink);scrollbar-width:thin}
.dtb__t{width:100%;border-collapse:collapse;table-layout:fixed}
.dtb__t th{position:sticky;top:0;z-index:1;background:#fff;text-align:left;font-weight:600;font-size:11.5px;color:var(--mut);padding:2px 6px;border-bottom:1px solid var(--line);white-space:nowrap}
.dtb__t th:nth-child(1){width:30px}.dtb__t th:nth-child(3){width:78px}.dtb__t th:nth-child(4){width:70px}.dtb__t th:nth-child(5){width:66px}
.dtb__t th button{display:inline-flex;align-items:center;gap:4px;min-height:24px;padding:0;border:0;background:none;font:inherit;color:inherit;cursor:pointer}
.dtb__t th button:hover{color:var(--ink)}
.dtb__t th button:focus-visible{outline:2px solid var(--pop);outline-offset:2px}
.dtb__t th i{width:7px;height:9px;opacity:.35;background:linear-gradient(currentColor,currentColor) center top/7px 3px no-repeat,linear-gradient(currentColor,currentColor) center bottom/7px 3px no-repeat;clip-path:polygon(50% 0,100% 33%,0 33%,0 67%,100% 67%,50% 100%)}
.dtb__t th[aria-sort="ascending"],.dtb__t th[aria-sort="descending"]{color:var(--ink)}
.dtb__t th[aria-sort="ascending"] i{opacity:1;clip-path:polygon(50% 0,100% 40%,0 40%)}
.dtb__t th[aria-sort="descending"] i{opacity:1;clip-path:polygon(0 60%,100% 60%,50% 100%)}
.dtb__num,.dtb__t td.dtb__num{text-align:right}
.dtb__t th.dtb__num button{flex-direction:row-reverse}
.dtb__t td{padding:6px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dtb__t td:first-child{overflow:visible;text-overflow:clip}
.dtb__t tbody tr{transition:background-color .15s}
.dtb__t tbody tr:hover{background:#faf9f8}
.dtb__t tbody tr.is-sel{background:#fdeee8}
.dtb__cb{position:relative;display:inline-grid;place-items:center;width:24px;height:24px;vertical-align:middle;margin-left:-5px}
.dtb__cb input{position:absolute;inset:0;width:24px;height:24px;margin:0;opacity:0;cursor:pointer}
.dtb__cb span{width:15px;height:15px;border:1.5px solid #6b6b6b;border-radius:4px;background:#fff;pointer-events:none;transition:background-color .15s,border-color .15s}
.dtb__cb input:hover + span{border-color:var(--ink)}
.dtb__cb input:checked + span{background:var(--ink) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='m4 8 3 3 5-6' fill='none' stroke='%23fff' stroke-width='2'/%3E%3C/svg%3E") center/12px no-repeat;border-color:var(--ink)}
.dtb__cb input:indeterminate + span{background:var(--ink) linear-gradient(#fff,#fff) center/8px 2px no-repeat;border-color:var(--ink)}
.dtb__cb input:focus-visible + span{outline:2px solid var(--pop);outline-offset:2px}
.dtb__k{display:block;font-weight:600;overflow:hidden;text-overflow:ellipsis}
.dtb__n{display:block;font-size:11px;color:var(--mut)}
.dtb__amt{font-variant-numeric:tabular-nums;font-weight:500}
.dtb__st{display:inline-flex;align-items:center;gap:5px}
.dtb__st::before{content:'';width:7px;height:7px;border-radius:4px;background:#b5b2ad}
.dtb__st--open::before{background:var(--ink)}
.dtb__st--late{color:var(--pd);font-weight:600}
.dtb__st--late::before{background:var(--pop)}
.dtb__st--paid{color:var(--mut)}
.dtb__empty td{white-space:normal;text-align:left;padding:22px 6px;color:var(--mut)}
.dtb__empty strong{display:block;color:var(--ink);margin-bottom:6px}
.dtb__empty button{padding:6px 10px;border:1px solid var(--ink);border-radius:4px;background:#fff;font:500 12px/1 'Switzer',system-ui,sans-serif;cursor:pointer}
.dtb__empty button:hover{background:var(--ink);color:#fff}
.dtb__pagin{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--mut)}
.dtb__range{margin-right:auto;font-variant-numeric:tabular-nums}
.dtb__pg{display:grid;place-items:center;width:26px;height:24px;border:1px solid var(--line);border-radius:4px;background:#fff;color:var(--ink);cursor:pointer;transition:border-color .15s}
.dtb__pg:hover:not(:disabled){border-color:var(--ink)}
.dtb__pg:disabled{opacity:.35;cursor:not-allowed}
.dtb__pg:focus-visible{outline:2px solid var(--pop);outline-offset:1px}
@container dtb (max-width:340px){
.dtb__std .dtb__sel:not(.dtb__sortsel){display:none}
.dtb__sortsel{display:block}
.dtb__sortsel select{max-width:118px}
.dtb__wrap{border-top:0}
.dtb__t,.dtb__t tbody{display:block}
.dtb__t thead{display:none}
.dtb__t tbody tr{display:grid;grid-template-columns:22px 1fr auto;column-gap:8px;row-gap:2px;padding:8px;margin-bottom:6px;border:1px solid var(--line);border-radius:4px}
.dtb__t td{display:block;padding:0;border:0}
.dtb__t td:nth-child(1){grid-row:1/3}
.dtb__t td:nth-child(3){grid-column:3;grid-row:1}
.dtb__t td:nth-child(4){grid-column:2;grid-row:2}
.dtb__t td:nth-child(5){grid-column:3;grid-row:2;text-align:right;color:var(--mut);font-size:11.5px}
.dtb__t td:nth-child(5)::before{content:'vervalt '}
.dtb__t tr.dtb__empty{display:block;border:0}
}
@media (prefers-reduced-motion:reduce){.dtb *{transition:none!important}}// Datatabel met bediening · NedDev · eigen werk.
// root = het element waarin de HTML staat
function init(root){
const w = root.querySelector('.dtb'); if (!w) return;
const tb = w.querySelector('tbody'), all = w.querySelector('.dtb__all');
const q = w.querySelector('.dtb__search input'), st = w.querySelector('.dtb__status'), sortSel = w.querySelector('.dtb__sortsel select');
const std = w.querySelector('.dtb__std'), bulk = w.querySelector('.dtb__bulk'), count = w.querySelector('.dtb__count');
const range = w.querySelector('.dtb__range'), live = w.querySelector('.dtb__live');
const eur = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR' });
const dag = new Intl.DateTimeFormat('nl-NL', { day: 'numeric', month: 'short' });
const base = new Date(2026, 9, 8);
const raw = [
['Bakkerij De Korenaar', 'Gouda', 1240, 'Betaald', -24], ['Fysio Westland', 'Naaldwijk', 685.5, 'Open', 6],
['Kapsalon Mooi', 'Delft', 312.95, 'Te laat', -9], ['Garage Van Leeuwen', 'Zoetermeer', 2890, 'Open', 13],
['Huisartsenpraktijk Laak', 'Den Haag', 960, 'Betaald', -16], ['Café De Zwaan', 'Leiden', 448.4, 'Te laat', -3],
['Bloemenhuis Iris', 'Rijswijk', 199, 'Open', 20], ['Schildersbedrijf Kok', 'Wassenaar', 3415.75, 'Open', 2],
['Dierenkliniek Duinzicht', 'Katwijk', 1120, 'Betaald', -30], ['Rijschool Vlot', 'Alphen a/d Rijn', 575, 'Te laat', -12],
['Kinderopvang Klimop', 'Voorburg', 1980, 'Open', 9], ['Fietsenmaker Spaak', 'Leidschendam', 264.1, 'Betaald', -5],
['Hoveniersbedrijf Groen', 'Pijnacker', 1655, 'Open', 27], ['Tandartspraktijk Ypenburg', 'Den Haag', 742.6, 'Te laat', -20]
];
const rows = raw.map((r, i) => ({ id: 'F2026-' + (412 + i), name: r[0], city: r[1], amt: r[2], status: r[3], due: new Date(base.getTime() + r[4] * 864e5) }));
const sel = new Set();
let sort = { k: 'due', dir: 1 }, page = 0;
const PER = 5;
const say = t => { live.textContent = ''; setTimeout(() => { live.textContent = t; }, 30); };
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
function filtered() {
const t = q.value.trim().toLowerCase();
return rows.filter(r => (!st.value || r.status === st.value) && (!t || (r.name + ' ' + r.city + ' ' + r.id).toLowerCase().includes(t)))
.sort((a, b) => { const x = a[sort.k], y = b[sort.k]; return (sort.k === 'name' ? x.localeCompare(y, 'nl') : x - y) * sort.dir; });
}
function render() {
const list = filtered();
const pages = Math.max(1, Math.ceil(list.length / PER));
if (page >= pages) page = pages - 1;
const vis = list.slice(page * PER, page * PER + PER);
if (!list.length) {
tb.innerHTML = '<tr class="dtb__empty"><td colspan="5"><strong>Geen facturen gevonden</strong>Niets past bij “' + esc(q.value.trim() || st.value) + '”. <button type="button" class="dtb__reset">Filters wissen</button></td></tr>';
} else {
tb.innerHTML = vis.map(r => {
const c = r.status === 'Betaald' ? 'paid' : r.status === 'Open' ? 'open' : 'late';
const on = sel.has(r.id);
return '<tr class="' + (on ? 'is-sel' : '') + '" data-id="' + r.id + '"><td><label class="dtb__cb"><input type="checkbox" ' + (on ? 'checked ' : '') + 'aria-label="Selecteer ' + esc(r.name) + ', ' + r.id + '"><span></span></label></td>' +
'<td><span class="dtb__k">' + esc(r.name) + '</span><span class="dtb__n">' + r.id + ' · ' + esc(r.city) + '</span></td>' +
'<td class="dtb__num dtb__amt">' + eur.format(r.amt) + '</td><td><span class="dtb__st dtb__st--' + c + '">' + r.status + '</span></td>' +
'<td>' + dag.format(r.due) + '</td></tr>';
}).join('');
}
const from = list.length ? page * PER + 1 : 0;
range.textContent = from + '-' + Math.min(list.length, page * PER + PER) + ' van ' + list.length;
w.querySelector('[data-d="-1"]').disabled = page === 0;
w.querySelector('[data-d="1"]').disabled = page >= pages - 1;
const visSel = vis.filter(r => sel.has(r.id)).length;
all.checked = vis.length > 0 && visSel === vis.length;
all.indeterminate = visSel > 0 && visSel < vis.length;
all.disabled = !vis.length;
w.querySelectorAll('th[aria-sort]').forEach(th => {
const k = th.querySelector('button').dataset.k;
th.setAttribute('aria-sort', k === sort.k ? (sort.dir > 0 ? 'ascending' : 'descending') : 'none');
});
const n = sel.size;
bulk.hidden = !n; std.hidden = !!n;
count.textContent = n + ' gekozen';
const open = [...sel].some(id => rows.find(r => r.id === id).status !== 'Betaald');
bulk.querySelectorAll('.dtb__btn').forEach(b => b.setAttribute('aria-disabled', String(!open)));
}
w.querySelectorAll('th button').forEach(b => b.addEventListener('click', () => {
const k = b.dataset.k;
sort = { k, dir: sort.k === k ? -sort.dir : (k === 'amt' ? -1 : 1) };
sortSel.value = k + ':' + (sort.dir > 0 ? 'asc' : 'desc');
render();
say('Gesorteerd op ' + b.textContent.trim() + ', ' + (sort.dir > 0 ? 'oplopend' : 'aflopend'));
}));
sortSel.addEventListener('change', () => { const p = sortSel.value.split(':'); sort = { k: p[0], dir: p[1] === 'asc' ? 1 : -1 }; render(); });
q.addEventListener('input', () => { page = 0; render(); const n = filtered().length; say(n ? n + ' facturen gevonden' : 'Geen facturen gevonden'); });
st.addEventListener('change', () => { page = 0; render(); say(filtered().length + ' facturen'); });
w.querySelectorAll('.dtb__pg').forEach(b => b.addEventListener('click', () => { page += +b.dataset.d; render(); }));
tb.addEventListener('change', e => {
const tr = e.target.closest('tr'); if (!tr || !tr.dataset.id) return;
e.target.checked ? sel.add(tr.dataset.id) : sel.delete(tr.dataset.id);
const wasBulk = !bulk.hidden;
render();
const cb = tb.querySelector('tr[data-id="' + tr.dataset.id + '"] input'); if (cb) cb.focus();
if (!wasBulk && sel.size) say('Selectiebalk: ' + sel.size + ' geselecteerd');
});
tb.addEventListener('click', e => {
if (e.target.closest('.dtb__reset')) { q.value = ''; st.value = ''; page = 0; render(); q.focus(); say('Filters gewist, 14 facturen'); return; }
const tr = e.target.closest('tr[data-id]');
if (tr && !e.target.closest('input,label')) { const cb = tr.querySelector('input'); cb.checked = !cb.checked; cb.dispatchEvent(new Event('change', { bubbles: true })); }
});
all.addEventListener('change', () => {
const list = filtered().slice(page * PER, page * PER + PER);
list.forEach(r => all.checked ? sel.add(r.id) : sel.delete(r.id));
render(); all.focus();
});
bulk.addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
if (b.getAttribute('aria-disabled') === 'true') { say('Alle geselecteerde facturen zijn al betaald'); return; }
const ids = [...sel];
if (b.dataset.act === 'none') { sel.clear(); render(); q.focus(); say('Selectie gewist'); return; }
const label = b.textContent;
b.textContent = 'Bezig...'; b.setAttribute('aria-busy', 'true');
setTimeout(() => {
b.textContent = label; b.removeAttribute('aria-busy');
if (b.dataset.act === 'paid') { ids.forEach(id => { rows.find(r => r.id === id).status = 'Betaald'; }); say(ids.length + ' facturen gemarkeerd als betaald'); }
else { const n = ids.filter(id => rows.find(r => r.id === id).status !== 'Betaald').length; say('Herinnering verstuurd voor ' + n + (n === 1 ? ' factuur' : ' facturen')); }
sel.clear(); render(); q.focus();
}, 600);
});
render();
}
init(document.querySelector('.jouw-container'));
More numbers & charts
Seen something you like?
Add it to your selection and send it over. We build your site around it, in your brand.
Get in touch →