@qoretechnologies/reqraft 0.10.65 → 0.10.66-pr.145.g124fc92

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 (161) hide show
  1. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  2. package/dist/components/form/engine/CompactRow.js +130 -25
  3. package/dist/components/form/engine/CompactRow.js.map +1 -1
  4. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  5. package/dist/components/form/engine/CompactToolbar.js +78 -15
  6. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  7. package/dist/components/form/engine/FormEngine.d.ts +48 -1
  8. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  9. package/dist/components/form/engine/FormEngine.js +260 -99
  10. package/dist/components/form/engine/FormEngine.js.map +1 -1
  11. package/dist/components/form/engine/OptionFieldMessages.d.ts +18 -2
  12. package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
  13. package/dist/components/form/engine/OptionFieldMessages.js +22 -3
  14. package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
  15. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -1
  16. package/dist/components/form/engine/_structuredData/SchemaDataView.js +27 -1
  17. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -1
  18. package/dist/components/form/engine/compactRowContext.d.ts +5 -0
  19. package/dist/components/form/engine/compactRowContext.d.ts.map +1 -1
  20. package/dist/components/form/engine/compactRowContext.js.map +1 -1
  21. package/dist/components/form/engine/compactRowStyles.d.ts +12 -0
  22. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  23. package/dist/components/form/engine/compactRowStyles.js +72 -39
  24. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  25. package/dist/components/form/engine/compactToolbarContext.d.ts +3 -0
  26. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  27. package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
  28. package/dist/components/form/engine/formAppearance.d.ts +48 -0
  29. package/dist/components/form/engine/formAppearance.d.ts.map +1 -0
  30. package/dist/components/form/engine/formAppearance.js +50 -0
  31. package/dist/components/form/engine/formAppearance.js.map +1 -0
  32. package/dist/components/form/engine/readFirst.d.ts +14 -2
  33. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  34. package/dist/components/form/engine/readFirst.js +6 -2
  35. package/dist/components/form/engine/readFirst.js.map +1 -1
  36. package/dist/components/form/engine/rowFocus.d.ts +9 -1
  37. package/dist/components/form/engine/rowFocus.d.ts.map +1 -1
  38. package/dist/components/form/engine/rowFocus.js +17 -5
  39. package/dist/components/form/engine/rowFocus.js.map +1 -1
  40. package/dist/components/form/expressions/renderExpressionToText.d.ts.map +1 -1
  41. package/dist/components/form/expressions/renderExpressionToText.js +21 -0
  42. package/dist/components/form/expressions/renderExpressionToText.js.map +1 -1
  43. package/dist/components/form/fields/ChoiceGroupHeading.d.ts +21 -0
  44. package/dist/components/form/fields/ChoiceGroupHeading.d.ts.map +1 -0
  45. package/dist/components/form/fields/ChoiceGroupHeading.js +33 -0
  46. package/dist/components/form/fields/ChoiceGroupHeading.js.map +1 -0
  47. package/dist/components/form/fields/Field.d.ts.map +1 -1
  48. package/dist/components/form/fields/Field.js +13 -11
  49. package/dist/components/form/fields/Field.js.map +1 -1
  50. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  51. package/dist/components/form/fields/allowed-values/AllowedValues.js +20 -7
  52. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  53. package/dist/components/form/fields/allowed-values/savedValues.d.ts +30 -0
  54. package/dist/components/form/fields/allowed-values/savedValues.d.ts.map +1 -0
  55. package/dist/components/form/fields/allowed-values/savedValues.js +18 -0
  56. package/dist/components/form/fields/allowed-values/savedValues.js.map +1 -0
  57. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  58. package/dist/components/form/fields/auto/AutoFormField.js +18 -8
  59. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  60. package/dist/components/form/fields/long-string/LongString.d.ts +7 -1
  61. package/dist/components/form/fields/long-string/LongString.d.ts.map +1 -1
  62. package/dist/components/form/fields/long-string/LongString.js +9 -5
  63. package/dist/components/form/fields/long-string/LongString.js.map +1 -1
  64. package/dist/components/form/fields/multi-select/MultiSelectFormField.d.ts.map +1 -1
  65. package/dist/components/form/fields/multi-select/MultiSelectFormField.js +6 -5
  66. package/dist/components/form/fields/multi-select/MultiSelectFormField.js.map +1 -1
  67. package/dist/components/form/fields/number/Number.d.ts +4 -1
  68. package/dist/components/form/fields/number/Number.d.ts.map +1 -1
  69. package/dist/components/form/fields/number/Number.js +13 -5
  70. package/dist/components/form/fields/number/Number.js.map +1 -1
  71. package/dist/components/form/fields/radio-group/RadioGroup.d.ts.map +1 -1
  72. package/dist/components/form/fields/radio-group/RadioGroup.js +13 -7
  73. package/dist/components/form/fields/radio-group/RadioGroup.js.map +1 -1
  74. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  75. package/dist/components/form/fields/template/TemplateField.js +71 -42
  76. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  77. package/dist/components/form/index.d.ts +2 -0
  78. package/dist/components/form/index.d.ts.map +1 -1
  79. package/dist/components/form/index.js +2 -0
  80. package/dist/components/form/index.js.map +1 -1
  81. package/dist/components/imageLightbox/ImageLightbox.d.ts.map +1 -1
  82. package/dist/components/imageLightbox/ImageLightbox.js +2 -0
  83. package/dist/components/imageLightbox/ImageLightbox.js.map +1 -1
  84. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +3 -2
  85. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -1
  86. package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -1
  87. package/dist/components/supportTicket/ReferencePicker.d.ts +2 -1
  88. package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -1
  89. package/dist/components/supportTicket/ReferencePicker.js +19 -13
  90. package/dist/components/supportTicket/ReferencePicker.js.map +1 -1
  91. package/dist/components/supportTicket/TicketHeader.d.ts.map +1 -1
  92. package/dist/components/supportTicket/TicketHeader.js +4 -1
  93. package/dist/components/supportTicket/TicketHeader.js.map +1 -1
  94. package/dist/components/supportTicket/TicketReplyBox.d.ts.map +1 -1
  95. package/dist/components/supportTicket/TicketReplyBox.js +29 -10
  96. package/dist/components/supportTicket/TicketReplyBox.js.map +1 -1
  97. package/dist/components/supportTicket/useLoadedImages.d.ts +10 -0
  98. package/dist/components/supportTicket/useLoadedImages.d.ts.map +1 -1
  99. package/dist/components/supportTicket/useLoadedImages.js +63 -1
  100. package/dist/components/supportTicket/useLoadedImages.js.map +1 -1
  101. package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -1
  102. package/dist/components/ticketReferences/TicketReferences.js +6 -2
  103. package/dist/components/ticketReferences/TicketReferences.js.map +1 -1
  104. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -1
  105. package/dist/components/ticketThread/TicketThread.js +10 -16
  106. package/dist/components/ticketThread/TicketThread.js.map +1 -1
  107. package/dist/helpers/allowedValues.d.ts +12 -0
  108. package/dist/helpers/allowedValues.d.ts.map +1 -1
  109. package/dist/helpers/allowedValues.js +20 -1
  110. package/dist/helpers/allowedValues.js.map +1 -1
  111. package/dist/helpers/fieldDescription.d.ts +16 -0
  112. package/dist/helpers/fieldDescription.d.ts.map +1 -0
  113. package/dist/helpers/fieldDescription.js +40 -0
  114. package/dist/helpers/fieldDescription.js.map +1 -0
  115. package/dist/helpers/singleLineString.d.ts.map +1 -1
  116. package/dist/helpers/singleLineString.js +1 -0
  117. package/dist/helpers/singleLineString.js.map +1 -1
  118. package/package.json +1 -1
  119. package/src/components/form/engine/CompactRow.tsx +143 -23
  120. package/src/components/form/engine/CompactToolbar.tsx +85 -16
  121. package/src/components/form/engine/FormEngine.stories.tsx +25 -13
  122. package/src/components/form/engine/FormEngine.tsx +367 -81
  123. package/src/components/form/engine/FormEngineAppearance.stories.tsx +540 -0
  124. package/src/components/form/engine/FormEngineStickyHeaders.stories.tsx +5 -1
  125. package/src/components/form/engine/OptionFieldMessages.tsx +73 -2
  126. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +17 -4
  127. package/src/components/form/engine/compactRowContext.ts +5 -0
  128. package/src/components/form/engine/compactRowStyles.ts +218 -38
  129. package/src/components/form/engine/compactToolbarContext.ts +3 -0
  130. package/src/components/form/engine/formAppearance.ts +78 -0
  131. package/src/components/form/engine/readFirst.ts +16 -3
  132. package/src/components/form/engine/rowFocus.ts +17 -6
  133. package/src/components/form/expressions/renderExpressionToText.ts +15 -0
  134. package/src/components/form/fields/ChoiceGroupHeading.tsx +38 -0
  135. package/src/components/form/fields/Field.tsx +11 -9
  136. package/src/components/form/fields/allowed-values/AllowedValues.tsx +21 -9
  137. package/src/components/form/fields/allowed-values/savedValues.ts +41 -0
  138. package/src/components/form/fields/auto/AutoFormField.tsx +17 -4
  139. package/src/components/form/fields/long-string/LongString.stories.tsx +27 -0
  140. package/src/components/form/fields/long-string/LongString.tsx +14 -3
  141. package/src/components/form/fields/multi-select/MultiSelectFormField.tsx +5 -4
  142. package/src/components/form/fields/number/Number.tsx +19 -4
  143. package/src/components/form/fields/radio-group/RadioGroup.tsx +13 -7
  144. package/src/components/form/fields/template/TemplateField.tsx +32 -4
  145. package/src/components/form/index.tsx +2 -0
  146. package/src/components/imageLightbox/ImageLightbox.tsx +2 -0
  147. package/src/components/supportTicket/InterfaceReferenceTags.tsx +3 -2
  148. package/src/components/supportTicket/ReferencePicker.stories.tsx +0 -4
  149. package/src/components/supportTicket/ReferencePicker.tsx +22 -12
  150. package/src/components/supportTicket/TicketConversation.stories.tsx +0 -1
  151. package/src/components/supportTicket/TicketHeader.tsx +2 -1
  152. package/src/components/supportTicket/TicketReplyBox.stories.tsx +24 -21
  153. package/src/components/supportTicket/TicketReplyBox.tsx +27 -5
  154. package/src/components/supportTicket/useLoadedImages.ts +20 -0
  155. package/src/components/ticketReferences/TicketReferences.stories.tsx +26 -0
  156. package/src/components/ticketReferences/TicketReferences.tsx +12 -4
  157. package/src/components/ticketThread/TicketThread.stories.tsx +71 -2
  158. package/src/components/ticketThread/TicketThread.tsx +15 -28
  159. package/src/helpers/allowedValues.ts +19 -0
  160. package/src/helpers/fieldDescription.ts +39 -0
  161. package/src/helpers/singleLineString.ts +1 -0
