Repository: pandas-dev/pandas

Issue:
ENH: Add alternative html for Styler tooltips
### Feature Type

- [ ] Adding new functionality to pandas

- [X] Changing existing functionality in pandas

- [ ] Removing existing functionality in pandas


### Problem Description

Currently `pandas.io.formats.style.Styler.set_tooltips` works by adding a `<span>` tags around `<td>` elements. The `<span>` is hidden by default and upon hover of the corresponding cell in the rendered table, the contents is replaced by that of the `<span>`.

The issue is, if the contents of the `<span>` is too large it is not all shown because it goes outside of the `<td>` element.

### Feature Description

I propose adding an option to `pandas.io.formats.style.Styler.set_tooltips` to instead add the tooltip as a `title` attribute of  `<td title=''>`.  To my understanding the use of `title` attribute is the canonical way to add tooltips to `<td>` elements. 

### Alternative Solutions

Don't use `pandas.io.formats.style.Styler.set_tooltips` and instead manually manipulate the rendered html after the fact with `beautifulsoup4`. 

### Additional Context

_No response_


Discussion:
@attack68 
Using span on hover gives a lot of flexibility to the style of tooltip. I do not believe you get this with the title approach. However that might also have other benefits. Happy to review tjis addition if you propose it, provided it is not the default case.

Patch:
diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst
index 012fe47c476d1..470099c0b5cc4 100644
--- a/doc/source/whatsnew/v3.0.0.rst
+++ b/doc/source/whatsnew/v3.0.0.rst
@@ -30,6 +30,7 @@ Other enhancements
 ^^^^^^^^^^^^^^^^^^
 - :func:`DataFrame.to_excel` now raises an ``UserWarning`` when the character count in a cell exceeds Excel's limitation of 32767 characters (:issue:`56954`)
 - :func:`read_stata` now returns ``datetime64`` resolutions better matching those natively stored in the stata format (:issue:`55642`)
+- :meth:`Styler.set_tooltips` provides alternative method to storing tooltips by using title attribute of td elements. (:issue:`56981`)
 - Allow dictionaries to be passed to :meth:`pandas.Series.str.replace` via ``pat`` parameter (:issue:`51748`)
 -
 
@@ -269,7 +270,6 @@ ExtensionArray
 Styler
 ^^^^^^
 -
--
 
 Other
 ^^^^^
diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py
index 31e025ace4b03..3f607ef63e2f7 100644
--- a/pandas/io/formats/style.py
+++ b/pandas/io/formats/style.py
@@ -424,6 +424,7 @@ def set_tooltips(
         ttips: DataFrame,
         props: CSSProperties | None = None,
         css_class: str | None = None,
+        as_title_attribute: bool = False,
     ) -> Styler:
         """
         Set the DataFrame of strings on ``Styler`` generating ``:hover`` tooltips.
@@ -447,6 +448,9 @@ def set_tooltips(
             Name of the tooltip class used in CSS, should conform to HTML standards.
             Only useful if integrating tooltips with external CSS. If ``None`` uses the
             internal default value 'pd-t'.
+        as_title_attribute : bool, default False
+            Add the tooltip text as title attribute to resultant <td> element. If True
+            then props and css_class arguments are ignored.
 
         Returns
         -------
@@ -475,6 +479,12 @@ def set_tooltips(
         additional HTML for larger tables, since they also require that ``cell_ids``
         is forced to `True`.
 
+        If multiline tooltips are required, or if styling is not required and/or
+        space is of concern, then utilizing as_title_attribute as True will store
+        the tooltip on the <td> title attribute. This will cause no CSS
+        to be generated nor will the <span> elements. Storing tooltips through
+        the title attribute will mean that tooltip styling effects do not apply.
+
         Examples
         --------
         Basic application
@@ -502,6 +512,10 @@ def set_tooltips(
         ...     props="visibility:hidden; position:absolute; z-index:1;",
         ... )
         ... # doctest: +SKIP
+
+        Multiline tooltips with smaller size footprint
+
+        >>> df.style.set_tooltips(ttips, as_title_attribute=True)  # doctest: +SKIP
         """
         if not self.cell_ids:
             # tooltips not optimised for individual cell check. requires reasonable
@@ -516,10 +530,13 @@ def set_tooltips(
         if self.tooltips is None:  # create a default instance if necessary
             self.tooltips = Tooltips()
         self.tooltips.tt_data = ttips
-        if props:
-            self.tooltips.class_properties = props
-        if css_class:
-            self.tooltips.class_name = css_class
+        if not as_title_attribute:
+            if props:
+                self.tooltips.class_properties = props
+            if css_class:
+                self.tooltips.class_name = css_class
+        else:
+            self.tooltips.as_title_attribute = as_title_attribute
 
         return self
 
diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py
index 1cf54dc2cc756..fe03ba519629d 100644
--- a/pandas/io/formats/style_render.py
+++ b/pandas/io/formats/style_render.py
@@ -1979,6 +1979,11 @@ class Tooltips:
     tooltips: DataFrame, default empty
         DataFrame of strings aligned with underlying Styler data for tooltip
         display.
+    as_title_attribute: bool, default False
+        Flag to use title attribute based tooltips (True) or <span> based
+        tooltips (False).
+        Add the tooltip text as title attribute to resultant <td> element. If
+        True, no CSS is generated and styling effects do not apply.
 
     Notes
     -----
@@ -2007,11 +2012,13 @@ def __init__(
         ],
         css_name: str = "pd-t",
         tooltips: DataFrame = DataFrame(),
+        as_title_attribute: bool = False,
     ) -> None:
         self.class_name = css_name
         self.class_properties = css_props
         self.tt_data = tooltips
         self.table_styles: CSSStyles = []
