1
0
mirror of https://github.com/flarum/core.git synced 2025-05-31 03:29:40 +02:00

Add tooltip component (#2843)

* Add Tooltip component to common

Will be used to provide backwards compatibility when we switch to CSS tooltips.

All other methods of creating tooltips are deprecated and this component-based method should be used instead.

* Modify direct child instead of using container element

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.

* Add warning about component overwriting `title` attr

* Update previous uses of Tooltip component
This commit is contained in:
David Wheatley 2021-05-10 21:06:40 +01:00 committed by GitHub
parent 9bfb7f978d
commit f9e8424620
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 409 additions and 70 deletions

68
js/@types/tooltips/index.d.ts vendored Normal file
View File

@ -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;

11
js/shims.d.ts vendored
View File

@ -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;
}
}

View File

@ -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,

View File

@ -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 <span {...attrs}>{iconName ? icon(iconName, { className: 'Badge-icon' }) : m.trust('&nbsp;')}</span>;
}
const iconChild = iconName ? icon(iconName, { className: 'Badge-icon' }) : m.trust('&nbsp;');
oncreate(vnode) {
super.oncreate(vnode);
const badgeAttrs = {
className,
...attrs,
};
if (this.attrs.label) this.$().tooltip();
const badgeNode = <div {...badgeAttrs}>{iconChild}</div>;
// If we don't have a tooltip label, don't render the tooltip component.
if (!label) return badgeNode;
return <Tooltip text={label}>{badgeNode}</Tooltip>;
}
}

View File

@ -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(),
})
<Tooltip text={app.translator.trans('core.forum.composer.preview_tooltip')}>
<Button icon="far fa-eye" className="Button Button--icon" onclick={this.attrs.preview} />
</Tooltip>
);
}

View File

@ -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 {
view(vnode) {
const originalView = super.view(vnode);
// Steal tooltip label from the Button superclass
const tooltipText = originalView.attrs.title;
delete originalView.attrs.title;
return <Tooltip text={tooltipText}>{originalView}</Tooltip>;
}
static initAttrs(attrs) {
super.initAttrs(attrs);
attrs.className = attrs.className || 'Button Button--icon Button--link';
}
oncreate(vnode) {
super.oncreate(vnode);
this.$().tooltip();
}
}

View File

@ -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<TooltipAttrs, Tooltip> {
/**
* 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 `<span>` or `<div>`.
*
* **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 <caption>Basic usage</caption>
* <Tooltip text="You wish!">
* <Button>
* Click for free money!
* </Button>
* </Tooltip>
*
* @example <caption>Use of `position` and `showOnFocus` attrs</caption>
* <Tooltip text="Woah! That's cool!" position="bottom" showOnFocus>
* <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: 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 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 <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);
this.checkDomNodeChanged();
this.recreateTooltip();
}
onupdate(vnode: Mithril.VnodeDOM<TooltipAttrs, this>) {
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<any, any>).dom as HTMLElement;
if (domNode && !domNode.isSameNode(this.childDomNode)) {
this.childDomNode = domNode;
this.shouldRecreateTooltip = true;
}
}
}

View File

@ -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 `<Tooltip>` 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);
};

View File

@ -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 {
</span>
<div className={'DiscussionListItem-content Slidable-content' + (isUnread ? ' unread' : '') + (isRead ? ' read' : '')}>
<Link
href={user ? app.route.user(user) : '#'}
className="DiscussionListItem-author"
title={extractText(
app.translator.trans('core.forum.discussion_list.started_text', { user: user, ago: humanTime(discussion.createdAt()) })
)}
oncreate={function (vnode) {
$(vnode.dom).tooltip({ placement: 'right' });
}}
<Tooltip
text={app.translator.trans('core.forum.discussion_list.started_text', { user, ago: humanTime(discussion.createdAt()) })}
position="right"
>
{avatar(user, { title: '' })}
</Link>
<Link className="DiscussionListItem-author" href={user ? app.route.user(user) : '#'}>
{avatar(user, { title: '' })}
</Link>
</Tooltip>
<ul className="DiscussionListItem-badges badges">{listItems(discussion.badges().toArray())}</ul>

View File

@ -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 (
<span className="PostEdited" title={editedInfo}>
{app.translator.trans('core.forum.post.edited_text')}
</span>
<Tooltip text={editedInfo}>
<span class="PostEdited">{app.translator.trans('core.forum.post.edited_text')}</span>
</Tooltip>
);
}
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;
}
}
}