Feedback
Reading Drawer
Ящик во всю высоту для длинного текста: заголовки разделов прилипают к верху, а подвал с согласием стоит на месте — до действия не нужно доезжать до конца.
- drawer
- long read
- sticky
- consent
Превью
Использовать с AI
- 1. Скопируйте ссылку.
- 2. Напишите агенту своими словами и вставьте её в предложение.
- 3. Агент откроет ссылку и поставит компонент из registry.
размести это в шапке: https://vibeui.ru/c/drawer-007
Показать полную инструкцию
То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.
# Установка и размещение «drawer-007» (Reading Drawer) из VibeUI
## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/drawer-007.json
Registry item: https://vibeui.ru/r/drawer-007.json
Ставится в: components/vibeui/drawer-007.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.
Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.
## 2. Что это
Ящик во всю высоту для длинного текста: заголовки разделов прилипают к верху, а подвал с согласием стоит на месте — до действия не нужно доезжать до конца.
Ящик во всю высоту: длинный текст с липкими заголовками разделов, а внизу — флажок согласия и кнопка принятия, которая до отметки заблокирована. Ноль зависимостей, один файл, своя палитра.
## 3. Как использовать
import { Drawer007 } from "@/components/vibeui/drawer-007"
<Drawer007
triggerLabel="Читать условия"
title="Обновлённые условия"
sections={sections}
acceptLabel="Принять"
/>
Полный список пропсов — в установленном файле.
## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.
Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)
## 5. Сохранить как установлено
- локальную палитру --vibeui-drawer-007-* и не подменять её токенами вашей темы
- min-height:0 у прокручиваемой колонки: без него flex-контейнер растягивается и прокрутка уезжает наружу
- непрозрачный фон у липкого заголовка — иначе текст просвечивает сквозь него при прокрутке
- подвал вне прокрутки: до согласия не должно быть нужно доезжать до конца документа
- заблокированную кнопку принятия до отметки флажка: согласие обязано быть осознанным действием
- ширину в 30rem: строка длиннее читается тяжело даже в ящике
## 6. Можно менять
- title — заголовок ящика и его имя для скринридера
- массив sections: заголовки и абзацы любого объёма
- triggerLabel, agreeLabel и acceptLabel — подписи триггера, флажка и кнопки
- ширину ящика и размер шрифта текста
- поведение принятия: сейчас кнопка только закрывает ящик
## 7. Правила
- Прокрутка до конца не отслеживается: если нужен факт прочтения, ловите прокрутку отдельно.
- Не заменяйте этим ящиком страницу документа: длинный текст должен иметь свой адрес и печататься.
- Флажок не запоминается между открытиями — состояние согласия хранит вызывающий код.
- Не выносите CSS-переменные в globals.css: компонент обязан оставаться одним файлом.
## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.Для разработчика
npx shadcn@latest add https://vibeui.ru/r/drawer-007.jsonhttps://vibeui.ru/r/drawer-007.jsonКомпонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-drawer-007-*. Клиентский: "use client", useState держит согласие, useRef — showModal и close. Прокручивается только средняя колонка (flex:1 и min-height:0), заголовки разделов держатся на position:sticky с непрозрачным фоном, иначе текст просвечивает под ними. Кнопка принятия заблокирована, пока флажок не отмечен.
Исходник компонента
Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.
"use client"
import { useRef, useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"
export type Drawer007Section = {
heading: string
text: string
}
export type Drawer007Props = Omit<
ComponentPropsWithoutRef<"div">,
"children" | "title"
> & {
triggerLabel?: string
title?: string
sections?: Drawer007Section[]
agreeLabel?: string
acceptLabel?: string
accent?: string
}
// Идея компонента: ящик во всю высоту для длинного текста. Прокручивается
// только середина; заголовки разделов прилипают к верху колонки, а подвал с
// согласием и кнопкой стоит на месте — до действия не нужно доезжать до
// конца документа, и видно, в каком разделе сейчас находишься.
const STYLES = `
:where([data-vibeui-block="drawer-007"]){
--vibeui-drawer-007-bg:oklch(1 0 0);
--vibeui-drawer-007-fg:oklch(0.21 0.014 265);
--vibeui-drawer-007-muted:oklch(0.55 0.014 265);
--vibeui-drawer-007-border:oklch(0.91 0.006 265);
--vibeui-drawer-007-accent:oklch(0.55 0.17 265);
--vibeui-drawer-007-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="drawer-007"]{
display:inline-block;font-family:var(--vibeui-drawer-007-font);color:var(--vibeui-drawer-007-fg);
}
[data-vibeui-block="drawer-007"] [data-part="trigger"]{
appearance:none;cursor:pointer;height:2.25rem;padding:0 0.875rem;
border:1px solid var(--vibeui-drawer-007-border);border-radius:0.625rem;
background:var(--vibeui-drawer-007-bg);color:inherit;
font:inherit;font-size:0.8125rem;font-weight:600;
}
[data-vibeui-block="drawer-007"] [data-part="trigger"]:focus-visible{outline:2px solid var(--vibeui-drawer-007-accent);outline-offset:2px}
[data-vibeui-block="drawer-007"] dialog{
position:fixed;inset:0 0 0 auto;margin:0;
width:min(30rem,100vw);max-width:100vw;height:100dvh;max-height:100dvh;
padding:0;border:0;background:var(--vibeui-drawer-007-bg);color:inherit;
box-shadow:-24px 0 60px -30px oklch(0.2 0.02 265 / 55%);
translate:100% 0;transition:translate .22s ease,overlay .22s allow-discrete,display .22s allow-discrete;
}
[data-vibeui-block="drawer-007"] dialog[open]{translate:0 0}
@starting-style{
[data-vibeui-block="drawer-007"] dialog[open]{translate:100% 0}
}
[data-vibeui-block="drawer-007"] dialog::backdrop{background:oklch(0.19 0.02 265 / 45%)}
[data-vibeui-block="drawer-007"] [data-part="panel"]{display:flex;flex-direction:column;height:100%;box-sizing:border-box}
[data-vibeui-block="drawer-007"] [data-part="head"]{
display:flex;align-items:center;justify-content:space-between;gap:0.75rem;
padding:1rem 1.125rem 0.75rem;border-bottom:1px solid var(--vibeui-drawer-007-border);
}
[data-vibeui-block="drawer-007"] [data-part="title"]{margin:0;font-size:1.0625rem;font-weight:680}
[data-vibeui-block="drawer-007"] [data-part="close"]{
appearance:none;border:0;cursor:pointer;background:transparent;flex:none;
display:flex;align-items:center;justify-content:center;
width:2rem;height:2rem;border-radius:0.5rem;color:var(--vibeui-drawer-007-muted);
}
[data-vibeui-block="drawer-007"] [data-part="close"]:hover{background:oklch(0.96 0.004 265);color:var(--vibeui-drawer-007-fg)}
[data-vibeui-block="drawer-007"] [data-part="close"]:focus-visible{outline:2px solid var(--vibeui-drawer-007-accent);outline-offset:2px}
[data-vibeui-block="drawer-007"] [data-part="cross"]{position:relative;width:0.625rem;height:0.625rem}
[data-vibeui-block="drawer-007"] [data-part="cross"]::before,
[data-vibeui-block="drawer-007"] [data-part="cross"]::after{
content:"";position:absolute;left:0;top:50%;width:100%;height:1.5px;
margin-top:-0.75px;background:currentColor;border-radius:9999px;
}
[data-vibeui-block="drawer-007"] [data-part="cross"]::before{transform:rotate(45deg)}
[data-vibeui-block="drawer-007"] [data-part="cross"]::after{transform:rotate(-45deg)}
/* Единственная прокручиваемая область: min-height:0 обязателен во flex. */
[data-vibeui-block="drawer-007"] [data-part="body"]{
flex:1;min-height:0;overflow-y:auto;padding:0 1.125rem 1rem;
}
/* Заголовок раздела прилипает: видно, где ты, не поднимаясь наверх. */
[data-vibeui-block="drawer-007"] [data-part="heading"]{
position:sticky;top:0;z-index:1;margin:0;
padding:0.75rem 0 0.5rem;background:var(--vibeui-drawer-007-bg);
font-size:0.75rem;font-weight:700;letter-spacing:0.06em;text-transform:uppercase;
color:var(--vibeui-drawer-007-muted);
box-shadow:0 1px 0 var(--vibeui-drawer-007-border);
}
[data-vibeui-block="drawer-007"] [data-part="text"]{
margin:0.75rem 0 1.25rem;font-size:0.875rem;line-height:1.6;
}
[data-vibeui-block="drawer-007"] [data-part="foot"]{
display:flex;flex-direction:column;gap:0.625rem;
padding:0.875rem 1.125rem calc(0.875rem + env(safe-area-inset-bottom,0px));
border-top:1px solid var(--vibeui-drawer-007-border);background:var(--vibeui-drawer-007-bg);
}
[data-vibeui-block="drawer-007"] [data-part="agree"]{
display:flex;align-items:flex-start;gap:0.5rem;cursor:pointer;font-size:0.8125rem;line-height:1.4;
}
[data-vibeui-block="drawer-007"] [data-part="agree"] input{
appearance:none;flex:none;width:1.125rem;height:1.125rem;margin:0.0625rem 0 0;
border:1.5px solid var(--vibeui-drawer-007-border);border-radius:0.375rem;
background:var(--vibeui-drawer-007-bg);cursor:pointer;position:relative;
}
[data-vibeui-block="drawer-007"] [data-part="agree"] input:checked{
background:var(--vibeui-drawer-007-accent);border-color:var(--vibeui-drawer-007-accent);
}
[data-vibeui-block="drawer-007"] [data-part="agree"] input:checked::after{
content:"";position:absolute;left:0.3125rem;top:0.125rem;
width:0.25rem;height:0.5rem;border:solid oklch(0.99 0.01 265);
border-width:0 2px 2px 0;transform:rotate(45deg);
}
[data-vibeui-block="drawer-007"] [data-part="agree"] input:focus-visible{outline:2px solid var(--vibeui-drawer-007-accent);outline-offset:2px}
[data-vibeui-block="drawer-007"] [data-part="accept"]{
appearance:none;border:0;cursor:pointer;width:100%;height:2.75rem;border-radius:0.75rem;
background:var(--vibeui-drawer-007-accent);color:oklch(0.99 0.01 265);
font:inherit;font-size:0.9375rem;font-weight:650;
}
[data-vibeui-block="drawer-007"] [data-part="accept"]:disabled{cursor:not-allowed;opacity:.45}
[data-vibeui-block="drawer-007"] [data-part="accept"]:focus-visible{outline:2px solid var(--vibeui-drawer-007-accent);outline-offset:2px}
@media (prefers-reduced-motion:reduce){
[data-vibeui-block="drawer-007"] *{animation:none!important;transition:none!important}
[data-vibeui-block="drawer-007"] dialog{translate:0 0}
}
`
const DEFAULT_SECTIONS: Drawer007Section[] = [
{
heading: "Что меняется",
text: "С 1 апреля публикация проекта считается по числу активных адресов, а не по числу сборок. Старые проекты пересчитываются автоматически, доплачивать за прошедшие месяцы не нужно.",
},
{
heading: "Хранение данных",
text: "Исходники и журналы сборки хранятся 90 дней с момента последней публикации. После этого журналы удаляются, исходники остаются доступными для скачивания ещё год.",
},
{
heading: "Доступ команды",
text: "Приглашённый участник видит проекты, к которым его добавили, и историю их публикаций. Права владельца остаются у создателя пространства и передаются только вручную.",
},
{
heading: "Отказ от условий",
text: "Если новые условия не подходят, проект можно выгрузить одним архивом и удалить пространство. Выгрузка содержит исходники, настройки сборки и список адресов.",
},
]
/**
* Ящик во всю высоту: липкие заголовки разделов и подвал с согласием.
* Один файл, ноль зависимостей, собственная палитра.
*/
export function Drawer007({
triggerLabel = "Читать условия",
title = "Обновлённые условия",
sections = DEFAULT_SECTIONS,
agreeLabel = "Я прочитал условия и согласен с ними",
acceptLabel = "Принять",
accent,
className,
style,
...props
}: Drawer007Props) {
const drawer = useRef<HTMLDialogElement>(null)
const [agreed, setAgreed] = useState(false)
const palette = {
...(accent ? { "--vibeui-drawer-007-accent": accent } : null),
...style,
} as CSSProperties
return (
<>
<style href="vibeui-drawer-007" precedence="medium">
{STYLES}
</style>
<div
{...props}
data-vibeui-block="drawer-007"
className={className}
style={palette}
>
<button
type="button"
data-part="trigger"
onClick={() => drawer.current?.showModal()}
>
{triggerLabel}
</button>
<dialog
ref={drawer}
aria-label={title}
onClick={(event) => {
if (event.target === drawer.current) {
drawer.current.close()
}
}}
>
<div data-part="panel">
<div data-part="head">
<h2 data-part="title">{title}</h2>
<button
type="button"
data-part="close"
aria-label="Закрыть панель"
onClick={() => drawer.current?.close()}
>
<span data-part="cross" aria-hidden="true" />
</button>
</div>
<div data-part="body">
{sections.map((section) => (
<section key={section.heading}>
<h3 data-part="heading">{section.heading}</h3>
<p data-part="text">{section.text}</p>
</section>
))}
</div>
<div data-part="foot">
<label data-part="agree">
<input
type="checkbox"
checked={agreed}
onChange={(event) => setAgreed(event.target.checked)}
/>
<span>{agreeLabel}</span>
</label>
<button
type="button"
data-part="accept"
disabled={!agreed}
onClick={() => drawer.current?.close()}
>
{acceptLabel}
</button>
</div>
</div>
</dialog>
</div>
</>
)
}