diff --git a/js/@types/tooltips/index.d.ts b/js/@types/tooltips/index.d.ts
new file mode 100644
index 0000000000..bc96a5aefe
--- /dev/null
+++ b/js/@types/tooltips/index.d.ts
@@ -0,0 +1,68 @@
+/**
+ * Selection of options accepted by [Bootstrap's tooltips](https://getbootstrap.com/docs/3.3/javascript/#tooltips-options).
+ *
+ * ---
+ *
+ * Not all options are present from Bootstrap to discourage the use of options
+ * that will be deprecated in the future.
+ *
+ * More commonly used options that will be deprecated remain, but are marked as
+ * such.
+ *
+ * @see https://getbootstrap.com/docs/3.3/javascript/#tooltips-options
+ */
+export interface TooltipCreationOptions {
+ /**
+ * 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.
+ *
+ * @deprecated
+ */
+ html?: boolean;
+ /**
+ * Tooltip position around the target element.
+ */
+ placement?: 'top' | 'bottom' | 'left' | 'right';
+ /**
+ * 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.
+ *
+ * @deprecated
+ */
+ delay?: number;
+ /**
+ * Value used if no `title` attribute is present on the HTML element.
+ *
+ * If a function is given, it will be called with its `this` reference set to
+ * the element that the tooltip is attached to.
+ */
+ title?: string;
+ /**
+ * How the tooltip is triggered.
+ *
+ * Either on `hover`, on `hover focus` (either of the two).
+ *
+ * ---
+ *
+ * **Warning:** `manual`, `click` and `focus` on its own are deprecated options
+ * which will not be supported in the future.
+ */
+ trigger?: 'hover' | 'hover focus';
+}
+
+/**
+ * Creates a tooltip on a jQuery element reference.
+ *
+ * Returns the same jQuery reference to allow for method chaining.
+ */
+export type TooltipJQueryFunction = (tooltipOptions?: TooltipCreationOptions | 'destroy' | 'show' | 'hide') => JQuery;
diff --git a/js/shims.d.ts b/js/shims.d.ts
index 94fcf2c5fd..9a1d9dd822 100644
--- a/js/shims.d.ts
+++ b/js/shims.d.ts
@@ -8,6 +8,8 @@ import * as _$ from 'jquery';
// Globals from flarum/core
import Application from './src/common/Application';
+import type { TooltipJQueryFunction } from './@types/tooltips';
+
/**
* flarum/core exposes several extensions globally:
*
@@ -25,14 +27,7 @@ declare global {
// Extend JQuery with our custom functions, defined with $.fn
interface JQuery {
- /**
- * Creates a tooltip on a jQuery element reference.
- *
- * Optionally accepts placement and delay options.
- *
- * Returns the same reference to allow for method chaining.
- */
- tooltip: (tooltipOptions?: { placement?: 'top' | 'bottom' | 'left' | 'right'; delay?: number }) => JQuery;
+ tooltip: TooltipJQueryFunction;
}
}
diff --git a/js/src/common/compat.js b/js/src/common/compat.js
index c640038caa..5fe1017a95 100644
--- a/js/src/common/compat.js
+++ b/js/src/common/compat.js
@@ -62,6 +62,7 @@ import GroupBadge from './components/GroupBadge';
import TextEditor from './components/TextEditor';
import TextEditorButton from './components/TextEditorButton';
import EditUserModal from './components/EditUserModal';
+import Tooltip from './components/Tooltip';
import Model from './Model';
import Application from './Application';
import fullTime from './helpers/fullTime';
@@ -141,6 +142,7 @@ export default {
'components/GroupBadge': GroupBadge,
'components/TextEditor': TextEditor,
'components/TextEditorButton': TextEditorButton,
+ 'components/Tooltip': Tooltip,
'components/EditUserModal': EditUserModal,
Model: Model,
Application: Application,
diff --git a/js/src/common/components/Badge.js b/js/src/common/components/Badge.js
index d9267fdf32..f52099aee0 100644
--- a/js/src/common/components/Badge.js
+++ b/js/src/common/components/Badge.js
@@ -1,6 +1,7 @@
+import Tooltip from './Tooltip';
import Component from '../Component';
import icon from '../helpers/icon';
-import extract from '../utils/extract';
+import classList from '../utils/classList';
/**
* The `Badge` component represents a user/discussion badge, indicating some
@@ -17,19 +18,22 @@ import extract from '../utils/extract';
*/
export default class Badge extends Component {
view() {
- const attrs = Object.assign({}, this.attrs);
- const type = extract(attrs, 'type');
- const iconName = extract(attrs, 'icon');
+ const { type, icon: iconName, label, ...attrs } = this.attrs;
- attrs.className = 'Badge ' + (type ? 'Badge--' + type : '') + ' ' + (attrs.className || '');
- attrs.title = extract(attrs, 'label') || '';
+ const className = classList('Badge', [type && `Badge--${type}`], attrs.className);
- return {iconName ? icon(iconName, { className: 'Badge-icon' }) : m.trust(' ')};
- }
+ const iconChild = iconName ? icon(iconName, { className: 'Badge-icon' }) : m.trust(' ');
+
+ const badgeAttrs = {
+ className,
+ ...attrs,
+ };
+
+ const badgeNode =
{iconChild}
;
- oncreate(vnode) {
- super.oncreate(vnode);
+ // If we don't have a tooltip label, don't render the tooltip component.
+ if (!label) return badgeNode;
- if (this.attrs.label) this.$().tooltip();
+ return {badgeNode};
}
}
diff --git a/js/src/common/components/TextEditor.js b/js/src/common/components/TextEditor.js
index 105441ce2d..2b457725e8 100644
--- a/js/src/common/components/TextEditor.js
+++ b/js/src/common/components/TextEditor.js
@@ -4,6 +4,7 @@ import listItems from '../helpers/listItems';
import Button from './Button';
import BasicEditorDriver from '../utils/BasicEditorDriver';
+import Tooltip from './Tooltip';
/**
* The `TextEditor` component displays a textarea with controls, including a
@@ -108,13 +109,9 @@ export default class TextEditor extends Component {
if (this.attrs.preview) {
items.add(
'preview',
- Button.component({
- icon: 'far fa-eye',
- className: 'Button Button--icon',
- onclick: this.attrs.preview,
- title: app.translator.trans('core.forum.composer.preview_tooltip'),
- oncreate: (vnode) => $(vnode.dom).tooltip(),
- })
+
+
+
);
}
diff --git a/js/src/common/components/TextEditorButton.js b/js/src/common/components/TextEditorButton.js
index 8e54feafb7..4f379ef400 100644
--- a/js/src/common/components/TextEditorButton.js
+++ b/js/src/common/components/TextEditorButton.js
@@ -1,19 +1,24 @@
import Button from './Button';
+import Tooltip from './Tooltip';
/**
* The `TextEditorButton` component displays a button suitable for the text
* editor toolbar.
*/
export default class TextEditorButton extends Button {
- static initAttrs(attrs) {
- super.initAttrs(attrs);
+ view(vnode) {
+ const originalView = super.view(vnode);
- attrs.className = attrs.className || 'Button Button--icon Button--link';
+ // Steal tooltip label from the Button superclass
+ const tooltipText = originalView.attrs.title;
+ delete originalView.attrs.title;
+
+ return {originalView};
}
- oncreate(vnode) {
- super.oncreate(vnode);
+ static initAttrs(attrs) {
+ super.initAttrs(attrs);
- this.$().tooltip();
+ attrs.className = attrs.className || 'Button Button--icon Button--link';
}
}
diff --git a/js/src/common/components/Tooltip.tsx b/js/src/common/components/Tooltip.tsx
new file mode 100644
index 0000000000..13bc5590da
--- /dev/null
+++ b/js/src/common/components/Tooltip.tsx
@@ -0,0 +1,278 @@
+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 Mithril.CommonAttributes {
+ /**
+ * Tooltip textual content.
+ *
+ * String arrays, like those provided by the translator, will be flattened
+ * into strings.
+ */
+ text: string | string[];
+ /**
+ * Manually show tooltip. `false` will show based on cursor events.
+ *
+ * Default: `false`.
+ */
+ tooltipVisible?: boolean;
+ /**
+ * Whether to show on focus.
+ *
+ * Default: `true`.
+ */
+ showOnFocus?: boolean;
+ /**
+ * Tooltip position around element.
+ *
+ * Default: `'top'`.
+ */
+ position?: 'top' | 'bottom' | 'left' | 'right';
+ /**
+ * 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 may not work when we migrate to another
+ * tooltip library. Be prepared for this to break in Flarum stable.
+ *
+ * Default: `false`.
+ *
+ * @deprecated
+ */
+ html?: boolean;
+ /**
+ * Sets the delay between a trigger state occurring and the tooltip appearing
+ * on-screen.
+ *
+ * **Warning:** this option may be removed when switching to another tooltip
+ * library. Be prepared for this to break in Flarum stable.
+ *
+ * Default: `0`.
+ *
+ * @deprecated
+ */
+ delay?: number;
+ /**
+ * Used to disable the warning for passing text to the `title` attribute.
+ *
+ * Tooltip text should be passed to the `text` attribute.
+ */
+ ignoreTitleWarning?: boolean;
+}
+
+/**
+ * The `Tooltip` component is used to create a tooltip for an element. It
+ * 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 another tooltip library instead of
+ * Bootstrap tooltips.
+ *
+ * If you need to pass multiple children, surround them with another element,
+ * such as a `` or `
`.
+ *
+ * **Note:** this component will overwrite the `title` attribute of the first
+ * child you pass to it, as this is how the current tooltip system works in
+ * Flarum. This shouldn't be an issue if you're using this component correctly.
+ *
+ * @example
Basic usage
+ *
+ *
+ *
+ *
+ * @example
Use of `position` and `showOnFocus` attrs
+ *
+ * 3 replies
+ *
+ *
+ * @example
Incorrect usage
+ * // This is wrong! Surround the children with a or similar.
+ *
+ * Click
+ * here
+ *
+ */
+export default class Tooltip extends Component {
+ private firstChild: Mithril.Vnode | null = null;
+ private childDomNode: HTMLElement | null = null;
+
+ private oldText: string = '';
+ private oldVisibility: boolean | undefined;
+
+ private shouldRecreateTooltip: boolean = false;
+ private shouldChangeTooltipVisibility: boolean = false;
+
+ view(vnode: Mithril.Vnode) {
+ /**
+ * 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 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 = this.getRealText();
+
+ // We need to recreate the tooltip if the text has changed
+ if (realText !== this.oldText) {
+ this.oldText = realText;
+ this.shouldRecreateTooltip = true;
+ }
+
+ if (tooltipVisible !== this.oldVisibility) {
+ this.oldVisibility = this.attrs.tooltipVisible;
+ this.shouldChangeTooltipVisibility = true;
+ }
+
+ // 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
or .`
+ );
+ }
+
+ 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
or .`
+ );
+ }
+
+ this.firstChild = firstChild;
+
+ return children;
+ }
+
+ oncreate(vnode: Mithril.VnodeDOM) {
+ super.oncreate(vnode);
+
+ this.checkDomNodeChanged();
+ this.recreateTooltip();
+ }
+
+ onupdate(vnode: Mithril.VnodeDOM) {
+ super.onupdate(vnode);
+
+ this.checkDomNodeChanged();
+ this.recreateTooltip();
+ }
+
+ private recreateTooltip() {
+ 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'
+ );
+ this.createTooltip();
+ this.shouldRecreateTooltip = false;
+ }
+
+ if (this.shouldChangeTooltipVisibility) {
+ this.shouldChangeTooltipVisibility = false;
+ this.updateVisibility();
+ }
+ }
+
+ private updateVisibility() {
+ if (this.childDomNode === null) return;
+
+ if (this.attrs.tooltipVisible === true) {
+ $(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.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'
+ );
+ }
+ }
+
+ 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 realText = this.getRealText();
+ this.childDomNode.setAttribute('title', realText);
+ this.childDomNode.setAttribute('aria-label', realText);
+
+ // https://getbootstrap.com/docs/3.3/javascript/#tooltips-options
+ $(this.childDomNode).tooltip(
+ {
+ html,
+ delay,
+ placement: position,
+ // Fancy "hack" to assemble the trigger string
+ trigger,
+ },
+ // @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'
+ );
+ }
+
+ private getRealText(): string {
+ const { text } = this.attrs;
+
+ return Array.isArray(text) ? extractText(text) : text;
+ }
+
+ /**
+ * Checks if the tooltip DOM node has changed.
+ *
+ * If it has, it updates `this.childDomNode` to the new node, and sets
+ * `shouldRecreateTooltip` to `true`.
+ */
+ private checkDomNodeChanged() {
+ const domNode = (this.firstChild as Mithril.VnodeDOM).dom as HTMLElement;
+
+ if (domNode && !domNode.isSameNode(this.childDomNode)) {
+ this.childDomNode = domNode;
+ this.shouldRecreateTooltip = true;
+ }
+ }
+}
diff --git a/js/src/common/index.js b/js/src/common/index.js
index c2d576780e..8d3f554a76 100644
--- a/js/src/common/index.js
+++ b/js/src/common/index.js
@@ -25,3 +25,18 @@ import * as Extend from './extend/index';
export { Extend };
import './utils/arrayFlatPolyfill';
+
+const tooltipGen = $.fn.tooltip;
+
+// Remove in a future version of Flarum.
+$.fn.tooltip = function (options, caller) {
+ // Show a warning when `$.tooltip` is used outside of the Tooltip component.
+ // This functionality is deprecated and should not be used.
+ if (!['DANGEROUS_tooltip_jquery_fn_deprecation_exempt'].includes(caller)) {
+ console.warn(
+ "Calling `$.tooltip` is now deprecated. Please use the `` component exposed by flarum/core instead. `$.tooltip` may be removed in a future version of Flarum.\n\nIf this component doesn't meet your requirements, please open an issue: https://github.com/flarum/core/issues/new?assignees=davwheat&labels=type/bug,needs-verification&template=bug-report.md&title=Tooltip%20component%20unsuitable%20for%20use%20case"
+ );
+ }
+
+ tooltipGen.bind(this)(options);
+};
diff --git a/js/src/forum/components/DiscussionListItem.js b/js/src/forum/components/DiscussionListItem.js
index ed1fa3d5c0..52c0958111 100644
--- a/js/src/forum/components/DiscussionListItem.js
+++ b/js/src/forum/components/DiscussionListItem.js
@@ -16,6 +16,7 @@ import extractText from '../../common/utils/extractText';
import classList from '../../common/utils/classList';
import DiscussionPage from './DiscussionPage';
import escapeRegExp from '../../common/utils/escapeRegExp';
+import Tooltip from '../../common/components/Tooltip';
/**
* The `DiscussionListItem` component shows a single discussion in the
@@ -101,18 +102,14 @@ export default class DiscussionListItem extends Component {