@qoretechnologies/reqraft 0.10.36 → 0.10.38-pr.95.gf1f6403

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 (35) hide show
  1. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  2. package/dist/components/form/engine/CompactRow.js +121 -2
  3. package/dist/components/form/engine/CompactRow.js.map +1 -1
  4. package/dist/components/form/engine/FormEngine.d.ts +20 -0
  5. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  6. package/dist/components/form/engine/FormEngine.js +91 -22
  7. package/dist/components/form/engine/FormEngine.js.map +1 -1
  8. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts +27 -0
  9. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -0
  10. package/dist/components/form/engine/_structuredData/SchemaDataView.js +259 -0
  11. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -0
  12. package/dist/components/form/engine/compactRowStyles.d.ts +11 -0
  13. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  14. package/dist/components/form/engine/compactRowStyles.js +14 -3
  15. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  16. package/dist/components/form/engine/readFirst.d.ts +16 -0
  17. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  18. package/dist/components/form/engine/readFirst.js +72 -5
  19. package/dist/components/form/engine/readFirst.js.map +1 -1
  20. package/dist/components/form/fields/array/ArrayAuto.d.ts.map +1 -1
  21. package/dist/components/form/fields/array/ArrayAuto.js +13 -1
  22. package/dist/components/form/fields/array/ArrayAuto.js.map +1 -1
  23. package/dist/components/form/fields/auto/AutoFormField.d.ts +4 -0
  24. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  25. package/dist/components/form/fields/auto/AutoFormField.js +8 -1
  26. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/components/form/engine/CompactRow.tsx +112 -11
  29. package/src/components/form/engine/FormEngine.stories.tsx +313 -13
  30. package/src/components/form/engine/FormEngine.tsx +98 -7
  31. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +462 -0
  32. package/src/components/form/engine/compactRowStyles.ts +14 -2
  33. package/src/components/form/engine/readFirst.ts +81 -4
  34. package/src/components/form/fields/array/ArrayAuto.tsx +12 -0
  35. package/src/components/form/fields/auto/AutoFormField.tsx +11 -0
@@ -1164,7 +1164,7 @@ export const NestedOptionInheritsRenderPropFromAncestorCompact: Story = {
1164
1164
  docs: {
1165
1165
  description: {
1166
1166
  story:
1167
- "Renders the NestedOptionInheritsRenderPropFromAncestor schema with compact=true — the compact renderer summarises the list-of-hash rows as 'init, run' rather than [object Object].",
1167
+ "Renders the NestedOptionInheritsRenderPropFromAncestor schema with compact=true — the compact renderer previews the list-of-hash rows through their arg_schema, naming each method, rather than printing [object Object].",
1168
1168
  },
1169
1169
  },
1170
1170
  },
@@ -1229,11 +1229,13 @@ export const NestedOptionInheritsRenderPropFromAncestorCompact: Story = {
1229
1229
  await waitFor(() => expect(canvas.getAllByText('Methods').length).toBeGreaterThan(0), {
1230
1230
  timeout: 5000,
1231
1231
  });
1232
- // The list-of-hashes value summarises by the items' names — never a raw
1233
- // "[object Object]" (regression: it used to stringify each hash envelope).
1234
- await expect(
1235
- await canvas.findByText('init, run', undefined, { timeout: 5000 })
1236
- ).toBeInTheDocument();
1232
+ // The list-of-hashes value names its items — never a raw "[object Object]"
1233
+ // (regression: it used to stringify each hash envelope). The names now come
1234
+ // from the schema preview rather than a joined summary line above it, so the
1235
+ // assertion moved with them; what must never come back is the stringified
1236
+ // envelope.
1237
+ await expect(await canvas.findByText('init', undefined, { timeout: 5000 })).toBeInTheDocument();
1238
+ await expect(await canvas.findByText('run')).toBeInTheDocument();
1237
1239
  await expect(canvasElement.textContent ?? '').not.toContain('[object Object]');
1238
1240
  },
