Data Grid

Row Actions Menu

Сетка с колонкой действий: пять команд убраны в меню, оно ходит стрелками, закрывается по Escape с возвратом фокуса, а удаление отделено линией.

  • datagrid
  • menu
  • row-actions
  • table

Превью

1440pxHost theme

Использовать с AI

  1. 1. Скопируйте ссылку.
  2. 2. Напишите агенту своими словами и вставьте её в предложение.
  3. 3. Агент откроет ссылку и поставит компонент из registry.

размести это в шапке: https://vibeui.ru/c/datagrid-028

Заказы в работе

Действие ещё не выбрано

Действия строки убраны в меню, оно управляется стрелками
ЗаказЗаказчикСтатусСумма, ₽
ЗК-7710Артель «Кама»Собирается184 300
ЗК-7711Северный ПортОплачен92 100
ЗК-7712МостовикВ доставке461 000
ЗК-7713Гранд-СервисЧерновик15 700
Показать полную инструкцию

То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.

# Установка и размещение «datagrid-028» (Row Actions Menu) из VibeUI

## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/datagrid-028.json

Registry item: https://vibeui.ru/r/datagrid-028.json
Ставится в: components/vibeui/datagrid-028.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.

Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.

## 2. Что это
Сетка с колонкой действий: пять команд убраны в меню, оно ходит стрелками, закрывается по Escape с возвратом фокуса, а удаление отделено линией.

Сетка с колонкой действий и меню с клавиатурной навигацией и возвратом фокуса. Ноль зависимостей, один файл.

## 3. Как использовать
import { Datagrid028 } from "@/components/vibeui/datagrid-028"

<Datagrid028
  caption="Действия строки в меню"
  triggerLabel="Ещё"
/>

Полный список пропсов — в установленном файле.

## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.

Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)

## 5. Сохранить как установлено
- локальную палитру --vibeui-datagrid-028-* и не подменять её токенами вашей темы
- возврат фокуса на кнопку при закрытии: без него фокус улетает в начало страницы
- стрелки, Home и End внутри меню — мышиное меню без клавиатуры отрезает часть пользователей
- role=menu и role=menuitem на настоящих кнопках, а не div со слушателем клика
- отделение разрушительного пункта линией и словом «Удалить», а не одним красным цветом
- aria-haspopup вместе с aria-expanded на кнопке, иначе меню не объявляется как всплывающее

## 6. Можно менять
- подпись под шапкой через caption
- подпись кнопки меню через triggerLabel
- список заказов через rows
- акцентный цвет через accent

## 7. Правила
- Меню не закрывается по клику мимо: добавьте обработчик на документ, если это нужно.
- Удаление ничего не удаляет — подтверждение и сам запрос вешайте снаружи.
- Контейнер прокрутки оставлен с overflow-y:visible, иначе меню обрезалось бы его нижней кромкой.
- Не выносите CSS-переменные в globals.css: компонент обязан оставаться одним файлом.

## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.

Для разработчика

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

Компонент самодостаточен: один файл, без зависимостей, палитра в --vibeui-datagrid-028-*. Клиентский: useState хранит идентификатор открытой строки, ref держит кнопки-триггеры, useId даёт стабильные id панелей для aria-controls. Меню собрано по клавиатурной модели: стрелка вниз на кнопке открывает его, стрелки водят по пунктам, Home и End прыгают к краям, Escape закрывает и возвращает фокус на кнопку — без возврата фокус улетает в начало страницы. Разметка честная: список с role=menu и кнопки с role=menuitem. Разрушительный пункт отделён линией и помечен цветом вместе со словом «Удалить»: один цвет предупреждением не является.

Исходник компонента

Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.

"use client"

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

export type Datagrid028Row = {
  id: string
  order: string
  customer: string
  status: string
  amount: number
}

export type Datagrid028Props = Omit<
  ComponentPropsWithoutRef<"section">,
  "children"
> & {
  rows?: Datagrid028Row[]
  caption?: string
  triggerLabel?: string
  accent?: string
}

