diff --git a/packages/victory-core/src/exports.test.ts b/packages/victory-core/src/exports.test.ts index e4f2f9065..4937f167c 100644 --- a/packages/victory-core/src/exports.test.ts +++ b/packages/victory-core/src/exports.test.ts @@ -66,6 +66,10 @@ import { Portal, PortalContext, PortalContextValue, + PortalOutlet, + PortalOutletProps, + PortalProvider, + PortalProviderProps, PortalProps, RangePropType, RangeTuple, @@ -137,6 +141,7 @@ import { Wrapper, addEvents, mergeRefs, + usePortalContext, } from "./index"; import { pick } from "lodash"; @@ -173,6 +178,8 @@ describe("victory-core", () => { "PointPathHelpers", "Portal", "PortalContext", + "PortalOutlet", + "PortalProvider", "Rect", "Scale", "Selection", @@ -196,6 +203,7 @@ describe("victory-core", () => { "Wrapper", "addEvents", "mergeRefs", + "usePortalContext", "useVictoryContainer", ] `); diff --git a/packages/victory-core/src/index.ts b/packages/victory-core/src/index.ts index f8980cddd..d9d4666b7 100644 --- a/packages/victory-core/src/index.ts +++ b/packages/victory-core/src/index.ts @@ -9,8 +9,9 @@ export * from "./victory-clip-container/victory-clip-container"; export * from "./victory-theme/types"; export * from "./victory-theme/victory-theme"; export * from "./victory-portal/portal"; -export * from "./victory-portal/portal-context"; export * from "./victory-portal/victory-portal"; +export * from "./victory-portal/portal-context"; +export * from "./victory-portal/portal-outlet"; export * from "./victory-primitives"; export { Border as Box } from "./victory-primitives"; export type { BorderProps as BoxProps } from "./victory-primitives"; diff --git a/packages/victory-core/src/victory-container/victory-container.tsx b/packages/victory-core/src/victory-container/victory-container.tsx index 0e53148fe..950ff86ca 100644 --- a/packages/victory-core/src/victory-container/victory-container.tsx +++ b/packages/victory-core/src/victory-container/victory-container.tsx @@ -1,12 +1,13 @@ import React, { useRef } from "react"; import { uniqueId } from "lodash"; import { Portal } from "../victory-portal/portal"; -import { PortalContext } from "../victory-portal/portal-context"; import * as UserProps from "../victory-util/user-props"; import { OriginType } from "../victory-label/victory-label"; import { D3Scale } from "../types/prop-types"; import { VictoryThemeDefinition } from "../victory-theme/types"; import { mergeRefs } from "../victory-util"; +import { PortalOutlet } from "../victory-portal/portal-outlet"; +import { PortalProvider } from "../victory-portal/portal-context"; export interface VictoryContainerProps { "aria-describedby"?: string; @@ -58,10 +59,6 @@ export function useVictoryContainer( const localContainerRef = useRef(null); - const [portalElement, setPortalElement] = React.useState(); - - const portalRef = (element: SVGSVGElement) => setPortalElement(element); - // Generated ID stored in ref because it needs to persist across renders const generatedId = useRef(uniqueId("victory-container-")); const containerId = props.containerId ?? generatedId.current; @@ -103,8 +100,6 @@ export function useVictoryContainer( ariaLabelledBy, ariaDescribedBy, userProps, - portalRef, - portalElement, localContainerRef, }; } @@ -135,8 +130,6 @@ export const VictoryContainer = (initialProps: VictoryContainerProps) => { userProps, titleId, descId, - portalElement, - portalRef, containerRef, localContainerRef, } = useVictoryContainer(initialProps); @@ -156,22 +149,22 @@ export const VictoryContainer = (initialProps: VictoryContainerProps) => { }, []); return ( - -
+
+ { left: 0, }} > - {React.cloneElement(portalComponent, { - width, - height, - viewBox, - preserveAspectRatio, - style: { ...dimensions, overflow: "visible" }, - ref: portalRef, - })} +
-
-
+ + ); }; diff --git a/packages/victory-core/src/victory-portal/portal-context.ts b/packages/victory-core/src/victory-portal/portal-context.ts deleted file mode 100644 index 3551dcaf4..000000000 --- a/packages/victory-core/src/victory-portal/portal-context.ts +++ /dev/null @@ -1,15 +0,0 @@ -import React from "react"; - -export interface PortalContextValue { - portalElement: SVGSVGElement | undefined; -} - -/** - * The React context object consumers may use to access the context of the - * portal. - */ -export const PortalContext = React.createContext< - PortalContextValue | undefined ->(undefined); - -PortalContext.displayName = "PortalContext"; diff --git a/packages/victory-core/src/victory-portal/portal-context.tsx b/packages/victory-core/src/victory-portal/portal-context.tsx new file mode 100644 index 000000000..1ac6a95cc --- /dev/null +++ b/packages/victory-core/src/victory-portal/portal-context.tsx @@ -0,0 +1,63 @@ +import React from "react"; + +export interface PortalContextValue { + addChild: (id: string, node: React.ReactElement) => void; + removeChild: (id: string) => void; + children: Map; +} + +export const PortalContext = React.createContext< + PortalContextValue | undefined +>(undefined); +PortalContext.displayName = "PortalContext"; + +export const usePortalContext = () => { + const context = React.useContext(PortalContext); + return context; +}; + +export interface PortalProviderProps { + children?: React.ReactNode; +} + +export const PortalProvider = ({ children }: PortalProviderProps) => { + const [portalChildren, setPortalChildren] = React.useState< + Map + >(new Map()); + const addChild = React.useCallback( + (id: string, element: React.ReactElement) => { + setPortalChildren((prevChildren) => { + const nextChildren = new Map(prevChildren); + nextChildren.set(id, element); + return nextChildren; + }); + }, + [setPortalChildren], + ); + + const removeChild = React.useCallback( + (id: string) => { + setPortalChildren((prevChildren) => { + const nextChildren = new Map(prevChildren); + nextChildren.delete(id); + return nextChildren; + }); + }, + [setPortalChildren], + ); + + const contextValue: PortalContextValue = React.useMemo( + () => ({ + addChild, + removeChild, + children: portalChildren, + }), + [addChild, removeChild, portalChildren], + ); + + return ( + + {children} + + ); +}; diff --git a/packages/victory-core/src/victory-portal/portal-outlet.tsx b/packages/victory-core/src/victory-portal/portal-outlet.tsx new file mode 100644 index 000000000..0f8980757 --- /dev/null +++ b/packages/victory-core/src/victory-portal/portal-outlet.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { usePortalContext } from "./portal-context"; + +export interface PortalOutletProps { + as: React.ReactElement; + width?: number; + height?: number; + viewBox?: string; + preserveAspectRatio?: string; + style?: React.CSSProperties; + children?: (children: React.ReactElement[]) => React.ReactNode | undefined; +} + +export const PortalOutlet = ({ + as: portalComponent, + ...props +}: PortalOutletProps) => { + const portalContext = usePortalContext(); + + if (!portalContext) { + return null; + } + + const children = Array.from(portalContext.children.values()); + return React.cloneElement(portalComponent, props, children); +}; diff --git a/packages/victory-core/src/victory-portal/victory-portal.tsx b/packages/victory-core/src/victory-portal/victory-portal.tsx index b6f57ae5d..93bb4d9e3 100644 --- a/packages/victory-core/src/victory-portal/victory-portal.tsx +++ b/packages/victory-core/src/victory-portal/victory-portal.tsx @@ -2,21 +2,21 @@ import React from "react"; import { defaults } from "lodash"; import * as Log from "../victory-util/log"; import * as Helpers from "../victory-util/helpers"; -import { PortalContext } from "./portal-context"; -import { createPortal } from "react-dom"; +import { usePortalContext } from "./portal-context"; export interface VictoryPortalProps { - children?: React.ReactElement; + children: React.ReactElement; groupComponent?: React.ReactElement; } -const defaultProps: VictoryPortalProps = { +const defaultProps: Partial = { groupComponent: , }; export const VictoryPortal = (initialProps: VictoryPortalProps) => { const props = { ...defaultProps, ...initialProps }; - const portalContext = React.useContext(PortalContext); + const id = React.useId(); + const portalContext = usePortalContext(); if (!portalContext) { const msg = @@ -25,9 +25,9 @@ export const VictoryPortal = (initialProps: VictoryPortalProps) => { Log.warn(msg); } - const children = ( - Array.isArray(props.children) ? props.children[0] : props.children - ) as React.ReactElement; + const children = Array.isArray(props.children) + ? props.children[0] + : props.children; const { groupComponent } = props; const childProps = (children && children.props) || {}; const standardProps = childProps.groupComponent @@ -37,13 +37,21 @@ export const VictoryPortal = (initialProps: VictoryPortalProps) => { standardProps, childProps, Helpers.omit(props, ["children", "groupComponent"]), + { key: childProps.key ?? id }, ); const child = children && React.cloneElement(children, newProps); - // If there is no portal context, render the child in place - return portalContext?.portalElement - ? createPortal(child, portalContext.portalElement) - : child; + React.useEffect(() => { + portalContext?.addChild(id, child); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.children]); + + React.useEffect(() => { + return () => portalContext?.removeChild(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return portalContext ? null : child; }; VictoryPortal.role = "portal"; diff --git a/packages/victory-native/src/components/victory-container.tsx b/packages/victory-native/src/components/victory-container.tsx index d63763013..91e2a28b0 100644 --- a/packages/victory-native/src/components/victory-container.tsx +++ b/packages/victory-native/src/components/victory-container.tsx @@ -7,7 +7,8 @@ import { VictoryEventHandler, mergeRefs, useVictoryContainer, - PortalContext, + PortalProvider, + PortalOutlet, } from "victory-core/es"; import NativeHelpers from "../helpers/native-helpers"; import { Portal } from "./victory-portal/portal"; @@ -41,8 +42,6 @@ export const VictoryContainer = (initialProps: VictoryContainerNativeProps) => { viewBox, preserveAspectRatio, userProps, - portalRef, - portalElement, containerRef, events, onTouchStart, @@ -124,37 +123,37 @@ export const VictoryContainer = (initialProps: VictoryContainerNativeProps) => { const baseStyle = NativeHelpers.getStyle(style, ["width", "height"]); return ( - - + - - {/* The following Rect is a temporary solution until the following RNSVG issue is resolved https://github.com/react-native-svg/react-native-svg/issues/1488 */} - - {title ? {title} : null} - {desc ? {desc} : null} + {/* The following Rect is a temporary solution until the following RNSVG issue is resolved https://github.com/react-native-svg/react-native-svg/issues/1488 */} + + {title ? {title} : null} + {desc ? {desc} : null} + {children} { }} pointerEvents="box-none" > - } width={width} height={height} viewBox={viewBox} style={{ ...dimensions, overflow: "visible" }} - ref={portalRef} /> - - - + + + ); }; diff --git a/packages/victory-tooltip/src/victory-tooltip.tsx b/packages/victory-tooltip/src/victory-tooltip.tsx index b79e401e3..c851cda71 100644 --- a/packages/victory-tooltip/src/victory-tooltip.tsx +++ b/packages/victory-tooltip/src/victory-tooltip.tsx @@ -600,7 +600,7 @@ export class VictoryTooltip extends React.Component { const active = Helpers.evaluateProp(props.active, props); const { renderInPortal } = props; if (!active) { - return renderInPortal ? : null; + return null; } const evaluatedProps = this.getEvaluatedProps(props); const { flyoutComponent, labelComponent, groupComponent } = evaluatedProps; diff --git a/packages/victory/src/victory.test.ts b/packages/victory/src/victory.test.ts index 3ea13f275..91f25453e 100644 --- a/packages/victory/src/victory.test.ts +++ b/packages/victory/src/victory.test.ts @@ -336,6 +336,8 @@ describe("victory", () => { "PointPathHelpers", "Portal", "PortalContext", + "PortalOutlet", + "PortalProvider", "RawZoomHelpers", "Rect", "Scale", @@ -407,6 +409,7 @@ describe("victory", () => { "makeCreateContainerFunction", "mergeRefs", "useCanvasContext", + "usePortalContext", "useVictoryBrushContainer", "useVictoryContainer", "useVictoryCursorContainer", diff --git a/stories/victory-portal.stories.tsx b/stories/victory-portal.stories.tsx new file mode 100644 index 000000000..a763bfac1 --- /dev/null +++ b/stories/victory-portal.stories.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { VictoryChart } from "../packages/victory-chart"; +import { VictoryBar } from "../packages/victory-bar"; +import { VictoryGroup } from "../packages/victory-group"; +import { VictoryLabel, VictoryPortal } from "../packages/victory-core"; +import { Meta } from "@storybook/react"; +import { storyContainer } from "./decorators"; + +const meta: Meta = { + title: "Victory Charts/SVG Container/VictoryPortal", + component: VictoryPortal, + tags: ["autodocs"], + decorators: [storyContainer], +}; + +export default meta; + +export const Default = () => { + return ( +
+ + + + + + } + data={[ + { x: 1, y: 1 }, + { x: 2, y: 2 }, + { x: 3, y: 5 }, + ]} + /> + + + + +
+ ); +};