Featuring Design System
Columns

Sorting

클라이언트/서버 정렬, SortArrow 배선, 다중 정렬, 외부 컨트롤, aria-sort.

개요

정렬 엔진은 TanStack Table의 getSortedRowModel이 담당합니다. 그리드 패키지는 두 가지만 더합니다.

  1. DataGrid.SortArrow — 정렬 방향(asc/desc)과 다음 방향 미리보기(ghost)를 표시하는 인디케이터 primitive.
  2. 정직한 aria-sort — 실제 정렬 상태는 배선 여부와 무관하게 항상 노출됩니다. 외부 컨트롤로 프로그램적으로 정렬해도 보조기술(AT)에 사실이 전달됩니다.

그리드는 헤더 클릭에 정렬을 자동 배선하지 않습니다. 소비자가 onClick={header.column.getToggleSortingHandler()}<DataGrid.SortArrow>를 명시적으로 부착해야 합니다. 덕분에 같은 헤더에 필터 팝업·컬럼 메뉴 등 다른 인터랙션도 자유롭게 결합할 수 있습니다.

기본 사용법

클라이언트 단일 정렬의 최소 구성입니다. getSortedRowModeluseDataGrid에 전달하고, 헤더에 onClick + <DataGrid.SortArrow>를 부착합니다.

'use no memo'; // React Compiler와 TanStack Table mutable 인스턴스 충돌 방지

import { useState } from 'react';
import {
  getCoreRowModel,
  getSortedRowModel,
  useDataGrid,
  DataGrid,
} from '@featuring-corp/data-grid';
import type { ColumnDef, SortingState } from '@featuring-corp/data-grid';

type Row = { id: string; name: string; followers: number };

const columns: ColumnDef<Row>[] = [
  {
    accessorKey: 'name',
    header: ({ header }) => (
      <DataGrid.HeaderCell
        header={header}
        onClick={header.column.getToggleSortingHandler()}
      >
        채널
        <DataGrid.SortArrow
          direction={header.column.getIsSorted()}
          nextDirection={header.column.getNextSortingOrder()}
        />
      </DataGrid.HeaderCell>
    ),
    cell: (info) => (
      <DataGrid.Cell cell={info.cell}>{info.getValue() as string}</DataGrid.Cell>
    ),
  },
  {
    accessorKey: 'followers',
    // enableSorting: false — 정렬 불가 컬럼. affordance(커서/tabIndex/aria-sort) 미부착.
    enableSorting: false,
    header: ({ header }) => (
      <DataGrid.HeaderCell header={header}>팔로워</DataGrid.HeaderCell>
    ),
    cell: (info) => (
      <DataGrid.Cell cell={info.cell}>{info.getValue() as number}</DataGrid.Cell>
    ),
  },
];

function SortableGrid({ data }: { data: Row[] }) {
  const [sorting, setSorting] = useState<SortingState>([]);

  const table = useDataGrid<Row>({
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    data,
    columns,
    getRowId: (r) => r.id,
    state: { sorting },
    onSortingChange: setSorting,
  });

  return (
    <DataGrid.Root table={table}>
      <DataGrid.Header />
      <DataGrid.Body />
    </DataGrid.Root>
  );
}

아래 그리드에서 채널 헤더를 클릭하면 정렬이 바뀝니다(asc → desc → 해제). 팔로워enableSorting: false라 affordance가 붙지 않습니다.

const data = [
{ id: '1', name: '김민준', followers: 128000 },
{ id: '2', name: '이서연', followers: 84500 },
{ id: '3', name: '박도윤', followers: 233000 },
{ id: '4', name: '최하은', followers: 45200 },
];

const columns = [
{
  accessorKey: 'name',
  header: ({ header }) => (
    <DataGrid.HeaderCell header={header} onClick={header.column.getToggleSortingHandler()}>
      채널
      <DataGrid.SortArrow
        direction={header.column.getIsSorted()}
        nextDirection={header.column.getNextSortingOrder()}
      />
    </DataGrid.HeaderCell>
  ),
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
},
{
  accessorKey: 'followers',
  enableSorting: false,
  header: ({ header }) => <DataGrid.HeaderCell header={header}>팔로워</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue().toLocaleString()}</DataGrid.Cell>,
},
];

