diff --git a/.changeset/empty-garlics-clean.md b/.changeset/empty-garlics-clean.md
new file mode 100644
index 00000000000..ffb84d52778
--- /dev/null
+++ b/.changeset/empty-garlics-clean.md
@@ -0,0 +1,10 @@
+---
+"@primer/react": minor
+---
+
+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:
+
+```jsx
+// Hide pane on narrow viewports
+...
+```
diff --git a/docs/content/PageLayout.mdx b/docs/content/PageLayout.mdx
index f1b65c9f184..d9ad832f9e7 100644
--- a/docs/content/PageLayout.mdx
+++ b/docs/content/PageLayout.mdx
@@ -76,6 +76,25 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
```
+### With pane hidden on narrow viewports
+
+```jsx live
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
### With condensed spacing
```jsx live
@@ -112,8 +131,6 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
### PageLayout
-
-
+
@@ -181,6 +209,17 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
defaultValue="'full'"
description="The maximum width of the content region."
/>
+
@@ -222,6 +261,17 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
| 'filled'`}
defaultValue="'inherit'"
/>
+
@@ -242,6 +292,17 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
| 'filled'`}
defaultValue="'inherit'"
/>
+
diff --git a/package.json b/package.json
index a5dc5940308..e955e1372f6 100644
--- a/package.json
+++ b/package.json
@@ -162,6 +162,7 @@
"husky": "7.0.4",
"jest": "27.4.5",
"jest-axe": "5.0.1",
+ "jest-matchmedia-mock": "1.1.0",
"jest-styled-components": "6.3.4",
"jest-matchmedia-mock": "1.1.0",
"jscodeshift": "0.13.0",
diff --git a/src/PageLayout/PageLayout.test.tsx b/src/PageLayout/PageLayout.test.tsx
index fe96e6d384a..171bb6216f0 100644
--- a/src/PageLayout/PageLayout.test.tsx
+++ b/src/PageLayout/PageLayout.test.tsx
@@ -1,9 +1,21 @@
-import {render} from '@testing-library/react'
import React from 'react'
+import {act, render} from '@testing-library/react'
+import MatchMediaMock from 'jest-matchmedia-mock'
import {ThemeProvider} from '..'
+import {viewportRanges} from '../hooks/useResponsiveValue'
import {PageLayout} from './PageLayout'
+let matchMedia: MatchMediaMock
+
describe('PageLayout', () => {
+ beforeAll(() => {
+ matchMedia = new MatchMediaMock()
+ })
+
+ afterEach(() => {
+ matchMedia.clear()
+ })
+
it('renders default layout', () => {
const {container} = render(
@@ -63,4 +75,44 @@ describe('PageLayout', () => {
)
expect(container).toMatchSnapshot()
})
+
+ it('can hide pane when narrow', () => {
+ // Set narrow viewport
+ act(() => {
+ matchMedia.useMediaQuery(viewportRanges.narrow)
+ })
+
+ const {getByText} = render(
+
+
+ Header
+ Content
+ Pane
+ Footer
+
+
+ )
+
+ expect(getByText('Pane')).not.toBeVisible()
+ })
+
+ it('shows all subcomponents by default', () => {
+ // Set regular viewport
+ act(() => {
+ matchMedia.useMediaQuery(viewportRanges.regular)
+ })
+
+ const {getByText} = render(
+
+
+ Header
+ Content
+ Pane
+ Footer
+
+
+ )
+
+ expect(getByText('Pane')).toBeVisible()
+ })
})
diff --git a/src/PageLayout/PageLayout.tsx b/src/PageLayout/PageLayout.tsx
index b013246e569..eaab7e4c4a5 100644
--- a/src/PageLayout/PageLayout.tsx
+++ b/src/PageLayout/PageLayout.tsx
@@ -1,6 +1,7 @@
import React from 'react'
-import {BetterSystemStyleObject, merge, SxProp} from '../sx'
import {Box} from '..'
+import {ResponsiveValue, useResponsiveValue} from '../hooks/useResponsiveValue'
+import {BetterSystemStyleObject, merge, SxProp} from '../sx'
const REGION_ORDER = {
header: 0,
@@ -178,18 +179,22 @@ const VerticalDivider: React.FC = ({variant = 'none', variantWhenN
export type PageLayoutHeaderProps = {
divider?: 'none' | 'line'
dividerWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled'
+ hidden?: boolean | ResponsiveValue
} & SxProp
const Header: React.FC = ({
divider = 'none',
dividerWhenNarrow = 'inherit',
+ hidden = false,
children,
sx = {}
}) => {
+ const isHidden = useResponsiveValue(hidden, false)
const {rowGap} = React.useContext(PageLayoutContext)
return (
(
{
order: REGION_ORDER.header,
@@ -216,6 +221,7 @@ Header.displayName = 'PageLayout.Header'
export type PageLayoutContentProps = {
width?: keyof typeof contentWidths
+ hidden?: boolean | ResponsiveValue
} & SxProp
// TODO: Account for pane width when centering content
@@ -226,10 +232,12 @@ const contentWidths = {
xlarge: '1280px'
}
-const Content: React.FC = ({width = 'full', children, sx = {}}) => {
+const Content: React.FC = ({width = 'full', hidden = false, children, sx = {}}) => {
+ const isHidden = useResponsiveValue(hidden, false)
return (
(
{
order: REGION_ORDER.content,
@@ -260,6 +268,7 @@ export type PageLayoutPaneProps = {
width?: keyof typeof paneWidths
divider?: 'none' | 'line'
dividerWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled'
+ hidden?: boolean | ResponsiveValue
} & SxProp
const panePositions = {
@@ -279,9 +288,11 @@ const Pane: React.FC = ({
width = 'medium',
divider = 'none',
dividerWhenNarrow = 'inherit',
+ hidden = false,
children,
sx = {}
}) => {
+ const isHidden = useResponsiveValue(hidden, false)
const {rowGap, columnGap} = React.useContext(PageLayoutContext)
const computedPositionWhenNarrow = positionWhenNarrow === 'inherit' ? position : positionWhenNarrow
const computedDividerWhenNarrow = dividerWhenNarrow === 'inherit' ? divider : dividerWhenNarrow
@@ -293,7 +304,7 @@ const Pane: React.FC = ({
merge(
{
order: panePositions[computedPositionWhenNarrow],
- display: 'flex',
+ display: isHidden ? 'none' : 'flex',
flexDirection: computedPositionWhenNarrow === 'end' ? 'column' : 'column-reverse',
width: '100%',
marginX: 0,
@@ -335,18 +346,22 @@ Pane.displayName = 'PageLayout.Pane'
export type PageLayoutFooterProps = {
divider?: 'none' | 'line'
dividerWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled'
+ hidden?: boolean | ResponsiveValue
} & SxProp
const Footer: React.FC = ({
divider = 'none',
dividerWhenNarrow = 'inherit',
+ hidden = false,
children,
sx = {}
}) => {
+ const isHidden = useResponsiveValue(hidden, false)
const {rowGap} = React.useContext(PageLayoutContext)
return (
(
{
order: REGION_ORDER.footer,
diff --git a/src/__tests__/hooks/useResponsiveValue.test.tsx b/src/__tests__/hooks/useResponsiveValue.test.tsx
new file mode 100644
index 00000000000..73d7b0083fa
--- /dev/null
+++ b/src/__tests__/hooks/useResponsiveValue.test.tsx
@@ -0,0 +1,99 @@
+import {act, render} from '@testing-library/react'
+import MatchMediaMock from 'jest-matchmedia-mock'
+import {ResponsiveValue, useResponsiveValue, viewportRanges} from '../../hooks/useResponsiveValue'
+import React from 'react'
+
+let matchMedia: MatchMediaMock
+
+beforeAll(() => {
+ matchMedia = new MatchMediaMock()
+})
+
+afterEach(() => {
+ matchMedia.clear()
+})
+
+it('accepts non-responsive values', () => {
+ const Component = () => {
+ const value = useResponsiveValue('test', 'fallback')
+ return {value}
+ }
+
+ const {getByText} = render()
+
+ expect(getByText('test')).toBeInTheDocument()
+})
+
+it('returns narrow value when viewport is narrow', () => {
+ const Component = () => {
+ const value = useResponsiveValue({narrow: false, regular: true} as ResponsiveValue, true)
+ return {JSON.stringify(value)}
+ }
+
+ // Set narrow viewport
+ act(() => {
+ matchMedia.useMediaQuery(viewportRanges.narrow)
+ })
+
+ const {getByText} = render()
+
+ expect(getByText('false')).toBeInTheDocument()
+})
+
+it('returns wide value when viewport is wide', () => {
+ const Component = () => {
+ const value = useResponsiveValue(
+ {narrow: 'narrowValue', regular: 'regularValue', wide: 'wideValue'} as ResponsiveValue,
+ 'fallbackValue'
+ )
+ return {value}
+ }
+
+ // Set wide viewport
+ act(() => {
+ matchMedia.useMediaQuery(viewportRanges.wide)
+ })
+
+ const {getByText} = render()
+
+ expect(getByText('wideValue')).toBeInTheDocument()
+})
+
+it('returns regular value when viewport is regular', () => {
+ const Component = () => {
+ const value = useResponsiveValue(
+ {narrow: 'narrowValue', regular: 'regularValue', wide: 'wideValue'} as ResponsiveValue,
+ 'fallbackValue'
+ )
+ return {value}
+ }
+
+ // Set regular viewport
+ act(() => {
+ matchMedia.useMediaQuery(viewportRanges.regular)
+ })
+
+ const {getByText} = render()
+
+ expect(getByText('regularValue')).toBeInTheDocument()
+})
+
+it('returns fallback when no value is defined for current viewport', () => {
+ const Component = () => {
+ const value = useResponsiveValue(
+ // Missing value for `regular` viewports
+ {narrow: 'narrowValue', wide: 'wideValue'} as ResponsiveValue,
+ 'fallbackValue'
+ )
+ return {value}
+ }
+
+ // Set regular viewport
+ act(() => {
+ matchMedia.useMediaQuery(viewportRanges.regular)
+ })
+
+ const {getByText} = render()
+
+ expect(getByText('fallbackValue')).toBeInTheDocument()
+})
diff --git a/src/__tests__/hooks/useResponsiveValues.types.test.tsx b/src/__tests__/hooks/useResponsiveValues.types.test.tsx
new file mode 100644
index 00000000000..9941e55bee5
--- /dev/null
+++ b/src/__tests__/hooks/useResponsiveValues.types.test.tsx
@@ -0,0 +1,22 @@
+import React from 'react'
+import {ResponsiveValue, useResponsiveValue} from '../../hooks/useResponsiveValue'
+
+export function ShouldAcceptNonResponsiveValues() {
+ const value: string = useResponsiveValue('test', 'fallback')
+ return {value}
+}
+
+export function ShouldFlattenResponsiveValueTypes() {
+ const responsiveValue: ResponsiveValue<
+ // regular options
+ 'a' | 'b',
+ // narrow options
+ 'a' | 'b' | 'c'
+ > = {
+ regular: 'a',
+ narrow: 'c'
+ }
+
+ const value: 'a' | 'b' | 'c' = useResponsiveValue(responsiveValue, 'b')
+ return {value}
+}
diff --git a/src/hooks/useMedia.ts b/src/hooks/useMedia.ts
new file mode 100644
index 00000000000..e582b59ee64
--- /dev/null
+++ b/src/hooks/useMedia.ts
@@ -0,0 +1,49 @@
+// Copied from https://github.com/streamich/react-use/blob/master/src/useMedia.ts
+
+import React from 'react'
+
+const getInitialState = (query: string, defaultState?: boolean) => {
+ // Prevent a React hydration mismatch when a default value is provided by not defaulting to window.matchMedia(query).matches.
+ if (defaultState !== undefined) {
+ return defaultState
+ }
+
+ if (typeof window !== 'undefined') {
+ return window.matchMedia(query).matches
+ }
+
+ // A default value has not been provided, and you are rendering on the server, warn of a possible hydration mismatch when defaulting to false.
+ if (process.env.NODE_ENV !== 'production') {
+ // eslint-disable-next-line no-console
+ console.warn(
+ '`useMedia` When server side rendering, defaultState should be defined to prevent a hydration mismatches.'
+ )
+ }
+
+ return false
+}
+
+export function useMedia(query: string, defaultState?: boolean) {
+ const [state, setState] = React.useState(getInitialState(query, defaultState))
+
+ React.useEffect(() => {
+ let mounted = true
+ const mql = window.matchMedia(query)
+ const onChange = () => {
+ if (!mounted) {
+ return
+ }
+ setState(!!mql.matches)
+ }
+
+ mql.addListener(onChange)
+ setState(mql.matches)
+
+ return () => {
+ mounted = false
+ mql.removeListener(onChange)
+ }
+ }, [query])
+
+ return state
+}
diff --git a/src/hooks/useResponsiveValue.ts b/src/hooks/useResponsiveValue.ts
new file mode 100644
index 00000000000..efdcaa1d561
--- /dev/null
+++ b/src/hooks/useResponsiveValue.ts
@@ -0,0 +1,75 @@
+import {useMedia} from './useMedia'
+
+// This file contains utilities for working with responsive values.
+
+// The viewport range values from @primer/primtives don't work in Chrome
+// because they use `em` units inside `calc()` (e.g., calc(48em - 0.02px)).
+// As a temporary workaround, we're hardcoding the viewport ranges in `px` units.
+// TODO: Use viewport range tokens from @primer/primitives
+export const viewportRanges = {
+ narrow: '(max-width: calc(768px - 0.02px))', // < 768px
+ regular: '(min-width: 768px)', // >= 768px
+ wide: '(min-width: 1400px)' // >= 1400px
+}
+
+export type ResponsiveValue = {
+ narrow?: TNarrow // Applies when viewport is narrow
+ regular?: TRegular // Applies when viewports is regular
+ wide?: TWide // Applies when viewports is wide
+}
+
+/**
+ * Flattens all possible value types into single union type
+ * For example, if `T` is `'none' | 'line' | Responsive<'none' | 'line' | 'filled'>`,
+ * `FlattenResponsiveValue` will be `'none' | 'line' | 'filled'`
+ */
+export type FlattenResponsiveValue =
+ | (T extends ResponsiveValue ? TRegular | TNarrow | TWide : never)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ | Exclude>
+
+/**
+ * Checks if the value is a responsive value.
+ * In other words, is it an object with viewport range keys?
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function isResponsiveValue(value: any): value is ResponsiveValue {
+ return typeof value === 'object' && Object.keys(value).some(key => ['narrow', 'regular', 'wide'].includes(key))
+}
+
+/**
+ * Resolves responsive values based on the current viewport width.
+ * For example, if the current viewport width is narrow (less than 768px), the value of `{regular: 'foo', narrow: 'bar'}` will resolve to `'bar'`.
+ *
+ * @example
+ * const value = useResponsiveValue({regular: 'foo', narrow: 'bar'})
+ * console.log(value) // 'bar'
+ */
+// TODO: Improve SRR support
+export function useResponsiveValue(value: T, fallback: F): FlattenResponsiveValue | F {
+ // Check viewport size
+ // TODO: What is the performance cost of creating media query listeners in this hook?
+ const isNarrowViewport = useMedia(viewportRanges.narrow)
+ const isRegularViewport = useMedia(viewportRanges.regular)
+ const isWideViewport = useMedia(viewportRanges.wide)
+
+ if (isResponsiveValue(value)) {
+ // If we've reached this line, we know that value is a responsive value
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const responsiveValue = value as Extract>
+
+ if (isNarrowViewport && 'narrow' in responsiveValue) {
+ return responsiveValue.narrow
+ } else if (isWideViewport && 'wide' in responsiveValue) {
+ return responsiveValue.wide
+ } else if (isRegularViewport && 'regular' in responsiveValue) {
+ return responsiveValue.regular
+ } else {
+ return fallback
+ }
+ } else {
+ // If we've reached this line, we know that value is not a responsive value
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return value as Exclude>
+ }
+}