@telus-uds/components-base 3.30.0 → 3.31.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/CHANGELOG.md CHANGED
@@ -1,9 +1,23 @@
1
1
  # Change Log - @telus-uds/components-base
2
2
 
3
- This log was last generated on Thu, 16 Apr 2026 21:04:33 GMT and should not be manually modified.
3
+ This log was last generated on Fri, 22 May 2026 05:09:10 GMT and should not be manually modified.
4
4
 
5
5
  <!-- Start content -->
6
6
 
7
+ ## 3.31.0
8
+
9
+ Fri, 22 May 2026 05:09:10 GMT
10
+
11
+ ### Minor changes
12
+
13
+ - `Autocomplete`: Add scroll to overlay in mobile (david.melara1@telus.com)
14
+
15
+ ### Patches
16
+
17
+ - `MultiSelectFilter`: position issues fixed in MFE (josue.higueroscalderon@telus.com)
18
+ - `TextInput`: Fix `onChangeText` returning `undefined` instead of an empty string when `isNumeric` is set and no text is provided (david.melara1@telus.com)
19
+ - `Autocomplete`: inside modal story fixed to show the close button properly (josue.higueroscalderon@telus.com)
20
+
7
21
  ## 3.30.0
8
22
 
9
23
  Thu, 16 Apr 2026 21:04:33 GMT
@@ -126,6 +126,7 @@ const Autocomplete = /*#__PURE__*/_react.default.forwardRef((_ref2, ref) => {
126
126
  value,
127
127
  helpText = '',
128
128
  loadingLabel,
129
+ dropdownPosition = 'bottom',
129
130
  tokens,
130
131
  ...rest
131
132
  } = _ref2;
@@ -181,6 +182,13 @@ const Autocomplete = /*#__PURE__*/_react.default.forwardRef((_ref2, ref) => {
181
182
  isShown: isExpanded || hintExpansionEnabled,
182
183
  offsets: {
183
184
  vertical: _Platform.default.OS !== 'web' && (hint || inputLabel) ? 28 : 4
185
+ },
186
+ align: dropdownPosition === 'top' ? {
187
+ center: 'center',
188
+ bottom: 'top'
189
+ } : {
190
+ center: 'center',
191
+ top: 'bottom'
184
192
  }
185
193
  });
186
194
  const targetRef = _react.default.useRef(null);
@@ -533,6 +541,10 @@ Autocomplete.propTypes = {
533
541
  /**
534
542
  * Input value for controlled usage
535
543
  */
536
- value: _propTypes.default.string
544
+ value: _propTypes.default.string,
545
+ /**
546
+ * Controls whether the dropdown renders above or below the input. Defaults to 'bottom'.
547
+ */
548
+ dropdownPosition: _propTypes.default.oneOf(['top', 'bottom'])
537
549
  };
538
550
  var _default = exports.default = Autocomplete;
@@ -6,6 +6,6 @@ Object.defineProperty(exports, "__esModule", {
6
6
  exports.MIN_LISTBOX_WIDTH = exports.INPUT_LEFT_PADDING = exports.DEFAULT_MIN_TO_SUGGESTION = exports.DEFAULT_MAX_SUGGESTIONS = exports.DEFAULT_MAX_DROPDOWN_HEIGHT = void 0;
7
7
  const DEFAULT_MIN_TO_SUGGESTION = exports.DEFAULT_MIN_TO_SUGGESTION = 1;
8
8
  const DEFAULT_MAX_SUGGESTIONS = exports.DEFAULT_MAX_SUGGESTIONS = 5;
9
- const DEFAULT_MAX_DROPDOWN_HEIGHT = exports.DEFAULT_MAX_DROPDOWN_HEIGHT = 336; // Approximately 7 items (48px each)
9
+ const DEFAULT_MAX_DROPDOWN_HEIGHT = exports.DEFAULT_MAX_DROPDOWN_HEIGHT = 500;
10
10
  const INPUT_LEFT_PADDING = exports.INPUT_LEFT_PADDING = 16;
11
11
  const MIN_LISTBOX_WIDTH = exports.MIN_LISTBOX_WIDTH = 288;
@@ -9,6 +9,7 @@ var _propTypes = _interopRequireDefault(require("prop-types"));
9
9
  var _View = _interopRequireDefault(require("react-native-web/dist/cjs/exports/View"));
10
10
  var _StyleSheet = _interopRequireDefault(require("react-native-web/dist/cjs/exports/StyleSheet"));
11
11
  var _Platform = _interopRequireDefault(require("react-native-web/dist/cjs/exports/Platform"));
12
+ var _ScrollView = _interopRequireDefault(require("react-native-web/dist/cjs/exports/ScrollView"));
12
13
  var _Portal = _interopRequireDefault(require("../Portal"));
13
14
  var _ThemeProvider = require("../ThemeProvider");
14
15
  var _Card = _interopRequireDefault(require("../Card"));
@@ -67,7 +68,12 @@ const DropdownOverlay = /*#__PURE__*/_react.default.forwardRef((_ref, ref) => {
67
68
  paddingLeft: paddingHorizontal,
68
69
  paddingRight: paddingHorizontal
69
70
  },
70
- children: children
71
+ children: _Platform.default.OS !== 'web' ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_ScrollView.default, {
72
+ style: {
73
+ maxHeight
74
+ },
75
+ children: children
76
+ }) : children
71
77
  })
72
78
  });
73
79
  });
