diff --git a/.changeset/lovely-points-heal.md b/.changeset/lovely-points-heal.md new file mode 100644 index 0000000000..06a819f5d1 --- /dev/null +++ b/.changeset/lovely-points-heal.md @@ -0,0 +1,5 @@ +--- +"@navikt/ds-react": minor +--- + +Portal: Ny komponent `Portal` som lar deg enkelt bruke `createPortal`, også på serversiden diff --git a/@navikt/core/react/src/index.ts b/@navikt/core/react/src/index.ts index 77a394565c..c1d74f32d4 100644 --- a/@navikt/core/react/src/index.ts +++ b/@navikt/core/react/src/index.ts @@ -10,6 +10,7 @@ export * from "./expansion-card"; export * from "./form"; export * from "./grid"; export * from "./guide-panel"; +export * from "./overlays"; export * from "./help-text"; export * from "./internal-header"; export * from "./link"; diff --git a/@navikt/core/react/src/overlays/index.ts b/@navikt/core/react/src/overlays/index.ts new file mode 100644 index 0000000000..95af99bf01 --- /dev/null +++ b/@navikt/core/react/src/overlays/index.ts @@ -0,0 +1,2 @@ +export { Portal } from "./portal/Portal"; +export type { PortalProps } from "./portal/Portal"; diff --git a/@navikt/core/react/src/overlays/portal/Portal.stories.tsx b/@navikt/core/react/src/overlays/portal/Portal.stories.tsx new file mode 100644 index 0000000000..2d5248b8d7 --- /dev/null +++ b/@navikt/core/react/src/overlays/portal/Portal.stories.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { Box } from "../../layout/box"; +import { Provider } from "../../provider"; +import { Portal } from "./Portal"; + +export default { + title: "Utilities/Portal", + parameters: { + chromatic: { disable: true }, + }, +}; + +export const Default = () => { + return ( + +

In regular DOM tree

+

+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Temporibus + necessitatibus quis esse nesciunt est velit voluptatibus. Distinctio eum + commodi tempora unde. Nulla vel tempora incidunt? Voluptatem molestias + impedit commodi. Tenetur! +

+ +

Inside Portal to different DOM tree

+

+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Temporibus + necessitatibus quis esse nesciunt est velit voluptatibus. Distinctio + eum commodi tempora unde. Nulla vel tempora incidunt? Voluptatem + molestias impedit commodi. Tenetur! +

+
+
+ ); +}; + +export const CustomPortalRoot = () => { + const [portalContainer, setPortalContainer] = + React.useState(null); + + return ( + + +

Tree A

+ +

This is mounted to Tree B, while created inside Tree A

+
+
+ +

Tree B

+
+
+ ); +}; + +export const CustomPortalRootFromProvider = () => { + const [portalContainer, setPortalContainer] = + React.useState(); + + return ( + + + +

Tree A

+ +

This is mounted to Tree B, while created inside Tree A

+
+
+ +

Tree B

+
+
+
+ ); +}; + +export const AsChild = () => { + return ( + +

In regular DOM tree

+

+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Temporibus + necessitatibus quis esse nesciunt est velit voluptatibus. Distinctio eum + commodi tempora unde. Nulla vel tempora incidunt? Voluptatem molestias + impedit commodi. Tenetur! +

+ +
+

Inside Portal to different DOM tree

+

+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Temporibus + necessitatibus quis esse nesciunt est velit voluptatibus. Distinctio + eum commodi tempora unde. Nulla vel tempora incidunt? Voluptatem + molestias impedit commodi. Tenetur! +

+
+
+
+ ); +}; diff --git a/@navikt/core/react/src/overlays/portal/Portal.tsx b/@navikt/core/react/src/overlays/portal/Portal.tsx new file mode 100644 index 0000000000..7023d0f08f --- /dev/null +++ b/@navikt/core/react/src/overlays/portal/Portal.tsx @@ -0,0 +1,32 @@ +import React, { HTMLAttributes, forwardRef } from "react"; +import ReactDOM from "react-dom"; +import { useProvider } from "../../provider"; +import { Slot } from "../../util/Slot"; +import { AsChildProps } from "../../util/types"; + +interface PortalBaseProps extends HTMLAttributes { + /** + * An optional container where the portaled content should be appended. + */ + rootElement?: HTMLElement | null; +} + +export type PortalProps = PortalBaseProps & AsChildProps; + +export const Portal = forwardRef( + ({ rootElement, asChild, ...rest }, ref) => { + const contextRoot = useProvider()?.rootElement; + const root = rootElement ?? contextRoot ?? globalThis?.document?.body; + + const Component = asChild ? Slot : "div"; + + return root + ? ReactDOM.createPortal( + , + root, + ) + : null; + }, +); + +export default Portal; diff --git a/@navikt/core/react/src/tooltip/Tooltip.tsx b/@navikt/core/react/src/tooltip/Tooltip.tsx index c7bd417562..ea06eb26ac 100644 --- a/@navikt/core/react/src/tooltip/Tooltip.tsx +++ b/@navikt/core/react/src/tooltip/Tooltip.tsx @@ -1,5 +1,4 @@ import { - FloatingPortal, autoUpdate, arrow as flArrow, flip, @@ -21,7 +20,7 @@ import React, { useRef, } from "react"; import { ModalContext } from "../modal/ModalContext"; -import { useProvider } from "../provider"; +import Portal from "../overlays/portal/Portal"; import { Detail } from "../typography"; import { useId } from "../util/hooks"; import { useControllableState } from "../util/hooks/useControllableState"; @@ -123,10 +122,7 @@ export const Tooltip = forwardRef( const arrowRef = useRef(null); const modalContext = useContext(ModalContext); - const providerRootElement = useProvider()?.rootElement; - const rootElement = modalContext - ? modalContext.ref.current - : providerRootElement; + const rootElement = modalContext ? modalContext.ref.current : undefined; const { x, @@ -199,7 +195,7 @@ export const Tooltip = forwardRef( : children?.props["aria-describedby"], }), )} - + {_open && (
( )}
)} -
+ ); }, diff --git a/@navikt/core/react/src/util/Slot.tsx b/@navikt/core/react/src/util/Slot.tsx index b4863eb6c9..f69901a599 100644 --- a/@navikt/core/react/src/util/Slot.tsx +++ b/@navikt/core/react/src/util/Slot.tsx @@ -20,10 +20,12 @@ export const Slot = React.forwardRef( } if (React.Children.count(children) > 1) { - console.error( + const error = new Error( "Aksel: Components using 'asChild' expects to recieve a single React element child.", ); - return React.Children.only(null); + error.name = "SlotError"; + Error.captureStackTrace?.(error, Slot); + throw error; } return null; diff --git a/@navikt/core/react/src/util/types/AsChildProps.ts b/@navikt/core/react/src/util/types/AsChildProps.ts new file mode 100644 index 0000000000..b291dbb12a --- /dev/null +++ b/@navikt/core/react/src/util/types/AsChildProps.ts @@ -0,0 +1,37 @@ +export type AsChildProps = + | { + children: React.ReactElement | false | null; + /** + * Renders the component and its child as a single element, + * merging the props of the component with the props of the child. + * + * @example + * ```tsx + * + * + * + * + * // Renders + * + * ``` + */ + asChild: true; + } + | { + children: React.ReactNode; + /** + * Renders the component and its child as a single element, + * merging the props of the component with the props of the child. + * + * @example + * ```tsx + * + * + * + * + * // Renders + * + * ``` + */ + asChild?: false; + }; diff --git a/@navikt/core/react/src/util/types/index.ts b/@navikt/core/react/src/util/types/index.ts index 61d391379a..60c317deb5 100644 --- a/@navikt/core/react/src/util/types/index.ts +++ b/@navikt/core/react/src/util/types/index.ts @@ -1 +1,2 @@ export type { OverridableComponent } from "./OverridableComponent"; +export type { AsChildProps } from "./AsChildProps";