Skip to content

Commit

Permalink
Modify direct child instead of using container element
Browse files Browse the repository at this point in the history
Instead of using a container to house the tooltip, we'll now modify the
first direct child of the Tooltip component.

The Tooltip component will ensure that:
- children are passed to it
- only one child is present
- that child is an actual HTML Element and not a text node, or similar
- that child is currently present in the DOM

Only after all of the above are satisfied, will the tooltip be created
on that element. We store a reference to the DOM node that the tooltip
should be created on, then use this to perform tooltip actions via
jQuery. If this element gets changes (e.g. the tooltip content is
updated to another element) then the tooltip will be recreated.

If any of the first 3 requirements are not satisfied, an error will
be thrown to alert the developer to their misuse of this component.

To make this work, we do need to overwrite the title attribute of
the element with the tooltip, but this is the only solution other than
specifying `title` as an option when making the tooltip, but this is
not accessible by screenreaders unless they simulate a hover on the
element.
  • Loading branch information
davwheat committed May 10, 2021
1 parent 3fe6003 commit 22ebd0b
Showing 1 changed file with 102 additions and 35 deletions.
137 changes: 102 additions & 35 deletions js/src/common/components/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,17 @@
import Component, { ComponentAttrs } from '../Component';
import Component from '../Component';
import type Mithril from 'mithril';
import classList from '../utils/classList';
import { TooltipCreationOptions } from '../../../@types/tooltips';
import extractText from '../utils/extractText';

