UNPKG

quasar

Version:

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

775 lines (651 loc) 19.5 kB
import { h, withDirectives, ref, computed, watch, onMounted, onBeforeUnmount, nextTick, inject, getCurrentInstance } from 'vue' import useHistory from '../../composables/private.use-history/use-history.js' import useModelToggle, { useModelToggleProps, useModelToggleEmits } from '../../composables/private.use-model-toggle/use-model-toggle.js' import usePreventScroll from '../../composables/private.use-prevent-scroll/use-prevent-scroll.js' import useTimeout from '../../composables/use-timeout/use-timeout.js' import useDark, { useDarkProps } from '../../composables/private.use-dark/use-dark.js' import TouchPan from '../../directives/touch-pan/TouchPan.js' import { createComponent } from '../../utils/private.create/create.js' import { between } from '../../utils/format/format.js' import { hSlot, hDir } from '../../utils/private.render/render.js' import { layoutKey, emptyRenderFn } from '../../utils/private.symbols/symbols.js' const duration = 150 export default createComponent({ name: 'QDrawer', inheritAttrs: false, props: { ...useModelToggleProps, ...useDarkProps, side: { type: String, default: 'left', validator: v => ['left', 'right'].includes(v) }, width: { type: Number, default: 300 }, mini: Boolean, miniToOverlay: Boolean, miniWidth: { type: Number, default: 57 }, noMiniAnimation: Boolean, breakpoint: { type: Number, default: 1023 }, showIfAbove: Boolean, behavior: { type: String, validator: v => ['default', 'desktop', 'mobile'].includes(v), default: 'default' }, bordered: Boolean, elevated: Boolean, overlay: Boolean, persistent: Boolean, noSwipeOpen: Boolean, noSwipeClose: Boolean, noSwipeBackdrop: Boolean }, emits: [...useModelToggleEmits, 'onLayout', 'miniState'], setup(props, { slots, emit, attrs }) { const vm = getCurrentInstance() const { proxy: { $q } } = vm const isDark = useDark(props, $q) const { preventBodyScroll } = usePreventScroll() const { registerTimeout, removeTimeout } = useTimeout() const $layout = inject(layoutKey, emptyRenderFn) if ($layout === emptyRenderFn) { console.error('QDrawer needs to be child of QLayout') return emptyRenderFn } let lastDesktopState, timerMini = null, layoutTotalWidthWatcher const belowBreakpoint = ref( props.behavior === 'mobile' || (props.behavior !== 'desktop' && $layout.totalWidth.value <= props.breakpoint) ) const isMini = computed( () => props.mini === true && belowBreakpoint.value !== true ) const size = computed(() => isMini.value === true ? props.miniWidth : props.width ) const showing = ref( props.showIfAbove === true && belowBreakpoint.value === false ? true : props.modelValue === true ) const hideOnRouteChange = computed( () => props.persistent !== true && (belowBreakpoint.value === true || onScreenOverlay.value === true) ) function handleShow(evt, noEvent) { addToHistory() if (evt !== false) $layout.animate() applyPosition(0) if (belowBreakpoint.value === true) { const otherInstance = $layout.instances[otherSide.value] if (otherInstance?.belowBreakpoint === true) { otherInstance.hide(false) } applyBackdrop(1) if ($layout.isContainer.value !== true) preventBodyScroll(true) } else { applyBackdrop(0) if (evt !== false) setScrollable(false) } registerTimeout(() => { if (evt !== false) setScrollable(true) if (noEvent !== true) emit('show', evt) }, duration) } function handleHide(evt, noEvent) { removeFromHistory() if (evt !== false) $layout.animate() applyBackdrop(0) applyPosition(stateDirection.value * size.value) cleanup() if (noEvent !== true) { registerTimeout(() => { emit('hide', evt) }, duration) } else { removeTimeout() } } const { show, hide } = useModelToggle({ showing, hideOnRouteChange, handleShow, handleHide }) const { addToHistory, removeFromHistory } = useHistory( showing, hide, hideOnRouteChange ) const instance = { belowBreakpoint, hide } const rightSide = computed(() => props.side === 'right') const stateDirection = computed( () => ($q.lang.rtl === true ? -1 : 1) * (rightSide.value === true ? 1 : -1) ) const flagBackdropBg = ref(0) const flagPanning = ref(false) const flagMiniAnimate = ref(false) const flagContentPosition = ref( // starting with "hidden" for SSR size.value * stateDirection.value ) const otherSide = computed(() => rightSide.value === true ? 'left' : 'right' ) const offset = computed(() => showing.value === true && belowBreakpoint.value === false && props.overlay === false ? props.miniToOverlay === true ? props.miniWidth : size.value : 0 ) const fixed = computed( () => props.overlay === true || props.miniToOverlay === true || $layout.view.value.indexOf(rightSide.value ? 'R' : 'L') !== -1 || ($q.platform.is.ios === true && $layout.isContainer.value === true) ) const onLayout = computed( () => props.overlay === false && showing.value === true && belowBreakpoint.value === false ) const onScreenOverlay = computed( () => props.overlay === true && showing.value === true && belowBreakpoint.value === false ) const backdropClass = computed( () => 'fullscreen q-drawer__backdrop' + (showing.value === false && flagPanning.value === false ? ' hidden' : '') ) const backdropStyle = computed(() => ({ backgroundColor: `rgba(0,0,0,${flagBackdropBg.value * 0.4})` })) const headerSlot = computed(() => rightSide.value === true ? $layout.rows.value.top[2] === 'r' : $layout.rows.value.top[0] === 'l' ) const footerSlot = computed(() => rightSide.value === true ? $layout.rows.value.bottom[2] === 'r' : $layout.rows.value.bottom[0] === 'l' ) const aboveStyle = computed(() => { const css = {} if ($layout.header.space === true && headerSlot.value === false) { if (fixed.value === true) { css.top = `${$layout.header.offset}px` } else if ($layout.header.space === true) { css.top = `${$layout.header.size}px` } } if ($layout.footer.space === true && footerSlot.value === false) { if (fixed.value === true) { css.bottom = `${$layout.footer.offset}px` } else if ($layout.footer.space === true) { css.bottom = `${$layout.footer.size}px` } } return css }) const style = computed(() => { const acc = { width: `${size.value}px`, transform: `translateX(${flagContentPosition.value}px)` } return belowBreakpoint.value === true ? acc : Object.assign(acc, aboveStyle.value) }) const contentClass = computed( () => 'q-drawer__content fit ' + ($layout.isContainer.value !== true ? 'scroll' : 'overflow-auto') ) const classes = computed( () => `q-drawer q-drawer--${props.side}` + (flagMiniAnimate.value === true ? ' q-drawer--mini-animate' : '') + (props.bordered === true ? ' q-drawer--bordered' : '') + (isDark.value === true ? ' q-drawer--dark q-dark' : '') + (flagPanning.value === true ? ' no-transition' : showing.value === true ? '' : ' q-layout--prevent-focus') + (belowBreakpoint.value === true ? ' fixed q-drawer--on-top q-drawer--mobile q-drawer--top-padding' : ` q-drawer--${isMini.value === true ? 'mini' : 'standard'}` + (fixed.value === true || onLayout.value !== true ? ' fixed' : '') + (props.overlay === true || props.miniToOverlay === true ? ' q-drawer--on-top' : '') + (headerSlot.value === true ? ' q-drawer--top-padding' : '')) ) const openDirective = computed(() => { // if props.noSwipeOpen !== true const dir = $q.lang.rtl === true ? props.side : otherSide.value return [ [ TouchPan, onOpenPan, void 0, { [dir]: true, mouse: true } ] ] }) const contentCloseDirective = computed(() => { // if belowBreakpoint.value === true && props.noSwipeClose !== true const dir = $q.lang.rtl === true ? otherSide.value : props.side return [ [ TouchPan, onClosePan, void 0, { [dir]: true, mouse: true } ] ] }) const backdropCloseDirective = computed(() => { // if showing.value === true && props.noSwipeBackdrop !== true const dir = $q.lang.rtl === true ? otherSide.value : props.side return [ [ TouchPan, onClosePan, void 0, { [dir]: true, mouse: true, mouseAllDir: true } ] ] }) function updateBelowBreakpoint() { updateLocal( belowBreakpoint, props.behavior === 'mobile' || (props.behavior !== 'desktop' && $layout.totalWidth.value <= props.breakpoint) ) } watch(belowBreakpoint, val => { if (val === true) { // from lg to xs lastDesktopState = showing.value if (showing.value === true) hide(false) } else if ( props.overlay === false && props.behavior !== 'mobile' && lastDesktopState !== false ) { // from xs to lg if (showing.value === true) { applyPosition(0) applyBackdrop(0) cleanup() } else { show(false) } } }) watch( () => props.side, (newSide, oldSide) => { if ($layout.instances[oldSide] === instance) { $layout.instances[oldSide] = void 0 $layout[oldSide].space = false $layout[oldSide].offset = 0 } $layout.instances[newSide] = instance $layout[newSide].size = size.value $layout[newSide].space = onLayout.value $layout[newSide].offset = offset.value } ) watch($layout.totalWidth, () => { if ( $layout.isContainer.value === true || document.qScrollPrevented !== true ) { updateBelowBreakpoint() } }) watch(() => props.behavior + props.breakpoint, updateBelowBreakpoint) watch($layout.isContainer, val => { if (showing.value === true) preventBodyScroll(val !== true) if (val === true) updateBelowBreakpoint() }) watch($layout.scrollbarWidth, () => { applyPosition(showing.value === true ? 0 : void 0) }) watch(offset, val => { updateLayout('offset', val) }) watch(onLayout, val => { emit('onLayout', val) updateLayout('space', val) }) watch(rightSide, () => { applyPosition() }) watch(size, val => { applyPosition() updateSizeOnLayout(props.miniToOverlay, val) }) watch( () => props.miniToOverlay, val => { updateSizeOnLayout(val, size.value) } ) watch( () => $q.lang.rtl, () => { applyPosition() } ) watch( () => props.mini, () => { if (props.noMiniAnimation) return if (props.modelValue === true) { animateMini() $layout.animate() } } ) watch(isMini, val => { emit('miniState', val) }) function applyPosition(position) { if (position === void 0) { nextTick(() => { position = showing.value === true ? 0 : size.value applyPosition(stateDirection.value * position) }) } else { if ( $layout.isContainer.value === true && rightSide.value === true && (belowBreakpoint.value === true || Math.abs(position) === size.value) ) { position += stateDirection.value * $layout.scrollbarWidth.value } flagContentPosition.value = position } } function applyBackdrop(x) { flagBackdropBg.value = x } function setScrollable(v) { const action = v === true ? 'remove' : $layout.isContainer.value !== true ? 'add' : '' if (action !== '') { document.body.classList[action]('q-body--drawer-toggle') } } function animateMini() { if (timerMini !== null) clearTimeout(timerMini) if (vm.proxy && vm.proxy.$el) { // need to speed it up and apply it immediately, // even faster than Vue's nextTick! vm.proxy.$el.classList.add('q-drawer--mini-animate') } flagMiniAnimate.value = true timerMini = setTimeout(() => { timerMini = null flagMiniAnimate.value = false vm?.proxy?.$el?.classList.remove('q-drawer--mini-animate') }, 150) } function onOpenPan(evt) { if (showing.value !== false) { // some browsers might capture and trigger this // even if Drawer has just been opened (but animation is still pending) return } const width = size.value, position = between(evt.distance.x, 0, width) if (evt.isFinal === true) { const opened = position >= Math.min(75, width) if (opened === true) { show() } else { $layout.animate() applyBackdrop(0) applyPosition(stateDirection.value * width) } flagPanning.value = false return } applyPosition( ($q.lang.rtl === true ? rightSide.value !== true : rightSide.value) ? Math.max(width - position, 0) : Math.min(0, position - width) ) applyBackdrop(between(position / width, 0, 1)) if (evt.isFirst === true) { flagPanning.value = true } } function onClosePan(evt) { if (showing.value !== true) { // some browsers might capture and trigger this // even if Drawer has just been closed (but animation is still pending) return } const width = size.value, dir = evt.direction === props.side, position = ($q.lang.rtl === true ? dir !== true : dir) ? between(evt.distance.x, 0, width) : 0 if (evt.isFinal === true) { const opened = Math.abs(position) < Math.min(75, width) if (opened === true) { $layout.animate() applyBackdrop(1) applyPosition(0) } else { hide() } flagPanning.value = false return } applyPosition(stateDirection.value * position) applyBackdrop(between(1 - position / width, 0, 1)) if (evt.isFirst === true) { flagPanning.value = true } } function cleanup() { preventBodyScroll(false) setScrollable(true) } function updateLayout(prop, val) { $layout.update(props.side, prop, val) } function updateLocal(prop, val) { if (prop.value !== val) { prop.value = val } } function updateSizeOnLayout(miniToOverlay, newSize) { updateLayout('size', miniToOverlay === true ? props.miniWidth : newSize) } $layout.instances[props.side] = instance updateSizeOnLayout(props.miniToOverlay, size.value) updateLayout('space', onLayout.value) updateLayout('offset', offset.value) if ( props.showIfAbove === true && props.modelValue !== true && showing.value === true && props['onUpdate:modelValue'] !== void 0 ) { emit('update:modelValue', true) } onMounted(() => { emit('onLayout', onLayout.value) emit('miniState', isMini.value) lastDesktopState = props.showIfAbove === true const fn = () => { const action = showing.value === true ? handleShow : handleHide action(false, true) } if ($layout.totalWidth.value !== 0) { // make sure that all computed properties // have been updated before calling handleShow/handleHide() nextTick(fn) return } layoutTotalWidthWatcher = watch($layout.totalWidth, () => { layoutTotalWidthWatcher() layoutTotalWidthWatcher = void 0 if ( showing.value === false && props.showIfAbove === true && belowBreakpoint.value === false ) { show(false) } else { fn() } }) }) onBeforeUnmount(() => { layoutTotalWidthWatcher?.() if (timerMini !== null) { clearTimeout(timerMini) timerMini = null } if (showing.value === true) cleanup() if ($layout.instances[props.side] === instance) { $layout.instances[props.side] = void 0 updateLayout('size', 0) updateLayout('offset', 0) updateLayout('space', false) } }) return () => { const child = [] if (belowBreakpoint.value === true) { if (props.noSwipeOpen === false) { child.push( withDirectives( h('div', { key: 'open', class: `q-drawer__opener fixed-${props.side}`, 'aria-hidden': 'true' }), openDirective.value ) ) } child.push( hDir( 'div', { ref: 'backdrop', class: backdropClass.value, style: backdropStyle.value, 'aria-hidden': 'true', onClick: hide }, void 0, 'backdrop', props.noSwipeBackdrop !== true && showing.value === true, () => backdropCloseDirective.value ) ) } const mini = isMini.value === true && slots.mini !== void 0 const content = [ h( 'div', { ...attrs, key: String(mini), // required otherwise Vue will not diff correctly class: [contentClass.value, attrs.class] }, mini === true ? slots.mini() : hSlot(slots.default) ) ] if (props.elevated === true && showing.value === true) { content.push( h('div', { class: 'q-layout__shadow absolute-full overflow-hidden no-pointer-events' }) ) } child.push( hDir( 'aside', { ref: 'content', class: classes.value, style: style.value }, content, 'contentclose', props.noSwipeClose !== true && belowBreakpoint.value === true, () => contentCloseDirective.value ) ) return h('div', { class: 'q-drawer-container' }, child) } } })