Requested feature - Select with tree-structured options
-
I’ve hacked together a component to do this, using a container with q-select, q-btn, q-menu and q-tree. It took me a week to build and is very complex with many methods to handle mouse movement and user input, as well as style to make it look and behave like a native q-select.
There’s a post with discussion about this, including some screen shots of it working…
https://forum.quasar-framework.org/topic/6617/q-tree-within-q-menuI plan to use this component in many places within the app I’m building.
I and others think adding these features to the native q-select would be a valuable addition.My ‘TreeSelect’ component has the following features:
- Looks and behaves like standard q-select
- User can type something and see a pop-down list of potential matches from any level of the options tree - can then select one with down-arrow and Enter.
- If user types a complete valid entry it gets accepted.
- User can instead click the down arrow to see the options tree-view and select one. This tree can have any number of levels. The tree uses accordion style, so expanding one node collapses others.
- When an item is selected by either method, the component emits an event.
It would be nice if the standard q-select did all this!
Anyway, here’s my code in case anyone is interested. It’s not pretty but it works…
<template> <div class="container row no-wrap items-center justify-between" :style="totalWidth" :class="{ focus: hasFocus, active: isActive }" @mouseenter="gotFocus" @mouseleave="lostFocus" @mousedown="onMousedown" > <q-select borderless dense :input-style="inputWidth" :popup-content-style="popupWidth" hide-dropdown-icon v-model="selectedCategory" :options="autoList" option-value="id" :option-label="item => item.name" use-input hide-selected fill-input :label="label" input-debounce="0" emit-value map-options :autofocus="autofocus" @filter="filterList" @input="setModel" @click.native="onClickSelect" @blur="selectLostFocus" > <template v-slot:no-option> <q-item dense> <q-item-section> </q-item-section> </q-item> </template> </q-select> <q-btn tabindex="-1" flat dense color="grey-7" icon="fas fa-caret-down" size="sm" padding="12px 9px 10px 7px" > <q-menu anchor="bottom right" self="top right" v-model="showMenu" persistent > <div :style="totalWidth" class="q-pl-sm q-pr-xs" @mouseenter="onEnterMenu" @mouseleave="onLeaveMenu" > <q-tree outlined dense :nodes="categoryTree" :selected.sync="selectedCategory.id" accordion node-key="id" :expanded.sync="expanded" /> </div> </q-menu> </q-btn> </div> </template> <script> // widgets/TreeSelect.vue // This component builds a 'combo box' with a tree-structured drop-down menu and auto-complete drop-down. // Props: // label - default is 'Select Category:' // categories - Object containing hierarchy of categories. // Each must have at least the following structure... // { // id: <string>, // name: <string>, // category: <string> id of parent, or null, // subCategories: <array of strings> [subCategory ids if any] // } // width: <number> widget width in rem. (Default is 13) // include: <string> include dummy category for id '0' (default null). e.g. -all categories- or -none- // initialId: <string> optional id of initial selection, or 0 for -all categories- or -none-. // If null, initally show blank // // Events: selectCategory(id) export default { name: 'TreeSelect', props: { label: { type: String, default: 'Select Category:' }, categories: { // Object with id's as keys containing category objects type: Object }, width: { type: String, default: '13' // rem. Close to Quasar default value }, include: { // If not null, could be -all categories- or -none- // This will be listed with id = '0' type: String, default: null }, initialId: { // If not null, set this as the inital selected category id // To choose 'all' or 'none' set it to '0' type: String, default: null }, autofocus: { // Set true if want autofocus (e.g. for use in a dialog) type: Boolean, default: false } }, mounted () { this.buildCategoryTree(this.categories) if (this.initialId === '0') { // -all categories- or -none- this.selectedCategory = { id: '0', name: this.categoryTree[0].label } } else if (this.initialId !== null) { this.selectedCategory = this.categories[this.initialId] } }, computed: { // Size the main component accordng to the width prop, and the subcomponents to match totalWidth () { // Width of entire widget return 'width: ' + this.width + 'rem;' }, inputWidth () { // width of select component const width = this.width - 2.7 return 'width: ' + width + 'rem' }, popupWidth () { // width of the autofill popup const width = this.width - 1 return 'width: ' + width + 'rem' } }, watch: { expanded () { // Remove expanded children if parent not expanded this.expanded.forEach((id) => { const parentId = this.categories[id].category if (parentId) { if (!this.expanded.includes(parentId)) { this.expanded = this.expanded.filter(e => e !== id) } } }) }, categories () { // Whenever this prop changes, rebuild the tree to reflect the change this.buildCategoryTree(this.categories) } }, methods: { buildCategoryTree (categories) { this.categoryTree = [] // This array holds the nodes for the tree-view menu this.autoListCandidates = [] // This is a flat array of all category ids if (this.include) { // If this prop is set, include it (e.g. 'all categories' or 'none') const topNode = { id: '0', label: this.include, selectable: true, active: false, children: [], parent: null, index: null, handler: this.selectCategory } this.categoryTree.push(topNode) } Object.values(categories).forEach((category) => { if (!category.category) { // This is a root level category so start another // buildCategory will iterate to add all subcategories for this root this.buildCategory(null, category, categories) } }) // Sort the main categories (sub-categories have been sorted by buildCategory) this.categoryTree = this.sortCategories(this.categoryTree) }, buildCategory (parentNode, category, categories) { // Recursive function to build the tree from input prop for each category // 'categories' is the source data object containing all category objects indexed by id // Create new category node and add it to it's parent's 'children' array // Add this new category node to the list of all categories const newCategory = this.addCategory(parentNode, category) if (!newCategory) { return } // Must have encountered a problem // If this category has subcategories, call ourself to add them if (category.subCategories.length > 0) { // category.subCategories is an array of id's category.subCategories.forEach((id) => { // For each id listed, get the corresponding subCategory object // and add it to this category node's list of leaf nodes const subCategory = categories[id] this.buildCategory(newCategory, subCategory, categories) }) } // Sort the subCategories at this level if (newCategory.children.length > 0) { newCategory.children = this.sortCategories(newCategory.children) } }, addCategory (parentNode, category) { // Add another category to the categoryList try { const newCategory = { id: category.id, label: category.name, selectable: true, active: false, children: [], parent: parentNode, index: null, handler: this.selectCategory // Will be called when node is selected } // Add it to its parent's children array let parentArray if (!parentNode) { // top level parentArray = this.categoryTree // 'parent' array is just the main categoryTree array } else { // This is a sub-category - get its parent's 'children' array so we can add this node parentArray = parentNode.children } parentArray.push(newCategory) // Add this node to its parent this.autoListCandidates.push(newCategory.id) // And add it to the autofill list return newCategory // Return this new category node to buildcategory function } catch (err) { console.log('Bad category: ' + err + ' Category: ' + category) return null } }, sortCategories (childArray) { // Called from buildCategoryTree and buildcategory functions // Sort the category's 'children' array of subcategories const tempCategories = [] childArray.forEach((child) => { tempCategories.push(child) }) tempCategories.sort((a, b) => a.label.localeCompare(b.label)) // Return the sorted children array return tempCategories }, // Handling mouse events: gotFocus () { // If mouse is inside the main container, it's style will change to outline in black this.hasFocus = true this.autoListMatch = null // Start fresh with autofill items }, lostFocus () { // When mouse leaves main container, change style back to normal, and close menu if open this.hasFocus = false this.isActive = false if (this.autoListMatch) { // User has typed a good match before leaving this.setModel(this.autoListMatch) // This will show the selection and inform parent component } setTimeout(() => { // Need delay while mouse moves out of main container into menu if (!this.menuHasFocus) { this.showMenu = false } // If moves elsewhere, close the menu }, 100) }, selectLostFocus () { // User has clicked or tabbed outside the Select box this.isActive = false // Remove highlight if (this.autoListMatch) { // User has typed a good match before leaving this.setModel(this.autoListMatch) // This will show the selection and inform parent component } }, onMousedown () { // Clicking anywhere in the container gives it the active style this.isActive = true }, // Menu is persistent, so need to manage showing/hiding it: onEnterMenu () { this.menuHasFocus = true // Prevents lostFocus function from hiding the menu when focus leaves main container }, onLeaveMenu () { // Menu is persistent, or when component is in a dialog, the menu closes when a tree node is expanded before any item can be selected. // So we have to manually close it here this.menuHasFocus = false this.showMenu = false }, onClickSelect () { // Clicked within the Select component this.showMenu = false // Need to do this because menu is persistent }, // AutoFill feature: filterList (text, populateAutoFill, abort) { // As user types, show list of matching items from the categories data this.autoListMatch = null // If user types good match to the top item, we'll save it in this if (text.length < 2) { // Don't show the drop-down if less than 2 chars abort() return } populateAutoFill(() => { this.autoList = [] // This will hold ids of every category that matches input so far if (text.length > 0) { if (this.include && this.include.substr(0, text.length).toUpperCase() === text.toUpperCase()) { // Started to enter '-all categories-' or '-none-' this.autoList.push({ id: '0', name: this.include }) } else { // Look for a match in the candidate list this.autoListCandidates.forEach((id) => { const name = this.categories[id].name if (name.substr(0, text.length).toUpperCase() === text.toUpperCase()) { this.autoList.push({ id: id, name: name }) } }) // Sort the list this.autoList.sort((a, b) => a.name.localeCompare(b.name)) } } }) // Check for good match and use it. if (this.autoList.length === 1) { // Only one item, so must be close match if (text.toUpperCase() === this.autoList[0].name.toUpperCase()) { // Good match this.autoListMatch = this.autoList[0].id // Save the id; we'll use it to inform parent when focus leaves } } }, setModel (id) { // Selecting by typing or choosing from Autofill options // Called when select box has input from autofill list if (id === '0') { // 'all categories' or 'none' this.selectedCategory = { id: 0, name: this.include } } else if (id !== null) { const name = this.categories[id].name this.selectedCategory = { id: id, name: name } } this.$emit('selectCategory', id) // Inform parent of the selection }, selectCategory (node) { // Selecting by clicking a tree node // Each tree node has this handler in it's object this.selectedCategory = { id: node.id, name: node.label } this.expanded = [] // Collapse all nodes for next time menu is shown this.$emit('selectCategory', node.id) // Inform parent of the selection this.showMenu = false // Close the menu (it's persistent) } }, data () { return { categoryTree: [], // Hierearchical array of tree nodes sorted autoListCandidates: [], // Linear array of ids for autofill autoList: [], // Array of names matching input so far autoListMatch: null, // We'll set this to the id if user types matching item from the autoList showMenu: false, // Used to show/hide menu hasFocus: false, menuHasFocus: false, isActive: false, selectedCategory: { id: null, name: null }, expanded: null // Array of expanded node ids } } } </script> <style lang="stylus" scoped> // Styles to make the component look and behave like standard Quasar q-select component .container position: relative height: 2.5rem // Matches 'dense' height of standard Quasar widgets border: 1px solid $grey-5 border-radius: 4px background-color: white padding-right: 3px padding-left: 10px .focus border: 1px solid black .active border: 2px solid $blue-8 </style>
-
-
@dobbel said in Requested feature - Select with tree-structured options:
I found this vue repo that does tree selects:
https://vue-treeselect.js.org/great catch! I didn’t even knew that I needed that
-
Wow! Much more sophisticated than my little effort.
-
https://vue-treeselect.js.org/ doesn’t work on mobiles.
-
@CWoodman I think (might be wrong) but you supposed to submit feature requests on GitHub.
I don’t see it there.
https://github.com/quasarframework/quasar/issues?q=is%3Aissue+is%3Aopen++treehttps://quasar.dev/contribution-guide/contribution-guide#Reporting-an-Issue
I would just copy paste it over there if you have not yet submitted it. Otherwise just ignore me : )
-
From what I ‘heard’ these very complex specialized components are not going to be implemented in core Quasar. It’s up to the community to create these complex components as plugins for Quasar. Like the Q-calendar.
-
@turigeza said in Requested feature - Select with tree-structured options:
https://vue-treeselect.js.org/ doesn’t work on mobiles.
That was to be expected unfortunately.
-
If it’s not gonna make it to the core, it’s worth making it into an app-extension, should help other devs that might need.