iter(g329): 新增 editable 行内编辑

This commit is contained in:
HIsheR
2026-10-27 02:01:04 +08:00
parent 309cb795de
commit 0768a34801
13 changed files with 286 additions and 10 deletions
+71
View File
@@ -0,0 +1,71 @@
import { effect, h } from '@ui-arch/frame';
import type { EditableProps } from './types.js';
/*** 行内编辑:静止态就是文字(hover 才露出可编辑暗示),点一下就地变输入框;Enter 提交、Esc 取消、失焦提交;validate 不过则留在编辑态并标红(与 form 同口径) ***/
export function editable(props: EditableProps): HTMLElement {
// —— 状态集中声明 ——
const value = props.value; // 唯一数据源:只有提交才写回,取消绝不落值
let editing = false; // 是否处于编辑态
let canceling = false; // Esc 触发的失焦不算提交(blur 让位一次)
let painted: string | null = null; // 上次静止态渲染的值;null 表示还没画过(effect 首跑必须画一次)
const root = h('div', { class: 'ua-editable' }) as HTMLElement;
const isDisabled = (): boolean => (typeof props.disabled === 'function' ? props.disabled() : !!props.disabled); // 布尔与信号两种入参统一判断
/*** 回到静止态并重画(提交 / 取消共用一条出口,避免两条路径各漏一半清理) ***/
const stop = (): void => { editing = false; painted = null; paint(); canceling = false; }; // 画完再复位:paint 里的 replaceChildren 会同步触发 blur,那时 canceling 必须仍为 true
/*** 提交:先去空白再校验;校验不过留在编辑态标红,过了才写回信号与回调 ***/
const commit = (raw: string, field: HTMLElement): void => {
const next = raw.trim();
const msg = props.validate?.(next) ?? null;
if (msg) {
field.setAttribute('aria-invalid', 'true');
const tip = root.querySelector('.ua-editable-error');
if (tip) tip.textContent = msg;
else root.append(h('span', { class: 'ua-editable-error', role: 'alert' }, msg));
return;
}
value(next);
props.onCommit?.(next);
stop();
};
/*** 画一遍:编辑态渲染输入控件并聚焦,静止态渲染整块可点的文字按钮 ***/
const paint = (): void => {
if (editing) {
const field = props.multiline
? h('textarea', { class: 'ua-editable-input', rows: 3, disabled: props.disabled }, value())
: h('input', { class: 'ua-editable-input', disabled: props.disabled, value: value() });
const el = field as HTMLInputElement | HTMLTextAreaElement;
el.addEventListener('keydown', ((e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); canceling = true; stop(); return; } // Esc:丢弃草稿
const enter = e.key === 'Enter' && (!props.multiline || e.ctrlKey || e.metaKey); // 多行只认 Ctrl/Cmd+Enter,换行留给 Enter
if (enter) { e.preventDefault(); commit(el.value, el); }
}) as EventListener); // 联合类型(input | textarea)取不到同一个重载,这里显式收窄
el.addEventListener('blur', () => {
if (canceling) { canceling = false; return; } // 卫语句:取消导致的失焦不提交
commit(el.value, el);
});
root.replaceChildren(el);
el.focus();
el.select?.();
return;
}
const text = value();
painted = text;
root.replaceChildren(h('button', {
type: 'button',
class: `ua-editable-view${text ? '' : ' is-empty'}`,
disabled: props.disabled,
title: text ? '点击编辑' : '点击填写',
'aria-label': `${props.label ?? '编辑'}:${text || '空'}`,
onClick: () => { if (isDisabled()) return; editing = true; paint(); }, // 卫语句:禁用态点了也不进编辑
}, text || props.placeholder || '点击填写'));
};
effect(() => {
const next = value();
if (!editing && next !== painted) paint(); // 外部改值只同步静止态,不打断正在编辑的草稿
});
return root;
}
+1
View File
@@ -89,3 +89,4 @@ export { progressRing } from './progressRing.js';
export { noticeBar } from './noticeBar.js';
export { sortableList } from './sortableList.js';
export { masonry } from './masonry.js';
export { editable } from './editable.js';
+11
View File
@@ -1158,3 +1158,14 @@ export interface MasonryProps {
minColumnWidth?: number; // 自适应列宽下限(像素);缺省 240,容器越宽列数越多
gap?: string; // 列间距与行间距(CSS 长度或令牌值);缺省 var(--ua-space-3)
}
/*** 行内编辑入参 ***/
export interface EditableProps {
value: Signal<string>; // 展示值,双向绑定:只有提交才写回(Esc 取消绝不落值)
placeholder?: string; // 空值时的占位文案;缺省「点击填写」
label?: string; // 读屏用的字段名,作按钮 aria-label 前缀;缺省「编辑」
multiline?: boolean; // 多行模式(textarea):Enter 换行,Ctrl / Cmd + Enter 才提交
disabled?: boolean | Signal<boolean>; // 禁用只读;布尔与信号双口径,点了也不进编辑态
validate?: (v: string) => string | null; // 提交校验:返回文案即拒绝提交、保持编辑态并标红;null 表示通过
onCommit?: (v: string) => void; // 提交回调(值已写回信号之后触发)
}