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.

$npm i kob-ui

Upgrading

Already installed? Upgrade with npm i kob-ui@latest. npm update will not do it: npm saves the package as ^0.x.y, and while the version starts 0. that range only accepts patch releases, so a new minor with new widgets is never picked up. What is new in each release is in the changelog that ships in the package, node_modules/kob-ui/CHANGELOG.md.

If an AI assistant writes your code, it reads this site, which always describes the latest release. When it reaches for a widget or option your project does not have, compare node_modules/kob-ui/package.json with the version at the top of llms.txt — the answer is almost always to upgrade.

Script tag

<link rel="stylesheet" href="node_modules/kob-ui/dist/kob-ui.css"> <script src="node_modules/kob-ui/dist/kob-ui.min.js"></script> <script> Kob.datagrid('#grid', { columns, data }); </script>

Bundler

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

FileFormatUse it for
dist/kob-ui.min.jsIIFEA plain <script> tag. Defines window.Kob.
dist/kob-ui.jsIIFEThe same, unminified, for debugging.
dist/kob-ui.esm.jsESMimport in a bundler.
dist/kob-ui.cjsCommonJSrequire().
dist/kob-ui.cssCSSThe whole stylesheet, icons included.
types/index.d.tsTypeScriptPicked up automatically.

Browsers. Chrome/Edge 80+, Firefox 78+, Safari 14+. No polyfills required.

Creating widgets

Every widget has one accessor. Call it with options to build the widget; call it again with nothing to get the same instance back.

// Build it. The target may be a selector string or an element. const grid = Kob.datagrid('#orders', { columns, url: '/api/orders' }); // Anywhere else, later — same object, no bookkeeping. Kob.datagrid('#orders').reload(); // Passing options to an existing widget merges them and refreshes it. Kob.datagrid('#orders', { pageSize: 100 }); // Or keep it explicit. Kob.instance(document.querySelector('#orders'), 'datagrid');

Methods return the instance where there is nothing else to return, so they chain: grid.search('bangkok').gotoPage(1).

Events

Callbacks are options, named onSomething. Inside one, this is the widget's element.

Kob.datagrid('#orders', { onClickRow: (index, row) => select(row), onLoadSuccess: function () { console.log(this.id, 'loaded'); } });

Cleanup

destroy() unbinds listeners and puts the DOM back roughly as it was. Kob.unparse(root) does it for everything under an element — worth calling before you tear a screen out of the page.

Markup and data-options

A widget can also be declared in HTML. Kob.parse(root) walks the markup and builds anything whose class names a widget.

<input class="kob-textbox" data-options="label:'Name',labelWidth:110,required:true"> <div class="kob-panel" data-options="title:'Summary',collapsible:true">…</div>
Kob.parse(document); // build everything on the page Kob.parse(myFragment); // …or just what you injected Kob.autoParse(); // build on DOMContentLoaded

Parsing twice is safe: an element that already carries a widget is skipped, so you can call it again after injecting HTML.

The two can be combined. Declare the layout in markup, then hand the real configuration over from JavaScript — the widget merges it and rebuilds. That is how a datagrid declared as class="kob-datagrid" picks up its columns later.

What data-options accepts

Quoted strings, numbers, true, false and null. Functions, formatters and column arrays have no sensible text form, so pass those from JavaScript.

Loading remote data

combobox and datagrid both take a url. Everything they fetch goes through one function, so authentication and response shapes are configured once for the whole library.