export interface TooltipAttrs extends ComponentAttrs {
export interface TooltipAttrs extends Mithril.CommonAttributes<TooltipAttrs, Tooltip> {
/**
* Tooltip textual content.
*
* String arrays, like those provided by the translator, will be flattened
* into strings.
*/
text: string | string[];
/**
* Defines the type of container to use. Chosen option defines the `display`
* property of the container element in CSS.
*
* Default: `'block'`.
*/
containerType?: 'block' | 'inline' | 'inline-block';
/**
* Manually show tooltip. `false` will show based on cursor events.
*
Expand All @@ -41,8 +34,8 @@ export interface TooltipAttrs extends ComponentAttrs {
* Whether HTML content is allowed in the tooltip.
*
* **Warning:** this is a possible XSS attack vector. This option shouldn't
* be used wherever possible, and will not work when we migrate to CSS-only
* tooltips.
* be used wherever possible, and may not work when we migrate to another
* tooltip library. Be prepared for this to break in Flarum stable.
*
* Default: `false`.
*
Expand All @@ -53,8 +46,8 @@ export interface TooltipAttrs extends ComponentAttrs {
* Sets the delay between a trigger state occurring and the tooltip appearing
* on-screen.
*
* **Warning:** this option will be removed when we switch to CSS-only
* tooltips.
* **Warning:** this option may be removed when switching to another tooltip
* library. Be prepared for this to break in Flarum stable.
*
* Default: `0`.
*
Expand All @@ -71,12 +64,15 @@ export interface TooltipAttrs extends ComponentAttrs {

/**
* The `Tooltip` component is used to create a tooltip for an element. It
* surrounds it with a div (or span) which has the required tooltip setup
* applied.
* requires a single child element to be passed to it. Passing multiple
* children or fragments will throw an error.
*
* You should use this for any tooltips you create to allow for backwards
* compatibility when we switch to pure CSS tooltips instead of Bootstrap
* tooltips.
* compatibility when we switch to another tooltip library instead of
* Bootstrap tooltips.
*
* If you need to pass multiple children, surround them with another element,
* such as a `<span>` or `<div>`.
*
* @example <caption>Basic usage</caption>
* <Tooltip text="You wish!">
Expand All @@ -87,29 +83,43 @@ export interface TooltipAttrs extends ComponentAttrs {
*
* @example <caption>Use of `position` and `showOnFocus` attrs</caption>
* <Tooltip text="Woah! That's cool!" position="bottom" showOnFocus>
* <div>3 replies</div>
* <span>3 replies</span>
* </Tooltip>
*
* @example <caption>Incorrect usage</caption>
* // This is wrong! Surround the children with a <span> or similar.
* <Tooltip text="This won't work">
* Click
* <a href="/">here</a>
* </Tooltip>
*/
export default class Tooltip extends Component<TooltipAttrs> {
private firstChild: Mithril.Vnode<any, any> | null = null;
private childDomNode: HTMLElement | null = null;

private oldText: string = '';
private oldVisibility: boolean | undefined;

private shouldRecreateTooltip: boolean = false;
private shouldChangeTooltipVisibility: boolean = false;

view(vnode) {
const { children } = vnode;
view(vnode: Mithril.Vnode<TooltipAttrs, this>) {
/**
* We know this will be a ChildArray and not a primitive as this
* vnode is a component, not a text or trusted HTML vnode.
*/
const children = vnode.children as Mithril.ChildArray | undefined;

// We remove these to get the remaining attrs to pass to the DOM element
const { text, tooltipVisible, showOnFocus = true, position = 'top', ignoreTitleWarning = false, html = false, delay = 0, ...attrs } = this.attrs;

if (this.attrs.title && !ignoreTitleWarning) {
if ((this.attrs as any).title && !ignoreTitleWarning) {
console.warn(
'`title` attribute was passed to Tooltip component. Was this intentional? Tooltip content should be passed to the `text` attr instead.'
);
}

const realText = Array.isArray(text) ? extractText(text) : text;
const realText = this.getRealText();

// We need to recreate the tooltip if the text has changed
if (realText !== this.oldText) {
Expand All @@ -122,28 +132,70 @@ export default class Tooltip extends Component<TooltipAttrs> {
this.shouldChangeTooltipVisibility = true;
}

return (
<div title={realText} className={classList('tooltip-container', `tooltip-container--${containerType}`, className, classes)} {...attrs}>
{children}
</div>
);
// We'll try our best to detect any issues created by devs before they cause any weird effects.
// Throwing an error will prevent the forum rendering, but will be better at alerting devs to
// an issue.

if (typeof children === 'undefined') {
throw new Error(
`Tooltip component was provided with no direct child DOM element. Tooltips must contain a single direct DOM node to attach to.`
);
}

if (children.length !== 1) {
throw new Error(
`Tooltip component was either passed more than one or no child node.\n\nPlease wrap multiple children in another element, such as a <div> or <span>.`
);
}

const firstChild = children[0];

if (typeof firstChild !== 'object' || Array.isArray(firstChild) || firstChild === null) {
throw new Error(
`Tooltip component was provided with no direct child DOM element. Tooltips must contain a single direct DOM node to attach to.`
);
}

if (typeof firstChild.tag === 'string' && ['#', '[', '<'].includes(firstChild.tag)) {
throw new Error(
`Tooltip component with provided with a vnode with tag "${firstChild.tag}". This is not a DOM element, so is not a valid child element. Please wrap this vnode in another element, such as a <div> or <span>.`
);
}

this.firstChild = firstChild;

return children;
}

oncreate(vnode: Mithril.VnodeDOM<TooltipAttrs, this>) {
super.oncreate(vnode);

const domNode = (this.firstChild as Mithril.VnodeDOM<any, any>).dom as HTMLElement;

if (!domNode.isSameNode(this.childDomNode)) {
this.childDomNode = domNode;
this.shouldRecreateTooltip = true;
}

this.recreateTooltip();
}

onupdate(vnode: Mithril.VnodeDOM<TooltipAttrs, this>) {
super.onupdate(vnode);

const domNode = (this.firstChild as Mithril.VnodeDOM<any, any>).dom as HTMLElement;

if (!domNode.isSameNode(this.childDomNode)) {
this.childDomNode = domNode;
this.shouldRecreateTooltip = true;
}

this.recreateTooltip();
}

private recreateTooltip() {
if (this.shouldRecreateTooltip) {
this.$().tooltip(
if (this.shouldRecreateTooltip && this.childDomNode !== null) {
$(this.childDomNode).tooltip(
'destroy',
// @ts-expect-error We don't want this arg to be part of the public API. It only exists to prevent deprecation warnings when using `$.tooltip` in this component.
'DANGEROUS_tooltip_jquery_fn_deprecation_exempt'
Expand All @@ -159,14 +211,16 @@ export default class Tooltip extends Component<TooltipAttrs> {
}

private updateVisibility() {
if (this.childDomNode === null) return;

if (this.attrs.tooltipVisible === true) {
this.$().tooltip(
$(this.childDomNode).tooltip(
'show',
// @ts-expect-error We don't want this arg to be part of the public API. It only exists to prevent deprecation warnings when using `$.tooltip` in this component.
'DANGEROUS_tooltip_jquery_fn_deprecation_exempt'
);
} else if (this.attrs.tooltipVisible === false) {
this.$().tooltip(
$(this.childDomNode).tooltip(
'hide',
// @ts-expect-error We don't want this arg to be part of the public API. It only exists to prevent deprecation warnings when using `$.tooltip` in this component.
'DANGEROUS_tooltip_jquery_fn_deprecation_exempt'
Expand All @@ -175,21 +229,28 @@ export default class Tooltip extends Component<TooltipAttrs> {
}

private createTooltip() {
if (this.childDomNode === null) return;

const {
showOnFocus = true,
position = 'top',
delay,
// This will have no effect when switching to CSS tooltips
html = false,
tooltipVisible,
text,
} = this.attrs;

const trigger = (typeof tooltipVisible === 'boolean'
? 'manual'
: classList('hover', [showOnFocus && 'focus'])) as TooltipCreationOptions['trigger'];
const trigger = (
typeof tooltipVisible === 'boolean' ? 'manual' : classList('hover', [showOnFocus && 'focus'])
) as TooltipCreationOptions['trigger'];

const realText = this.getRealText();
this.childDomNode.setAttribute('title', realText);
this.childDomNode.setAttribute('aria-label', realText);

// https://getbootstrap.com/docs/3.3/javascript/#tooltips-options
this.$().tooltip(
$(this.childDomNode).tooltip(
{
html,
delay,
Expand All @@ -201,4 +262,10 @@ export default class Tooltip extends Component<TooltipAttrs> {
'DANGEROUS_tooltip_jquery_fn_deprecation_exempt'
);
}

private getRealText(): string {
const { text } = this.attrs;

return Array.isArray(text) ? extractText(text) : text;
}
}

0 comments on commit 22ebd0b

Please sign in to comment.