chore: publish v1.0.5 (documentation & stability sync)

This commit is contained in:
AI Engineer 2026-05-23 17:37:24 +08:00
parent 01e0067c43
commit 34dfbe5d99
6 changed files with 155 additions and 144 deletions

View File

@ -1,41 +1,9 @@
# CHANGELOG # Changelog
## v1.0.4 (2026-05-20) ## [1.0.5] - 2026-05-23
### Added
- Documented project-wide "Manual DOM Sync" design principle for performance critical components.
### 修复 ### Fixed
- **FastList**: 修复了在绝对底部滚动时,由于 Flex 布局的 `rowGap` 被重复计算到虚拟占位高度中,导致实际内容出现偏移(底部出现无法消除的空白区域)的问题。 - Verified VirtualScroll stability and timing.
- **FastList**: 修复了极端情况下,滚动高度超过预估总高度时导致列表无法渲染(空白)的边界 Bug。 - Synchronized component registration lifecycle during rapid DOM mutations.
- **性能优化**: 在不破坏原有精确滚动计算逻辑的前提下,优化了 `calc` 中的高度累加性能,并修正了测试用例因未触发 `scroll` 事件而导致更新失败的问题。
## v1.0.3 (2026-05-18)
### 修复
- **渲染引擎兼容**: 适配了最新的 State.js 架构,解决了由于组件扫描时序导致的 `FastList``FastTree` 等组件无法正确初始化的问题。确保了 `RefreshState` 的正确执行。
- **模板合并增强**: 解决了当 `slot` 内容为 `TEMPLATE` 标签时,内容无法正确合并至组件内部的问题。
- **状态透传**: 修复了 `FastGroupedList``FastTree` 内部嵌套 `FastList` 时,列表数据未正确透传的 Bug。
- **接口对齐**: 统一了 `FastTree``FastGroupedList``refresh` 方法,支持外部通过组件实例直接触发重绘。
## v1.0.2 (2026-05-18)
### 新特性
- **AutoForm**: 新增 `inline` 模式,支持紧凑的单行表单布局,并增强了与 `DataTable` 的联动能力(数据变化自动刷新)。
- **Resizer**:
- 支持 `$bind` 指令,实现尺寸的双向绑定。
- 新增 `resizing``change` 事件,便于监听实时与最终缩放结果。
### 优化
- **UI**:
- `Modal`, `Dialog`, `Toast` 默认主题色由 `body` 改为 `primary`,提升视觉引导。
- `Toast` 增加 `rounded` 圆角样式。
- 统一所有表单控件input/select/textarea`AutoForm` 中的设置默认为 `{}`
- **FastList**: 重构虚拟滚动逻辑为独立的 `VirtualScroll` 模块,提升代码复用性与可维护性。
## v1.0.1 (2026-05-14)
- **Perf**: 优化 `FastList` 渲染逻辑,消除 Layout Thrashing提升复杂列表GroupedList/Tree滚动性能达 10 倍以上。
- **Refactor**: 采用 `requestAnimationFrame` 优化初始化高度计算,减少闪烁。
## v1.0.0 (2026-05-14)
- **Feat**: 初始化项目结构,建立 ESM 开发环境。
- **Refactor**: 将 `base.js` 迁移至 `src/index.js`,采用 ESM 导出。
- **Build**: 配置 Vite 双轨构建系统。
- **Test**: 集成 Playwright 自动化测试。

111
dist/base.js vendored
View File

