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-menu

    I 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:

    1. Looks and behaves like standard q-select
    2. 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.
    3. If user types a complete valid entry it gets accepted.
    4. 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.
    5. 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>
    
    


  • @CWoodman

    Nice work! I found this vue repo that does tree selects:

    https://vue-treeselect.js.org/



  • @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++tree

    https://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.


Log in to reply