Skip to content

Commit

Permalink
[core] feat: OverlaysProvider (#6674)
Browse files Browse the repository at this point in the history
  • Loading branch information
adidahiya committed Jan 25, 2024
1 parent dcf31b8 commit 315061a
Show file tree
Hide file tree
Showing 32 changed files with 1,142 additions and 298 deletions.
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",
"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

1 comment on commit 315061a

@adidahiya
Copy link
Contributor Author

Choose a reason for hiding this comment

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

[core] feat: OverlaysProvider (#6674)

Build artifact links for this commit: documentation | landing | table | demo

This is an automated comment from the deploy-preview CircleCI job.

Please sign in to comment.