@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,4 @@
1
+ import Scroll from './Scroll';
2
+ export default Scroll;
3
+ export { default as ScrollItem } from './ScrollItem';
4
+ export { ScrollProvider, useScroll } from './ScrollContext';
@@ -272,7 +272,8 @@ const TextInputBase = /*#__PURE__*/React.forwardRef((_ref8, ref) => {
272
272
  }, [element, pattern]);
273
273
  const handleChangeText = event => {
274
274
  const text = event.nativeEvent?.text ?? event.target?.value;
275
- let filteredText = isNumeric ? text?.replace(/[^\d]/g, '') || undefined : text;
275
+ // Do NOT use `|| undefined`: empty string is falsy and would break the controlled contract
276
+ let filteredText = isNumeric ? text?.replace(/[^\d]/g, '') ?? '' : text;
276
277
  if (type === 'card' && filteredText) {
277
278
  const formattedValue = filteredText.replace(/[^a-zA-Z0-9]/g, '');
278
279
  const regex = new RegExp(`([a-zA-Z0-9]{4})(?=[a-zA-Z0-9])`, 'g');
package/lib/esm/index.js CHANGED
@@ -45,6 +45,7 @@ export { default as QuickLinksFeature } from './QuickLinksFeature';
45
45
  export { default as Radio, RadioGroup } from './Radio';
46
46
  export { default as RadioCard, RadioCardGroup } from './RadioCard';
47
47
  export { default as Responsive } from './Responsive';
48
+ export { default as Scroll, ScrollItem } from './Scroll';
48
49
  export { default as Search } from './Search';
49
50
  export { default as Select } from './Select';
50
51
  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,39 @@
1
+ import React from 'react';
2
+ import Animated from "react-native-web/dist/exports/Animated";
3
+ import Easing from "react-native-web/dist/exports/Easing";
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 = function (targetValue) {
15
+ let duration = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ANIMATION_DURATION;
16
+ const [currentValue, setCurrentValue] = React.useState(targetValue);
17
+ const animatedValue = React.useRef(new Animated.Value(targetValue)).current;
18
+ React.useEffect(() => {
19
+ const id = animatedValue.addListener(_ref => {
20
+ let {
21
+ value
22
+ } = _ref;
23
+ return setCurrentValue(value);
24
+ });
25
+ return () => animatedValue.removeListener(id);
26
+ }, [animatedValue]);
27
+ React.useEffect(() => {
28
+ const animation = Animated.timing(animatedValue, {
29
+ toValue: targetValue,
30
+ duration,
31
+ easing: Easing.inOut(Easing.quad),
32
+ useNativeDriver: false
33
+ });
34
+ animation.start();
35
+ return () => animation.stop();
36
+ }, [targetValue, animatedValue, duration]);
37
+ return currentValue;
38
+ };
39
+ export default useAnimatedValue;
@@ -47,7 +47,8 @@ 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
- const isWrapable = child => isStringOrNumber(child) || child.type === A11yText || child.type?.name === 'FootnoteLink';
50
+ // Check displayName first as it is explicitly set and reliable regardless of export style (named vs default)
51
+ const isWrapable = child => isStringOrNumber(child) || child.type === A11yText || child.type?.__UDS_COMPONENT_NAME__ === 'FootnoteLink';
51
52
  const combineKeys = childrenArray => childrenArray.reduce((newKey, child) => `${newKey}${child.key || ''}`, '');
52
53
 
53
54
  // Group wrappable children for one `<Text>` parent, merging adjacent text nodes
@@ -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.20.0",
15
+ "@telus-uds/system-theme-tokens": "^4.21.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.29.1",
87
+ "version": "3.31.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.20.0",
15
+ "@telus-uds/system-theme-tokens": "^4.21.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.29.1",
87
+ "version": "3.31.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
@@ -1403,9 +1403,14 @@ Carousel.propTypes = {
1403
1403
  */
1404
1404
  thumbnails: PropTypes.arrayOf(
1405
1405
  PropTypes.shape({
1406
+ /** Accessibility label for the thumbnail image, used by assistive technologies. */
1406
1407
  accessibilityLabel: PropTypes.string,
1408
+ /** Alternative text for the thumbnail image, displayed when the image cannot be rendered. */
1407
1409
  alt: PropTypes.string,
1408
- src: PropTypes.string
1410
+ /** When true, renders a play icon overlay on the thumbnail to indicate that the slide contains a video. */
1411
+ isVideo: PropTypes.bool,
1412
+ /** URL or path of the thumbnail image. When used with `isVideo`, this should be the video's poster/preview image. */
1413
+ src: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
1409
1414
  })
1410
1415
  ),
1411
1416
  /**
@@ -1,9 +1,10 @@
1
1
  import React from 'react'
2
2
  import PropTypes from 'prop-types'
3
- import { Pressable, Image } from 'react-native'
3
+ import { StyleSheet, Pressable, Image, View } from 'react-native'
4
4
  import { useCarousel } from './CarouselContext'
5
5
  import { useThemeTokensCallback } from '../ThemeProvider'
6
6
  import { useViewport } from '../ViewportProvider'
7
+ import Icon from '../Icon'
7
8
 
8
9
  const selectPressableTokens = ({ borderColor, borderRadius, borderWidth, margin, padding }) => ({
9
10
  borderColor,
@@ -13,83 +14,171 @@ const selectPressableTokens = ({ borderColor, borderRadius, borderWidth, margin,
13
14
  padding
14
15
  })
15
16
 
17
+ // Play icon overlay appearance is consistent across all brands and is not theme-configurable.
18
+ // The circle occupies 55% of the thumbnail size, and the icon occupies 55% of the circle diameter.
19
+ const PLAY_ICON_RATIO = 0.55
20
+ const PLAY_ICON_BORDER_RADIUS_DIVISOR = 2
21
+
22
+ const selectImageStyles = (size) => ({
23
+ width: size,
24
+ height: size
25
+ })
26
+
27
+ const selectSelectedStyles = ({ selectedBorderColor, selectedBorderWidth, padding, margin }) => ({
28
+ borderColor: selectedBorderColor,
29
+ borderWidth: selectedBorderWidth,
30
+ padding: padding - selectedBorderWidth,
31
+ marginBottom: margin + selectedBorderWidth
32
+ })
33
+
34
+ const selectNonSelectedStyles = ({ borderWidth, padding, margin, selectedBorderWidth }) => ({
35
+ padding: padding - borderWidth,
36
+ marginBottom: margin + selectedBorderWidth
37
+ })
38
+
39
+ const selectPlayIconCircleStyles = (thumbnailSize) => {
40
+ const diameter = thumbnailSize * PLAY_ICON_RATIO
41
+ return {
42
+ width: diameter,
43
+ height: diameter,
44
+ borderRadius: diameter / PLAY_ICON_BORDER_RADIUS_DIVISOR
45
+ }
46
+ }
47
+
48
+ const selectPlayIconTokens = (thumbnailSize) => ({
49
+ size: thumbnailSize * PLAY_ICON_RATIO * PLAY_ICON_RATIO
50
+ })
51
+
16
52
  /**
17
53
  * `Carousel.Thumbnail` is used to wrap the content of an individual slide and is suppsoed to be the
18
54
  * only top-level component passed to the `Carousel`
19
55
  */
20
- const CarouselThumbnail = React.forwardRef(({ accessibilityLabel, alt, index, src }, ref) => {
21
- const { activeIndex, itemLabel, totalItems, getCopyWithPlaceholders, goTo } = useCarousel()
22
- const getThumbnailTokens = useThemeTokensCallback('CarouselThumbnail')
23
- const viewport = useViewport()
24
- const thumbnailTitle =
25
- alt ??
26
- getCopyWithPlaceholders('stepTrackerLabel')
27
- .replace(/%\{itemLabel\}/g, itemLabel)
28
- .replace(/%\{stepNumber\}/g, index)
29
- .replace(/%\{stepCount\}/g, totalItems)
30
- const handlePress = () => goTo(index)
31
- const handleKeyDown = (event) => {
32
- // Allow using the spacebar for navigation
33
- if (event?.key === ' ') goTo(index)
34
- }
35
- const { borderWidth, padding, selectedBorderColor, selectedBorderWidth, size, margin } =
36
- getThumbnailTokens({ viewport })
37
- const styles = {
38
- image: {
39
- height: size,
40
- width: size
41
- },
42
- selected: {
43
- borderColor: selectedBorderColor,
44
- borderWidth: selectedBorderWidth,
45
- padding: padding - selectedBorderWidth,
46
- marginBottom: margin + selectedBorderWidth
47
- },
48
- nonSelected: {
49
- padding: padding - borderWidth,
50
- marginBottom: margin + selectedBorderWidth
56
+ const CarouselThumbnail = React.forwardRef(
57
+ ({ accessibilityLabel, alt, index, isVideo, src }, ref) => {
58
+ const { activeIndex, itemLabel, totalItems, getCopyWithPlaceholders, goTo } = useCarousel()
59
+ const getThumbnailTokens = useThemeTokensCallback('CarouselThumbnail')
60
+ const viewport = useViewport()
61
+ const thumbnailTitle =
62
+ alt ??
63
+ getCopyWithPlaceholders('stepTrackerLabel')
64
+ .replace(/%\{itemLabel\}/g, itemLabel)
65
+ .replace(/%\{stepNumber\}/g, index)
66
+ .replace(/%\{stepCount\}/g, totalItems)
67
+ const handlePress = () => goTo(index)
68
+ const handleKeyDown = (event) => {
69
+ // Allow using the spacebar for navigation
70
+ if (event?.key === ' ') goTo(index)
51
71
  }
52
- }
72
+ const {
73
+ borderWidth,
74
+ padding,
75
+ selectedBorderColor,
76
+ selectedBorderWidth,
77
+ size,
78
+ margin,
79
+ playIcon
80
+ } = getThumbnailTokens({ viewport })
53
81
 
54
- return (
55
- <Pressable
56
- key={src}
57
- onKeyDown={handleKeyDown}
58
- onPress={handlePress}
59
- style={({ hovered, pressed, focused }) => {
60
- const pressableStyles = selectPressableTokens(
61
- getThumbnailTokens({
62
- hover: hovered,
63
- pressed,
64
- focus: focused
65
- })
66
- )
82
+ return (
83
+ <Pressable
84
+ key={src}
85
+ onKeyDown={handleKeyDown}
86
+ onPress={handlePress}
87
+ style={({ hovered, pressed, focused }) => {
88
+ const pressableStyles = selectPressableTokens(
89
+ getThumbnailTokens({
90
+ hover: hovered,
91
+ pressed,
92
+ focus: focused
93
+ })
94
+ )
67
95
 
68
- return [
69
- pressableStyles,
70
- index === activeIndex ? [styles.selected, { outline: 'none' }] : styles.nonSelected
71
- ]
72
- }}
73
- ref={ref}
74
- >
75
- <Image
76
- accessibilityIgnoresInvertColors
77
- accessibilityLabel={accessibilityLabel ?? alt}
78
- source={src}
79
- style={styles.image}
80
- title={thumbnailTitle}
81
- />
82
- </Pressable>
83
- )
84
- })
96
+ return [
97
+ pressableStyles,
98
+ index === activeIndex
99
+ ? [
100
+ selectSelectedStyles({
101
+ selectedBorderColor,
102
+ selectedBorderWidth,
103
+ padding,
104
+ margin
105
+ }),
106
+ staticStyles.selectedPressableOutline
107
+ ]
108
+ : selectNonSelectedStyles({ borderWidth, padding, margin, selectedBorderWidth })
109
+ ]
110
+ }}
111
+ ref={ref}
112
+ >
113
+ <View style={[staticStyles.imageContainer, selectImageStyles(size)]}>
114
+ <Image
115
+ accessibilityIgnoresInvertColors
116
+ accessibilityLabel={accessibilityLabel ?? alt}
117
+ source={src}
118
+ style={selectImageStyles(size)}
119
+ title={thumbnailTitle}
120
+ />
121
+ {isVideo && playIcon && (
122
+ <View
123
+ style={staticStyles.playIconOverlayContainer}
124
+ testID={`play-icon-overlay-${index}`}
125
+ >
126
+ <View
127
+ style={[selectPlayIconCircleStyles(size), staticStyles.playIconCircleBackground]}
128
+ >
129
+ <Icon
130
+ icon={playIcon}
131
+ tokens={{
132
+ ...selectPlayIconTokens(size),
133
+ color: staticStyles.playIconSymbol.color
134
+ }}
135
+ />
136
+ </View>
137
+ </View>
138
+ )}
139
+ </View>
140
+ </Pressable>
141
+ )
142
+ }
143
+ )
85
144
 
86
145
  CarouselThumbnail.displayName = 'CarouselThumbnail'
87
146
 
88
147
  CarouselThumbnail.propTypes = {
148
+ /** Accessibility label for screen readers, overrides the alt text */
89
149
  accessibilityLabel: PropTypes.string,
150
+ /** Alt text for the thumbnail image */
90
151
  alt: PropTypes.string,
152
+ /** Zero-based index of this thumbnail within the carousel */
91
153
  index: PropTypes.number,
92
- src: PropTypes.string
154
+ /**
155
+ * When true, renders a play icon overlay on the thumbnail to indicate that the slide contains a video.
156
+ */
157
+ isVideo: PropTypes.bool,
158
+ /** Image source URI (web) or local asset require() (native) */
159
+ src: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
93
160
  }
94
161
 
162
+ const staticStyles = StyleSheet.create({
163
+ imageContainer: {
164
+ position: 'relative'
165
+ },
166
+ playIconOverlayContainer: {
167
+ position: 'absolute',
168
+ top: 0,
169
+ left: 0,
170
+ right: 0,
171
+ bottom: 0,
172
+ justifyContent: 'center',
173
+ alignItems: 'center'
174
+ },
175
+ selectedPressableOutline: { outlineStyle: 'none' },
176
+ playIconCircleBackground: {
177
+ backgroundColor: 'rgba(0, 0, 0, 0.6)',
178
+ justifyContent: 'center',
179
+ alignItems: 'center'
180
+ },
181
+ playIconSymbol: { color: 'rgb(255, 255, 255)' }
182
+ })
183
+
95
184
  export default CarouselThumbnail
@@ -27,11 +27,12 @@ const CarouselThumbnailNavigation = React.forwardRef(({ thumbnails = [] }, ref)
27
27
  return (
28
28
  <View style={containerStyles}>
29
29
  <StackWrap direction="row" tokens={stackWrapTokens} space={2} ref={ref}>
30
- {thumbnails.map(({ accessibilityLabel, alt, src }, index) => (
30
+ {thumbnails.map(({ accessibilityLabel, alt, isVideo, src }, index) => (
31
31
  <CarouselThumbnail
32
32
  accessibilityLabel={accessibilityLabel}
33
33
  alt={alt}
34
34
  index={index}
35
+ isVideo={isVideo}
35
36
  key={src}
36
37
  src={src}
37
38
  />
@@ -47,9 +48,14 @@ CarouselThumbnailNavigation.propTypes = {
47
48
  */
48
49
  thumbnails: PropTypes.arrayOf(
49
50
  PropTypes.shape({
51
+ /** Accessibility label for the thumbnail image, used by assistive technologies. */
50
52
  accessibilityLabel: PropTypes.string,
53
+ /** Alternative text for the thumbnail image, displayed when the image cannot be rendered. */
51
54
  alt: PropTypes.string,
52
- src: PropTypes.string
55
+ /** When true, renders a play icon overlay on the thumbnail to indicate that the slide contains a video. */
56
+ isVideo: PropTypes.bool,
57
+ /** URL or path of the thumbnail image. When used with `isVideo`, this should be the video's poster/preview image. */
58
+ src: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
53
59
  })
54
60
  ).isRequired
55
61
  }