@ykurilov/tandem-ui
v0.7.0
Published
React-компоненты и токены дизайн-системы Тандем. React 19, Tailwind CSS 4.
Readme
Tandem UI
React-компоненты и токены дизайн-системы Тандем. React 19, Tailwind CSS 4.
Как подключить
Пакет лежит на складе GitHub Packages. В .npmrc проекта:
@ykurilov:registry=https://npm.pkg.github.comТокен с правом read:packages — в ~/.npmrc (строка //npm.pkg.github.com/:_authToken=…). Потом:
pnpm add @tandem/ui@npm:@ykurilov/tandem-ui@^0.7.0В главном CSS приложения:
@import "tailwindcss";
@import "@tandem/ui/styles.css";
/* Tailwind должен видеть классы компонентов, иначе часть стилей не попадёт в сборку. */
@source "../../node_modules/@tandem/ui/dist";Путь в @source считается от этого CSS-файла до node_modules.
Надписи на другом языке
Компоненты сами пишут и озвучивают около полусотни надписей: «Отправить», «Скопировать код», «Закрыть», «Загружается…». По умолчанию они русские. Другой язык ставится один раз у корня приложения:
"use client"
import { TandemLabelsProvider, englishTandemLabels } from "@tandem/ui"
export function Providers({ children }: { children: React.ReactNode }) {
return <TandemLabelsProvider labels={englishTandemLabels}>{children}</TandemLabelsProvider>
}- Словарь — объект типа
TandemLabels, по группе на компонент:appShell,composer,dialog,markdown,userMessage. Готовые:russianTandemLabelsиenglishTandemLabels. - Свой язык собирайте по типу:
const german: TandemLabels = { … }— компилятор подскажет пропущенное. Надписи с числом или именем — функции:removeAttachment: name => `Remove ${name}`. - Можно передать часть словаря:
labels={{ composer: { send: "Go" } }}. Остальное возьмётся у внешнего провайдера, в конце — русское. - У каждого из этих компонентов есть свойство
labelsсо своей группой, оно главнее провайдера. УComposerещё главнее отдельныеplaceholder,aria-labelиstopLabel. - Провайдеру нужен браузерный React, поэтому в Next.js он живёт в файле с
"use client", как в примере.
Каркас приложения: AppShell
Боковое меню и рабочая область рядом. Умеет:
- знак и название, пункты со значками и счётчиками, группы с подписями;
- активный пункт по текущему адресу;
- человека внизу и меню профиля;
- сворачивание и перетаскивание ширины, с памятью между визитами и без мигания при загрузке;
- на телефоне — выезжающую панель с кнопкой открытия: фокус не выходит из панели, Escape закрывает;
- необязательный поиск по разделам.
Роутер оболочке не нужен. Ссылки рисует компонент из linkComponent, по умолчанию — обычный <a>.
Next.js 16
Оболочке нужен браузер, поэтому она живёт в своём файле с "use client". Серверный layout передаёт в неё пользователя и счётчики.
// app/(cabinet)/cabinet-shell.tsx
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Inbox, LogOut, Settings } from "lucide-react"
import { AppShell, TandemMark } from "@tandem/ui"
export function CabinetShell({ user, unanswered, children }: {
user: { name: string; email: string }
unanswered: number
children: React.ReactNode
}) {
return (
<AppShell
linkComponent={Link}
currentPath={usePathname()}
brand={{ logo: <TandemMark />, name: "Живые лидмагниты", href: "/" }}
navigation={[
{ href: "/inbox", label: "Входящие", icon: Inbox, badge: unanswered, badgeLabel: `${unanswered} без ответа` },
{ href: "/settings", label: "Настройки", icon: Settings }
]}
user={user}
userMenu={[{ href: "/api/auth/logout", label: "Выйти", icon: LogOut }]}
>
{children}
</AppShell>
)
}// app/(cabinet)/layout.tsx — серверный
export default async function CabinetLayout({ children }: { children: React.ReactNode }) {
const user = await requireUser()
const unanswered = await countUnansweredThreads(user.id)
return <CabinetShell user={user} unanswered={unanswered}>{children}</CabinetShell>
}Чтобы меню не мигало при загрузке, в <head> корневого app/layout.tsx ставится AppShellScript. Это маленький скрипт: до первой отрисовки он достаёт из localStorage свёрнутость и ширину и пишет их на <html>. Компонент без хуков, корневой layout остаётся серверным.
// app/layout.tsx — корневой, серверный
import { AppShellScript } from "@tandem/ui"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru" suppressHydrationWarning>
<head>
<AppShellScript />
</head>
<body>{children}</body>
</html>
)
}- Скрипт — только в корневом layout. Во вложенном layout React создал бы его заново при переходе на клиенте, а такой скрипт не выполняется, и React предупреждает об этом в консоли.
- Если у
AppShellзаданы своиstorageKey,defaultCollapsed,defaultWidth,minWidthилиmaxWidth, те же значения передаются вAppShellScript. Свёрнутостью или шириной управляет приложение (свойстваcollapsed,width) —rememberCollapsed={false}илиrememberWidth={false}.storageKey={null}у оболочки — скрипт не нужен. suppressHydrationWarningу<html>обязателен: скрипт меняет атрибуты<html>до гидратации, и без флага React предупредит, что они расходятся с серверными. Флаг действует только на сам<html>.- Строгая политика безопасности (CSP):
<AppShellScript nonce={nonce} />.
Без серверной отрисовки (обычное приложение на Vite) скрипт не нужен: оболочка читает сохранённое до первой отрисовки сама.
«Выйти» — обычная ссылка, не кнопка с JavaScript. Адреса /api/… и внешние ссылки оболочка всегда рисует простым <a>, мимо next/link: тот подгружает адреса заранее, и подгрузка выхода разлогинила бы человека. Для других адресов то же включает reloadDocument: true.
Vite и React Router
import { Link, useLocation } from "react-router"
import { AppShell } from "@tandem/ui"
function RouterLink({ href, ...props }: React.ComponentProps<"a"> & { href: string }) {
return <Link to={href} {...props} />
}
export function Layout({ children }: { children: React.ReactNode }) {
return (
<AppShell linkComponent={RouterLink} currentPath={useLocation().pathname} brand={…} navigation={…}>
{children}
</AppShell>
)
}Главные свойства
| Свойство | Что делает |
| --- | --- |
| brand | { logo, name, caption?, href? }. В свёрнутом меню остаётся знак. |
| navigation | Пункты { href, label, icon?, badge?, badgeLabel?, active? } или группы { label, items }. Счётчик больше 99 показан как «99+». badgeLabel — что услышит человек с экранным диктором, например «120 без ответа». |
| currentPath | Текущий адрес. Активен пункт с самым длинным подходящим href. Флаг active у пункта главнее. |
| user, userMenu | Человек { name, email?, avatarUrl? } и пункты его меню: ссылка { label, href }, действие { label, onSelect }, { type: "separator" }, { type: "label", label }. |
| linkComponent | Компонент ссылки роутера. Получает href, className, onClick, aria-current. |
| header | Верхняя полоса над содержимым. Без неё полоса есть только на телефоне: кнопка меню и знак. |
| search | { items: [{ label, href, hint?, keywords? }], placeholder? }. |
| sidebarFooter | Блок над пользователем. |
| storageKey | Ключ в localStorage, по умолчанию "tandem-sidebar". null — ничего не запоминать. |
| collapsed, width | Управляемые свёрнутость и ширина, если приложение хранит их само (например, в cookie). Пары: onCollapsedChange, onWidthChange. |
| defaultCollapsed, defaultWidth, minWidth, maxWidth | Начальные значения и пределы: 264, 220 и 380 px. |
| mainId, mainClassName | id и классы рабочей области. На неё ведёт ссылка «Перейти к содержимому». |
| labels | Подписи для другого языка. Главнее общего TandemLabelsProvider (см. «Надписи на другом языке»). |
Внутри оболочки useAppShell() даёт collapsed, mobileOpen, openMobileMenu, closeMobileMenu — например, чтобы закрыть панель из своего кода.
