bank20baht-ui 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,6 +35,94 @@ npm i bank20baht-ui
35
35
  `<baht-form>` is a **dumb controlled component**: props in → UI → `baht-change` out. No buttons, no
36
36
  cross-field logic, no fetch. Your app owns the state (mutate the JSON, feed it back).
37
37
 
38
+ ### Views — show a subset of one form
39
+
40
+ Define named display projections on the schema (e.g. per user role); select one with the
41
+ `view` attribute:
42
+
43
+ ```js
44
+ el.form = {
45
+ ...form,
46
+ views: [{ name: 'summary', fields: ['age', 'firstName'] }], // view order = display order
47
+ };
48
+ el.view = 'summary'; // or <baht-form view="summary">; unset = full form
49
+ ```
50
+
51
+ Views are **client-side display only**: the view picks which fields render (and validate —
52
+ the user can only fix what they see), but `getData()` still returns the full form and the
53
+ server (`bank20baht-validator`) always validates the complete schema. In a workflow, set
54
+ `view: 'summary'` on a step's form to pin the view that step renders.
55
+
56
+ ### Repeater — line items / repeating rows
57
+
58
+ ```js
59
+ { order: 5, type: 'repeater', key: 'items', label: 'Line items', minRows: 1,
60
+ fields: [
61
+ { order: 1, type: 'text', key: 'name', width: '1/2', required: true },
62
+ { order: 2, type: 'number', key: 'qty', width: '1/4', min: 1 },
63
+ ] }
64
+ ```
65
+
66
+ Value is an array of row objects (`getData().items → [{ name, qty }, …]`). Rows add/remove in
67
+ the UI (`minRows`/`maxRows` bound the count), each row validates its sub-fields — errors come
68
+ back as `items[0].name`, on both the client and `bank20baht-validator`. One level deep only.
69
+
70
+ ### Dependent / async dropdowns
71
+
72
+ ```js
73
+ // schema — functions never live in JSON, only registry names:
74
+ { order: 2, type: 'dropdown', key: 'amphoe', optionsFrom: 'amphoeFor', optionsDeps: ['province'] }
75
+
76
+ // registry:
77
+ el.registry = {
78
+ options: { amphoeFor: (data) => fetch(`/api/amphoe?p=${data.province}`).then(r => r.json()) },
79
+ };
80
+ ```
81
+
82
+ Options resolve from the registry with the form's current data (sync or Promise). When a
83
+ dep changes, options refetch and the dependent value clears — cascading down chains
84
+ (จังหวัด → อำเภอ → ตำบล).
85
+
86
+ ### Cross-field validation
87
+
88
+ ```js
89
+ { order: 2, type: 'date', key: 'end',
90
+ validators: [{ type: 'compare', op: 'gte', field: 'start' }] }
91
+ ```
92
+
93
+ `compare` checks against a sibling field (confirm email = `op: 'eq'`), numeric/date-aware,
94
+ skips while either side is empty. Works inside repeater rows and on the server.
95
+
96
+ ### Drafts / autosave (host pattern)
97
+
98
+ The library is controlled — autosave is three lines in your app:
99
+
100
+ ```js
101
+ el.addEventListener('baht-change', (e) => localStorage.setItem(key, JSON.stringify(e.detail.data)));
102
+ // (workflow: listen to baht-data-change instead)
103
+ el.value = JSON.parse(localStorage.getItem(key) ?? '{}'); // restore on mount
104
+ // clear the key after the server accepts the submission
105
+ ```
106
+
107
+ See `apps/web`'s render page for a working restore/discard/clear-on-submit flow.
108
+
109
+ ### i18n — Thai (or any) built-in strings
110
+
111
+ All built-in strings — validation messages, Next/Submit/Back, the approval step —
112
+ come from one catalog:
113
+
114
+ ```js
115
+ import { setLocale } from 'bank20baht-ui';
116
+
117
+ setLocale('th'); // built-ins: 'en' (default), 'th'
118
+ setLocale('th', { required: 'ห้ามว่าง' }); // locale + your own overrides
119
+ ```
120
+
121
+ Call it once before mounting. Per-field `message` and label attributes
122
+ (`next-label`, `approval.approveLabel`, …) still win over the catalog. The server can
123
+ localize too: `bank20baht-validator` re-exports `setLocale`, so validation issues come
124
+ back in the same language the client shows.
125
+
38
126
  ## Quick start — wizard with rules (Level 2)
39
127
 
