Philosophical Alignment: Purged black-box patches and aligned with state v2.3 foundation. By: AICoder
This commit is contained in:
parent
b330c641be
commit
cec22a01c8
228
dist/base.js
vendored
228
dist/base.js
vendored
@ -128,10 +128,14 @@
|
||||
<div class="modal fade" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div $class="modal-content border-\${this.state?.type || 'primary'} border-2 shadow-lg">
|
||||
<div $if="this.state?.title" $class="modal-header py-2 px-3 bg-light fw-bold text-\${this.state?.type || 'primary'}" $text="this.state?.title"></div>
|
||||
<template $if="this.state?.title">
|
||||
<div $class="modal-header py-2 px-3 bg-light fw-bold text-\${this.state?.type || 'primary'}" $text="this.state?.title"></div>
|
||||
</template>
|
||||
<div slot-id="body" class="modal-body p-4"><div $html="this.state?.message"></div></div>
|
||||
<div class="modal-footer py-2 px-3 bg-light">
|
||||
<button $each="this.state?.buttons || ['{#Close#}']" type="button" $class="btn btn-sm px-3 btn-\${index === (this.state?.buttons || []).length - 1 ? (this.state?.type && this.state?.type !== 'body' ? this.state?.type : 'primary') : 'outline-secondary border'}" $onclick="this.result=index+1;this.hide()" $text="\${item}"></button>
|
||||
<template $each="this.state?.buttons || ['{#Close#}']">
|
||||
<button type="button" $class="btn btn-sm px-3 btn-\${index === (this.state?.buttons || []).length - 1 ? (this.state?.type && this.state?.type !== 'body' ? this.state?.type : 'primary') : 'outline-secondary border'}" $onclick="this.result=index+1;this.hide()" $text="\${item}"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -188,12 +192,16 @@
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<span style="white-space:pre-wrap" class="fs-6" $text="this.state?.message"></span>
|
||||
<span $if="this.state?.left !== undefined" class="small text-dim ms-2" $text="\${this.state?.left}s"></span>
|
||||
<template $if="this.state?.left !== undefined">
|
||||
<span class="small text-dim ms-2" $text="\${this.state?.left}s"></span>
|
||||
</template>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link ms-3 bi bi-x-lg link-reset" style="color:inherit" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end gap-3">
|
||||
<button $each="this.state?.buttons || ['{#Close#}']" type="button" $class="btn btn-sm btn-\${this.state?.type} mt-2" data-bs-dismiss="toast" $onclick="this.result=index+1" $text="item"></button>
|
||||
<template $each="this.state?.buttons || ['{#Close#}']">
|
||||
<button type="button" $class="btn btn-sm btn-\${this.state?.type} mt-2" data-bs-dismiss="toast" $onclick="this.result=index+1" $text="item"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -214,20 +222,22 @@
|
||||
};
|
||||
state.Component.register("AutoForm", (container) => {
|
||||
if (!container.state.schema) container.state.schema = [];
|
||||
const ensureProxy = (v) => v && typeof v === "object" && !v.__isProxy ? state.NewState(v) : v;
|
||||
container.state.__watch("data", (v) => container.data = ensureProxy(v));
|
||||
container.data = ensureProxy(container.state.data || {});
|
||||
container.vertical = container.hasAttribute("vertical");
|
||||
container.inline = container.hasAttribute("inline");
|
||||
container.nobutton = container.hasAttribute("nobutton");
|
||||
container.request = { method: "POST" };
|
||||
container.response = {};
|
||||
container.result = null;
|
||||
Object.defineProperty(container, "data", {
|
||||
get: () => container.state.data,
|
||||
set: (v) => {
|
||||
container.state.data = (v == null ? void 0 : v.__isProxy) ? v : state.NewState(v || {});
|
||||
}
|
||||
});
|
||||
container.data = container.data || {};
|
||||
container._thisObj = container;
|
||||
if (container.inline) {
|
||||
state.State.__watch("editingData", (data) => {
|
||||
container.data = data;
|
||||
});
|
||||
state.State.__watch("editingSchema", (schema) => {
|
||||
container.state.schema = schema;
|
||||
});
|
||||
}
|
||||
container.form = state.$(container, "form");
|
||||
container.submit = (opt = {}) => {
|
||||
var _a, _b;
|
||||
@ -253,33 +263,64 @@
|
||||
/*html*/
|
||||
`
|
||||
<div $class="auto-form-root d-flex \${this.inline ? 'auto-form-inline h-100 w-100' : ''}">
|
||||
<form $class="w-100 \${this.inline ? 'd-flex align-items-stretch flex-fill h-100' : (this.vertical ? 'd-flex flex-column flex-fill' : 'auto-grid-form')}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
|
||||
<template $each="this.state.schema || []" as="item">
|
||||
<div $$if="item.if || 'true'" style="display:contents">
|
||||
<label $if="!this.inline" $name="item.name" class="col-form-label text-muted" $text="item.label"></label>
|
||||
<div control-wrapper $class="\${this.inline ? 'flex-grow-1 h-100 d-flex align-items-center' : 'mb-3'}">
|
||||
<input $if="['text', 'password', 'email', 'number', 'date', 'datetime', 'file'].includes(item.type)" $name="item.name" $type="item.type" $.="item.setting || {}" $bind="this.data[item.name]" $class="form-control h-100 \${item.type === 'number' ? 'text-end' : ''}">
|
||||
<select $if="item.type === 'select'" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="form-select h-100">
|
||||
<option value="" $if="item.placeholder" $text="item.placeholder" disabled></option>
|
||||
<option $each="item.options" $value="item.value || item" $text="item.label || item"></option>
|
||||
</select>
|
||||
<div $if="['checkbox', 'radio'].includes(item.type)" $class="\${this.inline ? 'h-100 d-flex align-items-center px-2 gap-3 justify-content-center w-100 bg-body' : ''}" style="white-space:nowrap; border-radius: 4px;">
|
||||
<label $each="item.options || [item.text||item.label||item.name]" as="option" $class="form-check \${item.vertical ? '' : 'form-check-inline'} mb-0 d-flex align-items-center p-0">
|
||||
<input $name="item.name" class="form-check-input me-1" $type="item.type" $.="item.setting || {}" $value="item.options?option:'on'" $bind="this.data[item.name]">
|
||||
<span $if="!this.inline || (item.options && item.options.length > 0)" $text="option" class="form-check-label"></span>
|
||||
</label>
|
||||
<form $class="w-100 d-flex flex-fill \${this.inline ? 'align-items-stretch h-100' : (this.vertical ? 'flex-column' : 'auto-grid-form')}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
|
||||
<template $each="this.state.schema || []">
|
||||
<template $if="item.if || 'true'">
|
||||
<div style="display:contents">
|
||||
<template $if="!this.inline">
|
||||
<label $name="item.name" class="col-form-label text-muted" $text="item.label"></label>
|
||||
</template>
|
||||
<div control-wrapper $class="\${this.inline ? 'flex-grow-1 h-100 d-flex align-items-center' : 'mb-3'}">
|
||||
<template $if="['text', 'password', 'email', 'number', 'date', 'datetime', 'file'].includes(item.type)">
|
||||
<input $name="item.name" $type="item.type" $.="item.setting || {}" $bind="this.data[item.name]" $class="form-control h-100 \${item.type === 'number' ? 'text-end' : ''}">
|
||||
</template>
|
||||
<template $if="item.type === 'select'">
|
||||
<select $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="form-select h-100">
|
||||
<template $if="item.placeholder">
|
||||
<option value="" $text="item.placeholder" disabled selected></option>
|
||||
</template>
|
||||
<template $each="item.options" as="opt">
|
||||
<option $value="opt.value || opt" $text="opt.label || opt"></option>
|
||||
</template>
|
||||
</select>
|
||||
</template>
|
||||
<template $if="['checkbox', 'radio'].includes(item.type)">
|
||||
<div $class="\${this.inline ? 'h-100 d-flex align-items-center px-2 gap-3 justify-content-center w-100 bg-body' : ''}" style="white-space:nowrap; border-radius: 4px;">
|
||||
<template $each="item.options || [item.text||item.label||item.name]" as="option">
|
||||
<label $class="form-check \${item.vertical ? '' : 'form-check-inline'} mb-0 d-flex align-items-center p-0">
|
||||
<input $name="item.name" class="form-check-input me-1" $type="item.type" $.="item.setting || {}" $value="item.options?option:'on'" $bind="this.data[item.name]">
|
||||
<template $if="!this.inline || (item.options && item.options.length > 0)">
|
||||
<span $text="option" class="form-check-label"></span>
|
||||
</template>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template $if="item.type === 'switch'">
|
||||
<div $class="form-check form-switch fs-4 h-100 d-flex align-items-center px-2 m-0 \${this.inline ? 'justify-content-center w-100' : ''}" style="padding-left:0">
|
||||
<input $name="item.name" class="form-check-input m-0" type="checkbox" style="cursor:pointer" $bind="this.data[item.name]">
|
||||
</div>
|
||||
</template>
|
||||
<template $if="item.type === 'textarea'">
|
||||
<textarea $name="item.name" class="form-control" $.="item.setting || {}" $bind="this.data[item.name]"></textarea>
|
||||
</template>
|
||||
|
||||
<!-- 扩展控件容器 -->
|
||||
<template $each="AutoForm.customTypes" as="ext">
|
||||
<template $if="item.type?.toLowerCase() === ext.typeName.toLowerCase()">
|
||||
<div $is="ext.name" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="h-100"></div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<div $if="item.type === 'switch'" $class="form-check form-switch fs-4 h-100 d-flex align-items-center px-2 m-0 \${this.inline ? 'justify-content-center w-100' : ''}" style="padding-left:0">
|
||||
<input $name="item.name" class="form-check-input m-0" type="checkbox" style="cursor:pointer" $bind="this.data[item.name]" $onchange="event.stopPropagation()">
|
||||
</div>
|
||||
<textarea $if="item.type === 'textarea'" $name="item.name" class="form-control" $.="item.setting || {}" $bind="this.data[item.name]"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template $if="!this.inline">
|
||||
<div class="d-flex justify-content-end align-items-baseline gap-3 mt-3" style="grid-column:1/-1">
|
||||
<div slot-id="actions"></div>
|
||||
<button type="submit" class="btn btn-primary" $text="this.submitlabel || '{#Submit#}'"></button>
|
||||
</div>
|
||||
</template>
|
||||
<div $if="!this.inline && !this.nobutton" class="d-flex justify-content-end align-items-baseline gap-3 mt-3" style="grid-column:1/-1">
|
||||
<div slot-id="actions"></div>
|
||||
<button type="submit" class="btn btn-primary" $text="this.submitlabel || '{#Submit#}'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`
|
||||
@ -294,38 +335,15 @@
|
||||
.auto-form-inline textarea.form-control { padding: 8px; min-height: 100px; height: auto; }
|
||||
</style>`
|
||||
));
|
||||
const _pendingAutoFormComponents = [];
|
||||
const AutoForm = {
|
||||
customTypes: state.NewState([]),
|
||||
register: (name, typeName) => {
|
||||
const entry = { name, typeName: typeName || name };
|
||||
if (typeof document !== "undefined") {
|
||||
if (document.readyState !== "loading" && state.Component.getTemplate("AutoForm")) AutoForm._addAutoFormComponent(entry);
|
||||
else _pendingAutoFormComponents.push(entry);
|
||||
}
|
||||
},
|
||||
_addAutoFormComponent: (entry) => {
|
||||
const { name, typeName } = entry;
|
||||
const template = state.Component.getTemplate("AutoForm");
|
||||
if (template) {
|
||||
let container = state.$(template.content, "[control-wrapper]");
|
||||
if (!container) {
|
||||
const nested = state.$(template.content, "template");
|
||||
if (nested) container = state.$(nested.content, "[control-wrapper]");
|
||||
}
|
||||
if (container && !container.querySelector(name)) {
|
||||
container.appendChild(state.Util.makeDom(`<${name} $if="item.type?.toLowerCase() === '${typeName.toLowerCase()}'" $name="item.name" $.="item.setting || {}" $bind="thisNode.closest('AutoForm').data[item.name]"></${name}>`));
|
||||
}
|
||||
const type = typeName || name;
|
||||
if (!AutoForm.customTypes.find((t) => t.name === name)) {
|
||||
AutoForm.customTypes.push({ name, typeName: type });
|
||||
}
|
||||
}
|
||||
};
|
||||
if (typeof document !== "undefined") {
|
||||
const initAutoForm = () => {
|
||||
_pendingAutoFormComponents.forEach((entry) => AutoForm._addAutoFormComponent(entry));
|
||||
_pendingAutoFormComponents.length = 0;
|
||||
};
|
||||
if (document.readyState !== "loading") setTimeout(initAutoForm, 100);
|
||||
else document.addEventListener("DOMContentLoaded", () => setTimeout(initAutoForm, 100), true);
|
||||
}
|
||||
state.Component.register("TagsInput", (container) => {
|
||||
container.state = state.NewState({ tags: [] });
|
||||
container.addEventListener("bind", (e) => {
|
||||
@ -762,17 +780,27 @@
|
||||
<template slot-id="item" $each="this.state?._renderedList">
|
||||
<div $onupdate="this.onItemUpdate(index, thisNode)" $class="list-group-item list-group-item-action d-inline-flex align-items-center ps-2 pe-2 \${item.type==='group'?(this.state?.selectedGroup===item[this.groupidfield]?'active':''):(this.state?.selectedItem===item[this.idfield]?'active':'')}" $onclick="item.type==='group'?this.selectGroup(item,index):this.selectItem(item,index)">
|
||||
<template $if="item.type === 'group'">
|
||||
<span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span>
|
||||
<template $if="this.groupicon">
|
||||
<span $class="bi bi-\${this.groupicon} text-body"></span>
|
||||
</template>
|
||||
<div class="flex-shrink-0 px-1" $text="\${item[this.grouplabelfield]}"></div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="\${item[this.groupsummaryfield]}"></div>
|
||||
<div slot-id="group-actions"></div>
|
||||
</template>
|
||||
<template $if="item.type === 'item'">
|
||||
<div $if="this.mode === 'tree'" $style="width:\${item._level * 16 + (this.collapsible ? 16 : 0)}px; cursor:\${this.collapsible ? 'pointer' : 'default'}" class="text-end text-muted flex-shrink-0" $onclick="event.stopPropagation(); this.toggleCollapse(item)">
|
||||
<i $if="this.collapsible && item._hasChildren" $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
|
||||
</div>
|
||||
<span $if="this.mode === 'tree'" $class="text-muted bi bi-\${item._hasChildren ? this.groupicon : this.itemicon}"></span>
|
||||
<span $if="this.mode !== 'tree' && this.itemicon" class="bi bi-\${this.itemicon} text-body"></span>
|
||||
<template $if="this.mode === 'tree'">
|
||||
<div $style="width:\${item._level * 16 + (this.collapsible ? 16 : 0)}px; cursor:\${this.collapsible ? 'pointer' : 'default'}" class="text-end text-muted flex-shrink-0" $onclick="event.stopPropagation(); this.toggleCollapse(item)">
|
||||
<template $if="this.collapsible && item._hasChildren">
|
||||
<i $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template $if="this.mode === 'tree'">
|
||||
<span $class="text-muted bi bi-\${item._hasChildren ? this.groupicon : this.itemicon}"></span>
|
||||
</template>
|
||||
<template $if="this.mode !== 'tree' && this.itemicon">
|
||||
<span $class="bi bi-\${this.itemicon} text-body"></span>
|
||||
</template>
|
||||
<div class="flex-shrink-0 px-1" $text="\${item[this.labelfield]}"></div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="\${item[this.summaryfield]}"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
@ -793,32 +821,47 @@
|
||||
/*html*/
|
||||
`
|
||||
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-3 \${this.vertical ? 'py-3' : 'pb-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}">
|
||||
<img $if="this.state.brand.image" $src="this.state.brand.image" $class="\${this.vertical ? 'mb-4' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
|
||||
<i $if="this.state.brand.icon" $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-4' : 'me-2'}"></i>
|
||||
<span $if="this.state.brand.label" $class="\${this.vertical ? 'mb-4 fw-bold' : 'me-2'}" $text="this.state.brand.label"></span>
|
||||
<template $if="this.state?.brand?.image">
|
||||
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-4' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
|
||||
</template>
|
||||
<template $if="this.state?.brand?.icon">
|
||||
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-4' : 'me-2'}"></i>
|
||||
</template>
|
||||
<template $if="this.state?.brand?.label">
|
||||
<span $class="\${this.vertical ? 'mb-4 fw-bold' : 'me-2'}" $text="this.state.brand.label"></span>
|
||||
</template>
|
||||
<div $class="\${this.vertical ? 'w-100' : 'ms-2'}"></div>
|
||||
<div $each="this.state.list" $class="\${this.vertical ? 'nav nav-pills flex-column w-100' : 'navbar-nav'} text-truncate \${item.type==='fill'?'flex-fill':''}">
|
||||
<button $if="item.type==='button'" $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" $onclick="this.click(item)">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state.list.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||
</button>
|
||||
<div $if="item.type==='dropdown'" $class="dropdown \${this.vertical ? 'w-100' : ''}">
|
||||
<button $class="nav-link \${Hash.nav===item.name?'active':''} w-100 \${this.vertical ? 'text-start d-flex justify-content-between align-items-center' : ''}" data-bs-toggle="dropdown">
|
||||
<span><i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state.list.length>5?'lg':'md') + '-inline'}" $text="item.label"></span></span>
|
||||
<i $if="this.vertical" class="bi bi-chevron-right small"></i>
|
||||
</button>
|
||||
<div $class="dropdown-menu \${this.vertical ? 'position-static border-0 bg-transparent shadow-none ps-3' : 'dropdown-menu-end p-3 bg-body-secondary shadow'}" $style="width: \${this.vertical ? '100%' : (item.width || 250) + 'px'};">
|
||||
<template $each="item.list" as="subitem">
|
||||
<button $if="subitem.type==='button'" class="nav-link px-0 w-100 text-start" $onclick="this.click(subitem, true)">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
|
||||
<template $each="this.state?.list || []">
|
||||
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100' : 'navbar-nav'} text-truncate \${item.type==='fill'?'flex-fill':''}">
|
||||
<template $if="item.type==='button'">
|
||||
<button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" $onclick="this.click(item)">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template $if="item.type==='dropdown'">
|
||||
<div class="dropdown">
|
||||
<button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" data-bs-toggle="dropdown">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||
</button>
|
||||
<div $if="subitem.type==='switch'" class="d-flex align-items-center">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
|
||||
<div class="form-switch"><input class="form-check-input mx-0" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
<div $class="dropdown-menu \${this.vertical?'position-static':'dropdown-menu-end'} p-3 bg-body-secondary shadow" $style="width: \${item.width || 250}px;">
|
||||
<template $each="item.list" as="subitem">
|
||||
<template $if="subitem.type==='button'">
|
||||
<button class="nav-link px-0 w-100 text-start" $onclick="this.click(subitem, true)">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template $if="subitem.type==='switch'">
|
||||
<div class="d-flex align-items-center">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
|
||||
<div class="form-switch"><input class="form-check-input mx-0" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
`
|
||||
));
|
||||
@ -902,7 +945,8 @@
|
||||
AutoForm,
|
||||
MouseMover,
|
||||
VirtualScroll,
|
||||
State: state.State
|
||||
State: state.State,
|
||||
List: VirtualScroll
|
||||
};
|
||||
if (typeof document !== "undefined") {
|
||||
globalThis.ApigoBase = ApigoBase;
|
||||
|
||||
2
dist/base.min.js
vendored
2
dist/base.min.js
vendored
File diff suppressed because one or more lines are too long
2
dist/base.min.mjs
vendored
2
dist/base.min.mjs
vendored
File diff suppressed because one or more lines are too long
230
dist/base.mjs
vendored
230
dist/base.mjs
vendored
@ -1,4 +1,4 @@
|
||||
import { Component, NewState, Util, $, Hash, State, RefreshState } from "@apigo.cc/state";
|
||||
import { Component, NewState, Util, $, State, Hash, RefreshState } from "@apigo.cc/state";
|
||||
import { State as State2 } from "@apigo.cc/state";
|
||||
import "@apigo.cc/bootstrap";
|
||||
const HTTP = {
|
||||
@ -127,10 +127,14 @@ Component.register("Dialog", Component.getSetupFunction("Modal"), Util.makeDom(
|
||||
<div class="modal fade" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div $class="modal-content border-\${this.state?.type || 'primary'} border-2 shadow-lg">
|
||||
<div $if="this.state?.title" $class="modal-header py-2 px-3 bg-light fw-bold text-\${this.state?.type || 'primary'}" $text="this.state?.title"></div>
|
||||
<template $if="this.state?.title">
|
||||
<div $class="modal-header py-2 px-3 bg-light fw-bold text-\${this.state?.type || 'primary'}" $text="this.state?.title"></div>
|
||||
</template>
|
||||
<div slot-id="body" class="modal-body p-4"><div $html="this.state?.message"></div></div>
|
||||
<div class="modal-footer py-2 px-3 bg-light">
|
||||
<button $each="this.state?.buttons || ['{#Close#}']" type="button" $class="btn btn-sm px-3 btn-\${index === (this.state?.buttons || []).length - 1 ? (this.state?.type && this.state?.type !== 'body' ? this.state?.type : 'primary') : 'outline-secondary border'}" $onclick="this.result=index+1;this.hide()" $text="\${item}"></button>
|
||||
<template $each="this.state?.buttons || ['{#Close#}']">
|
||||
<button type="button" $class="btn btn-sm px-3 btn-\${index === (this.state?.buttons || []).length - 1 ? (this.state?.type && this.state?.type !== 'body' ? this.state?.type : 'primary') : 'outline-secondary border'}" $onclick="this.result=index+1;this.hide()" $text="\${item}"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -187,12 +191,16 @@ Component.register("Toast", (container) => {
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<span style="white-space:pre-wrap" class="fs-6" $text="this.state?.message"></span>
|
||||
<span $if="this.state?.left !== undefined" class="small text-dim ms-2" $text="\${this.state?.left}s"></span>
|
||||
<template $if="this.state?.left !== undefined">
|
||||
<span class="small text-dim ms-2" $text="\${this.state?.left}s"></span>
|
||||
</template>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link ms-3 bi bi-x-lg link-reset" style="color:inherit" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end gap-3">
|
||||
<button $each="this.state?.buttons || ['{#Close#}']" type="button" $class="btn btn-sm btn-\${this.state?.type} mt-2" data-bs-dismiss="toast" $onclick="this.result=index+1" $text="item"></button>
|
||||
<template $each="this.state?.buttons || ['{#Close#}']">
|
||||
<button type="button" $class="btn btn-sm btn-\${this.state?.type} mt-2" data-bs-dismiss="toast" $onclick="this.result=index+1" $text="item"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -213,20 +221,22 @@ UI$1.toastConfirm = function(message, options = {}) {
|
||||
};
|
||||
Component.register("AutoForm", (container) => {
|
||||
if (!container.state.schema) container.state.schema = [];
|
||||
const ensureProxy = (v) => v && typeof v === "object" && !v.__isProxy ? NewState(v) : v;
|
||||
container.state.__watch("data", (v) => container.data = ensureProxy(v));
|
||||
container.data = ensureProxy(container.state.data || {});
|
||||
container.vertical = container.hasAttribute("vertical");
|
||||
container.inline = container.hasAttribute("inline");
|
||||
container.nobutton = container.hasAttribute("nobutton");
|
||||
container.request = { method: "POST" };
|
||||
container.response = {};
|
||||
container.result = null;
|
||||
Object.defineProperty(container, "data", {
|
||||
get: () => container.state.data,
|
||||
set: (v) => {
|
||||
container.state.data = (v == null ? void 0 : v.__isProxy) ? v : NewState(v || {});
|
||||
}
|
||||
});
|
||||
container.data = container.data || {};
|
||||
container._thisObj = container;
|
||||
if (container.inline) {
|
||||
State.__watch("editingData", (data) => {
|
||||
container.data = data;
|
||||
});
|
||||
State.__watch("editingSchema", (schema) => {
|
||||
container.state.schema = schema;
|
||||
});
|
||||
}
|
||||
container.form = $(container, "form");
|
||||
container.submit = (opt = {}) => {
|
||||
var _a, _b;
|
||||
@ -252,33 +262,64 @@ Component.register("AutoForm", (container) => {
|
||||
/*html*/
|
||||
`
|
||||
<div $class="auto-form-root d-flex \${this.inline ? 'auto-form-inline h-100 w-100' : ''}">
|
||||
<form $class="w-100 \${this.inline ? 'd-flex align-items-stretch flex-fill h-100' : (this.vertical ? 'd-flex flex-column flex-fill' : 'auto-grid-form')}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
|
||||
<template $each="this.state.schema || []" as="item">
|
||||
<div $$if="item.if || 'true'" style="display:contents">
|
||||
<label $if="!this.inline" $name="item.name" class="col-form-label text-muted" $text="item.label"></label>
|
||||
<div control-wrapper $class="\${this.inline ? 'flex-grow-1 h-100 d-flex align-items-center' : 'mb-3'}">
|
||||
<input $if="['text', 'password', 'email', 'number', 'date', 'datetime', 'file'].includes(item.type)" $name="item.name" $type="item.type" $.="item.setting || {}" $bind="this.data[item.name]" $class="form-control h-100 \${item.type === 'number' ? 'text-end' : ''}">
|
||||
<select $if="item.type === 'select'" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="form-select h-100">
|
||||
<option value="" $if="item.placeholder" $text="item.placeholder" disabled></option>
|
||||
<option $each="item.options" $value="item.value || item" $text="item.label || item"></option>
|
||||
</select>
|
||||
<div $if="['checkbox', 'radio'].includes(item.type)" $class="\${this.inline ? 'h-100 d-flex align-items-center px-2 gap-3 justify-content-center w-100 bg-body' : ''}" style="white-space:nowrap; border-radius: 4px;">
|
||||
<label $each="item.options || [item.text||item.label||item.name]" as="option" $class="form-check \${item.vertical ? '' : 'form-check-inline'} mb-0 d-flex align-items-center p-0">
|
||||
<input $name="item.name" class="form-check-input me-1" $type="item.type" $.="item.setting || {}" $value="item.options?option:'on'" $bind="this.data[item.name]">
|
||||
<span $if="!this.inline || (item.options && item.options.length > 0)" $text="option" class="form-check-label"></span>
|
||||
</label>
|
||||
<form $class="w-100 d-flex flex-fill \${this.inline ? 'align-items-stretch h-100' : (this.vertical ? 'flex-column' : 'auto-grid-form')}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
|
||||
<template $each="this.state.schema || []">
|
||||
<template $if="item.if || 'true'">
|
||||
<div style="display:contents">
|
||||
<template $if="!this.inline">
|
||||
<label $name="item.name" class="col-form-label text-muted" $text="item.label"></label>
|
||||
</template>
|
||||
<div control-wrapper $class="\${this.inline ? 'flex-grow-1 h-100 d-flex align-items-center' : 'mb-3'}">
|
||||
<template $if="['text', 'password', 'email', 'number', 'date', 'datetime', 'file'].includes(item.type)">
|
||||
<input $name="item.name" $type="item.type" $.="item.setting || {}" $bind="this.data[item.name]" $class="form-control h-100 \${item.type === 'number' ? 'text-end' : ''}">
|
||||
</template>
|
||||
<template $if="item.type === 'select'">
|
||||
<select $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="form-select h-100">
|
||||
<template $if="item.placeholder">
|
||||
<option value="" $text="item.placeholder" disabled selected></option>
|
||||
</template>
|
||||
<template $each="item.options" as="opt">
|
||||
<option $value="opt.value || opt" $text="opt.label || opt"></option>
|
||||
</template>
|
||||
</select>
|
||||
</template>
|
||||
<template $if="['checkbox', 'radio'].includes(item.type)">
|
||||
<div $class="\${this.inline ? 'h-100 d-flex align-items-center px-2 gap-3 justify-content-center w-100 bg-body' : ''}" style="white-space:nowrap; border-radius: 4px;">
|
||||
<template $each="item.options || [item.text||item.label||item.name]" as="option">
|
||||
<label $class="form-check \${item.vertical ? '' : 'form-check-inline'} mb-0 d-flex align-items-center p-0">
|
||||
<input $name="item.name" class="form-check-input me-1" $type="item.type" $.="item.setting || {}" $value="item.options?option:'on'" $bind="this.data[item.name]">
|
||||
<template $if="!this.inline || (item.options && item.options.length > 0)">
|
||||
<span $text="option" class="form-check-label"></span>
|
||||
</template>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template $if="item.type === 'switch'">
|
||||
<div $class="form-check form-switch fs-4 h-100 d-flex align-items-center px-2 m-0 \${this.inline ? 'justify-content-center w-100' : ''}" style="padding-left:0">
|
||||
<input $name="item.name" class="form-check-input m-0" type="checkbox" style="cursor:pointer" $bind="this.data[item.name]">
|
||||
</div>
|
||||
</template>
|
||||
<template $if="item.type === 'textarea'">
|
||||
<textarea $name="item.name" class="form-control" $.="item.setting || {}" $bind="this.data[item.name]"></textarea>
|
||||
</template>
|
||||
|
||||
<!-- 扩展控件容器 -->
|
||||
<template $each="AutoForm.customTypes" as="ext">
|
||||
<template $if="item.type?.toLowerCase() === ext.typeName.toLowerCase()">
|
||||
<div $is="ext.name" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="h-100"></div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<div $if="item.type === 'switch'" $class="form-check form-switch fs-4 h-100 d-flex align-items-center px-2 m-0 \${this.inline ? 'justify-content-center w-100' : ''}" style="padding-left:0">
|
||||
<input $name="item.name" class="form-check-input m-0" type="checkbox" style="cursor:pointer" $bind="this.data[item.name]" $onchange="event.stopPropagation()">
|
||||
</div>
|
||||
<textarea $if="item.type === 'textarea'" $name="item.name" class="form-control" $.="item.setting || {}" $bind="this.data[item.name]"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template $if="!this.inline">
|
||||
<div class="d-flex justify-content-end align-items-baseline gap-3 mt-3" style="grid-column:1/-1">
|
||||
<div slot-id="actions"></div>
|
||||
<button type="submit" class="btn btn-primary" $text="this.submitlabel || '{#Submit#}'"></button>
|
||||
</div>
|
||||
</template>
|
||||
<div $if="!this.inline && !this.nobutton" class="d-flex justify-content-end align-items-baseline gap-3 mt-3" style="grid-column:1/-1">
|
||||
<div slot-id="actions"></div>
|
||||
<button type="submit" class="btn btn-primary" $text="this.submitlabel || '{#Submit#}'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`
|
||||
@ -293,38 +334,15 @@ Component.register("AutoForm", (container) => {
|
||||
.auto-form-inline textarea.form-control { padding: 8px; min-height: 100px; height: auto; }
|
||||
</style>`
|
||||
));
|
||||
const _pendingAutoFormComponents = [];
|
||||
const AutoForm = {
|
||||
customTypes: NewState([]),
|
||||
register: (name, typeName) => {
|
||||
const entry = { name, typeName: typeName || name };
|
||||
if (typeof document !== "undefined") {
|
||||
if (document.readyState !== "loading" && Component.getTemplate("AutoForm")) AutoForm._addAutoFormComponent(entry);
|
||||
else _pendingAutoFormComponents.push(entry);
|
||||
}
|
||||
},
|
||||
_addAutoFormComponent: (entry) => {
|
||||
const { name, typeName } = entry;
|
||||
const template = Component.getTemplate("AutoForm");
|
||||
if (template) {
|
||||
let container = $(template.content, "[control-wrapper]");
|
||||
if (!container) {
|
||||
const nested = $(template.content, "template");
|
||||
if (nested) container = $(nested.content, "[control-wrapper]");
|
||||
}
|
||||
if (container && !container.querySelector(name)) {
|
||||
container.appendChild(Util.makeDom(`<${name} $if="item.type?.toLowerCase() === '${typeName.toLowerCase()}'" $name="item.name" $.="item.setting || {}" $bind="thisNode.closest('AutoForm').data[item.name]"></${name}>`));
|
||||
}
|
||||
const type = typeName || name;
|
||||
if (!AutoForm.customTypes.find((t) => t.name === name)) {
|
||||
AutoForm.customTypes.push({ name, typeName: type });
|
||||
}
|
||||
}
|
||||
};
|
||||
if (typeof document !== "undefined") {
|
||||
const initAutoForm = () => {
|
||||
_pendingAutoFormComponents.forEach((entry) => AutoForm._addAutoFormComponent(entry));
|
||||
_pendingAutoFormComponents.length = 0;
|
||||
};
|
||||
if (document.readyState !== "loading") setTimeout(initAutoForm, 100);
|
||||
else document.addEventListener("DOMContentLoaded", () => setTimeout(initAutoForm, 100), true);
|
||||
}
|
||||
Component.register("TagsInput", (container) => {
|
||||
container.state = NewState({ tags: [] });
|
||||
container.addEventListener("bind", (e) => {
|
||||
@ -761,17 +779,27 @@ Component.register("List", (container) => {
|
||||
<template slot-id="item" $each="this.state?._renderedList">
|
||||
<div $onupdate="this.onItemUpdate(index, thisNode)" $class="list-group-item list-group-item-action d-inline-flex align-items-center ps-2 pe-2 \${item.type==='group'?(this.state?.selectedGroup===item[this.groupidfield]?'active':''):(this.state?.selectedItem===item[this.idfield]?'active':'')}" $onclick="item.type==='group'?this.selectGroup(item,index):this.selectItem(item,index)">
|
||||
<template $if="item.type === 'group'">
|
||||
<span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span>
|
||||
<template $if="this.groupicon">
|
||||
<span $class="bi bi-\${this.groupicon} text-body"></span>
|
||||
</template>
|
||||
<div class="flex-shrink-0 px-1" $text="\${item[this.grouplabelfield]}"></div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="\${item[this.groupsummaryfield]}"></div>
|
||||
<div slot-id="group-actions"></div>
|
||||
</template>
|
||||
<template $if="item.type === 'item'">
|
||||
<div $if="this.mode === 'tree'" $style="width:\${item._level * 16 + (this.collapsible ? 16 : 0)}px; cursor:\${this.collapsible ? 'pointer' : 'default'}" class="text-end text-muted flex-shrink-0" $onclick="event.stopPropagation(); this.toggleCollapse(item)">
|
||||
<i $if="this.collapsible && item._hasChildren" $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
|
||||
</div>
|
||||
<span $if="this.mode === 'tree'" $class="text-muted bi bi-\${item._hasChildren ? this.groupicon : this.itemicon}"></span>
|
||||
<span $if="this.mode !== 'tree' && this.itemicon" class="bi bi-\${this.itemicon} text-body"></span>
|
||||
<template $if="this.mode === 'tree'">
|
||||
<div $style="width:\${item._level * 16 + (this.collapsible ? 16 : 0)}px; cursor:\${this.collapsible ? 'pointer' : 'default'}" class="text-end text-muted flex-shrink-0" $onclick="event.stopPropagation(); this.toggleCollapse(item)">
|
||||
<template $if="this.collapsible && item._hasChildren">
|
||||
<i $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template $if="this.mode === 'tree'">
|
||||
<span $class="text-muted bi bi-\${item._hasChildren ? this.groupicon : this.itemicon}"></span>
|
||||
</template>
|
||||
<template $if="this.mode !== 'tree' && this.itemicon">
|
||||
<span $class="bi bi-\${this.itemicon} text-body"></span>
|
||||
</template>
|
||||
<div class="flex-shrink-0 px-1" $text="\${item[this.labelfield]}"></div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="\${item[this.summaryfield]}"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
@ -792,32 +820,47 @@ Component.register("Nav", (container) => {
|
||||
/*html*/
|
||||
`
|
||||
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-3 \${this.vertical ? 'py-3' : 'pb-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}">
|
||||
<img $if="this.state.brand.image" $src="this.state.brand.image" $class="\${this.vertical ? 'mb-4' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
|
||||
<i $if="this.state.brand.icon" $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-4' : 'me-2'}"></i>
|
||||
<span $if="this.state.brand.label" $class="\${this.vertical ? 'mb-4 fw-bold' : 'me-2'}" $text="this.state.brand.label"></span>
|
||||
<template $if="this.state?.brand?.image">
|
||||
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-4' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
|
||||
</template>
|
||||
<template $if="this.state?.brand?.icon">
|
||||
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-4' : 'me-2'}"></i>
|
||||
</template>
|
||||
<template $if="this.state?.brand?.label">
|
||||
<span $class="\${this.vertical ? 'mb-4 fw-bold' : 'me-2'}" $text="this.state.brand.label"></span>
|
||||
</template>
|
||||
<div $class="\${this.vertical ? 'w-100' : 'ms-2'}"></div>
|
||||
<div $each="this.state.list" $class="\${this.vertical ? 'nav nav-pills flex-column w-100' : 'navbar-nav'} text-truncate \${item.type==='fill'?'flex-fill':''}">
|
||||
<button $if="item.type==='button'" $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" $onclick="this.click(item)">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state.list.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||
</button>
|
||||
<div $if="item.type==='dropdown'" $class="dropdown \${this.vertical ? 'w-100' : ''}">
|
||||
<button $class="nav-link \${Hash.nav===item.name?'active':''} w-100 \${this.vertical ? 'text-start d-flex justify-content-between align-items-center' : ''}" data-bs-toggle="dropdown">
|
||||
<span><i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state.list.length>5?'lg':'md') + '-inline'}" $text="item.label"></span></span>
|
||||
<i $if="this.vertical" class="bi bi-chevron-right small"></i>
|
||||
</button>
|
||||
<div $class="dropdown-menu \${this.vertical ? 'position-static border-0 bg-transparent shadow-none ps-3' : 'dropdown-menu-end p-3 bg-body-secondary shadow'}" $style="width: \${this.vertical ? '100%' : (item.width || 250) + 'px'};">
|
||||
<template $each="item.list" as="subitem">
|
||||
<button $if="subitem.type==='button'" class="nav-link px-0 w-100 text-start" $onclick="this.click(subitem, true)">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
|
||||
<template $each="this.state?.list || []">
|
||||
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100' : 'navbar-nav'} text-truncate \${item.type==='fill'?'flex-fill':''}">
|
||||
<template $if="item.type==='button'">
|
||||
<button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" $onclick="this.click(item)">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template $if="item.type==='dropdown'">
|
||||
<div class="dropdown">
|
||||
<button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" data-bs-toggle="dropdown">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||
</button>
|
||||
<div $if="subitem.type==='switch'" class="d-flex align-items-center">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
|
||||
<div class="form-switch"><input class="form-check-input mx-0" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
<div $class="dropdown-menu \${this.vertical?'position-static':'dropdown-menu-end'} p-3 bg-body-secondary shadow" $style="width: \${item.width || 250}px;">
|
||||
<template $each="item.list" as="subitem">
|
||||
<template $if="subitem.type==='button'">
|
||||
<button class="nav-link px-0 w-100 text-start" $onclick="this.click(subitem, true)">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template $if="subitem.type==='switch'">
|
||||
<div class="d-flex align-items-center">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
|
||||
<div class="form-switch"><input class="form-check-input mx-0" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
`
|
||||
));
|
||||
@ -901,7 +944,8 @@ const ApigoBase = {
|
||||
AutoForm,
|
||||
MouseMover,
|
||||
VirtualScroll,
|
||||
State
|
||||
State,
|
||||
List: VirtualScroll
|
||||
};
|
||||
if (typeof document !== "undefined") {
|
||||
globalThis.ApigoBase = ApigoBase;
|
||||
|
||||
753
old/base_original.js
Normal file
753
old/base_original.js
Normal file
@ -0,0 +1,753 @@
|
||||
// base.js v2.3
|
||||
(() => {
|
||||
const { Component, NewState } = globalThis
|
||||
|
||||
// 工具函数
|
||||
globalThis.Util = {
|
||||
clone: window.structuredClone || (obj => JSON.parse(JSON.stringify(obj))),
|
||||
base64: str => btoa(String.fromCharCode(...new TextEncoder().encode(str))),
|
||||
unbase64: str => new TextDecoder().decode(Uint8Array.from(atob(str), c => c.charCodeAt(0))),
|
||||
urlbase64: str => $base64(str).replace(/[+/=]/g, m => ({ '+': '-', '/': '', '=': '' }[m])),
|
||||
unurlbase64: str => $unbase64(str.replace(/[-_.]/g, m => ({ '-': '+', '_': '/', '.': '=' }[m])).padEnd(Math.ceil(str.length / 4) * 4, '=')),
|
||||
safeJson: str => { try { return JSON.parse(str) } catch { return null } },
|
||||
updateDefaults: (obj, defaults) => { for (const k in defaults) if (obj[k] === undefined) obj[k] = defaults[k] },
|
||||
copyFunction: (toObj, fromObj, ...funcNames) => { funcNames.forEach(name => toObj[name] = fromObj[name].bind(fromObj)) },
|
||||
getFunctionBody: fn => { const code = fn.toString(); return code.slice(code.indexOf('{') + 1, code.lastIndexOf('}')).trim() },
|
||||
makeDom: html => {
|
||||
if (html.includes('>\n')) html = html.replace(/>\s+</g, "><").trim()
|
||||
const node = document.createElement('div')
|
||||
node.innerHTML = html
|
||||
return node.children[0]
|
||||
},
|
||||
newAvg: () => {
|
||||
let total = 0, count = 0, avg = 0
|
||||
return {
|
||||
add: (v) => {
|
||||
total += v
|
||||
count++
|
||||
return avg = total / count
|
||||
},
|
||||
get: () => avg,
|
||||
clear: () => { total = 0, count = 0, avg = 0 }
|
||||
}
|
||||
},
|
||||
newTimeCount: () => {
|
||||
let startTime = 0, total = 0, count = 0
|
||||
return {
|
||||
start: () => startTime = new Date().getTime(),
|
||||
end: () => {
|
||||
const endTime = new Date().getTime()
|
||||
const left = endTime - startTime
|
||||
startTime = endTime
|
||||
total += left
|
||||
count++
|
||||
return left
|
||||
},
|
||||
avg: () => total / count
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// url hash 状态
|
||||
let _hashParams = new URLSearchParams(window.location.hash?.substring(1) || '')
|
||||
globalThis.Hash = NewState({}, k => Util.safeJson(_hashParams.get(k)), (k, v) => {
|
||||
const oldStr = _hashParams.get(k)
|
||||
const newStr = v === undefined ? undefined : JSON.stringify(v)
|
||||
if (oldStr === newStr || (oldStr === null && newStr === undefined)) return
|
||||
v === undefined ? _hashParams.delete(k) : _hashParams.set(k, newStr)
|
||||
window.location.hash = '#' + _hashParams.toString()
|
||||
})
|
||||
window.addEventListener('hashchange', () => {
|
||||
const oldHashParams = _hashParams
|
||||
_hashParams = new URLSearchParams(window.location.hash?.substring(1) || '')
|
||||
_hashParams.forEach((v, k) => { if (oldHashParams.get(k) !== v) Hash[k] = Util.safeJson(v) })
|
||||
oldHashParams.forEach((v, k) => { if (_hashParams.get(k) === undefined) Hash[k] = undefined })
|
||||
})
|
||||
|
||||
// localstorage 状态
|
||||
globalThis.LocalStorage = NewState({}, k => Util.safeJson(localStorage.getItem(k)), (k, v) => {
|
||||
const oldStr = localStorage.getItem(k)
|
||||
const newStr = v === undefined ? undefined : JSON.stringify(v)
|
||||
if (oldStr === newStr || (oldStr === null && newStr === undefined)) return
|
||||
v === undefined ? localStorage.removeItem(k) : localStorage.setItem(k, newStr)
|
||||
})
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const htmlNode = document.documentElement
|
||||
if (!htmlNode.hasAttribute('$data-bs-theme') && !htmlNode.hasAttribute('data-bs-theme')) htmlNode.setAttribute('$data-bs-theme', "LocalStorage.darkMode?'dark':'light'")
|
||||
}, true)
|
||||
|
||||
// 页面退出状态
|
||||
globalThis.State = NewState()
|
||||
State.exitBlocks = 0
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (State.exitBlocks > 0) event.preventDefault()
|
||||
})
|
||||
|
||||
// HTTP 和 API 组件
|
||||
globalThis.HTTP = {
|
||||
get: ({ url, ...opt }) => HTTP.request({ url, method: 'GET', ...opt }),
|
||||
post: ({ url, data, ...opt }) => HTTP.request({ url, method: 'POST', data, ...opt }),
|
||||
put: ({ url, data, ...opt }) => HTTP.request({ url, method: 'PUT', data, ...opt }),
|
||||
delete: ({ url, ...opt }) => HTTP.request({ url, method: 'DELETE', ...opt }),
|
||||
head: ({ url, ...opt }) => HTTP.request({ url, method: 'HEAD', ...opt }),
|
||||
request: async ({ url, method = 'POST', data = undefined, headers = {}, responseType, timeout = 10000 }) => {
|
||||
method = method.toUpperCase()
|
||||
const options = { method, signal: AbortSignal.timeout?.(timeout) }
|
||||
if (data !== undefined && method !== 'GET' && method !== 'HEAD') {
|
||||
if (data instanceof HTMLFormElement) data = new FormData(data)
|
||||
if (data && typeof data === 'object' && !(data instanceof FormData) && !(data instanceof ArrayBuffer || ArrayBuffer.isView(data)) && Object.values(data).some(v => v instanceof File || v instanceof Blob || v instanceof FileList || (Array.isArray(v) && v.some(i => i instanceof File || i instanceof Blob)))) {
|
||||
const fd = new FormData()
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (v instanceof FileList || Array.isArray(v)) Array.from(v).forEach(item => fd.append(k, item))
|
||||
else if (v !== undefined && v !== null) fd.append(k, v)
|
||||
}
|
||||
data = fd
|
||||
}
|
||||
if (data instanceof FormData) {
|
||||
delete headers['Content-Type']
|
||||
} else if (typeof data !== 'string' && !(data instanceof ArrayBuffer || ArrayBuffer.isView(data))) {
|
||||
data = JSON.stringify(data)
|
||||
if (!headers['Content-Type']) headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
options.body = data
|
||||
}
|
||||
if (Object.keys(headers).length) options.headers = headers
|
||||
const response = { error: null, ok: null, status: 0, headers: {}, responseType: '', result: null }
|
||||
try {
|
||||
const resp = await fetch(url, options)
|
||||
Object.assign(response, { ok: resp.ok, status: resp.status, headers: Object.fromEntries(resp.headers.entries()) })
|
||||
if (!responseType) {
|
||||
const contentType = resp.headers.get('Content-Type') || ''
|
||||
if (contentType.includes('application/json')) responseType = 'json'
|
||||
else if (/image|video|audio|pdf|zip|octet-stream/.test(contentType)) responseType = 'binary'
|
||||
else responseType = 'text'
|
||||
response.responseType = responseType
|
||||
}
|
||||
if (response.ok === false) response.error = (response.statusText || 'HTTP ' + response.status + ' error') + ' for ' + url
|
||||
if (responseType === 'json') response.result = await resp.json()
|
||||
else response.result = (responseType === 'binary') ? await resp.arrayBuffer() : await resp.text()
|
||||
} catch (err) {
|
||||
Object.assign(response, { error: err.message || String(err), ok: false })
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
Component.register('API', container => {
|
||||
container.request = NewState({ url: '', method: 'GET', headers: {}, data: null, timeout: 10000, responseType: '' })
|
||||
container.response = NewState({ loading: false, ok: null, status: null, error: null, headers: {}, responseType: '', result: null })
|
||||
container.result = NewState()
|
||||
container.do = (opt = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = { ...container.request, ...opt }
|
||||
if (!req.url) throw new Error('.url is required')
|
||||
req.headers = { ...container.request.headers, ...opt.headers }
|
||||
container.response.loading = true
|
||||
HTTP.request(req).then(resp => {
|
||||
Object.keys(resp).forEach(k => { if (k !== 'result') container.response[k] = resp[k] })
|
||||
if (resp.result && typeof resp.result === 'object' && container.result && typeof container.result === 'object') {
|
||||
Object.assign(container.result, resp.result)
|
||||
} else {
|
||||
container.result = resp.result
|
||||
}
|
||||
container.response.loading = false
|
||||
if (resp.ok === false) throw new Error(resp.error)
|
||||
if (typeof resp.result === 'object' && resp.result.error) throw new Error(resp.result.error)
|
||||
container.dispatchEvent(new CustomEvent('response', { detail: resp, bubbles: false }))
|
||||
resolve(resp)
|
||||
}).catch(err => {
|
||||
if (!opt.noui) UI.toast(err.message, { type: 'danger' })
|
||||
container.dispatchEvent(new CustomEvent('error', { detail: err, bubbles: true }))
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
let _autoTimer = null
|
||||
container.request.__watch(null, () => {
|
||||
if (!container.hasAttribute('auto') || !container.request.url) return
|
||||
if (_autoTimer) return
|
||||
_autoTimer = Promise.resolve().then(() => {
|
||||
container.do()
|
||||
_autoTimer = null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
globalThis.UI = {}
|
||||
// 模态窗口和对话框组件
|
||||
Component.register('Modal', container => {
|
||||
container.modal = new bootstrap.Modal(container)
|
||||
container.addEventListener('bind', e => {
|
||||
e.detail ? container.modal.show() : container.modal.hide()
|
||||
})
|
||||
container.addEventListener('hide.bs.modal', () => {
|
||||
document.activeElement?.blur()
|
||||
container.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: false }))
|
||||
})
|
||||
Util.copyFunction(container, container.modal, 'show', 'hide')
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="modal fade" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div $class="modal-content text-bg-\${this.state?.type || 'body'}">
|
||||
<div slot-id="header" class="modal-header">
|
||||
<h6 class="modal-title" $text="this.state?.title"></h6>
|
||||
<button type="button" class="btn btn-link ms-2 bi bi-x-lg link-reset" style="color:inherit" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div slot-id="body" class="modal-body"></div>
|
||||
<div slot-id="footer" class="modal-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
Component.register('Dialog', Component.getSetupFunction('Modal'), Util.makeDom(/*html*/`
|
||||
<div class="modal fade" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div $class="modal-content text-bg-\${this.state?.type || 'body'}">
|
||||
<div $if="this.state?.title" class="modal-header" $text="this.state?.title"></div>
|
||||
<div slot-id="body" class="modal-body"><div $html="this.state?.message"></div></div>
|
||||
<div class="modal-footer">
|
||||
<button $each="this.state?.buttons || ['{#Close#}']" type="button" class="btn btn-sm btn-\${index === buttons.length - 1 ? (type && type !== 'body' ? type : 'primary') : 'secondary'}" $onclick="this.result=index+1;this.hide()" $text="\${item}"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
let _dialogCount = 0
|
||||
UI.showDialog = function ({ title = '', message = '', buttons = ['{#Close#}'], type = 'body' }) {
|
||||
const d = document.body.appendChild(document.createElement('Dialog'))
|
||||
d.style.zIndex = 2000 + ++_dialogCount
|
||||
Promise.resolve().then(() => {
|
||||
Object.assign(d.state, { message, title, type, buttons })
|
||||
d.show()
|
||||
})
|
||||
return new Promise((resolve) => {
|
||||
d.addEventListener('change', e => {
|
||||
_dialogCount--
|
||||
resolve(d.result || 0)
|
||||
d.remove()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
UI.alert = function (message, options = {}) {
|
||||
return UI.showDialog({ message, ...options })
|
||||
}
|
||||
UI.confirm = function (message, options = {}) {
|
||||
return new Promise((resolve) => UI.showDialog({ message, buttons: ['{#Cancel#}', '{#Confirm#}'], ...options }).then(index => resolve(index >= 2)).catch(() => resolve(false)))
|
||||
}
|
||||
|
||||
// 通知组件
|
||||
Component.register('Toast', container => {
|
||||
container.toast = new bootstrap.Toast(container, { autohide: container.state.delay > 0 })
|
||||
Util.copyFunction(container, container.toast, 'show', 'hide')
|
||||
container.addEventListener('show.bs.toast', () => {
|
||||
if (container.state.delay > 0) {
|
||||
let timer
|
||||
const startTimer = () => {
|
||||
container.state.left = container.state.delay / 1000
|
||||
timer = setInterval(() => {
|
||||
if (!container.isConnected || --container.state.left <= 0) clearInterval(timer)
|
||||
}, 1000)
|
||||
}
|
||||
startTimer()
|
||||
container.addEventListener('mouseenter', () => { clearInterval(timer); container.state.left = undefined })
|
||||
container.addEventListener('mouseleave', startTimer)
|
||||
}
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="toast align-items-center border-0 m-1">
|
||||
<div $class="toast-body p-3 text-bg-\${this.state?.type}">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<span style="white-space:pre-wrap" class="fs-6" $text="this.state?.message"></span>
|
||||
<span $if="this.state?.left !== undefined" class="small text-dim ms-2" $text="\${this.state?.left}s"></span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link ms-3 bi bi-x-lg link-reset" style="color:inherit" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end gap-3">
|
||||
<button $each="this.state?.buttons || ['{#Close#}']" type="button" $class="btn btn-sm btn-\${this.state?.type} mt-2" data-bs-dismiss="toast" $onclick="this.result=index+1" $text="item"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`), Util.makeDom(/*html*/`<div toast-container="default" class="position-fixed bottom-0 end-0 overflow-auto" style="z-index:3000;max-height:80%"></div>`))
|
||||
|
||||
UI.toast = function (message, options = {}) {
|
||||
const delay = options.delay ?? 5000
|
||||
const t = document.createElement('Toast')
|
||||
t.state = { delay, left: delay ? delay / 1000 : undefined, type: options.type || 'body', message, buttons: options.buttons || [] }
|
||||
$(`[toast-container="${options.container || 'default'}"]`).appendChild(t)
|
||||
Promise.resolve().then(() => t.show())
|
||||
}
|
||||
|
||||
UI.toastConfirm = function (message, options = {}) {
|
||||
return new Promise((resolve) => UI.toast(message, { buttons: ['{#Confirm#}'], ...options }).then(index => resolve(index === 1)).catch(() => resolve(false)))
|
||||
}
|
||||
|
||||
Component.register('AutoForm', container => {
|
||||
if (!container.schema) container.schema = []
|
||||
container.vertical = container.hasAttribute('vertical')
|
||||
container.request = { method: 'POST' }
|
||||
container.response = {}
|
||||
container.result = null
|
||||
container.data = NewState(container.data || {})
|
||||
container.form = $(container, 'form')
|
||||
container.submit = (opt = {}) => {
|
||||
if (!container.form.reportValidity()) return UI.toast('{#verify failed#}', { type: 'danger' })
|
||||
if (!container.dispatchEvent(new CustomEvent('submit', { detail: container.data, cancelable: true, bubbles: false }))) return
|
||||
const req = { ...container.request, data: container.data, noui: true, ...opt }
|
||||
let task = null
|
||||
if (container.api) task = container.api.do(req)
|
||||
else if (container.request.url) task = HTTP.request(req)
|
||||
else return console.warn('{#please config .api or .request.url to auto submit#}')
|
||||
task.then(resp => {
|
||||
container.response = resp
|
||||
container.result = resp.result
|
||||
if (typeof resp.result === 'object' && resp.result.error) throw new Error(resp.result.error)
|
||||
container.dispatchEvent(new CustomEvent('response', { detail: resp, bubbles: false }))
|
||||
}).catch(err => {
|
||||
UI.toast(err.message, { type: 'danger' })
|
||||
container.dispatchEvent(new CustomEvent('error', { detail: err, bubbles: true }))
|
||||
})
|
||||
}
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div>
|
||||
<form $class="align-items-center \${this.vertical?'':'auto-grid-form'}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
|
||||
<div $each="this.schema || []" style="display:contents">
|
||||
<label $name="item.name" class="col-form-label text-muted" $text="item.label"></label>
|
||||
<div control-wrapper class="mb-3">
|
||||
<input $if="['text', 'password', 'email', 'number', 'date', 'datetime', 'file'].includes(item.type)" $name="item.name" class="form-control" $type="item.type" $.="item.setting" $bind="this.data[item.name]">
|
||||
<select $if="item.type === 'select'" $name="item.name" class="form-select" $.="item.setting" $bind="this.data[item.name]">
|
||||
<option value="" $if="item.placeholder" $text="item.placeholder" disabled selected></option>
|
||||
<option $each="item.options" $value="item.value || item" $text="item.label || item"></option>
|
||||
</select>
|
||||
<div $if="['checkbox', 'radio'].includes(item.type)" >
|
||||
<label $each="item.options || [item.text||item.label||item.name]" as="option" $class="form-check\${item.vertical ? '' : ' form-check-inline'}">
|
||||
<input $name="item.name" class="form-check-input" $type="item.type" $.="item.setting" $value="item.options?option:'on'" $bind="this.data[item.name]">
|
||||
<span $text="option" class="form-check-label"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div $if="item.type==='switch'" class="form-check form-switch fs-4"><input $name="item.name" class="form-check-input my-0" type="checkbox" $bind="this.data[item.name]"></div>
|
||||
<textarea $if="item.type==='textarea'" $name="item.name" class="form-control" $.="item.setting" $bind="this.data[item.name]"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end align-items-baseline gap-3 mt-3" style="grid-column:1/-1">
|
||||
<div slot-id="actions"></div>
|
||||
<button type="submit" class="btn btn-primary" $text="this.submitlabel || '{#Submit#}'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`), Util.makeDom(/*html*/`<style>@media (min-width: 576px) { .auto-grid-form {display: grid;grid-template-columns: max-content 1fr} .auto-grid-form .col-form-label {text-align: right; margin-bottom: 1rem;padding-right: 1rem;max-width: 200px} }</style>`))
|
||||
|
||||
const _pendingAutoFormComponents = []
|
||||
globalThis.AutoForm = {
|
||||
register: name => {
|
||||
if (document.readyState !== 'loading') AutoForm._addAutoFormComponent(name)
|
||||
else _pendingAutoFormComponents.push(name)
|
||||
},
|
||||
_addAutoFormComponent: name => {
|
||||
$(Component.getTemplate('AutoForm')?.content, '[control-wrapper]').appendChild(Util.makeDom(`<${name} $if="item.type.toUpperCase() === '${name.toUpperCase()}'" $name="item.name" $.="item.setting" $bind="this.data[item.name]"></${name}>`))
|
||||
}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
_pendingAutoFormComponents.forEach(name => AutoForm._addAutoFormComponent(name))
|
||||
_pendingAutoFormComponents.length = 0
|
||||
}, true)
|
||||
|
||||
Component.register('TagsInput', container => {
|
||||
container.addEventListener('bind', e => {
|
||||
container.state.tags = e.detail || []
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="form-control d-flex flex-wrap gap-1 align-items-center" style="min-height:38px;cursor:text">
|
||||
<button $each="this.state.tags" type="button" class="btn btn-sm btn-outline-primary rounded-pill py-0 px-2" $onkeydown="${Util.getFunctionBody(event => {
|
||||
if (['Backspace', 'Delete'].includes(event.key)) {
|
||||
event.preventDefault()
|
||||
this.state.tags.splice(index, 1)
|
||||
this.state.tags = this.state.tags
|
||||
this.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: this.state.tags }))
|
||||
Promise.resolve().then(() => {
|
||||
const buttons = $$(this, 'button');
|
||||
(buttons.length > 0 ? buttons[index > 0 ? index - 1 : 0] : $(this, 'input')).focus()
|
||||
})
|
||||
}
|
||||
})}" $text="item"></button>
|
||||
<input type="text" class="border-0 shadow-none py-0 px-2 flex-grow-1 bg-transparent" placeholder="{#new tag name#}" style="min-width:100px;width:0;outline:none" $onkeydown="${Util.getFunctionBody(event => {
|
||||
if (event.isComposing) return
|
||||
if (['Enter', ',', ' '].includes(event.key)) {
|
||||
event.preventDefault()
|
||||
const v = thisNode.value.trim()
|
||||
if (v && !this.state.tags.includes(v)) {
|
||||
this.state.tags.push(v)
|
||||
this.state.tags = this.state.tags
|
||||
this.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: this.state.tags }))
|
||||
}
|
||||
thisNode.value = ''
|
||||
}
|
||||
})}">
|
||||
</div>
|
||||
`), Util.makeDom(/*html*/`<style>TagsInput button:focus {background-color:var(--bs-btn-hover-bg);color:var(--bs-btn-hover-color)}</style>`))
|
||||
|
||||
AutoForm.register('TagsInput')
|
||||
|
||||
Component.register('FastList', container => {
|
||||
const itemHeights = new Map()
|
||||
const groupHeights = new Map()
|
||||
let groupItemCount = 1
|
||||
container.state.renderedList = []
|
||||
const avg = Util.newAvg()
|
||||
let containerPaddingTop = 0
|
||||
let containerRowGap = 0
|
||||
let topMargin = 0
|
||||
let listStartIndex = 0
|
||||
let visibleStartIndex = 0
|
||||
let visibleCount = 10
|
||||
let listInited = false
|
||||
|
||||
container.onItemUpdate = (index, node) => {
|
||||
const absoluteIndex = index + listStartIndex
|
||||
const oldHeight = itemHeights.get(absoluteIndex)
|
||||
const style = window.getComputedStyle(node)
|
||||
const marginTop = parseFloat(style.marginTop) || 0
|
||||
const marginBottom = parseFloat(style.marginBottom) || 0
|
||||
const newHeight = node.offsetHeight + marginTop + marginBottom + containerRowGap
|
||||
if (absoluteIndex === 0 && !topMargin) topMargin = marginTop
|
||||
if (newHeight !== oldHeight) {
|
||||
itemHeights.set(absoluteIndex, newHeight)
|
||||
avg.add(newHeight)
|
||||
const offset = newHeight - oldHeight || 0
|
||||
const groupIndex = absoluteIndex - (absoluteIndex % groupItemCount)
|
||||
if (groupHeights.has(groupIndex)) groupHeights.set(groupIndex, groupHeights.get(groupIndex) + offset)
|
||||
}
|
||||
}
|
||||
|
||||
// const timeCount = Util.newTimeCount()
|
||||
container.refresh = () => {
|
||||
if (!listInited) return
|
||||
// timeCount.start()
|
||||
visibleCount = Math.ceil((container.clientHeight || 100) / (avg.get() || 32))
|
||||
let list = []
|
||||
const fullList = container.state.list
|
||||
const fullSize = fullList.length
|
||||
let i = 0
|
||||
let prev = containerPaddingTop + topMargin + containerRowGap
|
||||
let post = 0
|
||||
let starus = 0
|
||||
for (i = 0; i < fullSize; i++) {
|
||||
if (starus === 0) {
|
||||
// 可视位置之前的计算
|
||||
const gh = groupHeights.get(i)
|
||||
if (gh && prev + gh < container.scrollTop) {
|
||||
prev += gh
|
||||
i += Math.min(groupItemCount, fullSize - i) - 1
|
||||
} else {
|
||||
const ih = itemHeights.get(i)
|
||||
if (prev + ih < container.scrollTop) {
|
||||
prev += ih
|
||||
} else {
|
||||
starus = 1
|
||||
// 可视位置处理
|
||||
visibleStartIndex = Math.max(0, i)
|
||||
listStartIndex = Math.max(0, visibleStartIndex - visibleCount)
|
||||
const listEndIndex = Math.min(listStartIndex + visibleCount * 3, fullSize)
|
||||
i = listEndIndex - 1
|
||||
list = fullList.slice(listStartIndex, listEndIndex)
|
||||
for (let j = listStartIndex; j < visibleStartIndex; j++) prev -= itemHeights.get(j)
|
||||
}
|
||||
}
|
||||
} else if (starus === 1) {
|
||||
// 底部填充计算
|
||||
const gh = groupHeights.get(i)
|
||||
if (gh) {
|
||||
post += gh
|
||||
i += groupItemCount - 1
|
||||
} else {
|
||||
post += itemHeights.get(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
// console.info('FastList refresh make list:', timeCount.end())
|
||||
container.state.prevHeight = prev - containerPaddingTop - topMargin - containerRowGap
|
||||
container.state.postHeight = post
|
||||
container.state.renderedList = list
|
||||
// console.info('FastList refresh time:', timeCount.end())
|
||||
}
|
||||
container.state.__watch('list', list => {
|
||||
listInited = false
|
||||
itemHeights.clear()
|
||||
groupHeights.clear()
|
||||
avg.clear()
|
||||
if (!list?.length) return
|
||||
|
||||
const listSize = list.length || 0
|
||||
groupItemCount = Math.ceil(Math.sqrt(listSize)) || 10
|
||||
const style = window.getComputedStyle(container)
|
||||
containerPaddingTop = parseFloat(style.paddingTop)
|
||||
containerRowGap = parseFloat(style.rowGap) || 0
|
||||
container.state.renderedList = list.slice(0, Math.min(30, listSize)) || []
|
||||
setTimeout(() => {
|
||||
// 渲染一批之后立刻计算所有高度
|
||||
container.state.list.forEach((item, index) => itemHeights.set(index, avg.get()))
|
||||
for (let i = 0; i < listSize; i += groupItemCount) groupHeights.set(i, Math.min(groupItemCount, listSize - i) * avg.get())
|
||||
listInited = true
|
||||
container.refresh()
|
||||
})
|
||||
})
|
||||
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="overflow-auto" onscroll="this.refresh()" style="overflow-anchor:none">
|
||||
<div $if="this.state.prevHeight>0" $style="height:\${this.state.prevHeight}px;" class="flex-shrink-0"></div>
|
||||
<div slot-id="item" class="flex-shrink-0" $each="this.state.renderedList" $onupdate="this.onItemUpdate(index,thisNode)"></div>
|
||||
<div $if="this.state.postHeight>0" $style="height:\${this.state.postHeight}px;" class="flex-shrink-0"></div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
Component.attachSelectFeature = (container, eventName = 'itemclick', selectActionName = 'selectItem', activeActionName = 'itemActiveTag', selectedItemKey = 'selectedItem', idfieldKey = 'idfield', selectableAttr = 'auto-select') => {
|
||||
const selectable = selectableAttr && container.hasAttribute(selectableAttr)
|
||||
container[selectActionName] = (item, index) => {
|
||||
if (selectable) container.state[selectedItemKey] = container.state[selectedItemKey] === item[container[idfieldKey]] ? null : item[container[idfieldKey]]
|
||||
container.dispatchEvent(new CustomEvent(eventName, { bubbles: false, detail: !selectable || container.state[selectedItemKey] ? { item, index } : { item: {}, index: null } }))
|
||||
}
|
||||
container[activeActionName] = (item) => container.state[selectedItemKey] !== null && container.state[selectedItemKey] === item[container[idfieldKey]] ? 'active' : ''
|
||||
}
|
||||
Component.register('List', container => {
|
||||
Component.attachSelectFeature(container)
|
||||
const defaultSets = { idfield: 'id', labelfield: 'label', summaryfield: 'summary' }
|
||||
container.state.__watch('list', oldList => {
|
||||
Util.updateDefaults(container, defaultSets)
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="list-group overflow-auto">
|
||||
<div slot="item" slot-id="item" $each="this.state.list" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action list-group-item-heading d-inline-flex pe-auto align-items-baseline \${this.itemActiveTag?.(item)}">
|
||||
<span $if="this.itemicon" class="bi bi-\${this.itemicon} text-body"></span>
|
||||
<div class="flex-shrink-0 px-1" $text>\${item[this.labelfield]}</div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
Component.register('GroupedList', container => {
|
||||
Component.getSetupFunction('List')(container)
|
||||
container._selectItem = container.selectItem
|
||||
container.selectItem = (item, index) => {
|
||||
container.state.selectedGroup = null
|
||||
container._selectItem(item, index)
|
||||
}
|
||||
Component.attachSelectFeature(container, 'groupclick', 'selectGroup', 'groupActiveTag', 'selectedGroup', 'groupidfield', container.hasAttribute('auto-select') && 'group-selectable')
|
||||
container._selectGroup = container.selectGroup
|
||||
container.selectGroup = (item, index) => {
|
||||
container.state.selectedItem = null
|
||||
container._selectGroup(item, index)
|
||||
}
|
||||
|
||||
const defaultSets = { typefield: 'type', groupidfield: 'id', grouplabelfield: 'label', groupsummaryfield: 'summary', groupfield: 'group' }
|
||||
container.state.__watch('list', oldList => {
|
||||
Util.updateDefaults(container, defaultSets)
|
||||
const newList = [];
|
||||
(container.state.groups || []).forEach(group => {
|
||||
newList.push({ [container.typefield]: 'group', ...group })
|
||||
if (oldList) oldList.forEach(item => {
|
||||
if (item[container.groupfield] === group[container.groupidfield]) {
|
||||
newList.push({ [container.typefield]: 'item', ...item })
|
||||
}
|
||||
})
|
||||
})
|
||||
aa = container
|
||||
return newList
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="list-group overflow-auto">
|
||||
<template $each="this.state.list">
|
||||
<div slot-id="group" $if="item.type === 'group'" $onclick="this.selectGroup(item,index)" style="height: 36px" $class="list-group-item list-group-item-action small d-inline-flex align-items-center ps-2 pe-2 \${this.groupActiveTag(item)}">
|
||||
<span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span>
|
||||
<div class="flex-shrink-0 px-1" $text>\${item[this.grouplabelfield]}</div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.groupsummaryfield]"></div>
|
||||
<div slot-id="group-actions"></div>
|
||||
</div>
|
||||
<div slot-id="item" $if="item.type === 'item'" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action d-inline-flex align-items-baseline ps-3 pe-2 \${this.itemActiveTag?.(item)}">
|
||||
<span $if="this.itemicon" $class="bi bi-\${this.itemicon} text-body"></span>
|
||||
<div class="flex-shrink-0 px-1" $text>\${item[this.labelfield]}</div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
`))
|
||||
|
||||
Component.register('FastGroupedList', container => {
|
||||
Component.getSetupFunction('List')(container)
|
||||
Component.getSetupFunction('GroupedList')(container)
|
||||
}, Util.makeDom(/*html*/`
|
||||
<FastList class="list-group">
|
||||
<div slot="item">
|
||||
<div slot-id="group" $if="item.type === 'group'" $onclick="this.selectGroup(item,index)" style="height: 36px" $class="list-group-item list-group-item-action small d-inline-flex align-items-center ps-2 pe-2 \${this.groupActiveTag(item)}">
|
||||
<span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span>
|
||||
<div class="flex-shrink-0 px-1" $text>\${item[this.grouplabelfield]}</div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.groupsummaryfield]"></div>
|
||||
<div slot-id="group-actions"></div>
|
||||
</div>
|
||||
<div slot-id="item" $if="item.type === 'item'" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action d-inline-flex align-items-baseline ps-3 pe-2 \${this.itemActiveTag?.(item)}">
|
||||
<span $if="this.itemicon" $class="bi bi-\${this.itemicon} text-body"></span>
|
||||
<div class="flex-shrink-0 px-1" $text>\${item[this.labelfield]}</div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</div>
|
||||
</div>
|
||||
</FastList>
|
||||
`))
|
||||
|
||||
Component.register('Tree', container => {
|
||||
const defaultSets = { parentfield: 'parent', groupicon: 'folder', itemicon: 'file' }
|
||||
container.state.__watch('list', () => {
|
||||
Util.updateDefaults(container, defaultSets)
|
||||
const childrenMap = {};
|
||||
(container.state.list || []).forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
|
||||
const newList = []
|
||||
const traverse = (list, level) => list.forEach(item => {
|
||||
const id = item[container.idfield]
|
||||
const hasChildren = !!childrenMap[id]?.length
|
||||
newList.push({ ...item, _level: level, _hasChildren: hasChildren })
|
||||
if (hasChildren) traverse(childrenMap[id], level + 1)
|
||||
})
|
||||
traverse(childrenMap[''] || [], 0)
|
||||
return newList
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<List>
|
||||
<div slot="item" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
|
||||
<div $style="width:\${item._level * 16}px" class="flex-shrink-0"></div>
|
||||
<div $class="text-muted bi bi-\${item._hasChildren?this.groupicon:this.itemicon}"></div>
|
||||
<div class="flex-grow-1 text-truncate px-2" $text="item[this.labelfield]+'!!'"></div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</div>
|
||||
</List>
|
||||
`))
|
||||
|
||||
Component.register('FastTree', container => {
|
||||
Component.getSetupFunction('List')(container)
|
||||
Component.getSetupFunction('Tree')(container)
|
||||
}, Util.makeDom(/*html*/`
|
||||
<FastList class="list-group list-group-action">
|
||||
<div slot="item" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
|
||||
<div $style="width:\${item._level * 16}px" class="flex-shrink-0"></div>
|
||||
<div $class="text-muted bi bi-\${item._hasChildren?this.groupicon:this.itemicon}"></div>
|
||||
<div class="flex-grow-1 text-truncate px-2" $text="item[this.labelfield]"></div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</div>
|
||||
</FastList>
|
||||
`))
|
||||
|
||||
Component.register('CollapseTree', container => {
|
||||
Component.attachSelectFeature(container)
|
||||
const defaultSets = { idfield: 'id', parentfield: 'parent', labelfield: 'label', summaryfield: 'summary' }
|
||||
container.collapsed = NewState({})
|
||||
container.state.__watch('list', () => {
|
||||
Util.updateDefaults(container, defaultSets)
|
||||
const childrenMap = {};
|
||||
(container.state.list || []).forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
|
||||
const newList = []
|
||||
const traverse = (list, level, parents) => list.forEach(item => {
|
||||
const id = item[container.idfield]
|
||||
const hasChildren = !!childrenMap[id]?.length
|
||||
newList.push({ ...item, _level: level, _hasChildren: hasChildren, _parents: parents })
|
||||
if (hasChildren) traverse(childrenMap[id], level + 1, [...parents, id])
|
||||
})
|
||||
traverse(childrenMap[''] || [], 0, [])
|
||||
return newList
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<List>
|
||||
<div slot="item" slot-id="item" $if="!item._parents.some(cur=>this.collapsed[cur])" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
|
||||
<div $style="width:\${item._level * 16 + 16}px; cursor: pointer;" class="text-end text-muted flex-shrink-0" $onclick="event.stopPropagation();this.collapsed[item[this.idfield]] = !this.collapsed[item[this.idfield]]">
|
||||
<i $if="item._hasChildren" $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 text-truncate px-1" $text="item[this.labelfield]"></div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</div>
|
||||
</List>
|
||||
`))
|
||||
|
||||
Component.register('Nav', container => {
|
||||
container.click = (item, noselect) => {
|
||||
if (!item.noselect && !noselect) Hash.nav = item.name
|
||||
container.dispatchEvent(new CustomEvent('nav', { detail: { item }, bubbles: false }))
|
||||
}
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="navbar navbar-expand bg-body-secondary px-3 pb-0 border-bottom align-items-center">
|
||||
<img $if="this.state.brand.image" $src="this.state.brand.image" class="me-2" style="height:30px;width:auto;max-width:300px">
|
||||
<i $if="this.state.brand.icon" $class="bi bi-\${this.state.brand.icon} me-2"></i>
|
||||
<span $if="this.state.brand.label" class="me-2" $text="this.state.brand.label"></span>
|
||||
<div class="ms-2"></div>
|
||||
<div $each="this.state.list" $class="navbar-nav text-truncate \${item.type==='fill'?'flex-fill':''}">
|
||||
<button $if="item.type==='button'" $class="nav-link \${Hash.nav===item.name?'active':''}" $onclick="this.click(item)">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="d-none d-\${this.state.list.length>5?'lg':'md'}-inline" $text="item.label"></span>
|
||||
</button>
|
||||
<div $if="item.type==='dropdown'" class="dropdown">
|
||||
<button $class="nav-link \${Hash.nav===item.name?'active':''}" data-bs-toggle="dropdown">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="d-none d-\${this.state.list.length>5?'lg':'md'}-inline" $text="item.label"></span>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-end p-3 bg-body-secondary shadow" $style="width: \${item.width || 250}px;">
|
||||
<template $each="item.list" as="subitem">
|
||||
<button $if="subitem.type==='button'" class="nav-link px-0 w-100 text-start" $onclick="this.click(subitem, true)">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
|
||||
</button>
|
||||
<div $if="subitem.type==='switch'" class="d-flex align-items-center">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
|
||||
<div class="form-switch"><input class="form-check-input mx-0" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
let _mouseMoverMoving = false
|
||||
let _mouseMoverPos = {}
|
||||
let _mouseMoverEvents = {}
|
||||
globalThis.MouseMover = {
|
||||
start: (event, { onmousemove, onmouseup }) => {
|
||||
_mouseMoverPos = { x: event.clientX, y: event.clientY, w: 0, h: 0 }
|
||||
_mouseMoverEvents = { onmousemove, onmouseup }
|
||||
_mouseMoverMoving = true
|
||||
},
|
||||
}
|
||||
document.addEventListener('mouseup', event => {
|
||||
if (!_mouseMoverMoving) return
|
||||
_mouseMoverMoving = false
|
||||
_mouseMoverEvents.onmouseup?.({ event, ..._mouseMoverPos })
|
||||
})
|
||||
document.addEventListener('mousemove', event => {
|
||||
if (!_mouseMoverMoving) return
|
||||
_mouseMoverPos.w = event.clientX - _mouseMoverPos.x
|
||||
_mouseMoverPos.h = event.clientY - _mouseMoverPos.y
|
||||
_mouseMoverEvents.onmousemove?.({ event, ..._mouseMoverPos })
|
||||
})
|
||||
|
||||
Component.register('Resizer', container => {
|
||||
const isVertical = container.hasAttribute('vertical')
|
||||
const min = parseInt(container.getAttribute('min')) || 10
|
||||
const max = parseInt(container.getAttribute('max')) || 1000
|
||||
const target = container.target || container.previousElementSibling
|
||||
const getSize = (startSize, w, h) => {
|
||||
const newSize = startSize + (isVertical ? h : w)
|
||||
return newSize < min ? min : newSize > max ? max : newSize
|
||||
}
|
||||
container.addEventListener('mousedown', event => {
|
||||
const startSize = isVertical ? target.offsetHeight : target.offsetWidth
|
||||
MouseMover.start(event, {
|
||||
onmousemove: ({ w, h }) => target.style[isVertical ? 'height' : 'width'] = getSize(startSize, w, h) + 'px',
|
||||
onmouseup: ({ w, h }) => container.dispatchEvent(new CustomEvent('resize', { detail: { oldSize: startSize, newSize: getSize(startSize, w, h) }, bubbles: false })),
|
||||
})
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div $class="border-\${this.isVertical?'top':'start'}" $style="\${this.isVertical?'height':'width'}:3px;cursor:\${this.isVertical?'row-resize':'col-resize'}"></div>
|
||||
`))
|
||||
|
||||
})()
|
||||
139
src/form.js
139
src/form.js
@ -1,24 +1,30 @@
|
||||
import { Component, NewState, Util, $ } from '@apigo.cc/state'
|
||||
import { Component, NewState, Util, $, State } from '@apigo.cc/state'
|
||||
import { HTTP } from './http.js'
|
||||
|
||||
Component.register('AutoForm', container => {
|
||||
if (!container.state.schema) container.state.schema = []
|
||||
|
||||
// TRY: 确保 state.data 始终是响应式 Proxy,支持深层嵌套属性的监听
|
||||
const ensureProxy = v => (v && typeof v === 'object' && !v.__isProxy) ? NewState(v) : v;
|
||||
container.state.__watch('data', v => container.data = ensureProxy(v));
|
||||
container.data = ensureProxy(container.state.data || {});
|
||||
|
||||
container.vertical = container.hasAttribute('vertical')
|
||||
container.inline = container.hasAttribute('inline')
|
||||
container.nobutton = container.hasAttribute('nobutton')
|
||||
container.request = { method: 'POST' }
|
||||
container.response = {}
|
||||
container.result = null
|
||||
|
||||
// Ensure data is reactive and mapped to state.data for template context
|
||||
Object.defineProperty(container, 'data', {
|
||||
get: () => container.state.data,
|
||||
set: v => { container.state.data = (v?.__isProxy ? v : NewState(v || {})); }
|
||||
})
|
||||
container.data = container.data || {} // Initialize
|
||||
container._thisObj = container
|
||||
|
||||
// TODO: $$ 双重计算机制暂不提供循环依赖保护,schema 编写者需确保 item.if 逻辑无副作用
|
||||
// 初始化数据联动:如果处于行编辑模式(inline),监听全局编辑状态
|
||||
if (container.inline) {
|
||||
State.__watch('editingData', (data) => {
|
||||
container.data = data
|
||||
})
|
||||
State.__watch('editingSchema', (schema) => {
|
||||
container.state.schema = schema
|
||||
})
|
||||
}
|
||||
|
||||
container.form = $(container, 'form')
|
||||
container.submit = (opt = {}) => {
|
||||
if (!container.form.reportValidity()) return globalThis.UI?.toast?.('{#verify failed#}', { type: 'danger' })
|
||||
@ -40,33 +46,64 @@ Component.register('AutoForm', container => {
|
||||
}
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div $class="auto-form-root d-flex \${this.inline ? 'auto-form-inline h-100 w-100' : ''}">
|
||||
<form $class="w-100 \${this.inline ? 'd-flex align-items-stretch flex-fill h-100' : (this.vertical ? 'd-flex flex-column flex-fill' : 'auto-grid-form')}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
|
||||
<template $each="this.state.schema || []" as="item">
|
||||
<div $$if="item.if || 'true'" style="display:contents">
|
||||
<label $if="!this.inline" $name="item.name" class="col-form-label text-muted" $text="item.label"></label>
|
||||
<div control-wrapper $class="\${this.inline ? 'flex-grow-1 h-100 d-flex align-items-center' : 'mb-3'}">
|
||||
<input $if="['text', 'password', 'email', 'number', 'date', 'datetime', 'file'].includes(item.type)" $name="item.name" $type="item.type" $.="item.setting || {}" $bind="this.data[item.name]" $class="form-control h-100 \${item.type === 'number' ? 'text-end' : ''}">
|
||||
<select $if="item.type === 'select'" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="form-select h-100">
|
||||
<option value="" $if="item.placeholder" $text="item.placeholder" disabled></option>
|
||||
<option $each="item.options" $value="item.value || item" $text="item.label || item"></option>
|
||||
</select>
|
||||
<div $if="['checkbox', 'radio'].includes(item.type)" $class="\${this.inline ? 'h-100 d-flex align-items-center px-2 gap-3 justify-content-center w-100 bg-body' : ''}" style="white-space:nowrap; border-radius: 4px;">
|
||||
<label $each="item.options || [item.text||item.label||item.name]" as="option" $class="form-check \${item.vertical ? '' : 'form-check-inline'} mb-0 d-flex align-items-center p-0">
|
||||
<input $name="item.name" class="form-check-input me-1" $type="item.type" $.="item.setting || {}" $value="item.options?option:'on'" $bind="this.data[item.name]">
|
||||
<span $if="!this.inline || (item.options && item.options.length > 0)" $text="option" class="form-check-label"></span>
|
||||
</label>
|
||||
<form $class="w-100 d-flex flex-fill \${this.inline ? 'align-items-stretch h-100' : (this.vertical ? 'flex-column' : 'auto-grid-form')}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
|
||||
<template $each="this.state.schema || []">
|
||||
<template $if="item.if || 'true'">
|
||||
<div style="display:contents">
|
||||
<template $if="!this.inline">
|
||||
<label $name="item.name" class="col-form-label text-muted" $text="item.label"></label>
|
||||
</template>
|
||||
<div control-wrapper $class="\${this.inline ? 'flex-grow-1 h-100 d-flex align-items-center' : 'mb-3'}">
|
||||
<template $if="['text', 'password', 'email', 'number', 'date', 'datetime', 'file'].includes(item.type)">
|
||||
<input $name="item.name" $type="item.type" $.="item.setting || {}" $bind="this.data[item.name]" $class="form-control h-100 \${item.type === 'number' ? 'text-end' : ''}">
|
||||
</template>
|
||||
<template $if="item.type === 'select'">
|
||||
<select $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="form-select h-100">
|
||||
<template $if="item.placeholder">
|
||||
<option value="" $text="item.placeholder" disabled selected></option>
|
||||
</template>
|
||||
<template $each="item.options" as="opt">
|
||||
<option $value="opt.value || opt" $text="opt.label || opt"></option>
|
||||
</template>
|
||||
</select>
|
||||
</template>
|
||||
<template $if="['checkbox', 'radio'].includes(item.type)">
|
||||
<div $class="\${this.inline ? 'h-100 d-flex align-items-center px-2 gap-3 justify-content-center w-100 bg-body' : ''}" style="white-space:nowrap; border-radius: 4px;">
|
||||
<template $each="item.options || [item.text||item.label||item.name]" as="option">
|
||||
<label $class="form-check \${item.vertical ? '' : 'form-check-inline'} mb-0 d-flex align-items-center p-0">
|
||||
<input $name="item.name" class="form-check-input me-1" $type="item.type" $.="item.setting || {}" $value="item.options?option:'on'" $bind="this.data[item.name]">
|
||||
<template $if="!this.inline || (item.options && item.options.length > 0)">
|
||||
<span $text="option" class="form-check-label"></span>
|
||||
</template>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template $if="item.type === 'switch'">
|
||||
<div $class="form-check form-switch fs-4 h-100 d-flex align-items-center px-2 m-0 \${this.inline ? 'justify-content-center w-100' : ''}" style="padding-left:0">
|
||||
<input $name="item.name" class="form-check-input m-0" type="checkbox" style="cursor:pointer" $bind="this.data[item.name]">
|
||||
</div>
|
||||
</template>
|
||||
<template $if="item.type === 'textarea'">
|
||||
<textarea $name="item.name" class="form-control" $.="item.setting || {}" $bind="this.data[item.name]"></textarea>
|
||||
</template>
|
||||
|
||||
<!-- 扩展控件容器 -->
|
||||
<template $each="AutoForm.customTypes" as="ext">
|
||||
<template $if="item.type?.toLowerCase() === ext.typeName.toLowerCase()">
|
||||
<div $is="ext.name" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="h-100"></div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<div $if="item.type === 'switch'" $class="form-check form-switch fs-4 h-100 d-flex align-items-center px-2 m-0 \${this.inline ? 'justify-content-center w-100' : ''}" style="padding-left:0">
|
||||
<input $name="item.name" class="form-check-input m-0" type="checkbox" style="cursor:pointer" $bind="this.data[item.name]" $onchange="event.stopPropagation()">
|
||||
</div>
|
||||
<textarea $if="item.type === 'textarea'" $name="item.name" class="form-control" $.="item.setting || {}" $bind="this.data[item.name]"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template $if="!this.inline">
|
||||
<div class="d-flex justify-content-end align-items-baseline gap-3 mt-3" style="grid-column:1/-1">
|
||||
<div slot-id="actions"></div>
|
||||
<button type="submit" class="btn btn-primary" $text="this.submitlabel || '{#Submit#}'"></button>
|
||||
</div>
|
||||
</template>
|
||||
<div $if="!this.inline && !this.nobutton" class="d-flex justify-content-end align-items-baseline gap-3 mt-3" style="grid-column:1/-1">
|
||||
<div slot-id="actions"></div>
|
||||
<button type="submit" class="btn btn-primary" $text="this.submitlabel || '{#Submit#}'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`), Util.makeDom(/*html*/`<style>
|
||||
@ -78,39 +115,14 @@ Component.register('AutoForm', container => {
|
||||
.auto-form-inline textarea.form-control { padding: 8px; min-height: 100px; height: auto; }
|
||||
</style>`))
|
||||
|
||||
const _pendingAutoFormComponents = []
|
||||
export const AutoForm = {
|
||||
customTypes: NewState([]),
|
||||
register: (name, typeName) => {
|
||||
const entry = { name, typeName: typeName || name }
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState !== 'loading' && Component.getTemplate('AutoForm')) AutoForm._addAutoFormComponent(entry)
|
||||
else _pendingAutoFormComponents.push(entry)
|
||||
}
|
||||
},
|
||||
_addAutoFormComponent: entry => {
|
||||
const { name, typeName } = entry
|
||||
const template = Component.getTemplate('AutoForm')
|
||||
if (template) {
|
||||
let container = $(template.content, '[control-wrapper]')
|
||||
if (!container) {
|
||||
const nested = $(template.content, 'template')
|
||||
if (nested) container = $(nested.content, '[control-wrapper]')
|
||||
}
|
||||
if (container && !container.querySelector(name)) {
|
||||
container.appendChild(Util.makeDom(`<${name} $if="item.type?.toLowerCase() === '${typeName.toLowerCase()}'" $name="item.name" $.="item.setting || {}" $bind="thisNode.closest('AutoForm').data[item.name]"></${name}>`))
|
||||
}
|
||||
const type = typeName || name
|
||||
if (!AutoForm.customTypes.find(t => t.name === name)) {
|
||||
AutoForm.customTypes.push({ name, typeName: type })
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const initAutoForm = () => {
|
||||
_pendingAutoFormComponents.forEach(entry => AutoForm._addAutoFormComponent(entry))
|
||||
_pendingAutoFormComponents.length = 0
|
||||
}
|
||||
if (document.readyState !== 'loading') setTimeout(initAutoForm, 100)
|
||||
else document.addEventListener('DOMContentLoaded', () => setTimeout(initAutoForm, 100), true)
|
||||
}
|
||||
|
||||
Component.register('TagsInput', container => {
|
||||
@ -160,4 +172,3 @@ Component.register('TagsInput', container => {
|
||||
</style>`))
|
||||
|
||||
AutoForm.register('TagsInput')
|
||||
|
||||
|
||||
@ -29,6 +29,8 @@ import { UI } from './ui.js'
|
||||
import { AutoForm } from './form.js'
|
||||
import { MouseMover } from './interaction.js'
|
||||
import { VirtualScroll } from './list.js'
|
||||
import './nav.js'
|
||||
import './controls.js'
|
||||
|
||||
globalThis.HTTP = HTTP
|
||||
globalThis.UI = UI
|
||||
@ -37,7 +39,8 @@ globalThis.MouseMover = MouseMover
|
||||
globalThis.VirtualScroll = VirtualScroll
|
||||
|
||||
const ApigoBase = {
|
||||
HTTP, UI, AutoForm, MouseMover, VirtualScroll, State
|
||||
HTTP, UI, AutoForm, MouseMover, VirtualScroll, State,
|
||||
List: VirtualScroll
|
||||
};
|
||||
|
||||
import { RefreshState } from '@apigo.cc/state'
|
||||
|
||||
22
src/list.js
22
src/list.js
@ -212,17 +212,27 @@ Component.register('List', container => {
|
||||
<template slot-id="item" $each="this.state?._renderedList">
|
||||
<div $onupdate="this.onItemUpdate(index, thisNode)" $class="list-group-item list-group-item-action d-inline-flex align-items-center ps-2 pe-2 \${item.type==='group'?(this.state?.selectedGroup===item[this.groupidfield]?'active':''):(this.state?.selectedItem===item[this.idfield]?'active':'')}" $onclick="item.type==='group'?this.selectGroup(item,index):this.selectItem(item,index)">
|
||||
<template $if="item.type === 'group'">
|
||||
<span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span>
|
||||
<template $if="this.groupicon">
|
||||
<span $class="bi bi-\${this.groupicon} text-body"></span>
|
||||
</template>
|
||||
<div class="flex-shrink-0 px-1" $text="\${item[this.grouplabelfield]}"></div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="\${item[this.groupsummaryfield]}"></div>
|
||||
<div slot-id="group-actions"></div>
|
||||
</template>
|
||||
<template $if="item.type === 'item'">
|
||||
<div $if="this.mode === 'tree'" $style="width:\${item._level * 16 + (this.collapsible ? 16 : 0)}px; cursor:\${this.collapsible ? 'pointer' : 'default'}" class="text-end text-muted flex-shrink-0" $onclick="event.stopPropagation(); this.toggleCollapse(item)">
|
||||
<i $if="this.collapsible && item._hasChildren" $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
|
||||
</div>
|
||||
<span $if="this.mode === 'tree'" $class="text-muted bi bi-\${item._hasChildren ? this.groupicon : this.itemicon}"></span>
|
||||
<span $if="this.mode !== 'tree' && this.itemicon" class="bi bi-\${this.itemicon} text-body"></span>
|
||||
<template $if="this.mode === 'tree'">
|
||||
<div $style="width:\${item._level * 16 + (this.collapsible ? 16 : 0)}px; cursor:\${this.collapsible ? 'pointer' : 'default'}" class="text-end text-muted flex-shrink-0" $onclick="event.stopPropagation(); this.toggleCollapse(item)">
|
||||
<template $if="this.collapsible && item._hasChildren">
|
||||
<i $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template $if="this.mode === 'tree'">
|
||||
<span $class="text-muted bi bi-\${item._hasChildren ? this.groupicon : this.itemicon}"></span>
|
||||
</template>
|
||||
<template $if="this.mode !== 'tree' && this.itemicon">
|
||||
<span $class="bi bi-\${this.itemicon} text-body"></span>
|
||||
</template>
|
||||
<div class="flex-shrink-0 px-1" $text="\${item[this.labelfield]}"></div>
|
||||
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="\${item[this.summaryfield]}"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
|
||||
59
src/nav.js
59
src/nav.js
@ -8,31 +8,46 @@ Component.register('Nav', container => {
|
||||
}
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-3 \${this.vertical ? 'py-3' : 'pb-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}">
|
||||
<img $if="this.state.brand.image" $src="this.state.brand.image" $class="\${this.vertical ? 'mb-4' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
|
||||
<i $if="this.state.brand.icon" $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-4' : 'me-2'}"></i>
|
||||
<span $if="this.state.brand.label" $class="\${this.vertical ? 'mb-4 fw-bold' : 'me-2'}" $text="this.state.brand.label"></span>
|
||||
<template $if="this.state?.brand?.image">
|
||||
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-4' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
|
||||
</template>
|
||||
<template $if="this.state?.brand?.icon">
|
||||
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-4' : 'me-2'}"></i>
|
||||
</template>
|
||||
<template $if="this.state?.brand?.label">
|
||||
<span $class="\${this.vertical ? 'mb-4 fw-bold' : 'me-2'}" $text="this.state.brand.label"></span>
|
||||
</template>
|
||||
<div $class="\${this.vertical ? 'w-100' : 'ms-2'}"></div>
|
||||
<div $each="this.state.list" $class="\${this.vertical ? 'nav nav-pills flex-column w-100' : 'navbar-nav'} text-truncate \${item.type==='fill'?'flex-fill':''}">
|
||||
<button $if="item.type==='button'" $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" $onclick="this.click(item)">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state.list.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||
</button>
|
||||
<div $if="item.type==='dropdown'" $class="dropdown \${this.vertical ? 'w-100' : ''}">
|
||||
<button $class="nav-link \${Hash.nav===item.name?'active':''} w-100 \${this.vertical ? 'text-start d-flex justify-content-between align-items-center' : ''}" data-bs-toggle="dropdown">
|
||||
<span><i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state.list.length>5?'lg':'md') + '-inline'}" $text="item.label"></span></span>
|
||||
<i $if="this.vertical" class="bi bi-chevron-right small"></i>
|
||||
</button>
|
||||
<div $class="dropdown-menu \${this.vertical ? 'position-static border-0 bg-transparent shadow-none ps-3' : 'dropdown-menu-end p-3 bg-body-secondary shadow'}" $style="width: \${this.vertical ? '100%' : (item.width || 250) + 'px'};">
|
||||
<template $each="item.list" as="subitem">
|
||||
<button $if="subitem.type==='button'" class="nav-link px-0 w-100 text-start" $onclick="this.click(subitem, true)">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
|
||||
<template $each="this.state?.list || []">
|
||||
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100' : 'navbar-nav'} text-truncate \${item.type==='fill'?'flex-fill':''}">
|
||||
<template $if="item.type==='button'">
|
||||
<button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" $onclick="this.click(item)">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template $if="item.type==='dropdown'">
|
||||
<div class="dropdown">
|
||||
<button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" data-bs-toggle="dropdown">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||
</button>
|
||||
<div $if="subitem.type==='switch'" class="d-flex align-items-center">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
|
||||
<div class="form-switch"><input class="form-check-input mx-0" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
<div $class="dropdown-menu \${this.vertical?'position-static':'dropdown-menu-end'} p-3 bg-body-secondary shadow" $style="width: \${item.width || 250}px;">
|
||||
<template $each="item.list" as="subitem">
|
||||
<template $if="subitem.type==='button'">
|
||||
<button class="nav-link px-0 w-100 text-start" $onclick="this.click(subitem, true)">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template $if="subitem.type==='switch'">
|
||||
<div class="d-flex align-items-center">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
|
||||
<div class="form-switch"><input class="form-check-input mx-0" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
`))
|
||||
|
||||
16
src/ui.js
16
src/ui.js
@ -31,10 +31,14 @@ Component.register('Dialog', Component.getSetupFunction('Modal'), Util.makeDom(/
|
||||
<div class="modal fade" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div $class="modal-content border-\${this.state?.type || 'primary'} border-2 shadow-lg">
|
||||
<div $if="this.state?.title" $class="modal-header py-2 px-3 bg-light fw-bold text-\${this.state?.type || 'primary'}" $text="this.state?.title"></div>
|
||||
<template $if="this.state?.title">
|
||||
<div $class="modal-header py-2 px-3 bg-light fw-bold text-\${this.state?.type || 'primary'}" $text="this.state?.title"></div>
|
||||
</template>
|
||||
<div slot-id="body" class="modal-body p-4"><div $html="this.state?.message"></div></div>
|
||||
<div class="modal-footer py-2 px-3 bg-light">
|
||||
<button $each="this.state?.buttons || ['{#Close#}']" type="button" $class="btn btn-sm px-3 btn-\${index === (this.state?.buttons || []).length - 1 ? (this.state?.type && this.state?.type !== 'body' ? this.state?.type : 'primary') : 'outline-secondary border'}" $onclick="this.result=index+1;this.hide()" $text="\${item}"></button>
|
||||
<template $each="this.state?.buttons || ['{#Close#}']">
|
||||
<button type="button" $class="btn btn-sm px-3 btn-\${index === (this.state?.buttons || []).length - 1 ? (this.state?.type && this.state?.type !== 'body' ? this.state?.type : 'primary') : 'outline-secondary border'}" $onclick="this.result=index+1;this.hide()" $text="\${item}"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -88,12 +92,16 @@ Component.register('Toast', container => {
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<span style="white-space:pre-wrap" class="fs-6" $text="this.state?.message"></span>
|
||||
<span $if="this.state?.left !== undefined" class="small text-dim ms-2" $text="\${this.state?.left}s"></span>
|
||||
<template $if="this.state?.left !== undefined">
|
||||
<span class="small text-dim ms-2" $text="\${this.state?.left}s"></span>
|
||||
</template>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link ms-3 bi bi-x-lg link-reset" style="color:inherit" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end gap-3">
|
||||
<button $each="this.state?.buttons || ['{#Close#}']" type="button" $class="btn btn-sm btn-\${this.state?.type} mt-2" data-bs-dismiss="toast" $onclick="this.result=index+1" $text="item"></button>
|
||||
<template $each="this.state?.buttons || ['{#Close#}']">
|
||||
<button type="button" $class="btn btn-sm btn-\${this.state?.type} mt-2" data-bs-dismiss="toast" $onclick="this.result=index+1" $text="item"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"2d2d5df53aa522571f18-1e4cef84b8acc2026271"
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: atomic_check.spec.js >> Capability demo page atomic tests verification
|
||||
- Location: test/atomic_check.spec.js:3:1
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: page.evaluate: SyntaxError: Failed to execute 'querySelectorAll' on 'Document': 'p[$text="DemoState.testTitle"]' is not a valid selector.
|
||||
at getTexts (eval at evaluate (:302:30), <anonymous>:2:49)
|
||||
at eval (eval at evaluate (:302:30), <anonymous>:5:20)
|
||||
at UtilityScript.evaluate (<anonymous>:304:16)
|
||||
at UtilityScript.<anonymous> (<anonymous>:1:44)
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e2]:
|
||||
- navigation [ref=e4]
|
||||
- generic [ref=e7]:
|
||||
- generic [ref=e8]:
|
||||
- heading "项目概览" [level=2] [ref=e9]
|
||||
- button " 切换主题" [ref=e11] [cursor=pointer]:
|
||||
- generic [ref=e12]:
|
||||
- text: 切换主题
|
||||
- generic [ref=e13]:
|
||||
- generic [ref=e14]:
|
||||
- heading "Apigo Base Mega Demo" [level=1] [ref=e15]
|
||||
- paragraph [ref=e16]: 点击左侧菜单查看不同组件的能力展示
|
||||
- generic [ref=e17]:
|
||||
- heading "基础底层能力验证 (State Core Capability)" [level=4] [ref=e18]
|
||||
- generic [ref=e19]:
|
||||
- generic [ref=e21]:
|
||||
- generic [ref=e22]: 1. $text & 样式绑定
|
||||
- generic [ref=e23]:
|
||||
- paragraph
|
||||
- button "切换颜色" [ref=e24] [cursor=pointer]
|
||||
- generic [ref=e26]:
|
||||
- generic [ref=e27]: 2. $if 显式模板判断
|
||||
- button "切换显示" [ref=e29] [cursor=pointer]
|
||||
- generic [ref=e31]:
|
||||
- generic [ref=e32]: 3. $each 循环渲染
|
||||
- generic [ref=e33]:
|
||||
- list
|
||||
- generic [ref=e35]:
|
||||
- generic [ref=e36]: 4. $if 嵌套 $each
|
||||
- button "切换外层" [ref=e38] [cursor=pointer]
|
||||
- generic [ref=e40]:
|
||||
- generic [ref=e41]: 5. $each 嵌套 $if (条件渲染列表项)
|
||||
- button "切换 Item B 显示" [ref=e43] [cursor=pointer]
|
||||
```
|
||||
32
test/atomic_check.spec.js
Normal file
32
test/atomic_check.spec.js
Normal file
@ -0,0 +1,32 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Capability demo page atomic tests verification', async ({ page }) => {
|
||||
page.on('console', msg => console.log('BROWSER:', msg.text()));
|
||||
|
||||
await page.goto('http://localhost:5173/test/capability.html');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const testResults = await page.evaluate(() => {
|
||||
const getTexts = (sel) => Array.from(document.querySelectorAll(sel)).map(el => el.textContent.trim());
|
||||
const getColors = (sel) => Array.from(document.querySelectorAll(sel)).map(el => el.style.color);
|
||||
|
||||
return {
|
||||
textBinding: getTexts('p[$text="DemoState.testTitle"]'),
|
||||
textColor: getColors('p[$text="DemoState.testTitle"]'),
|
||||
ifTrue: document.querySelector('.alert-success')?.textContent.trim(),
|
||||
ifFalse: document.querySelector('.alert-danger')?.textContent.trim(),
|
||||
eachItems: getTexts('li.list-group-item'),
|
||||
nestedEachInIf: getTexts('.badge.bg-primary'),
|
||||
nestedIfInEach: getTexts('.bg-white')
|
||||
};
|
||||
});
|
||||
|
||||
console.log('Atomic Test Results:', JSON.stringify(testResults, null, 2));
|
||||
|
||||
expect(testResults.textBinding).toContain('Framework Is Active');
|
||||
expect(testResults.ifTrue).toBe('显示的内容 (True)');
|
||||
expect(testResults.ifFalse).toBeUndefined();
|
||||
expect(testResults.eachItems).toHaveLength(3);
|
||||
expect(testResults.nestedEachInIf).toHaveLength(3);
|
||||
expect(testResults.nestedIfInEach).toHaveLength(2); // Item A and C are visible
|
||||
});
|
||||
297
test/capability.html
Normal file
297
test/capability.html
Normal file
@ -0,0 +1,297 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Apigo Base 能力展示 (Mega Demo - Sync)</title>
|
||||
<!-- 引入 Bootstrap 5.3 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
|
||||
<!-- 核心同步脚本加载 (使用本地副本消灭路径风险) -->
|
||||
<script src="./lib/state.js"></script>
|
||||
<script src="./lib/bootstrap.js"></script>
|
||||
<script src="./lib/base.js"></script>
|
||||
|
||||
<style>
|
||||
body { height: 100vh; overflow: hidden; }
|
||||
.main-container { height: 100%; display: flex; }
|
||||
.sidebar { min-width: 200px; max-width: 500px; display: flex; flex-direction: column; }
|
||||
.content { flex: 1; overflow: auto; padding: 20px; }
|
||||
.section-card { margin-bottom: 30px; border: 1px solid #dee2e6; border-radius: 8px; overflow: hidden; }
|
||||
.section-header { background: #f8f9fa; padding: 10px 15px; border-bottom: 1px solid #dee2e6; font-weight: bold; }
|
||||
.section-body { padding: 15px; }
|
||||
[data-bs-theme="dark"] .section-header { background: #343a40; border-bottom-color: #495057; }
|
||||
[data-bs-theme="dark"] .section-card { border-color: #495057; }
|
||||
.btn-xs { padding: 0.1rem 0.3rem; font-size: 0.75rem; }
|
||||
</style>
|
||||
<script>
|
||||
// 1. 静态结构数据 (用于组件初始化,必须在 body 解析前就绪)
|
||||
window.brand = { icon: 'cpu', label: 'Base 能力展示' };
|
||||
window.navList = [
|
||||
{ type: 'button', name: 'welcome', label: '项目概览', icon: 'house' },
|
||||
{ type: 'button', name: 'forms', label: '表单与控件', icon: 'input-cursor-text' },
|
||||
{ type: 'button', name: 'lists', label: '增强列表', icon: 'list-ul' },
|
||||
{ type: 'button', name: 'ui', label: '交互反馈', icon: 'chat-square-dots' },
|
||||
{ type: 'button', name: 'api', label: '网络通讯', icon: 'cloud-arrow-down' }
|
||||
];
|
||||
window.formSchema = [
|
||||
{ name: 'username', label: '用户名', type: 'text', placeholder: '请输入用户名', setting: { required: true } },
|
||||
{ name: 'password', label: '密码', type: 'password' },
|
||||
{ name: 'dateRange', label: '日期范围', type: 'DatePicker', setting: { rangeEnd: 'endDate' } },
|
||||
{ name: 'endDate', label: '结束日期', type: 'date', hidden: true },
|
||||
{ name: 'theme', label: '主题色', type: 'ColorPicker' },
|
||||
{ name: 'icon', label: '图标', type: 'IconPicker' },
|
||||
{ name: 'tags', label: '标签', type: 'TagsInput' },
|
||||
{ name: 'role', label: '角色', type: 'select', options: [{ label: '管理员', value: 'admin' }, { label: '普通用户', value: 'user' }] },
|
||||
{ name: 'gender', label: '性别', type: 'checkbox', options: ['男', '女', '保密'] },
|
||||
{ name: 'notify', label: '接收通知', type: 'switch' }
|
||||
];
|
||||
|
||||
// 2. 初始化全局 State 初始值
|
||||
// 使用 window.State 因为 state.js 已经同步加载完毕
|
||||
Object.assign(window.State, {
|
||||
currentSection: 'welcome',
|
||||
testTitle: 'Synchronous State Ready',
|
||||
testShow: true,
|
||||
testColor: 'blue',
|
||||
testItems: ['Alpha', 'Beta', 'Gamma'],
|
||||
showOuter: true,
|
||||
innerItems: ['Red', 'Green', 'Blue'],
|
||||
members: [
|
||||
{ id: 1, name: 'Alice (1)', odd: true },
|
||||
{ id: 2, name: 'Bob (2)', odd: false },
|
||||
{ id: 3, name: 'Charlie (3)', odd: true },
|
||||
{ id: 4, name: 'David (4)', odd: false },
|
||||
{ id: 5, name: 'Eve (5)', odd: true }
|
||||
],
|
||||
showOddOnly: false,
|
||||
listData: [],
|
||||
listGroups: [
|
||||
{ id: 'group1', label: '开发团队', icon: 'code-slash' },
|
||||
{ id: 'group2', label: '设计团队', icon: 'palette' }
|
||||
],
|
||||
listMode: 'tree',
|
||||
formData: { username: 'ApigoUser', theme: '#0d6efd', tags: ['Fast'] },
|
||||
apiResult: null
|
||||
});
|
||||
|
||||
if (!Hash.nav) Hash.nav = 'welcome';
|
||||
</script>
|
||||
</head>
|
||||
<body $data-bs-theme="LocalStorage.darkMode ? 'dark' : 'light'">
|
||||
|
||||
<div class="main-container">
|
||||
<!-- 侧边栏导航 -->
|
||||
<div id="sidebar" class="sidebar bg-body-tertiary border-end" style="width: 260px;">
|
||||
<Nav id="mainNav" vertical class="flex-fill"
|
||||
$.state.brand="window.brand"
|
||||
$.state.list="window.navList"
|
||||
$onnav="State.currentSection = event.detail.item.name">
|
||||
</Nav>
|
||||
</div>
|
||||
|
||||
<!-- 拖拽调节器 -->
|
||||
<Resizer target="sidebar" min="150" max="450"></Resizer>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="content bg-body">
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 $text="window.navList.find(s => s.name === State.currentSection)?.label || '欢迎'"></h2>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="LocalStorage.darkMode = !LocalStorage.darkMode">
|
||||
<i class="bi" $class="LocalStorage.darkMode ? 'bi-sun' : 'bi-moon'"></i>
|
||||
切换主题
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 1. 表单展示 -->
|
||||
<template $if="State.currentSection === 'forms'">
|
||||
<div class="section-card">
|
||||
<div class="section-header">AutoForm & 扩展控件</div>
|
||||
<div class="section-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<AutoForm id="demoForm" vertical
|
||||
$.state.schema="window.formSchema"
|
||||
$state.data="State.formData"
|
||||
$onsubmit="UI.toast('提交数据: ' + JSON.stringify(event.detail))">
|
||||
</AutoForm>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h6>实时数据预览:</h6>
|
||||
<pre class="bg-light p-2 border rounded" $text="JSON.stringify(State.formData, null, 2)"></pre>
|
||||
<button class="btn btn-primary w-100" onclick="resetFormData()">重置数据</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 2. 列表展示 -->
|
||||
<template $if="State.currentSection === 'lists'">
|
||||
<div class="section-card">
|
||||
<div class="section-header">List 增强列表 (虚拟滚动 + 树形)</div>
|
||||
<div class="section-body">
|
||||
<div class="mb-3 d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="State.listMode = 'normal'">普通列表</button>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="State.listMode = 'group'">分组列表</button>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="State.listMode = 'tree'">树形列表</button>
|
||||
</div>
|
||||
|
||||
<div style="height: 500px;" class="border rounded overflow-hidden">
|
||||
<List id="demoList" fast collapsible auto-select class="h-100 overflow-auto"
|
||||
$mode="State.listMode"
|
||||
$list="State.listData"
|
||||
$.state.groups="State.listGroups"
|
||||
$onitemclick="UI.toast('点击项目: ' + event.detail.item.label)">
|
||||
<template slot-id="item">
|
||||
<i class="bi me-2" $class="item.icon || 'bi-file-earmark'"></i>
|
||||
<span $text="item.label"></span>
|
||||
<span class="ms-auto badge bg-secondary" $text="item.id"></span>
|
||||
</template>
|
||||
</List>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 3. 交互展示 -->
|
||||
<template $if="State.currentSection === 'ui'">
|
||||
<div class="section-card">
|
||||
<div class="section-header">UI 交互工具</div>
|
||||
<div class="section-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4"><button class="btn btn-info w-100" onclick="UI.toast('这是一个通知消息')">Toast (Info)</button></div>
|
||||
<div class="col-md-4"><button class="btn btn-danger w-100" onclick="UI.toast('操作失败!', { type: 'danger' })">Toast (Danger)</button></div>
|
||||
<div class="col-md-4"><button class="btn btn-success w-100" onclick="UI.toastConfirm('你确定要完成吗?').then(r => UI.toast(r ? '已确认' : '已取消'))">Toast Confirm</button></div>
|
||||
<div class="col-md-4"><button class="btn btn-primary w-100" onclick="UI.alert('这是一个警告框')">UI.alert</button></div>
|
||||
<div class="col-md-4"><button class="btn btn-warning w-100" onclick="UI.confirm('你确定要删除吗?').then(r => UI.toast(r ? '已点击确认' : '已点击取消'))">UI.confirm</button></div>
|
||||
<div class="col-md-4"><button class="btn btn-dark w-100" onclick="showComplexDialog()">UI.showDialog</button></div>
|
||||
<div class="col-md-4"><button class="btn btn-secondary w-100" onclick="testHTTP()">HTTP.get Test</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 4. 网络展示 -->
|
||||
<template $if="State.currentSection === 'api'">
|
||||
<div class="section-card">
|
||||
<div class="section-header">API 声明式请求</div>
|
||||
<div class="section-body">
|
||||
<API id="userApi"
|
||||
$request="{ url: 'https://jsonplaceholder.typicode.com/users/1', method: 'GET' }"
|
||||
$onresponse="State.apiResult = event.detail.result"
|
||||
$onerror="UI.toast('API 请求失败: ' + event.detail.error, { type: 'danger' })">
|
||||
</API>
|
||||
<div class="d-flex gap-2 mb-3">
|
||||
<button class="btn btn-primary" onclick="userApi.do()">触发请求</button>
|
||||
<button class="btn btn-outline-secondary" onclick="State.apiResult = null">清空结果</button>
|
||||
</div>
|
||||
<h6>请求结果 (JSONPlaceholder):</h6>
|
||||
<pre class="bg-light p-2 border rounded" $text="State.apiResult ? JSON.stringify(State.apiResult, null, 2) : '等待请求...'"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 默认页 -->
|
||||
<template $if="State.currentSection === 'welcome'">
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-rocket-takeoff text-primary" style="font-size: 5rem;"></i>
|
||||
<h1 class="mt-3">Apigo Base Mega Demo</h1>
|
||||
<p class="text-muted">点击左侧菜单查看不同组件的能力展示</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 p-4 border rounded bg-light">
|
||||
<h4 class="mb-4 border-bottom pb-2 text-start">核心原子能力验证 (State singleton)</h4>
|
||||
|
||||
<div class="row g-4 text-start">
|
||||
<!-- 1. $text & style binding -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">1. $text & 样式绑定</div>
|
||||
<div class="card-body text-center">
|
||||
<p class="fs-4 fw-bold" $text="State.testTitle" $style="'color:' + State.testColor"></p>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="State.testColor = State.testColor === 'blue' ? 'green' : 'blue'">切换颜色</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. $if (True/False) -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">2. $if 显式模板判断</div>
|
||||
<div class="card-body text-center">
|
||||
<template $if="State.testShow">
|
||||
<div class="alert alert-success py-2">已激活 ($if="true")</div>
|
||||
</template>
|
||||
<button class="btn btn-sm btn-outline-primary mt-2" onclick="State.testShow = !State.testShow">切换显示状态</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. $each (Simple) -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">3. $each 循环渲染</div>
|
||||
<div class="card-body">
|
||||
<div class="list-group list-group-flush border rounded">
|
||||
<template $each="State.testItems">
|
||||
<div class="list-group-item py-1" $text="item"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Nested each inside if -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">4. $if 嵌套 $each</div>
|
||||
<div class="card-body text-center">
|
||||
<template $if="State.showOuter">
|
||||
<div class="p-2 border rounded bg-white mb-2">
|
||||
<template $each="State.innerItems">
|
||||
<span class="badge bg-secondary me-1" $text="item"></span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="State.showOuter = !State.showOuter">切换外层容器</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. Nested if inside each (Filter) -->
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
5. $each 嵌套 $if (奇偶成员过滤)
|
||||
<button class="btn btn-xs btn-primary py-0" onclick="State.showOddOnly = !State.showOddOnly" $text="State.showOddOnly ? '显示全部' : '仅看奇数项'"></button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<template $each="State.members">
|
||||
<template $if="!State.showOddOnly || item.odd">
|
||||
<div class="p-2 border rounded bg-white shadow-sm">
|
||||
<i class="bi bi-person-fill text-primary"></i>
|
||||
<span $text="item.name"></span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./capability.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
49
test/capability.js
Normal file
49
test/capability.js
Normal file
@ -0,0 +1,49 @@
|
||||
// 1. 生成演示大列表数据 (异步填充)
|
||||
const generateListData = () => {
|
||||
const data = [];
|
||||
data.push({ id: 'r1', label: '研发中心', icon: 'building', parent: '', group: 'group1' });
|
||||
data.push({ id: 'r2', label: '创意中心', icon: 'brush', parent: '', group: 'group2' });
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
data.push({ id: `dev-${i}`, label: `开发 ${i} 组`, parent: 'r1', group: 'group1' });
|
||||
data.push({ id: `design-${i}`, label: `设计 ${i} 组`, parent: 'r2', group: 'group2' });
|
||||
}
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
data.push({ id: `user-${i}`, label: `成员 ${i}`, parent: i <= 50 ? 'dev-1' : 'design-1', icon: 'person' });
|
||||
}
|
||||
State.listData = data;
|
||||
};
|
||||
|
||||
generateListData();
|
||||
|
||||
// 2. 业务逻辑
|
||||
window.resetFormData = () => {
|
||||
Object.assign(State.formData, {
|
||||
username: 'ApigoUser',
|
||||
dateRange: '2026-06-01',
|
||||
endDate: '2026-06-07',
|
||||
theme: '#0d6efd',
|
||||
icon: 'lightning-charge',
|
||||
tags: ['Fast', 'Powerful', 'Easy'],
|
||||
role: 'admin',
|
||||
gender: ['男'],
|
||||
notify: true
|
||||
});
|
||||
};
|
||||
|
||||
window.showComplexDialog = async () => {
|
||||
const result = await UI.showDialog({
|
||||
title: '系统升级确认',
|
||||
message: '<p>检测到有新版本可用,是否立即升级?</p><div class="alert alert-warning">升级期间服务可能会短暂中断。</div>',
|
||||
buttons: ['以后再说', '详细了解', '立即升级'],
|
||||
type: 'warning'
|
||||
});
|
||||
if (result === 3) UI.toast('开始升级...', { type: 'success' });
|
||||
else if (result === 2) UI.alert('当前版本: v1.0.11\n目标版本: v1.1.0\n更新内容: 修复了若干已知 Bug。');
|
||||
};
|
||||
|
||||
window.testHTTP = async () => {
|
||||
UI.toast('正在发起请求...');
|
||||
const resp = await HTTP.get({ url: 'https://jsonplaceholder.typicode.com/todos/1' });
|
||||
if (resp.ok) UI.alert('HTTP 请求成功:\n' + JSON.stringify(resp.result, null, 2));
|
||||
else UI.toast('请求失败: ' + resp.error, { type: 'danger' });
|
||||
};
|
||||
968
test/lib/base.js
Normal file
968
test/lib/base.js
Normal file
File diff suppressed because one or more lines are too long
7285
test/lib/bootstrap.js
vendored
Normal file
7285
test/lib/bootstrap.js
vendored
Normal file
File diff suppressed because one or more lines are too long
654
test/lib/state.js
Normal file
654
test/lib/state.js
Normal file
@ -0,0 +1,654 @@
|
||||
(function(global, factory) {
|
||||
typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define(["exports"], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, factory(global.ApigoState = {}));
|
||||
})(this, function(exports2) {
|
||||
"use strict";
|
||||
var _a;
|
||||
let _activeBinding = null;
|
||||
let _noWriteBack = null;
|
||||
const setActiveBinding = (val) => _activeBinding = val;
|
||||
const setNoWriteBack = (val) => _noWriteBack = val;
|
||||
const _notifiers = /* @__PURE__ */ new Set();
|
||||
const onNotifyUpdate = (fn) => _notifiers.add(fn);
|
||||
function NewState(defaults = {}, getter = null, setter = null) {
|
||||
const _defaults = {};
|
||||
const _stateMappings = /* @__PURE__ */ new Map();
|
||||
const _watchers = /* @__PURE__ */ new Map();
|
||||
const _watchFunc = (k, cb) => {
|
||||
if (!_watchers.has(k)) _watchers.set(k, /* @__PURE__ */ new Set());
|
||||
!cb ? _watchers.get(k).clear() : _watchers.get(k).add(cb);
|
||||
return () => _watchers.get(k).delete(cb);
|
||||
};
|
||||
const _unwatchFunc = (k, cb) => {
|
||||
if (_watchers.has(k)) _watchers.set(k, /* @__PURE__ */ new Set());
|
||||
_watchers.get(k).delete(cb);
|
||||
};
|
||||
const __getter = getter || ((k) => _defaults[k]);
|
||||
const __setter = setter || ((k, v) => _defaults[k] = v);
|
||||
Object.assign(_defaults, defaults);
|
||||
return new Proxy(_defaults, {
|
||||
get(target, key) {
|
||||
if (key === "__watch") return _watchFunc;
|
||||
if (key === "__unwatch") return _unwatchFunc;
|
||||
if (key === "__isProxy") return true;
|
||||
if (_activeBinding) {
|
||||
if (!_stateMappings.has(key)) _stateMappings.set(key, /* @__PURE__ */ new Set());
|
||||
_stateMappings.get(key).add(_activeBinding);
|
||||
if (!_activeBinding.node._states) _activeBinding.node._states = /* @__PURE__ */ new Set();
|
||||
_activeBinding.node._states.add(_stateMappings);
|
||||
}
|
||||
return __getter(key);
|
||||
},
|
||||
set(target, key, value) {
|
||||
if (__getter(key) !== value) {
|
||||
__setter(key, value);
|
||||
}
|
||||
if (_watchers.has(key)) {
|
||||
_watchers.get(key).forEach((cb) => {
|
||||
const r = cb(value);
|
||||
if (r !== void 0) {
|
||||
value = r;
|
||||
target[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (_watchers.has(null)) {
|
||||
_watchers.get(null).forEach((cb) => cb(value));
|
||||
}
|
||||
if (_stateMappings.has(key)) {
|
||||
const bindings = _stateMappings.get(key);
|
||||
for (const binding of bindings) {
|
||||
if (!binding.node.isConnected) {
|
||||
bindings.delete(binding);
|
||||
continue;
|
||||
}
|
||||
if (_noWriteBack !== binding.node) {
|
||||
_notifiers.forEach((fn) => fn(binding));
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
const $ = (a, b) => b ? a.querySelector(b) : document.querySelector(a);
|
||||
const $$ = (a, b) => b ? a.querySelectorAll(b) : document.querySelectorAll(a);
|
||||
const _components = /* @__PURE__ */ new Map();
|
||||
const _pendingTemplates = [];
|
||||
const Component = {
|
||||
getTemplate: (name) => document.querySelector(`template[component="${name.toUpperCase()}"]`),
|
||||
register: (name, setupFunc, templateNode = null, ...globalNodes) => {
|
||||
console.log("Component.register:", name.toUpperCase());
|
||||
_components.set(name.toUpperCase(), setupFunc);
|
||||
if (document.readyState !== "loading") Component._addTemplate(name, templateNode, globalNodes);
|
||||
else _pendingTemplates.push([name, templateNode, globalNodes]);
|
||||
},
|
||||
exists: (name) => _components.has(name.toUpperCase()),
|
||||
getSetupFunction: (name) => _components.get(name.toUpperCase()),
|
||||
_addTemplate: (name, templateNode, globalNodes) => {
|
||||
if (templateNode) {
|
||||
const template = document.createElement("TEMPLATE");
|
||||
template.setAttribute("component", name.toUpperCase());
|
||||
template.content.appendChild(templateNode);
|
||||
document.body.appendChild(template);
|
||||
}
|
||||
if (globalNodes) globalNodes.forEach((node) => document.body.appendChild(node));
|
||||
},
|
||||
_initPending: () => {
|
||||
_pendingTemplates.forEach(([name, templateNode, globalNodes]) => Component._addTemplate(name, templateNode, globalNodes));
|
||||
_pendingTemplates.length = 0;
|
||||
}
|
||||
};
|
||||
function _mergeNode(from, to, scanObj, exists = {}) {
|
||||
if (from.attributes) {
|
||||
Array.from(from.attributes).forEach((attr) => {
|
||||
if (attr.name === "class") return;
|
||||
if (attr.name === "style") {
|
||||
if (to.hasAttribute("style")) to.setAttribute("style", `${attr.value}; ${to.getAttribute("style")}`);
|
||||
else to.setAttribute("style", attr.value);
|
||||
} else if (!to.hasAttribute(attr.name)) {
|
||||
to.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
to.classList.add(...from.classList);
|
||||
Array.from(from.childNodes).forEach((child) => to.appendChild(child));
|
||||
if (from.tagName && Component.exists(from.tagName)) _makeComponent(from.tagName, to, scanObj, exists);
|
||||
}
|
||||
function _makeComponent(name, node, scanObj, exists = {}) {
|
||||
if (exists[name]) return;
|
||||
exists[name] = true;
|
||||
if (scanObj.thisObj) {
|
||||
Array.from(node.attributes).forEach((attr) => {
|
||||
if ((attr.name.startsWith("$") || attr.name.startsWith("st-")) && attr.value.includes("this.")) {
|
||||
attr.value = attr.value.replace(/\bthis\./g, "this.parent.");
|
||||
}
|
||||
});
|
||||
}
|
||||
const componentFunc = Component.getSetupFunction(name);
|
||||
const slots = {};
|
||||
Array.from(node.childNodes).forEach((child) => {
|
||||
if (child.nodeType === Node.ELEMENT_NODE && child.hasAttribute("slot")) {
|
||||
slots[child.getAttribute("slot")] = child;
|
||||
child.removeAttribute("slot");
|
||||
}
|
||||
});
|
||||
node.innerHTML = "";
|
||||
node.state = NewState(node.state || {});
|
||||
const template = Component.getTemplate(name);
|
||||
if (template) {
|
||||
const tplnode = template.content.cloneNode(true);
|
||||
if (tplnode.childNodes.length) {
|
||||
const rootNode = Array.from(tplnode.childNodes).find((n) => n.nodeType === Node.ELEMENT_NODE);
|
||||
if (rootNode) _mergeNode(rootNode, node, scanObj, exists);
|
||||
$$(node, "[slot-id]").forEach((placeholder) => {
|
||||
const slotName = placeholder.getAttribute("slot-id");
|
||||
if (slots[slotName]) {
|
||||
placeholder.removeAttribute("slot-id");
|
||||
placeholder.innerHTML = "";
|
||||
_mergeNode(slots[slotName], placeholder, scanObj, exists);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (componentFunc) componentFunc(node);
|
||||
}
|
||||
let _disableRunCodeError = false;
|
||||
function setDisableRunCodeError(value) {
|
||||
_disableRunCodeError = value;
|
||||
}
|
||||
const _fnCache = /* @__PURE__ */ new Map();
|
||||
function _runCode(code, vars, thisObj, extendVars) {
|
||||
const allVars = { ...extendVars || {}, ...vars || {} };
|
||||
const argKeys = Object.keys(allVars);
|
||||
const argValues = Object.values(allVars);
|
||||
const cacheKey = code + argKeys.join(",");
|
||||
try {
|
||||
let fn = _fnCache.get(cacheKey);
|
||||
if (!fn) {
|
||||
fn = new Function("Hash", "LocalStorage", "State", ...argKeys, code);
|
||||
_fnCache.set(cacheKey, fn);
|
||||
}
|
||||
return fn.apply(thisObj, [globalThis.Hash, globalThis.LocalStorage, globalThis.State, ...argValues]);
|
||||
} catch (e) {
|
||||
if (!_disableRunCodeError) console.error(e, extendVars, [code, extendVars, vars, thisObj]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function _returnCode(code, vars, thisObj, extendVars) {
|
||||
if (code.includes("${")) return _runCode("return `" + code + "`", vars, thisObj, extendVars);
|
||||
else return _runCode("return " + code, vars, thisObj, extendVars);
|
||||
}
|
||||
let _translator = (text, args) => {
|
||||
if (!text || typeof text !== "string") return text;
|
||||
return text.replace(/\{(.+?)\}/g, (match, key) => args.hasOwnProperty(key) ? args[key] : match);
|
||||
};
|
||||
const SetTranslator = (fn) => _translator = fn;
|
||||
const _translate = (text) => {
|
||||
if (!text || typeof text !== "string" || !text.includes("{#")) return text;
|
||||
return text.replace(/\{#(.+?)#\}/g, (m, content) => {
|
||||
const parts = content.split("||").map((s) => s.trim());
|
||||
const args = {};
|
||||
if (parts.length > 1) {
|
||||
const matches = parts[0].match(/\{(.+?)\}/g);
|
||||
if (matches) matches.forEach((match, i) => args[match.substring(1, match.length - 1)] = parts[i + 1] || "");
|
||||
}
|
||||
return _translator(parts[0], args);
|
||||
});
|
||||
};
|
||||
if (typeof document !== "undefined") {
|
||||
try {
|
||||
document.createElement("div").setAttribute("$t", "1");
|
||||
} catch (e) {
|
||||
const originalSetAttribute = Element.prototype.setAttribute;
|
||||
Element.prototype.setAttribute = function(name, value) {
|
||||
if (!name.startsWith("$")) return originalSetAttribute.call(this, name, value);
|
||||
return originalSetAttribute.call(this, "st-" + name.substring(1), value);
|
||||
};
|
||||
}
|
||||
}
|
||||
onNotifyUpdate((binding) => _updateBinding(binding));
|
||||
function _clearRenderedNodes(node) {
|
||||
if (node._renderedNodes) node._renderedNodes.forEach((nodes) => nodes.forEach((child) => {
|
||||
child.remove();
|
||||
if (child._renderedNodes) _clearRenderedNodes(child);
|
||||
}));
|
||||
}
|
||||
function _updateBinding(binding) {
|
||||
const node = binding.node;
|
||||
if (!node.isConnected && node.tagName !== "TEMPLATE") return;
|
||||
setActiveBinding(binding);
|
||||
let result = binding.exp ? binding.tpl ? _returnCode(binding.tpl, { thisNode: node }, node._thisObj || node, node._ref || null) : null : binding.tpl;
|
||||
if (binding.exp === 2 && typeof result === "string") {
|
||||
try {
|
||||
result = _returnCode(result, { thisNode: node }, node._thisObj || node, node._ref || null);
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
setActiveBinding(null);
|
||||
if (binding.prop) {
|
||||
const prop = binding.prop;
|
||||
let o = node;
|
||||
for (let i = 0; i < prop.length - 1; i++) {
|
||||
if (!prop[i]) continue;
|
||||
if (o[prop[i]] == null) o[prop[i]] = {};
|
||||
o = o[prop[i]];
|
||||
if (typeof o !== "object") break;
|
||||
}
|
||||
if (typeof o === "object" && o !== null) {
|
||||
const resultIsObject = typeof result === "object" && result != null && !Array.isArray(result);
|
||||
const lk = prop[prop.length - 1];
|
||||
if (lk) {
|
||||
if (resultIsObject && o[lk] == null) o[lk] = {};
|
||||
const lo = o[lk];
|
||||
if (typeof lo === "object" && lo != null && lo.__watch) Object.assign(lo, result);
|
||||
else {
|
||||
if (o[lk] !== result) o[lk] = result;
|
||||
}
|
||||
} else if (typeof result === "object" && result != null && !Array.isArray(result)) {
|
||||
Object.assign(o, result);
|
||||
}
|
||||
}
|
||||
} else if (binding.attr) {
|
||||
const attr = binding.attr;
|
||||
if (attr === "if") {
|
||||
if (result) {
|
||||
if (!node._renderedNodes || node._renderedNodes.length === 0) {
|
||||
node._children.forEach((child) => {
|
||||
node.parentNode.insertBefore(child, node);
|
||||
child._ref = { ...node._ref };
|
||||
child._thisObj = node._thisObj;
|
||||
});
|
||||
node._renderedNodes = [node._children];
|
||||
}
|
||||
} else {
|
||||
_clearRenderedNodes(node);
|
||||
node._renderedNodes = [];
|
||||
}
|
||||
} else if (attr === "each") {
|
||||
if (result && typeof result === "object") {
|
||||
const asName = node.getAttribute("as") || "item";
|
||||
const indexName = node.getAttribute("index") || "index";
|
||||
const keyName = node.getAttribute("key");
|
||||
let keys, getVal;
|
||||
if (result instanceof Map) {
|
||||
keys = Array.from(result.keys());
|
||||
getVal = (k) => result.get(k);
|
||||
} else if (typeof result[Symbol.iterator] === "function") {
|
||||
const arr = Array.isArray(result) ? result : Array.from(result);
|
||||
keys = new Array(arr.length);
|
||||
for (let i = 0; i < arr.length; i++) keys[i] = i;
|
||||
getVal = (k) => arr[k];
|
||||
} else {
|
||||
keys = Object.keys(result);
|
||||
getVal = (k) => result[k];
|
||||
}
|
||||
if (!node._keyedNodes) node._keyedNodes = /* @__PURE__ */ new Map();
|
||||
const newKeyedNodes = /* @__PURE__ */ new Map();
|
||||
const currentRenderedNodes = [];
|
||||
keys.forEach((k, i) => {
|
||||
const item = getVal(k);
|
||||
const rawKey = keyName ? item && typeof item === "object" ? item[keyName] : item : k;
|
||||
const keyVal = rawKey === void 0 || rawKey === null || newKeyedNodes.has(rawKey) ? `st_key_${i}` : rawKey;
|
||||
let existingNodes = node._keyedNodes.get(keyVal);
|
||||
if (existingNodes) {
|
||||
node._keyedNodes.delete(keyVal);
|
||||
existingNodes.forEach((child) => {
|
||||
child._ref[indexName] = k;
|
||||
child._ref[asName] = item;
|
||||
_scanTree(child);
|
||||
});
|
||||
} else {
|
||||
existingNodes = [];
|
||||
node._children.forEach((child) => {
|
||||
const cloned = child.cloneNode(true);
|
||||
cloned._ref = { ...node._ref, [indexName]: k, [asName]: item };
|
||||
cloned._thisObj = node._thisObj;
|
||||
node.parentNode.insertBefore(cloned, node);
|
||||
existingNodes.push(cloned);
|
||||
});
|
||||
}
|
||||
newKeyedNodes.set(keyVal, existingNodes);
|
||||
currentRenderedNodes.push(existingNodes);
|
||||
});
|
||||
node._keyedNodes.forEach((nodes) => nodes.forEach((child) => {
|
||||
_clearRenderedNodes(child);
|
||||
child.remove();
|
||||
}));
|
||||
node._keyedNodes = newKeyedNodes;
|
||||
node._renderedNodes = currentRenderedNodes;
|
||||
} else {
|
||||
_clearRenderedNodes(node);
|
||||
node._renderedNodes = [];
|
||||
}
|
||||
} else if (attr === "bind") {
|
||||
if (["INPUT", "SELECT", "TEXTAREA"].includes(node.tagName) && !node.hasAttribute("autocomplete")) node.setAttribute("autocomplete", "off");
|
||||
if (node.type === "checkbox") {
|
||||
if (node.value !== "on" && !result) {
|
||||
_runCode(`${binding.tpl} = []`, { thisNode: node }, node._thisObj || node, node._ref || {});
|
||||
result = [];
|
||||
}
|
||||
node._checkboxMultiMode = result instanceof Array;
|
||||
const isChecked = result instanceof Array ? result.includes(node.value) : !!result;
|
||||
if (node.checked !== isChecked) node.checked = isChecked;
|
||||
} else if (node.type === "radio") {
|
||||
if (node.checked !== (node.value === String(result ?? ""))) node.checked = node.value === String(result ?? "");
|
||||
} else if ("value" in node && node.type !== "file") {
|
||||
Promise.resolve().then(() => {
|
||||
if (node.value !== String(result ?? "")) node.value = result;
|
||||
});
|
||||
} else if (node.isContentEditable) {
|
||||
if (node.innerHTML !== String(result ?? "")) node.innerHTML = result;
|
||||
}
|
||||
node.dispatchEvent(new CustomEvent("bind", { bubbles: false, detail: result }));
|
||||
} else {
|
||||
if (["checked", "disabled", "readonly"].includes(attr)) result = !!result;
|
||||
if (typeof result === "boolean") result ? node.setAttribute(attr, "") : node.removeAttribute(attr);
|
||||
else if (result !== void 0) {
|
||||
if (typeof result !== "string") result = JSON.stringify(result);
|
||||
if (attr === "text") node.textContent = result ?? "";
|
||||
else if (attr === "html") node.innerHTML = result ?? "";
|
||||
else if (node.tagName === "IMG" && attr === "src" && result.includes(".svg")) node.setAttribute("_src", result ?? "");
|
||||
else node.setAttribute(attr, result ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const _initBinding = (binding) => {
|
||||
if (!binding.node._bindings) binding.node._bindings = [];
|
||||
binding.node._bindings.push({ attr: binding.attr, prop: binding.prop, tpl: binding.tpl, exp: binding.exp });
|
||||
_updateBinding(binding);
|
||||
};
|
||||
const _parseNode = (node, scanObj) => {
|
||||
if (node._bindings) {
|
||||
node._states = /* @__PURE__ */ new Set();
|
||||
node._bindings.forEach((b) => _updateBinding({ node, ...b }));
|
||||
if (node._hasOnUpdate) node.dispatchEvent(new Event("update", { bubbles: false }));
|
||||
return;
|
||||
}
|
||||
if (Component.exists(node.tagName) && !node._componentInitialized) {
|
||||
Array.from(node.attributes).forEach((attr) => {
|
||||
var _a2;
|
||||
if (attr.name.startsWith("$.")) {
|
||||
const realAttrName = attr.name.slice(2);
|
||||
let tpl = _translate(attr.value);
|
||||
if (tpl.includes("this.")) tpl = tpl.replace(/\bthis\./g, "this.parent.");
|
||||
const result = _returnCode(tpl, { thisNode: node }, { parent: scanObj.thisObj || node }, node._ref || {});
|
||||
let o = node;
|
||||
const prop = realAttrName.split(".");
|
||||
for (let i = 0; i < prop.length - 1; i++) {
|
||||
if (prop[i]) o = o[_a2 = prop[i]] ?? (o[_a2] = {});
|
||||
}
|
||||
o[prop[prop.length - 1]] = result;
|
||||
node.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
_makeComponent(node.tagName, node, scanObj);
|
||||
$$(node, "[slot-id]").forEach((p) => p.removeAttribute("slot-id"));
|
||||
node._componentInitialized = true;
|
||||
if (!node._thisObj) node._thisObj = node;
|
||||
}
|
||||
if (node.tagName === "TEMPLATE") {
|
||||
node._children = [...node.content.childNodes];
|
||||
if (!node._renderedNodes) node._renderedNodes = [];
|
||||
}
|
||||
let attrs = [];
|
||||
if (node.tagName === "TEMPLATE") {
|
||||
["$if", "$each", "st-if", "st-each"].forEach((n) => node.hasAttribute(n) && attrs.push(node.getAttributeNode(n)));
|
||||
} else {
|
||||
attrs = Array.from(node.attributes).filter((a) => (a.name.startsWith("$") || a.name.startsWith("st-")) && !["$if", "$each", "st-if", "st-each"].includes(a.name) || a.name.includes("."));
|
||||
}
|
||||
if (node._thisObj && scanObj.thisObj) node._thisObj.parent = scanObj.thisObj;
|
||||
if (!node._thisObj) node._thisObj = scanObj.thisObj || null;
|
||||
if (!node._ref) node._ref = scanObj.extendVars || {};
|
||||
node._states = /* @__PURE__ */ new Set();
|
||||
attrs.forEach((attr) => {
|
||||
let exp = 0;
|
||||
if (attr.name.startsWith("$$") || attr.name.startsWith("st-st-")) exp = 2;
|
||||
else if (attr.name.startsWith("$") || attr.name.startsWith("st-")) exp = 1;
|
||||
const realAttrName = exp === 2 ? attr.name.startsWith("$$") ? attr.name.slice(2) : attr.name.slice(6) : exp === 1 ? attr.name.startsWith("$") ? attr.name.slice(1) : attr.name.slice(3) : attr.name;
|
||||
let tpl = attr.value;
|
||||
node.removeAttribute(attr.name);
|
||||
if (realAttrName.startsWith(".")) _initBinding({ node, prop: realAttrName.split("."), tpl, exp });
|
||||
else if (realAttrName.startsWith("on")) {
|
||||
const eventName = realAttrName.slice(2);
|
||||
if (eventName === "update") node._hasOnUpdate = true;
|
||||
if (eventName === "load" && !["BODY", "IMG", "IFRAME"].includes(node.tagName)) node._hasOnLoad = true;
|
||||
if (eventName === "unload" && !["BODY", "IMG", "IFRAME"].includes(node.tagName)) node._hasOnUnload = true;
|
||||
node.addEventListener(eventName, (e) => _runCode(tpl, { event: e, thisNode: node, ...e.detail || {} }, scanObj.thisObj || node, node._ref || {}));
|
||||
} else {
|
||||
if (realAttrName === "bind") {
|
||||
node.addEventListener(["textarea", "text", "password"].includes(node.type || "text") || node.isContentEditable ? "input" : "change", (e) => {
|
||||
let newVal = node.isContentEditable ? e.target.innerHTML : node.type === "checkbox" ? e.target.checked : e.target.files || e.target.value || e.detail;
|
||||
setNoWriteBack(node);
|
||||
setDisableRunCodeError(true);
|
||||
if (node.type === "checkbox" && node._checkboxMultiMode) _runCode(`!!checked ? (!${tpl}.includes(val) && ${tpl}.push(val)) : (index = ${tpl}.indexOf(val), index > -1 && ${tpl}.splice(index, 1))`, { val: node.value, checked: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {});
|
||||
else _runCode(`${tpl} = val`, { val: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {});
|
||||
setDisableRunCodeError(false);
|
||||
setNoWriteBack(null);
|
||||
});
|
||||
} else if (realAttrName === "text" && !tpl) {
|
||||
tpl = node.textContent;
|
||||
node.textContent = "";
|
||||
}
|
||||
if (tpl) {
|
||||
tpl = _translate(tpl);
|
||||
_initBinding({ node, attr: realAttrName, tpl, exp });
|
||||
}
|
||||
}
|
||||
});
|
||||
if (node._hasOnLoad || node._componentInitialized) Promise.resolve().then(() => node.dispatchEvent(new Event("load", { bubbles: false })));
|
||||
if (node._hasOnUpdate) node.dispatchEvent(new Event("update", { bubbles: false }));
|
||||
if (node._thisObj) scanObj.thisObj = node._thisObj;
|
||||
};
|
||||
const _scanTree = (node, scanObj = {}) => {
|
||||
if (node.nodeType !== 1) return;
|
||||
if (!node._stTranslated) {
|
||||
Array.from(node.attributes).forEach((attr) => {
|
||||
if (!attr.name.startsWith("$") && !attr.name.startsWith("st-") && !attr.name.startsWith(".")) {
|
||||
const translated = _translate(attr.value);
|
||||
if (translated !== attr.value) attr.value = translated;
|
||||
}
|
||||
});
|
||||
node._stTranslated = true;
|
||||
}
|
||||
if (node.tagName === "TEMPLATE" && (node.hasAttribute("$if") || node.hasAttribute("st-if")) && (node.hasAttribute("$each") || node.hasAttribute("st-each"))) {
|
||||
const template = document.createElement("TEMPLATE");
|
||||
const attrs = Array.from(node.attributes).filter((attr2) => ["$if", "$each", "st-if", "st-each"].includes(attr2.name));
|
||||
const attr = attrs[attrs.length - 1];
|
||||
template.setAttribute(attr.name, attr.value);
|
||||
node.removeAttribute(attr.name);
|
||||
if (attr.name === "$each" || attr.name === "st-each") {
|
||||
Array.from(node.attributes).filter((attr2) => ["as", "index"].includes(attr2.name)).forEach((attr2) => {
|
||||
template.setAttribute(attr2.name, attr2.value);
|
||||
node.removeAttribute(attr2.name);
|
||||
});
|
||||
}
|
||||
Array.from(node.content.childNodes).forEach((child) => template.content.appendChild(child));
|
||||
node.content.appendChild(template);
|
||||
template._ref = node._ref;
|
||||
}
|
||||
if (node.tagName === "IMG" && (node.hasAttribute("src") || node.hasAttribute("_src") || node.hasAttribute("$src"))) {
|
||||
const imgNode = node;
|
||||
Promise.resolve().then(() => {
|
||||
const url = imgNode.getAttribute("_src") || imgNode.getAttribute("src");
|
||||
if (url) fetch(url, { cache: "force-cache" }).then((r) => r.text()).then((svgText) => {
|
||||
const realSvg = new DOMParser().parseFromString(svgText, "image/svg+xml").querySelector("svg");
|
||||
if (realSvg) {
|
||||
Array.from(imgNode.attributes).forEach((attr) => realSvg.setAttribute(attr.name, attr.value));
|
||||
imgNode.replaceWith(realSvg);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if (node._thisObj !== void 0) scanObj.thisObj = node._thisObj || null;
|
||||
else {
|
||||
let curr = node;
|
||||
while (curr && curr._thisObj === void 0) curr = curr.parentNode;
|
||||
scanObj.thisObj = curr ? curr._thisObj : null;
|
||||
}
|
||||
if (node._ref === void 0) {
|
||||
let curr = node;
|
||||
while (curr && curr._ref === void 0) curr = curr.parentNode;
|
||||
node._ref = curr ? { ...curr._ref } : {};
|
||||
}
|
||||
if (scanObj.extendVars) Object.assign(node._ref, scanObj.extendVars);
|
||||
_parseNode(node, { ...scanObj });
|
||||
const nodes = [...node.childNodes || []];
|
||||
nodes.forEach((child) => _scanTree(child, { thisObj: scanObj.thisObj, extendVars: { ...node._ref } }));
|
||||
};
|
||||
const _unbindTree = (node) => {
|
||||
if (node.nodeType !== 1) return;
|
||||
if (node._hasOnUnload) node.dispatchEvent(new Event("unload", { bubbles: false }));
|
||||
if (node._states) node._states.forEach((mappings) => {
|
||||
for (const [key, bindingSet] of mappings) {
|
||||
for (const binding of bindingSet) {
|
||||
if (binding.node === node) bindingSet.delete(binding);
|
||||
}
|
||||
}
|
||||
});
|
||||
node.childNodes && node.childNodes.forEach((child) => _unbindTree(child));
|
||||
};
|
||||
const RefreshState = _scanTree;
|
||||
const Util = {
|
||||
clone: window.structuredClone || ((obj) => JSON.parse(JSON.stringify(obj))),
|
||||
base64: (str) => btoa(String.fromCharCode(...new TextEncoder().encode(str))),
|
||||
unbase64: (str) => new TextDecoder().decode(Uint8Array.from(atob(str), (c) => c.charCodeAt(0))),
|
||||
urlbase64: (str) => Util.base64(str).replace(/[+/=]/g, (m) => ({ "+": "-", "/": "", "=": "" })[m]),
|
||||
unurlbase64: (str) => Util.unbase64(str.replace(/[-_.]/g, (m) => ({ "-": "+", "_": "/", ".": "=" })[m]).padEnd(Math.ceil(str.length / 4) * 4, "=")),
|
||||
safeJson: (str) => {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
updateDefaults: (obj, defaults) => {
|
||||
for (const k in defaults) if (obj[k] === void 0) obj[k] = defaults[k];
|
||||
},
|
||||
copyFunction: (toObj, fromObj, ...funcNames) => {
|
||||
funcNames.forEach((name) => toObj[name] = fromObj[name].bind(fromObj));
|
||||
},
|
||||
getFunctionBody: (fn) => {
|
||||
const code = fn.toString();
|
||||
return code.slice(code.indexOf("{") + 1, code.lastIndexOf("}")).trim();
|
||||
},
|
||||
makeDom: (html) => {
|
||||
if (html.includes(">\n")) html = html.replace(/>\s+</g, "><").trim();
|
||||
const node = document.createElement("div");
|
||||
node.innerHTML = html;
|
||||
return node.children[0];
|
||||
},
|
||||
newAvg: () => {
|
||||
let total = 0, count = 0, avg = 0;
|
||||
return {
|
||||
add: (v) => {
|
||||
total += v;
|
||||
count++;
|
||||
return avg = total / count;
|
||||
},
|
||||
get: () => avg,
|
||||
clear: () => {
|
||||
total = 0, count = 0, avg = 0;
|
||||
}
|
||||
};
|
||||
},
|
||||
newTimeCount: () => {
|
||||
let startTime = 0, total = 0, count = 0;
|
||||
return {
|
||||
start: () => startTime = (/* @__PURE__ */ new Date()).getTime(),
|
||||
end: () => {
|
||||
const endTime = (/* @__PURE__ */ new Date()).getTime();
|
||||
const left = endTime - startTime;
|
||||
startTime = endTime;
|
||||
total += left;
|
||||
count++;
|
||||
return left;
|
||||
},
|
||||
avg: () => total / count
|
||||
};
|
||||
}
|
||||
};
|
||||
globalThis.Util = Util;
|
||||
let _hashParams = new URLSearchParams(((_a = window.location.hash) == null ? void 0 : _a.substring(1)) || "");
|
||||
const Hash = NewState({}, (k) => Util.safeJson(_hashParams.get(k)), (k, v) => {
|
||||
const oldStr = _hashParams.get(k);
|
||||
const newStr = v === void 0 ? void 0 : JSON.stringify(v);
|
||||
if (oldStr === newStr || oldStr === null && newStr === void 0) return;
|
||||
v === void 0 ? _hashParams.delete(k) : _hashParams.set(k, newStr);
|
||||
window.location.hash = "#" + _hashParams.toString();
|
||||
});
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("hashchange", () => {
|
||||
var _a2;
|
||||
const newParams = new URLSearchParams(((_a2 = window.location.hash) == null ? void 0 : _a2.substring(1)) || "");
|
||||
const keys = /* @__PURE__ */ new Set([..._hashParams.keys(), ...newParams.keys()]);
|
||||
_hashParams = newParams;
|
||||
keys.forEach((k) => Hash[k] = Hash[k]);
|
||||
});
|
||||
}
|
||||
const LocalStorage = NewState({}, (k) => Util.safeJson(localStorage.getItem(k)), (k, v) => {
|
||||
const oldStr = localStorage.getItem(k);
|
||||
const newStr = v === void 0 ? void 0 : JSON.stringify(v);
|
||||
if (oldStr === newStr || oldStr === null && newStr === void 0) return;
|
||||
v === void 0 ? localStorage.removeItem(k) : localStorage.setItem(k, newStr);
|
||||
});
|
||||
const State = NewState({
|
||||
exitBlocks: 0
|
||||
});
|
||||
globalThis.Hash = Hash;
|
||||
globalThis.LocalStorage = LocalStorage;
|
||||
globalThis.State = State;
|
||||
const ApigoState = {
|
||||
NewState,
|
||||
Component,
|
||||
$,
|
||||
$$,
|
||||
RefreshState,
|
||||
SetTranslator,
|
||||
_scanTree,
|
||||
_unbindTree,
|
||||
Util,
|
||||
Hash,
|
||||
LocalStorage,
|
||||
State,
|
||||
_runCode,
|
||||
_returnCode,
|
||||
onNotifyUpdate,
|
||||
setActiveBinding
|
||||
};
|
||||
if (typeof window !== "undefined") {
|
||||
window.ApigoState = ApigoState;
|
||||
}
|
||||
if (typeof document !== "undefined") {
|
||||
const init = () => {
|
||||
const htmlNode = document.documentElement;
|
||||
if (!htmlNode.hasAttribute("$data-bs-theme") && !htmlNode.hasAttribute("data-bs-theme")) {
|
||||
htmlNode.setAttribute("$data-bs-theme", "LocalStorage.darkMode?'dark':'light'");
|
||||
}
|
||||
new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((newNode) => {
|
||||
if (newNode.isConnected) _scanTree(newNode);
|
||||
});
|
||||
mutation.removedNodes.forEach((oldNode) => _unbindTree(oldNode));
|
||||
});
|
||||
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||
_scanTree(document.documentElement);
|
||||
};
|
||||
if (document.readyState !== "loading") init();
|
||||
else document.addEventListener("DOMContentLoaded", init, true);
|
||||
}
|
||||
exports2.$ = $;
|
||||
exports2.$$ = $$;
|
||||
exports2.Component = Component;
|
||||
exports2.Hash = Hash;
|
||||
exports2.LocalStorage = LocalStorage;
|
||||
exports2.NewState = NewState;
|
||||
exports2.RefreshState = RefreshState;
|
||||
exports2.SetTranslator = SetTranslator;
|
||||
exports2.State = State;
|
||||
exports2.Util = Util;
|
||||
exports2._scanTree = _scanTree;
|
||||
exports2._unbindTree = _unbindTree;
|
||||
Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user