Kob.config.request = async (url, params) => { const query = new URLSearchParams(params ?? {}); const res = await fetch(`${url}?${query}`, { headers: { Authorization: `Bearer ${token}` } }); if (res.status === 401) { location.href = '/login'; return []; } const payload = await res.json(); if (!payload.success) { throw new Error(payload.message); } return payload.data; // an array, or { rows, total } }; // Called whenever the above rejects. Kob.config.onRequestError = (err, url) => reportToSentry(err, url);

A single widget can still reshape its own payload with loadFilter.

Theming

Nothing in the stylesheet hard-codes a colour. Redefine the tokens on :root — or on any container — and everything inside follows.

:root { --kob-accent: #6d3bd4; --kob-accent-dark: #4c2597; --kob-accent-soft: rgba(109, 59, 212, 0.09); --kob-radius: 14px; }
TokenUsed for
--kob-accentPrimary buttons, selection, focus rings, active tabs
--kob-accent-darkHeader text, sorted column, emphasis on the accent
--kob-accent-softHeaders, hovers, striped rows, chip backgrounds
--kob-accent-hoverThe stronger hover state
--kob-surface / --kob-surface-2Panels, windows, dropdowns
--kob-bgThe ground the widgets sit on
--kob-text / --kob-mutedBody text, secondary text
--kob-border / --kob-border-softOutlines, table rules
--kob-danger / --kob-success / --kob-warn / --kob-infoStatus colours and badges
--kob-radius / --kob-radius-sm / --kob-radius-xsCorner rounding
--kob-shadow-1 / -2 / -3Cards, dropdowns, windows
--kob-scrollbar-thumb / -hover / -track / -sizeScrollbars — see below

Scrollbars

The browser's own scrollbars are a fixed grey that reads as a foreign object on a dark surface, so every box the library scrolls gets one painted from the tokens instead — vertical, horizontal, and the corner where the two meet. Anything scrolling inside a window, panel or layout matches too.

To carry the same treatment to the rest of the page, put the class on the root element:

<html class="kob-scrollbars">

A region that keeps its own colours — a dark code panel on a light page, say — just redefines the tokens locally:

.code { --kob-scrollbar-thumb: rgba(207, 228, 236, 0.26); --kob-scrollbar-thumb-hover: rgba(207, 228, 236, 0.45); }

The five themes

Five palettes ship. Leave the attribute off and the library follows the operating system, choosing between light and dark; set it and that choice is pinned.

ValueWhat it looks like
lightThe default. Cool greys on white.
darkBlue-grey night theme, surfaces from #0b1e27 up.
black Neutral near-black with no colour cast — surfaces from #0f1011, and the header and hover washes are white at low alpha rather than a tinted accent, so nothing goes blue. Deliberately not #000: pure black under near-white text is the highest contrast a panel can make, which reads as glare in a dim room.
navy Haze grey and olive drab with khaki markings, taken from a naval equipment panel, for screens that should look military-issue — a haze grey page from #2b2f31 under cards, panels, windows and fields in a dulled olive drab, like switch housings on a panel. Khaki is the accent and the header text, as the stencilled labels are; status colours are instrument lamps. The OS preference never picks it — set it to get it.
vintage Charcoal housings with amber readouts, taken from a rack of old radio and studio gear — surfaces from #242424. Amber is the accent and glows faintly under every header; everything that is not a readout stays grey, and the status colours are the rack's lamps, with warn pushed toward yellow so it never reads as the accent. Like navy, it is only ever pinned.
<html data-kob-theme="black"> <!-- or "vintage", "navy", "dark", or "light" -->

Switching at runtime is one attribute. Remembering the choice is on you — the library never writes to storage:

var THEMES = ['light', 'dark', 'black', 'navy', 'vintage']; function nextTheme() { var here = document.documentElement.getAttribute('data-kob-theme') || 'light'; var next = THEMES[(THEMES.indexOf(here) + 1) % THEMES.length]; document.documentElement.setAttribute('data-kob-theme', next); localStorage.setItem('theme', next); } // Restore before the first paint, or the page flashes the wrong theme. var saved = localStorage.getItem('theme'); if (saved) { document.documentElement.setAttribute('data-kob-theme', saved); }

A theme of your own is one more block. Only the tokens that differ need redefining — everything else falls through to the light values:

:root[data-kob-theme="sepia"] { --kob-bg: #f4ecdf; --kob-surface: #fbf6ec; --kob-text: #3b3026; --kob-accent: #8a5a2b; }

Localisation

Every string the widgets show, plus the date format and calendar era, comes from the active locale. English and Thai are built in.

Kob.locale('th'); // switch Kob.locale(); // read the current one Kob.t('showing', { from: 1, to: 20, total: 93 }); Kob.addLocale('de', { ok: 'OK', cancel: 'Abbrechen', today: 'Heute', months: ['Januar', 'Februar', /* … */], weekdaysShort: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'], firstDay: 1, dateFormat: 'dd.mm.yyyy' }); Kob.locale('de'); // gaps fall back to English

Dates stay ISO. A Thai locale shows 09/03/2567 on screen, but datebox.getValue() still returns '2024-03-09'. The calendar in use never changes what you send to the server.

The datebox example further down switches between English and Thai live, and reports both what is on screen and what getValue() returns.

Icons

181 single-colour SVGs applied as a CSS mask. They take the element's color, live inside the stylesheet, and cost no requests.

<a class="kob-linkbutton icon-add"><span class="kob-icon"></span>Add</a>
Kob.linkbutton('#btn', { iconCls: 'icon-add', text: 'Add' });

The full set — click any icon to copy its class name:

Your own artwork works the same way, without touching the library — point the mask at your SVG:

.icon-invoice .kob-icon { -webkit-mask-image: url(/img/invoice.svg); mask-image: url(/img/invoice.svg); }

textbox, numberbox, passwordbox

Three thin wrappers over <input>: an optional label, a required flag, and for numbers, clamping and formatting.

Options

OptionTypeDefaultDescription
labelstring''Draws a label next to the field. Omit for a bare input.
labelWidthnumber110Label width in pixels when it sits to the left.
labelPosition'left' | 'top'autoDefaults to top on touch screens, left otherwise.
promptstring''Placeholder text.
valueany—Initial value.
requiredbooleanfalseChecked by isValid() and by the containing form.
readonly / disabledboolean—Applied to the underlying input.
min / maxnumber—numberbox: clamped on blur.
precisionnumber—numberbox: decimal places applied on blur.
groupSeparatorstring''numberbox: thousands separator shown while unfocused.
onChangefunction(value)—Fires on the input's native change.

Methods

MethodReturnsDescription
getValue()string | numbernumberbox returns a number, or '' when empty.
setValue(v)thisnumberbox also clamps and formats.
clear()thisEmpties the field and drops the invalid mark.
isValid()booleanWhether required is satisfied.
enable() / disable()this 
readonly(mode)this 
focus()this 

textbox: masks, addons and counters

The furniture a plain input grows once it is in a real form: a shape the typing has to take, a currency in front of it, a count underneath.

OptionTypeDefaultDescription
maskstring''A pattern of placeholders and literals: 9 a digit, A a letter, * either, # an optional digit. Everything else is written in for the user.
maskPromptstring'_'Character the mask is shown with in the placeholder. '' shows none.
prefix / suffixstring''Small text in front of or after the field — a currency, a unit, a domain.
clearablebooleanfalseA button that empties it, shown once there is something in it.
counterbooleanfalse"12/140" under the field.
maxLengthnumber0Caps the length, and gives the counter something to count against.
autosizebooleanfalseA <textarea> that grows with what is typed into it.

The mask types its own literals. Somebody entering a phone number types ten digits and the dashes appear; making them type the dash is how a masked field ends up slower than an unmasked one. A value pasted with its own punctuation is reshaped to the mask, and a half-typed value does not grow a trailing separator.

getValue() returns the masked text — what the user sees, what they would copy, and what a validation message has to be about. getRaw() is the characters underneath, for the API call that wants 0812345678.

combobox

A text input backed by a list. The value the form submits and the text the user reads are kept apart, so an id column never leaks onto screen.

Read-only. readonly: true (or .readonly(true)) is understood by combobox, datebox, datetimebox, monthbox, yearbox, timebox, tagbox, colorpicker, combogrid and combotree: the value is shown and can be selected and copied, the list or calendar will not open, and chips cannot be removed. It sets aria-readonly. disabled given as an option is applied the same way. checkbox, radiobox, slider, filebox and daterangebox take the same option.

Options

OptionTypeDefaultDescription
dataobject[]nullRows to show.
urlstring''Fetched through Kob.config.request.
queryParamsobjectnullSent with the request.
valueFieldstring'id'Field getValue() returns.
textFieldstring'text'Field shown in the box.
formatterfunction(row)—HTML for one list item. The box shows its plain text.
multiplebooleanfalsePick several: getValue() returns an array and the chosen rows show as chips. See Picking several.
editablebooleantruefalse means pick from the list only.
panelHeightnumber300Maximum dropdown height in pixels.
limitnumber300Items rendered at once; the rest are reachable by typing.
label / labelWidth / prompt / required——As for textbox.
onChangefunction(value, row)—After a pick.
onSelectfunction(row)—The row that was clicked.
onLoadSuccessfunction(rows)—After loadData or a fetch.

Methods

MethodReturnsDescription
getValue() / setValue(v)any / thisThe valueField value.
getText()stringWhat the user reads.
loadData(rows)thisSwap the list without touching the value.
reload(url?)PromiseFetch again, optionally from a new URL.
getData()object[]The current list.
clear()this 

Keyboard: arrows move through the list, Enter picks, Escape closes. The input is announced as a combobox and the list as a listbox, with the highlighted row named by aria-activedescendant.

Picking several

multiple: true turns the field into a list. The chosen rows become chips in front of the typing area, getValue() returns an array — and takes one, or a comma-separated string — clicking a row again takes it off, and Backspace in an empty box removes the last chip, which is the gesture every tag field has.

combobox: searching, grouping, and adding

Three options that decide whether a combobox can be pointed at a table of forty thousand rows or only at a list of twelve.

OptionTypeDefaultDescription
mode'local' | 'remote''local''local' holds the whole list and filters it as you type — right for the few hundred rows a lookup table usually is. 'remote' sends what was typed and shows what comes back.
minCharsnumber1Remote only: say nothing until this many characters are typed.
delaynumber300Remote only: wait this long after the last keystroke. One request per keystroke is a denial of service on your own API.
groupFieldstring''Rows with the same value here are drawn under one heading, in the order they were given.
allowCreatebooleanfalseOffer what was typed as a new item when nothing matches it exactly.
onCreatefunction(text)—Make the row — where an application saves it and hands back the row the server made, with its real id. Returning nothing uses the text itself; returning false refuses.

A remote field loads nothing on the way up and does not filter what comes back: the server already answered the question, and filtering its answer again would hide rows it matched for reasons of its own — a customer found by phone number, an item found by barcode. Answers to questions that are no longer the current one are dropped, because three keystrokes are three requests and the slowest must not win.

For a lookup that needs more than one line per row, see combogrid; for one out of a hierarchy, see combotree.

datebox

A text field with a month calendar. It reads and writes ISO dates whatever the locale displays.

Options

OptionTypeDefaultDescription
valuestring | Date—ISO, a Date, or a string in the display format.
formatstringlocaleOverrides the locale's format, from yyyy, yy, mm, dd.
min / maxstring''ISO bounds; days outside them are disabled.
showTodaybooleantrueThe shortcut under the calendar.
label / labelWidth / prompt / required——As for textbox.
onChange / onSelectfunction(iso)—Both receive the ISO date.

Methods

MethodReturnsDescription
getValue()stringISO yyyy-mm-dd, or ''.
setValue(v)thisAccepts ISO, a Date, or the display format.
getText()stringWhat is on screen, e.g. 09/03/2567.
clear()this 

monthbox and yearbox

A period rather than a day. A payroll run, a VAT return, a sales report, a meter reading: the answer is "March 2026", and a datebox would make somebody pick a day that is then ignored. getValue() is 'yyyy-mm' — or 'yyyy' for yearbox — in the Gregorian calendar, whatever the locale shows.

Options

OptionTypeDefaultDescription
valuestring | Date—'yyyy-mm' (yearbox: 'yyyy'), a Date, or text the way the field shows it.
formatstring'MMMM yyyy'How the field shows it. Tokens: yyyy, yy, mm, m, MMMM (the month's name), MMM (its short name). yearbox takes yyyy and yy.
min / maxstring'''yyyy-mm' (yearbox: 'yyyy'). Periods outside are disabled. A full ISO date is cut to fit.
showCurrentbooleantrueThe "This month" / "This year" button under the grid.
label / labelWidth / prompt / required / rules / message——As for textbox; rules see the ISO value. See Validation.
onChange / onSelectfunction(value)—Both receive 'yyyy-mm' (yearbox: 'yyyy'). onChange fires once per actual change — a pick, or typed text on blur or Enter — never for setValue().

Methods

MethodReturnsDescription
getValue()string'yyyy-mm' (yearbox: 'yyyy'), or ''.
setValue(v)thisAccepts the ISO form, a Date, or the display text.
getText()stringWhat is on screen, e.g. มีนาคม 2569.
getRange(){ from, to } | nullThe first and last ISO day of the period — { from: '2026-02-01', to: '2026-02-28' } — which is what a report query wants.
open() / close()thisThe picker panel.
clear() / isValid() / validationMessage()—As for the other fields.
enable() / disable()this 
Typing works, and forgivingly: 3/69, 03/2569, มี.ค. 2569, March 2026 and 2026-03 all mean March 2026. A four-digit year above 2400 is read as the Buddhist era whatever the locale; a two-digit year is this century in the locale's own era, so 69 is 2569 in Thai and 2069 in English. On blur the text is rewritten in the display format, so the user sees what was understood.

checkbox

A real <input type="checkbox"> drawn as a switch, so forms submit it and getValue() is simply its checked state.

OptionTypeDefaultDescription
labelstring''Caption beside the switch; also its accessible name.
checkedboolean—Initial state.
disabledboolean— 
readonlyboolean—Shown as it is; a click or the space bar does not change it.
onChangefunction(checked)— 

Methods: getValue(), setValue(v), check(), uncheck(), toggle(), enable(), disable().

filebox

A drop zone and a file list. Drag files onto it or click to browse; it checks them against accept, maxSize and maxFiles, lists what survived, and — if you give it a url — uploads each one with a progress bar.

<div class="kob-filebox" data-options="accept:'.pdf,.png',maxSize:2000000"></div>
var box = Kob.filebox('#docs', { accept: '.pdf,image/*', maxSize: 2 * 1024 * 1024, url: '/api/upload', fieldName: 'document', data: { tenantId: 42 }, onProgress: function (file, percent) { … }, onComplete: function (ok, failed) { … } }); box.upload(); // returns a Promise<{ ok, failed }>
Leave url out and the widget never touches the network: it collects files and getFiles() hands them to your own FormData, which is what you want when the files ride along with the rest of a form.
OptionTypeDefaultWhat it does
multiplebooleantrueAccept more than one file.
acceptstring''Extensions and MIME patterns: '.pdf,image/*'.
maxSizenumber0Bytes per file. 0 means no limit.
maxFilesnumber0How many may be queued at once.
urlstring''POST target. Empty leaves it a picker.
fieldNamestring'file'Form field name each file is sent under.
dataobjectnullExtra form fields sent with every file.
headersobjectnullRequest headers, e.g. an Authorization token.
autoUploadbooleanfalseUpload as soon as a file is added.
readonlybooleanfalseLists the files but takes none by click or drop, and lets none be removed. add() from code still works.
text / hintstring''Drop-zone caption and sub-line. Both default to the locale strings.
onAdd / onRemovefunctionnull(file).
onProgressfunctionnull(file, percent) while uploading.
onSuccessfunctionnull(file, response) — parsed as JSON when it is JSON.
onErrorfunctionnull(file, message) — also fires for a file the rules turned away.
onCompletefunctionnull(ok, failed) once every queued file has finished.

Methods: add(files), remove(index), clear(), getFiles(), getItems() (each with its status and percent), upload(), enable() / disable().

Uploads go through XMLHttpRequest rather than fetch, because fetch still cannot report upload progress — and the progress bar is most of the point of a file field. One request per file, so a single failure does not lose the batch.

linkbutton

Turns an <a> or <button> into a toolbar button with an icon.

Colours

Five colours, in two weights. Solid is for the one action a screen is really about — Save, Delete. Soft is the same meaning at a fraction of the weight, for the row of secondary actions beside it, so a toolbar of six buttons does not end up shouting six things at once.

<a class="kob-linkbutton kob-linkbutton-danger">Delete</a> <a class="kob-linkbutton kob-linkbutton-danger kob-linkbutton-soft">Delete</a>
Kob.linkbutton('#save', { text: 'Save', iconCls: 'icon-save', color: 'accent' }); Kob.linkbutton('#del', { text: 'Delete', color: 'danger', soft: true });
colorClassFor
'accent'kob-linkbutton-accentThe main action. primary: true is the older spelling of the same thing.
'success'kob-linkbutton-successApprove, confirm, mark done.
'danger'kob-linkbutton-dangerDelete, and anything else that cannot be undone.
'warn'kob-linkbutton-warnVoid, reverse, override.
'info'kob-linkbutton-infoSend, export, anything informational.

Add soft: true or kob-linkbutton-soft for the tonal version. The same five work on a plain <button class="kob-btn kob-btn-danger"> with no JavaScript at all.

Each colour is a pair of tokens — --kob-danger-solid for the fill and --kob-on-danger for the label — rather than the plain --kob-danger, which is tuned to read as coloured text on the page and goes pale in a dark theme. Soft buttons use a third, --kob-danger-text. Every pair clears 4.5:1 in all five themes.
OptionTypeDefaultDescription
textstring—Caption. Omit to keep what is already in the tag.
iconClsstring''e.g. 'icon-save'. See Icons.
iconAlign'left' | 'right' | 'top' | 'bottom''left'Which side of the caption the icon sits on. The two stacked values put it above or below — see Icon and caption.
size'' | 'sm' | 'lg'''The middle size is the default. 'lg' grows the icon more than the label.
color'' | 'accent' | 'success' | 'danger' | 'warn' | 'info'''See Colours above.
softbooleanfalseThe tonal weight of color.
primarybooleanfalsePaints it in the accent colour. The older spelling of color: 'accent'.
plainbooleanfalseNo border or background until hovered.
togglebooleanfalseBehaves as a pressable toggle.
selectedbooleanfalseToggle state.
disabledbooleanfalse 
readonlybooleanfalseShows the value; the pointer and the arrow keys do not move it (Tab still leaves).
onClickfunction(event)— 

Methods: setText(s), getText(), setIcon(cls), toggle(on?), isSelected(), enable(), disable().

Icon and caption

iconAlign puts the icon on any of the four sides. 'left' and 'right' are the shapes a form wants; 'top' and 'bottom' stack the two into a column, which is the button a desktop toolbar has been made of for thirty years — a large glyph, its name underneath. A caption too long for the column wraps under the glyph rather than stretching the strip sideways.

size takes 'sm' and 'lg', with the middle size as the default. Stacked and large together is the toolbar shape, and there the extra size goes into the glyph rather than the label: a toolbar button is found by its picture and confirmed by its name.

// The toolbar shape. Kob.linkbutton('#new', { text: 'New', iconCls: 'icon-add', iconAlign: 'top', size: 'lg' }); // A row of settings at the far end of the same strip. Kob.linkbutton('#reload', { iconCls: 'icon-reload', title: 'Reload', size: 'sm' });
<!-- the same two, written as markup --> <a class="kob-linkbutton kob-icon-top kob-linkbutton-lg icon-add"> <span class="kob-icon"></span>New </a> <a class="kob-linkbutton kob-linkbutton-sm kob-icon-only icon-reload" title="Reload"> <span class="kob-icon"></span> </a>
ClassOptionWhat it does
kob-icon-righticonAlign: 'right'Icon after the caption.
kob-icon-topiconAlign: 'top'Icon above it, in a column.
kob-icon-bottomiconAlign: 'bottom'Icon below it.
kob-linkbutton-smsize: 'sm'Smaller text, a 14px glyph.
kob-linkbutton-lgsize: 'lg'Larger text, a 20px glyph — 24px once stacked.
kob-icon-only—Squares up a button with no caption. Give it a title: a button with no words needs one to be usable at all.

A toolbar

There is no toolbar widget, and none is needed: the strip is kob-toolbar, a flex row with a gap, and two helpers — kob-sep draws a divider between groups of commands, kob-spacer pushes everything after it to the far end. What goes in it are ordinary buttons, so the behaviour every toolbar actually needs — Delete greyed out until something is selected — is enable() and disable() on the button itself.

If you want the strip to carry named groups as well — Clipboard, Font, the caption under a block of commands — that is a ribbon, and it is already here. A toolbar is the flat version of the same idea.

form

Collects every kob-ui field inside a <form> so you can read, fill, validate and clear them in one call — whatever mix of widgets they are.

Loading a record, and saving it

Every record screen was writing the same four steps by hand: fetch, unwrap, fill, and remember to mark the form clean afterwards — that last one being what everybody forgets, which is how a form that has only been loaded ends up warning about unsaved changes.

MethodReturnsDescription
getValues()objectKeyed by each field's name, read through its widget.
setValues(obj)thisFills matching fields; unknown keys are ignored.
validate()booleanMarks offenders with .kob-invalid and focuses the first.
clear()thisEmpties every field and drops the marks.
load(url?, params?)PromiseFetch a record and fill the form with it — and mark it clean afterwards, which is the step everybody forgets and the reason a form that has only been loaded warns about unsaved changes.
submit(opts?)PromiseValidate — async rules included — then post the values as JSON. Resolves with the response, or null when validation refused; a failed request rejects, since that one is not the user's doing.
validateAsync()PromiseValidate, waiting on the fields whose rules have to ask a server.
isDirty() / setDirty(v) / markClean()boolean / thisWhether anything has been typed since the form was last marked clean.
hasAsyncRules()booleanTrue when any field carries a rule that has to ask something.
OptionTypeDescription
markInvalidbooleanAdd .kob-invalid to failing fields. Default true.
onValidatefunction(ok, values)Return false to fail validation with your own rule.
onSubmitfunction(event, values)Runs after validation passes on a real submit.
url / queryParams / loadFilterstring / object / functionFetch a record and fill the form with it on the way up.
action / methodstringWhere submit() posts, and how. Left empty, submit() only validates and hands the values to onSubmit — which is what a form inside a dialog wants.
trackbooleanWatch for changes, so isDirty() means something. Default true.
warnUnsavedbooleanWarn before leaving the page with unsaved changes. Off by default: a page that argues with the back button had better have a reason, and only the application knows whether this form is one.
onLoadSuccess / onLoadErrorfunction(values) / (error).
onSubmitSuccess / onSubmitErrorfunction(response, values) / (error, values).
onDirtyChangefunction(dirty)The first time the form becomes dirty, and when it is marked clean.

Validation

required answers one question about a field. Everything else it has to satisfy — an address that looks like one, a code in a format, a quantity inside a bound, a password typed twice — is a rules option away, on every field widget there is.

Writing a rule

A rule is one of three things, and rules takes one or a list:

rules: 'email' // a named rule rules: { minLength: 3 } // a named rule with a parameter rules: function (v) { // your own: true, or the message return v !== 'admin' || 'Reserved name'; } rules: ['email', { maxLength: 120 }] // checked in order; the first to fail speaks

The named rules

RuleParameterPasses when
email—It looks like an address.
url—It looks like a web address, with or without the scheme.
number—It reads as a number.
integer—A whole number, negative allowed.
digits—Digits and nothing else — a phone number, a tax id.
minLength / maxLength / lengthnumberCharacters, counted.
min / maxnumberThe value as a number is inside the bound.
patternRegExp | stringIt matches.
equalToselector | elementIt is the same as the other field — read through that field's widget, so a numberbox compares numbers and a datebox compares ISO dates.

An empty field passes every rule but required. Otherwise "optional, but an email address if it is filled in" — by far the commonest shape a field takes — could not be said at all. And a rule name nobody registered throws rather than quietly passing: a field that looks validated and never is would be a worse bug than a broken page.

Where the message comes from, and where it goes

Each named rule carries its own sentence, from the locale bundle, so Kob.locale('th') changes what a failing field says along with everything else. message on the field overrides whichever rule failed, which is what you want when a field has one rule and a better sentence to say about it.

The message is written under the whole field — under the label wrapper, not between the label and the input — and the control is given aria-invalid and an aria-describedby pointing at it. A red border says that something is wrong; it never says what.

When it runs

Option / callWhat happens
validateOn: 'blur'The default: a field with rules checks itself as the user leaves it.
validateOn: 'input'On every keystroke. Loud; save it for a field with a live counter.
validateOn: ''Only when something asks.
widget.isValid()Checks, shows or clears the message, returns a boolean.
widget.validationMessage()The reason, or '' — without complaining on screen.
form.validate()Every field at once; focuses the first that is wrong.

A field that has no rules behaves as it always did — marked when the form is submitted, not while it is being filled in — so nothing about an existing form changes until you give one a rule.

A rule of your own

Kob.addRule('sku', function (v) { return /^[A-Z]{2}-\d{4}$/.test(v); }, 'Two letters, a dash, four digits'); Kob.textbox('#code', { rules: 'sku' });

radiobox

One choice out of several. The widget mounts on the box that holds the options rather than on an input, because a single radio button means nothing on its own — the group is what a form reads, validates and fills in.

Options

OptionTypeDefaultDescription
itemsarraynull[{ value, text, disabled }]. Left out, the radios already in the element are adopted.
namestringgeneratedThe form field name every option shares.
valuestring—Which option is chosen.
direction'horizontal' | 'vertical''horizontal'Down the page when the options are sentences rather than words.
labelstring''A caption over the group, and its accessible name.
required / rules / message——As every other field. See Validation.
disabledboolean—Applies to every option.
readonlyboolean—The choice is shown and cannot be changed. readonly(mode) toggles it.
onChangefunction(value)— 

Methods: getValue(), setValue(v), setItems(items), clear(), enable(), disable(), isValid(), validationMessage().

Real <input type="radio"> elements go into the page, so the group submits with a plain form, the arrow keys move between the options for free, and a screen reader announces it as the radio group it is. Leave items out and the radios you already wrote are adopted rather than replaced — destroy() hands them back as it found them.

timebox, datetimebox and daterangebox

The three fields a scheduling or reporting screen needs and a calendar alone cannot give it: a time of day, a timestamp, and a period with two ends that hold each other in order.

timebox

A list of times at a sensible step, because a free-text time field is a small disaster in a form somebody fills in fifty times a day — everyone writes it differently and someone has to parse it afterwards. Typing still works, and forgivingly: 9, 905, 9:05 pm, 21.5 and 2130 all mean something. What leaves through getValue() is always 'HH:mm' on a 24-hour clock.

OptionTypeDefaultDescription
stepnumber30Minutes between the times on the list.
min / max'HH:mm'''Bounds the list, and clamps what was typed when the field is left.
hour12booleanfalseShow and accept a 12-hour clock. getValue() stays 24-hour.
required / rules / message——See Validation.

Methods: getValue(), setValue(v), getText(), clear(), focus(), enable(), disable(), isValid().

datetimebox

The datebox calendar with a time strip under it, so the day and the time are one gesture and one field rather than two that have to be recombined and kept in step by hand. Every datebox option applies, plus step, hour12 and defaultTime — what a day picked with no time yet means.

getValue() returns 'yyyy-mm-dd HH:mm': the ISO date this library speaks everywhere, a space, and a 24-hour clock. Deliberately not an ISO instant with a Z on the end — these fields record a wall-clock time in the user's own day, and stamping a timezone on that is how a viewing at 14:00 becomes a viewing at 07:00 for somebody else.

daterangebox

From one date to another, as one field. It mounts on a box rather than an input, since two inputs go inside it, and it does the three things every application was writing by hand: it stops the end being earlier than the start — the calendars simply will not offer those days — it carries the shortcuts a reporting screen reaches for, and it hands the pair over as one value.

OptionTypeDefaultDescription
from / toISO string''The starting range.
presetsbooleantrueToday, Last 7 days, This month, Last month, Last 90 days, This year — from the locale bundle.
readonlyboolean—Both dates are shown, neither can be changed, and the presets are switched off.
min / maxISO string''Outer bounds both halves are held inside.
formatstringlocalePassed to both calendars.
required / message——required wants both halves.
onChangefunction({ from, to })—Fires for a calendar, a shortcut, or setValue.
const period = Kob.daterangebox('#period', { label: 'Report period', onChange: function (range) { report.reload({ url: '/api/report', queryParams: range }); } }); period.getValue(); // { from: '2026-09-01', to: '2026-09-30' } period.setValue({ from: '2026-01-01', to: '2026-12-31' }); period.setValue('2026-01-01,2026-03-31'); // or a pair in a string

panel

A titled content box that can fold away or load its body from a URL — and parse the widgets in whatever comes back.

A panel with a title is a region named by it. The fold button carries aria-expanded and says "Fold this panel" or "Open this panel" in the current locale.

OptionTypeDefaultDescription
titlestring''Header caption. Omit for a headerless panel.
iconClsstring''Icon in the header.
collapsiblebooleanfalseShows the fold caret.
collapsedbooleanfalseStart folded.
closablebooleanfalseShows a close button that removes the panel.
hrefstring''Fetch this URL into the body on init.
fitbooleanfalseFill the parent box.
borderbooleantrue 
loadingMessagestringlocaleShown while href is in flight.
onLoad / onCollapse / onExpand / onClosefunction— 

Methods: getBody(), setTitle(s), load(url), setContent(html), collapse(), expand(), toggleCollapse(), close().

layout

The classic five-region frame. Regions are declared on the children; the widget restacks them once so flexbox can do the sizing.

Keyboard: a splitter is a focusable role="separator". The arrow keys along its axis move it ten pixels (eighty with Shift), within minSize and maxSize, and Enter folds a collapsible region. The fold button carries aria-expanded.

Region options

Set these in each child's data-options.

OptionTypeDescription
regionstringnorth, south, west, east or center. Required.
widthnumberwest and east, in pixels.
heightnumbernorth and south, in pixels.
splitbooleanAdds a draggable splitter on the inner edge.
minSize / maxSizenumberDrag limits.
collapsiblebooleanPuts a fold button on the splitter.

Methods: panel(region) returns the region element, plus resize(region, px), collapse(region), expand(region), toggle(region), remove(region).

Events: onResize(region, size), onCollapse(region), onExpand(region).

A layout fills its parent, so give the parent a height — height:100% or a fixed value — or it collapses to nothing.

tabs

Every direct child carrying a title attribute becomes a page.

OptionTypeDefaultDescription
selectednumber0Index shown first.
closablebooleanfalseClose buttons on every tab. A page can opt in on its own with data-options="closable:true".
onSelectfunction(index, title)— 
onBeforeClosefunction(index, title)—Return false to keep the tab open.
onClose / onAddfunction— 

Methods: select(i), selectByTitle(s), getSelected(), getPanel(i), getPages(), add({ title, content, iconCls, closable }), close(i).

Per-page options

A page carries its own settings in data-options, next to the title the tab is named after:

<div class="kob-tabs"> <div title="Details" data-options="iconCls:'icon-user'">…</div> <div title="History" data-options="iconCls:'icon-history',closable:true">…</div> </div>

Three looks

The default is a folder: the selected tab is a raised card that overlaps the strip below it, so it and the page read as one surface. Two classes on the container change that without touching the markup inside.

ClassLook
—The default folder tabs, with an accent cap on the selected one.
kob-tabs-pillA segmented control: the selected tab is a filled pill and there is no strip.
kob-tabs-plainA flat underline — tabs that get out of the way of the page.
<div class="kob-tabs kob-tabs-pill">…</div>

stepper

The path through a multi-step task, across the page or down it. Give it children with a title and each one becomes a step and the panel shown while that step is current; give it none and it is the indicator on its own.

<div class="kob-stepper"> <div title="Tenant" data-options="text:'Who is moving in'">…</div> <div title="Room" data-options="text:'Which unit'">…</div> <div title="Confirm" data-options="iconCls:'icon-check-circle'">…</div> </div>
// Or as data, with no panels — a progress rail. Kob.stepper('#delivery', { direction: 'vertical', steps: [ { title: 'Packed', text: 'Warehouse 2', status: 'done' }, { title: 'On the way' }, { title: 'Delivered' } ], active: 1 });
OptionTypeDefaultDescription
stepsarraynullStrings, or { title, text, iconCls, status }. Left out, the titled children are read instead.
activenumber0The step to start on.
direction'horizontal' | 'vertical''horizontal'Vertical puts the rail down the left and the panels beside it.
size'sm' | 'md''md'Marker diameter — 24px or 32px.
clickablebooleantrueA click on a step moves to it.
linearbooleantrueOnly steps already reached can be clicked. false frees every step.
markers'number' | 'dot''number'What an unfinished marker shows.
doneIcon / errorIconstring'icon-ok' / 'icon-close' 
onChangefunction(index, previous, step)—Any move, including next() and prev().
onSelectfunction(index, step)—A move the user clicked for.
onFinishfunction()—Fires once, when finish() marks every step done.

Methods: go(i), next(), prev(), finish(), reset(), canReach(i), isFirst(), isLast(), isFinished(), getActive(), getStep(i?), getSteps(), getPanel(i?), setStatus(i, status), error(i?), setSteps(list).

States

A step is done, active or todo according to where it sits relative to the current one. Pinning a status — on the step object or through setStatus() — overrides that, and is how a failed step stays red after the user has moved on:

if (!form.validate()) { wizard.error(); return; } // paint this step red wizard.setStatus(wizard.getActive(), null); // and clear it once fixed wizard.next();

next() on the last step calls finish() rather than doing nothing, so one button can carry a wizard from the first step to the end.

dialog and modal

Built around an element that is already on the page: it keeps its content and becomes the window body, with the frame added around it.

OptionTypeDefaultDescription
titlestring'' 
widthnumber460 
heightnumber | 'auto''auto' 
modalbooleantruefalse makes it a draggable, resizable window.
closedbooleantrueStart hidden; call open().
buttonsarraynull{ text, iconCls, primary, handler(dialog) }.
draggable / resizablebooleantrueNon-modal windows only.
minimizable / maximizablebooleantrueNon-modal windows only.
sheetbooleanfalseDocks to the bottom edge — the shape phones expect.
minWidth / minHeightnumber300 / 120Resize limits.
onOpen / onClose / onResize / onMove / onMinimize / onMaximizefunction—onMinimize returning false keeps the window open.

Methods: open(), close(), isOpen(), setTitle(s), setButtons(list), maximize(on?), restore(), minimize(), center(), resize({width,height}), move({left,top}), window(), getBody(), destroy().

Two spellings

The modal flag decides everything, so it has a name at each end and you never have to remember which way round it goes:

Kob.modal(el, { title: 'Edit' }); // dialog({ modal: true }) Kob.window(el, { title: 'Invoices' }); // dialog({ modal: false })

Dialog markup is usually hidden with style="display:none" so it cannot flash before the widget is built. The widget clears that when it takes the element over — the frame owns visibility from then on.

card

CSS only — there is no behaviour to attach, so there is no Kob.card(). Compose the parts you need.

<div class="kob-card kob-card-hover"> <div class="kob-card-header"> <span class="kob-icon icon-home"></span>Sukhumvit <div class="kob-card-tools"><span class="kob-badge kob-badge-success">92%</span></div> </div> <img class="kob-card-media" src="…" alt=""> <div class="kob-card-body">46 of 50 rooms occupied.</div> <div class="kob-card-footer"> <span class="kob-muted kob-small">Updated today</span> <span class="kob-spacer"></span> <a class="kob-linkbutton icon-forward"><span class="kob-icon"></span>Open</a> </div> </div>
ClassDescription
kob-cardThe box. A flex column, so the body stretches.
kob-card-headerTitle row. kob-card-tools inside pushes to the right.
kob-card-mediaFull-bleed image at the top.
kob-card-bodyContent. First and last child margins are collapsed away.
kob-card-footerAction row on the accent tint.
kob-card-hoverLifts on hover — for cards that are links.

app shell

The bar across the top, the navigation down the side, and the scrolling area between them. CSS only — there is nothing to attach, because everything here is a class the page toggles for itself.

The column down the left is the one part of this frame that has state worth keeping — which entry is current, whether it is folded, what it does on a phone. Attach sidebar to it and that part is looked after; the markup below goes on working either way.

<div class="kob-appshell" id="shell"> <header class="kob-appbar"> <button class="kob-appbar-btn" id="rail"><span class="kob-icon icon-sidebar"></span></button> <span class="kob-appbar-brand"><span class="kob-appbar-mark">R</span>RentRoll</span> <span class="kob-spacer"></span> <button class="kob-appbar-user"> <span class="kob-avatar">SP</span> <span class="kob-appbar-user-lines"> <span class="kob-appbar-user-name">Somchai P.</span> <span class="kob-appbar-user-role">Administrator</span> </span> </button> </header> <nav class="kob-sidebar"> <div class="kob-sidebar-title">Overview</div> <a class="kob-sidebar-item active" title="Dashboard"> <span class="kob-icon icon-dashboard"></span> <span class="kob-sidebar-label">Dashboard</span> </a> <a class="kob-sidebar-item" title="Invoices"> <span class="kob-icon icon-invoice"></span> <span class="kob-sidebar-label">Invoices</span> <span class="kob-badge kob-badge-danger">28</span> </a> </nav> <main class="kob-appmain">…</main> <div class="kob-statusbar kob-appfoot">…</div> </div>
// Collapsing the sidebar to an icon rail is one class on the shell. rail.onclick = () => shell.classList.toggle('kob-sidebar-collapsed');
ClassWhat it is
kob-appshellThe frame. A grid of bar / sidebar / content / footer; fills its parent, so give that a height.
kob-sidebar-collapsedOn the shell: narrows the sidebar to a rail and hides the labels.
kob-appbarThe top bar. kob-spacer and kob-sep work inside it.
kob-appbar-brand / -markProduct name, and the square badge before it.
kob-appbar-btnA quiet square button. A kob-badge inside it pins to the corner.
kob-appbar-user / -lines / -name / -roleThe account block at the right-hand end.
kob-avatarInitials or an <img>. kob-avatar-sm, kob-avatar-lg.
kob-sidebarThe navigation column.
kob-sidebar-titleA group heading inside it.
kob-sidebar-itemOne entry: icon, kob-sidebar-label, optional badge. active marks the current page.
kob-sidebar-sepA rule between groups.
kob-appmainThe scrolling content area.
kob-appfootPut it on a kob-statusbar to dock it under the content.
kob-pagehead / kob-crumbsThe heading row above the content, and the trail above that.

The whole thing running, with a grid, tabs and a stepper inside it, is the admin dashboard on the examples page.

Under 720px the sidebar becomes a rail on its own, whatever the page asked for — which is why every kob-sidebar-item should carry a title, so its name survives as a tooltip.

ribbon

Tabs of grouped commands. One large button, or a stack of small ones; a button with a menu opens it instead of firing.

Keyboard: the tab strip is a real tablist with one tab stop; Left, Right, Home and End move between tabs and open them, and each tab owns a tabpanel. A button with a menu is aria-haspopup="menu", and opens on its first item when pressed with Enter or Space.

OptionTypeDefaultDescription
tabsarraynull[{ title, groups: [{ title, items: [...] }] }]
selectednumber0Tab shown first.
collapsiblebooleantrueClicking the open tab folds the ribbon away.
collapsedbooleanfalseStart folded.
onSelect / onClickfunction—(index, tab) / (item)

Item keys: text, iconCls, size: 'large' | 'small', disabled, menu, onClick. Consecutive small buttons stack three to a column.

Methods: select(i), collapse(), expand(), toggleCollapse(), setTabs(tabs).

tree

Nodes are plain objects. Children may be an array, or left out with state: 'closed' to be fetched the first time the node opens.

Node shape

{ id: 1, text: 'Sukhumvit', iconCls: 'icon-home', state: 'open', // or 'closed' checked: false, children: [ /* … */ ] // omit + state:'closed' to load lazily from `url` }
OptionTypeDefaultDescription
dataTreeNode[]nullThe root nodes.
urlstring''Root through Kob.config.request; lazy children are fetched from the same URL with id.
idField / textField / childrenFieldstringid / text / childrenRead your own shape without remapping.
checkboxbooleanfalseA tick box on every node.
cascadeCheckbooleantrueFolders tick their children; partly-ticked folders show a dash.
linesbooleanfalseGuide lines down each branch.
expandDepthnumber1How deep to start open.
formatterfunction(node)—HTML for one label.
onSelect / onClick / onDblClickfunction(node)— 
onExpand / onCollapsefunction(node)— 
onCheckfunction(node, checked)— 
onContextMenufunction(event, node)—Pairs with menu.showAt().
dndbooleanfalseDrag a node onto another to move it — see Changing the tree.
editablebooleanfalseDouble-click a label, or press F2, to rename it in place.
filterMatchfunction(node, query)—How filter() matches a node. Defaults to a substring of its text.
onBeforeDropfunction(node, target, where)—Return false to refuse a drop. where is before, after or inside.
onDropfunction(node, target, where)—After the move.
onBeforeEdit / onAfterEdit / onCancelEditfunction—A rename, before it starts (return false to refuse), after it commits with (node, text, was), and when it is abandoned.
onChangefunction(nodes)—After any change to the data: append, insert, remove, update, move, rename.

Methods: loadData, reload, expand(node), collapse(node), toggle(node), expandAll(), collapseAll(), select(node), getSelected(), find(id), check(node, on), getChecked(), walk(nodes, fn), getData().

Changing the tree

A tree that can only be read is half a widget: every application that shows one — a chart of accounts, a category tree, an org chart — sooner or later has to add a node to it. Doing that against the raw data and calling loadData() to redraw loses the open state of every branch on the way, so these five work on the same node objects the application handed over.

MethodReturnsDescription
appendNode(parent, nodes)TreeNodeAdd under a parent — or at the root, with null. A leaf that has just been given a child is opened, since an added node nobody can see is an added node nobody believes in.
insertNode(ref, nodes, where)TreeNodeBeside an existing node: 'before' or 'after'.
removeNode(node)thisTakes everything under it too.
updateNode(node, patch)thisMerges fields in — its text, its icon, whatever it carries.
moveNode(node, target, where)this'inside', 'before' or 'after'. A node cannot land inside its own subtree — that is not a rearrangement, it is a tree with no root — so those are refused rather than corrected.
parentOf(n) / siblingsOf(n) / indexOf(n) / contains(a, b)—Where a node sits.
beginEdit(node) / endEdit() / cancelEdit()thisRename in place. The input replaces the label rather than opening a dialog, because the thing being renamed is the thing you are looking at.
filter(text) / getVisible()this / TreeNode[]Show only what matches — and the branches that lead to it, opened. A match three levels down is meaningless on its own.

Dragging. With dnd: true, where a drop lands is decided by where in the row it happens: the top quarter puts the node above the target, the bottom quarter below it, and the middle half puts it inside. A tree that only ever dropped into things could not reorder siblings, which is most of what anybody drags a node for.

The keyboard. The tree is one tab stop, and the arrow keys move within it: down and up walk the nodes on screen, right opens a folder and then steps into it, left closes one and then steps out. Home and End go to the ends, Enter selects, and Space ticks when there are tick boxes. A tab stop per node would make a tree of four hundred nodes something nobody can tab past.

desktop

A windowed shell: wallpaper with launcher icons, a taskbar, a start menu, and one window per running app. Each window is a non-modal dialog, so dragging, resizing and maximising come from that widget — what the desktop adds is knowing what is running, which window has focus, and how to get a minimised one back. From the keyboard: Enter on a launcher icon opens its app, the start button is a menu button, and each task button is an aria-pressed toggle for the window in front.

Apps

An app says what its window contains, in one of three ways.

KeyTypeDescription
idstringRequired. What open() and close() take.
titlestringWindow title, icon caption and taskbar label.
iconClsstringIcon from the built-in set.
iconstringAn image URL instead, for a real app icon.
contentstringHTML for the window body. Widgets in it are parsed.
hrefstringFetch a fragment into the body instead.
onOpenfunction(win, body)Or build the contents yourself.
width / heightnumberWindow size.
singlebooleanDefault true — opening again focuses the existing window.
hiddenbooleanReachable through open(), but not shown as an icon.

Options

OptionTypeDefaultDescription
appsApp[]nullAs above.
iconsbooleantrueLauncher icons on the wallpaper.
taskbarbooleantrueThe bar along the bottom.
startLabelstring | false'Start'Start button text; false hides it.
startExtrasMenuItem[]—Entries under the app list.
clockbooleantrueClock at the right.
userobject—{ name, iconCls, onLogout }
wallpaperstring—Any CSS background.
onOpen / onClose / onFocusfunction(app, win)— 

Methods: open(id) returns the window's Dialog, plus close(id), closeAll(), minimize(id), getRunning(), getWindow(id), setApps(apps), setWallpaper(css).

The desktop fills its parent, so give that parent a height. On a full page that means html, body { height: 100% }.

datagrid

The whole result set lives in memory, and searching, filtering, sorting and paging all happen locally — so changing a filter never costs another round trip.

Options

OptionTypeDefaultDescription
columnsarraynullSee Column definition. A nested [[…]] array is accepted for EasyUI-shaped configs.
dataarray | objectnullRows, { rows } or { data }.
urlstring''Fetched through Kob.config.request.
queryParamsobjectnullSent with the request.
loadFilterfunction(payload)—Reshape the payload before it is read.
idFieldstring''The column that identifies a row, for matching one back after an edit.
paginationbooleantrueThe footer with page controls. false renders every row that survives the filters.
pageSizenumber50 
pageListnumber[][20,50,100]Choices in the rows-per-page menu.
searchablebooleantrueThe free-text box in the footer. Needs pagination, which is what draws the footer.
filterablebooleantrueThe chip row above the header.
rownumbersbooleanfalseA leading column counting the rows. Dropped on a single-column grid, where it would be the only thing beside the content.
showHeaderbooleantruefalse hides the header row — and with it sorting, resizing and the chips.
stripedbooleantrue 
singleSelectbooleantruefalse lets clicks accumulate a selection. See Selecting rows.
checkboxbooleanfalseA leading column of checkboxes, with a tri-state box in the header. Turns multi-select on by itself unless singleSelect was set.
selectOnCheckboxbooleanfalseOnly the checkbox changes the selection; clicking the row leaves it alone.
resizablebooleantrueDrag the right edge of a header cell to resize that column. resizable: false on a column pins that one.
minColWidthnumber48A column will not be dragged narrower than this.
fitbooleanfalseFill the parent's height.
emptyMsgstringlocaleShown when nothing matches.
showSummarybooleannullThe totals row. Left alone it appears as soon as a column asks for one; false keeps it away for good.
detailfunction(row, panel, index)nullOpens a panel under the row and fills it — see A row that opens. Brings detailHeight and singleDetail with it.
rowActionsarraynullButtons over the row instead of in a column — see Editing from the row.
rowActionsOn'hover' | 'selected''hover''selected' drops the hover behaviour and shows the strip only on the selected row.
rowMenuarray | function(row, index)nullA menu on right-click. Item handlers are called with (row, index) — see The menu on a row.
columnChooserbooleanfalseA button that opens the list of columns, to be ticked and reordered.
exportButtonbooleanfalseA button offering CSV, a spreadsheet and print.
reorderablebooleanfalseDrag a header cell sideways to move that column.
stateKeystring''Remember widths, order, hidden columns, the sort and the page size under this name — see Remembering the layout.
stateFiltersbooleanfalseRemember the chips and the search box too.
multiSortbooleantrueShift-clicking a second header sorts by it within the first.
groupFieldstring''Break the rows into folding groups by this field — see Grouping.
groupFormatterfunction(value, rows, field)nullRenders one group heading. Defaults to the value and a row count.
groupCollapsiblebooleantruefalse leaves the headings there but stops them folding.
keyboardbooleantrueArrow keys, Enter to edit, Space to select, Ctrl+C to copy — see The keyboard.
typeToEditbooleantrueTyping a character over a cell opens its editor with that character in it.
remotebooleanfalseLet the server page, sort and filter — see Server-side mode.
totalFieldstring'total'Where the row count lives in a remote response.
remoteParamsfunction(state)nullReshape what is sent, for an API that spells page as offset.
remoteDelaynumber250Milliseconds to wait for typing to stop before asking the server.
virtualbooleanfalseDraw only the rows in the viewport — see Virtual scrolling.
rowHeightnumber32Row height, when virtual scrolling is on. Every row has to be this tall.
announceSelectionbooleantrueSay the size of the selection to a screen reader as it changes.

Column definition

KeyTypeDescription
fieldstringProperty on the row. A column without one is decoration: it cannot sort, filter or be searched.
titlestringHeader text.
widthnumberPixels, and a starting point — columns that fit are stretched to fill the grid.
align'left' | 'center' | 'right'Applied to the header cell and every body cell.
sortablebooleanfalse makes the header inert.
resizablebooleanfalse drops this column's grip while the rest stay draggable.
formatterfunction(value, row, index)Returns the cell's HTML. Filtering, searching and the chip menus all read what it produced.
editorstring | object | function(row, index)What the cell turns into while the row is edited: 'text', 'textarea', 'checkbox', 'select', 'numberbox', 'combobox', 'datebox', 'timebox', 'datetimebox', or { type, options, rules, required }. A function is asked per row and may return null. See Cell editors.
filterfalse | stringfalse drops the chip; a string forces its kind: text, number, enum, bool.
summarystring | functionA value in the totals row: sum, avg, count, min, max, any other string used as a label, or function(rows, column).
summaryFormatterfunction(value, rows, column)Formats the total. Falls back to the column own formatter.
frozenbooleanPin this column while the rest scrolls sideways. A leading run only — see Totals and frozen columns.
hiddenbooleanDrawn, but not right now. The column chooser turns it back on, and its width and filter survive the round trip.
exportablebooleanfalse keeps the column out of exports and prints while leaving it on screen.

Methods

MethodReturnsDescription
loadData(rows)thisReplace the rows. Accepts an array, { rows } or { data }. Clears the selection with them — they are new objects.
reload(url?)PromiseFetch again. Accepts { url, queryParams }.
getRows() / getData()Row[]Everything loaded. Two spellings of the same list.
getVisibleRows()Row[]After the search box, the chips and the sort — every page of it, not the one on screen.
getRowByIndex(i)RowBy position in the loaded set.
getSelected()Row | nullThe last row selected.
getSelections()Row[]Every selected row — including ones the current page is not showing.
getSelectionCount()number 
isSelected(row)booleanTakes the row itself, not an index.
selectRow(i) / unselectRow(i)this 
selectAll() / unselectAll()thisEvery row the filters let through, not only the page on screen.
clearSelections()thisThe older name for unselectAll().
sort(field, order?)thisOmit the order to flip it.
setFilter(field, value)this{ in: [...] }, { ge: n }, { q: 'text' }, { eq: v }, or null to drop it.
getFilters() / clearFilters()object / this 
search(text)thisAs if typed into the footer box, which it also fills in.
gotoPage(n) / pageCount()this / numberClamped to what exists.
beginEdit(i) / endEdit(i) / cancelEdit()thisSee Inline editing. A commit that fails the editors' own rules is refused and the row stays open.
validateEdit()objectCheck the open editors without committing: { field: message }, empty when the row is good.
addSort(field, order?) / getSort() / clearSort()this / SortKey[] / thisSorting by more than one column — what shift-clicking a header does.
toggleColumn(field, shown?) / hideColumn(f) / showColumn(f)thisTake a column out of the table, or put it back.
moveColumn(field, index)thisMove it in the arrangement, counting hidden columns.
column(field) / columnArray()Column / Column[]One column by name, or every one including the hidden.
getState() / setState(s) / saveState() / loadState() / resetState()object / thisThe arrangement as data, and the four ways to move it about.
groupBy(field, order?) / groups()this / RowGroup[]Group by a field, or by none; and the groups as they stand.
expandGroup(k) / collapseGroup(k) / toggleGroup(k) / collapseAllGroups() / expandAllGroups()thisFolding.
exportCsv(opts) / exportExcel(opts) / print(opts)thisTake the table out — see Export and print.
toMatrix(opts)array[]The table as rows of cells, header first — what every export is built from.
copySelection(opts) / copyRows(rows, opts)PromiseThe selected rows as tab-separated text, which pastes straight into a spreadsheet.
getCursor() / setCursor(row, col) / moveCursor(dr, dc) / cursorRow()object / this / RowThe cell cursor the arrow keys move.
remoteQuery() / fetchRemote() / refreshView()object / Promise / thisServer-side mode: what would be sent, asking for it, and the one call that redraws or refetches depending on which kind of grid this is.
pageRows()Row[]What is actually paged: the filtered rows, minus anything in a folded group.
renderSummary()thisRedraw the totals row. Done for you whenever the rows change.
refreshRow(i)thisRedraw after changing a row's data behind the grid's back.
rowElement(i)HTMLElement | nullThe <tr>, if that row is on the page being shown.
expandDetail(i) / collapseDetail(i) / toggleDetail(i)thisThe panel under a row — see A row that opens.
loading() / loaded()thisShow or hide the mask by hand, for work the grid knows nothing about. loadData() and reload() take it down themselves — being handed rows is the end of loading — so the usual pattern is loading(), your own fetch, loadData(), with no loaded() to forget.
resize()thisRe-measure columns. Rarely needed — it watches its own box.
refresh()voidRebuild after options changed through option({ … }).
destroy()voidGive the element back as it was found.

Events

EventArguments
onClickRow / onDblClickRow / onSelect / onUnselect(index, row)
onSelectionChange(rows) — every row now selected, however the change was made
onResizeColumn(field, width) — when a drag finishes
onLoadSuccess / onLoadError(data) / (error)
onSortChange(field, order, sorts) — the third argument carries every sort key, most significant first
onFilterChange(filters)
onExpandDetail / onCollapseDetail(row, panel) / (row)
onBeginEdit / onEndEdit / onCancelEdit(index, row) — the row as the editors open, commit, or are dropped
onEditError(index, row, errors) — a commit refused by the editors' own rules, with a message per field
onRowContextMenu(event, row, index) — return false to leave the browser's own menu alone
onColumnVisibility / onColumnMove(field, shown) / (field, index)
onExpandGroup / onCollapseGroup(key)
onExport(kind, filename) — after a csv, excel or print export
onCopy(rows, ok) — after a copy to the clipboard

Columns, and the widths they end up with

A width is a starting point rather than a contract. While the widths add up to less than the grid is wide, every column is stretched in proportion so the table fills its box — which is why a three-column grid never leaves a strip of empty background down the right. When they add up to more, the body scrolls sideways and the header follows it.

Drag one and that stops. A width set by hand is a decision, so the proportional stretch is switched off for good and only the last column — the one with no grip, because there is nothing to its right to give width back to — takes up the slack. The new width is written onto the column definition, so it survives sorting, paging and a reload, and onResizeColumn is where you would write it somewhere more permanent.

Filter chips

Above the header sits one chip per column. Each decides what to offer by looking at the data: a pick-list for categories, a lower bound for numbers, a contains box for free text.

They filter what the reader sees. A column with a formatter is flattened to text once per row and cached, so a status column rendering 1 as a green "Paid" badge filters and searches as Paid.

How a chip decides what kind it is

What the column holdsChipWhat it offers
Only booleansboolA pick-list of the two rendered labels — Yes and No, or whatever the formatter made of them.
Only numbers, all of them 0 or 1enumA pick-list. A flag stored as a number is still a flag.
Only numbersnumberA lower bound: at least n.
Text, few distinct valuesenumA pick-list. "Few" means no more than a dozen, and no more than a third of the rows — a column of unique names is not a category.
Anything elsetextA contains box.

filter: 'number' on the column overrides the guess, filter: false drops that chip, and filterable: false on the grid drops the row. It also hides itself when there is nothing to choose between — fewer than two filterable columns, or a grid with no header.

Searching and sorting

The box in the footer matches every column that has a field, against the same flattened text the chips use — so a search for overdue finds the rows whose status badge reads Overdue, and a formatted 12,500.00 is found by typing 12,5. search(text) does the same from code, and fills the box in so the user can see what they are looking at.

Sorting compares numbers as numbers and everything else with localeCompare, which is what puts Ähnlich where a reader expects it rather than after Z. Null and undefined sort as an empty string. Unlike filtering, sorting reads the underlying value, not the formatter's output: a date rendered as 5 Jan 2026 still sorts by the ISO string behind it.

Selecting rows

One row at a time by default. checkbox: true or singleSelect: false turns multi-select on, and from there the gestures are the ones the desktop taught everyone:

GestureWhat it does
ClickToggles that row.
Ctrl / Cmd + clickToggles it explicitly, leaving the rest alone.
Shift + clickTakes the run from the last row touched to this one, walking the rows in the order they are on screen — sorted and filtered — because that is the run being pointed at.
The row's checkboxChanges the selection and nothing else: no row click, no editor, no dialog opening behind the tick.
The header checkboxTicks or clears the page in front of the user, and shows a tri-state mark when part of it is selected. It reports on that page only — it cannot honestly claim to have ticked rows nobody has looked at.

The selection is kept as rows rather than as classes on whichever <tr>s exist at the moment, so it survives paging, sorting and filtering: rows picked on page one are still picked on page two, and getSelections() returns all of them. selectAll() means every row the filters let through, not the page on screen. loadData() clears it, because those rows are new objects.

Loading from a server

url fetches once, through Kob.config.request, and everything after that is local. There is no server-side paging protocol to implement: the grid asks for the result set, then does the searching, filtering, sorting and paging itself. A { rows, total } envelope is understood, as is a bare array or { data }; anything else is what loadFilter is for.

When the request fails the grid empties itself rather than leaving stale rows under a failed refresh, calls Kob.config.onRequestError, and then fires onLoadError. reload() refetches the same URL; reload('/api/other') or reload({ url, queryParams }) changes it and remembers the change.

Inline editing

beginEdit(i) turns the cells of that row into inputs — but only the columns that declared an editor; the rest stay as they are, which is how a key or a computed total is kept out of reach. A numberbox editor reads back as a number, rounded to its precision; a text one as a string.

endEdit(i) writes the inputs back into the row, redraws it and fires onEndEdit — which is where the save request belongs. cancelEdit() drops them without writing. Committing on Enter and abandoning on Escape is two lines in the page, not a built-in, because which key means "save" is an application's decision and not a widget's.

Totals, and columns that stay put

A column with a summary gets a value in a row along the bottom — sum, avg, count, min, max, a plain string used as a label, or a function handed the rows and returning whatever it likes. The row appears on its own as soon as one column asks for it.

Totals are taken over what the filters left, not over the page on screen. A footer that changed when you turned the page would be reporting on nothing anybody asked about. They sit in a table of their own under the body, so they stay put while the rows scroll and are not paged away with them, and a sum is shown through the column own formatter so the total reads in the same units the column does.

frozen: true on a leading column pins it while the rest scrolls sideways — the one thing a table of twenty columns cannot do without, since a row you cannot identify is a row you cannot read. Only a leading run can be frozen: a pinned column in the middle would slide under its neighbours on the way past. It is done with sticky cells in the one table rather than by drawing a second table over the first, which is where most grids keep their bugs — two tables mean two row heights to keep in step, two scroll positions, and a selection painted twice.

Editing and deleting from the row

Not every table has room for a column of buttons. rowActions gives you the actions without one: a strip that appears over a row when the pointer is on it, and parks on the row the user clicked so it stays reachable from the keyboard.

Kob.datagrid('#grid', { columns: [ … ], data: rows, rowActions: [ { text: 'Edit', iconCls: 'icon-edit', handler: edit }, { text: 'Delete', iconCls: 'icon-remove', color: 'danger', disabled: function (row) { return row.status === 'paid'; }, handler: remove } ], // The other half of the same idea — most people try this first. onDblClickRow: function (i, row) { edit(row); } });
KeyTypeWhat it does
textstringButton caption. Leave it out for an icon-only button; the label then comes from title.
iconClsstringe.g. 'icon-remove'.
colorstringOne of the five button colours.
softbooleanDefaults to true — a strip of solid buttons over a row is too loud.
disabledboolean | function(row, index). Re-evaluated for every row the strip lands on.
handlerfunction(row, index), with the grid element as this.

There is one strip, moved to whichever row wants it, rather than a copy per row: a grid repages and re-sorts constantly, and forty copies of the same three buttons is forty times the DOM for something only ever seen once. It hides itself when the row it belongs to scrolls out of the body's own viewport, so the buttons never end up floating over the header or the pager.

rowActionsOn: 'selected' drops the hover behaviour and shows the strip only on the selected row. Methods showRowActions(tr, hovering) and hideRowActions() drive it by hand.

The strip covers the right-hand end of the row while it is open, so put the column you can least afford to hide on the left. That is the trade for not spending a column on buttons — one or the other has to give.

A row that opens: invoice and its lines

detail is a function given a row and an empty <div> that has just been put under it. What goes in the div is up to you — a paragraph, a form, or, most often, a second datagrid holding the lines of the invoice the row is a header for. Setting the option adds the column of carets by itself.

Kob.datagrid('#invoices', {
  columns: [
    { field: 'no', title: 'Invoice', width: 120 },
    { field: 'customer', title: 'Customer' },
    { field: 'total', title: 'Total', align: 'right', summary: 'sum' }
  ],
  data: invoices,
  detail: function (row, panel) {
    Kob.datagrid(panel, {
      columns: [
        { field: 'sku', title: 'SKU', width: 110 },
        { field: 'name', title: 'Item' },
        { field: 'qty', title: 'Qty', align: 'right', width: 70 },
        { field: 'amount', title: 'Amount', align: 'right', width: 110, summary: 'sum' }
      ],
      url: '/api/invoices/' + row.id + '/lines',
      pagination: false
    });
  }
});

The function runs once per row, the first time that row is opened, and the panel it filled is then kept. Sorting, filtering and turning a page move the same element into the new table rather than calling you again — which matters, because for the shape this is for every call is a request to a server. Closing the panel is what throws it away, and everything KOB built inside it is destroyed with it.

This is not the treegrid, and the difference is worth being clear about. A treegrid's children are more rows of the same columns — the same table, indented. A detail panel is a different table entirely, with its own columns, its own totals and its own URL. Invoice lines are the second shape; a folder of files is the first.

OptionTypeDefaultNotes
detailfunction(row, panel, index)nullFills the panel opened under a row. Setting it adds the caret column.
detailHeightnumber0Pixels. 0 lets the panel be as tall as what you put in it; a number is what you want when the panel holds a grid of its own, which would otherwise have no height to measure against.
singleDetailbooleantrueOne panel open at a time. false lets them accumulate.
MethodReturnsNotes
expandDetail(row | index)thisOpens the panel, building it if this is the first time.
collapseDetail(row | index)thisCloses it and destroys what was inside.
toggleDetail(row | index)thisWhat the caret does.
isDetailOpen(row)boolean
collapseAllDetails()thisAlso what loadData() and reload() do for you: the panels belonged to rows that are gone.
EventArguments
onExpandDetail(row, panel) — after the panel was filled
onCollapseDetail(row)
Two grids in one page is one thing; two grids whose rows share a <tbody> is another. Every row knows which grid drew it, so a click on a line does not select the invoice behind it, the outer grid's getSelections() never returns a line, and the inner grid's own selection stays its own. You do not have to do anything to get that — but it is the reason nesting works here and does not in most grids that were not built expecting it.

One column, and it stops looking like a table

A grid of one column has nothing to sort against another, nothing to resize, and no second column to filter by — so it drops the header furniture and the chip row rather than showing controls that lead nowhere. What is left is a paged, searchable list whose rows look like whatever the formatter returns. showHeader: false does the same to a grid of many columns.

It is the same widget, so the search box, the pager, the row events, rowActions and the selection all still work. On a narrow screen this is often the shape a table wants to take.

Cell editors

A column's editor says what its cell turns into while the row is being edited. Eleven kinds are built in, and nine of them are the library's own field widgets put inside the cell — so a date in a table is picked off the same calendar it is picked off in a form, and a status is chosen from the same list. Anything else and a grid becomes a second, worse set of controls to learn.

rules and required on an editor are checked when the row is committed. A row that fails is not written back and its editors stay open — committing it would put a value the column has just called invalid into the data, and closing the editors would hide the only thing that can be fixed. The cell is outlined, carries the sentence as its title, and onEditError(index, row, errors) says which fields were refused.

Kob.registerEditor(name, factory) adds one of your own. The factory is handed the emptied cell, the spec from the column, and { row, index, column, grid }; whatever it returns is filled in with defaults, so an editor writes only the parts that are its own.

Kob.registerEditor('colour', function (td, spec) {
  var input = document.createElement('input');
  input.type = 'color';
  td.appendChild(input);
  return {
    node: input,
    getValue: function () { return input.value; },
    setValue: function (v) { input.value = v || '#000000'; }
  };
});

The keyboard, and the clipboard

The body takes one tab stop, and from there the arrow keys move a cell cursor around the page. A table of four hundred rows with a tab stop in every cell is a table nobody can tab past — and a screen reader announces the grid once rather than four hundred times.

The ring round the cursor's cell is drawn only while the keyboard is steering. A click still moves the cursor to the cell under the pointer, so the arrow keys carry on from there, but draws no ring — the row it lands on is already highlighted.

KeyWhat it does
ArrowsMove the cursor a cell at a time.
Home / EndFirst and last column. With Ctrl, the first and last row on the page.
Page Up / Page DownTurn the page.
SpaceSelect the row under the cursor, or unselect it.
Enter / F2Open the editors on that row. Enter again commits and steps down.
Tab / Shift+TabWhile editing: the next editor in the row, then commit and carry on to the row below.
EscapeDrop the editors without saving.
Any characterOpens the cell's editor with that character already in it, the way a spreadsheet does.
Ctrl+ASelect every row the filters left.
Ctrl+CCopy the selection — or the row under the cursor — as tab-separated text, which pastes straight into a spreadsheet.

The cursor is a pair of indices rather than a reference to a <td>, because every sort, filter and page turn throws the cells away: it survives all three and lands on the same place in the new page. keyboard: false turns the lot off.

Choosing and moving columns

A table of twenty columns is only usable once it has been cut down to the six that matter. columnChooser: true puts a button at the end of the chip row that opens the list; reorderable: true lets a header be dragged sideways. Hiding a column keeps everything it had — its width, its filter, its formatter — because it is the same object with one flag changed.

A filter on a hidden column goes on filtering, which is deliberate: hiding the column you filtered by should not silently show you rows you had excluded. And the last visible column cannot be hidden — an empty table has no header to bring the others back from.

Remembering the layout

stateKey files the arrangement — widths, order, hidden columns, the sort and the page size — under a name, in this browser. Two grids that share a key share a layout, which is how the same table on two pages stays the same table. stateFilters: true remembers the chips as well; it is off by default because a filter somebody set three weeks ago is a table that looks broken on the way back to it.

getState() and setState() are the same data without the storage, for an application that would rather keep the layout on its own server against the user's account. resetState() forgets it and goes back to the columns as they were written.

Export and print

exportCsv(), exportExcel() and print() take the table out of the page — exportButton: true puts them behind a button in the chip row. Three decisions run through all three.

What gets exported is what the filters left, not the page on screen. A file holding twenty of four hundred rows because that is where the pager happened to be is wrong in a way nobody notices until it is in an invoice. rows: 'selected', 'page' or 'all' asks for something else.

Cells come out as the user reads them — a status column that renders 1 as a green "Paid" badge exports as Paid, for the same reason it filters as Paid. raw: true asks for the values underneath instead, and exportable: false on a column keeps it out of the file while leaving it on screen.

And the CSV carries a UTF-8 byte order mark and quotes any cell that opens with =, +, - or @. Both look like superstition and are not: without the first, Thai text arrives in Excel as mojibake; without the second, a spreadsheet treats a cell of data as a formula to run.

grid.exportCsv({ filename: 'rent-roll', rows: 'filtered' });
grid.exportExcel({ sheetName: 'September' });
grid.print({ title: 'Rent roll — September 2026' });

The spreadsheet is SpreadsheetML, which Excel, Google Sheets and LibreOffice all open and which keeps numbers as numbers. A real .xlsx is a zip archive, and building one would mean shipping a compressor in a library whose whole claim is that it has no dependencies. Printing goes through a hidden iframe rather than a popup window — nothing to be blocked — and prints a plain table: the chips, the pager, the row-action strip and the frozen columns are screen furniture and mean nothing on paper.

Grouping, and sorting by more than one column

Shift-clicking a second header sorts by it within the first, and the headers number the keys once there is more than one of them. A plain click still replaces the sort outright, so nothing changes for a grid that never multi-sorts.

groupField breaks the rows into groups under headings that fold. The grid sorts by that field first whatever else it is sorted by — groups that were interleaved would not be groups — and folding is taken off before paging, so a page always holds a full page of rows.

The menu on a row

rowMenu is a list of menu items, or a function of the row that returns one — because most of what a row menu offers depends on the row, and an invoice that is paid cannot be paid again. Handlers are called with (row, index).

The row under the pointer is selected first, unless it was already part of a selection: right-clicking one of five selected rows means those five, and a menu that quietly threw four of them away before running Delete would be a menu that destroys data.

Server-side mode

Local mode holds the whole result set, which is why filtering never costs a round trip — and is exactly why it stops working somewhere north of a hundred thousand rows. With remote: true every page turn, sort, chip and keystroke in the search box becomes a request, and the server answers with one page and a total.

What is sent is deliberately plain — page, rows, sort, order, q, filter — because the shape of somebody else's API is not something a widget gets to have an opinion about. remoteParams renames or reshapes the lot in one function when it disagrees.

Two details that matter. Late answers to old questions are dropped: a user typing in the search box has three requests in the air, and the slowest must not be the one that decides what is on screen. And the selection survives a page turn only when there is an idField — a remote grid never sees the same row object twice, so identity cannot carry it, and without an id the selection is honestly dropped rather than held against objects nothing on screen shares.

Virtual scrolling

virtual: true draws only the rows in the viewport, for the long unpaged list — a ledger, a log — where the pager is in the way rather than the point. Two spacer rows stand in for everything above and below, so the scrollbar is the right size and in the right place while the DOM holds a screenful.

It needs every row to be the same height, which is why rowHeight is a number and not a guess. A grid with groupField or detail set has rows of uneven height, so it pages normally instead and says so once in the console rather than drawing the table wrong.

Driving the grid from your own code

Every gesture the grid offers is a method underneath, and the chips and the footer are only one caller of them. A toolbar of your own, a keyboard shortcut or a route change can sort, search, filter, page and select exactly as a click does — and the matching event fires either way, so a status line has one place to listen.

A grid inside a tab, a panel or a dialog

Column widths are measured, and a grid built inside a hidden tab has nothing to measure. It watches its own box with a ResizeObserver and re-measures when the box changes, so in most cases this takes care of itself. The containers that hide and show their children — tabs, layout, panel, dialog — also fire a kob-resize event on what they reveal, which the grid listens for.

If a grid does turn up with collapsed columns after something outside the library changed the layout, resize() re-measures on demand. fit: true makes it fill its parent's height, which is usually what you want in a dialog or a layout region.

treegrid

A datagrid whose rows have children. A chart of accounts, a bill of materials, an org chart, a category tree with quantities against it — the shape half the tables in an internal system actually have. It is a role="treegrid": each row says its aria-level and whether it is aria-expanded.

It is the datagrid, not a second one

The tree is flattened into the same rows the grid already sorts, filters, pages, selects and edits, with a level recorded against each. What the tree adds is which rows are folded away, and a caret in one column. Everything else — the filter chips, the search box, the totals row, inline editing, rowActions, the selection surviving a page turn — goes on working, because none of it knows anything has changed.

Two things do behave differently, and both have to. Sorting orders siblings inside their parent rather than sorting the whole list, because a tree sorted flat is no longer a tree. Searching keeps the ancestors of a match: a row three levels down, on its own, does not tell the reader where it is.

A branch at a time

A chart of accounts arrives whole. An asset tree with eighty thousand leaves in it does not, and asking for a branch at the moment somebody opens it is the only way that screen is usable at all.

Kob.treegrid('#assets', { columns: [ … ], // Rows that carry hasChildren: true are drawn with a caret and nothing under it. data: [{ name: 'Bangkok', hasChildren: true }, … ], // Called once, the first time such a row is opened. loadChildren: function (row) { return fetch('/api/assets?parent=' + row.id).then(function (r) { return r.json(); }); } });

The row does not open until the children are there — a branch that flicks open empty and fills in a moment later reads as a bug — and the caret turns into a spinner in place while it waits, so nothing moves under the pointer. It is asked once: collapsing and re-opening uses what it already has. A branch whose fetch failed keeps its caret rather than turning into a leaf, so the next click tries again, and the error goes to Kob.config.onRequestError like every other request in the library.

A lazy branch starts shut whatever expandDepth says, and expandAll() will not fetch the world. Both are the same decision: opening a row is what asks the server, so nothing else should.

Options

OptionTypeDefaultDescription
dataarraynullRows, each with an optional children array, nested as deep as you like.
treeFieldstringfirst columnWhich column carries the caret and the indent.
childFieldstring'children'Where the children live on a row.
expandDepthnumber1How many levels start open. 0 shows only the roots.
expandAllbooleanfalseEverything open, whatever the depth says.
indentnumber18Pixels per level.
loadChildrenfunction(row)—Fetch a branch the first time it is opened. Return an array, or a promise of one.
hasChildrenFieldstring'hasChildren'How a row says it has children it has not handed over yet.
onExpand / onCollapsefunction(row)—onExpand fires once the children are actually there.

Every datagrid option applies as well.

The totals row counts every row the filters left, folded or not: a balance that moved every time somebody opened a branch would be a number nobody could trust. It counts each row once, so give a parent no value of its own — let the leaves carry it — or use a summary function and add up whichever rows you mean.

Methods: expandRow(row), collapseRow(row), toggleRow(row), expandAll(), collapseAll(), revealRow(row) — open every ancestor of a row so it can be seen — getTree(), childrenOf(row), and the whole grid API underneath.

transfer

Two lists and the buttons between them. Assigning permissions to a role, members to a group, columns to an export: the job where a multi-select stops working somewhere around fifty options, because the question is not which of these but what does this end up with — and only two lists side by side answer that at a glance.

Options

OptionTypeDefaultDescription
dataarraynullEverything that can be chosen: [{ id, text, disabled }].
valuearraynullThe ids that start on the right.
valueField / textFieldstring'id' / 'text' 
titles[string, string]localeCaptions over the two lists.
searchablebooleantrueA search box over each side. Worth it past about thirty rows.
heightnumber260How tall the lists are.
required / message——required means at least one on the right.
onChangefunction(values)—The ids on the right, after any move.

Methods: getValue() — the ids on the right, in the order they were put there — setValue(ids), getSelections() for the rows rather than the ids, loadData(rows), clear(), isValid().

Click marks a row, Ctrl or Cmd marks another, Shift takes the run — the gestures a list of this shape has always had. Double-clicking a row moves it on its own, because that is what everybody tries first. A row marked disabled stays where it is even when everything else is moved across.

tooltip

The sentence a control cannot fit on itself. Internal systems are full of fields named after a policy nobody remembers and cells one word wider than their column; the native title attribute would do for both if it appeared inside a second rather than three, went where it could be read, and could be reached without a mouse.

Options

OptionTypeDefaultDescription
contentstring''The text. Left out, the element's own title is taken over — and taken away, so the browser's tip does not follow ours a second later.
position'top' | 'bottom' | 'left' | 'right''top'Flipped to the opposite side when there is no room, which is always the side that has some.
trigger'hover' | 'click''hover'Hover also means focus.
delaynumber250Milliseconds before it appears on hover. Focus shows it at once — somebody on a keyboard has already waited.
maxWidthnumber260Where the text starts wrapping.

Methods: show(), hide(), toggle(), isOpen(), setContent(text).

Anything with a tooltip is described by it — aria-describedby pointing at the bubble — so the sentence is read out as well as shown, and it appears on keyboard focus, not only under a pointer. Escape dismisses it. One is on screen at a time: two is two things to read and no way to tell which belongs to what.

popover and popconfirm

A small panel that belongs to one control. tooltip is a sentence you read; a popover is a panel you use — a title, a paragraph, a short form, a row of buttons. It stays open until it is dismissed, it is in the tab order, and a screen reader meets it as a dialog. popconfirm is the case most people want one for: "Delete this row?", asked beside the button that deletes it, without dimming the whole page the way a modal confirm does.

popover options

OptionTypeDefaultDescription
titlestring''Bold line across the top; also the dialog's accessible name. '' leaves it out.
contentstring | Element''A string is shown as text. An Element is moved into the panel — a hidden <form> in the page, say — and put back where it was, hidden again, on destroy().
htmlstring''Trusted markup for the body. Wins over content. Never pass user input here.
position'top' | 'bottom' | 'left' | 'right''bottom'Flipped to the opposite side when there is no room.
trigger'click' | 'hover' | 'manual''click'Hover includes keyboard focus, and moving the pointer onto the panel keeps it open. 'manual' answers only show() and hide().
width / maxWidthnumbernull / 320Pixels. With no width it sizes to its content up to maxWidth.
closeOnOutsidebooleantrueA click anywhere else puts it away. A popover opened from inside it does not count as elsewhere.
gap / delaynumber10 / 150Pixels off the control; milliseconds a hover popover waits in and out.
onOpen / onClosefunction()— 

popconfirm options

Every popover option, and these. It defaults to position: 'top'.

OptionTypeDefaultDescription
titlestring''The question.
contentstring''A muted line under it — what happens if they say yes.
okText / cancelTextstringlocaleButton labels. '' takes OK and Cancel from the locale bundle.
okType'accent' | 'danger''accent''danger' paints OK red and puts the focus on Cancel, so a stray Enter does no harm.
iconClsstring'icon-warn'Icon beside the question. '' for none.
onConfirmfunction()—OK was pressed. Return false to keep the panel open.
onCancelfunction()—Dismissed any other way: Cancel, Escape, a click elsewhere.

Methods

MethodReturnsDescription
show() / hide() / toggle()thisOpening one popover closes any other, except one it sits inside.
isOpen()boolean 
place()thisRe-position it, after its content has changed size.
getBody()HTMLElementThe body element, to fill yourself.
setContent(content) / setTitle(text)this 
ask(options?)Promise<boolean>popconfirm only. Opens the question and resolves true for OK, false for anything else. Options passed here replace the widget's for this and later asks.
confirm() / cancel()thispopconfirm only. Press OK or Cancel from code.
Put the action in onConfirm, not in a click handler of your own on the same button — that handler runs on the click that opens the question. For buttons drawn fresh on every render, like a grid's row actions, use trigger: 'manual' and ask() from the click handler you already have: if (await Kob.popconfirm(btn, { trigger: 'manual', title: 'Delete?' }).ask()) { … }. Calling Kob.popconfirm on the same button again returns the same instance, so this does not pile up widgets. Leave the trigger at 'click' there and the widget's own click handler would close on the second click what ask() has just opened.

timeline

Who did what, and when. Every record in an internal system eventually has to answer that: the trail an approval took, the states a document moved through, the audit log behind a number that changed. It is a role="list"; when onClickItem is set each entry takes the keyboard (Tab, then Enter or Space).

Options

OptionTypeDefaultDescription
itemsarraynull[{ time, title, text, html, by, tone, iconCls }].
direction'vertical' | 'horizontal''vertical'Horizontal suits a short, wide trail.
align'left' | 'alternate''left'Alternating sides reads well for a handful of entries and badly for fifty.
reversebooleanfalseTurn the list around.
limitnumber0Fold everything past this many behind a button. A long trail is read at its recent end and no further.
onClickItemfunction(item, index)— 

tone takes success, danger, warn, info or accent and colours the mark. html renders rich content — a before and after, a link to the document — where text is plain. Methods: setItems, getItems, add(item, atEnd?), expand().

This is not a stepper, and the difference is worth keeping: a stepper is a path with an end, drawn forward, and it knows what comes next. A timeline is a history — it only knows what already happened, and it is read newest first.

accordion

A stack of titled sections, one of them open. A long settings page, a filter column, a form split into parts that are filled in one at a time — anywhere the whole thing does not fit and the user only needs one part of it at once.

It reads your markup

Every direct child becomes a section. The header comes from the child's title attribute, or from a heading at the top of the block — an <h3> at the start of a section is how anyone would write this without a library, so it keeps working with one.

<div class="kob-accordion">
  <div title="Company">   … </div>
  <div><h3>Billing</h3> … </div>
</div>

items builds the same thing from data when the content is not yours to write by hand.

Options

OptionTypeDefaultDescription
itemsarraynull[{ title, content, iconCls, selected, disabled }]. content is HTML.
selectednumber0Which section starts open. -1 opens none.
multiplebooleanfalseAny number open at once.
collapsiblebooleanfalseAllow the last open section to be closed, leaving the box empty. See the note below.
borderbooleantrueDraw the outer border.
fitbooleanfalseFill the parent's height, the open section taking the slack.
animatebooleantruefalse drops the caret transition.

Methods

MethodReturnsDescription
select(which) / unselect(which) / toggle(which)thiswhich is an index or a title.
getSelected()number | nullThe first open section.
getSelections()number[]Every open section — the one to use with multiple.
getBody(which)HTMLElementThe element holding the content. Append your own nodes to it.
getTitle(which) / setTitle(which, text)string / this 
add(item, at?)thisWidgets in the new content are built for you.
remove(which)thisAnd torn down again here.
enable(which) / disable(which)thisA disabled section cannot be opened.

Events: onSelect(index, title) and onUnselect(index, title).

With one section open at a time, clicking the open header does nothing by default rather than closing it. An accordion that can collapse to a column of empty bars looks broken, and in a navigation column it loses the only marker of where you are. Set collapsible: true when the sections really are optional.

slider

Pick a number, or a span of two, by dragging. Good for a value whose bounds mean something and whose exact digits do not — a price filter, a tolerance, a discount, a quality setting.

Options

OptionTypeDefaultDescription
min / maxnumber0 / 100The ends of the track.
stepnumber1Granularity. 0.01 for money.
valuenumber | arraymin[low, high] when range is on.
rangebooleanfalseTwo handles and a span between them.
marksnumber | arraynullTicks under the track: every n, a list of values, or a list of { value, label }.
showTipbooleantrueThe bubble over the handle while it is being moved.
formatterfunction(value)—Formats the tip, the marks and getText().
label / labelWidth / labelPosition——As the other form widgets.
disabledbooleanfalse 

Methods and events

getValue() returns a number, or [low, high] in range mode; setValue(v) takes either. getText() is the value through formatter. Also enable(), disable(), focus().

onSlide(value) fires while the handle moves; onChange(value) fires when it is let go. Hang a search off onChange, not onSlide — one drag across the track is dozens of onSlide calls and would be dozens of requests.

Each handle is a real <input type="range"> with the track painted over it. That is deliberate: keyboard stepping, touch behaviour and screen-reader announcement already live there, and a slider built out of a div and a pointermove handler loses all three. Arrow keys, Home, End and PageUp work here because the browser is doing them.

calendar

The month grid on its own, outside a dropdown. datebox is the right control when a date is one field on a form; this is for when the date is the screen — a booking sheet, a leave request, a day view that other panels follow. The grid is a real role="grid" with one tab stop: arrows move by day or week, PageUp and PageDown by month (with Shift, by year), Home and End to the ends of the week, and the grid turns the page when the cursor leaves the month. Every day is labelled with its full date.

Options

OptionTypeDefaultDescription
valuestring''ISO yyyy-mm-dd.
min / maxstring''ISO. Days outside are drawn disabled and cannot be picked.
viewstring''The month to open on, as yyyy-mm or a full ISO date. Defaults to the value's month, then to this one.
showTodaybooleantrueThe "Today" button under the grid.

Methods

MethodReturnsDescription
getValue() / setValue(iso)string / thisISO in, ISO out — whatever the locale displays.
getView(){ y, m }The month on screen. m is 1-12.
moveTo(y, m)thisGo to a month without selecting a day.
next() / prev()thisStep whole months, carrying the year.
goToday()thisBack to the current month, selection untouched.
clear()this 

Events: onSelect(iso) fires on every click, onChange(iso) only when the date actually changed, and onMonthChange({ y, m }) when the grid moves — the one to hang "fetch this month's bookings" off.

This draws with the same code datebox uses, so the Buddhist era, the first day of the week and the min/max clamp behave identically in both. See Localisation.

propertygrid

Name and value, down the page, under headings. The settings screen, the details pane beside a record, the row of metadata nobody wants a whole form for.

It is the datagrid, with the columns decided

That is the whole trick: the search box, inline editing and everything else the datagrid does arrive already working. A settings page with sixty entries gets a search box for free, which is the difference between usable and not.

Options

OptionTypeDefaultDescription
rowsarraynull[{ name, value, group, editor, readonly, formatter }]. data works too and means the same thing.
showGroupbooleantrueDraw a heading before each run of a new group.
nameWidthnumber200The value column takes the rest.
nameTitle / valueTitlestring'Name' / 'Value'The two column headers.
defaultEditorstring'text'What a row with no editor of its own gets. '' makes the sheet read-only.
editablebooleanfalseFrom the datagrid — double-click to edit a value.

A row may carry editor: 'numberbox' for its own kind of input, readonly: true to refuse editing whatever the sheet says, and formatter(value, row) to decide how it reads.

Methods

MethodReturnsDescription
getRows()arrayEvery property, group headings left out.
getChanges()objectThe sheet as { name: value } — what a settings screen saves.
getPropertyValue(name)any 
setPropertyValue(name, value)thisFires onValueChange only when the value really changed.
loadData(rows)thisReplace every property. Headings are rebuilt from the new groups.

Events: onValueChange(row, value, was), plus every datagrid event.

Group headings are rows like any other, marked internally so the grid keeps treating the sheet as one flat list. They are never editable and never come back from getRows() — but they do count in the indexes beginEdit(i) takes, because that is the datagrid's own numbering.

messager and toasts

Each helper returns a promise, and still calls an old-style fn callback if you pass one.

if (await Kob.confirm({ title: 'Delete', msg: 'Remove this tenant?' })) { await api.remove(id); Kob.toast({ msg: 'Deleted', type: 'success' }); } const name = await Kob.prompt({ title: 'Rename', msg: 'New name:', value: current }); if (name !== null) { rename(name); }
CallResolves to
Kob.alert({ title, msg, html, icon })true
Kob.confirm({ title, msg })true or false
Kob.prompt({ title, msg, value })the typed string, or null
Kob.toast({ msg, type, timeout }){ close(), element }

icon takes 'info', 'warn' or 'error'; toast type takes 'success', 'error', 'warn' or 'info'. Pass html: true to let msg carry markup.

sweet

The big centred confirmation. Kob.alert and Kob.confirm draw a titled window, which is right for the dozens of small confirmations a data-entry app asks for. This is the other shape: one decision, front and centre, with a coloured mark that says at a glance whether the news is good.

await Kob.sweet.success('Saved', 'Invoice 2026-0042 has been issued.'); if (await Kob.sweet({ type: 'question', title: 'Delete this tenant?', msg: 'Room 112/3 becomes vacant. This cannot be undone.', showCancel: true, confirmText: 'Delete', cancelText: 'Keep', danger: true })) { await api.remove(id); }
It resolves true for confirm and false for everything else — cancel, Escape, a click on the backdrop, the timer running out — so if (await Kob.sweet(…)) is always safe to write. It never resolves undefined.
OptionTypeDefaultWhat it does
typestring'info'success, error, warn, info, question, or '' for no mark.
title / msgstring''Heading and body.
htmlbooleanfalseTreat msg as markup. Only pass markup you produced yourself.
showCancelbooleanfalseAdd the second button.
confirmText / cancelTextstringlocaleName the buttons after what they do, not OK and Cancel.
dangerbooleanfalsePaint the confirm button as destructive.
timernumber0Auto-dismiss after n ms, resolving false.
closeOnBackdropbooleantrueA click on the dimmed page dismisses.

Kob.sweet.success, .error, .warn, .info and .question are shorthands taking (title, msg) or the same options object.

statusbar

The strip along the bottom of a screen: what the app is doing, how many rows there are, who is signed in. CSS only — there is no widget to build and nothing to destroy.

<div class="kob-statusbar"> <span class="kob-status-item kob-status-ok"><i class="kob-status-dot"></i>Ready</span> <span class="kob-status-sep"></span> <span class="kob-status-item"><span class="kob-icon icon-table"></span><b>84</b> rows</span> <span class="kob-spacer"></span> <span class="kob-status-item"><span class="kob-icon icon-user"></span>admin</span> </div>
ClassWhat it is
kob-statusbarThe strip. Put it last inside a bordered box, or in a layout's south region.
kob-status-itemOne entry. Aligns an icon, a dot and text on one line.
kob-status-dotA coloured dot inside an item.
kob-status-sepA vertical rule between groups.
kob-spacerPushes everything after it to the right.
kob-status-ok / -busy / -badOn an item: colours its text and its dot green, amber or red.
The state classes colour the dot and the label, and the label still says what the state is. Colour on its own would leave the state invisible to a reader who cannot separate green from amber.

combogrid

A field that picks a record out of a table. Type to search, look at three columns to be sure it is the right customer, pick it. A combobox shows one line per row, and one line is not enough to tell two customers named Somchai apart.

OptionTypeDefaultDescription
columnsColumn[]nullColumns of the grid in the panel — the same shape a datagrid takes.
data / urlarray | stringnullRows to show, or where to fetch them.
idFieldstring'id'Which field the value comes from.
textFieldstring'text'Which field is shown in the box once a row is chosen.
modestring'local''remote' sends what was typed and shows what comes back — the only thing that works for a table nobody scrolls through.
minCharsnumber1Remote only: say nothing until this many characters are typed.
delaynumber300Remote only: milliseconds to wait for the typing to stop.
panelWidthnumber0Width of the panel. Left at 0 it matches the field, which is usually too narrow.
panelHeightnumber260Height of the grid inside it.
pageSizenumber20Rows per page inside the panel.
editablebooleantruefalse = pick from the grid only.
onSelectfunction—Called with the whole row.

Methods: getValue(), setValue(id), getRow(), setRow(row), getText(), getGrid(), loadData(rows), reload(url), clear(), isValid().

The record, not only its id. getRow() hands back everything that was on screen — the credit terms, the address, the price band — because what an application does next almost always needs them, and fetching the row again to get them would be absurd when it was just in front of the user.

combotree

A field that picks a node out of a hierarchy: a department, a category, an account. Flattening a tree into a combobox loses the only thing that mattered — Sales under Bangkok is not Sales under Chiang Mai.

OptionTypeDefaultDescription
data / urlarray | stringnullThe nodes, or where to fetch them. Same shape as a tree.
multiplebooleanfalseTick boxes, and an array of values.
cascadeCheckbooleantrueTicking a folder ticks its children.
selectFoldersbooleantruefalse lets only leaves be chosen.
searchablebooleantrueTyping filters the tree, keeping the branches that lead to a match.
expandDepthnumber1How many levels start open.
panelHeightnumber280Height of the panel.
onSelectfunction—Called with the node.

Methods: getValue(), setValue(v), getNode(), getText(), getTree(), loadData(nodes), reload(url), clear(), isValid().

tagbox

Free text, one chip at a time — keywords on a document, recipients on a message, labels on a ticket. The difference from a multiple combobox is the difference between choosing and writing: a combobox picks rows out of a list somebody else made, and this takes whatever is typed.

OptionTypeDefaultDescription
valuestring[] | string[]An array, or a comma-separated string.
separatorstring | RegExp','What ends a tag besides Enter and Tab. A pasted list is split on it.
maxTagsnumber0Cap the number of chips. 0 is no cap.
uniquebooleantrueRefuse a tag that is already there.
lowercasebooleanfalseFold every tag to lower case as it is committed.
suggestionsarraynullA list to pick from. The typed text still wins; the list is only faster.
tagRulesrulesnullRules each tag has to satisfy — 'email' is the common one.
onBeforeAddfunction—Return false to refuse a tag, or a string to change it.
onAdd / onRemovefunction—After the chip appears or goes.

Methods: getValue(), setValue(list), add(tag), remove(tag), getText(), clear(), isValid().

Enter, a comma, or Tab commits a tag; Backspace on an empty field takes the last one back. A half-typed tag is committed on the way out of the field rather than thrown away — somebody who typed a keyword and tabbed on meant to add it.

rating

Stars, on a real <input type="range"> hidden under them. That is not a trick: it is the only way the field is reachable by keyboard, announced properly by a screen reader, and submitted by a plain form — all three of which a row of clickable spans loses.

OptionTypeDefaultDescription
maxnumber5How many marks.
valuenumber0The score.
halfbooleanfalseAllow halves — 3.5 stars.
readonlybooleanfalseShow a score rather than collect one.
glyphstring★The mark. Any character; the CSS colours it.
textfunction | falsenullText beside the stars, as a function of the value.
clearablebooleantrueClicking the mark already set clears the rating — the only way back to "no rating".
onChange / onHoverfunction—The value as it is set, and as the pointer moves over the marks.

Methods: getValue(), setValue(n), clear(), readonly(mode), enable(), disable().

colorpicker

A colour, chosen from a palette or typed as hex. Not a hue wheel: the colours an internal system needs picking are the ones in its own palette, and a wheel makes #7b7b7c possible — which is not a decision anybody meant to make.

OptionTypeDefaultDescription
palettestring[]built inThe colours offered. The default set all take white or near-black text legibly.
allowCustombooleantrueA native colour input at the foot of the panel, for anything else.
editablebooleantrueLet the hex code be typed into the field.
clearablebooleantrueA button that empties it.
onSelectfunction—Called with the colour as '#rrggbb'.

Methods: getValue(), setValue(hex), clear(), isValid(). Kob.toHex(v) normalises any colour-shaped string.

The value crosses the API as a lowercase six-digit hex string, always: #ff0000, never red and never rgb(255,0,0). One shape means an application can compare two of them.

signature pad

A signature, drawn with a finger or a mouse — delivery notes, receipts, consent forms. Drawn at the device's pixel density so a capture on a phone is not a smear when it is printed, and kept as points rather than only as pixels, so it can be undone a stroke at a time and exported as vectors.

OptionTypeDefaultDescription
heightnumber160Pixels. The width follows the element.
colorstring'#1c1c1e'Ink.
minWidth / maxWidthnumber0.8 / 2.6The stroke at its thinnest and thickest — it varies with how fast the pointer is moving, which is what makes a signature look like one.
guidebooleantrueA line to sign on, the way a paper form has one.
guideTextstring''Text under that line.
buttonsbooleantrueClear and undo under the pad.
readonlybooleanfalseShow a signature already captured.
onChangefunction—Called with the strokes when one is finished.

Methods: isEmpty(), clear(), undo(), toDataURL(type), toSVG(), getStrokes(), setStrokes(list), readonly(mode), resize().

SVG for anything that will be printed. A signature captured at 300 pixels wide and printed across a page is a smudge as a bitmap and exact as vectors, and toSVG() costs nothing extra because the strokes were kept as points anyway.

editor

A small rich text editor: bold, italic, lists, headings, links. The scope is fixed and deliberately narrow — a note on a ticket, terms on a quotation, a description on a product. Everything past that (tables, images, collaborative editing) is an editor project rather than a widget.

OptionTypeDefaultDescription
valuestring''HTML. Sanitised on the way in and on the way out.
toolsstring[]a sensible setWhich buttons, in which order. '|' is a separator. Names: bold, italic, underline, strike, h1–h3, p, quote, ul, ol, left, center, right, indent, outdent, link, unlink, clean.
heightnumber200Pixels. The box scrolls past this.
promptstring''Placeholder shown while it is empty.
pasteHtmlbooleanfalseKeep a small set of tags when HTML is pasted, instead of taking the text.
maxLengthnumber0Caps the words, not the markup.
counterbooleanfalseShow the character count under the box.
readonlybooleanfalseShow without letting it be changed.
rules / requiredrules—Checked against the text, not the HTML.

Methods: getValue(), setValue(html), getText(), insertHTML(html), clear(), focus(), readonly(mode), isValid(). Kob.sanitize(html) is the cleaner on its own.

Pasting and sanitising. HTML from Word or from a web page brings fonts, colours and class names that then have to be cleaned out of the database forever, so a paste arrives as plain text unless pasteHtml is on. And the value is sanitised on the way out as well: an editor that trusted its own contenteditable would hand back whatever a determined user managed to put in it, which is a stored XSS in every page that renders the result.

drawer

A panel that slides in from an edge — the other answer to "where does this form go?". A dialog takes the middle of the screen and everything behind it stops; a drawer keeps the list you were looking at in view, and is the right shape for the long thin things a record usually is.

OptionTypeDefaultDescription
titlestring''Heading. It names the panel to a screen reader too.
positionstring'right'left, right, top, bottom.
width / heightnumber380 / 320Width for a side drawer, height for one from the top or bottom.
modalbooleantrueDim the page behind, and keep Tab inside the panel.
closeOnMaskbooleantrueClicking the dimmed page closes it.
closeOnEscapebooleantrueSo does Escape.
buttonsarraynullButtons along the bottom: { text, iconCls, color, handler }.
closedbooleantruefalse opens it on the way up.
focusstring | ElementnullWhat takes the focus on the way in.

Methods: open(), close(), toggle(), isOpen(), setTitle(text), setButtons(list), getPanel().

Same bones as the dialog: a mask, a focus trap, Escape to close, and the focus handed back to whatever opened it. The differences between the two are all geometry, and the parts that are easy to get wrong are the ones they share.

pagination

The pager, on its own — for the lists that are not grids. A page of cards, search results, a gallery, a report. It holds no data: it is told the total and asked what page it is on.

OptionTypeDefaultDescription
totalnumber0How many rows there are altogether.
pageSizenumber20How many fit on a page.
pagenumber1Which page is shown.
pageListnumber[][10,20,50,100]Sizes offered in the menu. Empty hides it.
maxButtonsnumber7How many numbered buttons before the run folds.
showInfobooleantrueShow "Showing 1-20 of 482".
showEndsbooleantrueFirst and last buttons as well as next and previous.
onChangefunction—(page, pageSize) whenever either changes.

Methods: select(page), setTotal(n, keepPage), setPageSize(n), getPage(), getPageSize(), pageCount(), getRange().

Numbered pages rather than only next and back, because "page 7 of 25" is the only way somebody gets back to where they were yesterday. The run folds around wherever you are and keeps its width, so the buttons do not move under the pointer as you walk through them.

skeleton

The shape of what is coming, while it is still coming. A spinner says "wait"; a skeleton says "wait, and it will look like this" — which matters on a screen where what is loading is a table of forty rows.

OptionTypeDefaultDescription
typestring'text'text (lines), table (rows of cells), card, list (an avatar and two lines each).
rowsnumber3How many lines, rows or items.
colsnumber4Columns, for the table shape.
avatarbooleanfalseA round block in front of the lines.
animatedbooleantrueThe shimmer. Off for anyone who asked for less motion, whatever this says.
labelstring'Loading...'What a screen reader is told while this is up.

Methods: replaceWith(content), and destroy() when the real thing arrives.

The shapes are aria-hidden with one live line beside them: "Loading" said once is useful, and forty grey rectangles announced one at a time is not.

avatar

A person, in a small round space. Without a photo — which on an internal system is most of the time — it draws initials on a colour derived from the name, so the same person is the same colour on every screen and in every session.

OptionTypeDefaultDescription
namestring''Used for the initials, the colour and the label.
srcstring''A photo. A broken one falls back to the initials rather than to the browser's broken-image glyph.
sizestring | number'md'sm, md, lg, or a number of pixels.
squarebooleanfalseRounded square instead of a circle.
statusstring''online, away, busy, offline — a dot in the corner.
initials / colorstring—Override what would be worked out from the name.
iconClsstring''An icon instead of initials, for a group or a system account.

Methods: setName(n), setSrc(url), setStatus(s), getInitials(). Kob.initialsFor(name) and Kob.colorFor(name) are the two on their own.

Thai names are handled properly, which is why this is a widget rather than three lines of CSS. "First letter of each word" gives one letter for a name written as one word, and taking a single code point leaves a vowel mark stranded — initialsFor('สมชาย ใจดี') is สใ, with each consonant keeping the marks that belong to it.

tilegrid

A grid of tiles you tap: products on a till, rooms in a hotel, seats in a cinema, tables in a restaurant. The widget owns the layout, the selection, the state colours and the keyboard — and nothing about what a tile is. What goes inside is whatever render(item) returns, so one widget draws a photo and a price, a room number with a guest under it, or a bare seat label.

OptionTypeDefaultDescription
itemsobject[][]The tiles. Each is any object; its idField names it. Fields the widget itself reads: state, disabled, badge, row, col, rowSpan, colSpan.
idFieldstring'id'The field that identifies a tile. Falls back to the position when an item has none.
selectModestring'click''click': every tap is an action and nothing stays chosen (a till). 'single': one tile at a time (a room). 'multiple': any number, up to max (seats).
valuearraynullIds chosen to start with, in single and multiple mode.
clearablebooleantrueIn single mode, tapping the chosen tile again unchooses it.
maxnumber0Most tiles that may be chosen in multiple mode; 0 is no limit. A tap past it calls onLimit.
columnsnumbernullA fixed column count. Left null, tiles fill the width, as many as fit. Tiles placed with col work the count out from the placements.
minWidthnumber140The narrowest a tile gets before the row wraps, in pixels. Sets --kob-tile-min.
gapnumber | stringnullSpace between tiles: pixels or any CSS length. Sets --kob-tile-gap (default 10px).
aspectstringnullA CSS aspect-ratio for every tile, e.g. '1 / 1'. Left null, tiles size to their content. Sets --kob-tile-aspect.
renderfunctionnull(item, ctx) → string | Element. Replaces the built-in tile. ctx is { index, selected, state, disabled }. A returned string is HTML and is not escaped — escape any user text in it with Kob.dom.escapeHtml, or return an Element.
imageField / titleField / textFieldstring'image' / 'name' / 'price'The fields the built-in tile draws: a picture, a bold title, a line of muted text. Values are escaped.
badgeFieldstring'badge'A number above zero is drawn as a count in the tile's corner (99+ past 99). The order count on a till.
stateFieldstring'state'The field read into the tile's data-state. available, occupied, reserved and cleaning are coloured by default; style any other name with .kob-tile[data-state="name"].
isSelectablefunctionnull(item) → boolean. False leaves the tile drawn but unchoosable — a taken seat. onClick still fires for it.
searchFieldsstring[]nullFields filter(text) looks in. Left null, the title field.
disabledbooleanfalseGreys out and ignores the whole grid. An item's own disabled: true does the same for one tile.
labelstringnullWhat a screen reader calls the grid. Give one in single and multiple mode, where it is a listbox.
onClickfunction—(item, event) on any tap of a tile that is not disabled, chosen or not.
onSelectfunction—(item) when a tap chooses a tile — on every tap in click mode.
onChangefunction—(items) with the full chosen set, whenever a tap changes it. Not called by the methods below.
onContextMenufunction—(item, event) on right-click, or long-press on a touch screen. Call event.preventDefault() and open a menu at the pointer.
onLimitfunction—(item) when a tap is refused because max is reached.

Methods: load(items), getItems(), getItem(id), add(item), remove(id), update(id, patch) (redraws just that tile), setBadge(id, n), getSelected() (items), getValue() (ids), setValue(ids), select(id), unselect(id), toggle(id), clearSelection(), filter(text | fn | null) (returns how many tiles are showing), enable(), disable().

Placing tiles. By default the grid places tiles itself, in order. For a plan — a cinema with an aisle, a floor of rooms — give items row and col (both 1-based, as in CSS grid) and optionally rowSpan and colSpan. Cells nobody claims stay empty, which is what makes the aisle.

Styling. Everything shape-related is a custom property on the element — --kob-tile-min, --kob-tile-gap, --kob-tile-aspect — so a project can restyle the grid in CSS without touching options. A tile is .kob-tile, with .kob-tile-selected, .kob-tile-disabled and .kob-tile-locked (drawn but not choosable) on it as they apply.

State is data, not mode: update it with update(id, { state: 'occupied' }), which redraws only that tile — a floor of rooms whose status changes every few seconds does not flicker.

descriptions

A record, read rather than edited — the other half of every record screen, and the half nobody ships. Written by hand it becomes a two-column table and a stylesheet per project, which is how one screen ends up with the labels bold and the next one has them grey.

OptionTypeDefaultDescription
itemsarraynull{ label, value, field, span, formatter, iconCls }.
titlestring''A heading above the block.
columnsnumber1How many label/value pairs per line.
layoutstring'horizontal''vertical' puts the value under the label, for a narrow panel.
borderedbooleanfalseRules between the rows.
sizestring'md''sm' for inside a panel.
emptyTextstring—What an empty value shows as.
labelWidthnumber0Width of the label column, in the horizontal layout.

Methods: setItems(list), getItems(), setValues(record).

A real <dl> underneath, because that is what this is — and because a screen reader then reads "Room, 101" as a pair rather than as two loose strings.

result

The page that says nothing is here, or that something went wrong. Every application has these four screens — empty, error, forbidden, done — and every application draws them differently, usually as a bare sentence in the middle of a white box.

OptionTypeDefaultDescription
typestring'empty'empty, error, forbidden, success, search, info.
titlestring''What happened.
textstring''What it means.
actionsarraynull{ text, iconCls, color, soft, href, handler } — the way out.
iconCls / imagestring—A different mark, or an illustration.
sizestring'md''sm' for inside a panel.
extrastring | ElementnullAnything else, under the actions.

Methods: set(spec) changes what it says without rebuilding it.

The actions are what this is really for. An empty state without one is a dead end, and "No invoices yet" with a New invoice button under it is the most useful screen in the application for somebody on their first day. An error or forbidden result is also announced as an alert; the others are a state of the page, not an interruption.

sparkline

A chart small enough to read as a word: the shape of a number over time, next to the number. Revenue in a KPI tile, stock movement in a grid cell, a trend beside a total. Inline SVG, and no dependency — which is the point, since a charting library is exactly what this library does not have.

OptionTypeDefaultDescription
dataarraynullNumbers, or objects read through valueField.
typestring'line'line, area, bar, arc (a half-circle gauge).
width / heightnumber100 / 28Pixels. The height is what makes a sparkline a sparkline — keep it small.
colorstringvar(--kob-accent)The stroke, or the fill for bars.
thresholdnumbernullBars below this take belowColor — a budget, a target.
lastPointbooleantrueA dot on the last point, which is the one anybody is looking for.
maxnumbernullFor 'arc': the value is a fraction of this.
zeroBasedbooleanfalseFix the bottom of the scale at zero rather than at the smallest value.
labelstring | functionnullWhat a screen reader is told instead of the picture.

Methods: setData(list), getData(), getValues().

The SVG is aria-hidden and the numbers are stated in text beside it — a picture of a trend says nothing to a screen reader, and "22, up from 4" does.

scheduler

A calendar with things in it. calendar is a month grid for picking a date; this is the other one — bookings, site visits, room occupancy, a delivery schedule — in month, week, day and agenda views.

Keyboard: every event is a button in the tab order and Enter or Space fires onEventClick; the toolbar is a toolbar whose view buttons carry aria-pressed, and the title is a live region. In the month view the days are a grid with one tab stop: arrows walk the days, and Enter or Space on a day fires onSlotClick exactly as clicking the empty part of it does. In the week and day views the empty slots are buttons with one tab stop for the whole grid (it starts at eight o'clock): Up and Down walk a day, Left and Right change day at the same hour, PageUp and PageDown move an hour, and Enter fires onSlotClick with the slot's start and end.

OptionTypeDefaultDescription
viewstring'week'month, week, day, agenda.
datestring | DatetodayThe day in view.
eventsarraynull{ id, title, start, end, allDay, color, className, data }. Times are 'yyyy-mm-dd HH:MM' or a Date.
urlstring''Or fetch them. from and to are sent on every view change.
startHour / endHournumber8 / 20First and last hour drawn in the week and day views.
hourHeightnumber44Height of one hour, in pixels.
slotMinutesnumber30What a click on empty space covers.
viewsstring[]all fourWhich views the toolbar offers.
firstDaynumberthe locale'sDay the week starts on: 0 is Sunday.
hour24booleantrue24-hour clock on the ruler.
eventFormatterfunctionnullRender one event's contents yourself.
onEventClickfunction—Called with the event.
onSlotClickfunction—(start, end) as 'yyyy-mm-dd HH:MM' — a click on empty space, which is how a new booking starts.
onDayClick / onViewChangefunction—A click on a day number, and either the view or the date changing.

Methods: loadData(events), reload(url), addEvent(e), updateEvent(id, patch), removeEvent(id), getEvent(id), getEvents(), setView(v), goto(date), step(±1), getDate(), getRange().

Overlapping events share the width of their hour rather than covering each other. Two bookings at ten o'clock is exactly the case somebody is looking at the calendar to find, and a calendar that hides the second one is worse than no calendar. Times cross the API in one shape — ISO date, 24-hour time — whatever the locale displays, so a Buddhist-era calendar can never leak 2569 into the data.

React, Vue and Angular

kob-ui is not a component library — it builds DOM imperatively. That works inside any framework, on one condition: give it an element the framework will leave alone, and destroy the widget when that element goes away.

The rule. Render an empty container, hand it to kob-ui, and never render children into it yourself. Everything inside belongs to the widget; everything outside belongs to your framework.

destroy() takes back every node the widget added and returns the element as it found it, so mounting again on the same element is safe — which is what React's StrictMode does in development, and what any router does when you revisit a screen.

React

import { useEffect, useRef } from 'react'; import Kob from 'kob-ui'; import 'kob-ui/css'; /** Builds a widget on mount and destroys it on unmount. */ export function useKob(widget, options, deps = []) { const ref = useRef(null); const instance = useRef(null); useEffect(() => { instance.current = Kob[widget](ref.current, options); return () => { instance.current.destroy(); instance.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); return [ref, instance]; }
function OrderGrid({ onPick }) { const [ref, grid] = useKob('datagrid', { columns: [ { field: 'room', title: 'Room', width: 100 }, { field: 'tenant', title: 'Tenant', width: 180 } ], url: '/api/rooms', onClickRow: (i, row) => onPick(row) }); // Drive it later through the ref — never by re-rendering into the div. const refresh = () => grid.current.reload(); return ( <>
); }

Vue 3

<script setup> import { ref, onMounted, onBeforeUnmount, shallowRef } from 'vue'; import Kob from 'kob-ui'; import 'kob-ui/css'; const host = ref(null); const grid = shallowRef(null); // shallowRef: never make a widget reactive onMounted(() => { grid.value = Kob.datagrid(host.value, { columns: [{ field: 'room', title: 'Room', width: 100 }], url: '/api/rooms' }); }); onBeforeUnmount(() => grid.value?.destroy()); defineExpose({ reload: () => grid.value.reload() }); </script> <template> <div ref="host" style="height: 420px"></div> </template>

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<HTMLElement>) {} ngOnInit() { this.grid = Kob.datagrid(this.host.nativeElement, this.options); } ngOnDestroy() { this.grid?.destroy(); } reload() { this.grid.reload(); } } // <div [kobDatagrid]="{ columns, url: '/api/rooms' }" style="height:420px"></div>

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.

filterbutton

The datagrid's filter chip, on its own — so a filter bar can sit above a chart, a card list, or anything else that is not a grid.

OptionTypeDefaultDescription
labelstring''Shown before the value: "Building: All".
kind'enum' | 'text' | 'number''enum'Tick list, contains box, or lower bound.
optionsarraynullenum only: ['Paid', 'Pending'] or [{ value, text }].
valuestring[] | string | numbernullStarting value.
multiplebooleantruefalse makes the list single-choice.
onChangefunction(value)—string[] for enum, number, string, or null.

Methods: getValue(), setValue(v), clear(), setOptions(list).

alert

The banner that stays on the page — a validation summary above a form, a warning across the top of a screen.

Two different things share the word. Kob.alert() opens a modal dialog. This inline banner is Kob.alertbox(), or class="kob-alert" in markup.

OptionTypeDefaultDescription
type'info' | 'success' | 'warn' | 'danger''info'Colour and icon.
titlestring''Bold line above the message.
msgstring—Body. Omit to keep what is already in the markup.
htmlbooleanfalseTreat title and message as HTML.
iconClsstring | falseby typeOverride or remove the icon.
closablebooleanfalseAdds a dismiss button.
timeoutnumber0Dismiss by itself after this many ms.
onClosefunction— 

Methods: setMessage(s), setType(t), close().

badge

Static badges are markup. The widget is for the ones that change — an unread count on a toolbar button, a status that flips as data arrives.

<span class="kob-badge kob-badge-success">Paid</span> <span class="kob-badge kob-badge-danger">Overdue</span> <span class="kob-badge kob-badge-dot kob-badge-warn"></span>
const unread = Kob.badge('#inbox-count', { type: 'danger', max: 99 }); unread.increment(); // 3 -> 4 unread.setText(250); // shows 99+ unread.setText(0); // hides itself
OptionTypeDefaultDescription
textstring | numbermarkupWhat is shown.
typestring'muted'muted, accent, success, warn, danger, info.
dotbooleanfalseA coloured dot with no text.
maxnumber99Counts above this show as "99+".
hideEmptybooleantrueHide at 0 or empty.
labelstring''What a dot means, for someone who cannot see its colour ("Overdue"). Sets role="img" and aria-label. A dot with no label is decoration.

Methods: setText(v), getText(), setType(t), increment(by).

progressbar

A measured fill, or a sweeping stripe while the length of the job is unknown.

OptionTypeDefaultDescription
valuenumber00-100, clamped.
indeterminatebooleanfalseSweeping stripe; no percentage shown.
textstring | false'{value}%'Label template; false for none.
heightnumber20Bar height in pixels.
typestring'accent'accent, success, warn, danger.
stripedbooleanfalseMoving stripes on the fill.
onChange / onCompletefunction—onComplete fires once, when the value first reaches 100.

Methods: setValue(n), getValue(), indeterminate(on).

preloader and spinner

Over an element it is a busy mask; over <body> it is the splash the page shows before the application has drawn anything.

// The one-liner: cover, work, uncover. const done = Kob.loading('#panel', 'Fetching invoices...'); await api.load(); done(); // Or keep the widget and drive it. const splash = Kob.preloader(document.body, { size: 'lg', msg: 'Starting' }); splash.setMessage('Loading settings'); splash.hide();

The spinner on its own is a class, with three sizes:

<span class="kob-spinner kob-spinner-sm"></span> <span class="kob-spinner kob-spinner-md"></span> <span class="kob-spinner kob-spinner-lg"></span>
OptionTypeDefaultDescription
msgstring''Caption under the spinner.
size'sm' | 'md' | 'lg''md' 
visiblebooleantruefalse builds it hidden.
backdropbooleantrueDim what is underneath.
timeoutnumber0Hide by itself after this many ms.

Methods: show(msg), hide(), isVisible(), setMessage(s).

A static element gets position: relative so the cover has something to sit against; destroy() puts that back.

Grid, flex and button groups

Enough layout CSS to lay out a form or a dashboard without reaching for a whole framework. Deliberately small.

The 12-column row

<div class="kob-row"> <div class="kob-col-8">main</div> <div class="kob-col-4">side</div> </div>

Below 720px every column takes the full width — one honest breakpoint rather than a matrix of per-size classes. For cards and tiles, kob-autogrid fits as many columns as it can:

<div class="kob-autogrid">…</div> <!-- min 260px --> <div class="kob-autogrid kob-autogrid-sm">…</div> <!-- min 180px --> <div class="kob-autogrid" style="--kob-min:320px">…</div>

Flex helpers

ClassDoes
kob-flex / kob-inline-flexFlex container with the standard gap.
kob-flex-col / kob-flex-wrapDirection and wrapping.
kob-items-start / -center / -end / -baseline / -stretchCross-axis alignment.
kob-justify-start / -center / -end / -between / -aroundMain-axis alignment.
kob-grow / kob-noneTake the slack, or refuse to.
kob-spacerAn empty element that pushes what follows to the far end.
kob-gap-0 … kob-gap-5Gap scale: 0, 6, 10, 14, 20, 28px.
kob-p-0 … kob-p-4, kob-mt-*, kob-mb-*Padding and vertical margin on the same scale.
kob-fill / kob-w-full / kob-h-full / kob-scrollSizing and overflow.
kob-truncate / kob-muted / kob-small / kob-strong / kob-text-*Text.

Button groups and toolbars

<div class="kob-btn-group"> <button class="kob-btn selected">Rows</button> <button class="kob-btn">Cards</button> <button class="kob-btn">Chart</button> </div> <div class="kob-toolbar"> <a class="kob-linkbutton icon-add"><span class="kob-icon"></span>New</a> <span class="kob-sep"></span> <a class="kob-linkbutton icon-print"><span class="kob-icon"></span>Print</a> <span class="kob-spacer"></span> <a class="kob-linkbutton icon-settings"><span class="kob-icon"></span></a> </div>

Keep selected on one member and the group reads as a segmented control. kob-btn-group-vertical stacks it.

kob-sep divides one group of commands from the next, and kob-spacer pushes what follows to the far end of the strip. For the desktop-application shape — a large glyph with its name underneath — see icon and caption on the button itself, and the worked toolbar beside it.

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.

MemberDescription
static widgetNameDrives the class name: 'rating' → class="kob-rating".
static defaultsMerged under data-options and the constructor argument.
this.elThe element the widget is mounted on.
this.optionsThe 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

MemberDescription
Kob.versionThe 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.configrequest, 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) / registryExtension points.
Kob.domThe internal DOM helpers: on, el, append, css, escapeHtml, …
Kob.dateparseDate, formatDate, toISO, today.
Kob.classesThe widget classes themselves, for extends.