@qoretechnologies/reqraft 0.10.59-pr.130.gf96dd6f → 0.10.59

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.
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ /**
3
+ * Bring a form row into view only when it is not already there, and never
4
+ * under a pinned header.
5
+ *
6
+ * A row that moves between status boxes (filling a field moves it from "Needs
7
+ * attention" to "Set") is followed so the reader does not lose it. That used
8
+ * to be `scrollIntoView({ block: 'center' })`, which scrolls even when the row
9
+ * is already in plain sight, and centres it against the whole scrollport while
10
+ * the box header pinned at the top hides part of it. In the qorus-ide schema
11
+ * editor the moved row landed with the "Needs attention" header (375-423)
12
+ * drawn over it (405).
13
+ *
14
+ * The visible area is the scrollport minus the pinned chrome above the row:
15
+ * the form's sticky toolbar and the sticky header of every status box the row
16
+ * sits in. Both are read from the DOM when the reveal runs, so a host's own
17
+ * offset, a toolbar that wraps, or a header that is not pinned at all are all
18
+ * accounted for without the caller knowing about them.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.revealRow = exports.isRowInView = exports.getRevealClearance = exports.getPinnedChromeHeight = exports.findScrollContainer = void 0;
22
+ /** Selectors of the elements whose header may be pinned above a row. */
23
+ var PINNED_HEADER_OWNERS = '.options-readfirst-group, .options-readfirst-scroll > .reqore-panel';
24
+ var isScrollable = function (element) {
25
+ var overflowY = getComputedStyle(element).overflowY;
26
+ return ((overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') &&
27
+ element.scrollHeight > element.clientHeight);
28
+ };
29
+ /** The nearest ancestor that actually scrolls; `undefined` means the viewport. */
30
+ var findScrollContainer = function (element) {
31
+ for (var parent_1 = element.parentElement; parent_1; parent_1 = parent_1.parentElement) {
32
+ if (parent_1 === document.body || parent_1 === document.documentElement) {
33
+ return undefined;
34
+ }
35
+ if (isScrollable(parent_1)) {
36
+ return parent_1;
37
+ }
38
+ }
39
+ return undefined;
40
+ };
41
+ exports.findScrollContainer = findScrollContainer;
42
+ /**
43
+ * How far below the top of the scrollport the pinned chrome above `element`
44
+ * reaches: the largest `top + height` of any sticky header of an enclosing
45
+ * status box or form toolbar. Zero when nothing above it is pinned.
46
+ */
47
+ var getPinnedChromeHeight = function (element) {
48
+ var _a, _b;
49
+ var clearance = 0;
50
+ for (var owner = (_a = element.parentElement) === null || _a === void 0 ? void 0 : _a.closest(PINNED_HEADER_OWNERS); owner; owner = (_b = owner.parentElement) === null || _b === void 0 ? void 0 : _b.closest(PINNED_HEADER_OWNERS)) {
51
+ var header = owner.querySelector(':scope > .reqore-panel-title');
52
+ if (!header || header.contains(element)) {
53
+ continue;
54
+ }
55
+ var style = getComputedStyle(header);
56
+ if (style.position !== 'sticky') {
57
+ continue;
58
+ }
59
+ var top_1 = parseFloat(style.top) || 0;
60
+ clearance = Math.max(clearance, top_1 + header.getBoundingClientRect().height);
61
+ }
62
+ return clearance;
63
+ };
64
+ exports.getPinnedChromeHeight = getPinnedChromeHeight;
65
+ /**
66
+ * How far below the top edge of the row's scrollport the pinned chrome reaches.
67
+ *
68
+ * A sticky `top` is measured from the scrollport's top edge INSET BY ITS
69
+ * PADDING, while `scrollIntoView` and `scroll-margin-top` align against the
70
+ * edge itself. Leaving the padding out put an opened row 16px under its box
71
+ * header inside a padded host scroller (the Storybook canvas, and any panel
72
+ * with padding).
73
+ */
74
+ var getRevealClearance = function (element) {
75
+ var container = (0, exports.findScrollContainer)(element);
76
+ var inset = container ? parseFloat(getComputedStyle(container).paddingTop) || 0 : 0;
77
+ return inset + (0, exports.getPinnedChromeHeight)(element);
78
+ };
79
+ exports.getRevealClearance = getRevealClearance;
80
+ /**
81
+ * Whether the whole of `element` is visible below `clearance` (measured from
82
+ * the top edge of its scrollport, see {@link getRevealClearance}).
83
+ */
84
+ var isRowInView = function (element, clearance) {
85
+ var container = (0, exports.findScrollContainer)(element);
86
+ var viewTop = container ? container.getBoundingClientRect().top + container.clientTop : 0;
87
+ var viewBottom = container ?
88
+ viewTop + container.clientHeight
89
+ : window.innerHeight || document.documentElement.clientHeight;
90
+ var rect = element.getBoundingClientRect();
91
+ return rect.top >= viewTop + clearance && rect.bottom <= viewBottom;
92
+ };
93
+ exports.isRowInView = isRowInView;
94
+ /**
95
+ * Scroll `element` the least distance that shows it clear of the pinned chrome
96
+ * above it — and not at all when it is already fully in view.
97
+ *
98
+ * `scroll-margin-top` is what keeps a row that has to come down from above from
99
+ * stopping under the header: `block: 'nearest'` aligns the row's margin box,
100
+ * so the row itself stops exactly below the pinned chrome.
101
+ *
102
+ * Returns whether a scroll was started.
103
+ */
104
+ var revealRow = function (element, behavior) {
105
+ if (behavior === void 0) { behavior = 'smooth'; }
106
+ // jsdom implements neither layout nor `scrollIntoView`; a missing reveal must
107
+ // never break a form.
108
+ if (typeof element.scrollIntoView !== 'function') {
109
+ return false;
110
+ }
111
+ var clearance = (0, exports.getRevealClearance)(element);
112
+ if ((0, exports.isRowInView)(element, clearance)) {
113
+ return false;
114
+ }
115
+ element.style.scrollMarginTop = "".concat(clearance, "px");
116
+ element.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: behavior });
117
+ return true;
118
+ };
119
+ exports.revealRow = revealRow;
120
+ //# sourceMappingURL=revealRow.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"revealRow.js","sourceRoot":"","sources":["../../../../src/components/form/engine/revealRow.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qoretechnologies/reqraft",
3
- "version": "0.10.59-pr.130.gf96dd6f",
3
+ "version": "0.10.59",
4
4
  "description": "ReQraft is a collection of React components and hooks that are used across Qore Technologies' products made using the ReQore component library from Qore.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -35,6 +35,7 @@ import { Description } from '../../Description';
