@adcops/autocore-react 3.3.113 → 3.3.116

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.
Files changed (69) hide show
  1. package/dist/components/index.d.ts +10 -2
  2. package/dist/components/index.d.ts.map +1 -1
  3. package/dist/components/index.js +1 -1
  4. package/dist/components/tis/CycleConfigDialog.d.ts +39 -0
  5. package/dist/components/tis/CycleConfigDialog.d.ts.map +1 -0
  6. package/dist/components/tis/CycleConfigDialog.js +1 -0
  7. package/dist/components/tis/CycleConfigForm.d.ts +22 -0
  8. package/dist/components/tis/CycleConfigForm.d.ts.map +1 -0
  9. package/dist/components/tis/CycleConfigForm.js +1 -0
  10. package/dist/components/tis/CycleStatusStrip.d.ts +10 -0
  11. package/dist/components/tis/CycleStatusStrip.d.ts.map +1 -0
  12. package/dist/components/tis/CycleStatusStrip.js +1 -0
  13. package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
  14. package/dist/components/tis/ResultHistoryTable.js +1 -1
  15. package/dist/components/tis/ScienceTable.d.ts +15 -0
  16. package/dist/components/tis/ScienceTable.d.ts.map +1 -1
  17. package/dist/components/tis/ScienceTable.js +1 -1
  18. package/dist/components/tis/TestDataView.d.ts +14 -4
  19. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  20. package/dist/components/tis/TestDataView.js +1 -1
  21. package/dist/components/tis/TestFieldRow.d.ts +151 -0
  22. package/dist/components/tis/TestFieldRow.d.ts.map +1 -0
  23. package/dist/components/tis/TestFieldRow.js +1 -0
  24. package/dist/components/tis/TestSetupForm.d.ts +15 -51
  25. package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
  26. package/dist/components/tis/TestSetupForm.js +1 -1
  27. package/dist/components/tis/TestSetupView.d.ts.map +1 -1
  28. package/dist/components/tis/TestSetupView.js +1 -1
  29. package/dist/components/tis/TisProvider.d.ts +80 -0
  30. package/dist/components/tis/TisProvider.d.ts.map +1 -1
  31. package/dist/components/tis/TisProvider.js +1 -1
  32. package/dist/components/tis-editor/TisConfigEditor.d.ts.map +1 -1
  33. package/dist/components/tis-editor/TisConfigEditor.js +1 -1
  34. package/dist/components/tis-editor/editor/CycleConfigSection.d.ts +19 -0
  35. package/dist/components/tis-editor/editor/CycleConfigSection.d.ts.map +1 -0
  36. package/dist/components/tis-editor/editor/CycleConfigSection.js +1 -0
  37. package/dist/components/tis-editor/editor/FieldArrayEditor.d.ts.map +1 -1
  38. package/dist/components/tis-editor/editor/FieldArrayEditor.js +1 -1
  39. package/dist/components/tis-editor/editor/MethodFormEditor.d.ts.map +1 -1
  40. package/dist/components/tis-editor/editor/MethodFormEditor.js +1 -1
  41. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts +3 -0
  42. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts.map +1 -1
  43. package/dist/components/tis-editor/editor/TestFieldDialog.js +1 -1
  44. package/dist/components/tis-editor/editor/ViewsEditor.d.ts.map +1 -1
  45. package/dist/components/tis-editor/editor/ViewsEditor.js +1 -1
  46. package/dist/components/tis-editor/types.d.ts +28 -1
  47. package/dist/components/tis-editor/types.d.ts.map +1 -1
  48. package/dist/components/tis-editor/validation.d.ts.map +1 -1
  49. package/dist/components/tis-editor/validation.js +1 -1
  50. package/package.json +110 -110
  51. package/src/components/index.ts +14 -0
  52. package/src/components/tis/CycleConfigDialog.tsx +173 -0
  53. package/src/components/tis/CycleConfigForm.tsx +192 -0
  54. package/src/components/tis/CycleStatusStrip.tsx +84 -0
  55. package/src/components/tis/ResultHistoryTable.tsx +141 -1
  56. package/src/components/tis/ScienceTable.tsx +71 -12
  57. package/src/components/tis/TestDataView.tsx +82 -7
  58. package/src/components/tis/TestFieldRow.tsx +297 -0
  59. package/src/components/tis/TestSetupForm.tsx +52 -212
  60. package/src/components/tis/TestSetupView.tsx +60 -3
  61. package/src/components/tis/TisProvider.tsx +191 -0
  62. package/src/components/tis-editor/TisConfigEditor.tsx +1 -0
  63. package/src/components/tis-editor/editor/CycleConfigSection.tsx +93 -0
  64. package/src/components/tis-editor/editor/FieldArrayEditor.tsx +10 -0
  65. package/src/components/tis-editor/editor/MethodFormEditor.tsx +10 -4
  66. package/src/components/tis-editor/editor/TestFieldDialog.tsx +22 -2
  67. package/src/components/tis-editor/editor/ViewsEditor.tsx +3 -0
  68. package/src/components/tis-editor/types.ts +34 -1
  69. package/src/components/tis-editor/validation.ts +20 -2
