# 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-ui
npm i flatpickr   # peer dependency — only if you render date fields / <baht-datepicker>
```

## Quick start — one form (Level 1)

```html
<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 = {
    form_id: 'f1',
    form_name: 'Person',
    fields: [
      { 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:

```js
el.form = {
  ...form,
  views: [{ name: 'summary', fields: ['age', 'firstName'] }], // view order = display order
};
el.view = 'summary'; // or <baht-form view="summary">; unset = full form
```

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

### Read-only display

`<baht-form readonly>` shows a submission instead of editing it: values as text, option
labels instead of keys, chips for multi-picks, Yes/No, no help text, "Not provided" for
blanks. Same grid, same widths. `displayValue(field, value)` from `bank20baht-ui/engine`
gives you the same formatting for tables.

### Repeater — line items / repeating rows

```js
{ order: 5, type: 'repeater', key: 'items', label: 'Line items', min_rows: 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 (`min_rows`/`max_rows` 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

```js
// schema — functions never live in JSON, only registry names:
{ order: 2, type: 'dropdown', key: 'amphoe', options_from: 'amphoeFor', options_deps: ['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

```js
{ 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

```js
// 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

```js
// schema:
{ order: 6, type: 'file', key: 'doc', uploader: 'default', accept: '.pdf,image/*', max_size: 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, `max_size`/`accept` validated on both client and
server. Omit `uploader` for the old capture-only mode.

No registry function at all? `<baht-upload>` talks to an endpoint straight from attributes:
the file is POSTed as multipart on pick, the endpoint returns the stored URL, and that URL is
the value (`baht-change` → `{ value, files }`; progress and error events included).

```html
<baht-upload url="/api/uploads" accept=".pdf,image/*" max-size="10485760" label="Attachment"></baht-upload>
```

### Drafts / autosave (host pattern)

The library is controlled — autosave is three lines in your app:

```js
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 submission
```

The `/builder` page of the docs site (`bun run --filter docs dev`) shows the same flow live.

### i18n — Thai (or any) built-in strings

All built-in strings — validation messages, Next/Submit/Back, the approval step —
come from one catalog:

```js
import { setLocale } from 'bank20baht-ui';

setLocale('th'); // built-ins: 'en' (default), 'th'
setLocale('th', { required: 'ห้ามว่าง' }); // locale + your own overrides
```

Call it once before mounting. Per-field `message` and label attributes
(`next-label`, `approval.approve_label`, …) 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)

```js
document.querySelector('baht-workflow').workflow = {
  workflow_id: 'wf-1',
  forms: [/* {form_id, form_name, order, fields}, ... */],
  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)

```ts
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:

```js
el.registry = {
  formatters: { uppercase: (v) => String(v).toUpperCase() },
  validators: { idCardTH: (v) => isThaiId(v) ? null : 'บัตรประชาชนไม่ถูกต้อง' }
};
// JSON side: { formatter: 'uppercase', validators: [{ type: 'custom', fn: 'idCardTH' }] }
```

### Client scripts — ServiceNow-style `load` / `change` / `submit`

When rules JSON isn't enough, register imperative hooks the same way — by name:

```js
// JSON side
form.scripts = [
  { on: 'change', field: 'type', handler: 'typeChanged' },
  { on: 'submit', handler: 'checkQuota' },
];
// host side — `g` is the g_form equivalent, scoped to this form
el.registry.scripts = {
  typeChanged: (g, { newValue, isLoading }) => {
    if (isLoading) return;
    g.setRequired('doc', newValue === 'sick');
    g.showFieldMsg('doc', 'แนบใบรับรองแพทย์', 'info');
  },
  checkQuota: (g) => {
    if (g.getValue('days') > 10) { g.addErrorMessage('เกินโควตา'); return false; } // blocks submit
  },
};
```

`g` reads (`getValue`, `getData`), writes (`setValue` — cascades to that field's `change`
script, depth-guarded), overrides UI state (`setVisible` / `setReadOnly` / `setRequired` /
`setDisabled`, on top of rule-derived states) and shows messages. `bank20baht-validator`
re-runs `submit` scripts on the server; `load`/`change` are UI-only. See
`docs/components/baht-form.md` and spec 01 §14.

## Design system — 20baht

Ships themed as the **20baht design system**, "ledger" retone: minimal and flat —
near-black ink (`#171717`) on white, hairline neutral borders, 44px controls, one indigo accent
(`#444FBF`), status as tint pills, hand-drawn koboyo spot illustrations for empty /
error / upload states, IBM Plex Sans Thai / IBM Plex Mono (load the
fonts yourself), borders-over-shadows, "paper" easing — plus an opt-in dark theme
(`<html data-baht-theme="dark">`). 64 elements:

```html
<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-swipe-row` · `baht-pagination` · `baht-filter`.
  `baht-table` ships a ServiceNow-style column picker with dot-walking into reference columns (`requester.department`) and emits `baht-columns-change` so the host can persist the layout.
  `items` is one page; `baht-page-change` asks your app to fetch the next one.
- **Actions** — `baht-button` (`shortcut="alt+q"`: bound key + tooltip hint) · `baht-split-button` (main action + menu with shortcut hints) · `baht-menu` (⋯ / labelled action menu) · `baht-theme-toggle` (light / dark switch)
- **Feedback** — `baht-modal` · `baht-toast` · `baht-alert` · `baht-tooltip` · `baht-popover` · `baht-progress` ·
  `baht-spinner` · `baht-skeleton`
- **Inputs** — `baht-rating` · `baht-range` · `baht-otp` · `baht-typeahead` · `baht-reference-picker` ·
  `baht-keyvalue-editor` · `baht-upload` (+ the 15 form field primitives). Typed fields debounce keystrokes
  (150 ms, `debounce`); `dropdown` renders its own listbox, never the OS picker.
- **Stats & charts** — `baht-stat` · `baht-stat-chart` · `baht-meter` · `baht-sparkline` · `baht-bar-chart` ·
  `baht-line-chart` · `baht-donut-chart` · `baht-heatmap` · `baht-progress-ring`
- **Structure & nav** — `baht-card` · `baht-fieldset` · `baht-timeline` · `baht-accordion` · `baht-steps` ·
  `baht-link` · `baht-breadcrumbs` · `baht-tabs` · `baht-avatar` · `baht-code` · `baht-empty-state` · `baht-error-state` ·
  `baht-loading-overlay`

## 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:

```css
.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](./docs/theming.md).

## Import only what you use

`import 'bank20baht-ui'` registers every element (~230 KB min). Three lighter routes:

```ts
// 1. per-element subpath — one module per tag, plus only what it imports
import 'bank20baht-ui/components/button'; // <baht-button>
import { BahtTable } from 'bank20baht-ui/components/table';
import 'bank20baht-ui/components/form'; // <baht-form> + the 15 field primitives
import 'bank20baht-ui/components/workflow';

// 2. by tag list — each tag is a dynamic import, fetched on demand
import { registerElements } from 'bank20baht-ui/register';
await registerElements(['baht-button', 'baht-avatar']);
```

```css
/* 3. split stylesheets instead of the all-in-one tokens.css (~115 KB) */
@import 'bank20baht-ui/css/tokens.css'; /* custom properties only, 18 KB */
@import 'bank20baht-ui/css/components.css'; /* .baht-* classes */
```

`bank20baht-ui/tokens.css` = the two sheets above in one import. The calendar is opt-in:

```css
/* with <baht-datepicker> / date fields — flatpickr's base sheet + the token re-skin */
@import 'bank20baht-ui/css/datepicker.css';
```

flatpickr itself is a **peer dependency** (`npm i flatpickr`) loaded with a dynamic
`import()` the first time a `<baht-datepicker>` mounts, so apps without date fields never
bundle it and apps with them get it as its own chunk. `await el.updateComplete` on the
element resolves once the calendar is attached.

## 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 64 elements | read `dist/custom-elements.json` ([CEM standard](https://github.com/webcomponents/custom-elements-manifest)) |

**VS Code** — `.vscode/settings.json`:

```json
{ "html.customData": ["./node_modules/bank20baht-ui/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`):

```ts
import 'bank20baht-ui/react'; // JSX typings for every <baht-*> element
```

React 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:

```ts
@Component({ schemas: [CUSTOM_ELEMENTS_SCHEMA], /* … */ })
```

Bind `readonly` as an attribute (`[attr.readonly]="x ? '' : null"`), not a property — Angular
rewrites `[readonly]` to the native `readOnly`. Zoneless Angular may connect an element before
binding its props; since 0.2.3 every field primitive renders empty until `field` arrives. Full
list of gotchas: [docs/frameworks/angular.md](./docs/frameworks/angular.md#gotchas).

**Vue 3** — `vite.config.ts`:

```ts
vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('baht-') } } })
```

## Server-side rendering

`bank20baht-ui/ssr` (Node) renders any Lit template with `@lit-labs/ssr` and moves each
`baht-*` element's shadow template into its light DOM, so the HTML string it returns is
exactly what the client would have rendered. `bank20baht-ui/hydrate` (browser, imported
**before** `bank20baht-ui`) picks that markup up instead of re-rendering it:

```js
// server
import { html } from 'lit';
import { renderToString } from 'bank20baht-ui/ssr';
const page = await renderToString(html`<baht-button variant="primary" label="Save"></baht-button>`);

// client — import order matters
import 'bank20baht-ui/hydrate';
import 'bank20baht-ui';
```

`@lit-labs/ssr` is an optional peer dependency (server only). Full guide, limitations
(light-DOM children, ids, i18n locale) and troubleshooting: [docs/ssr.md](./docs/ssr.md).

## Docs

- **Per-component docs**: [`docs/`](./docs/README.md) — one .md per element + framework
  guides (vanilla / React / Angular) + theming + SSR.
- **Docs site with live specimens**: https://bahtui-docs.pages.dev (source: `apps/docs`).
- **For agents**: the `bahtui-mcp` server (`apps/mcp`) serves these docs and runs the engine on your JSON.

## Field types (20)

`text · textarea · number · currency · email · password · dropdown · multiselect · tags · checkbox · radio ·
toggle · rating · range · reference · date · file · repeater (one level of rows) · label (static) · section (divider)`

Each field descriptor: `{ order, type, key, label, placeholder, width: '1/4'|'1/2'|'3/4'|'full',
value, disabled, required, options | options_from, max_length, min, max, validators[], formatter, flag }`.

## Visual Builder

The docs site 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: `bun run --filter docs dev` → `/builder`.
