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(), - }) + + + * + * + * @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 {
- - {avatar(user, { title: '' })} - + + {avatar(user, { title: '' })} + +
    {listItems(discussion.badges().toArray())}
diff --git a/js/src/forum/components/PostEdited.js b/js/src/forum/components/PostEdited.js index ec00430a32..f93207c365 100644 --- a/js/src/forum/components/PostEdited.js +++ b/js/src/forum/components/PostEdited.js @@ -1,6 +1,6 @@ import Component from '../../common/Component'; import humanTime from '../../common/utils/humanTime'; -import extractText from '../../common/utils/extractText'; +import Tooltip from '../../common/components/Tooltip'; /** * The `PostEdited` component displays information about when and by whom a post @@ -13,43 +13,21 @@ import extractText from '../../common/utils/extractText'; export default class PostEdited extends Component { oninit(vnode) { super.oninit(vnode); - - this.shouldUpdateTooltip = false; - this.oldEditedInfo = null; } view() { const post = this.attrs.post; const editedUser = post.editedUser(); - const editedInfo = extractText(app.translator.trans('core.forum.post.edited_tooltip', { user: editedUser, ago: humanTime(post.editedAt()) })); - if (editedInfo !== this.oldEditedInfo) { - this.shouldUpdateTooltip = true; - this.oldEditedInfo = editedInfo; - } + const editedInfo = app.translator.trans('core.forum.post.edited_tooltip', { user: editedUser, ago: humanTime(post.editedAt()) }); return ( - - {app.translator.trans('core.forum.post.edited_text')} - + + {app.translator.trans('core.forum.post.edited_text')} + ); } oncreate(vnode) { super.oncreate(vnode); - - this.rebuildTooltip(); - } - - onupdate(vnode) { - super.onupdate(vnode); - - this.rebuildTooltip(); - } - - rebuildTooltip() { - if (this.shouldUpdateTooltip) { - this.$().tooltip('destroy').tooltip(); - this.shouldUpdateTooltip = false; - } } }