Skip to content

Commit 4e7bc4d

Browse files
authored
Responsive design | Breakpoints + TopBar (#72)
## What does this change? Adds breakpoints to semantic tokens based on those from [Figma](https://www.figma.com/design/gmHnKr3tOzvnYp1FZeIl4t/Stand--Editorial-tool-Design-System--WIP%F0%9F%9A%A7-?node-id=1-42&m=dev) - `sm` -> 0 - 671px - `md` -> 672px - 1055px - `lg` -> 1056px - 1311px - `xl` -> 1312px - 1583px - `max` -> 1584px - 1783px - `maxplus`-> 1784px+ These breakpoints will serve as the basis for responsive design, layout and the grid system within stand and it's consuming applications. We've added in utilities in `mq.ts` to help work with the breakpoint system. Specifically a `from`, `until`, `between` methods used to create css media queries based on a given breakpoint. A `useResize` callback hook is also created to determine at runtime when a resize event happens. This PR also applies these to the `TopBar` component, thereby creating a responsive `TopBar`. We now collapse the items in the left hand side of the top bar at a given breakpoint. See [Figma](https://www.figma.com/design/gmHnKr3tOzvnYp1FZeIl4t/Stand--Editorial-tool-Design-System--WIP%F0%9F%9A%A7-?node-id=1746-8614&m=dev) for designs. The `TopBarToolName` has now been configured to only show the Favicon when below the given `collapseBelow` prop, and added `collapsedHoverText` for scenarios where we need a shorter hover text. The `TopBarContainerLeft` items now collapse into a hamburger menu automatically, with the items retaining similar syling as previous, achieved by updating `TopBarItem` and `TopBarNavigation` to account for menu open styles. The `TopBarContainerRight` never collapses, so a developer should be careful about what goes there. We also update `topBarContainerStyles` to account for which breakpoint we should show/hide a given element/componet at. The hamburger menu is implemented directly within the `TopBar` component based on the [`Popover`](https://react-aria.adobe.com/Popover) component and API. https://github.com/user-attachments/assets/cdaa2fd0-3d1a-4cb5-972f-085f71578f63 --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1213967334994009
2 parents e45b618 + a38cd01 commit 4e7bc4d

34 files changed

Lines changed: 1558 additions & 53 deletions

.changeset/old-bats-think.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@guardian/stand': patch
3+
---
4+
5+
Add breakpoints and responsive TopBar

src/components/topbar/TopBar.mdx

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import TopBarStories, {
55
Default,
66
WithTopNavigation,
77
WithButtons,
8+
WithButtonsMenuNavigation,
89
} from './TopBar.stories';
910
import {
1011
SandboxReact,
@@ -84,12 +85,13 @@ The children will be rendered in the following order from left to right:
8485

8586
### Props
8687

87-
| Name | Type | Required | Default | Description |
88-
| -------------- | ------------------ | -------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
89-
| `children` | `React.ReactNode` | No | N/A | Content to render inside the component. Only children of type `TopBarToolName`, `Avatar`, `TopBarContainerLeft` or `TopBarContainerRight` will be rendered. |
90-
| `theme` | `TopBarTheme` | No | N/A | Custom theme overrides. |
91-
| `cssOverrides` | `SerializedStyles` | No | N/A | Custom CSS styles. |
92-
| `className` | `string` | No | N/A | Additional class name(s). |
88+
| Name | Type | Required | Default | Description |
89+
| --------------- | ------------------------------------------------------- | -------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
90+
| `children` | `React.ReactNode` | No | N/A | Content to render inside the component. Only children of type `TopBarToolName`, `Avatar`, `TopBarContainerLeft` or `TopBarContainerRight` will be rendered. |
91+
| `collapseBelow` | `{ toolName?: Breakpoint; containerLeft?: Breakpoint }` | No | `{ toolName: 'lg', containerLeft: 'lg' }` | Controls at which breakpoint each section collapses. See [Responsive behaviour](#responsive-behaviour). |
92+
| `theme` | `TopBarTheme` | No | N/A | Custom theme overrides. |
93+
| `cssOverrides` | `SerializedStyles` | No | N/A | Custom CSS styles. |
94+
| `className` | `string` | No | N/A | Additional class name(s). |
9395

9496
### `TopBarContainerLeft` / `TopBarContainerRight` props
9597

@@ -115,6 +117,42 @@ All other content must be composed within the left hand side or right hand side.
115117

116118
<Story of={WithButtons} />
117119

120+
### With Buttons and Menu Navigation
121+
122+
<Story of={WithButtonsMenuNavigation} />
123+
124+
## Responsive behaviour
125+
126+
The TopBar supports responsive design via the `collapseBelow` prop. This controls at which breakpoint each section of the top bar collapses.
127+
128+
Available breakpoints: `sm` (0px), `md` (672px), `lg` (1056px), `xl` (1312px), `max` (1584px), `maxplus` (1784px).
129+
130+
```tsx
131+
import type { TopBarProps } from '@guardian/stand/TopBar';
132+
133+
// collapses at md and below (i.e. below 1056px)
134+
<TopBar collapseBelow={{ containerLeft: 'lg', toolName: 'lg' }}>
135+
```
136+
137+
### `containerLeft` collapse
138+
139+
When the viewport is below the specified breakpoint:
140+
141+
- The `TopBarContainerLeft` content is hidden.
142+
- A **hamburger menu button** appears in its place.
143+
- Clicking the button opens a popover containing the left container's `TopBarNavigation` and `TopBarItem` children.
144+
- The menu closes automatically when the viewport is resized back above the breakpoint.
145+
146+
The default value for both `containerLeft` and `toolName` is `'lg'`, meaning both collapse below 1056px by default.
147+
148+
### `toolName` collapse
149+
150+
When the viewport is below the `toolName` breakpoint, the `TopBarToolName` displays in a shortened form. See the [TopBarToolName docs](/docs/stand-tools-design-system-components-topbar-topbartoolname--docs) for the `collapsedHoverText` prop that controls its collapsed label.
151+
152+
### Note
153+
154+
The right container does not currently have a collapse behaviour, but this may be added in the future if there is demand for it. If using the right container be sure to test the layout on smaller screen sizes to ensure it works for your use case.
155+
118156
## Customisation
119157

120158
We recommend using the TopBar component as provided, but it can be customised using the `theme` or `cssOverrides` props as required.

src/components/topbar/TopBar.stories.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
33
import { baseColors } from '../../styleD/build/typescript/base/colors';
44
import { semanticColors } from '../../styleD/build/typescript/semantic/colors';
55
import { semanticSizing } from '../../styleD/build/typescript/semantic/sizing';
6+
import { TextInput } from '../../text-input';
67
import { Avatar } from '../avatar/Avatar';
78
import { Button } from '../button/Button';
9+
import { MenuItem, MenuSection } from '../menu/Menu';
810
import { TopBar, TopBarContainerLeft, TopBarContainerRight } from './TopBar';
911
import { TopBarItem } from './topBarItem/TopBarItem';
1012
import { TopBarNavigation } from './topBarNavigation/TopBarNavigation';
@@ -43,6 +45,7 @@ export const WithTopNavigation = {
4345
favicon={{ letter: 'D' }}
4446
href="#"
4547
hoverText="Back to dashboard"
48+
collapsedHoverText="Back"
4649
/>
4750
<TopBarContainerLeft>
4851
<TopBarNavigation isSelected text="Navigation 1" href="#" />
@@ -84,6 +87,54 @@ export const WithButtons = {
8487
),
8588
} satisfies Story;
8689

90+
export const WithButtonsMenuNavigation = {
91+
render: () => (
92+
<TopBar>
93+
<TopBarToolName name="Default" favicon={{ letter: 'D' }} />
94+
<TopBarContainerLeft>
95+
<TopBarNavigation
96+
text="Menu"
97+
icon="file_upload"
98+
menuChildren={
99+
<MenuSection name="Menu section">
100+
<MenuItem label="Menu item 1" />
101+
<MenuItem label="Menu item 2" />
102+
</MenuSection>
103+
}
104+
/>
105+
<TopBarNavigation isSelected text="Current" href="#" />
106+
<TopBarItem>
107+
<Button variant="primary">Primary</Button>
108+
</TopBarItem>
109+
<TopBarNavigation text="Link" href="#" />
110+
</TopBarContainerLeft>
111+
<TopBarContainerRight>
112+
<TopBarItem>
113+
<TextInput
114+
theme={{
115+
shared: {
116+
'margin-top': '0',
117+
},
118+
}}
119+
cssOverrides={css`
120+
width: 100px;
121+
`}
122+
placeholder="Search"
123+
/>
124+
</TopBarItem>
125+
<TopBarItem>
126+
<Button variant="tertiary">Tertiary</Button>
127+
</TopBarItem>
128+
</TopBarContainerRight>
129+
<Avatar
130+
src="https://uploads.guimcode.co.uk/2026/01/27/f85e2e477ce54f4c3b671faa5cd21673aa9f8072fddb5d70a73e6038dc812eec.jpg"
131+
alt="Mahesh Makani"
132+
size="sm"
133+
/>
134+
</TopBar>
135+
),
136+
} satisfies Story;
137+
87138
export const CustomTheme = {
88139
render: () => (
89140
<TopBar
@@ -116,6 +167,7 @@ export const CustomTheme = {
116167
}}
117168
href="#"
118169
hoverText="Back to dashboard"
170+
collapsedHoverText="Back"
119171
/>
120172
<TopBarContainerLeft>
121173
<TopBarNavigation isSelected text="Navigation 1" href="#" />

src/components/topbar/TopBar.tsx

Lines changed: 121 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
import React from 'react';
2+
import {
3+
Button as ReactAriaButton,
4+
DialogTrigger as ReactAriaDialogTrigger,
5+
Popover as ReactAriaPopover,
6+
} from 'react-aria-components';
7+
import { AvatarButton } from '../../avatar-button';
8+
import { AvatarLink } from '../../avatar-link';
29
import type { TopBarToolNameProps } from '../../TopBar';
310
import { mergeDeep } from '../../util/mergeDeep';
11+
import { useResize } from '../../util/useResize';
412
import { Avatar } from '../avatar/Avatar';
13+
import { Icon } from '../icon/Icon';
514
import type { TopBarTheme } from './styles';
615
import {
716
defaultTopBarTheme,
8-
topBarContainerLeftStyles,
9-
topBarContainerRightStyles,
17+
topBarCollapsedNavMenuButtonStyles,
18+
topBarCollapsedNavMenuPopoverStyles,
19+
topBarContainerStyles,
20+
topBarSpacerStyles,
1021
topBarStyles,
1122
} from './styles';
1223
import { TopBarItem } from './topBarItem/TopBarItem';
@@ -86,17 +97,23 @@ export function TopBarContainerLeft({
8697

8798
export function TopBar({
8899
children,
100+
collapseBelow = {
101+
toolName: 'lg',
102+
containerLeft: 'lg',
103+
},
89104
theme = {},
90105
cssOverrides,
91106
className,
92107
...props
93108
}: TopBarProps) {
109+
const [menuOpen, setMenuOpen] = React.useState(false);
110+
const buttonRef = React.useRef<HTMLButtonElement>(null);
94111
const mergedTheme = mergeDeep(defaultTopBarTheme, theme);
95112

96-
let leftSide: React.ReactElement | null = null;
97-
let rightSide: React.ReactElement | null = null;
98-
let toolName: React.ReactElement | null = null;
99-
let avatar: React.ReactElement | null = null;
113+
let leftSide: React.ReactElement | null | undefined;
114+
let rightSide: React.ReactElement | null | undefined;
115+
let toolName: React.ReactElement | null | undefined;
116+
let avatar: React.ReactElement | null | undefined;
100117

101118
React.Children.forEach(children, (child) => {
102119
if (!React.isValidElement(child)) {
@@ -109,14 +126,18 @@ export function TopBar({
109126
if (child.type === TopBarToolName) {
110127
toolName ??= React.cloneElement(
111128
child as React.ReactElement<TopBarToolNameProps>,
112-
{ theme: mergedTheme.ToolName },
129+
{ theme: mergedTheme.ToolName, collapseBelow: collapseBelow.toolName },
113130
);
114131
}
115132

116133
/**
117134
* Accepts an avatar that will always be rendered on the right hand side, within an item for styling
118135
*/
119-
if (child.type === Avatar) {
136+
if (
137+
child.type === Avatar ||
138+
child.type === AvatarLink ||
139+
child.type === AvatarButton
140+
) {
120141
avatar ??= (
121142
<TopBarItem theme={mergedTheme.Item} alignment="right">
122143
{child}
@@ -141,22 +162,109 @@ export function TopBar({
141162
}
142163
});
143164

165+
const leftSideMenuItems: React.ReactElement[] = [];
166+
167+
if (leftSide) {
168+
React.Children.forEach(
169+
(leftSide as React.ReactElement<{ children?: React.ReactNode }>).props
170+
.children as React.ReactElement[],
171+
(child) => {
172+
if (!React.isValidElement(child)) {
173+
return;
174+
}
175+
176+
if (child.type === TopBarNavigation) {
177+
leftSideMenuItems.push(
178+
React.cloneElement(
179+
child as React.ReactElement<TopBarNavigationProps>,
180+
{
181+
theme: mergedTheme.Navigation,
182+
alignment: 'left',
183+
_menuOpen: menuOpen,
184+
},
185+
),
186+
);
187+
}
188+
189+
if (child.type === TopBarItem) {
190+
leftSideMenuItems.push(
191+
React.cloneElement(child as React.ReactElement<TopBarItemProps>, {
192+
theme: mergedTheme.Item,
193+
alignment: 'left',
194+
_menuOpen: menuOpen,
195+
}),
196+
);
197+
}
198+
},
199+
);
200+
}
201+
202+
useResize(() => {
203+
if (menuOpen) {
204+
setMenuOpen(false);
205+
}
206+
});
207+
144208
return (
145209
<div
146210
css={[topBarStyles(mergedTheme), cssOverrides]}
147211
className={className}
148212
{...props}
149213
>
150214
{/* LHS */}
151-
<div css={topBarContainerLeftStyles(mergedTheme)}>
152-
{toolName}
215+
{leftSideMenuItems.length > 0 && (
216+
<div
217+
css={topBarContainerStyles(mergedTheme, {
218+
showUntil: collapseBelow.containerLeft,
219+
})}
220+
>
221+
<ReactAriaDialogTrigger
222+
isOpen={menuOpen}
223+
onOpenChange={(isOpen) => {
224+
setMenuOpen(isOpen);
225+
}}
226+
>
227+
<ReactAriaButton
228+
aria-label="Navigation Menu"
229+
ref={buttonRef}
230+
css={topBarCollapsedNavMenuButtonStyles(mergedTheme, menuOpen)}
231+
>
232+
<TopBarItem
233+
alignment="left"
234+
size="sm"
235+
theme={mergedTheme.Item}
236+
aria-label="Navigation Menu"
237+
>
238+
<Icon size="lg">{menuOpen ? 'close' : 'menu'}</Icon>
239+
</TopBarItem>
240+
</ReactAriaButton>
241+
<ReactAriaPopover
242+
css={topBarCollapsedNavMenuPopoverStyles(mergedTheme)}
243+
offset={12}
244+
>
245+
{leftSideMenuItems}
246+
</ReactAriaPopover>
247+
</ReactAriaDialogTrigger>
248+
</div>
249+
)}
250+
<div css={topBarContainerStyles(mergedTheme)}>{toolName}</div>
251+
<div
252+
css={topBarContainerStyles(mergedTheme, {
253+
collapseBelow: collapseBelow.containerLeft,
254+
})}
255+
>
153256
{leftSide}
154257
</div>
155-
{/* RHS */}
156-
<div css={topBarContainerRightStyles(mergedTheme)}>
258+
{/* RHS - topBarSpacerStyles pushes content to the right */}
259+
<div
260+
css={[
261+
topBarSpacerStyles(mergedTheme),
262+
topBarContainerStyles(mergedTheme),
263+
]}
264+
>
157265
{rightSide}
158-
{avatar}
159266
</div>
267+
<div css={topBarContainerStyles(mergedTheme)}>{avatar}</div>
160268
</div>
161269
);
162270
}

0 commit comments

Comments
 (0)