@skyscanner/backpack-web 42.13.0 → 42.13.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.
@@ -101,6 +101,18 @@ const BpkAutosuggest = /*#__PURE__*/forwardRef(({
101
101
  changes,
102
102
  type
103
103
  } = actionAndChanges;
104
+
105
+ // Intercept InputBlur before the alwaysRenderSuggestions early-return
106
+ if (type === useCombobox.stateChangeTypes.InputBlur) {
107
+ const keepOpen = Boolean(alwaysRenderSuggestions && hasSuggestions);
108
+ return {
109
+ ...changes,
110
+ isOpen: keepOpen,
111
+ highlightedIndex: -1,
112
+ selectedItem: state.selectedItem,
113
+ inputValue: state.inputValue
114
+ };
115
+ }
104
116
  const shouldForceKeepOpen = alwaysRenderSuggestions && hasSuggestions && changes.isOpen === false;
105
117
  if (shouldForceKeepOpen) {
106
118
  return {
@@ -205,6 +217,11 @@ const BpkAutosuggest = /*#__PURE__*/forwardRef(({
205
217
  onSuggestionHighlighted?.({
206
218
  suggestion: currentSuggestion
207
219
  });
220
+
221
+ // Only arm auto-select-on-blur for non-mouse-driven highlight changes.
222
+ if (type !== useCombobox.stateChangeTypes.ItemMouseMove && type !== useCombobox.stateChangeTypes.MenuMouseLeave) {
223
+ savedHighlightedIndexRef.current = newIndex ?? null;
224
+ }
208
225
  const isArrowKey = type === useCombobox.stateChangeTypes.InputKeyDownArrowDown || type === useCombobox.stateChangeTypes.InputKeyDownArrowUp;
209
226
  if (isArrowKey) {
210
227
  if (currentSuggestion) {
@@ -272,8 +289,6 @@ const BpkAutosuggest = /*#__PURE__*/forwardRef(({
272
289
  useEffect(() => {
273
290
  if (highlightedIndex === previousHighlightedIndexRef.current) return;
274
291
  previousHighlightedIndexRef.current = highlightedIndex;
275
- // Save highlighted index to allow auto-selection on blur
276
- savedHighlightedIndexRef.current = highlightedIndex;
277
292
  const currentSuggestion = highlightedIndex != null && highlightedIndex >= 0 ? flattenedSuggestions?.[highlightedIndex] ?? null : null;
278
293
  if (!currentSuggestion && originalInputOnPreviewRef.current !== null) {
279
294
  if ((inputValue ?? '') !== originalInputOnPreviewRef.current) {
@@ -463,8 +478,11 @@ const BpkAutosuggest = /*#__PURE__*/forwardRef(({
463
478
  highlightedSuggestion = firstSuggestion;
464
479
  }
465
480
  if (highlightedSuggestion) {
466
- // Use setTimeout to ensure selectItem runs after the blur event completes
481
+ // Use setTimeout to ensure selectItem runs after the blur event completes.
467
482
  setTimeout(() => {
483
+ if (committedSelectionRef.current) {
484
+ return;
485
+ }
468
486
  selectItem(highlightedSuggestion);
469
487
  }, 0);
470
488
  }
@@ -15,4 +15,4 @@
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */
18
- .bpk-scrim-content{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1000;overflow:auto;overflow-x:hidden;overflow-y:auto;-webkit-overflow-scrolling:touch}
18
+ .bpk-scrim-content{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1000;overflow:auto;overflow-x:hidden;overflow-y:auto;-webkit-overflow-scrolling:touch;touch-action:pan-y}
@@ -64,7 +64,9 @@ const focusStore = {
64
64
  return;
65
65
  }
66
66
  try {
67
- storedFocusElement.focus();
67
+ storedFocusElement.focus({
68
+ preventScroll: true
69
+ });
68
70
  } catch {
69
71
  // Element may have been detached from the DOM
70
72
  }
@@ -4,3 +4,5 @@ export declare const fixBody: () => void;
4
4
  export declare const unfixBody: () => void;
5
5
  export declare const lockScroll: () => void;
6
6
  export declare const unlockScroll: () => void;
7
+ export declare const lockTouchAction: () => void;
8
+ export declare const unlockTouchAction: () => void;
@@ -16,7 +16,19 @@
16
16
  * limitations under the License.
17
17
  */
18
18
 
19
+ // Module-level state is intentional: the <body> element is a global singleton,
20
+ // so nested scrims must coordinate through a shared counter. Each lock/unlock
21
+ // pair maintains its own depth counter so the "save original value, apply
22
+ // locked value" logic only runs on the 0↔1 transition. Inner nested calls are
23
+ // no-ops that simply bump the counter. This prevents the inner scrim from
24
+ // overwriting the outer scrim's saved "pre-lock" value — see the Nested story
25
+ // in BpkModal.stories.tsx.
19
26
  let scrollOffset = 0;
27
+ let savedTouchAction = '';
28
+ let savedOverscrollBehavior = '';
29
+ let fixBodyDepth = 0;
30
+ let lockScrollDepth = 0;
31
+ let lockTouchActionDepth = 0;
20
32
  const getWindow = () => typeof window !== 'undefined' ? window : null;
21
33
  const getBodyElement = () => typeof document !== 'undefined' && typeof document.body !== 'undefined' ? document.body : null;
22
34
  const getScrollBarWidth = () => {
@@ -41,6 +53,12 @@ const getScrollBarWidth = () => {
41
53
  return scrollBarWidth === 0 ? '' : `${scrollBarWidth}px`;
42
54
  };
43
55
  export const storeScroll = () => {
56
+ // Skip while an outer scrim already fixed the body: pageYOffset reads 0 once
57
+ // the body is position:fixed, so capturing it would clobber the outer
58
+ // scroll position and cause restoreScroll to jump back to the top on close.
59
+ if (fixBodyDepth > 0) {
60
+ return;
61
+ }
44
62
  const window = getWindow();
45
63
  if (window) {
46
64
  scrollOffset = window.pageYOffset;
@@ -57,29 +75,81 @@ export const fixBody = () => {
57
75
  if (!body) {
58
76
  return;
59
77
  }
60
- body.style.position = 'fixed';
78
+ if (fixBodyDepth === 0) {
79
+ // Set top before position:fixed so the browser doesn't jump to scrollY=0.
80
+ // scrollOffset is captured by storeScroll() immediately before this call.
81
+ body.style.top = `-${scrollOffset}px`;
82
+ body.style.width = '100%';
83
+ body.style.position = 'fixed';
84
+ }
85
+ fixBodyDepth += 1;
61
86
  };
62
87
  export const unfixBody = () => {
63
88
  const body = getBodyElement();
64
- if (!body) {
89
+ if (!body || fixBodyDepth === 0) {
65
90
  return;
66
91
  }
67
- body.style.position = '';
92
+ fixBodyDepth -= 1;
93
+ if (fixBodyDepth === 0) {
94
+ body.style.position = '';
95
+ body.style.top = '';
96
+ body.style.width = '';
97
+ }
68
98
  };
99
+
100
+ // Locks background scroll on the body. Safe to call on all platforms.
101
+ // None of these block user touch gestures.
69
102
  export const lockScroll = () => {
70
103
  const body = getBodyElement();
71
104
  if (!body) {
72
105
  return;
73
106
  }
74
- const paddingRight = getScrollBarWidth();
75
- body.style.overflow = 'hidden';
76
- body.style.paddingRight = paddingRight;
107
+ if (lockScrollDepth === 0) {
108
+ savedOverscrollBehavior = body.style.overscrollBehavior;
109
+ body.style.overflow = 'hidden';
110
+ body.style.paddingRight = getScrollBarWidth();
111
+ body.style.overscrollBehavior = 'contain';
112
+ }
113
+ lockScrollDepth += 1;
77
114
  };
78
115
  export const unlockScroll = () => {
116
+ const body = getBodyElement();
117
+ if (!body || lockScrollDepth === 0) {
118
+ return;
119
+ }
120
+ lockScrollDepth -= 1;
121
+ if (lockScrollDepth === 0) {
122
+ body.style.overflow = '';
123
+ body.style.paddingRight = '';
124
+ body.style.overscrollBehavior = savedOverscrollBehavior;
125
+ }
126
+ };
127
+
128
+ // Blocks touch gestures on the body via `touch-action: none`. iOS-only.
129
+ //
130
+ // On iOS Safari, `overflow: hidden` alone does not stop touch-scroll or
131
+ // rubber-band overscroll on the body — `touch-action: none` is needed. Do NOT
132
+ // call this on non-iOS platforms: `touch-action` combines with descendants'
133
+ // effective touch-action, so setting `none` on body blocks touch scrolling in
134
+ // any modal content that doesn't explicitly declare its own `touch-action`.
135
+ export const lockTouchAction = () => {
79
136
  const body = getBodyElement();
80
137
  if (!body) {
81
138
  return;
82
139
  }
83
- body.style.overflow = '';
84
- body.style.paddingRight = '';
140
+ if (lockTouchActionDepth === 0) {
141
+ savedTouchAction = body.style.touchAction;
142
+ body.style.touchAction = 'none';
143
+ }
144
+ lockTouchActionDepth += 1;
145
+ };
146
+ export const unlockTouchAction = () => {
147
+ const body = getBodyElement();
148
+ if (!body || lockTouchActionDepth === 0) {
149
+ return;
150
+ }
151
+ lockTouchActionDepth -= 1;
152
+ if (lockTouchActionDepth === 0) {
153
+ body.style.touchAction = savedTouchAction;
154
+ }
85
155
  };
@@ -21,7 +21,7 @@ import { cssModules, isDeviceIpad, isDeviceIphone, wrapDisplayName } from "../..
21
21
  import BpkScrim from "./BpkScrim";
22
22
  import focusScope from "./focusScope";
23
23
  import focusStore from "./focusStore";
24
- import { fixBody, lockScroll, restoreScroll, storeScroll, unfixBody, unlockScroll } from "./scroll-utils";
24
+ import { fixBody, lockScroll, lockTouchAction, restoreScroll, storeScroll, unfixBody, unlockScroll, unlockTouchAction } from "./scroll-utils";
25
25
  import STYLES from "./bpk-scrim-content.module.css";
26
26
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
27
27
  const getClassName = cssModules(STYLES);
@@ -41,33 +41,27 @@ const withScrim = WrappedComponent => {
41
41
  isIphone
42
42
  } = this.props;
43
43
  const applicationElement = getApplicationElement();
44
- requestAnimationFrame(() => {
45
- /**
46
- * iPhones & iPads need to have a fixed body
47
- * and scrolling stored to prevent some iOS specific issues occuring
48
- *
49
- * Issue description:
50
- * iOS safari does not prevent scrolling on the underlying content.
51
- * Without the below fixes this results in users being able to scroll below any modal or dialog that uses withScrim.
52
- *
53
- * The fixes can be summaried here: https://markus.oberlehner.net/blog/simple-solution-to-prevent-body-scrolling-on-ios/
54
- *
55
- * The most dangerous of the fixes below is the fixBody, this function applies changes to the <body> style.
56
- * This has the potential to override any custom styles already applied. The assumption here is that no one internally is making these changes to body.
57
- *
58
- * There is a corresponding set of functions in the componentWillUnmount block that deals with undoing these changes.
59
- */
60
- if (isIphone || isIpad) {
61
- storeScroll();
62
- fixBody();
63
- }
64
- /**
65
- * lockScroll and the associated unlockScroll is how we control the scroll behaviour of the application when the scrim is active.
66
- * The desired behaviour is to prevent the user from scrolling content behind the scrim. The above iOS fixes are in place because lockScroll alone does not solve due to iOS specific issues.
67
- */
68
44
 
69
- lockScroll();
70
- });
45
+ /**
46
+ * iPhones & iPads need to have a fixed body and scrolling stored to prevent some iOS
47
+ * specific issues occurring. iOS Safari does not prevent scrolling on the underlying
48
+ * content — without these fixes users can scroll below a modal or dialog that uses
49
+ * withScrim. See: https://markus.oberlehner.net/blog/simple-solution-to-prevent-body-scrolling-on-ios/
50
+ *
51
+ * The most dangerous of the fixes below is fixBody — it applies changes to the <body>
52
+ * style and has the potential to override any custom styles already applied. The
53
+ * assumption here is that no one internally is making these changes to body.
54
+ *
55
+ * These must run synchronously (not inside requestAnimationFrame) so the body is
56
+ * locked before the first paint, otherwise a visible scroll-jump occurs on open.
57
+ * componentWillUnmount has a corresponding set of calls that undo these changes.
58
+ */
59
+ if (isIphone || isIpad) {
60
+ storeScroll();
61
+ fixBody();
62
+ lockTouchAction();
63
+ }
64
+ lockScroll();
71
65
  if (applicationElement) {
72
66
  applicationElement.setAttribute('aria-hidden', 'true');
73
67
  }
@@ -84,8 +78,11 @@ const withScrim = WrappedComponent => {
84
78
  } = this.props;
85
79
  const applicationElement = getApplicationElement();
86
80
  if (isIphone || isIpad) {
87
- setTimeout(restoreScroll, 0);
81
+ // unfixBody before restoreScroll: restoring scroll while body is still fixed
82
+ // prevents a second visual jump on close.
88
83
  unfixBody();
84
+ restoreScroll();
85
+ unlockTouchAction();
89
86
  }
90
87
  unlockScroll();
91
88
  if (applicationElement) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyscanner/backpack-web",
3
- "version": "42.13.0",
3
+ "version": "42.13.2",
4
4
  "description": "Backpack Design System web library",
5
5
  "repository": {
6
6
  "type": "git",