feat(admin-ui): codemod @nstart/ui → @/ui (Stage 2.2)

This commit is contained in:
Александр Зимин
2026-05-10 22:17:06 +00:00
parent 0710efb8ce
commit b7d2fbd7ce
34 changed files with 398 additions and 92 deletions
+12 -3
View File
@@ -15,6 +15,8 @@ const alertVariants = cva(
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',
// Alias для nstart-compat: error === danger визуально.
error: 'border-l-pink bg-pink-bg text-ink',
neutral: 'border-l-line bg-surface-2 text-ink',
},
},
@@ -25,14 +27,21 @@ const alertVariants = cva(
export type AlertProps = React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof alertVariants> & {
title?: React.ReactNode
/** Right-aligned slot для action button(s) inside alert. */
action?: React.ReactNode
}
export const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
function Alert({ className, variant, title, children, ...props }, ref) {
function Alert({ className, variant, title, action, 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 className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
{title && <div className="font-medium mb-0.5">{title}</div>}
<div className="text-ink-2 text-[13px] leading-snug">{children}</div>
</div>
{action && <div className="shrink-0 flex items-center gap-2">{action}</div>}
</div>
</div>
)
},
@@ -12,6 +12,13 @@ const badgeVariants = cva(
success: 'bg-green-bg text-green',
warning: 'bg-warn-bg text-warn',
danger: 'bg-pink-bg text-pink',
// Aliases для nstart-compat: error == danger, neutral == default,
// info == accent.
error: 'bg-pink-bg text-pink',
neutral: 'bg-surface-2 text-ink-2 border border-line',
info: 'bg-accent-bg text-accent',
// Alias для nstart-compat — старый Badge variant='primary' эквивалентен accent.
primary: 'bg-accent text-on-accent',
outline: 'border border-line text-ink-2',
},
},
+25 -2
View File
@@ -61,21 +61,44 @@ export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
asChild?: boolean
leftIcon?: React.ReactNode
rightIcon?: React.ReactNode
/** Loading state — disables button + replaces leftIcon с inline spinner. */
loading?: boolean
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{ className, variant, size, asChild, leftIcon, rightIcon, children, ...props },
{
className,
variant,
size,
asChild,
leftIcon,
rightIcon,
loading,
disabled,
children,
...props
},
ref,
) {
const Comp = asChild ? Slot : 'button'
const renderedLeftIcon = loading ? (
<svg className="animate-spin size-3.5" viewBox="0 0 24 24" aria-hidden>
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeOpacity="0.25" strokeWidth="4" />
<path d="M4 12a8 8 0 0 1 8-8" fill="none" stroke="currentColor" strokeWidth="4" strokeLinecap="round" />
</svg>
) : (
leftIcon
)
return (
<Comp
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...props}
>
{leftIcon}
{renderedLeftIcon}
{children}
{rightIcon}
</Comp>
@@ -0,0 +1,68 @@
import * as React from 'react'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from './sheet'
import { cn } from '@/lib/utils'
/**
* Drawer — adapter с @nstart/ui-style API над Radix Sheet (slide-over).
* Используется существующим кодом для side panels (record drawer, history,
* review preview).
*
* Props mapping:
* isOpen → Sheet.open
* onClose → Sheet.onOpenChange
* title → SheetTitle
* side → SheetContent.side (right default)
* widthClassName → custom className на SheetContent (override defaults)
* size → SheetContent.size shorthand (sm/md/lg/xl)
*/
export type DrawerProps = {
isOpen: boolean
onClose: () => void
title?: React.ReactNode
description?: React.ReactNode
side?: 'left' | 'right' | 'top' | 'bottom'
size?: 'sm' | 'md' | 'lg' | 'xl'
widthClassName?: string
children?: React.ReactNode
}
export function Drawer({
isOpen,
onClose,
title,
description,
side = 'right',
size,
widthClassName,
children,
}: DrawerProps) {
return (
<Sheet
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose()
}}
>
<SheetContent
side={side}
size={size}
className={cn('overflow-y-auto', widthClassName)}
>
{(title || description) && (
<SheetHeader>
{title && <SheetTitle>{title}</SheetTitle>}
{description && <SheetDescription>{description}</SheetDescription>}
</SheetHeader>
)}
{children}
</SheetContent>
</Sheet>
)
}
+49 -5
View File
@@ -4,27 +4,71 @@ import { cn } from '@/lib/utils'
/**
* Text input — single-line. Использует наши tokens, focus-ring через accent.
*
* Wrap'итесь в {@code <Label>} выше для accessibility. Для search input'а
* с иконкой см. {@link SearchInput}.
* Поддерживает 2 режима:
* - "bare" (default) — просто <input>, для inline использования в кастомных
* layout'ах (см. SearchInput, FormGrid с FieldLabel separately).
* - "field" — когда передан label / hint / error, рендерится FormField
* обёртка с label сверху + hint/error снизу. Match'ит @nstart/ui TextInput API.
*/
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
/** Если задан — рендерится field-обёртка с label выше. */
label?: React.ReactNode
/** Optional helper text under input. */
hint?: React.ReactNode
/** Error message — overrides hint визуально, accent в pink. */
error?: React.ReactNode
/** Wrapper className когда rendering field-mode. */
containerClassName?: string
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
function Input({ className, type = 'text', ...props }, ref) {
return (
function Input(
{ className, type = 'text', label, hint, error, required, containerClassName, id, ...props },
ref,
) {
const autoId = React.useId()
const inputId = id ?? autoId
const inputEl = (
<input
id={inputId}
ref={ref}
type={type}
required={required}
aria-required={required || undefined}
aria-invalid={Boolean(error) || undefined}
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',
error && 'border-pink focus-visible:ring-pink',
className,
)}
{...props}
/>
)
if (!label && !hint && !error) return inputEl
return (
<div className={cn('flex flex-col gap-1', containerClassName)}>
{label && (
<label
htmlFor={inputId}
className="text-2xs font-medium font-display uppercase tracking-[0.10em] text-mute leading-none"
>
{label}
{required && <span className="text-pink ml-0.5">*</span>}
</label>
)}
{inputEl}
{error ? (
<span className="text-xs text-pink">{error}</span>
) : hint ? (
<span className="text-xs text-mute">{hint}</span>
) : null}
</div>
)
},
)
@@ -4,23 +4,37 @@ import { cn } from '@/lib/utils'
/**
* LoadingBlock — central spinner с подписью. Заменяет {@code @nstart/ui}
* LoadingBlock. Для inline placeholder используйте Skeleton.
*
* Size variants:
* - sm: small inline spinner (py-4)
* - md: default centered (py-12)
* - lg: prominent (py-20)
*/
export type LoadingBlockProps = React.HTMLAttributes<HTMLDivElement> & {
label?: React.ReactNode
size?: 'sm' | 'md' | 'lg'
}
export function LoadingBlock({ className, label, ...props }: LoadingBlockProps) {
const SIZES = {
sm: { wrapper: 'py-4', icon: 'size-3' },
md: { wrapper: 'py-12', icon: 'size-5' },
lg: { wrapper: 'py-20', icon: 'size-8' },
} as const
export function LoadingBlock({ className, label, size = 'md', ...props }: LoadingBlockProps) {
const s = SIZES[size]
return (
<div
role="status"
aria-busy="true"
className={cn(
'flex flex-col items-center justify-center gap-2 py-12 text-mute',
'flex flex-col items-center justify-center gap-2 text-mute',
s.wrapper,
className,
)}
{...props}
>
<Loader2 className="size-5 animate-spin text-accent" />
<Loader2 className={cn('animate-spin text-accent', s.icon)} />
{label && <span className="text-sm">{label}</span>}
</div>
)
@@ -0,0 +1,78 @@
import * as React from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from './dialog'
/**
* Modal — adapter с @nstart/ui-style API над Radix Dialog. Используется
* существующим кодом (см. dictionaries.$name.tsx, webhooks, reviews etc.) —
* упрощает codemod без рефактора каждого callsite.
*
* Props mapping:
* isOpen → Dialog.open
* onClose → Dialog.onOpenChange (false case)
* title → DialogTitle children
* description → DialogDescription
* maxWidth → DialogContent size (mapped: max-w-md→sm, max-w-lg→md, max-w-2xl→lg, max-w-4xl→xl)
* children → DialogContent body
*
* Новый код может использовать Dialog напрямую — он остаётся exported.
*/
const SIZE_MAP: Record<string, 'sm' | 'md' | 'lg' | 'xl' | 'full'> = {
'max-w-sm': 'sm',
'max-w-md': 'sm',
'max-w-lg': 'md',
'max-w-xl': 'md',
'max-w-2xl': 'lg',
'max-w-3xl': 'lg',
'max-w-4xl': 'xl',
'max-w-5xl': 'xl',
'max-w-6xl': 'full',
}
export type ModalProps = {
isOpen: boolean
onClose: () => void
title?: React.ReactNode
description?: React.ReactNode
maxWidth?: string
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
children?: React.ReactNode
hideClose?: boolean
}
export function Modal({
isOpen,
onClose,
title,
description,
maxWidth,
size,
hideClose,
children,
}: ModalProps) {
const resolved = size ?? (maxWidth ? SIZE_MAP[maxWidth] : undefined) ?? 'md'
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose()
}}
>
<DialogContent size={resolved} hideClose={hideClose}>
{(title || description) && (
<DialogHeader>
{title && <DialogTitle>{title}</DialogTitle>}
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
)}
{children}
</DialogContent>
</Dialog>
)
}
@@ -3,11 +3,15 @@ import { cn } from '@/lib/utils'
/**
* PageHeader — title + optional description + actions slot. Use в верху route'а.
*
* breadcrumb prop рендерится тонкой строкой ВЫШЕ title (cap-style).
* Обычно JSX с <Link>ами через separator: "Справочники / Космические аппараты".
*/
export type PageHeaderProps = React.HTMLAttributes<HTMLDivElement> & {
title: React.ReactNode
description?: React.ReactNode
actions?: React.ReactNode
breadcrumb?: React.ReactNode
}
export function PageHeader({
@@ -15,11 +19,17 @@ export function PageHeader({
title,
description,
actions,
breadcrumb,
children,
...props
}: PageHeaderProps) {
return (
<div className={cn('flex flex-col gap-1 mb-4', className)} {...props}>
{breadcrumb && (
<div className="text-2xs font-display uppercase tracking-[0.10em] text-mute leading-tight mb-1">
{breadcrumb}
</div>
)}
<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>
@@ -0,0 +1,53 @@
import * as React from 'react'
import {
Tabs as RadixTabs,
TabsList as RadixTabsList,
TabsTrigger as RadixTabsTrigger,
} from './tabs'
/**
* TabItem-based single-component API над Radix Tabs. Замена @nstart/ui Tabs
* с теми же props ({@code items, value, onValueChange}).
*
* Использование (legacy):
* <Tabs items={[{id: 'a', label: 'A'}, {id: 'b', label: 'B'}]}
* value={active} onValueChange={setActive} />
*
* Для content panels используйте TabsContent с radix Tabs root напрямую —
* этот adapter рендерит только tab bar.
*
* Новый код может использовать `<Tabs.Root>...<TabsList><TabsTrigger value="a">A</...`
* directly из ui/components/tabs.tsx.
*/
export type TabItem = {
id: string
label: React.ReactNode
disabled?: boolean
/** Right-aligned content в tab (badge, indicator). */
trailing?: React.ReactNode
}
export type TabsSimpleProps = {
items: ReadonlyArray<TabItem>
value: string
onValueChange: (id: string) => void
className?: string
}
export function TabsSimple({ items, value, onValueChange, className }: TabsSimpleProps) {
return (
<RadixTabs value={value} onValueChange={onValueChange} className={className}>
<RadixTabsList>
{items.map((item) => (
<RadixTabsTrigger key={item.id} value={item.id} disabled={item.disabled}>
<span className="inline-flex items-center gap-1.5">
{item.label}
{item.trailing}
</span>
</RadixTabsTrigger>
))}
</RadixTabsList>
</RadixTabs>
)
}