40
128
  ```js
@@ -16,6 +16,13 @@ export declare class BahtForm extends BahtElement {
16
16
  options: BahtFormOptions;
17
17
  /** L2 (workflow) derived per-field UI state, keyed by field key. Optional. */
18
18
  fieldStates: Record<string, DerivedFieldState>;
19
+ /**
20
+ * Name of a view from `form.views`. Display-only: when set, only that view's
21
+ * fields are rendered and validated (the user can only fix what they see),
22
+ * but getData() still returns the FULL form — the server always validates
23
+ * the complete schema, views never change the submit payload.
24
+ */
25
+ view?: string;
19
26
  /** Edits the user made since the host last set `value` (controlled overlay). */
20
27
  private edits;
21
28
  private errors;
@@ -25,7 +32,23 @@ export declare class BahtForm extends BahtElement {
25
32
  * which drops focus and makes continuous typing impossible.
26
33
  */
27
34
  private controls;
35
+ /** Resolved dynamic options + the dep snapshot / fetch epoch they belong to. */
36
+ private dynamicOptions;
37
+ private optionsSnapshots;
38
+ private optionsEpoch;
28
39
  protected willUpdate(changed: Map<PropertyKey, unknown>): void;
40
+ /**
41
+ * Refetch `optionsFrom` options whose dep snapshot changed. Sync fetchers
42
+ * land immediately; async ones re-render on resolve (stale responses are
43
+ * dropped by epoch).
44
+ */
45
+ private syncDynamicOptions;
46
+ /**
47
+ * A user edit invalidates every field whose optionsDeps include the edited
48
+ * key — their values reset to defaults, cascading down chains
49
+ * (province -> district -> subdistrict).
50
+ */
51
+ private clearDependents;
29
52
  private get sortedFields();
30
53
  private stateFor;
31
54
  private valueFor;
@@ -16,9 +16,10 @@ export declare class BahtWorkflow extends BahtElement {
16
16
  value: NestedData;
17
17
  registry: Registry;
18
18
  validateGate: boolean;
19
- nextLabel: string;
20
- submitLabel: string;
21
- backLabel: string;
19
+ /** Label attributes beat the i18n catalog; unset = t('next')/t('submit')/t('back'). */
20
+ nextLabel?: string;
21
+ submitLabel?: string;
22
+ backLabel?: string;
22
23
  showBack: boolean;
23
24
  private data;
24
25
  private derived;
@@ -0,0 +1,33 @@
1
+ import { TemplateResult } from 'lit';
2
+ import { BahtFieldBase } from '../base.js';
3
+ import { ValidationError } from '../../core/types.js';
4
+ /**
5
+ * <baht-repeater> — array-of-rows control (spec 01 §10). `value` is an array
6
+ * of row objects; every row renders the same `field.fields` sub-descriptors
7
+ * (one level deep — nested repeaters are skipped). Controlled like every
8
+ * primitive: edits emit the WHOLE array via `baht-input`; the host re-feeds.
9
+ */
10
+ export declare class BahtRepeater extends BahtFieldBase {
11
+ /** Row-scoped errors from the host form, keyed `key[i].subKey`. */
12
+ rowErrors: ValidationError[];
13
+ /**
14
+ * One control per `${rowIndex}:${subKey}`, reused across renders so typing
15
+ * doesn't drop focus (same trick as <baht-form>, NOTES round 27). Cleared
16
+ * when the descriptor changes or row indexes shift (remove).
17
+ */
18
+ private controls;
19
+ protected willUpdate(changed: Map<PropertyKey, unknown>): void;
20
+ private get rows();
21
+ private get subFields();
22
+ private patchRow;
23
+ private addRow;
24
+ private removeRow;
25
+ private errorFor;
26
+ private renderSub;
27
+ protected renderControl(): TemplateResult;
28
+ }
29
+ declare global {
30
+ interface HTMLElementTagNameMap {
31
+ 'baht-repeater': BahtRepeater;
32
+ }
33
+ }
@@ -1,4 +1,3 @@
1
- import { FieldType } from '../../core/types.js';
2
1
  export { BahtButton, type ButtonVariant, type ButtonSize } from './baht-button.js';
3
2
  export { renderIcon, isIconName, type IconName } from './icons.js';
4
3
  export { BahtBadge, type BadgeTone } from './baht-badge.js';
@@ -42,5 +41,5 @@ export { BahtEmptyState } from './baht-empty-state.js';
42
41
  export { BahtErrorState } from './baht-error-state.js';
43
42
  export { BahtKeyvalueEditor } from './baht-keyvalue-editor.js';
44
43
  export { BahtReferencePicker, type ReferenceOption } from './baht-reference-picker.js';
45
- /** field descriptor `type` -> primitive tag (used by <baht-form> and the builder). */
46
- export declare const TAG_FOR_TYPE: Record<FieldType, string>;
44
+ export { BahtRepeater } from './baht-repeater.js';
45
+ export { TAG_FOR_TYPE } from './tag-map.js';
@@ -0,0 +1,7 @@
1
+ import { FieldType } from '../../core/types.js';
2
+ /**
3
+ * field descriptor `type` -> primitive tag (used by <baht-form>, <baht-repeater>
4
+ * and the builder). Lives in its own module so baht-repeater can read it
5
+ * without importing the primitives barrel (which would be circular).
6
+ */
7
+ export declare const TAG_FOR_TYPE: Record<FieldType, string>;
@@ -1,4 +1,9 @@
1
1
  import { Condition, NestedData } from '../types.js';
2
2
  /** Read a "formName.fieldKey" reference out of nested data. */
3
3
  export declare function readRef(data: NestedData, ref: string): unknown;
4
+ /**
5
+ * Ordering used by rule conditions AND the `compare` validator: numeric when
6
+ * both sides coerce, date-aware, else lexicographic. Null = not comparable.
7
+ */
8
+ export declare function compareValues(a: unknown, b: unknown): number | null;
4
9
  export declare function evalCondition(cond: Condition | undefined, data: NestedData): boolean;
@@ -0,0 +1,48 @@
1
+ export interface MessageCatalog {
2
+ required: string;
3
+ minLength: string;
4
+ maxLength: string;
5
+ min: string;
6
+ max: string;
7
+ pattern: string;
8
+ email: string;
9
+ compareEq: string;
10
+ compareNe: string;
11
+ compareGt: string;
12
+ compareGte: string;
13
+ compareLt: string;
14
+ compareLte: string;
15
+ addRow: string;
16
+ removeRow: string;
17
+ minRows: string;
18
+ maxRows: string;
19
+ next: string;
20
+ submit: string;
21
+ back: string;
22
+ step: string;
23
+ approve: string;
24
+ reject: string;
25
+ decision: string;
26
+ comment: string;
27
+ commentRequiredSuffix: string;
28
+ decisionRequired: string;
29
+ commentRequired: string;
30
+ nothingToReview: string;
31
+ yes: string;
32
+ no: string;
33
+ emptyValue: string;
34
+ }
35
+ export type MessageKey = keyof MessageCatalog;
36
+ export type Locale = 'en' | 'th';
37
+ /**
38
+ * Switch every built-in string to a locale, optionally patching single keys:
39
+ *
40
+ * setLocale('th') // all Thai
41
+ * setLocale('th', { required: 'ห้ามว่าง' }) // Thai + one custom message
42
+ *
43
+ * Global and synchronous — set it before mounting (or before validating on
44
+ * the server). Components read the catalog at render time.
45
+ */
46
+ export declare function setLocale(locale: Locale, overrides?: Partial<MessageCatalog>): void;
47
+ /** Resolve one catalog string, interpolating `{param}` placeholders. */
48
+ export declare function t(key: MessageKey, params?: Record<string, unknown>): string;
@@ -2,6 +2,9 @@ export * from './types.js';
2
2
  export { defaultValue } from './defaults.js';
3
3
  export { approvalFields } from './approval.js';
4
4
  export { validateField } from './validators.js';
5
+ export { resolveViewFields } from './views.js';
6
+ export { setLocale, t } from './i18n.js';
7
+ export type { Locale, MessageCatalog, MessageKey } from './i18n.js';
5
8
  export { evaluate } from './engine/evaluate.js';
6
9
  export { evalCondition, readRef } from './engine/conditions.js';
7
10
  export { evalValueExpr } from './engine/expressions.js';
@@ -1,15 +1,24 @@
1
1
  /** Control types supported by v1 (spec 01 §2). */
2
- export type FieldType = 'text' | 'textarea' | 'number' | 'email' | 'dropdown' | 'multiselect' | 'checkbox' | 'radio' | 'toggle' | 'date' | 'file' | 'label';
2
+ export type FieldType = 'text' | 'textarea' | 'number' | 'email' | 'dropdown' | 'multiselect' | 'checkbox' | 'radio' | 'toggle' | 'date' | 'file' | 'label' | 'repeater';
3
3
  export type FieldWidth = '1/4' | '1/2' | '3/4' | 'full';
4
4
  export interface FieldOption {
5
5
  key: string;
6
6
  label: string;
7
7
  }
8
- /** Declarative validator (spec 01 §4). `custom` resolves `fn` from the registry. */
8
+ /** Ordering/equality ops available to the `compare` validator. */
9
+ export type CompareOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte';
10
+ /**
11
+ * Declarative validator (spec 01 §4). `custom` resolves `fn` from the
12
+ * registry; `compare` checks this value against a sibling field's value
13
+ * (same form — or same row inside a repeater) and skips while the sibling
14
+ * is empty or unknown.
15
+ */
9
16
  export interface ValidatorSpec {
10
- type: 'required' | 'minLength' | 'maxLength' | 'min' | 'max' | 'pattern' | 'email' | 'custom';
17
+ type: 'required' | 'minLength' | 'maxLength' | 'min' | 'max' | 'pattern' | 'email' | 'compare' | 'custom';
11
18
  value?: unknown;
12
19
  fn?: string;
20
+ op?: CompareOp;
21
+ field?: string;
13
22
  message?: string;
14
23
  }
15
24
  /** One item of `field_data[]` (spec 01 §1). */
@@ -26,6 +35,14 @@ export interface FieldDescriptor {
26
35
  disabled?: boolean;
27
36
  required?: boolean;
28
37
  options?: FieldOption[];
38
+ /** Registry name resolving this field's options from current form data. */
39
+ optionsFrom?: string;
40
+ /**
41
+ * Same-form field keys this field's options depend on. When one changes
42
+ * (user edit), the options refetch and this field's value clears —
43
+ * cascading through chained dependents (province -> district -> …).
44
+ */
45
+ optionsDeps?: string[];
29
46
  maxLength?: number;
30
47
  minLength?: number;
31
48
  min?: number | string;
@@ -37,14 +54,30 @@ export interface FieldDescriptor {
37
54
  maxSize?: number;
38
55
  text?: string;
39
56
  variant?: string;
57
+ fields?: FieldDescriptor[];
58
+ minRows?: number;
59
+ maxRows?: number;
60
+ addLabel?: string;
40
61
  validators?: ValidatorSpec[];
41
62
  formatter?: string;
42
63
  }
64
+ /**
65
+ * A named, client-side-only projection of a form: which fields to show, in
66
+ * what order (e.g. per user role). Display sugar only — the submit payload is
67
+ * always the full form and the server always validates the complete schema.
68
+ */
69
+ export interface FormViewSpec {
70
+ name: string;
71
+ /** field keys included, in display order; keys absent from field_data are ignored */
72
+ fields: string[];
73
+ }
43
74
  /** A Form definition — consumed standalone by <baht-form> (spec 00 §4.1). */
44
75
  export interface FormDefinition {
45
76
  formId: string;
46
77
  formName: string;
47
78
  field_data: FieldDescriptor[];
79
+ /** Optional named display subsets; select one via <baht-form view="...">. */
80
+ views?: FormViewSpec[];
48
81
  }
49
82
  export type ApprovalDecision = 'approved' | 'rejected';
50
83
  /** Comment box policy on an approval step. */
@@ -73,6 +106,8 @@ export interface WorkflowForm extends FormDefinition {
73
106
  */
74
107
  kind?: 'form' | 'approval';
75
108
  approval?: ApprovalConfig;
109
+ /** Name of one of this form's `views` to render for this step (display only). */
110
+ view?: string;
76
111
  }
77
112
  export type ConditionOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'notIn' | 'contains' | 'notContains' | 'startsWith' | 'endsWith' | 'isEmpty' | 'isNotEmpty';
78
113
  /** Leaf: compare `formName.fieldKey` against a constant or another field. */
@@ -136,6 +171,8 @@ export interface Registry {
136
171
  formatters?: Record<string, (value: unknown) => unknown>;
137
172
  /** Return null when valid, or an error message string. */
138
173
  validators?: Record<string, (value: unknown) => string | null>;
174
+ /** Option fetchers for `optionsFrom` — receive the form's current flat data. */
175
+ options?: Record<string, (data: Record<string, unknown>) => FieldOption[] | Promise<FieldOption[]>>;
139
176
  }
140
177
  export interface DerivedFieldState {
141
178
  visible: boolean;
@@ -2,5 +2,9 @@ import { FieldDescriptor, Registry, ValidationError } from './types.js';
2
2
  /**
3
3
  * Validate one field's value against its declarative validators (spec 01 §4).
4
4
  * Empty + not required = valid (other validators only run on non-empty values).
5
+ * `data` is the surrounding flat form data — only `compare` validators read
6
+ * it; omitting it skips them. Repeaters (spec 01 §10) additionally check row
7
+ * bounds and recurse into each row's sub-fields, prefixing keys as
8
+ * `key[i].subKey` (each row is its own compare context).
5
9
  */
6
- export declare function validateField(field: FieldDescriptor, value: unknown, registry?: Registry): ValidationError[];
10
+ export declare function validateField(field: FieldDescriptor, value: unknown, registry?: Registry, data?: Record<string, unknown>): ValidationError[];
@@ -0,0 +1,12 @@
1
+ import { FieldDescriptor, FormDefinition } from './types.js';
2
+ /**
3
+ * Resolve the fields a consumer of `form` should DISPLAY. Views are
4
+ * client-side projections only — data and server validation always cover the
5
+ * full form.
6
+ *
7
+ * - No `view` → every field, sorted by `order` (the full form).
8
+ * - A known view → only its fields, in the order the view lists them.
9
+ * - An unknown view → `null`; the caller decides the fallback (<baht-form>
10
+ * warns and shows the full form).
11
+ */
12
+ export declare function resolveViewFields(form: FormDefinition, view?: string): FieldDescriptor[] | null;