주민등록번호·카드번호처럼 형식이 정해진 값을 여러 구간으로 나눠 입력받는 컴포넌트입니다.
import "./styles" ;
import { ResidentNumberField } from "@/components/ui/resident-number-field" ;
export default function Example () {
return (
< view className = "segmented-text-input-preview" >
< ResidentNumberField label = "주민등록번호" />
</ view >
);
}
주민등록번호 뒷자리 첫 번째 자리만 받거나 카드번호 뒷자리를 가려야 하는 경우처럼, 하나의 Text Input으로 처리하기 어려운 국내 입력 형식에 사용합니다. 전화번호·이메일, 해외 카드번호처럼 구간을 나눌 필요가 없는 입력은 Text Field 에 포맷을 적용해서 받으세요.
Segment마다 native 입력 하나를 두고, 값은 입력 Segment 순서대로 string[]로 다룹니다.
끝에서 입력해 Segment를 채우면 다음 Segment로 이동합니다. 자리 수를 넘는 입력은 반영하지 않습니다. 기본 type("number")에서는 숫자 외 문자도 반영하지 않습니다.
가린(masked) Segment는 입력한 글자 대신 자리 수만큼 점을 그립니다. 가린 값 안에서는 caret을 옮기지 않고 끝에서만 입력·삭제합니다.
붙여넣은 값은 구분자를 지우고 다음 Segment까지 차례로 채웁니다. 가린(masked)·readOnly Segment 앞에서 멈춥니다.
autoComplete="bday" Segment는 붙여넣은 생년월일(yyyy-MM-dd 등)을 자리 수에 맞춰 바꿉니다.
한글 조합 중에는 다음 Segment로 이동하지 않습니다.
주민등록번호와 카드번호는 snippet으로 제공합니다. 자리 구성과 문구(accessibility-label, placeholder)는 snippet에 있으니 필요하면 설치한 파일에서 고쳐 쓰세요.
npx @seed-design/cli@latest add ui:resident-number-field pnpm dlx @seed-design/cli@latest add ui:resident-number-field yarn dlx @seed-design/cli@latest add ui:resident-number-field bun x @seed-design/cli@latest add ui:resident-number-field
의존성 설치
npm install @seed-design/lynx-react yarn add @seed-design/lynx-react pnpm add @seed-design/lynx-react bun add @seed-design/lynx-react 아래 코드를 복사 후 붙여넣고 사용하세요 /**
* @file ui:resident-number-field
* @requires @seed-design/lynx-react@^1.1.0
* @requires @seed-design/lynx-css@^1.1.0
**/
import * as React from "@lynx-js/react" ;
import {
Field as SeedField,
SegmentedTextInput as SeedSegmentedTextInput,
} from "@seed-design/lynx-react" ;
type SegmentedTextInputRootRef = React . ComponentRef < typeof SeedSegmentedTextInput.Root>;
type FieldRootRef = React . ComponentRef < typeof SeedField.Root>;
export interface ResidentNumberInputProps
extends Omit < SeedSegmentedTextInput . RootProps , "children" > {
/**
* 입력받을 자리 수.
* - `6`: 생년월일 6자리만 입력받고 뒷자리는 점으로 표시합니다.
* - `7`: 생년월일 6자리와 뒷자리 첫 번째 자리를 입력받습니다.
* - `13`: 생년월일 6자리와 뒷자리 7자리를 입력받습니다. 뒷자리는 가려서 표시합니다.
* @default 7
*/
digits ?: 6 | 7 | 13 ;
}
/**
* 값은 입력 Segment 순서대로 `string[]`입니다. `digits=6`이면 `[생년월일]`, 그 밖에는 `[생년월일, 뒷자리]`입니다.
*/
export const ResidentNumberInput = React.forwardRef <
SegmentedTextInputRootRef,
ResidentNumberInputProps
> (({ digits = 7 , ... otherProps }, ref ) => {
return (
< SeedSegmentedTextInput.Root ref = {ref} { ... otherProps}>
< SeedSegmentedTextInput.Segment
accessibility-label = "생년월일 6자리"
placeholder = "생년월일 6자리"
length = { 6 }
autoComplete = "bday"
sizing = "fill"
/>
< SeedSegmentedTextInput.Separator > - </ SeedSegmentedTextInput.Separator >
{digits === 6 ? (
< SeedSegmentedTextInput.Segment
staticText = {< SeedSegmentedTextInput.Dots count = { 7 } />}
sizing = "fill"
align = "center"
/>
) : null }
{digits === 7 ? (
< SeedSegmentedTextInput.Segment
accessibility-label = "주민등록번호 뒷자리 첫 번째 자리"
placeholder = {< SeedSegmentedTextInput.Dots count = { 1 } />}
length = { 1 }
staticSuffix = {< SeedSegmentedTextInput.Dots count = { 6 } />}
sizing = "fill"
align = "center"
/>
) : null }
{digits === 13 ? (
< SeedSegmentedTextInput.Segment
accessibility-label = "주민등록번호 뒷자리 7자리"
length = { 7 }
masked
sizing = "fill"
align = "center"
/>
) : null }
</ SeedSegmentedTextInput.Root >
);
});
ResidentNumberInput.displayName = "ResidentNumberInput" ;
export interface ResidentNumberFieldProps extends ResidentNumberInputProps {
label ?: React . ReactNode ;
labelWeight ?: SeedField . LabelProps [ "weight" ];
indicator ?: React . ReactNode ;
description ?: React . ReactNode ;
errorMessage ?: React . ReactNode ;
showRequiredIndicator ?: boolean ;
fieldRef ?: React . Ref < FieldRootRef >;
}
/**
* @see https://seed-design.io/lynx/components/segmented-text-input
*/
export const ResidentNumberField = React.forwardRef <
SegmentedTextInputRootRef,
ResidentNumberFieldProps
> (
(
{
label,
labelWeight,
indicator,
description,
errorMessage,
required,
disabled,
invalid,
readOnly,
showRequiredIndicator,
fieldRef,
... otherProps
},
ref,
) => {
const renderHeader = label != null || indicator != null ;
const renderDescription = description != null && ! (invalid && errorMessage != null );
const renderErrorMessage = invalid && errorMessage != null ;
const renderFooter = renderDescription || renderErrorMessage;
return (
< SeedField.Root
ref = {fieldRef}
required = {required}
disabled = {disabled}
invalid = {invalid}
readOnly = {readOnly}
>
{renderHeader ? (
< SeedField.Header >
< SeedField.Label weight = {labelWeight}>
{label}
{showRequiredIndicator ? < SeedField.RequiredIndicator /> : null }
{indicator != null ? (
< SeedField.IndicatorText >{indicator}</ SeedField.IndicatorText >
) : null }
</ SeedField.Label >
</ SeedField.Header >
) : null }
< ResidentNumberInput ref = {ref} { ... otherProps} />
{renderFooter ? (
< SeedField.Footer >
{renderDescription ? (
< SeedField.Description >{description}</ SeedField.Description >
) : null }
{renderErrorMessage ? (
< SeedField.ErrorMessage >{errorMessage}</ SeedField.ErrorMessage >
) : null }
</ SeedField.Footer >
) : null }
</ SeedField.Root >
);
},
);
ResidentNumberField.displayName = "ResidentNumberField" ;
/**
* This file is a snippet from SEED Design, helping you get started quickly with @seed-design/* packages.
* You can extend this snippet however you want.
*/
npx @seed-design/cli@latest add ui:card-number-field pnpm dlx @seed-design/cli@latest add ui:card-number-field yarn dlx @seed-design/cli@latest add ui:card-number-field bun x @seed-design/cli@latest add ui:card-number-field
의존성 설치
npm install @seed-design/lynx-react yarn add @seed-design/lynx-react pnpm add @seed-design/lynx-react bun add @seed-design/lynx-react 아래 코드를 복사 후 붙여넣고 사용하세요 /**
* @file ui:card-number-field
* @requires @seed-design/lynx-react@^1.1.0
* @requires @seed-design/lynx-css@^1.1.0
**/
import * as React from "@lynx-js/react" ;
import {
Field as SeedField,
SegmentedTextInput as SeedSegmentedTextInput,
} from "@seed-design/lynx-react" ;
type SegmentedTextInputRootRef = React . ComponentRef < typeof SeedSegmentedTextInput.Root>;
type FieldRootRef = React . ComponentRef < typeof SeedField.Root>;
const SEGMENT_LABELS = [
"카드번호 1-4번째 자리" ,
"카드번호 5-8번째 자리" ,
"카드번호 9-12번째 자리" ,
"카드번호 13-16번째 자리" ,
];
export interface CardNumberInputProps extends Omit < SeedSegmentedTextInput . RootProps , "children" > {
/**
* 가려서 입력받을 뒷자리 수. 가린 Segment는 붙여넣기로 채우지 않습니다.
* @default 8
*/
maskedDigits ?: 4 | 8 ;
/**
* 입력 끝에 표시할 요소. 카드사 로고 등을 넣습니다.
*/
suffix ?: React . ReactNode ;
}
/**
* 4자리씩 네 Segment로 카드번호를 입력받습니다. 값은 `string[]`(4개)입니다.
*/
export const CardNumberInput = React. forwardRef < SegmentedTextInputRootRef , CardNumberInputProps >(
({ maskedDigits = 8 , suffix , ... otherProps }, ref ) => {
const maskedFrom = SEGMENT_LABELS . length - maskedDigits / 4 ;
return (
< SeedSegmentedTextInput.Root ref = {ref} { ... otherProps}>
{ SEGMENT_LABELS . map (( label , i ) => {
const masked = i >= maskedFrom;
return (
< React.Fragment key = {label}>
{i > 0 ? (
< SeedSegmentedTextInput.Separator > - </ SeedSegmentedTextInput.Separator >
) : null }
< SeedSegmentedTextInput.Segment
accessibility-label = {label}
placeholder = {masked ? undefined : "0000" }
length = { 4 }
masked = {masked}
/>
</ React.Fragment >
);
})}
{suffix != null ? (
< SeedSegmentedTextInput.Suffix >{suffix}</ SeedSegmentedTextInput.Suffix >
) : null }
</ SeedSegmentedTextInput.Root >
);
},
);
CardNumberInput.displayName = "CardNumberInput" ;
export interface CardNumberFieldProps extends CardNumberInputProps {
label ?: React . ReactNode ;
labelWeight ?: SeedField . LabelProps [ "weight" ];
indicator ?: React . ReactNode ;
description ?: React . ReactNode ;
errorMessage ?: React . ReactNode ;
showRequiredIndicator ?: boolean ;
fieldRef ?: React . Ref < FieldRootRef >;
}
/**
* @see https://seed-design.io/lynx/components/segmented-text-input
*/
export const CardNumberField = React. forwardRef < SegmentedTextInputRootRef , CardNumberFieldProps >(
(
{
label,
labelWeight,
indicator,
description,
errorMessage,
required,
disabled,
invalid,
readOnly,
showRequiredIndicator,
fieldRef,
... otherProps
},
ref,
) => {
const renderHeader = label != null || indicator != null ;
const renderDescription = description != null && ! (invalid && errorMessage != null );
const renderErrorMessage = invalid && errorMessage != null ;
const renderFooter = renderDescription || renderErrorMessage;
return (
< SeedField.Root
ref = {fieldRef}
required = {required}
disabled = {disabled}
invalid = {invalid}
readOnly = {readOnly}
>
{renderHeader ? (
< SeedField.Header >
< SeedField.Label weight = {labelWeight}>
{label}
{showRequiredIndicator ? < SeedField.RequiredIndicator /> : null }
{indicator != null ? (
< SeedField.IndicatorText >{indicator}</ SeedField.IndicatorText >
) : null }
</ SeedField.Label >
</ SeedField.Header >
) : null }
< CardNumberInput ref = {ref} { ... otherProps} />
{renderFooter ? (
< SeedField.Footer >
{renderDescription ? (
< SeedField.Description >{description}</ SeedField.Description >
) : null }
{renderErrorMessage ? (
< SeedField.ErrorMessage >{errorMessage}</ SeedField.ErrorMessage >
) : null }
</ SeedField.Footer >
) : null }
</ SeedField.Root >
);
},
);
CardNumberField.displayName = "CardNumberField" ;
/**
* This file is a snippet from SEED Design, helping you get started quickly with @seed-design/* packages.
* You can extend this snippet however you want.
*/
label?React.ReactNode
labelWeight?"medium" | "bold" | undefined
indicator?React.ReactNode
description?React.ReactNode
errorMessage?React.ReactNode
showRequiredIndicator?boolean | undefined
fieldRef?React.Ref < NodesRef > | undefined
value?string[] | undefined
defaultValue?string[] | undefined
onValueChange?(( value : string []) => void ) | undefined
label?React.ReactNode
labelWeight?"medium" | "bold" | undefined
indicator?React.ReactNode
description?React.ReactNode
errorMessage?React.ReactNode
showRequiredIndicator?boolean | undefined
fieldRef?React.Ref < NodesRef > | undefined
value?string[] | undefined
defaultValue?string[] | undefined
onValueChange?(( value : string []) => void ) | undefined
children?React.ReactNode
value?string[] | undefined
defaultValue?string[] | undefined
onValueChange?(( value : string []) => void ) | undefined
staticText를 주면 입력 없이 정적 텍스트만 표시하는 Segment가 됩니다. 정적 Segment는 값 배열에 포함하지 않습니다.
주민등록번호 뒷자리처럼 가린 자리를 7×7 점으로 표시합니다. staticText·staticSuffix·placeholder에 넣습니다.
< SegmentedTextInput.Segment
accessibility-label = "주민등록번호 뒷자리 첫 번째 자리"
length = { 1 }
placeholder = {< SegmentedTextInput.Dots count = { 1 } />}
staticSuffix = {< SegmentedTextInput.Dots count = { 6 } />}
/>
digits로 입력받을 자리 수를 정합니다. (default: 7)
import "./styles" ;
import { VStack } from "@seed-design/lynx-react" ;
import { ResidentNumberField } from "@/components/ui/resident-number-field" ;
export default function Example () {
return (
< VStack className = "segmented-text-input-preview" gap = "spacingY.componentDefault" >
< ResidentNumberField label = "주민등록번호" description = "digits=6" digits = { 6 } />
< ResidentNumberField label = "주민등록번호" description = "digits=7 (default)" digits = { 7 } />
< ResidentNumberField label = "주민등록번호" description = "digits=13" digits = { 13 } />
</ VStack >
);
}
maskedDigits로 가릴 뒷자리 수를 정하고, suffix에 카드사 로고를 넣습니다. 로고 크기는 넣는 쪽에서 정합니다(Figma 기준 30×20).
import "./styles" ;
import { VStack } from "@seed-design/lynx-react" ;
import { CardNumberField } from "@/components/ui/card-number-field" ;
export default function Example () {
return (
< VStack className = "segmented-text-input-preview" gap = "spacingY.componentDefault" >
< CardNumberField label = "카드번호" description = "maskedDigits=8 (default)" />
< CardNumberField
label = "카드번호"
description = "maskedDigits=4, suffix"
maskedDigits = { 4 }
suffix = {< CardLogo />}
/>
</ VStack >
);
}
// 예시용 카드사 로고입니다. 실제 서비스에서는 카드사 로고 자산을 크기와 함께 넣습니다.
function CardLogo () {
return (
< view className = "segmented-text-input-preview__card-logo" accessibility-label = "카드사" >
< view className = "segmented-text-input-preview__card-logo-chip" />
</ view >
);
}
import "./styles" ;
import { VStack } from "@seed-design/lynx-react" ;
import { ResidentNumberField } from "@/components/ui/resident-number-field" ;
export default function Example () {
return (
< VStack className = "segmented-text-input-preview" gap = "spacingY.componentDefault" >
< ResidentNumberField label = "Enabled" description = "설명을 써주세요" />
< ResidentNumberField
label = "Invalid"
defaultValue = {[ "931305" , "1" ]}
invalid
errorMessage = "생년월일을 다시 확인해주세요"
/>
< ResidentNumberField label = "Disabled" defaultValue = {[ "931205" , "1" ]} disabled />
< ResidentNumberField label = "Read Only" defaultValue = {[ "931205" , "1" ]} readOnly />
</ VStack >
);
}
size로 크기를 정합니다. (default: large) medium은 React에서 데스크톱(Breakpoint lg 이상) 전용 크기입니다.
import "./styles" ;
import { VStack } from "@seed-design/lynx-react" ;
import { CardNumberField } from "@/components/ui/card-number-field" ;
export default function Example () {
return (
< VStack className = "segmented-text-input-preview" gap = "spacingY.componentDefault" >
< CardNumberField label = "카드번호" description = "size=large (default)" size = "large" />
< CardNumberField label = "카드번호" description = "size=medium" size = "medium" />
</ VStack >
);
}
import "./styles" ;
import { useState } from "@lynx-js/react" ;
import { VStack } from "@seed-design/lynx-react" ;
import { ResidentNumberField } from "@/components/ui/resident-number-field" ;
export default function Example () {
const [ value , setValue ] = useState < string []>([ "" , "" ]);
return (
< VStack className = "segmented-text-input-preview" gap = "x3" >
< ResidentNumberField label = "주민등록번호" value = {value} onValueChange = {setValue} />
< text className = "segmented-text-input-preview__status" >value: { JSON . stringify (value)}</ text >
</ VStack >
);
}
보안 키패드처럼 값을 밖에서 채워야 하면 해당 Segment를 readOnly로 두고 값을 controlled로 관리합니다. readOnly Segment는 native 입력 대신 텍스트로 그려져 시스템 키보드가 열리지 않습니다. Segment의 bindtap에서 키패드를 열고, 키패드가 value를 바꿉니다. 키패드 연동과 값 암호화는 서비스에서 구현합니다.
import "./styles" ;
import { Fragment, useState } from "@lynx-js/react" ;
import { ActionButton, Field, HStack, SegmentedTextInput, VStack } from "@seed-design/lynx-react" ;
const LABELS = [ "1-4번째 자리" , "5-8번째 자리" , "9-12번째 자리" , "13-16번째 자리" ];
const KEYPAD_SEGMENTS = [ 2 , 3 ];
const KEYS = [ "1" , "2" , "3" , "4" , "5" , "6" , "7" , "8" , "9" , "0" ];
export default function Example () {
const [ value , setValue ] = useState ([ "" , "" , "" , "" ]);
const [ keypadOpen , setKeypadOpen ] = useState ( false );
const press = ( key : string ) => {
"background only" ;
setValue (( prev ) => {
const index = KEYPAD_SEGMENTS . find (( i ) => (prev[i] ?? "" ). length < 4 );
if (index === undefined ) return prev;
return prev. map (( segment , i ) => (i === index ? segment + key : segment));
});
};
const erase = () => {
"background only" ;
setValue (( prev ) => {
const index = [ ... KEYPAD_SEGMENTS ]. reverse (). find (( i ) => (prev[i] ?? "" ). length > 0 );
if (index === undefined ) return prev;
return prev. map (( segment , i ) => (i === index ? segment. slice ( 0 , - 1 ) : segment));
});
};
return (
< VStack className = "segmented-text-input-preview" gap = "x4" >
< Field.Root >
< Field.Header >
< Field.Label >카드번호</ Field.Label >
</ Field.Header >
< SegmentedTextInput.Root value = {value} onValueChange = {setValue}>
{ LABELS . map (( label , i ) => {
const usesKeypad = KEYPAD_SEGMENTS . includes (i);
return (
< Fragment key = {label}>
{i > 0 ? < SegmentedTextInput.Separator > - </ SegmentedTextInput.Separator > : null }
< SegmentedTextInput.Segment
accessibility-label = { `카드번호 ${ label }` }
placeholder = {usesKeypad ? undefined : "0000" }
length = { 4 }
masked = {usesKeypad}
// 직접 입력을 막고 탭하면 외부 키패드를 연다.
readOnly = {usesKeypad}
bindtap = {() => {
"background only" ;
setKeypadOpen (usesKeypad);
}}
/>
</ Fragment >
);
})}
</ SegmentedTextInput.Root >
</ Field.Root >
{keypadOpen ? (
< HStack wrap = "wrap" gap = "x2" >
{ KEYS . map (( key ) => (
< ActionButton key = {key} variant = "neutralWeak" bindtap = {() => press (key)}>
{key}
</ ActionButton >
))}
< ActionButton variant = "neutralWeak" bindtap = {erase}>
지우기
</ ActionButton >
</ HStack >
) : null }
</ VStack >
);
}
snippet이 다루지 않는 형식은 SegmentedTextInput을 직접 조합합니다. 입력 Segment마다 accessibility-label로 구간 이름을 주세요.
import "./styles" ;
import { Field, SegmentedTextInput } from "@seed-design/lynx-react" ;
export default function Example () {
return (
< view className = "segmented-text-input-preview" >
< Field.Root >
< Field.Header >
< Field.Label >사업자등록번호</ Field.Label >
</ Field.Header >
< SegmentedTextInput.Root >
< SegmentedTextInput.Segment
accessibility-label = "사업자등록번호 앞 3자리"
placeholder = "000"
length = { 3 }
/>
< SegmentedTextInput.Separator > - </ SegmentedTextInput.Separator >
< SegmentedTextInput.Segment
accessibility-label = "사업자등록번호 가운데 2자리"
placeholder = "00"
length = { 2 }
/>
< SegmentedTextInput.Separator > - </ SegmentedTextInput.Separator >
< SegmentedTextInput.Segment
accessibility-label = "사업자등록번호 뒤 5자리"
placeholder = "00000"
length = { 5 }
/>
</ SegmentedTextInput.Root >
</ Field.Root >
</ view >
);
}
Keyboard Avoiding Scroll View 안에 두면 Segment에 focus할 때 Field 전체나 SegmentedTextInput.Root처럼 Segment보다 넓은 영역이 키보드에 가리지 않도록 스크롤합니다. Segment 사이로 focus가 옮겨 가도 스크롤 위치를 유지합니다.
import "./styles" ;
import { KeyboardAvoidingScrollView } from "@seed-design/lynx-react" ;
import { ResidentNumberField } from "@/components/ui/resident-number-field" ;
export default function Example () {
return (
< KeyboardAvoidingScrollView.Root
className = "keyboard-avoiding-scroll-view-preview"
keyboardGap = { 24 }
scrollBehavior = "smooth"
>
< KeyboardAvoidingScrollView.Content >
< view className = "keyboard-avoiding-scroll-view-preview__content" >
< text className = "keyboard-avoiding-scroll-view-preview__title" >구간 입력</ text >
< text className = "keyboard-avoiding-scroll-view-preview__description" >
아래 Segment를 탭하면 Field 전체가 키보드 위로 보이도록 자동 스크롤됩니다. 다음
Segment로 넘어가도 위치를 유지합니다.
</ text >
< view className = "keyboard-avoiding-scroll-view-preview__spacer" >
< text className = "keyboard-avoiding-scroll-view-preview__spacer-label" >
입력 영역이 화면 아래에 오도록 확보한 공간
</ text >
</ view >
< view className = "keyboard-avoiding-scroll-view-preview__field" >
< ResidentNumberField label = "주민등록번호" />
</ view >
< view className = "keyboard-avoiding-scroll-view-preview__footer-space" />
</ view >
</ KeyboardAvoidingScrollView.Content >
</ KeyboardAvoidingScrollView.Root >
);
}
이 예제는 네이티브 키보드 동작을 사용합니다. QR 코드 패널에서 Lynx Explorer로 실행한 뒤 주민등록번호 입력 영역을 탭해 확인해 주세요.
빈 Segment에서 Backspace를 눌러도 이전 Segment로 이동하지 않습니다. 빈 native 입력에서 지우기를 누르면 값이 바뀌지 않아 입력 이벤트가 오지 않습니다(Android에서 하드웨어·소프트 키보드 모두 확인). 이전 Segment는 탭해서 고칩니다.
←·→ 방향키로 Segment 사이를 이동하지 않습니다. masked Segment에서도 caret만 끝에 고정합니다.
native 입력에 paste 이벤트와 자동완성 속성이 없습니다. 한 번의 입력으로 두 글자 이상 늘어나면 붙여넣기로 보고 나눠 채웁니다. autoComplete는 native 입력에 전달하지 않고 "bday" 정규화에만 씁니다.
키보드 종류는 inputMode 대신 type(default: "number")으로 정합니다. masked Segment는 native type="password" 대신 글자를 숨긴 입력 위에 점을 그리므로 masked여도 type의 키보드가 열립니다.
readOnly에서는 Text Field 처럼 native 입력 대신 텍스트로 그립니다.
Field label이 group 이름이 되거나 label을 눌러 첫 Segment로 이동하는 동작이 없습니다. 각 Segment의 accessibility-label로 구간 이름을 알립니다.