UNPKG

vuetify

Version:

Vue Material Component Framework

492 lines (430 loc) 13.1 kB
// Styles import './VMenu.sass' // Components import { VThemeProvider } from '../VThemeProvider' // Mixins import Activatable from '../../mixins/activatable' import Delayable from '../../mixins/delayable' import Dependent from '../../mixins/dependent' import Detachable from '../../mixins/detachable' import Menuable from '../../mixins/menuable' import Returnable from '../../mixins/returnable' import Roundable from '../../mixins/roundable' import Toggleable from '../../mixins/toggleable' import Themeable from '../../mixins/themeable' // Directives import ClickOutside from '../../directives/click-outside' import Resize from '../../directives/resize' // Utilities import mixins from '../../util/mixins' import { removed } from '../../util/console' import { convertToUnit, keyCodes, } from '../../util/helpers' // Types import { VNode, VNodeDirective, VNodeData } from 'vue' const baseMixins = mixins( Dependent, Delayable, Detachable, Menuable, Returnable, Roundable, Toggleable, Themeable ) /* @vue/component */ export default baseMixins.extend({ name: 'v-menu', directives: { ClickOutside, Resize, }, provide (): object { return { isInMenu: true, // Pass theme through to default slot theme: this.theme, } }, props: { auto: Boolean, closeOnClick: { type: Boolean, default: true, }, closeOnContentClick: { type: Boolean, default: true, }, disabled: Boolean, disableKeys: Boolean, maxHeight: { type: [Number, String], default: 'auto', }, offsetX: Boolean, offsetY: Boolean, openOnClick: { type: Boolean, default: true, }, openOnHover: Boolean, origin: { type: String, default: 'top left', }, transition: { type: [Boolean, String], default: 'v-menu-transition', }, }, data () { return { calculatedTopAuto: 0, defaultOffset: 8, hasJustFocused: false, listIndex: -1, resizeTimeout: 0, selectedIndex: null as null | number, tiles: [] as HTMLElement[], } }, computed: { activeTile (): HTMLElement | undefined { return this.tiles[this.listIndex] }, calculatedLeft (): string { const menuWidth = Math.max(this.dimensions.content.width, parseFloat(this.calculatedMinWidth)) if (!this.auto) return this.calcLeft(menuWidth) || '0' return convertToUnit(this.calcXOverflow(this.calcLeftAuto(), menuWidth)) || '0' }, calculatedMaxHeight (): string { const height = this.auto ? '200px' : convertToUnit(this.maxHeight) return height || '0' }, calculatedMaxWidth (): string { return convertToUnit(this.maxWidth) || '0' }, calculatedMinWidth (): string { if (this.minWidth) { return convertToUnit(this.minWidth) || '0' } const minWidth = Math.min( this.dimensions.activator.width + Number(this.nudgeWidth) + (this.auto ? 16 : 0), Math.max(this.pageWidth - 24, 0) ) const calculatedMaxWidth = isNaN(parseInt(this.calculatedMaxWidth)) ? minWidth : parseInt(this.calculatedMaxWidth) return convertToUnit(Math.min( calculatedMaxWidth, minWidth )) || '0' }, calculatedTop (): string { const top = !this.auto ? this.calcTop() : convertToUnit(this.calcYOverflow(this.calculatedTopAuto)) return top || '0' }, hasClickableTiles (): boolean { return Boolean(this.tiles.find(tile => tile.tabIndex > -1)) }, styles (): object { return { maxHeight: this.calculatedMaxHeight, minWidth: this.calculatedMinWidth, maxWidth: this.calculatedMaxWidth, top: this.calculatedTop, left: this.calculatedLeft, transformOrigin: this.origin, zIndex: this.zIndex || this.activeZIndex, } }, }, watch: { isActive (val) { if (!val) this.listIndex = -1 }, isContentActive (val) { this.hasJustFocused = val }, listIndex (next, prev) { if (next in this.tiles) { const tile = this.tiles[next] tile.classList.add('v-list-item--highlighted') this.$refs.content.scrollTop = tile.offsetTop - tile.clientHeight } prev in this.tiles && this.tiles[prev].classList.remove('v-list-item--highlighted') }, }, created () { /* istanbul ignore next */ if (this.$attrs.hasOwnProperty('full-width')) { removed('full-width', this) } }, mounted () { this.isActive && this.callActivate() }, methods: { activate () { // Update coordinates and dimensions of menu // and its activator this.updateDimensions() // Start the transition requestAnimationFrame(() => { // Once transitioning, calculate scroll and top position this.startTransition().then(() => { if (this.$refs.content) { this.calculatedTopAuto = this.calcTopAuto() this.auto && (this.$refs.content.scrollTop = this.calcScrollPosition()) } }) }) }, calcScrollPosition () { const $el = this.$refs.content const activeTile = $el.querySelector('.v-list-item--active') as HTMLElement const maxScrollTop = $el.scrollHeight - $el.offsetHeight return activeTile ? Math.min(maxScrollTop, Math.max(0, activeTile.offsetTop - $el.offsetHeight / 2 + activeTile.offsetHeight / 2)) : $el.scrollTop }, calcLeftAuto () { return parseInt(this.dimensions.activator.left - this.defaultOffset * 2) }, calcTopAuto () { const $el = this.$refs.content const activeTile = $el.querySelector('.v-list-item--active') as HTMLElement | null if (!activeTile) { this.selectedIndex = null } if (this.offsetY || !activeTile) { return this.computedTop } this.selectedIndex = Array.from(this.tiles).indexOf(activeTile) const tileDistanceFromMenuTop = activeTile.offsetTop - this.calcScrollPosition() const firstTileOffsetTop = ($el.querySelector('.v-list-item') as HTMLElement).offsetTop return this.computedTop - tileDistanceFromMenuTop - firstTileOffsetTop - 1 }, changeListIndex (e: KeyboardEvent) { // For infinite scroll and autocomplete, re-evaluate children this.getTiles() if (!this.isActive || !this.hasClickableTiles) { return } else if (e.keyCode === keyCodes.tab) { this.isActive = false return } else if (e.keyCode === keyCodes.down) { this.nextTile() } else if (e.keyCode === keyCodes.up) { this.prevTile() } else if (e.keyCode === keyCodes.enter && this.listIndex !== -1) { this.tiles[this.listIndex].click() } else { return } // One of the conditions was met, prevent default action (#2988) e.preventDefault() }, closeConditional (e: Event) { const target = e.target as HTMLElement return this.isActive && !this._isDestroyed && this.closeOnClick && !this.$refs.content.contains(target) }, genActivatorAttributes () { const attributes = Activatable.options.methods.genActivatorAttributes.call(this) if (this.activeTile && this.activeTile.id) { return { ...attributes, 'aria-activedescendant': this.activeTile.id, } } return attributes }, genActivatorListeners () { const listeners = Menuable.options.methods.genActivatorListeners.call(this) if (!this.disableKeys) { listeners.keydown = this.onKeyDown } return listeners }, genTransition (): VNode { const content = this.genContent() if (!this.transition) return content return this.$createElement('transition', { props: { name: this.transition, }, }, [content]) }, genDirectives (): VNodeDirective[] { const directives: VNodeDirective[] = [{ name: 'show', value: this.isContentActive, }] // Do not add click outside for hover menu if (!this.openOnHover && this.closeOnClick) { directives.push({ name: 'click-outside', value: { handler: () => { this.isActive = false }, closeConditional: this.closeConditional, include: () => [this.$el, ...this.getOpenDependentElements()], }, }) } return directives }, genContent (): VNode { const options = { attrs: { ...this.getScopeIdAttrs(), role: 'role' in this.$attrs ? this.$attrs.role : 'menu', }, staticClass: 'v-menu__content', class: { ...this.rootThemeClasses, ...this.roundedClasses, 'v-menu__content--auto': this.auto, 'v-menu__content--fixed': this.activatorFixed, menuable__content__active: this.isActive, [this.contentClass.trim()]: true, }, style: this.styles, directives: this.genDirectives(), ref: 'content', on: { click: (e: Event) => { const target = e.target as HTMLElement if (target.getAttribute('disabled')) return if (this.closeOnContentClick) this.isActive = false }, keydown: this.onKeyDown, }, } as VNodeData if (this.$listeners.scroll) { options.on = options.on || {} options.on.scroll = this.$listeners.scroll } if (!this.disabled && this.openOnHover) { options.on = options.on || {} options.on.mouseenter = this.mouseEnterHandler } if (this.openOnHover) { options.on = options.on || {} options.on.mouseleave = this.mouseLeaveHandler } return this.$createElement('div', options, this.getContentSlot()) }, getTiles () { if (!this.$refs.content) return this.tiles = Array.from(this.$refs.content.querySelectorAll('.v-list-item')) }, mouseEnterHandler () { this.runDelay('open', () => { if (this.hasJustFocused) return this.hasJustFocused = true this.isActive = true }) }, mouseLeaveHandler (e: MouseEvent) { // Prevent accidental re-activation this.runDelay('close', () => { if (this.$refs.content.contains(e.relatedTarget as HTMLElement)) return requestAnimationFrame(() => { this.isActive = false this.callDeactivate() }) }) }, nextTile () { const tile = this.tiles[this.listIndex + 1] if (!tile) { if (!this.tiles.length) return this.listIndex = -1 this.nextTile() return } this.listIndex++ if (tile.tabIndex === -1) this.nextTile() }, prevTile () { const tile = this.tiles[this.listIndex - 1] if (!tile) { if (!this.tiles.length) return this.listIndex = this.tiles.length this.prevTile() return } this.listIndex-- if (tile.tabIndex === -1) this.prevTile() }, onKeyDown (e: KeyboardEvent) { if (e.keyCode === keyCodes.esc) { // Wait for dependent elements to close first setTimeout(() => { this.isActive = false }) const activator = this.getActivator() this.$nextTick(() => activator && activator.focus()) } else if ( !this.isActive && [keyCodes.up, keyCodes.down].includes(e.keyCode) ) { this.isActive = true } // Allow for isActive watcher to generate tile list this.$nextTick(() => this.changeListIndex(e)) }, onResize () { if (!this.isActive) return // Account for screen resize // and orientation change // eslint-disable-next-line no-unused-expressions this.$refs.content.offsetWidth this.updateDimensions() // When resizing to a smaller width // content width is evaluated before // the new activator width has been // set, causing it to not size properly // hacky but will revisit in the future clearTimeout(this.resizeTimeout) this.resizeTimeout = window.setTimeout(this.updateDimensions, 100) }, }, render (h): VNode { const data = { staticClass: 'v-menu', class: { 'v-menu--attached': this.attach === '' || this.attach === true || this.attach === 'attach', }, directives: [{ arg: '500', name: 'resize', value: this.onResize, }], } return h('div', data, [ !this.activator && this.genActivator(), this.showLazyContent(() => [ this.$createElement(VThemeProvider, { props: { root: true, light: this.light, dark: this.dark, }, }, [this.genTransition()]), ]), ]) }, })