iter(g234): 新增 pinInput 验证码输入组件,N 格数字输入支持进格回删移格与粘贴分发(71 组件)

This commit is contained in:
HIsheR
2026-10-26 21:22:57 +08:00
parent 45e56d06bb
commit 8740ab02a9
12 changed files with 232 additions and 10 deletions
+1
View File
@@ -70,3 +70,4 @@ export { avatarGroup } from './avatarGroup.js';
export { virtualList } from './virtualList.js';
export { commandPalette } from './commandPalette.js';
export { timePicker } from './timePicker.js';
export { pinInput } from './pinInput.js';
+62
View File
@@ -0,0 +1,62 @@
import { effect, h } from '@ui-arch/frame';
import type { PinInputProps } from './types.js';
/*** 验证码输入:N 格单字符数字输入,输入自动进格、退格回删、方向键移格、粘贴自动分发,value 信号双向绑定 ***/
export function pinInput(props: PinInputProps): HTMLDivElement {
const length = props.length ?? 6; // 格数缺省 6
const sanitize = (s: string): string => String(s).replace(/\D/g, '').slice(0, length); // 只留数字并截到格数
const inputs: HTMLInputElement[] = []; // 格子引用(格子建一次不重渲染,焦点天然不丢)
const focusAt = (i: number): void => inputs[Math.max(0, Math.min(length - 1, i))]?.focus(); // 目标格钳制到首尾
/*** 写回单一来源:拼接各格内容赋回外部信号(同值短路防 effect 环),并回调当前拼接串 ***/
const commit = (): void => {
const next = inputs.map((el) => el.value).join('');
if (props.value() !== next) props.value(next);
if (props.onChange) props.onChange(next);
};
/*** 单格构造:输入进格 / 退格回删 / 方向键移格 / 粘贴分发,非数字键一律拦下 ***/
const cell = (i: number): HTMLInputElement => h('input', {
class: 'ua-input ua-pin-cell',
type: props.mask ? 'password' : 'text',
inputmode: 'numeric',
autocomplete: props.mask ? 'one-time-code' : 'off',
maxlength: 1,
'aria-label': `第 ${i + 1} 位`,
onFocus: () => inputs[i].select(), // 聚焦全选:直接敲键即覆盖旧值
onInput: () => {
inputs[i].value = sanitize(inputs[i].value).slice(-1); // 单格只留最后敲入的一位
if (inputs[i].value && i < length - 1) focusAt(i + 1); // 敲完进下一格(末格不进)
commit();
},
onKeydown: (e: KeyboardEvent) => {
if (e.key === 'Backspace') { e.preventDefault(); if (inputs[i].value) { inputs[i].value = ''; } else if (i > 0) { inputs[i - 1].value = ''; focusAt(i - 1); } commit(); return; } // 本格有值删本格,空格回删上一格并左移
if (e.key === 'ArrowLeft') { e.preventDefault(); focusAt(i - 1); return; }
if (e.key === 'ArrowRight') { e.preventDefault(); focusAt(i + 1); return; }
if (e.key.length === 1 && !/\d/.test(e.key) && !e.ctrlKey && !e.metaKey) e.preventDefault(); // 只放行数字与控制键(粘贴由 onPaste 接管)
},
onPaste: (e: ClipboardEvent) => {
const digits = sanitize(e.clipboardData?.getData('text') ?? '');
if (!digits) return;
e.preventDefault();
const from = digits.length >= length ? 0 : i; // 整段验证码从首格铺起,零散片段从当前格续
for (let k = 0; k < digits.length && from + k < length; k++) inputs[from + k].value = digits[k]; // 逐格分发
focusAt(Math.min(length - 1, from + digits.length)); // 落到末个空格,接着手输也顺手
commit();
},
}) as HTMLInputElement;
const root = h('div', { class: 'ua-pin-input', role: 'group' }, ...Array.from({ length }, (_, i) => cell(i))) as HTMLDivElement;
root.querySelectorAll('input').forEach((el) => inputs.push(el as HTMLInputElement));
effect(() => { // 外部信号 → 格子回写(同值不写避免打断输入法),禁用态支持布尔或信号
const v = sanitize(props.value());
const dis = typeof props.disabled === 'function' ? props.disabled() : !!props.disabled;
inputs.forEach((el, k) => {
const ch = v[k] ?? '';
if (el.value !== ch) el.value = ch;
el.disabled = dis;
});
});
return root;
}
+9
View File
@@ -245,6 +245,15 @@ export interface RateProps {
onChange?: (value: number) => void; // 评分变化回调
}
/*** 验证码输入入参 ***/
export interface PinInputProps {
value: Signal<string>; // 当前验证码(数字拼接串),双向绑定(输入写回、外部赋值同步)
length?: number; // 格数;缺省 6
mask?: boolean; // 密文显示(type=password + one-time-code 自动填充);缺省明文
disabled?: boolean | Signal<boolean>; // 禁用态:布尔直接置位,传信号则动态同步
onChange?: (value: string) => void; // 任一格变化回调(参数为当前已输入拼接串)
}
/*** 空状态入参 ***/
export interface EmptyProps {
title?: string; // 主标题;缺省不渲染
+45 -1
View File
@@ -84,7 +84,8 @@
"avatarGroup(props):头像组,横向叠放并在超出 max 时折叠成 +N,整组 title 列出全部姓名",
"virtualList(props):虚拟列表,固定行高长列表只渲染可视窗口 + 上下缓冲条,万级数据滚动不随总量掉帧;返回 { el, scrollTo } 句柄",
"commandPalette(props):命令面板,⌘K / Ctrl+K 唤起,输入即过滤(主文案/说明/关键词三层),↑↓ 环形选择 + 回车执行 + Esc 收起,选中与关闭都归还焦点;返回 { el, open, close } 句柄",
"timePicker(props):时间选择,时 / 分两列弹层,value 信号双向绑定 HH:mm,分钟可配步长,Esc 收起"
"timePicker(props):时间选择,时 / 分两列弹层,value 信号双向绑定 HH:mm,分钟可配步长,Esc 收起",
"pinInput(props):验证码输入,N 格单字符数字输入,输入自动进格、退格回删、方向键移格、粘贴自动分发,value 信号双向绑定,mask 可密文显示"
],
"keyTypes": [
"ButtonProps",
@@ -3708,6 +3709,49 @@
"收起态键盘微调:聚焦触发器按 ↑↓ 按步长加减(12:30 步长 15 → 12:45),Shift + ↑↓ 按小时加减,跨 00:00 / 23:xx 自动环形;改完同样触发 onChange",
"打开弹层后焦点直接落在时列当前格(键盘用户不必再 Tab 找):列内 ↑↓ 立即生效,Esc 收起后焦点回到触发器"
]
},
{
"id": "pinInput",
"title": "验证码输入 PinInput",
"demo": "sec-pininput",
"props": [
{
"n": "value",
"t": "Signal<string>",
"r": 1,
"d": "当前验证码(数字拼接串)信号,双向绑定:格子输入写回、外部赋值同步回格子"
},
{
"n": "length",
"t": "number",
"df": "6",
"d": "格数;缺省 6"
},
{
"n": "mask",
"t": "boolean",
"df": "false",
"d": "密文显示(type=password,并声明 one-time-code 便于短信验证码自动填充);缺省明文"
},
{
"n": "disabled",
"t": "boolean | Signal<boolean>",
"d": "禁用态:布尔直接置位,传信号则动态同步"
},
{
"n": "onChange",
"t": "(value: string) => void",
"k": 1,
"d": "任一格变化回调(参数为当前已输入拼接串)"
}
],
"example": "const code = signal(''); pinInput({ value: code, length: 6, onChange: (v) => console.log(v) })",
"steps": [
"用 signal('') 创建验证码信号并传入 value;拼接串随输入实时更新,长度满格即验证码就绪",
"输入一位自动进下一格;退格在本格为空时回删上一格;←/→ 在格间移动;聚焦即全选,直接敲键覆盖",
"粘贴会自动分发:整段验证码(位数 ≥ 格数)从首格铺起,零散片段从当前格续填",
"mask: true 走密文显示并声明 one-time-code,短信验证码可自动填充;disabled 支持布尔或信号禁用"
]
}
]
}
+14
View File
@@ -5183,3 +5183,17 @@ h5.ua-type-title { font-size: var(--ua-font-size-md); }
padding-left: var(--ua-space-1);
border-left: 1px solid var(--ua-border-subtle); /* 时分分界:一条 1px 细线,比留白更能说明这是两列 */
}
/* ---- 验证码输入:N 格单字符数字输入(pinInput) ---- */
.ua-pin-input {
display: inline-flex;
gap: var(--ua-space-2);
}
.ua-pin-input .ua-input {
min-width: 0;
width: var(--ua-control-h-lg); /* 方格:宽高同档,正方形观感 */
padding: 0;
text-align: center;
font-variant-numeric: tabular-nums; /* 数字等宽:换位不左右窜 */
caret-color: var(--ua-color-accent);
}