@isi-ui7/bos7-shared 0.2.4 → 0.2.6

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,7 @@ import {
12
12
  Toggle,
13
13
  } from "@carbon/react";
14
14
  import { LookupInput } from "@isi-ui7/lookup-input";
15
+ import { EditableTable } from "@isi-ui7/editable-table";
15
16
  import { useI18n } from "@isi-ui7/i18n";
16
17
  import type { Ui7Locale } from "@isi-ui7/i18n";
17
18
  import {
@@ -27,7 +28,7 @@ import {
27
28
  UI7_FORM_VISUAL_CLASSNAMES,
28
29
  UI7_FORM_VISUAL_TOKENS,
29
30
  } from "./style-contract";
30
- import type { Ui7FormDensity } from "./style-contract";
31
+ import type { Ui7FormDensity, Ui7FormWidth } from "./style-contract";
31
32
  import { useBosSharedI18n } from "./i18n";
32
33
  import type { FormField, FormMode, FormSection } from "./form-types";
33
34
 
@@ -44,6 +45,8 @@ export type SchemaFormRendererProps<TData extends Record<string, unknown>> = {
44
45
  scopeClassName?: string;
45
46
  /** Form layout density. Defaults to "compact". */
46
47
  density?: Ui7FormDensity;
48
+ /** Desktop form-panel width. Defaults to "two-thirds". */
49
+ width?: Ui7FormWidth;
47
50
  };
48
51
 
49
52
  function clampSpan(span: number | undefined, defaultSpan: number): number {
@@ -80,7 +83,27 @@ function makeDisplayRenderer(
80
83
  ): ReactNode {
81
84
  if (field.format) return field.format(value as never, data as never);
82
85
  if (field.type === "checkbox") return value ? labels.boolYes : labels.boolNo;
83
- if (field.type === "toggle") return value ? labels.toggleOn : labels.toggleOff;
86
+ if (field.type === "toggle") {
87
+ // valueOn / valueOff override the default boolean truthiness check
88
+ // for non-boolean stored values (e.g., "Y"/"N").
89
+ if (field.toggle && field.toggle.valueOn !== undefined) {
90
+ return value === field.toggle.valueOn ? labels.toggleOn : labels.toggleOff;
91
+ }
92
+ return value ? labels.toggleOn : labels.toggleOff;
93
+ }
94
+ if (field.type === "lookup") {
95
+ // View/read-only mode mirrors the LookupInput initialDisplay logic:
96
+ // when initialDisplayFields is set and all referenced FormData
97
+ // values are non-empty, render the compound "kode - nama". Falls
98
+ // back to the raw primary key when companion fields are missing.
99
+ const lookup = field.lookup;
100
+ if (lookup?.initialDisplayFields?.length) {
101
+ const sep = lookup.initialDisplaySeparator ?? " ";
102
+ const parts = lookup.initialDisplayFields.map((k) => asString(data[String(k)]));
103
+ if (parts.every((p) => p !== "")) return parts.join(sep);
104
+ }
105
+ return asString(value) || labels.emptyValue;
106
+ }
84
107
  if (field.type === "number" && typeof value === "number") {
85
108
  const nloc = locale === "en" ? "en-US" : "id-ID";
86
109
  const numeric = field.numeric;
@@ -158,6 +181,7 @@ function NumericField({
158
181
  value,
159
182
  numericKind,
160
183
  prec,
184
+ currencyCode,
161
185
  locale,
162
186
  size,
163
187
  disabled,
@@ -172,6 +196,13 @@ function NumericField({
172
196
  value: number;
173
197
  numericKind: "currency" | "percent" | "integer" | "decimal";
174
198
  prec?: number;
199
+ /**
200
+ * Currency ISO code (e.g. "IDR", "USD") — only used when numericKind ===
201
+ * "currency". When set, the code is shown as a subdued suffix next to the
202
+ * field label so the user sees which currency they're entering, mirroring
203
+ * the view-mode renderer which shows `Rp 1.000.000` / `IDR 1.000.000`.
204
+ */
205
+ currencyCode?: string;
175
206
  locale: Ui7Locale;
176
207
  size?: "sm" | "md" | "lg";
177
208
  disabled?: boolean;
@@ -209,19 +240,94 @@ function NumericField({
209
240
  return parsed.ok ? parsed.value : 0;
210
241
  };
211
242
 
243
+ /**
244
+ * Input-time sanitizer — drops any character the user types that isn't part
245
+ * of a valid number in the active locale. Without this, typing "abc" would
246
+ * stick in the textbox (because `raw` stores the user's verbatim keystrokes)
247
+ * while the parsed numeric value silently becomes 0. Filtering at input gives
248
+ * immediate visual feedback: rejected chars never appear at all.
249
+ *
250
+ * Rules:
251
+ * - Integer: 0–9 plus a single leading minus.
252
+ * - Decimal / currency / percent: 0–9, thousand separator, a single decimal
253
+ * separator, leading minus. Decimal separator is rejected entirely when
254
+ * `prec === 0` (no fractional part allowed), and excess fraction digits
255
+ * beyond `prec` are clipped on the fly so users see the limit enforced.
256
+ */
257
+ const sanitizeInput = (input: string): string => {
258
+ let out = "";
259
+ let seenMinus = false;
260
+ if (numericKind === "integer") {
261
+ for (let i = 0; i < input.length; i++) {
262
+ const c = input[i];
263
+ if (c >= "0" && c <= "9") out += c;
264
+ else if (c === "-" && out === "" && !seenMinus) {
265
+ out += c;
266
+ seenMinus = true;
267
+ }
268
+ }
269
+ return out;
270
+ }
271
+ let seenDecimal = false;
272
+ const allowDecimal = prec === undefined || prec > 0;
273
+ for (let i = 0; i < input.length; i++) {
274
+ const c = input[i];
275
+ if (c >= "0" && c <= "9") out += c;
276
+ else if (c === decimalSep && !seenDecimal && allowDecimal) {
277
+ out += c;
278
+ seenDecimal = true;
279
+ } else if (c === thousandSep) out += c;
280
+ else if (c === "-" && out === "" && !seenMinus) {
281
+ out += c;
282
+ seenMinus = true;
283
+ }
284
+ }
285
+ // Clip excess fraction digits to declared precision.
286
+ if (typeof prec === "number" && prec > 0 && seenDecimal) {
287
+ const decIdx = out.indexOf(decimalSep);
288
+ const intPart = out.slice(0, decIdx);
289
+ const fracPart = out.slice(decIdx + 1);
290
+ if (fracPart.length > prec) {
291
+ out = intPart + decimalSep + fracPart.slice(0, prec);
292
+ }
293
+ }
294
+ return out;
295
+ };
296
+
212
297
  const displayValue = raw !== null ? raw : formatDisplay(value);
213
298
 
214
- return (
299
+ // Auto helper-text for currency fields with non-zero precision — surfaces
300
+ // the decimal limit to the user so the silent truncation in parseRaw
301
+ // doesn't surprise them. Skipped when prec === 0 (the display format
302
+ // already shows no decimal separator, no extra explanation needed) or
303
+ // when caller already supplied a helperText.
304
+ const autoCurrencyHelper = (() => {
305
+ if (helperText !== undefined) return undefined;
306
+ if (numericKind !== "currency" || !currencyCode) return undefined;
307
+ const p = prec ?? 0;
308
+ if (p <= 0) return undefined;
309
+ if (locale === "en") {
310
+ return `Up to ${p} decimal place${p > 1 ? "s" : ""}`;
311
+ }
312
+ return `Maksimal ${p} angka desimal`;
313
+ })();
314
+ const effectiveHelperText = helperText ?? autoCurrencyHelper;
315
+
316
+ const input = (
215
317
  <TextInput
216
318
  id={id}
217
319
  labelText={labelText}
218
- helperText={helperText}
320
+ helperText={effectiveHelperText}
219
321
  value={displayValue}
220
322
  onFocus={() => {
221
323
  setRaw(value === 0 ? "" : String(value).replace(".", decimalSep));
222
324
  }}
223
325
  onChange={(e) => {
224
- const next = e.target.value;
326
+ // Filter non-numeric input at the source so the textbox can never
327
+ // visibly display characters that aren't part of a valid number.
328
+ // Caret behaviour stays natural because we only ever DROP chars —
329
+ // never reorder.
330
+ const next = sanitizeInput(e.target.value);
225
331
  setRaw(next);
226
332
  onChange(parseRaw(next));
227
333
  }}
@@ -238,6 +344,79 @@ function NumericField({
238
344
  placeholder={placeholder}
239
345
  />
240
346
  );
347
+
348
+ // Currency chip — rendered to the right of the TextInput, height + Y position
349
+ // matched to the actual input field. We anchor via `align-items: flex-end` so
350
+ // the chip bottom sits flush with the input bottom (which is just above the
351
+ // helper-text / invalid-text row), then offset the chip up past those rows
352
+ // so its bottom lands on the input bottom edge regardless of whether the
353
+ // label wraps to two lines. `box-sizing: border-box` keeps height parity
354
+ // with the Carbon input (which also includes its 1px border in 2rem/2.5rem).
355
+ if (numericKind === "currency" && currencyCode) {
356
+ // Resolve locale-aware currency display so the chip matches what view-mode
357
+ // shows — `Intl.NumberFormat(... style:'currency')` renders IDR as "Rp" in
358
+ // id-ID and as "IDR" in en-US.
359
+ const chipText = (() => {
360
+ try {
361
+ const parts = new Intl.NumberFormat(nloc, {
362
+ style: "currency",
363
+ currency: currencyCode,
364
+ minimumFractionDigits: 0,
365
+ maximumFractionDigits: 0,
366
+ }).formatToParts(0);
367
+ const sym = parts.find((p) => p.type === "currency")?.value;
368
+ return sym && sym.trim() ? sym.trim() : currencyCode;
369
+ } catch {
370
+ return currencyCode;
371
+ }
372
+ })();
373
+
374
+ // Chip height + horizontal min-width follow the same CSS variable that the
375
+ // form contract uses to size .cds--text-input (1.75rem compact / 2.5rem
376
+ // comfortable). Fallback covers the rare host that mounts NumericField
377
+ // outside the .ui7-form-contract scope; map by Carbon `size` so the chip
378
+ // still matches Carbon's native input height there.
379
+ const chipHeightFallback = size === "lg" ? "3rem" : size === "md" ? "2.5rem" : "1.75rem";
380
+ const chipHeight = `var(--ui7-form-control-height, ${chipHeightFallback})`;
381
+ // `.cds--form-requirement` (invalid) ~1rem + 0.25rem margin; same for
382
+ // `.cds--form__helper-text`. Offset the chip up by that combined ~1.25rem
383
+ // so its bottom sits flush with the input bottom regardless of subtext.
384
+ const subtextRowOffset =
385
+ invalid && invalidText ? "1.25rem" : effectiveHelperText ? "1.25rem" : "0";
386
+
387
+ return (
388
+ <div style={{ display: "flex", alignItems: "flex-end", gap: "0.5rem" }}>
389
+ <div style={{ flex: "1 1 auto", minWidth: 0 }}>{input}</div>
390
+ <span
391
+ aria-label={`Mata uang: ${chipText}`}
392
+ title={currencyCode}
393
+ style={{
394
+ flex: "0 0 auto",
395
+ boxSizing: "border-box",
396
+ marginBlockEnd: subtextRowOffset,
397
+ height: chipHeight,
398
+ minWidth: chipHeight,
399
+ paddingInline: "0.625rem",
400
+ display: "inline-flex",
401
+ alignItems: "center",
402
+ justifyContent: "center",
403
+ fontSize: "0.75rem",
404
+ fontWeight: 500,
405
+ letterSpacing: "0.02em",
406
+ color: "var(--cds-text-primary, #161616)",
407
+ background: "var(--cds-layer-accent-01, #e8e8e8)",
408
+ border: "1px solid var(--cds-border-subtle, #e0e0e0)",
409
+ whiteSpace: "nowrap",
410
+ userSelect: "none",
411
+ }}
412
+ >
413
+ {chipText}
414
+ </span>
415
+ </div>
416
+ );
417
+ }
418
+
419
+ return input;
241
420
  }
242
421
 
243
422
  export function SchemaFormRenderer<TData extends Record<string, unknown>>({
@@ -251,12 +430,13 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
251
430
  showSectionHeader = true,
252
431
  scopeClassName,
253
432
  density = "compact",
433
+ width,
254
434
  }: SchemaFormRendererProps<TData>) {
255
435
  const isView = mode === "view";
256
436
  const dt = UI7_FORM_DENSITY_TOKENS[density];
257
437
  const carbonSize = density === "compact" ? "sm" : "md";
258
438
  const rootClassName =
259
- scopeClassName ?? getUi7FormContractClassName({ density, readonly: isView });
439
+ scopeClassName ?? getUi7FormContractClassName({ density, width, readonly: isView });
260
440
  const labels = useBosSharedI18n();
261
441
  const { locale } = useI18n();
262
442
  const renderDisplayValue = makeDisplayRenderer(labels, locale);
@@ -338,11 +518,65 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
338
518
  Boolean(field.validation?.required) && !effectiveReadonly;
339
519
  const invalidText = errors?.[field.key];
340
520
  const span = clampSpan(field.span, defaultSpan);
521
+ // `breakBefore` forces a new row by pinning to column 1.
522
+ // Without it, CSS grid auto-flow may fit the field on the
523
+ // current row if remaining columns are enough for `span`.
341
524
  const wrapperStyle = {
342
- gridColumn: `span ${span} / span ${span}`,
525
+ gridColumn: field.breakBefore
526
+ ? `1 / span ${span}`
527
+ : `span ${span} / span ${span}`,
343
528
  minWidth: 0,
344
529
  } as const;
345
530
 
531
+ // ── Detail-rows (master-detail editor) ──────────────────────
532
+ // Rendered BEFORE the readonly branch so view / edit / create
533
+ // share the same `<EditableTable>` instance — the table
534
+ // renders rows even when `readOnly={true}` and just hides
535
+ // the add/delete affordances. Default span = 12 (full width)
536
+ // because tables rarely look right in a half-column.
537
+ if (field.type === "detail-rows") {
538
+ const detail = field.detailRows;
539
+ if (!detail) return [];
540
+ const rows = Array.isArray(rawValue)
541
+ ? (rawValue as Record<string, unknown>[])
542
+ : [];
543
+ const detailSpan = clampSpan(field.span, 12);
544
+ const detailWrapperStyle = {
545
+ gridColumn: field.breakBefore
546
+ ? `1 / span ${detailSpan}`
547
+ : `span ${detailSpan} / span ${detailSpan}`,
548
+ minWidth: 0,
549
+ } as const;
550
+ return [
551
+ <div
552
+ key={key}
553
+ className={UI7_FORM_VISUAL_CLASSNAMES.column}
554
+ style={detailWrapperStyle}
555
+ >
556
+ <FieldShell
557
+ label={field.label}
558
+ required={effectiveRequired}
559
+ helperText={field.helperText}
560
+ >
561
+ <EditableTable
562
+ columns={detail.columns}
563
+ value={rows}
564
+ onChange={(newRows) =>
565
+ setValue(
566
+ field.key,
567
+ (newRows as unknown) as TData[keyof TData],
568
+ )
569
+ }
570
+ newRowFactory={detail.newRowFactory}
571
+ maxRows={detail.maxRows}
572
+ readOnly={effectiveReadonly}
573
+ validateRow={detail.validateRow}
574
+ />
575
+ </FieldShell>
576
+ </div>,
577
+ ];
578
+ }
579
+
346
580
  // ── View / readonly ─────────────────────────────────────────
347
581
  if (effectiveReadonly) {
348
582
  return [
@@ -376,6 +610,18 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
376
610
  // ── Lookup ──────────────────────────────────────────────────
377
611
  if (field.type === "lookup") {
378
612
  const lookup = field.lookup;
613
+ // Compose initialDisplay from sibling form fields, e.g.
614
+ // ["kode_cabang","branch_name"] → "001 KANTOR CABANG …".
615
+ // Falls back to undefined if any referenced field is
616
+ // empty so LookupInput uses its default (the key value).
617
+ let initialDisplay: string | undefined;
618
+ if (lookup?.initialDisplayFields?.length) {
619
+ const sep = lookup.initialDisplaySeparator ?? " ";
620
+ const parts = lookup.initialDisplayFields.map((k) => asString(value[k]));
621
+ if (parts.every((p) => p !== "")) {
622
+ initialDisplay = parts.join(sep);
623
+ }
624
+ }
379
625
  return [
380
626
  <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
381
627
  <FieldShell label={field.label} required={effectiveRequired} helperText={field.helperText} htmlFor={key}>
@@ -383,6 +629,7 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
383
629
  id={key}
384
630
  labelText=""
385
631
  value={asString(rawValue)}
632
+ initialDisplay={initialDisplay}
386
633
  onChange={(next: string) => setValue(field.key, next as TData[keyof TData])}
387
634
  onDataSelected={(row: Record<string, unknown>) => {
388
635
  if (lookup?.onDataPatch) {
@@ -465,10 +712,12 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
465
712
  const numeric = field.numeric;
466
713
  let numericKind: "currency" | "percent" | "integer" | "decimal" = "decimal";
467
714
  let prec: number | undefined;
715
+ let currencyCode: string | undefined;
468
716
 
469
717
  if (numeric?.kind === "currency") {
470
718
  numericKind = "currency";
471
719
  const code = String(value[numeric.currencyField] ?? "");
720
+ currencyCode = code || undefined;
472
721
  prec = getCurrencyPrecision(code, {
473
722
  precisionByCurrency: numeric.precisionByCurrency,
474
723
  defaultPrecision: numeric.defaultPrecision,
@@ -480,20 +729,46 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
480
729
  numericKind = "integer";
481
730
  }
482
731
 
732
+ // Live min/max validation — surfaces invalidText immediately
733
+ // as the user types instead of waiting for Save. Falls back
734
+ // to the submit-time error from the form host's errors map
735
+ // when the value is within bounds (or no bounds declared).
736
+ const numericValue = typeof rawValue === "number" ? rawValue : 0;
737
+ let liveInvalidText: string | undefined;
738
+ const v = field.validation;
739
+ if (v?.min !== undefined) {
740
+ const minV = Array.isArray(v.min) ? v.min[0] : v.min;
741
+ if (numericValue < minV) {
742
+ liveInvalidText = Array.isArray(v.min)
743
+ ? v.min[1]
744
+ : labels.validMin(minV);
745
+ }
746
+ }
747
+ if (!liveInvalidText && v?.max !== undefined) {
748
+ const maxV = Array.isArray(v.max) ? v.max[0] : v.max;
749
+ if (numericValue > maxV) {
750
+ liveInvalidText = Array.isArray(v.max)
751
+ ? v.max[1]
752
+ : labels.validMax(maxV);
753
+ }
754
+ }
755
+ const effectiveInvalidText = invalidText ?? liveInvalidText;
756
+
483
757
  return [
484
758
  <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
485
759
  <NumericField
486
760
  id={key}
487
761
  labelText={mkLabel(field.label, effectiveRequired)}
488
762
  helperText={field.helperText}
489
- value={typeof rawValue === "number" ? rawValue : 0}
763
+ value={numericValue}
490
764
  numericKind={numericKind}
491
765
  prec={prec}
766
+ currencyCode={currencyCode}
492
767
  locale={locale}
493
768
  size={carbonSize}
494
769
  disabled={disabled}
495
- invalid={Boolean(invalidText)}
496
- invalidText={invalidText}
770
+ invalid={Boolean(effectiveInvalidText)}
771
+ invalidText={effectiveInvalidText}
497
772
  placeholder={field.placeholder}
498
773
  onChange={(v) => setValue(field.key, v as TData[keyof TData])}
499
774
  />
@@ -520,6 +795,19 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
520
795
 
521
796
  // ── Toggle ──────────────────────────────────────────────────
522
797
  if (field.type === "toggle") {
798
+ // Optional valueOn/valueOff for non-boolean stored state
799
+ // (e.g., "Y"/"N"). When unset, falls back to plain Boolean.
800
+ const hasCustomMapping =
801
+ field.toggle && field.toggle.valueOn !== undefined;
802
+ const toggled = hasCustomMapping
803
+ ? rawValue === field.toggle!.valueOn
804
+ : Boolean(rawValue);
805
+ const onValue: unknown = hasCustomMapping
806
+ ? field.toggle!.valueOn
807
+ : true;
808
+ const offValue: unknown = hasCustomMapping
809
+ ? field.toggle!.valueOff
810
+ : false;
523
811
  return [
524
812
  <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
525
813
  <FieldShell label={field.label} required={effectiveRequired} helperText={field.helperText}>
@@ -529,9 +817,12 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
529
817
  labelText=""
530
818
  labelA={labels.toggleOff}
531
819
  labelB={labels.toggleOn}
532
- toggled={Boolean(rawValue)}
820
+ toggled={toggled}
533
821
  onToggle={(checked) =>
534
- setValue(field.key, Boolean(checked) as TData[keyof TData])
822
+ setValue(
823
+ field.key,
824
+ (checked ? onValue : offValue) as TData[keyof TData]
825
+ )
535
826
  }
536
827
  disabled={disabled}
537
828
  />
package/src/form-types.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
- import type { Ui7FormDensity } from "./style-contract";
2
+ import type { Ui7FormDensity, Ui7FormWidth } from "./style-contract";
3
3
  import type { T_LookupTblStruct } from "@isi-ui7/lookup-input";
4
+ import type { EditableColumnDef } from "@isi-ui7/editable-table";
4
5
 
5
6
  export type FormMode = "create" | "edit" | "view";
6
7
 
@@ -31,7 +32,8 @@ export type FieldValidation<TData extends Record<string, unknown>> = {
31
32
 
32
33
  export type FormFieldType =
33
34
  | "text" | "textarea" | "number" | "date"
34
- | "select" | "lookup" | "checkbox" | "toggle";
35
+ | "select" | "lookup" | "checkbox" | "toggle"
36
+ | "detail-rows";
35
37
 
36
38
  export type FormFieldOption = { value: string; label: string };
37
39
 
@@ -45,6 +47,17 @@ export type FormFieldLookupConfig<TData extends Record<string, unknown>> = {
45
47
  dataSource?: "api" | "direct";
46
48
  /** Return a patch to merge into the form when a lookup row is selected. */
47
49
  onDataPatch?: (row: Record<string, unknown>, data: TData) => Partial<TData>;
50
+ /**
51
+ * Form field keys whose values combine into the initial display text on
52
+ * Edit load. Example: `["kode_cabang","branch_name"]` renders
53
+ * "001 KANTOR CABANG BANDUNG" instead of just "001". Typical pairing
54
+ * with an onDataPatch that stashes the picked row's name into a
55
+ * companion field. If any referenced field is empty, the lookup falls
56
+ * back to the field's own primary-key value.
57
+ */
58
+ initialDisplayFields?: (keyof TData)[];
59
+ /** Separator joining initialDisplayFields. Default: " " */
60
+ initialDisplaySeparator?: string;
48
61
  };
49
62
 
50
63
  export type FormFieldNumericConfig<TData extends Record<string, unknown>> =
@@ -53,11 +66,56 @@ export type FormFieldNumericConfig<TData extends Record<string, unknown>> =
53
66
  | { kind: "integer" }
54
67
  | { kind: "phone"; minDigits: number; maxDigits: number; allowedPrefixes?: string[] };
55
68
 
69
+ /**
70
+ * Toggle field state mapping for non-boolean stored values.
71
+ *
72
+ * Example for a "Y" / "N" string column:
73
+ * { type: "toggle", toggle: { valueOn: "Y", valueOff: "N" } }
74
+ *
75
+ * Toggled = (current value === valueOn). Flipping the toggle writes
76
+ * either `valueOn` or `valueOff` into the form data. Defaults to
77
+ * boolean true / false when omitted.
78
+ */
79
+ export type FormFieldToggleConfig = {
80
+ valueOn?: unknown;
81
+ valueOff?: unknown;
82
+ };
83
+
84
+ /**
85
+ * Detail-rows config — embeds an inline `<EditableTable>` inside the form for
86
+ * master-detail entry. The field value at `key` must be an array of plain
87
+ * objects (e.g. `details: BucketRow[]`). The renderer reads / writes that
88
+ * array atomically via `onChange({ ...data, [key]: newRows })`.
89
+ *
90
+ * `columns`, `newRowFactory`, `validateRow`, `maxRows` mirror the props of
91
+ * `<EditableTable>` from `@isi-ui7/editable-table`.
92
+ */
93
+ export type DetailRowsConfig = {
94
+ columns: EditableColumnDef[];
95
+ newRowFactory: () => Record<string, unknown>;
96
+ validateRow?: (
97
+ row: Record<string, unknown>,
98
+ index: number,
99
+ ) => Record<string, string>;
100
+ maxRows?: number;
101
+ };
102
+
56
103
  export type FormField<TData extends Record<string, unknown>> = {
57
104
  key: keyof TData;
58
105
  label: ReactNode;
59
106
  type: FormFieldType;
60
107
  span?: number;
108
+ /**
109
+ * Force this field to start a new row even when the current row still
110
+ * has space for its `span`. Useful to break a logical group onto its
111
+ * own line (e.g. a wide picker that should sit alone, or a sub-group
112
+ * header pattern within one section).
113
+ *
114
+ * Implemented via `gridColumnStart: 1` — the CSS grid skips the
115
+ * remaining columns of the previous row and places this field at the
116
+ * left edge of a new row.
117
+ */
118
+ breakBefore?: boolean;
61
119
  /** Static or dynamic readonly. Function receives current mode + form data. */
62
120
  readonly?: boolean | ((mode: FormMode, data: TData) => boolean);
63
121
  /** Static or dynamic visibility. Hidden fields are skipped in validation. */
@@ -72,6 +130,8 @@ export type FormField<TData extends Record<string, unknown>> = {
72
130
  options?: FormFieldOption[];
73
131
  lookup?: FormFieldLookupConfig<TData>;
74
132
  numeric?: FormFieldNumericConfig<TData>;
133
+ toggle?: FormFieldToggleConfig;
134
+ detailRows?: DetailRowsConfig;
75
135
  format?: (value: TData[keyof TData], data: TData) => ReactNode;
76
136
  validation?: FieldValidation<TData>;
77
137
  };
@@ -111,6 +171,11 @@ export type CrudForm<TData extends Record<string, unknown>> = {
111
171
  backLabel?: { view?: string; others?: string};
112
172
  /** Form layout density. Defaults to "compact". */
113
173
  density?: Ui7FormDensity;
174
+ /**
175
+ * Desktop form-panel width — "half" | "two-thirds" | "full".
176
+ * Default "two-thirds". Tablet + mobile (<1056px) always stretch full.
177
+ */
178
+ width?: Ui7FormWidth;
114
179
 
115
180
  emptyData: TData;
116
181