Repository: marigold-ui/marigold
Pull request #2760 (2023-02-10 07:34:34)

feat: add new component Dialog.Controller

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: e5e2b5583b8f107cf80d9ffd0e86781ba295a6d5

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/marigold-ui/marigold/new/dialog-conatiner?filename=.changeset/blue-bottles-play.md&value=---%0A%22%40marigold%2Fdocs%22%3A%20patch%0A%22%40marigold%2Fcomponents%22%3A%20patch%0A---%0A%0Afeat%3A%20add%20new%20component%20Dialog.Controller%0A)


codecov[bot]: # [Codecov](https://codecov.io/gh/marigold-ui/marigold/pull/2760?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marigold-ui) Report
> Merging [#2760](https://codecov.io/gh/marigold-ui/marigold/pull/2760?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marigold-ui) (fe2b1d2) into [main](https://codecov.io/gh/marigold-ui/marigold/commit/e0045a5efaa0b95091d7eb2e2bc1891a5141dbda?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marigold-ui) (e0045a5) will **decrease** coverage by `0.09%`.
> The diff coverage is `95.00%`.

[![Impacted file tree graph](https://codecov.io/gh/marigold-ui/marigold/pull/2760/graphs/tree.svg?width=650&height=150&src=pr&token=YIIcKX7TZu&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marigold-ui)](https://codecov.io/gh/marigold-ui/marigold/pull/2760?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marigold-ui)

```diff
@@             Coverage Diff             @@
##              main    #2760      +/-   ##
===========================================
- Coverage   100.00%   99.91%   -0.09%     
===========================================
  Files          100      101       +1     
  Lines         1165     1185      +20     
  Branches       204      209       +5     
===========================================
+ Hits          1165     1184      +19     
- Misses           0        1       +1     
```

| Flag | Coverage Δ | |
|---|---|---|
| components | `99.91% <95.00%> (-0.09%)` | :arrow_down: |
| system | `99.91% <95.00%> (-0.09%)` | :arrow_down: |
| tokens | `99.91% <95.00%> (-0.09%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marigold-ui#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/marigold-ui/marigold/pull/2760?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marigold-ui) | Coverage Δ | |
|---|---|---|
| [...ackages/components/src/Dialog/DialogController.tsx](https://codecov.io/gh/marigold-ui/marigold/pull/2760?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marigold-ui#diff-cGFja2FnZXMvY29tcG9uZW50cy9zcmMvRGlhbG9nL0RpYWxvZ0NvbnRyb2xsZXIudHN4) | `94.44% <94.44%> (ø)` | |
| [packages/components/src/Dialog/Dialog.tsx](https://codecov.io/gh/marigold-ui/marigold/pull/2760?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=marigold-ui#diff-cGFja2FnZXMvY29tcG9uZW50cy9zcmMvRGlhbG9nL0RpYWxvZy50c3g=) | `100.00% <100.00%> (ø)` | |


Patch:
diff --git a/.changeset/fifty-ravens-hope.md b/.changeset/fifty-ravens-hope.md
new file mode 100644
index 000000000..36fba57ce
--- /dev/null
+++ b/.changeset/fifty-ravens-hope.md
@@ -0,0 +1,6 @@
+---
+"@marigold/docs": minor
+"@marigold/components": minor
+---
+
+feat: Allow `<Dialog>` to be controlled via `<Dialog.Container>`
diff --git a/docs/src/demos/components/Menu/index.ts b/docs/src/demos/components/Menu/index.ts
index 71ec50ec6..bca7fccb3 100644
--- a/docs/src/demos/components/Menu/index.ts
+++ b/docs/src/demos/components/Menu/index.ts
@@ -1,3 +1,4 @@
 export * from './menu-action.demo';
 export * from './menu-disabled.demo';
 export * from './kebab-menu.demo';
+export * from './menu-open-dialog.demo';
diff --git a/docs/src/demos/components/Menu/menu-open-dialog.demo.tsx b/docs/src/demos/components/Menu/menu-open-dialog.demo.tsx
new file mode 100644
index 000000000..13526664e
--- /dev/null
+++ b/docs/src/demos/components/Menu/menu-open-dialog.demo.tsx
@@ -0,0 +1,67 @@
+import { useState } from 'react';
+import {
+  Body,
+  Button,
+  Dialog,
+  Footer,
+  Header,
+  Headline,
+  Inline,
+  Menu,
+  Stack,
+  Text,
+} from '@marigold/components';
+
+export const OpenDialogFromMenu = () => {
+  const [open, setOpen] = useState(false);
+  const handleAction = (action: string) => {
+    switch (action) {
+      case 'two':
+        setOpen(!open);
+        break;
+      case 'one':
+        break;
+      default:
+        throw new Error(`Unhandled action "${action}"!`);
+    }
+  };
+  return (
+    <>
+      <Menu.Trigger>
+        <Button variant="menu" size="small">
+          Settings
+        </Button>
+        <Menu onAction={handleAction}>
+          <Menu.Item key="one">Save</Menu.Item>
+          <Menu.Item key="two">Delete</Menu.Item>
+        </Menu>
+      </Menu.Trigger>
+      <Dialog.Controller onOpenChange={o => setOpen(o)}>
+        {open && (
+          <Dialog closeButton>
+            {({ close, titleProps }) => (
+              <Stack space="medium">
+                <Header>
+                  <Headline {...titleProps}>Confirm delete</Headline>
+                </Header>
+                <Body>
+                  <Text>Do you really wanna delete this?</Text>
+                </Body>
+                <Footer>
+                  <Inline space="medium">
+                    <Button size="small" variant="ghost" onPress={close}>
+                      Abbrechen
+                    </Button>
+                    <Button size="small" variant="primary">
+                      Delete
+                    </Button>
+                  </Inline>
+                </Footer>
+              </Stack>
+            )}
+          </Dialog>
+        )}
+      </Dialog.Controller>
+    </>
+  );
+};
diff --git a/docs/src/pages/components/dialog.mdx b/docs/src/pages/components/dialog.mdx
index d9b778089..64035a7f8 100644
--- a/docs/src/pages/components/dialog.mdx
+++ b/docs/src/pages/components/dialog.mdx
@@ -6,6 +6,7 @@ github: https://github.com/marigold-ui/marigold/blob/main/packages/components/sr
 edit: https://github.com/marigold-ui/marigold/blob/main/docs/src/pages/components/dialog.mdx
 switchTheme: true
 caption: Component for displaying dialogs.
+badge: updated
 ---
 
 The `<Dialog>` is a overlay component. This means it displays a modal as a reaction to an event. It appears in the middle of the display and blurs out the underlay.
@@ -14,6 +15,8 @@ This component can be styled in different parts. It has as parts `closeButton` a
 
 A special thing is also that you can have props on the `<Dialog.Trigger>` and on the `<Dialog>` itself. The `closeButton` is an optionally property with which you can close the `<Dialog>`.
 
+The `<Dialog.Controller>` should be used if you want to use the `<Dialog>` without a trigger. For example if you want to open it within a `<Menu>`.
+
 <Preview>
   <DialogDemo />
 </Preview>
@@ -76,6 +79,45 @@ import { Dialog } from '@marigold/components';
   ]}
 />
 
+### Controller props
+
+<PropsTable
+  props={[
+    {
+      property: 'open',
+      type: 'boolean',
+      description: 'Whether the overlay is open by default (controlled).',
+      default: 'false',
+    },
+    {
+      property: 'onOpenChange',
+      type: '(isOpen: boolean) => void',
+      description:
+        "Handler that is called when the overlay's open state changes.",
+      default: 'none',
+    },
+    {
+      property: 'dismissable',
+      type: 'boolean',
+      description:
+        'Whether to close the overlay when the user interacts outside it.',
+      default: 'true',
+    },
+    {
+      property: 'keyboardDismissable',
+      type: 'boolean',
+      description:
+        'Whether pressing the escape key to close the overlay should be disabled.',
+      default: 'true',
+    },
+    {
+      property: 'children',
+      type: 'ReactNode',
+      description: 'Children of the component.',
+    },
+  ]}
+/>
+
 ## Examples
 
 ### Form Dialog
@@ -93,3 +135,12 @@ If you choose to add `dismissable={false}` to your `<Dialog.Trigger>`, you can't
 ```tsx preview file=components/Dialog/dialog-dismiss.demo.tsx
 
 ```
+
+### Dialog opend from Menu
+
+This Example shows how to open a [`<Dialog>`](/components/dialog/) from a `<Menu.Item>`.
+For that you have not to use a `<Dialog.Trigger>`, instead use our `<Dialog.Controller>`. With that you don't have to use a `<Button>` to trigger the dialog.
+
+```tsx preview file=components/Menu/menu-open-dialog.demo.tsx
+
+```
diff --git a/docs/src/pages/components/menu.mdx b/docs/src/pages/components/menu.mdx
index 56b88ccb1..9554a6a37 100644
--- a/docs/src/pages/components/menu.mdx
+++ b/docs/src/pages/components/menu.mdx
@@ -89,3 +89,12 @@ It is very similar to the normal `<Menu>` component.
 ```tsx preview file=components/Menu/kebab-menu.demo.tsx
 
 ```
+
+### Menu item opens Dialog
+
+This Example shows how to open a [`<Dialog>`](/components/dialog/) from a `<Menu.Item>`.
+For that you have not to use a `<Dialog.Trigger>`, instead use our `<Dialog.Controller>`. With that you don't have to use a `<Button>` to trigger the dialog.
+
+```tsx preview file=components/Menu/menu-open-dialog.demo.tsx
+
+```
diff --git a/packages/components/src/Dialog/Dialog.stories.tsx b/packages/components/src/Dialog/Dialog.stories.tsx
index 2c8f48b0b..2d79dea13 100644
--- a/packages/components/src/Dialog/Dialog.stories.tsx
+++ b/packages/components/src/Dialog/Dialog.stories.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState } from 'react';
 import type { Meta, ComponentStory } from '@storybook/react';
 
 import { Button } from '../Button';
@@ -14,6 +14,7 @@ import { Footer } from '../Footer';
 import { Header } from '../Header';
 import { Checkbox, CheckboxGroup } from '../Checkbox';
 import { Box } from '@marigold/system';
+import { Menu } from '../Menu';
 
 export default {
   title: 'Components/Dialog',
@@ -158,3 +159,56 @@ export const StickyFooter: ComponentStory<typeof Dialog.Trigger> = args => (
     </Dialog>
   </Dialog.Trigger>
 );
+
+export const FromMenu: ComponentStory<typeof Dialog.Trigger> = args => {
+  const [open, setOpen] = useState(false);
+  const handleAction = (action: string) => {
+    switch (action) {
+      case 'two':
+        setOpen(!open);
+        break;
+      default:
+        throw new Error(`Unhandled action "${action}"!`);
+    }
+  };
+
+  return (
+    <>
+      <Menu.Trigger>
+        <Button variant="menu" size="small">
+          Settings
+        </Button>
+        <Menu onAction={handleAction}>
+          <Menu.Item key="one">Save</Menu.Item>
+          <Menu.Item key="two">Delete</Menu.Item>
+        </Menu>
+      </Menu.Trigger>
+      <Dialog.Controller onOpenChange={o => setOpen(o)}>
+        {open && (
+          <Dialog closeButton>
+            {({ close, titleProps }) => (
+              <Stack space="medium">
+                <Header>
+                  <Headline {...titleProps}>Confirm delete</Headline>
+                </Header>
+                <Body>
+                  <Text>Do you really wanna delete this?</Text>
+                </Body>
+                <Footer>
+                  <Inline space="medium">
+                    <Button size="small" variant="ghost" onPress={close}>
+                      Abbrechen
+                    </Button>
+                    <Button size="small" variant="primary">
+                      Delete
+                    </Button>
+                  </Inline>
+                </Footer>
+              </Stack>
+            )}
+          </Dialog>
+        )}
+      </Dialog.Controller>
+    </>
+  );
+};
diff --git a/packages/components/src/Dialog/Dialog.tsx b/packages/components/src/Dialog/Dialog.tsx
index 621f3abb3..4501df37d 100644
--- a/packages/components/src/Dialog/Dialog.tsx
+++ b/packages/components/src/Dialog/Dialog.tsx
@@ -14,6 +14,7 @@ import { Header } from '../Header';
 import { Headline } from '../Headline';
 import { DialogContextProps, useDialogContext } from './Context';
 import { DialogTrigger } from './DialogTrigger';
+import { DialogController } from './DialogController';
 
 // Close Button
 // ---------------
@@ -145,3 +146,4 @@ export const Dialog = ({
 };
 
 Dialog.Trigger = DialogTrigger;
+Dialog.Controller = DialogController;
diff --git a/packages/components/src/Dialog/DialogController.tsx b/packages/components/src/Dialog/DialogController.tsx
new file mode 100644
index 000000000..b162b854e
--- /dev/null
+++ b/packages/components/src/Dialog/DialogController.tsx
@@ -0,0 +1,52 @@
+import { useOverlayTriggerState } from '@react-stately/overlays';
+import React, { ReactElement, ReactNode, useRef } from 'react';
+import { Modal, Overlay } from '../Overlay';
+import { DialogContext } from './Context';
+
+export interface DialogControllerProps {
+  children: ReactNode;
+  dismissable?: boolean;
+  keyboardDismissable?: boolean;
+  open?: boolean;
+  onOpenChange?: (isOpen: boolean) => void;
+}
+
+export const DialogController = ({
+  children,
+  dismissable = true,
+  keyboardDismissable = true,
+  onOpenChange,
+}: DialogControllerProps) => {
+  const childArray = React.Children.toArray(children);
+  if (childArray.length > 1) {
+    throw new Error('Only a single child can be passed to DialogController.');
+  }
+
+  const lastChild = useRef<ReactElement | null>(null);
+  const child = React.isValidElement(childArray[0]) ? childArray[0] : null;
+  if (child) {
+    lastChild.current = child;
+  }
+
+  const state = useOverlayTriggerState({
+    isOpen: !!child,
+    onOpenChange,
+  });
+
+  const ctx = { open: state.isOpen, close: state.close };
+
+  return (
+    <DialogContext.Provider value={ctx}>
+      <Overlay open={state.isOpen}>
+        <Modal
+          open={state.isOpen}
+          onClose={state.close}
+          dismissable={dismissable}
+          keyboardDismissable={keyboardDismissable}
+        >
+          {lastChild.current}
+        </Modal>
+      </Overlay>
+    </DialogContext.Provider>
+  );
+};


Test patch:
diff --git a/packages/components/src/Dialog/Dialog.test.tsx b/packages/components/src/Dialog/Dialog.test.tsx
index cb31444e8..9ec33cc5d 100644
--- a/packages/components/src/Dialog/Dialog.test.tsx
+++ b/packages/components/src/Dialog/Dialog.test.tsx
@@ -1,14 +1,15 @@
 /* eslint-disable testing-library/no-node-access */
+/* eslint-disable testing-library/no-container */
 import React from 'react';
 import { fireEvent, render, screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 
+import { OverlayProvider } from '@react-aria/overlays';
 import { ThemeProvider } from '@marigold/system';
 
 import { Dialog } from './Dialog';
 import { Button } from '../Button';
 import { Headline } from '../Headline';
-import { OverlayProvider } from '@react-aria/overlays';
 
 const theme = {
   colors: {
@@ -52,6 +53,17 @@ const theme = {
 };
 
 const user = userEvent.setup();
+
+let errorMock: jest.SpyInstance;
+
+beforeEach(() => {
+  errorMock = jest.spyOn(console, 'error').mockImplementation();
+});
+
+afterEach(() => {
+  errorMock.mockRestore();
+});
+
 test('renders children correctly', () => {
   render(
     <OverlayProvider>
@@ -416,3 +428,89 @@ test('dialog supports size', () => {
   expect(dialog).toBeVisible();
   expect(dialog).toHaveStyle('width: 400px');
 });
+
+test('renders with dialog controller', () => {
+  render(
+    <OverlayProvider>
+      <Dialog.Controller>
+        <Dialog>
+          <Headline>Headline</Headline>Content
+        </Dialog>
+      </Dialog.Controller>
+    </OverlayProvider>
+  );
+
+  const dialog = screen.getByRole('dialog');
+  expect(dialog).toBeVisible();
+});
+
+test('onOpenChange has been called in dialog controller', () => {
+  const setState = jest.fn();
+  const useStateSpy = jest.spyOn(React, 'useState');
+  // eslint-disable-next-line no-sparse-arrays
+  useStateSpy.mockImplementation(() => [, setState]);
+  render(
+    <OverlayProvider>
+      <Dialog.Controller onOpenChange={a => setState(a)}>
+        <Dialog>
+          <Headline>Headline</Headline>Content
+        </Dialog>
+      </Dialog.Controller>
+    </OverlayProvider>
+  );
+
+  expect(setState).toHaveBeenCalled();
+});
+
+test('dialog controller accepts only one child', () => {
+  render(
+    <OverlayProvider>
+      <Dialog.Controller>
+        <Dialog>
+          <Headline>Headline</Headline>Content
+        </Dialog>
+      </Dialog.Controller>
+    </OverlayProvider>
+  );
+
+  const dialog = screen.getByRole('dialog');
+  expect(dialog).toBeVisible();
+
+  const parent = dialog.parentElement;
+  expect(parent?.children).toHaveLength(1);
+  expect(parent?.children).not.toHaveLength(2);
+
+  expect(parent).toBeValid();
+  expect(dialog).toBeValid();
+});
+
+test('dialog controller throw errof if not one child', () => {
+  expect(() => {
+    render(
+      <OverlayProvider>
+        <Dialog.Controller>
+          <Dialog>Content</Dialog>
+          <div>something</div>
+        </Dialog.Controller>
+      </OverlayProvider>
+    );
+  }).toThrow(Error);
+  expect(errorMock).toHaveBeenCalled();
+});
+
+test('dialog controller expect no valid child', () => {
+  const children = null;
+  const { container } = render(
+    <OverlayProvider>
+      <Dialog.Controller data-testid="dialog">{children}</Dialog.Controller>
+    </OverlayProvider>
+  );
+
+  expect(
+    container.querySelector(`div[data-overlay-container="true"]`)?.childNodes
+  ).toHaveLength(0);
+
+  expect(
+    container.querySelector(`div[data-overlay-container="true"]`)?.lastChild
+  ).toBeNull();
+});