Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PageLayout: Implement responsive hidden prop #2174

Merged
merged 11 commits into from
Jul 26, 2022
10 changes: 10 additions & 0 deletions .changeset/empty-garlics-clean.md
Original file line number Diff line number Diff line change
@@ -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
<PageLayout.Pane hidden={{narrow: true}}>...</PageLayout.Pane>
```
65 changes: 63 additions & 2 deletions docs/content/PageLayout.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,25 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
</PageLayout>
```

### With pane hidden on narrow viewports

```jsx live
<PageLayout>
<PageLayout.Header>
<Placeholder label="Header" height={64} />
</PageLayout.Header>
<PageLayout.Content>
<Placeholder label="Content" height={240} />
</PageLayout.Content>
<PageLayout.Pane position="start" hidden={{narrow: true}}>
<Placeholder label="Pane" height={120} />
</PageLayout.Pane>
<PageLayout.Footer>
<Placeholder label="Footer" height={64} />
</PageLayout.Footer>
</PageLayout>
```

### With condensed spacing

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

### PageLayout

<!-- TODO: Responsive variants -->

<PropsTable>
<PropsTableRow
name="containerWidth"
Expand Down Expand Up @@ -166,6 +183,17 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
| 'filled'`}
defaultValue="'inherit'"
/>
<PropsTableRow
name="hidden"
type={`| boolean
| {
narrow?: boolean
regular?: boolean
wide?: boolean
}`}
defaultValue="false"
description="Whether the header is hidden."
/>
<PropsTableSxRow />
</PropsTable>

Expand All @@ -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."
/>
<PropsTableRow
name="hidden"
type={`| boolean
| {
narrow?: boolean
regular?: boolean
wide?: boolean
}`}
defaultValue="false"
description="Whether the content is hidden."
/>
<PropsTableSxRow />
</PropsTable>

Expand Down Expand Up @@ -222,6 +261,17 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
| 'filled'`}
defaultValue="'inherit'"
/>
<PropsTableRow
name="hidden"
type={`| boolean
| {
narrow?: boolean
regular?: boolean
wide?: boolean
}`}
defaultValue="false"
description="Whether the pane is hidden."
/>
<PropsTableSxRow />
</PropsTable>

