You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
RnQ/Distro/Template/vlist.tis

539 lines
17 KiB
Plaintext

{items};
function isTruthy(prop) {
if( prop === undefined ) return false;
if( prop === "" ) return true;
if( prop === "false" ) return false;
return true;
}
class VList : Reactor.Component
{
const styleset = ["vlist", $url(vlist.css)];
this var swIndex = 0; // scrolling window (buffer) position
this var swLength = 50; // scrolling window (buffer) length
this var itemHeight = 10; // will be recalculated later
this var visibleItems = 1; // will be recalculated later
this var renderedRecordset = null; // previously rendered recordset
this var nRenderedItems = 0; // total number of items in recordset
this var recordset = [];
this var multiselect = false;
this var unanimated = false;
this var recordsetLength = 0;
this var selectedRecords = {}; // key -> record map
this var currentRecord = null; // current record
//this var anchorRecord = null; // anchor record
function this(props,kids) {
this.recordset = props.recordset ?? [], // recordset
this.recordview = props.recordview; // function producing record markup (a.k.a. record view)
this.multiselect = isTruthy(props.multiselect);
this.unanimated = props.unanimated == "true";
assert typeof this.recordset == #array;
assert typeof this.recordview == #function;
}
function render() {
var {recordview, recordset} = this;
if(this.renderedRecordset && (this.renderedRecordset !== recordset)) {
// recordeset changed
this.selectedRecords = {};
this.currentRecord = null;
this.swIndex = 0; // rewind to start
this.post(:{
this.resetPaddings();
this.scrollTo(0,0,false);
});
} else if(this.nRenderedItems != recordset.length) {
// recordeset length changed
if( (this.swIndex + this.swLength) > recordset.length )
this.swIndex = Integer.max(0,recordset.length - this.swLength);
this.post(this.resetPaddings);
}
this.renderedRecordset = recordset;
this.nRenderedItems = recordset.length;
var end = Integer.min(recordset.length,this.swIndex + this.swLength);
var items = [];
for( var i = this.swIndex; i < end; ++i )
items.push( recordview.call(this,recordset[i],i) );
return {items};
}
function wheelEvent(e) {
return (this.scroll(#top) <= 0 && e.wheelDelta > 0) || (this.scroll(#bottom) <= 0 && e.wheelDelta < 0);
}
function attached() {
this.subscribe(this.wheelEvent, Event.MOUSE, Event.MOUSE_WHEEL);
this.paintContent = :{
if(!this.first) return;
this.itemHeight = Integer.max(10,this.first.box(#height,#border));
this.visibleItems = this.box(#height,#client) / this.itemHeight;
this.paintContent = null; // we need this only once
this.post(this.resetPaddings); // as we've discovered height of the single item we need to resetPaddings
};
this.calcWindow(true);
}
function detached() {
this.unsubscribe(this.wheelEvent);
}
function calcWindow(force = false) {
if(!this.first) return;
this.itemHeight = Integer.max(10,this.first.box(#height,#border));
var visibleItems = this.box(#height,#client) / this.itemHeight;
if (force || this.visibleItems != visibleItems) {
this.visibleItems = visibleItems;
this.post(:{
this.swLength = Integer.max(10,this.visibleItems + 5);
if((this.swIndex + this.swLength) > this.recordset.length )
this.swIndex = Integer.max(0,this.recordset.length - this.swLength);
this.merge(this.render());
this.resetPaddings();
});
}
}
function onSize() {
this.calcWindow();
}
function onScroll(evt)
{
//if( evt.type != Event.SCROLL_POS ) return;
if( evt.type != Event.SCROLL_SLIDER_PRESSED )
this.onScrollChange(evt.scrollPos);
}
function onScrollChange(scrollPos)
{
var rPos = scrollPos / this.itemHeight; // recordPos
var mPos = this.recordset.length - 1 - this.visibleItems; // max recordPos
if( rPos > mPos ) rPos = mPos;
if( rPos < 0 ) rPos = 0;
if( rPos + this.swLength >= this.recordset.length ) {
var ni = Integer.max(0,this.recordset.length - this.swLength);
if(this.swIndex == ni) return;
this.swIndex = ni;
}
else if( rPos + this.visibleItems >= this.swIndex + this.swLength )
this.swIndex = rPos;
else if( rPos < this.swIndex)
this.swIndex = rPos;
else
return;
// note, we do not use standard Component.update() method here
// as this needs to be done synchronously - onScroll is called under scroll animation
this.merge(this.render());
this.resetPaddings();
//Element.update.call(this); // use original Element.update method.
}
function resetPaddings() {
var top = this.swIndex * this.itemHeight;
const middle = this.swLength * this.itemHeight;
const total = this.recordset.length * this.itemHeight;
var bottom = total - middle - top;
if( bottom < 0 ) {
bottom = 0;
top = Integer.max(0,total - middle);
} else if(bottom + top + middle < total)
bottom = top = 0;
this.style.set {
padding-top: px(top),
padding-bottom: px(bottom)
};
Element.update.call(this); // use original Element.update method.
}
function isSelected(key) {
return this.selectedRecords[key] !== undefined;
}
function checkCurrent() {
if (this.multiselect && this.currentRecord !== null) {
this.toggleCheck(this.currentRecord);
this.update();
}
}
function checkAll() {
if (!this.multiselect) return;
for (var group in this.recordset) {
this.selectedRecords[group.key] = group;
for (var rec in group.items)
this.selectedRecords[rec.key] = rec;
}
this.postEvent("selectionchange");
this.update { currentRecord: cr };
}
function uncheckAll() {
if (!this.multiselect) return;
for (var group in this.recordset) {
delete this.selectedRecords[group.key];
for (var rec in group.items)
delete this.selectedRecords[rec.key];
}
this.postEvent("selectionchange");
this.update { currentRecord: cr };
}
function toggleCheck(cr) {
var checked = this.selectedRecords[cr.key] !== undefined;
if (checked)
delete this.selectedRecords[cr.key];
else
this.selectedRecords[cr.key] = cr;
if (cr.kind == 0)
for (var group in this.recordset)
for (var rec in group.items)
if (rec.kind == 1 && rec.groupId == cr.groupId)
if (checked)
delete this.selectedRecords[rec.key];
else
this.selectedRecords[rec.key] = rec;
this.postEvent("selectionchange");
}
event mousedown $(li,tr) (evt,li) {
var cr = this.recordset[li.index + this.swIndex];
if (this.multiselect && (evt.ctrlKey || evt.isOnIcon)) this.toggleCheck(cr);
this.update { currentRecord: cr };
this.postEvent("currentchange");
}
// event mouseup $(li,tr) (evt,li) {
// if(li.state.current)
// li.postEvent("item-click");
// }
function scrollToRecord(recordNo, animate = false) {
var y1 = this.scroll(#top);
var y2 = y1 + this.scroll(#height);
var ry1 = recordNo * this.itemHeight;
var ry2 = ry1 + this.itemHeight - 1;
var scrollPos;
if( ry1 < y1 ) scrollPos = ry1;
else if( ry2 > y2 ) scrollPos = ry2 - (y2 - y1) + 1;
else return true;
this.scrollTo(0, scrollPos, animate && !this.unanimated, true);
this.onScrollChange(scrollPos);
return false;
}
function setCurrent(recordNo, animate = true) {
if( recordNo < 0 ) recordNo = 0;
if( recordNo >= this.recordset.length ) recordNo = this.recordset.length - 1;
this.currentRecord = this.recordset[recordNo];
this.postEvent("currentchange");
this.scrollToRecord(recordNo, animate && !this.unanimated);
this.update();
}
event keydown (evt) {
const currentIndex = () => this.recordset.indexOf(this.currentRecord);
switch(evt.keyCode)
{
case Event.VK_HOME: this.setCurrent(0); return true;
case Event.VK_END: this.setCurrent(this.recordset.length - 1); return true;
case Event.VK_UP: this.setCurrent(currentIndex() - 1); return true;
case Event.VK_DOWN: this.setCurrent(currentIndex() + 1); return true;
case Event.VK_PRIOR: this.setCurrent(currentIndex() - this.visibleItems); return true;
case Event.VK_NEXT: this.setCurrent(currentIndex() + this.visibleItems); return true;
case Event.VK_LEFT:
case Event.VK_RIGHT:
case Event.VK_SPACE:
case Event.VK_RETURN:
if (var li = this.$(>:current)) {
li.postEvent("item-click", evt.keyCode);
return true;
}
break;
}
}
// get/set current record
property current(v) {
get return this.currentRecord;
set {
this.update {currentRecord: v};
this.postEvent("currentchange");
}
}
// this.current = record; + ensures its visibility
function navigateTo(record) {
this.currentRecord = record;
this.postEvent("currentchange");
if(record) {
var recordNo = this.recordset.indexOf(record);
if( recordNo < 0 ) return;
this.scrollToRecord(recordNo);
}
this.update();
}
// get/set recordset
property records(v) {
get return this.recordset;
set {
this.recordset = v || [];
this.scrollToRecord(0);
this.update();
}
}
}
// class component
class VTableBody : VList
{
const styleset = ["vtbody", $url(vlist.css)];
function render() {
var items = [];
var {recordview, recordset, swIndex, swLength} = this;
if((swIndex + swLength) > recordset.length )
swIndex = this.swIndex = Integer.max(0,recordset.length - swLength);
var end = Integer.min(recordset.length,swIndex + swLength);
for( var i = swIndex; i < end; ++i )
items.push( recordview.call(this,recordset[i],i) );
//debug log: swIndex, swLength, end, items.length;
//debug stacktrace;
var attrs = { tabindex: "1"};
if (this.unanimated) attrs.unanimated = true;
return
}
}
class VTable: Reactor.Component
{
const styleset = ["vtable", $url(vlist.css)];
this var vtbody;
this var sortField = undefined; // sorting
this var sortOrder = undefined; // true - ascending, false - descending
this var sortType = "string";
function this(props,kids) {
assert kids[0].tag == #columns; // expect ....
this.columnHeaders = kids[0][1]; // array of 'es
var resort = this.recordset !== props.recordset;
this.recordset = props.recordset ?? []; // recordset
this.recordview = props.recordview ?? kids[1]; // function producing record markup (a.k.a. record view)
this.multiselect = isTruthy(props.multiselect);
this.sortable = isTruthy(props.sortable);
this.unanimated = isTruthy(props.unanimated);
assert typeof this.recordset == #array;
assert typeof this.recordview == #function;
this.recordsForView();
}
function update(state = null) {
if (state) this.extend(state);
this.recordsForView();
this.merge(this.render());
this.vtbody.post(this.vtbody.resetPaddings);
}
function render() {
var atts = {}; if( this.multiselect ) atts.multiselect = true;
return {this.columnHeaders}
recordset={this.sortedset || this.recordset}
multiselect={this.multiselect}
unanimated={this.unanimated}
recordview={this.recordview}
@{this.vtbody} />
;
}
function focusList() {
if (this.vtbody) this.vtbody.post(:: this.state.focus = true);
}
function recordsForView() {
var rs2view = this.recordset;
this.sortedset = null;
if(this.sortField === undefined || this.sortOrder === undefined || !rs2view.length)
return rs2view;
if(!this.sortable)
return rs2view;
this.sortedset = rs2view = rs2view.clone();
var field = this.sortField;
var order = this.sortOrder;
var comparator;
switch (this.sortType) {
case "string":
comparator = order == #asc ? (a,b) => a[field].lexicalCompare(b[field], true)
: (a,b) => b[field].lexicalCompare(a[field], true);
break;
case "date":
comparator = order == #asc ? (a,b) => (a[field] ? a[field].valueOf() : Float.MAX) - (b[field] ? b[field].valueOf() : Float.MAX)
: (a,b) => (b[field] ? b[field].valueOf() : Float.MAX) - (a[field] ? a[field].valueOf() : Float.MAX);
break;
case "boolean":
comparator = order == #asc ? (a,b) => !a[field] && b[field] ? -1 : 0
: (a,b) => !b[field] && a[field] ? -1 : 0;
break;
case "float": case "integer":
comparator = order == #asc ? (a,b) => {
if ((typeof a[field] == #integer && typeof b[field] == #integer) || (a[field].toFloat() && b[field].toFloat()))
return a[field].toFloat() - b[field].toFloat();
else
return a[field].toString().lexicalCompare(b[field].toString(), true);
} : (a,b) => {
if ((typeof a[field] == #integer && typeof b[field] == #integer) || (a[field].toFloat() && b[field].toFloat()))
return b[field].toFloat() - a[field].toFloat();
else
return b[field].toString().lexicalCompare(a[field].toString(), true);
}
break;
default:
return rs2view;
}
rs2view.sort(comparator);
return rs2view;
}
property selectedRecords(v) {
get return this.vtbody?.selectedRecords ?? {};
set this.vtbody.update{ selectedRecords: v };
}
event click $(th[field]) (evt,th)
{
if( !this.sortable )
return false;
var field = th.attributes["field"];
this.sortType = th.attributes["as"] ?? "string";
if(!field) return false;
field = symbol(field);
if( this.sortField != field )
this.sortOrder = #asc;
else
switch(this.sortOrder) {
case #asc : this.sortOrder = #desc; break;
case #desc : this.sortOrder = undefined; break;
default: this.sortOrder = #asc;
}
this.sortField = field;
this.transformColumnHeaders();
this.recordsForView();
this.update();
}
function transformColumnHeaders() {
var field = this.sortField;
for(var header in this.columnHeaders) {
if(header[0].field == field && this.sortOrder !== undefined)
header[0].order = this.sortOrder.toString();
else
header[0].order = "none";
}
}
}
class VTableFiltered: Reactor.Component
{
this var vtable = null; // the
this var filter = null;
this var filterData = {}; // value of , json
this var sortField = undefined; // sorting
this var sortOrder = true; // true - ascending, false - descending
function this(props,kids) {
assert kids[0].tag == #filter; // expect ...
assert kids[1].tag == #columns; // expect ....
this.filterContent = kids[0][1]; // content of , array of vnodes
this.columnsContent = kids[1][1]; // content of "element", array of vnodes
this.stats = props.statsview;
this.filter = props.filter; // filter function
assert typeof this.filter == #function; // record => true/false
this.recordset = props.recordset ?? [], // recordset
this.recordview = props.recordview; // function producing record markup (a.k.a. record view)
this.multiselect = props.multiselect;
assert typeof this.recordset == #array;
assert typeof this.recordview == #function;
this.filteredset = this.recordsForView(); // filtered recordset - these are presented records
}
function render() {
return
{this.filterContent}
multiselect={this.multiselect}
recordview={this.recordview}
@{this.vtable}>
{this.columnsContent}
{this.stats ? this.stats.call(this): []}
}
function recordsForView(filterData = null) {
if(filterData)
this.filterData = filterData;
var rs2view = this.recordset.filter(this.filter,this.filterData);
return rs2view;
}
property selectedRecords(v) {
get return this.vtable?.selectedRecords ?? {};
set this.vtable.selectedRecords = v;
}
event selectionchange {
if( this.stats ) // update stats section
this[2].merge(this.stats.call(this));
}
event change $(form.filter) (evt,form) {
this.update { filteredset: this.recordsForView(form.value) };
}
}