Skip to content

Add "border" option including arrow #1048

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 3, 2023
3 changes: 2 additions & 1 deletion docs/docs/options.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,14 @@ import { Tooltip } from 'react-tooltip';
| `closeOnEsc` | `boolean` | no | `false` | `true` `false` | Pressing escape key will close the tooltip |
| `closeOnScroll` | `boolean` | no | `false` | `true` `false` | Scrolling will close the tooltip (for this to work, scroll element must be either the root html tag, the tooltip parent, or the anchor parent) |
| `closeOnEsc` | `boolean` | no | `false` | `true` `false` | Resizing the window will close the tooltip |
| `style` | `CSSProperties` | no | | a React inline style | Add inline styles directly to the tooltip |
| `style` | `CSSProperties` | no | | a CSS style object | Add inline styles directly to the tooltip |
| `position` | `{ x: number; y: number }` | no | | any `number` value for both `x` and `y` | Override the tooltip position on the DOM |
| `isOpen` | `boolean` | no | handled by internal state | `true` `false` | The tooltip can be controlled or uncontrolled, this attribute can be used to handle show and hide tooltip outside tooltip (can be used **without** `setIsOpen`) |
| `setIsOpen` | `function` | no | | | The tooltip can be controlled or uncontrolled, this attribute can be used to handle show and hide tooltip outside tooltip |
| `afterShow` | `function` | no | | | A function to be called after the tooltip is shown |
| `afterHide` | `function` | no | | | A function to be called after the tooltip is hidden |
| `middlewares` | `Middleware[]` | no | | array of valid `floating-ui` middlewares | Allows for advanced customization. Check the [`floating-ui` docs](https://floating-ui.com/docs/middleware) for more information |
| `border` | `CSSProperties['border']` | no | | a CSS border style | Change the style of the tooltip border (including the arrow) |

### Envs

Expand Down
3 changes: 3 additions & 0 deletions src/components/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const Tooltip = ({
setIsOpen,
activeAnchor,
setActiveAnchor,
border,
}: ITooltip) => {
const tooltipRef = useRef<HTMLElement>(null)
const tooltipArrowRef = useRef<HTMLElement>(null)
Expand Down Expand Up @@ -237,6 +238,7 @@ const Tooltip = ({
tooltipArrowReference: tooltipArrowRef.current,
strategy: positionStrategy,
middlewares,
border,
}).then((computedStylesData) => {
if (Object.keys(computedStylesData.tooltipStyles).length) {
setInlineStyles(computedStylesData.tooltipStyles)
Expand Down Expand Up @@ -503,6 +505,7 @@ const Tooltip = ({
tooltipArrowReference: tooltipArrowRef.current,
strategy: positionStrategy,
middlewares,
border,
}).then((computedStylesData) => {
if (!mounted.current) {
// invalidate computed positions after remount
Expand Down
1 change: 1 addition & 0 deletions src/components/Tooltip/TooltipTypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,5 @@ export interface ITooltip {
afterHide?: () => void
activeAnchor: HTMLElement | null
setActiveAnchor: (anchor: HTMLElement | null) => void
border?: CSSProperties['border']
}
19 changes: 19 additions & 0 deletions src/components/TooltipController/TooltipController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
} from 'components/Tooltip/TooltipTypes'
import { useTooltip } from 'components/TooltipProvider'
import { TooltipContent } from 'components/TooltipContent'
import { cssAttrIsValid } from 'utils/css-attr-is-valid'
import type { ITooltipController } from './TooltipControllerTypes'

const TooltipController = ({
Expand Down Expand Up @@ -44,6 +45,7 @@ const TooltipController = ({
style,
position,
isOpen,
border,
setIsOpen,
afterShow,
afterHide,
Expand Down Expand Up @@ -235,6 +237,22 @@ const TooltipController = ({
}
}, [anchorRefs, providerActiveAnchor, activeAnchor, anchorId, anchorSelect])

useEffect(() => {
if (process.env.NODE_ENV === 'production') {
return
}
if (style?.border) {
// eslint-disable-next-line no-console
console.warn('[react-tooltip] Do not set `style.border`. Use `border` prop instead.')
}
if (border && !cssAttrIsValid('border', border)) {
// eslint-disable-next-line no-console
console.warn(
`[react-tooltip] "${border}" is not a valid \`border\`. See https://developer.mozilla.org/en-US/docs/Web/CSS/border`,
)
}
}, [])

/**
* content priority: children < render or content < html
* children should be lower priority so that it can be used as the "default" content
Expand Down Expand Up @@ -283,6 +301,7 @@ const TooltipController = ({
style,
position,
isOpen,
border,
setIsOpen,
afterShow,
afterHide,
Expand Down
7 changes: 7 additions & 0 deletions src/components/TooltipController/TooltipControllerTypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ export interface ITooltipController {
style?: CSSProperties
position?: IPosition
isOpen?: boolean
/**
* @description see https://developer.mozilla.org/en-US/docs/Web/CSS/border.
*
* Adding a border with width > 3px, or with `em/cm/rem/...` instead of `px`
* might break the tooltip arrow positioning.
*/
border?: CSSProperties['border']
setIsOpen?: (value: boolean) => void
afterShow?: () => void
afterHide?: () => void
Expand Down
2 changes: 2 additions & 0 deletions src/utils/compute-positions-types.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CSSProperties } from 'react'
import type { Middleware } from '../components/Tooltip/TooltipTypes'

export interface IComputePositions {
Expand All @@ -20,4 +21,5 @@ export interface IComputePositions {
offset?: number
strategy?: 'absolute' | 'fixed'
middlewares?: Middleware[]
border?: CSSProperties['border']
}
28 changes: 26 additions & 2 deletions src/utils/compute-positions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const computeTooltipPosition = async ({
offset: offsetValue = 10,
strategy = 'absolute',
middlewares = [offset(Number(offsetValue)), flip(), shift({ padding: 5 })],
border,
}: IComputePositions) => {
if (!elementReference) {
// elementReference can be null or undefined and we will not compute the position
Expand All @@ -31,7 +32,7 @@ export const computeTooltipPosition = async ({
strategy,
middleware,
}).then(({ x, y, placement, middlewareData }) => {
const styles = { left: `${x}px`, top: `${y}px` }
const styles = { left: `${x}px`, top: `${y}px`, border }

const { x: arrowX, y: arrowY } = middlewareData.arrow ?? { x: 0, y: 0 }

Expand All @@ -43,12 +44,35 @@ export const computeTooltipPosition = async ({
left: 'right',
}[placement.split('-')[0]] ?? 'bottom'

const borderSide =
border &&
{
top: { borderBottom: border, borderRight: border },
right: { borderBottom: border, borderLeft: border },
bottom: { borderTop: border, borderLeft: border },
left: { borderTop: border, borderRight: border },
}[placement.split('-')[0]]

let borderWidth = 0
if (border) {
const match = `${border}`.match(/(\d+)px/)
if (match?.[1]) {
borderWidth = Number(match[1])
} else {
/**
* this means `border` was set without `width`, or non-px value
*/
borderWidth = 1
}
}

const arrowStyle = {
left: arrowX != null ? `${arrowX}px` : '',
top: arrowY != null ? `${arrowY}px` : '',
right: '',
bottom: '',
[staticSide]: '-4px',
...borderSide,
[staticSide]: `-${4 + borderWidth}px`,
}

return { tooltipStyles: styles, tooltipArrowStyles: arrowStyle, place: placement }
Expand Down
25 changes: 25 additions & 0 deletions src/utils/css-attr-is-valid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export const cssAttrIsValid = (attr: string, value: unknown) => {
const iframe = document.createElement('iframe')
Object.apply(iframe.style, {
display: 'none',
// in case `display: none` not supported
width: '0px',
height: '0px',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
document.body.appendChild(iframe)
if (!iframe.contentDocument) {
return true
}
const style = iframe.contentDocument.createElement('style')
style.innerHTML = `.test-css { ${attr}: ${value}; }`
iframe.contentDocument.head.appendChild(style)
const { sheet } = style
if (!sheet) {
return true
}
const result = sheet.cssRules[0].cssText
iframe.remove()
const match = result.match(new RegExp(`${attr}:`))
return !!match
}