@@ -6,6 +6,7 @@ import {
6
6
  ReqoreIcon,
7
7
  ReqoreMessage,
8
8
  ReqoreP,
9
+ ReqoreSpan,
9
10
  ReqoreTag,
10
11
  ReqoreTagGroup,
11
12
  ReqoreVerticalSpacer,
@@ -43,6 +44,7 @@ import { offersTypeChoices } from './typeChoices';
43
44
  import { optionRowActions, resolveOptionActions, TOptionActions } from './optionActions';
44
45
  import { createRendererOnlyUiTypeCheck, isRendererOnlyUiType } from './rendererTypes';
45
46
  import { revealRow } from './revealRow';
47
+ import { findRowFocusTarget } from './rowFocus';
46
48
  import { useEmittedValues } from '../fields/emittedValues';
47
49
  import { cloneDeep, findKey, flatten, forEach, isEqual, isPlainObject, last, uniq } from 'lodash';
48
50
  import map from 'lodash/map';
@@ -51,6 +53,8 @@ import size from 'lodash/size';
51
53
  import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
52
54
  import { useMeasure, useMount, useUpdateEffect } from 'react-use';
53
55
  import styled, { css } from 'styled-components';
56
+ import { allowedValueOf } from '../../../helpers/allowedValues';
57
+ import { fieldHasLongDescription } from '../../../helpers/fieldDescription';
54
58
  import {
55
59
  fixOperatorValue,
56
60
  getDefaultValue,
@@ -84,6 +88,7 @@ import { CompactRow } from './CompactRow';
84
88
  import { FormFieldsSkeleton } from './FormFieldsSkeleton';
85
89
  import { CompactRowContext, ICompactRowContext, TCodePreviewRenderer } from './compactRowContext';
86
90
  import {
91
+ CALM_SECTION_INSET,
87
92
  GROUP_INDENT,
88
93
  LABEL_AFFORDANCE_WIDTH,
89
94
  LABEL_COL_MAX,
@@ -96,6 +101,7 @@ import {
96
101
  StyledRequiredClusterHeader,
97
102
  StyledStatusBox,
98
103
  StyledStatusBoxGroupLabel,
104
+ StyledStatusDot,
99
105
  } from './compactRowStyles';
100
106
  import { CompactToolbar } from './CompactToolbar';
101
107
  import {
@@ -114,6 +120,14 @@ import {
114
120
  TMarkdownRenderer,
115
121
  } from '../../Description/markdownRendererContext';
116
122
  import { OptionsHelpDialog } from './OptionsHelpDialog';
123
+ import {
124
+ FormAppearanceProvider,
125
+ getCalmPanelHairline,
126
+ getCalmPanelSurface,
127
+ TFormEngineAppearance,
128
+ useFormAppearance,
129
+ useIsCalmForm,
130
+ } from './formAppearance';
117
131
  import { firstDeclaredType, isUntypedOptionType } from '../../../helpers/optionUiTypes';
118
132
  import {
119
133
  TReadFirstStatus,
@@ -197,6 +211,14 @@ export interface IFormValidityData {
197
211
  invalidFields: IFormFieldValidityData[];
198
212
  }
199
213
 
214
+ /** The two numbers the readiness line shows ("2 need attention · 1 to fill in"). */
215
+ export interface IFormAttention {
216
+ /** Fields in the first box: still to fill in, and faults. */
217
+ count: number;
218
+ /** How many of those are faults (amber) rather than only still to fill in. */
219
+ faults: number;
220
+ }
221
+
200
222
  /**
201
223
  * Resolve a field's `inherit_props` map against the current available
202
224
  * options (plus any values inherited from an outer FormEngine scope),
@@ -848,6 +870,21 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
848
870
  * value; clicking expands the real editor inline, Done collapses it.
849
871
  */
850
872
  compact?: boolean;
873
+ /**
874
+ * How the form LOOKS — never what it does. `'default'` keeps the established
875
+ * look; `'calm'` draws the status boxes as plain panels on the theme's panel
876
+ * surface with label/value rows split by hairlines, grey labels, an amber
877
+ * "Required · add a value" in place of red asterisks and messages, quiet
878
+ * counts, no dot on a healthy row, Yes/No as words, and a readiness meter
879
+ * whose done part is grey. See {@link TFormEngineAppearance}.
880
+ *
881
+ * Opt-in, so a consumer that does not pass it is unchanged. Inherited: a
882
+ * nested (`arg_schema`) sub-form and every field editor the form mounts
883
+ * follow the form's appearance, and a form with no `appearance` of its own
884
+ * follows a `FormAppearanceProvider` above it. Applies to compact mode; the
885
+ * classic collection layout only passes it on to its field editors.
886
+ */
887
+ appearance?: TFormEngineAppearance;
851
888
  /**
852
889
  * Compact mode only: drop the read-first wrap's horizontal gutter (the 12px
853
890
  * breathing room) so the form sits flush to its container's edges. For embeds
@@ -928,8 +965,32 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
928
965
  * `Optional` names a category rather than the things in it, and
929
966
  * `Optional settings` is what the reader is looking at. Only the labels
930
967
  * change; bucketing, icons and intents are untouched.
968
+ *
969
+ * `pending` names the first box while nothing in it is a fault yet — a new form's
970
+ * empty required fields, which are still to fill in (default `To fill in`).
971
+ */
972
+ compactBoxLabels?: Partial<Record<TFormEngineBoxKey | 'pending', string>>;
973
+ /**
974
+ * The host attempted a submit. Until then an empty required field the person has not
975
+ * been to yet is only "to fill in": a new form opens neutral rather than with every
976
+ * required field in amber, and a field turns amber once someone opens it and leaves
977
+ * it empty. Pass `true` after a failed submit to show every empty required field as
978
+ * needing attention. Invalid values are faults from the start either way.
979
+ */
980
+ revealRequired?: boolean;
981
+ /**
982
+ * Bump it to move the caret to the first field that needs attention: the answer to a
983
+ * submit that could not go is where to type. Each new value opens that field and puts
984
+ * the caret in it once, even though the host's submit button holds the caret (an open
985
+ * row otherwise never takes a caret held outside it). Pair it with `revealRequired`.
986
+ */
987
+ focusAttentionRequest?: number;
988
+ /**
989
+ * The readiness line's two numbers, reported whenever they change. A host that draws
990
+ * its own submit control draws it from these, so the control and the form can never
991
+ * disagree about what is left. `{ count: 0, faults: 0 }` is a form with nothing left.
931
992
  */
932
- compactBoxLabels?: Partial<Record<TFormEngineBoxKey, string>>;
993
+ onAttentionChange?: (attention: IFormAttention) => void;
933
994
  /**
934
995
  * Compact mode only: the three status-box headers stay pinned to the top of
935
996
  * whatever scrolls while their own box is in view. Default `true`.
@@ -1235,6 +1296,9 @@ const FormEngineImpl = ({
1235
1296
  forceDropdown,
1236
1297
  showTypeToggle = true,
1237
1298
  compact,
1299
+ // Read through the context `FormEngine` provides (resolved there against an
1300
+ // inherited appearance); destructured only to keep it out of `rest`.
1301
+ appearance: _appearance, // eslint-disable-line @typescript-eslint/no-unused-vars
1238
1302
  // Destructured only to keep it out of `rest` (and so off the DOM). The gutter
1239
1303
  // this prop documents was never implemented: it was passed to the wrap as
1240
1304
  // `$flush`, whose styled template had no interpolation slot, so it has always
@@ -1247,6 +1311,9 @@ const FormEngineImpl = ({
1247
1311
  compactPanelProps,
1248
1312
  compactCollapsedGroups: compactCollapsedGroupsProp,
1249
1313
  compactBoxLabels,
1314
+ revealRequired = false,
1315
+ focusAttentionRequest,
1316
+ onAttentionChange,
1250
1317
  compactStickyBoxHeaders = true,
1251
1318
  compactStickyOffset = 0,
1252
1319
  compactToolbar = true,
@@ -1318,6 +1385,7 @@ const FormEngineImpl = ({
1318
1385
  const [loading, setLoading] = useState<boolean>(!!(url || customUrl || operatorsUrl));
1319
1386
  const confirmAction = useReqoreProperty('confirmAction');
1320
1387
  const theme = useReqoreTheme();
1388
+ const calm = useIsCalmForm();
1321
1389
  const [focusedEditing, setFocusedEditing] = useState<string>();
1322
1390
  const [showFieldTypes, setShowFieldTypes] = useState<boolean>(false);
1323
1391
  // Global toggle (toolbar ⓘ): reveal the short-description info panel on every
@@ -1330,6 +1398,21 @@ const FormEngineImpl = ({
1330
1398
  const [showInvalidOptionsOnly, setShowInvalidOptionsOnly] = useState<boolean>(false);
1331
1399
  // Which options are expanded into their editor (several can be open at once).
1332
1400
  const [expandedOptions, setExpandedOptions] = useState<string[]>([]);
1401
+ // The fields the person has opened and left. An empty required field becomes a
1402
+ // fault only then (or after a submit attempt, `revealRequired`): a form nobody has
1403
+ // touched is not already wrong.
1404
+ const [visitedOptions, setVisitedOptions] = useState<string[]>([]);
1405
+ const lastExpandedOptions = useRef<string[]>([]);
1406
+ useEffect(() => {
1407
+ const left = lastExpandedOptions.current.filter((name) => !expandedOptions.includes(name));
1408
+ lastExpandedOptions.current = expandedOptions;
1409
+ if (left.length) {
1410
+ setVisitedOptions((prev) => {
1411
+ const added = left.filter((name) => !prev.includes(name));
1412
+ return added.length ? [...prev, ...added] : prev;
1413
+ });
1414
+ }
1415
+ }, [expandedOptions]);
1333
1416
  /** Status boxes whose "N more fields" control the reader has opened. Keyed by
1334
1417
  box so opening Optional does not also unfold Set. */
1335
1418
  const [expandedMoreBoxes, setExpandedMoreBoxes] = useState<TFormEngineBoxKey[]>([]);
@@ -2270,7 +2353,7 @@ const FormEngineImpl = ({
2270
2353
  forEach(options || {}, (optionSchema, name) => {
2271
2354
  forEach(optionSchema?.allowed_values || [], (allowedValue) => {
2272
2355
  if (!getAllowedValueAvailability(allowedValue, availableOptions, options || {}).available) {
2273
- locked.push(`${name}\u0000${JSON.stringify(allowedValue?.value?.value)}`);
2356
+ locked.push(`${name}\u0000${JSON.stringify(allowedValueOf(allowedValue))}`);
2274
2357
  }
2275
2358
  });
2276
2359
  });
@@ -2458,6 +2541,11 @@ const FormEngineImpl = ({
2458
2541
  (newValue, option, optionName) => {
2459
2542
  if (
2460
2543
  showInvalidOptionsOnly &&
2544
+ // An open row stays while the reader is in it, as its box does
2545
+ // (`stableBucketOf`): the first character typed into an empty required
2546
+ // field makes it valid, and hiding it then would pull the field out
2547
+ // from under the caret mid-word.
2548
+ !expandedOptions.includes(optionName) &&
2461
2549
  isOptionValid(
2462
2550
  optionName,
2463
2551
  getOptionFieldStorageType(
@@ -2480,6 +2568,7 @@ const FormEngineImpl = ({
2480
2568
  );
2481
2569
  }, [
2482
2570
  showInvalidOptionsOnly,
2571
+ expandedOptions,
2483
2572
  isRendererOnly,
2484
2573
  JSON.stringify(availableOptions),
2485
2574
  JSON.stringify(options),
@@ -2548,9 +2637,12 @@ const FormEngineImpl = ({
2548
2637
  covered,
2549
2638
  invalid,
2550
2639
  warned: msgIntent === 'warning',
2640
+ visited: revealRequired || visitedOptions.includes(name),
2551
2641
  });
2552
2642
  },
2553
2643
  [
2644
+ revealRequired,
2645
+ visitedOptions,
2554
2646
  isRendererOnly,
2555
2647
  JSON.stringify(options),
2556
2648
  JSON.stringify(availableOptions),
@@ -2583,6 +2675,24 @@ const FormEngineImpl = ({
2583
2675
  .length,
2584
2676
  [JSON.stringify(availableOptions), getOptionBucket]
2585
2677
  );
2678
+ // How many of those are faults rather than only still to fill in (`pending`). Zero
2679
+ // on a new form, so its first box reads "To fill in" without amber.
2680
+ const readFirstAttentionFaultCount = useMemo(
2681
+ () =>
2682
+ Object.keys(availableOptions || {}).filter(
2683
+ (name) => getOptionBucket(name) === 'attention' && getOptionStatus(name) !== 'pending'
2684
+ ).length,
2685
+ [JSON.stringify(availableOptions), getOptionBucket, getOptionStatus]
2686
+ );
2687
+ // The same two numbers, to a host that draws its own submit control from them.
2688
+ const onAttentionChangeRef = useRef(onAttentionChange);
2689
+ onAttentionChangeRef.current = onAttentionChange;
2690
+ useEffect(() => {
2691
+ onAttentionChangeRef.current?.({
2692
+ count: readFirstAttentionCount,
2693
+ faults: readFirstAttentionFaultCount,
2694
+ });
2695
+ }, [readFirstAttentionCount, readFirstAttentionFaultCount]);
2586
2696
 
2587
2697
  const getIntent = useCallback(
2588
2698
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -2708,6 +2818,39 @@ const FormEngineImpl = ({
2708
2818
  // field update or an in-place schema reload will NOT re-grab focus; only a
2709
2819
  // genuine remount (a fresh instance) auto-focuses again, which is the intended
2710
2820
  // on-mount behaviour.
2821
+ // What the "first field to fix" scans read: the opener on arrival below and the
2822
+ // caret move on a submit attempt (`focusAttentionRequest`) pick the same field.
2823
+ const getAttentionFieldMeta = useCallback(
2824
+ (fieldName: string) => {
2825
+ const schema = options?.[fieldName];
2826
+ if (!schema) {
2827
+ return undefined;
2828
+ }
2829
+ return {
2830
+ focusable:
2831
+ !readOnly &&
2832
+ !schema.disabled &&
2833
+ !(schema as { readonly?: boolean }).readonly &&
2834
+ !dependencyLockedNames.includes(fieldName),
2835
+ // Single source of truth: the same bucket the read-first status boxes
2836
+ // show, so a filled-but-invalid required row is a target too.
2837
+ needsAttention: getOptionBucket(fieldName) === 'attention',
2838
+ };
2839
+ },
2840
+ [options, readOnly, dependencyLockedNames, getOptionBucket]
2841
+ );
2842
+ // Opening a row by name: `expandMode: 'single'` replaces only what a click could
2843
+ // (see the note in the opener below), never a row the host named.
2844
+ const openOption = useCallback(
2845
+ (target: string) =>
2846
+ setExpandedOptions((prev) =>
2847
+ prev.includes(target) ? prev
2848
+ : expandMode === 'multi' ? [...prev, target]
2849
+ : [...prev.filter((name) => initialExpandedOptions?.includes(name)), target]
2850
+ ),
2851
+ [expandMode, initialExpandedOptions]
2852
+ );
2853
+
2711
2854
  const hasAutoFocusedRef = useRef(false);
2712
2855
  useEffect(() => {
2713
2856
  // Two decisions, one scan. Both open the row that needs attention;
@@ -2746,22 +2889,7 @@ const FormEngineImpl = ({
2746
2889
  return;
2747
2890
  }
2748
2891
 
2749
- const target = getFirstAttentionOptionName(orderedNames, (fieldName) => {
2750
- const schema = options[fieldName];
2751
- if (!schema) {
2752
- return undefined;
2753
- }
2754
- return {
2755
- focusable:
2756
- !readOnly &&
2757
- !schema.disabled &&
2758
- !(schema as { readonly?: boolean }).readonly &&
2759
- !dependencyLockedNames.includes(fieldName),
2760
- // Single source of truth: the same bucket the read-first status boxes
2761
- // show, so a filled-but-invalid required row is a target too.
2762
- needsAttention: getOptionBucket(fieldName) === 'attention',
2763
- };
2764
- });
2892
+ const target = getFirstAttentionOptionName(orderedNames, getAttentionFieldMeta);
2765
2893
 
2766
2894
  // Mark done even when nothing needs attention, so later value edits or an
2767
2895
  // in-place schema reload never re-scan or re-focus. Only a remount arms it
@@ -2769,17 +2897,13 @@ const FormEngineImpl = ({
2769
2897
  hasAutoFocusedRef.current = true;
2770
2898
 
2771
2899
  if (target) {
2772
- setExpandedOptions((prev) =>
2773
- prev.includes(target) ? prev
2774
- : expandMode === 'multi' ? [...prev, target]
2775
- // `expandMode: 'single'` is a rule about what a CLICK does: opening a
2776
- // row closes the one the reader last opened. It is not a licence to
2777
- // close a row the HOST named through `initialExpandedOptions`. That
2778
- // is an address — a statement about what the page is FOR, made before
2779
- // the reader did anything — and this scan is a convenience layered on
2780
- // top of it. Keep the named rows; replace only what a click could.
2781
- : [...prev.filter((name) => initialExpandedOptions?.includes(name)), target]
2782
- );
2900
+ // `expandMode: 'single'` is a rule about what a CLICK does: opening a row
2901
+ // closes the one the reader last opened. It is not a licence to close a row
2902
+ // the HOST named through `initialExpandedOptions`. That is an address — a
2903
+ // statement about what the page is FOR, made before the reader did anything
2904
+ // — and this scan is a convenience layered on top of it. `openOption` keeps
2905
+ // the named rows and replaces only what a click could.
2906
+ openOption(target);
2783
2907
  }
2784
2908
  }, [
2785
2909
  autoFocusFirstRequired,
@@ -2787,13 +2911,52 @@ const FormEngineImpl = ({
2787
2911
  compact,
2788
2912
  options,
2789
2913
  availableOptions,
2790
- readOnly,
2791
- dependencyLockedNames,
2792
- getOptionBucket,
2793
- expandMode,
2794
- initialExpandedOptions,
2914
+ getAttentionFieldMeta,
2915
+ openOption,
2795
2916
  ]);
2796
2917
 
2918
+ // A submit that could not go is answered with where to type: each new
2919
+ // `focusAttentionRequest` opens the first field that needs attention and puts the
2920
+ // caret in it. The host's submit button holds the caret at that moment, and an open
2921
+ // row never takes a caret held outside it (CompactRow), so the button lets go of it
2922
+ // here first; the person asked to submit, and this is the answer to that.
2923
+ const lastFocusRequest = useRef(focusAttentionRequest);
2924
+ useEffect(() => {
2925
+ if (focusAttentionRequest === lastFocusRequest.current) {
2926
+ return;
2927
+ }
2928
+ lastFocusRequest.current = focusAttentionRequest;
2929
+ if (!focusAttentionRequest || !compact || !options) {
2930
+ return;
2931
+ }
2932
+ const target = getFirstAttentionOptionName(
2933
+ Object.keys(availableOptions),
2934
+ getAttentionFieldMeta
2935
+ );
2936
+ if (!target) {
2937
+ return;
2938
+ }
2939
+ const wrap = compactWrapNodeRef.current;
2940
+ const active = document.activeElement as HTMLElement | null;
2941
+ if (active && active !== document.body && !wrap?.contains(active)) {
2942
+ active.blur();
2943
+ }
2944
+ if (!expandedOptions.includes(target)) {
2945
+ // The row's own open effect reveals it and takes the caret.
2946
+ openOption(target);
2947
+ return;
2948
+ }
2949
+ // Already open (the field the form opened on arrival): its open effect has run,
2950
+ // so the caret goes to its control here.
2951
+ const row = wrap?.querySelector<HTMLElement>(`[data-field="${target}"]`);
2952
+ if (row) {
2953
+ revealRow(row, 'auto');
2954
+ findRowFocusTarget(row.querySelector<HTMLElement>('.options-readfirst-row-editor'), {
2955
+ textEntryOnly: row.classList.contains('options-readfirst-card'),
2956
+ })?.focus({ preventScroll: true });
2957
+ }
2958
+ }, [focusAttentionRequest]);
2959
+
2797
2960
  // Read-first completion summary (how many shown options have a value set),
2798
2961
  // surfaced as a progress meter at the top of the compact form.
2799
2962
  const readFirstCompletion = useMemo(
@@ -3191,6 +3354,9 @@ const FormEngineImpl = ({
3191
3354
  option={{ type: resolvedType, ...other }}
3192
3355
  getType={getTypeForOption}
3193
3356
  isRendererOnly={isRendererOnly}
3357
+ // Open is not left: a field the form opened for typing (the first empty
3358
+ // one, on a new form) says what it needs without already being wrong.
3359
+ requirementPending={!(revealRequired || visitedOptions.includes(optionName))}
3194
3360
  />
3195
3361
  {operators && size(operators) && size(other.op) ?
3196
3362
  <>
@@ -3242,6 +3408,8 @@ const FormEngineImpl = ({
3242
3408
  getTypeForOption,
3243
3409
  inheritedScopes,
3244
3410
  inheritedFromParent,
3411
+ revealRequired,
3412
+ visitedOptions,
3245
3413
  ]
3246
3414
  );
3247
3415
 
@@ -3266,6 +3434,10 @@ const FormEngineImpl = ({
3266
3434
  // The "N more fields" row's dashed edge. Well above the divider tint: this
3267
3435
  // one is an affordance the reader has to notice, not a hairline between rows.
3268
3436
  const cMoreBorder = `${cText}4d`;
3437
+ // Calm: the section surface and its hairline, from the theme's panel (the
3438
+ // same surface and edge Reqore gives the section panels themselves).
3439
+ const cPanel = getCalmPanelSurface(theme);
3440
+ const cHairline = getCalmPanelHairline(theme);
3269
3441
 
3270
3442
  // The closure surface the extracted CompactRow reads through context. Refs and
3271
3443
  // setters are stable; the state/memo/handler fields change identity as they do
@@ -3284,6 +3456,8 @@ const FormEngineImpl = ({
3284
3456
  codePreviewRenderer,
3285
3457
  showAllDescriptions,
3286
3458
  expandedOptions,
3459
+ visitedOptions,
3460
+ revealRequired,
3287
3461
  highlightedOptions,
3288
3462
  flashedOptions,
3289
3463
  infoPanelOverrides,
@@ -3332,6 +3506,8 @@ const FormEngineImpl = ({
3332
3506
  codePreviewRenderer,
3333
3507
  showAllDescriptions,
3334
3508
  expandedOptions,
3509
+ visitedOptions,
3510
+ revealRequired,
3335
3511
  highlightedOptions,
3336
3512
  flashedOptions,
3337
3513
  infoPanelOverrides,
@@ -3383,6 +3559,7 @@ const FormEngineImpl = ({
3383
3559
  readOnly,
3384
3560
  invalidCount: size(validityData.invalidFields),
3385
3561
  attentionCount: readFirstAttentionCount,
3562
+ attentionFaultCount: readFirstAttentionFaultCount,
3386
3563
  completion: readFirstCompletion,
3387
3564
  showInvalidOnly: showInvalidOptionsOnly,
3388
3565
  onToggleInvalidOnly: handleToggleInvalidOnly,
@@ -3430,6 +3607,7 @@ const FormEngineImpl = ({
3430
3607
  onReadOnlyActivate,
3431
3608
  validityData,
3432
3609
  readFirstAttentionCount,
3610
+ readFirstAttentionFaultCount,
3433
3611
  readFirstCompletion,
3434
3612
  showInvalidOptionsOnly,
3435
3613
  handleToggleInvalidOnly,
@@ -3711,12 +3889,20 @@ const FormEngineImpl = ({
3711
3889
  intent?: 'warning' | 'success';
3712
3890
  icon: IReqoreIconName;
3713
3891
  }> = [
3714
- {
3715
- key: 'attention',
3716
- label: compactBoxLabels?.attention ?? 'Needs attention',
3717
- intent: 'warning',
3718
- icon: 'ErrorWarningLine',
3719
- },
3892
+ // The first box is amber only once something in it is a fault. On a new form it
3893
+ // holds the required fields still to fill in, which is not yet anything wrong.
3894
+ readFirstAttentionFaultCount ?
3895
+ {
3896
+ key: 'attention',
3897
+ label: compactBoxLabels?.attention ?? 'Needs attention',
3898
+ intent: 'warning',
3899
+ icon: 'ErrorWarningLine',
3900
+ }
3901
+ : {
3902
+ key: 'attention',
3903
+ label: compactBoxLabels?.pending ?? 'To fill in',
3904
+ icon: 'CheckboxBlankCircleLine',
3905
+ },
3720
3906
  { key: 'set', label: compactBoxLabels?.set ?? 'Set', intent: 'success', icon: 'CheckLine' },
3721
3907
  {
3722
3908
  key: 'optional',
@@ -3785,15 +3971,28 @@ const FormEngineImpl = ({
3785
3971
  // box (the Focus cluster). A met group needs no banner — the rail + the
3786
3972
  // "Covers"/"Covered by" chips already say which member satisfies it.
3787
3973
  if (requiredGroupsInfo.satisfiedBy[grp]) return railed;
3974
+ // Amber once someone has been to the group (or a submit was attempted); until
3975
+ // then it is still to fill in, in the quiet colour, like its members.
3976
+ const clusterColor =
3977
+ memberEntries.some((e) => getOptionStatus(e.name) !== 'pending') ? cWarning : cMuted;
3788
3978
  return (
3789
3979
  <StyledRequiredClusterBox
3790
3980
  key={grp}
3791
3981
  className='options-readfirst-required-cluster'
3792
- $border={`${cWarning}33`}
3793
- $tint={`${cWarning}0d`}
3982
+ $border={`${clusterColor}33`}
3983
+ $tint={`${clusterColor}0d`}
3984
+ $calm={calm}
3794
3985
  >
3795
- <StyledRequiredClusterHeader $color={cWarning}>
3796
- <ReqoreIcon icon='LinkM' size='11px' style={{ color: cWarning }} />
3986
+ <StyledRequiredClusterHeader
3987
+ className='options-readfirst-required-cluster-header'
3988
+ $color={clusterColor}
3989
+ $calm={calm}
3990
+ >
3991
+ <ReqoreIcon
3992
+ icon='LinkM'
3993
+ size={calm ? '13px' : '11px'}
3994
+ style={{ color: clusterColor }}
3995
+ />
3797
3996
  One of the below is required
3798
3997
  </StyledRequiredClusterHeader>
3799
3998
  {railed}
@@ -3821,20 +4020,31 @@ const FormEngineImpl = ({
3821
4020
  return (
3822
4021
  <React.Fragment key={groupName}>
3823
4022
  {showGroupSubLabel(groupName) ?
3824
- <StyledStatusBoxGroupLabel>
4023
+ <StyledStatusBoxGroupLabel
4024
+ className='options-readfirst-group-label'
4025
+ $calm={calm}
4026
+ $color={cKey}
4027
+ >
3825
4028
  {getOptionGroupLabel(groupName, groups)}
3826
4029
  </StyledStatusBoxGroupLabel>
3827
4030
  : null}
3828
4031
  {showGroupSubLabel(groupName) && groupConfig?.subtitle ?
3829
4032
  <ReqoreP
4033
+ className='options-readfirst-group-subtitle'
3830
4034
  size='small'
3831
4035
  effect={{ opacity: 0.6 }}
3832
- style={{
3833
- marginTop: 2,
3834
- marginBottom: 8,
3835
- marginLeft: GROUP_INDENT,
3836
- paddingRight: 10,
3837
- }}
4036
+ style={
4037
+ calm ?
4038
+ // Under the calm heading, at the rows' inset; the space below
4039
+ // it is the next row's own padding.
4040
+ { margin: 0, padding: `2px ${CALM_SECTION_INSET}px 4px` }
4041
+ : {
4042
+ marginTop: 2,
4043
+ marginBottom: 8,
4044
+ marginLeft: GROUP_INDENT,
4045
+ paddingRight: 10,
4046
+ }
4047
+ }
3838
4048
  >
3839
4049
  {groupConfig.subtitle}
3840
4050
  </ReqoreP>
@@ -3888,8 +4098,15 @@ const FormEngineImpl = ({
3888
4098
  )
3889
4099
  }
3890
4100
  flat
3891
- raised
4101
+ // Calm: no surface and no lift at all. A theme that gives
4102
+ // panels a surface of their own (Reqore's `theme.panel`) would
4103
+ // otherwise paint this wrapper as a card around the section
4104
+ // cards inside it. Transparent and un-raised, Reqore also drops
4105
+ // the wrapper's horizontal padding, so the readiness line, the
4106
+ // search and the sections share one left edge with the host.
4107
+ raised={!calm}
3892
4108
  minimal
4109
+ transparent={calm || undefined}
3893
4110
  // No panel background: the form sits transparently on whatever
3894
4111
  // hosts it (page, drawer, or — for an arg_schema field — the
3895
4112
  // parent's edit card) instead of stacking its own dark surface.
@@ -3967,8 +4184,13 @@ const FormEngineImpl = ({
3967
4184
  the reader's record withheld, and a page scrolls. */}
3968
4185
  {readOnly ?
3969
4186
  bucketCount('set') ?
3970
- <div className='options-readfirst-read' style={{ padding: '4px 4px 6px' }}>
4187
+ <div
4188
+ className='options-readfirst-read'
4189
+ style={{ padding: calm ? 0 : '4px 4px 6px' }}
4190
+ >
3971
4191
  <StyledGroupBody
4192
+ $calm={calm}
4193
+ $hairline={cHairline}
3972
4194
  $divider={cDivider}
3973
4195
  $hover={cHover}
3974
4196
  $focus={cWarning}
@@ -3976,7 +4198,7 @@ const FormEngineImpl = ({
3976
4198
  $rowBg={cRowBg}
3977
4199
  $lineColor={cGroupLine}
3978
4200
  $moreBorder={cMoreBorder}
3979
- className={compactNarrow ? 'readfirst-narrow' : undefined}
4201
+ className={`readfirst-read-list${compactNarrow ? ' readfirst-narrow' : ''}`}
3980
4202
  >
3981
4203
  {bucketGroups.set.map((groupName) => renderBoxGroup('set', groupName))}
3982
4204
  </StyledGroupBody>
@@ -4004,6 +4226,17 @@ const FormEngineImpl = ({
4004
4226
  const orderedRows: TRowEntry[] = groupsInBox.flatMap(
4005
4227
  (groupName) => buckets[box.key][groupName] || []
4006
4228
  );
4229
+ // The number in the box's header counts the rows whose value
4230
+ // belongs in it NOW. A field being edited keeps its box until it
4231
+ // is left (so it does not jump away under the caret), and it used
4232
+ // to keep its count too: a box read "To fill in 1" while its last
4233
+ // field already had a value and the host's submit read ready.
4234
+ const headerCount =
4235
+ readOnly ? count : (
4236
+ orderedRows.filter(
4237
+ (entry) => getOptionBucket(entry.name, entry.hidden) === box.key
4238
+ ).length
4239
+ );
4007
4240
  // Optional's own rule first: a box opened solely to reveal
4008
4241
  // preselected rows offers those, not an arbitrary first-N.
4009
4242
  const candidateRows =
@@ -4036,24 +4269,32 @@ const FormEngineImpl = ({
4036
4269
  );
4037
4270
  const moreOpen = Boolean(query) || expandedMoreBoxes.includes(box.key);
4038
4271
  const accent =
4039
- box.key === 'attention' ? cWarning
4272
+ box.key === 'attention' && readFirstAttentionFaultCount ? cWarning
4040
4273
  : box.key === 'set' ? cSuccess
4041
4274
  : cMuted;
4042
4275
  // The muted "Optional" box reads as a quieter, recessed
4043
4276
  // surface — a touch darker than the page rather than the
4044
4277
  // faint grey tint the accent would give.
4278
+ // Calm: every box is the same panel — no recessed one.
4045
4279
  const boxBg =
4046
- box.key === 'optional' ?
4280
+ box.key === 'optional' && !calm ?
4047
4281
  changeDarkness(getMainBackgroundColor(theme), 0.06)
4048
4282
  : undefined;
4049
4283
  return (
4050
4284
  <StyledStatusBox
4051
4285
  $accent={accent}
4052
4286
  $bg={boxBg}
4287
+ $calm={calm}
4053
4288
  // Only a box that PINS needs an opaque header (see
4054
4289
  // StyledStatusBox); passing it unconditionally would
4055
- // repaint every form's boxes for nothing.
4056
- $surface={stickyBoxHeaders ? getMainBackgroundColor(theme) : undefined}
4290
+ // repaint every form's boxes for nothing. A calm box
4291
+ // pins on its own panel surface.
4292
+ $surface={
4293
+ stickyBoxHeaders ?
4294
+ calm ? cPanel
4295
+ : getMainBackgroundColor(theme)
4296
+ : undefined
4297
+ }
4057
4298
  key={box.key}
4058
4299
  ref={(node: HTMLDivElement | null) => {
4059
4300
  statusBoxRefs.current[box.key] = node;
@@ -4063,7 +4304,11 @@ const FormEngineImpl = ({
4063
4304
  onCollapseChange={(collapsed) =>
4064
4305
  setOpenedStatusBox(collapsed ? undefined : box.key)
4065
4306
  }
4066
- flat
4307
+ // Calm: an ordinary panel — Reqore paints the theme's
4308
+ // panel surface and its hairline edge. The header stays
4309
+ // minimal (no surface or rule of its own); the first
4310
+ // row's hairline is the line under it.
4311
+ flat={!calm}
4067
4312
  minimal
4068
4313
  /* The header stays on screen while its own box is
4069
4314
  being read — see `compactStickyBoxHeaders`. Sticky
@@ -4101,29 +4346,57 @@ const FormEngineImpl = ({
4101
4346
  // author.
4102
4347
  isCollapsed={wouldCollapse && !hasPreselectedRows}
4103
4348
  label={
4104
- <StyledGroupHeader>
4105
- <ReqoreP effect={{ weight: 'bold' }} size='normal'>
4106
- {box.label}
4107
- </ReqoreP>
4108
- <ReqoreTag
4109
- size='small'
4110
- minimal
4111
- compact
4112
- intent={box.intent}
4113
- label={String(count)}
4114
- />
4115
- </StyledGroupHeader>
4349
+ calm ?
4350
+ // One status dot and a word — and only where a
4351
+ // person is needed. The count is a quiet number.
4352
+ <StyledGroupHeader $calm>
4353
+ {box.key === 'attention' && readFirstAttentionFaultCount ?
4354
+ <StyledStatusDot
4355
+ className='options-readfirst-group-dot'
4356
+ $color={cWarning}
4357
+ aria-hidden
4358
+ />
4359
+ : null}
4360
+ <ReqoreP effect={{ weight: 'bold' }} size='normal'>
4361
+ {box.label}
4362
+ </ReqoreP>
4363
+ {headerCount ?
4364
+ <ReqoreSpan
4365
+ className='options-readfirst-group-count'
4366
+ size='small'
4367
+ effect={{ opacity: 0.65 }}
4368
+ >
4369
+ {headerCount}
4370
+ </ReqoreSpan>
4371
+ : null}
4372
+ </StyledGroupHeader>
4373
+ : <StyledGroupHeader>
4374
+ <ReqoreP effect={{ weight: 'bold' }} size='normal'>
4375
+ {box.label}
4376
+ </ReqoreP>
4377
+ {headerCount ?
4378
+ <ReqoreTag
4379
+ size='small'
4380
+ minimal
4381
+ compact
4382
+ intent={box.intent}
4383
+ label={String(headerCount)}
4384
+ />
4385
+ : null}
4386
+ </StyledGroupHeader>
4116
4387
  }
4117
- icon={box.icon}
4388
+ icon={calm ? undefined : box.icon}
4118
4389
  className='options-readfirst-group'
4119
4390
  padded={false}
4120
- contentStyle={{ padding: '4px 4px 6px' }}
4391
+ contentStyle={{ padding: calm ? 0 : '4px 4px 6px' }}
4121
4392
  >
4122
4393
  {/* ONE group body per box: every field block (and the
4123
4394
  thin schema sub-labels) is a direct flex child, so the
4124
4395
  inter-field gap is identical everywhere — including
4125
4396
  across schema-group boundaries. */}
4126
4397
  <StyledGroupBody
4398
+ $calm={calm}
4399
+ $hairline={cHairline}
4127
4400
  $divider={cDivider}
4128
4401
  $hover={cHover}
4129
4402
  $focus={cWarning}
@@ -4379,14 +4652,16 @@ const FormEngineImpl = ({
4379
4652
  }
4380
4653
  : undefined,
4381
4654
  onClick:
4382
- options[optionName]?.desc ? () => handleOptionLabelClick(optionName) : undefined,
4655
+ fieldHasLongDescription(options[optionName]) ?
4656
+ () => handleOptionLabelClick(optionName)
4657
+ : undefined,
4383
4658
  },
4384
4659
  showLabelTooltip: !!options[optionName]?.short_desc,
4385
4660
  customLabelTooltip: {
4386
4661
  delay: 300,
4387
4662
  content: (
4388
4663
  <Description
4389
- longDescription={`${options[optionName]?.short_desc}${options[optionName]?.desc ? '. Click to learn more.' : ''}`}
4664
+ longDescription={`${options[optionName]?.short_desc}${fieldHasLongDescription(options[optionName]) ? '. Click to learn more.' : ''}`}
4390
4665
  margin='none'
4391
4666
  />
4392
4667
  ),
@@ -4398,7 +4673,8 @@ const FormEngineImpl = ({
4398
4673
  descriptionEffect: {
4399
4674
  textSize: '11px',
4400
4675
  opacity: 0.3,
4401
- underline: options[optionName]?.desc ? 'underline dotted #878787' : undefined,
4676
+ underline:
4677
+ fieldHasLongDescription(options[optionName]) ? 'underline dotted #878787' : undefined,
4402
4678
  },
4403
4679
  customTheme: {
4404
4680
  main: 'main:darken:1',
@@ -4560,10 +4836,20 @@ const FormEngineImpl = ({
4560
4836
  * a child of the row it belongs to. Threading a renderer to each of them by
4561
4837
  * hand is how one of them gets missed.
4562
4838
  */
4563
- export const FormEngine = (props: IFormEngineProps) => (
4564
- <MarkdownRendererContext.Provider value={props.markdownRenderer}>
4565
- <FormEngineImpl {...props} />
4566
- </MarkdownRendererContext.Provider>
4567
- );
4839
+ export const FormEngine = (props: IFormEngineProps) => {
4840
+ // The form's own `appearance` wins; without one it follows the form (or the
4841
+ // host's provider) it sits in, so a nested sub-form never changes look
4842
+ // halfway down its parent.
4843
+ const inherited = useFormAppearance();
4844
+ const appearance = props.appearance ?? inherited;
4845
+
4846
+ return (
4847
+ <MarkdownRendererContext.Provider value={props.markdownRenderer}>
4848
+ <FormAppearanceProvider value={appearance}>
4849
+ <FormEngineImpl {...props} />
4850
+ </FormAppearanceProvider>
4851
+ </MarkdownRendererContext.Provider>
4852
+ );
4853
+ };
4568
4854
 
4569
4855
  export default FormEngine;