@telus-uds/components-base 1.81.0 → 1.83.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 (56) hide show
  1. package/CHANGELOG.md +30 -2
  2. package/lib/Autocomplete/Autocomplete.js +3 -1
  3. package/lib/Icon/Icon.js +24 -2
  4. package/lib/Listbox/Listbox.js +14 -6
  5. package/lib/Modal/Modal.js +40 -4
  6. package/lib/Modal/WebModal.js +73 -0
  7. package/lib/Notification/Notification.js +1 -1
  8. package/lib/PriceLockup/PriceLockup.js +4 -1
  9. package/lib/PriceLockup/utils/renderFootnoteContent.js +2 -2
  10. package/lib/PriceLockup/utils/renderFootnoteLinks.js +2 -2
  11. package/lib/PriceLockup/utils/renderPrice.js +2 -2
  12. package/lib/PriceLockup/utils/renderTypography.js +1 -1
  13. package/lib/ProductCard/ProductCard.js +238 -0
  14. package/lib/ProductCard/dictionary.js +45 -0
  15. package/lib/ProductCard/index.js +10 -0
  16. package/lib/ProductCardGroup/ProductCardGroup.js +79 -0
  17. package/lib/ProductCardGroup/index.js +10 -0
  18. package/lib/Radio/Radio.js +1 -1
  19. package/lib/index.js +16 -0
  20. package/lib-module/Autocomplete/Autocomplete.js +3 -1
  21. package/lib-module/Icon/Icon.js +24 -2
  22. package/lib-module/Listbox/Listbox.js +15 -7
  23. package/lib-module/Modal/Modal.js +42 -5
  24. package/lib-module/Modal/WebModal.js +65 -0
  25. package/lib-module/Notification/Notification.js +1 -1
  26. package/lib-module/PriceLockup/PriceLockup.js +4 -1
  27. package/lib-module/PriceLockup/utils/renderFootnoteContent.js +2 -2
  28. package/lib-module/PriceLockup/utils/renderFootnoteLinks.js +2 -2
  29. package/lib-module/PriceLockup/utils/renderPrice.js +2 -2
  30. package/lib-module/PriceLockup/utils/renderTypography.js +1 -1
  31. package/lib-module/ProductCard/ProductCard.js +231 -0
  32. package/lib-module/ProductCard/dictionary.js +38 -0
  33. package/lib-module/ProductCard/index.js +2 -0
  34. package/lib-module/ProductCardGroup/ProductCardGroup.js +69 -0
  35. package/lib-module/ProductCardGroup/index.js +2 -0
  36. package/lib-module/Radio/Radio.js +1 -1
  37. package/lib-module/index.js +2 -0
  38. package/package.json +2 -2
  39. package/src/Autocomplete/Autocomplete.jsx +4 -1
  40. package/src/Icon/Icon.jsx +30 -2
  41. package/src/Listbox/Listbox.jsx +112 -100
  42. package/src/Modal/Modal.jsx +42 -3
  43. package/src/Modal/WebModal.jsx +60 -0
  44. package/src/Notification/Notification.jsx +1 -1
  45. package/src/PriceLockup/PriceLockup.jsx +8 -2
  46. package/src/PriceLockup/utils/renderFootnoteContent.jsx +2 -2
  47. package/src/PriceLockup/utils/renderFootnoteLinks.jsx +2 -2
  48. package/src/PriceLockup/utils/renderPrice.jsx +2 -2
  49. package/src/PriceLockup/utils/renderTypography.jsx +1 -1
  50. package/src/ProductCard/ProductCard.jsx +193 -0
  51. package/src/ProductCard/dictionary.js +38 -0
  52. package/src/ProductCard/index.js +3 -0
  53. package/src/ProductCardGroup/ProductCardGroup.jsx +75 -0
  54. package/src/ProductCardGroup/index.js +3 -0
  55. package/src/Radio/Radio.jsx +1 -1
  56. package/src/index.js +2 -0
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useRef, useState } from 'react'
1
+ import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react'
2
2
  import PropTypes from 'prop-types'
