Przeglądaj źródła

U buildDom; + modal.js

Piotr Labudda 5 lat temu
rodzic
commit
e47275ebf6
3 zmienionych plików z 977 dodań i 1 usunięć
  1. 26 1
      SE/static/p5UI/buildDom.js
  2. 100 0
      SE/static/p5UI/modal.css
  3. 851 0
      SE/static/p5UI/modal.js

+ 26 - 1
SE/static/p5UI/buildDom.js

@@ -6,7 +6,8 @@
 	var ReactDOM = p5VendorJs.ReactDOM
 	var createReactClass = p5VendorJs.createReactClass
 	var h = React.createElement
-	var DBG = 0
+	var DBG = 0;
+	var DBG1 = 1;
 
 	// global.p5VendorJs['P5UI__MyWidget'] = createReactClass({
 	// 	componentWillMount: function () { console.log('MyWidget::componentWillMount...'); },
@@ -692,7 +693,11 @@
 		if (null === dom) return null
 		if ('string' === typeof dom) return dom
 		var nodeReactType = dom[0]
+		if ('p5:' === dom[0].substr(0, 'p5:'.length)) { // eg. 'p5:Alert'
+			nodeReactType = 'P5UI__' + dom[0].substr('p5:'.length)
+		}
 		if ('P5UI__' === nodeReactType.substr(0, 'P5UI__'.length)) {
+			// TODO: if (!global.p5VendorJs[nodeReactType]) throw Exception or try to load external resource and complete after finish loading
 			if (nodeReactType in global.p5VendorJs) nodeReactType = global.p5VendorJs[nodeReactType]
 		}
 		return h(nodeReactType,
@@ -710,6 +715,26 @@
 			attrs['className'] = attrs['class']
 			delete attrs['class']
 		}
