@telus-uds/components-base 3.21.0 → 3.23.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 (64) hide show
  1. package/CHANGELOG.md +31 -1
  2. package/lib/cjs/Button/Button.js +12 -3
  3. package/lib/cjs/Button/ButtonBase.js +63 -10
  4. package/lib/cjs/Button/ButtonDropdown.js +2 -0
  5. package/lib/cjs/Button/ButtonGroup.js +45 -38
  6. package/lib/cjs/Button/propTypes.js +6 -0
  7. package/lib/cjs/Card/PressableCardBase.js +3 -1
  8. package/lib/cjs/Carousel/Carousel.js +63 -22
  9. package/lib/cjs/Carousel/CarouselItem/CarouselItem.js +23 -3
  10. package/lib/cjs/Icon/Icon.js +8 -11
  11. package/lib/cjs/Icon/IconText.js +0 -1
  12. package/lib/cjs/Listbox/GroupControl.js +33 -39
  13. package/lib/cjs/Listbox/Listbox.js +22 -13
  14. package/lib/cjs/Listbox/ListboxGroup.js +2 -1
  15. package/lib/cjs/Listbox/ListboxOverlay.js +5 -2
  16. package/lib/cjs/Listbox/PressableItem.js +8 -4
  17. package/lib/cjs/TextInput/TextInputBase.js +5 -1
  18. package/lib/cjs/ThemeProvider/index.js +9 -1
  19. package/lib/cjs/ThemeProvider/useResponsiveThemeTokensCallback.js +124 -0
  20. package/lib/cjs/Validator/Validator.js +171 -135
  21. package/lib/cjs/index.js +7 -0
  22. package/lib/esm/Button/Button.js +13 -4
  23. package/lib/esm/Button/ButtonBase.js +64 -11
  24. package/lib/esm/Button/ButtonDropdown.js +2 -0
  25. package/lib/esm/Button/ButtonGroup.js +44 -39
  26. package/lib/esm/Button/propTypes.js +6 -0
  27. package/lib/esm/Card/PressableCardBase.js +3 -1
  28. package/lib/esm/Carousel/Carousel.js +63 -22
  29. package/lib/esm/Carousel/CarouselItem/CarouselItem.js +23 -3
  30. package/lib/esm/Icon/Icon.js +8 -11
  31. package/lib/esm/Icon/IconText.js +0 -1
  32. package/lib/esm/Listbox/GroupControl.js +33 -39
  33. package/lib/esm/Listbox/Listbox.js +23 -14
  34. package/lib/esm/Listbox/ListboxGroup.js +2 -1
  35. package/lib/esm/Listbox/ListboxOverlay.js +5 -2
  36. package/lib/esm/Listbox/PressableItem.js +8 -4
  37. package/lib/esm/TextInput/TextInputBase.js +5 -1
  38. package/lib/esm/ThemeProvider/index.js +1 -0
  39. package/lib/esm/ThemeProvider/useResponsiveThemeTokensCallback.js +117 -0
  40. package/lib/esm/Validator/Validator.js +171 -135
  41. package/lib/esm/index.js +1 -1
  42. package/lib/package.json +2 -2
  43. package/package.json +2 -2
  44. package/src/Button/Button.jsx +26 -5
  45. package/src/Button/ButtonBase.jsx +79 -16
  46. package/src/Button/ButtonDropdown.jsx +2 -0
  47. package/src/Button/ButtonGroup.jsx +62 -45
  48. package/src/Button/propTypes.js +6 -0
  49. package/src/Card/PressableCardBase.jsx +3 -1
  50. package/src/Carousel/Carousel.jsx +71 -7
  51. package/src/Carousel/CarouselItem/CarouselItem.jsx +31 -3
  52. package/src/Icon/Icon.jsx +11 -14
  53. package/src/Icon/IconText.jsx +0 -1
  54. package/src/Listbox/GroupControl.jsx +41 -47
  55. package/src/Listbox/Listbox.jsx +26 -9
  56. package/src/Listbox/ListboxGroup.jsx +2 -1
  57. package/src/Listbox/ListboxOverlay.jsx +7 -2
  58. package/src/Listbox/PressableItem.jsx +8 -4
  59. package/src/PriceLockup/utils/renderPrice.jsx +15 -17
  60. package/src/TextInput/TextInputBase.jsx +5 -1
  61. package/src/ThemeProvider/index.js +1 -0
  62. package/src/ThemeProvider/useResponsiveThemeTokensCallback.js +129 -0
  63. package/src/Validator/Validator.jsx +180 -159
  64. package/src/index.js +2 -1
