Skip to content

Commit d2831ff

Browse files
authored
Merge pull request matrix-org#4758 from matrix-org/travis/room-list/sticky
Sticky and collapsing headers for new room list
2 parents 97d8786 + 6344741 commit d2831ff

File tree

6 files changed

+187
-18
lines changed

6 files changed

+187
-18
lines changed

res/css/structures/_LeftPanel2.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
131131
overflow-y: auto;
132132
width: 100%;
133133
max-width: 100%;
134+
position: relative; // for sticky headers
134135

135136
// Create a flexbox to trick the layout engine
136137
display: flex;

res/css/views/rooms/_RoomSublist2.scss

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,61 @@ limitations under the License.
2727
width: 100%;
2828

2929
.mx_RoomSublist2_headerContainer {
30-
// Create a flexbox to make ordering easy
30+
// Create a flexbox to make alignment easy
3131
display: flex;
3232
align-items: center;
33+
34+
// ***************************
35+
// Sticky Headers Start
36+
37+
// Ideally we'd be able to use `position: sticky; top: 0; bottom: 0;` on the
38+
// headerContainer, however due to our layout concerns we actually have to
39+
// calculate it manually so we can sticky things in the right places. We also
40+
// target the headerText instead of the container to reduce jumps when scrolling,
41+
// and to help hide the badges/other buttons that could appear on hover. This
42+
// all works by ensuring the header text has a fixed height when sticky so the
43+
// fixed height of the container can maintain the scroll position.
44+
45+
// The combined height must be set in the LeftPanel2 component for sticky headers
46+
// to work correctly.
3347
padding-bottom: 8px;
3448
height: 24px;
3549

50+
.mx_RoomSublist2_stickable {
51+
flex: 1;
52+
max-width: 100%;
53+
z-index: 2; // Prioritize headers in the visible list over sticky ones
54+
55+
// Set the same background color as the room list for sticky headers
56+
background-color: $roomlist2-bg-color;
57+
58+
// Create a flexbox to make ordering easy
59+
display: flex;
60+
align-items: center;
61+
62+
// We use a generic sticky class for 2 reasons: to reduce style duplication and
63+
// to identify when a header is sticky. If we didn't have a consistent sticky class,
64+
// we'd have to do the "is sticky" checks again on click, as clicking the header
65+
// when sticky scrolls instead of collapses the list.
66+
&.mx_RoomSublist2_headerContainer_sticky {
67+
position: fixed;
68+
z-index: 1; // over top of other elements, but still under the ones in the visible list
69+
height: 32px; // to match the header container
70+
// width set by JS
71+
}
72+
73+
&.mx_RoomSublist2_headerContainer_stickyBottom {
74+
bottom: 0;
75+
}
76+
77+
// We don't have a top style because the top is dependent on the room list header's
78+
// height, and is therefore calculated in JS.
79+
// The class, mx_RoomSublist2_headerContainer_stickyTop, is applied though.
80+
}
81+
82+
// Sticky Headers End
83+
// ***************************
84+
3685
.mx_RoomSublist2_badgeContainer {
3786
opacity: 0.8;
3887
width: 16px;
@@ -76,18 +125,45 @@ limitations under the License.
76125
}
77126

78127
.mx_RoomSublist2_headerText {
128+
flex: 1;
129+
max-width: calc(100% - 16px); // 16px is the badge width
79130
text-transform: uppercase;
80131
opacity: 0.5;
81132
line-height: $font-16px;
82133
font-size: $font-12px;
83134

84-
flex: 1;
85-
max-width: calc(100% - 16px); // 16px is the badge width
86-
87135
// Ellipsize any text overflow
88136
text-overflow: ellipsis;
89137
overflow: hidden;
90138
white-space: nowrap;
139+
140+
.mx_RoomSublist2_collapseBtn {
141+
display: inline-block;
142+
position: relative;
143+
144+
// Default hidden
145+
visibility: hidden;
146+
width: 0;
147+
height: 0;
148+
149+
&::before {
150+
content: '';
151+
width: 12px;
152+
height: 12px;
153+
position: absolute;
154+
top: 1px;
155+
left: 1px;
156+
mask-position: center;
157+
mask-size: contain;
158+
mask-repeat: no-repeat;
159+
background: $primary-fg-color;
160+
mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
161+
}
162+
163+
&.mx_RoomSublist2_collapseBtn_collapsed::before {
164+
mask-image: url('$(res)/img/feather-customised/chevron-right.svg');
165+
}
166+
}
91167
}
92168
}
93169

