Merge pull request #4758 from matrix-org/travis/room-list/sticky
Sticky and collapsing headers for new room list
This commit is contained in:
commit
d2831ffb0c
6 changed files with 187 additions and 18 deletions
|
@ -131,6 +131,7 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
position: relative; // for sticky headers
|
||||||
|
|
||||||
// Create a flexbox to trick the layout engine
|
// Create a flexbox to trick the layout engine
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -27,12 +27,61 @@ limitations under the License.
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
.mx_RoomSublist2_headerContainer {
|
.mx_RoomSublist2_headerContainer {
|
||||||
// Create a flexbox to make ordering easy
|
// Create a flexbox to make alignment easy
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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;
|
padding-bottom: 8px;
|
||||||
height: 24px;
|
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 {
|
.mx_RoomSublist2_badgeContainer {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
|
@ -76,18 +125,45 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_RoomSublist2_headerText {
|
.mx_RoomSublist2_headerText {
|
||||||
|
flex: 1;
|
||||||
|
max-width: calc(100% - 16px); // 16px is the badge width
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
line-height: $font-16px;
|
line-height: $font-16px;
|
||||||
font-size: $font-12px;
|
font-size: $font-12px;
|
||||||
|
|
||||||
flex: 1;
|
|
||||||
max-width: calc(100% - 16px); // 16px is the badge width
|
|
||||||
|
|
||||||
// Ellipsize any text overflow
|
// Ellipsize any text overflow
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
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;
|
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 {
|
&.mx_RoomSublist2_minimized {
|
||||||
|
|
1
res/img/feather-customised/chevron-right.svg
Normal file
1
res/img/feather-customised/chevron-right.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-right"><polyline points="9 18 15 12 9 6"></polyline></svg>
|
After Width: | Height: | Size: 270 B |
|
@ -86,6 +86,43 @@ 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_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 {
|
private renderHeader(): React.ReactNode {
|
||||||
// TODO: Update when profile info changes
|
// TODO: Update when profile info changes
|
||||||
// TODO: Presence
|
// TODO: Presence
|
||||||
|
@ -191,7 +228,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
||||||
<aside className="mx_LeftPanel2_roomListContainer">
|
<aside className="mx_LeftPanel2_roomListContainer">
|
||||||
{this.renderHeader()}
|
{this.renderHeader()}
|
||||||
{this.renderSearchExplore()}
|
{this.renderSearchExplore()}
|
||||||
<div className="mx_LeftPanel2_actualRoomListContainer">
|
<div className="mx_LeftPanel2_actualRoomListContainer" onScroll={this.onScroll}>
|
||||||
{roomList}
|
{roomList}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
@ -134,7 +134,28 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||||
this.forceUpdate(); // because the layout doesn't trigger a re-render
|
this.forceUpdate(); // because the layout doesn't trigger a re-render
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private onHeaderClick = (ev: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
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[] {
|
private renderTiles(): React.ReactElement[] {
|
||||||
|
if (this.props.layout && this.props.layout.isCollapsed) return []; // don't waste time on rendering
|
||||||
|
|
||||||
const tiles: React.ReactElement[] = [];
|
const tiles: React.ReactElement[] = [];
|
||||||
|
|
||||||
if (this.props.rooms) {
|
if (this.props.rooms) {
|
||||||
|
@ -250,6 +271,11 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const collapseClasses = classNames({
|
||||||
|
'mx_RoomSublist2_collapseBtn': true,
|
||||||
|
'mx_RoomSublist2_collapseBtn_collapsed': this.props.layout && this.props.layout.isCollapsed,
|
||||||
|
});
|
||||||
|
|
||||||
const classes = classNames({
|
const classes = classNames({
|
||||||
'mx_RoomSublist2_headerContainer': true,
|
'mx_RoomSublist2_headerContainer': true,
|
||||||
'mx_RoomSublist2_headerContainer_withAux': !!addRoomButton,
|
'mx_RoomSublist2_headerContainer_withAux': !!addRoomButton,
|
||||||
|
@ -258,19 +284,23 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||||
// TODO: a11y (see old component)
|
// TODO: a11y (see old component)
|
||||||
return (
|
return (
|
||||||
<div className={classes}>
|
<div className={classes}>
|
||||||
<AccessibleButton
|
<div className='mx_RoomSublist2_stickable'>
|
||||||
inputRef={ref}
|
<AccessibleButton
|
||||||
tabIndex={tabIndex}
|
inputRef={ref}
|
||||||
className={"mx_RoomSublist2_headerText"}
|
tabIndex={tabIndex}
|
||||||
role="treeitem"
|
className={"mx_RoomSublist2_headerText"}
|
||||||
aria-level={1}
|
role="treeitem"
|
||||||
>
|
aria-level={1}
|
||||||
<span>{this.props.label}</span>
|
onClick={this.onHeaderClick}
|
||||||
</AccessibleButton>
|
>
|
||||||
{this.renderMenu()}
|
<span className={collapseClasses} />
|
||||||
{addRoomButton}
|
<span>{this.props.label}</span>
|
||||||
<div className="mx_RoomSublist2_badgeContainer">
|
</AccessibleButton>
|
||||||
{badge}
|
{this.renderMenu()}
|
||||||
|
{addRoomButton}
|
||||||
|
<div className="mx_RoomSublist2_badgeContainer">
|
||||||
|
{badge}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -21,11 +21,13 @@ const TILE_HEIGHT_PX = 44;
|
||||||
interface ISerializedListLayout {
|
interface ISerializedListLayout {
|
||||||
numTiles: number;
|
numTiles: number;
|
||||||
showPreviews: boolean;
|
showPreviews: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ListLayout {
|
export class ListLayout {
|
||||||
private _n = 0;
|
private _n = 0;
|
||||||
private _previews = false;
|
private _previews = false;
|
||||||
|
private _collapsed = false;
|
||||||
|
|
||||||
constructor(public readonly tagId: TagID) {
|
constructor(public readonly tagId: TagID) {
|
||||||
const serialized = localStorage.getItem(this.key);
|
const serialized = localStorage.getItem(this.key);
|
||||||
|
@ -34,9 +36,19 @@ export class ListLayout {
|
||||||
const parsed = <ISerializedListLayout>JSON.parse(serialized);
|
const parsed = <ISerializedListLayout>JSON.parse(serialized);
|
||||||
this._n = parsed.numTiles;
|
this._n = parsed.numTiles;
|
||||||
this._previews = parsed.showPreviews;
|
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 {
|
public get showPreviews(): boolean {
|
||||||
return this._previews;
|
return this._previews;
|
||||||
}
|
}
|
||||||
|
@ -100,6 +112,7 @@ export class ListLayout {
|
||||||
return {
|
return {
|
||||||
numTiles: this.visibleTiles,
|
numTiles: this.visibleTiles,
|
||||||
showPreviews: this.showPreviews,
|
showPreviews: this.showPreviews,
|
||||||
|
collapsed: this.isCollapsed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue