@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.
- package/CHANGELOG.md +30 -2
- package/lib/Autocomplete/Autocomplete.js +3 -1
- package/lib/Icon/Icon.js +24 -2
- package/lib/Listbox/Listbox.js +14 -6
- package/lib/Modal/Modal.js +40 -4
- package/lib/Modal/WebModal.js +73 -0
- package/lib/Notification/Notification.js +1 -1
- package/lib/PriceLockup/PriceLockup.js +4 -1
- package/lib/PriceLockup/utils/renderFootnoteContent.js +2 -2
- package/lib/PriceLockup/utils/renderFootnoteLinks.js +2 -2
- package/lib/PriceLockup/utils/renderPrice.js +2 -2
- package/lib/PriceLockup/utils/renderTypography.js +1 -1
- package/lib/ProductCard/ProductCard.js +238 -0
- package/lib/ProductCard/dictionary.js +45 -0
- package/lib/ProductCard/index.js +10 -0
- package/lib/ProductCardGroup/ProductCardGroup.js +79 -0
- package/lib/ProductCardGroup/index.js +10 -0
- package/lib/Radio/Radio.js +1 -1
- package/lib/index.js +16 -0
- package/lib-module/Autocomplete/Autocomplete.js +3 -1
- package/lib-module/Icon/Icon.js +24 -2
- package/lib-module/Listbox/Listbox.js +15 -7
- package/lib-module/Modal/Modal.js +42 -5
- package/lib-module/Modal/WebModal.js +65 -0
- package/lib-module/Notification/Notification.js +1 -1
- package/lib-module/PriceLockup/PriceLockup.js +4 -1
- package/lib-module/PriceLockup/utils/renderFootnoteContent.js +2 -2
- package/lib-module/PriceLockup/utils/renderFootnoteLinks.js +2 -2
- package/lib-module/PriceLockup/utils/renderPrice.js +2 -2
- package/lib-module/PriceLockup/utils/renderTypography.js +1 -1
- package/lib-module/ProductCard/ProductCard.js +231 -0
- package/lib-module/ProductCard/dictionary.js +38 -0
- package/lib-module/ProductCard/index.js +2 -0
- package/lib-module/ProductCardGroup/ProductCardGroup.js +69 -0
- package/lib-module/ProductCardGroup/index.js +2 -0
- package/lib-module/Radio/Radio.js +1 -1
- package/lib-module/index.js +2 -0
- package/package.json +2 -2
- package/src/Autocomplete/Autocomplete.jsx +4 -1
- package/src/Icon/Icon.jsx +30 -2
- package/src/Listbox/Listbox.jsx +112 -100
- package/src/Modal/Modal.jsx +42 -3
- package/src/Modal/WebModal.jsx +60 -0
- package/src/Notification/Notification.jsx +1 -1
- package/src/PriceLockup/PriceLockup.jsx +8 -2
- package/src/PriceLockup/utils/renderFootnoteContent.jsx +2 -2
- package/src/PriceLockup/utils/renderFootnoteLinks.jsx +2 -2
- package/src/PriceLockup/utils/renderPrice.jsx +2 -2
- package/src/PriceLockup/utils/renderTypography.jsx +1 -1
- package/src/ProductCard/ProductCard.jsx +193 -0
- package/src/ProductCard/dictionary.js +38 -0
- package/src/ProductCard/index.js +3 -0
- package/src/ProductCardGroup/ProductCardGroup.jsx +75 -0
- package/src/ProductCardGroup/index.js +3 -0
- package/src/Radio/Radio.jsx +1 -1
- package/src/index.js +2 -0
package/src/Listbox/Listbox.jsx
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
45
|
+
const [selectedId, setSelectedId] = useState(defaultSelectedId)
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
const { minHeight, minWidth } = useThemeTokens('Listbox', variant, tokens)
|
|
44
48
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
}, [onClose, handleKeydown])
|
|
97
|
+
return () => {}
|
|
98
|
+
}, [onClose, handleKeydown])
|
|
90
99
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
109
|
+
// Give the list of refs.
|
|
110
|
+
const itemRef = (currentItemRef) => {
|
|
111
|
+
itemRefs.current[index] = currentItemRef
|
|
112
|
+
return currentItemRef
|
|
113
|
+
}
|
|
105
114
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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,
|
package/src/Modal/Modal.jsx
CHANGED
|
@@ -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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
})
|