UNPKG

@furo/ui5

Version:
1,109 lines (1,001 loc) 29.7 kB
import { LitElement, html, css } from 'lit'; import { FBP } from '@furo/fbp'; import '@furo/fbp/src/flow-repeat'; import { FieldNode } from '@furo/data/src/lib/FieldNode'; import '@furo/layout/src/furo-vertical-flex'; import { NodeEvent } from '@furo/framework/src/EventTreeNode.js'; import './subcomponents/furo-ui5-tree-item.js'; // import { html as statichtml, literal } from 'lit/static-html.js'; /** * `furo-tree` * renders a tree structure * * ## Data signature * * ```yaml * - type: 'tree.Tree #Navigation tree type with recursive navigation nodes' * fields: * root: 'tree.Navigationnode:1 #Root node of the tree' * id: 'string:2 #[optional] Id of the tree' * display_name: '- string:3 #[optional] String representation of the tree' * description: 'string:4 #[optional] description of the tree' * ``` * * * ```yaml * - type: 'tree.Navigationnode #Item of the navigationtree' * fields: * id: 'string:1 #Id of the node' * display_name: '- string:2 #String representation of the node' * children: '[] tree.Navigationnode:3 #Children of this node' * open: 'bool:4 #node is open or not' * secondary_text: 'string:5 #[optional] Secondary text of the node' * description: 'string:6 #[optional] Searchable description of the node' * icon: 'string:7 #[optional] icon of the node' * key_words: 'string:8 #[optional] searchable key words of the node' * has_error: 'bool:9 #[optional] error indicator' * is_group_label: 'bool:10 #[optional] Mark node as group label' * ``` * * @cssprop {N/A} [--surface=white] - background color * @cssprop {N/A} [--on-surface=#333333] - foreground color * * @fires {focused field} node-focused - Fired when * @fires {focused field} branch-focused - Fired when * @fires {} leaf-focused - Fired when * @fires {selected field} node-selected - Fired when the item gets selected, does not fire when you work with query params * @fires {Object {"this.qp": this._selectedField.id._value}} qp-change-requested - Fired when qp mode is enabled. Nodes are only selectable with qpIn or selectById * @fires {selected field} branch-selected - Fired when * @fires {selected field} leaf-selected - Fired when * @fires {} node-opened - Fired when a node is opened * @fires {} node-closed - Fired when a node is closed * @fires {} nodes-expanded - Fired when nodes are expanded recursive * @fires {} nodes-collapsed - Fired when nodes are collapsed recursive. * * * @cssprop {N/A} [--tree-indentation-1=16px] - tree indention level 1 * @cssprop {N/A} [--tree-indentation-2=32px] - tree indention level 2 * @cssprop {N/A} [--tree-indentation-3=48px] - tree indention level 3 * @cssprop {N/A} [--tree-indentation-4=56px] - tree indention level 4 * @cssprop {N/A} [--tree-indentation-5=64px] - tree indention level 5 * @cssprop {N/A} [--tree-indentation-6=72px] - tree indention level 6 * @cssprop {N/A} [--tree-indentation-7=80px] - tree indention level 7 * @cssprop {N/A} [--tree-indentation-8=88px] - tree indention level 8 * @cssprop {N/A} [--tree-indentation-9=92px] - tree indention level 9 * @cssprop {N/A} [--tree-indentation-10=96px] - tree indention level 10 * @cssprop {N/A} [--tree-indentation-11=100px] - tree indention level 11 * @cssprop {N/A} [--tree-indentation-12=104px] - tree indention level 12 * * @summary tree navigation menu * @element furo-ui5-tree * @appliesMixin FBP */ export class FuroUi5Tree extends FBP(LitElement) { constructor() { super(); /** * Flat list representation of the tree * @type {Array} * @private */ this._flatTree = []; /** * * @type {number} */ this.tabindex = 0; /** * * @type {string} * @private */ this._searchTerm = ''; /** * * @type {number} */ this.expandDepth = 2; /** * * @type {boolean} * @private */ this._searchIsActive = false; } /** * collapses the focused element. If it is closed the parent will be focused. */ collapseFocused() { // close when opened, parent when closed if (!this._focusedField.isBranch() && this._focusedField.open._value) { this._focusedField.toggleOpenClose(); } else { this.focusParent(); } } /** * expands the focused node, if it is opened the first child will be focused */ expandFocused() { // open when closed, next when opened if (!this._focusedField.isBranch() && !this._focusedField.open._value) { this._focusedField.toggleOpenClose(); } else { this.focusNext(); } } /** * expands the focused node recursive */ expandFocusedRecursive() { this._focusedField.expandRecursive(); } /** * collapses the focused node recursive */ collapseFocusedRecursive() { this._focusedField.collapseRecursive(); } /** * selects the focused element. */ selectFocused() { this._focusedField.selectItem(); } /** * Search in the visible nodes * @param term * @return {[]} */ search(term) { if (term.length > 1) { this._searchTerm = term; this.searchOpenTree(); } else { this.resetSearch(); } return this._foundSearchItems; } searchOpenTree() { this._searchIsActive = true; const d = { term: this._searchTerm, results: [] }; this._foundSearchItems = d.results; this._FBPTriggerWire('--searchRequested', d); // select first result if (d.results.length > 0) { d.results[0].triggerFocus(); } this._updateSearchmatchAttributesOnItems(); this.requestUpdate(); } /** * Disables the search mode and clears the term */ resetSearch() { this._searchIsActive = false; this._searchTerm = ''; this._foundSearchItems = []; this._updateSearchmatchAttributesOnItems(); } /** * * @private */ _updateSearchmatchAttributesOnItems() { this._rootNode.broadcastEvent( new NodeEvent('search-didnt-match', this._rootNode, true) ); this._foundSearchItems.forEach(node => { node.dispatchNodeEvent( new NodeEvent('search-matched', this._rootNode, false) ); }); } /** * Focuses the parent tree node without selecting it. * * The tree item will also scrollIntoViewIfNeeded() (on all platforms which support this method). */ focusParent() { const parent = this._focusedField.getParentElement(); if (parent.triggerFocus) { parent.triggerFocus(); } } /** * focus the previous visible node. * * The tree item will also scrollIntoViewIfNeeded() (on all platforms which support this method). */ focusPrevious() { let prev; if (this._searchIsActive) { for (let i = 0; i < this._foundSearchItems.length; i += 1) { if ( this._foundSearchItems[i].__flatTreeIndex >= this._focusedField.__flatTreeIndex ) { prev = this._foundSearchItems[i - 1]; break; } } // select last if (!prev) { prev = this._foundSearchItems[this._foundSearchItems.length - 1]; } } else { prev = this._focusedField.getPrevElement(); } if (prev) { prev.triggerFocus(); } } /** * Selects the node which is defined on `qp` * * Use this, if you do not have a location object. * * @param qpObject */ qpIn(qpObject) { if (qpObject[this.qp]) { this.selectById(qpObject[this.qp]); } } /** * Inject a location object, which contains a query param property to select the current node. * @param locationObject * @return {*|boolean} */ locationIn(locationObject) { if (locationObject.query[this.qp]) { const selected = this.selectById(locationObject.query[this.qp]); if (!selected) { // Store qp, for later binding this.__tmpQP = locationObject.query[this.qp]; } return selected; } return false; } selectById(nodeID) { for (let i = this._flatTree.length - 1; i >= 0; i -= 1) { const node = this._flatTree[i]; if (String(node.id._value) === String(nodeID)) { node.selectItem(); // update focused /** * * @private */ this._focusedField = this._selectedField || this._focusedField; /** * Fire event, when qp is set, because the selectItem will not fire */ if (this.qp) { const customEvent = new Event('node-selected', { composed: true, bubbles: true, }); customEvent.detail = this._selectedField; this.dispatchEvent(customEvent); } return node; } } return false; } /** * select the previous visible item */ selectPrev() { this._focusedField = this._selectedField || this._focusedField; this.focusPrevious(); // open the focused field this._focusedField.selectItem(); } /** * expands the currently selected node recursive */ expandNodeRecursive() { this._selectedField.expandRecursive(); } expandAll() { this._flatTree[0].expandRecursive(); } collapseAll() { this._flatTree[0].collapseRecursive(); } /** * expands the currently selected node recursive */ collapseNodeRecursive() { this._selectedField.collapseRecursive(); } /** * toggles the currently selected node */ toggle() { this._selectedField.toggleOpenClose(); } addSubNode(rawNode) { const newnode = this._selectedField.children.add(rawNode); this._init(); setTimeout(() => { newnode.selectItem(); }, 0); } deleteNode() { this._selectedField.__parentNode.deleteChild(this._selectedField.__index); this.selectPrev(); this._init(); } /** * select the next visible item */ selectNext() { this._focusedField = this._selectedField || this._focusedField; this.focusNext(); // open the focused field this._focusedField.selectItem(); } triggerNavigation(key) { switch (key) { case 'Enter': // not reseting the search at this position is by intention. if (this._focusedField._isSelected) { // openclose this._focusedField.toggleOpenClose(); } else { // open the focused field this._focusedField.selectItem(); } break; case 'ArrowDown': this.focusNext(); break; case 'ArrowUp': this.focusPrevious(); break; case 'PageDown': for (let i = 0; i < 10; i += 1) { this.focusNext(); } break; case 'PageUp': for (let i = 0; i < 10; i += 1) { this.focusPrevious(); } break; case 'End': this.focusLast(); break; case 'Home': this.focusFirst(); break; case 'ArrowLeft': this.collapseFocused(); break; case 'ArrowRight': this.expandFocused(); break; case 'Escape': break; default: } } /** * Focuses the first node in the tree without selecting it. * * Use selectFocused to select the focused node. * * The tree item will also scrollIntoViewIfNeeded() (on all platforms which support this method). */ focusFirst() { this._flatTree[0].triggerFocus(); } /** * Focuses the last node in the tree without selecting it. * * Use selectFocused to select the focused node. * * The tree item will also scrollIntoViewIfNeeded() (on all platforms which support this method). */ focusLast() { this.__visibleTree = this._flatTree.filter(node => !node._isHidden); this.__visibleTree[this.__visibleTree.length - 1].triggerFocus(); } /** * focuses the next visible tree node. * * The tree item will also scrollIntoViewIfNeeded() (on all platforms which support this method). */ focusNext() { let next; if (this._searchIsActive) { for (let i = this._foundSearchItems.length - 1; i >= 0; i -= 1) { if ( this._foundSearchItems[i].__flatTreeIndex <= this._focusedField.__flatTreeIndex ) { next = this._foundSearchItems[i + 1]; break; } } // select first if (!next) { [next] = this._foundSearchItems; } } else { next = this._focusedField.getNextVisibleElement(); } if (next) { next.triggerFocus(); } } /** * @private * @return {Object} */ static get properties() { return { /** * Maximal depth for the tree. Default is infinite. * * @type Number */ depth: { type: Number }, /** * Sets the maximal expand level relative from the current node. * * Expanding is a expensive operation. * * @type Number */ expandDepth: { type: Number, attribute: 'expand-depth' }, /** * Query param to watch. Set `qp` to have a deep linkable tree. * * If you set this attribute, the node-selected event will only be fired on `fn-qp-in` or `fn-select-by-id`. * * If you select an item the `qp-change-request` will be fired instead. With the qp-change-request event, you should update the url. * A `furo-location` should watch the url and update the location on the tree, which will trigger a node-selected event. * * ```html * <furo-location at-location-query-changed="--qp"></furo-location> * <furo-ui5-tree * qp="panel" * fn-location-in="--qp" at-qp-change-requested="--qpchangerequest"></furo-ui5-tree> * <!-- update the location with the selected tree item --> * <furo-location-updater fn-set-qp="--qpchangerequest"></furo-location-updater> * ``` * * @type String */ qp: { type: String }, /** * Sets the tabindex * * @type Number */ tabindex: { type: Number, reflect: true }, /** * Set this flag if you do not want a header-text section. * * @type Boolean */ rootAsHeader: { type: Boolean, attribute: 'root-as-header' }, /** * Set this flag if you do not want to see the root node * * @type Boolean */ hideRootNode: { type: Boolean, attribute: 'hide-root-node' }, /** * Override display name from root object * * @type String */ headerText: { type: String, attribute: 'header-text' }, /** * Override description from root object. * * @type String */ secondaryText: { type: String, attribute: 'secondary-text' }, /** * indicator for searching. Maybe you want style your item depending on this attribute * * @type Boolean */ _searchIsActive: { type: Boolean, attribute: 'searching', reflect: true }, /** * indicates that the element is focused * * @type Boolean */ focused: { type: Boolean, reflect: true }, }; } /** * focuses the tree itself. You can use this in combination with keyboard navigation (furo-navigation-pad) */ focus() { super.focus(); } /** * flow is ready lifecycle method * @private */ _FBPReady() { super._FBPReady(); // this._FBPTraceWires(); /** * Register hook on wire --headClicked to * select the root node */ this._FBPAddWireHook('--headClicked', () => { this._flatTree[0].selectItem(); }); // update the focused state this.addEventListener('focusin', () => { this.focused = true; }); this.addEventListener('focusout', () => { this.focused = false; }); } /** * Themable Styles * @private * @return {CSSResult} */ static get styles() { // language=CSS return css` :host { display: block; box-sizing: border-box; height: 100%; outline: none; position: relative; color: var(--sapTextColor, #32363a); font-size: var(--sapFontSize); font-family: var(--sapFontFamily), sans-serif; } .tablewrapper { overflow: auto; } :host([hidden]) { display: none; } td { padding: 0; } table { border-spacing: 0; min-width: 100%; padding: 0.25rem 1rem; box-sizing: border-box; } /* remove border on first group label if it is the first element */ tr:first-child *[is-group-label] { border-top: none; } /* focus, :host(:focus) td > *[focused] is for mouse navigation */ td > *:hover, :host([focused]) td > *[focused] { background: var(--sapList_Hover_Background); } /* selected focus */ :host([focused]) td > *[focused] { background: var(--sapList_Hover_SelectionBackground); } /* selected */ td > *[selected], :host(:not([focused])) td > *[selected] { background: var(--sapList_SelectionBackgroundColor); } /* selected focus */ :host([focused]) td > *[selected] { background: var(--sapList_Hover_SelectionBackground); } /* selected focus */ td:hover > *[selected][focused] { background: var(--sapList_Hover_SelectionBackground); } /* selected focus */ :host([focused]) td > *[selected][focused] { background: var(--sapList_Hover_SelectionBackground); } /* remove the background color on header node */ :host([root-as-header]) td furo-ui5-tree-item[selected][isheader], :host([root-as-header]) td furo-ui5-tree-item[selected][focused][isheader], :host([root-as-header]) td furo-ui5-tree-item[focused][isheader] { background-color: unset; } .title { font-size: 20px; height: 40px; line-height: 56px; padding-left: 1rem; } .secondary { font-size: 14px; height: 24px; letter-spacing: 0.1px; padding-left: 1rem; color: var(--sapNeutralTextColor, #6a6d70); line-height: 20px; } .head { height: 64px; cursor: pointer; } :host([noheader]) .head { display: none; } `; } /** * @private * @returns {TemplateResult} */ render() { // language=HTML return html` <furo-vertical-flex> <div class='tablewrapper' flex> <table> <flow-repeat fn-inject-items='--treeChanged' fn-trigger-all='--searchRequested' identity-path='id._value'> <template><tr><td> <furo-ui5-tree-item fn-bind-data="--itemInjected(*.item)" fn-search="--trigger"></furo-ui5-tree-item> </td></tr></template> </flow-repeat> </table> </furo-vertical-flex> </div> `; } /** * Binds a FieldNode. * * Supported types: everything with a `tree.Tree` or `tree.Navigationnode` signature. * * @param treeNode{NavigationNode|Tree} Fieldnode */ bindData(treeNode) { if (treeNode.root === undefined) { /** * * @private */ this._rootNode = treeNode; } else { this._rootNode = treeNode.root; } this._rootNode.children.clearListOnNewData = true; this._rootNode.children.addEventListener( 'this-repeated-field-changed', () => { this._setTitle(this._rootNode); this._init(); } ); this._setTitle(this._rootNode); this._init(); } /** * * @param treeNode * @private */ _setTitle(treeNode) { if (this.headerText && treeNode.display_name) { // eslint-disable-next-line no-param-reassign treeNode.display_name._value = this.headerText; } if (this.secondaryText && treeNode.secondary_text) { // eslint-disable-next-line no-param-reassign treeNode.secondary_text._value = this.secondaryText; } } /** * Focuses the currently selected tree item. * * The tree item will also scrollIntoViewIfNeeded() (on all platforms which support this method). */ focusSelected() { this._selectedField.triggerFocus(); } /** * convert the tree to a flat tree * @private */ _init() { this._buildFlatTree(this._rootNode); // set visible on root node this._rootNode.children.broadcastEvent( new NodeEvent('ancestor-visible', this._rootNode) ); if (!this.__listenersInitialized) { this._initFocusAndSelectEvents(); } this.__listenersInitialized = true; // initial focus on first element if (this._focusedField === undefined && this._flatTree.length > 0) { [this._focusedField] = this._flatTree; this._focusedField.triggerFocus(); } // select item if qp was set before if (this.__tmpQP !== undefined) { // because the tree is built async setTimeout(() => { this.selectById(this.__tmpQP); this.__tmpQP = undefined; }, 0); } } /** * * @private */ _initFocusAndSelectEvents() { // Internal Event, when a node gets focused this._rootNode.addEventListener('tree-node-focused', e => { // broadcast blur this._rootNode.broadcastEvent(new NodeEvent('tree-node-blur-requested')); this._focusedField = e.target; // only dispatch when the element contains a name if (this._focusedField.display_name._value != null) { const customEvent = new Event('node-focused', { composed: true, bubbles: true, }); customEvent.detail = this._focusedField; this.dispatchEvent(customEvent); if (this._focusedField.isBranch()) { const branchFocusedEvent = new Event('branch-focused', { composed: true, bubbles: true, }); branchFocusedEvent.detail = this._focusedField; this.dispatchEvent(branchFocusedEvent); } else { const leafFocusedEvent = new Event('leaf-focused', { composed: true, bubbles: true, }); leafFocusedEvent.detail = this._focusedField; this.dispatchEvent(leafFocusedEvent); } } }); // Internal Event, when a node gets selected this._rootNode.addEventListener('tree-node-selected', e => { //--- // broadcast deselect this._rootNode.broadcastEvent( new NodeEvent('tree-node-unselection-requested') ); /** * * @private */ this._selectedField = e.target; if (!this.qp) { const customEvent = new Event('node-selected', { composed: true, bubbles: true, }); customEvent.detail = this._selectedField; this.dispatchEvent(customEvent); } else if (this.__lastQP !== this._selectedField.id._value) { const customEvent = new Event('qp-change-requested', { composed: true, bubbles: true, }); const qp = {}; this.__lastQP = this._selectedField.id._value; qp[this.qp] = this._selectedField.id._value; customEvent.detail = qp; this.dispatchEvent(customEvent); } if (this._selectedField.isBranch()) { const customEvent = new Event('branch-selected', { composed: true, bubbles: true, }); customEvent.detail = this._selectedField; this.dispatchEvent(customEvent); } else { const customEvent = new Event('leaf-selected', { composed: true, bubbles: true, }); customEvent.detail = this._selectedField; this.dispatchEvent(customEvent); } }); } /** * * @param tree * @private */ _buildFlatTree(tree) { this._flatTree = [tree]; // eslint-disable-next-line no-param-reassign tree.__flatTreeIndex = 0; let startlevel = 0; if (this.hideRootNode === true) { startlevel = -1; // this._flatTree.pop(); this._flatTree[0]._isHidden = true; } else { // eslint-disable-next-line no-param-reassign tree._isRoot = true; // eslint-disable-next-line no-param-reassign tree.open._value = true; } if (this.rootAsHeader === true) { this._flatTree[0]._rootAsHeader = true; this._flatTree[0]._isHidden = false; startlevel = -1; } this._parseTreeRecursive(tree, startlevel, this.depth); for (let len = this._flatTree.length; len > 0; len -= 1) { const index = len - 1; const node = this._flatTree[index]; // open field if entity contains a field open with true if (!node.open) { node.addChildProperty( 'open', new FieldNode(node, { type: 'bool' }, 'open') ); node.open._value = false; } // Traverse the flat tree, it is simpler then the nested tree // next active element node.getNextVisibleElement = () => { for (let i = index + 1; i < this._flatTree.length; i += 1) { if (!this._flatTree[i]._isHidden) { return this._flatTree[i]; } } return false; }; // prev active element node.getPrevElement = () => { for (let i = index - 1; i >= 0; i -= 1) { if (!this._flatTree[i]._isHidden) { return this._flatTree[i]; } } return false; }; // is branch node.isBranch = () => node.children.repeats.length === 0; // get Parent node.getParentElement = () => node.__parentNode.__parentNode; // add openclose method to treeNode node.toggleOpenClose = () => { node.open._value = !node.open._value; if (node.open._value) { const customEvent = new Event('node-opened', { composed: true, bubbles: false, }); setTimeout(() => { this.dispatchEvent(customEvent); }, 0); } else { const customEvent = new Event('node-closed', { composed: true, bubbles: false, }); setTimeout(() => { this.dispatchEvent(customEvent); }, 0); } }; // focuss the current node // ?? maybe we want also do a tab-node-focused in panel-coordinator.tabs? node.triggerFocus = () => { node.dispatchNodeEvent(new NodeEvent('tree-node-focused', this, true)); node.dispatchNodeEvent(new NodeEvent('this-node-focused', this, false)); }; // selects the current item node.selectItem = () => { // focus the selected node.triggerFocus(); node.dispatchNodeEvent(new NodeEvent('tree-node-selected', node, true)); node.dispatchNodeEvent( new NodeEvent('this-node-selected', node, false) ); // used to open the paths upwards from the selected node node.__parentNode.dispatchNodeEvent( new NodeEvent('descendant-selected', this, true) ); }; // if a descendant was selected, we ensure to open the path node.addEventListener('descendant-selected', () => { node.open._value = true; }); // expand recursive node.expandRecursive = () => { const event = new NodeEvent('recursive-expand-requested', node); node.broadcastEvent(event); const customEvent = new Event('nodes-expanded', { composed: true, bubbles: false, }); setTimeout(() => { this.dispatchEvent(customEvent); }, 0); }; node.addEventListener('recursive-expand-requested', e => { // stop exanding after a depth of 2 if (e.detail.depth + this.expandDepth <= node.depth) { e.stopBroadcast(); } node.open._value = true; }); // collapse recursive node.collapseRecursive = () => { node.broadcastEvent( new NodeEvent('recursive-collapse-requested', node) ); const customEvent = new Event('nodes-collapsed', { composed: true, bubbles: false, }); setTimeout(() => { this.dispatchEvent(customEvent); }, 0); }; node.addEventListener('recursive-collapse-requested', () => { node.open._value = false; }); } // open the root ode // eslint-disable-next-line no-param-reassign tree.open._value = true; this._FBPTriggerWire( '--treeChanged', this._flatTree.filter(node => !node._isHidden) ); } /** * * @param tree * @param level * @param maxdepth * @private */ _parseTreeRecursive(tree, level, maxdepth) { if (maxdepth > 0 && !(level < maxdepth)) { return; } // eslint-disable-next-line no-param-reassign tree.depth = level; // do not indent on group labels if (!(tree.is_group_label && tree.is_group_label._value === true)) { // eslint-disable-next-line no-param-reassign level += 1; } tree.children.repeats.forEach(node => { // eslint-disable-next-line no-param-reassign node.depth = level; const i = this._flatTree.push(node); // eslint-disable-next-line no-param-reassign node.__flatTreeIndex = i - 1; if (node.children.repeats.length > 0) { this._parseTreeRecursive(node, level, maxdepth); } }); } } window.customElements.define('furo-ui5-tree', FuroUi5Tree);