Sign in to the rent roll
Double-click a row to edit it, then press Enter or use Save.
``` ```js var editing = -1; var grid = Kob.datagrid(root.querySelector('.ex-grid'), { columns: [ { field: 'room', title: 'Room', width: 90 }, { field: 'tenant', title: 'Tenant', width: 160, editor: 'text' }, { field: 'rent', title: 'Rent', width: 120, align: 'right', formatter: KOB_FMT.money, editor: { type: 'numberbox', options: { precision: 2 } } } ], data: KOB_SAMPLE.rows(12), pagination: false, onDblClickRow: function (index) { if (editing >= 0) { grid.endEdit(editing); } editing = index; grid.beginEdit(index); }, onEndEdit: function (index, row) { editing = -1; Kob.toast({ msg: 'Saved ' + row.room + ' — ' + KOB_FMT.money(row.rent), type: 'success' }); } }); root.addEventListener('keydown', function (e) { if (e.key === 'Enter' && editing >= 0) { grid.endEdit(editing); } if (e.key === 'Escape' && editing >= 0) { grid.cancelEdit(); editing = -1; } }); ``` **Example — Loading from an API** ```html ``` ```js // Kob.config.request is the single place the library talks to a // server. Replace it once and every widget follows — headers, an // envelope to unwrap, a redirect on 401. // // Kob.config.request = function (url, params) { // return fetch(url + new URLSearchParams(params || {}), { // headers: { Authorization: 'Bearer ' + token } // }).then(function (res) { // if (res.status === 401) { location.href = '/login'; return []; } // return res.json(); // }).then(function (payload) { // if (!payload.success) { throw new Error(payload.message); } // return payload.data; // an array, or { rows, total } // }); // }; // Stubbed here so the page needs no backend of its own. Kob.config.request = function () { return new Promise(function (resolve) { setTimeout(function () { resolve({ rows: KOB_SAMPLE.rows(60) }); }, 700); }); }; Kob.datagrid(root.querySelector('.ex-grid'), { columns: [ { field: 'room', title: 'Room', width: 90 }, { field: 'tenant', title: 'Tenant', width: 170 }, { field: 'rent', title: 'Rent', width: 110, align: 'right', formatter: KOB_FMT.money } ], url: '/api/invoices', queryParams: { year: 2026 }, pageSize: 20, onLoadSuccess: function () { Kob.toast({ msg: 'Loaded', type: 'info' }); } }); ``` **Example — Picking rows: checkboxes, Ctrl, Shift — and draggable columns** ```htmlClick a row, or its checkbox. Ctrl (Cmd) adds one, Shift takes the run from the last row you touched, and the box in the header ticks the whole page. Drag the edge of any header cell to resize that column.
``` ```js var rows = KOB_SAMPLE.rows(60); // checkbox:true turns multi-select on by itself — a column of // checkboxes that only ever holds one tick is a radio button in the // wrong clothes. var grid = Kob.datagrid(root.querySelector('#ds-grid'), { columns: [ { field: 'room', title: 'Room', width: 90 }, { field: 'tenant', title: 'Tenant', width: 170 }, { field: 'building', title: 'Building', width: 130 }, { field: 'rent', title: 'Rent', width: 110, align: 'right', formatter: KOB_FMT.money }, { field: 'status', title: 'Status', width: 110, resizable: false, formatter: function (v) { return KOB_FMT.badge(v, v === 'paid' ? 'success' : v === 'overdue' ? 'danger' : 'warn'); } } ], data: rows, checkbox: true, pageSize: 8, filterable: false, fit: true, // One event for every way the selection can change — a click, a // checkbox, Shift over a run, or the header box. onSelectionChange: function (picked) { show(picked); } }); var out = root.querySelector('#ds-count'); function show(picked) { if (!picked.length) { out.textContent = 'Nothing selected'; return; } var total = picked.reduce(function (n, r) { return n + r.rent; }, 0); out.innerHTML = '' + picked.length + ' selected · ' + KOB_FMT.money(total); } show([]); // The selection is kept as rows, not as highlightedNo actions column. Move the pointer over a row — or click one and use the keyboard. Double-clicking a row edits it too.
nothing yet
``` ```js var rows = KOB_SAMPLE.rows(40); var out = root.querySelector('#re-out'); function edit(row) { Kob.prompt({ title: 'Rent for ' + row.room, msg: 'New amount', value: row.rent }) .then(function (value) { if (value === null) { return; } row.rent = Number(value) || row.rent; grid.loadData(rows); out.textContent = row.room + ' set to ' + KOB_FMT.money(row.rent); }); } function remove(row) { Kob.sweet({ type: 'question', title: 'Delete ' + row.room + '?', msg: row.tenant + ' would lose the room.', showCancel: true, confirmText: 'Delete', danger: true }).then(function (yes) { if (!yes) { return; } rows.splice(rows.indexOf(row), 1); grid.loadData(rows); out.textContent = row.room + ' deleted'; }); } var grid = Kob.datagrid(root.querySelector('#re-grid'), { columns: [ { field: 'room', title: 'Room', width: 90 }, { field: 'tenant', title: 'Tenant', width: 150 }, { field: 'building', title: 'Building', width: 130 }, { field: 'rent', title: 'Rent', width: 110, align: 'right', formatter: KOB_FMT.money } ], data: rows, filterable: false, pageSize: 20, // One strip, moved to whichever row wants it. Nothing is added to the // columns, so the table is as wide as its data and no wider. rowActions: [ { text: 'Edit', iconCls: 'icon-edit', handler: edit }, { text: 'Delete', iconCls: 'icon-remove', color: 'danger', // A paid room is not deletable — the button says so rather than // letting the click through and failing afterwards. disabled: function (row) { return row.status === 'paid'; }, handler: remove } ], onDblClickRow: function (i, row) { edit(row); } }); ``` ## Forms Labelled inputs, a value/text combobox, a calendar that speaks two eras, a debounced search field, a file drop zone, and one call to read or validate the lot. **Example — Text, number and password** ```html ``` ```js var rent = Kob.numberbox(root.querySelector('.kob-numberbox')); rent.setValue(12500); // shown as 12,500.00, read back as a number root.querySelector('#ex-read').onclick = function () { Kob.alert({ title: 'Values', msg: 'tenant: ' + Kob.textbox(root.querySelector('.kob-textbox')).getValue() + '\nrent: ' + rent.getValue() + ' (' + typeof rent.getValue() + ')' }); }; ``` **Example — Combobox** ```htmlnothing picked yet
values appear here``` ```js Kob.combobox(root.querySelector('#ex-form-room'), { data: KOB_SAMPLE.rows(12).map(function (r) { return { id: r.room, text: r.room }; }) }); var form = Kob.form(root.querySelector('#ex-form')); var out = root.querySelector('#ex-form-out'); // validate() marks every offender and focuses the first one. root.querySelector('#ex-form-save').onclick = function () { out.textContent = form.validate() ? JSON.stringify(form.getValues(), null, 1) : 'Some required fields are still empty.'; }; root.querySelector('#ex-form-fill').onclick = function () { form.setValues({ tenant: 'Somchai P.', room: '104/5', rent: 8500, moveIn: '2026-04-01', active: true }); }; root.querySelector('#ex-form-clear').onclick = function () { form.clear(); out.textContent = 'cleared'; }; ``` **Example — Search box** ```html
/api/upload, which is not there — so you get the progress bar and then the error state, both genuine.Fold me away with the caret.
Content arrived, and the button below was ' + 'parsed out of it.
' + 'Refresh' ); ``` **Example — Tabs** ```htmltitle becomes a page.Progress, with a line of detail under each step.
Small, dotted markers — a status rail rather than a wizard.
' + 'Regions are plain elements — put whatever you like inside.
'); ``` **Example — Dialogs and windows** ```html ``` ```js var form = Kob.form(root.querySelector('#ex-modal-form')); var modal = Kob.dialog(root.querySelector('#ex-modal'), { title: 'Edit tenant', width: 420, buttons: [ { text: 'Cancel', iconCls: 'icon-cancel', handler: function (d) { d.close(); } }, { text: 'Save', iconCls: 'icon-save', primary: true, handler: function (d) { if (!form.validate()) { return; } Kob.toast({ msg: 'Saved ' + form.getValues().tenant, type: 'success' }); d.close(); } } ] }); // modal:false turns the dialog into an application window: draggable, // resizable, with minimise and maximise buttons. var win = Kob.dialog(root.querySelector('#ex-window'), { title: 'Invoices', width: 560, height: 330, modal: false }); Kob.datagrid(root.querySelector('#ex-window-grid'), { columns: [ { field: 'room', title: 'Room', width: 90 }, { field: 'tenant', title: 'Tenant', width: 150 }, { field: 'rent', title: 'Rent', width: 110, align: 'right', formatter: KOB_FMT.money } ], data: KOB_SAMPLE.rows(30), pagination: false, filterable: false }); root.querySelector('#ex-open-modal').onclick = function () { modal.open(); }; root.querySelector('#ex-open-window').onclick = function () { win.open(); }; ``` **Example — Buttons and icons** ```html ``` ```js // Markup with a kob-* class is built by Kob.parse(). Reach an // existing widget again by asking for it with no options. Kob.linkbutton(root.querySelector('#ex-toggle'), { toggle: true, onClick: function () { Kob.toast('Filter ' + (Kob.linkbutton(this).isSelected() ? 'on' : 'off')); } }); ``` **Example — Button colours** ```htmlSolid — for the one action a screen is about.
Soft — the same meanings, for a toolbar that has several of them.
Built from JavaScript, and a plain <button class="kob-btn">.
the result of the last one shows here``` ```js var out = root.querySelector('#ex-m-out'); // Each helper returns a promise, so it reads well with await too. root.querySelector('#ex-m-alert').onclick = function () { Kob.alert({ title: 'Heads up', msg: 'Invoice 2026-0042 has been issued.' }) .then(function (r) { out.textContent = 'alert resolved -> ' + r; }); }; root.querySelector('#ex-m-confirm').onclick = function () { Kob.confirm({ title: 'Delete', msg: 'Remove this tenant?' }) .then(function (yes) { out.textContent = 'confirm resolved -> ' + yes; }); }; root.querySelector('#ex-m-prompt').onclick = function () { Kob.prompt({ title: 'Rename', msg: 'New room number:', value: '101/2' }) .then(function (v) { out.textContent = 'prompt resolved -> ' + JSON.stringify(v); }); }; root.querySelector('#ex-m-ok').onclick = function () { Kob.toast({ msg: 'Saved successfully', type: 'success' }); }; root.querySelector('#ex-m-err').onclick = function () { Kob.toast({ msg: 'Could not reach the server', type: 'error' }); }; ``` **Example — Big confirmations** ```html ``` ```js var out = root.querySelector('#sw-out'); var COPY = { success: ['Saved', 'Invoice 2026-0042 has been issued.'], error: ['Could not save', 'The server refused the invoice number.'], warn: ['Check the dates', 'The move-out date is before the move-in date.'], info: ['Scheduled', 'Rent reminders go out at 09:00 tomorrow.'] }; root.querySelectorAll('[data-sweet]').forEach(function (btn) { btn.onclick = function () { var type = btn.getAttribute('data-sweet'); Kob.sweet[type](COPY[type][0], COPY[type][1]); }; }); // The destructive one asks, and says so in the button rather than // making the reader work out which of OK / Cancel deletes. root.querySelector('#sw-delete').onclick = function () { Kob.sweet({ type: 'question', title: 'Delete Tenant 12?', msg: 'Room 112/3 becomes vacant. This cannot be undone.', showCancel: true, confirmText: 'Delete', cancelText: 'Keep', danger: true }).then(function (yes) { out.textContent = yes ? 'deleted' : 'kept'; if (yes) { Kob.toast({ msg: 'Tenant 12 deleted', type: 'warn' }); } }); }; root.querySelector('#sw-timer').onclick = function () { Kob.sweet({ type: 'info', title: 'Closing in 2s', msg: 'timer: 2000', timer: 2000 }); }; ``` ## Navigation Menus and a menu bar, a ribbon, a tree — and the whole windowed shell they were built for. **Example — Desktop shell** ```html
Double-click an icon, or use Start. Windows drag, resize, minimise to the taskbar and maximise.
``` ```js var money = KOB_FMT.money; Kob.desktop(root.querySelector('#ex-desktop'), { user: { name: 'kob', onLogout: function () { Kob.toast('Logged out'); } }, startExtras: [ { text: 'Settings', iconCls: 'icon-settings' }, { text: 'Log out', iconCls: 'icon-back' } ], apps: [ { id: 'rooms', title: 'Rooms', iconCls: 'icon-home', width: 640, height: 380, // Build the window contents yourself when it opens. onOpen: function (win, body) { var grid = document.createElement('div'); grid.style.height = '100%'; body.appendChild(grid); Kob.datagrid(grid, { columns: [ { field: 'room', title: 'Room', width: 90 }, { field: 'tenant', title: 'Tenant', width: 160 }, { field: 'rent', title: 'Rent', width: 110, align: 'right', formatter: money } ], data: KOB_SAMPLE.rows(40), fit: true, filterable: false, pageSize: 20 }); } }, { id: 'invoices', title: 'Invoices', iconCls: 'icon-file', width: 520, height: 320, content: 'Any HTML works as a window body. Widgets in it are parsed.
' + 'click a command
``` ```js var out = root.querySelector('#ex-ribbon-out'); Kob.ribbon(root.querySelector('#ex-ribbon'), { tabs: [ { title: 'Home', groups: [ { title: 'Records', items: [ { text: 'New', iconCls: 'icon-add' }, { text: 'Edit', iconCls: 'icon-edit', size: 'small' }, { text: 'Delete', iconCls: 'icon-remove', size: 'small' }, { text: 'Copy', iconCls: 'icon-copy', size: 'small' } ] }, { title: 'Data', items: [ { text: 'Refresh', iconCls: 'icon-reload' }, // A button with `menu` opens one instead of firing. { text: 'Export', iconCls: 'icon-download', menu: [ { text: 'CSV', iconCls: 'icon-excel' }, { text: 'PDF', iconCls: 'icon-file' } ] } ] }, { title: 'Report', items: [{ text: 'Print', iconCls: 'icon-print' }] } ] }, { title: 'View', groups: [ { title: 'Layout', items: [ { text: 'Rows', iconCls: 'icon-menu' }, { text: 'Cards', iconCls: 'icon-folder' } ] } ] }, { title: 'Help', groups: [ { title: 'Support', items: [{ text: 'About', iconCls: 'icon-tip' }] } ] } ], // Clicking the tab that is already open folds the ribbon away. onClick: function (item) { out.textContent = 'command: ' + item.text; } }); ``` **Example — Menu bar** ```htmlnothing chosen yet
``` ```js var out = root.querySelector('#ex-mb-out'); Kob.menubar(root.querySelector('#ex-menubar'), { items: [ { text: 'File', items: [ { text: 'New tenant', iconCls: 'icon-add' }, { text: 'Open', iconCls: 'icon-folder' }, { separator: true }, { text: 'Print', iconCls: 'icon-print', shortcut: 'Ctrl+P' } ] }, { text: 'Edit', items: [ { text: 'Undo', iconCls: 'icon-undo', shortcut: 'Ctrl+Z' }, { text: 'Redo', iconCls: 'icon-redo', shortcut: 'Ctrl+Y' } ] }, { text: 'View', items: [ { text: 'Rows', checked: true }, { text: 'Cards' }, { separator: true }, { text: 'Refresh', iconCls: 'icon-reload' } ] }, { text: 'Help', items: [{ text: 'About', iconCls: 'icon-tip' }] } ], // Once one menu is open, moving along the bar switches menus. onClick: function (item, top) { out.textContent = top.text + ' \u2192 ' + item.text; } }); ``` **Example — Menus and context menus** ```htmlUse the ⋯ button on any row, or right-click the row — both open the same menu.
no action yet
``` ```js var rows = KOB_SAMPLE.rows(40); var out = root.querySelector('#rm-out'); var target = null; // One menu, reused for every row — building forty of them would be // forty times the DOM for a thing only ever seen once at a time. var menu = Kob.menu(document.createElement('div'), { items: [ { text: 'Edit', iconCls: 'icon-edit' }, { text: 'Duplicate', iconCls: 'icon-duplicate' }, { text: 'Print invoice', iconCls: 'icon-print' }, { separator: true }, { text: 'Mark as paid', iconCls: 'icon-check-circle' }, { text: 'Send reminder', iconCls: 'icon-mail' }, { separator: true }, { text: 'Delete', iconCls: 'icon-remove' } ], onClick: function (item) { if (!target) { return; } if (item.text === 'Delete') { return Kob.sweet({ type: 'question', title: 'Delete ' + target.room + '?', showCancel: true, confirmText: 'Delete', danger: true }).then(function (yes) { out.textContent = yes ? 'deleted ' + target.room : 'kept ' + target.room; }); } out.textContent = item.text + ' → ' + target.room + ' (' + target.tenant + ')'; } }); var grid = Kob.datagrid(root.querySelector('#rm-grid'), { columns: [ { field: 'room', title: 'Room', width: 80 }, { field: 'tenant', title: 'Tenant', width: 140 }, { field: 'rent', title: 'Rent', width: 100, align: 'right', formatter: KOB_FMT.money }, { field: 'id', title: '', width: 44, align: 'center', formatter: function () { return ''; } } ], data: rows, filterable: false, pageSize: 20, onClickRow: function (i, row) { target = row; } }); // The buttons are drawn by a formatter, so there is nothing to bind // to when the grid is built and nothing to rebind when it repages: // one delegated listener on the grid covers every row forever. root.querySelector('#rm-grid').addEventListener('click', function (e) { var button = e.target.closest('[data-row-menu]'); if (!button) { return; } e.stopPropagation(); var tr = button.closest('tr'); target = grid.getVisibleRows()[Array.prototype.indexOf.call(tr.parentNode.children, tr)]; menu.showFor(button); }); // Right-click anywhere on a row opens the same menu at the pointer. root.querySelector('#rm-grid').addEventListener('contextmenu', function (e) { var tr = e.target.closest('.kob-grid-body tbody tr'); if (!tr) { return; } e.preventDefault(); target = grid.getVisibleRows()[Array.prototype.indexOf.call(tr.parentNode.children, tr)]; menu.showAt(e.clientX, e.clientY); }); ``` **Example — Tree** ```htmlnothing ticked
Kob.loading() covers it and hands back the uncover function.
46 of 50 rooms occupied.
Collected rent across three buildings.