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 ad827ba3b1..d5fa72be90 100644 --- a/res/css/views/rooms/_RoomSublist2.scss +++ b/res/css/views/rooms/_RoomSublist2.scss @@ -27,12 +27,61 @@ 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; + + // *************************** + // 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_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 + // 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 a top style because the top is dependent on the room list header's + // height, and is therefore calculated in JS. + // The class, mx_RoomSublist2_headerContainer_stickyTop, is applied though. + } + + // Sticky Headers End + // *************************** + .mx_RoomSublist2_badgeContainer { opacity: 0.8; width: 16px; @@ -76,18 +125,45 @@ 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; - flex: 1; - max-width: calc(100% - 16px); // 16px is the badge width - // Ellipsize any text overflow 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'); + } + } } } @@ -201,6 +277,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 a644aa4837..ba0ba211b7 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -86,6 +86,43 @@ 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_stickable"); + + if (slRect.top + headerHeight > bottom && !gotBottom) { + 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) { + 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`; + } + } + }; + private renderHeader(): React.ReactNode { // TODO: Update when profile info changes // TODO: Presence @@ -191,7 +228,7 @@ export default class LeftPanel2 extends React.Component { diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index f03cb3ecbd..70d63daa23 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) { @@ -250,6 +271,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, @@ -258,19 +284,23 @@ 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} +
); 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, }; } }