@telus-uds/components-base 4.0.0-alpha.1 → 4.0.0-beta.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.
Files changed (129) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/lib/cjs/Autocomplete/Autocomplete.js +13 -1
  3. package/lib/cjs/Autocomplete/constants.js +1 -1
  4. package/lib/cjs/Footnote/FootnoteLink.js +18 -17
  5. package/lib/cjs/Listbox/ListboxOverlay.js +7 -1
  6. package/lib/cjs/MultiSelectFilter/ModalOverlay.js +7 -1
  7. package/lib/cjs/MultiSelectFilter/MultiSelectFilter.js +2 -28
  8. package/lib/cjs/Progress/Progress.js +30 -5
  9. package/lib/cjs/Scroll/Scroll.js +472 -0
  10. package/lib/cjs/Scroll/ScrollContext.js +55 -0
  11. package/lib/cjs/Scroll/ScrollItem.js +106 -0
  12. package/lib/cjs/Scroll/ScrollProgress.js +100 -0
  13. package/lib/cjs/Scroll/dictionary.js +18 -0
  14. package/lib/cjs/TextInput/TextInputBase.js +2 -1
  15. package/lib/cjs/index.js +14 -0
  16. package/lib/cjs/utils/animation/useAnimatedValue.js +46 -0
  17. package/lib/cjs/utils/children.js +2 -1
  18. package/lib/cjs/utils/useOverlaidPosition.js +83 -42
  19. package/lib/esm/Autocomplete/Autocomplete.js +13 -1
  20. package/lib/esm/Autocomplete/constants.js +1 -1
  21. package/lib/esm/Footnote/FootnoteLink.js +18 -17
  22. package/lib/esm/Listbox/ListboxOverlay.js +7 -1
  23. package/lib/esm/MultiSelectFilter/ModalOverlay.js +8 -2
  24. package/lib/esm/MultiSelectFilter/MultiSelectFilter.js +2 -28
  25. package/lib/esm/Progress/Progress.js +30 -5
  26. package/lib/esm/Scroll/Scroll.js +465 -0
  27. package/lib/esm/Scroll/ScrollContext.js +46 -0
  28. package/lib/esm/Scroll/ScrollItem.js +100 -0
  29. package/lib/esm/Scroll/ScrollProgress.js +93 -0
  30. package/lib/esm/Scroll/dictionary.js +12 -0
  31. package/lib/esm/TextInput/TextInputBase.js +2 -1
  32. package/lib/esm/index.js +2 -0
  33. package/lib/esm/utils/animation/useAnimatedValue.js +38 -0
  34. package/lib/esm/utils/children.js +2 -1
  35. package/lib/esm/utils/useOverlaidPosition.js +83 -42
  36. package/lib/package.json +2 -2
  37. package/package.json +2 -2
  38. package/src/Autocomplete/Autocomplete.jsx +11 -2
  39. package/src/Autocomplete/constants.js +1 -1
  40. package/src/Footnote/FootnoteLink.jsx +17 -12
  41. package/src/Listbox/ListboxOverlay.jsx +6 -2
  42. package/src/MultiSelectFilter/ModalOverlay.jsx +9 -3
  43. package/src/MultiSelectFilter/MultiSelectFilter.jsx +1 -31
  44. package/src/Progress/Progress.jsx +22 -3
  45. package/src/Scroll/Scroll.jsx +483 -0
  46. package/src/Scroll/ScrollContext.jsx +39 -0
  47. package/src/Scroll/ScrollItem.jsx +87 -0
  48. package/src/Scroll/ScrollProgress.jsx +74 -0
  49. package/src/Scroll/dictionary.js +12 -0
  50. package/src/TextInput/TextInputBase.jsx +2 -1
  51. package/src/index.js +2 -0
  52. package/src/utils/animation/useAnimatedValue.js +35 -0
  53. package/src/utils/children.jsx +4 -1
  54. package/src/utils/useOverlaidPosition.js +84 -34
  55. package/types/A11yInfoProvider.d.ts +9 -0
  56. package/types/A11yText.d.ts +13 -0
  57. package/types/ActionCard.d.ts +19 -0
  58. package/types/ActivityIndicator.d.ts +13 -0
  59. package/types/Autocomplete.d.ts +27 -0
  60. package/types/Badge.d.ts +15 -4
  61. package/types/BaseProvider.d.ts +26 -0
  62. package/types/Box.d.ts +13 -3
  63. package/types/Button.d.ts +35 -0
  64. package/types/Card.d.ts +19 -0
  65. package/types/CardGroup.d.ts +23 -0
  66. package/types/Carousel.d.ts +25 -0
  67. package/types/Checkbox.d.ts +15 -2
  68. package/types/CheckboxCard.d.ts +20 -0
  69. package/types/CheckboxCardGroup.d.ts +10 -4
  70. package/types/ChevronLink.d.ts +9 -2
  71. package/types/ColourToggle.d.ts +16 -0
  72. package/types/Common.d.ts +7 -3
  73. package/types/Divider.d.ts +13 -1
  74. package/types/DownloadApp.d.ts +19 -0
  75. package/types/ExpandCollapse.d.ts +16 -9
  76. package/types/ExpandCollapseMini.d.ts +16 -0
  77. package/types/Feedback.d.ts +16 -0
  78. package/types/Fieldset.d.ts +17 -0
  79. package/types/FileUpload.d.ts +6 -0
  80. package/types/FlexGrid.d.ts +57 -0
  81. package/types/Footnote.d.ts +18 -0
  82. package/types/HorizontalScroll.d.ts +15 -0
  83. package/types/HorizontalScrollButton.d.ts +17 -1
  84. package/types/Icon.d.ts +13 -2
  85. package/types/IconButton.d.ts +19 -0
  86. package/types/InputLabel.d.ts +12 -0
  87. package/types/InputSupports.d.ts +19 -0
  88. package/types/Link.d.ts +20 -4
  89. package/types/List.d.ts +11 -4
  90. package/types/Listbox.d.ts +13 -3
  91. package/types/Modal.d.ts +25 -0
  92. package/types/MultiSelectFilter.d.ts +24 -0
  93. package/types/Notification.d.ts +19 -0
  94. package/types/OrderedList.d.ts +17 -0
  95. package/types/Pagination.d.ts +25 -0
  96. package/types/Portal.d.ts +5 -1
  97. package/types/PriceLockup.d.ts +28 -0
  98. package/types/ProductCard.d.ts +20 -0
  99. package/types/ProductCardGroup.d.ts +21 -0
  100. package/types/Progress.d.ts +22 -0
  101. package/types/QuickLinks.d.ts +27 -0
  102. package/types/QuickLinksFeature.d.ts +27 -0
  103. package/types/Radio.d.ts +22 -0
  104. package/types/RadioCard.d.ts +21 -0
  105. package/types/Responsive.d.ts +14 -0
  106. package/types/Scroll.d.ts +66 -0
  107. package/types/Search.d.ts +15 -2
  108. package/types/Select.d.ts +12 -4
  109. package/types/SideNav.d.ts +38 -0
  110. package/types/Skeleton.d.ts +17 -0
  111. package/types/SkipLink.d.ts +14 -0
  112. package/types/Spacer.d.ts +8 -1
  113. package/types/StackView.d.ts +8 -2
  114. package/types/Status.d.ts +2 -3
  115. package/types/StepTracker.d.ts +16 -0
  116. package/types/TabBar.d.ts +25 -0
  117. package/types/Tabs.d.ts +12 -3
  118. package/types/Tags.d.ts +24 -0
  119. package/types/TextButton.d.ts +11 -4
  120. package/types/TextInput.d.ts +38 -0
  121. package/types/ThemeProvider.d.ts +29 -0
  122. package/types/Timeline.d.ts +16 -0
  123. package/types/ToggleSwitch.d.ts +11 -2
  124. package/types/ToolTip.d.ts +7 -3
  125. package/types/TooltipButton.d.ts +20 -0
  126. package/types/Typography.d.ts +8 -2
  127. package/types/Validator.d.ts +16 -0
  128. package/types/ViewportProvider.d.ts +12 -0
  129. package/types/index.d.ts +205 -49