+        self.as_title_attribute = as_title_attribute
 
     @property
     def _class_styles(self):
@@ -2101,35 +2108,53 @@ def _translate(self, styler: StylerRenderer, d: dict):
         if self.tt_data.empty:
             return d
 
-        name = self.class_name
         mask = (self.tt_data.isna()) | (self.tt_data.eq(""))  # empty string = no ttip
-        self.table_styles = [
-            style
-            for sublist in [
-                self._pseudo_css(styler.uuid, name, i, j, str(self.tt_data.iloc[i, j]))
-                for i in range(len(self.tt_data.index))
-                for j in range(len(self.tt_data.columns))
-                if not (
-                    mask.iloc[i, j]
-                    or i in styler.hidden_rows
-                    or j in styler.hidden_columns
-                )
+        # this conditional adds tooltips via pseudo css and <span> elements.
+        if not self.as_title_attribute:
+            name = self.class_name
+            self.table_styles = [
+                style
+                for sublist in [
+                    self._pseudo_css(
+                        styler.uuid, name, i, j, str(self.tt_data.iloc[i, j])
+                    )
+                    for i in range(len(self.tt_data.index))
+                    for j in range(len(self.tt_data.columns))
+                    if not (
+                        mask.iloc[i, j]
+                        or i in styler.hidden_rows
+                        or j in styler.hidden_columns
+                    )
+                ]
+                for style in sublist
             ]
-            for style in sublist
-        ]
-
-        if self.table_styles:
-            # add span class to every cell only if at least 1 non-empty tooltip
-            for row in d["body"]:
-                for item in row:
-                    if item["type"] == "td":
-                        item["display_value"] = (
-                            str(item["display_value"])
-                            + f'<span class="{self.class_name}"></span>'
-                        )
-            d["table_styles"].extend(self._class_styles)
-            d["table_styles"].extend(self.table_styles)
 
+            # add span class to every cell since there is at least 1 non-empty tooltip
+            if self.table_styles:
+                for row in d["body"]:
+                    for item in row:
+                        if item["type"] == "td":
+                            item["display_value"] = (
+                                str(item["display_value"])
+                                + f'<span class="{self.class_name}"></span>'
+                            )
+                d["table_styles"].extend(self._class_styles)
+                d["table_styles"].extend(self.table_styles)
+        # this conditional adds tooltips as extra "title" attribute on a <td> element
+        else:
+            index_offset = self.tt_data.index.nlevels
+            body = d["body"]
+            for i in range(len(self.tt_data.index)):
+                for j in range(len(self.tt_data.columns)):
+                    if (
+                        not mask.iloc[i, j]
+                        or i in styler.hidden_rows
+                        or j in styler.hidden_columns
+                    ):
+                        row = body[i]
+                        item = row[j + index_offset]
+                        value = self.tt_data.iloc[i, j]
+                        item["attributes"] += f' title="{value}"'
         return d
 
 


Test patch:
diff --git a/pandas/tests/io/formats/style/test_tooltip.py b/pandas/tests/io/formats/style/test_tooltip.py
index c49a0e05c6700..f1071c949299e 100644
--- a/pandas/tests/io/formats/style/test_tooltip.py
+++ b/pandas/tests/io/formats/style/test_tooltip.py
@@ -1,7 +1,10 @@
 import numpy as np
 import pytest
 
-from pandas import DataFrame
+from pandas import (
+    DataFrame,
+    MultiIndex,
+)
 
 pytest.importorskip("jinja2")
 from pandas.io.formats.style import Styler
@@ -22,19 +25,17 @@ def styler(df):
 
 
 @pytest.mark.parametrize(
-    "ttips",
+    "data, columns, index",
     [
-        DataFrame(  # Test basic reindex and ignoring blank
-            data=[["Min", "Max"], [np.nan, ""]],
-            columns=["A", "C"],
-            index=["x", "y"],
-        ),
-        DataFrame(  # Test non-referenced columns, reversed col names, short index
-            data=[["Max", "Min", "Bad-Col"]], columns=["C", "A", "D"], index=["x"]
-        ),
+        # Test basic reindex and ignoring blank
+        ([["Min", "Max"], [np.nan, ""]], ["A", "C"], ["x", "y"]),
+        # Test non-referenced columns, reversed col names, short index
+        ([["Max", "Min", "Bad-Col"]], ["C", "A", "D"], ["x"]),
     ],
 )
-def test_tooltip_render(ttips, styler):
+def test_tooltip_render(data, columns, index, styler):
+    ttips = DataFrame(data=data, columns=columns, index=index)
+
     # GH 21266
     result = styler.set_tooltips(ttips).to_html()
 
@@ -64,6 +65,7 @@ def test_tooltip_ignored(styler):
     result = styler.to_html()  # no set_tooltips() creates no <span>
     assert '<style type="text/css">\n</style>' in result
     assert '<span class="pd-t"></span>' not in result
+    assert 'title="' not in result
 
 
 def test_tooltip_css_class(styler):
@@ -83,3 +85,95 @@ def test_tooltip_css_class(styler):
         props="color:green;color:red;",
     ).to_html()
     assert "#T_ .another-class {\n  color: green;\n  color: red;\n}" in result
