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
-1
View File
@@ -12,7 +12,6 @@
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@nstart/ui": "^0.1.3",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
-60
View File
@@ -11,9 +11,6 @@ importers:
'@hookform/resolvers':
specifier: ^3.9.1
version: 3.10.0(react-hook-form@7.75.0(react@19.2.5))
'@nstart/ui':
specifier: ^0.1.3
version: 0.1.3(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react-i18next@17.0.6(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3))(react@19.2.5)
'@phosphor-icons/react':
specifier: ^2.1.10
version: 2.1.10(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -698,15 +695,6 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@nstart/ui@0.1.3':
resolution: {integrity: sha512-dLhM2Uzvok1UB+86c+WIYz/1uYr/wN3Ch1Ab3ioZbzxBEt0bZQs+QYkj3GXAEzffCjV4zTHen31yJUHzJmpyTQ==}
engines: {node: ^24.0.0 || ^22.0.0}
peerDependencies:
i18next: ^26.0.3
react: ^19.0.0
react-dom: ^19.0.0
react-i18next: ^17.0.2
'@octokit/auth-token@6.0.0':
resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==}
engines: {node: '>= 20'}
@@ -2055,9 +2043,6 @@ packages:
duplexer2@0.1.4:
resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==}
echarts@6.0.0:
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
electron-to-chromium@1.5.349:
resolution: {integrity: sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==}
@@ -2321,9 +2306,6 @@ packages:
highlight.js@10.7.3:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
hls.js@1.6.16:
resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==}
hook-std@4.0.0:
resolution: {integrity: sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ==}
engines: {node: '>=20'}
@@ -2647,15 +2629,6 @@ packages:
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
lottie-react@2.4.1:
resolution: {integrity: sha512-LQrH7jlkigIIv++wIyrOYFLHSKQpEY4zehPicL9bQsrt1rnoKRYCYgpCUe5maqylNtacy58/sQDZTkwMcTRxZw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
lottie-web@5.13.0:
resolution: {integrity: sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==}
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@@ -3742,9 +3715,6 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zrender@6.0.0:
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
snapshots:
'@adobe/css-tools@4.4.4': {}
@@ -4127,17 +4097,6 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@nstart/ui@0.1.3(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react-i18next@17.0.6(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3))(react@19.2.5)':
dependencies:
'@phosphor-icons/react': 2.1.10(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
echarts: 6.0.0
hls.js: 1.6.16
i18next: 26.0.8(typescript@5.9.3)
lottie-react: 2.4.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-i18next: 17.0.6(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)
'@octokit/auth-token@6.0.0': {}
'@octokit/core@7.0.6':
@@ -5481,11 +5440,6 @@ snapshots:
dependencies:
readable-stream: 2.3.8
echarts@6.0.0:
dependencies:
tslib: 2.3.0
zrender: 6.0.0
electron-to-chromium@1.5.349: {}
emoji-regex@8.0.0: {}
@@ -5808,8 +5762,6 @@ snapshots:
highlight.js@10.7.3: {}
hls.js@1.6.16: {}
hook-std@4.0.0: {}
hosted-git-info@7.0.2:
@@ -6076,14 +6028,6 @@ snapshots:
lodash@4.18.1: {}
lottie-react@2.4.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
lottie-web: 5.13.0
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
lottie-web@5.13.0: {}
loupe@3.2.1: {}
lowercase-keys@3.0.0: {}
@@ -6976,7 +6920,3 @@ snapshots:
yoctocolors@2.1.2: {}
zod@3.25.76: {}
zrender@6.0.0:
dependencies:
tslib: 2.3.0
@@ -66,8 +66,8 @@ describe('SchemaDrivenForm', () => {
onCancel={() => {}}
/>,
)
// @nstart/ui Tabs использует role="button" не "tab".
await user.click(screen.getByRole('button', { name: /Дополнительно/i }))
// Radix Tabs (Stage 3.9) использует role="tab" — semantically correct.
await user.click(screen.getByRole('tab', { name: /Дополнительно/i }))
// description (optional) теперь виден. i18n field.description=Описание.
expect(screen.getByText('Описание')).toBeInTheDocument()
})
@@ -775,7 +775,7 @@ const ReferenceSelectField = ({
return records
.map((r) => buildOption(r, refField, defaultLocale))
.filter((o): o is SelectOption => o !== null)
.sort((a, b) => a.label.localeCompare(b.label, 'ru'))
.sort((a, b) => String(a.label ?? '').localeCompare(String(b.label ?? ''), 'ru'))
}, [records, refField, defaultLocale])
const hint = schema.description ?? `FK на ${ref}`
@@ -832,7 +832,7 @@ const ReferenceSelectField = ({
'w-full appearance-none border rounded-sm pl-3 pr-10 py-2 text-body font-display',
'bg-white text-black transition-colors',
'focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent',
errorMsg ? 'border-mars' : 'border-line hover:border-carbon/40',
errorMsg ? 'border-mars' : 'border-line hover:border-ink/40',
isLoading ? 'cursor-wait text-mute' : 'cursor-pointer',
].join(' ')}
>
@@ -166,7 +166,7 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
'px-3 py-1.5 text-cell font-display rounded-sm border transition-colors',
active
? 'border-accent bg-accent text-white'
: 'border-line bg-white text-ink hover:border-carbon/40',
: 'border-line bg-white text-ink hover:border-ink/40',
].join(' ')}
>
{label}
@@ -192,7 +192,7 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
type="button"
onClick={handleClear}
disabled={!shape}
className="px-3 py-1.5 text-cell font-display rounded-sm border border-line bg-white text-ink hover:border-carbon/40 disabled:opacity-40 disabled:cursor-not-allowed"
className="px-3 py-1.5 text-cell font-display rounded-sm border border-line bg-white text-ink hover:border-ink/40 disabled:opacity-40 disabled:cursor-not-allowed"
>
{t('aoi.dialog.clear')}
</button>
@@ -61,7 +61,7 @@ const NeighborCard = ({
<div className={`flex items-baseline gap-2 ${side === 'left' ? 'justify-end' : ''}`}>
<span
className={`inline-block size-1.5 rounded-full shrink-0 ${
SCOPE_STRIP[dict.scope] ?? 'bg-carbon/40'
SCOPE_STRIP[dict.scope] ?? 'bg-ink/40'
}`}
aria-hidden="true"
/>
@@ -67,9 +67,9 @@ export const RecordHistoryDrawer = ({ open, onClose, dictionaryName, businessKey
isLatest && status === 'active'
? 'bg-accent border-accent'
: status === 'future'
? 'bg-orbit border-horizon'
? 'bg-warn border-horizon'
: status === 'expired'
? 'bg-white border-carbon/40'
? 'bg-white border-ink/40'
: 'bg-aurora border-aurora'
}`}
/>
@@ -306,7 +306,7 @@ function TimelineTrack({
{tooltipText && hover !== null && (
<div
className="text-mono absolute -top-2 -translate-y-full px-2 py-0.5 rounded bg-carbon text-white pointer-events-none whitespace-nowrap"
className="text-mono absolute -top-2 -translate-y-full px-2 py-0.5 rounded bg-ink text-white pointer-events-none whitespace-nowrap"
style={{ left: timeToX(hover), transform: 'translate(-50%, -100%)' }}
>
{tooltipText}
+5 -7
View File
@@ -2,7 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { NStartUiProvider, DatePickerProvider, DATE_PICKER_LOCALE_RU, Toaster } from '@/ui'
import { Toaster, TooltipProvider } from '@/ui'
import { AuthProvider } from 'react-oidc-context'
import { routeTree } from './routeTree.gen'
import { oidcAuthConfig } from '@/auth/oidcConfig'
@@ -31,16 +31,14 @@ createRoot(document.getElementById('root')!).render(
<AuthProvider {...oidcAuthConfig}>
<TokenSync />
<ThemeProvider>
<NStartUiProvider>
<TooltipProvider delayDuration={150}>
<RequireAuth>
<QueryClientProvider client={queryClient}>
<DatePickerProvider datePicker={DATE_PICKER_LOCALE_RU}>
<RouterProvider router={router} />
<Toaster />
</DatePickerProvider>
<RouterProvider router={router} />
<Toaster />
</QueryClientProvider>
</RequireAuth>
</NStartUiProvider>
</TooltipProvider>
</ThemeProvider>
</AuthProvider>
</StrictMode>,
+1 -1
View File
@@ -309,7 +309,7 @@ function ActiveFilterChips({ filters, onRemove }: ActiveFilterChipsProps) {
onClick={() => onRemove(f.key)}
className={[
'inline-flex items-center gap-1.5 px-2 py-1 rounded-sm border text-cell',
'bg-white border-line hover:border-carbon/40 hover:bg-line/30',
'bg-white border-line hover:border-ink/40 hover:bg-line/30',
'transition-colors group',
].join(' ')}
title={t('audit.filter.removeChip')}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import axios from 'axios'
@@ -322,18 +322,12 @@ function DictionaryDetail() {
const someVisibleSelected =
!allVisibleSelected && visibleKeys.some((k) => selection.has(k))
// Native checkbox indeterminate state — sync через useEffect, не callback ref.
// Callback ref срабатывает только на mount/unmount → state становится stale
// когда user toggle'ает selection per-row. useEffect re-runs на изменение
// someVisibleSelected. См. design handoff review #17 + Checkbox в @nstart/ui:
// ref forwarded к нативному <input>, но `indeterminate` это JS DOM-only prop
// (не HTML attribute), нужен manual sync.
const selectAllRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (selectAllRef.current) {
selectAllRef.current.indeterminate = someVisibleSelected
}
}, [someVisibleSelected])
// Stage 3.9: Radix Checkbox принимает {@code checked="indeterminate"} как
// tri-state значение declaratively. Старый useEffect+ref hack (для нативного
// input.indeterminate JS-only prop) больше не нужен.
const selectAllChecked: boolean | 'indeterminate' = someVisibleSelected
? 'indeterminate'
: allVisibleSelected
const toggleSelect = (key: string) => {
setSelection((prev) => {
@@ -723,7 +717,7 @@ function DictionaryDetail() {
'text-cap inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm border transition-colors',
active
? 'border-accent bg-accent/8 text-accent'
: 'border-line bg-white text-ink-2 hover:border-carbon/40',
: 'border-line bg-white text-ink-2 hover:border-ink/40',
].join(' ')}
>
<span
@@ -773,8 +767,7 @@ function DictionaryDetail() {
<TableHeaderCell>
<Checkbox
aria-label={t('dict.bulk.selectAll')}
checked={allVisibleSelected}
ref={selectAllRef}
checked={selectAllChecked}
onChange={toggleSelectAll}
/>
</TableHeaderCell>
@@ -834,7 +827,7 @@ function DictionaryDetail() {
'inline-block px-1.5 py-0.5 rounded-sm font-mono text-cell',
c.isFk
? 'bg-accent/8 text-accent'
: 'bg-carbon/8 text-ink',
: 'bg-ink/8 text-ink',
].join(' ')}
>
{text}
+2 -2
View File
@@ -235,7 +235,7 @@ function WebhookDetailPage() {
'text-cap inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm border transition-colors',
active
? 'border-accent bg-accent/8 text-accent'
: 'border-line bg-white text-ink-2 hover:border-carbon/40',
: 'border-line bg-white text-ink-2 hover:border-ink/40',
].join(' ')}
>
{s}
@@ -452,7 +452,7 @@ function RotateRevealModal({
<Alert variant="warning" title={t('webhooks.secret.warningTitle')}>
{t('webhooks.secret.rotatedWarning')}
</Alert>
<div className="bg-carbon/5 border border-line rounded-md p-3">
<div className="bg-ink/5 border border-line rounded-md p-3">
<code className="text-mono break-all">{secret}</code>
</div>
<div className="flex justify-end">
@@ -367,7 +367,7 @@ function SecretRevealModal({
<Alert variant="warning" title={t('webhooks.secret.warningTitle')}>
{t('webhooks.secret.warning')}
</Alert>
<div className="bg-carbon/5 border border-line rounded-md p-3">
<div className="bg-ink/5 border border-line rounded-md p-3">
<code className="text-mono break-all">{revealed.secret}</code>
</div>
<div className="flex justify-end gap-2">
+7 -22
View File
@@ -1,14 +1,9 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import '@nstart/ui/styles/fonts.css';
@import '@nstart/ui/styles/theme.css';
/* Без @source Tailwind v4 не сгенерирует CSS для классов внутри dist/@nstart/ui */
@source '../node_modules/@nstart/ui/dist';
/* Google Fonts Inter / JetBrains Mono / Tektur (handoff spec).
* @nstart fonts.css дублируется по части семейств overlap безвреден,
* на финальной фазе миграции (когда удалим @nstart/ui) останется только это. */
* Stage 3.9: @nstart/ui удалён, fonts/theme imports + @source directive
* убраны. Tailwind v4 теперь scan'ит только наш src/. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Tektur:wght@500;600;700&display=swap');
/* === Dark mode trigger ===
@@ -146,21 +141,11 @@
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
--font-display: 'Tektur', 'Inter', sans-serif;
/* Font scale.
*
* Наши собственные классы семантические @utility ниже (text-body,
* text-cell, text-mono, text-title-*, text-cap). Каждая роль = место в UI.
*
* --text-sm: 13px нужно потому что @nstart/ui dist внутри использует
* text-sm для buttons/inputs/forms (см. @source выше). Без этого override
* библиотечные компоненты ушли бы к Tailwind default 14px и разошлись с
* нашим handoff workhorse 13px. После Stage 3.9 (pnpm remove @nstart/ui)
* этот override можно убрать наш код text-sm не использует.
*
* --text-2xs override НЕ нужен: убрали все usage в audit, text-2xs больше
* нигде не появляется. */
--text-sm: 13px;
--text-sm--line-height: 1.45;
/* Font scale НЕ переопределяем Tailwind defaults. Все наши места
* используют семантические @utility ниже (text-body, text-cell, text-mono,
* text-title-*, text-cap). text-sm/text-xs/text-2xs не используются нигде
* после Stage 3.9 @nstart/ui (который требовал --text-sm override)
* удалён. */
--radius-sm: 4px;
--radius-md: 6px;
@@ -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'