start grid

This commit is contained in:
Tsanie Lily 2023-04-02 23:54:41 +08:00
parent 4e230ed7e7
commit c44aaf5177
8 changed files with 649 additions and 25 deletions

View File

@ -3,6 +3,7 @@ import { createIcon, resolveIcon } from "./ui/icon";
import { createCheckbox, resolveCheckbox } from "./ui/checkbox";
import { setTooltip, resolveTooltip } from "./ui/tooltip";
import Dropdown from "./ui/dropdown";
import Grid from "./ui/grid";
export {
// icon
@ -15,5 +16,7 @@ export {
setTooltip,
resolveTooltip,
// dropdown
Dropdown
Dropdown,
// grid
Grid
}

View File

@ -14,8 +14,7 @@ function fillCheckbox(container, type, label) {
}
}
function createCheckbox(opts) {
opts ??= {};
function createCheckbox(opts = {}) {
const container = document.createElement('label');
container.className = 'checkbox-wrapper';
const input = document.createElement('input');
@ -56,8 +55,7 @@ function createCheckbox(opts) {
return container;
}
function resolveCheckbox(container, legacy) {
container ??= document.body;
function resolveCheckbox(container = document.body, legacy) {
if (legacy) {
const checks = container.querySelectorAll('input[type="checkbox"]');
for (let chk of checks) {

View File

@ -97,8 +97,7 @@ class Dropdown {
onselected;
onexpanded;
constructor(options) {
options ??= {};
constructor(options = {}) {
options.searchplaceholder ??= r('searchHolder', 'Search...');
options.textkey ??= 'text';
options.valuekey ??= 'value';
@ -287,8 +286,7 @@ class Dropdown {
get #expanded() { return this.#container?.style.visibility === 'visible' }
#dropdown(flag) {
flag ??= true;
#dropdown(flag = true) {
const options = this.#options;
const textkey = options.textkey;
let panel = this.#container;
@ -472,8 +470,7 @@ class Dropdown {
}
}
static resolve(dom) {
dom ??= document.body;
static resolve(dom = document.body) {
const selects = dom.querySelectorAll('select');
for (let sel of selects) {
const source = [...sel.children].map(it => {

607
lib/ui/grid.js Normal file
View File

@ -0,0 +1,607 @@
import { isMobile, global, nullOrEmpty, throttle, truncate, isPositive } from "../utility";
import { r } from "../utility/lgres";
import { createIcon } from "../ui/icon";
import { createCheckbox } from "../ui/checkbox";
const ColumnChangedType = {
Reorder: 'reorder',
Resize: 'resize',
Sort: 'sort'
};
const RefreshInterval = isMobile() ? 32 : 0;
const MaxColumnBit = 10;
const MaxColumnMask = 0x3ff;
const RedumCount = 4;
const MiniDragOffset = 4;
const MiniColumnWidth = 50;
class GridColumn {
static create() { return document.createElement('span') }
static setValue(element, val) { element.innerText = val }
static getValue(element) { return element.innerText }
}
class GridInputColumn extends GridColumn {
static createEdit(trigger) {
const input = document.createElement('input');
input.setAttribute('type', 'input');
if (typeof trigger === 'function') {
input.addEventListener('change', trigger);
}
return input;
}
static setValue(element, val) { element.value = val }
static getValue(element) { return element.value }
static setEnabled(element, enabled) { element.disabled = enabled !== false }
}
class GridDropdownColumn extends GridColumn {
}
class GridCheckboxColumn extends GridColumn {
static createEdit(trigger) {
const check = createCheckbox({
onchange: typeof trigger === 'function' ? trigger : null
});
return check;
}
static setValue(element, val) { element.querySelector('input').checked = val }
static getValue(element) { return element.querySelector('input').checked }
static setEnabled(element, enabled) { element.querySelector('input').disabled = enabled !== false }
}
const ColumnTypes = {
0: GridColumn,
1: GridInputColumn,
2: GridDropdownColumn,
3: GridCheckboxColumn
};
class Grid {
#source;
#currentSource;
#parent;
#el;
#refs;
#rendering;
#selectedColumnIndex = -1;
#selectedIndexes;
#startIndex = 0;
#needResize;
#containerHeight;
#bodyClientWidth;
#rowCount = -1;
#overflows;
#scrollTop;
columns = [];
langs = {
all: r('allItem', '( All )'),
ok: r('ok', 'OK'),
reset: r('reset', 'Reset')
};
virtualCount = 100;
rowHeight = 27;
filterRowHeight = 26;
height;
multiSelect = false;
allowHtml = false;
holderDisabled = false;
window = global;
sortIndex = -1;
sortDirection = 'asc';
willSelect;
selectedRowChanged;
cellDblClicked;
cellClicked;
rowDblClicked;
columnChanged;
static ColumnTypes = {
Common: 0,
Input: 1,
Dropdown: 2,
Checkbox: 3
};
constructor(container) {
this.#parent = container;
}
get source() { return this.#source; }
set source(list) {
if (this.#el == null) {
throw new Error('grid has not been initialized.')
}
this.#source = list;
// TODO: filter to currentSource;
this.#currentSource = list;
this.#containerHeight = list.length * this.rowHeight;
this.#overflows = {};
this.#selectedColumnIndex = -1;
this.#selectedIndexes = [];
this.#startIndex = 0;
this.#scrollTop = 0;
this.#refs.body.scrollTop = 0;
this.#refs.bodyContent.style.top = '0px';
this.#refs.bodyContainer.style.height = `${this.#containerHeight}px`;
this.#rowCount = -1;
if (this.sortIndex >= 0) {
this.sortColumn(true);
} else {
this.resize();
}
}
get virtual() { return this.#currentSource?.length > this.virtualCount }
get sortKey() { }
get selectedIndexes() { return this.#selectedIndexes }
set selectedIndexes(indexes) { }
get selectedIndex() { }
get loading() { return this.#refs.loading?.style.visibility === 'visible' }
set loading(flag) {
if (this.#refs.loading == null) {
return;
}
if (flag === false) {
this.#refs.loading.style.visibility = 'hidden';
this.#refs.loading.style.opacity = 0;
} else {
this.#refs.loading.style.visibility = 'visible';
this.#refs.loading.style.opacity = 1;
}
}
get scrollTop() { return this.#refs.body?.scrollTop; }
set scrollTop(top) {
if (this.#refs.body == null) {
return;
}
this.#refs.body.scrollTop = top;
this.reload();
}
init(container = this.#parent) {
this.#el = null;
this.#refs = {};
this.#rendering = true;
this.#currentSource = this.source;
if (!(container instanceof HTMLElement)) {
throw new Error('no specified parent.');
}
this.#parent = container;
const grid = document.createElement('div');
grid.className = 'grid';
grid.setAttribute('tabindex', 0);
grid.addEventListener('keydown', e => {
let index = this.selectedIndex;
let flag = false;
if (e.key === 'ArrowUp') {
// up
flag = true;
if (index > 1) {
delete this.#currentSource[index].__selected;
index -= 1;
} else {
index = 0;
}
} else if (e.key === 'ArrowDown') {
// down
flag = true;
const count = this.#currentSource?.length ?? 0;
if (index < count - 1) {
delete this.#currentSource[index].__selected;
index += 1;
}
}
if (flag) {
this.selectedIndexes = [index];
this.scrollToIndex(index);
this.#currentSource[index].__selected = true;
this.refresh();
if (typeof this.selectedRowChanged === 'function') {
this.selectedRowChanged(index);
}
e.stopPropagation();
}
});
container.replaceChildren(grid);
const sizer = document.createElement('span');
sizer.className = 'grid-sizer';
grid.appendChild(sizer);
this.#refs.sizer = sizer;
// header & body
const header = this.#createHeader();
grid.appendChild(header);
const body = this.#createBody();
grid.appendChild(body);
// loading
const loading = document.createElement('div');
loading.className = 'grid-loading';
loading.appendChild(createIcon('fa-regular', 'spinner-third'));
this.#refs.loading = loading;
grid.appendChild(loading);
this.#el = grid;
this.#rendering = false;
if (this.sortIndex >= 0) {
this.sortColumn(true);
} else {
this.resize();
}
}
scrollToIndex(index) {
this.#scrollToTop(index * this.rowHeight, true);
}
resize(force) {
if (this.#rendering || this.#el == null) {
return;
}
const body = this.#refs.body;
let height = this.#refs.header.offsetHeight + 2;
let top = body.offsetTop;
if (top !== height) {
body.style.top = `${height}px`;
top = height;
}
height = this.height;
if (isNaN(height)) {
height = this.#el.offsetHeight - top;
} else if (height === 0) {
height = this.#refs.bodyContent.offsetHeight;
this.#el.style.height = `${top + height}px`;
}
body.style.height = `${height}px`;
const count = truncate((height - 1) / this.rowHeight) * (RedumCount * 2) + 1;
if (force || count !== this.#rowCount) {
this.#rowCount = count;
this.reload();
}
this.#bodyClientWidth = body.clientWidth;
}
reload() {
this.#containerHeight = this.#currentSource.length * this.rowHeight;
this.#adjustRows(this.#refs.bodyContent);
this.refresh();
}
refresh() {
if (this.#refs.bodyContent == null) {
throw new Error('body has not been created.');
}
const rows = this.#refs.bodyContent.children;
const widths = {};
this.#fillRows(rows, this.columns, widths);
if (this.#needResize && widths.flag) {
this.#needResize = false;
this.columns.forEach((col, i) => {
if (!col.autoResize) {
return;
}
const width = widths[i];
if (width > 0) {
this.#changeColumnWidth(i, width);
}
});
}
}
sortColumn(auto, reload) { }
#createHeader() {
const thead = document.createElement('table');
thead.className = 'grid-header';
const header = document.createElement('tr');
thead.appendChild(header);
const sizer = this.#refs.sizer;
for (let col of this.columns) {
if (col.visible === false) {
const hidden = document.createElement('th');
hidden.style.display = 'none';
if (col.sortable === true) {
hidden.dataset.key = col.key;
hidden.addEventListener('mouseup', e => this.#onHeaderClicked(col, e, true));
}
header.appendChild(hidden);
continue;
}
// style
if (col.width > 0 || col.shrink) {
col.autoResize = false;
} else {
col.autoResize = true;
this.#needResize = true;
sizer.innerText = col.caption;
let width = sizer.offsetWidth + 20;
if (width < MiniColumnWidth) {
width = MiniColumnWidth;
}
col.width = width;
}
col.align ??= 'left';
if (col.sortable !== false) {
col.sortable = true;
}
if (col.shrink) {
col.style = { 'text-align': col.align };
} else {
col.style = {
'width': col.width,
'max-width': col.width,
'min-width': col.width,
'text-align': col.align
};
}
// element
const th = document.createElement('th');
th.dataset.key = col.key;
for (let css of Object.entries(col.style)) {
th.style.setProperty(css[0], css[1]);
}
th.style.cursor = col.sortable ? 'pointer' : 'auto';
th.addEventListener('mouseup', e => this.#onHeaderClicked(col, e));
th.addEventListener('mousedown', e => this.#onDragStart(col, e));
const wrapper = document.createElement('div');
th.appendChild(wrapper);
if (col.enabled !== false && col.allcheck && col.type === Grid.ColumnTypes.Checkbox) {
const check = createCheckbox({
onchange: e => this.#onColumnAllChecked(col, e.target.checked)
});
wrapper.appendChild(check);
}
const caption = document.createElement('span');
caption = col.caption;
wrapper.appendChild(caption);
// order arrow
if (col.sortable) {
const arrow = document.createElement('layer');
arrow.className = 'arrow';
th.appendChild(arrow);
}
// filter
if (col.allowFilter) {
// TODO: filter
}
// resize spliter
if (col.resizable !== false) {
const spliter = document.createElement('layer');
spliter.className = 'spliter';
spliter.addEventListener('mousedown', e => this.#onResizeStart(col, e));
th.appendChild(spliter);
}
// tooltip
!nullOrEmpty(col.tooltip) && th.setAttribute('title', col.tooltip);
header.appendChild(th);
}
const placeholder = document.createElement('th');
const dragger = document.createElement('div');
dragger.className = 'dragger';
const draggerCursor = document.createElement('layer');
draggerCursor.className = 'dragger-cursor';
placeholder.append(dragger, draggerCursor);
header.appendChild(placeholder);
sizer.replaceChildren();
this.#refs.header = header;
this.#refs.dragger = dragger;
this.#refs.draggerCursor = draggerCursor;
return thead;
}
#createBody() {
const body = document.createElement('div');
body.className = 'grid-body';
body.addEventListener('scroll', e => throttle(this.#onScroll, RefreshInterval, this, e), { passive: true });
const cols = this.columns;
let height = this.#currentSource.length * this.rowHeight;
let width;
if (height === 0) {
height = 1;
width = 0;
for (let col of cols) {
if (col.visible !== false && !isNaN(col.width)) {
width += col.width + 1;
}
}
width += 1;
}
this.#containerHeight = height;
// body container
const bodyContainer = document.createElement('div');
bodyContainer.style.position = 'relative';
bodyContainer.style.minWidth = '100%';
bodyContainer.style.minHeight = '1px';
bodyContainer.style.height = `${height}px`;
if (width > 0) {
bodyContainer.style.width = `${width}px`;
}
body.appendChild(bodyContainer);
// body content
const bodyContent = document.createElement('table');
bodyContent.className = 'grid-body-content';
bodyContainer.appendChild(bodyContent);
this.#adjustRows();
// events
if (!this.holderDisabled) {
const holder = document.createElement('div');
holder.className = 'grid-hover-holder';
holder.style.display = 'none';
bodyContainer.appendChild(holder);
body.addEventListener('mousemove', e => throttle(this.#onBodyMouseMove, RefreshInterval, this, e, holder));
}
this.#refs.body = body;
this.#refs.bodyContainer = bodyContainer;
this.#refs.bodyContent = bodyContent;
// this.refresh();
return body;
}
#adjustRows() {
let count = this.#rowCount;
if (isNaN(count) || count < 0 || !this.virtual) {
count = this.#currentSource.length;
}
const cols = this.columns;
const content = this.#refs.bodyContent;
const exists = content.children.length;
count -= exists;
if (count > 0) {
for (let i = 0; i < count; i += 1) {
const row = document.createElement('tr');
row.className = 'grid-row';
row.addEventListener('mousedown', e => this.#onRowClicked(e, exists + 1));
row.addEventListener('dblclick', e => this.#onRowDblClicked(e));
cols.forEach((col, j) => {
const cell = document.createElement('td');
if (col.visible !== false) {
cell.keyid = ((exists + i) << MaxColumnBit) | j;
if (col.style != null) {
for (let css of Object.entries(col.style)) {
cell.style.setProperty(css[0], css[1]);
}
}
if (col.css != null) {
for (let css of Object.entries(col.css)) {
cell.style.setProperty(css[0], css[1]);
}
}
if (col.type === Grid.ColumnTypes.Checkbox) {
cell.appendChild(GridCheckboxColumn.createEdit(e => this.#onRowChanged(e, exists + i, col, e.target.checked)));
} else if (this.allowHtml && col.type != null && isNaN(col.type)) {
cell.appendChild(col.type.create());
} else {
cell.appendChild(GridColumn.create());
}
}
row.appendChild(cell);
});
row.appendChild(document.createElement('td'));
content.appendChild(row);
}
} else if (count < 0) {
for (let i = -1; i >= count; i -= 1) {
// content.removeChild(content.children[exists + i]);
content.children[exists + i].remove();
}
}
}
#fillRows(rows, cols, widths) {
const startIndex = this.startIndex;
const selected = this.#selectedIndexes;
const allowHtml = this.allowHtml;
rows.forEach((row, i) => {
const vals = this.#currentSource[startIndex + i];
if (vals == null) {
return;
}
if (!isPositive(row.children.length)) {
return;
}
const item = vals.values;
if (selected.indexOf(startIndex + i) < 0) {
row.classList.remove('selected');
} else {
row.classList.add('selected');
}
// data
const selected = row.dataset.selected === '1';
cols.forEach((col, j) => {
if (col.visible === false) {
return;
}
let val;
if (col.text != null) {
val = col.text;
} else if (typeof col.filter === 'function') {
val = col.filter(item);
} else {
val = item[col.key];
if (val?.displayValue != null) {
val = val.displayValue;
}
}
val ??= '';
// fill
const cell = row.children[j];
const custom = allowHtml && col.type != null && isNaN(col.type);
let element;
if (vals.__selected ^ selected) {
if (custom) {
element = selected ?
col.type.createEdit(e => this.#onRowChanged(e, startIndex + i, col, col.type.getValue(element))) :
col.type.create();
cell.replaceChildren(element);
// } else if (col.type !== Grid.ColumnTypes.Checkbox) {
// // TODO:
} else {
element = cell.children[0];
}
} else {
element = cell.children[0];
}
let enabled = col.enabled;
if (typeof enabled === 'string') {
enabled = item[enabled];
}
if (custom) {
col.type.setValue(element, item);
col.type.setEnabled(element, enabled);
} else if (col.type === Grid.ColumnTypes.Checkbox) {
GridCheckboxColumn.setValue(element, val);
GridCheckboxColumn.setEnabled(element, enabled);
} else {
// TODO: input, dropdown, etc...
GridColumn.setValue(element, val);
}
})
});
}
#changeColumnWidth(i, width) { }
#scrollToTop(top, reload) { }
#onHeaderClicked(col, e, force) { }
#onDragStart(col, e) { }
#onResizeStart(col, e) { }
#onColumnAllChecked(col, flag) { }
#onScroll(e) { }
#onBodyMouseMove(e, holder) { }
#onRowClicked(e, index, colIndex) { }
#onRowDblClicked(e) { }
#onRowChanged(_e, index, col, value) {
if (this.#currentSource == null) {
return;
}
const item = this.#currentSource[this.#startIndex + index].values;
if (item == null) {
return;
}
const enabled = typeof col.enabled === 'string' ? item[col.enabled] : col.enabled;
if (enabled !== false) {
item[col.key] = value;
item.__changed = true;
if (typeof col.onchanged === 'function') {
col.onchanged.call(this, item, value);
}
}
}
}
export default Grid;

View File

@ -48,8 +48,7 @@ function setTooltip(container, content) {
});
}
function resolveTooltip(container) {
container ??= document.body;
function resolveTooltip(container = document.body) {
const tips = container.querySelectorAll('[title]');
for (let tip of tips) {
const title = tip.getAttribute('title');

View File

@ -9,6 +9,28 @@ function isPositive(n) {
return !isNaN(n) && n > 0;
}
function isMobile() {
return /mobile/i.test(navigator.userAgent);
}
function throttle(method, delay = 100, context = g, ...args) {
if (method == null) {
return;
}
method.tiid && clearTimeout(method.tiid);
const current = new Date();
if (method.tdate == null || current - method.tdate > delay) {
method.apply(context, args);
method.tdate = current;
} else {
method.tiid = setTimeout(() => method.apply(context, args), delay);
}
}
function truncate(v) {
return (v > 0 ? Math.floor : Math.ceil)(v);
}
export {
// cookie
getCookie,
@ -29,5 +51,9 @@ export {
padStart,
// variables
g as global,
isPositive
isPositive,
isMobile,
// functions
throttle,
truncate
}

View File

@ -45,8 +45,7 @@ function getStorageKey(lgid) {
return `res_${lgid}`;
}
async function doRefreshLgres(template) {
template ??= '';
async function doRefreshLgres(template = '') {
const lgid = getCurrentLgId();
const r = await get(`language/${lgid}${template}`);
const dict = await r.json();
@ -81,7 +80,6 @@ function getLanguage(lgres, key, defaultValue) {
}
function applyLanguage(dom, result) {
dom ??= document.body;
for (let text of dom.querySelectorAll('[data-lgid]')) {
const key = text.dataset.lgid;
if (text.tagName === 'INPUT') {
@ -100,8 +98,7 @@ function applyLanguage(dom, result) {
}
}
async function init(dom, options) {
options ??= {};
async function init(dom = document.body, options = {}) {
const lgid = getCurrentLgId();
let lgres = localStorage.getItem(getStorageKey(lgid));
let result;

View File

@ -8,8 +8,7 @@ function combineUrl(url) {
return (consts.path || '') + url;
}
function get(url, options) {
options ??= {};
function get(url, options = {}) {
return fetch(combineUrl(url), {
method: options.method || 'GET',
headers: {
@ -21,8 +20,7 @@ function get(url, options) {
});
}
function post(url, data, options) {
options ??= {};
function post(url, data, options = {}) {
// let contentType;
if (data instanceof FormData) {
// contentType = 'multipart/form-data';
@ -47,7 +45,7 @@ function post(url, data, options) {
});
}
function upload(url, data, options) {
function upload(url, data, options = {}) {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.onreadystatechange = function () {
@ -59,7 +57,6 @@ function upload(url, data, options) {
}
}
};
options ??= {};
if (typeof options.progress === 'function') {
request.upload.addEventListener('progress', function (ev) {
if (ev.lengthComputable) {