@xapp/chat-widget 1.95.0 → 1.96.0

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/index.js CHANGED
@@ -2747,6 +2747,268 @@ function useSuggestionsFetch$1(options) {
2747
2747
  return response;
2748
2748
  }
2749
2749
 
2750
+ /**
2751
+ * Stop the host page scrolling behind an open overlay.
2752
+ *
2753
+ * `body { overflow: hidden }` is the obvious way to do this, and is what both widgets used
2754
+ * to do — but **iOS Safari ignores `overflow: hidden` on the body**, so the customer's page
2755
+ * carried on scrolling under the modal. The technique that does work there is to take the
2756
+ * body out of flow at a negative offset equal to the current scroll position, then put it
2757
+ * back and re-scroll to that exact position on release.
2758
+ *
2759
+ * Reference counted rather than a plain on/off flag, and the count lives on the `<body>`
2760
+ * element rather than in module scope. That is deliberate: the chat widget's `FormModal`
2761
+ * loads the **standalone** form widget bundle, which carries its own copy of this module,
2762
+ * so the two overlays that can be open at once are not sharing any JS state. With a module
2763
+ * counter, the second lock would read the already-pinned scroll position (which the browser
2764
+ * reports as 0), the first release would unpin the body while the second overlay was still
2765
+ * open, and the second release would then re-apply `position: fixed` and leave the host page
2766
+ * stuck. The DOM is the only thing both bundles can agree on.
2767
+ */
2768
+ /** How many overlays currently hold the lock. Absent means unlocked. */
2769
+ var COUNT_ATTRIBUTE$1 = "data-xapp-scroll-lock";
2770
+ /** What to put back when the last one lets go, as JSON. */
2771
+ var RESTORE_ATTRIBUTE = "data-xapp-scroll-lock-restore";
2772
+ var PINNED_PROPERTIES = ["position", "top", "left", "right", "width", "overflow"];
2773
+ function readCount$1(body) {
2774
+ var count = Number(body.getAttribute(COUNT_ATTRIBUTE$1));
2775
+ return Number.isFinite(count) && count > 0 ? count : 0;
2776
+ }
2777
+ function captureRestorePoint(body) {
2778
+ var inlineStyles = PINNED_PROPERTIES.reduce(function (styles, property) {
2779
+ styles[property] = body.style.getPropertyValue(property);
2780
+ return styles;
2781
+ }, {});
2782
+ return { scrollY: window.scrollY || window.pageYOffset || 0, inlineStyles: inlineStyles };
2783
+ }
2784
+ function takeRestorePoint(body) {
2785
+ var serialized = body.getAttribute(RESTORE_ATTRIBUTE);
2786
+ body.removeAttribute(RESTORE_ATTRIBUTE);
2787
+ try {
2788
+ var parsed = JSON.parse(serialized || "");
2789
+ if (parsed && typeof parsed.scrollY === "number" && parsed.inlineStyles) {
2790
+ return parsed;
2791
+ }
2792
+ }
2793
+ catch (_a) {
2794
+ // Fall through: something else rewrote the attribute. Better to clear our own
2795
+ // properties and leave the page usable than to throw and leave it pinned.
2796
+ }
2797
+ return { scrollY: 0, inlineStyles: {} };
2798
+ }
2799
+ /**
2800
+ * Lock the body and return the release function.
2801
+ *
2802
+ * The returned function is idempotent — calling it twice is a no-op, which matters because
2803
+ * React StrictMode runs an effect's cleanup more than once.
2804
+ */
2805
+ function lockBodyScroll() {
2806
+ if (typeof document === "undefined" || typeof window === "undefined") {
2807
+ return function () { return undefined; };
2808
+ }
2809
+ var body = document.body;
2810
+ if (readCount$1(body) === 0) {
2811
+ var restorePoint = captureRestorePoint(body);
2812
+ body.setAttribute(RESTORE_ATTRIBUTE, JSON.stringify(restorePoint));
2813
+ body.style.position = "fixed";
2814
+ body.style.top = "-".concat(restorePoint.scrollY, "px");
2815
+ body.style.left = "0";
2816
+ body.style.right = "0";
2817
+ body.style.width = "100%";
2818
+ // Redundant next to `position: fixed`, but harmless, and it keeps the behaviour the
2819
+ // widgets already had on the browsers that do honour it.
2820
+ body.style.overflow = "hidden";
2821
+ }
2822
+ body.setAttribute(COUNT_ATTRIBUTE$1, String(readCount$1(body) + 1));
2823
+ var released = false;
2824
+ return function releaseBodyScroll() {
2825
+ if (released) {
2826
+ return;
2827
+ }
2828
+ released = true;
2829
+ var remaining = readCount$1(body) - 1;
2830
+ if (remaining > 0) {
2831
+ body.setAttribute(COUNT_ATTRIBUTE$1, String(remaining));
2832
+ return;
2833
+ }
2834
+ if (remaining < 0) {
2835
+ return;
2836
+ }
2837
+ body.removeAttribute(COUNT_ATTRIBUTE$1);
2838
+ var _a = takeRestorePoint(body), scrollY = _a.scrollY, inlineStyles = _a.inlineStyles;
2839
+ PINNED_PROPERTIES.forEach(function (property) {
2840
+ if (inlineStyles[property]) {
2841
+ body.style.setProperty(property, inlineStyles[property]);
2842
+ }
2843
+ else {
2844
+ body.style.removeProperty(property);
2845
+ }
2846
+ });
2847
+ // An empty `style=""` on the host page's body is untidy and is not what we found.
2848
+ if (!body.style.length) {
2849
+ body.removeAttribute("style");
2850
+ }
2851
+ window.scrollTo(0, scrollY);
2852
+ };
2853
+ }
2854
+ /**
2855
+ * Lock the body scroll for as long as the calling component is mounted.
2856
+ *
2857
+ * The cleanup runs on unmount, so a modal that disappears without a clean close — a route
2858
+ * change, a parent re-render, an error boundary — still leaves the page scrollable.
2859
+ */
2860
+ function useBodyScrollLock(enabled) {
2861
+ if (enabled === void 0) { enabled = true; }
2862
+ react.useEffect(function () {
2863
+ if (!enabled) {
2864
+ return undefined;
2865
+ }
2866
+ return lockBodyScroll();
2867
+ }, [enabled]);
2868
+ }
2869
+
2870
+ /**
2871
+ * Publish the visual viewport's geometry as CSS custom properties, so overlays can size
2872
+ * themselves around the soft keyboard.
2873
+ *
2874
+ * We are an **embedded** widget: the customer owns the host page's `<meta name="viewport">`,
2875
+ * so we cannot add `interactive-widget=resizes-content` and let the browser solve this for
2876
+ * us. Reading `window.visualViewport` ourselves is the only mechanism we control. (Injecting
2877
+ * or mutating the host's viewport meta is not an option — it is not ours, and breaking it
2878
+ * breaks the whole page in ways nobody would attribute to us.)
2879
+ *
2880
+ * Three properties are published on `<html>`:
2881
+ *
2882
+ * --xapp-viewport-height the visible height, in px
2883
+ * --xapp-viewport-offset-top how far the visual viewport has been scrolled down within
2884
+ * the layout viewport — nonzero on iOS when the keyboard
2885
+ * pushes the page up
2886
+ * --xapp-keyboard-inset layout height minus visual height: how much the keyboard
2887
+ * (or any other browser UI) is covering
2888
+ *
2889
+ * `--xapp-keyboard-inset` is the one worth having. It is what a `padding-bottom` wants, and
2890
+ * deriving it here means the consuming CSS does not have to do arithmetic across two
2891
+ * variables that update at slightly different moments.
2892
+ *
2893
+ * Reference counted on the DOM, for the same reason `bodyScrollLock` is: chat-widget's
2894
+ * `FormModal` loads the **standalone** form-widget bundle, which carries its own copy of
2895
+ * this module. Two independent copies can be live at once, and a module-scope counter would
2896
+ * not see across them. Without counting, closing the form modal while the chat panel is
2897
+ * still open (with the keyboard still up) would clear the properties out from under the
2898
+ * panel and snap it back to its fallback height mid-interaction.
2899
+ */
2900
+ var HEIGHT_PROPERTY = "--xapp-viewport-height";
2901
+ var OFFSET_TOP_PROPERTY = "--xapp-viewport-offset-top";
2902
+ var KEYBOARD_INSET_PROPERTY = "--xapp-keyboard-inset";
2903
+ /** How many subscribers currently want these properties maintained. */
2904
+ var COUNT_ATTRIBUTE = "data-xapp-viewport-subscribers";
2905
+ /**
2906
+ * Below this, a shrunken viewport is treated as browser chrome rather than a keyboard.
2907
+ * Address bars collapse and expand by ~60-90px during normal scrolling, and reacting to
2908
+ * that would make the layout twitch while the user is only reading.
2909
+ */
2910
+ var KEYBOARD_THRESHOLD_PX = 120;
2911
+ function readCount(root) {
2912
+ var count = Number(root.getAttribute(COUNT_ATTRIBUTE));
2913
+ return Number.isFinite(count) && count > 0 ? count : 0;
2914
+ }
2915
+ /**
2916
+ * Write the current geometry to `<html>`.
2917
+ *
2918
+ * Deliberately does nothing when `visualViewport` is unavailable: the consuming CSS falls
2919
+ * back to `100dvh`, which is correct-ish and certainly better than a zero height. Publishing
2920
+ * `0px` would collapse the widget entirely — a worse failure than the one being fixed.
2921
+ */
2922
+ function publish(root) {
2923
+ var viewport = typeof window === "undefined" ? undefined : window.visualViewport;
2924
+ if (!viewport) {
2925
+ return;
2926
+ }
2927
+ var height = viewport.height, offsetTop = viewport.offsetTop;
2928
+ if (!(height > 0)) {
2929
+ // Mid-transition, or a browser reporting nonsense. Keep the last good values.
2930
+ return;
2931
+ }
2932
+ // window.innerHeight is the LAYOUT viewport, which the keyboard does not shrink on iOS.
2933
+ // The difference between the two is the occluded strip.
2934
+ var layoutHeight = window.innerHeight || height;
2935
+ var rawInset = Math.max(0, layoutHeight - height);
2936
+ var inset = rawInset >= KEYBOARD_THRESHOLD_PX ? rawInset : 0;
2937
+ root.style.setProperty(HEIGHT_PROPERTY, "".concat(Math.round(height), "px"));
2938
+ root.style.setProperty(OFFSET_TOP_PROPERTY, "".concat(Math.round(offsetTop), "px"));
2939
+ root.style.setProperty(KEYBOARD_INSET_PROPERTY, "".concat(Math.round(inset), "px"));
2940
+ }
2941
+ function clear(root) {
2942
+ root.style.removeProperty(HEIGHT_PROPERTY);
2943
+ root.style.removeProperty(OFFSET_TOP_PROPERTY);
2944
+ root.style.removeProperty(KEYBOARD_INSET_PROPERTY);
2945
+ }
2946
+ /**
2947
+ * Start publishing viewport geometry. Returns a release function.
2948
+ *
2949
+ * Imperative twin of {@link useVisualViewportProperties}, for callers outside React.
2950
+ */
2951
+ function trackVisualViewport() {
2952
+ if (typeof document === "undefined") {
2953
+ return function () { return undefined; };
2954
+ }
2955
+ var root = document.documentElement;
2956
+ root.setAttribute(COUNT_ATTRIBUTE, String(readCount(root) + 1));
2957
+ publish(root);
2958
+ var viewport = typeof window === "undefined" ? undefined : window.visualViewport;
2959
+ var frame;
2960
+ var onChange = function () {
2961
+ // These fire many times per second through the keyboard animation. Coalescing to one
2962
+ // write per frame keeps us off the layout-thrash path.
2963
+ if (frame !== undefined) {
2964
+ return;
2965
+ }
2966
+ frame = window.requestAnimationFrame(function () {
2967
+ frame = undefined;
2968
+ publish(root);
2969
+ });
2970
+ };
2971
+ // `scroll` matters as much as `resize`: on iOS the keyboard scrolls the visual viewport
2972
+ // within the layout viewport, and offsetTop only changes on that event.
2973
+ viewport === null || viewport === void 0 ? void 0 : viewport.addEventListener("resize", onChange);
2974
+ viewport === null || viewport === void 0 ? void 0 : viewport.addEventListener("scroll", onChange);
2975
+ var released = false;
2976
+ return function () {
2977
+ if (released) {
2978
+ return;
2979
+ }
2980
+ released = true;
2981
+ viewport === null || viewport === void 0 ? void 0 : viewport.removeEventListener("resize", onChange);
2982
+ viewport === null || viewport === void 0 ? void 0 : viewport.removeEventListener("scroll", onChange);
2983
+ if (frame !== undefined) {
2984
+ window.cancelAnimationFrame(frame);
2985
+ frame = undefined;
2986
+ }
2987
+ var remaining = readCount(root) - 1;
2988
+ if (remaining > 0) {
2989
+ root.setAttribute(COUNT_ATTRIBUTE, String(remaining));
2990
+ return;
2991
+ }
2992
+ root.removeAttribute(COUNT_ATTRIBUTE);
2993
+ clear(root);
2994
+ };
2995
+ }
2996
+ /**
2997
+ * Publish viewport geometry for as long as this component is mounted.
2998
+ *
2999
+ * @param enabled pass `false` to opt out — e.g. above the mobile breakpoint, where there is
3000
+ * no soft keyboard to work around and the desktop sizing should stand.
3001
+ */
3002
+ function useVisualViewportProperties(enabled) {
3003
+ if (enabled === void 0) { enabled = true; }
3004
+ react.useEffect(function () {
3005
+ if (!enabled) {
3006
+ return undefined;
3007
+ }
3008
+ return trackVisualViewport();
3009
+ }, [enabled]);
3010
+ }
3011
+
2750
3012
  /**
2751
3013
  * Quicker configurable uuid
2752
3014
  *
@@ -2773,7 +3035,9 @@ var SuggestionsList_1 = SuggestionsList;
2773
3035
  var findItemByIndex_1 = findItemByIndex;
2774
3036
  var getItemsLength_1 = getItemsLength;
2775
3037
  var parseSuggestionsResponse_1 = parseSuggestionsResponse;
3038
+ var useBodyScrollLock_1 = useBodyScrollLock;
2776
3039
  var useSuggestionsFetch_1 = useSuggestionsFetch$1;
3040
+ var useVisualViewportProperties_1 = useVisualViewportProperties;
2777
3041
  var uuid_1 = uuid;
2778
3042
 
2779
3043
  var cjs = {};
@@ -12519,14 +12783,11 @@ var FormModal = function (_a) {
12519
12783
  document.removeEventListener("keydown", handleKeyDown);
12520
12784
  };
12521
12785
  }, [onClose]);
12522
- // Prevent body scroll when modal is open
12523
- React.useEffect(function () {
12524
- var originalOverflow = document.body.style.overflow;
12525
- document.body.style.overflow = "hidden";
12526
- return function () {
12527
- document.body.style.overflow = originalOverflow;
12528
- };
12529
- }, []);
12786
+ // Prevent body scroll when modal is open.
12787
+ //
12788
+ // This used to set `body { overflow: hidden }` directly, which iOS Safari ignores - the
12789
+ // host page scrolled on underneath the open modal.
12790
+ useBodyScrollLock_1();
12530
12791
  // Focus trap - focus the modal content on mount
12531
12792
  React.useEffect(function () {
12532
12793
  if (contentRef.current) {
@@ -35517,6 +35778,20 @@ var createQueueWithTimer = (timeout) => {
35517
35778
  setTimeout(notify, timeout);
35518
35779
  };
35519
35780
  };
35781
+ var createRafWithFallbackTimer = (raf, timeout) => {
35782
+ return (notify) => {
35783
+ let called = false;
35784
+ const callback = () => {
35785
+ if (called) return;
35786
+ called = true;
35787
+ cancelAnimationFrame(rafId);
35788
+ clearTimeout(timerId);
35789
+ notify();
35790
+ };
35791
+ const rafId = raf(callback);
35792
+ const timerId = setTimeout(callback, timeout);
35793
+ };
35794
+ };
35520
35795
  var autoBatchEnhancer = (options = {
35521
35796
  type: "raf"
35522
35797
  }) => (next) => (...args) => {
@@ -35527,7 +35802,7 @@ var autoBatchEnhancer = (options = {
35527
35802
  const listeners = /* @__PURE__ */ new Set();
