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'
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: