feat(ui): drop @nstart/ui dependency (Stage 3.9)

Все font cascade bugs из последних MR были вызваны @nstart/ui pollution
(Tektur inheritance от Card parent'ов, Onest font в Button, font-primary
class overrides). Решили обрезать раньше plan'а.

What changed:

New primitives (8 файлов в src/ui/components/):
  - panel.tsx           — container с border/surface
  - field.tsx           — FieldLabel/Hint/Error typography helpers
  - text-input.tsx      — Input + label/hint/error composite
  - table.tsx           — Table/TableHeader/Body/Row/HeaderCell/Cell/Empty
  - date-picker.tsx     — native <input type=date> wrapper с label/hint/error
  - multi-select.tsx    — Radix Popover + inline checkboxes
  - single-select.tsx   — native <select> с label/hint/error chrome
  - language-switch.tsx — segmented control в Tektur uppercase

Compatibility shims (existing components extended):
  - checkbox.tsx        — + label/description/error/onChange (nstart-compat
                          synthetic event), wrap в <label> когда есть chrome
  - modal.tsx           — + panelClassName / bodyClassName props
  - form-actions.tsx    — + align prop (start/end/between)
  - tabs-simple.tsx     — TabItem.count alias for .trailing (nstart-compat)
  - table TableHead     — alias TableHeaderCell с align prop

Barrel rewrite (src/ui/index.ts):
  - Удалён 'export * from @nstart/ui'
  - Explicit exports для всех 45+ нужных символов
  - SelectOption / MultiSelectOption / LanguageOption теперь {id, label}
    с optional 'value' alias — nstart-compat без массового codemod call-sites

styles.css cleanup:
  - Removed @import @nstart/ui/styles/{fonts,theme}.css
  - Removed @source nstart/dist directive (Tailwind v4 scan)
  - Removed --text-sm: 13px override (был костыль для @nstart/ui dist)
  - Legacy color tokens (--color-carbon/ultramarain/orbit/regolith/asteroid)
    sсодержали fallback mappings — оставлены, но callsites больше не используют

Codemod: 9 файлов конвертированы legacy colors → handoff tokens
  carbon → ink, ultramarain → accent, orbit → warn, regolith → line, asteroid → mute

main.tsx:
  - NStartUiProvider → TooltipProvider (Radix)
  - DatePickerProvider убран (наш DatePicker native, no provider needed)

routes/dictionaries.$name.tsx:
  - Removed useRef-based <input>.indeterminate hack
  - Radix Checkbox принимает checked='indeterminate' declaratively

package.json: -@nstart/ui (was 0.1.3)

Bundle impact:
  before: vendor-nstart-ui chunk = 1228 KB
  after:  no nstart chunk, всё в vendor-misc (+~70 KB Radix wrappers)
  net savings: ~1.15 MB minified, ~440 KB gzip

