iter(g329): 新增 editable 行内编辑
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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; // 提交回调(值已写回信号之后触发)
|
||||
}
|
||||
|
||||
@@ -103,7 +103,8 @@
|
||||
"progressRing(props):环形进度,SVG 双圆靠 dasharray 画弧 + 中心百分比,value 可传信号自动重绘",
|
||||
"noticeBar(props):单行通知条,语义色 + 左侧符号 + 可选跑马灯与关闭,适合页顶常驻播报",
|
||||
"sortableList(props):拖拽排序列表,整行可拖 + Alt+↑↓ 键盘搬位,返回 { el, move, order } 句柄",
|
||||
"masonry(props):瀑布流多列布局,列宽自适应或固定列数,卡片不被拆到两列"
|
||||
"masonry(props):瀑布流多列布局,列宽自适应或固定列数,卡片不被拆到两列",
|
||||
"editable(props):行内编辑,静止态是文字、点一下就地变输入框,Enter 提交 / Esc 取消 / 失焦提交"
|
||||
],
|
||||
"keyTypes": [
|
||||
"ButtonProps",
|
||||
@@ -4637,6 +4638,28 @@
|
||||
"列高不等才是瀑布流的价值所在(图片墙、评论流、看板):等高内容请用 grid,瀑布流会把等高卡片铺成奇怪的错位",
|
||||
"什么时候别用:需要严格的行对齐(表格化数据)、需要按列独立滚动(用 splitter / 多个 virtualList),以及首屏内容需要按阅读顺序严格横向(多列布局是竖向填充,读屏顺序与视觉列序一致但不等同于行序)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "editable",
|
||||
"title": "行内编辑 Editable",
|
||||
"demo": "sec-editable",
|
||||
"props": [
|
||||
{ "n": "value", "t": "Signal<string>", "r": 1, "d": "展示值,双向绑定:只有提交才写回,Esc 取消绝不落值" },
|
||||
{ "n": "placeholder", "t": "string", "df": "'点击填写'", "d": "空值时的占位文案,颜色走辅助文字档" },
|
||||
{ "n": "label", "t": "string", "df": "'编辑'", "d": "读屏用的字段名,作静止态按钮 aria-label 的前缀(如「标题:季度目标」)" },
|
||||
{ "n": "multiline", "t": "boolean", "d": "多行模式(textarea):Enter 换行,Ctrl / Cmd + Enter 才提交;缺省单行" },
|
||||
{ "n": "disabled", "t": "boolean | Signal<boolean>", "d": "禁用只读,布尔与信号双口径;点了也不进编辑态" },
|
||||
{ "n": "validate", "t": "(v: string) => string | null", "k": 1, "d": "提交校验:返回文案即拒绝提交、保持编辑态并标红(aria-invalid + role=alert),返回 null 表示通过" },
|
||||
{ "n": "onCommit", "t": "(v: string) => void", "k": 1, "d": "提交回调,值已写回信号之后触发;取消不触发" }
|
||||
],
|
||||
"example": "const title = signal('季度目标'); editable({ value: title, label: '标题', validate: (v) => (v ? null : '标题不能为空'), onCommit: (v) => toast.success('已保存:' + v) })",
|
||||
"steps": [
|
||||
"传一个 Signal<string> 作唯一数据源:组件只在提交时写回,取消路径一个字都不改,「改了没保存」的中间态不会污染业务状态",
|
||||
"点文字进编辑态:Enter 提交、Esc 取消、点别处(失焦)提交;多行模式要 Ctrl / Cmd + Enter 才提交,Enter 留给换行——否则多行里连换行都写不了",
|
||||
"validate 返回文案时保留编辑态并标红:控件带 aria-invalid、下方 role=alert 出文案,与 form 的出错口径一致,用户改对再提交即可",
|
||||
"disabled 接受布尔或信号:禁用时静止态是灰字、点了不进编辑,适合权限只读的场景(要完全不给「可改」暗示就直接渲染文字)",
|
||||
"什么时候别用:长表单(用 form + textField,editable 没有 label 排版与必填标记)、需要撤销栈的富文本,以及值会被频繁外部改写的地方(编辑中不跟随外部更新,是有意的取舍)"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6193,3 +6193,55 @@ h5.ua-type-title { font-size: var(--ua-font-size-md); }
|
||||
break-inside: avoid; /* 卡片不被拆到两列:图片墙里断半截最刺眼 */
|
||||
page-break-inside: avoid; /* 打印时同样不拆断 */
|
||||
}
|
||||
|
||||
/* —— 行内编辑 —— */
|
||||
.ua-editable {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ua-space-05);
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.ua-editable-view {
|
||||
margin: 0 calc(-1 * var(--ua-space-1)); /* 反向外扩:hover 底的 padding 不改变文字基线位置,进出编辑态不跳 */
|
||||
padding: 0 var(--ua-space-1);
|
||||
border: 1px dashed transparent; /* 静止态不占视觉,hover 才显出「这里能改」 */
|
||||
border-radius: var(--ua-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ua-color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: text;
|
||||
transition: border-color var(--ua-duration-fast) var(--ua-ease), background var(--ua-duration-fast) var(--ua-ease);
|
||||
}
|
||||
.ua-editable-view:hover:not(:disabled) {
|
||||
border-color: var(--ua-color-border);
|
||||
background: var(--ua-color-surface-2);
|
||||
}
|
||||
.ua-editable-view:disabled {
|
||||
color: var(--ua-color-text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ua-editable-view.is-empty {
|
||||
color: var(--ua-color-text-muted); /* 空值给占位口气,但仍是可点区域 */
|
||||
}
|
||||
.ua-editable-input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: var(--ua-space-1) var(--ua-space-2);
|
||||
border: 1px solid var(--ua-color-accent); /* 编辑态直接上主题色描边:与静止态一眼可分 */
|
||||
border-radius: var(--ua-radius-sm);
|
||||
background: var(--ua-color-bg);
|
||||
color: var(--ua-color-text);
|
||||
font: inherit;
|
||||
line-height: var(--ua-leading-normal);
|
||||
box-shadow: var(--ua-focus-ring);
|
||||
}
|
||||
.ua-editable-input[aria-invalid='true'] {
|
||||
border-color: var(--ua-color-danger);
|
||||
box-shadow: var(--ua-focus-ring-danger);
|
||||
}
|
||||
.ua-editable-error {
|
||||
font-size: var(--ua-font-size-sm);
|
||||
color: var(--ua-color-danger);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user