@@ -0,0 +1,93 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import View from "react-native-web/dist/exports/View";
4
+ import { resolvePressableState } from '../utils/pressability';
5
+ import { useAnimatedValue } from '../utils/animation/useAnimatedValue';
6
+ import { Progress } from '../Progress/Progress';
7
+ import { ProgressBar } from '../Progress/ProgressBar';
8
+ import { jsx as _jsx } from "react/jsx-runtime";
9
+ const MAX_PERCENTAGE = 100;
10
+ const BORDER_RADIUS_DIVISOR = 2;
11
+ const staticProgressTokens = {
12
+ borderWidth: 0,
13
+ borderColor: 'transparent'
14
+ };
15
+ const staticProgressBarTokens = {
16
+ gradient: null,
17
+ outlineWidth: 0
18
+ };
19
+ const selectProgressTokens = _ref => {
20
+ let {
21
+ progressTrackHeight,
22
+ progressBarHeight,
23
+ progressTrackColor
24
+ } = _ref;
25
+ return {
26
+ height: progressTrackHeight,
27
+ barHeight: progressBarHeight,
28
+ backgroundColor: progressTrackColor,
29
+ borderRadius: progressTrackHeight / BORDER_RADIUS_DIVISOR
30
+ };
31
+ };
32
+ const selectBarTokens = _ref2 => {
33
+ let {
34
+ progressBarColor,
35
+ progressBarHeight
36
+ } = _ref2;
37
+ return {
38
+ backgroundColor: progressBarColor,
39
+ borderRadius: progressBarHeight / BORDER_RADIUS_DIVISOR
40
+ };
41
+ };
42
+ export const ScrollProgress = /*#__PURE__*/React.forwardRef((_ref3, ref) => {
43
+ let {
44
+ pressableState,
45
+ getTokens,
46
+ thumbWrapperStyle,
47
+ getCopy
48
+ } = _ref3;
49
+ const state = resolvePressableState(pressableState);
50
+ const themeTokens = getTokens(state);
51
+ const animatedBarHeight = useAnimatedValue(themeTokens.progressBarHeight);
52
+ const animatedTokens = {
53
+ ...themeTokens,
54
+ progressBarHeight: animatedBarHeight
55
+ };
56
+ return /*#__PURE__*/_jsx(Progress, {
57
+ tokens: {
58
+ ...staticProgressTokens,
59
+ ...selectProgressTokens(animatedTokens)
60
+ },
61
+ children: /*#__PURE__*/_jsx(View, {
62
+ ref: ref,
63
+ style: thumbWrapperStyle,
64
+ children: /*#__PURE__*/_jsx(ProgressBar, {
65
+ percentage: MAX_PERCENTAGE,
66
+ tokens: {
67
+ ...staticProgressBarTokens,
68
+ ...selectBarTokens(animatedTokens)
69
+ },
70
+ accessibilityLabel: getCopy('progressBarLabel')
71
+ })
72
+ })
73
+ });
74
+ });
75
+ ScrollProgress.displayName = 'ScrollProgress';
76
+ ScrollProgress.propTypes = {
77
+ /**
78
+ * State object from Pressable's render callback containing hovered, pressed, and focused states
79
+ */
80
+ pressableState: PropTypes.object.isRequired,
81
+ /**
82
+ * Theme token resolver callback from useThemeTokensCallback that accepts appearance state
83
+ */
84
+ getTokens: PropTypes.func.isRequired,
85
+ /**
86
+ * Style array for the thumb wrapper containing static and dynamic position styles
87
+ */
88
+ thumbWrapperStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.array]).isRequired,
89
+ /**
90
+ * Copy resolver function from useCopy for retrieving localized strings
91
+ */
92
+ getCopy: PropTypes.func.isRequired
93
+ };
@@ -0,0 +1,12 @@
1
+ export const dictionary = {
2
+ en: {
3
+ accessibilityLabel: 'Scrollable content',
4
+ instructionText: 'Use Tab, arrow keys, or space bar to navigate',
5
+ progressBarLabel: 'Scroll position'
6
+ },
7
+ fr: {
8
+ accessibilityLabel: 'Contenu défilable',
9
+ instructionText: "Utilisez Tab, les touches fléchées ou la barre d'espace pour naviguer",
10
+ progressBarLabel: 'Position de défilement'
11
+ }
12
+ };
@@ -283,7 +283,8 @@ export const TextInputBase = /*#__PURE__*/React.forwardRef((_ref8, ref) => {
283
283
  }, [element, pattern]);
