From 9e1de9c3bb20fb5e36d3b223fe40b921c201d021 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Thu, 19 Mar 2026 11:18:55 -0700 Subject: [PATCH 1/8] update footer layout on height --- packages/react/src/Dialog/Dialog.module.css | 14 ++-- packages/react/src/Dialog/Dialog.tsx | 76 ++++++++++++++++++++- 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/packages/react/src/Dialog/Dialog.module.css b/packages/react/src/Dialog/Dialog.module.css index c87b9321e56..c7e73a4416b 100644 --- a/packages/react/src/Dialog/Dialog.module.css +++ b/packages/react/src/Dialog/Dialog.module.css @@ -316,6 +316,7 @@ body[data-dialog-scroll-disabled] { .DialogOverflowWrapper { flex-grow: 1; + min-height: 0; } /* @@ -389,11 +390,12 @@ 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: auto; + overflow-y: hidden; + flex-direction: row; + justify-content: unset; } diff --git a/packages/react/src/Dialog/Dialog.tsx b/packages/react/src/Dialog/Dialog.tsx index 4ef73390514..965b83221dd 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,39 @@ const defaultPosition = { } const defaultFooterButtons: Array = [] +// Minimum room needed for body content before forcing footer buttons into horizontal scroll. +const MIN_BODY_HEIGHT_FOR_WRAPPED_FOOTER = 56 + +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 +307,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 +374,44 @@ 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; all others are capped at 100dvh - 64px + const dialogMaxHeight = + positionNarrow === 'fullscreen' || positionRegular === 'left' || positionRegular === 'right' + ? viewportHeight + : Math.max(0, viewportHeight - 64) + + 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_FOR_WRAPPED_FOOTER ? 'wrap' : 'scroll') + }, [hasFooter]) + + useResizeObserver(updateFooterButtonLayout, backdropRef) + const positionDataAttributes = typeof position === 'string' ? {'data-position-regular': position} @@ -371,7 +444,8 @@ const _Dialog = React.forwardRef From c16f37743527cadf58cc92ee7b6931d46d6252eb Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Thu, 19 Mar 2026 11:22:23 -0700 Subject: [PATCH 2/8] changeset --- .changeset/neat-moose-dress.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/neat-moose-dress.md 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. From e3c38c770b5f7c64bb6f406541797e4d79ef4699 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Thu, 19 Mar 2026 11:54:40 -0700 Subject: [PATCH 3/8] gutter changes on vh<280px --- packages/react/src/Dialog/Dialog.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react/src/Dialog/Dialog.tsx b/packages/react/src/Dialog/Dialog.tsx index 965b83221dd..164ae076536 100644 --- a/packages/react/src/Dialog/Dialog.tsx +++ b/packages/react/src/Dialog/Dialog.tsx @@ -397,11 +397,12 @@ const _Dialog = React.forwardRef Date: Thu, 19 Mar 2026 13:45:16 -0700 Subject: [PATCH 4/8] clean up and add comments --- packages/react/src/Dialog/Dialog.module.css | 4 +--- packages/react/src/Dialog/Dialog.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/react/src/Dialog/Dialog.module.css b/packages/react/src/Dialog/Dialog.module.css index c7e73a4416b..0532fed9f22 100644 --- a/packages/react/src/Dialog/Dialog.module.css +++ b/packages/react/src/Dialog/Dialog.module.css @@ -316,7 +316,6 @@ body[data-dialog-scroll-disabled] { .DialogOverflowWrapper { flex-grow: 1; - min-height: 0; } /* @@ -394,8 +393,7 @@ Add a border between the body and footer if: .Dialog[data-footer-button-layout='scroll'] .Footer { flex-wrap: nowrap; - overflow-x: auto; - overflow-y: hidden; + 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 164ae076536..d2b22336b51 100644 --- a/packages/react/src/Dialog/Dialog.tsx +++ b/packages/react/src/Dialog/Dialog.tsx @@ -241,8 +241,10 @@ const defaultPosition = { const defaultFooterButtons: Array = [] // Minimum room needed for body content before forcing footer buttons into horizontal scroll. -const MIN_BODY_HEIGHT_FOR_WRAPPED_FOOTER = 56 +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 @@ -408,7 +410,7 @@ const _Dialog = React.forwardRef= MIN_BODY_HEIGHT_FOR_WRAPPED_FOOTER ? 'wrap' : 'scroll') + setFooterButtonLayout(visibleBodyHeightWithWrap >= MIN_BODY_HEIGHT ? 'wrap' : 'scroll') }, [hasFooter]) useResizeObserver(updateFooterButtonLayout, backdropRef) From 8eaebc1af8aafa8e406c95a5388d8082b5a8d5d1 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Tue, 24 Mar 2026 08:57:30 -0700 Subject: [PATCH 5/8] remove clone footer and measure function --- packages/react/src/Dialog/Dialog.tsx | 57 ++-------------------------- 1 file changed, 3 insertions(+), 54 deletions(-) diff --git a/packages/react/src/Dialog/Dialog.tsx b/packages/react/src/Dialog/Dialog.tsx index d2b22336b51..37e418f3a0a 100644 --- a/packages/react/src/Dialog/Dialog.tsx +++ b/packages/react/src/Dialog/Dialog.tsx @@ -243,39 +243,6 @@ 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) const DIALOG_CONTEXT_VALUE = Object.freeze({}) @@ -389,28 +356,10 @@ const _Dialog = React.forwardRef(`.${classes.DialogOverflowWrapper}`) + const visibleHeight = scrollRegion?.clientHeight ?? 0 - 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') + setFooterButtonLayout(visibleHeight >= MIN_BODY_HEIGHT ? 'wrap' : 'scroll') }, [hasFooter]) useResizeObserver(updateFooterButtonLayout, backdropRef) From 7cfde85d3801a1d5ce2e950e5222a848842787ef Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Tue, 24 Mar 2026 09:36:47 -0700 Subject: [PATCH 6/8] tyler's comment --- packages/react/src/Dialog/Dialog.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/react/src/Dialog/Dialog.tsx b/packages/react/src/Dialog/Dialog.tsx index 37e418f3a0a..a3843356807 100644 --- a/packages/react/src/Dialog/Dialog.tsx +++ b/packages/react/src/Dialog/Dialog.tsx @@ -355,11 +355,20 @@ const _Dialog = React.forwardRef(`.${classes.DialogOverflowWrapper}`) - const visibleHeight = scrollRegion?.clientHeight ?? 0 + const newLayout = bodyHeight >= MIN_BODY_HEIGHT ? 'wrap' : 'scroll' + dialogElement.setAttribute('data-footer-button-layout', newLayout) - setFooterButtonLayout(visibleHeight >= MIN_BODY_HEIGHT ? 'wrap' : 'scroll') + setFooterButtonLayout(newLayout) }, [hasFooter]) useResizeObserver(updateFooterButtonLayout, backdropRef) From 9c10f7973a186853f910c0d11294e3b7285d9454 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Tue, 24 Mar 2026 09:59:44 -0700 Subject: [PATCH 7/8] min body height 56 -> 48 --- packages/react/src/Dialog/Dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/Dialog/Dialog.tsx b/packages/react/src/Dialog/Dialog.tsx index a3843356807..6151867bc87 100644 --- a/packages/react/src/Dialog/Dialog.tsx +++ b/packages/react/src/Dialog/Dialog.tsx @@ -241,7 +241,7 @@ const defaultPosition = { const defaultFooterButtons: Array = [] // Minimum room needed for body content before forcing footer buttons into horizontal scroll. -const MIN_BODY_HEIGHT = 56 +const MIN_BODY_HEIGHT = 48 // useful to determine whether we're inside a Dialog from a nested component export const DialogContext = React.createContext(undefined) From 00c9c2e5c8f5d007417f86d4d65933d7732f2445 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Tue, 24 Mar 2026 12:59:51 -0700 Subject: [PATCH 8/8] remove initial setFooterButtonLayout --- packages/react/src/Dialog/Dialog.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react/src/Dialog/Dialog.tsx b/packages/react/src/Dialog/Dialog.tsx index 6151867bc87..5d0ed137ddf 100644 --- a/packages/react/src/Dialog/Dialog.tsx +++ b/packages/react/src/Dialog/Dialog.tsx @@ -347,7 +347,6 @@ const _Dialog = React.forwardRef { if (!hasFooter) { - setFooterButtonLayout('wrap') return }