From fc06fab6c14f44e505cd7f82c1658dca826f34ac Mon Sep 17 00:00:00 2001 From: web-padawan Date: Fri, 17 Jul 2026 13:48:10 +0300 Subject: [PATCH] refactor: migrate Dialog wrapper to slotted content Render header/footer/children as light-DOM slotted content instead of routing through the web component's renderer/headerRenderer/footerRenderer properties (deprecated in Vaadin 25, removed in Vaadin 26). - header ->
, footer ->
, children -> default slot; wrappers rendered only when content is present so the web component never derives has-header/has-footer from empty slots. - Deprecated renderer/headerRenderer/footerRenderer props and function children still work, rendered into the slotted wrapper with the dialog element passed as `original` (tracked via useMergedRefs so it is never null when the renderer runs). - Drop useSimpleOrChildrenRenderer usage; no portal-into-renderer-root. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/react-components/src/Dialog.tsx | 46 ++++++++++++++---- test/Dialog.spec.tsx | 59 ++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 12 deletions(-) diff --git a/packages/react-components/src/Dialog.tsx b/packages/react-components/src/Dialog.tsx index dcc99c60..a566074e 100644 --- a/packages/react-components/src/Dialog.tsx +++ b/packages/react-components/src/Dialog.tsx @@ -1,14 +1,16 @@ import { type ComponentType, + createElement, type ForwardedRef, type HTMLAttributes, forwardRef, type ReactElement, type ReactNode, + useState, } from 'react'; import { Dialog as _Dialog, type DialogElement, type DialogProps as _DialogProps } from './generated/Dialog.js'; -import { useSimpleOrChildrenRenderer } from './renderers/useSimpleOrChildrenRenderer.js'; import type { ReactSimpleRendererProps } from './renderers/useSimpleRenderer.js'; +import useMergedRefs from './utils/useMergedRefs.js'; export * from './generated/Dialog.js'; @@ -25,25 +27,53 @@ export type DialogProps = Partial< Readonly<{ children?: ReactNode | ComponentType; footer?: ReactNode; + /** @deprecated Provide footer content via the `footer` prop instead. */ footerRenderer?: ComponentType | null; header?: ReactNode; + /** @deprecated Provide header content via the `header` prop instead. */ headerRenderer?: ComponentType | null; + /** @deprecated Provide content as children instead. */ renderer?: ComponentType | null; }>; +// Resolves a slot's content: a deprecated renderer component is rendered with the +// dialog element as `original` (once the element ref is populated), otherwise the +// plain node is used. +function resolveContent( + element: DialogElement | null, + renderer: ComponentType | null | undefined, + node: ReactNode, +): ReactNode { + if (renderer) { + return element ? createElement(renderer, { original: element }) : null; + } + return node; +} + function Dialog( { children, footer, header, ...props }: DialogProps, ref: ForwardedRef, ): ReactElement | null { - const [footerPortals, footerRenderer] = useSimpleOrChildrenRenderer(props.footerRenderer, footer); - const [headerPortals, headerRenderer] = useSimpleOrChildrenRenderer(props.headerRenderer, header); - const [portals, renderer] = useSimpleOrChildrenRenderer(props.renderer, children); + const [element, setElement] = useState(null); + const finalRef = useMergedRefs(setElement, ref); + + // A function passed as children is treated as a (deprecated) content renderer. + const childrenRenderer = typeof children === 'function' ? children : undefined; + const childrenNode = childrenRenderer ? undefined : (children as ReactNode); + + // Keep the deprecated renderer props out of the element spread so the wrapper no + // longer sets the web component's renderer properties (removed in Vaadin 26). + const { headerRenderer, footerRenderer, renderer, ...rest } = props; + + const headerContent = resolveContent(element, headerRenderer, header); + const footerContent = resolveContent(element, footerRenderer, footer); + const bodyContent = resolveContent(element, childrenRenderer ?? renderer, childrenNode); return ( - <_Dialog {...props} ref={ref} footerRenderer={footerRenderer} headerRenderer={headerRenderer} renderer={renderer}> - {headerPortals} - {footerPortals} - {portals} + <_Dialog {...rest} ref={finalRef}> + {headerContent ?
{headerContent}
: null} + {footerContent ?
{footerContent}
: null} + {bodyContent} ); } diff --git a/test/Dialog.spec.tsx b/test/Dialog.spec.tsx index 2538f19b..839b059c 100644 --- a/test/Dialog.spec.tsx +++ b/test/Dialog.spec.tsx @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-react'; import sinon from 'sinon'; -import { Dialog } from '../packages/react-components/src/Dialog.js'; +import { Dialog, type DialogReactRendererProps } from '../packages/react-components/src/Dialog.js'; import { nextRender } from './utils/nextRender.js'; import { useState } from 'react'; @@ -34,7 +34,7 @@ describe('Dialog', () => { it('should use children if no renderer property set', async () => { await render( Title} footer={<>Footer}> - FooBar + FooBar , ); await nextRender(); @@ -47,7 +47,7 @@ describe('Dialog', () => { opened headerRenderer={() => <>Title} footerRenderer={() => <>Footer} - renderer={() => <>FooBar} + renderer={() => FooBar} >, ); await nextRender(); @@ -57,13 +57,64 @@ describe('Dialog', () => { it('should use children as renderer prop', async () => { await render( <>Title} footerRenderer={() => <>Footer}> - {() => <>FooBar} + {() => FooBar} , ); await nextRender(); assert(); }); + it('should pass the dialog element as `original` to a renderer', async () => { + let received: DialogReactRendererProps['original'] | undefined; + await render( + { + // `original` must never be null/undefined when the renderer runs. + received = original; + return {original ? original.localName : 'no-element'}; + }} + >, + ); + await nextRender(); + + expect(received).to.exist; + expect(received!.localName).to.equal(dialogTag); + + const body = Array.from(document.querySelector(dialogTag)!.childNodes).find( + (node) => node.nodeType === Node.ELEMENT_NODE && !(node as Element).hasAttribute('slot'), + ); + expect(body).to.have.text(dialogTag); + }); + + it('should not render header / footer wrappers when only content is provided', async () => { + await render( + + FooBar + , + ); + await nextRender(); + + const dialog = document.querySelector(dialogTag)!; + expect(dialog.querySelector('[slot="header-content"]')).to.not.exist; + expect(dialog.querySelector('[slot="footer"]')).to.not.exist; + }); + + it('should not render header / footer wrappers for falsy content', async () => { + const showHeader = false; + const showFooter = false; + await render( + Title} footer={showFooter && <>Footer}> + FooBar + , + ); + await nextRender(); + + const dialog = document.querySelector(dialogTag)!; + expect(dialog.querySelector('[slot="header-content"]')).to.not.exist; + expect(dialog.querySelector('[slot="footer"]')).to.not.exist; + }); + it('should not warn on open', async () => { function TestDialog() { const [opened, setOpened] = useState(false);