Bug 1873833 - Make tooltips work on anonymous content / UA widgets. r=smaug,Gijs

We also simplify the tooltip text provider loop, and make it work better
now that e.g. the anonymous button in file inputs can be targeted.

Differential Revision: https://phabricator.services.mozilla.com/D203759
This commit is contained in:
Emilio Cobos Álvarez 2024-03-13 11:30:57 +00:00
parent 18cbc7bb3b
commit d7f3627404
6 changed files with 205 additions and 153 deletions

View file

@ -1177,7 +1177,7 @@ nsresult ChromeTooltipListener::MouseMove(Event* aMouseEvent) {
}
if (!mShowingTooltip) {
if (nsCOMPtr<EventTarget> eventTarget = aMouseEvent->GetComposedTarget()) {
if (nsCOMPtr<EventTarget> eventTarget = aMouseEvent->GetOriginalTarget()) {
mPossibleTooltipNode = nsINode::FromEventTarget(eventTarget);
}

View file

@ -80,16 +80,9 @@ void nsXULTooltipListener::MouseOut(Event* aEvent) {
// check to see if the mouse left the targetNode, and if so,
// hide the tooltip
if (currentTooltip) {
// which node did the mouse leave?
EventTarget* eventTarget = aEvent->GetComposedTarget();
nsCOMPtr<nsINode> targetNode = nsINode::FromEventTargetOrNull(eventTarget);
if (targetNode && targetNode->IsContent() &&
!targetNode->AsContent()->GetContainingShadow()) {
eventTarget = aEvent->GetTarget();
}
nsXULPopupManager* pm = nsXULPopupManager::GetInstance();
if (pm) {
nsCOMPtr<nsINode> targetNode =
nsINode::FromEventTargetOrNull(aEvent->GetOriginalTarget());
if (nsXULPopupManager* pm = nsXULPopupManager::GetInstance()) {
nsCOMPtr<nsINode> tooltipNode =
pm->GetLastTriggerTooltipNode(currentTooltip->GetComposedDoc());
@ -99,8 +92,7 @@ void nsXULTooltipListener::MouseOut(Event* aEvent) {
// tooltip appears positioned near the mouse.
nsCOMPtr<EventTarget> relatedTarget =
aEvent->AsMouseEvent()->GetRelatedTarget();
nsIContent* relatedContent =
nsIContent::FromEventTargetOrNull(relatedTarget);
auto* relatedContent = nsIContent::FromEventTargetOrNull(relatedTarget);
if (tooltipNode == targetNode && relatedContent != currentTooltip) {
HideTooltip();
// reset special tree tracking
@ -134,12 +126,12 @@ void nsXULTooltipListener::MouseMove(Event* aEvent) {
}
nsCOMPtr<nsIContent> currentTooltip = do_QueryReferent(mCurrentTooltip);
nsCOMPtr<EventTarget> eventTarget = aEvent->GetComposedTarget();
nsIContent* content = nsIContent::FromEventTargetOrNull(eventTarget);
auto* const mouseMoveTarget =
nsIContent::FromEventTargetOrNull(aEvent->GetOriginalTarget());
bool isSameTarget = true;
nsCOMPtr<nsIContent> tempContent = do_QueryReferent(mPreviousMouseMoveTarget);
if (tempContent && tempContent != content) {
if (tempContent && tempContent != mouseMoveTarget) {
isSameTarget = false;
}
@ -154,13 +146,15 @@ void nsXULTooltipListener::MouseMove(Event* aEvent) {
return;
}
mMouseScreenPoint = newMouseScreenPoint;
mPreviousMouseMoveTarget = do_GetWeakReference(content);
mPreviousMouseMoveTarget = do_GetWeakReference(mouseMoveTarget);
nsCOMPtr<nsIContent> sourceContent =
do_QueryInterface(aEvent->GetCurrentTarget());
auto* const sourceContent =
nsIContent::FromEventTargetOrNull(aEvent->GetCurrentTarget());
mSourceNode = do_GetWeakReference(sourceContent);
mIsSourceTree = sourceContent->IsXULElement(nsGkAtoms::treechildren);
if (mIsSourceTree) CheckTreeBodyMove(mouseEvent);
if (mIsSourceTree) {
CheckTreeBodyMove(mouseEvent);
}
// as the mouse moves, we want to make sure we reset the timer to show it,
// so that the delay is from when the mouse stops moving, not when it enters
@ -192,8 +186,7 @@ void nsXULTooltipListener::MouseMove(Event* aEvent) {
kNameSpaceID_None, nsGkAtoms::popupsinherittooltip,
nsGkAtoms::_true, eCaseMatters));
if (!allowTooltipCrossingPopup) {
for (nsIContent* targetContent =
nsIContent::FromEventTargetOrNull(eventTarget);
for (auto* targetContent = mouseMoveTarget;
targetContent && targetContent != sourceContent;
targetContent = targetContent->GetFlattenedTreeParent()) {
if (targetContent->IsAnyOfXULElements(
@ -204,7 +197,7 @@ void nsXULTooltipListener::MouseMove(Event* aEvent) {
}
}
mTargetNode = do_GetWeakReference(eventTarget);
mTargetNode = do_GetWeakReference(mouseMoveTarget);
if (mTargetNode) {
nsresult rv = NS_NewTimerWithFuncCallback(
getter_AddRefs(mTooltipTimer), sTooltipCallback, this,
@ -218,7 +211,9 @@ void nsXULTooltipListener::MouseMove(Event* aEvent) {
return;
}
if (mIsSourceTree) return;
if (mIsSourceTree) {
return;
}
// Hide the tooltip if it is currently showing.
if (currentTooltip) {
HideTooltip();
@ -505,7 +500,9 @@ static void GetImmediateChild(nsIContent* aContent, nsAtom* aTag,
nsresult nsXULTooltipListener::FindTooltip(nsIContent* aTarget,
nsIContent** aTooltip) {
if (!aTarget) return NS_ERROR_NULL_POINTER;
if (!aTarget) {
return NS_ERROR_NULL_POINTER;
}
// before we go on, make sure that target node still has a window
Document* document = aTarget->GetComposedDoc();

View file

@ -4,6 +4,37 @@
export function TooltipTextProvider() {}
function getFileInputTitleText(tipElement) {
let files = tipElement.files;
let bundle = Services.strings.createBundle(
"chrome://global/locale/layout/HtmlForm.properties"
);
if (!files.length) {
return bundle.GetStringFromName(
tipElement.multiple ? "NoFilesSelected" : "NoFileSelected"
);
}
let titleText = files[0].name;
// For UX and performance (jank) reasons we cap the number of
// files that we list in the tooltip to 20 plus a "and xxx more"
// line, or to 21 if exactly 21 files were picked.
const TRUNCATED_FILE_COUNT = 20;
let count = Math.min(files.length, TRUNCATED_FILE_COUNT);
for (let i = 1; i < count; ++i) {
titleText += "\n" + files[i].name;
}
if (files.length == TRUNCATED_FILE_COUNT + 1) {
titleText += "\n" + files[TRUNCATED_FILE_COUNT].name;
} else if (files.length > TRUNCATED_FILE_COUNT + 1) {
const l10n = new Localization(["toolkit/global/htmlForm.ftl"], true);
const andXMoreStr = l10n.formatValueSync("input-file-and-more-files", {
fileCount: files.length - TRUNCATED_FILE_COUNT,
});
titleText += "\n" + andXMoreStr;
}
return titleText;
}
TooltipTextProvider.prototype = {
getNodeText(tipElement, textOut, directionOut) {
// Don't show the tooltip if the tooltip node is a document or browser.
@ -29,89 +60,22 @@ TooltipTextProvider.prototype = {
var titleText = null;
var XLinkTitleText = null;
var SVGTitleText = null;
var XULtooltiptextText = null;
var lookingForSVGTitle = true;
var direction = tipElement.ownerDocument.dir;
// If the element is invalid per HTML5 Forms specifications and has no title,
// show the constraint validation error message.
if (
(defView.HTMLInputElement.isInstance(tipElement) ||
defView.HTMLTextAreaElement.isInstance(tipElement) ||
defView.HTMLSelectElement.isInstance(tipElement) ||
defView.HTMLButtonElement.isInstance(tipElement)) &&
!tipElement.hasAttribute("title") &&
(!tipElement.form || !tipElement.form.noValidate)
) {
// If the element is barred from constraint validation or valid,
// the validation message will be the empty string.
titleText = tipElement.validationMessage || null;
for (; tipElement; tipElement = tipElement.flattenedTreeParentNode) {
if (tipElement.nodeType != defView.Node.ELEMENT_NODE) {
continue;
}
// If the element is an <input type='file'> without a title, we should show
// the current file selection.
if (
!titleText &&
defView.HTMLInputElement.isInstance(tipElement) &&
tipElement.type == "file" &&
!tipElement.hasAttribute("title")
) {
let files = tipElement.files;
try {
var bundle = Services.strings.createBundle(
"chrome://global/locale/layout/HtmlForm.properties"
);
if (!files.length) {
if (tipElement.multiple) {
titleText = bundle.GetStringFromName("NoFilesSelected");
} else {
titleText = bundle.GetStringFromName("NoFileSelected");
}
} else {
titleText = files[0].name;
// For UX and performance (jank) reasons we cap the number of
// files that we list in the tooltip to 20 plus a "and xxx more"
// line, or to 21 if exactly 21 files were picked.
const TRUNCATED_FILE_COUNT = 20;
let count = Math.min(files.length, TRUNCATED_FILE_COUNT);
for (let i = 1; i < count; ++i) {
titleText += "\n" + files[i].name;
}
if (files.length == TRUNCATED_FILE_COUNT + 1) {
titleText += "\n" + files[TRUNCATED_FILE_COUNT].name;
} else if (files.length > TRUNCATED_FILE_COUNT + 1) {
const l10n = new Localization(
["toolkit/global/htmlForm.ftl"],
true
);
const andXMoreStr = l10n.formatValueSync(
"input-file-and-more-files",
{ fileCount: files.length - TRUNCATED_FILE_COUNT }
);
titleText += "\n" + andXMoreStr;
}
}
} catch (e) {}
}
// Check texts against null so that title="" can be used to undefine a
// title on a child element.
let usedTipElement = null;
while (
tipElement &&
titleText == null &&
XLinkTitleText == null &&
SVGTitleText == null &&
XULtooltiptextText == null
) {
if (tipElement.nodeType == defView.Node.ELEMENT_NODE) {
if (tipElement.namespaceURI == XUL_NS) {
XULtooltiptextText = tipElement.hasAttribute("tooltiptext")
lookingForSVGTitle = false;
// NOTE: getAttribute behaves differently for XUL so we can't rely on
// it returning null, see bug 232598.
titleText = tipElement.hasAttribute("tooltiptext")
? tipElement.getAttribute("tooltiptext")
: null;
} else if (!defView.SVGElement.isInstance(tipElement)) {
lookingForSVGTitle = false;
titleText = tipElement.getAttribute("title");
}
@ -124,37 +88,61 @@ TooltipTextProvider.prototype = {
) {
XLinkTitleText = tipElement.getAttributeNS(XLinkNS, "title");
}
// If the element is invalid per HTML5 Forms specifications and has no title,
// show the constraint validation error message.
if (
titleText == null &&
(defView.HTMLInputElement.isInstance(tipElement) ||
defView.HTMLTextAreaElement.isInstance(tipElement) ||
defView.HTMLSelectElement.isInstance(tipElement) ||
defView.HTMLButtonElement.isInstance(tipElement)) &&
!tipElement.form?.noValidate
) {
// If the element is barred from constraint validation or valid,
// the validation message will be the empty string.
titleText = tipElement.validationMessage || null;
}
// If the element is an <input type='file'> without a title, we should show
// the current file selection.
if (
titleText == null &&
defView.HTMLInputElement.isInstance(tipElement) &&
tipElement.type == "file"
) {
try {
titleText = getFileInputTitleText(tipElement);
} catch (ex) {}
}
if (
lookingForSVGTitle &&
(!defView.SVGElement.isInstance(tipElement) ||
tipElement.parentNode.nodeType == defView.Node.DOCUMENT_NODE)
tipElement.parentNode.nodeType != defView.Node.DOCUMENT_NODE
) {
lookingForSVGTitle = false;
}
if (lookingForSVGTitle) {
for (let childNode of tipElement.childNodes) {
if (defView.SVGTitleElement.isInstance(childNode)) {
SVGTitleText = childNode.textContent;
titleText = childNode.textContent;
break;
}
}
}
usedTipElement = tipElement;
// Check texts against null so that title="" can be used to undefine a
// title on a child element.
if (titleText != null || XLinkTitleText != null) {
break;
}
}
tipElement = tipElement.flattenedTreeParentNode;
}
return [titleText, XLinkTitleText, SVGTitleText, XULtooltiptextText].some(
function (t) {
return [titleText, XLinkTitleText].some(function (t) {
if (t && /\S/.test(t)) {
// Make CRLF and CR render one line break each.
textOut.value = t.replace(/\r\n?/g, "\n");
if (usedTipElement) {
if (tipElement) {
direction = defView
.getComputedStyle(usedTipElement)
.getComputedStyle(tipElement)
.getPropertyValue("direction");
}
@ -163,8 +151,7 @@ TooltipTextProvider.prototype = {
}
return false;
}
);
});
},
classID: Components.ID("{f376627f-0bbc-47b8-887e-fc92574cc91f}"),

View file

@ -13,4 +13,6 @@ support-files = ["xul_tooltiptext.xhtml"]
["browser_input_file_tooltips.js"]
skip-if = ["os == 'win' && os_version == '10.0'"] # Permafail on Win 10 (bug 1400368)
["browser_nac_tooltip.js"]
["browser_shadow_dom_tooltip.js"]

View file

@ -0,0 +1,66 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
/* eslint-disable mozilla/no-arbitrary-setTimeout */
"use strict";
add_setup(async function () {
await SpecialPowers.pushPrefEnv({ set: [["ui.tooltip.delay_ms", 0]] });
});
add_task(async function () {
await BrowserTestUtils.withNewTab(
{
gBrowser,
url: "data:text/html,<!DOCTYPE html>",
},
async function (browser) {
info("Moving mouse out of the way.");
await EventUtils.synthesizeAndWaitNativeMouseMove(browser, 300, 300);
await SpecialPowers.spawn(browser, [], function () {
let widget = content.document.insertAnonymousContent();
widget.root.innerHTML = `<button style="pointer-events: auto; position: absolute; width: 200px; height: 200px;" title="foo">bar</button>`;
let tttp = Cc[
"@mozilla.org/embedcomp/default-tooltiptextprovider;1"
].getService(Ci.nsITooltipTextProvider);
let text = {};
let dir = {};
ok(
tttp.getNodeText(widget.root.querySelector("button"), text, dir),
"A tooltip should be shown for NAC"
);
is(text.value, "foo", "Tooltip text should be correct");
});
let awaitTooltipOpen = new Promise(resolve => {
let tooltipId = Services.appinfo.browserTabsRemoteAutostart
? "remoteBrowserTooltip"
: "aHTMLTooltip";
let tooltip = document.getElementById(tooltipId);
tooltip.addEventListener(
"popupshown",
function (event) {
resolve(event.target);
},
{ once: true }
);
});
info("Initial mouse move");
await EventUtils.synthesizeAndWaitNativeMouseMove(browser, 50, 5);
info("Waiting");
await new Promise(resolve => setTimeout(resolve, 400));
info("Second mouse move");
await EventUtils.synthesizeAndWaitNativeMouseMove(browser, 70, 5);
info("Waiting for tooltip to open");
let tooltip = await awaitTooltipOpen;
is(
tooltip.getAttribute("label"),
"foo",
"tooltip label should match expectation"
);
}
);
});

View file

@ -15,12 +15,12 @@
<box id="parent" tooltiptext="Box Tooltip" style="margin: 10px">
<button id="withtext" label="Tooltip Text" tooltiptext="Button Tooltip"
style="-moz-appearance: none; padding: 0;"/>
<button id="without" label="No Tooltip" style="-moz-appearance: none; padding: 0;"/>
style="appearance: none; padding: 0;"/>
<button id="without" label="No Tooltip" style="appearance: none; padding: 0;"/>
<!-- remove the native theme and borders to avoid some platform
specific sizing differences -->
<button id="withtooltip" label="Tooltip Element" tooltip="thetooltip"
class="plain" style="-moz-appearance: none; padding: 0;"/>
class="plain" style="appearance: none; padding: 0;"/>
</box>
<script class="testbody" type="application/javascript">