Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 38 additions & 8 deletions packages/react-components/src/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -25,25 +27,53 @@ export type DialogProps = Partial<
Readonly<{
children?: ReactNode | ComponentType<DialogReactRendererProps>;
footer?: ReactNode;
/** @deprecated Provide footer content via the `footer` prop instead. */
footerRenderer?: ComponentType<DialogReactRendererProps> | null;
header?: ReactNode;
/** @deprecated Provide header content via the `header` prop instead. */
headerRenderer?: ComponentType<DialogReactRendererProps> | null;
/** @deprecated Provide content as children instead. */
renderer?: ComponentType<DialogReactRendererProps> | 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<DialogReactRendererProps> | 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<DialogElement>,
): 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<DialogElement | null>(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 ? <div slot="header-content">{headerContent}</div> : null}
{footerContent ? <div slot="footer">{footerContent}</div> : null}
{bodyContent}
</_Dialog>
);
}
Expand Down
59 changes: 55 additions & 4 deletions test/Dialog.spec.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -34,7 +34,7 @@ describe('Dialog', () => {
it('should use children if no renderer property set', async () => {
await render(
<Dialog opened header={<>Title</>} footer={<>Footer</>}>
FooBar
<span>FooBar</span>
</Dialog>,
);
await nextRender();
Expand All @@ -47,7 +47,7 @@ describe('Dialog', () => {
opened
headerRenderer={() => <>Title</>}
footerRenderer={() => <>Footer</>}
renderer={() => <>FooBar</>}
renderer={() => <span>FooBar</span>}
></Dialog>,
);
await nextRender();
Expand All @@ -57,13 +57,64 @@ describe('Dialog', () => {
it('should use children as renderer prop', async () => {
await render(
<Dialog opened headerRenderer={() => <>Title</>} footerRenderer={() => <>Footer</>}>
{() => <>FooBar</>}
{() => <span>FooBar</span>}
</Dialog>,
);
await nextRender();
assert();
});

it('should pass the dialog element as `original` to a renderer', async () => {
let received: DialogReactRendererProps['original'] | undefined;
await render(
<Dialog
opened
renderer={({ original }) => {
// `original` must never be null/undefined when the renderer runs.
received = original;
return <span>{original ? original.localName : 'no-element'}</span>;
}}
></Dialog>,
);
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(
<Dialog opened>
<span>FooBar</span>
</Dialog>,
);
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(
<Dialog opened header={showHeader && <>Title</>} footer={showFooter && <>Footer</>}>
<span>FooBar</span>
</Dialog>,
);
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);
Expand Down
Loading