ListRow에서 List로 올라와도 새 Recipe는 생기지 않았다
목록의 공간을 제목과 본문으로 나누고 그 위에 구분선과 배경 두 축을 세우기까지
앞 글에서 List는 children을 담는 컨테이너가 아니게 됐다. Recipe 하나와 입력 배열을 받아 목록 안의 행 형태를 고정하고, 순서에서 나오는 규칙과 목록 시맨틱을 소유하는 컴포넌트가 됐다.
정해진 것은 경계였다. 무엇을 List가 소유하고 무엇을 행이 소유하는지까지다. 그 경계 안에 무엇이 들어 있는지는 아직 나누지 않았다.
1막에서 행 하나만 봐서는 판단할 수 없다고 적어둔 요구가 다섯 있었다. 그중 하나는 앞 글이 가져갔다. 한 목록 안에서 행 형태가 섞이지 않는다는 것이고, itemComponent 하나를 받는 구조가 그 답이었다. 넷이 남는다.
- 행과 행 사이 구분선은 좌측 아이콘 폭만큼 들여쓴다
- 마지막 행 아래에는 구분선을 두지 않는다
- 목록 전체가 하나의 카드로 묶이는 형태가 따로 있다
- 항목이 하나도 없을 때 자리를 대신하는 화면이 있다
넷을 바로 prop으로 옮길 수도 있었다. 들여쓰기 값과 카드 여부와 빈 상태 문구를 각각 열면 화면은 그려진다. 행을 그렇게 열었을 때 무슨 일이 생기는지는 이미 봤다. 승인되지 않은 조합이 정상적인 코드로 표현되고, 그 판단이 화면마다 다시 일어난다.
넷이 같은 종류가 아니라는 것도 걸렸다. 앞의 셋은 행이 놓인 뒤에 걸리는 규칙이고, 마지막 하나는 행이 하나도 없을 때의 이야기다.
행에서 했던 순서를 그대로 밟기로 했다. 자리를 먼저 나누고, 그 위에 무엇을 열지 묻는다.
목록에서 행이 아닌 것을 모으자 자리가 하나 더 나왔다
앞 글까지 List가 가진 자리는 하나였다. 같은 Recipe의 행이 순서대로 놓이는 자리이고, itemComponent와 items가 그 자리의 계약이었다.
남은 넷을 그 자리에 대보면 셋은 들어가고 하나는 들어가지 않는다. 구분선과 카드는 행이 놓인 자리에 걸리는 규칙이다. 빈 상태는 행이 하나도 없을 때 그 자리에 대신 놓이는 것이라, 같은 자리를 쓰지만 같은 계약을 쓰지 않는다.
그리고 목록 층 요구에는 없는데 화면에는 늘 있던 것이 하나 더 있었다. 목록 위의 제목이다.
앞 글은 그것을 화면에 남겼다. 섹션이 필요한 화면에서는 List를 여러 개로 나누고, 각 섹션의 제목은 화면이 그렸다.
<section>
<h2>계정</h2>
<List
itemComponent={ListRowBasic}
items={accountItems}
/>
</section>그 결정을 뒤집지 않는다. 여러 목록을 가르는 구조는 지금도 화면의 몫이다. 다시 연 것은 제목 하나와 목록 하나 사이다. 이 코드에는 아무도 정하지 않은 값이 몇 개 있다.
<h2>와 첫 행 사이의 간격- 제목 아래에 구분선이 있는지
- 목록이 카드로 묶일 때 제목이 카드 안인지 밖인지
- 제목 오른쪽에 액션이 붙을 때 그 정렬 기준
전부 제목과 목록의 관계에서 나온다. 어느 한쪽만 봐서는 정해지지 않고, 그래서 목록이 그려지는 화면마다 다시 정해진다. 앞 글의 기준이 여기에도 걸린다. 그 결정이 화면마다 다시 내려지면 안 된다.
제목을 목록의 자리로 볼지는 시스템마다 갈렸다. 지금은 사용을 권하지 않는 Ant Design의 List는 header를 목록 바깥의 전용 영역으로 렌더했고, split이 켜지면 그 아래에 선을 넣었다. MUI의 List는 subheader를 목록의 항목 자리에 두고 기본값으로 sticky를 건다. Chakra의 List에는 그 슬롯이 없고, Radix에는 목록 컴포넌트 자체가 없다.
우리는 여는 쪽을 골랐다. 저 값들을 아무도 소유하지 않으면 화면마다 다르게 정해지고, 그것이 1막에서 세기 시작한 비용이다. 다만 이 결정이 여는 것은 제목 하나와 목록 하나의 관계까지다. 여러 목록을 묶는 <section>과 그 안의 순서는 그대로 화면이 소유한다.
그래서 목록의 공간을 둘로 나눴다.
List
├─ ListHeader
└─ Body
├─ Rows
└─ ListEmptyListHeader가 위에 있고 그 아래가 Body다. Body에는 배열이 있으면 Rows가 놓이고, 비어 있으면 ListEmpty가 놓인다. 둘은 같은 공간을 번갈아 쓰지만 입력 계약이 다르다. 앞의 것은 Recipe 하나와 items를 받고, 뒤의 것은 그 화면만 아는 빈 상태 문구를 받는다.
자리를 늘리는 비용은 행에서 자리를 나눌 때 한 번 셌다. 영역이 하나 생기면 그 자리에 올 수 있는 값의 목록이 생기고, 어떤 형태에서 필수이고 어떤 형태에서 금지인지가 생기고, 다른 영역과의 허용 조합이 생긴다. 여기서 는 것은 자리 하나뿐이고, ListHeader는 아래에 무엇이 놓이든 그대로 있어서 조합을 따질 자리도 좁다.
제목과 빈 상태를 목록의 자리로 두면 마크업에서 갈리는 것이 하나 있다. <ul>의 콘텐츠 모델은 <li>와 스크립트 지원 요소만 자식으로 허용한다. 둘을 <ul> 안에 두는 것이 불가능하지는 않다. <li>로 감싸면 된다. 다만 그렇게 감싼 것은 접근성 트리에서 목록의 항목이 된다.
MUI는 그 길을 갔다. ListSubheader는 실제로 <li>를 렌더한다. 목록의 머리를 목록의 항목 중 하나로 두는 것도 하나의 답이다.
우리가 제목과 빈 상태를 <ul> 바깥에 둔 것은 거기 둘 수 없어서가 아니라, 그것들이 항목으로 세어지지 않기를 바랐기 때문이다.
ListHeader는 Recipe도 Variant도 아니었다
자리를 만들었으니 그 자리에 무엇이 오는지 정해야 한다. 그 전에 이 자리가 무엇인지부터 갈렸다.
질문 3을 대면 답이 나오지 않는다. 새 Recipe인가. Recipe는 승인된 형태이고 ListRowBasic 옆에 놓이는데, 헤더는 행을 그리지 않는다. 기존 Recipe의 Variant 축인가. 그러면 무엇의 축인가. 헤더가 있고 없고는 승인된 표현 중 하나를 고르는 일이 아니라 그 화면에 제목이 있느냐다.
1막에서 같은 답이 한 번 나왔다. Leading도 Recipe가 아니고 Variant가 아니었다. 영역이고, 그 자리에 올 수 있는 값의 목록을 가진다.
ListHeader도 그랬다. 승인된 형태는 셋이었다.
| 형태 | 무엇이 놓이나 |
|---|---|
title |
제목만 |
count |
제목과 그 오른쪽의 숫자 |
action |
제목과 그 오른쪽의 부가 액션 |
고른 형태에 따라 함께 요구되는 값이 달라진다. 2막에서 Leading을 정할 때 세운 기준으로는 개별 prop이 아니라 descriptor가 맡을 자리다.
type ListHeaderAction = {
label: string;
onPress: () => void;
};
type ListHeaderSpec =
| {
type: 'title';
title: string;
count?: never;
action?: never;
}
| {
type: 'count';
title: string;
count: number;
action?: never;
}
| {
type: 'action';
title: string;
action: ListHeaderAction;
count?: never;
};판별자를 variant가 아니라 type으로 둔 것은 이름 고르기가 아니었다. 1막이 Recipe가 Variant를 담는다고 정해뒀고, ListHeader는 Recipe가 아니다. 여기에 variant를 쓰면 그 포함 관계가 한 층 아래에서 다시 깨진다. LeadingSpec과 TrailingSpec이 쓰는 이름이 이 자리에 맞는다.
숫자와 액션이 함께 오는 형태는 디자인에 없었다. ?: never가 붙어 있는 것은 판별자만으로는 부족해서다. 판별자만 두면 호출부에 직접 쓴 객체는 막히지만, 변수로 만들어 넘긴 객체는 초과 프로퍼티 검사를 지나쳐 그대로 통과한다. 1막에서 count와 description을 갈라둘 때 쓴 방법이 여기서도 같은 일을 한다.
여기서 정하지 않은 것이 하나 있다. 이 제목이 화면의 heading인지, 목록의 접근 가능한 이름인지, 그냥 시각 라벨인지다. 셋은 서로 다른 것이고 하나를 고른다고 나머지가 따라오지 않는다. WAI-ARIA 저작 안내서도 목록에 이름을 붙이는 것을 필수가 아니라 재량으로 두고, 이름이 이미 있는 컨테이너 안에서는 오히려 낭독이 늘어난다고 적는다. 답은 세 플랫폼을 함께 놓고 볼 때 낸다.
헤더의 숫자는 목록이 셀 수 있어 보였다
count 형태의 숫자에 질문 1을 대면 답이 갈린다.
List는 배열을 갖고 있다. items.length를 세면 숫자가 나온다. 1막에서 items가 비었는지 판정하는 일을 내부 규칙으로 분류한 것도 같은 배열에서 나오는 값이기 때문이었다. 그러면 이 숫자도 세어서 넣으면 될 것 같다.
화면을 보면 갈린다. 필터가 걸린 목록의 헤더는 전체 건수를 보여주고 그 아래에는 걸러진 것만 그려져 있었다. 목록이 페이지 단위로 잘리는 화면도 마찬가지다. 헤더의 숫자와 렌더된 행 수가 같은 화면이 있고 다른 화면이 있었다.
기본값을 items.length로 두고 필요할 때 덮어쓰게 할 수도 있다. 그러면 아무도 의미를 고르지 않았을 때 컴포넌트가 그 숫자를 현재 렌더된 행 수라고 대신 해석하게 된다. 그 해석은 어느 계약에도 적혀 있지 않은데, 기본값으로 들어가는 순간 계약이 된다.
질문 1이 재는 것은 셀 수 있는가가 아니라 그 숫자가 무엇을 세는 숫자인지를 누가 아는가다. items.length는 List가 안다. 헤더의 숫자가 무엇을 세는지는 제품이 안다.
그래서 count를 데이터로 받았다. 1막에서 정렬을 내부에 남긴 것과 방향이 반대로 나온 판정이다. 거기서는 Recipe가 이미 받은 description에서 계산할 수 있어 열지 않았고, 여기서는 셀 값이 있는 것처럼 보였지만 그 값이 배열에서 나오지 않았다.
들여쓰기 폭은 계산할 수 있었지만 값은 고르게 뒀다
이제 행과 행 사이다. 1막의 목록 층 요구 중 둘이 구분선이었다.
구분선은 좌측 아이콘 폭만큼 들여쓴다. 그 폭이 어디서 나오는지는 분명하다. Leading 트랙의 폭이고, 그 값은 행의 그리드를 그리는 Recipe가 안다.
그러면 자동으로 정할 수 있다고 생각했다. Leading이 있는 Recipe면 들여쓰고 없으면 전체 폭을 쓴다. 제품에 물어볼 것이 없어 보였다.
같은 Recipe를 쓰는 두 화면이 달랐다. 하나는 들여쓴 선을 썼고 다른 하나는 선을 아예 쓰지 않았다. 그 목록이 무엇을 나열하는지와 화면에서 어떤 무게를 갖는지가 선택을 정했다. Leading의 유무는 그 정보를 담고 있지 않다.
그래서 divider를 값 셋 중 하나를 받는 축으로 열었다. 'none'과 'inset'과 'full'이다.
폭은 안쪽이 계산하고, 어느 값을 쓸지는 제품이 고른다. inset이 몇 픽셀 들여쓰는지는 공개하지 않았다. 그 숫자는 Leading의 폭에서 나오고 Recipe마다 다르다.
숫자를 공개하면 어떤 일이 생기는지는 다른 시스템에서 볼 수 있다. MUI의 Divider는 variant="inset"을 왼쪽 마진 72로 고정해 둔다. 그 값이 어느 행 형태의 Leading에 맞춰진 것인지는 문서가 밝히지 않고, 같은 라이브러리의 아이콘 영역과 아바타 영역은 최소 폭이 서로 다르다. 이름 하나가 숫자 하나를 들고 있으면 그 숫자가 맞는 행 형태도 하나가 된다.
Material Design 3도 inset 구분선을 아이콘이나 아바타 같은 앵커 요소와 함께 쓰고 그 앞쪽 가장자리에 맞추라고 적는다. 기준이 되는 요소가 있어야 성립하는 값이라는 뜻이다. 우리도 같은 자리에 뒀고, 다만 그 정렬을 값 이름으로 부르고 계산은 안쪽에 남겼다.
질문 2로는 승인된 표현 선택이고, 질문 3으로는 정보 구조가 달라지지 않으므로 새 Recipe가 아니라 Variant다. 1막의 표에 divider: 'inset'이 승인된 표현 선택의 예로 이미 들어가 있었는데, 그 판정의 근거가 여기 있다.
목록 전체를 카드로 묶는 형태를 새 Recipe로 만들지 않았다
세 번째 요구는 목록 전체가 하나의 카드로 묶이는 형태였다.
후보는 둘이었다. CardList라는 별도 컴포넌트를 만들거나, List에 축을 하나 더하거나.
질문 3을 대면 답이 빨리 나온다. 카드로 묶인 목록도 자리는 ListHeader와 Body 그대로이고, 받는 것은 같은 items와 같은 itemComponent다. 어떤 자리가 새로 필수가 되지도 금지되지도 않는다. 달라지는 것은 목록이 놓이는 배경과 모서리다.
CardList로 가르면 List의 공개 prop을 전부 다시 갖거나 상속해야 한다. 제네릭 시그니처도 items와 itemComponent의 연결도 그대로 복제되고, 그 뒤로 두 컴포넌트가 같이 자란다. 호출부에서도 달라진다. 축이면 런타임 조건으로 값을 고를 수 있지만, 컴포넌트가 갈리면 JSX를 통째로 갈라야 한다.
type ListProps<C extends ListRowComponent<any>> = {
itemComponent: C;
items: readonly ListItem<RowPropsOf<C>>[];
divider?: 'none' | 'inset' | 'full';
surface?: 'plain' | 'card';
};둘 다 생략할 수 있고, 생략하면 디자인 시스템이 정한 기본값을 쓴다. divider는 'inset'이고 surface는 'plain'이다. 기본값도 승인된 선택지 중 하나이지 중립이 아니다.
divider와 surface는 서로 배타가 아니다. 하나를 고르는 것이 다른 하나를 막지 않고 둘은 각각 정해진다. Variant의 조건은 값들이 서로를 배제하는 것이 아니라, 선택지 목록이 승인된 것으로 정해져 있고 그 결과를 컴포넌트가 소유하는 것이다.
이 글에서는 divider와 surface가 서로의 허용 값을 바꾸지 않는다고 뒀다. 그래서 둘을 독립 축으로 열었고, 그 선택은 여섯 조합을 전부 계약에 넣는다는 뜻이었다. 실제 시스템에서 일부 조합만 승인됐다면 허용 조합만 표현하는 union이나 하나의 합성 축을 골라야 한다.
축을 하나로 묶은 시스템도 있다. UIKit의 UITableView.Style에서 plain은 섹션 헤더가 스크롤할 때 위에 붙고 grouped는 붙지 않는다. 배경 스타일을 고르는 값 하나가 헤더의 동작까지 정하는 셈이다. 축을 묶으면 그런 것이 이름 하나에 함께 딸려 온다.
Material Design 3은 또 다른 답을 권한다. 목록 가이드는 담긴 목록에는 간격을 쓰고 구분선은 담기지 않은 목록으로 제한하라고 적는다. 두 선택을 서로 무관하게 두지 않은 셈이다.
구현을 어떻게 고르느냐에 따라 그 권고는 강제가 되기도 한다. 구분선을 행 사이의 빈틈으로 만들고 그 틈에 컨테이너 배경을 비추면 선의 색이 곧 목록의 배경이 된다. 그러면 두 축이 코드에서 하나가 된다. 우리는 구분선을 행의 아래 테두리로 그렸고, 그래서 배경을 정하는 값과 선을 정하는 값이 따로 남았다.
조합을 확인하는 비용과 이름을 늘리는 비용 중 무엇이 싼지는 값의 개수가 정한다. 셋과 둘이면 표 하나로 확인할 수 있는 크기였다. 값이 더 늘면 그 계산은 뒤집힐 수 있다.
두 축이 정해지자 첫 행과 마지막 행이 할 일이 생겼다
순서를 아는 쪽이 List라는 것은 앞 글에서 정했다. 여기서 다시 논증하지 않는다. 남겨둔 것은 그 계산이 무엇을 바꾸는가였고, 이제 답할 수 있다. 바뀌는 대상이 방금 정한 두 축이기 때문이다.
surface: 'plain' |
surface: 'card' |
|
|---|---|---|
divider: 'none' |
없음 | 처음과 끝의 모서리 |
divider: 'inset' |
마지막 구분선 제거 | 마지막 구분선 제거 + 모서리 |
divider: 'full' |
마지막 구분선 제거 | 마지막 구분선 제거 + 모서리 |
목록의 처음과 끝이라는 사실 하나가 두 축의 경계 처리에 쓰인다. 이 대응표가 어디에 있어야 하는가가 남은 질문이다.
호출부에 있으면 목록을 그리는 화면마다 다시 쓴다. 축의 값이 하나 늘면 그 화면들을 전부 다시 본다.
다른 시스템도 여기서 갈린다. MUI의 ListItem은 divider를 boolean으로 열어두고 마지막 자식을 예외로 두지 않는다. 마지막 행의 선을 지우는 일이 호출부에 남는다. Ant Design의 List는 컴포넌트가 CSS로 지우는데, 목록 뒤에 footer나 pagination이 오면 그 선을 되살린다. 소스에 그 조건이 isSomethingAfterLastItem이라는 이름으로 있다.
두 번째 쪽이 이 계산의 성격을 잘 보여준다. 마지막 행이 무엇을 해야 하는지는 데이터의 마지막이 어디인지가 아니라 그 아래에 무엇이 오는지가 정한다. 행 하나는 어느 쪽도 모른다.
모서리를 그리는 방법도 둘이었다. 첫 행과 마지막 행에 각각 border-radius를 주거나, 목록 컨테이너에 border-radius를 주고 넘치는 부분을 자르거나.
뒤쪽이 편해 보인다. 행은 자기 위치를 몰라도 되고 모서리 계산이 컨테이너 하나로 끝난다. 대신 그 컨테이너가 스크롤 컨테이너가 되어 안쪽의 position: sticky가 기대한 자리에 붙지 않고, 첫 행과 마지막 행의 포커스 링이 잘린다. 2막에서 행의 조작 계약과 함께 정한 것이 그 포커스 표시였다.
어느 쪽을 고를지는 여기서 정하지 않는다. 이 글이 정한 것은 그 경계 처리를 List가 소유한다는 것까지다.
빈 상태는 전환과 문구가 다른 층에 있었다
1막에서 items가 비었는지 판정하는 일을 내부 규칙에 넣으면서 단서를 하나 달았다. 판정까지만 내부이고, 비었을 때 무엇을 보여줄지는 그 화면이 무엇을 하는 화면인지 알아야 정해진다는 것이었다. 어디서 갈리는지는 목록을 다룰 때 보자고 미뤄뒀다.
갈리는 자리가 여기다.
전환 판정은 List가 한다. items.length === 0은 List가 이미 받은 배열에서 나온다. 제품에 물어볼 것이 없고, 물어보면 그 판정이 화면마다 다시 일어난다.
무엇을 대신 보여줄지는 제품이 안다. 알림이 없는 목록과 신청 내역이 없는 목록은 같은 문구를 쓰지 않는다.
그래서 문구만 받았다.
type ListEmptySpec = {
title: string;
description?: string;
};이 자리를 ReactNode로 열면 2막이 막아둔 문이 다시 열린다. 빈 상태에 버튼이 들어가고 일러스트가 들어가고 또 다른 목록이 들어가도 타입은 막지 않는다. 승인된 빈 상태는 문구 한 줄과 필요하면 부연 한 줄이었고, 그 계약을 표현하는 데는 두 필드로 충분했다.
empty를 선택적으로 둘지도 정해야 했다. 앞 글에서 빈 배열은 유효한 입력이라고 했으니 empty 없이도 렌더는 된다. 다만 그때 무엇을 그릴지가 없다. 기본 문구를 두면 그 문구가 어느 화면에도 맞지 않는 자리에 남는다.
빈 상태는 목록의 예외가 아니라 두 상태 중 하나다. 그래서 필수로 뒀다. 앞 글이 보여준 items={[]} 호출은 이제 문구를 함께 요구한다. 절대 비지 않는 목록에도 문구를 쓰게 하는 비용이 있고, 그 비용을 받은 이유는 비지 않는다는 것을 타입이 확인해주지 않기 때문이다.
MUI의 List에는 이 자리가 없다. 대신 데이터를 직접 받는 DataGrid에 noRowsOverlay 슬롯이 있다. 전환을 판정하려면 데이터를 받는 쪽이어야 한다는 경계가 그대로 보인다. 그 DataGrid는 행이 없는 경우와 필터 결과가 없는 경우를 다른 슬롯으로 나누는데, 우리는 나누지 않았다. List는 받은 배열이 비었다는 것만 알고 왜 비었는지는 모른다. 그 구분이 필요한 화면은 문구로 표현했다.
ListHeader는 두 상태에서 모두 남는다. Body에 놓이는 값이 바뀌는 동안 제목은 그대로 있다. 제목을 Body와 갈라둔 것이 여기서 값을 한다.
행 사이 간격을 축으로 열면 없는 조합이 컴파일된다
축 둘을 세우고 나면 하나 더 열고 싶어진다. 행 사이 간격이다.
2막에서 이미 한 번 지나갔다. 행 안쪽의 여백과 크기는 Recipe가 소유하고, 행 사이 간격이나 목록을 감싸는 여백처럼 정당한 요구는 List 바깥에서 처리한다고 적었다. 그때는 행의 className을 막는 이야기였고, 지금은 List의 공개 축을 정하는 자리다.
열면 이렇게 된다.
<List
itemComponent={ListRowBasic}
items={items}
empty={emptyState}
divider="full"
spacing={16}
/>컴파일은 정상이다. 그런데 이 조합은 디자인에 없다. 구분선은 두 행이 맞닿아 있을 때 경계를 만든다. 행 사이가 벌어지면 그 간격 자체가 이미 경계이고, 선은 두 행을 나누는 대신 위 행에 붙은 밑줄이 된다.
타입으로 막을 수도 있다. divider와 spacing의 허용 조합을 판별자 union으로 쓰면 된다. 그러면 한쪽 값이 늘 때마다 브랜치가 곱해지고, 그 union을 옳게 유지하는 일이 새로 생긴다.
그래서 열지 않았다. 행 사이 간격을 독립 축으로 열면 승인되지 않은 divider와 spacing의 조합을 표현할 수 있게 된다.
SwiftUI는 이 축을 열었다. listRowSpacing이 행 사이 간격을 받고, listRowSeparator가 행마다 구분선의 표시 여부를 받는다. 다만 문서가 뒤에 한 줄을 붙인다. 이 수정자는 목록에 선호를 전달하는 것이고, 구분선을 실제로 보일지는 목록 스타일이 최종적으로 결정한다는 것이다.
여는 것과 결정권을 넘기는 것은 다르다. 우리는 그 자리에 축을 두지 않는 쪽을 골랐고, Apple은 축을 열되 최종 판단을 목록에 남겼다. 서로 다른 답이지만 지키는 것은 같다.
같은 질문이 한 계층 위에서도 답을 냈다
목록 층에서 내린 결정을 한자리에 모으면 이렇다.
| 결정 | 누가 아는가 | 입력의 의미 | 구조에서의 역할 |
|---|---|---|---|
divider |
제품 | 승인된 표현 선택 | List의 Variant |
surface |
제품 | 승인된 표현 선택 | List의 Variant |
| 헤더의 제목과 숫자 | 제품 | 데이터 | ListHeader descriptor |
| 헤더의 부가 액션 | 제품 | 행동 | ListHeader descriptor |
| 빈 상태 문구 | 제품 | 데이터 | ListEmpty descriptor |
| 구분선의 들여쓰기 폭 | Recipe가 계산 | 해당 없음 | 내부 규칙 |
| 첫 행과 마지막 행 | List가 계산 | 해당 없음 | 내부 규칙 |
| 빈 상태로의 전환 | List가 계산 | 해당 없음 | 내부 규칙 |
| 제목과 목록 사이의 간격·구분선·정렬 | List가 계산 | 해당 없음 | 내부 규칙 |
공개 계약은 앞 글의 둘에 넷이 붙은 모양이 됐다.
type ListProps<C extends ListRowComponent<any>> = {
itemComponent: C;
items: readonly ListItem<RowPropsOf<C>>[];
empty: ListEmptySpec;
header?: ListHeaderSpec;
divider?: 'none' | 'inset' | 'full';
surface?: 'plain' | 'card';
};제품은 앞 글에서 행의 Recipe 하나를 골랐다. 이 글에서 새로 연 것은 divider와 surface라는 List의 표현 축 둘이었다. 나머지 공개 입력은 고르는 값이 아니라 그 화면만 아는 데이터이고, 계산은 전부 목록 안에 남았다.
새 Recipe가 하나도 생기지 않았다. 우연이 아니었다. Recipe를 가르는 것은 정보 구조가 달라지는가인데, ListHeader와 Body 위에서는 새로 필수가 되거나 금지되는 자리가 없었다. 카드로 묶어도 제목은 제목이고 행은 행이고 빈 상태는 빈 상태다.
질문 셋이 계층을 바꿔도 그대로 쓰인 이유도 같은 자리에 있다. 질문 1이 묻는 것은 컴포넌트의 종류가 아니라 정보의 소유이고, 그 뒤의 둘은 공개로 판정된 입력을 다시 볼 뿐이다. 행에서 물을 때나 목록에서 물을 때나 물어보는 대상만 바뀐다.
질문 3이 낸 답을 그대로 받을 수 없었다
표를 다 채우고 나니 들어가지 않는 것이 하나 남았다. 선택 가능한 목록이다.
자리는 그대로다. 제목이 있고 행이 있고 비었을 때 문구가 있다. items도 itemComponent도 같다. 질문 3을 그대로 대면 정보 구조가 달라지지 않으므로 새 Recipe가 아니고, divider 옆에 축을 하나 더 두면 되는 것처럼 보인다.
그런데 divider를 바꿔서는 일어나지 않는 일들이 함께 일어난다.
- 목록이 무엇이 골라졌는지를 상태로 갖는다
- 행의 Trailing에 선택 표시가 들어간다
- 행을 누르는 것의 의미가 바뀐다
1막에서 이 자리를 예고해뒀다. 선택 표시가 Trailing에 보일 수는 있지만 선택 정책 자체를 Content의 종류로 보지는 않았고, 그 판단이 어디까지 버티는지는 목록 전체를 제한할 때 다시 확인하자고 적었다.
질문 1과 질문 2는 여기서도 답을 냈다. 무엇이 골라졌는지도 몇 개까지 골라질 수 있는지도 제품이 아는 값이라 둘 다 공개이고, 1막의 표는 앞의 것을 상태로 뒤의 것을 정책으로 이미 적어뒀다.
걸린 것은 질문 3 하나다. 그리고 정확히 말하면 답이 안 나온 것이 아니다. 정보 구조만 보면 기존 Recipe의 Variant라는 답이 나온다. 그 답을 그대로 따르면 선택을 divider와 같은 표현 분기로 취급하게 된다.
우리만 부딪히는 자리는 아니다. Material Design 3의 목록 가이드는 한 목록이 한 번에 하나의 선택 모드만 가질 수 있다고 적고, 선택 상태가 행 전체에 적용된다고 적는다. 선택 컨트롤을 Leading에 둘지 Trailing에 둘지는 열어두면서 그렇다. 슬롯의 배치와는 다른 층에 있는 제약이다.
1막은 기준을 세우면서 이렇게도 적었다. 더 큰 대상에 대볼 때마다 견딜지는 아직 모르고, 견디지 못하는 자리가 나오면 그때 고친다고. 여기가 그 첫 자리다.
이 글은 관찰까지만 한다. 목록의 공간을 둘로 나누고 그 위에 표현 축 둘을 세웠고, 셋째 후보가 그 옆에 놓이기를 거부했다. 질문 3이 Recipe를 가를 때 재는 것이 무엇을 놓치고 있었는지, 그래서 그 자를 어떻게 고쳐야 하는지는 다음 글에서 다룬다.