keyboard —— 키보드 내비게이션 알고리즘
접근성 컴포넌트의 키보드 상호작용에는 표준 정답이 있습니다(APG의 listbox / menu / grid 패턴). 하지만 “위쪽 화살표를 누르면 어느 항목으로 가야 하는가”라는 계산을 select / menu / tree / tabs / segmented에서 각각 작성하면 각각 따로 잘못됩니다. shared/keyboard는 이것을 순수 함수로 만듭니다: “목록 + 현재 인덱스 + 방향”을 입력으로 받고 “타깃 인덱스”를 출력합니다.
core 계층은 어떤 키도 감시하지 않습니다——키보드 이벤트 감시는 UI 계층에 속합니다(Vue3의 @keydown, React의 onKeyDown). 이 모듈은 판정과 계산만 제공합니다.
가져오기
섹션 제목: “가져오기”import { LH_KEYS, isActivationKey, getNextListIndex, getNextGridCell, getNextRangeValue, findTypeAheadIndex, getRovingTabIndex, type LhListNavigationOptions,} from '@lh-ui/share/core/shared/keyboard'상수와 타입
섹션 제목: “상수와 타입”// 키 이름 상수(KeyboardEvent.key), UI 계층은 더 이상 문자열 리터럴을 직접 쓰면 안 됨export const LH_KEYS = { Escape: 'Escape', Enter: 'Enter', Space: ' ', // 주의: 값은 공백 한 칸 ArrowUp: 'ArrowUp', ArrowDown: 'ArrowDown', ArrowLeft: 'ArrowLeft', ArrowRight: 'ArrowRight', Home: 'Home', End: 'End', Tab: 'Tab', Backspace: 'Backspace', Delete: 'Delete', PageUp: 'PageUp', PageDown: 'PageDown',} as const
// 실제 이벤트 또는 값만 있는 key 모두 받음(순수 함수 테스트에 필요)export type LhKeyEvent = KeyboardEvent | { key: string }
// 내비게이션 방향: 수치 오프셋 또는 첫/마지막 센티넬export type LhListDirection = 1 | -1 | 'first' | 'last'
// 목록 항목의 최소 제약: 비활성 표시만 필요, 구체적인 형태는 호출자가 제공export interface LhListItem { disabled?: boolean}
export interface LhListNavigationOptions<T = unknown> { loop?: boolean // 첫/마지막 순환, 기본 true skipDisabled?: boolean // 비활성 항목 건너뛰기, 기본 true isDisabled?: (item: T) => boolean // 커스텀 비활성 판정, 기본은 item.disabled를 읽음}
export interface LhGridNavOptions { cols: number // 행당 열 수 total: number // 그리드 전체 칸 수 loop?: boolean // 행 내 / 전체 순환, 기본 false(경계에 도달하면 null을 반환해 UI 계층이 페이지를 넘김)}
export interface LhRangeNavOptions { min: number max: number step: number // 방향키 스텝; 유한하지 않거나 ≤0이면 1로 취급해 경계 클램프만 수행 pageStep?: number // PageUp / PageDown 스텝, 기본 max(step, (max-min)/10)}함수 표
섹션 제목: “함수 표”키 판정
섹션 제목: “키 판정”| 함수 | 시그니처 | 역할 |
|---|---|---|
isActivationKey |
(e: LhKeyEvent) => boolean |
Enter 또는 Space |
isArrowKey |
(e: LhKeyEvent) => boolean |
4방향 화살표 |
isEscapeKey |
(e: LhKeyEvent) => boolean |
Escape(팝업 층 닫기) |
isHomeOrEndKey |
(e: LhKeyEvent) => boolean |
Home / End(목록 첫/마지막으로 점프) |
isSpaceKey |
(e: LhKeyEvent) => boolean |
공백(Enter와 구분해야 하는 상황, 예: 스크롤 컨테이너 안의 페이지 넘기기) |
isHorizontalArrowKey |
(e: LhKeyEvent) => boolean |
수평 방향키 |
isVerticalArrowKey |
(e: LhKeyEvent) => boolean |
수직 방향키 |
isHorizontalArrowKey / isVerticalArrowKey는 물리적 키 판정만 합니다——RTL 레이아웃에서 “왼쪽”이 “이전”인지 “다음”인지는 UI 계층의 의미이며, 알고리즘이 대신 뒤집어 주지 않습니다.
내비게이션 계산
섹션 제목: “내비게이션 계산”| 함수 | 시그니처 | 역할 |
|---|---|---|
getNextListIndex |
<T>(items, current, direction, options?) => number |
범용 목록 내비게이션, 타깃 인덱스 반환 |
findTypeAheadIndex |
(labels: string[], query: string, startIndex?) => number |
type-ahead 첫 글자 매칭 |
getRovingTabIndex |
<T>(items, activeIndex) => number |
roving tabindex: tabindex 0이 되어야 할 인덱스 반환 |
getNextGridCell |
(current, key, opts: LhGridNavOptions) => number | null |
2차원 그리드 내비게이션(날짜 그리드, 월 그리드) |
getNextRangeValue |
(current, key, opts: LhRangeNavOptions) => number |
range 컨트롤 내비게이션(slider의 키보드 스텝) |
getNextListIndex의 반환값 의미
섹션 제목: “getNextListIndex의 반환값 의미”가장 잘못 사용하기 쉬운 부분이므로 따로 설명합니다:
| 상황 | 반환값 |
|---|---|
'first' / 'last' |
첫 / 마지막 활성 항목으로 직행 |
| 정상 이동 | 타깃 인덱스 |
| 순환하지 않고 이미 경계에 도달 | 경계에 머무름(경계 인덱스를 반환, -1이 아님) |
빈 목록 / 전부 비활성이고 skipDisabled |
-1(사용 가능 항목 없음) |
| 경계 항목만 비활성이고 이미 경계에 도달 | -1 |
current가 범위를 벗어나거나 -1 |
“미선택”으로 취급: 정방향이면 첫 항목, 역방향이면 마지막 항목 |
순환하지 않을 때 -1을 반환해서 “끝에 도달”을 나타내지 않습니다——경계 인덱스를 반환해 포커스가 그 항목에 머물게 합니다. UI 계층은 이에 따라 preventDefault 할지 결정합니다; “진짜로 선택 가능한 항목이 없을 때”만 -1을 줍니다.
getRovingTabIndex가 반환하는 것은 인덱스
섹션 제목: “getRovingTabIndex가 반환하는 것은 인덱스”함수 이름이 오해를 일으키기 쉽습니다: 이것은 **“tabindex 0이 되어야 할 항목의 인덱스”**를 반환하며, tabindex 값 자체가 아닙니다. 호출자는 이에 따라 값을 적용합니다:
const activeIndex = getRovingTabIndex(items, selectedIndex)items.forEach((item, i) => (item.tabindex = i === activeIndex ? 0 : -1))이렇게 하면 그룹 전체에 항상 Tab으로 진입할 수 있는 닻이 하나 보장됩니다; activeIndex에 사용 가능 항목이 없으면 첫 활성 항목으로 폴백하므로, 그룹 전체가 -1이 되어 키보드가 진입하지 못하는 상황은 생기지 않습니다.
findTypeAheadIndex의 매칭 규칙
섹션 제목: “findTypeAheadIndex의 매칭 규칙”- 항목 라벨이 조회 문자열로 시작하면 매칭(대소문자 구분 없음).
startIndex이후부터 순환하며 찾습니다(기본-1, 처음부터 시작).- 여러 문자 누적 지원: 사용자가 빠르게
zh를 입력하면 UI 계층이 500ms 안에 조회 문자열을 누적하고, 시간 초과 시 비웁니다. - 매칭 없음 /
query가 빈 문자열 /labels가 빈 배열 →-1.
getNextGridCell의 행 간 흐름
섹션 제목: “getNextGridCell의 행 간 흐름”ArrowRight는 행 끝에서 다음 행의 행 머리로 흘러갑니다(뷰포트 안에서의 연속 이동, 그리드 전체 경계를 넘어서야만 null을 반환); ArrowDown / ArrowUp은 수직으로 ±cols 이동; Home / End는 행 머리 / 행 끝(마지막 행이 cols에 미달할 때 행 끝은 total로 수렴, 그리드 밖으로 나가지 않음).
비내비게이션 키, 순환하지 않을 때의 범위 이탈, current 범위 이탈, cols / total ≤ 0 → null.
loop의 기본값이 false인 것은 의도적입니다: 날짜 패널에서 “오른쪽 벗어남”은 보통 다음 달로 전환을 의미하며, 그 결정은 UI 계층이 내리고 알고리즘은 “끝에 도달했다”고만 알려 줍니다.
getNextRangeValue는 클램프만 할 뿐 정렬하지 않음
섹션 제목: “getNextRangeValue는 클램프만 할 뿐 정렬하지 않음”getNextRangeValue(25, 'ArrowUp', { min: 0, max: 100, step: 10 })// 25 + 10 = 35(step 정렬하지 않음)- 왜 정렬하지 않는가: 현재값이 25라면(사용자가 끌어서 정렬되지 않은 값), 정렬 알고리즘은 먼저 30으로 갔다가 40으로 가서 두 번 점프하는 것처럼 보이게 합니다. clamp + 스텝만 하고, 정렬은 컴포넌트 계층이 localValue에 기록할 때 보장합니다(드래그 손을 뗄 때 눈금에 스냅).
비내비게이션 키는 current를 있는 그대로 반환——호출자는 이에 따라 “이번 키를 처리해야 하는가”를 판단하고 preventDefault 여부를 결정합니다.
사용 예시
섹션 제목: “사용 예시”select의 키보드 내비게이션(roving tabindex + list nav)
섹션 제목: “select의 키보드 내비게이션(roving tabindex + list nav)”function onKeydown(e: KeyboardEvent) { if (isArrowKey(e)) { e.preventDefault() const next = getNextListIndex( options.value, selectedIndex.value, e.key === LH_KEYS.ArrowDown ? 1 : -1, { loop: true }, ) if (next !== -1) selectedIndex.value = next } else if (isHomeOrEndKey(e)) { selectedIndex.value = getNextListIndex( options.value, -1, e.key === LH_KEYS.Home ? 'first' : 'last', ) } else if (isActivationKey(e)) { open.value = !open.value } else if (isEscapeKey(e)) { open.value = false }}date-picker의 날짜 그리드
섹션 제목: “date-picker의 날짜 그리드”const next = getNextGridCell(focusedIndex.value, e.key, { cols: 7, // 일주일 7일 total: daysInMonth.value, // 이번 달의 날 수})if (next === null) { // 범위 이탈 → 달을 이전 / 다음 달로 전환(UI 계층이 결정) switchMonth(e.key === LH_KEYS.ArrowRight ? 1 : -1)} else { focusedIndex.value = next}slider의 키보드 스텝
섹션 제목: “slider의 키보드 스텝”if (isArrowKey(e) || isHomeOrEndKey(e) || e.key === LH_KEYS.PageUp || e.key === LH_KEYS.PageDown) { e.preventDefault() modelValue.value = getNextRangeValue(modelValue.value, e.key, { min: props.min, max: props.max, step: props.step, })}문자 입력 내비게이션
섹션 제목: “문자 입력 내비게이션”// 조회 문자열 누적(UI 계층이 500ms 타임아웃을 관리)typeAheadBuffer.value += e.keyconst idx = findTypeAheadIndex(labels, typeAheadBuffer.value, selectedIndex.value)if (idx !== -1) selectedIndex.value = idx주의 사항
섹션 제목: “주의 사항”- 감시는 UI 계층에:
onKeydown의 연결,preventDefault의 시기, 포커스 전환 뒤의focus()호출은 모두 각 프레임워크 컴포넌트의 책임입니다; core은 계산 결과만 줍니다. - RTL 레이아웃: 좌우 방향키의 의미 뒤집기는 UI 계층이 호출 전에
direction을 반전시켜서 구현하며, 알고리즘 자체는 물리적 키 의미를 유지합니다. isDisabled의 기본값:item.disabled를 읽고, 읽을 수 없으면 비활성 아님으로 취급합니다——{}타입의 항목을 전달하면 모든 항목이 내비게이션 가능합니다.- 빈 목록 안전: 모든 내비게이션 함수는 빈 목록에 대해
-1/null을 반환합니다. UI 계층이 한 번 판단하면 되며, 에러를 던지지 않습니다.
@lh-ui/share/core/keyboard → getNextListIndex( )(填好参数后点运行)Copyright © 2024-2026 FengMo WenXue