bank20baht-ui
v0.1.2
Published
JSON-driven form + workflow engine. Send JSON in, get an interactive form out.
Maintainers
Readme
bahtui
JSON in, interactive form out. A framework-neutral form + workflow engine shipped as Web Components (Lit, light DOM) with a pure-TypeScript rules core.
npm i bank20baht-uiQuick start — one form (Level 1)
<script type="module">
import 'bank20baht-ui'; // registers <baht-form> / <baht-workflow>
</script>
<link rel="stylesheet" href="bank20baht-ui/tokens.css" />
<baht-form id="f"></baht-form>
<script>
const el = document.getElementById('f');
el.form = {
formId: 'f1',
formName: 'Person',
field_data: [
{ order: 1, type: 'text', key: 'firstName', label: 'First name', width: '1/2', required: true, value: '' },
{ order: 2, type: 'text', key: 'lastName', label: 'Last name', width: '1/2', required: true, value: '' },
{ order: 3, type: 'number', key: 'age', label: 'Age', width: '1/4', min: 0, value: null }
]
};
el.addEventListener('baht-change', (e) => console.log(e.detail.data));
// el.validate() / el.getData() / el.setValue({...}) / el.reset()
</script><baht-form> is a dumb controlled component: props in → UI → baht-change out. No buttons, no
cross-field logic, no fetch. Your app owns the state (mutate the JSON, feed it back).
Views — show a subset of one form
Define named display projections on the schema (e.g. per user role); select one with the
view attribute:
el.form = {
...form,
views: [{ name: 'summary', fields: ['age', 'firstName'] }], // view order = display order
};
el.view = 'summary'; // or <baht-form view="summary">; unset = full formViews are client-side display only: the view picks which fields render (and validate —
the user can only fix what they see), but getData() still returns the full form and the
server (bank20baht-validator) always validates the complete schema. In a workflow, set
view: 'summary' on a step's form to pin the view that step renders.
Repeater — line items / repeating rows
{ order: 5, type: 'repeater', key: 'items', label: 'Line items', minRows: 1,
fields: [
{ order: 1, type: 'text', key: 'name', width: '1/2', required: true },
{ order: 2, type: 'number', key: 'qty', width: '1/4', min: 1 },
] }Value is an array of row objects (getData().items → [{ name, qty }, …]). Rows add/remove in
the UI (minRows/maxRows bound the count), each row validates its sub-fields — errors come
back as items[0].name, on both the client and bank20baht-validator. One level deep only.
Dependent / async dropdowns
// schema — functions never live in JSON, only registry names:
{ order: 2, type: 'dropdown', key: 'amphoe', optionsFrom: 'amphoeFor', optionsDeps: ['province'] }
// registry:
el.registry = {
options: { amphoeFor: (data) => fetch(`/api/amphoe?p=${data.province}`).then(r => r.json()) },
};Options resolve from the registry with the form's current data (sync or Promise). When a dep changes, options refetch and the dependent value clears — cascading down chains (จังหวัด → อำเภอ → ตำบล).
Cross-field validation
{ order: 2, type: 'date', key: 'end',
validators: [{ type: 'compare', op: 'gte', field: 'start' }] }compare checks against a sibling field (confirm email = op: 'eq'), numeric/date-aware,
skips while either side is empty. Works inside repeater rows and on the server.
Feature flags — deploy dark, flip a JSON file
// schema: gate a field / workflow step / rule
{ order: 9, type: 'text', key: 'promoCode', flag: 'promo' } // '!promo' = the old path
// host: flags from one static file — edit it, features flip
el.flags = await fetch('/flags.json').then((r) => r.json()); // { "promo": true }Flag-off means not in the schema: no render, no validation (a dark required can't
block), nothing in the payload. Missing keys count as off, so new features stay dark until
the file turns them on. Server side: validateSubmission(applyFlags(schema, flags), data)
with the same JSON — bank20baht-validator re-exports applyFlags.
File upload
// schema:
{ order: 6, type: 'file', key: 'doc', uploader: 'default', accept: '.pdf,image/*', maxSize: 10_485_760 }
// registry — File in, serializable ref out:
el.registry = {
uploaders: { default: async (file) => {
const fd = new FormData(); fd.append('file', file);
return (await fetch('/api/uploads', { method: 'POST', body: fd })).json();
} },
};Picking a file uploads immediately; the value becomes { name, size, type, url } — plain
JSON, chips with remove buttons in the UI, maxSize/accept validated on both client and
server. Omit uploader for the old capture-only mode.
Drafts / autosave (host pattern)
The library is controlled — autosave is three lines in your app:
el.addEventListener('baht-change', (e) => localStorage.setItem(key, JSON.stringify(e.detail.data)));
// (workflow: listen to baht-data-change instead)
el.value = JSON.parse(localStorage.getItem(key) ?? '{}'); // restore on mount
// clear the key after the server accepts the submissionSee apps/web's render page for a working restore/discard/clear-on-submit flow.
i18n — Thai (or any) built-in strings
All built-in strings — validation messages, Next/Submit/Back, the approval step — come from one catalog:
import { setLocale } from 'bank20baht-ui';
setLocale('th'); // built-ins: 'en' (default), 'th'
setLocale('th', { required: 'ห้ามว่าง' }); // locale + your own overridesCall it once before mounting. Per-field message and label attributes
(next-label, approval.approveLabel, …) still win over the catalog. The server can
localize too: bank20baht-validator re-exports setLocale, so validation issues come
back in the same language the client shows.
Quick start — wizard with rules (Level 2)
document.querySelector('baht-workflow').workflow = {
workflowId: 'wf-1',
forms: [/* {formId, formName, order, field_data}, ... */],
rules: [
{ id: 'skip-person',
when: { field: 'Intro.kind', op: 'eq', value: 'company' },
then: [{ action: 'skipForm', target: { form: 'Person' } }] },
{ id: 'vat',
when: { field: 'Company.hasVat', op: 'eq', value: true },
then: [{ action: 'show', target: { form: 'Company', field: 'vatId' } },
{ action: 'required', target: { form: 'Company', field: 'vatId' } }],
else: [{ action: 'hide', target: { form: 'Company', field: 'vatId' } }] }
]
};<baht-workflow> walks the forms in order, evaluates the rules on every change
(show/hide/enable/disable/readonly/required/setValue/skipForm…), renders the single
Next→Submit button, and emits baht-next / baht-submit / baht-complete / baht-data-change /
baht-step-change. The runtime navigates; your app acts.
Headless core (any framework, no DOM)
import { evaluate, validateField } from 'bank20baht-ui/engine';
const derived = evaluate(workflow, { Intro: { kind: 'company' } });
// derived.forms -> { Person: { visible: false }, ... }
// derived.fields -> { 'Company.vatId': { visible, enabled, readonly, required, computed } }
// derived.data -> nested output (hidden/skipped values cleared + excluded)The engine is a pure function with fixpoint evaluation (cycle-guarded), per-rule priority
conflict resolution, and clear-on-hide/skip semantics. Build a React/Vue renderer on top of it
without touching the Web Components.
Functions never travel in JSON
Formatters and custom validators are referenced by name and resolved from a registry you pass as a prop:
el.registry = {
formatters: { uppercase: (v) => String(v).toUpperCase() },
validators: { idCardTH: (v) => isThaiId(v) ? null : 'บัตรประชาชนไม่ถูกต้อง' }
};
// JSON side: { formatter: 'uppercase', validators: [{ type: 'custom', fn: 'idCardTH' }] }Design system — 20baht
Ships themed as the 20baht design system: banknote green (#1F7A4E), cream
paper background, Bricolage Grotesque / Geist / Geist Mono, borders-over-shadows,
"paper" easing. 35+ components:
<baht-button variant="secondary" size="sm">Cancel</baht-button>
<baht-badge tone="success" label="Running"></baht-badge>
<baht-table></baht-table> <baht-modal></baht-modal> <baht-toast></baht-toast> …- Data (server-side paging) —
baht-table·baht-list·baht-pagination·baht-filter.itemsis one page;baht-page-changeasks your app to fetch the next one. - Feedback —
baht-modal·baht-toast·baht-alert·baht-tooltip·baht-progress·baht-spinner·baht-skeleton - Inputs —
baht-rating·baht-range·baht-otp(+ the 11 form field primitives) - Structure & nav —
baht-card·baht-fieldset·baht-timeline·baht-accordion·baht-link·baht-breadcrumbs
Theming — design tokens + light DOM
Everything renders in light DOM and reads --baht-* CSS custom properties — override
at build time, at runtime, or inject your own sheet entirely:
.my-app {
--baht-color-primary: #047857;
--baht-radius-md: 6px;
}
.my-app .baht-label { text-transform: uppercase; } /* your CSS reaches every node */Styling API: .baht-form, .baht-field, .baht-field--w-1-2, .baht-field--hidden, .baht-label,
.baht-control, .baht-error, .baht-button, .baht-badge (+ the token list in tokens.css).
Full reference: docs/theming.md.
One install, typed everywhere
npm i bank20baht-ui ships the runtime and the metadata every editor/framework needs —
no hand-written type declarations in your app:
| You use | What you get | Setup |
|---|---|---|
| JetBrains / WebStorm (any framework) | autocomplete + docs for every <baht-*> tag | none — web-types is auto-detected |
| VS Code (HTML / Angular templates) | tag + attribute completion | one line, see below |
| React (TSX) | full JSX typings, typed props per element | import 'bank20baht-ui/react' once |
| Angular | template binding on properties/events | CUSTOM_ELEMENTS_SCHEMA, see below |
| Vue 3 | keep Vue from treating baht-* as components | isCustomElement, see below |
| Phoenix / Rails / plain HTML | it's just HTML — works as-is | none |
| Tooling (Storybook, docgen, custom) | machine-readable API of all 36 elements | read dist/custom-elements.json (CEM standard) |
VS Code — .vscode/settings.json:
{ "html.customData": ["./node_modules/bahtui/dist/vscode.html-custom-data.json"] }React — once, in your app entry (or load it globally without a runtime import via
"types": ["bank20baht-ui/react"] in tsconfig.json):
import 'bank20baht-ui/react'; // JSX typings for every <baht-*> elementReact 19+ sets non-string JSX props as DOM properties, so object props (form,
workflow, items, …) work inline; on React 18 set those via a ref.
Angular — the compiler must be told baht-* tags are custom elements:
@Component({ schemas: [CUSTOM_ELEMENTS_SCHEMA], /* … */ })Vue 3 — vite.config.ts:
vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('baht-') } } })Docs, Storybook, examples
- Per-component docs:
docs/— one .md per component + framework guides (vanilla / React / Angular). - Storybook:
pnpm --filter storybook-bahtui dev→ http://localhost:6006. - Copy-out starters:
examples/vanilla,examples/react,examples/angularat the repo root — same workflow JSON in three hosts.
Field types (12)
text · textarea · number · email · dropdown · multiselect · checkbox · radio · toggle · date ·
file (capture only, no upload) · label (static)
Each field descriptor: { order, type, key, label, placeholder, width: '1/4'|'1/2'|'3/4'|'full',
value, disabled, required, options, maxLength, min, max, validators[], formatter }.
Visual Builder
The playground app ships a two-mode Visual Builder (Form mode: palette/WYSIWYG canvas/properties;
Workflow mode: sequence + rules with a live preview running the real <baht-workflow>), with
Import/Export JSON. Run it from the monorepo: pnpm --filter playground dev → /builder.
