Skip to content

Commit 62dbc98

Browse files
authored
PageLayout: Implement responsive hidden prop (#2174)
* Implement responsive hidden prop * Write tests for useResponsiveValue * Document hidden prop * Update viewport range variables * Add hidden prop to header/footer/content * Update PageLayout tests * Create empty-garlics-clean.md * Fix lint errors
1 parent e28aadb commit 62dbc98

File tree

9 files changed

+390
-6
lines changed

9 files changed

+390
-6
lines changed

.changeset/empty-garlics-clean.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@primer/react": minor
3+
---
4+
5+
Add a responsive `hidden` prop to `PageLayout.Header`, `PageLayout.Pane`, `PageLayout.Content`, and `PageLayout.Footer` that allows you to hide layout regions based on the viewport width. Example usage:
6+
7+
```jsx
8+
// Hide pane on narrow viewports
9+
<PageLayout.Pane hidden={{narrow: true}}>...</PageLayout.Pane>
10+
```

docs/content/PageLayout.mdx

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,25 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
7676
</PageLayout>
7777
```
7878

79+
### With pane hidden on narrow viewports
80+
81+
```jsx live
82+
<PageLayout>
83+
<PageLayout.Header>
84+
<Placeholder label="Header" height={64} />
85+
</PageLayout.Header>
86+
<PageLayout.Content>
87+
<Placeholder label="Content" height={240} />
88+
</PageLayout.Content>
89+
<PageLayout.Pane position="start" hidden={{narrow: true}}>
90+
<Placeholder label="Pane" height={120} />
91+
</PageLayout.Pane>
92+
<PageLayout.Footer>
93+
<Placeholder label="Footer" height={64} />
94+
</PageLayout.Footer>
95+
</PageLayout>
96+
```
97+
7998
### With condensed spacing
8099

81100
```jsx live
@@ -112,8 +131,6 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
112131

113132
### PageLayout
114133

115-
<!-- TODO: Responsive variants -->
116-
117134
<PropsTable>
118135
<PropsTableRow
119136
name="containerWidth"
@@ -166,6 +183,17 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
166183
| 'filled'`}
167184
defaultValue="'inherit'"
168185
/>
186+
<PropsTableRow
187+
name="hidden"
188+
type={`| boolean
189+
| {
190+
narrow?: boolean
191+
regular?: boolean
192+
wide?: boolean
193+
}`}
194+
defaultValue="false"
195+
description="Whether the header is hidden."
196+
/>
169197
<PropsTableSxRow />
170198
</PropsTable>
171199

@@ -181,6 +209,17 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
181209
defaultValue="'full'"
182210
description="The maximum width of the content region."
183211
/>
212+
<PropsTableRow
213+
name="hidden"
214+
type={`| boolean
215+
| {
216+
narrow?: boolean
217+
regular?: boolean
218+
wide?: boolean
219+
}`}
220+
defaultValue="false"
221+
description="Whether the content is hidden."
222+
/>
184223
<PropsTableSxRow />
185224
</PropsTable>
186225

@@ -222,6 +261,17 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
222261
| 'filled'`}
223262
defaultValue="'inherit'"
224263
/>
264+
<PropsTableRow
265+
name="hidden"
266+
type={`| boolean
267+
| {
268+
narrow?: boolean
269+
regular?: boolean
270+
wide?: boolean
271+
}`}
272+
defaultValue="false"
273+
description="Whether the pane is hidden."
274+
/>
225275
<PropsTableSxRow />
226276
</PropsTable>
227277

