diff --git a/.changeset/lovely-hats-guess.md b/.changeset/lovely-hats-guess.md new file mode 100644 index 00000000000..0f270a72919 --- /dev/null +++ b/.changeset/lovely-hats-guess.md @@ -0,0 +1,5 @@ +--- +"@primer/react": minor +--- + +Convert PageLayout to CSS modules behind feature flags diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css new file mode 100644 index 00000000000..ddc5400b2f3 --- /dev/null +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -0,0 +1,329 @@ +/* Maintain resize cursor while dragging */ +/* stylelint-disable-next-line selector-no-qualifying-type */ +body[data-page-layout-dragging='true'] { + cursor: col-resize; +} + +/* Disable text selection while dragging */ +/* stylelint-disable-next-line selector-no-qualifying-type */ +body[data-page-layout-dragging='true'] * { + user-select: none; +} + +.PageLayoutRoot { + /* Region Order */ + --region-order-header: 0; + --region-order-pane-start: 1; + --region-order-content: 2; + --region-order-pane-end: 3; + --region-order-footer: 4; + + /* Spacing Values */ + --spacing-none: 0; + --spacing-condensed: var(--base-size-16); + --spacing-normal: var(--base-size-16); + + @media screen and (min-width: 1012px) { + --spacing-normal: var(--base-size-24); + } + + /* Pane Width Values */ + --pane-width-small: 100%; + --pane-width-medium: 100%; + --pane-width-large: 100%; + --pane-max-width-diff: 511px; + + @media screen and (min-width: 768px) { + --pane-width-small: 240px; + --pane-width-medium: 256px; + --pane-width-large: 256px; + } + + @media screen and (min-width: 1012px) { + --pane-width-small: 256px; + --pane-width-medium: 296px; + --pane-width-large: 320px; + } + + @media screen and (min-width: 1280px) { + --pane-width-large: 336px; + --pane-max-width-diff: 959px; + } + + /* These following CSS variables are dynamic values that get overridden by styles passed in via props. */ + --spacing: 0; + --spacing-row: 0; + --spacing-column: 0; + --spacing-divider: 0; + --offset-header: 0; + --sticky-pane-height: 0; + --pane-width: 0; + --pane-min-width: 0; + --pane-max-width: 0; + --pane-width-custom: 0; + --pane-width-size: 0; + + /* stylelint-disable-next-line primer/spacing */ + padding: var(--spacing); +} + +.PageLayoutWrapper { + display: flex; + margin-right: auto; + margin-left: auto; + flex-wrap: wrap; + + &:where([data-width='medium']) { + max-width: 768px; + } + + &:where([data-width='large']) { + max-width: 1012px; + } + + &:where([data-width='full']) { + max-width: 100%; + } + + &:where([data-width='xlarge']) { + max-width: 1280px; + } +} + +.PageLayoutContent { + display: flex; + flex: 1 1 100%; + flex-wrap: wrap; + max-width: 100%; +} + +.HorizontalDivider { + /* stylelint-disable-next-line primer/spacing */ + margin-right: calc(-1 * var(--spacing-divider)); + /* stylelint-disable-next-line primer/spacing */ + margin-left: calc(-1 * var(--spacing-divider)); + + &:where([data-variant='none']) { + display: none; + } + + &:where([data-variant='line']) { + display: block; + height: 1px; + /* stylelint-disable-next-line primer/colors */ + background-color: var(--borderColor-default); + } + + &:where([data-variant='filled']) { + display: block; + height: var(--base-size-8); + background-color: var(--bgColor-inset); + box-shadow: + /* stylelint-disable-next-line primer/box-shadow */ + inset 0 -1px 0 0 var(--borderColor-default), + inset 0 1px 0 0 var(--borderColor-default); + } + + @media screen and (min-width: 768px) { + margin-right: 0 !important; + margin-left: 0 !important; + } +} + +.VerticalDivider { + position: relative; + height: 100%; + + &:where([data-variant='none']) { + display: none; + } + + &:where([data-variant='line']) { + display: block; + width: 1px; + /* stylelint-disable-next-line primer/colors */ + background-color: var(--borderColor-default); + } + + &:where([data-variant='filled']) { + display: block; + width: var(--base-size-8); + background-color: var(--bgColor-inset); + box-shadow: + /* stylelint-disable-next-line primer/box-shadow */ + inset -1px 0 0 0 var(--borderColor-default), + inset 1px 0 0 0 var(--borderColor-default); + } +} + +.Header { + width: 100%; + /* stylelint-disable-next-line primer/spacing */ + margin-bottom: var(--spacing); +} + +.HeaderContent { + /* stylelint-disable-next-line primer/spacing */ + padding: var(--spacing); +} + +.HeaderHorizontalDivider { + /* stylelint-disable-next-line primer/spacing */ + margin-top: var(--spacing); +} + +.ContentWrapper { + display: flex; + + /* Hack to prevent overflowing content from pushing the pane region to the next line */ + min-width: 1px; + flex-direction: column; + order: var(--region-order-content); + + /* Set flex-basis to 0% to allow flex-grow to control the width of the content region. + Without this, the content region could wrap onto a different line + than the pane region on wide viewports if its contents are too wide. */ + flex-basis: 0; + flex-grow: 1; + flex-shrink: 1; + + &:where([data-is-hidden='true']) { + display: none; + } +} + +.Content { + width: 100%; + + /* stylelint-disable-next-line primer/spacing */ + padding: var(--spacing); + margin-right: auto; + margin-left: auto; + flex-grow: 1; + + &:where([data-width='medium']) { + max-width: 768px; + } + + &:where([data-width='large']) { + max-width: 1012px; + } + + &:where([data-width='full']) { + max-width: 100%; + } + + &:where([data-width='xlarge']) { + max-width: 1280px; + } +} + +.PaneWrapper { + display: flex; + width: 100%; + margin-right: 0; + margin-left: 0; + + &:where([data-is-hidden='true']) { + display: none; + } + + &:where([data-position='end']) { + /* stylelint-disable-next-line primer/spacing */ + margin-top: var(--spacing-row); + flex-direction: column; + order: var(--region-order-pane-end); + } + + &:where([data-position='start']) { + /* stylelint-disable-next-line primer/spacing */ + margin-bottom: var(--spacing-row); + flex-direction: column-reverse; + order: var(--region-order-pane-start); + } + + @media screen and (min-width: 768px) { + width: auto; + margin-top: 0 !important; + margin-bottom: 0 !important; + + &:where([data-sticky]) { + position: sticky; + /* stylelint-disable-next-line primer/spacing */ + top: var(--offset-header); + max-height: var(--sticky-pane-height); + } + + &:where([data-position='end']) { + /* stylelint-disable-next-line primer/spacing */ + margin-left: var(--spacing-column); + flex-direction: row-reverse; + } + + &:where([data-position='start']) { + /* stylelint-disable-next-line primer/spacing */ + margin-right: var(--spacing-column); + flex-direction: row; + } + } +} + +.PaneVerticalDivider { + &:where([data-position='start']) { + /* stylelint-disable-next-line primer/spacing */ + margin-left: var(--spacing); + } + + &:where([data-position='end']) { + /* stylelint-disable-next-line primer/spacing */ + margin-right: var(--spacing); + } +} + +.Pane { + width: var(--pane-width-size); + /* stylelint-disable-next-line primer/spacing */ + padding: var(--spacing); + + @media screen and (min-width: 768px) { + overflow: auto; + } + + &:where([data-resizable]) { + width: 100%; + + @media screen and (min-width: 768px) { + width: clamp(var(--pane-min-width), var(--pane-width), var(--pane-max-width)); + } + } +} + +.PaneHorizontalDivider { + &:where([data-position='start']) { + /* stylelint-disable-next-line primer/spacing */ + margin-top: var(--spacing); + } + + &:where([data-position='end']) { + /* stylelint-disable-next-line primer/spacing */ + margin-bottom: var(--spacing); + } +} + +.FooterWrapper { + width: 100%; + order: var(--region-order-footer); + + /* stylelint-disable-next-line primer/spacing */ + margin-top: var(--spacing); +} + +.FooterHorizontalDivider { + /* stylelint-disable-next-line primer/spacing */ + margin-bottom: var(--spacing); +} + +.FooterContent { + /* stylelint-disable-next-line primer/spacing */ + padding: var(--spacing); +} diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index dee02530fcc..d4d3b18c30e 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -1,5 +1,6 @@ import React, {useRef} from 'react' import {createGlobalStyle} from 'styled-components' +import {clsx} from 'clsx' import Box from '../Box' import {useId} from '../hooks/useId' import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef' @@ -13,6 +14,9 @@ import {canUseDOM} from '../utils/environment' import {useOverflow} from '../hooks/useOverflow' import {warning} from '../utils/warning' import {useStickyPaneHeight} from './useStickyPaneHeight' +import {useFeatureFlag} from '../FeatureFlags' + +import classes from './PageLayout.module.css' const REGION_ORDER = { header: 0, @@ -57,8 +61,12 @@ export type PageLayoutProps = { /** Private prop to allow SplitPageLayout to customize slot components */ _slotsConfig?: Record<'header' | 'footer', React.ElementType> + className?: string + style?: React.CSSProperties } & SxProp +const CSS_MODULES_FEATURE_FLAG = 'primer_react_css_modules_team' + const containerWidths = { full: '100%', medium: '768px', @@ -74,15 +82,47 @@ const Root: React.FC> = ({ columnGap = 'normal', children, sx = {}, + className, + style, _slotsConfig: slotsConfig, }) => { const {rootRef, enableStickyPane, disableStickyPane, contentTopRef, contentBottomRef, stickyPaneHeight} = useStickyPaneHeight() const paneRef = useRef(null) + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) const [slots, rest] = useSlots(children, slotsConfig ?? {header: Header, footer: Footer}) + const stylingProps = enabled + ? { + sx, + className: clsx(classes.PageLayoutRoot, className), + } + : { + sx: merge({padding: SPACING_MAP[padding]}, sx), + className, + } + + const wrapperStylingProps = enabled + ? {className: classes.PageLayoutWrapper, 'data-width': containerWidth} + : { + sx: { + maxWidth: containerWidths[containerWidth], + marginX: 'auto', + display: 'flex', + flexWrap: 'wrap', + }, + } + + const contentStylingProps = enabled + ? { + className: clsx(classes.PageLayoutContent, className), + } + : { + sx: {display: 'flex', flex: '1 1 100%', flexWrap: 'wrap', maxWidth: '100%'}, + } + return ( > = ({ > ({padding: SPACING_MAP[padding]}, sx)} + style={ + { + '--sticky-pane-height': stickyPaneHeight, + '--spacing': `var(--spacing-${padding})`, + ...style, + } as React.CSSProperties + } + {...stylingProps} > - + {slots.header} - {rest} + {rest} {slots.footer} @@ -128,6 +164,9 @@ Root.displayName = 'PageLayout' type DividerProps = { variant?: 'none' | 'line' | 'filled' | ResponsiveValue<'none' | 'line' | 'filled'> + className?: string + style?: React.CSSProperties + position?: keyof typeof panePositions } & SxProp const horizontalDividerVariants = { @@ -143,8 +182,7 @@ const horizontalDividerVariants = { display: 'block', height: 8, backgroundColor: 'canvas.inset', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - boxShadow: (theme: any) => + boxShadow: (theme: Theme) => `inset 0 -1px 0 0 ${theme.colors.border.default}, inset 0 1px 0 0 ${theme.colors.border.default}`, }, } @@ -158,27 +196,46 @@ function negateSpacingValue(value: number | null | Array) { return value === null ? null : -value } -const HorizontalDivider: React.FC> = ({variant = 'none', sx = {}}) => { +const HorizontalDivider: React.FC> = ({ + variant = 'none', + sx = {}, + className, + position, + style, +}) => { const {padding} = React.useContext(PageLayoutContext) const responsiveVariant = useResponsiveValue(variant, 'none') - return ( - - merge( - { - // Stretch divider to viewport edges on narrow screens - marginX: negateSpacingValue(SPACING_MAP[padding]), - ...horizontalDividerVariants[responsiveVariant], - [`@media screen and (min-width: ${theme.breakpoints[1]})`]: { - marginX: '0 !important', + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) + + const stylingProps = enabled + ? { + sx, + className: clsx(classes.HorizontalDivider, className), + 'data-variant': responsiveVariant, + 'data-position': position, + style: { + '--spacing-divider': `var(--spacing-${padding})`, + ...style, + } as React.CSSProperties, + } + : { + sx: (theme: Theme) => + merge( + { + // Stretch divider to viewport edges on narrow screens + marginX: negateSpacingValue(SPACING_MAP[padding]), + ...horizontalDividerVariants[responsiveVariant], + [`@media screen and (min-width: ${theme.breakpoints[1]})`]: { + marginX: '0 !important', + }, }, - }, - sx, - ) + sx, + ), + className, + style, } - /> - ) + + return } const verticalDividerVariants = { @@ -194,8 +251,7 @@ const verticalDividerVariants = { display: 'block', width: 8, backgroundColor: 'canvas.inset', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - boxShadow: (theme: any) => + boxShadow: (theme: Theme) => `inset -1px 0 0 0 ${theme.colors.border.default}, inset 1px 0 0 0 ${theme.colors.border.default}`, }, } @@ -227,11 +283,15 @@ const VerticalDivider: React.FC { const [isDragging, setIsDragging] = React.useState(false) const [isKeyboardDrag, setIsKeyboardDrag] = React.useState(false) const responsiveVariant = useResponsiveValue(variant, 'none') + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) const stableOnDrag = React.useRef(onDrag) const stableOnDragEnd = React.useRef(onDragEnd) @@ -322,17 +382,29 @@ const VerticalDivider: React.FC( - { - height: '100%', - position: 'relative', - ...verticalDividerVariants[responsiveVariant], - }, + const stylingProps = enabled + ? { sx, - )} - > + className: clsx(classes.VerticalDivider, className), + 'data-variant': responsiveVariant, + 'data-position': position, + style, + } + : { + sx: merge( + { + height: '100%', + position: 'relative', + ...verticalDividerVariants[responsiveVariant], + }, + sx, + ), + className, + style, + } + + return ( + {draggable ? ( // Drag handle <> @@ -373,7 +445,7 @@ const VerticalDivider: React.FC - + {!enabled && } ) : null} @@ -412,6 +484,8 @@ export type PageLayoutHeaderProps = { */ dividerWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled' hidden?: boolean | ResponsiveValue + className?: string + style?: React.CSSProperties } & SxProp const Header: React.FC> = ({ @@ -422,7 +496,9 @@ const Header: React.FC> = ({ dividerWhenNarrow = 'inherit', hidden = false, children, + style, sx = {}, + className, }) => { // Combine divider and dividerWhenNarrow for backwards compatibility const dividerProp = @@ -433,22 +509,64 @@ const Header: React.FC> = ({ const dividerVariant = useResponsiveValue(dividerProp, 'none') const isHidden = useResponsiveValue(hidden, false) const {rowGap} = React.useContext(PageLayoutContext) + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) + + const headerStylingProps = enabled + ? { + sx, + className: clsx(classes.Header, className), + style: { + '--spacing': `var(--spacing-${rowGap})`, + } as React.CSSProperties, + } + : { + sx: merge( + { + width: '100%', + marginBottom: SPACING_MAP[rowGap], + }, + sx, + ), + className, + } + + const contentStylingProps = enabled + ? { + className: classes.HeaderContent, + style: { + '--spacing': `var(--spacing-${padding})`, + } as React.CSSProperties, + } + : { + sx: { + padding: SPACING_MAP[padding], + }, + } + + const dividerStylingProps = enabled + ? { + className: classes.HeaderHorizontalDivider, + style: { + '--spacing': `var(--spacing-${rowGap})`, + } as React.CSSProperties, + } + : { + sx: { + marginTop: SPACING_MAP[rowGap], + }, + } + return ( ) } @@ -477,6 +595,8 @@ export type PageLayoutContentProps = { width?: keyof typeof contentWidths padding?: keyof typeof SPACING_MAP hidden?: boolean | ResponsiveValue + className?: string + style?: React.CSSProperties } & SxProp // TODO: Account for pane width when centering content @@ -496,45 +616,62 @@ const Content: React.FC> = ({ hidden = false, children, sx = {}, + className, + style, }) => { const isHidden = useResponsiveValue(hidden, false) const {contentTopRef, contentBottomRef} = React.useContext(PageLayoutContext) + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) - return ( - ( - { - display: isHidden ? 'none' : 'flex', - flexDirection: 'column', - order: REGION_ORDER.content, - // Set flex-basis to 0% to allow flex-grow to control the width of the content region. - // Without this, the content region could wrap onto a different line - // than the pane region on wide viewports if its contents are too wide. - flexBasis: 0, - flexGrow: 1, - flexShrink: 1, - minWidth: 1, // Hack to prevent overflowing content from pushing the pane region to the next line - }, + const wrapperStylingProps = enabled + ? { sx, - )} - > - {/* Track the top of the content region so we can calculate the height of the pane region */} - + className: clsx(classes.ContentWrapper, className), + 'data-is-hidden': isHidden, + } + : { + sx: merge( + { + display: isHidden ? 'none' : 'flex', + flexDirection: 'column', + order: REGION_ORDER.content, + // Set flex-basis to 0% to allow flex-grow to control the width of the content region. + // Without this, the content region could wrap onto a different line + // than the pane region on wide viewports if its contents are too wide. + flexBasis: 0, + flexGrow: 1, + flexShrink: 1, + minWidth: 1, // Hack to prevent overflowing content from pushing the pane region to the next line + }, + sx, + ), + className, + } - - {children} - + }, + } + + return ( + + {/* Track the top of the content region so we can calculate the height of the pane region */} + + + {children} {/* Track the bottom of the content region so we can calculate the height of the pane region */} @@ -610,6 +747,8 @@ export type PageLayoutPaneProps = { offsetHeader?: string | number hidden?: boolean | ResponsiveValue id?: string + className?: string + style?: React.CSSProperties } & SxProp const panePositions = { @@ -645,9 +784,13 @@ const Pane = React.forwardRef { + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) + // Combine position and positionWhenNarrow for backwards compatibility const positionProp = !isResponsiveValue(responsivePosition) && positionWhenNarrow !== 'inherit' @@ -733,60 +876,103 @@ const Pane = React.forwardRef - merge( - { - // Narrow viewports - display: isHidden ? 'none' : 'flex', - order: panePositions[position], - width: '100%', - marginX: 0, - ...(position === 'end' - ? {flexDirection: 'column', marginTop: SPACING_MAP[rowGap]} - : {flexDirection: 'column-reverse', marginBottom: SPACING_MAP[rowGap]}), - - // Regular and wide viewports - [`@media screen and (min-width: ${theme.breakpoints[1]})`]: { - width: 'auto', - marginY: '0 !important', - ...(sticky - ? { - position: 'sticky', - // If offsetHeader has value, it will stick the pane to the position where the sticky top ends - // else top will be 0 as the default value of offsetHeader - top: typeof offsetHeader === 'number' ? `${offsetHeader}px` : offsetHeader, - maxHeight: 'var(--sticky-pane-height)', - } - : {}), + const paneWrapperStylingProps = enabled + ? { + sx, + className: clsx(classes.PaneWrapper, className), + style: { + '--offset-header': typeof offsetHeader === 'number' ? `${offsetHeader}px` : offsetHeader, + '--spacing-row': `var(--spacing-${rowGap})`, + '--spacing-column': `var(--spacing-${columnGap})`, + ...style, + }, + 'data-is-hidden': isHidden, + 'data-position': position, + 'data-sticky': sticky || undefined, + } + : { + className, + sx: (theme: Theme) => + merge( + { + // Narrow viewports + display: isHidden ? 'none' : 'flex', + order: panePositions[position], + width: '100%', + marginX: 0, ...(position === 'end' - ? {flexDirection: 'row-reverse', marginLeft: SPACING_MAP[columnGap]} - : {flexDirection: 'row', marginRight: SPACING_MAP[columnGap]}), + ? {flexDirection: 'column', marginTop: SPACING_MAP[rowGap]} + : {flexDirection: 'column-reverse', marginBottom: SPACING_MAP[rowGap]}), + + // Regular and wide viewports + [`@media screen and (min-width: ${theme.breakpoints[1]})`]: { + width: 'auto', + marginY: '0 !important', + ...(sticky + ? { + position: 'sticky', + // If offsetHeader has value, it will stick the pane to the position where the sticky top ends + // else top will be 0 as the default value of offsetHeader + top: typeof offsetHeader === 'number' ? `${offsetHeader}px` : offsetHeader, + maxHeight: 'var(--sticky-pane-height)', + } + : {}), + ...(position === 'end' + ? {flexDirection: 'row-reverse', marginLeft: SPACING_MAP[columnGap]} + : {flexDirection: 'row', marginRight: SPACING_MAP[columnGap]}), + }, }, - }, - sx, - ) + sx, + ), + style, } - > - {/* Show a horizontal divider when viewport is narrow. Otherwise, show a vertical divider. */} - - ({ + } as React.CSSProperties, + } + : { + sx: (theme: Theme) => ({ '--pane-min-width': isCustomWidthOptions(width) ? width.min : `${minWidth}px`, '--pane-max-width-diff': '511px', '--pane-max-width': isCustomWidthOptions(width) ? width.max : `calc(100vw - var(--pane-max-width-diff))`, width: resizable - ? ['100%', null, 'clamp(var(--pane-min-width), var(--pane-width), var(--pane-max-width))'] + ? ['100%', null, `clamp(var(--pane-min-width), var(--pane-width), var(--pane-max-width))`] : isPaneWidth(width) ? paneWidths[width] : width.default, @@ -796,14 +982,29 @@ const Pane = React.forwardRef + {/* Show a horizontal divider when viewport is narrow. Otherwise, show a vertical divider. */} + + {children} - { // Get the number of pixels the divider was dragged let deltaWithDirection @@ -829,8 +1029,10 @@ const Pane = React.forwardRef updatePaneWidth(getDefaultPaneWidth(width))} + {...verticalDividerStylingProps} /> ) @@ -870,6 +1072,8 @@ export type PageLayoutFooterProps = { */ dividerWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled' hidden?: boolean | ResponsiveValue + className?: string + style?: React.CSSProperties } & SxProp const Footer: React.FC> = ({ @@ -881,6 +1085,8 @@ const Footer: React.FC> = ({ hidden = false, children, sx = {}, + className, + style, }) => { // Combine divider and dividerWhenNarrow for backwards compatibility const dividerProp = @@ -891,23 +1097,59 @@ const Footer: React.FC> = ({ const dividerVariant = useResponsiveValue(dividerProp, 'none') const isHidden = useResponsiveValue(hidden, false) const {rowGap} = React.useContext(PageLayoutContext) - return ( -