function SortableGrid() {
const [sorting, setSorting] = useState([]);
const table = useDataGrid({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  getRowId: (row) => row.id,
  state: { sorting },
  onSortingChange: setSorting,
});
return (
  <DataGrid.Root table={table} size="md">
    <DataGrid.Header />
    <DataGrid.Body />
  </DataGrid.Root>
);
}

render(<SortableGrid />);

정렬 affordance 게이팅: data-sortable(pointer 커서·tabIndex=0·Enter/Space 활성·aria-sort="none")은 capability(column.getCanSort()) + 실제 배선(onClick)이 모두 있을 때만 부착됩니다. enableSorting: false인 컬럼이나 onClick을 붙이지 않은 헤더에는 affordance가 생기지 않습니다. hover 미리보기(ghost)는 이 게이트와 별개로, SortArrownextDirection을 줄 때만 표시됩니다(아래 hover 미리보기 제어 참고).


공통 패턴 — SortHeader 래퍼

동일한 sort 배선을 여러 컬럼에 적용할 때는 작은 래퍼 함수로 정리하면 편합니다.

function SortHeader<T>({
  header,
  children,
}: {
  header: Header<T, unknown>;
  children: ReactNode;
}) {
  return (
    <DataGrid.HeaderCell
      header={header}
      onClick={header.column.getToggleSortingHandler()}
    >
      {children}
      <DataGrid.SortArrow
        direction={header.column.getIsSorted()}
        nextDirection={header.column.getNextSortingOrder()}
      />
    </DataGrid.HeaderCell>
  );
}

다중 정렬

enableMultiSort: true를 주면 여러 컬럼을 동시에 정렬할 수 있습니다. 기본 동작은 Shift+클릭으로 컬럼을 누적하며, isMultiSortEvent: () => true를 주면 Shift 없이도 누적됩니다.

const table = useDataGrid<Row>({
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  data,
  columns,
  getRowId: (r) => r.id,
  state: { sorting },
  onSortingChange: setSorting,
  enableMultiSort: true,
  isMultiSortEvent: () => true, // Shift 없이 클릭만으로 컬럼 누적
});

정렬 순서는 SortingState 배열로 드러납니다. 배열의 앞쪽 항목이 우선순위가 높습니다.

// 예: 카테고리 오름차순 → 단가 내림차순
const [sorting, setSorting] = useState<SortingState>([
  { id: 'category', desc: false },
  { id: 'costPerPost', desc: true },
]);

서버 정렬 (manual)

서버가 정렬을 수행하는 경우 manualSorting: true를 전달합니다. 그리드는 클라이언트 측 재정렬을 건너뛰고, onSortingChange로 받은 SortingState를 fetch 파라미터로 서버에 넘기는 방식으로 동작합니다.

function ServerSortGrid() {
  const [sorting, setSorting] = useState<SortingState>([
    { id: 'costPerPost', desc: true },
  ]);

  const { data, isLoading, error } = useFetchRows({
    sorting, // SortingState를 그대로 fetch 파라미터로
  });

  const table = useDataGrid<Row>({
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(), // manualSorting:true면 실제로는 호출 안 됨
    data: data?.items ?? [],
    columns,
    rowCount: data?.total,
    getRowId: (r) => r.id,
    manualSorting: true,
    state: { sorting },
    onSortingChange: setSorting,
    loading: isLoading,
    error,
    skeletonRows: 10,
  });

  return (
    <DataGrid.Root table={table}>
      <DataGrid.Header />
      <DataGrid.Body />
    </DataGrid.Root>
  );
}

getSortedRowModelmanualSorting: true일 때 실질적으로 동작하지 않습니다. 그러나 나중에 클라이언트 정렬로 전환하거나 혼용 시나리오를 위해 넣어두는 것이 일반적입니다.

외부 컨트롤

정렬 트리거를 헤더 밖(툴바·버튼)에 두는 경우입니다. 헤더에 onClick을 붙이지 않으므로 affordance(커서/tabIndex)가 없고, nextDirection을 주지 않아 hover 미리보기(ghost)도 뜨지 않습니다. 하지만 실제 정렬 상태는 정직하게 aria-sort/data-sorted + SortArrow 방향으로 노출됩니다.

