Skip to content

Commit

Permalink
feat: expose tooltip ref (imperative mode)
Browse files Browse the repository at this point in the history
  • Loading branch information
gabrieljablonski committed Oct 31, 2023
1 parent d7f98e8 commit 5b64278
Show file tree
Hide file tree
Showing 5 changed files with 411 additions and 309 deletions.
38 changes: 36 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { TooltipController as Tooltip } from 'components/TooltipController'
import { IPosition } from 'components/Tooltip/TooltipTypes.d'
import React, { useState } from 'react'
import { IPosition, TooltipImperativeProps } from 'components/Tooltip/TooltipTypes.d'
import React, { useEffect, useRef, useState } from 'react'
import { inline, offset } from '@floating-ui/dom'
import styles from './styles.module.css'

Expand All @@ -11,6 +11,7 @@ function App() {
const [isDarkOpen, setIsDarkOpen] = useState(false)
const [position, setPosition] = useState<IPosition>({ x: 0, y: 0 })
const [toggle, setToggle] = useState(false)
const tooltipRef = useRef<TooltipImperativeProps>(null)

const handlePositionClick: React.MouseEventHandler<HTMLDivElement> = (event) => {
const x = event.clientX
Expand All @@ -23,6 +24,19 @@ function App() {
setAnchorId(target.id)
}

useEffect(() => {
const handleQ = (event: KeyboardEvent) => {
if (event.key === 'q') {
// q
tooltipRef.current?.close()
}
}
window.addEventListener('keydown', handleQ)
return () => {
window.removeEventListener('keydown', handleQ)
}
})

return (
<main className={styles['main']}>
<button
Expand Down Expand Up @@ -86,6 +100,7 @@ function App() {
</p>
<Tooltip id="anchor-select">Tooltip content</Tooltip>
<Tooltip
ref={tooltipRef}
anchorSelect="section[id='section-anchor-select'] > p > button"
place="bottom"
events={['click']}
Expand Down Expand Up @@ -140,6 +155,25 @@ function App() {
positionStrategy="fixed"
/>
</div>
<button
id="imperativeTooltipButton"
style={{ height: 40, marginLeft: 100 }}
onClick={() => {
tooltipRef.current?.open({
anchorSelect: '#imperativeTooltipButton',
content: (
<div style={{ fontSize: 32 }}>
Opened imperatively!
<br />
<br />
Press Q to close imperatively too!
</div>
),
})
}}
>
imperative tooltip
</button>
</div>

<div style={{ marginTop: '1rem' }}>
Expand Down
55 changes: 45 additions & 10 deletions src/components/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef, useCallback } from 'react'
import React, { useEffect, useState, useRef, useCallback, useImperativeHandle } from 'react'
import { autoUpdate } from '@floating-ui/dom'
import classNames from 'classnames'
import debounce from 'utils/debounce'
Expand All @@ -8,10 +8,11 @@ import { getScrollParent } from 'utils/get-scroll-parent'
import { computeTooltipPosition } from 'utils/compute-positions'
import coreStyles from './core-styles.module.css'
import styles from './styles.module.css'
import type { IPosition, ITooltip, PlacesType } from './TooltipTypes'
import type { IPosition, ITooltip, PlacesType, TooltipImperativeOpenOptions } from './TooltipTypes'

const Tooltip = ({
// props
forwardRef,
id,
className,
classNameArrow,
Expand Down Expand Up @@ -58,6 +59,9 @@ const Tooltip = ({
const [inlineArrowStyles, setInlineArrowStyles] = useState({})
const [show, setShow] = useState(false)
const [rendered, setRendered] = useState(false)
const [imperativeOptions, setImperativeOptions] = useState<TooltipImperativeOpenOptions | null>(
null,
)
const wasShowing = useRef(false)
const lastFloatPosition = useRef<IPosition | null>(null)
/**
Expand Down Expand Up @@ -149,6 +153,7 @@ const Tooltip = ({
if (show) {
afterShow?.()
} else {
setImperativeOptions(null)
afterHide?.()
}
}, [show])
Expand Down Expand Up @@ -274,6 +279,9 @@ const Tooltip = ({
}

const handleClickOutsideAnchors = (event: MouseEvent) => {
if (!show) {
return
}
const anchorById = document.querySelector<HTMLElement>(`[id='${anchorId}']`)
const anchors = [anchorById, ...anchorsBySelect]
if (anchors.some((anchor) => anchor?.contains(event.target as HTMLElement))) {
Expand All @@ -293,9 +301,10 @@ const Tooltip = ({
const debouncedHandleShowTooltip = debounce(handleShowTooltip, 50, true)
const debouncedHandleHideTooltip = debounce(handleHideTooltip, 50, true)
const updateTooltipPosition = useCallback(() => {
if (position) {
const actualPosition = imperativeOptions?.position ?? position
if (actualPosition) {
// if `position` is set, override regular and `float` positioning
handleTooltipPosition(position)
handleTooltipPosition(actualPosition)
return
}

Expand Down Expand Up @@ -349,6 +358,7 @@ const Tooltip = ({
offset,
positionStrategy,
position,
imperativeOptions?.position,
float,
])

Expand Down Expand Up @@ -484,7 +494,7 @@ const Tooltip = ({
])

useEffect(() => {
let selector = anchorSelect ?? ''
let selector = imperativeOptions?.anchorSelect ?? anchorSelect ?? ''
if (!selector && id) {
selector = `[data-tooltip-id='${id}']`
}
Expand Down Expand Up @@ -584,7 +594,7 @@ const Tooltip = ({
return () => {
documentObserver.disconnect()
}
}, [id, anchorSelect, activeAnchor])
}, [id, anchorSelect, imperativeOptions?.anchorSelect, activeAnchor])

useEffect(() => {
updateTooltipPosition()
Expand Down Expand Up @@ -628,7 +638,7 @@ const Tooltip = ({
}, [])

useEffect(() => {
let selector = anchorSelect
let selector = imperativeOptions?.anchorSelect ?? anchorSelect
if (!selector && id) {
selector = `[data-tooltip-id='${id}']`
}
Expand All @@ -642,9 +652,34 @@ const Tooltip = ({
// warning was already issued in the controller
setAnchorsBySelect([])
}
}, [id, anchorSelect])
}, [id, anchorSelect, imperativeOptions?.anchorSelect])

const actualContent = imperativeOptions?.content ?? content
const canShow = Boolean(!hidden && actualContent && show && Object.keys(inlineStyles).length > 0)

const canShow = !hidden && content && show && Object.keys(inlineStyles).length > 0
useImperativeHandle(forwardRef, () => ({
open: (options) => {
if (options?.anchorSelect) {
try {
document.querySelector(options.anchorSelect)
} catch {
if (!process.env.NODE_ENV || process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line no-console
console.warn(`[react-tooltip] "${options.anchorSelect}" is not a valid CSS selector`)
}
return
}
}
setImperativeOptions(options ?? null)
handleShow(true)
},
close: () => {
handleShow(false)
},
activeAnchor,
place: actualPlacement,
isOpen: rendered && canShow,
}))

return rendered ? (
<WrapperElement
Expand All @@ -671,7 +706,7 @@ const Tooltip = ({
}}
ref={tooltipRef}
>
{content}
{actualContent}
<WrapperElement
className={classNames(
'react-tooltip-arrow',
Expand Down
24 changes: 24 additions & 0 deletions src/components/Tooltip/TooltipTypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,31 @@ export interface IPosition {
y: number
}

export interface TooltipImperativeOpenOptions {
anchorSelect?: string
position?: IPosition
content?: ChildrenType
}

export interface TooltipImperativeProps {
open: (options?: TooltipImperativeOpenOptions) => void
close: () => void
/**
* @readonly
*/
activeAnchor: HTMLElement | null
/**
* @readonly
*/
place: PlacesType
/**
* @readonly
*/
isOpen: boolean
}

export interface ITooltip {
forwardRef?: React.ForwardedRef<TooltipImperativeProps>
className?: string
classNameArrow?: string
content?: ChildrenType
Expand Down
Loading

0 comments on commit 5b64278

Please sign in to comment.