Data Grid
Resizable Columns
Сетка с изменением ширины колонок: разделитель между заголовками тянется мышью и настраивается стрелками с клавиатуры.
- datagrid
- resize
- columns
- table
Превью
Использовать с AI
- 1. Скопируйте ссылку.
- 2. Напишите агенту своими словами и вставьте её в предложение.
- 3. Агент откроет ссылку и поставит компонент из registry.
размести это в шапке: https://vibeui.ru/c/datagrid-009
Файлы проекта
| Файл | Владелец | Изменён | Размер |
|---|---|---|---|
| Отчёт по воронке, март.xlsx | Лебедь | 12 марта, 14:02 | 1,8 МБ |
| Макет каталога v7.fig | Орлова | 11 марта, 09:41 | 24,3 МБ |
| Договор Гранат.pdf | Гнедин | 9 марта, 18:20 | 412 КБ |
| Разметка событий.md | Савва | 6 марта, 11:05 | 18 КБ |
| Прайс-лист, апрель.csv | Ким | 2 марта, 08:12 | 96 КБ |
Показать полную инструкцию
То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.
# Установка и размещение «datagrid-009» (Resizable Columns) из VibeUI
## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/datagrid-009.json
Registry item: https://vibeui.ru/r/datagrid-009.json
Ставится в: components/vibeui/datagrid-009.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.
Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.
## 2. Что это
Сетка с изменением ширины колонок: разделитель между заголовками тянется мышью и настраивается стрелками с клавиатуры.
Сетка с перетаскиваемыми границами колонок и клавиатурной настройкой ширины через role=separator. Ноль зависимостей, один файл.
## 3. Как использовать
import { Datagrid009 } from "@/components/vibeui/datagrid-009"
<Datagrid009
caption="Потяните разделитель между заголовками"
minWidth={120}
/>
Полный список пропсов — в установленном файле.
## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.
Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)
## 5. Сохранить как установлено
- локальную палитру --vibeui-datagrid-009-* и не подменять её токенами вашей темы
- role="separator" с aria-valuenow и стрелками: без клавиатуры ширину колонки не выставить
- setPointerCapture — без него перетаскивание рвётся, стоит курсору выйти за заголовок
- table-layout:fixed вместе с colgroup: иначе браузер пересчитает ширины по содержимому
- нижний предел ширины: колонка в ноль пикселей исчезает вместе со своим содержимым
- светлую подложку сетки: на тёмной карточке каталога тёмный текст иначе пропадает
## 6. Можно менять
- подпись под шапкой через caption
- нижний предел ширины колонки через minWidth, в пикселях
- список файлов через rows
- акцентный цвет через accent
## 7. Правила
- Ширины живут в состоянии компонента и сбрасываются при перезагрузке — сохраняйте их сами.
- Шаг клавиатуры 16 px: для точной настройки добавьте модификатор, но не убирайте сам шаг.
- Содержимое ячейки обрезается многоточием, полное значение остаётся в атрибуте title.
- Не выносите CSS-переменные в globals.css: компонент обязан оставаться одним файлом.
## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.Для разработчика
npx shadcn@latest add https://vibeui.ru/r/datagrid-009.jsonhttps://vibeui.ru/r/datagrid-009.jsonКомпонент самодостаточен: один файл, без зависимостей, палитра в --vibeui-datagrid-009-*. Клиентский: ширины лежат в состоянии, черновик перетаскивания — в ref, чтобы движение указателя не гоняло лишние рендеры. Разделитель — кнопка с role=separator, aria-orientation, aria-valuenow, aria-valuemin и aria-valuemax; стрелки влево и вправо меняют ширину шагом 16 px, поэтому колонку можно настроить без мыши. Захват указателя через setPointerCapture: без него перетаскивание рвётся, стоит курсору выйти за заголовок. Ширины применяются к colgroup при table-layout:fixed, иначе браузер пересчитает их по содержимому. Содержимое ячейки обрезается многоточием, полное значение остаётся в title.
Исходник компонента
Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.
"use client"
import { useRef, useState } from "react"
import type {
ComponentPropsWithoutRef,
CSSProperties,
PointerEvent,
} from "react"
export type Datagrid009Row = {
file: string
owner: string
changed: string
size: string
}
export type Datagrid009Props = Omit<
ComponentPropsWithoutRef<"section">,
"children"
> & {
rows?: Datagrid009Row[]
caption?: string
minWidth?: number
accent?: string
}
type Key = "file" | "owner" | "changed" | "size"
// Идея компонента: ширину колонки тянут за разделитель между заголовками.
// Разделитель — это role="separator" с aria-valuenow и стрелками на
// клавиатуре: тянуть мышью умеют все сетки, а вот выставить ширину без мыши
// обычно нельзя. Ширины живут в colgroup при table-layout:fixed, поэтому
// перетаскивание не пересчитывает всю таблицу на каждое движение.
const STYLES = `
:where([data-vibeui-block="datagrid-009"]){
--vibeui-datagrid-009-bg:oklch(1 0 0);
--vibeui-datagrid-009-fg:oklch(0.23 0.012 240);
--vibeui-datagrid-009-muted:oklch(0.55 0.012 240);
--vibeui-datagrid-009-border:oklch(0.92 0.006 240);
--vibeui-datagrid-009-head:oklch(0.975 0.003 240);
--vibeui-datagrid-009-accent:oklch(0.55 0.16 250);
--vibeui-datagrid-009-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="datagrid-009"]{
box-sizing:border-box;width:100%;max-width:52rem;margin:0 auto;
background:var(--vibeui-datagrid-009-bg);color:var(--vibeui-datagrid-009-fg);
border:1px solid var(--vibeui-datagrid-009-border);border-radius:0.875rem;
font-family:var(--vibeui-datagrid-009-font);overflow:hidden;
}
[data-vibeui-block="datagrid-009"] *{box-sizing:border-box}
[data-vibeui-block="datagrid-009"] [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-009-border);
}
[data-vibeui-block="datagrid-009"] [data-part="title"]{margin:0;font-size:0.875rem;font-weight:650;margin-inline-end:auto}
[data-vibeui-block="datagrid-009"] [data-part="bar"] 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-009-border);
background:var(--vibeui-datagrid-009-bg);color:var(--vibeui-datagrid-009-fg);
}
[data-vibeui-block="datagrid-009"] [data-part="bar"] button:focus-visible{outline:2px solid var(--vibeui-datagrid-009-accent);outline-offset:2px}
[data-vibeui-block="datagrid-009"] [data-part="scroll"]{overflow-x:auto}
[data-vibeui-block="datagrid-009"] [data-part="scroll"]:focus-visible{outline:2px solid var(--vibeui-datagrid-009-accent);outline-offset:-2px}
/* fixed: без него браузер пересчитывает ширины по содержимому и перетянутая
колонка возвращается на место. */
[data-vibeui-block="datagrid-009"] table{
table-layout:fixed;border-collapse:collapse;font-size:0.8125rem;
}
[data-vibeui-block="datagrid-009"] caption{
padding:0 0.875rem 0.625rem;text-align:left;
font-size:0.75rem;color:var(--vibeui-datagrid-009-muted);
}
[data-vibeui-block="datagrid-009"] th,
[data-vibeui-block="datagrid-009"] td{
padding:0.4375rem 0.875rem;text-align:left;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
border-top:1px solid var(--vibeui-datagrid-009-border);
}
[data-vibeui-block="datagrid-009"] thead th{
position:relative;background:var(--vibeui-datagrid-009-head);font-weight:600;
}
/* Разделитель шире своей полоски: 12 px по горизонтали — минимальная
попадаемая цель мышью. */
[data-vibeui-block="datagrid-009"] [data-part="grip"]{
position:absolute;top:0;right:-6px;bottom:0;width:12px;
padding:0;border:0;background:none;cursor:col-resize;touch-action:none;z-index:1;
display:flex;align-items:center;justify-content:center;
}
[data-vibeui-block="datagrid-009"] [data-part="grip"]::before{
content:"";width:2px;height:60%;border-radius:1px;background:var(--vibeui-datagrid-009-border);
transition:background-color .14s ease;
}
[data-vibeui-block="datagrid-009"] [data-part="grip"]:hover::before,
[data-vibeui-block="datagrid-009"] [data-part="grip"][data-dragging="true"]::before{
background:var(--vibeui-datagrid-009-accent);
}
[data-vibeui-block="datagrid-009"] [data-part="grip"]:focus-visible{outline:2px solid var(--vibeui-datagrid-009-accent);outline-offset:-2px}
[data-vibeui-block="datagrid-009"] [data-part="name"]{font-weight:500}
[data-vibeui-block="datagrid-009"] [data-part="muted"]{color:var(--vibeui-datagrid-009-muted)}
[data-vibeui-block="datagrid-009"] [data-align="end"]{text-align:right;font-variant-numeric:tabular-nums}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="datagrid-009"] *{animation:none!important;transition:none!important}}
`
const DEFAULT_ROWS: Datagrid009Row[] = [
{
file: "Отчёт по воронке, март.xlsx",
owner: "Лебедь",
changed: "12 марта, 14:02",
size: "1,8 МБ",
},
{
file: "Макет каталога v7.fig",
owner: "Орлова",
changed: "11 марта, 09:41",
size: "24,3 МБ",
},
{
file: "Договор Гранат.pdf",
owner: "Гнедин",
changed: "9 марта, 18:20",
size: "412 КБ",
},
{
file: "Разметка событий.md",
owner: "Савва",
changed: "6 марта, 11:05",
size: "18 КБ",
},
{
file: "Прайс-лист, апрель.csv",
owner: "Ким",
changed: "2 марта, 08:12",
size: "96 КБ",
},
]
const COLUMNS: { key: Key; title: string; width: number; numeric?: boolean }[] =
[
{ key: "file", title: "Файл", width: 260 },
{ key: "owner", title: "Владелец", width: 130 },
{ key: "changed", title: "Изменён", width: 160 },
{ key: "size", title: "Размер", width: 100, numeric: true },
]
const START: Record<Key, number> = {
file: 260,
owner: 130,
changed: 160,
size: 100,
}
/**
* Сетка с изменением ширины колонок: разделитель тянется мышью и
* настраивается стрелками с клавиатуры. Один файл, ноль зависимостей.
*/
export function Datagrid009({
rows = DEFAULT_ROWS,
caption = "Потяните разделитель между заголовками или наведите на него фокус и жмите стрелки",
minWidth = 96,
accent,
className,
style,
...props
}: Datagrid009Props) {
const [widths, setWidths] = useState<Record<Key, number>>(START)
const [dragging, setDragging] = useState<Key | null>(null)
const drag = useRef<{ key: Key; x: number; width: number } | null>(null)
const clamp = (value: number) => Math.max(minWidth, Math.min(520, value))
const resize = (key: Key, next: number) =>
setWidths((current) => ({ ...current, [key]: clamp(next) }))
const onDown = (event: PointerEvent<HTMLButtonElement>, key: Key) => {
drag.current = { key, x: event.clientX, width: widths[key] }
setDragging(key)
event.currentTarget.setPointerCapture(event.pointerId)
}
const onMove = (event: PointerEvent<HTMLButtonElement>) => {
const state = drag.current
if (!state) return
resize(state.key, state.width + (event.clientX - state.x))
}
const onUp = () => {
drag.current = null
setDragging(null)
}
const total = COLUMNS.reduce((sum, column) => sum + widths[column.key], 0)
const palette = {
...(accent ? { "--vibeui-datagrid-009-accent": accent } : null),
...style,
} as CSSProperties
return (
<>
<style href="vibeui-datagrid-009" precedence="medium">
{STYLES}
</style>
<section
{...props}
data-vibeui-block="datagrid-009"
className={className}
style={palette}
>
<div data-part="bar">
<h3 data-part="title">Файлы проекта</h3>
<button type="button" onClick={() => setWidths(START)}>
Вернуть ширины
</button>
</div>
<div
data-part="scroll"
role="region"
aria-label="Таблица файлов, прокручивается вбок"
tabIndex={0}
>
<table style={{ width: `${total}px`, minWidth: "100%" }}>
<caption>{caption}</caption>
<colgroup>
{COLUMNS.map((column) => (
<col
key={column.key}
style={{ width: `${widths[column.key]}px` }}
/>
))}
</colgroup>
<thead>
<tr>
{COLUMNS.map((column, index) => (
<th
key={column.key}
scope="col"
data-align={column.numeric ? "end" : undefined}
>
{column.title}
{index < COLUMNS.length - 1 ? (
<button
type="button"
data-part="grip"
data-dragging={dragging === column.key}
role="separator"
aria-orientation="vertical"
aria-label={`Ширина колонки «${column.title}»`}
aria-valuenow={widths[column.key]}
aria-valuemin={minWidth}
aria-valuemax={520}
onPointerDown={(event) => onDown(event, column.key)}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
resize(column.key, widths[column.key] - 16)
}
if (event.key === "ArrowRight") {
event.preventDefault()
resize(column.key, widths[column.key] + 16)
}
}}
/>
) : null}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.file}>
<th scope="row" data-part="name" title={row.file}>
{row.file}
</th>
<td data-part="muted">{row.owner}</td>
<td data-part="muted">{row.changed}</td>
<td data-align="end">{row.size}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</>
)
}