From 50c7bfaa283154e28d1905e3eb01f7f01901c806 Mon Sep 17 00:00:00 2001 From: Cole Bemis Date: Mon, 7 Feb 2022 10:31:34 -0800 Subject: [PATCH] Implement PageLayout component (#1820) * Scaffold pagelayout component * Apply base styles to storybook stories * Create subcomponents * Implement stacking behavior * Implement containerWidth prop * Implement outerSpacing prop * Implement rowGap and columnGap props * Implement sx prop * Updated control panel * Use tshirt sizes for spacing * Implement position and positionWhenNarrow props * Update spacing names * Implement header divider * Implement pane width prop * Implement contentWidth prop * Add comment about 'none' * Implement header divider (messy) * Implement divider component * Make dividers full width on narrow viewports * Implement row and column gap * Clean up * Add render subcomponent option to story * Export PageLayout from drafts * Update PageLayout docs * Create pink-flowers-raise.md * Add seperator role to horizontal divider * Add pull request page example * Update src/PageLayout/PageLayout.tsx * Implement sx prop * Update docs/content/drafts/PageLayout.mdx * Update docs/content/drafts/PageLayout.mdx * Fix merge conflict * Re-enable storybook html addon * Rename "outerSpacing" prop to "padding" * Add 'none' as a spacing option * Update drafts exports --- .changeset/pink-flowers-raise.md | 5 + .storybook/preview.js | 18 +- docs/content/drafts/PageLayout.mdx | 146 +++++-- .../gatsby-theme-doctocat/live-code-scope.js | 58 +-- src/PageLayout/PageLayout.stories.tsx | 298 ++++++++++++++ src/PageLayout/PageLayout.tsx | 363 ++++++++++++++++++ src/PageLayout/index.ts | 1 + src/Placeholder.tsx | 24 ++ src/drafts/index.ts | 1 + 9 files changed, 848 insertions(+), 66 deletions(-) create mode 100644 .changeset/pink-flowers-raise.md create mode 100644 src/PageLayout/PageLayout.stories.tsx create mode 100644 src/PageLayout/PageLayout.tsx create mode 100644 src/PageLayout/index.ts create mode 100644 src/Placeholder.tsx diff --git a/.changeset/pink-flowers-raise.md b/.changeset/pink-flowers-raise.md new file mode 100644 index 00000000000..f25ada64493 --- /dev/null +++ b/.changeset/pink-flowers-raise.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +Add draft `PageLayout` component diff --git a/.storybook/preview.js b/.storybook/preview.js index d822350fba1..8f5eca7b9bd 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,5 +1,5 @@ import {addons} from '@storybook/addons' -import {ThemeProvider, themeGet, theme} from '../src' +import {ThemeProvider, themeGet, theme, BaseStyles} from '../src' import styled, {createGlobalStyle} from 'styled-components' import {addDecorator} from '@storybook/react' import {withPerformance} from 'storybook-addon-performance' @@ -86,9 +86,11 @@ const withThemeProvider = (Story, context) => { nightScheme={context.globals.nightScheme} > -
- -
+ +
+ +
+
@@ -102,9 +104,11 @@ const withThemeProvider = (Story, context) => { nightScheme={context.globals.nightScheme} > -
- -
+ +
+ +
+
) } diff --git a/docs/content/drafts/PageLayout.mdx b/docs/content/drafts/PageLayout.mdx index bb0f5f59e28..1791ae2b150 100644 --- a/docs/content/drafts/PageLayout.mdx +++ b/docs/content/drafts/PageLayout.mdx @@ -1,43 +1,109 @@ --- title: PageLayout status: Draft -description: TODO +# description: TODO +source: https://github.com/primer/react/tree/main/src/PageLayout +storybook: https://primer.style/react/storybook?path=/story/layout-pagelayout--playground --- -Not implemented yet +```js +import {PageLayout} from '@primer/react/drafts' +``` ## Examples + + +See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayout--playground) for fullscreen examples. + + + ### Default -```jsx +```jsx live drafts - Header - Content - Pane - Footer + + + + + + + + + + + + ``` ### With dividers -```jsx +```jsx live drafts + + + + + + + + + + + + + + +``` + +### With pane on left + +```jsx live drafts - Header - Content - Pane - Footer + + + + + + + + + + + + + +``` + +### With condensed spacing + +```jsx live drafts + + + + + + + + + + + + + ``` -### Condensed +### Without header or footer -```jsx - - Header - Content - Pane - Footer +```jsx live drafts + + + + + + + ``` @@ -58,21 +124,24 @@ description: TODO description="The maximum width of the page container." /> @@ -82,7 +151,12 @@ description: TODO ### PageLayout.Header - + @@ -131,9 +205,14 @@ description: TODO | 'medium' | 'large'`} defaultValue="'medium'" - description="Define the width of the pane." + description="The width of the pane." + /> + - - + { // eslint-disable-next-line jsx-a11y/anchor-has-content @@ -86,6 +87,7 @@ export default function resolveScope(metastring) { ConfirmationDialog, useConfirm, AnchoredOverlay, - SelectPanel + SelectPanel, + Placeholder } } diff --git a/src/PageLayout/PageLayout.stories.tsx b/src/PageLayout/PageLayout.stories.tsx new file mode 100644 index 00000000000..75ad48f0a87 --- /dev/null +++ b/src/PageLayout/PageLayout.stories.tsx @@ -0,0 +1,298 @@ +import {Meta, Story} from '@storybook/react' +import React from 'react' +import {Box, BranchName, Heading, Link, StateLabel, TabNav, Text} from '..' +import {Placeholder} from '../Placeholder' +import {PageLayout} from './PageLayout' + +const meta: Meta = { + title: 'Layout/PageLayout', + component: PageLayout, + parameters: { + layout: 'fullscreen', + controls: {expanded: true} + } +} + +export const Playground: Story = args => ( + + {args['Show header?'] ? ( + + + + ) : null} + + + + {args['Show pane?'] ? ( + + + + ) : null} + {args['Show footer?'] ? ( + + + + ) : null} + +) + +Playground.argTypes = { + 'Show header?': { + type: 'boolean', + defaultValue: true, + table: { + category: 'Header' + } + }, + 'Header.divider': { + type: { + name: 'enum', + value: ['none', 'line'] + }, + defaultValue: 'none', + control: { + type: 'radio' + }, + table: { + category: 'Header', + defaultValue: { + summary: '"none"' + } + } + }, + 'Header.dividerWhenNarrow': { + type: { + name: 'enum', + value: ['inherit', 'none', 'line', 'filled'] + }, + defaultValue: 'inherit', + control: { + type: 'radio' + }, + table: { + category: 'Header', + defaultValue: { + summary: '"inherit"' + } + } + }, + 'Content.width': { + type: { + name: 'enum', + value: ['full', 'medium', 'large', 'xlarge'] + }, + defaultValue: 'full', + control: { + type: 'radio' + }, + table: { + category: 'Content', + defaultValue: { + summary: '"full"' + } + } + }, + 'Show pane?': { + type: 'boolean', + defaultValue: true, + table: { + category: 'Pane' + } + }, + 'Pane.position': { + type: { + name: 'enum', + value: ['start', 'end'] + }, + defaultValue: 'end', + control: { + type: 'radio' + }, + table: { + category: 'Pane', + defaultValue: { + summary: '"end"' + } + } + }, + 'Pane.positionWhenNarrow': { + type: { + name: 'enum', + value: ['inherit', 'start', 'end'] + }, + defaultValue: 'inherit', + control: { + type: 'radio' + }, + table: { + category: 'Pane', + defaultValue: { + summary: '"inherit"' + } + } + }, + 'Pane.width': { + type: { + name: 'enum', + value: ['small', 'medium', 'large'] + }, + defaultValue: 'medium', + control: { + type: 'radio' + }, + table: { + category: 'Pane', + defaultValue: { + summary: '"medium"' + } + } + }, + 'Pane.divider': { + type: { + name: 'enum', + value: ['none', 'line'] + }, + defaultValue: 'none', + control: { + type: 'radio' + }, + table: { + category: 'Pane', + defaultValue: { + summary: '"none"' + } + } + }, + 'Pane.dividerWhenNarrow': { + type: { + name: 'enum', + value: ['inherit', 'none', 'line', 'filled'] + }, + defaultValue: 'inherit', + control: { + type: 'radio' + }, + table: { + category: 'Pane', + defaultValue: { + summary: '"inherit"' + } + } + }, + 'Show footer?': { + type: 'boolean', + defaultValue: true, + table: { + category: 'Footer' + } + }, + 'Footer.divider': { + type: { + name: 'enum', + value: ['none', 'line'] + }, + defaultValue: 'none', + control: { + type: 'radio' + }, + table: { + category: 'Footer', + defaultValue: { + summary: '"none"' + } + } + }, + 'Footer.dividerWhenNarrow': { + type: { + name: 'enum', + value: ['inherit', 'none', 'line', 'filled'] + }, + defaultValue: 'inherit', + control: { + type: 'radio' + }, + table: { + category: 'Footer', + defaultValue: { + summary: '"inherit"' + } + } + } +} + +Playground.args = { + containerWidth: 'xlarge', + padding: 'normal', + rowGap: 'normal', + columnGap: 'normal' +} + +export const PullRequestPage = () => ( + + + + + + Input validation styles #1831 + + + Open + + + mperrotti + {' '} + wants to merge 3 commits into main from{' '} + mp/validation-styles + + + + + + Conversation + + Commits + Checks + Files changed + + + + + + + + + + Assignees + + No one –{' '} + + assign yourself + + + + + + Labels + None yet + + + + +) + +// TODO: discussions page example +// TODO: settings page example + +export default meta diff --git a/src/PageLayout/PageLayout.tsx b/src/PageLayout/PageLayout.tsx new file mode 100644 index 00000000000..a7781c0da98 --- /dev/null +++ b/src/PageLayout/PageLayout.tsx @@ -0,0 +1,363 @@ +import React from 'react' +import {Box} from '..' +import {BetterSystemStyleObject, merge, SxProp} from '../sx' + +const REGION_ORDER = { + header: 0, + paneStart: 1, + content: 2, + paneEnd: 3, + footer: 4 +} + +const SPACING_MAP = { + none: 0, + condensed: 3, + normal: [3, null, null, 4] +} + +const PageLayoutContext = React.createContext<{ + padding: keyof typeof SPACING_MAP + rowGap: keyof typeof SPACING_MAP + columnGap: keyof typeof SPACING_MAP +}>({ + padding: 'normal', + rowGap: 'normal', + columnGap: 'normal' +}) + +// ---------------------------------------------------------------------------- +// PageLayout + +export type PageLayoutProps = { + /** The maximum width of the page container */ + containerWidth?: keyof typeof containerWidths + /** The spacing between the outer edges of the page container and the viewport */ + padding?: keyof typeof SPACING_MAP + rowGap?: keyof typeof SPACING_MAP + columnGap?: keyof typeof SPACING_MAP +} & SxProp + +const containerWidths = { + full: '100%', + medium: '768px', + large: '1012px', + xlarge: '1280px' +} + +// TODO: refs +const Root: React.FC = ({ + containerWidth = 'xlarge', + padding = 'normal', + rowGap = 'normal', + columnGap = 'normal', + children, + sx = {} +}) => { + return ( + + ({padding: SPACING_MAP[padding]}, sx)}> + + {children} + + + + ) +} + +Root.displayName = 'PageLayout' + +// ---------------------------------------------------------------------------- +// Divider (internal) + +type DividerProps = { + variant?: 'none' | 'line' + variantWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled' +} & SxProp + +const horizontalDividerVariants = { + none: { + display: 'none' + }, + line: { + display: 'block', + height: 1, + backgroundColor: 'border.default' + }, + filled: { + display: 'block', + height: 8, + backgroundColor: 'canvas.inset', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + boxShadow: (theme: any) => + `inset 0 -1px 0 0 ${theme.colors.border.default}, inset 0 1px 0 0 ${theme.colors.border.default}` + } +} + +function negateSpacingValue(value: number | null | Array) { + if (Array.isArray(value)) { + // Not using recursion to avoid deeply nested arrays + return value.map(v => (v === null ? null : -v)) + } + + return value === null ? null : -value +} + +const HorizontalDivider: React.FC = ({variant = 'none', variantWhenNarrow = 'inherit', sx = {}}) => { + const {padding} = React.useContext(PageLayoutContext) + return ( + + merge( + { + // Stretch divider to viewport edges on narrow screens + marginX: negateSpacingValue(SPACING_MAP[padding]), + ...horizontalDividerVariants[variantWhenNarrow === 'inherit' ? variant : variantWhenNarrow], + [`@media screen and (min-width: ${theme.breakpoints[1]})`]: { + marginX: '0 !important', + ...horizontalDividerVariants[variant] + } + }, + sx + ) + } + /> + ) +} + +const verticalDividerVariants = { + none: { + display: 'none' + }, + line: { + display: 'block', + width: 1, + backgroundColor: 'border.default' + }, + filled: { + display: 'block', + width: 8, + backgroundColor: 'canvas.inset', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + boxShadow: (theme: any) => + `inset -1px 0 0 0 ${theme.colors.border.default}, inset 1px 0 0 0 ${theme.colors.border.default}` + } +} + +const VerticalDivider: React.FC = ({variant = 'none', variantWhenNarrow = 'inherit', sx = {}}) => { + return ( + + merge( + { + height: '100%', + ...verticalDividerVariants[variantWhenNarrow === 'inherit' ? variant : variantWhenNarrow], + [`@media screen and (min-width: ${theme.breakpoints[1]})`]: { + ...verticalDividerVariants[variant] + } + }, + sx + ) + } + /> + ) +} + +// ---------------------------------------------------------------------------- +// PageLayout.Header + +export type PageLayoutHeaderProps = { + divider?: 'none' | 'line' + dividerWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled' +} & SxProp + +const Header: React.FC = ({ + divider = 'none', + dividerWhenNarrow = 'inherit', + children, + sx = {} +}) => { + const {rowGap} = React.useContext(PageLayoutContext) + return ( + ( + { + order: REGION_ORDER.header, + width: '100%', + marginBottom: SPACING_MAP[rowGap] + }, + sx + )} + > + {children} + + + ) +} + +Header.displayName = 'PageLayout.Header' + +// ---------------------------------------------------------------------------- +// PageLayout.Content + +export type PageLayoutContentProps = { + width?: keyof typeof contentWidths +} & SxProp + +// TODO: Account for pane width when centering content +const contentWidths = { + full: '100%', + medium: '768px', + large: '1012px', + xlarge: '1280px' +} + +const Content: React.FC = ({width = 'full', children, sx = {}}) => { + return ( + ({order: REGION_ORDER.content, flexGrow: 1}, sx)}> + {children} + + ) +} + +Content.displayName = 'PageLayout.Content' + +// ---------------------------------------------------------------------------- +// PageLayout.Pane + +export type PageLayoutPaneProps = { + position?: keyof typeof panePositions + positionWhenNarrow?: 'inherit' | keyof typeof panePositions + width?: keyof typeof paneWidths + divider?: 'none' | 'line' + dividerWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled' +} & SxProp + +const panePositions = { + start: REGION_ORDER.paneStart, + end: REGION_ORDER.paneEnd +} + +const paneWidths = { + small: ['100%', null, '240px', '256px'], + medium: ['100%', null, '256px', '296px'], + large: ['100%', null, '256px', '320px', '336px'] +} + +const Pane: React.FC = ({ + position = 'end', + positionWhenNarrow = 'inherit', + width = 'medium', + divider = 'none', + dividerWhenNarrow = 'inherit', + children, + sx = {} +}) => { + const {rowGap, columnGap} = React.useContext(PageLayoutContext) + const computedPositionWhenNarrow = positionWhenNarrow === 'inherit' ? position : positionWhenNarrow + const computedDividerWhenNarrow = dividerWhenNarrow === 'inherit' ? divider : dividerWhenNarrow + return ( + + merge( + { + order: panePositions[computedPositionWhenNarrow], + display: 'flex', + flexDirection: computedPositionWhenNarrow === 'end' ? 'column' : 'column-reverse', + width: '100%', + marginX: 0, + [computedPositionWhenNarrow === 'end' ? 'marginTop' : 'marginBottom']: SPACING_MAP[rowGap], + [`@media screen and (min-width: ${theme.breakpoints[1]})`]: { + width: 'auto', + [position === 'end' ? 'marginLeft' : 'marginRight']: SPACING_MAP[columnGap], + marginY: `0 !important`, + flexDirection: position === 'end' ? 'row' : 'row-reverse', + order: panePositions[position] + } + }, + sx + ) + } + > + {/* Show a horiztonal divider when viewport is narrow. Otherwise, show a vertical divider. */} + + + + {children} + + ) +} + +Pane.displayName = 'PageLayout.Pane' + +// ---------------------------------------------------------------------------- +// PageLayout.Footer + +export type PageLayoutFooterProps = { + divider?: 'none' | 'line' + dividerWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled' +} & SxProp + +const Footer: React.FC = ({ + divider = 'none', + dividerWhenNarrow = 'inherit', + children, + sx = {} +}) => { + const {rowGap} = React.useContext(PageLayoutContext) + return ( + ( + { + order: REGION_ORDER.footer, + width: '100%', + marginTop: SPACING_MAP[rowGap] + }, + sx + )} + > + + {children} + + ) +} + +Footer.displayName = 'PageLayout.Footer' + +// ---------------------------------------------------------------------------- +// Export + +export const PageLayout = Object.assign(Root, { + Header, + Content, + Pane, + Footer +}) diff --git a/src/PageLayout/index.ts b/src/PageLayout/index.ts new file mode 100644 index 00000000000..c3fa77184db --- /dev/null +++ b/src/PageLayout/index.ts @@ -0,0 +1 @@ +export * from './PageLayout' diff --git a/src/Placeholder.tsx b/src/Placeholder.tsx new file mode 100644 index 00000000000..1cfcdff1cc9 --- /dev/null +++ b/src/Placeholder.tsx @@ -0,0 +1,24 @@ +import {Box} from '.' +import React from 'react' + +/** Private component used to render placeholders in storybook and documentation examples */ +export const Placeholder: React.FC<{ + width?: number | string + height: number | string + label?: string +}> = ({width, height, label}) => { + return ( + + {label} + + ) +} diff --git a/src/drafts/index.ts b/src/drafts/index.ts index efa9ff196c5..291e56dafbb 100644 --- a/src/drafts/index.ts +++ b/src/drafts/index.ts @@ -11,3 +11,4 @@ export * from '../Button2' export * from '../ActionMenu2' export * from '../DropdownMenu2' export * from '../Label2' +export * from '../PageLayout'