-
Notifications
You must be signed in to change notification settings - Fork 385
Expand file tree
/
Copy pathpairing-code-input.tsx
More file actions
156 lines (146 loc) · 5.49 KB
/
Copy pathpairing-code-input.tsx
File metadata and controls
156 lines (146 loc) · 5.49 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
import { useRef, type ClipboardEvent, type KeyboardEvent } from "react";
import { cn } from "@App/pkg/utils/cn";
export interface PairingCodeInputProps {
/** Current code, concatenated without the visual separator (e.g. "3F9K7Q2A"). */
value: string;
onChange: (value: string) => void;
/** Total number of cells / code characters. */
length?: number;
/** Insert a dash separator after every `groupSize` cells (0 = no separators). */
groupSize?: number;
/** Fired once the last cell is filled (value reaches `length`). */
onComplete?: (value: string) => void;
autoFocus?: boolean;
disabled?: boolean;
className?: string;
"aria-label": string;
"data-testid"?: string;
}
// Only the accepted charset survives; the daemon derives the pairing key from a normalized form
// (offscreen/external-access-connect.ts:normalizePairingCode maps O→0, I/L→1), so we keep display
// permissive here — uppercased alphanumerics — and let that boundary normalize.
const sanitize = (raw: string) => raw.replace(/[^0-9a-zA-Z]/g, "").toUpperCase();
/**
* Segmented pairing-code input (OTP style): N single-character cells with an optional dash between
* groups, matching the 接入 sctl 对话框 design (8 cells, 4-4 split). Typing advances focus, Backspace
* retreats, arrow keys navigate, and pasting a full code fills every cell at once. The dash is
* purely visual — `value`/`onChange` carry the concatenated characters only.
*/
export function PairingCodeInput({
value,
onChange,
length = 8,
groupSize = 4,
onComplete,
autoFocus,
disabled,
className,
"aria-label": ariaLabel,
"data-testid": testId,
}: PairingCodeInputProps) {
const refs = useRef<(HTMLInputElement | null)[]>([]);
const chars = value.slice(0, length).padEnd(length, " ").split("");
const focusCell = (index: number) => {
const clamped = Math.max(0, Math.min(length - 1, index));
refs.current[clamped]?.focus();
refs.current[clamped]?.select();
};
const commit = (next: string) => {
const trimmed = next.replace(/\s+$/g, "");
onChange(trimmed);
if (trimmed.length === length) onComplete?.(trimmed);
};
const setCharAt = (index: number, char: string) => {
const arr = value.slice(0, length).padEnd(length, " ").split("");
arr[index] = char || " ";
return arr.join("").replace(/\s+$/g, "");
};
const handleChange = (index: number, raw: string) => {
const cleaned = sanitize(raw);
if (!cleaned) return;
// Typing over a filled cell, or a browser that batches keystrokes, can hand us several chars —
// spread them across this cell and the ones after it (same effect as a short paste).
const next = value.slice(0, length).padEnd(length, " ").split("");
let cursor = index;
for (const ch of cleaned) {
if (cursor >= length) break;
next[cursor] = ch;
cursor += 1;
}
commit(next.join("").replace(/\s+$/g, ""));
focusCell(cursor);
};
const handleKeyDown = (index: number, e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Backspace") {
e.preventDefault();
if (chars[index].trim()) {
commit(setCharAt(index, ""));
} else if (index > 0) {
commit(setCharAt(index - 1, ""));
focusCell(index - 1);
}
} else if (e.key === "ArrowLeft") {
e.preventDefault();
focusCell(index - 1);
} else if (e.key === "ArrowRight") {
e.preventDefault();
focusCell(index + 1);
}
};
const handlePaste = (index: number, e: ClipboardEvent<HTMLInputElement>) => {
e.preventDefault();
const cleaned = sanitize(e.clipboardData.getData("text"));
if (!cleaned) return;
const arr = value.slice(0, length).padEnd(length, " ").split("");
let cursor = index;
for (const ch of cleaned) {
if (cursor >= length) break;
arr[cursor] = ch;
cursor += 1;
}
commit(arr.join("").replace(/\s+$/g, ""));
focusCell(cursor);
};
return (
<div
data-slot="pairing-code-input"
data-testid={testId}
role="group"
aria-label={ariaLabel}
className={cn("flex items-center justify-center gap-2", className)}
>
{chars.map((char, index) => (
<div key={index} className="flex items-center gap-2">
{groupSize > 0 && index > 0 && index % groupSize === 0 && (
<span aria-hidden className="h-0.5 w-3 rounded-full bg-muted-foreground/50" />
)}
<input
ref={(el) => {
refs.current[index] = el;
}}
data-testid={testId ? `${testId}-cell-${index}` : undefined}
type="text"
inputMode="text"
autoCapitalize="characters"
autoComplete="off"
spellCheck={false}
maxLength={1}
disabled={disabled}
autoFocus={autoFocus && index === 0}
aria-label={`${ariaLabel} ${index + 1}`}
value={char.trim()}
onChange={(e) => handleChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
onPaste={(e) => handlePaste(index, e)}
onFocus={(e) => e.target.select()}
className={cn(
"size-10 rounded-md border border-input bg-background text-center font-mono text-lg font-semibold uppercase text-foreground shadow-sm outline-none transition-colors",
"focus-visible:border-primary focus-visible:ring-1 focus-visible:ring-primary",
"disabled:cursor-not-allowed disabled:opacity-50"
)}
/>
</div>
))}
</div>
);
}