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