Menu

Action Sheet

Лист действий снизу: крупные цели у нижнего края и отдельная кнопка отмены — закрыть должно быть так же легко, как открыть.

  • menu
  • sheet
  • mobile
  • actions

Превью

1440pxHost theme

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

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

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

Проект «Каталог»

Показать полную инструкцию

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

# Установка и размещение «menu-007» (Action Sheet) из VibeUI

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

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

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

## 2. Что это
Лист действий снизу: крупные цели у нижнего края и отдельная кнопка отмены — закрыть должно быть так же легко, как открыть.

Лист действий для телефона: заголовок с именем объекта, крупные пункты с пояснениями и отдельная отмена. Ноль зависимостей, один файл.

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

<Menu007 title="Проект «Каталог»" actions={actions} />

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

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

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

## 5. Сохранить как установлено
- локальную палитру --vibeui-menu-007-* и не подменять её токенами вашей темы
- высоту пунктов 2.75rem: до нижнего края дотягивается только большой палец
- отдельную крупную отмену — закрыть должно быть так же легко, как открыть
- env(safe-area-inset-bottom): иначе лист уезжает под системную черту
- имя объекта в заголовке: «удалить» без имени — источник ошибок
- пояснение у опасного действия: что именно случится

## 6. Можно менять
- массив actions и их пояснения
- title — что за объект
- cancelLabel
- акцент через проп accent

## 7. Правила
- Позиционирование не входит в компонент: прижимает лист к низу вызывающий код.
- Больше пяти действий в листе не помещается: остальное — на отдельном экране.
- Опасное действие в листе стоит подтверждать отдельно: одно нажатие пальцем слишком легко.
- Не выносите CSS-переменные в globals.css: компонент обязан оставаться одним файлом.

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

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

npx shadcn@latest add https://vibeui.ru/r/menu-007.json
https://vibeui.ru/r/menu-007.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-menu-007-*. Клиентский: "use client". Нижний отступ учитывает env(safe-area-inset-bottom), высота пункта 2.75rem — цель под большой палец. У пунктов есть пояснения второй строкой, опасное действие красится текстом, отмена вынесена отдельной кнопкой во всю ширину.

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

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

"use client"

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

export type Menu007Action = {
  label: string
  hint?: string
  danger?: boolean
}

export type Menu007Props = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
  title?: string
  actions?: Menu007Action[]
  cancelLabel?: string
  accent?: string
}

