@telus-uds/components-base 3.30.0 → 3.32.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 (45) hide show
  1. package/CHANGELOG.md +31 -1
  2. package/lib/cjs/Autocomplete/Autocomplete.js +13 -1
  3. package/lib/cjs/Autocomplete/constants.js +1 -1
  4. package/lib/cjs/Button/ButtonGroup.js +91 -23
  5. package/lib/cjs/Card/CardBase.js +75 -47
  6. package/lib/cjs/ColourToggle/ColourBubble.js +65 -12
  7. package/lib/cjs/ColourToggle/ColourToggle.js +40 -11
  8. package/lib/cjs/ColourToggle/constants.js +15 -0
  9. package/lib/cjs/ColourToggle/dictionary.js +15 -0
  10. package/lib/cjs/Listbox/ListboxOverlay.js +7 -1
  11. package/lib/cjs/MultiSelectFilter/ModalOverlay.js +7 -1
  12. package/lib/cjs/MultiSelectFilter/MultiSelectFilter.js +2 -28
  13. package/lib/cjs/TextInput/TextInputBase.js +2 -1
  14. package/lib/cjs/Validator/Validator.js +5 -1
  15. package/lib/cjs/utils/useOverlaidPosition.js +83 -42
  16. package/lib/esm/Autocomplete/Autocomplete.js +13 -1
  17. package/lib/esm/Autocomplete/constants.js +1 -1
  18. package/lib/esm/Button/ButtonGroup.js +91 -23
  19. package/lib/esm/Card/CardBase.js +75 -47
  20. package/lib/esm/ColourToggle/ColourBubble.js +66 -13
  21. package/lib/esm/ColourToggle/ColourToggle.js +41 -12
  22. package/lib/esm/ColourToggle/constants.js +8 -0
  23. package/lib/esm/ColourToggle/dictionary.js +9 -0
  24. package/lib/esm/Listbox/ListboxOverlay.js +7 -1
  25. package/lib/esm/MultiSelectFilter/ModalOverlay.js +8 -2
  26. package/lib/esm/MultiSelectFilter/MultiSelectFilter.js +2 -28
  27. package/lib/esm/TextInput/TextInputBase.js +2 -1
  28. package/lib/esm/Validator/Validator.js +5 -1
  29. package/lib/esm/utils/useOverlaidPosition.js +83 -42
  30. package/lib/package.json +2 -2
  31. package/package.json +2 -2
  32. package/src/Autocomplete/Autocomplete.jsx +11 -2
  33. package/src/Autocomplete/constants.js +1 -1
  34. package/src/Button/ButtonGroup.jsx +93 -24
  35. package/src/Card/CardBase.jsx +94 -75
  36. package/src/ColourToggle/ColourBubble.jsx +62 -7
  37. package/src/ColourToggle/ColourToggle.jsx +50 -10
  38. package/src/ColourToggle/constants.js +10 -0
  39. package/src/ColourToggle/dictionary.js +6 -0
  40. package/src/Listbox/ListboxOverlay.jsx +6 -2
  41. package/src/MultiSelectFilter/ModalOverlay.jsx +9 -3
  42. package/src/MultiSelectFilter/MultiSelectFilter.jsx +1 -31
  43. package/src/TextInput/TextInputBase.jsx +2 -1
  44. package/src/Validator/Validator.jsx +5 -1
  45. package/src/utils/useOverlaidPosition.js +84 -34
@@ -12,6 +12,8 @@ var _utils = require("../utils");
12
12
  var _StackView = require("../StackView");
13
13
  var _Typography = _interopRequireDefault(require("../Typography"));
14
14
  var _ColourBubble = _interopRequireDefault(require("./ColourBubble"));
15
+ var _constants = require("./constants");
16
+ var _dictionary = _interopRequireDefault(require("./dictionary"));
15
17
  var _jsxRuntime = require("react/jsx-runtime");
16
18
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
17
19
  const [selectProps, selectedSystemPropTypes] = (0, _utils.selectSystemProps)([_utils.a11yProps, _utils.viewProps]);
