quasar
Version:
Build high-performance VueJS user interfaces (SPA, PWA, SSR, Mobile and Desktop) in record time
720 lines (621 loc) • 20.6 kB
JavaScript
import {
h, ref, computed, watch,
withDirectives, vShow, nextTick, getCurrentInstance, onBeforeUpdate
} from 'vue'
import QIcon from '../icon/QIcon.js'
import QCheckbox from '../checkbox/QCheckbox.js'
import QSlideTransition from '../slide-transition/QSlideTransition.js'
import QSpinner from '../spinner/QSpinner.js'
import useDark, { useDarkProps } from '../../composables/private/use-dark.js'
import { createComponent } from '../../utils/private/create.js'
import { stopAndPrevent } from '../../utils/event.js'
import { shouldIgnoreKey } from '../../utils/private/key-composition.js'
import { injectProp } from '../../utils/private/inject-obj-prop.js'
const tickStrategyOptions = [ 'none', 'strict', 'leaf', 'leaf-filtered' ]
export default createComponent({
name: 'QTree',
props: {
...useDarkProps,
nodes: {
type: Array,
required: true
},
nodeKey: {
type: String,
required: true
},
labelKey: {
type: String,
default: 'label'
},
childrenKey: {
type: String,
default: 'children'
},
dense: Boolean,
color: String,
controlColor: String,
textColor: String,
selectedColor: String,
icon: String,
tickStrategy: {
type: String,
default: 'none',
validator: v => tickStrategyOptions.includes(v)
},
ticked: Array, // v-model:ticked
expanded: Array, // v-model:expanded
selected: {}, // v-model:selected
noSelectionUnset: Boolean,
defaultExpandAll: Boolean,
accordion: Boolean,
filter: String,
filterMethod: Function,
duration: Number,
noConnectors: Boolean,
noTransition: Boolean,
noNodesLabel: String,
noResultsLabel: String
},
emits: [
'update:expanded',
'update:ticked',
'update:selected',
'lazyLoad',
'afterShow',
'afterHide'
],
setup (props, { slots, emit }) {
const { proxy } = getCurrentInstance()
const { $q } = proxy
const isDark = useDark(props, $q)
const lazy = ref({})
const innerTicked = ref(props.ticked || [])
const innerExpanded = ref(props.expanded || [])
let blurTargets = {}
onBeforeUpdate(() => {
blurTargets = {}
})
const classes = computed(() =>
`q-tree q-tree--${ props.dense === true ? 'dense' : 'standard' }`
+ (props.noConnectors === true ? ' q-tree--no-connectors' : '')
+ (isDark.value === true ? ' q-tree--dark' : '')
+ (props.color !== void 0 ? ` text-${ props.color }` : '')
)
const hasSelection = computed(() => props.selected !== void 0)
const computedIcon = computed(() => props.icon || $q.iconSet.tree.icon)
const computedControlColor = computed(() => props.controlColor || props.color)
const textColorClass = computed(() => (
props.textColor !== void 0
? ` text-${ props.textColor }`
: ''
))
const selectedColorClass = computed(() => {
const color = props.selectedColor || props.color
return color ? ` text-${ color }` : ''
})
const computedFilterMethod = computed(() => (
props.filterMethod !== void 0
? props.filterMethod
: (node, filter) => {
const filt = filter.toLowerCase()
return node[ props.labelKey ]
&& node[ props.labelKey ].toLowerCase().indexOf(filt) > -1
}
))
const meta = computed(() => {
const meta = {}
const travel = (node, parent) => {
const tickStrategy = node.tickStrategy || (parent ? parent.tickStrategy : props.tickStrategy)
const
key = node[ props.nodeKey ],
isParent = node[ props.childrenKey ] && node[ props.childrenKey ].length > 0,
selectable = node.disabled !== true && hasSelection.value === true && node.selectable !== false,
expandable = node.disabled !== true && node.expandable !== false,
hasTicking = tickStrategy !== 'none',
strictTicking = tickStrategy === 'strict',
leafFilteredTicking = tickStrategy === 'leaf-filtered',
leafTicking = tickStrategy === 'leaf' || tickStrategy === 'leaf-filtered'
let tickable = node.disabled !== true && node.tickable !== false
if (leafTicking === true && tickable === true && parent && parent.tickable !== true) {
tickable = false
}
let localLazy = node.lazy
if (
localLazy === true
&& lazy.value[ key ] !== void 0
&& Array.isArray(node[ props.childrenKey ]) === true
) {
localLazy = lazy.value[ key ]
}
const m = {
key,
parent,
isParent,
lazy: localLazy,
disabled: node.disabled,
link: node.disabled !== true && (selectable === true || (expandable === true && (isParent === true || localLazy === true))),
children: [],
matchesFilter: props.filter ? computedFilterMethod.value(node, props.filter) : true,
selected: key === props.selected && selectable === true,
selectable,
expanded: isParent === true ? innerExpanded.value.includes(key) : false,
expandable,
noTick: node.noTick === true || (strictTicking !== true && localLazy && localLazy !== 'loaded'),
tickable,
tickStrategy,
hasTicking,
strictTicking,
leafFilteredTicking,
leafTicking,
ticked: strictTicking === true
? innerTicked.value.includes(key)
: (isParent === true ? false : innerTicked.value.includes(key))
}
meta[ key ] = m
if (isParent === true) {
m.children = node[ props.childrenKey ].map(n => travel(n, m))
if (props.filter) {
if (m.matchesFilter !== true) {
m.matchesFilter = m.children.some(n => n.matchesFilter)
}
else if (
m.noTick !== true
&& m.disabled !== true
&& m.tickable === true
&& leafFilteredTicking === true
&& m.children.every(n => n.matchesFilter !== true || n.noTick === true || n.tickable !== true) === true
) {
m.tickable = false
}
}
if (m.matchesFilter === true) {
if (m.noTick !== true && strictTicking !== true && m.children.every(n => n.noTick) === true) {
m.noTick = true
}
if (leafTicking) {
m.ticked = false
m.indeterminate = m.children.some(node => node.indeterminate === true)
m.tickable = m.tickable === true && m.children.some(node => node.tickable)
if (m.indeterminate !== true) {
const sel = m.children
.reduce((acc, meta) => (meta.ticked === true ? acc + 1 : acc), 0)
if (sel === m.children.length) {
m.ticked = true
}
else if (sel > 0) {
m.indeterminate = true
}
}
if (m.indeterminate === true) {
m.indeterminateNextState = m.children
.every(meta => meta.tickable !== true || meta.ticked !== true)
}
}
}
}
return m
}
props.nodes.forEach(node => travel(node, null))
return meta
})
watch(() => props.ticked, val => {
innerTicked.value = val
})
watch(() => props.expanded, val => {
innerExpanded.value = val
})
function getNodeByKey (key) {
const reduce = [].reduce
const find = (result, node) => {
if (result || !node) {
return result
}
if (Array.isArray(node) === true) {
return reduce.call(Object(node), find, result)
}
if (node[ props.nodeKey ] === key) {
return node
}
if (node[ props.childrenKey ]) {
return find(null, node[ props.childrenKey ])
}
}
return find(null, props.nodes)
}
function getTickedNodes () {
return innerTicked.value.map(key => getNodeByKey(key))
}
function getExpandedNodes () {
return innerExpanded.value.map(key => getNodeByKey(key))
}
function isExpanded (key) {
return key && meta.value[ key ]
? meta.value[ key ].expanded
: false
}
function collapseAll () {
if (props.expanded !== void 0) {
emit('update:expanded', [])
}
else {
innerExpanded.value = []
}
}
function expandAll () {
const
expanded = innerExpanded.value,
travel = node => {
if (node[ props.childrenKey ] && node[ props.childrenKey ].length > 0) {
if (node.expandable !== false && node.disabled !== true) {
expanded.push(node[ props.nodeKey ])
node[ props.childrenKey ].forEach(travel)
}
}
}
props.nodes.forEach(travel)
if (props.expanded !== void 0) {
emit('update:expanded', expanded)
}
else {
innerExpanded.value = expanded
}
}
function setExpanded (key, state, node = getNodeByKey(key), m = meta.value[ key ]) {
if (m.lazy && m.lazy !== 'loaded') {
if (m.lazy === 'loading') {
return
}
lazy.value[ key ] = 'loading'
if (Array.isArray(node[ props.childrenKey ]) !== true) {
node[ props.childrenKey ] = []
}
emit('lazyLoad', {
node,
key,
done: children => {
lazy.value[ key ] = 'loaded'
node[ props.childrenKey ] = Array.isArray(children) === true ? children : []
nextTick(() => {
const localMeta = meta.value[ key ]
if (localMeta && localMeta.isParent === true) {
localSetExpanded(key, true)
}
})
},
fail: () => {
delete lazy.value[ key ]
if (node[ props.childrenKey ].length === 0) {
delete node[ props.childrenKey ]
}
}
})
}
else if (m.isParent === true && m.expandable === true) {
localSetExpanded(key, state)
}
}
function localSetExpanded (key, state) {
let target = innerExpanded.value
const shouldEmit = props.expanded !== void 0
if (shouldEmit === true) {
target = target.slice()
}
if (state) {
if (props.accordion) {
if (meta.value[ key ]) {
const collapse = []
if (meta.value[ key ].parent) {
meta.value[ key ].parent.children.forEach(m => {
if (m.key !== key && m.expandable === true) {
collapse.push(m.key)
}
})
}
else {
props.nodes.forEach(node => {
const k = node[ props.nodeKey ]
if (k !== key) {
collapse.push(k)
}
})
}
if (collapse.length > 0) {
target = target.filter(k => collapse.includes(k) === false)
}
}
}
target = target.concat([ key ])
.filter((key, index, self) => self.indexOf(key) === index)
}
else {
target = target.filter(k => k !== key)
}
if (shouldEmit === true) {
emit('update:expanded', target)
}
else {
innerExpanded.value = target
}
}
function isTicked (key) {
return key && meta.value[ key ]
? meta.value[ key ].ticked
: false
}
function setTicked (keys, state) {
let target = innerTicked.value
const shouldEmit = props.ticked !== void 0
if (shouldEmit === true) {
target = target.slice()
}
if (state) {
target = target.concat(keys)
.filter((key, index, self) => self.indexOf(key) === index)
}
else {
target = target.filter(k => keys.includes(k) === false)
}
if (shouldEmit === true) {
emit('update:ticked', target)
}
}
function getSlotScope (node, meta, key) {
const scope = { tree: proxy, node, key, color: props.color, dark: isDark.value }
injectProp(
scope,
'expanded',
() => { return meta.expanded },
val => { val !== meta.expanded && setExpanded(key, val) }
)
injectProp(
scope,
'ticked',
() => { return meta.ticked },
val => { val !== meta.ticked && setTicked([ key ], val) }
)
return scope
}
function getChildren (nodes) {
return (
props.filter
? nodes.filter(n => meta.value[ n[ props.nodeKey ] ].matchesFilter)
: nodes
).map(child => getNode(child))
}
function getNodeMedia (node) {
if (node.icon !== void 0) {
return h(QIcon, {
class: 'q-tree__icon q-mr-sm',
name: node.icon,
color: node.iconColor
})
}
const src = node.img || node.avatar
if (src) {
return h('img', {
class: `q-tree__${ node.img ? 'img' : 'avatar' } q-mr-sm`,
src
})
}
}
function onShow () {
emit('afterShow')
}
function onHide () {
emit('afterHide')
}
function getNode (node) {
const
key = node[ props.nodeKey ],
m = meta.value[ key ],
header = node.header
? slots[ `header-${ node.header }` ] || slots[ 'default-header' ]
: slots[ 'default-header' ]
const children = m.isParent === true
? getChildren(node[ props.childrenKey ])
: []
const isParent = children.length > 0 || (m.lazy && m.lazy !== 'loaded')
let body = node.body
? slots[ `body-${ node.body }` ] || slots[ 'default-body' ]
: slots[ 'default-body' ]
const slotScope = header !== void 0 || body !== void 0
? getSlotScope(node, m, key)
: null
if (body !== void 0) {
body = h('div', { class: 'q-tree__node-body relative-position' }, [
h('div', { class: textColorClass.value }, [
body(slotScope)
])
])
}
return h('div', {
key,
class: 'q-tree__node relative-position'
+ ` q-tree__node--${ isParent === true ? 'parent' : 'child' }`
}, [
h('div', {
class: 'q-tree__node-header relative-position row no-wrap items-center'
+ (m.link === true ? ' q-tree__node--link q-hoverable q-focusable' : '')
+ (m.selected === true ? ' q-tree__node--selected' : '')
+ (m.disabled === true ? ' q-tree__node--disabled' : ''),
tabindex: m.link === true ? 0 : -1,
onClick: (e) => {
onClick(node, m, e)
},
onKeypress (e) {
if (shouldIgnoreKey(e) !== true) {
if (e.keyCode === 13) { onClick(node, m, e, true) }
else if (e.keyCode === 32) { onExpandClick(node, m, e, true) }
}
}
}, [
h('div', {
class: 'q-focus-helper',
tabindex: -1,
ref: el => { blurTargets[ m.key ] = el }
}),
m.lazy === 'loading'
? h(QSpinner, {
class: 'q-tree__spinner',
color: computedControlColor.value
})
: (
isParent === true
? h(QIcon, {
class: 'q-tree__arrow'
+ (m.expanded === true ? ' q-tree__arrow--rotate' : ''),
name: computedIcon.value,
onClick (e) { onExpandClick(node, m, e) }
})
: null
),
m.hasTicking === true && m.noTick !== true
? h(QCheckbox, {
class: 'q-tree__tickbox',
modelValue: m.indeterminate === true ? null : m.ticked,
color: computedControlColor.value,
dark: isDark.value,
dense: true,
keepColor: true,
disable: m.tickable !== true,
onKeydown: stopAndPrevent,
'onUpdate:modelValue': v => {
onTickedClick(m, v)
}
})
: null,
h('div', {
class: 'q-tree__node-header-content col row no-wrap items-center'
+ (m.selected === true ? selectedColorClass.value : textColorClass.value)
}, [
header
? header(slotScope)
: [
getNodeMedia(node),
h('div', node[ props.labelKey ])
]
])
]),
isParent === true
? (
props.noTransition === true
? h('div', {
class: 'q-tree__node-collapsible' + textColorClass.value,
key: `${ key }__q`
}, [
body,
h('div', {
class: 'q-tree__children'
+ (m.disabled === true ? ' q-tree__node--disabled' : '')
}, m.expanded ? children : null)
])
: h(QSlideTransition, {
duration: props.duration,
onShow,
onHide
}, () => withDirectives(
h('div', {
class: 'q-tree__node-collapsible' + textColorClass.value,
key: `${ key }__q`
}, [
body,
h('div', {
class: 'q-tree__children'
+ (m.disabled === true ? ' q-tree__node--disabled' : '')
}, children)
]),
[ [ vShow, m.expanded ] ]
))
)
: body
])
}
function blur (key) {
const blurTarget = blurTargets[ key ]
blurTarget && blurTarget.focus()
}
function onClick (node, meta, e, keyboard) {
keyboard !== true && meta.selectable !== false && blur(meta.key)
if (hasSelection.value && meta.selectable) {
if (props.noSelectionUnset === false) {
emit('update:selected', meta.key !== props.selected ? meta.key : null)
}
else if (meta.key !== props.selected) {
emit('update:selected', meta.key === void 0 ? null : meta.key)
}
}
else {
onExpandClick(node, meta, e, keyboard)
}
if (typeof node.handler === 'function') {
node.handler(node)
}
}
function onExpandClick (node, meta, e, keyboard) {
if (e !== void 0) {
stopAndPrevent(e)
}
keyboard !== true && meta.selectable !== false && blur(meta.key)
setExpanded(meta.key, !meta.expanded, node, meta)
}
function onTickedClick (meta, state) {
if (meta.indeterminate === true) {
state = meta.indeterminateNextState
}
if (meta.strictTicking) {
setTicked([ meta.key ], state)
}
else if (meta.leafTicking) {
const keys = []
const travel = meta => {
if (meta.isParent) {
if (state !== true && meta.noTick !== true && meta.tickable === true) {
keys.push(meta.key)
}
if (meta.leafTicking === true) {
meta.children.forEach(travel)
}
}
else if (
meta.noTick !== true
&& meta.tickable === true
&& (meta.leafFilteredTicking !== true || meta.matchesFilter === true)
) {
keys.push(meta.key)
}
}
travel(meta)
setTicked(keys, state)
}
}
props.defaultExpandAll === true && expandAll()
// expose public methods
Object.assign(proxy, {
getNodeByKey,
getTickedNodes,
getExpandedNodes,
isExpanded,
collapseAll,
expandAll,
setExpanded,
isTicked,
setTicked
})
return () => {
const children = getChildren(props.nodes)
return h(
'div', {
class: classes.value
},
children.length === 0
? (
props.filter
? props.noResultsLabel || $q.lang.tree.noResults
: props.noNodesLabel || $q.lang.tree.noNodes
)
: children
)
}
}
})