diff --git a/src-docs/src/views/tour/step_dom.js b/src-docs/src/views/tour/step_dom.js
new file mode 100644
index 00000000000..7943637b679
--- /dev/null
+++ b/src-docs/src/views/tour/step_dom.js
@@ -0,0 +1,71 @@
+import React, { useRef, useState } from 'react';
+
+import {
+ EuiButtonIcon,
+ EuiText,
+ EuiSpacer,
+ EuiTourStep,
+ EuiCode,
+} from '../../../../src/components';
+
+export default () => {
+ const [isOpenRef, setIsOpenRef] = useState(true);
+ const [isOpenSelector, setIsOpenSelector] = useState(true);
+ const anchorRef = useRef();
+ return (
+
+
anchorRef.current}
+ content={
+
+
+ Popover is attached to the anchorRef button
+
+
+ }
+ isStepOpen={isOpenRef}
+ minWidth={300}
+ onFinish={() => setIsOpenRef(false)}
+ step={1}
+ stepsTotal={1}
+ title="React ref as anchor location"
+ anchorPosition="rightDown"
+ />
+ setIsOpenRef(!isOpenRef)}
+ iconType="globe"
+ aria-label="Anchor"
+ buttonRef={anchorRef}
+ />
+
+
+
+
+
+
+ Popover is attached to the #anchorTarget button
+
+
+ }
+ isStepOpen={isOpenSelector}
+ minWidth={300}
+ onFinish={() => setIsOpenSelector(false)}
+ step={1}
+ stepsTotal={1}
+ title="DOM selector as anchor location"
+ anchorPosition="rightUp"
+ />
+ setIsOpenSelector(!isOpenSelector)}
+ iconType="globe"
+ aria-label="Anchor"
+ id="anchorTarget"
+ />
+
+
+
+ );
+};
diff --git a/src-docs/src/views/tour/tour_example.js b/src-docs/src/views/tour/tour_example.js
index 4cf03579c08..c8f85436ae0 100644
--- a/src-docs/src/views/tour/tour_example.js
+++ b/src-docs/src/views/tour/tour_example.js
@@ -10,6 +10,7 @@ import {
} from '../../../../src/components';
import Step from './step';
+import StepDom from './step_dom';
import Tour from './tour';
import Managed from './managed';
import ManagedHook from './managed_hook';
@@ -39,6 +40,7 @@ const stepSnippet = `
`;
+const stepDomSource = require('!!raw-loader!./step_dom');
const tourSource = require('!!raw-loader!./tour');
const managedSource = require('!!raw-loader!./managed');
const managedHookSource = require('!!raw-loader!./managed_hook');
@@ -91,6 +93,26 @@ export const TourExample = {
demo: ,
snippet: stepSnippet,
},
+ {
+ source: [
+ {
+ type: GuideSectionTypes.JS,
+ code: stepDomSource,
+ },
+ ],
+ text: (
+ <>
+ Using DOM selector as anchor location
+
+ Instead of wrapping the target element, use the{' '}
+ anchor prop to specify a DOM node. Accepted
+ values include an HTML element, a function returning an HTML
+ element, or a DOM query selector.
+
+ >
+ ),
+ demo: ,
+ },
{
title: 'Standalone steps',
source: [
diff --git a/src/components/focus_trap/focus_trap.tsx b/src/components/focus_trap/focus_trap.tsx
index 845e361e580..fc67ba70f2b 100644
--- a/src/components/focus_trap/focus_trap.tsx
+++ b/src/components/focus_trap/focus_trap.tsx
@@ -11,13 +11,9 @@ import { FocusOn } from 'react-focus-on';
import { ReactFocusOnProps } from 'react-focus-on/dist/es5/types';
import { CommonProps } from '../common';
+import { findElementBySelectorOrRef, ElementTarget } from '../../services';
-/**
- * A DOM node, a selector string (which will be passed to
- * `document.querySelector()` to find the DOM node), or a function that
- * returns a DOM node.
- */
-export type FocusTarget = HTMLElement | string | (() => HTMLElement);
+export type FocusTarget = ElementTarget;
interface EuiFocusTrapInterface {
/**
@@ -70,12 +66,7 @@ export class EuiFocusTrap extends Component {
// Programmatically sets focus on a nested DOM node; optional
setInitialFocus = (initialFocus?: FocusTarget) => {
- let node = initialFocus instanceof HTMLElement ? initialFocus : null;
- if (typeof initialFocus === 'string') {
- node = document.querySelector(initialFocus as string);
- } else if (typeof initialFocus === 'function') {
- node = (initialFocus as () => HTMLElement)();
- }
+ const node = findElementBySelectorOrRef(initialFocus);
if (!node) return;
// `data-autofocus` is part of the 'react-focus-on' API
node.setAttribute('data-autofocus', 'true');
diff --git a/src/components/tour/tour_step.spec.tsx b/src/components/tour/tour_step.spec.tsx
new file mode 100644
index 00000000000..f635f804a84
--- /dev/null
+++ b/src/components/tour/tour_step.spec.tsx
@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React from 'react';
+
+import { EuiTourStep } from './tour_step';
+
+const steps = [
+ {
+ step: 1,
+ content: 'You are here',
+ },
+];
+
+const config = {
+ onFinish: () => {},
+ stepsTotal: 1,
+ title: 'A demo',
+};
+
+describe('EuiTourStep', () => {
+ describe('with an `anchor` configuration', () => {
+ it('attaches to the anchor element', () => {
+ cy.realMount(
+ <>
+ Test
+
+ >
+ );
+
+ expect(cy.get('[data-test-subj="step"]').find('#anchor')).to.exist;
+ });
+ });
+});
diff --git a/src/components/tour/tour_step.tsx b/src/components/tour/tour_step.tsx
index 24887e09ed9..6da963feea1 100644
--- a/src/components/tour/tour_step.tsx
+++ b/src/components/tour/tour_step.tsx
@@ -11,10 +11,13 @@ import React, {
FunctionComponent,
ReactElement,
ReactNode,
+ useEffect,
+ useRef,
+ useState,
} from 'react';
import classNames from 'classnames';
-import { CommonProps, NoArgCallback } from '../common';
+import { CommonProps, ExclusiveUnion, NoArgCallback } from '../common';
import { EuiBeacon } from '../beacon';
import { EuiButtonEmpty, EuiButtonEmptyProps } from '../button';
@@ -25,88 +28,106 @@ import {
EuiPopoverFooter,
EuiPopoverProps,
EuiPopoverTitle,
+ EuiWrappingPopover,
} from '../popover';
import { EuiTitle } from '../title';
import { EuiTourStepIndicator, EuiTourStepStatus } from './tour_step_indicator';
-import { useGeneratedHtmlId } from '../../services';
+import {
+ useGeneratedHtmlId,
+ findElementBySelectorOrRef,
+ ElementTarget,
+} from '../../services';
type PopoverOverrides = 'button' | 'closePopover';
-type EuiPopoverPartials = Partial>;
-
-export interface EuiTourStepProps
- extends CommonProps,
- Omit,
- EuiPopoverPartials {
- /**
- * Element to which the tour step popover attaches when open
- */
- children: ReactElement;
-
- /**
- * Contents of the tour step popover
- */
- content: ReactNode;
-
- /**
- * Step will display if set to `true`
- */
- isStepOpen?: boolean;
-
- /**
- * Change the default min width of the popover panel
- */
- minWidth?: CSSProperties['minWidth'];
-
- /**
- * Change the default max width of the popover panel
- */
- maxWidth?: CSSProperties['maxWidth'];
-
- /**
- * Function to call for 'Skip tour' and 'End tour' actions
- */
- onFinish: NoArgCallback;
-
- /**
- * The number of the step within the parent tour. 1-based indexing.
- */
- step: number;
-
- /**
- * The total number of steps in the tour
- */
- stepsTotal: number;
-
- /**
- * Optional, standard DOM `style` attribute. Passed to the EuiPopover panel.
- */
- style?: CSSProperties;
-
- /**
- * Smaller title text that appears atop each step in the tour. The subtitle gets wrapped in the appropriate heading level.
- */
- subtitle?: ReactNode;
-
- /**
- * Larger title text specific to this step. The title gets wrapped in the appropriate heading level.
- */
- title: ReactNode;
-
- /**
- * Extra visual indication of step location
- */
- decoration?: 'none' | 'beacon';
-
- /**
- * Element to replace the 'Skip tour' link in the footer
- */
- footerAction?: ReactElement;
-}
+type EuiPopoverPartials = Partial>;
+
+export type EuiTourStepAnchorProps = ExclusiveUnion<
+ {
+ /**
+ * Element to which the tour step popover attaches when open
+ */
+ children: ReactElement;
+ /**
+ * Selector or reference to the element to which the tour step popover attaches when open
+ */
+ anchor?: never;
+ },
+ {
+ children?: never;
+ anchor: ElementTarget;
+ }
+>;
+
+export type EuiTourStepProps = CommonProps &
+ Omit &
+ EuiPopoverPartials &
+ EuiTourStepAnchorProps & {
+ /**
+ * Contents of the tour step popover
+ */
+ content: ReactNode;
+
+ /**
+ * Step will display if set to `true`
+ */
+ isStepOpen?: boolean;
+
+ /**
+ * Change the default min width of the popover panel
+ */
+ minWidth?: CSSProperties['minWidth'];
+
+ /**
+ * Change the default max width of the popover panel
+ */
+ maxWidth?: CSSProperties['maxWidth'];
+
+ /**
+ * Function to call for 'Skip tour' and 'End tour' actions
+ */
+ onFinish: NoArgCallback;
+
+ /**
+ * The number of the step within the parent tour. 1-based indexing.
+ */
+ step: number;
+
+ /**
+ * The total number of steps in the tour
+ */
+ stepsTotal: number;
+
+ /**
+ * Optional, standard DOM `style` attribute. Passed to the EuiPopover panel.
+ */
+ style?: CSSProperties;
+
+ /**
+ * Smaller title text that appears atop each step in the tour. The subtitle gets wrapped in the appropriate heading level.
+ */
+ subtitle?: ReactNode;
+
+ /**
+ * Larger title text specific to this step. The title gets wrapped in the appropriate heading level.
+ */
+ title: ReactNode;
+
+ /**
+ * Extra visual indication of step location
+ */
+ decoration?: 'none' | 'beacon';
+
+ /**
+ * Element to replace the 'Skip tour' link in the footer
+ */
+ footerAction?: ReactElement;
+ };
export const EuiTourStep: FunctionComponent = ({
anchorPosition = 'leftUp',
+ anchor,
children,
className,
closePopover = () => {},
@@ -131,6 +152,24 @@ export const EuiTourStep: FunctionComponent = ({
);
}
+ const [hasValidAnchor, setHasValidAnchor] = useState(false);
+ const animationFrameId = useRef();
+ const anchorNode = useRef(null);
+
+ useEffect(() => {
+ if (anchor) {
+ animationFrameId.current = window.requestAnimationFrame(() => {
+ anchorNode.current = findElementBySelectorOrRef(anchor);
+ setHasValidAnchor(anchorNode.current ? true : false);
+ });
+ }
+
+ return () => {
+ animationFrameId.current &&
+ window.cancelAnimationFrame(animationFrameId.current);
+ };
+ }, [anchor]);
+
const newStyle: CSSProperties = { ...style, maxWidth, minWidth };
const classes = classNames('euiTour', className);
@@ -195,20 +234,21 @@ export const EuiTourStep: FunctionComponent = ({
const hasBeacon = decoration === 'beacon';
- return (
- }
- {...rest}
- >
+ const popoverProps = {
+ anchorPosition: anchorPosition,
+ closePopover: closePopover,
+ isOpen: isStepOpen,
+ ownFocus: false,
+ panelClassName: classes,
+ panelStyle: newStyle,
+ offset: hasBeacon ? 10 : 0,
+ 'aria-labelledby': titleId,
+ arrowChildren: hasBeacon && ,
+ ...rest,
+ };
+
+ const layout = (
+ <>
{subtitle && (
@@ -221,6 +261,20 @@ export const EuiTourStep: FunctionComponent = ({
{content}
{footer}
-
+ >
);
+
+ if (!anchor && children) {
+ return (
+
+ {layout}
+
+ );
+ }
+
+ return hasValidAnchor && anchorNode.current ? (
+
+ {layout}
+
+ ) : null;
};
diff --git a/src/components/tour/useEuiTour.tsx b/src/components/tour/useEuiTour.tsx
index e7378dfe824..69a17e780de 100644
--- a/src/components/tour/useEuiTour.tsx
+++ b/src/components/tour/useEuiTour.tsx
@@ -11,8 +11,7 @@ import { assertNever } from '../common';
import { EuiTourStepProps } from './tour_step';
import { EuiTourAction, EuiTourActions, EuiTourState } from './types';
-export type EuiStatelessTourStep = Omit &
- Partial;
+export type EuiStatelessTourStep = EuiTourStepProps & Partial;
export const useEuiTour = (
stepsArray: EuiStatelessTourStep[],
diff --git a/src/services/findElement.test.tsx b/src/services/findElement.test.tsx
new file mode 100644
index 00000000000..7cd844f947f
--- /dev/null
+++ b/src/services/findElement.test.tsx
@@ -0,0 +1,43 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { findElementBySelectorOrRef } from './findElement';
+
+describe('findElementBySelectorOrRef', () => {
+ const element = document.createElement('div');
+ element.setAttribute('id', 'element');
+ document.body.appendChild(element);
+
+ describe('when passed `undefined`', () => {
+ it('should return `null`', () => {
+ expect(findElementBySelectorOrRef(undefined)).toBe(null);
+ });
+ });
+
+ describe('when passed an element', () => {
+ it('should return the element', () => {
+ expect(findElementBySelectorOrRef(element)).toBe(element);
+ });
+ });
+
+ describe('when passed a function', () => {
+ it('should return the result of the function', () => {
+ expect(findElementBySelectorOrRef(() => element)).toBe(element);
+ });
+ });
+
+ describe('when passed a DOM selector', () => {
+ it('should return the result of `querySelector` if found', () => {
+ expect(findElementBySelectorOrRef('#element')).toBe(element);
+ });
+
+ it('should return `null` if not found', () => {
+ expect(findElementBySelectorOrRef('#doesnotexist')).toBe(null);
+ });
+ });
+});
diff --git a/src/services/findElement.ts b/src/services/findElement.ts
new file mode 100644
index 00000000000..202ef1411f9
--- /dev/null
+++ b/src/services/findElement.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+/**
+ * A DOM node, a selector string (which will be passed to
+ * `document.querySelector()` to find the DOM node), or a function that
+ * returns a DOM node.
+ */
+export type ElementTarget = HTMLElement | string | (() => HTMLElement);
+
+export const findElementBySelectorOrRef = (elementTarget?: ElementTarget) => {
+ let node = elementTarget instanceof HTMLElement ? elementTarget : null;
+ if (typeof elementTarget === 'string') {
+ node = document.querySelector(elementTarget as string);
+ } else if (typeof elementTarget === 'function') {
+ node = (elementTarget as () => HTMLElement)();
+ }
+ return node;
+};
diff --git a/src/services/index.ts b/src/services/index.ts
index d37c6a45e77..351641139ba 100644
--- a/src/services/index.ts
+++ b/src/services/index.ts
@@ -76,6 +76,8 @@ export * from './console';
export { copyToClipboard } from './copy_to_clipboard';
+export * from './findElement';
+
export {
formatAuto,
formatBoolean,
diff --git a/upcoming_changelogs/5696.md b/upcoming_changelogs/5696.md
new file mode 100644
index 00000000000..314145df2ff
--- /dev/null
+++ b/upcoming_changelogs/5696.md
@@ -0,0 +1 @@
+- Added `anchor` prop to `EuiTourStep` to allow for DOM selector attachment
\ No newline at end of file