@telus-uds/components-base 3.28.0 → 3.28.2

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.
@@ -24,6 +24,7 @@ import Suggestions from './Suggestions'
24
24
  import {
25
25
  DEFAULT_MAX_SUGGESTIONS,
26
26
  DEFAULT_MIN_TO_SUGGESTION,
27
+ DEFAULT_MAX_DROPDOWN_HEIGHT,
27
28
  INPUT_LEFT_PADDING,
28
29
  MIN_LISTBOX_WIDTH
29
30
  } from './constants'
@@ -51,20 +52,44 @@ const [selectProps, selectedSystemPropTypes] = selectSystemProps([
51
52
  const highlightAllMatches = (str, substring = '', matchIndexes = [], resultsTextColor) => (
52
53
  // Wrapping all in bold
53
54
  <Typography variant={{ bold: false }} tokens={{ color: resultsTextColor }}>
54
- {matchIndexes.reduce(
55
- (acc, matchIndex, index) => [
56
- ...acc,
57
- // Add a piece of the string up to the first occurrence of the substring
58
- index === 0 && (str.slice(0, matchIndex) ?? ''),
59
- // Unbold the occurrence of the substring (while keeping the original casing)
60
- <Typography key={matchIndex} variant={{ bold: true }} tokens={{ color: resultsTextColor }}>
61
- {str.slice(matchIndex, matchIndex + substring.length)}
62
- </Typography>,
63
- // Add the rest of the string until the next occurrence or the end of it
64
- str.slice(matchIndex + substring.length, matchIndexes[index + 1] ?? str.length)
65
- ],
66
- []
67
- )}
55
+ {matchIndexes
56
+ .reduce((acc, matchIndex, index) => {
57
+ const prefix = index === 0 ? str.slice(0, matchIndex) : null
58
+ const match = str.slice(matchIndex, matchIndex + substring.length)
59
+ const suffix = str.slice(
60
+ matchIndex + substring.length,
61
+ matchIndexes[index + 1] ?? str.length
62
+ )
63
+ return [
64
+ ...acc,
65
+ prefix ? (
66
+ <Typography
67
+ key={`pre-${matchIndex}`}
68
+ variant={{ bold: false }}
69
+ tokens={{ color: resultsTextColor }}
70
+ >
71
+ {prefix}
72
+ </Typography>
73
+ ) : null,
74
+ <Typography
75
+ key={matchIndex}
76
+ variant={{ bold: true }}
77
+ tokens={{ color: resultsTextColor }}
78
+ >
79
+ {match}
80
+ </Typography>,
81
+ suffix ? (
82
+ <Typography
83
+ key={`post-${matchIndex}`}
84
+ variant={{ bold: false }}
85
+ tokens={{ color: resultsTextColor }}
86
+ >
87
+ {suffix}
88
+ </Typography>
89
+ ) : null
90
+ ]
91
+ }, [])
92
+ .filter(Boolean)}
68
93
  </Typography>
69
94
  )
70
95
  const highlight = (items = [], text = '', color) =>
@@ -94,12 +119,14 @@ const Autocomplete = React.forwardRef(
94
119
  isLoading = false,
95
120
  items,
96
121
  maxSuggestions = DEFAULT_MAX_SUGGESTIONS,
122
+ maxDropdownHeight = DEFAULT_MAX_DROPDOWN_HEIGHT,
97
123
  minToSuggestion = DEFAULT_MIN_TO_SUGGESTION,
98
124
  noResults,
99
125
  onChange,
100
126
  onClear,
101
127
  onSelect,
102
128
  readOnly,
129
+ showOptionsOnFocus = false,
103
130
  validation,
104
131
  value,
105
132
  helpText = '',
@@ -144,7 +171,7 @@ const Autocomplete = React.forwardRef(
144
171
 
145
172
  const { supportsProps, ...selectedProps } = selectProps(rest)
146
173
  const { hint, label: inputLabel } = supportsProps
147
- const hintExpansionEnabled = isFocused && helpText && !currentValue
174
+ const hintExpansionEnabled = isFocused && !!helpText && !currentValue
148
175
  const {
149
176
  overlaidPosition,
150
177
  sourceRef: inputRef,
@@ -198,9 +225,12 @@ const Autocomplete = React.forwardRef(
198
225
  }
199
226
 
200
227
  const handleChange = (newValue) => {
201
- onChange?.(newValue || '')
228
+ onChange?.(newValue)
202
229
  setCurrentValue(newValue)
203
- setIsExpanded(newValue?.length >= minToSuggestion)
230
+ const shouldExpand =
231
+ newValue?.length >= minToSuggestion ||
232
+ (showOptionsOnFocus && isFocused && newValue?.length === 0)
233
+ setIsExpanded(shouldExpand)
204
234
  if (!isControlled && initialItems !== undefined) {
205
235
  setCurrentItems(
206
236
  initialItems.filter(({ label }) =>
@@ -211,21 +241,24 @@ const Autocomplete = React.forwardRef(
211
241
  }
212
242
  const handleSelect = (selectedId) => {
213
243
  onSelect?.(selectedId)
214
- const {
215
- label: newValue,
216
- nested,
217
- title
218
- } = (isControlled ? items : currentItems)?.find(({ id }) => id === selectedId)
244
+ const selectedItem = (isControlled ? items : currentItems)?.find(
245
+ ({ id }) => id === selectedId
246
+ )
247
+ const { label, nested, title } = selectedItem
248
+
219
249
  if (title) return
220
250
  if (!nested) {
221
251
  setNestedSelectedValue(null)
222
- onChange?.(newValue)
252
+ onChange?.(label)
223
253
  setIsExpanded(false)
254
+ setCurrentValue(label)
224
255
  }
225
- setCurrentValue(newValue)
226
- if (!isControlled && inputRef?.current) inputRef.current.value = newValue
256
+ if (!isControlled && inputRef?.current) inputRef.current.value = label
227
257
 
228
- if (nested) setNestedSelectedValue(newValue)
258
+ if (nested) {
259
+ setNestedSelectedValue(label)
260
+ setCurrentValue(label)
261
+ }
229
262
 
230
263
  inputRef.current.focus()
231
264
  }
@@ -278,17 +311,13 @@ const Autocomplete = React.forwardRef(
278
311
  }, [inputRef])
279
312
 
280
313
  const handleClose = (event) => {
281
- if (
282
- (event.type === 'keydown' && (event.key === 'Escape' || event.key === '27')) ||
283
- (event.type === 'click' && !openOverlayRef?.current?.contains(event.target)) ||
284
- (event.type === 'touchstart' &&
285
- openOverlayRef?.current &&
286
- event.touches[0].target &&
287
- !openOverlayRef?.current?.contains(event.touches[0].target))
288
- ) {
314
+ if (event.type === 'keydown' && (event.key === 'Escape' || event.key === '27')) {
289
315
  setIsExpanded(false)
290
316
  setNestedSelectedValue(null)
291
- } else if (
317
+ return
318
+ }
319
+
320
+ if (
292
321
  event.type === 'keydown' &&
293
322
  (event.key === 'ArrowDown' || event.key === 'Tab') &&
294
323
  isExpanded &&
@@ -297,13 +326,30 @@ const Autocomplete = React.forwardRef(
297
326
  ) {
298
327
  event.preventDefault()
299
328
  targetRef.current.focus()
329
+ return
330
+ }
331
+
332
+ if (event.type === 'click' || event.type === 'touchstart') {
333
+ const clickTarget = event.type === 'click' ? event.target : event.touches[0]?.target
334
+ const isOutsideOverlay =
335
+ openOverlayRef?.current && !openOverlayRef.current.contains(clickTarget)
336
+ const isOutsideInput = inputRef?.current && !inputRef.current.contains(clickTarget)
337
+
338
+ if (isOutsideOverlay && isOutsideInput) {
339
+ setIsExpanded(false)
340
+ setNestedSelectedValue(null)
341
+ }
300
342
  }
301
343
  }
302
- const itemsToShow = currentValue
303
- ? itemsToSuggest(
304
- highlight(isControlled ? items : currentItems, currentValue, resultsTextColor)
305
- )
306
- : []
344
+ // Calculate items to show based on current state
345
+ let itemsToShow = []
346
+ if (currentValue?.length > 0) {
347
+ itemsToShow = itemsToSuggest(
348
+ highlight(isControlled ? items : currentItems, currentValue, resultsTextColor)
349
+ )
350
+ } else if (showOptionsOnFocus && isFocused) {
351
+ itemsToShow = itemsToSuggest(isControlled ? items : currentItems || initialItems)
352
+ }
307
353
  const helpTextToShow = isFocused && !currentValue ? helpText : noResults ?? getCopy('noResults')
308
354
 
309
355
  return (
@@ -337,9 +383,15 @@ const Autocomplete = React.forwardRef(
337
383
  onChange={handleChange}
338
384
  onFocus={() => {
339
385
  setisFocused(true)
386
+ if (showOptionsOnFocus && (!currentValue || currentValue.length === 0)) {
387
+ setIsExpanded(true)
388
+ }
340
389
  }}
341
390
  onBlur={() => {
342
391
  setisFocused(false)
392
+ if (showOptionsOnFocus && (!currentValue || currentValue.length === 0)) {
393
+ setIsExpanded(false)
394
+ }
343
395
  }}
344
396
  onClear={onClear}
345
397
  onKeyPress={handleClose}
@@ -368,45 +420,50 @@ const Autocomplete = React.forwardRef(
368
420
  )
369
421
  }}
370
422
  </InputSupports>
371
- {(isExpanded || hintExpansionEnabled) && isInputVisible && (
372
- <>
373
- <Listbox.Overlay
374
- overlaidPosition={overlaidPosition}
375
- isReady={isReady}
376
- minWidth={fullWidth ? inputWidth : MIN_LISTBOX_WIDTH}
377
- maxWidth={inputWidth}
378
- onLayout={handleMeasure}
379
- tokens={restOfTokens}
380
- ref={openOverlayRef}
381
- >
382
- {isLoading ? (
383
- <Loading label={loadingLabel ?? getCopy('loading')} />
384
- ) : (
385
- <Suggestions
386
- hasResults={getCopy('hasResults')}
387
- id="autocomplete"
388
- items={
389
- nestedSelectedValue
390
- ? itemsToSuggest(highlight(otherItems, nestedSelectedValue, resultsTextColor))
391
- : itemsToShow
392
- }
393
- noResults={helpTextToShow}
394
- onClose={handleClose}
395
- onSelect={handleSelect}
396
- parentRef={inputRef}
397
- ref={targetRef}
423
+ {(isExpanded || hintExpansionEnabled) &&
424
+ isInputVisible &&
425
+ (itemsToShow.length > 0 || isExpanded || hintExpansionEnabled) && (
426
+ <>
427
+ <Listbox.Overlay
428
+ overlaidPosition={overlaidPosition}
429
+ isReady={isReady}
430
+ minWidth={fullWidth ? inputWidth : MIN_LISTBOX_WIDTH}
431
+ maxWidth={inputWidth}
432
+ maxHeight={maxDropdownHeight}
433
+ onLayout={handleMeasure}
434
+ tokens={restOfTokens}
435
+ ref={openOverlayRef}
436
+ >
437
+ {isLoading ? (
438
+ <Loading label={loadingLabel ?? getCopy('loading')} />
439
+ ) : (
440
+ <Suggestions
441
+ hasResults={getCopy('hasResults')}
442
+ id="autocomplete"
443
+ items={
444
+ nestedSelectedValue
445
+ ? itemsToSuggest(
446
+ highlight(otherItems, nestedSelectedValue, resultsTextColor)
447
+ )
448
+ : itemsToShow
449
+ }
450
+ noResults={helpTextToShow}
451
+ onClose={handleClose}
452
+ onSelect={handleSelect}
453
+ parentRef={inputRef}
454
+ ref={targetRef}
455
+ />
456
+ )}
457
+ </Listbox.Overlay>
458
+ {targetRef?.current && (
459
+ <View
460
+ // This catches and shifts focus to other interactive elements.
461
+ onFocus={() => targetRef?.current?.focus()}
462
+ tabIndex={0}
398
463
  />
399
464
  )}
400
- </Listbox.Overlay>
401
- {targetRef?.current && (
402
- <View
403
- // This catches and shifts focus to other interactive elements.
404
- onFocus={() => targetRef?.current?.focus()}
405
- tabIndex={0}
406
- />
407
- )}
408
- </>
409
- )}
465
+ </>
466
+ )}
410
467
  </View>
411
468
  )
412
469
  }
@@ -468,6 +525,10 @@ Autocomplete.propTypes = {
468
525
  * Maximum number of suggestions provided at the same time
469
526
  */
470
527
  maxSuggestions: PropTypes.number,
528
+ /**
529
+ * Maximum height (in pixels) of the dropdown before scrolling is enabled
530
+ */
531
+ maxDropdownHeight: PropTypes.number,
471
532
  /**
472
533
  * Text or JSX to render when no results are available
473
534
  */
@@ -488,6 +549,10 @@ Autocomplete.propTypes = {
488
549
  * Callback function to be called when an item is selected from the list
489
550
  */
490
551
  onSelect: PropTypes.func,
552
+ /**
553
+ * When true, displays all available options when the input receives focus (even without typing)
554
+ */
555
+ showOptionsOnFocus: PropTypes.bool,
491
556
  /**
492
557
  * Input value for controlled usage
493
558
  */
@@ -1,4 +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
4
  export const INPUT_LEFT_PADDING = 16
4
5
  export const MIN_LISTBOX_WIDTH = 288
@@ -125,7 +125,7 @@ const selectControlButtonPositionStyles = ({
125
125
  isAutoPlayEnabled,
126
126
  viewport,
127
127
  maxWidth,
128
- viewportWidth
128
+ containerWidth
129
129
  }) => {
130
130
  const styles = {}
131
131
 
@@ -148,13 +148,11 @@ const selectControlButtonPositionStyles = ({
148
148
  }
149
149
 
150
150
  if (enablePeeking) {
151
- if (positionProperty === POSITION_PROPERTIES.RIGHT) {
152
- const rightMargin = (viewportWidth - maxWidth) / 2
153
- positionOffset += rightMargin
154
- } else if (positionProperty === POSITION_PROPERTIES.LEFT) {
155
- const leftMargin = (viewportWidth - maxWidth) / 2
156
- positionOffset += leftMargin
157
- }
151
+ const { peekingMiddleSpace, peekingGap } = getPeekingProps(viewport)
152
+ const clampedMaxWidth = Math.min(maxWidth || containerWidth, containerWidth)
153
+ const slideRightEdge = (containerWidth + clampedMaxWidth) / 2 - peekingMiddleSpace
154
+ const buttonCenter = slideRightEdge + peekingGap / 2
155
+ positionOffset = containerWidth - buttonCenter - buttonWidth / 2
158
156
  }
159
157
 
160
158
  styles[positionProperty] = positionOffset
@@ -173,7 +171,7 @@ const selectPreviousNextNavigationButtonStyles = (
173
171
  isAutoPlayEnabled,
174
172
  viewport,
175
173
  maxWidth,
176
- viewportWidth
174
+ containerWidth
177
175
  ) => {
178
176
  const styles = {
179
177
  zIndex: 1,
@@ -199,7 +197,7 @@ const selectPreviousNextNavigationButtonStyles = (
199
197
  isAutoPlayEnabled,
200
198
  viewport,
201
199
  maxWidth,
202
- viewportWidth
200
+ containerWidth
203
201
  })
204
202
  }
205
203
  }
@@ -343,7 +341,7 @@ const calculateFinalWidth = (containerWidth, enablePeeking, viewport, maxWidth)
343
341
 
344
342
  if (enablePeeking) {
345
343
  const { peekingGap, peekingMiddleSpace } = getPeekingProps(viewport)
346
- const baseWidth = maxWidth || containerWidth
344
+ const baseWidth = Math.min(maxWidth || containerWidth, containerWidth)
347
345
  finalWidth = baseWidth - peekingMiddleSpace * PEEKING_MULTIPLIER + peekingGap
348
346
  }
349
347
 
@@ -572,7 +570,10 @@ const Carousel = React.forwardRef(
572
570
  const { peekingGap, peekingMiddleSpace } = getPeekingProps(viewport)
573
571
 
574
572
  let finalWidth
575
- const baseWidth = maxWidth || containerLayoutRef.current.width
573
+ const baseWidth = Math.min(
574
+ maxWidth || containerLayoutRef.current.width,
575
+ containerLayoutRef.current.width
576
+ )
576
577
  const slideWide = baseWidth - peekingMiddleSpace * PEEKING_MULTIPLIER
577
578
 
578
579
  if (activeIndexRef.current === 0) {
@@ -1055,8 +1056,7 @@ const Carousel = React.forwardRef(
1055
1056
  // Related discussion - https://github.com/telus/universal-design-system/issues/1549
1056
1057
  const previousNextIconButtonVariants = {
1057
1058
  size: previousNextIconSize,
1058
- raised: !variant?.inverse && true,
1059
- inverse: variant?.inverse
1059
+ raised: true
1060
1060
  }
1061
1061
 
1062
1062
  const getCopyWithPlaceholders = React.useCallback(
@@ -1177,7 +1177,7 @@ const Carousel = React.forwardRef(
1177
1177
  isAutoPlayEnabled,
1178
1178
  viewport,
1179
1179
  maxWidth,
1180
- viewportWidth: currentViewportWidth
1180
+ containerWidth: containerLayout.width
1181
1181
  })
1182
1182
  ]}
1183
1183
  >
@@ -1205,7 +1205,7 @@ const Carousel = React.forwardRef(
1205
1205
  isAutoPlayEnabled,
1206
1206
  viewport,
1207
1207
  maxWidth,
1208
- currentViewportWidth
1208
+ containerLayout.width
1209
1209
  )}
1210
1210
  testID="previous-button-container"
1211
1211
  >
@@ -1303,7 +1303,7 @@ const Carousel = React.forwardRef(
1303
1303
  isAutoPlayEnabled,
1304
1304
  viewport,
1305
1305
  maxWidth,
1306
- currentViewportWidth
1306
+ containerLayout.width
1307
1307
  )}
1308
1308
  testID="next-button-container"
1309
1309
  >
@@ -31,10 +31,10 @@ const selectContainerStyle = ({
31
31
 
32
32
  if (enablePeeking) {
33
33
  const isFirst = elementIndex === 0
34
- const baseWidth = maxWidth || width
35
- adjustedWidth = baseWidth - peekingMiddleSpace * 2
34
+ const clampedMaxWidth = Math.min(maxWidth || width, width)
35
+ adjustedWidth = clampedMaxWidth - peekingMiddleSpace * 2
36
36
  if (isFirst) {
37
- marginLeft = peekingMiddleSpace + (viewportWidth - maxWidth) / 2
37
+ marginLeft = peekingMiddleSpace + (viewportWidth - clampedMaxWidth) / 2
38
38
  } else {
39
39
  marginLeft = peekingGap
40
40
  }
@@ -28,6 +28,7 @@ const DropdownOverlay = React.forwardRef(
28
28
  isReady = false,
29
29
  overlaidPosition,
30
30
  maxWidth,
31
+ maxHeight,
31
32
  minWidth,
32
33
  onLayout,
33
34
  tokens,
@@ -50,12 +51,18 @@ const DropdownOverlay = React.forwardRef(
50
51
  staticStyles.positioner,
51
52
  !isReady && staticStyles.hidden
52
53
  ]}
54
+ onMouseDown={(e) => {
55
+ e.preventDefault()
56
+ }}
53
57
  >
54
58
  <Card
55
59
  tokens={{
56
60
  shadow: systemTokens.shadow,
57
61
  borderRadius: systemTokens.borderRadius,
58
- ...(Platform.OS === 'web' && { overflowY: 'hidden' }),
62
+ ...(Platform.OS === 'web' && {
63
+ maxHeight,
64
+ overflowY: 'auto'
65
+ }),
59
66
  paddingBottom: paddingVertical,
60
67
  paddingTop: paddingVertical,
61
68
  paddingLeft: paddingHorizontal,
@@ -89,6 +96,7 @@ DropdownOverlay.propTypes = {
89
96
  width: PropTypes.number
90
97
  }),
91
98
  maxWidth: PropTypes.number,
99
+ maxHeight: PropTypes.number,
92
100
  minWidth: PropTypes.number,
93
101
  onLayout: PropTypes.func,
94
102
  tokens: PropTypes.object,
@@ -120,7 +120,7 @@ const PressableItem = React.forwardRef(
120
120
  >
121
121
  {(pressableState) => {
122
122
  return (
123
- <Text style={selectTextStyles(resolveButtonTokens(pressableState))}>{children} </Text>
123
+ <Text style={selectTextStyles(resolveButtonTokens(pressableState))}>{children}</Text>
124
124
  )
125
125
  }}
126
126
  </Pressable>
@@ -185,6 +185,12 @@ const MultiSelectFilter = React.forwardRef(
185
185
 
186
186
  React.useEffect(() => setCheckedIds(currentValues ?? []), [currentValues])
187
187
 
188
+ React.useEffect(() => {
189
+ if (isOpen && onOpen) {
190
+ onOpen()
191
+ }
192
+ }, [isOpen, onOpen])
193
+
188
194
  const uniqueFields = ['id', 'label']
189
195
  if (!containUniqueFields(items, uniqueFields)) {
190
196
  throw new Error(`MultiSelectFilter items must have unique ${uniqueFields.join(', ')}`)
@@ -194,7 +200,6 @@ const MultiSelectFilter = React.forwardRef(
194
200
 
195
201
  const handleChange = (event) => {
196
202
  if (pressHandlers.onPress) pressHandlers?.onPress(event)
197
- if (isOpen) onOpen()
198
203
  setIsOpen(!isOpen)
199
204
  }
200
205
 
@@ -255,8 +255,8 @@ const TextInputBase = React.forwardRef(
255
255
  }, [element, pattern])
256
256
 
257
257
  const handleChangeText = (event) => {
258
- const text = event.nativeEvent?.text || event.target?.value
259
- let filteredText = isNumeric ? text?.replace(/[^\d]/g, '') : text
258
+ const text = event.nativeEvent?.text ?? event.target?.value
259
+ let filteredText = isNumeric ? text?.replace(/[^\d]/g, '') || undefined : text
260
260
  if (type === 'card' && filteredText) {
261
261
  const formattedValue = filteredText.replace(/[^a-zA-Z0-9]/g, '')
262
262
  const regex = new RegExp(`([a-zA-Z0-9]{4})(?=[a-zA-Z0-9])`, 'g')