ListRow의 모양과 동작을 왜 하나의 계약으로 묶었을까
화살표와 버튼이 동작 영역, DOM, 접근성까지 결정한 과정
앞 글까지는 정보 구조에 따라 공개 컴포넌트를 Recipe로 나누고, Leading과 Trailing은 지원하는 표현만 고를 수 있는 descriptor로 제한했다.
<ListRow leading={{ type: 'icon', name: 'user' }} />leading을 넘기면 해당 아이콘을 그리고, 넘기지 않으면 아무것도 그리지 않는다. Leading은 무엇을 그릴지만 결정했고, 별도의 동작 계약은 따라오지 않았다.
반대쪽 Trailing은 달랐다. 무엇을 보여줄지 고르면 동작 영역과 DOM까지 함께 정해야 했다.
Trailing에 화살표가 보이는 행은 눌렀을 때 무언가 일어날 것처럼 보인다. 그런데 화살표 표시와 동작을 별개의 입력으로 받으면, 아무 동작도 하지 않는 화살표를 타입 오류 없이 만들 수 있다.
<ListRow trailing={{ type: 'arrow' }} />화면은 오류 없이 렌더링된다. 사용자는 화살표를 보고 행을 눌러보지만 아무 일도 일어나지 않는다.
버튼도 비슷했다. 우측에 버튼처럼 보이는 요소가 있을 때 그 버튼만 눌러야 하는 화면이 있었고, 행 전체를 누르는 편이 자연스러운 화면이 있었다. 행과 우측 버튼이 서로 다른 동작을 가져야 하는 화면도 있었다.
이 차이를 clickable, showArrow, buttonLabel로 각각 받으면, API만 보고 실제 동작을 판단하기 어렵다.
<ListRow clickable showArrow buttonLabel="실행" />이 코드에는 중요한 정보가 빠져 있다.
- 행 전체를 누르는가
- 화살표는 어떤 동작을 뜻하는가
- 버튼 모양이
<button>인가 - 행과 버튼이 서로 다른 동작인가
- 이동인가 명령인가
- 비활성 상태에서 무엇을 막아야 하는가
- hover와
focus-visible은 어느 영역에 표시되는가
Leading을 정할 때는 이런 후속 질문이 생기지 않았다. 앞의 일곱 질문은 서로 독립적인 것처럼 보이지만, 하나를 정하면 나머지가 따라온다. 화살표를 보여주기로 하면 동작이 있어야 한다. 동작의 종류가 정해지면 어떤 HTML 요소를 그릴지가 정해진다. 그 요소가 화면에서 어디까지 덮느냐가 키보드 탭 이동 지점(tab stop)과 hover의 범위를 정한다.
이 글은 그 일곱 질문을 어디까지 하나의 계약으로 묶어야 했는지에 답한다. 일곱 질문을 하나의 계약으로 묶은 뒤에는, 제품 개발자가 모양과 동작의 대응을 깨뜨릴 수 없도록 공개 props 타입을 만들어야 했다.
화살표가 있다면 동작도 있어야 했다
우리 디자인에서 화살표는 장식 아이콘이 아니었다. 행을 누르면 다른 위치로 이동하거나 어떤 동작이 실행된다는 신호였다.
그렇다면 화살표가 있는데 연결된 동작이 없는 상태는 표현할 수 없어야 했다.
먼저 행의 동작을 이동과 명령으로 나눴다. 타입에서는 각각 navigation과 command로 표현했다.
type RowAction =
| { actionType: 'navigation'; href: string; onPress?: never }
| { actionType: 'command'; onPress: () => void; href?: never };navigation에는 이동할 주소가 필요하고, command에는 실행할 함수가 필요하다.
const move: RowAction = {
actionType: 'navigation',
href: '/detail',
};
const run: RowAction = {
actionType: 'command',
onPress: () => openDialog(),
};두 값을 섞는 것은 허용하지 않았다.
const action: RowAction = {
actionType: 'navigation',
href: '/detail',
onPress: openDialog,
// TypeScript 오류:
// navigation에서는 onPress를 받을 수 없다.
};화살표를 선택한 경우에는 RowAction을 필수로 만들었다.
type ArrowInteraction = {
pressTarget: 'row';
trailing: { type: 'arrow' };
rowAction: RowAction;
};pressTarget은 행에서 실제로 동작하는 영역을 나타낸다. 여기서는 row가 행 전체를 동작 영역으로 만든다는 점만 보면 된다. row, trailing, rowAndTrailing의 차이는 다음 절에서 정리한다.
허용된 사용은 다음과 같다.
<ListRowRegular
label="신청 내역"
pressTarget="row"
trailing={{ type: 'arrow' }}
rowAction={{ actionType: 'navigation', href: '/detail' }}
/>화살표만 전달하면 타입 검사 단계에서 실패한다.
<ListRowRegular
label="신청 내역"
pressTarget="row"
trailing={{ type: 'arrow' }}
// TypeScript 오류:
// rowAction이 필요하다.
/>이렇게 묶으면 화살표를 보여줄지와 동작이 있는지를 다른 사람이 따로 판단하지 않아도 된다.
동작을 이동과 명령으로 나눈 이유는 필요한 값이 다르기 때문만은 아니었다. 동작의 의미에 따라 그릴 DOM도 달라졌다.
function RowActionSurface({
action,
children,
}: {
action: RowAction;
children: ReactNode;
}) {
if (action.actionType === 'navigation') {
return <a href={action.href}>{children}</a>;
}
return (
<button type="button" onClick={action.onPress}>
{children}
</button>
);
}다른 위치로 이동하는 navigation은 링크로, 현재 화면에서 명령을 실행하는 command는 버튼으로 렌더링한다.
href가 있는 동작을 모두 <div onClick>이나 <button>으로 처리하면 링크가 기본적으로 제공하는 동작을 잃는다. 새 탭에서 열기, 링크 주소 복사, 브라우저의 상태 표시, 보조기기에 링크로 노출되는 것, 키보드와 브라우저의 기본 이동 동작이 거기에 들어간다. 반대로 command를 링크로 렌더링하면 요소의 의미와 실제 동작이 어긋난다.
actionType은 함수의 형태뿐 아니라 Recipe가 렌더링할 HTML 요소까지 결정하는 판별자였다.
같은 버튼 모양이라도 누를 수 있는 범위는 달랐다
도입에서 나열한 세 화면이 여기서 갈린다. Trailing에 버튼 모양이 들어갈 때 사용자가 어디까지 누를 수 있어야 하는지를 두고 논의가 오래 갔고, 행 전체를 동작 영역으로 만드는 것이 항상 적절하지는 않았다.
그래서 clickable 같은 boolean으로 처리하지 않고, 사용자가 동작을 실행할 수 있는 영역에 따라 pressTarget을 나눴다.
아래 세 절의 타입 선언은 앞의 ArrowInteraction과 마찬가지로 축약형이다. 각 동작 영역의 차이를 보여주는 데 필요한 Trailing 하나만 남겼고, 허용되는 조합 전체는 뒤에서 최종 union으로 합친다.
행 전체가 동작 영역이면 버튼 모양은 tab stop을 만들지 않았다
행의 어느 위치를 눌러도 같은 동작이 실행되는 형태다. 여기에는 버튼처럼 보이지만 눌리지 않는 표현이 하나 필요했다.
type RowPressInteraction = {
pressTarget: 'row';
rowAction: RowAction;
trailing: { type: 'buttonVisual'; label: string };
};이때 Trailing의 버튼 모양은 <button>이 아니다. 행의 동작을 강조하는 시각 요소다.
아래는 rowAction이 navigation인 경우다. command라면 같은 행 전체 동작 영역을 <button>으로 렌더링한다.
<a href="/detail" className="rowAction">
<Content />
<span className="buttonVisual">상세 보기</span>
</a>행 전체가 하나의 동작 요소이므로 버튼 모양 안에 별도의 tab stop을 만들지 않는다. 사용자는 행의 어느 지점을 눌러도 같은 동작을 실행한다. hover와 pressed도 실제 동작 영역인 행 전체에 적용한다.
우측 버튼만 누를 수 있는데 행 전체에 hover를 주면 어긋났다
행은 정보만 보여주는 정적 영역이고, 우측 버튼만 조작할 수 있는 형태다.
type TrailingPressInteraction = {
pressTarget: 'trailing';
trailing: { type: 'button'; label: string; onPress: () => void };
};DOM에서도 우측 <button>만 상호작용 요소가 된다.
<div className="row">
<Content />
<button type="button" onClick={onPress}>
실행
</button>
</div>여기서 행 전체에 hover와 pressed를 적용하면, 누를 수 없는 영역이 누를 수 있는 것처럼 보인다. 시각적 피드백도 우측 버튼에만 줘야 했다.
행과 우측 버튼이 다른 동작을 가지면 링크와 버튼을 중첩하지 않았다
행을 누르면 상세 화면으로 이동하고, 우측 버튼을 누르면 별도의 명령을 실행하는 형태다.
type RowAndTrailingPressInteraction = {
pressTarget: 'rowAndTrailing';
rowAction: RowAction;
trailing: { type: 'button'; label: string; onPress: () => void };
};두 동작을 DOM에서도 형제로 분리했다.
<div className="row">
<a href="/detail" className="primaryAction">
<Content />
</a>
<button
type="button"
className="secondaryAction"
onClick={onTrailingPress}
>
실행
</button>
</div>이 예시의 rowAction은 navigation이다. command라면 주 동작도 <button>이 되어 버튼 둘이 형제로 놓인다.
<div className="row">
<button
type="button"
className="primaryAction"
onClick={onRowPress}
>
<Content />
</button>
<button
type="button"
className="secondaryAction"
onClick={onTrailingPress}
>
실행
</button>
</div>두 경우의 계약은 같다. 중첩하지 않고, 두 요소가 각자 tab stop과 focus-visible을 갖는다.
행 전체를 감싼 링크 안에 <button>을 넣고 stopPropagation()으로 조정하는 방식은 쓰지 않았다. 이벤트 전파를 막으면 마우스 클릭의 결과는 바꿀 수 있지만, 상호작용 요소가 중첩된 DOM 구조는 그대로 남는다.
- 키보드 포커스 순서가 복잡해진다.
- 상위 링크와 하위 버튼 중 어느 쪽이 동작을 실행하는지 모호해진다.
- hover와
focus-visible을 어느 요소에 표시할지 불분명해진다. - HTML의
interactive content규칙과 충돌할 수 있다. - 보조기기가 두 요소를 어떤 관계로 노출할지 예측하기 어려워진다.
행의 주 동작과 우측 보조 동작을 형제로 두면 각각의 의미와 동작 영역이 DOM 구조에 그대로 남는다. ListRow는 자신을 감싸는 목록 구조를 가정하지 않으므로, 여러 행을 어떤 구조로 묶을지는 여기서 정하지 않는다.
같은 버튼 모양이라도 pressTarget에 따라 그것이 <button>인지 아닌지가 달라졌다.
동작 영역에 맞춰 hover와 접근 가능한 이름도 달라졌다
pressTarget은 이벤트 핸들러를 어디에 붙일지만 결정하지 않았다. hover, pressed, focus-visible 상태도 실제로 동작하는 요소에 붙어야 했고, 그래서 선택자도 세 종류로 나뉘었다.
/* pressTarget: 'row'. 세 상태가 모두 행 전체에 붙는다 */
.rowAction:hover {
background: var(--surface-hover);
}
.rowAction:active {
background: var(--surface-pressed);
}
.rowAction:focus-visible {
outline: 2px solid var(--focus-ring);
}
/* pressTarget: 'trailing'. 같은 자리가 행이 아니라 우측 버튼이다 */
.trailingButton:hover {
background: var(--button-hover);
}
.trailingButton:focus-visible {
outline: 2px solid var(--focus-ring);
}
/* pressTarget: 'rowAndTrailing'. 두 요소가 각각 자기 상태를 가진다 */
.primaryAction:hover {
background: var(--surface-hover);
}
.primaryAction:focus-visible {
outline: 2px solid var(--focus-ring);
}
.secondaryAction:hover {
background: var(--button-hover);
}
.secondaryAction:focus-visible {
outline: 2px solid var(--focus-ring);
}pressed도 같은 동작 요소를 따른다. 각 상태에 어떤 색과 테두리를 쓸지는 요소별 표현 규칙에서 정하고, 한 요소의 hover가 행 전체 배경까지 바꿀지는 디자인 정책에서 정한다. 여기서는 세 상태를 어느 요소에 적용할지만 정했다. 행이 정적인데 행 컨테이너에 hover를 주거나, 행 전체가 동작 영역인데 Trailing에만 hover를 주면 보이는 영역과 실제 동작 영역이 어긋난다.
동작 영역을 나누자 공개 API에도 각 동작의 접근 가능한 이름을 만들 정보가 필요해졌다.
아래 두 경우는 접근성 트리의 이름 계산 규칙에서 예상한 것이고, 보조기기로 확인한 결과는 아니다.
행 전체가 링크인 경우
ListRowMax처럼 식별 정보와 여러 지표, 상태, Bottom이 한 링크 안에 들어가면 접근 가능한 이름이 지나치게 길어질 수 있다.
<a href="/detail">
<span>A 상품</span>
<span>금액 10,000원</span>
<span>예상 수익 3%</span>
</a>하위 텍스트가 모두 링크 이름에 들어가면 목록을 빠르게 탐색하기 어렵다. 필요한 경우에는 행의 식별 정보와 동작의 목적으로 더 명확한 이름을 줄 수 있어야 했다.
<a href="/detail" aria-label="A 상품 상세 보기">
<MaxContent />
</a>다만 모든 행에 무조건 aria-label을 넣는 것도 답은 아니다. 보이는 텍스트와 접근 가능한 이름이 불필요하게 달라지고, 화면에 있는 정보 일부가 이름 계산에서 빠질 수 있다.
Recipe는 행 전체의 정보 구조를 알고 있어서 언제 별도의 동작 이름이 필요한지 판단할 수 있었다. 다만 정보 구조를 안다고 화면별 문구나 번역까지 정할 수 있는 것은 아니다. 그래서 공개 계약에는 이름을 만드는 데 필요한 식별 값만 넣고, 그 값으로 일관된 형식의 이름을 만드는 일은 Recipe가 맡았다.
행과 우측 버튼에 동작이 각각 있는 경우
rowAndTrailing에서는 행마다 tab stop이 두 개가 된다. 우측 버튼의 보이는 label이 모두 실행, 선택, 보기처럼 같다면 목록에서 각 버튼을 구분하기 어렵다. 세 행이 나란히 있으면 세 버튼의 이름이 전부 같아진다.
그래서 우측 동작에는 화면에 보이는 label 외에 행의 식별 정보도 필요할 수 있다.
<button type="button" aria-label="A 상품 실행">실행</button>이 이름을 화면마다 직접 조합하면 문장 형식과 어순이 달라진다. 여기서도 Recipe가 식별 값을 받아 같은 규칙으로 이름을 만들었다.
disabled를 공개하려면 실행과 탭 순서까지 결정해야 했다
앞 글에서는 disabled를 Recipe나 Variant가 아니라 ListRow가 공통으로 받는 상태로 분류했다. 이 절에서는 그 상태를 공개할 때 디자인 시스템이 무엇을 함께 결정해야 했는지 살펴본다.
선택할 수 있는 값이 두 개이고 시각적 결과도 다르니 variant: 'default' | 'disabled'로 표현할 수도 있다. 렌더 함수가 그 값을 보고 동작까지 막는 코드를 쓰는 것도 가능하다. 달라지는 것은 호출부와 문서가 이 값을 무엇으로 읽느냐다. disabled를 variant 축에 놓으면 시각 표현을 고르는 값처럼 읽힌다. 그러면 이 값이 행의 동작까지 바꾼다는 사실이 API에서 드러나지 않는다.
disabled는 ListRow가 공통으로 받는 상태였다. 다만 navigation 행에서 링크 이동을 어떻게 막을지는 공통 계약으로 정하지 않았다.
시각 표현은 행 전체에 같은 opacity를 적용하는 형태였다.
.listRow[data-disabled='true'] {
opacity: 0.5;
}그러나 opacity는 비활성처럼 보이게 할 뿐 동작을 막지는 않는다. disabled가 무엇을 바꿔야 하는지 정리하면 확인할 항목은 여섯이다. 행의 이벤트 핸들러가 실행되는지, 링크 이동이 일어나는지, Enter나 Space로 실행되는지, 선택 컨트롤의 값이 바뀌는지, 우측 버튼이 실행되는지, hover와 pressed가 나타나는지다.
이 여섯 항목은 시각 표현, 포인터 입력, 키보드 실행, 보조기기 상태, 탭 순서라는 다섯 범위에 걸쳐 있고, opacity가 바꾸는 것은 시각 표현뿐이다.
이 목록은 당시 모든 Recipe가 실제로 보장한 결과가 아니다. disabled를 공개할 때 함께 결정해야 할 범위를 이 글을 쓰며 정리한 것이다.
pointer-events: none으로도 이 조건을 충족할 수 없다. 해당 요소가 포인터 hit testing에서 빠질 뿐이고 그 뒤에 있는 요소가 이벤트 대상이 된다. 키보드나 스크립트를 통한 실행, 보조기기 노출도 그대로다.
요소의 종류에 따라 쓸 수 있는 수단도 달랐다. 아래는 당시의 의사결정표가 아니라, 이 글을 쓰면서 각 수단이 무엇을 해결하고 무엇을 남기는지 정리한 것이다.
| 수단 | 기본 실행 동작 | 포인터 | 키보드와 포커스 | 접근성 상태 | 남는 책임 |
|---|---|---|---|---|---|
<button disabled> · <input disabled> |
막힌다 | 사용자 클릭으로 실행되지 않는다 | 탭 순서에서 빠진다 | 비활성으로 노출 | 시각 표현과 행 상태 동기화 |
| 링크를 정적 요소로 교체 | 링크가 없어 실행 자체가 사라진다 | 링크 대상이 아니다 | 탭 순서에서 빠진다 | 링크로 노출되지 않는다 | 비활성 항목의 존재를 알릴 방법 |
aria-disabled + 실행 방어 |
자동으로 막히지 않는다 | 그대로 발생한다 | 포커스와 탭 순서가 남는다 | 비활성으로 노출 | 실행 경로를 직접 막는 일 |
| 이벤트 핸들러 방어만 | 그 핸들러를 지나는 경로만 막는다 | 그대로 발생한다 | 남는다 | 노출되지 않는다 | 상태 전달과 나머지 경로 |
pointer-events: none |
disabled의 의미가 생기지 않는다 |
해당 요소가 hit testing에서 빠지고 뒤 요소가 대상이 된다 | 남는다 | 노출되지 않는다 | 실행 차단과 상태 전달 전부 |
<button>과 선택용 <input>에는 disabled 속성을 쓸 수 있다. 링크에는 그 속성이 없다.
한 가지 방법은 비활성 상태에서 링크를 정적 요소로 바꾸는 것이다.
return disabled ? (
<div className="listSurface" data-disabled="true">
{content}
</div>
) : (
<a className="listSurface" href={href}>
{content}
</a>
);이러면 링크 이동과 키보드 실행이 DOM 구조에서 사라진다. 비활성 행은 탭 순서에서 빠지고 링크로 노출되지 않는다.
하지만 모든 화면에서 이것만이 답은 아니었다. 키보드 사용자도 비활성 항목의 존재와 위치를 확인해야 한다면, 포커스를 유지하고 aria-disabled="true"를 노출하는 편이 나을 수 있다. 이 경우 실행은 코드에서 따로 막아야 한다.
<a
href={href}
aria-disabled={disabled}
onClick={(event) => {
if (disabled) {
event.preventDefault();
}
}}
>
{content}
</a>onClick에서 막는 것은 그 이벤트를 지나는 경로다. 가운데 버튼 클릭이나 컨텍스트 메뉴의 새 탭에서 열기는 그 경로를 지나지 않는다. 라우터 컴포넌트를 쓴다면 그 컴포넌트가 click과 키보드 실행을 어떻게 처리하는지도 함께 봐야 한다.
aria-disabled는 상태를 전달하지만 기본 동작을 막지 않는다. 그래서 이 방식은 여섯 항목 가운데 링크 이동 차단을 완전히 해결하지 못한다. href가 남아 있는 한 이동 경로를 전부 없앤 것이 아니다. 비활성 상태에서 어떤 이동도 일어나지 않아야 한다면 href 자체가 없어야 한다.
명령 쪽에도 방어를 둘 수 있다.
const handlePress = () => {
if (disabled) {
return;
}
onPress();
};이 방어는 예상하지 못한 경로에서 동작이 실행되는 것을 한 번 더 막는다. 그러나 핸들러가 실행되지 않는다고 해서 그 요소가 비활성으로 인식되지는 않는다. 방어만 걸린 <button>은 여전히 탭 순서에 들어오고 보조기기에도 활성 버튼으로 노출된다. 눌렀을 때 결과만 없다. 그래서 <button>에서는 disabled를 먼저 쓰고 핸들러 방어는 추가 안전장치로 뒀다.
disabled를 boolean 하나로 공개하더라도 내부에서는 어떤 HTML 요소를 렌더링할지, 실행을 어느 층에서 막을지, 탭 순서에 남길지, 보조기기에 상태를 노출할지, opacity를 어느 요소에서 한 번만 적용할지가 함께 정해져야 했다.
disabled는 스타일만 고르는 값이 아니라 상호작용 계약 전체를 바꾸는 상태였다.
동작 계약을 타입으로 옮기자 pressTarget이 판별자가 됐다
여기까지는 pressTarget과 disabled에 따라 DOM 구조, 포커스, 접근 가능한 이름이 어떻게 달라져야 하는지 정했다. 이제 이 규칙이 호출부에서도 깨지지 않도록 타입으로 옮길 차례였다.
먼저 Trailing을 한 번 정리해야 했다. 행 전체가 동작 영역일 때 쓰는 buttonVisual은 시각적 표현이고, 우측 버튼은 실제로 조작할 수 있는 요소다. 둘을 같은 자리에 두면 차이가 드러나지 않는다. 그래서 조작할 수 있는지로 나눴다.
type TrailingVisual =
| { type: 'arrow' }
| { type: 'buttonVisual'; label: string }
| { type: 'badge'; tone: 'accent' | 'neutral' }
| { type: 'selection'; selected: boolean };
type TrailingControl = {
type: 'button';
label: string;
onPress: () => void;
};
type TrailingSpec = TrailingVisual | TrailingControl;Trailing이 비어 있는 경우는 union의 브랜치가 아니라 trailing을 넘기지 않는 것으로 표현한다. Leading에서 부재를 속성 밖으로 옮긴 것과 같은 형태다.
렌더 함수가 받는 union은 TrailingSpec 하나다. 여기에 assertNever를 두면 브랜치를 추가한 뒤 렌더 함수 수정을 빠뜨린 경우가 타입 오류가 된다.
function assertNever(value: never): never {
throw new Error(`지원하지 않는 Trailing 타입: ${String(value)}`);
}
function renderTrailing(spec: TrailingSpec | undefined) {
if (!spec) return null;
switch (spec.type) {
case 'arrow':
return <ArrowIcon />;
case 'buttonVisual':
return <ButtonVisual label={spec.label} />;
case 'selection':
return <SelectionIndicator selected={spec.selected} />;
case 'button':
return (
<TrailingButton label={spec.label} onPress={spec.onPress} />
);
default:
return assertNever(spec);
}
}badge 분기를 빠뜨린 채로 컴파일하면 spec이 never로 좁혀지지 않는다.
error TS2345: Argument of type
'{ type: "badge"; tone: "accent" | "neutral"; }'
is not assignable to parameter of type 'never'.각 동작 영역이 허용하는 Trailing은 이 union의 부분집합이다.
type RowPressTrailing = Extract<
TrailingVisual,
{ type: 'arrow' | 'buttonVisual' | 'badge' }
>;
type StaticTrailing = Extract<
TrailingVisual,
{ type: 'badge' | 'selection' }
>;정적 행에서는 arrow와 buttonVisual을 고를 수 없다. 아무 일도 하지 않는 화살표가 표현될 수 없는 이유가 여기에 남는다. 반대 방향은 제한하지 않았다. 화살표가 있으면 동작이 있어야 한다는 것이 계약이었지 동작이 있으면 화살표가 있어야 한다는 것은 아니었으므로, RowPressTrailing에 badge가 남아 있고 trailing 자체도 필수가 아니다.
이제 최종 형태다. 앞의 축약 선언을 각 동작 영역이 허용하는 Trailing 전체로 합치면 다음과 같다. 정적 행도 이때 브랜치로 추가했다. 선택 표시만 있고 행 자체에는 동작이 없는 형태를 그전까지 표현할 수 없었다.
type StaticInteraction = {
pressTarget?: 'none';
trailing?: StaticTrailing;
rowAction?: never;
};
type RowPressInteraction = {
pressTarget: 'row';
rowAction: RowAction;
trailing?: RowPressTrailing;
};
type TrailingPressInteraction = {
pressTarget: 'trailing';
trailing: TrailingControl;
rowAction?: never;
};
type RowAndTrailingPressInteraction = {
pressTarget: 'rowAndTrailing';
rowAction: RowAction;
trailing: TrailingControl;
};rowAndTrailing만 rowAction과 trailing.onPress를 함께 갖는다. 두 함수는 서로 독립적이고 하나가 다른 하나를 대신하지 않는다. 앞에서 따로 정의했던 ArrowInteraction은 별도 브랜치로 남지 않았다. arrow와 buttonVisual은 모두 행 전체가 동작 영역이고 rowAction이 필요하다는 같은 계약을 따르므로, 차이인 우측 표현만 trailing 안의 브랜치로 내려갔다.
나머지는 화면마다 달라지는 데이터와 상태다.
type ListRowRegularBase = {
// LeadingSpec은 앞 글에서 정한 Leading descriptor의 타입이다.
leading?: LeadingSpec;
label: string;
description?: string;
disabled?: boolean;
};
type ListRowRegularProps =
| (ListRowRegularBase & StaticInteraction)
| (ListRowRegularBase & RowPressInteraction)
| (ListRowRegularBase & TrailingPressInteraction)
| (ListRowRegularBase & RowAndTrailingPressInteraction);공통 부분을 각 브랜치에 분배했다. ListRowRegularBase & (StaticInteraction | ...)처럼 교차 타입 하나로 써도 결과는 같았다. pressTarget 좁히기도 동작했고 오류 메시지도 타입 별칭 이름 말고는 글자까지 같았다. 최상위를 union으로 쓴 것은 선언을 읽는 사람이 네 브랜치를 목록으로 보게 하려는 것이지 타입이 달라져서가 아니다.
Recipe 안에서는 pressTarget으로 좁힌다.
function ListRowRegularView(props: ListRowRegularProps) {
if (props.pressTarget === 'row') {
// props.rowAction은 RowAction으로 좁혀진다.
// props.trailing은 RowPressTrailing | undefined로 좁혀진다.
}
}축은 다섯인데 최상위 브랜치는 넷이다. 어떤 값이 브랜치를 만드는지는 하나의 기준으로 갈렸다. 그 값이 같은 객체 안에 있는 다른 키의 존재 여부를 바꾸는가다.
leading.type은name과src의 존재를 바꾼다. 다만 그 영향이leading객체 안에서 끝나므로 union도leading안에 생긴다.pressTarget은rowAction의 존재를 바꾼다.rowAction은leading과 같은 층에 있는 최상위 키다. 그래서 union이 최상위에 생긴다.disabled는 어떤 키의 존재도 바꾸지 않는다. 그래서 값이 두 개여도 브랜치를 만들지 않는다.
곱셈이 일어나는 범위도 여기서 정해졌다. 같은 객체 안의 축끼리만 곱해진다. leading과 trailing은 서로 다른 객체 안에 있으므로 3 × 4가 만들어지지 않는다.
정보 구조마다 Recipe를 나눈 것도 같은 문제를 다른 층에서 풀었다. 정보 구조가 정해지면 쓸 수 있는 Leading과 Trailing이 좁아지고 Bottom은 일부 형태에만 있었다. 제약이 있는 축은 속성 안으로 밀어 넣을 수 없어서 컴포넌트 이름으로 옮겼고, 그 결과 ListRowRegularProps에는 Bottom 축이 아예 없다.
pressTarget은 다른 최상위 키의 존재를 바꾸므로 최상위 union에 남았고, 그렇지 않은 축은 전부 descriptor 속성 안으로 내려갔다.
이 타입이 막지 못하는 자리도 하나 남았다. { type: 'arrow' }는 판별자 말고 요구하는 값이 없어서, pressTarget="row"와 rowAction을 이미 준 호출에서는 trailing에 React element를 넘겨도 통과한다. 4편에서 LeadingSpec의 판별자만 있는 브랜치를 없앤 이유가 그것이었는데, 같은 구멍이 arrow에 남았다. 아무 일도 하지 않는 화살표는 계속 막히지만 임의의 element 입력까지 막힌 것은 아니다. 이 글을 쓰면서 TypeScript 6.0.3과 @types/react 18.3.31 · 19.2.18에서 재현했고, React 타입 정의에 기대는 동작이라 그 범위 밖까지 일반화하지는 않는다.
같은 계약을 최상위 union으로 펼치면 브랜치가 42개였다
3편은 모든 축을 최상위 prop으로 펼친 형태를 기각했다. 축이 하나 늘 때마다 브랜치가 더해지는 것이 아니라 곱해진다는 것이 근거였고, 축 하나가 늘 때 둘이 넷, 넷이 여덟이 되는 데까지 셌다. 4편은 그 형태를 Flat union이라고 불렀다. 이제 최종 타입이 있으니 끝까지 셀 수 있다.
두 형태를 같은 조건에서 센다. 둘 다 지원하지 않는 조합을 타입 오류로 막는다. 그래서 최상위에 펼친 쪽에서는 브랜치 하나가 그 조합에서 쓰는 키를 전부 적어야 한다. leadingType과 pressTarget이 같은 객체에 있으면 아이콘 Leading과 이미지 Leading은 서로 다른 키 집합이라 다른 브랜치가 된다. 키를 전부 optional로 바꾸면 브랜치는 줄지만 아이콘과 이미지를 함께 준 호출도 표현할 수 있게 되어 조건이 달라진다.
세기 전에 하나 짚어둔다. 중첩한 타입에서도 42가지 조합은 그대로 표현한다. 줄어드는 것은 조합 수가 아니라 한 번에 읽고 수정해야 하는 최상위 브랜치 수다.
- 정적 행: 없음 ·
badge·selection= 3 - 행 전체: 없음 ·
arrow·buttonVisual·badge= 4, 여기에rowAction의navigation·command둘을 곱해 8 - 우측 버튼만:
button= 1 - 행과 우측 버튼:
button= 1, 여기에rowAction의navigation·command둘을 곱해 2
합이 14이고, 여기에 Leading 3가지를 곱하면 42다. 세 가지는 아이콘, 이미지, leading을 넘기지 않는 경우다. 앞 글에서 none 브랜치를 없애고 leading을 넘기지 않는 형태로 바꿨기 때문이다. 이 수는 값의 가짓수가 아니라 브랜치의 수다. 브랜치 하나가 그 조합에서 쓰는 키를 전부 적으므로 disabled를 켠 호출과 끈 호출은 한 브랜치에 함께 들어간다.
양쪽 모두 같은 42가지 조합을 표현한다. 최상위에 펼친 쪽은 그것을 브랜치 42개로 늘어놓고, 중첩한 쪽은 최상위 interaction 브랜치 4개와 속성 안의 union으로 나눠 갖는다.
새 표현을 추가할 때 필요한 수정 범위도 달랐다. 디자인 시스템이 지원하는 Trailing 표현을 하나 더 만들어 정적 행과 행 전체에서 쓸 수 있게 한다고 하자. 중첩된 타입에서는 TrailingVisual에 한 줄을 더하고 허용 목록 두 곳에 이름을 넣는다. 펼친 쪽에서는 브랜치 9개를 새로 써야 하고 42가 51이 된다.
조합을 제한한 대가는 오류 메시지와 구현 비용이었다
오류 메시지가 복잡해지고 문서의 역할이 커졌다
화살표를 요구하면서 동작을 주지 않은 행은 타입 검사에서 실패한다. 아래 메시지는 TypeScript 6.0.3에서 재현한 것이고, 반복해서 출력되는 props는 { ... }로 줄였다.
error TS2322: Type '{ label: string; pressTarget: "row";
trailing: { type: "arrow"; }; }' is not assignable
to type 'IntrinsicAttributes & ListRowRegularProps'.
Property 'rowAction' is missing in type '{ ... }'
but required in type 'RowPressInteraction'.행 전체가 동작 영역인데 우측에 선택 표시를 넣는 조합도 막힌다.
error TS2322: Type '{ ... }' is not assignable to type
'IntrinsicAttributes & ListRowRegularProps'.
Types of property 'trailing' are incompatible.
Type '{ type: "selection"; selected: boolean; }' is not
assignable to type 'RowPressTrailing | undefined'.
Types of property 'type' are incompatible.
Type '"selection"' is not assignable to type
'"arrow" | "buttonVisual" | "badge"'.첫 줄에 props 전체가 다시 출력되는 것은 값이 열거된 union에서 피하기 어려웠고, 원인을 말해주는 것은 그 아래 줄이다. 모든 오류가 이 정도로 읽히지도 않는다. pressTarget을 생략한 정적 행에 arrow를 주면 컴파일러는 네 브랜치 가운데 가장 가까운 하나를 고른 뒤 그 브랜치에 없는 속성을 나열한다. 실제 원인은 정적 행이 arrow를 받지 않는다는 것인데 메시지는 다른 브랜치를 가리킨다. 어느 브랜치가 선택되는지는 컴파일러의 판단이라 브랜치를 적은 순서나 버전이 바뀌면 달라질 수 있다.
API를 탐색하는 비용도 다른 곳으로 옮겨졌다. 최상위 브랜치는 짧아졌지만 무엇을 고를 수 있는지가 한 화면에 보이지 않는다. leading을 열어야 종류가 보이고, trailing의 목록은 pressTarget을 정한 뒤에야 좁혀진다. 가능한 조합을 최상위 타입 하나에서 훑기 어려워졌고, Storybook과 문서에서 조합을 따로 보여줘야 했다. Recipe의 개수가 늘어난 것도 같은 방향으로 작용했다. 정보 구조가 하나 늘 때마다 Recipe와 테스트도 함께 늘었다.
pressTarget마다 DOM이 달라지자 CSS도 나뉘었다
동작 영역에 따라 DOM 구조가 달라지면 CSS grid의 item 구성도 달라진다. 행 전체가 동작 영역인 경우에는 <a>가 grid container가 될 수 있다.
<a className="rowGrid">
<Leading />
<Content />
<Trailing />
</a>rowAndTrailing에서는 행의 주 동작 안에 Leading과 Content가 들어가고 우측 버튼은 바깥 형제로 남는다.
<div className="rowGrid">
<a className="primaryAction">
<Leading />
<Content />
</a>
<button className="secondaryAction">실행</button>
</div>Leading과 Content가 바깥 행 grid의 직접 자식이 아니라서, 같은 열 정렬을 유지하려면 동작 영역마다 별도의 CSS가 필요해진다.
작업 당시의 구현과 별개로, 지금 다시 설계한다면 행의 주 동작 요소에 subgrid를 적용해 바깥 행의 열 정의를 공유하는 방식을 검토하겠다. 당시에 적용하거나 검증한 구현이 아니라 지금의 제안이다.
아래 코드는 행의 주 동작이 navigation인 경우다. command라면 rowGrid 또는 primaryAction 클래스가 같은 위치의 <button>에 적용된다.
.row {
display: grid;
grid-template-columns:
auto
minmax(0, 1fr)
auto;
}
.primaryAction {
display: grid;
grid-template-columns: subgrid;
grid-column: 1 / 3;
}행이 자체 grid container를 가지므로 이 CSS는 행 내부에서 끝나고, 바깥 컨테이너가 행 내부의 열 정의를 알 필요는 없다.
이 구조는 Leading과 Trailing이 서로 독립적일 때만 유지된다
축을 중첩할 수 있었던 이유는 그 축들 사이에 실제 제약이 없었기 때문이다. 이미지 Leading에서는 뱃지를 함께 쓸 수 없다는 규칙이 새로 생기면, 두 축을 최상위 union으로 다시 펴거나 별도 Recipe로 나눠야 한다. 42가지 조합이 사라진 것은 아니다. Leading과 Trailing이 서로 독립적이라는 판단 아래 접혀 있을 뿐이다.
최상위에 펼친 형태를 기각한 이유는 축이 많다는 것이 아니라, 제약이 없는 축까지 같은 union에서 곱해진다는 것이었다. 이 구조는 Leading과 Trailing이 서로 독립적이라는 판단이 유지되는 동안에만 유효하다.
모양이 사용자에게 동작을 약속했기 때문이다
화살표 표시 여부와 버튼 문구, 비활성 상태를 각각 따로 받으면 API는 단순해 보인다.
<ListRow showArrow buttonLabel="실행" disabled />하지만 이 값들은 모양보다 더 많은 것을 결정했다. 화살표는 동작의 존재를 약속하고, 이동과 명령은 서로 다른 데이터와 HTML 요소를 요구한다. 버튼 모양이 <button>인지 시각 요소인지에 따라 tab stop이 달라지고, 행과 우측 버튼이 다른 동작을 가지면 두 요소는 DOM에서도 분리돼야 한다. 동작 영역이 달라지면 hover와 focus-visible의 범위가 달라지고, disabled는 포인터 입력뿐 아니라 키보드 실행, 링크 이동, 값 변경, 탭 순서, 보조기기 상태까지 바꾼다.
무엇이 보이느냐가 무엇을 누를 수 있느냐를 정하고, 그것이 다시 DOM과 접근 가능한 이름과 상태 표현의 범위를 정한다.
그래서 시각 요소와 동작을 같은 계약 안에 넣었다. 제품 개발자가 화살표와 버튼, 동작을 따로 조합하게 두는 대신 디자인 시스템이 지원하는 동작 영역을 고르게 했고, 그 선택이 필요한 동작 데이터와 DOM 구조, 접근 가능한 이름, 상태 표현의 범위를 함께 좁혔다.
그 계약을 타입으로 옮기면서 축은 세 자리로 나뉘었다. pressTarget은 rowAction이라는 다른 최상위 키의 존재를 바꾸므로 최상위 union의 판별자로 남았다. Leading과 Trailing은 서로의 존재를 바꾸지 않으므로 descriptor 속성 안에 두어 각 union이 그 속성 안에서 닫히게 했다. 정보 구조는 다른 축의 범위를 통째로 좁히므로 아예 컴포넌트 이름으로 옮겼다. 그 결과 축 조합 42가지를 그대로 표현하면서 한 번에 읽어야 하는 브랜치는 넷으로 남았고, 대신 무엇을 고를 수 있는지가 타입 하나에 보이지 않게 되어 문서와 Storybook이 그 몫을 가져갔다.
잘못된 UI는 디자인과 다른 색이나 간격에서만 생기지 않는다. 누를 수 있을 것처럼 보이지만 동작하지 않는 화살표, 독립된 버튼처럼 보이지만 어느 쪽도 포커스를 받지 않는 표현, 비활성처럼 보이지만 키보드로 실행되는 행도 정상적으로 렌더링되는 잘못된 UI다.
행 단위 공개 API에서 허용한 범위는 여기까지다. 반대로 구현할 수 있어도 제품 코드에는 공개하지 않은 prop이 있었다. 다음 글에서는 그 값을 공개하지 않은 기준을 다룬다.