@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
@@ -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
@@ -12,7 +12,7 @@
12
12
  "@gorhom/portal": "^1.0.14",
13
13
  "@react-native-picker/picker": "^2.9.0",
14
14
  "@telus-uds/system-constants": "^3.0.0",
15
- "@telus-uds/system-theme-tokens": "^4.21.0",
15
+ "@telus-uds/system-theme-tokens": "^4.22.0",
16
16
  "airbnb-prop-types": "^2.16.0",
17
17
  "css-mediaquery": "^0.1.2",
18
18
  "expo-document-picker": "^13.0.1",
@@ -84,6 +84,6 @@
84
84
  "standard-engine": {
85
85
  "skip": true
86
86
  },
87
- "version": "3.30.0",
87
+ "version": "3.32.0",
88
88
  "types": "types/index.d.ts"
89
89
  }
package/package.json CHANGED
@@ -12,7 +12,7 @@
12
12
  "@gorhom/portal": "^1.0.14",
13
13
  "@react-native-picker/picker": "^2.9.0",
14
14
  "@telus-uds/system-constants": "^3.0.0",
15
- "@telus-uds/system-theme-tokens": "^4.21.0",
15
+ "@telus-uds/system-theme-tokens": "^4.22.0",
16
16
  "airbnb-prop-types": "^2.16.0",
17
17
  "css-mediaquery": "^0.1.2",
18
18
  "expo-document-picker": "^13.0.1",
@@ -84,6 +84,6 @@
84
84
  "standard-engine": {
85
85
  "skip": true
86
86
  },
87
- "version": "3.30.0",
87
+ "version": "3.32.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
@@ -28,6 +28,8 @@ const [selectItemProps, selectedItemPropTypes] = selectSystemProps([
28
28
  pressProps,
29
29
  viewProps
30
30
  ])
31
+ const EQUAL_WIDTH = 'equal'
32
+ const RESPONSIVE_WIDTH = 'responsive'
31
33
 
