quasar
Version:
Build high-performance VueJS user interfaces (SPA, PWA, SSR, Mobile and Desktop) in record time
685 lines (556 loc) • 20.7 kB
JavaScript
import { h, ref, computed, watch, onBeforeUnmount, onActivated, onDeactivated, getCurrentInstance, provide } from 'vue'
import QIcon from '../icon/QIcon.js'
import QResizeObserver from '../resize-observer/QResizeObserver.js'
import useTick from '../../composables/private/use-tick.js'
import useTimeout from '../../composables/private/use-timeout.js'
import { createComponent } from '../../utils/private/create.js'
import { hSlot } from '../../utils/private/render.js'
import { tabsKey } from '../../utils/private/symbols.js'
import { rtlHasScrollBug } from '../../utils/private/rtl.js'
function getIndicatorClass (color, top, vertical) {
const pos = vertical === true
? [ 'left', 'right' ]
: [ 'top', 'bottom' ]
return `absolute-${ top === true ? pos[ 0 ] : pos[ 1 ] }${ color ? ` text-${ color }` : '' }`
}
const alignValues = [ 'left', 'center', 'right', 'justify' ]
export default createComponent({
name: 'QTabs',
props: {
modelValue: [ Number, String ],
align: {
type: String,
default: 'center',
validator: v => alignValues.includes(v)
},
breakpoint: {
type: [ String, Number ],
default: 600
},
vertical: Boolean,
shrink: Boolean,
stretch: Boolean,
activeClass: String,
activeColor: String,
activeBgColor: String,
indicatorColor: String,
leftIcon: String,
rightIcon: String,
outsideArrows: Boolean,
mobileArrows: Boolean,
switchIndicator: Boolean,
narrowIndicator: Boolean,
inlineLabel: Boolean,
noCaps: Boolean,
dense: Boolean,
contentClass: String,
'onUpdate:modelValue': [ Function, Array ]
},
setup (props, { slots, emit }) {
const { proxy } = getCurrentInstance()
const { $q } = proxy
const { registerTick: registerScrollTick } = useTick()
const { registerTick: registerUpdateArrowsTick } = useTick()
const { registerTick: registerAnimateTick } = useTick()
const { registerTimeout: registerFocusTimeout, removeTimeout: removeFocusTimeout } = useTimeout()
const { registerTimeout: registerScrollToTabTimeout, removeTimeout: removeScrollToTabTimeout } = useTimeout()
const rootRef = ref(null)
const contentRef = ref(null)
const currentModel = ref(props.modelValue)
const scrollable = ref(false)
const leftArrow = ref(true)
const rightArrow = ref(false)
const justify = ref(false)
const tabDataList = []
const tabDataListLen = ref(0)
const hasFocus = ref(false)
let animateTimer = null, scrollTimer = null, unwatchRoute
const tabProps = computed(() => ({
activeClass: props.activeClass,
activeColor: props.activeColor,
activeBgColor: props.activeBgColor,
indicatorClass: getIndicatorClass(
props.indicatorColor,
props.switchIndicator,
props.vertical
),
narrowIndicator: props.narrowIndicator,
inlineLabel: props.inlineLabel,
noCaps: props.noCaps
}))
const hasActiveTab = computed(() => {
const len = tabDataListLen.value
const val = currentModel.value
for (let i = 0; i < len; i++) {
if (tabDataList[ i ].name.value === val) {
return true
}
}
return false
})
const alignClass = computed(() => {
const align = scrollable.value === true
? 'left'
: (justify.value === true ? 'justify' : props.align)
return `q-tabs__content--align-${ align }`
})
const classes = computed(() =>
'q-tabs row no-wrap items-center'
+ ` q-tabs--${ scrollable.value === true ? '' : 'not-' }scrollable`
+ ` q-tabs--${ props.vertical === true ? 'vertical' : 'horizontal' }`
+ ` q-tabs__arrows--${ props.outsideArrows === true ? 'outside' : 'inside' }`
+ ` q-tabs--mobile-with${ props.mobileArrows === true ? '' : 'out' }-arrows`
+ (props.dense === true ? ' q-tabs--dense' : '')
+ (props.shrink === true ? ' col-shrink' : '')
+ (props.stretch === true ? ' self-stretch' : '')
)
const innerClass = computed(() =>
'q-tabs__content scroll--mobile row no-wrap items-center self-stretch hide-scrollbar relative-position '
+ alignClass.value
+ (props.contentClass !== void 0 ? ` ${ props.contentClass }` : '')
)
const domProps = computed(() => (
props.vertical === true
? { container: 'height', content: 'offsetHeight', scroll: 'scrollHeight' }
: { container: 'width', content: 'offsetWidth', scroll: 'scrollWidth' }
))
const isRTL = computed(() => props.vertical !== true && $q.lang.rtl === true)
const rtlPosCorrection = computed(() => rtlHasScrollBug === false && isRTL.value === true)
watch(isRTL, updateArrows)
watch(() => props.modelValue, name => {
updateModel({ name, setCurrent: true, skipEmit: true })
})
watch(() => props.outsideArrows, recalculateScroll)
function updateModel ({ name, setCurrent, skipEmit }) {
if (currentModel.value !== name) {
if (skipEmit !== true && props[ 'onUpdate:modelValue' ] !== void 0) {
emit('update:modelValue', name)
}
if (
setCurrent === true
|| props[ 'onUpdate:modelValue' ] === void 0
) {
animate(currentModel.value, name)
currentModel.value = name
}
}
}
function recalculateScroll () {
registerScrollTick(() => {
updateContainer({
width: rootRef.value.offsetWidth,
height: rootRef.value.offsetHeight
})
})
}
function updateContainer (domSize) {
// it can be called faster than component being initialized
// so we need to protect against that case
// (one example of such case is the docs release notes page)
if (domProps.value === void 0 || contentRef.value === null) { return }
const
size = domSize[ domProps.value.container ],
scrollSize = Math.min(
contentRef.value[ domProps.value.scroll ],
Array.prototype.reduce.call(
contentRef.value.children,
(acc, el) => acc + (el[ domProps.value.content ] || 0),
0
)
),
scroll = size > 0 && scrollSize > size // when there is no tab, in Chrome, size === 0 and scrollSize === 1
scrollable.value = scroll
// Arrows need to be updated even if the scroll status was already true
scroll === true && registerUpdateArrowsTick(updateArrows)
justify.value = size < parseInt(props.breakpoint, 10)
}
function animate (oldName, newName) {
const
oldTab = oldName !== void 0 && oldName !== null && oldName !== ''
? tabDataList.find(tab => tab.name.value === oldName)
: null,
newTab = newName !== void 0 && newName !== null && newName !== ''
? tabDataList.find(tab => tab.name.value === newName)
: null
if (oldTab && newTab) {
const
oldEl = oldTab.tabIndicatorRef.value,
newEl = newTab.tabIndicatorRef.value
if (animateTimer !== null) {
clearTimeout(animateTimer)
animateTimer = null
}
oldEl.style.transition = 'none'
oldEl.style.transform = 'none'
newEl.style.transition = 'none'
newEl.style.transform = 'none'
const
oldPos = oldEl.getBoundingClientRect(),
newPos = newEl.getBoundingClientRect()
newEl.style.transform = props.vertical === true
? `translate3d(0,${ oldPos.top - newPos.top }px,0) scale3d(1,${ newPos.height ? oldPos.height / newPos.height : 1 },1)`
: `translate3d(${ oldPos.left - newPos.left }px,0,0) scale3d(${ newPos.width ? oldPos.width / newPos.width : 1 },1,1)`
// allow scope updates to kick in (QRouteTab needs more time)
registerAnimateTick(() => {
animateTimer = setTimeout(() => {
animateTimer = null
newEl.style.transition = 'transform .25s cubic-bezier(.4, 0, .2, 1)'
newEl.style.transform = 'none'
}, 70)
})
}
if (newTab && scrollable.value === true) {
scrollToTabEl(newTab.rootRef.value)
}
}
function scrollToTabEl (el) {
const
{ left, width, top, height } = contentRef.value.getBoundingClientRect(),
newPos = el.getBoundingClientRect()
let offset = props.vertical === true ? newPos.top - top : newPos.left - left
if (offset < 0) {
contentRef.value[ props.vertical === true ? 'scrollTop' : 'scrollLeft' ] += Math.floor(offset)
updateArrows()
return
}
offset += props.vertical === true ? newPos.height - height : newPos.width - width
if (offset > 0) {
contentRef.value[ props.vertical === true ? 'scrollTop' : 'scrollLeft' ] += Math.ceil(offset)
updateArrows()
}
}
function updateArrows () {
const content = contentRef.value
if (content === null) { return }
const
rect = content.getBoundingClientRect(),
pos = props.vertical === true ? content.scrollTop : Math.abs(content.scrollLeft)
if (isRTL.value === true) {
leftArrow.value = Math.ceil(pos + rect.width) < content.scrollWidth - 1
rightArrow.value = pos > 0
}
else {
leftArrow.value = pos > 0
rightArrow.value = props.vertical === true
? Math.ceil(pos + rect.height) < content.scrollHeight
: Math.ceil(pos + rect.width) < content.scrollWidth
}
}
function animScrollTo (value) {
scrollTimer !== null && clearInterval(scrollTimer)
scrollTimer = setInterval(() => {
if (scrollTowards(value) === true) {
stopAnimScroll()
}
}, 5)
}
function scrollToStart () {
animScrollTo(rtlPosCorrection.value === true ? Number.MAX_SAFE_INTEGER : 0)
}
function scrollToEnd () {
animScrollTo(rtlPosCorrection.value === true ? 0 : Number.MAX_SAFE_INTEGER)
}
function stopAnimScroll () {
if (scrollTimer !== null) {
clearInterval(scrollTimer)
scrollTimer = null
}
}
function onKbdNavigate (keyCode, fromEl) {
const tabs = Array.prototype.filter.call(
contentRef.value.children,
el => el === fromEl || (el.matches && el.matches('.q-tab.q-focusable') === true)
)
const len = tabs.length
if (len === 0) { return }
if (keyCode === 36) { // Home
scrollToTabEl(tabs[ 0 ])
tabs[ 0 ].focus()
return true
}
if (keyCode === 35) { // End
scrollToTabEl(tabs[ len - 1 ])
tabs[ len - 1 ].focus()
return true
}
const dirPrev = keyCode === (props.vertical === true ? 38 /* ArrowUp */ : 37 /* ArrowLeft */)
const dirNext = keyCode === (props.vertical === true ? 40 /* ArrowDown */ : 39 /* ArrowRight */)
const dir = dirPrev === true ? -1 : (dirNext === true ? 1 : void 0)
if (dir !== void 0) {
const rtlDir = isRTL.value === true ? -1 : 1
const index = tabs.indexOf(fromEl) + dir * rtlDir
if (index >= 0 && index < len) {
scrollToTabEl(tabs[ index ])
tabs[ index ].focus({ preventScroll: true })
}
return true
}
}
// let's speed up execution of time-sensitive scrollTowards()
// with a computed variable by directly applying the minimal
// number of instructions on get/set functions
const posFn = computed(() => (
rtlPosCorrection.value === true
? { get: content => Math.abs(content.scrollLeft), set: (content, pos) => { content.scrollLeft = -pos } }
: (
props.vertical === true
? { get: content => content.scrollTop, set: (content, pos) => { content.scrollTop = pos } }
: { get: content => content.scrollLeft, set: (content, pos) => { content.scrollLeft = pos } }
)
))
function scrollTowards (value) {
const
content = contentRef.value,
{ get, set } = posFn.value
let
done = false,
pos = get(content)
const direction = value < pos ? -1 : 1
pos += direction * 5
if (pos < 0) {
done = true
pos = 0
}
else if (
(direction === -1 && pos <= value)
|| (direction === 1 && pos >= value)
) {
done = true
pos = value
}
set(content, pos)
updateArrows()
return done
}
function hasQueryIncluded (targetQuery, matchingQuery) {
for (const key in targetQuery) {
if (targetQuery[ key ] !== matchingQuery[ key ]) {
return false
}
}
return true
}
// do not use directly; use verifyRouteModel() instead
function updateActiveRoute () {
let name = null, bestScore = { matchedLen: 0, queryDiff: 9999, hrefLen: 0 }
const list = tabDataList.filter(tab => tab.routeData !== void 0 && tab.routeData.hasRouterLink.value === true)
const { hash: currentHash, query: currentQuery } = proxy.$route
const currentQueryLen = Object.keys(currentQuery).length
// Vue Router does not keep account of hash & query when matching
// so we're doing this as well
for (const tab of list) {
const exact = tab.routeData.exact.value === true
if (tab.routeData[ exact === true ? 'linkIsExactActive' : 'linkIsActive' ].value !== true) {
// it cannot match anything as it's not active nor exact-active
continue
}
const { hash, query, matched, href } = tab.routeData.resolvedLink.value
const queryLen = Object.keys(query).length
if (exact === true) {
if (hash !== currentHash) {
// it's set to exact but it doesn't matches the hash
continue
}
if (
queryLen !== currentQueryLen
|| hasQueryIncluded(currentQuery, query) === false
) {
// it's set to exact but it doesn't matches the query
continue
}
// yey, we found the perfect match (route + hash + query)
name = tab.name.value
break
}
if (hash !== '' && hash !== currentHash) {
// it has hash and it doesn't matches
continue
}
if (
queryLen !== 0
&& hasQueryIncluded(query, currentQuery) === false
) {
// it has query and it doesn't includes the current one
continue
}
const newScore = {
matchedLen: matched.length,
queryDiff: currentQueryLen - queryLen,
hrefLen: href.length - hash.length
}
if (newScore.matchedLen > bestScore.matchedLen) {
// it matches more routes so it's more specific so we set it as current champion
name = tab.name.value
bestScore = newScore
continue
}
else if (newScore.matchedLen !== bestScore.matchedLen) {
// it matches less routes than the current champion so we discard it
continue
}
if (newScore.queryDiff < bestScore.queryDiff) {
// query is closer to the current one so we set it as current champion
name = tab.name.value
bestScore = newScore
}
else if (newScore.queryDiff !== bestScore.queryDiff) {
// it matches less routes than the current champion so we discard it
continue
}
if (newScore.hrefLen > bestScore.hrefLen) {
// href is lengthier so it's more specific so we set it as current champion
name = tab.name.value
bestScore = newScore
}
}
if (
name === null
&& tabDataList.some(tab => tab.routeData === void 0 && tab.name.value === currentModel.value) === true
) {
// we shouldn't interfere if non-route tab is active
return
}
updateModel({ name, setCurrent: true })
}
function onFocusin (e) {
removeFocusTimeout()
if (
hasFocus.value !== true
&& rootRef.value !== null
&& e.target
&& typeof e.target.closest === 'function'
) {
const tab = e.target.closest('.q-tab')
// if the target is contained by a QTab/QRouteTab
// (it might be other elements focused, like additional QBtn)
if (tab && rootRef.value.contains(tab) === true) {
hasFocus.value = true
scrollable.value === true && scrollToTabEl(tab)
}
}
}
function onFocusout () {
registerFocusTimeout(() => { hasFocus.value = false }, 30)
}
function verifyRouteModel () {
if ($tabs.avoidRouteWatcher === false) {
registerScrollToTabTimeout(updateActiveRoute)
}
else {
removeScrollToTabTimeout()
}
}
function watchRoute () {
if (unwatchRoute === void 0) {
const unwatch = watch(() => proxy.$route.fullPath, verifyRouteModel)
unwatchRoute = () => {
unwatch()
unwatchRoute = void 0
}
}
}
function registerTab (tabData) {
tabDataList.push(tabData)
tabDataListLen.value++
recalculateScroll()
// if it's a QTab or we don't have Vue Router
if (tabData.routeData === void 0 || proxy.$route === void 0) {
// we should position to the currently active tab (if any)
registerScrollToTabTimeout(() => {
if (scrollable.value === true) {
const value = currentModel.value
const newTab = value !== void 0 && value !== null && value !== ''
? tabDataList.find(tab => tab.name.value === value)
: null
newTab && scrollToTabEl(newTab.rootRef.value)
}
})
}
// else if it's a QRouteTab with a valid link
else {
// start watching route
watchRoute()
if (tabData.routeData.hasRouterLink.value === true) {
verifyRouteModel()
}
}
}
function unregisterTab (tabData) {
tabDataList.splice(tabDataList.indexOf(tabData), 1)
tabDataListLen.value--
recalculateScroll()
if (unwatchRoute !== void 0 && tabData.routeData !== void 0) {
// unwatch route if we don't have any QRouteTabs left
if (tabDataList.every(tab => tab.routeData === void 0) === true) {
unwatchRoute()
}
// then update model
verifyRouteModel()
}
}
const $tabs = {
currentModel,
tabProps,
hasFocus,
hasActiveTab,
registerTab,
unregisterTab,
verifyRouteModel,
updateModel,
onKbdNavigate,
avoidRouteWatcher: false // false | string (uid)
}
provide(tabsKey, $tabs)
function cleanup () {
animateTimer !== null && clearTimeout(animateTimer)
stopAnimScroll()
unwatchRoute !== void 0 && unwatchRoute()
}
let hadRouteWatcher
onBeforeUnmount(cleanup)
onDeactivated(() => {
hadRouteWatcher = unwatchRoute !== void 0
cleanup()
})
onActivated(() => {
hadRouteWatcher === true && watchRoute()
recalculateScroll()
})
return () => {
return h('div', {
ref: rootRef,
class: classes.value,
role: 'tablist',
onFocusin,
onFocusout
}, [
h(QResizeObserver, { onResize: updateContainer }),
h('div', {
ref: contentRef,
class: innerClass.value,
onScroll: updateArrows
}, hSlot(slots.default)),
h(QIcon, {
class: 'q-tabs__arrow q-tabs__arrow--left absolute q-tab__icon'
+ (leftArrow.value === true ? '' : ' q-tabs__arrow--faded'),
name: props.leftIcon || $q.iconSet.tabs[ props.vertical === true ? 'up' : 'left' ],
onMousedownPassive: scrollToStart,
onTouchstartPassive: scrollToStart,
onMouseupPassive: stopAnimScroll,
onMouseleavePassive: stopAnimScroll,
onTouchendPassive: stopAnimScroll
}),
h(QIcon, {
class: 'q-tabs__arrow q-tabs__arrow--right absolute q-tab__icon'
+ (rightArrow.value === true ? '' : ' q-tabs__arrow--faded'),
name: props.rightIcon || $q.iconSet.tabs[ props.vertical === true ? 'down' : 'right' ],
onMousedownPassive: scrollToEnd,
onTouchstartPassive: scrollToEnd,
onMouseupPassive: stopAnimScroll,
onMouseleavePassive: stopAnimScroll,
onTouchendPassive: stopAnimScroll
})
])
}
}
})