/**
* A list which supports a "rich rendering" of the items it contains
* (item values may be rendered using HTML).
* <p>Part of the XUI module which, for now, has an undocumented API.
*/
export class List extends HTMLElement {
constructor() {
super();
this._singleClickToAccept = false;
this.addEventListener("click", this.onClick.bind(this));
this.addEventListener("keydown", this.onKeydown.bind(this));
this._connected = false;
this._labelMaker = null;
this._htmlRenderer = null;
this._disabledChecker = null;
this._items = [];
this._selection = [];
this._anchor = this._extent = -1;
}
// -----------------------------------------------------------------------
// Custom element
// -----------------------------------------------------------------------
connectedCallback() {
if (!this._connected) {
this._connected = true;
// User code may have already added its own classes.
this.classList.add("xui-control");
// tabindex="0" makes this element focusable and tab-able.
this.setAttribute("tabindex", "0");
if (!this.hasAttribute("selectionmode")) {
this.selectionMode = "single";
}
}
}
static get observedAttributes() {
return [ "selectionmode" ];
}
attributeChangedCallback(attrName, oldVal, newVal) {
if (newVal === "single" && oldVal === "multiple") {
let index = this.getSelection();
if (index >= 0) {
this.setSelection(index);
}
}
}
// -----------------------------------------------------------------------
// Input events
// -----------------------------------------------------------------------
get singleClickToAccept() {
return this._singleClickToAccept;
}
set singleClickToAccept(single) {
// For use by AutocompleteField.
this._singleClickToAccept = single;
}
onClick(event) {
if (event.button === 0 && event.detail === 1) {
// First click on primary button.
Util.consumeEvent(event);
if (this.length > 0) {
let index = this.clickEventToIndex(event);
if (index >= 0) {
if (event.shiftKey) {
this.extendSelection(index, /*clickCount*/ 1);
} else if (Util.modKey(event)) {
this.toggleSelection(index, /*clickCount*/ 1);
} else {
let changed =
this.changeSelection(index, /*clickCount*/ 1);
if (!changed &&
this.singleClickToAccept &&
this.isSingleSelectionMode()) {
// Clicking on an item already selected in the
// list will not cause the selectionchanged event
// to be sent hence, without this trick,
// AutocompleteField.acceptSelection would not be
// invoked.
this.selectionChanged(/*prevSelection*/ [], 1);
}
}
}
}
this.focus();
}
}
clickEventToIndex(event) {
let itemDiv = null;
let elem = event.target;
while (elem !== null && !Object.is(elem, this)) {
if (elem.classList.contains("xui-lst-item")) {
itemDiv = elem;
break;
}
elem = elem.parentElement;
}
let index = -1;
if (itemDiv !== null) {
let i = 0;
let child = this.firstElementChild;
while (child !== null) {
if (Object.is(child, itemDiv)) {
index = i;
break;
}
++i;
child = child.nextElementSibling;
}
}
return index;
}
extendSelection(index, clickCount=0) {
if (this.isSingleSelectionMode()) {
this.changeSelection(index, clickCount);
return;
}
// ---
let curAnchor = this._anchor;
if (curAnchor < 0) {
this.setAnchor(0);
curAnchor = this._anchor;
}
let from, to;
if (index < curAnchor) {
from = index;
to = curAnchor;
} else if (index > curAnchor) {
from = curAnchor;
to = index;
} else {
from = to = index;
}
let sel = [];
for (let i = from; i <= to; ++i) {
sel.push(i);
}
if (!List.sameSelection(sel, this._selection)) {
// This clears anchor and extent.
let prevSelection = this.setSelection(...sel);
this.selectionChanged(prevSelection, clickCount);
}
this.setAnchor(curAnchor); // Restore anchor.
this._extent = index;
}
static sameSelection(a1, a2) {
let count;
if ((count = a1.length) !== a2.length) {
return false;
}
for (let i = 0; i < count; ++i) {
if (a1[i] !== a2[i]) {
return false;
}
}
return true;
}
toggleSelection(index, clickCount=0) {
let sel = [ ...this._selection ]; // Never modify this._selection
let pos = sel.indexOf(index);
if (pos >= 0) {
// Selected ==> unselect.
sel.splice(pos, 1);
} else {
// Not selected ==> select.
if (this.isSingleSelectionMode()) {
sel = [ index ];
} else {
sel.push(index);
}
}
let prevSelection = this.setSelection(...sel);
this.selectionChanged(prevSelection, clickCount);
this.setAnchor(index);
}
changeSelection(index, clickCount=0) {
let changed = false;
if (this._selection.length !== 1 || this._selection[0] !== index) {
let prevSelection = this.setSelection(index);
changed = this.selectionChanged(prevSelection, clickCount);
}
// Otherwise, nothing to do.
this.setAnchor(index);
return changed;
}
selectionChanged(prevSelection, clickCount=0) {
if (List.sameSelection(this._selection, prevSelection)) {
return false;
}
let event =
new Event("selectionchanged",
{ bubbles: true, cancelable: false, composed: true });
event.xuiSelection = this.getSelection();
event.xuiClickCount = clickCount;
this.dispatchEvent(event);
return true;
}
onKeydown(event) {
let isHotKey = true;
switch (event.key) {
// Ctrl-PageUp and Ctrl-PageDown not sent by Chrome & Firefox.
case "PageUp":
this.onUpKey(event.shiftKey, Util.modKey(event), 10);
break;
case "PageDown":
this.onDownKey(event.shiftKey, Util.modKey(event), 10);
break;
case "ArrowUp":
case "Up":
this.onUpKey(event.shiftKey, Util.modKey(event));
break;
case "ArrowDown":
case "Down":
this.onDownKey(event.shiftKey, Util.modKey(event));
break;
case "Home":
this.onHomeKey(event.shiftKey, Util.modKey(event));
break;
case "End":
this.onEndKey(event.shiftKey, Util.modKey(event));
break;
case " ": // Space bar
this.onSpaceKey(event.shiftKey, Util.modKey(event));
break;
case "a":
this.onAKey(event.shiftKey, Util.modKey(event));
break;
default:
isHotKey = false;
break;
}
if (isHotKey) {
// Whatever its modifiers.
Util.consumeEvent(event);
}
}
// ---------------------------------------
// Low-level API used by AutocompleteField
// ---------------------------------------
onUpKey(isShift, isMod, skip=1) {
if (this.length === 0) {
return;
}
let curExtent = this._extent;
if (this._anchor < 0) {
this.setAnchor(this.length-1);
curExtent = this.length + skip - 1; // See nextExtent below.
}
let nextExtent = curExtent - skip;
if (nextExtent < 0) {
nextExtent = 0;
}
if (isShift) {
this.extendSelection(nextExtent);
} else if (isMod) {
this.setAnchor(nextExtent);
} else {
this.changeSelection(nextExtent);
}
if (this._extent >= 0) {
this.ensureIsVisible(this._extent);
}
}
onDownKey(isShift, isMod, skip=1) {
if (this.length === 0) {
return;
}
let curExtent = this._extent;
if (this._anchor < 0) {
this.setAnchor(0);
curExtent = -skip; // See nextExtent below.
}
let nextExtent = curExtent + skip;
if (nextExtent >= this.length) {
nextExtent = this.length-1;
}
if (isShift) {
this.extendSelection(nextExtent);
} else if (isMod) {
this.setAnchor(nextExtent);
} else {
this.changeSelection(nextExtent);
}
if (this._extent >= 0) {
this.ensureIsVisible(this._extent);
}
}
onHomeKey(isShift, isMod) {
if (this.length === 0) {
return;
}
if (this._anchor < 0) {
this.setAnchor(0);
}
let nextExtent = 0;
if (isShift) {
this.extendSelection(nextExtent);
} else if (isMod) {
this.setAnchor(nextExtent);
} else {
this.changeSelection(nextExtent);
}
if (this._extent >= 0) {
this.ensureIsVisible(this._extent);
}
}
onEndKey(isShift, isMod) {
if (this.length === 0) {
return;
}
if (this._anchor < 0) {
this.setAnchor(this.length-1);
}
let nextExtent = this.length-1;
if (isShift) {
this.extendSelection(nextExtent);
} else if (isMod) {
this.setAnchor(nextExtent);
} else {
this.changeSelection(nextExtent);
}
if (this._extent >= 0) {
this.ensureIsVisible(this._extent);
}
}
onSpaceKey(isShift, isMod) {
if (this.length === 0) {
return;
}
if (this._anchor >= 0) {
if (isMod) {
this.toggleSelection(this._anchor);
} else {
this.changeSelection(this._anchor);
}
}
if (this._extent >= 0) {
this.ensureIsVisible(this._extent);
}
}
onAKey(isShift, isMod) {
if (this.length === 0) {
return;
}
if (isMod) {
let prevSelection = this.selectAll();
this.selectionChanged(prevSelection);
}
if (this._extent >= 0) {
this.ensureIsVisible(this._extent);
}
}
// -----------------------------------------------------------------------
// API
// -----------------------------------------------------------------------
get selectionMode() {
return this.getAttribute("selectionmode");
}
set selectionMode(mode) {
this.setAttribute("selectionmode",
(mode === "multiple")? mode : "single");
}
isSingleSelectionMode() {
return (this.selectionMode === "single");
}
get labelMaker() {
return this._labelMaker;
}
set labelMaker(maker) {
this._labelMaker = maker;
}
get htmlRenderer() {
return this._htmlRenderer;
}
set htmlRenderer(renderer) {
this._htmlRenderer = renderer;
}
get disabledChecker() {
return this._disabledChecker;
}
set disabledChecker(checker) {
this._disabledChecker = checker;
}
get length() {
return this._items.length;
}
get(index) {
let item = null;
if (index >= 0 && index < this.length) {
item = this._items[index];
}
return item;
}
getLabel(index) {
let label = null;
if (index >= 0 && index < this.length) {
label = this.itemLabel(this._items[index]);
}
return label;
}
itemLabel(item) {
let label = null;
if (this._labelMaker !== null) {
try {
label = this._labelMaker(item);
} catch (error) {
console.error(`XUI.List.labelMaker has failed: ${error}`);
}
}
if (label === null) {
label = item.toString();
}
return label;
}
isDisabled(index) {
let disabled = false;
if (index >= 0 && index < this.length) {
disabled = this.itemIsDisabled(this._items[index]);
}
return disabled;
}
itemIsDisabled(item) {
let disabled = false;
if (this._disabledChecker !== null) {
try {
disabled = this._disabledChecker(item);
} catch (error) {
console.error(`XUI.List.disabledChecker has failed: ${error}`);
}
}
return disabled;
}
getAll(copy=true) {
if (copy) {
return [ ...this._items ]; // A copy that can be modified.
} else {
return this._items; // Must not be modified. Use with care.
}
}
setAll(items) {
this.removeAll();
this._items = [ ...items ]; // Our own, private, copy.
let i = 0;
for (let item of items) {
let child = document.createElement("div");
child.setAttribute("class", "xui-lst-item");
this.populateChild(child, item, i++);
this.appendChild(child);
}
this.update(0);
this.ensureIsVisible(0);
}
removeAll() {
this._items = [];
this._selection = [];
this._anchor = -1;
Util.removeAllChildren(this);
}
populateChild(child, item, index) {
child.innerHTML = this.itemInnerHTML(item, index);
if (this.itemIsDisabled(item)) {
child.classList.add("xui-lst-disabled");
} else {
child.classList.remove("xui-lst-disabled");
}
}
itemInnerHTML(item, index) {
let html = null;
if (this._htmlRenderer !== null) {
// index may be rendered by client code too.
try {
html = this._htmlRenderer(item, index);
} catch (error) {
console.error(`XUI.List.htmlRenderer has failed: ${error}`);
}
}
if (html === null) {
html = Util.escapeHTML(this.itemLabel(item));
if (html.indexOf('\n') >= 0) {
html = html.replaceAll('\n', "<br>");
}
}
return html;
}
update(from) {
this._selection = [];
this._anchor = -1;
let i = 0;
let child = this.firstElementChild;
while (child !== null) {
let classList = child.classList;
if (i >= from) {
classList.remove("xui-lst-item0", "xui-lst-item1");
classList.add("xui-lst-item" + String(i % 2));
}
if (classList.contains("xui-lst-selected")) {
this._selection.push(i);
}
if (classList.contains("xui-lst-anchor")) {
this._anchor = i;
}
++i;
child = child.nextElementSibling;
}
}
ensureIsVisible(index) {
const count = this.length;
if (count === 0) {
// Nothing to do.
return;
}
if (index < 0) {
index = 0;
} else if (index >= count) {
index = count-1;
}
this.children[index].scrollIntoView({ block: "nearest",
inline: "nearest" });
}
set(index, item) {
let replaced = null;
if (index >= 0 && index < this.length) {
replaced = this._items[index];
this._items[index] = item;
this.populateChild(this.children[index], item, index);
}
return replaced;
}
remove(index) {
let removed = null;
if (index >= 0 && index < this.length) {
removed = this._items[index];
this._items.splice(index, 1);
this.removeChild(this.children[index]);
this.update(index);
}
return removed;
}
add(...newItems) {
this.insert(this.length, ...newItems);
}
insert(index, ...newItems) {
if (newItems.length === 0) {
// Nothing to do.
return;
}
const count = this.length;
if (index < 0) {
index = 0;
} else if (index > count) {
index = count;
}
let before = (index === count)? /*at end*/ null : this.children[index];
let i = index;
for (let newItem of newItems) {
let child = document.createElement("div");
child.setAttribute("class", "xui-lst-item");
this.populateChild(child, newItem, i++);
this.insertBefore(child, before);
}
this._items.splice(index, 0, ...newItems);
this.update(index);
}
getSelection() {
if (this.isSingleSelectionMode()) {
return (this._selection.length > 0)? this._selection[0] : -1;
} else {
return [ ...this._selection ]; // A copy that can be modified.
}
}
selectAll() {
let sel = [];
const count = this.length;
for (let i = 0; i < count; ++i) {
sel.push(i);
}
return this.setSelection(...sel);
}
clearSelection() {
return this.setSelection();
}
setSelection(...indices) {
if (this.isSingleSelectionMode() && indices.length > 0) {
indices = indices.slice(0, 1);
}
let prevSelection = this._selection;
this._selection = [];
const count = this.length;
for (let i = 0; i < count; ++i) {
let item = this._items[i];
let childClassList = this.children[i].classList;
if (indices.includes(i) && !this.itemIsDisabled(item)) {
this._selection.push(i);
childClassList.add("xui-lst-selected");
} else {
childClassList.remove("xui-lst-selected");
}
}
// Forget about these selection marks.
this.setAnchor(-1);
return prevSelection;
}
// ---------------------------------------
// Low-level API used by AutocompleteField
// ---------------------------------------
setAnchor(index) {
let oldAnchor = this._anchor;
this._anchor = this._extent = -1;
if (index >= 0 && index < this.length) {
this._anchor = this._extent = index;
}
if (this._anchor !== oldAnchor) {
if (oldAnchor >= 0) {
this.children[oldAnchor].classList.remove("xui-lst-anchor");
}
if (this._anchor >= 0) {
this.children[this._anchor].classList.add("xui-lst-anchor");
}
}
}
get anchor() {
return this._anchor;
}
get extent() {
return this._extent;
}
}
window.customElements.define("xui-list", List);