Skip to content

Commit

Permalink
perf(b-table, b-table-lite): delegate row event handlers to the tbody…
Browse files Browse the repository at this point in the history
… element (#4192)
  • Loading branch information
tmorehouse authored Oct 4, 2019
1 parent dfbc398 commit 3f0d46a
Show file tree
Hide file tree
Showing 2 changed files with 167 additions and 129 deletions.
132 changes: 16 additions & 116 deletions src/components/table/helpers/mixin-tbody-row.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import KeyCodes from '../../../utils/key-codes'
import get from '../../../utils/get'
import toString from '../../../utils/to-string'
import { arrayIncludes } from '../../../utils/array'
import { isFunction, isString, isUndefinedOrNull } from '../../../utils/inspect'
import filterEvent from './filter-event'
import textSelectionActive from './text-selection-active'
import { BTr } from '../tr'
import { BTd } from '../td'
import { BTh } from '../th'
Expand Down Expand Up @@ -65,99 +61,23 @@ export default {
}
}
},
rowEvtFactory(handler, item, rowIndex) {
// Return a row event handler
return evt => {
// If table is busy (via provider) then don't propagate
if (this.stopIfBusy && this.stopIfBusy(evt)) {
return
}
// Otherwise call the handler
handler(evt, item, rowIndex)
}
},
// Row event handlers (will be wrapped by the above rowEvtFactory function)
tbodyRowKeydown(evt, item, rowIndex) {
// Keypress handler
const keyCode = evt.keyCode
const target = evt.target
// `this.$refs.itemRow`s is most likely an array of `BTr` components, but it
// could be regular `tr` elements, so we map to the `tr` elements just in case
const trs = (this.$refs.itemRows || []).map(tr => tr.$el || tr)
if (!(target && target.tagName === 'TR' && target === document.activeElement)) {
// Ignore if not the active tr element
return
} else if (target.tabIndex !== 0) {
// Ignore if not focusable
/* istanbul ignore next */
return
} else if (trs.length === 0) {
// No item rows
/* istanbul ignore next */
return
}
const index = trs.indexOf(target)
if (keyCode === KeyCodes.ENTER || keyCode === KeyCodes.SPACE) {
// We also allow enter/space to trigger a click (when row is focused)
evt.stopPropagation()
evt.preventDefault()
// We translate to a row-clicked event
this.rowClicked(evt, item, rowIndex)
} else if (
arrayIncludes([KeyCodes.UP, KeyCodes.DOWN, KeyCodes.HOME, KeyCodes.END], keyCode)
) {
// Keyboard navigation of rows
evt.stopPropagation()
evt.preventDefault()
const shift = evt.shiftKey
if (keyCode === KeyCodes.HOME || (shift && keyCode === KeyCodes.UP)) {
// Focus first row
trs[0].focus()
} else if (keyCode === KeyCodes.END || (shift && keyCode === KeyCodes.DOWN)) {
// Focus last row
trs[trs.length - 1].focus()
} else if (keyCode === KeyCodes.UP && index > 0) {
// Focus previous row
trs[index - 1].focus()
} else if (keyCode === KeyCodes.DOWN && index < trs.length - 1) {
// Focus next row
trs[index + 1].focus()
}
}
},
rowClicked(evt, item, index) {
if (filterEvent(evt)) {
// 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
// Row event handlers
rowHovered(evt) {
// `mouseenter` handler (non-bubbling)
// `this.tbodyRowEvtStopped` from tbody mixin
if (!this.tbodyRowEvtStopped(evt)) {
// `this.emitTbodyRowEvent` from tbody mixin
this.emitTbodyRowEvent('row-hovered', evt)
}
this.$emit('row-clicked', item, index, evt)
},
middleMouseRowClicked(evt, item, index) {
if (evt.which === 2) {
this.$emit('row-middle-clicked', item, index, evt)
rowUnhovered(evt) {
// `mouseleave` handler (non-bubbling)
// `this.tbodyRowEvtStopped` from tbody mixin
if (!this.tbodyRowEvtStopped(evt)) {
// `this.emitTbodyRowEvent` from tbody mixin
this.emitTbodyRowEvent('row-unhovered', evt)
}
},
rowDblClicked(evt, item, index) {
if (filterEvent(evt)) {
// clicked on a non-disabled control so ignore
/* istanbul ignore next: event filtering already tested via click handler */
return
}
this.$emit('row-dblclicked', item, index, evt)
},
rowHovered(evt, item, index) {
this.$emit('row-hovered', item, index, evt)
},
rowUnhovered(evt, item, index) {
this.$emit('row-unhovered', item, index, evt)
},
rowContextmenu(evt, item, index) {
this.$emit('row-contextmenu', item, index, evt)
},
// Render helpers
renderTbodyRowCell(field, colIndex, item, rowIndex) {
// Renders a TD or TH for a row's field
Expand Down Expand Up @@ -257,13 +177,6 @@ export default {
// In the format of '{tableId}__row_{primaryKeyValue}'
const rowId = hasPkValue ? this.safeId(`_row_${item[primaryKey]}`) : null

const evtFactory = this.rowEvtFactory
const handlers = {}
if (hasRowClickHandler) {
handlers.click = evtFactory(this.rowClicked, item, rowIndex)
handlers.keydown = evtFactory(this.tbodyRowKeydown, item, rowIndex)
}

// Selectable classes and attributes
const selectableClasses = this.selectableRowClasses ? this.selectableRowClasses(rowIndex) : {}
const selectableAttrs = this.selectableRowAttrs ? this.selectableRowAttrs(rowIndex) : {}
Expand Down Expand Up @@ -293,22 +206,9 @@ export default {
...selectableAttrs
},
on: {
...handlers,
// TODO:
// Instantiate the following handlers only if we have registered
// listeners i.e. `this.$listeners['row-middle-clicked']`, etc.
//
// Could make all of this (including the above click/key handlers)
// the result of a factory function and/or make it a delegated event
// handler on the tbody (if we store the row index as a data-attribute
// on the TR as we can lookup the item data from the computedItems array
// or it could be a hidden prop (via attrs) on BTr instance)
auxclick: evtFactory(this.middleMouseRowClicked, item, rowIndex),
contextmenu: evtFactory(this.rowContextmenu, item, rowIndex),
// Note: These events are not accessibility friendly!
dblclick: evtFactory(this.rowDblClicked, item, rowIndex),
mouseenter: evtFactory(this.rowHovered, item, rowIndex),
mouseleave: evtFactory(this.rowUnhovered, item, rowIndex)
// Note: These events are not A11Y friendly!
mouseenter: this.rowHovered,
mouseleave: this.rowUnhovered
}
},
$tds
Expand Down
164 changes: 151 additions & 13 deletions src/components/table/helpers/mixin-tbody.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,139 @@
import KeyCodes from '../../../utils/key-codes'
import { arrayIncludes } from '../../../utils/array'
import { closest, isElement } from '../../../utils/dom'
import { props as tbodyProps, BTbody } from '../tbody'
import filterEvent from './filter-event'
import textSelectionActive from './text-selection-active'
import tbodyRowMixin from './mixin-tbody-row'

const props = {
...tbodyProps,
tbodyClass: {
type: [String, Array, Object]
// default: undefined
},
...tbodyProps
}
}

export default {
mixins: [tbodyRowMixin],
props,
methods: {
// Helper methods
getTbodyTrs() {
// Returns all the item TR elements (excludes detail and spacer rows)
// `this.$refs.itemRows` is an array of item TR components/elements
// Rows should all be B-TR components, but we map to TR elements
// TODO: This may take time for tables many rows, so we may want to cache
// the result of this during each render cycle on a non-reactive
// property. We clear out the cache as each render starts, and
// populate it on first access of this method if null
return (this.$refs.itemRows || []).map(tr => tr.$el || tr)
},
getTbodyTrIndex(el) {
// Returns index of a particular TBODY item TR
// We set `true` on closest to include self in result
/* istanbul ignore next: should not normally happen */
if (!isElement(el)) {
return -1
}
const tr = el.tagName === 'TR' ? el : closest('tr', el, true)
return tr ? this.getTbodyTrs().indexOf(tr) : -1
},
emitTbodyRowEvent(type, evt) {
// Emits a row event, with the item object, row index and original event
if (type && evt && evt.target) {
const rowIndex = this.getTbodyTrIndex(evt.target)
if (rowIndex > -1) {
// The array of TRs correlate to the `computedItems` array
const item = this.computedItems[rowIndex]
this.$emit(type, item, rowIndex, evt)
}
}
},
tbodyRowEvtStopped(evt) {
return this.stopIfBusy && this.stopIfBusy(evt)
},
// Delegated row event handlers
onTbodyRowKeydown(evt) {
// Keyboard navigation and row click emulation
const target = evt.target
if (
this.tbodyRowEvtStopped(evt) ||
target.tagName !== 'TR' ||
target !== document.activeElement ||
target.tabIndex !== 0
) {
// Early exit if not an item row TR
return
}
const keyCode = evt.keyCode
if (arrayIncludes([KeyCodes.ENTER, KeyCodes.SPACE], keyCode)) {
// Emulated click for keyboard users, transfer to click handler
evt.stopPropagation()
evt.preventDefault()
this.onTBodyRowClicked(evt)
} else if (
arrayIncludes([KeyCodes.UP, KeyCodes.DOWN, KeyCodes.HOME, KeyCodes.END], keyCode)
) {
// Keyboard navigation
const rowIndex = this.getTbodyTrIndex(target)
if (rowIndex > -1) {
evt.stopPropagation()
evt.preventDefault()
const trs = this.getTbodyTrs()
const shift = evt.shiftKey
if (keyCode === KeyCodes.HOME || (shift && keyCode === KeyCodes.UP)) {
// Focus first row
trs[0].focus()
} else if (keyCode === KeyCodes.END || (shift && keyCode === KeyCodes.DOWN)) {
// Focus last row
trs[trs.length - 1].focus()
} else if (keyCode === KeyCodes.UP && rowIndex > 0) {
// Focus previous row
trs[rowIndex - 1].focus()
} else if (keyCode === KeyCodes.DOWN && rowIndex < trs.length - 1) {
// Focus next row
trs[rowIndex + 1].focus()
}
}
}
},
onTBodyRowClicked(evt) {
if (this.tbodyRowEvtStopped(evt)) {
// If table is busy, then don't propagate
return
} else if (filterEvent(evt) || textSelectionActive(this.$el)) {
// Clicked on a non-disabled control so ignore
// Or user is selecting text, so ignore
return
}
this.emitTbodyRowEvent('row-clicked', evt)
},
onTbodyRowMiddleMouseRowClicked(evt) {
if (!this.tbodyRowEvtStopped(evt) && evt.which === 2) {
this.emitTbodyRowEvent('row-middle-clicked', evt)
}
},
onTbodyRowContextmenu(evt) {
if (!this.tbodyRowEvtStopped(evt)) {
this.emitTbodyRowEvent('row-contextmenu', evt)
}
},
onTbodyRowDblClicked(evt) {
if (!this.tbodyRowEvtStopped(evt) && !filterEvent(evt)) {
this.emitTbodyRowEvent('row-dblclicked', evt)
}
},
// Note: Row hover handlers are handled by the tbody-row mixin
// As mouseenter/mouseleave events do not bubble
//
// Render Helper
renderTbody() {
// Render the tbody element and children
const items = this.computedItems
// Shortcut to `createElement` (could use `this._c()` instead)
const h = this.$createElement
const hasRowClickHandler = this.$listeners['row-clicked'] || this.isSelectable

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

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

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

// render the rows
// Render the rows
items.forEach((item, rowIndex) => {
// Render the individual item row (rows if details slot)
$rows.push(this.renderTbodyRow(item, rowIndex))
})

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

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

const handlers = {
// TODO: We may want to to only instantiate these handlers
// if there is an event listener registered
auxclick: this.onTbodyRowMiddleMouseRowClicked,
// TODO: Perhaps we do want to automatically prevent the
// default context menu from showing if there is
// a `row-contextmenu` listener registered.
contextmenu: this.onTbodyRowContextmenu,
// The following event(s) is not considered A11Y friendly
dblclick: this.onTbodyRowDblClicked
// hover events (mouseenter/mouseleave) ad handled by tbody-row mixin
}
if (hasRowClickHandler) {
handlers.click = this.onTBodyRowClicked
handlers.keydown = this.onTbodyRowKeydown
}
// Assemble rows into the tbody
const $tbody = h(
BTbody,
{
ref: 'tbody',
class: this.tbodyClass || null,
props: {
tbodyTransitionProps: this.tbodyTransitionProps,
tbodyTransitionHandlers: this.tbodyTransitionHandlers
}
},
// BTbody transfers all native event listeners to the root element
// TODO: Only set the handlers if the table is not busy
on: handlers
},
$rows
)
Expand Down

0 comments on commit 3f0d46a

Please sign in to comment.