No More Posting New Topics!

If you have a question or an issue, please start a thread in our Github Discussions Forum.
This forum is closed for new threads/ topics.

Navigation

    Quasar Framework

    • Login
    • Search
    • Categories
    • Recent
    • Tags
    • Popular
    • Users
    • Groups
    • Search

    Requested feature - Select with tree-structured options

    Framework
    5
    9
    1613
    Loading More Posts
    • Oldest to Newest
    • Newest to Oldest
    • Most Votes
    Reply
    • Reply as topic
    Log in to reply
    This topic has been deleted. Only users with topic management privileges can see it.
    • CWoodman
      CWoodman last edited by CWoodman

      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>
      
      
      dobbel 1 Reply Last reply Reply Quote 0
      • dobbel
        dobbel @CWoodman last edited by

        @CWoodman

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

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

        qyloxe 1 Reply Last reply Reply Quote 0
        • qyloxe
          qyloxe @dobbel last edited by

          @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 😉

          1 Reply Last reply Reply Quote 0
          • CWoodman
            CWoodman last edited by

            Wow! Much more sophisticated than my little effort.

            1 Reply Last reply Reply Quote 0
            • T
              turigeza last edited by turigeza

              https://vue-treeselect.js.org/ doesn’t work on mobiles.

              dobbel 1 Reply Last reply Reply Quote 0
              • T
                turigeza last edited by turigeza

                @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 : )

                1 Reply Last reply Reply Quote 0
                • dobbel
                  dobbel last edited by

                  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.

                  1 Reply Last reply Reply Quote 0
                  • dobbel
                    dobbel @turigeza last edited by

                    @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.

                    1 Reply Last reply Reply Quote 0
                    • metalsadman
                      metalsadman last edited by

                      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.

                      1 Reply Last reply Reply Quote 0
                      • First post
                        Last post