Skip to content
1 change: 1 addition & 0 deletions doc/source/whatsnew/v1.3.2.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Bug fixes
- Bug in :meth:`pandas.read_excel` modifies the dtypes dictionary when reading a file with duplicate columns (:issue:`42462`)
- 1D slices over extension types turn into N-dimensional slices over ExtensionArrays (:issue:`42430`)
- :meth:`.Styler.hide_columns` now hides the index name header row as well as column headers (:issue:`42101`)
- :meth:`.Styler.set_sticky` has amended CSS to control the column/index names and ensure the correct sticky positions (:issue:`42537`)
- Bug in de-serializing datetime indexes in PYTHONOPTIMIZED mode (:issue:`42866`)
-

Expand Down
85 changes: 59 additions & 26 deletions pandas/io/formats/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -1534,24 +1534,24 @@ def set_sticky(
may produce strange behaviour due to CSS controls with missing elements.
"""
if axis in [0, "index"]:
axis, obj, tag, pos = 0, self.data.index, "tbody", "left"
axis, obj = 0, self.data.index
pixel_size = 75 if not pixel_size else pixel_size
elif axis in [1, "columns"]:
axis, obj, tag, pos = 1, self.data.columns, "thead", "top"
axis, obj = 1, self.data.columns
pixel_size = 25 if not pixel_size else pixel_size
else:
raise ValueError("`axis` must be one of {0, 1, 'index', 'columns'}")

props = "position:sticky; background-color:white;"
if not isinstance(obj, pd.MultiIndex):
# handling MultiIndexes requires different CSS
props = "position:sticky; background-color:white;"

if axis == 1:
# stick the first <tr> of <head> and, if index names, the second <tr>
# if self._hide_columns then no <thead><tr> here will exist: no conflict
styles: CSSStyles = [
{
"selector": "thead tr:first-child",
"selector": "thead tr:nth-child(1) th",
"props": props + "top:0px; z-index:2;",
}
]
Expand All @@ -1561,7 +1561,7 @@ def set_sticky(
)
styles.append(
{
"selector": "thead tr:nth-child(2)",
"selector": "thead tr:nth-child(2) th",
"props": props
+ f"top:{pixel_size}px; z-index:2; height:{pixel_size}px; ",
}
Expand All @@ -1572,34 +1572,67 @@ def set_sticky(
# but <th> will exist in <thead>: conflict with initial element
styles = [
{
"selector": "tr th:first-child",
"selector": "thead tr th:nth-child(1)",
"props": props + "left:0px; z-index:3 !important;",
},
{
"selector": "tbody tr th:nth-child(1)",
"props": props + "left:0px; z-index:1;",
}
},
]

return self.set_table_styles(styles, overwrite=False)

else:
# handle the MultiIndex case
range_idx = list(range(obj.nlevels))
levels = sorted(levels) if levels else range_idx

levels = sorted(levels) if levels else range_idx
for i, level in enumerate(levels):
self.set_table_styles(
[
{
"selector": f"{tag} th.level{level}",
"props": f"position: sticky; "
f"{pos}: {i * pixel_size}px; "
f"{f'height: {pixel_size}px; ' if axis == 1 else ''}"
f"{f'min-width: {pixel_size}px; ' if axis == 0 else ''}"
f"{f'max-width: {pixel_size}px; ' if axis == 0 else ''}"
f"background-color: white;",
}
],
overwrite=False,
)
if axis == 1:
styles = []
for i, level in enumerate(levels):
styles.append(
{
"selector": f"thead tr:nth-child({level+1}) th",
"props": props
+ (
f"top:{i * pixel_size}px; height:{pixel_size}px; "
"z-index:2;"
),
}
)
if not all(name is None for name in self.index.names):
styles.append(
{
"selector": f"thead tr:nth-child({obj.nlevels+1}) th",
"props": props
+ (
f"top:{(i+1) * pixel_size}px; height:{pixel_size}px; "
"z-index:2;"
),
}
)

return self
else:
styles = []
for i, level in enumerate(levels):
props_ = props + (
f"left:{i * pixel_size}px; "
f"min-width:{pixel_size}px; "
f"max-width:{pixel_size}px; "
)
styles.extend(
[
{
"selector": f"thead tr th:nth-child({level+1})",
"props": props_ + "z-index:3 !important;",
},
{
"selector": f"tbody tr th.level{level}",
"props": props_ + "z-index:1;",
},
]
)

return self.set_table_styles(styles, overwrite=False)

def set_table_styles(
self,
Expand Down
175 changes: 62 additions & 113 deletions pandas/tests/io/formats/style/test_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,26 +282,32 @@ def test_sticky_basic(styler, index, columns, index_name):
if columns:
styler.set_sticky(axis=1)

res = styler.set_uuid("").to_html()

css_for_index = (
"tr th:first-child {\n position: sticky;\n background-color: white;\n "
"left: 0px;\n z-index: 1;\n}"
left_css = (
"#T_ {0} {{\n position: sticky;\n background-color: white;\n"
" left: 0px;\n z-index: {1};\n}}"
)
assert (css_for_index in res) is index

css_for_cols_1 = (
"thead tr:first-child {\n position: sticky;\n background-color: white;\n "
"top: 0px;\n z-index: 2;\n"
top_css = (
"#T_ {0} {{\n position: sticky;\n background-color: white;\n"
" top: {1}px;\n z-index: {2};\n{3}}}"
)
css_for_cols_1 += " height: 25px;\n}" if index_name else "}"
assert (css_for_cols_1 in res) is columns

css_for_cols_2 = (
"thead tr:nth-child(2) {\n position: sticky;\n background-color: white;\n "
"top: 25px;\n z-index: 2;\n height: 25px;\n}"
res = styler.set_uuid("").to_html()

# test index stickys over thead and tbody
assert (left_css.format("thead tr th:nth-child(1)", "3 !important") in res) is index
assert (left_css.format("tbody tr th:nth-child(1)", "1") in res) is index

# test column stickys including if name row
assert (
top_css.format("thead tr:nth-child(1) th", "0", "2", " height: 25px;\n") in res
) is (columns and index_name)
assert (
top_css.format("thead tr:nth-child(2) th", "25", "2", " height: 25px;\n")
in res
) is (columns and index_name)
assert (top_css.format("thead tr:nth-child(1) th", "0", "2", "") in res) is (
columns and not index_name
)
assert (css_for_cols_2 in res) is (index_name and columns)


@pytest.mark.parametrize("index", [False, True])
Expand All @@ -312,73 +318,30 @@ def test_sticky_mi(styler_mi, index, columns):
if columns:
styler_mi.set_sticky(axis=1)

res = styler_mi.set_uuid("").to_html()
assert (
(
dedent(
"""\
#T_ tbody th.level0 {
position: sticky;
left: 0px;
min-width: 75px;
max-width: 75px;
background-color: white;
}
"""
)
in res
)
is index
left_css = (
"#T_ {0} {{\n position: sticky;\n background-color: white;\n"
" left: {1}px;\n min-width: 75px;\n max-width: 75px;\n z-index: {2};\n}}"
)
assert (
(
dedent(
"""\
#T_ tbody th.level1 {
position: sticky;
left: 75px;
min-width: 75px;
max-width: 75px;
background-color: white;
}
"""
)
in res
)
is index
top_css = (
"#T_ {0} {{\n position: sticky;\n background-color: white;\n"
" top: {1}px;\n height: 25px;\n z-index: {2};\n}}"
)

res = styler_mi.set_uuid("").to_html()

# test the index stickys for thead and tbody over both levels
assert (
(
dedent(
"""\
#T_ thead th.level0 {
position: sticky;
top: 0px;
height: 25px;
background-color: white;
}
"""
)
in res
)
is columns
)
left_css.format("thead tr th:nth-child(1)", "0", "3 !important") in res
) is index
assert (left_css.format("tbody tr th.level0", "0", "1") in res) is index
assert (
(
dedent(
"""\
#T_ thead th.level1 {
position: sticky;
top: 25px;
height: 25px;
background-color: white;
}
"""
)
in res
)
is columns
)
left_css.format("thead tr th:nth-child(2)", "75", "3 !important") in res
) is index
assert (left_css.format("tbody tr th.level1", "75", "1") in res) is index

# test the column stickys for each level row
assert (top_css.format("thead tr:nth-child(1) th", "0", "2") in res) is columns
assert (top_css.format("thead tr:nth-child(2) th", "25", "2") in res) is columns


@pytest.mark.parametrize("index", [False, True])
Expand All @@ -389,43 +352,29 @@ def test_sticky_levels(styler_mi, index, columns):
if columns:
styler_mi.set_sticky(axis=1, levels=[1])

res = styler_mi.set_uuid("").to_html()
assert "#T_ tbody th.level0 {" not in res
assert "#T_ thead th.level0 {" not in res
assert (
(
dedent(
"""\
#T_ tbody th.level1 {
position: sticky;
left: 0px;
min-width: 75px;
max-width: 75px;
background-color: white;
}
"""
)
in res
)
is index
left_css = (
"#T_ {0} {{\n position: sticky;\n background-color: white;\n"
" left: {1}px;\n min-width: 75px;\n max-width: 75px;\n z-index: {2};\n}}"
)
assert (
(
dedent(
"""\
#T_ thead th.level1 {
position: sticky;
top: 0px;
height: 25px;
background-color: white;
}
"""
)
in res
)
is columns
top_css = (
"#T_ {0} {{\n position: sticky;\n background-color: white;\n"
" top: {1}px;\n height: 25px;\n z-index: {2};\n}}"
)

res = styler_mi.set_uuid("").to_html()

# test no sticking of level0
assert "#T_ thead tr th:nth-child(1)" not in res
assert "#T_ tbody tr th.level0" not in res
assert "#T_ thead tr:nth-child(1) th" not in res

# test sticking level1
assert (
left_css.format("thead tr th:nth-child(2)", "0", "3 !important") in res
) is index
assert (left_css.format("tbody tr th.level1", "0", "1") in res) is index
assert (top_css.format("thead tr:nth-child(2) th", "0", "2") in res) is columns


def test_sticky_raises(styler):
with pytest.raises(ValueError, match="`axis` must be"):
Expand Down