@telus-uds/components-web 4.14.0 → 4.15.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/src/Card/Card.jsx CHANGED
@@ -28,6 +28,8 @@ import ConditionalWrapper from '../shared/ConditionalWrapper'
28
28
  // Passes React Native-oriented system props through UDS Card
29
29
  const [selectProps, selectedSystemPropTypes] = selectSystemProps([a11yProps, viewProps])
30
30
 
31
+ const GRID_COLUMNS = 12
32
+
31
33
  /**
32
34
  * A basic card component, unstyled by default.
33
35
  *
@@ -57,12 +59,32 @@ const [selectProps, selectedSystemPropTypes] = selectSystemProps([a11yProps, vie
57
59
  * ## Accessibility
58
60
  * `Card` component accepts all the standard accessibility props.
59
61
  */
60
- const DynamicWidthContainer = styled.div(({ width, display, borderRadius, overflow }) => ({
61
- width,
62
- display,
63
- borderRadius,
64
- overflow
65
- }))
62
+ const DynamicWidthContainer = styled.div.withConfig({
63
+ shouldForwardProp: (prop) =>
64
+ !['marginTop', 'marginBottom', 'marginLeft', 'marginRight', 'height', 'alignSelf'].includes(
65
+ prop
66
+ )
67
+ })(
68
+ ({
69
+ width,
70
+ display,
71
+ borderRadius,
72
+ overflow,
73
+ marginTop,
74
+ marginBottom,
75
+ marginLeft,
76
+ marginRight
77
+ }) => ({
78
+ width,
79
+ display,
80
+ borderRadius,
81
+ overflow,
82
+ marginTop,
83
+ marginBottom,
84
+ marginLeft,
85
+ marginRight
86
+ })
87
+ )
66
88
 