// Идея компонента: действия строки убраны в меню, а не разложены кнопками
// по последней колонке — иначе на пяти действиях таблица превращается в
// панель инструментов. Меню собрано по клавиатурной модели: стрелки водят
// по пунктам, Escape закрывает и возвращает фокус на кнопку, Home и End
// прыгают к краям. Разрушительный пункт отделён линией и помечен цветом
// вместе со словом «Удалить» — один цвет предупреждением не является.
const STYLES = `
:where([data-vibeui-block="datagrid-028"]){
--vibeui-datagrid-028-bg:oklch(1 0 0);
--vibeui-datagrid-028-fg:oklch(0.23 0.014 285);
--vibeui-datagrid-028-muted:oklch(0.55 0.014 285);
--vibeui-datagrid-028-border:oklch(0.92 0.006 285);
--vibeui-datagrid-028-head:oklch(0.975 0.003 285);
--vibeui-datagrid-028-accent:oklch(0.5 0.15 285);
--vibeui-datagrid-028-danger:oklch(0.53 0.19 27);
--vibeui-datagrid-028-shadow:oklch(0.23 0.014 285 / 16%);
--vibeui-datagrid-028-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="datagrid-028"]{
box-sizing:border-box;width:100%;max-width:50rem;margin:0 auto;
background:var(--vibeui-datagrid-028-bg);color:var(--vibeui-datagrid-028-fg);
border:1px solid var(--vibeui-datagrid-028-border);border-radius:0.875rem;
font-family:var(--vibeui-datagrid-028-font);
}
[data-vibeui-block="datagrid-028"] *{box-sizing:border-box}
[data-vibeui-block="datagrid-028"] [data-part="bar"]{
display:flex;flex-wrap:wrap;align-items:center;gap:0.5rem;min-height:3rem;
padding:0.625rem 0.875rem;border-bottom:1px solid var(--vibeui-datagrid-028-border);
}
[data-vibeui-block="datagrid-028"] [data-part="title"]{margin:0;font-size:0.875rem;font-weight:650;margin-inline-end:auto}
[data-vibeui-block="datagrid-028"] [data-part="log"]{margin:0;font-size:0.75rem;color:var(--vibeui-datagrid-028-muted)}
[data-vibeui-block="datagrid-028"] [data-part="scroll"]{overflow-x:auto;overflow-y:visible}
[data-vibeui-block="datagrid-028"] [data-part="scroll"]:focus-visible{outline:2px solid var(--vibeui-datagrid-028-accent);outline-offset:-2px}
[data-vibeui-block="datagrid-028"] table{width:100%;border-collapse:collapse;font-size:0.8125rem}
[data-vibeui-block="datagrid-028"] caption{
padding:0.625rem 0.875rem;text-align:left;font-size:0.75rem;color:var(--vibeui-datagrid-028-muted);caption-side:top;
}
[data-vibeui-block="datagrid-028"] th,
[data-vibeui-block="datagrid-028"] td{
padding:0.4375rem 0.875rem;text-align:left;white-space:nowrap;
border-top:1px solid var(--vibeui-datagrid-028-border);
}
[data-vibeui-block="datagrid-028"] thead th{background:var(--vibeui-datagrid-028-head);font-weight:600}
[data-vibeui-block="datagrid-028"] [data-align="end"]{text-align:right;font-variant-numeric:tabular-nums}
[data-vibeui-block="datagrid-028"] [data-part="code"]{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:0.75rem}
[data-vibeui-block="datagrid-028"] [data-part="status"]{color:var(--vibeui-datagrid-028-muted)}
[data-vibeui-block="datagrid-028"] [data-part="actions-cell"]{width:3rem;position:relative;text-align:right}
[data-vibeui-block="datagrid-028"] [data-part="trigger"]{
appearance:none;cursor:pointer;font:inherit;font-size:0.875rem;line-height:1;letter-spacing:0.08em;
width:1.75rem;height:1.75rem;border-radius:0.4375rem;
border:1px solid transparent;background:transparent;color:var(--vibeui-datagrid-028-muted);
}
[data-vibeui-block="datagrid-028"] [data-part="trigger"]:hover{border-color:var(--vibeui-datagrid-028-border);color:var(--vibeui-datagrid-028-fg)}
[data-vibeui-block="datagrid-028"] [data-part="trigger"][aria-expanded="true"]{border-color:var(--vibeui-datagrid-028-accent);color:var(--vibeui-datagrid-028-accent)}
[data-vibeui-block="datagrid-028"] [data-part="trigger"]:focus-visible{outline:2px solid var(--vibeui-datagrid-028-accent);outline-offset:2px}
[data-vibeui-block="datagrid-028"] [data-part="menu"]{
position:absolute;inset-inline-end:0.5rem;inset-block-start:2.125rem;z-index:6;
min-width:11rem;padding:0.25rem;margin:0;list-style:none;text-align:start;
border:1px solid var(--vibeui-datagrid-028-border);border-radius:0.625rem;
background:var(--vibeui-datagrid-028-bg);box-shadow:0 14px 32px var(--vibeui-datagrid-028-shadow);
}
[data-vibeui-block="datagrid-028"] [data-part="item"]{
appearance:none;cursor:pointer;font:inherit;font-size:0.8125rem;text-align:start;
display:block;width:100%;padding:0.375rem 0.5rem;border-radius:0.4375rem;
border:0;background:transparent;color:var(--vibeui-datagrid-028-fg);
}
[data-vibeui-block="datagrid-028"] [data-part="item"]:hover,
[data-vibeui-block="datagrid-028"] [data-part="item"]:focus-visible{background:var(--vibeui-datagrid-028-head);outline:none}
[data-vibeui-block="datagrid-028"] [data-part="item"]:focus-visible{box-shadow:inset 0 0 0 2px var(--vibeui-datagrid-028-accent)}
[data-vibeui-block="datagrid-028"] [data-danger="true"]{color:var(--vibeui-datagrid-028-danger);font-weight:600}
[data-vibeui-block="datagrid-028"] [data-part="sep"]{margin:0.25rem 0.25rem;border-top:1px solid var(--vibeui-datagrid-028-border)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="datagrid-028"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_ROWS: Datagrid028Row[] = [
  {
    id: "o1",
    order: "ЗК-7710",
    customer: "Артель «Кама»",
    status: "Собирается",
    amount: 184300,
  },
  {
    id: "o2",
    order: "ЗК-7711",
    customer: "Северный Порт",
    status: "Оплачен",
    amount: 92100,
  },
  {
    id: "o3",
    order: "ЗК-7712",
    customer: "Мостовик",
    status: "В доставке",
    amount: 461000,
  },
  {
    id: "o4",
    order: "ЗК-7713",
    customer: "Гранд-Сервис",
    status: "Черновик",
    amount: 15700,
  },
]

const ACTIONS = [
  { key: "open", label: "Открыть заказ", danger: false },
  { key: "copy", label: "Дублировать", danger: false },
  { key: "print", label: "Печать накладной", danger: false },
  { key: "hold", label: "Поставить на паузу", danger: false },
  { key: "delete", label: "Удалить заказ", danger: true },
]

/**
 * Сетка с колонкой действий и меню: клавиатурная навигация по пунктам,
 * Escape возвращает фокус на кнопку. Один файл, ноль зависимостей.
 */
export function Datagrid028({
  rows = DEFAULT_ROWS,
  caption = "Действия строки убраны в меню, оно управляется стрелками",
  triggerLabel = "Действия",
  accent,
  className,
  style,
  ...props
}: Datagrid028Props) {
  const [openRow, setOpenRow] = useState<string | null>(null)
  const [log, setLog] = useState("")
  const triggers = useRef<Record<string, HTMLButtonElement | null>>({})
  const base = useId()

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

  function close(rowId: string) {
    setOpenRow(null)
    triggers.current[rowId]?.focus()
  }

  function moveFocus(list: HTMLElement, step: number | "first" | "last") {
    const items = Array.from(
      list.querySelectorAll<HTMLButtonElement>('[data-part="item"]'),
    )
    const index = items.indexOf(document.activeElement as HTMLButtonElement)
    const next =
      step === "first"
        ? 0
        : step === "last"
          ? items.length - 1
          : (index + step + items.length) % items.length

    items[next]?.focus()
  }

  return (
    <>
      <style href="vibeui-datagrid-028" precedence="medium">
        {STYLES}
      </style>
      <section
        {...props}
        data-vibeui-block="datagrid-028"
        className={className}
        style={palette}
      >
        <div data-part="bar">
          <h3 data-part="title">Заказы в работе</h3>
          <p data-part="log" role="status" aria-live="polite">
            {log || "Действие ещё не выбрано"}
          </p>
        </div>
        <div
          data-part="scroll"
          role="region"
          aria-label="Таблица заказов, прокручивается вбок"
          tabIndex={0}
        >
          <table>
            <caption>{caption}</caption>
            <thead>
              <tr>
                <th scope="col">Заказ</th>
                <th scope="col">Заказчик</th>
                <th scope="col">Статус</th>
                <th scope="col" data-align="end">
                  Сумма, ₽
                </th>
                <th scope="col" data-part="actions-cell">
                  <span hidden>{triggerLabel}</span>
                </th>
              </tr>
            </thead>
            <tbody>
              {rows.map((row) => {
                const menuId = `${base}-${row.id}`
                const open = openRow === row.id

                return (
                  <tr key={row.id}>
                    <th scope="row" data-part="code">
                      {row.order}
                    </th>
                    <td>{row.customer}</td>
                    <td data-part="status">{row.status}</td>
                    <td data-align="end">
                      {row.amount.toLocaleString("ru-RU")}
                    </td>
                    <td data-part="actions-cell">
                      <button
                        type="button"
                        data-part="trigger"
                        ref={(node) => {
                          triggers.current[row.id] = node
                        }}
                        aria-haspopup="menu"
                        aria-expanded={open}
                        aria-controls={open ? menuId : undefined}
                        aria-label={`${triggerLabel} для заказа ${row.order}`}
                        onClick={() => setOpenRow(open ? null : row.id)}
                        onKeyDown={(event) => {
                          if (event.key === "ArrowDown") {
                            event.preventDefault()
                            setOpenRow(row.id)
                          }
                        }}
                      >
                        <span aria-hidden="true">···</span>
                      </button>
                      {open ? (
                        <ul
                          data-part="menu"
                          id={menuId}
                          role="menu"
                          aria-label={`Действия для заказа ${row.order}`}
                          onKeyDown={(event) => {
                            const list = event.currentTarget

                            if (event.key === "Escape") {
                              event.preventDefault()
                              close(row.id)
                            }

                            if (event.key === "ArrowDown") {
                              event.preventDefault()
                              moveFocus(list, 1)
                            }

                            if (event.key === "ArrowUp") {
                              event.preventDefault()
                              moveFocus(list, -1)
                            }

                            if (event.key === "Home") {
                              event.preventDefault()
                              moveFocus(list, "first")
                            }

                            if (event.key === "End") {
                              event.preventDefault()
                              moveFocus(list, "last")
                            }
                          }}
                        >
                          {ACTIONS.map((action, index) => (
                            <li key={action.key} role="none">
                              {action.danger ? <p data-part="sep" /> : null}
                              <button
                                type="button"
                                role="menuitem"
                                data-part="item"
                                data-danger={action.danger ? "true" : undefined}
                                autoFocus={index === 0}
                                onClick={() => {
                                  setLog(`${action.label} — заказ ${row.order}`)
                                  close(row.id)
                                }}
                              >
                                {action.label}
                              </button>
                            </li>
                          ))}
                        </ul>
                      ) : null}
                    </td>
                  </tr>
                )
              })}
            </tbody>
          </table>
        </div>
      </section>
    </>
  )
}