# Sortable

A list whose items people put in order by dragging them or with the keyboard.

Source: [sortable.tsx](https://github.com/jakejarvis/jarvis-ui/blob/main/src/components/ui/sortable.tsx)

```tsx
import * as React from "react";

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Item, ItemActions, ItemContent, ItemDescription, ItemTitle } from "@/components/ui/item";
import { Sortable, SortableHandle, SortableItem } from "@/components/ui/sortable";
import { people } from "@/examples/data";

const initialTasks = [
  {
    id: "freeze",
    title: "Freeze the release branch",
    detail: "Cut v2.4 from main",
    owner: people[0],
  },
  { id: "notes", title: "Write the release notes", detail: "Changes since v2.3", owner: people[1] },
  { id: "staging", title: "Deploy to staging", detail: "Run the smoke tests", owner: people[2] },
  {
    id: "announce",
    title: "Announce the release",
    detail: "Blog post and changelog",
    owner: people[3],
  },
];

export default function SortableDemo() {
  const [tasks, setTasks] = React.useState(initialTasks);

  return (
    <Sortable value={tasks} onValueChange={setTasks} className="w-full max-w-sm">
      {tasks.map((task) => (
        <SortableItem
          key={task.id}
          value={task.id}
          label={task.title}
          render={<Item variant="outline" size="sm" className="gap-2 ps-1.5" />}
        >
          <SortableHandle />
          <ItemContent>
            <ItemTitle>{task.title}</ItemTitle>
            <ItemDescription>{task.detail}</ItemDescription>
          </ItemContent>
          <ItemActions>
            <Avatar>
              <AvatarImage src={task.owner.image} alt={task.owner.name} />
              <AvatarFallback>{task.owner.initials}</AvatarFallback>
            </Avatar>
          </ItemActions>
        </SortableItem>
      ))}
    </Sortable>
  );
}
```

## Installation

Add the registry, once per project:

```bash
npx shadcn@latest registry add @jarvis=https://ui.jarv.is/r/{name}.json
```

Then add the component:

```bash
npx shadcn@latest add @jarvis/sortable
```

## Usage

```tsx
import * as React from "react";

import { Item } from "@/components/ui/item";
import { Sortable, SortableHandle, SortableItem } from "@/components/ui/sortable";

export function Tasks() {
  const [tasks, setTasks] = React.useState([
    { id: "notes", title: "Write the release notes" },
    { id: "deploy", title: "Deploy to staging" },
  ]);

  return (
    <Sortable value={tasks} onValueChange={setTasks}>
      {tasks.map((task) => (
        <SortableItem key={task.id} value={task.id} render={<Item variant="outline" size="sm" />}>
          <SortableHandle />
          {task.title}
        </SortableItem>
      ))}
    </Sortable>
  );
}
```

Pass `Sortable` your items as `value` and render a `SortableItem` for each, in the same order,
with the item's unique value. That's the item itself for strings and numbers, or else its `id`, and
`getItemValue` returns another. When an item is dropped somewhere new, `onValueChange` gets the
items in their new order.

People drag an item by its `SortableHandle`, or by the whole item when it has no handle. As an item
moves, the others slide aside to open its place, and it settles into the place when it's dropped.
Dragging near the edge of the page, of a box the list sits in, or of the list itself when it
scrolls, scrolls it.

Each handle, or each item without one, is a stop in the tab order. Space or Enter picks an item
up, the arrow keys along the list move it, and Home and End move it to the start or end. Space or
Enter drops it, and Escape, or moving focus away, puts it back. Screen readers that activate
controls with a click, as VoiceOver and TalkBack do, pick an item up and drop it the same way.
They hear where the item is as it moves, and `label` on a `SortableItem` names it, in place of
its text.

`Sortable` lays the items out in a column, or a row with `orientation="horizontal"`, and gives
the items no look of their own. Render each as an [Item](https://ui.jarv.is/components/item.md), or anything with a
background, since a lifted item passes over the others. It's a list and its items are list items,
unless it's rendered as part of a table or its items have no handles, since an item without a
handle is a button.

## Composition

- `Sortable`
  - `SortableItem`
    - `SortableHandle`

## Examples

### Without a handle

Leave out `SortableHandle` to drag the whole item. On a touch screen, people hold the item for a
moment first, so swiping across the list still scrolls the page. The item is then a button, which
screen readers read as one piece, so keep controls out of it.

```tsx
import * as React from "react";

import { Item, ItemContent, ItemTitle } from "@/components/ui/item";
import { Sortable, SortableItem } from "@/components/ui/sortable";

export default function SortableWithoutHandle() {
  const [priorities, setPriorities] = React.useState([
    "Faster page loads",
    "Offline support",
    "Dark mode",
    "Keyboard shortcuts",
    "Two-factor sign-in",
  ]);

  return (
    <Sortable value={priorities} onValueChange={setPriorities} className="w-full max-w-xs gap-1.5">
      {priorities.map((priority, index) => (
        <SortableItem
          key={priority}
          value={priority}
          label={priority}
          render={<Item variant="outline" size="xs" className="gap-2.5 px-2.5" />}
        >
          <span className="w-4 text-right text-xs text-faint tabular-nums">{index + 1}</span>
          <ItemContent>
            <ItemTitle>{priority}</ItemTitle>
          </ItemContent>
        </SortableItem>
      ))}
    </Sortable>
  );
}
```

### Horizontal

`orientation="horizontal"` runs the items in a row, which the left and right arrow keys move along.

```tsx
import * as React from "react";

import { Item } from "@/components/ui/item";
import { Sortable, SortableHandle, SortableItem } from "@/components/ui/sortable";

export default function SortableHorizontal() {
  const [columns, setColumns] = React.useState(["Name", "Status", "Owner", "Due"]);

  return (
    <div className="flex w-full max-w-md flex-col gap-2">
      <Sortable
        value={columns}
        onValueChange={setColumns}
        orientation="horizontal"
        aria-label="Columns"
        className="gap-1.5"
      >
        {columns.map((column) => (
          <SortableItem
            key={column}
            value={column}
            render={
              <Item variant="outline" size="xs" className="w-auto shrink-0 gap-1 ps-0.5 pe-2.5" />
            }
          >
            <SortableHandle />
            <span className="font-medium">{column}</span>
          </SortableItem>
        ))}
      </Sortable>
      <p className="text-sm text-muted-foreground">
        Columns show in the order <span className="text-foreground">{columns.join(", ")}</span>.
      </p>
    </div>
  );
}
```

### Interactive content

Give an item that holds buttons, switches, links or fields a `SortableHandle`. Its controls then
work as usual, and the handle moves it.

```tsx
import { BellIcon, MailIcon, MessageSquareIcon, SmartphoneIcon } from "lucide-react";
import * as React from "react";

import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item";
import { Sortable, SortableHandle, SortableItem } from "@/components/ui/sortable";
import { Switch } from "@/components/ui/switch";

const initialChannels = [
  { id: "push", name: "Push", detail: "On this device", icon: BellIcon, enabled: true },
  { id: "email", name: "Email", detail: "ada@example.com", icon: MailIcon, enabled: true },
  {
    id: "sms",
    name: "Text message",
    detail: "Ending in 4242",
    icon: SmartphoneIcon,
    enabled: false,
  },
  { id: "slack", name: "Slack", detail: "#alerts", icon: MessageSquareIcon, enabled: false },
];

export default function SortableInteractiveContent() {
  const [channels, setChannels] = React.useState(initialChannels);

  const toggle = (id: string, enabled: boolean) =>
    setChannels((current) =>
      current.map((channel) => (channel.id === id ? { ...channel, enabled } : channel)),
    );

  return (
    <Sortable value={channels} onValueChange={setChannels} className="w-full max-w-sm gap-1.5">
      {channels.map((channel) => (
        <SortableItem
          key={channel.id}
          value={channel.id}
          label={channel.name}
          render={<Item variant="outline" size="sm" className="gap-2 ps-1.5" />}
        >
          <SortableHandle />
          <ItemMedia variant="icon">
            <channel.icon />
          </ItemMedia>
          <ItemContent>
            <ItemTitle>{channel.name}</ItemTitle>
            <ItemDescription>{channel.detail}</ItemDescription>
          </ItemContent>
          <ItemActions>
            <Switch
              checked={channel.enabled}
              onCheckedChange={(enabled) => toggle(channel.id, enabled)}
              aria-label={`Notify by ${channel.name.toLowerCase()}`}
            />
          </ItemActions>
        </SortableItem>
      ))}
    </Sortable>
  );
}
```

### Disabled

`disabled` on a `SortableItem` keeps that item where it is while the others move around it.
`disabled` on `Sortable` keeps every item in place.

```tsx
import { InboxIcon, LockIcon } from "lucide-react";
import * as React from "react";

import { FieldLabel } from "@/components/ui/field";
import { Item, ItemActions, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item";
import { Sortable, SortableHandle, SortableItem } from "@/components/ui/sortable";
import { Switch } from "@/components/ui/switch";

export default function SortableDisabled() {
  const [views, setViews] = React.useState(["Inbox", "Today", "Upcoming", "Projects", "Archive"]);
  const [locked, setLocked] = React.useState(false);

  return (
    <div className="flex w-full max-w-xs flex-col gap-4">
      <FieldLabel className="gap-2 font-normal">
        <Switch checked={locked} onCheckedChange={setLocked} />
        Lock the order
      </FieldLabel>
      <Sortable value={views} onValueChange={setViews} disabled={locked} className="gap-1.5">
        {views.map((view) => (
          <SortableItem
            key={view}
            value={view}
            disabled={view === "Inbox"}
            render={<Item variant="outline" size="xs" className="gap-2 ps-1" />}
          >
            <SortableHandle />
            {view === "Inbox" && (
              <ItemMedia>
                <InboxIcon className="text-icon" />
              </ItemMedia>
            )}
            <ItemContent>
              <ItemTitle>{view}</ItemTitle>
            </ItemContent>
            {view === "Inbox" && (
              <ItemActions>
                <LockIcon
                  role="img"
                  aria-label="Always first"
                  className="size-3.5 text-icon-muted"
                />
              </ItemActions>
            )}
          </SortableItem>
        ))}
      </Sortable>
    </div>
  );
}
```

### Table rows

Render `Sortable` as a [Table](https://ui.jarv.is/components/table.md)'s `TableBody`, and each item as a `TableRow`.
`Sortable` then leaves the layout and the roles to the table, with or without handles. A row's border goes by its place
in the table, which only changes on the drop, so this example gives every row a border while one
is lifted.

```tsx
import * as React from "react";

import { Sortable, SortableHandle, SortableItem } from "@/components/ui/sortable";
import { Status } from "@/components/ui/status";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";

const initialStages = [
  { id: "lint", name: "Lint", duration: "18s", status: "success" },
  { id: "test", name: "Unit tests", duration: "2m 41s", status: "success" },
  { id: "build", name: "Build", duration: "1m 06s", status: "warning" },
  { id: "e2e", name: "End-to-end tests", duration: "6m 12s", status: "neutral" },
  { id: "deploy", name: "Deploy", duration: "—", status: "neutral" },
] as const;

const labels = { success: "Passed", warning: "Slow", neutral: "Queued" };

export default function SortableTable() {
  const [stages, setStages] = React.useState([...initialStages]);

  return (
    <Table className="max-w-lg">
      <TableHeader>
        <TableRow>
          <TableHead className="w-8">
            <span className="sr-only">Reorder</span>
          </TableHead>
          <TableHead>Stage</TableHead>
          <TableHead>Status</TableHead>
          <TableHead className="text-right">Duration</TableHead>
        </TableRow>
      </TableHeader>
      <Sortable
        value={stages}
        onValueChange={setStages}
        render={<TableBody />}
        className="data-dragging:[&>tr>td]:border-b-[0.5px] data-dragging:[&>tr>td]:border-border-muted"
      >
        {stages.map((stage) => (
          <SortableItem
            key={stage.id}
            value={stage.id}
            label={stage.name}
            render={<TableRow className="data-dragging:bg-background" />}
          >
            <TableCell className="py-1 pe-0">
              <SortableHandle />
            </TableCell>
            <TableCell className="font-medium">{stage.name}</TableCell>
            <TableCell>
              <Status variant={stage.status}>{labels[stage.status]}</Status>
            </TableCell>
            <TableCell className="text-right text-muted-foreground tabular-nums">
              {stage.duration}
            </TableCell>
          </SortableItem>
        ))}
      </Sortable>
    </Table>
  );
}
```

### In a form

Give a `Field` or `Sortable` a `name`, and each item's value submits under it in order, so
`formData.getAll(name)` reads the ranking. Pass `form` with a form's `id` when the list sits
outside it. With a `Form`, `onFormSubmit` gets the values as an array. A `FieldLabel` names the
list, and the field's description and errors are read out with it. An item mid-drag submits from
where it was until it's dropped. A form reset puts the items back in the order they first had,
through `onValueChange` with the reason `reset`. A disabled `Sortable` still submits its order.

```tsx
import * as React from "react";

import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Form } from "@/components/ui/form";
import { Item, ItemContent, ItemTitle } from "@/components/ui/item";
import { Sortable, SortableHandle, SortableItem } from "@/components/ui/sortable";
import { toast } from "@/components/ui/toast";

const initialFeatures = ["Dark mode", "Offline sync", "Shared folders", "Keyboard shortcuts"];

export default function SortableForm() {
  const [features, setFeatures] = React.useState(initialFeatures);

  return (
    <Form
      className="w-full max-w-xs"
      onFormSubmit={(values) =>
        toast.add({
          title: "Thanks for ranking",
          description: (values.features as string[]).join(", "),
        })
      }
    >
      <Field name="features">
        <FieldLabel>Rank these features</FieldLabel>
        <Sortable value={features} onValueChange={setFeatures} className="w-full gap-1.5">
          {features.map((feature) => (
            <SortableItem
              key={feature}
              value={feature}
              render={<Item variant="outline" size="xs" className="gap-2 ps-1" />}
            >
              <SortableHandle />
              <ItemContent>
                <ItemTitle>{feature}</ItemTitle>
              </ItemContent>
            </SortableItem>
          ))}
        </Sortable>
        <FieldDescription>Put the one you want most first.</FieldDescription>
      </Field>
      <div className="flex gap-2">
        <Button type="submit" variant="primary">
          Send ranking
        </Button>
        <Button type="reset">Reset</Button>
      </div>
    </Form>
  );
}
```

### Scrolling

A list in a [Scroll Area](https://ui.jarv.is/components/scroll-area.md) scrolls while an item is dragged near its top
or bottom, and moving an item with the keyboard keeps it in view.

```tsx
import * as React from "react";

import { Item, ItemContent, ItemTitle } from "@/components/ui/item";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sortable, SortableHandle, SortableItem } from "@/components/ui/sortable";

export default function SortableScrollable() {
  const [tracks, setTracks] = React.useState([
    "Morning Light",
    "Paper Boats",
    "Slow Tide",
    "Northern Lines",
    "Glasshouse",
    "Afterglow",
    "Low Orbit",
    "Field Notes",
    "Static Bloom",
    "Long Way Home",
    "Satellites",
    "Last Call",
  ]);

  return (
    <ScrollArea className="h-64 w-full max-w-xs rounded-md border-[0.5px] border-border-muted">
      <Sortable value={tracks} onValueChange={setTracks} aria-label="Queue" className="gap-0.5 p-1">
        {tracks.map((track, index) => (
          <SortableItem
            key={track}
            value={track}
            label={track}
            render={<Item size="xs" className="gap-2 ps-1 data-dragging:bg-background" />}
          >
            <SortableHandle />
            <span className="w-4 text-right text-xs text-faint tabular-nums">{index + 1}</span>
            <ItemContent>
              <ItemTitle>{track}</ItemTitle>
            </ItemContent>
          </SortableItem>
        ))}
      </Sortable>
    </ScrollArea>
  );
}
```

### Saving the order

`onValueChange` runs once per drop, and only when the order changed. Its second argument has the
`reason` `move`, and says which item moved and its index before and after, for saving the move. The items wait in their new
places for the new order, so it can arrive a moment later, as from a transition. If it hasn't
after a second, they slide back.

```tsx
import * as React from "react";

import { Item, ItemContent, ItemTitle } from "@/components/ui/item";
import { Sortable, SortableHandle, SortableItem } from "@/components/ui/sortable";

const initialMetrics = ["Revenue", "Active users", "Conversion", "Churn"];

export default function SortableSaveOrder() {
  const [metrics, setMetrics] = React.useState(initialMetrics);
  const [lastMove, setLastMove] = React.useState<string | null>(null);

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <Sortable
        value={metrics}
        onValueChange={(next, details) => {
          setMetrics(next);
          if (details.reason === "move") {
            const { item, from, to } = details;
            setLastMove(`Saved: ${item} moved from ${from + 1} to ${to + 1}.`);
          }
        }}
        className="gap-1.5"
      >
        {metrics.map((metric) => (
          <SortableItem
            key={metric}
            value={metric}
            render={<Item variant="outline" size="xs" className="gap-2 ps-1" />}
          >
            <SortableHandle />
            <ItemContent>
              <ItemTitle>{metric}</ItemTitle>
            </ItemContent>
          </SortableItem>
        ))}
      </Sortable>
      <p className="text-sm text-muted-foreground">
        {lastMove ?? "Move a metric to save the order."}
      </p>
    </div>
  );
}
```

## Props

`SortableHandle` shows a grip until you pass it children of your own. An item's `data-dragging`
attribute marks it while it's lifted, and `data-dropping` while it settles.

| Part | Extends |
| --- | --- |
| `Sortable` | `<div>` |
| `SortableItem` | `<div>` |
| `SortableHandle` | `<button>` |

The tables leave out `className`, `style` and `render`. Parts built on Base UI take them as its [styling](https://base-ui.com/react/handbook/styling) and [composition](https://base-ui.com/react/handbook/composition) guides describe.

### Sortable

| Prop             | Type                                                                    | Description                                                                                                                                                                         |
| ---------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `value`          | `unknown[]`                                                             | The items, in order. Render a `SortableItem` for each, in the same order.                                                                                                           |
| `onValueChange?` | `((value: unknown[], details: SortableChangeDetails<unknown>) => void)` | Called with the items in their new order when one is dropped in a new place, with which item moved and where, and when the list's form resets, with the items in their first order. |
| `getItemValue?`  | `((item: unknown) => string \| number)`                                 | Returns an item's unique value, which its `SortableItem` takes as `value`. Defaults to the item itself for strings and numbers, or else its `id`. Default: `defaultItemValue`       |
| `orientation?`   | `"horizontal" \| "vertical"`                                            | The direction the items run in, which picks the arrow keys that move them. Default: `"vertical"`                                                                                    |
| `disabled?`      | `boolean`                                                               | Keeps every item in place. The order still submits with a form. Default: `false`                                                                                                    |
| `name?`          | `string`                                                                | Submits each item's value with a form under this name, in order.                                                                                                                    |
| `form?`          | `string`                                                                | The `id` of the form to submit the order with, when the list isn't inside it.                                                                                                       |

### SortableItem

| Prop        | Type               | Description                                                          |
| ----------- | ------------------ | -------------------------------------------------------------------- |
| `value`     | `string \| number` | The item's unique value, which `getItemValue` returns for it.        |
| `label?`    | `string`           | Names the item to screen readers as it moves. Defaults to its text.  |
| `disabled?` | `boolean`          | Keeps the item in place. The others move around it. Default: `false` |
