From 1bbf2e053bbc3dc4a9b1f885566b89024a82e109 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sat, 13 Jun 2020 11:54:40 -0600 Subject: [PATCH 1/6] Initial attempt at sticky headers Docs enclosed in diff. --- res/css/structures/_LeftPanel2.scss | 1 + res/css/views/rooms/_RoomSublist2.scss | 67 ++++++++++++++++++++---- src/components/structures/LeftPanel2.tsx | 66 ++++++++++++++++++++++- src/utils/css.ts | 30 +++++++++++ 4 files changed, 154 insertions(+), 10 deletions(-) create mode 100644 src/utils/css.ts 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; +} From 7af2de29d669b4dd46187a999a78ce9fc7c2ff69 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sat, 13 Jun 2020 12:03:50 -0600 Subject: [PATCH 2/6] Remove unused utility --- src/utils/css.ts | 30 ------------------------------ 1 file changed, 30 deletions(-) delete mode 100644 src/utils/css.ts diff --git a/src/utils/css.ts b/src/utils/css.ts deleted file mode 100644 index cd629a0e8a..0000000000 --- a/src/utils/css.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* -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; -} From c26c79bda8718cc1805a521c4fb4e7ba6f79154c Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sat, 13 Jun 2020 19:02:21 -0600 Subject: [PATCH 3/6] Remove dead code --- src/components/structures/LeftPanel2.tsx | 25 ------------------------ 1 file changed, 25 deletions(-) diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index 6dbe4bcd4f..f6482b06ae 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -122,31 +122,6 @@ export default class LeftPanel2 extends React.Component { 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"; - // } } }; From eeac80096cc900d2fc0a9a60a406fa5500d111aa Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sat, 13 Jun 2020 19:07:19 -0600 Subject: [PATCH 4/6] Float the badges with the sticky headers --- res/css/views/rooms/_RoomSublist2.scss | 37 +++++++++++---------- src/components/structures/LeftPanel2.tsx | 2 +- src/components/views/rooms/RoomSublist2.tsx | 28 ++++++++-------- 3 files changed, 35 insertions(+), 32 deletions(-) diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss index 48e6ec7022..746f373e64 100644 --- a/res/css/views/rooms/_RoomSublist2.scss +++ b/res/css/views/rooms/_RoomSublist2.scss @@ -27,7 +27,7 @@ limitations under the License. width: 100%; .mx_RoomSublist2_headerContainer { - // Create a flexbox to make ordering easy + // Create a flexbox to make alignment easy display: flex; align-items: center; @@ -47,9 +47,18 @@ limitations under the License. padding-bottom: 8px; height: 24px; - .mx_RoomSublist2_headerText { + .mx_RoomSublist2_stickable { + flex: 1; + max-width: 100%; z-index: 2; // Prioritize headers in the visible list over sticky ones + // Set the same background color as the room list for sticky headers + background-color: $roomlist2-bg-color; + + // Create a flexbox to make ordering easy + display: flex; + align-items: center; + // 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 @@ -120,23 +129,15 @@ limitations under the License. .mx_RoomSublist2_headerText { flex: 1; max-width: calc(100% - 16px); // 16px is the badge width + text-transform: uppercase; + opacity: 0.5; + line-height: $font-16px; + font-size: $font-12px; - // 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; - } + // 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 f6482b06ae..650828e9b0 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -102,7 +102,7 @@ export default class LeftPanel2 extends React.Component { for (const sublist of sublists) { const slRect = sublist.getBoundingClientRect(); - const header = sublist.querySelector(".mx_RoomSublist2_headerText"); + const header = sublist.querySelector(".mx_RoomSublist2_stickable"); if (slRect.top + headerHeight > bottom && !gotBottom) { console.log(`${header.textContent} is off the bottom`); diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index 9f8b8579c3..5c23004a4f 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -257,19 +257,21 @@ export default class RoomSublist2 extends React.Component { // TODO: a11y (see old component) return (
- - {this.props.label} - - {this.renderMenu()} - {addRoomButton} -
- {badge} +
+ + {this.props.label} + + {this.renderMenu()} + {addRoomButton} +
+ {badge} +
); From 4186070489bba1b7289280afbd7daa6ea24c42d0 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 15 Jun 2020 19:47:25 -0600 Subject: [PATCH 5/6] Support list collapsing and jumping Fixes https://github.com/vector-im/riot-web/issues/14036 --- res/css/views/rooms/_RoomSublist2.scss | 39 ++++++++++++++++++++ res/img/feather-customised/chevron-right.svg | 1 + src/components/structures/LeftPanel2.tsx | 2 - src/components/views/rooms/RoomSublist2.tsx | 28 ++++++++++++++ src/stores/room-list/ListLayout.ts | 13 +++++++ 5 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 res/img/feather-customised/chevron-right.svg diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss index 746f373e64..c725b02f84 100644 --- a/res/css/views/rooms/_RoomSublist2.scss +++ b/res/css/views/rooms/_RoomSublist2.scss @@ -138,6 +138,34 @@ limitations under the License. text-overflow: ellipsis; overflow: hidden; white-space: nowrap; + + .mx_RoomSublist2_collapseBtn { + display: inline-block; + position: relative; + + // Default hidden + visibility: hidden; + width: 0; + height: 0; + + &::before { + content: ''; + width: 12px; + height: 12px; + position: absolute; + top: 1px; + left: 1px; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background: $primary-fg-color; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + } + + &.mx_RoomSublist2_collapseBtn_collapsed::before { + mask-image: url('$(res)/img/feather-customised/chevron-right.svg'); + } + } } } @@ -251,6 +279,17 @@ limitations under the License. background-color: $roomlist2-button-bg-color; } } + + .mx_RoomSublist2_headerContainer { + .mx_RoomSublist2_headerText { + .mx_RoomSublist2_collapseBtn { + visibility: visible; + width: 12px; + height: 12px; + margin-right: 4px; + } + } + } } &.mx_RoomSublist2_minimized { diff --git a/res/img/feather-customised/chevron-right.svg b/res/img/feather-customised/chevron-right.svg new file mode 100644 index 0000000000..258de414a1 --- /dev/null +++ b/res/img/feather-customised/chevron-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index 650828e9b0..ba0ba211b7 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -105,13 +105,11 @@ export default class LeftPanel2 extends React.Component { const header = sublist.querySelector(".mx_RoomSublist2_stickable"); 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`; diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index 5c23004a4f..2b0c549bd5 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -134,7 +134,28 @@ export default class RoomSublist2 extends React.Component { this.forceUpdate(); // because the layout doesn't trigger a re-render }; + private onHeaderClick = (ev: React.MouseEvent) => { + let target = ev.target as HTMLDivElement; + if (!target.classList.contains('mx_RoomSublist2_headerText')) { + // If we don't have the headerText class, the user clicked the span in the headerText. + target = target.parentElement as HTMLDivElement; + } + + const possibleSticky = target.parentElement; + const sublist = possibleSticky.parentElement.parentElement; + if (possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_sticky')) { + // is sticky - jump to list + sublist.scrollIntoView({behavior: 'smooth'}); + } else { + // on screen - toggle collapse + this.props.layout.isCollapsed = !this.props.layout.isCollapsed; + this.forceUpdate(); // because the layout doesn't trigger an update + } + }; + private renderTiles(): React.ReactElement[] { + if (this.props.layout && this.props.layout.isCollapsed) return []; // don't waste time on rendering + const tiles: React.ReactElement[] = []; if (this.props.rooms) { @@ -249,6 +270,11 @@ export default class RoomSublist2 extends React.Component { ); } + const collapseClasses = classNames({ + 'mx_RoomSublist2_collapseBtn': true, + 'mx_RoomSublist2_collapseBtn_collapsed': this.props.layout && this.props.layout.isCollapsed, + }); + const classes = classNames({ 'mx_RoomSublist2_headerContainer': true, 'mx_RoomSublist2_headerContainer_withAux': !!addRoomButton, @@ -264,7 +290,9 @@ export default class RoomSublist2 extends React.Component { className={"mx_RoomSublist2_headerText"} role="treeitem" aria-level={1} + onClick={this.onHeaderClick} > + {this.props.label} {this.renderMenu()} diff --git a/src/stores/room-list/ListLayout.ts b/src/stores/room-list/ListLayout.ts index af9d6801a3..f17001f64e 100644 --- a/src/stores/room-list/ListLayout.ts +++ b/src/stores/room-list/ListLayout.ts @@ -21,11 +21,13 @@ const TILE_HEIGHT_PX = 44; interface ISerializedListLayout { numTiles: number; showPreviews: boolean; + collapsed: boolean; } export class ListLayout { private _n = 0; private _previews = false; + private _collapsed = false; constructor(public readonly tagId: TagID) { const serialized = localStorage.getItem(this.key); @@ -34,9 +36,19 @@ export class ListLayout { const parsed = JSON.parse(serialized); this._n = parsed.numTiles; this._previews = parsed.showPreviews; + this._collapsed = parsed.collapsed; } } + public get isCollapsed(): boolean { + return this._collapsed; + } + + public set isCollapsed(v: boolean) { + this._collapsed = v; + this.save(); + } + public get showPreviews(): boolean { return this._previews; } @@ -100,6 +112,7 @@ export class ListLayout { return { numTiles: this.visibleTiles, showPreviews: this.showPreviews, + collapsed: this.isCollapsed, }; } } From 63447413ca36e9bb98e71da2df9319b3cb5eae35 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 17 Jun 2020 08:28:22 -0600 Subject: [PATCH 6/6] Replace class block with reference to class --- res/css/views/rooms/_RoomSublist2.scss | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss index c725b02f84..330a0729a7 100644 --- a/res/css/views/rooms/_RoomSublist2.scss +++ b/res/css/views/rooms/_RoomSublist2.scss @@ -74,11 +74,9 @@ limitations under the License. bottom: 0; } - // We don't have this style because the top is dependent on the room list header's + // We don't have a top 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; - //} + // The class, mx_RoomSublist2_headerContainer_stickyTop, is applied though. } // Sticky Headers End