@benji-money/connect-sdk 1.1.0-beta.1 → 1.1.0-beta.2

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/README.md CHANGED
@@ -90,6 +90,69 @@ The SDK accepts the following configuration options:
90
90
  - `onError` (optional): Callback function called when error occurs in the connect flow
91
91
  - `onExit` (optional): Callback function called when the user exits the connect flow
92
92
  - `onEvent` (optional): Callback function for handling various events
93
+ - `anchor` (optional): Live anchor binding — `HTMLElement`, CSS selector, getter `() => HTMLElement | null`, or ref `{ value: HTMLElement | null }`. Re-resolved on every layout pass. Falls back to centered when missing or invalid. See [Placement](#placement)
94
+ - `placement` (optional): Placement relative to `anchor` — see [Placement](#placement) below
95
+ - `offset` (optional): Pixel gap after placement — `{ top?: number; left?: number }` (default `{ top: 0, left: 0 }`)
96
+
97
+ ### Placement
98
+
99
+ Placement uses [Floating UI](https://floating-ui.com/docs/computePosition#placement) / CSS logical naming (`start` / `end`). In **LTR** layouts:
100
+
101
+ | Value | Meaning |
102
+ | ----- | ------- |
103
+ | `center` (default) | Full-viewport backdrop with iframe centered |
104
+ | `bottom-end` | Below the anchor, right edge aligned with anchor's right edge |
105
+ | `bottom-start` | Below the anchor, left edge aligned with anchor's left edge |
106
+
107
+ **Defaults:**
108
+
109
+ - No `anchor` → centered modal (unchanged behavior for existing integrations)
110
+ - `anchor` set, no `placement` → `bottom-end`
111
+ - If the anchor is missing or invalid on **initial open**, there is not enough viewport space below, or the viewport is narrower than the iframe (400px + padding), the SDK falls back to centered
112
+ - While the modal is **already open**, a temporarily hidden or detached anchor (e.g. crossing a responsive layout breakpoint) keeps the last anchored position instead of jumping to center; when the anchor becomes visible again, placement re-anchors automatically
113
+
114
+ **Live anchor bindings** (re-resolved on scroll, resize, and visual viewport changes):
115
+
116
+ | Form | Example | When to use |
117
+ | ---- | ------- | ----------- |
118
+ | CSS selector | `'#login-btn'` | Stable id on the anchor element |
119
+ | Ref object | `loginButtonRef` (Vue `{ value: el }`) | Anchor may re-render after async work |
120
+ | Getter | `() => document.querySelector('#login-btn')` | Custom lookup |
121
+ | `HTMLElement` | Snapshot at click time | Only when the node will not be replaced before `open()` |
122
+
123
+ Prefer selector, ref, or getter when the anchor can re-render (e.g. disabled/loading state) before the modal opens. A stale `HTMLElement` snapshot falls back to a centered modal on open instead of clipping to the top-left; while open, resize across breakpoints preserves the last anchored position until the anchor is visible again or the modal is closed.
124
+
125
+ **Viewport sizing:**
126
+
127
+ - Design size is **400×645** px (`IFRAME_WIDTH` / `IFRAME_HEIGHT`)
128
+ - **Centered** modals (default, explicit, or fallback) scale down proportionally when the viewport is smaller than the design size so the modal fits with 8px padding on each side
129
+ - **Anchored** popovers use the full design size when width and height checks pass; otherwise they fall back to a scaled centered modal
130
+ - Scroll, resize, and visual viewport changes while open recompute placement and size
131
+
132
+ **LOGIN popover example** (dropdown under a top-right button):
133
+
134
+ ```typescript
135
+ import {
136
+ ConnectSDK,
137
+ BenjiConnectMode,
138
+ BenjiConnectPlacement,
139
+ } from "@benji-money/connect-sdk";
140
+
141
+ // Prefer a live binding (selector or ref) when the button may re-render before open()
142
+ const sdk = new ConnectSDK({
143
+ environment: "sandbox",
144
+ token: connectToken,
145
+ mode: BenjiConnectMode.LOGIN,
146
+ anchor: "#login-btn",
147
+ placement: BenjiConnectPlacement.BOTTOM_END,
148
+ offset: { top: 8 },
149
+ onSuccess: (token) => { /* ... */ },
150
+ });
151
+
152
+ await sdk.open();
153
+ ```
154
+
155
+ > **`bottom-end`** opens below the anchor with the modal's right edge aligned to the anchor's right edge — matching a dropdown under a top-right LOGIN button.
93
156
 
94
157
  ### Environments
95
158
 
@@ -0,0 +1,14 @@
1
+ // These are compile-time constants replaced by tsup's `define` option.
2
+ // Declaring them globally prevents TypeScript errors.
3
+
4
+
5
+ declare global {
6
+ const __VERSION__: string;
7
+ const __NAMESPACE__: string;
8
+
9
+ // Global browser exposure for UMD builds
10
+ interface Window {
11
+ // SDK main class (UMD builds attach this)
12
+ ConnectSDK?: any;
13
+ }
14
+ }
@@ -110,7 +110,7 @@ var ConnectSDK = (() => {
110
110
  // src/utils/config.ts
111
111
  var buildContext = () => ({
112
112
  namespace: "benji-connect-sdk" ? "benji-connect-sdk" : "benji-connect-sdk",
113
- version: "1.1.0-beta.1" ? String("1.1.0-beta.1") : "0.0.0"
113
+ version: "1.1.0-beta.2" ? String("1.1.0-beta.2") : "0.0.0"
114
114
  });
115
115
  function isBenjiConnectEnvironment(environment) {
116
116
  return environment === "local" /* LOCAL */ || environment === "development" /* DEVELOPMENT */ || environment === "sandbox" /* SANDBOX */ || environment === "production" /* PRODUCTION */;
@@ -130,6 +130,287 @@ var ConnectSDK = (() => {
130
130
  return defaultEnvironment;
131
131
  }
132
132
 
133
+ // src/utils/placement.ts
134
+ var IFRAME_WIDTH = 400;
135
+ var IFRAME_HEIGHT = 645;
136
+ var IFRAME_STATIC_STYLE = {
137
+ border: "none",
138
+ borderRadius: "20px",
139
+ overflow: "hidden"
140
+ };
141
+ var CONTAINER_BASE_STYLE = {
142
+ position: "fixed",
143
+ top: "0",
144
+ left: "0",
145
+ width: "100%",
146
+ height: "100%",
147
+ backgroundColor: "rgba(0,0,0,0.5)",
148
+ zIndex: "9999"
149
+ };
150
+ var VIEWPORT_PADDING = 8;
151
+ function isPreservableAnchoredLayout(layout) {
152
+ return layout != null && layout.placement !== "center" /* CENTER */;
153
+ }
154
+ function clampPreservedAnchoredLayout(layout, viewport) {
155
+ const maxLeft = viewport.width - layout.width - VIEWPORT_PADDING;
156
+ const maxTop = viewport.height - layout.height - VIEWPORT_PADDING;
157
+ const minLeft = VIEWPORT_PADDING;
158
+ const minTop = VIEWPORT_PADDING;
159
+ return {
160
+ ...layout,
161
+ left: maxLeft < minLeft ? minLeft : Math.min(Math.max(layout.left, minLeft), maxLeft),
162
+ top: maxTop < minTop ? minTop : Math.min(Math.max(layout.top, minTop), maxTop)
163
+ };
164
+ }
165
+ function preservedAnchoredLayoutOrCenter(viewport, options) {
166
+ const { preserveAnchoredLayout } = options;
167
+ if (isPreservableAnchoredLayout(preserveAnchoredLayout)) {
168
+ return clampPreservedAnchoredLayout(preserveAnchoredLayout, viewport);
169
+ }
170
+ return centerLayout(viewport);
171
+ }
172
+ function isRefLikeAnchor(anchor) {
173
+ return typeof anchor === "object" && anchor !== null && !(anchor instanceof HTMLElement) && "value" in anchor;
174
+ }
175
+ function resolveAnchor(anchor) {
176
+ if (!anchor) {
177
+ return null;
178
+ }
179
+ if (typeof anchor === "string") {
180
+ return document.querySelector(anchor);
181
+ }
182
+ if (typeof anchor === "function") {
183
+ const resolved = anchor();
184
+ return resolved instanceof HTMLElement ? resolved : null;
185
+ }
186
+ if (isRefLikeAnchor(anchor)) {
187
+ const el = anchor.value;
188
+ return el instanceof HTMLElement ? el : null;
189
+ }
190
+ return anchor instanceof HTMLElement ? anchor : null;
191
+ }
192
+ function isAnchorVisible(el) {
193
+ if (!el.isConnected) {
194
+ return false;
195
+ }
196
+ const style = window.getComputedStyle(el);
197
+ if (style.display === "none" || style.visibility === "hidden") {
198
+ return false;
199
+ }
200
+ const rect = el.getBoundingClientRect();
201
+ return rect.width > 0 || rect.height > 0;
202
+ }
203
+ function resolveEffectivePlacement(config) {
204
+ var _a;
205
+ if (!config.anchor) {
206
+ return "center" /* CENTER */;
207
+ }
208
+ return (_a = config.placement) != null ? _a : "bottom-end" /* BOTTOM_END */;
209
+ }
210
+ function defaultOffset(offset) {
211
+ var _a, _b;
212
+ return {
213
+ top: (_a = offset == null ? void 0 : offset.top) != null ? _a : 0,
214
+ left: (_b = offset == null ? void 0 : offset.left) != null ? _b : 0
215
+ };
216
+ }
217
+ function resolveIframeDimensions(viewport) {
218
+ const maxW = viewport.width - 2 * VIEWPORT_PADDING;
219
+ const maxH = viewport.height - 2 * VIEWPORT_PADDING;
220
+ const scale = Math.min(1, maxW / IFRAME_WIDTH, maxH / IFRAME_HEIGHT);
221
+ return {
222
+ width: Math.floor(IFRAME_WIDTH * scale),
223
+ height: Math.floor(IFRAME_HEIGHT * scale)
224
+ };
225
+ }
226
+ function iframeFitsHorizontally(viewportWidth, iframeWidth = IFRAME_WIDTH) {
227
+ return viewportWidth >= iframeWidth + 2 * VIEWPORT_PADDING;
228
+ }
229
+ function centerLayout(viewport) {
230
+ const { width, height } = resolveIframeDimensions(viewport);
231
+ return {
232
+ top: 0,
233
+ left: 0,
234
+ width,
235
+ height,
236
+ placement: "center" /* CENTER */
237
+ };
238
+ }
239
+ function fitsBelow(anchorBottom, offsetTop, viewportHeight, iframeHeight = IFRAME_HEIGHT) {
240
+ const top = anchorBottom + offsetTop;
241
+ const bottom = top + iframeHeight;
242
+ return top >= VIEWPORT_PADDING && bottom <= viewportHeight - VIEWPORT_PADDING;
243
+ }
244
+ function clampHorizontal(left, viewportWidth) {
245
+ const min = VIEWPORT_PADDING;
246
+ const max = viewportWidth - IFRAME_WIDTH - VIEWPORT_PADDING;
247
+ if (max < min) {
248
+ return min;
249
+ }
250
+ return Math.min(Math.max(left, min), max);
251
+ }
252
+ function warnInvalidAnchor(message) {
253
+ console.warn(`[Benji Connect SDK] ${message}`);
254
+ }
255
+ function computeIframeRect(anchorElement, placement, offset, viewport, options = {}) {
256
+ const { warnOnInvalidAnchor = false } = options;
257
+ const resolvedOffset = defaultOffset(offset);
258
+ if (placement === "center" /* CENTER */) {
259
+ return centerLayout(viewport);
260
+ }
261
+ if (!isAnchorVisible(anchorElement)) {
262
+ if (isPreservableAnchoredLayout(options.preserveAnchoredLayout)) {
263
+ return clampPreservedAnchoredLayout(
264
+ options.preserveAnchoredLayout,
265
+ viewport
266
+ );
267
+ }
268
+ if (warnOnInvalidAnchor) {
269
+ warnInvalidAnchor(
270
+ "anchor is hidden or detached; falling back to centered modal"
271
+ );
272
+ }
273
+ return centerLayout(viewport);
274
+ }
275
+ const rect = anchorElement.getBoundingClientRect();
276
+ if (!fitsBelow(rect.bottom, resolvedOffset.top, viewport.height)) {
277
+ return centerLayout(viewport);
278
+ }
279
+ if (!iframeFitsHorizontally(viewport.width)) {
280
+ return centerLayout(viewport);
281
+ }
282
+ const top = rect.bottom + resolvedOffset.top;
283
+ const idealLeft = placement === "bottom-end" /* BOTTOM_END */ ? rect.right - IFRAME_WIDTH + resolvedOffset.left : rect.left + resolvedOffset.left;
284
+ if (idealLeft < VIEWPORT_PADDING) {
285
+ if (warnOnInvalidAnchor) {
286
+ warnInvalidAnchor(
287
+ "anchor rect is unusable for placement; falling back to centered modal"
288
+ );
289
+ }
290
+ return centerLayout(viewport);
291
+ }
292
+ const left = clampHorizontal(idealLeft, viewport.width);
293
+ return {
294
+ top,
295
+ left,
296
+ width: IFRAME_WIDTH,
297
+ height: IFRAME_HEIGHT,
298
+ placement
299
+ };
300
+ }
301
+ function resolveIframeLayout(config, viewport = {
302
+ width: window.innerWidth,
303
+ height: window.innerHeight
304
+ }, options = {}) {
305
+ const { warnOnInvalidAnchor = true } = options;
306
+ const placement = resolveEffectivePlacement(config);
307
+ const anchorElement = resolveAnchor(config.anchor);
308
+ if (warnOnInvalidAnchor && placement !== "center" /* CENTER */ && !anchorElement) {
309
+ warnInvalidAnchor("anchor not found; falling back to centered modal");
310
+ return preservedAnchoredLayoutOrCenter(viewport, options);
311
+ }
312
+ if (placement === "center" /* CENTER */) {
313
+ return centerLayout(viewport);
314
+ }
315
+ if (!anchorElement) {
316
+ return preservedAnchoredLayoutOrCenter(viewport, options);
317
+ }
318
+ return computeIframeRect(
319
+ anchorElement,
320
+ placement,
321
+ config.offset,
322
+ viewport,
323
+ options
324
+ );
325
+ }
326
+ function applyOverlayBaseStyle(container) {
327
+ Object.assign(container.style, CONTAINER_BASE_STYLE);
328
+ }
329
+ function getVisualViewportOffset() {
330
+ const vv = window.visualViewport;
331
+ if (!vv) {
332
+ return { top: 0, left: 0 };
333
+ }
334
+ return { top: vv.offsetTop, left: vv.offsetLeft };
335
+ }
336
+ function getRepositionScrollTargets(anchor) {
337
+ const targets = /* @__PURE__ */ new Set([window]);
338
+ const anchorElement = resolveAnchor(anchor);
339
+ if (!anchorElement) {
340
+ return [...targets];
341
+ }
342
+ targets.add(anchorElement);
343
+ let parent = anchorElement.parentElement;
344
+ while (parent) {
345
+ const { overflow, overflowX, overflowY } = window.getComputedStyle(parent);
346
+ const overflowValue = `${overflow} ${overflowX} ${overflowY}`;
347
+ if (/(auto|scroll|overlay)/.test(overflowValue)) {
348
+ targets.add(parent);
349
+ }
350
+ parent = parent.parentElement;
351
+ }
352
+ return [...targets];
353
+ }
354
+ function applyIframeLayoutToElements(config, container, iframe, state, options = { warnOnInvalidAnchor: false }) {
355
+ state.layout = resolveIframeLayout(config, void 0, {
356
+ ...options,
357
+ preserveAnchoredLayout: state.lastValidAnchoredLayout
358
+ });
359
+ if (state.layout.placement !== "center" /* CENTER */) {
360
+ state.lastValidAnchoredLayout = state.layout;
361
+ }
362
+ const iframeStyle = {
363
+ ...IFRAME_STATIC_STYLE,
364
+ width: `${state.layout.width}px`,
365
+ height: `${state.layout.height}px`
366
+ };
367
+ if (state.layout.placement === "center" /* CENTER */) {
368
+ Object.assign(container.style, {
369
+ display: "flex",
370
+ alignItems: "center",
371
+ justifyContent: "center",
372
+ overflow: "auto"
373
+ });
374
+ Object.assign(iframe.style, iframeStyle, {
375
+ position: "relative",
376
+ top: "",
377
+ left: ""
378
+ });
379
+ return;
380
+ }
381
+ Object.assign(container.style, {
382
+ display: "block",
383
+ overflow: "auto"
384
+ });
385
+ const viewportOffset = getVisualViewportOffset();
386
+ Object.assign(iframe.style, iframeStyle, {
387
+ position: "fixed",
388
+ top: `${state.layout.top + viewportOffset.top}px`,
389
+ left: `${state.layout.left + viewportOffset.left}px`
390
+ });
391
+ }
392
+ function attachRepositionListeners(config, onReposition) {
393
+ const repositionHandler = onReposition;
394
+ const repositionScrollTargets = getRepositionScrollTargets(config.anchor);
395
+ repositionScrollTargets.forEach((target) => {
396
+ target.addEventListener("scroll", repositionHandler, { passive: true });
397
+ });
398
+ window.addEventListener("resize", repositionHandler);
399
+ const visualViewport = window.visualViewport;
400
+ visualViewport == null ? void 0 : visualViewport.addEventListener("resize", repositionHandler);
401
+ visualViewport == null ? void 0 : visualViewport.addEventListener("scroll", repositionHandler);
402
+ return {
403
+ remove() {
404
+ repositionScrollTargets.forEach((target) => {
405
+ target.removeEventListener("scroll", repositionHandler);
406
+ });
407
+ window.removeEventListener("resize", repositionHandler);
408
+ visualViewport == null ? void 0 : visualViewport.removeEventListener("resize", repositionHandler);
409
+ visualViewport == null ? void 0 : visualViewport.removeEventListener("scroll", repositionHandler);
410
+ }
411
+ };
412
+ }
413
+
133
414
  // src/types/event.ts
134
415
  var BenjiConnectEventType = /* @__PURE__ */ ((BenjiConnectEventType2) => {
135
416
  BenjiConnectEventType2["AUTH_SUCCESS"] = "AUTH_SUCCESS";
@@ -369,18 +650,21 @@ var ConnectSDK = (() => {
369
650
  var MessageRouter = _MessageRouter;
370
651
 
371
652
  // src/connect-sdk.ts
372
- var IFRAME_STYLE = {
373
- position: "relative",
374
- width: "400px",
375
- height: "645px",
376
- border: "none",
377
- borderRadius: "20px",
378
- overflow: "hidden"
379
- };
380
653
  var ConnectSDK = class {
381
654
  constructor(config) {
382
655
  this.iframe = null;
383
656
  this.container = null;
657
+ this.layoutState = {
658
+ layout: {
659
+ top: 0,
660
+ left: 0,
661
+ width: IFRAME_WIDTH,
662
+ height: IFRAME_HEIGHT,
663
+ placement: "center" /* CENTER */
664
+ },
665
+ lastValidAnchoredLayout: null
666
+ };
667
+ this.repositionListeners = null;
384
668
  this.sdkConfig = config;
385
669
  if (!this.sdkConfig.environment) throw new Error("Environment is required");
386
670
  if (!this.sdkConfig.token) throw new Error("Connect token is required");
@@ -407,19 +691,7 @@ var ConnectSDK = (() => {
407
691
  openUI() {
408
692
  if (this.iframe) return;
409
693
  this.container = document.createElement("div");
410
- Object.assign(this.container.style, {
411
- position: "fixed",
412
- top: "0",
413
- left: "0",
414
- width: "100%",
415
- height: "100%",
416
- backgroundColor: "rgba(0,0,0,0.5)",
417
- zIndex: "9999",
418
- display: "flex",
419
- alignItems: "center",
420
- justifyContent: "center",
421
- overflow: "auto"
422
- });
694
+ applyOverlayBaseStyle(this.container);
423
695
  console.debug("[Connect SDK] endpoints", Endpoints);
424
696
  console.log("[Connect SDK] opeining URL", Endpoints.benji_connect_auth_url);
425
697
  const url = new URL(Endpoints.benji_connect_auth_url);
@@ -430,10 +702,28 @@ var ConnectSDK = (() => {
430
702
  url.searchParams.set("mode", String(this.sdkConfig.mode));
431
703
  }
432
704
  this.iframe = document.createElement("iframe");
433
- Object.assign(this.iframe.style, IFRAME_STYLE);
434
705
  this.iframe.src = url.toString();
435
706
  this.container.appendChild(this.iframe);
436
707
  document.body.appendChild(this.container);
708
+ applyIframeLayoutToElements(
709
+ this.sdkConfig,
710
+ this.container,
711
+ this.iframe,
712
+ this.layoutState,
713
+ { warnOnInvalidAnchor: true }
714
+ );
715
+ this.repositionListeners = attachRepositionListeners(
716
+ this.sdkConfig,
717
+ () => {
718
+ if (!this.iframe || !this.container) return;
719
+ applyIframeLayoutToElements(
720
+ this.sdkConfig,
721
+ this.container,
722
+ this.iframe,
723
+ this.layoutState
724
+ );
725
+ }
726
+ );
437
727
  this.container.addEventListener("click", (e) => {
438
728
  var _a, _b;
439
729
  if (e.target === this.container) {
@@ -446,9 +736,12 @@ var ConnectSDK = (() => {
446
736
  });
447
737
  }
448
738
  close() {
449
- var _a;
739
+ var _a, _b;
450
740
  if (!this.iframe) return;
451
- if ((_a = this.container) == null ? void 0 : _a.parentNode) {
741
+ (_a = this.repositionListeners) == null ? void 0 : _a.remove();
742
+ this.repositionListeners = null;
743
+ this.layoutState.lastValidAnchoredLayout = null;
744
+ if ((_b = this.container) == null ? void 0 : _b.parentNode) {
452
745
  this.container.parentNode.removeChild(this.container);
453
746
  }
454
747
  MessageRouter.removeEventListeners();