Tests: 116 pass (1 fixed: tab role from 'button' → 'tab', Radix semantically correct).
TS strict: clean.
Browser-verified (preview): 7 utilities computed correctly, nstart bundle absent.
This commit is contained in:
Zimin A.N.
2026-05-11 15:17:37 +03:00
parent f0dbc20f62
commit 5007e0f4eb
27 changed files with 916 additions and 191 deletions
@@ -4,16 +4,52 @@ 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.
* Checkbox — Radix primitive с nstart-compat label / description / onChange API.
*
* <p>Поддерживает три формы:
* <ol>
* <li>Bare: {@code <Checkbox checked={c} onCheckedChange={setC} />} — Radix native.</li>
* <li>nstart-style: {@code <Checkbox label="..." checked={c} onChange={(e) =>
* setC(e.target.checked)} />} — wraps в &lt;label&gt; с описанием.</li>
* <li>Indeterminate: {@code checked="indeterminate"} (Radix tri-state).</li>
* </ol>
*
* <p>onChange синтезирует {@code ChangeEvent<HTMLInputElement>}-like shape с
* {@code target.checked} для существующего кода (был nstart compatibility).
*/
type RadixCheckboxProps = React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
export type CheckboxProps = Omit<RadixCheckboxProps, 'onChange'> & {
label?: React.ReactNode
description?: React.ReactNode
/** Error message shown под label (nstart-compat). */
error?: React.ReactNode
/** nstart-style change handler. Synthetic event с {@code target.checked}. */
onChange?: (e: { target: { checked: boolean } }) => void
}
export const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(function Checkbox({ className, ...props }, ref) {
return (
CheckboxProps
>(function Checkbox(
{ className, label, description, error, onChange, onCheckedChange, ...props },
ref,
) {
const autoId = React.useId()
const id = props.id ?? autoId
const handleCheckedChange = (next: boolean | 'indeterminate') => {
onCheckedChange?.(next)
if (onChange && typeof next === 'boolean') {
onChange({ target: { checked: next } })
}
}
const root = (
<CheckboxPrimitive.Root
ref={ref}
id={id}
onCheckedChange={handleCheckedChange}
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',
@@ -33,4 +69,17 @@ export const Checkbox = React.forwardRef<
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
if (!label && !description && !error) return root
return (
<label htmlFor={id} className="inline-flex items-start gap-2 cursor-pointer select-none">
{root}
<span className="flex flex-col gap-0.5 min-w-0">
{label && <span className="text-body text-ink">{label}</span>}
{description && <span className="text-cell text-mute">{description}</span>}
{error && <span className="text-cell text-pink" role="alert">{error}</span>}
</span>
</label>
)
})
@@ -0,0 +1,95 @@
import * as React from 'react'
import { Input } from './input'
import { FieldLabel, FieldHint, FieldError } from './field'
import { cn } from '@/lib/utils'
/**
* DatePicker — MVP native HTML date input wrapper. Замена @nstart/ui DatePicker
* (тот же label/value/onChange API).
*
* <p>Сейчас простой &lt;input type="date"&gt; — браузерный picker (CSS hard
* styling limited, но functional). Phase 4 Stage 3.8b: заменить на
* react-day-picker если потребуется branded look или date range.
*
* <p>Format: ISO yyyy-mm-dd. onChange получает Date|null.
*/
export type DatePickerProps = {
label?: React.ReactNode
hint?: React.ReactNode
error?: React.ReactNode
required?: boolean
value?: Date | string | null
/** Receives Date or null on clear. */
onChange?: (date: Date | null) => void
/** Min / max bounds (ISO string yyyy-mm-dd or Date). */
min?: Date | string
max?: Date | string
id?: string
disabled?: boolean
/** Wrapper className. */
rootClassName?: string
inputClassName?: string
}
const toIso = (v: Date | string | null | undefined): string => {
if (!v) return ''
if (typeof v === 'string') return v.slice(0, 10)
if (v instanceof Date && !isNaN(v.getTime())) return v.toISOString().slice(0, 10)
return ''
}
export function DatePicker({
label,
hint,
error,
required,
value,
onChange,
min,
max,
id,
disabled,
rootClassName,
inputClassName,
}: DatePickerProps) {
const autoId = React.useId()
const inputId = id ?? autoId
const hintId = hint ? `${inputId}-hint` : undefined
const errorId = error ? `${inputId}-error` : undefined
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value
if (!raw) {
onChange?.(null)
return
}
const d = new Date(raw + 'T00:00:00')
onChange?.(isNaN(d.getTime()) ? null : d)
}
return (
<div className={cn('flex flex-col gap-1', rootClassName)}>
{label && (
<FieldLabel htmlFor={inputId} required={required}>
{label}
</FieldLabel>
)}
<Input
id={inputId}
type="date"
value={toIso(value)}
onChange={handleChange}
min={toIso(min)}
max={toIso(max)}
disabled={disabled}
required={required}
aria-invalid={error ? true : undefined}
aria-describedby={[hintId, errorId].filter(Boolean).join(' ') || undefined}
className={cn(error ? 'border-pink focus-visible:ring-pink/40' : '', inputClassName)}
/>
{hint && !error && <FieldHint id={hintId}>{hint}</FieldHint>}
{error && <FieldError id={errorId}>{error}</FieldError>}
</div>
)
}
@@ -0,0 +1,49 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
/**
* FieldLabel / FieldHint / FieldError — typography primitives для form fields.
* Замена @nstart/ui (без логики, чистые atomic spans/divs).
*
* <p>Per handoff: label = text-body (13/400), hint/error = text-cell (12.5).
*/
export type FieldLabelProps = React.LabelHTMLAttributes<HTMLLabelElement> & {
required?: boolean
}
export const FieldLabel = React.forwardRef<HTMLLabelElement, FieldLabelProps>(
function FieldLabel({ className, required, children, ...props }, ref) {
return (
<label
ref={ref}
className={cn(
'inline-flex items-baseline gap-0.5 text-body font-medium text-ink',
className,
)}
{...props}
>
{children}
{required && (
<span aria-hidden="true" className="text-pink">
*
</span>
)}
</label>
)
},
)
export function FieldHint({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn('text-cell text-mute', className)} {...props} />
}
export function FieldError({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p
role="alert"
className={cn('text-cell text-pink', className)}
{...props}
/>
)
}
@@ -5,13 +5,23 @@ import { cn } from '@/lib/utils'
* FormActions — фиксированная row кнопок [Cancel | Submit] в footer форм.
* Reverse на mobile — primary action на bottom (proper UX).
*/
export type FormActionsProps = React.HTMLAttributes<HTMLDivElement>
export type FormActionsProps = React.HTMLAttributes<HTMLDivElement> & {
/** Horizontal alignment. Default 'end' (right-justified). */
align?: 'start' | 'end' | 'between'
}
export function FormActions({ className, children, ...props }: FormActionsProps) {
const ALIGN_CLASS: Record<NonNullable<FormActionsProps['align']>, string> = {
start: 'sm:justify-start',
end: 'sm:justify-end',
between: 'sm:justify-between',
}
export function FormActions({ className, children, align = 'end', ...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',
'flex flex-col-reverse gap-2 sm:flex-row pt-3 border-t border-line-2',
ALIGN_CLASS[align],
className,
)}
{...props}
@@ -0,0 +1,70 @@
import { cn } from '@/lib/utils'
/**
* LanguageSwitch — segmented control для смены i18next language.
* Замена @nstart/ui LanguageSwitch (тот же value/options/onChange API).
*
* <p>Compact: 3-letter codes (RU/EN/ZH...) в Tektur uppercase. Tab-like
* underline indicator под active. На narrow screens сворачивается в icon
* только (TODO mobile responsive).
*/
/** nstart-compat: {id, label}. `value` accepted as alias. */
export type LanguageOption = {
id: string
label: string
/** Alias of `id`. */
value?: string
/** Short 2-3 char code (RU/EN/ZH). Default — id.toUpperCase(). */
short?: string
}
export type LanguageSwitchProps = {
value: string
options: LanguageOption[]
onChange: (id: string) => void
className?: string
'aria-label'?: string
}
export function LanguageSwitch({
value,
options,
onChange,
className,
'aria-label': ariaLabel,
}: LanguageSwitchProps) {
return (
<div
role="radiogroup"
aria-label={ariaLabel ?? 'Language'}
className={cn(
'inline-flex items-center rounded-md border border-line bg-surface p-0.5 gap-0.5',
className,
)}
>
{options.map((opt) => {
const v = opt.id ?? opt.value!
const active = v === value
return (
<button
key={v}
type="button"
role="radio"
aria-checked={active}
onClick={() => onChange(v)}
title={opt.label}
className={cn(
'px-2 py-1 rounded-sm text-cap leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active
? 'bg-accent-bg text-accent'
: 'text-mute hover:text-ink hover:bg-surface-2',
)}
>
{opt.short ?? v.toUpperCase()}
</button>
)
})}
</div>
)
}
+8 -2
View File
@@ -44,6 +44,10 @@ export type ModalProps = {
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
children?: React.ReactNode
hideClose?: boolean
/** Дополнительный className на DialogContent panel (nstart-compat). */
panelClassName?: string
/** Дополнительный className на body wrapper (nstart-compat). */
bodyClassName?: string
}
export function Modal({
@@ -54,6 +58,8 @@ export function Modal({
maxWidth,
size,
hideClose,
panelClassName,
bodyClassName,
children,
}: ModalProps) {
const resolved = size ?? (maxWidth ? SIZE_MAP[maxWidth] : undefined) ?? 'md'
@@ -64,14 +70,14 @@ export function Modal({
if (!open) onClose()
}}
>
<DialogContent size={resolved} hideClose={hideClose}>
<DialogContent size={resolved} hideClose={hideClose} className={panelClassName}>
{(title || description) && (
<DialogHeader>
{title && <DialogTitle>{title}</DialogTitle>}
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
)}
{children}
{bodyClassName ? <div className={bodyClassName}>{children}</div> : children}
</DialogContent>
</Dialog>
)
@@ -0,0 +1,165 @@
import * as React from 'react'
import { CheckIcon, CaretDownIcon } from '@phosphor-icons/react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { Popover, PopoverTrigger, PopoverContent } from './popover'
import { FieldLabel, FieldHint, FieldError } from './field'
import { Badge } from './badge'
import { cn } from '@/lib/utils'
/**
* MultiSelect — popover с inline checkboxes. Замена @nstart/ui MultiSelect.
*
* <p>Trigger показывает count selected + первые 2 selected labels.
* Popover — список с checkboxes. Click toggle. Поиск НЕ implemented (handoff
* MultiSelect не требует) — для search-heavy lists используй cmdk Combobox
* (Stage 3.8b TODO).
*
* <p>API: options [{value, label}], value string[], onChange (ids: string[]).
*/
/** nstart-compat: {id, label}. Use `id` key. */
export type MultiSelectOption = {
id: string
label: React.ReactNode
/** Alias of `id`. */
value?: string
}
export type MultiSelectProps = {
label?: React.ReactNode
hint?: React.ReactNode
error?: React.ReactNode
required?: boolean
options: MultiSelectOption[]
value: string[]
onChange: (next: string[]) => void
placeholder?: string
disabled?: boolean
/** Wrapper className. */
rootClassName?: string
/** Compact display (no badges, just count). */
compact?: boolean
}
export function MultiSelect({
label,
hint,
error,
required,
options,
value,
onChange,
placeholder,
disabled,
rootClassName,
compact,
}: MultiSelectProps) {
const autoId = React.useId()
const triggerId = autoId
const hintId = hint ? `${triggerId}-hint` : undefined
const errorId = error ? `${triggerId}-error` : undefined
const valueSet = React.useMemo(() => new Set(value), [value])
const toggle = (id: string) => {
const next = new Set(valueSet)
if (next.has(id)) next.delete(id)
else next.add(id)
onChange(Array.from(next))
}
const selectedLabels = options
.filter((o) => valueSet.has(o.id ?? o.value ?? ''))
.map((o) => o.label)
return (
<div className={cn('flex flex-col gap-1', rootClassName)}>
{label && (
<FieldLabel htmlFor={triggerId} required={required}>
{label}
</FieldLabel>
)}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
id={triggerId}
disabled={disabled}
aria-invalid={error ? true : undefined}
aria-describedby={[hintId, errorId].filter(Boolean).join(' ') || undefined}
className={cn(
'flex items-center justify-between gap-2 h-9 w-full rounded-md border border-line bg-surface px-3 text-body text-ink',
'hover:border-line-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'disabled:cursor-not-allowed disabled:opacity-50',
error && 'border-pink focus-visible:ring-pink/40',
)}
>
<span className="flex-1 min-w-0 flex items-center gap-1.5 overflow-hidden">
{selectedLabels.length === 0 ? (
<span className="text-mute truncate">{placeholder ?? '—'}</span>
) : compact ? (
<span className="text-mono">
{selectedLabels.length} / {options.length}
</span>
) : selectedLabels.length <= 2 ? (
selectedLabels.map((lbl, i) => (
<Badge key={i} variant="info">
{lbl}
</Badge>
))
) : (
<>
<Badge variant="info">{selectedLabels[0]}</Badge>
<Badge variant="info">+{selectedLabels.length - 1}</Badge>
</>
)}
</span>
<CaretDownIcon size={14} weight="bold" className="shrink-0 text-mute" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="p-1 min-w-[var(--radix-popover-trigger-width)]">
<PopoverPrimitive.Close className="hidden" />
<ul role="listbox" aria-multiselectable className="max-h-64 overflow-y-auto">
{options.map((opt) => {
const checked = valueSet.has((opt.id ?? opt.value ?? ''))
return (
<li
key={(opt.id ?? opt.value ?? '')}
role="option"
aria-selected={checked}
tabIndex={0}
onClick={() => toggle((opt.id ?? opt.value ?? ''))}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggle((opt.id ?? opt.value ?? ''))
}
}}
className={cn(
'flex items-center gap-2 px-2.5 py-1.5 rounded-sm cursor-pointer text-body',
'hover:bg-surface-2 focus:bg-surface-2 focus:outline-none',
checked && 'text-ink',
)}
>
<span
className={cn(
'inline-flex items-center justify-center size-4 rounded-sm border',
checked
? 'bg-accent border-accent text-on-accent'
: 'border-line-2 bg-surface',
)}
aria-hidden="true"
>
{checked && <CheckIcon size={10} weight="bold" />}
</span>
<span className="flex-1 truncate">{opt.label}</span>
</li>
)
})}
</ul>
</PopoverContent>
</Popover>
{hint && !error && <FieldHint id={hintId}>{hint}</FieldHint>}
{error && <FieldError id={errorId}>{error}</FieldError>}
</div>
)
}
@@ -0,0 +1,28 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
/**
* Panel — простой контейнер с border/radius/surface bg.
*
* Используется как разделитель секций на странице (Drafts/Reviews/Outbox listings,
* webhooks detail и т.п.). Замена @nstart/ui Panel.
*/
export type PanelProps = React.HTMLAttributes<HTMLDivElement> & {
/** Без internal padding — children сам решают (например table inside). */
flush?: boolean
}
export function Panel({ className, flush, children, ...props }: PanelProps) {
return (
<div
className={cn(
'rounded-lg border border-line bg-surface',
flush ? '' : 'p-4',
className,
)}
{...props}
>
{children}
</div>
)
}
@@ -0,0 +1,93 @@
import * as React from 'react'
import { FieldLabel, FieldHint, FieldError } from './field'
import { cn } from '@/lib/utils'
/**
* SingleSelect — native &lt;select&gt; wrapped в label/hint/error chrome.
* Замена @nstart/ui SingleSelect (тот же API: options, value, onChange).
*
* <p>Native select работает на mobile out-of-box, screen readers OK, по
* Enter/space открывается. Custom dropdown UI можно сделать через Radix
* Select (Stage 3.8b refinement) если потребуется branded look.
*/
/** nstart-compat shape: {id, label}. New code may prefer the more standard
* {value, label} — both keys accepted (id wins if both). */
export type SelectOption = {
id: string
label: React.ReactNode
/** Alias of `id` для совместимости с HTML option convention. */
value?: string
}
export type SingleSelectProps = {
label?: React.ReactNode
hint?: React.ReactNode
error?: React.ReactNode
required?: boolean
options: ReadonlyArray<SelectOption>
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
id?: string
rootClassName?: string
selectClassName?: string
}
export function SingleSelect({
label,
hint,
error,
required,
options,
value,
onChange,
placeholder,
disabled,
id,
rootClassName,
selectClassName,
}: SingleSelectProps) {
const autoId = React.useId()
const selectId = id ?? autoId
const hintId = hint ? `${selectId}-hint` : undefined
const errorId = error ? `${selectId}-error` : undefined
return (
<div className={cn('flex flex-col gap-1', rootClassName)}>
{label && (
<FieldLabel htmlFor={selectId} required={required}>
{label}
</FieldLabel>
)}
<select
id={selectId}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={required}
aria-invalid={error ? true : undefined}
aria-describedby={[hintId, errorId].filter(Boolean).join(' ') || undefined}
className={cn(
'flex h-9 w-full rounded-md border border-line bg-surface px-3 text-body text-ink',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'disabled:cursor-not-allowed disabled:opacity-50',
error && 'border-pink focus-visible:ring-pink/40',
selectClassName,
)}
>
{placeholder !== undefined && <option value="">{placeholder}</option>}
{options.map((opt) => {
const v = opt.id ?? opt.value
return (
<option key={v} value={v}>
{typeof opt.label === 'string' ? opt.label : v}
</option>
)
})}
</select>
{hint && !error && <FieldHint id={hintId}>{hint}</FieldHint>}
{error && <FieldError id={errorId}>{error}</FieldError>}
</div>
)
}
@@ -0,0 +1,156 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
/**
* Table primitives — shadcn-style atomic wrappers. Замена @nstart/ui Table*.
*
* <p>Per handoff: table cell text = text-cell (12.5/500). Применяется на
* &lt;td/th&gt; через CSS rule в `<tbody>` — но проще baked-in via TableCell
* className. Caller может override через className prop.
*
* <p>TableEmpty — single full-width row для empty state.
*/
export const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
function Table({ className, ...props }, ref) {
return (
<div className="w-full overflow-x-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-cell', className)}
{...props}
/>
</div>
)
},
)
export const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(function TableHeader({ className, ...props }, ref) {
return (
<thead
ref={ref}
className={cn('[&_tr]:border-b [&_tr]:border-line', className)}
{...props}
/>
)
})
export const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(function TableBody({ className, ...props }, ref) {
return (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
)
})
export const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(function TableFooter({ className, ...props }, ref) {
return (
<tfoot
ref={ref}
className={cn(
'border-t border-line bg-surface-2 font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
)
})
export const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
function TableRow({ className, ...props }, ref) {
return (
<tr
ref={ref}
className={cn(
'border-b border-line transition-colors hover:bg-surface-2/40 data-[state=selected]:bg-accent-bg',
className,
)}
{...props}
/>
)
},
)
export type TableHeaderCellProps = React.ThHTMLAttributes<HTMLTableCellElement> & {
align?: 'left' | 'center' | 'right'
}
export const TableHeaderCell = React.forwardRef<HTMLTableCellElement, TableHeaderCellProps>(
function TableHeaderCell({ className, align = 'left', ...props }, ref) {
return (
<th
ref={ref}
className={cn(
'h-9 px-3 align-middle text-cap text-mute',
align === 'right' && 'text-right',
align === 'center' && 'text-center',
align === 'left' && 'text-left',
'[&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
)
},
)
// Alias for shadcn convention.
export const TableHead = TableHeaderCell
export const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(function TableCell({ className, ...props }, ref) {
return (
<td
ref={ref}
className={cn('px-3 py-2 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
)
})
export const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(function TableCaption({ className, ...props }, ref) {
return (
<caption
ref={ref}
className={cn('mt-3 text-cell text-mute', className)}
{...props}
/>
)
})
/**
* TableEmpty — full-width row с заглушкой "ничего не найдено".
* Замена @nstart/ui TableEmptyRow API: <TableEmpty colSpan={N}>{text}</TableEmpty>.
*/
export type TableEmptyProps = React.TdHTMLAttributes<HTMLTableCellElement> & {
colSpan: number
}
export function TableEmpty({ colSpan, className, children, ...props }: TableEmptyProps) {
return (
<tr>
<td
colSpan={colSpan}
className={cn('py-10 text-center text-cell text-mute', className)}
{...props}
>
{children}
</td>
</tr>
)
}
@@ -24,8 +24,10 @@ export type TabItem = {
id: string
label: React.ReactNode
disabled?: boolean
/** Right-aligned content в tab (badge, indicator). */
/** Right-aligned content в tab (badge, indicator). Alias `trailing`. */
trailing?: React.ReactNode
/** nstart-compat alias для `trailing`. Если оба заданы — count wins. */
count?: React.ReactNode
}
export type TabsSimpleProps = {
@@ -43,7 +45,7 @@ export function TabsSimple({ items, value, onValueChange, className }: TabsSimpl
<RadixTabsTrigger key={item.id} value={item.id} disabled={item.disabled}>
<span className="inline-flex items-center gap-1.5">
{item.label}
{item.trailing}
{item.count ?? item.trailing}
</span>
</RadixTabsTrigger>
))}
@@ -0,0 +1,52 @@
import * as React from 'react'
import { Input } from './input'
import { FieldLabel, FieldHint, FieldError } from './field'
import { cn } from '@/lib/utils'
/**
* TextInput — composite field: label + input + hint/error.
*
* Замена @nstart/ui TextInput (тот же API): label, hint, error, required.
* Внутри использует наш shadcn Input primitive.
*/
export type TextInputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> & {
label?: React.ReactNode
hint?: React.ReactNode
error?: React.ReactNode
required?: boolean
/** Wrapper className (label + input + hint stack). */
rootClassName?: string
}
export const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
function TextInput(
{ label, hint, error, required, rootClassName, className, id, ...props },
ref,
) {
const autoId = React.useId()
const inputId = id ?? autoId
const hintId = hint ? `${inputId}-hint` : undefined
const errorId = error ? `${inputId}-error` : undefined
return (
<div className={cn('flex flex-col gap-1', rootClassName)}>
{label && (
<FieldLabel htmlFor={inputId} required={required}>
{label}
</FieldLabel>
)}
<Input
ref={ref}
id={inputId}
aria-invalid={error ? true : undefined}
aria-describedby={[hintId, errorId].filter(Boolean).join(' ') || undefined}
className={cn(error ? 'border-pink focus-visible:ring-pink/40' : '', className)}
required={required}
{...props}
/>
{hint && !error && <FieldHint id={hintId}>{hint}</FieldHint>}
{error && <FieldError id={errorId}>{error}</FieldError>}
</div>
)
},
)
+93 -58
View File
@@ -1,49 +1,38 @@
/**
* Barrel re-export для UI primitives.
* @/ui — single source of truth для UI primitives.
*
* <p>Стратегия миграции (Phase 2 stack migration):
* <ul>
* <li>{@code @nstart/ui} остаётся подключённым — все его компоненты
* pass-through через этот barrel, чтобы существующий код работал без
* рефактора props (Checkbox.label/description, Modal.panelClassName,
* FormActions.align, и т.п.).</li>
* <li>shadcn-style primitives в {@code ./components/*} добавлены как
* параллельный набор. Новый код использует их через alias'ы где они
* чище nstart'а ({@code Button}, {@code Badge}, {@code Alert} с
* Tailwind v4 tokens; {@code Cap}, {@code ScopeBadge}, {@code ScopeDot},
* {@code ScopeStrip} — handoff design system primitives).</li>
* <li>Phase 3: компоненты которые имеют bug'и или missing features в nstart
* (Combobox vs MultiSelect, TanStack Table vs Table) — заменяются
* per-file rewrite.</li>
* <li>Phase 4: @nstart/ui удаляется из package.json когда все callsites
* переписаны.</li>
* </ul>
* <p>Stage 3.9: @nstart/ui удалён. Все экспорты резолвят к локальным
* shadcn primitives в `./components/`. Имена сохранены в nstart-compat форме
* чтобы существующий код работал без массового рефактора imports.
*
* <p>Текущая семантика: import {X} from '@/ui' — Х берётся приоритетно из
* наших shadcn primitives (overrides ниже), иначе passthrough из @nstart/ui.
* Этот файл — single source of truth для UI imports.
* <p>Группы:
* <ol>
* <li><b>Handoff typography utility</b> — Cap, ScopeDot/Badge/Strip.</li>
* <li><b>Atomic</b> — Button, Badge, Alert, IconButton, Input, Label,
* Separator, Skeleton, Switch.</li>
* <li><b>Form fields</b> — TextInput, TextArea, Checkbox, FieldLabel/Hint/
* Error, FormActions, DatePicker, MultiSelect, SearchInput.</li>
* <li><b>Layout</b> — Card*, Panel, PageHeader, EmptyState, LoadingBlock,
* Tabs (legacy items-API + Radix root primitives).</li>
* <li><b>Overlays</b> — Modal (Dialog adapter), Drawer (Sheet adapter), Sheet*,
* Dialog*, Popover*, DropdownMenu*, Tooltip*.</li>
* <li><b>Switches</b> — LanguageSwitch.</li>
* <li><b>Toast</b> — Toaster, toast (sonner).</li>
* </ol>
*/
// === Полная re-export из @nstart/ui — все компоненты, hooks, locale const ===
// Дальше будут selective overrides где наши shadcn primitives лучше.
export * from '@nstart/ui'
// === Overrides: наши shadcn primitives с Tailwind v4 tokens ===
// Эти именованные re-export'ы перекрывают одноимённые из @nstart/ui выше
// (TS resolves last declaration → наши берут верх). Mostly cosmetic — общий
// API design сохранён, добавлены теневые props (loading, error-variant и т.д.).
// === Atomic ===
export { Button, buttonVariants, type ButtonProps } from './components/button'
export { Badge, type BadgeProps } from './components/badge'
export { Alert, type AlertProps } from './components/alert'
// TextArea override — shadcn с handoff tokens. Phase 3 migration step
// (Stage 3.8a). API совместим с @nstart/ui TextArea (label/hint/error).
export { TextArea, type TextAreaProps } from './components/textarea'
// PageHeader override — @nstart/ui рендерил h1 в Tektur 36px/700 (text-4xl
// uppercase tracking-tight font-primary), что не соответствует handoff
// page title 17/600 Inter. Наша версия использует text-title-lg utility.
export { PageHeader } from './components/page-header'
export { IconButton, type IconButtonProps } from './components/icon-button'
export { Input, type InputProps } from './components/input'
export { Label } from './components/label'
export { Separator } from './components/separator'
export { Skeleton } from './components/skeleton'
export { Switch } from './components/switch'
// === Handoff design system primitives — новые компоненты без nstart-аналога ===
// === Handoff design primitives ===
export { Cap, type CapProps } from './components/cap'
export {
ScopeDot,
@@ -52,23 +41,72 @@ export {
type DataScope as ScopeKind,
} from './components/scope'
// === shadcn primitives для НОВОГО кода (Phase 3) ===
// Эти именованные exports НЕ конфликтуют с nstart (имена не пересекаются).
// Используются в редизайне catalog/editor/drawer per handoff README.
export { IconButton as ShadcnIconButton, type IconButtonProps } from './components/icon-button'
export { Input as ShadcnInput, type InputProps as ShadcnInputProps } from './components/input'
export { Label as ShadcnLabel } from './components/label'
export { SearchInput as ShadcnSearchInput, type SearchInputProps as ShadcnSearchInputProps } from './components/search-input'
// === Form fields ===
export { TextInput, type TextInputProps } from './components/text-input'
export { TextArea, type TextAreaProps } from './components/textarea'
export { Checkbox } from './components/checkbox'
export {
Card as ShadcnCard,
FieldLabel,
FieldHint,
FieldError,
type FieldLabelProps,
} from './components/field'
export { FormActions, type FormActionsProps } from './components/form-actions'
export { DatePicker, type DatePickerProps } from './components/date-picker'
export {
MultiSelect,
type MultiSelectOption,
type MultiSelectProps,
} from './components/multi-select'
export {
SingleSelect,
type SelectOption,
type SingleSelectProps,
} from './components/single-select'
export { SearchInput, type SearchInputProps } from './components/search-input'
// === Layout ===
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from './components/card'
export { Separator } from './components/separator'
export { Skeleton } from './components/skeleton'
export { Panel, type PanelProps } from './components/panel'
export { PageHeader, type PageHeaderProps } from './components/page-header'
export { EmptyState, type EmptyStateProps } from './components/empty-state'
export { LoadingBlock, type LoadingBlockProps } from './components/loading-block'
// === Tables ===
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableRow,
TableHead,
TableHeaderCell,
TableCell,
TableCaption,
TableEmpty,
type TableHeaderCellProps,
type TableEmptyProps,
} from './components/table'
// === Tabs (legacy items[] API + Radix primitives для нового кода) ===
export { TabsSimple as Tabs, type TabItem, type TabsSimpleProps } from './components/tabs-simple'
export {
Tabs as TabsRoot,
TabsList,
TabsTrigger,
TabsContent,
} from './components/tabs'
// === Overlays ===
export { Modal, type ModalProps } from './components/modal'
export { Drawer, type DrawerProps } from './components/drawer'
export {
Dialog,
DialogTrigger,
@@ -106,13 +144,6 @@ export {
TooltipTrigger,
TooltipContent,
} from './components/tooltip'
// Tabs из shadcn — composition API.
export {
Tabs as ShadcnTabsRoot,
TabsList as ShadcnTabsList,
TabsTrigger as ShadcnTabsTrigger,
TabsContent as ShadcnTabsContent,
} from './components/tabs'
export {
DropdownMenu,
DropdownMenuTrigger,
@@ -129,9 +160,13 @@ export {
DropdownMenuSubContent,
DropdownMenuRadioGroup,
} from './components/dropdown-menu'
export { Switch as ShadcnSwitch } from './components/switch'
// Toast notifications через sonner (rich, accessible, themable).
// Mount <Toaster /> один раз в main.tsx; call toast.success/error/etc.
// в callbacks / effects.
// === Switches ===
export {
LanguageSwitch,
type LanguageOption,
type LanguageSwitchProps,
} from './components/language-switch'
// === Toast (sonner) ===
export { Toaster, toast } from './components/toaster'