diff --git a/.changeset/neat-moose-dress.md b/.changeset/neat-moose-dress.md new file mode 100644 index 00000000000..fc732c2f69c --- /dev/null +++ b/.changeset/neat-moose-dress.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Dialog: dynamically switch footer button layout based on available height. diff --git a/packages/react/src/Dialog/Dialog.module.css b/packages/react/src/Dialog/Dialog.module.css index c87b9321e56..0532fed9f22 100644 --- a/packages/react/src/Dialog/Dialog.module.css +++ b/packages/react/src/Dialog/Dialog.module.css @@ -389,11 +389,11 @@ Add a border between the body and footer if: padding: var(--base-size-16); gap: var(--base-size-8); flex-shrink: 0; +} - @media (max-height: 325px) { - flex-wrap: nowrap; - overflow-x: scroll; - flex-direction: row; - justify-content: unset; - } +.Dialog[data-footer-button-layout='scroll'] .Footer { + flex-wrap: nowrap; + overflow-x: scroll; + flex-direction: row; + justify-content: unset; } diff --git a/packages/react/src/Dialog/Dialog.tsx b/packages/react/src/Dialog/Dialog.tsx index 4ef73390514..d2b22336b51 100644 --- a/packages/react/src/Dialog/Dialog.tsx +++ b/packages/react/src/Dialog/Dialog.tsx @@ -16,6 +16,7 @@ import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../uti import classes from './Dialog.module.css' import {clsx} from 'clsx' import {useSlots} from '../hooks/useSlots' +import {useResizeObserver} from '../hooks/useResizeObserver' /* Dialog Version 2 */ @@ -239,6 +240,41 @@ const defaultPosition = { } const defaultFooterButtons: Array = [] +// Minimum room needed for body content before forcing footer buttons into horizontal scroll. +const MIN_BODY_HEIGHT = 56 + +// Measures what the footer height would be in wrap mode by cloning it offscreen, +// so we can decide layout without mutating the visible footer. +function measureWrappedFooterHeight(footerElement: HTMLElement) { + const measurementContainer = document.createElement('div') + const measuredFooter = footerElement.cloneNode(true) as HTMLElement + + Object.assign(measurementContainer.style, { + position: 'fixed', + top: '0', + left: '-99999px', + visibility: 'hidden', + pointerEvents: 'none', + contain: 'layout style size', + }) + + measuredFooter.style.width = `${footerElement.getBoundingClientRect().width}px` + + Object.assign(measuredFooter.style, { + flexWrap: 'wrap', + overflowX: '', + overflowY: '', + justifyContent: '', + }) + + measurementContainer.appendChild(measuredFooter) + document.body.appendChild(measurementContainer) + + const measuredHeight = measuredFooter.offsetHeight + measurementContainer.remove() + + return measuredHeight +} // useful to determine whether we're inside a Dialog from a nested component export const DialogContext = React.createContext(undefined) @@ -273,6 +309,7 @@ const _Dialog = React.forwardRef(false) + const [footerButtonLayout, setFooterButtonLayout] = useState<'scroll' | 'wrap'>('wrap') const defaultedProps = {...props, title, subtitle, role, dialogLabelId, dialogDescriptionId} const onBackdropClick = useCallback( (e: SyntheticEvent) => { @@ -339,6 +376,45 @@ const _Dialog = React.forwardRef { + if (!hasFooter) { + setFooterButtonLayout('wrap') + return + } + + const dialogElement = dialogRef.current + if (!(dialogElement instanceof HTMLElement)) { + return + } + + const headerElement = dialogElement.querySelector(`.${classes.Header}`) + const footerElement = dialogElement.querySelector(`.${classes.Footer}`) + + if (!(footerElement instanceof HTMLElement)) { + return + } + + const viewportHeight = backdropRef.current?.clientHeight ?? window.innerHeight + const positionRegular = dialogElement.getAttribute('data-position-regular') + const positionNarrow = dialogElement.getAttribute('data-position-narrow') + // fullscreen/left/right fill the full viewport; otherwise match CSS max-height gutter. + const gutter = viewportHeight <= 280 ? 12 : 64 + const dialogMaxHeight = + positionNarrow === 'fullscreen' || positionRegular === 'left' || positionRegular === 'right' + ? viewportHeight + : Math.max(0, viewportHeight - gutter) + + const headerHeight = headerElement instanceof HTMLElement ? headerElement.offsetHeight : 0 + const wrappedFooterHeight = measureWrappedFooterHeight(footerElement) + const visibleBodyHeightWithWrap = Math.max(0, dialogMaxHeight - headerHeight - wrappedFooterHeight) + + setFooterButtonLayout(visibleBodyHeightWithWrap >= MIN_BODY_HEIGHT ? 'wrap' : 'scroll') + }, [hasFooter]) + + useResizeObserver(updateFooterButtonLayout, backdropRef) + const positionDataAttributes = typeof position === 'string' ? {'data-position-regular': position} @@ -371,7 +447,8 @@ const _Dialog = React.forwardRef