From 730dd1553ac0812d00e1a6d6d6dcef2cc95b03f0 Mon Sep 17 00:00:00 2001 From: Tobias Date: Tue, 6 Sep 2022 14:54:02 +0200 Subject: [PATCH 1/3] chore(TypeScript): enhancements --- packages/dnb-eufemia/.eslintrc | 1 + packages/dnb-eufemia/src/shared/interfaces.tsx | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/dnb-eufemia/.eslintrc b/packages/dnb-eufemia/.eslintrc index 662bd51500f..cbf2262049c 100644 --- a/packages/dnb-eufemia/.eslintrc +++ b/packages/dnb-eufemia/.eslintrc @@ -132,6 +132,7 @@ "error", { "args": "none", "ignoreRestSiblings": true } ], + "react/prop-types": "off", "react/require-default-props": "off" } } diff --git a/packages/dnb-eufemia/src/shared/interfaces.tsx b/packages/dnb-eufemia/src/shared/interfaces.tsx index afd1d239657..3a377f325bb 100644 --- a/packages/dnb-eufemia/src/shared/interfaces.tsx +++ b/packages/dnb-eufemia/src/shared/interfaces.tsx @@ -1,8 +1,8 @@ -export interface ISpacingProps extends ISpacingElementProps { +export type ISpacingProps = ISpacingElementProps & { space?: SpaceTypes | ISpacingElementProps } -export interface ISpacingElementProps { +export type ISpacingElementProps = { top?: SpaceTypes right?: SpaceTypes bottom?: SpaceTypes From 05ac37306399f46de0f56e3b81acb95710e1fb59 Mon Sep 17 00:00:00 2001 From: Tobias Date: Tue, 6 Sep 2022 14:54:33 +0200 Subject: [PATCH 2/3] fix(Tooltip): rewrite to functional components with React Hooks --- .../__snapshots__/Dialog.test.tsx.snap | 4 +- .../__snapshots__/Drawer.test.tsx.snap | 4 +- .../__snapshots__/Modal.test.tsx.snap | 4 +- .../src/components/tooltip/Tooltip.tsx | 199 ++++---- .../components/tooltip/TooltipContainer.tsx | 476 ++++++++---------- .../src/components/tooltip/TooltipHelpers.tsx | 9 + .../src/components/tooltip/TooltipPortal.tsx | 234 ++++----- .../components/tooltip/TooltipWithEvents.tsx | 251 +++++---- .../src/components/tooltip/types.ts | 21 +- packages/dnb-eufemia/src/shared/Context.tsx | 2 + .../src/shared/component-helper.js | 18 - 11 files changed, 546 insertions(+), 676 deletions(-) diff --git a/packages/dnb-eufemia/src/components/dialog/__tests__/__snapshots__/Dialog.test.tsx.snap b/packages/dnb-eufemia/src/components/dialog/__tests__/__snapshots__/Dialog.test.tsx.snap index ab383d9dde4..714b1726e06 100644 --- a/packages/dnb-eufemia/src/components/dialog/__tests__/__snapshots__/Dialog.test.tsx.snap +++ b/packages/dnb-eufemia/src/components/dialog/__tests__/__snapshots__/Dialog.test.tsx.snap @@ -484,7 +484,7 @@ exports[`Dialog component snapshot should match component snapshot 1`] = ` group="dialog_id-tooltip" hide_delay={500} id="dialog_id-tooltip" - internal_id="dialog_id-tooltip" + internalId="dialog_id-tooltip" no_animation={false} position="top" show_delay={300} @@ -592,7 +592,7 @@ exports[`Dialog component snapshot should match component snapshot 1`] = ` group="dialog_id-tooltip" hide_delay={500} id="dialog_id-tooltip" - internal_id="dialog_id-tooltip" + internalId="dialog_id-tooltip" key="tooltip" no_animation={false} position="top" diff --git a/packages/dnb-eufemia/src/components/drawer/__tests__/__snapshots__/Drawer.test.tsx.snap b/packages/dnb-eufemia/src/components/drawer/__tests__/__snapshots__/Drawer.test.tsx.snap index e45d9d0b50c..b328e88012d 100644 --- a/packages/dnb-eufemia/src/components/drawer/__tests__/__snapshots__/Drawer.test.tsx.snap +++ b/packages/dnb-eufemia/src/components/drawer/__tests__/__snapshots__/Drawer.test.tsx.snap @@ -482,7 +482,7 @@ exports[`Drawer component snapshot should match component snapshot 1`] = ` group="drawer_id-tooltip" hide_delay={500} id="drawer_id-tooltip" - internal_id="drawer_id-tooltip" + internalId="drawer_id-tooltip" no_animation={false} position="top" show_delay={300} @@ -590,7 +590,7 @@ exports[`Drawer component snapshot should match component snapshot 1`] = ` group="drawer_id-tooltip" hide_delay={500} id="drawer_id-tooltip" - internal_id="drawer_id-tooltip" + internalId="drawer_id-tooltip" key="tooltip" no_animation={false} position="top" diff --git a/packages/dnb-eufemia/src/components/modal/__tests__/__snapshots__/Modal.test.tsx.snap b/packages/dnb-eufemia/src/components/modal/__tests__/__snapshots__/Modal.test.tsx.snap index 55eef6b680b..351c1132a0c 100644 --- a/packages/dnb-eufemia/src/components/modal/__tests__/__snapshots__/Modal.test.tsx.snap +++ b/packages/dnb-eufemia/src/components/modal/__tests__/__snapshots__/Modal.test.tsx.snap @@ -473,7 +473,7 @@ exports[`Modal component have to match snapshot 1`] = ` group="modal_id-tooltip" hide_delay={500} id="modal_id-tooltip" - internal_id="modal_id-tooltip" + internalId="modal_id-tooltip" no_animation={false} position="top" show_delay={300} @@ -581,7 +581,7 @@ exports[`Modal component have to match snapshot 1`] = ` group="modal_id-tooltip" hide_delay={500} id="modal_id-tooltip" - internal_id="modal_id-tooltip" + internalId="modal_id-tooltip" key="tooltip" no_animation={false} position="top" diff --git a/packages/dnb-eufemia/src/components/tooltip/Tooltip.tsx b/packages/dnb-eufemia/src/components/tooltip/Tooltip.tsx index 200bbcf93bc..4af046f1c2e 100644 --- a/packages/dnb-eufemia/src/components/tooltip/Tooltip.tsx +++ b/packages/dnb-eufemia/src/components/tooltip/Tooltip.tsx @@ -8,140 +8,113 @@ import classnames from 'classnames' import Context from '../../shared/Context' import { makeUniqueId, - registerElement, validateDOMAttributes, - processChildren, isTrue, } from '../../shared/component-helper' import { createSpacingClasses } from '../space/SpacingHelper' import TooltipContainer from './TooltipContainer' import TooltipWithEvents from './TooltipWithEvents' import TooltipPortal from './TooltipPortal' -import { defaultProps, injectTooltipSemantic } from './TooltipHelpers' +import { + defaultProps, + getPropsFromTooltipProp, + injectTooltipSemantic, +} from './TooltipHelpers' import { ISpacingProps } from '../../shared/interfaces' import { TooltipProps } from './types' import { includeValidProps } from '../form-row/FormRowHelpers' -export { injectTooltipSemantic } - -export default class Tooltip extends React.PureComponent< - TooltipProps & ISpacingProps -> { - _id: string +function Tooltip(localProps: TooltipProps & ISpacingProps) { + const context = React.useContext(Context) - static tagName = 'dnb-tooltip' - static contextType = Context + const inherited = getPropsFromTooltipProp(localProps) - static enableWebComponent() { - registerElement(Tooltip?.tagName, Tooltip, defaultProps) + // use only the props from context, who are available here anyway + const props = { + ...defaultProps, + ...localProps, + ...inherited, + ...context.getTranslation(localProps).Tooltip, + ...includeValidProps(context.FormRow), + ...context.Tooltip, } - static getContent(props) { - return processChildren(props) + const { + target_element, + target_selector, + className, + id, // eslint-disable-line + tooltip, // eslint-disable-line + group, + size, + animate_position, // eslint-disable-line + fixed_position, // eslint-disable-line + skip_portal, + no_animation, // eslint-disable-line + show_delay, // eslint-disable-line + hide_delay, // eslint-disable-line + active, // eslint-disable-line + position, // eslint-disable-line + arrow, // eslint-disable-line + align, // eslint-disable-line + ...params + } = props + + const [internalId] = React.useState(() => props.id || makeUniqueId()) // cause we need an id anyway + props.internalId = internalId + props.group = group || localProps.id || 'main-' + props.internalId + + const classes = classnames( + 'dnb-tooltip', + size === 'large' && 'dnb-tooltip--large', + createSpacingClasses(props), + className + ) + + const attributes = { + className: classes, + ...params, } - getPropsFromTooltipProp() { - return this.props.tooltip - ? React.isValidElement(this.props.tooltip) && - this.props.tooltip.props - ? this.props.tooltip.props - : { children: this.props.tooltip } - : null - } + // also used for code markup simulation + validateDOMAttributes(localProps, attributes) - constructor(props) { - super(props) - this._id = props.id || makeUniqueId() // cause we need an id anyway + if (!isTrue(props.active)) { + delete props.active } - render() { - const inherited = this.getPropsFromTooltipProp() - - // use only the props from context, who are available here anyway - const props = { - ...defaultProps, - ...this.props, - ...inherited, - ...this.context.getTranslation(this.props).Tooltip, - ...includeValidProps(this.context.FormRow), - ...this.context.Tooltip, - } - - const { - target_element, - target_selector, - className, - id, // eslint-disable-line - tooltip, // eslint-disable-line - group, - size, - animate_position, // eslint-disable-line - fixed_position, // eslint-disable-line - skip_portal, - no_animation, // eslint-disable-line - show_delay, // eslint-disable-line - hide_delay, // eslint-disable-line - active, // eslint-disable-line - position, // eslint-disable-line - arrow, // eslint-disable-line - align, // eslint-disable-line - ...params - } = props - - props.internal_id = this._id - props.group = this.props.id || group || 'main-' + this._id - - const content = Tooltip.getContent(props) - - const classes = classnames( - 'dnb-tooltip', - size === 'large' && 'dnb-tooltip--large', - createSpacingClasses(props), - className - ) - - const attributes = { - className: classes, - ...params, - } - - // also used for code markup simulation - validateDOMAttributes(this.props, attributes) - - if (!isTrue(props.active)) { - delete props.active - } - - return ( - <> - {skip_portal ? ( - + {skip_portal ? ( + + {props.children} + + ) : target_element ? ( + + {props.children} + + ) : ( + target_selector && ( + - {content} - - ) : target_element ? ( - - {content} - - ) : ( - target_selector && ( - - {content} - - ) - )} - - ) - } + {props.children} + + ) + )} + + ) } + +export { injectTooltipSemantic } +export default Tooltip diff --git a/packages/dnb-eufemia/src/components/tooltip/TooltipContainer.tsx b/packages/dnb-eufemia/src/components/tooltip/TooltipContainer.tsx index d6b5f2a2664..667c239a76e 100644 --- a/packages/dnb-eufemia/src/components/tooltip/TooltipContainer.tsx +++ b/packages/dnb-eufemia/src/components/tooltip/TooltipContainer.tsx @@ -14,324 +14,240 @@ type TooltipContainerProps = { clientX?: number style?: React.CSSProperties useHover?: boolean - internal_id?: string + internalId?: string attributes?: Record } -type TooltipContainerState = { - width: number - height: number - hover: boolean - hide: boolean - bodyWidth?: number - bodyHeight?: number - leaveInDOM?: boolean -} - -export default class TooltipContainer extends React.PureComponent< - TooltipProps & TooltipContainerProps, - TooltipContainerState -> { - _rootRef = React.createRef() - offset = 16 - state: TooltipContainerState = { - hide: null, - hover: null, - width: 0, - height: 0, - } +export default function TooltipContainer( + props: TooltipProps & TooltipContainerProps +) { + const { + internalId, + active, + attributes, + arrow, + position, + animate_position, + fixed_position, + no_animation, + useHover, + children, + } = props + + const [hover, setHover] = React.useState(false) + const [wasActive, makeActive] = React.useState(false) + const [renewStyles, forceRerender] = React.useState(0) + + const elementRef = React.useRef(null) + const offset = React.useRef(16) + const debounceTimeout = React.useRef() + const resizeObserver = React.useRef(null) + + const isActive = isTrue(active) || hover + + React.useEffect(() => { + const addPositionObserver = () => { + if (resizeObserver.current || typeof document === 'undefined') { + return // stop here + } - _ddt: NodeJS.Timeout - resizeObserver: ResizeObserver - _style: React.CSSProperties + try { + resizeObserver.current = new ResizeObserver((entries) => { + const run = () => { + const { width, height } = entries[0].contentRect + forceRerender(width + height) + } + if (!renewStyles) { + run() + } + clearTimeout(debounceTimeout.current) + debounceTimeout.current = setTimeout(run, 100) + }) - static getDerivedStateFromProps( - props: TooltipProps & TooltipContainerProps, - state: TooltipContainerState - ) { - if (state.leaveInDOM && !props.active && !state.hover) { - state.hide = true + resizeObserver.current.observe(document.body) + } catch (e) { + // + } } - if (props.active || state.hover) { - state.leaveInDOM = true - state.hide = false + const removePositionObserver = () => { + clearTimeout(debounceTimeout.current) + resizeObserver.current?.disconnect() } - return state - } - componentDidMount() { - if (isTrue(this.props.active)) { - this.updateSize() + if (isActive) { + makeActive(true) + addPositionObserver() + } else { + removePositionObserver() } - this.addPositionObserver() - } + return removePositionObserver - componentDidUpdate(prevProps) { - if (this.props !== prevProps) { - this.updateSize() - } - } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isActive]) - componentWillUnmount() { - this.removePositionObserver() - } - - addPositionObserver() { - if (this.resizeObserver || typeof document === 'undefined') { - return // stop here - } - - try { - this.resizeObserver = new ResizeObserver((entries) => { - // debounce - clearTimeout(this._ddt) - this._ddt = setTimeout(() => { - // force re-render - this.setState({ - bodyWidth: entries[0].contentRect.width, - bodyHeight: entries[0].contentRect.height, - }) - }, 30) - }) - - this.resizeObserver.observe(document.body) - } catch (e) { - // + const style = React.useMemo(() => { + if (typeof window === 'undefined' || !elementRef.current) { + return {} } - } - removePositionObserver() { - clearTimeout(this._ddt) - if (this.resizeObserver) { - this.resizeObserver.disconnect() - this.resizeObserver = null - } - } + const elementWidth = elementRef.current.offsetWidth + const elementHeight = elementRef.current.offsetHeight - getGlobalStyle() { - return this.makeStyle(this.props.position, this.props.arrow) - } - - makeStyle(position, arrow) { - if (typeof window === 'undefined') { - return {} - } let alignOffset = 0 - try { - const { - targetElement: target, - align, - fixed_position, - clientX, - } = this.props + const { targetElement: target, align, fixed_position } = props - const rect = target.getBoundingClientRect() + const rect = target.getBoundingClientRect() - const targetSize = { - width: target.offsetWidth, - height: target.offsetHeight, - } + const targetSize = { + width: target.offsetWidth, + height: target.offsetHeight, + } - // fix for svg - if (!target.offsetHeight) { - targetSize.width = rect.width - targetSize.height = rect.height - } + // fix for svg + if (!target.offsetHeight) { + targetSize.width = rect.width + targetSize.height = rect.height + } - const scrollY = - window.scrollY !== undefined ? window.scrollY : window.pageYOffset - const scrollX = - window.scrollX !== undefined ? window.scrollX : window.pageXOffset - const top = (isTrue(fixed_position) ? 0 : scrollY) + rect.top - - // Use Mouse position when target is too wide - const useMouseWhen = targetSize.width > 400 - const mousePos = - clientX - - getOffsetLeft(target) + - rect.left / 2 + - (this._rootRef.current ? this._rootRef.current.offsetWidth : 0) - const widthBased = scrollX + rect.left - const left = - useMouseWhen && mousePos < targetSize.width ? mousePos : widthBased - - const style = { ...this.props.style } - - if (align === 'left') { - alignOffset = -targetSize.width / 2 - } else if (align === 'right') { - alignOffset = targetSize.width / 2 - } + const scrollY = + window.scrollY !== undefined ? window.scrollY : window.pageYOffset + const scrollX = + window.scrollX !== undefined ? window.scrollX : window.pageXOffset + const top = (isTrue(fixed_position) ? 0 : scrollY) + rect.top + + // Use Mouse position when target is too wide + const useMouseWhen = targetSize.width > 400 + const mousePos = + getOffsetLeft(target) + + rect.left / 2 + + (elementRef.current ? elementRef.current.offsetWidth : 0) + const widthBased = scrollX + rect.left + const left = + useMouseWhen && mousePos < targetSize.width ? mousePos : widthBased + + const style = { ...props.style } + + if (align === 'left') { + alignOffset = -targetSize.width / 2 + } else if (align === 'right') { + alignOffset = targetSize.width / 2 + } - const stylesFromPosition = { - left: () => { - style.top = top + targetSize.height / 2 - this.state.height / 2 - style.left = left - this.state.width - this.offset - }, - right: () => { - style.top = top + targetSize.height / 2 - this.state.height / 2 - style.left = left + targetSize.width + this.offset - }, - top: () => { - style.left = - left - - this.state.width / 2 + - targetSize.width / 2 + - alignOffset - style.top = top - this.state.height - this.offset - }, - bottom: () => { - style.left = - left - - this.state.width / 2 + - targetSize.width / 2 + - alignOffset - style.top = top + targetSize.height + this.offset - }, - } + const topHorizontal = top + targetSize.height / 2 - elementHeight / 2 + const leftVertical = + left - elementWidth / 2 + targetSize.width / 2 + alignOffset + + const stylesFromPosition = { + left: () => { + style.top = topHorizontal + style.left = left - elementWidth - offset.current + }, + right: () => { + style.top = topHorizontal + style.left = left + targetSize.width + offset.current + }, + top: () => { + style.left = leftVertical + style.top = top - elementHeight - offset.current + }, + bottom: () => { + style.left = leftVertical + style.top = top + targetSize.height + offset.current + }, + } - const stylesFromArrow = { - left: () => { - style.left = - left + targetSize.width / 2 - this.offset + alignOffset - }, - right: () => { - style.left = - left - - this.state.width + - targetSize.width / 2 + - this.offset + - alignOffset - }, - top: () => { - style.top = top + targetSize.height / 2 - this.offset - }, - bottom: () => { - style.top = - top + targetSize.height / 2 - this.state.height + this.offset - }, - } + const stylesFromArrow = { + left: () => { + style.left = + left + targetSize.width / 2 - offset.current + alignOffset + }, + right: () => { + style.left = + left - + elementWidth + + targetSize.width / 2 + + offset.current + + alignOffset + }, + top: () => { + style.top = top + targetSize.height / 2 - offset.current + }, + bottom: () => { + style.top = + top + targetSize.height / 2 - elementHeight + offset.current + }, + } - if (stylesFromPosition[position]) { - stylesFromPosition[position]() - } - if (stylesFromArrow[arrow]) { - stylesFromArrow[arrow]() - } + if (stylesFromPosition[position]) { + stylesFromPosition[position]() + } + if (stylesFromArrow[arrow]) { + stylesFromArrow[arrow]() + } - return style - } catch (e) { - return {} + const rightOffset = + parseFloat(String(style.left)) + elementWidth - window.innerWidth + if (rightOffset > 0) { + style.left = window.innerWidth - elementWidth } - } - checkWindowPosition(style: React.CSSProperties) { if (style.left < 0) { - style.left = this.offset - } else { - try { - const rightOffset = - parseFloat(String(style.left)) + - this.state.width - - window.innerWidth - if (rightOffset > 0) { - style.left = window.innerWidth - this.state.width - this.offset - } - } catch (e) { - // - } + style.left = 0 + } + if (style.top < 0) { + style.top = 0 } return style - } - - updateSize() { - try { - // to ensure we do not wrap the content before getting the height - if (!this.state.height) { - this._rootRef.current.style.left = '' - } - const width = this._rootRef.current.offsetWidth - const height = this._rootRef.current.offsetHeight + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isActive, hover, children, elementRef, renewStyles]) - if (width !== this.state.width || height !== this.state.height) { - this.setState({ - width, - height, - }) - } - } catch (e) { - // + const handleMouseEnter = () => { + if (isTrue(active) && useHover !== false) { + setHover(true) } } - handleMouseEnter = () => { - if (isTrue(this.props.active) && this.props.useHover !== false) { - this.setState({ hover: true }) + const handleMouseLeave = () => { + if (useHover !== false) { + setHover(false) } } - handleMouseLeave = () => { - if (this.props.useHover !== false) { - this.setState({ hover: false }) - } - } - - render() { - const { - internal_id, - active, - attributes, - arrow, - position, - animate_position, - fixed_position, - no_animation, - children, - } = this.props - const { hover, hide } = this.state - - const isActive = isTrue(active) || hover - - if (isActive) { - this._style = this.checkWindowPosition(this.getGlobalStyle()) - } - - return ( - - {arrow && ( - - )} - - - {children} - + return ( + + {arrow && ( + + )} + + + {children} - ) - } + + ) } diff --git a/packages/dnb-eufemia/src/components/tooltip/TooltipHelpers.tsx b/packages/dnb-eufemia/src/components/tooltip/TooltipHelpers.tsx index 492ec7277d5..9da7fa340b7 100644 --- a/packages/dnb-eufemia/src/components/tooltip/TooltipHelpers.tsx +++ b/packages/dnb-eufemia/src/components/tooltip/TooltipHelpers.tsx @@ -3,6 +3,7 @@ * */ +import React from 'react' import classnames from 'classnames' export function injectTooltipSemantic(params) { @@ -36,3 +37,11 @@ export const defaultProps = { children: null, tooltip: null, } + +export function getPropsFromTooltipProp(localProps) { + return localProps.tooltip + ? React.isValidElement(localProps.tooltip) && localProps.tooltip.props + ? localProps.tooltip.props + : { children: localProps.tooltip } + : null +} diff --git a/packages/dnb-eufemia/src/components/tooltip/TooltipPortal.tsx b/packages/dnb-eufemia/src/components/tooltip/TooltipPortal.tsx index 259f25819f5..6558609ac51 100644 --- a/packages/dnb-eufemia/src/components/tooltip/TooltipPortal.tsx +++ b/packages/dnb-eufemia/src/components/tooltip/TooltipPortal.tsx @@ -27,70 +27,89 @@ type TooltipPortalProps = { target: HTMLElement active: boolean group?: string - internal_id?: string + internalId?: string + children?: React.ReactNode } -type TooltipPortalState = { - isMounted: boolean - isActive: boolean -} - -export default class TooltipPortal extends React.PureComponent< - TooltipProps & TooltipPortalProps, - TooltipPortalState -> { - state: TooltipPortalState = { isMounted: false, isActive: null } +function TooltipPortal(props: TooltipProps & TooltipPortalProps) { + const { + active, + group, + target, + hide_delay, + no_animation, + internalId, + children, + } = props + + const [isMounted, setIsMounted] = React.useState(false) + + React.useLayoutEffect(() => { + const getRootElement = () => { + if (typeof document !== 'undefined') { + try { + const elem = document.createElement('div') + elem.classList.add('dnb-tooltip__portal') + elem.classList.add('dnb-core-style') + document.body.appendChild(elem) - init = () => { - const { group, active } = this.props + return elem + } catch (e) { + warn(e) + } + } + } tooltipPortal[group] = tooltipPortal[group] || { - node: this.useRootElement(), + node: getRootElement(), count: 0, } tooltipPortal[group].count++ - this.setState({ isMounted: true, isActive: active }, () => { - if (!this.isMainGorup()) { - this.renderPortal() - } - }) - } + setIsMounted(true) - componentDidMount() { - if (document.readyState === 'complete') { - this.init() - } else if (typeof window !== 'undefined') { - window.addEventListener('load', this.init) + if (!isMainGorup(group) && active) { + renderPortal(true) } - } - componentDidUpdate(prevProps: TooltipProps & TooltipPortalProps) { - const { group, active, hide_delay } = this.props + return () => { + if (tooltipPortal[group]) { + tooltipPortal[group].count-- + + if (!isMainGorup(group)) { + clearTimeout(tooltipPortal[group].timeout) + ReactDOM.unmountComponentAtNode(tooltipPortal[group].node) + } - if (this.props.children !== prevProps.children) { - this.renderPortal() + if (tooltipPortal[group].count === 0) { + try { + document.body.removeChild(tooltipPortal[group].node) + } catch (e) { + // + } + + tooltipPortal[group] = null + } + } } + }, []) - if (tooltipPortal[group] && active !== prevProps.active) { + React.useEffect(() => { + if (tooltipPortal?.[group]) { clearTimeout(tooltipPortal[group].timeout) - if (active && !prevProps.active) { - this.setState({ isActive: true }, () => { - if (!this.isMainGorup()) { - this.renderPortal() - } - }) - } else if (!active && prevProps.active) { + if (active) { + if (!isMainGorup(group)) { + renderPortal(true) + } + } else if (!active) { const run = () => { - this.setState({ isActive: false }, () => { - if (!this.isMainGorup()) { - this.renderPortal() - } - }) + if (!isMainGorup(group)) { + renderPortal(false) + } } - if (this.props.no_animation || globalThis.IS_TEST) { + if (no_animation || globalThis.IS_TEST) { run() } else { tooltipPortal[group].timeout = setTimeout( @@ -100,92 +119,23 @@ export default class TooltipPortal extends React.PureComponent< } } } - } - - isMainGorup() { - const { group } = this.props - return group.includes('main') - } - - componentWillUnmount() { - const { group } = this.props + }, [children, active, group, hide_delay, no_animation]) - if (tooltipPortal[group]) { - tooltipPortal[group].count-- + const renderPortal = (isActive: boolean) => { + const targetElement = getTargetElement(target) - if (!this.isMainGorup()) { - clearTimeout(tooltipPortal[group].timeout) - ReactDOM.unmountComponentAtNode(tooltipPortal[group].node) - } - - if (tooltipPortal[group].count === 0) { - try { - document.body.removeChild(tooltipPortal[group].node) - } catch (e) { - // - } - - tooltipPortal[group] = null - } - } - } - - getTargetElement() { - if (typeof document !== 'undefined') { - const { target } = this.props - return typeof target === 'string' - ? typeof document !== 'undefined' && document.querySelector(target) - : target - } - } - - useRootElement() { - if (typeof document !== 'undefined') { - try { - const elem = document.createElement('div') - elem.classList.add('dnb-tooltip__portal') - elem.classList.add('dnb-core-style') - document.body.appendChild(elem) - - return elem - } catch (e) { - warn(e) - } - } - } - - handleAria(elem: HTMLElement) { - try { - if (!elem.classList.contains('dnb-tooltip__wrapper')) { - const existing = { - 'aria-describedby': elem.getAttribute('aria-describedby'), - } - elem.setAttribute( - 'aria-describedby', - combineDescribedBy(existing, this.props.internal_id) - ) - } - } catch (e) { - // - } - } - - renderPortal = () => { - const targetElement = this.getTargetElement() - const { group } = this.props - - if (!this.isMainGorup() && tooltipPortal[group]) { + if (!isMainGorup(group) && tooltipPortal[group]) { clearTimeout(tooltipPortal[group].timeout) } - this.handleAria(targetElement) + handleAria(targetElement, internalId) - if (this.isMainGorup()) { + if (isMainGorup(group)) { return ReactDOM.createPortal( , tooltipPortal[group].node ) @@ -193,19 +143,47 @@ export default class TooltipPortal extends React.PureComponent< ReactDOM.render( , tooltipPortal[group].node ) } } - render() { - if (this.state.isMounted && this.isMainGorup()) { - return this.renderPortal() - } + if (isMounted && isMainGorup(group)) { + return renderPortal(active) + } + + return <> +} + +export default TooltipPortal + +const isMainGorup = (group) => { + return group.includes('main') +} + +const getTargetElement = (target) => { + if (typeof document !== 'undefined') { + return typeof target === 'string' + ? typeof document !== 'undefined' && document.querySelector(target) + : target + } +} - return <> +const handleAria = (elem: HTMLElement, internalId: string) => { + try { + if (!elem.classList.contains('dnb-tooltip__wrapper')) { + const existing = { + 'aria-describedby': elem.getAttribute('aria-describedby'), + } + elem.setAttribute( + 'aria-describedby', + combineDescribedBy(existing, internalId) + ) + } + } catch (e) { + // } } diff --git a/packages/dnb-eufemia/src/components/tooltip/TooltipWithEvents.tsx b/packages/dnb-eufemia/src/components/tooltip/TooltipWithEvents.tsx index e1bd2ec9d23..d0b4c5383d6 100644 --- a/packages/dnb-eufemia/src/components/tooltip/TooltipWithEvents.tsx +++ b/packages/dnb-eufemia/src/components/tooltip/TooltipWithEvents.tsx @@ -4,92 +4,79 @@ */ import React from 'react' -import { - combineDescribedBy, - getInnerRef, - warn, -} from '../../shared/component-helper' +import { combineDescribedBy, warn } from '../../shared/component-helper' import { injectTooltipSemantic } from './TooltipHelpers' import TooltipPortal from './TooltipPortal' import { TooltipProps } from './types' type TooltipWithEventsProps = { - target: HTMLElement + target: React.ReactElement & React.RefObject active: boolean clientX: number - internal_id: string + internalId: string } -type TooltipWithEventsState = { - isActive: boolean - isNotSemanticElement: boolean - _isMounted: boolean - clientX: number -} - -export default class TooltipWithEvents extends React.PureComponent< - TooltipProps & TooltipWithEventsProps -> { - _onEnterTimeout: NodeJS.Timeout - _ref: HTMLElement - - state: TooltipWithEventsState = { - isActive: false, - isNotSemanticElement: false, - _isMounted: false, - clientX: null, - } - - constructor(props) { - super(props) - - this._ref = Object.prototype.hasOwnProperty.call( - props.target, - 'current' - ) - ? props.target - : React.createRef() - } +function TooltipWithEvents(props: TooltipProps & TooltipWithEventsProps) { + const { + children, + target, + // internalId,// NB: Do not remove internalId from props! + ...restProps + } = props + + const [isActive, setIsActive] = React.useState(false) + const [isNotSemanticElement, setIsNotSemanticElement] = + React.useState(false) + const [isMounted, setIsMounted] = React.useState(false) + const [clientX, setClientX] = React.useState(null) + + const onEnterTimeout = React.useRef() + const elementRef = React.useRef() + const cloneRef = React.useRef() + + React.useEffect(() => { + elementRef.current = getRefElement(cloneRef) + + // When used internal + if (!elementRef.current) { + elementRef.current = target.current + } - componentDidMount() { - this.setState( - { - _isMounted: true, - }, - () => { - this.addEvents() - this.handleSemanticElement() - } - ) - } + if (elementRef.current) { + setIsMounted(true) + addEvents(elementRef.current) + handleSemanticElement() + } - componentWillUnmount() { - clearTimeout(this._onEnterTimeout) - - const domElement = getInnerRef(this._ref).current - if (domElement) { - try { - domElement.removeEventListener('click', this.onMouseLeave) - domElement.removeEventListener('focus', this.onFocus) - domElement.removeEventListener('blur', this.onMouseLeave) - domElement.removeEventListener('mouseenter', this.onMouseEnter) - domElement.removeEventListener('mouseleave', this.onMouseLeave) - domElement.removeEventListener('touchstart', this.onMouseEnter) - domElement.removeEventListener('touchend', this.onMouseLeave) - } catch (e) { - warn(e) + return () => { + clearTimeout(onEnterTimeout.current) + + const element = elementRef.current + if (element) { + try { + element.removeEventListener('click', onMouseLeave) + element.removeEventListener('focus', onFocus) + element.removeEventListener('blur', onMouseLeave) + element.removeEventListener('mouseenter', onMouseEnter) + element.removeEventListener('mouseleave', onMouseLeave) + element.removeEventListener('touchstart', onMouseEnter) + element.removeEventListener('touchend', onMouseLeave) + } catch (e) { + warn(e) + } } } - } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) /** * Make the element focus able by keyboard, if it is not a semantic element * This will enable keyboard access to the tooltip by adding focus posibility */ - handleSemanticElement = () => { + const handleSemanticElement = () => { try { const targetElement = document.querySelector( - `*[aria-describedby*="${this.props.internal_id}"]` + `*[aria-describedby*="${props.internalId}"]` ) if (targetElement) { const role = targetElement.getAttribute('role') @@ -97,9 +84,7 @@ export default class TooltipWithEvents extends React.PureComponent< /div|p|span/i.test(targetElement?.tagName) && (!role || role === 'text') ) { - this.setState({ - isNotSemanticElement: true, - }) + setIsNotSemanticElement(true) } } } catch (e) { @@ -107,42 +92,36 @@ export default class TooltipWithEvents extends React.PureComponent< } } - addEvents = () => { - const domElement = getInnerRef(this._ref).current + const addEvents = (element: HTMLElement) => { try { - domElement.addEventListener('click', this.onMouseLeave) - domElement.addEventListener('focus', this.onFocus) - domElement.addEventListener('blur', this.onMouseLeave) - domElement.addEventListener('mouseenter', this.onMouseEnter) - domElement.addEventListener('mouseleave', this.onMouseLeave) - domElement.addEventListener('touchstart', this.onMouseEnter) - domElement.addEventListener('touchend', this.onMouseLeave) + element.addEventListener('click', onMouseLeave) + element.addEventListener('focus', onFocus) + element.addEventListener('blur', onMouseLeave) + element.addEventListener('mouseenter', onMouseEnter) + element.addEventListener('mouseleave', onMouseLeave) + element.addEventListener('touchstart', onMouseEnter) + element.addEventListener('touchend', onMouseLeave) } catch (e) { warn(e) } } - isTouch = (type: string) => { - return /touch/i.test(type) - } - - onFocus = (e: MouseEvent) => { + const onFocus = (e: MouseEvent) => { try { if ( document.documentElement.getAttribute('data-whatintent') === 'keyboard' ) { - return this.onMouseEnter(e) + return onMouseEnter(e) } } catch (e) { warn(e) } } - onMouseEnter = (e: MouseEvent) => { + const onMouseEnter = (e: MouseEvent) => { try { - const isTouch = this.isTouch(e.type) - if (isTouch) { + if (isTouch(e.type)) { const elem = e.currentTarget as HTMLElement elem.style.userSelect = 'none' } @@ -151,24 +130,24 @@ export default class TooltipWithEvents extends React.PureComponent< } const run = () => { - this.setState({ isActive: true, clientX: e.clientX }) + setIsActive(true) + setClientX(e.clientX) } - if (this.props.no_animation || globalThis.IS_TEST) { + if (props.no_animation || globalThis.IS_TEST) { run() } else { - clearTimeout(this._onEnterTimeout) - this._onEnterTimeout = setTimeout( + clearTimeout(onEnterTimeout.current) + onEnterTimeout.current = setTimeout( run, - parseFloat(String(this.props.show_delay)) || 1 + parseFloat(String(props.show_delay)) || 1 ) // have min 1 to make sure we are after onMouseLeave } } - onMouseLeave = (e: MouseEvent) => { + const onMouseLeave = (e: MouseEvent) => { try { - const isTouch = this.isTouch(e.type) - if (isTouch) { + if (isTouch(e.type)) { const elem = e.currentTarget as HTMLElement elem.style.userSelect = '' } @@ -176,51 +155,69 @@ export default class TooltipWithEvents extends React.PureComponent< warn(e) } - clearTimeout(this._onEnterTimeout) - this.setState({ isActive: false }) + clearTimeout(onEnterTimeout.current) + setIsActive(false) } - render() { - const { - children, - target, - // internal_id,// NB: Do not remove internal_id from props! - ...props - } = this.props - - let componentWrapper = null - + const componentWrapper = React.useMemo(() => { // we could also check against && target.props && !target.props.tooltip if (React.isValidElement(target)) { - const params = this.state.isNotSemanticElement + const params = isNotSemanticElement ? injectTooltipSemantic({ className: props.className }) : {} - componentWrapper = React.cloneElement(target, { - ref: this._ref, + return React.cloneElement(target, { + ref: cloneRef, ...params, 'aria-describedby': combineDescribedBy( target.props, - this.props.internal_id + props.internalId ), }) } - return ( - <> - {componentWrapper} - {this.state._isMounted && ( - - {children} - - )} - - ) + return null + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [target]) + + return ( + <> + {componentWrapper} + {isMounted && ( + + {children} + + )} + + ) +} + +export default TooltipWithEvents + +const isTouch = (type: string) => { + return /touch/i.test(type) +} + +function getRefElement(target: React.RefObject) { + const unknownTarget = target as unknown as React.RefObject<{ + _ref: React.RefObject + }> + let element = target as HTMLElement | React.RefObject + + // "_ref" is set inside e.g. the Button component (among many others) + if (unknownTarget?.current?._ref) { + element = getRefElement(unknownTarget.current._ref) } + + if (Object.prototype.hasOwnProperty.call(element, 'current')) { + element = (element as React.RefObject).current + } + + return element as HTMLElement } diff --git a/packages/dnb-eufemia/src/components/tooltip/types.ts b/packages/dnb-eufemia/src/components/tooltip/types.ts index 22a335a96af..263e82f9413 100644 --- a/packages/dnb-eufemia/src/components/tooltip/types.ts +++ b/packages/dnb-eufemia/src/components/tooltip/types.ts @@ -1,11 +1,24 @@ +export type TooltipPosition = 'top' | 'right' | 'bottom' | 'left' + +export type TooltipArrow = + | null + | 'center' + | 'top' + | 'right' + | 'bottom' + | 'left' +export type TooltipAlign = null | 'center' | 'right' | 'left' + +export type TooltipSize = 'basis' | 'large' + export type TooltipProps = { id?: string group?: string - size?: 'basis' | 'large' + size?: TooltipSize active?: boolean - position?: 'top' | 'right' | 'bottom' | 'left' - arrow?: null | 'center' | 'top' | 'right' | 'bottom' | 'left' - align?: null | 'center' | 'right' | 'left' + position?: TooltipPosition + arrow?: TooltipArrow + align?: TooltipAlign animate_position?: boolean fixed_position?: boolean skip_portal?: boolean diff --git a/packages/dnb-eufemia/src/shared/Context.tsx b/packages/dnb-eufemia/src/shared/Context.tsx index 2fcd250f655..b4b138d6094 100644 --- a/packages/dnb-eufemia/src/shared/Context.tsx +++ b/packages/dnb-eufemia/src/shared/Context.tsx @@ -24,6 +24,7 @@ import type { TimelineItemProps } from '../components/timeline/TimelineItem' import type { VisuallyHiddenProps } from '../components/visually-hidden/VisuallyHidden' import type { DrawerProps } from '../components/drawer/types' import type { DialogProps } from '../components/dialog/types' +import type { TooltipProps } from '../components/tooltip/types' // All TypeScript based Eufemia elements import type { AnchorProps } from '../elements/Anchor' @@ -47,6 +48,7 @@ export type ContextProps = { VisuallyHidden?: Partial Drawer?: Partial Dialog?: Partial + Tooltip?: Partial // -- TODO: Not converted yet -- diff --git a/packages/dnb-eufemia/src/shared/component-helper.js b/packages/dnb-eufemia/src/shared/component-helper.js index a7ae0dd6f67..a790aae5cb5 100644 --- a/packages/dnb-eufemia/src/shared/component-helper.js +++ b/packages/dnb-eufemia/src/shared/component-helper.js @@ -931,24 +931,6 @@ export function getStatusState(status) { ) } -export function getInnerRef(ref) { - let ret = ref - - if ( - ref && - ref.current && - !React.isValidElement(ref.current) && - ref.current._ref - ) { - const tmp = getInnerRef(ref.current._ref) - if (tmp && Object.prototype.hasOwnProperty.call(tmp, 'current')) { - ret = tmp - } - } - - return ret -} - export function combineLabelledBy(...params) { return combineAriaBy('aria-labelledby', params) } From 835b0177aee442c830dd649e2bf81f4f252ed11e Mon Sep 17 00:00:00 2001 From: Tobias Date: Mon, 12 Sep 2022 11:47:38 +0200 Subject: [PATCH 3/3] Remove clientX --- .../__tests__/__snapshots__/Dialog.test.tsx.snap | 1 - .../__tests__/__snapshots__/Drawer.test.tsx.snap | 1 - .../__tests__/__snapshots__/Modal.test.tsx.snap | 1 - .../src/components/tooltip/TooltipContainer.tsx | 1 - .../src/components/tooltip/TooltipPortal.tsx | 1 + .../src/components/tooltip/TooltipWithEvents.tsx | 12 ++---------- 6 files changed, 3 insertions(+), 14 deletions(-) diff --git a/packages/dnb-eufemia/src/components/dialog/__tests__/__snapshots__/Dialog.test.tsx.snap b/packages/dnb-eufemia/src/components/dialog/__tests__/__snapshots__/Dialog.test.tsx.snap index 714b1726e06..5da21a736f2 100644 --- a/packages/dnb-eufemia/src/components/dialog/__tests__/__snapshots__/Dialog.test.tsx.snap +++ b/packages/dnb-eufemia/src/components/dialog/__tests__/__snapshots__/Dialog.test.tsx.snap @@ -587,7 +587,6 @@ exports[`Dialog component snapshot should match component snapshot 1`] = ` } } className={null} - clientX={null} fixed_position={false} group="dialog_id-tooltip" hide_delay={500} diff --git a/packages/dnb-eufemia/src/components/drawer/__tests__/__snapshots__/Drawer.test.tsx.snap b/packages/dnb-eufemia/src/components/drawer/__tests__/__snapshots__/Drawer.test.tsx.snap index b328e88012d..8e95dfce37e 100644 --- a/packages/dnb-eufemia/src/components/drawer/__tests__/__snapshots__/Drawer.test.tsx.snap +++ b/packages/dnb-eufemia/src/components/drawer/__tests__/__snapshots__/Drawer.test.tsx.snap @@ -585,7 +585,6 @@ exports[`Drawer component snapshot should match component snapshot 1`] = ` } } className={null} - clientX={null} fixed_position={false} group="drawer_id-tooltip" hide_delay={500} diff --git a/packages/dnb-eufemia/src/components/modal/__tests__/__snapshots__/Modal.test.tsx.snap b/packages/dnb-eufemia/src/components/modal/__tests__/__snapshots__/Modal.test.tsx.snap index 351c1132a0c..607b37a4382 100644 --- a/packages/dnb-eufemia/src/components/modal/__tests__/__snapshots__/Modal.test.tsx.snap +++ b/packages/dnb-eufemia/src/components/modal/__tests__/__snapshots__/Modal.test.tsx.snap @@ -576,7 +576,6 @@ exports[`Modal component have to match snapshot 1`] = ` } } className={null} - clientX={null} fixed_position={false} group="modal_id-tooltip" hide_delay={500} diff --git a/packages/dnb-eufemia/src/components/tooltip/TooltipContainer.tsx b/packages/dnb-eufemia/src/components/tooltip/TooltipContainer.tsx index 667c239a76e..02bdfd5a40f 100644 --- a/packages/dnb-eufemia/src/components/tooltip/TooltipContainer.tsx +++ b/packages/dnb-eufemia/src/components/tooltip/TooltipContainer.tsx @@ -11,7 +11,6 @@ import { TooltipProps } from './types' type TooltipContainerProps = { targetElement: HTMLElement - clientX?: number style?: React.CSSProperties useHover?: boolean internalId?: string diff --git a/packages/dnb-eufemia/src/components/tooltip/TooltipPortal.tsx b/packages/dnb-eufemia/src/components/tooltip/TooltipPortal.tsx index 6558609ac51..e95d2d6d46d 100644 --- a/packages/dnb-eufemia/src/components/tooltip/TooltipPortal.tsx +++ b/packages/dnb-eufemia/src/components/tooltip/TooltipPortal.tsx @@ -119,6 +119,7 @@ function TooltipPortal(props: TooltipProps & TooltipPortalProps) { } } } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [children, active, group, hide_delay, no_animation]) const renderPortal = (isActive: boolean) => { diff --git a/packages/dnb-eufemia/src/components/tooltip/TooltipWithEvents.tsx b/packages/dnb-eufemia/src/components/tooltip/TooltipWithEvents.tsx index d0b4c5383d6..9f664ae8fba 100644 --- a/packages/dnb-eufemia/src/components/tooltip/TooltipWithEvents.tsx +++ b/packages/dnb-eufemia/src/components/tooltip/TooltipWithEvents.tsx @@ -12,23 +12,16 @@ import { TooltipProps } from './types' type TooltipWithEventsProps = { target: React.ReactElement & React.RefObject active: boolean - clientX: number internalId: string } function TooltipWithEvents(props: TooltipProps & TooltipWithEventsProps) { - const { - children, - target, - // internalId,// NB: Do not remove internalId from props! - ...restProps - } = props + const { children, target, ...restProps } = props const [isActive, setIsActive] = React.useState(false) const [isNotSemanticElement, setIsNotSemanticElement] = React.useState(false) const [isMounted, setIsMounted] = React.useState(false) - const [clientX, setClientX] = React.useState(null) const onEnterTimeout = React.useRef() const elementRef = React.useRef() @@ -98,6 +91,7 @@ function TooltipWithEvents(props: TooltipProps & TooltipWithEventsProps) { element.addEventListener('focus', onFocus) element.addEventListener('blur', onMouseLeave) element.addEventListener('mouseenter', onMouseEnter) + element.addEventListener('mousedown', onMouseEnter) element.addEventListener('mouseleave', onMouseLeave) element.addEventListener('touchstart', onMouseEnter) element.addEventListener('touchend', onMouseLeave) @@ -131,7 +125,6 @@ function TooltipWithEvents(props: TooltipProps & TooltipWithEventsProps) { const run = () => { setIsActive(true) - setClientX(e.clientX) } if (props.no_animation || globalThis.IS_TEST) { @@ -188,7 +181,6 @@ function TooltipWithEvents(props: TooltipProps & TooltipWithEventsProps) { key="tooltip" active={isActive} target={elementRef.current} - clientX={clientX} {...restProps} > {children}