# kob-ui v0.2.3 — complete reference Generated from the documentation site by scripts/build-llms.mjs. This file is the whole of it: every option table, every method, every code sample, the icon list, and the TypeScript declarations. Nothing is abridged, so an assistant working from this file alone has what the website shows a reader. Site: https://kob-ui.com · Package: npm i kob-ui · Global: `Kob` Licence: free for commercial use, no fee and no attribution required. Modification, reverse engineering and republication are not permitted, and the shipped bundle is obfuscated on purpose. Write application code against the public API documented here; do not try to read or patch dist/. ## How to read the examples Each example is the markup and the script that produce it on the site. The site runs them with `root` bound to the element holding that example, which is why they say `root.querySelector(...)` rather than a page-wide id — in an application, use your own selector. `KOB_SAMPLE.rows(n)` and `KOB_FMT` are the sample-data helpers the site defines, not part of the library. # Start ## Installation kob-ui ships one JavaScript file and one stylesheet. There is nothing to install alongside it and no build step unless you want one. ```bash npm i kob-ui ``` ### Script tag ```html index.html ``` ### Bundler ```js app.js import Kob from 'kob-ui'; import 'kob-ui/css'; // Named imports work too, and tree-shake in an ESM build. import { datagrid, dialog, toast } from 'kob-ui'; ``` ### What is in the package | File | Format | Use it for | | --- | --- | --- | | dist/kob-ui.min.js | IIFE | A plain ` Angular import { Directive, ElementRef, Input, OnInit, OnDestroy } from '@angular/core'; import Kob from 'kob-ui'; @Directive({ selector: '[kobDatagrid]', standalone: true }) export class KobDatagridDirective implements OnInit, OnDestroy { @Input('kobDatagrid') options: any = {}; private grid: any; constructor(private host: ElementRef) {} ngOnInit() { this.grid = Kob.datagrid(this.host.nativeElement, this.options); } ngOnDestroy() { this.grid?.destroy(); } reload() { this.grid.reload(); } } //
Widget callbacks fire outside Angular's zone by default. If a change detection cycle needs to follow one, wrap it: this.zone.run(() => …). Things to watch SituationWhat to do Data changed Call grid.loadData(rows) from an effect or watcher. Do not rebuild the widget. Options changed Kob.datagrid(el, { pageSize: 100 }) merges and refreshes in place. A cell needs a framework component A formatter returns an HTML string, not JSX. For rich cells, render into the row element from onLoadSuccess, or keep the cell simple and open a dialog on click. Server rendering Widgets touch the DOM, so build them in a client-only lifecycle hook — useEffect, onMounted, ngOnInit in the browser. Strict mode double mount Handled: destroy() restores the element, so the second mount produces exactly one widget. ``` ## Custom widgets Extend `Kob.Widget` and register it. From then on it behaves like a built-in: `Kob.parse()` builds it from markup, `data-options` works, and the accessor returns the instance. **Example — Writing your own widget** ```html

``` ```js // Extend Kob.Widget, register it, and Kob.parse() will build it from // markup exactly like a built-in — data-options and all. class Rating extends Kob.Widget { static widgetName = 'rating'; static defaults = { max: 5, value: 0, onChange: null }; init() { this.render(); this.track(Kob.dom.on(this.el, 'click', '[data-n]', (e, star) => { this.setValue(Number(star.dataset.n)); })); } setValue(v) { this.options.value = v; this.render(); this.emit('onChange', v); return this; } getValue() { return this.options.value; } render() { var html = ''; for (var n = 1; n <= this.options.max; n++) { html += ''; } this.el.innerHTML = html; } } if (!Kob.registry.has('rating')) { Kob.rating = Kob.register(Rating); } var out = root.querySelector('#ex-rating-out'); var widget = Kob.rating(root.querySelector('.kob-rating'), { onChange: function (v) { out.textContent = 'rated ' + v + ' / 5'; } }); out.textContent = 'rated ' + widget.getValue() + ' / 5'; ``` | Member | Description | | --- | --- | | static widgetName | Drives the class name: `'rating'` → `class="kob-rating"`. | | static defaults | Merged under `data-options` and the constructor argument. | | this.el | The element the widget is mounted on. | | this.options | The resolved options. | | init() | Build the DOM. Called by the constructor. | | refresh() | Called when new options arrive after construction. | | track(off) | Register a cleanup function — pass the unbinder `Kob.dom.on` returns. | | emit(hook, ...args) | Fire an option callback with the element as `this`. | | destroy() | Runs every tracked cleanup. Extend it, then call `super.destroy()`. | ## Kob reference | Member | Description | | --- | --- | | Kob.version | The library version. | | Kob.parse(root) / unparse(root) / autoParse() | Build or tear down the widgets declared in markup. | | Kob.parseOptions(str) | Read the inline `data-options` syntax. | | Kob.instance(el, name) | The widget of that name on an element, if any. | | Kob.config | `request`, `onRequestError`, `prefix`, `zIndex`, `locale`. | | Kob.locale(name?) / addLocale(name, bundle) / locales / t(key, vars) | Localisation. | | Kob.zIndex() | Next z-index for a floating layer, shared by every widget. | | Kob.Widget / register(Class) / registry | Extension points. | | Kob.dom | The internal DOM helpers: `on`, `el`, `append`, `css`, `escapeHtml`, … | | Kob.date | `parseDate`, `formatDate`, `toISO`, `today`. | | Kob.classes | The widget classes themselves, for `extends`. | # Recipes — complete screens ## A complete screen A bar across the top, a menu down the left, a toolbar, a grid, and an editor dialog with validation — the shape most internal tools end up in. Around a hundred lines, no framework. **Example — A complete screen: navbar, sidebar, grid, editor dialog** ```html
RRentRoll
RentRollRent roll

Rent roll

Export New tenant
Connected Somchai P.
``` ```js var rows = KOB_SAMPLE.rows(84); var shell = root.querySelector('#ex-admin'); var current = null; /* ---- the grid the whole screen is about ---- */ var grid = Kob.datagrid(root.querySelector('#ad-grid'), { columns: [ { field: 'room', title: 'Room', width: 90 }, { field: 'tenant', title: 'Tenant', width: 160 }, { field: 'building', title: 'Building', width: 120 }, { field: 'rent', title: 'Rent', width: 110, align: 'right', formatter: KOB_FMT.money }, { field: 'due', title: 'Due', width: 105 }, { field: 'status', title: 'Status', width: 105, formatter: function (v) { return KOB_FMT.badge(v, v === 'paid' ? 'success' : v === 'overdue' ? 'danger' : 'warn'); } } ], data: rows, fit: true, rownumbers: true, pageSize: 20, onClickRow: function (i, row) { current = row; }, onDblClickRow: function () { edit(); } }); /* ---- the bar across the top ---- */ // Collapsing the menu to a rail is one class on the shell; the // stylesheet drops the labels and narrows the column. root.querySelector('#ad-rail').onclick = function () { shell.classList.toggle('kob-sidebar-collapsed'); }; Kob.searchbox(root.querySelector('#ad-find'), { delay: 200, onSearch: function (q) { grid.search(q); showCount(); } }); // A menu hanging off a button in the bar. The button stays lit for as // long as its menu is up, so a click always shows where it landed — // and clicking again puts it away. function menuButton(sel, items) { var button = root.querySelector(sel); var menu = Kob.menu(document.createElement('div'), { items: items, onClick: function (item) { Kob.toast(item.text); }, onShow: function () { button.classList.add('active'); }, onHide: function () { button.classList.remove('active'); } }); button.onclick = function () { if (menu.isOpen()) { menu.hide(); } else { menu.showFor(button); } }; } menuButton('#ad-bell', [ { text: '3 invoices went overdue today', iconCls: 'icon-warn' }, { text: 'Tenant 12 paid 8,250.00', iconCls: 'icon-banknote' }, { text: 'Tenant 85 moved into 118/2', iconCls: 'icon-user-plus' }, { separator: true }, { text: 'Mark all as read', iconCls: 'icon-check-circle' } ]); menuButton('#ad-account', [ { text: 'Profile', iconCls: 'icon-user' }, { text: 'Preferences', iconCls: 'icon-sliders' }, { separator: true }, { text: 'Sign out', iconCls: 'icon-logout' } ]); root.querySelector('#ad-help').onclick = function () { Kob.sweet.info('Rent roll', 'Pick a building down the side, then edit a room — or double-click one.'); }; root.querySelector('#ad-export').onclick = function () { Kob.toast('Exporting the rent roll'); }; /* ---- the menu down the left ---- */ // Routing is the application's business — the shell only marks which // entry is current and repaints the heading above the content. var pages = root.querySelectorAll('.kob-sidebar-item[data-page]'); pages.forEach(function (link) { link.onclick = function () { var name = link.getAttribute('data-page'); if (name === 'Sign out') { Kob.toast('Signed out'); return; } pages.forEach(function (other) { other.classList.remove('active'); }); link.classList.add('active'); root.querySelector('#ad-title').textContent = name; root.querySelector('#ad-crumb').textContent = name; }; }); // The building list is part of the same menu, but it drives the grid's // own filter API rather than the heading. var scope = root.querySelector('#ad-scope'); var list = root.querySelector('#ad-buildings'); ['All buildings'].concat(KOB_SAMPLE.buildings).forEach(function (name, i) { var a = document.createElement('a'); a.className = 'kob-sidebar-item' + (i === 0 ? ' active' : ''); a.title = name; a.innerHTML = '' + '' + name + ''; a.onclick = function () { list.querySelectorAll('a').forEach(function (n) { n.classList.remove('active'); }); a.classList.add('active'); if (i === 0) { grid.clearFilters(); } else { grid.setFilter('building', { in: [name.toLowerCase()] }); } scope.textContent = name; showCount(); }; list.appendChild(a); }); scope.textContent = 'All buildings'; /* ---- the editor ---- */ var form = Kob.form(root.querySelector('#ad-form')); Kob.combobox(root.querySelector('#ad-building'), { data: KOB_SAMPLE.buildings.map(function (b) { return { id: b, text: b }; }) }); var dialog = Kob.dialog(root.querySelector('#ad-dialog'), { title: 'Tenant', width: 440, buttons: [ { text: 'Cancel', iconCls: 'icon-cancel', handler: function (d) { d.close(); } }, { text: 'Save', iconCls: 'icon-save', primary: true, handler: save } ] }); function edit() { if (!current) { return Kob.alert({ title: 'Pick a row', msg: 'Select a row first.' }); } form.setValues(current); dialog.setTitle('Edit ' + current.room).open(); } function save(d) { if (!form.validate()) { return; } Object.assign(current || (current = { id: Date.now(), status: 'pending' }), form.getValues()); if (rows.indexOf(current) === -1) { rows.unshift(current); } grid.loadData(rows); showCount(); Kob.toast({ msg: 'Saved ' + current.room, type: 'success' }); d.close(); } root.querySelector('#ad-new').onclick = function () { current = null; form.clear(); dialog.setTitle('New tenant').open(); }; root.querySelector('#ad-edit').onclick = edit; root.querySelector('#ad-del').onclick = function () { if (!current) { return Kob.alert({ title: 'Pick a row', msg: 'Select a row first.' }); } Kob.confirm({ title: 'Delete', msg: 'Remove ' + current.room + '?' }).then(function (yes) { if (!yes) { return; } rows.splice(rows.indexOf(current), 1); current = null; grid.loadData(rows); showCount(); Kob.toast({ msg: 'Deleted', type: 'warn' }); }); }; root.querySelector('#ad-reload').onclick = function () { rows = KOB_SAMPLE.rows(84); current = null; grid.loadData(rows); showCount(); Kob.toast('Reloaded'); }; /* ---- the line along the bottom ---- */ function showCount() { root.querySelector('#ad-count').innerHTML = '' + grid.getVisibleRows().length + ' of ' + rows.length + ' rooms'; } showCount(); ``` ## Whole screens The three screens every internal tool starts with — a sign-in, a sign-up, and the dashboard everyone lands on afterwards, that one with the bar, the sidebar and the status line an application actually ships with. **Example — Sign in** ```html

