Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[core] feat: OverlaysProvider #6674

Merged
merged 14 commits into from
Jan 25, 2024
Merged
5 changes: 4 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@
"normalize.css": "^8.0.1",
"react-popper": "^2.3.0",
"react-transition-group": "^4.4.5",
"tslib": "~2.6.2"
"react-uid": "^2.3.3",
"tslib": "~2.6.2",
"use-sync-external-store": "^1.2.0"
},
"peerDependencies": {
"@types/react": "^16.14.41 || 17 || 18",
Expand All @@ -73,6 +75,7 @@
"@blueprintjs/node-build-scripts": "workspace:^",
"@blueprintjs/test-commons": "workspace:^",
"@testing-library/react": "^12.1.5",
"@types/use-sync-external-store": "0.0.6",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note that this does not need to be in "dependencies" because it's not exposed in the generated types:

// useLegacyOverlayStack.d.ts
import React from "react";
import { type OverlayStackAction } from "../../context/overlays/overlaysProvider";
import type { UseOverlayStackReturnValue } from "./useOverlayStack";
/**
 * Public only for testing.
 */
export declare const dispatch: React.Dispatch<OverlayStackAction>;
/**
 * Legacy implementation of a global overlay stack which maintains state in a global variable.
 * This is used for backwards-compatibility with overlay-based components in Blueprint v5.
 * It will be removed in Blueprint v6 once `<OverlaysProvider>` is required.
 *
 * @see https://github.com/palantir/blueprint/wiki/Overlay2-migration
 */
export declare function useLegacyOverlayStack(): UseOverlayStackReturnValue;

"enzyme": "^3.11.0",
"karma": "^6.4.2",
"mocha": "^10.2.0",
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/common/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ export const DIALOG_WARN_NO_HEADER_CLOSE_BUTTON =
export const DRAWER_ANGLE_POSITIONS_ARE_CASTED =
ns + ` <Drawer> all angle positions are casted into pure position (TOP, BOTTOM, LEFT or RIGHT)`;

export const OVERLAY2_REQUIRES_OVERLAY_PROVDER =
ns +
` <Overlay2> was used outside of a <OverlaysProvider> context. This will no longer be supported in ` +
`Blueprint v6. See https://github.com/palantir/blueprint/wiki/Overlay2-migration`;
export const OVERLAY_CHILD_REF_AND_REFS_MUTEX =
ns + ` <Overlay2> cannot use childRef and childRefs props simultaneously`;
export const OVERLAY_WITH_MULTIPLE_CHILDREN_REQUIRES_CHILD_REFS =
Expand Down
30 changes: 22 additions & 8 deletions packages/core/src/components/dialog/dialog.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@# Dialogs

__Dialog__ presents content overlaid over other parts of the UI.
**Dialog** presents content overlaid over other parts of the UI.

<div class="@ns-callout @ns-intent-primary @ns-icon-info-sign @ns-callout-has-body-content">
<h5 class="@ns-heading">Terminology note</h5>
Expand All @@ -21,15 +21,29 @@ Blueprint provides two types of dialogs:

@reactExample DialogExample

A standard __Dialog__ renders its contents in an [__Overlay__](#core/components/overlay) with a
@## Usage

<div class="@ns-callout @ns-intent-warning @ns-icon-warning-sign @ns-callout-has-body-content">
<h5 class="@ns-heading">

[OverlaysProvider](#core/context/overlays-provider) recommended

</h5>

This component renders an **Overlay2** which works best inside a React tree which includes an
**OverlaysProvider**. Blueprint v5.x includes a backwards-compatibile shim which allows this context
to be optional, but it will be required in a future major version. See the full
[migration guide](https://github.com/palantir/blueprint/wiki/Overlay2-migration) on the wiki.

</div>

A standard **Dialog** renders its contents in an [**Overlay2**](#core/components/overlay2) with a
`Classes.DIALOG` element. You can use some simple dialog markup sub-components or CSS classes
to structure its contents:

```tsx
<Dialog title="Informational dialog" icon="info-sign">
<DialogBody>
{/* body contents here */}
</DialogBody>
<DialogBody>{/* body contents here */}</DialogBody>
<DialogFooter actions={<Button intent="primary" text="Close" onClick={/* ... */} />} />
</Dialog>
```
Expand Down Expand Up @@ -79,7 +93,7 @@ often fall out of sync as the design system is updated. You should use the React

@### Multistep dialog props

__MultistepDialog__ is a wrapper around __Dialog__ that displays a dialog with multiple steps. Each step has a
**MultistepDialog** is a wrapper around **Dialog** that displays a dialog with multiple steps. Each step has a
corresponding panel.

This component expects `<DialogStep>` child elements: each "step" is rendered in order and its panel is shown as the
Expand All @@ -89,8 +103,8 @@ dialog body content when the corresponding step is selected in the navigation pa

@### DialogStep

__DialogStep__ is a minimal wrapper with no functionality of its own&mdash;it is managed entirely by its parent
__MultistepDialog__ container. Typically, you should render a `<DialogBody>` element as the `panel` element. A step's
**DialogStep** is a minimal wrapper with no functionality of its own&mdash;it is managed entirely by its parent
**MultistepDialog** container. Typically, you should render a `<DialogBody>` element as the `panel` element. A step's
title text can be set via the `title` prop.

@interface DialogStepProps
14 changes: 14 additions & 0 deletions packages/core/src/components/drawer/drawer.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ the lower-level [**Overlay**](#core/components/overlay) component.

@## Usage

<div class="@ns-callout @ns-intent-warning @ns-icon-warning-sign @ns-callout-has-body-content">
<h5 class="@ns-heading">

[OverlaysProvider](#core/context/overlays-provider) recommended

</h5>

This component renders an **Overlay2** which works best inside a React tree which includes an
**OverlaysProvider**. Blueprint v5.x includes a backwards-compatibile shim which allows this context
to be optional, but it will be required in a future major version. See the full
[migration guide](https://github.com/palantir/blueprint/wiki/Overlay2-migration) on the wiki.

</div>

`<Drawer>` is a stateless React component controlled by its `isOpen` prop.

Use the `size` prop to set the size of a **Drawer**. This prop sets CSS `width` if `vertical={false}` (default)
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/components/overlay/overlay.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
---
tag: deprecated
---

@# Overlay

<div class="@ns-callout @ns-intent-danger @ns-icon-error @ns-callout-has-body-content">
Expand Down
25 changes: 17 additions & 8 deletions packages/core/src/components/overlay2/overlay2.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ Migrating from [Overlay](#core/components/overlay)?

</h5>

**Overlay2** is a replacement for **Overlay**. It will become the standard API in a future major version of
Blueprint. You are encouraged to use this new API now for forwards-compatibility. See the full
[**OverlaysProvider**](#core/context/overlays-provider) and **Overlay2**, when used
together, are a replacement for **Overlay**. You are encouraged to use these new APIs, as they will
become the standard in a future major version of Blueprint. See the full
[migration guide](https://github.com/palantir/blueprint/wiki/Overlay2-migration) on the wiki.

</div>
Expand Down Expand Up @@ -42,20 +43,28 @@ The optional backdrop element will be inserted before the children if `hasBackdr
The `onClose` callback prop is invoked when user interaction causes the overlay to close, but your
application is responsible for updating the state that actually closes the overlay.

**Overlay2** _strongly recommends_ usage only within a React subtree which has an
[**OverlaysProvider**](#core/context/overlays-provider). In Blueprint v5.x, the component
implements backwards-compatibilty (via the [`useOverlayStack()` hook](#core/hooks/use-overlay-stack))
such that it will work without one, but this functionality will be removed in a future major version.

```tsx
import { Button, Overlay2, OverlaysProvider } from "@blueprintjs/core";
import { useCallback, useState } from "react";

function Example() {
const [isOpen, setIsOpen] = useState(false);
const toggleOverlay = useCallback(() => setIsOpen(open => !open), [setIsOpen]);

return (
<div>
<Button text="Show overlay" onClick={toggleOverlay} />
<Overlay2 isOpen={isOpen} onClose={toggleOverlay}>
Overlaid contents...
</Overlay2>
</div>
<OverlaysProvider>
<div>
<Button text="Show overlay" onClick={toggleOverlay} />
<Overlay2 isOpen={isOpen} onClose={toggleOverlay}>
Overlaid contents...
</Overlay2>
</div>
</OverlaysProvider>
);
}
```
Expand Down
112 changes: 45 additions & 67 deletions packages/core/src/components/overlay2/overlay2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import classNames from "classnames";
import * as React from "react";
import { CSSTransition, TransitionGroup } from "react-transition-group";
import { useUID } from "react-uid";

import { Classes } from "../../common";
import {
Expand All @@ -33,33 +34,19 @@ import {
isNodeEnv,
isReactElement,
setRef,
uniqueId,
} from "../../common/utils";
import { hasDOMEnvironment } from "../../common/utils/domUtils";
import { useOverlayStack } from "../../hooks/overlays/useOverlayStack";
import { usePrevious } from "../../hooks/usePrevious";
import type { OverlayProps } from "../overlay/overlayProps";
import { getKeyboardFocusableElements } from "../overlay/overlayUtils";
import { Portal } from "../portal/portal";

// HACKHACK: ugly global state manipulation should move to a proper React context
const openStack: OverlayInstance[] = [];
const getLastOpened = () => openStack[openStack.length - 1];

/**
* HACKHACK: ugly global state manipulation should move to a proper React context
*
* @public for testing
* @internal
*/
export function resetOpenStack() {
openStack.splice(0, openStack.length);
}

/**
* Public instance properties & methods for an overlay in the current overlay stack.
*/
export interface OverlayInstance {
/** Bring document focus inside this eoverlay element. */
/** Bring document focus inside this overlay element. */
bringFocusInsideOverlay: () => void;

/** Reference to the overlay container element which may or may not be in a Portal. */
Expand All @@ -69,7 +56,7 @@ export interface OverlayInstance {
handleDocumentFocus: (e: FocusEvent) => void;

/** Unique ID for this overlay which helps to identify it across prop changes. */
id: string | null;
id: string;

/** Subset of props necessary for some overlay stack focus management logic. */
props: Pick<OverlayProps, "autoFocus" | "enforceFocus" | "usePortal" | "hasBackdrop">;
Expand Down Expand Up @@ -129,6 +116,7 @@ export const Overlay2: React.FC<Overlay2Props> = React.forwardRef<OverlayInstanc
} = props;

useOverlay2Validation(props);
const { closeOverlay, getLastOpened, getThisOverlayAndDescendants, openOverlay } = useOverlayStack();

const [isAutoFocusing, setIsAutoFocusing] = React.useState(false);
const [hasEverOpened, setHasEverOpened] = React.useState(false);
Expand Down Expand Up @@ -225,22 +213,22 @@ export const Overlay2: React.FC<Overlay2Props> = React.forwardRef<OverlayInstanc
// see https://github.com/palantir/blueprint/issues/4220
const eventTarget = (e.composed ? e.composedPath()[0] : e.target) as HTMLElement;

const stackIndex = openStack.indexOf(instance);
const isClickInThisOverlayOrDescendant = openStack
.slice(stackIndex)
.some(({ containerElement: containerRef }) => {
const thisOverlayAndDescendants = getThisOverlayAndDescendants(instance);
const isClickInThisOverlayOrDescendant = thisOverlayAndDescendants.some(
({ containerElement: containerRef }) => {
// `elem` is the container of backdrop & content, so clicking directly on that container
// should not count as being "inside" the overlay.
const elem = getRef(containerRef);
return elem?.contains(eventTarget) && !elem.isSameNode(eventTarget);
});
},
);

if (isOpen && !isClickInThisOverlayOrDescendant && canOutsideClickClose) {
// casting to any because this is a native event
onClose?.(e as any);
}
},
[canOutsideClickClose, instance, isOpen, onClose],
[canOutsideClickClose, getThisOverlayAndDescendants, instance, isOpen, onClose],
);

const handleContainerKeyDown = React.useCallback(
Expand All @@ -257,10 +245,12 @@ export const Overlay2: React.FC<Overlay2Props> = React.forwardRef<OverlayInstanc
);

const overlayWillOpen = React.useCallback(() => {
if (openStack.length > 0) {
document.removeEventListener("focus", getLastOpened().handleDocumentFocus, /* useCapture */ true);
const lastOpenedOverlay = getLastOpened();

if (lastOpenedOverlay != null) {
document.removeEventListener("focus", lastOpenedOverlay.handleDocumentFocus, /* useCapture */ true);
}
openStack.push(instance);
openOverlay(instance);

if (autoFocus) {
setIsAutoFocusing(true);
Expand All @@ -277,52 +267,38 @@ export const Overlay2: React.FC<Overlay2Props> = React.forwardRef<OverlayInstanc
document.addEventListener("mousedown", handleDocumentClick);
}

if (hasBackdrop && usePortal) {
// add a class to the body to prevent scrolling of content below the overlay
document.body.classList.add(Classes.OVERLAY_OPEN);
}

setRef(lastActiveElementBeforeOpened, getActiveElement(getRef(containerElement)));
}, [
instance,
autoFocus,
enforceFocus,
bringFocusInsideOverlay,
canOutsideClickClose,
enforceFocus,
getLastOpened,
handleDocumentClick,
handleDocumentFocus,
hasBackdrop,
usePortal,
bringFocusInsideOverlay,
handleDocumentClick,
instance,
openOverlay,
]);

const overlayWillClose = React.useCallback(() => {
document.removeEventListener("focus", handleDocumentFocus, /* useCapture */ true);
document.removeEventListener("mousedown", handleDocumentClick);

const stackIndex = openStack.findIndex(o =>
// fall back to container element ref
o.id != null ? o.id === id : o.containerElement.current === containerElement.current,
);
if (stackIndex !== -1) {
openStack.splice(stackIndex, 1);
if (openStack.length > 0) {
const lastOpenedOverlay = getLastOpened();
// Only bring focus back to last overlay if it had autoFocus _and_ enforceFocus enabled.
// If `autoFocus={false}`, it's likely that the overlay never received focus in the first place,
// so it would be surprising for us to send it there. See https://github.com/palantir/blueprint/issues/4921
if (lastOpenedOverlay.props.autoFocus && lastOpenedOverlay.props.enforceFocus) {
lastOpenedOverlay.bringFocusInsideOverlay();
document.addEventListener("focus", lastOpenedOverlay.handleDocumentFocus, /* useCapture */ true);
}
}

if (openStack.filter(o => o.props.usePortal && o.props.hasBackdrop).length === 0) {
document.body.classList.remove(Classes.OVERLAY_OPEN);
closeOverlay(instance);
const lastOpenedOverlay = getLastOpened();
if (lastOpenedOverlay !== undefined) {
// Only bring focus back to last overlay if it had autoFocus _and_ enforceFocus enabled.
// If `autoFocus={false}`, it's likely that the overlay never received focus in the first place,
// so it would be surprising for us to send it there. See https://github.com/palantir/blueprint/issues/4921
if (lastOpenedOverlay.props.autoFocus && lastOpenedOverlay.props.enforceFocus) {
lastOpenedOverlay.bringFocusInsideOverlay();
document.addEventListener("focus", lastOpenedOverlay.handleDocumentFocus, /* useCapture */ true);
}
}
}, [handleDocumentClick, handleDocumentFocus, id]);
}, [closeOverlay, getLastOpened, handleDocumentClick, handleDocumentFocus, instance]);

const prevIsOpen = usePrevious(isOpen);
const prevIsOpen = usePrevious(isOpen) ?? false;
React.useEffect(() => {
if (isOpen) {
setHasEverOpened(true);
Expand All @@ -340,8 +316,14 @@ export const Overlay2: React.FC<Overlay2Props> = React.forwardRef<OverlayInstanc
}, [isOpen, overlayWillOpen, overlayWillClose, prevIsOpen]);

// run once on unmount
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(() => overlayWillClose, []);
React.useEffect(() => {
return () => {
if (isOpen) {
overlayWillClose();
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const handleTransitionExited = React.useCallback(
(node: HTMLElement) => {
Expand Down Expand Up @@ -685,15 +667,11 @@ function useOverlay2Validation({ childRef, childRefs, children }: Overlay2Props)

/**
* Generates a unique ID for a given Overlay which persists across the component's lifecycle.
*
* N.B. unmounted overlays will have a `null` ID.
*/
function useOverlay2ID(): string | null {
const [id, setId] = React.useState<string | null>(null);
React.useEffect(() => {
setId(uniqueId(Overlay2.displayName!));
}, []);
return id;
function useOverlay2ID(): string {
// TODO: migrate to React.useId() in React 18
const id = useUID();
return `${Overlay2.displayName}-${id}`;
}

// N.B. the `onExiting` callback is not provided with the `node` argument as suggested in CSSTransition types since
Expand Down
Loading