@@ -10,6 +10,7 @@ var _View = _interopRequireDefault(require("react-native-web/dist/cjs/exports/Vi
10
10
  var _StyleSheet = _interopRequireDefault(require("react-native-web/dist/cjs/exports/StyleSheet"));
11
11
  var _Platform = _interopRequireDefault(require("react-native-web/dist/cjs/exports/Platform"));
12
12
  var _portal = require("@gorhom/portal");
13
+ var _Portal = _interopRequireDefault(require("../Portal/Portal"));
13
14
  var _utils = require("../utils");
14
15
  var _ViewportProvider = require("../ViewportProvider");
15
16
  var _ThemeProvider = require("../ThemeProvider");
@@ -123,7 +124,12 @@ const ModalOverlay = /*#__PURE__*/_react.default.forwardRef((_ref2, ref) => {
123
124
  copy
124
125
  });
125
126
  const closeLabel = getCopy('closeButton');
126
- return /*#__PURE__*/(0, _jsxRuntime.jsx)(_portal.Portal, {
127
+
128
+ // On web, use the local Portal (always appends to document.body) to ensure correct
129
+ // absolute positioning in MFE/iframe contexts. @gorhom/portal may render its host
130
+ // outside the iframe's document, causing coordinate mismatches when scrolled.
131
+ const PortalComponent = _Platform.default.OS === 'web' ? _Portal.default : _portal.Portal;
132
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(PortalComponent, {
127
133
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_View.default, {
128
134
  ref: containerRef,
129
135
  onLayout: onLayout,
@@ -6,7 +6,6 @@ Object.defineProperty(exports, "__esModule", {
6
6
  exports.default = void 0;
7
7
  var _react = _interopRequireDefault(require("react"));
8
8
  var _propTypes = _interopRequireDefault(require("prop-types"));
9
- var _portal = require("@gorhom/portal");
10
9
  var _View = _interopRequireDefault(require("react-native-web/dist/cjs/exports/View"));
11
10
  var _StyleSheet = _interopRequireDefault(require("react-native-web/dist/cjs/exports/StyleSheet"));
12
11
  var _Dimensions = _interopRequireDefault(require("react-native-web/dist/cjs/exports/Dimensions"));
@@ -219,20 +218,6 @@ const MultiSelectFilter = /*#__PURE__*/_react.default.forwardRef((_ref3, ref) =>
219
218
  setIsOpen(false);
220
219
  onCancel();
221
220
  };
222
- const appRootRef = _react.default.useRef(null);
223
- const [rootOffsets, setRootOffsets] = _react.default.useState(null);
224
- _react.default.useEffect(() => {
225
- if (rootOffsets) return;
226
- appRootRef.current?.measureInWindow((x, y) => {
227
- // Only set offsets if they are positive
228
- // this is because we want to avoid negative offsets that could cause
229
- // the dropdown to be positioned incorrectly in some situations
230
- if (y > 0) setRootOffsets({
231
- horizontal: x,
232
- vertical: y
233
- });
234
- });
235
- }, [isOpen, rootOffsets]);
236
221
  const {
237
222
  align,
238
223
  offsets
@@ -249,8 +234,7 @@ const MultiSelectFilter = /*#__PURE__*/_react.default.forwardRef((_ref3, ref) =>
249
234
  left: 'left'
250
235
  },
251
236
  offsets: {
252
- vertical: 4 - (rootOffsets?.vertical || 0),
253
- horizontal: -rootOffsets?.horizontal || 0
237
+ vertical: 4
254
238
  }
255
239
  }
256
240
  });
@@ -387,12 +371,7 @@ const MultiSelectFilter = /*#__PURE__*/_react.default.forwardRef((_ref3, ref) =>
387
371
  })]
388
372
  });
389
373
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
390
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_portal.Portal, {
391
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_View.default, {
392
- ref: appRootRef,
393
- style: styles.appRootRef
394
- })
395
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_Button.ButtonDropdown, {
374
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_Button.ButtonDropdown, {
396
375
  ref: sourceRef,
397
376
  ...pressHandlers,
398
377
  value: isOpen,
@@ -473,11 +452,6 @@ const styles = _StyleSheet.default.create({
473
452
  },
474
453
  scrollContainer: {
475
454
  padding: 1
476
- },
477
- appRootRef: {
478
- position: 'absolute',
479
- top: 0,
480
- left: 0
481
455
  }
482
456
  });
483
457
 
@@ -279,7 +279,8 @@ const TextInputBase = /*#__PURE__*/_react.default.forwardRef((_ref8, ref) => {
279
279
  }, [element, pattern]);
280
280
  const handleChangeText = event => {
281
281
  const text = event.nativeEvent?.text ?? event.target?.value;
282
- let filteredText = isNumeric ? text?.replace(/[^\d]/g, '') || undefined : text;
282
+ // Do NOT use `|| undefined`: empty string is falsy and would break the controlled contract
283
+ let filteredText = isNumeric ? text?.replace(/[^\d]/g, '') ?? '' : text;
283
284
  if (type === 'card' && filteredText) {
284
285
  const formattedValue = filteredText.replace(/[^a-zA-Z0-9]/g, '');
285
286
  const regex = new RegExp(`([a-zA-Z0-9]{4})(?=[a-zA-Z0-9])`, 'g');
@@ -61,46 +61,45 @@ function getOverlaidPosition(_ref2) {
61
61
  offsets = {},
62
62
  align
63
63
  } = _ref2;
64
- // Web-only: this will be difficult to mimic on native because there's no global scroll position.
65
- // TODO: wire something in e.g. a scroll ref accessible from a provider included in Allium provider
66
- // that can be passed to the appropriate ScrollView?
67
- const {
68
- scrollX = 0,
69
- scrollY = 0
70
- } = typeof window === 'object' ? window : {};
71
-
72
64
  // Will have top, bottom, left and/or right offsets depending on `align`
73
65
  const positioning = {};
74
66
  const verticalOffset = offsets.vertical ?? 0;
75
67
  const horizontalOffset = offsets.horizontal ?? 0;
76
68
  if (align.top) positioning.top = getPosition({
77
69
  edge: getEdgeType(align, 'top'),
78
- fromEdge: sourceLayout.y + scrollY + verticalOffset,
70
+ fromEdge: sourceLayout.y + verticalOffset,
79
71
  sourceSize: sourceLayout.height
80
72
  });
81
73
  if (align.middle) positioning.top = getPosition({
82
74
  edge: getEdgeType(align, 'middle'),
83
- fromEdge: sourceLayout.y + scrollY + verticalOffset - targetDimensions.height / 2,
84
- sourceSize: sourceLayout.height
85
- });
86
- if (align.bottom) positioning.bottom = getPosition({
87
- edge: getEdgeType(align, 'bottom'),
88
- fromEdge: windowDimensions.height - (sourceLayout.y + scrollY + sourceLayout.height - verticalOffset),
75
+ fromEdge: sourceLayout.y + verticalOffset - targetDimensions.height / 2,
89
76
  sourceSize: sourceLayout.height
90
77
  });
78
+ if (align.bottom) {
79
+ if (_Platform.default.OS !== 'web') {
80
+ // On native, position:absolute is parent-relative, so use negative top offset instead of window-based bottom math.
81
+ positioning.top = sourceLayout.y - targetDimensions.height - verticalOffset;
82
+ } else {
83
+ positioning.bottom = getPosition({
84
+ edge: getEdgeType(align, 'bottom'),
85
+ fromEdge: windowDimensions.height - (sourceLayout.y + sourceLayout.height - verticalOffset),
86
+ sourceSize: sourceLayout.height
87
+ });
88
+ }
89
+ }
91
90
  if (align.left) positioning.left = getPosition({
92
91
  edge: getEdgeType(align, 'left'),
93
- fromEdge: sourceLayout.x + scrollX + horizontalOffset,
92
+ fromEdge: sourceLayout.x + horizontalOffset,
94
93
  sourceSize: sourceLayout.width
95
94
  });
96
95
  if (align.center) positioning.left = getPosition({
97
96
  edge: getEdgeType(align, 'center'),
98
- fromEdge: sourceLayout.x + scrollX + horizontalOffset - targetDimensions.width / 2,
97
+ fromEdge: sourceLayout.x + horizontalOffset - targetDimensions.width / 2,
99
98
  sourceSize: sourceLayout.width
100
99
  });
101
100
  if (align.right) positioning.right = getPosition({
102
101
  edge: getEdgeType(align, 'right'),
103
- fromEdge: windowDimensions.width - (sourceLayout.x + scrollX + sourceLayout.width - horizontalOffset),
102
+ fromEdge: windowDimensions.width - (sourceLayout.x + sourceLayout.width - horizontalOffset),
104
103
  sourceSize: sourceLayout.width
105
104
  });
106
105
  if (!(align.left && align.right)) {
@@ -141,6 +140,32 @@ const useOverlaidPosition = _ref3 => {
141
140
  const targetRef = (0, _react.useRef)(null);
142
141
  const [targetDimensions, setTargetDimensions] = (0, _react.useState)(null);
143
142
  const [windowDimensions, setWindowDimensions] = (0, _react.useState)(null);
143
+ const hasRemeasuredRef = (0, _react.useRef)(false);
144
+ const applySourceLayout = (0, _react.useCallback)((dims, x, y, width, height) => {
145
+ setWindowDimensions(dims);
146
+ setSourceLayout({
147
+ x,
148
+ y,
149
+ width,
150
+ height
151
+ });
152
+ }, []);
153
+ const measureSourceOnWeb = (0, _react.useCallback)(windowDims => {
154
+ const el = sourceRef.current;
155
+ if (!el) return;
156
+ const domNode = el._nativeTag ?? el;
157
+ const dims = windowDims ?? _Dimensions.default.get('window');
158
+ const rect = typeof domNode.getBoundingClientRect === 'function' ? domNode.getBoundingClientRect() : null;
159
+ if (rect) {
160
+ const x = rect.left + (typeof window.scrollX === 'number' ? window.scrollX : 0);
161
+ const y = rect.top + (typeof window.scrollY === 'number' ? window.scrollY : 0);
162
+ applySourceLayout(dims, x, y, rect.width, rect.height);
163
+ } else {
164
+ el.measureInWindow((mx, my, width, height) => {
165
+ applySourceLayout(dims, mx, my, width, height);
166
+ });
167
+ }
168
+ }, [applySourceLayout]);
144
169
  const onTargetLayout = (0, _react.useCallback)(_ref4 => {
145
170
  let {
146
171
  nativeEvent: {
@@ -171,16 +196,19 @@ const useOverlaidPosition = _ref3 => {
171
196
  let {
172
197
  window
173
198
  } = _ref5;
174
- const measurementFunction = _Platform.default.OS === 'web' ? 'measureInWindow' : 'measure';
175
- sourceRef.current?.[measurementFunction]((x, y, width, height) => {
176
- setWindowDimensions(window);
177
- setSourceLayout({
178
- x,
179
- y,
180
- width,
181
- height
199
+ if (_Platform.default.OS === 'web') {
200
+ measureSourceOnWeb(window);
201
+ } else {
202
+ sourceRef.current?.measure((x, y, width, height) => {
203
+ setWindowDimensions(window);
204
+ setSourceLayout({
205
+ x,
206
+ y,
207
+ width,
208
+ height
209
+ });
182
210
  });
183
- });
211
+ }
184
212
  };
185
213
  let subscription;
186
214
  const unsubscribe = () => {
@@ -193,6 +221,7 @@ const useOverlaidPosition = _ref3 => {
193
221
  }
194
222
  setSourceLayout(null);
195
223
  setTargetDimensions(null);
224
+ hasRemeasuredRef.current = false;
196
225
  };
197
226
  if (readyToShow) {
198
227
  subscription = _Dimensions.default.addEventListener('change', handleDimensionsChange);
@@ -203,36 +232,48 @@ const useOverlaidPosition = _ref3 => {
203
232
  unsubscribe();
204
233
  }
205
234
  return unsubscribe;
206
- }, [readyToShow]);
235
+ }, [readyToShow, measureSourceOnWeb]);
236
+
237
+ // Re-measure source when targetDimensions first becomes available.
238
+ // Without this, there is a race condition: getBoundingClientRect() resolves faster than
239
+ // measureInWindow used to, so sourceLayout may be set before the dropdown has rendered
240
+ // and reported its dimensions via onTargetLayout. When targetDimensions finally arrives,
241
+ // sourceLayout is stale (no scroll has occurred to trigger handleScroll).
242
+ // Setting hasRemeasuredRef.current=true here also prevents the blink: isReady (on web)
243
+ // won't become true until this effect completes, guaranteeing the dropdown is always shown
244
+ // at its final correct position without an intermediate incorrect-position frame.
245
+ (0, _react.useEffect)(() => {
246
+ if (!isShown || !sourceRef.current || !targetDimensions || _Platform.default.OS !== 'web') return;
247
+ measureSourceOnWeb();
248
+ hasRemeasuredRef.current = true;
249
+ }, [targetDimensions, isShown, measureSourceOnWeb]);
207
250
  (0, _react.useEffect)(() => {
208
251
  if (_Platform.default.OS !== 'web') {
209
252
  return undefined;
210
253
  }
211
- const handleScroll = (0, _lodash.default)(() => {
212
- sourceRef.current?.measureInWindow((x, y, width, height) => {
213
- setWindowDimensions(window);
214
- setSourceLayout({
215
- x,
216
- y,
217
- width,
218
- height
219
- });
220
- });
221
- }, DEBOUNCE_DELAY);
254
+ const handleScroll = (0, _lodash.default)(measureSourceOnWeb, DEBOUNCE_DELAY);
222
255
  window.addEventListener('scroll', handleScroll);
223
256
  return () => {
224
257
  window.removeEventListener('scroll', handleScroll);
225
258
  handleScroll.cancel();
226
259
  };
227
- }, [sourceRef]);
228
- const isReady = Boolean(isShown && sourceLayout && windowDimensions && targetDimensions);
260
+ }, [measureSourceOnWeb]);
261
+
262
+ // On web, require hasRemeasuredRef to be true before declaring isReady. This ensures
263
+ // the dropdown is never shown with an intermediate stale position (the blink).
264
+ // On native, hasRemeasuredRef stays false (the web-only effect never runs), so we skip
265
+ // that check to preserve the original native behaviour.
266
+ const isReady = Boolean(isShown && sourceLayout && windowDimensions && targetDimensions && (_Platform.default.OS !== 'web' || hasRemeasuredRef.current));
229
267
  const overlaidPosition = isReady ? getOverlaidPosition({
230
268
  sourceLayout,
231
269
  targetDimensions,
232
270
  windowDimensions,
233
271
  offsets,
234
272
  align
235
- }) : {};
273
+ }) : {
274
+ top: 0,
275
+ left: 0
276
+ };
236
277
  return {
237
278
  overlaidPosition,
238
279
  sourceRef,
@@ -119,6 +119,7 @@ const Autocomplete = /*#__PURE__*/React.forwardRef((_ref2, ref) => {
119
119
  value,
120
120
  helpText = '',
121
121
  loadingLabel,
122
+ dropdownPosition = 'bottom',
122
123
  tokens,
123
124
  ...rest
124
125
  } = _ref2;
@@ -174,6 +175,13 @@ const Autocomplete = /*#__PURE__*/React.forwardRef((_ref2, ref) => {
174
175
  isShown: isExpanded || hintExpansionEnabled,
175
176
  offsets: {
176
177
  vertical: Platform.OS !== 'web' && (hint || inputLabel) ? 28 : 4
178
+ },
179
+ align: dropdownPosition === 'top' ? {
180
+ center: 'center',
181
+ bottom: 'top'
182
+ } : {
183
+ center: 'center',
184
+ top: 'bottom'
177
185
  }
178
186
  });
179
187
  const targetRef = React.useRef(null);
@@ -526,6 +534,10 @@ Autocomplete.propTypes = {
526
534
  /**
527
535
  * Input value for controlled usage
528
536
  */
529
- value: PropTypes.string
537
+ value: PropTypes.string,
538
+ /**
539
+ * Controls whether the dropdown renders above or below the input. Defaults to 'bottom'.
540
+ */
541
+ dropdownPosition: PropTypes.oneOf(['top', 'bottom'])
530
542
  };
531
543
  export default Autocomplete;
@@ -1,5 +1,5 @@
1
1
  export const DEFAULT_MIN_TO_SUGGESTION = 1;
2
2
  export const DEFAULT_MAX_SUGGESTIONS = 5;
3
- export const DEFAULT_MAX_DROPDOWN_HEIGHT = 336; // Approximately 7 items (48px each)
3
+ export const DEFAULT_MAX_DROPDOWN_HEIGHT = 500;
4
4
  export const INPUT_LEFT_PADDING = 16;
5
5
  export const MIN_LISTBOX_WIDTH = 288;
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
3
3
  import View from "react-native-web/dist/exports/View";
4
4
  import StyleSheet from "react-native-web/dist/exports/StyleSheet";
5
5
  import Platform from "react-native-web/dist/exports/Platform";
6
+ import ScrollView from "react-native-web/dist/exports/ScrollView";
6
7
  import Portal from '../Portal';
7
8
  import { useThemeTokens } from '../ThemeProvider';
8
9
  import Card from '../Card';
@@ -60,7 +61,12 @@ const DropdownOverlay = /*#__PURE__*/React.forwardRef((_ref, ref) => {
60
61
  paddingLeft: paddingHorizontal,
61
62
  paddingRight: paddingHorizontal
62
63
  },
63
- children: children
64
+ children: Platform.OS !== 'web' ? /*#__PURE__*/_jsx(ScrollView, {
65
+ style: {
66
+ maxHeight
67
+ },
68
+ children: children
69
+ }) : children
64
70
  })
65
71
  });
66
72
  });
@@ -3,7 +3,8 @@ import PropTypes from 'prop-types';
3
3
  import View from "react-native-web/dist/exports/View";
4
4
  import StyleSheet from "react-native-web/dist/exports/StyleSheet";
5
5
  import Platform from "react-native-web/dist/exports/Platform";
6
- import { Portal } from '@gorhom/portal';
6
+ import { Portal as GorhomPortal } from '@gorhom/portal';
7
+ import LocalPortal from '../Portal/Portal';
7
8
  import { useCopy, copyPropTypes, getTokensPropType, variantProp } from '../utils';
8
9
  import { useViewport } from '../ViewportProvider';
9
10
  import { useThemeTokens } from '../ThemeProvider';
@@ -116,7 +117,12 @@ const ModalOverlay = /*#__PURE__*/React.forwardRef((_ref2, ref) => {
116
117
  copy
117
118
  });
118
119
  const closeLabel = getCopy('closeButton');
119
- return /*#__PURE__*/_jsx(Portal, {
120
+
121
+ // On web, use the local Portal (always appends to document.body) to ensure correct
122
+ // absolute positioning in MFE/iframe contexts. @gorhom/portal may render its host
123
+ // outside the iframe's document, causing coordinate mismatches when scrolled.
124
+ const PortalComponent = Platform.OS === 'web' ? LocalPortal : GorhomPortal;
125
+ return /*#__PURE__*/_jsx(PortalComponent, {
120
126
  children: /*#__PURE__*/_jsx(View, {
121
127
  ref: containerRef,
122
128
  onLayout: onLayout,
@@ -1,6 +1,5 @@
1
1
  import React from 'react';
2
2
  import PropTypes from 'prop-types';
3
- import { Portal } from '@gorhom/portal';
4
3
  import View from "react-native-web/dist/exports/View";
5
4
  import StyleSheet from "react-native-web/dist/exports/StyleSheet";
6
5
  import Dimensions from "react-native-web/dist/exports/Dimensions";
@@ -212,20 +211,6 @@ const MultiSelectFilter = /*#__PURE__*/React.forwardRef((_ref3, ref) => {
212
211
  setIsOpen(false);
213
212
  onCancel();
214
213
  };
215
- const appRootRef = React.useRef(null);
216
- const [rootOffsets, setRootOffsets] = React.useState(null);
217
- React.useEffect(() => {
218
- if (rootOffsets) return;
219
- appRootRef.current?.measureInWindow((x, y) => {
220
- // Only set offsets if they are positive
221
- // this is because we want to avoid negative offsets that could cause
222
- // the dropdown to be positioned incorrectly in some situations
223
- if (y > 0) setRootOffsets({
224
- horizontal: x,
225
- vertical: y
226
- });
227
- });
228
- }, [isOpen, rootOffsets]);
229
214
  const {
230
215
  align,
231
216
  offsets
@@ -242,8 +227,7 @@ const MultiSelectFilter = /*#__PURE__*/React.forwardRef((_ref3, ref) => {
242
227
  left: 'left'
243
228
  },
244
229
  offsets: {
245
- vertical: 4 - (rootOffsets?.vertical || 0),
246
- horizontal: -rootOffsets?.horizontal || 0
230
+ vertical: 4
247
231
  }
248
232
  }
249
233
  });
@@ -380,12 +364,7 @@ const MultiSelectFilter = /*#__PURE__*/React.forwardRef((_ref3, ref) => {
380
364
  })]
381
365
  });
382
366
  return /*#__PURE__*/_jsxs(_Fragment, {
383
- children: [/*#__PURE__*/_jsx(Portal, {
384
- children: /*#__PURE__*/_jsx(View, {
385
- ref: appRootRef,
386
- style: styles.appRootRef
387
- })
388
- }), /*#__PURE__*/_jsx(ButtonDropdown, {
367
+ children: [/*#__PURE__*/_jsx(ButtonDropdown, {
389
368
  ref: sourceRef,
390
369
  ...pressHandlers,
391
370
  value: isOpen,
@@ -466,11 +445,6 @@ const styles = StyleSheet.create({
466
445
  },
467
446
  scrollContainer: {
468
447
  padding: 1
469
- },
470
- appRootRef: {
471
- position: 'absolute',
472
- top: 0,
473
- left: 0
474
448
  }
475
449
  });
476
450
 
@@ -272,7 +272,8 @@ const TextInputBase = /*#__PURE__*/React.forwardRef((_ref8, ref) => {
272
272
  }, [element, pattern]);
273
273
  const handleChangeText = event => {
274
274
  const text = event.nativeEvent?.text ?? event.target?.value;
275
- let filteredText = isNumeric ? text?.replace(/[^\d]/g, '') || undefined : text;
275
+ // Do NOT use `|| undefined`: empty string is falsy and would break the controlled contract
276
+ let filteredText = isNumeric ? text?.replace(/[^\d]/g, '') ?? '' : text;
276
277
  if (type === 'card' && filteredText) {
277
278
  const formattedValue = filteredText.replace(/[^a-zA-Z0-9]/g, '');
278
279
  const regex = new RegExp(`([a-zA-Z0-9]{4})(?=[a-zA-Z0-9])`, 'g');
@@ -54,46 +54,45 @@ function getOverlaidPosition(_ref2) {
54
54
  offsets = {},
55
55
  align
56
56
  } = _ref2;
57
- // Web-only: this will be difficult to mimic on native because there's no global scroll position.
58
- // TODO: wire something in e.g. a scroll ref accessible from a provider included in Allium provider
59
- // that can be passed to the appropriate ScrollView?
60
- const {
61
- scrollX = 0,
62
- scrollY = 0
63
- } = typeof window === 'object' ? window : {};
64
-
65
57
  // Will have top, bottom, left and/or right offsets depending on `align`
66
58
  const positioning = {};
67
59
  const verticalOffset = offsets.vertical ?? 0;
68
60
  const horizontalOffset = offsets.horizontal ?? 0;
69
61
  if (align.top) positioning.top = getPosition({
70
62
  edge: getEdgeType(align, 'top'),
71
- fromEdge: sourceLayout.y + scrollY + verticalOffset,
63
+ fromEdge: sourceLayout.y + verticalOffset,
72
64
  sourceSize: sourceLayout.height
73
65
  });
74
66
  if (align.middle) positioning.top = getPosition({
75
67
  edge: getEdgeType(align, 'middle'),
76
- fromEdge: sourceLayout.y + scrollY + verticalOffset - targetDimensions.height / 2,
77
- sourceSize: sourceLayout.height
78
- });
79
- if (align.bottom) positioning.bottom = getPosition({
80
- edge: getEdgeType(align, 'bottom'),
81
- fromEdge: windowDimensions.height - (sourceLayout.y + scrollY + sourceLayout.height - verticalOffset),
68
+ fromEdge: sourceLayout.y + verticalOffset - targetDimensions.height / 2,
82
69
  sourceSize: sourceLayout.height
83
70
  });
71
+ if (align.bottom) {
72
+ if (Platform.OS !== 'web') {
73
+ // On native, position:absolute is parent-relative, so use negative top offset instead of window-based bottom math.
74
+ positioning.top = sourceLayout.y - targetDimensions.height - verticalOffset;
75
+ } else {
76
+ positioning.bottom = getPosition({
77
+ edge: getEdgeType(align, 'bottom'),
78
+ fromEdge: windowDimensions.height - (sourceLayout.y + sourceLayout.height - verticalOffset),
79
+ sourceSize: sourceLayout.height
80
+ });
81
+ }
82
+ }
84
83
  if (align.left) positioning.left = getPosition({
85
84
  edge: getEdgeType(align, 'left'),
86
- fromEdge: sourceLayout.x + scrollX + horizontalOffset,
85
+ fromEdge: sourceLayout.x + horizontalOffset,
87
86
  sourceSize: sourceLayout.width
88
87
  });
89
88
  if (align.center) positioning.left = getPosition({
90
89
  edge: getEdgeType(align, 'center'),
91
- fromEdge: sourceLayout.x + scrollX + horizontalOffset - targetDimensions.width / 2,
90
+ fromEdge: sourceLayout.x + horizontalOffset - targetDimensions.width / 2,
92
91
  sourceSize: sourceLayout.width
93
92
  });
94
93
  if (align.right) positioning.right = getPosition({
95
94
  edge: getEdgeType(align, 'right'),
96
- fromEdge: windowDimensions.width - (sourceLayout.x + scrollX + sourceLayout.width - horizontalOffset),
95
+ fromEdge: windowDimensions.width - (sourceLayout.x + sourceLayout.width - horizontalOffset),
97
96
  sourceSize: sourceLayout.width
98
97
  });
99
98
  if (!(align.left && align.right)) {
@@ -134,6 +133,32 @@ const useOverlaidPosition = _ref3 => {
134
133
  const targetRef = useRef(null);
135
134
  const [targetDimensions, setTargetDimensions] = useState(null);
136
135
  const [windowDimensions, setWindowDimensions] = useState(null);
136
+ const hasRemeasuredRef = useRef(false);
137
+ const applySourceLayout = useCallback((dims, x, y, width, height) => {
138
+ setWindowDimensions(dims);
139
+ setSourceLayout({
140
+ x,
141
+ y,
142
+ width,
143
+ height
144
+ });
145
+ }, []);
146
+ const measureSourceOnWeb = useCallback(windowDims => {
147
+ const el = sourceRef.current;
148
+ if (!el) return;
149
+ const domNode = el._nativeTag ?? el;
150
+ const dims = windowDims ?? Dimensions.get('window');
151
+ const rect = typeof domNode.getBoundingClientRect === 'function' ? domNode.getBoundingClientRect() : null;
152
+ if (rect) {
153
+ const x = rect.left + (typeof window.scrollX === 'number' ? window.scrollX : 0);
154
+ const y = rect.top + (typeof window.scrollY === 'number' ? window.scrollY : 0);
155
+ applySourceLayout(dims, x, y, rect.width, rect.height);
156
+ } else {
157
+ el.measureInWindow((mx, my, width, height) => {
158
+ applySourceLayout(dims, mx, my, width, height);
159
+ });
160
+ }
161
+ }, [applySourceLayout]);
137
162
  const onTargetLayout = useCallback(_ref4 => {
138
163
  let {
139
164
  nativeEvent: {
@@ -164,16 +189,19 @@ const useOverlaidPosition = _ref3 => {
164
189
  let {
165
190
  window
166
191
  } = _ref5;
167
- const measurementFunction = Platform.OS === 'web' ? 'measureInWindow' : 'measure';
168
- sourceRef.current?.[measurementFunction]((x, y, width, height) => {
169
- setWindowDimensions(window);
170
- setSourceLayout({
171
- x,
172
- y,
173
- width,
174
- height
192
+ if (Platform.OS === 'web') {
193
+ measureSourceOnWeb(window);
194
+ } else {
195
+ sourceRef.current?.measure((x, y, width, height) => {
196
+ setWindowDimensions(window);
197
+ setSourceLayout({
198
+ x,
199
+ y,
200
+ width,
201
+ height
202
+ });
175
203
  });
176
- });
204
+ }
177
205
  };
178
206
  let subscription;
179
207
  const unsubscribe = () => {
@@ -186,6 +214,7 @@ const useOverlaidPosition = _ref3 => {
186
214
  }
187
215
  setSourceLayout(null);
188
216
  setTargetDimensions(null);
217
+ hasRemeasuredRef.current = false;
189
218
  };
190
219
  if (readyToShow) {
191
220
  subscription = Dimensions.addEventListener('change', handleDimensionsChange);
@@ -196,36 +225,48 @@ const useOverlaidPosition = _ref3 => {
196
225
  unsubscribe();
197
226
  }
198
227
  return unsubscribe;
199
- }, [readyToShow]);
228
+ }, [readyToShow, measureSourceOnWeb]);
229
+
230
+ // Re-measure source when targetDimensions first becomes available.
231
+ // Without this, there is a race condition: getBoundingClientRect() resolves faster than
232
+ // measureInWindow used to, so sourceLayout may be set before the dropdown has rendered
233
+ // and reported its dimensions via onTargetLayout. When targetDimensions finally arrives,
234
+ // sourceLayout is stale (no scroll has occurred to trigger handleScroll).
235
+ // Setting hasRemeasuredRef.current=true here also prevents the blink: isReady (on web)
236
+ // won't become true until this effect completes, guaranteeing the dropdown is always shown
237
+ // at its final correct position without an intermediate incorrect-position frame.
238
+ useEffect(() => {
239
+ if (!isShown || !sourceRef.current || !targetDimensions || Platform.OS !== 'web') return;
240
+ measureSourceOnWeb();
241
+ hasRemeasuredRef.current = true;
242
+ }, [targetDimensions, isShown, measureSourceOnWeb]);
200
243
  useEffect(() => {
201
244
  if (Platform.OS !== 'web') {
202
245
  return undefined;
203
246
  }
204
- const handleScroll = debounce(() => {
205
- sourceRef.current?.measureInWindow((x, y, width, height) => {
206
- setWindowDimensions(window);
207
- setSourceLayout({
208
- x,
209
- y,
210
- width,
211
- height
212
- });
213
- });
214
- }, DEBOUNCE_DELAY);
247
+ const handleScroll = debounce(measureSourceOnWeb, DEBOUNCE_DELAY);
215
248
  window.addEventListener('scroll', handleScroll);
216
249
  return () => {
217
250
  window.removeEventListener('scroll', handleScroll);
218
251
  handleScroll.cancel();
219
252
  };
220
- }, [sourceRef]);
221
- const isReady = Boolean(isShown && sourceLayout && windowDimensions && targetDimensions);
253
+ }, [measureSourceOnWeb]);
254
+
255
+ // On web, require hasRemeasuredRef to be true before declaring isReady. This ensures
256
+ // the dropdown is never shown with an intermediate stale position (the blink).
257
+ // On native, hasRemeasuredRef stays false (the web-only effect never runs), so we skip
258
+ // that check to preserve the original native behaviour.
259
+ const isReady = Boolean(isShown && sourceLayout && windowDimensions && targetDimensions && (Platform.OS !== 'web' || hasRemeasuredRef.current));
222
260
  const overlaidPosition = isReady ? getOverlaidPosition({
223
261
  sourceLayout,
224
262
  targetDimensions,
225
263
  windowDimensions,
226
264
  offsets,
227
265
  align
228
- }) : {};
266
+ }) : {
267
+ top: 0,
268
+ left: 0
269
+ };
229
270
  return {
230
271
  overlaidPosition,
231
272
  sourceRef,
package/lib/package.json CHANGED
@@ -84,6 +84,6 @@
84
84
  "standard-engine": {
85
85
  "skip": true
86
86
  },
87
- "version": "3.30.0",
87
+ "version": "3.31.0",
88
88
  "types": "types/index.d.ts"
89
89
  }
package/package.json CHANGED
@@ -84,6 +84,6 @@
84
84
  "standard-engine": {
85
85
  "skip": true
86
86
  },
87
- "version": "3.30.0",
87
+ "version": "3.31.0",
88
88
  "types": "types/index.d.ts"
89
89
  }
@@ -131,6 +131,7 @@ const Autocomplete = React.forwardRef(
131
131
  value,
132
132
  helpText = '',
133
133
  loadingLabel,
134
+ dropdownPosition = 'bottom',
134
135
  tokens,
135
136
  ...rest
136
137
  },
@@ -182,7 +183,11 @@ const Autocomplete = React.forwardRef(
182
183
  isShown: isExpanded || hintExpansionEnabled,
183
184
  offsets: {
184
185
  vertical: Platform.OS !== 'web' && (hint || inputLabel) ? 28 : 4
185
- }
186
+ },
187
+ align:
188
+ dropdownPosition === 'top'
189
+ ? { center: 'center', bottom: 'top' }
190
+ : { center: 'center', top: 'bottom' }
186
191
  })
187
192
  const targetRef = React.useRef(null)
188
193
  // We limit the number of suggestions displayed to avoid huge lists
@@ -556,7 +561,11 @@ Autocomplete.propTypes = {
556
561
  /**
557
562
  * Input value for controlled usage
558
563
  */
559
- value: PropTypes.string
564
+ value: PropTypes.string,
565
+ /**
566
+ * Controls whether the dropdown renders above or below the input. Defaults to 'bottom'.
567
+ */
568
+ dropdownPosition: PropTypes.oneOf(['top', 'bottom'])
560
569
  }
561
570
 
562
571
  export default Autocomplete
@@ -1,5 +1,5 @@
1
1
  export const DEFAULT_MIN_TO_SUGGESTION = 1
2
2
  export const DEFAULT_MAX_SUGGESTIONS = 5
3
- export const DEFAULT_MAX_DROPDOWN_HEIGHT = 336 // Approximately 7 items (48px each)
3
+ export const DEFAULT_MAX_DROPDOWN_HEIGHT = 500
4
4
  export const INPUT_LEFT_PADDING = 16
5
5
  export const MIN_LISTBOX_WIDTH = 288
@@ -1,6 +1,6 @@
1
1
  import React from 'react'
2
2
  import PropTypes from 'prop-types'
3
- import { View, StyleSheet, Platform } from 'react-native'
3
+ import { View, StyleSheet, Platform, ScrollView } from 'react-native'
4
4
  import Portal from '../Portal'
5
5
  import { useThemeTokens } from '../ThemeProvider'
6
6
  import Card from '../Card'
@@ -69,7 +69,11 @@ const DropdownOverlay = React.forwardRef(
69
69
  paddingRight: paddingHorizontal
70
70
  }}
71
71
  >
72
- {children}
72
+ {Platform.OS !== 'web' ? (
73
+ <ScrollView style={{ maxHeight }}>{children}</ScrollView>
74
+ ) : (
75
+ children
76
+ )}
73
77
  </Card>
74
78
  </View>
75
79
  )
@@ -1,7 +1,8 @@
1
1
  import React from 'react'
2
2
  import PropTypes from 'prop-types'
3
3
  import { View, StyleSheet, Platform } from 'react-native'
4
- import { Portal } from '@gorhom/portal'
4
+ import { Portal as GorhomPortal } from '@gorhom/portal'
5
+ import LocalPortal from '../Portal/Portal'
5
6
  import { useCopy, copyPropTypes, getTokensPropType, variantProp } from '../utils'
6
7
  import { useViewport } from '../ViewportProvider'
7
8
  import { useThemeTokens } from '../ThemeProvider'
@@ -105,8 +106,13 @@ const ModalOverlay = React.forwardRef(
105
106
  const getCopy = useCopy({ dictionary, copy })
106
107
  const closeLabel = getCopy('closeButton')
107
108
 
109
+ // On web, use the local Portal (always appends to document.body) to ensure correct
110
+ // absolute positioning in MFE/iframe contexts. @gorhom/portal may render its host
111
+ // outside the iframe's document, causing coordinate mismatches when scrolled.
112
+ const PortalComponent = Platform.OS === 'web' ? LocalPortal : GorhomPortal
113
+
108
114
  return (
109
- <Portal>
115
+ <PortalComponent>
110
116
  <View
111
117
  ref={containerRef}
112
118
  onLayout={onLayout}
@@ -135,7 +141,7 @@ const ModalOverlay = React.forwardRef(
135
141
  {children}
136
142
  </Card>
137
143
  </View>
138
- </Portal>
144
+ </PortalComponent>
139
145
  )
140
146
  }
141
147
  )
@@ -1,6 +1,5 @@
1
1
  import React from 'react'
2
2
  import PropTypes from 'prop-types'
3
- import { Portal } from '@gorhom/portal'
4
3
  import { View, StyleSheet, Dimensions, SafeAreaView, Platform, ScrollView } from 'react-native'
5
4
  import { useThemeTokens, useThemeTokensCallback, applyTextStyles } from '../ThemeProvider'
6
5
  import {
@@ -221,27 +220,11 @@ const MultiSelectFilter = React.forwardRef(
221
220
  onCancel()
222
221
  }
223
222
 
224
- const appRootRef = React.useRef(null)
225
- const [rootOffsets, setRootOffsets] = React.useState(null)
226
-
227
- React.useEffect(() => {
228
- if (rootOffsets) return
229
- appRootRef.current?.measureInWindow((x, y) => {
230
- // Only set offsets if they are positive
231
- // this is because we want to avoid negative offsets that could cause
232
- // the dropdown to be positioned incorrectly in some situations
233
- if (y > 0) setRootOffsets({ horizontal: x, vertical: y })
234
- })
235
- }, [isOpen, rootOffsets])
236
-
237
223
  const { align, offsets } = useResponsiveProp({
238
224
  xs: { align: { top: 'top', left: 'left' } },
239
225
  sm: {
240
226
  align: { top: 'bottom', left: 'left' },
241
- offsets: {
242
- vertical: 4 - (rootOffsets?.vertical || 0),
243
- horizontal: -rootOffsets?.horizontal || 0
244
- }
227
+ offsets: { vertical: 4 }
245
228
  }
246
229
  })
247
230
 
@@ -367,14 +350,6 @@ const MultiSelectFilter = React.forwardRef(
367
350
 
368
351
  return (
369
352
  <>
370
- {/*
371
- This View is rendered inside a Portal to determine the application's root position.
372
- Capturing it is crucial to determine offsets to position the Modal correctly, preventing
373
- misalignment that can occur when the Portal does not render the Modal at the body level.
374
- */}
375
- <Portal>
376
- <View ref={appRootRef} style={styles.appRootRef} />
377
- </Portal>
378
353
  <ButtonDropdown
379
354
  ref={sourceRef}
380
355
  key={id}
@@ -464,11 +439,6 @@ const styles = StyleSheet.create({
464
439
  },
465
440
  scrollContainer: {
466
441
  padding: 1
467
- },
468
- appRootRef: {
469
- position: 'absolute',
470
- top: 0,
471
- left: 0
472
442
  }
473
443
  })
474
444
 
@@ -256,7 +256,8 @@ const TextInputBase = React.forwardRef(
256
256
 
257
257
  const handleChangeText = (event) => {
258
258
  const text = event.nativeEvent?.text ?? event.target?.value
259
- let filteredText = isNumeric ? text?.replace(/[^\d]/g, '') || undefined : text
259
+ // Do NOT use `|| undefined`: empty string is falsy and would break the controlled contract
260
+ let filteredText = isNumeric ? text?.replace(/[^\d]/g, '') ?? '' : text
260
261
  if (type === 'card' && filteredText) {
261
262
  const formattedValue = filteredText.replace(/[^a-zA-Z0-9]/g, '')
262
263
  const regex = new RegExp(`([a-zA-Z0-9]{4})(?=[a-zA-Z0-9])`, 'g')
@@ -54,11 +54,6 @@ function getOverlaidPosition({
54
54
  offsets = {},
55
55
  align
56
56
  }) {
57
- // Web-only: this will be difficult to mimic on native because there's no global scroll position.
58
- // TODO: wire something in e.g. a scroll ref accessible from a provider included in Allium provider
59
- // that can be passed to the appropriate ScrollView?
60
- const { scrollX = 0, scrollY = 0 } = typeof window === 'object' ? window : {}
61
-
62
57
  // Will have top, bottom, left and/or right offsets depending on `align`
63
58
  const positioning = {}
64
59
 
@@ -68,40 +63,44 @@ function getOverlaidPosition({
68
63
  if (align.top)
69
64
  positioning.top = getPosition({
70
65
  edge: getEdgeType(align, 'top'),
71
- fromEdge: sourceLayout.y + scrollY + verticalOffset,
66
+ fromEdge: sourceLayout.y + verticalOffset,
72
67
  sourceSize: sourceLayout.height
73
68
  })
74
69
  if (align.middle)
75
70
  positioning.top = getPosition({
76
71
  edge: getEdgeType(align, 'middle'),
77
- fromEdge: sourceLayout.y + scrollY + verticalOffset - targetDimensions.height / 2,
78
- sourceSize: sourceLayout.height
79
- })
80
- if (align.bottom)
81
- positioning.bottom = getPosition({
82
- edge: getEdgeType(align, 'bottom'),
83
- fromEdge:
84
- windowDimensions.height - (sourceLayout.y + scrollY + sourceLayout.height - verticalOffset),
72
+ fromEdge: sourceLayout.y + verticalOffset - targetDimensions.height / 2,
85
73
  sourceSize: sourceLayout.height
86
74
  })
75
+ if (align.bottom) {
76
+ if (Platform.OS !== 'web') {
77
+ // On native, position:absolute is parent-relative, so use negative top offset instead of window-based bottom math.
78
+ positioning.top = sourceLayout.y - targetDimensions.height - verticalOffset
79
+ } else {
80
+ positioning.bottom = getPosition({
81
+ edge: getEdgeType(align, 'bottom'),
82
+ fromEdge: windowDimensions.height - (sourceLayout.y + sourceLayout.height - verticalOffset),
83
+ sourceSize: sourceLayout.height
84
+ })
85
+ }
86
+ }
87
87
 
88
88
  if (align.left)
89
89
  positioning.left = getPosition({
90
90
  edge: getEdgeType(align, 'left'),
91
- fromEdge: sourceLayout.x + scrollX + horizontalOffset,
91
+ fromEdge: sourceLayout.x + horizontalOffset,
92
92
  sourceSize: sourceLayout.width
93
93
  })
94
94
  if (align.center)
95
95
  positioning.left = getPosition({
96
96
  edge: getEdgeType(align, 'center'),
97
- fromEdge: sourceLayout.x + scrollX + horizontalOffset - targetDimensions.width / 2,
97
+ fromEdge: sourceLayout.x + horizontalOffset - targetDimensions.width / 2,
98
98
  sourceSize: sourceLayout.width
99
99
  })
100
100
  if (align.right)
101
101
  positioning.right = getPosition({
102
102
  edge: getEdgeType(align, 'right'),
103
- fromEdge:
104
- windowDimensions.width - (sourceLayout.x + scrollX + sourceLayout.width - horizontalOffset),
103
+ fromEdge: windowDimensions.width - (sourceLayout.x + sourceLayout.width - horizontalOffset),
105
104
  sourceSize: sourceLayout.width
106
105
  })
107
106
 
@@ -149,6 +148,34 @@ const useOverlaidPosition = ({
149
148
 
150
149
  const [windowDimensions, setWindowDimensions] = useState(null)
151
150
 
151
+ const hasRemeasuredRef = useRef(false)
152
+
153
+ const applySourceLayout = useCallback((dims, x, y, width, height) => {
154
+ setWindowDimensions(dims)
155
+ setSourceLayout({ x, y, width, height })
156
+ }, [])
157
+
158
+ const measureSourceOnWeb = useCallback(
159
+ (windowDims) => {
160
+ const el = sourceRef.current
161
+ if (!el) return
162
+ const domNode = el._nativeTag ?? el
163
+ const dims = windowDims ?? Dimensions.get('window')
164
+ const rect =
165
+ typeof domNode.getBoundingClientRect === 'function' ? domNode.getBoundingClientRect() : null
166
+ if (rect) {
167
+ const x = rect.left + (typeof window.scrollX === 'number' ? window.scrollX : 0)
168
+ const y = rect.top + (typeof window.scrollY === 'number' ? window.scrollY : 0)
169
+ applySourceLayout(dims, x, y, rect.width, rect.height)
170
+ } else {
171
+ el.measureInWindow((mx, my, width, height) => {
172
+ applySourceLayout(dims, mx, my, width, height)
173
+ })
174
+ }
175
+ },
176
+ [applySourceLayout]
177
+ )
178
+
152
179
  const onTargetLayout = useCallback(
153
180
  ({
154
181
  nativeEvent: {
@@ -173,12 +200,14 @@ const useOverlaidPosition = ({
173
200
  const readyToShow = Boolean(isShown && sourceRef.current)
174
201
  useEffect(() => {
175
202
  const handleDimensionsChange = ({ window }) => {
176
- const measurementFunction = Platform.OS === 'web' ? 'measureInWindow' : 'measure'
177
-
178
- sourceRef.current?.[measurementFunction]((x, y, width, height) => {
179
- setWindowDimensions(window)
180
- setSourceLayout({ x, y, width, height })
181
- })
203
+ if (Platform.OS === 'web') {
204
+ measureSourceOnWeb(window)
205
+ } else {
206
+ sourceRef.current?.measure((x, y, width, height) => {
207
+ setWindowDimensions(window)
208
+ setSourceLayout({ x, y, width, height })
209
+ })
210
+ }
182
211
  }
183
212
 
184
213
  let subscription
@@ -192,6 +221,7 @@ const useOverlaidPosition = ({
192
221
  }
193
222
  setSourceLayout(null)
194
223
  setTargetDimensions(null)
224
+ hasRemeasuredRef.current = false
195
225
  }
196
226
 
197
227
  if (readyToShow) {
@@ -202,19 +232,29 @@ const useOverlaidPosition = ({
202
232
  }
203
233
 
204
234
  return unsubscribe
205
- }, [readyToShow])
235
+ }, [readyToShow, measureSourceOnWeb])
236
+
237
+ // Re-measure source when targetDimensions first becomes available.
238
+ // Without this, there is a race condition: getBoundingClientRect() resolves faster than
239
+ // measureInWindow used to, so sourceLayout may be set before the dropdown has rendered
240
+ // and reported its dimensions via onTargetLayout. When targetDimensions finally arrives,
241
+ // sourceLayout is stale (no scroll has occurred to trigger handleScroll).
242
+ // Setting hasRemeasuredRef.current=true here also prevents the blink: isReady (on web)
243
+ // won't become true until this effect completes, guaranteeing the dropdown is always shown
244
+ // at its final correct position without an intermediate incorrect-position frame.
245
+ useEffect(() => {
246
+ if (!isShown || !sourceRef.current || !targetDimensions || Platform.OS !== 'web') return
247
+
248
+ measureSourceOnWeb()
249
+ hasRemeasuredRef.current = true
250
+ }, [targetDimensions, isShown, measureSourceOnWeb])
206
251
 
207
252
  useEffect(() => {
208
253
  if (Platform.OS !== 'web') {
209
254
  return undefined
210
255
  }
211
256
 
212
- const handleScroll = debounce(() => {
213
- sourceRef.current?.measureInWindow((x, y, width, height) => {
214
- setWindowDimensions(window)
215
- setSourceLayout({ x, y, width, height })
216
- })
217
- }, DEBOUNCE_DELAY)
257
+ const handleScroll = debounce(measureSourceOnWeb, DEBOUNCE_DELAY)
218
258
 
219
259
  window.addEventListener('scroll', handleScroll)
220
260
 
@@ -222,9 +262,19 @@ const useOverlaidPosition = ({
222
262
  window.removeEventListener('scroll', handleScroll)
223
263
  handleScroll.cancel()
224
264
  }
225
- }, [sourceRef])
265
+ }, [measureSourceOnWeb])
226
266
 
227
- const isReady = Boolean(isShown && sourceLayout && windowDimensions && targetDimensions)
267
+ // On web, require hasRemeasuredRef to be true before declaring isReady. This ensures
268
+ // the dropdown is never shown with an intermediate stale position (the blink).
269
+ // On native, hasRemeasuredRef stays false (the web-only effect never runs), so we skip
270
+ // that check to preserve the original native behaviour.
271
+ const isReady = Boolean(
272
+ isShown &&
273
+ sourceLayout &&
274
+ windowDimensions &&
275
+ targetDimensions &&
276
+ (Platform.OS !== 'web' || hasRemeasuredRef.current)
277
+ )
228
278
 
229
279
  const overlaidPosition = isReady
230
280
  ? getOverlaidPosition({
@@ -234,7 +284,7 @@ const useOverlaidPosition = ({
234
284
  offsets,
235
285
  align
236
286
  })
237
- : {}
287
+ : { top: 0, left: 0 }
238
288
 
239
289
  return {
240
290
  overlaidPosition,