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.
node_modules/kob-ui/package.json
with the version at the top of llms.txt — the
answer is almost always to upgrade.
Script tag
Bundler
What is in the package
| File | Format | Use it for |
|---|---|---|
| dist/kob-ui.min.js | IIFE | A plain <script> tag. Defines window.Kob. |
| dist/kob-ui.js | IIFE | The same, unminified, for debugging. |
| dist/kob-ui.esm.js | ESM | import in a bundler. |
| dist/kob-ui.cjs | CommonJS | require(). |
| dist/kob-ui.css | CSS | The whole stylesheet, icons included. |
| types/index.d.ts | TypeScript | Picked 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.
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.
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.
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.
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.
| Token | Used for |
|---|---|
| --kob-accent | Primary buttons, selection, focus rings, active tabs |
| --kob-accent-dark | Header text, sorted column, emphasis on the accent |
| --kob-accent-soft | Headers, hovers, striped rows, chip backgrounds |
| --kob-accent-hover | The stronger hover state |
| --kob-surface / --kob-surface-2 | Panels, windows, dropdowns |
| --kob-bg | The ground the widgets sit on |
| --kob-text / --kob-muted | Body text, secondary text |
| --kob-border / --kob-border-soft | Outlines, table rules |
| --kob-danger / --kob-success / --kob-warn / --kob-info | Status colours and badges |
| --kob-radius / --kob-radius-sm / --kob-radius-xs | Corner rounding |
| --kob-shadow-1 / -2 / -3 | Cards, dropdowns, windows |
| --kob-scrollbar-thumb / -hover / -track / -size | Scrollbars — 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:
A region that keeps its own colours — a dark code panel on a light page, say — just redefines the tokens locally:
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.
| Value | What it looks like |
|---|---|
| light | The default. Cool greys on white. |
| dark | Blue-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.
|
Switching at runtime is one attribute. Remembering the choice is on you — the library never writes to storage:
A theme of your own is one more block. Only the tokens that differ need redefining — everything else falls through to the light values:
Localisation
Every string the widgets show, plus the date format and calendar era, comes from the active locale. English and Thai are built in.
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.
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:
textbox, numberbox, passwordbox
Three thin wrappers over <input>: an optional label,
a required flag, and for numbers, clamping and formatting.
Options
| Option | Type | Default | Description |
|---|---|---|---|
| label | string | '' | Draws a label next to the field. Omit for a bare input. |
| labelWidth | number | 110 | Label width in pixels when it sits to the left. |
| labelPosition | 'left' | 'top' | auto | Defaults to top on touch screens, left otherwise. |
| prompt | string | '' | Placeholder text. |
| value | any | — | Initial value. |
| required | boolean | false | Checked by isValid() and by the containing form. |
| readonly / disabled | boolean | — | Applied to the underlying input. |
| min / max | number | — | numberbox: clamped on blur. |
| precision | number | — | numberbox: decimal places applied on blur. |
| groupSeparator | string | '' | numberbox: thousands separator shown while unfocused. |
| onChange | function(value) | — | Fires on the input's native change. |
Methods
| Method | Returns | Description |
|---|---|---|
| getValue() | string | number | numberbox returns a number, or '' when empty. |
| setValue(v) | this | numberbox also clamps and formats. |
| clear() | this | Empties the field and drops the invalid mark. |
| isValid() | boolean | Whether 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.
| Option | Type | Default | Description |
|---|---|---|---|
| mask | string | '' | A pattern of placeholders and literals: 9 a digit, A a letter, * either, # an optional digit. Everything else is written in for the user. |
| maskPrompt | string | '_' | Character the mask is shown with in the placeholder. '' shows none. |
| prefix / suffix | string | '' | Small text in front of or after the field — a currency, a unit, a domain. |
| clearable | boolean | false | A button that empties it, shown once there is something in it. |
| counter | boolean | false | "12/140" under the field. |
| maxLength | number | 0 | Caps the length, and gives the counter something to count against. |
| autosize | boolean | false | A <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
| Option | Type | Default | Description |
|---|---|---|---|
| data | object[] | null | Rows to show. |
| url | string | '' | Fetched through Kob.config.request. |
| queryParams | object | null | Sent with the request. |
| valueField | string | 'id' | Field getValue() returns. |
| textField | string | 'text' | Field shown in the box. |
| formatter | function(row) | — | HTML for one list item. The box shows its plain text. |
| multiple | boolean | false | Pick several: getValue() returns an array and the chosen rows show as chips. See Picking several. |
| editable | boolean | true | false means pick from the list only. |
| panelHeight | number | 300 | Maximum dropdown height in pixels. |
| limit | number | 300 | Items rendered at once; the rest are reachable by typing. |
| label / labelWidth / prompt / required | — | — | As for textbox. |
| onChange | function(value, row) | — | After a pick. |
| onSelect | function(row) | — | The row that was clicked. |
| onLoadSuccess | function(rows) | — | After loadData or a fetch. |
Methods
| Method | Returns | Description |
|---|---|---|
| getValue() / setValue(v) | any / this | The valueField value. |
| getText() | string | What the user reads. |
| loadData(rows) | this | Swap the list without touching the value. |
| reload(url?) | Promise | Fetch 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.
| Option | Type | Default | Description |
|---|---|---|---|
| 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. |
| minChars | number | 1 | Remote only: say nothing until this many characters are typed. |
| delay | number | 300 | Remote only: wait this long after the last keystroke. One request per keystroke is a denial of service on your own API. |
| groupField | string | '' | Rows with the same value here are drawn under one heading, in the order they were given. |
| allowCreate | boolean | false | Offer what was typed as a new item when nothing matches it exactly. |
| onCreate | function(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
| Option | Type | Default | Description |
|---|---|---|---|
| value | string | Date | — | ISO, a Date, or a string in the display format. |
| format | string | locale | Overrides the locale's format, from yyyy, yy, mm, dd. |
| min / max | string | '' | ISO bounds; days outside them are disabled. |
| showToday | boolean | true | The shortcut under the calendar. |
| label / labelWidth / prompt / required | — | — | As for textbox. |
| onChange / onSelect | function(iso) | — | Both receive the ISO date. |
Methods
| Method | Returns | Description |
|---|---|---|
| getValue() | string | ISO yyyy-mm-dd, or ''. |
| setValue(v) | this | Accepts ISO, a Date, or the display format. |
| getText() | string | What 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
| Option | Type | Default | Description |
|---|---|---|---|
| value | string | Date | — | 'yyyy-mm' (yearbox: 'yyyy'), a Date, or text the way the field shows it. |
| format | string | '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 / max | string | '' | 'yyyy-mm' (yearbox: 'yyyy'). Periods outside are disabled. A full ISO date is cut to fit. |
| showCurrent | boolean | true | The "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 / onSelect | function(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
| Method | Returns | Description |
|---|---|---|
| getValue() | string | 'yyyy-mm' (yearbox: 'yyyy'), or ''. |
| setValue(v) | this | Accepts the ISO form, a Date, or the display text. |
| getText() | string | What is on screen, e.g. มีนาคม 2569. |
| getRange() | { from, to } | null | The 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() | this | The picker panel. |
| clear() / isValid() / validationMessage() | — | As for the other fields. |
| enable() / disable() | this |
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.
| Option | Type | Default | Description |
|---|---|---|---|
| label | string | '' | Caption beside the switch; also its accessible name. |
| checked | boolean | — | Initial state. |
| disabled | boolean | — | |
| readonly | boolean | — | Shown as it is; a click or the space bar does not change it. |
| onChange | function(checked) | — |
Methods: getValue(), setValue(v), check(), uncheck(), toggle(), enable(), disable().
searchbox
A text input that looks and behaves like a search field: a magnifier
on the left, a clear button that appears once there is something to
clear, Escape to empty it, and onSearch debounced so
holding a key down costs one search rather than twenty.
| Option | Type | Default | What it does |
|---|---|---|---|
| delay | number | 300 | Milliseconds of quiet before onSearch fires. 0 fires on every keystroke. |
| searchOnEnter | boolean | false | Never search while typing — only on Enter or the clear button. |
| clearable | boolean | true | Show the clear button once the field has a value. |
| onSearch | function | null | Receives the query, already trimmed. |
| onClear | function | null | Fires when the field is emptied by the button or by Escape. |
It inherits label, labelWidth,
prompt and the rest from
textbox. Methods:
getValue() (trimmed), setValue(),
clear(), search(value?) — which runs
onSearch immediately, ignoring both the debounce and the
guard that skips an unchanged query.
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.
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.
| Option | Type | Default | What it does |
|---|---|---|---|
| multiple | boolean | true | Accept more than one file. |
| accept | string | '' | Extensions and MIME patterns: '.pdf,image/*'. |
| maxSize | number | 0 | Bytes per file. 0 means no limit. |
| maxFiles | number | 0 | How many may be queued at once. |
| url | string | '' | POST target. Empty leaves it a picker. |
| fieldName | string | 'file' | Form field name each file is sent under. |
| data | object | null | Extra form fields sent with every file. |
| headers | object | null | Request headers, e.g. an Authorization token. |
| autoUpload | boolean | false | Upload as soon as a file is added. |
| readonly | boolean | false | Lists the files but takes none by click or drop, and lets none be removed. add() from code still works. |
| text / hint | string | '' | Drop-zone caption and sub-line. Both default to the locale strings. |
| onAdd / onRemove | function | null | (file). |
| onProgress | function | null | (file, percent) while uploading. |
| onSuccess | function | null | (file, response) — parsed as JSON when it is JSON. |
| onError | function | null | (file, message) — also fires for a file the rules turned away. |
| onComplete | function | null | (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.
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.
| Method | Returns | Description |
|---|---|---|
| getValues() | object | Keyed by each field's name, read through its widget. |
| setValues(obj) | this | Fills matching fields; unknown keys are ignored. |
| validate() | boolean | Marks offenders with .kob-invalid and focuses the first. |
| clear() | this | Empties every field and drops the marks. |
| load(url?, params?) | Promise | Fetch 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?) | Promise | Validate — 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() | Promise | Validate, waiting on the fields whose rules have to ask a server. |
| isDirty() / setDirty(v) / markClean() | boolean / this | Whether anything has been typed since the form was last marked clean. |
| hasAsyncRules() | boolean | True when any field carries a rule that has to ask something. |
| Option | Type | Description |
|---|---|---|
| markInvalid | boolean | Add .kob-invalid to failing fields. Default true. |
| onValidate | function(ok, values) | Return false to fail validation with your own rule. |
| onSubmit | function(event, values) | Runs after validation passes on a real submit. |
| url / queryParams / loadFilter | string / object / function | Fetch a record and fill the form with it on the way up. |
| action / method | string | Where submit() posts, and how. Left empty, submit() only validates and hands the values to onSubmit — which is what a form inside a dialog wants. |
| track | boolean | Watch for changes, so isDirty() means something. Default true. |
| warnUnsaved | boolean | Warn 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 / onLoadError | function | (values) / (error). |
| onSubmitSuccess / onSubmitError | function | (response, values) / (error, values). |
| onDirtyChange | function(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:
The named rules
| Rule | Parameter | Passes when |
|---|---|---|
| — | 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 / length | number | Characters, counted. |
| min / max | number | The value as a number is inside the bound. |
| pattern | RegExp | string | It matches. |
| equalTo | selector | element | It 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 / call | What 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
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
| Option | Type | Default | Description |
|---|---|---|---|
| items | array | null | [{ value, text, disabled }]. Left out, the radios already in the element are adopted. |
| name | string | generated | The form field name every option shares. |
| value | string | — | Which option is chosen. |
| direction | 'horizontal' | 'vertical' | 'horizontal' | Down the page when the options are sentences rather than words. |
| label | string | '' | A caption over the group, and its accessible name. |
| required / rules / message | — | — | As every other field. See Validation. |
| disabled | boolean | — | Applies to every option. |
| readonly | boolean | — | The choice is shown and cannot be changed. readonly(mode) toggles it. |
| onChange | function(value) | — |
Methods: getValue(),
setValue(v), setItems(items),
clear(), enable(), disable(),
isValid(), validationMessage().
<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.
| Option | Type | Default | Description |
|---|---|---|---|
| step | number | 30 | Minutes between the times on the list. |
| min / max | 'HH:mm' | '' | Bounds the list, and clamps what was typed when the field is left. |
| hour12 | boolean | false | Show 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.
| Option | Type | Default | Description |
|---|---|---|---|
| from / to | ISO string | '' | The starting range. |
| presets | boolean | true | Today, Last 7 days, This month, Last month, Last 90 days, This year — from the locale bundle. |
| readonly | boolean | — | Both dates are shown, neither can be changed, and the presets are switched off. |
| min / max | ISO string | '' | Outer bounds both halves are held inside. |
| format | string | locale | Passed to both calendars. |
| required / message | — | — | required wants both halves. |
| onChange | function({ from, to }) | — | Fires for a calendar, a shortcut, or setValue. |
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.
| Option | Type | Default | Description |
|---|---|---|---|
| title | string | '' | Header caption. Omit for a headerless panel. |
| iconCls | string | '' | Icon in the header. |
| collapsible | boolean | false | Shows the fold caret. |
| collapsed | boolean | false | Start folded. |
| closable | boolean | false | Shows a close button that removes the panel. |
| href | string | '' | Fetch this URL into the body on init. |
| fit | boolean | false | Fill the parent box. |
| border | boolean | true | |
| loadingMessage | string | locale | Shown while href is in flight. |
| onLoad / onCollapse / onExpand / onClose | function | — |
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.
| Option | Type | Description |
|---|---|---|
| region | string | north, south, west, east or center. Required. |
| width | number | west and east, in pixels. |
| height | number | north and south, in pixels. |
| split | boolean | Adds a draggable splitter on the inner edge. |
| minSize / maxSize | number | Drag limits. |
| collapsible | boolean | Puts 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.
| Option | Type | Default | Description |
|---|---|---|---|
| selected | number | 0 | Index shown first. |
| closable | boolean | false | Close buttons on every tab. A page can opt in on its own with data-options="closable:true". |
| onSelect | function(index, title) | — | |
| onBeforeClose | function(index, title) | — | Return false to keep the tab open. |
| onClose / onAdd | function | — |
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:
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.
| Class | Look |
|---|---|
| — | The default folder tabs, with an accent cap on the selected one. |
kob-tabs-pill | A segmented control: the selected tab is a filled pill and there is no strip. |
kob-tabs-plain | A flat underline — tabs that get out of the way of the page. |
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.
| Option | Type | Default | Description |
|---|---|---|---|
| steps | array | null | Strings, or { title, text, iconCls, status }. Left out, the titled children are read instead. |
| active | number | 0 | The 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. |
| clickable | boolean | true | A click on a step moves to it. |
| linear | boolean | true | Only steps already reached can be clicked. false frees every step. |
| markers | 'number' | 'dot' | 'number' | What an unfinished marker shows. |
| doneIcon / errorIcon | string | 'icon-ok' / 'icon-close' | |
| onChange | function(index, previous, step) | — | Any move, including next() and prev(). |
| onSelect | function(index, step) | — | A move the user clicked for. |
| onFinish | function() | — | 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:
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.
| Option | Type | Default | Description |
|---|---|---|---|
| title | string | '' | |
| width | number | 460 | |
| height | number | 'auto' | 'auto' | |
| modal | boolean | true | false makes it a draggable, resizable window. |
| closed | boolean | true | Start hidden; call open(). |
| buttons | array | null | { text, iconCls, primary, handler(dialog) }. |
| draggable / resizable | boolean | true | Non-modal windows only. |
| minimizable / maximizable | boolean | true | Non-modal windows only. |
| sheet | boolean | false | Docks to the bottom edge — the shape phones expect. |
| minWidth / minHeight | number | 300 / 120 | Resize limits. |
| onOpen / onClose / onResize / onMove / onMinimize / onMaximize | function | — | 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:
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.
| Class | Description |
|---|---|
| kob-card | The box. A flex column, so the body stretches. |
| kob-card-header | Title row. kob-card-tools inside pushes to the right. |
| kob-card-media | Full-bleed image at the top. |
| kob-card-body | Content. First and last child margins are collapsed away. |
| kob-card-footer | Action row on the accent tint. |
| kob-card-hover | Lifts 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.
| Class | What it is |
|---|---|
| kob-appshell | The frame. A grid of bar / sidebar / content / footer; fills its parent, so give that a height. |
| kob-sidebar-collapsed | On the shell: narrows the sidebar to a rail and hides the labels. |
| kob-appbar | The top bar. kob-spacer and kob-sep work inside it. |
| kob-appbar-brand / -mark | Product name, and the square badge before it. |
| kob-appbar-btn | A quiet square button. A kob-badge inside it pins to the corner. |
| kob-appbar-user / -lines / -name / -role | The account block at the right-hand end. |
| kob-avatar | Initials or an <img>. kob-avatar-sm, kob-avatar-lg. |
| kob-sidebar | The navigation column. |
| kob-sidebar-title | A group heading inside it. |
| kob-sidebar-item | One entry: icon, kob-sidebar-label, optional badge. active marks the current page. |
| kob-sidebar-sep | A rule between groups. |
| kob-appmain | The scrolling content area. |
| kob-appfoot | Put it on a kob-statusbar to dock it under the content. |
| kob-pagehead / kob-crumbs | The 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.
| Option | Type | Default | Description |
|---|---|---|---|
| tabs | array | null | [{ title, groups: [{ title, items: [...] }] }] |
| selected | number | 0 | Tab shown first. |
| collapsible | boolean | true | Clicking the open tab folds the ribbon away. |
| collapsed | boolean | false | Start folded. |
| onSelect / onClick | function | — | (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
| Option | Type | Default | Description |
|---|---|---|---|
| data | TreeNode[] | null | The root nodes. |
| url | string | '' | Root through Kob.config.request; lazy children are fetched from the same URL with id. |
| idField / textField / childrenField | string | id / text / children | Read your own shape without remapping. |
| checkbox | boolean | false | A tick box on every node. |
| cascadeCheck | boolean | true | Folders tick their children; partly-ticked folders show a dash. |
| lines | boolean | false | Guide lines down each branch. |
| expandDepth | number | 1 | How deep to start open. |
| formatter | function(node) | — | HTML for one label. |
| onSelect / onClick / onDblClick | function(node) | — | |
| onExpand / onCollapse | function(node) | — | |
| onCheck | function(node, checked) | — | |
| onContextMenu | function(event, node) | — | Pairs with menu.showAt(). |
| dnd | boolean | false | Drag a node onto another to move it — see Changing the tree. |
| editable | boolean | false | Double-click a label, or press F2, to rename it in place. |
| filterMatch | function(node, query) | — | How filter() matches a node. Defaults to a substring of its text. |
| onBeforeDrop | function(node, target, where) | — | Return false to refuse a drop. where is before, after or inside. |
| onDrop | function(node, target, where) | — | After the move. |
| onBeforeEdit / onAfterEdit / onCancelEdit | function | — | A rename, before it starts (return false to refuse), after it commits with (node, text, was), and when it is abandoned. |
| onChange | function(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.
| Method | Returns | Description |
|---|---|---|
| appendNode(parent, nodes) | TreeNode | Add 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) | TreeNode | Beside an existing node: 'before' or 'after'. |
| removeNode(node) | this | Takes everything under it too. |
| updateNode(node, patch) | this | Merges 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() | this | Rename 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.
| Key | Type | Description |
|---|---|---|
| id | string | Required. What open() and close() take. |
| title | string | Window title, icon caption and taskbar label. |
| iconCls | string | Icon from the built-in set. |
| icon | string | An image URL instead, for a real app icon. |
| content | string | HTML for the window body. Widgets in it are parsed. |
| href | string | Fetch a fragment into the body instead. |
| onOpen | function(win, body) | Or build the contents yourself. |
| width / height | number | Window size. |
| single | boolean | Default true — opening again focuses the existing window. |
| hidden | boolean | Reachable through open(), but not shown as an icon. |
Options
| Option | Type | Default | Description |
|---|---|---|---|
| apps | App[] | null | As above. |
| icons | boolean | true | Launcher icons on the wallpaper. |
| taskbar | boolean | true | The bar along the bottom. |
| startLabel | string | false | 'Start' | Start button text; false hides it. |
| startExtras | MenuItem[] | — | Entries under the app list. |
| clock | boolean | true | Clock at the right. |
| user | object | — | { name, iconCls, onLogout } |
| wallpaper | string | — | Any CSS background. |
| onOpen / onClose / onFocus | function(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
| Option | Type | Default | Description |
|---|---|---|---|
| columns | array | null | See Column definition. A nested [[…]] array is accepted for EasyUI-shaped configs. |
| data | array | object | null | Rows, { rows } or { data }. |
| url | string | '' | Fetched through Kob.config.request. |
| queryParams | object | null | Sent with the request. |
| loadFilter | function(payload) | — | Reshape the payload before it is read. |
| idField | string | '' | The column that identifies a row, for matching one back after an edit. |
| pagination | boolean | true | The footer with page controls. false renders every row that survives the filters. |
| pageSize | number | 50 | |
| pageList | number[] | [20,50,100] | Choices in the rows-per-page menu. |
| searchable | boolean | true | The free-text box in the footer. Needs pagination, which is what draws the footer. |
| filterable | boolean | true | The chip row above the header. |
| rownumbers | boolean | false | A leading column counting the rows. Dropped on a single-column grid, where it would be the only thing beside the content. |
| showHeader | boolean | true | false hides the header row — and with it sorting, resizing and the chips. |
| striped | boolean | true | |
| singleSelect | boolean | true | false lets clicks accumulate a selection. See Selecting rows. |
| checkbox | boolean | false | A leading column of checkboxes, with a tri-state box in the header. Turns multi-select on by itself unless singleSelect was set. |
| selectOnCheckbox | boolean | false | Only the checkbox changes the selection; clicking the row leaves it alone. |
| resizable | boolean | true | Drag the right edge of a header cell to resize that column. resizable: false on a column pins that one. |
| minColWidth | number | 48 | A column will not be dragged narrower than this. |
| fit | boolean | false | Fill the parent's height. |
| emptyMsg | string | locale | Shown when nothing matches. |
| showSummary | boolean | null | The totals row. Left alone it appears as soon as a column asks for one; false keeps it away for good. |
| detail | function(row, panel, index) | null | Opens a panel under the row and fills it — see A row that opens. Brings detailHeight and singleDetail with it. |
| rowActions | array | null | Buttons 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. |
| rowMenu | array | function(row, index) | null | A menu on right-click. Item handlers are called with (row, index) — see The menu on a row. |
| columnChooser | boolean | false | A button that opens the list of columns, to be ticked and reordered. |
| exportButton | boolean | false | A button offering CSV, a spreadsheet and print. |
| reorderable | boolean | false | Drag a header cell sideways to move that column. |
| stateKey | string | '' | Remember widths, order, hidden columns, the sort and the page size under this name — see Remembering the layout. |
| stateFilters | boolean | false | Remember the chips and the search box too. |
| multiSort | boolean | true | Shift-clicking a second header sorts by it within the first. |
| groupField | string | '' | Break the rows into folding groups by this field — see Grouping. |
| groupFormatter | function(value, rows, field) | null | Renders one group heading. Defaults to the value and a row count. |
| groupCollapsible | boolean | true | false leaves the headings there but stops them folding. |
| keyboard | boolean | true | Arrow keys, Enter to edit, Space to select, Ctrl+C to copy — see The keyboard. |
| typeToEdit | boolean | true | Typing a character over a cell opens its editor with that character in it. |
| remote | boolean | false | Let the server page, sort and filter — see Server-side mode. |
| totalField | string | 'total' | Where the row count lives in a remote response. |
| remoteParams | function(state) | null | Reshape what is sent, for an API that spells page as offset. |
| remoteDelay | number | 250 | Milliseconds to wait for typing to stop before asking the server. |
| virtual | boolean | false | Draw only the rows in the viewport — see Virtual scrolling. |
| rowHeight | number | 32 | Row height, when virtual scrolling is on. Every row has to be this tall. |
| announceSelection | boolean | true | Say the size of the selection to a screen reader as it changes. |
Column definition
| Key | Type | Description |
|---|---|---|
| field | string | Property on the row. A column without one is decoration: it cannot sort, filter or be searched. |
| title | string | Header text. |
| width | number | Pixels, 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. |
| sortable | boolean | false makes the header inert. |
| resizable | boolean | false drops this column's grip while the rest stay draggable. |
| formatter | function(value, row, index) | Returns the cell's HTML. Filtering, searching and the chip menus all read what it produced. |
| editor | string | 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. |
| filter | false | string | false drops the chip; a string forces its kind: text, number, enum, bool. |
| summary | string | function | A value in the totals row: sum, avg, count, min, max, any other string used as a label, or function(rows, column). |
| summaryFormatter | function(value, rows, column) | Formats the total. Falls back to the column own formatter. |
| frozen | boolean | Pin this column while the rest scrolls sideways. A leading run only — see Totals and frozen columns. |
| hidden | boolean | Drawn, but not right now. The column chooser turns it back on, and its width and filter survive the round trip. |
| exportable | boolean | false keeps the column out of exports and prints while leaving it on screen. |
Methods
| Method | Returns | Description |
|---|---|---|
| loadData(rows) | this | Replace the rows. Accepts an array, { rows } or { data }. Clears the selection with them — they are new objects. |
| reload(url?) | Promise | Fetch 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) | Row | By position in the loaded set. |
| getSelected() | Row | null | The last row selected. |
| getSelections() | Row[] | Every selected row — including ones the current page is not showing. |
| getSelectionCount() | number | |
| isSelected(row) | boolean | Takes the row itself, not an index. |
| selectRow(i) / unselectRow(i) | this | |
| selectAll() / unselectAll() | this | Every row the filters let through, not only the page on screen. |
| clearSelections() | this | The older name for unselectAll(). |
| sort(field, order?) | this | Omit 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) | this | As if typed into the footer box, which it also fills in. |
| gotoPage(n) / pageCount() | this / number | Clamped to what exists. |
| beginEdit(i) / endEdit(i) / cancelEdit() | this | See Inline editing. A commit that fails the editors' own rules is refused and the row stays open. |
| validateEdit() | object | Check the open editors without committing: { field: message }, empty when the row is good. |
| addSort(field, order?) / getSort() / clearSort() | this / SortKey[] / this | Sorting by more than one column — what shift-clicking a header does. |
| toggleColumn(field, shown?) / hideColumn(f) / showColumn(f) | this | Take a column out of the table, or put it back. |
| moveColumn(field, index) | this | Move 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 / this | The 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() | this | Folding. |
| exportCsv(opts) / exportExcel(opts) / print(opts) | this | Take 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) | Promise | The selected rows as tab-separated text, which pastes straight into a spreadsheet. |
| getCursor() / setCursor(row, col) / moveCursor(dr, dc) / cursorRow() | object / this / Row | The cell cursor the arrow keys move. |
| remoteQuery() / fetchRemote() / refreshView() | object / Promise / this | Server-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() | this | Redraw the totals row. Done for you whenever the rows change. |
| refreshRow(i) | this | Redraw after changing a row's data behind the grid's back. |
| rowElement(i) | HTMLElement | null | The <tr>, if that row is on the page being shown. |
| expandDetail(i) / collapseDetail(i) / toggleDetail(i) | this | The panel under a row — see A row that opens. |
| loading() / loaded() | this | Show 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() | this | Re-measure columns. Rarely needed — it watches its own box. |
| refresh() | void | Rebuild after options changed through option({ … }). |
| destroy() | void | Give the element back as it was found. |
Events
| Event | Arguments |
|---|---|
| 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 holds | Chip | What it offers |
|---|---|---|
| Only booleans | bool | A pick-list of the two rendered labels — Yes and No, or whatever the formatter made of them. |
| Only numbers, all of them 0 or 1 | enum | A pick-list. A flag stored as a number is still a flag. |
| Only numbers | number | A lower bound: at least n. |
| Text, few distinct values | enum | A 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 else | text | A 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:
| Gesture | What it does |
|---|---|
| Click | Toggles that row. |
| Ctrl / Cmd + click | Toggles it explicitly, leaving the rest alone. |
| Shift + click | Takes 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 checkbox | Changes the selection and nothing else: no row click, no editor, no dialog opening behind the tick. |
| The header checkbox | Ticks 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.
| Key | Type | What it does |
|---|---|---|
| text | string | Button caption. Leave it out for an icon-only button; the label then comes from title. |
| iconCls | string | e.g. 'icon-remove'. |
| color | string | One of the five button colours. |
| soft | boolean | Defaults to true — a strip of solid buttons over a row is too loud. |
| disabled | boolean | function | (row, index). Re-evaluated for every row the strip lands on. |
| handler | function | (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.
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.
| Option | Type | Default | Notes |
|---|---|---|---|
| detail | function(row, panel, index) | null | Fills the panel opened under a row. Setting it adds the caret column. |
| detailHeight | number | 0 | Pixels. 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. |
| singleDetail | boolean | true | One panel open at a time. false lets them accumulate. |
| Method | Returns | Notes |
|---|---|---|
| expandDetail(row | index) | this | Opens the panel, building it if this is the first time. |
| collapseDetail(row | index) | this | Closes it and destroys what was inside. |
| toggleDetail(row | index) | this | What the caret does. |
| isDetailOpen(row) | boolean | |
| collapseAllDetails() | this | Also what loadData() and reload() do for you: the panels belonged to rows that are gone. |
| Event | Arguments |
|---|---|
| onExpandDetail | (row, panel) — after the panel was filled |
| onCollapseDetail | (row) |
<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.
| Key | What it does |
|---|---|
| Arrows | Move the cursor a cell at a time. |
| Home / End | First and last column. With Ctrl, the first and last row on the page. |
| Page Up / Page Down | Turn the page. |
| Space | Select the row under the cursor, or unselect it. |
| Enter / F2 | Open the editors on that row. Enter again commits and steps down. |
| Tab / Shift+Tab | While editing: the next editor in the row, then commit and carry on to the row below. |
| Escape | Drop the editors without saving. |
| Any character | Opens the cell's editor with that character already in it, the way a spreadsheet does. |
| Ctrl+A | Select every row the filters left. |
| Ctrl+C | Copy 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.
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.
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
| Option | Type | Default | Description |
|---|---|---|---|
| data | array | null | Rows, each with an optional children array, nested as deep as you like. |
| treeField | string | first column | Which column carries the caret and the indent. |
| childField | string | 'children' | Where the children live on a row. |
| expandDepth | number | 1 | How many levels start open. 0 shows only the roots. |
| expandAll | boolean | false | Everything open, whatever the depth says. |
| indent | number | 18 | Pixels per level. |
| loadChildren | function(row) | — | Fetch a branch the first time it is opened. Return an array, or a promise of one. |
| hasChildrenField | string | 'hasChildren' | How a row says it has children it has not handed over yet. |
| onExpand / onCollapse | function(row) | — | onExpand fires once the children are actually there. |
Every datagrid option applies as well.
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
| Option | Type | Default | Description |
|---|---|---|---|
| data | array | null | Everything that can be chosen: [{ id, text, disabled }]. |
| value | array | null | The ids that start on the right. |
| valueField / textField | string | 'id' / 'text' | |
| titles | [string, string] | locale | Captions over the two lists. |
| searchable | boolean | true | A search box over each side. Worth it past about thirty rows. |
| height | number | 260 | How tall the lists are. |
| required / message | — | — | required means at least one on the right. |
| onChange | function(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().
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
| Option | Type | Default | Description |
|---|---|---|---|
| content | string | '' | 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. |
| delay | number | 250 | Milliseconds before it appears on hover. Focus shows it at once — somebody on a keyboard has already waited. |
| maxWidth | number | 260 | Where the text starts wrapping. |
Methods: show(), hide(),
toggle(), isOpen(),
setContent(text).
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
| Option | Type | Default | Description |
|---|---|---|---|
| title | string | '' | Bold line across the top; also the dialog's accessible name. '' leaves it out. |
| content | string | 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(). |
| html | string | '' | 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 / maxWidth | number | null / 320 | Pixels. With no width it sizes to its content up to maxWidth. |
| closeOnOutside | boolean | true | A click anywhere else puts it away. A popover opened from inside it does not count as elsewhere. |
| gap / delay | number | 10 / 150 | Pixels off the control; milliseconds a hover popover waits in and out. |
| onOpen / onClose | function() | — |
popconfirm options
Every popover option, and these. It defaults to position: 'top'.
| Option | Type | Default | Description |
|---|---|---|---|
| title | string | '' | The question. |
| content | string | '' | A muted line under it — what happens if they say yes. |
| okText / cancelText | string | locale | Button 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. |
| iconCls | string | 'icon-warn' | Icon beside the question. '' for none. |
| onConfirm | function() | — | OK was pressed. Return false to keep the panel open. |
| onCancel | function() | — | Dismissed any other way: Cancel, Escape, a click elsewhere. |
Methods
| Method | Returns | Description |
|---|---|---|
| show() / hide() / toggle() | this | Opening one popover closes any other, except one it sits inside. |
| isOpen() | boolean | |
| place() | this | Re-position it, after its content has changed size. |
| getBody() | HTMLElement | The 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() | this | popconfirm only. Press OK or Cancel from code. |
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
| Option | Type | Default | Description |
|---|---|---|---|
| items | array | null | [{ 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. |
| reverse | boolean | false | Turn the list around. |
| limit | number | 0 | Fold everything past this many behind a button. A long trail is read at its recent end and no further. |
| onClickItem | function(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().
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
| Option | Type | Default | Description |
|---|---|---|---|
| items | array | null | [{ title, content, iconCls, selected, disabled }]. content is HTML. |
| selected | number | 0 | Which section starts open. -1 opens none. |
| multiple | boolean | false | Any number open at once. |
| collapsible | boolean | false | Allow the last open section to be closed, leaving the box empty. See the note below. |
| border | boolean | true | Draw the outer border. |
| fit | boolean | false | Fill the parent's height, the open section taking the slack. |
| animate | boolean | true | false drops the caret transition. |
Methods
| Method | Returns | Description |
|---|---|---|
| select(which) / unselect(which) / toggle(which) | this | which is an index or a title. |
| getSelected() | number | null | The first open section. |
| getSelections() | number[] | Every open section — the one to use with multiple. |
| getBody(which) | HTMLElement | The element holding the content. Append your own nodes to it. |
| getTitle(which) / setTitle(which, text) | string / this | |
| add(item, at?) | this | Widgets in the new content are built for you. |
| remove(which) | this | And torn down again here. |
| enable(which) / disable(which) | this | A disabled section cannot be opened. |
Events: onSelect(index, title) and
onUnselect(index, title).
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
| Option | Type | Default | Description |
|---|---|---|---|
| min / max | number | 0 / 100 | The ends of the track. |
| step | number | 1 | Granularity. 0.01 for money. |
| value | number | array | min | [low, high] when range is on. |
| range | boolean | false | Two handles and a span between them. |
| marks | number | array | null | Ticks under the track: every n, a list of values, or a list of { value, label }. |
| showTip | boolean | true | The bubble over the handle while it is being moved. |
| formatter | function(value) | — | Formats the tip, the marks and getText(). |
| label / labelWidth / labelPosition | — | — | As the other form widgets. |
| disabled | boolean | false |
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.
<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
| Option | Type | Default | Description |
|---|---|---|---|
| value | string | '' | ISO yyyy-mm-dd. |
| min / max | string | '' | ISO. Days outside are drawn disabled and cannot be picked. |
| view | string | '' | The month to open on, as yyyy-mm or a full ISO date. Defaults to the value's month, then to this one. |
| showToday | boolean | true | The "Today" button under the grid. |
Methods
| Method | Returns | Description |
|---|---|---|
| getValue() / setValue(iso) | string / this | ISO in, ISO out — whatever the locale displays. |
| getView() | { y, m } | The month on screen. m is 1-12. |
| moveTo(y, m) | this | Go to a month without selecting a day. |
| next() / prev() | this | Step whole months, carrying the year. |
| goToday() | this | Back 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.
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
| Option | Type | Default | Description |
|---|---|---|---|
| rows | array | null | [{ name, value, group, editor, readonly, formatter }]. data works too and means the same thing. |
| showGroup | boolean | true | Draw a heading before each run of a new group. |
| nameWidth | number | 200 | The value column takes the rest. |
| nameTitle / valueTitle | string | 'Name' / 'Value' | The two column headers. |
| defaultEditor | string | 'text' | What a row with no editor of its own gets. '' makes the sheet read-only. |
| editable | boolean | false | From 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
| Method | Returns | Description |
|---|---|---|
| getRows() | array | Every property, group headings left out. |
| getChanges() | object | The sheet as { name: value } — what a settings screen saves. |
| getPropertyValue(name) | any | |
| setPropertyValue(name, value) | this | Fires onValueChange only when the value really changed. |
| loadData(rows) | this | Replace every property. Headings are rebuilt from the new groups. |
Events: onValueChange(row, value, was),
plus every datagrid event.
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.
| Call | Resolves 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.
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.
| Option | Type | Default | What it does |
|---|---|---|---|
| type | string | 'info' | success, error, warn, info, question, or '' for no mark. |
| title / msg | string | '' | Heading and body. |
| html | boolean | false | Treat msg as markup. Only pass markup you produced yourself. |
| showCancel | boolean | false | Add the second button. |
| confirmText / cancelText | string | locale | Name the buttons after what they do, not OK and Cancel. |
| danger | boolean | false | Paint the confirm button as destructive. |
| timer | number | 0 | Auto-dismiss after n ms, resolving false. |
| closeOnBackdrop | boolean | true | A 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.
| Class | What it is |
|---|---|
| kob-statusbar | The strip. Put it last inside a bordered box, or in a layout's south region. |
| kob-status-item | One entry. Aligns an icon, a dot and text on one line. |
| kob-status-dot | A coloured dot inside an item. |
| kob-status-sep | A vertical rule between groups. |
| kob-spacer | Pushes everything after it to the right. |
| kob-status-ok / -busy / -bad | On an item: colours its text and its dot green, amber or red. |
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.
| Option | Type | Default | Description |
|---|---|---|---|
| columns | Column[] | null | Columns of the grid in the panel — the same shape a datagrid takes. |
| data / url | array | string | null | Rows to show, or where to fetch them. |
| idField | string | 'id' | Which field the value comes from. |
| textField | string | 'text' | Which field is shown in the box once a row is chosen. |
| mode | string | 'local' | 'remote' sends what was typed and shows what comes back — the only thing that works for a table nobody scrolls through. |
| minChars | number | 1 | Remote only: say nothing until this many characters are typed. |
| delay | number | 300 | Remote only: milliseconds to wait for the typing to stop. |
| panelWidth | number | 0 | Width of the panel. Left at 0 it matches the field, which is usually too narrow. |
| panelHeight | number | 260 | Height of the grid inside it. |
| pageSize | number | 20 | Rows per page inside the panel. |
| editable | boolean | true | false = pick from the grid only. |
| onSelect | function | — | 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.
| Option | Type | Default | Description |
|---|---|---|---|
| data / url | array | string | null | The nodes, or where to fetch them. Same shape as a tree. |
| multiple | boolean | false | Tick boxes, and an array of values. |
| cascadeCheck | boolean | true | Ticking a folder ticks its children. |
| selectFolders | boolean | true | false lets only leaves be chosen. |
| searchable | boolean | true | Typing filters the tree, keeping the branches that lead to a match. |
| expandDepth | number | 1 | How many levels start open. |
| panelHeight | number | 280 | Height of the panel. |
| onSelect | function | — | 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.
| Option | Type | Default | Description |
|---|---|---|---|
| value | string[] | string | [] | An array, or a comma-separated string. |
| separator | string | RegExp | ',' | What ends a tag besides Enter and Tab. A pasted list is split on it. |
| maxTags | number | 0 | Cap the number of chips. 0 is no cap. |
| unique | boolean | true | Refuse a tag that is already there. |
| lowercase | boolean | false | Fold every tag to lower case as it is committed. |
| suggestions | array | null | A list to pick from. The typed text still wins; the list is only faster. |
| tagRules | rules | null | Rules each tag has to satisfy — 'email' is the common one. |
| onBeforeAdd | function | — | Return false to refuse a tag, or a string to change it. |
| onAdd / onRemove | function | — | 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.
| Option | Type | Default | Description |
|---|---|---|---|
| max | number | 5 | How many marks. |
| value | number | 0 | The score. |
| half | boolean | false | Allow halves — 3.5 stars. |
| readonly | boolean | false | Show a score rather than collect one. |
| glyph | string | ★ | The mark. Any character; the CSS colours it. |
| text | function | false | null | Text beside the stars, as a function of the value. |
| clearable | boolean | true | Clicking the mark already set clears the rating — the only way back to "no rating". |
| onChange / onHover | function | — | 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.
| Option | Type | Default | Description |
|---|---|---|---|
| palette | string[] | built in | The colours offered. The default set all take white or near-black text legibly. |
| allowCustom | boolean | true | A native colour input at the foot of the panel, for anything else. |
| editable | boolean | true | Let the hex code be typed into the field. |
| clearable | boolean | true | A button that empties it. |
| onSelect | function | — | 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.
| Option | Type | Default | Description |
|---|---|---|---|
| height | number | 160 | Pixels. The width follows the element. |
| color | string | '#1c1c1e' | Ink. |
| minWidth / maxWidth | number | 0.8 / 2.6 | The stroke at its thinnest and thickest — it varies with how fast the pointer is moving, which is what makes a signature look like one. |
| guide | boolean | true | A line to sign on, the way a paper form has one. |
| guideText | string | '' | Text under that line. |
| buttons | boolean | true | Clear and undo under the pad. |
| readonly | boolean | false | Show a signature already captured. |
| onChange | function | — | 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.
| Option | Type | Default | Description |
|---|---|---|---|
| value | string | '' | HTML. Sanitised on the way in and on the way out. |
| tools | string[] | a sensible set | Which 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. |
| height | number | 200 | Pixels. The box scrolls past this. |
| prompt | string | '' | Placeholder shown while it is empty. |
| pasteHtml | boolean | false | Keep a small set of tags when HTML is pasted, instead of taking the text. |
| maxLength | number | 0 | Caps the words, not the markup. |
| counter | boolean | false | Show the character count under the box. |
| readonly | boolean | false | Show without letting it be changed. |
| rules / required | rules | — | 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.
| Option | Type | Default | Description |
|---|---|---|---|
| title | string | '' | Heading. It names the panel to a screen reader too. |
| position | string | 'right' | left, right, top, bottom. |
| width / height | number | 380 / 320 | Width for a side drawer, height for one from the top or bottom. |
| modal | boolean | true | Dim the page behind, and keep Tab inside the panel. |
| closeOnMask | boolean | true | Clicking the dimmed page closes it. |
| closeOnEscape | boolean | true | So does Escape. |
| buttons | array | null | Buttons along the bottom: { text, iconCls, color, handler }. |
| closed | boolean | true | false opens it on the way up. |
| focus | string | Element | null | What 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.
| Option | Type | Default | Description |
|---|---|---|---|
| total | number | 0 | How many rows there are altogether. |
| pageSize | number | 20 | How many fit on a page. |
| page | number | 1 | Which page is shown. |
| pageList | number[] | [10,20,50,100] | Sizes offered in the menu. Empty hides it. |
| maxButtons | number | 7 | How many numbered buttons before the run folds. |
| showInfo | boolean | true | Show "Showing 1-20 of 482". |
| showEnds | boolean | true | First and last buttons as well as next and previous. |
| onChange | function | — | (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.
| Option | Type | Default | Description |
|---|---|---|---|
| type | string | 'text' | text (lines), table (rows of cells), card, list (an avatar and two lines each). |
| rows | number | 3 | How many lines, rows or items. |
| cols | number | 4 | Columns, for the table shape. |
| avatar | boolean | false | A round block in front of the lines. |
| animated | boolean | true | The shimmer. Off for anyone who asked for less motion, whatever this says. |
| label | string | '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.
| Option | Type | Default | Description |
|---|---|---|---|
| name | string | '' | Used for the initials, the colour and the label. |
| src | string | '' | A photo. A broken one falls back to the initials rather than to the browser's broken-image glyph. |
| size | string | number | 'md' | sm, md, lg, or a number of pixels. |
| square | boolean | false | Rounded square instead of a circle. |
| status | string | '' | online, away, busy, offline — a dot in the corner. |
| initials / color | string | — | Override what would be worked out from the name. |
| iconCls | string | '' | 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.
| Option | Type | Default | Description |
|---|---|---|---|
| items | object[] | [] | The tiles. Each is any object; its idField names it. Fields the widget itself reads: state, disabled, badge, row, col, rowSpan, colSpan. |
| idField | string | 'id' | The field that identifies a tile. Falls back to the position when an item has none. |
| selectMode | string | '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). |
| value | array | null | Ids chosen to start with, in single and multiple mode. |
| clearable | boolean | true | In single mode, tapping the chosen tile again unchooses it. |
| max | number | 0 | Most tiles that may be chosen in multiple mode; 0 is no limit. A tap past it calls onLimit. |
| columns | number | null | A fixed column count. Left null, tiles fill the width, as many as fit. Tiles placed with col work the count out from the placements. |
| minWidth | number | 140 | The narrowest a tile gets before the row wraps, in pixels. Sets --kob-tile-min. |
| gap | number | string | null | Space between tiles: pixels or any CSS length. Sets --kob-tile-gap (default 10px). |
| aspect | string | null | A CSS aspect-ratio for every tile, e.g. '1 / 1'. Left null, tiles size to their content. Sets --kob-tile-aspect. |
| render | function | null | (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 / textField | string | 'image' / 'name' / 'price' | The fields the built-in tile draws: a picture, a bold title, a line of muted text. Values are escaped. |
| badgeField | string | 'badge' | A number above zero is drawn as a count in the tile's corner (99+ past 99). The order count on a till. |
| stateField | string | '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"]. |
| isSelectable | function | null | (item) → boolean. False leaves the tile drawn but unchoosable — a taken seat. onClick still fires for it. |
| searchFields | string[] | null | Fields filter(text) looks in. Left null, the title field. |
| disabled | boolean | false | Greys out and ignores the whole grid. An item's own disabled: true does the same for one tile. |
| label | string | null | What a screen reader calls the grid. Give one in single and multiple mode, where it is a listbox. |
| onClick | function | — | (item, event) on any tap of a tile that is not disabled, chosen or not. |
| onSelect | function | — | (item) when a tap chooses a tile — on every tap in click mode. |
| onChange | function | — | (items) with the full chosen set, whenever a tap changes it. Not called by the methods below. |
| onContextMenu | function | — | (item, event) on right-click, or long-press on a touch screen. Call event.preventDefault() and open a menu at the pointer. |
| onLimit | function | — | (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.
| Option | Type | Default | Description |
|---|---|---|---|
| items | array | null | { label, value, field, span, formatter, iconCls }. |
| title | string | '' | A heading above the block. |
| columns | number | 1 | How many label/value pairs per line. |
| layout | string | 'horizontal' | 'vertical' puts the value under the label, for a narrow panel. |
| bordered | boolean | false | Rules between the rows. |
| size | string | 'md' | 'sm' for inside a panel. |
| emptyText | string | — | What an empty value shows as. |
| labelWidth | number | 0 | Width 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.
| Option | Type | Default | Description |
|---|---|---|---|
| type | string | 'empty' | empty, error, forbidden, success, search, info. |
| title | string | '' | What happened. |
| text | string | '' | What it means. |
| actions | array | null | { text, iconCls, color, soft, href, handler } — the way out. |
| iconCls / image | string | — | A different mark, or an illustration. |
| size | string | 'md' | 'sm' for inside a panel. |
| extra | string | Element | null | Anything 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.
| Option | Type | Default | Description |
|---|---|---|---|
| data | array | null | Numbers, or objects read through valueField. |
| type | string | 'line' | line, area, bar, arc (a half-circle gauge). |
| width / height | number | 100 / 28 | Pixels. The height is what makes a sparkline a sparkline — keep it small. |
| color | string | var(--kob-accent) | The stroke, or the fill for bars. |
| threshold | number | null | Bars below this take belowColor — a budget, a target. |
| lastPoint | boolean | true | A dot on the last point, which is the one anybody is looking for. |
| max | number | null | For 'arc': the value is a fraction of this. |
| zeroBased | boolean | false | Fix the bottom of the scale at zero rather than at the smallest value. |
| label | string | function | null | What 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.
| Option | Type | Default | Description |
|---|---|---|---|
| view | string | 'week' | month, week, day, agenda. |
| date | string | Date | today | The day in view. |
| events | array | null | { id, title, start, end, allDay, color, className, data }. Times are 'yyyy-mm-dd HH:MM' or a Date. |
| url | string | '' | Or fetch them. from and to are sent on every view change. |
| startHour / endHour | number | 8 / 20 | First and last hour drawn in the week and day views. |
| hourHeight | number | 44 | Height of one hour, in pixels. |
| slotMinutes | number | 30 | What a click on empty space covers. |
| views | string[] | all four | Which views the toolbar offers. |
| firstDay | number | the locale's | Day the week starts on: 0 is Sunday. |
| hour24 | boolean | true | 24-hour clock on the ruler. |
| eventFormatter | function | null | Render one event's contents yourself. |
| onEventClick | function | — | Called with the event. |
| onSlotClick | function | — | (start, end) as 'yyyy-mm-dd HH:MM' — a click on empty space, which is how a new booking starts. |
| onDayClick / onViewChange | function | — | 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
Vue 3
Angular
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
| Situation | What 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. |
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.
| Option | Type | Default | Description |
|---|---|---|---|
| type | 'info' | 'success' | 'warn' | 'danger' | 'info' | Colour and icon. |
| title | string | '' | Bold line above the message. |
| msg | string | — | Body. Omit to keep what is already in the markup. |
| html | boolean | false | Treat title and message as HTML. |
| iconCls | string | false | by type | Override or remove the icon. |
| closable | boolean | false | Adds a dismiss button. |
| timeout | number | 0 | Dismiss by itself after this many ms. |
| onClose | function | — |
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.
| Option | Type | Default | Description |
|---|---|---|---|
| text | string | number | markup | What is shown. |
| type | string | 'muted' | muted, accent, success, warn, danger, info. |
| dot | boolean | false | A coloured dot with no text. |
| max | number | 99 | Counts above this show as "99+". |
| hideEmpty | boolean | true | Hide at 0 or empty. |
| label | string | '' | 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.
| Option | Type | Default | Description |
|---|---|---|---|
| value | number | 0 | 0-100, clamped. |
| indeterminate | boolean | false | Sweeping stripe; no percentage shown. |
| text | string | false | '{value}%' | Label template; false for none. |
| height | number | 20 | Bar height in pixels. |
| type | string | 'accent' | accent, success, warn, danger. |
| striped | boolean | false | Moving stripes on the fill. |
| onChange / onComplete | function | — | 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 spinner on its own is a class, with three sizes:
| Option | Type | Default | Description |
|---|---|---|---|
| msg | string | '' | Caption under the spinner. |
| size | 'sm' | 'md' | 'lg' | 'md' | |
| visible | boolean | true | false builds it hidden. |
| backdrop | boolean | true | Dim what is underneath. |
| timeout | number | 0 | Hide 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
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:
Flex helpers
| Class | Does |
|---|---|
| kob-flex / kob-inline-flex | Flex container with the standard gap. |
| kob-flex-col / kob-flex-wrap | Direction and wrapping. |
| kob-items-start / -center / -end / -baseline / -stretch | Cross-axis alignment. |
| kob-justify-start / -center / -end / -between / -around | Main-axis alignment. |
| kob-grow / kob-none | Take the slack, or refuse to. |
| kob-spacer | An empty element that pushes what follows to the far end. |
| kob-gap-0 … kob-gap-5 | Gap 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-scroll | Sizing and overflow. |
| kob-truncate / kob-muted / kob-small / kob-strong / kob-text-* | Text. |
Button groups and toolbars
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.
| Member | Description |
|---|---|
| static widgetName | Drives the class name: 'rating' → class="kob-rating". |
| static defaults | Merged under data-options and the constructor argument. |
| this.el | The element the widget is mounted on. |
| this.options | The resolved options. |
| init() | Build the DOM. Called by the constructor. |
| refresh() | Called when new options arrive after construction. |
| track(off) | Register a cleanup function — pass the unbinder Kob.dom.on returns. |
| emit(hook, ...args) | Fire an option callback with the element as this. |
| destroy() | Runs every tracked cleanup. Extend it, then call super.destroy(). |
Kob reference
| Member | Description |
|---|---|
| Kob.version | The library version. |
| Kob.parse(root) / unparse(root) / autoParse() | Build or tear down the widgets declared in markup. |
| Kob.parseOptions(str) | Read the inline data-options syntax. |
| Kob.instance(el, name) | The widget of that name on an element, if any. |
| Kob.config | request, onRequestError, prefix, zIndex, locale. |
| Kob.locale(name?) / addLocale(name, bundle) / locales / t(key, vars) | Localisation. |
| Kob.zIndex() | Next z-index for a floating layer, shared by every widget. |
| Kob.Widget / register(Class) / registry | Extension points. |
| Kob.dom | The internal DOM helpers: on, el, append, css, escapeHtml, … |
| Kob.date | parseDate, formatDate, toISO, today. |
| Kob.classes | The widget classes themselves, for extends. |