Data Grid

Reorder Rows

A grid that reorders rows: the grip drags with the mouse while the arrows, Home and End move a row from the keyboard and announce its new position.

  • datagrid
  • drag-and-drop
  • reorder
  • table

Preview

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 3. The agent opens the link and installs the component from the registry.

put this in the header: https://vibeui.ru/c/datagrid-015?lang=en

Маршрут обработки заявки

Шагов в маршруте: 6

Drag the grip on the left, or focus it and press the arrows
ШагОтветственныйСрок
1Заявка принятаПриёмка10 мин
2Проверка комплектностиСклад1 ч
3Оценка стоимостиСервис2 ч
4Согласование с клиентомМенеджер4 ч
5РемонтМастерская2 дня
6ВыдачаПриёмка20 мин

Стрелки — на шаг, Home и End — в начало и конец списка.

Show the full instructions

What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.

# Install and place "datagrid-015" (Reorder Rows) from VibeUI

## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/datagrid-015.json

Registry item: https://vibeui.ru/r/datagrid-015.json
Installs to: components/vibeui/datagrid-015.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.

Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.

## 2. What it is
A grid that reorders rows: the grip drags with the mouse while the arrows, Home and End move a row from the keyboard and announce its new position.

A grid that reorders rows by dragging and from the keyboard: the position is announced through aria-live. Zero dependencies, one file.

## 3. How to use it
import { Datagrid015 } from "@/components/vibeui/datagrid-015"

<Datagrid015
  caption="You set the order of the steps"
  liveTemplate="{шаг}: position {позиция} of {всего}"
/>

Read the installed file for the full prop list.

## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.

Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)

## 5. Keep exactly as installed
- the local --vibeui-datagrid-015-* palette — do not swap it for your theme tokens
- the keyboard path on the grip: mouse dragging alone cuts off part of your users
- announcing the new position through aria-live — otherwise the row simply vanishes for a screen reader
- the grip's aria-label naming the step and its current position: identical grips without context are useless
- highlighting the drop target with a stripe, not only a fill: the fill matches the hover state
- the grid's own light surface: on the dark catalog card dark text disappears without it

## 6. You may change
- the line under the toolbar through caption
- the wording of the announcement through liveTemplate with its placeholders
- the route steps through rows
- the accent color through accent

## 7. Rules
- The order lives in component state: sending the new order to the server is yours to add.
- Dragging rests on the HTML drag events: on touch screens the arrow keys remain, and that is a deliberate trade-off.
- The whole row accepts the drop, not just the grip — otherwise the target is too hard to hit with a mouse.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.

## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.

For developers

npx shadcn@latest add https://vibeui.ru/r/datagrid-015.json
https://vibeui.ru/r/datagrid-015.json

Компонент самодостаточен: один файл, без зависимостей, палитра в --vibeui-datagrid-015-*. Клиентский: useState хранит порядок строк, идентификатор перетаскиваемой строки и строки под курсором. Ручка захвата — кнопка с draggable: стрелки вверх и вниз двигают строку на шаг, Home и End отправляют её в начало и конец, поэтому порядок меняется без мыши. Каждое перемещение пишется в область с role=status и aria-live: для скринридера строка иначе просто исчезает с прежнего места. Строка под курсором помечена и заливкой, и полосой сверху через inset box-shadow — одна заливка совпадает с наведением. Номер позиции пересчитывается из индекса, а не хранится в данных.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

"use client"

import { useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Datagrid015Row = {
  id: string
  step: string
  owner: string
  duration: string
}

export type Datagrid015Props = Omit<
  ComponentPropsWithoutRef<"section">,
  "children"
> & {
  rows?: Datagrid015Row[]
  caption?: string
  liveTemplate?: string
  accent?: string
}

// Идея компонента: порядок строк задаётся перетаскиванием, но мышь —
// не единственный способ. Ручка захвата это кнопка: стрелки вверх и вниз
// двигают строку на шаг, Home и End отправляют её в начало и конец.
// Каждое перемещение объявляется через aria-live, иначе для скринридера
// строка просто исчезает с прежнего места.
const STYLES = `
:where([data-vibeui-block="datagrid-015"]){
--vibeui-datagrid-015-bg:oklch(1 0 0);
--vibeui-datagrid-015-fg:oklch(0.23 0.014 285);
--vibeui-datagrid-015-muted:oklch(0.55 0.014 285);
--vibeui-datagrid-015-border:oklch(0.92 0.006 285);
--vibeui-datagrid-015-head:oklch(0.975 0.003 285);
--vibeui-datagrid-015-accent:oklch(0.53 0.16 300);
--vibeui-datagrid-015-drop:oklch(0.96 0.03 300);
--vibeui-datagrid-015-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="datagrid-015"]{
box-sizing:border-box;width:100%;max-width:48rem;margin:0 auto;
background:var(--vibeui-datagrid-015-bg);color:var(--vibeui-datagrid-015-fg);
border:1px solid var(--vibeui-datagrid-015-border);border-radius:0.875rem;
font-family:var(--vibeui-datagrid-015-font);overflow:hidden;
}
[data-vibeui-block="datagrid-015"] *{box-sizing:border-box}
[data-vibeui-block="datagrid-015"] [data-part="bar"]{
display:flex;flex-wrap:wrap;align-items:center;gap:0.5rem;
padding:0.75rem 0.875rem;border-bottom:1px solid var(--vibeui-datagrid-015-border);
}
[data-vibeui-block="datagrid-015"] [data-part="title"]{margin:0;font-size:0.875rem;font-weight:650;margin-inline-end:auto}
[data-vibeui-block="datagrid-015"] [data-part="live"]{margin:0;font-size:0.75rem;color:var(--vibeui-datagrid-015-muted)}
[data-vibeui-block="datagrid-015"] [data-part="scroll"]{overflow-x:auto}
[data-vibeui-block="datagrid-015"] [data-part="scroll"]:focus-visible{outline:2px solid var(--vibeui-datagrid-015-accent);outline-offset:-2px}
[data-vibeui-block="datagrid-015"] table{width:100%;border-collapse:collapse;font-size:0.8125rem}
[data-vibeui-block="datagrid-015"] caption{
padding:0.625rem 0.875rem;text-align:left;font-size:0.75rem;color:var(--vibeui-datagrid-015-muted);caption-side:top;
}
[data-vibeui-block="datagrid-015"] th,
[data-vibeui-block="datagrid-015"] td{
padding:0.4375rem 0.875rem;text-align:left;white-space:nowrap;
border-top:1px solid var(--vibeui-datagrid-015-border);
}
[data-vibeui-block="datagrid-015"] thead th{background:var(--vibeui-datagrid-015-head);font-weight:600}
[data-vibeui-block="datagrid-015"] [data-part="rank"]{
width:2.5rem;text-align:center;font-variant-numeric:tabular-nums;color:var(--vibeui-datagrid-015-muted);
}
[data-vibeui-block="datagrid-015"] [data-part="handle-cell"]{width:2.75rem;padding-inline:0.5rem}
[data-vibeui-block="datagrid-015"] [data-part="handle"]{
appearance:none;cursor:grab;font:inherit;line-height:1;
width:1.75rem;height:1.75rem;border-radius:0.5rem;
border:1px solid var(--vibeui-datagrid-015-border);
background:var(--vibeui-datagrid-015-bg);color:var(--vibeui-datagrid-015-muted);
transition:color .15s ease,border-color .15s ease;
}
[data-vibeui-block="datagrid-015"] [data-part="handle"]:hover{color:var(--vibeui-datagrid-015-accent);border-color:var(--vibeui-datagrid-015-accent)}
[data-vibeui-block="datagrid-015"] [data-part="handle"]:focus-visible{outline:2px solid var(--vibeui-datagrid-015-accent);outline-offset:2px}
[data-vibeui-block="datagrid-015"] tbody tr[data-dragging="true"]{opacity:.45}
[data-vibeui-block="datagrid-015"] tbody tr[data-over="true"] td,
[data-vibeui-block="datagrid-015"] tbody tr[data-over="true"] th{
background:var(--vibeui-datagrid-015-drop);box-shadow:inset 0 2px 0 var(--vibeui-datagrid-015-accent);
}
[data-vibeui-block="datagrid-015"] [data-part="owner"]{color:var(--vibeui-datagrid-015-muted)}
[data-vibeui-block="datagrid-015"] [data-part="hint"]{
margin:0;padding:0.5rem 0.875rem 0.75rem;font-size:0.6875rem;color:var(--vibeui-datagrid-015-muted);
border-top:1px solid var(--vibeui-datagrid-015-border);
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="datagrid-015"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_ROWS: Datagrid015Row[] = [
  { id: "s1", step: "Заявка принята", owner: "Приёмка", duration: "10 мин" },
  { id: "s2", step: "Проверка комплектности", owner: "Склад", duration: "1 ч" },
  { id: "s3", step: "Оценка стоимости", owner: "Сервис", duration: "2 ч" },
  {
    id: "s4",
    step: "Согласование с клиентом",
    owner: "Менеджер",
    duration: "4 ч",
  },
  { id: "s5", step: "Ремонт", owner: "Мастерская", duration: "2 дня" },
  { id: "s6", step: "Выдача", owner: "Приёмка", duration: "20 мин" },
]

function moveItem<T>(list: T[], from: number, to: number) {
  if (to < 0 || to >= list.length || from === to) {
    return list
  }

  const next = [...list]
  const [item] = next.splice(from, 1)
  next.splice(to, 0, item)

  return next
}

/**
 * Сетка с перестановкой строк перетаскиванием и стрелками с клавиатуры:
 * порядок шагов задаёт сам читатель. Один файл, ноль зависимостей.
 */
export function Datagrid015({
  rows = DEFAULT_ROWS,
  caption = "Тяните ручку слева или наведите на неё фокус и жмите стрелки",
  liveTemplate = "«{шаг}» теперь на позиции {позиция} из {всего}",
  accent,
  className,
  style,
  ...props
}: Datagrid015Props) {
  const [order, setOrder] = useState(rows)
  const [dragging, setDragging] = useState<string | null>(null)
  const [over, setOver] = useState<string | null>(null)
  const [message, setMessage] = useState("")

  const palette = {
    ...(accent ? { "--vibeui-datagrid-015-accent": accent } : null),
    ...style,
  } as CSSProperties

  function announce(list: Datagrid015Row[], id: string) {
    const index = list.findIndex((row) => row.id === id)

    setMessage(
      liveTemplate
        .replace("{шаг}", list[index].step)
        .replace("{позиция}", String(index + 1))
        .replace("{всего}", String(list.length)),
    )
  }

  function relocate(id: string, to: number) {
    setOrder((current) => {
      const from = current.findIndex((row) => row.id === id)
      const next = moveItem(current, from, to)
      announce(next, id)

      return next
    })
  }

  return (
    <>
      <style href="vibeui-datagrid-015" precedence="medium">
        {STYLES}
      </style>
      <section
        {...props}
        data-vibeui-block="datagrid-015"
        className={className}
        style={palette}
      >
        <div data-part="bar">
          <h3 data-part="title">Маршрут обработки заявки</h3>
          <p data-part="live" role="status" aria-live="polite">
            {message || `Шагов в маршруте: ${order.length}`}
          </p>
        </div>
        <div
          data-part="scroll"
          role="region"
          aria-label="Таблица шагов маршрута, прокручивается вбок"
          tabIndex={0}
        >
          <table>
            <caption>{caption}</caption>
            <thead>
              <tr>
                <th scope="col" data-part="handle-cell">
                  <span aria-hidden="true">⇅</span>
                  <span hidden>Перемещение</span>
                </th>
                <th scope="col" data-part="rank">
                  №
                </th>
                <th scope="col">Шаг</th>
                <th scope="col">Ответственный</th>
                <th scope="col">Срок</th>
              </tr>
            </thead>
            <tbody>
              {order.map((row, index) => (
                <tr
                  key={row.id}
                  data-dragging={dragging === row.id ? "true" : undefined}
                  data-over={
                    over === row.id && dragging !== row.id ? "true" : undefined
                  }
                  onDragOver={(event) => {
                    event.preventDefault()
                    setOver(row.id)
                  }}
                  onDrop={(event) => {
                    event.preventDefault()

                    if (dragging) {
                      relocate(dragging, index)
                    }

                    setDragging(null)
                    setOver(null)
                  }}
                >
                  <td data-part="handle-cell">
                    <button
                      type="button"
                      data-part="handle"
                      draggable
                      aria-label={`Переместить шаг «${row.step}», сейчас позиция ${index + 1} из ${order.length}`}
                      onDragStart={() => setDragging(row.id)}
                      onDragEnd={() => {
                        setDragging(null)
                        setOver(null)
                      }}
                      onKeyDown={(event) => {
                        if (event.key === "ArrowUp") {
                          event.preventDefault()
                          relocate(row.id, index - 1)
                        }

                        if (event.key === "ArrowDown") {
                          event.preventDefault()
                          relocate(row.id, index + 1)
                        }

                        if (event.key === "Home") {
                          event.preventDefault()
                          relocate(row.id, 0)
                        }

                        if (event.key === "End") {
                          event.preventDefault()
                          relocate(row.id, order.length - 1)
                        }
                      }}
                    >
                      <span aria-hidden="true">⣿</span>
                    </button>
                  </td>
                  <td data-part="rank">{index + 1}</td>
                  <th scope="row">{row.step}</th>
                  <td data-part="owner">{row.owner}</td>
                  <td>{row.duration}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
        <p data-part="hint">
          Стрелки — на шаг, Home и End — в начало и конец списка.
        </p>
      </section>
    </>
  )
}