@telus-uds/components-web 4.14.0 → 4.16.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,34 @@ 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
+ alignSelf
78
+ }) => ({
79
+ width,
80
+ display,
81
+ borderRadius,
82
+ overflow,
83
+ marginTop,
84
+ marginBottom,
85
+ marginLeft,
86
+ marginRight,
87
+ alignSelf
88
+ })
89
+ )
66
90
 
67
91
  const Card = React.forwardRef(
68
92
  (
@@ -84,6 +108,9 @@ const Card = React.forwardRef(
84
108
  },
85
109
  ref
86
110
  ) => {
111
+ const { hrefAttrs: cardLevelHrefAttrs, rest: restWithoutHrefAttrs } = hrefAttrsProp.bundle(rest)
112
+ const { href: cardLevelHref, ...restProps } = restWithoutHrefAttrs
113
+
87
114
  const {
88
115
  contentStackAlign,
89
116
  contentStackDirection,
@@ -92,15 +119,54 @@ const Card = React.forwardRef(
92
119
  fullBleedContentChildrenAlign
93
120
  } = useFullBleedContentProps(fullBleedContent)
94
121
 
95
- const { imgCol } = fullBleedContentProps
122
+ const {
123
+ imgCol,
124
+ interactive: fullBleedInteractive,
125
+ onPress: fullBleedOnPress,
126
+ href: fullBleedHref,
127
+ hrefAttrs: fullBleedHrefAttrs,
128
+ ...fullBleedContentPropsClean
129
+ } = fullBleedContentProps
130
+
131
+ const effectiveFullBleedOnPress = fullBleedOnPress || onPress
132
+ const effectiveFullBleedHref = fullBleedHref || cardLevelHref
133
+ const effectiveFullBleedHrefAttrs = fullBleedHrefAttrs || cardLevelHrefAttrs
96
134
 
97
135
  // If the card has rounded corners and a full bleed image, we need to apply
98
136
  // those corners on the image as well, but partially
99
- const { borderRadius } = useThemeTokens('Card', tokens, variant)
137
+ const allThemeTokens = useThemeTokens('Card', tokens, variant)
138
+ const { borderRadius } = allThemeTokens
139
+
140
+ const cardBaseBorderWidth = allThemeTokens.borderWidth
141
+ const pressableBorderWidth = useThemeTokens(
142
+ 'Card',
143
+ {},
144
+ { ...variant, interactive: true }
145
+ ).borderWidth
146
+ const marginOffset = `${-(cardBaseBorderWidth + pressableBorderWidth) / 2}px`
147
+
100
148
  const getThemeTokens = useThemeTokensCallback('Card', interactiveCard?.tokens, {
101
149
  interactive: true,
102
150
  ...(interactiveCard?.variant || {})
103
151
  })
152
+
153
+ const { backgroundColor: _, ...tokensWithoutBg } = tokens
154
+
155
+ const getFullBleedInteractiveTokens = useThemeTokensCallback('Card', tokensWithoutBg, {
156
+ ...variant,
157
+ interactive: true
158
+ })
159
+
160
+ const getFullBleedInteractiveCardTokens = (cardState) => ({
161
+ ...getFullBleedInteractiveTokens(cardState),
162
+ paddingTop: 0,
163
+ paddingBottom: 0,
164
+ paddingLeft: 0,
165
+ paddingRight: 0,
166
+ // Suppress gradient if interactiveCard.body exists to avoid border duplication
167
+ ...(interactiveCard?.body ? { gradient: undefined } : {})
168
+ })
169
+
104
170
  const hasFooter = Boolean(footer)
105
171
  const fullBleedBorderRadius = getFullBleedBorderRadius(
106
172
  borderRadius,
@@ -112,7 +178,7 @@ const Card = React.forwardRef(
112
178
  // card content will adapt to the size of image to add up to 100% width of card width
113
179
  // pass as props to ConditionalWrapper
114
180
  const imgColCurrentViewport = useResponsiveProp(imgCol)
115
- const maxCol = 12
181
+ const maxCol = GRID_COLUMNS
116
182
  const fullBleedImageWidth = `${(imgColCurrentViewport / maxCol) * 100}%`
117
183
  const adaptiveContentWidth = `${((maxCol - imgColCurrentViewport) / maxCol) * 100}%`
118
184
 
@@ -121,14 +187,10 @@ const Card = React.forwardRef(
121
187
 
122
188
  const contentWrapperStyleProps = {
123
189
  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
190
+ ...(imgColCurrentViewport >= maxCol && { display: 'none' }),
191
+ ...(fullBleedContentChildrenAlign && {
192
+ alignSelf: fullBleedContentChildrenAlign
193
+ })
132
194
  }
133
195
 
134
196
  const columnFlex = {
@@ -137,7 +199,24 @@ const Card = React.forwardRef(
137
199
  justifyContent: 'space-between'
138
200
  }
139
201
 
140
- const { paddingTop, paddingBottom, paddingLeft, paddingRight, ...cardBaseTokens } = tokens
202
+ const cardBaseTokens = Object.fromEntries(
203
+ Object.entries(tokens).filter(
204
+ ([key]) =>
205
+ ![
206
+ 'paddingTop',
207
+ 'paddingBottom',
208
+ 'paddingLeft',
209
+ 'paddingRight',
210
+ ...(backgroundImage ? ['backgroundColor'] : [])
211
+ ].includes(key)
212
+ )
213
+ )
214
+
215
+ const imageWrapperStyleProps = {
216
+ width: fullBleedImageWidth,
217
+ ...(imgColCurrentViewport >= maxCol && { borderRadius, overflow: 'hidden' }),
218
+ ...(imgColCurrentViewport === 0 && { display: 'none' })
219
+ }
141
220
 
142
221
  return (
143
222
  <CardBase
@@ -145,30 +224,125 @@ const Card = React.forwardRef(
145
224
  variant={{ ...variant, padding: 'custom' }}
146
225
  tokens={cardBaseTokens}
147
226
  backgroundImage={backgroundImage}
148
- onPress={onPress}
149
- {...(interactiveCard?.selectionType && { interactiveCard })}
150
- {...selectProps(rest)}
227
+ onPress={fullBleedInteractive ? undefined : onPress}
228
+ {...(interactiveCard?.selectionType && { interactiveCard, id: rest.id })}
229
+ {...selectProps(restProps)}
151
230
  >
231
+ {interactiveCard?.selectionType && children ? (
232
+ <CardContent tokens={tokens} variant={variant} withFooter={hasFooter}>
233
+ {children}
234
+ </CardContent>
235
+ ) : null}
152
236
  {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)}
237
+ <>
238
+ <PressableCardBase
239
+ ref={ref}
240
+ tokens={getThemeTokens}
241
+ dataSet={dataSet}
242
+ onPress={onPress}
243
+ href={interactiveCard?.href}
244
+ hrefAttrs={interactiveCard?.hrefAttrs}
245
+ {...selectProps(restProps)}
246
+ >
247
+ {(cardState) => (
248
+ <>
249
+ {typeof interactiveCard?.body === 'function'
250
+ ? interactiveCard.body(cardState)
251
+ : interactiveCard.body}
252
+ </>
253
+ )}
254
+ </PressableCardBase>
255
+ {children && fullBleedContentPosition === 'none' && !fullBleedInteractive ? (
256
+ <CardContent tokens={tokens} variant={variant} withFooter={hasFooter}>
257
+ {children}
258
+ </CardContent>
259
+ ) : null}
260
+ </>
261
+ ) : null}
262
+ {fullBleedInteractive ? (
263
+ <div
264
+ style={{
265
+ marginTop: marginOffset,
266
+ marginBottom: marginOffset,
267
+ marginLeft: marginOffset,
268
+ marginRight: marginOffset
269
+ }}
161
270
  >
162
- {(cardState) => (
163
- <>
164
- {typeof interactiveCard?.body === 'function'
165
- ? interactiveCard.body(cardState)
166
- : interactiveCard.body}
167
- </>
168
- )}
169
- </PressableCardBase>
271
+ <PressableCardBase
272
+ ref={ref}
273
+ tokens={getFullBleedInteractiveCardTokens}
274
+ dataSet={dataSet}
275
+ onPress={effectiveFullBleedOnPress}
276
+ href={effectiveFullBleedHref}
277
+ hrefAttrs={effectiveFullBleedHrefAttrs}
278
+ {...selectProps(restProps)}
279
+ >
280
+ {(cardState) => (
281
+ <StackView
282
+ direction={contentStackDirection}
283
+ tokens={{ ...columnFlex, alignItems: contentStackAlign }}
284
+ space={0}
285
+ >
286
+ {children ? (
287
+ <ConditionalWrapper
288
+ WrapperComponent={DynamicWidthContainer}
289
+ wrapperProps={contentWrapperStyleProps}
290
+ condition={isImageWidthAdjustable}
291
+ >
292
+ <CardContent
293
+ tokens={{
294
+ ...tokensWithoutBg,
295
+ ...(backgroundImage ? {} : { backgroundColor: 'transparent' }),
296
+ ...(!isImageWidthAdjustable &&
297
+ fullBleedContentChildrenAlign && {
298
+ alignSelf: fullBleedContentChildrenAlign
299
+ })
300
+ }}
301
+ variant={variant}
302
+ withFooter={hasFooter}
303
+ >
304
+ {children}
305
+ </CardContent>
306
+ </ConditionalWrapper>
307
+ ) : null}
308
+ {fullBleedContentPosition !== 'none' && (
309
+ <ConditionalWrapper
310
+ WrapperComponent={DynamicWidthContainer}
311
+ wrapperProps={imageWrapperStyleProps}
312
+ condition={isImageWidthAdjustable}
313
+ >
314
+ <FullBleedContent
315
+ borderRadius={fullBleedBorderRadius}
316
+ {...fullBleedContentPropsClean}
317
+ position={fullBleedContentPosition}
318
+ cardState={cardState}
319
+ />
320
+ </ConditionalWrapper>
321
+ )}
322
+ </StackView>
323
+ )}
324
+ </PressableCardBase>
325
+ </div>
170
326
  ) : null}
171
- {children || fullBleedContentPosition !== 'none' ? (
327
+ {!fullBleedInteractive &&
328
+ !interactiveCard?.body &&
329
+ fullBleedContentPosition === 'none' &&
330
+ children ? (
331
+ <CardContent
332
+ tokens={{
333
+ ...tokens,
334
+ ...(backgroundImage ? { backgroundColor: 'transparent' } : {}),
335
+ ...(fullBleedContentChildrenAlign && {
336
+ alignSelf: fullBleedContentChildrenAlign
337
+ })
338
+ }}
339
+ variant={variant}
340
+ withFooter={hasFooter}
341
+ >
342
+ {children}
343
+ </CardContent>
344
+ ) : null}
345
+ {!fullBleedInteractive && fullBleedContentPosition !== 'none' ? (
172
346
  <StackView
173
347
  direction={contentStackDirection}
174
348
  tokens={{ ...columnFlex, alignItems: contentStackAlign }}
@@ -183,9 +357,10 @@ const Card = React.forwardRef(
183
357
  <CardContent
184
358
  tokens={{
185
359
  ...tokens,
186
- ...(fullBleedContentChildrenAlign && {
187
- alignSelf: fullBleedContentChildrenAlign
188
- })
360
+ ...(!isImageWidthAdjustable &&
361
+ fullBleedContentChildrenAlign && {
362
+ alignSelf: fullBleedContentChildrenAlign
363
+ })
189
364
  }}
190
365
  variant={variant}
191
366
  withFooter={hasFooter}
@@ -194,19 +369,18 @@ const Card = React.forwardRef(
194
369
  </CardContent>
195
370
  </ConditionalWrapper>
196
371
  ) : 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
- )}
372
+ <ConditionalWrapper
373
+ WrapperComponent={DynamicWidthContainer}
374
+ wrapperProps={imageWrapperStyleProps}
375
+ condition={isImageWidthAdjustable}
376
+ >
377
+ <FullBleedContent
378
+ borderRadius={fullBleedBorderRadius}
379
+ {...fullBleedContentPropsClean}
380
+ position={fullBleedContentPosition}
381
+ cardState={undefined}
382
+ />
383
+ </ConditionalWrapper>
210
384
  </StackView>
211
385
  ) : null}
212
386
  {footer && (
@@ -225,6 +399,26 @@ const PositionedFullBleedContentPropType = PropTypes.shape({
225
399
  position: responsiveProps.getTypeOptionallyByViewport(PropTypes.oneOf(positionValues)).isRequired,
226
400
  align: responsiveProps.getTypeOptionallyByViewport(PropTypes.oneOf(alignValues)),
227
401
  contentAlign: responsiveProps.getTypeOptionallyByViewport(PropTypes.oneOf(alignValues)),
402
+ /**
403
+ * Make the full bleed content interactive.
404
+ * When true, the entire card (including the full bleed content) becomes interactive.
405
+ */
406
+ interactive: PropTypes.bool,
407
+ /**
408
+ * Function to call when the full bleed content is pressed.
409
+ * If not provided, falls back to the Card's onPress prop for backward compatibility.
410
+ */
411
+ onPress: PropTypes.func,
412
+ /**
413
+ * URL to navigate to when the full bleed content is pressed.
414
+ * If not provided, falls back to the Card's href prop for backward compatibility.
415
+ */
416
+ href: PropTypes.string,
417
+ /**
418
+ * Additional attributes for the href link.
419
+ * If not provided, falls back to the Card's hrefAttrs prop for backward compatibility.
420
+ */
421
+ hrefAttrs: PropTypes.shape(hrefAttrsProp.types),
228
422
  // eslint-disable-next-line react/forbid-foreign-prop-types
229
423
  ...FullBleedContent.propTypes
230
424
  })
@@ -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,7 @@ const NavigationSubMenu = React.forwardRef(
101
103
  selectedId={selectedId}
102
104
  LinkRouter={LinkRouter}
103
105
  linkRouterProps={linkRouterProps}
106
+ variant={variant}
104
107
  ref={itemsContainerRef || ref}
105
108
  />
106
109
  </Listbox.Overlay>
@@ -133,7 +136,8 @@ NavigationSubMenu.propTypes = {
133
136
  openOverlayRef: PropTypes.object,
134
137
  LinkRouter: PropTypes.elementType,
135
138
  linkRouterProps: PropTypes.object,
136
- itemsContainerRef: PropTypes.object
139
+ itemsContainerRef: PropTypes.object,
140
+ variant: PropTypes.object
137
141
  }
138
142
 
139
143
  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
  */
@@ -55,7 +55,7 @@ const useFullBleedContentProps = (fullBleedContent) => {
55
55
 
56
56
  const fullBleedContentChildrenAlign = useResponsiveProp(
57
57
  fullBleedContentChildrenAlignProp,
58
- 'stretch'
58
+ fullBleedContentAlign
59
59
  )
60
60
 
61
61
  return {
@@ -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
  }