@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.
- package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
- package/dist/components/form/engine/CompactRow.js +7 -3
- package/dist/components/form/engine/CompactRow.js.map +1 -1
- package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
- package/dist/components/form/engine/CompactToolbar.js +1 -1
- package/dist/components/form/engine/CompactToolbar.js.map +1 -1
- package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
- package/dist/components/form/engine/FormEngine.js +13 -5
- package/dist/components/form/engine/FormEngine.js.map +1 -1
- package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
- package/dist/components/form/engine/compactRowStyles.js +33 -27
- package/dist/components/form/engine/compactRowStyles.js.map +1 -1
- package/dist/components/form/engine/revealRow.d.ts +53 -0
- package/dist/components/form/engine/revealRow.d.ts.map +1 -0
- package/dist/components/form/engine/revealRow.js +120 -0
- package/dist/components/form/engine/revealRow.js.map +1 -0
- package/package.json +1 -1
- package/src/components/form/engine/CompactRow.tsx +6 -2
- package/src/components/form/engine/CompactToolbar.tsx +8 -1
- package/src/components/form/engine/FormEngine.stories.tsx +349 -0
- package/src/components/form/engine/FormEngine.tsx +20 -2
- package/src/components/form/engine/compactRowStyles.ts +42 -0
- package/src/components/form/engine/revealRow.ts +123 -0
- package/src/stories/Tests/utils.ts +36 -0
|
@@ -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":";AAAA;;;;;;;;;;;;;;;;;GAiBG;;;AAEH,wEAAwE;AACxE,IAAM,oBAAoB,GAAG,qEAAqE,CAAC;AAEnG,IAAM,YAAY,GAAG,UAAC,OAAoB;IAChC,IAAA,SAAS,GAAK,gBAAgB,CAAC,OAAO,CAAC,UAA9B,CAA+B;IAChD,OAAO,CACL,CAAC,SAAS,KAAK,MAAM,IAAI,SAAS,KAAK,QAAQ,IAAI,SAAS,KAAK,SAAS,CAAC;QAC3E,OAAO,CAAC,YAAY,GAAG,OAAO,CAAC,YAAY,CAC5C,CAAC;AACJ,CAAC,CAAC;AAEF,kFAAkF;AAC3E,IAAM,mBAAmB,GAAG,UAAC,OAAoB;IACtD,KAAK,IAAI,QAAM,GAAG,OAAO,CAAC,aAAa,EAAE,QAAM,EAAE,QAAM,GAAG,QAAM,CAAC,aAAa,EAAE,CAAC;QAC/E,IAAI,QAAM,KAAK,QAAQ,CAAC,IAAI,IAAI,QAAM,KAAK,QAAQ,CAAC,eAAe,EAAE,CAAC;YACpE,OAAO,SAAS,CAAC;QACnB,CAAC;QACD,IAAI,YAAY,CAAC,QAAM,CAAC,EAAE,CAAC;YACzB,OAAO,QAAM,CAAC;QAChB,CAAC;IACH,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC,CAAC;AAVW,QAAA,mBAAmB,uBAU9B;AAEF;;;;GAIG;AACI,IAAM,qBAAqB,GAAG,UAAC,OAAoB;;IACxD,IAAI,SAAS,GAAG,CAAC,CAAC;IAClB,KACE,IAAI,KAAK,GAAG,MAAA,OAAO,CAAC,aAAa,0CAAE,OAAO,CAAc,oBAAoB,CAAC,EAC7E,KAAK,EACL,KAAK,GAAG,MAAA,KAAK,CAAC,aAAa,0CAAE,OAAO,CAAc,oBAAoB,CAAC,EACvE,CAAC;QACD,IAAM,MAAM,GAAG,KAAK,CAAC,aAAa,CAAc,8BAA8B,CAAC,CAAC;QAChF,IAAI,CAAC,MAAM,IAAI,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC;YACxC,SAAS;QACX,CAAC;QACD,IAAM,KAAK,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAAC;QACvC,IAAI,KAAK,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;YAChC,SAAS;QACX,CAAC;QACD,IAAM,KAAG,GAAG,UAAU,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACvC,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,KAAG,GAAG,MAAM,CAAC,qBAAqB,EAAE,CAAC,MAAM,CAAC,CAAC;IAC/E,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC,CAAC;AAnBW,QAAA,qBAAqB,yBAmBhC;AAEF;;;;;;;;GAQG;AACI,IAAM,kBAAkB,GAAG,UAAC,OAAoB;IACrD,IAAM,SAAS,GAAG,IAAA,2BAAmB,EAAC,OAAO,CAAC,CAAC;IAC/C,IAAM,KAAK,GAAG,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC,gBAAgB,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACtF,OAAO,KAAK,GAAG,IAAA,6BAAqB,EAAC,OAAO,CAAC,CAAC;AAChD,CAAC,CAAC;AAJW,QAAA,kBAAkB,sBAI7B;AAEF;;;GAGG;AACI,IAAM,WAAW,GAAG,UAAC,OAAoB,EAAE,SAAiB;IACjE,IAAM,SAAS,GAAG,IAAA,2BAAmB,EAAC,OAAO,CAAC,CAAC;IAC/C,IAAM,OAAO,GAAG,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,qBAAqB,EAAE,CAAC,GAAG,GAAG,SAAS,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;IAC5F,IAAM,UAAU,GACd,SAAS,CAAC,CAAC;QACT,OAAO,GAAG,SAAS,CAAC,YAAY;QAClC,CAAC,CAAC,MAAM,CAAC,WAAW,IAAI,QAAQ,CAAC,eAAe,CAAC,YAAY,CAAC;IAChE,IAAM,IAAI,GAAG,OAAO,CAAC,qBAAqB,EAAE,CAAC;IAC7C,OAAO,IAAI,CAAC,GAAG,IAAI,OAAO,GAAG,SAAS,IAAI,IAAI,CAAC,MAAM,IAAI,UAAU,CAAC;AACtE,CAAC,CAAC;AATW,QAAA,WAAW,eAStB;AAEF;;;;;;;;;GASG;AACI,IAAM,SAAS,GAAG,UAAC,OAAoB,EAAE,QAAmC;IAAnC,yBAAA,EAAA,mBAAmC;IACjF,8EAA8E;IAC9E,sBAAsB;IACtB,IAAI,OAAO,OAAO,CAAC,cAAc,KAAK,UAAU,EAAE,CAAC;QACjD,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAM,SAAS,GAAG,IAAA,0BAAkB,EAAC,OAAO,CAAC,CAAC;IAC9C,IAAI,IAAA,mBAAW,EAAC,OAAO,EAAE,SAAS,CAAC,EAAE,CAAC;QACpC,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,CAAC,KAAK,CAAC,eAAe,GAAG,UAAG,SAAS,OAAI,CAAC;IACjD,OAAO,CAAC,cAAc,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,UAAA,EAAE,CAAC,CAAC;IAC1E,OAAO,IAAI,CAAC;AACd,CAAC,CAAC;AAbW,QAAA,SAAS,aAapB"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qoretechnologies/reqraft",
|
|
3
|
-
"version": "0.10.59
|
|
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
|
-
//
|
|
935
|
-
|
|
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
|
|
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
|
-
|
|
1524
|
-
|
|
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
|