@qoretechnologies/reqraft 0.10.59-pr.130.gff5fd49 → 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 +18 -14
- 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 +24 -29
- package/dist/components/form/engine/FormEngine.js.map +1 -1
- package/dist/components/form/engine/OptionFieldMessages.d.ts +7 -14
- package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
- package/dist/components/form/engine/OptionFieldMessages.js +22 -6
- package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
- package/dist/components/form/engine/compactRowContext.d.ts +2 -0
- package/dist/components/form/engine/compactRowContext.d.ts.map +1 -1
- package/dist/components/form/engine/compactRowContext.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/dist/helpers/allowedValues.d.ts +11 -1
- package/dist/helpers/allowedValues.d.ts.map +1 -1
- package/dist/helpers/allowedValues.js +25 -2
- package/dist/helpers/allowedValues.js.map +1 -1
- package/package.json +1 -1
- package/src/components/form/engine/CompactRow.tsx +21 -19
- package/src/components/form/engine/CompactToolbar.tsx +8 -1
- package/src/components/form/engine/FormEngine.stories.tsx +499 -9
- package/src/components/form/engine/FormEngine.tsx +28 -23
- package/src/components/form/engine/OptionFieldMessages.tsx +27 -18
- package/src/components/form/engine/compactRowContext.ts +2 -0
- package/src/components/form/engine/compactRowStyles.ts +42 -0
- package/src/components/form/engine/revealRow.ts +123 -0
- package/src/helpers/allowedValues.ts +35 -2
- package/src/stories/Tests/utils.ts +36 -0
|
@@ -327,20 +327,13 @@ export interface IOptionFieldMessagesProps {
|
|
|
327
327
|
name: string;
|
|
328
328
|
getType: (type: string) => string;
|
|
329
329
|
/**
|
|
330
|
-
*
|
|
330
|
+
* Whether a `ui_type` names a bespoke editor, the consumer's own included.
|
|
331
331
|
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
* the asterisk on the label, the Needs-attention box the row sits in, and the
|
|
336
|
-
* completion meter — so the message waits until the reader has been in the
|
|
337
|
-
* field and left it empty, which IS a thing that went wrong.
|
|
338
|
-
*
|
|
339
|
-
* Only the plain required message is held back. A value that fails
|
|
340
|
-
* validation, an unmet required GROUP and a locked dependency are all facts
|
|
341
|
-
* about what the form currently holds, and they show immediately.
|
|
332
|
+
* Such a field's `allowed_values` are not fixed choices (the editor decides
|
|
333
|
+
* what a value means), so a value is not judged against them. Without it only
|
|
334
|
+
* reqraft's built-in editors are known.
|
|
342
335
|
*/
|
|
343
|
-
|
|
336
|
+
isRendererOnly?: (type?: any) => boolean;
|
|
344
337
|
}
|
|
345
338
|
|
|
346
339
|
/**
|
|
@@ -354,7 +347,7 @@ export const getOptionFieldMessages = ({
|
|
|
354
347
|
name,
|
|
355
348
|
allOptions,
|
|
356
349
|
getType,
|
|
357
|
-
|
|
350
|
+
isRendererOnly,
|
|
358
351
|
}: IOptionFieldMessagesProps): IReqoreTagProps[] => {
|
|
359
352
|
const optionSchema = schema[name];
|
|
360
353
|
const result: IReqoreTagProps[] = [];
|
|
@@ -386,6 +379,7 @@ export const getOptionFieldMessages = ({
|
|
|
386
379
|
// config never reached the validator.
|
|
387
380
|
...(optionSchema as object),
|
|
388
381
|
optionSchema: schema,
|
|
382
|
+
hasOwnEditor: !!isRendererOnly?.(optionSchema?.ui_type),
|
|
389
383
|
}
|
|
390
384
|
);
|
|
391
385
|
if (!validationData.isValid) {
|
|
@@ -395,9 +389,10 @@ export const getOptionFieldMessages = ({
|
|
|
395
389
|
});
|
|
396
390
|
}
|
|
397
391
|
} else {
|
|
398
|
-
//
|
|
392
|
+
// An empty required field says so from the start, on a new form too. A
|
|
393
|
+
// locked field is not asked for: the dependency message below says what
|
|
399
394
|
// unlocks it, and until then there is nothing to fill in.
|
|
400
|
-
if (optionSchema?.required && !
|
|
395
|
+
if (optionSchema?.required && !locked) {
|
|
401
396
|
result.push({ label: 'This field is required', intent: 'danger' });
|
|
402
397
|
}
|
|
403
398
|
|
|
@@ -442,11 +437,25 @@ export const OptionFieldMessages = ({
|
|
|
442
437
|
name,
|
|
443
438
|
allOptions,
|
|
444
439
|
getType,
|
|
445
|
-
|
|
440
|
+
isRendererOnly,
|
|
446
441
|
}: IOptionFieldMessagesProps) => {
|
|
447
442
|
const messages: IReqoreTagProps[] = useMemo(
|
|
448
|
-
() =>
|
|
449
|
-
|
|
443
|
+
() =>
|
|
444
|
+
getOptionFieldMessages({
|
|
445
|
+
schema,
|
|
446
|
+
option,
|
|
447
|
+
name,
|
|
448
|
+
allOptions,
|
|
449
|
+
getType,
|
|
450
|
+
isRendererOnly,
|
|
451
|
+
}),
|
|
452
|
+
[
|
|
453
|
+
JSON.stringify(schema),
|
|
454
|
+
JSON.stringify(option),
|
|
455
|
+
JSON.stringify(allOptions),
|
|
456
|
+
name,
|
|
457
|
+
isRendererOnly,
|
|
458
|
+
]
|
|
450
459
|
);
|
|
451
460
|
|
|
452
461
|
if (!size(messages)) {
|
|
@@ -98,6 +98,8 @@ export interface ICompactRowContext {
|
|
|
98
98
|
handleOptionLabelClick: (optionName: string) => void;
|
|
99
99
|
removeSelectedOption: (optionName: string) => void;
|
|
100
100
|
getTypeForOption: (type: string) => string;
|
|
101
|
+
/** Whether a `ui_type` names a bespoke editor, reqraft's or the consumer's own. */
|
|
102
|
+
isRendererOnly?: (type?: any) => boolean;
|
|
101
103
|
isOptionValid: (optionName: string, type: any, optionValue: any) => boolean;
|
|
102
104
|
confirmAction: ReturnType<typeof useReqoreProperty<'confirmAction'>>;
|
|
103
105
|
|
|
@@ -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
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bring a form row into view only when it is not already there, and never
|
|
3
|
+
* under a pinned header.
|
|
4
|
+
*
|
|
5
|
+
* A row that moves between status boxes (filling a field moves it from "Needs
|
|
6
|
+
* attention" to "Set") is followed so the reader does not lose it. That used
|
|
7
|
+
* to be `scrollIntoView({ block: 'center' })`, which scrolls even when the row
|
|
8
|
+
* is already in plain sight, and centres it against the whole scrollport while
|
|
9
|
+
* the box header pinned at the top hides part of it. In the qorus-ide schema
|
|
10
|
+
* editor the moved row landed with the "Needs attention" header (375-423)
|
|
11
|
+
* drawn over it (405).
|
|
12
|
+
*
|
|
13
|
+
* The visible area is the scrollport minus the pinned chrome above the row:
|
|
14
|
+
* the form's sticky toolbar and the sticky header of every status box the row
|
|
15
|
+
* sits in. Both are read from the DOM when the reveal runs, so a host's own
|
|
16
|
+
* offset, a toolbar that wraps, or a header that is not pinned at all are all
|
|
17
|
+
* accounted for without the caller knowing about them.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Selectors of the elements whose header may be pinned above a row. */
|
|
21
|
+
const PINNED_HEADER_OWNERS = '.options-readfirst-group, .options-readfirst-scroll > .reqore-panel';
|
|
22
|
+
|
|
23
|
+
const isScrollable = (element: HTMLElement): boolean => {
|
|
24
|
+
const { overflowY } = getComputedStyle(element);
|
|
25
|
+
return (
|
|
26
|
+
(overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') &&
|
|
27
|
+
element.scrollHeight > element.clientHeight
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** The nearest ancestor that actually scrolls; `undefined` means the viewport. */
|
|
32
|
+
export const findScrollContainer = (element: HTMLElement): HTMLElement | undefined => {
|
|
33
|
+
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
|
34
|
+
if (parent === document.body || parent === document.documentElement) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
if (isScrollable(parent)) {
|
|
38
|
+
return parent;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How far below the top of the scrollport the pinned chrome above `element`
|
|
46
|
+
* reaches: the largest `top + height` of any sticky header of an enclosing
|
|
47
|
+
* status box or form toolbar. Zero when nothing above it is pinned.
|
|
48
|
+
*/
|
|
49
|
+
export const getPinnedChromeHeight = (element: HTMLElement): number => {
|
|
50
|
+
let clearance = 0;
|
|
51
|
+
for (
|
|
52
|
+
let owner = element.parentElement?.closest<HTMLElement>(PINNED_HEADER_OWNERS);
|
|
53
|
+
owner;
|
|
54
|
+
owner = owner.parentElement?.closest<HTMLElement>(PINNED_HEADER_OWNERS)
|
|
55
|
+
) {
|
|
56
|
+
const header = owner.querySelector<HTMLElement>(':scope > .reqore-panel-title');
|
|
57
|
+
if (!header || header.contains(element)) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const style = getComputedStyle(header);
|
|
61
|
+
if (style.position !== 'sticky') {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const top = parseFloat(style.top) || 0;
|
|
65
|
+
clearance = Math.max(clearance, top + header.getBoundingClientRect().height);
|
|
66
|
+
}
|
|
67
|
+
return clearance;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* How far below the top edge of the row's scrollport the pinned chrome reaches.
|
|
72
|
+
*
|
|
73
|
+
* A sticky `top` is measured from the scrollport's top edge INSET BY ITS
|
|
74
|
+
* PADDING, while `scrollIntoView` and `scroll-margin-top` align against the
|
|
75
|
+
* edge itself. Leaving the padding out put an opened row 16px under its box
|
|
76
|
+
* header inside a padded host scroller (the Storybook canvas, and any panel
|
|
77
|
+
* with padding).
|
|
78
|
+
*/
|
|
79
|
+
export const getRevealClearance = (element: HTMLElement): number => {
|
|
80
|
+
const container = findScrollContainer(element);
|
|
81
|
+
const inset = container ? parseFloat(getComputedStyle(container).paddingTop) || 0 : 0;
|
|
82
|
+
return inset + getPinnedChromeHeight(element);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether the whole of `element` is visible below `clearance` (measured from
|
|
87
|
+
* the top edge of its scrollport, see {@link getRevealClearance}).
|
|
88
|
+
*/
|
|
89
|
+
export const isRowInView = (element: HTMLElement, clearance: number): boolean => {
|
|
90
|
+
const container = findScrollContainer(element);
|
|
91
|
+
const viewTop = container ? container.getBoundingClientRect().top + container.clientTop : 0;
|
|
92
|
+
const viewBottom =
|
|
93
|
+
container ?
|
|
94
|
+
viewTop + container.clientHeight
|
|
95
|
+
: window.innerHeight || document.documentElement.clientHeight;
|
|
96
|
+
const rect = element.getBoundingClientRect();
|
|
97
|
+
return rect.top >= viewTop + clearance && rect.bottom <= viewBottom;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Scroll `element` the least distance that shows it clear of the pinned chrome
|
|
102
|
+
* above it — and not at all when it is already fully in view.
|
|
103
|
+
*
|
|
104
|
+
* `scroll-margin-top` is what keeps a row that has to come down from above from
|
|
105
|
+
* stopping under the header: `block: 'nearest'` aligns the row's margin box,
|
|
106
|
+
* so the row itself stops exactly below the pinned chrome.
|
|
107
|
+
*
|
|
108
|
+
* Returns whether a scroll was started.
|
|
109
|
+
*/
|
|
110
|
+
export const revealRow = (element: HTMLElement, behavior: ScrollBehavior = 'smooth'): boolean => {
|
|
111
|
+
// jsdom implements neither layout nor `scrollIntoView`; a missing reveal must
|
|
112
|
+
// never break a form.
|
|
113
|
+
if (typeof element.scrollIntoView !== 'function') {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
const clearance = getRevealClearance(element);
|
|
117
|
+
if (isRowInView(element, clearance)) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
element.style.scrollMarginTop = `${clearance}px`;
|
|
121
|
+
element.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior });
|
|
122
|
+
return true;
|
|
123
|
+
};
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { TQorusFormFieldSchema } from '@qoretechnologies/ts-toolkit';
|
|
11
11
|
import isEqual from 'lodash/isEqual';
|
|
12
|
+
import { isRendererOnlyUiType } from '../components/form/engine/rendererTypes';
|
|
12
13
|
import { richtextHasTag, richtextToString } from './common';
|
|
13
14
|
import { getListElementValue } from './options';
|
|
14
15
|
import { isValueTemplate } from './templates';
|
|
@@ -109,8 +110,32 @@ export interface IChoiceFieldSchema {
|
|
|
109
110
|
/** Set when the value is an expression, which is resolved at run time. */
|
|
110
111
|
isFunction?: boolean;
|
|
111
112
|
is_expression?: boolean;
|
|
113
|
+
/** The editor the field is drawn with; see {@link hasOwnEditor}. */
|
|
114
|
+
ui_type?: string | readonly string[];
|
|
115
|
+
/**
|
|
116
|
+
* Set by a form that knows the field is drawn by a bespoke editor of the
|
|
117
|
+
* consumer's own (one it declared in `rendererOnlyUiTypes`), which reqraft's
|
|
118
|
+
* built-in list cannot name.
|
|
119
|
+
*/
|
|
120
|
+
hasOwnEditor?: boolean;
|
|
112
121
|
}
|
|
113
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Does a bespoke editor, rather than reqraft's choice picker, draw this field?
|
|
125
|
+
*
|
|
126
|
+
* Then the editor alone decides what a value means, and `allowed_values` is not
|
|
127
|
+
* a closed set of values the field may hold. The AI endpoint's Tools field is
|
|
128
|
+
* the case that proved it: its `tool-catalog` editor stores SELECTORS -- `*` for
|
|
129
|
+
* every tool, `system:*`, a connection name, `connection/tool` -- and the host
|
|
130
|
+
* hangs the tool catalogue's SOURCES on `allowed_values` for the editor to
|
|
131
|
+
* browse. Judging the selectors against the sources flagged the server's own
|
|
132
|
+
* default, `["*"]`, as "not one of the choices", so no chat endpoint could be
|
|
133
|
+
* published with it. The server says the same: a custom `ui_type` "defines its
|
|
134
|
+
* own value shape".
|
|
135
|
+
*/
|
|
136
|
+
const hasOwnEditor = (field: IChoiceFieldSchema): boolean =>
|
|
137
|
+
!!field.hasOwnEditor || isRendererOnlyUiType(field.ui_type);
|
|
138
|
+
|
|
114
139
|
/**
|
|
115
140
|
* The parts of a stored value that are not among the field's fixed choices.
|
|
116
141
|
*
|
|
@@ -121,14 +146,22 @@ export interface IChoiceFieldSchema {
|
|
|
121
146
|
* all of which are resolved when the interface runs;
|
|
122
147
|
* - `allowed_values: []` — the server has no choices to offer right now, which
|
|
123
148
|
* is "unknown", not "nothing is allowed";
|
|
124
|
-
* - creatable choices, where any value may be added
|
|
149
|
+
* - creatable choices, where any value may be added;
|
|
150
|
+
* - a field drawn by a bespoke editor (a renderer-only `ui_type` such as
|
|
151
|
+
* `tool-catalog`), whose values are whatever that editor says they are.
|
|
125
152
|
*
|
|
126
153
|
* A single-valued field is judged as a whole. A multi-select field, and a list
|
|
127
154
|
* whose elements are constrained by `element_allowed_values`, are judged per
|
|
128
155
|
* element, so the answer names exactly the elements that are not choices.
|
|
129
156
|
*/
|
|
130
157
|
export const getUnlistedChoices = (value: unknown, field?: IChoiceFieldSchema): unknown[] => {
|
|
131
|
-
if (
|
|
158
|
+
if (
|
|
159
|
+
!field ||
|
|
160
|
+
isEmptyChoiceValue(value) ||
|
|
161
|
+
field.isFunction ||
|
|
162
|
+
field.is_expression ||
|
|
163
|
+
hasOwnEditor(field)
|
|
164
|
+
) {
|
|
132
165
|
return [];
|
|
133
166
|
}
|
|
134
167
|
|
|
@@ -282,3 +282,39 @@ export async function _testsDoubleClickButton({
|
|
|
282
282
|
}
|
|
283
283
|
console.log('Clicked button:', label, selector, nth, wait);
|
|
284
284
|
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Run `body` with the story's own viewport narrowed to `width` x `height`, then
|
|
288
|
+
* put it back.
|
|
289
|
+
*
|
|
290
|
+
* qlip's `parameters.qlip.viewport` sizes the CAPTURE, which happens after the
|
|
291
|
+
* play has finished — the play itself runs at the test browser's width. A play
|
|
292
|
+
* that asserts the phone layout has to narrow the viewport itself, with the
|
|
293
|
+
* same `page.viewport` call qlip makes. It is a DYNAMIC import: `vitest/browser`
|
|
294
|
+
* throws on import outside Browser Mode, so a static one would take the
|
|
295
|
+
* Storybook dev server down. Where the call is unavailable, `body` runs at the
|
|
296
|
+
* width it already had and is told so (`narrowed` is false).
|
|
297
|
+
*/
|
|
298
|
+
export const _testsWithViewport = async (
|
|
299
|
+
width: number,
|
|
300
|
+
height: number,
|
|
301
|
+
body: (narrowed: boolean) => Promise<void>
|
|
302
|
+
) => {
|
|
303
|
+
let resize: ((w: number, h: number) => Promise<void>) | undefined;
|
|
304
|
+
try {
|
|
305
|
+
({
|
|
306
|
+
page: { viewport: resize },
|
|
307
|
+
} = (await import('vitest/browser')) as never);
|
|
308
|
+
} catch {
|
|
309
|
+
resize = undefined;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const was = { width: window.innerWidth, height: window.innerHeight };
|
|
313
|
+
await resize?.(width, height);
|
|
314
|
+
try {
|
|
315
|
+
await body(!!resize && window.innerWidth === width);
|
|
316
|
+
} finally {
|
|
317
|
+
// Unconditional: a viewport left narrowed would narrow every later story.
|
|
318
|
+
await resize?.(was.width, was.height);
|
|
319
|
+
}
|
|
320
|
+
};
|