From a1a2fe0e9236672d7c8faff7be7c2454731dadf3 Mon Sep 17 00:00:00 2001 From: Florian Stancioiu Date: Fri, 12 Apr 2024 11:41:05 +0300 Subject: [PATCH] feat: create src dir for pionjs component --- src/cosmoz-bottom-bar.html.js | 136 +++++++++++++++++ src/cosmoz-bottom-bar.js | 269 ++++++++++++++++++++++++++++++++++ 2 files changed, 405 insertions(+) create mode 100644 src/cosmoz-bottom-bar.html.js create mode 100644 src/cosmoz-bottom-bar.js diff --git a/src/cosmoz-bottom-bar.html.js b/src/cosmoz-bottom-bar.html.js new file mode 100644 index 0000000..e71b1b5 --- /dev/null +++ b/src/cosmoz-bottom-bar.html.js @@ -0,0 +1,136 @@ +/* eslint-disable max-len */ +import '@neovici/cosmoz-dropdown'; +import { html } from 'lit-html'; + +export default html` + +
+
+ + + +
+ +`; diff --git a/src/cosmoz-bottom-bar.js b/src/cosmoz-bottom-bar.js new file mode 100644 index 0000000..eec1c7d --- /dev/null +++ b/src/cosmoz-bottom-bar.js @@ -0,0 +1,269 @@ +/* eslint-disable max-lines */ +import { FlattenedNodesObserver } from '@polymer/polymer/lib/utils/flattened-nodes-observer'; +import { Debouncer } from '@polymer/polymer/lib/utils/debounce.js'; +import { timeOut } from '@polymer/polymer/lib/utils/async'; +import { defaultPlacement } from '@neovici/cosmoz-dropdown'; + +import { component } from '@pionjs/pion'; +import template from './cosmoz-bottom-bar.html.js'; + +const BOTTOM_BAR_TOOLBAR_SLOT = 'bottom-bar-toolbar'; +const BOTTOM_BAR_MENU_SLOT = 'bottom-bar-menu'; +const rendered = Symbol('rendered'); + +/** + * `` is a horizontal responsive bottom toolbar containing items that + * can be used for actions. + * + * The items placed inside the `cosmoz-bottom-bar` are distributed into the toolbar in a horizontal container. + * If the items do not fit the available width, those that do not fit are placed in a dropdown + * menu triggered by a button in the toolbar. + * The class specified by the property `toolbarClass` (default `cosmoz-bottom-bar-toolbar`) + * is applied to items distributed to the toolbar. + * The class specified in the property `menuClass` (default `cosmoz-bottom-bar-menu`) + * is applied to items distributed to the menu. + * + * ### Usage + * + * See demo for example usage + * + * @element cosmoz-bottom-bar + * @demo demo/bottom-bar.html Basic Demo + * @demo demo/bottom-bar-match-parent.html match parent Demo + */ +const CosmozBottomBar = ({ + active = false, + hideActions = false, + barHeight = 64, + matchParent = false, + hasMenuItems = false, + hasExtraItems = false, + selectedClass = 'cosmoz-bottom-bar-selected-item', + toolbarClass = 'cosmoz-bottom-bar-toolbar', + menuClass = 'cosmoz-bottom-bar-menu', + maxToolbarItems = 1, + computedBarHeight, + forceOpen = false, + visible, + hasActions = false, + _matchHeightElement, + topPlacement = ['top-right', ...defaultPlacement], +}) => { + const connectedCallback = () => { + const layoutOnRemove = (info) => + info.removedNodes.filter(this._isActionNode) && + this._debounceLayoutActions(); + this._nodeObservers = [ + new FlattenedNodesObserver(this.$.content, this._boundChildrenUpdated), + new FlattenedNodesObserver(this.$.extraSlot, (info) => + this.set('hasExtraItems', info.addedNodes.length > 0), + ), + new FlattenedNodesObserver(this.$.bottomBarToolbar, layoutOnRemove), + new FlattenedNodesObserver(this.$.bottomBarMenu, layoutOnRemove), + ]; + this._hiddenMutationObserver = new MutationObserver(() => + this._debounceLayoutActions(), + ); + this._resizeObserver.observe(this); + this.computedBarHeight = this._computeComputedBarHeight( + this._matchHeightElement, + this.barHeight, + ); + } + + const disconnectedCallback = () => { + super.disconnectedCallback(); + this[rendered] = false; + + [...this._nodeObservers, this._hiddenMutationObserver].forEach((e) => + e.disconnect(e), + ); + this._layoutDebouncer?.cancel(); /* eslint-disable-line no-unused-expressions */ + this._resizeObserver.unobserve(this); + } + + const _childrenUpdated = (info) => { + const addedNodes = info.addedNodes.filter(this._isActionNode), + removedNodes = info.removedNodes.filter(this._isActionNode), + newNodes = addedNodes.filter((node) => !removedNodes.includes(node)); + + if ( + (addedNodes.length === 0 && removedNodes.length === 0) || + newNodes.length === 0 + ) { + return; + } + newNodes.forEach((node) => { + this._hiddenMutationObserver.observe(node, { + attributes: true, + attributeFilter: ['hidden'], + }); + this._moveElement(node, false); + }); + + this._debounceLayoutActions(); + } + + const _computeComputedBarHeight = (matchElementHeight, barHeight) => { + if (matchElementHeight) { + return matchElementHeight.offsetHeight; + } + return barHeight; + } + + const _computeVisible(hasActions, active, hasExtraItems, forceOpen) => { + return forceOpen || ((hasActions || hasExtraItems) && active); + } + + const _debounceLayoutActions = () => { + this._layoutDebouncer = Debouncer.debounce( + this._layoutDebouncer, + timeOut.after(30), + this._boundLayoutActions, + ); + } + + const _getHeightMatchingElement = (matchParent) => { + if (matchParent) { + return this.parentElement; + } + + return null; + } + + const _getHeightStyle = (height) => { + return 'height: ' + height + 'px;'; + } + + const _isActionNode = (node) => { + return ( + node.nodeType === Node.ELEMENT_NODE && + node.getAttribute('slot') !== 'info' && + node.tagName !== 'TEMPLATE' && + node.tagName !== 'STYLE' && + node.tagName !== 'DOM-REPEAT' && + node.tagName !== 'DOM-IF' && + node.getAttribute('slot') !== 'extra' + ); + } + + const _getElements = () => { + const elements = FlattenedNodesObserver.getFlattenedNodes(this) + .filter(this._isActionNode) + .filter((element) => !element.hidden) + .sort((a, b) => (a.dataset.index ?? 0) - (b.dataset.index ?? 0)); + + if (elements.length === 0) { + return elements; + } + + const topPriorityAction = elements.reduce( + (top, element) => { + return parseInt(top.dataset.priority ?? 0, 10) >= + parseInt(element.dataset.priority ?? 0, 10) + ? top + : element; + }, + { dataset: { priority: '-1000' } }, + [], + ); + + return [ + topPriorityAction, + ...elements.filter((e) => e !== topPriorityAction), + ]; + } + /** + * Layout the actions available as buttons or menu items + * + * If the window is resizing down, just make sure that all buttons fits, and if not, + * move one to menu and call itself async (to allow re-rendering) and see if we fit. + * Repeat until the button fits or no buttons are left. + * + * If the window is sizing up, try to place a menu item out as a button, call itself + * async (to allow re-rendering) and see if we fit - if we don't, remove the button again. + * + * We also need to keep track of `_scalingUp` between calls since the resize might fire + * a lot of events, and we don't want to be starting multiple "calculation processes" + * since this will result in an infinite loop. + * + * The actual layouting of actions will be performed by adding or removing the 'button' + * attribute from the action, which will cause it to match different content insertion + * points. + * + * @returns {void} + */ + const _layoutActions = () => { + // eslint-disable-line max-statements + const elements = this._getElements(), + hasActions = elements.length > 0 || this.hasExtraItems; + this._setHasActions(hasActions); + + if (!hasActions) { + // No need to render if we don't have any actions + return this._setHasMenuItems(false); + } + + const toolbarElements = elements.slice(0, this.maxToolbarItems), + menuElements = elements.slice(toolbarElements.length); + toolbarElements.forEach((el) => this._moveElement(el, true)); + menuElements.forEach((el) => this._moveElement(el)); + this._setHasMenuItems(menuElements.length > 0); + } + + const _moveElement = (element, toToolbar) => { + const slot = toToolbar ? BOTTOM_BAR_TOOLBAR_SLOT : BOTTOM_BAR_MENU_SLOT, + tabindex = '0'; + element.setAttribute('slot', slot); + element.setAttribute('tabindex', tabindex); + element.classList.toggle(this.menuClass, !toToolbar); + element.classList.toggle(this.toolbarClass, toToolbar); + this.updateStyles(); + } + + const _onResize = ([entry]) => { + const hidden = + entry.borderBoxSize?.[0]?.inlineSize === 0 || + entry.contentRect?.width === 0; + if (hidden) { + return; + } + this.computedBarHeight = this._computeComputedBarHeight( + this._matchHeightElement, + this.barHeight, + ); + } + + const _showHideBottomBar = (visible) => { + this.style.transitionDuration = 0; + this.style.display = ''; + this.style.maxHeight = ''; + + const height = this.computedBarHeight, + to = !visible ? '0px' : height + 'px'; + + let from = visible ? '0px' : height + 'px'; + + if (!this[rendered]) { + from = to; + this[rendered] = true; + } + + this.style.maxHeight = from; + + const onEnd = () => { + this.removeEventListener('transitionend', onEnd); + this.style.maxHeight = ''; + this.style.display = this.visible ? '' : 'none'; + }; + requestAnimationFrame(() => { + this.addEventListener('transitionend', onEnd); + this.style.transitionDuration = ''; + this.style.maxHeight = to; + }); + } + + return template; +}; + +customElements.define('cosmoz-dropdown-list', component(CosmozBottomBar));