Menu List
Меню с ценами — третий вариант вида pricing для локального бизнеса: группы позиций с точечной отбивкой до цены, как в бумажном меню; данные плоские (позиция несёт свою группу), появление групп по прокрутке на view(), 0 КБ JS.
Редактируемые поля
Секция «pricing». Весь контент приходит пропсами — ниже то, что конструктор даст поправить, не трогая код.
| Проп | Что это | Тип |
|---|---|---|
| eyebrow | Надпись сверху | text |
| title | Заголовок | text |
| sub | Подзаголовок | text |
| items | Позиции | list · до 18 |
| note | Сноска | text |
Все пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| eyebrow? | string | "меню" | |
| title? | string | "Печём утром — к вечеру полки пустые" | |
| sub? | string | "Всё из своей печи. Что закончилось — за… | |
| items? | MenuItem[] | ITEMS | |
| note? | string | "Цены действуют на месте и с собой. Про … | Сноска под меню: аллергены, часы кухни. |
| background? | ReactNode | — | Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`. Занят — секция снимает собственную заливку, чтобы не спорить за слой. |
MenuItem — что внутри
- group: string— Группа меню (формат «Выпечка») — позиции с одинаковой группой собираются вместе.
- name: string
- price: string— Цена строкой вместе с валютой (формат «240 ₽»).
- note?: string— Строка под названием: состав, объём.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/menu-list.jsonЧто нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
import type { ReactNode } from "react";
const css = `.mnl { position: relative; isolation: isolate; width: 100%;
padding-block: var(--ds-section-y, var(--ds-8, 80px));
background: var(--ds-bg, oklch(0.14 0.02 275));
color: var(--ds-fg, oklch(0.95 0.01 265));
font-family: var(--ds-font, system-ui, sans-serif); }
.mnl-in { max-width: var(--ds-container, 1120px); margin-inline: auto;
padding-inline: var(--ds-gutter, clamp(18px, 5vw, 54px)); }
.mnl-eyebrow { margin: 0 0 var(--ds-2, 8px); text-align: center;
font-family: var(--ds-font-mono, ui-monospace, monospace);
font-size: var(--ds-text-xs, 0.75rem); letter-spacing: 0.16em; text-transform: uppercase;
color: var(--ds-accent, oklch(0.72 0.16 300)); }
.mnl-h { margin: 0; text-align: center;
font-size: var(--ds-text-xl, 2rem); font-weight: 750;
letter-spacing: var(--ds-tracking-tight, -0.03em); }
.mnl-sub { margin: var(--ds-2, 10px) auto 0; max-width: 56ch; text-align: center;
font-size: var(--ds-text-sm, 0.92rem); line-height: var(--ds-leading-body, 1.55);
color: var(--ds-muted, oklch(0.72 0.02 265)); }
/* группы текут колонками; группа не рвётся на переносе */
.mnl-groups { margin: var(--ds-7, 54px) 0 0;
display: grid; gap: var(--ds-6, 36px) clamp(28px, 5vw, 64px);
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
.mnl-group { min-width: 0; }
.mnl-gname { margin: 0 0 var(--ds-4, 16px);
font-size: var(--ds-text-md, 1.05rem); font-weight: 750;
letter-spacing: 0.02em; text-transform: uppercase;
color: var(--ds-accent, oklch(0.72 0.16 300)); }
.mnl-items { margin: 0; padding: 0; list-style: none;
display: grid; gap: var(--ds-4, 14px); }
.mnl-row { display: flex; align-items: baseline; gap: 10px; }
.mnl-name { font-size: var(--ds-text-sm, 0.95rem); font-weight: 600; }
/* точечная отбивка — классика бумажного меню: пунктир доводит взгляд до цены.
Рисуется бордером у гибкого филлера, а не псевдоэлементом с шириной: филлер
сам занимает ровно столько, сколько осталось между названием и ценой */
.mnl-dots { flex: 1; min-width: 24px;
border-bottom: 2px dotted var(--ds-border, oklch(0.35 0.02 275));
transform: translateY(-4px); }
.mnl-price { font-variant-numeric: tabular-nums; white-space: nowrap;
font-size: var(--ds-text-sm, 0.95rem); font-weight: 700; }
.mnl-note { margin: 3px 0 0; flex-basis: 100%;
font-size: var(--ds-text-xs, 0.78rem); line-height: 1.45;
color: var(--ds-muted, oklch(0.72 0.02 265)); }
.mnl-item { display: flex; flex-wrap: wrap; align-items: baseline; }
.mnl-item .mnl-row { flex: 1 1 100%; }
.mnl-foot { margin: var(--ds-6, 36px) auto 0; max-width: 60ch; text-align: center;
font-family: var(--ds-font-mono, ui-monospace, monospace);
font-size: var(--ds-text-xs, 0.75rem);
color: var(--ds-muted, oklch(0.72 0.02 265)); }
/* появление групп по прокрутке: 0 JS; не поддержано/reduce — меню просто стоит */
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.mnl-group { animation: mnl-in 1s var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both;
animation-timeline: view();
animation-range: entry 0% entry 55%; }
}
}
@keyframes mnl-in { from { opacity: 0; transform: translateY(16px); } }
/* СОКЕТ ФОНА — контракт как у остальных секций */
.mnl-bg { position: absolute; inset: 0; z-index: -1; }
.mnl[data-slot-bg] { background: transparent; }
.mnl-bg::after { content: ""; position: absolute; inset: 0; pointer-events: none; }
.mnl[data-tone="dark"] { --ds-fg: oklch(0.96 0.01 265); --ds-muted: oklch(0.84 0.02 265);
--ds-border: oklch(0.98 0.01 265 / 0.2); color: var(--ds-fg); }
.mnl[data-tone="dark"] .mnl-bg::after {
background: oklch(0.09 0.02 265 / calc(var(--mnl-scrim, 0.5) * 0.9)); }
.mnl[data-tone="light"] { --ds-fg: oklch(0.2 0.02 265); --ds-muted: oklch(0.42 0.02 265);
--ds-border: oklch(0.2 0.02 265 / 0.22); color: var(--ds-fg); }
.mnl[data-tone="light"] .mnl-bg::after {
background: oklch(0.99 0 0 / calc(var(--mnl-scrim, 0.5) * 0.9)); }
`;
export interface MenuItem {
/** Группа меню (формат «Выпечка») — позиции с одинаковой группой собираются вместе. */
group: string;
name: string;
/** Цена строкой вместе с валютой (формат «240 ₽»). */
price: string;
/** Строка под названием: состав, объём. */
note?: string;
}
export interface MenuListProps {
eyebrow?: string;
title?: string;
sub?: string;
items?: MenuItem[];
/** Сноска под меню: аллергены, часы кухни. */
note?: string;
/**
* Сокет фона: любой эффект с `mount.role: "background"`, например `<AuroraField />`.
* Занят — секция снимает собственную заливку, чтобы не спорить за слой.
*/
background?: ReactNode;
/** Какой текст читается поверх фона; приходит из `mount.tone` подставленной записи. */
tone?: "dark" | "light";
/** Плотность скрима между фоном и текстом; 0 — выключить. */
scrim?: number;
}
const ITEMS: MenuItem[] = [
{ group: "Хлеб", name: "Тартин на закваске", price: "320 ₽", note: "пшеница и цельное зерно, 600 г" },
{ group: "Хлеб", name: "Багет утренний", price: "180 ₽", note: "хрустит до вечера, потом — гренки" },
{ group: "Хлеб", name: "Ржаной с тмином", price: "290 ₽" },
{ group: "Выпечка", name: "Круассан", price: "210 ₽", note: "78 слоёв, нормандское масло" },
{ group: "Выпечка", name: "Булочка с кардамоном", price: "190 ₽" },
{ group: "Выпечка", name: "Тарт с сезонной ягодой", price: "340 ₽", note: "что созрело — то и внутри" },
{ group: "Кофе", name: "Эспрессо", price: "160 ₽" },
{ group: "Кофе", name: "Капучино", price: "240 ₽", note: "220 мл, молоко можно овсяное" },
{ group: "Кофе", name: "Фильтр дня", price: "200 ₽", note: "спросите у бариста, что в воронке" },
];
/**
* МЕНЮ с ценами — третий вариант вида pricing: у SaaS-тарифов колонки планов,
* у локального бизнеса — группы позиций с точечной отбивкой до цены, как в
* бумажном меню (пунктир рисует гибкий филлер с бордером — занимает ровно
* остаток строки, никаких замеров).
*
* Данные НАМЕРЕННО плоские: одна позиция = {group, name, price, note}, группировка
* происходит в рендере с сохранением порядка появления групп. Вложенные группы
* (группа → список позиций) упёрлись бы в «карточку в карточке» — вложенных
* списков схема слотов не умеет (открытый пункт BACKLOG), а плоский список
* правится inline уже сегодня.
*/
export function MenuList({
eyebrow = "меню",
title = "Печём утром — к вечеру полки пустые",
sub = "Всё из своей печи. Что закончилось — закончилось, приходите к открытию.",
items = ITEMS,
note = "Цены действуют на месте и с собой. Про аллергены спрашивайте — знаем состав каждой позиции.",
background,
tone,
scrim = 0.5,
}: MenuListProps) {
// порядок групп = порядок их первого появления в списке
const groups: { name: string; rows: { item: MenuItem; idx: number }[] }[] = [];
items.forEach((item, idx) => {
const g = groups.find((x) => x.name === item.group);
if (g) g.rows.push({ item, idx });
else groups.push({ name: item.group, rows: [{ item, idx }] });
});
return (
<section
className="mnl"
data-slot-bg={background ? "" : undefined}
data-tone={tone}
style={{ textAlign: "start", ["--mnl-scrim" as string]: scrim }}
>
<style href="motiva-menu-list" precedence="medium">{css}</style>
{background && <div className="mnl-bg">{background}</div>}
<div className="mnl-in">
{/* data-slot: адрес слота для inline-правки в конструкторе (/build) */}
{eyebrow && <p className="mnl-eyebrow" data-slot="eyebrow">{eyebrow}</p>}
{title && <h2 className="mnl-h" data-slot="title">{title}</h2>}
{sub && <p className="mnl-sub" data-slot="sub">{sub}</p>}
<div className="mnl-groups">
{groups.map((g) => (
<section className="mnl-group" key={g.name}>
<h3 className="mnl-gname" data-slot="items" data-idx={g.rows[0]!.idx} data-field="group">
{g.name}
</h3>
<ul className="mnl-items">
{g.rows.map(({ item, idx }) => (
<li className="mnl-item" key={item.name}>
<span className="mnl-row">
<span className="mnl-name" data-slot="items" data-idx={idx} data-field="name">{item.name}</span>
<span className="mnl-dots" aria-hidden="true" />
<span className="mnl-price" data-slot="items" data-idx={idx} data-field="price">{item.price}</span>
</span>
{item.note && (
<p className="mnl-note" data-slot="items" data-idx={idx} data-field="note">{item.note}</p>
)}
</li>
))}
</ul>
</section>
))}
</div>
{note && <p className="mnl-foot" data-slot="note">{note}</p>}
</div>
</section>
);
}