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

+ +
+ +
+ + +
+
+
+ + + 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; }