diff --git a/res/css/structures/_LeftPanel2.scss b/res/css/structures/_LeftPanel2.scss index eca50bb639..5cdefa0324 100644 --- a/res/css/structures/_LeftPanel2.scss +++ b/res/css/structures/_LeftPanel2.scss @@ -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; diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss index 3f5f654494..48e6ec7022 100644 --- a/res/css/views/rooms/_RoomSublist2.scss +++ b/res/css/views/rooms/_RoomSublist2.scss @@ -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; + } } } diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index a644aa4837..6dbe4bcd4f 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -86,6 +86,70 @@ export default class LeftPanel2 extends React.Component { } }; + // TODO: Apply this on resize, init, etc for reliability + private onScroll = (ev: React.MouseEvent) => { + const list = ev.target as HTMLDivElement; + const rlRect = list.getBoundingClientRect(); + const bottom = rlRect.bottom; + const top = rlRect.top; + const sublists = list.querySelectorAll(".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(".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 { diff --git a/src/utils/css.ts b/src/utils/css.ts new file mode 100644 index 0000000000..cd629a0e8a --- /dev/null +++ b/src/utils/css.ts @@ -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; +}