Skip to content

Commit 0da8645

Browse files
committed
fix: Implement TopNavigation breakpoints in CSS
This commit switches from JavaScript-based breakpoints to CSS container queries for TopNavigation, making its appearance consistent when rendered with SSR. To support container queries, we need `container-type: inline-size;` on the TopNavigation root element. This causes overflowing content to get clipped (at least in Safari), so I've enabled `expandToViewport` to render utility dropdowns in a portal. Note that OverflowMenu doesn't need this treatment because it expands the height of TopNavigation rather than overflowing. Fixes cloudscape-design#3337
1 parent 0a681b8 commit 0da8645

File tree

8 files changed

+130
-117
lines changed

8 files changed

+130
-117
lines changed

src/internal/components/menu-dropdown/index.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export const ButtonTrigger = React.forwardRef(
3131
expanded,
3232
children,
3333
onClick,
34+
// Used by TopNavigation to apply styles.
35+
className,
3436
}: ButtonTriggerProps,
3537
ref: React.Ref<any>
3638
) => {
@@ -40,7 +42,7 @@ export const ButtonTrigger = React.forwardRef(
4042
<button
4143
ref={ref}
4244
type="button"
43-
className={clsx(styles.button, styles[`offset-right-${offsetRight}`], testUtilsClass, {
45+
className={clsx(styles.button, styles[`offset-right-${offsetRight}`], testUtilsClass, className, {
4446
[styles.expanded]: expanded,
4547
})}
4648
aria-label={ariaLabel}
@@ -49,7 +51,7 @@ export const ButtonTrigger = React.forwardRef(
4951
disabled={disabled}
5052
onClick={event => {
5153
event.preventDefault();
52-
onClick && onClick();
54+
onClick?.();
5355
}}
5456
>
5557
{hasIcon && (
@@ -79,6 +81,7 @@ const MenuDropdown = ({
7981
badge,
8082
offsetRight,
8183
children,
84+
className,
8285
...props
8386
}: MenuDropdownProps) => {
8487
const baseProps = getBaseProps(props);
@@ -94,6 +97,7 @@ const MenuDropdown = ({
9497
return (
9598
<ButtonTrigger
9699
testUtilsClass={testUtilsClass}
100+
className={className}
97101
ref={triggerRef}
98102
disabled={disabled}
99103
expanded={isOpen}

src/internal/components/menu-dropdown/interfaces.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { IconProps } from '../../../icon/interfaces';
55

66
export interface ButtonTriggerProps {
77
testUtilsClass?: string;
8+
className?: string;
89
iconName?: IconProps.Name;
910
iconUrl?: string;
1011
iconAlt?: string;
@@ -19,12 +20,14 @@ export interface ButtonTriggerProps {
1920
expanded?: boolean;
2021
}
2122

22-
export interface MenuDropdownProps extends InternalButtonDropdownProps {
23+
export interface MenuDropdownProps extends Omit<InternalButtonDropdownProps, 'items'> {
24+
items: InternalButtonDropdownProps['items'];
2325
iconName?: IconProps.Name;
2426
iconUrl?: string;
2527
iconAlt?: string;
2628
iconSvg?: React.ReactNode;
2729
badge?: boolean;
2830
description?: string;
2931
offsetRight?: 'l' | 'xxl';
32+
className?: string;
3033
}

src/internal/styles/foundation/breakpoints.scss

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
// Breakpoints
6+
// Media query breakpoints
77
$breakpoint-xxx-small: 0;
88
$breakpoint-xx-small: 576px;
99
$breakpoint-x-small: 688px;
@@ -16,6 +16,18 @@ $breakpoint-xx-large: 2540px;
1616
$_smallest_breakpoint: $breakpoint-xxx-small;
1717
$_largest_breakpoint: $breakpoint-x-large;
1818

19+
// Container breakpoints, matching the Grid component
20+
$container-breakpoint-default: -1;
21+
$container-breakpoint-xxs: 465px;
22+
$container-breakpoint-xs: 688px;
23+
$container-breakpoint-s: 912px;
24+
$container-breakpoint-m: 1120px;
25+
$container-breakpoint-l: 1320px;
26+
$container-breakpoint-xl: 1840px;
27+
28+
$_container_smallest_breakpoint: $container-breakpoint-default;
29+
$_container_largest_breakpoint: $container-breakpoint-xl;
30+
1931
// Media of at least the minimum breakpoint width. No query for the smallest breakpoint.
2032
// Makes the @content apply to the wider than given breakpoint.
2133
@mixin media-breakpoint-up($breakpoint) {
@@ -39,3 +51,27 @@ $_largest_breakpoint: $breakpoint-x-large;
3951
@content;
4052
}
4153
}
54+
55+
// Container query for widths greater than the given breakpoint.
56+
// Matches the behavior of getMatchingBreakpoint in breakpoints.ts
57+
@mixin container-breakpoint-up($breakpoint) {
58+
@if $breakpoint != $_container_smallest_breakpoint {
59+
@container (min-width: #{$breakpoint + 1px}) {
60+
@content;
61+
}
62+
} @else {
63+
@content;
64+
}
65+
}
66+
67+
// Container query for widths less than or equal to the given breakpoint.
68+
// Matches the behavior of matchBreakpointMapping in breakpoints.ts
69+
@mixin container-breakpoint-down($breakpoint) {
70+
@if $breakpoint != $_container_largest_breakpoint {
71+
@container (max-width: #{$breakpoint}) {
72+
@content;
73+
}
74+
} @else {
75+
@content;
76+
}
77+
}

src/test-utils/dom/top-navigation/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,10 @@ export class TopNavigationMenuDropdownWrapper extends ButtonDropdownWrapper {
101101
}
102102

103103
findTitle(): ElementWrapper | null {
104-
return this.findByClassName(buttonDropdownStyles.title);
104+
return createWrapper().findComponent(`.${buttonDropdownStyles.title}`, ElementWrapper);
105105
}
106106

107107
findDescription(): ElementWrapper | null {
108-
return this.findByClassName(buttonDropdownStyles.description);
108+
return createWrapper().findComponent(`.${buttonDropdownStyles.description}`, ElementWrapper);
109109
}
110110
}

src/top-navigation/internal.tsx

Lines changed: 29 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,13 @@ export default function InternalTopNavigation({
3434
}: InternalTopNavigationProps) {
3535
checkSafeUrl('TopNavigation', identity.href);
3636
const baseProps = getBaseProps(restProps);
37-
const { mainRef, virtualRef, breakpoint, responsiveState, isSearchExpanded, onSearchUtilityClick } = useTopNavigation(
38-
{ identity, search, utilities }
39-
);
37+
const { mainRef, virtualRef, responsiveState, isSearchExpanded, onSearchUtilityClick } = useTopNavigation({
38+
identity,
39+
search,
40+
utilities,
41+
});
4042
const [overflowMenuOpen, setOverflowMenuOpen] = useState(false);
4143
const overflowMenuTriggerRef = useRef<HTMLButtonElement>(null);
42-
const isNarrowViewport = breakpoint === 'default';
43-
const isMediumViewport = breakpoint === 'xxs';
44-
const isLargeViewport = breakpoint === 's';
4544
const i18n = useInternalI18n('top-navigation');
4645

4746
// ButtonDropdown supports checkbox items but we don't support these in TopNavigation. Shown an error in development mode
@@ -98,23 +97,14 @@ export default function InternalTopNavigation({
9897
className={clsx(styles['top-navigation'], {
9998
[styles.virtual]: isVirtual,
10099
[styles.hidden]: isVirtual,
101-
[styles.narrow]: isNarrowViewport,
102-
[styles.medium]: isMediumViewport,
103100
})}
104101
>
105102
<div className={styles['padding-box']}>
106103
{showIdentity && (
107104
<div className={clsx(styles.identity, !identity.logo && styles['no-logo'])}>
108105
<a className={styles['identity-link']} href={identity.href} onClick={onIdentityClick}>
109106
{identity.logo && (
110-
<img
111-
role="img"
112-
src={identity.logo?.src}
113-
alt={identity.logo?.alt}
114-
className={clsx(styles.logo, {
115-
[styles.narrow]: isNarrowViewport,
116-
})}
117-
/>
107+
<img role="img" src={identity.logo?.src} alt={identity.logo?.alt} className={styles.logo} />
118108
)}
119109
{showTitle && <span className={styles.title}>{identity.title}</span>}
120110
</a>
@@ -135,11 +125,7 @@ export default function InternalTopNavigation({
135125
className={clsx(
136126
styles['utility-wrapper'],
137127
styles['utility-type-button'],
138-
styles['utility-type-button-link'],
139-
{
140-
[styles.narrow]: isNarrowViewport,
141-
[styles.medium]: isMediumViewport,
142-
}
128+
styles['utility-type-button-link']
143129
)}
144130
data-utility-special="search"
145131
>
@@ -163,69 +149,45 @@ export default function InternalTopNavigation({
163149
(_utility, i) =>
164150
isVirtual || !responsiveState.hideUtilities || responsiveState.hideUtilities.indexOf(i) === -1
165151
)
166-
.map((utility, i) => {
167-
const hideText = !!responsiveState.hideUtilityText;
168-
const isLast = (isVirtual || !showMenuTrigger) && i === utilities.length - 1;
169-
const offsetRight = isLast && isLargeViewport ? 'xxl' : isLast ? 'l' : undefined;
170-
171-
return (
172-
<div
173-
key={i}
174-
className={clsx(
175-
styles['utility-wrapper'],
176-
styles[`utility-type-${utility.type}`],
177-
utility.type === 'button' && styles[`utility-type-button-${utility.variant ?? 'link'}`],
178-
{
179-
[styles.narrow]: isNarrowViewport,
180-
[styles.medium]: isMediumViewport,
181-
}
182-
)}
183-
data-utility-index={i}
184-
data-utility-hide={`${hideText}`}
185-
>
186-
<Utility hideText={hideText} definition={utility} offsetRight={offsetRight} />
187-
</div>
188-
);
189-
})}
190-
191-
{isVirtual &&
192-
utilities.map((utility, i) => {
193-
const hideText = !responsiveState.hideUtilityText;
194-
const isLast = !showMenuTrigger && i === utilities.length - 1;
195-
const offsetRight = isLast && isLargeViewport ? 'xxl' : isLast ? 'l' : undefined;
196-
197-
return (
152+
.map((utility, i) => (
198153
<div
199154
key={i}
200155
className={clsx(
201156
styles['utility-wrapper'],
202157
styles[`utility-type-${utility.type}`],
203-
utility.type === 'button' && styles[`utility-type-button-${utility.variant ?? 'link'}`],
204-
{
205-
[styles.narrow]: isNarrowViewport,
206-
[styles.medium]: isMediumViewport,
207-
}
158+
utility.type === 'button' && styles[`utility-type-button-${utility.variant ?? 'link'}`]
208159
)}
209160
data-utility-index={i}
210-
data-utility-hide={`${hideText}`}
161+
data-utility-hide={`${!!responsiveState.hideUtilityText}`}
211162
>
212-
<Utility hideText={hideText} definition={utility} offsetRight={offsetRight} />
163+
<Utility hideText={!!responsiveState.hideUtilityText} definition={utility} />
213164
</div>
214-
);
215-
})}
165+
))}
166+
167+
{isVirtual &&
168+
utilities.map((utility, i) => (
169+
<div
170+
key={i}
171+
className={clsx(
172+
styles['utility-wrapper'],
173+
styles[`utility-type-${utility.type}`],
174+
utility.type === 'button' && styles[`utility-type-button-${utility.variant ?? 'link'}`]
175+
)}
176+
data-utility-index={i}
177+
data-utility-hide={`${!responsiveState.hideUtilityText}`}
178+
>
179+
<Utility hideText={!responsiveState.hideUtilityText} definition={utility} />
180+
</div>
181+
))}
216182

217183
{showMenuTrigger && (
218184
<div
219-
className={clsx(styles['utility-wrapper'], styles['utility-type-menu-dropdown'], {
220-
[styles.narrow]: isNarrowViewport,
221-
[styles.medium]: isMediumViewport,
222-
})}
185+
className={clsx(styles['utility-wrapper'], styles['utility-type-menu-dropdown'])}
223186
data-utility-special="menu-trigger"
224187
>
225188
<ButtonTrigger
226189
expanded={overflowMenuOpen}
227190
onClick={toggleOverflowMenu}
228-
offsetRight="l"
229191
ref={!isVirtual ? overflowMenuTriggerRef : undefined}
230192
>
231193
{i18n('i18nStrings.overflowMenuTriggerText', i18nStrings?.overflowMenuTriggerText)}

src/top-navigation/parts/utility.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
33
import React from 'react';
4-
import clsx from 'clsx';
54

65
import { InternalButton } from '../../button/internal';
76
import { isLinkItem } from '../../button-dropdown/utils/utils';
@@ -33,7 +32,7 @@ export default function Utility({ hideText, definition, offsetRight }: UtilityPr
3332
checkSafeUrl('TopNavigation', definition.href);
3433
if (definition.variant === 'primary-button') {
3534
return (
36-
<span className={styles[`offset-right-${offsetRight}`]}>
35+
<span>
3736
<InternalButton
3837
variant="primary"
3938
href={definition.href}
@@ -53,7 +52,7 @@ export default function Utility({ hideText, definition, offsetRight }: UtilityPr
5352
<>
5453
{' '}
5554
<span
56-
className={clsx(styles['utility-button-external-icon'], styles[`offset-right-${offsetRight}`])}
55+
className={styles['utility-button-external-icon']}
5756
aria-label={definition.externalIconAriaLabel}
5857
role={definition.externalIconAriaLabel ? 'img' : undefined}
5958
>
@@ -69,7 +68,7 @@ export default function Utility({ hideText, definition, offsetRight }: UtilityPr
6968
} else {
7069
// Link
7170
return (
72-
<span className={styles[`offset-right-${offsetRight}`]}>
71+
<span>
7372
<InternalLink
7473
variant="top-navigation"
7574
href={definition.href}
@@ -120,7 +119,9 @@ export default function Utility({ hideText, definition, offsetRight }: UtilityPr
120119
items={items}
121120
title={shouldShowTitle ? title : ''}
122121
ariaLabel={ariaLabel}
122+
expandToViewport={true}
123123
offsetRight={offsetRight}
124+
className={styles['utility-menu-dropdown-button']}
124125
>
125126
{!shouldHideText && definition.text}
126127
</MenuDropdown>

0 commit comments

Comments
 (0)