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; // 标签集合变化回调(参数是变化后的完整集合)
}