@@ -201,6 +277,17 @@ limitations under the License.
201277
background-color: $roomlist2-button-bg-color;
202278
}
203279
}
280+
281+
.mx_RoomSublist2_headerContainer {
282+
.mx_RoomSublist2_headerText {
283+
.mx_RoomSublist2_collapseBtn {
284+
visibility: visible;
285+
width: 12px;
286+
height: 12px;
287+
margin-right: 4px;
288+
}
289+
}
290+
}
204291
}
205292

206293
&.mx_RoomSublist2_minimized {
Lines changed: 1 addition & 0 deletions
Loading

src/components/structures/LeftPanel2.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,43 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
8686
}
8787
};
8888

89+
// TODO: Apply this on resize, init, etc for reliability
90+
private onScroll = (ev: React.MouseEvent<HTMLDivElement>) => {
91+
const list = ev.target as HTMLDivElement;
92+
const rlRect = list.getBoundingClientRect();
93+
const bottom = rlRect.bottom;
94+
const top = rlRect.top;
95+
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist2");
96+
const headerHeight = 32; // Note: must match the CSS!
97+
const headerRightMargin = 24; // calculated from margins and widths to align with non-sticky tiles
98+
99+
const headerStickyWidth = rlRect.width - headerRightMargin;
100+
101+
let gotBottom = false;
102+
for (const sublist of sublists) {
103+
const slRect = sublist.getBoundingClientRect();
104+
105+
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist2_stickable");
106+
107+
if (slRect.top + headerHeight > bottom && !gotBottom) {
108+
header.classList.add("mx_RoomSublist2_headerContainer_sticky");
109+
header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom");
110+
header.style.width = `${headerStickyWidth}px`;
111+
gotBottom = true;
112+
} else if (slRect.top < top) {
113+
header.classList.add("mx_RoomSublist2_headerContainer_sticky");
114+
header.classList.add("mx_RoomSublist2_headerContainer_stickyTop");
115+
header.style.width = `${headerStickyWidth}px`;
116+
header.style.top = `${rlRect.top}px`;
117+
} else {
118+
header.classList.remove("mx_RoomSublist2_headerContainer_sticky");
119+
header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop");
120+
header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom");
121+
header.style.width = `unset`;
122+
}
123+
}
124+
};
125+
89126
private renderHeader(): React.ReactNode {
90127
// TODO: Update when profile info changes
91128
// TODO: Presence
@@ -191,7 +228,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
191228
<aside className="mx_LeftPanel2_roomListContainer">
192229
{this.renderHeader()}
193230
{this.renderSearchExplore()}
194-
<div className="mx_LeftPanel2_actualRoomListContainer">
231+
<div className="mx_LeftPanel2_actualRoomListContainer" onScroll={this.onScroll}>
195232
{roomList}
196233
</div>
197234
</aside>

src/components/views/rooms/RoomSublist2.tsx

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,28 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
134134
this.forceUpdate(); // because the layout doesn't trigger a re-render
135135
};
136136

