From 304568283b7f8a0d5e6fe613a30a9b717c7d20a0 Mon Sep 17 00:00:00 2001 From: Dave Conway-Jones Date: Mon, 16 May 2016 16:07:23 +0100 Subject: [PATCH] Add dialog polypill for "other" browsers to close #1 --- package.json | 2 +- worldmap/index.html | 11 +- worldmap/leaflet/dialog-polyfill.css | 40 ++ worldmap/leaflet/dialog-polyfill.js | 526 +++++++++++++++++++++++++++ 4 files changed, 575 insertions(+), 4 deletions(-) create mode 100644 worldmap/leaflet/dialog-polyfill.css create mode 100644 worldmap/leaflet/dialog-polyfill.js diff --git a/package.json b/package.json index ee67f79..7969c04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name" : "node-red-contrib-web-worldmap", - "version" : "0.0.12", + "version" : "0.0.13", "description" : "A Node-RED node to provide a web page of a world map for plotting things on.", "dependencies" : { "express": "4.*" diff --git a/worldmap/index.html b/worldmap/index.html index a20b6fc..6c2b021 100644 --- a/worldmap/index.html +++ b/worldmap/index.html @@ -47,6 +47,9 @@ + + + @@ -64,7 +67,7 @@ Auto Pan Map Lock Map Heatmap all layers - Help + Help
@@ -130,7 +133,7 @@ else { var ibmfoot = " © IBM 2015,2016" var isChrome = !!window.chrome; -if (!isChrome) { document.getElementById("showHelp").innerHTML=""; } +//if (!isChrome) { document.getElementById("showHelp").innerHTML=""; } function start(wsUri) { // Create the websocket ws = new WebSocket(wsUri); @@ -192,7 +195,9 @@ var clrHeat = L.easyButton( 'Reset Heatmap', function() { heat.setLatLngs([]); }, "Clears the current heatmap", "bottomright"); -var dialog = document.getElementById('helpWindow'); +//var dialog = document.getElementById('helpWindow'); +var dialog = document.querySelector('dialog'); +dialogPolyfill.registerDialog(dialog); document.getElementById('showHelp').onclick = function() { dialog.show(); }; diff --git a/worldmap/leaflet/dialog-polyfill.css b/worldmap/leaflet/dialog-polyfill.css new file mode 100644 index 0000000..1adc607 --- /dev/null +++ b/worldmap/leaflet/dialog-polyfill.css @@ -0,0 +1,40 @@ +dialog { + position: absolute; + left: 0; right: 0; + width: -moz-fit-content; + width: -webkit-fit-content; + width: fit-content; + height: -moz-fit-content; + height: -webkit-fit-content; + height: fit-content; + margin: auto; + border: solid; + padding: 1em; + background: white; + color: black; + display: none; +} + +dialog[open] { + display: block; +} + +dialog + .backdrop { + position: fixed; + top: 0; right: 0; bottom: 0; left: 0; + background: rgba(0,0,0,0.1); +} + +/* for small devices, modal dialogs go full-screen */ +@media screen and (max-width: 540px) { + dialog[_polyfill_modal] { /* TODO: implement */ + top: 0; + width: auto; + margin: 1em; + } +} + +._dialog_overlay { + position: fixed; + top: 0; right: 0; bottom: 0; left: 0; +} diff --git a/worldmap/leaflet/dialog-polyfill.js b/worldmap/leaflet/dialog-polyfill.js new file mode 100644 index 0000000..dc415f0 --- /dev/null +++ b/worldmap/leaflet/dialog-polyfill.js @@ -0,0 +1,526 @@ +(function() { + + var supportCustomEvent = window.CustomEvent; + if (!supportCustomEvent || typeof supportCustomEvent == 'object') { + supportCustomEvent = function CustomEvent(event, x) { + x = x || {}; + var ev = document.createEvent('CustomEvent'); + ev.initCustomEvent(event, !!x.bubbles, !!x.cancelable, x.detail || null); + return ev; + }; + supportCustomEvent.prototype = window.Event.prototype; + } + + /** + * Finds the nearest from the passed element. + * + * @param {Element} el to search from + * @return {HTMLDialogElement} dialog found + */ + function findNearestDialog(el) { + while (el) { + if (el.nodeName.toUpperCase() == 'DIALOG') { + return /** @type {HTMLDialogElement} */ (el); + } + el = el.parentElement; + } + return null; + } + + /** + * Blur the specified element, as long as it's not the HTML body element. + * This works around an IE9/10 bug - blurring the body causes Windows to + * blur the whole application. + * + * @param {Element} el to blur + */ + function safeBlur(el) { + if (el && el.blur && el != document.body) { + el.blur(); + } + } + + /** + * @param {!NodeList} nodeList to search + * @param {Node} node to find + * @return {boolean} whether node is inside nodeList + */ + function inNodeList(nodeList, node) { + for (var i = 0; i < nodeList.length; ++i) { + if (nodeList[i] == node) { + return true; + } + } + return false; + } + + /** + * @param {!HTMLDialogElement} dialog to upgrade + * @constructor + */ + function dialogPolyfillInfo(dialog) { + this.dialog_ = dialog; + this.replacedStyleTop_ = false; + this.openAsModal_ = false; + + // Set a11y role. Browsers that support dialog implicitly know this already. + if (!dialog.hasAttribute('role')) { + dialog.setAttribute('role', 'dialog'); + } + + dialog.show = this.show.bind(this); + dialog.showModal = this.showModal.bind(this); + dialog.close = this.close.bind(this); + + if (!('returnValue' in dialog)) { + dialog.returnValue = ''; + } + + this.maybeHideModal = this.maybeHideModal.bind(this); + if ('MutationObserver' in window) { + // IE11+, most other browsers. + var mo = new MutationObserver(this.maybeHideModal); + mo.observe(dialog, { attributes: true, attributeFilter: ['open'] }); + } else { + dialog.addEventListener('DOMAttrModified', this.maybeHideModal); + } + // Note that the DOM is observed inside DialogManager while any dialog + // is being displayed as a modal, to catch modal removal from the DOM. + + Object.defineProperty(dialog, 'open', { + set: this.setOpen.bind(this), + get: dialog.hasAttribute.bind(dialog, 'open') + }); + + this.backdrop_ = document.createElement('div'); + this.backdrop_.className = 'backdrop'; + this.backdropClick_ = this.backdropClick_.bind(this); + } + + dialogPolyfillInfo.prototype = { + + get dialog() { + return this.dialog_; + }, + + /** + * Maybe remove this dialog from the modal top layer. This is called when + * a modal dialog may no longer be tenable, e.g., when the dialog is no + * longer open or is no longer part of the DOM. + */ + maybeHideModal: function() { + if (!this.openAsModal_) { return; } + if (this.dialog_.hasAttribute('open') && + document.body.contains(this.dialog_)) { return; } + + this.openAsModal_ = false; + this.dialog_.style.zIndex = ''; + + // This won't match the native exactly because if the user set + // top on a centered polyfill dialog, that top gets thrown away when the + // dialog is closed. Not sure it's possible to polyfill this perfectly. + if (this.replacedStyleTop_) { + this.dialog_.style.top = ''; + this.replacedStyleTop_ = false; + } + + // Optimistically clear the modal part of this . + this.backdrop_.removeEventListener('click', this.backdropClick_); + if (this.backdrop_.parentElement) { + this.backdrop_.parentElement.removeChild(this.backdrop_); + } + dialogPolyfill.dm.removeDialog(this); + }, + + /** + * @param {boolean} value whether to open or close this dialog + */ + setOpen: function(value) { + if (value) { + this.dialog_.hasAttribute('open') || this.dialog_.setAttribute('open', ''); + } else { + this.dialog_.removeAttribute('open'); + this.maybeHideModal(); // nb. redundant with MutationObserver + } + }, + + /** + * Handles clicks on the fake .backdrop element, redirecting them as if + * they were on the dialog itself. + * + * @param {!Event} e to redirect + */ + backdropClick_: function(e) { + var redirectedEvent = document.createEvent('MouseEvents'); + redirectedEvent.initMouseEvent(e.type, e.bubbles, e.cancelable, window, + e.detail, e.screenX, e.screenY, e.clientX, e.clientY, e.ctrlKey, + e.altKey, e.shiftKey, e.metaKey, e.button, e.relatedTarget); + this.dialog_.dispatchEvent(redirectedEvent); + e.stopPropagation(); + }, + + /** + * Focuses on the first focusable element within the dialog. This will always blur the current + * focus, even if nothing within the dialog is found. + */ + focus_: function() { + // Find element with `autofocus` attribute, or fall back to the first form/tabindex control. + var target = this.dialog_.querySelector('[autofocus]:not([disabled])'); + if (!target) { + // Note that this is 'any focusable area'. This list is probably not exhaustive, but the + // alternative involves stepping through and trying to focus everything. + var opts = ['button', 'input', 'keygen', 'select', 'textarea']; + var query = opts.map(function(el) { + return el + ':not([disabled])'; + }); + // TODO(samthor): tabindex values that are not numeric are not focusable. + query.push('[tabindex]:not([disabled]):not([tabindex=""])'); // tabindex != "", not disabled + target = this.dialog_.querySelector(query.join(', ')); + } + safeBlur(document.activeElement); + target && target.focus(); + }, + + /** + * Sets the zIndex for the backdrop and dialog. + * + * @param {number} backdropZ + * @param {number} dialogZ + */ + updateZIndex: function(backdropZ, dialogZ) { + this.backdrop_.style.zIndex = backdropZ; + this.dialog_.style.zIndex = dialogZ; + }, + + /** + * Shows the dialog. If the dialog is already open, this does nothing. + */ + show: function() { + if (!this.dialog_.open) { + this.setOpen(true); + this.focus_(); + } + }, + + /** + * Show this dialog modally. + */ + showModal: function() { + if (this.dialog_.hasAttribute('open')) { + throw new Error('Failed to execute \'showModal\' on dialog: The element is already open, and therefore cannot be opened modally.'); + } + if (!document.body.contains(this.dialog_)) { + throw new Error('Failed to execute \'showModal\' on dialog: The element is not in a Document.'); + } + if (!dialogPolyfill.dm.pushDialog(this)) { + throw new Error('Failed to execute \'showModal\' on dialog: There are too many open modal dialogs.'); + } + this.show(); + this.openAsModal_ = true; + + // Optionally center vertically, relative to the current viewport. + if (dialogPolyfill.needsCentering(this.dialog_)) { + dialogPolyfill.reposition(this.dialog_); + this.replacedStyleTop_ = true; + } else { + this.replacedStyleTop_ = false; + } + + // Insert backdrop. + this.backdrop_.addEventListener('click', this.backdropClick_); + this.dialog_.parentNode.insertBefore(this.backdrop_, + this.dialog_.nextSibling); + }, + + /** + * Closes this HTMLDialogElement. This is optional vs clearing the open + * attribute, however this fires a 'close' event. + * + * @param {string=} opt_returnValue to use as the returnValue + */ + close: function(opt_returnValue) { + if (!this.dialog_.hasAttribute('open')) { + throw new Error('Failed to execute \'close\' on dialog: The element does not have an \'open\' attribute, and therefore cannot be closed.'); + } + this.setOpen(false); + + // Leave returnValue untouched in case it was set directly on the element + if (opt_returnValue !== undefined) { + this.dialog_.returnValue = opt_returnValue; + } + + // Triggering "close" event for any attached listeners on the . + var closeEvent = new supportCustomEvent('close', { + bubbles: false, + cancelable: false + }); + this.dialog_.dispatchEvent(closeEvent); + } + + }; + + var dialogPolyfill = {}; + + dialogPolyfill.reposition = function(element) { + var scrollTop = document.body.scrollTop || document.documentElement.scrollTop; + var topValue = scrollTop + (window.innerHeight - element.offsetHeight) / 2; + element.style.top = Math.max(scrollTop, topValue) + 'px'; + }; + + dialogPolyfill.isInlinePositionSetByStylesheet = function(element) { + for (var i = 0; i < document.styleSheets.length; ++i) { + var styleSheet = document.styleSheets[i]; + var cssRules = null; + // Some browsers throw on cssRules. + try { + cssRules = styleSheet.cssRules; + } catch (e) {} + if (!cssRules) + continue; + for (var j = 0; j < cssRules.length; ++j) { + var rule = cssRules[j]; + var selectedNodes = null; + // Ignore errors on invalid selector texts. + try { + selectedNodes = document.querySelectorAll(rule.selectorText); + } catch(e) {} + if (!selectedNodes || !inNodeList(selectedNodes, element)) + continue; + var cssTop = rule.style.getPropertyValue('top'); + var cssBottom = rule.style.getPropertyValue('bottom'); + if ((cssTop && cssTop != 'auto') || (cssBottom && cssBottom != 'auto')) + return true; + } + } + return false; + }; + + dialogPolyfill.needsCentering = function(dialog) { + var computedStyle = window.getComputedStyle(dialog); + if (computedStyle.position != 'absolute') { + return false; + } + + // We must determine whether the top/bottom specified value is non-auto. In + // WebKit/Blink, checking computedStyle.top == 'auto' is sufficient, but + // Firefox returns the used value. So we do this crazy thing instead: check + // the inline style and then go through CSS rules. + if ((dialog.style.top != 'auto' && dialog.style.top != '') || + (dialog.style.bottom != 'auto' && dialog.style.bottom != '')) + return false; + return !dialogPolyfill.isInlinePositionSetByStylesheet(dialog); + }; + + /** + * @param {!Element} element to force upgrade + */ + dialogPolyfill.forceRegisterDialog = function(element) { + if (element.showModal) { + console.warn('This browser already supports , the polyfill ' + + 'may not work correctly', element); + } + if (element.nodeName.toUpperCase() != 'DIALOG') { + throw new Error('Failed to register dialog: The element is not a dialog.'); + } + new dialogPolyfillInfo(/** @type {!HTMLDialogElement} */ (element)); + }; + + /** + * @param {!Element} element to upgrade + */ + dialogPolyfill.registerDialog = function(element) { + if (element.showModal) { + console.warn('Can\'t upgrade : already supported', element); + } else { + dialogPolyfill.forceRegisterDialog(element); + } + }; + + /** + * @constructor + */ + dialogPolyfill.DialogManager = function() { + /** @type {!Array} */ + this.pendingDialogStack = []; + + // The overlay is used to simulate how a modal dialog blocks the document. + // The blocking dialog is positioned on top of the overlay, and the rest of + // the dialogs on the pending dialog stack are positioned below it. In the + // actual implementation, the modal dialog stacking is controlled by the + // top layer, where z-index has no effect. + this.overlay = document.createElement('div'); + this.overlay.className = '_dialog_overlay'; + this.overlay.addEventListener('click', function(e) { + e.stopPropagation(); + }); + + this.handleKey_ = this.handleKey_.bind(this); + this.handleFocus_ = this.handleFocus_.bind(this); + this.handleRemove_ = this.handleRemove_.bind(this); + + this.zIndexLow_ = 100000; + this.zIndexHigh_ = 100000 + 150; + }; + + /** + * @return {Element} the top HTML dialog element, if any + */ + dialogPolyfill.DialogManager.prototype.topDialogElement = function() { + if (this.pendingDialogStack.length) { + var t = this.pendingDialogStack[this.pendingDialogStack.length - 1]; + return t.dialog; + } + return null; + }; + + /** + * Called on the first modal dialog being shown. Adds the overlay and related + * handlers. + */ + dialogPolyfill.DialogManager.prototype.blockDocument = function() { + document.body.appendChild(this.overlay); + document.body.addEventListener('focus', this.handleFocus_, true); + document.addEventListener('keydown', this.handleKey_); + document.addEventListener('DOMNodeRemoved', this.handleRemove_); + }; + + /** + * Called on the first modal dialog being removed, i.e., when no more modal + * dialogs are visible. + */ + dialogPolyfill.DialogManager.prototype.unblockDocument = function() { + document.body.removeChild(this.overlay); + document.body.removeEventListener('focus', this.handleFocus_, true); + document.removeEventListener('keydown', this.handleKey_); + document.removeEventListener('DOMNodeRemoved', this.handleRemove_); + }; + + dialogPolyfill.DialogManager.prototype.updateStacking = function() { + var zIndex = this.zIndexLow_; + + for (var i = 0; i < this.pendingDialogStack.length; i++) { + if (i == this.pendingDialogStack.length - 1) { + this.overlay.style.zIndex = zIndex++; + } + this.pendingDialogStack[i].updateZIndex(zIndex++, zIndex++); + } + }; + + dialogPolyfill.DialogManager.prototype.handleFocus_ = function(event) { + var candidate = findNearestDialog(/** @type {Element} */ (event.target)); + if (candidate != this.topDialogElement()) { + event.preventDefault(); + event.stopPropagation(); + safeBlur(/** @type {Element} */ (event.target)); + // TODO: Focus on the browser chrome (aka document) or the dialog itself + // depending on the tab direction. + return false; + } + }; + + dialogPolyfill.DialogManager.prototype.handleKey_ = function(event) { + if (event.keyCode == 27) { + event.preventDefault(); + event.stopPropagation(); + var cancelEvent = new supportCustomEvent('cancel', { + bubbles: false, + cancelable: true + }); + var dialog = this.topDialogElement(); + if (dialog.dispatchEvent(cancelEvent)) { + dialog.close(); + } + } + }; + + dialogPolyfill.DialogManager.prototype.handleRemove_ = function(event) { + if (event.target.nodeName.toUpperCase() != 'DIALOG') { return; } + + var dialog = /** @type {HTMLDialogElement} */ (event.target); + if (!dialog.open) { return; } + + // Find a dialogPolyfillInfo which matches the removed . + this.pendingDialogStack.some(function(dpi) { + if (dpi.dialog == dialog) { + // This call will clear the dialogPolyfillInfo on this DialogManager + // as a side effect. + dpi.maybeHideModal(); + return true; + } + }); + }; + + /** + * @param {!dialogPolyfillInfo} dpi + * @return {boolean} whether the dialog was allowed + */ + dialogPolyfill.DialogManager.prototype.pushDialog = function(dpi) { + var allowed = (this.zIndexHigh_ - this.zIndexLow_) / 2 - 1; + if (this.pendingDialogStack.length >= allowed) { + return false; + } + this.pendingDialogStack.push(dpi); + if (this.pendingDialogStack.length == 1) { + this.blockDocument(); + } + this.updateStacking(); + return true; + }; + + /** + * @param {dialogPolyfillInfo} dpi + */ + dialogPolyfill.DialogManager.prototype.removeDialog = function(dpi) { + var index = this.pendingDialogStack.indexOf(dpi); + if (index == -1) { return; } + + this.pendingDialogStack.splice(index, 1); + this.updateStacking(); + if (this.pendingDialogStack.length == 0) { + this.unblockDocument(); + } + }; + + dialogPolyfill.dm = new dialogPolyfill.DialogManager(); + + /** + * Global form 'dialog' method handler. Closes a dialog correctly on submit + * and possibly sets its return value. + */ + document.addEventListener('submit', function(ev) { + var target = ev.target; + if (!target || !target.hasAttribute('method')) { return; } + if (target.getAttribute('method').toLowerCase() != 'dialog') { return; } + ev.preventDefault(); + + var dialog = findNearestDialog(/** @type {Element} */ (ev.target)); + if (!dialog) { return; } + + // FIXME: The original event doesn't contain the element used to submit the + // form (if any). Look in some possible places. + var returnValue; + var cands = [document.activeElement, ev.explicitOriginalTarget]; + var els = ['BUTTON', 'INPUT']; + cands.some(function(cand) { + if (cand && cand.form == ev.target && els.indexOf(cand.nodeName.toUpperCase()) != -1) { + returnValue = cand.value; + return true; + } + }); + dialog.close(returnValue); + }, true); + + dialogPolyfill['forceRegisterDialog'] = dialogPolyfill.forceRegisterDialog; + dialogPolyfill['registerDialog'] = dialogPolyfill.registerDialog; + + if (typeof define === 'function' && 'amd' in define) { + // AMD support + define(function() { return dialogPolyfill; }); + } else if (typeof module === 'object' && typeof module['exports'] === 'object') { + // CommonJS support + module['exports'] = dialogPolyfill; + } else { + // all others + window['dialogPolyfill'] = dialogPolyfill; + } +})();