+		if ('style' in attrs) {
+			DBG && console.log('DBG:convertAttrsToReact style', { style: attrs['style'], isString: 'string' === typeof attrs['style'], attrs });
+			if ('string' === typeof attrs['style']) {
+				attrs['style'] = attrs['style'].split(';').reduce(function (ret, st) {
+					if (-1 !== st.indexOf(':')) {
+						var stParts = st.split(':');
+						var key = stParts[0];
+						var val = stParts[1];
+						// TODO: browser specific key: '--...'
+						if (-1 !== key.indexOf('-')) {
+							var keyParts = key.split('-');
+							key = keyParts[0] + keyParts[1].charAt(0).toUpperCase() + keyParts[1].slice(1);
+						}
+						ret[key] = val;
+					}
+					return ret;
+				}, {});
+				DBG && console.log('DBG:convertAttrsToReact style fixed', { style: attrs['style'] });
+			}
+		}
 		if ('input' === tagName && 'value' in attrs) { // fix input to uncontrolled
 			attrs['defaultValue'] = attrs['value']
 			delete attrs['value']

+ 100 - 0
SE/static/p5UI/modal.css

@@ -0,0 +1,100 @@
+
+/*
+p5-modal--overlay {
+	display: block;
+	position: fixed;
+	z-index: 10;
+	left: 0;
+	top: 0;
+	width: 100%;
+	height: 100%;
+	overflow: auto;
+	background-color: #000;
+	background-color: #0009;
+}
+p5-modal--content {
+	box-sizing: border-box;
+	background-color: #fefefe;
+	padding: 24px 32px;
+};
+p5-modal--title {
+	box-sizing: border-box;
+	background-color: #fefefe;
+	margin: 0;
+	padding: 6px 12px;
+	borderBottom: 1px solid #ddd;
+	font-size: 14px;
+};
+p5-modal--body {
+	box-sizing: border-box;
+	background-color: #fff;
+	margin: 0;
+	padding: 6px 12px;
+	font-size: 14px;
+	line-height: 1.6em;
+};
+p5-modal--footer {
+	box-sizing: border-box;
+	background-color: #fefefe;
+	margin: 0;
+	padding: 6px 12px;
+	border-top: 1px solid #ddd;
+	font-size: 14px;
+};
+*/
+
+
+.p5-dropdown--overlay {
+	display: block;
+	position: absolute;
+	z-index: 10;
+	left: 0;
+	top: 0;
+	width: 100%;
+	height: 100%;
+	overflow: auto;
+	background-color: #000;
+	background-color: #0003;
+}
+.p5-dropdown--content {
+	box-sizing: border-box;
+	background-color: #fefefe;
+	position: absolute;
+	padding: 8px 0;
+	border: 1px solid #ccc;
+	border-radius: 8px;
+	box-shadow: 0 6px 12px rgba(0,0,0,.175);
+}
+.p5-dropdown--title {
+	display: none;
+}
+.p5-dropdown--body {
+	box-sizing: border-box;
+	background-color: #fff;
+	margin: 0;
+	border: none;
+	padding: 0;
+	text-align: left;
+	font-size: 14px;
+	line-height: 1.6em;
+}
+.p5-dropdown--footer {
+	display: none;
+}
+
+
+.p5-dropdown--item { /* bootstrap .dropdown-menu>li>a */
+	display: block;
+	padding: 3px 20px;
+	clear: both;
+	font-weight: 400;
+	line-height: 1.42857143;
+	color: #333;
+	white-space: nowrap;
+}
+.p5-dropdown--item:hover {
+	color: #262626;
+	text-decoration: none;
+	background-color: #f5f5f5;
+	cursor: pointer;
+}

+ 851 - 0
SE/static/p5UI/modal.js

@@ -0,0 +1,851 @@
+var DBG = 0;
+var DBG1 = 1;
+
+// Multiple modal require global state with list of opened modal:
+// - open (example: btn: p5Modal.openModal(event, this))
+// - close (ESC key, 'x' btn, click on overlay behind modal, ?cancel btn)
+
+// - only one overlay - under top modal
+// - when top modal closed, and has other modal, then show overlay under next top modal
+
+function p5UI__openModal__TORM(event, targetNode, props) {
+	event.stopPropagation()
+	event.preventDefault()
+	var defaultProps = {
+		source: null, // node id or null
+	};
+	var props = props || defaultProps;
+
+	if (!targetNode) targetNode = event.target;
+
+	// https://dev.to/aleksandrhovhannisyan/multiple-modals-on-one-page-using-html-css-and-javascript-353b#4-closing-a-stacked-modal-with-the-escape-key
+
+	// IDEA: global p5Modal manager which generates unique ids and manage close and other actions
+	p5Modal.openModal(targetNode)
+
+	// var name = targetNode.getAttribute('data-name');
+	// if (!name) throw "Missing 'name' in sidebar panel button";
+
+	// var idHtmlNode = 'p5__js-p5-side_panel-' + name;
+
+	// var panelNode = document.getElementById(idHtmlNode)
+	// if (!panelNode) throw "Missing content node";
+
+	// // if (panelNode.parentNode !== document.body)
+
+	// DBG1 && console.log("DBG:p5UI__openModal__TORM", {
+	// 	targetNode,
+	// 	name,
+	// 	parent: panelNode.parentNode,
+	// 	parentDiffBody: (panelNode.parentNode !== document.body),
+	// });
+
+	// if (panelNode.parentNode !== document.body) { // mv at the end of body if not
+	// 	document.body.appendChild(panelNode)
+	// }
+	// if (!panelNode._p5_onClick) panelNode._p5_onClick = function (event) {
+	// 	// DBG1 && console.log("DBG:_p5_onClick", { self: this })
+	// 	if (hasClass(event.target, 'p5-side_panel--js-close') || hasId(event.target, idHtmlNode)) {
+	// 		event.preventDefault();
+	// 		// removeClass(panelNode, 'p5-side_panel--is-visible');
+	// 		_closeSidePanel(panelNode); // TODO: check use this, not panelNode
+	// 	}
+	// }
+	// if (!panelNode._p5_onEsc) panelNode._p5_onEsc = function (event) {
+	// 	// DBG1 && console.log("DBG:_p5_onEsc", { self: this })
+	// 	if (event.keyCode == 27) {
+	// 		_closeSidePanel(panelNode); // TODO: check use this, not panelNode
+	// 	}
+	// }
+
+	// // p5-side_panel p5-side_panel--from-right js-p5-side_panel-main p5-side_panel--is-visible
+	// addClass(panelNode, 'p5-side_panel--from-right'); // TODO: from props: left | right
+	// if (hasClass(panelNode, 'p5-side_panel--is-visible')) {
+	// 	// removeClass(panelNode, 'p5-side_panel--is-visible');
+	// 	_closeSidePanel(panelNode);
+	// } else {
+	// 	setTimeout(function () {
+	// 		// addClass(panelNode, 'p5-side_panel--is-visible')
+	// 		_openSidePanel(panelNode)
+	// 	}, 10)
+	// }
+}
+
+
+// https://dev.to/aleksandrhovhannisyan/multiple-modals-on-one-page-using-html-css-and-javascript-353b#4-closing-a-stacked-modal-with-the-escape-key
+
+// IDEA: global p5Modal manager which generates unique ids and manage close and other actions (refresh page/widget, go to url, etc.)
+
+if (!global.p5Modal) { // TODO: mv to global file
+
+	global.p5Modal = (function () {
+		var _openedModal = []; // [ props, ... ]
+		var _actionAfterClose = []; // [ '' | 'reload-page' | 'reload-#id' | 'reload-.class' , ... ]
+		// fire every _actionAfterClose after close last modal
+		// - if 'reload-page' in _actionAfterClose then only reload page
+		// fill _actionAfterClose after every modal close
+
+		var _escKeyListener = null;
+		function _addEscKeyListener() {
+			DBG && console.log('DBG:p5Modal:_addEscKeyListener #1');
+			if (_escKeyListener) return;
+			DBG && console.log('DBG:p5Modal:_addEscKeyListener #2');
+
+			_escKeyListener = true;
+			document.addEventListener('keyup', _handleEscKey);
+		}
+		function _removeEscKeyListener() {
+			document.removeEventListener('keyup', _handleEscKey);
+			_escKeyListener = false;
+		}
+		function _handleEscKey(event) {
+			DBG && console.log('DBG:p5Modal:onkeyup:_handleEscKey', {
+				keyCode: event.keyCode,
+				code: event.code,
+				key: event.key,
+				_openedModal: _openedModal,
+			});
+			// if ("Escape" === event.key)
+			if (27 === event.keyCode) {
+				_closeLastOpenedModal();
+			}
+		}
+		function _closeLastOpenedModal() {
+			if (!_openedModal.length) {
+				// TODO: remove key listener
+				return;
+			}
+			var lastModalProps = _openedModal.pop();
+			var lastModalId = _openedModal.length;
+			var lastModalNode = document.getElementById(_modalHtmlId(lastModalId));
+			DBG && console.log('DBG:p5Modal:_closeLastOpenedModal', {
+				_openedModal: _openedModal,
+				lastModalProps: lastModalProps,
+				lastModalId: lastModalId,
+			});
+
+			// TODO: check props - is close allowed?
+
+			if (lastModalNode) {
+				lastModalNode.parentNode.removeChild(lastModalNode);
+			}
+
+			if (!_openedModal.length) _removeEscKeyListener();
+		}
+
+		function _px(size) { return '' + size + 'px'; }
+		var style = {};
+		// { w: window.innerWidth, h: window.innerHeight }
+
+		style.baseModal = {};
+		style.baseModal.overlay = {
+			display: 'block',
+			position: 'fixed',
+			zIndex: 10,
+			left: 0,
+			top: 0,
+			width: '100%',
+			height: '100%',
+			overflow: 'auto',
+			backgroundColor: '#000',
+			backgroundColor: '#0009',
+		};
+		style.baseModal.content = {
+			boxSizing: 'border-box',
+			backgroundColor: '#fefefe',
+			padding: '24px 32px',
+		};
+		style.baseModal.title = {
+			boxSizing: 'border-box',
+			backgroundColor: '#fefefe',
+			margin: '0',
+			padding: '6px 12px',
+			borderBottom: '1px solid #ddd',
+			fontSize: '14px',
+		};
+		style.baseModal.body = {
+			boxSizing: 'border-box',
+			backgroundColor: '#fff',
+			margin: '0',
+			padding: '6px 12px',
+			fontSize: '14px',
+			lineHeight: '1.6em',
+		};
+		style.baseModal.footer = {
+			boxSizing: 'border-box',
+			backgroundColor: '#fefefe',
+			margin: '0',
+			padding: '6px 12px',
+			borderTop: '1px solid #ddd',
+			fontSize: '14px',
+		};
+
+		var sidePanelWidth = Math.round(window.innerWidth * 0.8); // "80%",
+		style.side_panel = Object.assign({}, style.baseModal, {
+			overlay: Object.assign({}, style.baseModal.overlay),
+			content: Object.assign({}, style.baseModal.content, {
+				marginLeft: 'auto',
+				width: _px(sidePanelWidth),
+				height: _px(window.innerHeight),
+				border: 'none',
+				padding: '0',
+			}),
+			title: Object.assign({}, style.baseModal.title, {
+				padding: '0 24px',
+				height: '40px',
+				lineHeight: '40px',
+			}),
+			body: Object.assign({}, style.baseModal.body, {
+				height: _px(window.innerHeight - 40 - 40),
+				overflow: 'auto', // TODO: only Y ?
+				padding: '12px 24px',
+			}),
+			footer: Object.assign({}, style.baseModal.footer, {
+				width: _px(sidePanelWidth),
+				padding: '0 24px',
+				height: '40px',
+				lineHeight: '40px',
+			}),
+		});
+
+		style.modal = Object.assign({}, style.baseModal, {
+			overlay: Object.assign({}, style.baseModal.overlay),
+			content: Object.assign({}, style.baseModal.content, {
+				marginTop: _px(Math.round(window.innerHeight * 0.1)),
+				marginBottom: _px(Math.round(window.innerHeight * 0.1)),
+				marginLeft: 'auto',
+				marginRight: 'auto',
+				width: '80%',
+				maxWidth: '800px',
+				border: '1px solid #888',
+				borderRadius: '8px',
+			}),
+			title: Object.assign({}, style.baseModal.title, {
+				padding: '0 12px 24px 12px',
+				fontSize: '18px',
+				lineHeight: '2em',
+				textAlign: 'center',
+				border: 'none',
+			}),
+			body: Object.assign({}, style.baseModal.body, {
+				textAlign: 'center',
+				border: 'none',
+			}),
+			footer: Object.assign({}, style.baseModal.footer, {
+				padding: '24px 12px 0 12px',
+				textAlign: 'center',
+				border: 'none',
+			}),
+		});
+
+		// TODO: if body scroll
+		style.alert = Object.assign({}, style.baseModal, {
+			overlay: Object.assign({}, style.baseModal.overlay),
+			content: Object.assign({}, style.baseModal.content, {
+				width: '60%',
+				maxWidth: '800px',
+				marginTop: _px(Math.round(window.innerHeight * 0.3)),
+				marginBottom: _px(Math.round(window.innerHeight * 0.3)),
+				marginLeft: 'auto',
+				marginRight: 'auto',
+				padding: '24px',
+				border: '1px solid #888',
+				borderRadius: '8px',
+			}),
+			title: Object.assign({}, style.baseModal.title, {
+				padding: '0 12px 24px 12px',
+				fontSize: '20px',
+				lineHeight: '2em',
+				textAlign: 'center',
+				border: 'none',
+			}),
+			body: Object.assign({}, style.baseModal.body, {
+				textAlign: 'center',
+				border: 'none',
+			}),
+			footer: Object.assign({}, style.baseModal.footer, {
+				padding: '24px 12px 0 12px',
+				textAlign: 'center',
+				border: 'none',
+			}),
+		});
+
+		style.dropdownUnder = Object.assign({}, style.baseModal, {
+			overlay: Object.assign({}, style.baseModal.overlay, {
+				// display: 'block',
+				position: 'absolute',
+				// zIndex: 10,
+				// left: 0,
+				// top: 0,
+				// { w: window.innerWidth, h: window.innerHeight }
+				width: '100%',
+				height: '100%',
+				// overflow: 'auto',
+				// backgroundColor: '#000',
+				backgroundColor: '#0003', // '#0009',
+			}),
+			content: Object.assign({}, style.baseModal.content, {
+				position: 'absolute',
+				// top, left: under targetNode (button)
+				// width: '60%',
+				// maxWidth: '800px',
+				padding: '8px 0',
+				border: '1px solid #ccc',
+				borderRadius: '8px',
+				boxShadow: '0 6px 12px rgba(0,0,0,.175)',
+			}),
+			title: Object.assign({}, style.baseModal.title, {
+				display: 'none',
+			}),
+			body: Object.assign({}, style.baseModal.body, {
+				padding: '0 12px',
+				textAlign: 'left',
+				border: 'none',
+			}),
+			footer: Object.assign({}, style.baseModal.footer, {
+				display: 'none',
+			}),
+		});
+
+		function _style(type) {
+			return (type in style) ? style[type] : style.baseModal;
+		}
+
+		function _getPropsFromAttr(node) {
+			// 'title' => V::get('title', '', $props),
+			// 'data-url' => V::get('href', '', $props),
+			// 'data-cancel' => V::get('cancel', '', $props),
+			// 'data-cancel-label' => V::get('cancel-label', '', $props),
+			// 'data-success-reload' => V::get('success-reload', '', $props),
+			var props = {};
+			if (node.hasAttribute('data-source')) props.source = node.getAttribute('data-source');
+			return props;
+		}
+
+		function _openModal(event, targetNode, props) {
+			event.stopPropagation();
+			event.preventDefault();
+			targetNode.blur(); // loose focus from element
+			var modal = _createModal('modal', targetNode || event.target, props || {});
+			_setModalContent('modal', modal);
+			document.body.appendChild(modal.overlay);
+			modal.content.focus(); // TODO: BUG hit Enter after open modal will open the same modal on top of last
+		}
+
+		function _openSidePanel(event, targetNode, props) {
+			event.stopPropagation();
+			event.preventDefault();
+			DBG && console.log('DBG:p5Modal:_openSidePanel:', { targetNode, props });
+			targetNode.blur(); // loose focus from element
+			var modal = _createModal('side_panel', targetNode || event.target, props || {});
+			_setModalContent('side_panel', modal, props || {}); // require props.body = function (modal)
+			document.body.appendChild(modal.overlay);
+			modal.content.focus(); // TODO: BUG hit Enter after open modal will open the same modal on top of last
+		}
+
+		function _openAlert(event, targetNode, props) {
+			event.stopPropagation();
+			event.preventDefault();
+			targetNode.blur(); // loose focus from element
+			var modal = _createModal('alert', targetNode || event.target, props || {});
+			_setModalContent('alert', modal);
+			document.body.appendChild(modal.overlay);
+			modal.content.focus(); // TODO: BUG hit Enter after open modal will open the same modal on top of last
+		}
+
+		function _openDropdownUnder(event, targetNode, props) {
+			event.stopPropagation();
+			event.preventDefault();
+
+			var props = ('function' === typeof props) ? props() : props;
+
+			var targetNode = targetNode || event.target;
+			var w = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
+			var h = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
+			var rect = targetNode.getBoundingClientRect();
+			DBG && console.log('DBG:p5Modal:_openDropdownUnder:rect', { rect, w, h });
+			targetNode.blur();
+			if (event.target !== targetNode) event.target.blur();
+
+			var bodyNode = document.body;
+			var htmlNode = document.documentElement;
+			var height = Math.max(bodyNode.scrollHeight, bodyNode.offsetHeight, htmlNode.clientHeight, htmlNode.scrollHeight, htmlNode.offsetHeight);
+			DBG && console.log('DBG:p5Modal:_openDropdownUnder:rect', {
+				height,
+				'bodyNode.scrollHeight': bodyNode.scrollHeight,
+				'bodyNode.offsetHeight': bodyNode.offsetHeight,
+				'htmlNode.clientHeight': htmlNode.clientHeight,
+				'htmlNode.scrollHeight': htmlNode.scrollHeight,
+				'htmlNode.offsetHeight': htmlNode.offsetHeight,
+			});
+
+			var modal = _createModal('dropdownUnder', targetNode, props);
+			_setModalContent('dropdownUnder', modal, props);
+
+			var posY = window.scrollY + rect.y + rect.height + 2;
+			modal.content.style.top = _px(posY);
+			modal.content.style.left = _px(rect.x);
+			modal.content.style.maxHeight = _px(height - posY);
+			modal.body.style.maxHeight = _px(height - posY - 8 - 8 - 20);
+			modal.body.style.overflowY = 'auto';
+			DBG && console.log('DBG:p5Modal:_openDropdownUnder:rect', { outerWidth: window.outerWidth, outerHeight: window.outerHeight, posY });
+
+			var _handleDropdownUnderClick = (function (nr) {
+				return function ___handleDropdownUnderClick() {
+					DBG && console.log('DBG:p5Modal:_handleDropdownUnderClick', { nr: nr });
+					_closeModalByNr(nr);
+				}
+			})(modal.nr)
+
+			modal.content.addEventListener('click', _handleDropdownUnderClick, false); // useCapture = false - trigger after inside node event callbacks
+			document.body.appendChild(modal.overlay);
+			modal.content.focus(); // TODO: BUG hit Enter after open modal will open the same modal on top of last
+
+			// https://dev.to/aleksandrhovhannisyan/multiple-modals-on-one-page-using-html-css-and-javascript-353b#4-closing-a-stacked-modal-with-the-escape-key
+
+			// IDEA: global p5Modal manager which generates unique ids and manage close and other actions
+			// 'data-url'
+			// glyphicon glyphicon-chevron-left - for cancel btn
+			// glyphicon glyphicon-chevron-right - for continue btn
+		}
+
+		function _setModalContent(type, modal, props) {
+			DBG && console.log('DBG:p5Modal:_setModalContent(', {type, modal, props}, ')');
+			if (!props.body) {
+				DBG && console.log('DBG:p5Modal:_setModalContent:props.body missing props.body!');
+			}
+			else {
+				if ('function' === typeof props.body) {
+					DBG && console.log('DBG:p5Modal:_setModalContent:props.body ...');
+					props.body(modal);
+				} else if ('string' === typeof props.body) {
+					// TODO: clone node document.getElementById(props.body)
+					//       or innerHTML ('#...')
+					//       or ajax call ('http...') + loading... text or anim
+					//       - callback to handle response (success, fail) and modify modal content
+					DBG && console.log('DBG:p5Modal:_setModalContent:props.body string ...');
+				} else {
+					DBG && console.log('DBG:p5Modal:_setModalContent:props.body not implemented props.body ...');
+				}
+				return;
+			}
+
+			if (type === 'dropdownUnder') {
+				modal.body.appendChild(document.createTextNode('TODO: dropdownUnder Body ...')); // TODO: DBG
+			} else {
+				modal.header.appendChild(document.createTextNode('TODO: Title ...')); // TODO: DBG
+				for (var i = 0; i < 100; i++) {
+					modal.body.appendChild(document.createTextNode('TODO: Body ...')); // TODO: DBG
+					modal.body.appendChild(document.createElement('br')); // TODO: DBG
+				}
+				modal.footer.appendChild(document.createTextNode('TODO: Footer ...')); // TODO: DBG
+			}
+		}
+
+		function _createModal(type, targetNode, props) {
+			_addEscKeyListener();
+			var defaultProps = {
+				// type: 'modal', // modal | sidePanel | alert
+				source: null, // node id | url | js function | null
+			};
+			var attrProps = _getPropsFromAttr(targetNode);
+			var props = Object.assign(defaultProps, attrProps, props || {});
+			props.type = type;
+			DBG && console.log('DBG:p5Modal:openModal:' + props.type + ':', { targetNode, props });
+
+			// add to _openedModal
+			// get _openedModal to set id attribute on overlay node
+			var modal = {
+				nr: _insertModal(props),
+				overlay: document.createElement('div'),
+				content: document.createElement('div'),
+				header: document.createElement('div'),
+				body: document.createElement('div'),
+				footer: document.createElement('div'),
+			};
+			{
+				modal.overlay.setAttribute('id', _modalHtmlId(modal.nr));
+				_addStyle(modal.overlay, props.type, 'overlay', props);
+				jQuery(modal.overlay).on('click', _makeCloseModal(modal.nr));
+
+				{
+					modal.content.setAttribute('id', _modalContentHtmlId(modal.nr));
+					_addStyle(modal.content, props.type, 'content', props);
+					jQuery(modal.content).on('click', function (event) {
+						event.stopPropagation();
+					});
+				}
+				modal.overlay.appendChild(modal.content);
+
+				{
+					modal.header.setAttribute('id', _modalTitleHtmlId(modal.nr));
+					_addStyle(modal.header, props.type, 'title', props);
+				}
+				modal.content.appendChild(modal.header);
+
+				{
+					modal.body.setAttribute('id', _modalBodyHtmlId(modal.nr));
+					_addStyle(modal.body, props.type, 'body', props);
+				}
+				modal.content.appendChild(modal.body);
+
+				{
+					modal.footer.setAttribute('id', _modalFooterHtmlId(modal.nr));
+					_addStyle(modal.footer, props.type, 'footer', props);
+				}
+				modal.content.appendChild(modal.footer);
+			}
+			return modal;
+		}
+
+		function _insertModal(props) {
+			_openedModal.push(props);
+			return _openedModal.length - 1;
+		}
+
+		function _modalHtmlId(nr) { return 'p5-modal-' + nr; }
+		function _modalContentHtmlId(nr) { return 'p5-modal-' + nr + '--content'; }
+		function _modalTitleHtmlId(nr) { return 'p5-modal-' + nr + '--title'; }
+		function _modalBodyHtmlId(nr) { return 'p5-modal-' + nr + '--body'; }
+		function _modalFooterHtmlId(nr) { return 'p5-modal-' + nr + '--footer'; }
+
+		function _closeModalByNr(nr) {
+			// TODO: validate if nr is last
+
+			if (!_openedModal.length) {
+				DBG && console.log('DBG:p5Modal:_closeModalByNr: empty _openedModal');
+				return;
+			}
+			if (nr != _openedModal.length - 1) {
+				DBG && console.log('DBG:p5Modal:_closeModalByNr: modal nr(' + nr + ') is not last _openedModal');
+				return;
+			}
+			_closeLastOpenedModal();
+
+			// var idHtml = _modalHtmlId(nr);
+			// // TODO: validte if may close - read props
+			// // - if not -> blink to get user focus
+			// var modalNode = document.getElementById(idHtml);
+			// if (!modalNode) DBG && console.log('DBG:p5Modal:BUG: missing modal node in html tree!');
+			// if (modalNode) document.body.removeChild(modalNode);
+		}
+
+		function _makeCloseModal(nr) {
+			return function __closeModalByNr(event) {
+				_closeModalByNr(nr);
+			};
+		}
+
+		function _addStyle(node, type, nodeName, props) {
+
+			if (type === 'dropdownUnder') {
+				node.className += 'p5-dropdown--' + nodeName;
+				return;
+			}
+
+			var style = _style(type)[nodeName];
+
+			for (name in style) {
+				node.style[name] = style[name];
+			}
+		}
+
+		return {
+			getTotal: function () { return _openedModal.length; },
+			openModal: _openModal,
+			openSidePanel: _openSidePanel,
+			openAlert: _openAlert,
+			openDropdownUnder: _openDropdownUnder,
+			closeLast: _closeLastOpenedModal,
+		};
+	})();
+}
+
+
+global.p5Modal__sidePanel_from_ButtonPost = function __p5Modal__sidePanel_from_ButtonPost(event, node) {
+	DBG && console.log('DBG:p5Modal__sidePanel_from_ButtonPost', { event, node })
+	event.stopPropagation();
+	event.preventDefault();
+	if (!node.form) {
+		DBG && console.log('DBG:p5Modal__sidePanel_from_ButtonPost -> !node.form -> return');
+		return;
+	}
+	DBG && console.log('DBG:p5Modal__sidePanel_from_ButtonPost: form', { form: node.form, elements: node.form.elements });
+	// node.form.method: "post"
+	// node.form.action: "https://biuro.biall.com.pl/dev-pl/se-rsync/index.php?id_order=3324&_route=UrlAction_KioskPanel"
+	// node.form.elements: HTMLFormControlsCollection(3)
+	// - 0: input
+	// - 1: input
+	// - 2: button.btn.btn.btn-default.btn-xs
+
+	var actionURL = node.form.action;
+	var values = {};
+	for (var idx = 0; idx < node.form.elements.length; idx++) {
+		var el = node.form.elements[idx];
+		var name = el.name;
+		if (name) {
+			values[name] = node.form[name].value;
+		}
+		DBG && console.log('DBG:p5Modal__sidePanel_from_ButtonPost: form.elements['+idx+']', { name, value: el.value, el });
+	}
+	// form.elements[0] {name: "_postTask", value: "printLogs", el: input}
+	// form.elements[1] {name: "id_order", value: "10", el: input}
+	// form.elements[2] {name: "", value: "", el: button.btn.btn.btn-default.btn-xs}
+
+	DBG && console.log('DBG:p5Modal__sidePanel_from_ButtonPost: values', { values, actionURL });
+
+	// '_postTask' -> '_modalAction' or just add _doModalAction=1 to form.action URL
+	if ('_postTask' in values) {
+		values['_modalAction'] = values['_postTask'];
+		delete values['_postTask'];
+	}
+
+	function _initFetchSidePanelContent(modal) {
+		DBG && console.log('DBG:p5Modal__sidePanel_from_ButtonPost: _initFetchSidePanelContent', { modal, values, actionURL });
+		var formData = new FormData();
+		for (var key in values) {
+			formData.append(key, values[key]);
+		}
+
+		global.fetch(actionURL
+			, (!values)
+				?	{ method: 'GET',
+						// headers: { 'Content-Type': 'application/json' },
+						credentials: 'same-origin',
+					}
+				:	{ method: 'POST',
+						// headers: { 'Content-Type': 'application/json' },
+						credentials: 'same-origin',
+						body: formData
+					}
+		).then(function (response) {
+			return response.json()
+		}).then(function (response) {
+			DBG && console.log('DBG:p5Modal__sidePanel_from_ButtonPost: _initFetchSidePanelContent -> response', { response, modal, values, actionURL });
+			if (!response) {
+				p5UI__buildDom([ 'div', { className: 'alert alert-danger' }, 'Error: empty response' ], modal.body);
+			} else if ('error' === response.type) {
+				p5UI__buildDom([ 'div', { className: 'alert alert-danger' }, response.msg || 'Error: empty response' ], modal.body);
+			} else if (response.body && response.body.modalBodyReactNode) {
+				p5UI__buildDom(response.body.modalBodyReactNode, modal.body);
+				if (response.body.modalHeaderReactNode) {
+					p5UI__buildDom(response.body.modalHeaderReactNode, modal.header);
+				} else {
+					// TODO: btn 'x'
+				}
+				if (response.body.modalFooterReactNode) {
+					p5UI__buildDom(response.body.modalFooterReactNode, modal.footer);
+				} else {
+					// TODO: modal.footer ?
+				}
+			} else {
+				p5UI__buildDom([ 'div', { className: 'alert alert-danger' }, 'Error' ], modal.body);
+			}
+		})
+	}
+
+	return p5Modal.openSidePanel(event, node, props = {
+		body: _initFetchSidePanelContent,
+	});
+};
+
+
+
+function _openSidePanel(panelNode) {
+	addClass(panelNode, 'p5-side_panel--is-visible');
+	document.addEventListener('keyup', panelNode._p5_onEsc);
+	panelNode.addEventListener('click', panelNode._p5_onClick);
+	// fix content scrollTop
+	var contentNode = panelNode.getElementsByClassName('p5-side_panel__content')
+	if (contentNode && contentNode[0]) {
+		contentNode[0].scrollTop = 0;
+	}
+}
+
+function _closeSidePanel(panelNode) {
+	removeClass(panelNode, 'p5-side_panel--is-visible');
+	document.removeEventListener('keyup', panelNode._p5_onEsc)
+	panelNode.removeEventListener('click', panelNode._p5_onClick);
+}
+
+function hasId(el, id) {
+	return (id === el.getAttribute('id'));
+}
+//class manipulations - needed if classList is not supported
+//https://jaketrent.com/post/addremove-classes-raw-javascript/
+function hasClass(el, className) {
+	if (el.classList) return el.classList.contains(className);
+	else return !!el.className.match(new RegExp('(\\s|^)' + className + '(\\s|$)'));
+}
+
+function addClass(el, className) {
+	if (el.classList) el.classList.add(className);
+	else if (!hasClass(el, className)) el.className += ' ' + className;
+}
+
+function removeClass(el, className) {
+	if (el.classList) el.classList.remove(className);
+	else if (hasClass(el, className)) {
+		var reg = new RegExp('(\\s|^)' + className + '(\\s|$)');
+		el.className = el.className.replace(reg, ' ');
+	}
+}
+
+
+// module.exports['p5UI__openModal__TORM'] = p5UI__openModal__TORM;
+
+
+
+
+
+
+
+
+
+
+
+// --------------
+
+function __buyIndeksAjax(ind, url) {
+	var n = document.getElementById('basket__' + ind + '_buy_input'),
+		wrap = document.getElementById('basket__' + ind),
+		val = n.value;
+	if (!n || !wrap) return true;
+
+	var tmpNodes = uslugiNode.getElementsByClassName('buy__info__' + ind)
+	var indeksInfoNode = tmpNodes ? tmpNodes[0] : null;
+	var tmpNodes = uslugiNode.getElementsByClassName('buy__noservice__' + ind)
+	var noServiceNode = tmpNodes ? tmpNodes[0] : null;
+	var serviceNodes = uslugiNode.getElementsByClassName('buy__usluga__' + ind)
+	var totalServiceNodes = serviceNodes.length;
+	if (!totalServiceNodes || !indeksInfoNode || !noServiceNode) {
+		return __buyIndeksAjax_simple(ind, url);
+	}
+
+	var buyModal = document.getElementById('modal__buy');
+	if (buyModal) document.body.removeChild(buyModal);
+	buyModal = document.createElement("div");
+	buyModal.setAttribute('id', 'modal__buy');
+	buyModal.style.display = "block";
+	buyModal.style.position = "fixed";
+	buyModal.style.zIndex = 1;
+	buyModal.style.left = 0;
+	buyModal.style.top = 0;
+	buyModal.style.width = "100%";
+	buyModal.style.height = "100%";
+	buyModal.style.overflow = "auto";
+	buyModal.style.backgroundColor = "#000";
+	buyModal.style.backgroundColor = "#0009";
+	jQuery(buyModal).on('click', __closeBuyIdneksModal)
+
+	var content = document.createElement("div");
+	content.style.backgroundColor = "#fefefe"
+	content.style.margin = "15% auto"
+	content.style.padding = "24px 32px"
+	content.style.border = "1px solid #888"
+	content.style.width = "80%"
+	content.style.maxWidth = "800px"
+	content.style.textAlign = "left"
+	content.style.fontSize = "14px"
+	jQuery(content).on('click', function (event) {
+		event.stopPropagation();
+	})
+
+	var nodeH3 = document.createElement("h3");
+	nodeH3.style.fontSize = "18px";
+	nodeH3.style.lineHeight = "1.6em";
+	nodeH3.style.marginBottom = "16px";
+	nodeH3.appendChild(document.createTextNode("Wybierz dodatkowe us�ugi:"));
+
+	var nodeInfo = document.createElement("div");
+	nodeInfo.style.marginBottom = "24px";
+	nodeInfo.appendChild(indeksInfoNode.cloneNode(true));
+
+	var nodeTable = document.createElement("table");
+	nodeTable.setAttribute('cellspacing', '0')
+	nodeTable.setAttribute('cellpadding', '0')
+	nodeTable.style.borderCollapse = "collapse";
+	nodeTable.style.width = "100%";
+	var nodeTr, nodeTd, nodeTdSelect;
+	for (var i = 0; i < totalServiceNodes; i++) {
+		idService = serviceNodes[i].getAttribute('data-service')
+		if (!idService) {
+			continue;
+		}
+		nodeTr = document.createElement("tr"); {
+			nodeTd = document.createElement("td");
+			nodeTd.style.padding = "24px";
+			nodeTd.style.border = "1px solid #ddd";
+			nodeTd.appendChild(serviceNodes[i].cloneNode(true));
+		}
+		nodeTr.appendChild(nodeTd); {
+			nodeTdSelect = document.createElement("td");
+			nodeTdSelect.style.padding = "24px";
+			nodeTdSelect.style.border = "1px solid #ddd";
+			nodeTdSelect.style.textAlign = "center";
+			var selectBtn = __createBuyWithServiceAjaxBtn(ind, idService, url);
+			nodeTdSelect.appendChild(selectBtn);
+		}
+		nodeTr.appendChild(nodeTdSelect);
+	}
+	nodeTable.appendChild(nodeTr);
+
+	{ // no service
+		nodeTr = document.createElement("tr"); {
+			nodeTd = document.createElement("td");
+			nodeTd.style.padding = "24px";
+			nodeTd.style.border = "1px solid #ddd";
+			nodeTd.style.color = "#666";
+			nodeTd.appendChild(noServiceNode.cloneNode(true));
+		}
+		nodeTr.appendChild(nodeTd); {
+			nodeTdSelect = document.createElement("td");
+			nodeTdSelect.style.padding = "24px";
+			nodeTdSelect.style.border = "1px solid #ddd";
+			nodeTdSelect.style.textAlign = "center";
+			var selectBtn = __createSimpleBuyAjaxBtn(ind, url);
+			nodeTdSelect.appendChild(selectBtn);
+		}
+		nodeTr.appendChild(nodeTdSelect);
+	}
+	nodeTable.appendChild(nodeTr);
+
+	content.appendChild(nodeH3);
+	content.appendChild(nodeInfo);
+	content.appendChild(nodeTable);
+	buyModal.appendChild(content);
+	document.body.appendChild(buyModal);
+
+	return false;
+}
+
+function __createSimpleBuyAjaxBtn(ind, url) {
+	var nodeBtn = document.createElement("a");
+	nodeBtn.setAttribute('href', url + 'buy,' + ind + '.html')
+	nodeBtn.setAttribute('onclick', "return __buyIndeksAjax_simpleCloseModal('" + ind + "', '" + url + "');")
+	nodeBtn.setAttribute('class', "btn-shop")
+	nodeBtn.style.padding = "6px 16px";
+	nodeBtn.appendChild(document.createTextNode("Wybieram"));
+	return nodeBtn;
+}
+
+function __createBuyWithServiceAjaxBtn(ind, service, url) {
+	var nodeBtn = document.createElement("a");
+	nodeBtn.setAttribute('href', url + 'buy,' + ind + '.html')
+	nodeBtn.setAttribute('onclick', "return __buyIndeksAjax_withService('" + ind + "', '" + service + "', '" + url + "');")
+	nodeBtn.setAttribute('class', "btn-shop")
+	nodeBtn.style.padding = "6px 16px";
+	nodeBtn.appendChild(document.createTextNode("Wybieram"));
+	return nodeBtn;
+}
+
+function __buyIndeksAjax_simpleCloseModal(ind, url) {
+	__closeBuyIdneksModal();
+	__buyIndeksAjax_simple(ind, url);
+	return false;
+}
+
+function __closeBuyIdneksModal() {
+	var buyModal = document.getElementById('modal__buy');
+	if (buyModal) document.body.removeChild(buyModal);
+}