List를 나눈 것은 모양이 아니라 '상태의 소유권'이었다
선택을 넣자 List가 ListRow의 우측 결정권까지 가져갔다
앞 글은 목록의 공간을 ListHeader와 Body 둘로 나누고 그 위에 표현 축 둘을 새로 열었다. divider와 surface다. 새 Recipe는 하나도 생기지 않았고, 1막의 질문 셋은 한 계층 위에서도 그대로 쓰였다.
셋째 후보 하나만 그 옆에 놓이기를 거부했다. 선택 가능한 목록이다.
자리는 그대로였다. 제목이 있고 행이 있고 비었을 때 문구가 있다. 같은 itemComponent를 쓰고 행이 보여주는 정보도 같다. 질문 3을 대면 답도 나온다. 정보 구조가 달라지지 않으니 기존 Recipe의 Variant다. 그런데 그 답을 따르면 선택이 divider와 같은 표현 분기가 된다.
이 글이 그 답을 다시 본다. 판정 자체는 한 줄로 끝나지만, 거기까지 가는 동안 1막이 세운 자에 눈금이 하나 빠져 있었다는 것이 드러난다. 그 자는 행을 보면서 만들어졌고, 행에서는 맞았다.
행마다 선택 모드를 고를 수 있으면 같은 그룹에서 정책이 갈린다
먼저 해볼 수 있는 것은 행에 여는 쪽이다. 이 행이 단일 선택인지 복수 선택인지를 행이 받는다.
<List
itemComponent={ListRowBasic}
items={[
{
id: 'now',
row: {
variant: 'default',
label: '즉시',
selectionMode: 'single',
},
},
{
id: 'daily',
row: {
variant: 'default',
label: '하루 한 번',
selectionMode: 'multiple',
},
},
]}
empty={emptyState}
/>컴파일은 정상이다. 화면에는 라디오 하나와 체크박스 하나가 나란히 놓인 목록이 그려진다.
행이 선택에 대해 아무것도 몰라야 한다는 말은 아니다. 1막에서 입력의 의미를 나눌 때 selected는 상태로, selectionMode는 정책으로 적었다. 어느 행이 지금 골라져 있는지는 그 행에 대한 사실이고, 몇 개까지 골라질 수 있는지는 그 행에 대한 사실이 아니다.
행이 상태를 드는 구조는 실제로 흔하다. MUI의 ListItemButton은 selected를 boolean으로 받고 그 값으로 스타일만 바꾼다. List 쪽에는 선택에 대한 prop이 없어서, 무엇이 골라졌는지도 몇 개까지 골라질 수 있는지도 전부 호출부에 남는다.
단일 선택이라는 것은 그 행에 대한 사실이 아니다. 여러 선택지 중 하나만 골라져 있어야 한다는 규칙이고, 그 규칙은 형제 행을 봐야 지킬 수 있다. 행 하나는 자기가 골라졌는지까지는 알아도 다른 행이 골라졌는지는 모른다.
앞 글에서 첫 행과 마지막 행을 다룰 때와 같은 모양이다. 그때 걸린 것은 기술적 가능 여부가 아니었다. 판단에 필요한 정보를 어느 계층이 갖고 있는가였다.
선택 정책은 이미 목록이 알고 있었다
질문 1을 대면 답은 빨리 나온다. 어느 행이 골라졌는지는 제품이 알고, 몇 개까지 골라질 수 있는지도 제품이 안다. 둘 다 공개다. 다만 그 값을 받는 자리가 행이 아니라 목록이다. 목록은 배열을 갖고 있고, 배열을 가진 쪽만 형제 행을 본다.
정책을 그룹이 드는 구조는 다른 시스템에도 있다. Ant Design의 Table은 rowSelection을 테이블의 prop으로 받고 그 안의 type이 체크박스인지 라디오인지를 정한다. Radix Primitives는 RadioGroup을 하나의 primitive로 두고 value와 onValueChange를 Root가 갖는다.
여기까지는 앞 글까지의 판정을 그대로 옮긴 것이다. 계층은 정해졌다.
남은 것은 어느 축인가다. 질문 3이 묻는 것이고, 후보는 둘이었다.
divider와surface옆에 축을 하나 더 둔다- 선택 가능한 목록을 별도 Recipe로 만든다
정보 구조로는 갈릴 이유가 없었다
새 Recipe인지를 재는 기준은 1막이 정해뒀다. 정보 구조가 달라지는가다. 그리고 정보 구조라는 말의 범위도 그때 함께 정했다. 어떤 자리가 필수가 되고 어떤 자리가 금지되는지, 그리고 어떤 계층의 정보가 몇 개 오는지다. ListRowRegular와 ListRowMax가 갈린 것도 후자에 Bottom이 필수라서였다.
선택 가능한 목록에 그 기준을 대면 전부 아니오가 나온다.
| 일반 목록 | 선택 가능한 목록 | |
|---|---|---|
| 자리 | ListHeader와 Body |
같다 |
| 새로 필수가 되는 자리 | 없다 | |
| 새로 금지되는 자리 | 없다 | |
| 행이 보여주는 정보 | 같은 Recipe의 정보 구조 | 같다 |
이 표가 말하는 것은 자리와 정보까지다. 공개 API가 통째로 같다는 뜻이 아니다. 선택을 넣으면 selectionMode가 붙고 선택값과 변경 통지가 붙는다. 입력은 분명히 늘어난다.
늘어난 입력이 전부 정보 구조의 변화라고 읽으면 질문 3은 언제나 새 Recipe라고 답한다. disabled가 붙어도 rowAction이 붙어도 새 Recipe가 된다. 1막이 정보 구조를 자리와 정보의 계층으로 한정한 이유가 이것이었다. 그 눈금으로 재면 선택 가능한 목록은 눈금 위에 올라오지 않는다.
앞 글이 카드로 묶는 형태를 새 Recipe로 만들지 않은 근거도 같은 표였다. 그러면 divider 옆에 두면 된다. 그런데 그렇게 두려니 나머지가 걸린다.
달라진 것은 구조가 아니라 소유였다
divider를 inset에서 full로 바꿀 때 목록에 일어나는 일은 하나다. 선의 시작점이 옮겨간다. 그 값을 바꿔도 목록이 새로 들고 있어야 할 것은 생기지 않는다.
선택을 넣으면 다섯 가지가 함께 일어난다.
- 목록이 선택 상태 계약을 받는다. 무엇이 골라졌는지를 값 하나로 받고 변경을 통지한다
- 목록이 행의 Trailing을 채운다. 그 자리에 무엇이 놓일지를 행 입력이 정하지 않게 된다
- 행을 누르는 것의 의미가 바뀐다. 다른 화면으로 가는 대신 이 항목이 골라진다
- 목록이 그룹으로서의 이름을 필요로 한다. 이 선택지들이 하나의 질문에 대한 답이라는 것을 알려야 한다
- 목록이 선택 규칙을 강제한다. 하나를 고르면 앞의 것이 풀리는 일은 형제 행을 봐야 할 수 있다
여기서 소유가 세 층으로 갈린다는 것을 먼저 정리해둔다. 값의 출처는 여전히 제품이다. 질문 1의 판정은 바뀌지 않는다. 목록은 그 값을 받아 행마다 계산해 내려주고, 행은 계산된 결과를 받는다. 아래에서 목록이 소유한다고 쓸 때 가리키는 것은 값의 출처가 아니라 계약을 어느 계층이 들고 있는가다.
divider나 surface를 바꿀 때는 이 중 아무것도 일어나지 않는다. 두 축은 이미 목록이 들고 있던 것의 표현을 고를 뿐이고, 선택은 목록이 드는 계약 자체를 바꾼다.
Recipe를 가르는 기준을 책임 모델까지 넓혔다
기준이 답을 못 낸 것은 아니었다. 재는 것이 하나 빠져 있었을 뿐이다.
1막에서 Recipe를 가르는 기준을 정보 구조로 쓴 데는 이유가 있다. 그때 보고 있던 것이 행이었기 때문이다. 행 Recipe를 서로 가른 축은 정보 구조였다. 행 하나가 여러 조작 계약을 갖는다는 것은 2막이 이미 다뤘지만, 그 계약을 채우는 쪽은 언제나 호출부였다. 그래서 무엇을 보여주는가만 물으면 충분했다.
목록에서는 갈린다. 자리가 같아도 그 계약을 누가 채우는지가 다를 수 있다.
그래서 기준을 이렇게 넓혔다.
정보 구조 또는 책임 모델이 달라지는 승인된 형태가 Recipe다.
책임 모델은 책임의 개수가 아니라 누가 어떤 상태와 슬롯과 행동을 소유하는가다. 다만 이렇게만 쓰면 눈금이 너무 무디다. variant: 'count'를 골라도 무엇이 필수가 되고 무엇이 금지되는지가 함께 정해지고, pressTarget을 골라도 슬롯과 행동이 함께 정해진다. 그것만으로 Recipe가 갈린다면 Variant가 남지 않는다.
그래서 판정을 이 자리로 좁힌다.
상태와 슬롯과 행동의 결과가 달라지는 것만으로는 Recipe가 갈리지 않는다. 그 차이 때문에 최상위 공개 경계에서 누가 입력을 공급하고 누가 나머지 계약을 완성하는지가 바뀌는지를 본다.
넓힌 기준이 앞의 판정들을 뒤집는지 확인해야 했다. 뒤집지 않는다.
| 앞의 판정 | 정보 구조 | 행 입력을 공급하는 쪽 | 나머지를 완성하는 쪽 | 결론 |
|---|---|---|---|---|
ListRowRegular와 ListRowMax |
다르다 | 호출부 | 없다 | 그대로 Recipe |
pressTarget의 네 브랜치 |
같다 | 호출부 | 없다 | 한 Recipe 안의 조작 계약 |
disabled |
같다 | 호출부 | 없다 | 그대로 상태 prop |
ListHeader의 세 형태 |
같다 | 호출부 | 없다 | 그대로 한 영역의 분기 |
divider와 surface |
같다 | 호출부 | 없다 | 그대로 Variant |
| 선택 가능한 목록 | 같다 | 호출부와 목록 | 목록 | Recipe |
pressTarget이 가장 아슬아슬한 줄이다. 네 브랜치는 행의 DOM과 tab stop 개수와 허용되는 Trailing까지 바꾼다. 그래도 별도 Recipe가 아닌 이유는 마지막 두 칸에 있다. 어느 브랜치를 골라도 그 값을 채우는 것은 호출부이고, 행이 받을 props 중 남는 자리가 없다.
기준이 넓어졌다는 것은 갈릴 수 있는 근거가 하나 늘었다는 뜻이지 이미 갈린 것이 합쳐진다는 뜻이 아니다. 행에서는 정보 구조가 Recipe를 가르고, 목록에서는 누가 행의 계약을 완성하는가가 가른다.
그래서 목록을 둘로 갈랐다
이름은 StandardList와 SelectableList로 뒀다. 앞 글까지 List라고만 부른 것이 StandardList가 된다. 자리도 축도 계약도 그대로이고, 갈릴 상대가 생겨서 이름이 붙었을 뿐이다.
type ListSelection =
| {
selectionMode: 'single';
value: string | null;
onValueChange: (value: string) => void;
}
| {
selectionMode: 'multiple';
value: readonly string[];
onValueChange: (
value: readonly string[],
) => void;
};여기에 'none'이 없다. 선택하지 않는 목록은 StandardList이고, 그 컴포넌트에는 selectionMode라는 prop 자체가 없다.
타입이 못 하는 일이라서 가른 것은 아니다. 세 값을 한 컴포넌트의 최상위 union으로 두는 안도 써봤다. none 브랜치에서 value와 onValueChange를 ?: never로 막고, 브랜치마다 items의 원소 타입을 다르게 두면 된다. 실제로 컴파일하면 승인되지 않은 조합은 양쪽 다 막힌다. 선택 목록의 행 입력에 rowAction을 넣는 호출도, selectionMode 없이 value만 넘기는 호출도 오류가 난다.
둘의 차이는 다른 데 있었다.
하나의 List union |
StandardList · SelectableList |
|
|---|---|---|
| 승인되지 않은 조합 차단 | 된다 | 된다 |
선택으로 바꿀 때 items 재매핑 |
필요하다 | 필요하다 |
| 호출부에서 바뀌는 것 | prop 하나 | 컴포넌트 이름과 import |
| 소유권이 바뀐다는 사실 | 브랜치 안에 있다 | 이름에 있다 |
| 문서와 학습 | 진입점 하나 | 둘 |
이름을 늘리는 쪽을 골랐다. 근거는 마지막 두 줄이다. selectionMode 하나를 바꾸는 것처럼 보이는 자리에서 실제로는 목록이 행의 계약을 대신 채우기 시작하는데, 그 사실이 union 브랜치 안에만 있으면 호출부에서 보이지 않는다. 대가는 이름 둘과 문서 둘, 그리고 두 컴포넌트를 오갈 때의 전환 비용이다. 뒤에서 그 전환이 무엇을 요구하는지 다시 나온다.
SelectableList가 드는 계약 중에는 아직 형태를 정하지 않은 것이 하나 있다. 그룹의 이름이다. ListHeader의 제목을 쓸 수도 있고 별도 입력을 받을 수도 있는데, 앞 글에서 제목이 화면의 heading인지 목록의 접근 가능한 이름인지 시각 라벨인지를 정하지 않고 남겨뒀다. 그 답이 나와야 이 자리도 정해진다.
single과 multiple은 왜 다시 갈리지 않았나
기준이 넓어졌으니 그 안에서 한 번 더 갈릴 수 있는지도 물어야 한다.
먼저 여기서 multiple이 무엇을 가리키는지 좁혀둔다. 서로 독립적인 체크박스 여러 개가 아니다. 하나의 질문 아래 여러 값을 고르고, 목록이 골라진 id 집합을 한 번에 받아 변경을 통지하는 승인된 형태다. 독립적인 설정 스위치들은 그냥 각자 상태를 든 행이고 StandardList에 놓인다.
그 범위 안에서는 single과 multiple의 책임 모델이 같다. 둘 다 목록이 선택 상태 계약을 받고, 둘 다 행의 Trailing을 채우고, 둘 다 행을 누르면 선택이 된다. 그룹의 이름도 양쪽 다 필요하다. 달라지는 것은 그 안의 규칙과 표현이다. 하나를 고르면 앞의 것이 풀리는지, 우측에 무엇이 그려지는지.
그래서 Variant다. 판별자는 selectionMode이고, 그 값을 고르면 value의 형태가 함께 정해진다.
같은 모양을 1막에서 이미 봤다. variant: 'count'를 고르면 description이 금지되고 count가 필수가 된다. 여기서는 single을 고르면 value가 string | null이 되고 multiple을 고르면 readonly string[]이 된다.
<SelectableList
itemComponent={ListRowBasic}
items={items}
empty={emptyState}
selectionMode="single"
value={['now']}
onValueChange={setValues}
/>error TS2322: ...
Types of property 'value' are incompatible.
Type 'string[]' is not assignable
to type 'string'.같은 자리를 같은 방식으로 처리한 시스템도 있다. SwiftUI의 List는 selection 바인딩을 받는데, 값 하나를 넘기면 단일이고 집합을 넘기면 복수다. 컴포넌트가 갈리지 않고 바인딩의 형태가 갈린다.
1막에서 이 시리즈의 Variant가 스타일 도구의 variant보다 넓다고 밝히고 시작한 이유가 여기 있다. 분기를 고르는 것이 데이터 계약까지 좁히는 경우를 Variant에 포함시켰고, selectionMode가 그 사례다.
선택 표시는 하필 행이 열어둔 자리에 나타났다
목록 층의 판정은 여기서 끝난다. 그런데 이 결정이 행에 닿는다.
1막에서 영역을 나눌 때 한 문장을 미뤄뒀다. 선택 표시가 Trailing 자리에 보일 수는 있지만 선택 정책 자체를 Content의 종류로 보지는 않았고, 그 판단이 어디까지 버티는지는 목록 전체를 제한할 때 다시 확인하겠다는 것이었다.
그 판단은 버텼다. 선택은 Content의 종류가 되지 않았고 목록의 책임 모델로 갈렸다. 대신 다른 것이 걸린다.
2막에서 ListRowBasic은 trailing을 공개했다. 승인된 값 목록이 있고 그중 하나를 호출부가 고른다. arrow, buttonVisual, badge, button, 그리고 선택 표시다.
선택 목록에서는 그 자리에 무엇이 오는지를 호출부가 고르지 않는다. 목록의 selectionMode가 정한다.
| 목록 | 행의 Trailing | 행을 눌렀을 때 |
|---|---|---|
StandardList |
호출부가 고른 값 | rowAction 실행 |
SelectableList single |
라디오 | 이 항목이 골라진다 |
SelectableList multiple |
체크박스 | 이 항목이 토글된다 |
같은 ListRowBasic이 이동 표면이었다가 선택 컨트롤이 된다. 제품이 넘긴 표시 데이터는 같은데 그렇다. 행 입력 전체가 같은 것은 아니다. StandardList의 화살표 행에는 pressTarget과 rowAction과 trailing이 함께 있어야 하고, 선택 목록에서는 제품이 그 셋을 넘기지 않는다.
여기서 회수하지 못한 것을 하나 적어둔다. 선택 표시는 2막이 정한 공개 Trailing 값 목록에 여전히 들어 있고, 정적 행의 값으로 남아 있다. 그래서 StandardList에 눌리지 않는 선택 표시를 넣는 호출은 지금도 컴파일된다. 이 글이 회수한 결정권은 SelectableList 경계 안에서다. 시스템 전체에서 선택 표시가 선택 목록에만 나타나게 하려면 그 값을 공개 TrailingSpec에서 빼고 목록이 주입하는 값으로 옮겨야 하는데, 그러면 2막이 정한 행의 공개 계약을 다시 여는 일이 된다. 눌리지 않는 선택 표시가 승인된 형태인지부터 질문 3으로 분류해야 할 일이라 여기서 정하지 않았다.
선택 전용 행 Recipe를 만들지 않았다
SelectableListRowBasic을 만들면 위 문제가 사라진다. 그 Recipe는 trailing을 공개하지 않고 선택 표시를 자기가 그린다.
만들지 않은 이유는 방금 좁힌 판정에 있다. 선택 목록에 놓인 행의 정보 구조는 그대로다. label과 description이 있고 count가 붙는 형태가 있다. 그리고 행의 공개 계약도 그대로다. 달라진 것은 그 계약을 호출부가 채우던 자리를 목록이 채운다는 것이고, 그건 행 쪽에서 일어난 변화가 아니다.
행에서 Recipe를 가르는 것은 여전히 정보 구조다. 목록의 책임 모델이 달라졌다고 해서 행까지 갈리지는 않는다.
비용도 세어봐야 했다. 행 Recipe가 셋이니 선택형 판을 만들면 여섯이 된다. 목록에 요구가 하나 더 붙을 때마다 그 수가 다시 곱해진다.
문서에 적고 관례로 두는 길도 있었다. 선택 목록에서는 trailing을 넘기지 마세요, 라고. 2막 전체가 그런 문장을 타입으로 옮기는 이야기였으니 여기서 되돌릴 수는 없었다.
남은 후보는 둘이다. 행의 입력에서 그 필드를 빼거나, 애초에 행이 받는 것을 둘로 나누거나.
trailing만 빼려 하자 브랜치 사이의 관계가 풀렸다
빼는 쪽을 먼저 써봤다. 선택 목록이 받는 입력은 행 props에서 조작 계약과 trailing을 제외한 것이다.
type SelectableRowInput = Omit<
ListRowBasicProps,
'pressTarget' | 'rowAction' | 'trailing'
>;컴파일된다. 그리고 이 타입은 디자인에 없는 조합을 통과시킨다.
const input: SelectableRowInput = {
variant: 'default',
label: '즉시',
count: 3,
};ListRowBasicProps는 판별자 union이다. Omit은 union의 각 브랜치에 분배되지 않고, 브랜치 전체에 공통으로 있는 키만 남긴 객체 하나로 접는다.
// 접힌 결과
{
variant: 'default' | 'count';
label: string;
description?: string;
count?: number;
disabled?: boolean;
}variant가 남아 있지만 판별자 노릇을 못 한다. 'default'를 골라도 count가 함께 올 수 있다. 1막이 ?: never로 막아둔 조합이 여기서 되살아난다.
분배되는 Omit을 직접 만들면 이 문제는 사라진다.
type DistributiveOmit<T, K extends PropertyKey> =
T extends unknown ? Omit<T, K> : never;같은 객체가 이제 막힌다.
error TS2322: ...
Types of property 'count' are incompatible.
Type 'number' is not assignable
to type 'undefined'.그래서 이쪽도 타입으로 막을 수 있다. 이 안을 고르지 않은 이유는 안전성이 아니라 방향이었다.
Omit은 행 props에서 키 이름을 빼는 제외 목록이다. 그 목록을 아는 쪽이 목록 컴포넌트라서, 행 Recipe에 조작 계약 키가 하나 늘면 목록 쪽 제외 목록을 함께 고쳐야 하고 안 고쳐도 컴파일은 된다. 그리고 그 목록은 Recipe마다 다시 써야 한다. ListRowMax의 조작 계약은 ListRowBasic과 키가 같지 않다.
반대 방향도 있다. Recipe를 정의하는 자리에서 제품이 공급하는 입력이 무엇인지를 한 번 선언하는 것이다. 그러면 제외 목록이 필요 없고, 그 선언을 여러 상위 컴포넌트가 함께 쓴다. 어느 쪽이든 선언을 옳게 유지하는 사람이 있어야 한다는 것은 같다. 다른 것은 그 선언이 무엇에 붙어 있는가다.
빼야 한다는 것 자체가 신호였다. 행이 받는 것 안에 공급자가 다른 둘이 섞여 있다는 뜻이다.
제품이 공급하는 입력과 목록이 완성하는 계약을 갈랐다
3막을 열면서 봉투 안의 row를 이렇게 설명했다. 그 행이 무엇을 보여줄지와 무엇을 할지까지가 row이고, 몇 번째인지는 목록이 안다.
그 문장을 여기서 다시 가른다. 무엇을 보여줄지와 무엇을 할지는 한 덩어리가 아니었다.
가르는 축은 데이터냐 동작이냐가 아니다. 1막이 이미 데이터와 상태와 행동과 정책을 갈라뒀고, disabled는 그중 상태다. 그런데 disabled는 선택 목록에서도 제품이 넘긴다. 어느 선택지를 고를 수 없는지는 제품만 안다. 축은 누가 공급하는가다.
type ListRowBasicInput =
| {
variant: 'default';
label: string;
description?: string;
count?: never;
disabled?: boolean;
}
| {
variant: 'count';
label: string;
count: number;
description?: never;
disabled?: boolean;
};
type ListRowBasicProps =
| (ListRowBasicInput & StaticInteraction)
| (ListRowBasicInput & RowPressInteraction)
| (ListRowBasicInput & TrailingPressInteraction)
| (ListRowBasicInput & RowAndTrailingPressInteraction);2막에서 이 조각의 이름은 공통 부분이라는 뜻의 Base였다. 브랜치마다 같은 필드를 반복해 적지 않으려고 뽑은 것이었고, 그때는 그 조각이 무엇인지 물을 일이 없었다. 여기서 그것이 이름이 된다.
앞 글의 브랜드는 이 중 뒤엣것 하나만 들고 다녔다. ListRowComponent<P>이고 P가 행 props 전체였다. 목록이 제품 입력만 필요할 때 꺼내 쓸 것이 없어서 Omit이 나온 것이다.
그래서 브랜드가 둘 다 들게 했다.
declare const rowContract: unique symbol;
type ListRowComponent<Input, Props> =
ComponentType<Props> & {
readonly [rowContract]: {
input: Input;
props: Props;
};
};
type RowInputOf<C> =
C extends ListRowComponent<infer I, any>
? I
: never;
type RowPropsOf<C> =
C extends ListRowComponent<any, infer P>
? P
: never;Recipe를 만드는 helper도 둘을 함께 받는다.
function defineListRow<I, P extends I>(
component: ComponentType<P>,
): ListRowComponent<I, P> {
return component as ListRowComponent<I, P>;
}
const ListRowBasic = defineListRow<
ListRowBasicInput,
ListRowBasicProps
>(function ListRowBasic(props) {
return <BasicRow {...props} />;
});P extends I가 이 helper가 지키는 관계다. 제품 입력은 props의 부분집합이고, 그 반대는 성립하지 않는다.
이제 두 목록이 같은 Recipe에서 서로 다른 절반을 꺼낸다.
type StandardListProps<
C extends ListRowComponent<any, any>,
> = {
itemComponent: C;
items: readonly ListItem<RowPropsOf<C>>[];
empty: ListEmptySpec;
header?: ListHeaderSpec;
divider?: 'none' | 'inset' | 'full';
surface?: 'plain' | 'card';
};
type SelectableListProps<
C extends ListRowComponent<any, any>,
> = {
itemComponent: C;
items: readonly ListItem<RowInputOf<C>>[];
empty: ListEmptySpec;
header?: ListHeaderSpec;
divider?: 'none' | 'inset' | 'full';
surface?: 'plain' | 'card';
} & ListSelection;선언은 한 줄만 다르다. StandardList는 행이 공개한 것을 그대로 통과시키고, SelectableList는 제품 입력만 받는다.
호출부에서는 한 줄이 아니다. 이미 쓰던 목록을 선택형으로 바꾸려면 각 item에서 pressTarget과 rowAction과 제품이 고른 trailing을 걷어내야 하고, 이동 대상과 handler를 화면 쪽으로 옮겨야 한다. 컴포넌트 이름만 바꾸는 전환이 아니라 제품이 채우던 조작 계약을 목록에 넘기는 이관이다. 앞 절에서 이름을 늘리는 대가라고 적은 것이 이것이고, 타입이 그 이관을 빠짐없이 짚어준다는 점이 그나마 값이었다.
나머지 절반은 목록이 채운다.
{items.map(({ id, row }) => (
<li key={id}>
<ListRowBasic
{...row}
trailing={{
type: 'selection',
selected: value === id,
}}
/>
</li>
))}선택 상태를 입력에 직접 넣으려는 시도는 타입에서 막힌다.
items={[
{
id: 'now',
row: {
variant: 'default',
label: '즉시',
selected: true,
},
},
]}error TS2353: Object literal may only specify known
properties, and 'selected' does not exist in type
'{ variant: "default"; label: string;
description?: string | undefined;
count?: undefined; disabled?: boolean | undefined; }'.1막에서 selected를 상태로 분류하면서 단서를 하나 달아뒀다. 여러 행이 모인 뒤에도 그 값을 행이 그대로 받는지 목록이 계산해 내려주는지는 행 하나만 보고 있어서는 답할 수 없다는 것이었다. 여기서 답이 나온다. 목록이 값 하나를 받고 행마다 계산해 내려준다. 양쪽이 각자 들면 두 벌이 되고, 두 벌은 어긋날 수 있다.
rowAction도 같은 자리에서 막힌다. 그래서 선택 목록에서 행을 눌러도 상세 화면으로 가지 않는다. 이건 화면마다 다시 내리는 정책이 아니라 타입의 결과다. 행이 받은 계약에 조작이 없고, 누르는 동작은 목록이 감싼 네이티브 컨트롤이 가져간다.
슬롯이 열려 있다는 것과 모든 맥락에서 그 슬롯의 결정권까지 열려 있다는 것은 다르다. ListRowBasic은 trailing을 공개하는 Recipe이고, 그 사실은 변하지 않았다. 어느 목록에 놓이느냐에 따라 그 결정권을 호출부가 갖기도 하고 목록이 갖기도 한다.
브랜드가 증명하지 않는 것
여기까지 쓰고 나면 타입이 이 구조를 보장한다고 읽기 쉬운데, 브랜드가 실제로 드는 것은 그보다 좁다.
P extends I가 말하는 것은 제품 입력의 필드가 props 안에 있다는 것까지다. 제품 입력에 선택 표시를 얹으면 props가 완성된다는 것은 말하지 않는다. 그래서 SelectableList를 제네릭으로 구현하면 그 자리에서 타입 검사가 걸린다.
error TS2769: No overload matches this call.
Type 'RowInputOf<C> & { trailing: ... }'
is not assignable to type 'RowPropsOf<C>'.지금 구현은 그 자리에 단언을 두고 있다. 위에서 본 ListRowBasic 호출이 통과한 것은 Recipe가 구체적으로 정해졌을 때의 이야기이고, 임의의 C에 대해서는 성립하지 않는다. 같은 이유로 ListRowMax가 걸러지지 않는다. Trailing이 Arrow로 고정된 Recipe라 선택 표시를 받을 자리가 없는데, SelectableList의 제네릭 제약은 그것을 거르지 못한다.
지금 다시 보면 브랜드가 증거를 함께 들게 하는 형태가 후보다. Recipe를 정의할 때 제품 입력에 선택 표시를 얹어 props를 완성하는 함수를 함께 등록하는 것이다.
declare const selectableRow: unique symbol;
type SelectionInjection = {
trailing: {
type: 'selection';
selected: boolean;
};
};
type SelectableRowComponent<I, P> =
ListRowComponent<I, P> & {
readonly [selectableRow]: (
input: I & SelectionInjection,
) => P;
};이렇게 두면 제네릭 구현이 단언 없이 통과한다. 그리고 ListRowMax는 그 함수를 만들 수 없어서 Recipe를 정의하는 자리에서 거부된다.
error TS2322: Type 'MaxInput & SelectionInjection'
is not assignable to type 'MaxProps'.
Property 'rowAction' is missing in type
'MaxInput & SelectionInjection'.오류가 목록 호출부가 아니라 Recipe 선언에 붙는다는 것이 이 형태의 값이다.
당시에는 승인된 조합에 선택형 ListRowMax 화면이 없어서 여기까지 가지 않았다. 없는 화면을 근거로 삼은 것이라 그 화면이 생기면 다시 열릴 자리다.
네이티브 radio와 checkbox를 골랐다
선택 목록을 만들 때 흔한 답이 하나 있다. 목록에 role="listbox"를 붙이고 각 행을 role="option"으로 두고, 포커스는 한 곳만 남기고 화살표 키로 옮기는 방식이다.
고르지 않았다.
일반 정보 목록과 composite widget은 다른 것이다. 후자는 목록 전체가 하나의 조작 대상이 되고, 키보드 모델도 보조기기가 안내하는 내용도 그에 맞춰 바뀐다. 우리가 만들던 것은 설정 화면의 선택지 목록이었고, 폼 안에 놓여 제출되는 값이었다. name과 value가 그대로 필요했다. 승인된 선택 목록의 항목 수도 대개 열 개 아래였다.
WAI-ARIA 저작 안내서가 그 자리에서 하는 말이 있다. 역할은 약속이라는 것이다. role을 붙이면 그 역할이 기대하게 만드는 키보드 동작까지 함께 구현해야 하고, 지키지 못한 약속은 없느니만 못하다.
이 문장이 뒷받침하는 것은 listbox를 고를 때 따라오는 구현 책임까지다. 네이티브를 골라야 한다는 말은 아니다. 그쪽을 고른 근거는 앞 문단의 제품 조건이고, 안내서는 반대쪽 선택지의 값을 재는 데 쓰였다.
내준 것도 있다. listbox는 목록 전체가 하나의 조작 대상이라 Tab 순서에 자리를 하나만 차지하고, 화살표로 항목 사이를 연속해서 옮길 수 있으며, 어느 항목이 지금 활성인지를 위젯 하나가 관리한다. 체크박스 그룹에서는 항목마다 Tab 자리가 생긴다. 라디오 그룹은 브라우저가 그룹을 한 덩어리로 다뤄서 그렇지 않은데, 화살표 키가 그룹 안에서 정확히 어떻게 도는지는 브라우저와 WebView마다 차이가 남아 있다.
항목이 많아지면 이 계산은 뒤집힐 수 있다. 승인된 목록이 짧아서 감수한 비용이고, 목록이 길어질 때 무엇이 달라지는지는 4막에서 다시 나온다.
여기까지가 이 글이 정하는 범위다. 선택 목록이 그룹으로서의 이름을 필요로 한다는 것은 책임이고, 그 이름을 어떤 요소로 실현할지는 다른 층의 질문이다. 세 플랫폼이 같은 계약을 공유하는 디자인 시스템에서 그 둘이 어떻게 갈리는지가 다음 글의 주제다.
행이 여는 자리와 목록이 허용하는 자리는 같지 않았다
이 글에서 내린 결정을 모으면 이렇다.
| 결정 | 누가 아는가 | 입력의 의미 | 구조에서의 역할 |
|---|---|---|---|
| 선택 가능한 목록이다 | 제품 | 승인된 형태 선택 | Recipe (SelectableList) |
selectionMode |
제품 | 정책 | Variant 판별자 |
value |
제품 | 상태 | 판별자가 형태를 정하는 prop |
onValueChange |
제품 | 행동 | 판별자가 형태를 정하는 prop |
행의 disabled |
제품 | 상태 | 제품이 공급하는 행 입력 |
| 행의 Trailing에 무엇이 오는가 | 목록이 계산 | 해당 없음 | 내부 규칙 |
| 행을 누르면 무엇이 일어나는가 | 목록이 계산 | 해당 없음 | 내부 규칙 |
표에 없는 것이 이 글의 절반이다. 선택 전용 행 Recipe를 만들지 않았다. 목록이 책임 모델로 갈렸다고 해서 행까지 갈리지 않았다. 행 Recipe가 무엇을 공개하는가와, 그 Recipe가 어느 목록에 놓였을 때 그 공개된 자리를 누가 채우는가는 다른 결정이다.
기준 쪽에서는 하나가 넓어졌다. 1막이 Recipe를 정보 구조로 정의했고, 그 정의는 행을 보면서 만들어졌다. 계층이 올라가자 자리는 같은데 계약을 채우는 쪽이 다른 사례가 나왔고, 정보 구조 또는 책임 모델로 넓혔다. 넓힌 만큼 눈금이 무뎌지지 않도록 판정을 최상위 공개 경계 하나로 좁혔다.
받아둔 빚도 있다. 정적 선택 표시는 아직 StandardList에서도 표현되고, 어떤 Recipe가 선택형 주입을 받을 수 있는지는 브랜드가 들고 있지 않다. 둘 다 승인된 조합에 해당 화면이 없어서 미뤄둔 것이라, 화면이 생기면 다시 열린다.
기준이 한 번 넓어졌다는 것은 다음에 또 그럴 수 있다는 뜻이기도 하다. 여기까지 온 계약은 행 안에서도 행 사이에서도 승인된 조합만 표현되게 만들었다. 다음 막은 그 계약을 다른 경계에 대보는 여섯 편이다.
첫 경계는 플랫폼이다. 지금까지 정한 것 중에는 <a>와 <button>을 가른 것도 있고, 목록 시맨틱을 List에 둔 것도 있고, 방금 라디오를 고른 것도 있다. 세 플랫폼이 같은 Figma를 보고 만들었는데, 그 결정들은 공통 명세에 한 줄도 없었다.