import $ from '$qui/lib/jquery.module.js'
import Logger from '$qui/lib/logger.module.js'
import {gettext} from '$qui/base/i18n.js'
import {mix} from '$qui/base/mixwith.js'
import StockIcon from '$qui/icons/stock-icon.js'
import * as Lists from '$qui/lists/lists.js'
import * as Theme from '$qui/theme.js'
import Debouncer from '$qui/utils/debouncer.js'
import * as Gestures from '$qui/utils/gestures.js'
import {asap} from '$qui/utils/misc.js'
import * as StringUtils from '$qui/utils/string.js'
import {ProgressViewMixin} from '$qui/views/common-views/common-views.js'
import {StructuredViewMixin} from '$qui/views/common-views/common-views.js'
import ViewMixin from '$qui/views/view.js'
/* Associates item elements with their items. A WeakMap is used rather than jQuery's element data, which would
* store the item in an expando on the DOM element itself, creating an item -> element -> item reference cycle. */
const itemsByElement = new WeakMap()
/* How long to wait after the last keystroke before filtering the list, in milliseconds */
const SEARCH_FILTER_DELAY = 100
const logger = Logger.get('qui.lists.list')
/**
* A list view.
* @alias qui.lists.List
* @mixes qui.views.ViewMixin
* @mixes qui.views.commonviews.StructuredViewMixin
* @mixes qui.views.commonviews.ProgressViewMixin
*/
class List extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin) {
/**
* @constructs
* @param {qui.lists.ListItem[]} [initialItems] initial list items
* @param {Boolean} [searchEnabled] set to `true` to enable the search feature (defaults to `false`)
* @param {Boolean} [addEnabled] set to `true` to enable the add item feature (defaults to `false`)
* @param {String} [selectMode] one of:
* * {@link qui.lists.LIST_SELECT_MODE_DISABLED}
* * {@link qui.lists.LIST_SELECT_MODE_SINGLE} (default)
* * {@link qui.lists.LIST_SELECT_MODE_MULTIPLE}
* @param {Boolean} longPressMultipleSelection set to `true` to enable toggling between single and multiple select
* modes by long pressing items
* @param {...*} args parent class parameters
*/
constructor({
initialItems = null,
searchEnabled = false,
addEnabled = false,
selectMode = Lists.LIST_SELECT_MODE_SINGLE,
longPressMultipleSelection = false,
...args
} = {}) {
super(args)
this._items = initialItems || []
this._searchEnabled = searchEnabled
this._addEnabled = addEnabled
this._selectMode = selectMode
this._longPressMultipleSelection = longPressMultipleSelection
this._addElem = null
this._searchElem = null
this._filterInput = null
/* Search filtering state */
this._filteredOutItems = new Set()
this._pendingReveal = new Set()
this._filterCollapseTimeout = null
this._revealFrameHandle = null
this._applySearchFilterDebouncer = new Debouncer(() => this._applySearchFilter(), SEARCH_FILTER_DELAY)
/* Counts clicks, so that only the last of several quick clicks gets handled */
this._selectionClickCount = 0
this._selectionChangeFromItems = null
}
makeHTML() {
return $('<div></div>', {class: 'qui-list'})
}
initHTML(html) {
super.initHTML(html)
html.addClass(`select-mode-${this._selectMode}`)
}
init() {
super.init()
/* Set initial items */
if (this._items.length) {
this.setItems(this._items)
}
}
makeBody() {
let bodyDiv = $('<div></div>', {class: 'qui-list-body'})
/* Item events are delegated to the list body rather than bound to each item. A list of a few hundred items
* would otherwise install a few thousand event handlers, including a mousemove handler per item. */
bodyDiv.on('click', 'div.qui-list-item', function (e) {
let item = this._itemFromElement($(e.currentTarget))
if (item) {
this._handleItemClick(item)
}
}.bind(this))
if (this._longPressMultipleSelection) {
Gestures.enableLongPress(bodyDiv, {
selector: 'div.qui-list-item',
onLongPress: function (element) {
let item = this._itemFromElement(element)
if (item) {
this._handleLongPress(item)
}
}.bind(this)
})
}
if (this._searchEnabled) {
this._enableSearch(bodyDiv)
}
if (this._addEnabled) {
this._enableAdd(bodyDiv)
}
return bodyDiv
}
/* Items */
/**
* Return all items.
* @returns {qui.lists.ListItem[]}
*/
getItems() {
return this._items.slice()
}
/**
* Set the items of the list.
* @param {qui.lists.ListItem[]} items list items
*/
setItems(items) {
if (this._updateItemsInPlace(items)) {
return
}
this._items.forEach(function (i) {
this._forgetItemFilter(i)
i.getHTML().remove()
}, this)
items.forEach(i => this.prepareItem(i))
this._items = items
if (this._searchEnabled) {
this._applySearchFilter()
}
this._items.forEach(function (item) {
if (this._addElem) {
this._addElem.before(item.getHTML())
}
else {
this.getBody().append(item.getHTML())
}
}, this)
}
/* The same keys in the same order mean the same rows, so they can be patched where they are instead of every
* element being thrown away and built again. Measured on a 200-row list: 4.2ms to rebuild, 0.36ms to patch, and
* that is only the DOM -- it counts none of the item objects, visibility managers or icon renders a rebuild also
* throws away. Anything else (an item added, removed, or moved) falls back to the rebuild. */
_updateItemsInPlace(items) {
let oldItems = this._items
if (!oldItems.length || oldItems.length !== items.length) {
return false
}
for (let i = 0; i < items.length; i++) {
/* An item that has not been through prepareItem() is not in the list yet, whatever _items says: init()
* hands the initial items straight back to setItems(), where they would otherwise update from themselves
* and report success without ever being prepared or appended. */
if (oldItems[i].getList() !== this) {
return false
}
let key = items[i].getKey()
if (key == null || key !== oldItems[i].getKey()) {
return false
}
}
/* Giving up part way through is safe: the caller then rebuilds from the new items, and these half-updated
* ones are discarded along with their elements. */
for (let i = 0; i < items.length; i++) {
if (!oldItems[i].updateFrom(items[i])) {
return false
}
}
if (this._searchEnabled) {
this._applySearchFilter()
}
return true
}
/**
* Update one item.
* @param {Number} index the index where to perform the update
* @param {qui.lists.ListItem} item the item to update
*/
setItem(index, item) {
this.prepareItem(item)
if (this._searchEnabled) {
this._applySearchFilter(item)
}
let oldItem = this._items[index]
this._forgetItemFilter(oldItem)
oldItem.getHTML().replaceWith(item.getHTML())
this._items[index] = item
}
/**
* Add one item to the list.
* @param {Number} index the index where the item should be added; `-1` will add the item at the end
* @param {qui.lists.ListItem} item the item
*/
addItem(index, item) {
this.prepareItem(item)
if (this._searchEnabled) {
this._applySearchFilter(item)
}
if (index < 0 || !this._items.length) {
if (this._addElem) {
this._addElem.before(item.getHTML())
}
else {
this.getBody().append(item.getHTML())
}
this._items.push(item)
}
else {
this._items[index].getHTML().before(item.getHTML())
this._items.splice(index, 0, item)
}
}
/**
* Remove the item at a given index.
* @param {Number} index the index of the item to remove
* @returns {?qui.lists.ListItem} the removed item
*/
removeItemAt(index) {
let item = this._items[index]
if (item) {
this._forgetItemFilter(item)
item.getHTML().remove()
}
return this._items.splice(index, 1)[0] || null
}
/**
* Remove a specific item.
* @param {qui.lists.ListItem} item the item to remove
* @returns {Boolean} `true` if item found and removed, `false` otherwise
*/
removeItem(item) {
return this.removeItems(i => i === item).length > 0
}
/**
* Remove all items that match a condition.
* @param {qui.lists.ListItemMatchFunc} matchFunc
* @returns {qui.lists.ListItem[]} the removed items
*/
removeItems(matchFunc) {
let removedItems = []
for (let i = 0; i < this._items.length; i++) {
if (matchFunc(this._items[i])) {
removedItems.push(this.removeItemAt(i--))
}
}
return removedItems
}
/**
* Prepare item to be part of this list.
* @param {qui.lists.ListItem} item
*/
prepareItem(item) {
item.setList(this)
/* Associate the item with its element, so that delegated event handlers can find it back */
itemsByElement.set(item.getHTML()[0], item)
item.setSelectMode(this._selectMode)
}
/**
* Return the item that owns a given element, if it belongs to this list.
* @param {jQuery} element
* @returns {?qui.lists.ListItem}
*/
_itemFromElement(element) {
let item = itemsByElement.get(element[0])
if (!item || item.getList() !== this) {
return null
}
return item
}
_handleItemClick(item) {
/* Flag to prevent handling clicks on long press */
if (item._wasLongPressed) {
item._wasLongPressed = false
return
}
if (this._selectMode === Lists.LIST_SELECT_MODE_DISABLED) {
return
}
let oldItems = this._items.filter(i => i.isSelected())
let newItems = []
let addedItems = []
let removedItems = []
if (this._selectMode === Lists.LIST_SELECT_MODE_MULTIPLE) {
/* In multi-selection mode, simply add/remove new item to/from selection */
if (oldItems.includes(item)) {
newItems = oldItems.filter(i => i !== item)
removedItems.push(item)
}
else {
newItems = oldItems.concat([item])
addedItems.push(item)
}
}
else { /* Assuming Lists.LIST_SELECT_MODE_SINGLE */
newItems.push(item)
removedItems = oldItems
addedItems.push(item)
}
/* Items are compared by identity: deep comparison would walk each item's entire object graph, including its
* HTML element and everything reachable from it */
if (oldItems.length === newItems.length && oldItems.every((item, i) => item === newItems[i])) {
return /* Selection unchanged */
}
let setSelection = function (deselected, selected) {
try {
deselected.forEach(i => i.setSelected(false))
selected.forEach(i => i.setSelected(true))
}
catch (e) {
logger.errorStack('setSelected failed', e)
}
}
/* Show the new selection first and let its fade finish before onSelectionChange(). That usually builds and
* pushes a whole page, which blocks the main thread and would otherwise hold back the tap feedback. */
setSelection(removedItems, addedItems)
/* Quick clicks in a row make one change, from the selection before the first one to that of the last one, so
* that no page gets built for a selection that is already gone */
if (this._selectionChangeFromItems == null) {
this._selectionChangeFromItems = oldItems
}
let clickCount = ++this._selectionClickCount
let fromItems = null
new Promise(resolve => window.requestAnimationFrame(resolve)).then(function () {
return Theme.afterTransitionPromise()
}).then(function () {
if (clickCount !== this._selectionClickCount) {
return /* A later click took over */
}
fromItems = this._selectionChangeFromItems
this._selectionChangeFromItems = null
if (fromItems.length === newItems.length && fromItems.every(i => newItems.includes(i))) {
return /* Clicked back to where it started */
}
return this.onSelectionChange(fromItems, newItems)
}.bind(this)).catch(function (e) {
if (fromItems && clickCount === this._selectionClickCount) {
setSelection(newItems, fromItems)
}
if (e == null) {
logger.debug('selection change rejected')
}
else {
throw e
}
}.bind(this))
}
_handleLongPress(item) {
if (!this._longPressMultipleSelection) {
return
}
if (this._selectMode === Lists.LIST_SELECT_MODE_SINGLE) {
this.setSelectMode(Lists.LIST_SELECT_MODE_MULTIPLE)
let selectedItems = this.getSelectedItems()
if (!selectedItems.includes(item)) {
selectedItems.push(item)
this.setSelectedItems(selectedItems)
}
}
else if (this._selectMode === Lists.LIST_SELECT_MODE_MULTIPLE) {
this.setSelectMode(Lists.LIST_SELECT_MODE_SINGLE)
this.setSelectedItems([item])
}
item._wasLongPressed = true
}
/* Add feature */
/**
* Tell if the add feature is enabled
* @returns {Boolean}
*/
isAddEnabled() {
return this._addEnabled
}
/**
* Enable the search feature.
*/
enableAdd() {
if (this._addEnabled) {
return
}
this._addEnabled = true
this._enableAdd(this.getBody())
}
_enableAdd(element) {
this._addElem = this._makeAddElem()
element.append(this._addElem)
element.addClass('add-enabled')
}
/**
* Disable the add feature.
*/
disableAdd() {
if (!this._addEnabled) {
return
}
this._addEnabled = false
this._disableAdd()
}
_disableAdd() {
this._addElem.remove()
this._addElem = null
this.getBody().removeClass('add-enabled')
}
_makeAddElem() {
let addElem = $('<div></div>', {class: 'qui-base-button qui-list-child qui-list-add'})
let addIcon = $('<div></div>', {class: 'qui-icon'})
addElem.append(addIcon)
new StockIcon({name: 'plus', variant: 'interactive'}).applyTo(addIcon)
addElem.on('click', function () {
let promise = this.onAdd()
promise = promise || Promise.resolve()
promise.then(function () {
try {
this._items.forEach(i => i.setSelected(false))
}
catch (e) {
logger.errorStack('setSelected failed', e)
}
}.bind(this)).catch(function (e) {
if (e == null) {
logger.debug('add rejected')
}
else {
throw e
}
})
}.bind(this))
return addElem
}
/**
* Override this to define the behavior of the list when the add button is pressed.
* @returns {?Promise} an optional promise which, if rejected with no argument, will cancel adding
*/
onAdd() {
}
/* Search feature */
_makeSearchElem() {
let list = this
let searchElem = $('<div></div>', {class: 'qui-list-child qui-list-search'})
let searchInput = $('<input>', {type: 'text'})
searchInput.attr('placeholder', gettext('search...'))
let searchWrapper = $('<div></div>', {class: 'qui-list-search-wrapper'})
searchWrapper.append(searchInput)
searchElem.append(searchWrapper)
let searchIcon = $('<div></div>', {class: 'qui-icon'})
new StockIcon({
name: 'magnifier', variant: 'interactive',
activeName: 'magnifier', activeVariant: 'interactive',
focusedName: 'close', focusedVariant: 'background'
}).applyTo(searchIcon)
searchWrapper.append(searchIcon)
searchInput.on('keydown', function (e) {
if (e.which === 27) {
if (list._filterInput.val().length) {
list._clearSearch()
}
else {
list._filterInput.blur()
}
}
})
searchInput.on('keyup', function () {
list._applySearchFilterDebouncer.call()
})
searchInput.on('paste', function () {
list._applySearchFilterDebouncer.call()
})
searchIcon.on('pointerdown', function () {
if (searchInput.is(':focus')) {
searchInput.blur()
list._clearSearch()
return false
}
else {
asap(function () {
searchInput.focus()
})
}
})
return searchElem
}
/**
* Tell if an item is currently filtered out by the search filter.
* @param {qui.lists.ListItem} item
* @returns {Boolean}
*/
isItemFilteredOut(item) {
return this._filteredOutItems.has(item)
}
_forgetItemFilter(item) {
this._pendingReveal.delete(item)
if (!this._filteredOutItems.delete(item)) {
return /* The filter never touched this item's element */
}
/* Undo what the filter did, so that an item that is added to a list again does not stay invisible */
item.getHTML().css({opacity: '', display: ''})
}
_makeSearchExpression() {
if (!this._filterInput) {
return null
}
let searchText = this._filterInput.val().trim()
if (!searchText) {
return null
}
/* The whole search text is compiled into a single expression, which also takes care of splitting it into
* groups. Compiling it here means compiling it once per list rather than once per item. */
return StringUtils.intelliSearchRegExp(searchText)
}
_applySearchFilter(item = null) {
let searchExpression = this._makeSearchExpression()
let items = item ? [item] : this._items
/* Filtering is applied to the whole list at once: items are faded together, collapsed together by a single
* timer, and revealed together on a single frame. Going through each item's visibility manager instead would
* schedule two timeouts per item whose visibility changes.
*
* Visibility is driven by inline styles rather than by classes, so that filtering does not depend on a
* stylesheet built from the same sources as this file. The fade comes from the opacity transition that
* div.qui-list-child already carries. show() and hide() drive the same inline properties through the item's
* visibility manager, so revealing an item leaves it hidden if it has also been explicitly hidden. */
let toReveal = []
let toCollapse = []
items.forEach(function (item) {
let filteredOut = searchExpression != null && !item.isMatch(searchExpression)
if (filteredOut === this._filteredOutItems.has(item)) {
return /* Nothing to do for this item */
}
let html = item.getHTML()
if (filteredOut) {
this._filteredOutItems.add(item)
this._pendingReveal.delete(item)
if (html[0].isConnected) {
html.css('opacity', '0') /* Starts fading out */
toCollapse.push(item)
}
else { /* Not part of the document yet, so there is nothing to transition from */
html.css({opacity: '0', display: 'none'})
}
}
else {
this._filteredOutItems.delete(item)
/* Take up layout again, still transparent, and start fading in on the next frame, unless the item
* has been explicitly hidden, in which case its visibility manager owns the display property */
html.css('display', item.isExplicitlyHidden() ? 'none' : '')
this._pendingReveal.add(item)
toReveal.push(item)
}
}, this)
if (toCollapse.length) {
this._scheduleFilterCollapse()
}
if (toReveal.length && this._revealFrameHandle == null) {
this._revealFrameHandle = window.requestAnimationFrame(function () {
this._revealFrameHandle = null
this._pendingReveal.forEach(function (item) {
if (!this._filteredOutItems.has(item)) {
item.getHTML().css('opacity', '')
}
}, this)
this._pendingReveal.clear()
}.bind(this))
}
}
_scheduleFilterCollapse() {
if (this._filterCollapseTimeout != null) {
return /* Items added to the batch in the meantime are collapsed by the pending timeout */
}
this._filterCollapseTimeout = setTimeout(function () {
this._filterCollapseTimeout = null
this._filteredOutItems.forEach(i => i.getHTML().css('display', 'none'))
}.bind(this), Theme.getTransitionDuration())
}
_clearSearch() {
this._filterInput.val('')
this._applySearchFilter()
}
/**
* Tell if the search feature is enabled
* @returns {Boolean}
*/
isSearchEnabled() {
return this._searchEnabled
}
/**
* Enable the search feature.
*/
enableSearch() {
if (this._searchEnabled) {
return
}
this._searchEnabled = true
this._enableSearch(this.getBody())
}
_enableSearch(element) {
this._searchElem = this._makeSearchElem()
this._filterInput = this._searchElem.find('input[type=text]')
element.prepend(this._searchElem)
element.addClass('search-enabled')
}
/**
* Disable the search feature.
*/
disableSearch() {
if (!this._searchEnabled) {
return
}
this._searchEnabled = false
this._disableSearch()
}
_disableSearch() {
this._searchElem.remove()
this._searchElem = null
this._filterInput = null
this.getBody().removeClass('search-enabled')
this._applySearchFilter()
}
/* Selection */
/**
* Set selection mode.
* @param {String} selectMode one of:
* * {@link qui.lists.LIST_SELECT_MODE_DISABLED}
* * {@link qui.lists.LIST_SELECT_MODE_SINGLE} (default)
* * {@link qui.lists.LIST_SELECT_MODE_MULTIPLE}
*/
setSelectMode(selectMode) {
this._selectMode = selectMode
let selectedItems = this._items.filter(i => i.isSelected())
if (this._selectMode === Lists.LIST_SELECT_MODE_DISABLED) {
selectedItems.forEach(i => i.setSelected(false))
}
else if (this._selectMode === Lists.LIST_SELECT_MODE_SINGLE) {
if (selectedItems.length > 1) {
selectedItems.slice(1).forEach(i => i.setSelected(false))
}
}
/* Update HTML class according to new select mode */
let html = this.getHTML()
html.removeClass([
Lists.LIST_SELECT_MODE_DISABLED,
Lists.LIST_SELECT_MODE_SINGLE,
Lists.LIST_SELECT_MODE_MULTIPLE
].map(m => `select-mode-${m}`).join(' '))
html.addClass(`select-mode-${this._selectMode}`)
/* Update items select mode */
this.getItems().forEach(i => i.setSelectMode(this._selectMode))
}
/**
* Return the currently selected items.
* @returns {qui.lists.ListItem[]}
*/
getSelectedItems() {
return this._items.filter(i => i.isSelected())
}
/**
* Update current selection.
* @param {qui.lists.ListItem[]} items the list of new items to select; empty list clears selection
*/
setSelectedItems(items) {
if (this._selectMode === Lists.LIST_SELECT_MODE_DISABLED) {
return
}
/* Resolve by key anything that is not one of our own items. A caller that rebuilt its items and handed us the
* fresh ones would otherwise wipe the selection, since setItems() may have kept the originals and updated
* them in place. */
items = items.map(function (item) {
if (this._items.includes(item)) {
return item
}
let key = item.getKey()
return (key != null) ? this._items.find(i => i.getKey() === key) || null : null
}, this).filter(i => i != null)
if (this._selectMode === Lists.LIST_SELECT_MODE_SINGLE) {
if (items.length > 1) {
items = items.slice(0, 1) /* Keep only first element in single selection mode */
}
}
let selectedItems = this._items.filter(i => i.isSelected())
/* Remove selection from items no longer selected */
selectedItems.filter(i => !items.includes(i)).forEach(i => i.setSelected(false))
/* Add selection to newly selected items */
items.filter(i => !selectedItems.includes(i)).forEach(i => i.setSelected(true))
}
/**
* Called when the current selection is changed by user.
* @param {qui.lists.ListItem[]} oldItems the previously selected items (can be empty)
* @param {qui.lists.ListItem[]} newItems the new selected items (can be empty)
* @returns {?Promise} an optional promise which, if rejected with no argument, will cancel the selection change
*/
onSelectionChange(oldItems, newItems) {
}
}
export default List