35
35
  import { useMarkdownRenderer } from '../../Description/markdownRendererContext';
36
36
  import { FocusedEditing } from '../../FocusedEditing';
37
37
  import { CompactRowContext } from './compactRowContext';
38
+ import { revealRow } from './revealRow';
38
39
  import {
39
40
  StyledActionSlot,
40
41
  StyledCardHeading,
@@ -931,8 +932,11 @@ export const CompactRow = memo(
931
932
  }
932
933
  const control = findRowFocusTarget(editor);
933
934
  if (!control) return false;
934
- // Optional call: jsdom does not implement `scrollIntoView`.
935
- rowRef.current?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
935
+ // The least scroll that shows the row clear of the pinned box header,
936
+ // and none when it is already in view (see `revealRow`).
937
+ if (rowRef.current) {
938
+ revealRow(rowRef.current, 'auto');
939
+ }
936
940
  // `preventScroll`, because the reveal above has already put the row
937
941
  // where it belongs; letting focus scroll as well moves it twice.
938
942
  control.focus({ preventScroll: true });
@@ -147,7 +147,14 @@ export const CompactToolbar = memo((reqoreProps: Partial<IReqoreControlGroupProp
147
147
  const attentionPct = completion.total ? (attentionCount / completion.total) * 100 : 0;
148
148
 
149
149
  return (
150
- <ReqoreControlGroup {...reqoreProps} vertical fluid fixed={false} gapSize='big'>
150
+ <ReqoreControlGroup
151
+ {...reqoreProps}
152
+ className='options-readfirst-toolbar'
153
+ vertical
154
+ fluid
155
+ fixed={false}
156
+ gapSize='big'
157
+ >
151
158
  {/* No completion in a read-only form. The meter answers "how much is
152
159
  left to do", and a reader can do none of it: "4/6 set · 67%" over a
153
160
  form nobody can fill in is a progress report on somebody else's
@@ -23,6 +23,7 @@ import {
23
23
  _testsWaitForText,
24
24
  _testsWaitForTextsCount,
25
25
  _testsWaitForTextToNotExist,
26
+ _testsWithViewport,
26
27
  sleep,
27
28
  } from '../../../stories/Tests/utils';
28
29
  import { mockExpressions } from '../expressions/mockExpressions';
@@ -2934,6 +2935,99 @@ export const CompactWithPanelProps: Story = {
2934
2935
  },
2935
2936
  };
2936
2937
 
2938
+ /**
2939
+ * The outside-dressed panel, its toolbar and its rows all fit the width they
2940
+ * are given: nothing scrolls sideways, the label, the host's action and the
2941
+ * toolbar are on screen, and every row's label and value lie inside the form.
2942
+ */
2943
+ const assertPanelPropsFitNarrowWidth = async (canvasElement: HTMLElement) => {
2944
+ await _testsWaitForText('Connection settings');
2945
+ await _testsWaitForText('order-fulfilment');
2946
+ const form = canvasElement.querySelector<HTMLElement>('.options-readfirst-scroll')!;
2947
+ await waitFor(() => expect(form.querySelector('.readfirst-narrow')).toBeTruthy());
2948
+ const formRect = form.getBoundingClientRect();
2949
+ // No sideways scroll: not the page, not the form.
2950
+ expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(
2951
+ document.documentElement.clientWidth
2952
+ );
2953
+ expect(form.scrollWidth).toBeLessThanOrEqual(form.clientWidth + 1);
2954
+ const inside = (element: Element | null, what: string) => {
2955
+ expect(element, what).toBeTruthy();
2956
+ const rect = element!.getBoundingClientRect();
2957
+ expect(rect.width, `${what} is drawn`).toBeGreaterThan(0);
2958
+ expect(rect.left, `${what} starts inside the form`).toBeGreaterThanOrEqual(formRect.left - 1);
2959
+ expect(rect.right, `${what} ends inside the form`).toBeLessThanOrEqual(formRect.right + 1);
2960
+ };
2961
+ // The panel chrome supplied from outside, and the engine's own toolbar.
2962
+ const panelTitle = [...canvasElement.querySelectorAll<HTMLElement>('.reqore-panel-title')].find(
2963
+ (title) => (title.textContent || '').includes('Connection settings')
2964
+ )!;
2965
+ inside(panelTitle, 'the panel header');
2966
+ // The label keeps its width: squeezed to nothing was how it went missing.
2967
+ inside(panelTitle.querySelector('.reqore-panel-title-label-row'), 'the panel label');
2968
+ expect(
2969
+ panelTitle.querySelector('.reqore-panel-title-label-row')!.getBoundingClientRect().width
2970
+ ).toBeGreaterThan(100);
2971
+ inside(
2972
+ [...panelTitle.querySelectorAll('.reqore-button')].find((button) =>
2973
+ (button.textContent || '').includes('Docs')
2974
+ ) ?? null,
2975
+ 'the Docs action'
2976
+ );
2977
+ inside(form.querySelector('.options-readfirst-completion'), 'the completion meter');
2978
+ // Every row's label and value.
2979
+ const rows = [...form.querySelectorAll<HTMLElement>('.readfirst-row')];
2980
+ expect(rows.length).toBeGreaterThan(0);
2981
+ for (const row of rows) {
2982
+ inside(row, `row ${row.dataset.field}`);
2983
+ }
2984
+ };
2985
+
2986
+ export const CompactWithPanelPropsPhone: Story = {
2987
+ ...CompactWithPanelProps,
2988
+ parameters: {
2989
+ qlip: { viewport: { width: 390, height: 844 } },
2990
+ docs: {
2991
+ description: {
2992
+ story:
2993
+ 'Compact With Panel Props on a 390px phone: the dressed panel header, its Docs action, the toolbar and every row fit the screen, the rows stack their values under their names, and nothing scrolls sideways.',
2994
+ },
2995
+ },
2996
+ },
2997
+ play: async ({ canvasElement }) => {
2998
+ // The capture is taken at 390px after the play; the play narrows the
2999
+ // viewport itself so it asserts the layout that is captured.
3000
+ await _testsWithViewport(390, 844, async (narrowed) => {
3001
+ await _testsWaitForText('Connection settings');
3002
+ if (narrowed) {
3003
+ await assertPanelPropsFitNarrowWidth(canvasElement);
3004
+ }
3005
+ });
3006
+ },
3007
+ };
3008
+
3009
+ export const CompactWithPanelPropsNarrow: Story = {
3010
+ ...CompactWithPanelProps,
3011
+ parameters: {
3012
+ docs: {
3013
+ description: {
3014
+ story:
3015
+ 'Compact With Panel Props in a 360px column on a wide screen (a side panel or drawer): the same narrow layout as on a phone, decided by the width the form is given rather than by the screen.',
3016
+ },
3017
+ },
3018
+ },
3019
+ decorators: [
3020
+ (StoryComponent: React.ComponentType) => (
3021
+ <div style={{ maxWidth: 360, margin: '0 auto', border: '1px dashed #ffffff22' }}>
3022
+ <StoryComponent />
3023
+ </div>
3024
+ ),
3025
+ ],
3026
+ play: async ({ canvasElement }) => {
3027
+ await assertPanelPropsFitNarrowWidth(canvasElement);
3028
+ },
3029
+ };
3030
+
2937
3031
  export const CompactReadOnly: Story = {
2938
3032
  parameters: {
2939
3033
  docs: {
@@ -9354,3 +9448,258 @@ export const AReadOnlyFormShowsWhatIsWrong: Story = {
9354
9448
  });
9355
9449
  },
9356
9450
  };
9451
+
9452
+ /** A form taller than its 400px scroller: one empty required field, a required
9453
+ * Owner that is set, and a dozen set fields below them. */
9454
+ const MovedRowSchema: IOptionsSchema = {
9455
+ title: { type: 'string', display_name: 'Title', required: true },
9456
+ owner: { type: 'string', display_name: 'Owner', required: true },
9457
+ ...Object.fromEntries(
9458
+ Array.from({ length: 12 }, (_, index) => [
9459
+ `detail${index + 1}`,
9460
+ { type: 'string', display_name: `Detail ${index + 1}` },
9461
+ ])
9462
+ ),
9463
+ };
9464
+ const MovedRowValue: IOptions = {
9465
+ owner: { type: 'string', value: 'ops-team' },
9466
+ ...Object.fromEntries(
9467
+ Array.from({ length: 12 }, (_, index) => [
9468
+ `detail${index + 1}`,
9469
+ { type: 'string', value: `value ${index + 1}` },
9470
+ ])
9471
+ ),
9472
+ };
9473
+
9474
+ /** Renders the form with buttons that change a value from outside it, the way a
9475
+ * host's save or a server update does — which is what moves a collapsed row. */
9476
+ const MovedRowRender = ({ onChange, ...rest }: IFormEngineProps) => {
9477
+ const [val, setValue] = useState<IOptions>(MovedRowValue);
9478
+ return (
9479
+ <>
9480
+ <button type='button' onClick={() => setValue((v) => ({ ...v, title: { type: 'string', value: 'Quarterly report' } }))}>
9481
+ Fill title
9482
+ </button>
9483
+ <button type='button' onClick={() => setValue((v) => ({ ...v, owner: { type: 'string', value: '' } }))}>
9484
+ Clear owner
9485
+ </button>
9486
+ <div style={{ height: 400, overflow: 'hidden', display: 'flex', flexFlow: 'column' }}>
9487
+ <FormEngine
9488
+ {...rest}
9489
+ value={val}
9490
+ onChange={(_n, v, m) => {
9491
+ setValue(v as IOptions);
9492
+ onChange?.(_n, v, m);
9493
+ }}
9494
+ />
9495
+ </div>
9496
+ </>
9497
+ );
9498
+ };
9499
+
9500
+ /** Counts `scrollIntoView` calls on rows while a play runs. */
9501
+ const watchRowScrolls = () => {
9502
+ const original = HTMLElement.prototype.scrollIntoView;
9503
+ const scrolled: string[] = [];
9504
+ HTMLElement.prototype.scrollIntoView = function (this: HTMLElement, arg?: boolean | ScrollIntoViewOptions) {
9505
+ scrolled.push(this.getAttribute('data-field') || '');
9506
+ return original.call(this, arg);
9507
+ };
9508
+ return { scrolled, restore: () => (HTMLElement.prototype.scrollIntoView = original) };
9509
+ };
9510
+
9511
+ const movedRowScroller = () =>
9512
+ document.querySelector<HTMLElement>('.options-readfirst-scroll')!;
9513
+ const clickHostButton = (label: string) =>
9514
+ fireEvent.click([...document.querySelectorAll('button')].find((b) => b.textContent === label)!);
9515
+ const nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
9516
+
9517
+ export const AMovedRowInViewIsNotScrolled: Story = {
9518
+ parameters: {
9519
+ docs: {
9520
+ description: {
9521
+ story:
9522
+ 'Title is empty, so it sits under Needs attention. "Fill title" sets it from outside the form, and the row moves into Set, where it is already in plain view. The form follows the row with a flash but does not scroll, because nothing needs bringing into view.',
9523
+ },
9524
+ },
9525
+ },
9526
+ render: MovedRowRender,
9527
+ args: {
9528
+ compact: true,
9529
+ compactScroll: 'own' as const,
9530
+ minColumnWidth: '300px',
9531
+ options: MovedRowSchema,
9532
+ },
9533
+ play: async () => {
9534
+ await _testsWaitForText('Needs attention');
9535
+ const watch = watchRowScrolls();
9536
+ try {
9537
+ const scroller = movedRowScroller();
9538
+ const before = scroller.scrollTop;
9539
+ clickHostButton('Fill title');
9540
+ // The move is followed by a flash in the frame the reveal runs in: wait
9541
+ // for it, then one more frame so the reveal has certainly run.
9542
+ await waitFor(() =>
9543
+ expect(document.querySelector('.readfirst-row-flash[data-field="title"]')).toBeTruthy()
9544
+ );
9545
+ await nextFrame();
9546
+ expect(watch.scrolled).not.toContain('title');
9547
+ expect(scroller.scrollTop).toBe(before);
9548
+ } finally {
9549
+ watch.restore();
9550
+ }
9551
+ },
9552
+ };
9553
+
9554
+ export const AMovedRowLandsClearOfThePinnedHeader: Story = {
9555
+ parameters: {
9556
+ docs: {
9557
+ description: {
9558
+ story:
9559
+ 'The form is scrolled to its end, then "Clear owner" empties Owner, so its row moves up into Needs attention, out of view. The form scrolls the least distance that shows it, and the row stops just below the pinned "Needs attention" header instead of underneath it.',
9560
+ },
9561
+ },
9562
+ },
9563
+ render: MovedRowRender,
9564
+ args: {
9565
+ compact: true,
9566
+ compactScroll: 'own' as const,
9567
+ minColumnWidth: '300px',
9568
+ options: MovedRowSchema,
9569
+ },
9570
+ play: async () => {
9571
+ await _testsWaitForText('Detail 12');
9572
+ const scroller = movedRowScroller();
9573
+ const scrollEnd = () =>
9574
+ new Promise<void>((resolve) =>
9575
+ scroller.addEventListener('scrollend', () => resolve(), { once: true })
9576
+ );
9577
+ // Even an instant scroll ends with a `scrollend`; consume it here, or it
9578
+ // would be taken for the end of the reveal below.
9579
+ const atBottom = scrollEnd();
9580
+ scroller.scrollTop = scroller.scrollHeight;
9581
+ await atBottom;
9582
+ expect(scroller.scrollTop).toBeGreaterThan(0);
9583
+ const watch = watchRowScrolls();
9584
+ try {
9585
+ // The reveal is a smooth scroll; its end is the `scrollend` event, armed
9586
+ // before the click so it cannot be missed.
9587
+ const scrolled = scrollEnd();
9588
+ clickHostButton('Clear owner');
9589
+ await waitFor(() => expect(watch.scrolled).toContain('owner'));
9590
+ await scrolled;
9591
+ const row = document.querySelector<HTMLElement>('.readfirst-row[data-field="owner"]')!;
9592
+ const attention = [...document.querySelectorAll<HTMLElement>('.options-readfirst-group')].find(
9593
+ (group) => (group.textContent || '').startsWith('Needs attention')
9594
+ )!;
9595
+ const header = attention.querySelector<HTMLElement>(':scope > .reqore-panel-title')!;
9596
+ const rowRect = row.getBoundingClientRect();
9597
+ const viewRect = scroller.getBoundingClientRect();
9598
+ // Clear of the pinned header — and right below it, the least scroll that
9599
+ // shows the row, not centred in the scroller — and inside the scroller.
9600
+ const headerBottom = header.getBoundingClientRect().bottom;
9601
+ expect(rowRect.top).toBeGreaterThanOrEqual(headerBottom - 1);
9602
+ expect(rowRect.top).toBeLessThanOrEqual(headerBottom + 2);
9603
+ expect(rowRect.bottom).toBeLessThanOrEqual(viewRect.bottom + 1);
9604
+ } finally {
9605
+ watch.restore();
9606
+ }
9607
+ },
9608
+ };
9609
+
9610
+ /**
9611
+ * Scrolls the form so the end of its Needs attention box passes up through the
9612
+ * pinned toolbar, and asserts at each step that the toolbar stays on top: the
9613
+ * box header pinned below it is pushed up as its box scrolls away, and must
9614
+ * slide UNDER the toolbar, never over it. Each step waits on `scrollend`.
9615
+ */
9616
+ const assertBoxHeaderSlidesUnderToolbar = async (scroller: HTMLElement) => {
9617
+ await _testsWaitForText('Needs attention');
9618
+ const toolbar = document.querySelector<HTMLElement>(
9619
+ '.options-readfirst-scroll > .reqore-panel > .reqore-panel-title'
9620
+ )!;
9621
+ const box = [...document.querySelectorAll<HTMLElement>('.options-readfirst-group')].find(
9622
+ (group) => (group.textContent || '').startsWith('Needs attention')
9623
+ )!;
9624
+ const header = box.querySelector<HTMLElement>(':scope > .reqore-panel-title')!;
9625
+ const scrollBy = async (delta: number) => {
9626
+ const ended = new Promise<void>((resolve) =>
9627
+ scroller.addEventListener('scrollend', () => resolve(), { once: true })
9628
+ );
9629
+ scroller.scrollTop += delta;
9630
+ await ended;
9631
+ };
9632
+ // Box fully in view first: its header sits clear below the toolbar.
9633
+ expect(header.getBoundingClientRect().top).toBeGreaterThanOrEqual(
9634
+ toolbar.getBoundingClientRect().bottom - 1
9635
+ );
9636
+ // Bring the end of the box up to the toolbar's bottom edge, then past it.
9637
+ await scrollBy(box.getBoundingClientRect().bottom - toolbar.getBoundingClientRect().bottom);
9638
+ for (const step of [12, 12, 12]) {
9639
+ await scrollBy(step);
9640
+ const toolbarRect = toolbar.getBoundingClientRect();
9641
+ const headerRect = header.getBoundingClientRect();
9642
+ // Where the pushed-up header and the toolbar overlap, the toolbar is on top.
9643
+ const overlapTop = Math.max(toolbarRect.top, headerRect.top);
9644
+ const overlapBottom = Math.min(toolbarRect.bottom, headerRect.bottom);
9645
+ expect(overlapBottom).toBeGreaterThan(overlapTop);
9646
+ const hit = document.elementFromPoint(
9647
+ toolbarRect.left + toolbarRect.width / 2,
9648
+ (overlapTop + overlapBottom) / 2
9649
+ );
9650
+ expect(toolbar.contains(hit), 'the toolbar is painted over the box header').toBe(true);
9651
+ }
9652
+ };
9653
+
9654
+ const StickyBoxesSchema: IOptionsSchema = {
9655
+ ...MovedRowSchema,
9656
+ summary: { type: 'string', display_name: 'Summary', required: true },
9657
+ reviewer: { type: 'string', display_name: 'Reviewer', required: true },
9658
+ };
9659
+
9660
+ const stickyBoxesStory = (layout: 'own' | 'host' | 'padded-host'): Story => ({
9661
+ parameters: {
9662
+ docs: {
9663
+ description: {
9664
+ story: `${
9665
+ layout === 'own' ? 'The form owns its scroller'
9666
+ : layout === 'host' ? 'The host scrolls the form'
9667
+ : 'A padded host scrolls the form'
9668
+ }. Scrolling the end of the Needs attention box up past the pinned toolbar pushes the box's pinned header up with it, and the header slides under the toolbar: the toolbar stays fully visible.`,
9669
+ },
9670
+ },
9671
+ },
9672
+ render: ({ value, ...rest }: IFormEngineProps) => (
9673
+ <div
9674
+ data-testid='sticky-host'
9675
+ style={{
9676
+ height: 420,
9677
+ overflow: layout === 'own' ? 'hidden' : 'auto',
9678
+ padding: layout === 'padded-host' ? 20 : 0,
9679
+ display: 'flex',
9680
+ flexFlow: 'column',
9681
+ }}
9682
+ >
9683
+ <FormEngine {...rest} value={value} />
9684
+ </div>
9685
+ ),
9686
+ args: {
9687
+ compact: true,
9688
+ compactScroll: layout === 'own' ? 'own' : 'host',
9689
+ minColumnWidth: '300px',
9690
+ options: StickyBoxesSchema,
9691
+ value: MovedRowValue,
9692
+ },
9693
+ play: async () => {
9694
+ await _testsWaitForText('Detail 12');
9695
+ const scroller =
9696
+ layout === 'own' ?
9697
+ document.querySelector<HTMLElement>('.options-readfirst-scroll')!
9698
+ : document.querySelector<HTMLElement>('[data-testid="sticky-host"]')!;
9699
+ await assertBoxHeaderSlidesUnderToolbar(scroller);
9700
+ },
9701
+ });
9702
+
9703
+ export const BoxHeaderSlidesUnderToolbarOwnScroll: Story = stickyBoxesStory('own');
9704
+ export const BoxHeaderSlidesUnderToolbarHostScroll: Story = stickyBoxesStory('host');
9705
+ export const BoxHeaderSlidesUnderToolbarPaddedHost: Story = stickyBoxesStory('padded-host');
@@ -42,6 +42,7 @@ import { shouldMarkAsExpression } from '../expressions/argumentPresence';
42
42
  import { offersTypeChoices } from './typeChoices';
43
43
  import { optionRowActions, resolveOptionActions, TOptionActions } from './optionActions';
44
44
  import { createRendererOnlyUiTypeCheck, isRendererOnlyUiType } from './rendererTypes';
45
+ import { revealRow } from './revealRow';
45
46
  import { cloneDeep, findKey, flatten, forEach, isEqual, isPlainObject, last, uniq } from 'lodash';
46
47
  import map from 'lodash/map';
47
48
  import reduce from 'lodash/reduce';
@@ -1520,8 +1521,15 @@ const FormEngineImpl = ({
1520
1521
  const target = compactWrapNodeRef.current?.querySelector<HTMLElement>(
1521
1522
  `.readfirst-row[data-field="${optionNames[0]}"]`
1522
1523
  );
1523
- if (typeof target?.scrollIntoView === 'function') {
1524
- target.scrollIntoView({ block: 'center', behavior: 'smooth' });
1524
+ // Only when it is not already in view, and never under a pinned box
1525
+ // header: `block: 'center'` scrolled a row that was in plain sight and
1526
+ // centred it against the whole scrollport, so the header pinned at the
1527
+ // top was drawn over it (see `revealRow`).
1528
+ if (target) {
1529
+ revealRow(
1530
+ target,
1531
+ window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ? 'auto' : 'smooth'
1532
+ );
1525
1533
  }
1526
1534
  });
1527
1535
  }
@@ -3826,6 +3834,16 @@ const FormEngineImpl = ({
3826
3834
  // Keeps the wrapper from clipping, which is what a pinned box
3827
3835
  // header inside it needs — see StyledCompactPanel.
3828
3836
  $stickyBoxes={compactStickyBoxHeaders && !compactNested}
3837
+ // A narrow header the consumer dressed stacks its toolbar
3838
+ // under the label and actions (see StyledCompactPanel).
3839
+ $stackHeader={
3840
+ compactNarrow &&
3841
+ !!(
3842
+ compactPanelProps?.label ||
3843
+ compactPanelProps?.icon ||
3844
+ compactPanelProps?.actions?.length
3845
+ )
3846
+ }
3829
3847
  flat
3830
3848
  raised
3831
3849
  minimal
@@ -66,6 +66,9 @@ export const StyledCompactPanel = styled(ReqorePanel)<{
66
66
  $tightHeader?: boolean;
67
67
  /** True when the status boxes inside pin their headers. */
68
68
  $stickyBoxes?: boolean;
69
+ /** True when a narrow form's header carries a consumer's label, icon or
70
+ * actions beside the toolbar, which then has to take a line of its own. */
71
+ $stackHeader?: boolean;
69
72
  }>`
70
73
  /* The wrapper must not clip, or nothing INSIDE it can pin.
71
74
 
@@ -89,6 +92,37 @@ export const StyledCompactPanel = styled(ReqorePanel)<{
89
92
  }
90
93
  `
91
94
  : ''}
95
+ /* A NARROW header dressed by the consumer (\`compactPanelProps\` label, icon
96
+ or actions) stacks: the label and the consumer's actions share the first
97
+ line, and the toolbar takes the whole second line.
98
+
99
+ ReqorePanel lays its header out as one unwrapping row — the label, then an
100
+ action group that never shrinks. The toolbar is one of those actions and
101
+ is as wide as the form, so at phone width the label was squeezed to
102
+ nothing and the consumer's own action was pushed past the panel's edge and
103
+ clipped. Flattening the action group (\`display: contents\`) lets its
104
+ members wrap with the label, and \`order\` moves the toolbar after them. */
105
+ ${({ $stackHeader }) =>
106
+ $stackHeader ?
107
+ css`
108
+ && > .reqore-panel-title {
109
+ flex-wrap: wrap;
110
+ row-gap: ${GAP_FROM_SIZE.normal}px;
111
+ }
112
+ && > .reqore-panel-title > .reqore-panel-title-header {
113
+ flex: 1 1 0;
114
+ min-width: 0;
115
+ }
116
+ && > .reqore-panel-title > .reqore-control-group {
117
+ display: contents;
118
+ }
119
+ && > .reqore-panel-title .options-readfirst-toolbar {
120
+ order: 1;
121
+ flex: 1 1 100%;
122
+ min-width: 0;
123
+ }
124
+ `
125
+ : ''}
92
126
  > .reqore-panel-title {
93
127
  /* The blur + translateZ exist only to make the STICKY top-level toolbar ghost
94
128
  content beneath it; a nested sub-form's header isn't sticky, so skip them
@@ -97,6 +131,14 @@ export const StyledCompactPanel = styled(ReqorePanel)<{
97
131
  $nested ? '' : (
98
132
  'backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); transform: translateZ(0);'
99
133
  )}
134
+ /* Above the status-box headers, which pin just below this toolbar. A
135
+ pinned header cannot leave its own box, so when the end of a box
136
+ scrolls up past the toolbar it pushes the header up with it — and with
137
+ both at ReqorePanel's sticky z-index the header, later in the
138
+ document, slid OVER the toolbar and covered the search and the
139
+ meter. One step higher and it slides under the toolbar's blur
140
+ instead. Nested sub-forms pin nothing, so they keep the default. */
141
+ ${({ $nested }) => ($nested ? '' : 'z-index: 3;')}
100
142
  padding-top: ${GAP_FROM_SIZE[HEADER_GAP]}px;
101
143
  /* A header with a SEARCH ROW keeps the \`big\` gap below it — a tall
102
144
  interactive control earns separation from the content it filters. A