Files
UI/packages/skin/ui/src/editable.ts
T
2026-10-27 02:01:04 +08:00

72 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}