1239
1241
  };
@@ -1290,12 +1292,15 @@ export const CompactNestedListRowsStayCompact: Story = {
1290
1292
  } as unknown as IOptionsSchema,
1291
1293
  },
1292
1294
  play: async ({ canvasElement }) => {
1293
- // Collapsed, the row summarises by the items' names.
1294
- await _testsWaitForText('init, run');
1295
+ // Collapsed, the row previews its items through their schema — one numbered
1296
+ // entry per method, each naming itself.
1297
+ await _testsWaitForText('Method Name');
1298
+ await _testsWaitForText('init');
1295
1299
 
1296
1300
  // Open the Methods row — this mounts ArrayAuto and, with it, one
1297
- // arg_schema sub-form per row.
1298
- await _testsClickText('init, run');
1301
+ // arg_schema sub-form per row. Clicked by the field's NAME: the joined
1302
+ // summary that used to sit on this row is gone, replaced by the preview.
1303
+ await _testsClickText('Methods');
1299
1304
 
1300
1305
  await waitFor(
1301
1306
  () => expect(canvasElement.querySelectorAll('.array-auto-item')).toHaveLength(2),
@@ -1347,8 +1352,8 @@ export const CompactNestedListRowsStayCompactMobile: Story = {
1347
1352
  ),
1348
1353
  ],
1349
1354
  play: async ({ canvasElement }) => {
1350
- await _testsWaitForText('init, run');
1351
- await _testsClickText('init, run');
1355
+ await _testsWaitForText('init');
1356
+ await _testsClickText('Methods');
1352
1357
 
1353
1358
  await waitFor(
1354
1359
  () => expect(canvasElement.querySelectorAll('.array-auto-item')).toHaveLength(2),
@@ -4122,6 +4127,29 @@ export const CompactFieldTypes: Story = {
4122
4127
  secure: { type: 'bool', display_name: 'Secure' },
4123
4128
  },
4124
4129
  },
4130
+ // The list counterpart of `connectionInfo`: a list of hashes whose fields
4131
+ // are declared. This is the shape the schema preview was built for, and the
4132
+ // catalogue was missing it — it covered the described HASH but not the
4133
+ // described LIST, which is the case that was actually reported.
4134
+ routes: {
4135
+ type: 'list',
4136
+ ui_type: 'list',
4137
+ element_type: 'hash',
4138
+ display_name: 'List of hashes (arg_schema)',
4139
+ group: 'structured',
4140
+ arg_schema: {
4141
+ method: {
4142
+ type: 'string',
4143
+ display_name: 'Method',
4144
+ allowed_values: [
4145
+ { value: 'get', display_name: 'GET' },
4146
+ { value: 'post', display_name: 'POST' },
4147
+ ],
4148
+ },
4149
+ path: { type: 'string', display_name: 'Path' },
4150
+ handler: { type: 'string', ui_type: 'code-editor', display_name: 'Handler' },
4151
+ },
4152
+ },
4125
4153
  freeConfig: {
4126
4154
  type: 'free-hash',
4127
4155
  ui_type: 'free-hash',
@@ -4330,6 +4358,22 @@ export const CompactFieldTypes: Story = {
4330
4358
  secure: { type: 'bool', value: true },
4331
4359
  },
4332
4360
  },
4361
+ routes: {
4362
+ type: 'list',
4363
+ value: [
4364
+ // One item complete, one with the optional field unset — the empty
4365
+ // state stays exercised rather than being filled in everywhere.
4366
+ {
4367
+ type: 'hash',
4368
+ value: {
4369
+ method: 'get',
4370
+ path: '/orders',
4371
+ handler: 'sub get_orders() {\n return orders.all();\n}',
4372
+ },
4373
+ },
4374
+ { type: 'hash', value: { method: 'post', path: '/orders' } },
4375
+ ],
4376
+ },
4333
4377
  freeConfig: { type: 'free-hash', value: '%YAML 1.2\n---\nretries: 3\ntimeout: 30\n' },
4334
4378
  bigConfig: {
4335
4379
  type: 'hash',
@@ -4412,7 +4456,23 @@ export const CompactFieldTypes: Story = {
4412
4456
  await _testsWaitForText('#0000FF'); // colour → uppercase hex
4413
4457
  await _testsWaitForText('rgba(255, 0, 0, 0.5)'); // colour with alpha → rgba()
4414
4458
  await _testsWaitForText('config.txt'); // file → filename
4415
- await _testsWaitForText('3 fields'); // structured hash → field count summary
4459
+ // A hash the schema DESCRIBES needs no count: its preview names every field,
4460
+ // so "3 fields" above "Host / Port / Secure" would be the same fact twice.
4461
+ // A hash it does not describe keeps the count — the untyped tree beneath it
4462
+ // says nothing about what the value means, so the count is the only line
4463
+ // that does. Both halves asserted here: the distinction IS the behaviour.
4464
+ await _testsWaitForText('2 fields'); // undescribed hash → field count summary
4465
+ await _testsWaitForText('Host'); // described hash → its fields, by name
4466
+ await _testsWaitForTextToNotExist('3 fields');
4467
+
4468
+ // Described LIST of hashes: numbered items, labels by display_name, and the
4469
+ // allowed value read back as its label rather than the stored `get`.
4470
+ await _testsWaitForText('Path');
4471
+ await _testsWaitForText('/orders');
4472
+ await _testsWaitForText('GET');
4473
+ // A code sub-field renders AS code, not as a flattened mono line.
4474
+ await _testsWaitForText('Handler');
4475
+ await _testsWaitForText(/sub get_orders/);
4416
4476
  await _testsWaitForText('order-to-invoice'); // interface reference → raw value
4417
4477
 
4418
4478
  // Field stack (merged from dpql): byte-size shows its value string; the
@@ -5915,3 +5975,243 @@ export const CompactRowMarkdownWithoutRenderer: Story = {
5915
5975
  expect(descText).not.toContain('**');
5916
5976
  },
5917
5977
  };
5978
+
5979
+ /**
5980
+ * The auth-profile scheme sub-schema, which is where all three of the list-row
5981
+ * affordances below were reported. One required choice with named allowed
5982
+ * values, one field that belongs to a single choice.
5983
+ */
5984
+ const AuthSchemeArgSchema = {
5985
+ type: {
5986
+ type: 'string',
5987
+ ui_type: 'string',
5988
+ display_name: 'Scheme Type',
5989
+ short_desc: 'Authentication scheme type',
5990
+ required: true,
5991
+ allowed_values: [
5992
+ { value: 'default', display_name: 'Default RBAC' },
5993
+ { value: 'cookie', display_name: 'Cookie' },
5994
+ { value: 'oauth2', display_name: 'OAuth2' },
5995
+ ],
5996
+ },
5997
+ cookie_name: {
5998
+ type: 'string',
5999
+ ui_type: 'string',
6000
+ display_name: 'Session Cookie Name',
6001
+ short_desc: 'Applies to the Cookie scheme alone',
6002
+ depends_on: ['type=cookie'],
6003
+ },
6004
+ } as unknown as IOptionsSchema;
6005
+
6006
+ const AuthSchemeOptions = {
6007
+ schemes: {
6008
+ type: 'list',
6009
+ ui_type: 'list',
6010
+ element_type: 'hash',
6011
+ display_name: 'Authentication Schemes',
6012
+ short_desc: 'Schemes tried in order, first match wins',
6013
+ required: true,
6014
+ arg_schema: AuthSchemeArgSchema,
6015
+ },
6016
+ } as unknown as IOptionsSchema;
6017
+
6018
+ /**
6019
+ * A list-of-hash row reads back in the words the form asked for the value.
6020
+ *
6021
+ * Reported on an auth profile: the row summarised as "2 items" and its preview
6022
+ * printed a data tree — "Object · 1 field" over `type: default`. Both are
6023
+ * exactly what is stored, and neither is a string the author has ever seen: the
6024
+ * form calls that key "Scheme Type" and that value "Default RBAC". A row meant
6025
+ * to confirm a choice showed a value nobody had chosen, in a shape nobody had
6026
+ * asked about.
6027
+ *
6028
+ * A generic data view has to announce what it found, because inference is all it
6029
+ * has. A field with an `arg_schema` needs none of that — the shape, the names and
6030
+ * the choices are known before the value arrives — so the preview renders
6031
+ * THROUGH the schema (`SchemaDataView`) and the summary resolves through the
6032
+ * same one. The row, the preview and the editor cannot describe a value three
6033
+ * ways.
6034
+ */
6035
+ export const CompactListOfHashReadsInSchemaWords: Story = {
6036
+ parameters: {
6037
+ docs: {
6038
+ description: {
6039
+ story:
6040
+ 'A list-of-hash option whose sub-schema names its fields and values. The collapsed row summarises by those names ("Default RBAC, Cookie") and the preview renders as labelled schema rows — "Scheme Type · Default RBAC" per numbered item — instead of an untyped data tree over the stored `type: default`.',
6041
+ },
6042
+ },
6043
+ },
6044
+ args: {
6045
+ compact: true,
6046
+ minColumnWidth: '300px',
6047
+ options: AuthSchemeOptions,
6048
+ value: {
6049
+ schemes: {
6050
+ type: 'list',
6051
+ value: [
6052
+ { type: 'hash', value: { type: 'default' } },
6053
+ { type: 'hash', value: { type: 'cookie', cookie_name: 'qorus-session' } },
6054
+ ],
6055
+ },
6056
+ },
6057
+ },
6058
+ play: async ({ canvasElement }) => {
6059
+ // The preview IS the value here — the row prints no summary line above it.
6060
+ // A joined "Default RBAC, Cookie" directly over a preview that names both
6061
+ // items is the same facts twice, the lossy version first.
6062
+ const preview = await waitFor(() => {
6063
+ const element = canvasElement.querySelector('.schema-data-view');
6064
+ expect(element).toBeTruthy();
6065
+ return element!;
6066
+ });
6067
+ const text = preview.textContent ?? '';
6068
+ expect(text).toContain('Scheme Type');
6069
+ expect(text).toContain('Default RBAC');
6070
+ expect(text).toContain('Session Cookie Name');
6071
+
6072
+ // Nothing announces the container's shape — that is the tell of a renderer
6073
+ // guessing at data it has not been told about.
6074
+ expect(text).not.toMatch(/Object\b/);
6075
+ expect(text).not.toMatch(/\d+ fields?\b/);
6076
+
6077
+ // The joined summary is gone from the row, and it is the ROW that has to be
6078
+ // checked: the same words still exist inside the preview, so asserting on
6079
+ // the whole canvas would pass whether or not the line was removed.
6080
+ const valueLine = canvasElement.querySelector(
6081
+ '[data-field="schemes"] .options-readfirst-valuetext'
6082
+ );
6083
+ expect(valueLine?.textContent ?? '').not.toBe('Default RBAC, Cookie');
6084
+
6085
+ // A literal the author typed is set in mono; a chosen label is not.
6086
+ const mono = [...preview.querySelectorAll('.schema-view-data')].map((el) =>
6087
+ (el.textContent ?? '').trim()
6088
+ );
6089
+ expect(mono).toContain('qorus-session');
6090
+ expect(mono).not.toContain('Default RBAC');
6091
+
6092
+ // The stored spellings are what the author never chose, so they must not be
6093
+ // in the preview at all. Substring checks cannot say this — "Scheme Type"
6094
+ // contains "type" and "Default RBAC" contains "default" — so the assertion
6095
+ // is on whole leaf elements: no chip or cell reads exactly `type`,
6096
+ // `cookie_name` or `default`. A substring assertion here passed against a
6097
+ // preview that was still rendering the raw pair.
6098
+ const leafTexts = [...preview.querySelectorAll('*')]
6099
+ .filter((element) => element.children.length === 0)
6100
+ .map((element) => (element.textContent ?? '').trim());
6101
+ expect(leafTexts).toContain('Scheme Type');
6102
+ for (const stored of ['type', 'cookie_name', 'default']) {
6103
+ expect(leafTexts).not.toContain(stored);
6104
+ }
6105
+ expect(canvasElement.textContent ?? '').not.toContain('[object Object]');
6106
+ },
6107
+ };
6108
+
6109
+ /**
6110
+ * Adding a list item opens the field the item cannot be saved without.
6111
+ *
6112
+ * `+ Add new item for "Authentication Scheme"` added a row whose one required
6113
+ * field sat collapsed, so the author had to find it and click it before the
6114
+ * form could be completed — a second click to reach the only thing the first
6115
+ * click could have meant.
6116
+ *
6117
+ * The engine already had `autoFocusFirstRequired`, and it could not fire here:
6118
+ * it waits for focus to be free so it never steals a caret, and a form mounted
6119
+ * BY a click never sees free focus — the button that mounted it still holds it.
6120
+ * Opening a row and moving the caret are separate decisions, so they are now
6121
+ * separate flags; this one only opens.
6122
+ */
6123
+ export const CompactAddedListRowOpensItsRequiredField: Story = {
6124
+ parameters: {
6125
+ docs: {
6126
+ description: {
6127
+ story:
6128
+ 'Clicks "Add new item" on a list-of-hash option and shows the new row with its required "Scheme Type" field already open. Rows that were already in the value stay collapsed — only the row just added is opened.',
6129
+ },
6130
+ },
6131
+ },
6132
+ args: {
6133
+ compact: true,
6134
+ minColumnWidth: '300px',
6135
+ options: AuthSchemeOptions,
6136
+ value: {
6137
+ schemes: {
6138
+ type: 'list',
6139
+ value: [{ type: 'hash', value: { type: 'default' } }],
6140
+ },
6141
+ },
6142
+ },
6143
+ play: async ({ canvasElement }) => {
6144
+ // Open the list itself, which mounts the rows and the Add button. Clicked by
6145
+ // FIELD, not by its text: the row no longer prints a summary line, and the
6146
+ // words that remain live inside the preview.
6147
+ await _testsClickText('Authentication Schemes');
6148
+ await waitFor(() => expect(canvasElement.querySelectorAll('.array-auto-item')).toHaveLength(1));
6149
+
6150
+ // The row that was already there is collapsed — this is the state the new
6151
+ // row must NOT be confused with.
6152
+ const isOpen = (element: Element | null | undefined) =>
6153
+ !!element &&
6154
+ (element.classList.contains('readfirst-row-editing') ||
6155
+ element.classList.contains('options-readfirst-card'));
6156
+ const typeRows = () => canvasElement.querySelectorAll('[data-field="type"]');
6157
+ expect([...typeRows()].some(isOpen)).toBe(false);
6158
+
6159
+ await _testsClickButton({ label: 'Add new item for "Authentication Schemes"' });
6160
+
6161
+ await waitFor(() => expect(canvasElement.querySelectorAll('.array-auto-item')).toHaveLength(2));
6162
+ // Exactly one open required field: the one belonging to the row just added.
6163
+ await waitFor(() => expect([...typeRows()].filter(isOpen)).toHaveLength(1));
6164
+ },
6165
+ };
6166
+
6167
+ /**
6168
+ * The same schema-worded row at a phone-class width.
6169
+ *
6170
+ * The narrow branch is what makes this worth its own story: below 480px the row
6171
+ * stacks the value under the label, so a summary and a preview that were both
6172
+ * rewritten to be READ (rather than decoded) have to survive the stacking
6173
+ * without wrapping into an unreadable column. `compactNarrow` comes from
6174
+ * `useMeasure` on the form's own wrapper, not from a media query, so a narrow
6175
+ * container is the honest way to reach the branch here — the viewport-parameter
6176
+ * rule applies to media-query components, which this is not.
6177
+ */
6178
+ export const CompactListOfHashReadsInSchemaWordsMobile: Story = {
6179
+ parameters: {
6180
+ docs: {
6181
+ description: {
6182
+ story:
6183
+ 'The schema-worded list-of-hash row at a ~360px width: the summary and the renamed preview stack under the field name and stay legible in one column.',
6184
+ },
6185
+ },
6186
+ },
6187
+ args: CompactListOfHashReadsInSchemaWords.args,
6188
+ decorators: [
6189
+ (StoryComponent: React.ComponentType) => (
6190
+ <div style={{ maxWidth: 360, margin: '0 auto', border: '1px dashed #ffffff22' }}>
6191
+ <StoryComponent />
6192
+ </div>
6193
+ ),
6194
+ ],
6195
+ play: async ({ canvasElement }) => {
6196
+ await _testsWaitForText('Scheme Type');
6197
+
6198
+ // The narrow branch is actually engaged — otherwise this is the desktop
6199
+ // story with a border round it, and it would pass while proving nothing.
6200
+ await waitFor(() =>
6201
+ expect(canvasElement.querySelector('.readfirst-narrow')).toBeTruthy()
6202
+ );
6203
+
6204
+ const preview = await waitFor(() => {
6205
+ const element = canvasElement.querySelector('.schema-data-view');
6206
+ expect(element).toBeTruthy();
6207
+ return element!;
6208
+ });
6209
+ const leafTexts = [...preview.querySelectorAll('*')]
6210
+ .filter((element) => element.children.length === 0)
6211
+ .map((element) => (element.textContent ?? '').trim());
6212
+ expect(leafTexts).toContain('Scheme Type');
6213
+ for (const stored of ['type', 'cookie_name', 'default']) {
6214
+ expect(leafTexts).not.toContain(stored);
6215
+ }
6216
+ },
6217
+ };
@@ -107,6 +107,7 @@ import {
107
107
  import { OptionsHelpDialog } from './OptionsHelpDialog';
108
108
  import {
109
109
  TReadFirstStatus,
110
+ findAllowedValueOption,
110
111
  getFirstAttentionOptionName,
111
112
  getOptionGroup,
112
113
  getOptionGroupLabel,
@@ -429,13 +430,18 @@ export const fixOptions = (
429
430
  };
430
431
  }
431
432
 
433
+ // A value that is not one of the declared choices is dropped. What counts
434
+ // as "one of the choices" is `findAllowedValueOption` — the same predicate
435
+ // the read-first row uses to LABEL a value — because a value the row can
436
+ // name is by definition a value the form must keep. Inlining a narrower
437
+ // test here (envelope and `name`, but not a bare `value`) silently erased
438
+ // every value declared the bare way: the collapsed row still showed its
439
+ // display name while the editor showed "—" and the value never reached
440
+ // the submitted data.
432
441
  if (
433
442
  newOption.value !== undefined &&
434
443
  options?.[optionName]?.allowed_values &&
435
- !options?.[optionName]?.allowed_values?.find(
436
- (allowedValue: any) =>
437
- allowedValue.value?.value === newOption.value || allowedValue.name === newOption.value
438
- ) &&
444
+ !findAllowedValueOption(newOption.value, options?.[optionName]) &&
439
445
  !isValueTemplate(newOption.value) &&
440
446
  !options?.[optionName]?.multiselect &&
441
447
  !options?.[optionName]?.allowed_values_creatable
@@ -715,6 +721,27 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
715
721
  */
716
722
  autoFocusFirstRequired?: boolean;
717
723
 
724
+ /**
725
+ * Opt-in: on mount, OPEN the first row that needs attention — without taking
726
+ * focus.
727
+ *
728
+ * `autoFocusFirstRequired` fuses two decisions that are not the same: which
729
+ * row is open (layout) and where the caret is (focus). Focus is the half that
730
+ * can be stolen, so that flag waits for focus to be free — and a form mounted
731
+ * BY a click never sees free focus, because the button that mounted it still
732
+ * has it. Adding a list item is exactly that case: the row the author must
733
+ * fill stayed shut behind a second click, and the flag that exists to prevent
734
+ * it could not fire.
735
+ *
736
+ * Opening a row takes nothing from the user, so this half needs no guard. Use
737
+ * it where the mount is already the answer to a deliberate action; use
738
+ * `autoFocusFirstRequired` where the form is the destination and the caret
739
+ * should land in it. Setting both keeps the focusing behaviour. Same one-shot
740
+ * contract: it fires on the first render with focusable content and then never
741
+ * again for the life of the instance. No-op in classic mode. Default: off.
742
+ */
743
+ expandFirstRequired?: boolean;
744
+
718
745
  /**
719
746
  * Names of read-first rows to open on mount, in addition to whatever the
720
747
  * user opens afterwards.
@@ -783,6 +810,7 @@ const FormEngineImpl = ({
783
810
  rendererOnlyUiTypes,
784
811
  inheritedFromParent,
785
812
  autoFocusFirstRequired,
813
+ expandFirstRequired,
786
814
  initialExpandedOptions,
787
815
  ...rest
788
816
  }: IFormEngineProps) => {
@@ -831,6 +859,47 @@ const FormEngineImpl = ({
831
859
  const [showInvalidOptionsOnly, setShowInvalidOptionsOnly] = useState<boolean>(false);
832
860
  // Which options are expanded into their editor (several can be open at once).
833
861
  const [expandedOptions, setExpandedOptions] = useState<string[]>([]);
862
+
863
+ // --- Reveal a status box's content when it is opened ----------------------
864
+ //
865
+ // The status boxes stack, so the last one ("Optional", which holds every
866
+ // not-yet-added field) sits at the bottom of the form — frequently at the
867
+ // bottom of the scroll container too. Opening it mounts its rows BELOW the
868
+ // fold: the box's own header is all that stays on screen, the click appears to
869
+ // have done nothing, and the fields it just revealed have to be hunted for by
870
+ // scrolling.
871
+ //
872
+ // The box that was just opened, read by the layout effect below. Held as state
873
+ // rather than acted on inside the collapse handler because the handler runs
874
+ // BEFORE React re-renders — at that moment the rows do not exist yet, so the
875
+ // box has not grown and there is nothing to scroll to. A layout effect runs
876
+ // after the DOM is updated and before paint, which makes the reveal
877
+ // deterministic instead of a guessed delay.
878
+ const [openedStatusBox, setOpenedStatusBox] = useState<string | undefined>(undefined);
879
+ const statusBoxRefs = useRef<Record<string, HTMLDivElement | null>>({});
880
+ useLayoutEffect(() => {
881
+ if (!openedStatusBox) {
882
+ return;
883
+ }
884
+ const element = statusBoxRefs.current[openedStatusBox];
885
+ // Consume it either way: a box that has gone (a search narrowed it out of
886
+ // existence) must not leave a pending reveal armed for a later render.
887
+ setOpenedStatusBox(undefined);
888
+ // `scrollIntoView` is not implemented in jsdom, and a missing reveal must
889
+ // never break a form.
890
+ if (!element?.scrollIntoView) {
891
+ return;
892
+ }
893
+ // 'nearest' scrolls the MINIMUM needed: nothing at all when the box is
894
+ // already fully visible, and a top-alignment when it is taller than the
895
+ // viewport (header plus the first rows — the right answer for a long
896
+ // Optional list). Anything else moves the page when it did not need to.
897
+ element.scrollIntoView({
898
+ block: 'nearest',
899
+ behavior:
900
+ window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ? 'auto' : 'smooth',
901
+ });
902
+ }, [openedStatusBox]);
834
903
  // Remembers each row's last settled status box, so an actively-edited field
835
904
  // stays put when its status flips (e.g. becomes valid) instead of jumping to
836
905
  // another box mid-edit and stealing focus. Keyed by option name.
@@ -1863,7 +1932,11 @@ const FormEngineImpl = ({
1863
1932
  // (or below-the-fold) form is never scrolled into view on mount.
1864
1933
  const autoFocusNameRef = useRef<string | undefined>(undefined);
1865
1934
  useEffect(() => {
1866
- if (!autoFocusFirstRequired || !compact || !options) {
1935
+ // Two decisions, one scan. `autoFocusFirstRequired` opens the row AND puts
1936
+ // the caret in it; `expandFirstRequired` only opens it. Setting both keeps
1937
+ // the focusing behaviour, since focusing implies opening.
1938
+ const wantsFocus = !!autoFocusFirstRequired;
1939
+ if ((!wantsFocus && !expandFirstRequired) || !compact || !options) {
1867
1940
  return;
1868
1941
  }
1869
1942
  if (hasAutoFocusedRef.current) {
@@ -1881,8 +1954,14 @@ const FormEngineImpl = ({
1881
1954
  // clean mount focus rests on the body, so the intended "drop into the first
1882
1955
  // field" still fires. Bailing here leaves `hasAutoFocusedRef` false, so it
1883
1956
  // retries once focus is free.
1957
+ //
1958
+ // Only the FOCUSING variant waits: opening a row moves no caret, so there is
1959
+ // nothing to steal and nothing to wait for. Making the expand-only variant
1960
+ // wait here would make it a no-op in its main use — a sub-form mounted by a
1961
+ // click, where the button that mounted it still holds focus and never gives
1962
+ // it up.
1884
1963
  const active = document.activeElement as HTMLElement | null;
1885
- if (active && active !== document.body) {
1964
+ if (wantsFocus && active && active !== document.body) {
1886
1965
  return;
1887
1966
  }
1888
1967
 
@@ -1910,7 +1989,10 @@ const FormEngineImpl = ({
1910
1989
 
1911
1990
  if (target) {
1912
1991
  // Set the ref before the state update so CompactRow's focus timer sees it.
1913
- autoFocusNameRef.current = target;
1992
+ // Expand-only leaves it unset, which is what keeps the caret where it is.
1993
+ if (wantsFocus) {
1994
+ autoFocusNameRef.current = target;
1995
+ }
1914
1996
  setExpandedOptions((prev) =>
1915
1997
  prev.includes(target) ? prev
1916
1998
  : expandMode === 'multi' ? [...prev, target]
@@ -1919,6 +2001,7 @@ const FormEngineImpl = ({
1919
2001
  }
1920
2002
  }, [
1921
2003
  autoFocusFirstRequired,
2004
+ expandFirstRequired,
1922
2005
  compact,
1923
2006
  options,
1924
2007
  availableOptions,
@@ -2783,6 +2866,14 @@ const FormEngineImpl = ({
2783
2866
  $accent={accent}
2784
2867
  $bg={boxBg}
2785
2868
  key={box.key}
2869
+ ref={(node: HTMLDivElement | null) => {
2870
+ statusBoxRefs.current[box.key] = node;
2871
+ }}
2872
+ // Opening arms the reveal; collapsing disarms it, so a
2873
+ // close never scrolls.
2874
+ onCollapseChange={(collapsed) =>
2875
+ setOpenedStatusBox(collapsed ? undefined : box.key)
2876
+ }
2786
2877
  flat
2787
2878
  minimal
2788
2879
  collapseButtonProps={{ flat: true, minimal: true, size: 'small' }}