first draft of generic popup and menu views

This commit is contained in:
Bruno Windels 2020-11-13 15:57:14 +01:00
parent 6fd10b63e5
commit 9bb521986b
6 changed files with 645 additions and 6 deletions

View file

@ -0,0 +1,378 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style type="text/css">
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
.container {
display: grid;
grid-template: "left middle" 1fr /
200px 1fr;
height: 100vh;
}
.container .left {
display: grid;
grid-template:
"welcome" auto
"rooms" 1fr /
1fr;
min-height: 0;
}
.container .middle {
display: grid;
grid-template:
"header" auto
"timeline" 1fr
"composer" auto /
1fr;
min-height: 0;
position: relative;
}
.left { grid-area: left;}
.left p {
grid-area welcome;
display: flex;
}
.left ul {
grid-area: rooms;
min-height: 0;
overflow-y: auto;
}
.middle { grid-area: middle;}
.middle .header { grid-area: header;}
.middle .timeline {
grid-area: timeline;
min-height: 0;
overflow-y: auto;
}
.middle .composer {
grid-area: composer;
}
.header {
display: flex;
}
.header h2 {
flex: 1;
}
.composer {
display: flex;
}
.composer input {
display: block;
flex: 1;
}
.menu {
position: absolute;
border-radius: 8px;
box-shadow: 2px 2px 10px rgba(0,0,0,0.5);
padding: 16px;
background-color: white;
z-index: 1;
list-style: none;
margin: 0;
}
</style>
</head>
<body>
<div class="container">
<div class="left">
<p>Welcome!<button></button></p>
<ul>
<li>Room xyz <button></button></li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz <button></button></li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz <button></button></li>
</ul>
</div>
<div class="middle">
<div class="header">
<h2>Room xyz</h2>
<button></button>
</div>
<ul class="timeline">
<li>Message abc</li>
<li>Message abc <button></button></li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc <button></button></li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc <button></button></li>
</ul>
<div class="composer">
<input type="text" name="">
<button></button>
</div>
</div>
</div>
<script type="text/javascript">
let menu;
function createMenu(options) {
const menu = document.createElement("ul");
menu.className = "menu";
for (const o of options) {
const li = document.createElement("li");
li.innerText = o;
menu.appendChild(li);
}
return menu;
}
function showMenu(evt) {
if (menu) {
menu = menu.close();
} else if (evt.target.tagName.toLowerCase() === "button") {
menu = showPopup(evt.target, createMenu(["Send file", "Save contact", "Send picture", "Foo the bar"]), {
horizontal: {
relativeTo: "end",
align: "start",
after: 0,
},
vertical: {
relativeTo: "end",
align: "end",
after: 10,
}
});
}
}
function showMenuInScroller(evt) {
if (!menu && evt.target.tagName.toLowerCase() === "button") {
evt.stopPropagation();
menu = showPopup(evt.target, createMenu(["Show reactions", "Share"]), {
horizontal: {
relativeTo: "start",
align: "end",
after: 10,
},
vertical: {
relativeTo: "start",
align: "center",
}
});
}
}
document.body.addEventListener("click", showMenu, false);
document.querySelector(".middle ul").addEventListener("click", showMenuInScroller, false);
document.querySelector(".left ul").addEventListener("click", showMenuInScroller, false);
function showPopup(target, popup, arrangement) {
targetAxes = elementToAxes(target);
if (!arrangement) {
arrangement = getAutoArrangement(targetAxes);
}
target.offsetParent.appendChild(popup);
const popupAxes = elementToAxes(popup);
const scrollerAxes = elementToAxes(findScrollParent(target));
const offsetParentAxes = elementToAxes(target.offsetParent);
function reposition() {
if (scrollerAxes && !isVisibleInScrollParent(targetAxes.vertical, scrollerAxes.vertical)) {
popupObj.close();
}
applyArrangement(
popupAxes.vertical,
targetAxes.vertical,
offsetParentAxes.vertical,
scrollerAxes?.vertical,
arrangement.vertical
);
applyArrangement(
popupAxes.horizontal,
targetAxes.horizontal,
offsetParentAxes.horizontal,
scrollerAxes?.horizontal,
arrangement.horizontal
);
}
reposition();
document.body.addEventListener("scroll", reposition, true);
const popupObj = {
close() {
document.body.removeEventListener("scroll", reposition, true);
popup.remove();
}
};
return popupObj;
}
function elementToAxes(element) {
if (element) {
return {
horizontal: new HorizontalAxis(element),
vertical: new VerticalAxis(element),
element
};
}
}
function findScrollParent(el) {
let parent = el;
do {
parent = parent.parentElement;
if (parent.scrollHeight > parent.clientHeight) {
return parent;
}
} while (parent !== el.offsetParent);
}
function isVisibleInScrollParent(targetAxis, scrollerAxis) {
// clipped at start?
if ((targetAxis.offsetStart + targetAxis.clientSize) < (
scrollerAxis.offsetStart +
scrollerAxis.scrollOffset
)) {
return false;
}
// clipped at end?
if (targetAxis.offsetStart > (
scrollerAxis.offsetStart +
scrollerAxis.clientSize +
scrollerAxis.scrollOffset
)) {
return false;
}
return true;
}
function applyArrangement(elAxis, targetAxis, offsetParentAxis, scrollerAxis, {relativeTo, align, before, after}) {
if (relativeTo === "end") {
let end = offsetParentAxis.clientSize - targetAxis.offsetStart;
if (align === "end") {
end -= elAxis.offsetSize;
} else if (align === "center") {
end -= ((elAxis.offsetSize / 2) - (targetAxis.offsetSize / 2));
}
if (typeof before === "number") {
end += before;
} else if (typeof after === "number") {
end -= (targetAxis.offsetSize + after);
}
elAxis.end = end;
} else if (relativeTo === "start") {
let scrollOffset = scrollerAxis?.scrollOffset || 0;
let start = targetAxis.offsetStart - scrollOffset;
if (align === "start") {
start -= elAxis.offsetSize;
} else if (align === "center") {
start -= ((elAxis.offsetSize / 2) - (targetAxis.offsetSize / 2));
}
if (typeof before === "number") {
start -= before;
} else if (typeof after === "number") {
start += (targetAxis.offsetSize + after);
}
elAxis.start = start;
} else {
throw new Error("unknown relativeTo: " + relativeTo);
}
}
class HorizontalAxis {
constructor(el) {
this.element = el;
}
get scrollOffset() {return this.element.scrollLeft;}
get clientSize() {return this.element.clientWidth;}
get offsetSize() {return this.element.offsetWidth;}
get offsetStart() {return this.element.offsetLeft;}
set start(value) {this.element.style.left = `${value}px`;}
set end(value) {this.element.style.right = `${value}px`;}
}
class VerticalAxis {
constructor(el) {
this.element = el;
}
get scrollOffset() {return this.element.scrollTop;}
get clientSize() {return this.element.clientHeight;}
get offsetSize() {return this.element.offsetHeight;}
get offsetStart() {return this.element.offsetTop;}
set start(value) {this.element.style.top = `${value}px`;}
set end(value) {this.element.style.bottom = `${value}px`;}
}
</script>
</body>
</html>