@@ -23,37 +25,49 @@ const ColourToggle = /*#__PURE__*/_react.default.forwardRef((_ref, ref) => {
23
25
  items,
24
26
  onChange,
25
27
  showTooltips,
28
+ copy = 'en',
29
+ dictionary = _dictionary.default,
26
30
  ...rest
27
31
  } = _ref;
28
32
  const [currentColourId, setCurrentColourId] = _react.default.useState(defaultColourId);
29
33
  const getTokens = (0, _ThemeProvider.useThemeTokensCallback)('ColourToggle', tokens, variant);
34
+ const getCopy = (0, _utils.useCopy)({
35
+ dictionary,
36
+ copy
37
+ });
30
38
  const {
31
39
  space
32
40
  } = getTokens();
33
- const {
34
- colourName: currentColourName = ''
35
- } = items.find(item => item.id === currentColourId) || '';
41
+ const currentItem = items.find(_ref2 => {
42
+ let {
43
+ id
44
+ } = _ref2;
45
+ return id === currentColourId;
46
+ });
36
47
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_View.default, {
37
48
  ref: ref,
38
49
  ...selectProps(rest),
39
50
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_Typography.default, {
40
- children: currentColourName
51
+ children: currentItem?.unavailable ? `${currentItem.colourName} ${getCopy('unavailable')}` : currentItem?.colourName ?? ''
41
52
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_StackView.StackWrap, {
42
53
  space: space,
43
54
  accessibilityRole: "radiogroup",
44
- children: items.map((_ref2, index) => {
55
+ children: items.map((_ref3, index) => {
45
56
  let {
46
57
  id,
47
58
  colourHexCode,
48
- colourName
49
- } = _ref2;
59
+ colourName,
60
+ unavailable,
61
+ disabled
62
+ } = _ref3;
50
63
  const colourBubbleId = id || `ColourBubble[${index}]`;
51
64
  const handleChangeColour = event => {
52
65
  setCurrentColourId(id);
53
66
  onChange?.(event, {
54
67
  id,
55
68
  colourHexCode,
56
- colourName
69
+ colourName,
70
+ unavailable
57
71
  });
58
72
  };
59
73
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_ColourBubble.default, {
@@ -63,7 +77,9 @@ const ColourToggle = /*#__PURE__*/_react.default.forwardRef((_ref, ref) => {
63
77
  colourHexCode: colourHexCode,
64
78
  colourName: colourName,
65
79
  onPress: handleChangeColour,
66
- showTooltip: showTooltips
80
+ showTooltip: showTooltips,
81
+ unavailable: unavailable,
82
+ isDisabled: !!disabled
67
83
  }, colourBubbleId);
68
84
  })
69
85
  })]
@@ -90,7 +106,9 @@ ColourToggle.propTypes = {
90
106
  items: _propTypes.default.arrayOf(_propTypes.default.exact({
91
107
  colourHexCode: _propTypes.default.string,
92
108
  colourName: _propTypes.default.string,
93
- id: _propTypes.default.string
109
+ id: _propTypes.default.string,
110
+ unavailable: _propTypes.default.oneOf(Object.values(_constants.UNAVAILABLE_VARIANT)),
111
+ disabled: _propTypes.default.bool
94
112
  })),
95
113
  /**
96
114
  * If provided, this function is called when the current selection of the color is changed of all currently `items`. Receives two parameters: item object selected and the event
@@ -99,6 +117,17 @@ ColourToggle.propTypes = {
99
117
  /**
100
118
  * When true, displays each colour's name as a tooltip on hover (web only).
101
119
  */
102
- showTooltips: _propTypes.default.bool
120
+ showTooltips: _propTypes.default.bool,
121
+ /**
122
+ * Select English or French copy.
123
+ */
124
+ copy: _propTypes.default.oneOf(['en', 'fr']),
125
+ /**
126
+ * Override default labels.
127
+ */
128
+ dictionary: _propTypes.default.shape({
129
+ en: _constants.DICTIONARY_CONTENT_SHAPE,
130
+ fr: _constants.DICTIONARY_CONTENT_SHAPE
131
+ })
103
132
  };
104
133
  var _default = exports.default = ColourToggle;
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.UNAVAILABLE_VARIANT = exports.DICTIONARY_CONTENT_SHAPE = void 0;
7
+ var _propTypes = _interopRequireDefault(require("prop-types"));
8
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
9
+ const UNAVAILABLE_VARIANT = exports.UNAVAILABLE_VARIANT = {
10
+ DARK: 'dark',
11
+ LIGHT: 'light'
12
+ };
13
+ const DICTIONARY_CONTENT_SHAPE = exports.DICTIONARY_CONTENT_SHAPE = _propTypes.default.shape({
14
+ unavailable: _propTypes.default.string
15
+ });
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+ const DEFAULT_COLOUR_TOGGLE_DICTIONARY = {
8
+ en: {
9
+ unavailable: '(Out of stock)'
10
+ },
11
+ fr: {
12
+ unavailable: '(En rupture de stock)'
13
+ }
14
+ };
15
+ var _default = exports.default = DEFAULT_COLOUR_TOGGLE_DICTIONARY;
@@ -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');
@@ -206,7 +206,11 @@ const Validator = /*#__PURE__*/_react.default.forwardRef((_ref2, ref) => {
206
206
 
207
207
  // Sync external value prop to internal state
208
208
  _react.default.useEffect(() => {
209
- if (value && Number(value).toString() !== 'NaN') {
209
+ if (value === '') {
210
+ setCodes(Array(validatorsLength).fill(''));
211
+ return;
212
+ }
213
+ if (Number(value).toString() !== 'NaN') {
210
214
  const digits = value.split('').slice(0, validatorsLength);
211
215
  const newCodes = Array(validatorsLength).fill('');
212
216
  digits.forEach((digit, i) => {
@@ -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;
@@ -13,6 +13,8 @@ import { getPressHandlersWithArgs } from '../utils/pressability';
13
13
  import { jsx as _jsx } from "react/jsx-runtime";
14
14
  const [selectProps, selectedSystemPropTypes] = selectSystemProps([a11yProps, viewProps]);
15
15
  const [selectItemProps, selectedItemPropTypes] = selectSystemProps([a11yProps, focusHandlerProps, pressProps, viewProps]);
16
+ const EQUAL_WIDTH = 'equal';
17
+ const RESPONSIVE_WIDTH = 'responsive';
16
18
  const ButtonGroup = /*#__PURE__*/React.forwardRef((_ref, ref) => {
17
19
  let {
18
20
  variant,
@@ -57,31 +59,90 @@ const ButtonGroup = /*#__PURE__*/React.forwardRef((_ref, ref) => {
57
59
  const isMobileNonContained = Platform.OS !== 'web' && (!variant || variant?.style !== 'contained');
58
60
  const themeButtonTokensCallback = useThemeTokensCallback('ButtonGroupItem', tokens, variant);
59
61
  const gapValue = useSpacingScale(gap || space);
62
+ const isWeb = Platform.OS === 'web';
63
+ const buttonWidthVariant = variant?.width;
60
64
  const getButtonTokens = useCallback(state => {
61
65
  const themeButtonTokens = themeButtonTokensCallback(state);
62
66
  const shouldUseTransparentBackground = isMobileNonContained && !state.selected && !state.pressed && !state.hover && !state.focus;
67
+ let widthStyle;
68
+ switch (buttonWidthVariant) {
69
+ case EQUAL_WIDTH:
70
+ widthStyle = staticStyles.equalWidth;
71
+ break;
72
+ case RESPONSIVE_WIDTH:
73
+ widthStyle = staticStyles.responsiveWidth;
74
+ break;
75
+ // no default
76
+ }
63
77
  return {
64
78
  ...themeButtonTokens,
65
- ...(variant?.width === 'equal' && staticStyles.equalWidth),
79
+ ...widthStyle,
66
80
  ...(shouldUseTransparentBackground && {
67
81
  backgroundColor: 'transparent'
68
82
  }),
69
83
  alignSelf: themeButtonTokens.width ? 'flex-start' : 'center'
70
84
  };
71
- }, [themeButtonTokensCallback, isMobileNonContained, variant?.width]);
72
- const fieldsetStyles = useMemo(() => ({
73
- ...staticStyles.fieldsetBase,
74
- borderRadius,
75
- backgroundColor: isMobileNonContained ? 'transparent' : backgroundColor || 'transparent',
76
- padding,
77
- width: variant?.width === 'equal' ? '100%' : 'auto'
78
- }), [borderRadius, backgroundColor, padding, variant?.width, isMobileNonContained]);
79
- const viewStyles = useMemo(() => ({
80
- ...staticStyles.viewBase,
81
- flexDirection: direction === 'column' ? 'column' : 'row',
82
- gap: gapValue || 0,
83
- justifyContent: variant?.width === 'equal' ? 'space-evenly' : 'flex-start'
84
- }), [direction, gapValue, variant?.width]);
85
+ }, [themeButtonTokensCallback, isMobileNonContained, buttonWidthVariant]);
86
+ const fieldsetStyles = useMemo(() => {
87
+ let fieldSetBase;
88
+ switch (buttonWidthVariant) {
89
+ case EQUAL_WIDTH:
90
+ case RESPONSIVE_WIDTH:
91
+ fieldSetBase = staticStyles.fieldSetResponsive;
92
+ break;
93
+ default:
94
+ fieldSetBase = staticStyles.fieldsetBase;
95
+ break;
96
+ }
97
+ return {
98
+ ...fieldSetBase,
99
+ borderRadius,
100
+ backgroundColor: isMobileNonContained ? 'transparent' : backgroundColor || 'transparent',
101
+ padding
102
+ };
103
+ }, [borderRadius, backgroundColor, padding, isMobileNonContained, buttonWidthVariant]);
104
+ const buttonsContainerStyles = useMemo(() => {
105
+ let flexWrap;
106
+ if (isWeb) {
107
+ flexWrap = 'wrap';
108
+ } else {
109
+ switch (buttonWidthVariant) {
110
+ case EQUAL_WIDTH:
111
+ case RESPONSIVE_WIDTH:
112
+ flexWrap = 'nowrap';
113
+ break;
114
+ default:
115
+ flexWrap = 'wrap';
116
+ break;
117
+ }
118
+ }
119
+ let justifyContent;
120
+ switch (buttonWidthVariant) {
121
+ case EQUAL_WIDTH:
122
+ justifyContent = 'space-evenly';
123
+ break;
124
+ default:
125
+ justifyContent = 'flex-start';
126
+ break;
127
+ }
128
+ let viewBaseStyle;
129
+ switch (buttonWidthVariant) {
130
+ case EQUAL_WIDTH:
131
+ case RESPONSIVE_WIDTH:
132
+ viewBaseStyle = {};
133
+ break;
134
+ default:
135
+ viewBaseStyle = staticStyles.viewBase;
136
+ break;
137
+ }
138
+ return {
139
+ ...viewBaseStyle,
140
+ flexWrap,
141
+ flexDirection: direction === 'column' ? 'column' : 'row',
142
+ gap: gapValue || 0,
143
+ justifyContent
144
+ };
145
+ }, [direction, gapValue, buttonWidthVariant, isWeb]);
85
146
  const {
86
147
  currentValues,
87
148
  toggleOneValue
@@ -103,7 +164,7 @@ const ButtonGroup = /*#__PURE__*/React.forwardRef((_ref, ref) => {
103
164
  }
104
165
 
105
166
  // Some web screenreaders e.g. MacOS Voiceover don't handle radiogroups properly unless radio is direct child of radiogroup
106
- const innerRole = Platform.OS === 'web' && accessibilityRole === 'radiogroup' ? accessibilityRole : undefined;
167
+ const buttonsWrapperRole = Platform.OS === 'web' && accessibilityRole === 'radiogroup' ? accessibilityRole : undefined;
107
168
  return /*#__PURE__*/_jsx(Fieldset, {
108
169
  ...systemProps,
109
170
  ref: ref,
@@ -121,8 +182,8 @@ const ButtonGroup = /*#__PURE__*/React.forwardRef((_ref, ref) => {
121
182
  style: fieldsetStyles,
122
183
  ...selectProps(rest),
123
184
  children: /*#__PURE__*/_jsx(View, {
124
- accessibilityRole: innerRole,
125
- style: viewStyles,
185
+ accessibilityRole: buttonsWrapperRole,
186
+ style: buttonsContainerStyles,
126
187
  children: items.map((_ref2, index) => {
127
188
  let {
128
189
  label,
@@ -290,17 +351,24 @@ ButtonGroup.propTypes = {
290
351
  copy: PropTypes.oneOf(['en', 'fr'])
291
352
  };
292
353
  const staticStyles = StyleSheet.create({
354
+ equalWidth: {
355
+ width: '100%',
356
+ flex: 1
357
+ },
358
+ responsiveWidth: {
359
+ width: 'auto'
360
+ },
293
361
  fieldsetBase: {
294
362
  alignSelf: 'flex-start',
295
- display: 'inline'
363
+ display: 'inline',
364
+ width: 'auto'
365
+ },
366
+ fieldSetResponsive: {
367
+ width: '100%'
296
368
  },
297
369
  viewBase: {
298
370
  flexWrap: 'wrap',
299
371
  alignItems: 'center'
300
- },
301
- equalWidth: {
302
- width: '100%',
303
- flex: 1
304
372
  }
305
373
  });
306
374
  export default ButtonGroup;