
리모컨을 웹에서 동작하여 electron 을 사용하여 앱을 만들 수 있다.
그럼 마치 tvOS 처럼 동작하는데 이를 위해 web-client 에서 리모컨을 사용할 수있도록 도와주는 library 가 있다.
이에 Norigin-Spatial-Navigation 을 소개하고자 한다.
Norigin-Spatial-Navigation 은 리모컨 또는 키보드 기반의 UI 탐색(특히 TV 앱)을 위한 React 중심의 spatial navigation 라이브러리이다.
넷플릭스나 유튜브 TV처럼 리모컨 방향키로 UI 요소 간 이동이 필요한 환경에서 매우 유용하다.

위 사진을 보면 이 library 가 어떻게 동작할지 궁금할텐데
이 library 는 기본적으로 DOM 기반으로 동작한다.
즉, 렌더링된 DOM 요소들의 위치와 크기를 계산하고, 어떤 요소가 포커스 가능한지(tabIndex, data-sn 속성 등)를 파악하여 공간적으로 다음 포커스 위치를 결정한다.
참고로 React key prop 과 focusable 의 key 는 전혀 관련이 없다.
useFocusable 훅을 통해 focusKey를 각 컴포넌트에 부여하고 trackChildren 속성을 설정함으로써, 라이브러리는 이 focusKey들을 사용하여 포커스 가능한 요소들을 관리하고, 포커스 이동 시 어떤 요소가 현재 활성화되어 있는지 등을 추적한다.
그리고 이 과정에서 CSS(DOM 요소의 레이아웃 정보)가 포커스 이동 방향을 결정하는 데 중요한 역할을 한다. 예를 들어, 왼쪽/오른쪽 화살표 키를 눌렀을 때, 현재 포커스된 요소의 DOM 위치를 기준으로 "오른쪽에 있는" 다음 포커스 가능한 요소를 찾는다.
npm i @noriginmedia/norigin-spatial-navigation --save
useEffect(() => {
init({
// debug: true,
// visualDebug: true,
});
setKeyMap({
left: ['ArrowLeft', 37],
up: ['ArrowUp', 38],
right: ['ArrowRight', 39],
down: ['ArrowDown', 40],
enter: ['Enter', 13]
});
}, []);
init 은 본인이 navigate 하고 싶은 모든 요소를 포함하는 부모 컴포넌트에 위치하면 된다.
이게 핵심 훅이다.
이때 대개 2가지 종류로 작성할 수 있다.
const {ref: busPageRef, focusKey: busInfoPageFocusKey, focusSelf: focusBusInfoPage} = useFocusable({
focusKey: "bus-page",
trackChildren: true,
});
// 중략
return (
<FocusContext.Provider value={busInfoPageFocusKey}>
<Container ref={busPageRef}>
<BusCard
direction="left"
stationName={busesInfo.station_1_info.name + " (상행)"}
highlightBusNumber={arrivalSoonBusL}
buses={station1Sorted}
/>
<BusCard
direction="right"
stationName={busesInfo.station_2_info.name + " (하행)"}
highlightBusNumber={arrivalSoonBusR}
buses={station2Sorted}
/>
</Container>
</FocusContext.Provider>
);
const {ref: busCardRef, focused} = useFocusable({
focusKey: `bus-card-${direction}`,
onArrowPress: (direction) => {
if (focused && scrollContainerRef.current) {
const container = scrollContainerRef.current;
const scrollAmount = 100;
if (direction === 'up') {
container.scrollTop = Math.max(0, container.scrollTop - scrollAmount);
return false;
} else if (direction === 'down') {
container.scrollTop = Math.min(
container.scrollHeight - container.clientHeight,
container.scrollTop + scrollAmount
);
return false;
} else if (direction === "left") {
if (getCurrentFocusKey() === "bus-card-left") {
setFocus("busArrived");
}
}
}
return true;
}
});
return (
<CardWrapper ref={busCardRef} $focused={focused} $direction={direction}>
<Title>{stationName}</Title>
<Header $direction={direction}>
<div>곧 도착</div>
<div>
{highlightBusNumber.map((num, idx) => (
<span key={idx}>
<img
src={direction === "left" ? BusR : BusB}
alt="버스"
style={{width: "1.2rem", height: "1.2rem", marginRight: "0.25rem"}}
/>
{num}
</span>
))}
</div>
</Header>
<Table ref={scrollContainerRef} $direction={direction}>
// 생략
</Table>
</CardWrapper>
);
const {
ref,
focusSelf,
focusKey,
} = useFocusable({
focusable: true, // 컨테이너 자체는 포커스 불가
saveLastFocusedChild: true,
trackChildren: true,
autoRestoreFocus: true,
isFocusBoundary: true,
focusBoundaryDirections: ["up", "down", "left"],
focusKey: currentRemoteFocus,
preferredChildFocusKey: undefined,
extraProps: { foo: "bar" },
});