284
284
  const handleChangeText = event => {
285
285
  const text = event.nativeEvent?.text ?? event.target?.value;
286
- let filteredText = isNumeric ? text?.replace(/[^\d]/g, '') || undefined : text;
286
+ // Do NOT use `|| undefined`: empty string is falsy and would break the controlled contract
287
+ let filteredText = isNumeric ? text?.replace(/[^\d]/g, '') ?? '' : text;
287
288
  if (type === 'card' && filteredText) {
288
289
  const formattedValue = filteredText.replace(/[^a-zA-Z0-9]/g, '');
289
290
  const regex = new RegExp(`([a-zA-Z0-9]{4})(?=[a-zA-Z0-9])`, 'g');
package/lib/esm/index.js CHANGED
@@ -73,6 +73,8 @@ export { RadioGroup } from './Radio/RadioGroup';
73
73
  export { RadioCard } from './RadioCard/RadioCard';
74
74
  export { RadioCardGroup } from './RadioCard/RadioCardGroup';
75
75
  export { Responsive } from './Responsive/Responsive';
76
+ export { Scroll } from './Scroll/Scroll';
77
+ export { ScrollItem } from './Scroll/ScrollItem';
76
78
  export { Search } from './Search/Search';
77
79
  export { Select } from './Select/Select';
78
80
  export { SelectItem } from './Select/SelectItem';
@@ -0,0 +1,38 @@
1
+ import React from 'react';
2
+ import Animated from "react-native-web/dist/exports/Animated";
3
+ import Easing from "react-native-web/dist/exports/Easing";
4
+ const ANIMATION_DURATION = 200;
5
+
6
+ /**
7
+ * Smoothly animates a numeric value to a new target using a quadratic ease-in-out
8
+ * curve. Returns the current interpolated value as a plain number so
9
+ * consumers can use it in non-Animated style props.
10
+ *
11
+ * @param {number} targetValue - The value to animate towards.
12
+ * @param {number} [duration=ANIMATION_DURATION] - Animation duration in ms.
13
+ */
14
+ export const useAnimatedValue = function (targetValue) {
15
+ let duration = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ANIMATION_DURATION;
16
+ const [currentValue, setCurrentValue] = React.useState(targetValue);
17
+ const animatedValue = React.useRef(new Animated.Value(targetValue)).current;
18
+ React.useEffect(() => {
19
+ const id = animatedValue.addListener(_ref => {
20
+ let {
21
+ value
22
+ } = _ref;
23
+ return setCurrentValue(value);
24
+ });
25
+ return () => animatedValue.removeListener(id);
26
+ }, [animatedValue]);
27
+ React.useEffect(() => {
28
+ const animation = Animated.timing(animatedValue, {
29
+ toValue: targetValue,
30
+ duration,
31
+ easing: Easing.inOut(Easing.quad),
32
+ useNativeDriver: false
33
+ });
34
+ animation.start();
35
+ return () => animation.stop();
36
+ }, [targetValue, animatedValue, duration]);
37
+ return currentValue;
38
+ };
@@ -47,7 +47,8 @@ export const unpackFragment = child => {
47
47
  };
48
48
  const isStringOrNumber = child => typeof child === 'string' || typeof child === 'number';
49
49
  // Wrap an A11yText with neighouring text strings so it doesn't split them into multiple <Text>s
50
- const isWrapable = child => isStringOrNumber(child) || child.type === A11yText || child.type?.name === 'FootnoteLink';
50
+ // Check displayName first as it is explicitly set and reliable regardless of export style (named vs default)
51
+ const isWrapable = child => isStringOrNumber(child) || child.type === A11yText || child.type?.__UDS_COMPONENT_NAME__ === 'FootnoteLink';
51
52
  const combineKeys = childrenArray => childrenArray.reduce((newKey, child) => `${newKey}${child.key || ''}`, '');
52
53
 
53
54
  // Group wrappable children for one `<Text>` parent, merging adjacent text nodes
@@ -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 @@ export 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 @@ export 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 @@ export 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 @@ export 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
@@ -12,7 +12,7 @@
12
12
  "@gorhom/portal": "^1.0.14",
13
13
  "@react-native-picker/picker": "^2.11.4",
14
14
  "@telus-uds/system-constants": "^4.0.0-alpha.0",
15
- "@telus-uds/system-theme-tokens": "^5.0.0-alpha.1",
15
+ "@telus-uds/system-theme-tokens": "^5.0.0-alpha.2",
16
16
  "css-mediaquery": "^0.1.2",
17
17
  "expo-document-picker": "^14.0.8",
18
18
  "expo-linear-gradient": "^15.0.8",
@@ -81,6 +81,6 @@
81
81
  "standard-engine": {
82
82
  "skip": true
83
83
  },
84
- "version": "4.0.0-alpha.1",
84
+ "version": "4.0.0-beta.0",
85
85
  "types": "types/index.d.ts"
86
86
  }
package/package.json CHANGED
@@ -12,7 +12,7 @@
12
12
  "@gorhom/portal": "^1.0.14",
13
13
  "@react-native-picker/picker": "^2.11.4",
14
14
  "@telus-uds/system-constants": "^4.0.0-alpha.0",
15
- "@telus-uds/system-theme-tokens": "^5.0.0-alpha.1",
15
+ "@telus-uds/system-theme-tokens": "^5.0.0-alpha.2",
16
16
  "css-mediaquery": "^0.1.2",
17
17
  "expo-document-picker": "^14.0.8",
18
18
  "expo-linear-gradient": "^15.0.8",
@@ -81,6 +81,6 @@
81
81
  "standard-engine": {
82
82
  "skip": true
83
83
  },
84
- "version": "4.0.0-alpha.1",
84
+ "version": "4.0.0-beta.0",
85
85
  "types": "types/index.d.ts"
86
86
  }