// Идея компонента: меню действий, которое на телефоне выезжает снизу. Список у
// края экрана — единственное место, куда дотягивается большой палец; выпадающее
// меню в верхнем углу для этого не годится. Кнопка «отмена» стоит отдельно и
// крупно: закрыть лист должно быть так же легко, как открыть.
const STYLES = `
:where([data-vibeui-block="menu-007"]){
--vibeui-menu-007-bg:oklch(1 0 0);
--vibeui-menu-007-fg:oklch(0.24 0.014 265);
--vibeui-menu-007-muted:oklch(0.56 0.014 265);
--vibeui-menu-007-border:oklch(0.9 0.006 265);
--vibeui-menu-007-hover:oklch(0.96 0.004 265);
--vibeui-menu-007-danger:oklch(0.56 0.19 25);
--vibeui-menu-007-accent:oklch(0.55 0.17 265);
--vibeui-menu-007-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="menu-007"]{
display:flex;flex-direction:column;gap:0.5rem;
width:100%;max-width:17rem;box-sizing:border-box;
font-family:var(--vibeui-menu-007-font);color:var(--vibeui-menu-007-fg);
}
[data-vibeui-block="menu-007"] [data-part="trigger"]{
appearance:none;cursor:pointer;align-self:flex-start;
height:2.25rem;padding:0 0.875rem;
border:1px solid var(--vibeui-menu-007-border);border-radius:0.625rem;
background:var(--vibeui-menu-007-bg);color:inherit;
font:inherit;font-size:0.8125rem;font-weight:600;
}
[data-vibeui-block="menu-007"] [data-part="trigger"]:focus-visible{outline:2px solid var(--vibeui-menu-007-accent);outline-offset:2px}
/* Лист прижат к нижнему краю: туда дотягивается большой палец. */
[data-vibeui-block="menu-007"] [data-part="sheet"]{
display:flex;flex-direction:column;gap:0.5rem;
padding:0.5rem 0.5rem calc(0.5rem + env(safe-area-inset-bottom,0px));
border:1px solid var(--vibeui-menu-007-border);
border-radius:1.125rem 1.125rem 0.875rem 0.875rem;
background:var(--vibeui-menu-007-bg);
box-shadow:0 -12px 32px -24px oklch(0.2 0.02 265 / 55%);
}
[data-vibeui-block="menu-007"] [data-part="grabber"]{
align-self:center;width:2.25rem;height:0.25rem;margin:0.125rem 0 0.25rem;
border-radius:9999px;background:var(--vibeui-menu-007-border);
}
[data-vibeui-block="menu-007"] [data-part="title"]{
padding:0 0.5rem 0.25rem;font-size:0.75rem;color:var(--vibeui-menu-007-muted);
}
[data-vibeui-block="menu-007"] [data-part="item"]{
display:flex;flex-direction:column;gap:0.0625rem;
width:100%;min-height:2.75rem;padding:0.4375rem 0.75rem;
appearance:none;border:0;border-radius:0.75rem;background:transparent;color:inherit;
font:inherit;font-size:0.9375rem;font-weight:600;text-align:left;cursor:pointer;
}
[data-vibeui-block="menu-007"] [data-part="item"]:hover{background:var(--vibeui-menu-007-hover)}
[data-vibeui-block="menu-007"] [data-part="item"]:focus-visible{outline:2px solid var(--vibeui-menu-007-accent);outline-offset:-2px}
[data-vibeui-block="menu-007"] [data-part="item"][data-danger="true"]{color:var(--vibeui-menu-007-danger)}
[data-vibeui-block="menu-007"] [data-part="hint"]{font-size:0.75rem;font-weight:500;color:var(--vibeui-menu-007-muted)}
/* Отмена крупная и отдельная: закрыть должно быть так же легко, как открыть. */
[data-vibeui-block="menu-007"] [data-part="cancel"]{
width:100%;min-height:2.75rem;
appearance:none;border:1px solid var(--vibeui-menu-007-border);border-radius:0.75rem;
background:var(--vibeui-menu-007-bg);color:inherit;
font:inherit;font-size:0.9375rem;font-weight:650;cursor:pointer;
}
[data-vibeui-block="menu-007"] [data-part="cancel"]:focus-visible{outline:2px solid var(--vibeui-menu-007-accent);outline-offset:2px}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="menu-007"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_ACTIONS: Menu007Action[] = [
  { label: "Поделиться", hint: "Ссылка с правом на просмотр" },
  { label: "Дублировать", hint: "Копия появится рядом" },
  { label: "Переместить в архив" },
  { label: "Удалить", hint: "Отменить будет нельзя", danger: true },
]

/**
 * Лист действий снизу: крупные цели и отдельная кнопка отмены.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Menu007({
  title = "Проект «Каталог»",
  actions = DEFAULT_ACTIONS,
  cancelLabel = "Отмена",
  accent,
  className,
  style,
  ...props
}: Menu007Props) {
  const [open, setOpen] = useState(true)

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

  return (
    <>
      <style href="vibeui-menu-007" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="menu-007"
        className={className}
        style={palette}
      >
        {open ? null : (
          <button
            type="button"
            data-part="trigger"
            onClick={() => setOpen(true)}
          >
            Действия
          </button>
        )}
        {open ? (
          <div data-part="sheet" role="menu" aria-label={title}>
            <span data-part="grabber" aria-hidden="true" />
            <p data-part="title">{title}</p>
            {actions.map((action) => (
              <button
                key={action.label}
                type="button"
                role="menuitem"
                data-part="item"
                data-danger={action.danger}
                onClick={() => setOpen(false)}
              >
                {action.label}
                {action.hint ? (
                  <span data-part="hint">{action.hint}</span>
                ) : null}
              </button>
            ))}
            <button
              type="button"
              data-part="cancel"
              onClick={() => setOpen(false)}
            >
              {cancelLabel}
            </button>
          </div>
        ) : null}
      </div>
    </>
  )
}