iter(g244): 新增 inputTag 标签输入组件,回车成标签并支持满额禁输

This commit is contained in:
HIsheR
2026-10-26 21:57:52 +08:00
parent cd8a8e9b12
commit ea5149338a
12 changed files with 288 additions and 10 deletions
+1
View File
@@ -75,3 +75,4 @@ export { copyButton } from './copyButton.js';
export { countdown } from './countdown.js';
export { kbd } from './kbd.js';
export { highlight } from './highlight.js';
export { inputTag } from './inputTag.js';
+68
View File
@@ -0,0 +1,68 @@
import { effect, h, type Signal } from '@ui-arch/frame';
import type { InputTagProps } from './types.js';
/*** 作用:把布尔或信号口径的禁用态读成当前值(供渲染时统一判断,天然跟随信号) ***/
const off = (v: boolean | Signal<boolean> | undefined): boolean => (typeof v === 'function' ? v() : Boolean(v));
/*** 作用:标签输入——回车 / 逗号成标签、chip 逐个删、空框退格删末个、失焦自动入列;外框与 chip 复用现有类,零新增视觉字面量 ***/
export function inputTag(props: InputTagProps): HTMLElement {
const root = h('div', { class: 'ua-input-group ua-input-tag' }) as HTMLElement;
const hint = props.placeholder ?? '输入后回车添加'; // 未满时的占位文案
const input = h('input', { class: 'ua-input', placeholder: hint, 'aria-label': props.ariaLabel ?? '标签输入' }) as HTMLInputElement;
const limit = props.max ?? 0; // 0 = 不限条数
const maxLength = props.maxLength ?? 24; // 单个标签超长直接截断,防止一个标签撑爆整行
const allowDuplicate = props.allowDuplicate ?? false;
root.append(input);
/*** 作用:写回标签集合(单一出口:信号与 onChange 永远同源,避免两处各写一次) ***/
const put = (next: string[]): void => {
props.value(next);
props.onChange?.(next);
};
/*** 作用:追加一个标签:截断超长、丢弃重复与超限,并把输入框清空 ***/
const commit = (raw: string): void => {
const text = raw.trim().slice(0, maxLength);
input.value = '';
if (!text) return;
const list = props.value();
if (limit > 0 && list.length >= limit) return; // 已达上限:静默丢弃,输入框已清空
if (!allowDuplicate && list.includes(text)) return; // 重复标签:不重复入列
put([...list, text]);
};
/*** 作用:按信号重画 chip 与输入框状态(chip 全量重建,input 始终留在末尾) ***/
const render = (): void => {
const list = props.value();
root.querySelectorAll('.ua-tag').forEach((node) => node.remove()); // 先清后建:条数少,重建比 diff 稳
for (const tag of list) {
const close = h('button', {
type: 'button',
class: 'ua-tag-close',
'aria-label': `移除标签 ${tag}`,
onClick: () => put(props.value().filter((item) => item !== tag)), // 按值删,不用索引(外部可能已改序)
}, '×');
root.insertBefore(h('span', { class: 'ua-tag' }, h('span', {}, tag), close), input);
}
const full = limit > 0 && list.length >= limit;
input.disabled = off(props.disabled) || full; // 满额即禁输:占位文案同时改成「已达上限」
input.placeholder = full ? `最多 ${limit} 个标签` : hint;
};
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ',' || event.key === ',') {
event.preventDefault(); // 回车不提交外层表单;逗号不留进输入值
commit(input.value);
return;
}
if (event.key === 'Backspace' && input.value === '') {
const list = props.value();
if (list.length) put(list.slice(0, -1)); // 空框退格:删掉最后一个 chip
}
});
input.addEventListener('blur', () => commit(input.value)); // 失焦即入列:手输后忘了回车也不会丢
root.addEventListener('click', () => input.focus()); // 点外框任意位置都聚焦输入框
effect(render);
return root;
}
+12
View File
@@ -985,3 +985,15 @@ export interface CommandPaletteHandle {
open: () => void; // 打开:清空搜索词并聚焦输入框
close: () => void; // 关闭:归还打开前焦点
}
/*** 标签输入入参 ***/
export interface InputTagProps {
value: Signal<string[]>; // 已选标签集合,双向绑定(成标签 / 删 chip 都写回)
placeholder?: string; // 未满时的占位文案;缺省「输入后回车添加」
max?: number; // 最多允许的标签条数;缺省 0 表示不限
maxLength?: number; // 单个标签最大字符数;缺省 24(超出截断)
allowDuplicate?: boolean; // 是否允许重复标签;缺省 false
disabled?: boolean | Signal<boolean>; // 禁用态:布尔或信号,满额时也会禁用输入
ariaLabel?: string; // 输入框无障碍标签;缺省「标签输入」
onChange?: (tags: string[]) => void; // 标签集合变化回调(参数是变化后的完整集合)
}
+65 -1
View File
@@ -89,7 +89,8 @@
"copyButton(props):复制按钮,点击把 text 写入剪贴板并短暂显示已复制,text 可传函数取实时值,clipboard API 不可用时降级 execCommand",
"countdown(props):倒计时,秒级递减到 0 自动停表并触发 onFinish,缺省 mm:ss 展示,返回句柄 start/stop/reset",
"kbd(props):键盘按键标记,键帽样式展示单个按键或组合键(多键以 + 连接),用于快捷键提示",
"highlight(props):关键词高亮,命中片段包 mark(大小写不敏感全部命中),text / keyword 支持信号响应式刷新"
"highlight(props):关键词高亮,命中片段包 mark(大小写不敏感全部命中),text / keyword 支持信号响应式刷新",
"inputTag(props):标签输入,输入后回车 / 逗号成标签、chip 可单个删除、空框退格删末个,value 信号双向绑定,max 满额自动禁输"
],
"keyTypes": [
"ButtonProps",
@@ -3898,6 +3899,69 @@
"两个入参都接受信号:搜索框输入实时联动高亮,无需手动重渲染",
"大小写不敏感且全部命中(非只第一处);关键词两侧空白自动忽略"
]
},
{
"id": "inputTag",
"title": "标签输入 InputTag",
"demo": "sec-inputtag",
"props": [
{
"n": "value",
"t": "Signal<string[]>",
"r": 1,
"d": "已选标签集合,双向绑定:成标签与删 chip 都写回信号"
},
{
"n": "placeholder",
"t": "string",
"r": 0,
"d": "未满时的占位文案;缺省「输入后回车添加」"
},
{
"n": "max",
"t": "number",
"r": 0,
"d": "最多允许的标签条数;缺省 0 表示不限,满额后输入框禁用并提示上限"
},
{
"n": "maxLength",
"t": "number",
"r": 0,
"d": "单个标签最大字符数;缺省 24,超出直接截断"
},
{
"n": "allowDuplicate",
"t": "boolean",
"r": 0,
"d": "是否允许重复标签;缺省 false(重复的静默丢弃)"
},
{
"n": "disabled",
"t": "boolean | Signal<boolean>",
"r": 0,
"d": "禁用态:布尔或信号,true 时不可输入也不可删"
},
{
"n": "ariaLabel",
"t": "string",
"r": 0,
"d": "输入框无障碍标签;缺省「标签输入」"
},
{
"n": "onChange",
"t": "(tags: string[]) => void",
"r": 0,
"d": "标签集合变化回调,参数是变化后的完整集合"
}
],
"example": "const tags = signal(['前端', '设计']);\ninputTag({ value: tags, max: 5, onChange: (list) => console.log(list.length) })",
"steps": [
"传一个 signal<string[]> 作为 value:回车或逗号把当前输入转成 chip,写回信号后立即重画",
"点 chip 上的 × 删除单个标签;输入框为空时按退格键删掉最后一个(手不离键盘也能改)",
"输入后直接失焦也会成标签,不必强行回车;max 满额后输入框禁用,占位文案变成「最多 N 个标签」",
"要区分大小写重复判断就传 allowDuplicate: true;默认按原样去重",
"样式复用输入组与标签:外框聚焦光环、chip 圆角与语义色都跟既有组件一致,无新增视觉字面量"
]
}
]
}
+24
View File
@@ -5259,3 +5259,27 @@ h5.ua-type-title { font-size: var(--ua-font-size-md); }
animation: none; /* 降动效:转圈静态化为实色环 */
}
}
/* ---- 标签输入:外框复用输入组(边框 / 聚焦光环),chip 复用 .ua-tag,这里只排布 ----
多标签必须换行:单行 flex 会把 chip 挤成省略号,用户看不出已选几个 */
.ua-input-tag {
display: flex;
flex-wrap: wrap;
gap: var(--ua-space-1);
padding: var(--ua-space-1) var(--ua-space-2);
min-height: var(--ua-control-h-md);
cursor: text; /* 点框内任意处都能输入 */
}
.ua-input-tag .ua-input {
flex: 1 1 auto;
min-width: 0; /* 允许收缩:否则长 chip 会把输入框顶出容器 */
padding: var(--ua-space-05) 0; /* 纵向内距交给外框,输入框自带内距会让 chip 与文字错行 */
height: auto;
}
.ua-input-tag .ua-input:disabled {
background: transparent; /* 满额 / 禁用时外框仍显示 chip,内层不叠一层灰底 */
}
.ua-input-tag .ua-tag {
max-width: 100%;
overflow-wrap: anywhere;
}