@@ -129,6 +129,7 @@ export const Autocomplete = React.forwardRef(
129
129
  value,
130
130
  helpText = '',
131
131
  loadingLabel,
132
+ dropdownPosition = 'bottom',
132
133
  tokens,
133
134
  ...rest
134
135
  },
@@ -180,7 +181,11 @@ export const Autocomplete = React.forwardRef(
180
181
  isShown: isExpanded || hintExpansionEnabled,
181
182
  offsets: {
182
183
  vertical: Platform.OS !== 'web' && (hint || inputLabel) ? 28 : 4
183
- }
184
+ },
185
+ align:
186
+ dropdownPosition === 'top'
187
+ ? { center: 'center', bottom: 'top' }
188
+ : { center: 'center', top: 'bottom' }
184
189
  })
185
190
  const targetRef = React.useRef(null)
186
191
  // We limit the number of suggestions displayed to avoid huge lists
@@ -554,5 +559,9 @@ Autocomplete.propTypes = {
554
559
  /**
555
560
  * Input value for controlled usage
556
561
  */
557
- value: PropTypes.string
562
+ value: PropTypes.string,
563
+ /**
564
+ * Controls whether the dropdown renders above or below the input. Defaults to 'bottom'.
565
+ */
566
+ dropdownPosition: PropTypes.oneOf(['top', 'bottom'])
558
567
  }
