Skip to content
Snippets Groups Projects
Commit 50371cbb authored by Jan-Hendrik Willms's avatar Jan-Hendrik Willms
Browse files

add aria-label to all tooltips and align usage in vue as well, fixes #4330

Closes #4330

Merge request studip/studip!3132
parent 64e35ff5
No related branches found
No related tags found
No related merge requests found
......@@ -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'));
}
/**
......
// 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();
});
......@@ -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"
......
......@@ -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,
......
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;
......@@ -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;
}
}
}
<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>
......@@ -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>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment