Featuring Design System
Layout & Scale

Touch & Pointer

Pointer Events 단일 경로로 마우스·펜·터치를 한 코드로 처리합니다 — 별도 터치 설정이 없습니다.

개요

DataGrid의 인터랙션은 Pointer Events 단일 경로로 구현되어 있습니다. BodyonPointerDown 하나로 셀 선택을 처리하고, 그 안에서 e.pointerType만 분기합니다. 별도의 touch 코드 경로가 없으므로, useDataGridSelectionuseDataGridEditing을 배선하면 마우스·펜·터치 지원이 자동으로 따라옵니다 — 소비자가 켜는 옵션이 아닙니다.

const initialData = [
{ id: '1', name: '김민준', category: '뷰티', followers: 128000 },
{ id: '2', name: '이서연', category: '패션', followers: 84500 },
{ id: '3', name: '박도윤', category: '게임', followers: 233000 },
{ id: '4', name: '최하은', category: '푸드', followers: 45200 },
{ id: '5', name: '정시우', category: '여행', followers: 67800 },
];

const columns = [
{
  accessorKey: 'name',
  meta: { editable: true },
  header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>,
  cell: (info) => (
    <DataGrid.Cell cell={info.cell}>
      <DataGrid.CellDisplay>{info.getValue()}</DataGrid.CellDisplay>
      <DataGrid.CellEditor>
        <DataGrid.TextEditor />
      </DataGrid.CellEditor>
    </DataGrid.Cell>
  ),
},
{
  accessorKey: 'category',
  meta: { editable: true },
  header: ({ header }) => <DataGrid.HeaderCell header={header}>카테고리</DataGrid.HeaderCell>,
  cell: (info) => (
    <DataGrid.Cell cell={info.cell}>
      <DataGrid.CellDisplay>{info.getValue()}</DataGrid.CellDisplay>
      <DataGrid.CellEditor>
        <DataGrid.TextEditor />
      </DataGrid.CellEditor>
    </DataGrid.Cell>
  ),
},
{
  accessorKey: 'followers',
  meta: { editable: true },
  header: ({ header }) => <DataGrid.HeaderCell header={header}>팔로워</DataGrid.HeaderCell>,
  cell: (info) => (
    <DataGrid.Cell cell={info.cell}>
      <DataGrid.CellDisplay>{Number(info.getValue()).toLocaleString()}</DataGrid.CellDisplay>
      <DataGrid.CellEditor>
        <DataGrid.TextEditor parse={(raw) => Number(raw)} />
      </DataGrid.CellEditor>
    </DataGrid.Cell>
  ),
},
];

// 이 한 번의 배선으로 마우스·펜·터치가 모두 동작한다.
// 데스크톱에서는 클릭 드래그(범위 선택)·더블클릭(편집), 터치에서는 탭·더블탭으로 같은 경로에 합류한다.
function TouchGrid() {
const [data, setData] = useState(initialData);
const table = useDataGrid({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getRowId: (row) => row.id,
});
const selection = useDataGridSelection(table);
const editing = useDataGridEditing(table, {
  selection,
  onCellEdit: ({ coord, newValue }) =>
    setData((rows) => rows.map((r) => (r.id === coord.rowId ? { ...r, [coord.columnId]: newValue } : r))),
});
return (
  <DataGrid.Root table={table} selection={selection} editing={editing} size="md">
    <DataGrid.Header />
    <DataGrid.Body />
  </DataGrid.Root>
);
}

render(<TouchGrid />);

입력 종류에 따라 affordance만 자동으로 전환됩니다. 터치 모드에서는 모서리 선택 핸들이 나타나고 fill handle이 숨으며, 마우스·펜 모드에서는 반대로 drag-select와 fill handle이 활성화됩니다. 이 전환은 Root가 capture 단계 onPointerDown으로 pointerInputMode를 추적해 이루어지며, 전환은 다음 인터랙션부터 반영됩니다(현재 제스처 중에는 핸들을 교체하지 않습니다).

실기기 또는 DevTools 디바이스 에뮬레이션으로 터치 동작을 확인하세요. 데스크톱 마우스로는 마우스 모드 affordance(drag-select, fill handle)가 표시됩니다.

라이브 스토리 →


탭 — 셀 선택과 스크롤