Welcome back

Sign in to the rent roll

``` ```js var form = Kob.form(root.querySelector('#lg-form')); var error = root.querySelector('#lg-error'); var button = root.querySelector('#lg-go'); function fail(message) { error.textContent = message; error.style.display = ''; } root.querySelector('#lg-go').onclick = function () { error.style.display = 'none'; // validate() marks every invalid field and returns false, so the // banner only has to say what the fields cannot. if (!form.validate()) { return fail('Fill in both fields.'); } var values = form.read(); Kob.linkbutton(button, { disabled: true, text: 'Signing in…' }); // Stand-in for the real POST. setTimeout(function () { Kob.linkbutton(button, { disabled: false, text: 'Sign in' }); if (values.user !== 'admin' || values.pass !== 'admin') { return fail('That user and password do not match.'); } Kob.sweet.success('Signed in', 'Welcome back, ' + values.user + '.'); }, 600); }; // Enter anywhere in the form submits, the way a login is expected to. root.querySelector('#lg-form').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); button.click(); } }); // Try admin / admin. ``` **Example — Create an account** ```html
Create account Reset
``` ```js Kob.combobox(root.querySelector('[name=building]'), { data: KOB_SAMPLE.buildings.map(function (b) { return { id: b, text: b }; }) }); var form = Kob.form(root.querySelector('#rg-form')); var meter = root.querySelector('#rg-strength i'); // A password meter is a hint, not a gate: it never blocks the button. root.querySelector('[name=pass]').addEventListener('input', function () { var v = this.value; var score = 0; if (v.length >= 8) { score++; } if (/[A-Z]/.test(v) && /[a-z]/.test(v)) { score++; } if (/[0-9]/.test(v)) { score++; } if (/[^A-Za-z0-9]/.test(v)) { score++; } meter.style.width = (score * 25) + '%'; meter.style.background = score >= 4 ? 'var(--kob-success)' : score >= 2 ? 'var(--kob-warn)' : 'var(--kob-danger)'; }); root.querySelector('#rg-go').onclick = function () { if (!form.validate()) { return Kob.sweet.warn('Not quite', 'Some required fields are still empty.'); } var v = form.read(); if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v.email)) { return Kob.sweet.error('Check the email', v.email + ' does not look like an address.'); } if (v.pass !== v.pass2) { return Kob.sweet.error('Passwords differ', 'The two passwords are not the same.'); } if (!v.terms) { return Kob.sweet.warn('One more thing', 'You have to accept the terms.'); } Kob.sweet.success('Account created', v.first + ' ' + v.last + ' can sign in now.'); }; root.querySelector('#rg-reset').onclick = function () { form.clear(); meter.style.width = '0'; }; ``` **Example — Admin dashboard — full shell** ```html
RRentRoll
RentRollDashboard

Dashboard

Export New invoice
Live
``` ```js var rows = KOB_SAMPLE.rows(84); var shell = root.querySelector('#db-shell'); function sum(list, field) { return list.reduce(function (n, r) { return n + r[field]; }, 0); } function count(status) { return rows.filter(function (r) { return r.status === status; }).length; } /* ---- the bar ---- */ // Collapsing the sidebar to a rail is one class on the shell; the // stylesheet drops the labels and narrows the column. root.querySelector('#db-rail').onclick = function () { shell.classList.toggle('kob-sidebar-collapsed'); }; var account = Kob.menu(document.createElement('div'), { items: [ { text: 'Profile', iconCls: 'icon-user' }, { text: 'Preferences', iconCls: 'icon-sliders' }, { separator: true }, { text: 'Sign out', iconCls: 'icon-logout' } ], onClick: function (item) { Kob.toast(item.text); } }); root.querySelector('#db-account').onclick = function () { account.showFor(this); }; root.querySelector('#db-bell').onclick = function () { Kob.toast({ msg: '3 invoices went overdue today', type: 'warn' }); }; root.querySelector('#db-help').onclick = function () { Kob.sweet.info('Month-end close', 'Read the meters, issue, chase, then lock the month.'); }; root.querySelector('#db-export').onclick = function () { Kob.toast('Exporting 84 rows'); }; root.querySelector('#db-new').onclick = function () { Kob.toast({ msg: 'The invoice form would open here', type: 'info' }); }; /* ---- the sidebar ---- */ // Routing is the application's business — the shell only marks which // entry is current and repaints the heading above the content. var links = root.querySelectorAll('.kob-sidebar-item'); links.forEach(function (link) { link.onclick = function () { var name = link.getAttribute('title'); if (name === 'Sign out') { Kob.toast('Signed out'); return; } links.forEach(function (other) { other.classList.remove('active'); }); link.classList.add('active'); root.querySelector('#db-title').textContent = name; root.querySelector('#db-crumb').textContent = name; }; }); /* ---- the tile row ---- */ var TILES = [ { label: 'Billed this month', value: KOB_FMT.money(sum(rows, 'rent')), icon: 'icon-banknote', trend: 'up', note: '+6.4% vs last month' }, { label: 'Paid', value: count('paid'), icon: 'icon-check-circle', tone: 'success' }, { label: 'Overdue', value: count('overdue'), icon: 'icon-warn', tone: 'danger', trend: 'down', note: 'chase today' }, { label: 'Occupied rooms', value: rows.length + ' / 96', icon: 'icon-building' } ]; root.querySelector('#db-tiles').innerHTML = TILES.map(function (t) { var tone = t.tone ? 'var(--kob-' + t.tone + ')' : 'var(--kob-accent)'; return '
' + '
' + '' + t.label + '
' + '
' + t.value + '
' + (t.note ? '
' + '' + t.note + '
' : '') + '
'; }).join(''); /* ---- the grid, worst first ---- */ var tabs = Kob.tabs(root.querySelector('#db-tabs')); var RANK = { overdue: 0, pending: 1, paid: 2 }; var grid = Kob.datagrid(root.querySelector('#db-grid'), { columns: [ { field: 'room', title: 'Room', width: 80 }, { field: 'tenant', title: 'Tenant', width: 130 }, { field: 'rent', title: 'Rent', width: 100, align: 'right', formatter: KOB_FMT.money }, { field: 'status', title: 'Status', width: 100, formatter: function (v) { return KOB_FMT.badge(v, v === 'paid' ? 'success' : v === 'overdue' ? 'danger' : 'warn'); } } ], data: rows.slice().sort(function (a, b) { return RANK[a.status] - RANK[b.status]; }), filterable: false, pagination: false, fit: true }); // The bar's search field drives the grid, and brings its tab forward // so the result is not filtered away behind another page. Kob.searchbox(root.querySelector('#db-find'), { delay: 200, onSearch: function (q) { grid.search(q); tabs.select(0); } }); /* ---- a bar per building, drawn with two divs ---- */ var byBuilding = KOB_SAMPLE.buildings.map(function (name) { var mine = rows.filter(function (r) { return r.building === name; }); return { name: name, total: sum(mine, 'rent'), rooms: mine.length }; }); var peak = Math.max.apply(null, byBuilding.map(function (b) { return b.total; })); root.querySelector('#db-bars').innerHTML = byBuilding.map(function (b) { return '
' + '
' + '' + b.name + ' (' + b.rooms + ')' + '' + KOB_FMT.money(b.total) + '' + '
' + '
' + '' + '
'; }).join(''); /* ---- what happened lately ---- */ var FEED = [ { icon: 'icon-banknote', tone: 'success', text: 'Tenant 12 paid 8,250.00', when: '2 minutes ago' }, { icon: 'icon-warn', tone: 'danger', text: 'Invoice 2026-0042 is overdue', when: 'an hour ago' }, { icon: 'icon-user-plus', tone: 'accent', text: 'Tenant 85 moved into 118/2', when: 'yesterday' }, { icon: 'icon-file-text', tone: 'accent', text: '28 invoices issued for the month', when: '2 days ago' } ]; root.querySelector('#db-feed').innerHTML = FEED.map(function (f) { return '
' + '' + '
' + f.text + '
' + '
' + f.when + '
'; }).join(''); /* ---- the checklist down the side ---- */ var steps = Kob.stepper(root.querySelector('#db-steps'), { steps: [ { title: 'Read meters', text: 'Water and electricity' }, { title: 'Issue invoices', text: '84 rooms' }, { title: 'Chase overdue', text: count('overdue') + ' outstanding' }, { title: 'Lock the month' } ], active: 1 }); root.querySelector('#db-back').onclick = function () { steps.prev(); }; root.querySelector('#db-fwd').onclick = function () { var last = steps.isLast(); steps.next(); if (last) { Kob.toast({ msg: 'The month is closed', type: 'success' }); } }; /* ---- the status bar ---- */ root.querySelector('#db-count').innerHTML = '' + rows.length + ' tenants · ' + count('overdue') + ' overdue'; var clock = root.querySelector('#db-time'); function tick() { clock.textContent = new Date().toLocaleTimeString(); } tick(); setInterval(tick, 1000); ``` ## Data The grid, in the shapes it is usually needed in — including one with no actions column, where editing and deleting come from the row. **Example — A grid in eight lines** ```html
``` ```js Kob.datagrid(root.querySelector('.ex-grid'), { columns: [ { field: 'room', title: 'Room', width: 90 }, { field: 'tenant', title: 'Tenant', width: 160 }, { field: 'building', title: 'Building', width: 130 }, { field: 'rent', title: 'Rent', width: 110, align: 'right' } ], data: KOB_SAMPLE.rows(48) }); ``` **Example — Filters read what the reader sees** ```html
Only overdue Rent ≥ 8,000 Clear
``` ```js var grid = Kob.datagrid(root.querySelector('.ex-grid'), { columns: [ { field: 'room', title: 'Room', width: 90 }, { field: 'tenant', title: 'Tenant', width: 150 }, { field: 'building', title: 'Building', width: 120 }, { field: 'rent', title: 'Rent', width: 110, align: 'right', formatter: KOB_FMT.money }, { field: 'due', title: 'Due', width: 110 }, // The cell renders a badge; the chip menu still offers // paid / pending / overdue, because filtering runs on the // text the formatter produced. { field: 'status', title: 'Status', width: 110, formatter: function (v) { return KOB_FMT.badge(v, v === 'paid' ? 'success' : v === 'overdue' ? 'danger' : 'warn'); } }, // 1 and 0 read as Yes and No, and filter that way too. { field: 'active', title: 'Active', width: 90, align: 'center', formatter: function (v) { return KOB_FMT.badge(v ? 'Yes' : 'No', v ? 'info' : 'muted'); } } ], data: KOB_SAMPLE.rows(137), rownumbers: true, pageSize: 20 }); // The same filters the chips set, driven from your own toolbar. root.querySelector('#ex-f-overdue').onclick = function () { grid.setFilter('status', { in: ['overdue'] }); }; root.querySelector('#ex-f-big').onclick = function () { grid.setFilter('rent', { ge: 8000 }); }; root.querySelector('#ex-f-clear').onclick = function () { grid.clearFilters(); }; ``` **Example — Editing a row in place** ```html

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** ```html

Click 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.

Select all Clear Delete selected
``` ```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 highlighted s — so it // survives paging, sorting and filtering, and selectAll() means every // row the filters let through, not just the eight on screen. root.querySelector('#ds-all').onclick = function () { grid.selectAll(); }; root.querySelector('#ds-none').onclick = function () { grid.unselectAll(); }; root.querySelector('#ds-del').onclick = function () { var picked = grid.getSelections(); if (!picked.length) { return Kob.alert({ title: 'Nothing selected', msg: 'Tick a row first.' }); } Kob.confirm({ title: 'Delete', msg: 'Remove ' + picked.length + ' room(s)?' }).then(function (yes) { if (!yes) { return; } rows = rows.filter(function (r) { return picked.indexOf(r) === -1; }); grid.loadData(rows); show([]); Kob.toast({ msg: 'Deleted ' + picked.length, type: 'warn' }); }); }; ``` **Example — Edit and delete from the row itself** ```html

No 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
Read values
``` ```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** ```html

nothing picked yet

``` ```js var out = root.querySelector('#ex-cb-out'); // Pick from the list only — typing does not filter. Kob.combobox(root.querySelector('#ex-cb-room'), { data: KOB_SAMPLE.rows(24).map(function (r) { return { id: r.id, text: r.room + ' — ' + r.tenant }; }), value: 3, onChange: function (value, row) { out.textContent = 'value ' + value + ' · text "' + row.text + '"'; } }); // Editable: typing filters, and a formatter may return HTML. Kob.combobox(root.querySelector('#ex-cb-type'), { valueField: 'code', textField: 'name', data: [ { code: 'studio', name: 'Studio', size: 24 }, { code: '1br', name: '1 bedroom', size: 32 }, { code: '2br', name: '2 bedroom', size: 48 }, { code: 'duplex', name: 'Duplex', size: 64 } ], formatter: function (row) { return row.name + ' ' + row.size + ' m²'; } }); ``` **Example — Date picker and the Buddhist calendar** ```html
English ไทย (พ.ศ.)

``` ```js var box = Kob.datebox(root.querySelector('#ex-date'), { value: '2026-03-09', onChange: report }); function report() { root.querySelector('#ex-date-out').textContent = 'on screen: ' + box.getText() + ' · getValue(): ' + box.getValue(); } // The locale decides the display format and the era. The value the // application reads is ISO yyyy-mm-dd either way. function relocalise(name) { var iso = box.getValue(); Kob.locale(name); box.setValue(iso); report(); } root.querySelector('#ex-en').onclick = function () { relocalise('en'); }; root.querySelector('#ex-th').onclick = function () { relocalise('th'); }; report(); ``` **Example — Checkbox switch** ```html
``` ```js // A real stays in the DOM, so forms submit it // and getValue() is just its checked state. Kob.checkbox(root.querySelector('#ex-cb-mail'), { onChange: function (checked) { Kob.toast({ msg: checked ? 'Receipt will be emailed' : 'No email', type: 'info' }); } }); ``` **Example — Reading and validating a whole form** ```html
Clear Fill Save
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
``` ```js var rows = KOB_SAMPLE.rows(84); var out = root.querySelector('#sx-out'); var grid = Kob.datagrid(root.querySelector('#sx-grid'), { columns: [ { field: 'room', title: 'Room', width: 80 }, { field: 'tenant', title: 'Tenant', width: 140 }, { field: 'building', title: 'Building', width: 120 }, { field: 'rent', title: 'Rent', width: 100, align: 'right', formatter: KOB_FMT.money } ], data: rows, filterable: false, searchable: false, pageSize: 20 }); // onSearch is debounced, so holding a key down costs one search, not // twenty. Enter and the clear button skip the wait. Kob.searchbox(root.querySelector('#sx-find'), { delay: 200, onSearch: function (q) { grid.search(q); out.textContent = q ? grid.getVisibleRows().length + ' of ' + rows.length + ' match “' + q + '”' : ''; } }); ``` **Example — File upload** ```html
This page has no server
Uploading really does POST to /api/upload, which is not there — so you get the progress bar and then the error state, both genuine.

  
``` ```js var out = root.querySelector('#up-out'); var box = Kob.filebox(root.querySelector('#up-box'), { accept: '.pdf,.png,.jpg,.csv', maxSize: 2 * 1024 * 1024, maxFiles: 5, url: '/api/upload', fieldName: 'document', data: { tenantId: 42 }, onAdd: function (file) { show(); }, onRemove: function () { show(); }, onProgress: function (file, percent) { out.textContent = file.name + ' — ' + percent + '%'; }, onError: function (file, why) { Kob.toast({ msg: file.name + ': ' + why, type: 'error' }); }, onComplete: function (ok, failed) { Kob.sweet[failed ? 'error' : 'success']( failed ? 'Upload failed' : 'Uploaded', ok + ' succeeded, ' + failed + ' failed.' ); show(); } }); function show() { var files = box.getFiles(); out.textContent = files.length ? files.map(function (f) { return f.name + ' (' + f.size + ' bytes)'; }).join('\n') : 'nothing queued'; } show(); root.querySelector('#up-send').onclick = function () { if (!box.getFiles().length) { return Kob.sweet.warn('Nothing to upload', 'Add a file first.'); } box.upload(); }; root.querySelector('#up-clear').onclick = function () { box.clear(); show(); }; // Leave 'url' out and the widget never touches the network: it just // collects files, and box.getFiles() hands them to your own FormData. ``` ## Containers and chrome Panels, tabs, steppers, the layout frame, windows, and the message helpers. **Example — Panel** ```html

Fold me away with the caret.

``` ```js // panel.load(url) drops the response HTML into the body and parses // any widgets it contains. Here the markup is set directly instead, // which goes through the same parsing step. Kob.panel(root.querySelector('#ex-panel-load')).setContent( '

Content arrived, and the button below was ' + 'parsed out of it.

' + 'Refresh' ); ``` **Example — Tabs** ```html
Any direct child with a title becomes a page.
This one can be closed.
Add a tab
``` ```js var tabs = Kob.tabs(root.querySelector('#ex-tabs'), { onSelect: function (index, title) { Kob.toast('Tab: ' + title); } }); var n = 0; root.querySelector('#ex-tab-add').onclick = function () { tabs.add({ title: 'Note ' + (++n), content: '
Added at ' + new Date().toLocaleTimeString() + '
', closable: true }); }; ``` **Example — Stepper — across the page** ```html
Nothing has been saved yet — the button below is what writes the lease.
Back Next Start over
``` ```js // Each child with a title is both a step and the panel shown while // that step is current — the same idiom as tabs. var form = Kob.form(root.querySelector('#ex-step-form')); // A list of objects is more than data-options can express, so the room // list is handed over from here. Kob.combobox(root.querySelector('#ex-step-room'), { data: [ { id: '104/5', text: '104/5 — Sukhumvit' }, { id: '118/2', text: '118/2 — Ratchada' }, { id: '122/1', text: '122/1 — Thonglor' } ] }); var back = Kob.linkbutton(root.querySelector('#ex-step-back')); var next = Kob.linkbutton(root.querySelector('#ex-step-next')); var wizard = Kob.stepper(root.querySelector('#ex-stepper'), { onChange: function () { sync(); }, onFinish: function () { Kob.toast({ msg: 'Lease created', type: 'success' }); sync(); } }); function sync() { if (wizard.isFirst()) { back.disable(); } else { back.enable(); } next.setText(wizard.isLast() ? 'Create the lease' : 'Next'); next.setIcon(wizard.isLast() ? 'icon-check-circle' : 'icon-forward'); } sync(); root.querySelector('#ex-step-back').onclick = function () { wizard.prev(); }; root.querySelector('#ex-step-next').onclick = function () { // A wizard has to be sure of the step it is leaving. error() paints // the marker red and leaves it that way until the page clears it. if (wizard.getActive() === 0 && !form.validate()) { wizard.error(); return; } wizard.setStatus(wizard.getActive(), null); wizard.next(); }; root.querySelector('#ex-step-reset').onclick = function () { form.clear(); wizard.reset(); sync(); }; ``` **Example — Stepper — down the side** ```html

Progress, with a line of detail under each step.

Back Advance Fail this step

Small, dotted markers — a status rail rather than a wizard.

``` ```js // With no titled children the widget is the indicator on its own, and // the steps come from the option instead. var track = Kob.stepper(root.querySelector('#ex-vstep'), { steps: [ { title: 'Order placed', text: 'Paid by card' }, { title: 'Packed', text: 'Warehouse 2' }, { title: 'On the way', text: 'Courier collected it' }, { title: 'Delivered', text: 'Signed for at reception' } ], active: 2, onChange: function (i, was, step) { Kob.toast(step.title); } }); root.querySelector('#ex-vstep-prev').onclick = function () { track.prev(); }; root.querySelector('#ex-vstep-next').onclick = function () { track.next(); }; root.querySelector('#ex-vstep-fail').onclick = function () { track.error(); }; // Every step here carries its own status, so nothing is derived from // where the active one sits. Kob.stepper(root.querySelector('#ex-vstep2'), { steps: [ { title: 'Meters read', text: '84 of 84', status: 'done' }, { title: 'Invoices sent', text: '84 of 84', status: 'done' }, { title: 'Payments in', text: '56 of 84', status: 'active' }, { title: 'Reminders', text: 'blocked by 3 disputes', status: 'error' }, { title: 'Month locked', text: 'due on the 5th' } ] }); ``` **Example — Layout with a draggable splitter** ```html
North — fixed height
West
drag my right edge
Center — takes the rest
South
Fold the west region
``` ```js var layout = Kob.layout(root.querySelector('#ex-layout')); root.querySelector('#ex-layout-fold').onclick = function () { layout.toggle('west'); }; // layout.panel('center') hands back the region element, so a datagrid // or anything else can be mounted straight into it. layout.panel('center').insertAdjacentHTML('beforeend', '

' + 'Regions are plain elements — put whatever you like inside.

'); ``` **Example — Dialogs and windows** ```html
Modal dialog Draggable window
``` ```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
Save Add Edit Delete Print Filter
``` ```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** ```html

Solid — for the one action a screen is about.

Save Approve Delete Void Send Plain

Soft — the same meanings, for a toolbar that has several of them.

Edit Mark paid Delete Reverse Export

Built from JavaScript, and a plain <button class="kob-btn">.

``` ```js // The class in the markup and the option are the same thing said two // ways — Kob.parse reads one, this writes the other. Kob.linkbutton(root.querySelector('#bc-js'), { text: 'From options', iconCls: 'icon-bolt', color: 'success' }); var COLORS = ['accent', 'success', 'danger', 'warn', 'info']; var at = 0; var cycle = Kob.linkbutton(root.querySelector('#bc-cycle'), { text: 'color: accent', iconCls: 'icon-palette', color: 'accent', onClick: function () { at = (at + 1) % COLORS.length; cycle.option({ color: COLORS[at], text: 'color: ' + COLORS[at] }); } }); // The five come from --kob--solid and --kob-on-, a pair per // colour, so the label stays legible in all three themes. Try the theme // switch at the top of the page. ``` **Example — Alerts, confirms, prompts and toasts** ```html
Alert Confirm Prompt Success toast Error toast
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
Success Error Warning Info Delete a tenant Auto-close answer appears here
``` ```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: '

Invoices

' + '

Any HTML works as a window body. Widgets in it are parsed.

' + '
' }, { id: 'chart', title: 'Reports', iconCls: 'icon-chart', width: 420, height: 260, content: '
' + 'Reports would render here.
' }, { id: 'settings', title: 'Settings', iconCls: 'icon-settings', width: 380, height: 220, content: '
Settings
' } ] }); ``` **Example — Ribbon** ```html

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** ```html

nothing 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** ```html
Open a menu …or right-click the panel below
right-click here

``` ```js var out = root.querySelector('#ex-menu-out'); // A menu lives on its own element and floats — nothing is drawn // where you build it. var m = Kob.menu(document.createElement('div'), { items: [ { text: 'New', iconCls: 'icon-add', shortcut: 'Ctrl+N' }, { text: 'Open', iconCls: 'icon-folder', shortcut: 'Ctrl+O' }, { separator: true }, { text: 'Export', iconCls: 'icon-download', items: [ { text: 'CSV', iconCls: 'icon-excel' }, { text: 'PDF', iconCls: 'icon-file' }, { text: 'Print', iconCls: 'icon-print' } ] }, { separator: true }, { text: 'Delete', iconCls: 'icon-remove', disabled: true } ], onClick: function (item) { out.textContent = 'picked: ' + item.text; } }); root.querySelector('#ex-menu-open').onclick = function () { m.showFor(this); }; // One call turns any element into a right-click surface. m.bindContextMenu(root.querySelector('#ex-menu-zone')); ``` **Example — Row actions: a button and a right-click** ```html

Use 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** ```html

nothing ticked

``` ```js var data = [ { id: 1, text: 'Sukhumvit', iconCls: 'icon-home', children: [ { id: 11, text: 'Floor 1', children: [ { id: 111, text: 'Room 101/2' }, { id: 112, text: 'Room 102/3' } ] }, { id: 12, text: 'Floor 2', children: [{ id: 121, text: 'Room 201/1' }] } ] }, { id: 2, text: 'Ratchada', iconCls: 'icon-home', children: [ { id: 21, text: 'Floor 1', children: [{ id: 211, text: 'Room 101/5' }] } ] } ]; var t = Kob.tree(root.querySelector('#ex-tree'), { data: JSON.parse(JSON.stringify(data)), lines: true, onSelect: function (node) { Kob.toast(node.text); } }); root.querySelector('#ex-tree-all').onclick = function () { t.expandAll(); }; root.querySelector('#ex-tree-none').onclick = function () { t.collapseAll(); }; // With checkbox:true a folder ticks and unticks its children, and // shows a dash while only some of them are ticked. var out = root.querySelector('#ex-tree-out'); Kob.tree(root.querySelector('#ex-tree-check'), { data: JSON.parse(JSON.stringify(data)), checkbox: true, expandDepth: 2, onCheck: function () { var picked = Kob.instance(root.querySelector('#ex-tree-check'), 'tree').getChecked(); out.textContent = picked.length ? picked.length + ' ticked: ' + picked.map(function (n) { return n.text; }).join(', ') : 'nothing ticked'; } }); ``` ## Feedback and layout Progress, spinners, banners and badges — plus the cards, button groups and grid helpers that hold a screen together. **Example — Progress and spinners** ```html
Run a job Reset
A panel that goes busy

Kob.loading() covers it and hands back the uncover function.

Cover it for 2s
``` ```js var bar = Kob.progressbar(root.querySelector('#ex-pb'), { value: 35, onComplete: function () { Kob.toast({ msg: 'Finished', type: 'success' }); } }); Kob.progressbar(root.querySelector('#ex-pb-striped'), { value: 70, type: 'warn', striped: true }); // No total to measure against: a sweeping stripe instead of a fill. Kob.progressbar(root.querySelector('#ex-pb-wait'), { indeterminate: true, type: 'success', height: 8 }); root.querySelector('#ex-pb-run').onclick = function () { var v = 0; var timer = setInterval(function () { v += 7; bar.setValue(v); if (v >= 100) { clearInterval(timer); } }, 120); }; root.querySelector('#ex-pb-reset').onclick = function () { bar.setValue(0); }; root.querySelector('#ex-pre-go').onclick = function () { var done = Kob.loading(root.querySelector('#ex-pre'), 'Fetching invoices...'); setTimeout(done, 2000); }; ``` **Example — Inline alerts and badges** ```html
Rent for March has not been invoiced yet.
All 137 rows were written.
Three rooms have a reading lower than last month.
The server refused the request.
Inbox 3 One more Paid Pending Overdue New Draft
``` ```js // Kob.alert() opens a modal. The inline banner is Kob.alertbox(), // or class="kob-alert" picked up by the parser — as above. var badge = Kob.badge(root.querySelector('#ex-badge'), { type: 'danger', max: 99 }); root.querySelector('#ex-badge-inc').onclick = function () { badge.increment(); }; // Built from script rather than markup, and dismissed after a while. Kob.alertbox(root.querySelector('#ex-alert-ok')).setMessage('All 137 rows were written.'); ``` **Example — Status bar** ```html
Ready no selection admin saved just now
``` ```js var rows = KOB_SAMPLE.rows(60); var state = root.querySelector('#sb-state'); var grid = Kob.datagrid(root.querySelector('#sb-grid'), { columns: [ { field: 'room', title: 'Room', width: 80 }, { field: 'tenant', title: 'Tenant', width: 150 }, { field: 'rent', title: 'Rent', width: 100, align: 'right', formatter: KOB_FMT.money } ], data: rows, filterable: false, pagination: false, fit: true, onClickRow: function (i, row) { root.querySelector('#sb-sel').textContent = row.room + ' selected'; } }); root.querySelector('#sb-rows').textContent = rows.length + ' rows'; // Three states, one element: the class carries the colour and the dot, // the text says what it means. Colour alone would leave the state // invisible to anyone who cannot separate green from amber. function setState(kind, text) { state.className = 'kob-status-item kob-status-' + kind; state.lastChild.textContent = text; } root.querySelector('#sb-save').onclick = function () { setState('busy', 'Saving…'); setTimeout(function () { setState('ok', 'Ready'); root.querySelector('#sb-saved').textContent = 'saved at ' + new Date().toLocaleTimeString(); }, 900); }; root.querySelector('#sb-sync').onclick = function () { setState('busy', 'Syncing…'); setTimeout(function () { setState('ok', 'Up to date'); }, 1200); }; root.querySelector('#sb-break').onclick = function () { setState('bad', 'Connection lost'); }; setState('ok', 'Ready'); ``` **Example — Filter buttons** ```html
Reset
``` ```js var rows = KOB_SAMPLE.rows(137); var grid = Kob.datagrid(root.querySelector('.ex-grid'), { columns: [ { field: 'room', title: 'Room', width: 90 }, { field: 'tenant', title: 'Tenant', width: 150 }, { field: 'building', title: 'Building', width: 120 }, { field: 'rent', title: 'Rent', width: 110, align: 'right', formatter: KOB_FMT.money }, { field: 'status', title: 'Status', width: 110 } ], data: rows, filterable: false, // the buttons above do the filtering pageSize: 20 }); // The chip from the grid's own toolbar, usable on its own — so a // filter bar can live anywhere on the screen. var filters = {}; function apply() { grid.loadData(rows.filter(function (r) { if (filters.building && filters.building.indexOf(r.building) === -1) { return false; } if (filters.status && filters.status.indexOf(r.status) === -1) { return false; } if (filters.rent !== undefined && filters.rent !== null && r.rent < filters.rent) { return false; } if (filters.tenant && r.tenant.toLowerCase().indexOf(filters.tenant.toLowerCase()) === -1) { return false; } return true; })); } Kob.filterbutton(root.querySelector('#ex-f-building'), { label: 'Building', options: KOB_SAMPLE.buildings, onChange: function (v) { filters.building = v; apply(); } }); Kob.filterbutton(root.querySelector('#ex-f-status'), { label: 'Status', options: KOB_SAMPLE.statuses, multiple: false, onChange: function (v) { filters.status = v; apply(); } }); Kob.filterbutton(root.querySelector('#ex-f-rent'), { label: 'Rent', kind: 'number', onChange: function (v) { filters.rent = v; apply(); } }); Kob.filterbutton(root.querySelector('#ex-f-name'), { label: 'Tenant', kind: 'text', onChange: function (v) { filters.tenant = v; apply(); } }); root.querySelector('#ex-f-reset').onclick = function () { ['building', 'status', 'rent', 'name'].forEach(function (key) { Kob.filterbutton(root.querySelector('#ex-f-' + key)).clear(); }); filters = {}; apply(); }; ``` **Example — Cards, button groups and the layout helpers** ```html
Sukhumvit
92%

46 of 50 rooms occupied.

This month
฿418,500+4.2%

Collected rent across three buildings.

Needs attention
7 invoices are overdue.

Button groups

The 12-column row

kob-col-8
kob-col-4
kob-col-6
kob-col-3
kob-col-3
``` ```js // A button group behaves as a segmented control when you keep the // selected class on one member. var group = root.querySelector('#ex-bg'); group.addEventListener('click', function (e) { var btn = e.target.closest('.kob-btn'); if (!btn) { return; } group.querySelectorAll('.kob-btn').forEach(function (b) { b.classList.remove('selected'); }); btn.classList.add('selected'); Kob.toast('View: ' + btn.textContent); }); // Cards, alerts and the progress bar above came from markup — // Kob.parse() built them before this script ran. ``` ## Making it your own Repaint it with CSS variables, or add a widget of your own that the markup parser treats exactly like a built-in. **Example — Retheming with three variables** ```html
Teal Violet Rust Forest
``` ```js Kob.datagrid(root.querySelector('.ex-grid'), { columns: [ { field: 'room', title: 'Room', width: 90 }, { field: 'tenant', title: 'Tenant', width: 160 }, { field: 'rent', title: 'Rent', width: 110, align: 'right', formatter: KOB_FMT.money } ], data: KOB_SAMPLE.rows(20), pagination: false, filterable: false }); // Nothing in the stylesheet hard-codes a colour, so overriding the // accent tokens on any element retheme everything inside it. var scope = root.querySelector('#ex-theme'); root.querySelectorAll('[data-accent]').forEach(function (btn) { btn.onclick = function () { var accent = btn.getAttribute('data-accent'); scope.style.setProperty('--kob-accent', accent); scope.style.setProperty('--kob-accent-dark', shade(accent, -0.25)); scope.style.setProperty('--kob-accent-soft', accent + '18'); scope.style.setProperty('--kob-accent-hover', accent + '2b'); }; }); function shade(hex, amount) { var n = parseInt(hex.slice(1), 16); var c = [n >> 16, (n >> 8) & 255, n & 255].map(function (v) { return Math.max(0, Math.min(255, Math.round(v + v * amount))); }); return 'rgb(' + c.join(',') + ')'; } ``` **Example — Writing your own widget** ```html

``` ```js // Extend Kob.Widget, register it, and Kob.parse() will build it from // markup exactly like a built-in — data-options and all. class Rating extends Kob.Widget { static widgetName = 'rating'; static defaults = { max: 5, value: 0, onChange: null }; init() { this.render(); this.track(Kob.dom.on(this.el, 'click', '[data-n]', (e, star) => { this.setValue(Number(star.dataset.n)); })); } setValue(v) { this.options.value = v; this.render(); this.emit('onChange', v); return this; } getValue() { return this.options.value; } render() { var html = ''; for (var n = 1; n <= this.options.max; n++) { html += ''; } this.el.innerHTML = html; } } if (!Kob.registry.has('rating')) { Kob.rating = Kob.register(Rating); } var out = root.querySelector('#ex-rating-out'); var widget = Kob.rating(root.querySelector('.kob-rating'), { onChange: function (v) { out.textContent = 'rated ' + v + ' / 5'; } }); out.textContent = 'rated ' + widget.getValue() + ' / 5'; ``` # TypeScript declarations The contents of `types/index.d.ts` as published — every accessor, option interface and method signature the library exposes. ```ts /* Type definitions for kob-ui */ export as namespace Kob; /** Anything a widget accessor will resolve to an element. */ export type Target = string | Element | ArrayLike; export interface RequestConfig { /** Class prefix the markup parser looks for. Default 'kob-'. */ prefix: string; /** Base z-index the floating-layer stack counts up from. Default 9100. */ zIndex: number; /** Active locale name. Default 'en'. */ locale: string; /** * How combobox and datagrid fetch remote data. Replace to add auth headers, * unwrap an envelope, or redirect on 401. */ request(url: string, params?: Record | null): Promise; /** Called when `request` rejects. */ onRequestError(error: unknown, url: string): void; } export interface LocaleBundle { ok: string; cancel: string; close: string; minimize: string; maximize: string; restore: string; loading: string; loadingData: string; noData: string; noMatch: string; all: string; applyFilter: string; clear: string; clearFilters: string; searchAll: string; contains: string; atLeast: string; anyNumber: string; rowsPerPage: string; pageOf: string; showing: string; first: string; prev: string; next: string; last: string; today: string; months: string[]; weekdaysShort: string[]; /** 0 = Sunday. */ firstDay: number; /** Display format built from yyyy / yy / mm / dd. */ dateFormat: string; /** Show years in the Thai Buddhist era. */ buddhistEra: boolean; } /* ------------------------------------------------------------------ base */ export interface WidgetOptions { [key: string]: unknown; } export class Widget { static widgetName: string; static defaults: WidgetOptions; readonly el: HTMLElement; readonly name: string; options: O; constructor(el: HTMLElement, options?: Partial); init(): void; /** Merge in options and refresh; with no argument, returns the options. */ option(patch?: Partial): O; option(key: string): unknown; refresh(): void; destroy(): void; } /* ------------------------------------------------------------- form input */ export interface FieldOptions extends WidgetOptions { label?: string; labelWidth?: number; labelPosition?: 'left' | 'top' | ''; prompt?: string; value?: unknown; readonly?: boolean; disabled?: boolean; required?: boolean; onChange?: (this: HTMLElement, value: unknown) => void; } export class TextBox extends Widget { getValue(): V; setValue(v: unknown): this; getText(): string; clear(): this; enable(): this; disable(): this; readonly(mode?: boolean): this; focus(): this; isValid(): boolean; } export class PasswordBox extends TextBox {} export interface NumberBoxOptions extends FieldOptions { min?: number; max?: number; /** Decimal places applied on blur. */ precision?: number; /** Thousands separator shown while the field is not focused. */ groupSeparator?: string; } export class NumberBox extends TextBox { normalize(keepEmpty?: boolean): this; } export interface ComboBoxOptions extends FieldOptions { data?: Record[] | null; url?: string; queryParams?: Record | null; valueField?: string; textField?: string; formatter?: (row: Record) => string; /** false = pick from the list only. */ editable?: boolean; panelHeight?: number; limit?: number; onSelect?: (this: HTMLElement, row: Record) => void; onLoadSuccess?: (this: HTMLElement, rows: Record[]) => void; } export class ComboBox extends Widget { getValue(): unknown; setValue(v: unknown): this; getText(): string; loadData(rows: Record[]): this; reload(url?: string): Promise; getData(): Record[]; clear(): this; isValid(): boolean; enable(): this; disable(): this; } export interface DateBoxOptions extends FieldOptions { /** Overrides the locale's dateFormat for this field. */ format?: string; /** ISO bounds, 'yyyy-mm-dd'. */ min?: string; max?: string; showToday?: boolean; onSelect?: (this: HTMLElement, iso: string) => void; } export class DateBox extends Widget { /** ISO 'yyyy-mm-dd', or '' when empty. */ getValue(): string; /** Accepts ISO, a Date, or a string in the display format. */ setValue(v: string | Date | null): this; /** What the user sees, e.g. '09/03/2567'. */ getText(): string; clear(): this; isValid(): boolean; enable(): this; disable(): this; } export interface CheckBoxOptions extends WidgetOptions { label?: string; checked?: boolean; disabled?: boolean; onChange?: (this: HTMLElement, checked: boolean) => void; } export class CheckBox extends Widget { getValue(): boolean; setValue(v: boolean): this; check(): this; uncheck(): this; toggle(): this; enable(): this; disable(): this; } /* ---------------------------------------------------------------- button */ export type ButtonColor = 'accent' | 'success' | 'danger' | 'warn' | 'info'; export interface LinkButtonOptions extends WidgetOptions { text?: string; iconCls?: string; iconAlign?: 'left' | 'right'; plain?: boolean; /** Shorthand for color: 'accent'. Superseded by `color` when both are set. */ primary?: boolean; /** Paints the button. '' leaves it plain. */ color?: '' | ButtonColor; /** The tonal version of `color`: tinted background, coloured label. */ soft?: boolean; toggle?: boolean; selected?: boolean; disabled?: boolean; onClick?: (this: HTMLElement, event: MouseEvent) => void; } export class LinkButton extends Widget { setText(text: string): this; getText(): string; setIcon(iconCls: string): this; toggle(on?: boolean): this; isSelected(): boolean; enable(): this; disable(): this; } /* ------------------------------------------------------------- searchbox */ export interface SearchBoxOptions extends FieldOptions { /** Milliseconds of quiet before onSearch fires. 0 fires on every keystroke. */ delay?: number; /** Never search while typing — only on Enter or the clear button. */ searchOnEnter?: boolean; /** Show the clear button once the field has a value. */ clearable?: boolean; /** Receives the trimmed query. */ onSearch?: (this: HTMLElement, query: string) => void; /** Fires when the field is emptied by the button or by Escape. */ onClear?: (this: HTMLElement) => void; } export class SearchBox extends Widget { /** The current query, trimmed. */ getValue(): string; setValue(value: string): this; clear(): this; /** Run onSearch now, ignoring the debounce and the no-change guard. */ search(value?: string): this; focus(): this; } /* --------------------------------------------------------------- filebox */ export type FileBoxStatus = 'ready' | 'uploading' | 'done' | 'error'; export interface FileBoxItem { file: File; status: FileBoxStatus; /** 0-100 while uploading. */ percent: number; error: string; } export interface FileBoxOptions extends WidgetOptions { multiple?: boolean; /** Same syntax as the attribute: '.pdf,image/*'. */ accept?: string; /** Bytes. 0 means no limit. */ maxSize?: number; /** How many files may be queued at once. 0 means no limit. */ maxFiles?: number; /** Caption inside the drop zone. Falls back to the locale string. */ text?: string; hint?: string; /** POST target. Leave empty and the widget never touches the network. */ url?: string; /** Form field name each file is sent under. Default 'file'. */ fieldName?: string; /** Extra form fields sent alongside every file. */ data?: Record | null; autoUpload?: boolean; headers?: Record | null; disabled?: boolean; required?: boolean; onAdd?: (this: HTMLElement, file: File) => void; onRemove?: (this: HTMLElement, file: File) => void; onProgress?: (this: HTMLElement, file: File, percent: number) => void; onSuccess?: (this: HTMLElement, file: File, response: unknown) => void; onError?: (this: HTMLElement, file: File, message: string) => void; onComplete?: (this: HTMLElement, ok: number, failed: number) => void; } export class FileBox extends Widget { /** Queue files, dropping any that fail accept / maxSize / maxFiles. */ add(files: FileList | File[]): this; remove(index: number): this; clear(): this; getFiles(): File[]; getItems(): FileBoxItem[]; isValid(): boolean; enable(): this; disable(): this; /** POST every queued file, one request each. Rejects when no url is set. */ upload(): Promise<{ ok: number; failed: number }>; } /** "1.4 MB" — the same formatting the file list shows. */ export function formatSize(bytes: number): string; /* ------------------------------------------------------------ containers */ export interface PanelOptions extends WidgetOptions { title?: string; iconCls?: string; fit?: boolean; border?: boolean; collapsible?: boolean; collapsed?: boolean; closable?: boolean; href?: string; loadingMessage?: string; onLoad?: (this: HTMLElement, body: HTMLElement) => void; onCollapse?: (this: HTMLElement) => void; onExpand?: (this: HTMLElement) => void; onClose?: (this: HTMLElement) => void; } export class Panel extends Widget { getBody(): HTMLElement; getHeader(): HTMLElement | null; setTitle(text: string): this; load(url?: string, done?: (body: HTMLElement) => void): Promise; setContent(html: string): this; collapse(silent?: boolean): this; expand(silent?: boolean): this; toggleCollapse(): this; close(): this; } export type Region = 'north' | 'south' | 'west' | 'east' | 'center'; export interface LayoutOptions extends WidgetOptions { splitSize?: number; onCollapse?: (this: HTMLElement, region: Region) => void; onExpand?: (this: HTMLElement, region: Region) => void; onResize?: (this: HTMLElement, region: Region, size: number) => void; } export class Layout extends Widget { panel(region: Region): HTMLElement | undefined; resize(region: Region, size: number): this; collapse(region: Region): this; expand(region: Region): this; toggle(region: Region): this; remove(region: Region): this; } export interface TabPage { el: HTMLElement; tab: HTMLElement; title: string; options: Record; } export interface TabsOptions extends WidgetOptions { selected?: number; closable?: boolean; onSelect?: (this: HTMLElement, index: number, title: string) => void; onBeforeClose?: (this: HTMLElement, index: number, title: string) => boolean | void; onClose?: (this: HTMLElement, index: number, title: string) => void; onAdd?: (this: HTMLElement, title: string, el: HTMLElement) => void; } export class Tabs extends Widget { select(index: number): this; selectByTitle(title: string): this; getSelected(): TabPage | undefined; getPanel(index: number): HTMLElement | undefined; getPages(): TabPage[]; add(spec: { title: string; content?: string; element?: HTMLElement; closable?: boolean; iconCls?: string; selected?: boolean }): TabPage; close(index: number): this; } /* --------------------------------------------------------------- stepper */ export type StepStatus = 'done' | 'active' | 'todo' | 'error'; export interface Step { title?: string; /** The line of explanation under the title. */ text?: string; /** Replaces the number in the marker. */ iconCls?: string; /** Pins a state on the step instead of deriving it from its position. */ status?: StepStatus; [key: string]: unknown; } export interface StepperOptions extends WidgetOptions { /** Steps as data. Left out, the titled children are read instead. */ steps?: (Step | string)[] | null; active?: number; direction?: 'horizontal' | 'vertical'; size?: 'sm' | 'md'; clickable?: boolean; /** Only steps already reached can be clicked. */ linear?: boolean; markers?: 'number' | 'dot'; doneIcon?: string; errorIcon?: string; onChange?: (this: HTMLElement, index: number, previous: number, step: Step) => void; onSelect?: (this: HTMLElement, index: number, step: Step) => void; onFinish?: (this: HTMLElement) => void; } export class Stepper extends Widget { go(index: number, silent?: boolean): this; next(): this; prev(): this; finish(): this; reset(): this; canReach(index: number): boolean; isFirst(): boolean; isLast(): boolean; isFinished(): boolean; getActive(): number; getStep(index?: number): Step | undefined; getSteps(): Step[]; getPanel(index?: number): HTMLElement | undefined; setStatus(index: number, status: StepStatus | null): this; error(index?: number): this; setSteps(steps: (Step | string)[]): this; } /* ---------------------------------------------------------------- dialog */ export interface DialogButton { text: string; iconCls?: string; primary?: boolean; handler?: (this: HTMLElement, dialog: Dialog) => void; } export interface DialogOptions extends WidgetOptions { title?: string; width?: number; height?: number | 'auto'; modal?: boolean; closed?: boolean; buttons?: DialogButton[] | null; draggable?: boolean; resizable?: boolean; minimizable?: boolean; maximizable?: boolean; /** Dock to the bottom edge instead of floating. */ sheet?: boolean; minWidth?: number; minHeight?: number; onOpen?: (this: HTMLElement) => void; onClose?: (this: HTMLElement) => void; onResize?: (this: HTMLElement, box: { width: number; height: number }) => void; onMove?: (this: HTMLElement, box: { left: number; top: number }) => void; onMinimize?: (this: HTMLElement) => boolean | void; onMaximize?: (this: HTMLElement, maximized: boolean) => void; } export class Dialog extends Widget { open(): this; close(): this; isOpen(): boolean; minimize(): this; maximize(on?: boolean): this; restore(): this; isMaximized(): boolean; center(): this; setTitle(text: string): this; getTitle(): string; setButtons(buttons: DialogButton[] | null): this; resize(box: { width?: number; height?: number }): this; move(box: { left: number; top: number }): this; /** The outer .kob-window element. */ window(): HTMLElement; getBody(): HTMLElement; } /* -------------------------------------------------------------- datagrid */ export type Row = Record; export interface Column { field?: string; title?: string; width?: number; align?: 'left' | 'center' | 'right'; sortable?: boolean; /** false pins this column to its width when the others can be dragged. */ resizable?: boolean; /** Render one cell. May return HTML. */ formatter?: (value: any, row: Row, index: number) => string; /** Inline editor for this column. */ editor?: 'text' | 'numberbox' | { type: 'text' | 'numberbox'; options?: { precision?: number } }; /** false disables the filter chip; a string forces the chip kind. */ filter?: false | 'text' | 'number' | 'enum' | 'bool'; } export interface GridFilter { /** Substring match against the rendered cell text. */ q?: string; /** Numeric lower bound. */ ge?: number; /** Exact match against the raw field. */ eq?: unknown; /** One of these rendered texts. */ in?: string[]; } export interface RowAction { /** Caption. Omit for an icon-only button, which takes its name from `title`. */ text?: string; iconCls?: string; title?: string; color?: ButtonColor; /** Defaults to true — a strip of solid buttons over a row is too loud. */ soft?: boolean; /** Re-evaluated for every row the strip lands on. */ disabled?: boolean | ((this: HTMLElement, row: Row, index: number) => boolean); handler?: (this: HTMLElement, row: Row, index: number) => void; } export interface DataGridOptions extends WidgetOptions { columns?: Column[] | Column[][] | null; data?: Row[] | { rows: Row[] } | null; url?: string; queryParams?: Record | null; loadFilter?: (payload: unknown) => unknown; idField?: string; pagination?: boolean; pageSize?: number; pageList?: number[]; searchable?: boolean; filterable?: boolean; rownumbers?: boolean; showHeader?: boolean; striped?: boolean; singleSelect?: boolean; /** A leading column of checkboxes. Implies multi-select unless singleSelect is set. */ checkbox?: boolean; /** Only the checkbox changes the selection; clicking the row does not. */ selectOnCheckbox?: boolean; /** Drag the right edge of a header cell to resize that column. Default true. */ resizable?: boolean; /** A column will not be dragged narrower than this. Default 48. */ minColWidth?: number; fit?: boolean; emptyMsg?: string; onLoadSuccess?: (this: HTMLElement, data: unknown) => void; onLoadError?: (this: HTMLElement, error: unknown) => void; /** Actions shown over a row, for a grid with no column to spare. */ rowActions?: RowAction[] | null; /** 'hover' — on hover and on the selected row. 'selected' — selection only. */ rowActionsOn?: 'hover' | 'selected'; onClickRow?: (this: HTMLElement, index: number, row: Row) => void; onDblClickRow?: (this: HTMLElement, index: number, row: Row) => void; onSelect?: (this: HTMLElement, index: number, row: Row) => void; onUnselect?: (this: HTMLElement, index: number, row: Row) => void; /** Every change to the selection, with the rows that are now selected. */ onSelectionChange?: (this: HTMLElement, rows: Row[]) => void; onResizeColumn?: (this: HTMLElement, field: string, width: number) => void; onSortChange?: (this: HTMLElement, field: string, order: 'asc' | 'desc') => void; onFilterChange?: (this: HTMLElement, filters: Record) => void; onEndEdit?: (this: HTMLElement, index: number, row: Row) => void; } export class DataGrid extends Widget { readonly cols: Column[]; loadData(payload: Row[] | { rows: Row[] } | { data: Row[] }): this; reload(arg?: string | { url: string; queryParams?: Record }): Promise; getRows(): Row[]; getData(): Row[]; getVisibleRows(): Row[]; getRowByIndex(index: number): Row | undefined; getSelected(): Row | null; /** Every selected row, including ones on other pages. */ getSelections(): Row[]; getSelectionCount(): number; isSelected(row: Row): boolean; selectRow(index: number): this; unselectRow(index: number): this; /** Every row the filters let through, not only the page on screen. */ selectAll(): this; unselectAll(): this; clearSelections(): this; sort(field: string, order?: 'asc' | 'desc'): this; setFilter(field: string, value: GridFilter | null): this; getFilters(): Record; clearFilters(): this; search(text: string): this; gotoPage(n: number): this; pageCount(): number; loading(): this; loaded(): this; resize(): this; beginEdit(index: number): this; endEdit(index: number): this; cancelEdit(): this; refreshRow(index: number): this; /** The row's ``, if that row is on the page being shown. */ rowElement(index: number): HTMLElement | null; /** Put the rowActions strip on a row; `hovering` paints it to match. */ showRowActions(tr: HTMLElement, hovering?: boolean): this; hideRowActions(): this; } /* ------------------------------------------------------------------ form */ export interface FormOptions extends WidgetOptions { markInvalid?: boolean; onValidate?: (this: HTMLElement, ok: boolean, values: Record) => boolean | void; onSubmit?: (this: HTMLElement, event: Event, values: Record) => void; } export class Form extends Widget { getValues(): Record; setValues(values: Record): this; clear(): this; validate(): boolean; } /* -------------------------------------------------------------- messager */ export interface MessageOptions { title?: string; msg?: string; /** Treat msg as HTML rather than text. */ html?: boolean; icon?: 'info' | 'error' | 'warn'; value?: string; fn?: (result: any) => void; } export interface ToastOptions { msg: string; type?: 'success' | 'error' | 'warn' | 'info'; timeout?: number; } export interface ToastHandle { close(): void; element: HTMLElement; } export function alert(options: MessageOptions): Promise; export function alert(title: string, msg?: string, fn?: (r: true) => void): Promise; export function confirm(options: MessageOptions): Promise; export function confirm(title: string, msg?: string, fn?: (r: boolean) => void): Promise; export function prompt(options: MessageOptions): Promise; export function prompt(title: string, msg?: string, fn?: (r: string | null) => void): Promise; export function toast(options: ToastOptions | string): ToastHandle; export const messager: { alert: typeof alert; confirm: typeof confirm; prompt: typeof prompt; toast: typeof toast; sweet: typeof sweet; show(options: MessageOptions & { buttons?: { text: string; primary?: boolean; result?: unknown }[]; input?: boolean }): Promise; }; /* ----------------------------------------------------------------- menu */ export interface MenuItem { text?: string; iconCls?: string; shortcut?: string; disabled?: boolean; checked?: boolean; separator?: boolean; items?: MenuItem[]; onClick?: (this: HTMLElement, item: MenuItem) => void; [key: string]: unknown; } export interface MenuOptions extends WidgetOptions { items?: MenuItem[]; minWidth?: number; onClick?: (this: HTMLElement, item: MenuItem) => void; onShow?: (this: HTMLElement) => void; onHide?: (this: HTMLElement) => void; } export class Menu extends Widget { showFor(anchor: Element, place?: { side?: 'right'; gap?: number }): this; showAt(x: number, y: number): this; /** Right-click inside `target` opens this menu at the pointer. */ bindContextMenu(target: Element | Document): this; hide(): this; isOpen(): boolean; setItems(items: MenuItem[]): this; panel(): HTMLElement; } export interface MenuBarOptions extends WidgetOptions { items?: { text?: string; iconCls?: string; items?: MenuItem[] }[]; onClick?: (this: HTMLElement, item: MenuItem, top: unknown) => void; } export class MenuBar extends Widget { openMenu(index: number): this; closeMenu(): this; getMenu(index: number): Menu | undefined; setItems(items: MenuBarOptions['items']): this; } /* ----------------------------------------------------------------- tree */ export interface TreeNode { id?: string | number; text?: string; iconCls?: string; children?: TreeNode[]; state?: 'open' | 'closed'; checked?: boolean; [key: string]: any; } export interface TreeOptions extends WidgetOptions { data?: TreeNode[] | null; url?: string; queryParams?: Record | null; idField?: string; textField?: string; childrenField?: string; checkbox?: boolean; cascadeCheck?: boolean; lines?: boolean; expandDepth?: number; formatter?: (node: TreeNode) => string; onClick?: (this: HTMLElement, node: TreeNode) => void; onDblClick?: (this: HTMLElement, node: TreeNode) => void; onSelect?: (this: HTMLElement, node: TreeNode) => void; onExpand?: (this: HTMLElement, node: TreeNode) => void; onCollapse?: (this: HTMLElement, node: TreeNode) => void; onCheck?: (this: HTMLElement, node: TreeNode, checked: boolean) => void; onLoadSuccess?: (this: HTMLElement, nodes: TreeNode[]) => void; onContextMenu?: (this: HTMLElement, event: MouseEvent, node: TreeNode) => void; } export class Tree extends Widget { loadData(data: TreeNode[] | { rows: TreeNode[] }): this; reload(url?: string): Promise; expand(node: TreeNode): Promise; collapse(node: TreeNode): this; toggle(node: TreeNode): this | Promise; expandAll(): this; collapseAll(): this; select(node: TreeNode | null): this; getSelected(): TreeNode | null; find(id: string | number): TreeNode | null; check(node: TreeNode, on?: boolean): this; getChecked(): TreeNode[]; walk(nodes: TreeNode[], fn: (node: TreeNode) => void): this; getData(): TreeNode[]; } /* --------------------------------------------------------------- ribbon */ export interface RibbonItem { text?: string; iconCls?: string; size?: 'large' | 'small'; disabled?: boolean; menu?: MenuItem[]; onClick?: (this: HTMLElement, item: RibbonItem) => void; } export interface RibbonTab { title?: string; groups?: { title?: string; items?: RibbonItem[] }[]; } export interface RibbonOptions extends WidgetOptions { tabs?: RibbonTab[]; selected?: number; collapsible?: boolean; collapsed?: boolean; onSelect?: (this: HTMLElement, index: number, tab: RibbonTab) => void; onClick?: (this: HTMLElement, item: RibbonItem | MenuItem, parent?: RibbonItem) => void; } export class Ribbon extends Widget { select(index: number): this; collapse(): this; expand(): this; toggleCollapse(): this; setTabs(tabs: RibbonTab[]): this; } /* ------------------------------------------------- progress, preloader */ export interface ProgressBarOptions extends WidgetOptions { value?: number; indeterminate?: boolean; text?: string | false; height?: number; type?: 'accent' | 'success' | 'warn' | 'danger'; striped?: boolean; onChange?: (this: HTMLElement, value: number, previous: number) => void; onComplete?: (this: HTMLElement) => void; } export class ProgressBar extends Widget { setValue(value: number): this; getValue(): number; indeterminate(on?: boolean): this; } export interface PreloaderOptions extends WidgetOptions { msg?: string; size?: 'sm' | 'md' | 'lg'; visible?: boolean; backdrop?: boolean; timeout?: number; onShow?: (this: HTMLElement) => void; onHide?: (this: HTMLElement) => void; } export class Preloader extends Widget { show(msg?: string): this; hide(): this; isVisible(): boolean; setMessage(msg: string): this; } /** Cover something while work happens; the returned function uncovers it. */ export function loading(target: Target, msg?: string): () => void; /* ------------------------------------------------------- alert, badge */ export interface AlertOptions extends WidgetOptions { type?: 'info' | 'success' | 'warn' | 'danger'; title?: string; msg?: string; html?: boolean; iconCls?: string | false; closable?: boolean; timeout?: number; onClose?: (this: HTMLElement) => void; } /** The inline banner. `Kob.alert()` is the modal dialog. */ export class Alert extends Widget { setMessage(msg: string): this; setType(type: AlertOptions['type']): this; close(): this; } export interface BadgeOptions extends WidgetOptions { text?: string | number; type?: 'muted' | 'accent' | 'success' | 'warn' | 'danger' | 'info'; dot?: boolean; max?: number; hideEmpty?: boolean; } export class Badge extends Widget { setText(text: string | number): this; getText(): string; setType(type: BadgeOptions['type']): this; increment(by?: number): this; } /* -------------------------------------------------------- filterbutton */ export interface FilterButtonOptions extends WidgetOptions { label?: string; kind?: 'enum' | 'text' | 'number'; options?: (string | { value: string | number; text?: string })[]; value?: string[] | string | number | null; multiple?: boolean; clearText?: string | false; onChange?: (this: HTMLElement, value: string[] | string | number | null) => void; } export class FilterButton extends Widget { getValue(): string[] | string | number | null; setValue(value: string[] | string | number | null): this; clear(): this; setOptions(options: FilterButtonOptions['options']): this; } /* ------------------------------------------------------------- desktop */ export interface DesktopApp { id: string; title?: string; iconCls?: string; /** Image URL, used instead of iconCls. */ icon?: string; /** Fetch this fragment into the window. */ href?: string; /** Or set the window body's HTML directly. */ content?: string; width?: number; height?: number; /** false allows more than one window of this app. */ single?: boolean; hidden?: boolean; onOpen?: (this: HTMLElement, win: Dialog, body: HTMLElement) => void; } export interface DesktopOptions extends WidgetOptions { apps?: DesktopApp[]; icons?: boolean; taskbar?: boolean; startLabel?: string | false; startExtras?: MenuItem[]; clock?: boolean; user?: { name?: string; iconCls?: string; onLogout?: (this: HTMLElement) => void }; wallpaper?: string; onOpen?: (this: HTMLElement, app: DesktopApp, win: Dialog) => void; onClose?: (this: HTMLElement, app: DesktopApp) => void; onFocus?: (this: HTMLElement, app: DesktopApp, win: Dialog) => void; } export class Desktop extends Widget { /** Launch an app, or focus the window it already has. */ open(id: string): Dialog | null; close(app: string | object): this; closeAll(): this; minimize(id: string): this; getRunning(): { app: DesktopApp; win: Dialog }[]; getWindow(id: string): Dialog | undefined; setApps(apps: DesktopApp[]): this; setWallpaper(value: string): this; } /* ----------------------------------------------------------------- popup */ export interface Popup { el: HTMLElement; isOpen(): boolean; open(): Popup; close(): Popup; showFor(anchor: Element, place?: { side?: 'right'; gap?: number }): Popup; showAt(x: number, y: number): Popup; destroy(): void; } export function createPopup(options?: { className?: string; onClose?: () => void }): Popup; export function closeAllPopups(): void; /* --------------------------------------------------------------- factory */ /** Create the widget, or return the existing one; passing options updates it. */ export interface WidgetFactory { (target: Target, options?: Partial): W; Widget: new (el: HTMLElement, options?: Partial) => W; defaults: O; } export const textbox: WidgetFactory; export const passwordbox: WidgetFactory; export const numberbox: WidgetFactory; export const combobox: WidgetFactory; export const datebox: WidgetFactory; export const checkbox: WidgetFactory; export const searchbox: WidgetFactory; export const filebox: WidgetFactory; export const linkbutton: WidgetFactory; export const panel: WidgetFactory; export const layout: WidgetFactory; export const tabs: WidgetFactory; export const stepper: WidgetFactory; export const dialog: WidgetFactory; export const datagrid: WidgetFactory; export const form: WidgetFactory; export const menu: WidgetFactory; export const menubar: WidgetFactory; export const tree: WidgetFactory; export const ribbon: WidgetFactory; export const progressbar: WidgetFactory; export const preloader: WidgetFactory; export const badge: WidgetFactory; export const filterbutton: WidgetFactory; export const desktop: WidgetFactory; /** The inline banner widget. Kob.alert() is the modal dialog. */ export const alertbox: WidgetFactory; export type SweetType = 'success' | 'error' | 'warn' | 'info' | 'question'; export interface SweetOptions { type?: SweetType | ''; title?: string; msg?: string; /** Treat msg as markup. Only pass markup you produced yourself. */ html?: boolean; confirmText?: string; cancelText?: string; showCancel?: boolean; /** Paint the confirm button as destructive. */ danger?: boolean; /** Auto-dismiss after n milliseconds, resolving false. */ timer?: number; closeOnBackdrop?: boolean; fn?: (answer: boolean) => void; } export interface Sweet { /** * The big centred confirmation. Resolves true for confirm and false for * cancel, Escape, backdrop or timer — so `if (await Kob.sweet(…))` is * always safe to write. */ (options: SweetOptions | string): Promise; success(title: string | SweetOptions, msg?: string): Promise; error(title: string | SweetOptions, msg?: string): Promise; warn(title: string | SweetOptions, msg?: string): Promise; info(title: string | SweetOptions, msg?: string): Promise; question(title: string | SweetOptions, msg?: string): Promise; } export const sweet: Sweet; /** dialog() with modal forced on. */ export function modal(target: Target, options?: Partial): Dialog; /** dialog() with modal forced off — a draggable application window. */ export function windowed(target: Target, options?: Partial): Dialog; /* ----------------------------------------------------------------- misc */ export const version: string; export const config: RequestConfig; export const locales: Record; /** Build every widget declared in the markup under `root`. */ export function parse(root?: Element | Document): Widget[]; /** Destroy every widget under `root`. */ export function unparse(root?: Element | Document): void; /** parse(document) now, or as soon as the document is ready. */ export function autoParse(): void; /** Read the inline data-options syntax. */ export function parseOptions(str: string): Record; /** Switch locale, or read the current one. */ export function locale(name?: string): string; /** Register a locale; missing keys fall back to English. */ export function addLocale(name: string, bundle: Partial): LocaleBundle; /** Look up a locale string, interpolating {placeholders}. */ export function t(key: string, vars?: Record): string; /** Next z-index for a floating layer. */ export function zIndex(): number; /** The widget of that name living on an element, if any. */ export function getInstance(el: Element, name: string): Widget | undefined; /** Register your own widget class. */ export function register( klass: new (el: HTMLElement, options?: Partial) => W ): WidgetFactory; export const registry: Map; declare const Kob: { version: string; textbox: typeof textbox; passwordbox: typeof passwordbox; numberbox: typeof numberbox; combobox: typeof combobox; datebox: typeof datebox; checkbox: typeof checkbox; searchbox: typeof searchbox; filebox: typeof filebox; linkbutton: typeof linkbutton; panel: typeof panel; layout: typeof layout; tabs: typeof tabs; stepper: typeof stepper; dialog: typeof dialog; datagrid: typeof datagrid; form: typeof form; menu: typeof menu; menubar: typeof menubar; tree: typeof tree; ribbon: typeof ribbon; progressbar: typeof progressbar; preloader: typeof preloader; loading: typeof loading; badge: typeof badge; filterbutton: typeof filterbutton; desktop: typeof desktop; alertbox: typeof alertbox; modal: typeof modal; window: typeof windowed; popup: typeof createPopup; closePopups: typeof closeAllPopups; messager: typeof messager; alert: typeof alert; confirm: typeof confirm; prompt: typeof prompt; toast: typeof toast; parse: typeof parse; unparse: typeof unparse; autoParse: typeof autoParse; parseOptions: typeof parseOptions; config: RequestConfig; locale: typeof locale; addLocale: typeof addLocale; locales: typeof locales; t: typeof t; zIndex: typeof zIndex; Widget: typeof Widget; register: typeof register; registry: typeof registry; instance: typeof getInstance; dom: Record; date: Record; classes: Record; }; export default Kob; ```