Skip to content

Commit 255f35b

Browse files
SOIVclaude
andcommitted
feat(controls): PinInput 컴포넌트 추가 및 OtpInput padEnd 버그 수정
- PinInput: 직사각형 박스 + 브라우저 native 점 표시 방식으로 구현 - OtpInput/PinInput: padEnd('') 로 digits 배열이 빈 배열이 되는 버그 수정 (Array.from 방식으로 교체) - AdminPinModal: PinInput 컴포넌트로 교체, 텍스트-입력 간격 및 박스 크기 조정 - controls index: pin-input 레지스트리 등록 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7762851 commit 255f35b

8 files changed

Lines changed: 1816 additions & 34 deletions

File tree

apps/web/src/components/AdminPinModal.tsx

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { useEffect, useRef, useState, type FormEvent } from "react";
1+
import { useEffect, useState, type FormEvent } from "react";
22

3-
import { Button, Input, Modal } from "@fieldstack/controls";
3+
import { Button, Modal, PinInput } from "@fieldstack/controls";
44

55
// 개발 mock PIN — 실제 구현 시 API 검증으로 교체
66
const MOCK_ADMIN_PIN = "1234";
@@ -18,7 +18,6 @@ export function AdminPinModal({ onVerified, onClose }: AdminPinModalProps) {
1818
const [attempts, setAttempts] = useState(0);
1919
const [lockedUntil, setLockedUntil] = useState<number | null>(null);
2020
const [remaining, setRemaining] = useState(0);
21-
const inputRef = useRef<HTMLInputElement>(null);
2221

2322
// 잠금 카운트다운
2423
useEffect(() => {
@@ -30,7 +29,6 @@ export function AdminPinModal({ onVerified, onClose }: AdminPinModalProps) {
3029
setAttempts(0);
3130
setError("");
3231
setRemaining(0);
33-
inputRef.current?.focus();
3432
} else {
3533
setRemaining(left);
3634
}
@@ -40,11 +38,6 @@ export function AdminPinModal({ onVerified, onClose }: AdminPinModalProps) {
4038
return () => clearInterval(id);
4139
}, [lockedUntil]);
4240

43-
// 모달 열릴 때 포커스
44-
useEffect(() => {
45-
inputRef.current?.focus();
46-
}, []);
47-
4841
const isLocked = lockedUntil !== null;
4942

5043
const handleSubmit = (e: FormEvent) => {
@@ -66,7 +59,6 @@ export function AdminPinModal({ onVerified, onClose }: AdminPinModalProps) {
6659
setError(`PIN ${MAX_ATTEMPTS}회 오류 — 5분간 잠금`);
6760
} else {
6861
setError(`PIN이 올바르지 않습니다. (${next}/${MAX_ATTEMPTS})`);
69-
inputRef.current?.focus();
7062
}
7163
};
7264

@@ -91,21 +83,14 @@ export function AdminPinModal({ onVerified, onClose }: AdminPinModalProps) {
9183
</div>
9284

9385
<form id="pin-form" onSubmit={handleSubmit} noValidate>
94-
<Input
95-
ref={inputRef}
96-
type="password"
97-
inputMode="numeric"
98-
pattern="[0-9]*"
99-
maxLength={6}
100-
placeholder="••••"
86+
<PinInput
87+
length={4}
10188
value={pin}
102-
onChange={(e) => {
103-
setPin(e.target.value.replace(/\D/g, ""));
89+
onChange={(val: string) => {
90+
setPin(val);
10491
if (!isLocked) setError("");
10592
}}
10693
disabled={isLocked}
107-
autoComplete="off"
108-
aria-label="관리자 PIN"
10994
error={error ? (isLocked ? `${error}${remaining}초 후 재시도 가능` : error) : undefined}
11095
/>
11196
</form>

apps/web/src/styles/admin.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@
257257
text-align: center;
258258
display: grid;
259259
gap: 6px;
260+
margin-bottom: 20px;
260261
}
261262

262263
.pin-modal-icon {

packages/controls/src/components/OtpInput.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function OtpInput({
1818
className = '',
1919
}: OtpInputProps) {
2020
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
21-
const digits = value.padEnd(length, '').slice(0, length).split('');
21+
const digits = Array.from({ length }, (_, i) => value[i] ?? '');
2222

2323
const update = (index: number, char: string) => {
2424
const next = digits.map((d, i) => (i === index ? char : d)).join('').trimEnd();
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { useRef } from 'react';
2+
3+
export interface PinInputProps {
4+
length?: 4 | 6;
5+
value: string;
6+
onChange: (value: string) => void;
7+
error?: string;
8+
disabled?: boolean;
9+
className?: string;
10+
}
11+
12+
export function PinInput({
13+
length = 4,
14+
value,
15+
onChange,
16+
error,
17+
disabled = false,
18+
className = '',
19+
}: PinInputProps) {
20+
const inputRef = useRef<HTMLInputElement>(null);
21+
22+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
23+
const sanitized = e.target.value.replace(/\D/g, '').slice(0, length);
24+
onChange(sanitized);
25+
};
26+
27+
return (
28+
<div className={`fs-pin-wrap ${className}`}>
29+
<input
30+
ref={inputRef}
31+
type="password"
32+
inputMode="numeric"
33+
className={['fs-pin-input', error ? 'fs-pin-input-error' : ''].filter(Boolean).join(' ')}
34+
value={value}
35+
onChange={handleChange}
36+
maxLength={length}
37+
disabled={disabled}
38+
autoComplete="off"
39+
aria-label="PIN 입력"
40+
aria-invalid={error ? true : undefined}
41+
/>
42+
{error && (
43+
<p className="fs-pin-error" role="alert">{error}</p>
44+
)}
45+
</div>
46+
);
47+
}

packages/controls/src/components/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ export type { PasswordInputProps, PasswordStrength } from './PasswordInput.js';
3939
export { OtpInput } from './OtpInput.js';
4040
export type { OtpInputProps } from './OtpInput.js';
4141

42+
export { PinInput } from './PinInput.js';
43+
export type { PinInputProps } from './PinInput.js';
44+
4245
export { SearchInput } from './SearchInput.js';
4346
export type { SearchInputProps } from './SearchInput.js';
4447

@@ -66,7 +69,8 @@ export type ControlName =
6669
| 'form-field'
6770
| 'input'
6871
| 'modal'
69-
| 'otp-pin-input'
72+
| 'otp-input'
73+
| 'pin-input'
7074
| 'password-input'
7175
| 'progress'
7276
| 'radio'
@@ -98,7 +102,8 @@ export const CONTROL_DESCRIPTORS: ControlDescriptor[] = [
98102
{ name: 'progress', tier: 'p0', ready: true },
99103
{ name: 'textarea', tier: 'p0_5', ready: true },
100104
{ name: 'password-input', tier: 'p0_5', ready: true },
101-
{ name: 'otp-pin-input', tier: 'p0_5', ready: true },
105+
{ name: 'otp-input', tier: 'p0_5', ready: true },
106+
{ name: 'pin-input', tier: 'p0_5', ready: true },
102107
{ name: 'search-input', tier: 'p0_5', ready: true },
103108
{ name: 'spinner', tier: 'p0_5', ready: true },
104109
{ name: 'toast', tier: 'p0_5', ready: true },
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useState } from 'react';
2+
import type { Meta, StoryObj } from '@storybook/react';
3+
import { PinInput } from '../components/PinInput.js';
4+
5+
const meta: Meta = { title: 'Controls/PinInput' };
6+
export default meta;
7+
8+
export const FourDigit: StoryObj = {
9+
render: () => {
10+
const [value, setValue] = useState('');
11+
const handleChange = (v: string) => {
12+
setValue(v);
13+
if (v.length === 4) alert(`PIN 입력 완료: ${v}`);
14+
};
15+
return <PinInput length={4} value={value} onChange={handleChange} />;
16+
},
17+
};
18+
19+
export const SixDigit: StoryObj = {
20+
render: () => {
21+
const [value, setValue] = useState('');
22+
const handleChange = (v: string) => {
23+
setValue(v);
24+
if (v.length === 6) alert(`PIN 입력 완료: ${v}`);
25+
};
26+
return <PinInput length={6} value={value} onChange={handleChange} />;
27+
},
28+
};
29+
30+
export const WithError: StoryObj = {
31+
render: () => {
32+
const [value, setValue] = useState('');
33+
return <PinInput length={4} value={value} onChange={setValue} error="PIN이 올바르지 않습니다. (1/5)" />;
34+
},
35+
};
36+
37+
export const Disabled: StoryObj = {
38+
render: () => {
39+
return <PinInput length={4} value="12" onChange={() => undefined} disabled />;
40+
},
41+
};

packages/controls/src/styles/controls.css

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,3 +841,44 @@
841841
border-color: var(--err);
842842
box-shadow: 0 0 0 3px color-mix(in srgb, var(--err) 20%, transparent);
843843
}
844+
845+
/* ═══════════════════════════════════════════════════════════════
846+
PIN INPUT
847+
═══════════════════════════════════════════════════════════════ */
848+
.fs-pin-wrap {
849+
display: flex;
850+
flex-direction: column;
851+
gap: 6px;
852+
}
853+
.fs-pin-input {
854+
width: 100%;
855+
height: 44px;
856+
border-radius: 8px;
857+
border: 1px solid var(--border);
858+
background: var(--bg-elevated);
859+
color: var(--text);
860+
font-size: 22px;
861+
text-align: center;
862+
letter-spacing: 8px;
863+
padding: 0 14px;
864+
font-family: inherit;
865+
transition: border-color 110ms ease, box-shadow 110ms ease;
866+
}
867+
.fs-pin-input:focus {
868+
outline: none;
869+
border-color: var(--accent);
870+
box-shadow: 0 0 0 3px var(--focus-ring);
871+
}
872+
.fs-pin-input:disabled {
873+
opacity: 0.45;
874+
cursor: not-allowed;
875+
}
876+
.fs-pin-input-error {
877+
border-color: var(--err);
878+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--err) 20%, transparent);
879+
}
880+
.fs-pin-error {
881+
font-size: 12px;
882+
color: var(--err);
883+
margin: 0;
884+
}

0 commit comments

Comments
 (0)