pointerType === 'touch'인 경우, Body는 selection.setActive(coord)단일 셀 선택만 수행하고 preventDefault를 호출하지 않습니다.

이 선택은 의도적입니다. preventDefault가 없으므로 손가락 드래그가 셀 선택 drag-select가 아닌 네이티브 표 스크롤로 흐릅니다. 셀 자체에는 touch-action을 설정하지 않습니다 — 그리드가 스크롤을 가로채지 않기 위한 설계입니다(touch-action: none은 선택 핸들과 리사이즈 핸들에만 적용됩니다).

제스처동작
단일 셀 선택(native scroll 보존)
손가락 드래그표 스크롤(drag-select 아님)
마우스/펜 드래그Excel-like drag-select

더블탭 — 편집 진입

같은 셀을 350ms 이내에 두 번 탭하면 편집 모드로 진입합니다.

iOS에서 native dblclick 이벤트가 불안정하기 때문에, Body가 직전 탭의 타임스탬프·좌표를 기억하고 350ms 윈도 안에 같은 셀 탭이 재감지되면 편집으로 진입합니다. 마우스 더블클릭이나 Enter 키와 동일한 편집 경로로 합류하므로, 에디터 컴포넌트는 입력 종류를 구분하지 않습니다.

{
  accessorKey: 'lastUploadAt',
  meta: { editable: true }, // 더블탭 편집은 meta.editable === true 컬럼에서만 발동
  cell: (info) => (
    <DataGrid.Cell cell={info.cell}>
      <DataGrid.CellDisplay>{String(info.getValue() ?? '')}</DataGrid.CellDisplay>
      <DataGrid.CellEditor>
        <DataGrid.TextEditor />
      </DataGrid.CellEditor>
    </DataGrid.Cell>
  ),
}

meta.editable이 없는 컬럼은 더블탭해도 편집에 진입하지 않습니다. 350ms를 넘기거나 다른 셀을 탭하면 더블탭으로 인식되지 않으며, 첫 번째 탭은 단순 셀 선택으로 처리됩니다.


선택 핸들 — 범위 확장

터치 모드에서는 마우스의 drag-select 대신 모서리 선택 핸들로 범위를 확장합니다. 모바일 Excel·Google Sheets와 동일한 패턴입니다.

핸들은 다음 조건을 모두 만족할 때 표시됩니다.

  • pointerInputMode === 'touch' (Root가 자동 추적)
  • selection.enabled === true
  • 선택 range가 정확히 1개(비연속 다중 선택에서는 핸들이 숨음)

조건을 충족하면 선택 범위의 좌상단(TL)·우하단(BR)에 파란 점 핸들이 나타납니다. 핸들을 드래그하면 반대 코너를 anchor로 잡고 selection.setActive(coord, { extend: true })를 호출해 범위를 확장합니다 — 마우스 drag-select와 동일한 뮤테이터입니다.

핸들 자체에는 touch-action: none이 적용되어 있어, 핸들 위 드래그가 스크롤에 뺏기지 않습니다. 셀 본문의 스크롤은 그대로 유지됩니다.

v1 한계: 코너 셀이 가상화로 화면 밖이거나 병합(merged)된 경우 핸들이 숨습니다. Pinned 컬럼에 anchored된 핸들의 sticky 정합은 현재 범위 밖입니다.

선택 범위 셀의 배경색을 터치 모드에서 강조하려면, <DataGrid.Cell>style 콜백에서 state.isInRange를 읽으세요. $css는 상태 기반 분기를 지원하지 않으므로 콜백 패턴을 사용합니다.

const rangeStyle = (state: DataGridCellState): React.CSSProperties =>
  state.isInRange ? { backgroundColor: 'var(--global-colors-primary-10)' } : {};

// 각 컬럼의 DataGrid.Cell에 직접 부착
<DataGrid.Cell cell={info.cell} style={rangeStyle}>
  {/* ... */}
</DataGrid.Cell>

패키지 CSS는 @layer ft-components 안에 있으므로, 이 인라인 스타일 override가 항상 우선합니다.


다음 단계

  • Selection model — anchor·focused·ranges 모델, setActive, 정책 seam.
  • Editing model — 편집 진입·commit·undo/redo, meta.editable, 에디터 패턴.
  • 라이브 스토리 — DevTools 디바이스 에뮬레이션으로 터치 동작 직접 확인.