Skip to content

Commit 00bd96f

Browse files
attack68meeseeksmachine
authored andcommitted
Backport PR pandas-dev#42072: ENH: Styler.set_sticky for maintaining index and column headers in HTML frame
1 parent 44f2649 commit 00bd96f

File tree

5 files changed

+243
-7
lines changed

5 files changed

+243
-7
lines changed

doc/source/reference/style.rst

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Style application
4242
Styler.set_table_attributes
4343
Styler.set_tooltips
4444
Styler.set_caption
45+
Styler.set_sticky
4546
Styler.set_properties
4647
Styler.set_uuid
4748
Styler.clear

doc/source/user_guide/style.ipynb

+22-6
Original file line numberDiff line numberDiff line change
@@ -1405,7 +1405,26 @@
14051405
"source": [
14061406
"### Sticky Headers\n",
14071407
"\n",
1408-
"If you display a large matrix or DataFrame in a notebook, but you want to always see the column and row headers you can use the following CSS to make them stick. We might make this into an API function later."
1408+
"If you display a large matrix or DataFrame in a notebook, but you want to always see the column and row headers you can use the [.set_sticky][sticky] method which manipulates the table styles CSS.\n",
1409+
"\n",
1410+
"[sticky]: ../reference/api/pandas.io.formats.style.Styler.set_sticky.rst"
1411+
]
1412+
},
1413+
{
1414+
"cell_type": "code",
1415+
"execution_count": null,
1416+
"metadata": {},
1417+
"outputs": [],
1418+
"source": [
1419+
"bigdf = pd.DataFrame(np.random.randn(16, 100))\n",
1420+
"bigdf.style.set_sticky(axis=\"index\")"
1421+
]
1422+
},
1423+
{
1424+
"cell_type": "markdown",
1425+
"metadata": {},
1426+
"source": [
1427+
"It is also possible to stick MultiIndexes and even only specific levels."
14091428
]
14101429
},
14111430
{
@@ -1414,11 +1433,8 @@
14141433
"metadata": {},
14151434
"outputs": [],
14161435
"source": [
1417-
"bigdf = pd.DataFrame(np.random.randn(15, 100))\n",
1418-
"bigdf.style.set_table_styles([\n",
1419-
" {'selector': 'thead th', 'props': 'position: sticky; top:0; background-color:salmon;'},\n",
1420-
" {'selector': 'tbody th', 'props': 'position: sticky; left:0; background-color:lightgreen;'} \n",
1421-
"])"
1436+
"bigdf.index = pd.MultiIndex.from_product([[\"A\",\"B\"],[0,1],[0,1,2,3]])\n",
1437+
"bigdf.style.set_sticky(axis=\"index\", pixel_size=18, levels=[1,2])"
14221438
]
14231439
},
14241440
{

doc/source/whatsnew/v1.3.0.rst

+1
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ which has been revised and improved (:issue:`39720`, :issue:`39317`, :issue:`404
138138
- Added the option ``styler.render.max_elements`` to avoid browser overload when styling large DataFrames (:issue:`40712`)
139139
- Added the method :meth:`.Styler.to_latex` (:issue:`21673`), which also allows some limited CSS conversion (:issue:`40731`)
140140
- Added the method :meth:`.Styler.to_html` (:issue:`13379`)
141+
- Added the method :meth:`.Styler.set_sticky` to make index and column headers permanently visible in scrolling HTML frames (:issue:`29072`)
141142

142143
.. _whatsnew_130.enhancements.dataframe_honors_copy_with_dict:
143144

pandas/io/formats/style.py

+65
Original file line numberDiff line numberDiff line change
@@ -1414,6 +1414,71 @@ def set_caption(self, caption: str | tuple) -> Styler:
14141414
self.caption = caption
14151415
return self
14161416

1417+
def set_sticky(
1418+
self,
1419+
axis: Axis = 0,
1420+
pixel_size: int | None = None,
1421+
levels: list[int] | None = None,
1422+
) -> Styler:
1423+
"""
1424+
Add CSS to permanently display the index or column headers in a scrolling frame.
1425+
1426+
Parameters
1427+
----------
1428+
axis : {0 or 'index', 1 or 'columns', None}, default 0
1429+
Whether to make the index or column headers sticky.
1430+
pixel_size : int, optional
1431+
Required to configure the width of index cells or the height of column
1432+
header cells when sticking a MultiIndex. Defaults to 75 and 25 respectively.
1433+
levels : list of int
1434+
If ``axis`` is a MultiIndex the specific levels to stick. If ``None`` will
1435+
stick all levels.
1436+
1437+
Returns
1438+
-------
1439+
self : Styler
1440+
"""
1441+
if axis in [0, "index"]:
1442+
axis, obj, tag, pos = 0, self.data.index, "tbody", "left"
1443+
pixel_size = 75 if not pixel_size else pixel_size
1444+
elif axis in [1, "columns"]:
1445+
axis, obj, tag, pos = 1, self.data.columns, "thead", "top"
1446+
pixel_size = 25 if not pixel_size else pixel_size
1447+
else:
1448+
raise ValueError("`axis` must be one of {0, 1, 'index', 'columns'}")
1449+
1450+
if not isinstance(obj, pd.MultiIndex):
1451+
return self.set_table_styles(
1452+
[
1453+
{
1454+
"selector": f"{tag} th",
1455+
"props": f"position:sticky; {pos}:0px; background-color:white;",
1456+
}
1457+
],
1458+
overwrite=False,
1459+
)
1460+
else:
1461+
range_idx = list(range(obj.nlevels))
1462+
1463+
levels = sorted(levels) if levels else range_idx
1464+
for i, level in enumerate(levels):
1465+
self.set_table_styles(
1466+
[
1467+
{
1468+
"selector": f"{tag} th.level{level}",
1469+
"props": f"position: sticky; "
1470+
f"{pos}: {i * pixel_size}px; "
1471+
f"{f'height: {pixel_size}px; ' if axis == 1 else ''}"
1472+
f"{f'min-width: {pixel_size}px; ' if axis == 0 else ''}"
1473+
f"{f'max-width: {pixel_size}px; ' if axis == 0 else ''}"
1474+
f"background-color: white;",
1475+
}
1476+
],
1477+
overwrite=False,
1478+
)
1479+
1480+
return self
1481+
14171482
def set_table_styles(
14181483
self,
14191484
table_styles: dict[Any, CSSStyles] | CSSStyles,

pandas/tests/io/formats/style/test_html.py

+154-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
from textwrap import dedent
22

3+
import numpy as np
34
import pytest
45

5-
from pandas import DataFrame
6+
from pandas import (
7+
DataFrame,
8+
MultiIndex,
9+
)
610

711
jinja2 = pytest.importorskip("jinja2")
812
from pandas.io.formats.style import Styler
@@ -16,6 +20,12 @@ def styler():
1620
return Styler(DataFrame([[2.61], [2.69]], index=["a", "b"], columns=["A"]))
1721

1822

23+
@pytest.fixture
24+
def styler_mi():
25+
midx = MultiIndex.from_product([["a", "b"], ["c", "d"]])
26+
return Styler(DataFrame(np.arange(16).reshape(4, 4), index=midx, columns=midx))
27+
28+
1929
@pytest.fixture
2030
def tpl_style():
2131
return env.get_template("html_style.tpl")
@@ -236,3 +246,146 @@ def test_from_custom_template(tmpdir):
236246
def test_caption_as_sequence(styler):
237247
styler.set_caption(("full cap", "short cap"))
238248
assert "<caption>full cap</caption>" in styler.render()
249+
250+
251+
@pytest.mark.parametrize("index", [False, True])
252+
@pytest.mark.parametrize("columns", [False, True])
253+
def test_sticky_basic(styler, index, columns):
254+
if index:
255+
styler.set_sticky(axis=0)
256+
if columns:
257+
styler.set_sticky(axis=1)
258+
259+
res = styler.set_uuid("").to_html()
260+
cs1 = "tbody th {\n position: sticky;\n left: 0px;\n background-color: white;\n}"
261+
assert (cs1 in res) is index
262+
cs2 = "thead th {\n position: sticky;\n top: 0px;\n background-color: white;\n}"
263+
assert (cs2 in res) is columns
264+
265+
266+
@pytest.mark.parametrize("index", [False, True])
267+
@pytest.mark.parametrize("columns", [False, True])
268+
def test_sticky_mi(styler_mi, index, columns):
269+
if index:
270+
styler_mi.set_sticky(axis=0)
271+
if columns:
272+
styler_mi.set_sticky(axis=1)
273+
274+
res = styler_mi.set_uuid("").to_html()
275+
assert (
276+
(
277+
dedent(
278+
"""\
279+
#T_ tbody th.level0 {
280+
position: sticky;
281+
left: 0px;
282+
min-width: 75px;
283+
max-width: 75px;
284+
background-color: white;
285+
}
286+
"""
287+
)
288+
in res
289+
)
290+
is index
291+
)
292+
assert (
293+
(
294+
dedent(
295+
"""\
296+
#T_ tbody th.level1 {
297+
position: sticky;
298+
left: 75px;
299+
min-width: 75px;
300+
max-width: 75px;
301+
background-color: white;
302+
}
303+
"""
304+
)
305+
in res
306+
)
307+
is index
308+
)
309+
assert (
310+
(
311+
dedent(
312+
"""\
313+
#T_ thead th.level0 {
314+
position: sticky;
315+
top: 0px;
316+
height: 25px;
317+
background-color: white;
318+
}
319+
"""
320+
)
321+
in res
322+
)
323+
is columns
324+
)
325+
assert (
326+
(
327+
dedent(
328+
"""\
329+
#T_ thead th.level1 {
330+
position: sticky;
331+
top: 25px;
332+
height: 25px;
333+
background-color: white;
334+
}
335+
"""
336+
)
337+
in res
338+
)
339+
is columns
340+
)
341+
342+
343+
@pytest.mark.parametrize("index", [False, True])
344+
@pytest.mark.parametrize("columns", [False, True])
345+
def test_sticky_levels(styler_mi, index, columns):
346+
if index:
347+
styler_mi.set_sticky(axis=0, levels=[1])
348+
if columns:
349+
styler_mi.set_sticky(axis=1, levels=[1])
350+
351+
res = styler_mi.set_uuid("").to_html()
352+
assert "#T_ tbody th.level0 {" not in res
353+
assert "#T_ thead th.level0 {" not in res
354+
assert (
355+
(
356+
dedent(
357+
"""\
358+
#T_ tbody th.level1 {
359+
position: sticky;
360+
left: 0px;
361+
min-width: 75px;
362+
max-width: 75px;
363+
background-color: white;
364+
}
365+
"""
366+
)
367+
in res
368+
)
369+
is index
370+
)
371+
assert (
372+
(
373+
dedent(
374+
"""\
375+
#T_ thead th.level1 {
376+
position: sticky;
377+
top: 0px;
378+
height: 25px;
379+
background-color: white;
380+
}
381+
"""
382+
)
383+
in res
384+
)
385+
is columns
386+
)
387+
388+
389+
def test_sticky_raises(styler):
390+
with pytest.raises(ValueError, match="`axis` must be"):
391+
styler.set_sticky(axis="bad")

0 commit comments

Comments
 (0)