Expand All @@ -242,6 +292,17 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
| 'filled'`}
defaultValue="'inherit'"
/>
<PropsTableRow
name="hidden"
type={`| boolean
| {
narrow?: boolean
regular?: boolean
wide?: boolean
}`}
defaultValue="false"
description="Whether the footer is hidden."
/>
<PropsTableSxRow />
</PropsTable>

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
54 changes: 53 additions & 1 deletion src/PageLayout/PageLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ThemeProvider>
Expand Down Expand Up @@ -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(
<ThemeProvider>
<PageLayout>
<PageLayout.Header>Header</PageLayout.Header>
<PageLayout.Content>Content</PageLayout.Content>
<PageLayout.Pane hidden={{narrow: true}}>Pane</PageLayout.Pane>
<PageLayout.Footer>Footer</PageLayout.Footer>
</PageLayout>
</ThemeProvider>
)

expect(getByText('Pane')).not.toBeVisible()
})

it('shows all subcomponents by default', () => {
// Set regular viewport
act(() => {
matchMedia.useMediaQuery(viewportRanges.regular)
})

const {getByText} = render(
<ThemeProvider>
<PageLayout>
<PageLayout.Header>Header</PageLayout.Header>
<PageLayout.Content>Content</PageLayout.Content>
<PageLayout.Pane hidden={{narrow: true}}>Pane</PageLayout.Pane>
<PageLayout.Footer>Footer</PageLayout.Footer>
</PageLayout>
</ThemeProvider>
)

expect(getByText('Pane')).toBeVisible()
})
})
21 changes: 18 additions & 3 deletions src/PageLayout/PageLayout.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -178,18 +179,22 @@ const VerticalDivider: React.FC<DividerProps> = ({variant = 'none', variantWhenN
export type PageLayoutHeaderProps = {
divider?: 'none' | 'line'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking nitpick because it's unrelated to this PR: : What if instead of having divider and dividerWhenNarrow, we just had a divider type that looked like this:

'none' | 'line' | {
  narrow: 'inherit' | 'none' | 'line' | 'filled' 
  regular: 'none' | 'line'
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally agree! That's what I'm planning to do next:

CleanShot 2022-07-26 at 08 33 19@2x

dividerWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking nitpick because it's unrelated to this PR: I find it a little strange that this type is different from divider. I'm do we never want 'filled' on wider viewports?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll defer to @vdepizzol

hidden?: boolean | ResponsiveValue<boolean>
} & SxProp

const Header: React.FC<PageLayoutHeaderProps> = ({
divider = 'none',
dividerWhenNarrow = 'inherit',
hidden = false,
children,
sx = {}
}) => {
const isHidden = useResponsiveValue(hidden, false)
const {rowGap} = React.useContext(PageLayoutContext)
return (
<Box
as="header"
hidden={isHidden}
sx={merge<BetterSystemStyleObject>(
{
order: REGION_ORDER.header,
Expand All @@ -216,6 +221,7 @@ Header.displayName = 'PageLayout.Header'

export type PageLayoutContentProps = {
width?: keyof typeof contentWidths
hidden?: boolean | ResponsiveValue<boolean>
} & SxProp

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

const Content: React.FC<PageLayoutContentProps> = ({width = 'full', children, sx = {}}) => {
const Content: React.FC<PageLayoutContentProps> = ({width = 'full', hidden = false, children, sx = {}}) => {
const isHidden = useResponsiveValue(hidden, false)
return (
<Box
as="main"
hidden={isHidden}
sx={merge<BetterSystemStyleObject>(
{
order: REGION_ORDER.content,
Expand Down Expand Up @@ -260,6 +268,7 @@ export type PageLayoutPaneProps = {
width?: keyof typeof paneWidths
divider?: 'none' | 'line'
dividerWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled'
hidden?: boolean | ResponsiveValue<boolean>
} & SxProp

const panePositions = {
Expand All @@ -279,9 +288,11 @@ const Pane: React.FC<PageLayoutPaneProps> = ({
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
Expand All @@ -293,7 +304,7 @@ const Pane: React.FC<PageLayoutPaneProps> = ({
merge<BetterSystemStyleObject>(
{
order: panePositions[computedPositionWhenNarrow],
display: 'flex',
display: isHidden ? 'none' : 'flex',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just confirming that we want hidden to prevent screen readers from accessing the content too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe so 👍 cc @hectahertz @alliethu @ichelsea to confirm

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's part of the page layout that shouldn't be able to be accessed by anyone, then we'd want to hide if from screen reader users too.

flexDirection: computedPositionWhenNarrow === 'end' ? 'column' : 'column-reverse',
width: '100%',
marginX: 0,
Expand Down Expand Up @@ -335,18 +346,22 @@ Pane.displayName = 'PageLayout.Pane'
export type PageLayoutFooterProps = {
divider?: 'none' | 'line'
dividerWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled'
hidden?: boolean | ResponsiveValue<boolean>
} & SxProp

const Footer: React.FC<PageLayoutFooterProps> = ({
divider = 'none',
dividerWhenNarrow = 'inherit',
hidden = false,
children,
sx = {}
}) => {
const isHidden = useResponsiveValue(hidden, false)
const {rowGap} = React.useContext(PageLayoutContext)
return (
<Box
as="footer"
hidden={isHidden}
sx={merge<BetterSystemStyleObject>(
{
order: REGION_ORDER.footer,
Expand Down
99 changes: 99 additions & 0 deletions src/__tests__/hooks/useResponsiveValue.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <div>{value}</div>
}

const {getByText} = render(<Component />)

expect(getByText('test')).toBeInTheDocument()
})

it('returns narrow value when viewport is narrow', () => {
const Component = () => {
const value = useResponsiveValue({narrow: false, regular: true} as ResponsiveValue<boolean>, true)
return <div>{JSON.stringify(value)}</div>
}

// Set narrow viewport
act(() => {
matchMedia.useMediaQuery(viewportRanges.narrow)
})

const {getByText} = render(<Component />)

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<string>,
'fallbackValue'
)
return <div>{value}</div>
}

// Set wide viewport
act(() => {
matchMedia.useMediaQuery(viewportRanges.wide)
})

const {getByText} = render(<Component />)

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<string>,
'fallbackValue'
)
return <div>{value}</div>
}

// Set regular viewport
act(() => {
matchMedia.useMediaQuery(viewportRanges.regular)
})

const {getByText} = render(<Component />)

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<string>,
'fallbackValue'
)
return <div>{value}</div>
}

// Set regular viewport
act(() => {
matchMedia.useMediaQuery(viewportRanges.regular)
})

const {getByText} = render(<Component />)

expect(getByText('fallbackValue')).toBeInTheDocument()
})
Loading