3
3
  import { View, StyleSheet, Platform } from 'react-native'
4
4
  import { useThemeTokens } from '../ThemeProvider'
@@ -25,114 +25,126 @@ const getInitialOpen = (items, selectedId) =>
25
25
  )
26
26
  .map((item) => item.id ?? item.label)
27
27
 
28
- const Listbox = ({
29
- items = [],
30
- firstItemRef = null, // focus will be moved to this one once within the menu
31
- parentRef = null, // to return focus to after leaving the last menu item
32
- selectedId: defaultSelectedId,
33
- LinkRouter,
34
- itemRouterProps,
35
- onClose,
36
- variant,
37
- tokens
38
- }) => {
39
- const initialOpen = getInitialOpen(items, defaultSelectedId)
28
+ const Listbox = forwardRef(
29
+ (
30
+ {
31
+ items = [],
32
+ firstItemRef = null, // focus will be moved to this one once within the menu
33
+ parentRef = null, // to return focus to after leaving the last menu item
34
+ selectedId: defaultSelectedId,
35
+ LinkRouter,
36
+ itemRouterProps,
37
+ onClose,
38
+ variant,
39
+ tokens
40
+ },
41
+ ref
42
+ ) => {
43
+ const initialOpen = getInitialOpen(items, defaultSelectedId)
40
44
 
41
- const [selectedId, setSelectedId] = useState(defaultSelectedId)
45
+ const [selectedId, setSelectedId] = useState(defaultSelectedId)
42
46
 
43
- const { minHeight, minWidth } = useThemeTokens('Listbox', variant, tokens)
47
+ const { minHeight, minWidth } = useThemeTokens('Listbox', variant, tokens)
44
48
 
45
- // We need to keep track of each item's ref in order to be able to
46
- // focus on a specific item via keyboard navigation
47
- const itemRefs = useRef([])
48
- if (firstItemRef?.current) itemRefs.current[0] = firstItemRef.current
49
- const [focusedIndex, setFocusedIndex] = useState(0)
50
- const handleKeydown = useCallback(
51
- (event) => {
52
- const nextItemRef = itemRefs.current[focusedIndex + 1]
53
- const prevItemRef = itemRefs.current[focusedIndex - 1]
54
- if (event.key === 'ArrowUp' || (event.shiftKey && event.key === 'Tab')) {
55
- // Move the focus to the previous item or to the parent one if on the first
56
- if (prevItemRef) {
49
+ // We need to keep track of each item's ref in order to be able to
50
+ // focus on a specific item via keyboard navigation
51
+ const itemRefs = useRef([])
52
+ if (firstItemRef?.current) itemRefs.current[0] = firstItemRef.current
53
+ const [focusedIndex, setFocusedIndex] = useState(0)
54
+ const handleKeydown = useCallback(
55
+ (event) => {
56
+ const nextItemRef = itemRefs.current[focusedIndex + 1]
57
+ const prevItemRef = itemRefs.current[focusedIndex - 1]
58
+ if (event.key === 'ArrowUp' || (event.shiftKey && event.key === 'Tab')) {
59
+ // Move the focus to the previous item or to the parent one if on the first
60
+ if (prevItemRef) {
61
+ event.preventDefault()
62
+ prevItemRef.focus()
63
+ } else if (parentRef) parentRef.current?.focus()
64
+ setFocusedIndex(focusedIndex - 1)
65
+ } else if ((event.key === 'ArrowDown' || event.key === 'Tab') && nextItemRef) {
57
66
  event.preventDefault()
58
- prevItemRef.focus()
59
- } else if (parentRef) parentRef.current?.focus()
60
- setFocusedIndex(focusedIndex - 1)
61
- } else if ((event.key === 'ArrowDown' || event.key === 'Tab') && nextItemRef) {
62
- event.preventDefault()
63
- setFocusedIndex(focusedIndex + 1)
64
- nextItemRef.focus()
65
- } else if (event.key === 'Escape') {
66
- // Close the dropdown
67
- parentRef?.current?.click()
68
- // Return focus to the dropdown control after leaving the last item
69
- parentRef?.current?.focus()
70
- if (onClose) onClose(event)
71
- }
72
- },
73
- [focusedIndex, onClose, parentRef]
74
- )
67
+ setFocusedIndex(focusedIndex + 1)
68
+ nextItemRef.focus()
69
+ } else if (event.key === 'Escape') {
70
+ // Close the dropdown
71
+ parentRef?.current?.click()
72
+ // Return focus to the dropdown control after leaving the last item
73
+ parentRef?.current?.focus()
74
+ if (onClose) onClose(event)
75
+ } else if (!nextItemRef && firstItemRef) {
76
+ // If the last item is focused, move the focus to the first one
77
+ event.preventDefault()
78
+ setFocusedIndex(0)
79
+ firstItemRef.current?.focus()
80
+ }
81
+ },
82
+ [focusedIndex, onClose, parentRef, firstItemRef]
83
+ )
75
84
 
76
- // Add listeners for mouse clicks outside and for key presses
77
- useEffect(() => {
78
- if (Platform.OS === 'web') {
79
- window.addEventListener('click', onClose)
80
- window.addEventListener('keydown', handleKeydown)
81
- window.addEventListener('touchstart', onClose)
82
- return () => {
83
- window.removeEventListener('click', onClose)
84
- window.removeEventListener('keydown', handleKeydown)
85
- window.removeEventListener('touchstart', onClose)
85
+ // Add listeners for mouse clicks outside and for key presses
86
+ useEffect(() => {
87
+ if (Platform.OS === 'web') {
88
+ window.addEventListener('click', onClose)
89
+ window.addEventListener('keydown', handleKeydown)
90
+ window.addEventListener('touchstart', onClose)
91
+ return () => {
92
+ window.removeEventListener('click', onClose)
93
+ window.removeEventListener('keydown', handleKeydown)
94
+ window.removeEventListener('touchstart', onClose)
95
+ }
86
96
  }
87
- }
88
- return () => {}
89
- }, [onClose, handleKeydown])
97
+ return () => {}
98
+ }, [onClose, handleKeydown])
90
99
 
91
- return (
92
- <ListboxContext.Provider value={{ selectedId, setSelectedId }}>
93
- <ExpandCollapse initialOpen={initialOpen} maxOpen={1}>
94
- {(expandProps) => (
95
- <View style={[styles.list, { minHeight, minWidth }]} role="listbox">
96
- {items.map((item, index) => {
97
- const { id, label, items: nestedItems } = item
98
- const itemId = id ?? label
100
+ return (
101
+ <ListboxContext.Provider value={{ selectedId, setSelectedId }}>
102
+ <ExpandCollapse initialOpen={initialOpen} maxOpen={1} ref={ref}>
103
+ {(expandProps) => (
104
+ <View style={[styles.list, { minHeight, minWidth }]} role="listbox">
105
+ {items.map((item, index) => {
106
+ const { id, label, items: nestedItems } = item
107
+ const itemId = id ?? label
99
108
 
100
- // Give the list of refs.
101
- const itemRef = (ref) => {
102
- itemRefs.current[index] = ref
103
- return ref
104
- }
109
+ // Give the list of refs.
110
+ const itemRef = (currentItemRef) => {
111
+ itemRefs.current[index] = currentItemRef
112
+ return currentItemRef
113
+ }
105
114
 
106
- return nestedItems ? (
107
- <ListboxGroup
108
- {...item}
109
- expandProps={expandProps}
110
- LinkRouter={LinkRouter}
111
- itemRouterProps={itemRouterProps}
112
- prevItemRef={itemRefs.current[index - 1] ?? null}
113
- nextItemRef={itemRefs.current[index + 1] ?? null}
114
- ref={index === 0 ? firstItemRef : itemRef}
115
- key={itemId}
116
- />
117
- ) : (
118
- <ListboxItem
119
- {...item}
120
- key={itemId}
121
- id={itemId}
122
- LinkRouter={LinkRouter}
123
- itemRouterProps={itemRouterProps}
124
- prevItemRef={itemRefs.current[index - 1] ?? null}
125
- nextItemRef={itemRefs.current[index + 1] ?? null}
126
- ref={index === 0 ? firstItemRef : itemRef}
127
- />
128
- )
129
- })}
130
- </View>
131
- )}
132
- </ExpandCollapse>
133
- </ListboxContext.Provider>
134
- )
135
- }
115
+ return nestedItems ? (
116
+ <ListboxGroup
117
+ {...item}
118
+ expandProps={expandProps}
119
+ LinkRouter={LinkRouter}
120
+ itemRouterProps={itemRouterProps}
121
+ prevItemRef={itemRefs.current[index - 1] ?? null}
122
+ nextItemRef={itemRefs.current[index + 1] ?? null}
123
+ ref={index === 0 ? firstItemRef : itemRef}
124
+ key={itemId}
125
+ />
126
+ ) : (
127
+ <ListboxItem
128
+ {...item}
129
+ key={itemId}
130
+ id={itemId}
131
+ LinkRouter={LinkRouter}
132
+ itemRouterProps={itemRouterProps}
133
+ prevItemRef={itemRefs.current[index - 1] ?? null}
134
+ nextItemRef={itemRefs.current[index + 1] ?? null}
135
+ ref={index === 0 ? firstItemRef : itemRef}
136
+ />
137
+ )
138
+ })}
139
+ </View>
140
+ )}
141
+ </ExpandCollapse>
142
+ </ListboxContext.Provider>
143
+ )
144
+ }
145
+ )
146
+
147
+ Listbox.displayName = 'Listbox'
136
148
 
137
149
  Listbox.propTypes = {
138
150
  ...withLinkRouter.propTypes,
@@ -1,4 +1,4 @@
1
- import React, { forwardRef } from 'react'
1
+ import React, { forwardRef, useEffect, useRef } from 'react'
2
2
  import {
3
3
  StyleSheet,
4
4
  TouchableWithoutFeedback,
@@ -24,6 +24,7 @@ import IconButton from '../IconButton'
24
24
  import dictionary from './dictionary'
25
25
  import useScrollBlocking from '../utils/useScrollBlocking'
26
26
  import ModalContent from './ModalContent'
27
+ import WebModal from './WebModal'
27
28
 
28
29
  const [selectProps, selectedSystemPropTypes] = selectSystemProps([a11yProps, viewProps])
29
30
 
@@ -138,12 +139,35 @@ const Modal = forwardRef(
138
139
  // Hide the close button if `closeButton` is `null`.
139
140
  const showCloseButton = closeButton !== null
140
141
 
142
+ // These refs are used to manage focus in the web modal container
143
+ const focusTrapRef = useRef(null)
144
+ const closeButtonRef = useRef(null)
145
+
146
+ useEffect(() => {
147
+ if (Platform.OS === 'web') {
148
+ const handleFocus = () => {
149
+ // If the focus is on the last item of the web modal container, move it to the close button
150
+ if (document.activeElement === focusTrapRef.current) {
151
+ closeButtonRef.current.focus()
152
+ }
153
+ return undefined
154
+ }
155
+
156
+ // Add an event listener to manage focus in the web modal container
157
+ document.addEventListener('focusin', handleFocus)
158
+
159
+ // Clean up the event listener
160
+ return () => document.removeEventListener('focusin', handleFocus)
161
+ }
162
+ return undefined
163
+ }, [])
164
+
141
165
  if (!isOpen) {
142
166
  return null
143
167
  }
144
168
 
145
- return (
146
- <NativeModal transparent {...selectProps(rest)}>
169
+ const content = (
170
+ <>
147
171
  <ScrollView contentContainerStyle={[staticStyles.positioningContainer]} ref={modalRef}>
148
172
  <View
149
173
  style={[staticStyles.sizingContainer, selectContainerStyles(themeTokens)]}
@@ -167,6 +191,7 @@ const Modal = forwardRef(
167
191
  icon={CloseIconComponent}
168
192
  accessibilityRole="button"
169
193
  accessibilityLabel={closeLabel}
194
+ ref={closeButtonRef}
170
195
  />
171
196
  )}
172
197
  </View>
@@ -200,6 +225,20 @@ const Modal = forwardRef(
200
225
  <View style={[staticStyles.backdrop, selectBackdropStyles(themeTokens)]} />
201
226
  </TouchableWithoutFeedback>
202
227
  </ScrollView>
228
+ </>
229
+ )
230
+
231
+ if (Platform.OS === 'web') {
232
+ return (
233
+ <WebModal {...selectProps(rest)}>
234
+ {content}
235
+ <View accessibilityRole="button" ref={focusTrapRef} />
236
+ </WebModal>
237
+ )
238
+ }
239
+ return (
240
+ <NativeModal transparent {...selectProps(rest)}>
241
+ {content}
203
242
  </NativeModal>
204
243
  )
205
244
  }
@@ -0,0 +1,60 @@
1
+ import React from 'react'
2
+ import PropTypes from 'prop-types'
3
+ import { View, StyleSheet } from 'react-native'
4
+ import { a11yProps, selectSystemProps, viewProps } from '../utils'
5
+
6
+ const [selectProps, selectedSystemPropTypes] = selectSystemProps([a11yProps, viewProps])
7
+
8
+ /**
9
+ * WebModal component.
10
+ *
11
+ * @component
12
+ * @param {Object} props - The component props.
13
+ * @param {ReactNode} props.children - The content of the modal.
14
+ * @returns {JSX.Element} The rendered WebModal component.
15
+ */
16
+ const WebModal = ({ children, ...rest }) => {
17
+ return (
18
+ <View style={staticStyles.container} {...selectProps(rest)}>
19
+ <View style={staticStyles.content}>{children}</View>
20
+ </View>
21
+ )
22
+ }
23
+
24
+ WebModal.propTypes = {
25
+ ...selectedSystemPropTypes,
26
+ // children to be rendered within the modal
27
+ children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)])
28
+ }
29
+
30
+ const staticStyles = StyleSheet.create({
31
+ container: {
32
+ position: 'fixed',
33
+ backgroundColor: 'rgba(0, 0, 0, 0)',
34
+ top: 0,
35
+ right: 0,
36
+ left: 0,
37
+ bottom: 0,
38
+ alignItems: 'stretch',
39
+ boxSizing: 'border-box',
40
+ display: 'flex',
41
+ flexBasis: 'auto',
42
+ flexDirection: 'column',
43
+ flexShrink: 0,
44
+ listStyle: 'none',
45
+ margin: 0,
46
+ minHeight: 0,
47
+ minWidth: 0,
48
+ padding: 0,
49
+ textDecoration: 'none',
50
+ zIndex: 1
51
+ },
52
+ content: {
53
+ flex: 1,
54
+ flexGrow: 1,
55
+ flexShrink: 1,
56
+ flexBasis: 0
57
+ }
58
+ })
59
+
60
+ export default WebModal
@@ -65,7 +65,7 @@ const selectDismissButtonContainerStyles = ({ dismissButtonGap }) => ({
65
65
  })
66
66
 
67
67
  const selectContentContainerStyle = (maxWidth) => ({
68
- width: maxWidth || '100%'
68
+ maxWidth: maxWidth || '100%'
69
69
  })
70
70
 
71
71
  const getMediaQueryStyles = (themeTokens, themeOptions, viewport, mediaIdsRef, dismissible) => {
@@ -85,7 +85,6 @@ const PriceLockup = ({
85
85
  bottomTextMarginTop,
86
86
  priceMarginBottom,
87
87
  bottomLinksMarginLeft,
88
- topTextMarginBottom,
89
88
  fontColor,
90
89
  dividerColor,
91
90
  ...themeTokens
@@ -104,7 +103,11 @@ const PriceLockup = ({
104
103
 
105
104
  return (
106
105
  <View style={[staticStyles.priceLockupContainer, { ...selectProps(rest) }]}>
107
- {topText ? <View>{renderTypography(topText, topTextTypographyTokens)}</View> : null}
106
+ {topText ? (
107
+ <View style={staticStyles.topText}>
108
+ {renderTypography(topText, topTextTypographyTokens)}
109
+ </View>
110
+ ) : null}
108
111
  {renderPrice(
109
112
  price,
110
113
  rateText,
@@ -214,5 +217,8 @@ export default PriceLockup
214
217
  const staticStyles = StyleSheet.create({
215
218
  priceLockupContainer: {
216
219
  alignSelf: 'flex-start'
220
+ },
221
+ topText: {
222
+ marginBottom: 4
217
223
  }
218
224
  })
@@ -35,7 +35,7 @@ const renderFootnoteContent = (
35
35
  <Text style={selectFootnoteBottomTextContainer({ bottomTextMarginTop })}>
36
36
  {renderTypography(bottomText, bottomTextTypographyTokens, undefined, fontColor)}{' '}
37
37
  </Text>
38
- {footnoteLinks.length <= MAX_FOOTNOTE_LINKS_ALLOWED ? (
38
+ {footnoteLinks?.length <= MAX_FOOTNOTE_LINKS_ALLOWED ? (
39
39
  <View
40
40
  style={[
41
41
  staticStyles.footnoteLinkContainer,
@@ -46,7 +46,7 @@ const renderFootnoteContent = (
46
46
  </View>
47
47
  ) : null}
48
48
  </View>
49
- {footnoteLinks.length > MAX_FOOTNOTE_LINKS_ALLOWED ? (
49
+ {footnoteLinks?.length > MAX_FOOTNOTE_LINKS_ALLOWED ? (
50
50
  <View style={staticStyles.verticalFootnoteLinkContainer}>
51
51
  {renderFootnoteLinks(footnoteLinks, themeTokens, onClickFootnote)}
52
52
  </View>
@@ -14,7 +14,7 @@ const selectFootnoteLinkStyles = (
14
14
  // This is used to apply the proper line height when there is 4 or more footnote links
15
15
  const MAX_FOOTNOTE_LINKS_ALLOWED = 3
16
16
  const lineHeight =
17
- footnoteLinks.length > MAX_FOOTNOTE_LINKS_ALLOWED
17
+ footnoteLinks?.length > MAX_FOOTNOTE_LINKS_ALLOWED
18
18
  ? footnoteLinkFontSize * footnoteLinkLineHeight
19
19
  : undefined
20
20
  return {
@@ -27,7 +27,7 @@ const selectFootnoteLinkStyles = (
27
27
  }
28
28
 
29
29
  const renderFootnoteLinks = (footnoteLinks, themeTokens, onClickFootnote) =>
30
- footnoteLinks && footnoteLinks.length > 0 ? (
30
+ footnoteLinks?.length > 0 ? (
31
31
  <FootnoteLink
32
32
  tokens={selectFootnoteLinkStyles(themeTokens, footnoteLinks)}
33
33
  content={footnoteLinks}
@@ -133,7 +133,7 @@ const renderPrice = (
133
133
  {renderTypography(rateText, rateTypographyTokens, ratePosition, fontColor)}
134
134
  </Text>
135
135
  ) : null}
136
- {!bottomText && footnoteLinks.length <= MAX_FOOTNOTE_LINKS_ALLOWED ? (
136
+ {!bottomText && footnoteLinks?.length <= MAX_FOOTNOTE_LINKS_ALLOWED ? (
137
137
  <Text
138
138
  style={[
139
139
  footnoteLinkPositionStyles,
@@ -144,7 +144,7 @@ const renderPrice = (
144
144
  </Text>
145
145
  ) : null}
146
146
  </View>
147
- {!bottomText && footnoteLinks.length > MAX_FOOTNOTE_LINKS_ALLOWED ? (
147
+ {!bottomText && footnoteLinks?.length > MAX_FOOTNOTE_LINKS_ALLOWED ? (
148
148
  <View style={staticStyles.verticalFootnoteLinkContainer}>
149
149
  {renderFootnoteLinks(footnoteLinks, themeTokens, onClickFootnote)}
150
150
  </View>
@@ -3,7 +3,7 @@ import Typography from '../../Typography'
3
3
 
4
4
  const renderTypography = (value, themeTokens, ratePosition, fontColor) => {
5
5
  const customProps =
6
- ratePosition === 'bottom'
6
+ ratePosition === 'bottom' && value !== '$'
7
7
  ? { variant: { size: 'micro' }, tokens: { color: fontColor } }
8
8
  : { tokens: { ...themeTokens, color: fontColor } }
9
9
 
@@ -0,0 +1,193 @@
1
+ import React from 'react'
2
+ import PropTypes from 'prop-types'
3
+ import { StyleSheet, View, Image } from 'react-native'
4
+ import { useViewport } from '../ViewportProvider'
5
+ import { useThemeTokens } from '../ThemeProvider'
6
+ import defaultDictionary from './dictionary'
7
+ import {
8
+ selectSystemProps,
9
+ getTokensPropType,
10
+ htmlAttrs,
11
+ viewProps,
12
+ useInputValue,
13
+ useCopy,
14
+ a11yProps
15
+ } from '../utils'
16
+
17
+ import Badge from '../Badge'
18
+ import PriceLockup from '../PriceLockup'
19
+ import Typography from '../Typography'
20
+ import { Button } from '../Button'
21
+ import StackView from '../StackView'
22
+ import Box from '../Box'
23
+ import Icon from '../Icon'
24
+
25
+ const [selectProps, selectedSystemPropTypes] = selectSystemProps([htmlAttrs, viewProps, a11yProps])
26
+
27
+ const selectProductCardTokens = ({
28
+ borderStyle,
29
+ borderColor,
30
+ borderWidth,
31
+ borderRadius,
32
+ paddingHorizontal,
33
+ paddingVertical
34
+ }) => ({
35
+ borderStyle,
36
+ borderColor,
37
+ borderWidth,
38
+ borderRadius,
39
+ paddingHorizontal,
40
+ paddingVertical
41
+ })
42
+
43
+ const ProductCard = ({
44
+ copy = 'en',
45
+ dictionary = defaultDictionary,
46
+ image = {
47
+ src: '',
48
+ alt: ''
49
+ },
50
+ cardId,
51
+ isSelected,
52
+ onSelect,
53
+ tokens,
54
+ ...rest
55
+ }) => {
56
+ const viewport = useViewport()
57
+ const themeTokens = useThemeTokens('ProductCard', tokens, { viewport })
58
+
59
+ const getCopy = useCopy({ copy, dictionary })
60
+
61
+ const { currentValue, setValue } = useInputValue()
62
+ const hasClicked = isSelected || currentValue
63
+
64
+ const handlePress = (event) => {
65
+ if (cardId) {
66
+ onSelect(cardId)
67
+ } else {
68
+ setValue(!currentValue, event)
69
+ }
70
+ }
71
+
72
+ const renderButton = hasClicked ? (
73
+ <Box vertical={1}>
74
+ <StackView space={2} direction="row">
75
+ <Icon icon={themeTokens.selectedButtonIcon} variant={{ color: 'success' }} />
76
+ <Typography variant={{ size: 'h4' }} tokens={{ fontWeight: 400 }}>
77
+ {getCopy('selectedButtonLabel')}
78
+ </Typography>
79
+ </StackView>
80
+ </Box>
81
+ ) : (
82
+ <Button onPress={handlePress} variant={{ purpose: 'primary', size: 'small', width: 'full' }}>
83
+ {getCopy('buttonLabel')}
84
+ </Button>
85
+ )
86
+
87
+ return (
88
+ <View
89
+ {...selectProps(rest)}
90
+ style={[selectProductCardTokens(themeTokens), staticStyles.container]}
91
+ >
92
+ {image?.src ? (
93
+ <View style={staticStyles.imageContainer}>
94
+ <Image
95
+ source={image.src}
96
+ style={staticStyles.image}
97
+ alt={image.alt}
98
+ accessibilityLabel={image.alt}
99
+ resizeMethod="resize"
100
+ accessibilityIgnoresInvertColors
101
+ />
102
+ </View>
103
+ ) : null}
104
+
105
+ <View style={staticStyles.textContainer}>
106
+ <Box left={3}>
107
+ <StackView space={1}>
108
+ {getCopy('badgeText') ? (
109
+ <Badge variant={{ outline: true, purpose: 'editorial' }}>
110
+ {getCopy('badgeText')}
111
+ </Badge>
112
+ ) : null}
113
+ <Typography variant={{ size: 'h6' }}>{getCopy('brandName')}</Typography>
114
+ <Typography variant={{ size: 'h4', colour: 'brand' }} tokens={{ fontWeight: 400 }}>
115
+ {getCopy('productName')}
116
+ </Typography>
117
+ <StackView space={3} divider direction="row">
118
+ <PriceLockup {...getCopy('primaryPrice')} size="small" ratePosition="bottom" />
119
+ {getCopy('secondaryPrice')?.price ? (
120
+ <PriceLockup {...getCopy('secondaryPrice')} size="small" ratePosition="bottom" />
121
+ ) : null}
122
+ </StackView>
123
+ <Box top={2}>
124
+ <StackView space={2}>
125
+ <Typography variant={{ size: 'h6' }} tokens={{ fontWeight: 500 }}>
126
+ {getCopy('term')}
127
+ </Typography>
128
+ {getCopy('buttonLabel') ? <Box top={1}>{renderButton}</Box> : null}
129
+ </StackView>
130
+ </Box>
131
+ </StackView>
132
+ </Box>
133
+ </View>
134
+ </View>
135
+ )
136
+ }
137
+
138
+ ProductCard.displayName = 'ProductCard'
139
+
140
+ // If a language dictionary entry is provided, it must contain every key
141
+ const dictionaryContentShape = PropTypes.shape({
142
+ badgeText: PropTypes.string,
143
+ brandName: PropTypes.string.isRequired,
144
+ productName: PropTypes.string.isRequired,
145
+ primaryPrice: PropTypes.object.isRequired,
146
+ secondaryPrice: PropTypes.object,
147
+ term: PropTypes.string.isRequired,
148
+ buttonLabel: PropTypes.string.isRequired,
149
+ selectedButtonLabel: PropTypes.string.isRequired
150
+ })
151
+
152
+ ProductCard.propTypes = {
153
+ ...selectedSystemPropTypes,
154
+ image: PropTypes.shape({
155
+ src: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
156
+ alt: PropTypes.string
157
+ }),
158
+ /**
159
+ * Select English or French copy for the place holder labels.
160
+ * You may also pass in a custom dictionary object.
161
+ */
162
+ copy: PropTypes.oneOfType([PropTypes.oneOf(['en', 'fr'])]),
163
+ /**
164
+ * Override the default dictionary, by passing the complete dictionary object for `en` and `fr`
165
+ */
166
+ dictionary: PropTypes.shape({
167
+ en: dictionaryContentShape,
168
+ fr: dictionaryContentShape
169
+ }),
170
+ tokens: getTokensPropType('ProductCard')
171
+ }
172
+ export default ProductCard
173
+
174
+ const staticStyles = StyleSheet.create({
175
+ container: {
176
+ flexDirection: 'row',
177
+ flex: 1
178
+ },
179
+ imageContainer: {
180
+ width: '30%',
181
+ minWidth: 96,
182
+ maxWidth: 96
183
+ },
184
+ image: {
185
+ resizeMode: 'contain',
186
+ width: '100%',
187
+ height: undefined, // This is to maintain the aspect ratio
188
+ aspectRatio: 0.8
189
+ },
190
+ textContainer: {
191
+ width: '70%'
192
+ }
193
+ })