Merge branch 'feat/v1.1.7-shadcn-primitives' into 'main'

feat(admin-ui): shadcn primitives + @/ui barrel (Stage 2.1)

See merge request 2-6/2-6-4/terravault/ordinis!30
This commit is contained in:
Александр Зимин
2026-05-10 21:47:24 +00:00
26 changed files with 1378 additions and 2 deletions
+17 -2
View File
@@ -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,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>
)
})
+125
View File
@@ -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'