From 91b898ce2696ca7a0cb315dd98cb4111abd8ecdb Mon Sep 17 00:00:00 2001 From: Manish Date: Thu, 9 Apr 2026 14:52:33 +0530 Subject: [PATCH 1/2] Make Dialog header and footer sticky --- packages/ui/src/dialog/style.module.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/ui/src/dialog/style.module.css b/packages/ui/src/dialog/style.module.css index c2dff0b2fd8d11..bd55be131cf06b 100644 --- a/packages/ui/src/dialog/style.module.css +++ b/packages/ui/src/dialog/style.module.css @@ -91,10 +91,14 @@ } .header { + position: sticky; + top: 0; + z-index: 1; display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--wpds-dimension-gap-lg); + background-color: var(--wpds-color-bg-surface-neutral-strong); } .title { @@ -106,11 +110,15 @@ } .footer { + position: sticky; + bottom: 0; + z-index: 1; display: flex; justify-content: flex-end; align-items: center; gap: var(--wpds-dimension-gap-sm); margin-top: var(--wpds-dimension-gap-lg); padding-top: var(--wpds-dimension-padding-lg); + background-color: var(--wpds-color-bg-surface-neutral-strong); } } From 1f31ac550a562b2b8c789c26e3b1904b0055a662 Mon Sep 17 00:00:00 2001 From: Manish Date: Wed, 22 Apr 2026 14:24:47 +0530 Subject: [PATCH 2/2] changes updated --- packages/ui/CHANGELOG.md | 1 + packages/ui/src/dialog/footer.tsx | 14 +++- packages/ui/src/dialog/header.tsx | 10 ++- packages/ui/src/dialog/popup.tsx | 65 +++++++++++++++++-- .../ui/src/dialog/scroll-chrome-context.tsx | 23 +++++++ .../ui/src/dialog/stories/index.story.tsx | 38 +++++++++++ packages/ui/src/dialog/style.module.css | 25 +++++-- packages/ui/src/dialog/types.ts | 8 +++ 8 files changed, 173 insertions(+), 11 deletions(-) create mode 100644 packages/ui/src/dialog/scroll-chrome-context.tsx diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 957f241dc8a539..1ddb6f6413f6ef 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -12,6 +12,7 @@ ### Enhancements +- `Dialog`: Sticky `Dialog.Header` and `Dialog.Footer` show a border when content scrolls underneath; add optional `sticky` prop on `Dialog.Footer` (defaults to `true`) ([#77180](https://github.com/WordPress/gutenberg/issues/77180)). - Add defensive styles against global WordPress stylesheets like common.css and forms.css ([#76783](https://github.com/WordPress/gutenberg/pull/76783)). ## 0.10.0 (2026-04-01) diff --git a/packages/ui/src/dialog/footer.tsx b/packages/ui/src/dialog/footer.tsx index 98064c61a9af6b..ae628e34e7e71d 100644 --- a/packages/ui/src/dialog/footer.tsx +++ b/packages/ui/src/dialog/footer.tsx @@ -1,6 +1,7 @@ import { mergeProps, useRender } from '@base-ui/react'; import clsx from 'clsx'; import { forwardRef } from '@wordpress/element'; +import { useDialogScrollChrome } from './scroll-chrome-context'; import styles from './style.module.css'; import type { FooterProps } from './types'; @@ -9,14 +10,23 @@ import type { FooterProps } from './types'; * action buttons. */ const Footer = forwardRef< HTMLDivElement, FooterProps >( function DialogFooter( - { className, render, ...props }, + { className, render, sticky = true, ...props }, ref ) { + const scrollChrome = useDialogScrollChrome(); + const element = useRender( { render, ref, props: mergeProps< 'div' >( props, { - className: clsx( styles.footer, className ), + className: clsx( + styles.footer, + sticky && styles.footerSticky, + sticky && + scrollChrome?.footerHasContentBelow && + styles.footerHasContentBelow, + className + ), } ), } ); diff --git a/packages/ui/src/dialog/header.tsx b/packages/ui/src/dialog/header.tsx index 2e1b665fa33659..16594857ec33ce 100644 --- a/packages/ui/src/dialog/header.tsx +++ b/packages/ui/src/dialog/header.tsx @@ -1,6 +1,7 @@ import { mergeProps, useRender } from '@base-ui/react'; import clsx from 'clsx'; import { forwardRef } from '@wordpress/element'; +import { useDialogScrollChrome } from './scroll-chrome-context'; import styles from './style.module.css'; import type { HeaderProps } from './types'; @@ -12,11 +13,18 @@ const Header = forwardRef< HTMLDivElement, HeaderProps >( function DialogHeader( { className, render, ...props }, ref ) { + const scrollChrome = useDialogScrollChrome(); + const element = useRender( { render, ref, props: mergeProps< 'div' >( props, { - className: clsx( styles.header, className ), + className: clsx( + styles.header, + scrollChrome?.headerScrolledFromTop && + styles.headerHasScrolledContent, + className + ), } ), } ); diff --git a/packages/ui/src/dialog/popup.tsx b/packages/ui/src/dialog/popup.tsx index 43eec71a115c11..c2497aadfa1e17 100644 --- a/packages/ui/src/dialog/popup.tsx +++ b/packages/ui/src/dialog/popup.tsx @@ -1,6 +1,12 @@ import { Dialog as _Dialog } from '@base-ui/react/dialog'; import clsx from 'clsx'; -import { forwardRef } from '@wordpress/element'; +import type { UIEvent } from 'react'; +import { + forwardRef, + useCallback, + useLayoutEffect, + useState, +} from '@wordpress/element'; import { useMergeRefs } from '@wordpress/compose'; import { type ThemeProvider as ThemeProviderType, @@ -9,6 +15,10 @@ import { import { unlock } from '../lock-unlock'; import { useDeprioritizedInitialFocus } from '../utils/use-deprioritized-initial-focus'; import { DialogValidationProvider } from './context'; +import { + DialogScrollChromeContext, + type DialogScrollChromeContextValue, +} from './scroll-chrome-context'; import styles from './style.module.css'; import type { PopupProps } from './types'; @@ -28,6 +38,7 @@ const Popup = forwardRef< HTMLDivElement, PopupProps >( function DialogPopup( initialFocus, finalFocus, children, + onScroll, ...props }, ref @@ -38,6 +49,49 @@ const Popup = forwardRef< HTMLDivElement, PopupProps >( function DialogPopup( } ); const mergedRef = useMergeRefs( [ ref, popupRef ] ); + const [ scrollChrome, setScrollChrome ] = + useState< DialogScrollChromeContextValue >( { + headerScrolledFromTop: false, + footerHasContentBelow: false, + } ); + + const syncScrollChrome = useCallback( () => { + const el = popupRef.current; + if ( ! el ) { + return; + } + const { scrollTop, scrollHeight, clientHeight } = el; + setScrollChrome( { + headerScrolledFromTop: scrollTop > 0, + footerHasContentBelow: scrollTop + clientHeight < scrollHeight - 1, + } ); + }, [ popupRef ] ); + + useLayoutEffect( () => { + let resizeObserver: ResizeObserver | undefined; + const frameId = requestAnimationFrame( () => { + const el = popupRef.current; + if ( ! el ) { + return; + } + syncScrollChrome(); + resizeObserver = new ResizeObserver( syncScrollChrome ); + resizeObserver.observe( el ); + } ); + return () => { + cancelAnimationFrame( frameId ); + resizeObserver?.disconnect(); + }; + }, [ syncScrollChrome, popupRef ] ); + + const handleScroll = useCallback( + ( event: UIEvent< HTMLDivElement > ) => { + onScroll?.( event ); + syncScrollChrome(); + }, + [ onScroll, syncScrollChrome ] + ); + return ( <_Dialog.Portal> <_Dialog.Backdrop className={ styles.backdrop } /> @@ -52,10 +106,13 @@ const Popup = forwardRef< HTMLDivElement, PopupProps >( function DialogPopup( initialFocus={ resolvedInitialFocus } finalFocus={ finalFocus } { ...props } + onScroll={ handleScroll } > - - { children } - + + + { children } + + diff --git a/packages/ui/src/dialog/scroll-chrome-context.tsx b/packages/ui/src/dialog/scroll-chrome-context.tsx new file mode 100644 index 00000000000000..c3f1d573ae8148 --- /dev/null +++ b/packages/ui/src/dialog/scroll-chrome-context.tsx @@ -0,0 +1,23 @@ +import { createContext, useContext } from '@wordpress/element'; + +export type DialogScrollChromeContextValue = { + /** + * True when the popup is scrolled away from the top (content sits under the header). + */ + headerScrolledFromTop: boolean; + /** + * True when more content exists below the visible area (content can scroll under the footer). + */ + footerHasContentBelow: boolean; +}; + +export const DialogScrollChromeContext = + createContext< DialogScrollChromeContextValue | null >( null ); + +/** + * Scroll chrome for sticky `Dialog.Header` / `Dialog.Footer` borders. Returns `null` + * when used outside `Dialog.Popup`. + */ +export function useDialogScrollChrome(): DialogScrollChromeContextValue | null { + return useContext( DialogScrollChromeContext ); +} diff --git a/packages/ui/src/dialog/stories/index.story.tsx b/packages/ui/src/dialog/stories/index.story.tsx index 086af1fa06170b..c7f3777bec07dc 100644 --- a/packages/ui/src/dialog/stories/index.story.tsx +++ b/packages/ui/src/dialog/stories/index.story.tsx @@ -161,3 +161,41 @@ export const WithCustomZIndex: Story = { ..._Default, name: 'With Custom z-index', }; + +const LONG_BODY_COPY = Array( 24 ) + .fill( + 'This dialog demonstrates scrollable body content. The header and footer stay pinned while you scroll; a border appears when content passes underneath them, similar to the legacy Modal component. ' + ) + .join( '' ); + +/** + * Long body copy so the popup scrolls — use this story to verify sticky + * header/footer and scroll-dependent borders. + */ +export const ScrollableWithStickyChrome: Story = { + name: 'Scrollable (sticky header & footer)', + parameters: { + docs: { + description: { + story: 'Use `Dialog.Footer` with `sticky={ false }` when the footer should scroll with the body instead of staying pinned.', + }, + }, + }, + args: { + children: ( + <> + Open scrollable dialog + + + Scrollable dialog + + +