32
34
  const ButtonGroup = React.forwardRef(
33
35
  (
@@ -70,6 +72,9 @@ const ButtonGroup = React.forwardRef(
70
72
  const themeButtonTokensCallback = useThemeTokensCallback('ButtonGroupItem', tokens, variant)
71
73
  const gapValue = useSpacingScale(gap || space)
72
74
 
75
+ const isWeb = Platform.OS === 'web'
76
+ const buttonWidthVariant = variant?.width
77
+
73
78
  const getButtonTokens = useCallback(
74
79
  (state) => {
75
80
  const themeButtonTokens = themeButtonTokensCallback(state)
@@ -77,36 +82,93 @@ const ButtonGroup = React.forwardRef(
77
82
  const shouldUseTransparentBackground =
78
83
  isMobileNonContained && !state.selected && !state.pressed && !state.hover && !state.focus
79
84
 
85
+ let widthStyle
86
+
87
+ switch (buttonWidthVariant) {
88
+ case EQUAL_WIDTH:
89
+ widthStyle = staticStyles.equalWidth
90
+ break
91
+ case RESPONSIVE_WIDTH:
92
+ widthStyle = staticStyles.responsiveWidth
93
+ break
94
+ // no default
95
+ }
96
+
80
97
  return {
81
98
  ...themeButtonTokens,
82
- ...(variant?.width === 'equal' && staticStyles.equalWidth),
99
+ ...widthStyle,
83
100
  ...(shouldUseTransparentBackground && { backgroundColor: 'transparent' }),
84
101
  alignSelf: themeButtonTokens.width ? 'flex-start' : 'center'
85
102
  }
86
103
  },
87
- [themeButtonTokensCallback, isMobileNonContained, variant?.width]
104
+ [themeButtonTokensCallback, isMobileNonContained, buttonWidthVariant]
88
105
  )
89
106
 
90
- const fieldsetStyles = useMemo(
91
- () => ({
92
- ...staticStyles.fieldsetBase,
107
+ const fieldsetStyles = useMemo(() => {
108
+ let fieldSetBase
109
+ switch (buttonWidthVariant) {
110
+ case EQUAL_WIDTH:
111
+ case RESPONSIVE_WIDTH:
112
+ fieldSetBase = staticStyles.fieldSetResponsive
113
+ break
114
+ default:
115
+ fieldSetBase = staticStyles.fieldsetBase
116
+ break
117
+ }
118
+
119
+ return {
120
+ ...fieldSetBase,
93
121
  borderRadius,
94
122
  backgroundColor: isMobileNonContained ? 'transparent' : backgroundColor || 'transparent',
95
- padding,
96
- width: variant?.width === 'equal' ? '100%' : 'auto'
97
- }),
98
- [borderRadius, backgroundColor, padding, variant?.width, isMobileNonContained]
99
- )
123
+ padding
124
+ }
125
+ }, [borderRadius, backgroundColor, padding, isMobileNonContained, buttonWidthVariant])
100
126
 
101
- const viewStyles = useMemo(
102
- () => ({
103
- ...staticStyles.viewBase,
127
+ const buttonsContainerStyles = useMemo(() => {
128
+ let flexWrap
129
+ if (isWeb) {
130
+ flexWrap = 'wrap'
131
+ } else {
132
+ switch (buttonWidthVariant) {
133
+ case EQUAL_WIDTH:
134
+ case RESPONSIVE_WIDTH:
135
+ flexWrap = 'nowrap'
136
+ break
137
+ default:
138
+ flexWrap = 'wrap'
139
+ break
140
+ }
141
+ }
142
+
143
+ let justifyContent
144
+ switch (buttonWidthVariant) {
145
+ case EQUAL_WIDTH:
146
+ justifyContent = 'space-evenly'
147
+ break
148
+ default:
149
+ justifyContent = 'flex-start'
150
+ break
151
+ }
152
+
153
+ let viewBaseStyle
154
+ switch (buttonWidthVariant) {
155
+ case EQUAL_WIDTH:
156
+ case RESPONSIVE_WIDTH:
157
+ viewBaseStyle = {}
158
+ break
159
+ default:
160
+ viewBaseStyle = staticStyles.viewBase
161
+ break
162
+ }
163
+
164
+ return {
165
+ ...viewBaseStyle,
166
+ flexWrap,
104
167
  flexDirection: direction === 'column' ? 'column' : 'row',
105
168
  gap: gapValue || 0,
106
- justifyContent: variant?.width === 'equal' ? 'space-evenly' : 'flex-start'
107
- }),
108
- [direction, gapValue, variant?.width]
109
- )
169
+ justifyContent
170
+ }
171
+ }, [direction, gapValue, buttonWidthVariant, isWeb])
110
172
 
111
173
  const { currentValues, toggleOneValue } = useMultipleInputValues({
112
174
  initialValues,
@@ -128,7 +190,7 @@ const ButtonGroup = React.forwardRef(
128
190
  }
129
191
 
130
192
  // Some web screenreaders e.g. MacOS Voiceover don't handle radiogroups properly unless radio is direct child of radiogroup
131
- const innerRole =
193
+ const buttonsWrapperRole =
132
194
  Platform.OS === 'web' && accessibilityRole === 'radiogroup' ? accessibilityRole : undefined
133
195
 
134
196
  return (
@@ -149,7 +211,7 @@ const ButtonGroup = React.forwardRef(
149
211
  style={fieldsetStyles}
150
212
  {...selectProps(rest)}
151
213
  >
152
- <View accessibilityRole={innerRole} style={viewStyles}>
214
+ <View accessibilityRole={buttonsWrapperRole} style={buttonsContainerStyles}>
153
215
  {items.map(
154
216
  (
155
217
  {
@@ -329,17 +391,24 @@ ButtonGroup.propTypes = {
329
391
  }
330
392
 
331
393
  const staticStyles = StyleSheet.create({
394
+ equalWidth: {
395
+ width: '100%',
396
+ flex: 1
397
+ },
398
+ responsiveWidth: {
399
+ width: 'auto'
400
+ },
332
401
  fieldsetBase: {
333
402
  alignSelf: 'flex-start',
334
- display: 'inline'
403
+ display: 'inline',
404
+ width: 'auto'
405
+ },
406
+ fieldSetResponsive: {
407
+ width: '100%'
335
408
  },
336
409
  viewBase: {
337
410
  flexWrap: 'wrap',
338
411
  alignItems: 'center'
339
- },
340
- equalWidth: {
341
- width: '100%',
342
- flex: 1
343
412
  }
344
413
  })
345
414