@@ -242,6 +292,17 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
242292
| 'filled'`}
243293
defaultValue="'inherit'"
244294
/>
295+
<PropsTableRow
296+
name="hidden"
297+
type={`| boolean
298+
| {
299+
narrow?: boolean
300+
regular?: boolean
301+
wide?: boolean
302+
}`}
303+
defaultValue="false"
304+
description="Whether the footer is hidden."
305+
/>
245306
<PropsTableSxRow />
246307
</PropsTable>
247308

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@
162162
"husky": "7.0.4",
163163
"jest": "27.4.5",
164164
"jest-axe": "5.0.1",
165+
"jest-matchmedia-mock": "1.1.0",
165166
"jest-styled-components": "6.3.4",
166167
"jest-matchmedia-mock": "1.1.0",
167168
"jscodeshift": "0.13.0",

src/PageLayout/PageLayout.test.tsx

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
1-
import {render} from '@testing-library/react'
21
import React from 'react'
2+
import {act, render} from '@testing-library/react'
3+
import MatchMediaMock from 'jest-matchmedia-mock'
34
import {ThemeProvider} from '..'
5+
import {viewportRanges} from '../hooks/useResponsiveValue'
46
import {PageLayout} from './PageLayout'
57

8+
let matchMedia: MatchMediaMock
9+
610
describe('PageLayout', () => {
11+
beforeAll(() => {
12+
matchMedia = new MatchMediaMock()
13+
})
14+
15+
afterEach(() => {
16+
matchMedia.clear()
17+
})
18+
719
it('renders default layout', () => {
820
const {container} = render(
921
<ThemeProvider>
@@ -63,4 +75,44 @@ describe('PageLayout', () => {
6375
)
6476
expect(container).toMatchSnapshot()
6577
})
78+
79+
it('can hide pane when narrow', () => {
80+
// Set narrow viewport
81+
act(() => {
82+
matchMedia.useMediaQuery(viewportRanges.narrow)
83+
})
84+
85+
const {getByText} = render(
86+
<ThemeProvider>
87+
<PageLayout>
88+
<PageLayout.Header>Header</PageLayout.Header>
89+
<PageLayout.Content>Content</PageLayout.Content>
90+
<PageLayout.Pane hidden={{narrow: true}}>Pane</PageLayout.Pane>
91+
<PageLayout.Footer>Footer</PageLayout.Footer>
92+
</PageLayout>
93+
</ThemeProvider>
94+
)
95+
96+
expect(getByText('Pane')).not.toBeVisible()
97+
})
98+
99+
it('shows all subcomponents by default', () => {
100+
// Set regular viewport
101+
act(() => {
102+
matchMedia.useMediaQuery(viewportRanges.regular)
103+
})
104+
105+
const {getByText} = render(
106+
<ThemeProvider>
107+
<PageLayout>
108+
<PageLayout.Header>Header</PageLayout.Header>
109+
<PageLayout.Content>Content</PageLayout.Content>
110+
<PageLayout.Pane hidden={{narrow: true}}>Pane</PageLayout.Pane>
111+
<PageLayout.Footer>Footer</PageLayout.Footer>
112+
</PageLayout>
113+
</ThemeProvider>
114+
)
115+
116+
expect(getByText('Pane')).toBeVisible()
117+
})
66118
})

src/PageLayout/PageLayout.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react'
2-
import {BetterSystemStyleObject, merge, SxProp} from '../sx'
32
import {Box} from '..'
3+
import {ResponsiveValue, useResponsiveValue} from '../hooks/useResponsiveValue'
4+
import {BetterSystemStyleObject, merge, SxProp} from '../sx'
45

56
const REGION_ORDER = {
67
header: 0,
@@ -178,18 +179,22 @@ const VerticalDivider: React.FC<DividerProps> = ({variant = 'none', variantWhenN
178179
export type PageLayoutHeaderProps = {
179180
divider?: 'none' | 'line'
180181
dividerWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled'
182+
hidden?: boolean | ResponsiveValue<boolean>
181183
} & SxProp
182184

183185
const Header: React.FC<PageLayoutHeaderProps> = ({
184186
divider = 'none',
185187
dividerWhenNarrow = 'inherit',
188+
hidden = false,
186189
children,
187190
sx = {}
188191
}) => {
192+
const isHidden = useResponsiveValue(hidden, false)
189193
const {rowGap} = React.useContext(PageLayoutContext)
190194
return (
191195
<Box
192196
as="header"
197+
hidden={isHidden}
193198
sx={merge<BetterSystemStyleObject>(
194199
{
195200
order: REGION_ORDER.header,
@@ -216,6 +221,7 @@ Header.displayName = 'PageLayout.Header'
216221

217222
export type PageLayoutContentProps = {
218223
width?: keyof typeof contentWidths
224+
hidden?: boolean | ResponsiveValue<boolean>
219225
} & SxProp
220226

221227
// TODO: Account for pane width when centering content
@@ -226,10 +232,12 @@ const contentWidths = {
226232
xlarge: '1280px'
227233
}
228234

229-
const Content: React.FC<PageLayoutContentProps> = ({width = 'full', children, sx = {}}) => {
235+
const Content: React.FC<PageLayoutContentProps> = ({width = 'full', hidden = false, children, sx = {}}) => {
236+
const isHidden = useResponsiveValue(hidden, false)
230237
return (
231238
<Box
232239
as="main"
240+
hidden={isHidden}
233241
sx={merge<BetterSystemStyleObject>(
234242
{
235243
order: REGION_ORDER.content,
@@ -260,6 +268,7 @@ export type PageLayoutPaneProps = {
260268
width?: keyof typeof paneWidths
261269
divider?: 'none' | 'line'
262270
dividerWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled'
271+
hidden?: boolean | ResponsiveValue<boolean>
263272
} & SxProp
264273

265274
const panePositions = {
@@ -279,9 +288,11 @@ const Pane: React.FC<PageLayoutPaneProps> = ({
279288
width = 'medium',
280289
divider = 'none',
281290
dividerWhenNarrow = 'inherit',
291+
hidden = false,
282292
children,
283293
sx = {}
284294
}) => {
295+
const isHidden = useResponsiveValue(hidden, false)
285296
const {rowGap, columnGap} = React.useContext(PageLayoutContext)
286297
const computedPositionWhenNarrow = positionWhenNarrow === 'inherit' ? position : positionWhenNarrow
287298
const computedDividerWhenNarrow = dividerWhenNarrow === 'inherit' ? divider : dividerWhenNarrow
@@ -293,7 +304,7 @@ const Pane: React.FC<PageLayoutPaneProps> = ({
293304
merge<BetterSystemStyleObject>(
294305
{
295306
order: panePositions[computedPositionWhenNarrow],
296-
display: 'flex',
307+
display: isHidden ? 'none' : 'flex',
297308
flexDirection: computedPositionWhenNarrow === 'end' ? 'column' : 'column-reverse',
298309
width: '100%',
299310
marginX: 0,
@@ -335,18 +346,22 @@ Pane.displayName = 'PageLayout.Pane'
335346
export type PageLayoutFooterProps = {
336347
divider?: 'none' | 'line'
337348
dividerWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled'
349+
hidden?: boolean | ResponsiveValue<boolean>
338350
} & SxProp
339351

340352
const Footer: React.FC<PageLayoutFooterProps> = ({
341353
divider = 'none',
342354
dividerWhenNarrow = 'inherit',
355+
hidden = false,
343356
children,
344357
sx = {}
345358
}) => {
359+
const isHidden = useResponsiveValue(hidden, false)
346360
const {rowGap} = React.useContext(PageLayoutContext)
347361
return (
348362
<Box
349363
as="footer"
364+
hidden={isHidden}
350365
sx={merge<BetterSystemStyleObject>(
351366
{
352367
order: REGION_ORDER.footer,
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import {act, render} from '@testing-library/react'
2+
import MatchMediaMock from 'jest-matchmedia-mock'
3+
import {ResponsiveValue, useResponsiveValue, viewportRanges} from '../../hooks/useResponsiveValue'
4+
import React from 'react'
5+
6+
let matchMedia: MatchMediaMock
7+
8+
beforeAll(() => {
9+
matchMedia = new MatchMediaMock()
10+
})
11+
12+
afterEach(() => {
13+
matchMedia.clear()
14+
})
15+
16+
it('accepts non-responsive values', () => {
17+
const Component = () => {
18+
const value = useResponsiveValue('test', 'fallback')
19+
return <div>{value}</div>
20+
}
21+
22+
const {getByText} = render(<Component />)
23+
24+
expect(getByText('test')).toBeInTheDocument()
25+
})
26+
27+
it('returns narrow value when viewport is narrow', () => {
28+
const Component = () => {
29+
const value = useResponsiveValue({narrow: false, regular: true} as ResponsiveValue<boolean>, true)
30+
return <div>{JSON.stringify(value)}</div>
31+
}
32+
33+
// Set narrow viewport
34+
act(() => {
35+
matchMedia.useMediaQuery(viewportRanges.narrow)
36+
})
37+
38+
const {getByText} = render(<Component />)
39+
40+
expect(getByText('false')).toBeInTheDocument()
41+
})
42+
43+
it('returns wide value when viewport is wide', () => {
44+
const Component = () => {
45+
const value = useResponsiveValue(
46+
{narrow: 'narrowValue', regular: 'regularValue', wide: 'wideValue'} as ResponsiveValue<string>,
47+
'fallbackValue'
48+
)
49+
return <div>{value}</div>
50+
}
51+
52+
// Set wide viewport
53+
act(() => {
54+
matchMedia.useMediaQuery(viewportRanges.wide)
55+
})
56+
57+
const {getByText} = render(<Component />)
58+
59+
expect(getByText('wideValue')).toBeInTheDocument()
60+
})
61+
62+
it('returns regular value when viewport is regular', () => {
63+
const Component = () => {
64+
const value = useResponsiveValue(
65+
{narrow: 'narrowValue', regular: 'regularValue', wide: 'wideValue'} as ResponsiveValue<string>,
66+
'fallbackValue'
67+
)
68+
return <div>{value}</div>
69+
}
70+
71+
// Set regular viewport
72+
act(() => {
73+
matchMedia.useMediaQuery(viewportRanges.regular)
74+
})
75+
76+
const {getByText} = render(<Component />)
77+
78+
expect(getByText('regularValue')).toBeInTheDocument()
79+
})
80+
81+
it('returns fallback when no value is defined for current viewport', () => {
82+
const Component = () => {
83+
const value = useResponsiveValue(
84+
// Missing value for `regular` viewports
85+
{narrow: 'narrowValue', wide: 'wideValue'} as ResponsiveValue<string>,
86+
'fallbackValue'
87+
)
88+
return <div>{value}</div>
89+
}
90+
91+
// Set regular viewport
92+
act(() => {
93+
matchMedia.useMediaQuery(viewportRanges.regular)
94+
})
95+
96+
const {getByText} = render(<Component />)
97+
98+
expect(getByText('fallbackValue')).toBeInTheDocument()
99+
})

0 commit comments

Comments
 (0)