Data Grid
Cell Validation
Сетка с валидацией на месте: ошибка стоит под самим значением, поле помечено aria-invalid и связано с текстом ошибки, а сохранение заблокировано до починки.
- datagrid
- validation
- inline-error
- table
Превью
Использовать с AI
- 1. Скопируйте ссылку.
- 2. Напишите агенту своими словами и вставьте её в предложение.
- 3. Агент откроет ссылку и поставит компонент из registry.
размести это в шапке: https://vibeui.ru/c/datagrid-022
Табель за март
Ошибок в ячейках: 3
| Сотрудник | Почта | Ставка, ₽/ч | Часы |
|---|---|---|---|
| Гусева А. | |||
| Лапин К. | Нужен адрес вида имя@домен.ru | ||
| Орлова М. | Ставка должна быть больше нуля | ||
| Сафин Р. | Часов не больше 200 |
Показать полную инструкцию
То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.
# Установка и размещение «datagrid-022» (Cell Validation) из VibeUI
## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/datagrid-022.json
Registry item: https://vibeui.ru/r/datagrid-022.json
Ставится в: components/vibeui/datagrid-022.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.
Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.
## 2. Что это
Сетка с валидацией на месте: ошибка стоит под самим значением, поле помечено aria-invalid и связано с текстом ошибки, а сохранение заблокировано до починки.
Сетка с валидацией ячейки на месте: aria-invalid, текст ошибки под значением и блокировка сохранения. Ноль зависимостей, один файл.
## 3. Как использовать
import { Datagrid022 } from "@/components/vibeui/datagrid-022"
<Datagrid022 caption="Проверка табеля" maxHours={160} />
Полный список пропсов — в установленном файле.
## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.
Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)
## 5. Сохранить как установлено
- локальную палитру --vibeui-datagrid-022-* и не подменять её токенами вашей темы
- пару aria-invalid и aria-describedby: одна красная рамка скринридеру ничего не сообщает
- текст ошибки рядом с полем, а не в сводке наверху — иначе непонятно, какую ячейку чинить
- зарезервированную строку под сообщением: без неё появление ошибки двигает соседние строки
- блокировку сохранения при ошибках, иначе форма уедет заведомо кривой
- выравнивание ячеек по верхнему краю: ячейка с ошибкой иначе перекашивает всю строку
## 6. Можно менять
- подпись под шапкой через caption
- верхний предел часов через maxHours
- состав табеля через rows
- акцентный цвет через accent
## 7. Правила
- Проверка почты сделана простым выражением: для боевой формы полагайтесь на подтверждение письмом.
- Ошибки считаются на каждом рендере — на сотнях строк выносите их в мемоизацию.
- Стартовые данные намеренно содержат три ошибки: так состояние видно на витрине, в бою начинайте с чистых.
- Не выносите CSS-переменные в globals.css: компонент обязан оставаться одним файлом.
## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.Для разработчика
npx shadcn@latest add https://vibeui.ru/r/datagrid-022.jsonhttps://vibeui.ru/r/datagrid-022.jsonКомпонент самодостаточен: один файл, без зависимостей, палитра в --vibeui-datagrid-022-*. Клиентский: значения лежат в состоянии, проверка считается на каждом рендере, useId даёт стабильные идентификаторы для aria-describedby. Поле с ошибкой получает aria-invalid и ссылку на текст ошибки — одна красная рамка скринридеру ничего не сообщает. Под каждой ячейкой зарезервирована строка высотой с сообщение, поэтому появление ошибки не двигает соседние строки. Кнопка сохранения выключена, пока ошибки есть, а их число объявлено role=status. Ячейки выровнены по верхнему краю: иначе строка с ошибкой перекашивает соседей.
Исходник компонента
Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.
"use client"
import { useId, useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"
export type Datagrid022Row = {
id: string
employee: string
email: string
rate: string
hours: string
}
export type Datagrid022Props = Omit<
ComponentPropsWithoutRef<"section">,
"children"
> & {
rows?: Datagrid022Row[]
caption?: string
maxHours?: number
accent?: string
}
// Идея компонента: ошибка живёт в той же ячейке, что и значение, а не в
// сводке над таблицей. Поле помечается aria-invalid и связывается с текстом
// ошибки через aria-describedby — иначе красная рамка для скринридера
// ничего не значит. Сохранение блокируется, пока есть хоть одна ошибка,
// и счётчик ошибок объявлен отдельно: пользователь должен знать, сколько
// ячеек ещё надо починить.
const STYLES = `
:where([data-vibeui-block="datagrid-022"]){
--vibeui-datagrid-022-bg:oklch(1 0 0);
--vibeui-datagrid-022-fg:oklch(0.23 0.014 285);
--vibeui-datagrid-022-muted:oklch(0.55 0.014 285);
--vibeui-datagrid-022-border:oklch(0.92 0.006 285);
--vibeui-datagrid-022-head:oklch(0.975 0.003 285);
--vibeui-datagrid-022-accent:oklch(0.5 0.15 215);
--vibeui-datagrid-022-bad:oklch(0.53 0.19 27);
--vibeui-datagrid-022-badbg:oklch(0.97 0.03 27);
--vibeui-datagrid-022-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="datagrid-022"]{
box-sizing:border-box;width:100%;max-width:52rem;margin:0 auto;
background:var(--vibeui-datagrid-022-bg);color:var(--vibeui-datagrid-022-fg);
border:1px solid var(--vibeui-datagrid-022-border);border-radius:0.875rem;
font-family:var(--vibeui-datagrid-022-font);overflow:hidden;
}
[data-vibeui-block="datagrid-022"] *{box-sizing:border-box}
[data-vibeui-block="datagrid-022"] [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-022-border);
}
[data-vibeui-block="datagrid-022"] [data-part="title"]{margin:0;font-size:0.875rem;font-weight:650;margin-inline-end:auto}
[data-vibeui-block="datagrid-022"] [data-part="status"]{margin:0;font-size:0.75rem;color:var(--vibeui-datagrid-022-muted)}
[data-vibeui-block="datagrid-022"] [data-part="status"][data-bad="true"]{color:var(--vibeui-datagrid-022-bad);font-weight:600}
[data-vibeui-block="datagrid-022"] [data-part="save"]{
appearance:none;cursor:pointer;font:inherit;font-size:0.75rem;font-weight:600;
padding:0.375rem 0.75rem;border-radius:0.5rem;border:1px solid transparent;
background:var(--vibeui-datagrid-022-accent);color:oklch(1 0 0);
}
[data-vibeui-block="datagrid-022"] [data-part="save"]:disabled{opacity:.4;cursor:not-allowed}
[data-vibeui-block="datagrid-022"] [data-part="save"]:focus-visible{outline:2px solid var(--vibeui-datagrid-022-accent);outline-offset:2px}
[data-vibeui-block="datagrid-022"] [data-part="scroll"]{overflow-x:auto}
[data-vibeui-block="datagrid-022"] [data-part="scroll"]:focus-visible{outline:2px solid var(--vibeui-datagrid-022-accent);outline-offset:-2px}
[data-vibeui-block="datagrid-022"] table{width:100%;border-collapse:collapse;font-size:0.8125rem}
[data-vibeui-block="datagrid-022"] caption{
padding:0.625rem 0.875rem;text-align:left;font-size:0.75rem;color:var(--vibeui-datagrid-022-muted);caption-side:top;
}
[data-vibeui-block="datagrid-022"] th,
[data-vibeui-block="datagrid-022"] td{
padding:0.375rem 0.875rem;text-align:left;vertical-align:top;
border-top:1px solid var(--vibeui-datagrid-022-border);
}
[data-vibeui-block="datagrid-022"] thead th{background:var(--vibeui-datagrid-022-head);font-weight:600;white-space:nowrap;vertical-align:bottom}
[data-vibeui-block="datagrid-022"] tbody th[scope="row"]{padding-top:0.6875rem;white-space:nowrap}
[data-vibeui-block="datagrid-022"] [data-part="input"]{
width:100%;min-width:6rem;font:inherit;font-size:0.8125rem;color:inherit;
padding:0.3125rem 0.5rem;border-radius:0.375rem;
border:1px solid var(--vibeui-datagrid-022-border);background:var(--vibeui-datagrid-022-bg);
}
[data-vibeui-block="datagrid-022"] [data-part="input"]:focus-visible{outline:2px solid var(--vibeui-datagrid-022-accent);outline-offset:1px}
[data-vibeui-block="datagrid-022"] [data-part="input"][aria-invalid="true"]{
border-color:var(--vibeui-datagrid-022-bad);background:var(--vibeui-datagrid-022-badbg);
}
[data-vibeui-block="datagrid-022"] [data-part="error"]{
display:flex;align-items:flex-start;gap:0.25rem;
margin:0.25rem 0 0.1875rem;font-size:0.6875rem;line-height:1.3;color:var(--vibeui-datagrid-022-bad);
}
[data-vibeui-block="datagrid-022"] [data-part="error"]::before{content:"!";flex:none;font-weight:700}
[data-vibeui-block="datagrid-022"] [data-part="ok"]{
margin:0.25rem 0 0.1875rem;font-size:0.6875rem;line-height:1.3;color:transparent;
}
[data-vibeui-block="datagrid-022"] [data-align="end"] [data-part="input"]{text-align:right;font-variant-numeric:tabular-nums}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="datagrid-022"] *{animation:none!important;transition:none!important}}
`
const DEFAULT_ROWS: Datagrid022Row[] = [
{
id: "e1",
employee: "Гусева А.",
email: "guseva@studio.ru",
rate: "1800",
hours: "148",
},
{
id: "e2",
employee: "Лапин К.",
email: "lapin(at)studio.ru",
rate: "2100",
hours: "160",
},
{
id: "e3",
employee: "Орлова М.",
email: "orlova@studio.ru",
rate: "-400",
hours: "132",
},
{
id: "e4",
employee: "Сафин Р.",
email: "safin@studio.ru",
rate: "1950",
hours: "290",
},
]
type Field = "email" | "rate" | "hours"
/**
* Сетка с валидацией ячейки на месте: ошибка стоит под значением,
* поле помечено aria-invalid, сохранение заблокировано. Один файл.
*/
export function Datagrid022({
rows = DEFAULT_ROWS,
caption = "Ошибка показывается в той же ячейке, где стоит значение",
maxHours = 200,
accent,
className,
style,
...props
}: Datagrid022Props) {
const [values, setValues] = useState(rows)
const [saved, setSaved] = useState(false)
const base = useId()
function validate(row: Datagrid022Row, field: Field) {
if (field === "email") {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(row.email)
? ""
: "Нужен адрес вида имя@домен.ru"
}
const number = Number(row[field])
if (row[field].trim() === "" || Number.isNaN(number)) {
return "Нужно число"
}
if (field === "rate" && number <= 0) {
return "Ставка должна быть больше нуля"
}
if (field === "hours" && (number < 0 || number > maxHours)) {
return `Часов не больше ${maxHours}`
}
return ""
}
const problems = values.flatMap((row) =>
(["email", "rate", "hours"] as Field[])
.map((field) => validate(row, field))
.filter(Boolean),
)
const palette = {
...(accent ? { "--vibeui-datagrid-022-accent": accent } : null),
...style,
} as CSSProperties
function cell(
row: Datagrid022Row,
field: Field,
label: string,
numeric: boolean,
) {
const error = validate(row, field)
const errorId = `${base}-${row.id}-${field}`
return (
<td data-align={numeric ? "end" : undefined}>
<input
data-part="input"
type="text"
inputMode={numeric ? "numeric" : "email"}
value={row[field]}
aria-label={`${label}, ${row.employee}`}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
onChange={(event) => {
setSaved(false)
setValues((current) =>
current.map((item) =>
item.id === row.id
? { ...item, [field]: event.target.value }
: item,
),
)
}}
/>
{error ? (
<p data-part="error" id={errorId}>
{error}
</p>
) : (
<p data-part="ok" aria-hidden="true">
ок
</p>
)}
</td>
)
}
return (
<>
<style href="vibeui-datagrid-022" precedence="medium">
{STYLES}
</style>
<section
{...props}
data-vibeui-block="datagrid-022"
className={className}
style={palette}
>
<div data-part="bar">
<h3 data-part="title">Табель за март</h3>
<p
data-part="status"
data-bad={problems.length > 0 ? "true" : undefined}
role="status"
aria-live="polite"
>
{problems.length > 0
? `Ошибок в ячейках: ${problems.length}`
: saved
? "Табель сохранён"
: "Ошибок нет"}
</p>
<button
type="button"
data-part="save"
disabled={problems.length > 0}
onClick={() => setSaved(true)}
>
Сохранить табель
</button>
</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" data-align="end">
Ставка, ₽/ч
</th>
<th scope="col" data-align="end">
Часы
</th>
</tr>
</thead>
<tbody>
{values.map((row) => (
<tr key={row.id}>
<th scope="row">{row.employee}</th>
{cell(row, "email", "Почта", false)}
{cell(row, "rate", "Ставка", true)}
{cell(row, "hours", "Часы", true)}
</tr>
))}
</tbody>
</table>
</div>
</section>
</>
)
}