From 5261dfab5d11e6fbe669d83f905f77645d3eead8 Mon Sep 17 00:00:00 2001 From: Sergey Myssak Date: Mon, 15 May 2023 11:21:46 +0600 Subject: [PATCH] Portale element to any referenced DOM element (#707) Co-authored-by: Andrey Myssak Signed-off-by: Sergey Myssak --- scripts/jest/config.json | 1 + scripts/jest/setup/html_element.js | 24 +++++++ .../__snapshots__/bottom_bar.test.tsx.snap | 63 ++++++++++++------- src/components/bottom_bar/bottom_bar.test.tsx | 19 +++++- src/components/bottom_bar/bottom_bar.tsx | 22 ++++--- src/components/popover/popover.tsx | 7 +-- .../portal/__snapshots__/portal.test.tsx.snap | 26 ++++---- src/components/portal/index.ts | 2 +- src/components/portal/portal.test.tsx | 48 ++++++++++++-- src/components/portal/portal.tsx | 32 +++++++--- 10 files changed, 176 insertions(+), 68 deletions(-) create mode 100644 scripts/jest/setup/html_element.js diff --git a/scripts/jest/config.json b/scripts/jest/config.json index 36fa62a369..6dbc972ad6 100644 --- a/scripts/jest/config.json +++ b/scripts/jest/config.json @@ -19,6 +19,7 @@ }, "setupFiles": [ "/scripts/jest/setup/enzyme.js", + "/scripts/jest/setup/html_element.js", "/scripts/jest/setup/throw_on_console_error.js" ], "setupFilesAfterEnv": [ diff --git a/scripts/jest/setup/html_element.js b/scripts/jest/setup/html_element.js new file mode 100644 index 0000000000..3586a541a6 --- /dev/null +++ b/scripts/jest/setup/html_element.js @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +if (typeof window !== 'undefined') { + HTMLElement.prototype.insertAdjacentElement = function (position, element) { + switch (position) { + case 'beforebegin': + this.parentNode.insertBefore(element, this); + break; + case 'afterend': + if (this.nextSibling) { + this.parentNode.insertBefore(element, this.nextSibling); + } else { + this.parentNode.appendChild(element); + } + break; + // add other cases if needed + default: + throw new Error(`Unsupported position: ${position}`); + } + }; +} diff --git a/src/components/bottom_bar/__snapshots__/bottom_bar.test.tsx.snap b/src/components/bottom_bar/__snapshots__/bottom_bar.test.tsx.snap index 9a21156ab8..422beaaae0 100644 --- a/src/components/bottom_bar/__snapshots__/bottom_bar.test.tsx.snap +++ b/src/components/bottom_bar/__snapshots__/bottom_bar.test.tsx.snap @@ -57,6 +57,48 @@ exports[`OuiBottomBar props bodyClassName is rendered 1`] = ` `; +exports[`OuiBottomBar props insert root prop is altered 1`] = ` +Array [ +
+

+ Page level controls +

+
, +

+ There is a new region landmark with page level controls at the end of the document. +

, +] +`; + +exports[`OuiBottomBar props insert sibling prop is altered 1`] = ` +Array [ +
+

+ Page level controls +

+
, +

+ There is a new region landmark with page level controls at the end of the document. +

, +] +`; + exports[`OuiBottomBar props landmarkHeading 1`] = ` Array [
, ] `; - -exports[`OuiBottomBar props usePortal can be false 1`] = ` -Array [ -
-

- Page level controls -

-
, -

- There is a new region landmark with page level controls at the end of the document. -

, -] -`; diff --git a/src/components/bottom_bar/bottom_bar.test.tsx b/src/components/bottom_bar/bottom_bar.test.tsx index b4a822e4aa..638efa063c 100644 --- a/src/components/bottom_bar/bottom_bar.test.tsx +++ b/src/components/bottom_bar/bottom_bar.test.tsx @@ -92,8 +92,23 @@ describe('OuiBottomBar', () => { expect(component).toMatchSnapshot(); }); - test('usePortal can be false', () => { - const component = render(); + test('insert root prop is altered', () => { + const component = render( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('insert sibling prop is altered', () => { + const component = render( + + ); expect(component).toMatchSnapshot(); }); diff --git a/src/components/bottom_bar/bottom_bar.tsx b/src/components/bottom_bar/bottom_bar.tsx index 6d4d4e271b..b29b94eb76 100644 --- a/src/components/bottom_bar/bottom_bar.tsx +++ b/src/components/bottom_bar/bottom_bar.tsx @@ -41,7 +41,7 @@ import { OuiScreenReaderOnly } from '../accessibility'; import { CommonProps, ExclusiveUnion } from '../common'; import { OuiI18n } from '../i18n'; import { useResizeObserver } from '../observer/resize_observer'; -import { OuiPortal } from '../portal'; +import { OuiPortal, OuiPortalInsert } from '../portal'; type BottomBarPaddingSize = 'none' | 's' | 'm' | 'l'; @@ -59,26 +59,27 @@ export const POSITIONS = ['static', 'fixed', 'sticky'] as const; export type _BottomBarPosition = typeof POSITIONS[number]; type _BottomBarExclusivePositions = ExclusiveUnion< + { position?: 'static' | 'sticky' }, { position?: 'fixed'; /** - * Whether to wrap in an OuiPortal which appends the component to the body element. + * Whether to wrap in OuiPortal. Can be configured using "insert" prop. * Only works if `position` is `fixed`. */ usePortal?: boolean; /** - * Whether the component should apply padding on the document body element to afford for its own displacement height. - * Only works if `usePortal` is true and `position` is `fixed`. + * Configuration for placing children in the DOM. By default, attaches children to the body element. + * Only works if `position` is `fixed` and `usePortal` is true. */ - affordForDisplacement?: boolean; - }, - { + insert?: OuiPortalInsert; /** - * How to position the bottom bar against its parent. + * Whether the component should apply padding on the document body element to afford for its own displacement height. + * Only works if `position` is `fixed` and `usePortal` is true. */ - position: 'static' | 'sticky'; + affordForDisplacement?: boolean; } >; + export type OuiBottomBarProps = CommonProps & HTMLAttributes & _BottomBarExclusivePositions & { @@ -132,6 +133,7 @@ export const OuiBottomBar = forwardRef< bodyClassName, landmarkHeading, usePortal = true, + insert, left, right, bottom, @@ -230,7 +232,7 @@ export const OuiBottomBar = forwardRef< ); - return usePortal ? {bar} : bar; + return usePortal ? {bar} : bar; } ); diff --git a/src/components/popover/popover.tsx b/src/components/popover/popover.tsx index 02d8d6788a..0595ef12e5 100644 --- a/src/components/popover/popover.tsx +++ b/src/components/popover/popover.tsx @@ -56,7 +56,7 @@ import { OuiScreenReaderOnly } from '../accessibility'; import { OuiPanel, PanelPaddingSize, OuiPanelProps } from '../panel'; -import { OuiPortal } from '../portal'; +import { OuiPortal, OuiPortalInsert } from '../portal'; import { OuiMutationObserver } from '../observer/mutation_observer'; @@ -141,10 +141,7 @@ export interface OuiPopoverProps { * Passed directly to OuiPortal for DOM positioning. Both properties are * required if prop is specified */ - insert?: { - sibling: HTMLElement; - position: 'before' | 'after'; - }; + insert?: OuiPortalInsert; /** * Visibility state of the popover */ diff --git a/src/components/portal/__snapshots__/portal.test.tsx.snap b/src/components/portal/__snapshots__/portal.test.tsx.snap index 4aa44a6363..334ab87f9e 100644 --- a/src/components/portal/__snapshots__/portal.test.tsx.snap +++ b/src/components/portal/__snapshots__/portal.test.tsx.snap @@ -1,17 +1,15 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`OuiPortal is rendered 1`] = ` -
- - - Content -
- } - > - Content - - - +exports[`OuiPortal should render OuiPortal 1`] = ` + + + Content + + } + > + Content + + `; diff --git a/src/components/portal/index.ts b/src/components/portal/index.ts index fe345a81cb..ff05765aa2 100644 --- a/src/components/portal/index.ts +++ b/src/components/portal/index.ts @@ -28,4 +28,4 @@ * under the License. */ -export { OuiPortal, OuiPortalProps } from './portal'; +export { OuiPortal, OuiPortalProps, OuiPortalInsert } from './portal'; diff --git a/src/components/portal/portal.test.tsx b/src/components/portal/portal.test.tsx index 62f483d695..a1f204a231 100644 --- a/src/components/portal/portal.test.tsx +++ b/src/components/portal/portal.test.tsx @@ -33,13 +33,49 @@ import { mount } from 'enzyme'; import { OuiPortal } from './portal'; describe('OuiPortal', () => { - test('is rendered', () => { - const component = mount( -
- Content -
- ); + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('should render OuiPortal', () => { + const component = mount(Content); expect(component).toMatchSnapshot(); }); + + it('should attach Content to body', () => { + mount(Content); + + expect(document.body.innerHTML).toEqual('
Content
'); + }); + + it('should attach Content inside an element', () => { + const container = document.createElement('div'); + container.setAttribute('id', 'container'); + document.body.appendChild(container); + document.body.appendChild(document.createElement('div')); + + mount(Content); + + expect(document.body.innerHTML).toEqual( + '
Content
' + ); + }); + + it('should attach Content before an element', () => { + const container = document.createElement('div'); + container.setAttribute('id', 'container'); + document.body.appendChild(container); + + mount( + + Content + , + { attachTo: document.body } + ); + + expect(document.body.innerHTML).toEqual( + '
Content
' + ); + }); }); diff --git a/src/components/portal/portal.tsx b/src/components/portal/portal.tsx index 13f788aaa8..56fe35399a 100644 --- a/src/components/portal/portal.tsx +++ b/src/components/portal/portal.tsx @@ -35,7 +35,7 @@ import { Component, ReactNode } from 'react'; import { createPortal } from 'react-dom'; -import { keysOf } from '../common'; +import { ExclusiveUnion, keysOf } from '../common'; interface InsertPositionsMap { after: InsertPosition; @@ -52,18 +52,22 @@ export const INSERT_POSITIONS: OuiPortalInsertPosition[] = keysOf( ); type OuiPortalInsertPosition = keyof typeof insertPositions; +export type OuiPortalInsert = ExclusiveUnion< + { root?: HTMLElement }, + { sibling: HTMLElement; position: OuiPortalInsertPosition } +>; export interface OuiPortalProps { /** * ReactNode to render as this component's content */ children: ReactNode; - insert?: { sibling: HTMLElement; position: OuiPortalInsertPosition }; - portalRef?: (ref: HTMLDivElement | null) => void; + insert?: OuiPortalInsert; + portalRef?: (ref: HTMLElement | null) => void; } export class OuiPortal extends Component { - portalNode: HTMLDivElement; + portalNode: HTMLElement; constructor(props: OuiPortalProps) { super(props); @@ -71,12 +75,22 @@ export class OuiPortal extends Component { this.portalNode = document.createElement('div'); + // no insertion defined, append to body if (insert == null) { - // no insertion defined, append to body document.body.appendChild(this.portalNode); - } else { - // inserting before or after an element - const { sibling, position } = insert; + return; + } + + const { root, sibling, position } = insert; + + // inserting within an element + if (root) { + this.portalNode = root; + return; + } + + // inserting before or after an element + if (sibling && position) { sibling.insertAdjacentElement(insertPositions[position], this.portalNode); } } @@ -92,7 +106,7 @@ export class OuiPortal extends Component { this.updatePortalRef(null); } - updatePortalRef(ref: HTMLDivElement | null) { + updatePortalRef(ref: HTMLElement | null) { if (this.props.portalRef) { this.props.portalRef(ref); }