Skip to content

Commit 3f0d46a

Browse files
authored
perf(b-table, b-table-lite): delegate row event handlers to the tbody element (#4192)
1 parent dfbc398 commit 3f0d46a

File tree

2 files changed

+167
-129
lines changed

2 files changed

+167
-129
lines changed

src/components/table/helpers/mixin-tbody-row.js

Lines changed: 16 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
1-
import KeyCodes from '../../../utils/key-codes'
21
import get from '../../../utils/get'
32
import toString from '../../../utils/to-string'
4-
import { arrayIncludes } from '../../../utils/array'
53
import { isFunction, isString, isUndefinedOrNull } from '../../../utils/inspect'
6-
import filterEvent from './filter-event'
7-
import textSelectionActive from './text-selection-active'
84
import { BTr } from '../tr'
95
import { BTd } from '../td'
106
import { BTh } from '../th'
@@ -65,99 +61,23 @@ export default {
6561
}
6662
}
6763
},
68-
rowEvtFactory(handler, item, rowIndex) {
69-
// Return a row event handler
70-
return evt => {
71-
// If table is busy (via provider) then don't propagate
72-
if (this.stopIfBusy && this.stopIfBusy(evt)) {
73-
return
74-
}
75-
// Otherwise call the handler
76-
handler(evt, item, rowIndex)
77-
}
78-
},
79-
// Row event handlers (will be wrapped by the above rowEvtFactory function)
80-
tbodyRowKeydown(evt, item, rowIndex) {
81-
// Keypress handler
82-
const keyCode = evt.keyCode
83-
const target = evt.target
84-
// `this.$refs.itemRow`s is most likely an array of `BTr` components, but it
85-
// could be regular `tr` elements, so we map to the `tr` elements just in case
86-
const trs = (this.$refs.itemRows || []).map(tr => tr.$el || tr)
87-
if (!(target && target.tagName === 'TR' && target === document.activeElement)) {
88-
// Ignore if not the active tr element
89-
return
90-
} else if (target.tabIndex !== 0) {
91-
// Ignore if not focusable
92-
/* istanbul ignore next */
93-
return
94-
} else if (trs.length === 0) {
95-
// No item rows
96-
/* istanbul ignore next */
97-
return
98-
}
99-
const index = trs.indexOf(target)
100-
if (keyCode === KeyCodes.ENTER || keyCode === KeyCodes.SPACE) {
101-
// We also allow enter/space to trigger a click (when row is focused)
102-
evt.stopPropagation()
103-
evt.preventDefault()
104-
// We translate to a row-clicked event
105-
this.rowClicked(evt, item, rowIndex)
106-
} else if (
107-
arrayIncludes([KeyCodes.UP, KeyCodes.DOWN, KeyCodes.HOME, KeyCodes.END], keyCode)
108-
) {
109-
// Keyboard navigation of rows
110-
evt.stopPropagation()
111-
evt.preventDefault()
112-
const shift = evt.shiftKey
113-
if (keyCode === KeyCodes.HOME || (shift && keyCode === KeyCodes.UP)) {
114-
// Focus first row
115-
trs[0].focus()
116-
} else if (keyCode === KeyCodes.END || (shift && keyCode === KeyCodes.DOWN)) {
117-
// Focus last row
118-
trs[trs.length - 1].focus()
119-
} else if (keyCode === KeyCodes.UP && index > 0) {
120-
// Focus previous row
121-
trs[index - 1].focus()
122-
} else if (keyCode === KeyCodes.DOWN && index < trs.length - 1) {
123-
// Focus next row
124-
trs[index + 1].focus()
125-
}
126-
}
127-
},
128-
rowClicked(evt, item, index) {
129-
if (filterEvent(evt)) {
130-
// clicked on a non-disabled control so ignore
131-
return
132-
} else if (textSelectionActive(this.$el)) {
133-
// User is selecting text, so ignore
134-
/* istanbul ignore next: JSDOM doesn't support getSelection() */
135-
return
64+
// Row event handlers
65+
rowHovered(evt) {
66+
// `mouseenter` handler (non-bubbling)
67+
// `this.tbodyRowEvtStopped` from tbody mixin
68+
if (!this.tbodyRowEvtStopped(evt)) {
69+
// `this.emitTbodyRowEvent` from tbody mixin
70+
this.emitTbodyRowEvent('row-hovered', evt)
13671
}
137-
this.$emit('row-clicked', item, index, evt)
13872
},
139-
middleMouseRowClicked(evt, item, index) {
140-
if (evt.which === 2) {
141-
this.$emit('row-middle-clicked', item, index, evt)
73+
rowUnhovered(evt) {
74+
// `mouseleave` handler (non-bubbling)
75+
// `this.tbodyRowEvtStopped` from tbody mixin
76+
if (!this.tbodyRowEvtStopped(evt)) {
77+
// `this.emitTbodyRowEvent` from tbody mixin
78+
this.emitTbodyRowEvent('row-unhovered', evt)
14279
}
14380
},
144-
rowDblClicked(evt, item, index) {
145-
if (filterEvent(evt)) {
146-
// clicked on a non-disabled control so ignore
147-
/* istanbul ignore next: event filtering already tested via click handler */
148-
return
149-
}
150-
this.$emit('row-dblclicked', item, index, evt)
151-
},
152-
rowHovered(evt, item, index) {
153-
this.$emit('row-hovered', item, index, evt)
154-
},
155-
rowUnhovered(evt, item, index) {
156-
this.$emit('row-unhovered', item, index, evt)
157-
},
158-
rowContextmenu(evt, item, index) {
159-
this.$emit('row-contextmenu', item, index, evt)
160-
},
16181
// Render helpers
16282
renderTbodyRowCell(field, colIndex, item, rowIndex) {
16383
// Renders a TD or TH for a row's field
@@ -257,13 +177,6 @@ export default {
257177
// In the format of '{tableId}__row_{primaryKeyValue}'
258178
const rowId = hasPkValue ? this.safeId(`_row_${item[primaryKey]}`) : null
259179

260-
const evtFactory = this.rowEvtFactory
261-
const handlers = {}
262-
if (hasRowClickHandler) {
263-
handlers.click = evtFactory(this.rowClicked, item, rowIndex)
264-
handlers.keydown = evtFactory(this.tbodyRowKeydown, item, rowIndex)
265-
}
266-
267180
// Selectable classes and attributes
268181
const selectableClasses = this.selectableRowClasses ? this.selectableRowClasses(rowIndex) : {}
269182
const selectableAttrs = this.selectableRowAttrs ? this.selectableRowAttrs(rowIndex) : {}
@@ -293,22 +206,9 @@ export default {
293206
...selectableAttrs
294207
},
295208
on: {
296-
...handlers,
297-
// TODO:
298-
// Instantiate the following handlers only if we have registered
299-
// listeners i.e. `this.$listeners['row-middle-clicked']`, etc.
300-
//
301-
// Could make all of this (including the above click/key handlers)
302-
// the result of a factory function and/or make it a delegated event
303-
// handler on the tbody (if we store the row index as a data-attribute
304-
// on the TR as we can lookup the item data from the computedItems array
305-
// or it could be a hidden prop (via attrs) on BTr instance)
306-
auxclick: evtFactory(this.middleMouseRowClicked, item, rowIndex),
307-
contextmenu: evtFactory(this.rowContextmenu, item, rowIndex),
308-
// Note: These events are not accessibility friendly!
309-
dblclick: evtFactory(this.rowDblClicked, item, rowIndex),
310-
mouseenter: evtFactory(this.rowHovered, item, rowIndex),
311-
mouseleave: evtFactory(this.rowUnhovered, item, rowIndex)
209+
// Note: These events are not A11Y friendly!
210+
mouseenter: this.rowHovered,
211+
mouseleave: this.rowUnhovered
312212
}
313213
},
314214
$tds

src/components/table/helpers/mixin-tbody.js

Lines changed: 151 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,139 @@
1+
import KeyCodes from '../../../utils/key-codes'
2+
import { arrayIncludes } from '../../../utils/array'
3+
import { closest, isElement } from '../../../utils/dom'
14
import { props as tbodyProps, BTbody } from '../tbody'
5+
import filterEvent from './filter-event'
6+
import textSelectionActive from './text-selection-active'
27
import tbodyRowMixin from './mixin-tbody-row'
38

49
const props = {
10+
...tbodyProps,
511
tbodyClass: {
612
type: [String, Array, Object]
713
// default: undefined
8-
},
9-
...tbodyProps
14+
}
1015
}
1116

1217
export default {
1318
mixins: [tbodyRowMixin],
1419
props,
1520
methods: {
21+
// Helper methods
22+
getTbodyTrs() {
23+
// Returns all the item TR elements (excludes detail and spacer rows)
24+
// `this.$refs.itemRows` is an array of item TR components/elements
25+
// Rows should all be B-TR components, but we map to TR elements
26+
// TODO: This may take time for tables many rows, so we may want to cache
27+
// the result of this during each render cycle on a non-reactive
28+
// property. We clear out the cache as each render starts, and
29+
// populate it on first access of this method if null
30+
return (this.$refs.itemRows || []).map(tr => tr.$el || tr)
31+
},
32+
getTbodyTrIndex(el) {
33+
// Returns index of a particular TBODY item TR
34+
// We set `true` on closest to include self in result
35+
/* istanbul ignore next: should not normally happen */
36+
if (!isElement(el)) {
37+
return -1
38+
}
39+
const tr = el.tagName === 'TR' ? el : closest('tr', el, true)
40+
return tr ? this.getTbodyTrs().indexOf(tr) : -1
41+
},
42+
emitTbodyRowEvent(type, evt) {
43+
// Emits a row event, with the item object, row index and original event
44+
if (type && evt && evt.target) {
45+
const rowIndex = this.getTbodyTrIndex(evt.target)
46+
if (rowIndex > -1) {
47+
// The array of TRs correlate to the `computedItems` array
48+
const item = this.computedItems[rowIndex]
49+
this.$emit(type, item, rowIndex, evt)
50+
}
51+
}
52+
},
53+
tbodyRowEvtStopped(evt) {
54+
return this.stopIfBusy && this.stopIfBusy(evt)
55+
},
56+
// Delegated row event handlers
57+
onTbodyRowKeydown(evt) {
58+
// Keyboard navigation and row click emulation
59+
const target = evt.target
60+
if (
61+
this.tbodyRowEvtStopped(evt) ||
62+
target.tagName !== 'TR' ||
63+
target !== document.activeElement ||
64+
target.tabIndex !== 0
65+
) {
66+
// Early exit if not an item row TR
67+
return
68+
}
69+
const keyCode = evt.keyCode
70+
if (arrayIncludes([KeyCodes.ENTER, KeyCodes.SPACE], keyCode)) {
71+
// Emulated click for keyboard users, transfer to click handler
72+
evt.stopPropagation()
73+
evt.preventDefault()
74+
this.onTBodyRowClicked(evt)
75+
} else if (
76+
arrayIncludes([KeyCodes.UP, KeyCodes.DOWN, KeyCodes.HOME, KeyCodes.END], keyCode)
77+
) {
78+
// Keyboard navigation
79+
const rowIndex = this.getTbodyTrIndex(target)
80+
if (rowIndex > -1) {
81+
evt.stopPropagation()
82+
evt.preventDefault()
83+
const trs = this.getTbodyTrs()
84+
const shift = evt.shiftKey
85+
if (keyCode === KeyCodes.HOME || (shift && keyCode === KeyCodes.UP)) {
86+
// Focus first row
87+
trs[0].focus()
88+
} else if (keyCode === KeyCodes.END || (shift && keyCode === KeyCodes.DOWN)) {
89+
// Focus last row
90+
trs[trs.length - 1].focus()
91+
} else if (keyCode === KeyCodes.UP && rowIndex > 0) {
92+
// Focus previous row
93+
trs[rowIndex - 1].focus()
94+
} else if (keyCode === KeyCodes.DOWN && rowIndex < trs.length - 1) {
95+
// Focus next row
96+
trs[rowIndex + 1].focus()
97+
}
98+
}
99+
}
100+
},
101+
onTBodyRowClicked(evt) {
102+
if (this.tbodyRowEvtStopped(evt)) {
103+
// If table is busy, then don't propagate
104+
return
105+
} else if (filterEvent(evt) || textSelectionActive(this.$el)) {
106+
// Clicked on a non-disabled control so ignore
107+
// Or user is selecting text, so ignore
108+
return
109+
}
110+
this.emitTbodyRowEvent('row-clicked', evt)
111+
},
112+
onTbodyRowMiddleMouseRowClicked(evt) {
113+
if (!this.tbodyRowEvtStopped(evt) && evt.which === 2) {
114+
this.emitTbodyRowEvent('row-middle-clicked', evt)
115+
}
116+
},
117+
onTbodyRowContextmenu(evt) {
118+
if (!this.tbodyRowEvtStopped(evt)) {
119+
this.emitTbodyRowEvent('row-contextmenu', evt)
120+
}
121+
},
122+
onTbodyRowDblClicked(evt) {
123+
if (!this.tbodyRowEvtStopped(evt) && !filterEvent(evt)) {
124+
this.emitTbodyRowEvent('row-dblclicked', evt)
125+
}
126+
},
127+
// Note: Row hover handlers are handled by the tbody-row mixin
128+
// As mouseenter/mouseleave events do not bubble
129+
//
130+
// Render Helper
16131
renderTbody() {
17132
// Render the tbody element and children
18133
const items = this.computedItems
19134
// Shortcut to `createElement` (could use `this._c()` instead)
20135
const h = this.$createElement
136+
const hasRowClickHandler = this.$listeners['row-clicked'] || this.isSelectable
21137

22138
// Prepare the tbody rows
23139
const $rows = []
@@ -30,10 +146,10 @@ export default {
30146
} else {
31147
// Table isn't busy, or we don't have a busy slot
32148

33-
// Create a slot cache for improved performace when looking up cell slot names.
34-
// Values will be keyed by the field's `key` and will store the slot's name.
35-
// Slots could be dynamic (i.e. `v-if`), so we must compute on each render.
36-
// Used by tbodyRow mixin render helper.
149+
// Create a slot cache for improved performance when looking up cell slot names
150+
// Values will be keyed by the field's `key` and will store the slot's name
151+
// Slots could be dynamic (i.e. `v-if`), so we must compute on each render
152+
// Used by tbody-row mixin render helper
37153
const cache = {}
38154
const defaultSlotName = this.hasNormalizedSlot('cell()') ? 'cell()' : null
39155
this.computedFields.forEach(field => {
@@ -46,35 +162,57 @@ export default {
46162
? lowerName
47163
: defaultSlotName
48164
})
49-
// Created as a non-reactive property so to not trigger component updates.
50-
// Must be a fresh object each render.
165+
// Created as a non-reactive property so to not trigger component updates
166+
// Must be a fresh object each render
51167
this.$_bodyFieldSlotNameCache = cache
52168

53-
// Add static Top Row slot (hidden in visibly stacked mode as we can't control data-label attr)
169+
// Add static top row slot (hidden in visibly stacked mode
170+
// as we can't control `data-label` attr)
54171
$rows.push(this.renderTopRow ? this.renderTopRow() : h())
55172

56-
// render the rows
173+
// Render the rows
57174
items.forEach((item, rowIndex) => {
58175
// Render the individual item row (rows if details slot)
59176
$rows.push(this.renderTbodyRow(item, rowIndex))
60177
})
61178

62-
// Empty Items / Empty Filtered Row slot (only shows if items.length < 1)
179+
// Empty items / empty filtered row slot (only shows if `items.length < 1`)
63180
$rows.push(this.renderEmpty ? this.renderEmpty() : h())
64181

65-
// Static bottom row slot (hidden in visibly stacked mode as we can't control data-label attr)
182+
// Static bottom row slot (hidden in visibly stacked mode
183+
// as we can't control `data-label` attr)
66184
$rows.push(this.renderBottomRow ? this.renderBottomRow() : h())
67185
}
68186

187+
const handlers = {
188+
// TODO: We may want to to only instantiate these handlers
189+
// if there is an event listener registered
190+
auxclick: this.onTbodyRowMiddleMouseRowClicked,
191+
// TODO: Perhaps we do want to automatically prevent the
192+
// default context menu from showing if there is
193+
// a `row-contextmenu` listener registered.
194+
contextmenu: this.onTbodyRowContextmenu,
195+
// The following event(s) is not considered A11Y friendly
196+
dblclick: this.onTbodyRowDblClicked
197+
// hover events (mouseenter/mouseleave) ad handled by tbody-row mixin
198+
}
199+
if (hasRowClickHandler) {
200+
handlers.click = this.onTBodyRowClicked
201+
handlers.keydown = this.onTbodyRowKeydown
202+
}
69203
// Assemble rows into the tbody
70204
const $tbody = h(
71205
BTbody,
72206
{
207+
ref: 'tbody',
73208
class: this.tbodyClass || null,
74209
props: {
75210
tbodyTransitionProps: this.tbodyTransitionProps,
76211
tbodyTransitionHandlers: this.tbodyTransitionHandlers
77-
}
212+
},
213+
// BTbody transfers all native event listeners to the root element
214+
// TODO: Only set the handlers if the table is not busy
215+
on: handlers
78216
},
79217
$rows
80218
)

0 commit comments

Comments
 (0)