@@ -72,6 +72,12 @@ export interface TestFieldDef {
72
72
  * (label === stored value) or an explicit { label, value } pair.
73
73
  * Mirrors the Rust TestField::options (untagged FieldOption). */
74
74
  options?: Array<string | number | boolean | { label: string; value: any }>;
75
+ /** Operator-editable after recording — annotation columns only (a specimen
76
+ * or lot id noted against a captured row). Meaningful on `cycle_fields`;
77
+ * the server refuses `tis.update_cycle` for any field without it, so
78
+ * measured columns can never be typed over. Mirrors Rust
79
+ * `TestField::editable`. */
80
+ editable?: boolean;
75
81
  }
76
82
 
77
83
  export interface ChartAxis { field?: string; column?: string; label?: string; }
@@ -155,10 +161,20 @@ export interface TestConfiguration {
155
161
  defaults?: { [field: string]: any };
156
162
  }
157
163
  export interface TestMethod {
158
- project_fields: TestFieldDef[];
159
- config_fields: TestFieldDef[];
160
- cycle_fields: TestFieldDef[];
161
- results_fields: TestFieldDef[];
164
+ /* All four field lists are OPTIONAL by design. Server-side each is
165
+ * `#[serde(default)]`, so a method that declares only `config_fields` is
166
+ * perfectly valid and the other keys simply aren't in the JSON. Typing
167
+ * them as required let `schema.cycle_fields.map(...)` compile and then
168
+ * blank the operator's page at runtime (gen4_ac, 2026-08-11). Keep them
169
+ * optional so the compiler demands a `?? []` at every use. */
170
+ project_fields?: TestFieldDef[];
171
+ config_fields?: TestFieldDef[];
172
+ /** Operator-entered per-row annotation. Recorded onto each cycle row
173
+ * by the server and rendered as the first Cycle Data columns after
174
+ * the row index, in declared order. */
175
+ cycle_config_fields?: TestFieldDef[];
176
+ cycle_fields?: TestFieldDef[];
177
+ results_fields?: TestFieldDef[];
162
178
  raw_data?: RawDataShape | null;
163
179
  views?: { [name: string]: ChartView };
164
180
  /** Optional pretty label for the Test Method picker. */
@@ -208,6 +224,46 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
208
224
  const [rawOpen, setRawOpen] = useState(false);
209
225
  const [configOpen, setConfigOpen] = useState(false);
210
226
 
227
+ /**
228
+ * Commit an operator edit to an annotation cell (`tis.update_cycle`).
229
+ *
230
+ * The server is the gate: it accepts a field only if the method declares it
231
+ * `editable: true`, so a measured column is refused even if a caller asks.
232
+ * We update local state only after the round trip succeeds — showing an edit
233
+ * that didn't persist is worse than showing the old value.
234
+ */
235
+ const saveCycleField = useCallback(
236
+ async (row: any, field: string, value: any) => {
237
+ const cycleIndex = row?.cycle_index;
238
+ if (!projectId || !methodId || !runId || !cycleIndex) return;
239
+ try {
240
+ const resp: any = await invoke(
241
+ 'tis.update_cycle' as any,
242
+ MessageType.Request,
243
+ {
244
+ project_id: projectId,
245
+ method_id: methodId,
246
+ run_id: runId,
247
+ cycle_index: cycleIndex,
248
+ fields: { [field]: value },
249
+ } as any,
250
+ );
251
+ if (resp?.success === false) {
252
+ console.error('tis.update_cycle rejected:', resp?.error_message);
253
+ return;
254
+ }
255
+ setCycles((prev) =>
256
+ prev.map((c) =>
257
+ c?.cycle_index === cycleIndex ? { ...c, [field]: value } : c,
258
+ ),
259
+ );
260
+ } catch (e) {
261
+ console.error('tis.update_cycle failed:', e);
262
+ }
263
+ },
264
+ [invoke, projectId, methodId, runId],
265
+ );
266
+
211
267
  // Direct handle on the chart.js instance so the toolbar's reset-
212
268
  // zoom button can call chart.resetZoom() — the zoom plugin's only
213
269
  // imperative API. Customers like the wheel/pinch/drag zoom but
@@ -742,12 +798,30 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
742
798
  doesn't render 1000 row elements at once. */}
743
799
  {(() => {
744
800
  const useVirtual = cycles.length > CYCLE_VIRTUAL_THRESHOLD;
745
- const cycleColumns: ScienceColumn[] = schema.cycle_fields.map(f => ({
801
+ // `?? []` is load-bearing: cycle_fields is `#[serde(default)]`
802
+ // server-side, so a method that declares only config_fields is
803
+ // valid and arrives with the key absent. Without the guard the
804
+ // `.map` throws inside render and React unmounts the whole
805
+ // app — the operator sees a blank page, not a missing table.
806
+ // Operator-entered annotation leads, right after the row
807
+ // index, then the measured columns — the same order the
808
+ // server writes into every CSV export. Annotation columns
809
+ // are always editable: they're the operator's own note, so
810
+ // correcting a mistyped one is the intended workflow (the
811
+ // server accepts cycle_config_fields on tis.update_cycle
812
+ // without an `editable` flag), while measured columns still
813
+ // have to opt in.
814
+ const toColumn = (f: TestFieldDef, editable: boolean): ScienceColumn => ({
746
815
  field: f.name,
747
816
  label: f.name,
748
817
  units: f.units,
818
+ editable,
749
819
  body: (row) => formatCell(row[f.name], f.type, f.scale),
750
- }));
820
+ });
821
+ const cycleColumns: ScienceColumn[] = [
822
+ ...(schema.cycle_config_fields ?? []).map(f => toColumn(f, true)),
823
+ ...(schema.cycle_fields ?? []).map(f => toColumn(f, !!f.editable)),
824
+ ];
751
825
  return (
752
826
  <ScienceTable
753
827
  title={`Cycle Data (${cycles.length})`}
@@ -756,6 +830,7 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
756
830
  scrollable={useVirtual}
757
831
  scrollHeight={useVirtual ? cycleTableHeight : undefined}
758
832
  emptyMessage="No cycles yet."
833
+ onCellEdit={saveCycleField}
759
834
  />
760
835
  );
761
836
  })()}
@@ -767,7 +842,7 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
767
842
  {(() => {
768
843
  const values = { ...results, ...sampleSummary };
769
844
  const hasValues = Object.keys(values).length > 0;
770
- const resultColumns: ScienceColumn[] = schema.results_fields.map(f => ({
845
+ const resultColumns: ScienceColumn[] = (schema.results_fields ?? []).map(f => ({
771
846
  field: f.name,
772
847
  label: f.name,
773
848
  units: f.units,
@@ -0,0 +1,297 @@
1
+ /*
2
+ * Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
3
+ *
4
+ * The one place a TIS `TestField` becomes an input.
5
+ *
6
+ * Test Configuration (<TestSetupForm>, per-test) and Cycle Configuration
7
+ * (<CycleConfigForm>, per-row) render the same declarative field schema
8
+ * with the same display/raw unit convention, the same emptiness and range
9
+ * rules, and the same 4-column `ac-form-grid` row shape. They differ only
10
+ * in where the value is stored and when it is committed, so the rendering
11
+ * and the validation rules live here and both forms call in.
12
+ *
13
+ * Storage is always RAW; `scale` converts only at the boundary:
14
+ * display = raw * scale raw = display / scale
15
+ */
16
+
17
+ import React from 'react';
18
+ import { Button } from 'primereact/button';
19
+ import { Dropdown } from 'primereact/dropdown';
20
+ import { ValueInput } from '../ValueInput';
21
+ import { TextInput } from '../TextInput';
22
+
23
+ /**
24
+ * One field in a test method's schema. Mirrors the Rust `TestField`
25
+ * (autocore-ams `config.rs`) — the same shape backs project_fields,
26
+ * config_fields, cycle_config_fields, cycle_fields and results_fields.
27
+ */
28
+ export interface TestFieldDef {
29
+ /** Canonical key — wire format, generated code, on-disk JSON. */
30
+ name: string;
31
+ type: string;
32
+ units?: string;
33
+ required?: boolean;
34
+ source?: string;
35
+ /** Pretty label rendered by the form. Falls back to `name`. Units
36
+ * are appended automatically; don't pre-format `[mm]` into label. */
37
+ label?: string;
38
+ /** Long-form guidance surfaced as a hover tooltip on an info icon. */
39
+ description?: string;
40
+ /** Seed value applied when the operator selects this test method.
41
+ * For source-bound fields the default is written to the GM tag so
42
+ * the control program sees it; for non-source fields it's stashed
43
+ * straight into stagedConfig. Operator edits override per-stage,
44
+ * but the schema default never mutates — re-selecting the method
45
+ * re-applies the default. **Authored in display units** when
46
+ * `scale` is set — the form divides by `scale` before writing to
47
+ * GM / stagedConfig so the underlying storage stays raw. */
48
+ default?: any;
49
+ /** Optional unit-conversion multiplier. Mirrors AutoCoreTagContext:
50
+ * ```
51
+ * display = raw * scale
52
+ * raw = display / scale
53
+ * ```
54
+ * Example: backend stores degrees, operator enters revolutions →
55
+ * `scale: 0.00277778`. None / 1.0 = no conversion.
56
+ *
57
+ * Storage stays raw — the form scales only on input and display.
58
+ * Cycle and results values are scaled by the corresponding paths
59
+ * in TestDataView; the server scales CSV exports too. */
60
+ scale?: number;
61
+ /** Optional inclusive bounds (display units, same convention as `default`
62
+ * and `scale`) for the operator's numeric entry. The numeric input
63
+ * rejects values outside `[min, max]`; non-numeric fields ignore them. */
64
+ min?: number;
65
+ max?: number;
66
+ /** Optional fixed set of choices. When present, the field renders
67
+ * as a dropdown and the operator must pick one of the declared
68
+ * values rather than typing freely. Each entry is either a bare
69
+ * scalar (label === value) or an explicit `{ label, value }` pair
70
+ * when the displayed text should differ from the stored value.
71
+ * Works with any `type`; like `default`, values are authored in
72
+ * display units when `scale` is set. */
73
+ options?: Array<
74
+ | string
75
+ | number
76
+ | boolean
77
+ | { label?: string; value: string | number | boolean }
78
+ >;
79
+ /** Operator-editable after recording. Meaningful on `cycle_fields`;
80
+ * `cycle_config_fields` are always editable (they're annotation, not
81
+ * measurement) and don't need the flag. */
82
+ editable?: boolean;
83
+ /** `cycle_config_fields` only: clear this entry once a row has recorded
84
+ * it, so the next row can't silently inherit a stale annotation. Fires
85
+ * at the method's `cycle_config_scope` boundary. */
86
+ autoclear?: boolean;
87
+ }
88
+
89
+ /**
90
+ * Which boundary an operator annotates `cycle_config_fields` at. Mirrors
91
+ * the Rust `CycleConfigScope`.
92
+ *
93
+ * `'run'` one latch per Start — the same values stamp every cycle of
94
+ * the run and clear when it closes. Default, and the only
95
+ * correct choice for a machine that records many cycles from
96
+ * one Start.
97
+ * `'cycle'` one latch per cycle — values clear after each recorded row
98
+ * and the gate re-arms, so the operator annotates every one.
99
+ */
100
+ export type CycleConfigScope = 'run' | 'cycle';
101
+
102
+ // -------------------------------------------------------------------------
103
+ // Display ↔ raw boundary
104
+ // -------------------------------------------------------------------------
105
+
106
+ /**
107
+ * Convention: `display = raw * scale`, `raw = display / scale`. Matches
108
+ * AutoCoreTagContext. Default scale = 1.0 (no conversion). Non-numeric
109
+ * values (string, null, undefined, NaN) pass through unchanged — the
110
+ * forms share one change handler across fields of every type.
111
+ *
112
+ * Storage (config map, GM, on-disk JSON) always holds the raw value. Only
113
+ * the inputs the operator looks at and the cells in TestDataView use the
114
+ * display value, so scales can change in project.json without
115
+ * invalidating historical records.
116
+ */
117
+ export const rawToDisplay = (raw: any, scale: number | undefined): any => {
118
+ if (!scale || scale === 1) return raw;
119
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) return raw;
120
+ return raw * scale;
121
+ };
122
+
123
+ export const displayToRaw = (display: any, scale: number | undefined): any => {
124
+ if (!scale || scale === 1) return display;
125
+ if (typeof display !== 'number' || !Number.isFinite(display)) return display;
126
+ return display / scale;
127
+ };
128
+
129
+ /** Field label with `[units]` appended when declared. */
130
+ export const labelOf = (f: TestFieldDef): string => {
131
+ const base = f.label && f.label.length > 0 ? f.label : f.name;
132
+ return f.units ? `${base} [${f.units}]` : base;
133
+ };
134
+
135
+ export const hasDescription = (f: TestFieldDef): boolean =>
136
+ typeof f.description === 'string' && f.description.length > 0;
137
+
138
+ /**
139
+ * "Not entered." Mirrors the server's `cycle_config_value_is_empty`
140
+ * (autocore-tis `config.rs`) so the HMI gate and the GM gate can never
141
+ * disagree about whether a field is filled in: absent, null, or an
142
+ * empty/whitespace string. Numeric zero and `false` are entries.
143
+ */
144
+ export const fieldValueIsEmpty = (v: any): boolean =>
145
+ v === undefined || v === null || (typeof v === 'string' && v.trim() === '');
146
+
147
+ /**
148
+ * Range check for a numeric field's stored RAW value against its declared
149
+ * `min`/`max` (authored in DISPLAY units). Returns a short human-readable
150
+ * reason ("must be ≥ 5") when out of range, or null when in range / not
151
+ * applicable (empty value, no bounds, or non-numeric).
152
+ */
153
+ export const rangeIssue = (f: TestFieldDef, raw: any): string | null => {
154
+ if (fieldValueIsEmpty(raw) || raw === '') return null;
155
+ if (f.min == null && f.max == null) return null;
156
+ const disp = Number(rawToDisplay(Number(raw), f.scale));
157
+ if (!Number.isFinite(disp)) return null;
158
+ // Bounds form an unordered interval — an author may write the range in
159
+ // magnitude order (min:-50, max:-1000), which is numerically inverted.
160
+ const lo = f.min != null && f.max != null ? Math.min(f.min, f.max) : f.min;
161
+ const hi = f.min != null && f.max != null ? Math.max(f.min, f.max) : f.max;
162
+ if (lo != null && disp < lo) return `must be ≥ ${lo}`;
163
+ if (hi != null && disp > hi) return `must be ≤ ${hi}`;
164
+ return null;
165
+ };
166
+
167
+ /**
168
+ * The single validity rule for an operator-entered field: required-and-empty,
169
+ * or out of range. Returns a human-readable reason, or null when the value
170
+ * is acceptable. Both forms build their issue lists and their per-field red
171
+ * markers from this, so the summary dialog and the field icons always agree.
172
+ */
173
+ export const fieldIssue = (f: TestFieldDef, raw: any): string | null => {
174
+ if (f.required && fieldValueIsEmpty(raw)) return 'is required';
175
+ return rangeIssue(f, raw);
176
+ };
177
+
178
+ export const fieldIsValid = (f: TestFieldDef, raw: any): boolean =>
179
+ fieldIssue(f, raw) === null;
180
+
181
+ /**
182
+ * Normalise a field's `options` (bare scalars and/or `{label, value}`
183
+ * pairs) into the `{ label, value }[]` shape PrimeReact's Dropdown wants.
184
+ * Bare scalars use their stringified form as the label.
185
+ */
186
+ export const normalizeOptions = (
187
+ opts: TestFieldDef['options'],
188
+ ): { label: string; value: string | number | boolean }[] =>
189
+ (opts ?? []).map((o) =>
190
+ o !== null && typeof o === 'object'
191
+ ? { label: String(o.label ?? o.value), value: o.value }
192
+ : { label: String(o), value: o },
193
+ );
194
+
195
+ // -------------------------------------------------------------------------
196
+ // Rendering
197
+ // -------------------------------------------------------------------------
198
+
199
+ export interface TestFieldRowProps {
200
+ field: TestFieldDef;
201
+ /** Stored RAW value. */
202
+ value: any;
203
+ /** Receives the RAW value (the row converts from display for you). */
204
+ onChange: (raw: any) => void;
205
+ /** Marks the control invalid. Defaults to `fieldIsValid(field, value)`. */
206
+ valid?: boolean;
207
+ /** Read-only rendering — used while a cycle is in flight and its
208
+ * annotation is already latched. */
209
+ disabled?: boolean;
210
+ /**
211
+ * Replaces the schema-derived input for this field. Used by
212
+ * <TestSetupForm> for config fields an AMS `asset_ref` claims, where
213
+ * the picker is sourced from the asset registry rather than the field
214
+ * declaration.
215
+ */
216
+ control?: React.ReactNode;
217
+ /** Opens the host form's shared info dialog. When omitted, fields with
218
+ * a `description` render no info affordance. */
219
+ onInfo?: (field: TestFieldDef) => void;
220
+ }
221
+
222
+ /**
223
+ * One row of a 4-column `ac-form-grid`: label, control, info button,
224
+ * validity icon. Returns a Fragment, so the caller owns the grid.
225
+ */
226
+ export const TestFieldRow: React.FC<TestFieldRowProps> = ({
227
+ field, value, onChange, valid, disabled, control, onInfo,
228
+ }) => {
229
+ const ok = valid ?? fieldIsValid(field, value);
230
+ const dropdownOptions = !control && Array.isArray(field.options) && field.options.length > 0
231
+ ? normalizeOptions(field.options)
232
+ : null;
233
+ const isNum = !control && !dropdownOptions
234
+ && field.type !== 'string' && field.type !== 'bool';
235
+
236
+ const input = control ?? (dropdownOptions ? (
237
+ <Dropdown
238
+ value={value ?? null}
239
+ options={dropdownOptions}
240
+ onChange={(e) => onChange(e.value)}
241
+ placeholder={`Select ${field.label ?? field.name}…`}
242
+ className={`ac-dropdown-clearable${!ok ? ' p-invalid' : ''}`}
243
+ showClear={!field.required}
244
+ disabled={disabled}
245
+ />
246
+ ) : isNum ? (
247
+ <ValueInput
248
+ label={undefined}
249
+ // Storage is RAW; render the DISPLAY value so the operator sees
250
+ // the units they configured. The change handler runs the inverse
251
+ // before committing back to storage.
252
+ value={value != null ? Number(rawToDisplay(Number(value), field.scale)) : null}
253
+ // min/max are authored in display units, matching the value
254
+ // rendered above — ValueInput rejects out-of-range entries.
255
+ min={field.min}
256
+ max={field.max}
257
+ onValueChanged={(val) => onChange(displayToRaw(val, field.scale))}
258
+ className={!ok ? 'p-invalid' : ''}
259
+ disabled={disabled}
260
+ />
261
+ ) : (
262
+ <TextInput
263
+ label={undefined}
264
+ value={value != null ? String(value) : ''}
265
+ onValueChanged={(val) => onChange(val)}
266
+ className={!ok ? 'p-invalid' : ''}
267
+ disabled={disabled}
268
+ />
269
+ ));
270
+
271
+ return (
272
+ <React.Fragment>
273
+ <span className="ac-form-label">{labelOf(field)}</span>
274
+ {input}
275
+ {onInfo && hasDescription(field) ? (
276
+ <Button
277
+ icon="pi pi-info-circle"
278
+ text
279
+ rounded
280
+ aria-label={`About ${labelOf(field)}`}
281
+ // Replaces a hover Tooltip — touch-friendly. The
282
+ // description is shown in the host form's info dialog.
283
+ onClick={() => onInfo(field)}
284
+ />
285
+ ) : (
286
+ <span aria-hidden="true" />
287
+ )}
288
+ <span style={{
289
+ color: ok ? 'var(--green-500)' : 'var(--red-500)',
290
+ display: 'flex',
291
+ alignItems: 'center',
292
+ }}>
293
+ <i className={ok ? 'pi pi-check' : 'pi pi-times'} />
294
+ </span>
295
+ </React.Fragment>
296
+ );
297
+ };