@qoretechnologies/reqraft 0.10.50-pr.115.gf7dfde4 → 0.10.50

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.
@@ -2588,7 +2588,7 @@ export const CompactBasic: Story = {
2588
2588
  docs: {
2589
2589
  description: {
2590
2590
  story:
2591
- "Renders FormEngine in compact mode over the full Basic fixture — every value renders in its read-first form (templates by name, colours as hex, hashes as field-count summaries), disabled and dependency-locked rows stay non-interactive, and the dependency lock's popover navigates to blockers.",
2591
+ "Renders FormEngine in compact mode over the full Basic fixture — every value renders in its read-first form (templates by name, colours as hex, hashes as field-count summaries), disabled and dependency-locked rows stay non-interactive, and the dependency lock's popover navigates to blockers. The play scrolls the richtext editor into view to type into it, so the captured frame sits mid-form.",
2592
2592
  },
2593
2593
  },
2594
2594
  chromatic: { disable: true },
@@ -3989,7 +3989,7 @@ export const CompactOverflowAndStickyHeader: Story = {
3989
3989
  docs: {
3990
3990
  description: {
3991
3991
  story:
3992
- 'Renders a compact form tall enough to scroll — the group headers stick to the top of the panel as the form scrolls under them.',
3992
+ 'Renders a compact form with `compactScroll="own"` — the form caps at the host height and scrolls its own body, and its toolbar stays pinned to the top of that body.',
3993
3993
  },
3994
3994
  },
3995
3995
  chromatic: { disable: true },
@@ -4005,6 +4005,9 @@ export const CompactOverflowAndStickyHeader: Story = {
4005
4005
  ],
4006
4006
  args: {
4007
4007
  compact: true,
4008
+ // The opt-in branch: this story is about the form owning its scroll, so it
4009
+ // asks for it. The default is `'host'` — see CompactHostOwnedScroll.
4010
+ compactScroll: 'own' as const,
4008
4011
  minColumnWidth: '300px',
4009
4012
  options: CompactScrollableSchema,
4010
4013
  groups: CompactGroups,
@@ -4044,6 +4047,170 @@ export const CompactOverflowAndStickyHeader: Story = {
4044
4047
  },
4045
4048
  };
4046
4049
 
4050
+ // The default branch of `compactScroll`. Two compact forms inside one scrolling
4051
+ // host is the shape that exposed this: each form used to cap at the host height
4052
+ // and scroll its own body, so a page with two of them showed three scrollbars in
4053
+ // two styles and the reader could not tell which moved what.
4054
+ export const CompactHostOwnedScroll: Story = {
4055
+ parameters: {
4056
+ docs: {
4057
+ description: {
4058
+ story:
4059
+ 'Renders two compact forms inside one scrolling host. With the default `compactScroll="host"` neither form scrolls itself — the host owns the only scrollbar — and each form\'s toolbar still pins to the top of the host as it scrolls.',
4060
+ },
4061
+ },
4062
+ chromatic: { disable: true },
4063
+ },
4064
+ // A COLUMN FLEX host with a definite height — the shape real consumers use
4065
+ // (qorus-ide's ContentWrapper is exactly this). It matters: a plain block
4066
+ // host does not exercise the flex shrink allowance, so the zero-height
4067
+ // collapse assertion below cannot fail there and the test would be worthless.
4068
+ decorators: [
4069
+ (StoryComponent: React.ComponentType) => (
4070
+ <div
4071
+ style={{ height: 400, overflow: 'auto', display: 'flex', flexFlow: 'column' }}
4072
+ data-testid='compact-scroll-host'
4073
+ >
4074
+ <StoryComponent />
4075
+ <StoryComponent />
4076
+ </div>
4077
+ ),
4078
+ ],
4079
+ args: {
4080
+ compact: true,
4081
+ minColumnWidth: '300px',
4082
+ options: CompactScrollableSchema,
4083
+ groups: CompactGroups,
4084
+ value: CompactValue,
4085
+ },
4086
+ play: async () => {
4087
+ await _testsWaitForText('order-fulfilment');
4088
+ const host = document.querySelector('[data-testid="compact-scroll-host"]') as HTMLElement;
4089
+ const wraps = Array.from(
4090
+ document.querySelectorAll('.options-readfirst-scroll')
4091
+ ) as HTMLElement[];
4092
+ await expect(wraps.length).toBe(2);
4093
+
4094
+ // (a) No form owns a scroll context — `overflow-x: clip` rather than
4095
+ // `hidden` matters here: `hidden` would coerce `overflow-y: visible` back
4096
+ // to `auto`, leaving each form a scroll container whose sticky toolbar
4097
+ // silently stops pinning to the host.
4098
+ for (const wrap of wraps) {
4099
+ await expect(getComputedStyle(wrap).overflowY).toBe('visible');
4100
+ await expect(wrap.scrollHeight).toBeLessThanOrEqual(wrap.clientHeight + 1);
4101
+ }
4102
+
4103
+ // (b) Each form still OCCUPIES its content. Releasing the scroll without
4104
+ // also releasing the flex shrink allowance collapses the wrap to zero
4105
+ // height: the rows paint outside it, so it looks correct while the host
4106
+ // sizes and scrolls to nothing. Assert the box, not just the pixels.
4107
+ for (const wrap of wraps) {
4108
+ const child = wrap.firstElementChild as HTMLElement;
4109
+ await expect(wrap.getBoundingClientRect().height).toBeGreaterThan(0);
4110
+ await expect(wrap.getBoundingClientRect().height).toBeCloseTo(
4111
+ child.getBoundingClientRect().height,
4112
+ 0
4113
+ );
4114
+ }
4115
+
4116
+ // (c) Exactly one element on the page scrolls, and it is the host.
4117
+ const scrollers = (Array.from(document.querySelectorAll('*')) as HTMLElement[]).filter(
4118
+ (el) =>
4119
+ /(auto|scroll)/.test(getComputedStyle(el).overflowY) &&
4120
+ el.scrollHeight > el.clientHeight + 4 &&
4121
+ el.clientHeight > 80
4122
+ );
4123
+ await expect(scrollers).toEqual([host]);
4124
+
4125
+ // (d) The toolbar still pins — the guarantee the self-scroll existed for.
4126
+ host.scrollTop = 300;
4127
+ await waitFor(() => {
4128
+ const search = document.querySelector('input[placeholder="Filter fields..."]') as HTMLElement;
4129
+ const hostTop = host.getBoundingClientRect().top;
4130
+ expect(search.getBoundingClientRect().top).toBeGreaterThanOrEqual(hostTop - 1);
4131
+ });
4132
+ },
4133
+ };
4134
+
4135
+ // The PADDED twin of CompactHostOwnedScroll. It is a separate story because the
4136
+ // unpadded one cannot fail for this: `position: sticky; top: 0` resolves against
4137
+ // a scrollport's CONTENT box, so only a host WITH padding can reveal a toolbar
4138
+ // pinning below it. Needs reqore >= 0.74.1, which measures that padding and
4139
+ // compensates the sticky line (reqore#648).
4140
+ export const CompactHostOwnedScrollPaddedHost: Story = {
4141
+ parameters: {
4142
+ docs: {
4143
+ description: {
4144
+ story:
4145
+ "The same two compact forms in one scrolling host, but the host carries its own 24px padding. The host still owns the only scrollbar, and each form's toolbar still pins flush to the host's visible top edge rather than below its padding.",
4146
+ },
4147
+ },
4148
+ chromatic: { disable: true },
4149
+ },
4150
+ decorators: [
4151
+ (StoryComponent: React.ComponentType) => (
4152
+ <div
4153
+ style={{
4154
+ height: 400,
4155
+ overflow: 'auto',
4156
+ display: 'flex',
4157
+ flexFlow: 'column',
4158
+ padding: 24,
4159
+ }}
4160
+ data-testid='compact-scroll-host'
4161
+ >
4162
+ <StoryComponent />
4163
+ <StoryComponent />
4164
+ </div>
4165
+ ),
4166
+ ],
4167
+ args: {
4168
+ compact: true,
4169
+ minColumnWidth: '300px',
4170
+ options: CompactScrollableSchema,
4171
+ groups: CompactGroups,
4172
+ value: CompactValue,
4173
+ },
4174
+ play: async () => {
4175
+ await _testsWaitForText('order-fulfilment');
4176
+ const host = document.querySelector('[data-testid="compact-scroll-host"]') as HTMLElement;
4177
+ const wraps = Array.from(
4178
+ document.querySelectorAll('.options-readfirst-scroll')
4179
+ ) as HTMLElement[];
4180
+ await expect(wraps.length).toBe(2);
4181
+
4182
+ // Still exactly one scroller, and no form owns one — the padding must not
4183
+ // buy a flush toolbar back by handing the scroll to the form.
4184
+ for (const wrap of wraps) {
4185
+ await expect(getComputedStyle(wrap).overflowY).toBe('visible');
4186
+ await expect(wrap.getBoundingClientRect().height).toBeGreaterThan(0);
4187
+ }
4188
+ const scrollers = (Array.from(document.querySelectorAll('*')) as HTMLElement[]).filter(
4189
+ (el) =>
4190
+ /(auto|scroll)/.test(getComputedStyle(el).overflowY) &&
4191
+ el.scrollHeight > el.clientHeight + 4 &&
4192
+ el.clientHeight > 80
4193
+ );
4194
+ await expect(scrollers).toEqual([host]);
4195
+
4196
+ // The toolbar pins to the host's VISIBLE top edge, not to its padding box.
4197
+ // Measure the STICKY ELEMENT — the panel title. The "Filter fields..." input
4198
+ // lives inside that header below the completion meter, so it sits ~69px
4199
+ // lower by design; asserting on it measures the header's own internals and
4200
+ // says nothing about where the header pinned.
4201
+ const stickyHeader = wraps[0].firstElementChild!.querySelector(
4202
+ '.reqore-panel-title'
4203
+ ) as HTMLElement;
4204
+ host.scrollTop = 300;
4205
+ await waitFor(() => {
4206
+ const line =
4207
+ host.getBoundingClientRect().top + (parseFloat(getComputedStyle(host).borderTopWidth) || 0);
4208
+ // Two-sided: a `>=` bound passes at +24, which is exactly the bug.
4209
+ expect(Math.abs(stickyHeader.getBoundingClientRect().top - line)).toBeLessThanOrEqual(2);
4210
+ });
4211
+ },
4212
+ };
4213
+
4047
4214
  // on_change/refetch + has_dependents flow through the same handleValueChange
4048
4215
  // as classic — the read-first editor must fire and reset the same way.
4049
4216
  export const CompactOnChangeAndDependents: Story = {
@@ -4616,7 +4783,7 @@ export const CompactFieldTypes: Story = {
4616
4783
  docs: {
4617
4784
  description: {
4618
4785
  story:
4619
- 'Renders a compact form that exercises the full catalogue of ui_type renderers — every type (string, richtext, hash, list, file, colour, byte-size, cron, connection, enum, etc.) is present with a representative value.',
4786
+ "Renders a compact form that exercises the full catalogue of ui_type renderers — every type (string, richtext, hash, list, file, colour, byte-size, cron, connection, enum, etc.) is present with a representative value. The play leaves the `String` row expanded with its editor focused; the form leaves scrolling to its host (`compactScroll` defaults to `'host'`), so that focus scrolls the page — the captured frame sits mid-form with the toolbar pinned over the rows, not at the top of the form.",
4620
4787
  },
4621
4788
  },
4622
4789
  },
@@ -5197,7 +5364,15 @@ export const CompactFieldTypesEditing: Story = {
5197
5364
  chromatic: { disable: true },
5198
5365
  },
5199
5366
  // multi: this story expands every row at once (single-open would collapse them).
5200
- args: { ...CompactFieldTypes.args, expandMode: 'multi' as const },
5367
+ //
5368
+ // `compactScroll: 'own'` bounds the form's height. With every editor open the
5369
+ // document is long, and the play clicks its way down all of them — each click
5370
+ // scrolling and hit-testing a taller page. Measured at ~8.5s bounded against
5371
+ // ~12.5s unbounded, and this story already spent 17.2s of its 30s budget in
5372
+ // CI, so the unbounded cost times it out. Nothing here is about scroll
5373
+ // ownership, so bounding it loses no coverage; the default is exercised by
5374
+ // CompactHostOwnedScroll.
5375
+ args: { ...CompactFieldTypes.args, expandMode: 'multi' as const, compactScroll: 'own' as const },
5201
5376
  play: async () => {
5202
5377
  await _testsWaitForText('hello');
5203
5378
  await _compactExpandAllRows();
@@ -17,7 +17,6 @@ import { IReqoreCollectionProps } from '@qoretechnologies/reqore/dist/components
17
17
  import { IReqoreCollectionItemProps } from '@qoretechnologies/reqore/dist/components/Collection/item';
18
18
  import { IReqorePanelProps } from '@qoretechnologies/reqore/dist/components/Panel';
19
19
  import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea';
20
- import { TFieldWithOwnTemplates } from './rendererTypes';
21
20
  import { TReqoreIntent } from '@qoretechnologies/reqore/dist/constants/theme';
22
21
  import {
23
22
  changeDarkness,
@@ -48,7 +47,7 @@ import reduce from 'lodash/reduce';
48
47
  import size from 'lodash/size';
49
48
  import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
50
49
  import { useMeasure, useMount, useUpdateEffect } from 'react-use';
51
- import styled from 'styled-components';
50
+ import styled, { css } from 'styled-components';
52
51
  import { createContext } from 'use-context-selector';
53
52
  import {
54
53
  fixOperatorValue,
@@ -234,7 +233,7 @@ const PositiveColorEffect: any = {
234
233
  // Compact (read-first) layout: flat label | value | action rows in collapsible
235
234
  // group panels; colours come in as props so the layout follows the Reqore theme.
236
235
 
237
- const StyledCompactWrap = styled.div<{ $flush?: boolean }>`
236
+ const StyledCompactWrap = styled.div<{ $ownScroll?: boolean }>`
238
237
  display: flex;
239
238
  flex-flow: column;
240
239
  gap: 10px;
@@ -243,21 +242,48 @@ const StyledCompactWrap = styled.div<{ $flush?: boolean }>`
243
242
  engage instead of overflowing the container horizontally. */
244
243
  min-width: 0;
245
244
  max-width: 100%;
246
- /* Own our scroll context instead of borrowing the host's. The sticky toolbar
247
- pins to whatever scrolls; if that scroller carries top padding (e.g. a
248
- ReqorePanel/ReqoreContent body), sticky \`top: 0\` resolves against its
249
- padding box and leaves an unblurred strip above the toolbar. By scrolling
250
- here — an UNPADDED box — the toolbar always pins flush and content ghosts
251
- cleanly beneath it, regardless of host padding (mirrors how the IDE
252
- dashboard keeps its StyledScrollBody scroller unpadded).
253
- We cap at the host's available height and scroll past it, but do NOT grow
254
- to fill it — short forms still hug their content. When the host height is
255
- indefinite, \`max-height: 100%\` resolves to none and the box grows so the
256
- host scrolls as before (graceful fallback). */
257
- min-height: 0;
258
- max-height: 100%;
259
- overflow-y: auto;
260
- overflow-x: hidden;
245
+
246
+ /* Scroll ownership — see \`compactScroll\`.
247
+
248
+ 'host' (the default): the page or panel around us already scrolls, so we
249
+ must not cap our height and scroll our own body, or the reader gets two
250
+ scrollbars in two styles and cannot tell which moves what.
251
+
252
+ \`overflow-x: clip\`, NOT \`hidden\`: CSS coerces an \`overflow-y: visible\`
253
+ to \`auto\` whenever the other axis is neither visible nor clip, which would
254
+ leave this box a scroll container — the element stops scrolling only
255
+ because \`max-height: none\` means nothing overflows, while \`position:
256
+ sticky\` inside it still resolves against US instead of the host, so the
257
+ toolbar silently stops pinning. \`clip\` keeps the horizontal guard without
258
+ that coercion. */
259
+ ${({ $ownScroll }) =>
260
+ $ownScroll
261
+ ? css`
262
+ /* 'own': we are the scroller. The sticky toolbar pins to whatever
263
+ scrolls, so a host whose scrollport carries top padding would
264
+ resolve sticky \`top: 0\` against its padding box and leave an
265
+ unblurred strip above the toolbar. Scrolling in this unpadded box
266
+ pins it flush. Cap at the host's available height, but do not grow
267
+ to fill it — short forms still hug their content.
268
+
269
+ \`min-height: 0\` is what lets a flex item shrink below its content
270
+ so there is something to scroll; it belongs to this branch only. */
271
+ min-height: 0;
272
+ max-height: 100%;
273
+ overflow-y: auto;
274
+ overflow-x: hidden;
275
+ `
276
+ : css`
277
+ /* \`min-height: auto\` is load-bearing, not a default: inside a column
278
+ flex parent the shrink allowance above collapses this box to zero
279
+ height while its rows paint outside it, so the form looks right but
280
+ contributes nothing to layout — the host cannot size or scroll to
281
+ it and anything below it stacks against nothing. */
282
+ min-height: auto;
283
+ max-height: none;
284
+ overflow-y: visible;
285
+ overflow-x: clip;
286
+ `}
261
287
 
262
288
  /* Option logos (e.g. language images) render as <img> inside ReqoreIcon's
263
289
  square box; constrain them so portrait PNGs don't overflow the row. */
@@ -627,13 +653,6 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
627
653
  recordRequiresSearchOptions?: boolean;
628
654
  readOnly?: boolean;
629
655
  allowTemplates?: boolean;
630
- /**
631
- * The form's SHARED template vocabulary — config items, system properties —
632
- * offered to every field that supports templates.
633
- *
634
- * A field may also declare `templates` of its own in the schema, and that
635
- * narrower list wins for that field: see {@link TFieldWithOwnTemplates}.
636
- */
637
656
  stringTemplates?: IReqoreFormTemplates;
638
657
  /** Opt-in: fetch global templates from `system/getContextData` for this context. */
639
658
  interfaceContext?: string;
@@ -654,6 +673,11 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
654
673
  * breathing room) so the form sits flush to its container's edges. For embeds
655
674
  * that own their own gutters — e.g. the SchemaDefinition tab body, where the
656
675
  * form should line up with the section description above it. Default `false`.
676
+ *
677
+ * @deprecated No-op. The wrap has never had the gutter this prop claims to
678
+ * drop — the value reached a styled template with no interpolation slot — so
679
+ * every form already renders flush. Kept so existing call sites keep
680
+ * compiling; do not add new ones.
657
681
  */
658
682
  compactFlush?: boolean;
659
683
  /**
@@ -662,8 +686,27 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
662
686
  * scroll context, so the toolbar isn't sticky and its header drops the dark
663
687
  * blurred backdrop (and the stacking context that goes with it) — it sits
664
688
  * transparently inside the parent's edit card. Default `false`.
689
+ *
690
+ * Implies `compactScroll: 'host'` — a nested sub-form never owns a scroller.
665
691
  */
666
692
  compactNested?: boolean;
693
+ /**
694
+ * Compact mode only: who owns the form's vertical scroll.
695
+ *
696
+ * - `'host'` (default) — the surrounding page, panel or drawer scrolls, and
697
+ * the form grows to its content. This is right almost everywhere: a form
698
+ * inside something that already scrolls must not cap its own height, or the
699
+ * reader gets two scrollbars side by side in two different styles and no
700
+ * way to tell which one moves what.
701
+ * - `'own'` — the form caps at the host's available height and scrolls its
702
+ * own body. Reach for this only when the host has a definite height and is
703
+ * NOT itself a scroll container (it would clip the form instead of
704
+ * scrolling it), or when the host's scrollport is padded and the sticky
705
+ * toolbar must pin flush against an unpadded box.
706
+ *
707
+ * Default `'host'`.
708
+ */
709
+ compactScroll?: 'own' | 'host';
667
710
  /**
668
711
  * Compact mode only: props forwarded to the read-first form's outer
669
712
  * `ReqorePanel` — the panel that carries the sticky toolbar header and holds
@@ -888,8 +931,15 @@ const FormEngineImpl = ({
888
931
  forceDropdown,
889
932
  showTypeToggle = true,
890
933
  compact,
934
+ // Destructured only to keep it out of `rest` (and so off the DOM). The gutter
935
+ // this prop documents was never implemented: it was passed to the wrap as
936
+ // `$flush`, whose styled template had no interpolation slot, so it has always
937
+ // been a no-op. Removing it from the API is a separate, breaking change —
938
+ // every consumer passing it today already gets the flush layout by default.
939
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
891
940
  compactFlush = false,
892
941
  compactNested = false,
942
+ compactScroll = 'host',
893
943
  compactPanelProps,
894
944
  compactCollapsedGroups = ['optional'],
895
945
  compactToolbar = true,
@@ -2391,23 +2441,7 @@ const FormEngineImpl = ({
2391
2441
  isFixedCompactAllowedValueOption(optionSchema) ||
2392
2442
  (options?.[optionName]?.supports_custom_values !== false && resolvedType !== 'any')
2393
2443
  }
2394
- // A field may declare a template list OF ITS OWN, and when it does
2395
- // that list is the one to offer. The engine-wide `stringTemplates`
2396
- // are the form's shared vocabulary - config items, system properties
2397
- // - and every field draws on the same set. A per-field list answers a
2398
- // question only that field asks: a test assertion's `$.` references
2399
- // are the values ITS case captured, and no other field in the form
2400
- // should be offering them.
2401
- //
2402
- // Passing the engine's list unconditionally overwrote the schema's,
2403
- // so a field that declared templates had none by the time it
2404
- // rendered. On a typed field that only cost the picker; on an
2405
- // ANY-LIKE one it changed the question, because `TemplateField`
2406
- // opens on the template selector only when there is something to
2407
- // pick and otherwise falls back to the type picker. An untyped field
2408
- // therefore asked the author to choose `string`/`int`/`hash` before
2409
- // it would let them name a value they had already captured.
2410
- templates={(optionSchema as TFieldWithOwnTemplates | undefined)?.templates ?? templates.value}
2444
+ templates={templates.value}
2411
2445
  {...getTypeAndCanBeNull(
2412
2446
  // The RENDERER type picks the editor — the storage type lives on
2413
2447
  // the value envelope. Passing storage here rendered a `richtext`
@@ -3095,9 +3129,9 @@ const FormEngineImpl = ({
3095
3129
  <StyledCompactWrap
3096
3130
  ref={setCompactWrap}
3097
3131
  className='options-readfirst-scroll'
3098
- // A nested sub-form sits flush inside the parent's card — no outer
3099
- // gutter (the card already provides the breathing room).
3100
- $flush={compactFlush || compactNested}
3132
+ // A nested sub-form is never the scroller — it sits inside the
3133
+ // parent form's card, which is inside whatever actually scrolls.
3134
+ $ownScroll={compactScroll === 'own' && !compactNested}
3101
3135
  >
3102
3136
  <StyledCompactPanel
3103
3137
  // Mirrors the toolbar's own search-row gate: when no search
@@ -1,16 +1,4 @@
1
1
  import { TQorusType } from '@qoretechnologies/ts-toolkit';
2
- import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea';
3
-
4
- /**
5
- * A field schema that carries a template list of its own.
6
- *
7
- * `templates` is deliberately NOT added to `TQorusFormFieldSchema`: that type is
8
- * owned by ts-toolkit and shared with consumers that have no notion of a form
9
- * engine, so the capability is read through this narrow view instead of widening
10
- * the shared type from here. A schema that sets it is offering a list scoped to
11
- * one field rather than to the whole form.
12
- */
13
- export type TFieldWithOwnTemplates = { templates?: IReqoreFormTemplates };
14
2
 
15
3
  /**
16
4
  * A schema entry carries two type-ish keys with different jobs: