feat(admin-ui): shadcn primitives + @/ui barrel (Stage 2.1)
This commit is contained in:
@@ -14,21 +14,36 @@
|
|||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@nstart/ui": "^0.1.3",
|
"@nstart/ui": "^0.1.3",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"@tanstack/react-router": "^1.86.0",
|
"@tanstack/react-router": "^1.86.0",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"ajv-formats": "^3.0.1",
|
"ajv-formats": "^3.0.1",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"i18next": "^26.0.3",
|
"i18next": "^26.0.3",
|
||||||
"i18next-browser-languagedetector": "^8.0.2",
|
"i18next-browser-languagedetector": "^8.0.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"lucide-react": "^1.14.0",
|
||||||
"oidc-client-ts": "^3.5.0",
|
"oidc-client-ts": "^3.5.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-i18next": "^17.0.2",
|
"react-i18next": "^17.0.2",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-oidc-context": "^3.3.1"
|
"react-oidc-context": "^3.3.1",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0-beta.7",
|
"@tailwindcss/vite": "^4.0.0-beta.7",
|
||||||
|
|||||||
Generated
+1035
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,11 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="32" height="32">
|
||||||
<!-- Background: rounded square в brand ultramarain. Tailwind rounded-sm = 2px → ratio 2/32 = 6.25% → r=2 для viewBox 32. -->
|
<!-- Brand mark v2 (handoff redesign): rounded-square в navy/coffee tone +
|
||||||
<rect width="32" height="32" rx="6" ry="6" fill="#120a8f"/>
|
gold знак из logo-ujcvk01-dark-gold.svg. Цвета matched к --color-navy
|
||||||
<!-- 'O' — Ordinis. Stroke-only (open ring), match'ит open design system без heavy fills. -->
|
и --color-warn handoff palette — при 16×16 в browser tab остаётся
|
||||||
<circle cx="16" cy="16" r="7.5" fill="none" stroke="#ffffff" stroke-width="2.5"/>
|
читаемым на light и dark backgrounds. -->
|
||||||
<!-- Спутник: маленький точка на орбите вокруг 'O'. Связь с ДЗЗ-доменом. -->
|
<rect width="200" height="200" rx="36" ry="36" fill="#3a2818"/>
|
||||||
<circle cx="25.5" cy="9.5" r="1.7" fill="#ffffff"/>
|
<g fill="#d4a653" transform="matrix(.085 0 0 -.085 7.55 192.45)">
|
||||||
<!-- Orbit trace: тонкая дуга через спутник, opacity 0.35 чтобы не доминировала. -->
|
<path d="m950 1292v-512h65 65l2 266 3 265 183-327 183-328-3-60-3-59-175-108c-96-59-178-108-182-108-5-1-8 80-8 179v180h-65-65v-295c0-162 2-295 5-295 2 0 143 85 312 188l308 189 3 109 3 109-307 550c-168 303-310 554-315 560-5 5-9-193-9-503z"/>
|
||||||
<path d="M 8 24 Q 16 30 26 22" fill="none" stroke="#ffffff" stroke-width="0.7" stroke-linecap="round" opacity="0.35"/>
|
<path d="m577 1238-308-552 3-110 3-109 308-189c169-103 310-188 312-188 3 0 4 132 3 293l-3 292-62 3-63 3v-181c0-99-3-180-7-180-5 0-87 49-183 108l-175 108-3 61-3 61 183 328 183 328 3-267 2-268 63 3 62 3 3 503c1 276-1 502-6 502-4 0-146-248-315-552z"/>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 909 B After Width: | Height: | Size: 1.0 KiB |
@@ -25,14 +25,29 @@ export function TokenSync() {
|
|||||||
const redirecting = useRef(false)
|
const redirecting = useRef(false)
|
||||||
|
|
||||||
// 1) Token sync — single update path к accessToken holder в client.ts.
|
// 1) Token sync — single update path к accessToken holder в client.ts.
|
||||||
|
// ВАЖНО: пропускаем токен ТОЛЬКО когда user реально authenticated AND token
|
||||||
|
// не expired. Раньше передавали raw {@code auth.user?.access_token} — это
|
||||||
|
// включало случаи когда oidc-client-ts вернул user object с уже expired
|
||||||
|
// token'ом (silent renew failed но user object всё ещё в state). axios
|
||||||
|
// отправлял Bearer expired_token → backend (с permissive=false для guest
|
||||||
|
// anyway re-validates JWT через oauth2ResourceServer) → 401 → catalog UI
|
||||||
|
// показывал «Не удалось загрузить данные» вместо PUBLIC dicts.
|
||||||
|
//
|
||||||
|
// user.expired property из oidc-client-ts: true если access_token expiry
|
||||||
|
// прошла. Дополнительно проверяем isAuthenticated — react-oidc-context
|
||||||
|
// обновляет это в реальном времени.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAccessToken(auth.user?.access_token ?? null)
|
const liveToken =
|
||||||
|
auth.isAuthenticated && auth.user && !auth.user.expired
|
||||||
|
? auth.user.access_token
|
||||||
|
: null
|
||||||
|
setAccessToken(liveToken ?? null)
|
||||||
// Cleanup legacy localStorage key (was used до fix #12 + #2/3/13 cascade).
|
// Cleanup legacy localStorage key (was used до fix #12 + #2/3/13 cascade).
|
||||||
// Идемпотентный no-op если ключа нет.
|
// Идемпотентный no-op если ключа нет.
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.localStorage.removeItem(LEGACY_TOKEN_STORAGE_KEY)
|
window.localStorage.removeItem(LEGACY_TOKEN_STORAGE_KEY)
|
||||||
}
|
}
|
||||||
}, [auth.user?.access_token])
|
}, [auth.isAuthenticated, auth.user, auth.user?.access_token, auth.user?.expired])
|
||||||
|
|
||||||
// 2) 401 handler — registered once на app mount, делегирует silent renew /
|
// 2) 401 handler — registered once на app mount, делегирует silent renew /
|
||||||
// redirect на Keycloak. Guard'ы:
|
// redirect на Keycloak. Guard'ы:
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Monitor, Moon, Sun } from 'lucide-react'
|
||||||
|
import { useTheme, type ThemePreference } from '@/stores/ThemeProvider'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tri-state theme switch: Light / Dark / System.
|
||||||
|
*
|
||||||
|
* <p>Segmented control с тремя иконками. Активный — `--color-accent`
|
||||||
|
* background + `--color-on-accent` foreground. Click меняет `preference`
|
||||||
|
* в {@link ThemeProvider}, который пишет в localStorage и переключает
|
||||||
|
* `data-theme` на html.
|
||||||
|
*
|
||||||
|
* <p>System icon показывает «follow OS» — auto-update при OS theme change.
|
||||||
|
*/
|
||||||
|
export function ThemeSwitch() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { preference, setPreference } = useTheme()
|
||||||
|
|
||||||
|
const items: { id: ThemePreference; label: string; icon: typeof Sun }[] = [
|
||||||
|
{ id: 'light', label: t('theme.light'), icon: Sun },
|
||||||
|
{ id: 'dark', label: t('theme.dark'), icon: Moon },
|
||||||
|
{ id: 'system', label: t('theme.system'), icon: Monitor },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={t('theme.label')}
|
||||||
|
className="inline-flex items-center rounded-md border border-line bg-surface p-0.5"
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
const active = preference === item.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={active}
|
||||||
|
aria-label={item.label}
|
||||||
|
title={item.label}
|
||||||
|
onClick={() => setPreference(item.id)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-6 w-6 items-center justify-center rounded transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-accent text-on-accent'
|
||||||
|
: 'text-mute hover:text-ink hover:bg-surface-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={13} strokeWidth={2.25} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ i18n
|
|||||||
'ru-RU': {
|
'ru-RU': {
|
||||||
translation: {
|
translation: {
|
||||||
'app.title': 'Ordinis',
|
'app.title': 'Ordinis',
|
||||||
|
'theme.label': 'Тема',
|
||||||
|
'theme.light': 'Светлая',
|
||||||
|
'theme.dark': 'Тёмная',
|
||||||
|
'theme.system': 'Системная',
|
||||||
|
'nav.docs': 'Руководство',
|
||||||
'updateBanner.message': 'Доступна новая версия НСИ ({{version}}). Обновите страницу, чтобы получить последние исправления.',
|
'updateBanner.message': 'Доступна новая версия НСИ ({{version}}). Обновите страницу, чтобы получить последние исправления.',
|
||||||
'updateBanner.reload': 'Обновить',
|
'updateBanner.reload': 'Обновить',
|
||||||
'updateBanner.docs': 'Что нового',
|
'updateBanner.docs': 'Что нового',
|
||||||
@@ -513,6 +518,11 @@ i18n
|
|||||||
'en-US': {
|
'en-US': {
|
||||||
translation: {
|
translation: {
|
||||||
'app.title': 'Ordinis MDM',
|
'app.title': 'Ordinis MDM',
|
||||||
|
'theme.label': 'Theme',
|
||||||
|
'theme.light': 'Light',
|
||||||
|
'theme.dark': 'Dark',
|
||||||
|
'theme.system': 'System',
|
||||||
|
'nav.docs': 'Docs',
|
||||||
'updateBanner.message': 'A new NSI version ({{version}}) is available. Reload to get the latest fixes.',
|
'updateBanner.message': 'A new NSI version ({{version}}) is available. Reload to get the latest fixes.',
|
||||||
'updateBanner.reload': 'Reload',
|
'updateBanner.reload': 'Reload',
|
||||||
'updateBanner.docs': "What's new",
|
'updateBanner.docs': "What's new",
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tailwind class merger — combines clsx (conditional classes) + tailwind-merge
|
||||||
|
* (resolves conflicts: e.g. `px-2 px-4` → `px-4`).
|
||||||
|
*
|
||||||
|
* Standard shadcn/ui utility, used by every primitive variant.
|
||||||
|
*/
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { routeTree } from './routeTree.gen'
|
|||||||
import { oidcAuthConfig } from '@/auth/oidcConfig'
|
import { oidcAuthConfig } from '@/auth/oidcConfig'
|
||||||
import { TokenSync } from '@/auth/TokenSync'
|
import { TokenSync } from '@/auth/TokenSync'
|
||||||
import { RequireAuth } from '@/auth/RequireAuth'
|
import { RequireAuth } from '@/auth/RequireAuth'
|
||||||
|
import { ThemeProvider } from '@/stores/ThemeProvider'
|
||||||
import './i18n'
|
import './i18n'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
@@ -29,15 +30,17 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<AuthProvider {...oidcAuthConfig}>
|
<AuthProvider {...oidcAuthConfig}>
|
||||||
<TokenSync />
|
<TokenSync />
|
||||||
<NStartUiProvider>
|
<ThemeProvider>
|
||||||
<RequireAuth>
|
<NStartUiProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<RequireAuth>
|
||||||
<DatePickerProvider datePicker={DATE_PICKER_LOCALE_RU}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RouterProvider router={router} />
|
<DatePickerProvider datePicker={DATE_PICKER_LOCALE_RU}>
|
||||||
</DatePickerProvider>
|
<RouterProvider router={router} />
|
||||||
</QueryClientProvider>
|
</DatePickerProvider>
|
||||||
</RequireAuth>
|
</QueryClientProvider>
|
||||||
</NStartUiProvider>
|
</RequireAuth>
|
||||||
|
</NStartUiProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { LanguageSwitch } from '@nstart/ui'
|
import { LanguageSwitch } from '@nstart/ui'
|
||||||
import { AuthBadge } from '@/auth/AuthBadge'
|
import { AuthBadge } from '@/auth/AuthBadge'
|
||||||
import { useCanMutate } from '@/auth/useCanMutate'
|
import { useCanMutate } from '@/auth/useCanMutate'
|
||||||
|
import { ThemeSwitch } from '@/components/layout/ThemeSwitch'
|
||||||
import { UpdateBanner } from '@/components/version/UpdateBanner'
|
import { UpdateBanner } from '@/components/version/UpdateBanner'
|
||||||
import { VersionBadge } from '@/components/version/VersionBadge'
|
import { VersionBadge } from '@/components/version/VersionBadge'
|
||||||
|
|
||||||
@@ -46,6 +47,17 @@ function RootLayout() {
|
|||||||
>
|
>
|
||||||
{t('nav.search')}
|
{t('nav.search')}
|
||||||
</Link>
|
</Link>
|
||||||
|
{/* Документация — внешняя страница (Docusaurus → ordinis-docs
|
||||||
|
deployment монтируется на /docs ingress'ом). target=_blank
|
||||||
|
чтобы не терять контекст приложения. */}
|
||||||
|
<a
|
||||||
|
href="/docs/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className="text-carbon hover:text-ultramarain"
|
||||||
|
>
|
||||||
|
{t('nav.docs')}
|
||||||
|
</a>
|
||||||
{canMutate && (
|
{canMutate && (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
@@ -88,6 +100,7 @@ function RootLayout() {
|
|||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
<VersionBadge />
|
<VersionBadge />
|
||||||
|
<ThemeSwitch />
|
||||||
<LanguageSwitch
|
<LanguageSwitch
|
||||||
value={i18n.language}
|
value={i18n.language}
|
||||||
options={LANG_OPTIONS}
|
options={LANG_OPTIONS}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme: 'light' (Earth) | 'dark' (Claude-warm) | 'system' (follow prefers-color-scheme).
|
||||||
|
*
|
||||||
|
* <p>Persist'ится в {@code localStorage[STORAGE_KEY]}. На mount читает saved
|
||||||
|
* preference или fallback'ает к 'system'. Меняет {@code data-theme} attr на
|
||||||
|
* {@code <html>} → CSS custom properties в styles.css переключают палитру.
|
||||||
|
*
|
||||||
|
* <p>{@code system} mode — если user ничего не выбрал, следим за prefers-color-scheme
|
||||||
|
* media query. matchMedia listener обновляет attr реально-time при OS theme change.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'ord-theme'
|
||||||
|
|
||||||
|
export type ThemePreference = 'light' | 'dark' | 'system'
|
||||||
|
export type ResolvedTheme = 'light' | 'dark'
|
||||||
|
|
||||||
|
type ThemeContextValue = {
|
||||||
|
preference: ThemePreference
|
||||||
|
resolved: ResolvedTheme
|
||||||
|
setPreference: (next: ThemePreference) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||||
|
|
||||||
|
function readSystemPreference(): ResolvedTheme {
|
||||||
|
if (typeof window === 'undefined') return 'light'
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSavedPreference(): ThemePreference {
|
||||||
|
if (typeof window === 'undefined') return 'system'
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (raw === 'light' || raw === 'dark' || raw === 'system') return raw
|
||||||
|
return 'system'
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyToDom(theme: ResolvedTheme) {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
|
// shadcn convention — добавляем .dark class тоже на случай third-party
|
||||||
|
// компонентов которые ждут именно его. Custom variant в styles.css всё равно
|
||||||
|
// их связывает, но эта мера держит совместимость на корню.
|
||||||
|
if (theme === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark')
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark')
|
||||||
|
}
|
||||||
|
// <meta name="color-scheme"> чтобы system browser UI (scrollbars, autofill)
|
||||||
|
// был в гармонии с темой.
|
||||||
|
let meta = document.querySelector<HTMLMetaElement>('meta[name="color-scheme"]')
|
||||||
|
if (!meta) {
|
||||||
|
meta = document.createElement('meta')
|
||||||
|
meta.name = 'color-scheme'
|
||||||
|
document.head.appendChild(meta)
|
||||||
|
}
|
||||||
|
meta.content = theme
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [preference, setPreferenceState] = useState<ThemePreference>(() => readSavedPreference())
|
||||||
|
const [systemResolved, setSystemResolved] = useState<ResolvedTheme>(() => readSystemPreference())
|
||||||
|
|
||||||
|
// Слушаем OS theme change при mode='system'.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
const handler = () => setSystemResolved(mq.matches ? 'dark' : 'light')
|
||||||
|
mq.addEventListener('change', handler)
|
||||||
|
return () => mq.removeEventListener('change', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resolved: ResolvedTheme = preference === 'system' ? systemResolved : preference
|
||||||
|
|
||||||
|
// Применяем к DOM каждый раз когда resolved меняется.
|
||||||
|
useEffect(() => {
|
||||||
|
applyToDom(resolved)
|
||||||
|
}, [resolved])
|
||||||
|
|
||||||
|
const setPreference = (next: ThemePreference) => {
|
||||||
|
setPreferenceState(next)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ preference, resolved, setPreference }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const ctx = useContext(ThemeContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useTheme must be used inside <ThemeProvider>')
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -1,12 +1,187 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
@import "@nstart/ui/styles/fonts.css";
|
@import 'tw-animate-css';
|
||||||
@import "@nstart/ui/styles/theme.css";
|
@import '@nstart/ui/styles/fonts.css';
|
||||||
|
@import '@nstart/ui/styles/theme.css';
|
||||||
|
|
||||||
/* Без @source Tailwind v4 не сгенерирует CSS для классов внутри dist/@nstart/ui */
|
/* Без @source Tailwind v4 не сгенерирует CSS для классов внутри dist/@nstart/ui */
|
||||||
@source "../node_modules/@nstart/ui/dist";
|
@source '../node_modules/@nstart/ui/dist';
|
||||||
|
|
||||||
html,
|
/* Google Fonts — Inter / JetBrains Mono / Tektur (handoff spec).
|
||||||
body,
|
* @nstart fonts.css дублируется по части семейств — overlap безвреден,
|
||||||
#root {
|
* на финальной фазе миграции (когда удалим @nstart/ui) останется только это. */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Tektur:wght@500;600;700&display=swap');
|
||||||
|
|
||||||
|
/* === Dark mode trigger ===
|
||||||
|
* shadcn использует .dark class по умолчанию. Handoff просит [data-theme="dark"]
|
||||||
|
* на <html>. Custom variant связывает оба пути — компоненты с `dark:` modifier
|
||||||
|
* срабатывают при любом из условий. */
|
||||||
|
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
|
||||||
|
|
||||||
|
/* === DESIGN TOKENS — Light theme (Earth, default :root) === */
|
||||||
|
:root {
|
||||||
|
--color-ink: #1a1714;
|
||||||
|
--color-ink-2: #52483d;
|
||||||
|
--color-mute: #8c8276;
|
||||||
|
--color-bg: #fbf8ee;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-surface-2: #f4eedc;
|
||||||
|
--color-line: #e3dccb;
|
||||||
|
--color-line-2: #efe9d8;
|
||||||
|
--color-navy: #3a2818;
|
||||||
|
--color-accent: #b05a2e;
|
||||||
|
--color-accent-bg: rgb(176 90 46 / 11%);
|
||||||
|
--color-on-accent: #ffffff;
|
||||||
|
--color-green: #4f7038;
|
||||||
|
--color-green-bg: #e4ead0;
|
||||||
|
--color-warn: #a8762a;
|
||||||
|
--color-warn-bg: #f6e8bc;
|
||||||
|
--color-pink: #a64636;
|
||||||
|
--color-pink-bg: #f4d9cd;
|
||||||
|
|
||||||
|
/* shadcn semantic tokens — re-mapped onto наши значения */
|
||||||
|
--background: var(--color-bg);
|
||||||
|
--foreground: var(--color-ink);
|
||||||
|
--card: var(--color-surface);
|
||||||
|
--card-foreground: var(--color-ink);
|
||||||
|
--popover: var(--color-surface);
|
||||||
|
--popover-foreground: var(--color-ink);
|
||||||
|
--primary: var(--color-navy);
|
||||||
|
--primary-foreground: var(--color-on-accent);
|
||||||
|
--secondary: var(--color-surface-2);
|
||||||
|
--secondary-foreground: var(--color-ink);
|
||||||
|
--muted: var(--color-surface-2);
|
||||||
|
--muted-foreground: var(--color-mute);
|
||||||
|
--accent: var(--color-accent);
|
||||||
|
--accent-foreground: var(--color-on-accent);
|
||||||
|
--destructive: var(--color-pink);
|
||||||
|
--destructive-foreground: var(--color-on-accent);
|
||||||
|
--border: var(--color-line);
|
||||||
|
--input: var(--color-line);
|
||||||
|
--ring: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] {
|
||||||
|
--color-ink: #f0ece4;
|
||||||
|
--color-ink-2: #c8c1b3;
|
||||||
|
--color-mute: #8a8474;
|
||||||
|
--color-bg: #1c1a17;
|
||||||
|
--color-surface: #262421;
|
||||||
|
--color-surface-2: #2e2b27;
|
||||||
|
--color-line: #3a3733;
|
||||||
|
--color-line-2: #2d2a27;
|
||||||
|
--color-navy: #d97757;
|
||||||
|
--color-accent: #d97757;
|
||||||
|
--color-accent-bg: rgb(217 119 87 / 16%);
|
||||||
|
--color-on-accent: #1c1a17;
|
||||||
|
--color-green: #6fa97d;
|
||||||
|
--color-green-bg: rgb(111 169 125 / 18%);
|
||||||
|
--color-warn: #d4a653;
|
||||||
|
--color-warn-bg: rgb(212 166 83 / 18%);
|
||||||
|
--color-pink: #d18272;
|
||||||
|
--color-pink-bg: rgb(209 130 114 / 20%);
|
||||||
|
|
||||||
|
--background: var(--color-bg);
|
||||||
|
--foreground: var(--color-ink);
|
||||||
|
--card: var(--color-surface);
|
||||||
|
--card-foreground: var(--color-ink);
|
||||||
|
--popover: var(--color-surface);
|
||||||
|
--popover-foreground: var(--color-ink);
|
||||||
|
--primary: var(--color-navy);
|
||||||
|
--primary-foreground: var(--color-on-accent);
|
||||||
|
--secondary: var(--color-surface-2);
|
||||||
|
--secondary-foreground: var(--color-ink);
|
||||||
|
--muted: var(--color-surface-2);
|
||||||
|
--muted-foreground: var(--color-mute);
|
||||||
|
--accent: var(--color-accent);
|
||||||
|
--accent-foreground: var(--color-on-accent);
|
||||||
|
--destructive: var(--color-pink);
|
||||||
|
--destructive-foreground: var(--color-on-accent);
|
||||||
|
--border: var(--color-line);
|
||||||
|
--input: var(--color-line);
|
||||||
|
--ring: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tailwind v4 @theme inline — экспонирует CSS vars как utility classes
|
||||||
|
* (`bg-bg`, `text-ink`, `border-line`, `bg-accent` и т.д.). */
|
||||||
|
@theme inline {
|
||||||
|
--color-bg: var(--color-bg);
|
||||||
|
--color-surface: var(--color-surface);
|
||||||
|
--color-surface-2: var(--color-surface-2);
|
||||||
|
--color-ink: var(--color-ink);
|
||||||
|
--color-ink-2: var(--color-ink-2);
|
||||||
|
--color-mute: var(--color-mute);
|
||||||
|
--color-line: var(--color-line);
|
||||||
|
--color-line-2: var(--color-line-2);
|
||||||
|
--color-navy: var(--color-navy);
|
||||||
|
--color-accent: var(--color-accent);
|
||||||
|
--color-accent-bg: var(--color-accent-bg);
|
||||||
|
--color-on-accent: var(--color-on-accent);
|
||||||
|
--color-green: var(--color-green);
|
||||||
|
--color-green-bg: var(--color-green-bg);
|
||||||
|
--color-warn: var(--color-warn);
|
||||||
|
--color-warn-bg: var(--color-warn-bg);
|
||||||
|
--color-pink: var(--color-pink);
|
||||||
|
--color-pink-bg: var(--color-pink-bg);
|
||||||
|
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
|
||||||
|
--font-display: 'Tektur', 'Inter', sans-serif;
|
||||||
|
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 6px;
|
||||||
|
--radius-lg: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-feature-settings: 'ss01', 'cv11', 'cv02', 'cv03', 'cv04';
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tektur uppercase caption helper — handoff .cap class */
|
||||||
|
.cap {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.10em;
|
||||||
|
color: var(--color-mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Legacy fallback: старые компоненты используют nstart token names
|
||||||
|
* (--color-carbon, --color-ultramarain etc). Маппим их на новые pending
|
||||||
|
* полная миграция. === */
|
||||||
|
:root,
|
||||||
|
[data-theme='dark'] {
|
||||||
|
--color-carbon: var(--color-ink);
|
||||||
|
--color-ultramarain: var(--color-accent);
|
||||||
|
--color-orbit: var(--color-warn);
|
||||||
|
--color-regolith: var(--color-line);
|
||||||
|
--color-asteroid: var(--color-mute);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alert / inline message — статусная карточка с border-left accent.
|
||||||
|
* shadcn-style API: children может быть [Title, Description] или произвольный.
|
||||||
|
*/
|
||||||
|
const alertVariants = cva(
|
||||||
|
'relative w-full rounded-md border-l-4 px-4 py-3 text-sm [&>svg]:text-ink-2 [&>svg]:size-4 [&>svg]:absolute [&>svg]:left-3 [&>svg]:top-3 [&>svg+*]:pl-6',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
info: 'border-l-accent bg-accent-bg text-ink',
|
||||||
|
success: 'border-l-green bg-green-bg text-ink',
|
||||||
|
warning: 'border-l-warn bg-warn-bg text-ink',
|
||||||
|
danger: 'border-l-pink bg-pink-bg text-ink',
|
||||||
|
neutral: 'border-l-line bg-surface-2 text-ink',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'info' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type AlertProps = React.HTMLAttributes<HTMLDivElement> &
|
||||||
|
VariantProps<typeof alertVariants> & {
|
||||||
|
title?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
|
||||||
|
function Alert({ className, variant, title, children, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props}>
|
||||||
|
{title && <div className="font-medium mb-0.5">{title}</div>}
|
||||||
|
<div className="text-ink-2 text-[13px] leading-snug">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-2xs font-medium font-mono uppercase tracking-wide whitespace-nowrap',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-surface-2 text-ink-2 border border-line',
|
||||||
|
accent: 'bg-accent-bg text-accent',
|
||||||
|
success: 'bg-green-bg text-green',
|
||||||
|
warning: 'bg-warn-bg text-warn',
|
||||||
|
danger: 'bg-pink-bg text-pink',
|
||||||
|
outline: 'border border-line text-ink-2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type BadgeProps = React.HTMLAttributes<HTMLSpanElement> &
|
||||||
|
VariantProps<typeof badgeVariants>
|
||||||
|
|
||||||
|
export function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <span className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button — shadcn-стиль, адаптирован под наш token palette.
|
||||||
|
*
|
||||||
|
* Variants per design handoff:
|
||||||
|
* - primary — navy bg + on-accent text (deep coffee → cream).
|
||||||
|
* - secondary — surface bg + ink text + line border (default outlined).
|
||||||
|
* - ghost — transparent + hover:surface-2.
|
||||||
|
* - danger — pink bg + on-accent text (destructive actions).
|
||||||
|
* - link — accent text + underline-offset, no bg.
|
||||||
|
*
|
||||||
|
* Sizes: sm (28px), md (36px default), lg (40px), icon-sm (24px square),
|
||||||
|
* icon (32px square).
|
||||||
|
*
|
||||||
|
* Use {@code asChild} (Radix Slot pattern) для рендера кастомного root
|
||||||
|
* элемента — например {@code <Button asChild><Link to="/">…</Link></Button>}.
|
||||||
|
*/
|
||||||
|
const buttonVariants = cva(
|
||||||
|
[
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap',
|
||||||
|
'rounded-md text-sm font-medium transition-colors',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
'[&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||||
|
].join(' '),
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary:
|
||||||
|
'bg-navy text-on-accent hover:opacity-90 active:opacity-95',
|
||||||
|
secondary:
|
||||||
|
'border border-line bg-surface text-ink hover:bg-surface-2',
|
||||||
|
ghost:
|
||||||
|
'text-ink hover:bg-surface-2',
|
||||||
|
danger:
|
||||||
|
'bg-pink text-on-accent hover:opacity-90 active:opacity-95',
|
||||||
|
link:
|
||||||
|
'text-accent underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: 'h-7 px-2.5 text-xs',
|
||||||
|
md: 'h-9 px-3.5',
|
||||||
|
lg: 'h-10 px-4',
|
||||||
|
'icon-sm': 'h-6 w-6',
|
||||||
|
icon: 'h-8 w-8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'primary',
|
||||||
|
size: 'md',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
leftIcon?: React.ReactNode
|
||||||
|
rightIcon?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
function Button(
|
||||||
|
{ className, variant, size, asChild, leftIcon, rightIcon, children, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{leftIcon}
|
||||||
|
{children}
|
||||||
|
{rightIcon}
|
||||||
|
</Comp>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export { buttonVariants }
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cap — Tektur uppercase caption (handoff design system primitive).
|
||||||
|
* 10.5px / 600 / letter-spacing 0.10em / mute color.
|
||||||
|
*
|
||||||
|
* Use для section labels, table headers (cap variant), toolbar group titles.
|
||||||
|
*/
|
||||||
|
export type CapProps = React.HTMLAttributes<HTMLSpanElement>
|
||||||
|
|
||||||
|
export function Cap({ className, ...props }: CapProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'font-display text-[10.5px] font-semibold uppercase tracking-[0.10em] text-mute leading-none',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card / Panel — surface block с radius-card, border-line. Замена для
|
||||||
|
* {@code @nstart/ui} Panel. Используем сложно через {@link CardHeader},
|
||||||
|
* {@link CardContent}, {@link CardFooter}.
|
||||||
|
*/
|
||||||
|
export const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
function Card({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-line bg-surface text-ink shadow-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
function CardHeader({ className, ...props }, ref) {
|
||||||
|
return <div ref={ref} className={cn('flex flex-col gap-1.5 p-4', className)} {...props} />
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
function CardTitle({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-base font-semibold leading-tight text-ink', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(function CardDescription({ className, ...props }, ref) {
|
||||||
|
return <p ref={ref} className={cn('text-sm text-ink-2', className)} {...props} />
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
function CardContent({ className, ...props }, ref) {
|
||||||
|
return <div ref={ref} className={cn('p-4 pt-0', className)} {...props} />
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
function CardFooter({ className, ...props }, ref) {
|
||||||
|
return <div ref={ref} className={cn('flex items-center p-4 pt-0', className)} {...props} />
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||||
|
import { Check, Minus } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checkbox — Radix primitive с indeterminate support через {@code checked='indeterminate'}.
|
||||||
|
* Решает старый bug @nstart/ui где indeterminate не sync'ался через ref.
|
||||||
|
*/
|
||||||
|
export const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(function Checkbox({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'peer h-4 w-4 shrink-0 rounded-[3px] border border-line bg-surface',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'data-[state=checked]:bg-accent data-[state=checked]:border-accent data-[state=checked]:text-on-accent',
|
||||||
|
'data-[state=indeterminate]:bg-accent data-[state=indeterminate]:border-accent data-[state=indeterminate]:text-on-accent',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
|
||||||
|
{props.checked === 'indeterminate' ? (
|
||||||
|
<Minus size={11} strokeWidth={3} />
|
||||||
|
) : (
|
||||||
|
<Check size={11} strokeWidth={3} />
|
||||||
|
)}
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Radix Dialog — modal в shadcn-стиле. Заменяет {@code @nstart/ui} Modal.
|
||||||
|
*
|
||||||
|
* Использование:
|
||||||
|
* <pre>
|
||||||
|
* <Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
* <DialogContent size="md">
|
||||||
|
* <DialogHeader>
|
||||||
|
* <DialogTitle>Создать запись</DialogTitle>
|
||||||
|
* <DialogDescription>Описание формы</DialogDescription>
|
||||||
|
* </DialogHeader>
|
||||||
|
* <div className="…body…" />
|
||||||
|
* <DialogFooter>
|
||||||
|
* <Button variant="secondary" onClick={() => setOpen(false)}>Отмена</Button>
|
||||||
|
* <Button>Сохранить</Button>
|
||||||
|
* </DialogFooter>
|
||||||
|
* </DialogContent>
|
||||||
|
* </Dialog>
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* Default size — 560px (handoff Create-dict). Variants: sm 440px, md 560px,
|
||||||
|
* lg 720px, xl 880px, full (mobile fullscreen).
|
||||||
|
*/
|
||||||
|
export const Dialog = DialogPrimitive.Root
|
||||||
|
export const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
export const DialogPortal = DialogPrimitive.Portal
|
||||||
|
export const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
export const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(function DialogOverlay({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/40 backdrop-blur-[1px]',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const SIZE_CLASSES: Record<string, string> = {
|
||||||
|
sm: 'max-w-[440px]',
|
||||||
|
md: 'max-w-[560px]',
|
||||||
|
lg: 'max-w-[720px]',
|
||||||
|
xl: 'max-w-[880px]',
|
||||||
|
full: 'max-w-full max-h-full sm:max-w-[920px]',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
|
size?: keyof typeof SIZE_CLASSES
|
||||||
|
hideClose?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
DialogContentProps
|
||||||
|
>(function DialogContent({ className, children, size = 'md', hideClose, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-1/2 top-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 border border-line bg-surface p-6 shadow-2xl duration-200 sm:rounded-lg',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
SIZE_CLASSES[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{!hideClose && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
aria-label="Закрыть"
|
||||||
|
className="absolute right-3 top-3 rounded-sm p-1 text-mute hover:text-ink hover:bg-surface-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors"
|
||||||
|
>
|
||||||
|
<X size={16} strokeWidth={2.25} />
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('flex flex-col space-y-1.5 pr-8', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end pt-2 border-t border-line-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(function DialogTitle({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-base font-semibold text-ink leading-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(function DialogDescription({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-ink-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||||
|
import { Check, ChevronRight, Circle } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
export const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
export const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
export const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
export const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(function DropdownMenuSubTrigger({ className, inset, children, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-surface-2 data-[state=open]:bg-surface-2',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(function DropdownMenuSubContent({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-line bg-surface p-1 text-ink shadow-lg',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(function DropdownMenuContent({ className, sideOffset = 4, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-line bg-surface p-1 text-ink shadow-lg',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
||||||
|
>(function DropdownMenuItem({ className, inset, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-surface-2 focus:text-ink',
|
||||||
|
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(function DropdownMenuCheckboxItem({ className, children, checked, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-surface-2',
|
||||||
|
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(function DropdownMenuRadioItem({ className, children, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-surface-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
|
||||||
|
>(function DropdownMenuLabel({ className, inset, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('px-2 py-1.5 text-2xs font-semibold uppercase tracking-wider text-mute', inset && 'pl-8', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(function DropdownMenuSeparator({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-line', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type EmptyStateProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
icon?: React.ReactNode
|
||||||
|
title: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
action?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
className,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
...props
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center text-center py-12 px-6 gap-3',
|
||||||
|
'rounded-lg border border-dashed border-line bg-surface-2/40',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon && <div className="text-mute [&>svg]:size-8">{icon}</div>}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-base font-semibold text-ink">{title}</div>
|
||||||
|
{description && <div className="text-sm text-ink-2 max-w-md">{description}</div>}
|
||||||
|
</div>
|
||||||
|
{action && <div className="mt-2">{action}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FormActions — фиксированная row кнопок [Cancel | Submit] в footer форм.
|
||||||
|
* Reverse на mobile — primary action на bottom (proper UX).
|
||||||
|
*/
|
||||||
|
export type FormActionsProps = React.HTMLAttributes<HTMLDivElement>
|
||||||
|
|
||||||
|
export function FormActions({ className, children, ...props }: FormActionsProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end pt-3 border-t border-line-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IconButton — square button с иконкой. Заменяет {@code @nstart/ui} IconButton.
|
||||||
|
* Variants: default (ghost), accent (primary fill), danger (destructive).
|
||||||
|
*/
|
||||||
|
const iconButtonVariants = cva(
|
||||||
|
[
|
||||||
|
'inline-flex items-center justify-center rounded-md transition-colors',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
].join(' '),
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'text-ink-2 hover:text-ink hover:bg-surface-2',
|
||||||
|
accent: 'bg-accent text-on-accent hover:opacity-90',
|
||||||
|
danger: 'text-pink hover:bg-pink-bg',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: 'h-6 w-6 [&_svg]:size-3.5',
|
||||||
|
md: 'h-8 w-8 [&_svg]:size-4',
|
||||||
|
lg: 'h-10 w-10 [&_svg]:size-5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default', size: 'md' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type IconButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
|
||||||
|
VariantProps<typeof iconButtonVariants> & {
|
||||||
|
/** Required для accessibility — иконки без текста должны иметь label. */
|
||||||
|
label: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||||
|
function IconButton({ className, variant, size, label, icon, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
className={cn(iconButtonVariants({ variant, size }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text input — single-line. Использует наши tokens, focus-ring через accent.
|
||||||
|
*
|
||||||
|
* Wrap'итесь в {@code <Label>} выше для accessibility. Для search input'а
|
||||||
|
* с иконкой см. {@link SearchInput}.
|
||||||
|
*/
|
||||||
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||||
|
|
||||||
|
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
function Input({ className, type = 'text', ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-line bg-surface px-3 py-1 text-sm text-ink',
|
||||||
|
'placeholder:text-mute',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-bg',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(function Label({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-2xs font-medium font-display uppercase tracking-[0.10em] text-mute leading-none',
|
||||||
|
'peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LoadingBlock — central spinner с подписью. Заменяет {@code @nstart/ui}
|
||||||
|
* LoadingBlock. Для inline placeholder используйте Skeleton.
|
||||||
|
*/
|
||||||
|
export type LoadingBlockProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
label?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingBlock({ className, label, ...props }: LoadingBlockProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-busy="true"
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center gap-2 py-12 text-mute',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Loader2 className="size-5 animate-spin text-accent" />
|
||||||
|
{label && <span className="text-sm">{label}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PageHeader — title + optional description + actions slot. Use в верху route'а.
|
||||||
|
*/
|
||||||
|
export type PageHeaderProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
title: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
actions?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col gap-1 mb-4', className)} {...props}>
|
||||||
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-lg font-semibold text-ink leading-tight truncate">{title}</h1>
|
||||||
|
{description && <p className="text-sm text-ink-2 mt-1 max-w-2xl">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="shrink-0 flex items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export const Popover = PopoverPrimitive.Root
|
||||||
|
export const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
export const PopoverAnchor = PopoverPrimitive.Anchor
|
||||||
|
|
||||||
|
export const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(function PopoverContent({ className, align = 'center', sideOffset = 6, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 w-72 rounded-md border border-line bg-surface p-3 text-sm text-ink shadow-lg outline-none',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScopeDot — маленький цветной квадратик-маркер для PUBLIC/INTERNAL/RESTRICTED.
|
||||||
|
* 8×8 (size=md) или 6×6 (size=sm).
|
||||||
|
*/
|
||||||
|
export type DataScope = 'PUBLIC' | 'INTERNAL' | 'RESTRICTED'
|
||||||
|
|
||||||
|
const SCOPE_COLORS: Record<DataScope, string> = {
|
||||||
|
PUBLIC: 'bg-accent', // terracotta/orange
|
||||||
|
INTERNAL: 'bg-warn', // amber
|
||||||
|
RESTRICTED: 'bg-pink', // brick red
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCOPE_BG: Record<DataScope, string> = {
|
||||||
|
PUBLIC: 'bg-accent-bg text-accent',
|
||||||
|
INTERNAL: 'bg-warn-bg text-warn',
|
||||||
|
RESTRICTED: 'bg-pink-bg text-pink',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScopeDot({
|
||||||
|
scope,
|
||||||
|
size = 'md',
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
scope: DataScope
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
'inline-block rounded-sm shrink-0',
|
||||||
|
size === 'md' ? 'size-2' : 'size-1.5',
|
||||||
|
SCOPE_COLORS[scope],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCOPE_LABELS: Record<DataScope, string> = {
|
||||||
|
PUBLIC: 'Публичный',
|
||||||
|
INTERNAL: 'Внутренний',
|
||||||
|
RESTRICTED: 'Ограниченный',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCOPE_SHORT: Record<DataScope, string> = {
|
||||||
|
PUBLIC: 'PUB',
|
||||||
|
INTERNAL: 'INT',
|
||||||
|
RESTRICTED: 'RES',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScopeBadge({
|
||||||
|
scope,
|
||||||
|
short,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
scope: DataScope
|
||||||
|
short?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-sm px-1.5 py-0.5 text-2xs font-mono font-medium',
|
||||||
|
SCOPE_BG[scope],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ScopeDot scope={scope} size="sm" className="!bg-current" />
|
||||||
|
{short ? SCOPE_SHORT[scope] : SCOPE_LABELS[scope]}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 3px вертикальная полоса для левого края card / row — quick visual scope hint. */
|
||||||
|
export function ScopeStrip({ scope, className }: { scope: DataScope; className?: string }) {
|
||||||
|
return <span className={cn('block w-[3px] shrink-0', SCOPE_COLORS[scope], className)} aria-hidden />
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Search, X } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Input } from './input'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SearchInput — Input с magnifying-glass иконкой слева и optional clear button
|
||||||
|
* справа когда есть value. Заменяет {@code @nstart/ui} SearchInput.
|
||||||
|
*/
|
||||||
|
export type SearchInputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> & {
|
||||||
|
onClear?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchInput = React.forwardRef<HTMLInputElement, SearchInputProps>(
|
||||||
|
function SearchInput({ className, value, onClear, ...props }, ref) {
|
||||||
|
const hasValue = value !== undefined && value !== ''
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Search
|
||||||
|
size={14}
|
||||||
|
strokeWidth={2}
|
||||||
|
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-mute"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
type="search"
|
||||||
|
value={value}
|
||||||
|
className={cn('pl-8', hasValue && onClear && 'pr-8', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{hasValue && onClear && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Очистить"
|
||||||
|
onClick={onClear}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-mute hover:text-ink rounded-sm p-0.5 hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<X size={13} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(function Separator({ className, orientation = 'horizontal', decorative = true, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-line',
|
||||||
|
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sheet — slide-over drawer (right/left/top/bottom). Заменяет
|
||||||
|
* {@code @nstart/ui} Drawer. Backed by Radix Dialog.
|
||||||
|
*
|
||||||
|
* Записи: {@code <Sheet open onOpenChange><SheetContent side="right" size="md">…}.
|
||||||
|
* Default — right slide-over 520px (handoff RecordDrawer narrow). Wide — 880px.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const Sheet = DialogPrimitive.Root
|
||||||
|
export const SheetTrigger = DialogPrimitive.Trigger
|
||||||
|
export const SheetClose = DialogPrimitive.Close
|
||||||
|
export const SheetPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const sheetOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(function SheetOverlay({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/40',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const sheetVariants = cva(
|
||||||
|
[
|
||||||
|
'fixed z-50 gap-4 bg-surface p-6 shadow-2xl border-line transition ease-in-out',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:duration-200 data-[state=open]:duration-300',
|
||||||
|
].join(' '),
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||||
|
bottom: 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||||
|
left: 'inset-y-0 left-0 h-full border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left',
|
||||||
|
right: 'inset-y-0 right-0 h-full border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: 'sm:max-w-sm w-full',
|
||||||
|
md: 'sm:max-w-[520px] w-full',
|
||||||
|
lg: 'sm:max-w-[720px] w-full',
|
||||||
|
xl: 'sm:max-w-[880px] w-full',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { side: 'right', size: 'md' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type SheetContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
|
||||||
|
VariantProps<typeof sheetVariants> & { hideClose?: boolean }
|
||||||
|
|
||||||
|
export const SheetContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
SheetContentProps
|
||||||
|
>(function SheetContent({ className, children, side, size, hideClose, ...props }, ref) {
|
||||||
|
const Overlay = sheetOverlay
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<Overlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(sheetVariants({ side, size }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{!hideClose && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
aria-label="Закрыть"
|
||||||
|
className="absolute right-3 top-3 rounded-sm p-1 text-mute hover:text-ink hover:bg-surface-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors"
|
||||||
|
>
|
||||||
|
<X size={16} strokeWidth={2.25} />
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function SheetHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('flex flex-col space-y-1 pr-8', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SheetFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'mt-auto flex flex-col-reverse gap-2 sm:flex-row sm:justify-end pt-3 border-t border-line-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SheetTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(function SheetTitle({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-base font-semibold text-ink', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SheetDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(function SheetDescription({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-ink-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton — animated placeholder. Заменяет LoadingBlock в большинстве кейсов
|
||||||
|
* (block-level loading indicator). Высоту/ширину передавайте через className.
|
||||||
|
*/
|
||||||
|
export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-busy="true"
|
||||||
|
className={cn('animate-pulse rounded-md bg-surface-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as SwitchPrimitives from '@radix-ui/react-switch'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(function Switch({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'data-[state=checked]:bg-accent data-[state=unchecked]:bg-line',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none block h-4 w-4 rounded-full bg-on-accent shadow-md ring-0 transition-transform',
|
||||||
|
'data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Radix Tabs — handoff style: bottom-border accent на active tab, weight 600.
|
||||||
|
* Use {@code <Tabs value onValueChange><TabsList>…</TabsList><TabsContent />}.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
export const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(function TabsList({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-10 items-center gap-1 border-b border-line w-full',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(function TabsTrigger({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap px-3.5 h-10 text-sm text-ink-2 -mb-px',
|
||||||
|
'border-b-2 border-transparent transition-all hover:text-ink',
|
||||||
|
'focus-visible:outline-none focus-visible:text-accent',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
'data-[state=active]:border-accent data-[state=active]:text-ink data-[state=active]:font-semibold',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(function TabsContent({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'mt-3 focus-visible:outline-none',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
export const Tooltip = TooltipPrimitive.Root
|
||||||
|
export const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
export const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(function TooltipContent({ className, sideOffset = 4, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 overflow-hidden rounded-sm bg-ink text-on-accent px-2 py-1 text-2xs font-medium shadow',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Barrel re-export для shadcn-style UI primitives.
|
||||||
|
*
|
||||||
|
* <p>Это единая точка входа для UI компонентов в admin-ui. Цель — постепенно
|
||||||
|
* переключить все импорты с {@code @nstart/ui} на {@code @/ui}, и затем
|
||||||
|
* удалить @nstart/ui из package.json.
|
||||||
|
*
|
||||||
|
* <p>Phase 2 миграции (handoff plan): новые компоненты — Radix primitives +
|
||||||
|
* Tailwind v4 tokens. Phase 3 — каждый touched файл переносится с {@code
|
||||||
|
* @nstart/ui} на {@code @/ui} имена. Phase 4 — pkg removed.
|
||||||
|
*
|
||||||
|
* <p>Все компоненты используют наши design tokens (--color-ink, --color-accent
|
||||||
|
* etc.) через @theme inline в styles.css.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Buttons + actions
|
||||||
|
export { Button, buttonVariants, type ButtonProps } from './components/button'
|
||||||
|
export { IconButton, type IconButtonProps } from './components/icon-button'
|
||||||
|
|
||||||
|
// Inputs
|
||||||
|
export { Input, type InputProps } from './components/input'
|
||||||
|
export { Label } from './components/label'
|
||||||
|
export { SearchInput, type SearchInputProps } from './components/search-input'
|
||||||
|
|
||||||
|
// Form layout
|
||||||
|
export { FormActions, type FormActionsProps } from './components/form-actions'
|
||||||
|
|
||||||
|
// Status / feedback
|
||||||
|
export { Badge, type BadgeProps } from './components/badge'
|
||||||
|
export { Alert, type AlertProps } from './components/alert'
|
||||||
|
export { Skeleton } from './components/skeleton'
|
||||||
|
export { LoadingBlock, type LoadingBlockProps } from './components/loading-block'
|
||||||
|
export { EmptyState, type EmptyStateProps } from './components/empty-state'
|
||||||
|
|
||||||
|
// Surfaces
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
} from './components/card'
|
||||||
|
export { Separator } from './components/separator'
|
||||||
|
export { PageHeader, type PageHeaderProps } from './components/page-header'
|
||||||
|
|
||||||
|
// Overlays
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogPortal,
|
||||||
|
DialogClose,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
type DialogContentProps,
|
||||||
|
} from './components/dialog'
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetPortal,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
type SheetContentProps,
|
||||||
|
} from './components/sheet'
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverTrigger,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverContent,
|
||||||
|
} from './components/popover'
|
||||||
|
export {
|
||||||
|
Tooltip,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
TooltipContent,
|
||||||
|
} from './components/tooltip'
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent } from './components/tabs'
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
} from './components/dropdown-menu'
|
||||||
|
|
||||||
|
// Form controls
|
||||||
|
export { Checkbox } from './components/checkbox'
|
||||||
|
export { Switch } from './components/switch'
|
||||||
|
|
||||||
|
// Domain-specific atoms (handoff design system)
|
||||||
|
export { Cap, type CapProps } from './components/cap'
|
||||||
|
export {
|
||||||
|
ScopeDot,
|
||||||
|
ScopeBadge,
|
||||||
|
ScopeStrip,
|
||||||
|
type DataScope,
|
||||||
|
} from './components/scope'
|
||||||
|
|
||||||
|
// === @nstart/ui compatibility aliases ===
|
||||||
|
// Чтобы упростить миграцию touched файлов: перевод import только-source,
|
||||||
|
// not всего code. Каждый alias документирован, как соотносится с новым API.
|
||||||
|
|
||||||
|
export { Card as Panel } from './components/card'
|
||||||
|
export { Dialog as Modal } from './components/dialog'
|
||||||
|
export { Sheet as Drawer } from './components/sheet'
|
||||||
|
export { Input as TextInput } from './components/input'
|
||||||
Reference in New Issue
Block a user