# 포커스와 분할 입력

`ocFocus`는 DOM 포커스 이동, `useAutoAdvance`는 선택적 입력 완료 이동을 제공합니다. 검증·문자 정규화·마스킹은 사용하는 폼이 담당합니다.

```tsx
import { useRef } from 'react';
import { TextInput } from '@orioncactuscorp/ui';
import { useAutoAdvance } from '@orioncactuscorp/ui/react/focus';

function CodeFields() {
  const next = useRef<HTMLInputElement>(null);
  const advance = useAutoAdvance({
    enabled: true,
    next: () => next.current,
    isComplete: value => value.length === 3,
    validate: value => /^[0-9]{3}$/.test(value),
  });
  return (
    <>
      <p id='code-help'>숫자 3자리를 입력하면 다음 칸으로 자동 이동합니다.</p>
      <TextInput
        {...advance}
        aria-label='코드 앞자리'
        aria-describedby='code-help'
        inputMode='numeric'
        maxLength={3}
      />
      <TextInput ref={next} aria-label='코드 뒷자리' />
    </>
  );
}
```

## 자동 이동 계약

- `enabled` 기본값은 비활성입니다. 기본 TextInput 동작은 바뀌지 않습니다.
- `next()`는 명시한 다음 요소를 반환합니다. DOM에서 다음 필드를 임의로 찾거나 마지막 입력에서 제출하지 않습니다.
- `isComplete(value)`와 선택적 `validate(value)`는 새 입력값을 평가합니다. `validate`는 boolean 또는 Promise<boolean>을 반환합니다. 예외·거부 시 이동하지 않습니다.
- `invalid`, `pending`, readOnly, disabled 상태 및 네이티브 validity 오류·`aria-invalid="true"`에서는 이동하지 않습니다. 폼은 수정된 값에 맞춰 오류 상태를 갱신해야 합니다. 비동기 검증 자체의 대기는 `validate`가 반환한 Promise로 표현하고, `pending`은 외부 작업으로 이동을 막을 때 사용합니다.
- 입력 끝에 텍스트 또는 붙여넣기로 추가한 경우에만 이동을 검토합니다. IME 조합과 조합 확정, 삭제·중간 수정·전체 교체, 자동완성의 replacement 이벤트는 자동 이동 대상으로 삼지 않습니다.
- 커서 위치 확인이 가능한 text/search/tel/url/password 입력에 사용합니다. email/number 등의 선택 범위 API가 없는 입력은 이동하지 않습니다.
- 초기값·리셋·값의 프로그램 변경으로 자동 이동을 시작하지 않습니다. 비동기 검증 중 입력값 변경, blur, reset, unmount가 발생하면 기존 이동을 취소합니다.
- 반환된 focus/blur/beforeInput/input/composition 핸들러를 유지해야 합니다. 같은 이벤트를 추가할 때는 반환된 핸들러를 호출하도록 합성합니다. `onChange`는 독립적으로 사용할 수 있습니다.
- 다음 요소를 가리키는 ref는 실제 네이티브 input에 연결합니다. Tab/Shift+Tab은 브라우저 기본 순서를 유지합니다.

