# Sorting

> Click to sort, shift-click to add a column, and comparators that understand text, dates and your own domain order.

URL: https://react-tablekit.vercel.app/react-tablekit/sorting/

## Basics

**Example: Sorting** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/sorting/demo-basics)

```tsx
import { createColumnHelper, DataTable, type SortingFn } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';

const data = generatePeople(60);
const col = createColumnHelper<DemoPerson>();

/** Seniority is an order, not an alphabet: "active" before "invited" before "suspended". */
const STATUS_ORDER: Record<DemoPerson['status'], number> = {
  active: 0,
  invited: 1,
  suspended: 2,
};

const byStatus: SortingFn<DemoPerson> = (rowA, rowB) =>
  STATUS_ORDER[rowA.original.status] - STATUS_ORDER[rowB.original.status];

const columns = [
  // `alphanumeric` is the natural comparator: "Room 2" sorts before "Room 10".
  col.accessor((p) => `${p.firstName} ${p.lastName}`, {
    id: 'name',
    header: 'Name',
    sortingFn: 'alphanumeric',
  }),
  // Locale-aware, so "Ávila" lands next to "Avila" rather than after "Z".
  col.accessor('city', {
    header: 'City',
    sortingFn: 'text',
    // Rows with no city go last in both directions, instead of bubbling to the top.
    sortUndefined: 'last',
  }),
  col.accessor('department', { header: 'Department' }),
  // A custom comparator for a domain order no built-in can know.
  col.accessor('status', { header: 'Status', sortingFn: byStatus }),
  // Money and dates are almost always most-interesting-first on the first click.
  col.accessor('salary', { header: 'Salary', type: 'number', sortDescFirst: true }),
  col.accessor('joined', { header: 'Joined', type: 'date', sortDescFirst: true }),
  col.accessor('age', { header: 'Age', type: 'number' }),
];

/**
 * Client sorting. Click a header to sort, click again to reverse, and a third time to
 * clear it. Hold Shift and click to add a second and third column; the badge on each header
 * shows its priority, and the whole thing is one comparison chain.
 */
export default function ClientSortingExample() {
  return (
    <DataTable<DemoPerson>
      aria-label="People"
      data={data}
      columns={columns}
      getRowId={(p) => p.id}
      enableSorting
      enableMultiSort
      initialState={{
        sorting: [{ id: 'status', desc: false }],
        pagination: { pageIndex: 0, pageSize: 8 },
      }}
    />
  );
}
```

Sorting is on by default. A click cycles ascending → descending → unsorted; holding **Shift**
adds a column to the sort instead of replacing it, and each header shows its position in the
chain.

### Choosing a comparator

The column `type` already picks a sensible comparator, so most tables need nothing here.
Override it with `sortingFn` when the default ordering is wrong for your data:

```tsx
col.accessor('name', { sortingFn: 'alphanumeric' }); // "Room 2" before "Room 10"
col.accessor('city', { sortingFn: 'text' }); // locale-aware: "Ávila" next to "Avila"
col.accessor('status', {
  sortingFn: (a, b) => RANK[a.original.status] - RANK[b.original.status],
});
```

The built-ins are `alphanumeric`, `alphanumericCaseSensitive`, `text`, `textCaseSensitive`,
`datetime`, `basic` and `boolean`.

### Missing values and direction

`sortUndefined` decides where empty cells go. `'last'` keeps them out of the way in both
directions, which is usually what people expect. `sortDescFirst` makes the first click
descending, which suits money and dates, and `invertSorting` suits ranks where lower is better.

When the value you sort by differs from the value you display (a formatted date, a chip label),
give the column a `sortValue`.

### Sorting on the server

**Example: Server sorting** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/sorting/demo-server)

```tsx
import { createColumnHelper, createLocalDataSource, DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { RequestLog, useRequestLog } from '@/demo-support/requestLog';

const col = createColumnHelper<DemoPerson>();

/** The server sorts by its own field names, mapped with `sortServerKey`. */
const columns = [
  col.accessor((p) => `${p.firstName} ${p.lastName}`, {
    id: 'name',
    header: 'Name',
    sortServerKey: 'last_name',
  }),
  col.accessor('email', { header: 'Email', enableSorting: false }),
  col.accessor('department', { header: 'Department', sortServerKey: 'department_code' }),
  col.accessor('age', { header: 'Age', type: 'number', sortServerKey: 'age_years' }),
  col.accessor('joined', { header: 'Joined', type: 'date', sortServerKey: 'joined_at' }),
];

const people = generatePeople(120);
const source = createLocalDataSource(people, {
  columns,
  getRowId: (p) => p.id,
  latencyMs: [150, 350],
});

/**
 * Manual (server) sorting: the table emits `sorting` using each column's `sortServerKey`, and the
 * simulated server maps those keys back to its data. Shift+click adds a second sort column.
 */
export default function ServerSortingExample() {
  const { dataSource, log, clear } = useRequestLog(source);
  return (
    <div className="example-stack">
      <DataTable
        aria-label="People (server sorting)"
        columns={columns}
        dataSource={dataSource}
        getRowId={(p) => p.id}
        manualSorting
        manualPagination
        enableMultiSort
        initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
      />
      <RequestLog log={log} clear={clear} />
    </div>
  );
}
```

