@isi-ui7/bos7-shared 0.2.4 → 0.2.7

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.
@@ -12,6 +12,8 @@ import {
12
12
  Toggle,
13
13
  } from "@carbon/react";
14
14
  import { LookupInput } from "@isi-ui7/lookup-input";
15
+ import type { T_LookupTblStruct } from "@isi-ui7/lookup-input";
16
+ import { EditableTable } from "@isi-ui7/editable-table";
15
17
  import { useI18n } from "@isi-ui7/i18n";
16
18
  import type { Ui7Locale } from "@isi-ui7/i18n";
17
19
  import {
@@ -27,7 +29,7 @@ import {
27
29
  UI7_FORM_VISUAL_CLASSNAMES,
28
30
  UI7_FORM_VISUAL_TOKENS,
29
31
  } from "./style-contract";
30
- import type { Ui7FormDensity } from "./style-contract";
32
+ import type { Ui7FormDensity, Ui7FormWidth } from "./style-contract";
31
33
  import { useBosSharedI18n } from "./i18n";
32
34
  import type { FormField, FormMode, FormSection } from "./form-types";
33
35
 
@@ -44,6 +46,8 @@ export type SchemaFormRendererProps<TData extends Record<string, unknown>> = {
44
46
  scopeClassName?: string;
45
47
  /** Form layout density. Defaults to "compact". */
46
48
  density?: Ui7FormDensity;
49
+ /** Desktop form-panel width. Defaults to "two-thirds". */
50
+ width?: Ui7FormWidth;
47
51
  };
48
52
 
49
53
  function clampSpan(span: number | undefined, defaultSpan: number): number {
@@ -80,7 +84,60 @@ function makeDisplayRenderer(
80
84
  ): ReactNode {
81
85
  if (field.format) return field.format(value as never, data as never);
82
86
  if (field.type === "checkbox") return value ? labels.boolYes : labels.boolNo;
83
- if (field.type === "toggle") return value ? labels.toggleOn : labels.toggleOff;
87
+ if (field.type === "toggle") {
88
+ // valueOn / valueOff override the default boolean truthiness check
89
+ // for non-boolean stored values (e.g., "Y"/"N").
90
+ if (field.toggle && field.toggle.valueOn !== undefined) {
91
+ return value === field.toggle.valueOn ? labels.toggleOn : labels.toggleOff;
92
+ }
93
+ return value ? labels.toggleOn : labels.toggleOff;
94
+ }
95
+ if (field.type === "select") {
96
+ // Resolve the option label so read-only mode shows "Active" rather than
97
+ // the stored code "active". Falls back to the raw value when no option
98
+ // matches (e.g. a legacy value no longer in the options list).
99
+ const match = (field.options ?? []).find((o) => String(o.value) === asString(value));
100
+ if (match) return match.label;
101
+ return asString(value) || labels.emptyValue;
102
+ }
103
+ if (field.type === "date") {
104
+ const raw = asString(value);
105
+ if (!raw) return labels.emptyValue;
106
+ const d = new Date(raw);
107
+ if (Number.isNaN(d.getTime())) return raw;
108
+ const nloc = locale === "en" ? "en-GB" : "id-ID";
109
+ return new Intl.DateTimeFormat(nloc, { day: "2-digit", month: "short", year: "numeric" }).format(d);
110
+ }
111
+ if (field.type === "datetime") {
112
+ // ISO timestamp → locale-aware "18 Jun 2026, 10:23" (24h). Echoes the
113
+ // raw value if unparseable. Edit mode falls through to a text input.
114
+ const raw = asString(value);
115
+ if (!raw) return labels.emptyValue;
116
+ const d = new Date(raw);
117
+ if (Number.isNaN(d.getTime())) return raw;
118
+ const nloc = locale === "en" ? "en-GB" : "id-ID";
119
+ return new Intl.DateTimeFormat(nloc, {
120
+ day: "2-digit",
121
+ month: "short",
122
+ year: "numeric",
123
+ hour: "2-digit",
124
+ minute: "2-digit",
125
+ hour12: false,
126
+ }).format(d);
127
+ }
128
+ if (field.type === "lookup") {
129
+ // View/read-only mode mirrors the LookupInput initialDisplay logic:
130
+ // when initialDisplayFields is set and all referenced FormData
131
+ // values are non-empty, render the compound "kode - nama". Falls
132
+ // back to the raw primary key when companion fields are missing.
133
+ const lookup = field.lookup;
134
+ if (lookup?.initialDisplayFields?.length) {
135
+ const sep = lookup.initialDisplaySeparator ?? " ";
136
+ const parts = lookup.initialDisplayFields.map((k) => asString(data[String(k)]));
137
+ if (parts.every((p) => p !== "")) return parts.join(sep);
138
+ }
139
+ return asString(value) || labels.emptyValue;
140
+ }
84
141
  if (field.type === "number" && typeof value === "number") {
85
142
  const nloc = locale === "en" ? "en-US" : "id-ID";
86
143
  const numeric = field.numeric;
@@ -158,6 +215,7 @@ function NumericField({
158
215
  value,
159
216
  numericKind,
160
217
  prec,
218
+ currencyCode,
161
219
  locale,
162
220
  size,
163
221
  disabled,
@@ -172,6 +230,13 @@ function NumericField({
172
230
  value: number;
173
231
  numericKind: "currency" | "percent" | "integer" | "decimal";
174
232
  prec?: number;
233
+ /**
234
+ * Currency ISO code (e.g. "IDR", "USD") — only used when numericKind ===
235
+ * "currency". When set, the code is shown as a subdued suffix next to the
236
+ * field label so the user sees which currency they're entering, mirroring
237
+ * the view-mode renderer which shows `Rp 1.000.000` / `IDR 1.000.000`.
238
+ */
239
+ currencyCode?: string;
175
240
  locale: Ui7Locale;
176
241
  size?: "sm" | "md" | "lg";
177
242
  disabled?: boolean;
@@ -209,19 +274,94 @@ function NumericField({
209
274
  return parsed.ok ? parsed.value : 0;
210
275
  };
211
276
 
277
+ /**
278
+ * Input-time sanitizer — drops any character the user types that isn't part
279
+ * of a valid number in the active locale. Without this, typing "abc" would
280
+ * stick in the textbox (because `raw` stores the user's verbatim keystrokes)
281
+ * while the parsed numeric value silently becomes 0. Filtering at input gives
282
+ * immediate visual feedback: rejected chars never appear at all.
283
+ *
284
+ * Rules:
285
+ * - Integer: 0–9 plus a single leading minus.
286
+ * - Decimal / currency / percent: 0–9, thousand separator, a single decimal
287
+ * separator, leading minus. Decimal separator is rejected entirely when
288
+ * `prec === 0` (no fractional part allowed), and excess fraction digits
289
+ * beyond `prec` are clipped on the fly so users see the limit enforced.
290
+ */
291
+ const sanitizeInput = (input: string): string => {
292
+ let out = "";
293
+ let seenMinus = false;
294
+ if (numericKind === "integer") {
295
+ for (let i = 0; i < input.length; i++) {
296
+ const c = input[i];
297
+ if (c >= "0" && c <= "9") out += c;
298
+ else if (c === "-" && out === "" && !seenMinus) {
299
+ out += c;
300
+ seenMinus = true;
301
+ }
302
+ }
303
+ return out;
304
+ }
305
+ let seenDecimal = false;
306
+ const allowDecimal = prec === undefined || prec > 0;
307
+ for (let i = 0; i < input.length; i++) {
308
+ const c = input[i];
309
+ if (c >= "0" && c <= "9") out += c;
310
+ else if (c === decimalSep && !seenDecimal && allowDecimal) {
311
+ out += c;
312
+ seenDecimal = true;
313
+ } else if (c === thousandSep) out += c;
314
+ else if (c === "-" && out === "" && !seenMinus) {
315
+ out += c;
316
+ seenMinus = true;
317
+ }
318
+ }
319
+ // Clip excess fraction digits to declared precision.
320
+ if (typeof prec === "number" && prec > 0 && seenDecimal) {
321
+ const decIdx = out.indexOf(decimalSep);
322
+ const intPart = out.slice(0, decIdx);
323
+ const fracPart = out.slice(decIdx + 1);
324
+ if (fracPart.length > prec) {
325
+ out = intPart + decimalSep + fracPart.slice(0, prec);
326
+ }
327
+ }
328
+ return out;
329
+ };
330
+
212
331
  const displayValue = raw !== null ? raw : formatDisplay(value);
213
332
 
214
- return (
333
+ // Auto helper-text for currency fields with non-zero precision — surfaces
334
+ // the decimal limit to the user so the silent truncation in parseRaw
335
+ // doesn't surprise them. Skipped when prec === 0 (the display format
336
+ // already shows no decimal separator, no extra explanation needed) or
337
+ // when caller already supplied a helperText.
338
+ const autoCurrencyHelper = (() => {
339
+ if (helperText !== undefined) return undefined;
340
+ if (numericKind !== "currency" || !currencyCode) return undefined;
341
+ const p = prec ?? 0;
342
+ if (p <= 0) return undefined;
343
+ if (locale === "en") {
344
+ return `Up to ${p} decimal place${p > 1 ? "s" : ""}`;
345
+ }
346
+ return `Maksimal ${p} angka desimal`;
347
+ })();
348
+ const effectiveHelperText = helperText ?? autoCurrencyHelper;
349
+
350
+ const input = (
215
351
  <TextInput
216
352
  id={id}
217
353
  labelText={labelText}
218
- helperText={helperText}
354
+ helperText={effectiveHelperText}
219
355
  value={displayValue}
220
356
  onFocus={() => {
221
357
  setRaw(value === 0 ? "" : String(value).replace(".", decimalSep));
222
358
  }}
223
359
  onChange={(e) => {
224
- const next = e.target.value;
360
+ // Filter non-numeric input at the source so the textbox can never
361
+ // visibly display characters that aren't part of a valid number.
362
+ // Caret behaviour stays natural because we only ever DROP chars —
363
+ // never reorder.
364
+ const next = sanitizeInput(e.target.value);
225
365
  setRaw(next);
226
366
  onChange(parseRaw(next));
227
367
  }}
@@ -232,12 +372,84 @@ function NumericField({
232
372
  size={size}
233
373
  disabled={disabled}
234
374
  inputMode={numericKind === "integer" ? "numeric" : "decimal"}
235
- light
236
375
  invalid={invalid}
237
376
  invalidText={invalidText}
238
377
  placeholder={placeholder}
239
378
  />
240
379
  );
380
+
381
+ // Currency chip — rendered to the right of the TextInput, height + Y position
382
+ // matched to the actual input field. We anchor via `align-items: flex-end` so
383
+ // the chip bottom sits flush with the input bottom (which is just above the
384
+ // helper-text / invalid-text row), then offset the chip up past those rows
385
+ // so its bottom lands on the input bottom edge regardless of whether the
386
+ // label wraps to two lines. `box-sizing: border-box` keeps height parity
387
+ // with the Carbon input (which also includes its 1px border in 2rem/2.5rem).
388
+ if (numericKind === "currency" && currencyCode) {
389
+ // Resolve locale-aware currency display so the chip matches what view-mode
390
+ // shows — `Intl.NumberFormat(... style:'currency')` renders IDR as "Rp" in
391
+ // id-ID and as "IDR" in en-US.
392
+ const chipText = (() => {
393
+ try {
394
+ const parts = new Intl.NumberFormat(nloc, {
395
+ style: "currency",
396
+ currency: currencyCode,
397
+ minimumFractionDigits: 0,
398
+ maximumFractionDigits: 0,
399
+ }).formatToParts(0);
400
+ const sym = parts.find((p) => p.type === "currency")?.value;
401
+ return sym && sym.trim() ? sym.trim() : currencyCode;
402
+ } catch {
403
+ return currencyCode;
404
+ }
405
+ })();
406
+
407
+ // Chip height + horizontal min-width follow the same CSS variable that the
408
+ // form contract uses to size .cds--text-input (1.75rem compact / 2.5rem
409
+ // comfortable). Fallback covers the rare host that mounts NumericField
410
+ // outside the .ui7-form-contract scope; map by Carbon `size` so the chip
411
+ // still matches Carbon's native input height there.
412
+ const chipHeightFallback = size === "lg" ? "3rem" : size === "md" ? "2.5rem" : "1.75rem";
413
+ const chipHeight = `var(--ui7-form-control-height, ${chipHeightFallback})`;
414
+ // `.cds--form-requirement` (invalid) ~1rem + 0.25rem margin; same for
415
+ // `.cds--form__helper-text`. Offset the chip up by that combined ~1.25rem
416
+ // so its bottom sits flush with the input bottom regardless of subtext.
417
+ const subtextRowOffset =
418
+ invalid && invalidText ? "1.25rem" : effectiveHelperText ? "1.25rem" : "0";
419
+
420
+ return (
421
+ <div style={{ display: "flex", alignItems: "flex-end", gap: "0.5rem" }}>
422
+ <div style={{ flex: "1 1 auto", minWidth: 0 }}>{input}</div>
423
+ <span
424
+ aria-label={`Mata uang: ${chipText}`}
425
+ title={currencyCode}
426
+ style={{
427
+ flex: "0 0 auto",
428
+ boxSizing: "border-box",
429
+ marginBlockEnd: subtextRowOffset,
430
+ height: chipHeight,
431
+ minWidth: chipHeight,
432
+ paddingInline: "0.625rem",
433
+ display: "inline-flex",
434
+ alignItems: "center",
435
+ justifyContent: "center",
436
+ fontSize: "0.75rem",
437
+ fontWeight: 500,
438
+ letterSpacing: "0.02em",
439
+ color: "var(--cds-text-primary, #161616)",
440
+ background: "var(--cds-layer-accent-01, #e8e8e8)",
441
+ border: "1px solid var(--cds-border-subtle, #e0e0e0)",
442
+ whiteSpace: "nowrap",
443
+ userSelect: "none",
444
+ }}
445
+ >
446
+ {chipText}
447
+ </span>
448
+ </div>
449
+ );
450
+ }
451
+
452
+ return input;
241
453
  }
242
454
 
243
455
  export function SchemaFormRenderer<TData extends Record<string, unknown>>({
@@ -251,12 +463,13 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
251
463
  showSectionHeader = true,
252
464
  scopeClassName,
253
465
  density = "compact",
466
+ width,
254
467
  }: SchemaFormRendererProps<TData>) {
255
468
  const isView = mode === "view";
256
469
  const dt = UI7_FORM_DENSITY_TOKENS[density];
257
470
  const carbonSize = density === "compact" ? "sm" : "md";
258
471
  const rootClassName =
259
- scopeClassName ?? getUi7FormContractClassName({ density, readonly: isView });
472
+ scopeClassName ?? getUi7FormContractClassName({ density, width, readonly: isView });
260
473
  const labels = useBosSharedI18n();
261
474
  const { locale } = useI18n();
262
475
  const renderDisplayValue = makeDisplayRenderer(labels, locale);
@@ -281,7 +494,10 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
281
494
  style={{
282
495
  padding: "1rem 1.125rem",
283
496
  border: "1px solid var(--cds-border-subtle, #e0e0e0)",
284
- background: "var(--cds-layer-01, #ffffff)",
497
+ // Section sits flat on the page background (white) and is set
498
+ // apart by its border line only — fields carry the contrasting
499
+ // fill instead (see read-only box + non-light Carbon inputs).
500
+ background: "var(--cds-background, #ffffff)",
285
501
  }}
286
502
  >
287
503
  {showSectionHeader && (section.title || section.description) ? (
@@ -338,11 +554,98 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
338
554
  Boolean(field.validation?.required) && !effectiveReadonly;
339
555
  const invalidText = errors?.[field.key];
340
556
  const span = clampSpan(field.span, defaultSpan);
557
+ // `breakBefore` forces a new row by pinning to column 1.
558
+ // Without it, CSS grid auto-flow may fit the field on the
559
+ // current row if remaining columns are enough for `span`.
341
560
  const wrapperStyle = {
342
- gridColumn: `span ${span} / span ${span}`,
561
+ gridColumn: field.breakBefore
562
+ ? `1 / span ${span}`
563
+ : `span ${span} / span ${span}`,
343
564
  minWidth: 0,
344
565
  } as const;
345
566
 
567
+ // ── Detail-rows (master-detail editor) ──────────────────────
568
+ // Rendered BEFORE the readonly branch so view / edit / create
569
+ // share the same `<EditableTable>` instance — the table
570
+ // renders rows even when `readOnly={true}` and just hides
571
+ // the add/delete affordances. Default span = 12 (full width)
572
+ // because tables rarely look right in a half-column.
573
+ if (field.type === "detail-rows") {
574
+ const detail = field.detailRows;
575
+ if (!detail) return [];
576
+ const rows = Array.isArray(rawValue)
577
+ ? (rawValue as Record<string, unknown>[])
578
+ : [];
579
+ const detailSpan = clampSpan(field.span, 12);
580
+ const detailWrapperStyle = {
581
+ gridColumn: field.breakBefore
582
+ ? `1 / span ${detailSpan}`
583
+ : `span ${detailSpan} / span ${detailSpan}`,
584
+ minWidth: 0,
585
+ } as const;
586
+ return [
587
+ <div
588
+ key={key}
589
+ className={UI7_FORM_VISUAL_CLASSNAMES.column}
590
+ style={detailWrapperStyle}
591
+ >
592
+ <FieldShell
593
+ label={field.label}
594
+ required={effectiveRequired}
595
+ helperText={field.helperText}
596
+ >
597
+ <EditableTable
598
+ columns={detail.columns}
599
+ value={rows}
600
+ onChange={(newRows) =>
601
+ setValue(
602
+ field.key,
603
+ (newRows as unknown) as TData[keyof TData],
604
+ )
605
+ }
606
+ newRowFactory={detail.newRowFactory}
607
+ maxRows={detail.maxRows}
608
+ readOnly={effectiveReadonly}
609
+ lockExistingRows={detail.lockExistingRows}
610
+ validateRow={detail.validateRow}
611
+ lookupCellRenderer={(args) => {
612
+ const ct = args.column.columnType;
613
+ if (ct.type !== "lookup") return null;
614
+ let initialDisplay: string | undefined;
615
+ if (ct.initialDisplayFields?.length) {
616
+ const sep = ct.initialDisplaySeparator ?? " ";
617
+ const parts = ct.initialDisplayFields.map((k) => asString(args.row[k]));
618
+ if (parts.every((p) => p !== "")) initialDisplay = parts.join(sep);
619
+ }
620
+ return (
621
+ <LookupInput
622
+ id={`et-${String(field.key)}-${args.rowIndex}-${args.column.field}`}
623
+ labelText=""
624
+ value={args.value}
625
+ initialDisplay={initialDisplay}
626
+ onChange={() => {}}
627
+ onDataSelected={(picked: Record<string, unknown>) => {
628
+ const patch = ct.patch
629
+ ? ct.patch(picked)
630
+ : { [args.column.field]: asString(picked[args.column.field]) };
631
+ args.onPatch(patch);
632
+ }}
633
+ dataSource="api"
634
+ lookupAPI={ct.lookupAPI}
635
+ lookupDataStructure={ct.lookupDataStructure as Record<string, T_LookupTblStruct>}
636
+ lookupFieldTitles={ct.lookupFieldTitles ?? {}}
637
+ displayFieldNames={ct.displayFieldNames ?? []}
638
+ popupWidthPx={ct.popupWidthPx}
639
+ readOnly={args.readOnly}
640
+ />
641
+ );
642
+ }}
643
+ />
644
+ </FieldShell>
645
+ </div>,
646
+ ];
647
+ }
648
+
346
649
  // ── View / readonly ─────────────────────────────────────────
347
650
  if (effectiveReadonly) {
348
651
  return [
@@ -351,12 +654,18 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
351
654
  <div
352
655
  className={`${UI7_FORM_VISUAL_CLASSNAMES.control} ${UI7_FORM_VISUAL_CLASSNAMES.controlReadonly}`}
353
656
  style={{
354
- minHeight: UI7_FORM_VISUAL_TOKENS.controlHeight,
657
+ // Match the editable control height for the active
658
+ // density (compact = 2rem) so read-only fields line
659
+ // up with create/edit inputs instead of being 40px.
660
+ minHeight: dt.controlHeight,
355
661
  display: "flex",
356
662
  alignItems: "center",
357
663
  fontSize: UI7_FORM_VISUAL_TOKENS.inputFontSize,
358
664
  lineHeight: UI7_FORM_VISUAL_TOKENS.inputLineHeight,
359
665
  background: "var(--ui7-form-readonly-background, #f4f4f4)",
666
+ // No bottom rule for non-editable fields — the gray
667
+ // fill alone sets them apart from the white section;
668
+ // an input-style underline would imply editability.
360
669
  cursor: "var(--ui7-form-readonly-cursor, default)",
361
670
  width: "100%",
362
671
  padding: "0 0.75rem",
@@ -376,6 +685,18 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
376
685
  // ── Lookup ──────────────────────────────────────────────────
377
686
  if (field.type === "lookup") {
378
687
  const lookup = field.lookup;
688
+ // Compose initialDisplay from sibling form fields, e.g.
689
+ // ["kode_cabang","branch_name"] → "001 KANTOR CABANG …".
690
+ // Falls back to undefined if any referenced field is
691
+ // empty so LookupInput uses its default (the key value).
692
+ let initialDisplay: string | undefined;
693
+ if (lookup?.initialDisplayFields?.length) {
694
+ const sep = lookup.initialDisplaySeparator ?? " ";
695
+ const parts = lookup.initialDisplayFields.map((k) => asString(value[k]));
696
+ if (parts.every((p) => p !== "")) {
697
+ initialDisplay = parts.join(sep);
698
+ }
699
+ }
379
700
  return [
380
701
  <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
381
702
  <FieldShell label={field.label} required={effectiveRequired} helperText={field.helperText} htmlFor={key}>
@@ -383,6 +704,7 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
383
704
  id={key}
384
705
  labelText=""
385
706
  value={asString(rawValue)}
707
+ initialDisplay={initialDisplay}
386
708
  onChange={(next: string) => setValue(field.key, next as TData[keyof TData])}
387
709
  onDataSelected={(row: Record<string, unknown>) => {
388
710
  if (lookup?.onDataPatch) {
@@ -448,7 +770,6 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
448
770
  value={asString(rawValue)}
449
771
  onChange={(e) => setValue(field.key, e.target.value as TData[keyof TData])}
450
772
  disabled={disabled}
451
- light
452
773
  invalid={Boolean(invalidText)}
453
774
  invalidText={invalidText}
454
775
  >
@@ -465,10 +786,12 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
465
786
  const numeric = field.numeric;
466
787
  let numericKind: "currency" | "percent" | "integer" | "decimal" = "decimal";
467
788
  let prec: number | undefined;
789
+ let currencyCode: string | undefined;
468
790
 
469
791
  if (numeric?.kind === "currency") {
470
792
  numericKind = "currency";
471
793
  const code = String(value[numeric.currencyField] ?? "");
794
+ currencyCode = code || undefined;
472
795
  prec = getCurrencyPrecision(code, {
473
796
  precisionByCurrency: numeric.precisionByCurrency,
474
797
  defaultPrecision: numeric.defaultPrecision,
@@ -480,20 +803,46 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
480
803
  numericKind = "integer";
481
804
  }
482
805
 
806
+ // Live min/max validation — surfaces invalidText immediately
807
+ // as the user types instead of waiting for Save. Falls back
808
+ // to the submit-time error from the form host's errors map
809
+ // when the value is within bounds (or no bounds declared).
810
+ const numericValue = typeof rawValue === "number" ? rawValue : 0;
811
+ let liveInvalidText: string | undefined;
812
+ const v = field.validation;
813
+ if (v?.min !== undefined) {
814
+ const minV = Array.isArray(v.min) ? v.min[0] : v.min;
815
+ if (numericValue < minV) {
816
+ liveInvalidText = Array.isArray(v.min)
817
+ ? v.min[1]
818
+ : labels.validMin(minV);
819
+ }
820
+ }
821
+ if (!liveInvalidText && v?.max !== undefined) {
822
+ const maxV = Array.isArray(v.max) ? v.max[0] : v.max;
823
+ if (numericValue > maxV) {
824
+ liveInvalidText = Array.isArray(v.max)
825
+ ? v.max[1]
826
+ : labels.validMax(maxV);
827
+ }
828
+ }
829
+ const effectiveInvalidText = invalidText ?? liveInvalidText;
830
+
483
831
  return [
484
832
  <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
485
833
  <NumericField
486
834
  id={key}
487
835
  labelText={mkLabel(field.label, effectiveRequired)}
488
836
  helperText={field.helperText}
489
- value={typeof rawValue === "number" ? rawValue : 0}
837
+ value={numericValue}
490
838
  numericKind={numericKind}
491
839
  prec={prec}
840
+ currencyCode={currencyCode}
492
841
  locale={locale}
493
842
  size={carbonSize}
494
843
  disabled={disabled}
495
- invalid={Boolean(invalidText)}
496
- invalidText={invalidText}
844
+ invalid={Boolean(effectiveInvalidText)}
845
+ invalidText={effectiveInvalidText}
497
846
  placeholder={field.placeholder}
498
847
  onChange={(v) => setValue(field.key, v as TData[keyof TData])}
499
848
  />
@@ -520,6 +869,19 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
520
869
 
521
870
  // ── Toggle ──────────────────────────────────────────────────
522
871
  if (field.type === "toggle") {
872
+ // Optional valueOn/valueOff for non-boolean stored state
873
+ // (e.g., "Y"/"N"). When unset, falls back to plain Boolean.
874
+ const hasCustomMapping =
875
+ field.toggle && field.toggle.valueOn !== undefined;
876
+ const toggled = hasCustomMapping
877
+ ? rawValue === field.toggle!.valueOn
878
+ : Boolean(rawValue);
879
+ const onValue: unknown = hasCustomMapping
880
+ ? field.toggle!.valueOn
881
+ : true;
882
+ const offValue: unknown = hasCustomMapping
883
+ ? field.toggle!.valueOff
884
+ : false;
523
885
  return [
524
886
  <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
525
887
  <FieldShell label={field.label} required={effectiveRequired} helperText={field.helperText}>
@@ -529,9 +891,12 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
529
891
  labelText=""
530
892
  labelA={labels.toggleOff}
531
893
  labelB={labels.toggleOn}
532
- toggled={Boolean(rawValue)}
894
+ toggled={toggled}
533
895
  onToggle={(checked) =>
534
- setValue(field.key, Boolean(checked) as TData[keyof TData])
896
+ setValue(
897
+ field.key,
898
+ (checked ? onValue : offValue) as TData[keyof TData]
899
+ )
535
900
  }
536
901
  disabled={disabled}
537
902
  />
@@ -582,7 +947,6 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
582
947
  disabled={disabled}
583
948
  maxLength={field.maxLength}
584
949
  inputMode="tel"
585
- light
586
950
  invalid={Boolean(invalidText)}
587
951
  invalidText={invalidText}
588
952
  placeholder={field.placeholder}
@@ -606,7 +970,6 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
606
970
  disabled={disabled}
607
971
  maxLength={field.maxLength}
608
972
  inputMode={field.inputMode}
609
- light
610
973
  invalid={Boolean(invalidText)}
611
974
  invalidText={invalidText}
612
975
  placeholder={field.placeholder}