UNPKG

quasar

Version:

Build high-performance VueJS user interfaces (SPA, PWA, SSR, Mobile and Desktop) in record time

626 lines (505 loc) 18.3 kB
import { h, ref, computed, watch, nextTick, 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 { noop } from '../../utils/event.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' ] const emptyFn = () => {} 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 vm = getCurrentInstance() const { proxy: { $q } } = vm const { registerTick: registerScrollTick } = useTick() const { registerTimeout: registerFocusTimeout, removeTimeout: removeFocusTimeout } = useTimeout() const { registerTimeout } = 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 arrowsEnabled = computed(() => $q.platform.is.desktop === true || props.mobileArrows === true ) const tabList = [] const hasFocus = ref(false) let localFromRoute = false, animateTimer, scrollTimer, unwatchRoute let localUpdateArrows = arrowsEnabled.value === true ? updateArrowsFn : noop 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 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--${ arrowsEnabled.value === true && props.outsideArrows === true ? 'outside' : 'inside' }` + (props.dense === true ? ' q-tabs--dense' : '') + (props.shrink === true ? ' col-shrink' : '') + (props.stretch === true ? ' self-stretch' : '') ) const innerClass = computed(() => 'q-tabs__content row no-wrap items-center self-stretch hide-scrollbar relative-position ' + alignClass.value + (props.contentClass !== void 0 ? ` ${ props.contentClass }` : '') + ($q.platform.is.mobile === true ? ' scroll' : '') ) 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, localUpdateArrows) watch(() => props.modelValue, name => { updateModel({ name, setCurrent: true, skipEmit: true }) }) watch(() => props.outsideArrows, () => { nextTick(recalculateScroll()) }) watch(arrowsEnabled, v => { localUpdateArrows = v === true ? updateArrowsFn : noop nextTick(recalculateScroll()) }) function updateModel ({ name, setCurrent, skipEmit, fromRoute }) { if (currentModel.value !== name) { skipEmit !== true && emit('update:modelValue', name) if ( setCurrent === true || props[ 'onUpdate:modelValue' ] === void 0 ) { animate(currentModel.value, name) currentModel.value = name } } if (fromRoute !== void 0) { localFromRoute = fromRoute } } function recalculateScroll () { registerScrollTick(() => { if (vm.isDeactivated !== true && vm.isUnmounted !== true) { 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 if (scrollable.value !== scroll) { scrollable.value = scroll } // Arrows need to be updated even if the scroll status was already true scroll === true && nextTick(localUpdateArrows) const localJustify = size < parseInt(props.breakpoint, 10) if (justify.value !== localJustify) { justify.value = localJustify } } function animate (oldName, newName) { const oldTab = oldName !== void 0 && oldName !== null && oldName !== '' ? tabList.find(tab => tab.name.value === oldName) : null, newTab = newName !== void 0 && newName !== null && newName !== '' ? tabList.find(tab => tab.name.value === newName) : null if (oldTab && newTab) { const oldEl = oldTab.tabIndicatorRef.value, newEl = newTab.tabIndicatorRef.value clearTimeout(animateTimer) 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) nextTick(() => { animateTimer = setTimeout(() => { 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) localUpdateArrows() return } offset += props.vertical === true ? newPos.height - height : newPos.width - width if (offset > 0) { contentRef.value[ props.vertical === true ? 'scrollTop' : 'scrollLeft' ] += Math.ceil(offset) localUpdateArrows() } } function updateArrowsFn () { const content = contentRef.value if (content !== null) { 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) { stopAnimScroll() scrollTowards(value) 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 () { clearInterval(scrollTimer) } 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 ]) return true } if (keyCode === 35) { // End scrollToTabEl(tabs[ len - 1 ]) 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) localUpdateArrows() return done } function getRouteList () { return tabList.filter(tab => tab.routerProps !== void 0 && tab.routerProps.hasRouterLink.value === true) } // do not use directly; use verifyRouteModel() instead function updateActiveRoute () { let name = null, wasActive = localFromRoute const best = { matchedLen: 0, hrefLen: 0, exact: false, found: false }, { hash } = vm.proxy.$route, model = currentModel.value let wasItActive = wasActive === true ? emptyFn : tab => { if (model === tab.name.value) { wasActive = true wasItActive = emptyFn } } const tabList = getRouteList() for (const tab of tabList) { const exact = tab.routerProps.exact.value === true if ( tab.routerProps[ exact === true ? 'linkIsExactActive' : 'linkIsActive' ].value !== true || (best.exact === true && exact !== true) ) { wasItActive(tab) continue } const linkRoute = tab.routerProps.linkRoute.value, tabHash = linkRoute.hash // Vue Router does not match the hash too, even if link is set to "exact" if (exact === true) { if (hash === tabHash) { name = tab.name.value break } else if (hash !== '' && tabHash !== '') { wasItActive(tab) continue } } const matchedLen = linkRoute.matched.length, hrefLen = linkRoute.href.length - tabHash.length if ( matchedLen === best.matchedLen ? hrefLen > best.hrefLen : matchedLen > best.matchedLen ) { name = tab.name.value Object.assign(best, { matchedLen, hrefLen, exact }) continue } wasItActive(tab) } if (wasActive === true || name !== null) { updateModel({ name, setCurrent: true, fromRoute: 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 } } } function onFocusout () { registerFocusTimeout(() => { hasFocus.value = false }, 30) } function verifyRouteModel () { if ($tabs.avoidRouteWatcher !== true) { registerTimeout(updateActiveRoute) } } function registerTab (getTab) { tabList.push(getTab) const routeList = getRouteList() if (routeList.length > 0) { if (unwatchRoute === void 0) { unwatchRoute = watch(() => vm.proxy.$route, verifyRouteModel) } verifyRouteModel() } } /* * Vue has an aggressive diff (in-place replacement) so we cannot * ensure that the instance getting destroyed is the actual tab * reported here. As a result, we cannot use its name or check * if it's a route one to make the necessary updates. We need to * always check the existing list again and infer the changes. */ function unregisterTab (tabData) { tabList.splice(tabList.indexOf(tabData), 1) if (unwatchRoute !== void 0) { const routeList = getRouteList() if (routeList.length === 0) { unwatchRoute() unwatchRoute = void 0 } verifyRouteModel() } } const $tabs = { currentModel, tabProps, hasFocus, registerTab, unregisterTab, verifyRouteModel, updateModel, recalculateScroll, onKbdNavigate, avoidRouteWatcher: false } provide(tabsKey, $tabs) onBeforeUnmount(() => { clearTimeout(animateTimer) unwatchRoute !== void 0 && unwatchRoute() }) let shouldActivate = false onDeactivated(() => { shouldActivate = true }) onActivated(() => { shouldActivate === true && recalculateScroll() }) return () => { const child = [ h(QResizeObserver, { onResize: updateContainer }), h('div', { ref: contentRef, class: innerClass.value, onScroll: localUpdateArrows }, hSlot(slots.default)) ] arrowsEnabled.value === true && child.push( 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' ], onMousedown: scrollToStart, onTouchstartPassive: scrollToStart, onMouseup: stopAnimScroll, onMouseleave: stopAnimScroll, onTouchend: 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' ], onMousedown: scrollToEnd, onTouchstartPassive: scrollToEnd, onMouseup: stopAnimScroll, onMouseleave: stopAnimScroll, onTouchend: stopAnimScroll }) ) return h('div', { ref: rootRef, class: classes.value, role: 'tablist', onFocusin, onFocusout }, child) } } })