In server mode the sort list is sent rather than applied. Map column ids to your API's field
names with `sortServerKey`; nothing else changes.

```tsx
col.accessor('name', { sortServerKey: 'LastName' });
```

## Customization

The sort indicator, the clickable header and the header cell itself are all slots. Replacing the
indicator alone is the common case:

```tsx
<DataTable slots={{ SortIcon: ({ direction }) => <MyChevron direction={direction} /> }} />
```

To change what a click _does_ rather than how it looks, wrap the `onSortToggle` handler; it can
observe the intent, change it, or cancel it by not calling `next`. See
[Handler middleware](/react-tablekit/customization/handlers/).

## Limitations

- **Multi-sort priority cannot be reordered by dragging.** A column joins the chain in the order
  it was shift-clicked. To reorder, clear the sort and rebuild it, or set `state.sorting`
  yourself.
- **Server mode sorts whatever the server returns.** If you enable client sorting alongside
  server pagination, only the fetched page is ordered, and the table warns about this in
  development unless you pass `acknowledgePageLocalSorting`. See
  [Client and server](/react-tablekit/client-and-server/).
- **Grouped rows sort within their group**, never across groups; the group order comes from the
  grouping column. See [Grouping](/react-tablekit/grouping/).

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableSorting`, `enableMultiSort`,
  `enableSortingRemoval`, `sortingMode`, `onSortingChange`.
- [`ColumnDef`](/react-tablekit/api/column-def/): `sortingFn`, `sortValue`, `sortDescFirst`,
  `sortUndefined`, `invertSorting`, `sortServerKey`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `setSorting`, `resetSorting`.
- [Utilities](/react-tablekit/api/utilities/): the `sortingFns` registry.

---

# Shared example code

The examples above import these site modules: sample data and shared column definitions. They are not part of the package; they are here so that every example is complete.

## @/demo-support/requestLog

```tsx
import { useCallback, useMemo, useState } from 'react';
import type { DataSource, TableQuery } from '@kingtech/react-tablekit';

export interface LoggedRequest {
  at: string;
  reason: string;
  query: Pick<TableQuery, 'globalFilter' | 'columnFilters' | 'sorting' | 'pagination'>;
}

/** Wraps a data source so the demo can show what the "server" was asked for. */
export function useRequestLog<TData>(source: DataSource<TData>, limit = 8) {
  const [log, setLog] = useState<LoggedRequest[]>([]);

  const dataSource = useMemo<DataSource<TData>>(
    () => ({
      fetch: (query, ctx) => {
        setLog((prev) =>
          [
            {
              at: new Date().toLocaleTimeString(),
              reason: ctx.reason,
              query: {
                globalFilter: query.globalFilter,
                columnFilters: query.columnFilters,
                sorting: query.sorting,
                pagination: query.pagination,
              },
            },
            ...prev,
          ].slice(0, limit),
        );
        return source.fetch(query, ctx);
      },
      ...(source.fetchFacets ? { fetchFacets: source.fetchFacets.bind(source) } : {}),
    }),
    [source, limit],
  );

  const clear = useCallback(() => setLog([]), []);
  return { dataSource, log, clear };
}

/** Renders the request log as a definition list. */
export function RequestLog({ log, clear }: { log: LoggedRequest[]; clear: () => void }) {
  return (
    <section className="example-panel" aria-labelledby="request-log-title">
      <h2 id="request-log-title">Server requests</h2>
      {log.length === 0 ? (
        <p className="example-pending">No requests yet.</p>
      ) : (
        <>
          <button type="button" onClick={clear}>
            Clear log
          </button>
          <ol className="example-log">
            {log.map((entry, i) => (
              <li key={`${entry.at}-${String(i)}`}>
                <code>
                  {entry.at} · {entry.reason} · page {entry.query.pagination.pageIndex + 1} ·{' '}
                  {entry.query.sorting.length > 0
                    ? entry.query.sorting
                        .map((s) => `${s.id} ${s.desc ? 'desc' : 'asc'}`)
                        .join(', ')
                    : 'unsorted'}
                  {entry.query.globalFilter ? ` · q="${entry.query.globalFilter}"` : ''}
                  {entry.query.columnFilters.length > 0
                    ? ` · ${entry.query.columnFilters.map((f) => `${f.id}=${JSON.stringify(f.value)}`).join(', ')}`
                    : ''}
                </code>
              </li>
            ))}
          </ol>
        </>
      )}
    </section>
  );
}
```

## @/mock/data/people

```ts
import { toPlainLatin } from '../format';
import { createRandom } from '../prng';

