Skip to content

feat(b-table): add header-contextmenu event (closes #5841) #6488

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 34 additions & 29 deletions src/components/table/helpers/mixin-thead.js
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down
58 changes: 58 additions & 0 deletions src/components/table/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
141 changes: 141 additions & 0 deletions src/components/table/table-thead-events.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand All @@ -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: {
Expand All @@ -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: {
Expand Down Expand Up @@ -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)': '<button id="a">button</button>',
'head(b)': '<input id="b">',
'head(c)': '<a href="#" id="c">link</a>'
}
})
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()
})
})
1 change: 1 addition & 0 deletions src/constants/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down