@qoretechnologies/reqraft 0.10.61 → 0.10.62-pr.137.g86641aa

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 (101) hide show
  1. package/dist/components/dpqlEditor/DpqlEditor.d.ts.map +1 -1
  2. package/dist/components/dpqlEditor/DpqlEditor.js +8 -3
  3. package/dist/components/dpqlEditor/DpqlEditor.js.map +1 -1
  4. package/dist/components/dpqlEditor/types.d.ts +5 -0
  5. package/dist/components/dpqlEditor/types.d.ts.map +1 -1
  6. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  7. package/dist/components/form/engine/CompactRow.js +17 -8
  8. package/dist/components/form/engine/CompactRow.js.map +1 -1
  9. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  10. package/dist/components/form/engine/FormEngine.js +65 -15
  11. package/dist/components/form/engine/FormEngine.js.map +1 -1
  12. package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
  13. package/dist/components/form/engine/OptionFieldMessages.js +12 -1
  14. package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
  15. package/dist/components/form/engine/compactRowContext.d.ts +2 -0
  16. package/dist/components/form/engine/compactRowContext.d.ts.map +1 -1
  17. package/dist/components/form/engine/compactRowContext.js.map +1 -1
  18. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  19. package/dist/components/form/engine/readFirst.js +5 -1
  20. package/dist/components/form/engine/readFirst.js.map +1 -1
  21. package/dist/components/form/expressions/ExpressionField.d.ts +21 -1
  22. package/dist/components/form/expressions/ExpressionField.d.ts.map +1 -1
  23. package/dist/components/form/expressions/ExpressionField.js +72 -14
  24. package/dist/components/form/expressions/ExpressionField.js.map +1 -1
  25. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  26. package/dist/components/form/expressions/builder/index.js +16 -3
  27. package/dist/components/form/expressions/builder/index.js.map +1 -1
  28. package/dist/components/form/expressions/expressionOperands.d.ts +16 -0
  29. package/dist/components/form/expressions/expressionOperands.d.ts.map +1 -0
  30. package/dist/components/form/expressions/expressionOperands.js +97 -0
  31. package/dist/components/form/expressions/expressionOperands.js.map +1 -0
  32. package/dist/components/form/expressions/mockExpressions.d.ts.map +1 -1
  33. package/dist/components/form/expressions/mockExpressions.js +15 -0
  34. package/dist/components/form/expressions/mockExpressions.js.map +1 -1
  35. package/dist/components/form/expressions/renderExpressionToText.d.ts +2 -0
  36. package/dist/components/form/expressions/renderExpressionToText.d.ts.map +1 -1
  37. package/dist/components/form/expressions/renderExpressionToText.js +24 -5
  38. package/dist/components/form/expressions/renderExpressionToText.js.map +1 -1
  39. package/dist/components/form/expressions/textOfExpression.d.ts +18 -0
  40. package/dist/components/form/expressions/textOfExpression.d.ts.map +1 -0
  41. package/dist/components/form/expressions/textOfExpression.js +37 -0
  42. package/dist/components/form/expressions/textOfExpression.js.map +1 -0
  43. package/dist/components/form/expressions/useRenderExpression.d.ts.map +1 -1
  44. package/dist/components/form/expressions/useRenderExpression.js +7 -7
  45. package/dist/components/form/expressions/useRenderExpression.js.map +1 -1
  46. package/dist/components/form/fields/rich-text/RichText.d.ts +8 -1
  47. package/dist/components/form/fields/rich-text/RichText.d.ts.map +1 -1
  48. package/dist/components/form/fields/rich-text/RichText.js +10 -10
  49. package/dist/components/form/fields/rich-text/RichText.js.map +1 -1
  50. package/dist/components/form/fields/template/TemplateField.d.ts +6 -0
  51. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  52. package/dist/components/form/fields/template/TemplateField.js +280 -55
  53. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  54. package/dist/components/form/fields/template/writtenAsText.d.ts +57 -0
  55. package/dist/components/form/fields/template/writtenAsText.d.ts.map +1 -0
  56. package/dist/components/form/fields/template/writtenAsText.js +203 -0
  57. package/dist/components/form/fields/template/writtenAsText.js.map +1 -0
  58. package/dist/components/form/index.d.ts +2 -0
  59. package/dist/components/form/index.d.ts.map +1 -1
  60. package/dist/components/form/index.js +7 -1
  61. package/dist/components/form/index.js.map +1 -1
  62. package/dist/components/smartEditor/useLspSession.d.ts +6 -0
  63. package/dist/components/smartEditor/useLspSession.d.ts.map +1 -1
  64. package/dist/components/smartEditor/useLspSession.js +7 -6
  65. package/dist/components/smartEditor/useLspSession.js.map +1 -1
  66. package/dist/helpers/templates.d.ts +18 -0
  67. package/dist/helpers/templates.d.ts.map +1 -1
  68. package/dist/helpers/templates.js +30 -1
  69. package/dist/helpers/templates.js.map +1 -1
  70. package/dist/helpers/validations.d.ts.map +1 -1
  71. package/dist/helpers/validations.js +98 -18
  72. package/dist/helpers/validations.js.map +1 -1
  73. package/package.json +1 -1
  74. package/src/components/dpqlEditor/DpqlEditor.tsx +11 -18
  75. package/src/components/dpqlEditor/types.ts +5 -0
  76. package/src/components/form/engine/CompactRow.tsx +31 -10
  77. package/src/components/form/engine/FormEngine.stories.tsx +656 -18
  78. package/src/components/form/engine/FormEngine.tsx +64 -22
  79. package/src/components/form/engine/FormEngineRemote.stories.tsx +5 -0
  80. package/src/components/form/engine/OptionFieldMessages.tsx +12 -1
  81. package/src/components/form/engine/compactRowContext.ts +2 -0
  82. package/src/components/form/engine/readFirst.ts +7 -1
  83. package/src/components/form/expressions/ExpressionField.stories.tsx +57 -9
  84. package/src/components/form/expressions/ExpressionField.tsx +179 -60
  85. package/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +8 -1
  86. package/src/components/form/expressions/builder/index.tsx +16 -3
  87. package/src/components/form/expressions/dpqlMockLanguage.ts +7 -2
  88. package/src/components/form/expressions/expressionOperands.ts +74 -0
  89. package/src/components/form/expressions/mockExpressions.ts +16 -4
  90. package/src/components/form/expressions/renderExpressionToText.ts +26 -9
  91. package/src/components/form/expressions/textOfExpression.ts +39 -0
  92. package/src/components/form/expressions/useRenderExpression.ts +9 -1
  93. package/src/components/form/fields/number/Number.stories.tsx +8 -7
  94. package/src/components/form/fields/rich-text/RichText.tsx +18 -10
  95. package/src/components/form/fields/template/TemplateField.stories.tsx +486 -18
  96. package/src/components/form/fields/template/TemplateField.tsx +506 -62
  97. package/src/components/form/fields/template/writtenAsText.ts +217 -0
  98. package/src/components/form/index.tsx +6 -0
  99. package/src/components/smartEditor/useLspSession.ts +15 -10
  100. package/src/helpers/templates.ts +35 -0
  101. package/src/helpers/validations.ts +83 -12