/** A generic person record for the feature demos. */
export interface DemoPerson {
  id: string;
  firstName: string;
  lastName: string;
  email: string;
  age: number;
  department: 'Engineering' | 'Sales' | 'Support' | 'Marketing' | 'Finance';
  status: 'active' | 'invited' | 'suspended';
  salary: number;
  joined: string;
  remote: boolean;
  city?: string;
}

const FIRST = [
  'Ava',
  'Ben',
  'Chloé',
  'Dev',
  'Emma',
  'Finn',
  'Gyula',
  'Hana',
  'Ivan',
  'Júlia',
  'Kai',
  'Lena',
  'Mateo',
  'Noah',
  'Olivia',
  'Priya',
  'Quinn',
  'Rosa',
  'Sven',
  'Tariq',
  'Uma',
  'Viktor',
  'Wen',
  'Yusuf',
  'Zoë',
] as const;
const LAST = [
  'Anders',
  'Baker',
  'Chen',
  'Dubois',
  'Evans',
  'Fischer',
  'García',
  'Horváth',
  'Ito',
  'Jensen',
  'Kowalski',
  'López',
  'Müller',
  'Nakamura',
  'Okafor',
  'Patel',
  'Rossi',
  'Schmidt',
  'Tanaka',
  'Weber',
] as const;
const DEPARTMENTS = ['Engineering', 'Sales', 'Support', 'Marketing', 'Finance'] as const;
const STATUSES = ['active', 'active', 'active', 'invited', 'suspended'] as const;
const CITIES = ['Budapest', 'Berlin', 'Austin', 'Lisbon', 'Toronto', 'Osaka', 'Nairobi'] as const;

const cache = new Map<number, DemoPerson[]>();

/** Deterministic people (cached per count). */
export function generatePeople(count: number, seed = 42): DemoPerson[] {
  const key = count * 1000 + seed;
  const hit = cache.get(key);
  if (hit) return hit;
  const r = createRandom(seed);
  const people = Array.from({ length: count }, (_, i): DemoPerson => {
    const firstName = r.pick(FIRST);
    const lastName = r.pick(LAST);
    const handle = toPlainLatin(`${firstName}.${lastName}`).toLowerCase();
    const person: DemoPerson = {
      id: `u${String(i + 1).padStart(5, '0')}`,
      firstName,
      lastName,
      email: `${handle}${i}@example.com`,
      age: r.int(19, 67),
      department: r.pick(DEPARTMENTS),
      status: r.pick(STATUSES),
      salary: r.int(38, 190) * 1000,
      joined: new Date(Date.UTC(r.int(2012, 2026), r.int(0, 11), r.int(1, 28)))
        .toISOString()
        .slice(0, 10),
      remote: r.chance(0.4),
    };
    if (r.chance(0.85)) person.city = r.pick(CITIES);
    return person;
  });
  cache.set(key, people);
  return people;
}
```

## @/mock/format

```ts
import type { Address, PhoneType } from './types';

const PHONE_ORDER: Record<PhoneType, number> = { Work: 1, Mobile: 2, Home: 3, Unknown: 4 };

/**
 * Phone list formatting: grouped and sorted by type (Work, Mobile, Home, Unknown), and numbered
 * within a type ("Mobile 2") when that type appears more than once.
 */
export function formatPhoneLines(phones: { number: string; phoneType: PhoneType }[]): string[] {
  const sorted = [...phones].sort((a, b) => PHONE_ORDER[a.phoneType] - PHONE_ORDER[b.phoneType]);
  const totals = new Map<PhoneType, number>();
  for (const p of sorted) totals.set(p.phoneType, (totals.get(p.phoneType) ?? 0) + 1);
  const seen = new Map<PhoneType, number>();
  return sorted.map((p) => {
    const n = (seen.get(p.phoneType) ?? 0) + 1;
    seen.set(p.phoneType, n);
    const label = (totals.get(p.phoneType) ?? 0) > 1 ? `${p.phoneType} ${n}` : p.phoneType;
    return `${label}: ${p.number}`;
  });
}

