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'