@@ -24,12 +24,12 @@ const Validator = React.forwardRef(
24
24
 
25
25
  const { supportsProps } = selectProps(rest)
26
26
  const strValidation = supportsProps.validation
27
- const [, setIndividualCodes] = React.useState({})
28
- const [text, setText] = React.useState(value)
29
27
  const validatorsLength = 6
30
28
  const prefix = 'code'
31
29
  const sufixValidation = 'Validation'
32
30
 
31
+ const [codes, setCodes] = React.useState(() => Array(validatorsLength).fill(''))
32
+
33
33
  const [isHover, setIsHover] = React.useState(false)
34
34
  const handleMouseOver = () => {
35
35
  setIsHover(true)
@@ -41,110 +41,99 @@ const Validator = React.forwardRef(
41
41
 
42
42
  const themeTokens = useThemeTokens('TextInput', tokens, variant, { hover: isHover })
43
43
 
44
- const [codeReferences, singleCodes] = React.useMemo(() => {
45
- const codes = []
46
- const valueCodes = {}
47
- Array.from({ length: validatorsLength }, (_, i) => {
48
- codes[prefix + i] = React.createRef()
49
- valueCodes[prefix + i] = ''
50
- valueCodes[prefix + i + sufixValidation] = ''
51
- return null
52
- })
53
- return [codes, valueCodes]
54
- }, [validatorsLength, prefix, sufixValidation])
55
-
56
- const handleSingleCodes = React.useCallback(
57
- (codeId, val, validation) => {
58
- singleCodes[codeId] = val
59
- singleCodes[codeId + sufixValidation] = validation
60
- setIndividualCodes((prev) => ({
61
- ...prev,
62
- [codeId]: val
63
- }))
64
- },
65
- [singleCodes, sufixValidation]
66
- )
44
+ // Create refs for input elements
45
+ const codeReferences = React.useMemo(() => {
46
+ return Array.from({ length: validatorsLength }, () => React.createRef())
47
+ }, [validatorsLength])
67
48
 
68
- const changeDataMasking = React.useCallback(
69
- (boxElement) => {
70
- let charMasking = ''
71
- const element = boxElement
72
- if (mask && mask.length === 1) {
73
- charMasking = mask
74
- } else if (mask && mask.length > 1) {
75
- charMasking = mask.substring(0, 1)
76
- }
49
+ // Keep onChange and mask in refs to avoid re-creating event listeners
50
+ const onChangeRef = React.useRef(onChange)
51
+ const maskRef = React.useRef(mask)
52
+ React.useEffect(() => {
53
+ onChangeRef.current = onChange
54
+ maskRef.current = mask
55
+ }, [onChange, mask])
56
+
57
+ // Update a single code digit
58
+ const updateCode = React.useCallback(
59
+ (index, digit) => {
60
+ setCodes((prevCodes) => {
61
+ const newCodes = [...prevCodes]
62
+ newCodes[index] = digit
63
+
64
+ if (onChangeRef.current) {
65
+ const codeString = newCodes.join('')
66
+ const singleCodesObj = {}
67
+ newCodes.forEach((code, i) => {
68
+ singleCodesObj[prefix + i] = code
69
+ singleCodesObj[prefix + i + sufixValidation] = code ? 'success' : ''
70
+ })
71
+ onChangeRef.current(codeString, singleCodesObj)
72
+ }
77
73
 
78
- if (charMasking && element) {
79
- element.value = charMasking
80
- }
74
+ return newCodes
75
+ })
81
76
  },
82
- [mask]
77
+ [prefix, sufixValidation]
83
78
  )
84
79
 
85
- const handleChangeCode = React.useCallback(() => {
86
- const code = Array.from(
87
- { length: validatorsLength },
88
- (_, i) => singleCodes[prefix + i] || ''
89
- ).join('')
90
- if (typeof onChange === 'function') {
91
- onChange(code, singleCodes)
92
- }
93
- }, [validatorsLength, singleCodes, prefix, onChange])
94
-
95
- const handleChangeCodeValues = React.useCallback(
96
- (event, codeId, nextIndex) => {
97
- const codeElement = codeReferences[codeId]?.current
80
+ // Handle input change
81
+ const handleInputChange = React.useCallback(
82
+ (index, event) => {
98
83
  const val = event.nativeEvent?.value || event.target?.value
99
84
 
100
- // Only allow numeric characters and limit to single digit
101
- const numericOnly = val.replace(/\D/g, '').substring(0, 1)
102
-
103
- if (codeElement && codeElement.value) {
104
- codeElement.value = numericOnly
85
+ // This prevents the infinite loop where setting element.value triggers another input event
86
+ if (maskRef.current && val === maskRef.current.substring(0, 1)) {
87
+ return
105
88
  }
106
89
 
107
- handleSingleCodes(codeId, numericOnly, numericOnly ? 'success' : '')
108
- handleChangeCode()
90
+ const numericOnly = val.replace(/\D/g, '').substring(0, 1)
91
+
92
+ // Update state
93
+ updateCode(index, numericOnly)
109
94
 
110
- if (nextIndex === validatorsLength) {
111
- codeElement?.blur()
112
- changeDataMasking(codeElement)
113
- return
95
+ // Update DOM element
96
+ const element = codeReferences[index]?.current
97
+ if (element) {
98
+ if (maskRef.current && numericOnly) {
99
+ element.value = maskRef.current.substring(0, 1)
100
+ } else {
101
+ element.value = numericOnly
102
+ }
114
103
  }
115
104
 
116
- if (numericOnly.length > 0) {
117
- const nextElement = codeReferences[prefix + nextIndex]?.current
105
+ // Move to next field if digit entered
106
+ if (numericOnly && index < validatorsLength - 1) {
107
+ const nextElement = codeReferences[index + 1]?.current
118
108
  nextElement?.focus()
119
- changeDataMasking(codeElement)
109
+ } else if (index === validatorsLength - 1) {
110
+ element?.blur()
120
111
  }
121
112
  },
122
- [
123
- codeReferences,
124
- handleSingleCodes,
125
- handleChangeCode,
126
- validatorsLength,
127
- changeDataMasking,
128
- prefix
129
- ]
113
+ [codeReferences, updateCode, validatorsLength]
130
114
  )
131
115
 
132
- const handleKeyPress = (event, currentIndex, previousIndex) => {
133
- if (!(event.keyCode === 8 || event.code === 'Backspace')) {
134
- return
135
- }
136
- if (currentIndex > 0) {
137
- const currentElement = codeReferences[prefix + currentIndex]?.current
138
- const previousElement = codeReferences[prefix + previousIndex]?.current
116
+ // Handle backspace
117
+ const handleKeyPress = React.useCallback(
118
+ (index, event) => {
119
+ if (!(event.keyCode === 8 || event.code === 'Backspace')) {
120
+ return
121
+ }
122
+
123
+ const currentElement = codeReferences[index]?.current
139
124
 
140
- if (currentElement && currentElement.value) {
125
+ if (currentElement) {
141
126
  currentElement.value = ''
142
127
  }
143
- previousElement?.focus()
144
- }
145
- handleSingleCodes(prefix + currentIndex, '', '')
146
- handleChangeCode()
147
- }
128
+ updateCode(index, '')
129
+
130
+ if (index > 0) {
131
+ const previousElement = codeReferences[index - 1]?.current
132
+ previousElement?.focus()
133
+ }
134
+ },
135
+ [codeReferences, updateCode]
136
+ )
148
137
 
149
138
  const getCodeComponents = () => {
150
139
  return Array.from({ length: validatorsLength }, (_, i) => {
@@ -152,11 +141,14 @@ const Validator = React.forwardRef(
152
141
  const codeInputProps = {
153
142
  nativeID: codeId,
154
143
  keyboardType: 'numeric',
155
- ref: codeReferences[codeId] ?? null,
156
- validation: strValidation || singleCodes[codeId + sufixValidation],
144
+ ref: codeReferences[i] ?? null,
145
+ validation: strValidation || (codes[i] ? 'success' : ''),
157
146
  tokens: selectCodeTextInputTokens(themeTokens),
147
+ // Only use secureTextEntry in React Native, web handles mask differently
148
+ secureTextEntry: !!(Platform.OS !== 'web' && mask),
149
+ selectTextOnFocus: Platform.OS !== 'web',
158
150
  onFocus: () => {
159
- const element = codeReferences[codeId]?.current
151
+ const element = codeReferences[i]?.current
160
152
  if (Platform.OS === 'web' && element?.select) {
161
153
  return element.select() ?? null
162
154
  }
@@ -165,11 +157,39 @@ const Validator = React.forwardRef(
165
157
  }
166
158
  return null
167
159
  },
168
- onKeyPress: (event) => handleKeyPress(event, i, i - 1),
160
+ onKeyPress: (event) => handleKeyPress(i, event),
169
161
  onMouseOver: handleMouseOver,
170
162
  onMouseOut: handleMouseOut,
171
163
  inactive
172
164
  }
165
+
166
+ // For React Native, use onChangeText and maxLength
167
+ if (Platform.OS !== 'web') {
168
+ codeInputProps.maxLength = 1
169
+ codeInputProps.value = codes[i]
170
+ codeInputProps.onChange = () => {}
171
+
172
+ codeInputProps.onChangeText = (text) => {
173
+ if (text) {
174
+ updateCode(i, text)
175
+
176
+ if (i < validatorsLength - 1) {
177
+ setTimeout(() => {
178
+ codeReferences[i + 1]?.current?.focus()
179
+ }, 50)
180
+ }
181
+ } else {
182
+ updateCode(i, '')
183
+
184
+ if (i > 0) {
185
+ setTimeout(() => {
186
+ codeReferences[i - 1]?.current?.focus()
187
+ }, 50)
188
+ }
189
+ }
190
+ }
191
+ }
192
+
173
193
  if (!codeInputProps.validation) {
174
194
  delete codeInputProps.validation
175
195
  }
@@ -182,100 +202,101 @@ const Validator = React.forwardRef(
182
202
  })
183
203
  }
184
204
 
205
+ // Sync external value prop to internal state
185
206
  React.useEffect(() => {
186
- if (Number(value).toString() !== 'NaN') {
187
- setText(value)
207
+ if (value && Number(value).toString() !== 'NaN') {
208
+ const digits = value.split('').slice(0, validatorsLength)
209
+ const newCodes = Array(validatorsLength).fill('')
210
+ digits.forEach((digit, i) => {
211
+ if (/\d/.test(digit)) {
212
+ newCodes[i] = digit
213
+ }
214
+ })
215
+ setCodes(newCodes)
188
216
  }
189
- }, [value])
217
+ }, [value, validatorsLength])
190
218
 
219
+ // Sync codes state to DOM elements
191
220
  React.useEffect(() => {
192
- Array.from({ length: validatorsLength }, (_, i) => {
193
- const element = codeReferences[prefix + i]?.current
221
+ codes.forEach((code, i) => {
222
+ const element = codeReferences[i]?.current
194
223
  if (element && element.value !== undefined) {
195
- if (mask && text[i]) {
196
- element.value = mask
224
+ if (mask && code) {
225
+ element.value = mask.substring(0, 1)
197
226
  } else {
198
- element.value = text[i] ?? ''
227
+ element.value = code
199
228
  }
200
229
  }
201
- handleSingleCodes(prefix + i, text[i] ?? '', text[i] ? 'success' : '')
202
- return null
203
230
  })
204
- }, [text, mask, validatorsLength, prefix, codeReferences, handleSingleCodes])
231
+ }, [codes, codeReferences, mask])
205
232
 
233
+ // Setup event listeners - only runs once on mount
206
234
  React.useEffect(() => {
207
- const handlePasteCode = (event) => {
208
- event.preventDefault()
209
-
210
- // Clear current state first
211
- setText('')
212
-
213
- // Clear all individual input fields and their state
214
- Array.from({ length: validatorsLength }, (_, i) => {
215
- const element = codeReferences[prefix + i]?.current
216
- if (element && element.value !== undefined) {
217
- element.value = ''
218
- }
219
- handleSingleCodes(prefix + i, '', '')
220
- return null
221
- })
235
+ if (Platform.OS !== 'web') {
236
+ return undefined
237
+ }
222
238
 
239
+ const handlePaste = (event) => {
240
+ event.preventDefault()
223
241
  const clipBoardText = event.clipboardData.getData('text')
224
-
225
- // Validate that input contains only digits and truncate to 6 characters
226
242
  const numericOnly = clipBoardText.replace(/\D/g, '').substring(0, validatorsLength)
227
243
 
228
- if (numericOnly.length > 0) {
229
- setText(numericOnly)
244
+ const newCodes = Array(validatorsLength).fill('')
245
+ numericOnly.split('').forEach((digit, i) => {
246
+ newCodes[i] = digit
247
+ })
248
+ setCodes(newCodes)
249
+
250
+ if (onChangeRef.current) {
251
+ const singleCodesObj = {}
252
+ newCodes.forEach((code, i) => {
253
+ singleCodesObj[prefix + i] = code
254
+ singleCodesObj[prefix + i + sufixValidation] = code ? 'success' : ''
255
+ })
256
+ onChangeRef.current(numericOnly, singleCodesObj)
230
257
  }
231
258
  }
232
259
 
233
260
  const handleCopy = (event) => {
234
- const clipBoardText = Array.from(
235
- { length: validatorsLength },
236
- (_, i) => singleCodes[prefix + i] || ''
237
- ).join('')
238
- event.clipboardData.setData('text/plain', clipBoardText)
239
- event.preventDefault()
261
+ setCodes((currentCodes) => {
262
+ const clipBoardText = currentCodes.join('')
263
+ event.clipboardData.setData('text/plain', clipBoardText)
264
+ event.preventDefault()
265
+ return currentCodes
266
+ })
240
267
  }
241
268
 
242
- if (Platform.OS === 'web') {
243
- Array.from({ length: validatorsLength }, (_, i) => {
244
- const element = codeReferences[prefix + i]?.current
245
- if (element && typeof element.addEventListener === 'function') {
246
- element.addEventListener('paste', handlePasteCode)
247
- element.addEventListener('copy', handleCopy)
248
- element.addEventListener('input', (event) =>
249
- handleChangeCodeValues(event, prefix + i, i + 1)
269
+ // Add event listeners to each input
270
+ codeReferences.forEach((inputRef, i) => {
271
+ const element = inputRef?.current
272
+ if (element && typeof element.addEventListener === 'function') {
273
+ element.addEventListener('paste', handlePaste)
274
+ element.addEventListener('copy', handleCopy)
275
+ element.addEventListener('input', (event) => handleInputChange(i, event))
276
+ }
277
+ })
278
+
279
+ // Cleanup
280
+ return () => {
281
+ codeReferences.forEach((inputRef) => {
282
+ const element = inputRef?.current
283
+ if (element && typeof element.removeEventListener === 'function') {
284
+ element.removeEventListener('paste', handlePaste)
285
+ element.removeEventListener('copy', handleCopy)
286
+ element.removeEventListener('input', (event) =>
287
+ handleInputChange(event.target.dataset.index, event)
250
288
  )
251
289
  }
252
- return null
253
290
  })
254
291
  }
255
-
256
- return () => {
257
- if (Platform.OS === 'web') {
258
- Array.from({ length: validatorsLength }, (_, i) => {
259
- const element = codeReferences[prefix + i]?.current
260
- if (element && typeof element.removeEventListener === 'function') {
261
- element.removeEventListener('paste', handlePasteCode)
262
- element.removeEventListener('copy', handleCopy)
263
- element.removeEventListener('input', (event) =>
264
- handleChangeCodeValues(event, prefix + i, i + 1)
265
- )
266
- }
267
- return null
268
- })
269
- }
270
- }
271
- }, [
272
- validatorsLength,
273
- prefix,
274
- codeReferences,
275
- handleChangeCodeValues,
276
- handleSingleCodes,
277
- singleCodes
278
- ])
292
+ /*
293
+ * codeReferences and handleInputChange are intentionally omitted from dependencies
294
+ * because we want event listeners to be registered ONLY ONCE when the component mounts.
295
+ * codeReferences is stable (created with useMemo) and handleInputChange is stable (useCallback).
296
+ * Including them would cause unnecessary re-registration of listeners on each render.
297
+ */
298
+ // eslint-disable-next-line react-hooks/exhaustive-deps
299
+ }, [])
279
300
 
280
301
  return (
281
302
  <InputSupports
package/src/index.js CHANGED
@@ -83,7 +83,8 @@ export {
83
83
  applyOuterBorder,
84
84
  applyTextStyles,
85
85
  applyShadowToken,
86
- useResponsiveThemeTokens
86
+ useResponsiveThemeTokens,
87
+ useResponsiveThemeTokensCallback
87
88
  } from './ThemeProvider'
88
89
 
89
90
  export * from './utils'