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
50 changes: 35 additions & 15 deletions packages/main/src/components/Modals/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
'use client';

import { useIsomorphicLayoutEffect, useSyncRef } from '@ui5/webcomponents-react-base/internal/hooks';
import type { MutableRefObject } from 'react';
import { createRef, useEffect, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { getRandomId } from '../../internal/getRandomId.js';
import type { IModal } from '../../internal/ModalStore.js';
import { ModalStore } from '../../internal/ModalStore.js';
import type { DialogDomRef, DialogPropTypes } from '../../webComponents/Dialog/index.js';
import { Dialog } from '../../webComponents/Dialog/index.js';
Expand Down Expand Up @@ -84,7 +86,7 @@ function showDialogFn(
Component: Dialog,
props: {
...props,
open: true,
open: false,
onClose: (event) => {
if (typeof props.onClose === 'function') {
props.onClose(event);
Expand Down Expand Up @@ -129,7 +131,7 @@ function showPopoverFn(
Component: Popover,
props: {
...props,
open: true,
open: false,
onClose: (event) => {
if (typeof props.onClose === 'function') {
props.onClose(event);
Expand Down Expand Up @@ -177,7 +179,7 @@ function showResponsivePopoverFn(
Component: ResponsivePopover,
props: {
...props,
open: true,
open: false,
onClose: (event) => {
if (typeof props.onClose === 'function') {
props.onClose(event);
Expand Down Expand Up @@ -219,7 +221,7 @@ function showMenuFn(
Component: Menu,
props: {
...props,
open: true,
open: false,
onClose: (event) => {
if (typeof props.onClose === 'function') {
props.onClose(event);
Expand Down Expand Up @@ -262,7 +264,7 @@ function showMessageBoxFn(
Component: MessageBox,
props: {
...props,
open: true,
open: false,
onClose: (event) => {
if (typeof props.onClose === 'function') {
props.onClose(event);
Expand Down Expand Up @@ -301,7 +303,7 @@ function showToastFn(
Component: Toast,
props: {
...props,
open: true,
open: false,
onClose: (event) => {
if (typeof props.onClose === 'function') {
props.onClose(event);
Expand All @@ -319,6 +321,32 @@ function showToastFn(
};
}

// TODO: Temporary workaround for #9116 — the web component resolves its initial focus synchronously in the
// `open` setter instead of when focus is applied. Remove once fixed upstream (UI5/webcomponents#14234).
/**
* Renders a single modal and opens it only after mount, so the web component's `open` setter runs after all
* other props (e.g. `preventInitialFocus`) are applied. Setting `open` in props applies it too early.
*/
function ModalRenderer({ modal }: { modal: IModal }) {
const { Component, props, ref, container, id } = modal;
const [componentRef, modalRef] = useSyncRef(ref);

useIsomorphicLayoutEffect(() => {
const node = modalRef.current as (Element & { open?: boolean }) | null;
if (node && 'open' in node) {
node.open = true;
}
}, [modalRef]);

// @ts-expect-error: ref is supported by all supported modals
const element = <Component {...props} ref={componentRef} data-id={id} />;

if (container) {
return createPortal(element, container);
}
return element;
}

/**
* Utility class for opening modals in an imperative way.
*
Expand All @@ -344,15 +372,7 @@ export function Modals() {
<>
{modals.map((modal) => {
if (modal?.Component) {
if (modal.container) {
return createPortal(
// @ts-expect-error: ref is supported by all supported modals
<modal.Component {...modal.props} ref={modal.ref} key={modal.id} data-id={modal.id} />,
modal.container,
);
}
// @ts-expect-error: ref is supported by all supported modals
return <modal.Component {...modal.props} ref={modal.ref} key={modal.id} data-id={modal.id} />;
return <ModalRenderer key={modal.id} modal={modal} />;
}
})}
</>
Expand Down
33 changes: 33 additions & 0 deletions packages/main/src/components/Modals/test/Modals.gallery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useState } from 'react';
import { Button } from '../../../webComponents/Button/index.js';
import { Modals } from '../index.js';

export const ModalsPreventInitialFocus = () => {
const [opened, setOpened] = useState(false);
return (
<>
<Modals />
<Button
data-testid="open-dialog"
onClick={() => {
const { close } = Modals.showDialog({
preventInitialFocus: true,
headerText: 'Dialog Title',
onOpen: () => {
setOpened(true);
},
children: "I'm a Dialog!",
footer: (
<Button data-testid="dialog-close" onClick={() => close()}>
Close
</Button>
),
});
}}
>
Open Dialog
</Button>
<span data-testid="opened-flag">{opened ? 'opened' : 'closed'}</span>
</>
);
};
19 changes: 19 additions & 0 deletions packages/main/src/components/Modals/test/Modals.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { expect, test } from '../../../../../../playwright/fixtures/gallery-fixtures.js';

test.describe('Modals.showDialog', () => {
test('preventInitialFocus is applied before the dialog opens', async ({ mount, page }) => {
await mount('Modals/ModalsPreventInitialFocus');

await page.getByTestId('open-dialog').click();

// The `open` event fires only after the dialog applied its initial focus, so waiting
// for it guarantees the focus decision has already been made before we assert on it.
await expect(page.getByTestId('opened-flag')).toHaveText('opened');
await expect(page.locator('[ui5-dialog]')).toBeVisible();

// With preventInitialFocus, focus must not be moved into the dialog...
await expect(page.getByTestId('dialog-close')).not.toBeFocused();
// ...it stays on the opener instead.
await expect(page.getByTestId('open-dialog')).toBeFocused();
});
});
Loading