@telus-uds/components-base 3.29.1 → 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.
Files changed (71) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/lib/cjs/Autocomplete/Autocomplete.js +13 -1
  3. package/lib/cjs/Autocomplete/constants.js +1 -1
  4. package/lib/cjs/Carousel/Carousel.js +6 -1
  5. package/lib/cjs/Carousel/CarouselThumbnail.js +123 -31
  6. package/lib/cjs/Carousel/CarouselThumbnailNavigation.js +8 -1
  7. package/lib/cjs/Footnote/FootnoteLink.js +18 -17
  8. package/lib/cjs/Listbox/ListboxOverlay.js +7 -1
  9. package/lib/cjs/MultiSelectFilter/ModalOverlay.js +7 -1
  10. package/lib/cjs/MultiSelectFilter/MultiSelectFilter.js +2 -28
  11. package/lib/cjs/Progress/Progress.js +30 -5
  12. package/lib/cjs/Scroll/Scroll.js +466 -0
  13. package/lib/cjs/Scroll/ScrollContext.js +55 -0
  14. package/lib/cjs/Scroll/ScrollItem.js +103 -0
  15. package/lib/cjs/Scroll/ScrollProgress.js +99 -0
  16. package/lib/cjs/Scroll/dictionary.js +18 -0
  17. package/lib/cjs/Scroll/index.js +29 -0
  18. package/lib/cjs/TextInput/TextInputBase.js +2 -1
  19. package/lib/cjs/index.js +15 -0
  20. package/lib/cjs/utils/animation/index.js +7 -0
  21. package/lib/cjs/utils/animation/useAnimatedValue.js +46 -0
  22. package/lib/cjs/utils/children.js +2 -1
  23. package/lib/cjs/utils/useOverlaidPosition.js +83 -42
  24. package/lib/esm/Autocomplete/Autocomplete.js +13 -1
  25. package/lib/esm/Autocomplete/constants.js +1 -1
  26. package/lib/esm/Carousel/Carousel.js +6 -1
  27. package/lib/esm/Carousel/CarouselThumbnail.js +124 -32
  28. package/lib/esm/Carousel/CarouselThumbnailNavigation.js +8 -1
  29. package/lib/esm/Footnote/FootnoteLink.js +18 -17
  30. package/lib/esm/Listbox/ListboxOverlay.js +7 -1
  31. package/lib/esm/MultiSelectFilter/ModalOverlay.js +8 -2
  32. package/lib/esm/MultiSelectFilter/MultiSelectFilter.js +2 -28
  33. package/lib/esm/Progress/Progress.js +30 -5
  34. package/lib/esm/Scroll/Scroll.js +459 -0
  35. package/lib/esm/Scroll/ScrollContext.js +47 -0
  36. package/lib/esm/Scroll/ScrollItem.js +96 -0
  37. package/lib/esm/Scroll/ScrollProgress.js +92 -0
  38. package/lib/esm/Scroll/dictionary.js +12 -0
  39. package/lib/esm/Scroll/index.js +4 -0
  40. package/lib/esm/TextInput/TextInputBase.js +2 -1
  41. package/lib/esm/index.js +1 -0
  42. package/lib/esm/utils/animation/index.js +1 -1
  43. package/lib/esm/utils/animation/useAnimatedValue.js +39 -0
  44. package/lib/esm/utils/children.js +2 -1
  45. package/lib/esm/utils/useOverlaidPosition.js +83 -42
  46. package/lib/package.json +2 -2
  47. package/package.json +2 -2
  48. package/src/Autocomplete/Autocomplete.jsx +11 -2
  49. package/src/Autocomplete/constants.js +1 -1
  50. package/src/Carousel/Carousel.jsx +6 -1
  51. package/src/Carousel/CarouselThumbnail.jsx +153 -64
  52. package/src/Carousel/CarouselThumbnailNavigation.jsx +8 -2
  53. package/src/Footnote/FootnoteLink.jsx +17 -12
  54. package/src/Listbox/ListboxOverlay.jsx +6 -2
  55. package/src/MultiSelectFilter/ModalOverlay.jsx +9 -3
  56. package/src/MultiSelectFilter/MultiSelectFilter.jsx +1 -31
  57. package/src/Progress/Progress.jsx +22 -3
  58. package/src/Scroll/Scroll.jsx +488 -0
  59. package/src/Scroll/ScrollContext.jsx +41 -0
  60. package/src/Scroll/ScrollItem.jsx +89 -0
  61. package/src/Scroll/ScrollProgress.jsx +75 -0
  62. package/src/Scroll/dictionary.js +12 -0
  63. package/src/Scroll/index.js +5 -0
  64. package/src/TextInput/TextInputBase.jsx +2 -1
  65. package/src/index.js +1 -0
  66. package/src/utils/animation/index.js +1 -1
  67. package/src/utils/animation/useAnimatedValue.js +37 -0
  68. package/src/utils/children.jsx +4 -1
  69. package/src/utils/useOverlaidPosition.js +84 -34
  70. package/types/Scroll.d.ts +66 -0
  71. package/types/index.d.ts +9 -0
@@ -0,0 +1,5 @@
1
+ import Scroll from './Scroll'
2
+
3
+ export default Scroll
4
+ export { default as ScrollItem } from './ScrollItem'
5
+ export { ScrollProvider, useScroll } from './ScrollContext'
@@ -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')
package/src/index.js CHANGED
@@ -49,6 +49,7 @@ export { default as QuickLinksFeature } from './QuickLinksFeature'
49
49
  export { default as Radio, RadioGroup } from './Radio'
