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:
@@ -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>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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