diff --git a/.changeset/nice-boxes-sell.md b/.changeset/nice-boxes-sell.md new file mode 100644 index 00000000000..a3c1bf90596 --- /dev/null +++ b/.changeset/nice-boxes-sell.md @@ -0,0 +1,5 @@ +--- +"@primer/react": minor +--- + +feat(Overlay): Convert Overlay to CSS modules behind team feature flag diff --git a/packages/react/src/Overlay/Overlay.module.css b/packages/react/src/Overlay/Overlay.module.css new file mode 100644 index 00000000000..86751fb2e98 --- /dev/null +++ b/packages/react/src/Overlay/Overlay.module.css @@ -0,0 +1,158 @@ +@keyframes overlay-appear { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +.Overlay { + position: absolute; + width: auto; + min-width: 192px; + height: auto; + overflow: hidden; + background-color: var(--overlay-bgColor); + border-radius: var(--borderRadius-large); + box-shadow: var(--shadow-floating-small); + animation: overlay-appear 200ms cubic-bezier(0.33, 1, 0.68, 1); + + &:focus { + outline: none; + } + + @media (forced-colors: active) { + /* Support for Windows high contrast https://sarahmhigley.com/writing/whcm-quick-tips */ + outline: solid 1px transparent; + } + + &:where([data-reflow-container='true']) { + max-width: calc(100vw - 2rem); + } + + &:where([data-overflow-auto]) { + overflow: auto; + } + + &:where([data-overflow-hidden]) { + overflow: hidden; + } + + &:where([data-overflow-scroll]) { + overflow: scroll; + } + + &:where([data-overflow-visible]) { + overflow: visible; + } + + &:where([data-height-xsmall]) { + height: 192px; + } + + &:where([data-height-small]) { + height: 256px; + } + + &:where([data-height-medium]) { + height: 320px; + } + + &:where([data-height-large]) { + height: 432px; + } + + &:where([data-height-xlarge]) { + height: 600px; + } + + &:where([data-height-auto]), + &:where([data-height-initial]) { + height: auto; + } + + &:where([data-height-fit-content]) { + height: fit-content; + } + + &:where([data-max-height-xsmall]) { + max-height: 192px; + } + + &:where([data-max-height-small]) { + max-height: 256px; + } + + &:where([data-max-height-medium]) { + max-height: 320px; + } + + &:where([data-max-height-large]) { + max-height: 432px; + } + + &:where([data-max-height-xlarge]) { + max-height: 600px; + } + + &:where([data-max-height-fit-content]) { + max-height: fit-content; + } + + &:where([data-width-small]) { + width: 256px; + } + + &:where([data-width-medium]) { + width: 320px; + } + + &:where([data-width-large]) { + /* stylelint-disable-next-line primer/responsive-widths */ + width: 480px; + } + + &:where([data-width-xlarge]) { + /* stylelint-disable-next-line primer/responsive-widths */ + width: 640px; + } + + &:where([data-width-xxlarge]) { + /* stylelint-disable-next-line primer/responsive-widths */ + width: 960px; + } + + &:where([data-width-auto]) { + width: auto; + } + + &:where([data-max-width-small]) { + max-width: 256px; + } + + &:where([data-max-width-medium]) { + max-width: 320px; + } + + &:where([data-max-width-large]) { + max-width: 480px; + } + + &:where([data-max-width-xlarge]) { + max-width: 640px; + } + + &:where([data-max-width-xxlarge]) { + max-width: 960px; + } + + &:where([data-visibility-visible]) { + visibility: visible; + } + + &:where([data-visibility-hidden]) { + visibility: hidden; + } +} diff --git a/packages/react/src/Overlay/Overlay.tsx b/packages/react/src/Overlay/Overlay.tsx index ce69ef27f5c..9fe4a255ef4 100644 --- a/packages/react/src/Overlay/Overlay.tsx +++ b/packages/react/src/Overlay/Overlay.tsx @@ -14,6 +14,11 @@ import type {AnchorSide} from '@primer/behaviors' import {useTheme} from '../ThemeProvider' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' import {useFeatureFlag} from '../FeatureFlags' +import {toggleStyledComponent} from '../internal/utils/toggleStyledComponent' +import classes from './Overlay.module.css' +import {clsx} from 'clsx' + +const CSS_MODULES_FLAG = 'primer_react_css_modules_team' type StyledOverlayProps = { width?: keyof typeof widthMap @@ -22,7 +27,6 @@ type StyledOverlayProps = { maxWidth?: keyof Omit visibility?: 'visible' | 'hidden' overflow?: 'auto' | 'hidden' | 'scroll' | 'visible' - anchorSide?: AnchorSide style?: React.CSSProperties } & SxProp @@ -61,49 +65,49 @@ function getSlideAnimationStartingVector(anchorSide?: AnchorSide): {x: number; y return {x: 0, y: 0} } -export const StyledOverlay = styled.div` - background-color: ${get('colors.canvas.overlay')}; - box-shadow: ${get('shadows.overlay.shadow')}; - position: absolute; - min-width: 192px; - max-width: ${props => props.maxWidth && widthMap[props.maxWidth]}; - height: ${props => heightMap[props.height || 'auto']}; - max-height: ${props => props.maxHeight && heightMap[props.maxHeight]}; - width: ${props => widthMap[props.width || 'auto']}; - border-radius: 12px; - overflow: ${props => (props.overflow ? props.overflow : 'hidden')}; - animation: overlay-appear ${animationDuration}ms ${get('animation.easeOutCubic')}; - - @keyframes overlay-appear { - 0% { - opacity: 0; +const StyledOverlay = toggleStyledComponent( + CSS_MODULES_FLAG, + 'div', + styled.div` + background-color: ${get('colors.canvas.overlay')}; + box-shadow: ${get('shadows.overlay.shadow')}; + position: absolute; + min-width: 192px; + max-width: ${props => props.maxWidth && widthMap[props.maxWidth]}; + height: ${props => heightMap[props.height || 'auto']}; + max-height: ${props => props.maxHeight && heightMap[props.maxHeight]}; + width: ${props => widthMap[props.width || 'auto']}; + border-radius: 12px; + overflow: ${props => (props.overflow ? props.overflow : 'hidden')}; + animation: overlay-appear ${animationDuration}ms ${get('animation.easeOutCubic')}; + + @keyframes overlay-appear { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } } - 100% { - opacity: 1; + visibility: var(--styled-overlay-visibility); + :focus { + outline: none; } - } - visibility: var(--styled-overlay-visibility); - :focus { - outline: none; - } - @media (forced-colors: active) { - /* Support for Windows high contrast https://sarahmhigley.com/writing/whcm-quick-tips */ - outline: solid 1px transparent; - } + @media (forced-colors: active) { + /* Support for Windows high contrast https://sarahmhigley.com/writing/whcm-quick-tips */ + outline: solid 1px transparent; + } - &[data-reflow-container='true'] { - max-width: calc(100vw - 2rem); - } + &[data-reflow-container='true'] { + max-width: calc(100vw - 2rem); + } + + ${sx}; + `, +) - ${sx}; -` type BaseOverlayProps = { - ignoreClickRefs?: React.RefObject[] - initialFocusRef?: React.RefObject - returnFocusRef: React.RefObject - onClickOutside: (e: TouchOrMouseEvent) => void - onEscape: (e: KeyboardEvent) => void visibility?: 'visible' | 'hidden' 'data-test-id'?: unknown position?: React.CSSProperties['position'] @@ -111,11 +115,9 @@ type BaseOverlayProps = { left?: React.CSSProperties['left'] right?: React.CSSProperties['right'] bottom?: React.CSSProperties['bottom'] - portalContainerName?: string - preventFocusOnOpen?: boolean role?: AriaRole children?: React.ReactNode - preventOverflow?: boolean + className?: string } type OwnOverlayProps = Merge @@ -124,48 +126,138 @@ type OwnOverlayProps = Merge * An `Overlay` is a flexible floating surface, used to display transient content such as menus, * selection options, dialogs, and more. Overlays use shadows to express elevation. The `Overlay` * component handles all behaviors needed by overlay UIs as well as the common styles that all overlays * should have. - * @param ignoreClickRefs Optional. An array of ref objects to ignore clicks on in the `onOutsideClick` behavior. This is often used to ignore clicking on the element that toggles the open/closed state for the `Overlay` to prevent the `Overlay` from being toggled twice. - * @param initialFocusRef Optional. Ref for the element to focus when the `Overlay` is opened. If nothing is provided, the first focusable element in the `Overlay` body is focused. - * @param returnFocusRef Required. Ref for the element to focus when the `Overlay` is closed. - * @param onClickOutside Required. Function to call when clicking outside of the `Overlay`. Typically this function removes the Overlay. - * @param onEscape Required. Function to call when user presses `Escape`. Typically this function removes the Overlay. - * @param width Sets the width of the `Overlay`, pick from our set list of widths, or pass `auto` to automatically set the width based on the content of the `Overlay`. `small` corresponds to `256px`, `medium` corresponds to `320px`, `large` corresponds to `480px`, `xlarge` corresponds to `640px`, `xxlarge` corresponds to `960px`. * @param height Sets the height of the `Overlay`, pick from our set list of heights, or pass `auto` to automatically set the height based on the content of the `Overlay`, or pass `initial` to set the height based on the initial content of the `Overlay` (i.e. ignoring content changes). `xsmall` corresponds to `192px`, `small` corresponds to `256px`, `medium` corresponds to `320px`, `large` corresponds to `432px`, `xlarge` corresponds to `600px`. + * @param width Sets the width of the `Overlay`, pick from our set list of widths, or pass `auto` to automatically set the width based on the content of the `Overlay`. `small` corresponds to `256px`, `medium` corresponds to `320px`, `large` corresponds to `480px`, `xlarge` corresponds to `640px`, `xxlarge` corresponds to `960px`. * @param maxHeight Sets the maximum height of the `Overlay`, pick from our set list of heights. `xsmall` corresponds to `192px`, `small` corresponds to `256px`, `medium` corresponds to `320px`, `large` corresponds to `432px`, `xlarge` corresponds to `600px`. - * @param anchorSide If provided, the Overlay will slide into position from the side of the anchor with a brief animation * @param top Optional. Vertical top position of the overlay, relative to its closest positioned ancestor (often its `Portal`). * @param left Optional. Horizontal left position of the overlay, relative to its closest positioned ancestor (often its `Portal`). * @param right Optional. Horizontal right position of the overlay, relative to its closest positioned ancestor (often its `Portal`). * @param bottom Optional. Vertical bottom position of the overlay, relative to its closest positioned ancestor (often its `Portal`). * @param position Optional. Sets how an element is positioned in a document. Defaults to `absolute` positioning. - * @param portalContainerName Optional. The name of the portal container to render the Overlay into. - * @param preventOverflow Optional. The Overlay width will be adjusted responsively if there is not enough space to display the Overlay. If `preventOverflow` is `true`, the width of the `Overlay` will not be adjusted. */ -const Overlay = React.forwardRef( +export const BaseOverlay = React.forwardRef( ( { - onClickOutside, - role = 'none', - initialFocusRef, - returnFocusRef, - ignoreClickRefs, - onEscape, - visibility = 'visible', - height = 'auto', - width = 'auto', + visibility, + height, + width, top, left, right, bottom, - anchorSide, - portalContainerName, - preventFocusOnOpen, position, style: styleFromProps = {}, - preventOverflow = true, + className, + maxHeight, + maxWidth, ...rest }, forwardedRef, + ): ReactElement => { + const cssModulesEnabled = useFeatureFlag(CSS_MODULES_FLAG) + + if (cssModulesEnabled) { + return ( + + ) + } else { + return ( + + ) + } + }, +) as PolymorphicForwardRefComponent<'div', OwnOverlayProps> + +type ContainerProps = { + anchorSide?: AnchorSide + ignoreClickRefs?: React.RefObject[] + initialFocusRef?: React.RefObject + onClickOutside: (e: TouchOrMouseEvent) => void + onEscape: (e: KeyboardEvent) => void + portalContainerName?: string + preventOverflow?: boolean + preventFocusOnOpen?: boolean + returnFocusRef: React.RefObject +} + +type internalOverlayProps = Merge + +/** + * @param anchorSide If provided, the Overlay will slide into position from the side of the anchor with a brief animation + * @param height Sets the height of the `Overlay`, pick from our set list of heights, or pass `auto` to automatically set the height based on the content of the `Overlay`, or pass `initial` to set the height based on the initial content of the `Overlay` (i.e. ignoring content changes). `xsmall` corresponds to `192px`, `small` corresponds to `256px`, `medium` corresponds to `320px`, `large` corresponds to `432px`, `xlarge` corresponds to `600px`. + * @param ignoreClickRefs Optional. An array of ref objects to ignore clicks on in the `onOutsideClick` behavior. This is often used to ignore clicking on the element that toggles the open/closed state for the `Overlay` to prevent the `Overlay` from being toggled twice. + * @param initialFocusRef Optional. Ref for the element to focus when the `Overlay` is opened. If nothing is provided, the first focusable element in the `Overlay` body is focused. + * @param left Optional. Horizontal left position of the overlay, relative to its closest positioned ancestor (often its `Portal`). + * @param onClickOutside Required. Function to call when clicking outside of the `Overlay`. Typically this function removes the Overlay. + * @param onEscape Required. Function to call when user presses `Escape`. Typically this function removes the Overlay. + * @param portalContainerName Optional. The name of the portal container to render the Overlay into. + * @param preventOverflow Optional. The Overlay width will be adjusted responsively if there is not enough space to display the Overlay. If `preventOverflow` is `true`, the width of the `Overlay` will not be adjusted. + * @param returnFocusRef Required. Ref for the element to focus when the `Overlay` is closed. + * @param right Optional. Horizontal right position of the overlay, relative to its closest positioned ancestor (often its `Portal`). + * @param width Sets the width of the `Overlay`, pick from our set list of widths, or pass `auto` to automatically set the width based on the content of the `Overlay`. `small` corresponds to `256px`, `medium` corresponds to `320px`, `large` corresponds to `480px`, `xlarge` corresponds to `640px`, `xxlarge` corresponds to `960px`. + + */ +const Overlay = React.forwardRef( + ( + { + anchorSide, + height = 'auto', + ignoreClickRefs, + initialFocusRef, + left, + onClickOutside, + onEscape, + portalContainerName, + preventOverflow = true, + preventFocusOnOpen, + returnFocusRef, + right, + role = 'none', + visibility = 'visible', + width = 'auto', + ...props + }, + forwardedRef, ): ReactElement => { const overlayRef = useRef(null) useRefObjectAsForwardedRef(forwardedRef, overlayRef) @@ -206,35 +298,26 @@ const Overlay = React.forwardRef( }, [anchorSide, slideAnimationDistance, slideAnimationEasing, visibility]) // To be backwards compatible with the old Overlay, we need to set the left prop if x-position is not specified - const leftPosition: React.CSSProperties = left === undefined && right === undefined ? {left: 0} : {left} - - const enabled = useFeatureFlag('primer_react_overlay_overflow') + const leftPosition = left === undefined && right === undefined ? 0 : left + const overflowEnabled = useFeatureFlag('primer_react_overlay_overflow') return ( - ) }, -) as PolymorphicForwardRefComponent<'div', OwnOverlayProps> +) as PolymorphicForwardRefComponent<'div', internalOverlayProps> export type OverlayProps = ComponentPropsWithRef diff --git a/packages/react/src/experimental/SelectPanel2/SelectPanel.tsx b/packages/react/src/experimental/SelectPanel2/SelectPanel.tsx index a15bd539f33..9ccece930c7 100644 --- a/packages/react/src/experimental/SelectPanel2/SelectPanel.tsx +++ b/packages/react/src/experimental/SelectPanel2/SelectPanel.tsx @@ -19,7 +19,7 @@ import {ActionListContainerContext} from '../../ActionList/ActionListContainerCo import {useSlots} from '../../hooks/useSlots' import {useProvidedRefOrCreate, useId, useAnchoredPosition} from '../../hooks' import type {OverlayProps} from '../../Overlay/Overlay' -import {StyledOverlay, heightMap} from '../../Overlay/Overlay' +import {BaseOverlay, heightMap} from '../../Overlay/Overlay' import {InputLabel} from '../../internal/components/InputLabel' import {invariant} from '../../utils/invariant' import {AriaStatus} from '../../live-region' @@ -249,7 +249,7 @@ const Panel: React.FC = ({ <> {Anchor} - = ({ } className={enabled ? classes.Overlay : undefined} {...props} - onClick={event => { + onClick={(event: React.MouseEvent) => { if (event.target === event.currentTarget) onClickOutside() }} > @@ -378,7 +378,7 @@ const Panel: React.FC = ({ )} - + ) }