From 87901417900e006c4edb2b33690e011c3ed2e43f Mon Sep 17 00:00:00 2001 From: Samuel Richardson Date: Sat, 23 May 2026 10:16:26 +1000 Subject: [PATCH] feat(positioning): added external scroll into view Adds a new function which can be used to externally scroll into view a portion of the embed. This is designed to fix the issue with cross origin scroll into view on Safari. --- README.md | 35 ++++--- src/index.test.ts | 105 +++++++++++++++++++ src/index.ts | 24 +++++ src/services/messages.ts | 1 + src/services/messages/v1/scroll_into_view.ts | 11 ++ 5 files changed, 163 insertions(+), 13 deletions(-) create mode 100644 src/services/messages/v1/scroll_into_view.ts diff --git a/README.md b/README.md index e2da10d..01a8a84 100644 --- a/README.md +++ b/README.md @@ -105,19 +105,20 @@ embed.on(MESSAGE_KIND.TOAST, (data: ToastMessageDataV1) => { The full list of events is below. Events marked _none_ in the **Data** column are fired without a payload; events with a linked payload have their data shape documented further down. -| Event | Constant | Data | When it fires | -| ---------------------------- | ------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Toast message | `MESSAGE_KIND.TOAST` | [See payload](#toast-payload) | A notification should be shown to the user, e.g. when they have selected a fund. | -| Window dimension change | `MESSAGE_KIND.WINDOW_DIMENSION_CHANGE` | [See payload](#window-dimension-change-payload) | The DOM contents of the embed have changed and caused the height of the embed to change. | -| Loaded | `MESSAGE_KIND.LOADED` | _none_ | The embed has finished loading. | -| Employer settings updated¹ | `MESSAGE_KIND.EMPLOYER_SETTINGS_UPDATED` | _none_ | An update has been made to the employer settings but the change has not yet been delivered to the partner system. | -| Employer settings committed¹ | `MESSAGE_KIND.EMPLOYER_SETTINGS_COMMITTED` | _none_ | A change to the employer settings has been committed into the partner system (the partner webhook responded with a `<400` status code). | -| Onboarding intent completed | `MESSAGE_KIND.ONBOARDING_INTENT_COMPLETED` | _none_ | A user has completed their onboarding session intent. Useful for showing a "processing" state while waiting for the webhook to complete. | -| Onboarding session committed | `MESSAGE_KIND.ONBOARDING_SESSION_COMMITTED` | _none_ | A user has completed the onboarding flow and the data has been delivered into the partner system (the same "committed" rules as employer settings apply here). | -| Onboarding session finished | `MESSAGE_KIND.ONBOARDING_SESSION_FINISHED` | _none_ | A user has finished the onboarding flow but the payload has not yet been transmitted. Use this to move the user to the next step without waiting on async provisioning. | -| Onboarding step changed | `MESSAGE_KIND.ONBOARDING_STEP_CHANGED` | [See payload](#onboarding-step-changed-payload) | The embed has loaded, or the user has moved to the next step in the onboarding flow. | -| MFA verification completed | `MESSAGE_KIND.MFA_VERIFICATION_COMPLETED` | [See payload](#mfa-verification-completed-payload) | The user has finished their MFA session, either by verifying successfully or by exhausting the maximum number of verification attempts. | -| Page loaded | `MESSAGE_KIND.PAGE_LOADED` | _none_ | A new page is loaded inside the embed. The embed will also scroll the iFrame to the top of the viewport so the new page is visible. | +| Event | Constant | Data | When it fires | +| ---------------------------- | ------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Toast message | `MESSAGE_KIND.TOAST` | [See payload](#toast-payload) | A notification should be shown to the user, e.g. when they have selected a fund. | +| Window dimension change | `MESSAGE_KIND.WINDOW_DIMENSION_CHANGE` | [See payload](#window-dimension-change-payload) | The DOM contents of the embed have changed and caused the height of the embed to change. | +| Loaded | `MESSAGE_KIND.LOADED` | _none_ | The embed has finished loading. | +| Employer settings updated¹ | `MESSAGE_KIND.EMPLOYER_SETTINGS_UPDATED` | _none_ | An update has been made to the employer settings but the change has not yet been delivered to the partner system. | +| Employer settings committed¹ | `MESSAGE_KIND.EMPLOYER_SETTINGS_COMMITTED` | _none_ | A change to the employer settings has been committed into the partner system (the partner webhook responded with a `<400` status code). | +| Onboarding intent completed | `MESSAGE_KIND.ONBOARDING_INTENT_COMPLETED` | _none_ | A user has completed their onboarding session intent. Useful for showing a "processing" state while waiting for the webhook to complete. | +| Onboarding session committed | `MESSAGE_KIND.ONBOARDING_SESSION_COMMITTED` | _none_ | A user has completed the onboarding flow and the data has been delivered into the partner system (the same "committed" rules as employer settings apply here). | +| Onboarding session finished | `MESSAGE_KIND.ONBOARDING_SESSION_FINISHED` | _none_ | A user has finished the onboarding flow but the payload has not yet been transmitted. Use this to move the user to the next step without waiting on async provisioning. | +| Onboarding step changed | `MESSAGE_KIND.ONBOARDING_STEP_CHANGED` | [See payload](#onboarding-step-changed-payload) | The embed has loaded, or the user has moved to the next step in the onboarding flow. | +| MFA verification completed | `MESSAGE_KIND.MFA_VERIFICATION_COMPLETED` | [See payload](#mfa-verification-completed-payload) | The user has finished their MFA session, either by verifying successfully or by exhausting the maximum number of verification attempts. | +| Page loaded | `MESSAGE_KIND.PAGE_LOADED` | _none_ | A new page is loaded inside the embed. The embed will also scroll the iFrame to the top of the viewport so the new page is visible. | +| Scroll into view | `MESSAGE_KIND.SCROLL_INTO_VIEW` | [See payload](#scroll-into-view-payload) | The embed has requested that the host page scroll so a position inside the iFrame becomes visible. The library smoothly scrolls the host so that position is at the top of the viewport. | ¹ Only fires for URLs which load the employer settings embed. The updated/committed pair can be used together to drive a busy/in-flight state in your UI. @@ -153,6 +154,14 @@ If a sticky header on your host page is hiding the top of the iFrame after a `PA | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `verified_at` | An ISO 8601 timestamp indicating when the MFA session was verified. Will be `null` when the maximum number of verification attempts has been exceeded, allowing you to distinguish a successful verification from an exhausted one. | +#### Scroll into view payload + +| Name | Description | +| ----------- | ---------------------------------------------------------------------------------------------------------------------- | +| `offsetTop` | The vertical position inside the iFrame, in CSS pixels, that should be brought to the top of the host page's viewport. | + +If your host page sets `scroll-padding-top` on the `html` element (e.g. for a sticky header), it is respected. + ### Instance methods The object returned from `new Embed({ ... })` exposes the following methods. Event callbacks receive the event's payload (the inner `data`), not the full message envelope; the payload shape for each event is described in the [Events](#events) section. diff --git a/src/index.test.ts b/src/index.test.ts index 3f4130f..cebf1d1 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -498,6 +498,111 @@ describe("Embed", () => { }); }); }); + + describe("scroll into view event", () => { + let scrollMarginAtCallTime: string | undefined; + + beforeEach(() => { + scrollMarginAtCallTime = undefined; + Element.prototype.scrollIntoView = jest.fn(function ( + this: HTMLElement, + ) { + scrollMarginAtCallTime = this.style.scrollMarginTop; + }); + }); + + it("logs the event", () => { + fireEvent( + window, + new MessageEvent("message", { + data: { + kind: MESSAGE_KIND.SCROLL_INTO_VIEW, + data: { offsetTop: 240 }, + }, + origin: "https://api.superapi.com.au", + }), + ); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(loglevel.debug).toHaveBeenCalledWith( + "Reacting to scroll into view, scrolling host to iFrame offset 240", + ); + }); + + it("calls externally bound listener", () => { + const listener = jest.fn(); + + const data = { + kind: MESSAGE_KIND.SCROLL_INTO_VIEW, + data: { offsetTop: 240 }, + }; + + embed.on(MESSAGE_KIND.SCROLL_INTO_VIEW, listener); + + fireEvent( + window, + new MessageEvent("message", { + data, + origin: "https://api.superapi.com.au", + }), + ); + + expect(listener).toHaveBeenCalledWith(data.data); + }); + + it("smooth-scrolls the iframe into view", () => { + fireEvent( + window, + new MessageEvent("message", { + data: { + kind: MESSAGE_KIND.SCROLL_INTO_VIEW, + data: { offsetTop: 240 }, + }, + origin: "https://api.superapi.com.au", + }), + ); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ + behavior: "smooth", + block: "start", + }); + }); + + it("applies a negative scrollMarginTop on the iframe for the scrollIntoView call", () => { + fireEvent( + window, + new MessageEvent("message", { + data: { + kind: MESSAGE_KIND.SCROLL_INTO_VIEW, + data: { offsetTop: 240 }, + }, + origin: "https://api.superapi.com.au", + }), + ); + + expect(scrollMarginAtCallTime).toBe("-240px"); + }); + + it("restores the iframe scrollMarginTop after scrolling", () => { + const scope = within(element); + const iframe = scope.getByTestId("iframe"); + iframe.style.scrollMarginTop = "16px"; + + fireEvent( + window, + new MessageEvent("message", { + data: { + kind: MESSAGE_KIND.SCROLL_INTO_VIEW, + data: { offsetTop: 240 }, + }, + origin: "https://api.superapi.com.au", + }), + ); + + expect(iframe.style.scrollMarginTop).toBe("16px"); + }); + }); }); }); diff --git a/src/index.ts b/src/index.ts index c0d1fcb..7c644e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,10 @@ import type { Data as PageLoadedDataV1, Message as PageLoadedMessageV1, } from "./services/messages/v1/page_loaded"; +import type { + Data as ScrollIntoViewDataV1, + Message as ScrollIntoViewMessageV1, +} from "./services/messages/v1/scroll_into_view"; import type { Data as ToastMessageDataV1, Kind as ToastKindV1, @@ -60,6 +64,7 @@ export { export { OnboardingSessionFinishedDataV1, OnboardingSessionFinishedMessageV1 }; export { OnboardingStepChangedDataV1, OnboardingStepChangedMessageV1 }; export { PageLoadedDataV1, PageLoadedMessageV1 }; +export { ScrollIntoViewDataV1, ScrollIntoViewMessageV1 }; export { ToastKindV1, ToastMessageDataV1, ToastMessageV1 }; export { LoadedMessageDataV1, LoadedMessageV1 }; export { WindowDimensionChangeMessageDataV1, WindowDimensionChangeMessageV1 }; @@ -74,6 +79,7 @@ export type AvailableMessages = | OnboardingSessionFinishedMessageV1 | OnboardingStepChangedMessageV1 | PageLoadedMessageV1 + | ScrollIntoViewMessageV1 | ToastMessageV1 | WindowDimensionChangeMessageV1; @@ -87,6 +93,7 @@ export type MessageKindToTypeMap = { [MESSAGE_KIND.ONBOARDING_SESSION_FINISHED]: OnboardingSessionFinishedDataV1; [MESSAGE_KIND.ONBOARDING_STEP_CHANGED]: OnboardingStepChangedDataV1; [MESSAGE_KIND.PAGE_LOADED]: PageLoadedDataV1; + [MESSAGE_KIND.SCROLL_INTO_VIEW]: ScrollIntoViewDataV1; [MESSAGE_KIND.TOAST]: ToastMessageDataV1; [MESSAGE_KIND.WINDOW_DIMENSION_CHANGE]: WindowDimensionChangeMessageDataV1; }; @@ -306,6 +313,23 @@ export class Embed { break; } + case MESSAGE_KIND.SCROLL_INTO_VIEW: { + const { offsetTop } = event.data.data; + + log.debug( + `Reacting to scroll into view, scrolling host to iFrame offset ${offsetTop}`, + ); + + const previousScrollMargin = this.iframe.style.scrollMarginTop; + this.iframe.style.scrollMarginTop = `-${offsetTop}px`; + this.iframe.scrollIntoView({ behavior: "smooth", block: "start" }); + this.iframe.style.scrollMarginTop = previousScrollMargin; + + this.bus.emit(event.data.kind, event.data.data); + + break; + } + case MESSAGE_KIND.TOAST: { this.bus.emit(event.data.kind, event.data.data); break; diff --git a/src/services/messages.ts b/src/services/messages.ts index 3bf5642..c2cd438 100644 --- a/src/services/messages.ts +++ b/src/services/messages.ts @@ -8,6 +8,7 @@ export enum MESSAGE_KIND { ONBOARDING_STEP_CHANGED = "onboardingStepChanged", ONBOARDING_SESSION_FINISHED = "onboardingSessionFinished", PAGE_LOADED = "pageLoaded", + SCROLL_INTO_VIEW = "scrollIntoView", TOAST = "toast", WINDOW_DIMENSION_CHANGE = "windowDimensionChange", } diff --git a/src/services/messages/v1/scroll_into_view.ts b/src/services/messages/v1/scroll_into_view.ts new file mode 100644 index 0000000..88eaa6c --- /dev/null +++ b/src/services/messages/v1/scroll_into_view.ts @@ -0,0 +1,11 @@ +import { MESSAGE_KIND } from "../../messages"; + +export type Data = { + offsetTop: number; +}; + +export type Message = { + data: Data; + kind: MESSAGE_KIND.SCROLL_INTO_VIEW; + version: "v1"; +};