Skip to content

Commit

Permalink
fix(activatable): add listeners for custom activators (#8121)
Browse files Browse the repository at this point in the history
fixes #7944
fixes #5890
fixes #5860

* fix(activatable): add listeners for custom activators

* chore(activatable): clean-up

* chore: update packages/vuetify/src/mixins/activatable/index.ts

Co-Authored-By: Dmitry Sharshakov <d3dx12.xx@gmail.com>

* refactor(activatable): switch to add/remove event listener binding
  • Loading branch information
johnleider committed Aug 12, 2019
1 parent c90fc9d commit 3beea7c
Show file tree
Hide file tree
Showing 4 changed files with 141 additions and 30 deletions.
2 changes: 1 addition & 1 deletion packages/vuetify/src/components/VMenu/VMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ export default baseMixins.extend({
}

return h('div', data, [
this.genActivator(),
!this.activator && this.genActivator(),
this.$createElement(ThemeProvider, {
props: {
root: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import toHaveBeenWarnedInit from '../../../../test/util/to-have-been-warned'

describe('activatable.ts', () => {
const Mock = Activatable.extend({
data: () => ({
isActive: false,
}),
render: h => h('div'),
})
type Instance = InstanceType<typeof Mock>
Expand Down Expand Up @@ -112,4 +115,67 @@ describe('activatable.ts', () => {

expect(`The activator slot must be bound, try '<template v-slot:activator="{ on }"><v-btn v-on="on">'`).toHaveBeenWarned()
})

it('should bind listeners to custom activator', async () => {
const el = document.createElement('button')
el.id = 'foobar'
document.body.appendChild(el)

const wrapper = mountFunction({
propsData: {
activator: '#foobar',
},
})

const activatorElement = wrapper.vm.activatorElement as any

await wrapper.vm.$nextTick()

expect(wrapper.vm.isActive).toBe(false)
expect(el).toEqual(activatorElement)
activatorElement.dispatchEvent(new Event('click'))
expect(wrapper.vm.isActive).toBe(true)

wrapper.setProps({ openOnHover: true, value: false })

await wrapper.vm.$nextTick()

expect(wrapper.vm.isActive).toBe(false)
activatorElement.dispatchEvent(new Event('mouseenter'))

await new Promise(resolve => setTimeout(resolve, wrapper.vm.openDelay))

expect(wrapper.vm.isActive).toBe(true)

activatorElement.dispatchEvent(new Event('mouseleave'))
await new Promise(resolve => setTimeout(resolve, wrapper.vm.leaveDelay))

expect(wrapper.vm.isActive).toBe(false)

document.body.removeChild(el)
})

it('should remove listeners on custom activator', async () => {
const el = document.createElement('button')
el.id = 'foobar'
document.body.appendChild(el)

const wrapper = mountFunction({
propsData: {
activator: '#foobar',
},
})

await wrapper.vm.$nextTick()

expect(wrapper.vm.listeners).not.toEqual({})

wrapper.destroy()

await wrapper.vm.$nextTick()

expect(wrapper.vm.listeners).toEqual({})

document.body.removeChild(el)
})
})
100 changes: 74 additions & 26 deletions packages/vuetify/src/mixins/activatable/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,27 @@ export default baseMixins.extend({
validator: (val: string | object) => {
return ['string', 'object'].includes(typeof val)
},
} as PropValidator<string | HTMLElement>,
} as PropValidator<string | HTMLElement | VNode | Element | null>,
disabled: Boolean,
internalActivator: Boolean,
openOnHover: Boolean,
},

data: () => ({
activatorElement: null as null | HTMLElement,
activatorElement: null as HTMLElement | null,
activatorNode: [] as VNode[],
events: ['click', 'mouseenter', 'mouseleave'],
listeners: {} as Record<string, (e: MouseEvent & KeyboardEvent) => void>,
}),

watch: {
activator () {
this.activatorElement = null
this.getActivator()
activator: 'resetActivator',
activatorElement (val) {
if (!val) return

this.addActivatorEvents()
},
openOnHover: 'resetActivator',
},

mounted () {
Expand All @@ -50,18 +55,27 @@ export default baseMixins.extend({
if (slotType && ['v-slot', 'normal'].includes(slotType)) {
consoleError(`The activator slot must be bound, try '<template v-slot:activator="{ on }"><v-btn v-on="on">'`, this)
}

this.getActivator()
},

beforeDestroy () {
this.removeActivatorEvents()
},

methods: {
getValueProxy (): object {
const self = this
return {
get value () {
return self.isActive
},
set value (isActive: boolean) {
self.isActive = isActive
},
addActivatorEvents () {
if (
!this.activator ||
this.disabled ||
!this.activatorElement
) return

this.listeners = this.genActivatorListeners()
const keys = Object.keys(this.listeners)

for (const key of keys) {
(this.activatorElement as any).addEventListener(key, this.listeners[key])
}
},
genActivator () {
Expand All @@ -74,9 +88,6 @@ export default baseMixins.extend({

return node
},
getContentSlot () {
return getSlot(this, 'default', this.getValueProxy(), true)
},
genActivatorAttributes () {
return {
role: 'button',
Expand All @@ -100,9 +111,7 @@ export default baseMixins.extend({
}
} else {
listeners.click = (e: MouseEvent) => {
const activator = this.getActivator(e)

if (activator) activator.focus()
if (this.activatorElement) this.activatorElement.focus()

this.isActive = !this.isActive
}
Expand All @@ -119,18 +128,57 @@ export default baseMixins.extend({
if (this.activator) {
const target = this.internalActivator ? this.$el : document

activator = typeof this.activator === 'string'
? target.querySelector(this.activator)
: this.activator
// Selector
if (typeof this.activator === 'string') {
activator = target.querySelector(this.activator)
// VNode
} else if ((this.activator as any).$el) {
activator = (this.activator as any).$el
// HTMLElement | Element
} else {
activator = this.activator
}
} else if (e) {
activator = e.currentTarget || e.target
activator = (e.currentTarget || e.target) as HTMLElement
} else if (this.activatorNode.length) {
activator = this.activatorNode[0].elm
activator = this.activatorNode[0].elm as HTMLElement
}

this.activatorElement = activator as HTMLElement
this.activatorElement = activator

return this.activatorElement
},
getContentSlot () {
return getSlot(this, 'default', this.getValueProxy(), true)
},
getValueProxy (): object {
const self = this
return {
get value () {
return self.isActive
},
set value (isActive: boolean) {
self.isActive = isActive
},
}
},
removeActivatorEvents () {
if (
!this.activator ||
!this.activatorElement
) return

const keys = Object.keys(this.listeners)

for (const key of keys) {
(this.activatorElement as any).removeEventListener(key, this.listeners[key])
}

this.listeners = {}
},
resetActivator () {
this.activatorElement = null
this.getActivator()
},
},
})
3 changes: 0 additions & 3 deletions packages/vuetify/src/mixins/menuable/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import mixins, { ExtractVue } from '../../util/mixins'
import { convertToUnit } from '../../util/helpers'

// Types
import { VNode } from 'vue'

const baseMixins = mixins(
Stackable,
Positionable,
Expand Down Expand Up @@ -80,7 +78,6 @@ export default baseMixins.extend<options>().extend({
absoluteY: 0,
activatedBy: null as EventTarget | null,
activatorFixed: false,
activatorNode: null as null | VNode | VNode[],
dimensions: {
activator: {
top: 0,
Expand Down

0 comments on commit 3beea7c

Please sign in to comment.