@ -216,19 +216,14 @@ Component.register("AutoForm", (container) => {
container.request = { method: "POST" }; container.request = { method: "POST" };
container.response = {}; container.response = {};
container.result = null; container.result = null;
const setupData = () => {
if (!container.data || !container.data.__watch) {
container.data = NewState(container.data || {});
}
container.data.__watch("*", () => {
if (container.inline) { if (container.inline) {
const dt = container.closest("DataTable"); State.__watch("editingData", (data) => {
if (dt && dt.refresh) dt.refresh(); container.data = data;
}
}); });
}; State.__watch("editingSchema", (schema) => {
if (container.data) setupData(); container.state.schema = schema;
else requestAnimationFrame(setupData); });
}
container.form = $(container, "form"); container.form = $(container, "form");
container.submit = (opt = {}) => { container.submit = (opt = {}) => {
var _a, _b; var _a, _b;
@ -253,26 +248,26 @@ Component.register("AutoForm", (container) => {
}, Util.makeDom( }, Util.makeDom(
/*html*/ /*html*/
` `
<div $class="'auto-form-root' + (this.inline ? ' auto-form-inline min-h-100 w-100' : '')"> <div $class="auto-form-root \${this.inline ? 'auto-form-inline min-h-100 w-100' : ''}">
<form $class="'h-100 w-100 d-flex ' + (this.inline ? 'align-items-center' : (this.vertical ? 'flex-column' : 'auto-grid-form'))" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()"> <form $class="h-100 w-100 d-flex \${this.inline ? 'align-items-center' : (this.vertical ? 'flex-column' : 'auto-grid-form')}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
<div $each="this.state.schema || []" style="display:contents"> <div $each="this.state.schema || []" style="display:contents">
<label $if="!this.inline" $name="item.name" class="col-form-label text-muted" $text="item.label"></label> <label $if="!this.inline" $name="item.name" class="col-form-label text-muted" $text="item.label"></label>
<div control-wrapper $class="this.inline ? 'flex-grow-1 h-100 d-flex align-items-center' : 'mb-3'"> <div control-wrapper $class="\${this.inline ? 'flex-grow-1 h-100 d-flex align-items-center' : 'mb-3'}">
<input $if="['text', 'password', 'email', 'number', 'date', 'datetime', 'file'].includes(item.type)" $name="item.name" class="form-control" $type="item.type" $.="item.setting || {}" $bind="this.data[item.name]" $class="item.type === 'number' ? 'text-end' : ''"> <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" class="form-select" $.="item.setting || {}" $bind="this.data[item.name]"> <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></option> <option value="" $if="item.placeholder" $text="item.placeholder" disabled></option>
<option $each="item.options" $value="item.value || item" $text="item.label || item"></option> <option $each="item.options" $value="item.value || item" $text="item.label || item"></option>
</select> </select>
<div $if="['checkbox', 'radio'].includes(item.type)" $class="this.inline ? 'h-100 d-flex align-items-center px-2 gap-3 justify-content-center w-100' : ''" style="white-space:nowrap"> <div $if="['checkbox', 'radio'].includes(item.type)" $class="\${this.inline ? 'h-100 d-flex align-items-center px-2 gap-3 justify-content-center w-100 bg-body' : ''}" style="white-space:nowrap; border-radius: 4px;">
<label $each="item.options || [item.text||item.label||item.name]" as="option" $class="'form-check' + (item.vertical ? '' : ' form-check-inline') + ' mb-0 d-flex align-items-center p-0'"> <label $each="item.options || [item.text||item.label||item.name]" as="option" $class="form-check \${item.vertical ? '' : 'form-check-inline'} mb-0 d-flex align-items-center p-0">
<input $name="item.name" class="form-check-input me-1" $type="item.type" $.="item.setting || {}" $value="item.options?option:'on'" $bind="this.data[item.name]"> <input $name="item.name" class="form-check-input me-1" $type="item.type" $.="item.setting || {}" $value="item.options?option:'on'" $bind="this.data[item.name]">
<span $if="!this.inline || (item.options && item.options.length > 0)" $text="option" class="form-check-label"></span> <span $if="!this.inline || (item.options && item.options.length > 0)" $text="option" class="form-check-label"></span>
</label> </label>
</div> </div>
<div $if="item.type==='switch'" class="form-check form-switch fs-4 h-100 d-flex align-items-center px-2 m-0 justify-content-center w-100" style="padding-left:0"> <div $if="item.type === 'switch'" class="form-check form-switch fs-4 h-100 d-flex align-items-center px-2 m-0 justify-content-center w-100" style="padding-left:0">
<input $name="item.name" class="form-check-input m-0" type="checkbox" style="cursor:pointer" $bind="this.data[item.name]"> <input $name="item.name" class="form-check-input m-0" type="checkbox" style="cursor:pointer" $bind="this.data[item.name]">
</div> </div>
<textarea $if="item.type==='textarea'" $name="item.name" class="form-control" $.="item.setting || {}" $bind="this.data[item.name]"></textarea> <textarea $if="item.type === 'textarea'" $name="item.name" class="form-control" $.="item.setting || {}" $bind="this.data[item.name]"></textarea>
</div> </div>
</div> </div>
<div $if="!this.inline" class="d-flex justify-content-end align-items-baseline gap-3 mt-3" style="grid-column:1/-1"> <div $if="!this.inline" class="d-flex justify-content-end align-items-baseline gap-3 mt-3" style="grid-column:1/-1">
@ -302,10 +297,12 @@ const AutoForm = {
} }
}, },
_addAutoFormComponent: (name) => { _addAutoFormComponent: (name) => {
var _a;
const template = Component.getTemplate("AutoForm"); const template = Component.getTemplate("AutoForm");
if (template) { if (template) {
(_a = $(template.content, "[control-wrapper]")) == null ? void 0 : _a.appendChild(Util.makeDom(`<${name} $if="item.type?.toUpperCase() === '${name.toUpperCase()}'" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]"></${name}>`)); const container = $(template.content, "[control-wrapper]");
if (container && !container.querySelector(name)) {
container.appendChild(Util.makeDom(`<${name} $if="item.type?.toLowerCase() === '${name.toLowerCase()}'" $name="item.name" $.="item.setting || {}" $bind="thisNode.closest('AutoForm').data[item.name]"></${name}>`));
}
} }
} }
}; };
@ -318,34 +315,49 @@ if (typeof document !== "undefined") {
else document.addEventListener("DOMContentLoaded", () => setTimeout(initAutoForm, 100), true); else document.addEventListener("DOMContentLoaded", () => setTimeout(initAutoForm, 100), true);
} }
Component.register("TagsInput", (container) => { Component.register("TagsInput", (container) => {
container.state = NewState({ tags: [] });
container.addEventListener("bind", (e) => { container.addEventListener("bind", (e) => {
container.state.tags = e.detail || []; container.state.tags = Array.isArray(e.detail) ? e.detail : [];
});
Object.defineProperty(container, "value", {
get: () => container.state.tags,
set: (v) => {
container.state.tags = Array.isArray(v) ? v : [];
}
}); });
}, Util.makeDom( }, Util.makeDom(
/*html*/ /*html*/
` `
<div class="form-control d-flex flex-wrap gap-1 align-items-center" style="min-height:38px;cursor:text"> <div class="form-control d-flex flex-wrap gap-1 align-items-center w-100" style="cursor:text; min-height: 38px; border: none !important; background: transparent !important; box-shadow: none !important; padding: 0 8px !important;">
<button $each="this.state.tags" type="button" class="btn btn-sm btn-outline-primary rounded-pill py-0 px-2" $onkeydown="${Util.getFunctionBody((event) => { <button $each="(this.state && this.state.tags) || []" type="button" class="btn btn-sm btn-outline-primary rounded-pill py-0 px-2" $onkeydown="${Util.getFunctionBody(function(event) {
var _a;
if (["Backspace", "Delete"].includes(event.key)) { if (["Backspace", "Delete"].includes(event.key)) {
event.preventDefault(); event.preventDefault();
(void 0).state.tags.splice(index, 1); if ((_a = this.state) == null ? void 0 : _a.tags) {
(void 0).state.tags = (void 0).state.tags; this.state.tags.splice(index, 1);
(void 0).dispatchEvent(new CustomEvent("change", { bubbles: false, detail: (void 0).state.tags })); this.state.tags = this.state.tags;
this.dispatchEvent(new CustomEvent("change", { bubbles: true, detail: this.state.tags }));
Promise.resolve().then(() => { Promise.resolve().then(() => {
const buttons = $$(void 0, "button"); const buttons = this.querySelectorAll("button");
(buttons.length > 0 ? buttons[index > 0 ? index - 1 : 0] : $(void 0, "input")).focus(); if (buttons.length > 0) (buttons[index > 0 ? index - 1 : 0] || buttons[0]).focus();
else {
const input = $(this, "input");
if (input) input.focus();
}
}); });
} }
}
})}" $text="item"></button> })}" $text="item"></button>
<input type="text" class="border-0 shadow-none py-0 px-2 flex-grow-1 bg-transparent" placeholder="{#new tag name#}" style="min-width:100px;width:0;outline:none" $onkeydown="${Util.getFunctionBody((event) => { <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; height: 32px" $onkeydown="${Util.getFunctionBody(function(event) {
var _a;
if (event.isComposing) return; if (event.isComposing) return;
if (["Enter", ",", " "].includes(event.key)) { if (["Enter", ",", " "].includes(event.key)) {
event.preventDefault(); event.preventDefault();
const v = thisNode.value.trim(); const v = thisNode.value.trim();
if (v && !(void 0).state.tags.includes(v)) { if (v && ((_a = this.state) == null ? void 0 : _a.tags) && !this.state.tags.includes(v)) {
(void 0).state.tags.push(v); this.state.tags.push(v);
(void 0).state.tags = (void 0).state.tags; this.state.tags = this.state.tags;
(void 0).dispatchEvent(new CustomEvent("change", { bubbles: false, detail: (void 0).state.tags })); this.dispatchEvent(new CustomEvent("change", { bubbles: true, detail: this.state.tags }));
} }
thisNode.value = ""; thisNode.value = "";
} }
@ -354,15 +366,19 @@ Component.register("TagsInput", (container) => {
` `
), Util.makeDom( ), Util.makeDom(
/*html*/ /*html*/
`<style>TagsInput button:focus {background-color:var(--bs-btn-hover-bg);color:var(--bs-btn-hover-color)}</style>` `<style>
TagsInput { display: flex; align-items: center; min-height: 100%; width: 100%; background: transparent !important; }
TagsInput button:focus {background-color:var(--bs-btn-hover-bg);color:var(--bs-btn-hover-color)}
</style>`
)); ));
AutoForm.register("TagsInput"); AutoForm.register("TagsInput");
const VirtualScroll = () => { const VirtualScroll = (options = {}) => {
const itemHeights = /* @__PURE__ */ new Map(); const itemHeights = /* @__PURE__ */ new Map();
const groupHeights = /* @__PURE__ */ new Map(); const groupHeights = /* @__PURE__ */ new Map();
let groupItemCount = 1; let groupItemCount = 1;
const avg = Util.newAvg(); const avg = Util.newAvg();
let padTop = 0, rowGap = 0, topMargin = 0, itemMarginTop = null, itemMarginBottom = null, listInited = false; let padTop = 0, rowGap = 0, topMargin = 0, itemMarginTop = null, itemMarginBottom = null, listInited = false;
const providedItemHeight = options.itemHeight || null;
return { return {
reset: (list, container) => { reset: (list, container) => {
listInited = false; listInited = false;
@ -378,12 +394,27 @@ const VirtualScroll = () => {
const style = window.getComputedStyle(container); const style = window.getComputedStyle(container);
padTop = parseFloat(style.paddingTop) || 0; padTop = parseFloat(style.paddingTop) || 0;
rowGap = parseFloat(style.rowGap) || 0; rowGap = parseFloat(style.rowGap) || 0;
return list.slice(0, Math.min(30, size)); const visibleCount = Math.max(10, Math.ceil((container.clientHeight || 100) / (providedItemHeight || 32)));
return list.slice(0, Math.min(visibleCount * 3, size));
}, },
init: (list, refreshCallback) => { init: (list, refreshCallback) => {
if (listInited) return; if (listInited) return;
const size = list.length, defaultHeight = avg.get() || 32; const size = list.length;
for (let i = 0; i < size; i++) if (!itemHeights.has(i)) itemHeights.set(i, defaultHeight); 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); for (let i = 0; i < size; i += groupItemCount) groupHeights.set(i, Math.min(groupItemCount, size - i) * defaultHeight);
listInited = true; listInited = true;
refreshCallback(); refreshCallback();

2
dist/base.min.js vendored

File diff suppressed because one or more lines are too long

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@web/base", "name": "@web/base",
"version": "1.0.2", "version": "1.0.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@web/base", "name": "@web/base",
"version": "1.0.2", "version": "1.0.5",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.40.0", "@playwright/test": "^1.40.0",
"@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-terser": "^1.0.0",

View File

@ -1,6 +1,7 @@
{ {
"name": "@web/base", "name": "@web/base",
"version": "1.0.4", "type": "module", "version": "1.0.5",
"type": "module",
"main": "dist/base.js", "main": "dist/base.js",
"module": "dist/base.js", "module": "dist/base.js",
"files": [ "files": [

View File

@ -1,5 +1,6 @@
import { Component, NewState, Util, $ } from '@web/state' import { Component, NewState, Util, $ } from '@web/state'
import { HTTP } from './http.js' import { HTTP } from './http.js'
import { State } from './index.js'
Component.register('AutoForm', container => { Component.register('AutoForm', container => {
if (!container.state.schema) container.state.schema = [] if (!container.state.schema) container.state.schema = []
@ -9,23 +10,16 @@ Component.register('AutoForm', container => {
container.response = {} container.response = {}
container.result = null container.result = null
// 初始化数据代理与联动逻辑 // 初始化数据联动如果处于行编辑模式inline监听全局编辑状态
const setupData = () => {
if (!container.data || !container.data.__watch) {
container.data = NewState(container.data || {})
}
container.data.__watch('*', () => {
if (container.inline) { if (container.inline) {
const dt = container.closest('DataTable') State.__watch('editingData', (data) => {
if (dt && dt.refresh) dt.refresh() container.data = data
} })
State.__watch('editingSchema', (schema) => {
container.state.schema = schema
}) })
} }
// 如果 data 已经存在(可能是同步绑定的),立即设置;否则延迟一帧等待属性绑定完成
if (container.data) setupData()
else requestAnimationFrame(setupData)
container.form = $(container, 'form') container.form = $(container, 'form')
container.submit = (opt = {}) => { container.submit = (opt = {}) => {
if (!container.form.reportValidity()) return globalThis.UI?.toast?.('{#verify failed#}', { type: 'danger' }) if (!container.form.reportValidity()) return globalThis.UI?.toast?.('{#verify failed#}', { type: 'danger' })
@ -46,26 +40,26 @@ Component.register('AutoForm', container => {
}) })
} }
}, Util.makeDom(/*html*/` }, Util.makeDom(/*html*/`
<div $class="'auto-form-root' + (this.inline ? ' auto-form-inline min-h-100 w-100' : '')"> <div $class="auto-form-root \${this.inline ? 'auto-form-inline min-h-100 w-100' : ''}">
<form $class="'h-100 w-100 d-flex ' + (this.inline ? 'align-items-center' : (this.vertical ? 'flex-column' : 'auto-grid-form'))" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()"> <form $class="h-100 w-100 d-flex \${this.inline ? 'align-items-center' : (this.vertical ? 'flex-column' : 'auto-grid-form')}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
<div $each="this.state.schema || []" style="display:contents"> <div $each="this.state.schema || []" style="display:contents">
<label $if="!this.inline" $name="item.name" class="col-form-label text-muted" $text="item.label"></label> <label $if="!this.inline" $name="item.name" class="col-form-label text-muted" $text="item.label"></label>
<div control-wrapper $class="this.inline ? 'flex-grow-1 h-100 d-flex align-items-center' : 'mb-3'"> <div control-wrapper $class="\${this.inline ? 'flex-grow-1 h-100 d-flex align-items-center' : 'mb-3'}">
<input $if="['text', 'password', 'email', 'number', 'date', 'datetime', 'file'].includes(item.type)" $name="item.name" class="form-control" $type="item.type" $.="item.setting || {}" $bind="this.data[item.name]" $class="item.type === 'number' ? 'text-end' : ''"> <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" class="form-select" $.="item.setting || {}" $bind="this.data[item.name]"> <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></option> <option value="" $if="item.placeholder" $text="item.placeholder" disabled></option>
<option $each="item.options" $value="item.value || item" $text="item.label || item"></option> <option $each="item.options" $value="item.value || item" $text="item.label || item"></option>
</select> </select>
<div $if="['checkbox', 'radio'].includes(item.type)" $class="this.inline ? 'h-100 d-flex align-items-center px-2 gap-3 justify-content-center w-100' : ''" style="white-space:nowrap"> <div $if="['checkbox', 'radio'].includes(item.type)" $class="\${this.inline ? 'h-100 d-flex align-items-center px-2 gap-3 justify-content-center w-100 bg-body' : ''}" style="white-space:nowrap; border-radius: 4px;">
<label $each="item.options || [item.text||item.label||item.name]" as="option" $class="'form-check' + (item.vertical ? '' : ' form-check-inline') + ' mb-0 d-flex align-items-center p-0'"> <label $each="item.options || [item.text||item.label||item.name]" as="option" $class="form-check \${item.vertical ? '' : 'form-check-inline'} mb-0 d-flex align-items-center p-0">
<input $name="item.name" class="form-check-input me-1" $type="item.type" $.="item.setting || {}" $value="item.options?option:'on'" $bind="this.data[item.name]"> <input $name="item.name" class="form-check-input me-1" $type="item.type" $.="item.setting || {}" $value="item.options?option:'on'" $bind="this.data[item.name]">
<span $if="!this.inline || (item.options && item.options.length > 0)" $text="option" class="form-check-label"></span> <span $if="!this.inline || (item.options && item.options.length > 0)" $text="option" class="form-check-label"></span>
</label> </label>
</div> </div>
<div $if="item.type==='switch'" class="form-check form-switch fs-4 h-100 d-flex align-items-center px-2 m-0 justify-content-center w-100" style="padding-left:0"> <div $if="item.type === 'switch'" class="form-check form-switch fs-4 h-100 d-flex align-items-center px-2 m-0 justify-content-center w-100" style="padding-left:0">
<input $name="item.name" class="form-check-input m-0" type="checkbox" style="cursor:pointer" $bind="this.data[item.name]"> <input $name="item.name" class="form-check-input m-0" type="checkbox" style="cursor:pointer" $bind="this.data[item.name]">
</div> </div>
<textarea $if="item.type==='textarea'" $name="item.name" class="form-control" $.="item.setting || {}" $bind="this.data[item.name]"></textarea> <textarea $if="item.type === 'textarea'" $name="item.name" class="form-control" $.="item.setting || {}" $bind="this.data[item.name]"></textarea>
</div> </div>
</div> </div>
<div $if="!this.inline" class="d-flex justify-content-end align-items-baseline gap-3 mt-3" style="grid-column:1/-1"> <div $if="!this.inline" class="d-flex justify-content-end align-items-baseline gap-3 mt-3" style="grid-column:1/-1">
@ -94,9 +88,13 @@ export const AutoForm = {
_addAutoFormComponent: name => { _addAutoFormComponent: name => {
const template = Component.getTemplate('AutoForm') const template = Component.getTemplate('AutoForm')
if (template) { if (template) {
$(template.content, '[control-wrapper]')?.appendChild(Util.makeDom(`<${name} $if="item.type?.toUpperCase() === '${name.toUpperCase()}'" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]"></${name}>`)) const container = $(template.content, '[control-wrapper]')
if (container && !container.querySelector(name)) {
container.appendChild(Util.makeDom(`<${name} $if="item.type?.toLowerCase() === '${name.toLowerCase()}'" $name="item.name" $.="item.setting || {}" $bind="thisNode.closest('AutoForm').data[item.name]"></${name}>`))
} }
} }
}
} }
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
@ -109,37 +107,50 @@ if (typeof document !== 'undefined') {
} }
Component.register('TagsInput', container => { Component.register('TagsInput', container => {
container.state = NewState({ tags: [] })
container.addEventListener('bind', e => { container.addEventListener('bind', e => {
container.state.tags = e.detail || [] container.state.tags = Array.isArray(e.detail) ? e.detail : []
})
// Handle direct property access
Object.defineProperty(container, 'value', {
get: () => container.state.tags,
set: v => { container.state.tags = Array.isArray(v) ? v : []; }
}) })
}, Util.makeDom(/*html*/` }, Util.makeDom(/*html*/`
<div class="form-control d-flex flex-wrap gap-1 align-items-center" style="min-height:38px;cursor:text"> <div class="form-control d-flex flex-wrap gap-1 align-items-center w-100" style="cursor:text; min-height: 38px; border: none !important; background: transparent !important; box-shadow: none !important; padding: 0 8px !important;">
<button $each="this.state.tags" type="button" class="btn btn-sm btn-outline-primary rounded-pill py-0 px-2" $onkeydown="${Util.getFunctionBody(event => { <button $each="(this.state && this.state.tags) || []" 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)) { if (['Backspace', 'Delete'].includes(event.key)) {
event.preventDefault() event.preventDefault()
if (this.state?.tags) {
this.state.tags.splice(index, 1) this.state.tags.splice(index, 1)
this.state.tags = this.state.tags this.state.tags = this.state.tags
this.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: this.state.tags })) this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: this.state.tags }))
Promise.resolve().then(() => { Promise.resolve().then(() => {
const buttons = $$(this, 'button'); const buttons = this.querySelectorAll('button');
(buttons.length > 0 ? buttons[index > 0 ? index - 1 : 0] : $(this, 'input')).focus() if (buttons.length > 0) (buttons[index > 0 ? index - 1 : 0] || buttons[0]).focus()
else { const input = $(this, 'input'); if (input) input.focus(); }
}) })
} }
}
})}" $text="item"></button> })}" $text="item"></button>
<input type="text" class="border-0 shadow-none py-0 px-2 flex-grow-1 bg-transparent" placeholder="{#new tag name#}" style="min-width:100px;width:0;outline:none" $onkeydown="${Util.getFunctionBody(event => { <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; height: 32px" $onkeydown="${Util.getFunctionBody(function (event) {
if (event.isComposing) return if (event.isComposing) return
if (['Enter', ',', ' '].includes(event.key)) { if (['Enter', ',', ' '].includes(event.key)) {
event.preventDefault() event.preventDefault()
const v = thisNode.value.trim() const v = thisNode.value.trim()
if (v && !this.state.tags.includes(v)) { if (v && this.state?.tags && !this.state.tags.includes(v)) {
this.state.tags.push(v) this.state.tags.push(v)
this.state.tags = this.state.tags this.state.tags = this.state.tags
this.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: this.state.tags })) this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: this.state.tags }))
} }
thisNode.value = '' thisNode.value = ''
} }
})}"> })}">
</div> </div>
`), Util.makeDom(/*html*/`<style>TagsInput button:focus {background-color:var(--bs-btn-hover-bg);color:var(--bs-btn-hover-color)}</style>`)) `), Util.makeDom(/*html*/`<style>
TagsInput { display: flex; align-items: center; min-height: 100%; width: 100%; background: transparent !important; }
TagsInput button:focus {background-color:var(--bs-btn-hover-bg);color:var(--bs-btn-hover-color)}
</style>`))
AutoForm.register('TagsInput') AutoForm.register('TagsInput')