diff --git a/prototypes/menu-relative.html b/prototypes/menu-relative.html
new file mode 100644
index 00000000..8b1c79b0
--- /dev/null
+++ b/prototypes/menu-relative.html
@@ -0,0 +1,378 @@
+
+
+
+
+
+
+
+
+
+
Welcome!
+
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+ - Room xyz
+
+
+
+
+
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+ - Message abc
+
+
+
+
+
+
+
+
+
+
diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css
index c4a48425..841ab3ef 100644
--- a/src/platform/web/ui/css/layout.css
+++ b/src/platform/web/ui/css/layout.css
@@ -96,6 +96,8 @@ main {
width: 100%;
/* otherwise we don't get scrollbars and the content grows as large as it can */
min-height: 0;
+ /* make popups relative to this element so changing the left panel width doesn't affect their position */
+ position: relative;
}
.RoomView {
@@ -163,3 +165,8 @@ main {
z-index: 1;
pointer-events: none;
}
+
+.menu {
+ position: absolute;
+ z-index: 2;
+}
diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css
index 57bfc03b..99af88d0 100644
--- a/src/platform/web/ui/css/themes/element/theme.css
+++ b/src/platform/web/ui/css/themes/element/theme.css
@@ -762,4 +762,31 @@ button.link {
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;
+}
diff --git a/src/platform/web/ui/general/Menu.js b/src/platform/web/ui/general/Menu.js
new file mode 100644
index 00000000..10c5f07e
--- /dev/null
+++ b/src/platform/web/ui/general/Menu.js
@@ -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;
+ }
+}
diff --git a/src/platform/web/ui/general/Popup.js b/src/platform/web/ui/general/Popup.js
new file mode 100644
index 00000000..e6d18033
--- /dev/null
+++ b/src/platform/web/ui/general/Popup.js
@@ -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);
+}
diff --git a/src/platform/web/ui/general/TemplateView.js b/src/platform/web/ui/general/TemplateView.js
index 8158fcb3..14cb53ac 100644
--- a/src/platform/web/ui/general/TemplateView.js
+++ b/src/platform/web/ui/general/TemplateView.js
@@ -44,9 +44,6 @@ export class TemplateView {
this._render = render;
this._eventListeners = 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._root = null;
this._boundUpdateFromValue = null;
@@ -57,7 +54,7 @@ export class TemplateView {
}
_subscribe() {
- if (typeof this._value.on === "function") {
+ if (typeof this._value?.on === "function") {
this._boundUpdateFromValue = this._updateFromValue.bind(this);
this._value.on("change", this._boundUpdateFromValue);
}
@@ -146,12 +143,19 @@ export class TemplateView {
this._bindings.push(bindingFn);
}
- _addSubView(view) {
+ addSubView(view) {
if (!this._subViews) {
this._subViews = [];
}
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
@@ -288,7 +292,7 @@ class TemplateBuilder {
} catch (err) {
return errorToDOM(err);
}
- this._templateView._addSubView(view);
+ this._templateView.addSubView(view);
return root;
}