chore: refactor base to component-based architecture By: AICoder
This commit is contained in:
parent
0f90305ae7
commit
402863fba8
8
.vite/deps/_metadata.json
Normal file
8
.vite/deps/_metadata.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "eef72e69",
|
||||
"configHash": "23a093bf",
|
||||
"lockfileHash": "e3b0c442",
|
||||
"browserHash": "e8ef8295",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
937
dist/base.js
vendored
937
dist/base.js
vendored
File diff suppressed because one or more lines are too long
2
dist/base.min.js
vendored
2
dist/base.min.js
vendored
File diff suppressed because one or more lines are too long
44
src/API.js
Normal file
44
src/API.js
Normal file
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* API Component Module
|
||||
*/
|
||||
const APIComponent = globalThis.Component.register('API', container => {
|
||||
container.request = globalThis.NewState({ url: '', method: 'GET', headers: {}, data: null, timeout: 10000, responseType: '' })
|
||||
container.response = globalThis.NewState({ loading: false, ok: null, status: null, error: null, headers: {}, responseType: '', result: null })
|
||||
container.result = globalThis.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
|
||||
globalThis.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 && globalThis.UI?.toast) globalThis.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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
export { APIComponent };
|
||||
136
src/AutoForm.js
Normal file
136
src/AutoForm.js
Normal file
@ -0,0 +1,136 @@
|
||||
/**
|
||||
* AutoForm Core Module
|
||||
*/
|
||||
|
||||
const AUTOFORM_BLUEPRINT = globalThis.Util.makeDom(/*html*/`
|
||||
<div class="auto-form-root">
|
||||
<form $class="\${this.inline ? 'd-flex flex-wrap align-items-center gap-3' : (this.vertical ? 'd-flex flex-column' : (this.horizontal ? 'auto-grid-form forced-horizontal' : 'auto-grid-form'))}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
|
||||
|
||||
<template $each="this.state.schema || []">
|
||||
<div style="display:contents" $if="this.checkIf(item)">
|
||||
<label $name="item.name" $class="\${this.inline ? 'mb-0 text-muted text-nowrap' : 'col-form-label text-muted'}" $text="item.label"></label>
|
||||
<div control-wrapper $class="\${this.inline ? 'd-flex align-items-center' : 'mb-3 d-flex align-items-center'}">
|
||||
<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 \${item.type === 'number' ? 'text-end' : ''}">
|
||||
|
||||
<select $if="item.type === 'select'" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="form-select">
|
||||
<option value="" $if="item.placeholder" $text="item.placeholder" disabled selected></option>
|
||||
<option $each="item.options" as="opt" $value="opt.value !== undefined ? opt.value : opt" $text="opt.label || opt"></option>
|
||||
</select>
|
||||
|
||||
<div $if="['checkbox', 'radio'].includes(item.type)" class="d-flex align-items-center flex-wrap gap-3 h-100">
|
||||
<label $each="item.options || [item.text||item.label||item.name]" as="opt" class="form-check mb-0 d-flex align-items-center" style="padding-left:0; cursor:pointer;">
|
||||
<input $name="item.name" class="form-check-input m-0 me-2" style="float:none;" $type="item.type" $.="item.setting || {}" $value="item.options?opt:'on'" $bind="this.data[item.name]">
|
||||
<span $if="!this.inline || (item.options && item.options.length > 0)" $text="opt.label || opt" class="form-check-label"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div $if="item.type === 'switch'" class="form-check form-switch fs-5 d-flex align-items-center m-0" style="padding-left:0; min-height:0; display:flex !important;">
|
||||
<input $name="item.name" class="form-check-input m-0" style="float:none; cursor:pointer" 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>
|
||||
</template>
|
||||
|
||||
<div $if="!this.nobutton" $class="\${this.inline ? '' : 'd-flex justify-content-end align-items-baseline gap-3 mt-2'}" $style="\${this.inline ? '' : 'grid-column:1/-1'}">
|
||||
<div slot-id="actions"></div>
|
||||
<button type="submit" class="btn btn-primary" $text="this.submitlabel || '{#Submit#}'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`)
|
||||
|
||||
const AUTOFORM_STYLE = globalThis.Util.makeDom(/*html*/`<style>
|
||||
.auto-grid-form { display: block; }
|
||||
.auto-grid-form .col-form-label { text-align: left; margin-bottom: 0.25rem; padding-top: 0; }
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.auto-grid-form { display: grid; grid-template-columns: max-content 1fr; gap: 0 1.5rem; }
|
||||
.auto-grid-form .col-form-label { text-align: right; margin-bottom: 1rem; padding-right: 0; max-width: 200px; padding-top: calc(0.375rem + 1px); }
|
||||
.auto-grid-form [control-wrapper] { min-height: calc(2.25rem + 2px); }
|
||||
}
|
||||
|
||||
.auto-grid-form.forced-horizontal { display: grid !important; grid-template-columns: max-content 1fr !important; gap: 0 1.5rem !important; }
|
||||
.auto-grid-form.forced-horizontal .col-form-label { text-align: right !important; margin-bottom: 1rem !important; padding-right: 0 !important; max-width: 200px !important; padding-top: calc(0.375rem + 1px) !important; }
|
||||
.auto-grid-form.forced-horizontal [control-wrapper] { min-height: calc(2.25rem + 2px) !important; }
|
||||
|
||||
.auto-form-root .form-check-input { width: 1.2em; height: 1.2em; }
|
||||
.auto-form-root .form-switch .form-check-input { width: 2em; }
|
||||
</style>`)
|
||||
|
||||
globalThis.Component.register('AutoForm', container => {
|
||||
if (!container.state.schema) container.state.schema = []
|
||||
|
||||
const ensureProxy = v => (v && typeof v === 'object' && !v.__isProxy) ? globalThis.NewState(v) : v;
|
||||
container.state.__watch('data', v => container.data = ensureProxy(v));
|
||||
container.data = ensureProxy(container.state.data || {});
|
||||
|
||||
container.vertical = container.hasAttribute('vertical')
|
||||
container.horizontal = container.hasAttribute('horizontal')
|
||||
container.inline = container.hasAttribute('inline')
|
||||
container.nobutton = container.hasAttribute('nobutton')
|
||||
container.request = { method: 'POST' }
|
||||
container.response = {}
|
||||
container.result = null
|
||||
|
||||
container.form = globalThis.$(container, 'form')
|
||||
container.submit = (opt = {}) => {
|
||||
if (!container.form.reportValidity()) return globalThis.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 = globalThis.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 => {
|
||||
if (globalThis.UI?.toast) globalThis.UI.toast(err.message, { type: 'danger' })
|
||||
container.dispatchEvent(new CustomEvent('error', { detail: err, bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
container.checkIf = (item) => {
|
||||
if (!item.if) return true
|
||||
try {
|
||||
const fn = new Function('Hash', 'LocalStorage', 'State', 'item', 'data', 'return ' + item.if)
|
||||
return fn.call(container, globalThis.Hash, globalThis.LocalStorage, globalThis.State, item, container.data)
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}, AUTOFORM_BLUEPRINT, AUTOFORM_STYLE)
|
||||
|
||||
const findAnchorInBlueprint = (root) => {
|
||||
let f = root.querySelector('[control-wrapper]');
|
||||
if (f) return f;
|
||||
for (const t of root.querySelectorAll('template')) {
|
||||
f = findAnchorInBlueprint(t.content); if (f) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const AutoForm = {
|
||||
customTypes: [],
|
||||
register: (name, typeName) => {
|
||||
const type = typeName || name
|
||||
if (!AutoForm.customTypes.find(t => t.name === name)) {
|
||||
AutoForm.customTypes.push({ name, typeName: type })
|
||||
AutoForm._addAutoFormComponent(name, type)
|
||||
}
|
||||
},
|
||||
_addAutoFormComponent: (name, type) => {
|
||||
const wrapper = findAnchorInBlueprint(AUTOFORM_BLUEPRINT)
|
||||
if (wrapper) {
|
||||
const node = globalThis.Util.makeDom(`<${name} $if="item.type?.toLowerCase() === '${type.toLowerCase()}'" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="w-100"></${name}>`)
|
||||
wrapper.appendChild(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.AutoForm = AutoForm;
|
||||
export { AutoForm };
|
||||
27
src/ColorPicker.js
Normal file
27
src/ColorPicker.js
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* ColorPicker Component Module
|
||||
*/
|
||||
globalThis.Component.register('ColorPicker', container => {
|
||||
container._thisObj = container;
|
||||
container.state = globalThis.NewState({ value: '#000000' })
|
||||
container.addEventListener('bind', e => {
|
||||
container.state.value = e.detail || '#000000'
|
||||
})
|
||||
Object.defineProperty(container, 'value', {
|
||||
get: () => container.state.value,
|
||||
set: v => { container.state.value = v || '#000000'; }
|
||||
})
|
||||
container.updateValue = (val) => {
|
||||
container.state.value = val
|
||||
container.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: val }))
|
||||
}
|
||||
}, globalThis.Util.makeDom(/*html*/`
|
||||
<div class="d-flex align-items-center gap-2 w-100 h-100">
|
||||
<input type="color" class="form-control form-control-color flex-shrink-0" style="width: 3rem; padding: 0.25rem" $bind="this.state.value" $onchange="this.updateValue(thisNode.value)">
|
||||
<input type="text" class="form-control" $bind="this.state.value" $onchange="this.updateValue(thisNode.value)">
|
||||
</div>
|
||||
`))
|
||||
|
||||
if (globalThis.AutoForm) {
|
||||
globalThis.AutoForm.register('ColorPicker')
|
||||
}
|
||||
60
src/DatePicker.js
Normal file
60
src/DatePicker.js
Normal file
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* DatePicker Component Module
|
||||
*/
|
||||
globalThis.Component.register('DatePicker', container => {
|
||||
container._thisObj = container;
|
||||
container.state = globalThis.NewState({ start: '', end: '' })
|
||||
|
||||
container.addEventListener('bind', e => {
|
||||
container.state.start = e.detail || ''
|
||||
const form = container.closest('AutoForm')
|
||||
const name = container.getAttribute('name')
|
||||
const item = form?.state?.schema?.find(i => i.name === name)
|
||||
const rangeEnd = item?.setting?.rangeEnd || container.rangeEnd
|
||||
if (form && rangeEnd) {
|
||||
container.state.end = form.data[rangeEnd] || ''
|
||||
}
|
||||
})
|
||||
|
||||
Object.defineProperty(container, 'isRange', {
|
||||
get: () => {
|
||||
const form = container.closest('AutoForm')
|
||||
const name = container.getAttribute('name')
|
||||
const item = form?.state?.schema?.find(i => i.name === name)
|
||||
return !!(item?.setting?.rangeEnd || container.rangeEnd)
|
||||
}
|
||||
})
|
||||
|
||||
Object.defineProperty(container, 'value', {
|
||||
get: () => container.state.start,
|
||||
set: v => { container.state.start = v || ''; }
|
||||
})
|
||||
|
||||
container.updateStart = (val) => {
|
||||
container.state.start = val
|
||||
container.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: val }))
|
||||
}
|
||||
|
||||
container.updateEnd = (val) => {
|
||||
container.state.end = val
|
||||
const form = container.closest('AutoForm')
|
||||
const name = container.getAttribute('name')
|
||||
const item = form?.state?.schema?.find(i => i.name === name)
|
||||
const rangeEnd = item?.setting?.rangeEnd || container.rangeEnd
|
||||
if (form && rangeEnd) {
|
||||
form.data[rangeEnd] = val
|
||||
}
|
||||
}
|
||||
}, globalThis.Util.makeDom(/*html*/`
|
||||
<div class="d-flex align-items-center gap-1 w-100">
|
||||
<input type="date" class="form-control h-100" $bind="this.state.start" $onchange="this.updateStart(thisNode.value)">
|
||||
<template $if="this.isRange">
|
||||
<span class="text-muted mx-1">-</span>
|
||||
<input type="date" class="form-control h-100" $bind="this.state.end" $onchange="this.updateEnd(thisNode.value)">
|
||||
</template>
|
||||
</div>
|
||||
`))
|
||||
|
||||
if (globalThis.AutoForm) {
|
||||
globalThis.AutoForm.register('DatePicker')
|
||||
}
|
||||
46
src/Dialog.js
Normal file
46
src/Dialog.js
Normal file
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Dialog Component Module
|
||||
*/
|
||||
globalThis.Component.register('Dialog', globalThis.Component.getSetupFunction('Modal'), globalThis.Util.makeDom(/*html*/`
|
||||
<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">
|
||||
<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 class="modal-body p-4 text-center">
|
||||
<div $text="this.state?.message" class="fs-5 text-secondary"></div>
|
||||
</div>
|
||||
<div class="modal-footer py-2 px-3 bg-light border-0 d-flex justify-content-center gap-3">
|
||||
<template $each="this.state?.buttons">
|
||||
<button type="button" $class="btn btn-\${this.state?.type} px-4" data-bs-dismiss="modal" $onclick="this.result=index+1" $text="item"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
let _dialogCount = 0
|
||||
globalThis.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()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
globalThis.UI.alert = function (message, options = {}) {
|
||||
return globalThis.UI.showDialog({ message, ...options })
|
||||
}
|
||||
globalThis.UI.confirm = function (message, options = {}) {
|
||||
return new Promise((resolve) => globalThis.UI.showDialog({ message, buttons: ['{#Cancel#}', '{#Confirm#}'], ...options }).then(index => resolve(index >= 2)).catch(() => resolve(false)))
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
27
src/Modal.js
Normal file
27
src/Modal.js
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Modal Component Module
|
||||
*/
|
||||
globalThis.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 }))
|
||||
})
|
||||
globalThis.Util.copyFunction(container, container.modal, 'show', 'hide')
|
||||
}, globalThis.Util.makeDom(/*html*/`
|
||||
<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 slot-id="header" class="modal-header py-2 px-3 bg-light">
|
||||
<h6 $class="modal-title fw-bold text-\${this.state?.type || 'primary'}" $text="this.state?.title"></h6>
|
||||
<button type="button" class="btn btn-link ms-2 bi bi-x-lg link-reset p-0" style="color:inherit; text-decoration:none" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div slot-id="body" class="modal-body p-3"></div>
|
||||
<div slot-id="footer" class="modal-footer py-2 px-3 bg-light"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
31
src/MouseMover.js
Normal file
31
src/MouseMover.js
Normal file
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* MouseMover Utility Module
|
||||
*/
|
||||
let _mouseMoverMoving = false
|
||||
let _mouseMoverPos = {}
|
||||
let _mouseMoverEvents = {}
|
||||
|
||||
const MouseMover = {
|
||||
start: (event, { onmousemove, onmouseup }) => {
|
||||
_mouseMoverPos = { x: event.clientX, y: event.clientY, w: 0, h: 0 }
|
||||
_mouseMoverEvents = { onmousemove, onmouseup }
|
||||
_mouseMoverMoving = true
|
||||
},
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
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 })
|
||||
})
|
||||
}
|
||||
|
||||
globalThis.MouseMover = MouseMover;
|
||||
export { MouseMover };
|
||||
35
src/Resizer.js
Normal file
35
src/Resizer.js
Normal file
@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Resizer Component Module
|
||||
*/
|
||||
globalThis.Component.register('Resizer', container => {
|
||||
container.isVertical = container.hasAttribute('vertical')
|
||||
const min = parseInt(container.getAttribute('min')) || 10
|
||||
const max = parseInt(container.getAttribute('max')) || 1000
|
||||
const target = container.target || container.previousElementSibling
|
||||
container.addEventListener('bind', e => {
|
||||
if (e.detail !== undefined && e.detail !== null) {
|
||||
target.style[container.isVertical ? 'height' : 'width'] = e.detail + 'px'
|
||||
}
|
||||
})
|
||||
const getSize = (startSize, w, h) => {
|
||||
const newSize = startSize + (container.isVertical ? h : w)
|
||||
return newSize < min ? min : newSize > max ? max : newSize
|
||||
}
|
||||
container.addEventListener('mousedown', event => {
|
||||
const startSize = container.isVertical ? target.offsetHeight : target.offsetWidth
|
||||
globalThis.MouseMover.start(event, {
|
||||
onmousemove: ({ w, h }) => {
|
||||
const newSize = getSize(startSize, w, h)
|
||||
target.style[container.isVertical ? 'height' : 'width'] = newSize + 'px'
|
||||
container.dispatchEvent(new CustomEvent('resizing', { detail: { oldSize: startSize, newSize }, bubbles: false }))
|
||||
},
|
||||
onmouseup: ({ w, h }) => {
|
||||
const newSize = getSize(startSize, w, h)
|
||||
container.dispatchEvent(new CustomEvent('resize', { detail: { oldSize: startSize, newSize }, bubbles: false }))
|
||||
container.dispatchEvent(new CustomEvent('change', { detail: newSize, bubbles: false }))
|
||||
},
|
||||
})
|
||||
})
|
||||
}, globalThis.Util.makeDom(/*html*/`
|
||||
<div $class="border-\${this.isVertical?'top':'start'} flex-shrink-0" $style="\${this.isVertical?'height':'width'}:3px;\${!this.isVertical?'height':'width'}:100%;cursor:\${this.isVertical?'row-resize':'col-resize'}"></div>
|
||||
`))
|
||||
43
src/TagsInput.js
Normal file
43
src/TagsInput.js
Normal file
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* TagsInput Component Module
|
||||
*/
|
||||
globalThis.Component.register('TagsInput', container => {
|
||||
container._thisObj = container;
|
||||
container.addEventListener('bind', e => {
|
||||
container.state.tags = Array.isArray(e.detail) ? e.detail : []
|
||||
})
|
||||
}, globalThis.Util.makeDom(/*html*/`
|
||||
<div class="form-control d-flex flex-wrap gap-1 align-items-center" style="min-height:38px;cursor:text">
|
||||
<template $each="this.state.tags">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary rounded-pill py-0 px-2" $onkeydown='${globalThis.Util.getFunctionBody(function (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 = globalThis.$$(this, 'button');
|
||||
(buttons.length > 0 ? buttons[index > 0 ? index - 1 : 0] : globalThis.$(this, 'input')).focus()
|
||||
})
|
||||
}
|
||||
})}' $text="item"></button>
|
||||
</template>
|
||||
<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='${globalThis.Util.getFunctionBody(function (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>
|
||||
`), globalThis.Util.makeDom(/*html*/`<style>TagsInput button:focus {background-color:var(--bs-btn-hover-bg);color:var(--bs-btn-hover-color)}</style>`))
|
||||
|
||||
if (globalThis.AutoForm) {
|
||||
globalThis.AutoForm.register('TagsInput')
|
||||
}
|
||||
100
src/VirtualScroll.js
Normal file
100
src/VirtualScroll.js
Normal file
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* VirtualScroll Utility Module
|
||||
*/
|
||||
const VirtualScroll = (options = {}) => {
|
||||
const itemHeights = new Map()
|
||||
const groupHeights = new Map()
|
||||
let groupItemCount = 1
|
||||
const avg = globalThis.Util.newAvg()
|
||||
let padTop = 0, rowGap = 0, topMargin = 0, itemMarginTop = null, itemMarginBottom = null, listInited = false
|
||||
|
||||
const providedItemHeight = options.itemHeight || null;
|
||||
|
||||
return {
|
||||
reset: (list, container) => {
|
||||
listInited = false; itemHeights.clear(); groupHeights.clear(); avg.clear(); topMargin = 0; itemMarginTop = null; itemMarginBottom = null;
|
||||
if (!list?.length) return [];
|
||||
const size = list.length; groupItemCount = Math.ceil(Math.sqrt(size)) || 10;
|
||||
const style = window.getComputedStyle(container);
|
||||
padTop = parseFloat(style.paddingTop) || 0; rowGap = parseFloat(style.rowGap) || 0;
|
||||
const visibleCount = Math.max(10, Math.ceil((container.clientHeight || 100) / (providedItemHeight || 32)));
|
||||
return list.slice(0, Math.min(visibleCount * 3, size));
|
||||
},
|
||||
init: (list, refreshCallback) => {
|
||||
if (listInited) return;
|
||||
const size = list.length;
|
||||
let defaultHeight = providedItemHeight || avg.get() || 32;
|
||||
if (size > 0 && typeof list[0] === 'object' && list[0] !== null && list[0]._itemHeight) {
|
||||
defaultHeight = list[0]._itemHeight;
|
||||
}
|
||||
avg.add(defaultHeight);
|
||||
if (itemMarginTop === null) { itemMarginTop = 0; itemMarginBottom = 0; }
|
||||
for (let i = 0; i < size; i++) {
|
||||
if (!itemHeights.has(i)) {
|
||||
const ih = (typeof list[i] === 'object' && list[i] !== null && list[i]._itemHeight) ? list[i]._itemHeight : defaultHeight;
|
||||
itemHeights.set(i, ih);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < size; i += groupItemCount) groupHeights.set(i, Math.min(groupItemCount, size - i) * defaultHeight);
|
||||
listInited = true; refreshCallback();
|
||||
},
|
||||
update: (absoluteIndex, node) => {
|
||||
if (node.offsetHeight === 0) return;
|
||||
if (itemMarginTop === null) {
|
||||
const style = window.getComputedStyle(node);
|
||||
itemMarginTop = parseFloat(style.marginTop) || 0; itemMarginBottom = parseFloat(style.marginBottom) || 0;
|
||||
}
|
||||
if (absoluteIndex === 0 && !topMargin) topMargin = itemMarginTop;
|
||||
const newHeight = node.offsetHeight + itemMarginTop + itemMarginBottom + rowGap;
|
||||
const oldHeight = itemHeights.get(absoluteIndex);
|
||||
if (newHeight !== oldHeight) {
|
||||
itemHeights.set(absoluteIndex, newHeight); avg.add(newHeight);
|
||||
const offset = newHeight - (oldHeight || 0), groupIndex = absoluteIndex - (absoluteIndex % groupItemCount);
|
||||
if (groupHeights.has(groupIndex)) groupHeights.set(groupIndex, groupHeights.get(groupIndex) + offset);
|
||||
}
|
||||
},
|
||||
calc: (container, list) => {
|
||||
if (!listInited || !list) return null;
|
||||
const size = list.length;
|
||||
const avgVal = Math.max(16, avg.get() || 32);
|
||||
let visibleCount = Math.max(10, Math.ceil((container.clientHeight || 100) / avgVal));
|
||||
let prev = padTop + topMargin + rowGap, post = 0, status = 0, listStartIndex = 0, listEndIndex = 0;
|
||||
let renderedList = [];
|
||||
const scrollTop = container.scrollTop;
|
||||
let loopCount = 0;
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
if (++loopCount > size * 2) throw new Error('VirtualScroll infinite loop');
|
||||
if (status === 0) {
|
||||
const gh = groupHeights.get(i);
|
||||
if (gh && prev + gh <= scrollTop && (i + groupItemCount < size)) {
|
||||
prev += gh; i += groupItemCount - 1;
|
||||
} else {
|
||||
const ih = itemHeights.get(i);
|
||||
if (prev + ih <= scrollTop && i < size - 1) {
|
||||
prev += ih;
|
||||
} else {
|
||||
status = 1;
|
||||
let visibleStartIndex = Math.max(0, i);
|
||||
listStartIndex = Math.max(0, visibleStartIndex - visibleCount);
|
||||
listEndIndex = Math.min(listStartIndex + visibleCount * 3, size);
|
||||
i = listEndIndex - 1;
|
||||
renderedList = list.slice(listStartIndex, listEndIndex);
|
||||
for (let j = listStartIndex; j < visibleStartIndex; j++) prev -= itemHeights.get(j);
|
||||
}
|
||||
}
|
||||
} else if (status === 1) {
|
||||
const gh = groupHeights.get(i);
|
||||
if (gh) { post += gh; i += groupItemCount - 1; }
|
||||
else post += itemHeights.get(i);
|
||||
}
|
||||
}
|
||||
const finalPrevHeight = Math.max(0, prev - padTop - topMargin - rowGap - (listStartIndex > 0 ? rowGap : 0));
|
||||
const finalPostHeight = post > 0 ? Math.max(0, post - 2 * rowGap) : 0;
|
||||
return { prevHeight: finalPrevHeight, postHeight: finalPostHeight, renderedList, listStartIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.VirtualScroll = VirtualScroll;
|
||||
export { VirtualScroll };
|
||||
178
src/form.js
178
src/form.js
@ -1,178 +0,0 @@
|
||||
import { Component, NewState, Util, $, State } from '@apigo.cc/state'
|
||||
import { HTTP } from './http.js'
|
||||
|
||||
/**
|
||||
* AutoForm 蓝图定义
|
||||
*/
|
||||
const AUTOFORM_BLUEPRINT = Util.makeDom(/*html*/`
|
||||
<div class="auto-form-root">
|
||||
<form $class="\${this.inline ? 'd-flex flex-wrap align-items-center gap-3' : (this.vertical ? 'd-flex flex-column' : (this.horizontal ? 'auto-grid-form forced-horizontal' : 'auto-grid-form'))}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
|
||||
|
||||
<template $each="this.state.schema || []">
|
||||
<div style="display:contents" $if="this.checkIf(item)">
|
||||
<label $name="item.name" $class="\${this.inline ? 'mb-0 text-muted text-nowrap' : 'col-form-label text-muted'}" $text="item.label"></label>
|
||||
<div control-wrapper $class="\${this.inline ? 'd-flex align-items-center' : 'mb-3 d-flex align-items-center'}">
|
||||
<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 \${item.type === 'number' ? 'text-end' : ''}">
|
||||
|
||||
<select $if="item.type === 'select'" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="form-select">
|
||||
<option value="" $if="item.placeholder" $text="item.placeholder" disabled selected></option>
|
||||
<option $each="item.options" as="opt" $value="opt.value !== undefined ? opt.value : opt" $text="opt.label || opt"></option>
|
||||
</select>
|
||||
|
||||
<div $if="['checkbox', 'radio'].includes(item.type)" class="d-flex align-items-center flex-wrap gap-3 h-100">
|
||||
<label $each="item.options || [item.text||item.label||item.name]" as="opt" class="form-check mb-0 d-flex align-items-center" style="padding-left:0; cursor:pointer;">
|
||||
<input $name="item.name" class="form-check-input m-0 me-2" style="float:none;" $type="item.type" $.="item.setting || {}" $value="item.options?opt:'on'" $bind="this.data[item.name]">
|
||||
<span $if="!this.inline || (item.options && item.options.length > 0)" $text="opt.label || opt" class="form-check-label"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div $if="item.type === 'switch'" class="form-check form-switch fs-5 d-flex align-items-center m-0" style="padding-left:0; min-height:0; display:flex !important;">
|
||||
<input $name="item.name" class="form-check-input m-0" style="float:none; cursor:pointer" 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>
|
||||
</template>
|
||||
|
||||
<div $if="!this.nobutton" $class="\${this.inline ? '' : 'd-flex justify-content-end align-items-baseline gap-3 mt-2'}" $style="\${this.inline ? '' : 'grid-column:1/-1'}">
|
||||
<div slot-id="actions"></div>
|
||||
<button type="submit" class="btn btn-primary" $text="this.submitlabel || '{#Submit#}'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`)
|
||||
|
||||
const AUTOFORM_STYLE = Util.makeDom(/*html*/`<style>
|
||||
.auto-grid-form { display: block; }
|
||||
.auto-grid-form .col-form-label { text-align: left; margin-bottom: 0.25rem; padding-top: 0; }
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.auto-grid-form { display: grid; grid-template-columns: max-content 1fr; gap: 0 1.5rem; }
|
||||
.auto-grid-form .col-form-label { text-align: right; margin-bottom: 1rem; padding-right: 0; max-width: 200px; padding-top: calc(0.375rem + 1px); }
|
||||
.auto-grid-form [control-wrapper] { min-height: calc(2.25rem + 2px); }
|
||||
}
|
||||
|
||||
.auto-grid-form.forced-horizontal { display: grid !important; grid-template-columns: max-content 1fr !important; gap: 0 1.5rem !important; }
|
||||
.auto-grid-form.forced-horizontal .col-form-label { text-align: right !important; margin-bottom: 1rem !important; padding-right: 0 !important; max-width: 200px !important; padding-top: calc(0.375rem + 1px) !important; }
|
||||
.auto-grid-form.forced-horizontal [control-wrapper] { min-height: calc(2.25rem + 2px) !important; }
|
||||
|
||||
.auto-form-root .form-check-input { width: 1.2em; height: 1.2em; }
|
||||
.auto-form-root .form-switch .form-check-input { width: 2em; }
|
||||
</style>`)
|
||||
|
||||
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.horizontal = container.hasAttribute('horizontal')
|
||||
container.inline = container.hasAttribute('inline')
|
||||
container.nobutton = container.hasAttribute('nobutton')
|
||||
container.request = { method: 'POST' }
|
||||
container.response = {}
|
||||
container.result = null
|
||||
|
||||
container.form = $(container, 'form')
|
||||
container.submit = (opt = {}) => {
|
||||
if (!container.form.reportValidity()) return globalThis.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 => {
|
||||
if (globalThis.UI?.toast) UI.toast(err.message, { type: 'danger' })
|
||||
container.dispatchEvent(new CustomEvent('error', { detail: err, bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
container.checkIf = (item) => {
|
||||
if (!item.if) return true
|
||||
try {
|
||||
const fn = new Function('Hash', 'LocalStorage', 'State', 'item', 'data', 'return ' + item.if)
|
||||
return fn.call(container, globalThis.Hash, globalThis.LocalStorage, globalThis.State, item, container.data)
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}, AUTOFORM_BLUEPRINT, AUTOFORM_STYLE)
|
||||
|
||||
const findAnchorInBlueprint = (root) => {
|
||||
let f = root.querySelector('[control-wrapper]');
|
||||
if (f) return f;
|
||||
for (const t of root.querySelectorAll('template')) {
|
||||
f = findAnchorInBlueprint(t.content); if (f) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const AutoForm = {
|
||||
customTypes: [],
|
||||
register: (name, typeName) => {
|
||||
const type = typeName || name
|
||||
if (!AutoForm.customTypes.find(t => t.name === name)) {
|
||||
AutoForm.customTypes.push({ name, typeName: type })
|
||||
AutoForm._addAutoFormComponent(name, type)
|
||||
}
|
||||
},
|
||||
_addAutoFormComponent: (name, type) => {
|
||||
const wrapper = findAnchorInBlueprint(AUTOFORM_BLUEPRINT)
|
||||
if (wrapper) {
|
||||
const node = Util.makeDom(`<${name} $if="item.type?.toLowerCase() === '${type.toLowerCase()}'" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="w-100"></${name}>`)
|
||||
wrapper.appendChild(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TagsInput
|
||||
* 100% 还原 base_original.js 逻辑与写法
|
||||
*/
|
||||
Component.register('TagsInput', container => {
|
||||
container._thisObj = container; // 夺回上下文主权
|
||||
container.addEventListener('bind', e => {
|
||||
container.state.tags = Array.isArray(e.detail) ? e.detail : []
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="form-control d-flex flex-wrap gap-1 align-items-center" style="min-height:38px;cursor:text">
|
||||
<template $each="this.state.tags">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary rounded-pill py-0 px-2" $onkeydown='${Util.getFunctionBody(function (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>
|
||||
</template>
|
||||
<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(function (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')
|
||||
141
src/http.js
141
src/http.js
@ -1,91 +1,54 @@
|
||||
import { Component, NewState } from '@apigo.cc/state'
|
||||
|
||||
export const 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
|
||||
}
|
||||
/**
|
||||
* HTTP Module
|
||||
*/
|
||||
const 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
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP 和 API 组件
|
||||
export const APIComponent = 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 && globalThis.UI?.toast) 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.HTTP = HTTP;
|
||||
export { HTTP };
|
||||
|
||||
75
src/index.js
75
src/index.js
@ -1,49 +1,34 @@
|
||||
import { NewState, State } from '@apigo.cc/state'
|
||||
import '@apigo.cc/bootstrap'
|
||||
/**
|
||||
* Base Project Entry Point
|
||||
* Orchestrates component loading and re-exports public APIs.
|
||||
*/
|
||||
|
||||
// Re-exports
|
||||
export * from './http.js'
|
||||
export * from './ui.js'
|
||||
export * from './form.js'
|
||||
export * from './controls.js'
|
||||
export * from './list.js'
|
||||
export * from './nav.js'
|
||||
export * from './interaction.js'
|
||||
// Core Framework & State (Must be loaded first if not already global)
|
||||
export { State } from '@apigo.cc/state';
|
||||
|
||||
export { State }
|
||||
// 1. Foundation Objects
|
||||
import './HTTP.js';
|
||||
import './UI.js';
|
||||
import './AutoForm.js';
|
||||
import './VirtualScroll.js';
|
||||
import './MouseMover.js';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (State.exitBlocks > 0) event.preventDefault()
|
||||
})
|
||||
}
|
||||
|
||||
const htmlNode = document.documentElement;
|
||||
if (!htmlNode.hasAttribute('$data-bs-theme') && !htmlNode.hasAttribute('data-bs-theme')) {
|
||||
htmlNode.setAttribute('$data-bs-theme', "LocalStorage.darkMode?'dark':'light'");
|
||||
}
|
||||
|
||||
// Side effects: ensure global namespaces are populated
|
||||
import { HTTP } from './http.js'
|
||||
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
|
||||
globalThis.AutoForm = AutoForm
|
||||
globalThis.MouseMover = MouseMover
|
||||
globalThis.VirtualScroll = VirtualScroll
|
||||
|
||||
const ApigoBase = {
|
||||
HTTP, UI, AutoForm, MouseMover, VirtualScroll, State,
|
||||
List: VirtualScroll
|
||||
};
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
globalThis.ApigoBase = ApigoBase;
|
||||
}
|
||||
// 2. Components (Order matters for some)
|
||||
import './API.js';
|
||||
import './Modal.js';
|
||||
import './Dialog.js';
|
||||
import './TagsInput.js';
|
||||
import './DatePicker.js';
|
||||
import './ColorPicker.js';
|
||||
import './IconPicker.js';
|
||||
import './List.js';
|
||||
import './Nav.js';
|
||||
import './Resizer.js';
|
||||
|
||||
// Re-exports for ESM compatibility
|
||||
export { HTTP } from './HTTP.js';
|
||||
export { UI } from './UI.js';
|
||||
export { AutoForm } from './AutoForm.js';
|
||||
export { VirtualScroll } from './VirtualScroll.js';
|
||||
export { MouseMover } from './MouseMover.js';
|
||||
export { APIComponent } from './API.js';
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
import { Component, Util } from '@apigo.cc/state'
|
||||
|
||||
let _mouseMoverMoving = false
|
||||
let _mouseMoverPos = {}
|
||||
let _mouseMoverEvents = {}
|
||||
|
||||
export const MouseMover = {
|
||||
start: (event, { onmousemove, onmouseup }) => {
|
||||
_mouseMoverPos = { x: event.clientX, y: event.clientY, w: 0, h: 0 }
|
||||
_mouseMoverEvents = { onmousemove, onmouseup }
|
||||
_mouseMoverMoving = true
|
||||
},
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
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 => {
|
||||
container.isVertical = container.hasAttribute('vertical')
|
||||
const min = parseInt(container.getAttribute('min')) || 10
|
||||
const max = parseInt(container.getAttribute('max')) || 1000
|
||||
const target = container.target || container.previousElementSibling
|
||||
container.addEventListener('bind', e => {
|
||||
if (e.detail !== undefined && e.detail !== null) {
|
||||
target.style[container.isVertical ? 'height' : 'width'] = e.detail + 'px'
|
||||
}
|
||||
})
|
||||
const getSize = (startSize, w, h) => {
|
||||
const newSize = startSize + (container.isVertical ? h : w)
|
||||
return newSize < min ? min : newSize > max ? max : newSize
|
||||
}
|
||||
container.addEventListener('mousedown', event => {
|
||||
const startSize = container.isVertical ? target.offsetHeight : target.offsetWidth
|
||||
MouseMover.start(event, {
|
||||
onmousemove: ({ w, h }) => {
|
||||
const newSize = getSize(startSize, w, h)
|
||||
target.style[container.isVertical ? 'height' : 'width'] = newSize + 'px'
|
||||
container.dispatchEvent(new CustomEvent('resizing', { detail: { oldSize: startSize, newSize }, bubbles: false }))
|
||||
},
|
||||
onmouseup: ({ w, h }) => {
|
||||
const newSize = getSize(startSize, w, h)
|
||||
container.dispatchEvent(new CustomEvent('resize', { detail: { oldSize: startSize, newSize }, bubbles: false }))
|
||||
container.dispatchEvent(new CustomEvent('change', { detail: newSize, bubbles: false }))
|
||||
},
|
||||
})
|
||||
})
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div $class="border-\${this.isVertical?'top':'start'} flex-shrink-0" $style="\${this.isVertical?'height':'width'}:3px;\${!this.isVertical?'height':'width'}:100%;cursor:\${this.isVertical?'row-resize':'col-resize'}"></div>
|
||||
`))
|
||||
316
src/list.js
316
src/list.js
@ -1,217 +1,123 @@
|
||||
import { Component, NewState, Util, Hash } from '@apigo.cc/state'
|
||||
/**
|
||||
* List Component Module
|
||||
*/
|
||||
globalThis.Component.register('List', container => {
|
||||
container.mode = container.getAttribute('mode') || 'normal'
|
||||
container.fast = container.hasAttribute('fast')
|
||||
container.collapsible = container.hasAttribute('collapsible')
|
||||
|
||||
export const VirtualScroll = (options = {}) => {
|
||||
const itemHeights = new Map()
|
||||
const groupHeights = new Map()
|
||||
let groupItemCount = 1
|
||||
const avg = Util.newAvg()
|
||||
let padTop = 0, rowGap = 0, topMargin = 0, itemMarginTop = null, itemMarginBottom = null, listInited = false
|
||||
const padTopEl = container.fast ? container.querySelector('.vs-pad-top') : null
|
||||
const padBottomEl = container.fast ? container.querySelector('.vs-pad-bottom') : null
|
||||
|
||||
const providedItemHeight = options.itemHeight || null;
|
||||
const defaultSets = {
|
||||
idfield: 'id', labelfield: 'label', summaryfield: 'summary',
|
||||
groupidfield: 'id', grouplabelfield: 'label', groupsummaryfield: 'summary', groupfield: 'group',
|
||||
parentfield: 'parent', groupicon: 'folder', itemicon: 'file'
|
||||
}
|
||||
container.collapsed = globalThis.NewState({})
|
||||
container.state.renderedList = []
|
||||
|
||||
return {
|
||||
reset: (list, container) => {
|
||||
listInited = false; itemHeights.clear(); groupHeights.clear(); avg.clear(); topMargin = 0; itemMarginTop = null; itemMarginBottom = null;
|
||||
if (!list?.length) return [];
|
||||
const size = list.length; groupItemCount = Math.ceil(Math.sqrt(size)) || 10;
|
||||
const style = window.getComputedStyle(container);
|
||||
padTop = parseFloat(style.paddingTop) || 0; rowGap = parseFloat(style.rowGap) || 0;
|
||||
const visibleCount = Math.max(10, Math.ceil((container.clientHeight || 100) / (providedItemHeight || 32)));
|
||||
return list.slice(0, Math.min(visibleCount * 3, size));
|
||||
},
|
||||
init: (list, refreshCallback) => {
|
||||
if (listInited) return;
|
||||
const size = list.length;
|
||||
let defaultHeight = providedItemHeight || avg.get() || 32;
|
||||
if (size > 0 && typeof list[0] === 'object' && list[0] !== null && list[0]._itemHeight) {
|
||||
defaultHeight = list[0]._itemHeight;
|
||||
}
|
||||
avg.add(defaultHeight);
|
||||
if (itemMarginTop === null) { itemMarginTop = 0; itemMarginBottom = 0; }
|
||||
for (let i = 0; i < size; i++) {
|
||||
if (!itemHeights.has(i)) {
|
||||
const ih = (typeof list[i] === 'object' && list[i] !== null && list[i]._itemHeight) ? list[i]._itemHeight : defaultHeight;
|
||||
itemHeights.set(i, ih);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < size; i += groupItemCount) groupHeights.set(i, Math.min(groupItemCount, size - i) * defaultHeight);
|
||||
listInited = true; refreshCallback();
|
||||
},
|
||||
update: (absoluteIndex, node) => {
|
||||
if (node.offsetHeight === 0) return;
|
||||
if (itemMarginTop === null) {
|
||||
const style = window.getComputedStyle(node);
|
||||
itemMarginTop = parseFloat(style.marginTop) || 0; itemMarginBottom = parseFloat(style.marginBottom) || 0;
|
||||
}
|
||||
if (absoluteIndex === 0 && !topMargin) topMargin = itemMarginTop;
|
||||
const newHeight = node.offsetHeight + itemMarginTop + itemMarginBottom + rowGap;
|
||||
const oldHeight = itemHeights.get(absoluteIndex);
|
||||
if (newHeight !== oldHeight) {
|
||||
itemHeights.set(absoluteIndex, newHeight); avg.add(newHeight);
|
||||
const offset = newHeight - (oldHeight || 0), groupIndex = absoluteIndex - (absoluteIndex % groupItemCount);
|
||||
if (groupHeights.has(groupIndex)) groupHeights.set(groupIndex, groupHeights.get(groupIndex) + offset);
|
||||
}
|
||||
},
|
||||
calc: (container, list) => {
|
||||
if (!listInited || !list) return null;
|
||||
const size = list.length;
|
||||
const avgVal = Math.max(16, avg.get() || 32);
|
||||
let visibleCount = Math.max(10, Math.ceil((container.clientHeight || 100) / avgVal));
|
||||
let prev = padTop + topMargin + rowGap, post = 0, status = 0, listStartIndex = 0, listEndIndex = 0;
|
||||
let renderedList = [];
|
||||
const scrollTop = container.scrollTop;
|
||||
let loopCount = 0;
|
||||
const updateFlatList = () => {
|
||||
globalThis.Util.updateDefaults(container, defaultSets)
|
||||
const list = container.state.list || [], flatList = []
|
||||
if (container.mode === 'group') {
|
||||
const itemMap = {}
|
||||
list.forEach(item => (itemMap[item[container.groupfield]] ??= []).push(item));
|
||||
(container.state.groups || []).forEach(group => {
|
||||
flatList.push({ type: 'group', ...group })
|
||||
const items = itemMap[group[container.groupidfield]]
|
||||
if (items) items.forEach(item => flatList.push({ type: 'item', ...item }))
|
||||
})
|
||||
} else if (container.mode === 'tree') {
|
||||
const childrenMap = {}
|
||||
list.forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
|
||||
const traverse = (items, level, parents) => items.forEach(item => {
|
||||
const id = item[container.idfield], hasChildren = !!childrenMap[id]?.length
|
||||
const isCollapsed = container.collapsed[id]
|
||||
flatList.push({ type: 'item', ...item, _level: level, _hasChildren: hasChildren, _parents: parents })
|
||||
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id])
|
||||
})
|
||||
traverse(childrenMap[''] || [], 0, [])
|
||||
} else list.forEach(item => flatList.push({ type: 'item', ...item }))
|
||||
container.state.flatList = flatList
|
||||
}
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
if (++loopCount > size * 2) throw new Error('VirtualScroll infinite loop');
|
||||
if (status === 0) {
|
||||
const gh = groupHeights.get(i);
|
||||
if (gh && prev + gh <= scrollTop && (i + groupItemCount < size)) {
|
||||
prev += gh; i += groupItemCount - 1;
|
||||
} else {
|
||||
const ih = itemHeights.get(i);
|
||||
if (prev + ih <= scrollTop && i < size - 1) {
|
||||
prev += ih;
|
||||
} else {
|
||||
status = 1;
|
||||
let visibleStartIndex = Math.max(0, i);
|
||||
listStartIndex = Math.max(0, visibleStartIndex - visibleCount);
|
||||
listEndIndex = Math.min(listStartIndex + visibleCount * 3, size);
|
||||
i = listEndIndex - 1;
|
||||
renderedList = list.slice(listStartIndex, listEndIndex);
|
||||
for (let j = listStartIndex; j < visibleStartIndex; j++) prev -= itemHeights.get(j);
|
||||
}
|
||||
}
|
||||
} else if (status === 1) {
|
||||
const gh = groupHeights.get(i);
|
||||
if (gh) { post += gh; i += groupItemCount - 1; }
|
||||
else post += itemHeights.get(i);
|
||||
}
|
||||
}
|
||||
const finalPrevHeight = Math.max(0, prev - padTop - topMargin - rowGap - (listStartIndex > 0 ? rowGap : 0));
|
||||
const finalPostHeight = post > 0 ? Math.max(0, post - 2 * rowGap) : 0;
|
||||
return { prevHeight: finalPrevHeight, postHeight: finalPostHeight, renderedList, listStartIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
container.state.__watch('list', updateFlatList)
|
||||
const vs = container.fast ? globalThis.VirtualScroll() : null
|
||||
|
||||
Component.register('List', container => {
|
||||
container.mode = container.getAttribute('mode') || 'normal'
|
||||
container.fast = container.hasAttribute('fast')
|
||||
container.collapsible = container.hasAttribute('collapsible')
|
||||
let refreshing = false
|
||||
container.refresh = () => {
|
||||
if (!container.fast || refreshing) return
|
||||
refreshing = true
|
||||
try {
|
||||
const res = vs.calc(container, container.state.flatList)
|
||||
if (res) {
|
||||
if (padTopEl) padTopEl.style.height = `${res.prevHeight}px`
|
||||
if (padBottomEl) padBottomEl.style.height = `${res.postHeight}px`
|
||||
container.state.listStartIndex = res.listStartIndex
|
||||
container.state.renderedList = res.renderedList
|
||||
}
|
||||
} finally {
|
||||
setTimeout(() => { refreshing = false }, 0)
|
||||
}
|
||||
}
|
||||
|
||||
const padTopEl = container.fast ? container.querySelector('.vs-pad-top') : null
|
||||
const padBottomEl = container.fast ? container.querySelector('.vs-pad-bottom') : null
|
||||
container.onItemUpdate = (index, node) => { if (container.fast) vs.update(index + (container.state.listStartIndex || 0), node) }
|
||||
|
||||
const defaultSets = {
|
||||
idfield: 'id', labelfield: 'label', summaryfield: 'summary',
|
||||
groupidfield: 'id', grouplabelfield: 'label', groupsummaryfield: 'summary', groupfield: 'group',
|
||||
parentfield: 'parent', groupicon: 'folder', itemicon: 'file'
|
||||
}
|
||||
container.collapsed = NewState({})
|
||||
container.state.renderedList = []
|
||||
container.state.__watch('flatList', flatList => {
|
||||
if (container.fast) {
|
||||
if (padTopEl) padTopEl.style.height = '0px'
|
||||
if (padBottomEl) padBottomEl.style.height = '0px'
|
||||
container.state.listStartIndex = 0
|
||||
container.state.renderedList = vs.reset(flatList, container) || []
|
||||
setTimeout(() => { if (container.state.flatList === flatList) vs.init(flatList, container.refresh) })
|
||||
} else container.state.renderedList = flatList
|
||||
})
|
||||
|
||||
const updateFlatList = () => {
|
||||
Util.updateDefaults(container, defaultSets)
|
||||
const list = container.state.list || [], flatList = []
|
||||
if (container.mode === 'group') {
|
||||
const itemMap = {}
|
||||
list.forEach(item => (itemMap[item[container.groupfield]] ??= []).push(item));
|
||||
(container.state.groups || []).forEach(group => {
|
||||
flatList.push({ type: 'group', ...group })
|
||||
const items = itemMap[group[container.groupidfield]]
|
||||
if (items) items.forEach(item => flatList.push({ type: 'item', ...item }))
|
||||
})
|
||||
} else if (container.mode === 'tree') {
|
||||
const childrenMap = {}
|
||||
list.forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
|
||||
const traverse = (items, level, parents) => items.forEach(item => {
|
||||
const id = item[container.idfield], hasChildren = !!childrenMap[id]?.length
|
||||
const isCollapsed = container.collapsed[id]
|
||||
flatList.push({ type: 'item', ...item, _level: level, _hasChildren: hasChildren, _parents: parents })
|
||||
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id])
|
||||
})
|
||||
traverse(childrenMap[''] || [], 0, [])
|
||||
} else list.forEach(item => flatList.push({ type: 'item', ...item }))
|
||||
container.state.flatList = flatList
|
||||
}
|
||||
container.selectItem = (item, index) => {
|
||||
if (container.hasAttribute('auto-select')) container.state.selectedItem = container.state.selectedItem === item[container.idfield] ? null : item[container.idfield]
|
||||
container.dispatchEvent(new CustomEvent('itemclick', { bubbles: false, detail: { item, index: index + (container.fast ? (container.state.listStartIndex || 0) : 0) } }))
|
||||
}
|
||||
container.selectGroup = (item, index) => {
|
||||
if (container.hasAttribute('auto-select-group')) container.state.selectedGroup = container.state.selectedGroup === item[container.groupidfield] ? null : item[container.groupidfield]
|
||||
container.dispatchEvent(new CustomEvent('groupclick', { bubbles: false, detail: { item, index } }))
|
||||
}
|
||||
container.toggleCollapse = (item) => { if (container.collapsible && item._hasChildren) { container.collapsed[item[container.idfield]] = !container.collapsed[item[container.idfield]]; updateFlatList(); } }
|
||||
|
||||
container.state.__watch('list', updateFlatList)
|
||||
const vs = container.fast ? VirtualScroll() : null
|
||||
|
||||
let refreshing = false
|
||||
container.refresh = () => {
|
||||
if (!container.fast || refreshing) return
|
||||
refreshing = true
|
||||
try {
|
||||
const res = vs.calc(container, container.state.flatList)
|
||||
if (res) {
|
||||
if (padTopEl) padTopEl.style.height = `${res.prevHeight}px`
|
||||
if (padBottomEl) padBottomEl.style.height = `${res.postHeight}px`
|
||||
container.state.listStartIndex = res.listStartIndex
|
||||
container.state.renderedList = res.renderedList
|
||||
}
|
||||
} finally {
|
||||
setTimeout(() => { refreshing = false }, 0)
|
||||
}
|
||||
}
|
||||
|
||||
container.onItemUpdate = (index, node) => { if (container.fast) vs.update(index + (container.state.listStartIndex || 0), node) }
|
||||
|
||||
container.state.__watch('flatList', flatList => {
|
||||
if (container.fast) {
|
||||
if (padTopEl) padTopEl.style.height = '0px'
|
||||
if (padBottomEl) padBottomEl.style.height = '0px'
|
||||
container.state.listStartIndex = 0
|
||||
container.state.renderedList = vs.reset(flatList, container) || []
|
||||
setTimeout(() => { if (container.state.flatList === flatList) vs.init(flatList, container.refresh) })
|
||||
} else container.state.renderedList = flatList
|
||||
})
|
||||
|
||||
container.selectItem = (item, index) => {
|
||||
if (container.hasAttribute('auto-select')) container.state.selectedItem = container.state.selectedItem === item[container.idfield] ? null : item[container.idfield]
|
||||
container.dispatchEvent(new CustomEvent('itemclick', { bubbles: false, detail: { item, index: index + (container.fast ? (container.state.listStartIndex || 0) : 0) } }))
|
||||
}
|
||||
container.selectGroup = (item, index) => {
|
||||
if (container.hasAttribute('auto-select-group')) container.state.selectedGroup = container.state.selectedGroup === item[container.groupidfield] ? null : item[container.groupidfield]
|
||||
container.dispatchEvent(new CustomEvent('groupclick', { bubbles: false, detail: { item, index } }))
|
||||
}
|
||||
container.toggleCollapse = (item) => { if (container.collapsible && item._hasChildren) { container.collapsed[item[container.idfield]] = !container.collapsed[item[container.idfield]]; updateFlatList(); } }
|
||||
|
||||
updateFlatList()
|
||||
}, Util.makeDom(/*html*/`
|
||||
updateFlatList()
|
||||
}, globalThis.Util.makeDom(/*html*/`
|
||||
<div class="list-group overflow-auto" onscroll="this.refresh()" style="overflow-anchor:none">
|
||||
<div class="vs-pad-top flex-shrink-0" style="height:0px;"></div>
|
||||
<template slot-id="item" $each="this.state.renderedList">
|
||||
<div $onupdate="this.onItemUpdate(index, thisNode)" $class="list-group-item d-inline-flex align-items-center pe-2 \${item.type==='group'?'bg-body-tertiary fw-bold ps-2':'list-group-item-action ' + (this.mode==='group'?'ps-4':'ps-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'">
|
||||
<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="\${item[this.groupsummaryfield]}"></div>
|
||||
<div slot-id="group-actions"></div>
|
||||
</template>
|
||||
<template $if="item.type === 'item'">
|
||||
<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="\${item[this.summaryfield]}"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="vs-pad-bottom flex-shrink-0" style="height:0px;"></div>
|
||||
<div class="vs-pad-top flex-shrink-0" style="height:0px;"></div>
|
||||
<template slot-id="item" $each="this.state.renderedList">
|
||||
<div $onupdate="this.onItemUpdate(index, thisNode)" $class="list-group-item d-inline-flex align-items-center pe-2 \${item.type==='group'?'bg-body-tertiary fw-bold ps-2':'list-group-item-action ' + (this.mode==='group'?'ps-4':'ps-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'">
|
||||
<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="\${item[this.groupsummaryfield]}"></div>
|
||||
<div slot-id="group-actions"></div>
|
||||
</template>
|
||||
<template $if="item.type === 'item'">
|
||||
<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="\${item[this.summaryfield]}"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="vs-pad-bottom flex-shrink-0" style="height:0px;"></div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
101
src/nav.js
101
src/nav.js
@ -1,53 +1,54 @@
|
||||
import { Component, Hash, Util } from '@apigo.cc/state'
|
||||
|
||||
Component.register('Nav', container => {
|
||||
container.vertical = container.hasAttribute('vertical')
|
||||
container.click = (item, noselect) => {
|
||||
if (!item.noselect && !noselect) Hash.nav = item.name
|
||||
container.dispatchEvent(new CustomEvent('nav', { detail: { item }, bubbles: false }))
|
||||
}
|
||||
}, Util.makeDom(/*html*/`
|
||||
/**
|
||||
* Nav Component Module
|
||||
*/
|
||||
globalThis.Component.register('Nav', container => {
|
||||
container.vertical = container.hasAttribute('vertical')
|
||||
container.click = (item, noselect) => {
|
||||
if (!item.noselect && !noselect) globalThis.Hash.nav = item.name
|
||||
container.dispatchEvent(new CustomEvent('nav', { detail: { item }, bubbles: false }))
|
||||
}
|
||||
}, globalThis.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' : ''}">
|
||||
<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>
|
||||
<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 $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>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<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>
|
||||
<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 $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>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
`))
|
||||
|
||||
127
src/ui.js
127
src/ui.js
@ -1,92 +1,26 @@
|
||||
import { Component, Util, $ } from '@apigo.cc/state'
|
||||
/**
|
||||
* UI Module
|
||||
*/
|
||||
const UI = {}
|
||||
|
||||
export const 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 border-\${this.state?.type || 'primary'} border-2 shadow-lg">
|
||||
<div slot-id="header" class="modal-header py-2 px-3 bg-light">
|
||||
<h6 $class="modal-title fw-bold text-\${this.state?.type || 'primary'}" $text="this.state?.title"></h6>
|
||||
<button type="button" class="btn btn-link ms-2 bi bi-x-lg link-reset p-0" style="color:inherit; text-decoration:none" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div slot-id="body" class="modal-body p-3"></div>
|
||||
<div slot-id="footer" class="modal-footer py-2 px-3 bg-light"></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 border-\${this.state?.type || 'primary'} border-2 shadow-lg">
|
||||
<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">
|
||||
<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>
|
||||
</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*/`
|
||||
globalThis.Component.register('Toast', container => {
|
||||
container.toast = new bootstrap.Toast(container, { autohide: container.state.delay > 0 })
|
||||
globalThis.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)
|
||||
}
|
||||
})
|
||||
}, globalThis.Util.makeDom(/*html*/`
|
||||
<div class="toast align-items-center border-0 m-1">
|
||||
<div $class="toast-body rounded p-3 text-bg-\${this.state?.type}">
|
||||
<div class="d-flex align-items-center">
|
||||
@ -105,16 +39,19 @@ Component.register('Toast', container => {
|
||||
</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>`))
|
||||
`), globalThis.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 || 'primary', message, buttons: options.buttons || [] }
|
||||
$(`[toast-container="${options.container || 'default'}"]`).appendChild(t)
|
||||
Promise.resolve().then(() => t.show())
|
||||
const delay = options.delay ?? 5000
|
||||
const t = document.createElement('Toast')
|
||||
t.state = { delay, left: delay ? delay / 1000 : undefined, type: options.type || 'primary', message, buttons: options.buttons || [] }
|
||||
globalThis.$(`[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)))
|
||||
return new Promise((resolve) => UI.toast(message, { buttons: ['{#Confirm#}'], ...options }).then(index => resolve(index === 1)).catch(() => resolve(false)))
|
||||
}
|
||||
|
||||
globalThis.UI = UI;
|
||||
export { UI };
|
||||
|
||||
937
test/lib/base.js
937
test/lib/base.js
File diff suppressed because one or more lines are too long
@ -1,8 +1,72 @@
|
||||
(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 = {}));
|
||||
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 = global.ApigoState || {}));
|
||||
})(this, function(exports2) {
|
||||
"use strict";
|
||||
var _a;
|
||||
var _a, _b;
|
||||
const Util = {
|
||||
clone: globalThis.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
|
||||
};
|
||||
}
|
||||
};
|
||||
const $ = (a, b) => b ? a.querySelector(b) : document.querySelector(a);
|
||||
const $$ = (a, b) => b ? a.querySelectorAll(b) : document.querySelectorAll(a);
|
||||
globalThis.Util = Util;
|
||||
globalThis.$ = $;
|
||||
globalThis.$$ = $$;
|
||||
let __activeBinding = null;
|
||||
let __noWriteBack = null;
|
||||
const _setActiveBinding = (val) => __activeBinding = val;
|
||||
@ -70,14 +134,67 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
const $ = (a, b) => b ? a.querySelector(b) : document.querySelector(a);
|
||||
const $$ = (a, b) => b ? a.querySelectorAll(b) : document.querySelectorAll(a);
|
||||
globalThis.NewState = NewState;
|
||||
let _hashParams = new URLSearchParams(typeof globalThis !== "undefined" ? ((_b = (_a = globalThis.location) == null ? void 0 : _a.hash) == null ? void 0 : _b.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);
|
||||
globalThis.location.hash = "#" + _hashParams.toString();
|
||||
});
|
||||
if (typeof globalThis !== "undefined") {
|
||||
globalThis.addEventListener("hashchange", () => {
|
||||
var _a2;
|
||||
const newParams = new URLSearchParams(((_a2 = globalThis.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;
|
||||
let _disableRunCodeError = false;
|
||||
const 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);
|
||||
}
|
||||
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]);
|
||||
@ -154,32 +271,6 @@
|
||||
}
|
||||
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);
|
||||
@ -354,12 +445,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
const _initBinding = (binding) => {
|
||||
function _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) => {
|
||||
}
|
||||
function _parseNode(node, scanObj) {
|
||||
if (node._bindings) {
|
||||
node._states = /* @__PURE__ */ new Set();
|
||||
node._bindings.forEach((b) => _updateBinding({ node, ...b }));
|
||||
@ -372,7 +463,7 @@
|
||||
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.");
|
||||
if (scanObj.thisObj && 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(".");
|
||||
@ -398,7 +489,7 @@
|
||||
} 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 && scanObj.thisObj && 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();
|
||||
@ -440,7 +531,7 @@
|
||||
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 === 3) {
|
||||
if (node._stTranslated) return;
|
||||
@ -469,6 +560,7 @@
|
||||
node.parentNode.insertBefore(template, node);
|
||||
template.content.appendChild(node);
|
||||
template._ref = node._ref;
|
||||
_scanTree(template, scanObj);
|
||||
return;
|
||||
}
|
||||
if (node.tagName === "TEMPLATE" && (node.hasAttribute("$if") || node.hasAttribute("st-if")) && (node.hasAttribute("$each") || node.hasAttribute("st-each"))) {
|
||||
@ -528,116 +620,14 @@
|
||||
});
|
||||
node.childNodes && node.childNodes.forEach((child) => _unbindTree(child));
|
||||
};
|
||||
const _unsafeRefreshState = _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: _unsafeRefreshState,
|
||||
SetTranslator,
|
||||
_scanTree,
|
||||
_unbindTree,
|
||||
Util,
|
||||
Hash,
|
||||
LocalStorage,
|
||||
State
|
||||
};
|
||||
if (typeof window !== "undefined") {
|
||||
window.ApigoState = ApigoState;
|
||||
}
|
||||
globalThis.Component = Component;
|
||||
globalThis.SetTranslator = SetTranslator;
|
||||
globalThis.__unsafeRefreshState = _scanTree;
|
||||
if (typeof document !== "undefined") {
|
||||
const init = () => {
|
||||
Component._initPending();
|
||||
if (globalThis.Component && globalThis.Component._initPending) {
|
||||
globalThis.Component._initPending();
|
||||
}
|
||||
const htmlNode = document.documentElement;
|
||||
if (!htmlNode.hasAttribute("$data-bs-theme") && !htmlNode.hasAttribute("data-bs-theme")) {
|
||||
htmlNode.setAttribute("$data-bs-theme", "LocalStorage.darkMode?'dark':'light'");
|
||||
@ -655,17 +645,16 @@
|
||||
if (document.readyState !== "loading") init();
|
||||
else document.addEventListener("DOMContentLoaded", init, true);
|
||||
}
|
||||
const __unsafeRefreshState = _scanTree;
|
||||
exports2.$ = $;
|
||||
exports2.$$ = $$;
|
||||
exports2.Component = Component;
|
||||
exports2.Hash = Hash;
|
||||
exports2.LocalStorage = LocalStorage;
|
||||
exports2.NewState = NewState;
|
||||
exports2.RefreshState = _unsafeRefreshState;
|
||||
exports2.SetTranslator = SetTranslator;
|
||||
exports2.State = State;
|
||||
exports2.Util = Util;
|
||||
exports2._scanTree = _scanTree;
|
||||
exports2._unbindTree = _unbindTree;
|
||||
exports2.__unsafeRefreshState = __unsafeRefreshState;
|
||||
Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
|
||||
});
|
||||
|
||||
@ -28,6 +28,7 @@ export default defineConfig({
|
||||
format: 'umd',
|
||||
name: 'ApigoBase',
|
||||
entryFileNames: 'base.js',
|
||||
extend: true,
|
||||
globals: {
|
||||
'@apigo.cc/state': 'ApigoState',
|
||||
'@apigo.cc/bootstrap': 'bootstrap'
|
||||
@ -37,6 +38,7 @@ export default defineConfig({
|
||||
format: 'umd',
|
||||
name: 'ApigoBase',
|
||||
entryFileNames: 'base.min.js',
|
||||
extend: true,
|
||||
globals: {
|
||||
'@apigo.cc/state': 'ApigoState',
|
||||
'@apigo.cc/bootstrap': 'bootstrap'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user