const data = [
{ id: '1', name: '김민준', followers: 128000 },
{ id: '2', name: '이서연', followers: 84500 },
{ id: '3', name: '박도윤', followers: 233000 },
{ id: '4', name: '최하은', followers: 45200 },
{ id: '5', name: '정시우', followers: 67800 },
];

const columns = [
{
  accessorKey: 'name',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
},
{
  accessorKey: 'followers',
  // onClick 없음 — 헤더는 표시 전용. SortArrow는 현재 방향만 표시.
  header: ({ header }) => (
    <DataGrid.HeaderCell header={header}>
      팔로워
      <DataGrid.SortArrow direction={header.column.getIsSorted()} />
    </DataGrid.HeaderCell>
  ),
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue().toLocaleString()}</DataGrid.Cell>,
},
];

function ExternalSortGrid() {
const [sorting, setSorting] = useState([]);

const table = useDataGrid({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  getRowId: (row) => row.id,
  state: { sorting },
  onSortingChange: setSorting,
});

return (
  <VStack $css={{ gap: '$spacing-300' }}>
    {/* 외부 정렬 트리거 */}
    <HStack $css={{ gap: '$spacing-200' }}>
      <Button.Root variant="secondary" size="sm" onClick={() => setSorting([{ id: 'followers', desc: false }])}>
        팔로워 ↑
      </Button.Root>
      <Button.Root variant="secondary" size="sm" onClick={() => setSorting([{ id: 'followers', desc: true }])}>
        팔로워 ↓
      </Button.Root>
      <Button.Root variant="tertiary" size="sm" onClick={() => setSorting([])}>
        정렬 해제
      </Button.Root>
    </HStack>

    <DataGrid.Root table={table}>
      <DataGrid.Header />
      <DataGrid.Body />
    </DataGrid.Root>
  </VStack>
);
}

render(<ExternalSortGrid />);

접근성

상황aria-sort부착 조건
정렬됨 (오름차순)"ascending"배선 여부 무관 — 항상 노출
정렬됨 (내림차순)"descending"배선 여부 무관 — 항상 노출
정렬 가능·미정렬"none"배선 있을 때만 (isSortInteractive)
정렬 불가없음enableSorting: false 또는 배선 없음

aria-sort="none"은 "이 컬럼은 정렬 가능하지만 현재 정렬되어 있지 않다"는 안내입니다. 배선(onClick)이 없는 헤더에 붙이면 거짓 안내가 되므로 그리드가 자동으로 차단합니다.

외부 컨트롤로 프로그램적으로 정렬한 경우에도 aria-sort가 정직하게 갱신되어 스크린 리더 사용자에게 현재 정렬 상태가 전달됩니다.

접근성 전반은 접근성 페이지를 참고하세요.

DataGrid.SortArrow props

Prop

Type

hover 미리보기 제어 — nextDirection

hover 미리보기(ghost)는 nextDirection을 줄 때만 표시됩니다. 정렬 트리거가 헤더 클릭이 아니라 컬럼 메뉴 등 별도 위치에 있어 미리보기가 "클릭하면 정렬될 것 같다"는 오해를 줄 수 있는 경우, nextDirection을 생략하면 ghost 없이 현재 정렬 방향만 표시됩니다. 헤더의 onClick 배선 유무와는 무관합니다.

// 미리보기 ON — 다음 클릭 방향을 hover로 예고
<DataGrid.SortArrow
  direction={header.column.getIsSorted()}
  nextDirection={header.column.getNextSortingOrder()}
/>

// 미리보기 OFF — nextDirection 생략. 실제 정렬 방향만 표시.
<DataGrid.SortArrow direction={header.column.getIsSorted()} />

다음 단계

  • Columns — 컬럼 정의, enableSorting, sortDescFirst 등 컬럼별 정렬 옵션.
  • Filtering — 정렬과 함께 쓰는 클라이언트/서버 필터링.
  • Pagination — 서버 정렬과 페이지네이션을 함께 배선하는 방법.
  • Accessibilityaria-sort 전략과 키보드 내비게이션 전반.
  • 라이브 스토리 — 클라이언트/서버/다중/외부 컨트롤 정렬을 모두 확인할 수 있습니다.