@flamingo-stack/openframe-frontend-core 0.0.673 → 0.0.674-2431.7289.1
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/{chunk-3UDBK2YG.cjs → chunk-4J7CFT6D.cjs} +57 -57
- package/dist/chunk-4J7CFT6D.cjs.map +1 -0
- package/dist/{chunk-L3KN6HLJ.cjs → chunk-4Q6P3VHS.cjs} +26 -26
- package/dist/{chunk-L3KN6HLJ.cjs.map → chunk-4Q6P3VHS.cjs.map} +1 -1
- package/dist/{chunk-A6WWJR7C.cjs → chunk-7F73YNDS.cjs} +7 -7
- package/dist/{chunk-A6WWJR7C.cjs.map → chunk-7F73YNDS.cjs.map} +1 -1
- package/dist/{chunk-GH236QRY.js → chunk-BQICLH4C.js} +7 -7
- package/dist/{chunk-FO26QOTY.cjs → chunk-CHKIZRUP.cjs} +39 -39
- package/dist/{chunk-FO26QOTY.cjs.map → chunk-CHKIZRUP.cjs.map} +1 -1
- package/dist/{chunk-RNERYM2P.cjs → chunk-EGQOPBXC.cjs} +5 -5
- package/dist/{chunk-RNERYM2P.cjs.map → chunk-EGQOPBXC.cjs.map} +1 -1
- package/dist/{chunk-4IXJYRVE.cjs → chunk-EJZV4QGU.cjs} +403 -403
- package/dist/{chunk-4IXJYRVE.cjs.map → chunk-EJZV4QGU.cjs.map} +1 -1
- package/dist/{chunk-MT44R3LJ.js → chunk-EPKQTPNJ.js} +6 -6
- package/dist/{chunk-EP7QPXSI.cjs → chunk-FUEAXTZE.cjs} +17 -17
- package/dist/{chunk-EP7QPXSI.cjs.map → chunk-FUEAXTZE.cjs.map} +1 -1
- package/dist/{chunk-YIPFHZHY.js → chunk-G7HMYIDN.js} +2 -2
- package/dist/{chunk-YTGB5CHX.js → chunk-GJTLUW2H.js} +4 -4
- package/dist/{chunk-Y2CXELYC.cjs → chunk-HN7KNVKL.cjs} +6 -5
- package/dist/chunk-HN7KNVKL.cjs.map +1 -0
- package/dist/{chunk-3T4GPKSB.js → chunk-IGKXYFCP.js} +2 -1
- package/dist/{chunk-3T4GPKSB.js.map → chunk-IGKXYFCP.js.map} +1 -1
- package/dist/{chunk-V2A4MAAL.cjs → chunk-ILAHSBZI.cjs} +3 -2
- package/dist/chunk-ILAHSBZI.cjs.map +1 -0
- package/dist/{chunk-6MGQOZPT.cjs → chunk-L7PO6NSD.cjs} +10 -10
- package/dist/{chunk-6MGQOZPT.cjs.map → chunk-L7PO6NSD.cjs.map} +1 -1
- package/dist/{chunk-2HEZSBUU.js → chunk-M76D6OJZ.js} +8 -8
- package/dist/{chunk-MENONRB4.js → chunk-MMOXFVQN.js} +3 -3
- package/dist/{chunk-PWRRSAO6.cjs → chunk-MRT6DZG5.cjs} +50 -50
- package/dist/{chunk-PWRRSAO6.cjs.map → chunk-MRT6DZG5.cjs.map} +1 -1
- package/dist/{chunk-O4KO4CT6.cjs → chunk-N6CI2E7D.cjs} +12 -12
- package/dist/{chunk-O4KO4CT6.cjs.map → chunk-N6CI2E7D.cjs.map} +1 -1
- package/dist/{chunk-2OYZ5INK.js → chunk-NRHEKFPG.js} +4 -4
- package/dist/{chunk-WJJA42FG.cjs → chunk-OAQROGFN.cjs} +80 -80
- package/dist/{chunk-WJJA42FG.cjs.map → chunk-OAQROGFN.cjs.map} +1 -1
- package/dist/{chunk-DKFWIJD7.cjs → chunk-PP3N5MN4.cjs} +3 -3
- package/dist/{chunk-DKFWIJD7.cjs.map → chunk-PP3N5MN4.cjs.map} +1 -1
- package/dist/{chunk-SEGLWILF.js → chunk-PXWMRXVI.js} +55 -26
- package/dist/{chunk-SEGLWILF.js.map → chunk-PXWMRXVI.js.map} +1 -1
- package/dist/{chunk-32JKIRN4.js → chunk-QO4RASGC.js} +4 -3
- package/dist/chunk-QO4RASGC.js.map +1 -0
- package/dist/{chunk-JYDAEBWM.cjs → chunk-QZMKKQQN.cjs} +20 -20
- package/dist/{chunk-JYDAEBWM.cjs.map → chunk-QZMKKQQN.cjs.map} +1 -1
- package/dist/{chunk-6EBW4HFR.cjs → chunk-RCLYHHB3.cjs} +12 -12
- package/dist/{chunk-6EBW4HFR.cjs.map → chunk-RCLYHHB3.cjs.map} +1 -1
- package/dist/{chunk-QPPDBX5E.js → chunk-RSOBME56.js} +5 -5
- package/dist/{chunk-2GF77XVC.js → chunk-TECYEWVQ.js} +5 -5
- package/dist/{chunk-MU5ORFBU.cjs → chunk-TWQWCOJ7.cjs} +22 -22
- package/dist/{chunk-MU5ORFBU.cjs.map → chunk-TWQWCOJ7.cjs.map} +1 -1
- package/dist/{chunk-5WG4GVJS.cjs → chunk-UIQQBZLU.cjs} +90 -61
- package/dist/chunk-UIQQBZLU.cjs.map +1 -0
- package/dist/{chunk-JFTWABV6.js → chunk-UYPICURQ.js} +2 -2
- package/dist/{chunk-JD4U7QMG.js → chunk-VQX6JGWJ.js} +6 -6
- package/dist/chunk-VQX6JGWJ.js.map +1 -0
- package/dist/{chunk-EEIN4JOR.js → chunk-XBNO42PI.js} +3 -3
- package/dist/{chunk-OO45SNKF.js → chunk-XYZQXHAX.js} +5 -5
- package/dist/{chunk-EUL7CPOO.js → chunk-YRNYKT4J.js} +2 -2
- package/dist/{chunk-FGQNWXIT.js → chunk-YVJDVVHY.js} +4 -4
- package/dist/components/case-studies/index.cjs +17 -17
- package/dist/components/case-studies/index.js +5 -5
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +3 -3
- package/dist/components/chat/index.js +2 -2
- package/dist/components/contact/index.cjs +6 -6
- package/dist/components/contact/index.js +5 -5
- package/dist/components/docs/index.cjs +9 -9
- package/dist/components/docs/index.js +8 -8
- package/dist/components/docs/use-scroll-spy.d.ts +17 -5
- package/dist/components/docs/use-scroll-spy.d.ts.map +1 -1
- package/dist/components/embeds/index.cjs +6 -6
- package/dist/components/embeds/index.js +5 -5
- package/dist/components/faq/index.cjs +6 -6
- package/dist/components/faq/index.js +5 -5
- package/dist/components/features/index.cjs +5 -5
- package/dist/components/features/index.js +4 -4
- package/dist/components/help-center-pages/index.cjs +85 -86
- package/dist/components/help-center-pages/index.cjs.map +1 -1
- package/dist/components/help-center-pages/index.js +20 -21
- package/dist/components/help-center-pages/index.js.map +1 -1
- package/dist/components/help-center-pages/trust-center-page.d.ts.map +1 -1
- package/dist/components/index.cjs +45 -45
- package/dist/components/index.js +15 -15
- package/dist/components/meeting-scheduler/index.cjs +30 -30
- package/dist/components/meeting-scheduler/index.js +4 -4
- package/dist/components/navigation/index.cjs +8 -8
- package/dist/components/navigation/index.js +7 -7
- package/dist/components/navigation/sticky-section-nav.d.ts.map +1 -1
- package/dist/components/onboarding-guides/index.cjs +8 -8
- package/dist/components/onboarding-guides/index.js +7 -7
- package/dist/components/tickets/index.cjs +9 -9
- package/dist/components/tickets/index.js +8 -8
- package/dist/components/ui/index.cjs +5 -5
- package/dist/components/ui/index.js +4 -4
- package/dist/hooks/index.cjs +3 -3
- package/dist/hooks/index.js +2 -2
- package/dist/index.cjs +10 -10
- package/dist/index.js +8 -8
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/scroll-into-view.d.ts +10 -0
- package/dist/utils/scroll-into-view.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/chat-message-enhanced.tsx +12 -3
- package/src/components/chat/chat-message-list.tsx +1 -1
- package/src/components/chat/entity-cards/dispatch.tsx +1 -1
- package/src/components/docs/__tests__/use-scroll-spy.test.tsx +163 -3
- package/src/components/docs/use-scroll-spy.ts +109 -37
- package/src/components/help-center-pages/__tests__/trust-center-page.test.tsx +5 -1
- package/src/components/help-center-pages/trust-center-page.tsx +15 -11
- package/src/components/navigation/sticky-section-nav.tsx +3 -0
- package/src/stories/TrustCenterPage.stories.tsx +38 -0
- package/src/utils/scroll-into-view.ts +7 -2
- package/dist/chunk-32JKIRN4.js.map +0 -1
- package/dist/chunk-3UDBK2YG.cjs.map +0 -1
- package/dist/chunk-5WG4GVJS.cjs.map +0 -1
- package/dist/chunk-JD4U7QMG.js.map +0 -1
- package/dist/chunk-V2A4MAAL.cjs.map +0 -1
- package/dist/chunk-Y2CXELYC.cjs.map +0 -1
- /package/dist/{chunk-GH236QRY.js.map → chunk-BQICLH4C.js.map} +0 -0
- /package/dist/{chunk-MT44R3LJ.js.map → chunk-EPKQTPNJ.js.map} +0 -0
- /package/dist/{chunk-YIPFHZHY.js.map → chunk-G7HMYIDN.js.map} +0 -0
- /package/dist/{chunk-YTGB5CHX.js.map → chunk-GJTLUW2H.js.map} +0 -0
- /package/dist/{chunk-2HEZSBUU.js.map → chunk-M76D6OJZ.js.map} +0 -0
- /package/dist/{chunk-MENONRB4.js.map → chunk-MMOXFVQN.js.map} +0 -0
- /package/dist/{chunk-2OYZ5INK.js.map → chunk-NRHEKFPG.js.map} +0 -0
- /package/dist/{chunk-QPPDBX5E.js.map → chunk-RSOBME56.js.map} +0 -0
- /package/dist/{chunk-2GF77XVC.js.map → chunk-TECYEWVQ.js.map} +0 -0
- /package/dist/{chunk-JFTWABV6.js.map → chunk-UYPICURQ.js.map} +0 -0
- /package/dist/{chunk-EEIN4JOR.js.map → chunk-XBNO42PI.js.map} +0 -0
- /package/dist/{chunk-OO45SNKF.js.map → chunk-XYZQXHAX.js.map} +0 -0
- /package/dist/{chunk-EUL7CPOO.js.map → chunk-YRNYKT4J.js.map} +0 -0
- /package/dist/{chunk-FGQNWXIT.js.map → chunk-YVJDVVHY.js.map} +0 -0
|
@@ -61,6 +61,16 @@ export interface ScrollElementIntoViewOptions {
|
|
|
61
61
|
/** Tween duration in ms (smooth only). Default 320. */
|
|
62
62
|
durationMs?: number;
|
|
63
63
|
}
|
|
64
|
+
/** Nearest ancestor that is a *real* scroll container, or `null` when the
|
|
65
|
+
* window/document is the scroller. Only `auto | scroll | overlay` count —
|
|
66
|
+
* `clip` / `hidden` are intentionally excluded (a wrapper using `overflow-clip`
|
|
67
|
+
* purely to round corners must let the scroll bubble to the page).
|
|
68
|
+
*
|
|
69
|
+
* THE one rule for "what scrolls this element": `useScrollSpy` resolves the
|
|
70
|
+
* scroller it listens to with it, so the section a spy highlights is the
|
|
71
|
+
* section this helper scrolls to — inside a `<main overflow-y-auto>` shell as
|
|
72
|
+
* much as on a window-scrolled page. */
|
|
73
|
+
export declare function getScrollableAncestor(el: HTMLElement): HTMLElement | null;
|
|
64
74
|
/**
|
|
65
75
|
* Scroll the page so `target` lands at the top of the viewport (below sticky
|
|
66
76
|
* chrome via `headerOffset`). SSR-safe; `null`/`undefined` target is a no-op so
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scroll-into-view.d.ts","sourceRoot":"","sources":["../../src/utils/scroll-into-view.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AAEH,MAAM,WAAW,4BAA4B;IAC3C;+EAC2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;oFACgF;IAChF,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B;;;6EAGyE;IACzE,aAAa,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IAC/C,uDAAuD;IACvD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;
|
|
1
|
+
{"version":3,"file":"scroll-into-view.d.ts","sourceRoot":"","sources":["../../src/utils/scroll-into-view.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AAEH,MAAM,WAAW,4BAA4B;IAC3C;+EAC2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;oFACgF;IAChF,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B;;;6EAGyE;IACzE,aAAa,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IAC/C,uDAAuD;IACvD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAqBD;;;;;;;;yCAQyC;AACzC,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,WAAW,GAAG,WAAW,GAAG,IAAI,CAWzE;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,EACtC,OAAO,GAAE,4BAAiC,GACzC,IAAI,CA+EN"}
|
package/package.json
CHANGED
|
@@ -542,8 +542,17 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
|
|
|
542
542
|
Avatar is INLINE in the name row below (2025-2026 chat
|
|
543
543
|
pattern — Claude.ai, ChatGPT, Gemini, Perplexity).
|
|
544
544
|
Legacy hanging-avatar layout (`absolute -left-16`) wasted
|
|
545
|
-
64px of gutter and clipped in narrow panels.
|
|
546
|
-
|
|
545
|
+
64px of gutter and clipped in narrow panels.
|
|
546
|
+
|
|
547
|
+
Single-column GRID, not `flex flex-col` — here, in the segment
|
|
548
|
+
stack below, in the thread's per-message wrapper and in the
|
|
549
|
+
card/video pair (entity-cards/dispatch): WebKit re-lays-out the
|
|
550
|
+
children of every nested column flexbox to measure them, so the
|
|
551
|
+
cost compounds per level. One 9-level Mingo reply took ~155ms of
|
|
552
|
+
layout per drawer-resize frame in Safari / the desktop WKWebView
|
|
553
|
+
vs ~9ms as grid (Chrome: ~2ms either way). Same gap and stretch,
|
|
554
|
+
no margin collapsing — visually identical. */}
|
|
555
|
+
<div className="grid min-w-0 grid-cols-1 gap-[var(--spacing-system-xxs)]">
|
|
547
556
|
{/* Avatar + Name + Timestamp Row.
|
|
548
557
|
Sizing rationale (per design-token measurements):
|
|
549
558
|
- Name uses `text-h3` = 14px mobile / 18px desktop.
|
|
@@ -604,7 +613,7 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
|
|
|
604
613
|
|
|
605
614
|
{/* Message segments — hidden for system messages without content */}
|
|
606
615
|
{(!isSystem || segments.length > 0) && (
|
|
607
|
-
<div className="
|
|
616
|
+
<div className="grid grid-cols-1 gap-2">
|
|
608
617
|
{segments.map((segment, index) => {
|
|
609
618
|
// The engine's streaming path (atomic-block memoization +
|
|
610
619
|
// fence tail-completion + aria-live) applies ONLY to the
|
|
@@ -1017,7 +1017,7 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
1017
1017
|
// thread without any stays byte-identical to before.
|
|
1018
1018
|
if (!ownedContent) return row;
|
|
1019
1019
|
return (
|
|
1020
|
-
<div key={message.id} className="
|
|
1020
|
+
<div key={message.id} className="grid w-full min-w-0 grid-cols-1">
|
|
1021
1021
|
{row}
|
|
1022
1022
|
{ownedContent}
|
|
1023
1023
|
</div>
|
|
@@ -2203,7 +2203,7 @@ export function ChatCardLoader({
|
|
|
2203
2203
|
// Same 12px rhythm as the message renderer's block-sibling wrapper
|
|
2204
2204
|
// (`my-3` in chat-message-enhanced) so card→player spacing matches
|
|
2205
2205
|
// the spacing between any two hoisted blocks.
|
|
2206
|
-
<div className="
|
|
2206
|
+
<div className="grid min-w-0 grid-cols-1 gap-3">
|
|
2207
2207
|
{finish(entry.render(item, finalChatRef, renderOpts))}
|
|
2208
2208
|
<ChatVideoEntityCard chatRef={videoRef} />
|
|
2209
2209
|
</div>
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { act, renderHook } from '@testing-library/react';
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { scrollElementIntoView } from '../../../utils/scroll-into-view';
|
|
3
4
|
import { useScrollSpy } from '../use-scroll-spy';
|
|
4
5
|
|
|
6
|
+
// The click's scroll is the shared tween; only its arguments matter here.
|
|
7
|
+
// `getScrollableAncestor` stays real — it is what the spy resolves its scroller with.
|
|
8
|
+
vi.mock('../../../utils/scroll-into-view', async importOriginal => ({
|
|
9
|
+
...(await importOriginal()),
|
|
10
|
+
scrollElementIntoView: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
5
13
|
/**
|
|
6
|
-
* `useScrollSpy` highlights the section whose
|
|
7
|
-
*
|
|
8
|
-
* ancestor), and the last section once the
|
|
14
|
+
* `useScrollSpy` highlights the section whose top — measured in the SCROLLER's
|
|
15
|
+
* own coordinates (not `offsetTop`, which is relative to a positioned
|
|
16
|
+
* ancestor) — has passed the offset line, and the last section once the
|
|
17
|
+
* scroller is at the bottom. The scroller is the window on a plain page, and
|
|
18
|
+
* the nearest scroll container when an app shell scrolls a `<main>` instead.
|
|
9
19
|
*/
|
|
10
20
|
|
|
11
21
|
const TOPS: Record<string, number> = { a: 300, b: 900, c: 1500 };
|
|
@@ -107,3 +117,153 @@ describe('useScrollSpy — syncHash', () => {
|
|
|
107
117
|
expect(replaceState).not.toHaveBeenCalled();
|
|
108
118
|
});
|
|
109
119
|
});
|
|
120
|
+
|
|
121
|
+
describe('useScrollSpy — a rail click', () => {
|
|
122
|
+
const sections = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
|
123
|
+
|
|
124
|
+
it('highlights the clicked section at once and scrolls it under the header offset', () => {
|
|
125
|
+
placeSections(0);
|
|
126
|
+
const { result } = renderHook(() => useScrollSpy(sections, { headerOffset: 96 }));
|
|
127
|
+
act(() => result.current.handleSectionClick('c'));
|
|
128
|
+
expect(result.current.activeSection).toBe('c');
|
|
129
|
+
expect(scrollElementIntoView).toHaveBeenCalledWith(created.get('c'), { headerOffset: 96 });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('with syncHash, writes #section right away — replaceState, no history entry, no hashchange', () => {
|
|
133
|
+
const replaceState = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {});
|
|
134
|
+
const pushState = vi.spyOn(window.history, 'pushState');
|
|
135
|
+
const hashchange = vi.fn();
|
|
136
|
+
window.addEventListener('hashchange', hashchange);
|
|
137
|
+
placeSections(0);
|
|
138
|
+
const { result } = renderHook(() => useScrollSpy(sections, { syncHash: true }));
|
|
139
|
+
act(() => result.current.handleSectionClick('b'));
|
|
140
|
+
expect(replaceState).toHaveBeenCalledWith(null, '', '/#b');
|
|
141
|
+
expect(pushState).not.toHaveBeenCalled();
|
|
142
|
+
expect(hashchange).not.toHaveBeenCalled();
|
|
143
|
+
window.removeEventListener('hashchange', hashchange);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('without syncHash, a click leaves the URL alone', () => {
|
|
147
|
+
const replaceState = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {});
|
|
148
|
+
placeSections(0);
|
|
149
|
+
const { result } = renderHook(() => useScrollSpy(sections));
|
|
150
|
+
act(() => result.current.handleSectionClick('b'));
|
|
151
|
+
expect(replaceState).not.toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('useScrollSpy — headerOffset', () => {
|
|
156
|
+
const sections = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
|
157
|
+
|
|
158
|
+
it('is the detection line too, so the highlight agrees with where a click lands', () => {
|
|
159
|
+
placeSections(0);
|
|
160
|
+
const { result } = renderHook(() => useScrollSpy(sections, { headerOffset: 50 }));
|
|
161
|
+
scrollTo(860); // 860 + 50 ≥ 900 → b
|
|
162
|
+
expect(result.current.activeSection).toBe('b');
|
|
163
|
+
scrollTo(840); // 890 < 900 → still a (the default 100 would already say b)
|
|
164
|
+
expect(result.current.activeSection).toBe('a');
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* An app shell that scrolls a fixed-height `<main overflow-y-auto>` instead of
|
|
170
|
+
* the window (OpenFrame's `AppLayout`): the window never scrolls and never
|
|
171
|
+
* emits `scroll`, so the spy must listen to the container and measure section
|
|
172
|
+
* tops relative to it — the shell's header sits ABOVE the container, so a
|
|
173
|
+
* viewport-relative top is off by that much.
|
|
174
|
+
*/
|
|
175
|
+
describe('useScrollSpy — inside a scroll container', () => {
|
|
176
|
+
const sections = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
|
177
|
+
/** The shell header above `<main>`: where the container starts in the viewport. */
|
|
178
|
+
const CONTAINER_TOP = 64;
|
|
179
|
+
let container: HTMLElement;
|
|
180
|
+
let containerScrollTop = 0;
|
|
181
|
+
|
|
182
|
+
function placeInContainer(scrollTop: number) {
|
|
183
|
+
for (const [id, top] of Object.entries(TOPS)) {
|
|
184
|
+
const el = created.get(id);
|
|
185
|
+
if (!el) throw new Error(`section ${id} not mounted`);
|
|
186
|
+
// Viewport-relative top = container's viewport top + (offset inside the container − its scrollTop).
|
|
187
|
+
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({ top: CONTAINER_TOP + top - scrollTop } as DOMRect);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function mountContainer(scrollHeight = 5000) {
|
|
192
|
+
container = document.createElement('main');
|
|
193
|
+
container.style.overflowY = 'auto';
|
|
194
|
+
document.body.append(container);
|
|
195
|
+
containerScrollTop = 0;
|
|
196
|
+
// jsdom lays nothing out: give the container the geometry of a scrolling <main>.
|
|
197
|
+
Object.defineProperty(container, 'scrollHeight', { configurable: true, value: scrollHeight });
|
|
198
|
+
Object.defineProperty(container, 'clientHeight', { configurable: true, value: 700 });
|
|
199
|
+
Object.defineProperty(container, 'scrollTop', {
|
|
200
|
+
configurable: true,
|
|
201
|
+
get: () => containerScrollTop,
|
|
202
|
+
set: (value: number) => {
|
|
203
|
+
containerScrollTop = value;
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({ top: CONTAINER_TOP } as DOMRect);
|
|
207
|
+
for (const id of Object.keys(TOPS)) {
|
|
208
|
+
const el = document.createElement('section');
|
|
209
|
+
el.id = id;
|
|
210
|
+
container.append(el);
|
|
211
|
+
created.set(id, el);
|
|
212
|
+
}
|
|
213
|
+
placeInContainer(0);
|
|
214
|
+
// The window stays parked: a window-bound spy would read these and see nothing.
|
|
215
|
+
Object.defineProperty(window, 'scrollY', { configurable: true, value: 0 });
|
|
216
|
+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 });
|
|
217
|
+
Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 800 });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function scrollContainerTo(scrollTop: number) {
|
|
221
|
+
container.scrollTop = scrollTop;
|
|
222
|
+
placeInContainer(scrollTop);
|
|
223
|
+
act(() => {
|
|
224
|
+
container.dispatchEvent(new Event('scroll'));
|
|
225
|
+
vi.advanceTimersByTime(150);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
afterEach(() => container.remove());
|
|
230
|
+
|
|
231
|
+
it('follows the container scroll, measuring section tops in the container coordinates', () => {
|
|
232
|
+
mountContainer();
|
|
233
|
+
const { result } = renderHook(() => useScrollSpy(sections));
|
|
234
|
+
expect(result.current.activeSection).toBe('a');
|
|
235
|
+
scrollContainerTo(850); // 850 + 100 ≥ 900 → b (viewport top 114 alone would have said a)
|
|
236
|
+
expect(result.current.activeSection).toBe('b');
|
|
237
|
+
scrollContainerTo(250); // 350 ≥ 300 → a
|
|
238
|
+
expect(result.current.activeSection).toBe('a');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('ignores the window: a window scroll event changes nothing', () => {
|
|
242
|
+
mountContainer();
|
|
243
|
+
const { result } = renderHook(() => useScrollSpy(sections));
|
|
244
|
+
scrollContainerTo(850);
|
|
245
|
+
expect(result.current.activeSection).toBe('b');
|
|
246
|
+
Object.defineProperty(window, 'scrollY', { configurable: true, value: 4000 });
|
|
247
|
+
act(() => {
|
|
248
|
+
window.dispatchEvent(new Event('scroll'));
|
|
249
|
+
vi.advanceTimersByTime(150);
|
|
250
|
+
});
|
|
251
|
+
expect(result.current.activeSection).toBe('b');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('highlights the last section at the bottom of the container', () => {
|
|
255
|
+
mountContainer(2200);
|
|
256
|
+
const { result } = renderHook(() => useScrollSpy(sections));
|
|
257
|
+
scrollContainerTo(1500); // 700 + 1500 ≥ 2200 → bottom → c (its top, 1500, never passes 1600)
|
|
258
|
+
expect(result.current.activeSection).toBe('c');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('syncHash follows the container scroll', () => {
|
|
262
|
+
const replaceState = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {});
|
|
263
|
+
mountContainer();
|
|
264
|
+
renderHook(() => useScrollSpy(sections, { syncHash: true }));
|
|
265
|
+
scrollContainerTo(850);
|
|
266
|
+
scrollContainerTo(50); // 150 < 300: above section a → cleared
|
|
267
|
+
expect(replaceState.mock.calls.map(call => call[2])).toEqual(['/#b', '/']);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
|
4
|
-
import { scrollElementIntoView } from '../../utils/scroll-into-view';
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
4
|
+
import { getScrollableAncestor, scrollElementIntoView } from '../../utils/scroll-into-view';
|
|
5
|
+
|
|
6
|
+
// Default sticky-chrome height. Used for BOTH the scroll target offset (where a
|
|
7
|
+
// clicked section lands) AND the active-section detection threshold (where the
|
|
8
|
+
// scroll listener flips highlight). They must match — previously 100 vs 150
|
|
9
|
+
// caused a 50px window where the indicator jumped to the next section even
|
|
10
|
+
// though that section's top was still below the clicked one's resting offset.
|
|
11
|
+
// `headerOffset` overrides both at once, so they cannot drift apart.
|
|
12
12
|
const SCROLL_OFFSET = 100;
|
|
13
13
|
|
|
14
14
|
// Separator for the section-id key. A newline cannot occur inside an HTML id,
|
|
@@ -23,12 +23,19 @@ interface ScrollSpySection {
|
|
|
23
23
|
|
|
24
24
|
export interface UseScrollSpyOptions {
|
|
25
25
|
/**
|
|
26
|
-
* Keep the URL's `#hash` on the section being read
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* Keep the URL's `#hash` on the section being read (`replaceState`: no
|
|
27
|
+
* history entry, and no `hashchange`, so `useScrollToHash` never re-scrolls
|
|
28
|
+
* to it) — as the user SCROLLS, and when a rail click scrolls for them.
|
|
29
|
+
* Above the first section the hash is cleared. Only a scroll or a click
|
|
30
|
+
* writes it — mounting a page never adds a hash.
|
|
30
31
|
*/
|
|
31
32
|
syncHash?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Sticky-chrome height in px: where a clicked section lands AND the line a
|
|
35
|
+
* section's top must pass to become the active one. One number for both, so
|
|
36
|
+
* the highlight agrees with where the click put the section. Default 100.
|
|
37
|
+
*/
|
|
38
|
+
headerOffset?: number;
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
/** Point the URL's hash at `sectionId` (or clear it) without a history entry or a `hashchange`. */
|
|
@@ -39,6 +46,50 @@ function replaceHash(sectionId: string | null): void {
|
|
|
39
46
|
window.history.replaceState(window.history.state, '', `${pathname}${search}${next}`);
|
|
40
47
|
}
|
|
41
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The thing that scrolls the sections: the nearest real scroll container
|
|
51
|
+
* around them, or the window. Reads are routed through it so the same
|
|
52
|
+
* arithmetic serves both — the window is just the scroller whose visible area
|
|
53
|
+
* starts at viewport y = 0.
|
|
54
|
+
*/
|
|
55
|
+
interface Scroller {
|
|
56
|
+
target: HTMLElement | Window;
|
|
57
|
+
scrollTop: () => number;
|
|
58
|
+
/** Where the scroller's visible area starts, in viewport coordinates. */
|
|
59
|
+
viewportTop: () => number;
|
|
60
|
+
clientHeight: () => number;
|
|
61
|
+
scrollHeight: () => number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the scroller from the first section, with the SAME rule
|
|
66
|
+
* `scrollElementIntoView` uses to pick where it scrolls (`getScrollableAncestor`).
|
|
67
|
+
* App shells that put page content in a fixed-height `<main overflow-y-auto>`
|
|
68
|
+
* (OpenFrame's `AppLayout`) never scroll the window: a window-bound spy there
|
|
69
|
+
* sees `scrollY` 0 forever and never gets a `scroll` event, so the first
|
|
70
|
+
* section stayed highlighted whatever was on screen. No such ancestor (a plain
|
|
71
|
+
* page, or the sections not rendered yet) → the window, as before.
|
|
72
|
+
*/
|
|
73
|
+
function resolveScroller(firstSection: HTMLElement | null): Scroller {
|
|
74
|
+
const container = firstSection ? getScrollableAncestor(firstSection) : null;
|
|
75
|
+
if (container) {
|
|
76
|
+
return {
|
|
77
|
+
target: container,
|
|
78
|
+
scrollTop: () => container.scrollTop,
|
|
79
|
+
viewportTop: () => container.getBoundingClientRect().top,
|
|
80
|
+
clientHeight: () => container.clientHeight,
|
|
81
|
+
scrollHeight: () => container.scrollHeight,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
target: window,
|
|
86
|
+
scrollTop: () => window.scrollY,
|
|
87
|
+
viewportTop: () => 0,
|
|
88
|
+
clientHeight: () => window.innerHeight,
|
|
89
|
+
scrollHeight: () => document.documentElement.scrollHeight,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
42
93
|
interface UseScrollSpyReturn {
|
|
43
94
|
activeSection: string;
|
|
44
95
|
handleSectionClick: (sectionId: string) => void;
|
|
@@ -46,13 +97,18 @@ interface UseScrollSpyReturn {
|
|
|
46
97
|
|
|
47
98
|
/**
|
|
48
99
|
* Shared scroll spy hook for tracking active section based on scroll position.
|
|
49
|
-
* Used by DocViewer for sticky section navigation.
|
|
100
|
+
* Used by DocViewer and TrustCenterPage for sticky section navigation.
|
|
101
|
+
*
|
|
102
|
+
* Listens to whatever actually scrolls the sections — the nearest scroll
|
|
103
|
+
* container, else the window (see `resolveScroller`) — and measures section
|
|
104
|
+
* tops in that scroller's own coordinates, which is exactly how
|
|
105
|
+
* `scrollElementIntoView` computes the target of a click.
|
|
50
106
|
*/
|
|
51
107
|
export function useScrollSpy(
|
|
52
108
|
sections: ScrollSpySection[] | undefined,
|
|
53
109
|
options: UseScrollSpyOptions = {},
|
|
54
110
|
): UseScrollSpyReturn {
|
|
55
|
-
const { syncHash = false } = options;
|
|
111
|
+
const { syncHash = false, headerOffset = SCROLL_OFFSET } = options;
|
|
56
112
|
const [activeSection, setActiveSection] = useState('');
|
|
57
113
|
const isScrollingFromClick = useRef(false);
|
|
58
114
|
|
|
@@ -63,46 +119,63 @@ export function useScrollSpy(
|
|
|
63
119
|
// of the dependency array.
|
|
64
120
|
const sectionIdsKey = useMemo(() => sections?.map(s => s.id).join(ID_SEPARATOR) ?? '', [sections]);
|
|
65
121
|
|
|
66
|
-
const handleSectionClick = useCallback(
|
|
67
|
-
|
|
68
|
-
|
|
122
|
+
const handleSectionClick = useCallback(
|
|
123
|
+
(sectionId: string) => {
|
|
124
|
+
const targetElement = document.getElementById(sectionId);
|
|
125
|
+
if (!targetElement) return;
|
|
69
126
|
|
|
70
|
-
|
|
71
|
-
|
|
127
|
+
isScrollingFromClick.current = true;
|
|
128
|
+
setActiveSection(sectionId);
|
|
129
|
+
// The click IS the reader's position now: say so in the URL right away
|
|
130
|
+
// rather than waiting for a scroll event the click guard below swallows.
|
|
131
|
+
if (syncHash) replaceHash(sectionId);
|
|
72
132
|
|
|
73
|
-
|
|
133
|
+
scrollElementIntoView(targetElement, { headerOffset });
|
|
74
134
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
135
|
+
setTimeout(() => {
|
|
136
|
+
isScrollingFromClick.current = false;
|
|
137
|
+
}, 800);
|
|
138
|
+
},
|
|
139
|
+
[syncHash, headerOffset],
|
|
140
|
+
);
|
|
79
141
|
|
|
80
142
|
useEffect(() => {
|
|
81
143
|
const sectionIds = sectionIdsKey === '' ? [] : sectionIdsKey.split(ID_SEPARATOR);
|
|
82
144
|
if (sectionIds.length === 0) return undefined;
|
|
83
145
|
|
|
146
|
+
// Pick the scroller ONCE per section set, like `scrollElementIntoView` does
|
|
147
|
+
// per call: the sections and their container render together, so the
|
|
148
|
+
// effect sees the final layout.
|
|
149
|
+
const scroller = resolveScroller(document.getElementById(sectionIds[0] ?? ''));
|
|
150
|
+
|
|
84
151
|
// `fromScroll`: a real scroll settled (not the mount-time pass).
|
|
85
152
|
const handleScroll = (fromScroll = false) => {
|
|
86
153
|
if (isScrollingFromClick.current) return;
|
|
87
154
|
|
|
88
|
-
const
|
|
155
|
+
const scrollTop = scroller.scrollTop();
|
|
156
|
+
const viewportTop = scroller.viewportTop();
|
|
157
|
+
const scrollPosition = scrollTop + headerOffset;
|
|
158
|
+
// A section's top in the scroller's coordinates. `offsetTop` is relative
|
|
159
|
+
// to the nearest POSITIONED ancestor, which is neither the document nor
|
|
160
|
+
// the scroll container inside most layouts — measure from the viewport
|
|
161
|
+
// and translate.
|
|
162
|
+
const sectionTop = (element: HTMLElement) => element.getBoundingClientRect().top - viewportTop + scrollTop;
|
|
89
163
|
let currentSection = sectionIds[0] ?? '';
|
|
90
164
|
|
|
91
165
|
// At the bottom of the page the last sections can never reach the offset
|
|
92
166
|
// line, so they would never highlight: the last one wins there. Only on a
|
|
93
167
|
// page that actually SCROLLS — a short page is "at the bottom" at
|
|
94
|
-
//
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
const
|
|
168
|
+
// scrollTop 0, which would otherwise highlight the last section on load.
|
|
169
|
+
const scrollHeight = scroller.scrollHeight();
|
|
170
|
+
const clientHeight = scroller.clientHeight();
|
|
171
|
+
const scrollable = scrollHeight > clientHeight + 2;
|
|
172
|
+
const atBottom = scrollable && clientHeight + scrollTop >= scrollHeight - 2;
|
|
98
173
|
if (atBottom) {
|
|
99
174
|
currentSection = sectionIds[sectionIds.length - 1] ?? currentSection;
|
|
100
175
|
} else {
|
|
101
176
|
for (let i = sectionIds.length - 1; i >= 0; i--) {
|
|
102
177
|
const element = document.getElementById(sectionIds[i]);
|
|
103
|
-
|
|
104
|
-
// POSITIONED ancestor, which is not the document inside most layouts.
|
|
105
|
-
if (element && scrollPosition >= element.getBoundingClientRect().top + window.scrollY) {
|
|
178
|
+
if (element && scrollPosition >= sectionTop(element)) {
|
|
106
179
|
currentSection = sectionIds[i];
|
|
107
180
|
break;
|
|
108
181
|
}
|
|
@@ -113,8 +186,7 @@ export function useScrollSpy(
|
|
|
113
186
|
|
|
114
187
|
if (syncHash && fromScroll) {
|
|
115
188
|
const first = document.getElementById(sectionIds[0] ?? '');
|
|
116
|
-
const aboveFirst =
|
|
117
|
-
!atBottom && first !== null && scrollPosition < first.getBoundingClientRect().top + window.scrollY;
|
|
189
|
+
const aboveFirst = !atBottom && first !== null && scrollPosition < sectionTop(first);
|
|
118
190
|
replaceHash(aboveFirst ? null : currentSection);
|
|
119
191
|
}
|
|
120
192
|
};
|
|
@@ -125,14 +197,14 @@ export function useScrollSpy(
|
|
|
125
197
|
scrollTimer = setTimeout(() => handleScroll(true), 100);
|
|
126
198
|
};
|
|
127
199
|
|
|
128
|
-
|
|
200
|
+
scroller.target.addEventListener('scroll', throttledScroll);
|
|
129
201
|
handleScroll();
|
|
130
202
|
|
|
131
203
|
return () => {
|
|
132
|
-
|
|
204
|
+
scroller.target.removeEventListener('scroll', throttledScroll);
|
|
133
205
|
clearTimeout(scrollTimer);
|
|
134
206
|
};
|
|
135
|
-
}, [sectionIdsKey, syncHash]);
|
|
207
|
+
}, [sectionIdsKey, syncHash, headerOffset]);
|
|
136
208
|
|
|
137
209
|
return { activeSection, handleSectionClick };
|
|
138
210
|
}
|
|
@@ -397,7 +397,7 @@ describe('TrustCenterPage', () => {
|
|
|
397
397
|
expect(subprocessors.queryByText('Location')).toBeNull();
|
|
398
398
|
});
|
|
399
399
|
|
|
400
|
-
it('anchors: a rail click puts #section in the URL
|
|
400
|
+
it('anchors: a rail click highlights the section at once and puts #section in the URL (replace, not push)', () => {
|
|
401
401
|
// The shared setup stubs `window.location` without an origin; give it one for this navigation.
|
|
402
402
|
const stubbed = window.location;
|
|
403
403
|
Object.defineProperty(window, 'location', {
|
|
@@ -417,6 +417,10 @@ describe('TrustCenterPage', () => {
|
|
|
417
417
|
render(<TrustCenterPage initialData={makeData()} />);
|
|
418
418
|
const rail = within(screen.getByRole('complementary', { name: 'Trust center sections' }));
|
|
419
419
|
fireEvent.click(rail.getByRole('button', { name: 'Documents' }));
|
|
420
|
+
// The click IS the reader's position: the rail says so before any scroll
|
|
421
|
+
// event lands (inside a `<main overflow-y-auto>` shell none ever did).
|
|
422
|
+
expect(rail.getByRole('button', { name: 'Documents' })).toHaveAttribute('aria-current', 'location');
|
|
423
|
+
expect(rail.getByRole('button', { name: 'Compliance' })).not.toHaveAttribute('aria-current');
|
|
420
424
|
expect(replaceState).toHaveBeenCalledWith(null, '', '/trust-center#documents');
|
|
421
425
|
expect(pushState).not.toHaveBeenCalled();
|
|
422
426
|
} finally {
|
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
type TrustCenterPublic,
|
|
44
44
|
type TrustCenterSectionId,
|
|
45
45
|
} from '../../types/trust-center';
|
|
46
|
-
import {
|
|
46
|
+
import { STICKY_HEADER_OFFSET_PX } from '../../utils/same-page-hash-nav';
|
|
47
47
|
import { useScrollSpy } from '../docs/use-scroll-spy';
|
|
48
48
|
import { FaqSection } from '../faq/faq-section';
|
|
49
49
|
import { PageShell } from '../layout/article-detail-layout';
|
|
@@ -178,16 +178,20 @@ export function TrustCenterPage({
|
|
|
178
178
|
});
|
|
179
179
|
|
|
180
180
|
const sections = useMemo(() => (data ? visibleSections(data) : []), [data]);
|
|
181
|
-
// Anchors
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
// scrolls
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
181
|
+
// Anchors. The scroll spy lights the rail from whatever scrolls the sections
|
|
182
|
+
// (the window, or a host shell's `<main overflow-y-auto>` — OpenFrame's
|
|
183
|
+
// `AppLayout`) and keeps the URL's hash on the section in view (`syncHash`;
|
|
184
|
+
// `replace`: the rail is a table of contents, not a navigation step). A rail
|
|
185
|
+
// click goes through the spy too: it highlights the section at once, writes
|
|
186
|
+
// `#section` and scrolls it below the sticky header — the SAME offset the
|
|
187
|
+
// spy's detection line uses, so the highlight agrees with where the section
|
|
188
|
+
// landed. A visit that ARRIVES with `#section`, and back/forward, scroll
|
|
189
|
+
// there once the sections have rendered (`hashchange`, which `replaceState`
|
|
190
|
+
// never fires — so a click never re-scrolls through that path).
|
|
191
|
+
const { activeSection, handleSectionClick } = useScrollSpy(sections, {
|
|
192
|
+
syncHash: true,
|
|
193
|
+
headerOffset: STICKY_HEADER_OFFSET_PX,
|
|
194
|
+
});
|
|
191
195
|
useScrollToHash(sections.length > 0 ? sections.map(section => section.id).join('|') : null, {
|
|
192
196
|
headerOffset: STICKY_HEADER_OFFSET_PX,
|
|
193
197
|
});
|
|
@@ -66,6 +66,9 @@ export function StickySectionNav({
|
|
|
66
66
|
{/* Navigation button */}
|
|
67
67
|
<button
|
|
68
68
|
onClick={() => onSectionClick(section.id)}
|
|
69
|
+
// The ribbon is the visual; this is the same state for assistive
|
|
70
|
+
// tech (a table of contents marks the reader's current location).
|
|
71
|
+
aria-current={activeSection === section.id ? 'location' : undefined}
|
|
69
72
|
className="relative flex flex-1 cursor-pointer items-center gap-2 px-3 py-2"
|
|
70
73
|
>
|
|
71
74
|
<span
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
|
2
|
+
import { fn } from 'storybook/test';
|
|
2
3
|
import {
|
|
3
4
|
TRUST_CENTER_FIXTURE_FAQ,
|
|
4
5
|
makeTrustCenterData,
|
|
5
6
|
} from '../components/help-center-pages/__fixtures__/trust-center';
|
|
6
7
|
import { TrustCenterPage } from '../components/help-center-pages/trust-center-page';
|
|
8
|
+
import { ChartDonutIcon, QuestionCircleIcon } from '../components/icons-v2-generated';
|
|
9
|
+
import { AppLayout } from '../components/navigation/app-layout';
|
|
7
10
|
|
|
8
11
|
// Stories pass `initialData`, so the page never fetches. The shared fixture
|
|
9
12
|
// computes its instants when called, so the "monitored" story stays inside its window.
|
|
@@ -93,6 +96,41 @@ export const NotConnected: Story = {
|
|
|
93
96
|
},
|
|
94
97
|
};
|
|
95
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Embedded in an app shell (OpenFrame's `AppLayout`), where the page content
|
|
101
|
+
* scrolls inside a fixed-height `<main overflow-y-auto>` and the window never
|
|
102
|
+
* scrolls. The section rail must still follow the reader: the scroll spy
|
|
103
|
+
* listens to that container, not the window.
|
|
104
|
+
*/
|
|
105
|
+
export const InAppShell: Story = {
|
|
106
|
+
args: { shell: false, backButton: { label: 'Back to Help Center', href: '/help-center' } },
|
|
107
|
+
render: args => (
|
|
108
|
+
<AppLayout
|
|
109
|
+
sidebarConfig={{
|
|
110
|
+
items: [
|
|
111
|
+
{ id: 'dashboard', label: 'Dashboard', icon: <ChartDonutIcon size={24} />, path: '/dashboard' },
|
|
112
|
+
{
|
|
113
|
+
id: 'help-center',
|
|
114
|
+
label: 'Help Center',
|
|
115
|
+
icon: <QuestionCircleIcon size={24} />,
|
|
116
|
+
path: '/help-center',
|
|
117
|
+
section: 'secondary',
|
|
118
|
+
isActive: true,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
onNavigate: fn(),
|
|
122
|
+
onToggleMinimized: fn(),
|
|
123
|
+
}}
|
|
124
|
+
headerProps={{ showUser: true, userName: 'Alex Developer', userEmail: 'alex@openframe.dev' }}
|
|
125
|
+
mobileBurgerMenuProps={{ user: { userName: 'Alex Developer', userEmail: 'alex@openframe.dev' } }}
|
|
126
|
+
>
|
|
127
|
+
<div className="page-shell-content">
|
|
128
|
+
<TrustCenterPage {...args} />
|
|
129
|
+
</div>
|
|
130
|
+
</AppLayout>
|
|
131
|
+
),
|
|
132
|
+
};
|
|
133
|
+
|
|
96
134
|
/** Certified framework with a published percent (progress ring + report period). */
|
|
97
135
|
export const CertifiedWithPercent: Story = {
|
|
98
136
|
args: {
|
|
@@ -85,8 +85,13 @@ const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3);
|
|
|
85
85
|
/** Nearest ancestor that is a *real* scroll container, or `null` when the
|
|
86
86
|
* window/document is the scroller. Only `auto | scroll | overlay` count —
|
|
87
87
|
* `clip` / `hidden` are intentionally excluded (a wrapper using `overflow-clip`
|
|
88
|
-
* purely to round corners must let the scroll bubble to the page).
|
|
89
|
-
|
|
88
|
+
* purely to round corners must let the scroll bubble to the page).
|
|
89
|
+
*
|
|
90
|
+
* THE one rule for "what scrolls this element": `useScrollSpy` resolves the
|
|
91
|
+
* scroller it listens to with it, so the section a spy highlights is the
|
|
92
|
+
* section this helper scrolls to — inside a `<main overflow-y-auto>` shell as
|
|
93
|
+
* much as on a window-scrolled page. */
|
|
94
|
+
export function getScrollableAncestor(el: HTMLElement): HTMLElement | null {
|
|
90
95
|
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
91
96
|
const overflowY = getComputedStyle(node).overflowY;
|
|
92
97
|
if (
|