View file

@ -96,6 +96,8 @@ main {
width: 100%; width: 100%;
/* otherwise we don't get scrollbars and the content grows as large as it can */ /* otherwise we don't get scrollbars and the content grows as large as it can */
min-height: 0; min-height: 0;
/* make popups relative to this element so changing the left panel width doesn't affect their position */
position: relative;
} }
.RoomView { .RoomView {
@ -163,3 +165,8 @@ main {
z-index: 1; z-index: 1;
pointer-events: none; pointer-events: none;
} }
.menu {
position: absolute;
z-index: 2;
}

View file

@ -762,4 +762,31 @@ button.link {
width: 200px; width: 200px;
} }
.menu {
border-radius: 8px;
box-shadow: 2px 2px 10px rgba(0,0,0,0.5);
padding: 4px;
background-color: white;
list-style: none;
margin: 0;
}
.menu button {
border-radius: 4px;
display: block;
border: none;
width: 100%;
background-color: transparent;
text-align: left;
padding: 8px 32px 8px 8px;
}
.menu button:focus {
background-color: #03B381;
color: white;
}
.menu button:hover {
background-color: #03B381;
color: white;
}

View file

@ -0,0 +1,49 @@
/*
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.
*/
import {TemplateView} from "./TemplateView.js";
export class Menu extends TemplateView {
static option(label, callback) {
return new MenuOption(label, callback);
}
constructor(options) {
super();
this._options = options;
}
render(t) {
return t.ul({className: "menu", role: "menu"}, this._options.map(o => {
return t.li({
className: o.icon ? `icon ${o.icon}` : "",
}, t.button({onClick: o.callback}, o.label));
}));
}
}
class MenuOption {
constructor(label, callback) {
this.label = label;
this.callback = callback;
this.icon = null;
}
setIcon(className) {
this.icon = className;
return this;
}
}

View file