/** Short address format: "City, ST 12345". */
export function getShortFormatAddress(address: Address | undefined): string {
  if (!address) return '';
  return `${address.city}, ${address.region} ${address.postalCode}`;
}

/** Letters that are not a base letter plus a combining mark, so NFD alone leaves them in place. */
const TRANSLITERATE: Record<string, string> = {
  ø: 'o',
  Ø: 'O',
  æ: 'ae',
  Æ: 'Ae',
  œ: 'oe',
  Œ: 'Oe',
  ß: 'ss',
  ł: 'l',
  Ł: 'L',
  đ: 'd',
  Đ: 'D',
  þ: 'th',
  Þ: 'Th',
};

/** Plain Latin letters for a name: "Bjørn Østergaard" becomes "Bjorn Ostergaard". */
export function toPlainLatin(value: string): string {
  return value
    .normalize('NFD')
    .replace(/\p{M}/gu, '')
    .replace(/[øØæÆœŒßłŁđĐþÞ]/g, (letter) => TRANSLITERATE[letter] ?? letter);
}
```

## @/mock/prng

```ts
/** mulberry32: a tiny, fast, seedable PRNG. Same seed → same sequence on every machine. */
export function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** Small helper API over a PRNG. */
export interface Random {
  next(): number;
  int(min: number, max: number): number;
  float(min: number, max: number, decimals?: number): number;
  pick<T>(items: readonly T[]): T;
  chance(probability: number): boolean;
}

export function createRandom(seed: number): Random {
  const next = mulberry32(seed);
  return {
    next,
    int: (min, max) => Math.floor(next() * (max - min + 1)) + min,
    float: (min, max, decimals = 2) => Number((next() * (max - min) + min).toFixed(decimals)),
    pick: <T>(items: readonly T[]): T => {
      const item = items[Math.floor(next() * items.length)];
      if (item === undefined) throw new Error('pick() on an empty list');
      return item;
    },
    chance: (probability) => next() < probability,
  };
}

/** Derives a stable numeric seed from a string (FNV-1a). */
export function seedFromString(value: string): number {
  let hash = 0x811c9dc5;
  for (let i = 0; i < value.length; i++) {
    hash ^= value.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193);
  }
  return hash >>> 0;
}
```

## @/mock/types

```ts
// Domain types for the demo mock server. A generic asset-management domain: accounts own sites,
// sites hold assets, and assets accumulate readings over time.

export interface Address {
  address1: string;
  address2?: string;
  city: string;
  /** State / region. */
  region: string;
  postalCode: string;
  country: string;
}

export type PhoneType = 'Work' | 'Mobile' | 'Home' | 'Unknown';

export type AssetKind = 'Server' | 'Switch' | 'Sensor' | 'Gateway';

export interface Asset {
  id: string;
  name?: string;
  kind: AssetKind;
  /** Rated throughput in units/hour. */
  capacity: number;
  enclosure?: string;
  powerSource?: string;
  tier?: string;
  placement?: string;
  mounting?: string;
  coolingType?: string;
  installedAtUTC?: string;
  vendor?: string;
  notes?: string;
  accountId: string;
}

export interface Site {
  identifiers: { id: string };
  address: Address;
  assets: Asset[];
}

export interface Account {
  identifiers: { id: string };
  displayName: { firstName: string; lastName: string; companyName?: string };
  billingAddress?: Address;
  contactInformation?: {
    emailAddresses: { email: string }[];
    phoneNumbers: { number: string; phoneType: PhoneType }[];
  };
  sites?: Site[];
}

/** One telemetry sample for an asset: 16 numeric metrics plus an optional report. */
export interface Reading {
  id: string;
  date: string;
  reportUrl?: string;
  reportId?: string;
  workOrderId?: string;
  loadFactor: number;
  inputVoltage: number;
  outputVoltage: number;
  throughput: number;
  errorRate: number;
  queueDepth: number;
  memoryUsed: number;
  diskUsed: number;
  packetLoss: number;
  jitter: number;
  latency: number;
  uptimeDays: number;
  fanSpeed: number;
  powerDraw: number;
  peakDraw: number;
  temperature: number;
}

/** The server's paging contract. */
export interface ListingCriteria {
  ascending?: boolean;
  pageNumber: number;
  pageSize: number;
  sortColumn?: string;
}

export interface SearchAccountRequest {
  listingCriteria?: Partial<ListingCriteria>;
  queryCriteria?: string;
}

export interface PagedResponse<T> {
  items: T[];
  totalItemCount: number;
  /** The normalized request, echoed back by the server. */
  requestCriteria: ListingCriteria;
}
```