35528
35803
  const queueCallback = options.type === "tick" ? queueMicrotask : options.type === "raf" ? (
35529
35804
  // requestAnimationFrame won't exist in SSR environments. Fall back to a vague approximation just to keep from erroring.
35530
- typeof window !== "undefined" && window.requestAnimationFrame ? window.requestAnimationFrame : createQueueWithTimer(10)
35805
+ typeof window !== "undefined" && window.requestAnimationFrame ? createRafWithFallbackTimer(window.requestAnimationFrame, 100) : createQueueWithTimer(10)
35531
35806
  ) : options.type === "callback" ? options.queueNotification : createQueueWithTimer(options.timeout);
35532
35807
  const notifyListeners = () => {
35533
35808
  notificationQueued = false;
@@ -35849,6 +36124,14 @@ var ChatWidgetUI = function (props) {
35849
36124
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13;
35850
36125
  var innerDispatch = useChatDispatch();
35851
36126
  var dispatch = useChatServerDispatch();
36127
+ // Publish the visual viewport's geometry so the panel can size around the soft keyboard.
36128
+ //
36129
+ // Mounted unconditionally rather than behind a width check, and the mobile-only scoping
36130
+ // lives in ChatWidget.scss instead: only the max-width: 640px block reads
36131
+ // --xapp-keyboard-inset, so on desktop this publishes properties nothing consumes and
36132
+ // behaviour is unchanged. A width check here would mean a fourth hardcoded 640 in a
36133
+ // codebase where that duplication is already an open issue (#1561).
36134
+ useVisualViewportProperties_1();
35852
36135
  // From Redux
35853
36136
  var chatState = reactRedux.useSelector(function (state) { return state; });
35854
36137
  // Refresh modalReference