@ -0,0 +1,174 @@
/*
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.
*/
const HorizontalAxis = {
scrollOffset(el) {return el.scrollLeft;},
size(el) {return el.offsetWidth;},
offsetStart(el) {return el.offsetLeft;},
setStart(el, value) {el.style.left = `${value}px`;},
setEnd(el, value) {el.style.right = `${value}px`;},
};
const VerticalAxis = {
scrollOffset(el) {return el.scrollTop;},
size(el) {return el.offsetHeight;},
offsetStart(el) {return el.offsetTop;},
setStart(el, value) {el.style.top = `${value}px`;},
setEnd(el, value) {el.style.bottom = `${value}px`;},
};
export class Popup {
constructor(view) {
this._view = view;
this._target = null;
this._arrangement = null;
this._scroller = null;
this._fakeRoot = null;
this._trackingTemplateView = null;
}
trackInTemplateView(templateView) {
this._trackingTemplateView = templateView;
this._trackingTemplateView.addSubView(this);
}
showRelativeTo(target, arrangement) {
this._target = target;
this._arrangement = arrangement;
this._scroller = findScrollParent(this._target);
this._view.mount();
this._target.offsetParent.appendChild(this._popup);
this._applyArrangementAxis(HorizontalAxis, this._arrangement.horizontal);
this._applyArrangementAxis(VerticalAxis, this._arrangement.vertical);
if (this._scroller) {
document.body.addEventListener("scroll", this, true);
}
setTimeout(() => {
document.body.addEventListener("click", this, false);
}, 10);
}
close() {
this._view.unmount();
this._trackingTemplateView.removeSubView(this);
if (this._scroller) {
document.body.removeEventListener("scroll", this, true);
}
document.body.removeEventListener("click", this, false);
this._popup.remove();
}
get _popup() {
return this._view.root();
}
handleEvent(evt) {
if (evt.type === "scroll") {
this._onScroll();
} else if (evt.type === "click") {
this._onClick(evt);
}
}
_onScroll() {
if (this._scroller && !this._isVisibleInScrollParent(VerticalAxis)) {
this.close();
}
this._applyArrangementAxis(HorizontalAxis, this._arrangement.horizontal);
this._applyArrangementAxis(VerticalAxis, this._arrangement.vertical);
}
_onClick() {
this.close();
}
_applyArrangementAxis(axis, {relativeTo, align, before, after}) {
if (relativeTo === "end") {
let end = axis.size(this._target.offsetParent) - axis.offsetStart(this._target);
if (align === "end") {
end -= axis.size(this._popup);
} else if (align === "center") {
end -= ((axis.size(this._popup) / 2) - (axis.size(this._target) / 2));
}
if (typeof before === "number") {
end += before;
} else if (typeof after === "number") {
end -= (axis.size(this._target) + after);
}
axis.setEnd(this._popup, end);
} else if (relativeTo === "start") {
let scrollOffset = this._scroller ? axis.scrollOffset(this._scroller) : 0;
let start = axis.offsetStart(this._target) - scrollOffset;
if (align === "start") {
start -= axis.size(this._popup);
} else if (align === "center") {
start -= ((axis.size(this._popup) / 2) - (axis.size(this._target) / 2));
}
if (typeof before === "number") {
start -= before;
} else if (typeof after === "number") {
start += (axis.size(this._target) + after);
}
axis.setStart(this._popup, start);
} else {
throw new Error("unknown relativeTo: " + relativeTo);
}
}
_isVisibleInScrollParent(axis) {
// clipped at start?
if ((axis.offsetStart(this._target) + axis.size(this._target)) < (
axis.offsetStart(this._scroller) +
axis.scrollOffset(this._scroller)
)) {
return false;
}
// clipped at end?
if (axis.offsetStart(this._target) > (
axis.offsetStart(this._scroller) +
axis.size(this._scroller) +
axis.scrollOffset(this._scroller)
)) {
return false;
}
return true;
}
/* fake UIView api, so it can be tracked by a template view as a subview */
root() {
return this._fakeRoot;
}
mount() {
this._fakeRoot = document.createComment("popup");
return this._fakeRoot;
}
unmount() {
this.close();
}
update() {}
}
function findScrollParent(el) {
let parent = el;
do {
parent = parent.parentElement;
if (parent.scrollHeight > parent.clientHeight) {
return parent;
}
} while (parent !== el.offsetParent);
}

View file

@ -44,9 +44,6 @@ export class TemplateView {
this._render = render; this._render = render;
this._eventListeners = null; this._eventListeners = null;
this._bindings = null; this._bindings = null;
// this should become _subViews and also include templates.
// How do we know which ones we should update though?
// Wrapper class?
this._subViews = null; this._subViews = null;
this._root = null; this._root = null;
this._boundUpdateFromValue = null; this._boundUpdateFromValue = null;
@ -57,7 +54,7 @@ export class TemplateView {
} }
_subscribe() { _subscribe() {
if (typeof this._value.on === "function") { if (typeof this._value?.on === "function") {
this._boundUpdateFromValue = this._updateFromValue.bind(this); this._boundUpdateFromValue = this._updateFromValue.bind(this);
this._value.on("change", this._boundUpdateFromValue); this._value.on("change", this._boundUpdateFromValue);
} }
@ -146,12 +143,19 @@ export class TemplateView {
this._bindings.push(bindingFn); this._bindings.push(bindingFn);
} }
_addSubView(view) { addSubView(view) {
if (!this._subViews) { if (!this._subViews) {
this._subViews = []; this._subViews = [];
} }
this._subViews.push(view); this._subViews.push(view);
} }
removeSubView(view) {
const idx = this._subViews.indexOf(view);
if (idx !== -1) {
this._subViews.splice(idx, 1);
}
}
} }
// what is passed to render // what is passed to render
@ -288,7 +292,7 @@ class TemplateBuilder {
} catch (err) { } catch (err) {
return errorToDOM(err); return errorToDOM(err);
} }
this._templateView._addSubView(view); this._templateView.addSubView(view);
return root; return root;
} }