diff --git a/lib/visual.inc.php b/lib/visual.inc.php index 7b7ef101472ecbb0a6630963b3ba08643e9f6e98..08026064a73e3a96cd99f88d1faa1771ea027eff 100644 --- a/lib/visual.inc.php +++ b/lib/visual.inc.php @@ -596,12 +596,7 @@ function tooltipIcon($text, $important = false, $html = false): string // render tooltip $template = $GLOBALS['template_factory']->open('shared/tooltip'); - return $template->render([ - 'text' => $text, - 'important' => $important, - 'html' => $html, - 'tooltip_id' => md5($text) - ]); + return $template->render(compact('text', 'important', 'html')); } /** @@ -613,13 +608,9 @@ function tooltipIcon($text, $important = false, $html = false): string function tooltipHtmlIcon($text, $important = false) { // render tooltip + $html = true; $template = $GLOBALS['template_factory']->open('shared/tooltip'); - return $template->render([ - 'text' => $text, - 'important' => $important, - 'html' => true, - 'tooltip_id' => md5($text) - ]); + return $template->render(compact('text', 'important', 'html')); } /** diff --git a/resources/assets/javascripts/bootstrap/tooltip.js b/resources/assets/javascripts/bootstrap/tooltip.js new file mode 100644 index 0000000000000000000000000000000000000000..4b17cb0604e1f794b138f43df7d47669fea3025c --- /dev/null +++ b/resources/assets/javascripts/bootstrap/tooltip.js @@ -0,0 +1,72 @@ +// Attach global hover handler for tooltips. +// Applies to all elements having a "data-tooltip" attribute. +// Tooltip may be provided in the data-attribute itself or by +// defining a title attribute. The latter is prefered due to +// the obvious accessibility issues. + +let timeout = null; + +STUDIP.Tooltip.threshold = 6; + +$(document).on('mouseenter mouseleave focusin focusout', '[data-tooltip],.tooltip:has(.tooltip-content)', function(event) { + let data = $(this).data(); + + const visible = ['mouseenter', 'focusin'].includes(event.type); + const offset = $(this).offset(); + const x = offset.left + $(this).outerWidth(true) / 2; + const y = offset.top; + const delay = data.tooltipDelay ?? 300; + + let content; + let tooltip; + + if (!data.tooltipObject) { + // If tooltip has not yet been created (first hover), obtain it's + // contents and create the actual tooltip object. + if (!data.tooltip || !$.isPlainObject(data.tooltip)) { + content = $('<div/>').text(data.tooltip || $(this).attr('title')).html(); + } else if (data.tooltip.html !== undefined) { + content = data.tooltip.html; + } else if (data.tooltip.text !== undefined) { + content = data.tooltip.text; + } else { + throw "Invalid content for tooltip via data"; + } + if (!content) { + content = $(this).find('.tooltip-content').remove().html(); + } + $(this).attr('title', null); + $(this).attr('data-tooltip', content); + + tooltip = new STUDIP.Tooltip(x, y, content); + + data.tooltipObject = tooltip; + + if (!this.hasAttribute('aria-label')) { + const div = document.createElement('div'); + div.innerHTML = content; + this.setAttribute('aria-label', div.innerText.trim()); + } + + $(this).on('remove', function() { + tooltip.remove(); + }); + } else if (visible) { + // If tooltip has already been created, update it's position. + // This is neccessary if the surrounding content is scrollable AND has + // been scrolled. Otherwise the tooltip would appear at it's previous + // and now wrong location. + data.tooltipObject.position(x, y); + } + + if (visible) { + $('.studip-tooltip').not(data.tooltipObject).hide(); + data.tooltipObject.show(); + } else { + timeout = setTimeout(() => data.tooltipObject.hide(), delay); + } +}).on('mouseenter focusin', '.studip-tooltip', () => { + clearTimeout(timeout); +}).on('mouseleave focusout', '.studip-tooltip', function() { + $(this).hide(); +}); diff --git a/resources/assets/javascripts/entry-base.js b/resources/assets/javascripts/entry-base.js index 5f88c3069ba8f098cad208ad8221efd838bd01ce..f551c6dadbb67502633b14d76afb7b61e2cf1722 100644 --- a/resources/assets/javascripts/entry-base.js +++ b/resources/assets/javascripts/entry-base.js @@ -57,6 +57,7 @@ import "./bootstrap/article.js" import "./bootstrap/copyable_links.js" import "./bootstrap/selection.js" import "./bootstrap/data_secure.js" +import "./bootstrap/tooltip.js" import "./bootstrap/lightbox.js" import "./bootstrap/application.js" import "./bootstrap/global_search.js" diff --git a/resources/assets/javascripts/init.js b/resources/assets/javascripts/init.js index 1d7d5ac2ee21e5da0949c2aa09c86aed75b00aa6..3a8402e4acaf65964edb944f532c7b41d6354b9d 100644 --- a/resources/assets/javascripts/init.js +++ b/resources/assets/javascripts/init.js @@ -75,6 +75,7 @@ import Statusgroups from './lib/statusgroups.js'; import study_area_selection from './lib/study_area_selection.js'; import Table from './lib/table.js'; import TableOfContents from './lib/table-of-contents.js'; +import Tooltip from './lib/tooltip.js'; import Tour from './lib/tour.js'; import * as Gettext from './lib/gettext'; import UserFilter from './lib/user_filter.js'; @@ -160,6 +161,7 @@ window.STUDIP = _.assign(window.STUDIP || {}, { study_area_selection, Table, TableOfContents, + Tooltip, Tour, URLHelper, UserFilter, diff --git a/resources/assets/javascripts/lib/tooltip.js b/resources/assets/javascripts/lib/tooltip.js new file mode 100644 index 0000000000000000000000000000000000000000..2cdac27be88aaa69b6fa0cdba5cfae2af8e9c826 --- /dev/null +++ b/resources/assets/javascripts/lib/tooltip.js @@ -0,0 +1,227 @@ +import CSS from './css.js'; + +/** + * Tooltip library for Stud.IP + * + * @author Jan-Hendrik Willms <tleilax+studip@gmail.com> + * @copyright Stud.IP Core Group 2014 + * @license GPL2 or any later version + * @since Stud.IP 3.1 + */ + +let count = 0; +let threshold = 0; + +class Tooltip { + static get count() { + return count; + } + + static set count(value) { + count = value; + } + + // Threshold used for "edge detection" (imagine a padding along the edges) + static get threshold() { + return threshold; + } + + static set threshold(value) { + threshold = value; + } + + /** + * Returns a new unique id of a tooltip. + * + * @return {string} Unique id + * @static + */ + static getId() { + const id = `studip-tooltip-${Tooltip.count}`; + Tooltip.count += 1; + return id; + } + + /** + * Constructs a new tooltip at given location with given content. + * The applied css class may be changed by the fourth parameter. + * + * @class + * @classdesc Stud.IP tooltips provide an improved layout and handling + * of contents (including html) than the browser's default + * tooltip through title attribute would + * + * @param {int} x - Horizontal position of the tooltip + * @param {int} y - Vertical position of the tooltip + * @param {string} content - Content of the tooltip (may be html) + * @param {string} css_class - Optional name of the applied css class / + * defaults to 'studip-tooltip' + */ + constructor(x, y, content, css_class) { + // Obtain unique id of the tooltip + this.id = Tooltip.getId(); + + // Create dom element of the tooltip, apply id and class and attach + // to dom + this.element = $('<div>'); + this.element.addClass(css_class || 'studip-tooltip'); + this.element.attr('id', this.id); + this.element.attr('role', 'tooltip'); + this.element.appendTo('body'); + + // Set position and content and paint the tooltip + this.position(x, y); + this.update(content); + this.paint(); + } + + /** + * Translates the arrow(s) under a tooltip using css3 translate + * transforms. This is needed at the edges of the screen. + * This implies that a current browser is used. The translation could + * also be achieved by adjusting margins but that way we would need + * to hardcode values into this function since it's a struggle to + * obtain the neccessary values from the CSS pseudo selectors in JS. + * + * Internal, css rules are dynamically created and applied to the current + * document by using the methods provided in the file studip-css.js. + * + * @param {int} x - Horizontal offset + * @param {int} y - Vertical offset + */ + translateArrows(x, y, left_arrow = false) { + CSS.removeRule(`#${this.id}::before`); + CSS.removeRule(`#${this.id}::after`); + + if (x !== 0 || y !== 0) { + let before_rule = { + transform: `translate(${x}px, ${y}px);` + }; + if (left_arrow) { + before_rule.transform = `translate(${x}px, ${y}px) rotate(90deg);`; + } + let after_rule = before_rule; + if (left_arrow) { + after_rule['border-width'] = '9px'; + } + CSS.addRule(`#${this.id}::before`, before_rule, ['-ms-', '-webkit-']); + CSS.addRule(`#${this.id}::after`, after_rule, ['-ms-', '-webkit-']); + } + } + + /** + * Updates the position of the tooltip. + * + * @param {int} x - Horizontal position of the tooltip + * @param {int} y - Vertical position of the tooltip + */ + position(x, y) { + this.x = x; + this.y = y; + } + + /** + * Updates the contents of the tooltip. + * + * @param {string} content - Content of the tooltip (may be html) + */ + update(content) { + this.element.html(content); + } + + /** + * "Paints" the tooltip. This method actually computes the dimensions of + * the tooltips, checks for screen edges and calculates the actual offset + * in the current document. + * This method is neccessary due to the fact that position and content + * can be changed apart from each other. + * Thus: Don't forget to repaint after adjusting any of the two. + */ + paint() { + const width = this.element.outerWidth(true); + const height = this.element.outerHeight(true); + const maxWidth = $(document).width(); + const maxHeight = $(document).height(); + let x = this.x - width / 2; + let y = this.y - height; + //The arrow offset is the offset from the bottom right corner of + //the tooltip "frame". + let arrow_offset_x = 0; + let arrow_offset_y = 0; + let left_arrow = false; + + if (y < 0) { + y = 0; + x = this.x + 20; + //Put the arrow on the left side and move the tooltip, + //if there is still enough place left on the right. + left_arrow = true; + arrow_offset_y = -height + this.y + 10; + if (arrow_offset_y > -20) { + y+= arrow_offset_y + 20; + arrow_offset_y = -20; + } + arrow_offset_x = -width / 2 - 8; + } else if (y + height > maxHeight) { + y = maxHeight - height; + } + + if (x < 0) { + arrow_offset_x = 0; + x = 0; + } else if (x + width > maxWidth) { + arrow_offset_x = x + width - maxWidth; + x = maxWidth - width; + } + this.translateArrows(arrow_offset_x, arrow_offset_y, left_arrow); + + this.element.css({ + left: x, + top: y + }); + } + + /** + * Toggles the visibility of the tooltip. If no state is provided, + * the tooltip will be hidden if visible and vice versa. Pretty straight + * forward and no surprises here. + * This method implicitely calls paint before a tooltip is shown (in case + * it was forgotten). + * + * @param {bool} visible - Optional visibility parameter to set the + * tooltip to a certain state + */ + toggle(visible) { + if (visible) { + this.paint(); + } + this.element.toggle(visible); + } + + /** + * Reveals the tooltip. + * + * @see Tooltip.toggle + */ + show() { + this.toggle(true); + } + + /** + * Hides the tooltip. + * + * @see Tooltip.toggle + */ + hide() { + this.toggle(false); + } + + /** + * Removes the tooltip + */ + remove() { + this.element.remove(); + } +} + +export default Tooltip; diff --git a/resources/assets/stylesheets/scss/tooltip.scss b/resources/assets/stylesheets/scss/tooltip.scss index ac570a86388920572befecd8ad2fc993de08099e..873eeede2382ebe456416652b1bdd73d6b7d1b5a 100644 --- a/resources/assets/stylesheets/scss/tooltip.scss +++ b/resources/assets/stylesheets/scss/tooltip.scss @@ -7,7 +7,7 @@ box-shadow: 0 1px 0 fade-out($white, 0.5) inset; font-size: var(--font-size-base); margin-bottom: 8px; - max-width: $grid-element-width; + max-width: 230px; padding: 10px; position: absolute; text-align: left; @@ -24,6 +24,7 @@ } .tooltip { + cursor: help; display: inline-block; position: relative; @@ -38,13 +39,14 @@ @extend %tooltip; display: none; } - - &:hover .tooltip-content, - &:focus .tooltip-content { - bottom: 100%; - display: inline-block; - left: 50%; - margin-left: - calc($grid-element-width / 2) - 10px; - width: $grid-element-width; + &:hover, + &:focus { + .tooltip-content { + bottom: 100%; + display: inline-block; + left: 50%; + margin-left: -129px; + width: 230px; + } } } diff --git a/resources/vue/components/StudipTooltipIcon.vue b/resources/vue/components/StudipTooltipIcon.vue index 30d203345224287ded158beeb5353ade4632dc26..ac0f59c6cf8e3961ad9d4644f43ccf8372e19c9a 100644 --- a/resources/vue/components/StudipTooltipIcon.vue +++ b/resources/vue/components/StudipTooltipIcon.vue @@ -1,7 +1,7 @@ <template> - <span data-tooltip class="tooltip tooltip-icon" :class="cssClass" :title="title" tabindex="0"> + <span data-tooltip class="tooltip tooltip-icon" :class="cssClass" tabindex="0" :aria-label="cleanText"> <span class="tooltip-content" v-if="isHtml" v-html="text"></span> - <studip-icon shape="info-circle" role="inactive" :size="size"></studip-icon> + <span class="tooltip-content" v-else>{{ text }}</span> </span> </template> @@ -26,21 +26,14 @@ } }, computed: { + cleanText() { + const div = document.createElement('div'); + div.innerHTML = this.text; + return div.innerText.trim(); + }, cssClass () { return this.isImportant ? 'tooltip-important' : ''; - }, - title () { - return !this.isHtml ? this.text : ''; } } } </script> - -<style lang="scss" scoped> -.tooltip img { - vertical-align: text-bottom; -} -.tooltip.tooltip-icon::before { - display: none; -} -</style> diff --git a/templates/shared/tooltip.php b/templates/shared/tooltip.php index 513700f6f20cc9c77c66e5ba89e09f84f0164baa..207eaa2779c3e4d8b699997eb62d17594c62c670 100644 --- a/templates/shared/tooltip.php +++ b/templates/shared/tooltip.php @@ -5,7 +5,10 @@ * @var string $text */ ?> -<span class="tooltip tooltip-icon <? if ($important) echo 'tooltip-important'; ?>" - tabindex="0" aria-label="<?= $html ? htmlReady(strip_tags($text)) : htmlReady($text) ?>"> +<span class="as-link tooltip tooltip-icon <? if ($important) echo 'tooltip-important'; ?>" + tabindex="0" + data-tooltip + aria-label="<?= htmlReady($html ? strip_tags($text) : $text) ?>" +> <span class="tooltip-content"><?= $html ? $text : htmlReady($text) ?></span> </span>