bank20baht-ui 0.0.7 → 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
@@ -67,6 +67,45 @@ Value is an array of row objects (`getData().items → [{ name, qty }, …]`). R
67
67
  the UI (`minRows`/`maxRows` bound the count), each row validates its sub-fields — errors come
68
68
  back as `items[0].name`, on both the client and `bank20baht-validator`. One level deep only.
69
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
+
70
109
  ### i18n — Thai (or any) built-in strings
71
110
 
72
111
  All built-in strings — validation messages, Next/Submit/Back, the approval step —
@@ -32,7 +32,23 @@ export declare class BahtForm extends BahtElement {
32
32
  * which drops focus and makes continuous typing impossible.
33
33
  */
34
34
  private controls;
35
+ /** Resolved dynamic options + the dep snapshot / fetch epoch they belong to. */
36
+ private dynamicOptions;
37
+ private optionsSnapshots;
38
+ private optionsEpoch;
35
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;
36
52
  private get sortedFields();
37
53
  private stateFor;
38
54
  private valueFor;
@@ -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;
@@ -6,6 +6,12 @@ export interface MessageCatalog {
6
6
  max: string;
7
7
  pattern: string;
8
8
  email: string;
9
+ compareEq: string;
10
+ compareNe: string;
11
+ compareGt: string;
12
+ compareGte: string;
13
+ compareLt: string;
14
+ compareLte: string;
9
15
  addRow: string;
10
16
  removeRow: string;
11
17
  minRows: string;
@@ -5,11 +5,20 @@ 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;
@@ -154,6 +171,8 @@ export interface Registry {
154
171
  formatters?: Record<string, (value: unknown) => unknown>;
155
172
  /** Return null when valid, or an error message string. */
156
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[]>>;
157
176
  }
158
177
  export interface DerivedFieldState {
159
178
  visible: boolean;
@@ -2,7 +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
- * Repeaters (spec 01 §10) additionally check row bounds and recurse into each
6
- * row's sub-fields, prefixing keys as `key[i].subKey`.
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).
7
9
  */
8
- 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[];
@@ -86,6 +86,55 @@
86
86
  "default": "new Map<string, HTMLElement & Record<string, unknown>>()",
87
87
  "description": "One control element per field key, reused across renders. Recreating them\nevery render (the naive approach) replaces the DOM node on each keystroke,\nwhich drops focus and makes continuous typing impossible."
88
88
  },
89
+ {
90
+ "kind": "field",
91
+ "name": "dynamicOptions",
92
+ "privacy": "private",
93
+ "default": "new Map<string, FieldOption[]>()",
94
+ "description": "Resolved dynamic options + the dep snapshot / fetch epoch they belong to."
95
+ },
96
+ {
97
+ "kind": "field",
98
+ "name": "optionsSnapshots",
99
+ "privacy": "private",
100
+ "default": "new Map<string, string>()"
101
+ },
102
+ {
103
+ "kind": "field",
104
+ "name": "optionsEpoch",
105
+ "privacy": "private",
106
+ "default": "new Map<string, number>()"
107
+ },
108
+ {
109
+ "kind": "method",
110
+ "name": "syncDynamicOptions",
111
+ "privacy": "private",
112
+ "return": {
113
+ "type": {
114
+ "text": "void"
115
+ }
116
+ },
117
+ "description": "Refetch `optionsFrom` options whose dep snapshot changed. Sync fetchers\nland immediately; async ones re-render on resolve (stale responses are\ndropped by epoch)."
118
+ },
119
+ {
120
+ "kind": "method",
121
+ "name": "clearDependents",
122
+ "privacy": "private",
123
+ "return": {
124
+ "type": {
125
+ "text": "void"
126
+ }
127
+ },
128
+ "parameters": [
129
+ {
130
+ "name": "changedKey",
131
+ "type": {
132
+ "text": "string"
133
+ }
134
+ }
135
+ ],
136
+ "description": "A user edit invalidates every field whose optionsDeps include the edited\nkey — their values reset to defaults, cascading down chains\n(province -> district -> subdistrict)."
137
+ },
89
138
  {
90
139
  "kind": "field",
91
140
  "name": "sortedFields",