Data Grid

Detail Row

A grid where a row opens its details right underneath: a full-width panel, with the button-to-panel link declared through aria-controls.

  • datagrid
  • expandable
  • details
  • 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-008?lang=en

Отгрузки

Открыта одна строка

Click the arrow to open the shipment details
ПартияМаршрутСтатусПрибытие
Партия 4410Москва → КазаньВ пути14 марта
Перевозчик
Ирбис-Логистика
Мест
18 паллет
Вес
4 240 кг
Документы
ТТН, счёт-фактура
Партия 4411Москва → ПетербургГотовится16 марта
Партия 4412Казань → ЕкатеринбургДоставлена9 марта
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-008" (Detail Row) 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-008.json

Registry item: https://vibeui.ru/r/datagrid-008.json
Installs to: components/vibeui/datagrid-008.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 where a row opens its details right underneath: a full-width panel, with the button-to-panel link declared through aria-controls.

A grid with an expandable detail row: a full-width panel under its own row and the relationship declared through aria-controls. Zero dependencies, one file.

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

<Datagrid008
  caption="Click the arrow to open the details"
  single={false}
/>

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-008-* palette — do not swap it for your theme tokens
- the aria-expanded and aria-controls pair: without the panel id the button loses its link to the details
- the full-width detail row via colSpan — a nested table breaks cell navigation
- a separate aria-label on the disclosure button naming the row, or every arrow button reads the same
- the arrow rotation as a second signal of state beyond the panel appearing
- 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 one-row-at-a-time mode through single
- the shipments and term-value pairs through rows
- the accent color through accent

## 7. Rules
- With single={false} a dozen open rows turn the table into a list — enable it deliberately.
- The fade-in rests on @starting-style: where it is unsupported the panel simply appears at once.
- The detail pairs fall apart if you drop display:contents from the wrapper inside the dl.
- 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-008.json
https://vibeui.ru/r/datagrid-008.json

Компонент самодостаточен: один файл, без зависимостей, палитра в --vibeui-datagrid-008-*. Клиентский: useState держит список раскрытых строк, useId даёт стабильные id панелей для aria-controls. Кнопка раскрытия несёт aria-expanded и собственный aria-label с именем партии. Детали лежат в отдельной строке с colSpan на всю ширину — вложенная таблица сломала бы навигацию по ячейкам. Пары «термин — значение» собраны в dl, обёртка пары получает display:contents, иначе колонки разъезжаются. Появление панели плавное через @starting-style: где он не поддержан, панель просто появляется сразу. Режим single держит открытой одну строку.

Component source

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

"use client"

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

export type Datagrid008Row = {
  id: string
  shipment: string
  route: string
  status: string
  eta: string
  details: { term: string; value: string }[]
}

export type Datagrid008Props = Omit<
  ComponentPropsWithoutRef<"section">,
  "children"
> & {
  rows?: Datagrid008Row[]
  caption?: string
  single?: boolean
  accent?: string
}