@@ -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
@@ -13,22 +13,26 @@ import { useThemeTokens } from '../ThemeProvider/useThemeTokens'
13
13
 
14
14
  const [selectProps, selectedSystemPropTypes] = selectSystemProps([htmlAttrs, viewProps])
15
15
 
16
- // The top property varies between devices due to how devices render the viewport.
17
- const selectTextStyle = ({ fontSize, lineHeight }) => ({
18
- fontSize,
19
- lineHeight: lineHeight !== 1 ? lineHeight : fontSize * 2,
20
- ...Platform.select({
21
- ios: {
22
- top: -fontSize / 2
23
- },
24
- android: {
25
- top: fontSize / 4
16
+ const HALF_FONT_SIZE = 2
17
+ const QUARTER_FONT_SIZE = 4
18
+ const DEFAULT_LINE_HEIGHT = 1
19
+ const LINE_HEIGHT_MULTIPLIER = 2
20
+
21
+ const selectTextStyle = ({ fontSize, lineHeight }) =>
22
+ Platform.select({
23
+ native: {
24
+ fontSize: fontSize / HALF_FONT_SIZE,
25
+ lineHeight: fontSize,
26
+ position: 'relative',
27
+ top: -(fontSize * lineHeight) / HALF_FONT_SIZE
26
28
  },
27
29
  web: {
28
- top: -fontSize / 2.8
30
+ fontSize,
31
+ lineHeight:
32
+ lineHeight !== DEFAULT_LINE_HEIGHT ? lineHeight : fontSize * LINE_HEIGHT_MULTIPLIER,
33
+ top: -fontSize / QUARTER_FONT_SIZE
29
34
  }
30
35
  })
31
- })
32
36
 
33
37
  export const FootnoteLink = React.forwardRef(
34
38
  ({ copy = 'en', content = [], onClick, tokens, variant = {}, ...rest }, ref) => {
@@ -69,6 +73,7 @@ export const FootnoteLink = React.forwardRef(
69
73
  )
70
74
 
71
75
  FootnoteLink.displayName = 'FootnoteLink'
76
+ FootnoteLink.__UDS_COMPONENT_NAME__ = 'FootnoteLink'
72
77
 
73
78
  const copyShape = PropTypes.shape({ a11yLabel: PropTypes.string.isRequired })
74
79
 
@@ -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/Portal'
5
5
  import { useThemeTokens } from '../ThemeProvider/useThemeTokens'
6
6
  import { Card } from '../Card/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 { Portal as LocalPortal } from '../Portal/Portal'
5
6
  import { useCopy } from '../utils/useCopy'
6
7
  import { copyPropTypes } from '../utils/props/copyPropTypes'
7
8
  import { getTokensPropType } from '../utils/props/tokens'
@@ -107,8 +108,13 @@ export const ModalOverlay = React.forwardRef(
107
108
  const getCopy = useCopy({ dictionary, copy })
108
109
  const closeLabel = getCopy('closeButton')
109
110
 
111
+ // On web, use the local Portal (always appends to document.body) to ensure correct
112
+ // absolute positioning in MFE/iframe contexts. @gorhom/portal may render its host
113
+ // outside the iframe's document, causing coordinate mismatches when scrolled.
114
+ const PortalComponent = Platform.OS === 'web' ? LocalPortal : GorhomPortal
115
+
110
116
  return (
111
- <Portal>
117
+ <PortalComponent>
112
118
  <View
113
119
  ref={containerRef}
114
120
  onLayout={onLayout}
@@ -137,7 +143,7 @@ export const ModalOverlay = React.forwardRef(
137
143
  {children}
138
144
  </Card>
139
145
  </View>
140
- </Portal>
146
+ </PortalComponent>
141
147
  )
142
148
  }
143
149
  )
@@ -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 } from '../ThemeProvider/useThemeTokens'
6
5
  import { applyTextStyles } from '../ThemeProvider/utils/styles'
@@ -218,27 +217,11 @@ export const MultiSelectFilter = React.forwardRef(
218
217
  onCancel()
219
218
  }
220
219
 
221
- const appRootRef = React.useRef(null)
222
- const [rootOffsets, setRootOffsets] = React.useState(null)
223
-
224
- React.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({ horizontal: x, vertical: y })
231
- })
232
- }, [isOpen, rootOffsets])
233
-
234
220
  const { align, offsets } = useResponsiveProp({
235
221
  xs: { align: { top: 'top', left: 'left' } },
236
222
  sm: {
237
223
  align: { top: 'bottom', left: 'left' },
238
- offsets: {
239
- vertical: 4 - (rootOffsets?.vertical || 0),
240
- horizontal: -rootOffsets?.horizontal || 0
241
- }
224
+ offsets: { vertical: 4 }
242
225
  }
243
226
  })
244
227
 
@@ -364,14 +347,6 @@ export const MultiSelectFilter = React.forwardRef(
364
347
 
365
348
  return (
366
349
  <>
367
- {/*
368
- This View is rendered inside a Portal to determine the application's root position.
369
- Capturing it is crucial to determine offsets to position the Modal correctly, preventing
370
- misalignment that can occur when the Portal does not render the Modal at the body level.
371
- */}
372
- <Portal>
373
- <View ref={appRootRef} style={styles.appRootRef} />
374
- </Portal>
375
350
  <ButtonDropdown
376
351
  ref={sourceRef}
377
352
  key={id}
@@ -460,11 +435,6 @@ const styles = StyleSheet.create({
460
435
  },
461
436
  scrollContainer: {
462
437
  padding: 1
463
- },
464
- appRootRef: {
465
- position: 'absolute',
466
- top: 0,
467
- left: 0
468
438
  }
469
439
  })
470
440