From 0768a3480106d84e1cc815e16a2d1b9f1a85be8f Mon Sep 17 00:00:00 2001 From: HIsheR Date: Tue, 27 Oct 2026 02:01:04 +0800 Subject: [PATCH] =?UTF-8?q?iter(g329):=20=E6=96=B0=E5=A2=9E=20editable=20?= =?UTF-8?q?=E8=A1=8C=E5=86=85=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ua/backlog.json | 9 ++++ .ua/index.json | 60 +++++++++++++++++++++++++- AGENTS.md | 2 +- ARCHITECTURE.md | 2 +- README.md | 8 ++-- apps/demo/main.ts | 23 ++++++++++ llms-full.txt | 31 +++++++++++++- llms.txt | 1 + packages/skin/ui/src/editable.ts | 71 +++++++++++++++++++++++++++++++ packages/skin/ui/src/index.ts | 1 + packages/skin/ui/src/types.ts | 11 +++++ packages/skin/ui/ua.manifest.json | 25 ++++++++++- packages/skin/ui/ui.css | 52 ++++++++++++++++++++++ 13 files changed, 286 insertions(+), 10 deletions(-) create mode 100644 packages/skin/ui/src/editable.ts diff --git a/.ua/backlog.json b/.ua/backlog.json index 884ba44..3fef43e 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -3347,6 +3347,15 @@ "motivation": "accent 有 --ua-color-tint-accent(亮 10% / 暗 14%),语义三色却没有对应令牌,15 处淡底全靠手写 color-mix:同一组 toast / tag 里 info 走令牌(透明叠加)、success / warning / danger 走「与页面底混色」(不透明),四种语义档的底其实两套口径;手写浓度也无法被门禁守住——accent 那档被 audit:surface 抓到过暗色几乎看不见,语义三色同样没有栅栏。", "acceptance": "① tokens.css 新增 --ua-color-tint-danger / -success / -warning(亮色 var(--ua-alpha-soft),暗色 14%,与 accent 分档同构),tokens.json 同步三键(verify 的名字与值双源校验要过);② ui.css 15 处手写语义淡底(含与页面底混色的 6 处)统一换成令牌,四种语义档只剩一套口径;③ audit:surface 阶梯新增「表面 → 危险/成功/警告淡底」三档(阈值 1.04),四套主题全部实测达标;④ audit:visual 混色浓度计数不涨;⑤ npm run check 全绿。", "notes": "" + }, + { + "id": "g329", + "domain": "new", + "status": "open", + "title": "新增 editable 行内编辑:改个标题不必整块塞进输入框", + "motivation": "库里有 textField / textarea,却没有「就地改一段已有文字」的基础件:看板标题改名、表格单元格、详情页字段这类场景只能把输入框常驻在页面上(视觉噪音大),或者自己写一套「点击变输入框 + Enter 提交 + Esc 取消」——每个业务都重写一遍,行为还不一致(多数忘了 Esc 不落值、忘了失焦提交、忘了校验失败要留在编辑态)。", + "acceptance": "① 新增 editable 组件:静止态是可点文字按钮(hover 才露虚线暗示),点击 / 回车进编辑态,Enter 提交、Esc 取消(绝不写回)、失焦提交,多行模式 Ctrl/Cmd+Enter 提交、Enter 换行;② validate 返回文案时保持编辑态、控件 aria-invalid + 下方 role=alert 文案(与 form 同口径);③ disabled 支持布尔与信号双口径;外部改值只同步静止态,不打断正在编辑的草稿;④ 样式只消费 --ua-* 令牌(禁新增硬编码阴影 / 时长 / 字号),audit:visual 计数不涨;⑤ 十个落点齐全(实现 / 类型 / 导出 / 样式 / 清单 exports / docs.components 五步 / demo 段 / README 清单与分组 / 计数同步),组件数 89 → 90,npm run check 全绿。", + "notes": "" } ] } diff --git a/.ua/index.json b/.ua/index.json index 19a35c1..015f378 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T17:55:42.939Z", + "generatedAt": "2026-10-26T17:59:51.593Z", "repoRoot": ".", "packages": [ { @@ -233,7 +233,8 @@ "progressRing(props):环形进度,SVG 双圆靠 dasharray 画弧 + 中心百分比,value 可传信号自动重绘", "noticeBar(props):单行通知条,语义色 + 左侧符号 + 可选跑马灯与关闭,适合页顶常驻播报", "sortableList(props):拖拽排序列表,整行可拖 + Alt+↑↓ 键盘搬位,返回 { el, move, order } 句柄", - "masonry(props):瀑布流多列布局,列宽自适应或固定列数,卡片不被拆到两列" + "masonry(props):瀑布流多列布局,列宽自适应或固定列数,卡片不被拆到两列", + "editable(props):行内编辑,静止态是文字、点一下就地变输入框,Enter 提交 / Esc 取消 / 失焦提交" ], "keyTypes": [ "ButtonProps", @@ -4767,6 +4768,61 @@ "列高不等才是瀑布流的价值所在(图片墙、评论流、看板):等高内容请用 grid,瀑布流会把等高卡片铺成奇怪的错位", "什么时候别用:需要严格的行对齐(表格化数据)、需要按列独立滚动(用 splitter / 多个 virtualList),以及首屏内容需要按阅读顺序严格横向(多列布局是竖向填充,读屏顺序与视觉列序一致但不等同于行序)" ] + }, + { + "id": "editable", + "title": "行内编辑 Editable", + "demo": "sec-editable", + "props": [ + { + "n": "value", + "t": "Signal", + "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", + "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 作唯一数据源:组件只在提交时写回,取消路径一个字都不改,「改了没保存」的中间态不会污染业务状态", + "点文字进编辑态:Enter 提交、Esc 取消、点别处(失焦)提交;多行模式要 Ctrl / Cmd + Enter 才提交,Enter 留给换行——否则多行里连换行都写不了", + "validate 返回文案时保留编辑态并标红:控件带 aria-invalid、下方 role=alert 出文案,与 form 的出错口径一致,用户改对再提交即可", + "disabled 接受布尔或信号:禁用时静止态是灰字、点了不进编辑,适合权限只读的场景(要完全不给「可改」暗示就直接渲染文字)", + "什么时候别用:长表单(用 form + textField,editable 没有 label 排版与必填标记)、需要撤销栈的富文本,以及值会被频繁外部改写的地方(编辑中不跟随外部更新,是有意的取舍)" + ] } ] }, diff --git a/AGENTS.md b/AGENTS.md index 5c8614f..12368ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ - `packages/core/table-core`(@ui-arch/table-core):Headless 表格逻辑内核 - `packages/core/frame`(@ui-arch/frame):signal + hyperscript 微框架 -- `packages/skin/ui`(@ui-arch/ui):89 个 DOM 组件(表单 / 反馈 / 浮层 / 导航 / 表格),样式只消费令牌 +- `packages/skin/ui`(@ui-arch/ui):90 个 DOM 组件(表单 / 反馈 / 浮层 / 导航 / 表格),样式只消费令牌 - `packages/design/tokens`(@ui-arch/tokens):设计令牌 - `packages/tools/cli`(@ui-arch/cli):`ua` 命令行 + MCP 注册表服务(private) - `apps/demo`(@ui-arch/demo):交互演示,产物 apps/demo/dist/demo.html diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9ca70eb..d356d5e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -39,7 +39,7 @@ ui-arch-mvp/ │ │ ├── table-core/ # 表格状态内核 │ │ └── frame/ # 信号 + hyperscript 微框架 │ ├── skin/ # 皮肤层(DOM 组件,可发布) -│ │ └── ui/ # 89 个组件 + ui.css +│ │ └── ui/ # 90 个组件 + ui.css │ ├── design/ # 资产层(可发布) │ │ └── tokens/ # --ua-* 设计令牌 │ └── tools/ # 工程层(private,不发布) diff --git a/README.md b/README.md index fc18f46..620d0fe 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,13 @@ npm run docs # 生成人类文档站 apps/docs/index.html + Agent 全 npm run changelog # 依据 git 提交史重写 CHANGELOG.md(发版前跑;changelog:check 可查漂移) ``` -## 组件(共 89 个) +## 组件(共 90 个) -accordion · affix · alert · anchor · autoComplete · avatar · avatarGroup · backTop · badge · breadcrumb · button · calendar · carousel · card · cascader · checkbox · checkboxGroup · codeBlock · colorPicker · commandPalette · copyButton · countdown · dataTable · datePicker · descriptions · diffView · divider · drawer · empty · floatButton · form · grid · heatmap · highlight · image · inputNumber · inputTag · jsonViewer · kbd · layout · link · list · loading · masonry · menu · modal · mentions · multiSelect · noticeBar · notification · pagination · pinInput · popover · popconfirm · progress · progressRing · radioGroup · rate · relativeTime · result · segmented · select · signaturePad · skeleton · slider · sortableList · space · sparkline · spinner · splitter · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · timePicker · toast · tooltip · tour · transfer · tree · treeSelect · typography · upload · virtualList · watermark +accordion · affix · alert · anchor · autoComplete · avatar · avatarGroup · backTop · badge · breadcrumb · button · calendar · carousel · card · cascader · checkbox · checkboxGroup · codeBlock · colorPicker · commandPalette · copyButton · countdown · dataTable · datePicker · descriptions · diffView · editable · divider · drawer · empty · floatButton · form · grid · heatmap · highlight · image · inputNumber · inputTag · jsonViewer · kbd · layout · link · list · loading · masonry · menu · modal · mentions · multiSelect · noticeBar · notification · pagination · pinInput · popover · popconfirm · progress · progressRing · radioGroup · rate · relativeTime · result · segmented · select · signaturePad · skeleton · slider · sortableList · space · sparkline · spinner · splitter · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · timePicker · toast · tooltip · tour · transfer · tree · treeSelect · typography · upload · virtualList · watermark -分组:表单(button / copyButton / signaturePad / textField / inputNumber / inputTag / textarea / checkbox / checkboxGroup / colorPicker / switchButton / select / mentions / multiSelect / radioGroup / slider / rate / autoComplete / datePicker / timePicker / pinInput / segmented / upload / form + formItem / transfer / cascader)、反馈(alert / loading / toast / notification / noticeBar / result / spinner / progress / progressRing / skeleton / badge / empty / statistic / countdown)、浮层(tooltip / popover / popconfirm / modal / drawer / menu / commandPalette)、导航(tabs / breadcrumb / pagination / accordion / stepper / tree / treeSelect / anchor)、数据展示(dataTable / jsonViewer / diffView / codeBlock / relativeTime / sparkline / heatmap / descriptions / card / list / sortableList / kbd / highlight / timeline / tag / avatar / avatarGroup / image / carousel / calendar / typography / virtualList)、其他(floatButton / grid / layout / link / space / divider / splitter / backTop / affix / tour / masonry / watermark)。 +分组:表单(button / copyButton / signaturePad / textField / editable / inputNumber / inputTag / textarea / checkbox / checkboxGroup / colorPicker / switchButton / select / mentions / multiSelect / radioGroup / slider / rate / autoComplete / datePicker / timePicker / pinInput / segmented / upload / form + formItem / transfer / cascader)、反馈(alert / loading / toast / notification / noticeBar / result / spinner / progress / progressRing / skeleton / badge / empty / statistic / countdown)、浮层(tooltip / popover / popconfirm / modal / drawer / menu / commandPalette)、导航(tabs / breadcrumb / pagination / accordion / stepper / tree / treeSelect / anchor)、数据展示(dataTable / jsonViewer / diffView / codeBlock / relativeTime / sparkline / heatmap / descriptions / card / list / sortableList / kbd / highlight / timeline / tag / avatar / avatarGroup / image / carousel / calendar / typography / virtualList)、其他(floatButton / grid / layout / link / space / divider / splitter / backTop / affix / tour / masonry / watermark)。 ## 可访问性约定 @@ -34,7 +34,7 @@ accordion · affix · alert · anchor · autoComplete · avatar · avatarGroup |---|---| | `packages/core/frame` | @ui-arch/frame:signal / computed / effect + hyperscript(无虚拟 DOM) | | `packages/core/table-core` | @ui-arch/table-core:headless 表格(filter → sort → paginate 派生管线) | -| `packages/skin/ui` | @ui-arch/ui:89 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) | +| `packages/skin/ui` | @ui-arch/ui:90 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) | | `packages/design/tokens` | @ui-arch/tokens:设计令牌(暗色只换值不换名) | | `packages/tools/cli` | @ui-arch/cli:`ua` 命令行与 stdio MCP server | | `apps/demo` | 交互演示(esbuild 打包成单文件) | diff --git a/apps/demo/main.ts b/apps/demo/main.ts index 86f345c..1f35cf1 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -63,6 +63,7 @@ import { watermark, menu, masonry, + editable, modal, mentions, multiSelect, @@ -246,6 +247,10 @@ const score2 = signal(3); // 演示 allowClear 可清除评分 // —— 空状态:搜索无结果演示 —— const noResult = signal(true); +// —— 行内编辑:标题与备注各一份信号,提交才落值(Esc 取消可肉眼验证没被改) —— +const titleText = signal('季度增长看板'); +const remark = signal('点这里就地改;Esc 丢弃草稿,失焦自动保存。'); + // —— 受控选中:dataTable 内部勾选实时写回,外部可清空 —— const picked = signal([]); @@ -1959,6 +1964,24 @@ content.append( ], }), ), + section( + 'sec-editable', + '行内编辑', + 'editable:静止态是文字、点一下就地变输入框;Enter 提交、Esc 取消(不落值)、失焦提交;validate 拦下时保持编辑态并标红,多行模式用 Ctrl+Enter 提交。', + h( + 'div', + { class: 'row' }, + editable({ value: titleText, label: '标题', placeholder: '未命名看板', onCommit: (v) => toast.success(`标题改成「${v}」`) }), + editable({ + value: remark, + label: '备注', + multiline: true, + validate: (v) => (v.length > 40 ? `最多 40 字(当前 ${v.length})` : null), + onCommit: () => toast.info('备注已保存'), + }), + editable({ value: signal('只读字段'), label: '只读', disabled: true }), + ), + ), ); // 悬浮按钮:fixed 定位,挂 body 不参与页面布局 diff --git a/llms-full.txt b/llms-full.txt index 37fd620..4927d50 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @ui-arch 组件库 Agent 文档(无图形版) -> 自动生成于 2026-10-26T17:55:42.939Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 +> 自动生成于 2026-10-26T17:59:51.593Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 ## 快速上手 @@ -2563,6 +2563,34 @@ masonry({ minColumnWidth: 220, items: shots.map((s) => card({ title: s.title }, 现场演示:apps/docs/demo.html#sec-masonry +### editable — 行内编辑 Editable + +参数: + +- value(必填):Signal —— 展示值,双向绑定:只有提交才写回,Esc 取消绝不落值 +- placeholder(默认 '点击填写'):string —— 空值时的占位文案,颜色走辅助文字档 +- label(默认 '编辑'):string —— 读屏用的字段名,作静止态按钮 aria-label 的前缀(如「标题:季度目标」) +- multiline:boolean —— 多行模式(textarea):Enter 换行,Ctrl / Cmd + Enter 才提交;缺省单行 +- disabled:boolean | Signal —— 禁用只读,布尔与信号双口径;点了也不进编辑态 +- validate(回调):(v: string) => string | null —— 提交校验:返回文案即拒绝提交、保持编辑态并标红(aria-invalid + role=alert),返回 null 表示通过 +- onCommit(回调):(v: string) => void —— 提交回调,值已写回信号之后触发;取消不触发 + +示例: + +``` +const title = signal('季度目标'); editable({ value: title, label: '标题', validate: (v) => (v ? null : '标题不能为空'), onCommit: (v) => toast.success('已保存:' + v) }) +``` + +手把手: + +1. 传一个 Signal 作唯一数据源:组件只在提交时写回,取消路径一个字都不改,「改了没保存」的中间态不会污染业务状态 +2. 点文字进编辑态:Enter 提交、Esc 取消、点别处(失焦)提交;多行模式要 Ctrl / Cmd + Enter 才提交,Enter 留给换行——否则多行里连换行都写不了 +3. validate 返回文案时保留编辑态并标红:控件带 aria-invalid、下方 role=alert 出文案,与 form 的出错口径一致,用户改对再提交即可 +4. disabled 接受布尔或信号:禁用时静止态是灰字、点了不进编辑,适合权限只读的场景(要完全不给「可改」暗示就直接渲染文字) +5. 什么时候别用:长表单(用 form + textField,editable 没有 label 排版与必填标记)、需要撤销栈的富文本,以及值会被频繁外部改写的地方(编辑中不跟随外部更新,是有意的取舍) + +现场演示:apps/docs/demo.html#sec-editable + API 速览: - button(props):按钮,variant 三档视觉强度,disabled / loading 支持布尔或信号动态同步(loading 转圈并禁点),onClick 点击回调 @@ -2656,6 +2684,7 @@ API 速览: - noticeBar(props):单行通知条,语义色 + 左侧符号 + 可选跑马灯与关闭,适合页顶常驻播报 - sortableList(props):拖拽排序列表,整行可拖 + Alt+↑↓ 键盘搬位,返回 { el, move, order } 句柄 - masonry(props):瀑布流多列布局,列宽自适应或固定列数,卡片不被拆到两列 +- editable(props):行内编辑,静止态是文字、点一下就地变输入框,Enter 提交 / Esc 取消 / 失焦提交 一行用法: diff --git a/llms.txt b/llms.txt index 3bff8f7..37427e1 100644 --- a/llms.txt +++ b/llms.txt @@ -138,6 +138,7 @@ - noticeBar(props):单行通知条,语义色 + 左侧符号 + 可选跑马灯与关闭,适合页顶常驻播报 - sortableList(props):拖拽排序列表,整行可拖 + Alt+↑↓ 键盘搬位,返回 { el, move, order } 句柄 - masonry(props):瀑布流多列布局,列宽自适应或固定列数,卡片不被拆到两列 + - editable(props):行内编辑,静止态是文字、点一下就地变输入框,Enter 提交 / Esc 取消 / 失焦提交 - 用法: `const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')` - 约定: 皮肤不持有业务状态,一律委托 headless 内核或调用方信号 - 约定: 表单类组件统一约定:*Props.value/checked/active 为 Signal 双向绑定,onChange/onInput 为回调 diff --git a/packages/skin/ui/src/editable.ts b/packages/skin/ui/src/editable.ts new file mode 100644 index 0000000..3f320ba --- /dev/null +++ b/packages/skin/ui/src/editable.ts @@ -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; +} diff --git a/packages/skin/ui/src/index.ts b/packages/skin/ui/src/index.ts index 14b9b12..67411e7 100644 --- a/packages/skin/ui/src/index.ts +++ b/packages/skin/ui/src/index.ts @@ -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'; diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index 7fb7492..6d63b87 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -1158,3 +1158,14 @@ export interface MasonryProps { minColumnWidth?: number; // 自适应列宽下限(像素);缺省 240,容器越宽列数越多 gap?: string; // 列间距与行间距(CSS 长度或令牌值);缺省 var(--ua-space-3) } + +/*** 行内编辑入参 ***/ +export interface EditableProps { + value: Signal; // 展示值,双向绑定:只有提交才写回(Esc 取消绝不落值) + placeholder?: string; // 空值时的占位文案;缺省「点击填写」 + label?: string; // 读屏用的字段名,作按钮 aria-label 前缀;缺省「编辑」 + multiline?: boolean; // 多行模式(textarea):Enter 换行,Ctrl / Cmd + Enter 才提交 + disabled?: boolean | Signal; // 禁用只读;布尔与信号双口径,点了也不进编辑态 + validate?: (v: string) => string | null; // 提交校验:返回文案即拒绝提交、保持编辑态并标红;null 表示通过 + onCommit?: (v: string) => void; // 提交回调(值已写回信号之后触发) +} diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index c7aa0c7..72fce73 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -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", "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", "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 作唯一数据源:组件只在提交时写回,取消路径一个字都不改,「改了没保存」的中间态不会污染业务状态", + "点文字进编辑态:Enter 提交、Esc 取消、点别处(失焦)提交;多行模式要 Ctrl / Cmd + Enter 才提交,Enter 留给换行——否则多行里连换行都写不了", + "validate 返回文案时保留编辑态并标红:控件带 aria-invalid、下方 role=alert 出文案,与 form 的出错口径一致,用户改对再提交即可", + "disabled 接受布尔或信号:禁用时静止态是灰字、点了不进编辑,适合权限只读的场景(要完全不给「可改」暗示就直接渲染文字)", + "什么时候别用:长表单(用 form + textField,editable 没有 label 排版与必填标记)、需要撤销栈的富文本,以及值会被频繁外部改写的地方(编辑中不跟随外部更新,是有意的取舍)" + ] } ] } diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index 1d217c1..a869ca6 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -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); +}