Skip to content

Commit 9c18810

Browse files
committed
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 #3337
1 parent 673f781 commit 9c18810

File tree

5 files changed

+113
-92
lines changed

5 files changed

+113
-92
lines changed

src/internal/styles/foundation/breakpoints.scss

+37-1
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/top-navigation/internal.tsx

+29-67
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

+1
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export default function Utility({ hideText, definition, offsetRight }: UtilityPr
120120
items={items}
121121
title={shouldShowTitle ? title : ''}
122122
ariaLabel={ariaLabel}
123+
expandToViewport={true}
123124
offsetRight={offsetRight}
124125
>
125126
{!shouldHideText && definition.text}

src/top-navigation/styles.scss

+44-18
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
@use '../internal/styles' as styles;
77
@use '../internal/styles/tokens' as awsui;
88
@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible;
9+
@use '../internal/styles/foundation/breakpoints' as breakpoints;
910

1011
.top-navigation {
1112
@include styles.styles-reset;
1213
background: awsui.$color-background-container-content;
14+
container-type: inline-size;
1315

1416
> .padding-box {
1517
display: flex;
@@ -22,18 +24,52 @@
2224
padding-inline-start: awsui.$space-xxl;
2325
}
2426

25-
&.medium > .padding-box,
26-
&.narrow > .padding-box {
27-
padding-inline-start: awsui.$space-l;
27+
// Large container (width ≥ 912px)
28+
@include breakpoints.container-breakpoint-up(breakpoints.$container-breakpoint-s) {
29+
> .utilities > .utility-wrapper:last-of-type {
30+
margin-inline-end: awsui.$space-m;
31+
}
2832
}
2933

30-
&.medium > .padding-box {
31-
block-size: calc(#{awsui.$space-xxxl} + #{awsui.$space-scaled-xs});
32-
padding-inline-end: 0;
34+
// Medium container (width < 912px)
35+
@include breakpoints.container-breakpoint-down(breakpoints.$container-breakpoint-s) {
36+
> .padding-box {
37+
block-size: calc(#{awsui.$space-xxxl} + #{awsui.$space-scaled-xs});
38+
padding-inline-start: awsui.$space-l;
39+
padding-inline-end: 0;
40+
}
41+
42+
> .utilities {
43+
padding-inline-start: 0;
44+
}
45+
46+
> .utilities > .utility-wrapper {
47+
padding-inline: awsui.$space-m;
48+
}
49+
50+
> .utilities > .utility-type-menu-dropdown:last-of-type {
51+
padding-inline-end: 0;
52+
}
53+
54+
> .utilities > .utility-wrapper:last-of-type {
55+
margin-inline-end: awsui.$space-xxs;
56+
}
3357
}
3458

35-
&.narrow > .padding-box {
36-
block-size: awsui.$space-xxxl;
59+
// Narrow container (width < 465px)
60+
@include breakpoints.container-breakpoint-down(breakpoints.$container-breakpoint-xxs) {
61+
> .padding-box {
62+
block-size: awsui.$space-xxxl;
63+
padding-inline-start: awsui.$space-l;
64+
}
65+
66+
> .logo {
67+
max-block-size: awsui.$space-xl;
68+
}
69+
70+
> .utilities {
71+
padding-inline-start: 0;
72+
}
3773
}
3874
}
3975

@@ -87,10 +123,6 @@
87123

88124
// Setting an arbitrary min-width here discourages browser from lazy rendering
89125
min-inline-size: 10px;
90-
91-
&.narrow {
92-
max-block-size: awsui.$space-xl;
93-
}
94126
}
95127

96128
.title {
@@ -129,11 +161,6 @@
129161

130162
// Expand height of utilies fully so that the dropdown is anchored directly underneath it.
131163
block-size: 100%;
132-
133-
.medium > .padding-box > &,
134-
.narrow > .padding-box > & {
135-
padding-inline-start: 0;
136-
}
137164
}
138165

139166
.utility-wrapper {
@@ -171,7 +198,6 @@
171198
padding-inline: awsui.$space-s;
172199
align-items: stretch;
173200

174-
&:not(.narrow):last-of-type,
175201
&:not(.medium):last-of-type {
176202
padding-inline-end: 0;
177203
}

src/top-navigation/use-top-navigation.ts

+2-6
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
44

55
import { useContainerQuery } from '@cloudscape-design/component-toolkit';
66

7-
import { useContainerBreakpoints } from '../internal/hooks/container-queries';
87
import { useMergeRefs } from '../internal/hooks/use-merge-refs';
98
import { TopNavigationProps } from './interfaces';
109

@@ -41,7 +40,6 @@ interface UseTopNavigation {
4140
virtualRef: React.Ref<HTMLDivElement>;
4241

4342
responsiveState: ResponsiveState;
44-
breakpoint: 'default' | 'xxs' | 's';
4543
isSearchExpanded: boolean;
4644
onSearchUtilityClick: () => void;
4745
}
@@ -50,10 +48,9 @@ interface UseTopNavigation {
5048
const RESPONSIVENESS_BUFFER = 20;
5149

5250
export function useTopNavigation({ identity, search, utilities }: UseTopNavigationParams): UseTopNavigation {
53-
// Refs and breakpoints
51+
// Refs
5452
const mainRef = useRef<HTMLElement | null>(null);
5553
const virtualRef = useRef<HTMLDivElement | null>(null);
56-
const [breakpoint, breakpointRef] = useContainerBreakpoints(['xxs', 's']);
5754

5855
// Responsiveness state
5956
// The component works by calculating the possible resize states that it can
@@ -149,13 +146,12 @@ export function useTopNavigation({ identity, search, utilities }: UseTopNavigati
149146
}
150147
}, [isSearchExpanded, mainRef]);
151148

152-
const mergedMainRef = useMergeRefs(mainRef, containerQueryRef, breakpointRef);
149+
const mergedMainRef = useMergeRefs(mainRef, containerQueryRef);
153150

154151
return {
155152
mainRef: mergedMainRef,
156153
virtualRef: onVirtualMount,
157154
responsiveState: responsiveState ?? responsiveStates[0],
158-
breakpoint: breakpoint ?? 'default',
159155
isSearchExpanded: !!isSearchExpanded,
160156
onSearchUtilityClick: () => setSearchMinimized(isSearchMinimized => !isSearchMinimized),
161157
};

0 commit comments

Comments
 (0)