diff --git a/packages/react/src/components/Tour/Tour.api.mdx b/packages/react/src/components/Tour/Tour.api.mdx new file mode 100644 index 000000000..ac636899a --- /dev/null +++ b/packages/react/src/components/Tour/Tour.api.mdx @@ -0,0 +1,55 @@ +import { Meta } from '@storybook/addon-docs/blocks'; +import LinkTo from '@storybook/addon-links/react'; +import { TableInterface } from '~storybook/components/TableInterface'; + + + +# Tour API + +```js +import { Tour } from '@esfront/react'; +``` + +## Component name + +The name `ESTour` can be used when providing default props. + +## Props + + + +
+ +## Step + +Each entry of the `steps` prop describes one stop of the tour. + + + +
+ +## Content + +The `content` of a step may be a function, in which case it receives the navigation handles. + + + +
+ +## Step context + +The `before` and `after` callbacks of a step receive the step the tour is moving to. + + + +
+ +## Demos + +
    +
  • + + Tour + +
  • +
diff --git a/packages/react/src/components/Tour/Tour.stories.tsx b/packages/react/src/components/Tour/Tour.stories.tsx new file mode 100644 index 000000000..d9a9270d7 --- /dev/null +++ b/packages/react/src/components/Tour/Tour.stories.tsx @@ -0,0 +1,195 @@ +import { useState } from 'react'; + +import { TourContentProps, TourStep } from './Tour.types'; + +import { Meta, StoryContext, StoryObj } from '@storybook/react-vite'; + +import { Tour } from './Tour'; + +import { Button } from '../Button'; + +const getText = (context: StoryContext) => { + const isEnglish = context.globals.locale === 'en'; + + return { + start: isEnglish ? 'Start the tour' : 'Начать тур', + back: isEnglish ? 'Back' : 'Назад', + next: isEnglish ? 'Next' : 'Далее', + done: isEnglish ? 'Done' : 'Готово', + skip: isEnglish ? 'Skip' : 'Пропустить', + loading: isEnglish ? 'Loading…' : 'Загрузка…', + welcome: isEnglish ? 'Welcome' : 'Добро пожаловать', + welcomeText: isEnglish + ? 'This short tour shows where everything lives.' + : 'Короткий тур покажет, где что находится.', + header: isEnglish ? 'Header' : 'Шапка', + headerText: isEnglish + ? 'The header stays pinned to the top of the page.' + : 'Шапка всегда закреплена в верхней части страницы.', + content: isEnglish ? 'Content' : 'Контент', + contentText: isEnglish + ? 'The cutout follows only the part the header does not cover.' + : 'Подсветка охватывает только ту часть, которую не закрывает шапка.', + late: isEnglish ? 'Loaded on demand' : 'Загружается по требованию', + lateText: isEnglish + ? 'This panel is fetched when the step opens and the tour waits for it.' + : 'Эта панель запрашивается при открытии шага и тур дожидается её.', + panel: isEnglish ? 'Fetched panel' : 'Полученная панель', + row: isEnglish ? 'Row' : 'Строка', + }; +}; + +const delay = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +const meta: Meta = { + tags: ['autodocs'], + component: Tour, + parameters: { + references: ['Tour'], + }, + argTypes: { + steps: { + table: { + disable: true, + }, + }, + open: { + table: { + disable: true, + }, + }, + step: { + table: { + disable: true, + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Demo: Story = { + render: function Render(args, context) { + const text = getText(context); + + const [isOpen, setOpen] = useState(false); + const [isPanelLoaded, setPanelLoaded] = useState(false); + + const renderContent = (title: string, body: string) => { + const render = ({ step, count, loading, next, prev, skip }: TourContentProps) => ( +
+
{loading ? text.loading : `${step + 1} / ${count}`}
+
{title}
+
{body}
+
+ + + + +
+
+ ); + + return render; + }; + + const steps: TourStep[] = [ + { + content: renderContent(text.welcome, text.welcomeText), + }, + { + placement: 'bottom-start', + selector: '#tour-header', + content: renderContent(text.header, text.headerText), + }, + { + placement: 'right', + selector: '#tour-content', + padding: '12px', + radius: '12px', + content: renderContent(text.content, text.contentText), + }, + { + placement: 'top', + selector: '#tour-panel', + content: renderContent(text.late, text.lateText), + before: async () => { + await delay(1000); + setPanelLoaded(true); + }, + after: () => { + setPanelLoaded(false); + }, + }, + ]; + + return ( +
+
+
+ {text.header} +
+ + +
+ +
+
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((row) => ( + {`${text.row} ${row}`} + ))} +
+ + {isPanelLoaded && ( +
+ {text.panel} +
+ )} +
+ + { + setOpen(false); + setPanelLoaded(false); + }} + /> +
+ ); + }, +}; diff --git a/packages/react/src/components/Tour/Tour.tsx b/packages/react/src/components/Tour/Tour.tsx new file mode 100644 index 000000000..0d859becb --- /dev/null +++ b/packages/react/src/components/Tour/Tour.tsx @@ -0,0 +1,373 @@ +'use client'; + +import { CSSProperties, forwardRef, useEffect, useMemo, useRef, useState } from 'react'; + +import { TourContentProps, TourDirection, TourProps } from './Tour.types'; + +import clsx from 'clsx'; + +import { useTourTarget } from './useTourTarget'; +import { resolveSelector, TourRect, waitForTarget } from './utils'; + +import { useControlled, useDocumentEventListener, useEvent, useForkRef } from '../../hooks'; +import { useDefaultProps } from '../../theming'; +import { ownerDocument } from '../../utils'; +import { Popper } from '../Popper'; +import { Portal } from '../Portal'; + +import { flip, limitShift, shift } from '@floating-ui/react-dom'; + +const VIEWPORT_PADDING = 8; + +const middleware = [ + flip({ padding: VIEWPORT_PADDING }), + shift({ + padding: VIEWPORT_PADDING, + crossAxis: true, + limiter: limitShift({ crossAxis: false }), + }), +]; + +interface TourActive { + step: number; + element: Element | null; +} + +const toClientRect = (rect: TourRect) => ({ + x: rect.left, + y: rect.top, + top: rect.top, + left: rect.left, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + width: rect.width, + height: rect.height, +}); + +/** + * The Tour walks the user through the interface, highlighting one element at a time and showing a card next to it. + */ +export const Tour = forwardRef(function Tour(inProps, ref) { + const { + steps, + open, + step: stepProp, + defaultStep = 0, + onStepChange, + onClose, + padding, + radius, + timeout = 5000, + container, + disablePortal = false, + disableEscapeKeyDown = false, + disableInteraction = false, + disableOcclusionTracking = false, + disableScrollIntoView = false, + className, + style, + slots = {}, + slotProps = {}, + ...other + } = useDefaultProps({ + props: inProps, + name: 'ESTour', + }); + + const [step, setStep] = useControlled(defaultStep, stepProp); + const [active, setActive] = useState(null); + const [loading, setLoading] = useState(false); + const [overlay, setOverlay] = useState(null); + + const handleRef = useForkRef(setOverlay, ref); + const cardRef = useRef(null); + const spotlightRef = useRef(null); + const controllerRef = useRef(null); + const pendingRef = useRef(null); + + const count = steps.length; + const rect = useTourTarget({ element: active?.element || null, overlay, disableOcclusionTracking }); + + const run = useEvent(async (target: number, direction: TourDirection) => { + const next = steps[target]; + + if (!next) { + return; + } + + controllerRef.current?.abort(); + + const controller = new AbortController(); + const { signal } = controller; + + controllerRef.current = controller; + pendingRef.current = target; + + const context = { step: target, count, direction }; + const current = active ? steps[active.step] : undefined; + + setLoading(true); + + try { + if (current?.after) { + await current.after(context); + + if (signal.aborted) { + return; + } + } + + if (next.before) { + await next.before(context); + + if (signal.aborted) { + return; + } + } + + let element: Element | null = null; + + if (next.selector) { + element = await waitForTarget(ownerDocument(overlay), next.selector, next.timeout ?? timeout, signal); + + if (signal.aborted) { + return; + } + + if (!disableScrollIntoView) { + element.scrollIntoView({ block: 'center', inline: 'center' }); + } + } + + pendingRef.current = null; + setActive({ step: target, element }); + setLoading(false); + } catch (error) { + if (signal.aborted) { + return; + } + + pendingRef.current = null; + setLoading(false); + + if (process.env.NODE_ENV !== 'production') { + console.error(error); + } + + onClose?.('error'); + } + }); + + useEffect(() => { + if (!open) { + controllerRef.current?.abort(); + controllerRef.current = null; + pendingRef.current = null; + setActive(null); + setLoading(false); + return; + } + + if (active?.step === step || pendingRef.current === step) { + return; + } + + run(step, active ? (step > active.step ? 'next' : 'prev') : 'init'); + }, [open, step, active, run]); + + useEffect(() => { + return () => { + controllerRef.current?.abort(); + }; + }, []); + + const activeStep = active?.step; + const hasActiveSelector = activeStep !== undefined && !!steps[activeStep]?.selector; + + const syncElement = useEvent(() => { + const selector = activeStep === undefined ? undefined : steps[activeStep]?.selector; + + if (!selector) { + return; + } + + const element = resolveSelector(ownerDocument(overlay), selector); + + setActive((previous) => (previous && previous.element !== element ? { ...previous, element } : previous)); + }); + + useEffect(() => { + if (!open || !hasActiveSelector) { + return; + } + + const document = ownerDocument(overlay); + const observer = new MutationObserver(syncElement); + + observer.observe(document.documentElement, { attributes: true, childList: true, subtree: true }); + + return () => { + observer.disconnect(); + }; + }, [open, overlay, activeStep, hasActiveSelector, syncElement]); + + useEffect(() => { + if (activeStep !== undefined) { + cardRef.current?.focus(); + } + }, [activeStep]); + + useDocumentEventListener('keydown', (event) => { + if (open && !disableEscapeKeyDown && event.key === 'Escape') { + event.stopPropagation(); + onClose?.('escapeKeyDown'); + } + }); + + const changeStep = useEvent((target: number, direction: 'next' | 'prev') => { + if (loading) { + return; + } + + setStep(target); + onStepChange?.(target, { direction }); + }); + + const element = active?.element || null; + + const anchorEl = useMemo(() => { + if (!element) { + return null; + } + + if (!rect) { + return element; + } + + return { + getBoundingClientRect: () => spotlightRef.current?.getBoundingClientRect() ?? toClientRect(rect), + contextElement: element, + }; + }, [element, rect]); + + const current = active ? steps[active.step] : undefined; + + if (!open || !active || !current) { + return null; + } + + const contentProps: TourContentProps = { + step: active.step, + count, + loading, + next: () => { + if (loading) { + return; + } + + if (active.step >= count - 1) { + onClose?.('complete'); + } else { + changeStep(active.step + 1, 'next'); + } + }, + prev: () => { + if (active.step > 0) { + changeStep(active.step - 1, 'prev'); + } + }, + skip: () => { + onClose?.('skip'); + }, + }; + + const isTargetPresent = !current.selector || !!element; + + const stepPadding = current.padding ?? padding; + const stepRadius = current.radius ?? radius; + + const rootStyle = { + ...(rect && { + '--es-tour-rect-top': `${rect.top}px`, + '--es-tour-rect-left': `${rect.left}px`, + '--es-tour-rect-width': `${rect.width}px`, + '--es-tour-rect-height': `${rect.height}px`, + + ...(rect.gap.top !== null && { '--es-tour-gap-top': `${rect.gap.top}px` }), + ...(rect.gap.right !== null && { '--es-tour-gap-right': `${rect.gap.right}px` }), + ...(rect.gap.bottom !== null && { '--es-tour-gap-bottom': `${rect.gap.bottom}px` }), + ...(rect.gap.left !== null && { '--es-tour-gap-left': `${rect.gap.left}px` }), + + ...((rect.cut.top || rect.cut.left) && { '--es-tour-radius-top-left': '0px' }), + ...((rect.cut.top || rect.cut.right) && { '--es-tour-radius-top-right': '0px' }), + ...((rect.cut.bottom || rect.cut.right) && { '--es-tour-radius-bottom-right': '0px' }), + ...((rect.cut.bottom || rect.cut.left) && { '--es-tour-radius-bottom-left': '0px' }), + }), + ...(stepPadding && { '--es-tour-padding': stepPadding }), + ...(stepRadius && { '--es-tour-radius': stepRadius }), + ...style, + ...slotProps.root?.style, + } as CSSProperties; + + const Root = slots.root || 'div'; + const Card = slots.card || 'div'; + + const card = ( + + {typeof current.content === 'function' ? current.content(contentProps) : current.content} + + ); + + return ( + + + {!!rect &&
} + {rect && !disableInteraction ? ( + <> +
+
+
+
+ + ) : ( +
+ )} + {isTargetPresent && + (anchorEl ? ( + + {card} + + ) : ( +
{card}
+ ))} + + + ); +}); diff --git a/packages/react/src/components/Tour/Tour.types.ts b/packages/react/src/components/Tour/Tour.types.ts new file mode 100644 index 000000000..c0f77e88e --- /dev/null +++ b/packages/react/src/components/Tour/Tour.types.ts @@ -0,0 +1,196 @@ +import { ElementType, HTMLAttributes, ReactNode } from 'react'; + +import { PopperPlacement, PopperProps } from '../Popper'; +import { PortalProps } from '../Portal'; + +/** The direction a step was reached from. `init` is the step the tour opened on. */ +export type TourDirection = 'init' | 'next' | 'prev'; + +export type TourCloseReason = 'complete' | 'skip' | 'escapeKeyDown' | 'error'; + +/** A CSS selector, or a function resolving the element itself. */ +export type TourSelector = string | (() => Element | null); + +export interface TourStepContext { + /** The index of the step the tour is moving to. */ + step: number; + /** The total number of steps. */ + count: number; + /** The direction the step is reached from. */ + direction: TourDirection; +} + +export interface TourContentProps { + /** The index of the step being rendered. */ + step: number; + /** The total number of steps. */ + count: number; + + /** + * If `true`, the tour is waiting for a `before`, an `after` or for the next element to appear. The step currently + * rendered stays on screen until the next one is ready. + */ + loading: boolean; + + /** Moves to the next step, or closes the tour with the `complete` reason on the last one. */ + next: () => void; + /** Moves to the previous step. Does nothing on the first one. */ + prev: () => void; + /** Closes the tour with the `skip` reason. */ + skip: () => void; +} + +export interface TourStep { + /** + * The placement of the card relative to the highlighted element. + * @default 'bottom' + */ + placement?: PopperPlacement; + /** + * The element to highlight, as a CSS selector or as a function returning it. The tour waits for it to appear before + * showing the step, see `timeout`. + * + * It is resolved again whenever the page changes, so the highlight follows an element that is re-rendered as a new + * node. While nothing matches, the card is hidden and the page is left dimmed; both come back with the element. An + * element that is only covered or scrolled out of sight keeps its card. + * + * When omitted, nothing is highlighted and the card is centered in the viewport. + */ + selector?: TourSelector; + + /** The content of the card. The function form receives the navigation handles. */ + content: ReactNode | ((props: TourContentProps) => ReactNode); + + /** + * The number of milliseconds to wait for `selector` to match. Overrides the `timeout` prop. + * + * When it elapses the tour closes with the `error` reason. + */ + timeout?: number; + /** + * The space between the highlighted element and the cutout, as a CSS length. Overrides the `padding` prop. + * + * Applied through the `--es-tour-padding` custom property, so a `var()` is a valid value. On each edge it shrinks to + * what is free, down to nothing where the element itself is covered, so the cutout never reaches over a sticky + * header or a scroll container next to it. + */ + padding?: string; + /** + * The corner radius of the cutout, as a CSS length. Overrides the `radius` prop. + * + * Applied through the `--es-tour-radius` custom property, so a `var()` is a valid value. Corners next to an edge the + * cutout had to stop short of are squared off, since the element carries on under whatever covers it there. + */ + radius?: string; + + /** + * Runs before the step is shown, e.g. to navigate to another page or to fetch the data the highlighted element + * needs. The tour keeps the previous step on screen with `loading` set until the returned promise settles. + * + * A rejection closes the tour with the `error` reason. + */ + before?: (context: TourStepContext) => void | Promise; + /** + * Runs when the step is left, before the `before` of the step being moved to. + * + * A rejection closes the tour with the `error` reason. + */ + after?: (context: TourStepContext) => void | Promise; +} + +export interface TourProps extends Omit, 'children'> { + /** The steps of the tour. */ + steps: TourStep[]; + + /** If `true`, the tour is shown. */ + open: boolean; + + /** The index of the active step. */ + step?: number; + /** + * The index of the step the tour starts on when it is not controlled. + * @default 0 + */ + defaultStep?: number; + + /** Callback fired when the tour requests another step. */ + onStepChange?: (step: number, context: { direction: TourDirection }) => void; + /** + * Callback fired when the tour requests to be closed. The `reason` parameter can be used to control the response. + * + * @param {string} reason `"complete"`, `"skip"`, `"escapeKeyDown"`, `"error"`. + */ + onClose?: (reason: TourCloseReason) => void; + + /** + * The default number of milliseconds to wait for a step selector to match. Steps may override it. + * @default 5000 + */ + timeout?: number; + /** + * The default space between the highlighted element and the cutout, as a CSS length. Steps may override it. It is + * an upper bound: on each edge it shrinks to what is free. + * + * When omitted the value of the `--es-tour-padding` custom property is used. + */ + padding?: string; + /** + * The default corner radius of the cutout, as a CSS length. Steps may override it. + * + * When omitted the value of the `--es-tour-radius` custom property is used. + */ + radius?: string; + + /** + * An element or a function that returns one. The `container` will have the portal children appended to it. + * Defaults to the body of the top-level document object. + */ + container?: PortalProps['container']; + + /** + * The tour will be under the DOM hierarchy of the parent component. + * @default false + */ + disablePortal?: boolean; + /** + * If `true`, hitting escape will not fire the `onClose` callback. + * @default false + */ + disableEscapeKeyDown?: boolean; + /** + * If `true`, the highlighted element cannot be interacted with either. By default only the rest of the page is + * blocked, so the user can act on the element the step is about. + * @default false + */ + disableInteraction?: boolean; + /** + * If `true`, the cutout always covers the whole bounding box of the highlighted element. By default the tour + * measures the part of it that is actually on screen, so a sticky header or a scroll container overlapping the + * element is not highlighted along with it. + * @default false + */ + disableOcclusionTracking?: boolean; + /** + * If `true`, the highlighted element is not scrolled into view when its step becomes active. + * @default false + */ + disableScrollIntoView?: boolean; + + /** + * The components used for each slot inside. + * @default {} + */ + slots?: { + root?: ElementType; + card?: ElementType; + }; + /** + * The extra props for the slot components. You can override the existing props or add new ones. + * @default {} + */ + slotProps?: { + root?: HTMLAttributes; + popper?: Partial; + card?: HTMLAttributes; + }; +} diff --git a/packages/react/src/components/Tour/index.ts b/packages/react/src/components/Tour/index.ts new file mode 100644 index 000000000..fddb9c365 --- /dev/null +++ b/packages/react/src/components/Tour/index.ts @@ -0,0 +1,10 @@ +export { Tour } from './Tour'; +export type { + TourCloseReason, + TourContentProps, + TourDirection, + TourProps, + TourSelector, + TourStep, + TourStepContext, +} from './Tour.types'; diff --git a/packages/react/src/components/Tour/useTourTarget.ts b/packages/react/src/components/Tour/useTourTarget.ts new file mode 100644 index 000000000..58d609fd4 --- /dev/null +++ b/packages/react/src/components/Tour/useTourTarget.ts @@ -0,0 +1,68 @@ +'use client'; + +import { useState } from 'react'; + +import { getClippedRect, getVisibleRect, isSameRect, TourRect } from './utils'; + +import { useEnhancedEffect } from '../../hooks'; + +import { autoUpdate } from '@floating-ui/react-dom'; + +export interface UseTourTargetProps { + /** The highlighted element, or `null` when the step has no selector. */ + element: Element | null; + /** The root of the tour. Its subtree is ignored while measuring, and it is watched for resizes. */ + overlay: HTMLElement | null; + /** If `true`, the whole bounding box is returned instead of the part of it that is on screen. */ + disableOcclusionTracking?: boolean; +} + +/** + * Tracks the rectangle the cutout should cover, in viewport coordinates, keeping it in sync with scrolling, resizes + * and layout shifts. Returns `null` while the element is off screen or fully covered. + */ +export const useTourTarget = ({ element, overlay, disableOcclusionTracking }: UseTourTargetProps): TourRect | null => { + const [rect, setRect] = useState(null); + + useEnhancedEffect(() => { + if (!element || !overlay) { + setRect(null); + return; + } + + let frame = 0; + let isFirstUpdate = true; + + const measure = () => { + const next = disableOcclusionTracking ? getClippedRect(element) : getVisibleRect(element, overlay); + + setRect((previous) => (isSameRect(previous, next) ? previous : next)); + }; + + const update = () => { + const window = element.ownerDocument.defaultView; + + if (!window) { + return; + } + + if (isFirstUpdate) { + isFirstUpdate = false; + measure(); + return; + } + + window.cancelAnimationFrame(frame); + frame = window.requestAnimationFrame(measure); + }; + + const cleanup = autoUpdate(element, overlay, update); + + return () => { + element.ownerDocument.defaultView?.cancelAnimationFrame(frame); + cleanup(); + }; + }, [element, overlay, disableOcclusionTracking]); + + return rect; +}; diff --git a/packages/react/src/components/Tour/utils/getClippedRect.ts b/packages/react/src/components/Tour/utils/getClippedRect.ts new file mode 100644 index 000000000..a0ff7d56d --- /dev/null +++ b/packages/react/src/components/Tour/utils/getClippedRect.ts @@ -0,0 +1,44 @@ +import { TourRect } from './types'; + +/** + * The bounding box of `element`, clipped to the viewport. Returns `null` when nothing of it is on screen. + * + * Edges the viewport cut report no free space, since padding there would only be drawn off screen anyway. + */ +export const getClippedRect = (element: Element): TourRect | null => { + const window = element.ownerDocument.defaultView; + + if (!window) { + return null; + } + + const rect = element.getBoundingClientRect(); + + const top = Math.max(rect.top, 0); + const left = Math.max(rect.left, 0); + const bottom = Math.min(rect.bottom, window.innerHeight); + const right = Math.min(rect.right, window.innerWidth); + + if (bottom <= top || right <= left) { + return null; + } + + return { + top, + left, + width: right - left, + height: bottom - top, + gap: { + top: rect.top < top ? 0 : null, + right: rect.right > right ? 0 : null, + bottom: rect.bottom > bottom ? 0 : null, + left: rect.left < left ? 0 : null, + }, + cut: { + top: rect.top < top, + right: rect.right > right, + bottom: rect.bottom > bottom, + left: rect.left < left, + }, + }; +}; diff --git a/packages/react/src/components/Tour/utils/getVisibleRect.ts b/packages/react/src/components/Tour/utils/getVisibleRect.ts new file mode 100644 index 000000000..58814f168 --- /dev/null +++ b/packages/react/src/components/Tour/utils/getVisibleRect.ts @@ -0,0 +1,184 @@ +import { getClippedRect } from './getClippedRect'; +import { TourRect } from './types'; + +/** The number of bisections used to locate an edge. Combined with the half-pixel bail out below it is never reached. */ +const EDGE_PROBE_STEPS = 12; + +/** The accuracy an edge is located with, in pixels. */ +const EDGE_PROBE_ACCURACY = 0.5; + +/** + * How far beyond an edge to look for something the padding must not be drawn over, in pixels. The padding itself is a + * CSS length the component never resolves, so the free space is reported instead and CSS takes the smaller of the two. + * An obstruction further out than this is not reported, and a padding that large would not be clamped by it. + */ +const GAP_PROBE_LIMIT = 64; + +/** + * Where to look for a point of the element that is on screen, as fractions of its box. Bisecting an edge needs one to + * bisect towards, and the middle is not it when a header covers the top half. The order walks outwards from the middle + * so that the common case costs a single hit test and a thin visible sliver along an edge is still found. + */ +const SEED_FRACTIONS = [0.5, 0.25, 0.75, 0.125, 0.375, 0.625, 0.875, 0.0625, 0.9375]; + +/** + * The part of `element` that is actually painted on screen, in viewport coordinates, together with the padding each of + * its edges has room for. + * + * The bounding box is not enough: the element may be scrolled under a container with a hidden overflow, or covered by + * a sticky header, which is not an ancestor and therefore invisible to a purely geometric computation. Each edge of + * the clipped box is instead bisected towards the center until the topmost element at that point is the target, which + * accounts for clipping and for overlapping elements alike. + * + * The cutout is drawn a padding beyond each edge, so the band outside it is measured as well, this time bisecting + * outwards until something that is neither the target nor one of its ancestors is met. The free space found is what + * the padding may grow to; an edge whose own pixels are covered reports none at all. + * + * Nodes inside `ignore` — the tour overlay itself — are skipped, so the card never counts as an obstruction. + * + * Returns `null` when no sampled point of the element turns out to be on screen, which is taken to mean it is covered. + */ +export const getVisibleRect = (element: Element, ignore: Element | null): TourRect | null => { + const document = element.ownerDocument; + const rect = getClippedRect(element); + + if (!rect) { + return null; + } + + const getTopmost = (x: number, y: number) => { + for (const hit of document.elementsFromPoint(x, y)) { + if (!ignore?.contains(hit)) { + return hit; + } + } + + return null; + }; + + const isTarget = (x: number, y: number) => { + const hit = getTopmost(x, y); + + return !!hit && (hit === element || element.contains(hit)); + }; + + // Outside the element the background is whatever the target sits in, so its ancestors do not obstruct the padding. + const isFree = (x: number, y: number) => { + const hit = getTopmost(x, y); + + return !hit || hit === element || element.contains(hit) || hit.contains(element); + }; + + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + const findSeed = () => { + for (const fraction of SEED_FRACTIONS) { + const y = rect.top + rect.height * fraction; + + if (isTarget(centerX, y)) { + return { x: centerX, y }; + } + } + + for (const fraction of SEED_FRACTIONS) { + const x = rect.left + rect.width * fraction; + + if (isTarget(x, centerY)) { + return { x, y: centerY }; + } + } + + return null; + }; + + const seed = findSeed(); + + if (!seed) { + return null; + } + + // `from` is the point known to fail the test and `to` the one known to pass it. Returns the coordinate of the first + // passing point met while moving from the former towards the latter. + const probe = (isPassing: (value: number) => boolean, from: number, to: number) => { + let failing = from; + let passing = to; + + for (let i = 0; i < EDGE_PROBE_STEPS && Math.abs(passing - failing) > EDGE_PROBE_ACCURACY; i++) { + const middle = (failing + passing) / 2; + + if (isPassing(middle)) { + passing = middle; + } else { + failing = middle; + } + } + + return passing; + }; + + const probeEdge = ( + isPassing: (value: number) => boolean, + edge: number, + center: number, + clippedGap: number | null + ) => { + const outwards = Math.sign(edge - center); + const inset = edge - outwards * EDGE_PROBE_ACCURACY; + + // Where the element itself is covered the cutout stops at the covering boundary, and gets no padding: extending it + // would put the cutout straight back over whatever was found. + if (!isPassing(inset)) { + return { value: probe(isPassing, inset, center), gap: 0, isCut: true }; + } + + if (clippedGap === 0) { + return { value: edge, gap: 0, isCut: true }; + } + + const limit = edge + outwards * GAP_PROBE_LIMIT; + + if (isPassing(limit)) { + return { value: edge, gap: null, isCut: false }; + } + + return { value: edge, gap: Math.abs(probe(isPassing, limit, edge) - edge), isCut: false }; + }; + + const isTargetY = (y: number) => isTarget(seed.x, y); + const isTargetX = (x: number) => isTarget(x, seed.y); + const isFreeY = (y: number) => isFree(seed.x, y); + const isFreeX = (x: number) => isFree(x, seed.y); + + const top = probeEdge((y) => (y < rect.top ? isFreeY(y) : isTargetY(y)), rect.top, seed.y, rect.gap.top); + const left = probeEdge((x) => (x < rect.left ? isFreeX(x) : isTargetX(x)), rect.left, seed.x, rect.gap.left); + + const bottomEdge = rect.top + rect.height; + const rightEdge = rect.left + rect.width; + + const bottom = probeEdge((y) => (y > bottomEdge ? isFreeY(y) : isTargetY(y)), bottomEdge, seed.y, rect.gap.bottom); + const right = probeEdge((x) => (x > rightEdge ? isFreeX(x) : isTargetX(x)), rightEdge, seed.x, rect.gap.right); + + if (bottom.value <= top.value || right.value <= left.value) { + return null; + } + + return { + top: top.value, + left: left.value, + width: right.value - left.value, + height: bottom.value - top.value, + gap: { + top: top.gap, + right: right.gap, + bottom: bottom.gap, + left: left.gap, + }, + cut: { + top: top.isCut, + right: right.isCut, + bottom: bottom.isCut, + left: left.isCut, + }, + }; +}; diff --git a/packages/react/src/components/Tour/utils/index.ts b/packages/react/src/components/Tour/utils/index.ts new file mode 100644 index 000000000..ab2b86453 --- /dev/null +++ b/packages/react/src/components/Tour/utils/index.ts @@ -0,0 +1,6 @@ +export { getClippedRect } from './getClippedRect'; +export { getVisibleRect } from './getVisibleRect'; +export { isSameRect } from './isSameRect'; +export { resolveSelector } from './resolveSelector'; +export type { TourEdges, TourGap, TourRect } from './types'; +export { waitForTarget } from './waitForTarget'; diff --git a/packages/react/src/components/Tour/utils/isSameRect.ts b/packages/react/src/components/Tour/utils/isSameRect.ts new file mode 100644 index 000000000..1a10df4e0 --- /dev/null +++ b/packages/react/src/components/Tour/utils/isSameRect.ts @@ -0,0 +1,22 @@ +import { TourRect } from './types'; + +export const isSameRect = (a: TourRect | null, b: TourRect | null): boolean => { + if (!a || !b) { + return a === b; + } + + return ( + a.top === b.top && + a.left === b.left && + a.width === b.width && + a.height === b.height && + a.gap.top === b.gap.top && + a.gap.right === b.gap.right && + a.gap.bottom === b.gap.bottom && + a.gap.left === b.gap.left && + a.cut.top === b.cut.top && + a.cut.right === b.cut.right && + a.cut.bottom === b.cut.bottom && + a.cut.left === b.cut.left + ); +}; diff --git a/packages/react/src/components/Tour/utils/resolveSelector.ts b/packages/react/src/components/Tour/utils/resolveSelector.ts new file mode 100644 index 000000000..2cf6191b7 --- /dev/null +++ b/packages/react/src/components/Tour/utils/resolveSelector.ts @@ -0,0 +1,5 @@ +import { TourSelector } from '../Tour.types'; + +export const resolveSelector = (document: Document, selector: TourSelector): Element | null => { + return typeof selector === 'function' ? selector() : document.querySelector(selector); +}; diff --git a/packages/react/src/components/Tour/utils/types.ts b/packages/react/src/components/Tour/utils/types.ts new file mode 100644 index 000000000..b82a14888 --- /dev/null +++ b/packages/react/src/components/Tour/utils/types.ts @@ -0,0 +1,27 @@ +/** + * The free space found beyond each edge, in pixels, which is how much padding fits there. `null` means nothing was met + * within the probing limit, so the padding is not constrained at all. + */ +export interface TourGap { + top: number | null; + right: number | null; + bottom: number | null; + left: number | null; +} + +/** The edges where the cutout stops inside the element, because something covers or clips it there. */ +export interface TourEdges { + top: boolean; + right: boolean; + bottom: boolean; + left: boolean; +} + +export interface TourRect { + top: number; + left: number; + width: number; + height: number; + gap: TourGap; + cut: TourEdges; +} diff --git a/packages/react/src/components/Tour/utils/waitForTarget.ts b/packages/react/src/components/Tour/utils/waitForTarget.ts new file mode 100644 index 000000000..53d599011 --- /dev/null +++ b/packages/react/src/components/Tour/utils/waitForTarget.ts @@ -0,0 +1,78 @@ +import { TourSelector } from '../Tour.types'; + +import { resolveSelector } from './resolveSelector'; + +/** + * Resolves with the element matching `selector`, waiting for it to be added to the document if it is not there yet. + * + * Rejects with an `AbortError` when `signal` is aborted, and with an `Error` when `timeout` elapses first. + */ +export const waitForTarget = ( + document: Document, + selector: TourSelector, + timeout: number, + signal: AbortSignal +): Promise => { + return new Promise((resolve, reject) => { + const initial = resolveSelector(document, selector); + + if (initial) { + resolve(initial); + return; + } + + const abortError = () => new DOMException('The tour was closed while waiting for an element.', 'AbortError'); + + if (signal.aborted) { + reject(abortError()); + return; + } + + let timer = 0; + let observer: MutationObserver | null = null; + let isSettled = false; + + const settle = () => { + isSettled = true; + observer?.disconnect(); + document.defaultView?.clearTimeout(timer); + }; + + signal.addEventListener( + 'abort', + () => { + if (!isSettled) { + settle(); + reject(abortError()); + } + }, + { once: true } + ); + + observer = new MutationObserver(() => { + const element = resolveSelector(document, selector); + + if (element) { + settle(); + resolve(element); + } + }); + + // Attributes are observed as well: the element may already be mounted and only become matchable once a class or a + // data attribute lands on it. + observer.observe(document.documentElement, { attributes: true, childList: true, subtree: true }); + + timer = + document.defaultView?.setTimeout(() => { + settle(); + + reject( + new Error( + `ESTour: no element matched ${ + typeof selector === 'function' ? 'the selector function' : `\`${selector}\`` + } within ${timeout}ms.` + ) + ); + }, timeout) ?? 0; + }); +}; diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index ae0b7877c..abf036fee 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -95,4 +95,5 @@ export * from './TextFieldGroup'; export * from './Tooltip'; export * from './TooltipEllipsis'; export * from './TouchRipple'; +export * from './Tour'; export * from './Zoom'; diff --git a/packages/react/src/overrides.ts b/packages/react/src/overrides.ts index c2e6cfe46..c38a37754 100644 --- a/packages/react/src/overrides.ts +++ b/packages/react/src/overrides.ts @@ -174,6 +174,7 @@ import { TextFieldProps } from './components/TextField'; import { TextFieldGroupProps } from './components/TextFieldGroup'; import { TooltipProps } from './components/Tooltip'; import { TouchRippleProps } from './components/TouchRipple'; +import { TourProps } from './components/Tour'; import { ZoomProps } from './components/Zoom'; import { AvatarProps } from './components'; @@ -363,6 +364,7 @@ declare module './theming/DefaultPropsProvider/DefaultPropsProvider.types' { ESTextField: TextFieldProps; ESTextFieldGroup: TextFieldGroupProps; ESTooltip: TooltipProps; + ESTour: TourProps; ESZoom: ZoomProps; } } diff --git a/packages/theme/lib/components/_index.scss b/packages/theme/lib/components/_index.scss index 87b12b0b7..784731912 100644 --- a/packages/theme/lib/components/_index.scss +++ b/packages/theme/lib/components/_index.scss @@ -78,6 +78,7 @@ @use './text-field-group'; @use './tooltip'; @use './touch-ripple'; +@use './tour'; @mixin include() { @include alert.include; @@ -160,4 +161,5 @@ @include text-field-group.include; @include tooltip.include; @include touch-ripple.include; + @include tour.include; } diff --git a/packages/theme/lib/components/_tour.scss b/packages/theme/lib/components/_tour.scss new file mode 100644 index 000000000..ae39d4c25 --- /dev/null +++ b/packages/theme/lib/components/_tour.scss @@ -0,0 +1,116 @@ +@mixin include() { + .es-tour { + --es-tour-rect-top: 0px; + --es-tour-rect-left: 0px; + --es-tour-rect-width: 0px; + --es-tour-rect-height: 0px; + --es-tour-padding: 8px; + --es-tour-gap-top: 9999px; + --es-tour-gap-right: 9999px; + --es-tour-gap-bottom: 9999px; + --es-tour-gap-left: 9999px; + --es-tour-padding-top: min(var(--es-tour-padding), var(--es-tour-gap-top)); + --es-tour-padding-right: min(var(--es-tour-padding), var(--es-tour-gap-right)); + --es-tour-padding-bottom: min(var(--es-tour-padding), var(--es-tour-gap-bottom)); + --es-tour-padding-left: min(var(--es-tour-padding), var(--es-tour-gap-left)); + --es-tour-radius: 4px; + --es-tour-radius-top-left: var(--es-tour-radius); + --es-tour-radius-top-right: var(--es-tour-radius); + --es-tour-radius-bottom-right: var(--es-tour-radius); + --es-tour-radius-bottom-left: var(--es-tour-radius); + --es-tour-distance: 8px; + + inset: 0; + pointer-events: none; + position: fixed; + z-index: 1400; + + &__spotlight { + border-radius: var(--es-tour-radius-top-left) var(--es-tour-radius-top-right) var(--es-tour-radius-bottom-right) + var(--es-tour-radius-bottom-left); + box-shadow: 0 0 0 9999px var(--es-overlay-300); + height: calc(var(--es-tour-rect-height) + var(--es-tour-padding-top) + var(--es-tour-padding-bottom)); + left: calc(var(--es-tour-rect-left) - var(--es-tour-padding-left)); + pointer-events: none; + position: absolute; + top: calc(var(--es-tour-rect-top) - var(--es-tour-padding-top)); + width: calc(var(--es-tour-rect-width) + var(--es-tour-padding-left) + var(--es-tour-padding-right)); + } + + &__blocker { + pointer-events: auto; + position: absolute; + + &--full { + inset: 0; + + .es-tour--dimmed & { + background-color: var(--es-overlay-300); + } + } + + &--top { + height: calc(var(--es-tour-rect-top) - var(--es-tour-padding-top)); + left: 0; + right: 0; + top: 0; + } + + &--bottom { + inset: calc(var(--es-tour-rect-top) + var(--es-tour-rect-height) + var(--es-tour-padding-bottom)) 0 0; + } + + &--left { + height: calc(var(--es-tour-rect-height) + var(--es-tour-padding-top) + var(--es-tour-padding-bottom)); + left: 0; + top: calc(var(--es-tour-rect-top) - var(--es-tour-padding-top)); + width: calc(var(--es-tour-rect-left) - var(--es-tour-padding-left)); + } + + &--right { + height: calc(var(--es-tour-rect-height) + var(--es-tour-padding-top) + var(--es-tour-padding-bottom)); + left: calc(var(--es-tour-rect-left) + var(--es-tour-rect-width) + var(--es-tour-padding-right)); + right: 0; + top: calc(var(--es-tour-rect-top) - var(--es-tour-padding-top)); + } + } + + &__popper { + z-index: 1; + + &[data-es-placement*='bottom'] { + padding-top: var(--es-tour-distance); + } + + &[data-es-placement*='top'] { + padding-bottom: var(--es-tour-distance); + } + + &[data-es-placement*='right'] { + padding-left: var(--es-tour-distance); + } + + &[data-es-placement*='left'] { + padding-right: var(--es-tour-distance); + } + } + + &__center { + align-items: center; + display: flex; + inset: 0; + justify-content: center; + pointer-events: none; + position: absolute; + } + + &__card { + background-color: var(--es-surface-400); + border-radius: 6px; + box-shadow: var(--es-shadow-down-600); + color: var(--es-mono-a-a900); + outline: 0; + pointer-events: auto; + } + } +}