diff --git a/site/site.config.mjs b/site/site.config.mjs index 69640b5a8d..3bca02d10c 100644 --- a/site/site.config.mjs +++ b/site/site.config.mjs @@ -452,6 +452,12 @@ export default { path: '/react/components/drawer', component: () => import('tdesign-react/drawer/drawer.md'), }, + { + title: 'Guide 引导', + name: 'guide', + path: '/react/components/guide', + component: () => import('tdesign-react/guide/guide.md'), + }, { title: 'Message 全局提醒', name: 'message', diff --git a/src/_util/dom.ts b/src/_util/dom.ts index cd8a210e57..a8b846b44b 100644 --- a/src/_util/dom.ts +++ b/src/_util/dom.ts @@ -208,3 +208,88 @@ export const getCssVarsValue = (name: string, element?: HTMLElement) => { const el = element || document.documentElement; return getComputedStyle(el).getPropertyValue(name); }; + +/** + * 检查元素是否在父元素视图 + * http://stackoverflow.com/questions/123999/how-to-tell-if-a-dom-element-is-visible-in-the-current-viewport + * @param elm 元素 + * @param parent + * @returns boolean + */ +export function elementInViewport(elm: HTMLElement, parent?: HTMLElement): boolean { + const rect = elm.getBoundingClientRect(); + if (parent) { + const parentRect = parent.getBoundingClientRect(); + return ( + rect.top >= parentRect.top && + rect.left >= parentRect.left && + rect.bottom <= parentRect.bottom && + rect.right <= parentRect.right + ); + } + return rect.top >= 0 && rect.left >= 0 && rect.bottom + 80 <= window.innerHeight && rect.right <= window.innerWidth; +} + +/** + * 获取元素某个 css 对应的值 + * @param element 元素 + * @param propName css 名 + * @returns string + */ +export function getElmCssPropValue(element: HTMLElement, propName: string): string { + let propValue = ''; + + if (document.defaultView && document.defaultView.getComputedStyle) { + propValue = document.defaultView.getComputedStyle(element, null).getPropertyValue(propName); + } + + if (propValue && propValue.toLowerCase) { + return propValue.toLowerCase(); + } + + return propValue; +} + +/** + * 判断元素是否处在 position fixed 中 + * @param element 元素 + * @returns boolean + */ +export function isFixed(element: HTMLElement): boolean { + const p = element.parentNode as HTMLElement; + + if (!p || p.nodeName === 'HTML') { + return false; + } + + if (getElmCssPropValue(element, 'position') === 'fixed') { + return true; + } + + return isFixed(p); +} + +/** + * 获取当前视图滑动的距离 + * @returns { scrollTop: number, scrollLeft: number } + */ +export function getWindowScroll(): { scrollTop: number; scrollLeft: number } { + const { body } = document; + const docElm = document.documentElement; + const scrollTop = window.pageYOffset || docElm.scrollTop || body.scrollTop; + const scrollLeft = window.pageXOffset || docElm.scrollLeft || body.scrollLeft; + + return { scrollTop, scrollLeft }; +} + +/** + * 获取当前视图的大小 + * @returns { width: number, height: number } + */ +export function getWindowSize(): { width: number; height: number } { + if (window.innerWidth !== undefined) { + return { width: window.innerWidth, height: window.innerHeight }; + } + const doc = document.documentElement; + return { width: doc.clientWidth, height: doc.clientHeight }; +} diff --git a/src/guide/Guide.tsx b/src/guide/Guide.tsx new file mode 100644 index 0000000000..7f1aaf74b9 --- /dev/null +++ b/src/guide/Guide.tsx @@ -0,0 +1,412 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import cx from 'classnames'; +import { createPortal } from 'react-dom'; +import Button from '../button'; +import useConfig from '../hooks/useConfig'; +import Popup from '../popup'; +import { GuideCrossProps, StepPopupPlacement, TdGuideProps } from './type'; +import { addClass, removeClass, isFixed, getWindowScroll } from '../_util/dom'; +import { scrollToParentVisibleArea, getRelativePosition, getTargetElm, scrollToElm } from './utils'; +import setStyle from '../_common/js/utils/set-style'; +import useControlled from '../hooks/useControlled'; +import { guideDefaultProps } from './defaultProps'; + +export type GuideProps = TdGuideProps; + +const Guide = (props: GuideProps) => { + const { counter, hideCounter, hidePrev, hideSkip, steps, zIndex } = props; + + const { classPrefix } = useConfig(); + const prefixCls = `${classPrefix}-guide`; + const lockCls = `${prefixCls}--lock`; + + const [innerCurrent, setInnerCurrent] = useControlled(props, 'current', props.onChange); + + // 覆盖层,用于覆盖所有元素 + const overlayLayerRef = useRef(null); + // 高亮层,用于高亮元素 + const highlightLayerRef = useRef(null); + // 提示层,用于高亮元素 + const referenceLayerRef = useRef(null); + // 当前高亮的元素 + const currentHighlightLayerElm = useRef(null); + // 下一个高亮的元素 + const nextHighlightLayerElm = useRef(null); + // dialog wrapper ref + const dialogWrapperRef = useRef(null); + // dialog ref + const dialogTooltipRef = useRef(null); + // 是否开始展示 + const [actived, setActive] = useState(false); + // 步骤总数 + const stepsTotal = steps.length; + // 当前步骤的信息 + const currentStepInfo = useMemo(() => steps[innerCurrent], [steps, innerCurrent]); + // 获取当前步骤的所有属性 用户当前步骤设置 > 用户全局设置的 > 默认值 + const getCurrentCrossProps = (propsName: Key) => + currentStepInfo?.[propsName] ?? props[propsName]; + // 当前是否为 popup + const isPopup = getCurrentCrossProps('mode') === 'popup'; + // 当前元素位置状态 + const currentElmIsFixed = isFixed(currentHighlightLayerElm.current || document.body); + + // 设置高亮层的位置 + const setHighlightLayerPosition = (highlighLayer: HTMLElement) => { + let { top, left } = getRelativePosition(nextHighlightLayerElm.current, currentHighlightLayerElm.current); + let { width, height } = nextHighlightLayerElm.current.getBoundingClientRect(); + const highlightPadding = getCurrentCrossProps('highlightPadding'); + + if (isPopup) { + width += highlightPadding * 2; + height += highlightPadding * 2; + top -= highlightPadding; + left -= highlightPadding; + } else { + const { scrollTop, scrollLeft } = getWindowScroll(); + top += scrollTop; + left += scrollLeft; + } + + setStyle(highlighLayer, { + width: `${width}px`, + height: `${height}px`, + top: `${top}px`, + left: `${left}px`, + }); + }; + + const showPopupGuide = () => { + const currentElement = getTargetElm(currentStepInfo.element); + nextHighlightLayerElm.current = currentElement; + currentHighlightLayerElm.current = currentElement; + + setTimeout(() => { + scrollToParentVisibleArea(nextHighlightLayerElm.current); + setHighlightLayerPosition(highlightLayerRef.current); + setHighlightLayerPosition(referenceLayerRef.current); + scrollToElm(nextHighlightLayerElm.current); + }); + }; + + const destroyTooltipElm = () => { + referenceLayerRef.current?.parentNode.removeChild(referenceLayerRef.current); + }; + + const showDialogGuide = () => { + setTimeout(() => { + const currentElement = dialogTooltipRef.current; + nextHighlightLayerElm.current = currentElement; + currentHighlightLayerElm.current = currentElement; + scrollToParentVisibleArea(nextHighlightLayerElm.current); + setHighlightLayerPosition(highlightLayerRef.current); + scrollToElm(nextHighlightLayerElm.current); + }); + }; + + const destroyDialogTooltipElm = () => { + dialogTooltipRef.current?.parentNode.removeChild(dialogTooltipRef.current); + dialogWrapperRef.current?.parentNode.removeChild(dialogWrapperRef.current); + }; + + const showGuide = () => { + if (isPopup) { + destroyDialogTooltipElm(); + showPopupGuide(); + } else { + destroyTooltipElm(); + showDialogGuide(); + } + }; + + const destroyGuide = () => { + destroyTooltipElm(); + destroyDialogTooltipElm(); + highlightLayerRef.current?.parentNode.removeChild(highlightLayerRef.current); + overlayLayerRef.current?.parentNode.removeChild(overlayLayerRef.current); + removeClass(document.body, lockCls); + }; + + const handleSkip = (e) => { + const total = stepsTotal; + setActive(false); + setInnerCurrent(-1, { e, total }); + props.onSkip?.({ e, current: -1, total }); + }; + + const handlePrev = (e) => { + const total = stepsTotal; + setInnerCurrent(innerCurrent - 1, { e, total }); + props.onPrevStepClick?.({ + e, + prev: innerCurrent - 1, + current: innerCurrent, + total, + }); + }; + + const handleNext = (e) => { + const total = stepsTotal; + setInnerCurrent(innerCurrent + 1, { e, total }); + props.onNextStepClick?.({ + e, + next: innerCurrent + 1, + current: innerCurrent, + total, + }); + }; + + const handleFinish = (e) => { + const total = stepsTotal; + setActive(false); + setInnerCurrent(-1, { e, total }); + props.onFinish?.({ e, current: -1, total }); + }; + + const initGuide = () => { + if (innerCurrent >= 0 && innerCurrent < steps.length) { + if (!actived) { + setActive(true); + + addClass(document.body, lockCls); + } + showGuide(); + } + }; + + useEffect(() => { + if (innerCurrent >= 0 && innerCurrent < steps.length) { + initGuide(); + } else { + setActive(false); + destroyGuide(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [innerCurrent]); + + useEffect(() => { + initGuide(); + + return destroyGuide; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const renderOverlayLayer = () => + createPortal( +
, + document.body, + ); + + const renderHighlightLayer = () => { + const style = { zIndex: zIndex - 1 }; + const highlightClass = [ + `${prefixCls}__highlight`, + `${prefixCls}__highlight--${isPopup ? 'popup' : 'dialog'}`, + `${prefixCls}--${currentElmIsFixed && isPopup ? 'fixed' : 'absolute'}`, + ]; + const showOverlay = getCurrentCrossProps('showOverlay'); + const maskClass = [`${prefixCls}__highlight--${showOverlay ? 'mask' : 'nomask'}`]; + const { highlightContent } = currentStepInfo; + const showHighlightContent = highlightContent && isPopup; + + return createPortal( +
+ {showHighlightContent && + React.cloneElement(highlightContent as any, { + className: cx(highlightClass.concat(maskClass)), + style, + })} +
, + document.body, + ); + }; + + const renderCounter = () => { + let popupSlotCounter; + + if (React.isValidElement(counter)) { + popupSlotCounter = React.cloneElement(counter, { total: stepsTotal, current: innerCurrent } as any); + } + + const popupDefaultCounter = ( +
+ {popupSlotCounter || ( + + {innerCurrent + 1}/{stepsTotal} + + )} +
+ ); + return <>{!hideCounter && popupDefaultCounter}; + }; + + const renderAction = (mode: TdGuideProps['mode']) => { + const isLast = innerCurrent === stepsTotal - 1; + const isFirst = innerCurrent === 0; + const buttonSize = mode === 'popup' ? 'small' : 'medium'; + + return ( +
+ {!hideSkip && !isLast && ( +
+ ); + }; + + const renderTooltipBody = () => { + const title =
{currentStepInfo.title}
; + const { body: descBody } = currentStepInfo; + + const desc =
{descBody}
; + + return ( + <> + {title} + {desc} + + ); + }; + + const renderPopupContent = () => { + const footerClasses = [`${prefixCls}__footer`, `${prefixCls}__footer--popup`]; + const action = ( +
+ {renderCounter()} + {renderAction('popup')} +
+ ); + + return ( +
+ {renderTooltipBody()} + {action} +
+ ); + }; + + const renderPopupGuide = () => { + const { content } = currentStepInfo; + let renderBody; + if (React.isValidElement(content)) { + const contentProps = { + handlePrev, + handleNext, + handleSkip, + handleFinish, + current: innerCurrent, + total: stepsTotal, + }; + renderBody = React.cloneElement(content as any, contentProps); + } else { + renderBody = renderPopupContent(); + } + + const classes = [`${prefixCls}__reference`, `${prefixCls}--${currentElmIsFixed ? 'fixed' : 'absolute'}`]; + + return createPortal( + +
+ , + document.body, + ); + }; + + const renderDialogGuide = () => { + const style = { zIndex }; + const wrapperClasses = [ + `${prefixCls}__wrapper`, + { [`${prefixCls}__wrapper--center`]: currentStepInfo.placement === 'center' }, + ]; + const dialogClasses = [ + `${prefixCls}__reference`, + `${prefixCls}--absolute`, + `${prefixCls}__dialog`, + { + [`${prefixCls}__dialog--nomask`]: !getCurrentCrossProps('showOverlay'), + [currentStepInfo.stepOverlayClass]: !!currentStepInfo.stepOverlayClass, + }, + ]; + const footerClasses = [`${prefixCls}__footer`, `${prefixCls}__footer--popup`]; + return ( + <> + {createPortal( +
+
+ {renderTooltipBody()} +
+ {renderCounter()} + {renderAction('dialog')} +
+
+
, + document.body, + )} + + ); + }; + + const renderGuide = () => ( + <> + {renderOverlayLayer()} + {renderHighlightLayer()} + {isPopup ? renderPopupGuide() : renderDialogGuide()} + + ); + + return <>{actived && renderGuide()}; +}; + +Guide.displayName = 'Guide'; + +Guide.defaultProps = guideDefaultProps; + +export default Guide; diff --git a/src/guide/_example/base.jsx b/src/guide/_example/base.jsx new file mode 100644 index 0000000000..c04b7047df --- /dev/null +++ b/src/guide/_example/base.jsx @@ -0,0 +1,154 @@ +import React, { useEffect } from 'react'; +import { Button, Drawer, Guide, Input, Row } from 'tdesign-react'; + +const classStyles = ` + +`; + +export default function BasicGuide() { + useEffect(() => { + // 添加示例代码所需样式 + document.head.insertAdjacentHTML('beforeend', classStyles); + }, []); + + const steps = [ + { + element: '.main-title-base', + title: '新手引导标题', + body: '新手引导的说明文案', + placement: 'bottom-right', + }, + { + element: '.label-field-base', + title: '新手引导标题', + body: '新手引导的说明文案', + placement: 'bottom', + }, + { + element: '.action-base', + title: '新手引导标题', + body: '新手引导的说明文案', + placement: 'right', + }, + ]; + + const [visible, setVisible] = React.useState(false); + const [current, setCurrent] = React.useState(-1); + + const handleClick = () => { + setVisible(true); + setTimeout(() => { + setCurrent(0); + }, 800); + }; + + const handleChange = (current, { e, total }) => { + setCurrent(current); + console.log(current, e, total); + }; + + const handlePrevStepClick = ({ e, prev, current, total }) => { + console.log(e, prev, current, total); + }; + + const handleNextStepClick = ({ e, next, current, total }) => { + console.log(e, next, current, total); + }; + + const handleFinish = ({ e, current, total }) => { + setVisible(false); + console.log(e, current, total); + }; + + const handleSkip = ({ e, current, total }) => { + console.log('skip'); + setVisible(false); + console.log(e, current, total); + }; + + return ( + + + setVisible(false)}> 关闭抽屉 } + visible={visible} + header="演示新手引导" + size="60%" + showOverlay={false} + destroyOnClose={true} + > +
+
+
Guide 用户引导
+
按钮用于开启一个闭环的操作任务,如“删除”对象、“购买”商品等。
+
+
+
Label
+ +
+
+
Label
+ +
+ + + + +
+ + +
+
+ ); +} diff --git a/src/guide/_example/custom-popup.jsx b/src/guide/_example/custom-popup.jsx new file mode 100644 index 0000000000..b5384b6123 --- /dev/null +++ b/src/guide/_example/custom-popup.jsx @@ -0,0 +1,224 @@ +import React, { useEffect } from 'react'; +import { ArrowUpIcon } from 'tdesign-icons-react'; +import { Button, Drawer, Guide, Input, Row } from 'tdesign-react'; + +const classStyles = ` + +`; + +function MyPopup(props) { + useEffect(() => { + // 添加示例代码所需样式 + document.head.insertAdjacentHTML('beforeend', classStyles); + }, []); + + const { handlePrev, handleNext, handleSkip, handleFinish, current, total } = props; + + return ( +
+ +

自定义的图形或说明文案,用来解释或指导该功能使用。

+
+ + {current !== 0 && ( + + )} + {current + 1 < total && ( + + )} + {current + 1 === total && ( + + )} +
+
+ ); +} + +export default function CustomPopupGuide() { + useEffect(() => { + // 添加示例代码所需样式 + document.head.insertAdjacentHTML('beforeend', classStyles); + }, []); + + const [visible, setVisible] = React.useState(false); + const [current, setCurrent] = React.useState(-1); + + const handleClick = () => { + setVisible(true); + setTimeout(() => { + setCurrent(0); + }, 800); + }; + + const handleChange = (current, { e, total }) => { + setCurrent(current); + console.log(current, e, total); + }; + + const handlePrevStepClick = ({ e, prev, current, total }) => { + console.log(e, prev, current, total); + }; + + const handleNextStepClick = ({ e, next, current, total }) => { + console.log(e, next, current, total); + }; + + const handleFinish = ({ e, current, total }) => { + setVisible(false); + console.log(e, current, total); + }; + + const handleSkip = ({ e, current, total }) => { + console.log('skip'); + setVisible(false); + console.log(e, current, total); + }; + + const steps = [ + { + element: '.main-title-custom-popup', + title: '新手引导标题', + description: '新手引导的说明文案', + placement: 'bottom-right', + content: , + }, + { + element: '.label-field-1-custom-popup', + title: '新手引导标题', + description: '新手引导的说明文案', + placement: 'bottom', + content: , + }, + { + element: '.label-field-2-custom-popup', + title: '新手引导标题', + description: '新手引导的说明文案', + placement: 'bottom-left', + content: , + }, + ]; + + return ( + + + setVisible(false)}> 关闭抽屉 } + visible={visible} + header="演示新手引导" + size="60%" + showOverlay={false} + destroyOnClose={true} + > +
+
+
Guide 用户引导
+
按钮用于开启一个闭环的操作任务,如“删除”对象、“购买”商品等。
+
+
+
Label
+ +
+
+
Label
+ +
+ + + + +
+ + +
+
+ ); +} diff --git a/src/guide/_example/dialog.jsx b/src/guide/_example/dialog.jsx new file mode 100644 index 0000000000..63df9d8912 --- /dev/null +++ b/src/guide/_example/dialog.jsx @@ -0,0 +1,205 @@ +import React, { useEffect } from 'react'; +import { Button, Drawer, Guide, Input, Row } from 'tdesign-react'; + +const classStyles = ` + +`; + +function DialogBody() { + useEffect(() => { + // 添加示例代码所需样式 + document.head.insertAdjacentHTML('beforeend', classStyles); + }, []); + + return ( +
+
+ demo +
+

此处显示本页引导的说明文案,可按需要撰写,如内容过多可折行显示。图文也可按需自由设计。

+
+ ); +} + +export default function DialogGuide() { + useEffect(() => { + // 添加示例代码所需样式 + document.head.insertAdjacentHTML('beforeend', classStyles); + }, []); + + const steps = [ + { + element: '.main-title-dialog', + title: '新手引导标题', + body: DialogBody(), + placement: 'bottom-right', + }, + { + element: '.label-field-dialog', + title: '新手引导标题', + body: DialogBody(), + placement: 'bottom', + }, + { + element: '.action-dialog', + title: '新手引导标题', + body: DialogBody(), + placement: 'right', + }, + ]; + + const [visible, setVisible] = React.useState(false); + const [current, setCurrent] = React.useState(-1); + + const handleClick = () => { + setVisible(true); + setTimeout(() => { + setCurrent(0); + }, 800); + }; + + const handleChange = (current, { e, total }) => { + setCurrent(current); + console.log(current, e, total); + }; + + const handlePrevStepClick = ({ e, prev, current, total }) => { + console.log(e, prev, current, total); + }; + + const handleNextStepClick = ({ e, next, current, total }) => { + console.log(e, next, current, total); + }; + + const handleFinish = ({ e, current, total }) => { + setVisible(false); + console.log(e, current, total); + }; + + const handleSkip = ({ e, current, total }) => { + console.log('skip'); + setVisible(false); + console.log(e, current, total); + }; + + return ( + + + setVisible(false)}> 关闭抽屉 } + visible={visible} + header="演示新手引导" + size="60%" + showOverlay={false} + destroyOnClose={true} + > +
+
+
Guide 用户引导
+
按钮用于开启一个闭环的操作任务,如“删除”对象、“购买”商品等。
+
+
+
Label
+ +
+
+
Label
+ +
+ + + + +
+ + +
+
+ ); +} diff --git a/src/guide/_example/no-mask.jsx b/src/guide/_example/no-mask.jsx new file mode 100644 index 0000000000..bdca014272 --- /dev/null +++ b/src/guide/_example/no-mask.jsx @@ -0,0 +1,156 @@ +import React, { useEffect } from 'react'; +import { Button, Drawer, Guide, Input, Row } from 'tdesign-react'; + +const classStyles = ` + +`; + +export default function NoMaskGuide() { + useEffect(() => { + // 添加示例代码所需样式 + document.head.insertAdjacentHTML('beforeend', classStyles); + }, []); + + const steps = [ + { + element: '.main-title-no-mask', + title: '新手引导标题', + body: '新手引导的说明文案', + placement: 'bottom-right', + }, + { + element: '.label-field-no-mask', + title: '新手引导标题', + body: '新手引导的说明文案', + placement: 'bottom', + }, + { + element: '.action-no-mask', + title: '新手引导标题', + body: '新手引导的说明文案', + placement: 'right', + }, + ]; + + const [visible, setVisible] = React.useState(false); + const [current, setCurrent] = React.useState(-1); + + const handleClick = () => { + setVisible(true); + setTimeout(() => { + setCurrent(0); + }, 800); + }; + + const handleChange = (current, { e, total }) => { + setCurrent(current); + console.log(current, e, total); + }; + + const handlePrevStepClick = ({ e, prev, current, total }) => { + console.log(e, prev, current, total); + }; + + const handleNextStepClick = ({ e, next, current, total }) => { + console.log(e, next, current, total); + }; + + const handleFinish = ({ e, current, total }) => { + setVisible(false); + console.log(e, current, total); + }; + + const handleSkip = ({ e, current, total }) => { + console.log('skip'); + setVisible(false); + console.log(e, current, total); + }; + + return ( + + + setVisible(false)}> 关闭抽屉 } + visible={visible} + header="演示新手引导" + size="60%" + showOverlay={false} + destroyOnClose={true} + > +
+
+
Guide 用户引导
+
按钮用于开启一个闭环的操作任务,如“删除”对象、“购买”商品等。
+
+
+
Label
+ +
+
+
Label
+ +
+ + + + +
+ + +
+
+ ); +} diff --git a/src/guide/_example/popup-dialog.jsx b/src/guide/_example/popup-dialog.jsx new file mode 100644 index 0000000000..baf7837716 --- /dev/null +++ b/src/guide/_example/popup-dialog.jsx @@ -0,0 +1,190 @@ +import React, { useEffect } from 'react'; +import { Button, Drawer, Guide, Input, Row } from 'tdesign-react'; + +const classStyles = ` + +`; + +function DialogBody() { + useEffect(() => { + // 添加示例代码所需样式 + document.head.insertAdjacentHTML('beforeend', classStyles); + }, []); + + return ( +
+
+ demo +
+

此处显示本页引导的说明文案,可按需要撰写,如内容过多可折行显示。图文也可按需自由设计。

+
+ ); +} + +export default function PopupDialogGuide() { + useEffect(() => { + // 添加示例代码所需样式 + document.head.insertAdjacentHTML('beforeend', classStyles); + }, []); + + const steps = [ + { + element: '.main-title-popup-dialog', + title: '新手引导标题', + body: '新手引导的说明文案', + placement: 'bottom-right', + }, + { + element: '.label-field-popup-dialog', + title: '新手引导标题', + body: DialogBody(), + placement: 'bottom', + mode: 'dialog', + }, + { + element: '.action-popup-dialog', + title: '新手引导标题', + body: '新手引导的说明文案', + placement: 'right', + }, + ]; + + const [visible, setVisible] = React.useState(false); + const [current, setCurrent] = React.useState(-1); + + const handleClick = () => { + setVisible(true); + setTimeout(() => { + setCurrent(0); + }, 800); + }; + + const handleChange = (current, { e, total }) => { + setCurrent(current); + console.log(current, e, total); + }; + + const handlePrevStepClick = ({ e, prev, current, total }) => { + console.log(e, prev, current, total); + }; + + const handleNextStepClick = ({ e, next, current, total }) => { + console.log(e, next, current, total); + }; + + const handleFinish = ({ e, current, total }) => { + setVisible(false); + console.log(e, current, total); + }; + + const handleSkip = ({ e, current, total }) => { + console.log('skip'); + setVisible(false); + console.log(e, current, total); + }; + + return ( + + + setVisible(false)}> 关闭抽屉 } + visible={visible} + header="演示新手引导" + size="60%" + showOverlay={false} + destroyOnClose={true} + > +
+
+
Guide 用户引导
+
按钮用于开启一个闭环的操作任务,如“删除”对象、“购买”商品等。
+
+
+
Label
+ +
+
+
Label
+ +
+ + + + +
+ + +
+
+ ); +} diff --git a/src/guide/defaultProps.ts b/src/guide/defaultProps.ts new file mode 100644 index 0000000000..fe840e4b1f --- /dev/null +++ b/src/guide/defaultProps.ts @@ -0,0 +1,16 @@ +/** + * 该文件为脚本自动生成文件,请勿随意修改。如需修改请联系 PMC + * */ + +import { TdGuideProps } from './type'; + +export const guideDefaultProps: TdGuideProps = { + finishButtonProps: { content: '完成', theme: 'primary' }, + highlightPadding: 8, + mode: 'popup', + nextButtonProps: { content: '下一步', theme: 'primary' }, + prevButtonProps: { content: '上一步', theme: 'default' }, + showOverlay: true, + skipButtonProps: { content: '跳过', theme: 'default' }, + zIndex: 999999, +}; diff --git a/src/guide/index.ts b/src/guide/index.ts new file mode 100644 index 0000000000..341b0a6dae --- /dev/null +++ b/src/guide/index.ts @@ -0,0 +1,9 @@ +import _Guide from './Guide'; + +import './style/index.js'; + +export type { GuideProps } from './Guide'; +export * from './type'; + +export const Guide = _Guide; +export default Guide; diff --git a/src/guide/style/css.js b/src/guide/style/css.js new file mode 100644 index 0000000000..6a9a4b1328 --- /dev/null +++ b/src/guide/style/css.js @@ -0,0 +1 @@ +import './index.css'; diff --git a/src/guide/style/index.js b/src/guide/style/index.js new file mode 100644 index 0000000000..6ff0f4f84c --- /dev/null +++ b/src/guide/style/index.js @@ -0,0 +1 @@ +import '../../_common/style/web/components/guide/_index.less'; diff --git a/src/guide/type.ts b/src/guide/type.ts new file mode 100644 index 0000000000..2569d034da --- /dev/null +++ b/src/guide/type.ts @@ -0,0 +1,197 @@ +/* eslint-disable */ + +/** + * 该文件为脚本自动生成文件,请勿随意修改。如需修改请联系 PMC + * */ + +import { ButtonProps } from '../button'; +import { TNode, AttachNode } from '../common'; + +export interface TdGuideProps { + /** + * 用于自定义渲染计数部分 + */ + counter?: TNode; + /** + * null + * @default 当前步骤,即整个引导的进度。-1 则不展示,用于需要中断展示的场景 + */ + current?: number; + /** + * null,非受控属性 + * @default 当前步骤,即整个引导的进度。-1 则不展示,用于需要中断展示的场景 + */ + defaultCurrent?: number; + /** + * null + * @default 当前步骤,即整个引导的进度。-1 则不展示,用于需要中断展示的场景 + */ + modelValue?: number; + /** + * 透传 完成 的全部属性 + * @default { content: '完成', theme: 'primary' } + */ + finishButtonProps?: ButtonProps; + /** + * 是否隐藏计数 + * @default false + */ + hideCounter?: boolean; + /** + * 是否隐藏上一步按钮 + * @default false + */ + hidePrev?: boolean; + /** + * 是否隐藏跳过按钮 + * @default false + */ + hideSkip?: boolean; + /** + * 高亮框的内边距 + * @default 8 + */ + highlightPadding?: number; + /** + * 引导框的类型 + * @default popup + */ + mode?: 'popup' | 'dialog'; + /** + * 透传 下一步按钮 的全部属性 + * @default { content: '下一步', theme: 'primary' } + */ + nextButtonProps?: ButtonProps; + /** + * 透传 上一步按钮 的全部属性 + * @default { content: '上一步', theme: 'primary' } + */ + prevButtonProps?: ButtonProps; + /** + * 是否出现遮罩层 + * @default true + */ + showOverlay?: boolean; + /** + * 透传 跳过按钮 的全部属性 + * @default { content: '跳过', theme: 'default' } + */ + skipButtonProps?: ButtonProps; + /** + * 用于定义每个步骤的内容,包括高亮的节点、相对位置和具体的文案内容等。 + */ + steps?: Array; + /** + * 提示框的层级 + * @default 999999 + */ + zIndex?: number; + /** + * 当前步骤发生变化时触发 + */ + onChange?: (current: number, context?: { e: MouseEvent; total: number }) => void; + /** + * 点击完成按钮时触发 + */ + onFinish?: (context: { e: MouseEvent; current: number; total: number }) => void; + /** + * 点击下一步时触发 + */ + onNextStepClick?: (context: { e: MouseEvent; next: number; current: number; total: number }) => void; + /** + * 点击上一步时触发 + */ + onPrevStepClick?: (context: { e: MouseEvent; prev: number; current: number; total: number }) => void; + /** + * 点击跳过按钮时触发 + */ + onSkip?: (context: { e: MouseEvent; current: number; total: number }) => void; +} + +export interface TdGuideStepProps { + /** + * 当前步骤提示框的内容 + */ + body?: string | TNode; + /** + * 自定义内容,同 content + */ + children?: string | TNode; + /** + * 用户自定义引导弹框的内容,一旦存在,此时除 `placement`、`offset`和`element` 外,其它属性全部失效) + */ + content?: TNode; + /** + * 高亮的节点。数据类型为 String 时,会被当作选择器处理,进行节点查询。示例:'#tdesign' 或 () => document.querySelector('#tdesign') + */ + element: AttachNode; + /** + * 用户自定义的高亮框 (仅当 `mode` 为 `popup` 时生效) + */ + highlightContent?: TNode; + /** + * 高亮框的内边距 + */ + highlightPadding?: number; + /** + * 引导框的类型 + */ + mode?: 'popup' | 'dialog'; + /** + * 用于自定义当前引导框的下一步按钮的内容 + */ + nextButtonProps?: ButtonProps; + /** + * 相对于 placement 的偏移量,示例:[-10, 20] 或 ['10px', '8px'] + */ + offset?: Array; + /** + * 引导框相对于高亮元素出现的位置 + * @default 'top' + */ + placement?: StepPopupPlacement | StepDialogPlacement; + /** + * 用于自定义当前引导框的上一步按钮的内容 + */ + prevButtonProps?: ButtonProps; + /** + * 是否出现遮罩层 + * @default true + */ + showOverlay?: boolean; + /** + * 用于自定义当前步骤引导框的跳过按钮的内容 + */ + skipButtonProps?: ButtonProps; + /** + * 覆盖引导框的类名 + * @default '' + */ + stepOverlayClass?: string; + /** + * 当前步骤的标题内容 + * @default '' + */ + title?: string; +} + +export type StepPopupPlacement = + | 'top' + | 'left' + | 'right' + | 'bottom' + | 'top-left' + | 'top-right' + | 'bottom-left' + | 'bottom-right' + | 'left-top' + | 'left-bottom' + | 'right-top' + | 'right-bottom'; + +export type StepDialogPlacement = 'top' | 'center'; + +export type GuideCrossProps = Pick< + TdGuideStepProps, + 'mode' | 'skipButtonProps' | 'prevButtonProps' | 'nextButtonProps' | 'showOverlay' | 'highlightPadding' +>; diff --git a/src/guide/utils/getRelativePosition.ts b/src/guide/utils/getRelativePosition.ts new file mode 100644 index 0000000000..89603219f6 --- /dev/null +++ b/src/guide/utils/getRelativePosition.ts @@ -0,0 +1,34 @@ +import { getElmCssPropValue, isFixed, getWindowScroll } from '../../_util/dom'; + +/** + * 获取元素相对于另一个元素的位置(或者说相对于 body) + * 感谢 `meouw`: http://stackoverflow.com/a/442474/375966 + */ +export default function getRelativePosition(elm: HTMLElement, relativeElm: HTMLElement = document.body) { + const { scrollTop, scrollLeft } = getWindowScroll(); + const { top: elmTop, left: elmLeft } = elm.getBoundingClientRect(); + const { top: relElmTop, left: relElmLeft } = relativeElm.getBoundingClientRect(); + const relativeElmPosition = getElmCssPropValue(relativeElm, 'position'); + + if ( + (relativeElm.tagName.toLowerCase() !== 'body' && relativeElmPosition === 'relative') || + relativeElmPosition === 'sticky' + ) { + return { + top: elmTop - relElmTop, + left: elmLeft - relElmLeft, + }; + } + + if (isFixed(elm)) { + return { + top: elmTop, + left: elmLeft, + }; + } + + return { + top: elmTop + scrollTop, + left: elmLeft + scrollLeft, + }; +} diff --git a/src/guide/utils/getScrollParent.ts b/src/guide/utils/getScrollParent.ts new file mode 100644 index 0000000000..0fb577f4af --- /dev/null +++ b/src/guide/utils/getScrollParent.ts @@ -0,0 +1,28 @@ +import { elementInViewport } from '../../_util/dom'; + +export function getScrollParent(element: HTMLElement) { + let style = getComputedStyle(element); + const excludeStaticParent = style.position === 'absolute'; + const overflowRegex = /(auto|scroll)/; + + if (style.position === 'fixed') return document.body; + + for (let parent = element; parent.parentElement; ) { + parent = parent.parentElement; + style = getComputedStyle(parent); + if (excludeStaticParent && style.position === 'static') { + continue; + } + if (overflowRegex.test(style.overflow + style.overflowY + style.overflowX)) return parent; + } + + return document.body; +} + +export function scrollToParentVisibleArea(element: HTMLElement) { + const parent = getScrollParent(element); + if (parent === document.body) return; + // !todo 逻辑待验证 + if (elementInViewport(element, parent)) return; + parent.scrollTop = element.offsetTop - parent.offsetTop; +} diff --git a/src/guide/utils/getTargetElm.ts b/src/guide/utils/getTargetElm.ts new file mode 100644 index 0000000000..cd7379892a --- /dev/null +++ b/src/guide/utils/getTargetElm.ts @@ -0,0 +1,20 @@ +import { AttachNode } from '../../common'; + +export default function getTargetElm(elm: AttachNode): HTMLElement { + if (elm) { + let targetElement: HTMLElement = null; + if (typeof elm === 'string') { + targetElement = document.querySelector(elm); + } else if (typeof elm === 'function') { + targetElement = elm() as HTMLElement; + } else { + throw new Error('elm should be string or function'); + } + if (targetElement) { + return targetElement as HTMLElement; + } + throw new Error('There is no element with given.'); + } else { + return document.body; + } +} diff --git a/src/guide/utils/index.ts b/src/guide/utils/index.ts new file mode 100644 index 0000000000..f79a227893 --- /dev/null +++ b/src/guide/utils/index.ts @@ -0,0 +1,6 @@ +import { scrollToParentVisibleArea } from './getScrollParent'; +import getRelativePosition from './getRelativePosition'; +import getTargetElm from './getTargetElm'; +import scrollToElm from './scrollToElm'; + +export { scrollToParentVisibleArea, getRelativePosition, getTargetElm, scrollToElm }; diff --git a/src/guide/utils/scrollToElm.ts b/src/guide/utils/scrollToElm.ts new file mode 100644 index 0000000000..170389ed3a --- /dev/null +++ b/src/guide/utils/scrollToElm.ts @@ -0,0 +1,18 @@ +import { getWindowSize, elementInViewport, scrollTo } from '../../_util/dom'; + +export default function scrollToElm(elm: HTMLElement) { + const rect = elm.getBoundingClientRect(); + + if (!elementInViewport(elm)) { + const winHeight = getWindowSize().height; + // const top = rect.bottom - (rect.bottom - rect.top); + scrollTo(rect.top - (winHeight / 2 - rect.height / 2), {}); + + // todo 先暂时保留这里的逻辑 + // if (top < 0 || element.clientHeight > winHeight) { + // window.scrollBy(0, rect.top - (winHeight / 2 - rect.height / 2)); + // } else { + // window.scrollBy(0, rect.top - (winHeight / 2 - rect.height / 2)); + // } + } +} diff --git a/src/index.ts b/src/index.ts index baa3eea5ac..92901e41fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,3 +61,4 @@ export * from './timeline'; export * from './image'; export * from './rate'; export * from './link'; +export * from './guide'; diff --git a/test/snap/__snapshots__/csr.test.jsx.snap b/test/snap/__snapshots__/csr.test.jsx.snap index 51f6c5cd7c..59b4590d44 100644 --- a/test/snap/__snapshots__/csr.test.jsx.snap +++ b/test/snap/__snapshots__/csr.test.jsx.snap @@ -96269,6 +96269,1911 @@ exports[`csr snapshot test > csr test src/grid/_example/valign.jsx 1`] = ` } `; +exports[`csr snapshot test > csr test src/guide/_example/base.jsx 1`] = ` +{ + "asFragment": [Function], + "baseElement": +