137+
private onHeaderClick = (ev: React.MouseEvent<HTMLDivElement>) => {
138+
let target = ev.target as HTMLDivElement;
139+
if (!target.classList.contains('mx_RoomSublist2_headerText')) {
140+
// If we don't have the headerText class, the user clicked the span in the headerText.
141+
target = target.parentElement as HTMLDivElement;
142+
}
143+
144+
const possibleSticky = target.parentElement;
145+
const sublist = possibleSticky.parentElement.parentElement;
146+
if (possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_sticky')) {
147+
// is sticky - jump to list
148+
sublist.scrollIntoView({behavior: 'smooth'});
149+
} else {
150+
// on screen - toggle collapse
151+
this.props.layout.isCollapsed = !this.props.layout.isCollapsed;
152+
this.forceUpdate(); // because the layout doesn't trigger an update
153+
}
154+
};
155+
137156
private renderTiles(): React.ReactElement[] {
157+
if (this.props.layout && this.props.layout.isCollapsed) return []; // don't waste time on rendering
158+
138159
const tiles: React.ReactElement[] = [];
139160

140161
if (this.props.rooms) {
@@ -250,6 +271,11 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
250271
);
251272
}
252273

274+
const collapseClasses = classNames({
275+
'mx_RoomSublist2_collapseBtn': true,
276+
'mx_RoomSublist2_collapseBtn_collapsed': this.props.layout && this.props.layout.isCollapsed,
277+
});
278+
253279
const classes = classNames({
254280
'mx_RoomSublist2_headerContainer': true,
255281
'mx_RoomSublist2_headerContainer_withAux': !!addRoomButton,
@@ -258,19 +284,23 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
258284
// TODO: a11y (see old component)
259285
return (
260286
<div className={classes}>
261-
<AccessibleButton
262-
inputRef={ref}
263-
tabIndex={tabIndex}
264-
className={"mx_RoomSublist2_headerText"}
265-
role="treeitem"
266-
aria-level={1}
267-
>
268-
<span>{this.props.label}</span>
269-
</AccessibleButton>
270-
{this.renderMenu()}
271-
{addRoomButton}
272-
<div className="mx_RoomSublist2_badgeContainer">
273-
{badge}
287+
<div className='mx_RoomSublist2_stickable'>
288+
<AccessibleButton
289+
inputRef={ref}
290+
tabIndex={tabIndex}
291+
className={"mx_RoomSublist2_headerText"}
292+
role="treeitem"
293+
aria-level={1}
294+
onClick={this.onHeaderClick}
295+
>
296+
<span className={collapseClasses} />
297+
<span>{this.props.label}</span>
298+
</AccessibleButton>
299+
{this.renderMenu()}
300+
{addRoomButton}
301+
<div className="mx_RoomSublist2_badgeContainer">
302+
{badge}
303+
</div>
274304
</div>
275305
</div>
276306
);

src/stores/room-list/ListLayout.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@ const TILE_HEIGHT_PX = 44;
2121
interface ISerializedListLayout {
2222
numTiles: number;
2323
showPreviews: boolean;
24+
collapsed: boolean;
2425
}
2526

2627
export class ListLayout {
2728
private _n = 0;
2829
private _previews = false;
30+
private _collapsed = false;
2931

3032
constructor(public readonly tagId: TagID) {
3133
const serialized = localStorage.getItem(this.key);
@@ -34,9 +36,19 @@ export class ListLayout {
3436
const parsed = <ISerializedListLayout>JSON.parse(serialized);
3537
this._n = parsed.numTiles;
3638
this._previews = parsed.showPreviews;
39+
this._collapsed = parsed.collapsed;
3740
}
3841
}
3942

43+
public get isCollapsed(): boolean {
44+
return this._collapsed;
45+
}
46+
47+
public set isCollapsed(v: boolean) {
48+
this._collapsed = v;
49+
this.save();
50+
}
51+
4052
public get showPreviews(): boolean {
4153
return this._previews;
4254
}
@@ -100,6 +112,7 @@ export class ListLayout {
100112
return {
101113
numTiles: this.visibleTiles,
102114
showPreviews: this.showPreviews,
115+
collapsed: this.isCollapsed,
103116
};
104117
}
105118
}

0 commit comments

Comments
 (0)