67
89
  const Card = React.forwardRef(
68
90
  (
@@ -84,6 +106,9 @@ const Card = React.forwardRef(
84
106
  },
85
107
  ref
86
108
  ) => {
109
+ const { hrefAttrs: cardLevelHrefAttrs, rest: restWithoutHrefAttrs } = hrefAttrsProp.bundle(rest)
110
+ const { href: cardLevelHref, ...restProps } = restWithoutHrefAttrs
111
+
87
112
  const {
88
113
  contentStackAlign,
89
114
  contentStackDirection,
@@ -92,15 +117,54 @@ const Card = React.forwardRef(
92
117
  fullBleedContentChildrenAlign
93
118
  } = useFullBleedContentProps(fullBleedContent)
94
119
 
95
- const { imgCol } = fullBleedContentProps
120
+ const {
121
+ imgCol,
122
+ interactive: fullBleedInteractive,
123
+ onPress: fullBleedOnPress,
124
+ href: fullBleedHref,
125
+ hrefAttrs: fullBleedHrefAttrs,
126
+ ...fullBleedContentPropsClean
127
+ } = fullBleedContentProps
128
+
129
+ const effectiveFullBleedOnPress = fullBleedOnPress || onPress
130
+ const effectiveFullBleedHref = fullBleedHref || cardLevelHref
131
+ const effectiveFullBleedHrefAttrs = fullBleedHrefAttrs || cardLevelHrefAttrs
96
132
 
97
133
  // If the card has rounded corners and a full bleed image, we need to apply
98
134
  // those corners on the image as well, but partially
99
- const { borderRadius } = useThemeTokens('Card', tokens, variant)
135
+ const allThemeTokens = useThemeTokens('Card', tokens, variant)
136
+ const { borderRadius } = allThemeTokens
137
+
138
+ const cardBaseBorderWidth = allThemeTokens.borderWidth
139
+ const pressableBorderWidth = useThemeTokens(
140
+ 'Card',
141
+ {},
142
+ { ...variant, interactive: true }
143
+ ).borderWidth
144
+ const marginOffset = `${-(cardBaseBorderWidth + pressableBorderWidth) / 2}px`
145
+
100
146
  const getThemeTokens = useThemeTokensCallback('Card', interactiveCard?.tokens, {
101
147
  interactive: true,
102
148
  ...(interactiveCard?.variant || {})
103
149
  })
150
+
151
+ const { backgroundColor: _, ...tokensWithoutBg } = tokens
152
+
153
+ const getFullBleedInteractiveTokens = useThemeTokensCallback('Card', tokensWithoutBg, {
154
+ ...variant,
155
+ interactive: true
156
+ })
157
+
158
+ const getFullBleedInteractiveCardTokens = (cardState) => ({
159
+ ...getFullBleedInteractiveTokens(cardState),
160
+ paddingTop: 0,
161
+ paddingBottom: 0,
162
+ paddingLeft: 0,
163
+ paddingRight: 0,
164
+ // Suppress gradient if interactiveCard.body exists to avoid border duplication
165
+ ...(interactiveCard?.body ? { gradient: undefined } : {})
166
+ })
167
+
104
168
  const hasFooter = Boolean(footer)
105
169
  const fullBleedBorderRadius = getFullBleedBorderRadius(
106
170
  borderRadius,
@@ -112,7 +176,7 @@ const Card = React.forwardRef(
112
176
  // card content will adapt to the size of image to add up to 100% width of card width
113
177
  // pass as props to ConditionalWrapper
114
178
  const imgColCurrentViewport = useResponsiveProp(imgCol)
115
- const maxCol = 12
179
+ const maxCol = GRID_COLUMNS
116
180
  const fullBleedImageWidth = `${(imgColCurrentViewport / maxCol) * 100}%`
117
181
  const adaptiveContentWidth = `${((maxCol - imgColCurrentViewport) / maxCol) * 100}%`
118
182
 
@@ -121,14 +185,7 @@ const Card = React.forwardRef(
121
185
 
122
186
  const contentWrapperStyleProps = {
123
187
  width: adaptiveContentWidth,
124
- display: imgColCurrentViewport >= maxCol ? 'none' : undefined
125
- }
126
-
127
- const imageWrapperStyleProps = {
128
- width: fullBleedImageWidth,
129
- borderRadius: imgColCurrentViewport >= maxCol ? borderRadius : undefined,
130
- overflow: imgColCurrentViewport >= maxCol ? 'hidden' : undefined,
131
- display: imgColCurrentViewport === 0 ? 'none' : undefined
188
+ ...(imgColCurrentViewport >= maxCol && { display: 'none' })
132
189
  }
133
190
 
134
191
  const columnFlex = {
@@ -137,7 +194,24 @@ const Card = React.forwardRef(
137
194
  justifyContent: 'space-between'
138
195
  }
139
196
 
140
- const { paddingTop, paddingBottom, paddingLeft, paddingRight, ...cardBaseTokens } = tokens
197
+ const cardBaseTokens = Object.fromEntries(
198
+ Object.entries(tokens).filter(
199
+ ([key]) =>
200
+ ![
201
+ 'paddingTop',
202
+ 'paddingBottom',
203
+ 'paddingLeft',
204
+ 'paddingRight',
205
+ ...(backgroundImage ? ['backgroundColor'] : [])
206
+ ].includes(key)
207
+ )
208
+ )
209
+
210
+ const imageWrapperStyleProps = {
211
+ width: fullBleedImageWidth,
212
+ ...(imgColCurrentViewport >= maxCol && { borderRadius, overflow: 'hidden' }),
213
+ ...(imgColCurrentViewport === 0 && { display: 'none' })
214
+ }
141
215
 
142
216
  return (
143
217
  <CardBase
@@ -145,30 +219,124 @@ const Card = React.forwardRef(
145
219
  variant={{ ...variant, padding: 'custom' }}
146
220
  tokens={cardBaseTokens}
147
221
  backgroundImage={backgroundImage}
148
- onPress={onPress}
149
- {...(interactiveCard?.selectionType && { interactiveCard })}
150
- {...selectProps(rest)}
222
+ onPress={fullBleedInteractive ? undefined : onPress}
223
+ {...(interactiveCard?.selectionType && { interactiveCard, id: rest.id })}
224
+ {...selectProps(restProps)}
151
225
  >
226
+ {interactiveCard?.selectionType && children ? (
227
+ <CardContent tokens={tokens} variant={variant} withFooter={hasFooter}>
228
+ {children}
229
+ </CardContent>
230
+ ) : null}
152
231
  {interactiveCard?.body && !interactiveCard.selectionType ? (
153
- <PressableCardBase
154
- ref={ref}
155
- tokens={getThemeTokens}
156
- dataSet={dataSet}
157
- onPress={onPress}
158
- href={interactiveCard?.href}
159
- hrefAttrs={interactiveCard?.hrefAttrs}
160
- {...selectProps(rest)}
232
+ <>
233
+ <PressableCardBase
234
+ ref={ref}
235
+ tokens={getThemeTokens}
236
+ dataSet={dataSet}
237
+ onPress={onPress}
238
+ href={interactiveCard?.href}
239
+ hrefAttrs={interactiveCard?.hrefAttrs}
240
+ {...selectProps(restProps)}
241
+ >
242
+ {(cardState) => (
243
+ <>
244
+ {typeof interactiveCard?.body === 'function'
245
+ ? interactiveCard.body(cardState)
246
+ : interactiveCard.body}
247
+ </>
248
+ )}
249
+ </PressableCardBase>
250
+ {children && fullBleedContentPosition === 'none' && !fullBleedInteractive ? (
251
+ <CardContent tokens={tokens} variant={variant} withFooter={hasFooter}>
252
+ {children}
253
+ </CardContent>
254
+ ) : null}
255
+ </>
256
+ ) : null}
257
+ {fullBleedInteractive ? (
258
+ <div
259
+ style={{
260
+ marginTop: marginOffset,
261
+ marginBottom: marginOffset,
262
+ marginLeft: marginOffset,
263
+ marginRight: marginOffset
264
+ }}
161
265
  >
162
- {(cardState) => (
163
- <>
164
- {typeof interactiveCard?.body === 'function'
165
- ? interactiveCard.body(cardState)
166
- : interactiveCard.body}
167
- </>
168
- )}
169
- </PressableCardBase>
266
+ <PressableCardBase
267
+ ref={ref}
268
+ tokens={getFullBleedInteractiveCardTokens}
269
+ dataSet={dataSet}
270
+ onPress={effectiveFullBleedOnPress}
271
+ href={effectiveFullBleedHref}
272
+ hrefAttrs={effectiveFullBleedHrefAttrs}
273
+ {...selectProps(restProps)}
274
+ >
275
+ {(cardState) => (
276
+ <StackView
277
+ direction={contentStackDirection}
278
+ tokens={{ ...columnFlex, alignItems: contentStackAlign }}
279
+ space={0}
280
+ >
281
+ {children ? (
282
+ <ConditionalWrapper
283
+ WrapperComponent={DynamicWidthContainer}
284
+ wrapperProps={contentWrapperStyleProps}
285
+ condition={isImageWidthAdjustable}
286
+ >
287
+ <CardContent
288
+ tokens={{
289
+ ...tokensWithoutBg,
290
+ ...(backgroundImage ? {} : { backgroundColor: 'transparent' }),
291
+ ...(fullBleedContentChildrenAlign && {
292
+ alignSelf: fullBleedContentChildrenAlign
293
+ })
294
+ }}
295
+ variant={variant}
296
+ withFooter={hasFooter}
297
+ >
298
+ {children}
299
+ </CardContent>
300
+ </ConditionalWrapper>
301
+ ) : null}
302
+ {fullBleedContentPosition !== 'none' && (
303
+ <ConditionalWrapper
304
+ WrapperComponent={DynamicWidthContainer}
305
+ wrapperProps={imageWrapperStyleProps}
306
+ condition={isImageWidthAdjustable}
307
+ >
308
+ <FullBleedContent
309
+ borderRadius={fullBleedBorderRadius}
310
+ {...fullBleedContentPropsClean}
311
+ position={fullBleedContentPosition}
312
+ cardState={cardState}
313
+ />
314
+ </ConditionalWrapper>
315
+ )}
316
+ </StackView>
317
+ )}
318
+ </PressableCardBase>
319
+ </div>
170
320
  ) : null}
171
- {children || fullBleedContentPosition !== 'none' ? (
321
+ {!fullBleedInteractive &&
322
+ !interactiveCard?.body &&
323
+ fullBleedContentPosition === 'none' &&
324
+ children ? (
325
+ <CardContent
326
+ tokens={{
327
+ ...tokens,
328
+ ...(backgroundImage ? { backgroundColor: 'transparent' } : {}),
329
+ ...(fullBleedContentChildrenAlign && {
330
+ alignSelf: fullBleedContentChildrenAlign
331
+ })
332
+ }}
333
+ variant={variant}
334
+ withFooter={hasFooter}
335
+ >
336
+ {children}
337
+ </CardContent>
338
+ ) : null}
339
+ {!fullBleedInteractive && fullBleedContentPosition !== 'none' ? (
172
340
  <StackView
173
341
  direction={contentStackDirection}
174
342
  tokens={{ ...columnFlex, alignItems: contentStackAlign }}
@@ -194,19 +362,18 @@ const Card = React.forwardRef(
194
362
  </CardContent>
195
363
  </ConditionalWrapper>
196
364
  ) : null}
197
- {fullBleedContentPosition !== 'none' && (
198
- <ConditionalWrapper
199
- WrapperComponent={DynamicWidthContainer}
200
- wrapperProps={imageWrapperStyleProps}
201
- condition={isImageWidthAdjustable}
202
- >
203
- <FullBleedContent
204
- borderRadius={fullBleedBorderRadius}
205
- {...fullBleedContentProps}
206
- position={fullBleedContentPosition}
207
- />
208
- </ConditionalWrapper>
209
- )}
365
+ <ConditionalWrapper
366
+ WrapperComponent={DynamicWidthContainer}
367
+ wrapperProps={imageWrapperStyleProps}
368
+ condition={isImageWidthAdjustable}
369
+ >
370
+ <FullBleedContent
371
+ borderRadius={fullBleedBorderRadius}
372
+ {...fullBleedContentPropsClean}
373
+ position={fullBleedContentPosition}
374
+ cardState={undefined}
375
+ />
376
+ </ConditionalWrapper>
210
377
  </StackView>
211
378
  ) : null}
212
379
  {footer && (
@@ -225,6 +392,26 @@ const PositionedFullBleedContentPropType = PropTypes.shape({
225
392
  position: responsiveProps.getTypeOptionallyByViewport(PropTypes.oneOf(positionValues)).isRequired,
226
393
  align: responsiveProps.getTypeOptionallyByViewport(PropTypes.oneOf(alignValues)),
227
394
  contentAlign: responsiveProps.getTypeOptionallyByViewport(PropTypes.oneOf(alignValues)),
395
+ /**
396
+ * Make the full bleed content interactive.
397
+ * When true, the entire card (including the full bleed content) becomes interactive.
398
+ */
399
+ interactive: PropTypes.bool,
400
+ /**
401
+ * Function to call when the full bleed content is pressed.
402
+ * If not provided, falls back to the Card's onPress prop for backward compatibility.
403
+ */
404
+ onPress: PropTypes.func,
405
+ /**
406
+ * URL to navigate to when the full bleed content is pressed.
407
+ * If not provided, falls back to the Card's href prop for backward compatibility.
408
+ */
409
+ href: PropTypes.string,
410
+ /**
411
+ * Additional attributes for the href link.
412
+ * If not provided, falls back to the Card's hrefAttrs prop for backward compatibility.
413
+ */
414
+ hrefAttrs: PropTypes.shape(hrefAttrsProp.types),
228
415
  // eslint-disable-next-line react/forbid-foreign-prop-types
229
416
  ...FullBleedContent.propTypes
230
417
  })
@@ -27,7 +27,8 @@ const CardContentContainer = styled.div(
27
27
  contentFlexShrink: flexShrink,
28
28
  contentJustifyContent: justifyContent,
29
29
  borderWidth,
30
- alignSelf
30
+ alignSelf,
31
+ backgroundColor
31
32
  }) => ({
32
33
  // We need to make sure to have sharp corners on the bottom
33
34
  // if the card has a footer
@@ -45,7 +46,8 @@ const CardContentContainer = styled.div(
45
46
  flexGrow,
46
47
  flexShrink,
47
48
  justifyContent,
48
- alignSelf
49
+ alignSelf,
50
+ backgroundColor
49
51
  })
50
52
  )
51
53
 
@@ -7,7 +7,8 @@ import {
7
7
  useHash,
8
8
  useInputValue,
9
9
  useResponsiveProp,
10
- withLinkRouter
10
+ withLinkRouter,
11
+ variantProp
11
12
  } from '@telus-uds/components-base'
12
13
  import styled from 'styled-components'
13
14
  import { htmlAttrs, scrollToAnchor } from '../utils'
@@ -42,6 +43,7 @@ const NavigationBar = React.forwardRef(
42
43
  LinkRouter,
43
44
  linkRouterProps,
44
45
  tokens,
46
+ variant,
45
47
  ...rest
46
48
  },
47
49
  ref
@@ -220,6 +222,7 @@ const NavigationBar = React.forwardRef(
220
222
  linkRouterProps={{ ...linkRouterProps, ...itemLinkRouterProps }}
221
223
  items={scrollableNestedItems}
222
224
  tokens={tokens}
225
+ variant={variant}
223
226
  selected={itemId === currentValue}
224
227
  itemsContainerRef={itemsRef}
225
228
  {...itemRest}
@@ -295,7 +298,11 @@ NavigationBar.propTypes = {
295
298
  /**
296
299
  * Accesibility role for stackview
297
300
  */
298
- accessibilityRole: PropTypes.string
301
+ accessibilityRole: PropTypes.string,
302
+ /**
303
+ * Variant configuration
304
+ */
305
+ variant: variantProp.propType
299
306
  }
300
307
 
301
308
  export default NavigationBar
@@ -23,6 +23,7 @@ const NavigationSubMenu = React.forwardRef(
23
23
  id,
24
24
  isOpen = false,
25
25
  tokens = {},
26
+ variant = {},
26
27
  label,
27
28
  onClick,
28
29
  selectedId,
@@ -93,6 +94,7 @@ const NavigationSubMenu = React.forwardRef(
93
94
  isReady={isReady}
94
95
  onLayout={onTargetLayout}
95
96
  ref={openOverlayRef}
97
+ variant={variant}
96
98
  >
97
99
  <Listbox
98
100
  items={items}
@@ -101,6 +103,8 @@ const NavigationSubMenu = React.forwardRef(
101
103
  selectedId={selectedId}
102
104
  LinkRouter={LinkRouter}
103
105
  linkRouterProps={linkRouterProps}
106
+ variant={variant}
107
+ onClose={onClick}
104
108
  ref={itemsContainerRef || ref}
105
109
  />
106
110
  </Listbox.Overlay>
@@ -133,7 +137,8 @@ NavigationSubMenu.propTypes = {
133
137
  openOverlayRef: PropTypes.object,
134
138
  LinkRouter: PropTypes.elementType,
135
139
  linkRouterProps: PropTypes.object,
136
- itemsContainerRef: PropTypes.object
140
+ itemsContainerRef: PropTypes.object,
141
+ variant: PropTypes.object
137
142
  }
138
143
 
139
144
  export default NavigationSubMenu
@@ -34,13 +34,18 @@ const FullBleedContentContainer = styled.div(
34
34
  borderBottomLeftRadius,
35
35
  borderBottomRightRadius,
36
36
  borderTopLeftRadius,
37
- borderTopRightRadius
37
+ borderTopRightRadius,
38
+ opacity,
39
+ transform
38
40
  }) => ({
39
41
  overflow: 'hidden',
40
42
  borderBottomLeftRadius,
41
43
  borderBottomRightRadius,
42
44
  borderTopLeftRadius,
43
- borderTopRightRadius
45
+ borderTopRightRadius,
46
+ opacity,
47
+ transform,
48
+ transition: 'opacity 0.2s ease, transform 0.2s ease'
44
49
  })
45
50
  )
46
51
 
@@ -49,11 +54,13 @@ const FullBleedContentContainer = styled.div(
49
54
  * a number of sources corresponding to the `ResponsiveImage` component API,
50
55
  * or a custom component.
51
56
  */
52
- const FullBleedContent = ({ borderRadius, content, ...rest }) => (
53
- <FullBleedContentContainer {...borderRadius}>
54
- {content ?? <ResponsiveImage {...selectFullBleedContentProps(rest)} />}
55
- </FullBleedContentContainer>
56
- )
57
+ const FullBleedContent = ({ borderRadius, content, cardState, ...rest }) => {
58
+ return (
59
+ <FullBleedContentContainer {...borderRadius}>
60
+ {content ?? <ResponsiveImage {...selectFullBleedContentProps(rest)} />}
61
+ </FullBleedContentContainer>
62
+ )
63
+ }
57
64
 
58
65
  FullBleedContent.propTypes = {
59
66
  /**
@@ -69,6 +76,14 @@ FullBleedContent.propTypes = {
69
76
  * Custom JSX to be used for rendering the content (defaults to `ResponsiveImage` receiving other props).
70
77
  */
71
78
  content: PropTypes.node,
79
+ /**
80
+ * Card state object containing interactive states (hovered, pressed, focused).
81
+ */
82
+ cardState: PropTypes.shape({
83
+ hovered: PropTypes.bool,
84
+ pressed: PropTypes.bool,
85
+ focused: PropTypes.bool
86
+ }),
72
87
  /**
73
88
  * Image source.
74
89
  */
@@ -43,6 +43,29 @@ type ListboxTokens = {
43
43
  itemHeight?: number
44
44
  groupHeight?: number
45
45
  lineHeight?: number
46
+ secondLevelHeaderBackgroundColor?: string
47
+ secondLevelHeaderPaddingTop?: number
48
+ secondLevelHeaderPaddingBottom?: number
49
+ secondLevelHeaderPaddingLeft?: number
50
+ secondLevelHeaderPaddingRight?: number
51
+ secondLevelBackIcon?: string
52
+ secondLevelBackIconColor?: string
53
+ secondLevelBackIconSize?: number
54
+ secondLevelBackLinkColor?: string
55
+ secondLevelBackLinkFontSize?: number
56
+ secondLevelBackLinkFontName?: string
57
+ secondLevelBackLinkFontWeight?: string
58
+ secondLevelCloseIcon?: string
59
+ secondLevelCloseIconColor?: string
60
+ secondLevelCloseIconSize?: number
61
+ secondLevelCloseButtonBackgroundColor?: string
62
+ secondLevelCloseButtonBorderColor?: string
63
+ secondLevelCloseButtonBorderWidth?: number | string
64
+ secondLevelCloseButtonBorderRadius?: number | string
65
+ secondLevelCloseButtonPadding?: number
66
+ secondLevelDividerColor?: string
67
+ secondLevelDividerWidth?: number
68
+ secondLevelParentIcon?: string
46
69
  }
47
70
 
48
71
  type ListboxItems = {
@@ -58,6 +81,7 @@ export interface ListboxProps {
58
81
  LinkRouter?: ElementType
59
82
  linkRouterProps?: object
60
83
  tokens?: ListboxTokens
84
+ variant?: { secondLevel?: boolean }
61
85
  selectedId?: string
62
86
  onClose?: () => void
63
87
  }