@@ -1378,6 +1378,8 @@ const FormEngineImpl = ({
1378
1378
  // stays put when its status flips (e.g. becomes valid) instead of jumping to
1379
1379
  // another box mid-edit and stealing focus. Keyed by option name.
1380
1380
  const settledBucket = useRef<Record<string, 'attention' | 'set' | 'optional'>>({});
1381
+ // the one field that says where it moves when it is closed: see `heldOptions`
1382
+ const movesToNoteFor = useRef<string | null | undefined>(undefined);
1381
1383
  // Measured form width (not viewport — the form lives in drawers/panels of
1382
1384
  // arbitrary width) drives the stacked narrow layout.
1383
1385
  const [compactWrapRef, { width: compactWrapWidth }] = useMeasure<HTMLDivElement>();
@@ -1425,9 +1427,7 @@ const FormEngineImpl = ({
1425
1427
  setCompactToolbarHeight(0);
1426
1428
  return undefined;
1427
1429
  }
1428
- const header = wrap.querySelector<HTMLElement>(
1429
- ':scope > .reqore-panel > .reqore-panel-title'
1430
- );
1430
+ const header = wrap.querySelector<HTMLElement>(':scope > .reqore-panel > .reqore-panel-title');
1431
1431
  if (!header) {
1432
1432
  setCompactToolbarHeight(0);
1433
1433
  return undefined;
@@ -2148,7 +2148,6 @@ const FormEngineImpl = ({
2148
2148
  return newValue;
2149
2149
  }
2150
2150
 
2151
-
2152
2151
  const rendererType = getType(
2153
2152
  (options[optionName].ui_type || options[optionName].type) as TQorusType,
2154
2153
  operators,
@@ -2392,29 +2391,39 @@ const FormEngineImpl = ({
2392
2391
  const isEmpty = optionValue === undefined || optionValue === '';
2393
2392
 
2394
2393
  let validation: IValidationResult;
2395
-
2396
- /* A required field locked by an unmet `depends_on` does not apply
2394
+ /* A field whose host gives its own reason (`invalid_reason`) is invalid while it does, whatever the
2395
+ field makes of its value: the host knows something the field cannot - that a field it names
2396
+ is not in the record, that text around a field is not a whole number - and says it in the field,
2397
+ in place of the field's own message (`getOptionFieldMessages`). */
2398
+ const hostReason = (options?.[optionName] as { invalid_reason?: string } | undefined)
2399
+ ?.invalid_reason;
2400
+
2401
+ if (hostReason) {
2402
+ validation = { isValid: false, reason: hostReason, reasons: [hostReason] };
2403
+ } else {
2404
+ /* A required field locked by an unmet `depends_on` does not apply
2397
2405
  while it is locked: it is not in "Needs attention", and an empty one
2398
2406
  does not make the form incomplete either. Otherwise a check whose
2399
2407
  kind is Equals could never be saved, because Minimum — required for
2400
2408
  Between alone — is empty; and a form whose only gaps are fields
2401
2409
  nobody can fill in would say Incomplete with nothing to act on. A
2402
2410
  locked field that still HOLDS a value is validated as before. */
2403
- if (
2404
- isEmpty &&
2405
- ((!isRequired && !hasRequiredGroups) || dependencyLockedNames.includes(optionName))
2406
- ) {
2407
- validation = { isValid: true, reasons: [] };
2408
- } else {
2409
- validation = validateFieldWithResult(getType(type), optionValue, {
2410
- has_to_have_value: true,
2411
- optionSchema: options,
2412
- options: availableOptions,
2413
- ...options?.[optionName],
2414
- // The expression flag lives on the field value, not the schema.
2415
- isFunction: (option as { is_expression?: boolean }).is_expression,
2416
- hasOwnEditor: isRendererOnly(options?.[optionName]?.ui_type as TQorusType),
2417
- } as any);
2411
+ if (
2412
+ isEmpty &&
2413
+ ((!isRequired && !hasRequiredGroups) || dependencyLockedNames.includes(optionName))
2414
+ ) {
2415
+ validation = { isValid: true, reasons: [] };
2416
+ } else {
2417
+ validation = validateFieldWithResult(getType(type), optionValue, {
2418
+ has_to_have_value: true,
2419
+ optionSchema: options,
2420
+ options: availableOptions,
2421
+ ...options?.[optionName],
2422
+ // The expression flag lives on the field value, not the schema.
2423
+ isFunction: (option as { is_expression?: boolean }).is_expression,
2424
+ hasOwnEditor: isRendererOnly(options?.[optionName]?.ui_type as TQorusType),
2425
+ } as any);
2426
+ }
2418
2427
  }
2419
2428
 
2420
2429
  result.push({
@@ -3103,6 +3112,9 @@ const FormEngineImpl = ({
3103
3112
  // reqraft: form-level expression fields get the Visual/Text shell
3104
3113
  // (DPQL text mode); opt out per-form via `templateFieldProps`.
3105
3114
  allowTextExpressions
3115
+ // a form's options enter their value on tabs: Value · Expression · Visual, or Value · Template
3116
+ // where only templates are taken (qorus#646); opt out per-form via `templateFieldProps`
3117
+ valueTabs
3106
3118
  // A fixed allowed-value field still needs its selector even when
3107
3119
  // arbitrary custom values are forbidden. TemplateField uses this
3108
3120
  // flag to decide whether to mount AutoFormField at all; treating
@@ -3270,9 +3282,35 @@ const FormEngineImpl = ({
3270
3282
  // The closure surface the extracted CompactRow reads through context. Refs and
3271
3283
  // setters are stable; the state/memo/handler fields change identity as they do
3272
3284
  // today, so a row re-renders exactly when its inputs do.
3285
+ /* An open field is held in the box it was opened in, so finishing an edit does not remount it elsewhere
3286
+ and take the focus (see `stableBucketOf`). Held, it says where it now belongs - otherwise a value
3287
+ already right sat under "Needs attention" until ✓ was pressed, with nothing saying why (David's review
3288
+ of qorus#646). Keyed by option name: the box it moves to when closed.
3289
+
3290
+ It is said once per form, on the first field held this way, and on no field once that one is closed:
3291
+ the user knows it from then on, and saying it on every field takes room (qlip build 20261008-083118;
3292
+ David's decision). `movesToNoteFor` is that field - undefined until one is held, null once it is
3293
+ closed. */
3294
+ const heldEntries = expandedOptions
3295
+ .map((name) => [name, settledBucket.current[name], getOptionBucket(name)] as const)
3296
+ .filter(([, held, now]) => !!held && held !== now)
3297
+ .map(([name, , now]) => [name, now] as const);
3298
+ if (movesToNoteFor.current === undefined && heldEntries.length) {
3299
+ movesToNoteFor.current = heldEntries[0][0];
3300
+ } else if (movesToNoteFor.current && !expandedOptions.includes(movesToNoteFor.current)) {
3301
+ movesToNoteFor.current = null;
3302
+ }
3303
+ const heldOptionsKey = JSON.stringify(
3304
+ Object.fromEntries(heldEntries.filter(([name]) => name === movesToNoteFor.current))
3305
+ );
3306
+ const heldOptions = useMemo<Record<string, 'attention' | 'set' | 'optional'>>(
3307
+ () => JSON.parse(heldOptionsKey),
3308
+ [heldOptionsKey]
3309
+ );
3273
3310
  const compactRowContextValue = useMemo<ICompactRowContext>(
3274
3311
  () => ({
3275
3312
  templates: templates.value,
3313
+ heldOptions,
3276
3314
  readOnly,
3277
3315
  onReadOnlyActivate,
3278
3316
  commitMode,
@@ -3322,6 +3360,7 @@ const FormEngineImpl = ({
3322
3360
  }),
3323
3361
  [
3324
3362
  templates.value,
3363
+ heldOptions,
3325
3364
  readOnly,
3326
3365
  commitMode,
3327
3366
  expandMode,
@@ -4245,7 +4284,10 @@ const FormEngineImpl = ({
4245
4284
  tested. Rendered as `data-wait` on the placeholder so a page can be asked
4246
4285
  what it is waiting FOR instead of only that it is waiting. */
4247
4286
  const waitReason =
4248
- rest.skeleton ? (rest.skeletonReason ? `host:${rest.skeletonReason}` : 'host')
4287
+ rest.skeleton ?
4288
+ rest.skeletonReason ?
4289
+ `host:${rest.skeletonReason}`
4290
+ : 'host'
4249
4291
  : templates.loading ? 'templates'
4250
4292
  : typesLoading ? 'types'
4251
4293
  : optionsLoading ? 'options'
@@ -633,6 +633,11 @@ export const InjectedOptionActionsEditCard: Story = {
633
633
  document.querySelector('.options-readfirst-card .option-branch-action')
634
634
  ).toBeTruthy();
635
635
  });
636
+
637
+ // the card's name is centred on its actions, not above them (David's review of qorus#646)
638
+ const header = document.querySelector('.options-readfirst-card-header') as HTMLElement;
639
+ const [heading, actions] = [...header.children].map((el) => el.getBoundingClientRect());
640
+ await expect(Math.abs(heading.top + heading.height / 2 - (actions.top + actions.height / 2))).toBeLessThanOrEqual(2);
636
641
  },
637
642
  };
638
643
 
@@ -356,7 +356,16 @@ export const getOptionFieldMessages = ({
356
356
  !!optionSchema?.depends_on &&
357
357
  !hasAllDependenciesFullfilled(optionSchema.depends_on, allOptions, schema);
358
358
 
359
- if (option.value || option.value === false || option.value === 0 || option.value === null) {
359
+ /* The host's own words for what is wrong with this field (`invalid_reason`) are said in place of the
360
+ field's own reason and its "This field is required": a host that has a sentence of its own, in the
361
+ user's language, showed it beside the field's generic one, two messages for one problem (qorus#646).
362
+ Required groups and dependencies still say what they say. */
363
+ const hostReason = (optionSchema as { invalid_reason?: string } | undefined)?.invalid_reason;
364
+ const hasValue = option.value || option.value === false || option.value === 0 || option.value === null;
365
+
366
+ if (hostReason) {
367
+ result.push({ label: hostReason, intent: 'danger' });
368
+ } else if (hasValue) {
360
369
  /* A concrete type the VALUE carries beats an untyped schema.
361
370
  `auto`/`any` declare no type — the author picks one (Binary, Date, …)
362
371
  and it is recorded beside the value. Reading the schema first validated
@@ -396,7 +405,9 @@ export const getOptionFieldMessages = ({
396
405
  if (optionSchema?.required && !locked) {
397
406
  result.push({ label: 'This field is required', intent: 'danger' });
398
407
  }
408
+ }
399
409
 
410
+ if (!hasValue) {
400
411
  if (
401
412
  optionSchema?.required_groups &&
402
413
  !validateOptionWithRequiredGroups(allOptions, schema, optionSchema.required_groups)
@@ -69,6 +69,8 @@ export interface ICompactRowContext {
69
69
  // display name in the read-first chip.
70
70
  templates?: IReqoreFormTemplates;
71
71
  expandedOptions: string[];
72
+ /** Open fields held in the box they were opened in, with the box each moves to when it is closed. */
73
+ heldOptions?: Record<string, 'attention' | 'set' | 'optional'>;
72
74
  highlightedOptions: string[];
73
75
  flashedOptions: string[];
74
76
  infoPanelOverrides: Record<string, boolean>;
@@ -4,6 +4,7 @@ import {
4
4
  TQorusFormFieldSchema,
5
5
  TQorusType,
6
6
  } from '@qoretechnologies/ts-toolkit';
7
+ import { isValueTemplate } from '../../../helpers/templates';
7
8
  import { isRendererOnlyUiType } from './rendererTypes';
8
9
  import { isEmptyUiEnvelope, isUiEncodedValue } from './_structuredData/structuredData';
9
10
  import { renderExpressionToText } from '../expressions/renderExpressionToText';
@@ -642,7 +643,12 @@ export const formatOptionValue = (
642
643
 
643
644
  const type = getValueType(option, schema);
644
645
 
645
- if (type === 'bool' || type === 'boolean') {
646
+ /* A template is the field it names, in a yes/no as in any type: it is not "Yes" because it is not
647
+ empty. Only a value that is true or false reads as Yes or No (qorus#646). */
648
+ if (
649
+ (type === 'bool' || type === 'boolean') &&
650
+ !(typeof value === 'string' && isValueTemplate(value))
651
+ ) {
646
652
  return value ? 'Yes' : 'No';
647
653
  }
648
654
 
@@ -659,6 +659,37 @@ export const TextMode: Story = {
659
659
  },
660
660
  };
661
661
 
662
+ /** A comparison built in the Visual view and left unfinished, seen in the Text view. */
663
+ export const TextModeOfAnUnfinishedExpression: Story = {
664
+ args: {
665
+ value: {
666
+ is_expression: true,
667
+ // ">" with its first operand switched to "Use Expression", nothing chosen, and its second empty
668
+ value: { exp: '>', args: [{ value: { args: [null] }, is_expression: true }, {}] },
669
+ },
670
+ defaultMode: 'text',
671
+ },
672
+ parameters: {
673
+ docs: {
674
+ description: {
675
+ story:
676
+ 'Renders ExpressionField in Text mode over a comparison left unfinished in the Visual view: an operand switched to Use Expression with no operation chosen, and an empty one. The text shows a hole for each, "… > …", as the row summary does.',
677
+ },
678
+ },
679
+ },
680
+ async play({ canvasElement }) {
681
+ await waitFor(
682
+ () => {
683
+ const el = canvasElement.querySelector('[contenteditable="true"]');
684
+ expect(el?.textContent).toBe('… > …');
685
+ },
686
+ { timeout: 6000 }
687
+ );
688
+ expect(canvasElement.textContent).not.toContain('[object Object]');
689
+ await waitForLspIdle(canvasElement);
690
+ },
691
+ };
692
+
662
693
  /**
663
694
  * A plain `bool` field with `supports_expressions` and no value — starts as
664
695
  * the normal field; the "Use Expression" toggle (More menu) switches it.
@@ -757,10 +788,10 @@ const settle = (root: HTMLElement, quietMs = 400): Promise<void> =>
757
788
  });
758
789
 
759
790
  /** Open a compact row — a read-first row shows its label, not its editor. */
760
- const openCompactRow = async (canvasElement: HTMLElement): Promise<void> => {
791
+ const openCompactRow = async (canvasElement: HTMLElement, rowLabel = 'Value'): Promise<void> => {
761
792
  const label = (await waitFor(
762
793
  () => {
763
- const el = within(canvasElement).queryByText('Value');
794
+ const el = within(canvasElement).queryByText(rowLabel);
764
795
  if (!el) throw new Error('row not rendered');
765
796
  return el as HTMLElement;
766
797
  },
@@ -1108,7 +1139,7 @@ export const FieldMenuStaysInTheToolbar: Story = {
1108
1139
  docs: {
1109
1140
  description: {
1110
1141
  story:
1111
- "Accepts the offer on a compact row whose field also has templates. The row shows ONE More menu — the field publishes its items into the row's ⋮ rather than drawing its own — and that menu sits in the shell's toolbar row, level with Undo, not part-way down the editor.",
1142
+ "Types 1 + 2 on a compact row whose field also has templates: the field enters its value on tabs (Value · Expression · Visual), and the expression moves it to Expression. The row shows ONE More menu — the field publishes its items into the row's ⋮ rather than drawing its own — and that menu sits in the tabs' row, not part-way down the editor.",
1112
1143
  },
1113
1144
  },
1114
1145
  },
@@ -1128,7 +1159,7 @@ export const FieldMenuStaysInTheToolbar: Story = {
1128
1159
  expected: {
1129
1160
  type: 'string',
1130
1161
  ui_type: 'string',
1131
- display_name: 'Value',
1162
+ display_name: 'Expected',
1132
1163
  preselected: true,
1133
1164
  supports_expressions: true,
1134
1165
  supports_templates: true,
@@ -1150,8 +1181,25 @@ export const FieldMenuStaysInTheToolbar: Story = {
1150
1181
  );
1151
1182
  },
1152
1183
  async play({ canvasElement }) {
1153
- await openCompactRow(canvasElement);
1154
- await acceptTypedExpression(canvasElement);
1184
+ await openCompactRow(canvasElement, 'Expected');
1185
+ const input = (await waitFor(
1186
+ () => {
1187
+ const el = canvasElement.querySelector('.value-tab-text [contenteditable="true"]');
1188
+ if (!el) throw new Error('field not ready');
1189
+ return el as HTMLElement;
1190
+ },
1191
+ { timeout: 10000 }
1192
+ )) as HTMLElement;
1193
+ await userEvent.click(input);
1194
+ await userEvent.type(input, '1 + 2');
1195
+ // the Value tab never holds an expression: the field moves to Expression, in the tabs' row
1196
+ await waitFor(
1197
+ () =>
1198
+ expect(
1199
+ canvasElement.querySelector('.value-tab[aria-pressed="true"]')?.getAttribute('data-tab')
1200
+ ).toBe('Expression'),
1201
+ { timeout: 10000 }
1202
+ );
1155
1203
 
1156
1204
  // One menu per control: the row's ⋮, not a second one drawn by the field.
1157
1205
  expect(canvasElement.querySelector('.template-more')).toBeNull();
@@ -1159,9 +1207,9 @@ export const FieldMenuStaysInTheToolbar: Story = {
1159
1207
  expect(menus).toHaveLength(1);
1160
1208
  const menu = menus[0] as HTMLElement;
1161
1209
 
1162
- // The toolbar row is the shell's first line — the Visual/Text toggle.
1163
- const toggle = within(canvasElement).getByText('Text').closest('button') as HTMLElement;
1164
- const toolbar = toggle.getBoundingClientRect();
1210
+ // The toolbar row is the field's first line — its tabs.
1211
+ const tabs = canvasElement.querySelector('.value-tabs') as HTMLElement;
1212
+ const toolbar = tabs.getBoundingClientRect();
1165
1213
  const control = menu.getBoundingClientRect();
1166
1214
 
1167
1215
  /* Reported as numbers: "the menu is in the wrong place" is not something a