{ LONG_BODY_COPY }

+ + Got it + +
+ + ), + }, +}; diff --git a/packages/ui/src/dialog/style.module.css b/packages/ui/src/dialog/style.module.css index bd55be131cf06b..8571675bf33d42 100644 --- a/packages/ui/src/dialog/style.module.css +++ b/packages/ui/src/dialog/style.module.css @@ -94,13 +94,20 @@ position: sticky; top: 0; z-index: 1; + isolation: isolate; display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--wpds-dimension-gap-lg); + box-sizing: border-box; + border-block-end: 1px solid transparent; background-color: var(--wpds-color-bg-surface-neutral-strong); } + .headerHasScrolledContent { + border-block-end-color: var(--wpds-color-stroke-surface-neutral); + } + .title { margin: 0; font-size: var(--wpds-font-size-xl); @@ -110,15 +117,25 @@ } .footer { - position: sticky; - bottom: 0; - z-index: 1; display: flex; justify-content: flex-end; align-items: center; gap: var(--wpds-dimension-gap-sm); margin-top: var(--wpds-dimension-gap-lg); - padding-top: var(--wpds-dimension-padding-lg); + padding-block: var(--wpds-dimension-padding-lg); + box-sizing: border-box; + border-block-start: 1px solid transparent; background-color: var(--wpds-color-bg-surface-neutral-strong); } + + .footerSticky { + position: sticky; + bottom: 0; + z-index: 1; + isolation: isolate; + } + + .footerHasContentBelow { + border-block-start-color: var(--wpds-color-stroke-surface-neutral); + } } diff --git a/packages/ui/src/dialog/types.ts b/packages/ui/src/dialog/types.ts index 28db2a69988a2c..965643f55be5f8 100644 --- a/packages/ui/src/dialog/types.ts +++ b/packages/ui/src/dialog/types.ts @@ -61,6 +61,14 @@ export interface FooterProps extends ComponentProps< 'div' > { * The content to be rendered inside the component. */ children?: ReactNode; + + /** + * When `true`, the footer stays pinned to the bottom of the dialog while + * the body scrolls. When `false`, the footer scrolls with the content. + * + * @default true + */ + sticky?: boolean; } export interface HeaderProps extends ComponentProps< 'div' > {