@telus-uds/components-web 4.16.0 → 4.18.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
@@ -30,6 +30,56 @@ const [selectProps, selectedSystemPropTypes] = selectSystemProps([a11yProps, vie
30
30
 
31
31
  const GRID_COLUMNS = 12
32
32
 
33
+ const POSITION = {
34
+ LEFT: 'left',
35
+ RIGHT: 'right',
36
+ TOP: 'top',
37
+ BOTTOM: 'bottom',
38
+ NONE: 'none'
39
+ }
40
+
41
+ /**
42
+ * Helper function to get StackView tokens
43
+ * @param {Object} columnFlex - Column flex properties
44
+ * @param {string} contentStackAlign - Alignment value for content stack
45
+ * @returns {Object} StackView tokens object
46
+ */
47
+ const getStackViewTokens = (columnFlex, contentStackAlign) => ({
48
+ ...columnFlex,
49
+ alignItems: contentStackAlign
50
+ })
51
+
52
+ /**
53
+ * Helper function to get CardContent tokens
54
+ * @param {Object} baseTokens - Base tokens to spread
55
+ * @param {Object} options - Options object
56
+ * @param {boolean} options.backgroundImage - Whether background image is present
57
+ * @param {string} options.fullBleedContentChildrenAlign - Alignment for full bleed content children
58
+ * @param {boolean} options.useTransparentBackground - Whether to use transparent background when no backgroundImage
59
+ * @returns {Object} CardContent tokens object
60
+ */
61
+ const getCardContentTokens = (baseTokens, options = {}) => {
62
+ const { backgroundImage, fullBleedContentChildrenAlign, useTransparentBackground } = options
63
+
64
+ // Determine background color based on conditions
65
+ let backgroundColorOverride = {}
66
+ if (useTransparentBackground) {
67
+ if (!backgroundImage) {
68
+ backgroundColorOverride = { backgroundColor: 'transparent' }
69
+ }
70
+ } else if (backgroundImage) {
71
+ backgroundColorOverride = { backgroundColor: 'transparent' }
72
+ }
73
+
74
+ return {
75
+ ...baseTokens,
76
+ ...backgroundColorOverride,
77
+ ...(fullBleedContentChildrenAlign && {
78
+ alignSelf: fullBleedContentChildrenAlign
79
+ })
80
+ }
81
+ }
82
+
33
83
  /**
34
84
  * A basic card component, unstyled by default.
35
85
  *
@@ -88,6 +138,40 @@ const DynamicWidthContainer = styled.div.withConfig({
88
138
  })
89
139
  )
90
140
 
141
+ const InteractiveCardWrapper = styled.div(() => ({
142
+ position: 'relative',
143
+ flex: 1,
144
+ display: 'flex',
145
+ flexDirection: 'column'
146
+ }))
147
+
148
+ const InteractiveOverlay = styled.div(({ overlayOpacity, borderRadius }) => ({
149
+ position: 'absolute',
150
+ top: 0,
151
+ left: 0,
152
+ right: 0,
153
+ bottom: 0,
154
+ backgroundColor: `rgba(0, 0, 0, ${overlayOpacity || 0})`,
155
+ borderRadius,
156
+ pointerEvents: 'none',
157
+ transition: 'background-color 0.2s ease',
158
+ zIndex: 1
159
+ }))
160
+
161
+ const FocusBorder = styled.div(({ borderWidth, borderColor, borderRadius }) => ({
162
+ position: 'absolute',
163
+ top: 0,
164
+ left: 0,
165
+ right: 0,
166
+ bottom: 0,
167
+ borderWidth,
168
+ borderColor,
169
+ borderRadius,
170
+ borderStyle: 'solid',
171
+ pointerEvents: 'none',
172
+ zIndex: 2
173
+ }))
174
+
91
175
  const Card = React.forwardRef(
92
176
  (
93
177
  {
@@ -137,19 +221,61 @@ const Card = React.forwardRef(
137
221
  const allThemeTokens = useThemeTokens('Card', tokens, variant)
138
222
  const { borderRadius } = allThemeTokens
139
223
 
140
- const cardBaseBorderWidth = allThemeTokens.borderWidth
141
- const pressableBorderWidth = useThemeTokens(
224
+ // Interactive cards: merge variants for CardBase (outer container)
225
+ // The outer variant takes priority over interactiveCard.variant for the style property
226
+ // This ensures the gradient is only applied to CardBase, not PressableCardBase and avoid duplication
227
+ const interactiveStyle = interactiveCard?.variant?.style
228
+ const outerStyle = variant?.style
229
+ const mergedVariant = {
230
+ ...variant,
231
+ style: outerStyle || interactiveStyle
232
+ }
233
+
234
+ // Interactive cards: build configuration for PressableCardBase
235
+ // This determines which style to use for interactive states (hover, pressed, etc.)
236
+ // without causing gradient duplication
237
+ let interactiveCardConfig = {}
238
+ if (interactiveCard?.body) {
239
+ const styleToUse = interactiveCard?.variant?.style || variant?.style
240
+ const { style, ...otherVariantProps } = interactiveCard?.variant || {}
241
+
242
+ interactiveCardConfig = {
243
+ interactive: true,
244
+ ...(styleToUse && { style: styleToUse }),
245
+ ...otherVariantProps
246
+ }
247
+ }
248
+
249
+ const getThemeTokensBase = useThemeTokensCallback(
142
250
  'Card',
143
- {},
144
- { ...variant, interactive: true }
145
- ).borderWidth
146
- const marginOffset = `${-(cardBaseBorderWidth + pressableBorderWidth) / 2}px`
251
+ interactiveCard?.tokens,
252
+ interactiveCardConfig
253
+ )
147
254
 
148
- const getThemeTokens = useThemeTokensCallback('Card', interactiveCard?.tokens, {
149
- interactive: true,
150
- ...(interactiveCard?.variant || {})
151
- })
255
+ // Wrap getThemeTokens to remove gradient from resolved tokens
256
+ // PressableCardBase calls this function with pressableState (hover, pressed, etc.)
257
+ // We intercept the resolved tokens and remove gradient properties to prevent duplication
258
+ // since the gradient should only appear on CardBase (outer container)
259
+ const getThemeTokens = React.useCallback(
260
+ (pressableState) => {
261
+ const resolvedTokens = getThemeTokensBase(pressableState)
262
+ const { gradient, backgroundGradient, ...tokensWithoutGradient } = resolvedTokens
263
+ return tokensWithoutGradient
264
+ },
265
+ [getThemeTokensBase]
266
+ )
267
+
268
+ const getFocusBorderTokens = useThemeTokensCallback(
269
+ 'Card',
270
+ {},
271
+ {
272
+ ...variant,
273
+ interactive: true
274
+ }
275
+ )
152
276
 
277
+ // Remove backgroundColor from tokens for full bleed interactive content
278
+ // to prevent background from covering the full bleed image
153
279
  const { backgroundColor: _, ...tokensWithoutBg } = tokens
154
280
 
155
281
  const getFullBleedInteractiveTokens = useThemeTokensCallback('Card', tokensWithoutBg, {
@@ -157,15 +283,17 @@ const Card = React.forwardRef(
157
283
  interactive: true
158
284
  })
159
285
 
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
- })
286
+ const getFullBleedInteractiveCardTokens = (cardState) => {
287
+ return {
288
+ ...getFullBleedInteractiveTokens(cardState),
289
+ paddingTop: 0,
290
+ paddingBottom: 0,
291
+ paddingLeft: 0,
292
+ paddingRight: 0,
293
+ borderWidth: 0,
294
+ ...(interactiveCard?.body ? { gradient: undefined } : {})
295
+ }
296
+ }
169
297
 
170
298
  const hasFooter = Boolean(footer)
171
299
  const fullBleedBorderRadius = getFullBleedBorderRadius(
@@ -179,15 +307,23 @@ const Card = React.forwardRef(
179
307
  // pass as props to ConditionalWrapper
180
308
  const imgColCurrentViewport = useResponsiveProp(imgCol)
181
309
  const maxCol = GRID_COLUMNS
182
- const fullBleedImageWidth = `${(imgColCurrentViewport / maxCol) * 100}%`
183
- const adaptiveContentWidth = `${((maxCol - imgColCurrentViewport) / maxCol) * 100}%`
310
+
311
+ const hasValidImgCol =
312
+ imgCol && imgColCurrentViewport !== undefined && !Number.isNaN(imgColCurrentViewport)
313
+ const fullBleedImageWidth = hasValidImgCol
314
+ ? `${(imgColCurrentViewport / maxCol) * 100}%`
315
+ : undefined
316
+ const adaptiveContentWidth = hasValidImgCol
317
+ ? `${((maxCol - imgColCurrentViewport) / maxCol) * 100}%`
318
+ : undefined
184
319
 
185
320
  const isImageWidthAdjustable =
186
- imgCol && (fullBleedContentPosition === 'left' || fullBleedContentPosition === 'right')
321
+ hasValidImgCol &&
322
+ (fullBleedContentPosition === POSITION.LEFT || fullBleedContentPosition === POSITION.RIGHT)
187
323
 
188
324
  const contentWrapperStyleProps = {
189
- width: adaptiveContentWidth,
190
- ...(imgColCurrentViewport >= maxCol && { display: 'none' }),
325
+ ...(hasValidImgCol && { width: adaptiveContentWidth }),
326
+ ...(hasValidImgCol && imgColCurrentViewport >= maxCol && { display: 'none' }),
191
327
  ...(fullBleedContentChildrenAlign && {
192
328
  alignSelf: fullBleedContentChildrenAlign
193
329
  })
@@ -207,23 +343,29 @@ const Card = React.forwardRef(
207
343
  'paddingBottom',
208
344
  'paddingLeft',
209
345
  'paddingRight',
210
- ...(backgroundImage ? ['backgroundColor'] : [])
346
+ ...(backgroundImage && interactiveCard?.body ? ['backgroundColor'] : [])
211
347
  ].includes(key)
212
348
  )
213
349
  )
214
350
 
351
+ const isHorizontalFullBleed =
352
+ fullBleedContentPosition === POSITION.LEFT || fullBleedContentPosition === POSITION.RIGHT
353
+ const isVerticalFullBleed =
354
+ fullBleedContentPosition === POSITION.TOP || fullBleedContentPosition === POSITION.BOTTOM
355
+
215
356
  const imageWrapperStyleProps = {
216
- width: fullBleedImageWidth,
217
- ...(imgColCurrentViewport >= maxCol && { borderRadius, overflow: 'hidden' }),
218
- ...(imgColCurrentViewport === 0 && { display: 'none' })
357
+ ...(isImageWidthAdjustable && { width: fullBleedImageWidth }),
358
+ ...(isImageWidthAdjustable &&
359
+ imgColCurrentViewport >= maxCol && { borderRadius, overflow: 'hidden' }),
360
+ ...(isImageWidthAdjustable && imgColCurrentViewport === 0 && { display: 'none' })
219
361
  }
220
362
 
221
363
  return (
222
364
  <CardBase
223
365
  ref={ref}
224
- variant={{ ...variant, padding: 'custom' }}
366
+ variant={{ ...(interactiveCard?.body ? mergedVariant : variant), padding: 'custom' }}
225
367
  tokens={cardBaseTokens}
226
- backgroundImage={backgroundImage}
368
+ backgroundImage={!interactiveCard?.body && backgroundImage}
227
369
  onPress={fullBleedInteractive ? undefined : onPress}
228
370
  {...(interactiveCard?.selectionType && { interactiveCard, id: rest.id })}
229
371
  {...selectProps(restProps)}
@@ -239,6 +381,7 @@ const Card = React.forwardRef(
239
381
  ref={ref}
240
382
  tokens={getThemeTokens}
241
383
  dataSet={dataSet}
384
+ backgroundImage={backgroundImage}
242
385
  onPress={onPress}
243
386
  href={interactiveCard?.href}
244
387
  hrefAttrs={interactiveCard?.hrefAttrs}
@@ -252,7 +395,7 @@ const Card = React.forwardRef(
252
395
  </>
253
396
  )}
254
397
  </PressableCardBase>
255
- {children && fullBleedContentPosition === 'none' && !fullBleedInteractive ? (
398
+ {children && fullBleedContentPosition === POSITION.NONE && !fullBleedInteractive ? (
256
399
  <CardContent tokens={tokens} variant={variant} withFooter={hasFooter}>
257
400
  {children}
258
401
  </CardContent>
@@ -260,92 +403,119 @@ const Card = React.forwardRef(
260
403
  </>
261
404
  ) : null}
262
405
  {fullBleedInteractive ? (
263
- <div
264
- style={{
265
- marginTop: marginOffset,
266
- marginBottom: marginOffset,
267
- marginLeft: marginOffset,
268
- marginRight: marginOffset
269
- }}
406
+ <PressableCardBase
407
+ ref={ref}
408
+ tokens={getFullBleedInteractiveCardTokens}
409
+ dataSet={dataSet}
410
+ onPress={effectiveFullBleedOnPress}
411
+ href={effectiveFullBleedHref}
412
+ hrefAttrs={effectiveFullBleedHrefAttrs}
413
+ {...selectProps(restProps)}
270
414
  >
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}
415
+ {(cardState) => {
416
+ let overlayOpacity = 0
417
+ if (cardState.pressed) {
418
+ overlayOpacity = 0.2
419
+ } else if (cardState.hover) {
420
+ overlayOpacity = 0.1
421
+ }
422
+
423
+ const focusTokens = getFocusBorderTokens(cardState)
424
+ const showFocusBorder = cardState.focus && focusTokens.borderWidth > 0
425
+
426
+ return (
427
+ <InteractiveCardWrapper>
428
+ <StackView
429
+ direction={contentStackDirection}
430
+ tokens={getStackViewTokens(columnFlex, contentStackAlign)}
431
+ space={0}
432
+ >
433
+ {children ? (
434
+ <ConditionalWrapper
435
+ WrapperComponent={DynamicWidthContainer}
436
+ wrapperProps={contentWrapperStyleProps}
437
+ condition={isImageWidthAdjustable}
438
+ >
439
+ <CardContent
440
+ tokens={{
441
+ ...getCardContentTokens(tokensWithoutBg, {
442
+ backgroundImage,
443
+ fullBleedContentChildrenAlign,
444
+ useTransparentBackground: true
445
+ }),
446
+ paddingTop:
447
+ tokens.paddingTop !== undefined
448
+ ? tokens.paddingTop
449
+ : allThemeTokens.paddingTop,
450
+ paddingBottom:
451
+ tokens.paddingBottom !== undefined
452
+ ? tokens.paddingBottom
453
+ : allThemeTokens.paddingBottom,
454
+ paddingLeft:
455
+ tokens.paddingLeft !== undefined
456
+ ? tokens.paddingLeft
457
+ : allThemeTokens.paddingLeft,
458
+ paddingRight:
459
+ tokens.paddingRight !== undefined
460
+ ? tokens.paddingRight
461
+ : allThemeTokens.paddingRight
462
+ }}
463
+ variant={variant}
464
+ withFooter={hasFooter}
465
+ >
466
+ {children}
467
+ </CardContent>
468
+ </ConditionalWrapper>
469
+ ) : null}
470
+ {fullBleedContentPosition !== POSITION.NONE && (
471
+ <ConditionalWrapper
472
+ WrapperComponent={DynamicWidthContainer}
473
+ wrapperProps={imageWrapperStyleProps}
474
+ condition={
475
+ isImageWidthAdjustable || isHorizontalFullBleed || isVerticalFullBleed
476
+ }
303
477
  >
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>
478
+ <FullBleedContent
479
+ borderRadius={fullBleedBorderRadius}
480
+ {...fullBleedContentPropsClean}
481
+ position={fullBleedContentPosition}
482
+ cardState={undefined}
483
+ />
484
+ </ConditionalWrapper>
485
+ )}
486
+ </StackView>
487
+ <InteractiveOverlay overlayOpacity={overlayOpacity} borderRadius={borderRadius} />
488
+ {showFocusBorder && (
489
+ <FocusBorder
490
+ borderWidth={`${focusTokens.borderWidth}px`}
491
+ borderColor={focusTokens.borderColor}
492
+ borderRadius={borderRadius}
493
+ />
321
494
  )}
322
- </StackView>
323
- )}
324
- </PressableCardBase>
325
- </div>
495
+ </InteractiveCardWrapper>
496
+ )
497
+ }}
498
+ </PressableCardBase>
326
499
  ) : null}
327
500
  {!fullBleedInteractive &&
328
501
  !interactiveCard?.body &&
329
- fullBleedContentPosition === 'none' &&
502
+ fullBleedContentPosition === POSITION.NONE &&
330
503
  children ? (
331
504
  <CardContent
332
- tokens={{
333
- ...tokens,
334
- ...(backgroundImage ? { backgroundColor: 'transparent' } : {}),
335
- ...(fullBleedContentChildrenAlign && {
336
- alignSelf: fullBleedContentChildrenAlign
337
- })
338
- }}
505
+ tokens={getCardContentTokens(tokens, {
506
+ backgroundImage,
507
+ fullBleedContentChildrenAlign
508
+ })}
339
509
  variant={variant}
340
510
  withFooter={hasFooter}
341
511
  >
342
512
  {children}
343
513
  </CardContent>
344
514
  ) : null}
345
- {!fullBleedInteractive && fullBleedContentPosition !== 'none' ? (
515
+ {!fullBleedInteractive && fullBleedContentPosition !== POSITION.NONE ? (
346
516
  <StackView
347
517
  direction={contentStackDirection}
348
- tokens={{ ...columnFlex, alignItems: contentStackAlign }}
518
+ tokens={getStackViewTokens(columnFlex, contentStackAlign)}
349
519
  space={0}
350
520
  >
351
521
  {children ? (
@@ -355,13 +525,9 @@ const Card = React.forwardRef(
355
525
  condition={isImageWidthAdjustable}
356
526
  >
357
527
  <CardContent
358
- tokens={{
359
- ...tokens,
360
- ...(!isImageWidthAdjustable &&
361
- fullBleedContentChildrenAlign && {
362
- alignSelf: fullBleedContentChildrenAlign
363
- })
364
- }}
528
+ tokens={getCardContentTokens(tokens, {
529
+ fullBleedContentChildrenAlign
530
+ })}
365
531
  variant={variant}
366
532
  withFooter={hasFooter}
367
533
  >
@@ -372,7 +538,7 @@ const Card = React.forwardRef(
372
538
  <ConditionalWrapper
373
539
  WrapperComponent={DynamicWidthContainer}
374
540
  wrapperProps={imageWrapperStyleProps}
375
- condition={isImageWidthAdjustable}
541
+ condition={isImageWidthAdjustable || isHorizontalFullBleed || isVerticalFullBleed}
376
542
  >
377
543
  <FullBleedContent
378
544
  borderRadius={fullBleedBorderRadius}
@@ -393,7 +559,7 @@ const Card = React.forwardRef(
393
559
  }
394
560
  )
395
561
 
396
- const positionValues = ['none', 'bottom', 'left', 'right', 'top']
562
+ const positionValues = Object.values(POSITION)
397
563
  const alignValues = ['start', 'end', 'center', 'stretch']
398
564
  const PositionedFullBleedContentPropType = PropTypes.shape({
399
565
  position: responsiveProps.getTypeOptionallyByViewport(PropTypes.oneOf(positionValues)).isRequired,
@@ -8,7 +8,9 @@ import {
8
8
  useInputValue,
9
9
  useResponsiveProp,
10
10
  withLinkRouter,
11
- variantProp
11
+ variantProp,
12
+ resolveContentMaxWidth,
13
+ useTheme
12
14
  } from '@telus-uds/components-base'
13
15
  import styled from 'styled-components'
14
16
  import { htmlAttrs, scrollToAnchor } from '../utils'
@@ -26,6 +28,15 @@ const Heading = styled.div({
26
28
  '> *': { display: 'contents', letterSpacing: 0 }
27
29
  })
28
30
 
31
+ const ContentWrapper = styled.div(({ maxWidth }) => ({
32
+ width: '100%',
33
+ ...(maxWidth && {
34
+ maxWidth,
35
+ marginLeft: 'auto',
36
+ marginRight: 'auto'
37
+ })
38
+ }))
39
+
29
40
  /**
30
41
  * NavigationBar can be used to allow customers to consistently navigate across
31
42
  * key pages within a specific product line
@@ -44,11 +55,17 @@ const NavigationBar = React.forwardRef(
44
55
  linkRouterProps,
45
56
  tokens,
46
57
  variant,
58
+ contentMaxWidth,
47
59
  ...rest
48
60
  },
49
61
  ref
50
62
  ) => {
51
63
  const { currentValue, setValue } = useInputValue({ value, initialValue: selectedId, onChange })
64
+ const { themeOptions } = useTheme()
65
+
66
+ const contentWidthValue = useResponsiveProp(contentMaxWidth)
67
+ const responsiveWidth = useResponsiveProp(themeOptions?.contentMaxWidth)
68
+ const maxWidth = resolveContentMaxWidth(contentWidthValue, responsiveWidth)
52
69
 
53
70
  useHash(
54
71
  (hash, event) => {
@@ -149,7 +166,7 @@ const NavigationBar = React.forwardRef(
149
166
  }
150
167
  }, [openSubMenuId, handleMouseDown])
151
168
 
152
- return (
169
+ const stackView = (
153
170
  <StackView
154
171
  accessibilityRole={accessibilityRole}
155
172
  direction={direction}
@@ -215,7 +232,6 @@ const NavigationBar = React.forwardRef(
215
232
  key={itemId}
216
233
  href={href}
217
234
  onClick={handleClick}
218
- // TODO: refactor to pass selected ID via context
219
235
  selectedId={currentValue}
220
236
  index={index}
221
237
  LinkRouter={ItemLinkRouter}
@@ -236,6 +252,8 @@ const NavigationBar = React.forwardRef(
236
252
  )}
237
253
  </StackView>
238
254
  )
255
+
256
+ return maxWidth ? <ContentWrapper maxWidth={maxWidth}>{stackView}</ContentWrapper> : stackView
239
257
  }
240
258
  )
241
259
 
@@ -302,7 +320,24 @@ NavigationBar.propTypes = {
302
320
  /**
303
321
  * Variant configuration
304
322
  */
305
- variant: variantProp.propType
323
+ variant: variantProp.propType,
324
+ /**
325
+ * The maximum width of the content in the NavigationBar.
326
+ * This prop accepts responsive values for different viewports. If a number is provided,
327
+ * it will be the max content width for the desired viewport.
328
+ * - `xs`: 'max' | 'full' | <number>
329
+ * - `sm`: 'max' | 'full' | <number>
330
+ * - `md`: 'max' | 'full' | <number>
331
+ * - `lg`: 'max' | 'full' | <number>
332
+ * - `xl`: 'max' | 'full' | <number>
333
+ */
334
+ contentMaxWidth: PropTypes.shape({
335
+ xl: PropTypes.oneOfType([PropTypes.oneOf(['max', 'full']), PropTypes.number]),
336
+ lg: PropTypes.oneOfType([PropTypes.oneOf(['max', 'full']), PropTypes.number]),
337
+ md: PropTypes.oneOfType([PropTypes.oneOf(['max', 'full']), PropTypes.number]),
338
+ sm: PropTypes.oneOfType([PropTypes.oneOf(['max', 'full']), PropTypes.number]),
339
+ xs: PropTypes.oneOfType([PropTypes.oneOf(['max', 'full']), PropTypes.number])
340
+ })
306
341
  }
307
342
 
308
343
  export default NavigationBar
@@ -11,6 +11,34 @@ import NavigationItem from './NavigationItem'
11
11
  import useOverlaidPosition from '../utils/useOverlaidPosition'
12
12
  import resolveItemSelection from './resolveItemSelection'
13
13
 
14
+ const MIN_WIDTH = 192
15
+ const MAX_WIDTH = 289
16
+ const DEFAULT_OFFSETS = { offsets: { vertical: 4 } }
17
+ const XS_ALIGN = { top: 'bottom', left: 'left' }
18
+ const SM_ALIGN = { top: 'bottom', right: 'right' }
19
+ const LG_ALIGN = { top: 'bottom', center: 'center' }
20
+
21
+ const getResponsiveBreakpoints = (sourceWidth) => ({
22
+ xs: {
23
+ ...DEFAULT_OFFSETS,
24
+ align: XS_ALIGN,
25
+ minWidth: sourceWidth,
26
+ maxWidth: sourceWidth
27
+ },
28
+ sm: {
29
+ ...DEFAULT_OFFSETS,
30
+ align: SM_ALIGN,
31
+ minWidth: sourceWidth,
32
+ maxWidth: sourceWidth
33
+ },
34
+ lg: {
35
+ ...DEFAULT_OFFSETS,
36
+ align: LG_ALIGN,
37
+ minWidth: MIN_WIDTH,
38
+ maxWidth: MAX_WIDTH
39
+ }
40
+ })
41
+
14
42
  /**
15
43
  * A NavigationItem that opens or closes a Listbox of other NavigationItems.
16
44
  *
@@ -36,18 +64,11 @@ const NavigationSubMenu = React.forwardRef(
36
64
  ref
37
65
  ) => {
38
66
  const focusTrapRef = React.useRef()
67
+ const [sourceWidth, setSourceWidth] = React.useState(0)
39
68
 
40
- const maxWidth = 289 // Slightly over 288 of nav item to account for subpixel rounding
41
- const defaultOffsets = { offsets: { vertical: 4 } }
42
- const { align, offsets, minWidth } = useResponsiveProp({
43
- xs: { ...defaultOffsets, align: { top: 'bottom', left: 'left' }, minWidth: maxWidth },
44
- sm: { ...defaultOffsets, align: { top: 'bottom', right: 'right' }, minWidth: maxWidth },
45
- lg: {
46
- ...defaultOffsets,
47
- align: { top: 'bottom', center: 'center' },
48
- minWidth: 192
49
- }
50
- })
69
+ const { align, offsets, minWidth, maxWidth } = useResponsiveProp(
70
+ getResponsiveBreakpoints(sourceWidth)
71
+ )
51
72
 
52
73
  const { overlaidPosition, sourceRef, targetRef, onTargetLayout, isReady } = useOverlaidPosition(
53
74
  {
@@ -64,6 +85,12 @@ const NavigationSubMenu = React.forwardRef(
64
85
 
65
86
  const { icoMenu } = useThemeTokens('NavigationBar', tokens, {}, { expanded: isOpen })
66
87
 
88
+ React.useEffect(() => {
89
+ sourceRef.current?.measureInWindow((_, __, width) => {
90
+ setSourceWidth(width)
91
+ })
92
+ }, [isOpen, sourceRef])
93
+
67
94
  return (
68
95
  <>
69
96
  <NavigationItem
@@ -7,7 +7,7 @@ const collapseItems = (items, selectedId) => {
7
7
  // Give the root item the label of the current active link
8
8
  // (or the first item if for some reason there's no match on the selectedId)
9
9
  let rootLabel = items[0].label
10
- const isSelected = ({ label, id }) => selectedId === id ?? label
10
+ const isSelected = ({ label, id }) => selectedId === (id ?? label)
11
11
 
12
12
  // Linter doesn't like for loops, simulate loop that breaks
13
13
  items.some((item) => {