자동 이동을 사용하기 전에 설명으로 안내하고 `aria-describedby`로 연결하세요. 이름처럼 길이가 자유로운 값은 글자 수로 완료를 추측하지 않습니다. [WCAG On Input](https://www.w3.org/WAI/WCAG22/Understanding/on-input.html)

## 명시적 이동과 제출 오류

### 분할 입력의 화살표 이동

`react/focus`의 `useInputNavigation`은 `enabled: true`로 선택 적용합니다. 각 입력에 `previous`/`next` 대상 resolver를 연결하고 반환된 `onKeyDown`을 전달합니다. 기본 비활성이며 자동 완료 검증과 독립적입니다.

```tsx
import { useInputNavigation } from '@orioncactuscorp/ui/react/focus';

const navigation = useInputNavigation({
  enabled: true,
  previous: () => previousRef.current,
  next: () => nextRef.current,
});
<TextInput {...navigation} aria-label='번호 가운데 구간' />;
```

LTR에서는 맨 앞의 ←가 이전 입력 끝으로, 맨 끝의 →가 다음 입력 처음으로 이동합니다. RTL에서는 키 방향을 반대로 해석하며 `direction`으로 명시할 수 있습니다. 입력 중간, 선택 영역, Shift/Ctrl/Alt/Meta 조합, IME 조합은 기본 편집을 유지합니다. 선택 범위 API가 있는 단일 행 input만 지원합니다. 대상이 없거나 이동할 수 없으면 기본 키 동작을 취소하지 않습니다. 오류·검증 대기·readOnly는 명시적 탐색을 막지 않으며 값은 변경하지 않습니다. Tab 순서와 Backspace 편집은 변경하지 않습니다.

`useAutoAdvance`와 함께 사용할 때 이 훅의 `onKeyDown`을 추가로 전달할 수 있습니다. 별도 `onKeyDown`이 있으면 핸들러를 합성하고 `event.defaultPrevented`를 존중하세요. Storybook의 RRN에서 `arrowNavigation`으로 이 기능을 확인할 수 있습니다. 독립적인 이름/전화번호 항목 사이에는 기본 적용하지 않습니다.

### 대상 포커스와 첫 오류 이동

```ts
import { ocFocus } from '@orioncactuscorp/ui/utils/focus';

ocFocus.focus(nextInput, { preventScroll: true });
ocFocus.focusFirst(orderedErrorTargets);
```

두 메서드는 이동 성공 여부를 반환합니다. 연결되지 않은 요소, disabled, inert, hidden 또는 접근성상 숨긴 영역의 요소는 건너뜁니다. 요소 자체가 포커스를 받을 수 있어야 하며 임의로 tabindex를 추가하지 않습니다. `focusFirst`는 전달받은 순서대로 시도하며 오류를 판정하거나 DOM의 오류 필드를 검색하지 않습니다.

제출 실패 시 폼이 오류 필드 또는 오류 요약 중 적절한 대상을 결정한 뒤 호출합니다. 오류 메시지를 먼저 렌더링하고 각 입력과 연결하세요. 입력할 때마다 오류 필드로 강제 이동하지 않습니다.

## InputGroup

여러 네이티브 입력을 하나의 시각적 항목으로 배치합니다. `Field`/`TextInput`의 단일 입력 계약과 독립적입니다.

```tsx
import {
  InputGroup,
  InputGroupInput,
  InputGroupSeparator,
} from '@orioncactuscorp/ui';

<InputGroup
  label='주민등록번호'
  variant='underlined'
  labelPlacement='floating'
  description='앞 6자리와 뒤 첫 1자리만 입력하세요.'
>
  <InputGroupInput
    aria-label='주민등록번호 앞 6자리'
    name='birth-date'
    inputMode='numeric'
    maxLength={6}
    autoComplete='off'
  />
  <InputGroupSeparator>-</InputGroupSeparator>
  <InputGroupInput
    aria-label='주민등록번호 뒤 첫 1자리'
    name='identity-first-digit'
    inputMode='numeric'
    maxLength={1}
    autoComplete='off'
  />
  <InputGroupSeparator>••••••</InputGroupSeparator>
</InputGroup>;
```

- `InputGroup`은 native fieldset/legend로 그룹 이름을 제공합니다. 각 `InputGroupInput`에는 별도 label 또는 aria-label을 제공하세요.
- `variant`: `outlined`(기본값), `underlined`.
- `labelPlacement`: `outside`, `floating`. 기본값은 floating입니다. outlined에서는 두 값 모두 외부 라벨로 표시하고, underlined에서만 floating을 적용합니다.
- underlined의 `floating`은 값이 있거나 placeholder를 가진 그룹 내부에 포커스가 있을 때 올라가는 라벨입니다.
- 라벨 공간은 CSS typography·spacing으로 처음부터 확보합니다. hydration 이후 측정으로 높이를 재설정하지 않습니다. 초기 웹폰트 자체의 교체까지 레이아웃 이동이 없음을 보장하지는 않습니다.
- `invalid`, `errorMessage`, `description`은 그룹과 입력의 접근성 연결을 관리합니다. 입력별 `invalid`와 `aria-describedby`도 지원합니다.
- 그룹의 `disabled`는 native fieldset을 통해, `readOnly`/`required`는 context를 통해 각 입력에 적용됩니다. required는 모든 구간이 필수인 경우에 사용합니다.
- `InputGroupSeparator`는 장식용이며 보조 기술에서 숨깁니다. 의미 있는 안내는 description 또는 각 입력의 이름에 넣으세요.
- 입력별 name/ref/value/defaultValue/onChange 등은 네이티브로 전달됩니다. RRN 전용 타입이나 전체 번호의 저장·유효성 검증은 제공하지 않습니다.
- 그룹 안에 다른 InputGroup을 중첩하지 않습니다. 각 그룹은 독립된 surface를 사용합니다.

Storybook의 `Components/InputGroup → RRN`은 외형·라벨·상태 Controls를 제공하고, `Utilities/Focus → Fields`는 이름·휴대전화번호·RRN과 제출 오류 이동을 보여줍니다. RRN 예시의 날짜 검사는 입력 편의를 위한 월/일 검사이며 실제 주민등록번호 유효성 확인을 대체하지 않습니다.

RRN 예시에서 Backspace는 뒷자리부터 한 글자씩 삭제합니다. 빈 뒷칸에서 다시 누르면 앞자리 마지막 글자를 삭제하고 앞칸 끝으로 이동합니다. 이 동작은 RRN 예시의 값 편집 정책이며 InputGroup이나 ocFocus의 기본 동작이 아닙니다. 선택 영역·IME 조합·readOnly·disabled 상태에서는 경계를 넘는 삭제를 적용하지 않습니다.