50
50
  export { default as RadioCard, RadioCardGroup } from './RadioCard'
51
51
  export { default as Responsive } from './Responsive'
52
+ export { default as Scroll, ScrollItem } from './Scroll'
52
53
  export { default as Search } from './Search'
53
54
  export { default as Select } from './Select'
54
55
  export { default as Shortcuts, ShortcutsItem } from './Shortcuts'
@@ -1,2 +1,2 @@
1
- /* eslint-disable import/prefer-default-export */
1
+ export { default as useAnimatedValue } from './useAnimatedValue'
2
2
  export { default as useVerticalExpandAnimation } from './useVerticalExpandAnimation'
@@ -0,0 +1,37 @@
1
+ import React from 'react'
2
+ import { Animated, Easing } from 'react-native'
3
+
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
+ const useAnimatedValue = (targetValue, duration = ANIMATION_DURATION) => {
15
+ const [currentValue, setCurrentValue] = React.useState(targetValue)
16
+ const animatedValue = React.useRef(new Animated.Value(targetValue)).current
17
+
18
+ React.useEffect(() => {
19
+ const id = animatedValue.addListener(({ value }) => setCurrentValue(value))
20
+ return () => animatedValue.removeListener(id)
21
+ }, [animatedValue])
22
+
23
+ React.useEffect(() => {
24
+ const animation = Animated.timing(animatedValue, {
25
+ toValue: targetValue,
26
+ duration,
27
+ easing: Easing.inOut(Easing.quad),
28
+ useNativeDriver: false
29
+ })
30
+ animation.start()
31
+ return () => animation.stop()
32
+ }, [targetValue, animatedValue, duration])
33
+
34
+ return currentValue
35
+ }
36
+
37
+ export default useAnimatedValue
@@ -47,8 +47,11 @@ 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
+ // Check displayName first as it is explicitly set and reliable regardless of export style (named vs default)
50
51
  const isWrapable = (child) =>
51
- isStringOrNumber(child) || child.type === A11yText || child.type?.name === 'FootnoteLink'
52
+ isStringOrNumber(child) ||
53
+ child.type === A11yText ||
54
+ child.type?.__UDS_COMPONENT_NAME__ === 'FootnoteLink'
52
55
  const combineKeys = (childrenArray) =>
53
56
  childrenArray.reduce((newKey, child) => `${newKey}${child.key || ''}`, '')
54
57
 
@@ -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,
@@ -0,0 +1,66 @@
1
+ import type { ComponentType, ReactNode } from 'react'
2
+ import type { SemanticTag } from './Common'
3
+
4
+ export interface ScrollDictionary {
5
+ accessibilityLabel: string
6
+ instructionText: string
7
+ progressBarLabel: string
8
+ }
9
+
10
+ export interface ScrollProps {
11
+ /**
12
+ * Scroll items to display. Should be `ScrollItem` components.
13
+ */
14
+ children: ReactNode
15
+ /**
16
+ * Theme tokens for customizing the Scroll appearance.
17
+ */
18
+ tokens?: ScrollTokens
19
+ /**
20
+ * Variant for the Scroll component.
21
+ */
22
+ variant?: ScrollVariant
23
+ /**
24
+ * Select English or French copy for accessible labels.
25
+ */
26
+ copy?: 'en' | 'fr'
27
+ /**
28
+ * Override the default dictionary by passing the complete dictionary object for `en` and `fr`.
29
+ */
30
+ dictionary?: {
31
+ en: ScrollDictionary
32
+ fr: ScrollDictionary
33
+ }
34
+ /**
35
+ * Accessibility label for the scrollable region.
36
+ */
37
+ accessibilityLabel?: string
38
+ }
39
+
40
+ export type ScrollVariant = {}
41
+
42
+ export interface ScrollTokens {
43
+ itemGap?: number
44
+ progressBarColor?: string
45
+ progressBarHeight?: number
46
+ progressHitAreaHeight?: number
47
+ progressSpacing?: number
48
+ progressTrackColor?: string
49
+ progressTrackHeight?: number
50
+ }
51
+
52
+ export interface ScrollItemProps {
53
+ /**
54
+ * Content of the scroll item.
55
+ */
56
+ children: ReactNode
57
+ /**
58
+ * Sets the HTML tag of the outer container. Defaults to `'li'`.
59
+ */
60
+ tag?: SemanticTag
61
+ }
62
+
63
+ declare const Scroll: ComponentType<ScrollProps>
64
+ export declare const ScrollItem: ComponentType<ScrollItemProps>
65
+
66
+ export default Scroll
package/types/index.d.ts CHANGED
@@ -45,6 +45,15 @@ export { LinkProps, LinkSize, LinkAndTextButtonVariants, LinkTokens } from './Li
45
45
  export { default as List } from './List'
46
46
  export { ListVariants, ListTokens, ListProps, ListItemProps, ListItemTokens } from './List'
47
47
 
48
+ export { default as Scroll, ScrollItem } from './Scroll'
49
+ export type {
50
+ ScrollProps,
51
+ ScrollTokens,
52
+ ScrollVariant,
53
+ ScrollItemProps,
54
+ ScrollDictionary
55
+ } from './Scroll'
56
+
48
57
  export { default as Search } from './Search'
49
58
  export { SearchTokens, SearchProps } from './Search'
50
59