콘텐츠로 이동

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의 키보드 스텝)

가장 잘못 사용하기 쉬운 부분이므로 따로 설명합니다:

상황 반환값
'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이 되어 키보드가 진입하지 못하는 상황은 생기지 않습니다.

  • 항목 라벨이 조회 문자열로 시작하면 매칭(대소문자 구분 없음).
  • startIndex 이후부터 순환하며 찾습니다(기본 -1, 처음부터 시작).
  • 여러 문자 누적 지원: 사용자가 빠르게 zh를 입력하면 UI 계층이 500ms 안에 조회 문자열을 누적하고, 시간 초과 시 비웁니다.
  • 매칭 없음 / query가 빈 문자열 / labels가 빈 배열 → -1.

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
}
}
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
}
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.key
const 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