@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,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
+ };
@@ -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
+ };