diff --git a/src/components/table/helpers/mixin-thead.js b/src/components/table/helpers/mixin-thead.js
index f5126f8ea61..2418b38a10d 100644
--- a/src/components/table/helpers/mixin-thead.js
+++ b/src/components/table/helpers/mixin-thead.js
@@ -1,5 +1,5 @@
import { Vue } from '../../../vue'
-import { EVENT_NAME_HEAD_CLICKED } from '../../../constants/events'
+import { EVENT_NAME_HEAD_CLICKED, EVENT_NAME_HEAD_CONTEXTMENU } from '../../../constants/events'
import { CODE_ENTER, CODE_SPACE } from '../../../constants/key-codes'
import { PROP_TYPE_ARRAY_OBJECT_STRING, PROP_TYPE_STRING } from '../../../constants/props'
import { SLOT_NAME_THEAD_TOP } from '../../../constants/slots'
@@ -59,6 +59,21 @@ export const theadMixin = Vue.extend({
stopEvent(event)
this.$emit(EVENT_NAME_HEAD_CLICKED, field.key, field, event, isFoot)
},
+ headContextMenu(event, field, isFoot) {
+ if (this.stopIfBusy && this.stopIfBusy(event)) {
+ // If table is busy (via provider) then don't propagate
+ return
+ } else if (filterEvent(event)) {
+ // Clicked on a non-disabled control so ignore
+ return
+ } else if (textSelectionActive(this.$el)) {
+ // User is selecting text, so ignore
+ /* istanbul ignore next: JSDOM doesn't support getSelection() */
+ return
+ }
+ stopEvent(event)
+ this.$emit(EVENT_NAME_HEAD_CONTEXTMENU, field.key, field, event, isFoot)
+ },
renderThead(isFoot = false) {
const {
computedFields: fields,
@@ -77,7 +92,12 @@ export const theadMixin = Vue.extend({
return h()
}
- const hasHeadClickListener = isSortable || this.hasListener(EVENT_NAME_HEAD_CLICKED)
+ const hasHeadClickListener =
+ isSortable ||
+ this.hasListener(EVENT_NAME_HEAD_CLICKED) ||
+ this.hasListener(EVENT_NAME_HEAD_CONTEXTMENU)
+
+ const hasHeadContextMenuListener = this.hasListener(EVENT_NAME_HEAD_CONTEXTMENU)
// Reference to `selectAllRows` and `clearSelected()`, if table is selectable
const selectAllRows = isSelectable ? this.selectAllRows : noop
@@ -108,6 +128,12 @@ export const theadMixin = Vue.extend({
}
}
+ if (hasHeadContextMenuListener) {
+ on.contextmenu = event => {
+ this.headContextMenu(event, field, isFoot)
+ }
+ }
+
const sortAttrs = isSortable ? this.sortTheadThAttrs(key, field, isFoot) : {}
const sortClass = isSortable ? this.sortTheadThClasses(key, field, isFoot) : null
const sortLabel = isSortable ? this.sortTheadThLabel(key, field, isFoot) : null
@@ -159,19 +185,13 @@ export const theadMixin = Vue.extend({
]
}
- const scope = {
- label,
- column: key,
- field,
- isFoot,
- // Add in row select methods
- selectAllRows,
- clearSelected
- }
+ const scope = { label, column: key, field, isFoot, selectAllRows, clearSelected } // Add in row select methods
const $content =
this.normalizeSlot(slotNames, scope) ||
- h('div', { domProps: htmlOrText(labelHtml, label) })
+ h('div', {
+ domProps: htmlOrText(labelHtml, label)
+ })
const $srLabel = sortLabel ? h('span', { staticClass: 'sr-only' }, ` (${sortLabel})`) : null
@@ -200,25 +220,10 @@ export const theadMixin = Vue.extend({
)
)
} else {
- const scope = {
- columns: fields.length,
- fields,
- // Add in row select methods
- selectAllRows,
- clearSelected
- }
+ const scope = { columns: fields.length, fields, selectAllRows, clearSelected } // Add in row select methods
$trs.push(this.normalizeSlot(SLOT_NAME_THEAD_TOP, scope) || h())
- $trs.push(
- h(
- BTr,
- {
- class: this.theadTrClass,
- props: { variant: headRowVariant }
- },
- $cells
- )
- )
+ $trs.push(h(BTr, { class: this.theadTrClass, props: { variant: headRowVariant } }, $cells))
}
return h(
diff --git a/src/components/table/package.json b/src/components/table/package.json
index 51914e54c4e..65a4d8e321c 100644
--- a/src/components/table/package.json
+++ b/src/components/table/package.json
@@ -359,6 +359,35 @@
}
]
},
+ {
+ "event": "head-contextmenu",
+ "description": "Emitted when a header or footer cell is context/right clicked. Not applicable for 'custom-foot' slot",
+ "args": [
+ {
+ "arg": "key",
+ "type": "String",
+ "description": "Column key clicked (field name)"
+ },
+ {
+ "arg": "field",
+ "type": "Object",
+ "description": "Field definition object"
+ },
+ {
+ "arg": "event",
+ "type": [
+ "MouseEvent",
+ "KeyboardEvent"
+ ],
+ "description": "Native event object"
+ },
+ {
+ "arg": "isFooter",
+ "type": "Boolean",
+ "description": "'True' if this event originated from clicking on the footer cell"
+ }
+ ]
+ },
{
"event": "refreshed",
"description": "Emitted when the items provider function has returned data"
@@ -1183,6 +1212,35 @@
}
]
},
+ {
+ "event": "head-contextmenu",
+ "description": "Emitted when a header or footer cell is context/right clicked. Not applicable for 'custom-foot' slot",
+ "args": [
+ {
+ "arg": "key",
+ "type": "String",
+ "description": "Column key clicked (field name)"
+ },
+ {
+ "arg": "field",
+ "type": "Object",
+ "description": "Field definition object"
+ },
+ {
+ "arg": "event",
+ "type": [
+ "MouseEvent",
+ "KeyboardEvent"
+ ],
+ "description": "Native event object"
+ },
+ {
+ "arg": "isFooter",
+ "type": "Boolean",
+ "description": "'True' if this event originated from clicking on the footer cell"
+ }
+ ]
+ },
{
"event": "row-clicked",
"description": "Emitted when a row is clicked",
diff --git a/src/components/table/table-thead-events.spec.js b/src/components/table/table-thead-events.spec.js
index 1e4d96be889..b130e3e54f3 100644
--- a/src/components/table/table-thead-events.spec.js
+++ b/src/components/table/table-thead-events.spec.js
@@ -27,6 +27,28 @@ describe('table > thead events', () => {
expect(wrapper.emitted('head-clicked')).toBeUndefined()
})
+ it('should not emit head-contextmenu event when a head cell is clicked and no head-contextmenu listener', async () => {
+ const wrapper = mount(BTable, {
+ propsData: {
+ fields: testFields,
+ items: testItems
+ },
+ listeners: {}
+ })
+ expect(wrapper).toBeDefined()
+ const $rows = wrapper.findAll('thead > tr')
+ expect($rows.length).toBe(1)
+ const $ths = wrapper.findAll('thead > tr > th')
+ expect($ths.length).toBe(testFields.length)
+ expect(wrapper.emitted('head-contextmenu')).toBeUndefined()
+ await $ths.at(0).trigger('contextmenu')
+ expect(wrapper.emitted('head-contextmenu')).toBeUndefined()
+ await $ths.at(1).trigger('contextmenu')
+ expect(wrapper.emitted('head-contextmenu')).toBeUndefined()
+ await $ths.at(2).trigger('contextmenu')
+ expect(wrapper.emitted('head-contextmenu')).toBeUndefined()
+ })
+
it('should emit head-clicked event when a head cell is clicked', async () => {
const wrapper = mount(BTable, {
propsData: {
@@ -62,6 +84,41 @@ describe('table > thead events', () => {
wrapper.destroy()
})
+ it('should emit head-contextmenu event when a head cell is context clicked', async () => {
+ const wrapper = mount(BTable, {
+ propsData: {
+ fields: testFields,
+ items: testItems
+ },
+ listeners: {
+ // Head-contextmenu will only be emitted if there is a registered listener
+ 'head-contextmenu': () => {}
+ }
+ })
+ expect(wrapper).toBeDefined()
+ const $rows = wrapper.findAll('thead > tr')
+ expect($rows.length).toBe(1)
+ const $ths = wrapper.findAll('thead > tr > th')
+ expect($ths.length).toBe(testFields.length)
+ expect(wrapper.emitted('head-contextmenu')).toBeUndefined()
+ await $ths.at(0).trigger('contextmenu')
+ expect(wrapper.emitted('head-contextmenu')).toBeDefined()
+ expect(wrapper.emitted('head-contextmenu').length).toBe(1)
+ expect(wrapper.emitted('head-contextmenu')[0][0]).toEqual(testFields[0].key) // Field key
+ expect(wrapper.emitted('head-contextmenu')[0][1]).toEqual(testFields[0]) // Field definition
+ expect(wrapper.emitted('head-contextmenu')[0][2]).toBeInstanceOf(MouseEvent) // Event
+ expect(wrapper.emitted('head-contextmenu')[0][3]).toBe(false) // Is footer
+
+ await $ths.at(2).trigger('contextmenu')
+ expect(wrapper.emitted('head-contextmenu').length).toBe(2)
+ expect(wrapper.emitted('head-contextmenu')[1][0]).toEqual(testFields[2].key) // Field key
+ expect(wrapper.emitted('head-contextmenu')[1][1]).toEqual(testFields[2]) // Field definition
+ expect(wrapper.emitted('head-contextmenu')[1][2]).toBeInstanceOf(MouseEvent) // Event
+ expect(wrapper.emitted('head-contextmenu')[1][3]).toBe(false) // Is footer
+
+ wrapper.destroy()
+ })
+
it('should not emit head-clicked event when prop busy is set', async () => {
const wrapper = mount(BTable, {
propsData: {
@@ -84,6 +141,28 @@ describe('table > thead events', () => {
wrapper.destroy()
})
+ it('should not emit head-contextmenu event when prop busy is set', async () => {
+ const wrapper = mount(BTable, {
+ propsData: {
+ fields: testFields,
+ items: testItems,
+ busy: true
+ },
+ listeners: {
+ // Head-contextmenu will only be emitted if there is a registered listener
+ 'head-contextmenu': () => {}
+ }
+ })
+ expect(wrapper).toBeDefined()
+ const $ths = wrapper.findAll('thead > tr > th')
+ expect($ths.length).toBe(testFields.length)
+ expect(wrapper.emitted('head-contextmenu')).toBeUndefined()
+ await $ths.at(0).trigger('contextmenu')
+ expect(wrapper.emitted('head-contextmenu')).toBeUndefined()
+
+ wrapper.destroy()
+ })
+
it('should not emit head-clicked event when vm.localBusy is true', async () => {
const wrapper = mount(BTable, {
propsData: {
@@ -108,6 +187,28 @@ describe('table > thead events', () => {
wrapper.destroy()
})
+ it('should not emit head-contextmenu event when vm.localBusy is true', async () => {
+ const wrapper = mount(BTable, {
+ propsData: {
+ fields: testFields,
+ items: testItems
+ },
+ listeners: {
+ // Head-contextmenu will only be emitted if there is a registered listener
+ 'head-contextmenu': () => {}
+ }
+ })
+ await wrapper.setData({ localBusy: true })
+ expect(wrapper).toBeDefined()
+ const $ths = wrapper.findAll('thead > tr > th')
+ expect($ths.length).toBe(testFields.length)
+ expect(wrapper.emitted('head-contextmenu')).toBeUndefined()
+ await $ths.at(0).trigger('contextmenu')
+ expect(wrapper.emitted('head-contextmenu')).toBeUndefined()
+
+ wrapper.destroy()
+ })
+
it('should not emit head-clicked event when clicking on a button or other interactive element', async () => {
const wrapper = mount(BTable, {
propsData: {
@@ -147,4 +248,44 @@ describe('table > thead events', () => {
wrapper.destroy()
})
+
+ it('should not emit head-contextmenu event when clicking on a button or other interactive element', async () => {
+ const wrapper = mount(BTable, {
+ propsData: {
+ fields: testFields,
+ items: testItems
+ },
+ listeners: {
+ // Head-contextmenu will only be emitted if there is a registered listener
+ 'head-contextmenu': () => {}
+ },
+ slots: {
+ // In Vue 2.6x, slots get translated into scopedSlots
+ 'head(a)': '',
+ 'head(b)': '',
+ 'head(c)': 'link'
+ }
+ })
+ expect(wrapper).toBeDefined()
+ const $ths = wrapper.findAll('thead > tr > th')
+ expect($ths.length).toBe(testFields.length)
+ expect(wrapper.emitted('head-contextmenu')).toBeUndefined()
+
+ const $btn = wrapper.find('button[id="a"]')
+ expect($btn.exists()).toBe(true)
+ await $btn.trigger('contextmenu')
+ expect(wrapper.emitted('head-contextmenu')).toBeUndefined()
+
+ const $input = wrapper.find('input[id="b"]')
+ expect($input.exists()).toBe(true)
+ await $input.trigger('contextmenu')
+ expect(wrapper.emitted('head-contextmenu')).toBeUndefined()
+
+ const $link = wrapper.find('a[id="c"]')
+ expect($link.exists()).toBe(true)
+ await $link.trigger('contextmenu')
+ expect(wrapper.emitted('head-contextmenu')).toBeUndefined()
+
+ wrapper.destroy()
+ })
})
diff --git a/src/constants/events.js b/src/constants/events.js
index 7e4b1fc4210..556df13dfce 100644
--- a/src/constants/events.js
+++ b/src/constants/events.js
@@ -20,6 +20,7 @@ export const EVENT_NAME_FOCUS = 'focus'
export const EVENT_NAME_FOCUSIN = 'focusin'
export const EVENT_NAME_FOCUSOUT = 'focusout'
export const EVENT_NAME_HEAD_CLICKED = 'head-clicked'
+export const EVENT_NAME_HEAD_CONTEXTMENU = 'head-contextmenu'
export const EVENT_NAME_HIDDEN = 'hidden'
export const EVENT_NAME_HIDE = 'hide'
export const EVENT_NAME_IMG_ERROR = 'img-error'