+
+
+@pytest.mark.parametrize(
+    "data, columns, index",
+    [
+        # Test basic reindex and ignoring blank
+        ([["Min", "Max"], [np.nan, ""]], ["A", "C"], ["x", "y"]),
+        # Test non-referenced columns, reversed col names, short index
+        ([["Max", "Min", "Bad-Col"]], ["C", "A", "D"], ["x"]),
+    ],
+)
+def test_tooltip_render_as_title(data, columns, index, styler):
+    ttips = DataFrame(data=data, columns=columns, index=index)
+    # GH 56605
+    result = styler.set_tooltips(ttips, as_title_attribute=True).to_html()
+
+    # test css not added
+    assert "#T_ .pd-t {\n  visibility: hidden;\n" not in result
+
+    # test 'Min' tooltip added as title attribute and css does not exist
+    assert "#T_ #T__row0_col0:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert '#T_ #T__row0_col0 .pd-t::after {\n  content: "Min";\n}' not in result
+    assert 'class="data row0 col0"  title="Min">0</td>' in result
+
+    # test 'Max' tooltip added as title attribute and css does not exist
+    assert "#T_ #T__row0_col2:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert '#T_ #T__row0_col2 .pd-t::after {\n  content: "Max";\n}' not in result
+    assert 'class="data row0 col2"  title="Max">2</td>' in result
+
+    # test Nan, empty string and bad column ignored
+    assert "#T_ #T__row1_col0:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert "#T_ #T__row1_col1:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert "#T_ #T__row0_col1:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert "#T_ #T__row1_col2:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert "Bad-Col" not in result
+    assert 'class="data row0 col1" >1</td>' in result
+    assert 'class="data row1 col0" >3</td>' in result
+    assert 'class="data row1 col1" >4</td>' in result
+    assert 'class="data row1 col2" >5</td>' in result
+    assert 'class="data row2 col0" >6</td>' in result
+    assert 'class="data row2 col1" >7</td>' in result
+    assert 'class="data row2 col2" >8</td>' in result
+
+
+def test_tooltip_render_as_title_with_hidden_index_level():
+    df = DataFrame(
+        data=[[0, 1, 2], [3, 4, 5], [6, 7, 8]],
+        columns=["A", "B", "C"],
+        index=MultiIndex.from_arrays(
+            [["x", "y", "z"], [1, 2, 3], ["aa", "bb", "cc"]],
+            names=["alpha", "num", "char"],
+        ),
+    )
+    ttips = DataFrame(
+        # Test basic reindex and ignoring blank, and hide level 2 (num) from index
+        data=[["Min", "Max"], [np.nan, ""]],
+        columns=["A", "C"],
+        index=MultiIndex.from_arrays(
+            [["x", "y"], [1, 2], ["aa", "bb"]], names=["alpha", "num", "char"]
+        ),
+    )
+    styler = Styler(df, uuid_len=0)
+    styler = styler.hide(axis=0, level=-1, names=True)
+    # GH 56605
+    result = styler.set_tooltips(ttips, as_title_attribute=True).to_html()
+
+    # test css not added
+    assert "#T_ .pd-t {\n  visibility: hidden;\n" not in result
+
+    # test 'Min' tooltip added as title attribute and css does not exist
+    assert "#T_ #T__row0_col0:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert '#T_ #T__row0_col0 .pd-t::after {\n  content: "Min";\n}' not in result
+    assert 'class="data row0 col0"  title="Min">0</td>' in result
+
+    # test 'Max' tooltip added as title attribute and css does not exist
+    assert "#T_ #T__row0_col2:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert '#T_ #T__row0_col2 .pd-t::after {\n  content: "Max";\n}' not in result
+    assert 'class="data row0 col2"  title="Max">2</td>' in result
+
+    # test Nan, empty string and bad column ignored
+    assert "#T_ #T__row1_col0:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert "#T_ #T__row1_col1:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert "#T_ #T__row0_col1:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert "#T_ #T__row1_col2:hover .pd-t {\n  visibility: visible;\n}" not in result
+    assert "Bad-Col" not in result
+    assert 'class="data row0 col1" >1</td>' in result
+    assert 'class="data row1 col0" >3</td>' in result
+    assert 'class="data row1 col1" >4</td>' in result
+    assert 'class="data row1 col2" >5</td>' in result
+    assert 'class="data row2 col0" >6</td>' in result
+    assert 'class="data row2 col1" >7</td>' in result
+    assert 'class="data row2 col2" >8</td>' in result