Q-tree within Q-menu
-
I’ve built a component I call ‘ComboTree’ which provides a text-input box that combines an auto-fill feature (using q-select) and a drop-down selection menu using q-menu with an imbedded q-tree. This shows a tree-view of available options.
Here are a couple of screen shots showing it in action:-
using the drop-down tree-view:
-
Using the autofill feature:
It works great as you can see in these screenshots. BUT, I tried to use the same component within a q-dialog and as soon as I click on an arrow in the tree-view to expand or collapse the item, the menu closes! I’ve spent the better part of a day trying to figure out what’s different, but with no luck.
I even copied the code into this codepen, and it doesn’t work either - in fact it doesn’t even show the tree structure; just to top level nodes, and the autofill doesn’t work at all.
Here’s the codepen, so you can see the code…
https://codepen.io/crawfordw/pen/abdrNRN?editors=1111 -
-
May have found a solution, but it’s a kludge…
I made the menu persistent, and now it remains open while you expand a tree node. But because it’s persistent, I have to force close the menu when an item is selected, and had to add a delay before doing this! Also have to force it closed if user takes any other unrelated action or the main component loses focus, so lots of extra checks in the code. A better solution would be appreciated. -
Although I don’t know how you could solve your case I did something similar but I used popup proxy. It takes some hacking to make it work. Like I had to have 2 tree’s to be able to reference it and use its functions. I also change if the popup is persistent or not.
https://quasar.dev/vue-components/popup-proxy#QPopupProxy-API
I leave the code here in case it helps but I am sure someone will come up a better way of doing this.
<template> <div class="relative-position v_tree_select" @keydown="on_field_keydown" @click="on_field_click"> <q-field ref="field" stack-label tabindex="0" v-model="filter" :disable="_disable" :hint="hint" :label="label" :loading="loading" @focus="on_focus" @input="on_input" > <template v-slot:append> <q-icon name="arrow_drop_down" /> </template> <!-- :borderless="borderless" --> <template v-slot:control> <div class="v_tree_select_field_wrap"> <template v-if="_tree.length !== 0"> <template v-if="multi_select && ticked_nodes.length > 0 "> <q-chip color="primary" text-color="white" dense v-for="node in ticked_nodes" :key="node[tree_key]"> {{ node[tree_label_key] }} <svg v-if="clearable" @click.stop="remove_chip(node[tree_key])" aria-hidden="true" role="presentation" focusable="false" viewBox="0 0 24 24" class="q-chip__icon q-chip__icon--remove cursor-pointer q-icon notranslate" tabindex="-1"> <path d="M12,2C17.53,2 22,6.47 22,12C22,17.53 17.53,22 12,22C6.47,22 2,17.53 2,12C2,6.47 6.47,2 12,2M15.59,7L12,10.59L8.41,7L7,8.41L10.59,12L7,15.59L8.41,17L12,13.41L15.59,17L17,15.59L13.41,12L17,8.41L15.59,7Z"> </path> </svg> </q-chip> </template> <template v-if="!multi_select && selected_node"> <q-chip color="primary" text-color="white" dense> {{ selected_node[tree_label_key] }} <svg v-if="clearable" @click.stop="remove_chip()" aria-hidden="true" role="presentation" focusable="false" viewBox="0 0 24 24" class="q-chip__icon q-chip__icon--remove cursor-pointer q-icon notranslate" tabindex="-1"> <path d="M12,2C17.53,2 22,6.47 22,12C22,17.53 17.53,22 12,22C6.47,22 2,17.53 2,12C2,6.47 6.47,2 12,2M15.59,7L12,10.59L8.41,7L7,8.41L10.59,12L7,15.59L8.41,17L12,13.41L15.59,17L17,15.59L13.41,12L17,8.41L15.59,7Z"> </path> </svg> </q-chip> </template> </template> <template v-else> <div class="text-grey">No nodes available</div> </template> </div> </template> <!-- HIDDEN TREE SO WE CAN GET THE REFRENCE TO IT AND USE ITS METHODS--> <q-tree style="display:none" ref="hidden_tree" :nodes="_tree" :node-key="tree_key" :label-key="tree_label_key" :default-expand-all="default_expand_all" :selected="selected" :ticked="ticked" @update:selected="node_selected" @update:ticked="node_ticked" :key="hidden_key" :tickStrategy="_tick_strategy" > </q-tree> <q-popup-proxy :breakpoint="600" fit ref="popup" full-width content-class="v_tree_select_dialog" v-model="dialog" :persistent="persistent" no-parent-event > <!-- no-parent-event --> <!-- maximized --> <div class="column v_tree_select_popup_wrap"> <!-- <q-input bottom-slots v-model="filter" filled :dense="dense" placeholder="Placeholder" > <template v-slot:prepend> <q-icon name="filter_list"></q-icon> </template> </q-input> --> <q-input v-show="searchable" ref="filter" v-model="filter" class="v_tree_select_filter_input" clearable placeholder="Filter" filled @keydown="on_filter_keydown"> <template v-slot:prepend> <q-icon name="filter_list" /> </template> </q-input> <div class="col scroll" @keydown="on_keydown" tabindex="0"> <q-tree ref="tree" class="q-ma-sm col" :default-expand-all="default_expand_all" :filter="filter" :key="key" :label-key="tree_label_key" :nodes="_tree" :node-key="tree_key" :selected="selected" :ticked="ticked" :tickStrategy="_tick_strategy" @update:selected="node_selected" @update:ticked="node_ticked" > </q-tree> </div> <div class="q-pa-md col-auto v_tree_select_dialog_close"> <q-btn label="Close" unelevated class="full-width" color="primary" @click="hide_dialog" /> </div> <!-- list_loading --> <q-inner-loading :showing="list_loading" > <q-spinner-gears size="50px" color="primary" /> </q-inner-loading> </div> </q-popup-proxy> </q-field> </div> </template> <script> export default { props: { after_change: { type: Function }, borderless: { type: Boolean, default: true }, clearable: { type: Boolean, default: true }, close_after_select: { type: Boolean, default: true }, confirm_action: { type: String }, default_expand_all: { type: Boolean, default: true }, disable: { type: Boolean, default: false }, field: { type: String }, hint: { type: String }, label: { type: String, default: '' }, load_trigger: { type: [String, Number, Object] }, multi_select: { type: Boolean, default: false }, section_open: { type: Boolean }, searchable: { type: Boolean, default: true }, sync_data: { type: [String, Object, Array] }, sync_selected_key: { type: String, default: 'relations' }, sync_action: { type: String }, tick_strategy: { type: String, default: 'strict' }, // tree tree: { type: Array }, tree_key: { type: String, required: true }, tree_label_key: { type: String, required: true }, tree_field: { type: String }, tree_action: { type: String }, tree_getter_data: { type: [String, Number, Array, Object] }, tree_getter: { type: String, default: '' }, // value value: { type: [String, Array, Number] }, value_action: { type: String }, value_action_data: { type: [String, Object, Array] }, value_getter: { type: String } }, data: function () { return { dialog: false, expanded: [], filter: '', hidden_key: 1, key: 1, list_loading: false, loading: false, model: null, persistent: false, selected_node: null, ticked_nodes: [] }; }, computed: { _disable () { if (this._tree.length === 0) { return true; } else if (this.loading) { return true; } else { return this.disable; } }, selected () { if (this.multi_select) { return ''; } return this._value; }, _tick_strategy () { if (this.multi_select) { return this.tick_strategy; } else { return undefined; } }, _value () { let v = ''; if (typeof this.value !== 'undefined') { v = this.value; } else if (this.value_getter) { if (typeof this.$store.getters[this.value_getter] === 'function') { v = this.$store.getters[this.value_getter](this.field); } else { v = this.$store.getters[this.value_getter]; } } else if (this.field) { v = $younit._get(this.field); } else { throw new Error('There is no value provided for tree select.'); } return v; }, _tree () { if (this.tree) { return this.tree; } else if (this.tree_getter) { return this.$store.getters[this.tree_getter](this.tree_getter_data); } else if (this.tree_field) { return $younit._get(this.tree_field); } return throw new Error('NO SOURCE FOR THE TREE'); }, ticked () { if (!this.multi_select) { return []; } return this._value || []; } }, watch: { section_open: function (n, o) { this.load(); }, load_trigger: function (n, o) { this.load(); } }, methods: { hide_dialog () { setTimeout(() => { this.dialog = false; }, 30); }, focus () { this.$refs.field.focus(); }, async node_selected (v) { if (!this.sync_action) { this.$emit('input', v); if (this.close_after_select) { this.hide_dialog(); } return; } if (this.multi_select) { // if (this.tickStrategy !== 'strict') { // // for now only this startegy is supported because the tick strategy of leaf I can not reproduce the behaviour // return; // } const ticked = [...this.ticked]; // const ticked = this.$refs.hidden_tree.getTickedNodes().map(item => item[this.tree_key]); const index = ticked.indexOf(v); if (index > -1) { this.$refs.tree.setTicked([v], false); } else { this.$refs.tree.setTicked([v], true); } return; // // selected node is already ticked // if (ticked.indexOf(v) > -1) { // ticked.splice(index, 1); // } else { // ticked.push(v); // } // // return this.node_ticked(this.ticked); // // return; // // return this.node_ticked(ticked); } try { const old_value = this._value; this.list_loading = true; this.selected_node = this.$refs.hidden_tree.getNodeByKey(v); if (this.confirm_action) { this.persistent = true; const rs = await this.$store.dispatch(this.confirm_action, {}); if (!rs) { this.selected_node = this.$refs.hidden_tree.getNodeByKey(old_value); this.list_loading = false; this.persistent = false; return; } this.persistent = false; } const sync_data = { ...{ [this.sync_selected_key]: v }, ...this.sync_data }; const r = await this.$store.dispatch(this.sync_action, sync_data); if (r && r.r) { this.hide_dialog(); } else { this.selected_node = this.$refs.hidden_tree.getNodeByKey(old_value); } } catch ($e) { this.list_loading = false; throw $e; } this.list_loading = false; }, async node_ticked (v) { try { const old_ticked_nodes = this.$refs.hidden_tree.getTickedNodes(); this.list_loading = true; this.ticked_nodes = this.$refs.hidden_tree.getTickedNodes(); if (this.confirm_action) { this.persistent = true; const rs = await this.$store.dispatch(this.confirm_action, {}); if (!rs) { this.ticked_nodes = old_ticked_nodes; this.list_loading = false; this.persistent = false; return; } this.persistent = false; } const sync_data = { ...{ [this.sync_selected_key]: v }, ...this.sync_data }; const r = await this.$store.dispatch(this.sync_action, sync_data); if (r && r.r) { this.ticked_nodes = this.$refs.hidden_tree.getTickedNodes(); } else { this.ticked_nodes = old_ticked_nodes; } } catch ($e) { this.list_loading = false; throw $e; } this.list_loading = false; }, async load () { if (this.section_open) { this.loading = true; let value_action_promise = true; if (this.value_action) { value_action_promise = this.$store.dispatch(this.value_action, this.value_action_data); } let tree_action_promise = true; if (this.tree_action) { tree_action_promise = this.$store.dispatch(this.tree_action, {}); } await Promise.all([ value_action_promise, tree_action_promise ]); this.loading = false; this.key = Math.random(); this.hidden_key = Math.random(); if (this.multi_select) { this.ticked_nodes = this.$refs.hidden_tree.getTickedNodes(); } else { this.selected_node = this.$refs.hidden_tree.getNodeByKey(this._value); } } }, // get_ticked_nodes (v) { // const ticked_nodes = []; // v.forEach((id) => { // ticked_nodes.push(this.$refs.hidden_tree.getNodeByKey(id)); // }); // return ticked_nodes; // }, on_focus () { // this.show_dialog(); }, on_keydown (e) { // 37 left 38 up, 39 right 40 down const $el = this.$refs.tree.$el; const $focus = $el.querySelector('.q-tree__node-header:focus'); const all = [].slice.call($el.querySelectorAll('.q-tree__node-header')); let index = 0; const key = e.which; // down and up if (key === 40 || key === 38) { if (key === 40) { if ($focus) { const i = all.indexOf($focus); if (i !== all.length - 1) { index = i + 1; } } } else if (key === 38) { if ($focus) { const i = all.indexOf($focus); if (i !== 0) { index = i - 1; } else { index = all.length - 1; } } else { index = all.length - 1; } } all[index].focus(); } }, on_input () { this.show_dialog(); }, on_filter_keydown (e) { this.on_keydown(e); }, on_field_click (e) { if (this.dialog) { this.hide_dialog(); } else { this.show_dialog(); } this.show_dialog(); }, on_field_keydown (e) { this.$emit('keydown', e); // down if ((e.keyCode === 40 || e.keyCode === 13) && !this.dialog) { this.show_dialog(); setTimeout(() => { this.$refs.filter.focus(); }, 100); } return true; }, remove_chip (node_id) { if (this.multi_select) { const ticked = [...this.ticked]; const index = ticked.indexOf(node_id); ticked.splice(index, 1); this.node_ticked(ticked); } else { this.node_selected(null); } }, show_dialog () { if (this.dialog) { return; } this.dialog = true; setTimeout(() => { // this.$refs.filter.focus(); }, 100); } } }; </script> <style lang="stylus"> .v_tree_select{ } .v_tree_select .q-tree{ width: 100%; } .v_tree_select_field_wrap{ width: 100%; } .v_tree_select_dialog{ // background-color: white; } .v_tree_select_dialog .q-dialog__backdrop{ // display: none; } .v_tree_select_dialog .q-dialog__inner > div{ box-shadow: none; } .v_tree_select_dialog .v_tree_select_popup_wrap{ background-color: white; flex-wrap: nowrap; } .v_tree_select_dialog.q-menu .v_tree_select_dialog_close{ display: none; } .v_tree_select_dialog .q-tree { margin: 16px; } .v_tree_select_filter_input{ margin-bottom: 0 ; } </style>
-
Thanks. Maybe we just need to hack our way through this. Anyway, my approach seems to be working - just had to detect all the lost focus and click elsewhere events to hide the persistent tree.
-
@CWoodman said in Q-tree within Q-menu:
(…) just had to detect all the lost focus and click elsewhere events to hide the persistent tree.
does your solution works on the mobile, too?
It seems, as there’s a lot of code in both solutions, it should be simple imho.
It could mean, that there is a fundamental problem in Quasar abstractions if such use cases are hard to grasp -
Yes it works on my phone. I agree it should be simpler!
-
It would help if you show some working code. Obviously not all use case are covered since it varies for each. the use case isn’t simple either.
If you think there’s something missing on the component or its api that would help you, then it would help to make a feature request about it so that it can be improved if said feature is deemed logical to be added.
-
@metalsadman There’s a codepen of an earlier version at https://codepen.io/crawfordw/pen/abdrNRN but it doesn’t work correctly there for some reason.
Anyway, what I needed was a Select component that would show a tree-view in the drop-down, and support autofill with items from any level of the tree.
I don’t know if this is a general enough need to warrant building it into Quasar, but I intend to use it in many places in my app. I have it working well now, though needed to use a few kludgy techniques to get there.
If you’re interested, I can provide the complete code here.
-
@metalsadman yeah, you’re obviously right: it needs specification. It is not a problem I need to solve just now, but it is an interesting case, and I always assumed that at some point of its way, Quasar will need an abstraction of “compound components”.
We have something similar already - as in q-page or q-card, which are examples of compound components. Now, in this use case, OP encountered a need for overlayed, sibling information exchange, shared state component. His implementation in the context of framework provided abstractions, ended up complicated and not obvious.
Maybe it’s about time to create a new abstraction in Quasar ie q-compound or q-overlayed which allows to build something like that?
I think it is an excellent example, where app extensions are too heavy. If you wanted to build such light user-space and app-specific components as an AE, then this AE would be hard to maintain, debug, big and error prone.
The needed q-compound should:
- take care of events propagated and emitted
- work in overlays / popups
- support “decorator” interfaces (animations, transitions for example)
- … and more?
The actual code which OP used, is not elegant. I consider, that when framework forces you to write “not elegant” code, it is a strong sign of lacking abstractions in said framework. And Quasar is highly elegant. This is just a new use case which needs new abstraction.
Honestly just a bunch of thoughts, hoping this would start a fruitful discussion.
-
@qyloxe Interesting discussion. Probably out of my depth on this. I’m just hacking up something that meets my UX needs. The original title of this post re: ‘tree within a menu’ doesn’t really reflect my starting point… Maybe I should have titled it ‘Challenges making a Select component that supports tree-structured options list’. If there was a native Quasar Select component that did that, I wouldn’t need to add a menu and a button and a bunch of styles and methods to make the whole mess look and behave like a regular Select box.
-
I personally feel this component should be part of the framework.
It is very hard to avoid tree structures. Everybody wants categories and folders I find. So we will all start building our own tree select eventually and not very elegantly (or at least most of us).
-
I just posted a feature request for this…
https://forum.quasar-framework.org/topic/6639/requested-feature-select-with-tree-structured-options -
Just added my code and description of how it works to the feature request post above
-
@turigeza you could see this as a tree select: its’a tree and you can select.
https://quasar.dev/vue-components/tree#Example--Selectable-nodes
Combine it with the filter example and you have the functionality of a q-select with a tree.
-
@dobbel Yes, that’s what I did.
See my code at https://forum.quasar-framework.org/topic/6639/requested-feature-select-with-tree-structured-options