From 135e01696bbd8f632c83ccf3d98da14a3797d1e9 Mon Sep 17 00:00:00 2001 From: mkrause Date: Mon, 20 Jan 2025 20:44:30 +0100 Subject: [PATCH] Implement useOverlayWithSubject() hook. --- .../overlays/DialogModal/DialogModal.tsx | 7 ++-- .../DialogOverlay/DialogOverlay.stories.tsx | 30 ++++++++++++++++ .../overlays/DialogOverlay/DialogOverlay.tsx | 34 +++++++++++++++++++ .../overlays/ToastProvider/ToastProvider.tsx | 22 ++++++------ 4 files changed, 81 insertions(+), 12 deletions(-) diff --git a/src/components/overlays/DialogModal/DialogModal.tsx b/src/components/overlays/DialogModal/DialogModal.tsx index 336ae7d..64de64b 100644 --- a/src/components/overlays/DialogModal/DialogModal.tsx +++ b/src/components/overlays/DialogModal/DialogModal.tsx @@ -111,7 +111,8 @@ export const useConfirmationModal = ( children: 'Are you sure you want to perform this action?', actions: ( <> - { const subject = modal.subject; if (typeof subject === 'undefined') { @@ -121,7 +122,9 @@ export const useConfirmationModal = ( onCancel?.(subject); }} /> - { const subject = modal.subject; if (typeof subject === 'undefined') { diff --git a/src/components/overlays/DialogOverlay/DialogOverlay.stories.tsx b/src/components/overlays/DialogOverlay/DialogOverlay.stories.tsx index 1786151..43bb3c4 100644 --- a/src/components/overlays/DialogOverlay/DialogOverlay.stories.tsx +++ b/src/components/overlays/DialogOverlay/DialogOverlay.stories.tsx @@ -46,6 +46,36 @@ export const DialogOverlaySmall: Story = { args: { size: 'small' } }; export const DialogOverlayMedium: Story = { args: { size: 'medium' } }; export const DialogOverlayLarge: Story = { args: { size: 'large' } }; +const DialogOverlayControlledWithSubject = (props: React.ComponentProps) => { + type Subject = { name: string }; + const overlay = DialogOverlay.useOverlayWithSubject(); + + return ( +
+ {overlay.subject && + + Details about {overlay.subject.name} here. + + } + +

A single details overlay will be used, filled in with the subject based on which name was pressed.

+ +

+

+ ); +}; +export const DialogModalWithSubject: Story = { + args: { + trigger: undefined, + }, + render: (args) => , +}; + export const DialogOverlayWithNestedModal: Story = { args: { display: 'slide-over', diff --git a/src/components/overlays/DialogOverlay/DialogOverlay.tsx b/src/components/overlays/DialogOverlay/DialogOverlay.tsx index da1e4ca..ab27376 100644 --- a/src/components/overlays/DialogOverlay/DialogOverlay.tsx +++ b/src/components/overlays/DialogOverlay/DialogOverlay.tsx @@ -3,6 +3,7 @@ |* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import * as React from 'react'; +import { flushSync } from 'react-dom'; import { mergeRefs } from '../../../util/reactUtil.ts'; import { classNames as cx } from '../../../util/componentUtil.ts'; @@ -51,6 +52,38 @@ export type DialogOverlayProps = Omit, 'chil providerProps?: undefined | Omit, }; +export type OverlayWithSubject = { + props: Partial, + subject: undefined | S, + activateWith: (subject: S | (() => S)) => void, +}; +/** + * Utility hook to get a reference to a `DialogOverlay` for imperative usage. To open, you can call `activate()`, or + * `activateWith()` if you want to include some subject data to be shown in the modal. + */ +export const useOverlayWithSubject = ( + config?: undefined | { + subjectInitial?: undefined | S | (() => undefined | S), + }, +): OverlayWithSubject => { + const { subjectInitial } = config ?? {}; + + const popoverRef = PopoverProvider.useRef(null); + const [subject, setSubject] = React.useState(subjectInitial); + + return { + props: { popoverRef }, + subject, + activateWith: (subject: S | (() => S)) => { + // Use flushSync() to force the modal to render, in case the modal rendering is conditional + // on the subject being set. + flushSync(() => { setSubject(subject); }); + + popoverRef.current?.activate(); + }, + }; +}; + /** * A dialog component displayed as a popover when activating the given trigger. */ @@ -108,6 +141,7 @@ export const DialogOverlay = Object.assign( }, { usePopoverRef: PopoverProvider.useRef, + useOverlayWithSubject, Action: Dialog.Action, ActionIcon: Dialog.ActionIcon, CancelAction: Dialog.CancelAction, diff --git a/src/components/overlays/ToastProvider/ToastProvider.tsx b/src/components/overlays/ToastProvider/ToastProvider.tsx index 7c894d6..937a415 100644 --- a/src/components/overlays/ToastProvider/ToastProvider.tsx +++ b/src/components/overlays/ToastProvider/ToastProvider.tsx @@ -119,17 +119,19 @@ export const Toaster = (props: ToasterProps) => { }, [toastStore]); // Pause auto-close when the page is not currently visible by the user - const handleVisibilityChange = React.useCallback(() => { - if (document.visibilityState === 'visible') { - toastStore.onPageVisible(); - } else { - toastStore.onPageHide(); - } - }, [toastStore]); React.useEffect(() => { - window.document.addEventListener('visibilitychange', handleVisibilityChange, false); - return () => { window.document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, [handleVisibilityChange]); + const controller = new AbortController(); + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + toastStore.onPageVisible(); + } else { + toastStore.onPageHide(); + } + }, { signal: controller.signal }); + + return () => { controller.abort(); }; + }, [toastStore]); const containerRef = React.useRef>(null); const openPopover = React.useCallback((container: null | HTMLElement) => {