// Идея компонента: подробности живут в строке под своей строкой, а не в
// диалоге — сравнивать две записи проще, когда обе остаются на месте.
// Кнопка раскрытия несёт aria-expanded и aria-controls на id строки-деталей,
// поэтому связь «кнопка → панель» есть и без зрения. Режим single держит
// открытой одну строку: иначе таблица на десяти раскрытых записях
// перестаёт быть таблицей.
const STYLES = `
:where([data-vibeui-block="datagrid-008"]){
--vibeui-datagrid-008-bg:oklch(1 0 0);
--vibeui-datagrid-008-fg:oklch(0.23 0.014 230);
--vibeui-datagrid-008-muted:oklch(0.55 0.014 230);
--vibeui-datagrid-008-border:oklch(0.92 0.006 230);
--vibeui-datagrid-008-head:oklch(0.975 0.003 230);
--vibeui-datagrid-008-panel:oklch(0.98 0.006 230);
--vibeui-datagrid-008-accent:oklch(0.52 0.15 235);
--vibeui-datagrid-008-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="datagrid-008"]{
box-sizing:border-box;width:100%;max-width:52rem;margin:0 auto;
background:var(--vibeui-datagrid-008-bg);color:var(--vibeui-datagrid-008-fg);
border:1px solid var(--vibeui-datagrid-008-border);border-radius:0.875rem;
font-family:var(--vibeui-datagrid-008-font);overflow:hidden;
}
[data-vibeui-block="datagrid-008"] *{box-sizing:border-box}
[data-vibeui-block="datagrid-008"] [data-part="head"]{
display:flex;flex-wrap:wrap;align-items:baseline;gap:0.5rem;
padding:0.875rem;border-bottom:1px solid var(--vibeui-datagrid-008-border);
}
[data-vibeui-block="datagrid-008"] [data-part="title"]{margin:0;font-size:0.875rem;font-weight:650}
[data-vibeui-block="datagrid-008"] [data-part="hint"]{
margin:0 0 0 auto;font-size:0.75rem;color:var(--vibeui-datagrid-008-muted);
}
[data-vibeui-block="datagrid-008"] [data-part="scroll"]{overflow-x:auto}
[data-vibeui-block="datagrid-008"] [data-part="scroll"]:focus-visible{outline:2px solid var(--vibeui-datagrid-008-accent);outline-offset:-2px}
[data-vibeui-block="datagrid-008"] table{width:100%;border-collapse:collapse;font-size:0.8125rem}
[data-vibeui-block="datagrid-008"] caption{
padding:0 0.875rem 0.625rem;text-align:left;
font-size:0.75rem;color:var(--vibeui-datagrid-008-muted);
}
[data-vibeui-block="datagrid-008"] th,
[data-vibeui-block="datagrid-008"] td{
padding:0.4375rem 0.875rem;text-align:left;white-space:nowrap;
border-top:1px solid var(--vibeui-datagrid-008-border);
}
[data-vibeui-block="datagrid-008"] thead th{background:var(--vibeui-datagrid-008-head);font-weight:600}
[data-vibeui-block="datagrid-008"] [data-part="pick"]{width:2.5rem;padding:0}
[data-vibeui-block="datagrid-008"] [data-part="toggle"]{
display:flex;align-items:center;justify-content:center;
width:100%;height:2rem;
appearance:none;border:0;background:none;cursor:pointer;color:inherit;font:inherit;
}
[data-vibeui-block="datagrid-008"] [data-part="toggle"]:focus-visible{outline:2px solid var(--vibeui-datagrid-008-accent);outline-offset:-2px}
[data-vibeui-block="datagrid-008"] [data-part="caret"]{
font-size:0.625rem;color:var(--vibeui-datagrid-008-accent);
transition:transform .16s ease;
}
[data-vibeui-block="datagrid-008"] [data-part="toggle"][aria-expanded="true"] [data-part="caret"]{transform:rotate(90deg)}
[data-vibeui-block="datagrid-008"] tr[data-open="true"] > *{background:var(--vibeui-datagrid-008-panel)}
[data-vibeui-block="datagrid-008"] [data-part="detail-cell"]{padding:0;background:var(--vibeui-datagrid-008-panel)}
[data-vibeui-block="datagrid-008"] [data-part="detail"]{
display:flex;flex-wrap:wrap;gap:1rem;align-items:flex-start;
padding:0.875rem 0.875rem 0.875rem 3.375rem;
opacity:1;transition:opacity .18s ease;
}
@starting-style{[data-vibeui-block="datagrid-008"] [data-part="detail"]{opacity:0}}
[data-vibeui-block="datagrid-008"] dl{
display:grid;grid-template-columns:auto auto;gap:0.25rem 1rem;margin:0;
}
/* display:contents на обёртке пары: без неё div становится элементом
   сетки и колонки «термин / значение» разъезжаются. */
[data-vibeui-block="datagrid-008"] dl > div{display:contents}
[data-vibeui-block="datagrid-008"] dt{font-size:0.75rem;color:var(--vibeui-datagrid-008-muted)}
[data-vibeui-block="datagrid-008"] dd{margin:0;font-size:0.75rem;font-weight:550}
[data-vibeui-block="datagrid-008"] [data-part="detail-actions"]{
display:flex;gap:0.375rem;margin-inline-start:auto;
}
[data-vibeui-block="datagrid-008"] [data-part="detail-actions"] button{
appearance:none;cursor:pointer;font:inherit;font-size:0.75rem;
padding:0.3125rem 0.625rem;border-radius:0.5rem;
border:1px solid var(--vibeui-datagrid-008-border);
background:var(--vibeui-datagrid-008-bg);color:var(--vibeui-datagrid-008-fg);
}
[data-vibeui-block="datagrid-008"] [data-part="detail-actions"] button:focus-visible{outline:2px solid var(--vibeui-datagrid-008-accent);outline-offset:2px}
[data-vibeui-block="datagrid-008"] [data-part="route"]{color:var(--vibeui-datagrid-008-muted)}
[data-vibeui-block="datagrid-008"] [data-align="end"]{text-align:right;font-variant-numeric:tabular-nums}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="datagrid-008"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_ROWS: Datagrid008Row[] = [
  {
    id: "SH-4410",
    shipment: "Партия 4410",
    route: "Москва → Казань",
    status: "В пути",
    eta: "14 марта",
    details: [
      { term: "Перевозчик", value: "Ирбис-Логистика" },
      { term: "Мест", value: "18 паллет" },
      { term: "Вес", value: "4 240 кг" },
      { term: "Документы", value: "ТТН, счёт-фактура" },
    ],
  },
  {
    id: "SH-4411",
    shipment: "Партия 4411",
    route: "Москва → Петербург",
    status: "Готовится",
    eta: "16 марта",
    details: [
      { term: "Перевозчик", value: "Северный путь" },
      { term: "Мест", value: "6 паллет" },
      { term: "Вес", value: "1 180 кг" },
      { term: "Документы", value: "Заявка" },
    ],
  },
  {
    id: "SH-4412",
    shipment: "Партия 4412",
    route: "Казань → Екатеринбург",
    status: "Доставлена",
    eta: "9 марта",
    details: [
      { term: "Перевозчик", value: "Ирбис-Логистика" },
      { term: "Мест", value: "11 паллет" },
      { term: "Вес", value: "2 960 кг" },
      { term: "Документы", value: "ТТН, акт приёмки" },
    ],
  },
]

/**
 * Сетка с раскрывающейся строкой-деталями: панель занимает всю ширину под
 * своей строкой, связь объявлена через aria-controls. Один файл.
 */
export function Datagrid008({
  rows = DEFAULT_ROWS,
  caption = "Нажмите на стрелку, чтобы раскрыть подробности партии",
  single = true,
  accent,
  className,
  style,
  ...props
}: Datagrid008Props) {
  const prefix = useId()
  const [open, setOpen] = useState<string[]>([rows[0]?.id ?? ""])

  const toggle = (id: string) =>
    setOpen((current) => {
      if (current.includes(id)) return current.filter((value) => value !== id)
      return single ? [id] : [...current, id]
    })

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

  return (
    <>
      <style href="vibeui-datagrid-008" precedence="medium">
        {STYLES}
      </style>
      <section
        {...props}
        data-vibeui-block="datagrid-008"
        className={className}
        style={palette}
      >
        <div data-part="head">
          <h3 data-part="title">Отгрузки</h3>
          <p data-part="hint">
            {single ? "Открыта одна строка" : "Можно раскрыть несколько"}
          </p>
        </div>
        <div
          data-part="scroll"
          role="region"
          aria-label="Таблица отгрузок, прокручивается вбок"
          tabIndex={0}
        >
          <table>
            <caption>{caption}</caption>
            <thead>
              <tr>
                <th scope="col" data-part="pick">
                  <span aria-hidden="true">·</span>
                </th>
                <th scope="col">Партия</th>
                <th scope="col">Маршрут</th>
                <th scope="col">Статус</th>
                <th scope="col" data-align="end">
                  Прибытие
                </th>
              </tr>
            </thead>
            <tbody>
              {rows.map((row) => {
                const expanded = open.includes(row.id)
                const panel = `${prefix}-${row.id}`

                return (
                  <Fragment key={row.id}>
                    <tr data-open={expanded}>
                      <td data-part="pick">
                        <button
                          type="button"
                          data-part="toggle"
                          aria-expanded={expanded}
                          aria-controls={panel}
                          aria-label={`Подробности: ${row.shipment}`}
                          onClick={() => toggle(row.id)}
                        >
                          <span data-part="caret" aria-hidden="true">
                            ▶
                          </span>
                        </button>
                      </td>
                      <th scope="row">{row.shipment}</th>
                      <td data-part="route">{row.route}</td>
                      <td>{row.status}</td>
                      <td data-align="end">{row.eta}</td>
                    </tr>
                    {expanded ? (
                      <tr>
                        <td colSpan={5} data-part="detail-cell">
                          <div data-part="detail" id={panel}>
                            <dl>
                              {row.details.map((item) => (
                                <div key={item.term}>
                                  <dt>{item.term}</dt>
                                  <dd>{item.value}</dd>
                                </div>
                              ))}
                            </dl>
                            <div data-part="detail-actions">
                              <button type="button">Документы</button>
                              <button type="button">Трек</button>
                            </div>
                          </div>
                        </td>
                      </tr>
                    ) : null}
                  </Fragment>
                )
              })}
            </tbody>
          </table>
        </div>
      </section>
    </>
  )
}