diff --git a/.changeset/forty-hornets-exercise.md b/.changeset/forty-hornets-exercise.md new file mode 100644 index 00000000000..2f5eb8c0058 --- /dev/null +++ b/.changeset/forty-hornets-exercise.md @@ -0,0 +1,5 @@ +--- +"@primer/react": minor +--- + +Convert Dialog v2 to CSS Modules behind feature flag diff --git a/packages/react/src/Dialog/Dialog.module.css b/packages/react/src/Dialog/Dialog.module.css new file mode 100644 index 00000000000..444df0b3510 --- /dev/null +++ b/packages/react/src/Dialog/Dialog.module.css @@ -0,0 +1,253 @@ +@keyframes dialog-backdrop-appear { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes Overlay--motion-scaleFade { + 0% { + opacity: 0; + transform: scale(0.5); + } + + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes Overlay--motion-slideUp { + from { + transform: translateY(100%); + } +} + +@keyframes Overlay--motion-slideInRight { + from { + transform: translateX(-100%); + } +} + +@keyframes Overlay--motion-slideInLeft { + from { + transform: translateX(100%); + } +} + +.Backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: flex; + background-color: var(--overlay-backdrop-bgColor); + animation: dialog-backdrop-appear 200ms cubic-bezier(0.33, 1, 0.68, 1); + align-items: center; + justify-content: center; + + &[data-position-regular='center'] { + align-items: center; + justify-content: center; + } + + &[data-position-regular='left'] { + align-items: center; + justify-content: flex-start; + } + + &[data-position-regular='right'] { + align-items: center; + justify-content: flex-end; + } + + .DialogOverflowWrapper { + flex-grow: 1; + } + + @media (max-width: 767px) { + &[data-position-narrow='center'] { + align-items: center; + justify-content: center; + } + + &[data-position-narrow='bottom'] { + align-items: end; + justify-content: center; + } + } +} + +.Dialog { + display: flex; + /* stylelint-disable-next-line primer/responsive-widths */ + width: 640px; + min-width: 296px; + max-width: calc(100dvw - 64px); + height: auto; + max-height: calc(100dvh - 64px); + flex-direction: column; + background-color: var(--overlay-bgColor); + border-radius: var(--borderRadius-large); + border-radius: var(--borderRadius-large, var(--borderRadius-large)); + box-shadow: var(--shadow-floating-small); + opacity: 1; + + &:where([data-width='small']) { + width: 296px; + } + + &:where([data-width='medium']) { + width: 320px; + } + + &:where([data-width='large']) { + /* stylelint-disable-next-line primer/responsive-widths */ + width: 480px; + } + + &:where([data-height='small']) { + height: 480px; + } + + &:where([data-height='large']) { + height: 640px; + } + + @media screen and (prefers-reduced-motion: no-preference) { + animation: Overlay--motion-scaleFade 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running; + } + + &[data-position-regular='center'] { + border-radius: var(--borderRadius-large, var(--borderRadius-large)); + + @media screen and (prefers-reduced-motion: no-preference) { + animation: Overlay--motion-scaleFade 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running; + } + } + + &[data-position-regular='left'] { + height: 100dvh; + max-height: unset; + border-radius: var(--borderRadius-large, var(--borderRadius-large)); + border-top-left-radius: 0; + border-bottom-left-radius: 0; + + @media screen and (prefers-reduced-motion: no-preference) { + animation: Overlay--motion-slideInRight 0.25s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running; + } + } + + &[data-position-regular='right'] { + height: 100dvh; + max-height: unset; + border-radius: var(--borderRadius-large, var(--borderRadius-large)); + border-top-right-radius: 0; + border-bottom-right-radius: 0; + + @media screen and (prefers-reduced-motion: no-preference) { + animation: Overlay--motion-slideInLeft 0.25s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running; + } + } + + @media (max-width: 767px) { + &[data-position-narrow='center'] { + /* stylelint-disable-next-line primer/responsive-widths */ + width: 640px; + height: auto; + border-radius: var(--borderRadius-large, var(--borderRadius-large)); + + &:where([data-width='small']) { + width: 296px; + } + + &:where([data-width='medium']) { + width: 320px; + } + + &:where([data-width='large']) { + /* stylelint-disable-next-line primer/responsive-widths */ + width: 480px; + } + + &:where([data-height='small']) { + height: 480px; + } + + &:where([data-height='large']) { + height: 640px; + } + } + + &[data-position-narrow='bottom'] { + width: 100dvw; + max-width: 100dvw; + height: auto; + max-height: calc(100dvh - 64px); + border-radius: var(--borderRadius-large, var(--borderRadius-large)); + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + + @media screen and (prefers-reduced-motion: no-preference) { + animation: Overlay--motion-slideUp 0.25s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running; + } + } + + &[data-position-narrow='fullscreen'] { + width: 100%; + max-width: 100dvw; + height: 100%; + max-height: 100dvh; + border-radius: unset !important; + flex-grow: 1; + + @media screen and (prefers-reduced-motion: no-preference) { + animation: Overlay--motion-scaleFade 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running; + } + } + } +} + +.Header { + z-index: 1; + padding: var(--base-size-8); + /* stylelint-disable-next-line primer/box-shadow */ + box-shadow: 0 1px 0 var(--borderColor-default); + flex-shrink: 0; +} + +.Title { + margin: 0; /* override default margin */ + font-size: var(--text-body-size-medium); + font-weight: var(--text-title-weight-large); +} + +.Subtitle { + margin: 0; /* override default margin */ + margin-top: var(--base-size-4); + font-size: var(--text-body-size-small); + font-weight: var(--base-text-weight-normal); + color: var(--fgColor-muted); +} + +.Body { + padding: var(--base-size-16); + overflow: auto; + flex-grow: 1; +} + +.Footer { + z-index: 1; + display: flex; + padding: var(--base-size-16); + /* stylelint-disable-next-line primer/box-shadow */ + box-shadow: 0 -1px 0 var(--borderColor-default); + flex-flow: wrap; + justify-content: flex-end; + gap: var(--base-size-8); + flex-shrink: 0; +} diff --git a/packages/react/src/Dialog/Dialog.test.tsx b/packages/react/src/Dialog/Dialog.test.tsx index 85095fa0b20..1d707fd321f 100644 --- a/packages/react/src/Dialog/Dialog.test.tsx +++ b/packages/react/src/Dialog/Dialog.test.tsx @@ -6,6 +6,7 @@ import MatchMediaMock from 'jest-matchmedia-mock' import {behavesAsComponent, checkExports} from '../utils/testing' import axe from 'axe-core' import {Button} from '../Button' +import {FeatureFlags} from '../FeatureFlags' let matchMedia: MatchMediaMock @@ -226,6 +227,53 @@ describe('Dialog', () => { expect(getByRole('button', {name: 'return focus to (button 2)'})).toHaveFocus() }) + + it('should support `className` on the Dialog element', async () => { + const Fixture = () => { + const [isOpen, setIsOpen] = React.useState(true) + const triggerRef = React.useRef(null) + + return ( + <> + + {isOpen && ( + setIsOpen(false)} returnFocusRef={triggerRef} className="custom-class"> + body + + )} + + ) + } + + const FeatureFlagElement = () => { + return ( + + + + ) + } + + const user = userEvent.setup() + + let component = render() + let triggerButton = component.getByRole('button', {name: 'Show dialog'}) + await user.click(triggerButton) + expect(component.getByRole('dialog')).toHaveClass('custom-class') + component.unmount() + + component = render() + triggerButton = component.getByRole('button', {name: 'Show dialog'}) + await user.click(triggerButton) + expect(component.getByRole('dialog')).toHaveClass('custom-class') + }) }) it('automatically focuses the element that is specified as initialFocusRef', () => { diff --git a/packages/react/src/Dialog/Dialog.tsx b/packages/react/src/Dialog/Dialog.tsx index 13d1b4fd752..de6299e7e1f 100644 --- a/packages/react/src/Dialog/Dialog.tsx +++ b/packages/react/src/Dialog/Dialog.tsx @@ -16,6 +16,12 @@ import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef' import {useId} from '../hooks/useId' import {ScrollableRegion} from '../ScrollableRegion' import type {ResponsiveValue} from '../hooks/useResponsiveValue' +import {toggleStyledComponent} from '../internal/utils/toggleStyledComponent' +import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' + +import classes from './Dialog.module.css' +import {useFeatureFlag} from '../FeatureFlags' +import {clsx} from 'clsx' /* Dialog Version 2 */ @@ -166,58 +172,65 @@ export interface DialogHeaderProps extends DialogProps { dialogDescriptionId: string } -const Backdrop = styled('div')` - position: fixed; - top: 0; - left: 0; - bottom: 0; - right: 0; - display: flex; - align-items: center; - justify-content: center; - background-color: ${get('colors.primer.canvas.backdrop')}; - animation: dialog-backdrop-appear 200ms ${get('animation.easeOutCubic')}; - - &[data-position-regular='center'] { +const CSS_MODULES_FEATURE_FLAG = 'primer_react_css_modules_team' + +const Backdrop = toggleStyledComponent( + CSS_MODULES_FEATURE_FLAG, + 'div', + styled.div` + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + display: flex; align-items: center; justify-content: center; - } - - &[data-position-regular='left'] { - align-items: center; - justify-content: flex-start; - } + background-color: ${get('colors.primer.canvas.backdrop')}; + animation: dialog-backdrop-appear 200ms ${get('animation.easeOutCubic')}; - &[data-position-regular='right'] { - align-items: center; - justify-content: flex-end; - } + &[data-position-regular='center'] { + align-items: center; + justify-content: center; + } - .DialogOverflowWrapper { - flex-grow: 1; - } + &[data-position-regular='left'] { + align-items: center; + justify-content: flex-start; + } - @media (max-width: 767px) { - &[data-position-narrow='center'] { + &[data-position-regular='right'] { align-items: center; - justify-content: center; + justify-content: flex-end; } - &[data-position-narrow='bottom'] { - align-items: end; - justify-content: center; + .DialogOverflowWrapper { + flex-grow: 1; } - } - @keyframes dialog-backdrop-appear { - 0% { - opacity: 0; + @media (max-width: 767px) { + &[data-position-narrow='center'] { + align-items: center; + justify-content: center; + } + + &[data-position-narrow='bottom'] { + align-items: end; + justify-content: center; + } } - 100% { - opacity: 1; + + @keyframes dialog-backdrop-appear { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } } - } -` + `, +) +Backdrop.displayName = 'Backdrop' const heightMap = { small: '480px', @@ -238,123 +251,128 @@ export type DialogHeight = keyof typeof heightMap type StyledDialogProps = { width?: DialogWidth height?: DialogHeight -} & SxProp - -const StyledDialog = styled.div` - display: flex; - flex-direction: column; - background-color: ${get('colors.canvas.overlay')}; - box-shadow: ${get('shadows.overlay.shadow')}; - width: ${props => widthMap[props.width ?? ('xlarge' as const)]}; - height: ${props => heightMap[props.height ?? ('auto' as const)]}; - min-width: 296px; - max-width: calc(100dvw - 64px); - max-height: calc(100dvh - 64px); - border-radius: 12px; - opacity: 1; - - @media screen and (prefers-reduced-motion: no-preference) { - animation: Overlay--motion-scaleFade 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running; - } - - &[data-position-regular='center'] { - border-radius: var(--borderRadius-large, 0.75rem); +} & SxProp & + React.ComponentPropsWithRef<'div'> + +const StyledDialog = toggleStyledComponent( + CSS_MODULES_FEATURE_FLAG, + 'div', + styled.div` + display: flex; + flex-direction: column; + background-color: ${get('colors.canvas.overlay')}; + box-shadow: ${get('shadows.overlay.shadow')}; + width: ${props => widthMap[props.width ?? ('xlarge' as const)]}; + height: ${props => heightMap[props.height ?? ('auto' as const)]}; + min-width: 296px; + max-width: calc(100dvw - 64px); + max-height: calc(100dvh - 64px); + border-radius: 12px; + opacity: 1; @media screen and (prefers-reduced-motion: no-preference) { animation: Overlay--motion-scaleFade 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running; } - } - - &[data-position-regular='left'] { - height: 100dvh; - max-height: unset; - border-radius: var(--borderRadius-large, 0.75rem); - border-top-left-radius: 0; - border-bottom-left-radius: 0; - - @media screen and (prefers-reduced-motion: no-preference) { - animation: Overlay--motion-slideInRight 0.25s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running; - } - } - &[data-position-regular='right'] { - height: 100dvh; - max-height: unset; - border-radius: var(--borderRadius-large, 0.75rem); - border-top-right-radius: 0; - border-bottom-right-radius: 0; + &[data-position-regular='center'] { + border-radius: var(--borderRadius-large, 0.75rem); - @media screen and (prefers-reduced-motion: no-preference) { - animation: Overlay--motion-slideInLeft 0.25s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running; + @media screen and (prefers-reduced-motion: no-preference) { + animation: Overlay--motion-scaleFade 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running; + } } - } - @media (max-width: 767px) { - &[data-position-narrow='center'] { + &[data-position-regular='left'] { + height: 100dvh; + max-height: unset; border-radius: var(--borderRadius-large, 0.75rem); - width: ${props => widthMap[props.width ?? ('xlarge' as const)]}; - height: ${props => heightMap[props.height ?? ('auto' as const)]}; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + + @media screen and (prefers-reduced-motion: no-preference) { + animation: Overlay--motion-slideInRight 0.25s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running; + } } - &[data-position-narrow='bottom'] { - width: 100dvw; - height: auto; - max-width: 100dvw; - max-height: calc(100dvh - 64px); + &[data-position-regular='right'] { + height: 100dvh; + max-height: unset; border-radius: var(--borderRadius-large, 0.75rem); + border-top-right-radius: 0; border-bottom-right-radius: 0; - border-bottom-left-radius: 0; @media screen and (prefers-reduced-motion: no-preference) { - animation: Overlay--motion-slideUp 0.25s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running; + animation: Overlay--motion-slideInLeft 0.25s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running; } } - &[data-position-narrow='fullscreen'] { - width: 100%; - max-width: 100dvw; - height: 100%; - max-height: 100dvh; - border-radius: unset !important; - flex-grow: 1; + @media (max-width: 767px) { + &[data-position-narrow='center'] { + border-radius: var(--borderRadius-large, 0.75rem); + width: ${props => widthMap[props.width ?? ('xlarge' as const)]}; + height: ${props => heightMap[props.height ?? ('auto' as const)]}; + } - @media screen and (prefers-reduced-motion: no-preference) { - animation: Overlay--motion-scaleFade 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running; + &[data-position-narrow='bottom'] { + width: 100dvw; + height: auto; + max-width: 100dvw; + max-height: calc(100dvh - 64px); + border-radius: var(--borderRadius-large, 0.75rem); + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + + @media screen and (prefers-reduced-motion: no-preference) { + animation: Overlay--motion-slideUp 0.25s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running; + } } - } - } - @keyframes Overlay--motion-scaleFade { - 0% { - opacity: 0; - transform: scale(0.5); + &[data-position-narrow='fullscreen'] { + width: 100%; + max-width: 100dvw; + height: 100%; + max-height: 100dvh; + border-radius: unset !important; + flex-grow: 1; + + @media screen and (prefers-reduced-motion: no-preference) { + animation: Overlay--motion-scaleFade 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running; + } + } } - 100% { - opacity: 1; - transform: scale(1); + + @keyframes Overlay--motion-scaleFade { + 0% { + opacity: 0; + transform: scale(0.5); + } + 100% { + opacity: 1; + transform: scale(1); + } } - } - @keyframes Overlay--motion-slideUp { - from { - transform: translateY(100%); + @keyframes Overlay--motion-slideUp { + from { + transform: translateY(100%); + } } - } - @keyframes Overlay--motion-slideInRight { - from { - transform: translateX(-100%); + @keyframes Overlay--motion-slideInRight { + from { + transform: translateX(-100%); + } } - } - @keyframes Overlay--motion-slideInLeft { - from { - transform: translateX(100%); + @keyframes Overlay--motion-slideInLeft { + from { + transform: translateX(100%); + } } - } - ${sx}; -` + ${sx}; + `, +) const DefaultHeader: React.FC> = ({ dialogLabelId, @@ -416,6 +434,8 @@ const _Dialog = React.forwardRef(null) @@ -481,29 +501,30 @@ const _Dialog = React.forwardRef { + onMouseDown={(e: React.MouseEvent) => { setLastMouseDownIsBackdrop(e.target === e.currentTarget) }} > {header} @@ -518,51 +539,109 @@ const _Dialog = React.forwardRef` - box-shadow: 0 1px 0 ${get('colors.border.default')}; - padding: ${get('space.2')}; - z-index: 1; - flex-shrink: 0; - ${sx}; -` - -const Title = styled.h1` - font-size: ${get('fontSizes.1')}; - font-weight: ${get('fontWeights.bold')}; - margin: 0; /* override default margin */ - ${sx}; -` - -const Subtitle = styled.h2` - font-size: ${get('fontSizes.0')}; - color: ${get('colors.fg.muted')}; - margin: 0; /* override default margin */ - margin-top: ${get('space.1')}; - font-weight: normal; - - ${sx}; -` - -const Body = styled.div` - flex-grow: 1; - overflow: auto; - padding: ${get('space.3')}; - - ${sx}; -` - -const Footer = styled.div` - box-shadow: 0 -1px 0 ${get('colors.border.default')}; - padding: ${get('space.3')}; - display: flex; - flex-flow: wrap; - justify-content: flex-end; - gap: ${get('space.2')}; - z-index: 1; - flex-shrink: 0; - - ${sx}; -` +const StyledHeader = toggleStyledComponent( + CSS_MODULES_FEATURE_FLAG, + 'div', + styled.div` + box-shadow: 0 1px 0 ${get('colors.border.default')}; + padding: ${get('space.2')}; + z-index: 1; + flex-shrink: 0; + ${sx}; + `, +) +type StyledHeaderProps = React.ComponentProps<'div'> & SxProp + +const Header = React.forwardRef(function Header({className, ...rest}, forwardRef) { + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) + return +}) +Header.displayName = 'Dialog.Header' + +const StyledTitle = toggleStyledComponent( + CSS_MODULES_FEATURE_FLAG, + 'h1', + styled.h1` + font-size: ${get('fontSizes.1')}; + font-weight: ${get('fontWeights.bold')}; + margin: 0; /* override default margin */ + ${sx}; + `, +) +type StyledTitleProps = React.ComponentProps<'h1'> & SxProp + +const Title = React.forwardRef(function Title({className, ...rest}, forwardRef) { + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) + return +}) +Title.displayName = 'Dialog.Title' + +const StyledSubtitle = toggleStyledComponent( + CSS_MODULES_FEATURE_FLAG, + 'h2', + styled.h2` + font-size: ${get('fontSizes.0')}; + color: ${get('colors.fg.muted')}; + margin: 0; /* override default margin */ + margin-top: ${get('space.1')}; + font-weight: normal; + ${sx}; + `, +) +type StyledSubtitleProps = React.ComponentProps<'h2'> & SxProp + +const Subtitle = React.forwardRef(function Subtitle( + {className, ...rest}, + forwardRef, +) { + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) + return +}) +Subtitle.displayName = 'Dialog.Subtitle' + +const StyledBody = toggleStyledComponent( + CSS_MODULES_FEATURE_FLAG, + 'div', + styled.div` + flex-grow: 1; + overflow: auto; + padding: ${get('space.3')}; + + ${sx}; + `, +) +type StyledBodyProps = React.ComponentProps<'div'> & SxProp + +const Body = React.forwardRef(function Body({className, ...rest}, forwardRef) { + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) + return +}) as PolymorphicForwardRefComponent<'div', StyledBodyProps> + +Body.displayName = 'Dialog.Body' + +const StyledFooter = toggleStyledComponent( + CSS_MODULES_FEATURE_FLAG, + 'div', + styled.div` + box-shadow: 0 -1px 0 ${get('colors.border.default')}; + padding: ${get('space.3')}; + display: flex; + flex-flow: wrap; + justify-content: flex-end; + gap: ${get('space.2')}; + z-index: 1; + flex-shrink: 0; + + ${sx}; + `, +) +type StyledFooterProps = React.ComponentProps<'div'> & SxProp + +const Footer = React.forwardRef(function Footer({className, ...rest}, forwardRef) { + const enabled = useFeatureFlag(CSS_MODULES_FEATURE_FLAG) + return +}) +Footer.displayName = 'Dialog.Footer' const Buttons: React.FC> = ({buttons}) => { const autoFocusRef = useProvidedRefOrCreate(buttons.find(button => button.autoFocus)?.ref)