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
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
20 changes: 5 additions & 15 deletions packages/core/src/context/hotkeys/hotkeys-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ Migrating from [HotkeysTarget](#core/legacy/hotkeys-legacy)?

</h5>

__HotkeysProvider__ and `useHotkeys`, used together, are a replacement for __HotkeysTarget__.
**HotkeysProvider** and `useHotkeys`, used together, are a replacement for **HotkeysTarget**.
You are encouraged to use this new API, as it will become the standard APIs in a future major version of Blueprint.
See the full [migration guide](https://github.com/palantir/blueprint/wiki/HotkeysTarget-&-useHotkeys-migration)
on the wiki.

</div>

HotkeysProvider generates a React context necessary for the [`useHotkeys` hook](#core/hooks/use-hotkeys)
**HotkeysProvider** generates a React context necessary for the [`useHotkeys` hook](#core/hooks/use-hotkeys)
to maintain state for the globally-accessible hotkeys dialog. As your application runs and components
are mounted/unmounted, global and local hotkeys are registered/unregistered with this context and
the dialog displays/hides the relevant information. You can try it out in the Blueprint docs app
Expand Down Expand Up @@ -44,12 +44,7 @@ it with the root context instance. This ensures that there will only be one "glo
which has multiple HotkeysProviders.

```tsx
import {
HotkeyConfig,
HotkeysContext,
HotkeysProvider,
HotkeysTarget2
} from "@blueprintjs/core";
import { HotkeyConfig, HotkeysContext, HotkeysProvider, HotkeysTarget2 } from "@blueprintjs/core";
import React, { useContext, useEffect, useRef } from "react";
import * as ReactDOM from "react-dom";

Expand Down Expand Up @@ -84,7 +79,7 @@ function Plugin() {
global: true,
label: "Search",
onKeyDown: () => console.info("search"),
}
},
];

return (
Expand All @@ -100,12 +95,7 @@ function PluginSlot(props) {

useEffect(() => {
if (ref.current != null) {
ReactDOM.render(
<HotkeysProvider value={hotkeysContext}>
{props.children}
</HotkeysProvider>,
ref.current,
);
ReactDOM.render(<HotkeysProvider value={hotkeysContext}>{props.children}</HotkeysProvider>, ref.current);
}
}, [ref, hotkeysContext, props.children]);

Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,10 @@ export {
HotkeysProvider,
type HotkeysProviderProps,
} from "./hotkeys/hotkeysProvider";
export {
OverlaysContext,
OverlaysProvider,
type OverlaysContextInstance,
type OverlaysProviderProps,
} from "./overlays/overlaysProvider";
export { PortalContext, type PortalContextOptions, PortalProvider } from "./portal/portalProvider";
41 changes: 41 additions & 0 deletions packages/core/src/context/overlays/overlays-provider.md
adidahiya marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
@# OverlaysProvider

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

Migrating from [Overlay](#core/components/overlay)?

</h5>

**OverlaysProvider** and [`useOverlayStack()`](#core/hooks/overlays/use-overlay-stack), 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>

**OverlaysProvider** is responsible for managing global overlay state in the application,
specifically the stack of all overlays which are currently open. It provides a
[React context](https://react.dev/learn/passing-data-deeply-with-context) which is used primarily by
the [**Overlay2** component](#core/components/overlay2).

## Usage

To use **OverlaysProvider**, wrap your application with it at the root level:

```tsx
import { OverlaysProvider } from "@blueprintjs/core";
import * as React from "react";
import * as ReactDOM from "react-dom";

ReactDOM.render(
<OverlaysProvider>
<div>My app has overlays 😎</div>
</OverlaysProvider>,
document.querySelector("#app"),
);
```

@## Props interface

@interface OverlaysProviderProps
Loading