Initial attempt at sticky headers

Docs enclosed in diff.
pull/21833/head
Travis Ralston 2020-06-13 11:54:40 -06:00
parent cbe9ade1c9
commit 1bbf2e053b
4 changed files with 154 additions and 10 deletions

View File

@ -131,6 +131,7 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
overflow-y: auto;
width: 100%;
max-width: 100%;
position: relative; // for sticky headers
// Create a flexbox to trick the layout engine
display: flex;

View File

@ -30,9 +30,51 @@ limitations under the License.
// Create a flexbox to make ordering easy
display: flex;
align-items: center;
// ***************************
// Sticky Headers Start
// Ideally we'd be able to use `position: sticky; top: 0; bottom: 0;` on the
// headerContainer, however due to our layout concerns we actually have to
// calculate it manually so we can sticky things in the right places. We also
// target the headerText instead of the container to reduce jumps when scrolling,
// and to help hide the badges/other buttons that could appear on hover. This
// all works by ensuring the header text has a fixed height when sticky so the
// fixed height of the container can maintain the scroll position.
// The combined height must be set in the LeftPanel2 component for sticky headers
// to work correctly.
padding-bottom: 8px;
height: 24px;
.mx_RoomSublist2_headerText {
z-index: 2; // Prioritize headers in the visible list over sticky ones
// We use a generic sticky class for 2 reasons: to reduce style duplication and
// to identify when a header is sticky. If we didn't have a consistent sticky class,
// we'd have to do the "is sticky" checks again on click, as clicking the header
// when sticky scrolls instead of collapses the list.
&.mx_RoomSublist2_headerContainer_sticky {
position: fixed;
z-index: 1; // over top of other elements, but still under the ones in the visible list
height: 32px; // to match the header container
// width set by JS
}
&.mx_RoomSublist2_headerContainer_stickyBottom {
bottom: 0;
}
// We don't have this style because the top is dependent on the room list header's
// height, and is therefore calculated in JS.
//&.mx_RoomSublist2_headerContainer_stickyTop {
// top: 0;
//}
}
// Sticky Headers End
// ***************************
.mx_RoomSublist2_badgeContainer {
opacity: 0.8;
width: 16px;
@ -76,18 +118,25 @@ limitations under the License.
}
.mx_RoomSublist2_headerText {
text-transform: uppercase;
opacity: 0.5;
line-height: $font-16px;
font-size: $font-12px;
flex: 1;
max-width: calc(100% - 16px); // 16px is the badge width
// Ellipsize any text overflow
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
// Set the same background color as the room list for sticky headers
background-color: $roomlist2-bg-color;
// Target the span inside the container so we don't opacify the
// whole header, which can make the sticky header experience annoying.
> span {
text-transform: uppercase;
opacity: 0.5;
line-height: $font-16px;
font-size: $font-12px;
// Ellipsize any text overflow
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}

View File

@ -86,6 +86,70 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
}
};
// TODO: Apply this on resize, init, etc for reliability
private onScroll = (ev: React.MouseEvent<HTMLDivElement>) => {
const list = ev.target as HTMLDivElement;
const rlRect = list.getBoundingClientRect();
const bottom = rlRect.bottom;
const top = rlRect.top;
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist2");
const headerHeight = 32; // Note: must match the CSS!
const headerRightMargin = 24; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = rlRect.width - headerRightMargin;
let gotBottom = false;
for (const sublist of sublists) {
const slRect = sublist.getBoundingClientRect();
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist2_headerText");
if (slRect.top + headerHeight > bottom && !gotBottom) {
console.log(`${header.textContent} is off the bottom`);
header.classList.add("mx_RoomSublist2_headerContainer_sticky");
header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom");
header.style.width = `${headerStickyWidth}px`;
gotBottom = true;
} else if (slRect.top < top) {
console.log(`${header.textContent} is off the top`);
header.classList.add("mx_RoomSublist2_headerContainer_sticky");
header.classList.add("mx_RoomSublist2_headerContainer_stickyTop");
header.style.width = `${headerStickyWidth}px`;
header.style.top = `${rlRect.top}px`;
} else {
header.classList.remove("mx_RoomSublist2_headerContainer_sticky");
header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop");
header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom");
header.style.width = `unset`;
}
// const name = header.textContent;
// if (hRect.bottom + headerHeight < top) {
// // Before the content (top of list)
// header.classList.add(
// "mx_RoomSublist2_headerContainer_sticky",
// "mx_RoomSublist2_headerContainer_stickyTop",
// );
// } else {
// header.classList.remove(
// "mx_RoomSublist2_headerContainer_sticky",
// "mx_RoomSublist2_headerContainer_stickyTop",
// "mx_RoomSublist2_headerContainer_stickyBottom",
// );
// }
// if (!hitMiddle && (headerHeight + hRect.top) >= bottom) {
// // if we got here, the header is visible
// hitMiddle = true;
// header.style.backgroundColor = 'red';
// } else {
// header.style.top = "0px";
// header.style.bottom = "unset";
// header.style.backgroundColor = "unset";
// }
}
};
private renderHeader(): React.ReactNode {
// TODO: Update when profile info changes
// TODO: Presence
@ -191,7 +255,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
<aside className="mx_LeftPanel2_roomListContainer">
{this.renderHeader()}
{this.renderSearchExplore()}
<div className="mx_LeftPanel2_actualRoomListContainer">
<div className="mx_LeftPanel2_actualRoomListContainer" onScroll={this.onScroll}>
{roomList}
</div>
</aside>

30
src/utils/css.ts Normal file
View File

@ -0,0 +1,30 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export function addClass(classes: string, clazz: string): string {
if (!classes.includes(clazz)) return `${classes} ${clazz}`;
return classes;
}
export function removeClass(classes: string, clazz: string): string {
const idx = classes.indexOf(clazz);
if (idx >= 0) {
const beforeStr = classes.substring(0, idx);
const afterStr = classes.substring(idx + clazz.length);
return `${beforeStr} ${afterStr}`.trim();
}
return classes;
}